feat(web): 技术信息折叠、原因码人话化、待处理「指定作品」(DEAI-2)

- 新增 TechInfo 折叠组件(默认收起,标题「技术信息」);设置新增「界面」分区,
  「默认展开技术信息」开关存本机浏览器
- 原因码表 @umt/contracts reasons.ts(identity_unknown、blocked:target_exists、below_min_size…
  → 一句人话 + 下一步),服务端整理记录 message 与冲突事项标题/摘要改用它
- 整理记录列表/详情、下载详情与时间线:原码、ID、hash、用时、变更序号进技术信息;
  下载完成但入库失败时单独提示;时间线只列有记录的阶段,标题改「经过」
- 待处理:没识别出作品类事项主按钮「指定作品」(搜媒体库 + TMDB,走已有 resolve;
  整理类事项打开重新整理),忽略为次要;去掉正常态徽章与「实时」指示;原始证据改「详细信息」进技术信息
- 整理成功后同时关闭该下载的「没识别出作品」事项

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 20:42:27 +08:00
1 parent 4480fc346d
commit f0d4935fa7
21 files changed
+688 -78

No files matched your search

+106 -12
View File
@@ -1,6 +1,6 @@
import { useQueryClient } from "@tanstack/react-query";
import type { Decision, DecisionKind, DecisionOption, DecisionStatus } from "@umt/contracts";
import { BellOff, Bot, CheckCheck, ChevronDown, ChevronRight, Inbox, Radio, RotateCw } from "lucide-react";
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";
@@ -14,9 +14,12 @@ import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatTile } from "~/components/console/stat-tile.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
@@ -78,7 +81,7 @@ function GroupReleases({ d }: { d: Decision }) {
))}
{count > list.length ? (
<li className="text-meta text-muted-foreground">
另有 {count - list.length} 条,可在媒体概览 → 未识别按标题检索
另有 {count - list.length} 条,可在媒体库 → 未识别按标题检索
</li>
) : null}
</ul>
@@ -147,7 +150,14 @@ function Evidence({ d }: { d: Decision }) {
))}
</div>
) : null}
<JsonView label="原始证据" value={e} defaultOpen={false} maxHeight="14rem" />
<TechInfo
items={[
{ label: "事项 ID", value: d.id },
{ label: "类型", value: d.kind },
]}
>
<JsonView label="详细信息" value={e} defaultOpen={false} maxHeight="14rem" />
</TechInfo>
</div>
);
}
@@ -246,6 +256,82 @@ function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
);
}
/** 事项里能拿来搜作品的名字:代表资源标题 / 下载名 / 标题冒号后半段 */
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 (
@@ -274,13 +360,18 @@ function DismissButton({ d }: { d: Decision }) {
function DecisionCard({ d }: { d: Decision }) {
const open = OPEN.includes(d.status);
const options = d.options.filter((o) => o.key !== "dismiss");
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">
<Tone v={SEVERITY[d.severity]} />
<Tone v={DECISION_STATUS[d.status]} />
{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} 次` : ""}
@@ -294,6 +385,7 @@ function DecisionCard({ d }: { d: Decision }) {
</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} />
))}
@@ -404,13 +496,15 @@ export default function InboxPage() {
<>
<PageHeader
title="待处理"
description="智能体调查和重试后仍未解决的事项,按作品和问题类型合并"
description="需要你决定的事"
actions={
<>
<span className="hidden items-center gap-1.5 text-meta text-muted-foreground sm:inline-flex">
<Radio className="h-3.5 w-3.5" />
{live === "open" ? "实时" : live === "reconnecting" ? "重连中" : "连接中"}
</span>
{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"
@@ -510,7 +604,7 @@ export default function InboxPage() {
</span>
) : (
<span className="text-body font-semibold text-muted-foreground">
{g.kind === "identity_ambiguous" || g.kind === "identity_unresolved" ? "尚未归属作品" : "系统级"}
{IDENTITY_KINDS.includes(g.kind) ? "未关联作品" : "其他"}
</span>
)}
<span className="ml-auto text-meta text-muted-foreground">{g.items.length} 项</span>
+36 -33
View File
@@ -8,9 +8,11 @@ import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
import { Button } from "~/components/ui/button.tsx";
import { actorTypeLabel } from "~/lib/labels.ts";
import { reasonCode, reasonLine } from "~/lib/reasons.ts";
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
const ACTION_LABEL = {
@@ -23,7 +25,6 @@ const ACTION_LABEL = {
delete: "永久删除",
restore: "从回收站还原",
} as const;
const VIEW_LABEL = { downloader: "下载器视角", local: "执行进程本地", library: "媒体库目录相对" } as const;
const FILE_STATUS: Record<PublicOrganizationFile["status"], { label: string; tone: StatusTone }> = {
queued: { label: "排队", tone: "neutral" },
running: { label: "执行中", tone: "neutral" },
@@ -42,22 +43,14 @@ const ROLE_LABEL = {
other: "其他",
} as const;
/** 稳定 code 与说明不同时才附上 code */
function reasonText(r: { code: string; message: string }) {
return r.code && r.code !== r.message && /^[a-z0-9_.-]+$/i.test(r.code) ? `${r.message}(${r.code})` : r.message;
}
/** failed:失败且没有目标路径时直接说"未生成目标路径",不再显示视角与"未知或未授权" */
/** 路径:失败且没有目标路径时写"未生成目标路径",其余没有路径的写"未整理" */
function PathBlock({ label, p, failed = false }: { label: string; p: PublicPathRef; failed?: boolean }) {
const noTarget = failed && p.path === null;
return (
<div className="min-w-0 flex-1 rounded-lg border border-border/70 px-3 py-2">
<p className="text-meta text-muted-foreground">
{label}
{noTarget ? "" : ` · ${VIEW_LABEL[p.view]}`}
</p>
<p className="text-meta text-muted-foreground">{label}</p>
<p className="mt-0.5 font-mono text-caption break-all text-foreground">
{p.path ?? (noTarget ? "未生成目标路径" : "未知或未授权")}
{p.path ?? (noTarget ? "未生成目标路径" : "未整理")}
</p>
</div>
);
@@ -107,13 +100,19 @@ function FileRow({ f }: { f: PublicOrganizationFile }) {
</div>
{reason ? (
<p className={cn("text-caption break-words", f.failure ? "text-destructive" : "text-muted-foreground")}>
{f.failure ? "失败" : "跳过"}原因:{reasonText(reason)}
{f.failure ? "失败" : "跳过"}原因:{reasonLine(reason)}
</p>
) : null}
<p className="text-meta text-muted-foreground">
{ACTION_LABEL[f.mode]} · {formatDateTime(f.startedAt)}
{duration(f.startedAt, f.finishedAt) ? ` · 用时 ${duration(f.startedAt, f.finishedAt)}` : ""}
</p>
<TechInfo
items={[
{ label: "原因码", value: reasonCode(reason) },
{ label: "用时", value: duration(f.startedAt, f.finishedAt), mono: false },
{ label: "文件结果 ID", value: f.id },
]}
/>
</div>
) : null}
</li>
@@ -153,7 +152,7 @@ export default function OrganizeRecordPage() {
size="sm"
onClick={() =>
openAgentDrawer({
label: `整理记录:${r.media?.title ?? r.id.slice(0, 8)}`,
label: `整理记录:${r.media?.title ?? "未关联作品"}`,
recordId: r.id,
entityId: r.media?.entityId,
goal: "排查这次整理为什么没有全部成功,给出恢复办法",
@@ -176,15 +175,17 @@ export default function OrganizeRecordPage() {
) : (
<div className="space-y-4">
<section className="pg-slab space-y-4 rounded-xl p-4 md:p-5">
<div className="flex flex-wrap items-center gap-1.5">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
<StatusBadge>{ACTION_LABEL[r.action]}</StatusBadge>
<StatusBadge>
{TRIGGER_LABEL[r.trigger]}
{r.trigger === "agent" ? "(计为自动)" : ""}
</StatusBadge>
<span className="text-meta text-muted-foreground tabular-nums">
{r.counts.succeeded}/{r.counts.total} 成功 · 失败 {r.counts.failed} · 跳过 {r.counts.skipped}
{[
`${TRIGGER_LABEL[r.trigger]}${ACTION_LABEL[r.action]}`,
r.counts.total ? `${r.counts.succeeded}/${r.counts.total} 个文件成功` : null,
r.counts.failed ? `${r.counts.failed} 个失败` : null,
r.counts.skipped ? `${r.counts.skipped} 个跳过` : null,
]
.filter(Boolean)
.join(" · ")}
</span>
</div>
{r.reason ? (
@@ -196,7 +197,7 @@ export default function OrganizeRecordPage() {
: "border-warning/40 bg-warning/10 text-warning",
)}
>
{reasonText(r.reason)}
{reasonLine(r.reason)}
</p>
) : null}
<div className="flex flex-col gap-2 md:flex-row md:items-center">
@@ -208,19 +209,21 @@ export default function OrganizeRecordPage() {
items={[
{ label: "开始", value: formatDateTime(r.startedAt) },
{ label: "完成", value: formatDateTime(r.finishedAt) },
{ label: "用时", value: duration(r.startedAt, r.finishedAt) },
{ label: "操作者", value: actorTypeLabel(r.actor.type) },
]}
/>
<TechInfo
items={[
{ label: "原因码", value: reasonCode(r.reason) },
{ label: "用时", value: duration(r.startedAt, r.finishedAt), mono: false },
{
label: "触发者",
value: `${actorTypeLabel(r.actor.type)}${r.actor.id && r.actor.type !== "system" ? ` · ${r.actor.id}` : ""}`,
},
{
label: "外部身份",
label: "外部编号",
value: r.media?.externalIds.map((e) => `${e.provider}:${e.entityType}:${e.externalId}`).join(", "),
mono: true,
},
{ label: "变更游标", value: r.changeSeq, mono: true },
{ label: "记录 ID", value: r.id, mono: true, wide: true },
{ label: "任务 ID", value: r.taskId, mono: true, wide: true },
{ label: "操作者 ID", value: r.actor.type !== "system" ? r.actor.id : null },
{ label: "变更序号", value: r.changeSeq },
{ label: "记录 ID", value: r.id, wide: true },
{ label: "任务 ID", value: r.taskId, wide: true },
]}
/>
{r.media ? (
+2 -2
View File
@@ -12,6 +12,7 @@ import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { reasonLine } from "~/lib/reasons.ts";
import { useSse } from "~/lib/sse.ts";
const ACTION_LABEL = {
@@ -63,7 +64,7 @@ const COLUMNS: DataColumn<PublicOrganizationRecord>[] = [
<div className="flex flex-col items-start gap-0.5">
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
{r.reason ? (
<span className="line-clamp-1 max-w-48 text-meta text-muted-foreground">{r.reason.message}</span>
<span className="line-clamp-1 max-w-48 text-meta text-muted-foreground">{reasonLine(r.reason)}</span>
) : null}
</div>
),
@@ -120,7 +121,6 @@ export default function OrganizePage() {
<>
<PageHeader
title="整理记录"
description="每次整理的前后路径与逐文件结果,自动和手动整理都记在这里。"
actions={
<Button size="sm" onClick={() => navigate("/console/files")}>
<FolderInput />
+1 -1
View File
@@ -59,7 +59,7 @@ export default function SettingsPage() {
return (
<>
<PageHeader title="设置" description="全局参数、实例与凭证。凭证保存后不回显。" />
<PageHeader title="设置" description="密钥保存后不再显示。" />
<div className="grid gap-6 lg:grid-cols-[13.5rem_minmax(0,1fr)] lg:gap-8">
{/* 窄屏:顶部下拉 */}
<div className="lg:hidden">