mirror of
https://github.com/JOYCEQL/magic-resume.git
synced 2026-07-03 14:07:11 +02:00
feat: baseInfoPanel visible control
This commit is contained in:
@@ -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"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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<Props> = ({ className, theme }) => {
|
||||
<Image className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">头像</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
onClick={() => setShowConfig(true)}
|
||||
>
|
||||
<Settings2 className="w-3 h-3" />
|
||||
</Button>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
onClick={() => setShowConfig(true)}
|
||||
>
|
||||
<Settings2 className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
onClick={() => {
|
||||
updateBasicInfo({
|
||||
...basic,
|
||||
photoConfig: {
|
||||
...basic.photoConfig,
|
||||
visible: !(basic.photoConfig?.visible ?? true)
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{basic.photoConfig?.visible !== false ? (
|
||||
<Eye className="w-4 h-4" />
|
||||
) : (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{basic.photo && (
|
||||
|
||||
@@ -15,16 +15,17 @@ type FieldProps = {
|
||||
type?: "text" | "textarea" | "date" | "editor";
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
// Field 组件
|
||||
const Field: React.FC<FieldProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
type = "text",
|
||||
placeholder,
|
||||
required
|
||||
required,
|
||||
className
|
||||
}) => {
|
||||
const theme = useResumeStore((state) => state.theme);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<motion.button
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium flex items-center space-x-2
|
||||
${
|
||||
theme === "dark"
|
||||
? "bg-indigo-600 hover:bg-indigo-700 text-white"
|
||||
: "bg-black hover:bg-neutral-800 text-white"
|
||||
}
|
||||
className={`bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-medium flex items-center space-x-2
|
||||
disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
whileHover={!isExporting ? { scale: 1.02 } : {}}
|
||||
whileTap={!isExporting ? { scale: 0.98 } : {}}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { cn } from "@/lib/utils";
|
||||
import { THEME_COLORS } from "@/types/resume";
|
||||
import debounce from "lodash/debounce";
|
||||
import { useMemo } from "react";
|
||||
import { Switch } from "../ui/switch";
|
||||
|
||||
const fontOptions = [
|
||||
{ value: "sans", label: "无衬线体" },
|
||||
@@ -603,6 +604,28 @@ export function SidePanel() {
|
||||
</div>
|
||||
</div>
|
||||
</SettingCard>
|
||||
<SettingCard icon={SpaceIcon} title="模式">
|
||||
<div>
|
||||
<Label
|
||||
className={cn(
|
||||
theme === "dark" ? "text-neutral-300" : "text-gray-600"
|
||||
)}
|
||||
>
|
||||
图标模式
|
||||
</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Switch
|
||||
checked={globalSettings.useIconMode}
|
||||
onCheckedChange={(checked) =>
|
||||
updateGlobalSettings({
|
||||
...globalSettings,
|
||||
useIconMode: checked
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingCard>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -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<string, string>;
|
||||
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<CustomFieldProps> = ({
|
||||
field,
|
||||
@@ -55,198 +58,330 @@ const CustomField: React.FC<CustomFieldProps> = ({
|
||||
id={field.id}
|
||||
className="group touch-none list-none"
|
||||
>
|
||||
<div
|
||||
<motion.div
|
||||
{...itemAnimations}
|
||||
className={cn(
|
||||
"grid grid-cols-[auto_auto_1fr_1fr_auto] gap-2 items-center p-2 rounded-lg border border-transparent",
|
||||
theme === "dark"
|
||||
? "hover:border-neutral-700"
|
||||
: "hover:border-neutral-200"
|
||||
"grid grid-cols-[auto_auto_1fr_1fr_auto_auto] gap-3 items-center p-3",
|
||||
"bg-white dark:bg-neutral-800 rounded-xl shadow-sm",
|
||||
"border border-neutral-100 dark:border-neutral-700",
|
||||
"transition-all duration-200",
|
||||
"hover:shadow-md hover:border-neutral-200 dark:hover:border-neutral-600",
|
||||
!field.visible && "opacity-60"
|
||||
)}
|
||||
>
|
||||
<GripVertical className="w-4 h-4 cursor-grab active:cursor-grabbing text-neutral-400" />
|
||||
<IconSelector
|
||||
value={field.icon}
|
||||
onChange={(value) => onUpdate({ ...field, icon: value })}
|
||||
theme={theme}
|
||||
/>
|
||||
<div className="flex items-center justify-center">
|
||||
<GripVertical
|
||||
className={cn(
|
||||
"w-4 h-4 cursor-grab active:cursor-grabbing",
|
||||
"text-neutral-300 dark:text-neutral-500",
|
||||
"transition-colors duration-200",
|
||||
"group-hover:text-neutral-400 dark:group-hover:text-neutral-400"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<IconSelector
|
||||
value={field.icon}
|
||||
onChange={(value) => onUpdate({ ...field, icon: value })}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
label=""
|
||||
value={field.label}
|
||||
onChange={(value) => 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"
|
||||
)}
|
||||
/>
|
||||
<Field
|
||||
label=""
|
||||
value={field.value}
|
||||
onChange={(value) => onUpdate({ ...field, value })}
|
||||
placeholder="字段内容"
|
||||
/>
|
||||
|
||||
<Trash2
|
||||
onClick={() => 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"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{field.visible ? (
|
||||
<Eye
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={() => onUpdate({ ...field, visible: !field.visible })}
|
||||
/>
|
||||
) : (
|
||||
<EyeOff
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={() => onUpdate({ ...field, visible: !field.visible })}
|
||||
/>
|
||||
)}
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => 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"
|
||||
)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</Reorder.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const BasicPanel: React.FC = () => {
|
||||
const { basic, updateBasicInfo, theme } = useResumeStore();
|
||||
const [fields, setFields] = useState<CustomFieldType[]>(
|
||||
basic?.customFields || []
|
||||
const [customFields, setCustomFields] = useState<CustomFieldType[]>(
|
||||
basic?.customFields?.map((field) => ({
|
||||
...field,
|
||||
visible: field.visible ?? true
|
||||
})) || []
|
||||
);
|
||||
const [basicFields, setBasicFields] = useState<BasicFieldType[]>(() => {
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconSelector
|
||||
value={selectedIcon}
|
||||
onChange={(value) => {
|
||||
updateBasicInfo({
|
||||
...basic,
|
||||
icons: {
|
||||
...(basic?.icons || {}),
|
||||
[key]: value
|
||||
}
|
||||
});
|
||||
}}
|
||||
theme={theme}
|
||||
/>
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label=""
|
||||
value={isCustom ? field?.value || "" : (basic?.[key] as string) || ""}
|
||||
onChange={(value) =>
|
||||
isCustom && field
|
||||
? updateField({ ...field, value })
|
||||
: updateBasicInfo({ ...basic, [key]: value })
|
||||
}
|
||||
placeholder={`请输入${label}`}
|
||||
type={type}
|
||||
/>
|
||||
</div>
|
||||
<Reorder.Item
|
||||
value={field}
|
||||
id={field.id}
|
||||
key={field.id}
|
||||
className="group touch-none list-none"
|
||||
>
|
||||
<motion.div
|
||||
{...itemAnimations}
|
||||
className={cn(
|
||||
"grid grid-cols-[auto_auto_1fr_auto] gap-3 items-center p-3",
|
||||
"bg-white dark:bg-neutral-800 rounded-xl shadow-sm",
|
||||
"border border-neutral-100 dark:border-neutral-700",
|
||||
"transition-all duration-200",
|
||||
"hover:shadow-md hover:border-neutral-200 dark:hover:border-neutral-600",
|
||||
!field.visible && "opacity-60"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<GripVertical
|
||||
className={cn(
|
||||
"w-4 h-4 cursor-grab active:cursor-grabbing",
|
||||
"text-neutral-300 dark:text-neutral-500",
|
||||
"transition-colors duration-200",
|
||||
"group-hover:text-neutral-400 dark:group-hover:text-neutral-400"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<IconSelector
|
||||
value={selectedIcon}
|
||||
onChange={(value) => {
|
||||
updateBasicInfo({
|
||||
...basic,
|
||||
icons: {
|
||||
...(basic?.icons || {}),
|
||||
[field.key]: value
|
||||
}
|
||||
});
|
||||
}}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
|
||||
{field.label}
|
||||
</span>
|
||||
</div>
|
||||
<Field
|
||||
label=""
|
||||
value={(basic[field.key] as string) || ""}
|
||||
onChange={(value) =>
|
||||
updateBasicInfo({
|
||||
...basic,
|
||||
[field.key]: value
|
||||
})
|
||||
}
|
||||
placeholder={`请输入${field.label}`}
|
||||
type={field.type}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
onClick={() => toggleFieldVisibility(field.id, !field.visible)}
|
||||
>
|
||||
{field.visible ? (
|
||||
<Eye className="w-4 h-4 cursor-pointer" />
|
||||
) : (
|
||||
<EyeOff className="w-4 h-4 cursor-pointer" />
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</Reorder.Item>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5 p-4">
|
||||
<PhotoUpload theme={theme} />
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="flex-1">{renderField("name", "姓名")}</div>
|
||||
<div className="space-y-6 p-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white dark:bg-neutral-800 rounded-xl p-3 shadow-sm border border-neutral-100 dark:border-neutral-700"
|
||||
>
|
||||
<PhotoUpload theme={theme} />
|
||||
</motion.div>
|
||||
|
||||
<div>{renderField("employementStatus", "在职状态")}</div>
|
||||
</div>
|
||||
{renderField("title", "职位")}
|
||||
{renderField("birthDate", "出生日期", { type: "date" })}
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
{renderField("email", "电子邮箱")}
|
||||
{renderField("phone", "电话")}
|
||||
</div>
|
||||
{renderField("location", "所在地")}
|
||||
{renderField("summary", "个人简介", { type: "textarea" })}
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium text-neutral-900 dark:text-neutral-200 px-1">
|
||||
基本字段
|
||||
</h3>
|
||||
<AnimatePresence mode="popLayout">
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
as="div"
|
||||
values={basicFields}
|
||||
onReorder={handleBasicReorder}
|
||||
className="space-y-3"
|
||||
>
|
||||
{basicFields.map((field) => renderBasicField(field))}
|
||||
</Reorder.Group>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* 自定义字段 */}
|
||||
<div className="space-y-4">
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={fields}
|
||||
onReorder={handleReorder}
|
||||
className="space-y-2"
|
||||
>
|
||||
{Array.isArray(fields) &&
|
||||
fields.map((field) => (
|
||||
<CustomField
|
||||
key={field.id}
|
||||
field={field}
|
||||
onUpdate={updateField}
|
||||
onDelete={deleteField}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium text-neutral-900 dark:text-neutral-200 px-1">
|
||||
自定义字段
|
||||
</h3>
|
||||
<AnimatePresence mode="popLayout">
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
as="div"
|
||||
values={customFields}
|
||||
onReorder={handleCustomFieldsReorder}
|
||||
className="space-y-3"
|
||||
>
|
||||
{Array.isArray(customFields) &&
|
||||
customFields.map((field) => (
|
||||
<CustomField
|
||||
key={field.id}
|
||||
field={field}
|
||||
onUpdate={updateCustomField}
|
||||
onDelete={deleteCustomField}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
</AnimatePresence>
|
||||
|
||||
<Button
|
||||
onClick={addCustomField}
|
||||
className={cn(
|
||||
"w-full",
|
||||
theme === "dark"
|
||||
? "bg-indigo-600 hover:bg-indigo-700 text-white"
|
||||
: "bg-black hover:bg-neutral-800 text-white"
|
||||
)}
|
||||
>
|
||||
<PlusCircle
|
||||
className={cn(
|
||||
"w-4 h-4 mr-2",
|
||||
theme === "dark" ? "text-neutral-400" : "text-neutral-500"
|
||||
)}
|
||||
/>
|
||||
添加自定义字段
|
||||
</Button>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<Button
|
||||
onClick={addCustomField}
|
||||
className={cn(
|
||||
"w-full mt-4 transition-colors",
|
||||
"text-white shadow-sm",
|
||||
"bg-indigo-600",
|
||||
"hover:bg-indigo-700 hover:bg-indigo-700"
|
||||
)}
|
||||
>
|
||||
<PlusCircle className="w-4 h-4 mr-2" />
|
||||
添加自定义字段
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 ? <IconComponent className="w-4 h-4" /> : 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 (
|
||||
<div className="text-center space-y-4">
|
||||
{basic.photo && (
|
||||
{/* 头像部分 */}
|
||||
{basic.photo && basic.photoConfig?.visible && (
|
||||
<motion.div layout="position" className="flex justify-center">
|
||||
<div
|
||||
style={{
|
||||
@@ -39,47 +124,56 @@ export function BaseInfo({ basic, globalSettings }: BaaseInfoProps) {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{/* 姓名 - 仅在可见时显示 */}
|
||||
{nameField && basic[nameField.key] && (
|
||||
<motion.h1
|
||||
layout="position"
|
||||
className="font-bold text-gray-900"
|
||||
style={{
|
||||
fontSize: `${(globalSettings?.headerSize || 24) * 1.5}px`
|
||||
}}
|
||||
>
|
||||
{basic[nameField.key] as string}
|
||||
</motion.h1>
|
||||
)}
|
||||
|
||||
{/* 职位 - 仅在可见时显示 */}
|
||||
{titleField && basic[titleField.key] && (
|
||||
<motion.h2
|
||||
layout="position"
|
||||
className="text-gray-600"
|
||||
style={{
|
||||
fontSize: `${globalSettings?.subheaderSize || 16}px`
|
||||
}}
|
||||
>
|
||||
{basic[titleField.key] as string}
|
||||
</motion.h2>
|
||||
)}
|
||||
|
||||
{/* 其他字段 */}
|
||||
<motion.div
|
||||
layout="position"
|
||||
className="flex justify-center items-center space-x-4 flex-wrap"
|
||||
className="flex justify-center items-center flex-wrap gap-3"
|
||||
style={{
|
||||
fontSize: `${globalSettings?.baseFontSize || 14}px`,
|
||||
color: "rgb(75, 85, 99)"
|
||||
}}
|
||||
>
|
||||
{[
|
||||
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) => (
|
||||
<React.Fragment key={index}>
|
||||
<span>{item}</span>
|
||||
{index < array.length - 1 && <span>•</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{allFields.map((item, index) => (
|
||||
<div key={item.key} className="flex items-center">
|
||||
{useIconMode ? (
|
||||
<span className="flex items-center gap-1">
|
||||
{getIcon(item.icon)}
|
||||
<span>{item.value}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-gray-500">{item.label}:</span>
|
||||
<span>{item.value}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -141,7 +141,8 @@ const initialState = {
|
||||
lineHeight: 1,
|
||||
sectionSpacing: 20,
|
||||
headerSize: 18,
|
||||
subheaderSize: 16
|
||||
subheaderSize: 16,
|
||||
useIconMode: false
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user