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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user