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:
1 parent
4480fc346d
commit
f0d4935fa7
21 files changed
+688
-78
No files matched your search
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user