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 {