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

+39 -3
View File
@@ -8,13 +8,14 @@ import {
DownloaderInstance,
type DownloadStatus,
DownloadTask,
DownloadWhy,
type PlanStatus,
} from "@umt/contracts";
import { z } from "zod";
import { TTL_SECOND } from "~/lib/api.ts";
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
// 下载任务、下载计划、缓存卷(W2-C:/download-tasks、/acquisition-plans、/download-cache/cleanup、/dashboard)
// 下载任务、计划(界面统称"下载")、缓存卷(W2-C:/download-tasks、/acquisition-plans、/download-cache/cleanup、/dashboard)
const DownloadPage = cursorPage(DownloadTask);
const PlanPage = cursorPage(AcquisitionPlan);
@@ -27,6 +28,7 @@ export const downloadKeys = {
one: (id: string) => ["downloads", "one", id] as const,
plans: (status?: PlanStatus) => ["downloads", "plans", status ?? "all"] as const,
plan: (id: string) => ["downloads", "plan", id] as const,
why: (id: string) => ["downloads", "why", id] as const,
cache: () => ["downloads", "cache"] as const,
board: (state: DownloadBoardState) => ["downloads", "board", state] as const,
};
@@ -72,6 +74,19 @@ export function useDownloadBoard(state: DownloadBoardState) {
});
}
/** 深链 ?plan=(DL-3):只取这个计划对应的面板行(等待中的计划或它的下载任务);没有行 = 已结束或不存在 */
export function useBoardPlanRow(planId: string | null) {
return useQuery({
queryKey: ["downloads", "board-plan", planId ?? ""],
enabled: !!planId,
queryFn: ({ signal }) =>
apiParsed(DownloadBoard, "/api/v1/download-tasks/board", {
params: { planId: planId ?? "", limit: 1 },
signal,
}).then((b) => b.items[0] ?? null),
});
}
export function useDownloadTask(id: string | undefined) {
return useQuery({
queryKey: downloadKeys.one(id ?? ""),
@@ -104,6 +119,27 @@ export function usePlan(id: string | null | undefined) {
});
}
/** 「为什么下这个」(DL-3):来源、目的、被替换的版本、选中理由要点与原始评估 */
export function usePlanWhy(id: string | null | undefined) {
return useQuery({
queryKey: downloadKeys.why(id ?? ""),
enabled: !!id,
staleTime: 60 * TTL_SECOND,
queryFn: ({ signal }) =>
apiParsed(DownloadWhy, "/api/v1/acquisition-plans/:id/why", { pathParams: { id: id ?? "" }, signal }),
});
}
/** 取消还没提交的下载(已预留 / 草案);待批准的走拒绝审批,服务端随之取消 */
export function useCancelPlan() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiMutate("/api/v1/acquisition-plans/:id/cancel", { pathParams: { id } }).then((d) => AcquisitionPlan.parse(d)),
onSuccess: () => qc.invalidateQueries({ queryKey: downloadKeys.all }),
});
}
/** 暂停(记为用户暂停)/ 恢复;阈值暂停在水位未回落前服务端拒绝恢复 */
export function useDownloadAction() {
const qc = useQueryClient();
@@ -186,8 +222,8 @@ export const PAUSE_REASON_LABEL = {
} as const;
export const PLAN_STATUS_LABEL: Record<PlanStatus, string> = {
draft: "草稿",
reserved: "已预留",
draft: "待批准",
reserved: "已预留空间",
submitted: "已提交",
completed: "已完成",
cancelled: "已取消",
+2 -2
View File
@@ -173,7 +173,7 @@ export function buildTimeline(input: {
key: "plan",
stage: "plan",
at: plan.createdAt,
title: `创建下载计划(${plan.purpose === "fill" ? "补缺" : plan.purpose === "upgrade" ? "升级" : "换源"})`,
title: `准备下载(${plan.purpose === "fill" ? "补缺" : plan.purpose === "upgrade" ? "升级" : "换源"})`,
detail: humanizeReason(plan.statusReason),
tone: "neutral",
});
@@ -182,7 +182,7 @@ export function buildTimeline(input: {
key: "plan-status",
stage: "plan",
at: plan.updatedAt,
title: `计划状态:${PLAN_STATUS_LABEL[plan.status]}`,
title: `下载准备:${PLAN_STATUS_LABEL[plan.status]}`,
detail: humanizeReason(plan.statusReason),
tone: plan.status === "failed" ? "destructive" : plan.status === "expired" ? "warning" : "neutral",
});