桌面应用(Tauri+React+SQLite):时间线/记录/学年学科成绩梯度/统计/类型与字段模板/附件内容寻址/详情视图/PWA 同步服务端(sync-server):多用户+邀请码/LWW推拉/seq游标/附件去重/Docker部署 多端同步地基:uuid v7/HLC混合逻辑时钟/软删除墓碑/sync_oplog,Node与Rust双端一致并通过自动化测试
196 lines
8.0 KiB
TypeScript
196 lines
8.0 KiB
TypeScript
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>
|
|
);
|
|
}
|