feat: experience

This commit is contained in:
JOYCEQL
2024-11-21 23:31:05 +08:00
parent 039903c8c2
commit dee3680f9e
6 changed files with 453 additions and 19 deletions
@@ -7,17 +7,7 @@ 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";
export function EditPanel() {
const { theme, activeSection, menuSections } = useResumeStore();
@@ -31,6 +21,9 @@ export function EditPanel() {
return <ProjectPanel />;
case "education":
return <EducationPanel />;
case "experience":
return <ExperiencePanel />;
case "skills":
default:
return null;
}
@@ -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
}
@@ -0,0 +1,367 @@
"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 theme = useResumeStore((state) => state.theme);
const [data, setData] = useState<Experience>(experience);
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
if (!data.company || !data.position || !data.date) 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.company}
onChange={(value) =>
setData((prev) => ({ ...prev, company: value }))
}
placeholder="项目名称"
required
/>
<Field
label="岗位"
value={data.position}
onChange={(value) =>
setData((prev) => ({ ...prev, position: value }))
}
placeholder="如:前端工程师"
required
/>
</div>
<Field
label="开始时间-结束时间"
value={data.date}
onChange={(value) => setData((prev) => ({ ...prev, date: value }))}
placeholder="如:2023.01 - 2023.06"
required
/>
<Field
label="主要职责"
value={data.details}
onChange={(value) => setData((prev) => ({ ...prev, details: 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 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",
theme === "dark"
? "bg-neutral-900/30 border-neutral-800"
: "bg-white border-gray-100"
)}
>
{/* 拖拽手柄区域 */}
<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" />
) : (
<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={(updatedData) => {
updateExperience(updatedData);
setExpandedId(null);
}}
onDelete={() => {
deleteExperience(experience.id);
setExpandedId(null);
}}
onCancel={() => setExpandedId(null)}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</Reorder.Item>
);
};
export default ExperienceItem;
@@ -0,0 +1,59 @@
"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();
console.log(1111, "experienceexperience");
const handleCreateProject = () => {
const newProject: Experience = {
id: crypto.randomUUID(),
company: "某科技有限公司",
position: "高级前端工程师",
date: "2020-至今",
details: "负责公司核心产品..."
};
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",
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 ExperiencePanel;
+19 -8
View File
@@ -37,8 +37,8 @@ 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;
@@ -165,16 +165,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) => ({
@@ -229,6 +234,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"
+1
View File
@@ -99,6 +99,7 @@ export interface Experience {
position: string;
date: string;
details: string;
visible?: boolean;
}
export interface Skill {