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:
root committed 2026-09-29 21:45:15 +08:00
commit 8315ece4d1
17 files changed
+1113 -93

No files matched your search

+25
View File
@@ -3,6 +3,8 @@ import {
AcquisitionPlan,
cursorPage,
Dashboard,
DownloadBoard,
type DownloadBoardState,
DownloaderInstance,
type DownloadStatus,
DownloadTask,
@@ -26,6 +28,7 @@ export const downloadKeys = {
plans: (status?: PlanStatus) => ["downloads", "plans", status ?? "all"] as const,
plan: (id: string) => ["downloads", "plan", id] as const,
cache: () => ["downloads", "cache"] as const,
board: (state: DownloadBoardState) => ["downloads", "board", state] as const,
};
const ACTIVE: DownloadStatus[] = ["queued", "submitting", "downloading", "importing", "stalled"];
@@ -47,6 +50,28 @@ export function useDownloadTasks(f: DownloadFilters) {
});
}
/** 下载面板轮询间隔:页面可见时刷新,标签页在后台时 React Query 自动暂停(refetchIntervalInBackground=false) */
export const BOARD_POLL_MS = 4 * TTL_SECOND;
/**
* 下载面板(DL-2):分组列表 + 实时速度 + 两组计数 + 下载器总速度。
* 不保留上一个分组的数据:两组列不同,切换时先显示加载态。
*/
export function useDownloadBoard(state: DownloadBoardState) {
return useInfiniteQuery({
queryKey: downloadKeys.board(state),
initialPageParam: undefined as string | undefined,
queryFn: ({ pageParam, signal }) =>
apiParsed(DownloadBoard, "/api/v1/download-tasks/board", {
params: cleanParams({ state, cursor: pageParam, limit: 50 }),
signal,
}),
getNextPageParam: (last) => last.nextCursor ?? undefined,
refetchInterval: BOARD_POLL_MS,
refetchIntervalInBackground: false,
});
}
export function useDownloadTask(id: string | undefined) {
return useQuery({
queryKey: downloadKeys.one(id ?? ""),
@@ -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 },
+41 -89
View File
@@ -1,42 +1,44 @@
import { useQueryClient } from "@tanstack/react-query";
import type { DownloadStatus, DownloadTask } from "@umt/contracts";
import type { DownloadBoardState } from "@umt/contracts";
import { EventType } from "@umt/contracts";
import { Download, RefreshCw } from "lucide-react";
import { useState } from "react";
import { RefreshCw } from "lucide-react";
import { useMemo, useState } from "react";
import { Link } from "react-router";
import { DOWNLOAD_STATUS_LABEL, useDownloaderNames, useDownloadTasks } from "~/api/downloads.ts";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { useDownloadBoard } from "~/api/downloads.ts";
import { DataTable } from "~/components/console/data-table.tsx";
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { useQueryState } from "~/components/console/use-query-state.ts";
import { readBoardState, writeBoardState } from "~/components/downloads/board-format.ts";
import { CacheVolumes } from "~/components/downloads/cache-volumes.tsx";
import {
DownloadDetail,
DownloadStatusBadges,
PauseResume,
ProgressLine,
} from "~/components/downloads/download-detail.tsx";
activeColumns,
BOARD_EMPTY,
BoardSegments,
completedColumns,
TransferLine,
} from "~/components/downloads/download-board.tsx";
import { DownloadDetail } from "~/components/downloads/download-detail.tsx";
import { DownloadsTabs } from "~/components/downloads/downloads-tabs.tsx";
import { Button } from "~/components/ui/button.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { useSse } from "~/lib/sse.ts";
import { formatBytes } from "~/lib/utils.ts";
export const meta = () => [{ title: "下载面板 · UHD 媒体自动化控制台" }];
// DL-2:默认只看进行中;已完成单独一段(本浏览器记住选择)。页面可见时每 4 秒刷新速度,切到后台停。
export default function DownloadsPage() {
const f = useQueryState(["status", "managed"] as const);
// SPA 模式:路由组件只在浏览器渲染,可直接读本地记住的分段
const [state, setStateRaw] = useState<DownloadBoardState>(readBoardState);
const setState = (s: DownloadBoardState) => {
setStateRaw(s);
writeBoardState(s);
};
const [openId, setOpenId] = useState<string | null>(null);
const qc = useQueryClient();
const downloads = useDownloadTasks({
status: (f.values.status || undefined) as DownloadStatus | undefined,
managed: (f.values.managed || undefined) as "true" | "false" | undefined,
});
const names = useDownloaderNames();
const board = useDownloadBoard(state);
// 下载进度/状态、阈值事件到达时刷新列表(SSE 为主,轮询兜底)
// 状态变化、阈值事件到达时立即刷新(速度靠轮询)
useSse(
[
EventType.downloadProgress,
@@ -47,45 +49,12 @@ export default function DownloadsPage() {
() => void qc.invalidateQueries({ queryKey: ["downloads"] }),
);
const rows = downloads.data?.pages.flatMap((p) => p.items);
const pages = board.data?.pages;
const rows = pages?.flatMap((p) => p.items);
const first = pages?.[0];
const open = rows?.find((t) => t.id === openId) ?? null;
const columns: DataColumn<DownloadTask>[] = [
{
key: "name",
header: "下载任务",
cell: (t) => (
<div className="min-w-0 max-w-sm">
<p className="line-clamp-2 break-all text-foreground">{t.name}</p>
<p className="mt-0.5 truncate text-meta text-muted-foreground">
{names.data?.get(t.instanceId) ?? "下载器"} · {t.tags.join(", ") || "无标签"}
</p>
</div>
),
},
{ key: "status", header: "状态", cell: (t) => <DownloadStatusBadges t={t} /> },
{ key: "progress", header: "进度", hideBelow: "sm", cell: (t) => <ProgressLine t={t} /> },
{
key: "size",
header: "大小",
hideBelow: "lg",
align: "right",
cell: (t) => (
<span className="whitespace-nowrap tabular-nums">
{t.sizeBytes === null ? "未知" : formatBytes(t.sizeBytes, 1)}
</span>
),
},
{
key: "at",
header: "更新",
hideBelow: "md",
cell: (t) => (
<span className="whitespace-nowrap text-muted-foreground">{relTime(t.lastSyncedAt ?? t.updatedAt)}</span>
),
},
{ key: "ops", header: "", align: "right", cell: (t) => <PauseResume t={t} size="xs" /> },
];
const columns = useMemo(() => (state === "active" ? activeColumns() : completedColumns()), [state]);
const empty = BOARD_EMPTY[state];
return (
<>
@@ -93,47 +62,30 @@ export default function DownloadsPage() {
title="下载面板"
actions={
<Button variant="outline" size="sm" onClick={() => void qc.invalidateQueries({ queryKey: ["downloads"] })}>
<RefreshCw className={downloads.isFetching ? "animate-spin" : undefined} />
<RefreshCw className={board.isFetching ? "animate-spin" : undefined} />
刷新
</Button>
}
/>
<DownloadsTabs />
<CacheVolumes />
<FilterBar onReset={f.active ? f.reset : undefined}>
<FilterSelect
label="状态"
value={f.values.status}
onChange={(v) => f.set("status", v)}
options={Object.entries(DOWNLOAD_STATUS_LABEL).map(([value, label]) => ({ value, label }))}
/>
<FilterSelect
label="来源"
value={f.values.managed}
onChange={(v) => f.set("managed", v)}
options={[
{ value: "true", label: "本工具" },
{ value: "false", label: "其他" },
]}
/>
</FilterBar>
<div className="mb-3 flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<BoardSegments value={state} counts={first?.counts} onChange={setState} />
<TransferLine transfer={first?.transfer} />
</div>
<DataTable
columns={columns}
rows={rows}
rowKey={(t) => t.id}
loading={downloads.isPending}
error={downloads.error}
onRetry={() => void downloads.refetch()}
loading={board.isPending}
error={board.error}
onRetry={() => void board.refetch()}
onRowClick={(t) => setOpenId(t.id)}
selectedKey={openId}
hasMore={downloads.hasNextPage}
onLoadMore={() => void downloads.fetchNextPage()}
loadingMore={downloads.isFetchingNextPage}
empty={{
icon: Download,
title: f.active ? "没有符合筛选的下载任务" : "还没有下载任务",
description: f.active ? undefined : "自动收录或在站点页下载后出现在这里。",
}}
hasMore={board.hasNextPage}
onLoadMore={() => void board.fetchNextPage()}
loadingMore={board.isFetchingNextPage}
empty={empty}
/>
<DetailSheet
@@ -149,7 +101,7 @@ export default function DownloadsPage() {
) : null
}
>
{open ? <DownloadDetail task={open} /> : null}
{open ? <DownloadDetail task={open} live={open.live} /> : null}
</DetailSheet>
</>
);