Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
641 lines
26 KiB
TypeScript
641 lines
26 KiB
TypeScript
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}
|
||
</>
|
||
);
|
||
}
|