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
+186
View File
@@ -0,0 +1,186 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Paperclip, CalendarDays } from 'lucide-react';
import { listTypes, timeline } from '@/lib/api';
import type { FootprintRow } from '@/lib/types';
import { Empty, Input, Select, Badge } from '@/components/ui';
import { StatusBadge, LevelBadge, RoleBadge, GradeBadge, NegativeBadge } from '@/components/common/badges';
import { TypeIcon } from '@/components/common/typeIcons';
import { RecordEditorDialog } from '@/components/records/RecordEditorDialog';
import { RecordDetailDialog } from '@/components/records/RecordDetailDialog';
export function TimelinePage() {
const [q, setQ] = useState('');
const [typeId, setTypeId] = useState('');
const [year, setYear] = useState('');
const [polarity, setPolarity] = useState('');
const [editingId, setEditingId] = useState<number | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [detailId, setDetailId] = useState<number | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const typesQ = useQuery({ queryKey: ['types'], queryFn: listTypes });
const query = useMemo(() => ({
q: q || undefined,
types: typeId ? [Number(typeId)] : undefined,
year: year ? Number(year) : undefined,
polarity: polarity || undefined,
sort: 'date_desc',
page_size: 2000
}), [q, typeId, year, polarity]);
const tlQ = useQuery({ queryKey: ['timeline', query], queryFn: () => timeline(query) });
const years = useMemo(() => {
const ys = new Set<number>();
for (const g of tlQ.data?.groups ?? []) ys.add(g.year);
return Array.from(ys).sort((a, b) => b - a);
}, [tlQ.data]);
const openDetail = (id: number) => {
setDetailId(id);
setDetailOpen(true);
};
return (
<div className="mx-auto max-w-[880px] px-6 py-5">
<div className="mb-4 flex flex-wrap items-center gap-2">
<Input
className="w-[220px]"
placeholder="搜索足迹…"
value={q}
onChange={e => setQ(e.target.value)}
/>
<Select
className="w-[130px]"
value={typeId}
onChange={e => setTypeId(e.target.value)}
options={[{ value: '', label: '全部类型' }, ...(typesQ.data?.items ?? []).map(t => ({ value: String(t.id), label: t.name }))]}
/>
<Select
className="w-[120px]"
value={year}
onChange={e => setYear(e.target.value)}
options={[{ value: '', label: '全部年份' }, ...years.map(y => ({ value: String(y), label: y + ' 年' }))]}
/>
<Select
className="w-[110px]"
value={polarity}
onChange={e => setPolarity(e.target.value)}
options={[
{ value: '', label: '全部性质' },
{ value: 'positive', label: '正面' },
{ value: 'negative', label: '负面' }
]}
/>
<span className="num ml-auto text-[12px] text-ink-3">共 {tlQ.data?.total ?? 0} 条</span>
</div>
{tlQ.isLoading ? (
<div className="py-16 text-center text-[13px] text-ink-3">加载中…</div>
) : !tlQ.data?.groups.length ? (
<Empty
icon={<CalendarDays size={36} strokeWidth={1.5} />}
title="暂无足迹"
description="点击右上角「新建记录」,开始记录你的第一段足迹。"
/>
) : (
<div className="relative">
<div className="absolute bottom-0 left-[7px] top-0 w-px bg-line" aria-hidden />
{tlQ.data.groups.map(group => (
<section key={group.year} className="mb-2">
<div className="sticky top-0 z-10 -mx-6 flex items-center gap-2 bg-bg/90 px-6 py-2 backdrop-blur">
<span className="num text-[16px] font-semibold text-ink-1">{group.year}</span>
<span className="text-[12px] text-ink-3">{group.count} 条记录</span>
</div>
{group.months.map(m => (
<div key={m.month} className="mb-3">
<div className="mb-2 pl-6 text-[12px] font-medium text-ink-3">{m.month} 月</div>
<div className="flex flex-col gap-2">
{m.items.map(item => (
<TimelineItem key={item.id} row={item} onClick={() => openDetail(item.id)} />
))}
</div>
</div>
))}
</section>
))}
</div>
)}
<RecordDetailDialog
open={detailOpen}
onClose={() => setDetailOpen(false)}
footprintId={detailId}
onEdit={id => {
setDetailOpen(false);
setEditingId(id);
setEditorOpen(true);
}}
/>
<RecordEditorDialog
open={editorOpen}
onClose={() => setEditorOpen(false)}
footprintId={editingId}
/>
</div>
);
}
function TimelineItem({ row, onClick }: { row: FootprintRow; onClick: () => void }) {
const date = (row.start_date ?? row.created_at).slice(0, 10);
return (
<button
onClick={onClick}
className="group relative w-full rounded-lg border border-line bg-surface px-4 py-3 text-left shadow-card transition-all hover:border-line-strong hover:shadow-pop"
>
<span
className="absolute left-0 top-3 bottom-3 w-[3px] rounded-full"
style={{ backgroundColor: row.type_color }}
aria-hidden
/>
<div className="flex items-start gap-3">
<div className="num w-[52px] shrink-0 pt-0.5 text-[12px] leading-relaxed text-ink-3">
{date.slice(5, 7)}-{date.slice(8, 10)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[13.5px] font-medium text-ink-1 group-hover:text-brand-text">
{row.title}
</span>
<Badge color={row.type_color}>{row.type_name}</Badge>
<LevelBadge level={row.level} />
<GradeBadge grade={row.award_grade} />
<RoleBadge role={row.role} />
<StatusBadge status={row.status} />
{row.polarity === 'negative' && <NegativeBadge />}
</div>
{row.organizer && (
<div className="mt-0.5 text-[12px] text-ink-3">{row.organizer}</div>
)}
{row.description && (
<p className="mt-1 line-clamp-2 text-[12.5px] leading-relaxed text-ink-2">{row.description}</p>
)}
{(row.result || row.tags.length > 0 || row.attachment_count > 0) && (
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-ink-3">
{row.result && <span className="text-success">结果:{row.result}</span>}
{row.tags.map(t => (
<span key={t} className="rounded-sm bg-surface-3 px-1.5 py-px">{t}</span>
))}
{row.attachment_count > 0 && (
<span className="inline-flex items-center gap-1">
<Paperclip size={11} />
{row.attachment_count}
</span>
)}
</div>
)}
</div>
<div className="shrink-0 pt-1 text-ink-3">
<TypeIcon name={row.type_icon} color={row.type_color} size={16} />
</div>
</div>
</button>
);
}