Merge branch 'dl2'
Co-Authored-By: Claude Fable 5.1 <[email protected]> # Conflicts: # apps/web/app/routes/console/downloads.tsx # docs/plan/00-first-version-plan.md
This commit is contained in:
commit
8315ece4d1
17 files changed
+1113
-93
No files matched your search
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatEta, formatSpeed, liveStateLabel, organizeState, peersText } from "./board-format.ts";
|
||||
|
||||
const RID = "00000000-0000-4000-8000-000000000001";
|
||||
const rec = (
|
||||
status: "queued" | "running" | "succeeded" | "partial" | "failed" | "cancelled",
|
||||
extra: { reason?: string | null; decision?: { id: string; kind: "identity_unresolved" } | null } = {},
|
||||
) => ({ recordId: RID, status, reason: null, decision: null, ...extra });
|
||||
|
||||
describe("下载面板展示换算(DL-2)", () => {
|
||||
it("速度:0 与未知显示 —,其余带 /s", () => {
|
||||
expect(formatSpeed(0)).toBe("—");
|
||||
expect(formatSpeed(null)).toBe("—");
|
||||
expect(formatSpeed(undefined)).toBe("—");
|
||||
expect(formatSpeed(12.3 * 1024 * 1024)).toBe("12.3 MB/s");
|
||||
expect(formatSpeed(512)).toBe("512 B/s");
|
||||
});
|
||||
|
||||
it("剩余时间:分钟 / 小时 / 天,未知为 —", () => {
|
||||
expect(formatEta(null)).toBe("—");
|
||||
expect(formatEta(30)).toBe("不到 1 分钟");
|
||||
expect(formatEta(620)).toBe("10 分钟");
|
||||
expect(formatEta(3600)).toBe("1 小时");
|
||||
expect(formatEta(2 * 3600 + 5 * 60)).toBe("2 小时 5 分");
|
||||
expect(formatEta(3 * 86400 + 4 * 3600)).toBe("3 天 4 小时");
|
||||
});
|
||||
|
||||
it("最近一次整理结果:已入库 / 整理中 / 待整理 / 入库失败 / 部分入库;有待处理事项时链到待处理", () => {
|
||||
expect(organizeState({ status: "completed", organize: null })).toMatchObject({ label: "待整理", to: null });
|
||||
expect(organizeState({ status: "importing", organize: null }).label).toBe("整理中");
|
||||
expect(organizeState({ status: "imported", organize: null }).label).toBe("已入库");
|
||||
expect(organizeState({ status: "completed", organize: rec("succeeded") })).toMatchObject({
|
||||
label: "已入库",
|
||||
tone: "success",
|
||||
to: `/console/organize/${RID}`,
|
||||
});
|
||||
expect(organizeState({ status: "completed", organize: rec("running") }).label).toBe("整理中");
|
||||
const failed = organizeState({
|
||||
status: "completed",
|
||||
organize: rec("failed", {
|
||||
reason: "identity_unknown",
|
||||
decision: { id: "00000000-0000-4000-8000-000000000002", kind: "identity_unresolved" },
|
||||
}),
|
||||
});
|
||||
// 原因码不直接给人看
|
||||
expect(failed).toMatchObject({ label: "入库失败", tone: "destructive", hint: null });
|
||||
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("cancelled") })).toMatchObject({
|
||||
label: "待整理",
|
||||
to: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("连接数:全 0 且总数未知时不显示", () => {
|
||||
const base = {
|
||||
dlSpeed: 0,
|
||||
upSpeed: 0,
|
||||
etaSec: null,
|
||||
progress: 0,
|
||||
rawState: "queuedDL",
|
||||
ratio: null,
|
||||
completedAt: null,
|
||||
};
|
||||
expect(peersText({ ...base, seeds: 0, leechs: 0, seedsTotal: null, leechsTotal: null })).toBeNull();
|
||||
expect(peersText({ ...base, seeds: 12, leechs: 3, seedsTotal: 80, leechsTotal: null })).toBe("做种 12/80 · 下载 3");
|
||||
expect(peersText(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("实时状态补充:校验、取元数据", () => {
|
||||
expect(liveStateLabel("checkingDL")).toBe("校验中");
|
||||
expect(liveStateLabel("metaDL")).toBe("获取元数据");
|
||||
expect(liveStateLabel("downloading")).toBeNull();
|
||||
expect(liveStateLabel(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { DownloadBoardItem, DownloadBoardState, DownloadLive } from "@umt/contracts";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { formatBytes } from "~/lib/utils.ts";
|
||||
|
||||
// 下载面板(DL-2)的展示换算:速度、剩余时间、整理状态、实时状态补充。
|
||||
|
||||
/** 速度:0 或未知显示"—",不显示 "0 B/s" */
|
||||
export function formatSpeed(bps: number | null | undefined): string {
|
||||
if (!bps || bps <= 0) return "—";
|
||||
return `${formatBytes(bps, 1)}/s`;
|
||||
}
|
||||
|
||||
/** 剩余时间:未知(qB 的无穷)显示"—" */
|
||||
export function formatEta(sec: number | null | undefined): string {
|
||||
if (sec === null || sec === undefined || sec < 0) return "—";
|
||||
if (sec < 60) return "不到 1 分钟";
|
||||
const m = Math.round(sec / 60);
|
||||
if (m < 60) return `${m} 分钟`;
|
||||
const h = Math.floor(sec / 3600);
|
||||
const rm = Math.round((sec - h * 3600) / 60);
|
||||
if (h < 24) return rm ? `${h} 小时 ${rm} 分` : `${h} 小时`;
|
||||
const d = Math.floor(h / 24);
|
||||
const rh = h - d * 24;
|
||||
return rh ? `${d} 天 ${rh} 小时` : `${d} 天`;
|
||||
}
|
||||
|
||||
export type OrganizeState = {
|
||||
label: string;
|
||||
tone: "success" | "warning" | "destructive" | "neutral";
|
||||
/** 点状态去哪:有未关闭的待处理事项去待处理,否则去整理记录;都没有为 null */
|
||||
to: string | null;
|
||||
/** 失败原因(人话);原因是内部码时不直接给人看 */
|
||||
hint: string | null;
|
||||
};
|
||||
|
||||
/** 原因:内部码(小写下划线)不显示,其余原样。TODO(DEAI-2 合并后):改用 ~/lib/reasons.ts 的 reasonLine 人话化 */
|
||||
export function reasonHint(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
return /^[a-z0-9_]+([:.,][a-z0-9_:.,-]*)?$/.test(raw) ? null : raw;
|
||||
}
|
||||
|
||||
/** 已完成任务的最近一次整理结果:已入库 / 整理中 / 待整理 / 入库失败 / 部分入库 */
|
||||
export function organizeState(t: Pick<DownloadBoardItem, "status" | "organize">): OrganizeState {
|
||||
const rec = t.organize;
|
||||
const to = rec?.decision
|
||||
? `${consolePath("inbox")}?kind=${rec.decision.kind}`
|
||||
: rec
|
||||
? consolePath("organize", rec.recordId)
|
||||
: null;
|
||||
if (rec?.status === "succeeded" || (t.status === "imported" && rec?.status !== "failed"))
|
||||
return { label: "已入库", tone: "success", to, hint: null };
|
||||
if (rec?.status === "failed") return { label: "入库失败", tone: "destructive", to, hint: reasonHint(rec.reason) };
|
||||
if (rec?.status === "partial") return { label: "部分入库", tone: "warning", to, hint: reasonHint(rec.reason) };
|
||||
if (rec?.status === "queued" || rec?.status === "running" || t.status === "importing")
|
||||
return { label: "整理中", tone: "neutral", to, hint: null };
|
||||
return { label: "待整理", tone: "warning", to: rec?.decision ? to : null, hint: null };
|
||||
}
|
||||
|
||||
/** qB 实时状态里数据库状态没有细分的几种(校验、取元数据) */
|
||||
export function liveStateLabel(rawState: string | null | undefined): string | null {
|
||||
const s = rawState?.toLowerCase() ?? "";
|
||||
if (s.startsWith("checking")) return "校验中";
|
||||
if (s === "metadl" || s === "forcedmetadl") return "获取元数据";
|
||||
if (s === "moving") return "移动中";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 进度:有实时数据时用实时进度 */
|
||||
export function progressOf(t: Pick<DownloadBoardItem, "progress" | "live">): number {
|
||||
return t.live?.progress ?? t.progress;
|
||||
}
|
||||
|
||||
export const BOARD_STATE_KEY = "umt.downloads.boardState";
|
||||
|
||||
export function readBoardState(): DownloadBoardState {
|
||||
try {
|
||||
return globalThis.localStorage?.getItem(BOARD_STATE_KEY) === "completed" ? "completed" : "active";
|
||||
} catch {
|
||||
return "active";
|
||||
}
|
||||
}
|
||||
|
||||
export function writeBoardState(s: DownloadBoardState) {
|
||||
try {
|
||||
globalThis.localStorage?.setItem(BOARD_STATE_KEY, s);
|
||||
} catch {
|
||||
// 隐私模式等写不进去时只影响"记住选择"
|
||||
}
|
||||
}
|
||||
|
||||
/** 连接数:"做种 12/80 · 下载 3/9";全为 0 且总数未知时不显示 */
|
||||
export function peersText(l: DownloadLive | null | undefined): string | null {
|
||||
if (!l) return null;
|
||||
if (!l.seeds && !l.leechs && l.seedsTotal === null && l.leechsTotal === null) return null;
|
||||
const seeds = l.seedsTotal === null ? `${l.seeds}` : `${l.seeds}/${l.seedsTotal}`;
|
||||
const leechs = l.leechsTotal === null ? `${l.leechs}` : `${l.leechs}/${l.leechsTotal}`;
|
||||
return `做种 ${seeds} · 下载 ${leechs}`;
|
||||
}
|
||||
|
||||
/** 详情抽屉里的实时数据行 */
|
||||
export function liveItems(l: DownloadLive | null | undefined): { label: string; value: string | null }[] {
|
||||
if (!l) return [];
|
||||
return [
|
||||
{ label: "下载速度", value: formatSpeed(l.dlSpeed) },
|
||||
{ label: "上传速度", value: formatSpeed(l.upSpeed) },
|
||||
{ label: "剩余时间", value: formatEta(l.etaSec) },
|
||||
{ label: "连接", value: peersText(l) },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import type { DownloadBoard, DownloadBoardItem, DownloadBoardState } from "@umt/contracts";
|
||||
import { ArrowDown, ArrowUp, CheckCircle2, Download } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type { DataColumn } from "~/components/console/data-table.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
import { formatEta, formatSpeed, liveStateLabel, organizeState, peersText, progressOf } from "./board-format.ts";
|
||||
import { DownloadStatusBadges, PauseResume, ProgressLine } from "./download-detail.tsx";
|
||||
|
||||
// 下载面板(DL-2):顶部总速度 + 「进行中 / 已完成」分段;进行中看进度与速度,已完成看完成时间、分享率与整理状态。
|
||||
|
||||
/** 下载 / 上传速度一对(0 显示"—") */
|
||||
export function SpeedPair({ dl, up, className }: { dl: number; up: number; className?: string }) {
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-2.5 tabular-nums whitespace-nowrap", className)}>
|
||||
<span className="inline-flex items-center gap-0.5" title="下载速度">
|
||||
<ArrowDown aria-label="下载" className="size-3.5 text-muted-foreground" />
|
||||
{formatSpeed(dl)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-0.5" title="上传速度">
|
||||
<ArrowUp aria-label="上传" className="size-3.5 text-muted-foreground" />
|
||||
{formatSpeed(up)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 总速度一行;不可达的下载器单独列出 */
|
||||
export function TransferLine({ transfer }: { transfer: DownloadBoard["transfer"] | undefined }) {
|
||||
if (!transfer || transfer.instances.length === 0) return null;
|
||||
const down = transfer.instances.filter((i) => !i.reachable);
|
||||
const perInstance =
|
||||
transfer.instances.length > 1
|
||||
? transfer.instances.map((i) => `${i.name}:↓ ${formatSpeed(i.dlSpeed)} ↑ ${formatSpeed(i.upSpeed)}`).join("\n")
|
||||
: undefined;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||
<span title={perInstance} className="text-foreground">
|
||||
<SpeedPair dl={transfer.dlSpeed} up={transfer.upSpeed} />
|
||||
</span>
|
||||
{down.length ? (
|
||||
<span className="text-meta text-warning">{down.map((i) => `「${i.name}」`).join("")}连接失败</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BoardSegments({
|
||||
value,
|
||||
counts,
|
||||
onChange,
|
||||
}: {
|
||||
value: DownloadBoardState;
|
||||
counts: DownloadBoard["counts"] | undefined;
|
||||
onChange: (v: DownloadBoardState) => void;
|
||||
}) {
|
||||
const options: { value: DownloadBoardState; label: string }[] = [
|
||||
{ value: "active", label: "进行中" },
|
||||
{ value: "completed", label: "已完成" },
|
||||
];
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="下载任务分组"
|
||||
className="inline-flex gap-0.5 rounded-xl border border-border p-0.5"
|
||||
>
|
||||
{options.map((o) => (
|
||||
// biome-ignore lint/a11y/useSemanticElements: 分段按钮
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value === o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-lg px-3 py-1 text-caption transition-colors duration-micro",
|
||||
value === o.value
|
||||
? "bg-foreground text-background"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{o.label}
|
||||
{counts ? <span className="tabular-nums opacity-80">{counts[o.value]}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkLine({ t, extra }: { t: DownloadBoardItem; extra?: ReactNode }) {
|
||||
if (!t.media && !extra) return null;
|
||||
return (
|
||||
<p className="mt-0.5 flex min-w-0 flex-wrap items-center gap-x-2 text-meta text-muted-foreground">
|
||||
{t.media ? (
|
||||
<Link
|
||||
to={consolePath("library", t.media.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="truncate underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
{t.media.title}
|
||||
{t.media.year ? ` (${t.media.year})` : ""}
|
||||
</Link>
|
||||
) : null}
|
||||
{extra}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function NameCell({ t, children }: { t: DownloadBoardItem; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0 max-w-md">
|
||||
<p className="line-clamp-2 break-all text-foreground">{t.name}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveStatus({ t }: { t: DownloadBoardItem }) {
|
||||
const live = liveStateLabel(t.live?.rawState);
|
||||
return (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
<DownloadStatusBadges t={t} />
|
||||
{live ? <StatusBadge>{live}</StatusBadge> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function activeColumns(): DataColumn<DownloadBoardItem>[] {
|
||||
return [
|
||||
{
|
||||
key: "name",
|
||||
header: "下载任务",
|
||||
cell: (t) => (
|
||||
<NameCell t={t}>
|
||||
<WorkLine t={t} />
|
||||
{/* 窄屏:进度与速度并进名称列 */}
|
||||
<div className="mt-1.5 space-y-1 md:hidden">
|
||||
<ActiveStatus t={t} />
|
||||
<ProgressLine t={{ ...t, progress: progressOf(t) }} />
|
||||
{t.live?.dlSpeed || t.live?.upSpeed ? (
|
||||
<p className="flex flex-wrap items-center gap-x-3 text-meta text-muted-foreground">
|
||||
<SpeedPair dl={t.live.dlSpeed} up={t.live.upSpeed} />
|
||||
{t.live.etaSec !== null ? <span>剩余 {formatEta(t.live.etaSec)}</span> : null}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</NameCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "progress",
|
||||
header: "进度",
|
||||
hideBelow: "md",
|
||||
className: "w-40",
|
||||
cell: (t) => <ProgressLine t={{ ...t, progress: progressOf(t) }} />,
|
||||
},
|
||||
{
|
||||
key: "speed",
|
||||
header: "速度",
|
||||
hideBelow: "md",
|
||||
cell: (t) =>
|
||||
!t.live?.dlSpeed && !t.live?.upSpeed ? (
|
||||
<span className="text-meta text-muted-foreground">—</span>
|
||||
) : (
|
||||
<div className="space-y-0.5 text-meta tabular-nums whitespace-nowrap">
|
||||
<p className="flex items-center gap-0.5 text-foreground">
|
||||
<ArrowDown aria-label="下载" className="size-3.5 text-muted-foreground" />
|
||||
{formatSpeed(t.live?.dlSpeed)}
|
||||
</p>
|
||||
<p className="flex items-center gap-0.5 text-muted-foreground">
|
||||
<ArrowUp aria-label="上传" className="size-3.5" />
|
||||
{formatSpeed(t.live?.upSpeed)}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "eta",
|
||||
header: "剩余时间",
|
||||
hideBelow: "lg",
|
||||
cell: (t) => (
|
||||
<div className="text-meta whitespace-nowrap">
|
||||
<p className="text-foreground tabular-nums">{formatEta(t.live?.etaSec)}</p>
|
||||
{peersText(t.live) ? <p className="text-muted-foreground tabular-nums">{peersText(t.live)}</p> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "status", header: "状态", hideBelow: "md", cell: (t) => <ActiveStatus t={t} /> },
|
||||
{ key: "ops", header: "", align: "right", cell: (t) => <PauseResume t={t} size="xs" /> },
|
||||
];
|
||||
}
|
||||
|
||||
function OrganizeBadge({ t }: { t: DownloadBoardItem }) {
|
||||
const s = organizeState(t);
|
||||
const badge = <StatusBadge tone={s.tone}>{s.label}</StatusBadge>;
|
||||
return (
|
||||
<div className="flex max-w-56 flex-col items-start gap-0.5">
|
||||
{s.to ? (
|
||||
<Link
|
||||
to={s.to}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={t.organize?.decision ? "去待处理" : "查看整理记录"}
|
||||
className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{badge}
|
||||
</Link>
|
||||
) : (
|
||||
badge
|
||||
)}
|
||||
{s.hint ? <span className="line-clamp-2 text-micro text-muted-foreground">{s.hint}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 完成时间以下载器记录为准(接管的任务在数据库里记的是接管时刻) */
|
||||
const completedAtOf = (t: DownloadBoardItem) => t.live?.completedAt ?? t.completedAt ?? null;
|
||||
const sizeText = (t: DownloadBoardItem) => (t.sizeBytes === null ? "未知" : formatBytes(t.sizeBytes, 1));
|
||||
const ratioOf = (t: DownloadBoardItem) => t.live?.ratio ?? t.ratio;
|
||||
|
||||
export function completedColumns(): DataColumn<DownloadBoardItem>[] {
|
||||
return [
|
||||
{
|
||||
key: "name",
|
||||
header: "下载任务",
|
||||
cell: (t) => (
|
||||
<NameCell t={t}>
|
||||
<WorkLine t={t} />
|
||||
<p className="mt-1 flex flex-wrap items-center gap-x-2 text-meta text-muted-foreground tabular-nums md:hidden">
|
||||
<span>{sizeText(t)}</span>
|
||||
<span>{relTime(completedAtOf(t))}完成</span>
|
||||
{ratioOf(t) !== null ? <span>分享率 {ratioOf(t)?.toFixed(2)}</span> : null}
|
||||
{t.live?.upSpeed ? (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<ArrowUp aria-label="上传" className="size-3.5" />
|
||||
{formatSpeed(t.live.upSpeed)}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</NameCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "size",
|
||||
header: "大小",
|
||||
hideBelow: "md",
|
||||
align: "right",
|
||||
cell: (t) => <span className="whitespace-nowrap tabular-nums">{sizeText(t)}</span>,
|
||||
},
|
||||
{
|
||||
key: "done",
|
||||
header: "完成时间",
|
||||
hideBelow: "md",
|
||||
cell: (t) => (
|
||||
<span className="whitespace-nowrap text-muted-foreground" title={formatDateTime(completedAtOf(t))}>
|
||||
{relTime(completedAtOf(t))}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "ratio",
|
||||
header: "分享率 / 上传",
|
||||
hideBelow: "lg",
|
||||
cell: (t) => (
|
||||
<div className="text-meta whitespace-nowrap tabular-nums">
|
||||
<p className="text-foreground">{ratioOf(t) === null ? "—" : ratioOf(t)?.toFixed(2)}</p>
|
||||
{t.live?.upSpeed ? (
|
||||
<p className="flex items-center gap-0.5 text-muted-foreground">
|
||||
<ArrowUp aria-label="上传" className="size-3.5" />
|
||||
{formatSpeed(t.live.upSpeed)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "organize", header: "整理", cell: (t) => <OrganizeBadge t={t} /> },
|
||||
];
|
||||
}
|
||||
|
||||
export const BOARD_EMPTY: Record<DownloadBoardState, { title: string; description: string; icon: typeof Download }> = {
|
||||
active: {
|
||||
icon: Download,
|
||||
title: "没有进行中的下载",
|
||||
description: "自动收录或在站点页下载后出现在这里;下载完成的在「已完成」。",
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
title: "还没有下载完成的任务",
|
||||
description: "下载完成(含做种)后出现在这里。",
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DownloadTask } from "@umt/contracts";
|
||||
import type { DownloadLive, DownloadTask } from "@umt/contracts";
|
||||
import { Bot, FolderInput, Pause, Play, ShieldAlert } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
@@ -26,6 +26,7 @@ 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 { liveItems } from "./board-format.ts";
|
||||
import { BusinessTimeline } from "./business-timeline.tsx";
|
||||
|
||||
const PLAN_PURPOSE_LABEL: Record<string, string> = { fill: "补缺", upgrade: "升级", replace_source: "换源" };
|
||||
@@ -91,7 +92,7 @@ export function PauseResume({ t, size = "sm" }: { t: DownloadTask; size?: "sm" |
|
||||
}
|
||||
|
||||
/** 下载任务详情:状态、阈值说明、业务时间线、文件、关联计划 */
|
||||
export function DownloadDetail({ task }: { task: DownloadTask }) {
|
||||
export function DownloadDetail({ task, live }: { task: DownloadTask; live?: DownloadLive | null }) {
|
||||
const plan = usePlan(task.acquisitionPlanId);
|
||||
const names = useDownloaderNames();
|
||||
const trace = useDownloadTrace(task, plan.data);
|
||||
@@ -157,13 +158,14 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
|
||||
) : null}
|
||||
|
||||
<DetailSection title="进度">
|
||||
<ProgressLine t={task} />
|
||||
<ProgressLine t={live ? { ...task, progress: live.progress } : 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) },
|
||||
...liveItems(live),
|
||||
{ label: "分享率", value: task.ratio === null ? "—" : task.ratio.toFixed(2) },
|
||||
{ label: "最近进展", value: relTime(task.lastProgressAt) },
|
||||
{ label: "保存路径", value: task.savePath, mono: true, wide: true },
|
||||
|
||||
Reference in new issue
Block a user