feat: implement swiss resume template

This commit is contained in:
JOYCEQL
2026-06-01 13:17:02 +08:00
parent 5d7b9fc29d
commit 3789fe45f0
32 changed files with 846 additions and 17 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 480 KiB

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 KiB

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 KiB

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 387 KiB

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 597 KiB

After

Width:  |  Height:  |  Size: 607 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 490 KiB

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 654 KiB

After

Width:  |  Height:  |  Size: 652 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 588 KiB

After

Width:  |  Height:  |  Size: 584 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 KiB

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 620 KiB

After

Width:  |  Height:  |  Size: 616 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 498 KiB

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 734 KiB

After

Width:  |  Height:  |  Size: 745 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 666 KiB

After

Width:  |  Height:  |  Size: 664 KiB

+3
View File
@@ -10,6 +10,7 @@ import { minimalistConfig } from "./minimalist/config";
import { elegantConfig } from "./elegant/config";
import { creativeConfig } from "./creative/config";
import { editorialConfig } from "./editorial/config";
import { swissConfig } from "./swiss/config";
// Import components
import ClassicTemplate from "./classic";
@@ -20,6 +21,7 @@ import MinimalistTemplate from "./minimalist";
import ElegantTemplate from "./elegant";
import CreativeTemplate from "./creative";
import EditorialTemplate from "./editorial";
import SwissTemplate from "./swiss";
export interface TemplateRegistryEntry {
config: ResumeTemplate;
@@ -40,6 +42,7 @@ export const TEMPLATE_REGISTRY: TemplateRegistryEntry[] = [
{ config: elegantConfig, Component: ElegantTemplate },
{ config: creativeConfig, Component: CreativeTemplate },
{ config: editorialConfig, Component: EditorialTemplate },
{ config: swissConfig, Component: SwissTemplate },
];
/** All template configs — drop-in replacement for the old DEFAULT_TEMPLATES */
+24
View File
@@ -0,0 +1,24 @@
import { ResumeTemplate } from "@/types/template";
export const swissConfig: ResumeTemplate = {
id: "swiss",
name: "瑞士美学",
description: "极具艺术感的包豪斯国际排版,超粗字重对比与几何色块点缀,彰显理性与高级",
thumbnail: "swiss",
layout: "swiss",
colorScheme: {
primary: "#0f172a",
secondary: "#64748b",
background: "#ffffff",
text: "#0f172a",
},
spacing: {
sectionGap: 36,
itemGap: 20,
contentPadding: 36,
},
basic: {
layout: "left",
},
availableSections: ["skills", "experience", "projects", "education", "selfEvaluation", "certificates"],
};
+73
View File
@@ -0,0 +1,73 @@
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 SelfEvaluationSection from "./sections/SelfEvaluationSection";
import CustomSection from "./sections/CustomSection";
import SectionTitle from "./sections/SectionTitle";
import SectionWrapper from "../shared/SectionWrapper";
import CertificatesSection from "../shared/CertificatesSection";
interface SwissTemplateProps {
data: ResumeData;
template: ResumeTemplate;
}
const SwissTemplate: React.FC<SwissTemplateProps> = ({ 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 <BaseInfo basic={data.basic} globalSettings={data.globalSettings} template={template} />;
case "experience":
return <ExperienceSection experiences={data.experience} globalSettings={data.globalSettings} />;
case "education":
return <EducationSection education={data.education} globalSettings={data.globalSettings} />;
case "skills":
return <SkillSection skill={data.skillContent} globalSettings={data.globalSettings} />;
case "projects":
return <ProjectSection projects={data.projects} globalSettings={data.globalSettings} />;
case "certificates":
return (
<SectionWrapper sectionId="certificates" style={{ marginTop: `${data.globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="certificates" globalSettings={data.globalSettings} />
<div className="mt-4">
<CertificatesSection certificates={data.certificates} />
</div>
</SectionWrapper>
);
case "selfEvaluation":
return <SelfEvaluationSection content={data.selfEvaluationContent} globalSettings={data.globalSettings} />;
default:
if (sectionId in data.customData) {
const title = data.menuSections.find((s) => s.id === sectionId)?.title || sectionId;
return <CustomSection title={title} sectionId={sectionId} items={data.customData[sectionId]} globalSettings={data.globalSettings} />;
}
return null;
}
};
return (
<div
className="flex flex-col w-full min-h-screen selection:bg-slate-100"
style={{
backgroundColor: colorScheme.background,
color: colorScheme.text
}}
>
{enabledSections.map((section) => (
<div key={section.id} className="w-full">
{renderSection(section.id)}
</div>
))}
</div>
);
};
export default SwissTemplate;
@@ -0,0 +1,199 @@
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";
import { getCustomFieldDisplayText, getCustomFieldHref, shouldShowCustomFieldLabelPrefix } from "@/lib/customField";
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 themeColor = globalSettings?.themeColor || "#E31C24";
const getIcon = (iconName: string | undefined) => {
const IconComponent = Icons[iconName as keyof typeof Icons] as React.ElementType;
return IconComponent ? <IconComponent className="h-3.5 w-3.5 shrink-0" style={{ color: themeColor }} /> : 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 && Boolean(getCustomFieldDisplayText(field))).map((field) => ({
key: field.id, value: getCustomFieldDisplayText(field), icon: field.icon, label: field.label, visible: true, custom: true, displayLabel: field.displayLabel, href: getCustomFieldHref(field),
})) || []),
];
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 && (
<motion.div layout="position" className="shrink-0">
<div
style={{
width: `${basic.photoConfig?.width || 90}px`,
height: `${basic.photoConfig?.height || 90}px`,
borderRadius: getBorderRadiusValue(basic.photoConfig || { borderRadius: "none", customBorderRadius: 0 }),
overflow: "hidden"
}}
className="border-2 border-white shadow-md ring-2 ring-slate-100"
>
<img src={basic.photo} alt={`${basic.name}'s photo`} className="w-full h-full object-cover" />
</div>
</motion.div>
);
// 瑞士风格全新的不对称排版样式,完全基于 Framer Motion 实现丝滑的动态排版交互
const layoutStyles = {
left: {
container: "flex flex-col gap-6 w-full items-start justify-start",
headerRow: "flex items-center gap-6 w-full text-left",
nameTitle: "flex flex-col min-w-0 flex-1 relative pl-5",
accentBar: "absolute left-0 top-1.5 bottom-1.5 w-1 rounded-sm",
cardWrapper: "flex flex-wrap gap-2.5 w-full justify-start mt-2"
},
right: {
container: "flex flex-col gap-6 w-full items-end justify-start",
headerRow: "flex flex-row-reverse items-center gap-6 w-full text-right",
nameTitle: "flex flex-col min-w-0 flex-1 relative pr-5",
accentBar: "absolute right-0 top-1.5 bottom-1.5 w-1 rounded-sm",
cardWrapper: "flex flex-wrap gap-2.5 w-full justify-end mt-2"
},
center: {
container: "flex flex-col gap-6 w-full items-center justify-start text-center",
headerRow: "flex flex-col items-center gap-4 w-full",
nameTitle: "flex flex-col items-center min-w-0 w-full relative pb-4",
accentBar: "absolute bottom-0 left-1/2 -translate-x-1/2 w-16 h-1 rounded-sm",
cardWrapper: "flex flex-wrap gap-2.5 w-full justify-center mt-2"
},
};
const styles = layoutStyles[layout as keyof typeof layoutStyles] || layoutStyles.left;
return (
<SectionWrapper sectionId="basic">
<div className={styles.container}>
{/* 第一排:雕塑级姓名与职位锚点 */}
<div className={styles.headerRow}>
{PhotoComponent}
<div className={styles.nameTitle}>
{/* 瑞士美学标志性几何侧边/底部高亮线,高灵动质感 */}
<motion.div
key={`accent-${layout}`}
layout="position"
className={styles.accentBar}
style={{ backgroundColor: themeColor }}
/>
{nameField.visible !== false && basic[nameField.key] && (
<motion.h1
layout="position"
className="font-black tracking-tight whitespace-normal break-normal [overflow-wrap:normal] leading-none text-slate-800"
style={{ fontSize: "38px" }}
>
{basic[nameField.key] as string}
</motion.h1>
)}
{titleField.visible !== false && basic[titleField.key] && (
<motion.h2
layout="position"
className="whitespace-normal break-normal [overflow-wrap:normal] font-bold tracking-widest mt-2.5 text-slate-400 uppercase text-[12px]"
>
{basic[titleField.key] as string}
</motion.h2>
)}
</div>
</div>
{/* 第二排:自适应流式空气卡片群 (Fluid Flexible Capsule Cards) */}
<motion.div
layout="position"
className={styles.cardWrapper}
style={{ fontSize: `${globalSettings?.baseFontSize || 13}px` }}
>
{allFields.map((item) => {
const customFieldHref = item.custom && "href" in item && typeof item.href === "string" ? item.href : null;
return (
<motion.div
key={item.key}
layout="position"
className="group flex items-center gap-2 px-3 py-1.5 rounded-lg bg-slate-50/70 border border-slate-100 hover:bg-white hover:shadow-sm hover:border-slate-200 transition-all duration-300 min-w-0 max-w-full flex-initial"
>
{/* 图标 */}
<div className="shrink-0 group-hover:scale-110 transition-transform duration-300">
{getIcon(item.icon)}
</div>
{/* 字段显示:非图标模式下带粗体前缀,长字符支持在小空间中完美安全折行,绝不重叠 */}
<div className="flex items-center gap-1.5 min-w-0 leading-tight">
{!useIconMode && !item.custom && (
<span className="shrink-0 font-extrabold text-[12px] text-slate-400 uppercase tracking-wider">
{t(`basicPanel.basicFields.${item.key}`)}
</span>
)}
{!useIconMode && item.custom && shouldShowCustomFieldLabelPrefix(item) && (
<span className="shrink-0 font-extrabold text-[12px] text-slate-400 uppercase tracking-wider">
{item.label}
</span>
)}
{/* 实际内容值 */}
{customFieldHref ? (
<a
href={customFieldHref}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-slate-600 hover:text-slate-900 underline truncate break-all [overflow-wrap:anywhere]"
>
{item.value}
</a>
) : item.key === "email" ? (
<a
href={`mailto:${item.value}`}
className="font-medium text-slate-600 hover:text-slate-900 underline truncate break-all [overflow-wrap:anywhere]"
>
{item.value}
</a>
) : (
<span className="font-medium text-slate-600 break-all [overflow-wrap:anywhere]">
{item.value}
</span>
)}
</div>
</motion.div>
);
})}
</motion.div>
</div>
{basic.githubContributionsVisible && (
<GithubContribution className="mt-6 border border-slate-100 rounded-xl p-3 bg-slate-50/50" githubKey={basic.githubKey} username={basic.githubUseName} />
)}
</SectionWrapper>
);
};
export default BaseInfo;
@@ -0,0 +1,91 @@
import React from "react";
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 themeColor = globalSettings?.themeColor || "#E31C24";
return (
<SectionWrapper sectionId={sectionId} style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle title={title} type="custom" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
<div className="flex flex-col gap-6" style={{ marginTop: `${globalSettings?.paragraphSpacing || 16}px` }}>
{visibleItems.map((item) => (
<motion.div key={item.id} layout="position" className="group">
{/* 不对称网格对齐头部 */}
<div className="flex items-baseline justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-3 gap-y-1">
<h4
className="font-extrabold text-slate-800 tracking-tight"
style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}
>
{item.title}
</h4>
{centerSubtitle && (
<span
className="font-medium text-slate-500 border-l border-slate-300 pl-3 text-[14px]"
style={{ fontSize: `${(globalSettings?.subheaderSize || 16) - 1}px` }}
>
{item.subtitle}
</span>
)}
</div>
<div
className="ml-auto self-center font-mono text-slate-400 bg-slate-50 border border-slate-100/80 px-2 py-0.5 rounded text-[11px] font-semibold shrink-0"
>
{formatDateString(item.dateRange, locale)}
</div>
</div>
{/* 非居中模式下的副标题展示 */}
{!centerSubtitle && item.subtitle && (
<div
className="font-semibold text-slate-500 mt-1 uppercase tracking-wider"
style={{ fontSize: `${(globalSettings?.subheaderSize || 16) - 2}px` }}
>
{item.subtitle}
</div>
)}
{/* 自定义描述:移除 text-justify 修复列表小点拉伸 bug */}
{item.description && (
<motion.div layout="position" className="relative pl-4 mt-2.5">
<div
className="absolute left-0 top-1 bottom-1 w-[1.5px] opacity-20 group-hover:opacity-100 transition-opacity"
style={{ backgroundColor: themeColor }}
/>
<div
className="text-slate-600 prose prose-sm max-w-none prose-p:my-1 [&>ul]:pl-4 [&>ul]:mt-1 [&>ul>li]:my-0.5 marker:text-slate-400"
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(item.description) }}
style={{
fontSize: `${globalSettings?.baseFontSize || 13}px`,
lineHeight: globalSettings?.lineHeight || 1.6
}}
/>
</motion.div>
)}
</motion.div>
))}
</div>
</AnimatePresence>
</SectionWrapper>
);
};
export default CustomSection;
@@ -0,0 +1,92 @@
import React from "react";
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 { hasMeaningfulRichTextContent, normalizeRichTextContent } from "@/lib/richText";
import { formatDateRange } 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 themeColor = globalSettings?.themeColor || "#E31C24";
return (
<SectionWrapper sectionId="education" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="education" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
<div className="flex flex-col gap-6" style={{ marginTop: `${globalSettings?.paragraphSpacing || 16}px` }}>
{visibleEducation?.map((edu) => (
<motion.div key={edu.id} layout="position" className="group">
{/* 不对称网格对齐头部 */}
<div className="flex items-baseline justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-3 gap-y-1">
<h4
className="font-extrabold text-slate-800 tracking-tight"
style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}
>
{edu.school}
</h4>
{centerSubtitle && (
<span
className="font-medium text-slate-500 border-l border-slate-300 pl-3 text-[14px]"
style={{ fontSize: `${(globalSettings?.subheaderSize || 16) - 1}px` }}
>
{[edu.major, edu.degree].filter(Boolean).join(" · ")}
{edu.gpa && ` · GPA ${edu.gpa}`}
</span>
)}
</div>
<div
className="ml-auto self-center font-mono text-slate-400 bg-slate-50 border border-slate-100/80 px-2 py-0.5 rounded text-[11px] font-semibold shrink-0"
suppressHydrationWarning
>
{formatDateRange(edu.startDate, edu.endDate, locale)}
</div>
</div>
{/* 非居中模式下的专业学历展示 */}
{!centerSubtitle && (
<div
className="font-semibold text-slate-500 mt-1 uppercase tracking-wider"
style={{ fontSize: `${(globalSettings?.subheaderSize || 16) - 2}px` }}
>
{[edu.major, edu.degree].filter(Boolean).join(" · ")}
{edu.gpa && ` · GPA ${edu.gpa}`}
</div>
)}
{/* 教育描述 */}
{hasMeaningfulRichTextContent(edu.description) && (
<motion.div layout="position" className="relative pl-4 mt-2.5">
<div
className="absolute left-0 top-1 bottom-1 w-[1.5px] opacity-20 group-hover:opacity-100 transition-opacity"
style={{ backgroundColor: themeColor }}
/>
<div
className="text-slate-600 prose prose-sm max-w-none prose-p:my-1 [&>ul]:pl-4 [&>ul]:mt-1 [&>ul>li]:my-0.5 marker:text-slate-400"
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(edu.description) }}
style={{
fontSize: `${globalSettings?.baseFontSize || 13}px`,
lineHeight: globalSettings?.lineHeight || 1.6
}}
/>
</motion.div>
)}
</motion.div>
))}
</div>
</AnimatePresence>
</SectionWrapper>
);
};
export default EducationSection;
@@ -0,0 +1,89 @@
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<ExperienceSectionProps> = ({ experiences, globalSettings, showTitle = true }) => {
const locale = useLocale();
const visibleExperiences = experiences?.filter((exp) => exp.visible);
const centerSubtitle = globalSettings?.centerSubtitle;
const themeColor = globalSettings?.themeColor || "#E31C24";
return (
<SectionWrapper sectionId="experience" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="experience" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
<div className="flex flex-col gap-6" style={{ marginTop: `${globalSettings?.paragraphSpacing || 16}px` }}>
{visibleExperiences?.map((exp) => (
<motion.div key={exp.id} layout="position" className="group">
{/* 不对称网格对齐头部 */}
<div className="flex items-baseline justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-3 gap-y-1">
<h4
className="font-extrabold text-slate-800 tracking-tight"
style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}
>
{exp.company}
</h4>
{centerSubtitle && (
<span
className="font-medium text-slate-500 border-l border-slate-300 pl-3 text-[14px]"
style={{ fontSize: `${(globalSettings?.subheaderSize || 16) - 1}px` }}
>
{exp.position}
</span>
)}
</div>
<div
className="ml-auto self-center font-mono text-slate-400 bg-slate-50 border border-slate-100/80 px-2 py-0.5 rounded text-[11px] font-semibold shrink-0"
>
{formatDateString(exp.date, locale)}
</div>
</div>
{/* 非居中模式下的职位展示 */}
{exp.position && !centerSubtitle && (
<div
className="font-semibold text-slate-500 mt-1 uppercase tracking-wider"
style={{ fontSize: `${(globalSettings?.subheaderSize || 16) - 2}px` }}
>
{exp.position}
</div>
)}
{/* 详情内容 */}
{exp.details && (
<motion.div layout="position" className="relative pl-4 mt-2.5">
<div
className="absolute left-0 top-1 bottom-1 w-[1.5px] opacity-20 group-hover:opacity-100 transition-opacity"
style={{ backgroundColor: themeColor }}
/>
<div
className="text-slate-600 prose prose-sm max-w-none prose-p:my-1 [&>ul]:pl-4 [&>ul]:mt-1 [&>ul>li]:my-0.5 marker:text-slate-400"
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(exp.details) }}
style={{
fontSize: `${globalSettings?.baseFontSize || 13}px`,
lineHeight: globalSettings?.lineHeight || 1.6
}}
/>
</motion.div>
)}
</motion.div>
))}
</div>
</AnimatePresence>
</SectionWrapper>
);
};
export default ExperienceSection;
@@ -0,0 +1,111 @@
import React from "react";
import { motion, AnimatePresence } from "framer-motion";
import * as Icons from "lucide-react";
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";
import { getProjectLinkMeta } from "@/lib/projectLink";
interface ProjectSectionProps {
projects: Project[];
globalSettings?: GlobalSettings;
showTitle?: boolean;
}
const ProjectSection: React.FC<ProjectSectionProps> = ({ projects, globalSettings, showTitle = true }) => {
const locale = useLocale();
const visibleProjects = projects?.filter((p) => p.visible);
const centerSubtitle = globalSettings?.centerSubtitle;
const themeColor = globalSettings?.themeColor || "#E31C24";
return (
<SectionWrapper sectionId="projects" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="projects" globalSettings={globalSettings} showTitle={showTitle} />
<motion.div layout="position" className="flex flex-col gap-6" style={{ marginTop: `${globalSettings?.paragraphSpacing || 16}px` }}>
<AnimatePresence mode="popLayout">
{visibleProjects.map((project) => {
const projectLink = getProjectLinkMeta(project, {
preferFullUrl: centerSubtitle,
});
return (
<motion.div key={project.id} layout="position" className="group">
{/* 项目排版头部 */}
<div className="flex items-baseline justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1">
<h4
className="font-extrabold text-slate-800 tracking-tight"
style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}
>
{project.name}
</h4>
{centerSubtitle && (
<span
className="font-medium text-slate-500 border-l border-slate-300 pl-3 text-[14px]"
style={{ fontSize: `${(globalSettings?.subheaderSize || 16) - 1}px` }}
>
{project.role}
</span>
)}
{/* 瑞士风格精致小卡片链接 */}
{projectLink && (
<a
href={projectLink.href}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-slate-50 border border-slate-100 hover:bg-slate-100 transition-colors text-slate-500 font-medium"
title={projectLink.title}
>
<Icons.ExternalLink className="w-3 h-3 shrink-0" style={{ color: themeColor }} />
<span>{projectLink.label}</span>
</a>
)}
</div>
<div
className="ml-auto self-center font-mono text-slate-400 bg-slate-50 border border-slate-100/80 px-2 py-0.5 rounded text-[11px] font-semibold shrink-0"
>
{formatDateString(project.date, locale)}
</div>
</div>
{/* 非居中模式下的角色展示 */}
{project.role && !centerSubtitle && (
<div
className="font-semibold text-slate-500 mt-1 uppercase tracking-wider"
style={{ fontSize: `${(globalSettings?.subheaderSize || 16) - 2}px` }}
>
{project.role}
</div>
)}
{/* 项目描述内容 */}
{project.description && (
<motion.div layout="position" className="relative pl-4 mt-2.5">
<div
className="absolute left-0 top-1 bottom-1 w-[1.5px] opacity-20 group-hover:opacity-100 transition-opacity"
style={{ backgroundColor: themeColor }}
/>
<div
className="text-slate-600 prose prose-sm max-w-none prose-p:my-1 [&>ul]:pl-4 [&>ul]:mt-1 [&>ul>li]:my-0.5 marker:text-slate-400"
style={{
fontSize: `${globalSettings?.baseFontSize || 13}px`,
lineHeight: globalSettings?.lineHeight || 1.6
}}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(project.description) }}
/>
</motion.div>
)}
</motion.div>
);
})}
</AnimatePresence>
</motion.div>
</SectionWrapper>
);
};
export default ProjectSection;
@@ -0,0 +1,63 @@
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 || "#E31C24"; // 默认瑞士红
if (!showTitle) return null;
return (
<div
className="flex flex-col w-full"
style={{
marginBottom: `${globalSettings?.paragraphSpacing || 12}px`,
}}
>
<div className="flex items-center gap-2.5">
{/* 瑞士风格高亮色块 */}
<div
className="w-[6px] rounded-sm shrink-0"
style={{
height: `${(globalSettings?.headerSize || 18) * 1.1}px`,
backgroundColor: themeColor,
}}
/>
<h3
className="font-black tracking-wider uppercase"
style={{
fontSize: `${globalSettings?.headerSize || 18}px`,
color: "#0f172a",
}}
>
{renderTitle}
</h3>
</div>
{/* 不对称的分隔线 */}
<div
className="w-full h-[1px] mt-2 opacity-15"
style={{
backgroundColor: "#0f172a",
}}
/>
</div>
);
};
export default SectionTitle;
@@ -0,0 +1,42 @@
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 SelfEvaluationSectionProps {
content?: string;
globalSettings?: GlobalSettings;
showTitle?: boolean;
}
const SelfEvaluationSection = ({ content, globalSettings, showTitle = true }: SelfEvaluationSectionProps) => {
const themeColor = globalSettings?.themeColor || "#E31C24";
return (
<SectionWrapper sectionId="selfEvaluation" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="selfEvaluation" globalSettings={globalSettings} showTitle={showTitle} />
<motion.div style={{ marginTop: `${globalSettings?.paragraphSpacing || 16}px` }}>
<motion.div
layout="position"
className="relative pl-5 py-2 text-slate-600 prose prose-sm max-w-none prose-p:my-1 [&>ul]:pl-4 [&>ul]:mt-1 [&>ul>li]:my-0.5 marker:text-slate-400 bg-slate-50/30 rounded-r-xl"
>
{/* 瑞士经典引言左侧实线 */}
<div
className="absolute left-0 top-0 bottom-0 w-[3px] rounded-l"
style={{ backgroundColor: themeColor }}
/>
<div
style={{
fontSize: `${globalSettings?.baseFontSize || 13}px`,
lineHeight: globalSettings?.lineHeight || 1.6
}}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(content) }}
/>
</motion.div>
</motion.div>
</SectionWrapper>
);
};
export default SelfEvaluationSection;
@@ -0,0 +1,32 @@
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 (
<SectionWrapper sectionId="skills" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="skills" globalSettings={globalSettings} showTitle={showTitle} />
<motion.div style={{ marginTop: `${globalSettings?.paragraphSpacing || 16}px` }}>
<motion.div
className="text-slate-600 prose prose-sm max-w-none prose-p:my-1 prose-strong:font-bold prose-ul:my-1 prose-li:my-0.5 [&>ul]:pl-4 marker:text-slate-400 p-4 rounded-xl bg-slate-50/50 border border-slate-100/50 hover:border-slate-200/60 transition-colors"
layout="position"
style={{
fontSize: `${globalSettings?.baseFontSize || 13}px`,
lineHeight: globalSettings?.lineHeight || 1.6
}}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(skill) }}
/>
</motion.div>
</SectionWrapper>
);
};
export default SkillSection;
+19 -17
View File
@@ -1,26 +1,28 @@
export const TEMPLATE_SNAPSHOT_MANIFEST = {
"version": 1,
"generatedAt": "2026-04-18T17:37:35.391Z",
"generatedAt": "2026-06-01T05:05:39.212Z",
"locales": {
"zh": {
"classic": "/template-snapshots/zh/classic.png?v=2026-03-22T15%3A40%3A35.007Z",
"modern": "/template-snapshots/zh/modern.png?v=2026-03-22T15%3A40%3A35.007Z",
"left-right": "/template-snapshots/zh/left-right.png?v=2026-03-22T15%3A40%3A35.007Z",
"timeline": "/template-snapshots/zh/timeline.png?v=2026-03-22T15%3A40%3A35.007Z",
"minimalist": "/template-snapshots/zh/minimalist.png?v=2026-03-22T15%3A40%3A35.007Z",
"elegant": "/template-snapshots/zh/elegant.png?v=2026-03-22T15%3A40%3A35.007Z",
"creative": "/template-snapshots/zh/creative.png?v=2026-03-22T15%3A40%3A35.007Z",
"editorial": "/template-snapshots/zh/editorial.png?v=2026-04-18T17%3A37%3A35.391Z"
"classic": "/template-snapshots/zh/classic.png?v=2026-06-01T05%3A05%3A39.212Z",
"modern": "/template-snapshots/zh/modern.png?v=2026-06-01T05%3A05%3A39.212Z",
"left-right": "/template-snapshots/zh/left-right.png?v=2026-06-01T05%3A05%3A39.212Z",
"timeline": "/template-snapshots/zh/timeline.png?v=2026-06-01T05%3A05%3A39.212Z",
"minimalist": "/template-snapshots/zh/minimalist.png?v=2026-06-01T05%3A05%3A39.212Z",
"elegant": "/template-snapshots/zh/elegant.png?v=2026-06-01T05%3A05%3A39.212Z",
"creative": "/template-snapshots/zh/creative.png?v=2026-06-01T05%3A05%3A39.212Z",
"editorial": "/template-snapshots/zh/editorial.png?v=2026-06-01T05%3A05%3A39.212Z",
"swiss": "/template-snapshots/zh/swiss.png?v=2026-06-01T05%3A05%3A39.212Z"
},
"en": {
"classic": "/template-snapshots/en/classic.png?v=2026-03-22T15%3A40%3A35.007Z",
"modern": "/template-snapshots/en/modern.png?v=2026-03-22T15%3A40%3A35.007Z",
"left-right": "/template-snapshots/en/left-right.png?v=2026-03-22T15%3A40%3A35.007Z",
"timeline": "/template-snapshots/en/timeline.png?v=2026-03-22T15%3A40%3A35.007Z",
"minimalist": "/template-snapshots/en/minimalist.png?v=2026-03-22T15%3A40%3A35.007Z",
"elegant": "/template-snapshots/en/elegant.png?v=2026-03-22T15%3A40%3A35.007Z",
"creative": "/template-snapshots/en/creative.png?v=2026-03-22T15%3A40%3A35.007Z",
"editorial": "/template-snapshots/en/editorial.png?v=2026-04-18T17%3A37%3A35.391Z"
"classic": "/template-snapshots/en/classic.png?v=2026-06-01T05%3A05%3A39.212Z",
"modern": "/template-snapshots/en/modern.png?v=2026-06-01T05%3A05%3A39.212Z",
"left-right": "/template-snapshots/en/left-right.png?v=2026-06-01T05%3A05%3A39.212Z",
"timeline": "/template-snapshots/en/timeline.png?v=2026-06-01T05%3A05%3A39.212Z",
"minimalist": "/template-snapshots/en/minimalist.png?v=2026-06-01T05%3A05%3A39.212Z",
"elegant": "/template-snapshots/en/elegant.png?v=2026-06-01T05%3A05%3A39.212Z",
"creative": "/template-snapshots/en/creative.png?v=2026-06-01T05%3A05%3A39.212Z",
"editorial": "/template-snapshots/en/editorial.png?v=2026-06-01T05%3A05%3A39.212Z",
"swiss": "/template-snapshots/en/swiss.png?v=2026-06-01T05%3A05%3A39.212Z"
}
}
} as const;
+4
View File
@@ -250,6 +250,10 @@
"editorial": {
"name": "Editorial",
"description": "Luxurious blend of bold serif and refined sans-serif typography"
},
"swiss": {
"name": "Swiss Aesthetic",
"description": "Artistic Bauhaus layout with strong typographic hierarchy and geometric accents, showing modern minimalism"
}
}
},
+4
View File
@@ -251,6 +251,10 @@
"editorial": {
"name": "画报风模板",
"description": "大号精美衬线体与窄体无衬线的完美结合,极具奢华感"
},
"swiss": {
"name": "瑞士美学",
"description": "极具艺术感的包豪斯国际排版,超粗字重对比与几何色块点缀,彰显理性与高级"
}
}
},