From 8a4c6d5023b2471fc308b150edeef7b6fbdb3341 Mon Sep 17 00:00:00 2001
From: JOYCEQL <1449239013@qq.com>
Date: Sat, 28 Feb 2026 00:22:35 +0800
Subject: [PATCH] refactor: reorganize template system
---
src/components/preview/BaseInfo.tsx | 278 ------------------
src/components/preview/CustomSection.tsx | 127 --------
src/components/preview/EducationSection.tsx | 127 --------
src/components/preview/ExperienceSection.tsx | 131 ---------
src/components/preview/ProjectSection.tsx | 185 ------------
src/components/preview/SectionTitle.tsx | 178 -----------
src/components/preview/SkillPanel.tsx | 48 ---
src/components/templates/ClassicTemplate.tsx | 103 -------
src/components/templates/CreativeTemplate.tsx | 119 --------
src/components/templates/ElegantTemplate.tsx | 108 -------
.../templates/LeftRightTemplate.tsx | 105 -------
.../templates/MinimalistTemplate.tsx | 106 -------
src/components/templates/ModernTemplate.tsx | 111 -------
src/components/templates/TimelineTemplate.tsx | 162 ----------
src/components/templates/classic/config.ts | 23 ++
src/components/templates/classic/index.tsx | 50 ++++
.../templates/classic/sections/BaseInfo.tsx | 108 +++++++
.../classic/sections/CustomSection.tsx | 58 ++++
.../classic/sections/EducationSection.tsx | 61 ++++
.../classic/sections/ExperienceSection.tsx | 56 ++++
.../classic/sections/ProjectSection.tsx | 69 +++++
.../classic/sections/SectionTitle.tsx | 42 +++
.../classic/sections/SkillSection.tsx | 27 ++
src/components/templates/creative/config.ts | 23 ++
src/components/templates/creative/index.tsx | 66 +++++
.../templates/creative/sections/BaseInfo.tsx | 108 +++++++
.../creative/sections/CustomSection.tsx | 58 ++++
.../creative/sections/EducationSection.tsx | 61 ++++
.../creative/sections/ExperienceSection.tsx | 56 ++++
.../creative/sections/ProjectSection.tsx | 69 +++++
.../creative/sections/SectionTitle.tsx | 41 +++
.../creative/sections/SkillSection.tsx | 27 ++
src/components/templates/elegant/config.ts | 23 ++
src/components/templates/elegant/index.tsx | 52 ++++
.../templates/elegant/sections/BaseInfo.tsx | 108 +++++++
.../elegant/sections/CustomSection.tsx | 58 ++++
.../elegant/sections/EducationSection.tsx | 61 ++++
.../elegant/sections/ExperienceSection.tsx | 56 ++++
.../elegant/sections/ProjectSection.tsx | 69 +++++
.../elegant/sections/SectionTitle.tsx | 44 +++
.../elegant/sections/SkillSection.tsx | 27 ++
src/components/templates/index.tsx | 35 +--
src/components/templates/left-right/config.ts | 23 ++
src/components/templates/left-right/index.tsx | 50 ++++
.../left-right/sections/BaseInfo.tsx | 108 +++++++
.../left-right/sections/CustomSection.tsx | 58 ++++
.../left-right/sections/EducationSection.tsx | 61 ++++
.../left-right/sections/ExperienceSection.tsx | 56 ++++
.../left-right/sections/ProjectSection.tsx | 69 +++++
.../left-right/sections/SectionTitle.tsx | 44 +++
.../left-right/sections/SkillSection.tsx | 27 ++
src/components/templates/minimalist/config.ts | 23 ++
src/components/templates/minimalist/index.tsx | 50 ++++
.../minimalist/sections/BaseInfo.tsx | 108 +++++++
.../minimalist/sections/CustomSection.tsx | 58 ++++
.../minimalist/sections/EducationSection.tsx | 61 ++++
.../minimalist/sections/ExperienceSection.tsx | 56 ++++
.../minimalist/sections/ProjectSection.tsx | 69 +++++
.../minimalist/sections/SectionTitle.tsx | 40 +++
.../minimalist/sections/SkillSection.tsx | 27 ++
src/components/templates/modern/config.ts | 23 ++
src/components/templates/modern/index.tsx | 58 ++++
.../templates/modern/sections/BaseInfo.tsx | 99 +++++++
.../modern/sections/CustomSection.tsx | 58 ++++
.../modern/sections/EducationSection.tsx | 61 ++++
.../modern/sections/ExperienceSection.tsx | 56 ++++
.../modern/sections/ProjectSection.tsx | 69 +++++
.../modern/sections/SectionTitle.tsx | 42 +++
.../modern/sections/SkillSection.tsx | 27 ++
src/components/templates/registry.ts | 55 ++++
.../templates/shared/SectionWrapper.tsx | 35 +++
src/components/templates/timeline/config.ts | 23 ++
src/components/templates/timeline/index.tsx | 71 +++++
.../templates/timeline/sections/BaseInfo.tsx | 108 +++++++
.../timeline/sections/CustomSection.tsx | 58 ++++
.../timeline/sections/EducationSection.tsx | 61 ++++
.../timeline/sections/ExperienceSection.tsx | 56 ++++
.../timeline/sections/ProjectSection.tsx | 69 +++++
.../timeline/sections/SectionTitle.tsx | 41 +++
.../timeline/sections/SkillSection.tsx | 27 ++
src/config/index.ts | 155 +---------
src/config/templates.ts | 70 -----
src/types/template.ts | 2 +-
83 files changed, 3594 insertions(+), 2142 deletions(-)
delete mode 100644 src/components/preview/BaseInfo.tsx
delete mode 100644 src/components/preview/CustomSection.tsx
delete mode 100644 src/components/preview/EducationSection.tsx
delete mode 100644 src/components/preview/ExperienceSection.tsx
delete mode 100644 src/components/preview/ProjectSection.tsx
delete mode 100644 src/components/preview/SectionTitle.tsx
delete mode 100644 src/components/preview/SkillPanel.tsx
delete mode 100644 src/components/templates/ClassicTemplate.tsx
delete mode 100644 src/components/templates/CreativeTemplate.tsx
delete mode 100644 src/components/templates/ElegantTemplate.tsx
delete mode 100644 src/components/templates/LeftRightTemplate.tsx
delete mode 100644 src/components/templates/MinimalistTemplate.tsx
delete mode 100644 src/components/templates/ModernTemplate.tsx
delete mode 100644 src/components/templates/TimelineTemplate.tsx
create mode 100644 src/components/templates/classic/config.ts
create mode 100644 src/components/templates/classic/index.tsx
create mode 100644 src/components/templates/classic/sections/BaseInfo.tsx
create mode 100644 src/components/templates/classic/sections/CustomSection.tsx
create mode 100644 src/components/templates/classic/sections/EducationSection.tsx
create mode 100644 src/components/templates/classic/sections/ExperienceSection.tsx
create mode 100644 src/components/templates/classic/sections/ProjectSection.tsx
create mode 100644 src/components/templates/classic/sections/SectionTitle.tsx
create mode 100644 src/components/templates/classic/sections/SkillSection.tsx
create mode 100644 src/components/templates/creative/config.ts
create mode 100644 src/components/templates/creative/index.tsx
create mode 100644 src/components/templates/creative/sections/BaseInfo.tsx
create mode 100644 src/components/templates/creative/sections/CustomSection.tsx
create mode 100644 src/components/templates/creative/sections/EducationSection.tsx
create mode 100644 src/components/templates/creative/sections/ExperienceSection.tsx
create mode 100644 src/components/templates/creative/sections/ProjectSection.tsx
create mode 100644 src/components/templates/creative/sections/SectionTitle.tsx
create mode 100644 src/components/templates/creative/sections/SkillSection.tsx
create mode 100644 src/components/templates/elegant/config.ts
create mode 100644 src/components/templates/elegant/index.tsx
create mode 100644 src/components/templates/elegant/sections/BaseInfo.tsx
create mode 100644 src/components/templates/elegant/sections/CustomSection.tsx
create mode 100644 src/components/templates/elegant/sections/EducationSection.tsx
create mode 100644 src/components/templates/elegant/sections/ExperienceSection.tsx
create mode 100644 src/components/templates/elegant/sections/ProjectSection.tsx
create mode 100644 src/components/templates/elegant/sections/SectionTitle.tsx
create mode 100644 src/components/templates/elegant/sections/SkillSection.tsx
create mode 100644 src/components/templates/left-right/config.ts
create mode 100644 src/components/templates/left-right/index.tsx
create mode 100644 src/components/templates/left-right/sections/BaseInfo.tsx
create mode 100644 src/components/templates/left-right/sections/CustomSection.tsx
create mode 100644 src/components/templates/left-right/sections/EducationSection.tsx
create mode 100644 src/components/templates/left-right/sections/ExperienceSection.tsx
create mode 100644 src/components/templates/left-right/sections/ProjectSection.tsx
create mode 100644 src/components/templates/left-right/sections/SectionTitle.tsx
create mode 100644 src/components/templates/left-right/sections/SkillSection.tsx
create mode 100644 src/components/templates/minimalist/config.ts
create mode 100644 src/components/templates/minimalist/index.tsx
create mode 100644 src/components/templates/minimalist/sections/BaseInfo.tsx
create mode 100644 src/components/templates/minimalist/sections/CustomSection.tsx
create mode 100644 src/components/templates/minimalist/sections/EducationSection.tsx
create mode 100644 src/components/templates/minimalist/sections/ExperienceSection.tsx
create mode 100644 src/components/templates/minimalist/sections/ProjectSection.tsx
create mode 100644 src/components/templates/minimalist/sections/SectionTitle.tsx
create mode 100644 src/components/templates/minimalist/sections/SkillSection.tsx
create mode 100644 src/components/templates/modern/config.ts
create mode 100644 src/components/templates/modern/index.tsx
create mode 100644 src/components/templates/modern/sections/BaseInfo.tsx
create mode 100644 src/components/templates/modern/sections/CustomSection.tsx
create mode 100644 src/components/templates/modern/sections/EducationSection.tsx
create mode 100644 src/components/templates/modern/sections/ExperienceSection.tsx
create mode 100644 src/components/templates/modern/sections/ProjectSection.tsx
create mode 100644 src/components/templates/modern/sections/SectionTitle.tsx
create mode 100644 src/components/templates/modern/sections/SkillSection.tsx
create mode 100644 src/components/templates/registry.ts
create mode 100644 src/components/templates/shared/SectionWrapper.tsx
create mode 100644 src/components/templates/timeline/config.ts
create mode 100644 src/components/templates/timeline/index.tsx
create mode 100644 src/components/templates/timeline/sections/BaseInfo.tsx
create mode 100644 src/components/templates/timeline/sections/CustomSection.tsx
create mode 100644 src/components/templates/timeline/sections/EducationSection.tsx
create mode 100644 src/components/templates/timeline/sections/ExperienceSection.tsx
create mode 100644 src/components/templates/timeline/sections/ProjectSection.tsx
create mode 100644 src/components/templates/timeline/sections/SectionTitle.tsx
create mode 100644 src/components/templates/timeline/sections/SkillSection.tsx
delete mode 100644 src/config/templates.ts
diff --git a/src/components/preview/BaseInfo.tsx b/src/components/preview/BaseInfo.tsx
deleted file mode 100644
index ccfbc31..0000000
--- a/src/components/preview/BaseInfo.tsx
+++ /dev/null
@@ -1,278 +0,0 @@
-import React from "react";
-import { useTranslations, useLocale } from "@/i18n/compat/client";
-import { motion } from "framer-motion";
-import * as Icons from "lucide-react";
-import { cn, formatDateString } from "@/lib/utils";
-import {
- BasicInfo,
- getBorderRadiusValue,
- GlobalSettings,
-} from "@/types/resume";
-import { ResumeTemplate } from "@/types/template";
-import { useResumeStore } from "@/store/useResumeStore";
-
-interface BaseInfoProps {
- basic: BasicInfo | undefined;
- globalSettings: GlobalSettings | undefined;
- template?: ResumeTemplate;
- showTitle?: boolean;
-}
-
-const BaseInfo = ({
- basic = {} as BasicInfo,
- globalSettings,
- template,
-}: BaseInfoProps) => {
- const t = useTranslations("workbench");
- const locale = useLocale();
- const { setActiveSection } = useResumeStore();
- const useIconMode = globalSettings?.useIconMode ?? false;
- const layout = basic?.layout || "left";
-
- const getIcon = (iconName: string | undefined) => {
- const IconComponent = Icons[
- iconName as keyof typeof Icons
- ] as React.ElementType;
- return IconComponent ? : null;
- };
-
- const isModernTemplate = React.useMemo(() => {
- return template?.layout === "modern";
- }, [template]);
-
- const isWhiteTextTemplate = React.useMemo(() => {
- return template?.layout === "modern" || template?.layout === "creative";
- }, [template]);
-
- const getOrderedFields = React.useMemo(() => {
- if (!basic.fieldOrder) {
- return [
- {
- key: "email",
- value: basic.email,
- icon: basic.icons?.email || "Mail",
- label: "电子邮箱",
- visible: true,
- custom: false,
- },
- ].filter((item) => Boolean(item.value && item.visible));
- }
-
- return basic.fieldOrder
- .filter(
- (field) =>
- field.visible !== false &&
- field.key !== "name" &&
- field.key !== "title"
- )
- .map((field) => ({
- key: field.key,
- value:
- field.key === "birthDate" && basic[field.key]
- ? formatDateString(basic[field.key] as string, locale)
- : (basic[field.key] as string),
- icon: basic.icons?.[field.key] || "User",
- label: field.label,
- visible: field.visible,
- custom: field.custom,
- }))
- .filter((item) => Boolean(item.value));
- }, [basic]);
-
- const allFields = [
- ...getOrderedFields,
- ...(basic.customFields
- ?.filter((field) => field.visible !== false)
- .map((field) => ({
- key: field.id,
- value: field.value,
- icon: field.icon,
- label: field.label,
- visible: true,
- custom: true,
- })) || []),
- ];
-
- const getNameField = () => {
- const nameField = basic.fieldOrder?.find(
- (field) => field.key === "name"
- ) || {
- key: "name",
- label: "姓名",
- visible: true,
- };
- return nameField.visible !== false ? nameField : null;
- };
-
- const getTitleField = () => {
- const titleField = basic.fieldOrder?.find(
- (field) => field.key === "title"
- ) || {
- key: "title",
- label: "职位",
- visible: true,
- };
- return titleField.visible !== false ? titleField : null;
- };
-
- const nameField = getNameField();
- const titleField = getTitleField();
-
- const PhotoComponent = basic.photo && basic.photoConfig?.visible && (
-
-
-

-
-
- );
-
- // 基础样式
- const baseContainerClass =
- "hover:cursor-pointer hover:bg-gray-100 rounded-md transition-all duration-300 ease-in-out hover:shadow-md";
- const baseFieldsClass = "";
- const baseFieldItemClass =
- "flex items-center whitespace-nowrap overflow-hidden text-baseFont";
- const baseNameTitleClass = "flex flex-col";
-
- // 左对齐布局样式
- const leftLayoutStyles = {
- container: "flex items-center justify-between gap-6",
- leftContent: "flex items-center gap-6 ",
- fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start",
- nameTitle: "text-left",
- };
-
- // 右对齐布局样式
- const rightLayoutStyles = {
- container: "flex items-center justify-between gap-6 flex-row-reverse",
- leftContent: "flex justify-end items-center gap-6 ",
- fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start",
- nameTitle: "text-right",
- };
-
- // 居中布局样式
- const centerLayoutStyles = {
- container: "flex flex-col items-center gap-3",
- leftContent: "flex flex-col items-center gap-4",
- fields: "w-full flex justify-start items-center flex-wrap gap-3",
- nameTitle: "text-center",
- };
-
- // 根据布局选择样式
- const getLayoutStyles = () => {
- switch (layout) {
- case "right":
- return rightLayoutStyles;
- case "center":
- return centerLayoutStyles;
- default:
- return leftLayoutStyles;
- }
- };
-
- const layoutStyles = getLayoutStyles();
-
- const containerClass = cn(baseContainerClass, layoutStyles.container);
- const leftContentClass = cn(layoutStyles.leftContent);
- const fieldsContainerClass = cn(baseFieldsClass, layoutStyles.fields);
- const nameTitleClass = cn(baseNameTitleClass, layoutStyles.nameTitle);
-
- const NameTitleComponent = (
-
- {nameField && basic[nameField.key] && (
-
- {basic[nameField.key] as string}
-
- )}
- {titleField && basic[titleField.key] && (
-
- {basic[titleField.key] as string}
-
- )}
-
- );
-
- const FieldsComponent = (
-
- {allFields.map((item) => (
-
- {useIconMode ? (
-
- {getIcon(item.icon)}
- {item.key === "email" ? (
-
- {item.value}
-
- ) : (
-
{item.value}
- )}
-
- ) : (
-
- {!item.custom && (
- {t(`basicPanel.basicFields.${item.key}`)}:
- )}
- {item.custom && {item.label}:}
-
- {item.value}
-
-
- )}
-
- ))}
-
- );
-
- return (
- setActiveSection("basic")}>
-
- {PhotoComponent}
- {NameTitleComponent}
-
- {FieldsComponent}
-
- );
-};
-
-export default BaseInfo;
diff --git a/src/components/preview/CustomSection.tsx b/src/components/preview/CustomSection.tsx
deleted file mode 100644
index 20cf68f..0000000
--- a/src/components/preview/CustomSection.tsx
+++ /dev/null
@@ -1,127 +0,0 @@
-import { AnimatePresence, motion } from "framer-motion";
-import SectionTitle from "./SectionTitle";
-import { GlobalSettings, CustomItem } from "@/types/resume";
-import { useResumeStore } from "@/store/useResumeStore";
-import { normalizeRichTextContent } from "@/lib/richText";
-import { formatDateString } from "@/lib/utils";
-import { useLocale } from "@/i18n/compat/client";
-
-interface CustomSectionProps {
- sectionId: string;
- title: string;
- items: CustomItem[];
- globalSettings?: GlobalSettings;
- showTitle?: boolean;
-}
-
-const CustomSection = ({
- sectionId,
- title,
- items,
- globalSettings,
- showTitle = true,
-}: CustomSectionProps) => {
- const { setActiveSection } = useResumeStore();
- const locale = useLocale();
- const visibleItems = items?.filter((item) => {
- return item.visible && (item.title || item.description);
- });
-
- const centerSubtitle = globalSettings?.centerSubtitle;
- const gridColumns = centerSubtitle ? 3 : 2;
-
- return (
- {
- setActiveSection(sectionId);
- }}
- >
-
-
- {visibleItems.map((item) => (
-
-
-
-
- {item.title}
-
-
-
- {centerSubtitle && (
-
- {item.subtitle}
-
- )}
-
-
- {formatDateString(item.dateRange, locale)}
-
-
-
- {!centerSubtitle && item.subtitle && (
-
- {item.subtitle}
-
- )}
-
- {item.description && (
-
- )}
-
- ))}
-
-
- );
-};
-
-export default CustomSection;
diff --git a/src/components/preview/EducationSection.tsx b/src/components/preview/EducationSection.tsx
deleted file mode 100644
index 8b096e3..0000000
--- a/src/components/preview/EducationSection.tsx
+++ /dev/null
@@ -1,127 +0,0 @@
-import { AnimatePresence, motion } from "framer-motion";
-import { Education, GlobalSettings } from "@/types/resume";
-import SectionTitle from "./SectionTitle";
-import { useResumeStore } from "@/store/useResumeStore";
-import { useLocale } from "@/i18n/compat/client";
-import { normalizeRichTextContent } from "@/lib/richText";
-import { formatDateString } from "@/lib/utils";
-
-interface EducationSectionProps {
- education?: Education[];
- globalSettings?: GlobalSettings;
- showTitle?: boolean;
-}
-
-const EducationSection = ({
- education,
- globalSettings,
- showTitle = true,
-}: EducationSectionProps) => {
- const { setActiveSection } = useResumeStore();
- const locale = useLocale();
- const visibleEducation = education?.filter((edu) => edu.visible);
- return (
- {
- setActiveSection("education");
- }}
- >
-
-
- {visibleEducation?.map((edu) => (
-
-
-
- {edu.school}
-
-
- {globalSettings?.centerSubtitle && (
-
- {[edu.major, edu.degree].filter(Boolean).join(" · ")}
- {edu.gpa && ` · GPA ${edu.gpa}`}
-
- )}
-
-
- {`${formatDateString(edu.startDate, locale)} - ${formatDateString(
- edu.endDate,
- locale
- )}`}
-
-
-
- {!globalSettings?.centerSubtitle && (
-
- {[edu.major, edu.degree].filter(Boolean).join(" · ")}
- {edu.gpa && ` · GPA ${edu.gpa}`}
-
- )}
-
- {edu.description && (
-
- )}
-
- ))}
-
-
- );
-};
-
-export default EducationSection;
diff --git a/src/components/preview/ExperienceSection.tsx b/src/components/preview/ExperienceSection.tsx
deleted file mode 100644
index 9df9eb7..0000000
--- a/src/components/preview/ExperienceSection.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-import React from "react";
-import { motion, AnimatePresence } from "framer-motion";
-import { Experience, GlobalSettings } from "@/types/resume";
-import SectionTitle from "./SectionTitle";
-import { useResumeStore } from "@/store/useResumeStore";
-import { normalizeRichTextContent } from "@/lib/richText";
-import { formatDateString } from "@/lib/utils";
-import { useLocale } from "@/i18n/compat/client";
-
-interface ExperienceSectionProps {
- experiences?: Experience[];
- globalSettings?: GlobalSettings;
- showTitle?: boolean;
-}
-
-interface ExperienceItemProps {
- experience: Experience;
- globalSettings?: GlobalSettings;
-}
-
-const ExperienceItem = React.forwardRef(
- ({ experience, globalSettings }, ref) => {
- const centerSubtitle = globalSettings?.centerSubtitle;
- const flexLayout = globalSettings?.flexibleHeaderLayout;
- const gridColumns = centerSubtitle ? 3 : 2;
- const locale = useLocale();
-
- return (
-
-
-
- {experience.company}
-
- {centerSubtitle && (
-
- {experience.position}
-
- )}
-
- {formatDateString(experience.date, locale)}
-
-
- {experience.position && !centerSubtitle && (
-
- {experience.position}
-
- )}
- {experience.details && (
-
- )}
-
- );
- }
-);
-
-ExperienceItem.displayName = "ExperienceItem";
-
-const ExperienceSection: React.FC = ({
- experiences,
- globalSettings,
- showTitle = true,
-}) => {
- const { setActiveSection } = useResumeStore();
-
- const visibleExperiences = experiences?.filter(
- (experience) => experience.visible
- );
-
- return (
- {
- setActiveSection("experience");
- }}
- >
- {showTitle && (
-
- )}
-
-
- {visibleExperiences?.map((experience) => (
-
- ))}
-
-
-
- );
-};
-
-export default ExperienceSection;
diff --git a/src/components/preview/ProjectSection.tsx b/src/components/preview/ProjectSection.tsx
deleted file mode 100644
index fd0beb3..0000000
--- a/src/components/preview/ProjectSection.tsx
+++ /dev/null
@@ -1,185 +0,0 @@
-
-import React from "react";
-import { motion, AnimatePresence } from "framer-motion";
-import SectionTitle from "./SectionTitle";
-import { Project, GlobalSettings } from "@/types/resume";
-import { useResumeStore } from "@/store/useResumeStore";
-import { normalizeRichTextContent } from "@/lib/richText";
-import { formatDateString } from "@/lib/utils";
-import { useLocale } from "@/i18n/compat/client";
-
-interface ProjectItemProps {
- project: Project;
- globalSettings?: GlobalSettings;
-}
-
-const ProjectItem = React.forwardRef(
- ({ project, globalSettings }, ref) => {
- const centerSubtitle = globalSettings?.centerSubtitle;
- const locale = useLocale();
-
- const flexLayout = globalSettings?.flexibleHeaderLayout;
-
- return (
-
-
-
-
- {project.name}
-
-
-
- {project.link && !centerSubtitle && (
-
- {(() => {
- try {
- const url = new URL(
- project.link.startsWith("http")
- ? project.link
- : `https://${project.link}`
- );
- return url.hostname.replace(/^www\./, "");
- } catch (e) {
- return project.link;
- }
- })()}
-
- )}
-
- {!project.link && !centerSubtitle && !flexLayout && }
-
- {centerSubtitle && (
-
- {project.role}
-
- )}
-
- {formatDateString(project.date, locale)}
-
-
- {project.role && !centerSubtitle && (
-
- {project.role}
-
- )}
- {project.link && centerSubtitle && (
-
- {project.link}
-
- )}
- {project.description ? (
-
- ) : (
-
- )}
-
- );
- }
-);
-
-ProjectItem.displayName = "ProjectItem";
-
-interface ProjectSectionProps {
- projects: Project[];
- globalSettings?: GlobalSettings;
- showTitle?: boolean;
-}
-
-const ProjectSection: React.FC = ({
- projects,
- globalSettings,
- showTitle = true,
-}) => {
- const { setActiveSection } = useResumeStore();
-
- const visibleProjects = projects?.filter((project) => project.visible);
-
- return (
- {
- setActiveSection("projects");
- }}
- >
-
-
-
- {visibleProjects.map((project) => (
-
- ))}
-
-
-
- );
-};
-
-export default ProjectSection;
diff --git a/src/components/preview/SectionTitle.tsx b/src/components/preview/SectionTitle.tsx
deleted file mode 100644
index 1c09690..0000000
--- a/src/components/preview/SectionTitle.tsx
+++ /dev/null
@@ -1,178 +0,0 @@
-import { useMemo } from "react";
-import { GlobalSettings } from "@/types/resume";
-import { useResumeStore } from "@/store/useResumeStore";
-import { cn } from "@/lib/utils";
-import { templateConfigs } from "@/config/templates";
-import { useTemplateContext } from "../templates/TemplateContext";
-
-interface SectionTitleProps {
- globalSettings?: GlobalSettings;
- type: string;
- title?: string;
- showTitle?: boolean;
-}
-
-const SectionTitle = ({ type, title, globalSettings, showTitle = true }: SectionTitleProps) => {
- const { activeResume } = useResumeStore();
- const templateContext = useTemplateContext();
-
- const menuSections = templateContext?.menuSections ?? activeResume?.menuSections ?? [];
- const templateId = templateContext?.templateId ?? activeResume?.templateId ?? "default";
-
- const renderTitle = useMemo(() => {
- if (type === "custom") {
- return title;
- }
- const sectionConfig = menuSections.find((s) => s.id === type);
- return sectionConfig?.title;
- }, [menuSections, type, title]);
-
- const config =
- templateConfigs[templateId as string] || templateConfigs["default"];
- const { styles } = config.sectionTitle;
-
- const themeColor = globalSettings?.themeColor;
-
- const baseStyles = useMemo(
- () => ({
- fontSize: `${globalSettings?.headerSize || styles.fontSize}px`,
- fontWeight: "bold",
- color: themeColor,
- marginBottom: `${globalSettings?.paragraphSpacing}px`,
- }),
- [
- globalSettings?.headerSize,
- globalSettings?.paragraphSpacing,
- styles.fontSize,
- themeColor,
- ]
- );
-
- const renderTemplateTitle = () => {
- if (!showTitle) return null;
- switch (templateId) {
- case "modern":
- return (
-
- {renderTitle}
-
- );
-
- case "left-right":
- return (
-
- );
-
- case "classic":
- return (
-
- {renderTitle}
-
- );
-
- case "minimalist":
- return (
-
- {renderTitle}
-
- );
-
-
-
- case "elegant":
- return (
-
- );
-
- case "creative":
- return (
-
- {renderTitle}
-
- );
-
-
-
- default:
- return (
-
- {renderTitle}
-
- );
- }
- };
-
- return renderTemplateTitle();
-};
-
-export default SectionTitle;
diff --git a/src/components/preview/SkillPanel.tsx b/src/components/preview/SkillPanel.tsx
deleted file mode 100644
index 73b2212..0000000
--- a/src/components/preview/SkillPanel.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { motion } from "framer-motion";
-import SectionTitle from "./SectionTitle";
-import { GlobalSettings } from "@/types/resume";
-import { useResumeStore } from "@/store/useResumeStore";
-import { normalizeRichTextContent } from "@/lib/richText";
-
-interface SkillSectionProps {
- skill?: string;
- globalSettings?: GlobalSettings;
- showTitle?: boolean;
-}
-
-const SkillSection = ({ skill, globalSettings, showTitle = true }: SkillSectionProps) => {
- const { setActiveSection } = useResumeStore();
-
- return (
- {
- setActiveSection("skills");
- }}
- >
- {showTitle && }
-
-
-
-
- );
-};
-
-export default SkillSection;
diff --git a/src/components/templates/ClassicTemplate.tsx b/src/components/templates/ClassicTemplate.tsx
deleted file mode 100644
index 1f95bde..0000000
--- a/src/components/templates/ClassicTemplate.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-import React from "react";
-import GithubContribution from "@/components/shared/GithubContribution";
-import BaseInfo from "../preview/BaseInfo";
-import ExperienceSection from "../preview/ExperienceSection";
-import EducationSection from "../preview/EducationSection";
-import SkillSection from "../preview/SkillPanel";
-import CustomSection from "../preview/CustomSection";
-import { ResumeData } from "@/types/resume";
-import { ResumeTemplate } from "@/types/template";
-import ProjectSection from "../preview/ProjectSection";
-
-interface ClassicTemplateProps {
- data: ResumeData;
- template: ResumeTemplate;
-}
-
-const ClassicTemplate: React.FC = ({
- data,
- template,
-}) => {
- const { colorScheme } = template;
- const enabledSections = data.menuSections.filter(
- (section) => section.enabled
- );
- const sortedSections = [...enabledSections].sort((a, b) => a.order - b.order);
-
- const renderSection = (sectionId: string) => {
- switch (sectionId) {
- case "basic":
- return (
- <>
-
-
- {data.basic.githubContributionsVisible && (
-
- )}
- >
- );
- case "experience":
- return (
-
- );
- case "education":
- return (
-
- );
- case "skills":
- return (
-
- );
- case "projects":
- return (
-
- );
- default:
- if (sectionId in data.customData) {
- const sectionTitle = data.menuSections.find(s => s.id === sectionId)?.title || sectionId;
- return (
-
- );
- }
- return null;
- }
- };
-
- return (
-
- {sortedSections.map((section) => (
-
{renderSection(section.id)}
- ))}
-
- );
-};
-
-export default ClassicTemplate;
diff --git a/src/components/templates/CreativeTemplate.tsx b/src/components/templates/CreativeTemplate.tsx
deleted file mode 100644
index 77bbb47..0000000
--- a/src/components/templates/CreativeTemplate.tsx
+++ /dev/null
@@ -1,119 +0,0 @@
-import React from "react";
-import BaseInfo from "../preview/BaseInfo";
-import ExperienceSection from "../preview/ExperienceSection";
-import EducationSection from "../preview/EducationSection";
-import SkillSection from "../preview/SkillPanel";
-import ProjectSection from "../preview/ProjectSection";
-import CustomSection from "../preview/CustomSection";
-import { ResumeData } from "@/types/resume";
-import { ResumeTemplate } from "@/types/template";
-import GithubContribution from "@/components/shared/GithubContribution";
-
-interface CreativeTemplateProps {
- data: ResumeData;
- template: ResumeTemplate;
-}
-
-const CreativeTemplate: React.FC = ({
- data,
- template,
-}) => {
- const { colorScheme } = template;
- const enabledSections = data.menuSections.filter(
- (section) => section.enabled
- );
- const sortedSections = [...enabledSections].sort((a, b) => a.order - b.order);
-
- const renderSection = (sectionId: string) => {
- switch (sectionId) {
- case "experience":
- return (
-
- );
- case "education":
- return (
-
- );
- case "skills":
- return (
-
- );
- case "projects":
- return (
-
- );
- default:
- if (sectionId in data.customData) {
- const sectionTitle = data.menuSections.find(s => s.id === sectionId)?.title || sectionId;
- return (
-
- );
- }
- return null;
- }
- };
-
- const basicSection = sortedSections.find((section) => section.id === "basic");
- const otherSections = sortedSections.filter(
- (section) => section.id !== "basic"
- );
-
- return (
-
- {/* 顶部色块(如果存在basic区块) */}
- {basicSection && (
-
-
-
- {data.basic.githubContributionsVisible && (
-
- )}
-
-
- )}
-
- {/* 下方内容区 */}
-
- {otherSections.map((section) => (
-
{renderSection(section.id)}
- ))}
-
-
- );
-};
-
-export default CreativeTemplate;
diff --git a/src/components/templates/ElegantTemplate.tsx b/src/components/templates/ElegantTemplate.tsx
deleted file mode 100644
index c161dec..0000000
--- a/src/components/templates/ElegantTemplate.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-import React from "react";
-import BaseInfo from "../preview/BaseInfo";
-import ExperienceSection from "../preview/ExperienceSection";
-import EducationSection from "../preview/EducationSection";
-import SkillSection from "../preview/SkillPanel";
-import ProjectSection from "../preview/ProjectSection";
-import CustomSection from "../preview/CustomSection";
-import { ResumeData } from "@/types/resume";
-import { ResumeTemplate } from "@/types/template";
-import GithubContribution from "@/components/shared/GithubContribution";
-
-interface ElegantTemplateProps {
- data: ResumeData;
- template: ResumeTemplate;
-}
-
-const ElegantTemplate: React.FC = ({
- data,
- template,
-}) => {
- const { colorScheme } = template;
- const enabledSections = data.menuSections.filter(
- (section) => section.enabled
- );
- const sortedSections = [...enabledSections].sort((a, b) => a.order - b.order);
-
- const renderSection = (sectionId: string) => {
- switch (sectionId) {
- case "basic":
- return (
-
-
-
- {data.basic.githubContributionsVisible && (
-
- )}
-
- );
- case "experience":
- return (
-
- );
- case "education":
- return (
-
- );
- case "skills":
- return (
-
- );
- case "projects":
- return (
-
- );
- default:
- if (sectionId in data.customData) {
- const sectionTitle = data.menuSections.find(s => s.id === sectionId)?.title || sectionId;
- return (
-
- );
- }
- return null;
- }
- };
-
- return (
-
- {/* 优雅版采用单行且较为居中的排版风格 */}
-
- {sortedSections.map((section) => (
-
- {renderSection(section.id)}
-
- ))}
-
-
- );
-};
-
-export default ElegantTemplate;
diff --git a/src/components/templates/LeftRightTemplate.tsx b/src/components/templates/LeftRightTemplate.tsx
deleted file mode 100644
index 015051c..0000000
--- a/src/components/templates/LeftRightTemplate.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import BaseInfo from "../preview/BaseInfo";
-import ExperienceSection from "../preview/ExperienceSection";
-import EducationSection from "../preview/EducationSection";
-import SkillSection from "../preview/SkillPanel";
-import ProjectSection from "../preview/ProjectSection";
-import CustomSection from "../preview/CustomSection";
-import { ResumeData } from "@/types/resume";
-import { ResumeTemplate } from "@/types/template";
-import GithubContribution from "../shared/GithubContribution";
-
-interface LeftRightTemplateProps {
- data: ResumeData;
- template: ResumeTemplate;
-}
-
-const LeftRightTemplate: React.FC = ({
- data,
- template,
-}) => {
- const { colorScheme } = template;
- const enabledSections = data.menuSections.filter(
- (section) => section.enabled
- );
- const sortedSections = [...enabledSections].sort((a, b) => a.order - b.order);
-
- const renderSection = (sectionId: string) => {
- switch (sectionId) {
- case "basic":
- return (
- <>
-
- {data.basic.githubContributionsVisible && (
-
- )}
- >
- );
- case "experience":
- return (
-
- );
- case "education":
- return (
-
- );
- case "skills":
- return (
-
- );
- case "projects":
- return (
-
- );
- default:
- if (sectionId in data.customData) {
- const sectionTitle = data.menuSections.find(s => s.id === sectionId)?.title || sectionId;
- return (
-
- );
- }
- return null;
- }
- };
-
- return (
-
- {sortedSections.map((section) => (
-
{renderSection(section.id)}
- ))}
-
- );
-};
-
-export default LeftRightTemplate;
diff --git a/src/components/templates/MinimalistTemplate.tsx b/src/components/templates/MinimalistTemplate.tsx
deleted file mode 100644
index fc6c69b..0000000
--- a/src/components/templates/MinimalistTemplate.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import React from "react";
-import GithubContribution from "@/components/shared/GithubContribution";
-import BaseInfo from "../preview/BaseInfo";
-import ExperienceSection from "../preview/ExperienceSection";
-import EducationSection from "../preview/EducationSection";
-import SkillSection from "../preview/SkillPanel";
-import CustomSection from "../preview/CustomSection";
-import { ResumeData } from "@/types/resume";
-import { ResumeTemplate } from "@/types/template";
-import ProjectSection from "../preview/ProjectSection";
-
-interface MinimalistTemplateProps {
- data: ResumeData;
- template: ResumeTemplate;
-}
-
-const MinimalistTemplate: React.FC = ({
- data,
- template,
-}) => {
- const { colorScheme } = template;
- const enabledSections = data.menuSections.filter(
- (section) => section.enabled
- );
- const sortedSections = [...enabledSections].sort((a, b) => a.order - b.order);
-
- const renderSection = (sectionId: string) => {
- switch (sectionId) {
- case "basic":
- return (
- <>
-
-
- {data.basic.githubContributionsVisible && (
-
- )}
- >
- );
- case "experience":
- return (
-
- );
- case "education":
- return (
-
- );
- case "skills":
- return (
-
- );
- case "projects":
- return (
-
- );
- default:
- if (sectionId in data.customData) {
- const sectionTitle = data.menuSections.find(s => s.id === sectionId)?.title || sectionId;
- return (
-
- );
- }
- return null;
- }
- };
-
- return (
-
- {/* 极简风格:单列居中体验,更大留白 */}
- {sortedSections.map((section) => (
-
- {renderSection(section.id)}
-
- ))}
-
- );
-};
-
-export default MinimalistTemplate;
diff --git a/src/components/templates/ModernTemplate.tsx b/src/components/templates/ModernTemplate.tsx
deleted file mode 100644
index c6f6409..0000000
--- a/src/components/templates/ModernTemplate.tsx
+++ /dev/null
@@ -1,111 +0,0 @@
-import React from "react";
-import BaseInfo from "../preview/BaseInfo";
-import ExperienceSection from "../preview/ExperienceSection";
-import EducationSection from "../preview/EducationSection";
-import SkillSection from "../preview/SkillPanel";
-import ProjectSection from "../preview/ProjectSection";
-import CustomSection from "../preview/CustomSection";
-import { ResumeData } from "@/types/resume";
-import { ResumeTemplate } from "@/types/template";
-
-interface ModernTemplateProps {
- data: ResumeData;
- template: ResumeTemplate;
-}
-
-const ModernTemplate: React.FC = ({ data, template }) => {
- const { colorScheme } = template;
- const enabledSections = data.menuSections.filter(
- (section) => section.enabled
- );
- const sortedSections = [...enabledSections].sort((a, b) => a.order - b.order);
-
- const renderSection = (sectionId: string) => {
- switch (sectionId) {
- case "basic":
- return (
-
- );
- case "experience":
- return (
-
- );
- case "education":
- return (
-
- );
- case "skills":
- return (
-
- );
- case "projects":
- return (
-
- );
- default:
- if (sectionId in data.customData) {
- const sectionTitle = data.menuSections.find(s => s.id === sectionId)?.title || sectionId;
- return (
-
- );
- }
- return null;
- }
- };
-
- const basicSection = sortedSections.find((section) => section.id === "basic");
- const otherSections = sortedSections.filter(
- (section) => section.id !== "basic"
- );
-
- return (
-
-
- {basicSection && renderSection(basicSection.id)}
-
-
-
- {otherSections.map((section) => (
-
{renderSection(section.id)}
- ))}
-
-
- );
-};
-
-export default ModernTemplate;
diff --git a/src/components/templates/TimelineTemplate.tsx b/src/components/templates/TimelineTemplate.tsx
deleted file mode 100644
index 169a638..0000000
--- a/src/components/templates/TimelineTemplate.tsx
+++ /dev/null
@@ -1,162 +0,0 @@
-import React from "react";
-import GithubContribution from "@/components/shared/GithubContribution";
-import BaseInfo from "../preview/BaseInfo";
-import ExperienceSection from "../preview/ExperienceSection";
-import EducationSection from "../preview/EducationSection";
-import SkillSection from "../preview/SkillPanel";
-import CustomSection from "../preview/CustomSection";
-import { ResumeData } from "@/types/resume";
-import { ResumeTemplate } from "@/types/template";
-import ProjectSection from "../preview/ProjectSection";
-
-interface TimelineTemplateProps {
- data: ResumeData;
- template: ResumeTemplate;
-}
-
-const TimelineTemplate: React.FC = ({
- data,
- template,
-}) => {
- const { colorScheme } = template;
- const enabledSections = data.menuSections.filter(
- (section) => section.enabled
- );
- const sortedSections = [...enabledSections].sort((a, b) => a.order - b.order);
-
- const renderTimelineItem = (content: React.ReactNode, title: string) => (
-
-
-
-
- {title}
-
-
{content}
-
- );
-
- const renderSection = (sectionId: string) => {
- const sectionTitle =
- data.menuSections.find((s) => s.id === sectionId)?.title || sectionId;
-
- switch (sectionId) {
- case "basic":
- return (
- <>
-
- {data.basic.githubContributionsVisible && (
-
- )}
- >
- );
- case "experience":
- return (
-
- {renderTimelineItem(
- ,
- sectionTitle
- )}
-
- );
- case "education":
- return (
-
- {renderTimelineItem(
- ,
- sectionTitle
- )}
-
- );
- case "skills":
- return (
-
- {renderTimelineItem(
- ,
- sectionTitle
- )}
-
- );
- case "projects":
- return (
-
- {renderTimelineItem(
-
,
- sectionTitle
- )}
-
- );
- default:
- if (sectionId in data.customData) {
- return (
-
- {renderTimelineItem(
- ,
- sectionTitle
- )}
-
- );
- }
- return null;
- }
- };
-
- return (
-
- {sortedSections.map((section) => (
-
- {renderSection(section.id)}
-
- ))}
-
- );
-};
-
-export default TimelineTemplate;
diff --git a/src/components/templates/classic/config.ts b/src/components/templates/classic/config.ts
new file mode 100644
index 0000000..f342405
--- /dev/null
+++ b/src/components/templates/classic/config.ts
@@ -0,0 +1,23 @@
+import { ResumeTemplate } from "@/types/template";
+
+export const classicConfig: ResumeTemplate = {
+ id: "classic",
+ name: "经典模板",
+ description: "传统简约的简历布局,适合大多数求职场景",
+ thumbnail: "classic",
+ layout: "classic",
+ colorScheme: {
+ primary: "#000000",
+ secondary: "#4b5563",
+ background: "#ffffff",
+ text: "#212529",
+ },
+ spacing: {
+ sectionGap: 24,
+ itemGap: 16,
+ contentPadding: 32,
+ },
+ basic: {
+ layout: "center",
+ },
+};
diff --git a/src/components/templates/classic/index.tsx b/src/components/templates/classic/index.tsx
new file mode 100644
index 0000000..75d74bb
--- /dev/null
+++ b/src/components/templates/classic/index.tsx
@@ -0,0 +1,50 @@
+import React from "react";
+import { ResumeData } from "@/types/resume";
+import { ResumeTemplate } from "@/types/template";
+import BaseInfo from "./sections/BaseInfo";
+import ExperienceSection from "./sections/ExperienceSection";
+import EducationSection from "./sections/EducationSection";
+import ProjectSection from "./sections/ProjectSection";
+import SkillSection from "./sections/SkillSection";
+import CustomSection from "./sections/CustomSection";
+
+interface ClassicTemplateProps {
+ data: ResumeData;
+ template: ResumeTemplate;
+}
+
+const ClassicTemplate: React.FC = ({ data, template }) => {
+ const { colorScheme } = template;
+ const enabledSections = data.menuSections.filter((s) => s.enabled).sort((a, b) => a.order - b.order);
+
+ const renderSection = (sectionId: string) => {
+ switch (sectionId) {
+ case "basic":
+ return ;
+ case "experience":
+ return ;
+ case "education":
+ return ;
+ case "skills":
+ return ;
+ case "projects":
+ return ;
+ default:
+ if (sectionId in data.customData) {
+ const sectionTitle = data.menuSections.find((s) => s.id === sectionId)?.title || sectionId;
+ return ;
+ }
+ return null;
+ }
+ };
+
+ return (
+
+ {enabledSections.map((section) => (
+
{renderSection(section.id)}
+ ))}
+
+ );
+};
+
+export default ClassicTemplate;
diff --git a/src/components/templates/classic/sections/BaseInfo.tsx b/src/components/templates/classic/sections/BaseInfo.tsx
new file mode 100644
index 0000000..e62b10c
--- /dev/null
+++ b/src/components/templates/classic/sections/BaseInfo.tsx
@@ -0,0 +1,108 @@
+import React from "react";
+import { motion } from "framer-motion";
+import * as Icons from "lucide-react";
+import { cn, formatDateString } from "@/lib/utils";
+import { BasicInfo, getBorderRadiusValue, GlobalSettings } from "@/types/resume";
+import { ResumeTemplate } from "@/types/template";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { useTranslations, useLocale } from "@/i18n/compat/client";
+import GithubContribution from "@/components/shared/GithubContribution";
+
+interface BaseInfoProps {
+ basic: BasicInfo | undefined;
+ globalSettings: GlobalSettings | undefined;
+ template?: ResumeTemplate;
+}
+
+const BaseInfo = ({ basic = {} as BasicInfo, globalSettings, template }: BaseInfoProps) => {
+ const t = useTranslations("workbench");
+ const locale = useLocale();
+ const useIconMode = globalSettings?.useIconMode ?? false;
+ const layout = basic?.layout || "left";
+
+ const getIcon = (iconName: string | undefined) => {
+ const IconComponent = Icons[iconName as keyof typeof Icons] as React.ElementType;
+ return IconComponent ? : null;
+ };
+
+ const getOrderedFields = React.useMemo(() => {
+ if (!basic.fieldOrder) {
+ return [{ key: "email", value: basic.email, icon: basic.icons?.email || "Mail", label: "电子邮箱", visible: true, custom: false }]
+ .filter((item) => Boolean(item.value && item.visible));
+ }
+ return basic.fieldOrder
+ .filter((field) => field.visible !== false && field.key !== "name" && field.key !== "title")
+ .map((field) => ({
+ key: field.key, value: field.key === "birthDate" && basic[field.key] ? formatDateString(basic[field.key] as string, locale) : (basic[field.key] as string),
+ icon: basic.icons?.[field.key] || "User", label: field.label, visible: field.visible, custom: field.custom,
+ }))
+ .filter((item) => Boolean(item.value));
+ }, [basic]);
+
+ const allFields = [
+ ...getOrderedFields,
+ ...(basic.customFields?.filter((field) => field.visible !== false).map((field) => ({
+ key: field.id, value: field.value, icon: field.icon, label: field.label, visible: true, custom: true,
+ })) || []),
+ ];
+
+ const nameField = basic.fieldOrder?.find((f) => f.key === "name") || { key: "name", label: "姓名", visible: true };
+ const titleField = basic.fieldOrder?.find((f) => f.key === "title") || { key: "title", label: "职位", visible: true };
+
+ const PhotoComponent = basic.photo && basic.photoConfig?.visible && (
+
+
+

+
+
+ );
+
+ const layoutStyles = {
+ left: { container: "flex items-center justify-between gap-6", leftContent: "flex items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-left" },
+ right: { container: "flex items-center justify-between gap-6 flex-row-reverse", leftContent: "flex justify-end items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-right" },
+ center: { container: "flex flex-col items-center gap-3", leftContent: "flex flex-col items-center gap-4", fields: "w-full flex justify-start items-center flex-wrap gap-3", nameTitle: "text-center" },
+ };
+
+ const styles = layoutStyles[layout as keyof typeof layoutStyles] || layoutStyles.left;
+
+ return (
+
+
+
+ {PhotoComponent}
+
+ {nameField.visible !== false && basic[nameField.key] && (
+ {basic[nameField.key] as string}
+ )}
+ {titleField.visible !== false && basic[titleField.key] && (
+ {basic[titleField.key] as string}
+ )}
+
+
+
+ {allFields.map((item) => (
+
+ {useIconMode ? (
+
+ {getIcon(item.icon)}
+ {item.key === "email" ?
{item.value} :
{item.value}}
+
+ ) : (
+
+ {!item.custom && {t(`basicPanel.basicFields.${item.key}`)}:}
+ {item.custom && {item.label}:}
+ {item.value}
+
+ )}
+
+ ))}
+
+
+ {basic.githubContributionsVisible && (
+
+ )}
+
+ );
+};
+
+export default BaseInfo;
diff --git a/src/components/templates/classic/sections/CustomSection.tsx b/src/components/templates/classic/sections/CustomSection.tsx
new file mode 100644
index 0000000..ff2f970
--- /dev/null
+++ b/src/components/templates/classic/sections/CustomSection.tsx
@@ -0,0 +1,58 @@
+import { AnimatePresence, motion } from "framer-motion";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { GlobalSettings, CustomItem } from "@/types/resume";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+import { useLocale } from "@/i18n/compat/client";
+
+interface CustomSectionProps {
+ sectionId: string;
+ title: string;
+ items: CustomItem[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const CustomSection = ({ sectionId, title, items, globalSettings, showTitle = true }: CustomSectionProps) => {
+ const locale = useLocale();
+ const visibleItems = items?.filter((item) => item.visible && (item.title || item.description));
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+ {visibleItems.map((item) => (
+
+
+
+
{item.title}
+
+ {centerSubtitle && (
+
+ {item.subtitle}
+
+ )}
+
+ {formatDateString(item.dateRange, locale)}
+
+
+ {!centerSubtitle && item.subtitle && (
+ {item.subtitle}
+ )}
+ {item.description && (
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+export default CustomSection;
diff --git a/src/components/templates/classic/sections/EducationSection.tsx b/src/components/templates/classic/sections/EducationSection.tsx
new file mode 100644
index 0000000..d49d094
--- /dev/null
+++ b/src/components/templates/classic/sections/EducationSection.tsx
@@ -0,0 +1,61 @@
+import { AnimatePresence, motion } from "framer-motion";
+import { Education, GlobalSettings } from "@/types/resume";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { useLocale } from "@/i18n/compat/client";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+
+interface EducationSectionProps {
+ education?: Education[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const EducationSection = ({ education, globalSettings, showTitle = true }: EducationSectionProps) => {
+ const locale = useLocale();
+ const visibleEducation = education?.filter((edu) => edu.visible);
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+ {visibleEducation?.map((edu) => (
+
+
+
+ {edu.school}
+
+ {centerSubtitle && (
+
+ {[edu.major, edu.degree].filter(Boolean).join(" · ")}
+ {edu.gpa && ` · GPA ${edu.gpa}`}
+
+ )}
+
+ {`${formatDateString(edu.startDate, locale)} - ${formatDateString(edu.endDate, locale)}`}
+
+
+ {!centerSubtitle && (
+
+ {[edu.major, edu.degree].filter(Boolean).join(" · ")}
+ {edu.gpa && ` · GPA ${edu.gpa}`}
+
+ )}
+ {edu.description && (
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+export default EducationSection;
diff --git a/src/components/templates/classic/sections/ExperienceSection.tsx b/src/components/templates/classic/sections/ExperienceSection.tsx
new file mode 100644
index 0000000..bb4d3ba
--- /dev/null
+++ b/src/components/templates/classic/sections/ExperienceSection.tsx
@@ -0,0 +1,56 @@
+import React from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { Experience, GlobalSettings } from "@/types/resume";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+import { useLocale } from "@/i18n/compat/client";
+
+interface ExperienceSectionProps {
+ experiences?: Experience[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const ExperienceSection: React.FC = ({ experiences, globalSettings, showTitle = true }) => {
+ const locale = useLocale();
+ const visibleExperiences = experiences?.filter((exp) => exp.visible);
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+ {visibleExperiences?.map((exp) => (
+
+
+
+ {exp.company}
+
+ {centerSubtitle && (
+
+ {exp.position}
+
+ )}
+
+ {formatDateString(exp.date, locale)}
+
+
+ {exp.position && !centerSubtitle && (
+ {exp.position}
+ )}
+ {exp.details && (
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+export default ExperienceSection;
diff --git a/src/components/templates/classic/sections/ProjectSection.tsx b/src/components/templates/classic/sections/ProjectSection.tsx
new file mode 100644
index 0000000..3607f72
--- /dev/null
+++ b/src/components/templates/classic/sections/ProjectSection.tsx
@@ -0,0 +1,69 @@
+import React from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { Project, GlobalSettings } from "@/types/resume";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+import { useLocale } from "@/i18n/compat/client";
+
+interface ProjectSectionProps {
+ projects: Project[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const ProjectSection: React.FC = ({ projects, globalSettings, showTitle = true }) => {
+ const locale = useLocale();
+ const visibleProjects = projects?.filter((p) => p.visible);
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+
+ {visibleProjects.map((project) => (
+
+
+
+
{project.name}
+
+ {project.link && !centerSubtitle && (
+
+ {(() => { try { return new URL(project.link.startsWith("http") ? project.link : `https://${project.link}`).hostname.replace(/^www\./, ""); } catch { return project.link; } })()}
+
+ )}
+ {!project.link && !centerSubtitle && !flexLayout && }
+ {centerSubtitle && (
+
+ {project.role}
+
+ )}
+
+ {formatDateString(project.date, locale)}
+
+
+ {project.role && !centerSubtitle && (
+ {project.role}
+ )}
+ {project.link && centerSubtitle && (
+ {project.link}
+ )}
+ {project.description && (
+
+ )}
+
+ ))}
+
+
+
+ );
+};
+
+export default ProjectSection;
diff --git a/src/components/templates/classic/sections/SectionTitle.tsx b/src/components/templates/classic/sections/SectionTitle.tsx
new file mode 100644
index 0000000..632ff42
--- /dev/null
+++ b/src/components/templates/classic/sections/SectionTitle.tsx
@@ -0,0 +1,42 @@
+import { useMemo } from "react";
+import { GlobalSettings } from "@/types/resume";
+import { useTemplateContext } from "../../TemplateContext";
+import { useResumeStore } from "@/store/useResumeStore";
+
+interface SectionTitleProps {
+ globalSettings?: GlobalSettings;
+ type: string;
+ title?: string;
+ showTitle?: boolean;
+}
+
+const SectionTitle = ({ type, title, globalSettings, showTitle = true }: SectionTitleProps) => {
+ const { activeResume } = useResumeStore();
+ const templateContext = useTemplateContext();
+ const menuSections = templateContext?.menuSections ?? activeResume?.menuSections ?? [];
+
+ const renderTitle = useMemo(() => {
+ if (type === "custom") return title;
+ return menuSections.find((s) => s.id === type)?.title;
+ }, [menuSections, type, title]);
+
+ const themeColor = globalSettings?.themeColor;
+
+ if (!showTitle) return null;
+
+ return (
+
+ {renderTitle}
+
+ );
+};
+
+export default SectionTitle;
diff --git a/src/components/templates/classic/sections/SkillSection.tsx b/src/components/templates/classic/sections/SkillSection.tsx
new file mode 100644
index 0000000..6721caf
--- /dev/null
+++ b/src/components/templates/classic/sections/SkillSection.tsx
@@ -0,0 +1,27 @@
+import { motion } from "framer-motion";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { GlobalSettings } from "@/types/resume";
+import { normalizeRichTextContent } from "@/lib/richText";
+
+interface SkillSectionProps {
+ skill?: string;
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const SkillSection = ({ skill, globalSettings, showTitle = true }: SkillSectionProps) => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default SkillSection;
diff --git a/src/components/templates/creative/config.ts b/src/components/templates/creative/config.ts
new file mode 100644
index 0000000..bbeffa4
--- /dev/null
+++ b/src/components/templates/creative/config.ts
@@ -0,0 +1,23 @@
+import { ResumeTemplate } from "@/types/template";
+
+export const creativeConfig: ResumeTemplate = {
+ id: "creative",
+ name: "创意模板",
+ description: "视觉错落设计,灵动活泼展现个性",
+ thumbnail: "creative",
+ layout: "creative",
+ colorScheme: {
+ primary: "#18181b",
+ secondary: "#64748b",
+ background: "#ffffff",
+ text: "#1e293b",
+ },
+ spacing: {
+ sectionGap: 24,
+ itemGap: 16,
+ contentPadding: 28,
+ },
+ basic: {
+ layout: "left",
+ },
+};
diff --git a/src/components/templates/creative/index.tsx b/src/components/templates/creative/index.tsx
new file mode 100644
index 0000000..7d0332b
--- /dev/null
+++ b/src/components/templates/creative/index.tsx
@@ -0,0 +1,66 @@
+import React from "react";
+import { ResumeData } from "@/types/resume";
+import { ResumeTemplate } from "@/types/template";
+import BaseInfo from "./sections/BaseInfo";
+import GithubContribution from "@/components/shared/GithubContribution";
+import ExperienceSection from "./sections/ExperienceSection";
+import EducationSection from "./sections/EducationSection";
+import ProjectSection from "./sections/ProjectSection";
+import SkillSection from "./sections/SkillSection";
+import CustomSection from "./sections/CustomSection";
+
+interface CreativeTemplateProps {
+ data: ResumeData;
+ template: ResumeTemplate;
+}
+
+const CreativeTemplate: React.FC = ({ data, template }) => {
+ const { colorScheme } = template;
+ const enabledSections = data.menuSections.filter((s) => s.enabled).sort((a, b) => a.order - b.order);
+
+ const basicSection = enabledSections.find((s) => s.id === "basic");
+ const otherSections = enabledSections.filter((s) => s.id !== "basic");
+
+ const renderSection = (sectionId: string) => {
+ switch (sectionId) {
+ case "experience":
+ return ;
+ case "education":
+ return ;
+ case "skills":
+ return ;
+ case "projects":
+ return ;
+ default:
+ if (sectionId in data.customData) {
+ const title = data.menuSections.find((s) => s.id === sectionId)?.title || sectionId;
+ return ;
+ }
+ return null;
+ }
+ };
+
+ return (
+
+ {/* Top colored header block */}
+ {basicSection && (
+
+
+
+ {data.basic.githubContributionsVisible && (
+
+ )}
+
+
+ )}
+ {/* Content sections */}
+
+ {otherSections.map((section) => (
+
{renderSection(section.id)}
+ ))}
+
+
+ );
+};
+
+export default CreativeTemplate;
diff --git a/src/components/templates/creative/sections/BaseInfo.tsx b/src/components/templates/creative/sections/BaseInfo.tsx
new file mode 100644
index 0000000..64c9737
--- /dev/null
+++ b/src/components/templates/creative/sections/BaseInfo.tsx
@@ -0,0 +1,108 @@
+import React from "react";
+import { motion } from "framer-motion";
+import * as Icons from "lucide-react";
+import { cn, formatDateString } from "@/lib/utils";
+import { BasicInfo, getBorderRadiusValue, GlobalSettings } from "@/types/resume";
+import { ResumeTemplate } from "@/types/template";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { useTranslations, useLocale } from "@/i18n/compat/client";
+
+interface BaseInfoProps {
+ basic: BasicInfo | undefined;
+ globalSettings: GlobalSettings | undefined;
+ template?: ResumeTemplate;
+}
+
+/**
+ * Creative template BaseInfo — white text, designed for the colored header block.
+ */
+const BaseInfo = ({ basic = {} as BasicInfo, globalSettings, template }: BaseInfoProps) => {
+ const t = useTranslations("workbench");
+ const locale = useLocale();
+ const useIconMode = globalSettings?.useIconMode ?? false;
+ const layout = basic?.layout || "left";
+
+ const getIcon = (iconName: string | undefined) => {
+ const IconComponent = Icons[iconName as keyof typeof Icons] as React.ElementType;
+ return IconComponent ? : null;
+ };
+
+ const getOrderedFields = React.useMemo(() => {
+ if (!basic.fieldOrder) {
+ return [{ key: "email", value: basic.email, icon: basic.icons?.email || "Mail", label: "电子邮箱", visible: true, custom: false }]
+ .filter((item) => Boolean(item.value && item.visible));
+ }
+ return basic.fieldOrder
+ .filter((field) => field.visible !== false && field.key !== "name" && field.key !== "title")
+ .map((field) => ({
+ key: field.key, value: field.key === "birthDate" && basic[field.key] ? formatDateString(basic[field.key] as string, locale) : (basic[field.key] as string),
+ icon: basic.icons?.[field.key] || "User", label: field.label, visible: field.visible, custom: field.custom,
+ }))
+ .filter((item) => Boolean(item.value));
+ }, [basic]);
+
+ const allFields = [
+ ...getOrderedFields,
+ ...(basic.customFields?.filter((field) => field.visible !== false).map((field) => ({
+ key: field.id, value: field.value, icon: field.icon, label: field.label, visible: true, custom: true,
+ })) || []),
+ ];
+
+ const nameField = basic.fieldOrder?.find((f) => f.key === "name") || { key: "name", visible: true };
+ const titleField = basic.fieldOrder?.find((f) => f.key === "title") || { key: "title", visible: true };
+
+ const PhotoComponent = basic.photo && basic.photoConfig?.visible && (
+
+
+

+
+
+ );
+
+ const layoutStyles = {
+ left: { container: "flex items-center justify-between gap-6", leftContent: "flex items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-left" },
+ right: { container: "flex items-center justify-between gap-6 flex-row-reverse", leftContent: "flex justify-end items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-right" },
+ center: { container: "flex flex-col items-center gap-3", leftContent: "flex flex-col items-center gap-4", fields: "w-full flex justify-start items-center flex-wrap gap-3", nameTitle: "text-center" },
+ };
+
+ const styles = layoutStyles[layout as keyof typeof layoutStyles] || layoutStyles.left;
+
+ return (
+
+
+
+ {PhotoComponent}
+
+ {nameField.visible !== false && basic[nameField.key] && (
+ {basic[nameField.key] as string}
+ )}
+ {titleField.visible !== false && basic[titleField.key] && (
+ {basic[titleField.key] as string}
+ )}
+
+
+
+ {allFields.map((item) => (
+
+ {useIconMode ? (
+
+ {getIcon(item.icon)}
+ {item.key === "email" ?
{item.value} :
{item.value}}
+
+ ) : (
+
+ {!item.custom && {t(`basicPanel.basicFields.${item.key}`)}:}
+ {item.custom && {item.label}:}
+ {item.value}
+
+ )}
+
+ ))}
+
+
+
+ );
+};
+
+export default BaseInfo;
diff --git a/src/components/templates/creative/sections/CustomSection.tsx b/src/components/templates/creative/sections/CustomSection.tsx
new file mode 100644
index 0000000..ff2f970
--- /dev/null
+++ b/src/components/templates/creative/sections/CustomSection.tsx
@@ -0,0 +1,58 @@
+import { AnimatePresence, motion } from "framer-motion";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { GlobalSettings, CustomItem } from "@/types/resume";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+import { useLocale } from "@/i18n/compat/client";
+
+interface CustomSectionProps {
+ sectionId: string;
+ title: string;
+ items: CustomItem[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const CustomSection = ({ sectionId, title, items, globalSettings, showTitle = true }: CustomSectionProps) => {
+ const locale = useLocale();
+ const visibleItems = items?.filter((item) => item.visible && (item.title || item.description));
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+ {visibleItems.map((item) => (
+
+
+
+
{item.title}
+
+ {centerSubtitle && (
+
+ {item.subtitle}
+
+ )}
+
+ {formatDateString(item.dateRange, locale)}
+
+
+ {!centerSubtitle && item.subtitle && (
+ {item.subtitle}
+ )}
+ {item.description && (
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+export default CustomSection;
diff --git a/src/components/templates/creative/sections/EducationSection.tsx b/src/components/templates/creative/sections/EducationSection.tsx
new file mode 100644
index 0000000..d49d094
--- /dev/null
+++ b/src/components/templates/creative/sections/EducationSection.tsx
@@ -0,0 +1,61 @@
+import { AnimatePresence, motion } from "framer-motion";
+import { Education, GlobalSettings } from "@/types/resume";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { useLocale } from "@/i18n/compat/client";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+
+interface EducationSectionProps {
+ education?: Education[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const EducationSection = ({ education, globalSettings, showTitle = true }: EducationSectionProps) => {
+ const locale = useLocale();
+ const visibleEducation = education?.filter((edu) => edu.visible);
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+ {visibleEducation?.map((edu) => (
+
+
+
+ {edu.school}
+
+ {centerSubtitle && (
+
+ {[edu.major, edu.degree].filter(Boolean).join(" · ")}
+ {edu.gpa && ` · GPA ${edu.gpa}`}
+
+ )}
+
+ {`${formatDateString(edu.startDate, locale)} - ${formatDateString(edu.endDate, locale)}`}
+
+
+ {!centerSubtitle && (
+
+ {[edu.major, edu.degree].filter(Boolean).join(" · ")}
+ {edu.gpa && ` · GPA ${edu.gpa}`}
+
+ )}
+ {edu.description && (
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+export default EducationSection;
diff --git a/src/components/templates/creative/sections/ExperienceSection.tsx b/src/components/templates/creative/sections/ExperienceSection.tsx
new file mode 100644
index 0000000..bb4d3ba
--- /dev/null
+++ b/src/components/templates/creative/sections/ExperienceSection.tsx
@@ -0,0 +1,56 @@
+import React from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { Experience, GlobalSettings } from "@/types/resume";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+import { useLocale } from "@/i18n/compat/client";
+
+interface ExperienceSectionProps {
+ experiences?: Experience[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const ExperienceSection: React.FC = ({ experiences, globalSettings, showTitle = true }) => {
+ const locale = useLocale();
+ const visibleExperiences = experiences?.filter((exp) => exp.visible);
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+ {visibleExperiences?.map((exp) => (
+
+
+
+ {exp.company}
+
+ {centerSubtitle && (
+
+ {exp.position}
+
+ )}
+
+ {formatDateString(exp.date, locale)}
+
+
+ {exp.position && !centerSubtitle && (
+ {exp.position}
+ )}
+ {exp.details && (
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+export default ExperienceSection;
diff --git a/src/components/templates/creative/sections/ProjectSection.tsx b/src/components/templates/creative/sections/ProjectSection.tsx
new file mode 100644
index 0000000..3607f72
--- /dev/null
+++ b/src/components/templates/creative/sections/ProjectSection.tsx
@@ -0,0 +1,69 @@
+import React from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { Project, GlobalSettings } from "@/types/resume";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+import { useLocale } from "@/i18n/compat/client";
+
+interface ProjectSectionProps {
+ projects: Project[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const ProjectSection: React.FC = ({ projects, globalSettings, showTitle = true }) => {
+ const locale = useLocale();
+ const visibleProjects = projects?.filter((p) => p.visible);
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+
+ {visibleProjects.map((project) => (
+
+
+
+
{project.name}
+
+ {project.link && !centerSubtitle && (
+
+ {(() => { try { return new URL(project.link.startsWith("http") ? project.link : `https://${project.link}`).hostname.replace(/^www\./, ""); } catch { return project.link; } })()}
+
+ )}
+ {!project.link && !centerSubtitle && !flexLayout && }
+ {centerSubtitle && (
+
+ {project.role}
+
+ )}
+
+ {formatDateString(project.date, locale)}
+
+
+ {project.role && !centerSubtitle && (
+ {project.role}
+ )}
+ {project.link && centerSubtitle && (
+ {project.link}
+ )}
+ {project.description && (
+
+ )}
+
+ ))}
+
+
+
+ );
+};
+
+export default ProjectSection;
diff --git a/src/components/templates/creative/sections/SectionTitle.tsx b/src/components/templates/creative/sections/SectionTitle.tsx
new file mode 100644
index 0000000..e676d37
--- /dev/null
+++ b/src/components/templates/creative/sections/SectionTitle.tsx
@@ -0,0 +1,41 @@
+import { useMemo } from "react";
+import { GlobalSettings } from "@/types/resume";
+import { useTemplateContext } from "../../TemplateContext";
+import { useResumeStore } from "@/store/useResumeStore";
+
+interface SectionTitleProps {
+ globalSettings?: GlobalSettings;
+ type: string;
+ title?: string;
+ showTitle?: boolean;
+}
+
+const SectionTitle = ({ type, title, globalSettings, showTitle = true }: SectionTitleProps) => {
+ const { activeResume } = useResumeStore();
+ const templateContext = useTemplateContext();
+ const menuSections = templateContext?.menuSections ?? activeResume?.menuSections ?? [];
+
+ const renderTitle = useMemo(() => {
+ if (type === "custom") return title;
+ return menuSections.find((s) => s.id === type)?.title;
+ }, [menuSections, type, title]);
+
+ const themeColor = globalSettings?.themeColor;
+ if (!showTitle) return null;
+
+ return (
+
+ {renderTitle}
+
+ );
+};
+
+export default SectionTitle;
diff --git a/src/components/templates/creative/sections/SkillSection.tsx b/src/components/templates/creative/sections/SkillSection.tsx
new file mode 100644
index 0000000..6721caf
--- /dev/null
+++ b/src/components/templates/creative/sections/SkillSection.tsx
@@ -0,0 +1,27 @@
+import { motion } from "framer-motion";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { GlobalSettings } from "@/types/resume";
+import { normalizeRichTextContent } from "@/lib/richText";
+
+interface SkillSectionProps {
+ skill?: string;
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const SkillSection = ({ skill, globalSettings, showTitle = true }: SkillSectionProps) => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default SkillSection;
diff --git a/src/components/templates/elegant/config.ts b/src/components/templates/elegant/config.ts
new file mode 100644
index 0000000..7a8fe4d
--- /dev/null
+++ b/src/components/templates/elegant/config.ts
@@ -0,0 +1,23 @@
+import { ResumeTemplate } from "@/types/template";
+
+export const elegantConfig: ResumeTemplate = {
+ id: "elegant",
+ name: "优雅模板",
+ description: "居中标题单列设计,具有高级感的分隔线",
+ thumbnail: "elegant",
+ layout: "elegant",
+ colorScheme: {
+ primary: "#18181b",
+ secondary: "#71717a",
+ background: "#ffffff",
+ text: "#27272a",
+ },
+ spacing: {
+ sectionGap: 28,
+ itemGap: 18,
+ contentPadding: 32,
+ },
+ basic: {
+ layout: "center",
+ },
+};
diff --git a/src/components/templates/elegant/index.tsx b/src/components/templates/elegant/index.tsx
new file mode 100644
index 0000000..c27bcf5
--- /dev/null
+++ b/src/components/templates/elegant/index.tsx
@@ -0,0 +1,52 @@
+import React from "react";
+import { ResumeData } from "@/types/resume";
+import { ResumeTemplate } from "@/types/template";
+import BaseInfo from "./sections/BaseInfo";
+import ExperienceSection from "./sections/ExperienceSection";
+import EducationSection from "./sections/EducationSection";
+import ProjectSection from "./sections/ProjectSection";
+import SkillSection from "./sections/SkillSection";
+import CustomSection from "./sections/CustomSection";
+
+interface ElegantTemplateProps {
+ data: ResumeData;
+ template: ResumeTemplate;
+}
+
+const ElegantTemplate: React.FC = ({ data, template }) => {
+ const { colorScheme } = template;
+ const enabledSections = data.menuSections.filter((s) => s.enabled).sort((a, b) => a.order - b.order);
+
+ const renderSection = (sectionId: string) => {
+ switch (sectionId) {
+ case "basic":
+ return ;
+ case "experience":
+ return ;
+ case "education":
+ return ;
+ case "skills":
+ return ;
+ case "projects":
+ return ;
+ default:
+ if (sectionId in data.customData) {
+ const title = data.menuSections.find((s) => s.id === sectionId)?.title || sectionId;
+ return ;
+ }
+ return null;
+ }
+ };
+
+ return (
+
+
+ {enabledSections.map((section) => (
+
{renderSection(section.id)}
+ ))}
+
+
+ );
+};
+
+export default ElegantTemplate;
diff --git a/src/components/templates/elegant/sections/BaseInfo.tsx b/src/components/templates/elegant/sections/BaseInfo.tsx
new file mode 100644
index 0000000..e62b10c
--- /dev/null
+++ b/src/components/templates/elegant/sections/BaseInfo.tsx
@@ -0,0 +1,108 @@
+import React from "react";
+import { motion } from "framer-motion";
+import * as Icons from "lucide-react";
+import { cn, formatDateString } from "@/lib/utils";
+import { BasicInfo, getBorderRadiusValue, GlobalSettings } from "@/types/resume";
+import { ResumeTemplate } from "@/types/template";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { useTranslations, useLocale } from "@/i18n/compat/client";
+import GithubContribution from "@/components/shared/GithubContribution";
+
+interface BaseInfoProps {
+ basic: BasicInfo | undefined;
+ globalSettings: GlobalSettings | undefined;
+ template?: ResumeTemplate;
+}
+
+const BaseInfo = ({ basic = {} as BasicInfo, globalSettings, template }: BaseInfoProps) => {
+ const t = useTranslations("workbench");
+ const locale = useLocale();
+ const useIconMode = globalSettings?.useIconMode ?? false;
+ const layout = basic?.layout || "left";
+
+ const getIcon = (iconName: string | undefined) => {
+ const IconComponent = Icons[iconName as keyof typeof Icons] as React.ElementType;
+ return IconComponent ? : null;
+ };
+
+ const getOrderedFields = React.useMemo(() => {
+ if (!basic.fieldOrder) {
+ return [{ key: "email", value: basic.email, icon: basic.icons?.email || "Mail", label: "电子邮箱", visible: true, custom: false }]
+ .filter((item) => Boolean(item.value && item.visible));
+ }
+ return basic.fieldOrder
+ .filter((field) => field.visible !== false && field.key !== "name" && field.key !== "title")
+ .map((field) => ({
+ key: field.key, value: field.key === "birthDate" && basic[field.key] ? formatDateString(basic[field.key] as string, locale) : (basic[field.key] as string),
+ icon: basic.icons?.[field.key] || "User", label: field.label, visible: field.visible, custom: field.custom,
+ }))
+ .filter((item) => Boolean(item.value));
+ }, [basic]);
+
+ const allFields = [
+ ...getOrderedFields,
+ ...(basic.customFields?.filter((field) => field.visible !== false).map((field) => ({
+ key: field.id, value: field.value, icon: field.icon, label: field.label, visible: true, custom: true,
+ })) || []),
+ ];
+
+ const nameField = basic.fieldOrder?.find((f) => f.key === "name") || { key: "name", label: "姓名", visible: true };
+ const titleField = basic.fieldOrder?.find((f) => f.key === "title") || { key: "title", label: "职位", visible: true };
+
+ const PhotoComponent = basic.photo && basic.photoConfig?.visible && (
+
+
+

+
+
+ );
+
+ const layoutStyles = {
+ left: { container: "flex items-center justify-between gap-6", leftContent: "flex items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-left" },
+ right: { container: "flex items-center justify-between gap-6 flex-row-reverse", leftContent: "flex justify-end items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-right" },
+ center: { container: "flex flex-col items-center gap-3", leftContent: "flex flex-col items-center gap-4", fields: "w-full flex justify-start items-center flex-wrap gap-3", nameTitle: "text-center" },
+ };
+
+ const styles = layoutStyles[layout as keyof typeof layoutStyles] || layoutStyles.left;
+
+ return (
+
+
+
+ {PhotoComponent}
+
+ {nameField.visible !== false && basic[nameField.key] && (
+ {basic[nameField.key] as string}
+ )}
+ {titleField.visible !== false && basic[titleField.key] && (
+ {basic[titleField.key] as string}
+ )}
+
+
+
+ {allFields.map((item) => (
+
+ {useIconMode ? (
+
+ {getIcon(item.icon)}
+ {item.key === "email" ?
{item.value} :
{item.value}}
+
+ ) : (
+
+ {!item.custom && {t(`basicPanel.basicFields.${item.key}`)}:}
+ {item.custom && {item.label}:}
+ {item.value}
+
+ )}
+
+ ))}
+
+
+ {basic.githubContributionsVisible && (
+
+ )}
+
+ );
+};
+
+export default BaseInfo;
diff --git a/src/components/templates/elegant/sections/CustomSection.tsx b/src/components/templates/elegant/sections/CustomSection.tsx
new file mode 100644
index 0000000..ff2f970
--- /dev/null
+++ b/src/components/templates/elegant/sections/CustomSection.tsx
@@ -0,0 +1,58 @@
+import { AnimatePresence, motion } from "framer-motion";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { GlobalSettings, CustomItem } from "@/types/resume";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+import { useLocale } from "@/i18n/compat/client";
+
+interface CustomSectionProps {
+ sectionId: string;
+ title: string;
+ items: CustomItem[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const CustomSection = ({ sectionId, title, items, globalSettings, showTitle = true }: CustomSectionProps) => {
+ const locale = useLocale();
+ const visibleItems = items?.filter((item) => item.visible && (item.title || item.description));
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+ {visibleItems.map((item) => (
+
+
+
+
{item.title}
+
+ {centerSubtitle && (
+
+ {item.subtitle}
+
+ )}
+
+ {formatDateString(item.dateRange, locale)}
+
+
+ {!centerSubtitle && item.subtitle && (
+ {item.subtitle}
+ )}
+ {item.description && (
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+export default CustomSection;
diff --git a/src/components/templates/elegant/sections/EducationSection.tsx b/src/components/templates/elegant/sections/EducationSection.tsx
new file mode 100644
index 0000000..d49d094
--- /dev/null
+++ b/src/components/templates/elegant/sections/EducationSection.tsx
@@ -0,0 +1,61 @@
+import { AnimatePresence, motion } from "framer-motion";
+import { Education, GlobalSettings } from "@/types/resume";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { useLocale } from "@/i18n/compat/client";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+
+interface EducationSectionProps {
+ education?: Education[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const EducationSection = ({ education, globalSettings, showTitle = true }: EducationSectionProps) => {
+ const locale = useLocale();
+ const visibleEducation = education?.filter((edu) => edu.visible);
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+ {visibleEducation?.map((edu) => (
+
+
+
+ {edu.school}
+
+ {centerSubtitle && (
+
+ {[edu.major, edu.degree].filter(Boolean).join(" · ")}
+ {edu.gpa && ` · GPA ${edu.gpa}`}
+
+ )}
+
+ {`${formatDateString(edu.startDate, locale)} - ${formatDateString(edu.endDate, locale)}`}
+
+
+ {!centerSubtitle && (
+
+ {[edu.major, edu.degree].filter(Boolean).join(" · ")}
+ {edu.gpa && ` · GPA ${edu.gpa}`}
+
+ )}
+ {edu.description && (
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+export default EducationSection;
diff --git a/src/components/templates/elegant/sections/ExperienceSection.tsx b/src/components/templates/elegant/sections/ExperienceSection.tsx
new file mode 100644
index 0000000..bb4d3ba
--- /dev/null
+++ b/src/components/templates/elegant/sections/ExperienceSection.tsx
@@ -0,0 +1,56 @@
+import React from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { Experience, GlobalSettings } from "@/types/resume";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+import { useLocale } from "@/i18n/compat/client";
+
+interface ExperienceSectionProps {
+ experiences?: Experience[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const ExperienceSection: React.FC = ({ experiences, globalSettings, showTitle = true }) => {
+ const locale = useLocale();
+ const visibleExperiences = experiences?.filter((exp) => exp.visible);
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+ {visibleExperiences?.map((exp) => (
+
+
+
+ {exp.company}
+
+ {centerSubtitle && (
+
+ {exp.position}
+
+ )}
+
+ {formatDateString(exp.date, locale)}
+
+
+ {exp.position && !centerSubtitle && (
+ {exp.position}
+ )}
+ {exp.details && (
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+export default ExperienceSection;
diff --git a/src/components/templates/elegant/sections/ProjectSection.tsx b/src/components/templates/elegant/sections/ProjectSection.tsx
new file mode 100644
index 0000000..3607f72
--- /dev/null
+++ b/src/components/templates/elegant/sections/ProjectSection.tsx
@@ -0,0 +1,69 @@
+import React from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { Project, GlobalSettings } from "@/types/resume";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+import { useLocale } from "@/i18n/compat/client";
+
+interface ProjectSectionProps {
+ projects: Project[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const ProjectSection: React.FC = ({ projects, globalSettings, showTitle = true }) => {
+ const locale = useLocale();
+ const visibleProjects = projects?.filter((p) => p.visible);
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+
+ {visibleProjects.map((project) => (
+
+
+
+
{project.name}
+
+ {project.link && !centerSubtitle && (
+
+ {(() => { try { return new URL(project.link.startsWith("http") ? project.link : `https://${project.link}`).hostname.replace(/^www\./, ""); } catch { return project.link; } })()}
+
+ )}
+ {!project.link && !centerSubtitle && !flexLayout && }
+ {centerSubtitle && (
+
+ {project.role}
+
+ )}
+
+ {formatDateString(project.date, locale)}
+
+
+ {project.role && !centerSubtitle && (
+ {project.role}
+ )}
+ {project.link && centerSubtitle && (
+ {project.link}
+ )}
+ {project.description && (
+
+ )}
+
+ ))}
+
+
+
+ );
+};
+
+export default ProjectSection;
diff --git a/src/components/templates/elegant/sections/SectionTitle.tsx b/src/components/templates/elegant/sections/SectionTitle.tsx
new file mode 100644
index 0000000..6d9b51f
--- /dev/null
+++ b/src/components/templates/elegant/sections/SectionTitle.tsx
@@ -0,0 +1,44 @@
+import { useMemo } from "react";
+import { GlobalSettings } from "@/types/resume";
+import { useTemplateContext } from "../../TemplateContext";
+import { useResumeStore } from "@/store/useResumeStore";
+
+interface SectionTitleProps {
+ globalSettings?: GlobalSettings;
+ type: string;
+ title?: string;
+ showTitle?: boolean;
+}
+
+const SectionTitle = ({ type, title, globalSettings, showTitle = true }: SectionTitleProps) => {
+ const { activeResume } = useResumeStore();
+ const templateContext = useTemplateContext();
+ const menuSections = templateContext?.menuSections ?? activeResume?.menuSections ?? [];
+
+ const renderTitle = useMemo(() => {
+ if (type === "custom") return title;
+ return menuSections.find((s) => s.id === type)?.title;
+ }, [menuSections, type, title]);
+
+ const themeColor = globalSettings?.themeColor;
+ if (!showTitle) return null;
+
+ return (
+
+ );
+};
+
+export default SectionTitle;
diff --git a/src/components/templates/elegant/sections/SkillSection.tsx b/src/components/templates/elegant/sections/SkillSection.tsx
new file mode 100644
index 0000000..6721caf
--- /dev/null
+++ b/src/components/templates/elegant/sections/SkillSection.tsx
@@ -0,0 +1,27 @@
+import { motion } from "framer-motion";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { GlobalSettings } from "@/types/resume";
+import { normalizeRichTextContent } from "@/lib/richText";
+
+interface SkillSectionProps {
+ skill?: string;
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const SkillSection = ({ skill, globalSettings, showTitle = true }: SkillSectionProps) => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default SkillSection;
diff --git a/src/components/templates/index.tsx b/src/components/templates/index.tsx
index 5acceed..494451a 100644
--- a/src/components/templates/index.tsx
+++ b/src/components/templates/index.tsx
@@ -1,14 +1,6 @@
import React from "react";
-import ClassicTemplate from "./ClassicTemplate";
-import ModernTemplate from "./ModernTemplate";
-import LeftRightTemplate from "./LeftRightTemplate";
-import TimelineTemplate from "./TimelineTemplate";
-import MinimalistTemplate from "./MinimalistTemplate";
-
-import ElegantTemplate from "./ElegantTemplate";
-import CreativeTemplate from "./CreativeTemplate";
-
import { TemplateProvider } from "./TemplateContext";
+import { getTemplateComponent } from "./registry";
import { ResumeData } from "@/types/resume";
import { ResumeTemplate } from "@/types/template";
@@ -19,32 +11,13 @@ interface TemplateProps {
const ResumeTemplateComponent: React.FC = ({
data,
- template
+ template,
}) => {
- const renderTemplate = () => {
- switch (template.layout) {
- case "modern":
- return ;
- case "left-right":
- return ;
- case "timeline":
- return ;
- case "minimalist":
- return ;
-
- case "elegant":
- return ;
- case "creative":
- return ;
-
- default:
- return ;
- }
- };
+ const TemplateComponent = getTemplateComponent(template.layout);
return (
- {renderTemplate()}
+
);
};
diff --git a/src/components/templates/left-right/config.ts b/src/components/templates/left-right/config.ts
new file mode 100644
index 0000000..1a451f5
--- /dev/null
+++ b/src/components/templates/left-right/config.ts
@@ -0,0 +1,23 @@
+import { ResumeTemplate } from "@/types/template";
+
+export const leftRightConfig: ResumeTemplate = {
+ id: "left-right",
+ name: "模块标题背景色",
+ description: "模块标题背景鲜明,突出美观特色",
+ thumbnail: "leftRight",
+ layout: "left-right",
+ colorScheme: {
+ primary: "#000000",
+ secondary: "#9ca3af",
+ background: "#ffffff",
+ text: "#212529",
+ },
+ spacing: {
+ sectionGap: 24,
+ itemGap: 16,
+ contentPadding: 32,
+ },
+ basic: {
+ layout: "left",
+ },
+};
diff --git a/src/components/templates/left-right/index.tsx b/src/components/templates/left-right/index.tsx
new file mode 100644
index 0000000..ab2529e
--- /dev/null
+++ b/src/components/templates/left-right/index.tsx
@@ -0,0 +1,50 @@
+import React from "react";
+import { ResumeData } from "@/types/resume";
+import { ResumeTemplate } from "@/types/template";
+import BaseInfo from "./sections/BaseInfo";
+import ExperienceSection from "./sections/ExperienceSection";
+import EducationSection from "./sections/EducationSection";
+import ProjectSection from "./sections/ProjectSection";
+import SkillSection from "./sections/SkillSection";
+import CustomSection from "./sections/CustomSection";
+
+interface LeftRightTemplateProps {
+ data: ResumeData;
+ template: ResumeTemplate;
+}
+
+const LeftRightTemplate: React.FC = ({ data, template }) => {
+ const { colorScheme } = template;
+ const enabledSections = data.menuSections.filter((s) => s.enabled).sort((a, b) => a.order - b.order);
+
+ const renderSection = (sectionId: string) => {
+ switch (sectionId) {
+ case "basic":
+ return ;
+ case "experience":
+ return ;
+ case "education":
+ return ;
+ case "skills":
+ return ;
+ case "projects":
+ return ;
+ default:
+ if (sectionId in data.customData) {
+ const title = data.menuSections.find((s) => s.id === sectionId)?.title || sectionId;
+ return ;
+ }
+ return null;
+ }
+ };
+
+ return (
+
+ {enabledSections.map((section) => (
+
{renderSection(section.id)}
+ ))}
+
+ );
+};
+
+export default LeftRightTemplate;
diff --git a/src/components/templates/left-right/sections/BaseInfo.tsx b/src/components/templates/left-right/sections/BaseInfo.tsx
new file mode 100644
index 0000000..e62b10c
--- /dev/null
+++ b/src/components/templates/left-right/sections/BaseInfo.tsx
@@ -0,0 +1,108 @@
+import React from "react";
+import { motion } from "framer-motion";
+import * as Icons from "lucide-react";
+import { cn, formatDateString } from "@/lib/utils";
+import { BasicInfo, getBorderRadiusValue, GlobalSettings } from "@/types/resume";
+import { ResumeTemplate } from "@/types/template";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { useTranslations, useLocale } from "@/i18n/compat/client";
+import GithubContribution from "@/components/shared/GithubContribution";
+
+interface BaseInfoProps {
+ basic: BasicInfo | undefined;
+ globalSettings: GlobalSettings | undefined;
+ template?: ResumeTemplate;
+}
+
+const BaseInfo = ({ basic = {} as BasicInfo, globalSettings, template }: BaseInfoProps) => {
+ const t = useTranslations("workbench");
+ const locale = useLocale();
+ const useIconMode = globalSettings?.useIconMode ?? false;
+ const layout = basic?.layout || "left";
+
+ const getIcon = (iconName: string | undefined) => {
+ const IconComponent = Icons[iconName as keyof typeof Icons] as React.ElementType;
+ return IconComponent ? : null;
+ };
+
+ const getOrderedFields = React.useMemo(() => {
+ if (!basic.fieldOrder) {
+ return [{ key: "email", value: basic.email, icon: basic.icons?.email || "Mail", label: "电子邮箱", visible: true, custom: false }]
+ .filter((item) => Boolean(item.value && item.visible));
+ }
+ return basic.fieldOrder
+ .filter((field) => field.visible !== false && field.key !== "name" && field.key !== "title")
+ .map((field) => ({
+ key: field.key, value: field.key === "birthDate" && basic[field.key] ? formatDateString(basic[field.key] as string, locale) : (basic[field.key] as string),
+ icon: basic.icons?.[field.key] || "User", label: field.label, visible: field.visible, custom: field.custom,
+ }))
+ .filter((item) => Boolean(item.value));
+ }, [basic]);
+
+ const allFields = [
+ ...getOrderedFields,
+ ...(basic.customFields?.filter((field) => field.visible !== false).map((field) => ({
+ key: field.id, value: field.value, icon: field.icon, label: field.label, visible: true, custom: true,
+ })) || []),
+ ];
+
+ const nameField = basic.fieldOrder?.find((f) => f.key === "name") || { key: "name", label: "姓名", visible: true };
+ const titleField = basic.fieldOrder?.find((f) => f.key === "title") || { key: "title", label: "职位", visible: true };
+
+ const PhotoComponent = basic.photo && basic.photoConfig?.visible && (
+
+
+

+
+
+ );
+
+ const layoutStyles = {
+ left: { container: "flex items-center justify-between gap-6", leftContent: "flex items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-left" },
+ right: { container: "flex items-center justify-between gap-6 flex-row-reverse", leftContent: "flex justify-end items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-right" },
+ center: { container: "flex flex-col items-center gap-3", leftContent: "flex flex-col items-center gap-4", fields: "w-full flex justify-start items-center flex-wrap gap-3", nameTitle: "text-center" },
+ };
+
+ const styles = layoutStyles[layout as keyof typeof layoutStyles] || layoutStyles.left;
+
+ return (
+
+
+
+ {PhotoComponent}
+
+ {nameField.visible !== false && basic[nameField.key] && (
+ {basic[nameField.key] as string}
+ )}
+ {titleField.visible !== false && basic[titleField.key] && (
+ {basic[titleField.key] as string}
+ )}
+
+
+
+ {allFields.map((item) => (
+
+ {useIconMode ? (
+
+ {getIcon(item.icon)}
+ {item.key === "email" ?
{item.value} :
{item.value}}
+
+ ) : (
+
+ {!item.custom && {t(`basicPanel.basicFields.${item.key}`)}:}
+ {item.custom && {item.label}:}
+ {item.value}
+
+ )}
+
+ ))}
+
+
+ {basic.githubContributionsVisible && (
+
+ )}
+
+ );
+};
+
+export default BaseInfo;
diff --git a/src/components/templates/left-right/sections/CustomSection.tsx b/src/components/templates/left-right/sections/CustomSection.tsx
new file mode 100644
index 0000000..ff2f970
--- /dev/null
+++ b/src/components/templates/left-right/sections/CustomSection.tsx
@@ -0,0 +1,58 @@
+import { AnimatePresence, motion } from "framer-motion";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { GlobalSettings, CustomItem } from "@/types/resume";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+import { useLocale } from "@/i18n/compat/client";
+
+interface CustomSectionProps {
+ sectionId: string;
+ title: string;
+ items: CustomItem[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const CustomSection = ({ sectionId, title, items, globalSettings, showTitle = true }: CustomSectionProps) => {
+ const locale = useLocale();
+ const visibleItems = items?.filter((item) => item.visible && (item.title || item.description));
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+ {visibleItems.map((item) => (
+
+
+
+
{item.title}
+
+ {centerSubtitle && (
+
+ {item.subtitle}
+
+ )}
+
+ {formatDateString(item.dateRange, locale)}
+
+
+ {!centerSubtitle && item.subtitle && (
+ {item.subtitle}
+ )}
+ {item.description && (
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+export default CustomSection;
diff --git a/src/components/templates/left-right/sections/EducationSection.tsx b/src/components/templates/left-right/sections/EducationSection.tsx
new file mode 100644
index 0000000..d49d094
--- /dev/null
+++ b/src/components/templates/left-right/sections/EducationSection.tsx
@@ -0,0 +1,61 @@
+import { AnimatePresence, motion } from "framer-motion";
+import { Education, GlobalSettings } from "@/types/resume";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { useLocale } from "@/i18n/compat/client";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+
+interface EducationSectionProps {
+ education?: Education[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const EducationSection = ({ education, globalSettings, showTitle = true }: EducationSectionProps) => {
+ const locale = useLocale();
+ const visibleEducation = education?.filter((edu) => edu.visible);
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+ {visibleEducation?.map((edu) => (
+
+
+
+ {edu.school}
+
+ {centerSubtitle && (
+
+ {[edu.major, edu.degree].filter(Boolean).join(" · ")}
+ {edu.gpa && ` · GPA ${edu.gpa}`}
+
+ )}
+
+ {`${formatDateString(edu.startDate, locale)} - ${formatDateString(edu.endDate, locale)}`}
+
+
+ {!centerSubtitle && (
+
+ {[edu.major, edu.degree].filter(Boolean).join(" · ")}
+ {edu.gpa && ` · GPA ${edu.gpa}`}
+
+ )}
+ {edu.description && (
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+export default EducationSection;
diff --git a/src/components/templates/left-right/sections/ExperienceSection.tsx b/src/components/templates/left-right/sections/ExperienceSection.tsx
new file mode 100644
index 0000000..bb4d3ba
--- /dev/null
+++ b/src/components/templates/left-right/sections/ExperienceSection.tsx
@@ -0,0 +1,56 @@
+import React from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { Experience, GlobalSettings } from "@/types/resume";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+import { useLocale } from "@/i18n/compat/client";
+
+interface ExperienceSectionProps {
+ experiences?: Experience[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const ExperienceSection: React.FC = ({ experiences, globalSettings, showTitle = true }) => {
+ const locale = useLocale();
+ const visibleExperiences = experiences?.filter((exp) => exp.visible);
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+ {visibleExperiences?.map((exp) => (
+
+
+
+ {exp.company}
+
+ {centerSubtitle && (
+
+ {exp.position}
+
+ )}
+
+ {formatDateString(exp.date, locale)}
+
+
+ {exp.position && !centerSubtitle && (
+ {exp.position}
+ )}
+ {exp.details && (
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+export default ExperienceSection;
diff --git a/src/components/templates/left-right/sections/ProjectSection.tsx b/src/components/templates/left-right/sections/ProjectSection.tsx
new file mode 100644
index 0000000..3607f72
--- /dev/null
+++ b/src/components/templates/left-right/sections/ProjectSection.tsx
@@ -0,0 +1,69 @@
+import React from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { Project, GlobalSettings } from "@/types/resume";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+import { useLocale } from "@/i18n/compat/client";
+
+interface ProjectSectionProps {
+ projects: Project[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const ProjectSection: React.FC = ({ projects, globalSettings, showTitle = true }) => {
+ const locale = useLocale();
+ const visibleProjects = projects?.filter((p) => p.visible);
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+
+ {visibleProjects.map((project) => (
+
+
+
+
{project.name}
+
+ {project.link && !centerSubtitle && (
+
+ {(() => { try { return new URL(project.link.startsWith("http") ? project.link : `https://${project.link}`).hostname.replace(/^www\./, ""); } catch { return project.link; } })()}
+
+ )}
+ {!project.link && !centerSubtitle && !flexLayout && }
+ {centerSubtitle && (
+
+ {project.role}
+
+ )}
+
+ {formatDateString(project.date, locale)}
+
+
+ {project.role && !centerSubtitle && (
+ {project.role}
+ )}
+ {project.link && centerSubtitle && (
+ {project.link}
+ )}
+ {project.description && (
+
+ )}
+
+ ))}
+
+
+
+ );
+};
+
+export default ProjectSection;
diff --git a/src/components/templates/left-right/sections/SectionTitle.tsx b/src/components/templates/left-right/sections/SectionTitle.tsx
new file mode 100644
index 0000000..c171f0b
--- /dev/null
+++ b/src/components/templates/left-right/sections/SectionTitle.tsx
@@ -0,0 +1,44 @@
+import { useMemo } from "react";
+import { GlobalSettings } from "@/types/resume";
+import { useTemplateContext } from "../../TemplateContext";
+import { useResumeStore } from "@/store/useResumeStore";
+
+interface SectionTitleProps {
+ globalSettings?: GlobalSettings;
+ type: string;
+ title?: string;
+ showTitle?: boolean;
+}
+
+const SectionTitle = ({ type, title, globalSettings, showTitle = true }: SectionTitleProps) => {
+ const { activeResume } = useResumeStore();
+ const templateContext = useTemplateContext();
+ const menuSections = templateContext?.menuSections ?? activeResume?.menuSections ?? [];
+
+ const renderTitle = useMemo(() => {
+ if (type === "custom") return title;
+ return menuSections.find((s) => s.id === type)?.title;
+ }, [menuSections, type, title]);
+
+ const themeColor = globalSettings?.themeColor;
+ if (!showTitle) return null;
+
+ return (
+
+ );
+};
+
+export default SectionTitle;
diff --git a/src/components/templates/left-right/sections/SkillSection.tsx b/src/components/templates/left-right/sections/SkillSection.tsx
new file mode 100644
index 0000000..6721caf
--- /dev/null
+++ b/src/components/templates/left-right/sections/SkillSection.tsx
@@ -0,0 +1,27 @@
+import { motion } from "framer-motion";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { GlobalSettings } from "@/types/resume";
+import { normalizeRichTextContent } from "@/lib/richText";
+
+interface SkillSectionProps {
+ skill?: string;
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const SkillSection = ({ skill, globalSettings, showTitle = true }: SkillSectionProps) => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default SkillSection;
diff --git a/src/components/templates/minimalist/config.ts b/src/components/templates/minimalist/config.ts
new file mode 100644
index 0000000..c025eee
--- /dev/null
+++ b/src/components/templates/minimalist/config.ts
@@ -0,0 +1,23 @@
+import { ResumeTemplate } from "@/types/template";
+
+export const minimalistConfig: ResumeTemplate = {
+ id: "minimalist",
+ name: "极简模板",
+ description: "大面积留白,干净纯粹的排版风格",
+ thumbnail: "minimalist",
+ layout: "minimalist",
+ colorScheme: {
+ primary: "#171717",
+ secondary: "#737373",
+ background: "#ffffff",
+ text: "#171717",
+ },
+ spacing: {
+ sectionGap: 32,
+ itemGap: 24,
+ contentPadding: 40,
+ },
+ basic: {
+ layout: "center",
+ },
+};
diff --git a/src/components/templates/minimalist/index.tsx b/src/components/templates/minimalist/index.tsx
new file mode 100644
index 0000000..9284fe5
--- /dev/null
+++ b/src/components/templates/minimalist/index.tsx
@@ -0,0 +1,50 @@
+import React from "react";
+import { ResumeData } from "@/types/resume";
+import { ResumeTemplate } from "@/types/template";
+import BaseInfo from "./sections/BaseInfo";
+import ExperienceSection from "./sections/ExperienceSection";
+import EducationSection from "./sections/EducationSection";
+import ProjectSection from "./sections/ProjectSection";
+import SkillSection from "./sections/SkillSection";
+import CustomSection from "./sections/CustomSection";
+
+interface MinimalistTemplateProps {
+ data: ResumeData;
+ template: ResumeTemplate;
+}
+
+const MinimalistTemplate: React.FC = ({ data, template }) => {
+ const { colorScheme } = template;
+ const enabledSections = data.menuSections.filter((s) => s.enabled).sort((a, b) => a.order - b.order);
+
+ const renderSection = (sectionId: string) => {
+ switch (sectionId) {
+ case "basic":
+ return ;
+ case "experience":
+ return ;
+ case "education":
+ return ;
+ case "skills":
+ return ;
+ case "projects":
+ return ;
+ default:
+ if (sectionId in data.customData) {
+ const title = data.menuSections.find((s) => s.id === sectionId)?.title || sectionId;
+ return ;
+ }
+ return null;
+ }
+ };
+
+ return (
+
+ {enabledSections.map((section) => (
+
{renderSection(section.id)}
+ ))}
+
+ );
+};
+
+export default MinimalistTemplate;
diff --git a/src/components/templates/minimalist/sections/BaseInfo.tsx b/src/components/templates/minimalist/sections/BaseInfo.tsx
new file mode 100644
index 0000000..e62b10c
--- /dev/null
+++ b/src/components/templates/minimalist/sections/BaseInfo.tsx
@@ -0,0 +1,108 @@
+import React from "react";
+import { motion } from "framer-motion";
+import * as Icons from "lucide-react";
+import { cn, formatDateString } from "@/lib/utils";
+import { BasicInfo, getBorderRadiusValue, GlobalSettings } from "@/types/resume";
+import { ResumeTemplate } from "@/types/template";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { useTranslations, useLocale } from "@/i18n/compat/client";
+import GithubContribution from "@/components/shared/GithubContribution";
+
+interface BaseInfoProps {
+ basic: BasicInfo | undefined;
+ globalSettings: GlobalSettings | undefined;
+ template?: ResumeTemplate;
+}
+
+const BaseInfo = ({ basic = {} as BasicInfo, globalSettings, template }: BaseInfoProps) => {
+ const t = useTranslations("workbench");
+ const locale = useLocale();
+ const useIconMode = globalSettings?.useIconMode ?? false;
+ const layout = basic?.layout || "left";
+
+ const getIcon = (iconName: string | undefined) => {
+ const IconComponent = Icons[iconName as keyof typeof Icons] as React.ElementType;
+ return IconComponent ? : null;
+ };
+
+ const getOrderedFields = React.useMemo(() => {
+ if (!basic.fieldOrder) {
+ return [{ key: "email", value: basic.email, icon: basic.icons?.email || "Mail", label: "电子邮箱", visible: true, custom: false }]
+ .filter((item) => Boolean(item.value && item.visible));
+ }
+ return basic.fieldOrder
+ .filter((field) => field.visible !== false && field.key !== "name" && field.key !== "title")
+ .map((field) => ({
+ key: field.key, value: field.key === "birthDate" && basic[field.key] ? formatDateString(basic[field.key] as string, locale) : (basic[field.key] as string),
+ icon: basic.icons?.[field.key] || "User", label: field.label, visible: field.visible, custom: field.custom,
+ }))
+ .filter((item) => Boolean(item.value));
+ }, [basic]);
+
+ const allFields = [
+ ...getOrderedFields,
+ ...(basic.customFields?.filter((field) => field.visible !== false).map((field) => ({
+ key: field.id, value: field.value, icon: field.icon, label: field.label, visible: true, custom: true,
+ })) || []),
+ ];
+
+ const nameField = basic.fieldOrder?.find((f) => f.key === "name") || { key: "name", label: "姓名", visible: true };
+ const titleField = basic.fieldOrder?.find((f) => f.key === "title") || { key: "title", label: "职位", visible: true };
+
+ const PhotoComponent = basic.photo && basic.photoConfig?.visible && (
+
+
+

+
+
+ );
+
+ const layoutStyles = {
+ left: { container: "flex items-center justify-between gap-6", leftContent: "flex items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-left" },
+ right: { container: "flex items-center justify-between gap-6 flex-row-reverse", leftContent: "flex justify-end items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-right" },
+ center: { container: "flex flex-col items-center gap-3", leftContent: "flex flex-col items-center gap-4", fields: "w-full flex justify-start items-center flex-wrap gap-3", nameTitle: "text-center" },
+ };
+
+ const styles = layoutStyles[layout as keyof typeof layoutStyles] || layoutStyles.left;
+
+ return (
+
+
+
+ {PhotoComponent}
+
+ {nameField.visible !== false && basic[nameField.key] && (
+ {basic[nameField.key] as string}
+ )}
+ {titleField.visible !== false && basic[titleField.key] && (
+ {basic[titleField.key] as string}
+ )}
+
+
+
+ {allFields.map((item) => (
+
+ {useIconMode ? (
+
+ {getIcon(item.icon)}
+ {item.key === "email" ?
{item.value} :
{item.value}}
+
+ ) : (
+
+ {!item.custom && {t(`basicPanel.basicFields.${item.key}`)}:}
+ {item.custom && {item.label}:}
+ {item.value}
+
+ )}
+
+ ))}
+
+
+ {basic.githubContributionsVisible && (
+
+ )}
+
+ );
+};
+
+export default BaseInfo;
diff --git a/src/components/templates/minimalist/sections/CustomSection.tsx b/src/components/templates/minimalist/sections/CustomSection.tsx
new file mode 100644
index 0000000..ff2f970
--- /dev/null
+++ b/src/components/templates/minimalist/sections/CustomSection.tsx
@@ -0,0 +1,58 @@
+import { AnimatePresence, motion } from "framer-motion";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { GlobalSettings, CustomItem } from "@/types/resume";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+import { useLocale } from "@/i18n/compat/client";
+
+interface CustomSectionProps {
+ sectionId: string;
+ title: string;
+ items: CustomItem[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const CustomSection = ({ sectionId, title, items, globalSettings, showTitle = true }: CustomSectionProps) => {
+ const locale = useLocale();
+ const visibleItems = items?.filter((item) => item.visible && (item.title || item.description));
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+ {visibleItems.map((item) => (
+
+
+
+
{item.title}
+
+ {centerSubtitle && (
+
+ {item.subtitle}
+
+ )}
+
+ {formatDateString(item.dateRange, locale)}
+
+
+ {!centerSubtitle && item.subtitle && (
+ {item.subtitle}
+ )}
+ {item.description && (
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+export default CustomSection;
diff --git a/src/components/templates/minimalist/sections/EducationSection.tsx b/src/components/templates/minimalist/sections/EducationSection.tsx
new file mode 100644
index 0000000..d49d094
--- /dev/null
+++ b/src/components/templates/minimalist/sections/EducationSection.tsx
@@ -0,0 +1,61 @@
+import { AnimatePresence, motion } from "framer-motion";
+import { Education, GlobalSettings } from "@/types/resume";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { useLocale } from "@/i18n/compat/client";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+
+interface EducationSectionProps {
+ education?: Education[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const EducationSection = ({ education, globalSettings, showTitle = true }: EducationSectionProps) => {
+ const locale = useLocale();
+ const visibleEducation = education?.filter((edu) => edu.visible);
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+ {visibleEducation?.map((edu) => (
+
+
+
+ {edu.school}
+
+ {centerSubtitle && (
+
+ {[edu.major, edu.degree].filter(Boolean).join(" · ")}
+ {edu.gpa && ` · GPA ${edu.gpa}`}
+
+ )}
+
+ {`${formatDateString(edu.startDate, locale)} - ${formatDateString(edu.endDate, locale)}`}
+
+
+ {!centerSubtitle && (
+
+ {[edu.major, edu.degree].filter(Boolean).join(" · ")}
+ {edu.gpa && ` · GPA ${edu.gpa}`}
+
+ )}
+ {edu.description && (
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+export default EducationSection;
diff --git a/src/components/templates/minimalist/sections/ExperienceSection.tsx b/src/components/templates/minimalist/sections/ExperienceSection.tsx
new file mode 100644
index 0000000..bb4d3ba
--- /dev/null
+++ b/src/components/templates/minimalist/sections/ExperienceSection.tsx
@@ -0,0 +1,56 @@
+import React from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { Experience, GlobalSettings } from "@/types/resume";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+import { useLocale } from "@/i18n/compat/client";
+
+interface ExperienceSectionProps {
+ experiences?: Experience[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const ExperienceSection: React.FC = ({ experiences, globalSettings, showTitle = true }) => {
+ const locale = useLocale();
+ const visibleExperiences = experiences?.filter((exp) => exp.visible);
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+ {visibleExperiences?.map((exp) => (
+
+
+
+ {exp.company}
+
+ {centerSubtitle && (
+
+ {exp.position}
+
+ )}
+
+ {formatDateString(exp.date, locale)}
+
+
+ {exp.position && !centerSubtitle && (
+ {exp.position}
+ )}
+ {exp.details && (
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+export default ExperienceSection;
diff --git a/src/components/templates/minimalist/sections/ProjectSection.tsx b/src/components/templates/minimalist/sections/ProjectSection.tsx
new file mode 100644
index 0000000..3607f72
--- /dev/null
+++ b/src/components/templates/minimalist/sections/ProjectSection.tsx
@@ -0,0 +1,69 @@
+import React from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { Project, GlobalSettings } from "@/types/resume";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+import { useLocale } from "@/i18n/compat/client";
+
+interface ProjectSectionProps {
+ projects: Project[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const ProjectSection: React.FC = ({ projects, globalSettings, showTitle = true }) => {
+ const locale = useLocale();
+ const visibleProjects = projects?.filter((p) => p.visible);
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+
+ {visibleProjects.map((project) => (
+
+
+
+
{project.name}
+
+ {project.link && !centerSubtitle && (
+
+ {(() => { try { return new URL(project.link.startsWith("http") ? project.link : `https://${project.link}`).hostname.replace(/^www\./, ""); } catch { return project.link; } })()}
+
+ )}
+ {!project.link && !centerSubtitle && !flexLayout && }
+ {centerSubtitle && (
+
+ {project.role}
+
+ )}
+
+ {formatDateString(project.date, locale)}
+
+
+ {project.role && !centerSubtitle && (
+ {project.role}
+ )}
+ {project.link && centerSubtitle && (
+ {project.link}
+ )}
+ {project.description && (
+
+ )}
+
+ ))}
+
+
+
+ );
+};
+
+export default ProjectSection;
diff --git a/src/components/templates/minimalist/sections/SectionTitle.tsx b/src/components/templates/minimalist/sections/SectionTitle.tsx
new file mode 100644
index 0000000..6addecf
--- /dev/null
+++ b/src/components/templates/minimalist/sections/SectionTitle.tsx
@@ -0,0 +1,40 @@
+import { useMemo } from "react";
+import { GlobalSettings } from "@/types/resume";
+import { useTemplateContext } from "../../TemplateContext";
+import { useResumeStore } from "@/store/useResumeStore";
+
+interface SectionTitleProps {
+ globalSettings?: GlobalSettings;
+ type: string;
+ title?: string;
+ showTitle?: boolean;
+}
+
+const SectionTitle = ({ type, title, globalSettings, showTitle = true }: SectionTitleProps) => {
+ const { activeResume } = useResumeStore();
+ const templateContext = useTemplateContext();
+ const menuSections = templateContext?.menuSections ?? activeResume?.menuSections ?? [];
+
+ const renderTitle = useMemo(() => {
+ if (type === "custom") return title;
+ return menuSections.find((s) => s.id === type)?.title;
+ }, [menuSections, type, title]);
+
+ const themeColor = globalSettings?.themeColor;
+ if (!showTitle) return null;
+
+ return (
+
+ {renderTitle}
+
+ );
+};
+
+export default SectionTitle;
diff --git a/src/components/templates/minimalist/sections/SkillSection.tsx b/src/components/templates/minimalist/sections/SkillSection.tsx
new file mode 100644
index 0000000..6721caf
--- /dev/null
+++ b/src/components/templates/minimalist/sections/SkillSection.tsx
@@ -0,0 +1,27 @@
+import { motion } from "framer-motion";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { GlobalSettings } from "@/types/resume";
+import { normalizeRichTextContent } from "@/lib/richText";
+
+interface SkillSectionProps {
+ skill?: string;
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const SkillSection = ({ skill, globalSettings, showTitle = true }: SkillSectionProps) => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default SkillSection;
diff --git a/src/components/templates/modern/config.ts b/src/components/templates/modern/config.ts
new file mode 100644
index 0000000..42f349c
--- /dev/null
+++ b/src/components/templates/modern/config.ts
@@ -0,0 +1,23 @@
+import { ResumeTemplate } from "@/types/template";
+
+export const modernConfig: ResumeTemplate = {
+ id: "modern",
+ name: "两栏布局",
+ description: "经典两栏,突出个人特色",
+ thumbnail: "modern",
+ layout: "modern",
+ colorScheme: {
+ primary: "#000000",
+ secondary: "#6b7280",
+ background: "#ffffff",
+ text: "#212529",
+ },
+ spacing: {
+ sectionGap: 20,
+ itemGap: 20,
+ contentPadding: 1,
+ },
+ basic: {
+ layout: "center",
+ },
+};
diff --git a/src/components/templates/modern/index.tsx b/src/components/templates/modern/index.tsx
new file mode 100644
index 0000000..d4f4c7e
--- /dev/null
+++ b/src/components/templates/modern/index.tsx
@@ -0,0 +1,58 @@
+import React from "react";
+import { ResumeData } from "@/types/resume";
+import { ResumeTemplate } from "@/types/template";
+import BaseInfo from "./sections/BaseInfo";
+import ExperienceSection from "./sections/ExperienceSection";
+import EducationSection from "./sections/EducationSection";
+import ProjectSection from "./sections/ProjectSection";
+import SkillSection from "./sections/SkillSection";
+import CustomSection from "./sections/CustomSection";
+
+interface ModernTemplateProps {
+ data: ResumeData;
+ template: ResumeTemplate;
+}
+
+const ModernTemplate: React.FC = ({ data, template }) => {
+ const { colorScheme } = template;
+ const enabledSections = data.menuSections.filter((s) => s.enabled).sort((a, b) => a.order - b.order);
+
+ const renderSection = (sectionId: string) => {
+ switch (sectionId) {
+ case "basic":
+ return ;
+ case "experience":
+ return ;
+ case "education":
+ return ;
+ case "skills":
+ return ;
+ case "projects":
+ return ;
+ default:
+ if (sectionId in data.customData) {
+ const title = data.menuSections.find((s) => s.id === sectionId)?.title || sectionId;
+ return ;
+ }
+ return null;
+ }
+ };
+
+ const basicSection = enabledSections.find((s) => s.id === "basic");
+ const otherSections = enabledSections.filter((s) => s.id !== "basic");
+
+ return (
+
+
+ {basicSection && renderSection(basicSection.id)}
+
+
+ {otherSections.map((section) => (
+
{renderSection(section.id)}
+ ))}
+
+
+ );
+};
+
+export default ModernTemplate;
diff --git a/src/components/templates/modern/sections/BaseInfo.tsx b/src/components/templates/modern/sections/BaseInfo.tsx
new file mode 100644
index 0000000..e57c972
--- /dev/null
+++ b/src/components/templates/modern/sections/BaseInfo.tsx
@@ -0,0 +1,99 @@
+import React from "react";
+import { motion } from "framer-motion";
+import * as Icons from "lucide-react";
+import { cn, formatDateString } from "@/lib/utils";
+import { BasicInfo, getBorderRadiusValue, GlobalSettings } from "@/types/resume";
+import { ResumeTemplate } from "@/types/template";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { useTranslations, useLocale } from "@/i18n/compat/client";
+
+interface BaseInfoProps {
+ basic: BasicInfo | undefined;
+ globalSettings: GlobalSettings | undefined;
+ template?: ResumeTemplate;
+}
+
+/**
+ * Modern template BaseInfo — designed for sidebar (white text on theme color background).
+ */
+const BaseInfo = ({ basic = {} as BasicInfo, globalSettings, template }: BaseInfoProps) => {
+ const t = useTranslations("workbench");
+ const locale = useLocale();
+ const useIconMode = globalSettings?.useIconMode ?? false;
+
+ const getIcon = (iconName: string | undefined) => {
+ const IconComponent = Icons[iconName as keyof typeof Icons] as React.ElementType;
+ return IconComponent ? : null;
+ };
+
+ const getOrderedFields = React.useMemo(() => {
+ if (!basic.fieldOrder) {
+ return [{ key: "email", value: basic.email, icon: basic.icons?.email || "Mail", label: "电子邮箱", visible: true, custom: false }]
+ .filter((item) => Boolean(item.value && item.visible));
+ }
+ return basic.fieldOrder
+ .filter((field) => field.visible !== false && field.key !== "name" && field.key !== "title")
+ .map((field) => ({
+ key: field.key, value: field.key === "birthDate" && basic[field.key] ? formatDateString(basic[field.key] as string, locale) : (basic[field.key] as string),
+ icon: basic.icons?.[field.key] || "User", label: field.label, visible: field.visible, custom: field.custom,
+ }))
+ .filter((item) => Boolean(item.value));
+ }, [basic]);
+
+ const allFields = [
+ ...getOrderedFields,
+ ...(basic.customFields?.filter((field) => field.visible !== false).map((field) => ({
+ key: field.id, value: field.value, icon: field.icon, label: field.label, visible: true, custom: true,
+ })) || []),
+ ];
+
+ const nameField = basic.fieldOrder?.find((f) => f.key === "name") || { key: "name", visible: true };
+ const titleField = basic.fieldOrder?.find((f) => f.key === "title") || { key: "title", visible: true };
+
+ const PhotoComponent = basic.photo && basic.photoConfig?.visible && (
+
+
+

+
+
+ );
+
+ return (
+
+
+
+ {PhotoComponent}
+
+ {nameField.visible !== false && basic[nameField.key] && (
+ {basic[nameField.key] as string}
+ )}
+ {titleField.visible !== false && basic[titleField.key] && (
+ {basic[titleField.key] as string}
+ )}
+
+
+
+ {allFields.map((item) => (
+
+ {useIconMode ? (
+
+ {getIcon(item.icon)}
+ {item.key === "email" ?
{item.value} :
{item.value}}
+
+ ) : (
+
+ {!item.custom && {t(`basicPanel.basicFields.${item.key}`)}:}
+ {item.custom && {item.label}:}
+ {item.value}
+
+ )}
+
+ ))}
+
+
+
+ );
+};
+
+export default BaseInfo;
diff --git a/src/components/templates/modern/sections/CustomSection.tsx b/src/components/templates/modern/sections/CustomSection.tsx
new file mode 100644
index 0000000..ff2f970
--- /dev/null
+++ b/src/components/templates/modern/sections/CustomSection.tsx
@@ -0,0 +1,58 @@
+import { AnimatePresence, motion } from "framer-motion";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { GlobalSettings, CustomItem } from "@/types/resume";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+import { useLocale } from "@/i18n/compat/client";
+
+interface CustomSectionProps {
+ sectionId: string;
+ title: string;
+ items: CustomItem[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const CustomSection = ({ sectionId, title, items, globalSettings, showTitle = true }: CustomSectionProps) => {
+ const locale = useLocale();
+ const visibleItems = items?.filter((item) => item.visible && (item.title || item.description));
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+ {visibleItems.map((item) => (
+
+
+
+
{item.title}
+
+ {centerSubtitle && (
+
+ {item.subtitle}
+
+ )}
+
+ {formatDateString(item.dateRange, locale)}
+
+
+ {!centerSubtitle && item.subtitle && (
+ {item.subtitle}
+ )}
+ {item.description && (
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+export default CustomSection;
diff --git a/src/components/templates/modern/sections/EducationSection.tsx b/src/components/templates/modern/sections/EducationSection.tsx
new file mode 100644
index 0000000..d49d094
--- /dev/null
+++ b/src/components/templates/modern/sections/EducationSection.tsx
@@ -0,0 +1,61 @@
+import { AnimatePresence, motion } from "framer-motion";
+import { Education, GlobalSettings } from "@/types/resume";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { useLocale } from "@/i18n/compat/client";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+
+interface EducationSectionProps {
+ education?: Education[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const EducationSection = ({ education, globalSettings, showTitle = true }: EducationSectionProps) => {
+ const locale = useLocale();
+ const visibleEducation = education?.filter((edu) => edu.visible);
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+ {visibleEducation?.map((edu) => (
+
+
+
+ {edu.school}
+
+ {centerSubtitle && (
+
+ {[edu.major, edu.degree].filter(Boolean).join(" · ")}
+ {edu.gpa && ` · GPA ${edu.gpa}`}
+
+ )}
+
+ {`${formatDateString(edu.startDate, locale)} - ${formatDateString(edu.endDate, locale)}`}
+
+
+ {!centerSubtitle && (
+
+ {[edu.major, edu.degree].filter(Boolean).join(" · ")}
+ {edu.gpa && ` · GPA ${edu.gpa}`}
+
+ )}
+ {edu.description && (
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+export default EducationSection;
diff --git a/src/components/templates/modern/sections/ExperienceSection.tsx b/src/components/templates/modern/sections/ExperienceSection.tsx
new file mode 100644
index 0000000..bb4d3ba
--- /dev/null
+++ b/src/components/templates/modern/sections/ExperienceSection.tsx
@@ -0,0 +1,56 @@
+import React from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { Experience, GlobalSettings } from "@/types/resume";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+import { useLocale } from "@/i18n/compat/client";
+
+interface ExperienceSectionProps {
+ experiences?: Experience[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const ExperienceSection: React.FC = ({ experiences, globalSettings, showTitle = true }) => {
+ const locale = useLocale();
+ const visibleExperiences = experiences?.filter((exp) => exp.visible);
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+ {visibleExperiences?.map((exp) => (
+
+
+
+ {exp.company}
+
+ {centerSubtitle && (
+
+ {exp.position}
+
+ )}
+
+ {formatDateString(exp.date, locale)}
+
+
+ {exp.position && !centerSubtitle && (
+ {exp.position}
+ )}
+ {exp.details && (
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+export default ExperienceSection;
diff --git a/src/components/templates/modern/sections/ProjectSection.tsx b/src/components/templates/modern/sections/ProjectSection.tsx
new file mode 100644
index 0000000..3607f72
--- /dev/null
+++ b/src/components/templates/modern/sections/ProjectSection.tsx
@@ -0,0 +1,69 @@
+import React from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { Project, GlobalSettings } from "@/types/resume";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+import { useLocale } from "@/i18n/compat/client";
+
+interface ProjectSectionProps {
+ projects: Project[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const ProjectSection: React.FC = ({ projects, globalSettings, showTitle = true }) => {
+ const locale = useLocale();
+ const visibleProjects = projects?.filter((p) => p.visible);
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+
+ {visibleProjects.map((project) => (
+
+
+
+
{project.name}
+
+ {project.link && !centerSubtitle && (
+
+ {(() => { try { return new URL(project.link.startsWith("http") ? project.link : `https://${project.link}`).hostname.replace(/^www\./, ""); } catch { return project.link; } })()}
+
+ )}
+ {!project.link && !centerSubtitle && !flexLayout && }
+ {centerSubtitle && (
+
+ {project.role}
+
+ )}
+
+ {formatDateString(project.date, locale)}
+
+
+ {project.role && !centerSubtitle && (
+ {project.role}
+ )}
+ {project.link && centerSubtitle && (
+ {project.link}
+ )}
+ {project.description && (
+
+ )}
+
+ ))}
+
+
+
+ );
+};
+
+export default ProjectSection;
diff --git a/src/components/templates/modern/sections/SectionTitle.tsx b/src/components/templates/modern/sections/SectionTitle.tsx
new file mode 100644
index 0000000..154fd52
--- /dev/null
+++ b/src/components/templates/modern/sections/SectionTitle.tsx
@@ -0,0 +1,42 @@
+import { useMemo } from "react";
+import { GlobalSettings } from "@/types/resume";
+import { useTemplateContext } from "../../TemplateContext";
+import { useResumeStore } from "@/store/useResumeStore";
+
+interface SectionTitleProps {
+ globalSettings?: GlobalSettings;
+ type: string;
+ title?: string;
+ showTitle?: boolean;
+}
+
+const SectionTitle = ({ type, title, globalSettings, showTitle = true }: SectionTitleProps) => {
+ const { activeResume } = useResumeStore();
+ const templateContext = useTemplateContext();
+ const menuSections = templateContext?.menuSections ?? activeResume?.menuSections ?? [];
+
+ const renderTitle = useMemo(() => {
+ if (type === "custom") return title;
+ return menuSections.find((s) => s.id === type)?.title;
+ }, [menuSections, type, title]);
+
+ const themeColor = globalSettings?.themeColor;
+ if (!showTitle) return null;
+
+ return (
+
+ {renderTitle}
+
+ );
+};
+
+export default SectionTitle;
diff --git a/src/components/templates/modern/sections/SkillSection.tsx b/src/components/templates/modern/sections/SkillSection.tsx
new file mode 100644
index 0000000..6721caf
--- /dev/null
+++ b/src/components/templates/modern/sections/SkillSection.tsx
@@ -0,0 +1,27 @@
+import { motion } from "framer-motion";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { GlobalSettings } from "@/types/resume";
+import { normalizeRichTextContent } from "@/lib/richText";
+
+interface SkillSectionProps {
+ skill?: string;
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const SkillSection = ({ skill, globalSettings, showTitle = true }: SkillSectionProps) => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default SkillSection;
diff --git a/src/components/templates/registry.ts b/src/components/templates/registry.ts
new file mode 100644
index 0000000..c5526cd
--- /dev/null
+++ b/src/components/templates/registry.ts
@@ -0,0 +1,55 @@
+import React from "react";
+import { ResumeTemplate } from "@/types/template";
+
+// Import configs
+import { classicConfig } from "./classic/config";
+import { modernConfig } from "./modern/config";
+import { leftRightConfig } from "./left-right/config";
+import { timelineConfig } from "./timeline/config";
+import { minimalistConfig } from "./minimalist/config";
+import { elegantConfig } from "./elegant/config";
+import { creativeConfig } from "./creative/config";
+
+// Import components
+import ClassicTemplate from "./classic";
+import ModernTemplate from "./modern";
+import LeftRightTemplate from "./left-right";
+import TimelineTemplate from "./timeline";
+import MinimalistTemplate from "./minimalist";
+import ElegantTemplate from "./elegant";
+import CreativeTemplate from "./creative";
+
+export interface TemplateRegistryEntry {
+ config: ResumeTemplate;
+ Component: React.FC<{ data: any; template: ResumeTemplate }>;
+}
+
+/**
+ * Unified template registry.
+ * To add a new template, create a directory under `templates/` with config.ts + index.tsx,
+ * then add one line here. No other files need to change.
+ */
+export const TEMPLATE_REGISTRY: TemplateRegistryEntry[] = [
+ { config: classicConfig, Component: ClassicTemplate },
+ { config: modernConfig, Component: ModernTemplate },
+ { config: leftRightConfig, Component: LeftRightTemplate },
+ { config: timelineConfig, Component: TimelineTemplate },
+ { config: minimalistConfig, Component: MinimalistTemplate },
+ { config: elegantConfig, Component: ElegantTemplate },
+ { config: creativeConfig, Component: CreativeTemplate },
+];
+
+/** All template configs — drop-in replacement for the old DEFAULT_TEMPLATES */
+export const DEFAULT_TEMPLATES: ResumeTemplate[] = TEMPLATE_REGISTRY.map(
+ (entry) => entry.config
+);
+
+/** Look up a template component by layout id */
+export function getTemplateComponent(
+ layout: string
+): React.FC<{ data: any; template: ResumeTemplate }> {
+ return (
+ TEMPLATE_REGISTRY.find((entry) => entry.config.layout === layout)
+ ?.Component ?? ClassicTemplate
+ );
+}
diff --git a/src/components/templates/shared/SectionWrapper.tsx b/src/components/templates/shared/SectionWrapper.tsx
new file mode 100644
index 0000000..d587ab3
--- /dev/null
+++ b/src/components/templates/shared/SectionWrapper.tsx
@@ -0,0 +1,35 @@
+import React from "react";
+import { motion } from "framer-motion";
+import { useResumeStore } from "@/store/useResumeStore";
+
+interface SectionWrapperProps {
+ sectionId: string;
+ children: React.ReactNode;
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+/**
+ * Thin interaction wrapper for all section components.
+ * Provides hover highlight + click-to-select behavior.
+ */
+const SectionWrapper: React.FC = ({
+ sectionId,
+ children,
+ className = "",
+ style,
+}) => {
+ const { setActiveSection } = useResumeStore();
+
+ return (
+ setActiveSection(sectionId)}
+ >
+ {children}
+
+ );
+};
+
+export default SectionWrapper;
diff --git a/src/components/templates/timeline/config.ts b/src/components/templates/timeline/config.ts
new file mode 100644
index 0000000..364c9f2
--- /dev/null
+++ b/src/components/templates/timeline/config.ts
@@ -0,0 +1,23 @@
+import { ResumeTemplate } from "@/types/template";
+
+export const timelineConfig: ResumeTemplate = {
+ id: "timeline",
+ name: "时间线风格",
+ description: "时间线布局,突出经历的时间顺序",
+ thumbnail: "timeline",
+ layout: "timeline",
+ colorScheme: {
+ primary: "#18181b",
+ secondary: "#64748b",
+ background: "#ffffff",
+ text: "#212529",
+ },
+ spacing: {
+ sectionGap: 1,
+ itemGap: 12,
+ contentPadding: 24,
+ },
+ basic: {
+ layout: "right",
+ },
+};
diff --git a/src/components/templates/timeline/index.tsx b/src/components/templates/timeline/index.tsx
new file mode 100644
index 0000000..9716f19
--- /dev/null
+++ b/src/components/templates/timeline/index.tsx
@@ -0,0 +1,71 @@
+import React from "react";
+import { ResumeData } from "@/types/resume";
+import { ResumeTemplate } from "@/types/template";
+import BaseInfo from "./sections/BaseInfo";
+import ExperienceSection from "./sections/ExperienceSection";
+import EducationSection from "./sections/EducationSection";
+import ProjectSection from "./sections/ProjectSection";
+import SkillSection from "./sections/SkillSection";
+import CustomSection from "./sections/CustomSection";
+
+interface TimelineTemplateProps {
+ data: ResumeData;
+ template: ResumeTemplate;
+}
+
+const TimelineTemplate: React.FC = ({ data, template }) => {
+ const { colorScheme } = template;
+ const enabledSections = data.menuSections.filter((s) => s.enabled).sort((a, b) => a.order - b.order);
+
+ const renderTimelineItem = (content: React.ReactNode, title: string) => (
+
+
+
+
+ {title}
+
+
{content}
+
+ );
+
+ const renderSection = (sectionId: string) => {
+ switch (sectionId) {
+ case "basic":
+ return ;
+ case "experience":
+ return ;
+ case "education":
+ return ;
+ case "skills":
+ return ;
+ case "projects":
+ return ;
+ default:
+ if (sectionId in data.customData) {
+ const title = data.menuSections.find((s) => s.id === sectionId)?.title || sectionId;
+ return ;
+ }
+ return null;
+ }
+ };
+
+ return (
+
+ {enabledSections.map((section) => {
+ if (section.id === "basic") {
+ return
{renderSection(section.id)}
;
+ }
+ const sectionTitle = data.menuSections.find((s) => s.id === section.id)?.title || section.id;
+ return (
+
+
+ {renderTimelineItem(renderSection(section.id), sectionTitle)}
+
+
+ );
+ })}
+
+ );
+};
+
+export default TimelineTemplate;
diff --git a/src/components/templates/timeline/sections/BaseInfo.tsx b/src/components/templates/timeline/sections/BaseInfo.tsx
new file mode 100644
index 0000000..e62b10c
--- /dev/null
+++ b/src/components/templates/timeline/sections/BaseInfo.tsx
@@ -0,0 +1,108 @@
+import React from "react";
+import { motion } from "framer-motion";
+import * as Icons from "lucide-react";
+import { cn, formatDateString } from "@/lib/utils";
+import { BasicInfo, getBorderRadiusValue, GlobalSettings } from "@/types/resume";
+import { ResumeTemplate } from "@/types/template";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { useTranslations, useLocale } from "@/i18n/compat/client";
+import GithubContribution from "@/components/shared/GithubContribution";
+
+interface BaseInfoProps {
+ basic: BasicInfo | undefined;
+ globalSettings: GlobalSettings | undefined;
+ template?: ResumeTemplate;
+}
+
+const BaseInfo = ({ basic = {} as BasicInfo, globalSettings, template }: BaseInfoProps) => {
+ const t = useTranslations("workbench");
+ const locale = useLocale();
+ const useIconMode = globalSettings?.useIconMode ?? false;
+ const layout = basic?.layout || "left";
+
+ const getIcon = (iconName: string | undefined) => {
+ const IconComponent = Icons[iconName as keyof typeof Icons] as React.ElementType;
+ return IconComponent ? : null;
+ };
+
+ const getOrderedFields = React.useMemo(() => {
+ if (!basic.fieldOrder) {
+ return [{ key: "email", value: basic.email, icon: basic.icons?.email || "Mail", label: "电子邮箱", visible: true, custom: false }]
+ .filter((item) => Boolean(item.value && item.visible));
+ }
+ return basic.fieldOrder
+ .filter((field) => field.visible !== false && field.key !== "name" && field.key !== "title")
+ .map((field) => ({
+ key: field.key, value: field.key === "birthDate" && basic[field.key] ? formatDateString(basic[field.key] as string, locale) : (basic[field.key] as string),
+ icon: basic.icons?.[field.key] || "User", label: field.label, visible: field.visible, custom: field.custom,
+ }))
+ .filter((item) => Boolean(item.value));
+ }, [basic]);
+
+ const allFields = [
+ ...getOrderedFields,
+ ...(basic.customFields?.filter((field) => field.visible !== false).map((field) => ({
+ key: field.id, value: field.value, icon: field.icon, label: field.label, visible: true, custom: true,
+ })) || []),
+ ];
+
+ const nameField = basic.fieldOrder?.find((f) => f.key === "name") || { key: "name", label: "姓名", visible: true };
+ const titleField = basic.fieldOrder?.find((f) => f.key === "title") || { key: "title", label: "职位", visible: true };
+
+ const PhotoComponent = basic.photo && basic.photoConfig?.visible && (
+
+
+

+
+
+ );
+
+ const layoutStyles = {
+ left: { container: "flex items-center justify-between gap-6", leftContent: "flex items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-left" },
+ right: { container: "flex items-center justify-between gap-6 flex-row-reverse", leftContent: "flex justify-end items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-right" },
+ center: { container: "flex flex-col items-center gap-3", leftContent: "flex flex-col items-center gap-4", fields: "w-full flex justify-start items-center flex-wrap gap-3", nameTitle: "text-center" },
+ };
+
+ const styles = layoutStyles[layout as keyof typeof layoutStyles] || layoutStyles.left;
+
+ return (
+
+
+
+ {PhotoComponent}
+
+ {nameField.visible !== false && basic[nameField.key] && (
+ {basic[nameField.key] as string}
+ )}
+ {titleField.visible !== false && basic[titleField.key] && (
+ {basic[titleField.key] as string}
+ )}
+
+
+
+ {allFields.map((item) => (
+
+ {useIconMode ? (
+
+ {getIcon(item.icon)}
+ {item.key === "email" ?
{item.value} :
{item.value}}
+
+ ) : (
+
+ {!item.custom && {t(`basicPanel.basicFields.${item.key}`)}:}
+ {item.custom && {item.label}:}
+ {item.value}
+
+ )}
+
+ ))}
+
+
+ {basic.githubContributionsVisible && (
+
+ )}
+
+ );
+};
+
+export default BaseInfo;
diff --git a/src/components/templates/timeline/sections/CustomSection.tsx b/src/components/templates/timeline/sections/CustomSection.tsx
new file mode 100644
index 0000000..ff2f970
--- /dev/null
+++ b/src/components/templates/timeline/sections/CustomSection.tsx
@@ -0,0 +1,58 @@
+import { AnimatePresence, motion } from "framer-motion";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { GlobalSettings, CustomItem } from "@/types/resume";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+import { useLocale } from "@/i18n/compat/client";
+
+interface CustomSectionProps {
+ sectionId: string;
+ title: string;
+ items: CustomItem[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const CustomSection = ({ sectionId, title, items, globalSettings, showTitle = true }: CustomSectionProps) => {
+ const locale = useLocale();
+ const visibleItems = items?.filter((item) => item.visible && (item.title || item.description));
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+ {visibleItems.map((item) => (
+
+
+
+
{item.title}
+
+ {centerSubtitle && (
+
+ {item.subtitle}
+
+ )}
+
+ {formatDateString(item.dateRange, locale)}
+
+
+ {!centerSubtitle && item.subtitle && (
+ {item.subtitle}
+ )}
+ {item.description && (
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+export default CustomSection;
diff --git a/src/components/templates/timeline/sections/EducationSection.tsx b/src/components/templates/timeline/sections/EducationSection.tsx
new file mode 100644
index 0000000..d49d094
--- /dev/null
+++ b/src/components/templates/timeline/sections/EducationSection.tsx
@@ -0,0 +1,61 @@
+import { AnimatePresence, motion } from "framer-motion";
+import { Education, GlobalSettings } from "@/types/resume";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { useLocale } from "@/i18n/compat/client";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+
+interface EducationSectionProps {
+ education?: Education[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const EducationSection = ({ education, globalSettings, showTitle = true }: EducationSectionProps) => {
+ const locale = useLocale();
+ const visibleEducation = education?.filter((edu) => edu.visible);
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+ {visibleEducation?.map((edu) => (
+
+
+
+ {edu.school}
+
+ {centerSubtitle && (
+
+ {[edu.major, edu.degree].filter(Boolean).join(" · ")}
+ {edu.gpa && ` · GPA ${edu.gpa}`}
+
+ )}
+
+ {`${formatDateString(edu.startDate, locale)} - ${formatDateString(edu.endDate, locale)}`}
+
+
+ {!centerSubtitle && (
+
+ {[edu.major, edu.degree].filter(Boolean).join(" · ")}
+ {edu.gpa && ` · GPA ${edu.gpa}`}
+
+ )}
+ {edu.description && (
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+export default EducationSection;
diff --git a/src/components/templates/timeline/sections/ExperienceSection.tsx b/src/components/templates/timeline/sections/ExperienceSection.tsx
new file mode 100644
index 0000000..bb4d3ba
--- /dev/null
+++ b/src/components/templates/timeline/sections/ExperienceSection.tsx
@@ -0,0 +1,56 @@
+import React from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { Experience, GlobalSettings } from "@/types/resume";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+import { useLocale } from "@/i18n/compat/client";
+
+interface ExperienceSectionProps {
+ experiences?: Experience[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const ExperienceSection: React.FC = ({ experiences, globalSettings, showTitle = true }) => {
+ const locale = useLocale();
+ const visibleExperiences = experiences?.filter((exp) => exp.visible);
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+ {visibleExperiences?.map((exp) => (
+
+
+
+ {exp.company}
+
+ {centerSubtitle && (
+
+ {exp.position}
+
+ )}
+
+ {formatDateString(exp.date, locale)}
+
+
+ {exp.position && !centerSubtitle && (
+ {exp.position}
+ )}
+ {exp.details && (
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+export default ExperienceSection;
diff --git a/src/components/templates/timeline/sections/ProjectSection.tsx b/src/components/templates/timeline/sections/ProjectSection.tsx
new file mode 100644
index 0000000..3607f72
--- /dev/null
+++ b/src/components/templates/timeline/sections/ProjectSection.tsx
@@ -0,0 +1,69 @@
+import React from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { Project, GlobalSettings } from "@/types/resume";
+import { normalizeRichTextContent } from "@/lib/richText";
+import { formatDateString } from "@/lib/utils";
+import { useLocale } from "@/i18n/compat/client";
+
+interface ProjectSectionProps {
+ projects: Project[];
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const ProjectSection: React.FC = ({ projects, globalSettings, showTitle = true }) => {
+ const locale = useLocale();
+ const visibleProjects = projects?.filter((p) => p.visible);
+ const centerSubtitle = globalSettings?.centerSubtitle;
+ const flexLayout = globalSettings?.flexibleHeaderLayout;
+
+ return (
+
+
+
+
+ {visibleProjects.map((project) => (
+
+
+
+
{project.name}
+
+ {project.link && !centerSubtitle && (
+
+ {(() => { try { return new URL(project.link.startsWith("http") ? project.link : `https://${project.link}`).hostname.replace(/^www\./, ""); } catch { return project.link; } })()}
+
+ )}
+ {!project.link && !centerSubtitle && !flexLayout && }
+ {centerSubtitle && (
+
+ {project.role}
+
+ )}
+
+ {formatDateString(project.date, locale)}
+
+
+ {project.role && !centerSubtitle && (
+ {project.role}
+ )}
+ {project.link && centerSubtitle && (
+ {project.link}
+ )}
+ {project.description && (
+
+ )}
+
+ ))}
+
+
+
+ );
+};
+
+export default ProjectSection;
diff --git a/src/components/templates/timeline/sections/SectionTitle.tsx b/src/components/templates/timeline/sections/SectionTitle.tsx
new file mode 100644
index 0000000..4827b1f
--- /dev/null
+++ b/src/components/templates/timeline/sections/SectionTitle.tsx
@@ -0,0 +1,41 @@
+import { useMemo } from "react";
+import { GlobalSettings } from "@/types/resume";
+import { useTemplateContext } from "../../TemplateContext";
+import { useResumeStore } from "@/store/useResumeStore";
+
+interface SectionTitleProps {
+ globalSettings?: GlobalSettings;
+ type: string;
+ title?: string;
+ showTitle?: boolean;
+}
+
+const SectionTitle = ({ type, title, globalSettings, showTitle = true }: SectionTitleProps) => {
+ const { activeResume } = useResumeStore();
+ const templateContext = useTemplateContext();
+ const menuSections = templateContext?.menuSections ?? activeResume?.menuSections ?? [];
+
+ const renderTitle = useMemo(() => {
+ if (type === "custom") return title;
+ return menuSections.find((s) => s.id === type)?.title;
+ }, [menuSections, type, title]);
+
+ const themeColor = globalSettings?.themeColor;
+ if (!showTitle) return null;
+
+ // Timeline SectionTitle is rendered by the template wrapper (renderTimelineItem)
+ // This is a minimal fallback for basic section
+ return (
+
+ {renderTitle}
+
+ );
+};
+
+export default SectionTitle;
diff --git a/src/components/templates/timeline/sections/SkillSection.tsx b/src/components/templates/timeline/sections/SkillSection.tsx
new file mode 100644
index 0000000..6721caf
--- /dev/null
+++ b/src/components/templates/timeline/sections/SkillSection.tsx
@@ -0,0 +1,27 @@
+import { motion } from "framer-motion";
+import SectionTitle from "./SectionTitle";
+import SectionWrapper from "../../shared/SectionWrapper";
+import { GlobalSettings } from "@/types/resume";
+import { normalizeRichTextContent } from "@/lib/richText";
+
+interface SkillSectionProps {
+ skill?: string;
+ globalSettings?: GlobalSettings;
+ showTitle?: boolean;
+}
+
+const SkillSection = ({ skill, globalSettings, showTitle = true }: SkillSectionProps) => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default SkillSection;
diff --git a/src/config/index.ts b/src/config/index.ts
index 0cceeab..5f6d8b3 100644
--- a/src/config/index.ts
+++ b/src/config/index.ts
@@ -1,5 +1,7 @@
import { BasicFieldType } from "@/types/resume";
-import { ResumeTemplate } from "@/types/template";
+
+export { DEFAULT_TEMPLATES } from "@/components/templates/registry";
+
export const DEFAULT_FIELD_ORDER: BasicFieldType[] = [
{ id: "1", key: "name", label: "姓名", type: "text", visible: true },
@@ -17,157 +19,6 @@ export const DEFAULT_FIELD_ORDER: BasicFieldType[] = [
{ id: "7", key: "location", label: "所在地", type: "text", visible: true }
];
-export const DEFAULT_TEMPLATES: ResumeTemplate[] = [
- {
- id: "classic",
- name: "经典模板",
- description: "传统简约的简历布局,适合大多数求职场景",
- thumbnail: "classic",
- layout: "classic",
- colorScheme: {
- primary: "#000000",
- secondary: "#4b5563",
- background: "#ffffff",
- text: "#212529"
- },
- spacing: {
- sectionGap: 24,
- itemGap: 16,
- contentPadding: 32
- },
- basic: {
- layout: "center"
- }
- },
- {
- id: "modern",
- name: "两栏布局",
- description: "经典两栏,突出个人特色",
- thumbnail: "modern",
- layout: "modern",
- colorScheme: {
- primary: "#000000",
- secondary: "#6b7280",
- background: "#ffffff",
- text: "#212529"
- },
- spacing: {
- sectionGap: 20,
- itemGap: 20,
- contentPadding: 1
- },
- basic: {
- layout: "center"
- }
- },
- {
- id: "left-right",
- name: "模块标题背景色",
- description: "模块标题背景鲜明,突出美观特色",
- thumbnail: "leftRight",
- layout: "left-right",
- colorScheme: {
- primary: "#000000",
- secondary: "#9ca3af",
- background: "#ffffff",
- text: "#212529"
- },
- spacing: {
- sectionGap: 24,
- itemGap: 16,
- contentPadding: 32
- },
- basic: {
- layout: "left"
- }
- },
- {
- id: "timeline",
- name: "时间线风格",
- description: "时间线布局,突出经历的时间顺序",
- thumbnail: "timeline",
- layout: "timeline",
- colorScheme: {
- primary: "#18181b",
- secondary: "#64748b",
- background: "#ffffff",
- text: "#212529"
- },
- spacing: {
- sectionGap: 1,
- itemGap: 12,
- contentPadding: 24
- },
- basic: {
- layout: "right"
- }
- },
- {
- id: "minimalist",
- name: "极简模板",
- description: "大面积留白,干净纯粹的排版风格",
- thumbnail: "minimalist",
- layout: "minimalist",
- colorScheme: {
- primary: "#171717",
- secondary: "#737373",
- background: "#ffffff",
- text: "#171717"
- },
- spacing: {
- sectionGap: 32,
- itemGap: 24,
- contentPadding: 40
- },
- basic: {
- layout: "center"
- }
- },
-
- {
- id: "elegant",
- name: "优雅模板",
- description: "居中标题单列设计,具有高级感的分隔线",
- thumbnail: "elegant",
- layout: "elegant",
- colorScheme: {
- primary: "#18181b",
- secondary: "#71717a",
- background: "#ffffff",
- text: "#27272a"
- },
- spacing: {
- sectionGap: 28,
- itemGap: 18,
- contentPadding: 32
- },
- basic: {
- layout: "center"
- }
- },
- {
- id: "creative",
- name: "创意模板",
- description: "视觉错落设计,灵动活泼展现个性",
- thumbnail: "creative",
- layout: "creative",
- colorScheme: {
- primary: "#18181b",
- secondary: "#64748b",
- background: "#ffffff",
- text: "#1e293b"
- },
- spacing: {
- sectionGap: 24,
- itemGap: 16,
- contentPadding: 28
- },
- basic: {
- layout: "left"
- }
- }
-];
-
export const GITHUB_REPO_URL = "https://github.com/JOYCEQL/magic-resume";
export const PDF_EXPORT_CONFIG = {
diff --git a/src/config/templates.ts b/src/config/templates.ts
deleted file mode 100644
index 12623eb..0000000
--- a/src/config/templates.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { TemplateConfig } from "@/types/template";
-
-export const templateConfigs: Record = {
- default: {
- sectionTitle: {
- styles: {
- fontSize: 18,
- },
- },
- },
- classic: {
- sectionTitle: {
- className: "border-b pb-2",
- styles: {
- fontSize: 18,
- borderColor: "var(--theme-color)",
- },
- },
- },
- modern: {
- sectionTitle: {
- className: "font-semibold mb-2 uppercase tracking-wider",
- styles: {
- fontSize: 18,
- },
- },
- },
- "left-right": {
- sectionTitle: {
- className: "pl-1 flex items-center",
- styles: {
- fontSize: 18,
- backgroundColor: "var(--theme-color)",
- opacity: 0.1,
- color: "var(--theme-color)",
- borderLeftWidth: "3px",
- borderLeftStyle: "solid",
- borderLeftColor: "var(--theme-color)",
- },
- },
- },
- minimalist: {
- sectionTitle: {
- className: "mb-3 tracking-widest",
- styles: {
- fontSize: 16,
- },
- },
- },
-
- elegant: {
- sectionTitle: {
- className: "flex items-center justify-center w-full mb-4 relative",
- styles: {
- fontSize: 20,
- },
- },
- },
- creative: {
- sectionTitle: {
- className: "inline-block px-3 py-1 mb-3 rounded-md text-white shadow-sm",
- styles: {
- fontSize: 16,
- backgroundColor: "var(--theme-color)",
- },
- },
- },
-
-};
-
diff --git a/src/types/template.ts b/src/types/template.ts
index a02a6c3..a735bec 100644
--- a/src/types/template.ts
+++ b/src/types/template.ts
@@ -5,7 +5,7 @@ export interface ResumeTemplate {
name: string;
description: string;
thumbnail: string;
- layout: "classic" | "modern" | "left-right" | "timeline" | "minimalist" | "elegant" | "creative";
+ layout: string;
colorScheme: {
primary: string;
secondary: string;