diff --git a/CHANGELOG.md b/CHANGELOG.md index b697c17d..fea0a253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - 运行状态页进程表按道显示「运行中/并发 · 排队」与维护道让路状态;心跳照常记录事件循环延迟(将来拆进程的判据)。 ### 变更 +- 下载计划并入下载面板(DL-3):去掉「下载计划」页签,旧地址(含 `?plan=` 与 `/downloads/plans/:id`)跳到下载面板并打开对应行;还没提交的下载作为「进行中」的行与下载任务按加入时间一起排并计入计数,状态写「等你批准」「等磁盘空间」「等开启自动化」「准备提交」,可直接批准 / 取消(等批准的同时在待处理里);下载详情新增「为什么下这个」(来源:订阅 / 智能体会话 / 手动;目的:补缺 / 升级(替换哪个版本)/ 换源;选中理由要点,原始评估进技术信息);没提交就结束的下载(失败 / 取消 / 过期)进作品的下载历史并写明原因;审批被拒、撤销或过期时草案随之结束。新增 `GET /acquisition-plans/{id}/why`、`POST /acquisition-plans/{id}/cancel`,面板接口行带 `kind`(task / plan)并支持 `planId`。界面与智能体能力标题不再出现"下载计划"(动作计数写"准备下载 N 个资源")。 - `WORKER_CONCURRENCY` 从 io 组总并发改为映射到网络道;compose 不再给它缺省值(缺省走设置页)。`worker` / `worker:agent` / `all` 角色与镜像入口不变。 ### 变更(IA-2 导航与站点类型) diff --git a/apps/server/src/modules/acquisition-organize/__tests__/download-board-plans.test.ts b/apps/server/src/modules/acquisition-organize/__tests__/download-board-plans.test.ts new file mode 100644 index 00000000..3ec38436 --- /dev/null +++ b/apps/server/src/modules/acquisition-organize/__tests__/download-board-plans.test.ts @@ -0,0 +1,314 @@ +import { sql } from "@umt/db"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createHarness, type Harness, pgAvailable, seedEntity, seedRelease, seedSlot } from "./harness.ts"; + +// DL-3 下载计划并入下载面板:等批准 / 等磁盘空间的计划作为「进行中」行与下载任务按时间一起排、计入计数; +// 深链按计划 id 取行;「为什么下这个」;取消还没提交的下载;没提交就结束的进作品下载历史; +// 审批被拒 / 过期时草案跟着结束。 + +const hasPg = await pgAvailable(); +const h40 = (n: number) => n.toString(16).padStart(40, "c"); +type Row = { kind: "task" | "plan"; id: string; name: string; wait?: string; waitReason?: string | null }; + +describe.skipIf(!hasPg)("DL-3 下载计划并入下载面板(真实 PG)", () => { + let h: Harness; + let entity: string; + let siteId: string; + const ids = {} as Record<"reserved" | "draft" | "orphan" | "submitted" | "failed" | "agentDraft", string>; + const slots = {} as Record; + let taskId: string; + let instId: string; + + const at = (min: number) => new Date(Date.UTC(2026, 8, 30, 8, min)); + + async function plan( + key: keyof typeof ids, + p: { status: string; min: number; purpose?: string; evaluation?: Record; reason?: string }, + ) { + const slot = await seedSlot(h.db, { entityId: entity, name: key === "submitted" ? "4K" : `slot-${key}` }); + slots[key] = slot; + const rel = await seedRelease(h.db, { + siteId, + entityId: entity, + title: `Severance.S02.${key}.2160p`, + size: 10_000_000_000, + hash: h40(Object.keys(ids).length + 1), + }); + await h.db + .updateTable("source_release") + .set({ promotion: JSON.stringify({ kind: "free" }), seeders: 41 }) + .where("id", "=", rel) + .execute(); + const row = await h.db + .insertInto("acquisition_plan") + .values({ + slot_id: slot, + source_release_id: rel, + purpose: (p.purpose ?? "fill") as "fill", + status: p.status as "draft", + evaluation: JSON.stringify(p.evaluation ?? { reasons: ["hard_conditions_met", "pri:90"], instanceId: instId }), + reserved_bytes: 10_000_000_000, + payload_hash: `hash-${key}`, + idempotency_key: `test:${key}`, + status_reason: p.reason ?? null, + created_at: at(p.min), + }) + .returning("id") + .executeTakeFirstOrThrow(); + ids[key] = row.id; + if (p.status === "reserved") + await h.db + .updateTable("acquisition_slot") + .set({ status: "reserved", reserved_plan_id: row.id }) + .where("id", "=", slot) + .execute(); + return row.id; + } + + async function approval(planId: string, status = "pending", scope: Record = {}) { + return ( + await h.db + .insertInto("approval") + .values({ + plan_type: "acquisition_plan", + plan_id: planId, + payload_hash: "hash-x", + expected_revision: 1, + scope: JSON.stringify(scope), + status: status as "pending", + requested_by: "agent:test", + expires_at: new Date(Date.now() + 3600_000), + }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + } + + beforeAll(async () => { + h = await createHarness("dl3plans"); + entity = await seedEntity(h.db, { kind: "series", title: "人生切割术", year: 2022 }); + siteId = ( + await h.db + .insertInto("site") + .values({ key: "dl3site", name: "测试站", adapter: "nexusphp", base_url: "https://pt.example.test" }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + // 下载器停用:面板不去连 qB,只看数据库 + instId = ( + await h.db + .insertInto("downloader_instance") + .values({ name: "qb", base_url: "http://127.0.0.1:9", username: "a", enabled: false }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + + // 时间线(新 → 旧):已预留等空间(50)> 下载中的任务(40)> 等批准(30)> 智能体草案(25)> 孤立草案(20)> 失败(10) + await plan("failed", { status: "failed", min: 10, reason: "rejected: torrent is invalid" }); + await plan("orphan", { status: "draft", min: 20 }); + await plan("agentDraft", { status: "draft", min: 25 }); + await plan("draft", { status: "draft", min: 30 }); + await plan("submitted", { status: "submitted", min: 35 }); + await plan("reserved", { status: "reserved", min: 50 }); + await approval(ids.draft); + await approval(ids.agentDraft, "pending", { agentRunId: "x" }); + + taskId = ( + await h.db + .insertInto("download_task") + .values({ + instance_id: instId, + torrent_hash: h40(99), + acquisition_plan_id: ids.submitted, + name: "Severance.S02.submitted.2160p", + status: "downloading", + managed: true, + media_entity_id: entity, + created_at: at(40), + }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + // 已预留的提交任务被下载盘阈值延后 + await h.db + .insertInto("task") + .values({ + kind: "submitDownload", + idempotency_key: `plan:${ids.reserved}`, + status: "waiting", + subject_type: "acquisition_plan", + subject_id: ids.reserved, + output: JSON.stringify({ status: "deferred", reason: "cache:projected_over_threshold" }), + }) + .execute(); + // 智能体会话准备的草案 + const run = await h.db + .insertInto("agent_run") + .values({ trigger: "user", goal: "补齐人生切割术第二季的 4K 版本" }) + .returning("id") + .executeTakeFirstOrThrow(); + await h.db + .insertInto("capability_invocation") + .values({ + idempotency_key: "inv-1", + capability_id: "download.plan", + capability_version: 1, + agent_run_id: run.id, + actor_id: "agent", + source: "agent", + status: "succeeded", + input: JSON.stringify({}), + output: JSON.stringify({ plan: { id: ids.agentDraft } }), + }) + .execute(); + }, 60_000); + + afterAll(async () => { + await h?.close(); + }); + + it("进行中:等批准、等磁盘空间的计划与下载任务按加入时间一起排,计数包含它们;孤立草案不出现", async () => { + const r = await h.api("GET", "/download-tasks/board"); + expect(r.status).toBe(200); + const b = r.body.data; + const rows = b.items as Row[]; + expect(rows.map((x) => [x.kind, x.id])).toEqual([ + ["plan", ids.reserved], + ["task", taskId], + ["plan", ids.draft], + ["plan", ids.agentDraft], + ]); + expect(b.counts).toEqual({ active: 4, completed: 0 }); + const reserved = b.items[0]; + expect(reserved).toMatchObject({ + wait: "space", + waitReason: "cache:projected_over_threshold", + status: "reserved", + purpose: "fill", + name: "Severance.S02.reserved.2160p", + sizeBytes: 10_000_000_000, + media: { id: entity, title: "人生切割术", year: 2022 }, + approval: null, + }); + const draft = b.items[2]; + expect(draft).toMatchObject({ wait: "approval", status: "draft", approval: { payloadHash: "hash-x" } }); + // 已完成分组不含计划 + const done = (await h.api("GET", "/download-tasks/board?state=completed")).body.data; + expect(done.items).toEqual([]); + }); + + it("翻页:两路按同一游标归并,不重不漏", async () => { + const p1 = (await h.api("GET", "/download-tasks/board?limit=3")).body.data; + expect(p1.items.map((x: Row) => x.id)).toEqual([ids.reserved, taskId, ids.draft]); + expect(p1.nextCursor).toBeTruthy(); + const p2 = (await h.api("GET", `/download-tasks/board?limit=3&cursor=${encodeURIComponent(p1.nextCursor)}`)).body + .data; + expect(p2.items.map((x: Row) => x.id)).toEqual([ids.agentDraft]); + expect(p2.nextCursor).toBeNull(); + }); + + it("深链 planId:等待中的计划给计划行,已提交的给它的下载任务,结束的没有行", async () => { + const a = (await h.api("GET", `/download-tasks/board?planId=${ids.reserved}`)).body.data; + expect(a.items.map((x: Row) => [x.kind, x.id])).toEqual([["plan", ids.reserved]]); + const b = (await h.api("GET", `/download-tasks/board?planId=${ids.submitted}&state=completed`)).body.data; + expect(b.items.map((x: Row) => [x.kind, x.id])).toEqual([["task", taskId]]); + const c = (await h.api("GET", `/download-tasks/board?planId=${ids.failed}`)).body.data; + expect(c.items).toEqual([]); + }); + + it("为什么下这个:订阅 / 智能体会话来源、目的、选中理由原始要点", async () => { + const sub = (await h.api("GET", `/acquisition-plans/${ids.submitted}/why`)).body.data; + expect(sub).toMatchObject({ + planId: ids.submitted, + source: { kind: "subscription", name: "人生切割术" }, + purpose: "fill", + slotName: "4K", + replaces: null, + reasons: ["hard_conditions_met", "pri:90"], + promotion: "free", + seeders: 41, + siteName: "测试站", + }); + expect(sub.evaluation.reasons).toEqual(["hard_conditions_met", "pri:90"]); + const agent = (await h.api("GET", `/acquisition-plans/${ids.agentDraft}/why`)).body.data; + expect(agent.source).toMatchObject({ kind: "agent", name: "补齐人生切割术第二季的 4K 版本" }); + expect((await h.api("GET", `/acquisition-plans/${crypto.randomUUID()}/why`)).status).toBe(404); + }); + + it("升级:被替换的版本取评估时记下的文件规格", async () => { + const asset = await h.db + .insertInto("media_asset") + .values({ + media_entity_id: entity, + kind: "video", + delivery: JSON.stringify({ resolution: "1080p", source: "BluRay", hdr: [], releaseGroup: "CMCT" }), + }) + .returning("id") + .executeTakeFirstOrThrow(); + await h.db + .updateTable("acquisition_plan") + .set({ + purpose: "upgrade", + evaluation: JSON.stringify({ reasons: ["source_tier_improved"], replacesAssetId: asset.id }), + }) + .where("id", "=", ids.submitted) + .execute(); + const w = (await h.api("GET", `/acquisition-plans/${ids.submitted}/why`)).body.data; + expect(w.purpose).toBe("upgrade"); + expect(w.replaces).toEqual({ assetId: asset.id, label: "1080p 蓝光压制 · CMCT" }); + }); + + it("取消:已预留的释放收录版本并进下载历史;已提交的 409", async () => { + const r = await h.api("POST", `/acquisition-plans/${ids.reserved}/cancel`); + expect(r.status).toBe(200); + expect(r.body.data).toMatchObject({ status: "cancelled", statusReason: "cancelled_by_user" }); + const slot = await h.db + .selectFrom("acquisition_slot") + .select(["status", "reserved_plan_id"]) + .where("id", "=", slots.reserved!) + .executeTakeFirstOrThrow(); + expect(slot.status).not.toBe("reserved"); + expect((await h.api("POST", `/acquisition-plans/${ids.submitted}/cancel`)).status).toBe(409); + expect((await h.api("POST", `/acquisition-plans/${ids.reserved}/cancel`)).status).toBe(409); + const b = (await h.api("GET", "/download-tasks/board")).body.data; + expect(b.counts.active).toBe(3); + expect(b.items.some((x: Row) => x.id === ids.reserved)).toBe(false); + }); + + it("作品下载历史:没提交就结束的下载带原因码;孤立草案与进行中的不在里面", async () => { + const d = (await h.api("GET", `/library/${entity}`)).body.data; + const dropped = d.droppedPlans.map((x: { plan: { id: string; statusReason: string } }) => [ + x.plan.id, + x.plan.statusReason, + ]); + expect(dropped).toEqual( + expect.arrayContaining([ + [ids.failed, "rejected: torrent is invalid"], + [ids.reserved, "cancelled_by_user"], + ]), + ); + expect(dropped).toHaveLength(2); + expect(d.droppedPlans.find((x: { plan: { id: string } }) => x.plan.id === ids.failed).releaseTitle).toBe( + "Severance.S02.failed.2160p", + ); + }); + + it("审批被拒 / 过期:草案跟着结束,不再算进行中", async () => { + const { closeDraftPlan } = await import("../../agent/interaction.ts"); + await closeDraftPlan(h.db, { plan_type: "acquisition_plan", plan_id: ids.draft }, "rejected"); + await closeDraftPlan(h.db, { plan_type: "acquisition_plan", plan_id: ids.agentDraft }, "expired"); + // 不是草案的不动 + await closeDraftPlan(h.db, { plan_type: "acquisition_plan", plan_id: ids.submitted }, "rejected"); + const rows = await sql<{ id: string; status: string; status_reason: string | null }>` + select id, status, status_reason from acquisition_plan where id in (${sql.join([ids.draft, ids.agentDraft, ids.submitted])})`.execute( + h.db, + ); + const by = new Map(rows.rows.map((r) => [r.id, [r.status, r.status_reason]])); + expect(by.get(ids.draft)).toEqual(["cancelled", "approval_rejected"]); + expect(by.get(ids.agentDraft)).toEqual(["expired", "approval_expired"]); + expect(by.get(ids.submitted)?.[0]).toBe("submitted"); + const b = (await h.api("GET", "/download-tasks/board")).body.data; + expect(b.items.map((x: Row) => x.kind)).toEqual(["task"]); + expect(b.counts.active).toBe(1); + }); +}); diff --git a/apps/server/src/modules/acquisition-organize/capabilities.ts b/apps/server/src/modules/acquisition-organize/capabilities.ts index c37e1a25..b63aaa69 100644 --- a/apps/server/src/modules/acquisition-organize/capabilities.ts +++ b/apps/server/src/modules/acquisition-organize/capabilities.ts @@ -363,7 +363,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () => const d = deps(); const db = d.ctx.db; const plan = await db.selectFrom("acquisition_plan").selectAll().where("id", "=", input.planId).executeTakeFirst(); - if (!plan) throw new CapabilityError("not_found", "下载计划不存在"); + if (!plan) throw new CapabilityError("not_found", "这个 planId 对应的下载不存在"); if (plan.status === "draft") { // 草案 → reserved:Slot 必须与生成计划时一致(revision 未变、没有其它预留) const ok = await db diff --git a/apps/server/src/modules/acquisition-organize/routes/downloads.ts b/apps/server/src/modules/acquisition-organize/routes/downloads.ts index 889d85a9..ab3269f3 100644 --- a/apps/server/src/modules/acquisition-organize/routes/downloads.ts +++ b/apps/server/src/modules/acquisition-organize/routes/downloads.ts @@ -4,11 +4,12 @@ import { ApiException } from "../../../http/errors.ts"; import type { ModuleApp } from "../../types.ts"; import { type AoDeps, enqueueTracked, timeSlot } from "../services/common.ts"; import { downloadBoard } from "../services/download-board.ts"; -import { userPause, userResume } from "../services/downloads.ts"; -import { actorOf, notFound, toApi, toApiList } from "../util.ts"; +import { applySlotEvent, userPause, userResume } from "../services/downloads.ts"; +import { planWhys } from "../services/plan-why.ts"; +import { actorOf, conflict, notFound, toApi, toApiList } from "../util.ts"; import { mount, paginate } from "./helpers.ts"; -// 下载计划 / 下载任务(含用户暂停与恢复)/ 手动触发缓存清理与资产对账。 +// 计划(界面统称"下载")/ 下载任务(含用户暂停与恢复)/ 手动触发缓存清理与资产对账。 const TAG = "downloads"; @@ -32,7 +33,62 @@ export function registerDownloadRoutes(app: ModuleApp, d: AoDeps) { Api.acquisitionPlans.get, async (_c, { params }) => { const r = await db().selectFrom("acquisition_plan").selectAll().where("id", "=", params.id).executeTakeFirst(); - return toApi(r ?? notFound("下载计划")); + return toApi(r ?? notFound("下载")); + }, + { tag: TAG }, + ); + + mount( + app, + Api.acquisitionPlans.why, + async (_c, { params }) => (await planWhys(db(), [params.id])).get(params.id) ?? notFound("下载"), + { tag: TAG }, + ); + // DL-3:取消还没提交的计划(已预留 / 草案);已有下载任务的不在这里取消(到下载器里删种) + mount( + app, + Api.acquisitionPlans.cancel, + async (c, { params }) => { + const row = await db() + .transaction() + .execute(async (trx) => { + const p = await trx + .selectFrom("acquisition_plan") + .selectAll() + .where("id", "=", params.id) + .forUpdate() + .executeTakeFirst(); + if (!p) return notFound("下载"); + if (p.status !== "draft" && p.status !== "reserved") conflict("这个下载已经结束或已提交,不能取消"); + const dt = await trx + .selectFrom("download_task") + .select("id") + .where("acquisition_plan_id", "=", p.id) + .executeTakeFirst(); + if (dt) conflict("已经提交到下载器,到下载任务里处理"); + const out = await trx + .updateTable("acquisition_plan") + .set({ status: "cancelled", status_reason: "cancelled_by_user" }) + .where("id", "=", p.id) + .returningAll() + .executeTakeFirstOrThrow(); + // 草案没有占用收录版本;已预留的释放预留,收录版本到期重评 + if (p.status === "reserved") + await applySlotEvent(d, trx, p.slot_id, { type: "release" }, { reason: "cancelled_by_user", planId: p.id }); + await trx + .insertInto("audit_event") + .values({ + actor_type: actorOf(c).startsWith("api_key:") ? "api_key" : "user", + actor_id: actorOf(c), + action: "acquisition_plan.cancel", + target_type: "acquisition_plan", + target_id: p.id, + payload: JSON.stringify({ from: p.status }), + }) + .execute(); + return out; + }); + return toApi(row); }, { tag: TAG }, ); diff --git a/apps/server/src/modules/acquisition-organize/routes/library.ts b/apps/server/src/modules/acquisition-organize/routes/library.ts index ca91c012..522e9a5d 100644 --- a/apps/server/src/modules/acquisition-organize/routes/library.ts +++ b/apps/server/src/modules/acquisition-organize/routes/library.ts @@ -336,6 +336,26 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) { .orderBy("created_at") .execute() : []; + // DL-3:没提交到下载器就结束的计划(失败 / 取消 / 过期、没有下载任务)也进下载历史 + const dropped = slotIds.length + ? await db() + .selectFrom("acquisition_plan as p") + .leftJoin("source_release as sr", "sr.id", "p.source_release_id") + .selectAll("p") + .select("sr.title as release_title") + .where("p.slot_id", "in", slotIds) + .where("p.status", "in", ["failed", "cancelled", "expired"]) + .where((eb) => + eb.not( + eb.exists( + eb.selectFrom("download_task as x").select("x.id").whereRef("x.acquisition_plan_id", "=", "p.id"), + ), + ), + ) + .orderBy("p.updated_at", "desc") + .limit(50) + .execute() + : []; const rootPath = new Map(roots.map((r) => [r.id, r.local_path])); const detail: LibraryDetail = { entity: toApi(e!), @@ -367,6 +387,10 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) { lastOrganizationStatus: mine[mine.length - 1]?.status ?? null, }; }), + droppedPlans: dropped.map(({ release_title, ...p }) => ({ + plan: toApi(p), + releaseTitle: release_title, + })), organizationRecords: entityRecs.map((r) => ({ id: r.id, status: r.status, diff --git a/apps/server/src/modules/acquisition-organize/services/acquisition.ts b/apps/server/src/modules/acquisition-organize/services/acquisition.ts index d9fa24fa..404802b1 100644 --- a/apps/server/src/modules/acquisition-organize/services/acquisition.ts +++ b/apps/server/src/modules/acquisition-organize/services/acquisition.ts @@ -1000,16 +1000,18 @@ async function reserve( mode: p.mode, unitKeys: p.unitKeys, }; + // 升级 = 替换已有资产;电影 Slot 缺口(无资产)的 mode 虽记 upgrade,用途仍是补缺 + const purpose = p.mode === "upgrade" && p.slot.current_asset_id ? ("upgrade" as const) : ("fill" as const); const evaluation = jsonb({ ...toContractSlotEvaluation(p.ev), mode: p.mode, instanceId: p.instanceId, volumeKey: p.volumeKey, savePath: p.savePath, + // DL-3「为什么下这个」:升级时记下被替换的文件(之后 Slot 的当前文件会变) + ...(purpose === "upgrade" ? { replacesAssetId: p.slot.current_asset_id } : {}), ...(p.evaluationExtra ?? {}), }); - // 升级 = 替换已有资产;电影 Slot 缺口(无资产)的 mode 虽记 upgrade,用途仍是补缺 - const purpose = p.mode === "upgrade" && p.slot.current_asset_id ? ("upgrade" as const) : ("fill" as const); if (c.draft) { // 草案:同一 Slot+发布复用未提交的草案;Slot 状态与预算不动,expected_revision 记下当前 Slot 版本 +1 const key = `draft:${p.slot.id}:${p.rel.id}`; diff --git a/apps/server/src/modules/acquisition-organize/services/download-board.ts b/apps/server/src/modules/acquisition-organize/services/download-board.ts index a1c6131f..9be2bde7 100644 --- a/apps/server/src/modules/acquisition-organize/services/download-board.ts +++ b/apps/server/src/modules/acquisition-organize/services/download-board.ts @@ -4,15 +4,18 @@ import { DOWNLOAD_BOARD_STATUSES, type DownloadBoard, type DownloadBoardItem, + type DownloadBoardPlanItem, type DownloadBoardQuery, + type DownloadBoardRow, type DownloadLive, type DownloadTask, type OrganizationStatus, + type PlanWait, } from "@umt/contracts"; import { type DownloaderInstanceRow, sql } from "@umt/db"; import { paginate } from "../routes/helpers.ts"; -import { errMessage, toApi } from "../util.ts"; -import type { AoDeps } from "./common.ts"; +import { encodeCursor, errMessage, toApi } from "../util.ts"; +import type { AoDeps, Exec } from "./common.ts"; // 下载面板(DL-2):数据库里的下载任务 + qB 实时数据。 // 实时数据按实例缓存 liveTtlMs(缺省 2 s)并合并并发请求:多个标签页 / 多人每 3–5 s 轮询, @@ -119,28 +122,160 @@ export function instanceLive(d: AoDeps, inst: DownloaderInstanceRow): Promise + eb.or([ + eb.and([ + eb("p.status", "=", "reserved"), + eb.not( + eb.exists( + eb.selectFrom("download_task as dt").select("dt.id").whereRef("dt.acquisition_plan_id", "=", "p.id"), + ), + ), + ]), + eb.and([ + eb("p.status", "=", "draft"), + eb.exists( + eb + .selectFrom("approval as a") + .select("a.id") + .where("a.plan_type", "=", "acquisition_plan") + .whereRef("a.plan_id", "=", sql.ref("p.id")) + .where("a.status", "=", "pending") + .where((x) => x.or([x("a.expires_at", "is", null), x("a.expires_at", ">", sql`now()`)])), + ), + ]), + ]), + ) + .$if(!!instanceId, (qb) => qb.where(sql`p.evaluation->>'instanceId'`, "=", instanceId!)); +} + +/** 提交任务的延后原因 → 在等什么 */ +export function planWaitOf(status: string, deferred: string | null): PlanWait { + if (status === "draft") return "approval"; + if (!deferred) return "queued"; + if (deferred.startsWith("cache:") || deferred === "volume_state_unknown") return "space"; + if (deferred === "automation_disabled" || deferred === "read_only_reconcile") return "automation_off"; + return "queued"; +} + +/** 等待中的计划 → 面板行 */ +async function planItems(db: Exec, rows: { id: string; slot_id: string; source_release_id: string }[]) { + if (!rows.length) + return new Map>(); + const ids = rows.map((r) => r.id); + const [extra, approvals, tasks] = await Promise.all([ + db + .selectFrom("acquisition_plan as p") + .innerJoin("acquisition_slot as s", "s.id", "p.slot_id") + .leftJoin("media_entity as e", "e.id", "s.media_entity_id") + .leftJoin("source_release as r", "r.id", "p.source_release_id") + .select([ + "p.id", + "p.reserved_bytes", + "r.title as release_title", + "r.size_bytes", + "e.id as entity_id", + "e.title", + "e.year", + ]) + .where("p.id", "in", ids) + .execute(), + db + .selectFrom("approval") + .select(["id", "plan_id", "payload_hash", "decision_id"]) + .where("plan_type", "=", "acquisition_plan") + .where("plan_id", "in", ids) + .where("status", "=", "pending") + .orderBy("created_at", "desc") + .execute(), + // 提交任务最近一次的结果:延后(deferred)时 output.reason 是原因码 + sql<{ subject_id: string; status: string; reason: string | null }>` + select distinct on (subject_id) subject_id, status, + case when output->>'status' = 'deferred' then output->>'reason' end as reason + from task + where kind = 'submitDownload' and subject_type = 'acquisition_plan' and subject_id in (${sql.join(ids)}) + order by subject_id, updated_at desc` + .execute(db) + .then((r) => r.rows), + ]); + const out = new Map>(); + for (const r of rows) { + const x = extra.find((e) => e.id === r.id); + const a = approvals.find((ap) => ap.plan_id === r.id); + const t = tasks.find((tk) => tk.subject_id === r.id); + out.set(r.id, { + name: x?.release_title ?? "未知资源", + sizeBytes: x?.size_bytes ?? (x?.reserved_bytes ? x.reserved_bytes : null), + media: x?.entity_id && x.title ? { id: x.entity_id, title: x.title, year: x.year ?? null } : null, + wait: "queued", + waitReason: t?.reason ?? null, + approval: a ? { id: a.id, payloadHash: a.payload_hash, decisionId: a.decision_id } : null, + }); + } + return out; +} + export async function downloadBoard(d: AoDeps, query: DownloadBoardQuery): Promise { const db = d.ctx.db; let q = db .selectFrom("download_task") .selectAll() .where("removed_at", "is", null) - .where("status", "in", [...DOWNLOAD_BOARD_STATUSES[query.state]]); + .where("status", "in", [ + ...(query.planId + ? [...DOWNLOAD_BOARD_STATUSES.active, ...DOWNLOAD_BOARD_STATUSES.completed] + : DOWNLOAD_BOARD_STATUSES[query.state]), + ]); if (query.instanceId) q = q.where("instance_id", "=", query.instanceId); + if (query.planId) q = q.where("acquisition_plan_id", "=", query.planId); let insts = db.selectFrom("downloader_instance").selectAll().where("enabled", "=", true); if (query.instanceId) insts = insts.where("id", "=", query.instanceId); - const [page, counts, instances] = await Promise.all([ + const withPlans = query.state === "active" || !!query.planId; + const planQ = query.planId + ? waitingPlansQuery(db, query.instanceId).where("p.id", "=", query.planId) + : waitingPlansQuery(db, query.instanceId); + const [page, planPage, counts, waitingCount, instances] = await Promise.all([ paginate(q, query), + withPlans ? paginate(planQ, query, "p.") : Promise.resolve({ rows: [], nextCursor: null }), sql<{ active: string; completed: string }>` select count(*) filter (where status in (${sql.join([...DOWNLOAD_BOARD_STATUSES.active])})) as active, count(*) filter (where status in (${sql.join([...DOWNLOAD_BOARD_STATUSES.completed])})) as completed from download_task where removed_at is null ${query.instanceId ? sql`and instance_id = ${query.instanceId}` : sql``}`.execute(db), + waitingPlansQuery(db, query.instanceId) + .clearSelect() + .select((eb) => eb.fn.countAll().as("n")) + .executeTakeFirst(), insts.orderBy("name").execute(), ]); - const rows = page.rows; + + // 两路按 (created_at, id) 倒序归并,取前 limit 条;游标取最后一条,两路下一页用同一游标 + type Merged = + | { kind: "task"; row: (typeof page.rows)[number] } + | { kind: "plan"; row: (typeof planPage.rows)[number] }; + const merged: Merged[] = [ + ...page.rows.map((row) => ({ kind: "task" as const, row })), + ...planPage.rows.map((row) => ({ kind: "plan" as const, row })), + ].sort((a, b) => { + const t = b.row.created_at.getTime() - a.row.created_at.getTime(); + return t !== 0 ? t : b.row.id < a.row.id ? -1 : b.row.id > a.row.id ? 1 : 0; + }); + const more = merged.length > query.limit || page.nextCursor !== null || planPage.nextCursor !== null; + const taken = merged.slice(0, query.limit); + const lastTaken = taken[taken.length - 1]; + const nextCursor = more && lastTaken ? encodeCursor(lastTaken.row.created_at, lastTaken.row.id) : null; + const rows = taken.flatMap((m) => (m.kind === "task" ? [m.row] : [])); + const planRows = taken.flatMap((m) => (m.kind === "plan" ? [m.row] : [])); const entityIds = [...new Set(rows.map((r) => r.media_entity_id).filter((x): x is string => !!x))]; const taskIds = rows.map((r) => r.id); @@ -182,11 +317,12 @@ export async function downloadBoard(d: AoDeps, query: DownloadBoardQuery): Promi const recordByTask = new Map(records.map((r) => [r.download_task_id, r])); const liveByInst = new Map(lives.map((l) => [l.instanceId, l])); - const items: DownloadBoardItem[] = rows.map((r) => { + const taskItems: DownloadBoardItem[] = rows.map((r) => { const e = r.media_entity_id ? entityById.get(r.media_entity_id) : undefined; const rec = recordByTask.get(r.id); return { ...toApi(r), + kind: "task" as const, live: liveByInst.get(r.instance_id)?.torrents.get(r.torrent_hash) ?? null, media: e ? { id: e.id, title: e.title, year: e.year } : null, organize: rec @@ -199,13 +335,32 @@ export async function downloadBoard(d: AoDeps, query: DownloadBoardQuery): Promi : null, }; }); + const planExtra = await planItems(db, planRows); + const taskById = new Map(taskItems.map((t) => [t.id, t])); + const items: DownloadBoardRow[] = taken.map((m): DownloadBoardRow => { + if (m.kind === "task") return taskById.get(m.row.id)!; + const p = m.row; + const x = planExtra.get(p.id)!; + return { + kind: "plan", + id: p.id, + createdAt: p.created_at.toISOString(), + purpose: p.purpose, + status: p.status, + ...x, + wait: planWaitOf(p.status, x.waitReason), + }; + }); const c = counts.rows[0]; const reachable = lives.filter((l) => l.reachable); const oldest = reachable.length ? Math.min(...reachable.map((l) => l.at.getTime())) : null; return { items, - nextCursor: page.nextCursor, - counts: { active: Number(c?.active ?? 0), completed: Number(c?.completed ?? 0) }, + nextCursor, + counts: { + active: Number(c?.active ?? 0) + Number(waitingCount?.n ?? 0), + completed: Number(c?.completed ?? 0), + }, transfer: { dlSpeed: reachable.reduce((a, l) => a + l.dlSpeed, 0), upSpeed: reachable.reduce((a, l) => a + l.upSpeed, 0), diff --git a/apps/server/src/modules/acquisition-organize/services/plan-why.ts b/apps/server/src/modules/acquisition-organize/services/plan-why.ts new file mode 100644 index 00000000..4f9f8134 --- /dev/null +++ b/apps/server/src/modules/acquisition-organize/services/plan-why.ts @@ -0,0 +1,131 @@ +import type { DownloadWhy } from "@umt/contracts"; +import { sql } from "@umt/db"; +import type { Exec } from "./common.ts"; + +// DL-3「为什么下这个」:计划的来源(订阅 / 智能体会话 / 手动)、目的(补缺 / 升级 / 换源)、被替换的版本、 +// 选中理由的原始要点(evaluation.reasons + 资源促销 / 做种数)。文案由前端按这些事实拼,原始评估进技术信息。 + +type Delivery = { + resolution?: string | null; + source?: string | null; + hdr?: string[] | null; + releaseGroup?: string | null; +}; + +const SOURCE_NAME: Record = { REMUX: "Remux", BluRay: "蓝光压制", "BluRay-Disc": "原盘" }; + +/** 现有版本的规格短句:"1080p 蓝光压制 HDR10 · CMCT";认不出规格时退回资源标题 */ +export function assetLabel(delivery: unknown, fallback: string | null): string { + const d = (delivery ?? {}) as Delivery; + const spec = [d.resolution, d.source ? (SOURCE_NAME[d.source] ?? d.source) : null, ...(d.hdr ?? [])] + .filter((x): x is string => !!x) + .join(" "); + const label = [spec, d.releaseGroup].filter(Boolean).join(" · "); + return label || fallback || "现有版本"; +} + +export async function planWhys(db: Exec, planIds: readonly string[]): Promise> { + const out = new Map(); + const ids = [...new Set(planIds)]; + if (!ids.length) return out; + const plans = await db + .selectFrom("acquisition_plan as p") + .innerJoin("acquisition_slot as s", "s.id", "p.slot_id") + .leftJoin("source_release as r", "r.id", "p.source_release_id") + .leftJoin("site", "site.id", "r.site_id") + .select([ + "p.id", + "p.purpose", + "p.status", + "p.evaluation", + "s.name as slot_name", + "s.subscription_id", + "s.current_asset_id", + "r.promotion", + "r.seeders", + "site.name as site_name", + ]) + .where("p.id", "in", ids) + .execute(); + if (!plans.length) return out; + + // 智能体 / 手动:download.plan 的输出或 download.submit 的输入里有这个计划 + const invocations = await sql<{ plan_id: string; agent_run_id: string | null; goal: string | null }>` + select coalesce(ci.input->>'planId', ci.output->'plan'->>'id') as plan_id, ci.agent_run_id, ar.goal + from capability_invocation ci + left join agent_run ar on ar.id = ci.agent_run_id + where ci.capability_id in ('download.plan', 'download.submit') + and coalesce(ci.input->>'planId', ci.output->'plan'->>'id') in (${sql.join(ids)})`.execute(db); + + const subIds = [...new Set(plans.map((p) => p.subscription_id).filter((x): x is string => !!x))]; + const subs = subIds.length + ? await db + .selectFrom("subscription as sub") + .leftJoin("media_entity as e", "e.id", "sub.media_entity_id") + .leftJoin("content_unit as cu", "cu.id", "sub.season_unit_id") + .select(["sub.id", "sub.title_query", "e.title", "cu.season_number"]) + .where("sub.id", "in", subIds) + .execute() + : []; + + const evOf = (p: (typeof plans)[number]) => (p.evaluation ?? {}) as Record; + // 被替换的版本:评估时记下的 replacesAssetId(DL-3 起);更早的计划在未结束前按收录版本当前的文件 + const replacedIds = new Map(); + for (const p of plans) { + if (p.purpose === "fill") continue; + const recorded = evOf(p).replacesAssetId; + const id = + typeof recorded === "string" + ? recorded + : ["draft", "reserved", "submitted"].includes(p.status) + ? p.current_asset_id + : null; + if (id) replacedIds.set(p.id, id); + } + const assetIds = [...new Set(replacedIds.values())]; + const assets = assetIds.length + ? await db + .selectFrom("media_asset as a") + .leftJoin("source_release as r", "r.id", "a.source_release_id") + .select(["a.id", "a.delivery", "r.title"]) + .where("a.id", "in", assetIds) + .execute() + : []; + + for (const p of plans) { + const inv = invocations.rows.filter((r) => r.plan_id === p.id); + const agent = inv.find((r) => r.agent_run_id); + const sub = p.subscription_id ? subs.find((s) => s.id === p.subscription_id) : undefined; + let source: DownloadWhy["source"]; + if (agent?.agent_run_id) { + const goal = (agent.goal ?? "").trim().replace(/\s+/g, " "); + source = { kind: "agent", runId: agent.agent_run_id, name: goal.length > 40 ? `${goal.slice(0, 40)}…` : goal }; + } else if (inv.length) source = { kind: "manual" }; + else if (sub) { + const title = sub.title ?? sub.title_query ?? ""; + source = { + kind: "subscription", + id: sub.id, + name: + sub.season_number !== null && sub.season_number !== undefined ? `${title} 第 ${sub.season_number} 季` : title, + }; + } else source = { kind: "auto" }; + const ev = evOf(p); + const aId = replacedIds.get(p.id); + const a = aId ? assets.find((x) => x.id === aId) : undefined; + const promo = (p.promotion ?? {}) as { kind?: unknown }; + out.set(p.id, { + planId: p.id, + source, + purpose: p.purpose, + slotName: p.slot_name, + replaces: a ? { assetId: a.id, label: assetLabel(a.delivery, a.title) } : null, + reasons: Array.isArray(ev.reasons) ? ev.reasons.filter((x): x is string => typeof x === "string") : [], + promotion: typeof promo.kind === "string" && promo.kind ? promo.kind : null, + seeders: p.seeders !== null && p.seeders !== undefined && p.seeders >= 0 ? p.seeders : null, + siteName: p.site_name ?? null, + evaluation: ev, + }); + } + return out; +} diff --git a/apps/server/src/modules/agent/capabilities.ts b/apps/server/src/modules/agent/capabilities.ts index b44f0074..fdb6595b 100644 --- a/apps/server/src/modules/agent/capabilities.ts +++ b/apps/server/src/modules/agent/capabilities.ts @@ -70,7 +70,7 @@ export async function taskTimeline(ctx: ServerContext, t: TaskRow) { out.push({ at: p.updated_at.toISOString(), type: "plan.status", - summary: `下载计划 ${p.status}${p.status_reason ? `(${p.status_reason})` : ""}`, + summary: `下载准备 ${p.status}${p.status_reason ? `(${p.status_reason})` : ""}`, data: { planId: t.subject_id, status: p.status }, }); const dts = await db diff --git a/apps/server/src/modules/agent/interaction.ts b/apps/server/src/modules/agent/interaction.ts index 05513ac3..03f1441e 100644 --- a/apps/server/src/modules/agent/interaction.ts +++ b/apps/server/src/modules/agent/interaction.ts @@ -648,14 +648,37 @@ export async function inlineApproval( return { allowed: false, reason: "已在步骤处发起审批,等待管理员批准" }; } +/** + * DL-3:下载的审批没通过(拒绝 / 撤销 / 过期)时,还是草案的计划跟着结束,进作品的下载历史; + * 否则草案一直挂着,下载面板与历史里都看不到。 + */ +export async function closeDraftPlan( + db: Kysely, + a: { plan_type?: string | null; plan_id?: string | null }, + outcome: "rejected" | "revoked" | "expired", +): Promise { + if (a.plan_type !== "acquisition_plan" || !a.plan_id) return; + await db + .updateTable("acquisition_plan") + .set( + outcome === "expired" + ? { status: "expired", status_reason: "approval_expired" } + : { status: "cancelled", status_reason: outcome === "rejected" ? "approval_rejected" : "approval_revoked" }, + ) + .where("id", "=", a.plan_id) + .where("status", "=", "draft") + .execute(); +} + async function expireApproval(ctx: ServerContext, approvalId: string) { const a = await ctx.db .updateTable("approval") .set({ status: "expired" }) .where("id", "=", approvalId) .where("status", "=", "pending") - .returning("decision_id") + .returning(["decision_id", "plan_type", "plan_id"]) .executeTakeFirst(); + if (a) await closeDraftPlan(ctx.db, a, "expired"); if (a?.decision_id) await closeDecision(ctx, a.decision_id, "expired", { approval: "expired" }, "system:timeout"); } @@ -664,9 +687,18 @@ async function expireApproval(ctx: ServerContext, approvalId: string) { */ export async function afterApprovalDecided( ctx: ServerContext, - a: { id: string; status: string; scope: unknown; decision_id: string | null; reason: string | null }, + a: { + id: string; + status: string; + scope: unknown; + decision_id: string | null; + reason: string | null; + plan_type?: string | null; + plan_id?: string | null; + }, by: string, ): Promise { + if (a.status === "rejected" || a.status === "revoked") await closeDraftPlan(ctx.db, a, a.status); const scope = (a.scope ?? {}) as Partial; if (!scope.agentRunId) return; if (a.decision_id) { @@ -697,12 +729,14 @@ export async function dismissPendingInteractions(ctx: ServerContext, runId: stri .where((eb) => eb.or([eb("dedupe_key", "like", `${OPTIONS_KEY}:%`), eb("dedupe_key", "like", `${APPROVAL_KEY}:%`)])) .execute(); for (const d of open) await closeDecision(ctx, d.id, "dismissed", { reason: "会话已停止" }, by); - await ctx.db + const revoked = await ctx.db .updateTable("approval") .set({ status: "revoked", decided_by: by, decided_at: new Date(), reason: "会话已停止" }) .where(sql`scope->>'agentRunId' = ${runId}`) .where("status", "=", "pending") + .returning(["plan_type", "plan_id"]) .execute(); + for (const a of revoked) await closeDraftPlan(ctx.db, a, "revoked"); // 选定即批准的审批还没用掉:会话停了就撤销(只绑本会话,不留给别处) await ctx.db .updateTable("approval") diff --git a/apps/server/src/modules/browser/capabilities.ts b/apps/server/src/modules/browser/capabilities.ts index 067dcd97..d2aca8ac 100644 --- a/apps/server/src/modules/browser/capabilities.ts +++ b/apps/server/src/modules/browser/capabilities.ts @@ -206,7 +206,7 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: ( sourceReleaseId: v.sourceReleaseId, duplicate: !!v.duplicate, note: v.duplicate - ? "同一会话已捕获过这个文件;先查已有下载计划,不要重复提交" + ? "同一会话已捕获过这个文件;先查已有的下载,不要重复提交" : !v.verification.valid ? "不是有效的种子文件" : v.sourceReleaseId diff --git a/apps/server/src/modules/browser/routes.ts b/apps/server/src/modules/browser/routes.ts index d9785fb2..d1fe5e14 100644 --- a/apps/server/src/modules/browser/routes.ts +++ b/apps/server/src/modules/browser/routes.ts @@ -262,7 +262,7 @@ export function registerBrowserRoutes(app: ModuleApp, svc: () => BrowserService) ...body.submit, sourceReleaseId: v.sourceReleaseId, }); - if (!submission.submitted) note = "提交未生成下载计划,见 rejected"; + if (!submission.submitted) note = "没有下载,原因见 rejected"; } catch (e) { // 与 POST /acquisition-plans 相同的拒绝(未识别、无授权等):产物已暂存,结果照常返回 if (!(e instanceof ApiException) || e.code === "internal") throw e; diff --git a/apps/server/src/modules/mcp/catalog.ts b/apps/server/src/modules/mcp/catalog.ts index ccc13c50..0090b6f5 100644 --- a/apps/server/src/modules/mcp/catalog.ts +++ b/apps/server/src/modules/mcp/catalog.ts @@ -144,7 +144,7 @@ export const MCP_PROMPTS: readonly McpPromptDef[] = [ { name: "acquire_title", title: "收录一部作品", - description: "从查作品到生成下载计划;提交下载要有授权。", + description: "从查作品到准备下载;提交下载要有授权。", arguments: [ { name: "title", description: "作品标题", required: true }, { name: "year", description: "年份(可选)", required: false }, @@ -154,8 +154,8 @@ export const MCP_PROMPTS: readonly McpPromptDef[] = [ `目标:收录「${a.title ?? ""}」${a.year ? `(${a.year})` : ""}。`, "1. catalog__lookup 确认作品,拿不准时列出候选,不要猜;", "2. release__search 找资源,release__inspect 看细节;", - "3. download__plan 生成下载计划并说明选择理由;", - "4. 有持续授权时再调用 download__submit;返回 unauthorized 就停下,给出计划 ID 等审批。", + "3. download__plan 准备下载(返回 planId)并说明选择理由;", + "4. 有持续授权时再调用 download__submit;返回 unauthorized 就停下,给出 planId 等审批。", ].join("\n"), }, { diff --git a/apps/server/src/modules/search/actions.ts b/apps/server/src/modules/search/actions.ts index f62eae0a..840aabaf 100644 --- a/apps/server/src/modules/search/actions.ts +++ b/apps/server/src/modules/search/actions.ts @@ -29,7 +29,6 @@ export const SEARCH_ACTIONS: readonly SearchAction[] = [ page("subscriptions", "获取", "订阅", "/console/subscriptions", ["subscription", "追剧"]), page("rules", "获取", "规则", "/console/rules", ["rules", "收录策略", "policy"]), page("downloads", "整理", "下载面板", "/console/downloads", ["download", "qb", "下载任务"]), - page("download-plans", "整理", "下载计划", "/console/downloads/plans", ["plan", "计划"]), page("organize", "整理", "整理记录", "/console/organize", ["organize", "入库", "历史"]), page("files", "整理", "文件管理", "/console/files", ["files", "文件"]), page("trash", "整理", "回收站", "/console/files/trash", ["trash", "删除"]), diff --git a/apps/web/app/api/downloads.ts b/apps/web/app/api/downloads.ts index ce18f626..af8dec7c 100644 --- a/apps/web/app/api/downloads.ts +++ b/apps/web/app/api/downloads.ts @@ -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 = { - draft: "草稿", - reserved: "已预留", + draft: "待批准", + reserved: "已预留空间", submitted: "已提交", completed: "已完成", cancelled: "已取消", diff --git a/apps/web/app/api/tasks.ts b/apps/web/app/api/tasks.ts index 858d7b63..b654a292 100644 --- a/apps/web/app/api/tasks.ts +++ b/apps/web/app/api/tasks.ts @@ -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", }); diff --git a/apps/web/app/components/agent/agent-interaction.tsx b/apps/web/app/components/agent/agent-interaction.tsx index 99329205..380b1256 100644 --- a/apps/web/app/components/agent/agent-interaction.tsx +++ b/apps/web/app/components/agent/agent-interaction.tsx @@ -298,7 +298,7 @@ export function OptionsCard({ runId, view, waiting }: { runId: string; view: Opt const RISK_LABEL: Record = { low: "低", medium: "中", high: "高" }; const PLAN_LABEL: Record = { - acquisition_plan: "下载计划", + acquisition_plan: "下载", organize_plan: "整理计划", policy_revision: "策略版本", }; diff --git a/apps/web/app/components/agent/agui3.test.ts b/apps/web/app/components/agent/agui3.test.ts index b4983049..19fa7e52 100644 --- a/apps/web/app/components/agent/agui3.test.ts +++ b/apps/web/app/components/agent/agui3.test.ts @@ -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" }) }]; diff --git a/apps/web/app/components/agent/conversation-model.ts b/apps/web/app/components/agent/conversation-model.ts index 286a643d..1f697237 100644 --- a/apps/web/app/components/agent/conversation-model.ts +++ b/apps/web/app/components/agent/conversation-model.ts @@ -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 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 - 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(","); } diff --git a/apps/web/app/components/agent/mock.ts b/apps/web/app/components/agent/mock.ts index f41d4ea8..862ecf3e 100644 --- a/apps/web/app/components/agent/mock.ts +++ b/apps/web/app/components/agent/mock.ts @@ -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 }, ], diff --git a/apps/web/app/components/agent/report-humanize.test.ts b/apps/web/app/components/agent/report-humanize.test.ts index 812db7f2..28a58db6 100644 --- a/apps/web/app/components/agent/report-humanize.test.ts +++ b/apps/web/app/components/agent/report-humanize.test.ts @@ -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("被规则拒绝(需要你批准),未重试"); diff --git a/apps/web/app/components/console/nav.ts b/apps/web/app/components/console/nav.ts index 532fb2ea..c4240bfb 100644 --- a/apps/web/app/components/console/nav.ts +++ b/apps/web/app/components/console/nav.ts @@ -83,7 +83,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: "整理记录", diff --git a/apps/web/app/components/downloads/business-tasks.tsx b/apps/web/app/components/downloads/business-tasks.tsx index 04a001fe..6a517eb4 100644 --- a/apps/web/app/components/downloads/business-tasks.tsx +++ b/apps/web/app/components/downloads/business-tasks.tsx @@ -53,7 +53,7 @@ export const ACTIVE_TASK: TaskStatus[] = ["pending", "running", "waiting"]; export const SUBJECT_LABEL: Record = { download_task: "下载任务", - acquisition_plan: "下载计划", + acquisition_plan: "下载", acquisition_slot: "收录版本", organization_record: "整理记录", organize_request: "整理请求", @@ -73,7 +73,7 @@ export function subjectLink(t: Pick): 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": diff --git a/apps/web/app/components/downloads/business-timeline.tsx b/apps/web/app/components/downloads/business-timeline.tsx index eacca1ca..a721d8c2 100644 --- a/apps/web/app/components/downloads/business-timeline.tsx +++ b/apps/web/app/components/downloads/business-timeline.tsx @@ -10,7 +10,7 @@ import { Timeline } from "~/components/console/timeline.tsx"; export const STAGE_LABEL: Record = { observation: "发现", - plan: "计划", + plan: "准备", download: "下载", organization: "整理", agent: "智能体", diff --git a/apps/web/app/components/downloads/download-board.tsx b/apps/web/app/components/downloads/download-board.tsx index 5cc01c2e..f22aac20 100644 --- a/apps/web/app/components/downloads/download-board.tsx +++ b/apps/web/app/components/downloads/download-board.tsx @@ -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; extra?: ReactNode }) { if (!t.media && !extra) return null; return (

