feat: education config

This commit is contained in:
JOYCEQL
2024-11-19 20:50:29 +08:00
parent 395578069a
commit b26456d22b
14 changed files with 651 additions and 132 deletions
@@ -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;
}
+3
View File
@@ -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()}
+1 -1
View File
@@ -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}
+29 -11
View File
@@ -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"
+8 -4
View File
@@ -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 {