mirror of
https://github.com/JOYCEQL/magic-resume.git
synced 2026-07-03 14:07:11 +02:00
perf: baseinfo custom and field
This commit is contained in:
@@ -11,21 +11,66 @@ import {
|
||||
FileText,
|
||||
Smartphone,
|
||||
Globe,
|
||||
Github
|
||||
Github,
|
||||
GripVertical,
|
||||
Trash2,
|
||||
LucideIcon
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import Field from "../Field";
|
||||
import { useResumeStore } from "@/store/useResumeStore";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@/components/ui/popover";
|
||||
import { motion, Reorder } from "framer-motion";
|
||||
|
||||
// 预定义图标选项
|
||||
const iconOptions = [
|
||||
type IconOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
type CustomFieldType = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
type Theme = "dark" | "light";
|
||||
|
||||
interface IconSelectorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
interface CustomFieldProps {
|
||||
field: CustomFieldType;
|
||||
onUpdate: (field: CustomFieldType) => void;
|
||||
onDelete: (id: string) => void;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
interface BasicInfo {
|
||||
name?: string;
|
||||
title?: string;
|
||||
birthDate?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
location?: string;
|
||||
summary?: string;
|
||||
employed?: boolean;
|
||||
customFields?: CustomFieldType[];
|
||||
icons?: Record<string, string>;
|
||||
}
|
||||
|
||||
const iconOptions: IconOption[] = [
|
||||
{ label: "用户", value: "User", icon: User },
|
||||
{ label: "工作", value: "Briefcase", icon: Briefcase },
|
||||
{ label: "日期", value: "Calendar", icon: Calendar },
|
||||
@@ -38,10 +83,15 @@ const iconOptions = [
|
||||
{ label: "Github", value: "Github", icon: Github }
|
||||
];
|
||||
|
||||
const IconSelector = ({ value, onChange, theme }) => {
|
||||
const IconSelector: React.FC<IconSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
theme
|
||||
}) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const selectedIcon =
|
||||
iconOptions.find((i) => i.value === value) || iconOptions[0];
|
||||
const Icon = selectedIcon.icon;
|
||||
|
||||
const handleSelect = (iconValue: string) => {
|
||||
onChange(iconValue);
|
||||
@@ -51,14 +101,7 @@ const IconSelector = ({ value, onChange, theme }) => {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors",
|
||||
theme === "dark" ? "text-neutral-200" : "text-neutral-700"
|
||||
)}
|
||||
>
|
||||
<selectedIcon.icon className="w-4 h-4" />
|
||||
</button>
|
||||
<Icon className="w-4 h-4" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={cn(
|
||||
@@ -67,7 +110,7 @@ const IconSelector = ({ value, onChange, theme }) => {
|
||||
)}
|
||||
>
|
||||
{iconOptions.map(({ value: iconValue, icon: Icon, label }) => (
|
||||
<button
|
||||
<Button
|
||||
key={iconValue}
|
||||
onClick={() => handleSelect(iconValue)}
|
||||
className={cn(
|
||||
@@ -81,41 +124,91 @@ const IconSelector = ({ value, onChange, theme }) => {
|
||||
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 text-xs rounded bg-black text-white opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const BasicPanel = () => {
|
||||
const CustomField: React.FC<CustomFieldProps> = ({
|
||||
field,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
theme
|
||||
}) => {
|
||||
return (
|
||||
<Reorder.Item
|
||||
value={field}
|
||||
id={field.id}
|
||||
className="group touch-none list-none"
|
||||
>
|
||||
<div
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
<Field
|
||||
label=""
|
||||
value={field.label}
|
||||
onChange={(value) => onUpdate({ ...field, label: value })}
|
||||
placeholder="字段名称"
|
||||
/>
|
||||
<Field
|
||||
label=""
|
||||
value={field.value}
|
||||
onChange={(value) => onUpdate({ ...field, value })}
|
||||
placeholder="字段内容"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => onDelete(field.id)}
|
||||
className={cn(
|
||||
"p-1.5 rounded-md opacity-0 group-hover:opacity-100 transition-opacity",
|
||||
theme === "dark"
|
||||
? "hover:bg-red-500/20 text-red-400 hover:text-red-300"
|
||||
: "hover:bg-red-500/10 text-red-500 hover:text-red-600"
|
||||
)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const BasicPanel: React.FC = () => {
|
||||
const { basic, updateBasicInfo, theme } = useResumeStore();
|
||||
const [fields, setFields] = useState(basic?.customFields || []);
|
||||
const [newLabel, setNewLabel] = useState("");
|
||||
const [newValue, setNewValue] = useState("");
|
||||
const [fields, setFields] = useState<CustomFieldType[]>(
|
||||
basic?.customFields || []
|
||||
);
|
||||
|
||||
const addCustomField = () => {
|
||||
const newField = {
|
||||
const fieldToAdd: CustomFieldType = {
|
||||
id: crypto.randomUUID(),
|
||||
label: newLabel,
|
||||
value: newValue,
|
||||
icon: "User",
|
||||
required: false,
|
||||
placeholder: ""
|
||||
label: "",
|
||||
value: "",
|
||||
icon: "User"
|
||||
};
|
||||
const updatedFields = [...fields, newField];
|
||||
const updatedFields = [...fields, fieldToAdd];
|
||||
setFields(updatedFields);
|
||||
updateBasicInfo({
|
||||
...basic,
|
||||
customFields: updatedFields
|
||||
});
|
||||
setNewLabel("");
|
||||
setNewValue("");
|
||||
};
|
||||
|
||||
const updateField = (id: string, updates: any) => {
|
||||
const updateField = (updatedField: CustomFieldType) => {
|
||||
const updatedFields = fields.map((field) =>
|
||||
field.id === id ? { ...field, ...updates } : field
|
||||
field.id === updatedField.id ? updatedField : field
|
||||
);
|
||||
setFields(updatedFields);
|
||||
updateBasicInfo({
|
||||
@@ -124,16 +217,6 @@ const BasicPanel = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const updateIcon = (key: string, iconName: string) => {
|
||||
updateBasicInfo({
|
||||
...basic,
|
||||
icons: {
|
||||
...(basic?.icons || {}),
|
||||
[key]: iconName
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const deleteField = (id: string) => {
|
||||
const updatedFields = fields.filter((field) => field.id !== id);
|
||||
setFields(updatedFields);
|
||||
@@ -143,13 +226,24 @@ const BasicPanel = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const renderField = (key: string, label: string, options = {}) => {
|
||||
const {
|
||||
required = false,
|
||||
type = "text",
|
||||
isCustom = false,
|
||||
field = null
|
||||
} = options;
|
||||
const handleReorder = (newOrder: CustomFieldType[]) => {
|
||||
setFields(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";
|
||||
|
||||
@@ -158,19 +252,27 @@ const BasicPanel = () => {
|
||||
<div className="flex items-center gap-2">
|
||||
<IconSelector
|
||||
value={selectedIcon}
|
||||
onChange={(value) => updateIcon(key, value)}
|
||||
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] || ""}
|
||||
label=""
|
||||
value={isCustom ? field?.value || "" : (basic?.[key] as string) || ""}
|
||||
onChange={(value) =>
|
||||
isCustom
|
||||
? updateField(field.id, { value })
|
||||
: updateBasicInfo({ [key]: value })
|
||||
isCustom && field
|
||||
? updateField({ ...field, value })
|
||||
: updateBasicInfo({ ...basic, [key]: value })
|
||||
}
|
||||
placeholder={`请输入${label}`}
|
||||
type={type}
|
||||
@@ -181,65 +283,81 @@ const BasicPanel = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-5 p-4">
|
||||
{renderField("name", "姓名", { required: true })}
|
||||
{renderField("title", "职位", { required: true })}
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="flex-1">{renderField("name", "姓名")}</div>
|
||||
<div className="flex items-center gap-2 pt-8">
|
||||
<Switch
|
||||
id="employed"
|
||||
checked={basic?.employed ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateBasicInfo({
|
||||
...basic,
|
||||
employed: checked
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
theme === "dark" ? "bg-neutral-700" : "bg-neutral-200",
|
||||
"data-[state=checked]:bg-green-500"
|
||||
)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="employed"
|
||||
className={cn(
|
||||
"text-sm cursor-pointer select-none",
|
||||
theme === "dark" ? "text-neutral-200" : "text-neutral-700"
|
||||
)}
|
||||
>
|
||||
{basic?.employed ?? true ? "在职" : "离职"}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
{renderField("title", "职位")}
|
||||
{renderField("birthDate", "出生日期", { type: "date" })}
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
{renderField("email", "电子邮箱", { required: true })}
|
||||
{renderField("phone", "电话", { required: true })}
|
||||
{renderField("email", "电子邮箱")}
|
||||
{renderField("phone", "电话")}
|
||||
</div>
|
||||
{renderField("location", "所在地", { required: true })}
|
||||
{renderField("location", "所在地")}
|
||||
{renderField("summary", "个人简介", { type: "textarea" })}
|
||||
|
||||
{/* 自定义字段 */}
|
||||
{Array.isArray(fields) &&
|
||||
fields.map((field) => (
|
||||
<div key={field.id} className="relative group">
|
||||
{renderField(field.id, field.label, { isCustom: true, field })}
|
||||
<button
|
||||
onClick={() => deleteField(field.id)}
|
||||
className="absolute -right-2 -top-2 p-1 rounded-full bg-red-500 text-white opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</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="flex gap-3">
|
||||
<Input
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
placeholder="字段名称"
|
||||
className={cn(
|
||||
"w-1/3",
|
||||
theme === "dark"
|
||||
? "bg-neutral-900 border-neutral-700 text-neutral-200 placeholder:text-neutral-500"
|
||||
: "bg-white border-gray-200 text-gray-900 placeholder:text-gray-500"
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
placeholder="字段值"
|
||||
className={cn(
|
||||
"w-1/3",
|
||||
theme === "dark"
|
||||
? "bg-neutral-900 border-neutral-700 text-neutral-200 placeholder:text-neutral-500"
|
||||
: "bg-white border-gray-200 text-gray-900 placeholder:text-gray-500"
|
||||
)}
|
||||
/>
|
||||
{/* 添加按钮 */}
|
||||
<Button
|
||||
onClick={addCustomField}
|
||||
disabled={!newLabel}
|
||||
variant={theme === "dark" ? "ghost" : "outline"}
|
||||
className={cn(
|
||||
"w-full border-dashed",
|
||||
theme === "dark"
|
||||
? "bg-indigo-600 hover:bg-indigo-700 text-white"
|
||||
: "bg-black hover:bg-neutral-800 text-white"
|
||||
? "hover:bg-neutral-800 border-neutral-700 text-neutral-200 hover:text-neutral-100"
|
||||
: "hover:bg-neutral-50 border-neutral-200 text-neutral-600 hover:text-neutral-900"
|
||||
)}
|
||||
>
|
||||
<PlusCircle className="w-4 h-4 mr-2" />
|
||||
添加
|
||||
<PlusCircle
|
||||
className={cn(
|
||||
"w-4 h-4 mr-2",
|
||||
theme === "dark" ? "text-neutral-400" : "text-neutral-500"
|
||||
)}
|
||||
/>
|
||||
添加自定义字段
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -66,6 +66,7 @@ const initialState = {
|
||||
location: "北京市",
|
||||
summary: "5年前端开发经验...",
|
||||
birthDate: "",
|
||||
employed: false,
|
||||
icons: {},
|
||||
customFields: []
|
||||
},
|
||||
|
||||
@@ -7,13 +7,12 @@ export interface BasicInfo {
|
||||
location: string;
|
||||
summary: string;
|
||||
icons: Record<string, string>;
|
||||
employed: boolean;
|
||||
customFields: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
icon: string;
|
||||
required: boolean;
|
||||
placeholder: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -102,20 +101,19 @@ export interface ResumeTheme {
|
||||
}
|
||||
|
||||
export const THEME_COLORS = [
|
||||
"#2563eb", // 经典蓝
|
||||
"#059669", // 翡翠绿
|
||||
"#7c3aed", // 优雅紫
|
||||
"#e11d48", // 玫瑰红
|
||||
"#d97706", // 琥珀金
|
||||
"#0891b2", // 青碧蓝
|
||||
"#4f46e5", // 靛青蓝
|
||||
"#0d9488", // 青蓝绿
|
||||
"#0284c7", // 天际蓝
|
||||
"#6d28d9", // 雅致紫
|
||||
"#c026d3", // 绯紫红
|
||||
"#db2777", // 粉红色
|
||||
"#ea580c", // 活力橙
|
||||
"#65a30d", // 青柠绿
|
||||
"#475569", // 岩石灰
|
||||
"#dc2626" // 中国红
|
||||
"#2563eb",
|
||||
"#059669",
|
||||
"#7c3aed",
|
||||
"#e11d48",
|
||||
"#d97706",
|
||||
"#0891b2",
|
||||
"#4f46e5",
|
||||
"#0d9488",
|
||||
"#0284c7",
|
||||
"#6d28d9",
|
||||
"#c026d3",
|
||||
"#ea580c",
|
||||
"#65a30d",
|
||||
"#475569",
|
||||
"#dc2626"
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user