From 4f7978b12983f8b51e536f7975714e18099d9181 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 17 Oct 2026 13:01:54 +0800 Subject: [PATCH] feat(resume): education section from profile + click-to-edit inline editing + overrides --- app/src/lib/resume.ts | 17 +- app/src/lib/types.ts | 1 + app/src/pages/resume/ResumePage.tsx | 149 ++++++++++++------ ...Page-CU2b53Nb.js => AdminPage-BNqTa_6o.js} | 6 +- ...-DNoywp7V.js => DashboardPage-CdulMvzh.js} | 2 +- ...age-DDtpIPC0.js => GradesPage-CUha1amL.js} | 2 +- ...ge-Bc_DPBs5.js => ProfilePage-DpeCQI6w.js} | 2 +- ...ge-BGd2ZncK.js => RecordsPage-CSkhray7.js} | 2 +- sync-server/www/assets/ResumePage-BZzTs9Hm.js | 39 +++++ sync-server/www/assets/ResumePage-Cej-3M7z.js | 39 ----- ...e-c8tjIm6X.js => TaxonomyPage-BiT78TVQ.js} | 2 +- ...Page-DbjmBLAq.js => TypesPage-DeIgmKFb.js} | 2 +- .../{card-BkBMnQAf.js => card-CvlqAMBZ.js} | 2 +- .../{index-DWQlGpif.js => index-BRtKGkIF.js} | 4 +- sync-server/www/assets/index-BmPfyPNh.css | 1 + sync-server/www/assets/index-CIs6Yi-t.css | 1 - ...QmSAuUh.js => mammoth.browser-0uWLsAq4.js} | 2 +- ...Chart-q1r1yv4-.js => useChart-CMyXfVKg.js} | 2 +- sync-server/www/index.html | 4 +- 19 files changed, 176 insertions(+), 103 deletions(-) rename sync-server/www/assets/{AdminPage-CU2b53Nb.js => AdminPage-BNqTa_6o.js} (97%) rename sync-server/www/assets/{DashboardPage-DNoywp7V.js => DashboardPage-CdulMvzh.js} (94%) rename sync-server/www/assets/{GradesPage-DDtpIPC0.js => GradesPage-CUha1amL.js} (99%) rename sync-server/www/assets/{ProfilePage-Bc_DPBs5.js => ProfilePage-DpeCQI6w.js} (99%) rename sync-server/www/assets/{RecordsPage-BGd2ZncK.js => RecordsPage-CSkhray7.js} (99%) create mode 100644 sync-server/www/assets/ResumePage-BZzTs9Hm.js delete mode 100644 sync-server/www/assets/ResumePage-Cej-3M7z.js rename sync-server/www/assets/{TaxonomyPage-c8tjIm6X.js => TaxonomyPage-BiT78TVQ.js} (97%) rename sync-server/www/assets/{TypesPage-DbjmBLAq.js => TypesPage-DeIgmKFb.js} (98%) rename sync-server/www/assets/{card-BkBMnQAf.js => card-CvlqAMBZ.js} (89%) rename sync-server/www/assets/{index-DWQlGpif.js => index-BRtKGkIF.js} (99%) create mode 100644 sync-server/www/assets/index-BmPfyPNh.css delete mode 100644 sync-server/www/assets/index-CIs6Yi-t.css rename sync-server/www/assets/{mammoth.browser-MQmSAuUh.js => mammoth.browser-0uWLsAq4.js} (99%) rename sync-server/www/assets/{useChart-q1r1yv4-.js => useChart-CMyXfVKg.js} (92%) diff --git a/app/src/lib/resume.ts b/app/src/lib/resume.ts index 77f18d6..8a7c17d 100644 --- a/app/src/lib/resume.ts +++ b/app/src/lib/resume.ts @@ -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( '' + esc(v) + '' + (i % 2 === 1 ? '' : '') ).join('') + ''; + } 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 = '' + + rows.map(function (kv) { + return '' + + ''; + }).join('') + '
' + esc(kv[0]) + '' + esc(kv[1]) + '
' + + (profile.majorCourses ? '
主修课程:' + + '' + esc(profile.majorCourses) + '
' : ''); } else if (sec.kind === 'list') { inner = '
' + (sec.items ?? []).map(it => { const sub = it.subtitle ? '
' + esc(it.subtitle) + '
' : ''; diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 3fa8952..83c3629 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -248,6 +248,7 @@ export interface ResumeSectionCfg { excluded?: number[]; items?: ResumeItemCfg[]; text?: string; + overrides?: Record>; } export interface ResumeConfig { diff --git a/app/src/pages/resume/ResumePage.tsx b/app/src/pages/resume/ResumePage.tsx index 69e452e..2a91ca8 100644 --- a/app/src/pages/resume/ResumePage.tsx +++ b/app/src/pages/resume/ResumePage.tsx @@ -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, '>'); + return ( + { + 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(null); const dragKey = useRef(null); - // 初始化(迁移 v1 配置) useEffect(() => { const stored = profileQ.data?.content; if (!stored) return; @@ -33,28 +50,52 @@ export function ResumePage() { const profile: Partial = { ...(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 = { ...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 (
@@ -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} > - {/* 头部 */}

