feat(downloads): fold download plans into download panel (DL-3)
- Waiting plans (approval / disk space / automation off / queued) are
active-segment rows on /download-tasks/board, merged by created_at,
counted, with approve / cancel actions; ?plan= deep links and legacy
/downloads/plans routes redirect to the panel
- Download detail shows "why this download" (source, purpose, replaced
version, reasons); raw evaluation in tech info
- Failed / cancelled / expired plans appear in the entity download
history; rejected / revoked / expired approvals close draft plans
- New GET /acquisition-plans/{id}/why, POST /acquisition-plans/{id}/cancel
- User-facing copy no longer says 下载计划; glossary updated
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c0522f2531
commit
a9147e647d
50 files changed
+1535
-348
No files matched your search
@@ -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` 角色与镜像入口不变。
|
||||
|
||||
## [0.3.0] - 2026-09-29
|
||||
|
||||
@@ -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<string, string>;
|
||||
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<string, unknown>; 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<string, unknown> = {}) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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<AcquisitionPlan>(r ?? notFound("下载计划"));
|
||||
return toApi<AcquisitionPlan>(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<AcquisitionPlan>(row);
|
||||
},
|
||||
{ tag: TAG },
|
||||
);
|
||||
|
||||
@@ -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<MediaEntity>(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<AcquisitionPlan>(p),
|
||||
releaseTitle: release_title,
|
||||
})),
|
||||
organizationRecords: entityRecs.map((r) => ({
|
||||
id: r.id,
|
||||
status: r.status,
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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<In
|
||||
return entry.p;
|
||||
}
|
||||
|
||||
/**
|
||||
* 还没提交到下载器、在等条件的计划(DL-3):草案且有待批准的审批(等批准);已预留但还没有下载任务
|
||||
* (等下载盘空间 / 自动化开启 / 提交任务执行)。没有审批的草案(智能体只生成没提交)不算在等。
|
||||
*/
|
||||
export function waitingPlansQuery(db: Exec, instanceId?: string) {
|
||||
return db
|
||||
.selectFrom("acquisition_plan as p")
|
||||
.selectAll("p")
|
||||
.where((eb) =>
|
||||
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<Date>`now()`)])),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
)
|
||||
.$if(!!instanceId, (qb) => qb.where(sql<string>`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<string, Omit<DownloadBoardPlanItem, "kind" | "id" | "createdAt" | "purpose" | "status">>();
|
||||
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<string, Omit<DownloadBoardPlanItem, "kind" | "id" | "createdAt" | "purpose" | "status">>();
|
||||
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<DownloadBoard> {
|
||||
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<string>().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<DownloadTask>(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),
|
||||
|
||||
@@ -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<string, string> = { 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<Map<string, DownloadWhy>> {
|
||||
const out = new Map<string, DownloadWhy>();
|
||||
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<string, unknown>;
|
||||
// 被替换的版本:评估时记下的 replacesAssetId(DL-3 起);更早的计划在未结束前按收录版本当前的文件
|
||||
const replacedIds = new Map<string, string>();
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -648,14 +648,37 @@ export async function inlineApproval(
|
||||
return { allowed: false, reason: "已在步骤处发起审批,等待管理员批准" };
|
||||
}
|
||||
|
||||
/**
|
||||
* DL-3:下载的审批没通过(拒绝 / 撤销 / 过期)时,还是草案的计划跟着结束,进作品的下载历史;
|
||||
* 否则草案一直挂着,下载面板与历史里都看不到。
|
||||
*/
|
||||
export async function closeDraftPlan(
|
||||
db: Kysely<DB>,
|
||||
a: { plan_type?: string | null; plan_id?: string | null },
|
||||
outcome: "rejected" | "revoked" | "expired",
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
if (a.status === "rejected" || a.status === "revoked") await closeDraftPlan(ctx.db, a, a.status);
|
||||
const scope = (a.scope ?? {}) as Partial<ApprovalScope>;
|
||||
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<boolean>`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")
|
||||
|
||||
@@ -206,7 +206,7 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: (
|
||||
sourceReleaseId: v.sourceReleaseId,
|
||||
duplicate: !!v.duplicate,
|
||||
note: v.duplicate
|
||||
? "同一会话已捕获过这个文件;先查已有下载计划,不要重复提交"
|
||||
? "同一会话已捕获过这个文件;先查已有的下载,不要重复提交"
|
||||
: !v.verification.valid
|
||||
? "不是有效的种子文件"
|
||||
: v.sourceReleaseId
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -30,7 +30,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", "删除"]),
|
||||
|
||||
@@ -8,13 +8,14 @@ import {
|
||||
DownloaderInstance,
|
||||
type DownloadStatus,
|
||||
DownloadTask,
|
||||
DownloadWhy,
|
||||
type PlanStatus,
|
||||
} from "@umt/contracts";
|
||||
import { z } from "zod";
|
||||
import { TTL_SECOND } from "~/lib/api.ts";
|
||||
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
|
||||
|
||||
// 下载任务、下载计划、缓存卷(W2-C:/download-tasks、/acquisition-plans、/download-cache/cleanup、/dashboard)
|
||||
// 下载任务、计划(界面统称"下载")、缓存卷(W2-C:/download-tasks、/acquisition-plans、/download-cache/cleanup、/dashboard)
|
||||
|
||||
const DownloadPage = cursorPage(DownloadTask);
|
||||
const PlanPage = cursorPage(AcquisitionPlan);
|
||||
@@ -27,6 +28,7 @@ export const downloadKeys = {
|
||||
one: (id: string) => ["downloads", "one", id] as const,
|
||||
plans: (status?: PlanStatus) => ["downloads", "plans", status ?? "all"] as const,
|
||||
plan: (id: string) => ["downloads", "plan", id] as const,
|
||||
why: (id: string) => ["downloads", "why", id] as const,
|
||||
cache: () => ["downloads", "cache"] as const,
|
||||
board: (state: DownloadBoardState) => ["downloads", "board", state] as const,
|
||||
};
|
||||
@@ -72,6 +74,19 @@ export function useDownloadBoard(state: DownloadBoardState) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 深链 ?plan=(DL-3):只取这个计划对应的面板行(等待中的计划或它的下载任务);没有行 = 已结束或不存在 */
|
||||
export function useBoardPlanRow(planId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["downloads", "board-plan", planId ?? ""],
|
||||
enabled: !!planId,
|
||||
queryFn: ({ signal }) =>
|
||||
apiParsed(DownloadBoard, "/api/v1/download-tasks/board", {
|
||||
params: { planId: planId ?? "", limit: 1 },
|
||||
signal,
|
||||
}).then((b) => b.items[0] ?? null),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDownloadTask(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: downloadKeys.one(id ?? ""),
|
||||
@@ -104,6 +119,27 @@ export function usePlan(id: string | null | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 「为什么下这个」(DL-3):来源、目的、被替换的版本、选中理由要点与原始评估 */
|
||||
export function usePlanWhy(id: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: downloadKeys.why(id ?? ""),
|
||||
enabled: !!id,
|
||||
staleTime: 60 * TTL_SECOND,
|
||||
queryFn: ({ signal }) =>
|
||||
apiParsed(DownloadWhy, "/api/v1/acquisition-plans/:id/why", { pathParams: { id: id ?? "" }, signal }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 取消还没提交的下载(已预留 / 草案);待批准的走拒绝审批,服务端随之取消 */
|
||||
export function useCancelPlan() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiMutate("/api/v1/acquisition-plans/:id/cancel", { pathParams: { id } }).then((d) => AcquisitionPlan.parse(d)),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: downloadKeys.all }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 暂停(记为用户暂停)/ 恢复;阈值暂停在水位未回落前服务端拒绝恢复 */
|
||||
export function useDownloadAction() {
|
||||
const qc = useQueryClient();
|
||||
@@ -186,8 +222,8 @@ export const PAUSE_REASON_LABEL = {
|
||||
} as const;
|
||||
|
||||
export const PLAN_STATUS_LABEL: Record<PlanStatus, string> = {
|
||||
draft: "草稿",
|
||||
reserved: "已预留",
|
||||
draft: "待批准",
|
||||
reserved: "已预留空间",
|
||||
submitted: "已提交",
|
||||
completed: "已完成",
|
||||
cancelled: "已取消",
|
||||
|
||||
@@ -173,7 +173,7 @@ export function buildTimeline(input: {
|
||||
key: "plan",
|
||||
stage: "plan",
|
||||
at: plan.createdAt,
|
||||
title: `创建下载计划(${plan.purpose === "fill" ? "补缺" : plan.purpose === "upgrade" ? "升级" : "换源"})`,
|
||||
title: `准备下载(${plan.purpose === "fill" ? "补缺" : plan.purpose === "upgrade" ? "升级" : "换源"})`,
|
||||
detail: humanizeReason(plan.statusReason),
|
||||
tone: "neutral",
|
||||
});
|
||||
@@ -182,7 +182,7 @@ export function buildTimeline(input: {
|
||||
key: "plan-status",
|
||||
stage: "plan",
|
||||
at: plan.updatedAt,
|
||||
title: `计划状态:${PLAN_STATUS_LABEL[plan.status]}`,
|
||||
title: `下载准备:${PLAN_STATUS_LABEL[plan.status]}`,
|
||||
detail: humanizeReason(plan.statusReason),
|
||||
tone: plan.status === "failed" ? "destructive" : plan.status === "expired" ? "warning" : "neutral",
|
||||
});
|
||||
|
||||
@@ -298,7 +298,7 @@ export function OptionsCard({ runId, view, waiting }: { runId: string; view: Opt
|
||||
|
||||
const RISK_LABEL: Record<RiskLevel, string> = { low: "低", medium: "中", high: "高" };
|
||||
const PLAN_LABEL: Record<string, string> = {
|
||||
acquisition_plan: "下载计划",
|
||||
acquisition_plan: "下载",
|
||||
organize_plan: "整理计划",
|
||||
policy_revision: "策略版本",
|
||||
};
|
||||
|
||||
@@ -189,6 +189,8 @@ describe("聚合:进度行用能力元数据的人话计数", () => {
|
||||
expect(activitySummary([call({ capabilityId: "download.inspect" }), call({ capabilityId: "site.list" })])).toBe(
|
||||
"查了 1 个下载任务和站点",
|
||||
);
|
||||
// DL-3:不再说"生成了 1 份下载计划"
|
||||
expect(activitySummary([call({ capabilityId: "download.plan" })])).toBe("准备下载 1 个资源");
|
||||
});
|
||||
it("一回合的全部调用聚成一行(1 次也折叠),展开前看不到能力 ID", () => {
|
||||
const activity: ActivityItem[] = [{ kind: "tool", key: "c1", call: call({ capabilityId: "site.list" }) }];
|
||||
|
||||
@@ -43,7 +43,7 @@ export function deniedReason(call: ToolCallView): { text: string; approvable: bo
|
||||
|
||||
// ── 进度行的人话计数 ──
|
||||
|
||||
type Group = { verb: string; nouns: { noun: string; unit: string | null; n: number }[] };
|
||||
type Group = { verb: string; bare: boolean; nouns: { noun: string; unit: string | null; n: number }[] };
|
||||
|
||||
function activityOf(id: string) {
|
||||
return (
|
||||
@@ -73,7 +73,7 @@ export function activitySummary(calls: Pick<ToolCallView, "capabilityId" | "stat
|
||||
if (c.status === "denied" || c.status === "failed" || authDenial(c)) continue;
|
||||
let g = groups.find((x) => x.verb === a.verb);
|
||||
if (!g) {
|
||||
g = { verb: a.verb, nouns: [] };
|
||||
g = { verb: a.verb, bare: !!a.bare, nouns: [] };
|
||||
groups.push(g);
|
||||
}
|
||||
const n = g.nouns.find((x) => x.noun === a.noun);
|
||||
@@ -81,7 +81,10 @@ export function activitySummary(calls: Pick<ToolCallView, "capabilityId" | "stat
|
||||
else g.nouns.push({ noun: a.noun, unit: a.unit, n: 1 });
|
||||
}
|
||||
const text = (g: Group) =>
|
||||
cjkJoin(`${g.verb}了`, joinNouns(g.nouns.map((x) => (x.unit ? `${x.n} ${x.unit}${x.noun}` : x.noun))));
|
||||
cjkJoin(
|
||||
g.bare ? g.verb : `${g.verb}了`,
|
||||
joinNouns(g.nouns.map((x) => (x.unit ? `${x.n} ${x.unit}${x.noun}` : x.noun))),
|
||||
);
|
||||
if (groups.length > 4) return `${groups.slice(0, 3).map(text).join(",")}等 ${groups.length} 类操作`;
|
||||
return groups.map(text).join(",");
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ function script(goal: string, opts: AgentSessionOptions): Step[] {
|
||||
const steps: AgentPlanStep[] = [
|
||||
{ id: "s1", title: "查本地库存和收录版本", status: "running", note: null },
|
||||
{ id: "s2", title: "在站点搜 4K 候选", status: "pending", note: null },
|
||||
{ id: "s3", title: "选资源并生成下载计划", status: "pending", note: null },
|
||||
{ id: "s3", title: "选资源并准备下载", status: "pending", note: null },
|
||||
{ id: "s4", title: "提交下载", status: "pending", note: null },
|
||||
];
|
||||
let revision = 0;
|
||||
@@ -324,7 +324,7 @@ function checkupSteps(goal: string, report: string): Step[] {
|
||||
]);
|
||||
}
|
||||
|
||||
/** 例 3:方案绑定下载计划(选定即批准),选了之后直接提交 */
|
||||
/** 例 3:方案绑定计划(选定即批准),选了之后直接提交 */
|
||||
const DUNE_PLAN = "00000000-0000-4000-8000-00000000b003";
|
||||
const DUNE_DECISION = "00000000-0000-4000-8000-00000000d003";
|
||||
function optionSteps(goal: string): Step[] {
|
||||
@@ -338,7 +338,7 @@ function optionSteps(goal: string): Step[] {
|
||||
revision: 1,
|
||||
steps: [
|
||||
{ id: "s1", title: "找 1080p 资源", status: "done", note: null },
|
||||
{ id: "s2", title: "生成下载计划", status: "done", note: null },
|
||||
{ id: "s2", title: "准备下载", status: "done", note: null },
|
||||
{ id: "s3", title: "等你选方案", status: "running", note: null },
|
||||
{ id: "s4", title: "提交下载", status: "pending", note: null },
|
||||
],
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("humanizeReport(汇报兜底)", () => {
|
||||
names,
|
||||
);
|
||||
expect(r.text).toBe(
|
||||
"检测站点 被拒;查看下载任务 无法调用(返回 参数不对);生成下载计划 返回 plan=null,拒绝原因 下载盘用量未知",
|
||||
"检测站点 被拒;查看下载任务 无法调用(返回 参数不对);准备下载 返回 plan=null,拒绝原因 下载盘用量未知",
|
||||
);
|
||||
expect(r.text).not.toMatch(/site\.health_check|invalid_input|volume_state_unknown/);
|
||||
expect(humanizeReport("被规则拒绝(unauthorized),未重试", names).text).toBe("被规则拒绝(需要你批准),未重试");
|
||||
|
||||
@@ -90,7 +90,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
title: "整理",
|
||||
items: [
|
||||
{ to: "/console/downloads", label: "下载面板", icon: Download, description: "下载任务、下载计划与下载盘" },
|
||||
{ to: "/console/downloads", label: "下载面板", icon: Download, description: "进行中与已完成的下载、下载盘" },
|
||||
{
|
||||
to: "/console/organize",
|
||||
label: "整理记录",
|
||||
|
||||
@@ -53,7 +53,7 @@ export const ACTIVE_TASK: TaskStatus[] = ["pending", "running", "waiting"];
|
||||
|
||||
export const SUBJECT_LABEL: Record<string, string> = {
|
||||
download_task: "下载任务",
|
||||
acquisition_plan: "下载计划",
|
||||
acquisition_plan: "下载",
|
||||
acquisition_slot: "收录版本",
|
||||
organization_record: "整理记录",
|
||||
organize_request: "整理请求",
|
||||
@@ -73,7 +73,7 @@ export function subjectLink(t: Pick<Task, "subjectType" | "subjectId">): string
|
||||
case "download_task":
|
||||
return consolePath("downloads", t.subjectId);
|
||||
case "acquisition_plan":
|
||||
return consolePath("downloads", "plans");
|
||||
return `${consolePath("downloads")}?plan=${encodeURIComponent(t.subjectId)}`;
|
||||
case "organization_record":
|
||||
return consolePath("organize", t.subjectId);
|
||||
case "media_entity":
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Timeline } from "~/components/console/timeline.tsx";
|
||||
|
||||
export const STAGE_LABEL: Record<TimelineStage, string> = {
|
||||
observation: "发现",
|
||||
plan: "计划",
|
||||
plan: "准备",
|
||||
download: "下载",
|
||||
organization: "整理",
|
||||
agent: "智能体",
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { DownloadBoard, DownloadBoardItem, DownloadBoardState } from "@umt/contracts";
|
||||
import type {
|
||||
DownloadBoard,
|
||||
DownloadBoardItem,
|
||||
DownloadBoardPlanItem,
|
||||
DownloadBoardRow,
|
||||
DownloadBoardState,
|
||||
} from "@umt/contracts";
|
||||
import { ArrowDown, ArrowUp, CheckCircle2, Download } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
@@ -9,8 +15,10 @@ import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
import { formatEta, formatSpeed, liveStateLabel, organizeState, peersText, progressOf } from "./board-format.ts";
|
||||
import { DownloadStatusBadges, PauseResume, ProgressLine } from "./download-detail.tsx";
|
||||
import { PlanActions, PlanWaitBadge } from "./plan-row.tsx";
|
||||
|
||||
// 下载面板(DL-2):顶部总速度 + 「进行中 / 已完成」分段;进行中看进度与速度,已完成看完成时间、分享率与整理状态。
|
||||
// DL-3:进行中里还有没提交到下载器的下载(等你批准 / 等磁盘空间…),与下载任务按加入时间一起排。
|
||||
|
||||
/** 下载 / 上传速度一对(0 显示"—") */
|
||||
export function SpeedPair({ dl, up, className }: { dl: number; up: number; className?: string }) {
|
||||
@@ -90,7 +98,7 @@ export function BoardSegments({
|
||||
);
|
||||
}
|
||||
|
||||
function WorkLine({ t, extra }: { t: DownloadBoardItem; extra?: ReactNode }) {
|
||||
function WorkLine({ t, extra }: { t: Pick<DownloadBoardItem, "media">; extra?: ReactNode }) {
|
||||
if (!t.media && !extra) return null;
|
||||
return (
|
||||
<p className="mt-0.5 flex min-w-0 flex-wrap items-center gap-x-2 text-meta text-muted-foreground">
|
||||
@@ -109,7 +117,7 @@ function WorkLine({ t, extra }: { t: DownloadBoardItem; extra?: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NameCell({ t, children }: { t: DownloadBoardItem; children?: ReactNode }) {
|
||||
function NameCell({ t, children }: { t: Pick<DownloadBoardItem, "name">; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0 max-w-md">
|
||||
<p className="line-clamp-2 break-all text-foreground">{t.name}</p>
|
||||
@@ -128,42 +136,63 @@ function ActiveStatus({ t }: { t: DownloadBoardItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function activeColumns(): DataColumn<DownloadBoardItem>[] {
|
||||
const dash = <span className="text-meta text-muted-foreground">—</span>;
|
||||
|
||||
/** 还没提交的下载:名称列(窄屏带状态与操作) */
|
||||
function PlanNameCell({ p }: { p: DownloadBoardPlanItem }) {
|
||||
return (
|
||||
<NameCell t={p}>
|
||||
<WorkLine
|
||||
t={p}
|
||||
extra={p.sizeBytes !== null ? <span className="tabular-nums">{formatBytes(p.sizeBytes, 1)}</span> : null}
|
||||
/>
|
||||
<div className="mt-1.5 flex flex-wrap items-center justify-between gap-2 md:hidden">
|
||||
<PlanWaitBadge p={p} hint />
|
||||
<PlanActions p={p} size="xs" />
|
||||
</div>
|
||||
</NameCell>
|
||||
);
|
||||
}
|
||||
|
||||
export function activeColumns(): DataColumn<DownloadBoardRow>[] {
|
||||
return [
|
||||
{
|
||||
key: "name",
|
||||
header: "下载任务",
|
||||
cell: (t) => (
|
||||
<NameCell t={t}>
|
||||
<WorkLine t={t} />
|
||||
{/* 窄屏:进度与速度并进名称列 */}
|
||||
<div className="mt-1.5 space-y-1 md:hidden">
|
||||
<ActiveStatus t={t} />
|
||||
<ProgressLine t={{ ...t, progress: progressOf(t) }} />
|
||||
{t.live?.dlSpeed || t.live?.upSpeed ? (
|
||||
<p className="flex flex-wrap items-center gap-x-3 text-meta text-muted-foreground">
|
||||
<SpeedPair dl={t.live.dlSpeed} up={t.live.upSpeed} />
|
||||
{t.live.etaSec !== null ? <span>剩余 {formatEta(t.live.etaSec)}</span> : null}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</NameCell>
|
||||
),
|
||||
cell: (t) =>
|
||||
t.kind === "plan" ? (
|
||||
<PlanNameCell p={t} />
|
||||
) : (
|
||||
<NameCell t={t}>
|
||||
<WorkLine t={t} />
|
||||
{/* 窄屏:进度与速度并进名称列 */}
|
||||
<div className="mt-1.5 space-y-1 md:hidden">
|
||||
<ActiveStatus t={t} />
|
||||
<ProgressLine t={{ ...t, progress: progressOf(t) }} />
|
||||
{t.live?.dlSpeed || t.live?.upSpeed ? (
|
||||
<p className="flex flex-wrap items-center gap-x-3 text-meta text-muted-foreground">
|
||||
<SpeedPair dl={t.live.dlSpeed} up={t.live.upSpeed} />
|
||||
{t.live.etaSec !== null ? <span>剩余 {formatEta(t.live.etaSec)}</span> : null}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</NameCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "progress",
|
||||
header: "进度",
|
||||
hideBelow: "md",
|
||||
className: "w-40",
|
||||
cell: (t) => <ProgressLine t={{ ...t, progress: progressOf(t) }} />,
|
||||
cell: (t) => (t.kind === "plan" ? dash : <ProgressLine t={{ ...t, progress: progressOf(t) }} />),
|
||||
},
|
||||
{
|
||||
key: "speed",
|
||||
header: "速度",
|
||||
hideBelow: "md",
|
||||
cell: (t) =>
|
||||
!t.live?.dlSpeed && !t.live?.upSpeed ? (
|
||||
<span className="text-meta text-muted-foreground">—</span>
|
||||
t.kind === "plan" || (!t.live?.dlSpeed && !t.live?.upSpeed) ? (
|
||||
dash
|
||||
) : (
|
||||
<div className="space-y-0.5 text-meta tabular-nums whitespace-nowrap">
|
||||
<p className="flex items-center gap-0.5 text-foreground">
|
||||
@@ -181,15 +210,35 @@ export function activeColumns(): DataColumn<DownloadBoardItem>[] {
|
||||
key: "eta",
|
||||
header: "剩余时间",
|
||||
hideBelow: "lg",
|
||||
cell: (t) => (
|
||||
<div className="text-meta whitespace-nowrap">
|
||||
<p className="text-foreground tabular-nums">{formatEta(t.live?.etaSec)}</p>
|
||||
{peersText(t.live) ? <p className="text-muted-foreground tabular-nums">{peersText(t.live)}</p> : null}
|
||||
</div>
|
||||
),
|
||||
cell: (t) =>
|
||||
t.kind === "plan" ? (
|
||||
dash
|
||||
) : (
|
||||
<div className="text-meta whitespace-nowrap">
|
||||
<p className="text-foreground tabular-nums">{formatEta(t.live?.etaSec)}</p>
|
||||
{peersText(t.live) ? <p className="text-muted-foreground tabular-nums">{peersText(t.live)}</p> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "状态",
|
||||
hideBelow: "md",
|
||||
cell: (t) => (t.kind === "plan" ? <PlanWaitBadge p={t} hint /> : <ActiveStatus t={t} />),
|
||||
},
|
||||
{
|
||||
key: "ops",
|
||||
header: "",
|
||||
align: "right",
|
||||
cell: (t) =>
|
||||
t.kind === "plan" ? (
|
||||
<span className="hidden md:inline-flex">
|
||||
<PlanActions p={t} size="xs" />
|
||||
</span>
|
||||
) : (
|
||||
<PauseResume t={t} size="xs" />
|
||||
),
|
||||
},
|
||||
{ key: "status", header: "状态", hideBelow: "md", cell: (t) => <ActiveStatus t={t} /> },
|
||||
{ key: "ops", header: "", align: "right", cell: (t) => <PauseResume t={t} size="xs" /> },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -284,7 +333,7 @@ export const BOARD_EMPTY: Record<DownloadBoardState, { title: string; descriptio
|
||||
active: {
|
||||
icon: Download,
|
||||
title: "没有进行中的下载",
|
||||
description: "自动收录或在站点页下载后出现在这里;下载完成的在「已完成」。",
|
||||
description: "自动收录或在站点页下载后出现在这里,等批准、等磁盘空间的也在这里;下载完成的在「已完成」。",
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
|
||||
@@ -23,13 +23,11 @@ import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { planReasonText } from "~/lib/labels.ts";
|
||||
import { humanizeReason, reasonCode, reasonLine } from "~/lib/reasons.ts";
|
||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
import { liveItems } from "./board-format.ts";
|
||||
import { BusinessTimeline } from "./business-timeline.tsx";
|
||||
|
||||
const PLAN_PURPOSE_LABEL: Record<string, string> = { fill: "补缺", upgrade: "升级", replace_source: "换源" };
|
||||
import { WhySection } from "./plan-row.tsx";
|
||||
|
||||
/** 进度:细线 + 文字;总量未知时不伪造百分比 */
|
||||
export function ProgressLine({ t }: { t: DownloadTask }) {
|
||||
@@ -91,7 +89,7 @@ export function PauseResume({ t, size = "sm" }: { t: DownloadTask; size?: "sm" |
|
||||
);
|
||||
}
|
||||
|
||||
/** 下载任务详情:状态、阈值说明、业务时间线、文件、关联计划 */
|
||||
/** 下载任务详情:状态、阈值说明、业务时间线、为什么下这个(DL-3)、文件 */
|
||||
export function DownloadDetail({ task, live }: { task: DownloadTask; live?: DownloadLive | null }) {
|
||||
const plan = usePlan(task.acquisitionPlanId);
|
||||
const names = useDownloaderNames();
|
||||
@@ -157,6 +155,20 @@ export function DownloadDetail({ task, live }: { task: DownloadTask; live?: Down
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{task.acquisitionPlanId ? (
|
||||
<WhySection
|
||||
planId={task.acquisitionPlanId}
|
||||
extraTech={[
|
||||
{
|
||||
label: "计划状态",
|
||||
value: plan.data
|
||||
? `${PLAN_STATUS_LABEL[plan.data.status]}${plan.data.statusReason ? ` · ${plan.data.statusReason}` : ""}`
|
||||
: null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DetailSection title="进度">
|
||||
<ProgressLine t={live ? { ...task, progress: live.progress } : task} />
|
||||
<KvList
|
||||
@@ -194,28 +206,6 @@ export function DownloadDetail({ task, live }: { task: DownloadTask; live?: Down
|
||||
<BusinessTimeline events={trace.events} loading={trace.loading} />
|
||||
</DetailSection>
|
||||
|
||||
{plan.data ? (
|
||||
<DetailSection title="下载计划">
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "状态", value: PLAN_STATUS_LABEL[plan.data.status] },
|
||||
{ label: "用途", value: PLAN_PURPOSE_LABEL[plan.data.purpose] ?? plan.data.purpose },
|
||||
{ label: "预留空间", value: formatBytes(plan.data.reservedBytes, 1) },
|
||||
{ label: "说明", value: planReasonText(plan.data.statusReason), wide: true },
|
||||
]}
|
||||
/>
|
||||
<TechInfo
|
||||
className="mt-2"
|
||||
items={[
|
||||
{ label: "计划 ID", value: plan.data.id, wide: true },
|
||||
{ label: "说明原码", value: plan.data.statusReason },
|
||||
]}
|
||||
>
|
||||
<JsonView label="评估依据" value={plan.data.evaluation} />
|
||||
</TechInfo>
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
{task.fileList?.length ? (
|
||||
<DetailSection title={`文件(${task.fileList.length})`}>
|
||||
<ul className="max-h-60 space-y-1 overflow-auto text-meta">
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { NavLink } from "react-router";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
// 下载面板的两个页签(IA-3:下载计划并入下载面板,路由保持 /downloads 与 /downloads/plans)
|
||||
|
||||
const TABS = [
|
||||
{ to: "/console/downloads", label: "下载任务", end: true },
|
||||
{ to: "/console/downloads/plans", label: "下载计划", end: true },
|
||||
];
|
||||
|
||||
export function DownloadsTabs() {
|
||||
return (
|
||||
<nav aria-label="下载面板" className="scroll-bleed mb-4 flex max-w-full items-center gap-1 overflow-x-auto pb-1">
|
||||
{TABS.map((t) => (
|
||||
<NavLink
|
||||
key={t.to}
|
||||
to={t.to}
|
||||
end={t.end}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"inline-flex h-8 shrink-0 items-center rounded-full border border-transparent px-3 text-sm font-medium whitespace-nowrap transition-[background-color,color] duration-micro",
|
||||
isActive
|
||||
? "border-(--mat-pill-selected-border) bg-(--mat-pill-selected-bg) text-foreground"
|
||||
: "text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { DownloadWhy } from "@umt/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { legacyRedirect } from "../../routes/console/redirects.ts";
|
||||
import { droppedReason, PLAN_WAIT, planWaitHint, whyPoints, whyPurpose, whySource, whyText } from "./plan-format.ts";
|
||||
|
||||
// DL-3:下载计划并入下载面板——在等什么、「为什么下这个」、没提交就结束的原因、旧深链。
|
||||
|
||||
const why = (over: Partial<DownloadWhy> = {}): DownloadWhy => ({
|
||||
planId: "00000000-0000-4000-8000-000000000001",
|
||||
source: { kind: "subscription", id: "00000000-0000-4000-8000-000000000002", name: "人生切割术 第 2 季" },
|
||||
purpose: "fill",
|
||||
slotName: "4K",
|
||||
replaces: null,
|
||||
reasons: ["hard_conditions_met", "pri:90", "rule_group:abc@3"],
|
||||
promotion: "free",
|
||||
seeders: 41,
|
||||
siteName: "测试站",
|
||||
evaluation: {},
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("为什么下这个", () => {
|
||||
it("订阅补缺:来源 · 目的,要点是免费 · 做种 · 符合收录版本", () => {
|
||||
expect(whyText(why())).toEqual({
|
||||
head: "订阅「人生切割术 第 2 季」 · 补缺",
|
||||
points: "免费 · 做种 41 · 符合收录版本「4K」",
|
||||
});
|
||||
});
|
||||
|
||||
it("升级写替换的版本;内部码(pri、rule_group)不给人看", () => {
|
||||
const w = why({
|
||||
purpose: "upgrade",
|
||||
replaces: { assetId: "00000000-0000-4000-8000-000000000003", label: "1080p 蓝光压制" },
|
||||
reasons: ["source_tier_improved", "pri:95", "tier:原盘优先"],
|
||||
promotion: null,
|
||||
seeders: null,
|
||||
});
|
||||
expect(whyPurpose(w)).toBe("升级(替换 1080p 蓝光压制)");
|
||||
expect(whyPoints(w)).toEqual(["来源更好", "命中「原盘优先」"]);
|
||||
});
|
||||
|
||||
it("来源:智能体会话 / 手动 / 自动收录", () => {
|
||||
expect(whySource({ kind: "agent", runId: "r", name: "补齐 4K 版本" })).toBe("智能体会话「补齐 4K 版本」");
|
||||
expect(whySource({ kind: "agent", runId: "r", name: "" })).toBe("智能体会话");
|
||||
expect(whySource({ kind: "manual" })).toBe("手动下载");
|
||||
expect(whySource({ kind: "auto" })).toBe("自动收录");
|
||||
});
|
||||
|
||||
it("换源没有被替换的版本时只写换源;做种 0 照写", () => {
|
||||
const w = why({ purpose: "replace_source", reasons: [], promotion: "none", seeders: 0 });
|
||||
expect(whyText(w)).toEqual({ head: "订阅「人生切割术 第 2 季」 · 换源", points: "做种 0" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("在等什么", () => {
|
||||
it("状态人话", () => {
|
||||
expect(PLAN_WAIT.approval.label).toBe("等你批准");
|
||||
expect(PLAN_WAIT.space.label).toBe("等磁盘空间");
|
||||
expect(PLAN_WAIT.automation_off.label).toBe("等开启自动化");
|
||||
});
|
||||
|
||||
it("补充一句走原因码表,原码不给人看", () => {
|
||||
expect(planWaitHint({ wait: "space", waitReason: "cache:projected_over_threshold" })).toBe(
|
||||
"下完会超过下载盘暂停线",
|
||||
);
|
||||
expect(planWaitHint({ wait: "automation_off", waitReason: "automation_disabled" })).toBe(
|
||||
"自动化已关闭。在设置 → 自动化里打开",
|
||||
);
|
||||
expect(planWaitHint({ wait: "approval", waitReason: null })).toBeNull();
|
||||
expect(planWaitHint({ wait: "queued", waitReason: "something_unknown" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("没提交就结束的下载", () => {
|
||||
it("原因码 → 一句话", () => {
|
||||
expect(droppedReason("cancelled_by_user")).toBe("你取消了这个下载");
|
||||
expect(droppedReason("approval_expired")).toBe("等批准超时,下载没有提交");
|
||||
expect(droppedReason("rejected: torrent is invalid")).toBe("下载器拒绝了这个种子");
|
||||
expect(droppedReason("source_failed: auth_expired: 401")).toBe("从站点取种子失败。检查站点登录后重新下载");
|
||||
expect(droppedReason(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("旧的下载计划地址", () => {
|
||||
it("页签与带 id 的深链都跳到下载面板", () => {
|
||||
expect(legacyRedirect("/console/downloads/plans", "")).toBe("/console/downloads");
|
||||
expect(legacyRedirect("/console/downloads/plans", "?plan=abc")).toBe("/console/downloads?plan=abc");
|
||||
expect(legacyRedirect("/console/downloads/plans/abc", "")).toBe("/console/downloads?plan=abc");
|
||||
expect(legacyRedirect("/console/tasks", "?tab=plans")).toBe("/console/downloads");
|
||||
expect(legacyRedirect("/console/downloads", "?plan=abc")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { DownloadBoardPlanItem, DownloadWhy, PlanPurpose, PlanWait } from "@umt/contracts";
|
||||
import { promotionLabel } from "~/components/workbench/release-fields.ts";
|
||||
import { reasonLine } from "~/lib/reasons.ts";
|
||||
|
||||
// DL-3:下载计划并入下载面板后的展示换算——还没提交的下载在等什么、「为什么下这个」一行。
|
||||
// 界面上不出现"计划",统一说"下载"。
|
||||
|
||||
export const PURPOSE_LABEL: Record<PlanPurpose, string> = { fill: "补缺", upgrade: "升级", replace_source: "换源" };
|
||||
|
||||
export const PLAN_WAIT: Record<PlanWait, { label: string; tone: "warning" | "neutral" }> = {
|
||||
approval: { label: "等你批准", tone: "warning" },
|
||||
space: { label: "等磁盘空间", tone: "warning" },
|
||||
automation_off: { label: "等开启自动化", tone: "warning" },
|
||||
queued: { label: "准备提交", tone: "neutral" },
|
||||
};
|
||||
|
||||
/** 在等什么的补充一句(下载盘哪条线、为什么还没提交);等批准与正常排队不补充 */
|
||||
export function planWaitHint(p: Pick<DownloadBoardPlanItem, "wait" | "waitReason">): string | null {
|
||||
if (p.wait === "approval") return null;
|
||||
if (!p.waitReason) return null;
|
||||
return reasonLine(p.waitReason, "") || null;
|
||||
}
|
||||
|
||||
/** 来源:订阅「人生切割术 第 2 季」/ 智能体会话「补齐 4K 版本」/ 手动下载 / 自动收录 */
|
||||
export function whySource(s: DownloadWhy["source"]): string {
|
||||
switch (s.kind) {
|
||||
case "subscription":
|
||||
return s.name ? `订阅「${s.name}」` : "订阅";
|
||||
case "agent":
|
||||
return s.name ? `智能体会话「${s.name}」` : "智能体会话";
|
||||
case "manual":
|
||||
return "手动下载";
|
||||
case "auto":
|
||||
return "自动收录";
|
||||
}
|
||||
}
|
||||
|
||||
/** 目的:补缺 / 升级(替换 1080p 蓝光压制)/ 换源 */
|
||||
export function whyPurpose(w: Pick<DownloadWhy, "purpose" | "replaces">): string {
|
||||
const base = PURPOSE_LABEL[w.purpose];
|
||||
return w.purpose !== "fill" && w.replaces ? `${base}(替换 ${w.replaces.label})` : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中理由要点:促销 · 做种数 · 评估结论(符合收录版本 / 来源更好 / 分辨率更高 / 优先级更高 / 命中某档)。
|
||||
* 内部码(pri:、rule_group:、按策略放行的未知字段)不给人看,原始评估在技术信息里。
|
||||
*/
|
||||
export function whyPoints(w: Pick<DownloadWhy, "reasons" | "promotion" | "seeders" | "slotName">): string[] {
|
||||
const out: string[] = [];
|
||||
const promo = w.promotion ? promotionLabel({ promotion: { kind: w.promotion }, promotionExpiresAt: null }) : null;
|
||||
if (promo) out.push(promo.label);
|
||||
if (w.seeders !== null) out.push(`做种 ${w.seeders}`);
|
||||
for (const r of w.reasons) {
|
||||
if (r === "hard_conditions_met") out.push(w.slotName ? `符合收录版本「${w.slotName}」` : "符合收录版本");
|
||||
else if (r === "source_tier_improved") out.push("来源更好");
|
||||
else if (r === "resolution_improved") out.push("分辨率更高");
|
||||
else if (r === "priority_improved") out.push("优先级更高");
|
||||
else if (r.startsWith("tier:") && r.length > 5) out.push(`命中「${r.slice(5)}」`);
|
||||
}
|
||||
return [...new Set(out)];
|
||||
}
|
||||
|
||||
/** 「为什么下这个」整行:来源 · 目的,下一行是要点 */
|
||||
export function whyText(w: DownloadWhy): { head: string; points: string } {
|
||||
return { head: `${whySource(w.source)} · ${whyPurpose(w)}`, points: whyPoints(w).join(" · ") };
|
||||
}
|
||||
|
||||
/** 没提交就结束的下载:状态 + 原因一句 */
|
||||
export const DROPPED_STATUS: Record<
|
||||
"failed" | "cancelled" | "expired",
|
||||
{ label: string; tone: "destructive" | "neutral" | "warning" }
|
||||
> = {
|
||||
failed: { label: "没提交成功", tone: "destructive" },
|
||||
cancelled: { label: "已取消", tone: "neutral" },
|
||||
expired: { label: "已过期", tone: "warning" },
|
||||
};
|
||||
|
||||
export function droppedReason(statusReason: string | null | undefined): string | null {
|
||||
return statusReason ? reasonLine(statusReason, "") || null : null;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { DownloadBoardPlanItem } from "@umt/contracts";
|
||||
import { Check, Inbox, X } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useDecideApproval, useIsAdminSession } from "~/api/agent.ts";
|
||||
import { useCancelPlan, usePlanWhy } from "~/api/downloads.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { DetailSection } from "~/components/console/detail-sheet.tsx";
|
||||
import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { reasonCode } from "~/lib/reasons.ts";
|
||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
import { PLAN_WAIT, PURPOSE_LABEL, planWaitHint, whyText } from "./plan-format.ts";
|
||||
|
||||
// DL-3:还没提交到下载器的下载(等批准 / 等磁盘空间 / 等开启自动化 / 准备提交)在下载面板里的状态、操作与详情;
|
||||
// 已提交的下载在详情里显示「为什么下这个」。
|
||||
|
||||
export function PlanWaitBadge({ p, hint = false }: { p: DownloadBoardPlanItem; hint?: boolean }) {
|
||||
const w = PLAN_WAIT[p.wait];
|
||||
const h = hint ? planWaitHint(p) : null;
|
||||
return (
|
||||
<span className="flex max-w-56 flex-col items-start gap-0.5">
|
||||
<StatusBadge tone={w.tone}>{w.label}</StatusBadge>
|
||||
{h ? <span className="line-clamp-2 text-micro text-muted-foreground">{h}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 批准 / 取消:等批准的走审批(拒绝后服务端取消这次下载);其余直接取消。都要确认 */
|
||||
export function PlanActions({ p, size = "sm" }: { p: DownloadBoardPlanItem; size?: "sm" | "xs" }) {
|
||||
const decide = useDecideApproval();
|
||||
const cancel = useCancelPlan();
|
||||
const admin = useIsAdminSession();
|
||||
const approval = p.approval;
|
||||
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();
|
||||
const decideApproval = (status: "approved" | "rejected") =>
|
||||
decide
|
||||
.mutateAsync({
|
||||
id: approval?.id ?? "",
|
||||
status,
|
||||
payloadHash: approval?.payloadHash ?? "",
|
||||
...(status === "rejected" ? { reason: "在下载面板取消" } : {}),
|
||||
})
|
||||
.then(
|
||||
() => toast.success(status === "approved" ? "已批准,开始提交下载" : "已取消这次下载"),
|
||||
(err) => {
|
||||
toast.error(errorMessage(err));
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: 只拦截行点击
|
||||
<span className="inline-flex justify-end gap-1.5 whitespace-nowrap" onClick={stop} onKeyDown={stop}>
|
||||
{approval ? (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button size={size} disabled={!admin} title={admin ? undefined : "只能在控制台管理员登录时批准"}>
|
||||
<Check />
|
||||
批准
|
||||
</Button>
|
||||
}
|
||||
title={`批准下载「${p.name}」?`}
|
||||
description={`${p.sizeBytes !== null ? `${formatBytes(p.sizeBytes, 1)},` : ""}批准后立即提交到下载器。`}
|
||||
confirmLabel="批准"
|
||||
onConfirm={() => decideApproval("approved")}
|
||||
/>
|
||||
) : null}
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="outline"
|
||||
size={size}
|
||||
disabled={!!approval && !admin}
|
||||
title={approval && !admin ? "只能在控制台管理员登录时处理" : undefined}
|
||||
>
|
||||
<X />
|
||||
取消
|
||||
</Button>
|
||||
}
|
||||
title={`取消下载「${p.name}」?`}
|
||||
description={
|
||||
approval ? "不批准这次下载,智能体会收到结果。" : "释放为它预留的空间和额度,收录版本之后会重新找资源。"
|
||||
}
|
||||
confirmLabel="取消下载"
|
||||
cancelLabel="返回"
|
||||
destructive
|
||||
onConfirm={() =>
|
||||
approval
|
||||
? decideApproval("rejected")
|
||||
: cancel.mutateAsync(p.id).then(
|
||||
() => toast.success("已取消这次下载"),
|
||||
(err) => {
|
||||
toast.error(errorMessage(err));
|
||||
throw err;
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 「为什么下这个」:来源 · 目的,下一行选中理由要点;原始评估进技术信息 */
|
||||
export function WhySection({
|
||||
planId,
|
||||
extraTech,
|
||||
}: {
|
||||
planId: string;
|
||||
extraTech?: { label: string; value: string | null }[];
|
||||
}) {
|
||||
const why = usePlanWhy(planId);
|
||||
if (why.isPending || !why.data) return null;
|
||||
const t = whyText(why.data);
|
||||
return (
|
||||
<DetailSection title="为什么下这个">
|
||||
<p className="text-sm text-foreground">{t.head}</p>
|
||||
{t.points ? <p className="mt-0.5 text-caption text-muted-foreground">{t.points}</p> : null}
|
||||
<TechInfo
|
||||
className="mt-2"
|
||||
items={[
|
||||
{ label: "计划 ID", value: planId, wide: true },
|
||||
why.data.source.kind === "agent" ? { label: "会话 ID", value: why.data.source.runId, wide: true } : null,
|
||||
why.data.replaces ? { label: "被替换的文件 ID", value: why.data.replaces.assetId, wide: true } : null,
|
||||
...(extraTech ?? []).map((x) => ({ ...x, wide: true })),
|
||||
]}
|
||||
>
|
||||
<JsonView label="评估依据" value={why.data.evaluation} defaultOpen={false} />
|
||||
</TechInfo>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
/** 还没提交的下载的详情(抽屉内容) */
|
||||
export function PlanDetail({ p }: { p: DownloadBoardPlanItem }) {
|
||||
const hint = planWaitHint(p);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge tone={PLAN_WAIT[p.wait].tone}>{PLAN_WAIT[p.wait].label}</StatusBadge>
|
||||
<div className="ml-auto">
|
||||
<PlanActions p={p} />
|
||||
</div>
|
||||
</div>
|
||||
{p.wait === "approval" ? (
|
||||
<p className="flex flex-wrap items-center gap-1 text-caption text-muted-foreground">
|
||||
这次下载要你批准后才提交,同一件事也在待处理里。
|
||||
{p.approval?.decisionId ? (
|
||||
<Link
|
||||
to={`${consolePath("inbox")}?kind=approval_required`}
|
||||
className="inline-flex items-center gap-0.5 underline underline-offset-4"
|
||||
>
|
||||
<Inbox className="size-3.5" />
|
||||
去待处理
|
||||
</Link>
|
||||
) : null}
|
||||
</p>
|
||||
) : hint ? (
|
||||
<p className="text-caption text-muted-foreground">{hint}。</p>
|
||||
) : null}
|
||||
<KvList
|
||||
items={[
|
||||
{
|
||||
label: "作品",
|
||||
value: p.media ? (
|
||||
<Link to={consolePath("library", p.media.id)} className="hover:underline">
|
||||
{p.media.title}
|
||||
{p.media.year ? ` (${p.media.year})` : ""}
|
||||
</Link>
|
||||
) : null,
|
||||
},
|
||||
{ label: "大小", value: p.sizeBytes === null ? "未知" : formatBytes(p.sizeBytes, 2) },
|
||||
{ label: "用途", value: PURPOSE_LABEL[p.purpose] },
|
||||
{ label: "加入时间", value: formatDateTime(p.createdAt) },
|
||||
]}
|
||||
/>
|
||||
<WhySection
|
||||
planId={p.id}
|
||||
extraTech={[{ label: "等待原因码", value: reasonCode(p.waitReason) ?? p.waitReason }]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import { formatBytes } from "~/lib/utils.ts";
|
||||
// 与站点工作台同一套下载说明;请求走 POST /search/releases/{siteId}/{siteReleaseId}/plan(同 /acquisition-plans 流程)
|
||||
|
||||
const STEPS = [
|
||||
"生成下载计划,预留收录版本和预算;每个收录版本同时只有一个在途计划",
|
||||
"预留收录版本和预算;每个收录版本同时只有一个在途的下载",
|
||||
"已有相同资源或相同 hash 的任务时直接接管,不重复提交",
|
||||
"缓存卷超过硬阈值或读不到磁盘状态时无法提交",
|
||||
"提交前复核免费、做种等易变字段",
|
||||
@@ -72,10 +72,10 @@ export function SearchDownloadDialog({
|
||||
if (r.submitted && r.plan) {
|
||||
setRejected(null);
|
||||
setDone(r.plan.id);
|
||||
toast.success(`已提交${slotName(r.slotId) ? `(${slotName(r.slotId)})` : ""},进度见「下载队列」`);
|
||||
toast.success(`已提交${slotName(r.slotId) ? `(${slotName(r.slotId)})` : ""},进度见「下载面板」`);
|
||||
} else {
|
||||
setRejected(r.rejected);
|
||||
toast.warning("没有生成下载计划");
|
||||
toast.warning("没有下载");
|
||||
}
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
@@ -131,7 +131,7 @@ export function SearchDownloadDialog({
|
||||
) : null}
|
||||
{rejected ? (
|
||||
<div className="rounded-xl border border-warning/40 bg-warning/10 px-3 py-2.5 text-caption text-warning">
|
||||
<p className="mb-1">没有生成下载计划:</p>
|
||||
<p className="mb-1">没有下载:</p>
|
||||
<ul className="list-disc space-y-0.5 pl-5">
|
||||
{rejected.map((r, i) => (
|
||||
<li key={`${r.slotId}-${r.code}-${i}`} className="break-words">
|
||||
@@ -144,8 +144,8 @@ export function SearchDownloadDialog({
|
||||
) : null}
|
||||
{done ? (
|
||||
<p className="text-caption text-success">
|
||||
计划已生成。
|
||||
<Link to="/console/downloads/plans" className="underline underline-offset-4">
|
||||
已加入下载。
|
||||
<Link to={`/console/downloads?plan=${done}`} className="underline underline-offset-4">
|
||||
查看
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -43,7 +43,7 @@ const CACHE_CARD: CardSpec = {
|
||||
const BUDGET_CARD: CardSpec = {
|
||||
anchor: "budget",
|
||||
title: "每日预算",
|
||||
description: "任一项到上限后不再建新的下载计划;前两项按自然日计。",
|
||||
description: "任一项到上限后不再开始新的下载;前两项按自然日计。",
|
||||
fields: [
|
||||
{ name: "budget.maxNewTitlesPerDay", label: "每日新作品数", kind: "number" },
|
||||
{ name: "budget.maxDownloadGiBPerDay", label: "每日下载量(GiB)", kind: "number" },
|
||||
|
||||
@@ -195,7 +195,7 @@ export function CoverageMatrix({
|
||||
);
|
||||
}
|
||||
|
||||
/** 点格打开:资产 / 质量 / pri / 计划 / 原因 / 可升级 / 延后 → 手动补全、跳下载计划 */
|
||||
/** 点格打开:资产 / 质量 / pri / 计划 / 原因 / 可升级 / 延后 → 手动补全、跳下载面板 */
|
||||
export function CellSheet({
|
||||
sel,
|
||||
subscriptionId,
|
||||
@@ -244,9 +244,9 @@ export function CellSheet({
|
||||
<>
|
||||
{c.planId ? (
|
||||
<Button asChild variant="outline">
|
||||
<Link to={`${consolePath("downloads", "plans")}?plan=${c.planId}`}>
|
||||
<Link to={`${consolePath("downloads")}?plan=${c.planId}`}>
|
||||
<Download />
|
||||
下载计划
|
||||
查看下载
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -300,7 +300,7 @@ export function CellSheet({
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
label: "下载计划",
|
||||
label: "下载",
|
||||
value: c.planId
|
||||
? `${PLAN_STAGE[c.planStage ?? ""] ?? c.planStage ?? "在途"} · ${c.planId.slice(0, 8)}`
|
||||
: null,
|
||||
|
||||
@@ -106,10 +106,10 @@ export function businessLink(b: EngineRun["business"], input?: unknown): { to: s
|
||||
case "subscription":
|
||||
return { to: consolePath("subscriptions"), label: "订阅" };
|
||||
case "download": {
|
||||
// 计划还没提交时 refId 是下载计划
|
||||
// 还没提交到下载器时 refId 是计划 id(下载面板按 ?plan= 打开对应行)
|
||||
const i = (input ?? {}) as { downloadTaskId?: unknown; acquisitionPlanId?: unknown };
|
||||
if (!i.downloadTaskId && i.acquisitionPlanId === b.refId)
|
||||
return { to: consolePath("downloads", "plans"), label: "下载计划" };
|
||||
return { to: `${consolePath("downloads")}?plan=${id}`, label: "下载" };
|
||||
return { to: `/console/downloads/${id}`, label: "下载任务" };
|
||||
}
|
||||
case "organization":
|
||||
|
||||
@@ -19,7 +19,7 @@ import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatBytes } from "~/lib/utils.ts";
|
||||
|
||||
const STEPS = [
|
||||
"生成下载计划,预留收录版本和预算;每个收录版本同时只有一个在途计划",
|
||||
"预留收录版本和预算;每个收录版本同时只有一个在途的下载",
|
||||
"已有相同资源或相同 hash 的任务时直接接管,不重复提交",
|
||||
"缓存卷超过硬阈值或读不到磁盘状态时无法提交",
|
||||
"提交前复核免费、做种等易变字段",
|
||||
@@ -56,10 +56,10 @@ export function DownloadDialog({
|
||||
if (r.submitted && r.plan) {
|
||||
setRejected(null);
|
||||
setDone(r.plan.id);
|
||||
toast.success(`已提交${slotName(r.slotId) ? `(${slotName(r.slotId)})` : ""},进度见「下载队列」`);
|
||||
toast.success(`已提交${slotName(r.slotId) ? `(${slotName(r.slotId)})` : ""},进度见「下载面板」`);
|
||||
} else {
|
||||
setRejected(r.rejected);
|
||||
toast.warning("没有生成下载计划");
|
||||
toast.warning("没有下载");
|
||||
}
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
@@ -109,7 +109,7 @@ export function DownloadDialog({
|
||||
)}
|
||||
{rejected ? (
|
||||
<div className="rounded-xl border border-warning/40 bg-warning/10 px-3 py-2.5 text-caption text-warning">
|
||||
<p className="mb-1">没有生成下载计划:</p>
|
||||
<p className="mb-1">没有下载:</p>
|
||||
<ul className="list-disc space-y-0.5 pl-5">
|
||||
{rejected.map((r, i) => (
|
||||
<li key={`${r.slotId}-${r.code}-${i}`} className="break-words">
|
||||
@@ -122,8 +122,8 @@ export function DownloadDialog({
|
||||
) : null}
|
||||
{done ? (
|
||||
<p className="text-caption text-success">
|
||||
计划已生成。
|
||||
<Link to="/console/downloads/plans" className="underline underline-offset-4">
|
||||
已加入下载。
|
||||
<Link to={`/console/downloads?plan=${done}`} className="underline underline-offset-4">
|
||||
查看
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -150,7 +150,7 @@ export const ACTOR_TYPES = [
|
||||
];
|
||||
export const actorTypeLabel = (t: string) => ACTOR_TYPES.find((a) => a.value === t)?.label ?? t;
|
||||
|
||||
/** 下载计划 statusReason(`added`、`organize:succeeded` 等)→ 中文;不认识的原样显示 */
|
||||
/** 计划 statusReason(`added`、`organize:succeeded` 等)→ 中文;不认识的原样显示 */
|
||||
const PLAN_REASON: Record<string, string> = {
|
||||
added: "已添加到下载器",
|
||||
"organize:succeeded": "整理成功",
|
||||
|
||||
@@ -28,7 +28,7 @@ export function reasonCode(r: { code?: string | null; message?: string | null }
|
||||
return [c, m && looksLikeCode(m) && m !== c ? m : null].filter(Boolean).join(" · ") || null;
|
||||
}
|
||||
|
||||
/** 原因串里的内部码替换成人话(如下载计划 statusReason、任务 waitReason) */
|
||||
/** 原因串里的内部码替换成人话(如计划 statusReason、任务 waitReason) */
|
||||
export function humanizeReason(v: string | null | undefined): string | null {
|
||||
if (!v) return null;
|
||||
return looksLikeCode(v) ? reasonLine(v, v) : v;
|
||||
|
||||
@@ -27,6 +27,7 @@ export default [
|
||||
route("rules", "routes/console/rules.tsx"),
|
||||
route("downloads", "routes/console/downloads.tsx"),
|
||||
route("downloads/plans", "routes/console/downloads.plans.tsx"),
|
||||
route("downloads/plans/:id", "routes/console/downloads.plans.tsx", { id: "downloads-plan-legacy" }),
|
||||
route("downloads/:id", "routes/console/downloads.$id.tsx"),
|
||||
route("tasks", "routes/console/tasks.tsx"),
|
||||
route("tasks/:id", "routes/console/tasks.$id.tsx"),
|
||||
|
||||
@@ -38,7 +38,8 @@ const ACTION_LABEL: Record<string, string> = {
|
||||
"pi_upstream.oauth_logout": "退出订阅登录",
|
||||
"schedule.run_now": "立即执行定时任务",
|
||||
"schedule.skip": "跳过定时任务",
|
||||
"acquisition_plan.manual_submit": "手动提交下载计划",
|
||||
"acquisition_plan.manual_submit": "手动提交下载",
|
||||
"acquisition_plan.cancel": "取消下载",
|
||||
"engine.run_retry": "重跑任务",
|
||||
"capability.invoke": "智能体操作",
|
||||
"capability.denied": "智能体操作被拒绝",
|
||||
@@ -75,7 +76,7 @@ const TARGET_LABEL: Record<string, string> = {
|
||||
site_account: "站点账号",
|
||||
feed_source: "RSS",
|
||||
engine_run: "任务",
|
||||
acquisition_plan: "下载计划",
|
||||
acquisition_plan: "下载",
|
||||
download_task: "下载任务",
|
||||
media_entity: "作品",
|
||||
source_release: "资源",
|
||||
|
||||
@@ -1,157 +1,8 @@
|
||||
import type { AcquisitionPlan, PlanStatus } from "@umt/contracts";
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { PLAN_STATUS_LABEL, useDownloadTasks, usePlans } from "~/api/downloads.ts";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { useQueryState } from "~/components/console/use-query-state.ts";
|
||||
import { DownloadsTabs } from "~/components/downloads/downloads-tabs.tsx";
|
||||
import { PRIORITY } from "~/components/library/labels.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { planReasonText } from "~/lib/labels.ts";
|
||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
import { legacyRedirectLoader } from "./redirects.ts";
|
||||
|
||||
export const meta = () => [{ title: "下载计划 · 下载面板 · UHD 媒体自动化控制台" }];
|
||||
// DL-3:下载计划并入下载面板;旧地址 /console/downloads/plans(?plan=)与 /plans/:id 跳到下载面板并打开对应行
|
||||
export const clientLoader = legacyRedirectLoader;
|
||||
|
||||
const PURPOSE = { fill: "补缺", upgrade: "升级", replace_source: "换源" } as const;
|
||||
|
||||
const planTone = (s: PlanStatus) =>
|
||||
s === "failed" ? "destructive" : s === "completed" ? "success" : s === "expired" ? "warning" : "neutral";
|
||||
|
||||
export default function DownloadPlansPage() {
|
||||
const f = useQueryState(["status"] as const);
|
||||
const [openPlan, setOpenPlan] = useState<AcquisitionPlan | null>(null);
|
||||
const plans = usePlans((f.values.status || undefined) as PlanStatus | undefined);
|
||||
// 计划 → 下载任务:同一列表按 acquisitionPlanId 对上
|
||||
const downloads = useDownloadTasks({});
|
||||
const rows = plans.data?.pages.flatMap((p) => p.items);
|
||||
const linkedDownload = openPlan
|
||||
? downloads.data?.pages.flatMap((p) => p.items).find((t) => t.acquisitionPlanId === openPlan.id)
|
||||
: undefined;
|
||||
|
||||
const columns: DataColumn<AcquisitionPlan>[] = [
|
||||
{
|
||||
key: "purpose",
|
||||
header: "计划",
|
||||
cell: (p) => (
|
||||
<p className="text-foreground">
|
||||
{PURPOSE[p.purpose]} · 优先级 {PRIORITY[p.priority as keyof typeof PRIORITY] ?? p.priority}
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "状态",
|
||||
cell: (p) => <StatusBadge tone={planTone(p.status)}>{PLAN_STATUS_LABEL[p.status]}</StatusBadge>,
|
||||
},
|
||||
{
|
||||
key: "reserved",
|
||||
header: "预留空间",
|
||||
hideBelow: "md",
|
||||
align: "right",
|
||||
cell: (p) => <span className="tabular-nums">{formatBytes(p.reservedBytes, 1)}</span>,
|
||||
},
|
||||
{
|
||||
key: "reason",
|
||||
header: "说明",
|
||||
hideBelow: "lg",
|
||||
cell: (p) => <span className="line-clamp-1 text-muted-foreground">{planReasonText(p.statusReason) ?? "—"}</span>,
|
||||
},
|
||||
{
|
||||
key: "at",
|
||||
header: "创建",
|
||||
hideBelow: "sm",
|
||||
cell: (p) => <span className="whitespace-nowrap text-muted-foreground">{relTime(p.createdAt)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="下载面板" />
|
||||
<DownloadsTabs />
|
||||
<FilterBar onReset={f.active ? f.reset : undefined}>
|
||||
<FilterSelect
|
||||
label="状态"
|
||||
value={f.values.status}
|
||||
onChange={(v) => f.set("status", v)}
|
||||
options={Object.entries(PLAN_STATUS_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</FilterBar>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(p) => p.id}
|
||||
loading={plans.isPending}
|
||||
error={plans.error}
|
||||
onRetry={() => void plans.refetch()}
|
||||
onRowClick={setOpenPlan}
|
||||
selectedKey={openPlan?.id}
|
||||
hasMore={plans.hasNextPage}
|
||||
onLoadMore={() => void plans.fetchNextPage()}
|
||||
loadingMore={plans.isFetchingNextPage}
|
||||
empty={{
|
||||
icon: ClipboardList,
|
||||
title: f.active ? "没有符合筛选的下载计划" : "还没有下载计划",
|
||||
description: f.active ? undefined : "选中可下载的资源后出现在这里。",
|
||||
}}
|
||||
/>
|
||||
|
||||
<DetailSheet
|
||||
open={!!openPlan}
|
||||
onOpenChange={(o) => !o && setOpenPlan(null)}
|
||||
title={openPlan ? `${PURPOSE[openPlan.purpose]}计划` : ""}
|
||||
description={planReasonText(openPlan?.statusReason) ?? undefined}
|
||||
footer={
|
||||
linkedDownload ? (
|
||||
<Button asChild size="sm">
|
||||
<Link to={consolePath("downloads", linkedDownload.id)}>查看下载任务</Link>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{openPlan ? (
|
||||
<>
|
||||
<KvList
|
||||
items={[
|
||||
{
|
||||
label: "状态",
|
||||
value: (
|
||||
<StatusBadge tone={planTone(openPlan.status)}>{PLAN_STATUS_LABEL[openPlan.status]}</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "优先级",
|
||||
value: PRIORITY[openPlan.priority as keyof typeof PRIORITY] ?? openPlan.priority,
|
||||
},
|
||||
{ label: "预留空间", value: formatBytes(openPlan.reservedBytes, 1) },
|
||||
{ label: "过期于", value: formatDateTime(openPlan.expiresAt) },
|
||||
{ label: "创建", value: formatDateTime(openPlan.createdAt) },
|
||||
]}
|
||||
/>
|
||||
<TechInfo
|
||||
className="mt-4"
|
||||
items={[
|
||||
{ label: "计划 ID", value: openPlan.id, wide: true },
|
||||
{ label: "收录版本 ID", value: openPlan.slotId, wide: true },
|
||||
{ label: "资源 ID", value: openPlan.sourceReleaseId, wide: true },
|
||||
{ label: "说明原码", value: openPlan.statusReason },
|
||||
]}
|
||||
>
|
||||
<JsonView label="评估依据" value={openPlan.evaluation} defaultOpen={false} />
|
||||
<JsonView label="预算预留" value={openPlan.budgetReservation} defaultOpen={false} />
|
||||
</TechInfo>
|
||||
</>
|
||||
) : null}
|
||||
</DetailSheet>
|
||||
</>
|
||||
);
|
||||
export default function LegacyRedirect() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { DownloadBoardState } from "@umt/contracts";
|
||||
import type { DownloadBoardItem, DownloadBoardRow, DownloadBoardState } from "@umt/contracts";
|
||||
import { EventType } from "@umt/contracts";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useDownloadBoard } from "~/api/downloads.ts";
|
||||
import { DataTable } from "~/components/console/data-table.tsx";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
import { useBoardPlanRow, useDownloadBoard, usePlan } from "~/api/downloads.ts";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { readBoardState, writeBoardState } from "~/components/downloads/board-format.ts";
|
||||
import { CacheVolumes } from "~/components/downloads/cache-volumes.tsx";
|
||||
import {
|
||||
@@ -19,13 +21,17 @@ import {
|
||||
TransferLine,
|
||||
} from "~/components/downloads/download-board.tsx";
|
||||
import { DownloadDetail } from "~/components/downloads/download-detail.tsx";
|
||||
import { DownloadsTabs } from "~/components/downloads/downloads-tabs.tsx";
|
||||
import { DROPPED_STATUS, droppedReason } from "~/components/downloads/plan-format.ts";
|
||||
import { PlanDetail, WhySection } from "~/components/downloads/plan-row.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
|
||||
export const meta = () => [{ title: "下载面板 · UHD 媒体自动化控制台" }];
|
||||
|
||||
// DL-2:默认只看进行中;已完成单独一段(本浏览器记住选择)。页面可见时每 4 秒刷新速度,切到后台停。
|
||||
// DL-3:没提交到下载器的下载(等批准 / 等磁盘空间…)也是「进行中」的行;?plan=<计划 id> 深链打开对应行。
|
||||
|
||||
const COMPLETED = new Set(["completed", "importing", "imported"]);
|
||||
|
||||
export default function DownloadsPage() {
|
||||
// SPA 模式:路由组件只在浏览器渲染,可直接读本地记住的分段
|
||||
@@ -35,8 +41,19 @@ export default function DownloadsPage() {
|
||||
writeBoardState(s);
|
||||
};
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [params, setParams] = useSearchParams();
|
||||
const deepPlan = params.get("plan");
|
||||
const qc = useQueryClient();
|
||||
const board = useDownloadBoard(state);
|
||||
const deep = useBoardPlanRow(deepPlan);
|
||||
|
||||
// 深链:找到行就切到它所在的分段并打开;没有行(已结束的下载)打开结束说明
|
||||
useEffect(() => {
|
||||
const row = deep.data;
|
||||
if (!row) return;
|
||||
setStateRaw(row.kind === "task" && COMPLETED.has(row.status) ? "completed" : "active");
|
||||
setOpenId(row.id);
|
||||
}, [deep.data]);
|
||||
|
||||
// 状态变化、阈值事件到达时立即刷新(速度靠轮询)
|
||||
useSse(
|
||||
@@ -50,11 +67,23 @@ export default function DownloadsPage() {
|
||||
);
|
||||
|
||||
const pages = board.data?.pages;
|
||||
const rows = pages?.flatMap((p) => p.items);
|
||||
const allRows = pages?.flatMap((p) => p.items);
|
||||
const rows: DownloadBoardRow[] | undefined =
|
||||
state === "completed" ? allRows?.filter((r): r is DownloadBoardItem => r.kind === "task") : allRows;
|
||||
const first = pages?.[0];
|
||||
const open = rows?.find((t) => t.id === openId) ?? null;
|
||||
const columns = useMemo(() => (state === "active" ? activeColumns() : completedColumns()), [state]);
|
||||
const open = rows?.find((t) => t.id === openId) ?? (deep.data?.id === openId ? deep.data : null) ?? null;
|
||||
const columns = useMemo(
|
||||
() => (state === "active" ? activeColumns() : (completedColumns() as unknown as DataColumn<DownloadBoardRow>[])),
|
||||
[state],
|
||||
);
|
||||
const empty = BOARD_EMPTY[state];
|
||||
const closeDeep = () => {
|
||||
if (!deepPlan) return;
|
||||
const next = new URLSearchParams(params);
|
||||
next.delete("plan");
|
||||
setParams(next, { replace: true });
|
||||
};
|
||||
const endedPlan = deepPlan && deep.isSuccess && deep.data === null ? deepPlan : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -67,7 +96,6 @@ export default function DownloadsPage() {
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DownloadsTabs />
|
||||
<CacheVolumes />
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
||||
<BoardSegments value={state} counts={first?.counts} onChange={setState} />
|
||||
@@ -90,19 +118,59 @@ export default function DownloadsPage() {
|
||||
|
||||
<DetailSheet
|
||||
open={!!open}
|
||||
onOpenChange={(o) => !o && setOpenId(null)}
|
||||
onOpenChange={(o) => {
|
||||
if (o) return;
|
||||
setOpenId(null);
|
||||
closeDeep();
|
||||
}}
|
||||
width="lg"
|
||||
title={open?.name ?? ""}
|
||||
footer={
|
||||
open ? (
|
||||
open?.kind === "task" ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={consolePath("downloads", open.id)}>打开详情页</Link>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{open ? <DownloadDetail task={open} live={open.live} /> : null}
|
||||
{open?.kind === "task" ? <DownloadDetail task={open} live={open.live} /> : null}
|
||||
{open?.kind === "plan" ? <PlanDetail p={open} /> : null}
|
||||
</DetailSheet>
|
||||
|
||||
<EndedPlanSheet planId={endedPlan} onClose={closeDeep} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 深链指向已结束的下载(没提交成功 / 已取消 / 已过期):说明原因与来由;作品的下载历史里也有 */
|
||||
function EndedPlanSheet({ planId, onClose }: { planId: string | null; onClose: () => void }) {
|
||||
const plan = usePlan(planId);
|
||||
const p = plan.data;
|
||||
const ended = p && (p.status === "failed" || p.status === "cancelled" || p.status === "expired") ? p.status : null;
|
||||
return (
|
||||
<DetailSheet
|
||||
open={!!planId}
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={ended ? "这次下载没有提交" : "下载"}
|
||||
description={p ? (droppedReason(p.statusReason) ?? undefined) : undefined}
|
||||
>
|
||||
{plan.isPending ? (
|
||||
<LoadingState rows={3} />
|
||||
) : !p ? (
|
||||
<p className="text-sm text-muted-foreground">没找到这次下载,可能已被清理。</p>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{ended ? (
|
||||
<StatusBadge tone={DROPPED_STATUS[ended].tone}>{DROPPED_STATUS[ended].label}</StatusBadge>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">这次下载已经结束。</p>
|
||||
)}
|
||||
<WhySection
|
||||
planId={p.id}
|
||||
extraTech={[{ label: "状态原码", value: `${p.status} · ${p.statusReason ?? "—"}` }]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { DROPPED_STATUS, droppedReason, PURPOSE_LABEL } from "~/components/downloads/plan-format.ts";
|
||||
import { evidenceLabel, FieldLockDialog, IdentityDialog, MergeDialog } from "~/components/library/entity-dialogs.tsx";
|
||||
import {
|
||||
CONTENT_TYPE,
|
||||
@@ -88,7 +89,7 @@ import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { useUiPrefs } from "~/lib/ui-prefs.ts";
|
||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
// 作品详情(REQ §13.1 / §13.4.3):实际路径、外部身份映射及状态、元数据来源、各 Slot、候选拒绝理由、
|
||||
// 内容单元、下载历史与校验结果,同一页同时展示。
|
||||
@@ -871,11 +872,7 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
|
||||
{d.downloadTask.name}
|
||||
</Link>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{d.plan
|
||||
? `${d.plan.purpose === "upgrade" ? "升级" : d.plan.purpose === "fill" ? "补缺" : "换源"}计划 · `
|
||||
: d.downloadTask.origin === "adopted"
|
||||
? "接管 · "
|
||||
: ""}
|
||||
{d.plan ? `${PURPOSE_LABEL[d.plan.purpose]} · ` : d.downloadTask.origin === "adopted" ? "接管 · " : ""}
|
||||
{formatDateTime(d.downloadTask.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -935,15 +932,54 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
|
||||
// 没有下载任务的整理记录(文件管理按路径手动整理等),下载任务的记录已在上表
|
||||
const listed = new Set(detail.downloads.map((d) => d.downloadTask.id));
|
||||
const manual = (detail.organizationRecords ?? []).filter((r) => !r.downloadTaskId || !listed.has(r.downloadTaskId));
|
||||
// DL-3:没提交到下载器就结束的下载(失败 / 取消 / 过期),原因走原因码表,原码进技术信息
|
||||
const dropped = detail.droppedPlans ?? [];
|
||||
return (
|
||||
<Panel title="下载历史与校验结果" bodyClassName="p-0 sm:p-0">
|
||||
<DataTable
|
||||
plain
|
||||
columns={columns}
|
||||
rows={detail.downloads}
|
||||
rowKey={(d) => d.downloadTask.id}
|
||||
empty={{ title: manual.length ? "没有下载任务,只有手动整理记录" : "还没有下载记录" }}
|
||||
/>
|
||||
{detail.downloads.length || !dropped.length ? (
|
||||
<DataTable
|
||||
plain
|
||||
columns={columns}
|
||||
rows={detail.downloads}
|
||||
rowKey={(d) => d.downloadTask.id}
|
||||
empty={{ title: manual.length ? "没有下载任务,只有手动整理记录" : "还没有下载记录" }}
|
||||
/>
|
||||
) : null}
|
||||
{dropped.length ? (
|
||||
<div className={cn("px-4 py-3 sm:px-5", detail.downloads.length ? "border-t border-border/60" : "")}>
|
||||
<p className="mb-2 text-meta text-muted-foreground">没提交到下载器</p>
|
||||
<ul className="space-y-2">
|
||||
{dropped.map(({ plan, releaseTitle }) => {
|
||||
const st = DROPPED_STATUS[plan.status as keyof typeof DROPPED_STATUS] ?? DROPPED_STATUS.failed;
|
||||
const why = droppedReason(plan.statusReason);
|
||||
return (
|
||||
<li key={plan.id} className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<StatusBadge tone={st.tone}>{st.label}</StatusBadge>
|
||||
<Link
|
||||
to={`${consolePath("downloads")}?plan=${plan.id}`}
|
||||
className="min-w-0 max-w-full truncate font-mono text-caption text-foreground hover:underline sm:max-w-md"
|
||||
title={releaseTitle ?? undefined}
|
||||
>
|
||||
{releaseTitle ?? "未知资源"}
|
||||
</Link>
|
||||
<span className="text-meta text-muted-foreground tabular-nums">
|
||||
{PURPOSE_LABEL[plan.purpose]} · {formatDateTime(plan.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
{why ? <p className="mt-0.5 text-meta text-muted-foreground">{why}</p> : null}
|
||||
<TechInfo
|
||||
items={[
|
||||
{ label: "计划 ID", value: plan.id, wide: true },
|
||||
{ label: "原因码", value: plan.statusReason, wide: true },
|
||||
]}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{manual.length ? (
|
||||
<div className="border-t border-border/60 px-4 py-3 sm:px-5">
|
||||
<p className="mb-2 text-meta text-muted-foreground">其他整理记录</p>
|
||||
|
||||
@@ -25,13 +25,17 @@ export function legacyRedirect(pathname: string, search: string): string | null
|
||||
if (engineRun) return withRest(`/console/tasks?run=${engineRun[1]}`);
|
||||
// IA-3:身份与元数据取消,待识别 / 外部身份 / 别名并入媒体概览
|
||||
if (path === "/console/identity") return withRest(`/console/library?${IDENTITY_TAB[tab ?? ""] ?? IDENTITY_TAB.pool}`);
|
||||
// DL-3:下载计划页签取消,计划并入下载面板;带计划 id 的深链(?plan= 或 /plans/:id)打开对应行
|
||||
if (path === "/console/downloads/plans") return withRest("/console/downloads");
|
||||
const planPath = /^\/console\/downloads\/plans\/([^/]+)$/.exec(path);
|
||||
if (planPath) return withRest(`/console/downloads?plan=${planPath[1]}`);
|
||||
|
||||
if (!tab) return null;
|
||||
if (path === "/console/tasks") {
|
||||
// FE-6:计划从任务页拆出,回到独立的计划页(?job= 透传)
|
||||
if (tab === "plan") return withRest("/console/schedules");
|
||||
if (tab === "downloads") return withRest("/console/downloads");
|
||||
if (tab === "plans") return withRest("/console/downloads/plans");
|
||||
if (tab === "plans") return withRest("/console/downloads");
|
||||
// 旧"业务任务"页签:统一任务流
|
||||
if (tab === "tasks" || tab === "business") return withRest("/console/tasks");
|
||||
return null;
|
||||
@@ -49,7 +53,10 @@ export function legacyRedirect(pathname: string, search: string): string | null
|
||||
export const LEGACY_REDIRECTS: { from: string; to: string }[] = [
|
||||
{ from: "/console/tasks?tab=downloads", to: "/console/downloads" },
|
||||
{ from: "/console/tasks?tab=downloads&status=paused", to: "/console/downloads?status=paused" },
|
||||
{ from: "/console/tasks?tab=plans", to: "/console/downloads/plans" },
|
||||
{ from: "/console/tasks?tab=plans", to: "/console/downloads" },
|
||||
{ from: "/console/downloads/plans", to: "/console/downloads" },
|
||||
{ from: "/console/downloads/plans?plan=p-1", to: "/console/downloads?plan=p-1" },
|
||||
{ from: "/console/downloads/plans/p-1", to: "/console/downloads?plan=p-1" },
|
||||
{ from: "/console/tasks?tab=tasks", to: "/console/tasks" },
|
||||
{ from: "/console/tasks?tab=business", to: "/console/tasks" },
|
||||
{ from: "/console/subscriptions?tab=policies", to: "/console/rules" },
|
||||
|
||||
@@ -15,7 +15,7 @@ import { legacyRedirectLoader } from "./redirects.ts";
|
||||
|
||||
export const meta = () => [{ title: "任务 · UHD 媒体自动化控制台" }];
|
||||
|
||||
// 旧的 ?tab=downloads / plans / business / plan 跳到下载面板 / 下载计划 / 任务流 / 计划页
|
||||
// 旧的 ?tab=downloads / plans / business / plan 跳到下载面板 / 下载面板 / 任务流 / 计划页
|
||||
export const clientLoader = legacyRedirectLoader;
|
||||
|
||||
type Tab = StreamTab;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
| 资源按收录版本评估后 | 不单独命名;说"可下载的资源" | 候选(只在代码与给模型的说明里) |
|
||||
| Decision | 待处理事项;入口叫"待处理" | 决策、工单、待决 |
|
||||
| Approval | 审批(名词);按钮用"批准 / 拒绝" | 授权记录 |
|
||||
| AcquisitionPlan(下载计划,DL-3 起界面不单独命名) | 下载;还没提交到下载器的按在等什么写状态:「等你批准」「等磁盘空间」「等开启自动化」「准备提交」;没提交就结束的写「没提交成功 / 已取消 / 已过期」+ 原因;来由写「为什么下这个」;智能体动作计数写"准备下载 N 个资源" | 下载计划、草稿、已预留(界面) |
|
||||
| DownloadTask | 下载任务 | 下载单 |
|
||||
| Organization record | 整理记录;动作叫"整理",结果状态叫"已入库" | 转移、入库记录 |
|
||||
| 整理方式 | 复制 / 移动 / 硬链接 | Copy / Move |
|
||||
@@ -45,7 +46,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 模式、先看方案再执行(开关) |
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -177,8 +177,8 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
|
||||
),
|
||||
"download.plan": d(
|
||||
"download.plan",
|
||||
"生成下载计划",
|
||||
"评估候选,生成占用收录版本(slot)与预算预留的下载计划;不提交。选定资源后、提交前用;不会提交下载,也不预留收录版本",
|
||||
"准备下载",
|
||||
"评估选定的资源能否下到这个收录版本(slot),准备一份待提交的下载(返回 planId);不提交,也不占用收录版本与预算。选定资源后、提交前用",
|
||||
"preview",
|
||||
"DownloadPlan",
|
||||
"organize.execute",
|
||||
@@ -189,7 +189,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
|
||||
"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<KnownCapabilityId, CapabilityActivity>
|
||||
"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("生成", "整理计划", "份"),
|
||||
|
||||
@@ -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<typeof DownloadBoardState>;
|
||||
|
||||
@@ -19,6 +22,8 @@ export const DOWNLOAD_BOARD_STATUSES: Record<DownloadBoardState, readonly Downlo
|
||||
export const DownloadBoardQuery = CursorQuery.extend({
|
||||
state: DownloadBoardState.default("active"),
|
||||
instanceId: Id.optional(),
|
||||
/** 深链(DL-3):只返回这个计划对应的行——等待中的计划,或它的下载任务(不分进行中 / 已完成) */
|
||||
planId: Id.optional(),
|
||||
});
|
||||
export type DownloadBoardQuery = z.infer<typeof DownloadBoardQuery>;
|
||||
|
||||
@@ -44,6 +49,7 @@ export const DownloadLive = z.object({
|
||||
export type DownloadLive = z.infer<typeof DownloadLive>;
|
||||
|
||||
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<typeof DownloadBoardItem>;
|
||||
|
||||
/**
|
||||
* 计划在等什么(DL-3):approval = 等管理员批准(有待处理的审批);space = 下载盘超过暂停线或用量未知;
|
||||
* automation_off = 自动化总开关关闭 / 只读对账;queued = 条件已满足、等提交任务执行。
|
||||
* 每日下载额度在生成计划时核对,超额不生成计划,所以没有"等额度"的计划。
|
||||
*/
|
||||
export const PlanWait = z.enum(["approval", "space", "automation_off", "queued"]);
|
||||
export type PlanWait = z.infer<typeof PlanWait>;
|
||||
|
||||
/** 还没提交到下载器的计划,作为「进行中」的一行(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<typeof DownloadBoardPlanItem>;
|
||||
|
||||
export const DownloadBoardRow = z.discriminatedUnion("kind", [DownloadBoardItem, DownloadBoardPlanItem]);
|
||||
export type DownloadBoardRow = z.infer<typeof DownloadBoardRow>;
|
||||
|
||||
/** 下载的来由(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<typeof DownloadWhy>;
|
||||
|
||||
export const DownloaderTransfer = z.object({
|
||||
instanceId: Id,
|
||||
name: z.string(),
|
||||
@@ -69,7 +130,8 @@ export const DownloaderTransfer = z.object({
|
||||
export type DownloaderTransfer = z.infer<typeof DownloaderTransfer>;
|
||||
|
||||
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 */
|
||||
|
||||
@@ -363,6 +363,13 @@ export const LibraryDownloadView = z.object({
|
||||
});
|
||||
export type LibraryDownloadView = z.infer<typeof LibraryDownloadView>;
|
||||
|
||||
/** 没提交到下载器就结束的下载(DL-3):失败 / 取消 / 过期且没有下载任务的计划,进作品的下载历史 */
|
||||
export const LibraryDroppedPlanView = z.object({
|
||||
plan: AcquisitionPlan,
|
||||
releaseTitle: z.string().nullable(),
|
||||
});
|
||||
export type LibraryDroppedPlanView = z.infer<typeof LibraryDroppedPlanView>;
|
||||
|
||||
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 去重展示。
|
||||
|
||||
@@ -77,6 +77,16 @@ const REASONS: Record<string, ReasonText> = {
|
||||
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: "检查站点登录后重新下载" },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user