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

+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>
</>
);