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

@@ -0,0 +1,187 @@
import type { DownloadBoardPlanItem } from "@umt/contracts";
import { Check, Inbox, X } from "lucide-react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useDecideApproval, useIsAdminSession } from "~/api/agent.ts";
import { useCancelPlan, usePlanWhy } from "~/api/downloads.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { reasonCode } from "~/lib/reasons.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
import { PLAN_WAIT, PURPOSE_LABEL, planWaitHint, whyText } from "./plan-format.ts";
// DL-3:还没提交到下载器的下载(等批准 / 等磁盘空间 / 等开启自动化 / 准备提交)在下载面板里的状态、操作与详情;
// 已提交的下载在详情里显示「为什么下这个」。
export function PlanWaitBadge({ p, hint = false }: { p: DownloadBoardPlanItem; hint?: boolean }) {
const w = PLAN_WAIT[p.wait];
const h = hint ? planWaitHint(p) : null;
return (
<span className="flex max-w-56 flex-col items-start gap-0.5">
<StatusBadge tone={w.tone}>{w.label}</StatusBadge>
{h ? <span className="line-clamp-2 text-micro text-muted-foreground">{h}</span> : null}
</span>
);
}
/** 批准 / 取消:等批准的走审批(拒绝后服务端取消这次下载);其余直接取消。都要确认 */
export function PlanActions({ p, size = "sm" }: { p: DownloadBoardPlanItem; size?: "sm" | "xs" }) {
const decide = useDecideApproval();
const cancel = useCancelPlan();
const admin = useIsAdminSession();
const approval = p.approval;
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();
const decideApproval = (status: "approved" | "rejected") =>
decide
.mutateAsync({
id: approval?.id ?? "",
status,
payloadHash: approval?.payloadHash ?? "",
...(status === "rejected" ? { reason: "在下载面板取消" } : {}),
})
.then(
() => toast.success(status === "approved" ? "已批准,开始提交下载" : "已取消这次下载"),
(err) => {
toast.error(errorMessage(err));
throw err;
},
);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: 只拦截行点击
<span className="inline-flex justify-end gap-1.5 whitespace-nowrap" onClick={stop} onKeyDown={stop}>
{approval ? (
<ConfirmDialog
trigger={
<Button size={size} disabled={!admin} title={admin ? undefined : "只能在控制台管理员登录时批准"}>
<Check />
批准
</Button>
}
title={`批准下载「${p.name}」?`}
description={`${p.sizeBytes !== null ? `${formatBytes(p.sizeBytes, 1)},` : ""}批准后立即提交到下载器。`}
confirmLabel="批准"
onConfirm={() => decideApproval("approved")}
/>
) : null}
<ConfirmDialog
trigger={
<Button
variant="outline"
size={size}
disabled={!!approval && !admin}
title={approval && !admin ? "只能在控制台管理员登录时处理" : undefined}
>
<X />
取消
</Button>
}
title={`取消下载「${p.name}」?`}
description={
approval ? "不批准这次下载,智能体会收到结果。" : "释放为它预留的空间和额度,收录版本之后会重新找资源。"
}
confirmLabel="取消下载"
cancelLabel="返回"
destructive
onConfirm={() =>
approval
? decideApproval("rejected")
: cancel.mutateAsync(p.id).then(
() => toast.success("已取消这次下载"),
(err) => {
toast.error(errorMessage(err));
throw err;
},
)
}
/>
</span>
);
}
/** 「为什么下这个」:来源 · 目的,下一行选中理由要点;原始评估进技术信息 */
export function WhySection({
planId,
extraTech,
}: {
planId: string;
extraTech?: { label: string; value: string | null }[];
}) {
const why = usePlanWhy(planId);
if (why.isPending || !why.data) return null;
const t = whyText(why.data);
return (
<DetailSection title="为什么下这个">
<p className="text-sm text-foreground">{t.head}</p>
{t.points ? <p className="mt-0.5 text-caption text-muted-foreground">{t.points}</p> : null}
<TechInfo
className="mt-2"
items={[
{ label: "计划 ID", value: planId, wide: true },
why.data.source.kind === "agent" ? { label: "会话 ID", value: why.data.source.runId, wide: true } : null,
why.data.replaces ? { label: "被替换的文件 ID", value: why.data.replaces.assetId, wide: true } : null,
...(extraTech ?? []).map((x) => ({ ...x, wide: true })),
]}
>
<JsonView label="评估依据" value={why.data.evaluation} defaultOpen={false} />
</TechInfo>
</DetailSection>
);
}
/** 还没提交的下载的详情(抽屉内容) */
export function PlanDetail({ p }: { p: DownloadBoardPlanItem }) {
const hint = planWaitHint(p);
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={PLAN_WAIT[p.wait].tone}>{PLAN_WAIT[p.wait].label}</StatusBadge>
<div className="ml-auto">
<PlanActions p={p} />
</div>
</div>
{p.wait === "approval" ? (
<p className="flex flex-wrap items-center gap-1 text-caption text-muted-foreground">
这次下载要你批准后才提交,同一件事也在待处理里。
{p.approval?.decisionId ? (
<Link
to={`${consolePath("inbox")}?kind=approval_required`}
className="inline-flex items-center gap-0.5 underline underline-offset-4"
>
<Inbox className="size-3.5" />
去待处理
</Link>
) : null}
</p>
) : hint ? (
<p className="text-caption text-muted-foreground">{hint}。</p>
) : null}
<KvList
items={[
{
label: "作品",
value: p.media ? (
<Link to={consolePath("library", p.media.id)} className="hover:underline">
{p.media.title}
{p.media.year ? ` (${p.media.year})` : ""}
</Link>
) : null,
},
{ label: "大小", value: p.sizeBytes === null ? "未知" : formatBytes(p.sizeBytes, 2) },
{ label: "用途", value: PURPOSE_LABEL[p.purpose] },
{ label: "加入时间", value: formatDateTime(p.createdAt) },
]}
/>
<WhySection
planId={p.id}
extraTech={[{ label: "等待原因码", value: reasonCode(p.waitReason) ?? p.waitReason }]}
/>
</div>
);
}