diff --git a/apps/fronted/src/app/workbench/page.tsx b/apps/fronted/src/app/workbench/page.tsx index b3955fd..248a30e 100644 --- a/apps/fronted/src/app/workbench/page.tsx +++ b/apps/fronted/src/app/workbench/page.tsx @@ -284,7 +284,7 @@ export default function Home() { className={cn( "w-full h-screen overflow-hidden", theme === "dark" - ? "bg-neutral-900 text-neutral-200" + ? "dark bg-neutral-900 text-neutral-200" : "bg-white text-gray-900" )} > diff --git a/apps/fronted/src/components/PhotoSelector.tsx b/apps/fronted/src/components/PhotoSelector.tsx index 5ea8688..77de09d 100644 --- a/apps/fronted/src/components/PhotoSelector.tsx +++ b/apps/fronted/src/components/PhotoSelector.tsx @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import { Settings2, Image } from "lucide-react"; +import { Settings2, Image, EyeOff, Eye } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import PhotoConfigDrawer from "./PhotoConfigDrawer"; @@ -40,14 +40,36 @@ const PhotoSelector: React.FC = ({ className, theme }) => { 头像 - +
+ + +
{basic.photo && ( diff --git a/apps/fronted/src/components/editor/Field.tsx b/apps/fronted/src/components/editor/Field.tsx index 313ee84..4bd6825 100644 --- a/apps/fronted/src/components/editor/Field.tsx +++ b/apps/fronted/src/components/editor/Field.tsx @@ -15,16 +15,17 @@ type FieldProps = { type?: "text" | "textarea" | "date" | "editor"; placeholder?: string; required?: boolean; + className?: string; }; -// Field 组件 const Field: React.FC = ({ label, value, onChange, type = "text", placeholder, - required + required, + className }) => { const theme = useResumeStore((state) => state.theme); diff --git a/apps/fronted/src/components/editor/PdfExport.tsx b/apps/fronted/src/components/editor/PdfExport.tsx index c2eba7b..239decd 100644 --- a/apps/fronted/src/components/editor/PdfExport.tsx +++ b/apps/fronted/src/components/editor/PdfExport.tsx @@ -10,27 +10,6 @@ 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"); @@ -41,10 +20,10 @@ export function PdfExport() { // 内容高度的问题 const contentHeight = element.scrollHeight - 30.22; const A4_HEIGHT_MM = 297; - const MARGINS_MM = 8; // 上下边距各4mm + const MARGINS_MM = 8; const CONTENT_HEIGHT_MM = A4_HEIGHT_MM - MARGINS_MM; - const DPI = 96; // 标准屏幕DPI - const MM_TO_PX = DPI / 25.4; // 1英寸=25.4毫米 + const DPI = 96; + const MM_TO_PX = DPI / 25.4; const pageHeightPx = Math.ceil(CONTENT_HEIGHT_MM * MM_TO_PX); const numberOfPages = Math.ceil(contentHeight / pageHeightPx); @@ -67,13 +46,13 @@ export function PdfExport() { filename: `${basic.name}_简历.pdf`, image: { type: "jpeg", quality: 0.98 }, pagebreak: { - mode: ["css", "legacy"] // 避免在元素中间断页 + mode: ["css", "legacy"] }, html2canvas: { - scale: window.devicePixelRatio * 3, // 增加清晰度 useCORS: true, + scale: window.devicePixelRatio * 3, letterRendering: true, - scrollY: -window.scrollY, // 添加这个 - height: pageHeightPx * numberOfPages // 设置为精确的高度 + scrollY: -window.scrollY, + height: pageHeightPx * numberOfPages }, jsPDF: { unit: "mm", @@ -104,12 +83,7 @@ export function PdfExport() { return (
+ +
+ +
+ + updateGlobalSettings({ + ...globalSettings, + useIconMode: checked + }) + } + /> +
+
+
); diff --git a/apps/fronted/src/components/editor/basic/BasicPanel.tsx b/apps/fronted/src/components/editor/basic/BasicPanel.tsx index 7d81077..aea58c1 100644 --- a/apps/fronted/src/components/editor/basic/BasicPanel.tsx +++ b/apps/fronted/src/components/editor/basic/BasicPanel.tsx @@ -1,47 +1,50 @@ -import React, { useState } from "react"; -import { PlusCircle, GripVertical, Trash2, LucideIcon } from "lucide-react"; +import React, { useState, useEffect } from "react"; +import { PlusCircle, GripVertical, Trash2, Eye, EyeOff } from "lucide-react"; import { Button } from "@/components/ui/button"; -import Field from "../Field"; -import { useResumeStore } from "@/store/useResumeStore"; import { cn } from "@/lib/utils"; -import { Reorder } from "framer-motion"; +import { Reorder, AnimatePresence, motion } from "framer-motion"; import IconSelector from "../IconSelector"; import PhotoUpload from "@/components/PhotoSelector"; +import Field from "../Field"; +import { useResumeStore } from "@/store/useResumeStore"; +import { BasicFieldType, CustomFieldType } from "@/types/resume"; -type CustomFieldType = { - id: string; - label: string; - value: string; - icon: string; -}; - -type Theme = "dark" | "light"; - -interface IconSelectorProps { - value: string; - onChange: (value: string) => void; - theme: Theme; -} +const DEFAULT_FIELD_ORDER: BasicFieldType[] = [ + { id: "1", key: "name", label: "姓名", type: "text", visible: true }, + { + id: "2", + key: "employementStatus", + label: "求职状态", + type: "text", + visible: true + }, + { id: "3", key: "title", label: "职位", type: "text", visible: true }, + { id: "4", key: "birthDate", label: "出生日期", type: "date", visible: true }, + { id: "5", key: "email", label: "电子邮箱", type: "text", visible: true }, + { id: "6", key: "phone", label: "电话", type: "text", visible: true }, + { id: "7", key: "location", label: "所在地", type: "text", visible: true }, + { + id: "8", + key: "summary", + label: "个人简介", + type: "textarea", + visible: true + } +]; interface CustomFieldProps { field: CustomFieldType; onUpdate: (field: CustomFieldType) => void; onDelete: (id: string) => void; - theme: Theme; + theme: "dark" | "light"; } -interface BasicInfo { - name?: string; - title?: string; - birthDate?: string; - email?: string; - phone?: string; - location?: string; - summary?: string; - customFields?: CustomFieldType[]; - icons?: Record; - employementStatus?: string; -} +const itemAnimations = { + initial: { opacity: 0, y: 20 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -20 }, + transition: { type: "spring", stiffness: 500, damping: 50, mass: 1 } +}; const CustomField: React.FC = ({ field, @@ -55,198 +58,330 @@ const CustomField: React.FC = ({ id={field.id} className="group touch-none list-none" > -
- - onUpdate({ ...field, icon: value })} - theme={theme} - /> +
+ +
+
+ onUpdate({ ...field, icon: value })} + theme={theme} + /> +
onUpdate({ ...field, label: value })} placeholder="字段名称" + className={cn( + "bg-neutral-50 dark:bg-neutral-900", + "border-neutral-200 dark:border-neutral-700", + "focus:border-blue-500 dark:focus:border-blue-400", + "placeholder-neutral-400 dark:placeholder-neutral-500" + )} /> onUpdate({ ...field, value })} placeholder="字段内容" - /> - - onDelete(field.id)} - size={26} className={cn( - "p-1.5 rounded-md cursor-pointer", - theme === "dark" - ? "text-red-400 hover:text-red-300" - : "text-red-500 hover:text-red-600" + "bg-neutral-50 dark:bg-neutral-900", + "border-neutral-200 dark:border-neutral-700", + "focus:border-blue-500 dark:focus:border-blue-400", + "placeholder-neutral-400 dark:placeholder-neutral-500" )} /> -
+ + {field.visible ? ( + onUpdate({ ...field, visible: !field.visible })} + /> + ) : ( + onUpdate({ ...field, visible: !field.visible })} + /> + )} + onDelete(field.id)} + className={cn( + "p-2 rounded-lg transition-colors", + "hover:bg-red-100 dark:hover:bg-red-900/40", + "text-red-500 bg-red-50 dark:bg-red-900/20" + )} + > + + + ); }; const BasicPanel: React.FC = () => { const { basic, updateBasicInfo, theme } = useResumeStore(); - const [fields, setFields] = useState( - basic?.customFields || [] + const [customFields, setCustomFields] = useState( + basic?.customFields?.map((field) => ({ + ...field, + visible: field.visible ?? true + })) || [] ); + const [basicFields, setBasicFields] = useState(() => { + if (!basic.fieldOrder) { + return DEFAULT_FIELD_ORDER; + } + return basic.fieldOrder.map((field) => ({ + ...field, + visible: field.visible ?? true + })); + }); + + useEffect(() => { + if (!basic.fieldOrder) { + updateBasicInfo({ + ...basic, + fieldOrder: DEFAULT_FIELD_ORDER + }); + } + }, []); + + const handleBasicReorder = (newOrder: BasicFieldType[]) => { + setBasicFields(newOrder); + updateBasicInfo({ + ...basic, + fieldOrder: newOrder + }); + }; + + const toggleFieldVisibility = (fieldId: string, isVisible: boolean) => { + const newFields = basicFields.map((field) => + field.id === fieldId ? { ...field, visible: isVisible } : field + ); + setBasicFields(newFields); + updateBasicInfo({ + ...basic, + fieldOrder: newFields + }); + }; const addCustomField = () => { const fieldToAdd: CustomFieldType = { id: crypto.randomUUID(), label: "", value: "", - icon: "User" + icon: "User", + visible: true }; - const updatedFields = [...fields, fieldToAdd]; - setFields(updatedFields); + const updatedFields = [...customFields, fieldToAdd]; + setCustomFields(updatedFields); updateBasicInfo({ ...basic, customFields: updatedFields }); }; - const updateField = (updatedField: CustomFieldType) => { - const updatedFields = fields.map((field) => + const updateCustomField = (updatedField: CustomFieldType) => { + const updatedFields = customFields.map((field) => field.id === updatedField.id ? updatedField : field ); - setFields(updatedFields); + setCustomFields(updatedFields); updateBasicInfo({ ...basic, customFields: updatedFields }); }; - const deleteField = (id: string) => { - const updatedFields = fields.filter((field) => field.id !== id); - setFields(updatedFields); + const deleteCustomField = (id: string) => { + const updatedFields = customFields.filter((field) => field.id !== id); + setCustomFields(updatedFields); updateBasicInfo({ ...basic, customFields: updatedFields }); }; - const handleReorder = (newOrder: CustomFieldType[]) => { - setFields(newOrder); + const handleCustomFieldsReorder = (newOrder: CustomFieldType[]) => { + setCustomFields(newOrder); updateBasicInfo({ ...basic, customFields: newOrder }); }; - - const renderField = ( - key: keyof BasicInfo, - label: string, - options: { - type?: "date" | "textarea" | "text" | "editor" | undefined; - isCustom?: boolean; - field?: CustomFieldType | null; - } = {} - ) => { - const { type = "text", isCustom = false, field = null } = options; - - const selectedIcon = basic?.icons?.[key] || "User"; + const renderBasicField = (field: BasicFieldType) => { + const selectedIcon = basic?.icons?.[field.key] || "User"; return ( -
-
- { - updateBasicInfo({ - ...basic, - icons: { - ...(basic?.icons || {}), - [key]: value - } - }); - }} - theme={theme} - /> - {label} -
- - - isCustom && field - ? updateField({ ...field, value }) - : updateBasicInfo({ ...basic, [key]: value }) - } - placeholder={`请输入${label}`} - type={type} - /> -
+ + +
+ +
+
+ { + updateBasicInfo({ + ...basic, + icons: { + ...(basic?.icons || {}), + [field.key]: value + } + }); + }} + theme={theme} + /> +
+
+
+ + {field.label} + +
+ + updateBasicInfo({ + ...basic, + [field.key]: value + }) + } + placeholder={`请输入${field.label}`} + type={field.type} + /> +
+ +
+
); }; return ( -
- -
-
{renderField("name", "姓名")}
+
+ + + -
{renderField("employementStatus", "在职状态")}
-
- {renderField("title", "职位")} - {renderField("birthDate", "出生日期", { type: "date" })} -
- {renderField("email", "电子邮箱")} - {renderField("phone", "电话")} -
- {renderField("location", "所在地")} - {renderField("summary", "个人简介", { type: "textarea" })} +
+
+

+ 基本字段 +

+ + + {basicFields.map((field) => renderBasicField(field))} + + +
- {/* 自定义字段 */} -
- - {Array.isArray(fields) && - fields.map((field) => ( - - ))} - +
+

+ 自定义字段 +

+ + + {Array.isArray(customFields) && + customFields.map((field) => ( + + ))} + + - + + + +
); diff --git a/apps/fronted/src/components/preview/BaseInfo.tsx b/apps/fronted/src/components/preview/BaseInfo.tsx index aceb9e0..8a45efa 100644 --- a/apps/fronted/src/components/preview/BaseInfo.tsx +++ b/apps/fronted/src/components/preview/BaseInfo.tsx @@ -2,20 +2,105 @@ import { cn } from "@/lib/utils"; import { BasicInfo, getBorderRadiusValue, - GlobalSettings + GlobalSettings, + BasicFieldType } from "@/types/resume"; import { motion } from "framer-motion"; import React from "react"; +import * as Icons from "lucide-react"; -interface BaaseInfoProps { +interface BaseInfoProps { basic: BasicInfo; globalSettings: GlobalSettings | undefined; } -export function BaseInfo({ basic, globalSettings }: BaaseInfoProps) { +export function BaseInfo({ basic, globalSettings }: BaseInfoProps) { + const useIconMode = globalSettings?.useIconMode ?? false; + + const getIcon = (iconName: string) => { + const IconComponent = Icons[ + iconName as keyof typeof Icons + ] as React.ElementType; + return IconComponent ? : null; + }; + + const getOrderedFields = () => { + if (!basic.fieldOrder) { + return [ + { + key: "email", + value: basic.email, + icon: basic.icons?.email || "Mail", + label: "电子邮箱", + visible: true + } + ].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] + ? new Date(basic[field.key] as string).toLocaleDateString() + : (basic[field.key] as string), + icon: basic.icons?.[field.key] || "User", + label: field.label, + visible: field.visible + })) + .filter((item) => Boolean(item.value)); + }; + + const orderedFields = getOrderedFields(); + + const allFields = [ + ...orderedFields, + ...(basic.customFields + ?.filter((field) => field.visible !== false) + .map((field) => ({ + key: field.id, + value: field.value, + icon: field.icon, + label: field.label, + visible: true + })) || []) + ]; + + const getNameField = () => { + const nameField = basic.fieldOrder?.find( + (field) => field.key === "name" + ) || { + key: "name", + label: "姓名", + visible: true + }; + return nameField.visible !== false ? nameField : null; + }; + + const getTitleField = () => { + const titleField = basic.fieldOrder?.find( + (field) => field.key === "title" + ) || { + key: "title", + label: "职位", + visible: true + }; + return titleField.visible !== false ? titleField : null; + }; + + const nameField = getNameField(); + const titleField = getTitleField(); + return (
- {basic.photo && ( + {/* 头像部分 */} + {basic.photo && basic.photoConfig?.visible && (
)} - - {basic.name} - - - {basic.title} - + {/* 姓名 - 仅在可见时显示 */} + {nameField && basic[nameField.key] && ( + + {basic[nameField.key] as string} + + )} + + {/* 职位 - 仅在可见时显示 */} + {titleField && basic[titleField.key] && ( + + {basic[titleField.key] as string} + + )} + + {/* 其他字段 */} - {[ - basic.email, - basic.phone, - basic.location, - basic.employementStatus, - basic.birthDate ? new Date(basic.birthDate).toLocaleDateString() : "", - ...(basic.customFields?.map((field) => field.value) || []) - ] - .filter(Boolean) - .map((item, index, array) => ( - - {item} - {index < array.length - 1 && } - - ))} + {allFields.map((item, index) => ( +
+ {useIconMode ? ( + + {getIcon(item.icon)} + {item.value} + + ) : ( + + {item.label}: + {item.value} + + )} +
+ ))}
); diff --git a/apps/fronted/src/store/useResumeStore.ts b/apps/fronted/src/store/useResumeStore.ts index 8d15696..9b2631c 100644 --- a/apps/fronted/src/store/useResumeStore.ts +++ b/apps/fronted/src/store/useResumeStore.ts @@ -141,7 +141,8 @@ const initialState = { lineHeight: 1, sectionSpacing: 20, headerSize: 18, - subheaderSize: 16 + subheaderSize: 16, + useIconMode: false } }; diff --git a/apps/fronted/src/types/resume.ts b/apps/fronted/src/types/resume.ts index b13f96e..140da74 100644 --- a/apps/fronted/src/types/resume.ts +++ b/apps/fronted/src/types/resume.ts @@ -4,6 +4,7 @@ export interface PhotoConfig { aspectRatio: "1:1" | "4:3" | "3:4" | "16:9" | "custom"; borderRadius: "none" | "medium" | "full" | "custom"; customBorderRadius: number; + visible?: boolean; } export const DEFAULT_CONFIG: PhotoConfig = { @@ -11,7 +12,8 @@ export const DEFAULT_CONFIG: PhotoConfig = { height: 96, aspectRatio: "1:1", borderRadius: "none", - customBorderRadius: 0 + customBorderRadius: 0, + visible: true }; // 助手函数 @@ -43,6 +45,21 @@ export const getBorderRadiusValue = (config?: PhotoConfig) => { } }; +export interface BasicFieldType { + id: string; + key: keyof BasicInfo; + label: string; + type?: "date" | "textarea" | "text" | "editor"; + visible: boolean; +} + +export interface CustomFieldType { + id: string; + label: string; + value: string; + icon: string; + visible?: boolean; +} export interface BasicInfo { birthDate: string; name: string; @@ -55,11 +72,13 @@ export interface BasicInfo { employementStatus: string; photo: string; photoConfig: PhotoConfig; + fieldOrder?: BasicFieldType[]; // 新增字段排序 customFields: Array<{ id: string; label: string; value: string; icon: string; + visible?: boolean; }>; } @@ -108,6 +127,7 @@ export type GlobalSettings = { sectionSpacing?: number | undefined; headerSize?: number | undefined; subheaderSize?: number | undefined; + useIconMode?: boolean | undefined; }; export interface ResumeTheme {