feat(resume): education section from profile + click-to-edit inline editing + overrides
This commit is contained in:
+15
-2
@@ -90,7 +90,7 @@ export interface AssembledItem {
|
||||
}
|
||||
|
||||
export interface AssembledSection {
|
||||
key: string; title: string; kind: 'info' | 'list' | 'text';
|
||||
key: string; title: string; kind: 'info' | 'edu' | 'list' | 'text';
|
||||
items?: AssembledItem[]; text?: string;
|
||||
}
|
||||
|
||||
@@ -137,7 +137,8 @@ export function assembleSections(
|
||||
// 其他桶:未归类的类型
|
||||
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));
|
||||
const items: AssembledItem[] = derived.map(recordToItem).filter(it => !it.fromRecord || !excluded.has(it.fromRecord))
|
||||
.map(it => ({ ...it, ...(s.overrides?.[String(it.fromRecord)] ?? {}) }));
|
||||
for (const c of s.items ?? []) items.push({ ...c });
|
||||
|
||||
items.sort((a, b) => (b.date ?? '').localeCompare(a.date ?? ''));
|
||||
@@ -193,6 +194,18 @@ export function resumeToWordHtml(
|
||||
'<td style="padding:3px 6px">' + esc(v) + '</td>' +
|
||||
(i % 2 === 1 ? '</tr>' : '')
|
||||
).join('') + '</table>';
|
||||
} else if (sec.kind === 'edu') {
|
||||
const rows: [string, string][] = ([
|
||||
['学校', profile.school ?? ''], ['专业', profile.major ?? ''], ['学制', profile.eduLength ?? ''],
|
||||
['学历', profile.degree ?? ''], ['入学', profile.eduStart ?? ''], ['毕业', profile.eduEnd ?? ''],
|
||||
] as [string, string][]).filter(r => r[1] !== '');
|
||||
inner = '<table style="width:100%;border-collapse:collapse;font-size:' + pt(12) + '">' +
|
||||
rows.map(function (kv) {
|
||||
return '<tr><td style="width:80px;padding:3px 6px;color:#555">' + esc(kv[0]) + '</td>' +
|
||||
'<td style="padding:3px 6px">' + esc(kv[1]) + '</td></tr>';
|
||||
}).join('') + '</table>' +
|
||||
(profile.majorCourses ? '<div style="margin-top:6pt"><b style="font-size:' + pt(11) + '">主修课程:</b>' +
|
||||
'<span style="font-size:' + pt(11.5) + '">' + esc(profile.majorCourses) + '</span></div>' : '');
|
||||
} 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>' : '';
|
||||
|
||||
@@ -248,6 +248,7 @@ export interface ResumeSectionCfg {
|
||||
excluded?: number[];
|
||||
items?: ResumeItemCfg[];
|
||||
text?: string;
|
||||
overrides?: Record<string, Partial<ResumeItemCfg>>;
|
||||
}
|
||||
|
||||
export interface ResumeConfig {
|
||||
|
||||
@@ -9,6 +9,24 @@ import {
|
||||
} from '@/lib/resume';
|
||||
import { Button, Card, CardBody, CardHeader, Field, Input, Select, Textarea, useToast } from '@/components/ui';
|
||||
|
||||
// 预览内联编辑组件:点击即改,失焦保存
|
||||
function Ed({ html, onCommit, className }: { html: string; onCommit: (v: string) => void; className?: string }) {
|
||||
const esc = html.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
return (
|
||||
<span
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
spellCheck={false}
|
||||
className={'outline-none rounded-sm transition-shadow focus:shadow-[0_0_0_1.5px_rgba(48,86,211,0.5)] cursor-text ' + (className ?? '')}
|
||||
dangerouslySetInnerHTML={{ __html: esc }}
|
||||
onBlur={e => {
|
||||
const v = (e.currentTarget.textContent ?? '').trim();
|
||||
if (v !== html) onCommit(v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResumePage() {
|
||||
const toast = useToast();
|
||||
const profileQ = useQuery({ queryKey: ['profile'], queryFn: profileGet });
|
||||
@@ -22,7 +40,6 @@ export function ResumePage() {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const dragKey = useRef<string | null>(null);
|
||||
|
||||
// 初始化(迁移 v1 配置)
|
||||
useEffect(() => {
|
||||
const stored = profileQ.data?.content;
|
||||
if (!stored) return;
|
||||
@@ -33,28 +50,52 @@ export function ResumePage() {
|
||||
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 previewProfile: Partial<ProfileInfo> = { ...profile, selfIntro };
|
||||
const sections = useMemo(() => assembleSections(records, cfg), [records, cfg]);
|
||||
|
||||
const move = (i: number, dir: -1 | 1) => setConfig(pc => {
|
||||
if (!pc) return pc;
|
||||
const c = pc;
|
||||
const arr = [...c.sections];
|
||||
const arr = [...pc.sections];
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= arr.length) return c;
|
||||
if (j < 0 || j >= arr.length) return pc;
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
return { ...c, sections: arr };
|
||||
return { ...pc, sections: arr };
|
||||
});
|
||||
const persist = (next: ResumeConfig) => {
|
||||
setConfig(next);
|
||||
void profileUpsert({ info: { ...profile, selfIntro }, resume: next }).catch(e => toast('error', e.message));
|
||||
};
|
||||
|
||||
// 一页限定:内容超高时自动缩放
|
||||
// 点击即改:统一入口
|
||||
const handleEdit = (p: { secKey: string; field: string; value: string; itemIdx?: number; fromRecord?: number }) => {
|
||||
setConfig(pc => {
|
||||
const base = pc ?? cfg;
|
||||
const sections2 = base.sections.map(x => {
|
||||
if (x.key !== p.secKey) return x;
|
||||
if (p.field === 'text') return { ...x, text: p.value };
|
||||
if (p.fromRecord != null) {
|
||||
const o = { ...(x.overrides?.[p.fromRecord] ?? {}), [p.field]: p.value };
|
||||
return { ...x, overrides: { ...(x.overrides ?? {}), [p.fromRecord]: o } };
|
||||
}
|
||||
if (p.itemIdx != null && x.items) {
|
||||
const items = [...x.items];
|
||||
items[p.itemIdx] = { ...items[p.itemIdx], [p.field]: p.value };
|
||||
return { ...x, items };
|
||||
}
|
||||
return x;
|
||||
});
|
||||
const next = { ...base, sections: sections2 };
|
||||
void profileUpsert({ info: { ...profile, selfIntro }, resume: next }).catch(e => toast('error', e.message));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 一页限定
|
||||
useEffect(() => {
|
||||
if (!cfg.onePage) { setFitZoom(1); return; }
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
const available = 1043; // A4 高 1123 - 上下 padding 80
|
||||
const available = 1043;
|
||||
const h = el.scrollHeight;
|
||||
setFitZoom(h > available ? Math.max(0.7, available / h) : 1);
|
||||
}, [cfg.onePage, cfg.fontScale, cfg.density, sections]);
|
||||
@@ -62,20 +103,20 @@ export function ResumePage() {
|
||||
const zoom = cfg.fontScale * (cfg.onePage ? fitZoom : 1);
|
||||
|
||||
const exportWord = () => {
|
||||
void import('@/lib/resume').then(({ resumeToWordHtml }) => {
|
||||
const html = resumeToWordHtml(sections, { ...profile, selfIntro }, cfg, profile.photoUrl);
|
||||
void import('@/lib/resume').then(function (m) {
|
||||
const html = m.resumeToWordHtml(sections, previewProfile, cfg, profile.photoUrl);
|
||||
const blob = new Blob(['\ufeff' + html], { type: 'application/msword' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = (profile.name || '个人简历') + '.doc';
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
toast('success', 'Word 文档已导出(按当前模板与字号)');
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
toast('success', 'Word 文档已导出');
|
||||
});
|
||||
};
|
||||
|
||||
const sectionTitles = (key: string) => RESUME_SECTION_DEFS.find(d => d.key === key)?.defaultTitle ?? key;
|
||||
const sectionTypes = (key: string) => RESUME_SECTION_DEFS.find(d => d.key === key)?.types?.join(' / ') ?? '固定板块';
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1280px] px-3 py-4 sm:px-6 sm:py-5">
|
||||
@@ -103,7 +144,6 @@ export function ResumePage() {
|
||||
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')}>
|
||||
@@ -121,32 +161,57 @@ export function ResumePage() {
|
||||
)}
|
||||
</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="r-sec-title mb-2.5 text-[14px] font-bold text-neutral-900">
|
||||
<Ed html={sec.title} onCommit={v => handleEdit({ secKey: sec.key, field: 'title', value: v })} />
|
||||
</div>
|
||||
{sec.kind === 'edu' ? (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-[12.5px]">
|
||||
{([['学校', profile.school], ['专业', profile.major], ['学制', profile.eduLength], ['学历', profile.degree], ['入学', profile.eduStart], ['毕业', profile.eduEnd]] as [string, string | undefined][])
|
||||
.filter(r => r[1]).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>
|
||||
{profile.majorCourses && (
|
||||
<p className="mt-2.5 text-[12.5px] leading-relaxed text-neutral-700">
|
||||
<b>主修课程:</b>
|
||||
<Ed html={profile.majorCourses} onCommit={v => {
|
||||
const next = { ...previewProfile, majorCourses: v };
|
||||
void profileUpsert({ info: next }).then(() => profileQ.refetch()).catch(e => toast('error', e.message));
|
||||
}} />
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
['手机', profile.phone], ['邮箱', profile.email]] as [string, string][]).filter(r => r[1]).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>
|
||||
) : sec.kind === 'text' ? (
|
||||
<p className={'whitespace-pre-wrap text-neutral-700 ' + (cfg.density === 'compact' ? '' : 'leading-relaxed')}>
|
||||
{sec.text || '(在右侧板块设置中填写内容)'}
|
||||
<Ed html={sec.text ?? ''} onCommit={v => handleEdit({ secKey: sec.key, field: 'text', value: v })} />
|
||||
</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}>
|
||||
{sec.items?.map((it, itemIdx) => (
|
||||
<div key={(it.title ?? '') + (it.date ?? '') + itemIdx}>
|
||||
<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="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 })} />
|
||||
</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>}
|
||||
{it.desc && (
|
||||
<p className={'text-[12px] text-neutral-500 ' + (cfg.density === 'compact' ? '' : 'mt-0.5')}>
|
||||
<Ed html={it.desc} onCommit={v => handleEdit({ secKey: sec.key, field: 'desc', value: v, itemIdx, fromRecord: it.fromRecord })} />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -165,7 +230,6 @@ export function ResumePage() {
|
||||
{/* 右侧设置面板(手机在下方) */}
|
||||
{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">
|
||||
@@ -210,14 +274,13 @@ export function ResumePage() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 板块布局:拖拽排序 + 启停 + 内容编辑 */}
|
||||
<Card>
|
||||
<CardHeader title="板块布局" extra={<span className="text-[11px] text-ink-3">拖动排序 · 内容可编辑</span>} />
|
||||
<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' ? '文本' : '记录';
|
||||
const kindLabel = s.kind === 'info' ? '固定' : s.kind === 'text' ? '文本' : s.kind === 'edu' ? '教育' : '记录';
|
||||
return (
|
||||
<div key={s.key}
|
||||
draggable
|
||||
@@ -227,7 +290,7 @@ export function ResumePage() {
|
||||
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);
|
||||
const moved = arr.splice(from, 1)[0];
|
||||
arr.splice(i, 0, moved);
|
||||
persist({ ...cfg, sections: arr });
|
||||
}
|
||||
@@ -252,7 +315,7 @@ export function ResumePage() {
|
||||
{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; })}>
|
||||
onClick={() => setOpenEditors(prev => { const n = new Set(prev); if (n.has(s.key)) n.delete(s.key); else n.add(s.key); return n; })}>
|
||||
<ChevronRight size={13} className={'transition-transform ' + (open ? 'rotate-90' : '')} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -264,10 +327,10 @@ export function ResumePage() {
|
||||
) : s.kind === 'list' ? (
|
||||
<ListSectionEditor
|
||||
cfg={cfg} section={s} records={records} persist={persist}
|
||||
sectionTitle={sectionTitles(s.key)}
|
||||
sectionTypes={sectionTypes(s.key)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-[11.5px] text-ink-3">基本信息板块来自「个人信息」页,无法在此编辑。</p>
|
||||
<p className="text-[11.5px] text-ink-3">该板块内容来自「个人信息」页与记录数据。</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -285,7 +348,7 @@ export function ResumePage() {
|
||||
});
|
||||
setOpenEditors(prev => new Set(prev).add(key));
|
||||
}}>
|
||||
<Plus size={13} /> 新增文本板块
|
||||
<Plus size={13} /> 新增文本板块
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -299,12 +362,12 @@ export function ResumePage() {
|
||||
}
|
||||
|
||||
// ---------- 列表板块编辑器:记录条目显隐 + 自定义条目 ----------
|
||||
function ListSectionEditor({ cfg, section, records, persist, sectionTitle }: {
|
||||
function ListSectionEditor({ cfg, section, records, persist, sectionTypes }: {
|
||||
cfg: ResumeConfig;
|
||||
section: ResumeSectionCfg;
|
||||
records: FootprintRow[];
|
||||
persist: (c: ResumeConfig) => void;
|
||||
sectionTitle: string;
|
||||
sectionTypes: string;
|
||||
}) {
|
||||
const [adding, setAdding] = useState<ResumeItemCfg | null>(null);
|
||||
const excluded = new Set(section.excluded ?? []);
|
||||
@@ -318,15 +381,13 @@ function ListSectionEditor({ cfg, section, records, persist, sectionTitle }: {
|
||||
|
||||
const toggleRecord = (id: number) => {
|
||||
const next = new Set(section.excluded ?? []);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
setSection({ excluded: [...next] });
|
||||
};
|
||||
|
||||
return (
|
||||
<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 => (
|
||||
<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)} />
|
||||
@@ -347,9 +408,9 @@ function ListSectionEditor({ cfg, section, records, persist, sectionTitle }: {
|
||||
<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 })} />
|
||||
<Input placeholder="日期(2025.9)" 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 })} />
|
||||
<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={() => {
|
||||
@@ -370,12 +431,10 @@ function ListSectionEditor({ cfg, section, records, persist, sectionTitle }: {
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!customs.length && !adding && (
|
||||
<p className="text-[11.5px] text-ink-3">暂无自定义条目</p>
|
||||
)}
|
||||
{!customs.length && !adding && <p className="text-[11.5px] text-ink-3">暂无自定义条目</p>}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-ink-3">「{sectionTitle}」板块的记录条目来自你的记录数据,此处可隐藏不需要展示的条目。</p>
|
||||
<p className="text-[11px] text-ink-3">「{sectionTypes}」相关条目来自你的记录数据,此处可隐藏不需要展示的条目;描述可在预览中点击直接修改。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+3
-3
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{r as g,f as v,j as t,S as j,t as S}from"./index-DWQlGpif.js";import{C as c,a as d,b as x}from"./card-BkBMnQAf.js";import{u as N,i as u,a as m}from"./useChart-q1r1yv4-.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-BRtKGkIF.js";import{C as c,a as d,b as x}from"./card-CvlqAMBZ.js";import{u as N,i as u,a as m}from"./useChart-CMyXfVKg.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
-1
@@ -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-DWQlGpif.js";import{C,a as M,b as _}from"./card-BkBMnQAf.js";import{u as X,i as Q,a as K}from"./useChart-q1r1yv4-.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-BRtKGkIF.js";import{C,a as M,b as _}from"./card-CvlqAMBZ.js";import{u as X,i as Q,a as K}from"./useChart-CMyXfVKg.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
-1
@@ -1,4 +1,4 @@
|
||||
import{c as E,u as P,d as S,r as p,f as U,g as F,j as e,B as f,l as q,a9 as B,F as L,H as k,S as z,I as M,aa as Q,ab as _,ac as v,ad as K}from"./index-DWQlGpif.js";/**
|
||||
import{c as E,u as P,d as S,r as p,f as U,g as F,j as e,B as f,l as q,a9 as B,F as L,H as k,S as z,I as M,aa as Q,ab as _,ac as v,ad as K}from"./index-BRtKGkIF.js";/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
@@ -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-DWQlGpif.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-BRtKGkIF.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
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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-DWQlGpif.js";import{C as Y}from"./card-BkBMnQAf.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-BRtKGkIF.js";import{C as Y}from"./card-CvlqAMBZ.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-DWQlGpif.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-BRtKGkIF.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
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{r}from"./index-DWQlGpif.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-BRtKGkIF.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};
|
||||
@@ -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-DWQlGpif.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CIs6Yi-t.css">
|
||||
<script type="module" crossorigin src="/assets/index-BRtKGkIF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BmPfyPNh.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user