266 lines
11 KiB
TypeScript
266 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|