diff --git a/apps/server/src/modules/acquisition-organize/routes/organization.ts b/apps/server/src/modules/acquisition-organize/routes/organization.ts index 80ded260..f5af3075 100644 --- a/apps/server/src/modules/acquisition-organize/routes/organization.ts +++ b/apps/server/src/modules/acquisition-organize/routes/organization.ts @@ -57,7 +57,31 @@ export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) { .select((eb) => eb.fn.countAll().as("n")), { ...query, attention: true }, ).executeTakeFirst(); + const facet = async (dim: "action" | "libraryRootId" | "category") => { + const col = + dim === "action" + ? sql`action::text` + : dim === "libraryRootId" + ? sql`target_library_root_id::text` + : CATEGORY_SQL; + const rows = await recordFilters( + db() + .selectFrom("organization_record") + .select([col.as("value"), (eb) => eb.fn.countAll().as("n")]) + .groupBy(col) + .orderBy(sql`count(*)`, "desc"), + { ...query, [dim]: undefined }, + ).execute(); + return rows + .filter((r: { value: string | null }) => r.value) + .map((r: { value: string; n: string }) => ({ value: r.value, n: Number(r.n) })); + }; return { + facets: { + action: await facet("action"), + libraryRootId: await facet("libraryRootId"), + category: await facet("category"), + }, attention: Number(att?.n ?? 0), total: rows.reduce((a, r) => a + Number(r.n), 0), succeeded: n("succeeded"), @@ -339,6 +363,9 @@ export function likePattern(input: string): string { return /[*?]/.test(input) ? esc.replace(/\*/g, "%").replace(/\?/g, "_") : `%${esc}%`; } +/** 冻结计划里的二级分类 */ +const CATEGORY_SQL = sql`plan->'classification'->>'category'`; + /** 列表 / 统计共用筛选(不含状态) */ // biome-ignore lint/suspicious/noExplicitAny: 同一张表的 select 与 groupBy 查询共用 function recordFilters>( @@ -355,6 +382,9 @@ function recordFilters=", new Date(query.since)); if (query.organizeOnly) q = q.where("action", "in", ["copy", "move", "in_place", "hardlink"]); if (query.attention) q = q.where(attentionSql()); + if (query.action) q = q.where("action", "=", query.action); + if (query.libraryRootId) q = q.where("target_library_root_id", "=", query.libraryRootId); + if (query.category) q = q.where(sql`${CATEGORY_SQL} = ${query.category}`); if (query.kind === "none") q = q.where("media_entity_id", "is", null); else if (query.kind) q = q.where("media_entity_id", "in", (eb: ExpressionBuilder) => diff --git a/apps/server/test/acquisition-organize/records-filter.test.ts b/apps/server/test/acquisition-organize/records-filter.test.ts index 8455337c..34c89b89 100644 --- a/apps/server/test/acquisition-organize/records-filter.test.ts +++ b/apps/server/test/acquisition-organize/records-filter.test.ts @@ -107,7 +107,7 @@ describe.skipIf(skip)("整理记录筛选与统计", () => { it("统计与列表同口径,不受状态筛选影响", async () => { const all = await h.api("GET", "/organization-records/stats"); expect(all.status).toBe(200); - expect(all.body.data).toEqual({ + expect(all.body.data).toMatchObject({ total: 4, succeeded: 2, partial: 0, @@ -120,6 +120,44 @@ describe.skipIf(skip)("整理记录筛选与统计", () => { expect(movies.body.data).toMatchObject({ total: 1, succeeded: 1, failed: 0 }); }); + it("整理方式、媒体库目录、分类:列表筛选与维度计数", async () => { + const root = await h.db + .insertInto("library_root") + .values({ name: "电影库", local_path: "/lib/movies" }) + .returning("id") + .executeTakeFirstOrThrow(); + await h.db + .updateTable("organization_record") + .set({ target_library_root_id: root.id }) + .where("id", "=", ids.film!) + .execute(); + try { + expect(idsOf(await list("organizeOnly=true&action=copy"))).toEqual([ids.film, ids.unknown].sort()); + expect(idsOf(await list("action=move"))).toEqual([ids.show]); + expect(idsOf(await list(`libraryRootId=${root.id}`))).toEqual([ids.film]); + expect(idsOf(await list(`category=${encodeURIComponent("欧美电影")}`))).toEqual([ids.film]); + expect(await list(`category=${encodeURIComponent("纪录片")}`)).toEqual([]); + // 维度计数:按其余条件计,不含本维度自己的条件;只列非零项 + const s = await h.api("GET", "/organization-records/stats?organizeOnly=true&action=copy"); + expect(s.body.data.total).toBe(2); + expect(s.body.data.facets.action).toEqual([ + { value: "copy", n: 2 }, + { value: "move", n: 1 }, + ]); + expect(s.body.data.facets.libraryRootId).toEqual([{ value: root.id, n: 1 }]); + expect(s.body.data.facets.category).toEqual([{ value: "欧美电影", n: 1 }]); + const bad = await h.api("GET", "/organization-records?action=rename"); + expect(bad.status).toBe(422); + } finally { + await h.db + .updateTable("organization_record") + .set({ target_library_root_id: null }) + .where("id", "=", ids.film!) + .execute(); + await h.db.deleteFrom("library_root").where("id", "=", root.id).execute(); + } + }); + it("历史分页和统计排除排队与运行记录,显式active仍可查询", async () => { const inserted = await h.db .insertInto("organization_record") diff --git a/packages/contracts/src/organization.ts b/packages/contracts/src/organization.ts index 948ab5df..42f98ade 100644 --- a/packages/contracts/src/organization.ts +++ b/packages/contracts/src/organization.ts @@ -340,6 +340,12 @@ export const OrganizationListQuery = z.object({ attention: z .union([z.boolean(), z.enum(["true", "false", "1", "0"]).transform((v) => v === "true" || v === "1")]) .optional(), + /** 整理方式(复制 / 移动 / 硬链接 / 原地整理) */ + action: z.enum(["copy", "move", "hardlink", "in_place"]).optional(), + /** 目标媒体库目录 */ + libraryRootId: Id.optional(), + /** 冻结计划里的二级分类(如「欧美电影」) */ + category: z.string().min(1).max(100).optional(), }); export type OrganizationListQuery = z.infer; @@ -356,6 +362,17 @@ export const OrganizationStats = z.object({ cancelled: NonNegInt, /** 需要处理(同列表 attention=true 的口径,不受状态筛选影响) */ attention: NonNegInt, + /** + * 筛选维度的可选值与条数(只列非零项):每个维度按其余条件计数(不含本维度自己的条件), + * 供整理记录的维度按钮列选项。libraryRootId 的 value 是媒体库目录 id。 + */ + facets: z + .object({ + action: z.array(z.object({ value: z.string(), n: NonNegInt })), + libraryRootId: z.array(z.object({ value: z.string(), n: NonNegInt })), + category: z.array(z.object({ value: z.string(), n: NonNegInt })), + }) + .optional(), }); export type OrganizationStats = z.infer;