From f95efd481f4eec1f8c29e90c2d5cc188adfd50e1 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 17 Oct 2026 12:31:33 +0800 Subject: [PATCH] fix(resume): remove toast line from ListSectionEditor type --- app/src/lib/resume.ts | 239 ++++++--- app/src/lib/types.ts | 22 +- app/src/pages/resume/ResumePage.tsx | 473 ++++++++++++------ app/src/styles/theme.css | 8 + ...Page-BaQEpWYv.js => AdminPage-EK2Zo7TL.js} | 6 +- ...-CjgGmY48.js => DashboardPage-CPUiI0iy.js} | 2 +- ...age-z1WFXi5G.js => GradesPage-Ij8UzdbZ.js} | 2 +- ...ge-CkPa7A_5.js => ProfilePage-CL6-SO69.js} | 2 +- ...ge-5m_vENBY.js => RecordsPage-COdEkyD5.js} | 2 +- sync-server/www/assets/ResumePage-BHj_NUxM.js | 36 ++ sync-server/www/assets/ResumePage-Cw9JFOLR.js | 16 - ...e-DCmvpNAQ.js => TaxonomyPage-IQFdyHqI.js} | 2 +- ...Page-BdTJ69cp.js => TypesPage-B7GapiTk.js} | 2 +- .../{card-DdglV9lU.js => card-BcMbz03g.js} | 2 +- sync-server/www/assets/index-BheMcaGY.css | 1 - sync-server/www/assets/index-CIs6Yi-t.css | 1 + .../{index-DkrEeFZh.js => index-CSVqyJB0.js} | 4 +- ...CMiIhTD.js => mammoth.browser-DRYI4-uz.js} | 2 +- ...Chart-DNtmZa2Q.js => useChart-CUy-j464.js} | 2 +- sync-server/www/index.html | 4 +- 20 files changed, 586 insertions(+), 242 deletions(-) rename sync-server/www/assets/{AdminPage-BaQEpWYv.js => AdminPage-EK2Zo7TL.js} (97%) rename sync-server/www/assets/{DashboardPage-CjgGmY48.js => DashboardPage-CPUiI0iy.js} (94%) rename sync-server/www/assets/{GradesPage-z1WFXi5G.js => GradesPage-Ij8UzdbZ.js} (99%) rename sync-server/www/assets/{ProfilePage-CkPa7A_5.js => ProfilePage-CL6-SO69.js} (99%) rename sync-server/www/assets/{RecordsPage-5m_vENBY.js => RecordsPage-COdEkyD5.js} (99%) create mode 100644 sync-server/www/assets/ResumePage-BHj_NUxM.js delete mode 100644 sync-server/www/assets/ResumePage-Cw9JFOLR.js rename sync-server/www/assets/{TaxonomyPage-DCmvpNAQ.js => TaxonomyPage-IQFdyHqI.js} (97%) rename sync-server/www/assets/{TypesPage-BdTJ69cp.js => TypesPage-B7GapiTk.js} (98%) rename sync-server/www/assets/{card-DdglV9lU.js => card-BcMbz03g.js} (89%) delete mode 100644 sync-server/www/assets/index-BheMcaGY.css create mode 100644 sync-server/www/assets/index-CIs6Yi-t.css rename sync-server/www/assets/{index-DkrEeFZh.js => index-CSVqyJB0.js} (99%) rename sync-server/www/assets/{mammoth.browser-BCMiIhTD.js => mammoth.browser-DRYI4-uz.js} (99%) rename sync-server/www/assets/{useChart-DNtmZa2Q.js => useChart-CUy-j464.js} (92%) diff --git a/app/src/lib/resume.ts b/app/src/lib/resume.ts index 66b5290..5bee697 100644 --- a/app/src/lib/resume.ts +++ b/app/src/lib/resume.ts @@ -1,115 +1,224 @@ import type { FootprintRow, ProfileInfo, ResumeConfig } from './types'; +// ---------- 简历数据模型 v2 ---------- + +export interface ResumeItemCfg { title: string; subtitle?: string; date?: string; desc?: string; fromRecord?: number } + export interface ResumeSectionDef { - key: string; defaultTitle: string; types?: string[]; fixed?: boolean; + key: string; defaultTitle: string; + kind: 'info' | 'list' | 'text'; + types?: string[]; // list 类:从哪些记录类型装配 + fixed?: boolean; // 不可删除/停用(基本信息、自我评价) } -// 简历板块定义(顺序 = 默认顺序) export const RESUME_SECTION_DEFS: ResumeSectionDef[] = [ - { key: 'info', defaultTitle: '基本信息', fixed: true }, - { key: 'education', defaultTitle: '教育背景', types: ['学业'] }, - { key: 'skills', defaultTitle: '技能证书', types: ['证书技能'] }, - { key: 'awards', defaultTitle: '荣誉奖项', types: ['荣誉惩处'] }, - { key: 'competition', defaultTitle: '竞赛经历', types: ['竞赛'] }, - { key: 'research', defaultTitle: '科研经历', types: ['科研'] }, - { key: 'practice', defaultTitle: '实践经历', types: ['志愿公益', '实习实践', '学生工作'] }, - { key: 'activity', defaultTitle: '文体活动', types: ['文体活动'] }, - { key: 'other', defaultTitle: '其他经历' }, - { key: 'self', defaultTitle: '自我评价', fixed: true }, + { key: 'info', defaultTitle: '基本信息', kind: 'info', fixed: true }, + { key: 'education', defaultTitle: '教育背景', kind: 'list', types: ['学业'] }, + { key: 'skills', defaultTitle: '技能证书', kind: 'list', types: ['证书技能'] }, + { key: 'awards', defaultTitle: '荣誉奖项', kind: 'list', types: ['荣誉惩处'] }, + { key: 'competition', defaultTitle: '竞赛经历', kind: 'list', types: ['竞赛'] }, + { key: 'research', defaultTitle: '科研经历', kind: 'list', types: ['科研'] }, + { key: 'practice', defaultTitle: '实践经历', kind: 'list', types: ['志愿公益', '实习实践', '学生工作'] }, + { key: 'activity', defaultTitle: '文体活动', kind: 'list', types: ['文体活动'] }, + { key: 'other', defaultTitle: '其他经历', kind: 'list' }, // 未归类的自定义类型 + 自定义条目 + { key: 'self', defaultTitle: '自我评价', kind: 'text', fixed: true }, ]; -export function defaultResumeConfig(): ResumeConfig { - return { sections: RESUME_SECTION_DEFS.map(s => ({ key: s.key, enabled: true, title: s.defaultTitle })) }; +export interface ResumeSectionCfg { + key: string; + title: string; + enabled: boolean; + kind?: 'info' | 'list' | 'text'; + types?: string[]; + excluded?: number[]; // 隐藏的记录 id + items?: ResumeItemCfg[]; // 自定义条目 + text?: string; // text 板块内容 } -export interface ResumeItem { - row: FootprintRow; +export type ResumeTemplate = 'classic' | 'tech' | 'minimal'; + + +export function defaultResumeConfig(): ResumeConfig { + return { + version: 2, template: 'tech', accent: '#3056d3', + fontScale: 1, density: 'normal', onePage: false, + sections: RESUME_SECTION_DEFS.map(d => ({ + key: d.key, title: d.defaultTitle, enabled: true, kind: d.kind, types: d.types, + })), + }; +} + +// v1({sections:[{key,enabled,title}]})→ v2 迁移 +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 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); + return { ...d, enabled: old?.enabled ?? true, title: old?.title ?? dd?.defaultTitle ?? d.title }; + }); + // v1 里用户新增的板块(不在默认定义中)→ 追加为自定义文本板块 + for (const o of oldSections) { + if (!sections.some(s => s.key === o.key)) { + sections.push({ key: o.key, title: o.title || o.key, enabled: o.enabled ?? true, kind: 'text', text: '' }); + } + } + return { + version: 2, + template: r.template ?? 'tech', + accent: r.accent ?? '#3056d3', + fontScale: Math.min(1.2, Math.max(0.8, r.fontScale ?? 1)), + density: r.density ?? 'normal', + onePage: r.onePage ?? false, + sections, + }; +} + +// ---------- 装配 ---------- + +export interface AssembledItem { + title: string; subtitle?: string; date?: string; desc?: string; + fromRecord?: number; excluded?: boolean; } export interface AssembledSection { - key: string; title: string; - items?: ResumeItem[]; - text?: string; + key: string; title: string; kind: 'info' | 'list' | 'text'; + items?: AssembledItem[]; text?: string; +} + +function claimedTypes(): string[] { + return RESUME_SECTION_DEFS.filter(d => d.types).flatMap(d => d.types!); +} + +function recordToItem(r: FootprintRow): ResumeItemCfg { + const subtitle = [r.organizer, r.level, r.award_grade].filter(v => v != null && v !== '').map(String).join(' · '); + const desc = [r.result, r.description].filter(v => v != null && v !== '').map(String).join(' — '); + return { + title: r.title, + subtitle: subtitle || undefined, + date: r.start_date ?? r.created_at.slice(0, 10), + desc: desc || undefined, + fromRecord: r.id, + }; } export function assembleSections( records: FootprintRow[], - profile: Partial | undefined, - config: ResumeConfig | undefined + config: ResumeConfig ): AssembledSection[] { - const cfg = config?.sections?.length ? config.sections : defaultResumeConfig().sections; - const byKey = new Map(RESUME_SECTION_DEFS.map(s => [s.key, s])); - const out: AssembledSection[] = []; - for (const s of cfg) { + const claimed = claimedTypes(); + + for (const s of config.sections) { if (!s.enabled) continue; - const def = byKey.get(s.key); - if (s.key === 'info') { out.push({ key: s.key, title: s.title, text: 'info' }); continue; } - if (s.key === 'self') { out.push({ key: s.key, title: s.title, text: profile?.selfIntro || '' }); 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 === 'self' || s.kind === 'text') { + out.push({ key: s.key, title: s.title, kind: 'text', text: s.text ?? '' }); + continue; + } + + // list:记录装配(按 types 或「其他」桶)+ 自定义条目合并 + const excluded = new Set(s.excluded ?? []); + let derived: FootprintRow[] = []; const types = def?.types; - if (!types) continue; - const items = records.filter(r => types.includes(r.type_name) && r.status !== 'draft'); + if (types) { + derived = records.filter(r => types.includes(r.type_name) && r.status !== 'draft' && !excluded.has(r.id)); + } else { + // 其他桶:未归类的类型 + 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)); + for (const c of s.items ?? []) items.push({ ...c }); + + items.sort((a, b) => (b.date ?? '').localeCompare(a.date ?? '')); if (!items.length) continue; - items.sort((a, b) => (b.start_date ?? b.created_at).localeCompare(a.start_date ?? a.created_at)); - out.push({ key: s.key, title: s.title, items: items.map(row => ({ row })) }); + out.push({ key: s.key, title: s.title, kind: 'list', items }); } return out; } -// Word 导出:简历 HTML(内联样式,Word/WPS 可直接打开) +// ---------- 模板 ---------- + +export const TEMPLATES: { key: ResumeTemplate; name: string; desc: string }[] = [ + { key: 'tech', name: '互联网技术风', desc: '品牌色标题 + 左侧色条,紧凑现代' }, + { key: 'minimal', name: '极简风', desc: '灰色大写字距标题,大量留白' }, + { key: 'classic', name: '经典风', desc: '黑色衬线标题 + 通栏分隔线,正式通用' }, +]; + +export const ACCENT_CHOICES = ['#3056d3', '#0f766e', '#7c3aed', '#c2410c', '#0e7490', '#1f2937']; + +// ---------- Word 导出(内联样式,按模板着色) ---------- + +const esc = (t: unknown) => String(t ?? '').replace(/&/g, '&').replace(//g, '>'); + export function resumeToWordHtml( sections: AssembledSection[], - profile: Partial | undefined, + profile: Partial, + cfg: ResumeConfig, photoUrl?: string ): string { - const esc = (t: string) => t.replace(/&/g, '&').replace(//g, '>'); - const P = profile ?? {}; + const scale = cfg.fontScale; + const pt = (base: number) => (base * scale).toFixed(1) + 'pt'; + const accent = cfg.accent; + const titleStyle: Record = { + classic: 'font-size:' + pt(14) + ';border-bottom:1.5pt solid #333;padding-bottom:3pt;margin:12pt 0 6pt;color:#111', + tech: 'font-size:' + pt(14) + ';color:' + accent + ';border-left:3pt solid ' + accent + ';padding-left:6pt;margin:12pt 0 6pt', + minimal: 'font-size:' + pt(11.5) + ';color:#555;letter-spacing:1.5pt;text-transform:uppercase;margin:14pt 0 6pt', + }; + const secHtml = sections.map(sec => { let inner = ''; - if (sec.key === 'info') { + if (sec.kind === 'info') { const rows: [string, string][] = ([ - ['姓名', P.name ?? ''], ['性别', P.gender ?? ''], ['出生年月', P.birth ?? ''], - ['民族', P.nation ?? ''], ['籍贯', P.native ?? ''], ['政治面貌', P.political ?? ''], - ['学校', P.school ?? ''], ['学院', P.college ?? ''], ['专业', P.major ?? ''], - ['班级', P.className ?? ''], ['学号', P.studentId ?? ''], ['学历', P.degree ?? ''], - ['手机', P.phone ?? ''], ['邮箱', P.email ?? ''], + ['姓名', P0(profile).name], ['性别', P0(profile).gender], ['出生年月', P0(profile).birth], + ['民族', P0(profile).nation], ['籍贯', P0(profile).native], ['政治面貌', P0(profile).political], + ['学校', P0(profile).school], ['学院', P0(profile).college], ['专业', P0(profile).major], + ['班级', P0(profile).className], ['学号', P0(profile).studentId], ['学历', P0(profile).degree], + ['手机', P0(profile).phone], ['邮箱', P0(profile).email], ] as [string, string][]).filter(r => r[1] !== ''); - inner = '' + + inner = '
' + rows.map(([k, v], i) => (i % 2 === 0 ? '' : '') + - '' + - '' + + '' + + '' + (i % 2 === 1 ? '' : '') ).join('') + '
' + esc(String(k)) + '' + esc(String(v)) + '' + esc(k) + '' + esc(v) + '
'; - } else if (sec.items) { - inner = '
' + sec.items.map(it => { - const r = it.row; - const date = (r.start_date ?? r.created_at).slice(0, 10); - const sub = [r.organizer, r.level, r.award_grade].filter(v => v != null && v !== '').map(v => esc(String(v))).join(' · '); + } else if (sec.kind === 'list') { + inner = '
' + (sec.items ?? []).map(it => { + const sub = it.subtitle ? '
' + esc(it.subtitle) + '
' : ''; + const desc = it.desc ? '
' + esc(it.desc) + '
' : ''; return '
' + - '
' + esc(r.title) + '' + - '' + esc(date) + '
' + - (sub ? '
' + sub + '
' : '') + - (r.result ? '
' + esc(r.result) + '
' : '') + - '
'; + '
' + esc(it.title) + '' + + '' + esc(it.date ?? '') + '
' + + sub + desc + '
'; }).join('') + '
'; - } else if (sec.text) { - inner = '

