Files
UHD_Media_Tool/apps/web/app/components/downloads/plan-row.tsx
T
rootandClaude Opus 5.5 a9147e647d 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]>
2026-09-30 01:15:25 +08:00

188 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}