From c3e21c0601e949f34e3b30128a2f4d6a308d3738 Mon Sep 17 00:00:00 2001 From: JOYCEQL <1449239013@qq.com> Date: Thu, 14 Nov 2024 23:15:42 +0800 Subject: [PATCH] feat: preview splitline && pref --- .../src/components/editor/PdfExport.tsx | 68 ++- .../src/components/editor/SidePanel.tsx | 39 +- .../src/components/preview/BaseInfo.tsx | 57 ++ .../components/preview/EducationSection.tsx | 78 +++ .../components/preview/ExperienceSection.tsx | 78 +++ .../src/components/preview/PreviewPanel.tsx | 505 +++++------------- .../src/components/preview/ProjectItem.tsx | 119 +++++ .../src/components/preview/SectionTitle.tsx | 28 + apps/fronted/src/store/useResumeStore.ts | 9 +- apps/fronted/src/types/resume.ts | 15 +- 10 files changed, 596 insertions(+), 400 deletions(-) create mode 100644 apps/fronted/src/components/preview/BaseInfo.tsx create mode 100644 apps/fronted/src/components/preview/EducationSection.tsx create mode 100644 apps/fronted/src/components/preview/ExperienceSection.tsx create mode 100644 apps/fronted/src/components/preview/ProjectItem.tsx create mode 100644 apps/fronted/src/components/preview/SectionTitle.tsx diff --git a/apps/fronted/src/components/editor/PdfExport.tsx b/apps/fronted/src/components/editor/PdfExport.tsx index ccb4bfc..c2eba7b 100644 --- a/apps/fronted/src/components/editor/PdfExport.tsx +++ b/apps/fronted/src/components/editor/PdfExport.tsx @@ -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 } : {}} diff --git a/apps/fronted/src/components/editor/SidePanel.tsx b/apps/fronted/src/components/editor/SidePanel.tsx index 2be01ac..0347bbf 100644 --- a/apps/fronted/src/components/editor/SidePanel.tsx +++ b/apps/fronted/src/components/editor/SidePanel.tsx @@ -430,6 +430,40 @@ export function SidePanel() { +
+ +
+ + updateGlobalSettings?.({ sectionSpacing: value }) + } + className={cn( + theme === "dark" + ? "[&_[role=slider]]:bg-neutral-200" + : "[&_[role=slider]]:bg-gray-900" + )} + /> + + {globalSettings?.sectionSpacing}px + +
+
+
diff --git a/apps/fronted/src/components/preview/BaseInfo.tsx b/apps/fronted/src/components/preview/BaseInfo.tsx new file mode 100644 index 0000000..9e58c68 --- /dev/null +++ b/apps/fronted/src/components/preview/BaseInfo.tsx @@ -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 ( +
+ + {basic.name} + + + {basic.title} + + + {[ + 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) => ( + + {item} + {index < array.length - 1 && } + + ))} + +
+ ); +} diff --git a/apps/fronted/src/components/preview/EducationSection.tsx b/apps/fronted/src/components/preview/EducationSection.tsx new file mode 100644 index 0000000..e9f1543 --- /dev/null +++ b/apps/fronted/src/components/preview/EducationSection.tsx @@ -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 ( + + + {education.map((edu) => ( +
+
+
+

+ {edu.school} +

+

+ {edu.degree} +

+
+ + {edu.date} + +
+

+ {edu.details} +

+
+ ))} +
+ ); +} diff --git a/apps/fronted/src/components/preview/ExperienceSection.tsx b/apps/fronted/src/components/preview/ExperienceSection.tsx new file mode 100644 index 0000000..f632458 --- /dev/null +++ b/apps/fronted/src/components/preview/ExperienceSection.tsx @@ -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 ( + + + {experience.map((exp) => ( +
+
+
+

+ {exp.company} +

+

+ {exp.position} +

+
+ + {exp.date} + +
+

+ {exp.details} +

+
+ ))} +
+ ); +} diff --git a/apps/fronted/src/components/preview/PreviewPanel.tsx b/apps/fronted/src/components/preview/PreviewPanel.tsx index b5ddc6b..239ef42 100644 --- a/apps/fronted/src/components/preview/PreviewPanel.tsx +++ b/apps/fronted/src/components/preview/PreviewPanel.tsx @@ -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 ( +
+
+
+
+ 第{pageNumber}页结束 +
+
+
+ ); +}; + export function PreviewPanel() { const { theme, @@ -30,7 +68,10 @@ export function PreviewPanel() { draggingProjectId, colorTheme } = useResumeStore(); + const previewRef = React.useRef(null); + const resumeContentRef = React.useRef(null); + const [contentHeight, setContentHeight] = useState(0); const [scrollBehavior, setScrollBehavior] = React.useState("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` }} > -

- 项目经历 -

+ {projects - .filter((project) => project.visible) // 只显示 visible 为 true 的项目 + .filter((project) => project.visible) .map((project) => ( - - {/* 拖拽高亮效果 */} - {draggingProjectId === project.id && ( - -
-
-
- - )} - - - - - {project.name || "未命名项目"} - - - {project.role} - - - - {project.date} - - - - {/* 项目详情 */} - {project.description && ( - -
- - )} - - {/* 技术栈 */} - {project.technologies && ( - - - 技术栈: - - - {project.technologies} - - - )} - - {/* 主要职责 */} - {project.responsibilities && ( - - - 主要职责: - - - {project.responsibilities} - - - )} - - {/* 项目成就 */} - {project.achievements && ( - - - 项目成就: - - - {project.achievements} - - - )} - + project={project} + draggingProjectId={draggingProjectId} + globalSettings={globalSettings} + /> ))} ); + 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 = () => ( + + +

+ {basic.summary} +

+
+ ); + const renderSection = (sectionId: string) => { switch (sectionId) { case "basic": - return ( - -

- 个人简介 -

-

- {basic.summary} -

-
- ); - + return renderBasicInfo(); case "education": return ( - -

- 教育经历 -

- {education.map((edu) => ( -
-
-
-

- {edu.school} -

-

- {edu.degree} -

-
- - {edu.date} - -
-

- {edu.details} -

-
- ))} -
+ ); - case "experience": return ( - -

- 工作经验 -

- {experience.map((exp) => ( -
-
-
-

- {exp.company} -

-

- {exp.position} -

-
- - {exp.date} - -
-

- {exp.details} -

-
- ))} -
+ ); case "projects": return renderProjects(); @@ -482,61 +262,21 @@ export function PreviewPanel() { fontFamilyClass, "text-[#000]" )} + style={{ + // 设置与 html2pdf 配置一致的尺寸 + minHeight: "297mm" + }} >
- + {/* Header */} -
- - {basic.name} - - - {basic.title} - - - {[ - 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) => ( - - {item} - {index < array.length - 1 && } - - ))} - -
+ {/* Sections */} {menuSections @@ -548,6 +288,11 @@ export function PreviewPanel() {
))}
+ + {/* 动态分页指示线 */} + {Array.from({ length: pageBreakCount }, (_, i) => ( + + ))}
diff --git a/apps/fronted/src/components/preview/ProjectItem.tsx b/apps/fronted/src/components/preview/ProjectItem.tsx new file mode 100644 index 0000000..7482782 --- /dev/null +++ b/apps/fronted/src/components/preview/ProjectItem.tsx @@ -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 ( + + {isDragging && ( + +
+
+
+ + )} + + + + + {project.name || "未命名项目"} + + + {project.role} + + + + {project.date} + + + + {project.description && ( + +
+ + )} + + ); +} diff --git a/apps/fronted/src/components/preview/SectionTitle.tsx b/apps/fronted/src/components/preview/SectionTitle.tsx new file mode 100644 index 0000000..9a46134 --- /dev/null +++ b/apps/fronted/src/components/preview/SectionTitle.tsx @@ -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 ( +

+ {title} +

+ ); +} diff --git a/apps/fronted/src/store/useResumeStore.ts b/apps/fronted/src/store/useResumeStore.ts index 4244960..0def9ab 100644 --- a/apps/fronted/src/store/useResumeStore.ts +++ b/apps/fronted/src/store/useResumeStore.ts @@ -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()( diff --git a/apps/fronted/src/types/resume.ts b/apps/fronted/src/types/resume.ts index c36b59c..85b3f1c 100644 --- a/apps/fronted/src/types/resume.ts +++ b/apps/fronted/src/types/resume.ts @@ -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 {