@@ -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; children?: ReactNode }) { return (

{t.name}

@@ -128,42 +136,63 @@ function ActiveStatus({ t }: { t: DownloadBoardItem }) { ); } -export function activeColumns(): DataColumn[] { +const dash = —; + +/** 还没提交的下载:名称列(窄屏带状态与操作) */ +function PlanNameCell({ p }: { p: DownloadBoardPlanItem }) { + return ( + + {formatBytes(p.sizeBytes, 1)} : null} + /> +
+ + +
+
+ ); +} + +export function activeColumns(): DataColumn[] { return [ { key: "name", header: "下载任务", - cell: (t) => ( - - - {/* 窄屏:进度与速度并进名称列 */} -
- - - {t.live?.dlSpeed || t.live?.upSpeed ? ( -

- - {t.live.etaSec !== null ? 剩余 {formatEta(t.live.etaSec)} : null} -

- ) : null} -
-
- ), + cell: (t) => + t.kind === "plan" ? ( + + ) : ( + + + {/* 窄屏:进度与速度并进名称列 */} +
+ + + {t.live?.dlSpeed || t.live?.upSpeed ? ( +

+ + {t.live.etaSec !== null ? 剩余 {formatEta(t.live.etaSec)} : null} +

+ ) : null} +
+
+ ), }, { key: "progress", header: "进度", hideBelow: "md", className: "w-40", - cell: (t) => , + cell: (t) => (t.kind === "plan" ? dash : ), }, { key: "speed", header: "速度", hideBelow: "md", cell: (t) => - !t.live?.dlSpeed && !t.live?.upSpeed ? ( - — + t.kind === "plan" || (!t.live?.dlSpeed && !t.live?.upSpeed) ? ( + dash ) : (

@@ -181,15 +210,35 @@ export function activeColumns(): DataColumn[] { key: "eta", header: "剩余时间", hideBelow: "lg", - cell: (t) => ( -

-

{formatEta(t.live?.etaSec)}

- {peersText(t.live) ?

{peersText(t.live)}

: null} -
- ), + cell: (t) => + t.kind === "plan" ? ( + dash + ) : ( +
+

{formatEta(t.live?.etaSec)}

+ {peersText(t.live) ?

{peersText(t.live)}

: null} +
+ ), + }, + { + key: "status", + header: "状态", + hideBelow: "md", + cell: (t) => (t.kind === "plan" ? : ), + }, + { + key: "ops", + header: "", + align: "right", + cell: (t) => + t.kind === "plan" ? ( + + + + ) : ( + + ), }, - { key: "status", header: "状态", hideBelow: "md", cell: (t) => }, - { key: "ops", header: "", align: "right", cell: (t) => }, ]; } @@ -284,7 +333,7 @@ export const BOARD_EMPTY: Record = { 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

) : null} + {task.acquisitionPlanId ? ( + + ) : null} + - {plan.data ? ( - - - - - - - ) : null} - {task.fileList?.length ? (
    diff --git a/apps/web/app/components/downloads/downloads-tabs.tsx b/apps/web/app/components/downloads/downloads-tabs.tsx deleted file mode 100644 index 26e64ef2..00000000 --- a/apps/web/app/components/downloads/downloads-tabs.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/apps/web/app/components/downloads/plan-format.test.ts b/apps/web/app/components/downloads/plan-format.test.ts new file mode 100644 index 00000000..d6aa8d93 --- /dev/null +++ b/apps/web/app/components/downloads/plan-format.test.ts @@ -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 => ({ + 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(); + }); +}); diff --git a/apps/web/app/components/downloads/plan-format.ts b/apps/web/app/components/downloads/plan-format.ts new file mode 100644 index 00000000..23b2d1e2 --- /dev/null +++ b/apps/web/app/components/downloads/plan-format.ts @@ -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 = { fill: "补缺", upgrade: "升级", replace_source: "换源" }; + +export const PLAN_WAIT: Record = { + approval: { label: "等你批准", tone: "warning" }, + space: { label: "等磁盘空间", tone: "warning" }, + automation_off: { label: "等开启自动化", tone: "warning" }, + queued: { label: "准备提交", tone: "neutral" }, +}; + +/** 在等什么的补充一句(下载盘哪条线、为什么还没提交);等批准与正常排队不补充 */ +export function planWaitHint(p: Pick): 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): 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): 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; +} diff --git a/apps/web/app/components/downloads/plan-row.tsx b/apps/web/app/components/downloads/plan-row.tsx new file mode 100644 index 00000000..bfe4752d --- /dev/null +++ b/apps/web/app/components/downloads/plan-row.tsx @@ -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 ( + + {w.label} + {h ? {h} : null} + + ); +} + +/** 批准 / 取消:等批准的走审批(拒绝后服务端取消这次下载);其余直接取消。都要确认 */ +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: 只拦截行点击 + + {approval ? ( + + + 批准 + + } + title={`批准下载「${p.name}」?`} + description={`${p.sizeBytes !== null ? `${formatBytes(p.sizeBytes, 1)},` : ""}批准后立即提交到下载器。`} + confirmLabel="批准" + onConfirm={() => decideApproval("approved")} + /> + ) : null} + + + 取消 + + } + 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; + }, + ) + } + /> + + ); +} + +/** 「为什么下这个」:来源 · 目的,下一行选中理由要点;原始评估进技术信息 */ +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 ( + +

    {t.head}

    + {t.points ?

    {t.points}

    : null} + ({ ...x, wide: true })), + ]} + > + + +
    + ); +} + +/** 还没提交的下载的详情(抽屉内容) */ +export function PlanDetail({ p }: { p: DownloadBoardPlanItem }) { + const hint = planWaitHint(p); + return ( +
    +
    + {PLAN_WAIT[p.wait].label} +
    + +
    +
    + {p.wait === "approval" ? ( +

    + 这次下载要你批准后才提交,同一件事也在待处理里。 + {p.approval?.decisionId ? ( + + + 去待处理 + + ) : null} +

    + ) : hint ? ( +

    {hint}。

    + ) : null} + + {p.media.title} + {p.media.year ? ` (${p.media.year})` : ""} + + ) : null, + }, + { label: "大小", value: p.sizeBytes === null ? "未知" : formatBytes(p.sizeBytes, 2) }, + { label: "用途", value: PURPOSE_LABEL[p.purpose] }, + { label: "加入时间", value: formatDateTime(p.createdAt) }, + ]} + /> + +
    + ); +} diff --git a/apps/web/app/components/search/search-download-dialog.tsx b/apps/web/app/components/search/search-download-dialog.tsx index 45bf63cf..945c396f 100644 --- a/apps/web/app/components/search/search-download-dialog.tsx +++ b/apps/web/app/components/search/search-download-dialog.tsx @@ -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 ? (
    -

    没有生成下载计划:

    +

    没有下载:

      {rejected.map((r, i) => (
    • @@ -144,8 +144,8 @@ export function SearchDownloadDialog({ ) : null} {done ? (

      - 计划已生成。 - + 已加入下载。 + 查看

      diff --git a/apps/web/app/components/settings/automation.tsx b/apps/web/app/components/settings/automation.tsx index 5be28d13..1b2ad5d2 100644 --- a/apps/web/app/components/settings/automation.tsx +++ b/apps/web/app/components/settings/automation.tsx @@ -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" }, diff --git a/apps/web/app/components/subscriptions/coverage-matrix.tsx b/apps/web/app/components/subscriptions/coverage-matrix.tsx index d2294a16..3e5e2409 100644 --- a/apps/web/app/components/subscriptions/coverage-matrix.tsx +++ b/apps/web/app/components/subscriptions/coverage-matrix.tsx @@ -195,7 +195,7 @@ export function CoverageMatrix({ ); } -/** 点格打开:资产 / 质量 / pri / 计划 / 原因 / 可升级 / 延后 → 手动补全、跳下载计划 */ +/** 点格打开:资产 / 质量 / pri / 计划 / 原因 / 可升级 / 延后 → 手动补全、跳下载面板 */ export function CellSheet({ sel, subscriptionId, @@ -244,9 +244,9 @@ export function CellSheet({ <> {c.planId ? ( ) : 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, diff --git a/apps/web/app/components/tasks/labels.ts b/apps/web/app/components/tasks/labels.ts index 1ad84733..a4abf435 100644 --- a/apps/web/app/components/tasks/labels.ts +++ b/apps/web/app/components/tasks/labels.ts @@ -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": diff --git a/apps/web/app/components/workbench/download-dialog.tsx b/apps/web/app/components/workbench/download-dialog.tsx index b22ca448..28a92460 100644 --- a/apps/web/app/components/workbench/download-dialog.tsx +++ b/apps/web/app/components/workbench/download-dialog.tsx @@ -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 ? (
      -

      没有生成下载计划:

      +

      没有下载:

        {rejected.map((r, i) => (
      • @@ -122,8 +122,8 @@ export function DownloadDialog({ ) : null} {done ? (

        - 计划已生成。 - + 已加入下载。 + 查看

        diff --git a/apps/web/app/lib/labels.ts b/apps/web/app/lib/labels.ts index c8a86107..ebe24804 100644 --- a/apps/web/app/lib/labels.ts +++ b/apps/web/app/lib/labels.ts @@ -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 = { added: "已添加到下载器", "organize:succeeded": "整理成功", diff --git a/apps/web/app/lib/reasons.ts b/apps/web/app/lib/reasons.ts index 1d95ed71..7e08e291 100644 --- a/apps/web/app/lib/reasons.ts +++ b/apps/web/app/lib/reasons.ts @@ -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; diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts index 273e947d..beeb45a3 100644 --- a/apps/web/app/routes.ts +++ b/apps/web/app/routes.ts @@ -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"), diff --git a/apps/web/app/routes/console/audit.tsx b/apps/web/app/routes/console/audit.tsx index 15301f8b..a546f464 100644 --- a/apps/web/app/routes/console/audit.tsx +++ b/apps/web/app/routes/console/audit.tsx @@ -38,7 +38,8 @@ const ACTION_LABEL: Record = { "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 = { site_account: "站点账号", feed_source: "RSS", engine_run: "任务", - acquisition_plan: "下载计划", + acquisition_plan: "下载", download_task: "下载任务", media_entity: "作品", source_release: "资源", diff --git a/apps/web/app/routes/console/downloads.plans.tsx b/apps/web/app/routes/console/downloads.plans.tsx index 39dc24ae..274fab96 100644 --- a/apps/web/app/routes/console/downloads.plans.tsx +++ b/apps/web/app/routes/console/downloads.plans.tsx @@ -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(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[] = [ - { - key: "purpose", - header: "计划", - cell: (p) => ( -

        - {PURPOSE[p.purpose]} · 优先级 {PRIORITY[p.priority as keyof typeof PRIORITY] ?? p.priority} -

        - ), - }, - { - key: "status", - header: "状态", - cell: (p) => {PLAN_STATUS_LABEL[p.status]}, - }, - { - key: "reserved", - header: "预留空间", - hideBelow: "md", - align: "right", - cell: (p) => {formatBytes(p.reservedBytes, 1)}, - }, - { - key: "reason", - header: "说明", - hideBelow: "lg", - cell: (p) => {planReasonText(p.statusReason) ?? "—"}, - }, - { - key: "at", - header: "创建", - hideBelow: "sm", - cell: (p) => {relTime(p.createdAt)}, - }, - ]; - - return ( - <> - - - - f.set("status", v)} - options={Object.entries(PLAN_STATUS_LABEL).map(([value, label]) => ({ value, label }))} - /> - - 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 : "选中可下载的资源后出现在这里。", - }} - /> - - !o && setOpenPlan(null)} - title={openPlan ? `${PURPOSE[openPlan.purpose]}计划` : ""} - description={planReasonText(openPlan?.statusReason) ?? undefined} - footer={ - linkedDownload ? ( - - ) : null - } - > - {openPlan ? ( - <> - {PLAN_STATUS_LABEL[openPlan.status]} - ), - }, - { - 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) }, - ]} - /> - - - - - - ) : null} - - - ); +export default function LegacyRedirect() { + return null; } diff --git a/apps/web/app/routes/console/downloads.tsx b/apps/web/app/routes/console/downloads.tsx index 5d8f396d..b857b0b0 100644 --- a/apps/web/app/routes/console/downloads.tsx +++ b/apps/web/app/routes/console/downloads.tsx @@ -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(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[])), + [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() { } /> -
        @@ -90,19 +118,59 @@ export default function DownloadsPage() { !o && setOpenId(null)} + onOpenChange={(o) => { + if (o) return; + setOpenId(null); + closeDeep(); + }} width="lg" title={open?.name ?? ""} footer={ - open ? ( + open?.kind === "task" ? ( ) : null } > - {open ? : null} + {open?.kind === "task" ? : null} + {open?.kind === "plan" ? : null} + + ); } + +/** 深链指向已结束的下载(没提交成功 / 已取消 / 已过期):说明原因与来由;作品的下载历史里也有 */ +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 ( + !o && onClose()} + title={ended ? "这次下载没有提交" : "下载"} + description={p ? (droppedReason(p.statusReason) ?? undefined) : undefined} + > + {plan.isPending ? ( + + ) : !p ? ( +

        没找到这次下载,可能已被清理。

        + ) : ( +
        + {ended ? ( + {DROPPED_STATUS[ended].label} + ) : ( +

        这次下载已经结束。

        + )} + +
        + )} +
        + ); +} diff --git a/apps/web/app/routes/console/library.$id.tsx b/apps/web/app/routes/console/library.$id.tsx index 256974cc..c07d03e6 100644 --- a/apps/web/app/routes/console/library.$id.tsx +++ b/apps/web/app/routes/console/library.$id.tsx @@ -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}

        - {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)}

        @@ -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 ( - d.downloadTask.id} - empty={{ title: manual.length ? "没有下载任务,只有手动整理记录" : "还没有下载记录" }} - /> + {detail.downloads.length || !dropped.length ? ( + d.downloadTask.id} + empty={{ title: manual.length ? "没有下载任务,只有手动整理记录" : "还没有下载记录" }} + /> + ) : null} + {dropped.length ? ( +
        +

        没提交到下载器

        +
          + {dropped.map(({ plan, releaseTitle }) => { + const st = DROPPED_STATUS[plan.status as keyof typeof DROPPED_STATUS] ?? DROPPED_STATUS.failed; + const why = droppedReason(plan.statusReason); + return ( +
        • +
          + {st.label} + + {releaseTitle ?? "未知资源"} + + + {PURPOSE_LABEL[plan.purpose]} · {formatDateTime(plan.updatedAt)} + +
          + {why ?

          {why}

          : null} + +
        • + ); + })} +
        +
        + ) : null} {manual.length ? (

        其他整理记录

        diff --git a/apps/web/app/routes/console/redirects.ts b/apps/web/app/routes/console/redirects.ts index 891f2851..0ba1a049 100644 --- a/apps/web/app/routes/console/redirects.ts +++ b/apps/web/app/routes/console/redirects.ts @@ -25,6 +25,10 @@ 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]}`); // IA-2:独立 RSS 页取消,RSS 在站点详情的 RSS Tab。?feed= 要查所属站点,由 sources.tsx 的 clientLoader 处理 if (path === "/console/sources") { @@ -40,7 +44,7 @@ export function legacyRedirect(pathname: string, search: string): string | null // 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; @@ -53,7 +57,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" }, diff --git a/apps/web/app/routes/console/tasks.tsx b/apps/web/app/routes/console/tasks.tsx index e2d7cac6..ef3e6e28 100644 --- a/apps/web/app/routes/console/tasks.tsx +++ b/apps/web/app/routes/console/tasks.tsx @@ -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; diff --git a/docs/design/glossary.md b/docs/design/glossary.md index d0d0b36d..4665a1d3 100644 --- a/docs/design/glossary.md +++ b/docs/design/glossary.md @@ -22,6 +22,7 @@ | 资源按收录版本评估后 | 不单独命名;说"可下载的资源" | 候选(只在代码与给模型的说明里) | | Decision | 待处理事项;入口叫"待处理" | 决策、工单、待决 | | Approval | 审批(名词);按钮用"批准 / 拒绝" | 授权记录 | +| AcquisitionPlan(下载计划,DL-3 起界面不单独命名) | 下载;还没提交到下载器的按在等什么写状态:「等你批准」「等磁盘空间」「等开启自动化」「准备提交」;没提交就结束的写「没提交成功 / 已取消 / 已过期」+ 原因;来由写「为什么下这个」;智能体动作计数写"准备下载 N 个资源" | 下载计划、草稿、已预留(界面) | | DownloadTask | 下载任务 | 下载单 | | Organization record | 整理记录;动作叫"整理",结果状态叫"已入库" | 转移、入库记录 | | 整理方式 | 复制 / 移动 / 硬链接 | Copy / Move | @@ -48,7 +49,7 @@ | AgentRun(parent_run_id 非空) | 子会话;派生它的叫"父会话" | 子代理、子 Agent、subagent | | AgentRun 被中止 | 停止;状态"已停止" | 中止、取消 | | AutomationGrant | 持续授权 | 自动化授权、automation_grant | -| agent.plan 的步骤清单 | 步骤;进度写"步骤 2/5" | 计划(计划只指下载计划 / 整理计划 / 计划页)、TODO、任务列表 | +| agent.plan 的步骤清单 | 步骤;进度写"步骤 2/5" | 计划(计划只指整理计划 / 计划页;下载计划界面上统称"下载")、TODO、任务列表 | | web.search / web.fetch | 网页搜索 / 读取网页;设置卡叫"网页搜索",提供方叫"搜索服务" | 联网、上网、爬取、网络代理 | | agent.propose_options | 方案;动作叫"选定",都不要叫"退回" | 选项、提案 | | scope.proposeFirst | 输入框指令 `/先给方案`(只作用于这一次,AGUI-3 起不再是开关);会话列表标签"先给方案" | 只要方案、只给方案、plan_only 模式、先看方案再执行(开关) | diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index 6558c38e..19d02ede 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -1118,6 +1118,24 @@ export const Api = { body: execution.ManualAcquisitionRequest, response: execution.ManualAcquisitionResult, }), + why: ep({ + method: "get", + path: "/acquisition-plans/{id}/why", + scope: "read", + summary: + "下载的来由:来源(订阅 / 智能体会话 / 手动)、目的(补缺 / 升级 / 换源)、被替换的版本、选中理由要点与原始评估", + params: IdParam, + response: board.DownloadWhy, + }), + cancel: ep({ + method: "post", + path: "/acquisition-plans/{id}/cancel", + scope: "organize.execute", + summary: + "取消还没提交到下载器的计划(待批准 / 已预留):释放收录版本与预算预留;已有下载任务的返回 409。待批准的计划请拒绝审批(拒绝后计划自动取消)", + params: IdParam, + response: execution.AcquisitionPlan, + }), }, downloadTasks: { ...crud( @@ -1138,7 +1156,7 @@ export const Api = { path: "/download-tasks/board", scope: "read", summary: - "下载面板:按进行中 / 已完成分组列出下载任务,附每个种子的实时速度、剩余时间、连接数与下载器总速度(实时数据短时缓存)", + "下载面板:按进行中 / 已完成分组列出下载任务(进行中含还没提交、在等批准或下载盘空间的计划,kind=plan),附每个种子的实时速度、剩余时间、连接数与下载器总速度(实时数据短时缓存)", query: board.DownloadBoardQuery, response: board.DownloadBoard, }), diff --git a/packages/contracts/src/capability.ts b/packages/contracts/src/capability.ts index eb91d484..2b66d4e1 100644 --- a/packages/contracts/src/capability.ts +++ b/packages/contracts/src/capability.ts @@ -177,8 +177,8 @@ export const CAPABILITY_CATALOG: Record ), "download.plan": d( "download.plan", - "生成下载计划", - "评估候选,生成占用收录版本(slot)与预算预留的下载计划;不提交。选定资源后、提交前用;不会提交下载,也不预留收录版本", + "准备下载", + "评估选定的资源能否下到这个收录版本(slot),准备一份待提交的下载(返回 planId);不提交,也不占用收录版本与预算。选定资源后、提交前用", "preview", "DownloadPlan", "organize.execute", @@ -189,7 +189,7 @@ export const CAPABILITY_CATALOG: Record "download.submit": d( "download.submit", "提交下载", - "按已保存的计划提交到下载器;校验授权与硬阈值。只提交 download.plan 生成的计划;结果未知时先用 download.inspect / task.inspect 核对,不要重复提交", + "把 download.plan 准备好的下载(planId)提交到下载器;校验授权与硬阈值。只提交 download.plan 返回的 planId;结果未知时先用 download.inspect / task.inspect 核对,不要重复提交", "execute", "DownloadSubmit", "organize.execute", @@ -602,6 +602,8 @@ export interface CapabilityActivity { noun: string; unit: string | null; hidden?: boolean; + /** 动作后不加「了」(「准备下载 1 个资源」) */ + bare?: boolean; } const act = (verb: string, noun: string, unit: string | null = null): CapabilityActivity => ({ verb, noun, unit }); @@ -611,7 +613,7 @@ export const CAPABILITY_ACTIVITY: Record "release.search": act("搜", "资源", "次"), "release.inspect": act("看", "资源", "个"), "identity.resolve": act("识别", "作品", "个"), - "download.plan": act("生成", "下载计划", "份"), + "download.plan": { ...act("准备下载", "资源", "个"), bare: true }, "download.submit": act("提交", "下载", "个"), "download.recheck": act("校验", "下载任务", "个"), "organize.plan": act("生成", "整理计划", "份"), diff --git a/packages/contracts/src/download-board.ts b/packages/contracts/src/download-board.ts index 64bf5a77..567a0ec3 100644 --- a/packages/contracts/src/download-board.ts +++ b/packages/contracts/src/download-board.ts @@ -1,13 +1,16 @@ import { z } from "zod"; import { DecisionKind } from "./agent.ts"; import { CursorQuery, Id, NonNegInt, Timestamp } from "./common.ts"; -import { type DownloadStatus, DownloadTask } from "./execution.ts"; +import { type DownloadStatus, DownloadTask, PlanPurpose, PlanStatus } from "./execution.ts"; import { OrganizationStatus } from "./organization.ts"; // 下载面板(DL-2):下载任务 + 下载器实时数据(速度、剩余时间、连接数)+ 分组计数 + 下载器总速度。 // 实时数据来自 qB torrents/info 与 transfer/info,服务端短 TTL 缓存(轮询不直打 qB)。 -/** 进行中 = 排队/提交中/下载中/暂停/无进展/出错/缺失;已完成 = 下载完成(含做种)/入库中/已入库;已移除不出现 */ +/** + * 进行中 = 排队/提交中/下载中/暂停/无进展/出错/缺失 + 还没提交、在等条件的计划(DL-3); + * 已完成 = 下载完成(含做种)/入库中/已入库;已移除不出现 + */ export const DownloadBoardState = z.enum(["active", "completed"]); export type DownloadBoardState = z.infer; @@ -19,6 +22,8 @@ export const DOWNLOAD_BOARD_STATUSES: Record; @@ -44,6 +49,7 @@ export const DownloadLive = z.object({ export type DownloadLive = z.infer; export const DownloadBoardItem = DownloadTask.extend({ + kind: z.literal("task"), live: DownloadLive.nullable(), /** 已识别的作品 */ media: z.object({ id: Id, title: z.string(), year: z.number().int().nullable() }).nullable(), @@ -59,6 +65,61 @@ export const DownloadBoardItem = DownloadTask.extend({ }); export type DownloadBoardItem = z.infer; +/** + * 计划在等什么(DL-3):approval = 等管理员批准(有待处理的审批);space = 下载盘超过暂停线或用量未知; + * automation_off = 自动化总开关关闭 / 只读对账;queued = 条件已满足、等提交任务执行。 + * 每日下载额度在生成计划时核对,超额不生成计划,所以没有"等额度"的计划。 + */ +export const PlanWait = z.enum(["approval", "space", "automation_off", "queued"]); +export type PlanWait = z.infer; + +/** 还没提交到下载器的计划,作为「进行中」的一行(id 是计划 id) */ +export const DownloadBoardPlanItem = z.object({ + kind: z.literal("plan"), + id: Id, + createdAt: Timestamp, + /** 资源标题 */ + name: z.string(), + sizeBytes: NonNegInt.nullable(), + purpose: PlanPurpose, + status: PlanStatus, + media: z.object({ id: Id, title: z.string(), year: z.number().int().nullable() }).nullable(), + wait: PlanWait, + /** 原因码(提交任务的延后原因等;人话见 reasons 表,原码进技术信息) */ + waitReason: z.string().nullable(), + /** 待批准时的审批(批准 / 拒绝回传 payloadHash) */ + approval: z.object({ id: Id, payloadHash: z.string(), decisionId: Id.nullable() }).nullable(), +}); +export type DownloadBoardPlanItem = z.infer; + +export const DownloadBoardRow = z.discriminatedUnion("kind", [DownloadBoardItem, DownloadBoardPlanItem]); +export type DownloadBoardRow = z.infer; + +/** 下载的来由(DL-3「为什么下这个」):来源、目的、替换的版本、选中理由的原始要点 */ +export const DownloadWhy = z.object({ + planId: Id, + source: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("subscription"), id: Id, name: z.string() }), + z.object({ kind: z.literal("agent"), runId: Id, name: z.string() }), + z.object({ kind: z.literal("manual") }), + z.object({ kind: z.literal("auto") }), + ]), + purpose: PlanPurpose, + /** 收录版本名 */ + slotName: z.string().nullable(), + /** 升级 / 换源时被替换的版本(规格短句) */ + replaces: z.object({ assetId: Id, label: z.string() }).nullable(), + /** evaluation.reasons 原串(hard_conditions_met、source_tier_improved、tier:xxx…) */ + reasons: z.array(z.string()), + /** 资源的促销类型(free / 2xfree / 50% …)与做种数 */ + promotion: z.string().nullable(), + seeders: NonNegInt.nullable(), + siteName: z.string().nullable(), + /** 原始评估结果(技术信息里显示) */ + evaluation: z.record(z.string(), z.unknown()), +}); +export type DownloadWhy = z.infer; + export const DownloaderTransfer = z.object({ instanceId: Id, name: z.string(), @@ -69,7 +130,8 @@ export const DownloaderTransfer = z.object({ export type DownloaderTransfer = z.infer; export const DownloadBoard = z.object({ - items: z.array(DownloadBoardItem), + /** 进行中:下载任务与等待中的计划按创建时间一起倒序;已完成只有下载任务 */ + items: z.array(DownloadBoardRow), nextCursor: z.string().nullable(), counts: z.object({ active: NonNegInt, completed: NonNegInt }), /** 下载器总速度(transfer/info,含非本系统管理的任务);不可达的实例速度记 0 */ diff --git a/packages/contracts/src/organization.ts b/packages/contracts/src/organization.ts index 3c0ac32b..54f5ad72 100644 --- a/packages/contracts/src/organization.ts +++ b/packages/contracts/src/organization.ts @@ -363,6 +363,13 @@ export const LibraryDownloadView = z.object({ }); export type LibraryDownloadView = z.infer; +/** 没提交到下载器就结束的下载(DL-3):失败 / 取消 / 过期且没有下载任务的计划,进作品的下载历史 */ +export const LibraryDroppedPlanView = z.object({ + plan: AcquisitionPlan, + releaseTitle: z.string().nullable(), +}); +export type LibraryDroppedPlanView = z.infer; + export const LibraryDetail = z.object({ entity: MediaEntity, /** 身份映射状态(含 proposed/conflicted,前端区分展示) */ @@ -374,6 +381,7 @@ export const LibraryDetail = z.object({ slots: z.array(AcquisitionSlot), candidates: z.array(CandidateEvaluation), downloads: z.array(LibraryDownloadView), + droppedPlans: z.array(LibraryDroppedPlanView).default([]), /** * 该作品的整理记录(最近 100 条,新→旧):含没有下载任务的手动整理(文件管理按路径整理)。 * 下载历史里已按下载任务列出的记录也在这里,前端按 downloadTaskId 去重展示。 diff --git a/packages/contracts/src/reasons.ts b/packages/contracts/src/reasons.ts index 9d9f2f01..a0c4f6d9 100644 --- a/packages/contracts/src/reasons.ts +++ b/packages/contracts/src/reasons.ts @@ -77,6 +77,16 @@ const REASONS: Record = { space_insufficient: { text: "空间不足" }, unidentifiable: { text: "没识别出作品" }, user_deleted: { text: "已删除" }, + // DL-3:还没提交就结束或在等的下载(计划的 statusReason、提交任务的延后原因) + cancelled_by_user: { text: "你取消了这个下载" }, + approval_rejected: { text: "你没批准这个下载" }, + approval_revoked: { text: "会话停止,下载没有提交" }, + approval_expired: { text: "等批准超时,下载没有提交" }, + subscription_reset: { text: "订阅重置时取消" }, + automation_disabled: { text: "自动化已关闭", action: "在设置 → 自动化里打开" }, + read_only_reconcile: { text: "只读对账中,暂不提交下载" }, + rejected: { text: "下载器拒绝了这个种子" }, + source_failed: { text: "从站点取种子失败", action: "检查站点登录后重新下载" }, }; /**