Merge pull request #13 from JOYCEQL/feat/workbench

Feat/workbench
This commit is contained in:
qingchen
2024-11-23 00:00:55 +08:00
committed by GitHub
25 changed files with 1385 additions and 251 deletions
@@ -584,7 +584,7 @@ const RichTextEditor = ({
<EditorContent editor={editor} />
{/* Bubble Menu */}
{editor && (
{/* {editor && (
<BubbleMenu
className={cn(
"flex items-center gap-0.5 p-1 rounded-md backdrop-blur border shadow-lg",
@@ -616,7 +616,7 @@ const RichTextEditor = ({
<TextColorButton editor={editor} />
<BackgroundColorButton editor={editor} />
</BubbleMenu>
)}
)} */}
</div>
);
};
@@ -55,11 +55,14 @@ export function PdfExport() {
scale: window.devicePixelRatio * 3,
letterRendering: true,
scrollY: -window.scrollY,
useCORS: true,
allowTaint: true,
height: pageHeightPx * numberOfPages
},
jsPDF: {
unit: "mm",
format: "a4",
useCORS: true,
orientation: "portrait",
putTotalPages: false
}
@@ -7,17 +7,9 @@ import { cn } from "@/lib/utils";
import BasicPanel from "./basic/BasicPanel";
import EducationPanel from "./education/EducationPanel";
import ProjectPanel from "./project/ProjectPanel";
interface Project {
id: string;
name: string;
role: string;
date: string;
description: string;
technologies: string;
responsibilities: string;
achievements: string;
visible: boolean;
}
import ExperiencePanel from "./experience/ExperiencePanel";
import CustomPanel from "./custom/CustomPanel";
import SkillPanel from "./skills/SkillPanel";
export function EditPanel() {
const { theme, activeSection, menuSections } = useResumeStore();
@@ -31,8 +23,16 @@ export function EditPanel() {
return <ProjectPanel />;
case "education":
return <EducationPanel />;
case "experience":
return <ExperiencePanel />;
case "skills":
return <SkillPanel />;
default:
return null;
if (activeSection.startsWith("custom")) {
return <CustomPanel sectionId={activeSection} />;
} else {
return <BasicPanel />;
}
}
};
@@ -4,7 +4,7 @@ import { motion } from "framer-motion";
import { Sun, Moon, Eye } from "lucide-react";
import { useResumeStore } from "@/store/useResumeStore";
import { getThemeConfig } from "@/theme/themeConfig";
import { PdfExport } from "./PdfExport";
import { PdfExport } from "../PdfExport";
interface EditorHeaderProps {
isMobile?: boolean;
+1 -1
View File
@@ -9,7 +9,7 @@ import { useResumeStore } from "@/store/useResumeStore";
import RichTextEditor from "@/app/workbench/compoents/Editor/RichText";
type FieldProps = {
label: string;
label?: string;
value?: string;
onChange: (value: string) => void;
type?: "text" | "textarea" | "date" | "editor";
@@ -82,7 +82,7 @@ interface IconOption {
}
interface IconSelectorProps {
value: string;
value?: string;
onChange: (value: string) => void;
theme?: "light" | "dark";
}
@@ -9,7 +9,8 @@ import {
Type,
SpaceIcon,
Palette,
Plus
Plus,
Trash2
} from "lucide-react";
import { useResumeStore } from "@/store/useResumeStore";
import {
@@ -92,7 +93,9 @@ export function SidePanel() {
globalSettings,
updateGlobalSettings,
colorTheme,
setColorTheme
setColorTheme,
updateMenuSections,
addCustomData
} = useResumeStore();
const debouncedSetColor = useMemo(
@@ -103,6 +106,27 @@ export function SidePanel() {
[]
);
const generateCustomSectionId = (menuSections: any[]) => {
const customSections = menuSections.filter((s) =>
s.id.startsWith("custom")
);
const nextNum = customSections.length + 1;
return `custom-${nextNum}`;
};
const handleCreateSection = () => {
const sectionId = generateCustomSectionId(menuSections);
const newSection = {
id: sectionId,
title: sectionId,
icon: "➕",
enabled: true,
order: menuSections.length
};
updateMenuSections([...menuSections, newSection]);
addCustomData(sectionId);
};
return (
<motion.div
className={cn(
@@ -178,15 +202,50 @@ export function SidePanel() {
)}
>
{item.enabled ? (
<Eye className="w-4 h-4" />
<Eye className="w-4 h-4 text-indigo-600" />
) : (
<EyeOff className="w-4 h-4" />
)}
</motion.button>
{/* 删除按钮 */}
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={() => {
updateMenuSections(
menuSections.filter((section) => section.id !== item.id)
);
setActiveSection(
menuSections[
menuSections.findIndex((s) => s.id === item.id) - 1
].id
);
}}
className={cn(
"p-1.5 rounded-md text-primary",
theme === "dark"
? "hover:bg-neutral-700 text-neutral-300"
: "hover:bg-gray-100 text-gray-600"
)}
>
<Trash2 className="w-4 h-4 text-red-400" />
</motion.button>
</div>
</Reorder.Item>
))}
</Reorder.Group>
{/* 添加自定义模块 */}
<div className="space-y-2 p-[16px]">
<motion.button
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.9 }}
onClick={handleCreateSection}
className="flex justify-center w-full rounded-lg items-center gap-2 py-2 px-3 text-sm font-medium text-indigo-600 bg-indigo-50"
>
添加自定义模块
</motion.button>
</div>
</SettingCard>
{/* 主题色设置 */}
@@ -107,7 +107,7 @@ const CustomField: React.FC<CustomFieldProps> = ({
{field.visible ? (
<Eye
className="w-4 h-4 cursor-pointer"
className="w-4 h-4 cursor-pointer text-indigo-600"
onClick={() => onUpdate({ ...field, visible: !field.visible })}
/>
) : (
@@ -291,7 +291,7 @@ const BasicPanel: React.FC = () => {
onClick={() => toggleFieldVisibility(field.id, !field.visible)}
>
{field.visible ? (
<Eye className="w-4 h-4" />
<Eye className="w-4 h-4 text-indigo-600" />
) : (
<EyeOff className="w-4 h-4" />
)}
@@ -0,0 +1,295 @@
import { useCallback, useState } from "react";
import {
motion,
useDragControls,
Reorder,
AnimatePresence
} from "framer-motion";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useResumeStore } from "@/store/useResumeStore";
import { GripVertical, Eye, EyeOff, ChevronDown, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
} from "@/components/ui/alert-dialog";
import Field from "../Field";
import { CustomItem as CustomItemType } from "@/types/resume";
const CustomItemEditor = ({
item,
onSave
}: {
item: CustomItemType;
onSave: (item: CustomItemType) => void;
}) => {
const handleChange = (field: keyof CustomItemType, value: string) => {
onSave({ ...item, [field]: value });
};
return (
<div className="space-y-5">
<div className="grid gap-5">
<div className="grid grid-cols-2 gap-4">
<Field
label="标题"
value={item.title}
onChange={(value) => handleChange("title", value)}
placeholder="标题"
/>
<Field
label="副标题"
value={item.subtitle}
onChange={(value) => handleChange("subtitle", value)}
placeholder="副标题"
/>
</div>
<Field
label="时间范围"
value={item.dateRange}
onChange={(value) => handleChange("dateRange", value)}
placeholder="例如: 2023.01 - 2024.01"
/>
<Field
label="详细描述"
value={item.description}
onChange={(value) => handleChange("description", value)}
type="editor"
placeholder="请输入详细描述..."
/>
</div>
</div>
);
};
const DeleteConfirmDialog = ({
title,
onDelete
}: {
title: string;
onDelete: () => void;
}) => {
const theme = useResumeStore((state) => state.theme);
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
"text-sm",
theme === "dark"
? "hover:bg-red-900/50 text-red-400"
: "hover:bg-red-50 text-red-600"
)}
onClick={(e) => e.stopPropagation()}
>
<Trash2 className="w-4 h-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent
className={cn(
theme === "dark" ? "bg-neutral-900 border-neutral-800" : "bg-white"
)}
onClick={(e) => e.stopPropagation()}
>
<AlertDialogHeader
className={cn(
theme === "dark" ? "text-neutral-200" : "text-gray-900"
)}
>
<AlertDialogTitle>确认删除</AlertDialogTitle>
<AlertDialogDescription>
您确定要删除 {title || "此项"} 吗?此操作无法撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
onClick={onDelete}
className="bg-red-600 hover:bg-red-700 text-white"
>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
const CustomItem = ({
item,
sectionId
}: {
item: CustomItemType;
sectionId: string;
}) => {
const dragControls = useDragControls();
const [expandedId, setExpandedId] = useState<string | null>(null);
const { theme, updateCustomItem, removeCustomItem } = useResumeStore();
const [isUpdating, setIsUpdating] = useState(false);
const handleVisibilityToggle = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (isUpdating) return;
setIsUpdating(true);
setTimeout(() => {
updateCustomItem(sectionId, item.id, { visible: !item.visible });
setIsUpdating(false);
}, 10);
},
[item, updateCustomItem, isUpdating, sectionId]
);
return (
<Reorder.Item
id={item.id}
value={item}
dragListener={false}
dragControls={dragControls}
className={cn(
"rounded-lg border overflow-hidden flex group",
"bg-white hover:border-indigo-600",
"dark:bg-neutral-900/30",
"border-gray-100 dark:border-neutral-800",
"dark:hover:border-indigo-600"
)}
>
<div
onPointerDown={(event) => {
if (expandedId === item.id) return;
dragControls.start(event);
}}
className={cn(
"w-12 flex items-center justify-center border-r shrink-0 touch-none",
theme === "dark" ? "border-neutral-800" : "border-gray-100",
expandedId === item.id
? "cursor-not-allowed"
: "cursor-grab hover:bg-gray-50 dark:hover:bg-neutral-800/50"
)}
>
<GripVertical
className={cn(
"w-4 h-4",
theme === "dark" ? "text-neutral-400" : "text-gray-400",
expandedId === item.id && "opacity-50"
)}
/>
</div>
<div className="flex-1 min-w-0">
<div
className={cn(
"px-4 py-4 flex items-center justify-between cursor-pointer select-none",
expandedId === item.id &&
(theme === "dark" ? "bg-neutral-800/50" : "bg-gray-50")
)}
onClick={() => setExpandedId(expandedId === item.id ? null : item.id)}
>
<div className="flex-1 min-w-0">
<h3
className={cn(
"font-medium truncate text-gray-700",
"dark:text-neutral-200"
)}
>
{item.title || "未命名模块"}
</h3>
{item.subtitle && (
<p
className={cn(
"text-sm truncate",
theme === "dark" ? "text-neutral-400" : "text-gray-500"
)}
>
{item.subtitle}
</p>
)}
</div>
<div className="flex items-center gap-2 ml-4 shrink-0">
<Button
variant="ghost"
size="sm"
disabled={isUpdating}
onClick={handleVisibilityToggle}
>
{item.visible ? (
<Eye className="w-4 h-4 text-indigo-600" />
) : (
<EyeOff className="w-4 h-4" />
)}
</Button>
<DeleteConfirmDialog
title={item.title}
onDelete={() => {
removeCustomItem(sectionId, item.id);
setExpandedId(null);
}}
/>
<motion.div
initial={false}
animate={{
rotate: expandedId === item.id ? 180 : 0
}}
>
<ChevronDown
className={cn(
"w-5 h-5",
theme === "dark" ? "text-neutral-400" : "text-gray-500"
)}
/>
</motion.div>
</div>
</div>
<AnimatePresence>
{expandedId === item.id && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div
className={cn(
"px-4 pb-4 space-y-4",
theme === "dark" ? "border-neutral-800" : "border-gray-100"
)}
>
<div
className={cn(
"h-px w-full",
theme === "dark" ? "bg-neutral-800" : "bg-gray-100"
)}
/>
<CustomItemEditor
item={item}
onSave={(updatedItem) => {
updateCustomItem(sectionId, item.id, updatedItem);
}}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</Reorder.Item>
);
};
export default CustomItem;
@@ -0,0 +1,54 @@
import { memo } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Reorder } from "framer-motion";
import { PlusCircle } from "lucide-react";
import CustomItem from "./CustomItem";
import { useResumeStore } from "@/store/useResumeStore";
import { CustomItem as CustomItemType } from "@/types/resume";
const CustomPanel = memo(({ sectionId }: { sectionId: string }) => {
const { customData, addCustomItem, updateCustomData } = useResumeStore();
const items = customData[sectionId] || [];
const handleCreateItem = () => {
addCustomItem(sectionId);
};
return (
<div
className={cn(
"space-y-4 px-4 py-4 rounded-lg",
"dark:bg-neutral-900/30 bg-white"
)}
>
<Reorder.Group
axis="y"
values={items}
onReorder={(newOrder) => {
updateCustomData(sectionId, newOrder);
}}
className="space-y-3"
>
{items.map((item: CustomItemType) => (
<CustomItem key={item.id} item={item} sectionId={sectionId} />
))}
<Button
onClick={handleCreateItem}
className={cn(
"w-full",
"dark:bg-indigo-600 hover:bg-indigo-700 text-white",
"bg-indigo-600 hover:bg-indigo-700 text-white"
)}
>
<PlusCircle className="w-4 h-4 mr-2" />
添加
</Button>
</Reorder.Group>
</div>
);
});
CustomPanel.displayName = "CustomPanel";
export default CustomPanel;
@@ -26,7 +26,7 @@ import {
interface EducationEditorProps {
education: Education;
onSave: (educaton: Education) => void;
onSave: (education: Education) => void;
onDelete: () => void;
onCancel: () => void;
}
@@ -40,41 +40,28 @@ const EducationEditor: React.FC<EducationEditorProps> = ({
education,
onSave
}) => {
const theme = useResumeStore((state) => state.theme);
const [data, setData] = useState<Education>(education);
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
if (
!data.school ||
!data.major ||
!data.degree ||
!data.startDate ||
!data.endDate
)
return;
onSave(data);
const handleChange = (field: keyof Education, value: string) => {
onSave({
...education,
[field]: value
});
};
return (
<form onSubmit={handleSave} className="space-y-5">
<div className="space-y-5">
<div className="grid gap-5">
<div className="grid grid-cols-2 gap-4">
<Field
label="学校名称"
value={data.school}
onChange={(value) =>
setData((prev) => ({ ...prev, school: value }))
}
value={education.school}
onChange={(value) => handleChange("school", value)}
placeholder="学校名称"
required
/>
<Field
label="所在地"
value={data.location || ""}
onChange={(value) =>
setData((prev) => ({ ...prev, location: value }))
}
value={education.location || ""}
onChange={(value) => handleChange("location", value)}
placeholder="城市"
/>
</div>
@@ -82,40 +69,33 @@ const EducationEditor: React.FC<EducationEditorProps> = ({
<div className="grid grid-cols-2 gap-4">
<Field
label="专业"
value={data.major}
onChange={(value) => setData((prev) => ({ ...prev, major: value }))}
value={education.major}
onChange={(value) => handleChange("major", value)}
placeholder="专业名称"
required
/>
<Field
label="学历"
value={data.degree}
onChange={(value) =>
setData((prev) => ({ ...prev, degree: value }))
}
value={education.degree}
onChange={(value) => handleChange("degree", value)}
placeholder="学历"
required
/>
</div>
{/* 时间和GPA */}
<div className="grid grid-cols-2 gap-4">
<Field
label="开始时间"
value={data.startDate}
onChange={(value) =>
setData((prev) => ({ ...prev, startDate: value }))
}
value={education.startDate}
onChange={(value) => handleChange("startDate", value)}
type="date"
placeholder="YYYY-MM"
required
/>
<Field
label="结束时间"
value={data.endDate}
onChange={(value) =>
setData((prev) => ({ ...prev, endDate: value }))
}
value={education.endDate}
onChange={(value) => handleChange("endDate", value)}
type="date"
placeholder="YYYY-MM"
required
@@ -124,37 +104,20 @@ const EducationEditor: React.FC<EducationEditorProps> = ({
<Field
label="GPA"
value={data.gpa || ""}
onChange={(value) => setData((prev) => ({ ...prev, gpa: value }))}
value={education.gpa || ""}
onChange={(value) => handleChange("gpa", value)}
placeholder="选填"
/>
{/* 在校经历描述 */}
<Field
label="在校经历"
value={data.description}
onChange={(value) =>
setData((prev) => ({ ...prev, description: value }))
}
value={education.description}
onChange={(value) => handleChange("description", value)}
type="editor"
placeholder="描述你的在校表现、获奖经历等..."
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="submit"
size="sm"
className={cn(
theme === "dark"
? "bg-indigo-600 hover:bg-indigo-700 text-white"
: "bg-black hover:bg-neutral-800 text-white"
)}
>
保存修改
</Button>
</div>
</form>
</div>
);
};
@@ -193,14 +156,14 @@ const DeleteConfirmDialog: React.FC<DeleteConfirmDialogProps> = ({
theme === "dark" ? "text-neutral-200" : "text-gray-900"
)}
>
确认删除项目
确认删除经历
</AlertDialogTitle>
<AlertDialogDescription
className={cn(
theme === "dark" ? "text-neutral-400" : "text-gray-500"
)}
>
您确定要删除项目 {schoolName} 吗?此操作无法撤销。
您确定要删除经历 {schoolName} 吗?此操作无法撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -265,12 +228,12 @@ const EducationItem = ({ education }: { education: Education }) => {
dragControls={dragControls}
className={cn(
"rounded-lg border overflow-hidden flex group",
theme === "dark"
? "bg-neutral-900/30 border-neutral-800"
: "bg-white border-gray-100"
"bg-white hover:border-indigo-600",
"dark:bg-neutral-900/30",
"border-gray-100 dark:border-neutral-800",
"dark:hover:border-indigo-600"
)}
>
{/* 拖拽手柄区域 */}
<div
onPointerDown={(event) => {
if (expandedId === education.id) return;
@@ -294,7 +257,6 @@ const EducationItem = ({ education }: { education: Education }) => {
/>
</div>
{/* 内容区域 */}
<div className="flex-1 min-w-0">
<div
className={cn(
@@ -355,7 +317,7 @@ const EducationItem = ({ education }: { education: Education }) => {
onClick={handleVisibilityToggle}
>
{education.visible ? (
<Eye className="w-4 h-4" />
<Eye className="w-4 h-4 text-indigo-600" />
) : (
<EyeOff className="w-4 h-4" />
)}
@@ -406,10 +368,7 @@ const EducationItem = ({ education }: { education: Education }) => {
/>
<EducationEditor
education={education}
onSave={(updatedEducation) => {
updateEducation(updatedEducation);
setExpandedId(null);
}}
onSave={updateEducation}
onDelete={() => {
deleteEducation(education.id);
setExpandedId(null);
@@ -48,15 +48,10 @@ const ProjectPanel = () => {
<Button
onClick={handleCreateProject}
className={cn(
"w-full",
theme === "dark"
? "bg-indigo-600 hover:bg-indigo-700 text-white"
: "bg-black hover:bg-neutral-800 text-white"
)}
className={cn("w-full bg-indigo-600 text-white hover:bg-indigo-600")}
>
<PlusCircle className="w-4 h-4 mr-2" />
添加项目
添加
</Button>
</Reorder.Group>
</div>
@@ -0,0 +1,344 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useResumeStore } from "@/store/useResumeStore";
import {
AnimatePresence,
motion,
Reorder,
useDragControls
} from "framer-motion";
import { ChevronDown, Eye, EyeOff, GripVertical, Trash2 } from "lucide-react";
import { useCallback, useState } from "react";
import Field from "../Field";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
} from "@/components/ui/alert-dialog";
import { Experience } from "@/types/resume";
interface ProjectEditorProps {
experience: Experience;
onSave: (experience: Experience) => void;
onDelete: () => void;
onCancel: () => void;
}
interface DeleteConfirmDialogProps {
projectName: string;
onDelete: () => void;
}
const DeleteConfirmDialog: React.FC<DeleteConfirmDialogProps> = ({
projectName,
onDelete
}) => {
const theme = useResumeStore((state) => state.theme);
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
"text-sm",
theme === "dark"
? "hover:bg-red-900/50 text-red-400"
: "hover:bg-red-50 text-red-600"
)}
onClick={(e) => e.stopPropagation()}
>
<Trash2 className="w-4 h-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent
className={cn(
theme === "dark" ? "bg-neutral-900 border-neutral-800" : "bg-white"
)}
onClick={(e) => e.stopPropagation()}
>
<AlertDialogHeader>
<AlertDialogTitle
className={cn(
theme === "dark" ? "text-neutral-200" : "text-gray-900"
)}
>
确认删除经历
</AlertDialogTitle>
<AlertDialogDescription
className={cn(
theme === "dark" ? "text-neutral-400" : "text-gray-500"
)}
>
您确定要删除经历 {projectName} 吗?此操作无法撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
className={cn(
theme === "dark"
? "bg-neutral-800 hover:bg-neutral-700 text-neutral-200 border-neutral-700"
: ""
)}
>
取消
</AlertDialogCancel>
<AlertDialogAction
onClick={(e: { preventDefault: () => void }) => {
e.preventDefault();
onDelete();
}}
className={cn(
theme === "dark"
? "bg-red-600 hover:bg-red-700 text-white"
: "bg-red-600 hover:bg-red-700 text-white"
)}
>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
const ProjectEditor: React.FC<ProjectEditorProps> = ({
experience,
onSave
}) => {
const handleChange = (field: keyof Experience, value: string) => {
onSave({
...experience,
[field]: value
});
};
return (
<div className="space-y-5">
<div className="grid gap-5">
<div className="grid grid-cols-2 gap-4">
<Field
label="公司名称"
value={experience.company}
onChange={(value) => handleChange("company", value)}
placeholder="项目名称"
required
/>
<Field
label="岗位"
value={experience.position}
onChange={(value) => handleChange("position", value)}
placeholder="如:前端工程师"
required
/>
</div>
<Field
label="开始时间-结束时间"
value={experience.date}
onChange={(value) => handleChange("date", value)}
placeholder="如:2023.01 - 2023.06"
required
/>
<Field
label="主要职责"
value={experience.details}
onChange={(value) => handleChange("details", value)}
type="editor"
placeholder="简要描述项目的背景和目标..."
/>
</div>
</div>
);
};
const ExperienceItem = ({ experience }: { experience: Experience }) => {
const dragControls = useDragControls();
const [expandedId, setExpandedId] = useState<string | null>(null);
const { theme, updateExperience, deleteExperience } = useResumeStore();
const [isUpdating, setIsUpdating] = useState(false);
const handleVisibilityToggle = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (isUpdating) return;
setIsUpdating(true);
setTimeout(() => {
updateExperience({
...experience,
visible: !experience.visible
});
setIsUpdating(false);
}, 10);
},
[experience, updateExperience, isUpdating]
);
return (
<Reorder.Item
id={experience.id}
value={experience}
dragListener={false}
dragControls={dragControls}
onDragEnd={() => {
useResumeStore.getState().setDraggingProjectId(null);
}}
className={cn(
"rounded-lg border overflow-hidden flex group",
"bg-white hover:border-indigo-600",
"dark:bg-neutral-900/30",
"border-gray-100 dark:border-neutral-800",
"dark:hover:border-indigo-600"
)}
>
<div
onPointerDown={(event) => {
if (expandedId === experience.id) return;
dragControls.start(event);
useResumeStore.getState().setDraggingProjectId(experience.id);
}}
onPointerUp={() => {
useResumeStore.getState().setDraggingProjectId(null);
}}
onPointerCancel={() => {
useResumeStore.getState().setDraggingProjectId(null);
}}
className={cn(
"w-12 flex items-center justify-center border-r shrink-0 touch-none",
theme === "dark" ? "border-neutral-800" : "border-gray-100",
expandedId === experience.id
? "cursor-not-allowed"
: "cursor-grab hover:bg-gray-50 dark:hover:bg-neutral-800/50"
)}
>
<GripVertical
className={cn(
"w-4 h-4",
theme === "dark" ? "text-neutral-400" : "text-gray-400",
expandedId === experience.id && "opacity-50",
"transform transition-transform group-hover:scale-110"
)}
/>
</div>
<div className="flex-1 min-w-0">
<div
className={cn(
"px-4 py-4 flex items-center justify-between",
expandedId === experience.id &&
(theme === "dark" ? "bg-neutral-800/50" : "bg-gray-50"),
"cursor-pointer select-none"
)}
onClick={(e) => {
if (expandedId === experience.id) {
setExpandedId(null);
} else {
setExpandedId(experience.id);
}
}}
>
<div className="flex-1 min-w-0">
<h3
className={cn(
"font-medium truncate",
theme === "dark" ? "text-neutral-200" : "text-gray-700"
)}
>
{experience.company || "家里蹲公司"}
</h3>
</div>
<div className="flex items-center gap-2 ml-4 shrink-0">
<Button
variant="ghost"
size="sm"
disabled={isUpdating}
className={cn(
"text-sm",
theme === "dark"
? experience.visible
? "hover:bg-neutral-800 text-neutral-400"
: "hover:bg-neutral-800 text-neutral-600"
: experience.visible
? "hover:bg-gray-100 text-gray-500"
: "hover:bg-gray-100 text-gray-400"
)}
onClick={handleVisibilityToggle}
>
{experience.visible ? (
<Eye className="w-4 h-4 text-indigo-600" />
) : (
<EyeOff className="w-4 h-4" />
)}
</Button>
<DeleteConfirmDialog
projectName={experience.company}
onDelete={() => {
deleteExperience(experience.id);
setExpandedId(null);
}}
/>
<motion.div
initial={false}
animate={{
rotate: expandedId === experience.id ? 180 : 0
}}
>
<ChevronDown
className={cn(
"w-5 h-5",
theme === "dark" ? "text-neutral-400" : "text-gray-500"
)}
/>
</motion.div>
</div>
</div>
<AnimatePresence>
{expandedId === experience.id && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div
className={cn(
"px-4 pb-4 space-y-4",
theme === "dark" ? "border-neutral-800" : "border-gray-100"
)}
onClick={(e) => e.stopPropagation()}
>
<div
className={cn(
"h-px w-full",
theme === "dark" ? "bg-neutral-800" : "bg-gray-100"
)}
/>
<ProjectEditor
experience={experience}
onSave={updateExperience}
onDelete={() => {
deleteExperience(experience.id);
setExpandedId(null);
}}
onCancel={() => setExpandedId(null)}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</Reorder.Item>
);
};
export default ExperienceItem;
@@ -0,0 +1,54 @@
"use client";
import { cn } from "@/lib/utils";
import { useResumeStore } from "@/store/useResumeStore";
import { Reorder } from "framer-motion";
import { PlusCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import ExperienceItem from "./ExperienceItem";
import { Experience } from "@/types/resume";
const ExperiencePanel = () => {
const { theme, experience = [], updateExperience } = useResumeStore();
const handleCreateProject = () => {
const newProject: Experience = {
id: crypto.randomUUID(),
company: "某科技有限公司",
position: "高级前端工程师",
date: "2020-至今",
details: "负责公司核心产品...",
visible: true
};
updateExperience(newProject);
};
return (
<div
className={cn(
"space-y-4 px-4 py-4 rounded-lg",
theme === "dark" ? "bg-neutral-900/30" : "bg-white"
)}
>
<Reorder.Group
axis="y"
values={experience}
onReorder={(newOrder) => {
useResumeStore.setState({ experience: newOrder });
}}
className="space-y-3"
>
{experience.map((item) => (
<ExperienceItem key={item.id} experience={item}></ExperienceItem>
))}
<Button
onClick={handleCreateProject}
className={cn("w-full bg-indigo-600 text-white hover:bg-indigo-600")}
>
<PlusCircle className="w-4 h-4 mr-2" />
添加
</Button>
</Reorder.Group>
</div>
);
};
export default ExperiencePanel;
@@ -122,65 +122,69 @@ const DeleteConfirmDialog: React.FC<DeleteConfirmDialogProps> = ({
};
const ProjectEditor: React.FC<ProjectEditorProps> = ({ project, onSave }) => {
const theme = useResumeStore((state) => state.theme);
const [data, setData] = useState<Project>(project);
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
if (!data.name || !data.role || !data.date) return;
onSave(data);
const handleChange = (field: keyof Project, value: string) => {
onSave({
...project,
[field]: value
});
};
return (
<form onSubmit={handleSave} className="space-y-5">
<div className="space-y-5">
<div className="grid gap-5">
<div className="grid grid-cols-2 gap-4">
<Field
label="项目名称"
value={data.name}
onChange={(value) => setData((prev) => ({ ...prev, name: value }))}
value={project.name}
onChange={(value) => handleChange("name", value)}
placeholder="项目名称"
required
/>
<Field
label="担任角色"
value={data.role}
onChange={(value) => setData((prev) => ({ ...prev, role: value }))}
value={project.role}
onChange={(value) => handleChange("role", value)}
placeholder="如:前端负责人"
required
/>
</div>
<Field
label="项目时间"
value={data.date}
onChange={(value) => setData((prev) => ({ ...prev, date: value }))}
value={project.date}
onChange={(value) => handleChange("date", value)}
placeholder="如:2023.01 - 2023.06"
required
/>
<Field
label="项目描述"
value={data.description}
onChange={(value) =>
setData((prev) => ({ ...prev, description: value }))
}
value={project.description}
onChange={(value) => handleChange("description", value)}
type="editor"
placeholder="简要描述项目的背景和目标..."
/>
<Field
label="技术栈"
value={project.technologies}
onChange={(value) => handleChange("technologies", value)}
type="editor"
placeholder="使用的技术栈和工具..."
/>
<Field
label="主要职责"
value={project.responsibilities}
onChange={(value) => handleChange("responsibilities", value)}
type="editor"
placeholder="在项目中的主要职责..."
/>
<Field
label="项目成果"
value={project.achievements}
onChange={(value) => handleChange("achievements", value)}
type="editor"
placeholder="项目取得的成果..."
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="submit"
size="sm"
className={cn(
theme === "dark"
? "bg-indigo-600 hover:bg-indigo-700 text-white"
: "bg-black hover:bg-neutral-800 text-white"
)}
>
保存修改
</Button>
</div>
</form>
</div>
);
};
@@ -220,12 +224,12 @@ const ProjectItem = ({ project }: { project: Project }) => {
}}
className={cn(
"rounded-lg border overflow-hidden flex group",
theme === "dark"
? "bg-neutral-900/30 border-neutral-800"
: "bg-white border-gray-100"
"bg-white hover:border-indigo-600",
"dark:bg-neutral-900/30",
"border-gray-100 dark:border-neutral-800",
"dark:hover:border-indigo-600"
)}
>
{/* 拖拽手柄区域 */}
<div
onPointerDown={(event) => {
if (expandedId === project.id) return;
@@ -256,7 +260,6 @@ const ProjectItem = ({ project }: { project: Project }) => {
/>
</div>
{/* 内容区域 */}
<div className="flex-1 min-w-0">
<div
className={cn(
@@ -301,7 +304,7 @@ const ProjectItem = ({ project }: { project: Project }) => {
onClick={handleVisibilityToggle}
>
{project.visible ? (
<Eye className="w-4 h-4" />
<Eye className="w-4 h-4 text-indigo-600" />
) : (
<EyeOff className="w-4 h-4" />
)}
@@ -354,7 +357,6 @@ const ProjectItem = ({ project }: { project: Project }) => {
project={project}
onSave={(updatedProject) => {
updateProjects(updatedProject);
setExpandedId(null);
}}
onDelete={() => {
deleteProject(project.id);
@@ -45,15 +45,10 @@ const ProjectPanel = () => {
<Button
onClick={handleCreateProject}
className={cn(
"w-full",
theme === "dark"
? "bg-indigo-600 hover:bg-indigo-700 text-white"
: "bg-black hover:bg-neutral-800 text-white"
)}
className="w-full bg-indigo-600 text-white hover:bg-indigo-600"
>
<PlusCircle className="w-4 h-4 mr-2" />
添加项目
添加
</Button>
</Reorder.Group>
</div>
@@ -0,0 +1,32 @@
"use client";
import { useResumeStore } from "@/store/useResumeStore";
import Field from "../Field";
import { cn } from "@/lib/utils";
const SkillPanel = () => {
const { updateSkillContent, skillContent } = useResumeStore();
const handleChange = (value: string) => {
updateSkillContent(value);
};
return (
<div
className={cn(
"rounded-lg border p-4",
"bg-white",
"dark:bg-neutral-900/30",
"border-gray-100 dark:border-neutral-800"
)}
>
<Field
value={skillContent}
onChange={handleChange}
type="editor"
placeholder="描述你的技能、专长等..."
/>
</div>
);
};
export default SkillPanel;
@@ -0,0 +1,91 @@
"use client";
import { motion } from "framer-motion";
import { GlobalSettings } from "@/types/resume";
import { SectionTitle } from "./SectionTitle";
import { CustomItem } from "@/types/resume";
interface CustomSectionProps {
sectionId: string;
title: string;
items: CustomItem[];
globalSettings: GlobalSettings | undefined;
themeColor: string;
}
export function CustomSection({
title,
items,
globalSettings,
themeColor
}: CustomSectionProps) {
const visibleItems = items.filter((item) => {
return item.visible && (item.title || item.description);
});
return (
<motion.div
layout
style={{
marginTop: `${globalSettings?.sectionSpacing || 24}px`
}}
>
<SectionTitle
title={title}
themeColor={themeColor}
globalSettings={globalSettings}
/>
{visibleItems.map((item) => (
<div
key={item.id}
style={{
marginTop: `${globalSettings?.paragraphSpacing}px`
}}
>
<div className="flex justify-between items-start">
<div className="space-y-1">
<div>
<h4
className="font-medium text-gray-800"
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`
}}
>
{item.title}
</h4>
<motion.div
layout
className={"text-gray-600"}
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`
}}
>
{item.subtitle}
</motion.div>
</div>
</div>
{item.dateRange && (
<span
className="text-gray-600 shrink-0 ml-4"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`
}}
>
{item.dateRange}
</span>
)}
</div>
{item.description && (
<div
className="text-gray-600 mt-1"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
lineHeight: globalSettings?.lineHeight || 1.6
}}
dangerouslySetInnerHTML={{ __html: item.description }}
/>
)}
</div>
))}
</motion.div>
);
}
@@ -14,9 +14,7 @@ export function EducationSection({
globalSettings,
themeColor
}: EducationSectionProps) {
// 只显示visible为true的教育经历
const visibleEducation = education.filter((edu) => edu.visible);
return (
<motion.div
layout
@@ -25,52 +25,54 @@ export function ExperienceSection({
themeColor={themeColor}
globalSettings={globalSettings}
/>
{experience.map((exp) => (
<div
key={exp.id}
style={{
marginTop: `${globalSettings?.paragraphSpacing}px`
}}
>
<div className="flex justify-between items-start">
<div>
<h4
className="font-medium text-gray-800"
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`
}}
>
{exp.company}
</h4>
<p
className="text-gray-600"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`
}}
>
{exp.position}
</p>
</div>
<span
className="text-gray-600"
{experience.map(
(exp) =>
exp.visible && (
<div
key={exp.id}
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`
marginTop: `${globalSettings?.paragraphSpacing}px`
}}
>
{exp.date}
</span>
</div>
<p
className="text-gray-600"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
lineHeight: globalSettings?.lineHeight || 1.6
}}
>
{exp.details}
</p>
</div>
))}
<div className="flex justify-between items-start">
<div>
<h4
className="font-medium text-gray-800"
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`
}}
>
{exp.company}
</h4>
<p
className="text-gray-600"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`
}}
>
{exp.position}
</p>
</div>
<span
className="text-gray-600"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`
}}
>
{exp.date}
</span>
</div>
<div
className="text-gray-600"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
lineHeight: globalSettings?.lineHeight || 1.6
}}
dangerouslySetInnerHTML={{ __html: exp.details }}
/>
</div>
)
)}
</motion.div>
);
}
@@ -11,6 +11,8 @@ import { SectionTitle } from "./SectionTitle";
import { ProjectItem } from "./ProjectItem";
import { ExperienceSection } from "./ExperienceSection";
import { EducationSection } from "./EducationSection";
import { CustomSection } from "./CustomSection";
import { SkillSection } from "./SkillPanel";
const getFontFamilyClass = (fontFamily: string) => {
switch (fontFamily) {
@@ -67,7 +69,9 @@ export function PreviewPanel() {
globalSettings,
projects,
draggingProjectId,
colorTheme
colorTheme,
customData,
skillContent
} = useResumeStore();
const previewRef = React.useRef<HTMLDivElement>(null);
@@ -196,6 +200,19 @@ export function PreviewPanel() {
}, [contentHeight]);
const renderSection = (sectionId: string) => {
if (sectionId.startsWith("custom")) {
const sectionConfig = menuSections.find((s) => s.id === sectionId);
return (
<CustomSection
sectionId={sectionId}
title={sectionConfig?.title || "自定义模块"}
items={customData[sectionId] || []}
globalSettings={globalSettings}
themeColor={currentThemeColor}
/>
);
}
switch (sectionId) {
case "education":
return (
@@ -215,6 +232,14 @@ export function PreviewPanel() {
);
case "projects":
return renderProjects();
case "skills":
return (
<SkillSection
skill={skillContent}
globalSettings={globalSettings}
themeColor={currentThemeColor}
/>
);
default:
return null;
}
@@ -3,7 +3,7 @@ import { GlobalSettings } from "@/types/resume";
interface SectionTitleProps {
title: string;
themeColor: string;
globalSettings: GlobalSettings;
globalSettings?: GlobalSettings;
}
export function SectionTitle({
@@ -0,0 +1,48 @@
import { motion } from "framer-motion";
import { GlobalSettings } from "@/types/resume";
import { SectionTitle } from "./SectionTitle";
interface SkillSectionProps {
skill: string;
globalSettings: GlobalSettings | undefined;
themeColor: string;
}
export function SkillSection({
skill,
globalSettings,
themeColor
}: SkillSectionProps) {
if (!skill) {
return null;
}
return (
<motion.div
layout
style={{
marginTop: `${globalSettings?.sectionSpacing || 24}px`
}}
>
<SectionTitle
title="技能特长"
themeColor={themeColor}
globalSettings={globalSettings}
/>
<div
style={{
marginTop: `${globalSettings?.paragraphSpacing}px`
}}
>
<div
className="text-gray-600"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
lineHeight: globalSettings?.lineHeight || 1.6
}}
dangerouslySetInnerHTML={{ __html: skill }}
/>
</div>
</motion.div>
);
}
+222 -54
View File
@@ -6,9 +6,15 @@ import {
Experience,
GlobalSettings,
DEFAULT_CONFIG,
Project
Project,
CustomItem
} from "../types/resume";
interface CustomSection {
id: string;
items: CustomItem[];
}
interface ResumeStore {
// 基础数据
basic: BasicInfo;
@@ -23,13 +29,13 @@ interface ResumeStore {
enabled: boolean;
order: number;
}[];
customData: Record<string, CustomItem[]>;
// 主题设置
theme: "light" | "dark";
activeSection: string;
colorTheme: string; // 当前使用的主题色 ID
// 当前使用的主题色 ID
colorTheme: string;
setColorTheme: (colorTheme: string) => void;
// Actions
@@ -37,12 +43,25 @@ interface ResumeStore {
updateEducation: (data: Education) => void;
deleteEducation: (id: string) => void;
updateExperience: (id: string, data: Partial<Experience>) => void;
updateExperience: (data: Experience) => void;
deleteExperience: (id: string) => void;
// 菜单操作
reorderSections: (newOrder: typeof initialState.menuSections) => void;
toggleSectionVisibility: (sectionId: string) => void;
setActiveSection: (sectionId: string) => void;
updateMenuSections: (sections: typeof initialState.menuSections) => void;
addCustomData: (sectionId: string) => void;
updateCustomData: (sectionId: string, items: CustomItem[]) => void;
removeCustomData: (sectionId: string) => void;
addCustomItem: (sectionId: string) => void;
updateCustomItem: (
sectionId: string,
itemId: string,
updates: Partial<CustomItem>
) => void;
removeCustomItem: (sectionId: string, itemId: string) => void;
toggleTheme: () => void;
// 全局设置
globalSettings: GlobalSettings;
@@ -53,6 +72,10 @@ interface ResumeStore {
deleteProject: (id: string) => void;
draggingProjectId: string | null;
setDraggingProjectId: (id: string | null) => void;
// 技能特长
skillContent: string;
updateSkillContent: (skillContent: string) => void;
}
const initialState = {
@@ -60,14 +83,17 @@ const initialState = {
basic: {
name: "张三",
title: "高级前端工程师",
email: "example@email.com",
email: "zhangsan@example.com",
phone: "13800138000",
location: "北京市",
birthDate: "",
location: "北京市朝阳区",
birthDate: "1995-01",
icons: {},
photoConfig: DEFAULT_CONFIG,
customFields: [],
employementStatus: "",
customFields: [
{ id: "personal", label: "个人网站", value: "https://zhangsan.dev" },
{ id: "github", label: "GitHub", value: "https://github.com/zhangsan" }
],
employementStatus: "全职",
photo: "avatar.svg"
},
education: [
@@ -76,22 +102,106 @@ const initialState = {
school: "北京大学",
major: "计算机科学与技术",
degree: "本科",
startDate: "2019-09",
endDate: "2023-06",
startDate: "2013-09",
endDate: "2017-06",
visible: true,
gpa: "3.8/4.0",
location: "北京",
description:
"主修课程:数据结构、算法设计、操作系统、计算机网络、数据库系统\n在校期间保持专业前10%,获得优秀学生奖学金,参与多个开源项目"
description: `<ul>
<li>主修课程:数据结构、算法设计、操作系统、计算机网络、Web开发技术</li>
<li>专业排名前 5%,连续三年获得一等奖学金</li>
<li>担任计算机协会技术部部长,组织多次技术分享会</li>
<li>参与开源项目贡献,获得 GitHub Campus Expert 认证</li>
</ul>`
}
],
skillContent: `<div class="skill-content">
<ul>
<li>前端框架:精通 React、Vue.js,熟悉 Next.js、Nuxt.js 等 SSR 框架</li>
<li>开发语言:TypeScript、JavaScript(ES6+)、HTML5、CSS3</li>
<li>UI/样式:精通 TailwindCSS、Sass/Less、CSS Module、Styled-components</li>
<li>状态管理:Redux、Vuex、Zustand、Jotai、React Query</li>
<li>工程化工具:Webpack、Vite、Rollup、Babel、ESLint</li>
<li>测试工具:Jest、React Testing Library、Cypress</li>
<li>性能优化:熟悉浏览器渲染原理、性能指标监控、代码分割、懒加载等优化技术</li>
<li>版本控制:Git、SVN</li>
</ul>
<ul>
<li>技术管理:具备团队管理经验,主导过多个大型项目的技术选型和架构设计</li>
<li>技术分享:定期组织团队技术分享,主导建设团队技术博客</li>
<li>敏捷开发:熟悉 Scrum 开发流程,具有良好的项目把控能力</li>
<li>英语能力:CET-6 分数 560,具备良好的英文文档阅读和技术交流能力</li>
</ul>
</div>`,
experience: [
{
id: "1",
company: "某科技有限公司",
company: "字节跳动",
position: "高级前端工程师",
date: "2020-至今",
details: "负责公司核心产品..."
date: "2021.07 - 至今",
details: `<ul>
<li>负责抖音创作者平台的开发与维护,主导多个核心功能的技术方案设计</li>
<li>优化项目工程化配置,将构建时间从 8 分钟优化至 2 分钟,提升团队开发效率</li>
<li>设计并实现组件库,提升代码复用率达 70%,显著减少开发时间</li>
<li>主导性能优化项目,使平台首屏加载时间减少 50%,接入 APM 监控系统</li>
<li>指导初级工程师,组织技术分享会,提升团队整体技术水平</li>
</ul>`
}
],
projects: [
{
id: "p1",
name: "抖音创作者中台",
role: "前端负责人",
date: "2022.06 - 2023.12",
description:
"基于 React 的创作者数据分析和内容管理平台,服务百万级创作者群体,包含数据分析、内容管理、收益管理等多个子系统。",
technologies:
"React 18、TypeScript、TailwindCSS、Zustand、React Query、shadcn/ui、Recharts",
responsibilities: `- 主导项目技术选型和架构设计
- 建立项目开发规范和 CI/CD 流程
- 负责核心功能模块开发和性能优化
- 指导团队成员,把控代码质量`,
achievements: `- 系统性能显著提升,首屏加载时间减少 50%
- 代码重用率提高到 80%,显著减少开发时间
- 平台日活跃用户提升 200%
- 获得公司年度最佳项目奖`,
visible: true
},
{
id: "p2",
name: "微信小程序开发者工具",
role: "核心开发者",
date: "2020.03 - 2021.06",
description:
"为开发者提供小程序开发、调试和发布的一站式解决方案。基于 Electron 构建的跨平台桌面应用。",
technologies:
"Electron、React、TypeScript、Monaco Editor、Node.js、WebSocket",
responsibilities: `- 负责编辑器核心功能开发
- 实现实时预览和调试功能
- 优化开发者使用体验
- 处理性能问题和 Bug 修复`,
achievements: `- 编辑器性能提升 40%
- 月活用户增长 150%
- 获得最佳工具奖`,
visible: true
},
{
id: "p3",
name: "前端监控平台",
role: "技术负责人",
date: "2021.09 - 2022.03",
description:
"一个完整的前端监控解决方案,包含错误监控、性能监控、用户行为分析等功能。",
technologies: "Vue 3、TypeScript、Vite、IndexedDB、WebWorker、Canvas",
responsibilities: `- 设计监控 SDK 和数据采集方案
- 开发可视化数据面板
- 优化数据处理性能
- 制定监控告警策略`,
achievements: `- 准确捕获 99.9% 的前端异常
- 帮助业务快速定位并解决问题
- 平均故障排查时间减少 60%`,
visible: true
}
],
menuSections: [
@@ -107,46 +217,19 @@ const initialState = {
{ id: "skills", title: "技能特长", icon: "⚡", enabled: true, order: 3 },
{ id: "projects", title: "项目经历", icon: "🚀", enabled: true, order: 4 }
],
customData: {},
theme: "light" as const,
colorTheme: "#2563eb",
activeSection: "basic",
projects: [
{
id: "p1",
name: "企业中台系统",
role: "前端负责人",
date: "2023.06 - 2023.12",
description:
"基于 React 的企业级中台项目,包含工作流、报表、系统管理等多个子系统",
technologies: "React, TypeScript, TailwindCSS, shadcn/ui",
responsibilities:
"负责整体技术方案设计和团队管理,把控项目进度和代码质量",
achievements: "系统整体性能提升 50%,代码重用率提高到 80%",
visible: true
},
{
id: "p2",
name: "xxx",
role: "前端负责人",
date: "2023.06 - 2023.12",
description:
"基于 React 的企业级中台项目,包含工作流、报表、系统管理等多个子系统",
technologies: "React, TypeScript, TailwindCSS, shadcn/ui",
responsibilities:
"负责整体技术方案设计和团队管理,把控项目进度和代码质量",
achievements: "系统整体性能提升 50%,代码重用率提高到 80%",
visible: true
}
],
globalSettings: {
fontFamily: "sans",
baseFontSize: 14,
pagePadding: 20,
paragraphSpacing: 20,
lineHeight: 1,
lineHeight: 1.2,
sectionSpacing: 20,
headerSize: 18,
subheaderSize: 16,
@@ -165,16 +248,21 @@ export const useResumeStore = create<ResumeStore>()(
setDraggingProjectId: (id) => set({ draggingProjectId: id }),
updateBasicInfo: (data) => {
console.log(data, "data");
set((state) => ({ basic: { ...state.basic, ...data } }));
},
updateExperience: (id, data) =>
set((state) => ({
experience: state.experience.map((exp) =>
exp.id === id ? { ...exp, ...data } : exp
updateExperience: (experience) =>
set((state) => {
const newExperience = state.experience.some(
(p) => p.id === experience.id
)
})),
? state.experience.map((p) =>
p.id === experience.id ? { ...experience } : p
)
: [...state.experience, { ...experience }];
return { experience: newExperience };
}),
reorderSections: (newOrder) => {
const updatedSections = newOrder.map((section, index) => ({
@@ -196,6 +284,8 @@ export const useResumeStore = create<ResumeStore>()(
setActiveSection: (sectionId) => set({ activeSection: sectionId }),
updateMenuSections: (sections) => set({ menuSections: sections }),
updateProjects: (project) =>
set((state) => {
const newProjects = state.projects.some((p) => p.id === project.id)
@@ -229,6 +319,12 @@ export const useResumeStore = create<ResumeStore>()(
set((state) => ({
education: state.education.filter((p) => p.id !== id)
})),
deleteExperience: (id) =>
set((state) => ({
experience: state.experience.filter((p) => p.id !== id)
})),
toggleTheme: () =>
set((state) => ({
theme: state.theme === "light" ? "dark" : "light"
@@ -242,7 +338,79 @@ export const useResumeStore = create<ResumeStore>()(
return {
globalSettings: newSettings
};
})
}),
addCustomData: (sectionId) =>
set((state) => ({
customData: {
...state.customData,
[sectionId]: [
{
id: crypto.randomUUID(),
title: "未命名模块",
subtitle: "",
dateRange: "",
description: "",
visible: true
}
]
}
})),
updateCustomData: (sectionId, items) =>
set((state) => ({
customData: {
...state.customData,
[sectionId]: items
}
})),
removeCustomData: (sectionId) =>
set((state) => {
const { [sectionId]: _, ...rest } = state.customData;
return { customData: rest };
}),
addCustomItem: (sectionId) =>
set((state) => ({
customData: {
...state.customData,
[sectionId]: [
...(state.customData[sectionId] || []),
{
id: crypto.randomUUID(),
title: "未命名模块",
subtitle: "",
dateRange: "",
description: "",
visible: true
}
]
}
})),
updateCustomItem: (sectionId, itemId, updates) => {
console.log(sectionId, "sectionId");
set((state) => ({
customData: {
...state.customData,
[sectionId]: state.customData[sectionId].map((item) =>
item.id === itemId ? { ...item, ...updates } : item
)
}
}));
},
removeCustomItem: (sectionId, itemId) =>
set((state) => ({
customData: {
...state.customData,
[sectionId]: state.customData[sectionId].filter(
(item) => item.id !== itemId
)
}
})),
updateSkillContent: (content) => set({ skillContent: content })
}),
{
+12 -2
View File
@@ -56,7 +56,7 @@ export interface CustomFieldType {
id: string;
label: string;
value: string;
icon: string;
icon?: string;
visible?: boolean;
}
export interface BasicInfo {
@@ -75,7 +75,7 @@ export interface BasicInfo {
id: string;
label: string;
value: string;
icon: string;
icon?: string;
visible?: boolean;
}>;
}
@@ -99,6 +99,7 @@ export interface Experience {
position: string;
date: string;
details: string;
visible?: boolean;
}
export interface Skill {
@@ -139,6 +140,15 @@ export interface ResumeTheme {
color: string;
}
export interface CustomItem {
id: string;
title: string;
subtitle: string;
dateRange: string;
description: string;
visible: boolean;
}
export const THEME_COLORS = [
"#2563eb",
"#059669",