mirror of
https://github.com/JOYCEQL/magic-resume.git
synced 2026-07-03 14:07:11 +02:00
feat: editor add avatar
This commit is contained in:
@@ -46,9 +46,11 @@
|
||||
"react-resizable-panels": "^2.0.20",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^1.1.1",
|
||||
"zustand": "^4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
import React, { useState, useRef, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerFooter,
|
||||
DrawerClose
|
||||
} from "@/components/ui/drawer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Upload, X, Link as LinkIcon } from "lucide-react";
|
||||
import {
|
||||
PhotoConfig,
|
||||
DEFAULT_CONFIG,
|
||||
getRatioMultiplier,
|
||||
getBorderRadiusValue
|
||||
} from "@/types/resume";
|
||||
import { motion } from "framer-motion";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
photo?: string;
|
||||
config?: PhotoConfig;
|
||||
onPhotoChange: (photo: string | undefined, config?: PhotoConfig) => void;
|
||||
onConfigChange: (config: PhotoConfig) => void;
|
||||
theme: "dark" | "light";
|
||||
}
|
||||
|
||||
const PhotoConfigDrawer: React.FC<Props> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
photo,
|
||||
config: initialConfig,
|
||||
onPhotoChange,
|
||||
onConfigChange,
|
||||
theme
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | undefined>(photo);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [imageUrl, setImageUrl] = useState(photo || "");
|
||||
const drawerContentRef = useRef<HTMLInputElement>(null);
|
||||
const [config, setConfig] = useState<PhotoConfig>(
|
||||
initialConfig || DEFAULT_CONFIG
|
||||
);
|
||||
const isMobile = useMemo(() => {
|
||||
return window.innerWidth <= 768;
|
||||
}, [window.innerWidth]);
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setConfig(initialConfig || DEFAULT_CONFIG);
|
||||
setPreviewUrl(photo);
|
||||
setImageUrl(photo || "");
|
||||
}
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (!drawerContentRef.current?.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClick);
|
||||
};
|
||||
}, [isOpen, initialConfig, photo]);
|
||||
|
||||
const handleFile = (file: File) => {
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert("图片大小不能超过5MB");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
alert("请上传图片文件");
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
setPreviewUrl(result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
handleFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlChange = (e: string) => {
|
||||
const url = e;
|
||||
setImageUrl(url);
|
||||
setPreviewUrl(url);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) {
|
||||
handleFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemovePhoto = () => {
|
||||
setPreviewUrl(undefined);
|
||||
setImageUrl("");
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfigChange = (updates: Partial<PhotoConfig>) => {
|
||||
const newConfig = { ...config, ...updates };
|
||||
|
||||
if (config.aspectRatio !== "custom") {
|
||||
if ("width" in updates) {
|
||||
const ratio = getRatioMultiplier(config.aspectRatio);
|
||||
newConfig.height =
|
||||
Math.round(updates.width! * ratio) > 200
|
||||
? 200
|
||||
: Math.round(updates.width! * ratio);
|
||||
}
|
||||
if ("height" in updates) {
|
||||
const ratio = 1 / getRatioMultiplier(config.aspectRatio);
|
||||
newConfig.width =
|
||||
Math.round(updates.height! * ratio) > 200
|
||||
? 200
|
||||
: Math.round(updates.height! * ratio);
|
||||
}
|
||||
}
|
||||
|
||||
setConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
};
|
||||
|
||||
const handleInputChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
key: "width" | "height" | "customBorderRadius"
|
||||
) => {
|
||||
const value = Number(e.target.value) > 200 ? 200 : e.target.value;
|
||||
|
||||
if (value === "") {
|
||||
setConfig((prev) => ({ ...prev, [key]: "" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const numValue = Number(value);
|
||||
if (!isNaN(numValue)) {
|
||||
setConfig((prev) => ({ ...prev, [key]: numValue }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputBlur = (
|
||||
e: React.FocusEvent<HTMLInputElement>,
|
||||
key: "width" | "height" | "customBorderRadius"
|
||||
) => {
|
||||
const value = e.target.value;
|
||||
const numValue = value === "" ? 0 : Number(value);
|
||||
|
||||
if (key === "customBorderRadius") {
|
||||
const maxRadius = Math.min(config.width, config.height) / 2;
|
||||
const validValue = Math.max(0, Math.min(numValue, maxRadius));
|
||||
handleConfigChange({ customBorderRadius: validValue });
|
||||
} else {
|
||||
const validValue = Math.max(24, Math.min(numValue, 200));
|
||||
handleConfigChange({ [key]: validValue });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onPhotoChange(previewUrl, config);
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<Drawer
|
||||
direction={isMobile ? "bottom" : "left"}
|
||||
modal={false}
|
||||
open={isOpen}
|
||||
dismissible={false}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
>
|
||||
<DrawerContent
|
||||
ref={drawerContentRef}
|
||||
className={cn(
|
||||
theme === "dark" ? "bg-neutral-900 text-white" : "bg-white",
|
||||
"md:fixed md:border-none md:flex md:bottom-0 md:left-0 md:right-0 md:h-[93%] md:max-w-[360px] md:mx-[-1px] md:z-10 md:outline-none shadow shadow-blue-500/40"
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-md overflow-y-auto">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle className="text-center">头像配置</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden border-2 transition-all mx-auto",
|
||||
isDragging ? "border-blue-500 border-solid" : "border-dashed",
|
||||
theme === "dark"
|
||||
? "border-neutral-700 hover:border-neutral-600"
|
||||
: "border-neutral-300 hover:border-neutral-400"
|
||||
)}
|
||||
style={{
|
||||
width: `${config.width}px`,
|
||||
height: `${config.height}px`,
|
||||
borderRadius: getBorderRadiusValue(config),
|
||||
maxWidth: "100%"
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{previewUrl ? (
|
||||
<div className="relative h-full group">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Profile"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 opacity-0 transition-opacity",
|
||||
"group-hover:opacity-100"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
onClick={handleRemovePhoto}
|
||||
className="p-1.5 rounded-full bg-white/10 hover:bg-white/20"
|
||||
>
|
||||
<X className="w-4 h-4 text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => inputRef.current?.click()}
|
||||
variant="ghost"
|
||||
className="w-full h-full flex flex-col items-center justify-center p-0"
|
||||
>
|
||||
<Upload
|
||||
className={cn(
|
||||
"w-6 h-6 mb-2",
|
||||
theme === "dark" ? "text-neutral-400" : "text-neutral-500"
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm">点击或拖拽上传照片</span>
|
||||
<span className="text-xs text-neutral-500 mt-1">
|
||||
支持 jpg、png 格式,≤5MB
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<motion.input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">在线链接</h3>
|
||||
<Textarea
|
||||
value={imageUrl}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
placeholder="请输入图片链接"
|
||||
className={cn(
|
||||
"h-9",
|
||||
theme === "dark" && "bg-neutral-800 border-neutral-700"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">尺寸设置</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={config.width}
|
||||
onChange={(e) => handleInputChange(e, "width")}
|
||||
onBlur={(e) => handleInputBlur(e, "width")}
|
||||
className={cn(
|
||||
"h-9 pr-7",
|
||||
theme === "dark" && "bg-neutral-800 border-neutral-700"
|
||||
)}
|
||||
min={24}
|
||||
max={200}
|
||||
placeholder="宽度"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-3 top-1/2 -translate-y-1/2 text-sm",
|
||||
theme === "dark"
|
||||
? "text-neutral-400"
|
||||
: "text-neutral-500"
|
||||
)}
|
||||
>
|
||||
W
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={config.height}
|
||||
onChange={(e) => handleInputChange(e, "height")}
|
||||
onBlur={(e) => handleInputBlur(e, "height")}
|
||||
className={cn(
|
||||
"h-9 pr-7",
|
||||
theme === "dark" && "bg-neutral-800 border-neutral-700"
|
||||
)}
|
||||
min={24}
|
||||
max={200}
|
||||
placeholder="高度"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-3 top-1/2 -translate-y-1/2 text-sm",
|
||||
theme === "dark"
|
||||
? "text-neutral-400"
|
||||
: "text-neutral-500"
|
||||
)}
|
||||
>
|
||||
H
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">纵横比</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["1:1", "4:3", "3:4", "16:9", "custom"] as const).map(
|
||||
(ratio) => (
|
||||
<Button
|
||||
key={ratio}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-9",
|
||||
config.aspectRatio === ratio
|
||||
? theme === "dark"
|
||||
? "bg-indigo-600 text-white"
|
||||
: "bg-neutral-900 text-white"
|
||||
: theme === "dark"
|
||||
? "bg-[#262626] text-white border-none"
|
||||
: "bg-transparent text-black border-neutral-200"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (ratio !== "custom") {
|
||||
const height = Math.round(
|
||||
config.width * getRatioMultiplier(ratio)
|
||||
);
|
||||
handleConfigChange({ aspectRatio: ratio, height });
|
||||
} else {
|
||||
handleConfigChange({ aspectRatio: ratio });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{ratio === "custom" ? "自定义" : ratio}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">圆角</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["none", "medium", "full", "custom"] as const).map(
|
||||
(radius) => (
|
||||
<Button
|
||||
key={radius}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-9",
|
||||
config.borderRadius === radius
|
||||
? theme === "dark"
|
||||
? "bg-indigo-600 text-white"
|
||||
: "bg-neutral-900 text-white"
|
||||
: theme === "dark"
|
||||
? "bg-[#262626] text-white"
|
||||
: "bg-transparent text-black border-2 border-neutral-200"
|
||||
)}
|
||||
onClick={() =>
|
||||
handleConfigChange({ borderRadius: radius })
|
||||
}
|
||||
>
|
||||
{radius === "none"
|
||||
? "无"
|
||||
: radius === "medium"
|
||||
? "中等"
|
||||
: radius === "full"
|
||||
? "圆形"
|
||||
: "自定义"}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{config.borderRadius === "custom" && (
|
||||
<Input
|
||||
type="number"
|
||||
value={config.customBorderRadius}
|
||||
onChange={(e) => handleInputChange(e, "customBorderRadius")}
|
||||
onBlur={(e) => handleInputBlur(e, "customBorderRadius")}
|
||||
className={cn(
|
||||
"h-9 mt-2",
|
||||
theme === "dark" && "bg-neutral-800 border-neutral-700"
|
||||
)}
|
||||
min={0}
|
||||
max={Math.min(config.width, config.height) / 2}
|
||||
placeholder="自定义圆角大小"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DrawerFooter>
|
||||
<div className="flex gap-2">
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleSave}
|
||||
variant="destructive"
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</div>
|
||||
</DrawerFooter>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhotoConfigDrawer;
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useState } from "react";
|
||||
import { Settings2, Image } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import PhotoConfigDrawer from "./PhotoConfigDrawer";
|
||||
import { useResumeStore } from "@/store/useResumeStore";
|
||||
import { PhotoConfig } from "@/types/resume";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
theme: "dark" | "light";
|
||||
}
|
||||
|
||||
const PhotoSelector: React.FC<Props> = ({ className, theme }) => {
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const { basic, updateBasicInfo } = useResumeStore();
|
||||
|
||||
const handlePhotoChange = (
|
||||
photo: string | undefined,
|
||||
config?: PhotoConfig
|
||||
) => {
|
||||
updateBasicInfo({
|
||||
...basic,
|
||||
photo,
|
||||
photoConfig: config
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfigChange = (config: PhotoConfig) => {
|
||||
updateBasicInfo({
|
||||
...basic,
|
||||
photoConfig: config
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">头像</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
onClick={() => setShowConfig(true)}
|
||||
>
|
||||
<Settings2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{basic.photo && (
|
||||
<div className="mt-2 relative overflow-hidden">
|
||||
<img
|
||||
src={basic.photo}
|
||||
alt="Selected"
|
||||
className="w-[48px] h-[48px] object-cover rounded"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PhotoConfigDrawer
|
||||
isOpen={showConfig}
|
||||
onClose={() => setShowConfig(false)}
|
||||
photo={basic.photo}
|
||||
config={basic.photoConfig}
|
||||
onPhotoChange={handlePhotoChange}
|
||||
onConfigChange={handleConfigChange}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhotoSelector;
|
||||
@@ -6,6 +6,7 @@ import { useResumeStore } from "@/store/useResumeStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Reorder } from "framer-motion";
|
||||
import IconSelector from "../IconSelector";
|
||||
import PhotoUpload from "@/components/PhotoSelector";
|
||||
|
||||
type CustomFieldType = {
|
||||
id: string;
|
||||
@@ -194,8 +195,10 @@ const BasicPanel: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-5 p-4">
|
||||
<PhotoUpload theme={theme} />
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="flex-1">{renderField("name", "姓名")}</div>
|
||||
|
||||
<div>{renderField("employementStatus", "在职状态")}</div>
|
||||
</div>
|
||||
{renderField("title", "职位")}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BasicInfo, GlobalSettings } from "@/types/resume";
|
||||
import {
|
||||
BasicInfo,
|
||||
getBorderRadiusValue,
|
||||
GlobalSettings
|
||||
} from "@/types/resume";
|
||||
import { motion } from "framer-motion";
|
||||
import React from "react";
|
||||
|
||||
@@ -11,6 +15,30 @@ interface BaaseInfoProps {
|
||||
export function BaseInfo({ basic, globalSettings }: BaaseInfoProps) {
|
||||
return (
|
||||
<div className="text-center space-y-4">
|
||||
{basic.photo && (
|
||||
<motion.div layout="position" className="flex justify-center">
|
||||
<div
|
||||
style={{
|
||||
width: `${basic.photoConfig?.width || 100}px`,
|
||||
height: `${basic.photoConfig?.height || 100}px`,
|
||||
borderRadius: getBorderRadiusValue(
|
||||
basic.photoConfig || {
|
||||
borderRadius: "none",
|
||||
customBorderRadius: 0
|
||||
}
|
||||
),
|
||||
overflow: "hidden"
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={basic.photo}
|
||||
alt={`${basic.name}'s photo`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.h1
|
||||
layout="position"
|
||||
className={cn("font-bold", "text-gray-900")}
|
||||
@@ -41,6 +69,7 @@ export function BaseInfo({ basic, globalSettings }: BaaseInfoProps) {
|
||||
basic.email,
|
||||
basic.phone,
|
||||
basic.location,
|
||||
basic.employementStatus,
|
||||
basic.birthDate ? new Date(basic.birthDate).toLocaleDateString() : "",
|
||||
...(basic.customFields?.map((field) => field.value) || [])
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AnimatePresence, motion, LayoutGroup } from "framer-motion";
|
||||
import { useResumeStore } from "@/store/useResumeStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { throttle } from "lodash";
|
||||
@@ -27,19 +27,18 @@ interface PageBreakLineProps {
|
||||
pageNumber: number;
|
||||
}
|
||||
|
||||
const PageBreakLine = ({ pageNumber }: PageBreakLineProps) => {
|
||||
const PageBreakLine = React.memo(({ pageNumber }: PageBreakLineProps) => {
|
||||
const A4_HEIGHT_MM = 297;
|
||||
const TOP_MARGIN_MM = 4;
|
||||
const BOTTOM_MARGIN_MM = 4;
|
||||
const CONTENT_HEIGHT_MM = A4_HEIGHT_MM - TOP_MARGIN_MM - BOTTOM_MARGIN_MM;
|
||||
const MM_TO_PX = 3.78;
|
||||
const SCALE_FACTOR = 2; // 考虑导出时的 scale: 2 设置
|
||||
|
||||
const pageHeight = CONTENT_HEIGHT_MM * MM_TO_PX;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 right-0 pointer-events-none page-break-line"
|
||||
className="absolute left-0 right-0 pointer-events-none page-break-line"
|
||||
style={{
|
||||
top: `${pageHeight * pageNumber}px`,
|
||||
breakAfter: "page",
|
||||
@@ -54,7 +53,9 @@ const PageBreakLine = ({ pageNumber }: PageBreakLineProps) => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
PageBreakLine.displayName = "PageBreakLine";
|
||||
|
||||
export function PreviewPanel() {
|
||||
const {
|
||||
@@ -79,20 +80,16 @@ export function PreviewPanel() {
|
||||
globalSettings?.fontFamily || "sans"
|
||||
);
|
||||
|
||||
// 获取当前主题色
|
||||
const currentThemeColor = useMemo(() => {
|
||||
return colorTheme || THEME_COLORS[0];
|
||||
}, [colorTheme]);
|
||||
|
||||
// 监测内容高度变化
|
||||
useEffect(() => {
|
||||
const updateContentHeight = () => {
|
||||
const updateContentHeight = throttle(() => {
|
||||
if (resumeContentRef.current) {
|
||||
setContentHeight(resumeContentRef.current.scrollHeight);
|
||||
}
|
||||
};
|
||||
|
||||
updateContentHeight();
|
||||
}, 100);
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateContentHeight);
|
||||
if (resumeContentRef.current) {
|
||||
@@ -101,10 +98,10 @@ export function PreviewPanel() {
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
updateContentHeight.cancel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 处理自动滚动
|
||||
const handleScroll = React.useCallback(
|
||||
throttle((offset: number) => {
|
||||
if (previewRef.current) {
|
||||
@@ -117,7 +114,6 @@ export function PreviewPanel() {
|
||||
[scrollBehavior]
|
||||
);
|
||||
|
||||
// 使用 IntersectionObserver 监测拖拽元素
|
||||
React.useEffect(() => {
|
||||
if (!draggingProjectId || !previewRef.current) return;
|
||||
|
||||
@@ -160,31 +156,33 @@ export function PreviewPanel() {
|
||||
}, [draggingProjectId, handleScroll]);
|
||||
|
||||
const renderProjects = () => (
|
||||
<motion.div
|
||||
layout
|
||||
className="space-y-4"
|
||||
style={{
|
||||
marginTop: `${globalSettings?.sectionSpacing || 24}px`
|
||||
}}
|
||||
>
|
||||
<SectionTitle
|
||||
title="项目经历"
|
||||
themeColor={currentThemeColor}
|
||||
globalSettings={globalSettings}
|
||||
/>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{projects
|
||||
.filter((project) => project.visible)
|
||||
.map((project) => (
|
||||
<ProjectItem
|
||||
key={project.id}
|
||||
project={project}
|
||||
draggingProjectId={draggingProjectId}
|
||||
globalSettings={globalSettings}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
<LayoutGroup>
|
||||
<motion.div
|
||||
layout
|
||||
className="space-y-4"
|
||||
style={{
|
||||
marginTop: `${globalSettings?.sectionSpacing || 24}px`
|
||||
}}
|
||||
>
|
||||
<SectionTitle
|
||||
title="项目经历"
|
||||
themeColor={currentThemeColor}
|
||||
globalSettings={globalSettings}
|
||||
/>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{projects
|
||||
.filter((project) => project.visible)
|
||||
.map((project) => (
|
||||
<ProjectItem
|
||||
key={project.id}
|
||||
project={project}
|
||||
draggingProjectId={draggingProjectId}
|
||||
globalSettings={globalSettings}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</LayoutGroup>
|
||||
);
|
||||
|
||||
const pageBreakCount = useMemo(() => {
|
||||
@@ -263,7 +261,6 @@ export function PreviewPanel() {
|
||||
"text-[#000]"
|
||||
)}
|
||||
style={{
|
||||
// 设置与 html2pdf 配置一致的尺寸
|
||||
minHeight: "297mm"
|
||||
}}
|
||||
>
|
||||
@@ -274,22 +271,20 @@ export function PreviewPanel() {
|
||||
}}
|
||||
id="resume-preview"
|
||||
>
|
||||
<motion.div layout className="space-y-8" ref={resumeContentRef}>
|
||||
{/* Header */}
|
||||
<BaseInfo basic={basic} globalSettings={globalSettings} />
|
||||
<LayoutGroup>
|
||||
<motion.div layout className="space-y-8" ref={resumeContentRef}>
|
||||
<BaseInfo basic={basic} globalSettings={globalSettings} />
|
||||
{menuSections
|
||||
.filter((section) => section.enabled)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((section) => (
|
||||
<motion.div key={section.id} layout>
|
||||
{renderSection(section.id)}
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</LayoutGroup>
|
||||
|
||||
{/* Sections */}
|
||||
{menuSections
|
||||
.filter((section) => section.enabled)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((section) => (
|
||||
<motion.div key={section.id} layout>
|
||||
{renderSection(section.id)}
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* 动态分页指示线 */}
|
||||
{Array.from({ length: pageBreakCount }, (_, i) => (
|
||||
<PageBreakLine key={i} pageNumber={i + 1} />
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Drawer.displayName = "Drawer"
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
))
|
||||
DrawerContent.displayName = "DrawerContent"
|
||||
|
||||
const DrawerHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DrawerHeader.displayName = "DrawerHeader"
|
||||
|
||||
const DrawerFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DrawerFooter.displayName = "DrawerFooter"
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Education,
|
||||
Experience,
|
||||
GlobalSettings,
|
||||
DEFAULT_CONFIG,
|
||||
Project
|
||||
} from "../types/resume";
|
||||
|
||||
@@ -29,7 +30,6 @@ interface ResumeStore {
|
||||
|
||||
colorTheme: string; // 当前使用的主题色 ID
|
||||
|
||||
// 新增 Actions
|
||||
setColorTheme: (colorTheme: string) => void;
|
||||
|
||||
// Actions
|
||||
@@ -64,8 +64,10 @@ const initialState = {
|
||||
summary: "5年前端开发经验...",
|
||||
birthDate: "",
|
||||
icons: {},
|
||||
photoConfig: DEFAULT_CONFIG,
|
||||
customFields: [],
|
||||
employementStatus: ""
|
||||
employementStatus: "",
|
||||
photo: "https://talencat.s3.amazonaws.com/app/avatar/builtin/cat001.png"
|
||||
},
|
||||
education: [
|
||||
{
|
||||
@@ -148,7 +150,6 @@ export const useResumeStore = create<ResumeStore>()(
|
||||
(set) => ({
|
||||
...initialState,
|
||||
setColorTheme: (colorTheme) => {
|
||||
console.log(colorTheme, "colorTheme");
|
||||
set({ colorTheme });
|
||||
},
|
||||
|
||||
@@ -172,10 +173,9 @@ export const useResumeStore = create<ResumeStore>()(
|
||||
})),
|
||||
|
||||
reorderSections: (newOrder) => {
|
||||
// 根据新顺序重新计算每个部分的 order
|
||||
const updatedSections = newOrder.map((section, index) => ({
|
||||
...section,
|
||||
order: index // 根据数组索引设置新的顺序
|
||||
order: index
|
||||
}));
|
||||
|
||||
set({ menuSections: updatedSections });
|
||||
|
||||
@@ -1,3 +1,48 @@
|
||||
export interface PhotoConfig {
|
||||
width: number;
|
||||
height: number;
|
||||
aspectRatio: "1:1" | "4:3" | "3:4" | "16:9" | "custom";
|
||||
borderRadius: "none" | "medium" | "full" | "custom";
|
||||
customBorderRadius: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: PhotoConfig = {
|
||||
width: 96,
|
||||
height: 96,
|
||||
aspectRatio: "1:1",
|
||||
borderRadius: "none",
|
||||
customBorderRadius: 0
|
||||
};
|
||||
|
||||
// 助手函数
|
||||
export const getRatioMultiplier = (ratio: PhotoConfig["aspectRatio"]) => {
|
||||
switch (ratio) {
|
||||
case "4:3":
|
||||
return 3 / 4;
|
||||
case "3:4":
|
||||
return 4 / 3;
|
||||
case "16:9":
|
||||
return 9 / 16;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
export const getBorderRadiusValue = (config?: PhotoConfig) => {
|
||||
if (!config) return "0";
|
||||
|
||||
switch (config.borderRadius) {
|
||||
case "medium":
|
||||
return "0.5rem";
|
||||
case "full":
|
||||
return "9999px";
|
||||
case "custom":
|
||||
return `${config.customBorderRadius}px`;
|
||||
default:
|
||||
return "0";
|
||||
}
|
||||
};
|
||||
|
||||
export interface BasicInfo {
|
||||
birthDate: string;
|
||||
name: string;
|
||||
@@ -8,6 +53,8 @@ export interface BasicInfo {
|
||||
summary: string;
|
||||
icons: Record<string, string>;
|
||||
employementStatus: string;
|
||||
photo: string;
|
||||
photoConfig: PhotoConfig;
|
||||
customFields: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -60,7 +107,7 @@ export type GlobalSettings = {
|
||||
lineHeight?: number | undefined;
|
||||
sectionSpacing?: number | undefined;
|
||||
headerSize?: number | undefined;
|
||||
subHeaderSize?: number | undefined;
|
||||
subheaderSize?: number | undefined;
|
||||
};
|
||||
|
||||
export interface ResumeTheme {
|
||||
|
||||
Generated
+6032
-4764
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user