feat(downloads): live speeds and active/completed split on the download panel (DL-2)

- GET /download-tasks/board?state=active|completed: per-torrent dl/up speed,
  ETA, seeds/leechs, downloader total speed (transfer/info), counts for both
  groups, latest organize result (status, reason code, open decision)
- live qB data cached per instance for 2 s with in-flight coalescing
- panel: total speed line, 进行中 / 已完成 segments remembered locally,
  4 s polling while the page is visible, mobile layout, "—" for zero speed

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 21:44:34 +08:00
1 parent 8086250ec2
commit 3b340745bf
17 files changed
+1113 -93

No files matched your search

@@ -90,6 +90,12 @@ export interface QbTorrent {
eta: number | null;
ratio: number | null;
seedingTimeSec: number | null;
/** 已连接的做种 / 下载用户数(num_seeds / num_leechs) */
numSeeds: number;
numLeechs: number;
/** Tracker 报告的做种 / 下载总数(num_complete / num_incomplete;未知为 null) */
numComplete: number | null;
numIncomplete: number | null;
}
export interface QbFile {
@@ -226,6 +232,11 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function num(v: unknown): number | null {
return typeof v === "number" && Number.isFinite(v) ? v : null;
}
/** qB 用 -1 表示未知(num_complete 等) */
function nonNeg(v: unknown): number | null {
const n = num(v);
return n !== null && n >= 0 ? n : null;
}
function str(v: unknown): string | null {
return typeof v === "string" && v !== "" ? v : null;
}
@@ -282,6 +293,10 @@ export function normalizeTorrent(instanceId: string, r: Record<string, unknown>)
eta: num(r.eta),
ratio: num(r.ratio),
seedingTimeSec: num(r.seeding_time),
numSeeds: num(r.num_seeds) ?? 0,
numLeechs: num(r.num_leechs) ?? 0,
numComplete: nonNeg(r.num_complete),
numIncomplete: nonNeg(r.num_incomplete),
};
}
@@ -359,6 +359,22 @@ describe("QbClient 查询与操作", () => {
expect((await qb.findByHash("b".repeat(40)))!.state).toBe("missing_files");
});
it("速度、剩余时间、连接数字段(DL-2);-1 的总数记为未知;transfer/info 为全局速度", async () => {
const { mock, qb } = await setup();
Object.assign(mock.seed({ hash: "c".repeat(40), name: "C", progress: 0.3 }), {
dlspeed: 2048,
upspeed: 512,
eta: 90,
num_seeds: 4,
num_leechs: 2,
num_complete: 30,
});
const c = (await qb.findByHash("c".repeat(40)))!;
expect(c).toMatchObject({ dlspeed: 2048, upspeed: 512, eta: 90, numSeeds: 4, numLeechs: 2, numComplete: 30 });
expect(c.numIncomplete).toBeNull();
expect(await qb.transferInfo()).toMatchObject({ dl_info_speed: 2048, up_info_speed: 512 });
});
it("文件列表、标签增删、删除必须显式 deleteFiles", async () => {
const { mock, qb } = await setup();
const t = makeTorrent({