fix(resume): remove toast line from ListSectionEditor type

This commit is contained in:
root
2026-10-17 12:31:33 +08:00
parent 013c9fb929
commit f95efd481f
20 changed files with 586 additions and 242 deletions
+174 -65
View File
@@ -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<ProfileInfo> | 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
export function resumeToWordHtml(
sections: AssembledSection[],
profile: Partial<ProfileInfo> | undefined,
profile: Partial<ProfileInfo>,
cfg: ResumeConfig,
photoUrl?: string
): string {
const esc = (t: string) => t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const P = profile ?? {};
const scale = cfg.fontScale;
const pt = (base: number) => (base * scale).toFixed(1) + 'pt';
const accent = cfg.accent;
const titleStyle: Record<ResumeTemplate, string> = {
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 = '<table style="width:100%;border-collapse:collapse;font-size:12pt">' +
inner = '<table style="width:100%;border-collapse:collapse;font-size:' + pt(12) + '">' +
rows.map(([k, v], i) =>
(i % 2 === 0 ? '<tr>' : '') +
'<td style="width:80px;padding:3px 6px;color:#555">' + esc(String(k)) + '</td>' +
'<td style="padding:3px 6px">' + esc(String(v)) + '</td>' +
'<td style="width:80px;padding:3px 6px;color:#555">' + esc(k) + '</td>' +
'<td style="padding:3px 6px">' + esc(v) + '</td>' +
(i % 2 === 1 ? '</tr>' : '')
).join('') + '</table>';
} else if (sec.items) {
inner = '<div style="margin-bottom:6pt">' + 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 = '<div style="margin-bottom:6pt">' + (sec.items ?? []).map(it => {
const sub = it.subtitle ? '<div style="color:#666;font-size:' + pt(10.5) + '">' + esc(it.subtitle) + '</div>' : '';
const desc = it.desc ? '<div style="color:#444;font-size:' + pt(11) + ';line-height:1.5">' + esc(it.desc) + '</div>' : '';
return '<div style="margin-bottom:7pt">' +
'<div style="display:flex;justify-content:space-between"><b style="font-size:12pt">' + esc(r.title) + '</b>' +
'<span style="color:#666;font-size:10.5pt">' + esc(date) + '</span></div>' +
(sub ? '<div style="color:#666;font-size:10.5pt">' + sub + '</div>' : '') +
(r.result ? '<div style="color:#177245;font-size:11pt">' + esc(r.result) + '</div>' : '') +
'</div>';
'<div style="display:flex;justify-content:space-between"><b style="font-size:' + pt(12) + '">' + esc(it.title) + '</b>' +
'<span style="color:#666;font-size:' + pt(10.5) + '">' + esc(it.date ?? '') + '</span></div>' +
sub + desc + '</div>';
}).join('') + '</div>';
} else if (sec.text) {
inner = '<p style="font-size:12pt;line-height:1.7;margin:0">' + esc(sec.text) + '</p>';
} else {
inner = '<p style="font-size:' + pt(12) + ';line-height:1.7;margin:0">' + esc(sec.text ?? '') + '</p>';
}
return '<h2 style="font-size:14pt;border-bottom:1.5pt solid #333;padding-bottom:3pt;margin:14pt 0 7pt">' + esc(sec.title) + '</h2>' + inner;
return '<h2 style="' + titleStyle[cfg.template] + '">' + esc(sec.title) + '</h2>' + inner;
}).join('');
const photo = photoUrl && P.photoUrl
? '<div style="text-align:right"><img src="' + esc(P.photoUrl) + '" alt="证件照" style="width:90pt;height:120pt;object-fit:cover" /></div>'
const photo = photoUrl && profile.photoUrl
? '<div style="text-align:right"><img src="' + esc(profile.photoUrl) + '" alt="证件照" style="width:' + (90 * scale).toFixed(0) + 'pt;height:' + (120 * scale).toFixed(0) + 'pt;object-fit:cover" /></div>'
: '';
const headSub = [P0(profile).school, P0(profile).college, P0(profile).major, P0(profile).degree].filter(v => v !== '').map(v => esc(v)).join(' · ');
return '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word">' +
'<head><meta charset="utf-8"><title>个人简历</title></head>' +
'<body style="font-family:SimSun,Calibri,sans-serif;margin:36pt">' +
'<head><meta charset="utf-8"><title>' + esc(P0(profile).name || '个人简历') + '</title></head>' +
'<body style="font-family:SimSun,Calibri,sans-serif;margin:36pt;color:#222">' +
'<div style="display:flex;justify-content:space-between;align-items:flex-start">' +
'<h1 style="font-size:20pt;margin:0 0 4pt">' + esc(P.name || '个人简历') + '</h1>' + photo + '</div>' +
(P.major || P.school ? '<div style="color:#555;font-size:11pt;margin-bottom:8pt">' +
[P.school, P.college, P.major, P.degree].filter(v => !!v).map(v => esc(String(v))).join(' · ') + '</div>' : '') +
'<h1 style="font-size:' + pt(20) + ';margin:0 0 4pt;color:' + (cfg.template === 'tech' ? accent : '#111') + '">' + esc(P0(profile).name || '个人简历') + '</h1>' + photo + '</div>' +
(headSub ? '<div style="color:#555;font-size:' + pt(11) + ';margin-bottom:8pt">' + headSub + '</div>' : '') +
secHtml +
'</body></html>';
}
// 避免循环引用的小工具
function P0(p: Partial<ProfileInfo> | undefined): Partial<ProfileInfo> {
return p ?? {};
}
+20 -2
View File
@@ -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[];
}
+331 -142
View File
@@ -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<ProfileInfo>;
const [config, setConfig] = useState<ResumeConfig>(() => stored?.resume ?? defaultResumeConfig());
const [selfIntro, setSelfIntro] = useState(profile.selfIntro ?? '');
const introDirty = selfIntro !== (profile.selfIntro ?? '');
const previewProfile: Partial<ProfileInfo> = { ...profile, selfIntro: selfIntroDraftFix(selfIntro) };
const [config, setConfig] = useState<ResumeConfig | null>(null);
const [selfIntro, setSelfIntro] = useState('');
const [panelOpen, setPanelOpen] = useState(true);
const [openEditors, setOpenEditors] = useState<Set<string>>(new Set());
const [fitZoom, setFitZoom] = useState(1);
const contentRef = useRef<HTMLDivElement>(null);
const dragKey = useRef<string | null>(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<ProfileInfo> = { ...(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 (
<div className="mx-auto max-w-[1080px] 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">
<h2 className="text-[15px] font-semibold text-ink-1">我的简历</h2>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => setPanelOpen(v => !v)}>
{panelOpen ? <PanelRightClose size={13} /> : <PanelRightOpen size={13} />}
{panelOpen ? '收起设置' : '展开设置'}
</Button>
<Button variant="secondary" onClick={exportWord}><Download size={13} /> 导出 Word</Button>
<Button variant="primary" onClick={() => window.print()}><Printer size={13} /> 打印 / 导出 PDF</Button>
<Button variant="primary" onClick={() => window.print()}><Printer size={13} /> 打印 / PDF</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[300px_1fr]">
{/* 布局面板 */}
<Card className="no-print">
<CardHeader title="板块布局" />
<CardBody className="p-0">
<div className="flex flex-col divide-y divide-line">
{config.sections.map((s, i) => {
const hasItems = sections.find(x => x.key === s.key);
return (
<div key={s.key} className="p-3">
<div className="flex items-center gap-1.5">
<input type="checkbox" checked={s.enabled} onChange={() => toggle(i)} aria-label="启用板块" className="size-3.5" />
<input
className="min-w-0 flex-1 rounded-sm border border-transparent bg-transparent px-1 py-0.5 text-[12.5px] font-medium text-ink-1 transition-colors hover:border-line focus:border-brand focus:outline-none"
value={s.title}
onChange={e => retitle(i, e.target.value)}
/>
<button className="rounded p-1 text-ink-3 transition-colors hover:bg-surface-3 hover:text-ink-1 disabled:opacity-30"
aria-label="上移" disabled={i === 0} onClick={() => move(i, -1)}><ArrowUp size={13} /></button>
<button className="rounded p-1 text-ink-3 transition-colors hover:bg-surface-3 hover:text-ink-1 disabled:opacity-30"
aria-label="下移" disabled={i === config.sections.length - 1} onClick={() => move(i, 1)}><ArrowDown size={13} /></button>
</div>
<div className="mt-0.5 pl-5 text-[11px] text-ink-3">
{RESUME_SECTION_DEFS.find(d => d.key === s.key)?.types?.join(' / ') ?? '固定板块'}
{s.enabled && !hasItems && <span className="text-warning"> · 暂无内容</span>}
</div>
</div>
);
})}
</div>
</CardBody>
<div className="border-t border-line p-3">
<Field label="自我评价">
<Textarea rows={4} value={selfIntro} onChange={e => setSelfIntro(e.target.value)} placeholder="简述性格、能力、研究方向…" />
</Field>
<Button size="sm" variant="primary" className="mt-2 w-full"
disabled={!introDirty} onClick={() => {
void (async () => {
try {
await profileUpsert({ info: { ...profile, selfIntro }, resume: config });
await profileQ.refetch();
toast('success', '自我评价已保存');
} catch (e) { toast('error', (e as Error).message); }
})();
}}>保存自我评价</Button>
<p className="mt-1 text-[11px] text-ink-3">板块顺序与标题自动保存 · 内容来自「个人信息」与各类型记录</p>
</div>
</Card>
{/* 简历预览(A4 比例白卡) */}
<div className={'grid gap-4 ' + (panelOpen ? 'lg:grid-cols-[1fr_340px]' : 'grid-cols-1')}>
{/* 简历预览(A4 白卡,打印区) */}
<div>
<div className="print-sheet mx-auto w-full max-w-[794px] rounded-lg border border-line bg-white p-8 text-[13px] leading-relaxed text-neutral-900 shadow-card sm:p-10">
<ResumePreviewBody sections={sections} profile={previewProfile} />
<div
className="print-sheet mx-auto w-full max-w-[794px] rounded-lg border border-line bg-white shadow-card"
style={{ minHeight: cfg.onePage ? 1123 : undefined }}
>
<div
ref={contentRef}
className={'p-8 sm:p-10 resume-tpl-' + cfg.template}
style={{ zoom, '--accent': cfg.accent } as React.CSSProperties}
>
{/* 头部 */}
<div className="mb-6 flex items-start justify-between gap-6">
<div>
<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>
</div>
{profile.photoUrl && (
<img src={profile.photoUrl} alt="证件照" className="h-[110px] w-[82px] rounded border border-neutral-200 object-cover" />
)}
</div>
{/* 板块 */}
{sections.map(sec => (
<div key={sec.key} className="mb-5 r-sec">
<div className="r-sec-title mb-2.5 text-[14px] font-bold text-neutral-900">{sec.title}</div>
{sec.kind === 'info' ? (
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-[12.5px]">
{([['姓名', profile.name], ['性别', profile.gender], ['出生年月', profile.birth], ['民族', profile.nation],
['籍贯', profile.native], ['政治面貌', profile.political], ['班级', profile.className], ['学号', profile.studentId],
['手机', profile.phone], ['邮箱', profile.email]] as [string, string][]).filter(r => r[1]).map(([k, v]) => (
<div key={k} className="flex gap-2"><span className="w-16 shrink-0 text-neutral-500">{k}</span><span>{v}</span></div>
))}
</div>
) : sec.kind === 'text' ? (
<p className={'whitespace-pre-wrap text-neutral-700 ' + (cfg.density === 'compact' ? '' : 'leading-relaxed')}>
{sec.text || '(在右侧板块设置中填写内容)'}
</p>
) : (
<div className={cfg.density === 'compact' ? 'flex flex-col gap-1.5' : 'flex flex-col gap-3'}>
{sec.items?.map(it => (
<div key={it.title + it.date}>
<div className="flex items-baseline justify-between gap-3">
<span className="text-[13px] font-semibold text-neutral-900 r-item-title">{it.title}</span>
<span className="num shrink-0 text-[11.5px] text-neutral-500">{it.date ?? ''}</span>
</div>
{it.subtitle && <div className="text-[12px] text-neutral-600">{it.subtitle}</div>}
{it.desc && <p className={'text-[12px] text-neutral-500 ' + (cfg.density === 'compact' ? '' : 'mt-0.5')}>{it.desc}</p>}
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
{cfg.onePage && (
<div className="no-print mt-2 text-center text-[11.5px] text-ink-3">
一页限定已启用:内容自动缩放 {Math.round(zoom * 100)}% 以适配单页 A4
</div>
)}
</div>
{/* 右侧设置面板(手机在下方) */}
{panelOpen && (
<div className="no-print flex flex-col gap-4 lg:sticky lg:top-4 lg:self-start">
{/* 模板 */}
<Card>
<CardHeader title="模板与风格" />
<CardBody className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-2">
{TEMPLATES.map(t => (
<button key={t.key}
className={'rounded-md border px-3 py-2 text-left transition-all ' + (cfg.template === t.key ? 'border-brand bg-brand-subtle' : 'border-line bg-surface hover:border-line-strong')}
onClick={() => persist({ ...cfg, template: t.key as ResumeTemplate })}>
<div className={'text-[12.5px] font-semibold ' + (cfg.template === t.key ? 'text-brand-text' : 'text-ink-1')}>{t.name}</div>
<div className="text-[11px] text-ink-3">{t.desc}</div>
</button>
))}
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="主题色">
<div className="flex flex-wrap gap-1.5 pt-1">
{ACCENT_CHOICES.map(c => (
<button key={c} aria-label={'主题色 ' + c}
className={'size-5 rounded-full border-2 transition-transform ' + (cfg.accent === c ? 'scale-110 border-ink-1' : 'border-transparent')}
style={{ backgroundColor: c }}
onClick={() => persist({ ...cfg, accent: c })} />
))}
</div>
</Field>
<Field label="字号">
<Select value={String(cfg.fontScale)} onChange={e => persist({ ...cfg, fontScale: Number(e.target.value) })}
options={[{ value: '0.9', label: '小' }, { value: '1', label: '标准' }, { value: '1.1', label: '大' }]} />
</Field>
</div>
<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'] })}
options={[{ value: 'compact', label: '紧凑' }, { value: 'normal', label: '标准' }, { value: 'relaxed', label: '宽松' }]} />
</Field>
<Field label="单页限定">
<button className={'flex h-9 w-full items-center justify-center gap-1.5 rounded-md border text-[12px] transition-colors ' + (cfg.onePage ? 'border-brand bg-brand-subtle text-brand-text' : 'border-line bg-surface text-ink-2')}
onClick={() => persist({ ...cfg, onePage: !cfg.onePage })}>
{cfg.onePage ? '已开启' : '已关闭'}
</button>
</Field>
</div>
</CardBody>
</Card>
{/* 板块布局:拖拽排序 + 启停 + 内容编辑 */}
<Card>
<CardHeader title="板块布局" extra={<span className="text-[11px] text-ink-3">拖动排序 · 内容可编辑</span>} />
<CardBody className="p-0">
<div className="flex flex-col divide-y divide-line">
{cfg.sections.map((s, i) => {
const open = openEditors.has(s.key);
const kindLabel = s.kind === 'info' ? '固定' : s.kind === 'text' ? '文本' : '记录';
return (
<div key={s.key}
draggable
onDragStart={() => { dragKey.current = s.key; }}
onDragOver={e => e.preventDefault()}
onDrop={() => {
const from = cfg.sections.findIndex(x => x.key === dragKey.current);
if (from >= 0 && from !== i) {
const arr = [...cfg.sections];
const [moved] = arr.splice(from, 1);
arr.splice(i, 0, moved);
persist({ ...cfg, sections: arr });
}
dragKey.current = null;
}}
className={'bg-surface ' + (dragKey.current === s.key ? 'opacity-40' : '')}
>
<div className="flex items-center gap-1 px-3 py-2">
<GripVertical size={13} className="shrink-0 cursor-grab text-ink-3" />
<input
className="min-w-0 flex-1 rounded-sm border border-transparent bg-transparent px-1 py-0.5 text-[12.5px] font-medium text-ink-1 hover:border-line focus:border-brand focus:outline-none"
value={s.title}
onChange={e => persist({ ...cfg, sections: cfg.sections.map(x => x.key === s.key ? { ...x, title: e.target.value } : x) })}
/>
<button className="rounded p-1 text-ink-3 hover:text-ink-1 disabled:opacity-30" aria-label="上移"
disabled={i === 0} onClick={() => move(i, -1)}><ArrowUp size={12} /></button>
<button className="rounded p-1 text-ink-3 hover:text-ink-1 disabled:opacity-30" aria-label="下移"
disabled={i === cfg.sections.length - 1} onClick={() => move(i, 1)}><ArrowDown size={12} /></button>
<button
className={'ml-0.5 rounded-sm border px-1.5 py-0.5 text-[11px] transition-colors ' + (s.enabled ? 'border-brand/40 bg-brand-subtle text-brand-text' : 'border-line bg-surface-2 text-ink-3')}
onClick={() => persist({ ...cfg, sections: cfg.sections.map(x => x.key === s.key ? { ...x, enabled: !x.enabled } : x) })}>
{s.enabled ? '显示' : '隐藏'}
</button>
<button className="rounded p-1 text-ink-3 hover:text-ink-1" aria-label="展开编辑"
onClick={() => setOpenEditors(prev => { const n = new Set(prev); n.has(s.key) ? n.delete(s.key) : n.add(s.key); return n; })}>
<ChevronRight size={13} className={'transition-transform ' + (open ? 'rotate-90' : '')} />
</button>
</div>
{open && (
<div className="border-t border-line bg-surface-2 px-3 py-2.5">
{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 === 'list' ? (
<ListSectionEditor
cfg={cfg} section={s} records={records} persist={persist}
sectionTitle={sectionTitles(s.key)}
/>
) : (
<p className="text-[11.5px] text-ink-3">基本信息板块来自「个人信息」页,无法在此编辑。</p>
)}
</div>
)}
<div className="px-3 pb-1.5 text-[10.5px] text-ink-3">{kindLabel}{s.types?.length ? ' · ' + s.types.join('/') : ''}</div>
</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} /> 新增文本板块
</Button>
</div>
</div>
</CardBody>
</Card>
</div>
)}
</div>
</div>
);
}
function ResumePreviewBody({ sections, profile: P }: {
sections: ReturnType<typeof assembleSections>;
profile: Partial<ProfileInfo>;
// ---------- 列表板块编辑器:记录条目显隐 + 自定义条目 ----------
function ListSectionEditor({ cfg, section, records, persist, sectionTitle }: {
cfg: ResumeConfig;
section: ResumeSectionCfg;
records: FootprintRow[];
persist: (c: ResumeConfig) => void;
sectionTitle: string;
}) {
const [adding, setAdding] = useState<ResumeItemCfg | null>(null);
const excluded = new Set(section.excluded ?? []);
const derived = records
.filter(r => (section.types ?? []).includes(r.type_name) && r.status !== 'draft')
.sort((a, b) => (b.start_date ?? b.created_at).localeCompare(a.start_date ?? a.created_at));
const customs = section.items ?? [];
const setSection = (patch: Partial<ResumeSectionCfg>) =>
persist({ ...cfg, sections: cfg.sections.map(x => x.key === section.key ? { ...x, ...patch } : x) });
const toggleRecord = (id: number) => {
const next = new Set(section.excluded ?? []);
next.has(id) ? next.delete(id) : next.add(id);
setSection({ excluded: [...next] });
};
return (
<>
<div className="mb-6 flex items-start justify-between gap-6">
<div>
<h1 className="text-[26px] font-bold leading-tight tracking-tight text-neutral-900">{P.name || '姓名'}</h1>
<div className="mt-1.5 text-[12.5px] text-neutral-500">
{[P.school, P.college, P.major, P.degree].filter(Boolean).join(' · ')}
</div>
<div className="mt-1 text-[12px] text-neutral-500">
{[P.phone, P.email, P.homepage].filter(Boolean).join(' · ')}
</div>
<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="mt-1 border-t border-line pt-2">
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11.5px] font-medium text-ink-2">自定义条目({customs.length})</span>
<Button size="sm" variant="ghost" onClick={() => setAdding({ title: '' })}>
<Plus size={12} /> 添加
</Button>
</div>
{P.photoUrl && <img src={P.photoUrl} alt="证件照" className="h-[120px] w-[90px] rounded border border-neutral-200 object-cover" />}
{adding && (
<div className="mb-2 rounded-md border border-line bg-surface p-2">
<Input className="mb-1.5" placeholder="标题" value={adding.title} onChange={e => setAdding({ ...adding, title: e.target.value })} />
<div className="mb-1.5 grid grid-cols-2 gap-1.5">
<Input placeholder="副标题(单位/角色)" value={adding.subtitle ?? ''} onChange={e => setAdding({ ...adding, subtitle: e.target.value })} />
<Input placeholder="日期(2025-06)" value={adding.date ?? ''} onChange={e => setAdding({ ...adding, date: e.target.value })} />
</div>
<Textarea rows={2} placeholder="描述" value={adding.desc ?? ''} onChange={e => setAdding({ ...adding, desc: e.target.value })} />
<div className="mt-1.5 flex gap-1.5">
<Button size="sm" variant="primary" disabled={!adding.title.trim()}
onClick={() => {
if (!adding.title.trim()) return;
setSection({ items: [...customs, adding] });
setAdding(null);
}}>添加</Button>
<Button size="sm" variant="ghost" onClick={() => setAdding(null)}>取消</Button>
</div>
</div>
)}
{customs.map((c, idx) => (
<div key={idx} className="flex items-center gap-2 rounded-md border border-line bg-surface p-1.5 text-[12px]">
<span className="min-w-0 flex-1 truncate text-ink-1">{c.title}<span className="text-ink-3"> · {c.date ?? ''}</span></span>
<button className="text-ink-3 hover:text-danger" aria-label="删除条目"
onClick={() => setSection({ items: customs.filter((_, x) => x !== idx) })}>
<Trash2 size={12} />
</button>
</div>
))}
{!customs.length && !adding && (
<p className="text-[11.5px] text-ink-3">暂无自定义条目</p>
)}
</div>
{sections.map(sec => (
<div key={sec.key} className="mb-5">
<div className="mb-2.5 border-b-2 border-neutral-800 pb-1 text-[14px] font-bold tracking-wide text-neutral-900">{sec.title}</div>
{sec.key === 'info' ? (
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-[12.5px]">
{([
['姓名', P.name], ['性别', P.gender], ['出生年月', P.birth], ['民族', P.nation],
['籍贯', P.native], ['政治面貌', P.political], ['班级', P.className], ['学号', P.studentId],
['手机', P.phone], ['邮箱', P.email],
] as [string, string][]).filter(r => r[1]).map(([k, v]) => (
<div key={k} className="flex gap-2"><span className="w-16 shrink-0 text-neutral-500">{k}</span><span>{v}</span></div>
))}
</div>
) : sec.text !== undefined ? (
<p className="whitespace-pre-wrap text-[12.5px] leading-relaxed">{sec.text || '(在「个人信息」中填写自我评价)'}</p>
) : (
<div className="flex flex-col gap-2.5">
{sec.items?.map(it => {
const r = it.row;
const date = (r.start_date ?? r.created_at).slice(0, 10);
return (
<div key={r.id}>
<div className="flex items-baseline justify-between gap-3">
<span className="text-[13px] font-semibold text-neutral-900">{r.title}</span>
<span className="num shrink-0 text-[11.5px] text-neutral-500">{date}{r.end_date ? ' ~ ' + r.end_date.slice(5, 10) : ''}</span>
</div>
<div className="text-[12px] text-neutral-600">
{[r.organizer, r.level, r.award_grade].filter(Boolean).join(' · ')}
{r.result && <span className="text-emerald-700"> — {r.result}</span>}
</div>
{r.description && <p className="mt-0.5 line-clamp-2 text-[12px] text-neutral-500">{r.description}</p>}
</div>
);
})}
</div>
)}
</div>
))}
</>
<p className="text-[11px] text-ink-3">「{sectionTitle}」板块的记录条目来自你的记录数据,此处可隐藏不需要展示的条目。</p>
</div>
);
}
+8
View File
@@ -164,3 +164,11 @@ input[type='number'] { -moz-appearance: textfield; appearance: textfield; }
.print-scroll { overflow: visible !important; height: auto !important; }
.print-sheet { border: none !important; box-shadow: none !important; margin: 0 !important; max-width: none !important; width: 100% !important; padding: 0 !important; }
}
/* ---------- 简历模板 ---------- */
.resume-tpl-tech .r-sec-title { color: var(--accent); border-left: 3px solid var(--accent); padding-left: 8px; }
.resume-tpl-tech .r-item-title { color: var(--accent); }
.resume-tpl-tech .num { color: var(--accent); }
.resume-tpl-minimal .r-sec-title { color: #666; font-weight: 600; letter-spacing: .12em; text-transform: uppercase; font-size: 11px; border-bottom: 1px solid #e2e2e2; padding-bottom: 4px; }
.resume-tpl-classic .r-sec-title { border-bottom: 2px solid #222; padding-bottom: 4px; }
.resume-tpl-minimal .r-item-title { font-weight: 600; }
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-DkrEeFZh.js";import{C as c,a as d,b as x}from"./card-DdglV9lU.js";import{u as N,i as u,a as m}from"./useChart-DNtmZa2Q.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-CSVqyJB0.js";import{C as c,a as d,b as x}from"./card-BcMbz03g.js";import{u as N,i as u,a as m}from"./useChart-CUy-j464.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 R,f as I,j as e,E as O,w as $,u as N,d as f,r as u,g as v,x as H,y as W,B as b,P as q,C as L,z as J,A as T,D as U,m as k,l as F,T as z,I as g,F as Y,H as S,J as E,K as V}from"./index-DkrEeFZh.js";import{C,a as M,b as _}from"./card-DdglV9lU.js";import{u as X,i as Q,a as K}from"./useChart-DNtmZa2Q.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 R,f as I,j as e,E as O,w as $,u as N,d as f,r as u,g as v,x as H,y as W,B as b,P as q,C as L,z as J,A as T,D as U,m as k,l as F,T as z,I as g,F as Y,H as S,J as E,K as V}from"./index-CSVqyJB0.js";import{C,a as M,b as _}from"./card-BcMbz03g.js";import{u as X,i as Q,a as K}from"./useChart-CUy-j464.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
*
* This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as U,u as C,d as E,r as p,f as F,g as S,j as e,B as f,l as q,a9 as B,F as z,H as k,S as M,I as Q,aa as _,ab as K,ac as v,ad as L}from"./index-DkrEeFZh.js";/**
import{c as U,u as C,d as E,r as p,f as F,g as S,j as e,B as f,l as q,a9 as B,F as z,H as k,S as M,I as Q,aa as _,ab as K,ac as v,ad as L}from"./index-CSVqyJB0.js";/**
* @license lucide-react v0.475.0 - ISC
*
* 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-DkrEeFZh.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-CSVqyJB0.js";/**
* @license lucide-react v0.475.0 - ISC
*
* 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-DkrEeFZh.js";import{C as Y}from"./card-DdglV9lU.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-CSVqyJB0.js";import{C as Y}from"./card-BcMbz03g.js";/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
@@ -1 +1 @@
import{j as s,a}from"./index-DkrEeFZh.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-CSVqyJB0.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
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-DkrEeFZh.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-CSVqyJB0.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};
+2 -2
View File
@@ -9,8 +9,8 @@
<link rel="icon" href="/icons/pwa-192.png" />
<link rel="apple-touch-icon" href="/icons/pwa-192.png" />
<title>足迹 Footprints</title>
<script type="module" crossorigin src="/assets/index-DkrEeFZh.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BheMcaGY.css">
<script type="module" crossorigin src="/assets/index-CSVqyJB0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CIs6Yi-t.css">
</head>
<body>
<div id="root"></div>