diff --git a/app/src/lib/resume.ts b/app/src/lib/resume.ts index c90632a..4e1346e 100644 --- a/app/src/lib/resume.ts +++ b/app/src/lib/resume.ts @@ -54,15 +54,23 @@ export function defaultResumeConfig(): ResumeConfig { export function migrateResumeConfig(raw: unknown): ResumeConfig { const def = defaultResumeConfig(); if (!raw || typeof raw !== 'object') return def; - const r = raw as { version?: number; template?: ResumeTemplate; accent?: string; fontScale?: number; density?: ResumeConfig['density']; onePage?: boolean; sections?: { key: string; enabled?: boolean; title?: string }[] }; + const r = raw as { version?: number; template?: ResumeTemplate; accent?: string; fontScale?: number; density?: ResumeConfig['density']; onePage?: boolean; header?: ResumeConfig['header']; sections?: ResumeSectionCfg[] }; const oldSections = Array.isArray(r.sections) ? r.sections : []; const sections = def.sections.map(d => { const old = oldSections.find(o => o.key === d.key); const dd = RESUME_SECTION_DEFS.find(x => x.key === d.key); - const base: ResumeSectionCfg = { ...d, enabled: old?.enabled ?? true, title: old?.title ?? dd?.defaultTitle ?? d.title }; - if (d.key === 'skills' && !base.text) base.text = SEED.skills.text; + // 关键:以「已存储的板块」为底展开 —— 保留用户的自定义条目/文本/覆盖/排序/显隐 + const base: ResumeSectionCfg = { + ...d, + ...(old ?? {}), + enabled: old?.enabled ?? true, + title: old?.title ?? dd?.defaultTitle ?? d.title, + kind: dd ? d.kind : (old?.kind ?? d.kind), + types: dd?.types ?? old?.types, + }; + if (d.key === 'skills' && base.text == null) base.text = SEED.skills.text; if (d.key === 'campus' && !(base.items?.length)) base.items = SEED.campus.items; - if (d.key === 'self' && !base.text) base.text = SEED.self.text; + if (d.key === 'self' && base.text == null) base.text = SEED.self.text; return base; }); // 旧版本已被取代的板块 key:直接丢弃,避免残留造成内容重复 @@ -88,6 +96,7 @@ export function migrateResumeConfig(raw: unknown): ResumeConfig { // ---------- 装配 ---------- export interface AssembledItem { + itemKey: string; title: string; subtitle?: string; date?: string; desc?: string; fromRecord?: number; excluded?: boolean; } @@ -95,6 +104,7 @@ export interface AssembledItem { export interface AssembledSection { key: string; title: string; kind: 'info' | 'edu' | 'list' | 'text'; items?: AssembledItem[]; text?: string; + hiddenFields?: string[]; } function claimedTypes(): string[] { @@ -125,7 +135,7 @@ export function assembleSections( if (!s.enabled) continue; const def = RESUME_SECTION_DEFS.find(d => d.key === s.key); - if (s.key === 'info') { out.push({ key: s.key, title: s.title, kind: 'info' }); continue; } + if (s.key === 'info') { out.push({ key: s.key, title: s.title, kind: 'info', hiddenFields: s.hiddenFields ?? [] }); continue; } if (s.key === 'self' || s.kind === 'text') { const txt = s.text ?? ''; if (!txt.trim()) continue; // 空板块不渲染 @@ -143,14 +153,21 @@ export function assembleSections( // 其他桶:未归类的类型 derived = records.filter(r => r.status !== 'draft' && !excluded.has(r.id) && !usedRecordIds.has(r.id) && !claimed.includes(r.type_name)); } - const items: AssembledItem[] = derived.map(recordToItem).filter(it => !it.fromRecord || !excluded.has(it.fromRecord)) - .map(it => ({ ...it, ...(s.overrides?.[String(it.fromRecord)] ?? {}) })); - items.forEach(it => { if (it.fromRecord) usedRecordIds.add(it.fromRecord); }); - for (const c of s.items ?? []) items.push({ ...c }); - - items.sort((a, b) => (b.date ?? '').localeCompare(a.date ?? '')); + // 手动排序:itemOrder 优先,未排序的按日期排在后面 + const orderIdx = new Map((s.itemOrder ?? []).map((k, i) => [k, i])); + const items: (AssembledItem & { _ord: number })[] = []; + for (const r of derived) { + const it: AssembledItem = { itemKey: 'r' + r.id, ...recordToItem(r), ...(s.overrides?.[String(r.id)] ?? {}) }; + items.push({ ...it, _ord: orderIdx.get(it.itemKey) ?? 9999 }); + usedRecordIds.add(r.id); + } + for (const c of s.items ?? []) { + const k = c.id ?? ('c' + (s.items ?? []).indexOf(c)); + items.push({ itemKey: k, ...c, _ord: orderIdx.get(k) ?? 9999 }); + } + items.sort((a, b) => (a._ord - b._ord) || (b.date ?? '').localeCompare(a.date ?? '')); if (!items.length) continue; - out.push({ key: s.key, title: s.title, kind: 'list', items }); + out.push({ key: s.key, title: s.title, kind: 'list', items: items.map(it => { const { _ord, ...rest } = it; return rest; }) }); } return out; } @@ -191,7 +208,7 @@ export function resumeToWordHtml( const nameColor = cfg.template === 'tech' ? accent : '#111'; const schoolLine = [P.school, P.college, P.major, P.degree].filter(v => v !== '').map(v => esc(v)).join(' · '); const contactLine = [P.phone, P.email, P.homepage].filter(v => v !== '').map(v => esc(v)).join(' · '); - const photo = photoUrl && P.photoUrl + const photo = (cfg.header?.showPhoto ?? true) && photoUrl && P.photoUrl ? '' + '证件照' : ''; @@ -199,8 +216,8 @@ export function resumeToWordHtml( const head = '' + '' + photo + '
' + '
' + esc(P.name || '个人简历') + '
' + - (schoolLine ? '
' + schoolLine + '
' : '') + - (contactLine ? '
' + contactLine + '
' : '') + + ((cfg.header?.showSchool ?? true) && schoolLine ? '
' + schoolLine + '
' : '') + + ((cfg.header?.showContact ?? true) && contactLine ? '
' + contactLine + '
' : '') + '
'; // 条目:标题(左) + 日期(右) 表格行 + 副标题 + 描述 @@ -224,17 +241,17 @@ export function resumeToWordHtml( '' + esc(kv[1]) + '').join('') + '' + (P.majorCourses ? '
主修课程:' + esc(P.majorCourses) + '
' : ''); } else if (sec.kind === 'info') { - const rows: [string, string][] = ([ - ['姓名', P.name ?? ''], ['性别', P.gender ?? ''], ['出生年月', P.birth ?? ''], - ['民族', P.nation ?? ''], ['籍贯', P.native ?? ''], ['政治面貌', P.political ?? ''], - ['班级', P.className ?? ''], ['学号', P.studentId ?? ''], ['学历', P.degree ?? ''], - ['手机', P.phone ?? ''], ['邮箱', P.email ?? ''], - ] as [string, string][]).filter(r => r[1] !== ''); + const infoRows: [string, string, string][] = ([ + ['name', '姓名', P.name ?? ''], ['gender', '性别', P.gender ?? ''], ['birth', '出生年月', P.birth ?? ''], + ['nation', '民族', P.nation ?? ''], ['native', '籍贯', P.native ?? ''], ['political', '政治面貌', P.political ?? ''], + ['className', '班级', P.className ?? ''], ['studentId', '学号', P.studentId ?? ''], ['degree', '学历', P.degree ?? ''], + ['phone', '手机', P.phone ?? ''], ['email', '邮箱', P.email ?? ''], + ] as [string, string, string][]).filter(r => r[2] !== '' && !(sec.hiddenFields ?? []).includes(r[0])); inner = '' + - rows.map((kv, i) => + infoRows.map((kv, i) => (i % 2 === 0 ? '' : '') + - '' + - '' + + '' + + '' + (i % 2 === 1 ? '' : '')).join('') + '
' + esc(kv[0]) + '' + esc(kv[1]) + '' + esc(kv[1]) + '' + esc(kv[2]) + '
'; } else if (sec.kind === 'list') { inner = '
' + (sec.items ?? []).map(itemHtml).join('') + '
'; diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 4f56f57..05ed9c2 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -240,7 +240,7 @@ export interface ProfileInfo { phone: string; email: string; homepage: string; photoUrl: string; selfIntro: string; } -export interface ResumeItemCfg { title: string; subtitle?: string; date?: string; desc?: string; fromRecord?: number } +export interface ResumeItemCfg { id?: string; title: string; subtitle?: string; date?: string; desc?: string; fromRecord?: number } export interface ResumeSectionCfg { key: string; title: string; enabled: boolean; @@ -250,6 +250,8 @@ export interface ResumeSectionCfg { items?: ResumeItemCfg[]; text?: string; overrides?: Record>; + itemOrder?: string[]; + hiddenFields?: string[]; } export interface ResumeConfig { @@ -259,5 +261,6 @@ export interface ResumeConfig { fontScale: number; density: 'compact' | 'normal' | 'relaxed'; onePage: boolean; + header?: { showPhoto?: boolean; showContact?: boolean; showSchool?: boolean }; sections: ResumeSectionCfg[]; } diff --git a/app/src/pages/resume/ResumePage.tsx b/app/src/pages/resume/ResumePage.tsx index 5cfe967..4fc10af 100644 --- a/app/src/pages/resume/ResumePage.tsx +++ b/app/src/pages/resume/ResumePage.tsx @@ -9,6 +9,18 @@ import { } from '@/lib/resume'; import { Button, Card, CardBody, CardHeader, Field, Input, Select, Textarea, useToast } from '@/components/ui'; +const INFO_KEY: Record = { + 姓名: 'name', 性别: 'gender', 出生年月: 'birth', 民族: 'nation', 籍贯: 'native', + 政治面貌: 'political', 班级: 'className', 学号: 'studentId', 手机: 'phone', 邮箱: 'email', +}; + +// 基本信息字段(可显隐) +const INFO_FIELDS: [string, string][] = [ + ['name', '姓名'], ['gender', '性别'], ['birth', '出生年月'], ['nation', '民族'], + ['native', '籍贯'], ['political', '政治面貌'], ['className', '班级'], ['studentId', '学号'], + ['phone', '手机'], ['email', '邮箱'], +]; + // 2023-09 → 2023.9 function fmtEduDate(v: string | undefined): string { const m = /^(\d{4})-(\d{1,2})/.exec(v ?? ''); @@ -129,8 +141,38 @@ export function ResumePage() { }); }; + const addSection = (kind: 'text' | 'list') => { + const key = 'custom_' + Date.now(); + persist({ + ...cfg, + sections: [...cfg.sections, kind === 'text' + ? { key, title: '自定义板块', enabled: true, kind: 'text', text: '' } + : { key, title: '自定义经历', enabled: true, kind: 'list', items: [] }], + }); + setOpenEditors(prev => new Set(prev).add(key)); + }; + const sectionTypes = (key: string) => RESUME_SECTION_DEFS.find(d => d.key === key)?.types?.join(' / ') ?? '固定板块'; + const exportConfig = () => { + const blob = new Blob([JSON.stringify(cfg, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = '简历配置.json'; a.click(); + setTimeout(() => URL.revokeObjectURL(url), 60000); + toast('success', '简历配置已导出'); + }; + const importConfig = (f: File | undefined) => { + if (!f) return; + void f.text().then(txt => { + try { + const next = migrateResumeConfig(JSON.parse(txt)); + persist(next); + toast('success', '配置已导入'); + } catch { toast('error', '配置文件格式不正确'); } + }); + }; + return (
@@ -162,14 +204,18 @@ export function ResumePage() {

{profile.name || '姓名'}

-
- {[profile.school, profile.college, profile.major, profile.degree].filter(Boolean).join(' · ') || '完善个人信息后自动展示'} -
-
- {[profile.phone, profile.email, profile.homepage].filter(Boolean).join(' · ')} -
+ {(cfg.header?.showSchool ?? true) && ( +
+ {[profile.school, profile.college, profile.major, profile.degree].filter(Boolean).join(' · ') || '完善个人信息后自动展示'} +
+ )} + {(cfg.header?.showContact ?? true) && ( +
+ {[profile.phone, profile.email, profile.homepage].filter(Boolean).join(' · ')} +
+ )}
- {profile.photoUrl && ( + {(cfg.header?.showPhoto ?? true) && profile.photoUrl && ( 证件照 )}
@@ -209,7 +255,7 @@ export function ResumePage() {
{([['姓名', 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(r => ( + ['手机', profile.phone], ['邮箱', profile.email]] as [string, string][]).filter(r => r[1] && !(sec.hiddenFields ?? []).includes(INFO_KEY[r[0]])).map(r => (
{r[0]}{r[1]}
))}
@@ -220,7 +266,7 @@ export function ResumePage() { ) : (
{sec.items?.map((it, itemIdx) => ( -
+
handleEdit({ secKey: sec.key, field: 'title', value: v, itemIdx, fromRecord: it.fromRecord })} /> @@ -282,6 +328,20 @@ export function ResumePage() { options={[{ value: '0.9', label: '小' }, { value: '1', label: '标准' }, { value: '1.1', label: '大' }]} />
+ +
+ {([['showPhoto', '证件照'], ['showContact', '联系行'], ['showSchool', '学校行']] as [string, string][]).map(t => { + const on = cfg.header?.[t[0] as 'showPhoto' | 'showContact' | 'showSchool'] ?? true; + return ( + + ); + })} +
+
{ importConfig(e.target.files?.[0]); e.target.value = ''; }} /> + +
@@ -347,6 +414,23 @@ export function ResumePage() { {s.kind === 'text' ? (