feat(downloads): fold download plans into download panel (DL-3)
- Waiting plans (approval / disk space / automation off / queued) are
active-segment rows on /download-tasks/board, merged by created_at,
counted, with approve / cancel actions; ?plan= deep links and legacy
/downloads/plans routes redirect to the panel
- Download detail shows "why this download" (source, purpose, replaced
version, reasons); raw evaluation in tech info
- Failed / cancelled / expired plans appear in the entity download
history; rejected / revoked / expired approvals close draft plans
- New GET /acquisition-plans/{id}/why, POST /acquisition-plans/{id}/cancel
- User-facing copy no longer says 下载计划; glossary updated
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c0522f2531
commit
a9147e647d
50 files changed
+1535
-348
No files matched your search
@@ -1,14 +1,16 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { DownloadBoardState } from "@umt/contracts";
|
||||
import type { DownloadBoardItem, DownloadBoardRow, DownloadBoardState } from "@umt/contracts";
|
||||
import { EventType } from "@umt/contracts";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useDownloadBoard } from "~/api/downloads.ts";
|
||||
import { DataTable } from "~/components/console/data-table.tsx";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
import { useBoardPlanRow, useDownloadBoard, usePlan } from "~/api/downloads.ts";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { readBoardState, writeBoardState } from "~/components/downloads/board-format.ts";
|
||||
import { CacheVolumes } from "~/components/downloads/cache-volumes.tsx";
|
||||
import {
|
||||
@@ -19,13 +21,17 @@ import {
|
||||
TransferLine,
|
||||
} from "~/components/downloads/download-board.tsx";
|
||||
import { DownloadDetail } from "~/components/downloads/download-detail.tsx";
|
||||
import { DownloadsTabs } from "~/components/downloads/downloads-tabs.tsx";
|
||||
import { DROPPED_STATUS, droppedReason } from "~/components/downloads/plan-format.ts";
|
||||
import { PlanDetail, WhySection } from "~/components/downloads/plan-row.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
|
||||
export const meta = () => [{ title: "下载面板 · UHD 媒体自动化控制台" }];
|
||||
|
||||
// DL-2:默认只看进行中;已完成单独一段(本浏览器记住选择)。页面可见时每 4 秒刷新速度,切到后台停。
|
||||
// DL-3:没提交到下载器的下载(等批准 / 等磁盘空间…)也是「进行中」的行;?plan=<计划 id> 深链打开对应行。
|
||||
|
||||
const COMPLETED = new Set(["completed", "importing", "imported"]);
|
||||
|
||||
export default function DownloadsPage() {
|
||||
// SPA 模式:路由组件只在浏览器渲染,可直接读本地记住的分段
|
||||
@@ -35,8 +41,19 @@ export default function DownloadsPage() {
|
||||
writeBoardState(s);
|
||||
};
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [params, setParams] = useSearchParams();
|
||||
const deepPlan = params.get("plan");
|
||||
const qc = useQueryClient();
|
||||
const board = useDownloadBoard(state);
|
||||
const deep = useBoardPlanRow(deepPlan);
|
||||
|
||||
// 深链:找到行就切到它所在的分段并打开;没有行(已结束的下载)打开结束说明
|
||||
useEffect(() => {
|
||||
const row = deep.data;
|
||||
if (!row) return;
|
||||
setStateRaw(row.kind === "task" && COMPLETED.has(row.status) ? "completed" : "active");
|
||||
setOpenId(row.id);
|
||||
}, [deep.data]);
|
||||
|
||||
// 状态变化、阈值事件到达时立即刷新(速度靠轮询)
|
||||
useSse(
|
||||
@@ -50,11 +67,23 @@ export default function DownloadsPage() {
|
||||
);
|
||||
|
||||
const pages = board.data?.pages;
|
||||
const rows = pages?.flatMap((p) => p.items);
|
||||
const allRows = pages?.flatMap((p) => p.items);
|
||||
const rows: DownloadBoardRow[] | undefined =
|
||||
state === "completed" ? allRows?.filter((r): r is DownloadBoardItem => r.kind === "task") : allRows;
|
||||
const first = pages?.[0];
|
||||
const open = rows?.find((t) => t.id === openId) ?? null;
|
||||
const columns = useMemo(() => (state === "active" ? activeColumns() : completedColumns()), [state]);
|
||||
const open = rows?.find((t) => t.id === openId) ?? (deep.data?.id === openId ? deep.data : null) ?? null;
|
||||
const columns = useMemo(
|
||||
() => (state === "active" ? activeColumns() : (completedColumns() as unknown as DataColumn<DownloadBoardRow>[])),
|
||||
[state],
|
||||
);
|
||||
const empty = BOARD_EMPTY[state];
|
||||
const closeDeep = () => {
|
||||
if (!deepPlan) return;
|
||||
const next = new URLSearchParams(params);
|
||||
next.delete("plan");
|
||||
setParams(next, { replace: true });
|
||||
};
|
||||
const endedPlan = deepPlan && deep.isSuccess && deep.data === null ? deepPlan : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -67,7 +96,6 @@ export default function DownloadsPage() {
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DownloadsTabs />
|
||||
<CacheVolumes />
|
||||
<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} />
|
||||
@@ -90,19 +118,59 @@ export default function DownloadsPage() {
|
||||
|
||||
<DetailSheet
|
||||
open={!!open}
|
||||
onOpenChange={(o) => !o && setOpenId(null)}
|
||||
onOpenChange={(o) => {
|
||||
if (o) return;
|
||||
setOpenId(null);
|
||||
closeDeep();
|
||||
}}
|
||||
width="lg"
|
||||
title={open?.name ?? ""}
|
||||
footer={
|
||||
open ? (
|
||||
open?.kind === "task" ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={consolePath("downloads", open.id)}>打开详情页</Link>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{open ? <DownloadDetail task={open} live={open.live} /> : null}
|
||||
{open?.kind === "task" ? <DownloadDetail task={open} live={open.live} /> : null}
|
||||
{open?.kind === "plan" ? <PlanDetail p={open} /> : null}
|
||||
</DetailSheet>
|
||||
|
||||
<EndedPlanSheet planId={endedPlan} onClose={closeDeep} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 深链指向已结束的下载(没提交成功 / 已取消 / 已过期):说明原因与来由;作品的下载历史里也有 */
|
||||
function EndedPlanSheet({ planId, onClose }: { planId: string | null; onClose: () => void }) {
|
||||
const plan = usePlan(planId);
|
||||
const p = plan.data;
|
||||
const ended = p && (p.status === "failed" || p.status === "cancelled" || p.status === "expired") ? p.status : null;
|
||||
return (
|
||||
<DetailSheet
|
||||
open={!!planId}
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={ended ? "这次下载没有提交" : "下载"}
|
||||
description={p ? (droppedReason(p.statusReason) ?? undefined) : undefined}
|
||||
>
|
||||
{plan.isPending ? (
|
||||
<LoadingState rows={3} />
|
||||
) : !p ? (
|
||||
<p className="text-sm text-muted-foreground">没找到这次下载,可能已被清理。</p>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{ended ? (
|
||||
<StatusBadge tone={DROPPED_STATUS[ended].tone}>{DROPPED_STATUS[ended].label}</StatusBadge>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">这次下载已经结束。</p>
|
||||
)}
|
||||
<WhySection
|
||||
planId={p.id}
|
||||
extraTech={[{ label: "状态原码", value: `${p.status} · ${p.statusReason ?? "—"}` }]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user