feat: implement swiss resume template
|
Before Width: | Height: | Size: 480 KiB After Width: | Height: | Size: 485 KiB |
|
Before Width: | Height: | Size: 446 KiB After Width: | Height: | Size: 440 KiB |
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 365 KiB |
|
Before Width: | Height: | Size: 393 KiB After Width: | Height: | Size: 410 KiB |
|
Before Width: | Height: | Size: 462 KiB After Width: | Height: | Size: 470 KiB |
|
Before Width: | Height: | Size: 387 KiB After Width: | Height: | Size: 383 KiB |
|
Before Width: | Height: | Size: 597 KiB After Width: | Height: | Size: 607 KiB |
|
After Width: | Height: | Size: 375 KiB |
|
Before Width: | Height: | Size: 490 KiB After Width: | Height: | Size: 496 KiB |
|
Before Width: | Height: | Size: 654 KiB After Width: | Height: | Size: 652 KiB |
|
Before Width: | Height: | Size: 588 KiB After Width: | Height: | Size: 584 KiB |
|
Before Width: | Height: | Size: 432 KiB After Width: | Height: | Size: 474 KiB |
|
Before Width: | Height: | Size: 535 KiB After Width: | Height: | Size: 532 KiB |
|
Before Width: | Height: | Size: 620 KiB After Width: | Height: | Size: 616 KiB |
|
Before Width: | Height: | Size: 498 KiB After Width: | Height: | Size: 495 KiB |
|
Before Width: | Height: | Size: 734 KiB After Width: | Height: | Size: 745 KiB |
|
After Width: | Height: | Size: 499 KiB |
|
Before Width: | Height: | Size: 666 KiB After Width: | Height: | Size: 664 KiB |
@@ -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 */
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -251,6 +251,10 @@
|
||||
"editorial": {
|
||||
"name": "画报风模板",
|
||||
"description": "大号精美衬线体与窄体无衬线的完美结合,极具奢华感"
|
||||
},
|
||||
"swiss": {
|
||||
"name": "瑞士美学",
|
||||
"description": "极具艺术感的包豪斯国际排版,超粗字重对比与几何色块点缀,彰显理性与高级"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||