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

@@ -0,0 +1,53 @@
import { ChevronDown } from "lucide-react";
import { type ReactNode, useId, useState } from "react";
import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx";
import { type KvItem, KvList } from "~/components/console/kv-list.tsx";
import { useUiPrefs } from "~/lib/ui-prefs.ts";
import { cn } from "~/lib/utils.ts";
/**
* 技术信息折叠区:ID、hash、trace、token 数、耗时、模型 ID、原始字段、内部状态码都放这里,默认收起
* (设置 → 界面可改为默认展开)。值为空的项不显示;全部为空时整块不渲染。
*/
export function TechInfo({
items,
children,
title = "技术信息",
className,
columns = 2,
}: {
items?: (KvItem | null | false | undefined)[];
children?: ReactNode;
title?: string;
className?: string;
columns?: 1 | 2;
}) {
const prefs = useUiPrefs();
const [override, setOverride] = useState<boolean | null>(null);
const open = override ?? prefs.techInfoExpanded;
const id = useId();
const rows = (items ?? []).filter(
(it): it is KvItem => !!it && it.value !== null && it.value !== undefined && it.value !== "",
);
if (!rows.length && !children) return null;
return (
<div className={cn("min-w-0", className)} data-slot="tech-info">
<button
type="button"
aria-expanded={open}
aria-controls={id}
onClick={() => setOverride(!open)}
className="inline-flex items-center gap-1 rounded-md py-1 text-meta text-muted-foreground transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none"
>
{title}
<ChevronDown className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} aria-hidden />
</button>
<Disclosure id={id} open={open}>
<div className="grid gap-3 pt-2 pb-1">
{rows.length ? <KvList items={rows.map((r) => ({ mono: true, ...r }))} columns={columns} /> : null}
{children}
</div>
</Disclosure>
</div>
);
}
@@ -9,7 +9,7 @@ import { Timeline } from "~/components/console/timeline.tsx";
// 阶段计数与首页/侧栏同口径;每条事件都有文字说明,不只靠颜色。
export const STAGE_LABEL: Record<TimelineStage, string> = {
observation: "观察",
observation: "发现",
plan: "计划",
download: "下载",
organization: "整理",
@@ -26,12 +26,11 @@ export function BusinessTimeline({ events, loading }: { events: TimelineEvent[];
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-1.5">
{ORDER.map((s) => {
const n = counts.get(s) ?? 0;
{ORDER.filter((s) => counts.get(s)).map((s) => {
const bad = events.some((e) => e.stage === s && e.tone === "destructive");
return (
<StatusBadge key={s} tone={bad ? "destructive" : n ? "success" : "neutral"}>
{STAGE_LABEL[s]} {n ? `· ${n}` : "· 无"}
<StatusBadge key={s} tone={bad ? "destructive" : "neutral"}>
{STAGE_LABEL[s]} · {counts.get(s)}
</StatusBadge>
);
})}
@@ -1,6 +1,7 @@
import type { DownloadTask } from "@umt/contracts";
import { Bot, FolderInput, Pause, Play, ShieldAlert } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import {
DOWNLOAD_STATUS_LABEL,
@@ -17,11 +18,13 @@ import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { StatusBadge } 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 { relTime } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
import { planReasonText } from "~/lib/labels.ts";
import { humanizeReason, reasonLine } from "~/lib/reasons.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
import { BusinessTimeline } from "./business-timeline.tsx";
@@ -52,7 +55,6 @@ export function DownloadStatusBadges({ t }: { t: DownloadTask }) {
<StatusBadge>{PAUSE_REASON_LABEL[t.pauseReason]}</StatusBadge>
) : null}
{!t.managed ? <StatusBadge>外部任务</StatusBadge> : null}
{t.origin === "adopted" ? <StatusBadge>接管</StatusBadge> : null}
</span>
);
}
@@ -95,6 +97,11 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
const trace = useDownloadTrace(task, plan.data);
const [organizing, setOrganizing] = useState(false);
const canOrganize = ["completed", "importing", "imported"].includes(task.status);
// 下载完成但最近一次整理失败:状态行只写"下载完成"会误导,单独说明入库失败
const lastRecord = [...(trace.records.data?.items ?? [])].sort((a, b) =>
(b.startedAt ?? b.createdAt).localeCompare(a.startedAt ?? a.createdAt),
)[0];
const importFailed = task.status === "completed" && lastRecord?.status === "failed" ? lastRecord : null;
return (
<div className="space-y-5">
@@ -132,7 +139,15 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
{task.pauseReason === "threshold" ? (
<p className="flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
缓存卷达到硬阈值,任务已暂停,水位回落到恢复线后自动恢复。手动恢复和智能体都不能绕过阈值。
下载盘超过暂停线,任务已暂停;降到恢复线以下后自动恢复。
</p>
) : null}
{importFailed ? (
<p className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive">
入库失败:{reasonLine(importFailed.reason)}。
<Link to="/console/inbox" className="ml-1 underline underline-offset-4">
去待处理
</Link>
</p>
) : null}
{task.noProgressSince ? (
@@ -151,17 +166,26 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
{ label: "剩余", value: task.remainingBytes === null ? "未知" : formatBytes(task.remainingBytes, 2) },
{ label: "分享率", value: task.ratio === null ? "—" : task.ratio.toFixed(2) },
{ label: "最近进展", value: relTime(task.lastProgressAt) },
{ label: "最近同步", value: relTime(task.lastSyncedAt) },
{ label: "标签", value: task.tags.join(", ") },
{ label: "分类", value: task.category },
{ label: "保存路径", value: task.savePath, mono: true, wide: true },
{ label: "内容路径", value: task.contentPath, mono: true, wide: true },
{ label: "torrent hash", value: task.torrentHash, mono: true, wide: true },
]}
/>
<TechInfo
className="mt-3"
items={[
{ label: "最近同步", value: relTime(task.lastSyncedAt), mono: false },
{ label: "标签", value: task.tags.join(", ") },
{ label: "分类", value: task.category },
{ label: "下载器状态", value: task.rawState },
{ label: "内容路径", value: task.contentPath, wide: true },
{ label: "torrent hash", value: task.torrentHash, wide: true },
{ label: "任务 ID", value: task.id, wide: true },
]}
>
{task.lastError ? <JsonView label="下载器错误原文" value={task.lastError} tone="destructive" /> : null}
</TechInfo>
</DetailSection>
<DetailSection title="业务时间线">
<DetailSection title="经过">
<BusinessTimeline events={trace.events} loading={trace.loading} />
</DetailSection>
@@ -175,7 +199,9 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
{ label: "说明", value: planReasonText(plan.data.statusReason), wide: true },
]}
/>
<JsonView className="mt-2" label="评估依据" value={plan.data.evaluation} />
<TechInfo className="mt-2">
<JsonView label="评估依据" value={plan.data.evaluation} />
</TechInfo>
</DetailSection>
) : null}
@@ -195,7 +221,12 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
</DetailSection>
) : null}
{task.lastError ? <JsonView label="下载器错误" value={task.lastError} tone="destructive" defaultOpen /> : null}
{task.lastError ? (
<p className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive">
下载器报错:
{humanizeReason(String((task.lastError as { message?: unknown }).message ?? "")) || "原文见技术信息"}
</p>
) : null}
{organizing ? (
<OrganizeWizard
+1 -1
View File
@@ -136,7 +136,7 @@ export const DECISION_KIND: Record<DecisionKind, string> = {
identity_unresolved: "未识别",
download_stalled: "下载无进展",
download_failed: "下载失败",
path_conflict: "路径冲突",
path_conflict: "整理冲突",
space_insufficient: "空间不足",
approval_required: "需要审批",
metadata_conflict: "元数据冲突",
@@ -0,0 +1,157 @@
import type { SearchWork } from "@umt/contracts";
import { Check, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { useSearch } from "~/api/search.ts";
import { LoadingState } from "~/components/console/empty-state.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { cn } from "~/lib/utils.ts";
import { MEDIA_KIND } from "./labels.ts";
/** 选中的作品:本地作品给 mediaEntityId,只在 TMDB 上的给 tmdb */
export type AssignedWork =
| { mediaEntityId: string; title: string }
| { tmdb: { type: "movie" | "tv"; id: string }; title: string };
const workTitle = (w: SearchWork) => `${w.title}${w.year ? ` (${w.year})` : ""}`;
/**
* 「指定作品」:搜本地媒体库与 TMDB(复用全局搜索 /search?scope=media),选一个交给 onAssign。
* onAssign 返回 Promise:执行中按钮禁用,resolve 后关闭,reject 时保持打开(错误由调用方提示)。
*/
export function WorkAssignDialog({
open,
onOpenChange,
initialQuery = "",
subject,
onAssign,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
initialQuery?: string;
/** 在说明里点名要指定的是什么(文件名 / 资源标题) */
subject?: string | null;
onAssign: (w: AssignedWork) => Promise<unknown>;
}) {
const [q, setQ] = useState(initialQuery);
const [debounced, setDebounced] = useState(initialQuery);
const [picked, setPicked] = useState<SearchWork | null>(null);
const [pending, setPending] = useState(false);
useEffect(() => {
const t = setTimeout(() => setDebounced(q), 300);
return () => clearTimeout(t);
}, [q]);
useEffect(() => {
if (open) {
setQ(initialQuery);
setDebounced(initialQuery);
setPicked(null);
}
}, [open, initialQuery]);
const search = useSearch(debounced, "media", 12, open);
const works = search.data?.media?.works ?? [];
const tmdbOff = search.data?.media && !search.data.media.tmdb.configured;
const confirm = async () => {
if (!picked) return;
const target: AssignedWork | null = picked.localEntityId
? { mediaEntityId: picked.localEntityId, title: workTitle(picked) }
: picked.tmdb
? { tmdb: { type: picked.tmdb.entityType, id: picked.tmdb.externalId }, title: workTitle(picked) }
: null;
if (!target) return;
setPending(true);
try {
await onAssign(target);
onOpenChange(false);
} catch {
// 调用方已提示,保持打开
} finally {
setPending(false);
}
};
return (
<Dialog open={open} onOpenChange={(o) => (pending ? undefined : onOpenChange(o))}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>指定作品</DialogTitle>
<DialogDescription className="break-all">
{subject ? `「${subject}」属于哪部作品?` : "选这个资源属于哪部作品。"}
选中后加人工锁,自动识别不再改它。
</DialogDescription>
</DialogHeader>
<div className="min-w-0 space-y-2">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
autoFocus
value={q}
onChange={(e) => {
setQ(e.target.value);
setPicked(null);
}}
placeholder="作品名,中英文都行"
className="pl-9"
aria-label="搜索作品"
/>
</div>
{debounced.trim() ? (
<div className="max-h-72 overflow-y-auto rounded-lg border border-border">
{search.isPending ? (
<LoadingState rows={3} className="p-2" />
) : search.isError ? (
<p className="p-3 text-meta text-destructive">{(search.error as Error).message}</p>
) : works.length === 0 ? (
<p className="p-3 text-meta text-muted-foreground">
没有找到{tmdbOff ? "本地作品;TMDB 未配置,只搜了媒体库" : "匹配的作品"}
</p>
) : (
<ul>
{works.map((w) => {
const active = picked?.key === w.key;
return (
<li key={w.key}>
<button
type="button"
onClick={() => setPicked(w)}
aria-pressed={active}
className={cn(
"flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-sm transition-colors duration-micro hover:bg-foreground/[0.05]",
active && "bg-foreground/[0.07]",
)}
>
{active ? <Check className="h-4 w-4 shrink-0 text-success" /> : null}
<span className="min-w-0 flex-1 truncate text-foreground">{workTitle(w)}</span>
<span className="shrink-0 text-meta text-muted-foreground">
{MEDIA_KIND[w.kind]} · {w.localEntityId ? "在媒体库" : "TMDB"}
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
取消
</Button>
<Button onClick={() => void confirm()} disabled={!picked || pending}>
{pending ? "处理中…" : "指定"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,28 @@
import { Switch } from "~/components/ui/switch.tsx";
import { setUiPref, useUiPrefs } from "~/lib/ui-prefs.ts";
import { SettingsBlock } from "./settings-block.tsx";
// 界面:本机显示偏好(存浏览器,不进 settings 表)
export function InterfaceSection() {
const prefs = useUiPrefs();
return (
<SettingsBlock section="interface" anchor="display" title="显示">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="text-sm font-medium text-foreground" id="ui-techinfo-label">
默认展开技术信息
</p>
<p className="mt-1 text-caption text-muted-foreground">
ID、hash、token 数、耗时和原始错误码平时收在「技术信息」里。开启后各页默认展开。
</p>
</div>
<Switch
aria-labelledby="ui-techinfo-label"
checked={prefs.techInfoExpanded}
onCheckedChange={(v) => setUiPref("techInfoExpanded", v)}
/>
</div>
</SettingsBlock>
);
}
+15 -1
View File
@@ -1,11 +1,24 @@
import { SETTINGS_SECTIONS, type SettingsSectionDef, type SettingsSectionId } from "@umt/contracts";
import { Bot, FolderTree, KeyRound, type LucideIcon, Server, Shield, Tags, Tv, UserCog, Zap } from "lucide-react";
import {
Bot,
FolderTree,
KeyRound,
type LucideIcon,
Monitor,
Server,
Shield,
Tags,
Tv,
UserCog,
Zap,
} from "lucide-react";
import type { ReactNode } from "react";
import { AccountSection } from "./account.tsx";
import { AgentSection } from "./agent.tsx";
import { ApiKeysSection } from "./api-keys.tsx";
import { AutomationSection } from "./automation.tsx";
import { DownloadersSection, MediaServersSection } from "./instances.tsx";
import { InterfaceSection } from "./interface.tsx";
import { LibrarySection } from "./library.tsx";
import { MetadataSection } from "./metadata.tsx";
import { SitesSection } from "./sites.tsx";
@@ -21,6 +34,7 @@ const UI: Record<SettingsSectionId, { icon: LucideIcon; render: () => ReactNode
metadata: { icon: Tags, render: () => <MetadataSection /> },
agent: { icon: Bot, render: () => <AgentSection /> },
"api-keys": { icon: KeyRound, render: () => <ApiKeysSection /> },
interface: { icon: Monitor, render: () => <InterfaceSection /> },
account: { icon: Shield, render: () => <AccountSection /> },
};