- 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]>
188 lines
7.3 KiB
TypeScript
188 lines
7.3 KiB
TypeScript
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>
|
||
);
|
||
}
|