From eb294b0ffab63aba4d38e233723f7cd576b07fb3 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 17:50:28 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(organize):=20=E6=95=B4=E7=90=86?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=8C=89=E6=95=B4=E7=90=86=E6=96=B9=E5=BC=8F?= =?UTF-8?q?=E3=80=81=E5=AA=92=E4=BD=93=E5=BA=93=E7=9B=AE=E5=BD=95=E3=80=81?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E7=AD=9B=E9=80=89=EF=BC=8C=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E7=BB=99=E5=87=BA=E7=BB=B4=E5=BA=A6=E8=AE=A1=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 列表与统计新增 action / libraryRootId / category 参数(分类取冻结计划的 classification.category); 统计 facets 按其余条件给出三个维度的可选值与条数(只列非零项),供整理记录的维度按钮列选项。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../routes/organization.ts | 30 ++++++++++++++ .../records-filter.test.ts | 40 ++++++++++++++++++- packages/contracts/src/organization.ts | 17 ++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) 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; From 83be47aea245098146653d9e0d260fdb80539a45 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 17:50:28 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(web):=20=E6=95=B4=E7=90=86=E9=A1=B5?= =?UTF-8?q?=E5=8F=AA=E7=95=99=E6=95=B4=E7=90=86=E8=AE=B0=E5=BD=95=EF=BC=8C?= =?UTF-8?q?=E6=95=B4=E7=90=86=E9=98=9F=E5=88=97=E6=94=B9=E6=88=90=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E6=A0=8F=E6=8C=89=E9=92=AE=EF=BC=8C=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E5=8A=A0=E7=BB=B4=E5=BA=A6=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 标题栏「队列 N」(排队 + 执行中,含刮削)点开右侧面板看队列;面板开关在 URL(?queue=1), 旧链接 ?tab=queue 打开面板、?tab=records 去掉;整理向导「整理队列」跳到面板。 - 筛选学 MP:状态、类型、整理方式、媒体库目录、分类、来源、时间做成维度按钮(单选,带条数), 已选条件(含作品、需要处理、关键词、本次整理)排成可移除标签 +「清除」;按作品分组照旧。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- apps/web/app/api/records.ts | 6 + .../organize-wizard/organize-wizard.tsx | 2 +- .../components/organize/organize-queue.tsx | 69 +++- .../organize/record-filter-model.test.ts | 89 +++++ .../organize/record-filter-model.ts | 114 ++++++ .../components/organize/record-filters.tsx | 141 +++++++ apps/web/app/routes/console/organize.$id.tsx | 2 +- apps/web/app/routes/console/organize.tsx | 360 +++++++++--------- 8 files changed, 587 insertions(+), 196 deletions(-) create mode 100644 apps/web/app/components/organize/record-filter-model.test.ts create mode 100644 apps/web/app/components/organize/record-filter-model.ts create mode 100644 apps/web/app/components/organize/record-filters.tsx diff --git a/apps/web/app/api/records.ts b/apps/web/app/api/records.ts index 54001624..cb67eb8d 100644 --- a/apps/web/app/api/records.ts +++ b/apps/web/app/api/records.ts @@ -34,6 +34,12 @@ export type RecordFilters = { organizeOnly?: boolean; /** 只看需要处理的(失败 / 部分完成没被取代、没被忽略,或有未关闭的事项) */ attention?: boolean; + /** 整理方式 */ + action?: "copy" | "move" | "hardlink" | "in_place"; + /** 目标媒体库目录 */ + libraryRootId?: string; + /** 冻结计划里的二级分类 */ + category?: string; }; export const recordKeys = { diff --git a/apps/web/app/components/organize-wizard/organize-wizard.tsx b/apps/web/app/components/organize-wizard/organize-wizard.tsx index a70dbbcf..ac1ad654 100644 --- a/apps/web/app/components/organize-wizard/organize-wizard.tsx +++ b/apps/web/app/components/organize-wizard/organize-wizard.tsx @@ -861,7 +861,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on onClick={() => { if (submitting.current) return; onClose(); - navigate("/console/organize?tab=queue"); + navigate("/console/organize?queue=1"); }} > diff --git a/apps/web/app/components/organize/organize-queue.tsx b/apps/web/app/components/organize/organize-queue.tsx index fc677041..5dd93957 100644 --- a/apps/web/app/components/organize/organize-queue.tsx +++ b/apps/web/app/components/organize/organize-queue.tsx @@ -1,9 +1,9 @@ import { useQueryClient } from "@tanstack/react-query"; import type { EngineRun } from "@umt/contracts"; -import { FileStack, RefreshCw } from "lucide-react"; +import { FileStack, ListOrdered, Loader2, RefreshCw } from "lucide-react"; import { Link } from "react-router"; import { useDownloadTask } from "~/api/downloads.ts"; -import { useEngineRun, useEngineRuns } from "~/api/engine.ts"; +import { useEngineRun, useEngineRuns, useEngineRunsPage } from "~/api/engine.ts"; import { useScrapeRecords } from "~/api/files.ts"; import { ACTION_LABEL, useRecord } from "~/api/records.ts"; import { useRecordsForDownload } from "~/api/tasks.ts"; @@ -13,9 +13,11 @@ import { useListPagination } from "~/components/console/use-list-pagination.ts"; import { relTime } from "~/components/library/parts.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Pagination } from "~/components/ui/pagination.tsx"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx"; import { qk } from "~/lib/query-keys.ts"; import { useSse } from "~/lib/sse.ts"; import { organizeQueueStage, organizeRunInput, scrapeQueueStage } from "./queue-model.ts"; +import { queueCountLabel } from "./record-filter-model.ts"; import { recordFileProgress, recordTitle } from "./record-parts.ts"; function ScrapeQueueItem({ run }: { run: EngineRun }) { @@ -81,7 +83,7 @@ function QueueItem({ run }: { run: EngineRun }) { const download = useDownloadTask(downloadId); const record = recordQuery.data ?? matchingRecord; const progress = record ? recordFileProgress(record) : null; - const title = record ? recordTitle(record) : (download.data?.name ?? run.business?.title ?? "核验入库任务"); + const title = record ? recordTitle(record) : (download.data?.name ?? run.business?.title ?? "核验整理任务"); const source = record?.source.path ?? download.data?.contentPath ?? download.data?.savePath; const status = run.status === "pending" ? "排队" : run.status === "sleeping" ? "等待" : "执行中"; return ( @@ -135,18 +137,19 @@ function QueueItem({ run }: { run: EngineRun }) { ); } -/** Existing engine workflows are authoritative, including jobs that have not created a record yet. */ -export function OrganizeQueue() { +const QUEUE_FILTER = { workflowName: "verifyAndImport,scrapeMedia", status: "pending,running,sleeping" } as const; + +function useQueueInvalidation() { const qc = useQueryClient(); - const runs = useEngineRuns( - { workflowName: "verifyAndImport,scrapeMedia", status: "pending,running,sleeping" }, - true, - 5_000, - ); useSse(["task.created", "task.status_changed", "organization.changed", "download.status_changed"], () => { void qc.invalidateQueries({ queryKey: qk.all("engine") }); void qc.invalidateQueries({ queryKey: ["records"] }); }); +} + +/** 排队与执行中的整理 / 刮削任务;以引擎运行为准(含还没生成整理记录的任务) */ +export function OrganizeQueue() { + const runs = useEngineRuns(QUEUE_FILTER, true, 5_000); const rows = runs.data?.pages.flatMap((page) => page.items); const paged = useListPagination(rows, { hasMore: runs.hasNextPage, @@ -157,10 +160,7 @@ export function OrganizeQueue() { }); return (
-
-

- 下载核验、手动与自动整理、整理后的 NFO / 图片刮削在此排队。整理完成后进入记录,刮削继续显示到任务结束。 -

+
); } + +const COUNT_LIMIT = 100; + +/** + * 整理队列入口(对照 MP 整理历史页的「整理队列」按钮):标题栏一个按钮写当前排队 + 执行中的任务数, + * 点开右侧面板看队列。面板开关由页面放在 URL(?queue=1),旧链接 ?tab=queue 也落到这里。 + */ +export function OrganizeQueueButton({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { + useQueueInvalidation(); + const page = useEngineRunsPage(QUEUE_FILTER, COUNT_LIMIT, 10_000); + const items = page.data?.items ?? []; + const running = items.some((r) => r.status === "running"); + const count = queueCountLabel(items.length, !!page.data?.nextCursor); + return ( + <> + + + + + 整理队列 + + 下载核验、手动与自动整理、整理后的 NFO / 图片刮削在这里排队。整理完成后进入整理记录,刮削显示到任务结束。 + + +
{open ? : null}
+
+
+ + ); +} diff --git a/apps/web/app/components/organize/record-filter-model.test.ts b/apps/web/app/components/organize/record-filter-model.test.ts new file mode 100644 index 00000000..ae6f623b --- /dev/null +++ b/apps/web/app/components/organize/record-filter-model.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { RECORD_STATUS_LABEL, TRIGGER_LABEL } from "~/api/records.ts"; +import { buildDims, filterTags, normalizeLegacyParams, queueCountLabel } from "./record-filter-model.ts"; + +const ctx = { + statusLabel: RECORD_STATUS_LABEL, + triggerLabel: TRIGGER_LABEL, + rootName: (id: string) => ({ r1: "电影库", r2: "剧集库" })[id] ?? "已移除的目录", + facets: { + action: [ + { value: "copy", n: 12 }, + { value: "move", n: 3 }, + ], + libraryRootId: [ + { value: "r1", n: 10 }, + { value: "r2", n: 5 }, + ], + category: [{ value: "欧美电影", n: 7 }], + }, +}; + +describe("整理记录筛选维度", () => { + it("维度顺序与选项:状态不含排队 / 执行中,整理方式与目录来自统计并带条数", () => { + const dims = buildDims({}, ctx); + expect(dims.map((d) => d.label)).toEqual(["状态", "类型", "整理方式", "媒体库目录", "分类", "来源", "时间"]); + expect(dims[0]!.options.map((o) => o.value)).toEqual(["succeeded", "partial", "failed", "cancelled"]); + expect(dims[2]!.options).toEqual([ + { value: "copy", label: "复制", n: 12 }, + { value: "move", label: "移动", n: 3 }, + ]); + expect(dims[3]!.options.map((o) => o.label)).toEqual(["电影库", "剧集库"]); + }); + + it("已选值当前没有记录时仍列出,免得选中项消失", () => { + const dims = buildDims({ category: "纪录片", action: "in_place" }, ctx); + expect(dims.find((d) => d.key === "category")!.options).toContainEqual({ value: "纪录片", label: "纪录片", n: 0 }); + expect(dims.find((d) => d.key === "action")!.options).toContainEqual({ + value: "in_place", + label: "原地整理", + n: 0, + }); + }); + + it("统计还没回来时整理方式列全部,目录与分类为空", () => { + const dims = buildDims({}, { ...ctx, facets: undefined }); + expect(dims.find((d) => d.key === "action")!.options.map((o) => o.label)).toEqual([ + "复制", + "移动", + "硬链接", + "原地整理", + ]); + expect(dims.find((d) => d.key === "root")!.options).toEqual([]); + }); +}); + +describe("已选条件标签", () => { + it("本次整理、作品、需要处理在前,其余按维度顺序,关键词最后", () => { + const v = { status: "failed", root: "r1", category: "欧美电影", attention: "1", q: "Arrival" }; + const tags = filterTags(v, buildDims(v, ctx), { entityId: "e1", entityTitle: "沙丘", pinned: 0 }); + expect(tags.map((t) => `${t.dimLabel}:${t.label}`)).toEqual([ + "作品:沙丘", + "只看:需要处理", + "状态:失败", + "媒体库目录:电影库", + "分类:欧美电影", + "关键词:Arrival", + ]); + }); + + it("没有条件时没有标签;作品标题没取到时写「已选作品」", () => { + expect(filterTags({}, buildDims({}, ctx), {})).toEqual([]); + expect(filterTags({}, buildDims({}, ctx), { entityId: "e1" })[0]).toMatchObject({ label: "已选作品" }); + expect(filterTags({}, buildDims({}, ctx), { pinned: 3 })[0]).toMatchObject({ dimLabel: "本次整理", label: "3 条" }); + }); +}); + +describe("旧链接与队列按钮", () => { + it("?tab=queue 打开队列面板,?tab=records 去掉,其余参数保留", () => { + expect(normalizeLegacyParams(new URLSearchParams("tab=queue"))?.toString()).toBe("queue=1"); + expect(normalizeLegacyParams(new URLSearchParams("tab=records&attention=1"))?.toString()).toBe("attention=1"); + expect(normalizeLegacyParams(new URLSearchParams("status=failed"))).toBeNull(); + }); + + it("队列数:0 不写,超过一页写 N+", () => { + expect(queueCountLabel(0, false)).toBeNull(); + expect(queueCountLabel(3, false)).toBe("3"); + expect(queueCountLabel(100, true)).toBe("100+"); + }); +}); diff --git a/apps/web/app/components/organize/record-filter-model.ts b/apps/web/app/components/organize/record-filter-model.ts new file mode 100644 index 00000000..badcce54 --- /dev/null +++ b/apps/web/app/components/organize/record-filter-model.ts @@ -0,0 +1,114 @@ +import type { OrganizationStats } from "@umt/contracts"; + +// 整理记录筛选(对照 MP 的维度按钮 + 可删条件标签):每个维度一个按钮,点开单选;已选条件排成可移除的标签。 +// 条件都写在 URL 里(刷新、分享不丢);旧链接 ?tab=queue / ?tab=records 换成单页 + 队列面板(?queue=1)。 + +export const RECORD_FILTER_KEYS = [ + "status", + "kind", + "action", + "root", + "category", + "trigger", + "time", + "q", + "group", + "attention", +] as const; +export type RecordFilterKey = (typeof RECORD_FILTER_KEYS)[number]; + +export const KIND_LABEL = { movie: "电影", series: "剧集", none: "未识别" } as const; +export const TIME_LABEL = { today: "今天", "7d": "最近 7 天", "30d": "最近 30 天" } as const; +/** 整理记录能筛的整理方式(不含文件管理的改名、删除、还原) */ +export const ORGANIZE_ACTION_LABEL = { copy: "复制", move: "移动", hardlink: "硬链接", in_place: "原地整理" } as const; + +export type FilterOptionN = { value: string; label: string; n?: number }; + +/** 一个维度按钮:单选,value 为空 = 不限 */ +export type FilterDim = { + key: "status" | "kind" | "action" | "root" | "category" | "trigger" | "time"; + label: string; + options: FilterOptionN[]; +}; + +/** 可移除的条件标签 */ +export type FilterTag = { key: string; dimLabel: string; label: string }; + +type Values = Partial>; + +/** + * 维度按钮的选项。整理方式 / 媒体库目录 / 分类只列有记录的值(来自统计的 facets,带条数); + * 已选但当前没有记录的值仍然列出,免得选中项"消失"。 + */ +export function buildDims( + v: Values, + ctx: { + statusLabel: Record; + triggerLabel: Record; + facets?: OrganizationStats["facets"]; + rootName: (id: string) => string; + }, +): FilterDim[] { + const facet = (list: { value: string; n: number }[] | undefined, chosen: string, label: (x: string) => string) => { + const opts: FilterOptionN[] = (list ?? []).map((x) => ({ value: x.value, label: label(x.value), n: x.n })); + if (chosen && !opts.some((o) => o.value === chosen)) opts.push({ value: chosen, label: label(chosen), n: 0 }); + return opts; + }; + const entries = (o: Record) => Object.entries(o).map(([value, label]) => ({ value, label })); + const actionLabel = (x: string) => ORGANIZE_ACTION_LABEL[x as keyof typeof ORGANIZE_ACTION_LABEL] ?? x; + return [ + { + key: "status", + label: "状态", + options: entries(ctx.statusLabel).filter((o) => o.value !== "queued" && o.value !== "running"), + }, + { key: "kind", label: "类型", options: entries(KIND_LABEL) }, + { + key: "action", + label: "整理方式", + options: ctx.facets ? facet(ctx.facets.action, v.action ?? "", actionLabel) : entries(ORGANIZE_ACTION_LABEL), + }, + { key: "root", label: "媒体库目录", options: facet(ctx.facets?.libraryRootId, v.root ?? "", ctx.rootName) }, + { key: "category", label: "分类", options: facet(ctx.facets?.category, v.category ?? "", (x) => x) }, + { key: "trigger", label: "来源", options: entries(ctx.triggerLabel) }, + { key: "time", label: "时间", options: entries(TIME_LABEL) }, + ]; +} + +/** 已选条件 → 标签(按维度顺序;作品、需要处理、本次整理的记录也算条件) */ +export function filterTags( + v: Values, + dims: FilterDim[], + extra: { entityTitle?: string | null; entityId?: string | null; pinned?: number }, +): FilterTag[] { + const tags: FilterTag[] = []; + if (extra.pinned) tags.push({ key: "ids", dimLabel: "本次整理", label: `${extra.pinned} 条` }); + if (extra.entityId) tags.push({ key: "mediaEntityId", dimLabel: "作品", label: extra.entityTitle || "已选作品" }); + if (v.attention === "1") tags.push({ key: "attention", dimLabel: "只看", label: "需要处理" }); + for (const d of dims) { + const value = v[d.key]; + if (!value) continue; + tags.push({ key: d.key, dimLabel: d.label, label: d.options.find((o) => o.value === value)?.label ?? value }); + } + if (v.q) tags.push({ key: "q", dimLabel: "关键词", label: v.q }); + return tags; +} + +/** + * 旧链接兼容:整理页曾分「整理队列」「整理记录」两个标签页。 + * ?tab=queue → 打开队列面板(?queue=1);?tab=records 或其它值 → 去掉。不需要改时返回 null。 + */ +export function normalizeLegacyParams(params: URLSearchParams): URLSearchParams | null { + const tab = params.get("tab"); + if (tab === null) return null; + const next = new URLSearchParams(params); + next.delete("tab"); + if (tab === "queue") next.set("queue", "1"); + return next; +} + +/** 队列按钮上的数:0 不写;超过一页写 N+ */ +export function queueCountLabel(n: number, more: boolean): string | null { + if (!n) return null; + return more ? `${n}+` : String(n); +} diff --git a/apps/web/app/components/organize/record-filters.tsx b/apps/web/app/components/organize/record-filters.tsx new file mode 100644 index 00000000..726a9c0c --- /dev/null +++ b/apps/web/app/components/organize/record-filters.tsx @@ -0,0 +1,141 @@ +import { Check, ChevronDown, X } from "lucide-react"; +import type * as React from "react"; +import { useState } from "react"; +import { Button } from "~/components/ui/button.tsx"; +import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx"; +import { cn } from "~/lib/utils.ts"; +import type { FilterDim, FilterTag } from "./record-filter-model.ts"; + +// 整理记录筛选条:一行可横向滚动的维度按钮(单选弹层),下面一行已选条件标签(点一下移除)+「清除」。 +// 外观与资源快速筛选(components/search/quick-filter-bar.tsx)一致。 + +export function RecordFilterChips({ + dims, + values, + onChange, +}: { + dims: FilterDim[]; + values: Partial>; + onChange: (key: FilterDim["key"], value: string) => void; +}) { + return ( +
+ {dims + // 只有一个可选值又没选时不用筛(比如只有一个媒体库目录) + .filter((d) => d.options.length > 1 || !!values[d.key]) + .map((d) => ( + onChange(d.key, x)} /> + ))} +
+ ); +} + +function DimButton({ dim, value, onChange }: { dim: FilterDim; value: string; onChange: (v: string) => void }) { + const [open, setOpen] = useState(false); + const pick = (x: string) => { + setOpen(false); + onChange(x); + }; + return ( + + + + + +
+ pick("")} /> + {dim.options.map((o) => ( + pick(value === o.value ? "" : o.value)} + /> + ))} +
+
+
+ ); +} + +function OptionRow({ on, label, n, onClick }: { on: boolean; label: string; n?: number; onClick: () => void }) { + return ( + + ); +} + +/** 维度按钮;Popover 的 asChild 需要转发 ref 与事件,所以透传其余属性 */ +function Chip({ + label, + active, + className, + ...rest +}: React.ComponentProps<"button"> & { label: string; active: boolean }) { + return ( + + ); +} + +/** 已选条件:每个一个可移除标签,末尾一个「清除」 */ +export function RecordFilterTags({ + tags, + onRemove, + onClear, +}: { + tags: FilterTag[]; + onRemove: (key: string) => void; + onClear: () => void; +}) { + if (!tags.length) return null; + return ( +
+ {tags.map((t) => ( + + ))} + +
+ ); +} diff --git a/apps/web/app/routes/console/organize.$id.tsx b/apps/web/app/routes/console/organize.$id.tsx index 118b7465..6501f635 100644 --- a/apps/web/app/routes/console/organize.$id.tsx +++ b/apps/web/app/routes/console/organize.$id.tsx @@ -133,7 +133,7 @@ export default function OrganizeRecordPage() { actions={ <> - ) : null} + + - } + setGrouping(grouped ? "none" : "entity")} > - setFilter("status", x)} - options={Object.entries(RECORD_STATUS_LABEL) - .filter(([value]) => value !== "queued" && value !== "running") - .map(([value, label]) => ({ value, label }))} - /> - setFilter("kind", x)} - options={Object.entries(KIND_LABEL).map(([value, label]) => ({ value, label }))} - /> - setFilter("trigger", x)} - options={Object.entries(TRIGGER_LABEL).map(([value, label]) => ({ value, label }))} - /> - setFilter("time", x)} - options={Object.entries(TIME_LABEL).map(([value, label]) => ({ value, label }))} - /> - {entityId ? ( - - ) : null} - {pinned ? ( - - ) : null} - - {pinned ? null : ( - setFilter("status", s)} - attention={attention} - onAttention={(on) => setFilter("attention", on ? "1" : "")} - /> - )} - {selecting ? ( -
- 已选 {selRows.length} - - - - - -
- ) : null} - r.id} - loading={pinned ? byIds.isPending : records.isPending} - error={pinned ? byIds.error : records.error} - onRetry={() => void (pinned ? byIds.refetch() : records.refetch())} - onRowClick={(r) => (selecting ? toggle(r.id) : navigate(`/console/organize/${r.id}`))} - selectedKey={null} - hasMore={!pinned && records.hasNextPage} - onLoadMore={() => void records.fetchNextPage()} - loadingMore={records.isFetchingNextPage} - groupBy={ - grouped - ? { - // 未识别的记录互不相干,各自一行 - key: (r) => r.media?.entityId ?? `none:${r.id}`, - label: (_k, g) => , - meta: (_k, g) => , - flat: (_k, g) => g.length === 1, - defaultCollapsed: true, - storageKey: "organize-records-entity", - } - : undefined - } - empty={{ - icon: FileStack, - title: attention ? "没有需要处理的整理记录" : filtered ? "没有符合筛选的整理记录" : "还没有整理记录", - description: attention - ? "识别不到作品、目标冲突或整理失败的记录会出现在这里。" - : filtered - ? undefined - : "下载完成后会自动整理,也可以在文件管理里选中文件整理。", - }} - /> - - + + 按作品分组 + + } + > + setFilter(key, value)} /> + + + {pinned ? null : ( + setFilter("status", s)} + attention={attention} + onAttention={(on) => setFilter("attention", on ? "1" : "")} + /> + )} + {selecting ? ( +
+ 已选 {selRows.length} + + + + + +
+ ) : null} + r.id} + loading={pinned ? byIds.isPending : records.isPending} + error={pinned ? byIds.error : records.error} + onRetry={() => void (pinned ? byIds.refetch() : records.refetch())} + onRowClick={(r) => (selecting ? toggle(r.id) : navigate(`/console/organize/${r.id}`))} + selectedKey={null} + hasMore={!pinned && records.hasNextPage} + onLoadMore={() => void records.fetchNextPage()} + loadingMore={records.isFetchingNextPage} + groupBy={ + grouped + ? { + // 未识别的记录互不相干,各自一行 + key: (r) => r.media?.entityId ?? `none:${r.id}`, + label: (_k, g) => , + meta: (_k, g) => , + flat: (_k, g) => g.length === 1, + defaultCollapsed: true, + storageKey: "organize-records-entity", + } + : undefined + } + empty={{ + icon: FileStack, + title: attention ? "没有需要处理的整理记录" : filtered ? "没有符合筛选的整理记录" : "还没有整理记录", + description: attention + ? "识别不到作品、目标冲突或整理失败的记录会出现在这里。" + : filtered + ? undefined + : "下载完成后会自动整理,也可以在文件管理里选中文件整理。", + }} + /> {choosingSource ? ( setChoosingSource(false)} From 79c3b8c9b9efa7924d50602de0211ce18d273dca Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 17:54:28 +0800 Subject: [PATCH 3/5] =?UTF-8?q?docs(organize):=20=E6=95=B4=E7=90=86?= =?UTF-8?q?=E9=A1=B5=E5=8D=95=E9=A1=B5=E3=80=81=E9=98=9F=E5=88=97=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E3=80=81=E7=BB=B4=E5=BA=A6=E7=AD=9B=E9=80=89=E4=B8=8E?= =?UTF-8?q?=E3=80=8C=E6=95=B4=E7=90=86=E3=80=8D=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- docs/design/file-manager-v2.md | 2 +- docs/runbook/organize.md | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/design/file-manager-v2.md b/docs/design/file-manager-v2.md index b6eed575..acca7d41 100644 --- a/docs/design/file-manager-v2.md +++ b/docs/design/file-manager-v2.md @@ -137,7 +137,7 @@ ### 5.1 行内 hover 操作(桌面) -鼠标悬停或键盘焦点所在行(Tab 进入按钮,Esc 回列表),在行尾浮出 2–3 个图标按钮(带 tooltip 与 aria-label,盖在修改时间列上),其余留在 ⋯:未入库视频/目录 → `整理` `识别` `刮削`;已入库 → `打开作品` `刷新元数据` `重命名`;未完成的视频 → `识别` `重命名` `移动到`;其他 → `重命名` `移动到`。窄屏卡片不依赖悬停,只用 ⋯。 +鼠标悬停或键盘焦点所在行(Tab 进入按钮,Esc 回列表),在行尾浮出 2–3 个图标按钮(带 tooltip 与 aria-label,盖在修改时间列上),其余留在 ⋯:未整理视频/目录 → `整理` `识别` `刮削`;已整理 → `打开作品` `刷新元数据` `重命名`;未完成的视频 → `识别` `重命名` `移动到`;其他 → `重命名` `移动到`。窄屏卡片不依赖悬停,只用 ⋯。 面包屑照 MP「本地 > MEDIA > 亚洲剧集」:`下载目录 / 媒体库`(说明,不可点)> 登记目录 > 各级子目录;窄屏去掉类型并折叠。 diff --git a/docs/runbook/organize.md b/docs/runbook/organize.md index 0efdb391..10fa4237 100644 --- a/docs/runbook/organize.md +++ b/docs/runbook/organize.md @@ -2,7 +2,11 @@ 原地入库为 v0.3.14 的待部署改动;当前生产 v0.3.13 的整理方式仍为复制/移动,独立就地刮削继续可用。 -整理页分为「整理队列」与「整理记录」。队列显示等待、执行和等待继续的后台入库任务;记录显示复制/移动/原地入库的处理结果。文件管理的改名、删除、还原账本继续保存,通用记录接口仍可查询。 +整理页只有「整理记录」一页(界面第二轮,2026-10-06)。整理队列学 MP 做成标题栏的「队列 N」按钮(N = 排队 + 执行中,含整理后的刮削,0 不写),点开右侧面板看队列;面板开关在 URL(`?queue=1`),旧链接 `?tab=queue` 打开面板、`?tab=records` 直接去掉。记录显示复制/移动/硬链接/原地整理的结果。文件管理的改名、删除、还原账本继续保存,通用记录接口仍可查询。 + +口径:本工具只负责「整理」——识别、元数据、NFO 与图片、按规则归到媒体库目录;媒体服务器有没有扫到由它自己决定,界面不写「入库」(见 `docs/design/glossary.md`)。 + +筛选(对照 MP 的维度按钮 + 条件标签):状态、类型、整理方式、媒体库目录、分类、来源、时间各一个维度按钮(单选;整理方式 / 媒体库目录 / 分类只列有记录的值并带条数,来自 `GET /organization-records/stats` 的 `facets`,按其余条件计数),已选条件(含作品、需要处理、关键词、本次整理的记录)排成可移除标签 +「清除」;按作品分组照旧。接口参数:`action`(copy / move / hardlink / in_place)、`libraryRootId`、`category`(冻结计划 `classification.category`),测试 `apps/server/test/acquisition-organize/records-filter.test.ts`。 实现: - 列表 / 统计 / 详情:`apps/server/src/modules/acquisition-organize/routes/organization.ts`(筛选 `recordFilters`,通配 `likePattern`);公开字段在 `services/organize.ts` 的 `loadRecordsPublic`。 @@ -104,7 +108,7 @@ MP 对照源:`MoviePilot-Frontend/src/views/reorganize/TransferHistoryView.vue` | 整理方式 复制 / 移动 | 已有 | 列表「方式」列;整理页聚焦复制/移动结果 | | 软链接、Rclone 复制 / 移动 | 不做 | 整理服务不支持这几种方式 | | 大小 | 新增 | 逐文件源大小之和(`sizeBytes`) | -| 状态 成功 / 失败 + 失败原因 | 已有,改 | 支持「部分成功」;执行中任务在队列查看;成功不加徽章;失败原因走 `reasons.ts` 一句话,原码进详情「技术信息」 | +| 状态 成功 / 失败 + 失败原因 | 已有,改 | 支持「部分成功」;执行中任务在「队列」面板查看;成功不加徽章;失败原因走 `reasons.ts` 一句话,原码进详情「技术信息」 | | 时间 | 已有,改 | 相对时间,悬停看完整时间 | | 来源 下载器 / 手动 / 自动 | 新增下载器 | 来源列加下载器名称;详情加「下载任务」一行,种子 hash 进「技术信息」 | | 关键字搜索(标题、源、目标;`*` `?` 通配) | 已有,补 | 补通配、逐文件的源 / 目标路径、种子 hash | @@ -116,7 +120,7 @@ MP 对照源:`MoviePilot-Frontend/src/views/reorganize/TransferHistoryView.vue` | 分页、每页条数 | 已有(不同) | 共享分页组件,10/20/50/100 条和翻页;游标按需加载,无总数时显示「已加载」 | | 列排序 | 不做 | 按时间倒序;分组模式下按作品聚合 | | 分组模式(按标题) | 新增 | 「按作品分组」,组头可折叠 | -| 筛选写进地址栏 | 新增 | 状态 / 类型 / 来源 / 时间 / 关键字 / 分组都在 URL,刷新不丢 | +| 筛选写进地址栏 | 新增 | 状态 / 类型 / 整理方式 / 媒体库目录 / 分类 / 来源 / 时间 / 关键字 / 分组都在 URL,刷新不丢 | | 单条:重新整理 | 已有,补 | 详情已有;列表行菜单新增。走共用弹窗(配置、展开预览、加入队列) | | 单条:智能助手整理 | 已有(不同) | 「让智能体排查」:失败 / 部分成功的记录在行菜单和详情里 | | 单条:删除(只删记录 / 连源文件 / 连媒体库文件 / 全部) | 新增 | 确认框里两个勾选项,先核对再执行,写清删几个文件、多大、哪些删不了、会不会被自动整理再处理 | From 550ac285f4a0a1065b0ffaad3e058d678bd9994e Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 17:54:28 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat(copy):=20=E5=A4=84=E7=90=86=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=9A=84=E3=80=8C=E5=85=A5=E5=BA=93=E3=80=8D=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E3=80=8C=E6=95=B4=E7=90=86=E3=80=8D=EF=BC=8C=E6=9C=AF?= =?UTF-8?q?=E8=AF=AD=E8=A1=A8=E4=B8=8E=20check:copy=20=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真正入库取决于媒体服务器有没有扫到,本工具只负责整理(元数据、NFO 与图片、按规则归到目录)。 界面与面向用户的服务端文案里「已入库 / 未入库 / 部分入库 / 入库失败 / 入库中 / 入库后 / 原地入库…」 改为「已整理 / 未整理 / 部分整理 / 整理失败 / 整理中 / 整理后 / 原地整理…」;文件管理快速筛选「已整理」、 目录副行「n 个已整理 / 未整理」、季卡片「已整理 x/y」。代码标识符不改,「Emby 已有」这类媒体服务器状态保留。 glossary 加「整理」口径条目;check:copy 拦处理状态语境的「入库」搭配,其它线待替换的文件暂列 TERM_PENDING。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../src/modules/acquisition-organize/index.ts | 10 +++---- .../acquisition-organize/routes/downloads.ts | 2 +- .../routes/library-versions.ts | 2 +- .../acquisition-organize/routes/library.ts | 2 +- .../services/manual-acquisition.ts | 2 +- .../services/organize-exec.ts | 4 +-- .../services/organize-finalize.ts | 4 +-- .../services/replacement.ts | 4 +-- .../acquisition-organize/services/scrape.ts | 4 +-- .../services/subscriptions.ts | 2 +- .../acquisition-organize/services/takeover.ts | 6 ++--- .../server/src/modules/file-manager/mutate.ts | 2 +- .../src/modules/file-manager/organize.ts | 2 +- .../src/modules/file-manager/transfer.ts | 2 +- apps/server/src/modules/file-manager/trash.ts | 2 +- .../organize-not-video.test.ts | 2 +- apps/web/app/api/downloads.ts | 4 +-- apps/web/app/api/records.ts | 2 +- .../components/downloads/board-format.test.ts | 14 +++++----- .../app/components/downloads/board-format.ts | 8 +++--- .../components/downloads/cache-volumes.tsx | 2 +- .../components/downloads/download-detail.tsx | 4 +-- .../downloads/manual-download-dialog.tsx | 6 ++--- .../app/components/episode-mapping/model.ts | 2 +- apps/web/app/components/files/dialogs.tsx | 12 ++++----- apps/web/app/components/files/file-detail.tsx | 4 +-- apps/web/app/components/files/model.test.ts | 6 ++--- apps/web/app/components/files/model.ts | 12 ++++----- .../library/episode-structure-model.ts | 6 ++--- .../library/episode-structure.test.ts | 8 +++--- .../components/library/episode-structure.tsx | 6 ++--- .../app/components/library/identity-views.tsx | 2 +- apps/web/app/components/library/labels.ts | 10 +++---- .../app/components/library/scrape-panel.tsx | 4 +-- apps/web/app/components/library/versions.tsx | 6 ++--- .../app/components/organize-wizard/model.ts | 2 +- .../organize-wizard/organize-wizard.tsx | 6 ++--- .../organize-wizard/step-options.tsx | 6 ++--- .../organize-wizard/step-preview.tsx | 2 +- .../organize/delete-records-dialog.tsx | 2 +- .../organize/record-attention.test.ts | 4 +-- .../components/organize/record-parts.test.ts | 2 +- .../search/search-results-ui.test.ts | 2 +- apps/web/app/components/search/work-card.tsx | 2 +- .../app/components/subscriptions/actions.tsx | 4 +-- .../web/app/components/subscriptions/model.ts | 10 +++---- .../subscriptions/overview-item.tsx | 2 +- .../components/subscriptions/slot-panel.tsx | 2 +- .../subscriptions/subscription-form.tsx | 2 +- .../components/subtitles/subtitle-dialog.tsx | 2 +- apps/web/app/lib/labels.ts | 4 +-- apps/web/app/routes/console/files.tsx | 2 +- apps/web/app/routes/console/library.$id.tsx | 12 ++++----- apps/web/app/routes/console/library.tsx | 2 +- .../routes/console/media.tmdb.$type.$id.tsx | 6 ++--- .../app/routes/console/subscriptions.$id.tsx | 2 +- apps/web/app/routes/login.tsx | 2 +- docs/design/glossary.md | 9 ++++--- packages/contracts/src/api/episode-mapping.ts | 2 +- packages/contracts/src/api/execution.ts | 2 +- packages/contracts/src/api/library.ts | 4 +-- packages/contracts/src/api/organize.ts | 2 +- packages/contracts/src/api/rules.ts | 4 +-- packages/contracts/src/capability.ts | 2 +- packages/contracts/src/reasons.ts | 10 +++---- packages/domain/src/organize/plan.ts | 2 +- scripts/check-copy-punctuation.mjs | 27 +++++++++++++++++++ 67 files changed, 171 insertions(+), 143 deletions(-) diff --git a/apps/server/src/modules/acquisition-organize/index.ts b/apps/server/src/modules/acquisition-organize/index.ts index 5f482143..ce9d726a 100644 --- a/apps/server/src/modules/acquisition-organize/index.ts +++ b/apps/server/src/modules/acquisition-organize/index.ts @@ -133,7 +133,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se key: "cache.threshold", defaults: { name: "下载盘检查", - description: "下载盘超过暂停线时清理已入库的下载", + description: "下载盘超过暂停线时清理已整理的下载", group: "maintain", schedule: every(planMs), }, @@ -155,8 +155,8 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se registerScheduledJob(ctx, { key: "downloads.orchestrate", defaults: { - name: "补提交与补入库", - description: "补交没提交成功的下载,补整理下载完但没入库的任务,重新排队卡住的整理", + name: "补提交与补整理", + description: "补交没提交成功的下载,补整理下载完但没整理的任务,重新排队卡住的整理", group: "download", schedule: every(planMs), }, @@ -165,7 +165,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se const imports = await sweepPendingImports(d); const rec = await recoverStalledRecords(d); return ( - `补提交 ${submitted},补入库 ${imports}` + + `补提交 ${submitted},补整理 ${imports}` + (rec.requeued ? `,重排整理 ${rec.requeued}` : "") + (rec.failed ? `,整理停止 ${rec.failed}` : "") + (rec.removed ? `,移除下载任务 ${rec.removed}` : "") @@ -278,7 +278,7 @@ async function sweepPendingImports(d: AoDeps): Promise { ); if (q) n++; } catch (err) { - d.ctx.logger.warn({ downloadTaskId: r.id, err: { message: errMessage(err) } }, "补入库入队失败"); + d.ctx.logger.warn({ downloadTaskId: r.id, err: { message: errMessage(err) } }, "补整理入队失败"); } } return n; diff --git a/apps/server/src/modules/acquisition-organize/routes/downloads.ts b/apps/server/src/modules/acquisition-organize/routes/downloads.ts index 5fb72e43..2d335e38 100644 --- a/apps/server/src/modules/acquisition-organize/routes/downloads.ts +++ b/apps/server/src/modules/acquisition-organize/routes/downloads.ts @@ -158,7 +158,7 @@ export function registerDownloadRoutes(app: ModuleApp, d: AoDeps) { method: "post", path: "/download-cache/cleanup", scope: "admin", - summary: "入队一轮缓存清理(只清已验证入库且满足保留/H&R 的纳管任务);返回任务 id,进度见任务页", + summary: "入队一轮缓存清理(只清已核验整理且满足保留/H&R 的纳管任务);返回任务 id,进度见任务页", body: z.object({ downloaderInstanceId: z.uuid().optional() }), response: accepted, }, diff --git a/apps/server/src/modules/acquisition-organize/routes/library-versions.ts b/apps/server/src/modules/acquisition-organize/routes/library-versions.ts index 8e3d285b..cd5fca82 100644 --- a/apps/server/src/modules/acquisition-organize/routes/library-versions.ts +++ b/apps/server/src/modules/acquisition-organize/routes/library-versions.ts @@ -14,7 +14,7 @@ export function registerLibraryVersionRoutes(app: ModuleApp, d: AoDeps) { method: "get", path: "/library/{id}/versions", scope: "read", - summary: "作品的入库版本:每季每集的本地文件版本与规格、季 / 作品汇总、与版本目标的比对", + summary: "作品的已整理版本:每季每集的本地文件版本与规格、季 / 作品汇总、与版本目标的比对", params: IdParam, query: LibraryVersionsQuery, response: LibraryVersions, diff --git a/apps/server/src/modules/acquisition-organize/routes/library.ts b/apps/server/src/modules/acquisition-organize/routes/library.ts index 6b938747..3431682e 100644 --- a/apps/server/src/modules/acquisition-organize/routes/library.ts +++ b/apps/server/src/modules/acquisition-organize/routes/library.ts @@ -433,7 +433,7 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) { mode: req.mode, actor: { type: actor?.type === "api_key" ? "api_key" : "user", id: actor?.id ?? null }, }); - if (!recs.length) throw new ApiException("precondition_failed", "作品没有已入库文件。到文件管理里对目录刮削"); + if (!recs.length) throw new ApiException("precondition_failed", "作品没有已整理文件。到文件管理里对目录刮削"); return { mediaEntityId: params.id, taskId: recs[0]?.taskId ?? null, diff --git a/apps/server/src/modules/acquisition-organize/services/manual-acquisition.ts b/apps/server/src/modules/acquisition-organize/services/manual-acquisition.ts index 80252015..99de405f 100644 --- a/apps/server/src/modules/acquisition-organize/services/manual-acquisition.ts +++ b/apps/server/src/modules/acquisition-organize/services/manual-acquisition.ts @@ -63,7 +63,7 @@ export async function planManualAcquisition( if (!topo?.savePath) throw new ApiException("precondition_failed", "没有可用的下载器或保存目录"); const replacementIds = body.importMode === "replace" ? [...new Set(body.replacementAssetIds)] : []; if (body.importMode === "replace" && (!entityId || replacementIds.length === 0)) - throw new ApiException("bad_request", "洗版入库需要明确作品及要替换的已有版本"); + throw new ApiException("bad_request", "洗版需要明确作品及要替换的已有版本"); if (replacementIds.length) { const assets = await db .selectFrom("media_asset") diff --git a/apps/server/src/modules/acquisition-organize/services/organize-exec.ts b/apps/server/src/modules/acquisition-organize/services/organize-exec.ts index 044e5db6..b9f4f5a1 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-exec.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-exec.ts @@ -420,7 +420,7 @@ export async function executeRecord( if (outcome.replaced?.disposition === "left") { await upsertDecision(d, db, { kind: "other", - title: "新版本已入库,旧文件未能处置", + title: "新版本已整理,旧文件未能处置", summary: `${pf.dst} 已替换为新版本;旧文件留在 ${outcome.replaced.path}`, subjectType: "organization_record", subjectId: recordId, @@ -604,7 +604,7 @@ export async function verifyInPlace( ): Promise { const observed = await fsr.proxy.statSafe(path, { root: frozen.rootPath, sentinel: frozen.sentinel }); if (observed.kind !== "ok" || !observed.facts.isFile || observed.facts.isSymbolicLink) { - return { status: "failed", error: { code: "SOURCE_MISSING", message: "原地入库的文件不存在或不可访问" } }; + return { status: "failed", error: { code: "SOURCE_MISSING", message: "原地整理的文件不存在或不可访问" } }; } const planned = frozen.sourceFacts?.[path]; if (observed.facts.size !== size || (planned && observed.facts.mtimeMs !== planned.mtimeMs)) { diff --git a/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts b/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts index fae3c190..a1ea1b65 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts @@ -437,7 +437,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise { const selected = [...new Set(ids)]; - if (!selected.length) throw new Error("洗版入库必须选择旧版本"); + if (!selected.length) throw new Error("洗版必须选择旧版本"); const assets = await d.ctx.db.selectFrom("media_asset").selectAll().where("id", "in", selected).execute(); if ( assets.length !== selected.length || @@ -592,7 +592,7 @@ export async function quarantineNotVideo(d: AoDeps, frozen: FrozenPlan, recordId const names = [...rejectedMains].map((p) => basename(p)); await upsertDecision(d, db, { kind: "other", - title: `${rejectedMains.size} 个文件不是视频,没有入库`, + title: `${rejectedMains.size} 个文件不是视频,没有整理`, summary: `${names.slice(0, 5).join("、")}${names.length > 5 ? " 等" : ""} 没有视频流(伪装或损坏的文件)。${ moved.length ? "已拷进媒体库的文件移入回收站" : "" }${left.length ? `${moved.length ? "," : ""}${left.length} 个文件留在媒体库里待核对` : ""}${ diff --git a/apps/server/src/modules/acquisition-organize/services/scrape.ts b/apps/server/src/modules/acquisition-organize/services/scrape.ts index d50de135..95d9ff26 100644 --- a/apps/server/src/modules/acquisition-organize/services/scrape.ts +++ b/apps/server/src/modules/acquisition-organize/services/scrape.ts @@ -565,7 +565,7 @@ export async function scrapeAfterImport( }); } catch (err) { // 新记录入队失败时 queueScrape 已把它标成 task_enqueue_failed(整理详情可见);这里再兜一条,防止没留下记录 - d.ctx.logger.warn({ entityId, recordId, dir, err: { message: errMessage(err) } }, "入库后自动刮削没排上"); + d.ctx.logger.warn({ entityId, recordId, dir, err: { message: errMessage(err) } }, "整理后自动刮削没排上"); const marked = await d.ctx.db .selectFrom("scrape_record") .select("id") @@ -577,7 +577,7 @@ export async function scrapeAfterImport( } } } catch (err) { - d.ctx.logger.warn({ entityId, recordId, err: { message: errMessage(err) } }, "入库后自动刮削没排上"); + d.ctx.logger.warn({ entityId, recordId, err: { message: errMessage(err) } }, "整理后自动刮削没排上"); await recordScrapeSkipped(d, recordId, "scrape_internal_error", { root, match }); } } diff --git a/apps/server/src/modules/acquisition-organize/services/subscriptions.ts b/apps/server/src/modules/acquisition-organize/services/subscriptions.ts index c5c491cb..d1857262 100644 --- a/apps/server/src/modules/acquisition-organize/services/subscriptions.ts +++ b/apps/server/src/modules/acquisition-organize/services/subscriptions.ts @@ -798,7 +798,7 @@ export async function afterSlotVerified( kind: "other", severity: "info", title: "疑似缺集", - summary: `已入库最高集之前缺:${desc}。总集数未知,订阅继续追踪`.slice(0, 500), + summary: `已整理最高集之前缺:${desc}。总集数未知,订阅继续追踪`.slice(0, 500), subjectType: "acquisition_slot", subjectId: slot.id, dedupeKey: `suspected_missing:${slot.id}`, diff --git a/apps/server/src/modules/acquisition-organize/services/takeover.ts b/apps/server/src/modules/acquisition-organize/services/takeover.ts index bc7ccc65..64e894d9 100644 --- a/apps/server/src/modules/acquisition-organize/services/takeover.ts +++ b/apps/server/src/modules/acquisition-organize/services/takeover.ts @@ -100,7 +100,7 @@ const OUTCOME_TEXT: Record = { export function takeoverSummary(t: TakeoverIdentify | null): string { if (!t) return "下载内容未关联作品。识别或指定作品后再整理"; const what = [t.parsed.title, t.parsed.year].filter(Boolean).join(" "); - return `按文件名识别${what ? `「${what}」` : ""}:${OUTCOME_TEXT[t.result.outcome]}。选择作品、作为临时作品入库,或忽略`; + return `按文件名识别${what ? `「${what}」` : ""}:${OUTCOME_TEXT[t.result.outcome]}。选择作品、作为临时作品整理,或忽略`; } /** 接管识别失败 / 歧义的事项选项:候选(最多 3 个)、选择作品(本地 / TMDB)、作为临时作品入库、忽略 */ @@ -140,7 +140,7 @@ export function identityOptions(t: TakeoverIdentify | null, fallbackTitle: strin t?.parsed.mediaTypeHint === "tv" || t?.parsed.season !== undefined || t?.parsed.episodes !== undefined; out.push({ key: "provisional", - label: "作为临时作品入库", + label: "作为临时作品整理", description: "按文件名建本地临时作品后整理,之后可合并或补外部身份", input: { provisional: { kind: tvLike ? "series" : "movie", title, year: t?.parsed.year ?? null } }, }); @@ -153,7 +153,7 @@ export function identityOptions(t: TakeoverIdentify | null, fallbackTitle: strin export const CONFLICT_OPTIONS: DecisionOptionInput[] = [ { key: "replace", label: "替换", description: "新文件核验通过后替换已有文件,旧文件进回收站" }, { key: "keep_both", label: "保留两者", description: "新文件改名(加序号)后放在旁边,已有文件不动" }, - { key: "skip", label: "跳过", description: "已存在的文件不整理,其余文件照常入库" }, + { key: "skip", label: "跳过", description: "已存在的文件不整理,其余文件照常整理" }, ]; // ── 保留两者:把目标已存在的主视频(连同附属文件)改名为 "<名称> (n)" ── diff --git a/apps/server/src/modules/file-manager/mutate.ts b/apps/server/src/modules/file-manager/mutate.ts index 6b1733ba..c764ef05 100644 --- a/apps/server/src/modules/file-manager/mutate.ts +++ b/apps/server/src/modules/file-manager/mutate.ts @@ -68,7 +68,7 @@ export async function renameEntry( if (hits.length > 0) throw new FilesError( "download_active", - `该路径属于下载任务 ${hits.map((h) => h.taskId).join(", ")},重命名会让下载器找不到文件。先整理入库再改`, + `该路径属于下载任务 ${hits.map((h) => h.taskId).join(", ")},重命名会让下载器找不到文件。先整理再改`, ); const dirAbs = dirname(r.abs); diff --git a/apps/server/src/modules/file-manager/organize.ts b/apps/server/src/modules/file-manager/organize.ts index 2d17a25c..c12896cb 100644 --- a/apps/server/src/modules/file-manager/organize.ts +++ b/apps/server/src/modules/file-manager/organize.ts @@ -306,7 +306,7 @@ export async function organizeFiles( if (action === "in_place") { // 本次保留实际库根与路径,不使用来源配置的搬运目标。 if (!r.def.libraryRootId) - throw new FilesError("bad_request", "原地入库需要文件位于已登记的媒体库目录;请先在设置中登记该目录"); + throw new FilesError("bad_request", "原地整理需要文件位于已登记的媒体库目录;请先在设置中登记该目录"); targetLib = r.def.libraryRootId; } if (action !== "in_place" && it.targetDir) { diff --git a/apps/server/src/modules/file-manager/transfer.ts b/apps/server/src/modules/file-manager/transfer.ts index 742ee55f..72816f78 100644 --- a/apps/server/src/modules/file-manager/transfer.ts +++ b/apps/server/src/modules/file-manager/transfer.ts @@ -230,7 +230,7 @@ export async function transfer( if (hnrBlocks.length > 0) throw new FilesError( "download_active", - `源属于下载任务 ${[...new Set(hnrBlocks.map((h) => h.taskId))].join(", ")},直接移动会让下载器丢失文件。改用整理入库,它会按做种义务决定能否移动`, + `源属于下载任务 ${[...new Set(hnrBlocks.map((h) => h.taskId))].join(", ")},直接移动会让下载器丢失文件。改用整理,它会按做种义务决定能否移动`, ); if (!isAdmin(principal)) { // 每个源按其(目录则为其下)已登记资产求作品集合,逐一核对授权;任一不覆盖 → 整体拒绝并列出未授权项 diff --git a/apps/server/src/modules/file-manager/trash.ts b/apps/server/src/modules/file-manager/trash.ts index e330e0fb..d71ea7f7 100644 --- a/apps/server/src/modules/file-manager/trash.ts +++ b/apps/server/src/modules/file-manager/trash.ts @@ -157,7 +157,7 @@ const BLOCK_ERR: Record< > = { download_active: ["download_active", "属于未完成或做种义务未满足的下载任务"], is_root: ["bad_request", "不能删除根目录或只读根下的内容"], - needs_retire: ["needs_retire", "含已入库文件,需勾选「同时从媒体库移除」"], + needs_retire: ["needs_retire", "含已整理文件,需勾选「同时从媒体库移除」"], needs_unbind: ["needs_unbind", "是作品/季的绑定目录,需勾选「解除绑定」"], root_unavailable: ["root_unavailable", "根不可用"], not_found: ["not_found", "不存在"], diff --git a/apps/server/test/acquisition-organize/organize-not-video.test.ts b/apps/server/test/acquisition-organize/organize-not-video.test.ts index ccccb370..dccf7aae 100644 --- a/apps/server/test/acquisition-organize/organize-not-video.test.ts +++ b/apps/server/test/acquisition-organize/organize-not-video.test.ts @@ -76,7 +76,7 @@ describe.skipIf(!hasPg)("整理:不是视频的文件不留在媒体库", () => .select(["title", "status"]) .where("dedupe_key", "=", `organize_not_video:${plan.recordId}`) .execute(); - expect(decisions).toEqual([{ title: "1 个文件不是视频,没有入库", status: "open" }]); + expect(decisions).toEqual([{ title: "1 个文件不是视频,没有整理", status: "open" }]); // 重放:已结束记录不重复移动 await executeRecord(h.d, plan.recordId!); diff --git a/apps/web/app/api/downloads.ts b/apps/web/app/api/downloads.ts index 7897b7b6..b27ada55 100644 --- a/apps/web/app/api/downloads.ts +++ b/apps/web/app/api/downloads.ts @@ -211,8 +211,8 @@ export const DOWNLOAD_STATUS_LABEL: Record = { paused: "已暂停", stalled: "无进展", completed: "下载完成", - importing: "入库中", - imported: "已入库", + importing: "整理中", + imported: "已整理", failed: "失败", removed: "已移除", missing: "下载器中缺失", diff --git a/apps/web/app/api/records.ts b/apps/web/app/api/records.ts index cb67eb8d..cd78dad8 100644 --- a/apps/web/app/api/records.ts +++ b/apps/web/app/api/records.ts @@ -182,7 +182,7 @@ export const TRIGGER_LABEL: Record = { auto: "自 /** 整理方式 / 文件操作(记录的 action) */ export const ACTION_LABEL = { copy: "复制", - in_place: "原地入库", + in_place: "原地整理", move: "移动", rename: "重命名", relocate: "目录调整", diff --git a/apps/web/app/components/downloads/board-format.test.ts b/apps/web/app/components/downloads/board-format.test.ts index ab5afad2..5d285146 100644 --- a/apps/web/app/components/downloads/board-format.test.ts +++ b/apps/web/app/components/downloads/board-format.test.ts @@ -25,12 +25,12 @@ describe("下载面板展示换算(DL-2)", () => { expect(formatEta(3 * 86400 + 4 * 3600)).toBe("3 天 4 小时"); }); - it("最近一次整理结果:已入库 / 整理中 / 待整理 / 入库失败 / 部分入库;有问题的链到整理记录就地处理", () => { + it("最近一次整理结果:已整理 / 整理中 / 待整理 / 整理失败 / 部分整理;有问题的链到整理记录就地处理", () => { expect(organizeState({ status: "completed", organize: null })).toMatchObject({ label: "待整理", to: null }); expect(organizeState({ status: "importing", organize: null })?.label).toBe("整理中"); - expect(organizeState({ status: "imported", organize: null })?.label).toBe("已入库"); + expect(organizeState({ status: "imported", organize: null })?.label).toBe("已整理"); expect(organizeState({ status: "completed", organize: rec("succeeded") })).toMatchObject({ - label: "已入库", + label: "已整理", tone: "success", to: `/console/organize/${RID}`, }); @@ -44,7 +44,7 @@ describe("下载面板展示换算(DL-2)", () => { }); // 原因码转成人话,不直接给人看 expect(failed).toMatchObject({ - label: "入库失败", + label: "整理失败", tone: "destructive", hint: "没识别出作品。指定作品后重新整理", }); @@ -52,7 +52,7 @@ describe("下载面板展示换算(DL-2)", () => { expect(organizeState({ status: "completed", organize: rec("failed", { reason: "磁盘已满" }) })?.hint).toBe( "磁盘已满", ); - expect(organizeState({ status: "completed", organize: rec("partial") })?.label).toBe("部分入库"); + expect(organizeState({ status: "completed", organize: rec("partial") })?.label).toBe("部分整理"); expect(organizeState({ status: "completed", organize: rec("failed", { reason: "transfer_failed" }) })?.hint).toBe( "文件没有整理成功。在整理记录里查看失败的文件", ); @@ -72,11 +72,11 @@ describe("下载面板展示换算(DL-2)", () => { it("外部任务:没整理过显示 —(null),整理过的照常显示结果", () => { expect(organizeState({ status: "completed", organize: null, managed: false })).toBeNull(); expect(organizeState({ status: "completed", organize: null, managed: true })?.label).toBe("待整理"); - expect(organizeState({ status: "completed", organize: rec("succeeded"), managed: false })?.label).toBe("已入库"); + expect(organizeState({ status: "completed", organize: rec("succeeded"), managed: false })?.label).toBe("已整理"); expect( organizeState({ status: "completed", organize: rec("failed", { reason: "transfer_failed" }), managed: false }) ?.label, - ).toBe("入库失败"); + ).toBe("整理失败"); }); it("连接数:全 0 且总数未知时不显示", () => { diff --git a/apps/web/app/components/downloads/board-format.ts b/apps/web/app/components/downloads/board-format.ts index 7d8e9d0a..cab0ccfc 100644 --- a/apps/web/app/components/downloads/board-format.ts +++ b/apps/web/app/components/downloads/board-format.ts @@ -40,7 +40,7 @@ export function reasonHint(raw: string | null | undefined): string | null { } /** - * 已完成任务的最近一次整理结果:已入库 / 整理中 / 待整理 / 入库失败 / 部分入库。 + * 已完成任务的最近一次整理结果:已整理 / 整理中 / 待整理 / 整理失败 / 部分整理。 * 外部任务(managed=false)没整理过时不算"待整理",返回 null(界面显示 —);整理过的照常显示结果。 */ export function organizeState( @@ -51,9 +51,9 @@ export function organizeState( // 有问题的整理在整理记录详情里就地处理(待处理中心已取消) const to = rec ? consolePath("organize", rec.recordId) : null; if (rec?.status === "succeeded" || (t.status === "imported" && rec?.status !== "failed")) - return { label: "已入库", tone: "success", to, hint: null }; - if (rec?.status === "failed") return { label: "入库失败", tone: "destructive", to, hint: reasonHint(rec.reason) }; - if (rec?.status === "partial") return { label: "部分入库", tone: "warning", to, hint: reasonHint(rec.reason) }; + return { label: "已整理", tone: "success", to, hint: null }; + if (rec?.status === "failed") return { label: "整理失败", tone: "destructive", to, hint: reasonHint(rec.reason) }; + if (rec?.status === "partial") return { label: "部分整理", tone: "warning", to, hint: reasonHint(rec.reason) }; if (rec?.status === "queued" || rec?.status === "running" || t.status === "importing") return { label: "整理中", tone: "neutral", to, hint: null }; return { label: "待整理", tone: "warning", to: rec?.decision ? to : null, hint: null }; diff --git a/apps/web/app/components/downloads/cache-volumes.tsx b/apps/web/app/components/downloads/cache-volumes.tsx index 6295a7d3..3645aa25 100644 --- a/apps/web/app/components/downloads/cache-volumes.tsx +++ b/apps/web/app/components/downloads/cache-volumes.tsx @@ -119,7 +119,7 @@ export function CacheVolumes() { } title="按规则清理下载缓存?" - description="只清理已入库、且满足保留期与 H&R 的本工具任务,其余不动。" + description="只清理已整理、且满足保留期与 H&R 的本工具任务,其余不动。" confirmLabel="清理" onConfirm={() => cleanup.mutateAsync(undefined).then( diff --git a/apps/web/app/components/downloads/download-detail.tsx b/apps/web/app/components/downloads/download-detail.tsx index 3ed2218c..66bbbebe 100644 --- a/apps/web/app/components/downloads/download-detail.tsx +++ b/apps/web/app/components/downloads/download-detail.tsx @@ -96,7 +96,7 @@ export function DownloadDetail({ task, live }: { task: DownloadTask; live?: Down const trace = useDownloadTrace(task, plan.data); const [organizing, setOrganizing] = useState(false); const canOrganize = ["completed", "importing", "imported"].includes(task.status); - // 下载完成但最近一次整理失败:状态行只写"下载完成"会误导,单独说明入库失败 + // 下载完成但最近一次整理失败:状态行只写"下载完成"会误导,单独说明整理失败 const lastRecord = [...(trace.records.data?.items ?? [])].sort((a, b) => (b.startedAt ?? b.createdAt).localeCompare(a.startedAt ?? a.createdAt), )[0]; @@ -143,7 +143,7 @@ export function DownloadDetail({ task, live }: { task: DownloadTask; live?: Down ) : null} {importFailed ? (

- 入库失败:{reasonLine(importFailed.reason)}。 + 整理失败:{reasonLine(importFailed.reason)}。 去整理记录处理 diff --git a/apps/web/app/components/downloads/manual-download-dialog.tsx b/apps/web/app/components/downloads/manual-download-dialog.tsx index 7c96ec19..de8a3567 100644 --- a/apps/web/app/components/downloads/manual-download-dialog.tsx +++ b/apps/web/app/components/downloads/manual-download-dialog.tsx @@ -610,10 +610,10 @@ export function ExistingVersionsSection({

{summary ?? `媒体库已有 ${versions.length} 个版本`}

onModeChange(v === "replace" ? "replace" : "normal")} - allLabel="正常入库,保留已有版本" + allLabel="保留" options={[{ value: "replace", label: "一次性洗版" }]} />
@@ -640,7 +640,7 @@ export function ExistingVersionsSection({ ))}

- 本次新文件成功入库后替换所选旧文件。剧集只替换成功对应的季集,其余保留。 + 本次新文件整理成功后替换所选旧文件。剧集只替换成功对应的季集,其余保留。

) : null} diff --git a/apps/web/app/components/episode-mapping/model.ts b/apps/web/app/components/episode-mapping/model.ts index d3062756..ca7a743a 100644 --- a/apps/web/app/components/episode-mapping/model.ts +++ b/apps/web/app/components/episode-mapping/model.ts @@ -45,7 +45,7 @@ export const VALIDATION: Record同步 {pv.assetLocations} 条文件位置,写一条整理记录。 ) : null} {pv.bindings.length > 0 ? ( - 这是作品或季的绑定目录。改名后旧绑定标为已替代,之后入库用新目录。 + 这是作品或季的绑定目录。改名后旧绑定标为已替代,之后整理用新目录。 ) : null} ) : null} @@ -629,7 +629,7 @@ export function TransferDialog({ 冲突 {conflicts.length}(跳过,不覆盖) ) : null} {plan.pairs.some((p) => p.registered) ? ( - 含已入库文件,执行后同步位置 + 含已整理文件,执行后同步位置 ) : null} {hnrBlocked ? ( @@ -790,7 +790,7 @@ export function DeleteDialog({ {single ? `删除 ${single}?` : `删除 ${items.length} 项?`} - 已检查做种任务、已入库文件和路径绑定。下载器里的任务不受影响。 + 已检查做种任务、已整理文件和路径绑定。下载器里的任务不受影响。
{check.isPending ? ( @@ -837,7 +837,7 @@ export function DeleteDialog({

))} {c.assets.length ? ( -

已入库文件 {c.assets.length} 个(删除后从媒体库移除,已满足的收录版本退回待处理)

+

已整理文件 {c.assets.length} 个(删除后从媒体库移除,已满足的收录版本退回待处理)

) : null} {c.bindings.map((bd) => (

@@ -869,7 +869,7 @@ export function DeleteDialog({ 解除路径绑定 - 绑定标为已替代,该作品下次入库重新选目录。 + 绑定标为已替代,该作品下次整理重新选目录。 @@ -1139,7 +1139,7 @@ export function ScrapeDialog({ items, root, onClose }: { items: FileEntry[]; roo 刮削{single ? ` · ${single.name}` : ` · ${items.length} 项`} - 按路径识别作品,在所在目录写 NFO 和图片,Emby、Jellyfin、Kodi 直接读取。没入库的也能刮削。 + 按路径识别作品,在所在目录写 NFO 和图片,Emby、Jellyfin、Kodi 直接读取。没整理的也能刮削。 {!started ? ( diff --git a/apps/web/app/components/files/file-detail.tsx b/apps/web/app/components/files/file-detail.tsx index d1825fd9..7916a78e 100644 --- a/apps/web/app/components/files/file-detail.tsx +++ b/apps/web/app/components/files/file-detail.tsx @@ -110,7 +110,7 @@ export function FileDetail({ value: e.type === "dir" ? e.dirSummary - ? `${e.dirSummary.videoCount}${e.dirSummary.partial ? "+" : ""} 个视频 · ${e.dirSummary.registeredCount} 个已入库` + ? `${e.dirSummary.videoCount}${e.dirSummary.partial ? "+" : ""} 个视频 · ${e.dirSummary.registeredCount} 个已整理` : "—" : formatBytes(e.size, 2), }, @@ -135,7 +135,7 @@ export function FileDetail({ {e.asset ? ( - + { it("状态徽标带文字,H&R 未满足为 warning,逃逸为 destructive", () => { - expect(entryStatusView({ ...base, status: "registered" })?.label).toBe("已入库"); + expect(entryStatusView({ ...base, status: "registered" })?.label).toBe("已整理"); expect( entryStatusView({ ...base, @@ -125,7 +125,7 @@ describe("状态与禁用原因", () => { expect(opDisabledReason("move", seeding, lib)).toMatch(/H&R/); expect(opDisabledReason("move", base, { kind: "library", writable: false })).toMatch(/只读/); expect(opDisabledReason("rename", base, { kind: "download", writable: false })).toBeNull(); - expect(opDisabledReason("scrape", base, lib)).toMatch(/未登记/); + expect(opDisabledReason("scrape", base, lib)).toMatch(/整理后才能/); expect(opDisabledReason("writeNfo", base, lib)).toBeNull(); expect(opDisabledReason("writeNfo", base, { kind: "library", writable: false })).toMatch(/只读/); expect(opDisabledReason("copyPath", { ...base, escape: true }, lib)).toBeNull(); @@ -272,7 +272,7 @@ describe("v2 菜单与副行", () => { type: "dir" as const, dirSummary: { videoCount: 13, registeredCount: 0, partial: false }, })?.text, - ).toBe("13 个视频 · 未入库"); + ).toBe("13 个视频 · 未整理"); // 没有 parsedHint(服务端解析不出标题)时不显示副行,前端不自己解析 expect(entrySubline({ ...video, parsedHint: null })).toBeNull(); }); diff --git a/apps/web/app/components/files/model.ts b/apps/web/app/components/files/model.ts index 68c4428f..ce7902f6 100644 --- a/apps/web/app/components/files/model.ts +++ b/apps/web/app/components/files/model.ts @@ -26,7 +26,7 @@ export function entryStatusView(e: Pick): string | null { - return s.inLibrary > 0 ? `已入库 ${s.inLibrary}/${s.episodeCount}` : null; + return s.inLibrary > 0 ? `已整理 ${s.inLibrary}/${s.episodeCount}` : null; } const pad = (n: number) => String(n).padStart(2, "0"); @@ -63,5 +63,5 @@ export function originText( /** 切换确认的说明:改的是作品设置,影响季表、集数映射、订阅与刮削 */ export function switchDescription(target: string): string { - return `作品改按「${target}」分季:季与集按 TMDB 原季集迁到新位置,已入库文件跟着走;集数映射、订阅和刮削都按新季集,已绑定的资源会重新换算。之后可以再切回来。`; + return `作品改按「${target}」分季:季与集按 TMDB 原季集迁到新位置,已整理文件跟着走;集数映射、订阅和刮削都按新季集,已绑定的资源会重新换算。之后可以再切回来。`; } diff --git a/apps/web/app/components/library/episode-structure.test.ts b/apps/web/app/components/library/episode-structure.test.ts index 268cd437..c0892e63 100644 --- a/apps/web/app/components/library/episode-structure.test.ts +++ b/apps/web/app/components/library/episode-structure.test.ts @@ -110,7 +110,7 @@ describe("季集结构:纯函数", () => { it("季卡片文案与原季集对照", () => { expect(seasonLine({ airDate: "2024-07-07", episodeCount: 13 })).toBe("2024 · 13 集"); expect(inLibraryText({ inLibrary: 0, episodeCount: 13 })).toBeNull(); - expect(inLibraryText({ inLibrary: 3, episodeCount: 13 })).toBe("已入库 3/13"); + expect(inLibraryText({ inLibrary: 3, episodeCount: 13 })).toBe("已整理 3/13"); expect(originText(2, { episodeNumber: 2, tmdbSeason: 1, tmdbEpisode: 14 })).toBe("原 S01E14"); expect(originText(1, { episodeNumber: 2, tmdbSeason: 1, tmdbEpisode: 2 })).toBeNull(); }); @@ -131,14 +131,14 @@ describe("季集结构:组件", () => { expect(html).toContain('aria-label="当前"'); }); - it("季卡片:季名、首播年 · 集数、已入库 x/y,选中的高亮", () => { + it("季卡片:季名、首播年 · 集数、已整理 x/y,选中的高亮", () => { const html = renderToStaticMarkup( createElement(SeasonCardGrid, { seasons: structure.seasons, selected: 2, onPick: () => {} }), ); expect(html).toContain("第一部"); expect(html).toContain("2024 · 12 集"); - expect(html).toContain("已入库 1/13"); - expect(html).not.toContain("已入库 0/12"); + expect(html).toContain("已整理 1/13"); + expect(html).not.toContain("已整理 0/12"); expect(html).toMatch(/aria-pressed="true"[^>]*class="[^"]*pill-selected/); }); diff --git a/apps/web/app/components/library/episode-structure.tsx b/apps/web/app/components/library/episode-structure.tsx index ef0473cf..653a5ca8 100644 --- a/apps/web/app/components/library/episode-structure.tsx +++ b/apps/web/app/components/library/episode-structure.tsx @@ -141,7 +141,7 @@ export function StructurePreview({ seasons }: { seasons: EpisodeStructureSeason[ overview: null, summary: ( = s.episodeCount ? "success" : "neutral"}> - 已入库 {s.inLibrary}/{s.episodeCount} + 已整理 {s.inLibrary}/{s.episodeCount} ), }} @@ -171,7 +171,7 @@ export function StructurePreview({ seasons }: { seasons: EpisodeStructureSeason[ runtime: null, stillPath: null, voteAverage: null, - status: e.inLibrary ? { label: "已入库", tone: "success" } : { label: "未入库", tone: "neutral" }, + status: e.inLibrary ? { label: "已整理", tone: "success" } : { label: "未整理", tone: "neutral" }, extra: originText(s.seasonNumber, e) ? (

{originText(s.seasonNumber, e)} @@ -228,7 +228,7 @@ export function EpisodeStructureSwitcher({ entityId, children }: { entityId: str

- 预览「{target?.label ?? DEFAULT_STRUCTURE_LABEL}」的分季,已入库按 TMDB 原季集对照。 + 预览「{target?.label ?? DEFAULT_STRUCTURE_LABEL}」的分季,已整理按 TMDB 原季集对照。

@@ -207,7 +207,7 @@ export function StepOptions({
-
+
刷新元数据 @@ -216,7 +216,7 @@ export function StepOptions({ set({ refreshMetadata: v })} - aria-label="入库后刷新元数据" + aria-label="整理后刷新元数据" />

diff --git a/apps/web/app/components/organize-wizard/step-preview.tsx b/apps/web/app/components/organize-wizard/step-preview.tsx index 303dd6b7..2007ebcc 100644 --- a/apps/web/app/components/organize-wizard/step-preview.tsx +++ b/apps/web/app/components/organize-wizard/step-preview.tsx @@ -112,7 +112,7 @@ function FileLine({ f, roots, nested }: { f: GroupFile; roots: FileRoot[]; neste ) : f.target === f.source ? ( 原地登记 ) : !f.target ? ( - 不入库 + 不整理 ) : null; return (

  • diff --git a/apps/web/app/components/organize/delete-records-dialog.tsx b/apps/web/app/components/organize/delete-records-dialog.tsx index 0bfb6f6a..4f63aadb 100644 --- a/apps/web/app/components/organize/delete-records-dialog.tsx +++ b/apps/web/app/components/organize/delete-records-dialog.tsx @@ -138,7 +138,7 @@ function Summary({ {files.length ? (

    {files.join("、")}({formatBytes(plan.bytes, 1)})会移入回收站,可以在回收站还原。 - {plan.assets ? `其中 ${plan.assets} 个是已入库文件,会从媒体库移除。` : ""} + {plan.assets ? `其中 ${plan.assets} 个是已整理文件,会从媒体库移除。` : ""}

    ) : (

    {withFiles ? "没有找到还在的文件,只删记录。" : "只删记录,文件不动。"}

    diff --git a/apps/web/app/components/organize/record-attention.test.ts b/apps/web/app/components/organize/record-attention.test.ts index c8a2f737..a066015f 100644 --- a/apps/web/app/components/organize/record-attention.test.ts +++ b/apps/web/app/components/organize/record-attention.test.ts @@ -26,7 +26,7 @@ const decision = (over: Partial> = {}) { key: "candidate_0", label: "Foo (2019)", input: { candidate: { mediaEntityId: R } } }, { key: "select_entity", label: "选择作品(本地)", input: { requires: "mediaEntityId" } }, { key: "select_tmdb", label: "选择作品(TMDB)", input: { requires: "tmdb" } }, - { key: "provisional", label: "作为临时作品入库", input: {} }, + { key: "provisional", label: "作为临时作品整理", input: {} }, { key: "dismiss", label: "忽略" }, ], agentRunId: null, @@ -54,7 +54,7 @@ describe("整理记录行内处理", () => { for (const t of ["指定作品", "让智能体识别", "重新整理", "忽略", "Foo (2019)"]) expect(out).toContain(t); // 「选择作品(本地 / TMDB)」并进「指定作品」,不单列;临时作品不在行内 expect(out).not.toContain("选择作品(本地)"); - expect(out).not.toContain("作为临时作品入库"); + expect(out).not.toContain("作为临时作品整理"); }); it("智能体识别中:显示进度与会话入口,不再给指定 / 识别按钮", () => { diff --git a/apps/web/app/components/organize/record-parts.test.ts b/apps/web/app/components/organize/record-parts.test.ts index 7cc3a789..7bb595e5 100644 --- a/apps/web/app/components/organize/record-parts.test.ts +++ b/apps/web/app/components/organize/record-parts.test.ts @@ -89,7 +89,7 @@ describe("整理记录列表:整理方式、分类、分组", () => { "移动", "复制", "硬链接", - "原地入库", + "原地整理", ]); }); diff --git a/apps/web/app/components/search/search-results-ui.test.ts b/apps/web/app/components/search/search-results-ui.test.ts index ae04c23b..dd1b620a 100644 --- a/apps/web/app/components/search/search-results-ui.test.ts +++ b/apps/web/app/components/search/search-results-ui.test.ts @@ -88,7 +88,7 @@ describe("库内状态文案", () => { it("未收录 / 已建档 / 已收录 / 正在下载;收录版本满足与缺失", () => { expect(inventoryBadge(inv({})).label).toBe("不在媒体库"); expect(inventoryBadge(inv({ state: "tracked" })).label).toBe("在媒体库 · 未下载"); - expect(inventoryBadge(inv({ state: "owned", versionCount: 2 })).label).toBe("已入库 · 2 个版本"); + expect(inventoryBadge(inv({ state: "owned", versionCount: 2 })).label).toBe("已整理 · 2 个版本"); expect(inventoryBadge(inv({ state: "downloading", downloadingCount: 1 })).label).toBe("正在下载 1 个"); expect( slotLine(inv({ state: "owned", slotTotal: 2, slotSatisfied: 1, missing: [{ slotId: ID, name: "2160p" }] })), diff --git a/apps/web/app/components/search/work-card.tsx b/apps/web/app/components/search/work-card.tsx index f862ec17..47527f97 100644 --- a/apps/web/app/components/search/work-card.tsx +++ b/apps/web/app/components/search/work-card.tsx @@ -37,7 +37,7 @@ export function inventoryBadge(inv: WorkInventory): { label: string; tone: Statu case "downloading": return { label: `正在下载 ${inv.downloadingCount} 个`, tone: "warning" }; case "owned": - return { label: `已入库 · ${inv.versionCount} 个版本`, tone: "success" }; + return { label: `已整理 · ${inv.versionCount} 个版本`, tone: "success" }; default: return { label: "在媒体库 · 未下载", tone: "neutral" }; } diff --git a/apps/web/app/components/subscriptions/actions.tsx b/apps/web/app/components/subscriptions/actions.tsx index 5d86801b..69408497 100644 --- a/apps/web/app/components/subscriptions/actions.tsx +++ b/apps/web/app/components/subscriptions/actions.tsx @@ -186,7 +186,7 @@ export function SubscriptionMenu({ open={confirmReset} onOpenChange={setConfirmReset} title="重置这个订阅的进度?" - description="取消还没提交、或下载任务已不在的计划,清掉延后记录,然后重新评估。仍在下载的任务、已入库文件和总集数保留。" + description="取消还没提交、或下载任务已不在的计划,清掉延后记录,然后重新评估。仍在下载的任务、已整理文件和总集数保留。" confirmLabel="重置" onConfirm={() => ops.reset(sub.id).catch((e) => { @@ -216,7 +216,7 @@ export function DeleteSubscriptionsDialog({ open={open} onOpenChange={onOpenChange} title={ids.length > 1 ? `删除 ${ids.length} 个订阅?` : "删除这个订阅?"} - description="不再搜索和追更。已入库文件、收录版本和下载记录保留,可在订阅历史里重新订阅。" + description="不再搜索和追更。已整理文件、收录版本和下载记录保留,可在订阅历史里重新订阅。" confirmLabel="删除" destructive onConfirm={() => diff --git a/apps/web/app/components/subscriptions/model.ts b/apps/web/app/components/subscriptions/model.ts index 005062b8..44bfdc9e 100644 --- a/apps/web/app/components/subscriptions/model.ts +++ b/apps/web/app/components/subscriptions/model.ts @@ -65,7 +65,7 @@ export function slotBadge(s: SubscriptionSlotSummary): ToneLabel { if (s.inflight > 0) return { label: `在途 ${s.inflight}`, tone: "neutral" }; if (s.missing > 0) return { label: `缺 ${s.missing}`, tone: "destructive" }; if (s.total !== null && s.total > 0 && s.have >= s.total) return { label: "已齐", tone: "success" }; - return { label: s.have > 0 ? `已入库 ${s.have}` : "未开始", tone: "neutral" }; + return { label: s.have > 0 ? `已整理 ${s.have}` : "未开始", tone: "neutral" }; } /** 名称列表折叠:≤max 个名 + "+N";空 = emptyLabel */ @@ -121,7 +121,7 @@ export function cellKind(c: CoverageCell): CellKind { } export const CELL_LABEL: Record = { - have: "已入库", + have: "已整理", upgradable: "可升级", inflight: "在途", deferred: "延后", @@ -429,9 +429,9 @@ export function draftToParams( // ── 日历(设计 §4)── export const CALENDAR_STATE_LABEL: Record = { - have: "已入库", - partial: "部分入库", - missing: "未入库", + have: "已整理", + partial: "部分整理", + missing: "未整理", upcoming: "未播出", }; diff --git a/apps/web/app/components/subscriptions/overview-item.tsx b/apps/web/app/components/subscriptions/overview-item.tsx index b2d59b31..0c682c30 100644 --- a/apps/web/app/components/subscriptions/overview-item.tsx +++ b/apps/web/app/components/subscriptions/overview-item.tsx @@ -57,7 +57,7 @@ export function ProgressBlock({ x, className }: { x: Pick {unknown ? null : ( <> diff --git a/apps/web/app/components/subscriptions/slot-panel.tsx b/apps/web/app/components/subscriptions/slot-panel.tsx index 9d2da3f9..59d77a94 100644 --- a/apps/web/app/components/subscriptions/slot-panel.tsx +++ b/apps/web/app/components/subscriptions/slot-panel.tsx @@ -110,7 +110,7 @@ export function SlotPanel({ disabled={setOrder.isPending} renderExtra={(r, i) => ( - 已入库 {r.summary.have} + 已整理 {r.summary.have} 缺 {r.summary.missing} 在途 {r.summary.inflight} {i === 0 ? null : {r.autoFill ? "自动补全" : "只登记缺失"}} diff --git a/apps/web/app/components/subscriptions/subscription-form.tsx b/apps/web/app/components/subscriptions/subscription-form.tsx index e9286ae4..72e27b3c 100644 --- a/apps/web/app/components/subscriptions/subscription-form.tsx +++ b/apps/web/app/components/subscriptions/subscription-form.tsx @@ -694,7 +694,7 @@ export function SubscriptionFormDialog({ void targets.refetch()} /> ) : !list.length ? ( - + ) : (
    setTab(v as "upload" | "online")}> diff --git a/apps/web/app/lib/labels.ts b/apps/web/app/lib/labels.ts index d5ceae79..258f83c4 100644 --- a/apps/web/app/lib/labels.ts +++ b/apps/web/app/lib/labels.ts @@ -30,8 +30,8 @@ const DOWNLOAD: Record = { paused: { label: "已暂停", tone: "warning" }, stalled: { label: "无进展", tone: "warning" }, completed: { label: "下载完成", tone: "success" }, - importing: { label: "入库中", tone: "neutral" }, - imported: { label: "已入库", tone: "success" }, + importing: { label: "整理中", tone: "neutral" }, + imported: { label: "已整理", tone: "success" }, failed: { label: "失败", tone: "destructive" }, removed: { label: "已移除", tone: "neutral" }, missing: { label: "丢失", tone: "destructive" }, diff --git a/apps/web/app/routes/console/files.tsx b/apps/web/app/routes/console/files.tsx index 62c718e3..76ef9bda 100644 --- a/apps/web/app/routes/console/files.tsx +++ b/apps/web/app/routes/console/files.tsx @@ -717,7 +717,7 @@ export default function FilesPage() { key: "scrape", label: "刷新元数据", icon: RefreshCw, - disabled: selected.every((e) => e.asset) ? null : "只对已入库的文件", + disabled: selected.every((e) => e.asset) ? null : "只对已整理的文件", onSelect: () => act("scrape", selected), }); items.push({ diff --git a/apps/web/app/routes/console/library.$id.tsx b/apps/web/app/routes/console/library.$id.tsx index b4f23a5c..86d5fdc1 100644 --- a/apps/web/app/routes/console/library.$id.tsx +++ b/apps/web/app/routes/console/library.$id.tsx @@ -141,7 +141,7 @@ function PathsPanel({ detail }: { detail: LibraryDetail }) { @@ -608,7 +608,7 @@ function UnitsPanel({ entity, detail }: { entity: ReturnType covered.has(u.id) ? ( - 已入库 + 已整理 ) : ( — ), @@ -711,10 +711,10 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten if (hit) return hit; const season = seasonUnitOf(u.seasonNumber!); if (covered.has(u.id) || (season && covered.has(season.id))) - return { label: "已入库", tone: "success", have: true }; + return { label: "已整理", tone: "success", have: true }; const air = finalOf(u, "airDate")?.value ?? u.airDate; if (typeof air === "string" && air > today()) return { label: "未播出", tone: "neutral", have: false }; - return { label: sub ? "缺失" : "未入库", tone: sub ? "destructive" : "neutral", have: false }; + return { label: sub ? "缺失" : "未整理", tone: sub ? "destructive" : "neutral", have: false }; }; const haveIn = (n: number) => episodes.filter((u) => u.seasonNumber === n && statusOf(u)?.have).length; const initial = @@ -747,7 +747,7 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten description={ sub ? "库内状态按订阅的覆盖矩阵;标题与简介按语言回退链取值" - : "没有订阅时只看是否已入库;标题与简介按语言回退链取值" + : "没有订阅时只看是否已整理;标题与简介按语言回退链取值" } actions={