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/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/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/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/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 54001624..cd78dad8 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 = { @@ -176,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/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-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-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..992c3966 --- /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 > 0 || !!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/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={ - ) : 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)} diff --git a/apps/web/app/routes/console/subscriptions.$id.tsx b/apps/web/app/routes/console/subscriptions.$id.tsx index 5e25f1cf..6dde9616 100644 --- a/apps/web/app/routes/console/subscriptions.$id.tsx +++ b/apps/web/app/routes/console/subscriptions.$id.tsx @@ -48,7 +48,7 @@ const TOTAL_SOURCE: Record = { tmdb: "TMDB", injected: "注入", manual: "手动", - covered_floor: "按已入库", + covered_floor: "按已整理", previous: "沿用", }; diff --git a/apps/web/app/routes/login.tsx b/apps/web/app/routes/login.tsx index 135d5a65..a0bf864d 100644 --- a/apps/web/app/routes/login.tsx +++ b/apps/web/app/routes/login.tsx @@ -253,7 +253,7 @@ export default function LoginPage() {

    媒体自动化

    - 订阅、下载、整理入库 + 订阅、下载、整理

    每一步都有记录。拿不准的事项进待处理,等你决定。 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/design/glossary.md b/docs/design/glossary.md index e2f6e14c..65785e2a 100644 --- a/docs/design/glossary.md +++ b/docs/design/glossary.md @@ -24,17 +24,18 @@ | Approval | 审批(名词);按钮用"批准 / 拒绝" | 授权记录 | | AcquisitionPlan(下载计划,DL-3 起界面不单独命名) | 下载;还没提交到下载器的按在等什么写状态:「等你批准」「等磁盘空间」「等开启自动化」「准备提交」;没提交就结束的写「没提交成功 / 已取消 / 已过期」+ 原因;来由写「为什么下这个」;智能体动作计数写"准备下载 N 个资源" | 下载计划、草稿、已预留(界面) | | DownloadTask | 下载任务 | 下载单 | -| Organization record | 整理记录;动作叫"整理",结果状态叫"已入库" | 转移、入库记录 | +| Organization record | 整理记录;动作叫"整理",结果状态叫「已整理」;整理页只有这一页,排队与执行中的任务在标题栏「队列 N」面板里(不叫「整理队列」标签页) | 转移、入库记录 | +| 本工具对文件的处理(识别、元数据、NFO 与图片、按规则归到媒体库目录) | 整理;状态「已整理 / 未整理 / 部分整理 / 整理失败 / 整理中」,数量写「已整理 x/y」;方式叫「整理方式」;之后的动作写「整理后…」(整理后刮削、整理后删源);只登记不搬的叫「原地整理」。2026-10-06 起「入库」不再表示处理状态:媒体服务器(Emby / Jellyfin / Plex)有没有扫到由它自己决定,本工具不判断;真要说媒体服务器时写「媒体服务器已扫描」。`check:copy` 拦「已入库 / 未入库 / 入库后 / 入库方式…」等搭配;代码标识符(`imported`、`media_asset`、`verifyAndImport`)不改 | 入库、已入库、未入库、部分入库、入库失败、入库中、入库方式、入库后、原地入库、核验入库 | | 在目录里就地写 NFO 与图片(scrapeMedia,SCR-1 / SCR-2) | 刮削;文件管理菜单「刮削…」,作品详情「重新刮削」「覆盖重写」;覆盖方式叫「更新 / 只补缺失 / 全部覆盖」(更新 = 只替换本工具写过且没被改过的文件);认不准作品时"选一个再刮削";只更新本工具字段的叫「刷新元数据」 | 投影、NFO 投影、写元数据(界面) | -| 整理方式 | 复制 / 移动 / 硬链接 | Copy / Move | +| 整理方式 | 复制 / 移动 / 硬链接 / 原地整理 | Copy / Move、原地入库 | | Library root | 媒体库目录 | 库根、媒体库根、根目录 | | 文件管理可浏览的目录(库根 + 下载器路径映射) | 登记目录 | 登记根、根 | -| MediaAsset / AssetLocation | 文件 / 已入库文件;位置叫"文件位置" | 资产、资产位置、媒体文件记录 | +| MediaAsset / AssetLocation | 文件 / 已整理文件;位置叫"文件位置";文件管理快速筛选「已整理」,目录副行「n 个已整理 / 未整理」 | 资产、资产位置、媒体文件记录、已入库文件 | | ExternalIdentity | 外部身份(TMDB/TVDB/IMDb ID) | 外部 ID 映射、外部标识 | | 未确认身份的文件/资源 | 未识别 | 待识别、待识别池 | | identity_ambiguous / 整理时认不出作品的待处理事项 | 没识别出作品;主按钮"指定作品" | 身份歧义、识别有歧义、无法确定作品、待确认身份 | | 作品登记进本地库(不订阅) | 加入媒体库;状态"在媒体库 · 未下载"/"不在媒体库" | 建档、已建档、未收录 | -| 已完成下载任务的整理状态(下载面板) | 待整理 / 整理中 / 已入库 / 部分入库 / 入库失败;失败原因用原因码表的一句话 | 未整理、导入失败、转移失败 | +| 已完成下载任务的整理状态(下载面板) | 待整理 / 整理中 / 已整理 / 部分整理 / 整理失败;失败原因用原因码表的一句话 | 未整理(下载面板里用「待整理」)、导入失败、转移失败、已入库、入库失败 | | 本工具提交并跟踪的下载任务(managed) | 本工具管理;反面叫"外部任务";筛选"来源:本工具 / 其他" | 纳管、未纳管、接管 | | 媒体库目录里的 `.uhd-root` | 标记文件;按钮"检查目录";失败说"目录不可访问或未挂载" | 哨兵、哨兵文件 | | 作品 / 版本 / 文件 / 外部身份的本地记录总称 | 不单独命名 | 账本、本地账本 | 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,刷新不丢 | | 单条:重新整理 | 已有,补 | 详情已有;列表行菜单新增。走共用弹窗(配置、展开预览、加入队列) | | 单条:智能助手整理 | 已有(不同) | 「让智能体排查」:失败 / 部分成功的记录在行菜单和详情里 | | 单条:删除(只删记录 / 连源文件 / 连媒体库文件 / 全部) | 新增 | 确认框里两个勾选项,先核对再执行,写清删几个文件、多大、哪些删不了、会不会被自动整理再处理 | diff --git a/packages/contracts/src/api/episode-mapping.ts b/packages/contracts/src/api/episode-mapping.ts index 785e1310..487f6c23 100644 --- a/packages/contracts/src/api/episode-mapping.ts +++ b/packages/contracts/src/api/episode-mapping.ts @@ -64,7 +64,7 @@ export const episodeMappingApi = { method: "get", path: "/media-entities/{id}/episode-structure", scope: "read", - summary: "某套季集结构(TMDB 默认季或剧集组)的季卡片与每集,带本地已入库情况(纯读)", + summary: "某套季集结构(TMDB 默认季或剧集组)的季卡片与每集,带本地已整理情况(纯读)", params: IdParam, query: em.EpisodeStructureQuery, response: em.EpisodeStructureSeasons, diff --git a/packages/contracts/src/api/execution.ts b/packages/contracts/src/api/execution.ts index 9ef097b6..db7f863b 100644 --- a/packages/contracts/src/api/execution.ts +++ b/packages/contracts/src/api/execution.ts @@ -60,7 +60,7 @@ export const executionApi = { method: "post", path: "/acquisition-plans", scope: "organize.execute", - summary: "独立手动下载:不要求订阅,支持未知资源确认、下载器与目录选择,以及本次正常或洗版入库", + summary: "独立手动下载:不要求订阅,支持未知资源确认、下载器与目录选择,以及本次正常整理或洗版", body: execution.ManualAcquisitionRequest, response: execution.ManualAcquisitionResult, }), diff --git a/packages/contracts/src/api/library.ts b/packages/contracts/src/api/library.ts index 7fbaf76b..c3d63e6d 100644 --- a/packages/contracts/src/api/library.ts +++ b/packages/contracts/src/api/library.ts @@ -99,7 +99,7 @@ export const libraryApi = { method: "get", path: "/library/{id}/versions", scope: "read", - summary: "作品的入库版本:每季每集的本地文件版本与规格、季 / 作品汇总、与版本目标的比对", + summary: "作品的已整理版本:每季每集的本地文件版本与规格、季 / 作品汇总、与版本目标的比对", params: IdParam, query: libraryVersions.LibraryVersionsQuery, response: libraryVersions.LibraryVersions, @@ -163,7 +163,7 @@ export const libraryApi = { method: "post", path: "/download-cache/cleanup", scope: "admin", - summary: "立即按阈值清理下载缓存(只清已验证入库且满足保留/H&R 的纳管任务)", + summary: "立即按阈值清理下载缓存(只清已核验整理且满足保留/H&R 的纳管任务)", body: z.object({ downloaderInstanceId: z.uuid().optional() }), response: z.object({ report: z.record(z.string(), z.unknown()) }), }), diff --git a/packages/contracts/src/api/organize.ts b/packages/contracts/src/api/organize.ts index 8011ee50..b5c0c465 100644 --- a/packages/contracts/src/api/organize.ts +++ b/packages/contracts/src/api/organize.ts @@ -196,7 +196,7 @@ export const organizeApi = { method: "post", path: "/files/organize", scope: "organize.execute", - summary: "按作品分组整理入库(每组走 planImport,与 /organization-records 同实现)", + summary: "按作品分组整理(每组走 planImport,与 /organization-records 同实现)", body: files.FilesOrganizeRequest, response: files.FilesOrganizeResult, }), diff --git a/packages/contracts/src/api/rules.ts b/packages/contracts/src/api/rules.ts index 3d88882a..44b254e6 100644 --- a/packages/contracts/src/api/rules.ts +++ b/packages/contracts/src/api/rules.ts @@ -29,7 +29,7 @@ export const rulesApi = { path: "/subscriptions/calendar", scope: "read", summary: - "订阅日历:追踪中订阅范围内集的播出日期(同日同季聚合)与入库状态,电影取上映日期;没有播出日期的列在 unknown。from/to 为 YYYY-MM-DD,跨度不超过 62 天", + "订阅日历:追踪中订阅范围内集的播出日期(同日同季聚合)与整理状态,电影取上映日期;没有播出日期的列在 unknown。from/to 为 YYYY-MM-DD,跨度不超过 62 天", query: z.object({ from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), @@ -41,7 +41,7 @@ export const rulesApi = { method: "post", path: "/subscriptions/{id}/reset", scope: "admin", - summary: "重置进度:取消已预留未提交、下载任务已不在的计划,清延后记录,立即重评;仍在下载的计划与已入库资产保留", + summary: "重置进度:取消已预留未提交、下载任务已不在的计划,清延后记录,立即重评;仍在下载的计划与已整理资产保留", params: IdParam, response: policy.SubscriptionResetResult, }), diff --git a/packages/contracts/src/capability.ts b/packages/contracts/src/capability.ts index 0d8035d6..0dd8ab62 100644 --- a/packages/contracts/src/capability.ts +++ b/packages/contracts/src/capability.ts @@ -221,7 +221,7 @@ export const CAPABILITY_CATALOG: Record "organize.plan": d( "organize.plan", "生成整理计划", - "生成并保存整理计划:冻结源/目标路径、整理方式与前置条件;不执行。下载完成后整理入库前用;只生成计划不动文件", + "生成并保存整理计划:冻结源/目标路径、整理方式与前置条件;不执行。下载完成后整理前用;只生成计划不动文件", "preview", "OrganizePlan", "organize.read", 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; diff --git a/packages/contracts/src/reasons.ts b/packages/contracts/src/reasons.ts index 49ace094..5a25e81a 100644 --- a/packages/contracts/src/reasons.ts +++ b/packages/contracts/src/reasons.ts @@ -9,9 +9,9 @@ export type ReasonText = { }; const REASONS: Record = { - in_place_failed: { text: "原地入库未完成", action: "在整理记录里查看未登记的文件" }, + in_place_failed: { text: "原地整理未完成", action: "在整理记录里查看未登记的文件" }, in_place_outside_library: { text: "文件不在所选媒体库目录内", action: "先登记该目录为媒体库,或改用复制/移动" }, - in_place_cannot_replace: { text: "原地入库不替换已有版本", action: "洗版请使用复制/移动入库" }, + in_place_cannot_replace: { text: "原地整理不替换已有版本", action: "洗版改用复制/移动整理" }, source_config_mismatch: { text: "文件或媒体类型不属于所选整理来源", action: "检查来源目录和媒体类型" }, classification_unavailable: { text: "暂时无法确定媒体分类", action: "检查 TMDB 连接或选择固定分类后重新整理" }, identity_unknown: { text: "没识别出作品", action: "指定作品后重新整理" }, @@ -25,7 +25,7 @@ const REASONS: Record = { hnr_unsatisfied: { text: "做种时间还不够,不能移动", action: "改用复制,或等做种完成" }, hnr_unknown: { text: "不确定做种要求是否满足", action: "改用复制,或确认站点规则" }, orphan_sidecar: { text: "字幕等附属文件找不到对应视频" }, - no_main_video: { text: "没有可入库的主视频" }, + no_main_video: { text: "没有可整理的主视频" }, below_min_size: { text: "文件小于最小体积,没有识别", action: "需要时在设置里调低最小体积" }, too_small: { text: "文件小于最小体积" }, source_path_unknown: { text: "下载器没有报告保存路径", action: "在下载器里确认任务还在" }, @@ -55,7 +55,7 @@ const REASONS: Record = { blocked_by_conflict: { text: "有冲突,暂不整理" }, transfer_failed: { text: "文件没有整理成功", action: "在整理记录里查看失败的文件" }, main_failed: { text: "主文件整理失败,附属文件一起跳过" }, - not_imported_kind: { text: "这类文件不入库" }, + not_imported_kind: { text: "这类文件不整理" }, target_root_not_managed: { text: "目标目录不是本工具管理的媒体库目录", action: "在设置 → 媒体库与目录里检查" }, download_removed: { text: "下载任务已从下载器删除" }, // FIX-7:智能体汇报与工具结果里常见的码(能力错误、下载计划的空间判定、站点登录、待处理类型) @@ -100,7 +100,7 @@ const REASONS: Record = { rejected: { text: "下载器拒绝了这个种子" }, source_failed: { text: "从站点取种子失败", action: "检查站点登录后重新下载" }, // SCR-1 / SCR-2 刮削(刮削记录、逐文件结果) - scrape_no_files: { text: "作品没有已入库文件", action: "到文件管理里对目录刮削" }, + scrape_no_files: { text: "作品没有已整理文件", action: "到文件管理里对目录刮削" }, scrape_no_metadata: { text: "没有可用的元数据", action: "指定 TMDB 条目后重新刮削" }, scrape_root_read_only: { text: "只登记模式的媒体库目录,不写文件" }, scrape_root_offline: { text: "目录不可访问或未挂载" }, diff --git a/packages/domain/src/organize/plan.ts b/packages/domain/src/organize/plan.ts index 7f1a4000..16f6c99b 100644 --- a/packages/domain/src/organize/plan.ts +++ b/packages/domain/src/organize/plan.ts @@ -290,7 +290,7 @@ export function planOrganize(input: OrganizeInput): OrganizePlan { } if (videos.length === 0 && input.files.length > 0) - conflicts.push({ kind: "no_main_video", blocking: true, detail: "没有可入库的主视频" }); + conflicts.push({ kind: "no_main_video", blocking: true, detail: "没有可整理的主视频" }); // 主视频 for (const v of videos) { diff --git a/scripts/check-copy-punctuation.mjs b/scripts/check-copy-punctuation.mjs index 7332eacb..ad483db4 100644 --- a/scripts/check-copy-punctuation.mjs +++ b/scripts/check-copy-punctuation.mjs @@ -44,9 +44,35 @@ function findIssues(text) { push("省略号应为 …", /\.\.\.|……/g); push("括号内有中文应用全角括号", new RegExp(`\\([^()]*[${CJK}][^()]*\\)`, "g")); push("引号应为「」", /[“”]/g); + if (termScope) push("处理状态写「整理」不写「入库」(见 glossary)", TERM_RUKU); return out; } +// 术语:我们只负责整理(元数据、NFO、图片、归到目录),媒体服务器有没有扫到不归本工具判断, +// 所以表示处理状态的「已入库 / 未入库 / 入库方式 / 入库后…」一律写「整理」。只抓处理状态语境的搭配, +// 单独的「入库」(如搜索关键词)不算。 +const TERM_RUKU = + /(已|未|部分|核验|验证|整理|登记|原地|复制|移动|最近|新|没有|没|不|补|按集|临时作品)入库|入库(失败|中|方式|后|版本|时间|文件|位置|状态|任务)/g; +// 其它线负责、术语替换待协调者合并的文件(清单见 .local/term-replacements.md);替换完成后删掉这一项。 +const TERM_PENDING = new Set([ + "apps/web/app/components/agent/agent-conversation.tsx", + "apps/web/app/components/agent/mock.ts", + "apps/web/app/components/agent/quick-goals.ts", + "apps/web/app/api/agent.ts", + "apps/web/app/api/tasks.ts", + "apps/web/app/components/console/nav.ts", + "apps/web/app/components/downloads/business-tasks.tsx", + "apps/web/app/components/settings/automation.tsx", + "apps/web/app/components/settings/instances.tsx", + "apps/web/app/components/settings/library.tsx", + "apps/web/app/components/settings/organize-sources.tsx", + "apps/web/app/components/tasks/labels.ts", + "apps/web/app/routes/console/home.tsx", + "apps/web/app/routes/console/tasks.$id.tsx", + "apps/server/src/modules/agent/report.ts", +]); +let termScope = true; + /** 能确定的自动修正;占位符 PH 视为非中文、非字母数字 */ function fixText(text) { if (!CJK_RE.test(text)) return text; @@ -195,6 +221,7 @@ function allowed(lines, line) { function processFile(file) { const src = readFileSync(file, "utf8"); + termScope = !TERM_PENDING.has(relative(ROOT, file)); if (/copy-allow-file/.test(src.split("\n").slice(0, 5).join("\n"))) return { issues: [], changed: false }; let ast; try {