Files
UHD_Media_Tool/apps/web/app/components/downloads/download-detail.tsx
T

266 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { DownloadLive, DownloadTask, PublicOrganizationRecord } from "@umt/contracts";
import { Bot, FolderInput, Pause, Play, ShieldAlert } from "lucide-react";
import { useState } from "react";
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 { RecordLink } from "~/components/console/record-sheet.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 { humanizeReason, reasonCode, reasonLine } from "~/lib/reasons.ts";
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
import { liveItems } from "./board-format.ts";
import { BusinessTimeline } from "./business-timeline.tsx";
import { WhySection } from "./plan-row.tsx";
/** 进度:细线 + 文字;总量未知时不伪造百分比 */
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 DownloadOrganizeIssue({ record }: { record: PublicOrganizationRecord | null | undefined }) {
if (!record || (record.status !== "failed" && record.status !== "partial")) return null;
const partial = record.status === "partial";
const reason = reasonLine(record.reason);
return (
<p
className={cn(
"rounded-lg border px-3 py-2 text-caption",
partial
? "border-warning/40 bg-warning/10 text-warning"
: "border-destructive/40 bg-destructive/10 text-destructive",
)}
>
{partial ? "部分整理完成,还有文件需要处理" : "整理失败"}
{reason ? `:${reason}` : ""}。
<RecordLink page="organize" id={record.id} className="ml-1 underline underline-offset-4">
去整理记录处理
</RecordLink>
</p>
);
}
/** 下载任务详情:状态、阈值说明、业务时间线、为什么下这个(DL-3)、文件 */
export function DownloadDetail({ task, live }: { task: DownloadTask; live?: DownloadLive | null }) {
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 importIssue =
canOrganize && (lastRecord?.status === "failed" || lastRecord?.status === "partial") ? 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}
<DownloadOrganizeIssue record={importIssue} />
{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}
{task.acquisitionPlanId ? (
<WhySection
planId={task.acquisitionPlanId}
extraTech={[
{
label: "计划状态",
value: plan.data
? `${PLAN_STATUS_LABEL[plan.data.status]}${plan.data.statusReason ? ` · ${plan.data.statusReason}` : ""}`
: null,
},
]}
/>
) : null}
<DetailSection title="进度">
<ProgressLine t={live ? { ...task, progress: live.progress } : task} />
<KvList
className="mt-3"
items={[
{ 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),
{ 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 },
{ label: "下载器 ID", value: task.instanceId, wide: true },
importIssue ? { label: "整理结果原因码", value: reasonCode(importIssue.reason) } : null,
importIssue ? { label: "整理记录 ID", value: importIssue.id, wide: true } : null,
]}
>
{task.lastError ? <JsonView label="下载器错误原文" value={task.lastError} tone="destructive" /> : null}
</TechInfo>
</DetailSection>
<DetailSection title="经过">
<BusinessTimeline events={trace.events} loading={trace.loading} />
</DetailSection>
{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,
tags: task.tags,
}}
onClose={() => setOrganizing(false)}
/>
) : null}
</div>
);
}