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:
1 parent
c0522f2531
commit
a9147e647d
50 files changed
+1535
-348
No files matched your search
@@ -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: "已取消",
|
||||
|
||||
@@ -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(",");
|
||||
}
|
||||
|
||||
@@ -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("被规则拒绝(需要你批准),未重试");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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": "整理成功",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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: "资源",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user