fix(downloads): humanized reasons and TechInfo on the download panel (DL-2b)

- board organize hint uses DEAI-2 reasonLine; add transfer_failed, blocked,
  blocked_by_conflict, main_failed, not_imported_kind, target_root_not_managed,
  download_removed to the reason table with tests
- download detail / plans tab: internal ids, raw codes and JSON moved into TechInfo
- library versions tech block switched to TechInfo (TODO(DEAI-2))
- copy: glossary terms (no 接管/候选), plan reason codes no longer shown raw

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 22:00:04 +08:00
1 parent 8315ece4d1
commit 3f1c4d6c3b
11 files changed
+105 -65

No files matched your search

+1 -1
View File
@@ -179,7 +179,7 @@ export const DOWNLOAD_STATUS_TONE: Record<DownloadStatus, "success" | "warning"
};
export const PAUSE_REASON_LABEL = {
user: "用户暂停",
user: "手动暂停",
threshold: "下载盘满暂停",
policy: "策略暂停",
agent: "智能体暂停",
+2 -2
View File
@@ -191,7 +191,7 @@ export function buildTimeline(input: {
key: "dl-submit",
stage: "download",
at: task.createdAt,
title: task.origin === "adopted" ? "接管已有下载" : "提交下载",
title: task.origin === "adopted" ? "开始管理已有下载" : "提交下载",
detail: task.name,
tone: "neutral",
});
@@ -230,7 +230,7 @@ export function buildTimeline(input: {
stage: "download",
at: task.updatedAt,
title: "下载器报错",
detail: String((task.lastError as { message?: unknown }).message ?? JSON.stringify(task.lastError)),
detail: humanizeReason(String((task.lastError as { message?: unknown }).message ?? "")) || null,
tone: "destructive",
});
for (const r of records) {
@@ -42,13 +42,27 @@ describe("下载面板展示换算(DL-2)", () => {
decision: { id: "00000000-0000-4000-8000-000000000002", kind: "identity_unresolved" },
}),
});
// 原因码不直接给人看
expect(failed).toMatchObject({ label: "入库失败", tone: "destructive", hint: null });
// 原因码转成人话,不直接给人看
expect(failed).toMatchObject({
label: "入库失败",
tone: "destructive",
hint: "没识别出作品。指定作品后重新整理",
});
expect(failed.to).toBe("/console/inbox?kind=identity_unresolved");
expect(organizeState({ status: "completed", organize: rec("failed", { reason: "磁盘已满" }) }).hint).toBe(
"磁盘已满",
);
expect(organizeState({ status: "completed", organize: rec("partial") }).label).toBe("部分入库");
expect(organizeState({ status: "completed", organize: rec("failed", { reason: "transfer_failed" }) }).hint).toBe(
"文件没有整理成功。在整理记录里查看失败的文件",
);
expect(
organizeState({ status: "completed", organize: rec("partial", { reason: "blocked:target_exists" }) }).hint,
).toBe("媒体库里已有同名文件。在待处理里选择替换或跳过");
// 不认识的码不显示
expect(
organizeState({ status: "completed", organize: rec("failed", { reason: "some_new_code" }) }).hint,
).toBeNull();
expect(organizeState({ status: "completed", organize: rec("cancelled") })).toMatchObject({
label: "待整理",
to: null,
@@ -1,5 +1,6 @@
import type { DownloadBoardItem, DownloadBoardState, DownloadLive } from "@umt/contracts";
import { consolePath } from "~/components/console/nav.ts";
import { reasonLine } from "~/lib/reasons.ts";
import { formatBytes } from "~/lib/utils.ts";
// 下载面板(DL-2)的展示换算:速度、剩余时间、整理状态、实时状态补充。
@@ -33,10 +34,9 @@ export type OrganizeState = {
hint: string | null;
};
/** 原因:内部码(小写下划线)不显示,其余原样。TODO(DEAI-2 合并后):改用 ~/lib/reasons.ts 的 reasonLine 人话化 */
/** 原因 → 一句人话(DEAI-2 原因码表);不认识的内部码不给人看,原码在详情的技术信息里 */
export function reasonHint(raw: string | null | undefined): string | null {
if (!raw) return null;
return /^[a-z0-9_]+([:.,][a-z0-9_:.,-]*)?$/.test(raw) ? null : raw;
return reasonLine(raw, "") || null;
}
/** 已完成任务的最近一次整理结果:已入库 / 整理中 / 待整理 / 入库失败 / 部分入库 */
@@ -24,7 +24,7 @@ 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 { humanizeReason, reasonCode, reasonLine } from "~/lib/reasons.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
import { liveItems } from "./board-format.ts";
import { BusinessTimeline } from "./business-timeline.tsx";
@@ -162,7 +162,7 @@ export function DownloadDetail({ task, live }: { task: DownloadTask; live?: Down
<KvList
className="mt-3"
items={[
{ label: "下载器", value: names.data?.get(task.instanceId) ?? task.instanceId.slice(0, 8) },
{ label: "下载器", value: names.data?.get(task.instanceId) ?? "未知" },
{ label: "大小", value: task.sizeBytes === null ? "未知" : formatBytes(task.sizeBytes, 2) },
{ label: "剩余", value: task.remainingBytes === null ? "未知" : formatBytes(task.remainingBytes, 2) },
...liveItems(live),
@@ -181,6 +181,9 @@ export function DownloadDetail({ task, live }: { task: DownloadTask; live?: Down
{ label: "内容路径", value: task.contentPath, wide: true },
{ label: "torrent hash", value: task.torrentHash, wide: true },
{ label: "任务 ID", value: task.id, wide: true },
{ label: "下载器 ID", value: task.instanceId, wide: true },
importFailed ? { label: "整理失败原因码", value: reasonCode(importFailed.reason) } : null,
importFailed ? { label: "整理记录 ID", value: importFailed.id, wide: true } : null,
]}
>
{task.lastError ? <JsonView label="下载器错误原文" value={task.lastError} tone="destructive" /> : null}
@@ -201,7 +204,13 @@ export function DownloadDetail({ task, live }: { task: DownloadTask; live?: Down
{ label: "说明", value: planReasonText(plan.data.statusReason), wide: true },
]}
/>
<TechInfo className="mt-2">
<TechInfo
className="mt-2"
items={[
{ label: "计划 ID", value: plan.data.id, wide: true },
{ label: "说明原码", value: plan.data.statusReason },
]}
>
<JsonView label="评估依据" value={plan.data.evaluation} />
</TechInfo>
</DetailSection>
+25 -38
View File
@@ -3,8 +3,9 @@ import { ChevronDown } from "lucide-react";
import { type ReactNode, useState } from "react";
import { Link } from "react-router";
import { useLibraryVersions } from "~/api/library-versions.ts";
import { type KvItem, KvList } from "~/components/console/kv-list.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { Panel } from "~/components/library/parts.tsx";
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
@@ -86,45 +87,31 @@ function duration(sec: number | null): string | null {
const SPEC_FROM: Record<string, string> = { name: "文件名", probe: "ffprobe" };
/** 技术信息:路径、音轨、字幕、入库时间、下载任务、探测原始值 */
// TODO(DEAI-2):TechInfo 组件合入后改用 components/console/tech-info.tsx,和其他页面统一折叠样式与默认展开偏好
function VersionTech({ v }: { v: AssetVersion }) {
const items: KvItem[] = [
{ label: "文件", value: v.absolutePath ?? v.relativePath ?? v.fileName, mono: true, wide: true },
{ label: "音轨", value: audioText(v) },
{ label: "字幕", value: v.subtitleLanguages.map(langLabel).join("、") || null },
{ label: "时长", value: duration(v.durationSec) },
{ label: "色深", value: v.bitDepth ? `${v.bitDepth} bit` : null },
{ label: "入库时间", value: formatDateTime(v.organizedAt) },
{
label: "下载任务",
value: v.downloadTaskId ? (
<Link to={consolePath("downloads", v.downloadTaskId)} className="underline-offset-2 hover:underline">
查看下载任务
</Link>
) : null,
},
{ label: "规格来源", value: v.specFrom.map((f) => SPEC_FROM[f] ?? f).join(" + ") || null },
].filter((it) => it.value !== null && it.value !== undefined && it.value !== "");
return (
<details className="group mt-1">
<summary className="inline-flex cursor-pointer list-none items-center gap-1 text-meta text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden">
技术信息
<ChevronDown className="size-3.5 transition-transform duration-micro group-open:rotate-180" aria-hidden />
</summary>
<div className="grid gap-3 pt-2 pb-1">
<KvList items={items} />
{v.probe ? (
<details className="group/raw">
<summary className="cursor-pointer list-none text-meta text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden">
探测原始值
</summary>
<pre className="mt-1 max-h-64 overflow-auto rounded-lg bg-muted/40 p-2 font-mono text-caption break-all whitespace-pre-wrap text-foreground">
{JSON.stringify(v.probe, null, 2)}
</pre>
</details>
) : null}
</div>
</details>
<TechInfo
className="mt-1"
items={[
{ label: "文件", value: v.absolutePath ?? v.relativePath ?? v.fileName, wide: true },
{ label: "音轨", value: audioText(v), mono: false },
{ label: "字幕", value: v.subtitleLanguages.map(langLabel).join("、") || null, mono: false },
{ label: "时长", value: duration(v.durationSec), mono: false },
{ label: "色深", value: v.bitDepth ? `${v.bitDepth} bit` : null, mono: false },
{ label: "入库时间", value: v.organizedAt ? formatDateTime(v.organizedAt) : null, mono: false },
{
label: "下载任务",
mono: false,
value: v.downloadTaskId ? (
<Link to={consolePath("downloads", v.downloadTaskId)} className="underline-offset-2 hover:underline">
查看下载任务
</Link>
) : null,
},
{ label: "规格来源", value: v.specFrom.map((f) => SPEC_FROM[f] ?? f).join(" + ") || null, mono: false },
]}
>
{v.probe ? <JsonView label="探测原始值" value={v.probe} defaultOpen={false} maxHeight="16rem" /> : null}
</TechInfo>
);
}
+7 -1
View File
@@ -1,4 +1,6 @@
import { looksLikeCode } from "@umt/contracts";
import type { StatusTone } from "~/components/console/status-badge.tsx";
import { reasonLine } from "~/lib/reasons.ts";
// 状态值 → 中文文案 + 语气。状态徽章必须带文字,这里集中维护常用的几组。
@@ -154,5 +156,9 @@ const PLAN_REASON: Record<string, string> = {
"organize:succeeded": "整理成功",
"organize:partial": "部分整理成功",
"organize:failed": "整理失败",
authorized: "已批准",
"reserved:agent": "智能体已预留空间",
};
export const planReasonText = (v: string | null | undefined) => (v ? (PLAN_REASON[v] ?? v) : null);
/** 不认识的内部码走原因码表,仍不认识就不显示(原码在技术信息里) */
export const planReasonText = (v: string | null | undefined) =>
v ? (PLAN_REASON[v] ?? (looksLikeCode(v) ? reasonLine(v, "") || null : v)) : null;
+22 -15
View File
@@ -4,13 +4,14 @@ import { useState } from "react";
import { Link } from "react-router";
import { PLAN_STATUS_LABEL, useDownloadTasks, usePlans } from "~/api/downloads.ts";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { KvList } from "~/components/console/kv-list.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 { useQueryState } from "~/components/console/use-query-state.ts";
import { DownloadsTabs } from "~/components/downloads/downloads-tabs.tsx";
import { PRIORITY } from "~/components/library/labels.ts";
@@ -42,12 +43,9 @@ export default function DownloadPlansPage() {
key: "purpose",
header: "计划",
cell: (p) => (
<div className="min-w-0">
<p className="text-foreground">
{PURPOSE[p.purpose]} · 优先级 {PRIORITY[p.priority as keyof typeof PRIORITY] ?? p.priority}
</p>
<p className="truncate font-mono text-meta text-muted-foreground">{p.id.slice(0, 8)}</p>
</div>
<p className="text-foreground">
{PURPOSE[p.purpose]} · 优先级 {PRIORITY[p.priority as keyof typeof PRIORITY] ?? p.priority}
</p>
),
},
{
@@ -103,7 +101,7 @@ export default function DownloadPlansPage() {
empty={{
icon: ClipboardList,
title: f.active ? "没有符合筛选的下载计划" : "还没有下载计划",
description: f.active ? undefined : "候选被选中后出现在这里。",
description: f.active ? undefined : "选中可下载的资源后出现在这里。",
}}
/>
@@ -130,18 +128,27 @@ export default function DownloadPlansPage() {
<StatusBadge tone={planTone(openPlan.status)}>{PLAN_STATUS_LABEL[openPlan.status]}</StatusBadge>
),
},
{ label: "优先级", value: openPlan.priority },
{
label: "优先级",
value: PRIORITY[openPlan.priority as keyof typeof PRIORITY] ?? openPlan.priority,
},
{ label: "预留空间", value: formatBytes(openPlan.reservedBytes, 1) },
{ label: "过期于", value: formatDateTime(openPlan.expiresAt) },
{ label: "创建", value: formatDateTime(openPlan.createdAt) },
{ label: "收录版本", value: openPlan.slotId, mono: true },
{ label: "资源", value: openPlan.sourceReleaseId, mono: true, wide: true },
]}
/>
<DetailSection title="评估依据">
<JsonView value={openPlan.evaluation} />
</DetailSection>
<JsonView label="预算预留" value={openPlan.budgetReservation} />
<TechInfo
className="mt-4"
items={[
{ label: "计划 ID", value: openPlan.id, wide: true },
{ label: "收录版本 ID", value: openPlan.slotId, wide: true },
{ label: "资源 ID", value: openPlan.sourceReleaseId, wide: true },
{ label: "说明原码", value: openPlan.statusReason },
]}
>
<JsonView label="评估依据" value={openPlan.evaluation} defaultOpen={false} />
<JsonView label="预算预留" value={openPlan.budgetReservation} defaultOpen={false} />
</TechInfo>
</>
) : null}
</DetailSheet>