feat(resume): education section from profile + click-to-edit inline editing + overrides
This commit is contained in:
+15
-2
@@ -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>' : '';
|
||||
|
||||
@@ -248,6 +248,7 @@ export interface ResumeSectionCfg {
|
||||
excluded?: number[];
|
||||
items?: ResumeItemCfg[];
|
||||
text?: string;
|
||||
overrides?: Record<string, Partial<ResumeItemCfg>>;
|
||||
}
|
||||
|
||||
export interface ResumeConfig {
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user