feat: preview splitline && pref

This commit is contained in:
JOYCEQL
2024-11-14 23:15:42 +08:00
parent 1c2553fb4e
commit c3e21c0601
10 changed files with 596 additions and 400 deletions
@@ -1,4 +1,5 @@
"use client";
import React, { useState } from "react";
import { motion } from "framer-motion";
import { Download, Loader2 } from "lucide-react";
@@ -9,28 +10,76 @@ export function PdfExport() {
const { basic, theme } = useResumeStore();
const [isExporting, setIsExporting] = useState(false);
const calculatePageCount = (
contentHeight: number,
pageHeightPx: number
): number => {
const rawPages = contentHeight / pageHeightPx;
// 保留两位小数
const roundedPages = Math.round(rawPages * 100) / 100;
// 根据小数部分决定是否需要额外的页
const decimalPart = roundedPages % 1;
if (decimalPart === 0) {
return roundedPages;
} else if (decimalPart <= 0.05) {
// 如果小数部分小于等于0.05,向下取整
return Math.floor(roundedPages);
} else {
// 如果小数部分大于0.05,向上取整
return Math.ceil(roundedPages);
}
};
const handleExport = () => {
setIsExporting(true);
const element = document.querySelector("#resume-preview");
if (!element) {
setIsExporting(false);
return;
}
// 内容高度的问题
const contentHeight = element.scrollHeight - 30.22;
const A4_HEIGHT_MM = 297;
const MARGINS_MM = 8; // 上下边距各4mm
const CONTENT_HEIGHT_MM = A4_HEIGHT_MM - MARGINS_MM;
const DPI = 96; // 标准屏幕DPI
const MM_TO_PX = DPI / 25.4; // 1英寸=25.4毫米
const pageHeightPx = Math.ceil(CONTENT_HEIGHT_MM * MM_TO_PX);
const numberOfPages = Math.ceil(contentHeight / pageHeightPx);
const pageBreakLines = element.querySelectorAll(".page-break-line");
pageBreakLines.forEach((line) => {
line.style.display = "none";
});
// 导出后的清理代码
const cleanup = () => {
pageBreakLines.forEach((line) => {
line.style.display = "";
});
setIsExporting(false);
};
const opt = {
margin: [10, 10, 10, 10],
margin: [4, 4, 4, 4],
filename: `${basic.name}_简历.pdf`,
image: { type: "jpeg", quality: 0.98 },
pagebreak: {
mode: ["css", "legacy"] // 避免在元素中间断页
},
html2canvas: {
scale: 2,
useCORS: true,
letterRendering: true
scale: window.devicePixelRatio * 3, // 增加清晰度 useCORS: true,
letterRendering: true,
scrollY: -window.scrollY, // 添加这个
height: pageHeightPx * numberOfPages // 设置为精确的高度
},
jsPDF: {
unit: "mm",
format: "a4",
orientation: "portrait"
orientation: "portrait",
putTotalPages: false
}
};
@@ -38,15 +87,16 @@ export function PdfExport() {
.set(opt)
.from(element)
.save()
.then((res) => {
.then((res: any) => {
return res;
})
.catch((error) => {
.catch((error: any) => {
console.error("PDF export failed:", error);
return { notFound: true };
})
.finally(() => {
setIsExporting(false);
cleanup();
return { finally: true };
});
};
@@ -59,7 +109,7 @@ export function PdfExport() {
theme === "dark"
? "bg-indigo-600 hover:bg-indigo-700 text-white"
: "bg-black hover:bg-neutral-800 text-white"
}
}
disabled:opacity-50 disabled:cursor-not-allowed`}
whileHover={!isExporting ? { scale: 1.02 } : {}}
whileTap={!isExporting ? { scale: 0.98 } : {}}
@@ -430,6 +430,40 @@ export function SidePanel() {
</div>
</div>
<div className="space-y-2">
<Label
className={cn(
theme === "dark" ? "text-neutral-300" : "text-gray-600"
)}
>
</Label>
<div className="flex items-center gap-4">
<Slider
value={[globalSettings?.sectionSpacing || 0]}
min={20}
max={100}
step={1}
onValueChange={([value]) =>
updateGlobalSettings?.({ sectionSpacing: value })
}
className={cn(
theme === "dark"
? "[&_[role=slider]]:bg-neutral-200"
: "[&_[role=slider]]:bg-gray-900"
)}
/>
<span
className={cn(
"min-w-[3ch] text-sm",
theme === "dark" ? "text-neutral-300" : "text-gray-600"
)}
>
{globalSettings?.sectionSpacing}px
</span>
</div>
</div>
<div className="space-y-2">
<Label
className={cn(
@@ -441,8 +475,7 @@ export function SidePanel() {
<div className="flex items-center gap-4">
<Slider
value={[globalSettings?.paragraphSpacing || 0]}
min={0.8}
max={2}
min={1}
step={0.1}
onValueChange={([value]) =>
updateGlobalSettings?.({ paragraphSpacing: value })
@@ -459,7 +492,7 @@ export function SidePanel() {
theme === "dark" ? "text-neutral-300" : "text-gray-600"
)}
>
{globalSettings?.paragraphSpacing}em
{globalSettings?.paragraphSpacing}px
</span>
</div>
</div>
@@ -0,0 +1,57 @@
import { cn } from "@/lib/utils";
import { BasicInfo, GlobalSettings } from "@/types/resume";
import { motion } from "framer-motion";
import React from "react";
interface ResumeHeaderProps {
basic: BasicInfo;
globalSettings: GlobalSettings;
}
export function ResumeHeader({ basic, globalSettings }: ResumeHeaderProps) {
return (
<div className="text-center space-y-4">
<motion.h1
layout="position"
className={cn("font-bold", "text-gray-900")}
style={{
fontSize: `${(globalSettings?.headerSize || 24) * 1.5}px`
}}
>
{basic.name}
</motion.h1>
<motion.h2
layout="position"
className="text-gray-600"
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`
}}
>
{basic.title}
</motion.h2>
<motion.div
layout="position"
className="flex justify-center items-center space-x-4 flex-wrap"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
color: "rgb(75, 85, 99)"
}}
>
{[
basic.email,
basic.phone,
basic.location,
basic.birthDate ? new Date(basic.birthDate).toLocaleDateString() : "",
...(basic.customFields?.map((field) => field.value) || [])
]
.filter(Boolean)
.map((item, index, array) => (
<React.Fragment key={index}>
<span>{item}</span>
{index < array.length - 1 && <span></span>}
</React.Fragment>
))}
</motion.div>
</div>
);
}
@@ -0,0 +1,78 @@
import { motion } from "framer-motion";
import { Education, GlobalSettings } from "@/types/resume";
import { SectionTitle } from "./SectionTitle";
interface EducationSectionProps {
education: Education[];
globalSettings: GlobalSettings;
themeColor: string;
}
export function EducationSection({
education,
globalSettings,
themeColor
}: EducationSectionProps) {
return (
<motion.div
layout
className="space-y-4"
style={{
marginTop: `${globalSettings?.sectionSpacing || 24}px`
}}
>
<SectionTitle
title="教育经历"
themeColor={themeColor}
globalSettings={globalSettings}
/>
{education.map((edu) => (
<div
key={edu.id}
className="space-y-2"
style={{
marginTop: `${globalSettings?.paragraphSpacing}px`
}}
>
<div className="flex justify-between items-start">
<div>
<h4
className="font-medium text-gray-800"
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`
}}
>
{edu.school}
</h4>
<p
className="text-gray-600"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`
}}
>
{edu.degree}
</p>
</div>
<span
className="text-gray-600"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`
}}
>
{edu.date}
</span>
</div>
<p
className="text-gray-600"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
lineHeight: globalSettings?.lineHeight || 1.6
}}
>
{edu.details}
</p>
</div>
))}
</motion.div>
);
}
@@ -0,0 +1,78 @@
import { motion } from "framer-motion";
import { Experience, GlobalSettings } from "@/types/resume";
import { SectionTitle } from "./SectionTitle";
interface ExperienceSectionProps {
experience: Experience[];
globalSettings: GlobalSettings;
themeColor: string;
}
export function ExperienceSection({
experience,
globalSettings,
themeColor
}: ExperienceSectionProps) {
return (
<motion.div
layout
className="space-y-4"
style={{
marginTop: `${globalSettings?.sectionSpacing || 24}px`
}}
>
<SectionTitle
title="工作经验"
themeColor={themeColor}
globalSettings={globalSettings}
/>
{experience.map((exp) => (
<div
key={exp.id}
className="space-y-2"
style={{
marginTop: `${globalSettings?.paragraphSpacing}px`
}}
>
<div className="flex justify-between items-start">
<div>
<h4
className="font-medium text-gray-800"
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`
}}
>
{exp.company}
</h4>
<p
className="text-gray-600"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`
}}
>
{exp.position}
</p>
</div>
<span
className="text-gray-600"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`
}}
>
{exp.date}
</span>
</div>
<p
className="text-gray-600"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
lineHeight: globalSettings?.lineHeight || 1.6
}}
>
{exp.details}
</p>
</div>
))}
</motion.div>
);
}
@@ -1,11 +1,16 @@
"use client";
import React, { useMemo } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useResumeStore } from "@/store/useResumeStore";
import { cn } from "@/lib/utils";
import { throttle } from "lodash";
import { THEME_COLORS } from "@/types/resume";
import { ResumeHeader } from "./BaseInfo";
import { SectionTitle } from "./SectionTitle";
import { ProjectItem } from "./ProjectItem";
import { ExperienceSection } from "./ExperienceSection";
import { EducationSection } from "./EducationSection";
const getFontFamilyClass = (fontFamily: string) => {
switch (fontFamily) {
@@ -18,6 +23,39 @@ const getFontFamilyClass = (fontFamily: string) => {
}
};
interface PageBreakLineProps {
pageNumber: number;
}
const PageBreakLine = ({ pageNumber }: PageBreakLineProps) => {
const A4_HEIGHT_MM = 297;
const TOP_MARGIN_MM = 4;
const BOTTOM_MARGIN_MM = 4;
const CONTENT_HEIGHT_MM = A4_HEIGHT_MM - TOP_MARGIN_MM - BOTTOM_MARGIN_MM;
const MM_TO_PX = 3.78;
const SCALE_FACTOR = 2; // 考虑导出时的 scale: 2 设置
const pageHeight = CONTENT_HEIGHT_MM * MM_TO_PX;
return (
<div
className="absolute left-0 right-0 pointer-events-none page-break-line"
style={{
top: `${pageHeight * pageNumber}px`,
breakAfter: "page",
breakBefore: "page"
}}
>
<div className="relative w-full">
<div className="absolute w-full border-t-2 border-dashed border-red-400" />
<div className="absolute right-0 -top-6 text-xs text-red-500">
{pageNumber}
</div>
</div>
</div>
);
};
export function PreviewPanel() {
const {
theme,
@@ -30,7 +68,10 @@ export function PreviewPanel() {
draggingProjectId,
colorTheme
} = useResumeStore();
const previewRef = React.useRef<HTMLDivElement>(null);
const resumeContentRef = React.useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState(0);
const [scrollBehavior, setScrollBehavior] =
React.useState<ScrollBehavior>("smooth");
@@ -43,12 +84,25 @@ export function PreviewPanel() {
return colorTheme || THEME_COLORS[0];
}, [colorTheme]);
// 标题样式的公共配置
const sectionTitleStyles = {
fontSize: `${globalSettings?.headerSize || 18}px`,
borderColor: currentThemeColor, // 使用主题色作为下边框颜色
color: currentThemeColor // 使用主题色作为文字颜色
};
// 监测内容高度变化
useEffect(() => {
const updateContentHeight = () => {
if (resumeContentRef.current) {
setContentHeight(resumeContentRef.current.scrollHeight);
}
};
updateContentHeight();
const resizeObserver = new ResizeObserver(updateContentHeight);
if (resumeContentRef.current) {
resizeObserver.observe(resumeContentRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, []);
// 处理自动滚动
const handleScroll = React.useCallback(
@@ -113,349 +167,75 @@ export function PreviewPanel() {
marginTop: `${globalSettings?.sectionSpacing || 24}px`
}}
>
<h3
className="text-lg font-semibold border-b border-gray-200 pb-2"
style={sectionTitleStyles}
>
</h3>
<SectionTitle
title="项目经历"
themeColor={currentThemeColor}
globalSettings={globalSettings}
/>
<AnimatePresence mode="popLayout" initial={false}>
{projects
.filter((project) => project.visible) // 只显示 visible 为 true 的项目
.filter((project) => project.visible)
.map((project) => (
<motion.div
<ProjectItem
key={project.id}
data-project-id={project.id}
layout
initial={{ opacity: 0, y: 20 }}
animate={{
opacity: 1,
y: 0,
scale: draggingProjectId === project.id ? 1.01 : 1
}}
exit={{ opacity: 0, y: -20 }}
transition={{
type: "spring",
stiffness: 500,
damping: 50,
mass: 1
}}
className={cn(
"space-y-2 relative rounded-lg p-4",
draggingProjectId === project.id && "z-10"
)}
style={{
marginTop: `${globalSettings?.paragraphSpacing || 1.5}em`
}}
>
{/* 拖拽高亮效果 */}
{draggingProjectId === project.id && (
<motion.div
layoutId="project-highlight"
className="absolute inset-0 rounded-lg pointer-events-none"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<div
className={cn(
"absolute inset-0 rounded-lg",
"bg-gradient-to-r from-indigo-500/5 via-purple-500/5 to-indigo-500/5 border-2 border-indigo-500/10"
)}
>
<div
className={cn(
"absolute inset-0 animate-pulse",
"bg-black/5",
"rounded-lg"
)}
/>
</div>
</motion.div>
)}
<motion.div
layout
className="flex justify-between items-start relative z-10"
>
<motion.div layout>
<motion.h4
layout
className={cn("font-medium", "text-gray-800")}
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`
}}
>
{project.name || "未命名项目"}
</motion.h4>
<motion.div
layout
className={cn("text-gray-600")}
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`
}}
>
{project.role}
</motion.div>
</motion.div>
<motion.span
layout
className={cn("text-gray-600")}
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`
}}
>
{project.date}
</motion.span>
</motion.div>
{/* 项目详情 */}
{project.description && (
<motion.div
layout
className={cn("whitespace-pre-wrap", "text-gray-600")}
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
lineHeight: globalSettings?.lineHeight || 1.6
}}
>
<div
dangerouslySetInnerHTML={{ __html: project.description }}
/>
</motion.div>
)}
{/* 技术栈 */}
{project.technologies && (
<motion.div layout>
<motion.div
layout
className={cn("font-medium", "text-gray-800")}
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`
}}
>
</motion.div>
<motion.div
layout
className={cn("whitespace-pre-wrap", "text-gray-600")}
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
lineHeight: globalSettings?.lineHeight || 1.6
}}
>
{project.technologies}
</motion.div>
</motion.div>
)}
{/* 主要职责 */}
{project.responsibilities && (
<motion.div layout>
<motion.div
layout
className={cn("font-medium", "text-gray-800")}
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`
}}
>
</motion.div>
<motion.div
layout
className={cn("whitespace-pre-wrap", "text-gray-600")}
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
lineHeight: globalSettings?.lineHeight || 1.6
}}
>
{project.responsibilities}
</motion.div>
</motion.div>
)}
{/* 项目成就 */}
{project.achievements && (
<motion.div layout>
<motion.div
layout
className={cn("font-medium", "text-gray-800")}
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`
}}
>
</motion.div>
<motion.div
layout
className={cn("whitespace-pre-wrap", "text-gray-600")}
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
lineHeight: globalSettings?.lineHeight || 1.6
}}
>
{project.achievements}
</motion.div>
</motion.div>
)}
</motion.div>
project={project}
draggingProjectId={draggingProjectId}
globalSettings={globalSettings}
/>
))}
</AnimatePresence>
</motion.div>
);
const pageBreakCount = useMemo(() => {
const A4_HEIGHT_MM = 297;
const TOP_MARGIN_MM = 4;
const BOTTOM_MARGIN_MM = 4;
const CONTENT_HEIGHT_MM = A4_HEIGHT_MM - TOP_MARGIN_MM - BOTTOM_MARGIN_MM;
const MM_TO_PX = 3.78;
const pageHeightPx = CONTENT_HEIGHT_MM * MM_TO_PX;
return Math.max(0, Math.ceil(contentHeight / pageHeightPx) - 1);
}, [contentHeight]);
const renderBasicInfo = () => (
<motion.div layout className="space-y-2">
<SectionTitle
title="个人简介"
themeColor={currentThemeColor}
globalSettings={globalSettings}
/>
<p
className="text-gray-600 whitespace-pre-wrap"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
lineHeight: globalSettings?.lineHeight || 1.6
}}
>
{basic.summary}
</p>
</motion.div>
);
const renderSection = (sectionId: string) => {
switch (sectionId) {
case "basic":
return (
<motion.div layout className="space-y-2">
<h3
className="text-lg font-semibold border-b border-gray-200 pb-2"
style={sectionTitleStyles}
>
</h3>
<p
className="text-gray-600 whitespace-pre-wrap"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
lineHeight: globalSettings?.lineHeight || 1.6
}}
>
{basic.summary}
</p>
</motion.div>
);
return renderBasicInfo();
case "education":
return (
<motion.div
layout
className="space-y-4"
style={{
marginTop: `${globalSettings?.sectionSpacing || 24}px`
}}
>
<h3
className="text-lg font-semibold border-b border-gray-200 pb-2"
style={sectionTitleStyles}
>
</h3>
{education.map((edu) => (
<div
key={edu.id}
className="space-y-2"
style={{
marginTop: `${globalSettings?.paragraphSpacing || 1.5}em`
}}
>
<div className="flex justify-between items-start">
<div>
<h4
className="font-medium text-gray-800"
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`
}}
>
{edu.school}
</h4>
<p
className="text-gray-600"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`
}}
>
{edu.degree}
</p>
</div>
<span
className="text-gray-600"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`
}}
>
{edu.date}
</span>
</div>
<p
className="text-gray-600"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
lineHeight: globalSettings?.lineHeight || 1.6
}}
>
{edu.details}
</p>
</div>
))}
</motion.div>
<EducationSection
education={education}
globalSettings={globalSettings}
themeColor={currentThemeColor}
/>
);
case "experience":
return (
<motion.div
layout
className="space-y-4"
style={{
marginTop: `${globalSettings?.sectionSpacing || 24}px`
}}
>
<h3
className="text-lg font-semibold border-b border-gray-200 pb-2"
style={sectionTitleStyles}
>
</h3>
{experience.map((exp) => (
<div
key={exp.id}
className="space-y-2"
style={{
marginTop: `${globalSettings?.paragraphSpacing || 1.5}em`
}}
>
<div className="flex justify-between items-start">
<div>
<h4
className="font-medium text-gray-800"
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`
}}
>
{exp.company}
</h4>
<p
className="text-gray-600"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`
}}
>
{exp.position}
</p>
</div>
<span
className="text-gray-600"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`
}}
>
{exp.date}
</span>
</div>
<p
className="text-gray-600"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
lineHeight: globalSettings?.lineHeight || 1.6
}}
>
{exp.details}
</p>
</div>
))}
</motion.div>
<ExperienceSection
experience={experience}
globalSettings={globalSettings}
themeColor={currentThemeColor}
/>
);
case "projects":
return renderProjects();
@@ -482,61 +262,21 @@ export function PreviewPanel() {
fontFamilyClass,
"text-[#000]"
)}
style={{
// 设置与 html2pdf 配置一致的尺寸
minHeight: "297mm"
}}
>
<div
className="relative"
style={{
padding: `${globalSettings?.pagePadding || 40}px`
padding: "4mm"
}}
id="resume-preview"
>
<motion.div layout className="space-y-8">
<motion.div layout className="space-y-8" ref={resumeContentRef}>
{/* Header */}
<div className="text-center space-y-4">
<motion.h1
layout="position"
className={cn("font-bold", "text-gray-900")}
style={{
fontSize: `${(globalSettings?.headerSize || 24) * 1.5}px`
}}
>
{basic.name}
</motion.h1>
<motion.h2
layout="position"
className={"text-gray-600"}
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`
}}
>
{basic.title}
</motion.h2>
<motion.div
layout="position"
className="flex justify-center items-center space-x-4 flex-wrap"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
color: "rgb(75, 85, 99)"
}}
>
{[
basic.email,
basic.phone,
basic.location,
basic.birthDate
? new Date(basic.birthDate).toLocaleDateString()
: "",
...(basic.customFields?.map((field) => field.value) || [])
]
.filter(Boolean)
.map((item, index, array) => (
<React.Fragment key={index}>
<span>{item}</span>
{index < array.length - 1 && <span></span>}
</React.Fragment>
))}
</motion.div>
</div>
<ResumeHeader basic={basic} globalSettings={globalSettings} />
{/* Sections */}
{menuSections
@@ -548,6 +288,11 @@ export function PreviewPanel() {
</motion.div>
))}
</motion.div>
{/* 动态分页指示线 */}
{Array.from({ length: pageBreakCount }, (_, i) => (
<PageBreakLine key={i} pageNumber={i + 1} />
))}
</div>
</motion.div>
</div>
@@ -0,0 +1,119 @@
import { AnimatePresence, motion } from "framer-motion";
import { Project, GlobalSettings } from "@/types/resume";
import { cn } from "@/lib/utils";
interface ProjectItemProps {
project: Project;
draggingProjectId: string | null;
globalSettings: GlobalSettings;
}
export function ProjectItem({
project,
draggingProjectId,
globalSettings
}: ProjectItemProps) {
const isDragging = draggingProjectId === project.id;
return (
<motion.div
key={project.id}
data-project-id={project.id}
layout
initial={{ opacity: 0, y: 20 }}
animate={{
opacity: 1,
y: 0,
scale: isDragging ? 1.01 : 1
}}
exit={{ opacity: 0, y: -20 }}
transition={{
type: "spring",
stiffness: 500,
damping: 50,
mass: 1
}}
className={cn(
"space-y-2 relative rounded-lg p-4 pl-0",
isDragging && "z-10"
)}
style={{
marginTop: `${globalSettings?.paragraphSpacing}px`
}}
>
{isDragging && (
<motion.div
layoutId="project-highlight"
className="absolute inset-0 rounded-lg pointer-events-none"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<div
className={cn(
"absolute inset-0 rounded-lg",
"bg-gradient-to-r from-indigo-500/5 via-purple-500/5 to-indigo-500/5 border-2 border-indigo-500/10"
)}
>
<div
className={cn(
"absolute inset-0 animate-pulse",
"bg-black/5",
"rounded-lg"
)}
/>
</div>
</motion.div>
)}
<motion.div
layout
className="flex justify-between items-start relative z-10 "
style={{ pageBreakInside: "avoid" }}
>
<motion.div layout>
<motion.h4
layout
className={cn("font-medium", "text-gray-800")}
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`
}}
>
{project.name || "未命名项目"}
</motion.h4>
<motion.div
layout
className={cn("text-gray-600")}
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`
}}
>
{project.role}
</motion.div>
</motion.div>
<motion.span
layout
className={cn("text-gray-600")}
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`
}}
>
{project.date}
</motion.span>
</motion.div>
{project.description && (
<motion.div
layout
className={cn("whitespace-pre-wrap", "text-gray-600")}
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
lineHeight: globalSettings?.lineHeight || 1.6
}}
>
<div dangerouslySetInnerHTML={{ __html: project.description }} />
</motion.div>
)}
</motion.div>
);
}
@@ -0,0 +1,28 @@
import { GlobalSettings } from "@/types/resume";
interface SectionTitleProps {
title: string;
themeColor: string;
globalSettings: GlobalSettings;
}
export function SectionTitle({
title,
themeColor,
globalSettings
}: SectionTitleProps) {
const sectionTitleStyles = {
fontSize: `${globalSettings?.headerSize || 18}px`,
borderColor: themeColor,
color: themeColor
};
return (
<h3
className="text-lg font-semibold border-b border-gray-200 pb-2"
style={sectionTitleStyles}
>
{title}
</h3>
);
}
+8 -1
View File
@@ -104,6 +104,7 @@ const initialState = {
colorTheme: "#2563eb", // 默认使用经典蓝主题
activeSection: "basic",
projects: [
{
id: "p1",
@@ -131,7 +132,13 @@ const initialState = {
achievements: "系统整体性能提升 50%,代码重用率提高到 80%",
visible: true
}
]
],
globalSettings: {
pagePadding: 20,
paragraphSpacing: 20,
lineHeight: 1,
sectionSpacing: 20
}
};
export const useResumeStore = create<ResumeStore>()(
+8 -7
View File
@@ -51,13 +51,14 @@ export interface Project {
}
export type GlobalSettings = {
theme: "light" | "dark";
themeColor: string;
fontFamily: string;
baseFontSize: number;
pagePadding: number;
paragraphSpacing: number;
lineHeight: number; // 新增行高设置
theme?: "light" | "dark" | undefined;
themeColor?: string | undefined;
fontFamily?: string | undefined;
baseFontSize?: number | undefined;
pagePadding?: number | undefined;
paragraphSpacing?: number | undefined;
lineHeight?: number | undefined;
sectionSpacing?: number | undefined;
};
export interface ResumeTheme {