feat(resume): education section from profile + click-to-edit inline editing + overrides

This commit is contained in:
root
2026-10-17 13:01:54 +08:00
parent 9d2e14b72a
commit 4f7978b129
19 changed files with 176 additions and 103 deletions
+15 -2
View File
@@ -90,7 +90,7 @@ export interface AssembledItem {
}
export interface AssembledSection {
key: string; title: string; kind: 'info' | 'list' | 'text';
key: string; title: string; kind: 'info' | 'edu' | 'list' | 'text';
items?: AssembledItem[]; text?: string;
}
@@ -137,7 +137,8 @@ export function assembleSections(
// 其他桶:未归类的类型
derived = records.filter(r => r.status !== 'draft' && !excluded.has(r.id) && !claimed.includes(r.type_name));
}
const items: AssembledItem[] = derived.map(recordToItem).filter(it => !it.fromRecord || !excluded.has(it.fromRecord));
const items: AssembledItem[] = derived.map(recordToItem).filter(it => !it.fromRecord || !excluded.has(it.fromRecord))
.map(it => ({ ...it, ...(s.overrides?.[String(it.fromRecord)] ?? {}) }));
for (const c of s.items ?? []) items.push({ ...c });
items.sort((a, b) => (b.date ?? '').localeCompare(a.date ?? ''));
@@ -193,6 +194,18 @@ export function resumeToWordHtml(
'<td style="padding:3px 6px">' + esc(v) + '</td>' +
(i % 2 === 1 ? '</tr>' : '')
).join('') + '</table>';
} else if (sec.kind === 'edu') {
const rows: [string, string][] = ([
['学校', profile.school ?? ''], ['专业', profile.major ?? ''], ['学制', profile.eduLength ?? ''],
['学历', profile.degree ?? ''], ['入学', profile.eduStart ?? ''], ['毕业', profile.eduEnd ?? ''],
] as [string, string][]).filter(r => r[1] !== '');
inner = '<table style="width:100%;border-collapse:collapse;font-size:' + pt(12) + '">' +
rows.map(function (kv) {
return '<tr><td style="width:80px;padding:3px 6px;color:#555">' + esc(kv[0]) + '</td>' +
'<td style="padding:3px 6px">' + esc(kv[1]) + '</td></tr>';
}).join('') + '</table>' +
(profile.majorCourses ? '<div style="margin-top:6pt"><b style="font-size:' + pt(11) + '">主修课程:</b>' +
'<span style="font-size:' + pt(11.5) + '">' + esc(profile.majorCourses) + '</span></div>' : '');
} else if (sec.kind === 'list') {
inner = '<div style="margin-bottom:6pt">' + (sec.items ?? []).map(it => {
const sub = it.subtitle ? '<div style="color:#666;font-size:' + pt(10.5) + '">' + esc(it.subtitle) + '</div>' : '';
+1
View File
@@ -248,6 +248,7 @@ export interface ResumeSectionCfg {
excluded?: number[];
items?: ResumeItemCfg[];
text?: string;
overrides?: Record<string, Partial<ResumeItemCfg>>;
}
export interface ResumeConfig {
+104 -45
View File
@@ -9,6 +9,24 @@ import {
} from '@/lib/resume';
import { Button, Card, CardBody, CardHeader, Field, Input, Select, Textarea, useToast } from '@/components/ui';
// 预览内联编辑组件:点击即改,失焦保存
function Ed({ html, onCommit, className }: { html: string; onCommit: (v: string) => void; className?: string }) {
const esc = html.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return (
<span
contentEditable
suppressContentEditableWarning
spellCheck={false}
className={'outline-none rounded-sm transition-shadow focus:shadow-[0_0_0_1.5px_rgba(48,86,211,0.5)] cursor-text ' + (className ?? '')}
dangerouslySetInnerHTML={{ __html: esc }}
onBlur={e => {
const v = (e.currentTarget.textContent ?? '').trim();
if (v !== html) onCommit(v);
}}
/>
);
}
export function ResumePage() {
const toast = useToast();
const profileQ = useQuery({ queryKey: ['profile'], queryFn: profileGet });
@@ -22,7 +40,6 @@ export function ResumePage() {
const contentRef = useRef<HTMLDivElement>(null);
const dragKey = useRef<string | null>(null);
// 初始化(迁移 v1 配置)
useEffect(() => {
const stored = profileQ.data?.content;
if (!stored) return;
@@ -33,28 +50,52 @@ export function ResumePage() {
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 previewProfile: Partial<ProfileInfo> = { ...profile, selfIntro };
const sections = useMemo(() => assembleSections(records, cfg), [records, cfg]);
const move = (i: number, dir: -1 | 1) => setConfig(pc => {
if (!pc) return pc;
const c = pc;
const arr = [...c.sections];
const arr = [...pc.sections];
const j = i + dir;
if (j < 0 || j >= arr.length) return c;
if (j < 0 || j >= arr.length) return pc;
[arr[i], arr[j]] = [arr[j], arr[i]];
return { ...c, sections: arr };
return { ...pc, sections: arr };
});
const persist = (next: ResumeConfig) => {
setConfig(next);
void profileUpsert({ info: { ...profile, selfIntro }, resume: next }).catch(e => toast('error', e.message));
};
// 一页限定:内容超高时自动缩放
// 点击即改:统一入口
const handleEdit = (p: { secKey: string; field: string; value: string; itemIdx?: number; fromRecord?: number }) => {
setConfig(pc => {
const base = pc ?? cfg;
const sections2 = base.sections.map(x => {
if (x.key !== p.secKey) return x;
if (p.field === 'text') return { ...x, text: p.value };
if (p.fromRecord != null) {
const o = { ...(x.overrides?.[p.fromRecord] ?? {}), [p.field]: p.value };
return { ...x, overrides: { ...(x.overrides ?? {}), [p.fromRecord]: o } };
}
if (p.itemIdx != null && x.items) {
const items = [...x.items];
items[p.itemIdx] = { ...items[p.itemIdx], [p.field]: p.value };
return { ...x, items };
}
return x;
});
const next = { ...base, sections: sections2 };
void profileUpsert({ info: { ...profile, selfIntro }, resume: next }).catch(e => toast('error', e.message));
return next;
});
};
// 一页限定
useEffect(() => {
if (!cfg.onePage) { setFitZoom(1); return; }
const el = contentRef.current;
if (!el) return;
const available = 1043; // A4 高 1123 - 上下 padding 80
const available = 1043;
const h = el.scrollHeight;
setFitZoom(h > available ? Math.max(0.7, available / h) : 1);
}, [cfg.onePage, cfg.fontScale, cfg.density, sections]);
@@ -62,20 +103,20 @@ export function ResumePage() {
const zoom = cfg.fontScale * (cfg.onePage ? fitZoom : 1);
const exportWord = () => {
void import('@/lib/resume').then(({ resumeToWordHtml }) => {
const html = resumeToWordHtml(sections, { ...profile, selfIntro }, cfg, profile.photoUrl);
void import('@/lib/resume').then(function (m) {
const html = m.resumeToWordHtml(sections, previewProfile, cfg, profile.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 文档已导出(按当前模板与字号)');
setTimeout(() => URL.revokeObjectURL(url), 60000);
toast('success', 'Word 文档已导出');
});
};
const sectionTitles = (key: string) => RESUME_SECTION_DEFS.find(d => d.key === key)?.defaultTitle ?? key;
const sectionTypes = (key: string) => RESUME_SECTION_DEFS.find(d => d.key === key)?.types?.join(' / ') ?? '固定板块';
return (
<div className="mx-auto max-w-[1280px] px-3 py-4 sm:px-6 sm:py-5">
@@ -103,7 +144,6 @@ export function ResumePage() {
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')}>
@@ -121,32 +161,57 @@ export function ResumePage() {
)}
</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="r-sec-title mb-2.5 text-[14px] font-bold text-neutral-900">
<Ed html={sec.title} onCommit={v => handleEdit({ secKey: sec.key, field: 'title', value: v })} />
</div>
{sec.kind === 'edu' ? (
<div>
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-[12.5px]">
{([['学校', profile.school], ['专业', profile.major], ['学制', profile.eduLength], ['学历', profile.degree], ['入学', profile.eduStart], ['毕业', profile.eduEnd]] as [string, string | undefined][])
.filter(r => r[1]).map(r => (
<div key={r[0]} className="flex gap-2"><span className="w-16 shrink-0 text-neutral-500">{r[0]}</span><span>{r[1]}</span></div>
))}
</div>
{profile.majorCourses && (
<p className="mt-2.5 text-[12.5px] leading-relaxed text-neutral-700">
<b>主修课程:</b>
<Ed html={profile.majorCourses} onCommit={v => {
const next = { ...previewProfile, majorCourses: v };
void profileUpsert({ info: next }).then(() => profileQ.refetch()).catch(e => toast('error', e.message));
}} />
</p>
)}
</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>
['手机', profile.phone], ['邮箱', profile.email]] as [string, string][]).filter(r => r[1]).map(r => (
<div key={r[0]} className="flex gap-2"><span className="w-16 shrink-0 text-neutral-500">{r[0]}</span><span>{r[1]}</span></div>
))}
</div>
) : sec.kind === 'text' ? (
<p className={'whitespace-pre-wrap text-neutral-700 ' + (cfg.density === 'compact' ? '' : 'leading-relaxed')}>
{sec.text || '(在右侧板块设置中填写内容)'}
<Ed html={sec.text ?? ''} onCommit={v => handleEdit({ secKey: sec.key, field: 'text', value: v })} />
</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}>
{sec.items?.map((it, itemIdx) => (
<div key={(it.title ?? '') + (it.date ?? '') + itemIdx}>
<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="text-[13px] font-semibold text-neutral-900 r-item-title">
<Ed html={it.title} onCommit={v => handleEdit({ secKey: sec.key, field: 'title', value: v, itemIdx, fromRecord: it.fromRecord })} />
</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>}
{it.desc && (
<p className={'text-[12px] text-neutral-500 ' + (cfg.density === 'compact' ? '' : 'mt-0.5')}>
<Ed html={it.desc} onCommit={v => handleEdit({ secKey: sec.key, field: 'desc', value: v, itemIdx, fromRecord: it.fromRecord })} />
</p>
)}
</div>
))}
</div>
@@ -165,7 +230,6 @@ export function ResumePage() {
{/* 右侧设置面板(手机在下方) */}
{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">
@@ -210,14 +274,13 @@ export function ResumePage() {
</CardBody>
</Card>
{/* 板块布局:拖拽排序 + 启停 + 内容编辑 */}
<Card>
<CardHeader title="板块布局" extra={<span className="text-[11px] text-ink-3">拖动排序 · 内容可编辑</span>} />
<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' ? '文本' : '记录';
const kindLabel = s.kind === 'info' ? '固定' : s.kind === 'text' ? '文本' : s.kind === 'edu' ? '教育' : '记录';
return (
<div key={s.key}
draggable
@@ -227,7 +290,7 @@ export function ResumePage() {
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);
const moved = arr.splice(from, 1)[0];
arr.splice(i, 0, moved);
persist({ ...cfg, sections: arr });
}
@@ -252,7 +315,7 @@ export function ResumePage() {
{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; })}>
onClick={() => setOpenEditors(prev => { const n = new Set(prev); if (n.has(s.key)) n.delete(s.key); else n.add(s.key); return n; })}>
<ChevronRight size={13} className={'transition-transform ' + (open ? 'rotate-90' : '')} />
</button>
</div>
@@ -264,10 +327,10 @@ export function ResumePage() {
) : s.kind === 'list' ? (
<ListSectionEditor
cfg={cfg} section={s} records={records} persist={persist}
sectionTitle={sectionTitles(s.key)}
sectionTypes={sectionTypes(s.key)}
/>
) : (
<p className="text-[11.5px] text-ink-3">基本信息板块来自「个人信息」页,无法在此编辑。</p>
<p className="text-[11.5px] text-ink-3">该板块内容来自「个人信息」页与记录数据。</p>
)}
</div>
)}
@@ -285,7 +348,7 @@ export function ResumePage() {
});
setOpenEditors(prev => new Set(prev).add(key));
}}>
<Plus size={13} /> 新增文本板块
<Plus size={13} /> 新增文本板块
</Button>
</div>
</div>
@@ -299,12 +362,12 @@ export function ResumePage() {
}
// ---------- 列表板块编辑器:记录条目显隐 + 自定义条目 ----------
function ListSectionEditor({ cfg, section, records, persist, sectionTitle }: {
function ListSectionEditor({ cfg, section, records, persist, sectionTypes }: {
cfg: ResumeConfig;
section: ResumeSectionCfg;
records: FootprintRow[];
persist: (c: ResumeConfig) => void;
sectionTitle: string;
sectionTypes: string;
}) {
const [adding, setAdding] = useState<ResumeItemCfg | null>(null);
const excluded = new Set(section.excluded ?? []);
@@ -318,15 +381,13 @@ function ListSectionEditor({ cfg, section, records, persist, sectionTitle }: {
const toggleRecord = (id: number) => {
const next = new Set(section.excluded ?? []);
next.has(id) ? next.delete(id) : next.add(id);
if (next.has(id)) next.delete(id); else next.add(id);
setSection({ excluded: [...next] });
};
return (
<div className="flex flex-col gap-2.5">
<div className="text-[11px] text-ink-3">
来自记录的条目(勾选 = 显示)· 另可添加自定义条目
</div>
<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)} />
@@ -347,9 +408,9 @@ function ListSectionEditor({ cfg, section, records, persist, sectionTitle }: {
<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 })} />
<Input placeholder="日期(2025.9)" 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 })} />
<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={() => {
@@ -370,12 +431,10 @@ function ListSectionEditor({ cfg, section, records, persist, sectionTitle }: {
</button>
</div>
))}
{!customs.length && !adding && (
<p className="text-[11.5px] text-ink-3">暂无自定义条目</p>
)}
{!customs.length && !adding && <p className="text-[11.5px] text-ink-3">暂无自定义条目</p>}
</div>
<p className="text-[11px] text-ink-3">「{sectionTitle}」板块的记录条目来自你的记录数据,此处可隐藏不需要展示的条目。</p>
<p className="text-[11px] text-ink-3">「{sectionTypes}」相关条目来自你的记录数据,此处可隐藏不需要展示的条目;描述可在预览中点击直接修改。</p>
</div>
);
}