feat: 足迹 Footprints 个人留痕系统
桌面应用(Tauri+React+SQLite):时间线/记录/学年学科成绩梯度/统计/类型与字段模板/附件内容寻址/详情视图/PWA 同步服务端(sync-server):多用户+邀请码/LWW推拉/seq游标/附件去重/Docker部署 多端同步地基:uuid v7/HLC混合逻辑时钟/软删除墓碑/sync_oplog,Node与Rust双端一致并通过自动化测试
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import * as echarts from 'echarts/core';
|
||||
import { BarChart, PieChart, LineChart } from 'echarts/charts';
|
||||
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { statsOverview } from '@/lib/api';
|
||||
import { Card, CardBody, CardHeader, Select } from '@/components/ui';
|
||||
import { useChart, inkColor, useThemeTick } from '@/hooks/useChart';
|
||||
|
||||
echarts.use([BarChart, PieChart, LineChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]);
|
||||
|
||||
export function DashboardPage() {
|
||||
const [year, setYear] = useState('');
|
||||
const themeTick = useThemeTick();
|
||||
const statsQ = useQuery({
|
||||
queryKey: ['stats', year || undefined],
|
||||
queryFn: () => statsOverview(year ? Number(year) : undefined)
|
||||
});
|
||||
|
||||
const s = statsQ.data;
|
||||
const ink2 = inkColor('2');
|
||||
const ink3 = inkColor('3');
|
||||
|
||||
const monthlyRef = useChart(() => ({
|
||||
grid: { left: 34, right: 12, top: 14, bottom: 24 },
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: s?.monthly.map(m => m.ym.slice(2)) ?? [],
|
||||
axisLine: { lineStyle: { color: ink3 } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: ink3, fontSize: 10 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitLine: { lineStyle: { color: 'rgba(128,138,155,0.15)' } },
|
||||
axisLabel: { color: ink3, fontSize: 10 }
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: s?.monthly.map(m => m.count) ?? [],
|
||||
barMaxWidth: 18,
|
||||
itemStyle: { color: '#3056d3', borderRadius: [3, 3, 0, 0] }
|
||||
}]
|
||||
}), [s, themeTick]);
|
||||
|
||||
const typeRef = useChart(() => {
|
||||
const items = (s?.by_type ?? []).filter(t => t.count > 0);
|
||||
return {
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { orient: 'vertical', right: 4, top: 'middle', textStyle: { color: ink2, fontSize: 11 }, itemWidth: 10, itemHeight: 10 },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['48%', '72%'],
|
||||
center: ['34%', '50%'],
|
||||
label: { show: false },
|
||||
data: items.map(t => ({ name: t.label, value: t.count, itemStyle: { color: t.color || '#64748b' } }))
|
||||
}]
|
||||
};
|
||||
}, [s, themeTick]);
|
||||
|
||||
const levelRef = useChart(() => {
|
||||
const items = (s?.by_level ?? []).filter(l => l.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: ink3, fontSize: 10 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: items.map(l => l.label),
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: ink2, fontSize: 11 }
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: items.map(l => ({
|
||||
value: l.count,
|
||||
itemStyle: { color: l.color || '#3056d3', borderRadius: [0, 3, 3, 0] }
|
||||
})),
|
||||
barMaxWidth: 14
|
||||
}]
|
||||
};
|
||||
}, [s, themeTick]);
|
||||
|
||||
const metricCards = (s?.metrics ?? []).slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1080px] px-6 py-5">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<MetricCard label="总记录" value={s?.total ?? 0} />
|
||||
<MetricCard label={year ? year + ' 年新增' : '本年新增'} value={s?.total_this_year ?? 0} />
|
||||
<MetricCard label="附件总数" value={s?.attachment_total ?? 0} />
|
||||
{metricCards.map(m => (
|
||||
<MetricCard key={m.key} label={m.label} value={m.value} unit={m.unit ?? ''} />
|
||||
))}
|
||||
</div>
|
||||
<Select
|
||||
className="w-[120px]"
|
||||
value={year}
|
||||
onChange={e => setYear(e.target.value)}
|
||||
options={[{ value: '', label: '全部年份' }, ...(s?.years ?? []).map(y => ({ value: String(y), label: y + ' 年' }))]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!s?.metrics.length && (
|
||||
<div className="mb-4 rounded-md border border-dashed border-line px-4 py-2 text-[12px] text-ink-3">
|
||||
提示:在「统计」配置中注册指标(如志愿时长、读书本数),带单位的数值字段即可在此量化展示。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader title="近 12 个月活跃度" />
|
||||
<CardBody><div ref={monthlyRef} className="h-[220px]" /></CardBody>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader title="类型分布" />
|
||||
<CardBody><div ref={typeRef} className="h-[220px]" /></CardBody>
|
||||
</Card>
|
||||
<Card className="col-span-2">
|
||||
<CardHeader title="级别分布" />
|
||||
<CardBody><div ref={levelRef} className="h-[200px]" /></CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, unit }: { label: string; value: number; unit?: string }) {
|
||||
return (
|
||||
<div className="min-w-[120px] rounded-lg border border-line bg-surface px-4 py-3 shadow-card">
|
||||
<div className="text-[12px] text-ink-3">{label}</div>
|
||||
<div className="num mt-0.5 text-[22px] font-semibold leading-tight text-ink-1">
|
||||
{value}
|
||||
{unit && <span className="ml-1 text-[12px] font-normal text-ink-3">{unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user