feat(resume): v3 flexibility - custom list sections, item reorder, info field visibility, header toggles, config io, migration persistence fix
This commit is contained in:
+41
-24
@@ -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
|
||||
? '<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>'
|
||||
: '';
|
||||
@@ -199,8 +216,8 @@ export function resumeToWordHtml(
|
||||
const head = '<table style="width:100%;border-collapse:collapse"><tr>' +
|
||||
'<td style="vertical-align:top">' +
|
||||
'<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>' : '') +
|
||||
(contactLine ? '<div style="color:#555;font-size:' + pt(11) + ';margin-top:2pt">' + contactLine + '</div>' : '') +
|
||||
((cfg.header?.showSchool ?? true) && schoolLine ? '<div style="color:#555;font-size:' + pt(11) + ';margin-top:3pt">' + schoolLine + '</div>' : '') +
|
||||
((cfg.header?.showContact ?? true) && contactLine ? '<div style="color:#555;font-size:' + pt(11) + ';margin-top:2pt">' + contactLine + '</div>' : '') +
|
||||
'</td>' + photo + '</tr></table>';
|
||||
|
||||
// 条目:标题(左) + 日期(右) 表格行 + 副标题 + 描述
|
||||
@@ -224,17 +241,17 @@ export function resumeToWordHtml(
|
||||
'<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>' : '');
|
||||
} 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 = '<table style="width:100%;border-collapse:collapse;font-size:' + pt(12) + '">' +
|
||||
rows.map((kv, i) =>
|
||||
infoRows.map((kv, i) =>
|
||||
(i % 2 === 0 ? '<tr>' : '') +
|
||||
'<td style="width:80px;padding:3px 6px;color:#555">' + esc(kv[0]) + '</td>' +
|
||||
'<td style="padding:3px 6px">' + esc(kv[1]) + '</td>' +
|
||||
'<td style="width:80px;padding:3px 6px;color:#555">' + esc(kv[1]) + '</td>' +
|
||||
'<td style="padding:3px 6px">' + esc(kv[2]) + '</td>' +
|
||||
(i % 2 === 1 ? '</tr>' : '')).join('') + '</table>';
|
||||
} else if (sec.kind === 'list') {
|
||||
inner = '<div>' + (sec.items ?? []).map(itemHtml).join('') + '</div>';
|
||||
|
||||
@@ -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<string, Partial<ResumeItemCfg>>;
|
||||
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[];
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
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 (
|
||||
<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">
|
||||
@@ -162,14 +204,18 @@ export function ResumePage() {
|
||||
<h1 className={'text-[26px] font-bold leading-tight ' + (cfg.template === 'tech' ? 'text-[var(--accent)]' : 'text-neutral-900')}>
|
||||
{profile.name || '姓名'}
|
||||
</h1>
|
||||
<div className="mt-1.5 text-[12.5px] text-neutral-500">
|
||||
{[profile.school, profile.college, profile.major, profile.degree].filter(Boolean).join(' · ') || '完善个人信息后自动展示'}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-neutral-500">
|
||||
{[profile.phone, profile.email, profile.homepage].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
{(cfg.header?.showSchool ?? true) && (
|
||||
<div className="mt-1.5 text-[12.5px] text-neutral-500">
|
||||
{[profile.school, profile.college, profile.major, profile.degree].filter(Boolean).join(' · ') || '完善个人信息后自动展示'}
|
||||
</div>
|
||||
)}
|
||||
{(cfg.header?.showContact ?? true) && (
|
||||
<div className="mt-1 text-[12px] text-neutral-500">
|
||||
{[profile.phone, profile.email, profile.homepage].filter(Boolean).join(' · ')}
|
||||
</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" />
|
||||
)}
|
||||
</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">
|
||||
{([['姓名', 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 => (
|
||||
<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>
|
||||
@@ -220,7 +266,7 @@ export function ResumePage() {
|
||||
) : (
|
||||
<div className={cfg.density === 'compact' ? 'flex flex-col gap-1.5' : 'flex flex-col gap-3'}>
|
||||
{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">
|
||||
<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 })} />
|
||||
@@ -282,6 +328,20 @@ export function ResumePage() {
|
||||
options={[{ value: '0.9', label: '小' }, { value: '1', label: '标准' }, { value: '1.1', label: '大' }]} />
|
||||
</Field>
|
||||
</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">
|
||||
<Field label="间距">
|
||||
<Select value={cfg.density} onChange={e => persist({ ...cfg, density: e.target.value as ResumeConfig['density'] })}
|
||||
@@ -294,6 +354,13 @@ export function ResumePage() {
|
||||
</button>
|
||||
</Field>
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
@@ -347,6 +414,23 @@ export function ResumePage() {
|
||||
{s.kind === 'text' ? (
|
||||
<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) })} />
|
||||
) : 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' ? (
|
||||
<ListSectionEditor
|
||||
cfg={cfg} section={s} records={records} persist={persist}
|
||||
@@ -361,17 +445,12 @@ export function ResumePage() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="p-3">
|
||||
<Button size="sm" variant="secondary" className="w-full"
|
||||
onClick={() => {
|
||||
const key = 'custom_' + Date.now();
|
||||
persist({
|
||||
...cfg,
|
||||
sections: [...cfg.sections, { key, title: '自定义板块', enabled: true, kind: 'text', text: '' }],
|
||||
});
|
||||
setOpenEditors(prev => new Set(prev).add(key));
|
||||
}}>
|
||||
<Plus size={13} /> 新增文本板块
|
||||
<div className="grid grid-cols-2 gap-2 p-3">
|
||||
<Button size="sm" variant="secondary" onClick={() => addSection('text')}>
|
||||
<Plus size={13} /> 文本板块
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => addSection('list')}>
|
||||
<Plus size={13} /> 列表板块
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -408,16 +487,54 @@ function ListSectionEditor({ cfg, section, records, persist, sectionTypes }: {
|
||||
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 (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<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)} />
|
||||
<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>
|
||||
))}
|
||||
<div className="text-[11px] text-ink-3">条目排序(↑↓)· 记录勾选显隐 · 自定义条目可增删</div>
|
||||
{allKeys.map(k => {
|
||||
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)} />
|
||||
<span className="min-w-0 flex-1 truncate text-ink-1">{r.title}</span>
|
||||
</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="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()}
|
||||
onClick={() => {
|
||||
if (!adding.title.trim()) return;
|
||||
setSection({ items: [...customs, adding] });
|
||||
setSection({ items: [...customs, { ...adding, id: 'c' + Date.now() }] });
|
||||
setAdding(null);
|
||||
}}>添加</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setAdding(null)}>取消</Button>
|
||||
|
||||
Reference in New Issue
Block a user