feat: profile page + resume builder (section reorder/enable, word/pdf export)

This commit is contained in:
root
2026-10-17 12:14:01 +08:00
parent 500d59ba58
commit 013c9fb929
25 changed files with 891 additions and 339 deletions
+192
View File
@@ -0,0 +1,192 @@
import { useMemo, 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; }
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 records = listQ.data?.items ?? [];
const sections = useMemo(
() => assembleSections(records, previewProfile, config),
[records, previewProfile, config]
);
const move = (i: number, dir: -1 | 1) => setConfig(c => {
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 exportWord = () => {
void import('@/lib/resume').then(({ resumeToWordHtml }) => {
const html = resumeToWordHtml(sections, previewProfile, previewProfile.photoUrl);
const blob = new Blob(['\ufeff' + html], { type: 'application/msword' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (profile.name || '个人简历') + '.doc';
a.click();
setTimeout(() => URL.revokeObjectURL(url), 60_000);
toast('success', 'Word 文档已导出');
});
};
return (
<div className="mx-auto max-w-[1080px] 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">
<Button variant="secondary" onClick={exportWord}><Download size={13} /> 导出 Word</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>
<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>
</div>
</div>
</div>
);
}
function ResumePreviewBody({ sections, profile: P }: {
sections: ReturnType<typeof assembleSections>;
profile: Partial<ProfileInfo>;
}) {
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>
{P.photoUrl && <img src={P.photoUrl} alt="证件照" className="h-[120px] w-[90px] rounded border border-neutral-200 object-cover" />}
</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>
))}
</>
);
}