mirror of
https://github.com/JOYCEQL/magic-resume.git
synced 2026-07-03 14:07:11 +02:00
feat: education config
This commit is contained in:
@@ -36,7 +36,7 @@ import { useResumeStore } from "@/store/useResumeStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface RichTextEditorProps {
|
||||
content: string;
|
||||
content?: string;
|
||||
onChange: (content: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
@@ -89,6 +89,9 @@ export default function Home() {
|
||||
setPanelSizes(newSizes);
|
||||
setLayoutKey((prev) => prev + 1);
|
||||
};
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let newSizes;
|
||||
|
||||
@@ -1,27 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
motion,
|
||||
AnimatePresence,
|
||||
Reorder,
|
||||
useDragControls
|
||||
} from "framer-motion";
|
||||
import {
|
||||
CalendarIcon,
|
||||
PlusCircle,
|
||||
ChevronDown,
|
||||
Trash2,
|
||||
X,
|
||||
GripVertical
|
||||
} from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useResumeStore } from "@/store/useResumeStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Field from "./Field";
|
||||
import ProjectItem from "./project/ProjectItem";
|
||||
import BasicPanel from "./basic/BasicPanel";
|
||||
|
||||
import EducationPanel from "./education/EducationPanel";
|
||||
import ProjectPanel from "./project/ProjectPanel";
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -34,35 +19,8 @@ interface Project {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
// 主面板组件
|
||||
export function EditPanel() {
|
||||
const {
|
||||
theme,
|
||||
activeSection,
|
||||
menuSections,
|
||||
basic,
|
||||
projects = [],
|
||||
updateBasicInfo,
|
||||
updateProjects,
|
||||
deleteProject
|
||||
} = useResumeStore();
|
||||
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
const handleCreateProject = () => {
|
||||
const newProject: Project = {
|
||||
id: crypto.randomUUID(),
|
||||
name: "Project Name",
|
||||
role: "Project Role",
|
||||
date: "",
|
||||
description: "",
|
||||
technologies: "",
|
||||
responsibilities: "",
|
||||
achievements: "",
|
||||
visible: true
|
||||
};
|
||||
setEditingId(newProject.id);
|
||||
updateProjects(newProject);
|
||||
};
|
||||
const { theme, activeSection, menuSections } = useResumeStore();
|
||||
|
||||
const renderFields = () => {
|
||||
switch (activeSection) {
|
||||
@@ -70,41 +28,9 @@ export function EditPanel() {
|
||||
return <BasicPanel />;
|
||||
|
||||
case "projects":
|
||||
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={projects}
|
||||
onReorder={(newOrder) => {
|
||||
useResumeStore.setState({ projects: newOrder });
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
{projects.map((project) => (
|
||||
<ProjectItem key={project.id} project={project}></ProjectItem>
|
||||
))}
|
||||
|
||||
{/* 添加项目按钮 */}
|
||||
<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"
|
||||
)}
|
||||
>
|
||||
<PlusCircle className="w-4 h-4 mr-2" />
|
||||
添加项目
|
||||
</Button>
|
||||
</Reorder.Group>
|
||||
</div>
|
||||
);
|
||||
return <ProjectPanel />;
|
||||
case "education":
|
||||
return <EducationPanel />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -146,10 +72,9 @@ export function EditPanel() {
|
||||
<motion.div
|
||||
className={cn(
|
||||
"rounded-lg",
|
||||
editingId === null &&
|
||||
(theme === "dark"
|
||||
? "bg-neutral-900/50 border-neutral-800"
|
||||
: "bg-white border-gray-100")
|
||||
theme === "dark"
|
||||
? "bg-neutral-900/50 border-neutral-800"
|
||||
: "bg-white border-gray-100"
|
||||
)}
|
||||
>
|
||||
{renderFields()}
|
||||
|
||||
@@ -10,7 +10,7 @@ import RichTextEditor from "@/app/workbench/compoents/Editor/RichText";
|
||||
|
||||
type FieldProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
type?: "text" | "textarea" | "date" | "editor";
|
||||
placeholder?: string;
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
"use client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useResumeStore } from "@/store/useResumeStore";
|
||||
import { Education } from "@/types/resume";
|
||||
import {
|
||||
useDragControls,
|
||||
Reorder,
|
||||
motion,
|
||||
AnimatePresence
|
||||
} from "framer-motion";
|
||||
import { GripVertical, Eye, EyeOff, ChevronDown, Trash2 } from "lucide-react";
|
||||
import { useState, useCallback } from "react";
|
||||
import Field from "../Field";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
interface EducationEditorProps {
|
||||
education: Education;
|
||||
onSave: (educaton: Education) => void;
|
||||
onDelete: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface DeleteConfirmDialogProps {
|
||||
schoolName: string;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSave} 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 }))
|
||||
}
|
||||
placeholder="学校名称"
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label="所在地"
|
||||
value={data.location || ""}
|
||||
onChange={(value) =>
|
||||
setData((prev) => ({ ...prev, location: value }))
|
||||
}
|
||||
placeholder="城市"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
label="专业"
|
||||
value={data.major}
|
||||
onChange={(value) => setData((prev) => ({ ...prev, major: value }))}
|
||||
placeholder="专业名称"
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label="学历"
|
||||
value={data.degree}
|
||||
onChange={(value) =>
|
||||
setData((prev) => ({ ...prev, 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 }))
|
||||
}
|
||||
type="date"
|
||||
placeholder="YYYY-MM"
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label="结束时间"
|
||||
value={data.endDate}
|
||||
onChange={(value) =>
|
||||
setData((prev) => ({ ...prev, endDate: value }))
|
||||
}
|
||||
type="date"
|
||||
placeholder="YYYY-MM"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="GPA"
|
||||
value={data.gpa || ""}
|
||||
onChange={(value) => setData((prev) => ({ ...prev, gpa: value }))}
|
||||
placeholder="选填"
|
||||
/>
|
||||
|
||||
{/* 在校经历描述 */}
|
||||
<Field
|
||||
label="在校经历"
|
||||
value={data.description}
|
||||
onChange={(value) =>
|
||||
setData((prev) => ({ ...prev, 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>
|
||||
);
|
||||
};
|
||||
|
||||
const DeleteConfirmDialog: React.FC<DeleteConfirmDialogProps> = ({
|
||||
schoolName,
|
||||
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"
|
||||
)}
|
||||
>
|
||||
您确定要删除项目 {schoolName} 吗?此操作无法撤销。
|
||||
</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 EducationItem = ({ education }: { education: Education }) => {
|
||||
const dragControls = useDragControls();
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const { theme, updateEducation, deleteEducation } = useResumeStore();
|
||||
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const handleVisibilityToggle = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (isUpdating) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
setTimeout(() => {
|
||||
updateEducation({
|
||||
...education,
|
||||
visible: !education.visible
|
||||
});
|
||||
setIsUpdating(false);
|
||||
}, 10);
|
||||
},
|
||||
[education, updateEducation, isUpdating]
|
||||
);
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
id={education.id}
|
||||
value={education}
|
||||
dragListener={false}
|
||||
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"
|
||||
)}
|
||||
>
|
||||
{/* 拖拽手柄区域 */}
|
||||
<div
|
||||
onPointerDown={(event) => {
|
||||
if (expandedId === education.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 === education.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 === education.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 === education.id &&
|
||||
(theme === "dark" ? "bg-neutral-800/50" : "bg-gray-50"),
|
||||
"cursor-pointer select-none"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (expandedId === education.id) {
|
||||
setExpandedId(null);
|
||||
} else {
|
||||
setExpandedId(education.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h3
|
||||
className={cn(
|
||||
"font-medium truncate",
|
||||
theme === "dark" ? "text-neutral-200" : "text-gray-700"
|
||||
)}
|
||||
>
|
||||
{education.school || "未填写学校"}
|
||||
</h3>
|
||||
{(education.major || education.degree) && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm truncate",
|
||||
theme === "dark" ? "text-neutral-400" : "text-gray-500"
|
||||
)}
|
||||
>
|
||||
{[education.major, education.degree]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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"
|
||||
? education.visible
|
||||
? "hover:bg-neutral-800 text-neutral-400"
|
||||
: "hover:bg-neutral-800 text-neutral-600"
|
||||
: education.visible
|
||||
? "hover:bg-gray-100 text-gray-500"
|
||||
: "hover:bg-gray-100 text-gray-400"
|
||||
)}
|
||||
onClick={handleVisibilityToggle}
|
||||
>
|
||||
{education.visible ? (
|
||||
<Eye className="w-4 h-4" />
|
||||
) : (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<DeleteConfirmDialog
|
||||
schoolName={education.school}
|
||||
onDelete={() => {
|
||||
deleteEducation(education.id);
|
||||
setExpandedId(null);
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
rotate: expandedId === education.id ? 180 : 0
|
||||
}}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"w-5 h-5",
|
||||
theme === "dark" ? "text-neutral-400" : "text-gray-500"
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{expandedId === education.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"
|
||||
)}
|
||||
/>
|
||||
<EducationEditor
|
||||
education={education}
|
||||
onSave={(updatedEducation) => {
|
||||
updateEducation(updatedEducation);
|
||||
setExpandedId(null);
|
||||
}}
|
||||
onDelete={() => {
|
||||
deleteEducation(education.id);
|
||||
setExpandedId(null);
|
||||
}}
|
||||
onCancel={() => setExpandedId(null)}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
);
|
||||
};
|
||||
|
||||
export default EducationItem;
|
||||
@@ -0,0 +1,65 @@
|
||||
"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 EducationItem from "./EducationItem";
|
||||
import { Education, Project } from "@/types/resume";
|
||||
|
||||
const ProjectPanel = () => {
|
||||
const { theme, education = [], updateEducation } = useResumeStore();
|
||||
|
||||
const handleCreateProject = () => {
|
||||
const newEducation: Education = {
|
||||
id: crypto.randomUUID(),
|
||||
school: "家里蹲大学",
|
||||
major: "",
|
||||
degree: "",
|
||||
startDate: "2015-09-01",
|
||||
endDate: "2019-06-30",
|
||||
description: "",
|
||||
visible: true
|
||||
};
|
||||
updateEducation(newEducation);
|
||||
};
|
||||
|
||||
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={education}
|
||||
onReorder={(newOrder) => {
|
||||
useResumeStore.setState({ education: newOrder });
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
{education.map((education) => (
|
||||
<EducationItem
|
||||
key={education.id}
|
||||
education={education}
|
||||
></EducationItem>
|
||||
))}
|
||||
|
||||
<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"
|
||||
)}
|
||||
>
|
||||
<PlusCircle className="w-4 h-4 mr-2" />
|
||||
添加项目
|
||||
</Button>
|
||||
</Reorder.Group>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default ProjectPanel;
|
||||
@@ -121,12 +121,7 @@ const DeleteConfirmDialog: React.FC<DeleteConfirmDialogProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const ProjectEditor: React.FC<ProjectEditorProps> = ({
|
||||
project,
|
||||
onSave,
|
||||
onDelete,
|
||||
onCancel
|
||||
}) => {
|
||||
const ProjectEditor: React.FC<ProjectEditorProps> = ({ project, onSave }) => {
|
||||
const theme = useResumeStore((state) => state.theme);
|
||||
const [data, setData] = useState<Project>(project);
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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 ProjectItem from "./ProjectItem";
|
||||
import { Project } from "@/types/resume";
|
||||
|
||||
const ProjectPanel = () => {
|
||||
const { theme, projects = [], updateProjects } = useResumeStore();
|
||||
|
||||
const handleCreateProject = () => {
|
||||
const newProject: Project = {
|
||||
id: crypto.randomUUID(),
|
||||
name: "Project Name",
|
||||
role: "Project Role",
|
||||
date: "",
|
||||
description: "",
|
||||
technologies: "",
|
||||
responsibilities: "",
|
||||
achievements: "",
|
||||
visible: true
|
||||
};
|
||||
updateProjects(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={projects}
|
||||
onReorder={(newOrder) => {
|
||||
useResumeStore.setState({ projects: newOrder });
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
{projects.map((project) => (
|
||||
<ProjectItem key={project.id} project={project}></ProjectItem>
|
||||
))}
|
||||
|
||||
<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"
|
||||
)}
|
||||
>
|
||||
<PlusCircle className="w-4 h-4 mr-2" />
|
||||
添加项目
|
||||
</Button>
|
||||
</Reorder.Group>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default ProjectPanel;
|
||||
@@ -1,3 +1,4 @@
|
||||
"use client";
|
||||
import { motion } from "framer-motion";
|
||||
import { Education, GlobalSettings } from "@/types/resume";
|
||||
import { SectionTitle } from "./SectionTitle";
|
||||
@@ -13,6 +14,9 @@ export function EducationSection({
|
||||
globalSettings,
|
||||
themeColor
|
||||
}: EducationSectionProps) {
|
||||
// 只显示visible为true的教育经历
|
||||
const visibleEducation = education.filter((edu) => edu.visible);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
@@ -26,7 +30,7 @@ export function EducationSection({
|
||||
themeColor={themeColor}
|
||||
globalSettings={globalSettings}
|
||||
/>
|
||||
{education.map((edu) => (
|
||||
{visibleEducation.map((edu) => (
|
||||
<div
|
||||
key={edu.id}
|
||||
className="space-y-2"
|
||||
@@ -35,42 +39,57 @@ export function EducationSection({
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4
|
||||
className="font-medium text-gray-800"
|
||||
style={{
|
||||
fontSize: `${globalSettings?.subheaderSize || 16}px`
|
||||
}}
|
||||
>
|
||||
{edu.school}
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4
|
||||
className="font-medium text-gray-800"
|
||||
style={{
|
||||
fontSize: `${globalSettings?.subheaderSize || 16}px`
|
||||
}}
|
||||
>
|
||||
{edu.school}
|
||||
</h4>
|
||||
{edu.location && (
|
||||
<span
|
||||
className="text-gray-600"
|
||||
style={{
|
||||
fontSize: `${globalSettings?.baseFontSize || 14}px`
|
||||
}}
|
||||
>
|
||||
· {edu.location}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className="text-gray-600"
|
||||
style={{
|
||||
fontSize: `${globalSettings?.baseFontSize || 14}px`
|
||||
}}
|
||||
>
|
||||
{edu.degree}
|
||||
{[edu.major, edu.degree].filter(Boolean).join(" · ")}
|
||||
{edu.gpa && ` · GPA ${edu.gpa}`}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className="text-gray-600"
|
||||
className="text-gray-600 shrink-0 ml-4"
|
||||
style={{
|
||||
fontSize: `${globalSettings?.baseFontSize || 14}px`
|
||||
}}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{edu.date}
|
||||
{`${new Date(edu.startDate).toLocaleDateString()} - ${new Date(edu.endDate).toLocaleDateString()}`}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="text-gray-600"
|
||||
style={{
|
||||
fontSize: `${globalSettings?.baseFontSize || 14}px`,
|
||||
lineHeight: globalSettings?.lineHeight || 1.6
|
||||
}}
|
||||
>
|
||||
{edu.details}
|
||||
</p>
|
||||
{edu.description && (
|
||||
<div
|
||||
className="text-gray-600"
|
||||
style={{
|
||||
fontSize: `${globalSettings?.baseFontSize || 14}px`,
|
||||
lineHeight: globalSettings?.lineHeight || 1.6
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: edu.description }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
@@ -161,7 +161,7 @@ export function PreviewPanel() {
|
||||
layout
|
||||
className="space-y-4"
|
||||
style={{
|
||||
marginTop: `${globalSettings?.sectionSpacing || 24}px`
|
||||
marginTop: `${globalSettings?.paragraphSpacing || 20}px`
|
||||
}}
|
||||
>
|
||||
<SectionTitle
|
||||
|
||||
@@ -19,7 +19,7 @@ export function SectionTitle({
|
||||
|
||||
return (
|
||||
<h3
|
||||
className="text-lg font-semibold border-b border-gray-200 pb-2"
|
||||
className="font-semibold border-b border-gray-200 pb-2"
|
||||
style={sectionTitleStyles}
|
||||
>
|
||||
{title}
|
||||
|
||||
@@ -34,7 +34,9 @@ interface ResumeStore {
|
||||
|
||||
// Actions
|
||||
updateBasicInfo: (data: Partial<BasicInfo>) => void;
|
||||
updateEducation: (id: string, data: Partial<Education>) => void;
|
||||
updateEducation: (data: Education) => void;
|
||||
deleteEducation: (id: string) => void;
|
||||
|
||||
updateExperience: (id: string, data: Partial<Experience>) => void;
|
||||
|
||||
// 菜单操作
|
||||
@@ -73,9 +75,15 @@ const initialState = {
|
||||
{
|
||||
id: "1",
|
||||
school: "北京大学",
|
||||
degree: "计算机科学与技术",
|
||||
date: "2016-2020",
|
||||
details: "主修课程:..."
|
||||
major: "计算机科学与技术",
|
||||
degree: "本科",
|
||||
startDate: "2019-09",
|
||||
endDate: "2023-06",
|
||||
visible: true,
|
||||
gpa: "3.8/4.0",
|
||||
location: "北京",
|
||||
description:
|
||||
"主修课程:数据结构、算法设计、操作系统、计算机网络、数据库系统\n在校期间保持专业前10%,获得优秀学生奖学金,参与多个开源项目"
|
||||
}
|
||||
],
|
||||
experience: [
|
||||
@@ -159,13 +167,6 @@ export const useResumeStore = create<ResumeStore>()(
|
||||
updateBasicInfo: (data) =>
|
||||
set((state) => ({ basic: { ...state.basic, ...data } })),
|
||||
|
||||
updateEducation: (id, data) =>
|
||||
set((state) => ({
|
||||
education: state.education.map((edu) =>
|
||||
edu.id === id ? { ...edu, ...data } : edu
|
||||
)
|
||||
})),
|
||||
|
||||
updateExperience: (id, data) =>
|
||||
set((state) => ({
|
||||
experience: state.experience.map((exp) =>
|
||||
@@ -204,11 +205,28 @@ export const useResumeStore = create<ResumeStore>()(
|
||||
return { projects: newProjects };
|
||||
}),
|
||||
|
||||
updateEducation: (education) =>
|
||||
set((state) => {
|
||||
const newEducations = state.education.some(
|
||||
(p) => p.id === education.id
|
||||
)
|
||||
? state.education.map((p) =>
|
||||
p.id === education.id ? { ...education } : p
|
||||
)
|
||||
: [...state.education, { ...education }];
|
||||
|
||||
return { education: newEducations };
|
||||
}),
|
||||
|
||||
deleteProject: (id) =>
|
||||
set((state) => ({
|
||||
projects: state.projects.filter((p) => p.id !== id)
|
||||
})),
|
||||
|
||||
deleteEducation: (id) =>
|
||||
set((state) => ({
|
||||
education: state.education.filter((p) => p.id !== id)
|
||||
})),
|
||||
toggleTheme: () =>
|
||||
set((state) => ({
|
||||
theme: state.theme === "light" ? "dark" : "light"
|
||||
|
||||
@@ -16,7 +16,6 @@ export const DEFAULT_CONFIG: PhotoConfig = {
|
||||
visible: true
|
||||
};
|
||||
|
||||
// 助手函数
|
||||
export const getRatioMultiplier = (ratio: PhotoConfig["aspectRatio"]) => {
|
||||
switch (ratio) {
|
||||
case "4:3":
|
||||
@@ -72,7 +71,7 @@ export interface BasicInfo {
|
||||
employementStatus: string;
|
||||
photo: string;
|
||||
photoConfig: PhotoConfig;
|
||||
fieldOrder?: BasicFieldType[]; // 新增字段排序
|
||||
fieldOrder?: BasicFieldType[];
|
||||
customFields: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -85,9 +84,14 @@ export interface BasicInfo {
|
||||
export interface Education {
|
||||
id: string;
|
||||
school: string;
|
||||
major: string;
|
||||
degree: string;
|
||||
date: string;
|
||||
details: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
gpa?: string;
|
||||
location?: string;
|
||||
description?: string;
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
export interface Experience {
|
||||
|
||||
Reference in New Issue
Block a user