@@ -121,32 +161,57 @@ export function ResumePage() { )}

- {/* 板块 */} {sections.map(sec => (
-
{sec.title}
- {sec.kind === 'info' ? ( +
+ handleEdit({ secKey: sec.key, field: 'title', value: v })} /> +
+ {sec.kind === 'edu' ? ( +
+
+ {([['学校', profile.school], ['专业', profile.major], ['学制', profile.eduLength], ['学历', profile.degree], ['入学', profile.eduStart], ['毕业', profile.eduEnd]] as [string, string | undefined][]) + .filter(r => r[1]).map(r => ( +
{r[0]}{r[1]}
+ ))} +
+ {profile.majorCourses && ( +

+ 主修课程: + { + const next = { ...previewProfile, majorCourses: v }; + void profileUpsert({ info: next }).then(() => profileQ.refetch()).catch(e => toast('error', e.message)); + }} /> +

+ )} +
+ ) : sec.kind === 'info' ? (
{([['姓名', 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]) => ( -
{k}{v}
+ ['手机', profile.phone], ['邮箱', profile.email]] as [string, string][]).filter(r => r[1]).map(r => ( +
{r[0]}{r[1]}
))}
) : sec.kind === 'text' ? (

- {sec.text || '(在右侧板块设置中填写内容)'} + handleEdit({ secKey: sec.key, field: 'text', value: v })} />

) : (
- {sec.items?.map(it => ( -
+ {sec.items?.map((it, itemIdx) => ( +
- {it.title} + + handleEdit({ secKey: sec.key, field: 'title', value: v, itemIdx, fromRecord: it.fromRecord })} /> + {it.date ?? ''}
{it.subtitle &&
{it.subtitle}
} - {it.desc &&

{it.desc}

} + {it.desc && ( +

+ handleEdit({ secKey: sec.key, field: 'desc', value: v, itemIdx, fromRecord: it.fromRecord })} /> +

+ )}
))}
@@ -165,7 +230,6 @@ export function ResumePage() { {/* 右侧设置面板(手机在下方) */} {panelOpen && (
- {/* 模板 */} @@ -210,14 +274,13 @@ export function ResumePage() { - {/* 板块布局:拖拽排序 + 启停 + 内容编辑 */} - 拖动排序 · 内容可编辑} /> + 拖动排序 · 点击预览可改内容} />
{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 (
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 ? '显示' : '隐藏'}
@@ -264,10 +327,10 @@ export function ResumePage() { ) : s.kind === 'list' ? ( ) : ( -

基本信息板块来自「个人信息」页,无法在此编辑。

+

该板块内容来自「个人信息」页与记录数据。

)}
)} @@ -285,7 +348,7 @@ export function ResumePage() { }); setOpenEditors(prev => new Set(prev).add(key)); }}> - 新增文本板块 + 新增文本板块
@@ -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(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 (
-
- 来自记录的条目(勾选 = 显示)· 另可添加自定义条目 -
+
来自记录的条目(勾选 = 显示)· 另可添加自定义条目
{derived.map(r => (