Files
UHD_Media_Tool/apps/web/app/routes/console/inbox.tsx
T
rootandClaude Opus 5.5 ceb66f8c77 feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)
生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。

- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
  PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
  (metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)

同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
  由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距

依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 15:47:17 +08:00

633 lines
25 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 { 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);
// 身份类:手动 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}
<GroupReleases d={d} />
<div className="mt-3">
<Evidence d={d} />
</div>
{open ? (
<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>
) : (
<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}
</>
);
}