fix(resume): remove toast line from ListSectionEditor type
This commit is contained in:
+331
-142
@@ -1,53 +1,69 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowDown, ArrowUp, Download, Printer } from 'lucide-react';
|
||||
import { footprintList, profileGet } from '@/lib/api';
|
||||
import type { ProfileInfo, ResumeConfig } from '@/lib/types';
|
||||
import { assembleSections, defaultResumeConfig, RESUME_SECTION_DEFS } from '@/lib/resume';
|
||||
import { profileUpsert } from '@/lib/api';
|
||||
import { Button, Card, CardBody, CardHeader, Field, Textarea, useToast } from '@/components/ui';
|
||||
|
||||
function selfIntroDraftFix(v: string) { return v; }
|
||||
import { ArrowDown, ArrowUp, ChevronRight, Download, GripVertical, PanelRightClose, PanelRightOpen, Plus, Printer, Trash2 } from 'lucide-react';
|
||||
import { footprintList, profileGet, profileUpsert } from '@/lib/api';
|
||||
import type { FootprintRow, ProfileInfo, ResumeConfig, ResumeItemCfg, ResumeSectionCfg } from '@/lib/types';
|
||||
import {
|
||||
ACCENT_CHOICES, assembleSections, defaultResumeConfig, migrateResumeConfig,
|
||||
RESUME_SECTION_DEFS, TEMPLATES, type ResumeTemplate,
|
||||
} from '@/lib/resume';
|
||||
import { Button, Card, CardBody, CardHeader, Field, Input, Select, Textarea, useToast } from '@/components/ui';
|
||||
|
||||
export function ResumePage() {
|
||||
const toast = useToast();
|
||||
const profileQ = useQuery({ queryKey: ['profile'], queryFn: profileGet });
|
||||
const listQ = useQuery({ queryKey: ['resume-records'], queryFn: () => footprintList({ page: 1, page_size: 500 }) });
|
||||
|
||||
const stored = profileQ.data?.content;
|
||||
const profile = { ...(stored?.info ?? {}) } as Partial<ProfileInfo>;
|
||||
const [config, setConfig] = useState<ResumeConfig>(() => stored?.resume ?? defaultResumeConfig());
|
||||
const [selfIntro, setSelfIntro] = useState(profile.selfIntro ?? '');
|
||||
const introDirty = selfIntro !== (profile.selfIntro ?? '');
|
||||
const previewProfile: Partial<ProfileInfo> = { ...profile, selfIntro: selfIntroDraftFix(selfIntro) };
|
||||
const [config, setConfig] = useState<ResumeConfig | null>(null);
|
||||
const [selfIntro, setSelfIntro] = useState('');
|
||||
const [panelOpen, setPanelOpen] = useState(true);
|
||||
const [openEditors, setOpenEditors] = useState<Set<string>>(new Set());
|
||||
const [fitZoom, setFitZoom] = useState(1);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const dragKey = useRef<string | null>(null);
|
||||
|
||||
const records = listQ.data?.items ?? [];
|
||||
const sections = useMemo(
|
||||
() => assembleSections(records, previewProfile, config),
|
||||
[records, previewProfile, config]
|
||||
);
|
||||
// 初始化(迁移 v1 配置)
|
||||
useEffect(() => {
|
||||
const stored = profileQ.data?.content;
|
||||
if (!stored) return;
|
||||
setConfig(migrateResumeConfig(stored.resume));
|
||||
setSelfIntro((stored.info as ProfileInfo | undefined)?.selfIntro ?? '');
|
||||
}, [profileQ.data]);
|
||||
|
||||
const move = (i: number, dir: -1 | 1) => setConfig(c => {
|
||||
const profile: Partial<ProfileInfo> = { ...(profileQ.data?.content?.info ?? {}) };
|
||||
const records: FootprintRow[] = listQ.data?.items ?? [];
|
||||
const cfg: ResumeConfig = config ?? defaultResumeConfig();
|
||||
const sections = useMemo(() => assembleSections(records, cfg), [records, profile, selfIntro, cfg]);
|
||||
|
||||
const move = (i: number, dir: -1 | 1) => setConfig(pc => {
|
||||
if (!pc) return pc;
|
||||
const c = pc;
|
||||
const arr = [...c.sections];
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= arr.length) return c;
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
return { ...c, sections: arr };
|
||||
});
|
||||
const toggle = (i: number) => setConfig(c => {
|
||||
const arr = [...c.sections];
|
||||
arr[i] = { ...arr[i], enabled: !arr[i].enabled };
|
||||
return { ...c, sections: arr };
|
||||
});
|
||||
const retitle = (i: number, title: string) => setConfig(c => {
|
||||
const arr = [...c.sections];
|
||||
arr[i] = { ...arr[i], title };
|
||||
return { ...c, sections: arr };
|
||||
});
|
||||
const persist = (next: ResumeConfig) => {
|
||||
setConfig(next);
|
||||
void profileUpsert({ info: { ...profile, selfIntro }, resume: next }).catch(e => toast('error', e.message));
|
||||
};
|
||||
|
||||
// 一页限定:内容超高时自动缩放
|
||||
useEffect(() => {
|
||||
if (!cfg.onePage) { setFitZoom(1); return; }
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
const available = 1043; // A4 高 1123 - 上下 padding 80
|
||||
const h = el.scrollHeight;
|
||||
setFitZoom(h > available ? Math.max(0.7, available / h) : 1);
|
||||
}, [cfg.onePage, cfg.fontScale, cfg.density, sections]);
|
||||
|
||||
const zoom = cfg.fontScale * (cfg.onePage ? fitZoom : 1);
|
||||
|
||||
const exportWord = () => {
|
||||
void import('@/lib/resume').then(({ resumeToWordHtml }) => {
|
||||
const html = resumeToWordHtml(sections, previewProfile, previewProfile.photoUrl);
|
||||
const html = resumeToWordHtml(sections, { ...profile, selfIntro }, cfg, profile.photoUrl);
|
||||
const blob = new Blob(['\ufeff' + html], { type: 'application/msword' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -55,138 +71,311 @@ export function ResumePage() {
|
||||
a.download = (profile.name || '个人简历') + '.doc';
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
toast('success', 'Word 文档已导出');
|
||||
toast('success', 'Word 文档已导出(按当前模板与字号)');
|
||||
});
|
||||
};
|
||||
|
||||
const sectionTitles = (key: string) => RESUME_SECTION_DEFS.find(d => d.key === key)?.defaultTitle ?? key;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1080px] px-3 py-4 sm:px-6 sm:py-5">
|
||||
<div className="mx-auto max-w-[1280px] px-3 py-4 sm:px-6 sm:py-5">
|
||||
<div className="no-print mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-[15px] font-semibold text-ink-1">我的简历</h2>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" onClick={() => setPanelOpen(v => !v)}>
|
||||
{panelOpen ? <PanelRightClose size={13} /> : <PanelRightOpen size={13} />}
|
||||
{panelOpen ? '收起设置' : '展开设置'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={exportWord}><Download size={13} /> 导出 Word</Button>
|
||||
<Button variant="primary" onClick={() => window.print()}><Printer size={13} /> 打印 / 导出 PDF</Button>
|
||||
<Button variant="primary" onClick={() => window.print()}><Printer size={13} /> 打印 / PDF</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[300px_1fr]">
|
||||
{/* 布局面板 */}
|
||||
<Card className="no-print">
|
||||
<CardHeader title="板块布局" />
|
||||
<CardBody className="p-0">
|
||||
<div className="flex flex-col divide-y divide-line">
|
||||
{config.sections.map((s, i) => {
|
||||
const hasItems = sections.find(x => x.key === s.key);
|
||||
return (
|
||||
<div key={s.key} className="p-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input type="checkbox" checked={s.enabled} onChange={() => toggle(i)} aria-label="启用板块" className="size-3.5" />
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded-sm border border-transparent bg-transparent px-1 py-0.5 text-[12.5px] font-medium text-ink-1 transition-colors hover:border-line focus:border-brand focus:outline-none"
|
||||
value={s.title}
|
||||
onChange={e => retitle(i, e.target.value)}
|
||||
/>
|
||||
<button className="rounded p-1 text-ink-3 transition-colors hover:bg-surface-3 hover:text-ink-1 disabled:opacity-30"
|
||||
aria-label="上移" disabled={i === 0} onClick={() => move(i, -1)}><ArrowUp size={13} /></button>
|
||||
<button className="rounded p-1 text-ink-3 transition-colors hover:bg-surface-3 hover:text-ink-1 disabled:opacity-30"
|
||||
aria-label="下移" disabled={i === config.sections.length - 1} onClick={() => move(i, 1)}><ArrowDown size={13} /></button>
|
||||
</div>
|
||||
<div className="mt-0.5 pl-5 text-[11px] text-ink-3">
|
||||
{RESUME_SECTION_DEFS.find(d => d.key === s.key)?.types?.join(' / ') ?? '固定板块'}
|
||||
{s.enabled && !hasItems && <span className="text-warning"> · 暂无内容</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardBody>
|
||||
<div className="border-t border-line p-3">
|
||||
<Field label="自我评价">
|
||||
<Textarea rows={4} value={selfIntro} onChange={e => setSelfIntro(e.target.value)} placeholder="简述性格、能力、研究方向…" />
|
||||
</Field>
|
||||
<Button size="sm" variant="primary" className="mt-2 w-full"
|
||||
disabled={!introDirty} onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
await profileUpsert({ info: { ...profile, selfIntro }, resume: config });
|
||||
await profileQ.refetch();
|
||||
toast('success', '自我评价已保存');
|
||||
} catch (e) { toast('error', (e as Error).message); }
|
||||
})();
|
||||
}}>保存自我评价</Button>
|
||||
<p className="mt-1 text-[11px] text-ink-3">板块顺序与标题自动保存 · 内容来自「个人信息」与各类型记录</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 简历预览(A4 比例白卡) */}
|
||||
<div className={'grid gap-4 ' + (panelOpen ? 'lg:grid-cols-[1fr_340px]' : 'grid-cols-1')}>
|
||||
{/* 简历预览(A4 白卡,打印区) */}
|
||||
<div>
|
||||
<div className="print-sheet mx-auto w-full max-w-[794px] rounded-lg border border-line bg-white p-8 text-[13px] leading-relaxed text-neutral-900 shadow-card sm:p-10">
|
||||
<ResumePreviewBody sections={sections} profile={previewProfile} />
|
||||
<div
|
||||
className="print-sheet mx-auto w-full max-w-[794px] rounded-lg border border-line bg-white shadow-card"
|
||||
style={{ minHeight: cfg.onePage ? 1123 : undefined }}
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={'p-8 sm:p-10 resume-tpl-' + cfg.template}
|
||||
style={{ zoom, '--accent': cfg.accent } as React.CSSProperties}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="mb-6 flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<h1 className={'text-[26px] font-bold leading-tight ' + (cfg.template === 'tech' ? 'text-[var(--accent)]' : 'text-neutral-900')}>
|
||||
{profile.name || '姓名'}
|
||||
</h1>
|
||||
<div className="mt-1.5 text-[12.5px] text-neutral-500">
|
||||
{[profile.school, profile.college, profile.major, profile.degree].filter(Boolean).join(' · ') || '完善个人信息后自动展示'}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-neutral-500">
|
||||
{[profile.phone, profile.email, profile.homepage].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
{profile.photoUrl && (
|
||||
<img src={profile.photoUrl} alt="证件照" className="h-[110px] w-[82px] rounded border border-neutral-200 object-cover" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 板块 */}
|
||||
{sections.map(sec => (
|
||||
<div key={sec.key} className="mb-5 r-sec">
|
||||
<div className="r-sec-title mb-2.5 text-[14px] font-bold text-neutral-900">{sec.title}</div>
|
||||
{sec.kind === 'info' ? (
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-[12.5px]">
|
||||
{([['姓名', profile.name], ['性别', profile.gender], ['出生年月', profile.birth], ['民族', profile.nation],
|
||||
['籍贯', profile.native], ['政治面貌', profile.political], ['班级', profile.className], ['学号', profile.studentId],
|
||||
['手机', profile.phone], ['邮箱', profile.email]] as [string, string][]).filter(r => r[1]).map(([k, v]) => (
|
||||
<div key={k} className="flex gap-2"><span className="w-16 shrink-0 text-neutral-500">{k}</span><span>{v}</span></div>
|
||||
))}
|
||||
</div>
|
||||
) : sec.kind === 'text' ? (
|
||||
<p className={'whitespace-pre-wrap text-neutral-700 ' + (cfg.density === 'compact' ? '' : 'leading-relaxed')}>
|
||||
{sec.text || '(在右侧板块设置中填写内容)'}
|
||||
</p>
|
||||
) : (
|
||||
<div className={cfg.density === 'compact' ? 'flex flex-col gap-1.5' : 'flex flex-col gap-3'}>
|
||||
{sec.items?.map(it => (
|
||||
<div key={it.title + it.date}>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-[13px] font-semibold text-neutral-900 r-item-title">{it.title}</span>
|
||||
<span className="num shrink-0 text-[11.5px] text-neutral-500">{it.date ?? ''}</span>
|
||||
</div>
|
||||
{it.subtitle && <div className="text-[12px] text-neutral-600">{it.subtitle}</div>}
|
||||
{it.desc && <p className={'text-[12px] text-neutral-500 ' + (cfg.density === 'compact' ? '' : 'mt-0.5')}>{it.desc}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{cfg.onePage && (
|
||||
<div className="no-print mt-2 text-center text-[11.5px] text-ink-3">
|
||||
一页限定已启用:内容自动缩放 {Math.round(zoom * 100)}% 以适配单页 A4
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧设置面板(手机在下方) */}
|
||||
{panelOpen && (
|
||||
<div className="no-print flex flex-col gap-4 lg:sticky lg:top-4 lg:self-start">
|
||||
{/* 模板 */}
|
||||
<Card>
|
||||
<CardHeader title="模板与风格" />
|
||||
<CardBody className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{TEMPLATES.map(t => (
|
||||
<button key={t.key}
|
||||
className={'rounded-md border px-3 py-2 text-left transition-all ' + (cfg.template === t.key ? 'border-brand bg-brand-subtle' : 'border-line bg-surface hover:border-line-strong')}
|
||||
onClick={() => persist({ ...cfg, template: t.key as ResumeTemplate })}>
|
||||
<div className={'text-[12.5px] font-semibold ' + (cfg.template === t.key ? 'text-brand-text' : 'text-ink-1')}>{t.name}</div>
|
||||
<div className="text-[11px] text-ink-3">{t.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="主题色">
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{ACCENT_CHOICES.map(c => (
|
||||
<button key={c} aria-label={'主题色 ' + c}
|
||||
className={'size-5 rounded-full border-2 transition-transform ' + (cfg.accent === c ? 'scale-110 border-ink-1' : 'border-transparent')}
|
||||
style={{ backgroundColor: c }}
|
||||
onClick={() => persist({ ...cfg, accent: c })} />
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="字号">
|
||||
<Select value={String(cfg.fontScale)} onChange={e => persist({ ...cfg, fontScale: Number(e.target.value) })}
|
||||
options={[{ value: '0.9', label: '小' }, { value: '1', label: '标准' }, { value: '1.1', label: '大' }]} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="间距">
|
||||
<Select value={cfg.density} onChange={e => persist({ ...cfg, density: e.target.value as ResumeConfig['density'] })}
|
||||
options={[{ value: 'compact', label: '紧凑' }, { value: 'normal', label: '标准' }, { value: 'relaxed', label: '宽松' }]} />
|
||||
</Field>
|
||||
<Field label="单页限定">
|
||||
<button className={'flex h-9 w-full items-center justify-center gap-1.5 rounded-md border text-[12px] transition-colors ' + (cfg.onePage ? 'border-brand bg-brand-subtle text-brand-text' : 'border-line bg-surface text-ink-2')}
|
||||
onClick={() => persist({ ...cfg, onePage: !cfg.onePage })}>
|
||||
{cfg.onePage ? '已开启' : '已关闭'}
|
||||
</button>
|
||||
</Field>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 板块布局:拖拽排序 + 启停 + 内容编辑 */}
|
||||
<Card>
|
||||
<CardHeader title="板块布局" extra={<span className="text-[11px] text-ink-3">拖动排序 · 内容可编辑</span>} />
|
||||
<CardBody className="p-0">
|
||||
<div className="flex flex-col divide-y divide-line">
|
||||
{cfg.sections.map((s, i) => {
|
||||
const open = openEditors.has(s.key);
|
||||
const kindLabel = s.kind === 'info' ? '固定' : s.kind === 'text' ? '文本' : '记录';
|
||||
return (
|
||||
<div key={s.key}
|
||||
draggable
|
||||
onDragStart={() => { dragKey.current = s.key; }}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={() => {
|
||||
const from = cfg.sections.findIndex(x => x.key === dragKey.current);
|
||||
if (from >= 0 && from !== i) {
|
||||
const arr = [...cfg.sections];
|
||||
const [moved] = arr.splice(from, 1);
|
||||
arr.splice(i, 0, moved);
|
||||
persist({ ...cfg, sections: arr });
|
||||
}
|
||||
dragKey.current = null;
|
||||
}}
|
||||
className={'bg-surface ' + (dragKey.current === s.key ? 'opacity-40' : '')}
|
||||
>
|
||||
<div className="flex items-center gap-1 px-3 py-2">
|
||||
<GripVertical size={13} className="shrink-0 cursor-grab text-ink-3" />
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded-sm border border-transparent bg-transparent px-1 py-0.5 text-[12.5px] font-medium text-ink-1 hover:border-line focus:border-brand focus:outline-none"
|
||||
value={s.title}
|
||||
onChange={e => persist({ ...cfg, sections: cfg.sections.map(x => x.key === s.key ? { ...x, title: e.target.value } : x) })}
|
||||
/>
|
||||
<button className="rounded p-1 text-ink-3 hover:text-ink-1 disabled:opacity-30" aria-label="上移"
|
||||
disabled={i === 0} onClick={() => move(i, -1)}><ArrowUp size={12} /></button>
|
||||
<button className="rounded p-1 text-ink-3 hover:text-ink-1 disabled:opacity-30" aria-label="下移"
|
||||
disabled={i === cfg.sections.length - 1} onClick={() => move(i, 1)}><ArrowDown size={12} /></button>
|
||||
<button
|
||||
className={'ml-0.5 rounded-sm border px-1.5 py-0.5 text-[11px] transition-colors ' + (s.enabled ? 'border-brand/40 bg-brand-subtle text-brand-text' : 'border-line bg-surface-2 text-ink-3')}
|
||||
onClick={() => persist({ ...cfg, sections: cfg.sections.map(x => x.key === s.key ? { ...x, enabled: !x.enabled } : x) })}>
|
||||
{s.enabled ? '显示' : '隐藏'}
|
||||
</button>
|
||||
<button className="rounded p-1 text-ink-3 hover:text-ink-1" aria-label="展开编辑"
|
||||
onClick={() => setOpenEditors(prev => { const n = new Set(prev); n.has(s.key) ? n.delete(s.key) : n.add(s.key); return n; })}>
|
||||
<ChevronRight size={13} className={'transition-transform ' + (open ? 'rotate-90' : '')} />
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="border-t border-line bg-surface-2 px-3 py-2.5">
|
||||
{s.kind === 'text' ? (
|
||||
<Textarea rows={4} value={s.text ?? ''} placeholder="输入该板块内容…"
|
||||
onChange={e => persist({ ...cfg, sections: cfg.sections.map(x => x.key === s.key ? { ...x, text: e.target.value } : x) })} />
|
||||
) : s.kind === 'list' ? (
|
||||
<ListSectionEditor
|
||||
cfg={cfg} section={s} records={records} persist={persist}
|
||||
sectionTitle={sectionTitles(s.key)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-[11.5px] text-ink-3">基本信息板块来自「个人信息」页,无法在此编辑。</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 pb-1.5 text-[10.5px] text-ink-3">{kindLabel}{s.types?.length ? ' · ' + s.types.join('/') : ''}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="p-3">
|
||||
<Button size="sm" variant="secondary" className="w-full"
|
||||
onClick={() => {
|
||||
const key = 'custom_' + Date.now();
|
||||
persist({
|
||||
...cfg,
|
||||
sections: [...cfg.sections, { key, title: '自定义板块', enabled: true, kind: 'text', text: '' }],
|
||||
});
|
||||
setOpenEditors(prev => new Set(prev).add(key));
|
||||
}}>
|
||||
<Plus size={13} /> 新增文本板块
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResumePreviewBody({ sections, profile: P }: {
|
||||
sections: ReturnType<typeof assembleSections>;
|
||||
profile: Partial<ProfileInfo>;
|
||||
// ---------- 列表板块编辑器:记录条目显隐 + 自定义条目 ----------
|
||||
function ListSectionEditor({ cfg, section, records, persist, sectionTitle }: {
|
||||
cfg: ResumeConfig;
|
||||
section: ResumeSectionCfg;
|
||||
records: FootprintRow[];
|
||||
persist: (c: ResumeConfig) => void;
|
||||
sectionTitle: string;
|
||||
}) {
|
||||
const [adding, setAdding] = useState<ResumeItemCfg | null>(null);
|
||||
const excluded = new Set(section.excluded ?? []);
|
||||
const derived = records
|
||||
.filter(r => (section.types ?? []).includes(r.type_name) && r.status !== 'draft')
|
||||
.sort((a, b) => (b.start_date ?? b.created_at).localeCompare(a.start_date ?? a.created_at));
|
||||
const customs = section.items ?? [];
|
||||
|
||||
const setSection = (patch: Partial<ResumeSectionCfg>) =>
|
||||
persist({ ...cfg, sections: cfg.sections.map(x => x.key === section.key ? { ...x, ...patch } : x) });
|
||||
|
||||
const toggleRecord = (id: number) => {
|
||||
const next = new Set(section.excluded ?? []);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
setSection({ excluded: [...next] });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6 flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<h1 className="text-[26px] font-bold leading-tight tracking-tight text-neutral-900">{P.name || '姓名'}</h1>
|
||||
<div className="mt-1.5 text-[12.5px] text-neutral-500">
|
||||
{[P.school, P.college, P.major, P.degree].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-neutral-500">
|
||||
{[P.phone, P.email, P.homepage].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="text-[11px] text-ink-3">
|
||||
来自记录的条目(勾选 = 显示)· 另可添加自定义条目
|
||||
</div>
|
||||
{derived.map(r => (
|
||||
<label key={r.id} className="flex cursor-pointer items-center gap-2 text-[12px]">
|
||||
<input type="checkbox" className="size-3.5" checked={!excluded.has(r.id)} onChange={() => toggleRecord(r.id)} />
|
||||
<span className="min-w-0 flex-1 truncate text-ink-1">{r.title}</span>
|
||||
<span className="num shrink-0 text-[11px] text-ink-3">{(r.start_date ?? '').slice(0, 7)}</span>
|
||||
</label>
|
||||
))}
|
||||
|
||||
<div className="mt-1 border-t border-line pt-2">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-[11.5px] font-medium text-ink-2">自定义条目({customs.length})</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => setAdding({ title: '' })}>
|
||||
<Plus size={12} /> 添加
|
||||
</Button>
|
||||
</div>
|
||||
{P.photoUrl && <img src={P.photoUrl} alt="证件照" className="h-[120px] w-[90px] rounded border border-neutral-200 object-cover" />}
|
||||
{adding && (
|
||||
<div className="mb-2 rounded-md border border-line bg-surface p-2">
|
||||
<Input className="mb-1.5" placeholder="标题" value={adding.title} onChange={e => setAdding({ ...adding, title: e.target.value })} />
|
||||
<div className="mb-1.5 grid grid-cols-2 gap-1.5">
|
||||
<Input placeholder="副标题(单位/角色)" value={adding.subtitle ?? ''} onChange={e => setAdding({ ...adding, subtitle: e.target.value })} />
|
||||
<Input placeholder="日期(2025-06)" value={adding.date ?? ''} onChange={e => setAdding({ ...adding, date: e.target.value })} />
|
||||
</div>
|
||||
<Textarea rows={2} placeholder="描述" value={adding.desc ?? ''} onChange={e => setAdding({ ...adding, desc: e.target.value })} />
|
||||
<div className="mt-1.5 flex gap-1.5">
|
||||
<Button size="sm" variant="primary" disabled={!adding.title.trim()}
|
||||
onClick={() => {
|
||||
if (!adding.title.trim()) return;
|
||||
setSection({ items: [...customs, adding] });
|
||||
setAdding(null);
|
||||
}}>添加</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setAdding(null)}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{customs.map((c, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 rounded-md border border-line bg-surface p-1.5 text-[12px]">
|
||||
<span className="min-w-0 flex-1 truncate text-ink-1">{c.title}<span className="text-ink-3"> · {c.date ?? ''}</span></span>
|
||||
<button className="text-ink-3 hover:text-danger" aria-label="删除条目"
|
||||
onClick={() => setSection({ items: customs.filter((_, x) => x !== idx) })}>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!customs.length && !adding && (
|
||||
<p className="text-[11.5px] text-ink-3">暂无自定义条目</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sections.map(sec => (
|
||||
<div key={sec.key} className="mb-5">
|
||||
<div className="mb-2.5 border-b-2 border-neutral-800 pb-1 text-[14px] font-bold tracking-wide text-neutral-900">{sec.title}</div>
|
||||
{sec.key === 'info' ? (
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-[12.5px]">
|
||||
{([
|
||||
['姓名', P.name], ['性别', P.gender], ['出生年月', P.birth], ['民族', P.nation],
|
||||
['籍贯', P.native], ['政治面貌', P.political], ['班级', P.className], ['学号', P.studentId],
|
||||
['手机', P.phone], ['邮箱', P.email],
|
||||
] as [string, string][]).filter(r => r[1]).map(([k, v]) => (
|
||||
<div key={k} className="flex gap-2"><span className="w-16 shrink-0 text-neutral-500">{k}</span><span>{v}</span></div>
|
||||
))}
|
||||
</div>
|
||||
) : sec.text !== undefined ? (
|
||||
<p className="whitespace-pre-wrap text-[12.5px] leading-relaxed">{sec.text || '(在「个人信息」中填写自我评价)'}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{sec.items?.map(it => {
|
||||
const r = it.row;
|
||||
const date = (r.start_date ?? r.created_at).slice(0, 10);
|
||||
return (
|
||||
<div key={r.id}>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-[13px] font-semibold text-neutral-900">{r.title}</span>
|
||||
<span className="num shrink-0 text-[11.5px] text-neutral-500">{date}{r.end_date ? ' ~ ' + r.end_date.slice(5, 10) : ''}</span>
|
||||
</div>
|
||||
<div className="text-[12px] text-neutral-600">
|
||||
{[r.organizer, r.level, r.award_grade].filter(Boolean).join(' · ')}
|
||||
{r.result && <span className="text-emerald-700"> — {r.result}</span>}
|
||||
</div>
|
||||
{r.description && <p className="mt-0.5 line-clamp-2 text-[12px] text-neutral-500">{r.description}</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
<p className="text-[11px] text-ink-3">「{sectionTitle}」板块的记录条目来自你的记录数据,此处可隐藏不需要展示的条目。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user