diff --git a/apps/fronted/src/components/editor/EditPanel.tsx b/apps/fronted/src/components/editor/EditPanel.tsx index 3fb69b6..cabfa43 100644 --- a/apps/fronted/src/components/editor/EditPanel.tsx +++ b/apps/fronted/src/components/editor/EditPanel.tsx @@ -8,6 +8,7 @@ import BasicPanel from "./basic/BasicPanel"; import EducationPanel from "./education/EducationPanel"; import ProjectPanel from "./project/ProjectPanel"; import ExperiencePanel from "./experience/ExperiencePanel"; +import CustomPanel from "./custom/CustomPanel"; export function EditPanel() { const { theme, activeSection, menuSections } = useResumeStore(); @@ -24,8 +25,13 @@ export function EditPanel() { case "experience": return ; case "skills": - default: return null; + default: + if (activeSection.startsWith("custom")) { + return ; + } else { + return ; + } } }; diff --git a/apps/fronted/src/components/editor/SidePanel.tsx b/apps/fronted/src/components/editor/SidePanel.tsx index 5a47472..fce339a 100644 --- a/apps/fronted/src/components/editor/SidePanel.tsx +++ b/apps/fronted/src/components/editor/SidePanel.tsx @@ -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 ( )} + {/* 删除按钮 */} + + { + updateMenuSections( + menuSections.filter((section) => section.id !== item.id) + ); + setActiveSection(menuSections[0].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" + )} + > + + ))} + {/* 添加自定义模块 */} +
+ + 添加自定义模块 + +
{/* 主题色设置 */} diff --git a/apps/fronted/src/components/editor/custom/CustomItem.tsx b/apps/fronted/src/components/editor/custom/CustomItem.tsx new file mode 100644 index 0000000..1d85cd4 --- /dev/null +++ b/apps/fronted/src/components/editor/custom/CustomItem.tsx @@ -0,0 +1,290 @@ +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 ( +
+
+
+ handleChange("title", value)} + placeholder="标题" + /> + handleChange("subtitle", value)} + placeholder="副标题" + /> +
+ + handleChange("dateRange", value)} + placeholder="例如: 2023.01 - 2024.01" + /> + + handleChange("description", value)} + type="editor" + placeholder="请输入详细描述..." + /> +
+
+ ); +}; + +const DeleteConfirmDialog = ({ + title, + onDelete +}: { + title: string; + onDelete: () => void; +}) => { + const theme = useResumeStore((state) => state.theme); + + return ( + + + + + e.stopPropagation()} + > + + 确认删除 + + 您确定要删除 {title || "此项"} 吗?此操作无法撤销。 + + + + 取消 + + 删除 + + + + + ); +}; + +const CustomItem = ({ + item, + sectionId +}: { + item: CustomItemType; + sectionId: string; +}) => { + const dragControls = useDragControls(); + const [expandedId, setExpandedId] = useState(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 ( + +
{ + 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" + )} + > + +
+ +
+
setExpandedId(expandedId === item.id ? null : item.id)} + > +
+

+ {item.title || "未命名模块"} +

+ {item.subtitle && ( +

+ {item.subtitle} +

+ )} +
+ +
+ + { + removeCustomItem(sectionId, item.id); + setExpandedId(null); + }} + /> + + + +
+
+ + + {expandedId === item.id && ( + +
+
+ { + updateCustomItem(sectionId, item.id, updatedItem); + }} + /> +
+ + )} + +
+ + ); +}; + +export default CustomItem; diff --git a/apps/fronted/src/components/editor/custom/CustomPanel.tsx b/apps/fronted/src/components/editor/custom/CustomPanel.tsx new file mode 100644 index 0000000..1d11e64 --- /dev/null +++ b/apps/fronted/src/components/editor/custom/CustomPanel.tsx @@ -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 ( +
+ { + updateCustomData(sectionId, newOrder); + }} + className="space-y-3" + > + {items.map((item: CustomItemType) => ( + + ))} + + + +
+ ); +}); + +CustomPanel.displayName = "CustomPanel"; + +export default CustomPanel; diff --git a/apps/fronted/src/components/editor/experience/ExperiencePanel.tsx b/apps/fronted/src/components/editor/experience/ExperiencePanel.tsx index c8cb126..3390b16 100644 --- a/apps/fronted/src/components/editor/experience/ExperiencePanel.tsx +++ b/apps/fronted/src/components/editor/experience/ExperiencePanel.tsx @@ -16,7 +16,8 @@ const ExperiencePanel = () => { company: "某科技有限公司", position: "高级前端工程师", date: "2020-至今", - details: "负责公司核心产品..." + details: "负责公司核心产品...", + visible: true }; updateExperience(newProject); }; diff --git a/apps/fronted/src/components/preview/CustomSection.tsx b/apps/fronted/src/components/preview/CustomSection.tsx new file mode 100644 index 0000000..c0bad4a --- /dev/null +++ b/apps/fronted/src/components/preview/CustomSection.tsx @@ -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.description; + }); + + return ( + + + {visibleItems.map((item) => ( +
+
+
+
+

+ {item.title} +

+ + {item.subtitle} + +
+
+ {item.dateRange && ( + + {item.dateRange} + + )} +
+ {item.description && ( +
+ )} +
+ ))} + + ); +} diff --git a/apps/fronted/src/components/preview/EducationSection.tsx b/apps/fronted/src/components/preview/EducationSection.tsx index 3f7f197..07d68e1 100644 --- a/apps/fronted/src/components/preview/EducationSection.tsx +++ b/apps/fronted/src/components/preview/EducationSection.tsx @@ -14,9 +14,7 @@ export function EducationSection({ globalSettings, themeColor }: EducationSectionProps) { - // 只显示visible为true的教育经历 const visibleEducation = education.filter((edu) => edu.visible); - return ( { switch (fontFamily) { @@ -67,7 +68,8 @@ export function PreviewPanel() { globalSettings, projects, draggingProjectId, - colorTheme + colorTheme, + customData } = useResumeStore(); const previewRef = React.useRef(null); @@ -196,6 +198,19 @@ export function PreviewPanel() { }, [contentHeight]); const renderSection = (sectionId: string) => { + if (sectionId.startsWith("custom")) { + const sectionConfig = menuSections.find((s) => s.id === sectionId); + return ( + + ); + } + switch (sectionId) { case "education": return ( diff --git a/apps/fronted/src/components/preview/SectionTitle.tsx b/apps/fronted/src/components/preview/SectionTitle.tsx index 835f988..959ce9e 100644 --- a/apps/fronted/src/components/preview/SectionTitle.tsx +++ b/apps/fronted/src/components/preview/SectionTitle.tsx @@ -3,7 +3,7 @@ import { GlobalSettings } from "@/types/resume"; interface SectionTitleProps { title: string; themeColor: string; - globalSettings: GlobalSettings; + globalSettings?: GlobalSettings; } export function SectionTitle({ diff --git a/apps/fronted/src/store/useResumeStore.ts b/apps/fronted/src/store/useResumeStore.ts index fa22646..95f1c0d 100644 --- a/apps/fronted/src/store/useResumeStore.ts +++ b/apps/fronted/src/store/useResumeStore.ts @@ -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; - // 主题设置 theme: "light" | "dark"; activeSection: string; - colorTheme: string; // 当前使用的主题色 ID - + // 当前使用的主题色 ID + colorTheme: string; setColorTheme: (colorTheme: string) => void; // Actions @@ -43,6 +49,19 @@ interface ResumeStore { 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 + ) => void; + removeCustomItem: (sectionId: string, itemId: string) => void; + toggleTheme: () => void; // 全局设置 globalSettings: GlobalSettings; @@ -107,6 +126,7 @@ 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", @@ -201,6 +221,8 @@ export const useResumeStore = create()( setActiveSection: (sectionId) => set({ activeSection: sectionId }), + updateMenuSections: (sections) => set({ menuSections: sections }), + updateProjects: (project) => set((state) => { const newProjects = state.projects.some((p) => p.id === project.id) @@ -253,7 +275,77 @@ export const useResumeStore = create()( 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 + ) + } + })) }), { diff --git a/apps/fronted/src/types/resume.ts b/apps/fronted/src/types/resume.ts index 5287b30..e1a823f 100644 --- a/apps/fronted/src/types/resume.ts +++ b/apps/fronted/src/types/resume.ts @@ -140,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",