feat: 足迹 Footprints 个人留痕系统

桌面应用(Tauri+React+SQLite):时间线/记录/学年学科成绩梯度/统计/类型与字段模板/附件内容寻址/详情视图/PWA

同步服务端(sync-server):多用户+邀请码/LWW推拉/seq游标/附件去重/Docker部署

多端同步地基:uuid v7/HLC混合逻辑时钟/软删除墓碑/sync_oplog,Node与Rust双端一致并通过自动化测试
This commit is contained in:
root
2026-10-16 13:53:15 +08:00
commit 42ca57abfd
107 changed files with 234676 additions and 0 deletions
+146
View File
@@ -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>
);
}