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