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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user