264 lines
12 KiB
TypeScript
264 lines
12 KiB
TypeScript
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 {
|
|
Badge, 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-3 py-4 sm:px-6 sm: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)}
|
|
/>
|
|
<div className="grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto sm:items-center sm:gap-2">
|
|
<Select className="w-full sm: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-full sm: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-full sm: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-full sm: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-full sm: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-full sm: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: '重要度优先' }
|
|
]} />
|
|
</div>
|
|
<div className="flex w-full items-center justify-between gap-2 sm:contents">
|
|
<span className="num text-[12px] text-ink-3 sm:ml-auto">共 {total} 条</span>
|
|
<Button variant="primary" size="md" onClick={openNew}>
|
|
<Plus size={14} />
|
|
新建
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="hidden rounded-lg border border-line bg-surface shadow-card md:block">
|
|
<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>
|
|
|
|
{/* 手机端:卡片列表(表格在窄屏不可用) */}
|
|
<div className="divide-y divide-line rounded-lg border border-line bg-surface md:hidden">
|
|
{(listQ.data?.items ?? []).map(row => (
|
|
<button key={row.id} onClick={() => openDetail(row.id)} className="block w-full px-3 py-3 text-left transition-colors active:bg-surface-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: row.type_color }} />
|
|
<span className="min-w-0 flex-1 truncate text-[13.5px] font-medium text-ink-1">{row.title}</span>
|
|
{row.attachment_count > 0 && <span className="num shrink-0 text-[11px] text-ink-3">{row.attachment_count} 附件</span>}
|
|
</div>
|
|
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
|
<Badge color={row.type_color}>{row.type_name}</Badge>
|
|
<StatusBadge status={row.status} />
|
|
{row.level && <LevelBadge level={row.level} />}
|
|
{row.award_grade && <GradeBadge grade={row.award_grade} />}
|
|
</div>
|
|
<div className="num mt-1 text-[11.5px] text-ink-3">{fmtRange(row.start_date, row.end_date)}</div>
|
|
</button>
|
|
))}
|
|
{!listQ.isLoading && !listQ.data?.items.length && (
|
|
<Empty
|
|
title="没有匹配的记录"
|
|
description="调整筛选条件,或新建一条记录。"
|
|
action={<Button variant="primary" onClick={openNew}><Plus size={14} /> 新建记录</Button>}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{total > 0 && (
|
|
<div className="flex h-11 items-center justify-end gap-3 border-t border-line bg-surface 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>
|
|
);
|
|
}
|