feat: server-side attachment thumbnails (480px webp at upload) + session caches - detail grids no longer download full originals

This commit is contained in:
root
2026-10-16 23:10:44 +08:00
parent 76cee988c0
commit 07f7cd51b2
19 changed files with 426 additions and 359 deletions
+1
View File
@@ -89,6 +89,7 @@ CREATE TABLE IF NOT EXISTS attachment (
size INTEGER NOT NULL DEFAULT 0, size INTEGER NOT NULL DEFAULT 0,
rel_path TEXT NOT NULL, rel_path TEXT NOT NULL,
sha256 TEXT, sha256 TEXT,
thumb_rel_path TEXT,
taken_at TEXT, taken_at TEXT,
deleted_at TEXT, deleted_at TEXT,
origin TEXT, origin TEXT,
+25 -3
View File
@@ -125,7 +125,7 @@ function preMigrateColumns(d) {
} }
}; };
for (const t of SYNC_TABLES) addCols(t, SYNC_COLS); for (const t of SYNC_TABLES) addCols(t, SYNC_COLS);
addCols('attachment', [['sha256', 'TEXT']]); addCols('attachment', [['sha256', 'TEXT'], ['thumb_rel_path', 'TEXT']]);
} }
export function migrateSync(d) { export function migrateSync(d) {
@@ -1011,13 +1011,25 @@ function listFootprints(d, query) {
const abs = path.join(DATA_DIR, rel); const abs = path.join(DATA_DIR, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, buf); fs.writeFileSync(abs, buf);
// 服务端缩略图(上传时由客户端 canvas 生成 480px WebP;无则 NULL,读取时回退原图)
let thumbRel = null;
if (p.thumb_b64) {
const tb = Buffer.from(p.thumb_b64, 'base64');
if (tb.length) {
thumbRel = path.join('attachments', String(p.owner_id), crypto.randomUUID() + '_thumb.webp');
const tAbs = path.join(DATA_DIR, thumbRel);
fs.mkdirSync(path.dirname(tAbs), { recursive: true });
fs.writeFileSync(tAbs, tb);
thumbRel = thumbRel.split(path.sep).join('/');
}
}
const uuid = uuidv7(); const uuid = uuidv7();
let item = null; let item = null;
withTx(d, () => { withTx(d, () => {
const h = hlcNext(d); const h = hlcNext(d);
const r = d.prepare( const r = d.prepare(
"INSERT INTO attachment (owner_type, owner_id, kind, file_name, mime, size, rel_path, sha256, uuid, hlc, origin) VALUES ('footprint',?,?,?,?,?,?,?,?,?,?)" "INSERT INTO attachment (owner_type, owner_id, kind, file_name, mime, size, rel_path, thumb_rel_path, sha256, uuid, hlc, origin) VALUES ('footprint',?,?,?,?,?,?,?,?,?,?,?)"
).run(p.owner_id, null, p.file_name, p.mime ?? null, buf.length, rel.split(path.sep).join('/'), sha, uuid, h, getDeviceId(d)); ).run(p.owner_id, null, p.file_name, p.mime ?? null, buf.length, rel.split(path.sep).join('/'), thumbRel, sha, uuid, h, getDeviceId(d));
item = d.prepare('SELECT * FROM attachment WHERE id=?').get(Number(r.lastInsertRowid)); item = d.prepare('SELECT * FROM attachment WHERE id=?').get(Number(r.lastInsertRowid));
touchOplog(d, 'attachment', uuid, 'upsert', h); touchOplog(d, 'attachment', uuid, 'upsert', h);
}); });
@@ -1037,6 +1049,7 @@ function listFootprints(d, query) {
withTx(d, () => softDelete(d, 'attachment', payload.id)); withTx(d, () => softDelete(d, 'attachment', payload.id));
const abs = path.join(DATA_DIR, row.rel_path); const abs = path.join(DATA_DIR, row.rel_path);
try { fs.unlinkSync(abs); } catch {} try { fs.unlinkSync(abs); } catch {}
if (row.thumb_rel_path) { try { fs.unlinkSync(path.join(DATA_DIR, row.thumb_rel_path)); } catch {} }
} }
return { ok: true }; return { ok: true };
} }
@@ -1048,6 +1061,15 @@ function listFootprints(d, query) {
return { data_b64: data.toString('base64'), mime: row.mime, file_name: row.file_name }; return { data_b64: data.toString('base64'), mime: row.mime, file_name: row.file_name };
} }
case 'attachment_thumb': {
// 缩略图读取:有服务端缩略图用之(20KB 级 WebP),老附件回退原图
const row = d.prepare('SELECT * FROM attachment WHERE id=?').get(payload.id);
if (!row) throw new Error('附件不存在');
const rel = row.thumb_rel_path || row.rel_path;
const data = fs.readFileSync(path.join(DATA_DIR, rel));
return { data_b64: data.toString('base64'), mime: row.thumb_rel_path ? 'image/webp' : row.mime, file_name: row.file_name };
}
case 'view_list': case 'view_list':
return { items: d.prepare('SELECT * FROM saved_view WHERE deleted_at IS NULL ORDER BY id').all().map(v => ({ return { items: d.prepare('SELECT * FROM saved_view WHERE deleted_at IS NULL ORDER BY id').all().map(v => ({
...v, filter: JSON.parse(v.filter || '{}'), sort: JSON.parse(v.sort || '{}'), is_default: !!v.is_default ...v, filter: JSON.parse(v.filter || '{}'), sort: JSON.parse(v.sort || '{}'), is_default: !!v.is_default
+23 -16
View File
@@ -2,9 +2,10 @@ import { useRef } from 'react';
import { Paperclip, FileText, Image, Trash2 } from 'lucide-react'; import { Paperclip, FileText, Image, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui'; import { Button } from '@/components/ui';
import { fmtSize } from '@/lib/format'; import { fmtSize } from '@/lib/format';
import { makeThumb } from '@/lib/img';
import type { Attachment } from '@/lib/types'; import type { Attachment } from '@/lib/types';
export interface PendingAtt { file_name: string; mime?: string; size: number; data_b64: string; preview?: string; } export interface PendingAtt { file_name: string; mime?: string; size: number; data_b64: string; thumb_b64?: string; preview?: string; }
export function AttachmentPicker({ items, pending, onPick, onDeleteSaved, onDeletePending, onOpenSaved, uploading }: { export function AttachmentPicker({ items, pending, onPick, onDeleteSaved, onDeletePending, onOpenSaved, uploading }: {
items: Attachment[]; items: Attachment[];
@@ -81,23 +82,29 @@ export function AttachmentPicker({ items, pending, onPick, onDeleteSaved, onDele
); );
} }
export function fileToPending(file: File): Promise<PendingAtt> { export async function fileToPending(file: File): Promise<PendingAtt> {
return new Promise((resolve, reject) => { const result = await new Promise<string>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => resolve(String(reader.result || ''));
const result = String(reader.result || '');
const comma = result.indexOf(',');
// 图片用 objectURL 做轻量预览(不上 Base64 进 DOM)
const preview = file.type?.startsWith('image/') ? URL.createObjectURL(file) : undefined;
resolve({
file_name: file.name,
mime: file.type || undefined,
size: file.size,
data_b64: comma >= 0 ? result.slice(comma + 1) : result,
preview
});
};
reader.onerror = () => reject(new Error('读取文件失败')); reader.onerror = () => reject(new Error('读取文件失败'));
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
const comma = result.indexOf(',');
const data_b64 = comma >= 0 ? result.slice(comma + 1) : result;
// 上传前生成 480px WebP 缩略图(详情网格用它,原图仅在查看时读取)
let thumb_b64: string | undefined;
if (file.type?.startsWith('image/') && !file.type.includes('gif')) {
try {
const thumbUrl = await makeThumb(data_b64, file.type);
thumb_b64 = thumbUrl.split(',')[1] || undefined;
} catch { thumb_b64 = undefined; }
}
return {
file_name: file.name,
mime: file.type || undefined,
size: file.size,
data_b64,
thumb_b64,
preview: file.type?.startsWith('image/') ? URL.createObjectURL(file) : undefined
};
} }
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Pencil, Trash2, Paperclip, FileText } from 'lucide-react'; import { Pencil, Trash2, Paperclip, FileText } from 'lucide-react';
import { attachmentRead, footprintGet, listDimensions, listTypes } from '@/lib/api'; import { attachmentRead, attachmentThumb, footprintGet, listDimensions, listTypes } from '@/lib/api';
import type { Attachment, FormField } from '@/lib/types'; import type { Attachment, FormField } from '@/lib/types';
import { Badge, Button, Confirm, Dialog, useToast } from '@/components/ui'; import { Badge, Button, Confirm, Dialog, useToast } from '@/components/ui';
import { StatusBadge, LevelBadge, GradeBadge, RoleBadge, NegativeBadge } from '@/components/common/badges'; import { StatusBadge, LevelBadge, GradeBadge, RoleBadge, NegativeBadge } from '@/components/common/badges';
@@ -9,6 +9,10 @@ import { openOrDownload } from '@/lib/file';
import { makeThumb } from '@/lib/img'; import { makeThumb } from '@/lib/img';
import { fmtRange, fmtDateTime, fmtSize } from '@/lib/format'; import { fmtRange, fmtDateTime, fmtSize } from '@/lib/format';
// 缩略图/原图会话级缓存:同一附件一次会话只读一次
const thumbCache = new Map<number, string>();
const fullCache = new Map<number, string>();
export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: { export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@@ -46,8 +50,13 @@ export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
const openAtt = async (a: Attachment) => { const openAtt = async (a: Attachment) => {
try { try {
const r = await attachmentRead(a.id); let b64 = fullCache.get(a.id);
openOrDownload(r.file_name, r.mime ?? undefined, r.data_b64); if (!b64) {
const r = await attachmentRead(a.id);
b64 = r.data_b64;
if ((a.size ?? 0) <= 2 * 1024 * 1024) fullCache.set(a.id, b64); // 大原图不驻留内存
}
openOrDownload(a.file_name, a.mime ?? undefined, b64);
} catch (e) { } catch (e) {
toast('error', (e as Error).message); toast('error', (e as Error).message);
} }
@@ -60,25 +69,36 @@ export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
const docs = atts.filter(a => !a.mime?.startsWith('image/')); const docs = atts.filter(a => !a.mime?.startsWith('image/'));
const attsKey = atts.map(a => a.id).join(','); const attsKey = atts.map(a => a.id).join(',');
useEffect(() => { useEffect(() => {
setImgMap({});
if (!open || !attsKey) return; if (!open || !attsKey) return;
let cancelled = false; let cancelled = false;
const list = atts.filter(a => a.mime?.startsWith('image/'));
(async () => { (async () => {
const map: Record<number, string> = {};
const list = atts.filter(a => a.mime?.startsWith('image/'));
for (const a of list) { for (const a of list) {
if (cancelled) return;
if (thumbCache.has(a.id)) { setImgMap(m => ({ ...m, [a.id]: thumbCache.get(a.id)! })); continue; }
try { try {
const r = await attachmentRead(a.id); if (a.thumb_rel_path) {
if (cancelled) return; // 新上传:服务端已存 480px WebP 缩略图,20KB 级,秒开
// 网格展示用缩略图(降采样至 480px WebP),原图仅在点击查看时读取 const r = await attachmentThumb(a.id);
map[a.id] = await makeThumb(r.data_b64, r.mime || 'image/png'); if (cancelled) return;
setImgMap({ ...map }); const url = 'data:' + (r.mime || 'image/webp') + ';base64,' + r.data_b64;
thumbCache.set(a.id, url);
setImgMap(m => ({ ...m, [a.id]: url }));
} else {
// 历史附件无缩略图:读原图 + 客户端降采样,结果进会话缓存
const r = await attachmentRead(a.id);
if (cancelled) return;
const url = await makeThumb(r.data_b64, r.mime || 'image/png');
thumbCache.set(a.id, url);
setImgMap(m => ({ ...m, [a.id]: url }));
}
} catch { } catch {
// 解码失败则退回原图
try { try {
const r = await attachmentRead(a.id); const r = await attachmentRead(a.id);
map[a.id] = 'data:' + (r.mime || 'image/png') + ';base64,' + r.data_b64; if (cancelled) return;
setImgMap({ ...map }); const url = 'data:' + (r.mime || 'image/png') + ';base64,' + r.data_b64;
thumbCache.set(a.id, url);
setImgMap(m => ({ ...m, [a.id]: url }));
} catch { /* 单张失败不影响其余 */ } } catch { /* 单张失败不影响其余 */ }
} }
} }
@@ -156,7 +156,7 @@ export function RecordEditorDialog({ open, onClose, footprintId, onSaved }: {
}; };
const saved = await footprintUpsert(input); const saved = await footprintUpsert(input);
for (const p of pendingAtts) { for (const p of pendingAtts) {
await attachmentAdd({ owner_id: saved.id, file_name: p.file_name, mime: p.mime, size: p.size, data_b64: p.data_b64 }); await attachmentAdd({ owner_id: saved.id, file_name: p.file_name, mime: p.mime, size: p.size, data_b64: p.data_b64, thumb_b64: p.thumb_b64 });
} }
return saved.id; return saved.id;
}, },
@@ -347,7 +347,7 @@ export function RecordEditorDialog({ open, onClose, footprintId, onSaved }: {
continue; continue;
} }
const p = await fileToPending(f); const p = await fileToPending(f);
await attachmentAdd({ owner_id: footprintId, file_name: p.file_name, mime: p.mime, size: p.size, data_b64: p.data_b64 }); await attachmentAdd({ owner_id: footprintId, file_name: p.file_name, mime: p.mime, size: p.size, data_b64: p.data_b64, thumb_b64: p.thumb_b64 });
done++; done++;
} }
if (done > 0) { if (done > 0) {
+3 -1
View File
@@ -129,12 +129,14 @@ export const bandUpsert = (p: GradeBand) => call<{ item: { id: number } }>('band
export const bandDelete = (id: number) => call<{ ok: true }>('band_delete', { id }); export const bandDelete = (id: number) => call<{ ok: true }>('band_delete', { id });
// ---- attachment ---- // ---- attachment ----
export const attachmentAdd = (p: { owner_id: number; file_name: string; mime?: string; size: number; data_b64: string }) => export const attachmentAdd = (p: { owner_id: number; file_name: string; mime?: string; size: number; data_b64: string; thumb_b64?: string }) =>
call<{ item: Attachment }>('attachment_add', p); call<{ item: Attachment }>('attachment_add', p);
export const attachmentList = (owner_id: number) => call<{ items: Attachment[] }>('attachment_list', { owner_id }); export const attachmentList = (owner_id: number) => call<{ items: Attachment[] }>('attachment_list', { owner_id });
export const attachmentDelete = (id: number) => call<{ ok: true }>('attachment_delete', { id }); export const attachmentDelete = (id: number) => call<{ ok: true }>('attachment_delete', { id });
export const attachmentRead = (id: number) => export const attachmentRead = (id: number) =>
call<{ data_b64: string; mime?: string; file_name: string }>('attachment_read', { id }); call<{ data_b64: string; mime?: string; file_name: string }>('attachment_read', { id });
export const attachmentThumb = (id: number) =>
call<{ data_b64: string; mime?: string; file_name: string }>('attachment_thumb', { id });
// ---- saved view ---- // ---- saved view ----
export interface SavedView { id: number; name: string; filter: Record<string, unknown>; sort?: Record<string, unknown>; is_default: boolean; } export interface SavedView { id: number; name: string; filter: Record<string, unknown>; sort?: Record<string, unknown>; is_default: boolean; }
+1
View File
@@ -50,6 +50,7 @@ export interface Attachment {
mime?: string | null; mime?: string | null;
size: number; size: number;
rel_path: string; rel_path: string;
thumb_rel_path?: string | null;
created_at: string; created_at: string;
} }
+15 -1
View File
@@ -57,6 +57,20 @@ ok((tl.body?.data?.groups?.length ?? 0) >= 1, 'timeline 有分组');
const st = await api(A, 'POST', '/api/stats_overview', {}, T); const st = await api(A, 'POST', '/api/stats_overview', {}, T);
ok(st.body?.ok === true, 'stats_overview 可用'); ok(st.body?.ok === true, 'stats_overview 可用');
// 附件缩略图:上传带 thumb → 列表标记 → thumb/原图分离读取
const tl0 = await api(A, 'POST', '/api/type_list', {}, T);
const fpUp = await api(A, 'POST', '/api/footprint_upsert', { item: { type_id: (tl0.body?.data?.items || [])[0]?.id, title: '附件缩略图测试', status: 'finished' } }, T);
const fpId = fpUp.body?.data?.id;
ok(!!fpId, '创建附件测试足迹', fpUp.body);
const attAdd = await api(A, 'POST', '/api/attachment_add', { owner_id: fpId, file_name: 'photo.jpg', mime: 'image/jpeg', size: 8, data_b64: Buffer.from('ORIGDATA').toString('base64'), thumb_b64: Buffer.from('THUMBWEBPDATA').toString('base64') }, T);
ok(attAdd.body?.ok === true && !!attAdd.body?.data?.item?.thumb_rel_path, 'attachment_add 存储缩略图', attAdd.body);
const attList = await api(A, 'POST', '/api/attachment_list', { owner_id: fpId }, T);
ok(!!attList.body?.data?.items?.[0]?.thumb_rel_path, 'attachment_list 返回缩略图标记');
const attThumb = await api(A, 'POST', '/api/attachment_thumb', { id: attAdd.body?.data?.item?.id }, T);
ok(attThumb.body?.data?.data_b64 === Buffer.from('THUMBWEBPDATA').toString('base64'), 'attachment_thumb 返回缩略图内容', attThumb.body);
const attRead2 = await api(A, 'POST', '/api/attachment_read', { id: attAdd.body?.data?.item?.id }, T);
ok(attRead2.body?.data?.data_b64 === Buffer.from('ORIGDATA').toString('base64'), 'attachment_read 原图不受影响', attRead2.body);
// 邀请码:增 // 邀请码:增
const c1 = await api(A, 'POST', '/admin/invites/create', { code: 'xiong-friend-2025', remark: '给学弟的码' }, T); const c1 = await api(A, 'POST', '/admin/invites/create', { code: 'xiong-friend-2025', remark: '给学弟的码' }, T);
ok(c1.status === 200 && c1.body?.data?.code === 'XIONG-FRIEND-2025', '新建自定义码(大小写归一化)', c1.body); ok(c1.status === 200 && c1.body?.data?.code === 'XIONG-FRIEND-2025', '新建自定义码(大小写归一化)', c1.body);
@@ -97,7 +111,7 @@ const login2 = await api(B, 'POST', '/auth/login', { username: 'demo', password:
ok(login2.status === 200, '重启后 demo 可登录'); ok(login2.status === 200, '重启后 demo 可登录');
const T2 = login2.body.data.token; const T2 = login2.body.data.token;
const fp2 = await api(B, 'POST', '/api/footprint_list', { query: { page: 1, page_size: 50 } }, T2); const fp2 = await api(B, 'POST', '/api/footprint_list', { query: { page: 1, page_size: 50 } }, T2);
ok(fp2.body?.data?.total === 16, '重启后仍是 16 条(无重复播种)', fp2.body?.data?.total); ok(fp2.body?.data?.total === 17, '重启后仍是 17 条(无重复播种)', fp2.body?.data?.total);
const list2 = (await api(B, 'GET', '/admin/invites', undefined, T2)).body.data.invites; const list2 = (await api(B, 'GET', '/admin/invites', undefined, T2)).body.data.invites;
ok(list2.some((i) => i.code === 'XIONG-FRIEND-2025'), '邀请码跨重启持久化'); ok(list2.some((i) => i.code === 'XIONG-FRIEND-2025'), '邀请码跨重启持久化');
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{r as g,e as v,j as t,S as j,s as S}from"./index-C58ZhVdG.js";import{C as c,a as d,b as x}from"./card-DTW0IIxx.js";import{u as N,i as u,a as m}from"./useChart-BSpPh9BI.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-6 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:"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,e as v,j as t,S as j,s as S}from"./index-DrwMmj76.js";import{C as c,a as d,b as x}from"./card-DvRCiB1a.js";import{u as N,i as u,a as m}from"./useChart-BCLvza0E.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-6 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:"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 K,e as I,j as e,E as O,t as $,u as k,b as S,r as u,f as y,v as H,w as W,B as g,P as q,C as Q,x as J,y as T,z as U,A as N,k as G,T as E,I as j,D as Y,F as C,H as B,J as V}from"./index-C58ZhVdG.js";import{C as f,a as z,b as _}from"./card-DTW0IIxx.js";import{u as X,i as M,a as L}from"./useChart-BSpPh9BI.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 K,e as I,j as e,E as O,t as $,u as k,b as S,r as u,f as y,v as H,w as W,B as g,P as q,C as Q,x as J,y as T,z as U,A as N,k as G,T as E,I as j,D as Y,F as C,H as B,J as V}from"./index-DrwMmj76.js";import{C as f,a as z,b as _}from"./card-DvRCiB1a.js";import{u as X,i as M,a as L}from"./useChart-BCLvza0E.js";import{u as Z,i as ee,b as se,a as te,c as ae,d as re,e as ne,f as le}from"./echarts-BIWefrIc.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as re,j as e,a as u,r as t,u as oe,b as ce,d as de,e as C,f as ue,I as xe,S as m,B as b,P as Y,g as pe,L as me,G as ve,R as he,h as je,i as ge,k as fe,T as ye,E as be,l as Ne,m as ke,C as Se,n as we,o as Ce,p as Ee,s as qe,q as _e}from"./index-C58ZhVdG.js";/** import{c as re,j as e,a as u,r as t,u as oe,b as ce,d as de,e as C,f as ue,I as xe,S as m,B as b,P as Y,g as pe,L as me,G as ve,R as he,h as je,i as ge,k as fe,T as ye,E as be,l as Ne,m as ke,C as Se,n as we,o as Ce,p as Ee,s as qe,q as _e}from"./index-DrwMmj76.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{c as P,u as Q,b as z,e as B,r as i,f as T,j as e,B as p,P as F,K as q,A as E,k as D,T as I,C as K,M as L,D as G,F as r,I as j,N as H,S as R,O as W,o as J}from"./index-C58ZhVdG.js";import{C as U}from"./card-DTW0IIxx.js";/** import{c as P,u as Q,b as z,e as B,r as i,f as T,j as e,B as p,P as F,K as q,A as E,k as D,T as I,C as K,M as L,D as G,F as r,I as j,N as H,S as R,O as W,o as J}from"./index-DrwMmj76.js";import{C as U}from"./card-DvRCiB1a.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1 +1 @@
import{j as s,a}from"./index-C58ZhVdG.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-DrwMmj76.js";function i({className:r,...e}){return s.jsx("div",{className:a("rounded-lg border border-line bg-surface shadow-card",r),...e})}function t({title:r,extra:e,className:n}){return s.jsxs("div",{className:a("flex min-h-11 flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-1.5",n),children:[s.jsx("h3",{className:"text-[13px] font-semibold text-ink-1",children:r}),e&&s.jsx("div",{className:"flex flex-wrap items-center gap-2",children:e})]})}function c({className:r,...e}){return s.jsx("div",{className:a("p-4",r),...e})}export{i as C,t as a,c as b};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{r}from"./index-C58ZhVdG.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-DrwMmj76.js";import{g as f}from"./echarts-BIWefrIc.js";function p(){const[n,o]=r.useState(0);return r.useEffect(()=>{const s=()=>o(t=>t+1);return window.addEventListener("fp-theme-change",s),()=>window.removeEventListener("fp-theme-change",s)},[]),n}function d(n){return getComputedStyle(document.documentElement).getPropertyValue("--text-"+n).trim()||"#454e5c"}function h(n,o){const s=r.useRef(null),[t,i]=r.useState(null),u=r.useRef(n);return u.current=n,r.useEffect(()=>{if(!t)return;const e=f(t);s.current=e,e.setOption(u.current(),{notMerge:!0});const c=new ResizeObserver(()=>e.resize());return c.observe(t),()=>{c.disconnect(),e.dispose(),s.current=null}},[t]),r.useEffect(()=>{var e;(e=s.current)==null||e.setOption(u.current(),{notMerge:!0})},[t,...o]),e=>i(e)}export{h as a,d as i,p as u};
+1 -1
View File
@@ -8,7 +8,7 @@
<link rel="icon" href="/icons/pwa-192.png" /> <link rel="icon" href="/icons/pwa-192.png" />
<link rel="apple-touch-icon" href="/icons/pwa-192.png" /> <link rel="apple-touch-icon" href="/icons/pwa-192.png" />
<title>足迹 Footprints</title> <title>足迹 Footprints</title>
<script type="module" crossorigin src="/assets/index-C58ZhVdG.js"></script> <script type="module" crossorigin src="/assets/index-DrwMmj76.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-zuv9BCiF.css"> <link rel="stylesheet" crossorigin href="/assets/index-zuv9BCiF.css">
</head> </head>
<body> <body>