' + esc(sec.text) + '

'; + } else { + inner = '

' + esc(sec.text ?? '') + '

'; } - return '

' + esc(sec.title) + '

' + inner; + return '

' + esc(sec.title) + '

' + inner; }).join(''); - const photo = photoUrl && P.photoUrl - ? '
证件照
' + const photo = photoUrl && profile.photoUrl + ? '
证件照
' : ''; + const headSub = [P0(profile).school, P0(profile).college, P0(profile).major, P0(profile).degree].filter(v => v !== '').map(v => esc(v)).join(' · '); + return '' + - '个人简历' + - '' + + '' + esc(P0(profile).name || '个人简历') + '' + + '' + '
' + - '

' + esc(P.name || '个人简历') + '

' + photo + '
' + - (P.major || P.school ? '
' + - [P.school, P.college, P.major, P.degree].filter(v => !!v).map(v => esc(String(v))).join(' · ') + '
' : '') + + '

' + esc(P0(profile).name || '个人简历') + '

' + photo + '' + + (headSub ? '
' + headSub + '
' : '') + secHtml + ''; } + +// 避免循环引用的小工具 +function P0(p: Partial | undefined): Partial { + return p ?? {}; +} diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 89cc6b2..ed5e537 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -239,5 +239,23 @@ export interface ProfileInfo { phone: string; email: string; homepage: string; photoUrl: string; selfIntro: string; } -export interface ResumeSectionCfg { key: string; enabled: boolean; title: string; } -export interface ResumeConfig { sections: ResumeSectionCfg[]; } +export interface ResumeItemCfg { title: string; subtitle?: string; date?: string; desc?: string; fromRecord?: number } + +export interface ResumeSectionCfg { + key: string; title: string; enabled: boolean; + kind?: 'info' | 'list' | 'text'; + types?: string[]; + excluded?: number[]; + items?: ResumeItemCfg[]; + text?: string; +} + +export interface ResumeConfig { + version: 2; + template: 'classic' | 'tech' | 'minimal'; + accent: string; + fontScale: number; + density: 'compact' | 'normal' | 'relaxed'; + onePage: boolean; + sections: ResumeSectionCfg[]; +} diff --git a/app/src/pages/resume/ResumePage.tsx b/app/src/pages/resume/ResumePage.tsx index c1388be..69e452e 100644 --- a/app/src/pages/resume/ResumePage.tsx +++ b/app/src/pages/resume/ResumePage.tsx @@ -1,53 +1,69 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, 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; } +import { ArrowDown, ArrowUp, ChevronRight, Download, GripVertical, PanelRightClose, PanelRightOpen, Plus, Printer, Trash2 } from 'lucide-react'; +import { footprintList, profileGet, profileUpsert } from '@/lib/api'; +import type { FootprintRow, ProfileInfo, ResumeConfig, ResumeItemCfg, ResumeSectionCfg } from '@/lib/types'; +import { + ACCENT_CHOICES, assembleSections, defaultResumeConfig, migrateResumeConfig, + RESUME_SECTION_DEFS, TEMPLATES, type ResumeTemplate, +} from '@/lib/resume'; +import { Button, Card, CardBody, CardHeader, Field, Input, Select, Textarea, useToast } from '@/components/ui'; 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; - const [config, setConfig] = useState(() => stored?.resume ?? defaultResumeConfig()); - const [selfIntro, setSelfIntro] = useState(profile.selfIntro ?? ''); - const introDirty = selfIntro !== (profile.selfIntro ?? ''); - const previewProfile: Partial = { ...profile, selfIntro: selfIntroDraftFix(selfIntro) }; + const [config, setConfig] = useState(null); + const [selfIntro, setSelfIntro] = useState(''); + const [panelOpen, setPanelOpen] = useState(true); + const [openEditors, setOpenEditors] = useState>(new Set()); + const [fitZoom, setFitZoom] = useState(1); + const contentRef = useRef(null); + const dragKey = useRef(null); - const records = listQ.data?.items ?? []; - const sections = useMemo( - () => assembleSections(records, previewProfile, config), - [records, previewProfile, config] - ); + // 初始化(迁移 v1 配置) + useEffect(() => { + const stored = profileQ.data?.content; + if (!stored) return; + setConfig(migrateResumeConfig(stored.resume)); + setSelfIntro((stored.info as ProfileInfo | undefined)?.selfIntro ?? ''); + }, [profileQ.data]); - const move = (i: number, dir: -1 | 1) => setConfig(c => { + 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 move = (i: number, dir: -1 | 1) => setConfig(pc => { + if (!pc) return pc; + const c = pc; 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 persist = (next: ResumeConfig) => { + setConfig(next); + void profileUpsert({ info: { ...profile, selfIntro }, resume: next }).catch(e => toast('error', e.message)); + }; + + // 一页限定:内容超高时自动缩放 + useEffect(() => { + if (!cfg.onePage) { setFitZoom(1); return; } + const el = contentRef.current; + if (!el) return; + const available = 1043; // A4 高 1123 - 上下 padding 80 + const h = el.scrollHeight; + setFitZoom(h > available ? Math.max(0.7, available / h) : 1); + }, [cfg.onePage, cfg.fontScale, cfg.density, sections]); + + const zoom = cfg.fontScale * (cfg.onePage ? fitZoom : 1); const exportWord = () => { void import('@/lib/resume').then(({ resumeToWordHtml }) => { - const html = resumeToWordHtml(sections, previewProfile, previewProfile.photoUrl); + const html = resumeToWordHtml(sections, { ...profile, selfIntro }, cfg, profile.photoUrl); const blob = new Blob(['\ufeff' + html], { type: 'application/msword' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -55,138 +71,311 @@ export function ResumePage() { a.download = (profile.name || '个人简历') + '.doc'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 60_000); - toast('success', 'Word 文档已导出'); + toast('success', 'Word 文档已导出(按当前模板与字号)'); }); }; + const sectionTitles = (key: string) => RESUME_SECTION_DEFS.find(d => d.key === key)?.defaultTitle ?? key; + return ( -
+

我的简历

-
+
+ - +
-
- {/* 布局面板 */} - - - -
- {config.sections.map((s, i) => { - const hasItems = sections.find(x => x.key === s.key); - return ( -
-
- toggle(i)} aria-label="启用板块" className="size-3.5" /> - retitle(i, e.target.value)} - /> - - -
-
- {RESUME_SECTION_DEFS.find(d => d.key === s.key)?.types?.join(' / ') ?? '固定板块'} - {s.enabled && !hasItems && · 暂无内容} -
-
- ); - })} -
-
-
- -