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
+195
View File
@@ -0,0 +1,195 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2 } from 'lucide-react';
import { deleteDimension, deleteTaxonomy, listDimensions, listTaxonomy, upsertDimension, upsertTaxonomy } from '@/lib/api';
import { Button, Card, CardHeader, Confirm, Input, useToast } from '@/components/ui';
const PALETTE = ['#3056d3', '#7c3aed', '#0891b2', '#16a34a', '#d97706', '#dc2626', '#db2777', '#64748b'];
export function TaxonomyPage() {
return (
<div className="mx-auto max-w-[1080px] px-6 py-5">
<p className="mb-4 text-[12px] text-ink-3">
字典驱动录入表单中的下拉选项;维度是自定义的成长分类轴,一条记录可挂多个维度。
</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<TaxonomyCard kind="level" title="级别字典" description="国际级、国家级、省市级等,可按学校口径增改" />
<TaxonomyCard kind="role" title="角色字典" description="主导、核心、参加等参与角色" />
<DimensionCard />
</div>
</div>
);
}
function TaxonomyCard({ kind, title, description }: { kind: string; title: string; description: string }) {
const qc = useQueryClient();
const toast = useToast();
const taxQ = useQuery({ queryKey: ['taxonomy-all'], queryFn: () => listTaxonomy() });
const items = (taxQ.data?.items ?? []).filter(t => t.kind === kind).sort((a, b) => a.sort - b.sort);
const [draft, setDraft] = useState('');
const [deleting, setDeleting] = useState<{ id: number; value: string } | null>(null);
const upMut = useMutation({
mutationFn: upsertTaxonomy,
onSuccess: () => void qc.invalidateQueries({ queryKey: ['taxonomy-all'] })
});
const delMut = useMutation({
mutationFn: (id: number) => deleteTaxonomy(id),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['taxonomy-all'] });
toast('success', '已删除');
setDeleting(null);
},
onError: (e: Error) => { toast('error', e.message); setDeleting(null); }
});
return (
<Card>
<CardHeader title={title} extra={<span className="text-[11px] text-ink-3">{description}</span>} />
<div className="flex flex-col gap-1 p-3">
{items.map((t, i) => (
<div key={t.id} className="flex items-center gap-2 rounded-md px-1 py-1 hover:bg-surface-2">
<input
type="color"
value={t.color ?? PALETTE[i % PALETTE.length]}
className="size-5 cursor-pointer rounded border border-line bg-surface"
onChange={e => upMut.mutate({ id: t.id, kind, value: t.value, color: e.target.value, sort: t.sort })}
/>
<input
className="h-7 flex-1 rounded border border-transparent bg-transparent px-1.5 text-[13px] text-ink-1 hover:border-line focus:border-brand focus:outline-none"
value={t.value}
onChange={() => {}}
onBlur={e => {
const v = e.target.value.trim();
if (v && v !== t.value) upMut.mutate({ id: t.id, kind, value: v, color: t.color, sort: t.sort });
}}
key={t.value}
defaultValue={t.value}
/>
<span className="num w-14 text-right text-[11px] text-ink-3">#{t.sort}</span>
{!t.is_builtin ? (
<Button variant="ghost" size="icon-sm" aria-label="删除" onClick={() => setDeleting({ id: t.id, value: t.value })}>
<Trash2 size={13} />
</Button>
) : (
<span className="w-7 text-center text-[11px] text-ink-3">内置</span>
)}
</div>
))}
<div className="mt-1 flex items-center gap-2 border-t border-line pt-2">
<Input
className="flex-1"
placeholder="新增条目,回车或点击按钮"
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && draft.trim()) {
upMut.mutate({ kind, value: draft.trim(), sort: (items.at(-1)?.sort ?? 0) + 10 });
setDraft('');
}
}}
/>
<Button
size="sm"
variant="secondary"
disabled={!draft.trim()}
onClick={() => {
upMut.mutate({ kind, value: draft.trim(), sort: (items.at(-1)?.sort ?? 0) + 10 });
setDraft('');
}}
>
<Plus size={13} /> 添加
</Button>
</div>
</div>
<Confirm
open={!!deleting}
title="删除字典条目"
content={deleting ? '确定删除「' + deleting.value + '」吗?' : ''}
onCancel={() => setDeleting(null)}
onOk={() => deleting && delMut.mutate(deleting.id)}
/>
</Card>
);
}
function DimensionCard() {
const qc = useQueryClient();
const toast = useToast();
const dimsQ = useQuery({ queryKey: ['dimensions'], queryFn: listDimensions });
const items = [...(dimsQ.data?.items ?? [])].sort((a, b) => a.sort - b.sort);
const [draft, setDraft] = useState('');
const [deleting, setDeleting] = useState<{ id: number; name: string } | null>(null);
const upMut = useMutation({
mutationFn: upsertDimension,
onSuccess: () => void qc.invalidateQueries({ queryKey: ['dimensions'] })
});
const delMut = useMutation({
mutationFn: (id: number) => deleteDimension(id),
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['dimensions'] }); toast('success', '已删除'); setDeleting(null); },
onError: (e: Error) => { toast('error', e.message); setDeleting(null); }
});
return (
<Card>
<CardHeader title="成长维度" extra={<span className="text-[11px] text-ink-3">如:学业深造 / 职业发展 / 兴趣生活</span>} />
<div className="flex flex-col gap-1 p-3">
{items.map((d, i) => (
<div key={d.id} className="flex items-center gap-2 rounded-md px-1 py-1 hover:bg-surface-2">
<span className="size-3 rounded-full" style={{ backgroundColor: d.color ?? PALETTE[i % PALETTE.length] }} />
<input
className="h-7 flex-1 rounded border border-transparent bg-transparent px-1.5 text-[13px] text-ink-1 hover:border-line focus:border-brand focus:outline-none"
key={d.name}
defaultValue={d.name}
onBlur={e => {
const v = e.target.value.trim();
if (v && v !== d.name) upMut.mutate({ id: d.id, name: v, color: d.color, sort: d.sort });
}}
/>
<Button variant="ghost" size="icon-sm" aria-label="删除" onClick={() => setDeleting({ id: d.id, name: d.name })}>
<Trash2 size={13} />
</Button>
</div>
))}
{!items.length && (
<div className="py-2 text-center text-[12px] text-ink-3">尚未配置维度</div>
)}
<div className="mt-1 flex items-center gap-2 border-t border-line pt-2">
<Input
className="flex-1"
placeholder="新增维度名称"
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && draft.trim()) {
upMut.mutate({ name: draft.trim(), sort: (items.at(-1)?.sort ?? 0) + 10 });
setDraft('');
}
}}
/>
<Button
size="sm"
variant="secondary"
disabled={!draft.trim()}
onClick={() => { upMut.mutate({ name: draft.trim(), sort: (items.at(-1)?.sort ?? 0) + 10 }); setDraft(''); }}
>
<Plus size={13} /> 添加
</Button>
</div>
</div>
<Confirm
open={!!deleting}
title="删除维度"
content={deleting ? '确定删除「' + deleting.name + '」吗?记录与该维度的关联将一并移除。' : ''}
onCancel={() => setDeleting(null)}
onOk={() => deleting && delMut.mutate(deleting.id)}
/>
</Card>
);
}