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:
rootandClaude Opus 5.5 committed 2026-09-30 01:15:25 +08:00
1 parent c0522f2531
commit a9147e647d
50 files changed
+1535 -348

No files matched your search

@@ -1,4 +1,10 @@
import type { DownloadBoard, DownloadBoardItem, DownloadBoardState } from "@umt/contracts";
import type {
DownloadBoard,
DownloadBoardItem,
DownloadBoardPlanItem,
DownloadBoardRow,
DownloadBoardState,
} from "@umt/contracts";
import { ArrowDown, ArrowUp, CheckCircle2, Download } from "lucide-react";
import type { ReactNode } from "react";
import { Link } from "react-router";
@@ -9,8 +15,10 @@ 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";
import { PlanActions, PlanWaitBadge } from "./plan-row.tsx";
// 下载面板(DL-2):顶部总速度 + 「进行中 / 已完成」分段;进行中看进度与速度,已完成看完成时间、分享率与整理状态。
// DL-3:进行中里还有没提交到下载器的下载(等你批准 / 等磁盘空间…),与下载任务按加入时间一起排。
/** 下载 / 上传速度一对(0 显示"—") */
export function SpeedPair({ dl, up, className }: { dl: number; up: number; className?: string }) {
@@ -90,7 +98,7 @@ export function BoardSegments({
);
}
function WorkLine({ t, extra }: { t: DownloadBoardItem; extra?: ReactNode }) {
function WorkLine({ t, extra }: { t: Pick<DownloadBoardItem, "media">; 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">
@@ -109,7 +117,7 @@ function WorkLine({ t, extra }: { t: DownloadBoardItem; extra?: ReactNode }) {
);
}
function NameCell({ t, children }: { t: DownloadBoardItem; children?: ReactNode }) {
function NameCell({ t, children }: { t: Pick<DownloadBoardItem, "name">; children?: ReactNode }) {
return (
<div className="min-w-0 max-w-md">
<p className="line-clamp-2 break-all text-foreground">{t.name}</p>
@@ -128,42 +136,63 @@ function ActiveStatus({ t }: { t: DownloadBoardItem }) {
);
}
export function activeColumns(): DataColumn<DownloadBoardItem>[] {
const dash = <span className="text-meta text-muted-foreground">—</span>;
/** 还没提交的下载:名称列(窄屏带状态与操作) */
function PlanNameCell({ p }: { p: DownloadBoardPlanItem }) {
return (
<NameCell t={p}>
<WorkLine
t={p}
extra={p.sizeBytes !== null ? <span className="tabular-nums">{formatBytes(p.sizeBytes, 1)}</span> : null}
/>
<div className="mt-1.5 flex flex-wrap items-center justify-between gap-2 md:hidden">
<PlanWaitBadge p={p} hint />
<PlanActions p={p} size="xs" />
</div>
</NameCell>
);
}
export function activeColumns(): DataColumn<DownloadBoardRow>[] {
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>
),
cell: (t) =>
t.kind === "plan" ? (
<PlanNameCell p={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) }} />,
cell: (t) => (t.kind === "plan" ? dash : <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>
t.kind === "plan" || (!t.live?.dlSpeed && !t.live?.upSpeed) ? (
dash
) : (
<div className="space-y-0.5 text-meta tabular-nums whitespace-nowrap">
<p className="flex items-center gap-0.5 text-foreground">
@@ -181,15 +210,35 @@ export function activeColumns(): DataColumn<DownloadBoardItem>[] {
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>
),
cell: (t) =>
t.kind === "plan" ? (
dash
) : (
<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) => (t.kind === "plan" ? <PlanWaitBadge p={t} hint /> : <ActiveStatus t={t} />),
},
{
key: "ops",
header: "",
align: "right",
cell: (t) =>
t.kind === "plan" ? (
<span className="hidden md:inline-flex">
<PlanActions p={t} size="xs" />
</span>
) : (
<PauseResume t={t} size="xs" />
),
},
{ key: "status", header: "状态", hideBelow: "md", cell: (t) => <ActiveStatus t={t} /> },
{ key: "ops", header: "", align: "right", cell: (t) => <PauseResume t={t} size="xs" /> },
];
}
@@ -284,7 +333,7 @@ export const BOARD_EMPTY: Record<DownloadBoardState, { title: string; descriptio
active: {
icon: Download,
title: "没有进行中的下载",
description: "自动收录或在站点页下载后出现在这里;下载完成的在「已完成」。",
description: "自动收录或在站点页下载后出现在这里,等批准、等磁盘空间的也在这里;下载完成的在「已完成」。",
},
completed: {
icon: CheckCircle2,