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,232 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { MoreHorizontal, Pencil, Trash2, Plus } from 'lucide-react';
|
||||
import {
|
||||
footprintDelete, footprintList, listTaxonomy, listTypes, statsOverview
|
||||
} from '@/lib/api';
|
||||
import type { FootprintRow } from '@/lib/types';
|
||||
import {
|
||||
Button, Confirm, Dropdown, DropdownItem, Empty, Input, Select, Table, TableWrap, Td, Th, Tr, useToast
|
||||
} from '@/components/ui';
|
||||
import { StatusBadge, LevelBadge, RoleBadge, GradeBadge } from '@/components/common/badges';
|
||||
import { RecordEditorDialog } from '@/components/records/RecordEditorDialog';
|
||||
import { RecordDetailDialog } from '@/components/records/RecordDetailDialog';
|
||||
import { fmtRange, fmtDateTime } from '@/lib/format';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export function RecordsPage() {
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [params, setParams] = useSearchParams();
|
||||
|
||||
const [q, setQ] = useState(params.get('q') ?? '');
|
||||
const [typeId, setTypeId] = useState('');
|
||||
const [year, setYear] = useState('');
|
||||
const [level, setLevel] = useState('');
|
||||
const [role, setRole] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [sort, setSort] = useState('date_desc');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState<FootprintRow | null>(null);
|
||||
const [detailId, setDetailId] = useState<number | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
// 新建入口:/records?new=1
|
||||
useEffect(() => {
|
||||
if (params.get('new') === '1') {
|
||||
setEditingId(null);
|
||||
setEditorOpen(true);
|
||||
params.delete('new');
|
||||
setParams(params, { replace: true });
|
||||
}
|
||||
}, [params, setParams]);
|
||||
|
||||
const typesQ = useQuery({ queryKey: ['types'], queryFn: listTypes });
|
||||
const taxQ = useQuery({ queryKey: ['taxonomy-all'], queryFn: () => listTaxonomy() });
|
||||
const yearsQ = useQuery({ queryKey: ['stats', undefined], queryFn: () => statsOverview(undefined) });
|
||||
|
||||
const query = useMemo(() => ({
|
||||
q: q || undefined,
|
||||
types: typeId ? [Number(typeId)] : undefined,
|
||||
levels: level ? [level] : undefined,
|
||||
roles: role ? [role] : undefined,
|
||||
statuses: status ? [status] : undefined,
|
||||
year: year ? Number(year) : undefined,
|
||||
sort,
|
||||
page,
|
||||
page_size: PAGE_SIZE
|
||||
}), [q, typeId, year, level, role, status, sort, page]);
|
||||
|
||||
const listQ = useQuery({ queryKey: ['footprints', query], queryFn: () => footprintList(query) });
|
||||
|
||||
useEffect(() => { setPage(1); }, [q, typeId, year, level, role, status]);
|
||||
|
||||
const delMut = useMutation({
|
||||
mutationFn: (id: number) => footprintDelete(id),
|
||||
onSuccess: () => {
|
||||
toast('success', '记录已删除');
|
||||
void qc.invalidateQueries({ queryKey: ['footprints'] });
|
||||
void qc.invalidateQueries({ queryKey: ['timeline'] });
|
||||
void qc.invalidateQueries({ queryKey: ['stats'] });
|
||||
setDeleting(null);
|
||||
}
|
||||
});
|
||||
|
||||
const total = listQ.data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const openNew = () => { setEditingId(null); setEditorOpen(true); };
|
||||
const openEdit = (id: number) => { setEditingId(id); setEditorOpen(true); };
|
||||
const openDetail = (id: number) => { setDetailId(id); setDetailOpen(true); };
|
||||
|
||||
return (
|
||||
<div className="px-6 py-5">
|
||||
<div className="mb-3 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-[110px]" value={year} onChange={e => setYear(e.target.value)}
|
||||
options={[{ value: '', label: '全部年份' }, ...(yearsQ.data?.years ?? []).map(y => ({ value: String(y), label: y + ' 年' }))]} />
|
||||
<Select className="w-[110px]" value={level} onChange={e => setLevel(e.target.value)}
|
||||
options={[{ value: '', label: '全部级别' }, ...(taxQ.data?.items ?? []).filter(t => t.kind === 'level').map(t => ({ value: t.value, label: t.value }))]} />
|
||||
<Select className="w-[100px]" value={role} onChange={e => setRole(e.target.value)}
|
||||
options={[{ value: '', label: '全部角色' }, ...(taxQ.data?.items ?? []).filter(t => t.kind === 'role').map(t => ({ value: t.value, label: t.value }))]} />
|
||||
<Select className="w-[110px]" value={status} onChange={e => setStatus(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'draft', label: '草稿' },
|
||||
{ value: 'ongoing', label: '进行中' },
|
||||
{ value: 'submitted', label: '已提交待果' },
|
||||
{ value: 'finished', label: '已完结' }
|
||||
]} />
|
||||
<Select className="w-[130px]" value={sort} onChange={e => setSort(e.target.value)}
|
||||
options={[
|
||||
{ value: 'date_desc', label: '时间从新到旧' },
|
||||
{ value: 'date_asc', label: '时间从旧到新' },
|
||||
{ value: 'updated_desc', label: '最近修改' },
|
||||
{ value: 'weight_desc', label: '重要度优先' }
|
||||
]} />
|
||||
<span className="num ml-auto text-[12px] text-ink-3">共 {total} 条</span>
|
||||
<Button variant="primary" size="md" onClick={openNew}>
|
||||
<Plus size={14} />
|
||||
新建
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-line bg-surface shadow-card">
|
||||
<TableWrap className="max-h-[calc(100vh-260px)] overflow-y-auto">
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th className="w-[32%]">标题</Th>
|
||||
<Th>类型</Th>
|
||||
<Th>时间</Th>
|
||||
<Th>级别</Th>
|
||||
<Th>奖等</Th>
|
||||
<Th>角色</Th>
|
||||
<Th>状态</Th>
|
||||
<Th className="text-right">附件</Th>
|
||||
<Th>更新时间</Th>
|
||||
<Th className="w-[52px]" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(listQ.data?.items ?? []).map(row => (
|
||||
<Tr key={row.id} onClick={() => openDetail(row.id)}>
|
||||
<Td>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: row.type_color }} />
|
||||
<span className="truncate font-medium" title={row.title}>{row.title}</span>
|
||||
</div>
|
||||
</Td>
|
||||
<Td className="whitespace-nowrap text-ink-2">{row.type_name}</Td>
|
||||
<Td className="num whitespace-nowrap text-ink-2">{fmtRange(row.start_date, row.end_date)}</Td>
|
||||
<Td><LevelBadge level={row.level} /></Td>
|
||||
<Td><GradeBadge grade={row.award_grade} /></Td>
|
||||
<Td><RoleBadge role={row.role} /></Td>
|
||||
<Td><StatusBadge status={row.status} /></Td>
|
||||
<Td className="num text-right text-ink-2">{row.attachment_count > 0 ? row.attachment_count : ''}</Td>
|
||||
<Td className="num whitespace-nowrap text-ink-3">{fmtDateTime(row.updated_at)}</Td>
|
||||
<Td onClick={e => e.stopPropagation()}>
|
||||
<Dropdown
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" aria-label="更多操作">
|
||||
<MoreHorizontal size={15} />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{close => (
|
||||
<>
|
||||
<DropdownItem onClick={() => { close(); openEdit(row.id); }}>
|
||||
<Pencil size={13} /> 编辑
|
||||
</DropdownItem>
|
||||
<DropdownItem danger onClick={() => { close(); setDeleting(row); }}>
|
||||
<Trash2 size={13} /> 删除
|
||||
</DropdownItem>
|
||||
</>
|
||||
)}
|
||||
</Dropdown>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
{!listQ.isLoading && !listQ.data?.items.length && (
|
||||
<Empty
|
||||
title="没有匹配的记录"
|
||||
description="调整筛选条件,或新建一条记录。"
|
||||
action={
|
||||
<Button variant="primary" onClick={openNew}>
|
||||
<Plus size={14} /> 新建记录
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TableWrap>
|
||||
|
||||
{total > 0 && (
|
||||
<div className="flex h-11 items-center justify-end gap-3 border-t border-line px-4 text-[12px] text-ink-3">
|
||||
<span className="num">第 {page} / {totalPages} 页</span>
|
||||
<Button variant="secondary" size="sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</Button>
|
||||
<Button variant="secondary" size="sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RecordDetailDialog
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
footprintId={detailId}
|
||||
onEdit={id => {
|
||||
setDetailOpen(false);
|
||||
openEdit(id);
|
||||
}}
|
||||
/>
|
||||
|
||||
<RecordEditorDialog
|
||||
open={editorOpen}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
footprintId={editingId}
|
||||
/>
|
||||
|
||||
<Confirm
|
||||
open={!!deleting}
|
||||
title="删除记录"
|
||||
content={deleting ? '确定删除「' + deleting.title + '」吗?该操作不可撤销。' : ''}
|
||||
onCancel={() => setDeleting(null)}
|
||||
onOk={() => deleting && delMut.mutate(deleting.id)}
|
||||
loading={delMut.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user