Files
UHD_Media_Tool/apps/web/app/components/downloads/download-detail.tsx
T
rootandClaude Opus 5.5 f0d4935fa7 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]>
2026-09-29 20:42:27 +08:00

246 lines
10 KiB
TypeScript

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,
DOWNLOAD_STATUS_TONE,
PAUSE_REASON_LABEL,
PLAN_STATUS_LABEL,
useDownloadAction,
useDownloaderNames,
usePlan,
} from "~/api/downloads.ts";
import { useDownloadTrace } from "~/api/tasks.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
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";
const PLAN_PURPOSE_LABEL: Record<string, string> = { fill: "补缺", upgrade: "升级", replace_source: "换源" };
/** 进度:细线 + 文字;总量未知时不伪造百分比 */
export function ProgressLine({ t }: { t: DownloadTask }) {
const known = t.sizeBytes !== null;
return (
<div className="min-w-24">
<div className="h-1 overflow-hidden rounded-full bg-foreground/[0.08]">
<div className="h-full rounded-full bg-foreground/60" style={{ width: `${Math.round(t.progress * 100)}%` }} />
</div>
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">
{known ? `${(t.progress * 100).toFixed(1)}%` : `已下 ${formatBytes(t.downloadedBytes, 1)}`}
</p>
</div>
);
}
export function DownloadStatusBadges({ t }: { t: DownloadTask }) {
return (
<span className="flex flex-wrap gap-1">
<StatusBadge tone={DOWNLOAD_STATUS_TONE[t.status]}>{DOWNLOAD_STATUS_LABEL[t.status]}</StatusBadge>
{t.pauseReason === "threshold" ? (
<StatusBadge tone="warning">阈值暂停</StatusBadge>
) : t.pauseReason ? (
<StatusBadge>{PAUSE_REASON_LABEL[t.pauseReason]}</StatusBadge>
) : null}
{!t.managed ? <StatusBadge>外部任务</StatusBadge> : null}
</span>
);
}
/** 暂停/恢复:阈值暂停在水位回落前不可手动恢复(服务端强制) */
export function PauseResume({ t, size = "sm" }: { t: DownloadTask; size?: "sm" | "xs" }) {
const act = useDownloadAction();
const canPause = ["queued", "downloading", "stalled"].includes(t.status);
const canResume = t.status === "paused";
if (!canPause && !canResume) return null;
const threshold = t.pauseReason === "threshold";
return (
<Button
variant="outline"
size={size}
disabled={act.isPending}
title={threshold ? "阈值暂停,缓存回落到恢复水位后自动恢复" : undefined}
onKeyDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
act.mutate(
{ id: t.id, action: canResume ? "resume" : "pause" },
{
onSuccess: () => toast.success(canResume ? "已恢复" : "已暂停。阈值回落时不会自动恢复"),
onError: (err) => toast.error(errorMessage(err)),
},
);
}}
>
{canResume ? <Play /> : <Pause />}
{canResume ? "恢复" : "暂停"}
</Button>
);
}
/** 下载任务详情:状态、阈值说明、业务时间线、文件、关联计划 */
export function DownloadDetail({ task }: { task: DownloadTask }) {
const plan = usePlan(task.acquisitionPlanId);
const names = useDownloaderNames();
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">
<div className="flex flex-wrap items-center gap-2">
<DownloadStatusBadges t={task} />
<div className="ml-auto flex flex-wrap gap-2">
<PauseResume t={task} />
<Button
variant="outline"
size="sm"
disabled={!canOrganize}
title={canOrganize ? undefined : "下载完成后才能整理"}
onClick={() => setOrganizing(true)}
>
<FolderInput />
整理
</Button>
<Button
variant="ghost"
size="sm"
onClick={() =>
openAgentDrawer({
label: `下载:${task.name}`,
downloadTaskId: task.id,
goal: task.status === "stalled" || task.status === "failed" ? "排查这个下载为什么没有进展" : "",
})
}
>
<Bot />
问智能体
</Button>
</div>
</div>
{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 ? (
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
自 {formatDateTime(task.noProgressSince)} 起无进展,已记入待处理,不会自动换源。
</p>
) : null}
<DetailSection title="进度">
<ProgressLine t={task} />
<KvList
className="mt-3"
items={[
{ label: "下载器", value: names.data?.get(task.instanceId) ?? task.instanceId.slice(0, 8) },
{ label: "大小", value: task.sizeBytes === null ? "未知" : formatBytes(task.sizeBytes, 2) },
{ 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: task.savePath, 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="经过">
<BusinessTimeline events={trace.events} loading={trace.loading} />
</DetailSection>
{plan.data ? (
<DetailSection title="下载计划">
<KvList
items={[
{ label: "状态", value: PLAN_STATUS_LABEL[plan.data.status] },
{ label: "用途", value: PLAN_PURPOSE_LABEL[plan.data.purpose] ?? plan.data.purpose },
{ label: "预留空间", value: formatBytes(plan.data.reservedBytes, 1) },
{ label: "说明", value: planReasonText(plan.data.statusReason), wide: true },
]}
/>
<TechInfo className="mt-2">
<JsonView label="评估依据" value={plan.data.evaluation} />
</TechInfo>
</DetailSection>
) : null}
{task.fileList?.length ? (
<DetailSection title={`文件(${task.fileList.length})`}>
<ul className="max-h-60 space-y-1 overflow-auto text-meta">
{task.fileList.map((f) => (
<li key={f.path} className="flex justify-between gap-3">
<span className="min-w-0 font-mono break-all text-foreground">{f.path}</span>
<span className="shrink-0 text-muted-foreground tabular-nums">
{formatBytes(f.size, 1)}
{f.progress !== undefined ? ` · ${Math.round(f.progress * 100)}%` : ""}
</span>
</li>
))}
</ul>
</DetailSection>
) : 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
source={{
kind: "download",
taskId: task.id,
instanceId: task.instanceId,
contentPath: task.contentPath,
name: task.name,
}}
onClose={() => setOrganizing(false)}
/>
) : null}
</div>
);
}