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",
});
@@ -298,7 +298,7 @@ export function OptionsCard({ runId, view, waiting }: { runId: string; view: Opt
const RISK_LABEL: Record<RiskLevel, string> = { low: "低", medium: "中", high: "高" };
const PLAN_LABEL: Record<string, string> = {
acquisition_plan: "下载计划",
acquisition_plan: "下载",
organize_plan: "整理计划",
policy_revision: "策略版本",
};
@@ -189,6 +189,8 @@ describe("聚合:进度行用能力元数据的人话计数", () => {
expect(activitySummary([call({ capabilityId: "download.inspect" }), call({ capabilityId: "site.list" })])).toBe(
"查了 1 个下载任务和站点",
);
// DL-3:不再说"生成了 1 份下载计划"
expect(activitySummary([call({ capabilityId: "download.plan" })])).toBe("准备下载 1 个资源");
});
it("一回合的全部调用聚成一行(1 次也折叠),展开前看不到能力 ID", () => {
const activity: ActivityItem[] = [{ kind: "tool", key: "c1", call: call({ capabilityId: "site.list" }) }];
@@ -43,7 +43,7 @@ export function deniedReason(call: ToolCallView): { text: string; approvable: bo
// ── 进度行的人话计数 ──
type Group = { verb: string; nouns: { noun: string; unit: string | null; n: number }[] };
type Group = { verb: string; bare: boolean; nouns: { noun: string; unit: string | null; n: number }[] };
function activityOf(id: string) {
return (
@@ -73,7 +73,7 @@ export function activitySummary(calls: Pick<ToolCallView, "capabilityId" | "stat
if (c.status === "denied" || c.status === "failed" || authDenial(c)) continue;
let g = groups.find((x) => x.verb === a.verb);
if (!g) {
g = { verb: a.verb, nouns: [] };
g = { verb: a.verb, bare: !!a.bare, nouns: [] };
groups.push(g);
}
const n = g.nouns.find((x) => x.noun === a.noun);
@@ -81,7 +81,10 @@ export function activitySummary(calls: Pick<ToolCallView, "capabilityId" | "stat
else g.nouns.push({ noun: a.noun, unit: a.unit, n: 1 });
}
const text = (g: Group) =>
cjkJoin(`${g.verb}了`, joinNouns(g.nouns.map((x) => (x.unit ? `${x.n} ${x.unit}${x.noun}` : x.noun))));
cjkJoin(
g.bare ? g.verb : `${g.verb}了`,
joinNouns(g.nouns.map((x) => (x.unit ? `${x.n} ${x.unit}${x.noun}` : x.noun))),
);
if (groups.length > 4) return `${groups.slice(0, 3).map(text).join(",")}等 ${groups.length} 类操作`;
return groups.map(text).join(",");
}
+3 -3
View File
@@ -52,7 +52,7 @@ function script(goal: string, opts: AgentSessionOptions): Step[] {
const steps: AgentPlanStep[] = [
{ id: "s1", title: "查本地库存和收录版本", status: "running", note: null },
{ id: "s2", title: "在站点搜 4K 候选", status: "pending", note: null },
{ id: "s3", title: "选资源并生成下载计划", status: "pending", note: null },
{ id: "s3", title: "选资源并准备下载", status: "pending", note: null },
{ id: "s4", title: "提交下载", status: "pending", note: null },
];
let revision = 0;
@@ -324,7 +324,7 @@ function checkupSteps(goal: string, report: string): Step[] {
]);
}
/** 例 3:方案绑定下载计划(选定即批准),选了之后直接提交 */
/** 例 3:方案绑定计划(选定即批准),选了之后直接提交 */
const DUNE_PLAN = "00000000-0000-4000-8000-00000000b003";
const DUNE_DECISION = "00000000-0000-4000-8000-00000000d003";
function optionSteps(goal: string): Step[] {
@@ -338,7 +338,7 @@ function optionSteps(goal: string): Step[] {
revision: 1,
steps: [
{ id: "s1", title: "找 1080p 资源", status: "done", note: null },
{ id: "s2", title: "生成下载计划", status: "done", note: null },
{ id: "s2", title: "准备下载", status: "done", note: null },
{ id: "s3", title: "等你选方案", status: "running", note: null },
{ id: "s4", title: "提交下载", status: "pending", note: null },
],
@@ -56,7 +56,7 @@ describe("humanizeReport(汇报兜底)", () => {
names,
);
expect(r.text).toBe(
"检测站点 被拒;查看下载任务 无法调用(返回 参数不对);生成下载计划 返回 plan=null,拒绝原因 下载盘用量未知",
"检测站点 被拒;查看下载任务 无法调用(返回 参数不对);准备下载 返回 plan=null,拒绝原因 下载盘用量未知",
);
expect(r.text).not.toMatch(/site\.health_check|invalid_input|volume_state_unknown/);
expect(humanizeReport("被规则拒绝(unauthorized),未重试", names).text).toBe("被规则拒绝(需要你批准),未重试");
+1 -1
View File
@@ -90,7 +90,7 @@ export const NAV_GROUPS: NavGroup[] = [
{
title: "整理",
items: [
{ to: "/console/downloads", label: "下载面板", icon: Download, description: "下载任务、下载计划与下载盘" },
{ to: "/console/downloads", label: "下载面板", icon: Download, description: "进行中与已完成的下载、下载盘" },
{
to: "/console/organize",
label: "整理记录",
@@ -53,7 +53,7 @@ export const ACTIVE_TASK: TaskStatus[] = ["pending", "running", "waiting"];
export const SUBJECT_LABEL: Record<string, string> = {
download_task: "下载任务",
acquisition_plan: "下载计划",
acquisition_plan: "下载",
acquisition_slot: "收录版本",
organization_record: "整理记录",
organize_request: "整理请求",
@@ -73,7 +73,7 @@ export function subjectLink(t: Pick<Task, "subjectType" | "subjectId">): string
case "download_task":
return consolePath("downloads", t.subjectId);
case "acquisition_plan":
return consolePath("downloads", "plans");
return `${consolePath("downloads")}?plan=${encodeURIComponent(t.subjectId)}`;
case "organization_record":
return consolePath("organize", t.subjectId);
case "media_entity":
@@ -10,7 +10,7 @@ import { Timeline } from "~/components/console/timeline.tsx";
export const STAGE_LABEL: Record<TimelineStage, string> = {
observation: "发现",
plan: "计划",
plan: "准备",
download: "下载",
organization: "整理",
agent: "智能体",
@@ -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,
@@ -23,13 +23,11 @@ import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx
import { Button } from "~/components/ui/button.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
import { planReasonText } from "~/lib/labels.ts";
import { humanizeReason, reasonCode, reasonLine } from "~/lib/reasons.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
import { liveItems } from "./board-format.ts";
import { BusinessTimeline } from "./business-timeline.tsx";
const PLAN_PURPOSE_LABEL: Record<string, string> = { fill: "补缺", upgrade: "升级", replace_source: "换源" };
import { WhySection } from "./plan-row.tsx";
/** 进度:细线 + 文字;总量未知时不伪造百分比 */
export function ProgressLine({ t }: { t: DownloadTask }) {
@@ -91,7 +89,7 @@ export function PauseResume({ t, size = "sm" }: { t: DownloadTask; size?: "sm" |
);
}
/** 下载任务详情:状态、阈值说明、业务时间线、文件、关联计划 */
/** 下载任务详情:状态、阈值说明、业务时间线、为什么下这个(DL-3)、文件 */
export function DownloadDetail({ task, live }: { task: DownloadTask; live?: DownloadLive | null }) {
const plan = usePlan(task.acquisitionPlanId);
const names = useDownloaderNames();
@@ -157,6 +155,20 @@ export function DownloadDetail({ task, live }: { task: DownloadTask; live?: Down
</p>
) : null}
{task.acquisitionPlanId ? (
<WhySection
planId={task.acquisitionPlanId}
extraTech={[
{
label: "计划状态",
value: plan.data
? `${PLAN_STATUS_LABEL[plan.data.status]}${plan.data.statusReason ? ` · ${plan.data.statusReason}` : ""}`
: null,
},
]}
/>
) : null}
<DetailSection title="进度">
<ProgressLine t={live ? { ...task, progress: live.progress } : task} />
<KvList
@@ -194,28 +206,6 @@ export function DownloadDetail({ task, live }: { task: DownloadTask; live?: Down
<BusinessTimeline events={trace.events} loading={trace.loading} />
</DetailSection>
{plan.data ? (
<DetailSection title="下载计划">
<KvList
items={[
{ label: "状态", value: PLAN_STATUS_LABEL[plan.data.status] },
{ label: "用途", value: PLAN_PURPOSE_LABEL[plan.data.purpose] ?? plan.data.purpose },
{ label: "预留空间", value: formatBytes(plan.data.reservedBytes, 1) },
{ label: "说明", value: planReasonText(plan.data.statusReason), wide: true },
]}
/>
<TechInfo
className="mt-2"
items={[
{ label: "计划 ID", value: plan.data.id, wide: true },
{ label: "说明原码", value: plan.data.statusReason },
]}
>
<JsonView label="评估依据" value={plan.data.evaluation} />
</TechInfo>
</DetailSection>
) : null}
{task.fileList?.length ? (
<DetailSection title={`文件(${task.fileList.length})`}>
<ul className="max-h-60 space-y-1 overflow-auto text-meta">
@@ -1,33 +0,0 @@
import { NavLink } from "react-router";
import { cn } from "~/lib/utils.ts";
// 下载面板的两个页签(IA-3:下载计划并入下载面板,路由保持 /downloads 与 /downloads/plans)
const TABS = [
{ to: "/console/downloads", label: "下载任务", end: true },
{ to: "/console/downloads/plans", label: "下载计划", end: true },
];
export function DownloadsTabs() {
return (
<nav aria-label="下载面板" className="scroll-bleed mb-4 flex max-w-full items-center gap-1 overflow-x-auto pb-1">
{TABS.map((t) => (
<NavLink
key={t.to}
to={t.to}
end={t.end}
className={({ isActive }) =>
cn(
"inline-flex h-8 shrink-0 items-center rounded-full border border-transparent px-3 text-sm font-medium whitespace-nowrap transition-[background-color,color] duration-micro",
isActive
? "border-(--mat-pill-selected-border) bg-(--mat-pill-selected-bg) text-foreground"
: "text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
)
}
>
{t.label}
</NavLink>
))}
</nav>
);
}
@@ -0,0 +1,92 @@
import type { DownloadWhy } from "@umt/contracts";
import { describe, expect, it } from "vitest";
import { legacyRedirect } from "../../routes/console/redirects.ts";
import { droppedReason, PLAN_WAIT, planWaitHint, whyPoints, whyPurpose, whySource, whyText } from "./plan-format.ts";
// DL-3:下载计划并入下载面板——在等什么、「为什么下这个」、没提交就结束的原因、旧深链。
const why = (over: Partial<DownloadWhy> = {}): DownloadWhy => ({
planId: "00000000-0000-4000-8000-000000000001",
source: { kind: "subscription", id: "00000000-0000-4000-8000-000000000002", name: "人生切割术 第 2 季" },
purpose: "fill",
slotName: "4K",
replaces: null,
reasons: ["hard_conditions_met", "pri:90", "rule_group:abc@3"],
promotion: "free",
seeders: 41,
siteName: "测试站",
evaluation: {},
...over,
});
describe("为什么下这个", () => {
it("订阅补缺:来源 · 目的,要点是免费 · 做种 · 符合收录版本", () => {
expect(whyText(why())).toEqual({
head: "订阅「人生切割术 第 2 季」 · 补缺",
points: "免费 · 做种 41 · 符合收录版本「4K」",
});
});
it("升级写替换的版本;内部码(pri、rule_group)不给人看", () => {
const w = why({
purpose: "upgrade",
replaces: { assetId: "00000000-0000-4000-8000-000000000003", label: "1080p 蓝光压制" },
reasons: ["source_tier_improved", "pri:95", "tier:原盘优先"],
promotion: null,
seeders: null,
});
expect(whyPurpose(w)).toBe("升级(替换 1080p 蓝光压制)");
expect(whyPoints(w)).toEqual(["来源更好", "命中「原盘优先」"]);
});
it("来源:智能体会话 / 手动 / 自动收录", () => {
expect(whySource({ kind: "agent", runId: "r", name: "补齐 4K 版本" })).toBe("智能体会话「补齐 4K 版本」");
expect(whySource({ kind: "agent", runId: "r", name: "" })).toBe("智能体会话");
expect(whySource({ kind: "manual" })).toBe("手动下载");
expect(whySource({ kind: "auto" })).toBe("自动收录");
});
it("换源没有被替换的版本时只写换源;做种 0 照写", () => {
const w = why({ purpose: "replace_source", reasons: [], promotion: "none", seeders: 0 });
expect(whyText(w)).toEqual({ head: "订阅「人生切割术 第 2 季」 · 换源", points: "做种 0" });
});
});
describe("在等什么", () => {
it("状态人话", () => {
expect(PLAN_WAIT.approval.label).toBe("等你批准");
expect(PLAN_WAIT.space.label).toBe("等磁盘空间");
expect(PLAN_WAIT.automation_off.label).toBe("等开启自动化");
});
it("补充一句走原因码表,原码不给人看", () => {
expect(planWaitHint({ wait: "space", waitReason: "cache:projected_over_threshold" })).toBe(
"下完会超过下载盘暂停线",
);
expect(planWaitHint({ wait: "automation_off", waitReason: "automation_disabled" })).toBe(
"自动化已关闭。在设置 → 自动化里打开",
);
expect(planWaitHint({ wait: "approval", waitReason: null })).toBeNull();
expect(planWaitHint({ wait: "queued", waitReason: "something_unknown" })).toBeNull();
});
});
describe("没提交就结束的下载", () => {
it("原因码 → 一句话", () => {
expect(droppedReason("cancelled_by_user")).toBe("你取消了这个下载");
expect(droppedReason("approval_expired")).toBe("等批准超时,下载没有提交");
expect(droppedReason("rejected: torrent is invalid")).toBe("下载器拒绝了这个种子");
expect(droppedReason("source_failed: auth_expired: 401")).toBe("从站点取种子失败。检查站点登录后重新下载");
expect(droppedReason(null)).toBeNull();
});
});
describe("旧的下载计划地址", () => {
it("页签与带 id 的深链都跳到下载面板", () => {
expect(legacyRedirect("/console/downloads/plans", "")).toBe("/console/downloads");
expect(legacyRedirect("/console/downloads/plans", "?plan=abc")).toBe("/console/downloads?plan=abc");
expect(legacyRedirect("/console/downloads/plans/abc", "")).toBe("/console/downloads?plan=abc");
expect(legacyRedirect("/console/tasks", "?tab=plans")).toBe("/console/downloads");
expect(legacyRedirect("/console/downloads", "?plan=abc")).toBeNull();
});
});
@@ -0,0 +1,80 @@
import type { DownloadBoardPlanItem, DownloadWhy, PlanPurpose, PlanWait } from "@umt/contracts";
import { promotionLabel } from "~/components/workbench/release-fields.ts";
import { reasonLine } from "~/lib/reasons.ts";
// DL-3:下载计划并入下载面板后的展示换算——还没提交的下载在等什么、「为什么下这个」一行。
// 界面上不出现"计划",统一说"下载"。
export const PURPOSE_LABEL: Record<PlanPurpose, string> = { fill: "补缺", upgrade: "升级", replace_source: "换源" };
export const PLAN_WAIT: Record<PlanWait, { label: string; tone: "warning" | "neutral" }> = {
approval: { label: "等你批准", tone: "warning" },
space: { label: "等磁盘空间", tone: "warning" },
automation_off: { label: "等开启自动化", tone: "warning" },
queued: { label: "准备提交", tone: "neutral" },
};
/** 在等什么的补充一句(下载盘哪条线、为什么还没提交);等批准与正常排队不补充 */
export function planWaitHint(p: Pick<DownloadBoardPlanItem, "wait" | "waitReason">): string | null {
if (p.wait === "approval") return null;
if (!p.waitReason) return null;
return reasonLine(p.waitReason, "") || null;
}
/** 来源:订阅「人生切割术 第 2 季」/ 智能体会话「补齐 4K 版本」/ 手动下载 / 自动收录 */
export function whySource(s: DownloadWhy["source"]): string {
switch (s.kind) {
case "subscription":
return s.name ? `订阅「${s.name}」` : "订阅";
case "agent":
return s.name ? `智能体会话「${s.name}」` : "智能体会话";
case "manual":
return "手动下载";
case "auto":
return "自动收录";
}
}
/** 目的:补缺 / 升级(替换 1080p 蓝光压制)/ 换源 */
export function whyPurpose(w: Pick<DownloadWhy, "purpose" | "replaces">): string {
const base = PURPOSE_LABEL[w.purpose];
return w.purpose !== "fill" && w.replaces ? `${base}(替换 ${w.replaces.label})` : base;
}
/**
* 选中理由要点:促销 · 做种数 · 评估结论(符合收录版本 / 来源更好 / 分辨率更高 / 优先级更高 / 命中某档)。
* 内部码(pri:、rule_group:、按策略放行的未知字段)不给人看,原始评估在技术信息里。
*/
export function whyPoints(w: Pick<DownloadWhy, "reasons" | "promotion" | "seeders" | "slotName">): string[] {
const out: string[] = [];
const promo = w.promotion ? promotionLabel({ promotion: { kind: w.promotion }, promotionExpiresAt: null }) : null;
if (promo) out.push(promo.label);
if (w.seeders !== null) out.push(`做种 ${w.seeders}`);
for (const r of w.reasons) {
if (r === "hard_conditions_met") out.push(w.slotName ? `符合收录版本「${w.slotName}」` : "符合收录版本");
else if (r === "source_tier_improved") out.push("来源更好");
else if (r === "resolution_improved") out.push("分辨率更高");
else if (r === "priority_improved") out.push("优先级更高");
else if (r.startsWith("tier:") && r.length > 5) out.push(`命中「${r.slice(5)}」`);
}
return [...new Set(out)];
}
/** 「为什么下这个」整行:来源 · 目的,下一行是要点 */
export function whyText(w: DownloadWhy): { head: string; points: string } {
return { head: `${whySource(w.source)} · ${whyPurpose(w)}`, points: whyPoints(w).join(" · ") };
}
/** 没提交就结束的下载:状态 + 原因一句 */
export const DROPPED_STATUS: Record<
"failed" | "cancelled" | "expired",
{ label: string; tone: "destructive" | "neutral" | "warning" }
> = {
failed: { label: "没提交成功", tone: "destructive" },
cancelled: { label: "已取消", tone: "neutral" },
expired: { label: "已过期", tone: "warning" },
};
export function droppedReason(statusReason: string | null | undefined): string | null {
return statusReason ? reasonLine(statusReason, "") || null : null;
}
@@ -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>
);
}
@@ -23,7 +23,7 @@ import { formatBytes } from "~/lib/utils.ts";
// 与站点工作台同一套下载说明;请求走 POST /search/releases/{siteId}/{siteReleaseId}/plan(同 /acquisition-plans 流程)
const STEPS = [
"生成下载计划,预留收录版本和预算;每个收录版本同时只有一个在途计划",
"预留收录版本和预算;每个收录版本同时只有一个在途的下载",
"已有相同资源或相同 hash 的任务时直接接管,不重复提交",
"缓存卷超过硬阈值或读不到磁盘状态时无法提交",
"提交前复核免费、做种等易变字段",
@@ -72,10 +72,10 @@ export function SearchDownloadDialog({
if (r.submitted && r.plan) {
setRejected(null);
setDone(r.plan.id);
toast.success(`已提交${slotName(r.slotId) ? `(${slotName(r.slotId)})` : ""},进度见「下载队列」`);
toast.success(`已提交${slotName(r.slotId) ? `(${slotName(r.slotId)})` : ""},进度见「下载面板」`);
} else {
setRejected(r.rejected);
toast.warning("没有生成下载计划");
toast.warning("没有下载");
}
},
onError: (e) => toast.error(errorMessage(e)),
@@ -131,7 +131,7 @@ export function SearchDownloadDialog({
) : null}
{rejected ? (
<div className="rounded-xl border border-warning/40 bg-warning/10 px-3 py-2.5 text-caption text-warning">
<p className="mb-1">没有生成下载计划:</p>
<p className="mb-1">没有下载:</p>
<ul className="list-disc space-y-0.5 pl-5">
{rejected.map((r, i) => (
<li key={`${r.slotId}-${r.code}-${i}`} className="break-words">
@@ -144,8 +144,8 @@ export function SearchDownloadDialog({
) : null}
{done ? (
<p className="text-caption text-success">
计划已生成。
<Link to="/console/downloads/plans" className="underline underline-offset-4">
已加入下载。
<Link to={`/console/downloads?plan=${done}`} className="underline underline-offset-4">
查看
</Link>
</p>
@@ -43,7 +43,7 @@ const CACHE_CARD: CardSpec = {
const BUDGET_CARD: CardSpec = {
anchor: "budget",
title: "每日预算",
description: "任一项到上限后不再建新的下载计划;前两项按自然日计。",
description: "任一项到上限后不再开始新的下载;前两项按自然日计。",
fields: [
{ name: "budget.maxNewTitlesPerDay", label: "每日新作品数", kind: "number" },
{ name: "budget.maxDownloadGiBPerDay", label: "每日下载量(GiB)", kind: "number" },
@@ -195,7 +195,7 @@ export function CoverageMatrix({
);
}
/** 点格打开:资产 / 质量 / pri / 计划 / 原因 / 可升级 / 延后 → 手动补全、跳下载计划 */
/** 点格打开:资产 / 质量 / pri / 计划 / 原因 / 可升级 / 延后 → 手动补全、跳下载面板 */
export function CellSheet({
sel,
subscriptionId,
@@ -244,9 +244,9 @@ export function CellSheet({
<>
{c.planId ? (
<Button asChild variant="outline">
<Link to={`${consolePath("downloads", "plans")}?plan=${c.planId}`}>
<Link to={`${consolePath("downloads")}?plan=${c.planId}`}>
<Download />
下载计划
查看下载
</Link>
</Button>
) : null}
@@ -300,7 +300,7 @@ export function CellSheet({
) : null,
},
{
label: "下载计划",
label: "下载",
value: c.planId
? `${PLAN_STAGE[c.planStage ?? ""] ?? c.planStage ?? "在途"} · ${c.planId.slice(0, 8)}`
: null,
+2 -2
View File
@@ -106,10 +106,10 @@ export function businessLink(b: EngineRun["business"], input?: unknown): { to: s
case "subscription":
return { to: consolePath("subscriptions"), label: "订阅" };
case "download": {
// 计划还没提交时 refId 是下载计划
// 还没提交到下载器时 refId 是计划 id(下载面板按 ?plan= 打开对应行)
const i = (input ?? {}) as { downloadTaskId?: unknown; acquisitionPlanId?: unknown };
if (!i.downloadTaskId && i.acquisitionPlanId === b.refId)
return { to: consolePath("downloads", "plans"), label: "下载计划" };
return { to: `${consolePath("downloads")}?plan=${id}`, label: "下载" };
return { to: `/console/downloads/${id}`, label: "下载任务" };
}
case "organization":
@@ -19,7 +19,7 @@ import { errorMessage } from "~/lib/api.ts";
import { formatBytes } from "~/lib/utils.ts";
const STEPS = [
"生成下载计划,预留收录版本和预算;每个收录版本同时只有一个在途计划",
"预留收录版本和预算;每个收录版本同时只有一个在途的下载",
"已有相同资源或相同 hash 的任务时直接接管,不重复提交",
"缓存卷超过硬阈值或读不到磁盘状态时无法提交",
"提交前复核免费、做种等易变字段",
@@ -56,10 +56,10 @@ export function DownloadDialog({
if (r.submitted && r.plan) {
setRejected(null);
setDone(r.plan.id);
toast.success(`已提交${slotName(r.slotId) ? `(${slotName(r.slotId)})` : ""},进度见「下载队列」`);
toast.success(`已提交${slotName(r.slotId) ? `(${slotName(r.slotId)})` : ""},进度见「下载面板」`);
} else {
setRejected(r.rejected);
toast.warning("没有生成下载计划");
toast.warning("没有下载");
}
},
onError: (e) => toast.error(errorMessage(e)),
@@ -109,7 +109,7 @@ export function DownloadDialog({
)}
{rejected ? (
<div className="rounded-xl border border-warning/40 bg-warning/10 px-3 py-2.5 text-caption text-warning">
<p className="mb-1">没有生成下载计划:</p>
<p className="mb-1">没有下载:</p>
<ul className="list-disc space-y-0.5 pl-5">
{rejected.map((r, i) => (
<li key={`${r.slotId}-${r.code}-${i}`} className="break-words">
@@ -122,8 +122,8 @@ export function DownloadDialog({
) : null}
{done ? (
<p className="text-caption text-success">
计划已生成。
<Link to="/console/downloads/plans" className="underline underline-offset-4">
已加入下载。
<Link to={`/console/downloads?plan=${done}`} className="underline underline-offset-4">
查看
</Link>
</p>
+1 -1
View File
@@ -150,7 +150,7 @@ export const ACTOR_TYPES = [
];
export const actorTypeLabel = (t: string) => ACTOR_TYPES.find((a) => a.value === t)?.label ?? t;
/** 下载计划 statusReason(`added`、`organize:succeeded` 等)→ 中文;不认识的原样显示 */
/** 计划 statusReason(`added`、`organize:succeeded` 等)→ 中文;不认识的原样显示 */
const PLAN_REASON: Record<string, string> = {
added: "已添加到下载器",
"organize:succeeded": "整理成功",
+1 -1
View File
@@ -28,7 +28,7 @@ export function reasonCode(r: { code?: string | null; message?: string | null }
return [c, m && looksLikeCode(m) && m !== c ? m : null].filter(Boolean).join(" · ") || null;
}
/** 原因串里的内部码替换成人话(如下载计划 statusReason、任务 waitReason) */
/** 原因串里的内部码替换成人话(如计划 statusReason、任务 waitReason) */
export function humanizeReason(v: string | null | undefined): string | null {
if (!v) return null;
return looksLikeCode(v) ? reasonLine(v, v) : v;
+1
View File
@@ -27,6 +27,7 @@ export default [
route("rules", "routes/console/rules.tsx"),
route("downloads", "routes/console/downloads.tsx"),
route("downloads/plans", "routes/console/downloads.plans.tsx"),
route("downloads/plans/:id", "routes/console/downloads.plans.tsx", { id: "downloads-plan-legacy" }),
route("downloads/:id", "routes/console/downloads.$id.tsx"),
route("tasks", "routes/console/tasks.tsx"),
route("tasks/:id", "routes/console/tasks.$id.tsx"),
+3 -2
View File
@@ -38,7 +38,8 @@ const ACTION_LABEL: Record<string, string> = {
"pi_upstream.oauth_logout": "退出订阅登录",
"schedule.run_now": "立即执行定时任务",
"schedule.skip": "跳过定时任务",
"acquisition_plan.manual_submit": "手动提交下载计划",
"acquisition_plan.manual_submit": "手动提交下载",
"acquisition_plan.cancel": "取消下载",
"engine.run_retry": "重跑任务",
"capability.invoke": "智能体操作",
"capability.denied": "智能体操作被拒绝",
@@ -75,7 +76,7 @@ const TARGET_LABEL: Record<string, string> = {
site_account: "站点账号",
feed_source: "RSS",
engine_run: "任务",
acquisition_plan: "下载计划",
acquisition_plan: "下载",
download_task: "下载任务",
media_entity: "作品",
source_release: "资源",
+5 -154
View File
@@ -1,157 +1,8 @@
import type { AcquisitionPlan, PlanStatus } from "@umt/contracts";
import { ClipboardList } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { PLAN_STATUS_LABEL, useDownloadTasks, usePlans } from "~/api/downloads.ts";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.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 { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { useQueryState } from "~/components/console/use-query-state.ts";
import { DownloadsTabs } from "~/components/downloads/downloads-tabs.tsx";
import { PRIORITY } from "~/components/library/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { planReasonText } from "~/lib/labels.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
import { legacyRedirectLoader } from "./redirects.ts";
export const meta = () => [{ title: "下载计划 · 下载面板 · UHD 媒体自动化控制台" }];
// DL-3:下载计划并入下载面板;旧地址 /console/downloads/plans(?plan=)与 /plans/:id 跳到下载面板并打开对应行
export const clientLoader = legacyRedirectLoader;
const PURPOSE = { fill: "补缺", upgrade: "升级", replace_source: "换源" } as const;
const planTone = (s: PlanStatus) =>
s === "failed" ? "destructive" : s === "completed" ? "success" : s === "expired" ? "warning" : "neutral";
export default function DownloadPlansPage() {
const f = useQueryState(["status"] as const);
const [openPlan, setOpenPlan] = useState<AcquisitionPlan | null>(null);
const plans = usePlans((f.values.status || undefined) as PlanStatus | undefined);
// 计划 → 下载任务:同一列表按 acquisitionPlanId 对上
const downloads = useDownloadTasks({});
const rows = plans.data?.pages.flatMap((p) => p.items);
const linkedDownload = openPlan
? downloads.data?.pages.flatMap((p) => p.items).find((t) => t.acquisitionPlanId === openPlan.id)
: undefined;
const columns: DataColumn<AcquisitionPlan>[] = [
{
key: "purpose",
header: "计划",
cell: (p) => (
<p className="text-foreground">
{PURPOSE[p.purpose]} · 优先级 {PRIORITY[p.priority as keyof typeof PRIORITY] ?? p.priority}
</p>
),
},
{
key: "status",
header: "状态",
cell: (p) => <StatusBadge tone={planTone(p.status)}>{PLAN_STATUS_LABEL[p.status]}</StatusBadge>,
},
{
key: "reserved",
header: "预留空间",
hideBelow: "md",
align: "right",
cell: (p) => <span className="tabular-nums">{formatBytes(p.reservedBytes, 1)}</span>,
},
{
key: "reason",
header: "说明",
hideBelow: "lg",
cell: (p) => <span className="line-clamp-1 text-muted-foreground">{planReasonText(p.statusReason) ?? "—"}</span>,
},
{
key: "at",
header: "创建",
hideBelow: "sm",
cell: (p) => <span className="whitespace-nowrap text-muted-foreground">{relTime(p.createdAt)}</span>,
},
];
return (
<>
<PageHeader title="下载面板" />
<DownloadsTabs />
<FilterBar onReset={f.active ? f.reset : undefined}>
<FilterSelect
label="状态"
value={f.values.status}
onChange={(v) => f.set("status", v)}
options={Object.entries(PLAN_STATUS_LABEL).map(([value, label]) => ({ value, label }))}
/>
</FilterBar>
<DataTable
columns={columns}
rows={rows}
rowKey={(p) => p.id}
loading={plans.isPending}
error={plans.error}
onRetry={() => void plans.refetch()}
onRowClick={setOpenPlan}
selectedKey={openPlan?.id}
hasMore={plans.hasNextPage}
onLoadMore={() => void plans.fetchNextPage()}
loadingMore={plans.isFetchingNextPage}
empty={{
icon: ClipboardList,
title: f.active ? "没有符合筛选的下载计划" : "还没有下载计划",
description: f.active ? undefined : "选中可下载的资源后出现在这里。",
}}
/>
<DetailSheet
open={!!openPlan}
onOpenChange={(o) => !o && setOpenPlan(null)}
title={openPlan ? `${PURPOSE[openPlan.purpose]}计划` : ""}
description={planReasonText(openPlan?.statusReason) ?? undefined}
footer={
linkedDownload ? (
<Button asChild size="sm">
<Link to={consolePath("downloads", linkedDownload.id)}>查看下载任务</Link>
</Button>
) : null
}
>
{openPlan ? (
<>
<KvList
items={[
{
label: "状态",
value: (
<StatusBadge tone={planTone(openPlan.status)}>{PLAN_STATUS_LABEL[openPlan.status]}</StatusBadge>
),
},
{
label: "优先级",
value: PRIORITY[openPlan.priority as keyof typeof PRIORITY] ?? openPlan.priority,
},
{ label: "预留空间", value: formatBytes(openPlan.reservedBytes, 1) },
{ label: "过期于", value: formatDateTime(openPlan.expiresAt) },
{ label: "创建", value: formatDateTime(openPlan.createdAt) },
]}
/>
<TechInfo
className="mt-4"
items={[
{ label: "计划 ID", value: openPlan.id, wide: true },
{ label: "收录版本 ID", value: openPlan.slotId, wide: true },
{ label: "资源 ID", value: openPlan.sourceReleaseId, wide: true },
{ label: "说明原码", value: openPlan.statusReason },
]}
>
<JsonView label="评估依据" value={openPlan.evaluation} defaultOpen={false} />
<JsonView label="预算预留" value={openPlan.budgetReservation} defaultOpen={false} />
</TechInfo>
</>
) : null}
</DetailSheet>
</>
);
export default function LegacyRedirect() {
return null;
}
+81 -13
View File
@@ -1,14 +1,16 @@
import { useQueryClient } from "@tanstack/react-query";
import type { DownloadBoardState } from "@umt/contracts";
import type { DownloadBoardItem, DownloadBoardRow, DownloadBoardState } from "@umt/contracts";
import { EventType } from "@umt/contracts";
import { RefreshCw } from "lucide-react";
import { useMemo, useState } from "react";
import { Link } from "react-router";
import { useDownloadBoard } from "~/api/downloads.ts";
import { DataTable } from "~/components/console/data-table.tsx";
import { useEffect, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { useBoardPlanRow, useDownloadBoard, usePlan } from "~/api/downloads.ts";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
import { LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { readBoardState, writeBoardState } from "~/components/downloads/board-format.ts";
import { CacheVolumes } from "~/components/downloads/cache-volumes.tsx";
import {
@@ -19,13 +21,17 @@ import {
TransferLine,
} from "~/components/downloads/download-board.tsx";
import { DownloadDetail } from "~/components/downloads/download-detail.tsx";
import { DownloadsTabs } from "~/components/downloads/downloads-tabs.tsx";
import { DROPPED_STATUS, droppedReason } from "~/components/downloads/plan-format.ts";
import { PlanDetail, WhySection } from "~/components/downloads/plan-row.tsx";
import { Button } from "~/components/ui/button.tsx";
import { useSse } from "~/lib/sse.ts";
export const meta = () => [{ title: "下载面板 · UHD 媒体自动化控制台" }];
// DL-2:默认只看进行中;已完成单独一段(本浏览器记住选择)。页面可见时每 4 秒刷新速度,切到后台停。
// DL-3:没提交到下载器的下载(等批准 / 等磁盘空间…)也是「进行中」的行;?plan=<计划 id> 深链打开对应行。
const COMPLETED = new Set(["completed", "importing", "imported"]);
export default function DownloadsPage() {
// SPA 模式:路由组件只在浏览器渲染,可直接读本地记住的分段
@@ -35,8 +41,19 @@ export default function DownloadsPage() {
writeBoardState(s);
};
const [openId, setOpenId] = useState<string | null>(null);
const [params, setParams] = useSearchParams();
const deepPlan = params.get("plan");
const qc = useQueryClient();
const board = useDownloadBoard(state);
const deep = useBoardPlanRow(deepPlan);
// 深链:找到行就切到它所在的分段并打开;没有行(已结束的下载)打开结束说明
useEffect(() => {
const row = deep.data;
if (!row) return;
setStateRaw(row.kind === "task" && COMPLETED.has(row.status) ? "completed" : "active");
setOpenId(row.id);
}, [deep.data]);
// 状态变化、阈值事件到达时立即刷新(速度靠轮询)
useSse(
@@ -50,11 +67,23 @@ export default function DownloadsPage() {
);
const pages = board.data?.pages;
const rows = pages?.flatMap((p) => p.items);
const allRows = pages?.flatMap((p) => p.items);
const rows: DownloadBoardRow[] | undefined =
state === "completed" ? allRows?.filter((r): r is DownloadBoardItem => r.kind === "task") : allRows;
const first = pages?.[0];
const open = rows?.find((t) => t.id === openId) ?? null;
const columns = useMemo(() => (state === "active" ? activeColumns() : completedColumns()), [state]);
const open = rows?.find((t) => t.id === openId) ?? (deep.data?.id === openId ? deep.data : null) ?? null;
const columns = useMemo(
() => (state === "active" ? activeColumns() : (completedColumns() as unknown as DataColumn<DownloadBoardRow>[])),
[state],
);
const empty = BOARD_EMPTY[state];
const closeDeep = () => {
if (!deepPlan) return;
const next = new URLSearchParams(params);
next.delete("plan");
setParams(next, { replace: true });
};
const endedPlan = deepPlan && deep.isSuccess && deep.data === null ? deepPlan : null;
return (
<>
@@ -67,7 +96,6 @@ export default function DownloadsPage() {
</Button>
}
/>
<DownloadsTabs />
<CacheVolumes />
<div className="mb-3 flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<BoardSegments value={state} counts={first?.counts} onChange={setState} />
@@ -90,19 +118,59 @@ export default function DownloadsPage() {
<DetailSheet
open={!!open}
onOpenChange={(o) => !o && setOpenId(null)}
onOpenChange={(o) => {
if (o) return;
setOpenId(null);
closeDeep();
}}
width="lg"
title={open?.name ?? ""}
footer={
open ? (
open?.kind === "task" ? (
<Button asChild variant="outline" size="sm">
<Link to={consolePath("downloads", open.id)}>打开详情页</Link>
</Button>
) : null
}
>
{open ? <DownloadDetail task={open} live={open.live} /> : null}
{open?.kind === "task" ? <DownloadDetail task={open} live={open.live} /> : null}
{open?.kind === "plan" ? <PlanDetail p={open} /> : null}
</DetailSheet>
<EndedPlanSheet planId={endedPlan} onClose={closeDeep} />
</>
);
}
/** 深链指向已结束的下载(没提交成功 / 已取消 / 已过期):说明原因与来由;作品的下载历史里也有 */
function EndedPlanSheet({ planId, onClose }: { planId: string | null; onClose: () => void }) {
const plan = usePlan(planId);
const p = plan.data;
const ended = p && (p.status === "failed" || p.status === "cancelled" || p.status === "expired") ? p.status : null;
return (
<DetailSheet
open={!!planId}
onOpenChange={(o) => !o && onClose()}
title={ended ? "这次下载没有提交" : "下载"}
description={p ? (droppedReason(p.statusReason) ?? undefined) : undefined}
>
{plan.isPending ? (
<LoadingState rows={3} />
) : !p ? (
<p className="text-sm text-muted-foreground">没找到这次下载,可能已被清理。</p>
) : (
<div className="space-y-5">
{ended ? (
<StatusBadge tone={DROPPED_STATUS[ended].tone}>{DROPPED_STATUS[ended].label}</StatusBadge>
) : (
<p className="text-sm text-muted-foreground">这次下载已经结束。</p>
)}
<WhySection
planId={p.id}
extraTech={[{ label: "状态原码", value: `${p.status} · ${p.statusReason ?? "—"}` }]}
/>
</div>
)}
</DetailSheet>
);
}
+49 -13
View File
@@ -45,6 +45,7 @@ import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty
import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { DROPPED_STATUS, droppedReason, PURPOSE_LABEL } from "~/components/downloads/plan-format.ts";
import { evidenceLabel, FieldLockDialog, IdentityDialog, MergeDialog } from "~/components/library/entity-dialogs.tsx";
import {
CONTENT_TYPE,
@@ -88,7 +89,7 @@ import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { useUiPrefs } from "~/lib/ui-prefs.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
// 作品详情(REQ §13.1 / §13.4.3):实际路径、外部身份映射及状态、元数据来源、各 Slot、候选拒绝理由、
// 内容单元、下载历史与校验结果,同一页同时展示。
@@ -871,11 +872,7 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
{d.downloadTask.name}
</Link>
<p className="text-meta text-muted-foreground">
{d.plan
? `${d.plan.purpose === "upgrade" ? "升级" : d.plan.purpose === "fill" ? "补缺" : "换源"}计划 · `
: d.downloadTask.origin === "adopted"
? "接管 · "
: ""}
{d.plan ? `${PURPOSE_LABEL[d.plan.purpose]} · ` : d.downloadTask.origin === "adopted" ? "接管 · " : ""}
{formatDateTime(d.downloadTask.createdAt)}
</p>
</div>
@@ -935,15 +932,54 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
// 没有下载任务的整理记录(文件管理按路径手动整理等),下载任务的记录已在上表
const listed = new Set(detail.downloads.map((d) => d.downloadTask.id));
const manual = (detail.organizationRecords ?? []).filter((r) => !r.downloadTaskId || !listed.has(r.downloadTaskId));
// DL-3:没提交到下载器就结束的下载(失败 / 取消 / 过期),原因走原因码表,原码进技术信息
const dropped = detail.droppedPlans ?? [];
return (
<Panel title="下载历史与校验结果" bodyClassName="p-0 sm:p-0">
<DataTable
plain
columns={columns}
rows={detail.downloads}
rowKey={(d) => d.downloadTask.id}
empty={{ title: manual.length ? "没有下载任务,只有手动整理记录" : "还没有下载记录" }}
/>
{detail.downloads.length || !dropped.length ? (
<DataTable
plain
columns={columns}
rows={detail.downloads}
rowKey={(d) => d.downloadTask.id}
empty={{ title: manual.length ? "没有下载任务,只有手动整理记录" : "还没有下载记录" }}
/>
) : null}
{dropped.length ? (
<div className={cn("px-4 py-3 sm:px-5", detail.downloads.length ? "border-t border-border/60" : "")}>
<p className="mb-2 text-meta text-muted-foreground">没提交到下载器</p>
<ul className="space-y-2">
{dropped.map(({ plan, releaseTitle }) => {
const st = DROPPED_STATUS[plan.status as keyof typeof DROPPED_STATUS] ?? DROPPED_STATUS.failed;
const why = droppedReason(plan.statusReason);
return (
<li key={plan.id} className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<StatusBadge tone={st.tone}>{st.label}</StatusBadge>
<Link
to={`${consolePath("downloads")}?plan=${plan.id}`}
className="min-w-0 max-w-full truncate font-mono text-caption text-foreground hover:underline sm:max-w-md"
title={releaseTitle ?? undefined}
>
{releaseTitle ?? "未知资源"}
</Link>
<span className="text-meta text-muted-foreground tabular-nums">
{PURPOSE_LABEL[plan.purpose]} · {formatDateTime(plan.updatedAt)}
</span>
</div>
{why ? <p className="mt-0.5 text-meta text-muted-foreground">{why}</p> : null}
<TechInfo
items={[
{ label: "计划 ID", value: plan.id, wide: true },
{ label: "原因码", value: plan.statusReason, wide: true },
]}
/>
</li>
);
})}
</ul>
</div>
) : null}
{manual.length ? (
<div className="border-t border-border/60 px-4 py-3 sm:px-5">
<p className="mb-2 text-meta text-muted-foreground">其他整理记录</p>
+9 -2
View File
@@ -25,13 +25,17 @@ export function legacyRedirect(pathname: string, search: string): string | null
if (engineRun) return withRest(`/console/tasks?run=${engineRun[1]}`);
// IA-3:身份与元数据取消,待识别 / 外部身份 / 别名并入媒体概览
if (path === "/console/identity") return withRest(`/console/library?${IDENTITY_TAB[tab ?? ""] ?? IDENTITY_TAB.pool}`);
// DL-3:下载计划页签取消,计划并入下载面板;带计划 id 的深链(?plan= 或 /plans/:id)打开对应行
if (path === "/console/downloads/plans") return withRest("/console/downloads");
const planPath = /^\/console\/downloads\/plans\/([^/]+)$/.exec(path);
if (planPath) return withRest(`/console/downloads?plan=${planPath[1]}`);
if (!tab) return null;
if (path === "/console/tasks") {
// FE-6:计划从任务页拆出,回到独立的计划页(?job= 透传)
if (tab === "plan") return withRest("/console/schedules");
if (tab === "downloads") return withRest("/console/downloads");
if (tab === "plans") return withRest("/console/downloads/plans");
if (tab === "plans") return withRest("/console/downloads");
// 旧"业务任务"页签:统一任务流
if (tab === "tasks" || tab === "business") return withRest("/console/tasks");
return null;
@@ -49,7 +53,10 @@ export function legacyRedirect(pathname: string, search: string): string | null
export const LEGACY_REDIRECTS: { from: string; to: string }[] = [
{ from: "/console/tasks?tab=downloads", to: "/console/downloads" },
{ from: "/console/tasks?tab=downloads&status=paused", to: "/console/downloads?status=paused" },
{ from: "/console/tasks?tab=plans", to: "/console/downloads/plans" },
{ from: "/console/tasks?tab=plans", to: "/console/downloads" },
{ from: "/console/downloads/plans", to: "/console/downloads" },
{ from: "/console/downloads/plans?plan=p-1", to: "/console/downloads?plan=p-1" },
{ from: "/console/downloads/plans/p-1", to: "/console/downloads?plan=p-1" },
{ from: "/console/tasks?tab=tasks", to: "/console/tasks" },
{ from: "/console/tasks?tab=business", to: "/console/tasks" },
{ from: "/console/subscriptions?tab=policies", to: "/console/rules" },
+1 -1
View File
@@ -15,7 +15,7 @@ import { legacyRedirectLoader } from "./redirects.ts";
export const meta = () => [{ title: "任务 · UHD 媒体自动化控制台" }];
// 旧的 ?tab=downloads / plans / business / plan 跳到下载面板 / 下载计划 / 任务流 / 计划页
// 旧的 ?tab=downloads / plans / business / plan 跳到下载面板 / 下载面板 / 任务流 / 计划页
export const clientLoader = legacyRedirectLoader;
type Tab = StreamTab;