Files
UHD_Media_Tool/apps/web/app/routes/console/inbox.tsx
T

641 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useQueryClient } from "@tanstack/react-query";
import type { Decision, DecisionKind, DecisionOption, DecisionStatus } from "@umt/contracts";
import { BellOff, Bot, CheckCheck, ChevronDown, ChevronRight, Inbox, Radio, RotateCw, Search } from "lucide-react";
import { type ReactNode, useId, useMemo, useRef, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { toast } from "sonner";
import { decisionKeys, useDecisionSummary, useDecisions, useResolveDecision } from "~/api/decisions.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { FormField } from "~/components/console/form-field.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { EpisodeDecision } from "~/components/episode-mapping/decision.tsx";
import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx";
import { DECISION_KIND, DECISION_STATUS, opts, SEVERITY } from "~/components/library/labels.ts";
import { Chip, relTime, Tone } from "~/components/library/parts.tsx";
import { type AssignedWork, WorkAssignDialog } from "~/components/library/work-assign-dialog.tsx";
import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { actorText } from "~/lib/labels.ts";
import { humanizeReason } from "~/lib/reasons.ts";
import { useSse, useSseStatus } from "~/lib/sse.ts";
import { formatDateTime } from "~/lib/utils.ts";
// 待处理(REQ §13.1):按问题类型 + 作品 / 来源合并并可折叠;每项给证据、建议、影响范围与预览;
// 处理选项来自后端 decision.options;SSE 收到 decision.* 事件即刷新。
// 缺省只看需要你处理的事项(警告及以上),"仅信息"类在"级别"筛选里展开。身份类事项后端已按来源 + 标题合并,
// 卡片里列出组内资源,选候选 / 指定 / 忽略对整组生效。
const OPEN: DecisionStatus[] = ["open", "investigating", "awaiting_user"];
const STATUS_FILTER = [
{ value: "resolved", label: "已处理" },
{ value: "dismissed", label: "已忽略" },
{ value: "expired", label: "已过期" },
];
const SEV_RANK = { info: 0, warning: 1, critical: 2 } as const;
const SEV_FILTER = [{ value: "all", label: "含仅信息" }];
/** 缺省展开的组数;其余折叠 */
const OPEN_GROUPS = 3;
/** 证据里约定俗成的字段(Agent/后端写入);其余原样放进"原始证据" */
const str = (v: unknown) => (typeof v === "string" && v.trim() ? v : null);
/** 身份类合并事项的来源分组(后端 evidence.group) */
function sourceGroupOf(d: Decision): { siteId: string; siteName: string | null } | null {
const g = d.evidence.group as { siteId?: unknown; siteName?: unknown } | undefined;
return g && typeof g.siteId === "string" ? { siteId: g.siteId, siteName: str(g.siteName) } : null;
}
/** 合并事项的组内资源 */
function GroupReleases({ d }: { d: Decision }) {
const list = Array.isArray(d.evidence.releases)
? (d.evidence.releases as { sourceReleaseId?: string; title?: string }[])
: [];
const count = typeof d.evidence.count === "number" ? d.evidence.count : list.length;
const [open, setOpen] = useState(false);
if (count <= 1) return null;
return (
<div className="mt-2">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="inline-flex cursor-pointer items-center gap-1 text-meta text-muted-foreground hover:text-foreground"
aria-expanded={open}
>
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
{open ? "收起" : "展开"}组内 {count} 条资源
</button>
{open ? (
<ul className="mt-1.5 space-y-1 border-l border-border/60 pl-3">
{list.map((r) => (
<li key={r.sourceReleaseId} className="font-mono text-meta break-all text-muted-foreground">
{r.title}
</li>
))}
{count > list.length ? (
<li className="text-meta text-muted-foreground">
另有 {count - list.length} 条,可在媒体库 → 未识别按标题检索
</li>
) : null}
</ul>
) : null}
</div>
);
}
function entityOf(d: Decision): string | null {
const m = d.evidence.mediaEntityId;
if (typeof m === "string" && /^[0-9a-f-]{36}$/.test(m)) return m;
return d.subjectType === "media_entity" ? d.subjectId : null;
}
const IMPACT_KEY: Record<string, string> = {
slots: "收录版本",
units: "季与集",
files: "文件",
releases: "资源",
tasks: "任务",
entities: "作品",
};
function describe(v: unknown): ReactNode {
if (v === null || v === undefined) return null;
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return String(v);
if (Array.isArray(v)) return v.map((x) => (typeof x === "object" ? JSON.stringify(x) : String(x))).join("、");
return Object.entries(v as Record<string, unknown>)
.map(
([k, x]) =>
`${IMPACT_KEY[k] ?? k} ${Array.isArray(x) ? x.join("、") : typeof x === "object" ? JSON.stringify(x) : String(x)}`,
)
.join(";");
}
function Evidence({ d }: { d: Decision }) {
const e = d.evidence;
const suggestion = str(e.suggestion);
const impact = describe(e.impact);
const preview = describe(e.preview);
const checked = Array.isArray(e.checked) ? (e.checked as unknown[]).map(String) : [];
const rows: [string, ReactNode][] = [];
if (suggestion) rows.push(["建议", suggestion]);
if (impact) rows.push(["影响范围", impact]);
if (preview) rows.push(["预览", preview]);
return (
<div className="space-y-2">
{rows.length ? (
<dl className="grid gap-1.5 text-caption sm:grid-cols-[5rem_1fr]">
{rows.map(([k, v]) => (
<div key={k} className="contents">
<dt className="text-muted-foreground">{k}</dt>
<dd className="min-w-0 break-words text-foreground">{v}</dd>
</div>
))}
</dl>
) : null}
{checked.length ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="inline-flex items-center gap-1 text-meta text-muted-foreground">
<Bot className="h-3.5 w-3.5" />
智能体已检查
</span>
{checked.map((c) => (
<Chip key={c}>{c}</Chip>
))}
</div>
) : null}
<TechInfo
items={[
{ label: "事项 ID", value: d.id },
{ label: "类型", value: d.kind },
]}
>
<JsonView label="详细信息" value={e} defaultOpen={false} maxHeight="14rem" />
</TechInfo>
</div>
);
}
/** 身份类手动指定选项需要的输入(后端在 option.input.requires 标注) */
type Requires = "tmdb" | "mediaEntityId" | null;
function requiresOf(option: DecisionOption): Requires {
const r = option.input?.requires;
return r === "tmdb" || r === "mediaEntityId" ? r : null;
}
function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
const resolve = useResolveDecision();
const uid = useId();
const [note, setNote] = useState("");
const requires = requiresOf(option);
const [tmdbType, setTmdbType] = useState<"movie" | "tv">("movie");
const [tmdbId, setTmdbId] = useState("");
const [entity, setEntity] = useState<PickedEntity | null>(null);
const tmdbOk = /^\d{1,10}$/.test(tmdbId.trim());
const identity =
requires === "tmdb"
? tmdbOk
? { tmdb: { type: tmdbType, id: tmdbId.trim() } }
: null
: requires === "mediaEntityId"
? entity
? { mediaEntityId: entity.id }
: null
: undefined;
return (
<ConfirmDialog
trigger={
<Button variant="outline" size="sm">
{option.label}
</Button>
}
title={option.label}
description={
<>
{option.description ? `${option.description}。` : ""}
{option.capabilityId ? `由能力 ${option.capabilityId} 执行,仍做授权校验。` : null}
</>
}
confirmLabel={option.label}
confirmDisabled={identity === null}
onConfirm={() =>
resolve
.mutateAsync({
id: d.id,
optionKey: option.key,
note: note.trim() || undefined,
...(identity ? { identity } : {}),
})
.then(
() => toast.success(`已处理:${option.label}`),
(err) => {
toast.error(errorMessage(err, "处理失败"));
throw err;
},
)
}
>
{requires === "tmdb" ? (
<div className="grid gap-x-2 sm:grid-cols-[8rem_minmax(0,1fr)]">
<FormField label="类型" htmlFor={`${uid}-type`}>
<Select value={tmdbType} onValueChange={(v) => setTmdbType(v as "movie" | "tv")}>
<SelectTrigger id={`${uid}-type`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="movie">电影</SelectItem>
<SelectItem value="tv">剧集</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField label="TMDB ID" htmlFor={`${uid}-tmdb`} required hint="纯数字,如 872585">
<Input
id={`${uid}-tmdb`}
inputMode="numeric"
value={tmdbId}
onChange={(e) => setTmdbId(e.target.value)}
aria-invalid={tmdbId.trim() !== "" && !tmdbOk}
/>
</FormField>
</div>
) : requires === "mediaEntityId" ? (
<FormField label="本地作品" required>
<EntityPicker value={entity} onChange={setEntity} />
</FormField>
) : null}
<FormField label="备注" htmlFor={`${uid}-note`} hint="可选,记在处理结果里">
<Textarea id={`${uid}-note`} value={note} onChange={(e) => setNote(e.target.value)} rows={2} />
</FormField>
</ConfirmDialog>
);
}
/** 事项里能拿来搜作品的名字:代表资源标题 / 下载名 / 标题冒号后半段 */
function subjectName(d: Decision): string | null {
const e = d.evidence;
const first = Array.isArray(e.releases) ? (e.releases[0] as { title?: unknown } | undefined) : undefined;
return (
str(first?.title) ??
str(e.title) ??
str(e.name) ??
(d.title.includes(":") ? d.title.split(":").slice(1).join(":") : null)
);
}
/** 搜作品用的关键词:去掉扩展名、分隔符、年份之后的规格串 */
function queryOf(name: string | null): string {
if (!name) return "";
const base = name.replace(/\.(mkv|mp4|ts|m2ts|iso|avi)$/i, "").replace(/[._]+/g, " ");
const m = base.match(/^(.*?)(?:\b(19|20)\d{2}\b|\bS\d{1,2}E?\d{0,3}\b|\b(2160|1080|720)p\b)/i);
return (m?.[1]?.trim() || base)
.replace(/[[\]()]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 60);
}
const IDENTITY_KINDS: DecisionKind[] = ["identity_ambiguous", "identity_unresolved"];
/** 「没识别出作品」的主操作:搜作品并指定。资源类事项走 resolve(本地作品 / TMDB),整理类事项打开重新整理 */
function AssignWorkButton({ d }: { d: Decision }) {
const resolve = useResolveDecision();
const [open, setOpen] = useState(false);
const [wizard, setWizard] = useState(false);
const local = d.options.find((o) => o.input?.requires === "mediaEntityId");
const tmdb = d.options.find((o) => o.input?.requires === "tmdb");
const recordId = d.subjectType === "organization_record" ? d.subjectId : null;
if (!local && !tmdb && !recordId) return null;
const name = subjectName(d);
const assign = (w: AssignedWork) => {
const option = "mediaEntityId" in w ? local : tmdb;
if (!option) {
toast.error("mediaEntityId" in w ? "这条事项只能按 TMDB 指定" : "这条事项只能选媒体库里的作品");
return Promise.reject(new Error("unsupported"));
}
return resolve
.mutateAsync({
id: d.id,
optionKey: option.key,
identity: "mediaEntityId" in w ? { mediaEntityId: w.mediaEntityId } : { tmdb: w.tmdb },
})
.then(
() => toast.success(`已指定为「${w.title}」`),
(err) => {
toast.error(errorMessage(err, "指定失败"));
throw err;
},
);
};
return (
<>
<Button size="sm" onClick={() => (recordId && !local && !tmdb ? setWizard(true) : setOpen(true))}>
<Search />
指定作品
</Button>
<WorkAssignDialog
open={open}
onOpenChange={setOpen}
subject={name}
initialQuery={queryOf(name)}
onAssign={assign}
/>
{wizard && recordId ? (
<OrganizeWizard source={{ kind: "record", recordId }} onClose={() => setWizard(false)} />
) : null}
</>
);
}
function DismissButton({ d }: { d: Decision }) {
const resolve = useResolveDecision();
return (
<ConfirmDialog
trigger={
<Button variant="ghost" size="sm">
<BellOff />
忽略
</Button>
}
title="忽略这条事项?"
description="只关闭提醒,问题本身不变;同一根因出现新证据时会重新提出。"
confirmLabel="忽略"
onConfirm={() =>
resolve.mutateAsync({ id: d.id, dismiss: true }).then(
() => toast.success("已忽略"),
(err) => {
toast.error(errorMessage(err, "操作失败"));
throw err;
},
)
}
/>
);
}
function DecisionCard({ d }: { d: Decision }) {
const open = OPEN.includes(d.status);
const identity = IDENTITY_KINDS.includes(d.kind);
// 集数待确认:证据与「确认 / 修改 / 忽略」由 EpisodeDecision 渲染
const episode = d.kind === "episode_mapping";
// 身份类:手动 TMDB / 选本地作品两个选项并进「指定作品」
const options = d.options.filter(
(o) =>
o.key !== "dismiss" && !(identity && (o.input?.requires === "tmdb" || o.input?.requires === "mediaEntityId")),
);
const resolution = d.resolution as { optionKey?: string | null; note?: string | null } | null;
return (
<article className="min-w-0 border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5">
<div className="flex flex-wrap items-center gap-2">
{d.severity === "critical" ? <Tone v={SEVERITY[d.severity]} /> : null}
{d.status === "investigating" ? <Tone v={DECISION_STATUS[d.status]} /> : null}
<span className="ml-auto text-meta text-muted-foreground tabular-nums">
{relTime(d.createdAt)}
{d.attempts ? ` · 调查 ${d.attempts} 次` : ""}
</span>
</div>
<h3 className="mt-2 text-body font-medium break-words text-foreground">{d.title}</h3>
{d.summary ? (
<p className="mt-1 text-caption break-words text-muted-foreground">{humanizeReason(d.summary)}</p>
) : null}
{episode ? null : <GroupReleases d={d} />}
{episode ? (
<div className="mt-3">
<EpisodeDecision d={d} open={open} />
</div>
) : null}
<div className="mt-3">
<Evidence d={d} />
</div>
{open && !episode ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
{identity ? <AssignWorkButton d={d} /> : null}
{options.map((o) => (
<OptionButton key={o.key} d={d} option={o} />
))}
<DismissButton d={d} />
{d.agentRunId ? (
<Link
to={consolePath("agent", d.agentRunId)}
className="ml-auto inline-flex items-center text-meta text-muted-foreground hover:text-foreground hover:underline"
>
智能体调查记录
<ChevronRight className="h-3 w-3" />
</Link>
) : null}
</div>
) : open ? null : (
<p className="mt-3 text-meta text-muted-foreground">
{d.resolvedBy ? `${actorText(d.resolvedBy)} ` : ""}于 {formatDateTime(d.resolvedAt)}
{resolution?.optionKey
? ` 选择「${d.options.find((o) => o.key === resolution.optionKey)?.label ?? resolution.optionKey}」`
: " 关闭"}
{resolution?.note ? `:${resolution.note}` : ""}
</p>
)}
</article>
);
}
type Group = {
key: string;
entityId: string | null;
source: { siteId: string; siteName: string | null } | null;
kind: DecisionKind;
items: Decision[];
};
export default function InboxPage() {
const [sp, setSp] = useSearchParams();
const status = (sp.get("status") ?? "") as DecisionStatus | "";
const kind = (sp.get("kind") ?? "") as DecisionKind | "";
const sev = sp.get("sev") ?? "";
const minSeverity = sev === "all" ? undefined : "warning";
const set = (k: string, v: string) =>
setSp(
(prev) => {
const n = new URLSearchParams(prev);
if (v) n.set(k, v);
else n.delete(k);
return n;
},
{ replace: true },
);
const summary = useDecisionSummary(status || undefined, minSeverity);
const list = useDecisions({ status: status || undefined, kind: kind || undefined, minSeverity });
const rows = list.data?.pages.flatMap((p) => p.items);
const paged = useListPagination(rows, {
hasMore: list.hasNextPage,
onLoadMore: () => list.fetchNextPage(),
loadingMore: list.isFetchingNextPage,
error: list.error,
resetKey: JSON.stringify({ status, kind, minSeverity }),
});
// SSE:decision.created / decision.updated → 刷新(合并 500ms 内的多条事件)
const qc = useQueryClient();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useSse(["decision.created", "decision.updated"], () => {
if (timer.current) return;
timer.current = setTimeout(() => {
timer.current = null;
void qc.invalidateQueries({ queryKey: decisionKeys.all });
}, 500);
});
const live = useSseStatus();
const titles = useMemo(() => {
const m = new Map<string, string>();
for (const g of summary.data?.groups ?? [])
if (g.mediaEntityId && g.mediaEntityTitle) m.set(g.mediaEntityId, g.mediaEntityTitle);
return m;
}, [summary.data]);
const byKind = useMemo(() => {
const m = new Map<DecisionKind, { count: number; sev: keyof typeof SEV_RANK }>();
for (const g of summary.data?.groups ?? []) {
const cur = m.get(g.kind);
m.set(g.kind, {
count: (cur?.count ?? 0) + g.count,
sev: cur && SEV_RANK[cur.sev] > SEV_RANK[g.maxSeverity] ? cur.sev : g.maxSeverity,
});
}
return [...m.entries()].sort((a, b) => SEV_RANK[b[1].sev] - SEV_RANK[a[1].sev] || b[1].count - a[1].count);
}, [summary.data]);
// 按问题类型 + 作品 / 来源合并;组内按严重度、时间
const groups = useMemo(() => {
const m = new Map<string, Group>();
for (const d of paged.items ?? []) {
const entityId = entityOf(d);
const source = entityId ? null : sourceGroupOf(d);
const key = `${entityId ?? (source ? `site:${source.siteId}` : `subject:${d.subjectType ?? "none"}`)}|${d.kind}`;
const g = m.get(key) ?? { key, entityId, source, kind: d.kind, items: [] };
g.items.push(d);
m.set(key, g);
}
const maxSev = (g: Group) => Math.max(...g.items.map((d) => SEV_RANK[d.severity]));
return [...m.values()].sort((a, b) => maxSev(b) - maxSev(a) || b.items.length - a.items.length);
}, [paged.items]);
const isOpenView = !status;
// 折叠状态:缺省前 OPEN_GROUPS 组展开;用户点过的组按用户的来
const [toggled, setToggled] = useState<Record<string, boolean>>({});
const isExpanded = (key: string, index: number) => toggled[key] ?? index < OPEN_GROUPS;
return (
<>
<PageHeader
title="待处理"
description="需要你决定的事"
actions={
<>
{live === "reconnecting" ? (
<span className="hidden items-center gap-1.5 text-meta text-warning sm:inline-flex">
<Radio className="h-3.5 w-3.5" />
重连中
</span>
) : null}
<Button
variant="outline"
size="sm"
onClick={() => void qc.invalidateQueries({ queryKey: decisionKeys.all })}
>
<RotateCw className={list.isFetching ? "animate-spin" : undefined} />
刷新
</Button>
</>
}
/>
{summary.isError ? (
<ErrorState className="mb-4" title="汇总加载失败" error={summary.error} onRetry={() => summary.refetch()} />
) : (
<fieldset className="mb-4 flex min-w-0 flex-wrap items-center gap-1.5 border-0 p-0" aria-label="按问题类型筛选">
<Button variant={kind ? "ghost" : "secondary"} size="sm" onClick={() => set("kind", "")} aria-pressed={!kind}>
<Inbox />
{isOpenView ? "全部" : (DECISION_STATUS[status as DecisionStatus]?.label ?? "全部")}
<span className="tabular-nums">{summary.isPending ? "…" : (summary.data?.total ?? 0)}</span>
</Button>
{byKind.map(([k, v]) => (
<Button
key={k}
variant={kind === k ? "secondary" : "ghost"}
size="sm"
aria-pressed={kind === k}
onClick={() => set("kind", kind === k ? "" : k)}
className={v.sev === "critical" ? "text-destructive" : undefined}
>
{DECISION_KIND[k]}
<span className="tabular-nums">{v.count}</span>
</Button>
))}
</fieldset>
)}
<FilterBar onReset={status || kind || sev ? () => setSp(new URLSearchParams(), { replace: true }) : undefined}>
<FilterSelect
label="状态"
value={status}
onChange={(v) => set("status", v)}
options={STATUS_FILTER}
allLabel="未关闭"
/>
<FilterSelect label="问题类型" value={kind} onChange={(v) => set("kind", v)} options={opts(DECISION_KIND)} />
<FilterSelect label="级别" value={sev} onChange={(v) => set("sev", v)} options={SEV_FILTER} allLabel="需处理" />
</FilterBar>
{list.isPending ? (
<div className="pg-slab rounded-xl p-4">
<LoadingState rows={4} />
</div>
) : list.isError && !rows ? (
<ErrorState error={list.error} onRetry={() => list.refetch()} />
) : groups.length === 0 ? (
<EmptyState
icon={CheckCheck}
title={isOpenView ? "没有需要你处理的事项" : "没有符合条件的事项"}
description={isOpenView ? "新事项会实时出现在这里。" : undefined}
/>
) : (
<div className="space-y-4">
{groups.map((g, index) => {
const title = g.entityId ? titles.get(g.entityId) : null;
const expanded = isExpanded(g.key, index);
const maxSev = g.items.reduce<keyof typeof SEV_RANK>(
(a, d) => (SEV_RANK[d.severity] > SEV_RANK[a] ? d.severity : a),
"info",
);
return (
<section key={g.key} className="pg-slab min-w-0 overflow-hidden rounded-xl">
<header
className={`flex flex-wrap items-center gap-2 px-4 py-3 sm:px-5 ${expanded ? "border-b border-border/60" : ""}`}
>
<button
type="button"
onClick={() => setToggled((t) => ({ ...t, [g.key]: !expanded }))}
className="inline-flex cursor-pointer items-center text-muted-foreground hover:text-foreground"
aria-expanded={expanded}
aria-label={expanded ? "折叠" : "展开"}
>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
<StatusBadge tone={SEVERITY[maxSev].tone}>{DECISION_KIND[g.kind]}</StatusBadge>
{g.entityId ? (
<Link
to={consolePath("library", g.entityId)}
className="min-w-0 truncate text-body font-semibold text-foreground hover:underline"
>
{title ?? "关联作品"}
</Link>
) : g.source ? (
<span className="min-w-0 truncate text-body font-semibold text-foreground">
来源「{g.source.siteName ?? "未知站点"}」
</span>
) : (
<span className="text-body font-semibold text-muted-foreground">
{IDENTITY_KINDS.includes(g.kind) ? "未关联作品" : "其他"}
</span>
)}
<span className="ml-auto text-meta text-muted-foreground">{g.items.length} 项</span>
</header>
{expanded ? g.items.map((d) => <DecisionCard key={d.id} d={d} />) : null}
</section>
);
})}
</div>
)}
{rows?.length || list.hasNextPage ? <Pagination {...paged.pagination} /> : null}
</>
);
}