feat(resume): v3 flexibility - custom list sections, item reorder, info field visibility, header toggles, config io, migration persistence fix

This commit is contained in:
root
2026-10-17 14:34:06 +08:00
parent 40661aa685
commit 5f3482538f
17 changed files with 245 additions and 108 deletions
+41 -24
View File
@@ -54,15 +54,23 @@ export function defaultResumeConfig(): ResumeConfig {
export function migrateResumeConfig(raw: unknown): ResumeConfig { export function migrateResumeConfig(raw: unknown): ResumeConfig {
const def = defaultResumeConfig(); const def = defaultResumeConfig();
if (!raw || typeof raw !== 'object') return def; 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 oldSections = Array.isArray(r.sections) ? r.sections : [];
const sections = def.sections.map(d => { const sections = def.sections.map(d => {
const old = oldSections.find(o => o.key === d.key); const old = oldSections.find(o => o.key === d.key);
const dd = RESUME_SECTION_DEFS.find(x => x.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 === '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; return base;
}); });
// 旧版本已被取代的板块 key:直接丢弃,避免残留造成内容重复 // 旧版本已被取代的板块 key:直接丢弃,避免残留造成内容重复
@@ -88,6 +96,7 @@ export function migrateResumeConfig(raw: unknown): ResumeConfig {
// ---------- 装配 ---------- // ---------- 装配 ----------
export interface AssembledItem { export interface AssembledItem {
itemKey: string;
title: string; subtitle?: string; date?: string; desc?: string; title: string; subtitle?: string; date?: string; desc?: string;
fromRecord?: number; excluded?: boolean; fromRecord?: number; excluded?: boolean;
} }
@@ -95,6 +104,7 @@ export interface AssembledItem {
export interface AssembledSection { export interface AssembledSection {
key: string; title: string; kind: 'info' | 'edu' | 'list' | 'text'; key: string; title: string; kind: 'info' | 'edu' | 'list' | 'text';
items?: AssembledItem[]; text?: string; items?: AssembledItem[]; text?: string;
hiddenFields?: string[];
} }
function claimedTypes(): string[] { function claimedTypes(): string[] {
@@ -125,7 +135,7 @@ export function assembleSections(
if (!s.enabled) continue; if (!s.enabled) continue;
const def = RESUME_SECTION_DEFS.find(d => d.key === s.key); 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') { if (s.key === 'self' || s.kind === 'text') {
const txt = s.text ?? ''; const txt = s.text ?? '';
if (!txt.trim()) continue; // 空板块不渲染 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)); 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)) // 手动排序:itemOrder 优先,未排序的按日期排在后面
.map(it => ({ ...it, ...(s.overrides?.[String(it.fromRecord)] ?? {}) })); const orderIdx = new Map((s.itemOrder ?? []).map((k, i) => [k, i]));
items.forEach(it => { if (it.fromRecord) usedRecordIds.add(it.fromRecord); }); const items: (AssembledItem & { _ord: number })[] = [];
for (const c of s.items ?? []) items.push({ ...c }); for (const r of derived) {
const it: AssembledItem = { itemKey: 'r' + r.id, ...recordToItem(r), ...(s.overrides?.[String(r.id)] ?? {}) };
items.sort((a, b) => (b.date ?? '').localeCompare(a.date ?? '')); 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; 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; return out;
} }
@@ -191,7 +208,7 @@ export function resumeToWordHtml(
const nameColor = cfg.template === 'tech' ? accent : '#111'; 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 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 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
? '<td style="width:' + (120 * scale).toFixed(0) + 'px;text-align:right;vertical-align:top">' + ? '<td style="width:' + (120 * scale).toFixed(0) + 'px;text-align:right;vertical-align:top">' +
'<img src="' + esc(P.photoUrl) + '" alt="证件照" width="' + Math.round(120 * scale) + '" height="' + Math.round(160 * scale) + '" style="width:' + Math.round(120 * scale) + 'px;height:' + Math.round(160 * scale) + 'px" /></td>' '<img src="' + esc(P.photoUrl) + '" alt="证件照" width="' + Math.round(120 * scale) + '" height="' + Math.round(160 * scale) + '" style="width:' + Math.round(120 * scale) + 'px;height:' + Math.round(160 * scale) + 'px" /></td>'
: ''; : '';
@@ -199,8 +216,8 @@ export function resumeToWordHtml(
const head = '<table style="width:100%;border-collapse:collapse"><tr>' + const head = '<table style="width:100%;border-collapse:collapse"><tr>' +
'<td style="vertical-align:top">' + '<td style="vertical-align:top">' +
'<div style="font-size:' + pt(20) + ';font-weight:bold;color:' + nameColor + '">' + esc(P.name || '个人简历') + '</div>' + '<div style="font-size:' + pt(20) + ';font-weight:bold;color:' + nameColor + '">' + esc(P.name || '个人简历') + '</div>' +
(schoolLine ? '<div style="color:#555;font-size:' + pt(11) + ';margin-top:3pt">' + schoolLine + '</div>' : '') + ((cfg.header?.showSchool ?? true) && schoolLine ? '<div style="color:#555;font-size:' + pt(11) + ';margin-top:3pt">' + schoolLine + '</div>' : '') +
(contactLine ? '<div style="color:#555;font-size:' + pt(11) + ';margin-top:2pt">' + contactLine + '</div>' : '') + ((cfg.header?.showContact ?? true) && contactLine ? '<div style="color:#555;font-size:' + pt(11) + ';margin-top:2pt">' + contactLine + '</div>' : '') +
'</td>' + photo + '</tr></table>'; '</td>' + photo + '</tr></table>';
// 条目:标题(左) + 日期(右) 表格行 + 副标题 + 描述 // 条目:标题(左) + 日期(右) 表格行 + 副标题 + 描述
@@ -224,17 +241,17 @@ export function resumeToWordHtml(
'<td style="padding:3px 6px">' + esc(kv[1]) + '</td></tr>').join('') + '</table>' + '<td style="padding:3px 6px">' + esc(kv[1]) + '</td></tr>').join('') + '</table>' +
(P.majorCourses ? '<div style="margin-top:6pt;font-size:' + pt(11.5) + ';line-height:1.6"><b>主修课程:</b>' + esc(P.majorCourses) + '</div>' : ''); (P.majorCourses ? '<div style="margin-top:6pt;font-size:' + pt(11.5) + ';line-height:1.6"><b>主修课程:</b>' + esc(P.majorCourses) + '</div>' : '');
} else if (sec.kind === 'info') { } else if (sec.kind === 'info') {
const rows: [string, string][] = ([ const infoRows: [string, string, string][] = ([
['姓名', P.name ?? ''], ['性别', P.gender ?? ''], ['出生年月', P.birth ?? ''], ['name', '姓名', P.name ?? ''], ['gender', '性别', P.gender ?? ''], ['birth', '出生年月', P.birth ?? ''],
['民族', P.nation ?? ''], ['籍贯', P.native ?? ''], ['政治面貌', P.political ?? ''], ['nation', '民族', P.nation ?? ''], ['native', '籍贯', P.native ?? ''], ['political', '政治面貌', P.political ?? ''],
['班级', P.className ?? ''], ['学号', P.studentId ?? ''], ['学历', P.degree ?? ''], ['className', '班级', P.className ?? ''], ['studentId', '学号', P.studentId ?? ''], ['degree', '学历', P.degree ?? ''],
['手机', P.phone ?? ''], ['邮箱', P.email ?? ''], ['phone', '手机', P.phone ?? ''], ['email', '邮箱', P.email ?? ''],
] as [string, string][]).filter(r => r[1] !== ''); ] as [string, string, string][]).filter(r => r[2] !== '' && !(sec.hiddenFields ?? []).includes(r[0]));
inner = '<table style="width:100%;border-collapse:collapse;font-size:' + pt(12) + '">' + inner = '<table style="width:100%;border-collapse:collapse;font-size:' + pt(12) + '">' +
rows.map((kv, i) => infoRows.map((kv, i) =>
(i % 2 === 0 ? '<tr>' : '') + (i % 2 === 0 ? '<tr>' : '') +
'<td style="width:80px;padding:3px 6px;color:#555">' + esc(kv[0]) + '</td>' + '<td style="width:80px;padding:3px 6px;color:#555">' + esc(kv[1]) + '</td>' +
'<td style="padding:3px 6px">' + esc(kv[1]) + '</td>' + '<td style="padding:3px 6px">' + esc(kv[2]) + '</td>' +
(i % 2 === 1 ? '</tr>' : '')).join('') + '</table>'; (i % 2 === 1 ? '</tr>' : '')).join('') + '</table>';
} else if (sec.kind === 'list') { } else if (sec.kind === 'list') {
inner = '<div>' + (sec.items ?? []).map(itemHtml).join('') + '</div>'; inner = '<div>' + (sec.items ?? []).map(itemHtml).join('') + '</div>';
+4 -1
View File
@@ -240,7 +240,7 @@ export interface ProfileInfo {
phone: string; email: string; homepage: string; photoUrl: string; phone: string; email: string; homepage: string; photoUrl: string;
selfIntro: 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 { export interface ResumeSectionCfg {
key: string; title: string; enabled: boolean; key: string; title: string; enabled: boolean;
@@ -250,6 +250,8 @@ export interface ResumeSectionCfg {
items?: ResumeItemCfg[]; items?: ResumeItemCfg[];
text?: string; text?: string;
overrides?: Record<string, Partial<ResumeItemCfg>>; overrides?: Record<string, Partial<ResumeItemCfg>>;
itemOrder?: string[];
hiddenFields?: string[];
} }
export interface ResumeConfig { export interface ResumeConfig {
@@ -259,5 +261,6 @@ export interface ResumeConfig {
fontScale: number; fontScale: number;
density: 'compact' | 'normal' | 'relaxed'; density: 'compact' | 'normal' | 'relaxed';
onePage: boolean; onePage: boolean;
header?: { showPhoto?: boolean; showContact?: boolean; showSchool?: boolean };
sections: ResumeSectionCfg[]; sections: ResumeSectionCfg[];
} }
+137 -20
View File
@@ -9,6 +9,18 @@ import {
} from '@/lib/resume'; } from '@/lib/resume';
import { Button, Card, CardBody, CardHeader, Field, Input, Select, Textarea, useToast } from '@/components/ui'; import { Button, Card, CardBody, CardHeader, Field, Input, Select, Textarea, useToast } from '@/components/ui';
const INFO_KEY: Record<string, string> = {
姓名: '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 // 2023-09 → 2023.9
function fmtEduDate(v: string | undefined): string { function fmtEduDate(v: string | undefined): string {
const m = /^(\d{4})-(\d{1,2})/.exec(v ?? ''); 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 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 ( return (
<div className="mx-auto max-w-[1280px] px-3 py-4 sm:px-6 sm:py-5"> <div className="mx-auto max-w-[1280px] px-3 py-4 sm:px-6 sm:py-5">
<div className="no-print mb-4 flex flex-wrap items-center justify-between gap-2"> <div className="no-print mb-4 flex flex-wrap items-center justify-between gap-2">
@@ -162,14 +204,18 @@ export function ResumePage() {
<h1 className={'text-[26px] font-bold leading-tight ' + (cfg.template === 'tech' ? 'text-[var(--accent)]' : 'text-neutral-900')}> <h1 className={'text-[26px] font-bold leading-tight ' + (cfg.template === 'tech' ? 'text-[var(--accent)]' : 'text-neutral-900')}>
{profile.name || '姓名'} {profile.name || '姓名'}
</h1> </h1>
{(cfg.header?.showSchool ?? true) && (
<div className="mt-1.5 text-[12.5px] text-neutral-500"> <div className="mt-1.5 text-[12.5px] text-neutral-500">
{[profile.school, profile.college, profile.major, profile.degree].filter(Boolean).join(' · ') || '完善个人信息后自动展示'} {[profile.school, profile.college, profile.major, profile.degree].filter(Boolean).join(' · ') || '完善个人信息后自动展示'}
</div> </div>
)}
{(cfg.header?.showContact ?? true) && (
<div className="mt-1 text-[12px] text-neutral-500"> <div className="mt-1 text-[12px] text-neutral-500">
{[profile.phone, profile.email, profile.homepage].filter(Boolean).join(' · ')} {[profile.phone, profile.email, profile.homepage].filter(Boolean).join(' · ')}
</div> </div>
)}
</div> </div>
{profile.photoUrl && ( {(cfg.header?.showPhoto ?? true) && profile.photoUrl && (
<img src={profile.photoUrl} alt="证件照" className="h-[110px] w-[82px] rounded border border-neutral-200 object-cover" /> <img src={profile.photoUrl} alt="证件照" className="h-[110px] w-[82px] rounded border border-neutral-200 object-cover" />
)} )}
</div> </div>
@@ -209,7 +255,7 @@ export function ResumePage() {
<div className="grid grid-cols-1 gap-x-8 gap-y-1 text-[12.5px] sm:grid-cols-2"> <div className="grid grid-cols-1 gap-x-8 gap-y-1 text-[12.5px] sm:grid-cols-2">
{([['姓名', profile.name], ['性别', profile.gender], ['出生年月', profile.birth], ['民族', profile.nation], {([['姓名', profile.name], ['性别', profile.gender], ['出生年月', profile.birth], ['民族', profile.nation],
['籍贯', profile.native], ['政治面貌', profile.political], ['班级', profile.className], ['学号', profile.studentId], ['籍贯', 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 => (
<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 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> </div>
@@ -220,7 +266,7 @@ export function ResumePage() {
) : ( ) : (
<div className={cfg.density === 'compact' ? 'flex flex-col gap-1.5' : 'flex flex-col gap-3'}> <div className={cfg.density === 'compact' ? 'flex flex-col gap-1.5' : 'flex flex-col gap-3'}>
{sec.items?.map((it, itemIdx) => ( {sec.items?.map((it, itemIdx) => (
<div key={(it.title ?? '') + (it.date ?? '') + itemIdx}> <div key={it.itemKey ?? (it.title ?? '') + itemIdx}>
<div className="flex items-baseline justify-between gap-3"> <div className="flex items-baseline justify-between gap-3">
<span className="text-[13px] font-semibold text-neutral-900 r-item-title"> <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 })} /> <Ed html={it.title} onCommit={v => 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: '大' }]} /> options={[{ value: '0.9', label: '小' }, { value: '1', label: '标准' }, { value: '1.1', label: '大' }]} />
</Field> </Field>
</div> </div>
<Field label="头部布局">
<div className="flex flex-wrap gap-1.5 pt-1">
{([['showPhoto', '证件照'], ['showContact', '联系行'], ['showSchool', '学校行']] as [string, string][]).map(t => {
const on = cfg.header?.[t[0] as 'showPhoto' | 'showContact' | 'showSchool'] ?? true;
return (
<button key={t[0]}
className={'rounded-md border px-2.5 py-1 text-[11.5px] transition-colors ' + (on ? 'border-brand bg-brand-subtle text-brand-text' : 'border-line bg-surface text-ink-3')}
onClick={() => persist({ ...cfg, header: { ...cfg.header, [t[0]]: !on } })}>
{t[1]}
</button>
);
})}
</div>
</Field>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<Field label="间距"> <Field label="间距">
<Select value={cfg.density} onChange={e => persist({ ...cfg, density: e.target.value as ResumeConfig['density'] })} <Select value={cfg.density} onChange={e => persist({ ...cfg, density: e.target.value as ResumeConfig['density'] })}
@@ -294,6 +354,13 @@ export function ResumePage() {
</button> </button>
</Field> </Field>
</div> </div>
<div className="grid grid-cols-2 gap-2 border-t border-line pt-3">
<Button size="sm" variant="secondary" onClick={exportConfig}>导出配置</Button>
<label className="flex h-8 cursor-pointer items-center justify-center rounded-md border border-line bg-surface text-[12px] text-ink-2 transition-colors hover:border-line-strong">
导入配置
<input type="file" accept="application/json" className="hidden" onChange={e => { importConfig(e.target.files?.[0]); e.target.value = ''; }} />
</label>
</div>
</CardBody> </CardBody>
</Card> </Card>
@@ -347,6 +414,23 @@ export function ResumePage() {
{s.kind === 'text' ? ( {s.kind === 'text' ? (
<Textarea rows={4} value={s.text ?? ''} placeholder="输入该板块内容…" <Textarea rows={4} value={s.text ?? ''} placeholder="输入该板块内容…"
onChange={e => persist({ ...cfg, sections: cfg.sections.map(x => x.key === s.key ? { ...x, text: e.target.value } : x) })} /> onChange={e => persist({ ...cfg, sections: cfg.sections.map(x => x.key === s.key ? { ...x, text: e.target.value } : x) })} />
) : s.kind === 'info' ? (
<div className="flex flex-col gap-1">
<div className="mb-0.5 text-[11px] text-ink-3">勾选 = 在基本信息中显示</div>
{INFO_FIELDS.map(f => (
<label key={f[0]} className="flex cursor-pointer items-center gap-2 text-[12px]">
<input type="checkbox" className="size-3.5"
checked={!(s.hiddenFields ?? []).includes(f[0])}
onChange={() => persist({ ...cfg, sections: cfg.sections.map(x => {
if (x.key !== s.key) return x;
const hid = new Set(x.hiddenFields ?? []);
if (hid.has(f[0])) hid.delete(f[0]); else hid.add(f[0]);
return { ...x, hiddenFields: [...hid] };
}) })} />
{f[1]}
</label>
))}
</div>
) : s.kind === 'list' ? ( ) : s.kind === 'list' ? (
<ListSectionEditor <ListSectionEditor
cfg={cfg} section={s} records={records} persist={persist} cfg={cfg} section={s} records={records} persist={persist}
@@ -361,17 +445,12 @@ export function ResumePage() {
</div> </div>
); );
})} })}
<div className="p-3"> <div className="grid grid-cols-2 gap-2 p-3">
<Button size="sm" variant="secondary" className="w-full" <Button size="sm" variant="secondary" onClick={() => addSection('text')}>
onClick={() => { <Plus size={13} /> 文本板块
const key = 'custom_' + Date.now(); </Button>
persist({ <Button size="sm" variant="secondary" onClick={() => addSection('list')}>
...cfg, <Plus size={13} /> 列表板块
sections: [...cfg.sections, { key, title: '自定义板块', enabled: true, kind: 'text', text: '' }],
});
setOpenEditors(prev => new Set(prev).add(key));
}}>
<Plus size={13} /> 新增文本板块
</Button> </Button>
</div> </div>
</div> </div>
@@ -408,16 +487,54 @@ function ListSectionEditor({ cfg, section, records, persist, sectionTypes }: {
setSection({ excluded: [...next] }); setSection({ excluded: [...next] });
}; };
// 合并视图:记录 + 自定义条目统一排序(itemOrder 优先,未排序按追加顺序)
const allKeys: string[] = [...(section.itemOrder ?? [])];
const seen = new Set(allKeys);
const rowMap = new Map<string, { type: 'record'; r: FootprintRow } | { type: 'custom'; c: ResumeItemCfg; idx: number }>();
derived.forEach(r => { const k = 'r' + r.id; rowMap.set(k, { type: 'record', r }); if (!seen.has(k)) { allKeys.push(k); seen.add(k); } });
customs.forEach((c, i) => { const k = c.id ?? 'c' + i; rowMap.set(k, { type: 'custom', c, idx: i }); if (!seen.has(k)) { allKeys.push(k); seen.add(k); } });
const moveKey = (k: string, dir: -1 | 1) => {
const i = allKeys.indexOf(k); const j = i + dir;
if (i < 0 || j < 0 || j >= allKeys.length) return;
[allKeys[i], allKeys[j]] = [allKeys[j], allKeys[i]];
setSection({ itemOrder: allKeys });
};
const ArrowBtns = ({ k }: { k: string }) => (
<span className="flex shrink-0 flex-col">
<button className="text-ink-3 hover:text-ink-1" aria-label="上移" onClick={() => moveKey(k, -1)}><ArrowUp size={11} /></button>
<button className="text-ink-3 hover:text-ink-1" aria-label="下移" onClick={() => moveKey(k, 1)}><ArrowDown size={11} /></button>
</span>
);
return ( return (
<div className="flex flex-col gap-2.5"> <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 => ( {allKeys.map(k => {
<label key={r.id} className="flex cursor-pointer items-center gap-2 text-[12px]"> const row = rowMap.get(k);
if (!row) return null;
if (row.type === 'record') {
const r = row.r;
return (
<div key={k} className="flex items-center gap-2 text-[12px]">
<ArrowBtns k={k} />
<label className="flex min-w-0 flex-1 cursor-pointer items-center gap-2">
<input type="checkbox" className="size-3.5" checked={!excluded.has(r.id)} onChange={() => toggleRecord(r.id)} /> <input type="checkbox" className="size-3.5" checked={!excluded.has(r.id)} onChange={() => toggleRecord(r.id)} />
<span className="min-w-0 flex-1 truncate text-ink-1">{r.title}</span> <span className="min-w-0 flex-1 truncate text-ink-1">{r.title}</span>
<span className="num shrink-0 text-[11px] text-ink-3">{(r.start_date ?? '').slice(0, 7)}</span>
</label> </label>
))} <span className="num shrink-0 text-[11px] text-ink-3">{(r.start_date ?? '').slice(0, 7)}</span>
</div>
);
}
return (
<div key={k} className="flex items-center gap-2 rounded-md border border-line bg-surface p-1.5 text-[12px]">
<ArrowBtns k={k} />
<span className="min-w-0 flex-1 truncate text-ink-1">{row.c.title}<span className="text-ink-3"> · {row.c.date ?? ''}</span></span>
<button className="text-ink-3 hover:text-danger" aria-label="删除条目"
onClick={() => setSection({ items: customs.filter((_, x) => x !== row.idx) })}>
<Trash2 size={12} />
</button>
</div>
);
})}
<div className="mt-1 border-t border-line pt-2"> <div className="mt-1 border-t border-line pt-2">
<div className="mb-1.5 flex items-center justify-between"> <div className="mb-1.5 flex items-center justify-between">
@@ -438,7 +555,7 @@ function ListSectionEditor({ cfg, section, records, persist, sectionTypes }: {
<Button size="sm" variant="primary" disabled={!adding.title.trim()} <Button size="sm" variant="primary" disabled={!adding.title.trim()}
onClick={() => { onClick={() => {
if (!adding.title.trim()) return; if (!adding.title.trim()) return;
setSection({ items: [...customs, adding] }); setSection({ items: [...customs, { ...adding, id: 'c' + Date.now() }] });
setAdding(null); setAdding(null);
}}>添加</Button> }}>添加</Button>
<Button size="sm" variant="ghost" onClick={() => setAdding(null)}>取消</Button> <Button size="sm" variant="ghost" onClick={() => setAdding(null)}>取消</Button>
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{r as g,f as v,j as t,S as j,t as S}from"./index-BCAZXiEu.js";import{C as c,a as d,b as x}from"./card-DaQiiTAq.js";import{u as N,i as u,a as m}from"./useChart-BXGoMcGj.js";import{u as k,i as w,a as C,b as L,c as R,d as $,e as z,f as T}from"./echarts-BIWefrIc.js";k([w,C,L,R,$,z,T]);function q(){const[l,n]=g.useState(""),i=N(),e=v({queryKey:["stats",l||void 0],queryFn:()=>S(l?Number(l):void 0)}).data,p=u("2"),r=u("3"),h=m(()=>({grid:{left:34,right:12,top:14,bottom:24},tooltip:{trigger:"axis"},xAxis:{type:"category",data:(e==null?void 0:e.monthly.map(a=>a.ym.slice(2)))??[],axisLine:{lineStyle:{color:r}},axisTick:{show:!1},axisLabel:{color:r,fontSize:10}},yAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},series:[{type:"bar",data:(e==null?void 0:e.monthly.map(a=>a.count))??[],barMaxWidth:18,itemStyle:{color:"#3056d3",borderRadius:[3,3,0,0]}}]}),[e,i]),b=m(()=>{const a=((e==null?void 0:e.by_type)??[]).filter(s=>s.count>0);return{tooltip:{trigger:"item"},legend:{orient:"vertical",right:4,top:"middle",textStyle:{color:p,fontSize:11},itemWidth:10,itemHeight:10},series:[{type:"pie",radius:["48%","72%"],center:["34%","50%"],label:{show:!1},data:a.map(s=>({name:s.label,value:s.count,itemStyle:{color:s.color||"#64748b"}}))}]}},[e,i]),y=m(()=>{const a=((e==null?void 0:e.by_level)??[]).filter(s=>s.count>0).slice().reverse();return{grid:{left:64,right:24,top:8,bottom:22},tooltip:{trigger:"axis"},xAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},yAxis:{type:"category",data:a.map(s=>s.label),axisLine:{show:!1},axisTick:{show:!1},axisLabel:{color:p,fontSize:11}},series:[{type:"bar",data:a.map(s=>({value:s.count,itemStyle:{color:s.color||"#3056d3",borderRadius:[0,3,3,0]}})),barMaxWidth:14}]}},[e,i]),f=((e==null?void 0:e.metrics)??[]).slice(0,3);return t.jsxs("div",{className:"mx-auto max-w-[1080px] px-3 py-4 sm:px-6 sm:py-5",children:[t.jsxs("div",{className:"mb-4 flex flex-wrap items-center justify-between gap-3",children:[t.jsxs("div",{className:"flex flex-wrap gap-3",children:[t.jsx(o,{label:"总记录",value:(e==null?void 0:e.total)??0}),t.jsx(o,{label:l?l+" 年新增":"本年新增",value:(e==null?void 0:e.total_this_year)??0}),t.jsx(o,{label:"附件总数",value:(e==null?void 0:e.attachment_total)??0}),f.map(a=>t.jsx(o,{label:a.label,value:a.value,unit:a.unit??""},a.key))]}),t.jsx(j,{className:"w-[120px]",value:l,onChange:a=>n(a.target.value),options:[{value:"",label:"全部年份"},...((e==null?void 0:e.years)??[]).map(a=>({value:String(a),label:a+" 年"}))]})]}),!(e!=null&&e.metrics.length)&&t.jsx("div",{className:"mb-4 rounded-md border border-dashed border-line px-4 py-2 text-[12px] text-ink-3",children:"提示:在「统计」配置中注册指标(如志愿时长、读书本数),带单位的数值字段即可在此量化展示。"}),t.jsxs("div",{className:"grid grid-cols-1 lg:grid-cols-2 gap-4",children:[t.jsxs(c,{children:[t.jsx(d,{title:"近 12 个月活跃度"}),t.jsx(x,{children:t.jsx("div",{ref:h,className:"h-[220px]"})})]}),t.jsxs(c,{children:[t.jsx(d,{title:"类型分布"}),t.jsx(x,{children:t.jsx("div",{ref:b,className:"h-[220px]"})})]}),t.jsxs(c,{className:"lg:col-span-2",children:[t.jsx(d,{title:"级别分布"}),t.jsx(x,{children:t.jsx("div",{ref:y,className:"h-[200px]"})})]})]})]})}function o({label:l,value:n,unit:i}){return t.jsxs("div",{className:"min-w-[120px] rounded-lg border border-line bg-surface px-4 py-3 shadow-card",children:[t.jsx("div",{className:"text-[12px] text-ink-3",children:l}),t.jsxs("div",{className:"num mt-0.5 text-[22px] font-semibold leading-tight text-ink-1",children:[n,i&&t.jsx("span",{className:"ml-1 text-[12px] font-normal text-ink-3",children:i})]})]})}export{q as DashboardPage}; import{r as g,f as v,j as t,S as j,t as S}from"./index-j5Xhw0AZ.js";import{C as c,a as d,b as x}from"./card-G_DTWQFR.js";import{u as N,i as u,a as m}from"./useChart-CcPk8-yZ.js";import{u as k,i as w,a as C,b as L,c as R,d as $,e as z,f as T}from"./echarts-BIWefrIc.js";k([w,C,L,R,$,z,T]);function q(){const[l,n]=g.useState(""),i=N(),e=v({queryKey:["stats",l||void 0],queryFn:()=>S(l?Number(l):void 0)}).data,p=u("2"),r=u("3"),h=m(()=>({grid:{left:34,right:12,top:14,bottom:24},tooltip:{trigger:"axis"},xAxis:{type:"category",data:(e==null?void 0:e.monthly.map(a=>a.ym.slice(2)))??[],axisLine:{lineStyle:{color:r}},axisTick:{show:!1},axisLabel:{color:r,fontSize:10}},yAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},series:[{type:"bar",data:(e==null?void 0:e.monthly.map(a=>a.count))??[],barMaxWidth:18,itemStyle:{color:"#3056d3",borderRadius:[3,3,0,0]}}]}),[e,i]),b=m(()=>{const a=((e==null?void 0:e.by_type)??[]).filter(s=>s.count>0);return{tooltip:{trigger:"item"},legend:{orient:"vertical",right:4,top:"middle",textStyle:{color:p,fontSize:11},itemWidth:10,itemHeight:10},series:[{type:"pie",radius:["48%","72%"],center:["34%","50%"],label:{show:!1},data:a.map(s=>({name:s.label,value:s.count,itemStyle:{color:s.color||"#64748b"}}))}]}},[e,i]),y=m(()=>{const a=((e==null?void 0:e.by_level)??[]).filter(s=>s.count>0).slice().reverse();return{grid:{left:64,right:24,top:8,bottom:22},tooltip:{trigger:"axis"},xAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},yAxis:{type:"category",data:a.map(s=>s.label),axisLine:{show:!1},axisTick:{show:!1},axisLabel:{color:p,fontSize:11}},series:[{type:"bar",data:a.map(s=>({value:s.count,itemStyle:{color:s.color||"#3056d3",borderRadius:[0,3,3,0]}})),barMaxWidth:14}]}},[e,i]),f=((e==null?void 0:e.metrics)??[]).slice(0,3);return t.jsxs("div",{className:"mx-auto max-w-[1080px] px-3 py-4 sm:px-6 sm:py-5",children:[t.jsxs("div",{className:"mb-4 flex flex-wrap items-center justify-between gap-3",children:[t.jsxs("div",{className:"flex flex-wrap gap-3",children:[t.jsx(o,{label:"总记录",value:(e==null?void 0:e.total)??0}),t.jsx(o,{label:l?l+" 年新增":"本年新增",value:(e==null?void 0:e.total_this_year)??0}),t.jsx(o,{label:"附件总数",value:(e==null?void 0:e.attachment_total)??0}),f.map(a=>t.jsx(o,{label:a.label,value:a.value,unit:a.unit??""},a.key))]}),t.jsx(j,{className:"w-[120px]",value:l,onChange:a=>n(a.target.value),options:[{value:"",label:"全部年份"},...((e==null?void 0:e.years)??[]).map(a=>({value:String(a),label:a+" 年"}))]})]}),!(e!=null&&e.metrics.length)&&t.jsx("div",{className:"mb-4 rounded-md border border-dashed border-line px-4 py-2 text-[12px] text-ink-3",children:"提示:在「统计」配置中注册指标(如志愿时长、读书本数),带单位的数值字段即可在此量化展示。"}),t.jsxs("div",{className:"grid grid-cols-1 lg:grid-cols-2 gap-4",children:[t.jsxs(c,{children:[t.jsx(d,{title:"近 12 个月活跃度"}),t.jsx(x,{children:t.jsx("div",{ref:h,className:"h-[220px]"})})]}),t.jsxs(c,{children:[t.jsx(d,{title:"类型分布"}),t.jsx(x,{children:t.jsx("div",{ref:b,className:"h-[220px]"})})]}),t.jsxs(c,{className:"lg:col-span-2",children:[t.jsx(d,{title:"级别分布"}),t.jsx(x,{children:t.jsx("div",{ref:y,className:"h-[200px]"})})]})]})]})}function o({label:l,value:n,unit:i}){return t.jsxs("div",{className:"min-w-[120px] rounded-lg border border-line bg-surface px-4 py-3 shadow-card",children:[t.jsx("div",{className:"text-[12px] text-ink-3",children:l}),t.jsxs("div",{className:"num mt-0.5 text-[22px] font-semibold leading-tight text-ink-1",children:[n,i&&t.jsx("span",{className:"ml-1 text-[12px] font-normal text-ink-3",children:i})]})]})}export{q as DashboardPage};
@@ -1,4 +1,4 @@
import{c as I,f as T,j as e,E as $,w as H,u as N,d as f,r as p,g as b,x as W,y as J,B as j,P as F,C as A,z as U,A as B,D as Y,m as k,l as M,T as _,I as g,F as V,H as w,J as E,K as D}from"./index-BCAZXiEu.js";import{C,a as G,b as q}from"./card-DaQiiTAq.js";import{u as X,i as K,a as P}from"./useChart-BXGoMcGj.js";import{u as Z,i as ee,b as se,a as te,c as ae,d as re,e as ne,f as le}from"./echarts-BIWefrIc.js";/** import{c as I,f as T,j as e,E as $,w as H,u as N,d as f,r as p,g as b,x as W,y as J,B as j,P as F,C as A,z as U,A as B,D as Y,m as k,l as M,T as _,I as g,F as V,H as w,J as E,K as D}from"./index-j5Xhw0AZ.js";import{C,a as G,b as q}from"./card-G_DTWQFR.js";import{u as X,i as K,a as P}from"./useChart-CcPk8-yZ.js";import{u as Z,i as ee,b as se,a as te,c as ae,d as re,e as ne,f as le}from"./echarts-BIWefrIc.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as E,u as P,d as S,r as p,f as F,g as q,j as e,B as f,l as B,ab as L,F as z,H as k,S as M,I as Q,ac as _,ad as K,ae as v,af as T}from"./index-BCAZXiEu.js";/** import{c as E,u as P,d as S,r as p,f as F,g as q,j as e,B as f,l as B,ab as L,F as z,H as k,S as M,I as Q,ac as _,ad as K,ae as v,af as T}from"./index-j5Xhw0AZ.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as fe,j as e,a as N,r as s,b as he,u as ve,d as ge,e as je,f as L,g as ye,I as be,S as k,B as w,P as Q,h as te,L as se,G as ae,R as Ne,i as ne,k as ke,l as we,T as Ce,E as le,m as Ee,n as Se,o as _e,C as ze,p as qe,q as Le,s as Re,t as De,v as Ie}from"./index-BCAZXiEu.js";/** import{c as fe,j as e,a as N,r as s,b as he,u as ve,d as ge,e as je,f as L,g as ye,I as be,S as k,B as w,P as Q,h as te,L as se,G as ae,R as Ne,i as ne,k as ke,l as we,T as Ce,E as le,m as Ee,n as Se,o as _e,C as ze,p as qe,q as Le,s as Re,t as De,v as Ie}from"./index-j5Xhw0AZ.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{c as D,u as K,d as F,f as L,r,g as I,j as e,B as p,P as q,M as O,m as _,l as H,T as P,C as R,N as G,F as U,H as i,I as j,O as W,S as z,Q as J,U as V,q as X}from"./index-BCAZXiEu.js";import{C as Y}from"./card-DaQiiTAq.js";/** import{c as D,u as K,d as F,f as L,r,g as I,j as e,B as p,P as q,M as O,m as _,l as H,T as P,C as R,N as G,F as U,H as i,I as j,O as W,S as z,Q as J,U as V,q as X}from"./index-j5Xhw0AZ.js";import{C as Y}from"./card-G_DTWQFR.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1 +1 @@
import{j as s,a}from"./index-BCAZXiEu.js";function i({className:r,...e}){return s.jsx("div",{className:a("rounded-lg border border-line bg-surface shadow-card",r),...e})}function t({title:r,extra:e,className:n}){return s.jsxs("div",{className:a("flex min-h-11 flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-1.5",n),children:[s.jsx("h3",{className:"text-[13px] font-semibold text-ink-1",children:r}),e&&s.jsx("div",{className:"flex flex-wrap items-center gap-2",children:e})]})}function c({className:r,...e}){return s.jsx("div",{className:a("p-4",r),...e})}export{i as C,t as a,c as b}; import{j as s,a}from"./index-j5Xhw0AZ.js";function i({className:r,...e}){return s.jsx("div",{className:a("rounded-lg border border-line bg-surface shadow-card",r),...e})}function t({title:r,extra:e,className:n}){return s.jsxs("div",{className:a("flex min-h-11 flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-1.5",n),children:[s.jsx("h3",{className:"text-[13px] font-semibold text-ink-1",children:r}),e&&s.jsx("div",{className:"flex flex-wrap items-center gap-2",children:e})]})}function c({className:r,...e}){return s.jsx("div",{className:a("p-4",r),...e})}export{i as C,t as a,c as b};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{r}from"./index-BCAZXiEu.js";import{g as f}from"./echarts-BIWefrIc.js";function p(){const[n,o]=r.useState(0);return r.useEffect(()=>{const s=()=>o(t=>t+1);return window.addEventListener("fp-theme-change",s),()=>window.removeEventListener("fp-theme-change",s)},[]),n}function d(n){return getComputedStyle(document.documentElement).getPropertyValue("--text-"+n).trim()||"#454e5c"}function h(n,o){const s=r.useRef(null),[t,i]=r.useState(null),u=r.useRef(n);return u.current=n,r.useEffect(()=>{if(!t)return;const e=f(t);s.current=e,e.setOption(u.current(),{notMerge:!0});const c=new ResizeObserver(()=>e.resize());return c.observe(t),()=>{c.disconnect(),e.dispose(),s.current=null}},[t]),r.useEffect(()=>{var e;(e=s.current)==null||e.setOption(u.current(),{notMerge:!0})},[t,...o]),e=>i(e)}export{h as a,d as i,p as u}; import{r}from"./index-j5Xhw0AZ.js";import{g as f}from"./echarts-BIWefrIc.js";function p(){const[n,o]=r.useState(0);return r.useEffect(()=>{const s=()=>o(t=>t+1);return window.addEventListener("fp-theme-change",s),()=>window.removeEventListener("fp-theme-change",s)},[]),n}function d(n){return getComputedStyle(document.documentElement).getPropertyValue("--text-"+n).trim()||"#454e5c"}function h(n,o){const s=r.useRef(null),[t,i]=r.useState(null),u=r.useRef(n);return u.current=n,r.useEffect(()=>{if(!t)return;const e=f(t);s.current=e,e.setOption(u.current(),{notMerge:!0});const c=new ResizeObserver(()=>e.resize());return c.observe(t),()=>{c.disconnect(),e.dispose(),s.current=null}},[t]),r.useEffect(()=>{var e;(e=s.current)==null||e.setOption(u.current(),{notMerge:!0})},[t,...o]),e=>i(e)}export{h as a,d as i,p as u};
+1 -1
View File
@@ -9,7 +9,7 @@
<link rel="icon" href="/icons/pwa-192.png" /> <link rel="icon" href="/icons/pwa-192.png" />
<link rel="apple-touch-icon" href="/icons/pwa-192.png" /> <link rel="apple-touch-icon" href="/icons/pwa-192.png" />
<title>足迹 Footprints</title> <title>足迹 Footprints</title>
<script type="module" crossorigin src="/assets/index-BCAZXiEu.js"></script> <script type="module" crossorigin src="/assets/index-j5Xhw0AZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B4Ww6089.css"> <link rel="stylesheet" crossorigin href="/assets/index-B4Ww6089.css">
</head> </head>
<body> <body>