feat(organize): 整理记录按整理方式、媒体库目录、分类筛选,统计给出维度计数
列表与统计新增 action / libraryRootId / category 参数(分类取冻结计划的 classification.category); 统计 facets 按其余条件给出三个维度的可选值与条数(只列非零项),供整理记录的维度按钮列选项。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
ad3bf8926b
commit
eb294b0ffa
3 files changed
+86
-1
No files matched your search
@@ -57,7 +57,31 @@ export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
.select((eb) => eb.fn.countAll<string>().as("n")),
|
.select((eb) => eb.fn.countAll<string>().as("n")),
|
||||||
{ ...query, attention: true },
|
{ ...query, attention: true },
|
||||||
).executeTakeFirst();
|
).executeTakeFirst();
|
||||||
|
const facet = async (dim: "action" | "libraryRootId" | "category") => {
|
||||||
|
const col =
|
||||||
|
dim === "action"
|
||||||
|
? sql<string>`action::text`
|
||||||
|
: dim === "libraryRootId"
|
||||||
|
? sql<string>`target_library_root_id::text`
|
||||||
|
: CATEGORY_SQL;
|
||||||
|
const rows = await recordFilters(
|
||||||
|
db()
|
||||||
|
.selectFrom("organization_record")
|
||||||
|
.select([col.as("value"), (eb) => eb.fn.countAll<string>().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 {
|
return {
|
||||||
|
facets: {
|
||||||
|
action: await facet("action"),
|
||||||
|
libraryRootId: await facet("libraryRootId"),
|
||||||
|
category: await facet("category"),
|
||||||
|
},
|
||||||
attention: Number(att?.n ?? 0),
|
attention: Number(att?.n ?? 0),
|
||||||
total: rows.reduce((a, r) => a + Number(r.n), 0),
|
total: rows.reduce((a, r) => a + Number(r.n), 0),
|
||||||
succeeded: n("succeeded"),
|
succeeded: n("succeeded"),
|
||||||
@@ -339,6 +363,9 @@ export function likePattern(input: string): string {
|
|||||||
return /[*?]/.test(input) ? esc.replace(/\*/g, "%").replace(/\?/g, "_") : `%${esc}%`;
|
return /[*?]/.test(input) ? esc.replace(/\*/g, "%").replace(/\?/g, "_") : `%${esc}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 冻结计划里的二级分类 */
|
||||||
|
const CATEGORY_SQL = sql<string>`plan->'classification'->>'category'`;
|
||||||
|
|
||||||
/** 列表 / 统计共用筛选(不含状态) */
|
/** 列表 / 统计共用筛选(不含状态) */
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: 同一张表的 select 与 groupBy 查询共用
|
// biome-ignore lint/suspicious/noExplicitAny: 同一张表的 select 与 groupBy 查询共用
|
||||||
function recordFilters<Q extends SelectQueryBuilder<DB, "organization_record", any>>(
|
function recordFilters<Q extends SelectQueryBuilder<DB, "organization_record", any>>(
|
||||||
@@ -355,6 +382,9 @@ function recordFilters<Q extends SelectQueryBuilder<DB, "organization_record", a
|
|||||||
if (query.since) q = q.where("created_at", ">=", new Date(query.since));
|
if (query.since) q = q.where("created_at", ">=", new Date(query.since));
|
||||||
if (query.organizeOnly) q = q.where("action", "in", ["copy", "move", "in_place", "hardlink"]);
|
if (query.organizeOnly) q = q.where("action", "in", ["copy", "move", "in_place", "hardlink"]);
|
||||||
if (query.attention) q = q.where(attentionSql());
|
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<boolean>`${CATEGORY_SQL} = ${query.category}`);
|
||||||
if (query.kind === "none") q = q.where("media_entity_id", "is", null);
|
if (query.kind === "none") q = q.where("media_entity_id", "is", null);
|
||||||
else if (query.kind)
|
else if (query.kind)
|
||||||
q = q.where("media_entity_id", "in", (eb: ExpressionBuilder<DB, "organization_record">) =>
|
q = q.where("media_entity_id", "in", (eb: ExpressionBuilder<DB, "organization_record">) =>
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ describe.skipIf(skip)("整理记录筛选与统计", () => {
|
|||||||
it("统计与列表同口径,不受状态筛选影响", async () => {
|
it("统计与列表同口径,不受状态筛选影响", async () => {
|
||||||
const all = await h.api("GET", "/organization-records/stats");
|
const all = await h.api("GET", "/organization-records/stats");
|
||||||
expect(all.status).toBe(200);
|
expect(all.status).toBe(200);
|
||||||
expect(all.body.data).toEqual({
|
expect(all.body.data).toMatchObject({
|
||||||
total: 4,
|
total: 4,
|
||||||
succeeded: 2,
|
succeeded: 2,
|
||||||
partial: 0,
|
partial: 0,
|
||||||
@@ -120,6 +120,44 @@ describe.skipIf(skip)("整理记录筛选与统计", () => {
|
|||||||
expect(movies.body.data).toMatchObject({ total: 1, succeeded: 1, failed: 0 });
|
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 () => {
|
it("历史分页和统计排除排队与运行记录,显式active仍可查询", async () => {
|
||||||
const inserted = await h.db
|
const inserted = await h.db
|
||||||
.insertInto("organization_record")
|
.insertInto("organization_record")
|
||||||
|
|||||||
@@ -340,6 +340,12 @@ export const OrganizationListQuery = z.object({
|
|||||||
attention: z
|
attention: z
|
||||||
.union([z.boolean(), z.enum(["true", "false", "1", "0"]).transform((v) => v === "true" || v === "1")])
|
.union([z.boolean(), z.enum(["true", "false", "1", "0"]).transform((v) => v === "true" || v === "1")])
|
||||||
.optional(),
|
.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<typeof OrganizationListQuery>;
|
export type OrganizationListQuery = z.infer<typeof OrganizationListQuery>;
|
||||||
|
|
||||||
@@ -356,6 +362,17 @@ export const OrganizationStats = z.object({
|
|||||||
cancelled: NonNegInt,
|
cancelled: NonNegInt,
|
||||||
/** 需要处理(同列表 attention=true 的口径,不受状态筛选影响) */
|
/** 需要处理(同列表 attention=true 的口径,不受状态筛选影响) */
|
||||||
attention: NonNegInt,
|
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<typeof OrganizationStats>;
|
export type OrganizationStats = z.infer<typeof OrganizationStats>;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user