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
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user