style(copy): punctuation rules and site-wide fix with a CI check (QA U11)

- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing,
  full-width parens around Chinese, single ellipsis, 「」 quotes, period rules)
- scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX
  text (not comments, types, keys, match/compare strings); --fix, --strict,
  --by-file; wired into deno task ci as check:copy in warn mode
- --fix applied to web, server and contracts; files other lanes were editing
  (subscriptions, sites, browser, agent, auth/account, inventory) left for them
- tests updated for the changed messages

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 20:09:29 +08:00
1 parent 9b017906cf
commit 52402864d6
143 files changed
+985 -606

No files matched your search

+1 -1
View File
@@ -63,7 +63,7 @@ export function loadConfig(env: Record<string, string | undefined> = Deno.env.to
const parsed = ConfigSchema.safeParse(env);
if (!parsed.success) {
const lines = parsed.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`);
throw new Error(`配置无效:\n${lines.join("\n")}`);
throw new Error(`配置无效:\n${lines.join("\n")}`);
}
return parsed.data;
}
+1 -1
View File
@@ -31,7 +31,7 @@ export class DeferredTaskEngine implements TaskEngine {
}
private get e(): TaskEngine {
if (!this.inner) throw new Error("任务引擎尚未就绪(模块不要在 register* 阶段调用引擎)");
if (!this.inner) throw new Error("任务引擎尚未就绪(模块不要在 register* 阶段调用引擎)");
return this.inner;
}
+1 -1
View File
@@ -13,7 +13,7 @@ const route = createRoute({
method: "get",
path: "/health",
tags: ["system"],
summary: "服务健康状态(免鉴权)",
summary: "服务健康状态(免鉴权)",
responses: {
200: {
description: "服务状态",
+1 -1
View File
@@ -89,7 +89,7 @@ export class PgEventBus implements EventBusPort {
client.removeAllListeners("notification");
client.end().catch(() => {});
if (!this.stopped) {
this.log.warn({ err }, "SSE 总线 LISTEN 连接断开,准备重连");
this.log.warn({ err }, "SSE 总线 LISTEN 连接断开,准备重连");
this.reconnect(500);
}
};
+1 -1
View File
@@ -110,7 +110,7 @@ export function sseRoutes({ bus, keepaliveMs = 15_000 }: SseOptions) {
summary: def.summary,
responses: {
200: {
description: "JSON 补读页(带 limit 或 Accept: application/json);否则为 text/event-stream",
description: "JSON 补读页(带 limit 或 Accept: application/json);否则为 text/event-stream",
content: {
"application/json": { schema: apiResponse(def.response) },
"text/event-stream": { schema: { type: "string" } },
+2 -2
View File
@@ -124,14 +124,14 @@ async function runOneShot(r: OneShotRole): Promise<number> {
const storage = await checkStorage({ ...paths, subdirs: DATA_SUBDIRS, mounts });
problems.push(...storage.problems);
for (const w of storage.warnings) {
logger.warn({ kind: w.kind, id: w.id, path: w.path }, `挂载路径不可用: ${w.name} ${w.reason}`);
logger.warn({ kind: w.kind, id: w.id, path: w.path }, `挂载路径不可用:${w.name} ${w.reason}`);
}
await auditStorageWarnings(db, storage.warnings).catch((err) => logger.warn({ err }, "存储告警写审计失败"));
// 验收开关误设提示(验收 07 S-10):只有 UMT_FS_TEST_HOOKS=1 时才真正生效
if (Deno.env.get("UMT_FS_TEST_HANG_AFTER_BYTES"))
logger.warn(
{ active: Deno.env.get("UMT_FS_TEST_HOOKS") === "1" },
"设置了 UMT_FS_TEST_HANG_AFTER_BYTES(fsproxy 挂死验收开关);生产环境不要设置,生效时所有数据类文件操作都会卡死到被 SIGKILL",
"设置了 UMT_FS_TEST_HANG_AFTER_BYTES(fsproxy 挂死验收开关);生产环境不要设置,生效时所有数据类文件操作都会卡死到被 SIGKILL",
);
for (const p of problems) logger.error({ check: p.check }, p.message);
if (problems.length) return 1;
@@ -147,7 +147,7 @@ describe.skipIf(!hasPg)("整理执行:onExisting / partial 清理 / TIMEOUT 重
for (const attempt of [1, 2]) {
const err = await executeRecord(h.d, plan.recordId!).catch((x) => x);
expect(err).toBeInstanceOf(Error);
expect(String(err.message)).toContain(`TIMEOUT@${f.src}(第 ${attempt} 次)`);
expect(String(err.message)).toContain(`TIMEOUT@${f.src}(第 ${attempt} 次)`);
const fr = await h.db
.selectFrom("organization_file_result")
.select(["status"])
@@ -111,7 +111,8 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
const c = (ev?.content ?? {}) as { rejected?: { code: string; message: string }[]; reasons?: string[] };
for (const r of c.rejected ?? []) rejected.push({ code: r.code, message: r.message });
for (const r of c.reasons ?? []) rejected.push({ code: r.split(":")[0] ?? r, message: r });
if (rejected.length === 0) rejected.push({ code: "not_planned", message: "未生成计划:版本目标正忙或条件不满足" });
if (rejected.length === 0)
rejected.push({ code: "not_planned", message: "未生成计划:版本目标正忙或条件不满足" });
}
const fresh = await db
.selectFrom("acquisition_slot")
@@ -148,7 +149,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
.where("id", "=", plan.slot_id)
.forUpdate()
.executeTakeFirstOrThrow();
if (slot.revision + 1 !== plan.expected_revision) return "版本目标已变化,重新生成计划";
if (slot.revision + 1 !== plan.expected_revision) return "版本目标已变化,重新生成计划";
if (BUSY.includes(slot.status)) return `版本目标正忙(${slot.status})`;
let st = { status: slot.status, hasAsset: slot.current_asset_id !== null };
if (st.status === "satisfied") st = transition(st, { type: "upgrade_found" });
@@ -177,7 +178,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
});
if (ok) throw new CapabilityError("conflict", ok);
} else if (!["reserved", "submitted", "completed"].includes(plan.status)) {
throw new CapabilityError("precondition", `计划状态为 ${plan.status},不能提交`);
throw new CapabilityError("precondition", `计划状态为 ${plan.status},不能提交`);
}
// 与自动路径同一幂等键:同一计划只会有一个 submitDownload 任务
const { taskId } = await enqueueTracked(d, {
@@ -231,7 +232,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
if (!frozen) {
throw new CapabilityError(
"precondition",
res.recordId ? `下载任务已有进行中的整理记录 ${res.recordId}` : `无法生成整理计划:${res.reason ?? res.status}`,
res.recordId ? `下载任务已有进行中的整理记录 ${res.recordId}` : `无法生成整理计划:${res.reason ?? res.status}`,
);
}
const entity = await d.ctx.db
@@ -313,7 +314,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
dryRun: true,
});
if (!again.plan || planHash(req, again.plan) !== art.content_hash)
throw new CapabilityError("conflict", "源文件或目标已变化,计划失效。重新生成整理计划");
throw new CapabilityError("conflict", "源文件或目标已变化,计划失效。重新生成整理计划");
const taskId = await ensureTaskRow(db, {
kind: "verifyAndImport",
idempotencyKey: taskKey,
@@ -389,8 +390,8 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
if (!cur) throw new CapabilityError("not_found", "策略版本不存在");
if (cur.status === "published") return cur; // 幂等:已发布
if (cur.revision !== input.expectedRevision)
throw new CapabilityError("conflict", `revision 不匹配(当前 ${cur.revision})`);
if (cur.status !== "draft") throw new CapabilityError("precondition", `只能发布草案(当前 ${cur.status})`);
throw new CapabilityError("conflict", `revision 不匹配(当前 ${cur.revision})`);
if (cur.status !== "draft") throw new CapabilityError("precondition", `只能发布草案(当前 ${cur.status})`);
return publishRevision(trx, cur.id, cur.policy_key, ctx.actorId, "superseded");
});
return { policy: toApi(row) as never };
@@ -106,19 +106,19 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
key: "downloads.sync",
defaults: {
name: "qB 同步",
description: "同步下载器任务状态,按缓存阈值暂停或恢复",
description: "同步下载器任务状态,按缓存阈值暂停或恢复",
group: "download",
schedule: every(syncMs),
},
run: async () => {
const st = await syncDownloads(d);
if (st.instances > 0 && st.unreachable.length === st.instances) {
throw new Error(`下载器都连不上:${st.unreachable.join("、")}`);
throw new Error(`下载器都连不上:${st.unreachable.join("、")}`);
}
return {
summary:
`${st.instances} 个下载器,更新 ${st.updated},完成 ${st.completed.length}` +
(st.unreachable.length ? `,连不上 ${st.unreachable.length}` : ""),
`${st.instances} 个下载器,更新 ${st.updated},完成 ${st.completed.length}` +
(st.unreachable.length ? `,连不上 ${st.unreachable.length}` : ""),
};
},
});
@@ -148,14 +148,14 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
run: guarded(d, async (d) => {
const submitted = await sweepReservedPlans(d);
const imports = await sweepPendingImports(d);
return `补提交 ${submitted},补入库 ${imports}`;
return `补提交 ${submitted},补入库 ${imports}`;
}),
});
registerScheduledJob(ctx, {
key: "slots.reevaluate",
defaults: {
name: "到期重评",
description: "到了重评时间的收录位重新评估(忙时延后的发布、定时)",
description: "到了重评时间的收录位重新评估(忙时延后的发布、定时)",
group: "download",
schedule: every(planMs),
},
@@ -107,7 +107,7 @@ export function createPorts(
return client;
},
async torrentSource({ release, site }) {
if (!site) throw new Error("站点不存在,无法获取种子");
if (!site) throw new Error("站点不存在,无法获取种子");
const raw = (release.raw ?? {}) as Record<string, unknown>;
const siteMeta = (raw._site ?? {}) as Record<string, unknown>;
const defId = [str(siteMeta.adapterSiteId), site.key].find((k) => k && getSiteDefinition(k));
@@ -18,7 +18,7 @@ export function registerDashboardRoutes(app: ModuleApp, d: AoDeps) {
method: "get",
path: "/dashboard",
scope: "read",
summary: "首页聚合:自动化概览/当前工作/需要我处理/缓存与保留/来源健康/最近产出",
summary: "首页聚合:自动化概览/当前工作/需要我处理/缓存与保留/来源健康/最近产出",
response: Dashboard,
},
async () => {
@@ -141,9 +141,9 @@ export function registerDashboardRoutes(app: ModuleApp, d: AoDeps) {
const dv = decideVolume(v, cache);
const cands = await cleanupCandidates(d, v);
const notes: string[] = [];
if (!v.snapshot) notes.push(v.path ? "磁盘用量读取失败,已暂停新增下载" : "实例未配置保存路径,无法统计");
if (!v.snapshot) notes.push(v.path ? "磁盘用量读取失败,已暂停新增下载" : "实例未配置保存路径,无法统计");
if (dv.unknownSizeTaskIds.length) notes.push(`${dv.unknownSizeTaskIds.length} 个在途任务体积未知`);
if (dv.unmanagedGrowthRisk) notes.push("有未纳管任务仍在增长,暂停纳管任务后磁盘仍会增长");
if (dv.unmanagedGrowthRisk) notes.push("有未纳管任务仍在增长,暂停纳管任务后磁盘仍会增长");
const pct = usedPercent(v);
volumes.push({
volumeKey: v.volumeKey,
@@ -75,7 +75,7 @@ export function registerDownloadRoutes(app: ModuleApp, d: AoDeps) {
const exists = await db().selectFrom("download_task").select("id").where("id", "=", params.id).executeTakeFirst();
if (!exists) notFound("下载任务");
const r = await userResume(d, params.id);
if (r === "threshold_active") throw new ApiException("precondition_failed", "缓存卷仍高于恢复水位,不能手动恢复");
if (r === "threshold_active") throw new ApiException("precondition_failed", "缓存卷仍高于恢复水位,不能手动恢复");
return toApi<DownloadTask>(r);
},
{ tag: TAG },
@@ -91,7 +91,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,
},
@@ -110,7 +110,7 @@ export function registerDownloadRoutes(app: ModuleApp, d: AoDeps) {
method: "post",
path: "/library/reconcile",
scope: "admin",
summary: "入队一轮资产对账(库根哨兵 + 增量 stat;不删记录、不补下载);返回任务 id,进度见任务页",
summary: "入队一轮资产对账(库根哨兵 + 增量 stat;不删记录、不补下载);返回任务 id,进度见任务页",
body: z.object({ libraryRootId: z.uuid().optional() }),
response: accepted,
},
@@ -137,7 +137,7 @@ export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) {
if (block) {
throw new ApiException(
"conflict",
`下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},已拒绝移动(未改为复制)。` +
`下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},已拒绝移动(未改为复制)。` +
`待处理事项 ${block.decisionId}`,
);
}
@@ -209,7 +209,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
.where("id", "=", params.id)
.executeTakeFirst();
if (!exists) notFound("版本目标");
conflict(`revision 不匹配(当前 ${exists!.revision})`);
conflict(`revision 不匹配(当前 ${exists!.revision})`);
}
await kickEvaluate(d, { acquisitionSlotId: params.id });
return toApi<AcquisitionSlot>(r!);
@@ -246,7 +246,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
if (!r.success)
throw new ApiException(
"validation_failed",
`rules 不符合收录策略结构: ${r.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`,
`rules 不符合收录策略结构:${r.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`,
);
};
mount(
@@ -293,7 +293,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
async (_c, { params, body }) => {
const cur = await db().selectFrom("policy_revision").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!cur) notFound("策略版本");
if (cur!.status !== "draft") conflict("只能修改草案。要改已发布版本,新建草案");
if (cur!.status !== "draft") conflict("只能修改草案。要改已发布版本,新建草案");
if (body.rules !== undefined) checkRules(cur!.kind, body.rules);
const r = await db()
.updateTable("policy_revision")
@@ -344,8 +344,8 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
.forUpdate()
.executeTakeFirst();
if (!cur) notFound("策略版本");
if (cur!.revision !== body.expectedRevision) conflict(`revision 不匹配(当前 ${cur!.revision})`);
if (cur!.status !== "draft") conflict(`只能发布草案(当前 ${cur!.status})`);
if (cur!.revision !== body.expectedRevision) conflict(`revision 不匹配(当前 ${cur!.revision})`);
if (cur!.status !== "draft") conflict(`只能发布草案(当前 ${cur!.status})`);
checkRules(cur!.kind, cur!.rules);
return publishRevision(trx, cur!.id, cur!.policy_key, actorOf(c), "superseded");
});
@@ -58,16 +58,16 @@ export async function sourceGuard(exec: Exec): Promise<SourceGuard> {
return {
roots,
async check(p) {
if (!isAbsolute(p)) throw new PathGuardError(`源路径必须是绝对路径: ${p}`, p);
if (!isAbsolute(p)) throw new PathGuardError(`源路径必须是绝对路径:${p}`, p);
if (p.split("/").some((seg) => seg === "..")) throw new PathGuardError(`源路径不允许包含 "..": ${p}`, p);
let r: string;
try {
r = await realpath(p);
} catch {
throw new PathGuardError(`源路径不存在或不可访问: ${p}`, p);
throw new PathGuardError(`源路径不存在或不可访问:${p}`, p);
}
if (!roots.some((root) => within(r, root)))
throw new PathGuardError(`源路径不在已登记的库根或下载器路径映射之下: ${p}`, p);
throw new PathGuardError(`源路径不在已登记的库根或下载器路径映射之下:${p}`, p);
return r;
},
};
@@ -280,7 +280,7 @@ export async function authorizeMoveEntities(
unauthorized.push({
label: s.label,
entityId: null,
reason: "无法确定所属作品,只能用一次性审批",
reason: "无法确定所属作品,只能用一次性审批",
});
continue;
}
@@ -306,8 +306,8 @@ export async function authorizeMoveEntities(
unauthorized,
reason:
detail.length > 0 && (reasons.length > 0 || grants.length > 0 || !single || unauthorized[0]?.entityId === null)
? `移动需要授权:${detail.join(";")}`
: "移动会删除源文件和下载器任务,需要管理员会话,或覆盖该作品的持续授权 / 一次性审批",
? `移动需要授权:${detail.join(";")}`
: "移动会删除源文件和下载器任务,需要管理员会话,或覆盖该作品的持续授权 / 一次性审批",
};
}
@@ -339,11 +339,11 @@ export async function hnrBlockForMove(
if (hnr === true) continue;
const decisionId = await upsertDecision(d, db, {
kind: "approval_required",
title: `做种义务未满足,已阻止移动:${dt.name ?? dt.torrent_hash}`,
title: `做种义务未满足,已阻止移动:${dt.name ?? dt.torrent_hash}`,
summary:
hnr === false
? "H&R / 做种要求未满足,移动会删除源文件和下载任务,已拒绝(未改为复制)"
: "H&R 要求未知,按未满足处理,已拒绝移动(未改为复制)",
? "H&R / 做种要求未满足,移动会删除源文件和下载任务,已拒绝(未改为复制)"
: "H&R 要求未知,按未满足处理,已拒绝移动(未改为复制)",
subjectType: "download_task",
subjectId: dt.id,
dedupeKey: `manual_move_hnr:${dt.id}`,
@@ -357,7 +357,7 @@ export async function hnrBlockForMove(
},
options: [
{ key: "wait", label: "做种满足后再移动" },
{ key: "copy", label: "改用复制(需重新发起)" },
{ key: "copy", label: "改用复制(需重新发起)" },
],
});
return { downloadTaskId: dt.id, hnr, decisionId };
@@ -55,7 +55,7 @@ async function walkTree(
if (out.files.length >= limit) return true;
const p = join(dir, n);
const st = await statSafe(p);
if (st.kind !== "ok") throw new Error(`源不可访问: ${p} (${st.kind})`);
if (st.kind !== "ok") throw new Error(`源不可访问:${p} (${st.kind})`);
if (st.facts.isSymbolicLink) out.links.push(p);
else if (st.facts.isDirectory) {
out.dirs.push(p);
@@ -93,7 +93,7 @@ export async function planTransfer(d: AoDeps, req: ImportRequest, t: TransferSpe
const add = async (src: string, dst: string) => {
if (t.guard) await t.guard(src);
const s = await statSafe(src);
if (s.kind !== "ok") throw new Error(`源不可访问: ${src} (${s.kind})`);
if (s.kind !== "ok") throw new Error(`源不可访问:${src} (${s.kind})`);
files.push({
src,
dst,
@@ -205,7 +205,7 @@ export async function planTransfer(d: AoDeps, req: ImportRequest, t: TransferSpe
target_path: rel(f.targetPath),
status: held ? "failed" : f.status,
skip_reason: held ? null : f.skipReason,
...(held ? { error: jsonb({ code: "EEXIST", message: "目标已存在,不覆盖" }), finished_at: now } : {}),
...(held ? { error: jsonb({ code: "EEXIST", message: "目标已存在,不覆盖" }), finished_at: now } : {}),
})
.execute();
}
@@ -178,7 +178,7 @@ async function walk(
guard?: ((p: string) => Promise<string>) | undefined,
): Promise<void> {
const st = await statSafe(p);
if (st.kind !== "ok") throw new FsError("ENOENT", `源路径不可访问: ${p} (${st.kind})`, { path: p });
if (st.kind !== "ok") throw new FsError("ENOENT", `源路径不可访问:${p} (${st.kind})`, { path: p });
if (st.facts.isFile) {
// 目录里的符号链接可能指向根外:逐个文件复核 realpath
if (guard) await guard(p);
@@ -389,7 +389,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
const rid = await fail("identity_unknown");
await decide_({
kind: "identity_ambiguous",
title: `无法确定作品:${dt?.name ?? req.sourcePaths?.[0] ?? "未知来源"}`,
title: `无法确定作品:${dt?.name ?? req.sourcePaths?.[0] ?? "未知来源"}`,
summary: "下载内容未关联作品。识别或指定作品后再整理",
subjectType: "organization_record",
subjectId: rid.recordId,
@@ -450,8 +450,8 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
await decide_({
kind: "other",
severity: "critical",
title: `库根离线:${root.name}`,
summary: `哨兵检查失败(${health.reason}),整理已暂停。文件不会因此被判为缺失`,
title: `库根离线:${root.name}`,
summary: `哨兵检查失败(${health.reason}),整理已暂停。文件不会因此被判为缺失`,
subjectType: "library_root",
subjectId: root.id,
dedupeKey: `library_root_offline:${root.id}`,
@@ -508,7 +508,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
if (dec.kind === "conflict") {
await decide_({
kind: "path_conflict",
title: `作品存在多个历史目录:${entity.title}`,
title: `作品存在多个历史目录:${entity.title}`,
summary: "不会按最新标题自动合并。指定要保留的目录",
subjectType: "media_entity",
subjectId: entity.id,
@@ -700,7 +700,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
const hnrConflict = blocking.find((c) => c.kind === "hnr_unsatisfied" || c.kind === "hnr_unknown");
await upsertDecision(d, trx, {
kind: hnrConflict ? "approval_required" : "path_conflict",
title: hnrConflict ? `做种义务未满足,已阻止移动:${entity.title}` : `整理冲突:${entity.title}`,
title: hnrConflict ? `做种义务未满足,已阻止移动:${entity.title}` : `整理冲突:${entity.title}`,
summary: blocking
.map((c) => c.detail ?? c.kind)
.join(";")
@@ -850,7 +850,7 @@ export async function executeRecord(
const fsr = fsRuntime(d);
// 根检查也走执行子进程:NFS/FUSE 挂死 → offline(timeout),按根离线重试,绝不当成文件缺失
const health = await fsr.proxy.checkRoot(frozen.rootPath, frozen.sentinel);
if (health.kind === "offline") throw new Error(`库根离线(${health.reason}),稍后重试`);
if (health.kind === "offline") throw new Error(`库根离线(${health.reason}),稍后重试`);
for (const dir of frozen.targetDirs) await mkdir(dir, { recursive: true });
// 写前顺带清本目录残留 partial(对照 MP:只清本目录、>24h;失败吞掉)。copy 路径在 copyWithVerify 内还会再清一次
if (fsr.partialMaxAgeMs > 0)
@@ -987,7 +987,7 @@ export async function executeRecord(
error: r.error
? jsonb(r.error)
: r.status === "attention"
? jsonb({ code: "source_not_removed", message: "目标已就绪,源文件未能删除" })
? jsonb({ code: "source_not_removed", message: "目标已就绪,源文件未能删除" })
: null,
finished_at: now,
})
@@ -1010,8 +1010,8 @@ export async function executeRecord(
if (outcome.status === "failed" && outcome.error && fsErrorClass(outcome.error.code) === "retry") {
await upsertDecision(d, db, {
kind: "other",
title: outcome.error.code === "TIMEOUT" ? "文件操作挂死,已达重试上限" : "文件读写失败,已达重试上限",
summary: `${pf.src} → ${pf.dst}:${outcome.error.message}(已尝试 ${outcome.attempt ?? fsr.maxAttempts} 次;记录与下载已保留,不会重新下载)`,
title: outcome.error.code === "TIMEOUT" ? "文件操作挂死,已达重试上限" : "文件读写失败,已达重试上限",
summary: `${pf.src} → ${pf.dst}:${outcome.error.message}(已尝试 ${outcome.attempt ?? fsr.maxAttempts} 次;记录与下载已保留,不会重新下载)`,
subjectType: "organization_record",
subjectId: recordId,
dedupeKey: `fs_retry_exhausted:${opId}`,
@@ -1027,8 +1027,8 @@ export async function executeRecord(
if (outcome.replaced?.disposition === "left") {
await upsertDecision(d, db, {
kind: "other",
title: "新版本已入库,旧文件未能处置",
summary: `${pf.dst} 已替换为新版本;旧文件留在 ${outcome.replaced.path}`,
title: "新版本已入库,旧文件未能处置",
summary: `${pf.dst} 已替换为新版本;旧文件留在 ${outcome.replaced.path}`,
subjectType: "organization_record",
subjectId: recordId,
dedupeKey: `replace_old_left:${opId}`,
@@ -1039,7 +1039,7 @@ export async function executeRecord(
await upsertDecision(d, db, {
kind: "other",
title: "跨卷移动后删除源文件失败",
summary: `${pf.src} 已复制到目标并校验,源文件未删除`,
summary: `${pf.src} 已复制到目标并校验,源文件未删除`,
subjectType: "organization_record",
subjectId: recordId,
dedupeKey: `move_source_left:${opId}`,
@@ -1050,7 +1050,7 @@ export async function executeRecord(
if (deferred.length > 0) {
// 交给任务引擎退避重试(executeRecord 幂等:已完成文件直接跳过,账本无结果的文件重跑并先对账)
throw new Error(
`文件操作可重试失败,等待重试:${deferred.map((x) => `${x.code}@${x.src}(第 ${x.attempt} 次)`).join(";")}`,
`文件操作可重试失败,等待重试:${deferred.map((x) => `${x.code}@${x.src}(第 ${x.attempt} 次)`).join(";")}`,
);
}
return finalizeRecord(d, recordId);
@@ -1149,9 +1149,9 @@ function statFailure(st: StatResult, path: string, role: "源" | "目标"): { co
case "ok":
return null;
case "missing":
return role === "源" ? { code: "SOURCE_MISSING", message: `源文件不存在: ${path}` } : null;
return role === "源" ? { code: "SOURCE_MISSING", message: `源文件不存在:${path}` } : null;
case "permission_denied":
return { code: "EACCES", message: `${role}无权限访问: ${path}` };
return { code: "EACCES", message: `${role}无权限访问:${path}` };
case "root_offline":
return { code: "ROOT_OFFLINE", message: `${role}所在根离线(${st.reason}): ${path}` };
case "error":
@@ -1217,7 +1217,7 @@ async function transferFile(
const err = fsErr(e);
return {
status: "failed",
error: { ...err, message: `对账比对失败:${err.message}` },
error: { ...err, message: `对账比对失败:${err.message}` },
evidence: { phase: "reconcile", src, dst },
};
}
@@ -1234,7 +1234,7 @@ async function transferFile(
};
}
} else if (x.onExisting !== "replace")
return { status: "failed", error: { code: "EEXIST", message: `目标已存在且内容不同: ${dst}` } };
return { status: "failed", error: { code: "EEXIST", message: `目标已存在且内容不同:${dst}` } };
}
}
const sFail = statFailure(s, src, "源");
@@ -1262,7 +1262,7 @@ async function transferFile(
if (crossDevice !== true) {
const m = await px.moveSafe(src, dst, call);
if (m.status === "moved") return { status: "succeeded", bytes: size };
if (m.status === "exists") return { status: "failed", error: { code: "EEXIST", message: `目标已存在: ${dst}` } };
if (m.status === "exists") return { status: "failed", error: { code: "EEXIST", message: `目标已存在:${dst}` } };
// EXDEV:不静默降级;按设置显式走可恢复三步
}
if (!x.allowCrossVolume)
@@ -1575,7 +1575,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
kind: "other",
severity: "info",
title: "旧版本待处置",
summary: "新版本已核验入库,旧文件保留,按保留策略处理",
summary: "新版本已核验入库,旧文件保留,按保留策略处理",
subjectType: "media_asset",
subjectId: old,
dedupeKey: `old_version:${old}`,
@@ -1636,7 +1636,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
} catch (err) {
d.ctx.logger.warn(
{ downloadTaskId: result.dt.id, err: { message: errMessage(err) } },
"Move 后移除 qB 任务失败,下次同步重试",
"Move 后移除 qB 任务失败,下次同步重试",
);
}
}
@@ -124,7 +124,7 @@ export function fsErrorToFiles(e: unknown): FilesError | null {
const code = errnoOf(e);
if (typeof code !== "string" || !/^E[A-Z]+$/.test(code)) return null;
const msg = e instanceof Error ? e.message : String(e);
if (OFFLINE_CODES.has(code)) return new FilesError("root_unavailable", `读取失败(${code}),根可能已离线: ${msg}`);
if (OFFLINE_CODES.has(code)) return new FilesError("root_unavailable", `读取失败(${code}),根可能已离线:${msg}`);
if (code === "ENOENT" || code === "ENOTDIR") return new FilesError("not_found", `路径已不存在(${code})`);
if (code === "EACCES" || code === "EPERM") return new FilesError("forbidden", `没有访问权限(${code})`);
return new FilesError("bad_request", `文件系统错误(${code}): ${msg}`);
@@ -189,7 +189,7 @@ export async function loadRootDefs(exec: Exec): Promise<RootDef[]> {
export async function rootDef(exec: Exec, id: string): Promise<RootDef> {
const r = (await loadRootDefs(exec)).find((x) => x.id === id);
if (!r) throw new FilesError("not_found", `根不存在或未启用: ${id}`);
if (!r) throw new FilesError("not_found", `根不存在或未启用:${id}`);
return r;
}
@@ -261,7 +261,7 @@ export interface Resolved {
}
function checkRel(rel: string) {
if (rel.startsWith("/") || rel.includes("\0")) throw new FilesError("path_escape", `非法相对路径: ${rel}`);
if (rel.startsWith("/") || rel.includes("\0")) throw new FilesError("path_escape", `非法相对路径:${rel}`);
const segs = rel.split("/").filter((s) => s.length > 0);
if (segs.some((s) => s === ".." || s === ".")) throw new FilesError("path_escape", `路径不允许包含 . 或 ..: ${rel}`);
return segs.join("/");
@@ -290,9 +290,9 @@ export async function resolveIn(
parentReal = await realpath(dirname(joined));
} catch (e) {
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `根 ${def.name} 读取失败(${errnoOf(e)})`);
throw new FilesError("not_found", `父目录不存在: ${dirname(rel) || "/"}`);
throw new FilesError("not_found", `父目录不存在:${dirname(rel) || "/"}`);
}
if (!within(parentReal, rootReal)) throw new FilesError("path_escape", `路径经符号链接指向根外: ${rel}`);
if (!within(parentReal, rootReal)) throw new FilesError("path_escape", `路径经符号链接指向根外:${rel}`);
const abs = join(parentReal, basename(joined));
let lst: Awaited<ReturnType<typeof lstat>> | null = null;
try {
@@ -301,12 +301,12 @@ export async function resolveIn(
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `根 ${def.name} 读取失败(${errnoOf(e)})`);
if (errnoOf(e) !== "ENOENT") throw new FilesError("bad_request", `无法访问 ${rel}: ${errnoOf(e)}`);
}
if (!lst && opts.mustExist !== false) throw new FilesError("not_found", `不存在: ${rel}`);
if (!lst && opts.mustExist !== false) throw new FilesError("not_found", `不存在:${rel}`);
const isSymlink = !!lst?.isSymbolicLink();
if (isSymlink) {
const target = await realpath(abs).catch(() => null);
if (!target || !within(target, rootReal))
throw new FilesError("path_escape", `符号链接指向根外或无法解析,不能操作: ${rel}`);
throw new FilesError("path_escape", `符号链接指向根外或无法解析,不能操作:${rel}`);
}
return { def, rootReal, rel: relOf(rootReal, abs), abs, lst, isSymlink };
}
@@ -318,9 +318,9 @@ export async function recheck(abs: string, rootReal: string): Promise<string> {
r = await realpath(abs);
} catch (e) {
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `读取失败(${errnoOf(e)})`);
throw new FilesError("plan_stale", `源已不存在: ${abs}`);
throw new FilesError("plan_stale", `源已不存在:${abs}`);
}
if (!within(r, rootReal)) throw new FilesError("path_escape", `执行时路径指向根外: ${abs}`);
if (!within(r, rootReal)) throw new FilesError("path_escape", `执行时路径指向根外:${abs}`);
return r;
}
@@ -43,12 +43,12 @@ export async function downloadFile(
): Promise<Response> {
// 路由中间件已按 auth:"session" 拦截;这里同口径复核(纵深防御)
if (!isAdminSession(principal))
throw new FilesError("forbidden", "下载仅限控制台管理员会话,API Key / DEV key 不可用");
throw new FilesError("forbidden", "下载仅限控制台管理员会话,API Key / DEV key 不可用");
const r = await resolveIn(d, q.root, q.path);
if (r.rel === "" || r.lst?.isDirectory()) throw new FilesError("bad_request", "只能下载文件,不能下载目录");
if (r.rel === "" || r.lst?.isDirectory()) throw new FilesError("bad_request", "只能下载文件,不能下载目录");
const name = basename(r.abs);
const kind = kindOfName(name);
if (!ALLOWED.has(kind)) throw new FilesError("forbidden", `只能下载 NFO、字幕和图片,此文件类型为 ${kind}`);
if (!ALLOWED.has(kind)) throw new FilesError("forbidden", `只能下载 NFO、字幕和图片,此文件类型为 ${kind}`);
const real = await recheck(r.abs, r.rootReal);
const file = await Deno.open(real, { read: true });
let size: number;
@@ -59,7 +59,7 @@ export async function downloadFile(
if (size > d.options.downloadMaxBytes)
throw new FilesError(
"too_large",
`文件 ${size} 字节,超过下载上限 ${d.options.downloadMaxBytes}(FILES_DOWNLOAD_MAX_BYTES)`,
`文件 ${size} 字节,超过下载上限 ${d.options.downloadMaxBytes}(FILES_DOWNLOAD_MAX_BYTES)`,
);
} catch (e) {
file.close();
@@ -16,7 +16,7 @@ const parentRel = (rel: string) => (rel.includes("/") ? dirname(rel) : "");
function assertCleanName(name: string) {
if (sanitizeSegment(name) !== name)
throw new FilesError("bad_request", `文件名含非法字符或首尾有空白/点,可改为:${sanitizeSegment(name)}`);
throw new FilesError("bad_request", `文件名含非法字符或首尾有空白/点,可改为:${sanitizeSegment(name)}`);
}
export async function mkdirIn(
@@ -26,15 +26,15 @@ export async function mkdirIn(
): Promise<FilesMkdirResult> {
const r = await resolveIn(d, body.root, body.path);
if (!(await isWritableTarget(r.def, r.rootReal))) throw new FilesError("not_writable", `根 ${r.def.name} 不可写`);
if (!r.lst?.isDirectory() || r.isSymlink) throw new FilesError("bad_request", `不是目录: ${r.rel || "/"}`);
if (!r.lst?.isDirectory() || r.isSymlink) throw new FilesError("bad_request", `不是目录:${r.rel || "/"}`);
assertCleanName(body.name);
await recheck(r.abs, r.rootReal);
const abs = join(r.abs, body.name);
try {
await mkdir(abs);
} catch (e) {
if (errnoOf(e) === "EEXIST") throw new FilesError("exists", `已存在: ${body.name}`);
throw new FilesError("bad_request", `创建目录失败: ${errMessage(e)}`);
if (errnoOf(e) === "EEXIST") throw new FilesError("exists", `已存在:${body.name}`);
throw new FilesError("bad_request", `创建目录失败:${errMessage(e)}`);
}
const rel = relOf(r.rootReal, abs);
await audit(d, principal, "mkdir", { root: body.root, path: rel });
@@ -57,7 +57,7 @@ export async function renameEntry(
): Promise<FilesRenameResult> {
const r = await resolveIn(d, body.root, body.path);
if (r.rel === "") throw new FilesError("bad_request", "根目录不能重命名");
if (!r.def.modifiable) throw new FilesError("not_writable", `根 ${r.def.name} 只读,不能重命名`);
if (!r.def.modifiable) throw new FilesError("not_writable", `根 ${r.def.name} 只读,不能重命名`);
assertCleanName(body.newName);
const oldName = basename(r.abs);
if (body.newName === oldName) throw new FilesError("bad_request", "新名称与原名称相同");
@@ -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);
@@ -91,7 +91,7 @@ export async function renameEntry(
const st = await lstat(p.toAbs).catch((e) => (errnoOf(e) === "ENOENT" ? null : e));
if (st) taken.push(p.to);
}
if (taken.length > 0) throw new FilesError("exists", `目标已存在,不覆盖: ${taken.join(", ")}`);
if (taken.length > 0) throw new FilesError("exists", `目标已存在,不覆盖:${taken.join(", ")}`);
const lib = r.def.libraryRootId;
let locCount = 0;
@@ -123,11 +123,11 @@ export async function renameEntry(
await recheck(p.fromAbs, r.rootReal);
const before = await lstat(p.fromAbs);
const res = await moveSafe(p.fromAbs, p.toAbs);
if (res.status === "exists") throw new FilesError("exists", `目标已存在,不覆盖: ${p.to}`);
if (res.status === "exists") throw new FilesError("exists", `目标已存在,不覆盖:${p.to}`);
if (res.status !== "moved") throw new FilesError("bad_request", `重命名失败(${res.status}): ${p.from}`);
const after = await lstat(p.toAbs);
if (after.ino !== before.ino || after.dev !== before.dev)
throw new FilesError("bad_request", `重命名后 inode 不一致,已中止: ${p.to}`);
throw new FilesError("bad_request", `重命名后 inode 不一致,已中止:${p.to}`);
done.push(p);
} catch (e) {
await rollback();
@@ -213,10 +213,10 @@ export async function organizeFiles(
for (const it of body.items) {
const r = await resolveIn(d, it.root, it.path);
if (r.rel === "") throw new FilesError("bad_request", "不能整理整个根");
if (r.isSymlink) throw new FilesError("bad_request", `不整理符号链接: ${r.rel}`);
if (r.isSymlink) throw new FilesError("bad_request", `不整理符号链接:${r.rel}`);
const action = it.operation ?? body.action;
if (action === "move" && !r.def.modifiable)
throw new FilesError("not_writable", `源根 ${r.def.name} 只读,不能移动`);
throw new FilesError("not_writable", `源根 ${r.def.name} 只读,不能移动`);
roots.add(it.root);
const isDir = !!r.lst?.isDirectory();
const entityId = entityOf(it);
@@ -231,7 +231,7 @@ export async function organizeFiles(
if (it.targetDir) {
const t = await resolveIn(d, it.targetDir.root, it.targetDir.path);
if (!t.lst?.isDirectory() || t.isSymlink || !t.def.libraryRootId)
throw new FilesError("bad_request", `目标目录必须是库根下已存在的目录: ${it.targetDir.path || "/"}`);
throw new FilesError("bad_request", `目标目录必须是库根下已存在的目录:${it.targetDir.path || "/"}`);
if (!(await isWritableTarget(t.def, t.rootReal)))
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写`);
targetDir = join(t.def.hostPath, t.rel);
@@ -239,13 +239,13 @@ export async function organizeFiles(
}
// 季/集
if (it.season !== undefined || it.episodes !== undefined) {
if (!isTv) warnings.push(`${r.rel}: 电影不适用季/集覆写,已忽略`);
if (!isTv) warnings.push(`${r.rel}:电影不适用季/集覆写,已忽略`);
else {
if (it.season !== undefined) ov.season = it.season;
if (it.episodes?.length) {
const range = episodeRange(it.episodes);
if (!range) warnings.push(`${r.rel}: 集号 ${it.episodes.join(",")} 不连续,未应用(只支持连续区间)`);
else if (isDir) warnings.push(`${r.rel}: 目录项不能指定集号(会套到其下所有视频),未应用`);
if (!range) warnings.push(`${r.rel}:集号 ${it.episodes.join(",")} 不连续,未应用(只支持连续区间)`);
else if (isDir) warnings.push(`${r.rel}:目录项不能指定集号(会套到其下所有视频),未应用`);
else ov.episodes = range;
}
}
@@ -255,7 +255,7 @@ export async function organizeFiles(
if (unit) {
const u = units.get(unit);
if (!u || u.media_entity_id !== entityId) {
warnings.push(`${r.rel}: 内容单元 ${unit} 不属于该作品,未应用`);
warnings.push(`${r.rel}:内容单元 ${unit} 不属于该作品,未应用`);
unit = null;
} else if (u.kind === "episode" || u.kind === "part") ov.contentUnitIds = [u.id];
}
@@ -264,10 +264,10 @@ export async function organizeFiles(
if (slotId) {
const s = slots.get(slotId);
if (!s || s.media_entity_id !== entityId) {
warnings.push(`${r.rel}: 版本目标 ${slotId} 不属于该作品,未应用`);
warnings.push(`${r.rel}:版本目标 ${slotId} 不属于该作品,未应用`);
slotId = null;
} else if (BUSY_SLOT.has(s.status)) {
warnings.push(`${r.rel}: 版本目标正在获取中(${s.status}),不改其状态`);
warnings.push(`${r.rel}:版本目标正在获取中(${s.status}),不改其状态`);
slotId = null;
}
}
@@ -416,12 +416,12 @@ export async function organizeFiles(
const frozen = (await loadPlan<Frozen>(d, planId, "organize", reqHash)).content;
const now = await factsOf(frozen.facts.map((f) => f.path));
const changed = now.find((f, i) => f.size !== frozen.facts[i]!.size || f.mtimeMs !== frozen.facts[i]!.mtimeMs);
if (changed) throw new FilesError("plan_stale", `源文件在预览后发生变化: ${changed.path}`);
if (changed) throw new FilesError("plan_stale", `源文件在预览后发生变化:${changed.path}`);
const again = await planImport(d.ao, importReq(true));
const nowPairs = pairsOf(again).map(({ src, dst }) => ({ src, dst }));
if (hashJson(nowPairs) !== hashJson(frozen.pairs))
throw new FilesError("plan_stale", "目标目录或绑定已变化,与预览不一致。重新预览");
if (again.status === "failed") throw new FilesError("bad_request", `计划不可执行:${again.reason ?? "blocked"}`);
throw new FilesError("plan_stale", "目标目录或绑定已变化,与预览不一致。重新预览");
if (again.status === "failed") throw new FilesError("bad_request", `计划不可执行:${again.reason ?? "blocked"}`);
if (g.action === "move") {
const managed = await managedTasksUnder(db, req.sourcePaths);
@@ -436,7 +436,7 @@ export async function organizeFiles(
if (block)
throw new FilesError(
"download_active",
`下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},不能移动,也不会自动改为复制。已建待处理事项 ${block.decisionId}`,
`下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},不能移动,也不会自动改为复制。已建待处理事项 ${block.decisionId}`,
);
}
await retirePlan(d, planId);
@@ -551,6 +551,6 @@ async function pickPlan(d: FmDeps, planIds: string[], reqHash: string): Promise<
.where("title", "=", "files_plan")
.execute();
const hit = rows.find((r) => (r.content as { op?: string; requestHash?: string }).requestHash === reqHash);
if (!hit) throw new FilesError("plan_stale", "没有与该组请求一致的 planId,重新预览");
if (!hit) throw new FilesError("plan_stale", "没有与该组请求一致的 planId,重新预览");
return hit.id;
}
@@ -120,7 +120,7 @@ export async function scrape(
.where("media_entity_id", "=", e.id)
.where("status", "=", "active")
.executeTakeFirst();
if (!registered) throw new FilesError("bad_request", "作品没有已登记资产。先识别并整理入库,再刮削");
if (!registered) throw new FilesError("bad_request", "作品没有已登记资产。先识别并整理入库,再刮削");
const { runId } = await d.ctx.engine.enqueue(
"refreshMetadata",
{ mediaEntityId: e.id },
@@ -166,7 +166,7 @@ export async function scrapeBatch(
for (const id of found) ids.add(id);
}
if (ids.size === 0 && skipped.length === 0) throw new FilesError("bad_request", "需要 items 或 mediaEntityIds");
if (ids.size > 200) throw new FilesError("bad_request", `一次最多刷新 200 个作品(本次 ${ids.size})`);
if (ids.size > 200) throw new FilesError("bad_request", `一次最多刷新 200 个作品(本次 ${ids.size})`);
const list = [...ids];
const ents = list.length
? await db.selectFrom("media_entity").select(["id", "title", "field_locks"]).where("id", "in", list).execute()
@@ -95,20 +95,20 @@ export async function transfer(
const t = await resolveIn(d, body.target.root, body.target.path);
if (!t.lst?.isDirectory() || t.isSymlink) throw new FilesError("bad_request", "目标必须是已存在的目录");
if (!t.def.libraryRootId || !(await isWritableTarget(t.def, t.rootReal)))
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写,只能选 managed 库根`);
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写,只能选 managed 库根`);
const sources: Resolved[] = [];
for (const it of body.items) {
const s = await resolveIn(d, it.root, it.path);
if (s.rel === "") throw new FilesError("bad_request", "根目录不能移动或复制");
if (mode === "move" && !s.def.modifiable) throw new FilesError("not_writable", `源根 ${s.def.name} 只读,只能复制`);
if (within(t.abs, s.abs)) throw new FilesError("bad_request", `目标在源目录之内: ${s.rel}`);
if (dirname(s.abs) === t.abs) throw new FilesError("bad_request", `源已在目标目录中: ${s.rel}`);
if (s.isSymlink) throw new FilesError("bad_request", `不能移动符号链接: ${s.rel}`);
if (mode === "move" && !s.def.modifiable) throw new FilesError("not_writable", `源根 ${s.def.name} 只读,只能复制`);
if (within(t.abs, s.abs)) throw new FilesError("bad_request", `目标在源目录之内:${s.rel}`);
if (dirname(s.abs) === t.abs) throw new FilesError("bad_request", `源已在目标目录中:${s.rel}`);
if (s.isSymlink) throw new FilesError("bad_request", `不能移动符号链接:${s.rel}`);
sources.push(s);
}
const names = sources.map((s) => basename(s.abs));
if (new Set(names).size !== names.length) throw new FilesError("bad_request", "多个源同名,会在目标目录冲突");
if (new Set(names).size !== names.length) throw new FilesError("bad_request", "多个源同名,会在目标目录冲突");
const snaps = await Promise.all(sources.map((s, i) => snapshot(d, s, body.items[i]!.root)));
const downloads = await managedDownloads(d);
@@ -148,7 +148,7 @@ export async function transfer(
const reason = planned.reason ?? "plan_failed";
throw new FilesError(
reason.startsWith("library_root_offline") ? "root_unavailable" : "bad_request",
`无法生成计划:${reason}`,
`无法生成计划:${reason}`,
);
}
const planFiles = planned.plan.organize.files;
@@ -189,9 +189,9 @@ export async function transfer(
const allowCross = (await d.ctx.settings.get("organize")).allowCrossVolumeMove;
const crossBlocked = mode === "move" && !allowCross && pairs.some((p) => p.crossDevice);
const warnings: string[] = [];
if (crossBlocked) warnings.push("未开启跨卷移动,执行会失败。改用复制,或在 设置 → 整理 中开启");
if (conflictSrc.size > 0) warnings.push(`${conflictSrc.size} 个文件在目标已存在,不覆盖,执行时记为失败`);
if (truncated) warnings.push("源文件数超过上限,分批操作");
if (crossBlocked) warnings.push("未开启跨卷移动,执行会失败。改用复制,或在 设置 → 整理 中开启");
if (conflictSrc.size > 0) warnings.push(`${conflictSrc.size} 个文件在目标已存在,不覆盖,执行时记为失败`);
if (truncated) warnings.push("源文件数超过上限,分批操作");
const base = { mode, pairs, totalBytes, freeBytes, spaceOk, hnrBlocks, truncated, warnings };
if (body.dryRun) {
@@ -215,22 +215,22 @@ export async function transfer(
for (const [i, s] of snaps.entries()) {
const frozen = plan.content.sources[i];
if (!frozen || frozen.abs !== s.abs || frozen.fingerprint !== s.fingerprint)
throw new FilesError("plan_stale", `源在预览后发生变化(大小/修改时间/文件清单): ${s.rel}`);
throw new FilesError("plan_stale", `源在预览后发生变化(大小/修改时间/文件清单):${s.rel}`);
}
if (plan.content.files && hashJson(plan.content.files) !== hashJson(filePairs))
throw new FilesError("plan_stale", "目标已变化,与预览不一致。重新预览");
if (truncated) throw new FilesError("bad_request", "源目录文件数超过上限,分批操作");
if (!spaceOk) throw new FilesError("bad_request", `目标卷空间不足:需要 ${needBytes},可用 ${freeBytes}`);
throw new FilesError("plan_stale", "目标已变化,与预览不一致。重新预览");
if (truncated) throw new FilesError("bad_request", "源目录文件数超过上限,分批操作");
if (!spaceOk) throw new FilesError("bad_request", `目标卷空间不足:需要 ${needBytes},可用 ${freeBytes}`);
if (crossBlocked)
throw new FilesError(
"bad_request",
"未开启跨卷移动(settings.organize.allowCrossVolumeMove)。改用复制,或开启后重试",
"未开启跨卷移动(settings.organize.allowCrossVolumeMove)。改用复制,或开启后重试",
);
if (mode === "move") {
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)) {
// 每个源按其(目录则为其下)已登记资产求作品集合,逐一核对授权;任一不覆盖 → 整体拒绝并列出未授权项
@@ -272,7 +272,7 @@ export async function transfer(
}
if (!rec.recordId || rec.status === "failed") {
await markTask(db, taskId, "failed", { error: rec.reason ?? rec.status });
throw new FilesError("bad_request", `计划不可执行:${rec.reason ?? rec.status}`);
throw new FilesError("bad_request", `计划不可执行:${rec.reason ?? rec.status}`);
}
const recordId = rec.recordId;
await db.updateTable("organization_record").set({ task_id: taskId }).where("id", "=", recordId).execute();
@@ -316,7 +316,7 @@ export async function transfer(
failed.push({
path: s.rel,
code: err.code === "EEXIST" ? "exists" : (err.code ?? bad.status),
message: `${err.message ?? bad.status}${mine.length > 1 ? `(${mine.filter((f) => f.status === "succeeded").length}/${mine.length} 个文件已完成)` : ""}`,
message: `${err.message ?? bad.status}${mine.length > 1 ? `(${mine.filter((f) => f.status === "succeeded").length}/${mine.length} 个文件已完成)` : ""}`,
});
}
}
+13 -13
View File
@@ -157,8 +157,8 @@ const BLOCK_ERR: Record<
> = {
download_active: ["download_active", "属于未完成或做种义务未满足的下载任务"],
is_root: ["bad_request", "不能删除根目录或只读根下的内容"],
needs_retire: ["needs_retire", "含已登记资产,需勾选“同时退役资产”"],
needs_unbind: ["needs_unbind", "是作品/季的绑定目录,需勾选“解除绑定”"],
needs_retire: ["needs_retire", "含已登记资产,需勾选「同时退役资产」"],
needs_unbind: ["needs_unbind", "是作品/季的绑定目录,需勾选「解除绑定」"],
root_unavailable: ["root_unavailable", "根不可用"],
not_found: ["not_found", "不存在"],
escape: ["path_escape", "路径指向根外"],
@@ -276,8 +276,8 @@ export async function deleteFiles(
for (const s of slots)
await upsertDecision(d.ao, trx, {
kind: "other",
title: `版本目标的文件已删除:${s.name}`,
summary: "满足该版本目标的资产已在文件管理中删除,版本目标改为需关注。确认是否重新获取",
title: `版本目标的文件已删除:${s.name}`,
summary: "满足该版本目标的资产已在文件管理中删除,版本目标改为需关注。确认是否重新获取",
subjectType: "acquisition_slot",
subjectId: s.id,
dedupeKey: `files_delete_slot:${s.id}`,
@@ -384,7 +384,7 @@ async function moveToTrash(
try {
await mkdir(dirname(trashPath), { recursive: true });
const r = await moveSafe(abs, trashPath);
if (r.status === "exists") throw new FilesError("exists", `回收站路径已存在: ${trashPath}`);
if (r.status === "exists") throw new FilesError("exists", `回收站路径已存在:${trashPath}`);
moved = r.status === "moved";
} catch (e) {
if (e instanceof FilesError) throw e;
@@ -396,7 +396,7 @@ async function moveToTrash(
if (check.size > d.options.trashCrossMaxBytes)
throw new FilesError(
"bad_request",
`无法同卷移入回收站,且大小 ${check.size} 超过跨卷上限 ${d.options.trashCrossMaxBytes}。改用永久删除`,
`无法同卷移入回收站,且大小 ${check.size} 超过跨卷上限 ${d.options.trashCrossMaxBytes}。改用永久删除`,
);
await rm(join(ctx.rootReal!, TRASH_DIR, stamp, id), { recursive: true, force: true }).catch(() => undefined);
trashPath = join(resolve(d.options.dataDir), "trash", stamp, id, check.path);
@@ -487,10 +487,10 @@ export async function mkdirWithin(rootReal: string, rel: string): Promise<void>
throw e;
});
if (st?.isSymbolicLink())
throw new FilesError("path_escape", `还原路径中的 ${relOfRoot(rootReal, cur)} 是符号链接,不能还原`);
throw new FilesError("path_escape", `还原路径中的 ${relOfRoot(rootReal, cur)} 是符号链接,不能还原`);
if (st && !st.isDirectory()) throw new FilesError("exists", `还原路径中的 ${relOfRoot(rootReal, cur)} 不是目录`);
if (!st) await mkdir(cur);
if (!within(await realpath(cur), rootReal)) throw new FilesError("path_escape", `还原路径指向根外: ${rel}`);
if (!within(await realpath(cur), rootReal)) throw new FilesError("path_escape", `还原路径指向根外:${rel}`);
}
}
const relOfRoot = (root: string, p: string) => (p === root ? "" : p.slice(root.length + 1));
@@ -509,12 +509,12 @@ export async function restoreTrash(
throw new FilesError("bad_request", "还原目标必须是已存在的目录");
// "还原到…"只能进可写的 managed 库根(与 move/copy 的目标规则一致;register_only / 下载根拒绝,验收 07 S-5)
if (target && !(await isWritableTarget(target.def, target.rootReal)))
throw new FilesError("not_writable", `还原目标根 ${target.def.name} 不可写,只能选 managed 库根`);
throw new FilesError("not_writable", `还原目标根 ${target.def.name} 不可写,只能选 managed 库根`);
for (const id of body.ids) {
try {
const row = await d.ctx.db.selectFrom("trash_item").selectAll().where("id", "=", id).executeTakeFirst();
if (!row) throw new FilesError("not_found", "回收站条目不存在");
if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝操作");
if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝操作");
let rootId = row.root_id;
let dst: string;
let rootReal: string;
@@ -537,7 +537,7 @@ export async function restoreTrash(
);
dst = parent.abs;
}
if (await lstat(dst).catch(() => null)) throw new FilesError("exists", "原位置已被占用,可选择“还原到…”");
if (await lstat(dst).catch(() => null)) throw new FilesError("exists", "原位置已被占用,可选择「还原到…」");
const r = await moveSafe(row.trash_path, dst);
if (r.status === "exists") throw new FilesError("exists", "原位置已被占用");
if (r.status === "exdev") {
@@ -547,7 +547,7 @@ export async function restoreTrash(
if (bytes > d.options.trashCrossMaxBytes)
throw new FilesError(
"too_large",
`跨卷还原大小 ${bytes} 超过上限 ${d.options.trashCrossMaxBytes}。在同卷还原,或手工处理 ${row.trash_path}`,
`跨卷还原大小 ${bytes} 超过上限 ${d.options.trashCrossMaxBytes}。在同卷还原,或手工处理 ${row.trash_path}`,
);
await copyTree(d, row.trash_path, dst, files);
await rm(row.trash_path, { recursive: true });
@@ -597,7 +597,7 @@ export async function purgeTrash(
const failed: FilesTrashPurgeResult["failed"] = [];
for (const row of rows) {
try {
if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝清除");
if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝清除");
const gone = await d.ctx.db.deleteFrom("trash_item").where("id", "=", row.id).returning("id").executeTakeFirst();
if (!gone) continue;
await rm(row.trash_path, { recursive: true, force: true });
+1 -1
View File
@@ -19,7 +19,7 @@ const hiddenName = (n: string) => n.startsWith(".") || isTempName(n);
export async function treeOf(d: FmDeps, q: FilesTreeQuery): Promise<FilesTreeResult> {
const r = await resolveIn(d, q.root, q.path);
if (!r.lst?.isDirectory() || r.isSymlink) throw new FilesError("bad_request", `不是目录: ${r.rel || "/"}`);
if (!r.lst?.isDirectory() || r.isSymlink) throw new FilesError("bad_request", `不是目录:${r.rel || "/"}`);
const names = await readdir(r.abs, { withFileTypes: true });
const subdirs = names
.filter((n) => n.isDirectory() && !hiddenName(n.name))
+1 -1
View File
@@ -74,7 +74,7 @@ export function createPiModule(
key: "pi.token_refresh",
defaults: {
name: "上游登录刷新",
description: "订阅登录即将过期的 Agent 上游,提前刷新 token",
description: "订阅登录即将过期的 Agent 上游,提前刷新 token",
group: "maintain",
schedule: { type: "interval", everyMs: interval },
},
+1 -1
View File
@@ -24,7 +24,7 @@ const DECISION_KEY = (id: string) => `pi_upstream_expired:${id}`;
/** 与其它模块的 upsertDecision 同一写法(跨模块不 import):同键未关闭时只累加 attempts */
async function upsertExpiredDecision(ctx: ServerContext, row: PiUpstreamRow, message: string) {
const title = `Agent 上游「${row.name}」登录已失效,需要重新登录`;
const title = `Agent 上游「${row.name}」登录已失效,需要重新登录`;
await ctx.db.transaction().execute(async (trx) => {
const r = await sql<{ id: string; inserted: boolean }>`
insert into decision (kind, severity, title, summary, subject_type, subject_id, dedupe_key, evidence, options)
+2 -2
View File
@@ -107,8 +107,8 @@ export function registerPiRoutes(
// 自定义 Base URL 的凭证检查不连端点:不据此改写可用状态,提示用最小补全确认可达(QA U2)
const unprobed = mode === "auth" && !!row.base_url && row.auth_kind !== "oauth";
const notes = [
t.inCatalog ? null : `模型 ${t.model} 不在内置模型目录中,按手填 id 调用`,
unprobed ? "只核对了凭证与模型,没有连接 Base URL;用「最小补全」确认端点可达" : null,
t.inCatalog ? null : `模型 ${t.model} 不在内置模型目录中,按手填 id 调用`,
unprobed ? "只核对了凭证与模型,没有连接 Base URL;用「最小补全」确认端点可达" : null,
].filter(Boolean);
out = { ok: true, source: t.source, model: t.model, errorKind: null, message: notes.join(";") || null };
if (!unprobed) await setStatus(ctx, row.id, { status: "ok", verified: true });
+4 -4
View File
@@ -63,12 +63,12 @@ function validateShape(input: {
if (!info.authKinds.includes(input.authKind as never))
throw new ApiException(
"validation_failed",
`${info.name} 不支持认证方式 ${input.authKind},可用:${info.authKinds.join(" / ")}`,
`${info.name} 不支持认证方式 ${input.authKind},可用:${info.authKinds.join(" / ")}`,
);
if (input.baseUrl && (!info.baseUrlOverridable || input.authKind === "oauth"))
throw new ApiException(
"validation_failed",
`${info.name}${input.authKind === "oauth" ? "(订阅登录)" : ""} 不能覆盖 baseUrl`,
`${info.name}${input.authKind === "oauth" ? "(订阅登录)" : ""} 不能覆盖 baseUrl`,
);
if (input.provider === "openai-compatible" && (!input.baseUrl || !input.defaultModel))
throw new ApiException("validation_failed", "自定义兼容端点必须填 baseUrl 与模型 id");
@@ -213,9 +213,9 @@ export async function resolveRunUpstream(ctx: ServerContext, upstreamId?: string
const id = upstreamId ?? (await defaultUpstreamId(ctx));
if (!id) return null;
const row = await getRow(ctx, id);
if (!row) throw new Error(upstreamId ? `指定的上游 ${upstreamId} 不存在` : "默认上游已不存在,在设置里重新选择");
if (!row) throw new Error(upstreamId ? `指定的上游 ${upstreamId} 不存在` : "默认上游已不存在,在设置里重新选择");
if (!row.enabled) throw new Error(`上游「${row.name}」已停用`);
if (row.status === "expired") throw new Error(`上游「${row.name}」登录已失效,在设置里重新登录`);
if (row.status === "expired") throw new Error(`上游「${row.name}」登录已失效,在设置里重新登录`);
if (row.status === "auth_required")
throw new Error(`上游「${row.name}」${row.auth_kind === "oauth" ? "未登录" : "未配置 API Key"}`);
return { row, spec: { ...specOf(row), extra: parseExtra(row.extra) }, credentials: dbCredentialStore(ctx, row.id) };
@@ -29,12 +29,12 @@ export const BUILTIN_AGENT_CHECKS: {
{
key: "check.pending",
name: "待处理巡检",
description: "检查待处理事项并给出建议(只读)",
description: "检查待处理事项并给出建议(只读)",
everyMs: 6 * HOUR,
config: {
goalTemplate:
"巡检待处理({{now}}):有 {{openDecisions}} 条未关闭的待处理事项。逐条查看证据,按紧急程度排序," +
"给出处理建议和理由。只读,不做任何变更。",
"巡检待处理({{now}}):有 {{openDecisions}} 条未关闭的待处理事项。逐条查看证据,按紧急程度排序," +
"给出处理建议和理由。只读,不做任何变更。",
capabilityAllowlist: [...INVESTIGATE_CAPABILITIES],
budget: { maxToolCalls: 20, maxDurationSec: 300 },
triggerCondition: "open_decisions",
@@ -43,12 +43,12 @@ export const BUILTIN_AGENT_CHECKS: {
{
key: "check.downloads",
name: "下载诊断",
description: "检查无进展或失败的下载并诊断;开启恢复后在授权内重试或重新校验",
description: "检查无进展或失败的下载并诊断;开启恢复后在授权内重试或重新校验",
everyMs: 2 * HOUR,
config: {
goalTemplate:
"巡检下载({{now}}):有 {{stalledDownloads}} 个下载任务无进展或失败。用只读能力查明原因(站点、做种、磁盘、任务错误)," +
"给出诊断与建议;若本次允许恢复,只在授权内做重试或强制重新校验,每个任务最多一次,不换源、不删除。",
"巡检下载({{now}}):有 {{stalledDownloads}} 个下载任务无进展或失败。用只读能力查明原因(站点、做种、磁盘、任务错误)," +
"给出诊断与建议;若本次允许恢复,只在授权内做重试或强制重新校验,每个任务最多一次,不换源、不删除。",
capabilityAllowlist: [...INVESTIGATE_CAPABILITIES],
budget: { maxToolCalls: 24, maxDurationSec: 420 },
triggerCondition: "stalled_downloads",
@@ -58,12 +58,12 @@ export const BUILTIN_AGENT_CHECKS: {
{
key: "check.weekly",
name: "订阅与站点周报",
description: "检查订阅缺集与站点健康,汇总写入待处理(只读)",
description: "检查订阅缺集与站点健康,汇总写入待处理(只读)",
everyMs: 7 * 24 * HOUR,
config: {
goalTemplate:
"生成本周巡检周报({{date}}):追踪中的订阅 {{activeSubscriptions}} 个。列出缺集的订阅及可能原因、" +
"健康异常的站点与 Feed,给出本周建议。只读。最后用不超过 300 字的中文汇总。",
"生成本周巡检周报({{date}}):追踪中的订阅 {{activeSubscriptions}} 个。列出缺集的订阅及可能原因、" +
"健康异常的站点与 Feed,给出本周建议。只读。最后用不超过 300 字的中文汇总。",
capabilityAllowlist: [...INVESTIGATE_CAPABILITIES],
budget: { maxToolCalls: 30, maxDurationSec: 600 },
triggerCondition: "subscriptions_active",
@@ -106,7 +106,7 @@ const isPlanOnly = (id: string) => {
/** agent_check 的执行体(内置与自定义共用) */
export async function runAgentCheck(ctx: ServerContext, c: JobRunContext): Promise<JobRunResult> {
const parsed = AgentCheckConfig.safeParse(c.config);
if (!parsed.success) throw new Error(`巡检参数无效:${parsed.error.issues[0]?.message ?? "未知"}`);
if (!parsed.success) throw new Error(`巡检参数无效:${parsed.error.issues[0]?.message ?? "未知"}`);
const cfg = parsed.data;
const f = await facts(ctx);
const unmet =
@@ -114,7 +114,7 @@ export async function runAgentCheck(ctx: ServerContext, c: JobRunContext): Promi
(cfg.triggerCondition === "stalled_downloads" && f.stalledDownloads === 0) ||
(cfg.triggerCondition === "subscriptions_active" && f.activeSubscriptions === 0);
// 手动执行不看触发条件
if (unmet && c.trigger === "schedule") return { status: "skipped", summary: "触发条件不满足,未唤醒 Agent" };
if (unmet && c.trigger === "schedule") return { status: "skipped", summary: "触发条件不满足,未唤醒 Agent" };
let mode: "execute" | "plan_only" = "plan_only";
let allowlist = cfg.capabilityAllowlist.filter(isPlanOnly);
@@ -124,7 +124,7 @@ export async function runAgentCheck(ctx: ServerContext, c: JobRunContext): Promi
if (grants.length > 0) {
mode = "execute";
allowlist = [...new Set([...(allowlist.length ? allowlist : INVESTIGATE_CAPABILITIES), ...RECOVER_CAPABILITIES])];
} else note = "(没有覆盖恢复动作的有效授权,改为只读)";
} else note = "(没有覆盖恢复动作的有效授权,改为只读)";
}
const goal = renderGoal(cfg.goalTemplate, c.now, f);
const dedupeKey = c.trigger === "manual" ? `schedule:${c.jobId}:manual:${c.runId}` : `schedule:${c.jobId}:${c.slot}`;
+4 -4
View File
@@ -23,7 +23,7 @@ function parseField(src: string, [lo, hi]: [number, number], label: string): Set
const out = new Set<number>();
for (const part of src.split(",")) {
const m = /^(\*|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/.exec(part);
if (!m) throw new Error(`cron ${label} 段无效:${part}`);
if (!m) throw new Error(`cron ${label} 段无效:${part}`);
let a = lo;
let b = hi;
if (m[1] !== "*") {
@@ -31,7 +31,7 @@ function parseField(src: string, [lo, hi]: [number, number], label: string): Set
b = m[3] !== undefined ? Number(m[3]) : m[4] !== undefined ? hi : a;
}
const step = m[4] !== undefined ? Number(m[4]) : 1;
if (a < lo || b > hi || a > b || step < 1) throw new Error(`cron ${label} 段越界:${part}`);
if (a < lo || b > hi || a > b || step < 1) throw new Error(`cron ${label} 段越界:${part}`);
for (let v = a; v <= b; v += step) out.add(v);
}
return out;
@@ -39,7 +39,7 @@ function parseField(src: string, [lo, hi]: [number, number], label: string): Set
export function parseCron(expr: string): CronFields {
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) throw new Error("cron 需要 5 段:分 时 日 月 周");
if (parts.length !== 5) throw new Error("cron 需要 5 段:分 时 日 月 周");
const [mi, h, d, mo, w] = parts as [string, string, string, string, string];
const weekday = parseField(w, RANGES[4]!, "周");
if (weekday.has(7)) weekday.add(0);
@@ -85,5 +85,5 @@ export function nextCronAfter(expr: string | CronFields, after: Date): Date {
}
return t;
}
throw new Error(`cron 在 4 年内没有触发时间:${typeof expr === "string" ? expr : "(parsed)"}`);
throw new Error(`cron 在 4 年内没有触发时间:${typeof expr === "string" ? expr : "(parsed)"}`);
}
+1 -1
View File
@@ -37,7 +37,7 @@ export function createSchedulesModule(opts: SchedulesModuleOptions = {}): Server
key: "maintain.backup",
defaults: {
name: "备份",
description: "数据库与数据目录备份;目前用 umt backup(宿主机 cron 调用),未接入调度",
description: "数据库与数据目录备份;目前用 umt backup(宿主机 cron 调用),未接入调度",
group: "maintain",
schedule: { type: "cron", cron: "30 3 * * *" },
enabled: false,
@@ -71,7 +71,7 @@ class MemoryRegistry implements ScheduleRegistry {
return [...this.defs.values()];
}
register(def: ScheduledJobDef) {
if (!/^[a-z][a-z0-9_.:-]{1,120}$/.test(def.key)) throw new Error(`定时任务 key 不合法:${def.key}`);
if (!/^[a-z][a-z0-9_.:-]{1,120}$/.test(def.key)) throw new Error(`定时任务 key 不合法:${def.key}`);
this.defs.set(def.key, def);
this.version += 1;
}
@@ -113,7 +113,7 @@ export class Scheduler {
});
} catch (err) {
this.leader = false;
this.log.warn({ err: { message: errText(err) } }, "调度器循环出错,稍后重试");
this.log.warn({ err: { message: errText(err) } }, "调度器循环出错,稍后重试");
if (!this.stopped) await this.sleep(retry);
}
}
@@ -121,7 +121,7 @@ export class Scheduler {
/** 上一个执行者中断时留下的 running 状态:标为失败 */
private async recoverInterrupted(): Promise<void> {
const msg = "调度器重启,上次运行中断";
const msg = "调度器重启,上次运行中断";
await this.db
.updateTable("scheduled_job_run")
.set({ status: "failed", error: msg, finished_at: this.now() })
@@ -296,7 +296,7 @@ export class Scheduler {
where r.agent_status is null and a.status = any(${TERMINAL_AGENT})
limit 50`.execute(this.db);
for (const r of rows.rows) {
const summary = (r.summary ?? "").trim() || `Agent 运行结束:${r.status}`;
const summary = (r.summary ?? "").trim() || `Agent 运行结束:${r.status}`;
await this.db
.updateTable("scheduled_job_run")
.set({ agent_status: r.status, summary: summary.slice(0, 4000) })
@@ -305,7 +305,7 @@ export class Scheduler {
if (r.config?.reportAsDecision === true && r.status === "succeeded") {
const ins = await sql<{ id: string }>`
insert into decision (kind, severity, title, summary, subject_type, subject_id, dedupe_key, evidence, agent_run_id)
values ('other', 'info', ${`巡检报告:${r.job_name}`}, ${summary.slice(0, 4000)}, 'agent_run', ${r.agent_run_id},
values ('other', 'info', ${`巡检报告:${r.job_name}`}, ${summary.slice(0, 4000)}, 'agent_run', ${r.agent_run_id},
${`schedule-report:${r.id}`}, ${JSON.stringify({ scheduledJobId: r.job_id, scheduledRunId: r.id })}::jsonb,
${r.agent_run_id})
on conflict do nothing
@@ -315,7 +315,7 @@ export class Scheduler {
await this.ctx.outbox.emit(
this.db,
"decision.created",
{ decisionId: d.id, kind: "other", severity: "info", title: `巡检报告:${r.job_name}` },
{ decisionId: d.id, kind: "other", severity: "info", title: `巡检报告:${r.job_name}` },
{ idempotencyKey: `decision.created:${d.id}` },
);
}
+5 -5
View File
@@ -139,7 +139,7 @@ export async function syncDefinitions(db: Kysely<DB>, defs: readonly ScheduledJo
export async function getJob(db: Kysely<DB>, id: string): Promise<ScheduledJobRow> {
const row = await db.selectFrom("scheduled_job").selectAll().where("id", "=", id).executeTakeFirst();
if (!row) throw new ApiException("not_found", `定时任务不存在:${id}`);
if (!row) throw new ApiException("not_found", `定时任务不存在:${id}`);
return row;
}
@@ -201,7 +201,7 @@ export async function updateJob(
});
}
if (patch.enabled !== undefined) {
if (patch.enabled && !row.available) throw new ApiException("conflict", "任务未配置,不能启用");
if (patch.enabled && !row.available) throw new ApiException("conflict", "任务未配置,不能启用");
set.enabled = patch.enabled;
set.customized = true;
if (patch.enabled && !row.enabled && !set.next_run_at) {
@@ -228,7 +228,7 @@ export async function updateJob(
/** 立即执行:置 run_requested_at(已在排队或运行中则合并,不重复) */
export async function requestRun(db: Kysely<DB>, id: string): Promise<{ row: ScheduledJobRow; queued: boolean }> {
const row = await getJob(db, id);
if (!row.available) throw new ApiException("conflict", "任务未配置,不能执行");
if (!row.available) throw new ApiException("conflict", "任务未配置,不能执行");
const upd = await db
.updateTable("scheduled_job")
.set({ run_requested_at: sql`now()` })
@@ -262,7 +262,7 @@ export async function skipNext(db: Kysely<DB>, id: string, now: Date): Promise<S
started_at: now,
finished_at: now,
duration_ms: 0,
summary: `跳过 ${base.toISOString()},下次 ${next.toISOString()}`,
summary: `跳过 ${base.toISOString()},下次 ${next.toISOString()}`,
})
.execute();
await trimRuns(trx, id);
@@ -317,6 +317,6 @@ export async function createAgentCheck(db: Kysely<DB>, body: AgentCheckCreate, n
export async function deleteJob(db: Kysely<DB>, id: string): Promise<void> {
const row = await getJob(db, id);
if (row.builtin || row.kind !== "agent_check") throw new ApiException("conflict", "内置任务不能删除,可以停用");
if (row.builtin || row.kind !== "agent_check") throw new ApiException("conflict", "内置任务不能删除,可以停用");
await db.deleteFrom("scheduled_job").where("id", "=", id).execute();
}
+1 -1
View File
@@ -41,7 +41,7 @@ export class InternalDispatcher {
return c.body(null, 500);
});
inner.notFound((c) => {
this.errors.set(c.req.raw, new ApiException("internal", `内部端点不存在:${c.req.method} ${c.req.path}`));
this.errors.set(c.req.raw, new ApiException("internal", `内部端点不存在:${c.req.method} ${c.req.path}`));
return c.body(null, 404);
});
this.inner = inner;
+1 -1
View File
@@ -208,7 +208,7 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
row = await find();
}
}
if (!row) throw new ApiException("not_found", "资源不在搜索结果里,重新搜索后再下载");
if (!row) throw new ApiException("not_found", "资源不在搜索结果里,重新搜索后再下载");
return dispatch.call<ManualAcquisitionResult>(c, "POST", "/acquisition-plans", {
...(body as SearchReleasePlanRequest),
sourceReleaseId: row.id,
@@ -102,7 +102,7 @@ export const D = {
method: "get" as const,
path: "/sites",
scope: "read" as const,
summary: "站点列表(include=accounts 附带账号与整体账号状态)",
summary: "站点列表(include=accounts 附带账号与整体账号状态)",
tags: TS,
query: SiteListQuery,
response: cursorPage(SiteListItem),
@@ -117,7 +117,7 @@ export const D = {
body: SiteCreate,
response: Site,
},
update: write("patch", "/sites/{id}", "admin", "修改站点(启停、快照周期、限流、代理)", SiteUpdate, Site, TS),
update: write("patch", "/sites/{id}", "admin", "修改站点(启停、快照周期、限流、代理)", SiteUpdate, Site, TS),
},
siteAccounts: {
list: list("/site-accounts", "站点账号列表", SiteAccount, { siteId: Id.optional() }, TS),
@@ -126,7 +126,7 @@ export const D = {
method: "post" as const,
path: "/site-accounts",
scope: "admin" as const,
summary: "新建站点账号(凭证只写,存为 secret)",
summary: "新建站点账号(凭证只写,存为 secret)",
tags: TS,
body: SiteAccountCreate,
response: SiteAccount,
@@ -148,7 +148,7 @@ export const D = {
method: "post" as const,
path: "/feed-sources",
scope: "admin" as const,
summary: "新建 Feed(url 只写,存为 secret;对外只给脱敏 URL)",
summary: "新建 Feed(url 只写,存为 secret;对外只给脱敏 URL)",
tags: TF,
body: FeedSourceCreate,
response: FeedSource,
@@ -158,7 +158,7 @@ export const D = {
"post",
"/feed-sources/{id}/fetch",
"admin",
"立即抓取(仍受站点限流)",
"立即抓取(仍受站点限流)",
undefined,
z.object({ taskId: Id }),
TF,
@@ -202,7 +202,7 @@ export const D = {
method: "post" as const,
path: "/media-entities",
scope: "organize.execute" as const,
summary: "手动建本地作品(TMDB 无记录时可先建档)",
summary: "手动建本地作品(TMDB 无记录时可先建档)",
tags: TE,
body: MediaEntityCreate,
response: MediaEntity,
@@ -211,7 +211,7 @@ export const D = {
"patch",
"/media-entities/{id}",
"organize.execute",
"修改作品(标题/原名/年份按字段覆盖并锁定)",
"修改作品(标题/原名/年份按字段覆盖并锁定)",
MediaEntityUpdate,
MediaEntity,
TE,
@@ -220,7 +220,7 @@ export const D = {
"post",
"/media-entities/{id}/merge",
"organize.execute",
"合并重复作品(保留旧 ID 跳转)",
"合并重复作品(保留旧 ID 跳转)",
MediaEntityMerge,
MediaEntity,
TE,
@@ -239,7 +239,7 @@ export const D = {
"patch",
"/content-units/{id}",
"organize.execute",
"修改内容单元(改编号即确认并锁定)",
"修改内容单元(改编号即确认并锁定)",
ContentUnitUpdate,
ContentUnit,
TE,
@@ -270,7 +270,7 @@ export const D = {
"post",
"/external-identities",
"organize.execute",
"新增外部身份(accepted 等同人工纠错)",
"新增外部身份(accepted 等同人工纠错)",
ExternalIdentityCreate,
ExternalIdentity,
TI,
@@ -280,7 +280,7 @@ export const D = {
"patch",
"/external-identities/{id}",
"organize.execute",
"修订外部身份(乐观并发)",
"修订外部身份(乐观并发)",
ExternalIdentityUpdate,
ExternalIdentity,
TI,
@@ -295,7 +295,7 @@ export const D = {
decisions: {
list: list(
"/decisions",
"待处理事项(缺省只列未关闭)",
"待处理事项(缺省只列未关闭)",
Decision,
{ status: DecisionStatus.optional(), kind: DecisionKind.optional() },
TD,
@@ -58,7 +58,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
return { summary: `${await fn(rt)} 项` };
},
});
job("feeds.poll", "Feed 轮询", "扫描到期的 Feed 并入队抓取(每个 Feed 仍按自己的轮询间隔)", tickFeeds);
job("feeds.poll", "Feed 轮询", "扫描到期的 Feed 并入队抓取(每个 Feed 仍按自己的轮询间隔)", tickFeeds);
job("sites.snapshot", "站点快照", "扫描到周期的站点并入队快照", tickSnapshots);
job("identity.review", "待识别复查", "到期的待识别条目重新识别", tickReviews);
// W9-A 站点中心:用户数据每 6 小时、健康检测每小时(可在定时任务页关闭或改周期)
@@ -66,7 +66,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
key: "sites.user_data",
defaults: {
name: "站点数据刷新",
description: "登录状态正常的站点账号刷新上传 / 下载 / 分享率等用户数据;会话失效的账密账号自动重登一次",
description: "登录状态正常的站点账号刷新上传 / 下载 / 分享率等用户数据;会话失效的账密账号自动重登一次",
group: "collect",
schedule: { type: "interval", everyMs: 6 * 3_600_000 },
firstRun: "after_interval",
@@ -80,7 +80,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
key: "sites.health_check",
defaults: {
name: "站点健康检测",
description: "已启用站点逐个检测首页连通与登录态,结果显示在站点页",
description: "已启用站点逐个检测首页连通与登录态,结果显示在站点页",
group: "collect",
schedule: { type: "interval", everyMs: 3_600_000 },
firstRun: "after_interval",
@@ -154,7 +154,7 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
.where("source_release_id", "=", d.subject_id)
.executeTakeFirst();
if (!(pool?.candidates as PoolCandidate[] | undefined)?.[opt.input.candidateIndex]) {
throw new ApiException("conflict", "候选已变化,刷新后重试");
throw new ApiException("conflict", "候选已变化,刷新后重试");
}
await assignReleaseIdentity(
rt(),
@@ -112,7 +112,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "get",
path: "/media-entities/{id}/detail",
scope: "read",
summary: "作品详情:外部身份、别名、内容单元、字段来源与覆盖(旧 ID 自动跳转)",
summary: "作品详情:外部身份、别名、内容单元、字段来源与覆盖(旧 ID 自动跳转)",
tags: T_ENT,
params: IdParam,
response: MediaEntityDetail,
@@ -202,7 +202,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "post",
path: "/media-entities/{id}/identities",
scope: "organize.execute",
summary: "人工纠错外部映射:旧映射退役(留修订),新映射 accepted + 人工锁",
summary: "人工纠错外部映射:旧映射退役(留修订),新映射 accepted + 人工锁",
tags: T_ID,
params: IdParam,
body: EntityIdentitySet,
@@ -221,7 +221,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "post",
path: "/media-entities/{id}/refresh",
scope: "organize.execute",
summary: "刷新元数据(字段级;用户锁优先,不动路径)",
summary: "刷新元数据(字段级;用户锁优先,不动路径)",
tags: T_ENT,
params: IdParam,
response: Accepted,
@@ -248,7 +248,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "put",
path: "/media-entities/{id}/fields/{field}",
scope: "organize.execute",
summary: "覆盖作品字段并锁定(自动刷新不再改写)",
summary: "覆盖作品字段并锁定(自动刷新不再改写)",
tags: T_ENT,
params: FieldParam,
body: FieldOverrideInput,
@@ -501,7 +501,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "get",
path: "/identity-pool",
scope: "read",
summary: "待识别池(可按原始标题检索;候选不建正式作品)",
summary: "待识别池(可按原始标题检索;候选不建正式作品)",
tags: T_ID,
query: IdentityPoolQuery,
response: cursorPage(IdentityPoolItem),
@@ -600,7 +600,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "post",
path: "/identity-pool/{id}/dismiss",
scope: "organize.execute",
summary: "忽略待识别资源(不再自动复查)",
summary: "忽略待识别资源(不再自动复查)",
tags: T_ID,
params: IdParam,
body: z.object({ note: z.string().max(2000).optional() }),
@@ -75,7 +75,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "get",
path: "/site-definitions",
scope: "read",
summary: "随产品发布的站点适配器定义(含是否已登记)",
summary: "随产品发布的站点适配器定义(含是否已登记)",
tags: T_SITES,
response: z.object({ items: z.array(SiteDefinitionView) }),
},
@@ -138,7 +138,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
throw new ApiException("bad_request", `站点 ${body.key} 的适配器类型应为 ${adapterOf(def)}`);
}
if (!def && body.adapter !== "generic_rss") {
throw new ApiException("bad_request", `注册表中没有站点 ${body.key};未适配站点只能以 generic_rss 登记`);
throw new ApiException("bad_request", `注册表中没有站点 ${body.key};未适配站点只能以 generic_rss 登记`);
}
try {
const row = await db()
@@ -199,7 +199,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "post",
path: `/sites/{id}/${enabled ? "enable" : "disable"}`,
scope: "admin",
summary: enabled ? "开启站点" : "关闭站点(停止网络访问与新候选,历史资源仍可查)",
summary: enabled ? "开启站点" : "关闭站点(停止网络访问与新候选,历史资源仍可查)",
tags: T_SITES,
params: IdParam,
response: Site,
@@ -213,7 +213,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "post",
path: "/sites/{id}/snapshot",
scope: "admin",
summary: "立即同步站点快照(受站点限流)",
summary: "立即同步站点快照(受站点限流)",
tags: T_SITES,
params: IdParam,
body: z.object({ maxPages: z.number().int().min(1).max(20).optional() }),
@@ -236,7 +236,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "get",
path: "/sites/{id}/snapshots",
scope: "read",
summary: "站点快照/补查记录(覆盖范围与新鲜度)",
summary: "站点快照/补查记录(覆盖范围与新鲜度)",
tags: T_SITES,
params: IdParam,
query: CursorQuery,
@@ -264,7 +264,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "delete",
path: "/sites/{id}",
scope: "admin",
summary: "删除站点:停用后删账号与凭证;已抓到的资源和下载任务保留(标站点已关闭)",
summary: "删除站点:停用后删账号与凭证;已抓到的资源和下载任务保留(标站点已关闭)",
tags: T_SITES,
params: IdParam,
response: OkResult,
@@ -281,7 +281,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "post",
path: "/sites/{id}/health-check",
scope: "admin",
summary: "检测健康:首页连通 + 登录态,同步返回并写站点健康列",
summary: "检测健康:首页连通 + 登录态,同步返回并写站点健康列",
tags: T_SITES,
params: IdParam,
response: SiteHealthResult,
@@ -294,7 +294,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "post",
path: "/sites/{id}/refresh-login",
scope: "admin",
summary: "刷新登录:账密重新登录换 Cookie;Cookie / API Key 校验;成功后拉用户数据",
summary: "刷新登录:账密重新登录换 Cookie;Cookie / API Key 校验;成功后拉用户数据",
tags: T_SITES,
params: IdParam,
body: SiteRefreshLoginInput,
@@ -356,7 +356,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "put",
path: "/sites/{id}/credentials",
scope: "admin",
summary: "设置站点账号凭证(按 kind:password / cookie / api_key / passkey;存为 secret,不回显)",
summary: "设置站点账号凭证(按 kind:password / cookie / api_key / passkey;存为 secret,不回显)",
tags: T_SITES,
params: IdParam,
body: SiteCredentialsPut,
@@ -625,7 +625,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "post",
path: "/feed-sources/{id}/reset",
scope: "admin",
summary: "手动恢复:关闭熔断、清空退避(凭证已更新后使用)",
summary: "手动恢复:关闭熔断、清空退避(凭证已更新后使用)",
tags: T_FEEDS,
params: IdParam,
response: FeedSource,
@@ -713,7 +713,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "post",
path: "/source-releases/{id}/identity",
scope: "organize.execute",
summary: "人工指定资源的作品/内容单元(加人工锁,自动识别不再改写)",
summary: "人工指定资源的作品/内容单元(加人工锁,自动识别不再改写)",
tags: T_RELEASES,
params: IdParam,
body: ReleaseIdentityAssign,
@@ -17,7 +17,7 @@ export function workbenchRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "get",
path: "/sites/{id}/releases",
scope: "read",
summary: "资源列表/搜索(快照优先,必要时实时补查;标注来源与更新时间)",
summary: "资源列表/搜索(快照优先,必要时实时补查;标注来源与更新时间)",
tags: T,
params: z.object({ id: Id }),
query: WorkbenchReleaseQuery,
@@ -31,7 +31,7 @@ export function workbenchRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "get",
path: "/sites/{id}/releases/{rid}",
scope: "read",
summary: "资源详情(refresh=true 时实时复核详情;失败保留既有数据)",
summary: "资源详情(refresh=true 时实时复核详情;失败保留既有数据)",
tags: T,
params: z.object({ id: Id, rid: Id }),
query: z.object({ refresh: z.enum(["true", "false"]).optional() }),
@@ -86,7 +86,7 @@ export class SourcesRuntime {
if (/^[0-9a-f]{32}$/i.test(secret)) apiKey = secret;
else accessToken = secret;
} catch (err) {
this.ctx.logger.warn({ err: { message: (err as Error).message } }, "TMDB 密钥解密失败,按未配置处理");
this.ctx.logger.warn({ err: { message: (err as Error).message } }, "TMDB 密钥解密失败,按未配置处理");
}
} else {
apiKey = env("TMDB_API_KEY");
@@ -41,14 +41,14 @@ export async function createEntityFromTmdbCandidate(
if (existing) return describe(db, existing, false, 0);
const client = await tmdb();
if (!client) throw new CandidateError("not_configured", "未配置 TMDB,无法按候选建作品");
if (!client) throw new CandidateError("not_configured", "未配置 TMDB,无法按候选建作品");
let detail: Awaited<ReturnType<TmdbClient["details"]>>;
try {
detail = await client.details(ref.kind, ref.externalId);
} catch (e) {
if (e instanceof TmdbError && e.kind === "notfound")
throw new CandidateError("not_found", `TMDB 无此条目 ${ref.kind}/${ref.externalId}`);
if (e instanceof TmdbError) throw new CandidateError("upstream", `TMDB 查询失败:${e.kind}`);
if (e instanceof TmdbError) throw new CandidateError("upstream", `TMDB 查询失败:${e.kind}`);
throw e;
}
@@ -144,7 +144,7 @@ export async function ingestFeed(
}
const lastError = auth
? { ...(state.lastError ?? {}), kind: "auth", message: "Feed 凭证失效或过期(401/403/过期提示),已停止自动重试" }
? { ...(state.lastError ?? {}), kind: "auth", message: "Feed 凭证失效或过期(401/403/过期提示),已停止自动重试" }
: (state.lastError ?? null);
// 疑似缺口:本次 ok 且零重叠 → 记下;本次 ok 且衔接上 → 清空;抓取失败 → 保留上次状态
const gap: StoredCursor["gap"] =
@@ -209,13 +209,13 @@ export async function ingestFeed(
kind: "source_unreachable",
severity: "warning",
title: `Feed「${feed.name}」凭证失效`,
summary: "RSS 返回 401/403 或过期提示,已停止自动重试。更新 Feed 链接或站点账号凭证后恢复抓取。",
summary: "RSS 返回 401/403 或过期提示,已停止自动重试。更新 Feed 链接或站点账号凭证后恢复抓取。",
subjectType: "feed_source",
subjectId: feed.id,
dedupeKey: `feed-auth:${feed.id}`,
evidence: { feedId: feed.id, siteId: feed.site_id, siteAccountId: feed.site_account_id, error: lastError },
options: [
{ key: "reset", label: "已更新凭证,恢复抓取" },
{ key: "reset", label: "已更新凭证,恢复抓取" },
{ key: "disable", label: "停用 Feed" },
],
});
@@ -34,16 +34,16 @@ async function entityForExternal(rt: SourcesRuntime, exec: Exec, ref: ExternalRe
const hit = await entityByExternal(exec, ref);
if (hit) return hit;
if (ref.provider !== "tmdb")
throw new ApiException("bad_request", `未登记的外部身份 ${ref.provider}:${ref.externalId},只支持按 TMDB 建档`);
throw new ApiException("bad_request", `未登记的外部身份 ${ref.provider}:${ref.externalId},只支持按 TMDB 建档`);
const tmdb = await rt.tmdb();
if (!tmdb) throw new ApiException("precondition_failed", "未配置 TMDB,无法按外部身份建档");
if (!tmdb) throw new ApiException("precondition_failed", "未配置 TMDB,无法按外部身份建档");
let detail: Awaited<ReturnType<typeof tmdb.details>>;
try {
detail = await tmdb.details(ref.entityType, ref.externalId);
} catch (e) {
if (e instanceof TmdbError && e.kind === "notfound")
throw new ApiException("not_found", `TMDB 无此条目 ${ref.entityType}/${ref.externalId}`);
if (e instanceof TmdbError) throw new ApiException("upstream_unavailable", `TMDB 查询失败:${e.kind}`);
if (e instanceof TmdbError) throw new ApiException("upstream_unavailable", `TMDB 查询失败:${e.kind}`);
throw e;
}
const r = await createEntityFromTmdb(exec, detail, {
@@ -241,7 +241,7 @@ export async function setEntityIdentity(rt: SourcesRuntime, entityId: string, in
if (!id) throw new ApiException("not_found", "作品不存在");
const other = await entityByExternal(trx, input);
if (other && other !== id) {
throw new ApiException("conflict", `该外部身份已属于作品 ${other}。如果是重复作品,改用合并`);
throw new ApiException("conflict", `该外部身份已属于作品 ${other}。如果是重复作品,改用合并`);
}
if (other === id) return { mediaEntityId: id, changed: false };
const olds = await trx
@@ -290,7 +290,7 @@ export async function updateExternalIdentity(
.executeTakeFirst();
if (!row) throw new ApiException("not_found", "外部身份不存在");
if (row.revision !== input.expectedRevision) {
throw new ApiException("precondition_failed", `修订号已变化(当前 ${row.revision})`);
throw new ApiException("precondition_failed", `修订号已变化(当前 ${row.revision})`);
}
const rev = Math.max(row.revision + 1, await nextRevision(trx, id));
const patch: Record<string, unknown> = { revision: rev };
@@ -365,7 +365,7 @@ export async function mergeEntities(
if (!from) throw new ApiException("not_found", "源作品不存在");
if (!intoResolved) throw new ApiException("not_found", "目标作品不存在");
if (from.status === "merged") throw new ApiException("conflict", "源作品已被合并");
if (intoResolved === fromId) throw new ApiException("conflict", "目标作品已合并到源作品,会形成环");
if (intoResolved === fromId) throw new ApiException("conflict", "目标作品已合并到源作品,会形成环");
// 外部身份:目标已有同 provider+类型 accepted 的,源的退役;否则迁到目标
const idents = await trx
@@ -511,13 +511,13 @@ function manualIdentityOptions(sourceReleaseId: string) {
{
key: "manual_tmdb",
label: "手动指定 TMDB ID",
description: "按 TMDB 电影或剧集 ID 建档并关联,加人工锁",
description: "按 TMDB 电影或剧集 ID 建档并关联,加人工锁",
input: { sourceReleaseId, requires: "tmdb" },
},
{
key: "local_entity",
label: "选本地作品",
description: "关联到已有的本地作品,加人工锁",
description: "关联到已有的本地作品,加人工锁",
input: { sourceReleaseId, requires: "mediaEntityId" },
},
];
@@ -592,7 +592,7 @@ async function toPool(
const d = await openDecision(ctx, trx, {
kind: "identity_ambiguous",
severity: "info",
title: `待确认身份:${release.title}`,
title: `待确认身份:${release.title}`,
summary: `${candidates.length} 个候选证据等级相近(${detailText(detail)})。选择一个或忽略`,
subjectType: "source_release",
subjectId: release.id,
@@ -615,10 +615,10 @@ async function toPool(
const d = await openDecision(ctx, trx, {
kind: "identity_unresolved",
severity: "info",
title: `待识别:${release.title}`,
title: `待识别:${release.title}`,
summary:
reason === "lookup_failed"
? `元数据查询多次失败(${detail}),已停止自动复查。手动指定作品或忽略`
? `元数据查询多次失败(${detail}),已停止自动复查。手动指定作品或忽略`
: `没有匹配的作品(${detailText(detail)})。手动指定 TMDB ID、选本地作品或忽略`,
subjectType: "source_release",
subjectId: release.id,
@@ -91,7 +91,7 @@ async function onAuthError(rt: SourcesRuntime, trx: Exec, site: SiteRow, e: Site
subjectId: site.id,
dedupeKey: `site-auth:${site.id}`,
evidence: { siteId: site.id, kind: e.kind, httpStatus: e.httpStatus ?? null },
options: [{ key: "reset", label: "已更新凭证,恢复访问" }],
options: [{ key: "reset", label: "已更新凭证,恢复访问" }],
});
// 有密码的账号:自动重登一次(同账号 10 分钟内最多一次),成功后关闭本待处理并复位站点健康
await enqueueRelogin(rt, site.id);
@@ -41,7 +41,7 @@ export async function liveSearch(
const st = liveState(rt);
const key = `${site.id}|${normalizeTitle(q) || q.toLowerCase()}`;
const failed = st.failedUntil.get(key);
if (failed && failed.until > rt.now().getTime()) return { error: `${failed.error}(退避中)`, coalesced: false };
if (failed && failed.until > rt.now().getTime()) return { error: `${failed.error}(退避中)`, coalesced: false };
const running = st.inflight.get(key);
if (running) return { ...(await running), coalesced: true };
const p = (async () => {
@@ -149,7 +149,7 @@ export async function listSiteReleases(
let coalesced = false;
const wantLive = q.live === "true" || (q.live !== "false" && offset === 0 && !!q.q && (rows.length === 0 || stale));
if (wantLive && q.q) {
if (!site.enabled) liveError = "站点已关闭,只展示历史数据";
if (!site.enabled) liveError = "站点已关闭,只展示历史数据";
else {
const r = await liveSearch(rt, site, q.q);
live = r.error === null;
+5 -5
View File
@@ -24,7 +24,7 @@ export async function runPreflight({ config, db, uid = Deno.uid() }: PreflightDe
const prod = config.NODE_ENV === "production";
if (prod && PLACEHOLDER.test(config.SECRET_MASTER_KEY)) {
problems.push({ check: "secret", message: "SECRET_MASTER_KEY 仍是占位符,生产环境必须换成随机串" });
problems.push({ check: "secret", message: "SECRET_MASTER_KEY 仍是占位符,生产环境必须换成随机串" });
}
if (prod && uid === 0) {
problems.push({ check: "user", message: "生产环境不允许以 root 运行" });
@@ -32,7 +32,7 @@ export async function runPreflight({ config, db, uid = Deno.uid() }: PreflightDe
try {
await sql`select 1`.execute(db);
} catch (err) {
problems.push({ check: "db", message: `数据库不可达: ${(err as Error).message}` });
problems.push({ check: "db", message: `数据库不可达:${(err as Error).message}` });
}
if (config.WEB_ROOT) {
try {
@@ -109,7 +109,7 @@ export async function checkStorage(opts: {
await Deno.mkdir(dir, { recursive: true });
await probeWrite(dir);
} catch (e) {
problems.push({ check: "data_dir", message: `数据目录不可写: ${dir}(${errText(e)});检查卷挂载与 PUID/PGID` });
problems.push({ check: "data_dir", message: `数据目录不可写:${dir}(${errText(e)});检查卷挂载与 PUID/PGID` });
}
}
for (const m of opts.mounts) {
@@ -118,13 +118,13 @@ export async function checkStorage(opts: {
if (!st.isDirectory) throw Object.assign(new Error("不是目录"), { code: "ENOTDIR" });
for await (const _ of Deno.readDir(m.path)) break;
} catch (e) {
warnings.push({ ...m, reason: `不可读: ${errText(e)}` });
warnings.push({ ...m, reason: `不可读:${errText(e)}` });
continue;
}
try {
await probeWrite(m.path);
} catch (e) {
warnings.push({ ...m, reason: `不可写: ${errText(e)}` });
warnings.push({ ...m, reason: `不可写:${errText(e)}` });
}
}
return { problems, warnings };
+1 -1
View File
@@ -244,7 +244,7 @@ describe.skipIf(skip)("files W5-A2", () => {
const tvTargets = tv.files.map((f: { target: string | null }) => f.target);
expect(tvTargets.some((t: string | null) => t?.endsWith("Season 2/剧 - S02E03-E04 - 第 3-4 集.mkv"))).toBe(true);
// 非连续集号不应用(只按季覆写,文件名无集号 → 计划排除),其余覆写不进 warnings
expect(tv.warnings).toEqual([expect.stringMatching(/weird-b\.mkv: 集号 5,7 不连续/)]);
expect(tv.warnings).toEqual([expect.stringMatching(/weird-b\.mkv:集号 5,7 不连续/)]);
expect(mv.warnings).toEqual([expect.stringMatching(/电影不适用季\/集覆写/)]);
expect(mv).toMatchObject({ action: "copy", slotId: slot.id, targetDir: join(h.lib, "picked") });
expect(mv.files[0].target).toBe(join(h.lib, "picked", "Film (2019) - 1080p.mkv"));
+2 -2
View File
@@ -78,10 +78,10 @@ export function useFetchFeedNow() {
export function feedGapHint(f: FeedSource, now = Date.now()): string | null {
if (f.possibleGap) {
const at = new Date(f.possibleGap.detectedAt).toLocaleString("zh-CN", { hour12: false });
return `疑似缺口(${at} 检测,${f.possibleGap.backfillQueued ? "已排补漏" : "Feed 未关联站点,无法自动补漏"})`;
return `疑似缺口(${at} 检测,${f.possibleGap.backfillQueued ? "已排补漏" : "Feed 未关联站点,无法自动补漏"})`;
}
if (!f.enabled || !f.lastSuccessAt) return null;
const gapMs = now - new Date(f.lastSuccessAt).getTime();
if (gapMs > f.pollIntervalSec * 1000 * 3) return "距上次成功已超过 3 个周期,可能漏项";
if (gapMs > f.pollIntervalSec * 1000 * 3) return "距上次成功已超过 3 个周期,可能漏项";
return null;
}
+1 -1
View File
@@ -13,7 +13,7 @@ export async function apiParsed<S extends z.ZodType>(schema: S, path: string, ar
const issue = parsed.error.issues[0];
throw new ApiRequestError(
"internal" satisfies ApiErrorCode,
`响应格式不符合接口定义:${issue ? `${issue.path.join(".")} ${issue.message}` : "未知字段"}`,
`响应格式不符合接口定义:${issue ? `${issue.path.join(".")} ${issue.message}` : "未知字段"}`,
);
}
return parsed.data;
+7 -7
View File
@@ -25,7 +25,7 @@ const DecisionPage = cursorPage(Decision);
const RESOLUTION_LABEL = {
unresolved: "待识别",
resolved: "已识别",
ambiguous: "有歧义,待确认",
ambiguous: "有歧义,待确认",
unidentifiable: "无法识别",
rejected: "已排除",
} as const;
@@ -162,8 +162,8 @@ export function buildTimeline(input: {
at: release.firstSeenAt,
title:
release.resolutionStatus === "resolved"
? "识别结果:已关联本地作品"
: `识别结果:${RESOLUTION_LABEL[release.resolutionStatus]}`,
? "识别结果:已关联本地作品"
: `识别结果:${RESOLUTION_LABEL[release.resolutionStatus]}`,
tone: release.resolutionStatus === "resolved" ? "success" : "warning",
});
}
@@ -181,7 +181,7 @@ export function buildTimeline(input: {
key: "plan-status",
stage: "plan",
at: plan.updatedAt,
title: `计划状态:${PLAN_STATUS_LABEL[plan.status]}`,
title: `计划状态:${PLAN_STATUS_LABEL[plan.status]}`,
detail: plan.statusReason,
tone: plan.status === "failed" ? "destructive" : plan.status === "expired" ? "warning" : "neutral",
});
@@ -199,7 +199,7 @@ export function buildTimeline(input: {
key: "dl-pause",
stage: "download",
at: task.updatedAt,
title: task.pauseReason === "threshold" ? "缓存达到硬阈值,已暂停" : `已暂停(${task.pauseReason})`,
title: task.pauseReason === "threshold" ? "缓存达到硬阈值,已暂停" : `已暂停(${task.pauseReason})`,
tone: "warning",
ongoing: task.status === "paused",
});
@@ -208,7 +208,7 @@ export function buildTimeline(input: {
key: "dl-stall",
stage: "download",
at: task.noProgressSince,
title: "无进展,不自动换源",
title: "无进展,不自动换源",
tone: "warning",
ongoing: task.status === "stalled",
});
@@ -250,7 +250,7 @@ export function buildTimeline(input: {
key: `dec-${d.id}`,
stage: "agent",
at: d.createdAt,
title: `待处理:${d.title}`,
title: `待处理:${d.title}`,
detail: d.summary,
tone: d.severity === "critical" ? "destructive" : "warning",
href: "/console/inbox",
@@ -85,7 +85,9 @@ export function FileDiff({
{l.type === "added" ? "+" : l.type === "removed" ? "−" : " "}
</span>
<span className="min-w-0 break-all text-foreground/85">
<span className="sr-only">{l.type === "added" ? "新增:" : l.type === "removed" ? "移除:" : ""}</span>
<span className="sr-only">
{l.type === "added" ? "新增:" : l.type === "removed" ? "移除:" : ""}
</span>
{l.content}
</span>
{l.note ? <span className="shrink-0 font-sans text-muted-foreground">{l.note}</span> : null}
@@ -171,7 +171,7 @@ export function MessageScroller({
>
<ArrowDown aria-hidden className="size-3.5" />
{jumpLabel}
{busy ? <span className="sr-only">(仍在输出)</span> : null}
{busy ? <span className="sr-only">(仍在输出)</span> : null}
</button>
) : null}
</div>
@@ -68,7 +68,7 @@ export interface ToolApprovalProps {
export function ToolApproval({
tool,
title = "允许执行这个工具?",
title = "允许执行这个工具?",
description,
parameters = [],
status = "pending",
@@ -134,7 +134,7 @@ export function ToolApproval({
</div>
{badges ? <div className="mt-1.5 flex flex-wrap items-center gap-1">{badges}</div> : null}
{rejected && reason ? (
<p className="mt-2 text-caption font-medium break-words text-destructive">原因:{reason}</p>
<p className="mt-2 text-caption font-medium break-words text-destructive">原因:{reason}</p>
) : null}
{description ? <p className="mt-1.5 text-caption leading-5 text-muted-foreground">{description}</p> : null}
{rejected && explanation ? (
@@ -89,7 +89,7 @@ export function InboxBell() {
{list.isPending ? (
<p className="px-4 py-6 text-center text-caption text-muted-foreground">加载中…</p>
) : list.isError ? (
<p className="px-4 py-6 text-center text-caption text-destructive">加载失败,稍后再试</p>
<p className="px-4 py-6 text-center text-caption text-destructive">加载失败,稍后再试</p>
) : groups.length === 0 ? (
<div className="flex flex-col items-center gap-2 px-4 py-8 text-caption text-muted-foreground">
<CheckCheck className="h-5 w-5" />
+4 -4
View File
@@ -51,13 +51,13 @@ export const NAV_GROUPS: NavGroup[] = [
to: "/console/tasks",
label: "任务",
icon: ListTodo,
description: "每次运行都是一条任务:定时、事件、手动、Agent 触发都在这里,含定时任务计划",
description: "每次运行都是一条任务:定时、事件、手动、Agent 触发都在这里,含定时任务计划",
},
{
to: "/console/agent",
label: "Agent 会话",
icon: Bot,
description: "交代目标,Agent 在授权内执行并汇报",
description: "交代目标,Agent 在授权内执行并汇报",
},
],
},
@@ -68,7 +68,7 @@ export const NAV_GROUPS: NavGroup[] = [
to: "/console/sites",
label: "站点",
icon: Globe,
description: "站点、凭证、账号数据与健康;资源、快照与 Feed",
description: "站点、凭证、账号数据与健康;资源、快照与 Feed",
},
{ to: "/console/sources", label: "RSS 订阅", icon: Rss, description: "全部 Feed 与抓取状态" },
],
@@ -94,7 +94,7 @@ export const NAV_GROUPS: NavGroup[] = [
to: "/console/files",
label: "文件管理",
icon: FolderTree,
description: "浏览登记目录,改名、移动、删除与整理",
description: "浏览登记目录,改名、移动、删除与整理",
},
{
to: "/console/library",
@@ -103,7 +103,7 @@ export function useTaskAction() {
body: op === "cancel" ? { reason: "控制台取消" } : {},
}).then((d) => Task.parse(d)),
onSuccess: (_t, v) => {
toast.success(v.op === "cancel" ? "已请求取消,后续步骤不再执行" : "已重新排队");
toast.success(v.op === "cancel" ? "已请求取消,后续步骤不再执行" : "已重新排队");
void qc.invalidateQueries({ queryKey: ["tasks"] });
},
onError: (e) => toast.error(errorMessage(e)),
@@ -37,7 +37,7 @@ export function BusinessTimeline({ events, loading }: { events: TimelineEvent[];
})}
</div>
{ongoing.length ? (
<p className="text-caption text-muted-foreground">当前:{ongoing.map((e) => e.title).join(";")}</p>
<p className="text-caption text-muted-foreground">当前:{ongoing.map((e) => e.title).join(";")}</p>
) : null}
{loading && !events.length ? (
<LoadingState rows={3} />
@@ -76,8 +76,8 @@ function VolumeBar({ v }: { v: Volume }) {
<span>保留锁定 {formatBytes(v.retentionLockedBytes, 1)}</span>
</div>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{pct === null ? <StatusBadge tone="warning">读不到磁盘状态,已暂停新提交</StatusBadge> : null}
{tripped ? <StatusBadge tone="destructive">已达硬阈值,停止新提交</StatusBadge> : null}
{pct === null ? <StatusBadge tone="warning">读不到磁盘状态,已暂停新提交</StatusBadge> : null}
{tripped ? <StatusBadge tone="destructive">已达硬阈值,停止新提交</StatusBadge> : null}
{v.pausedByThreshold ? <StatusBadge tone="warning">阈值暂停 {v.pausedByThreshold} 个任务</StatusBadge> : null}
</div>
{v.notes.map((n) => (
@@ -112,8 +112,8 @@ export function CacheVolumes() {
立即清理
</Button>
}
title="按规则清理下载缓存?"
description="只清理已入库、且满足保留期与 H&R 的纳管任务,其余不动。"
title="按规则清理下载缓存?"
description="只清理已入库、且满足保留期与 H&R 的纳管任务,其余不动。"
confirmLabel="清理"
onConfirm={() =>
cleanup.mutateAsync(undefined).then(
@@ -69,7 +69,7 @@ export function PauseResume({ t, size = "sm" }: { t: DownloadTask; size?: "sm" |
variant="outline"
size={size}
disabled={act.isPending}
title={threshold ? "阈值暂停,缓存回落到恢复水位后自动恢复" : undefined}
title={threshold ? "阈值暂停,缓存回落到恢复水位后自动恢复" : undefined}
onKeyDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
@@ -117,7 +117,7 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
size="sm"
onClick={() =>
openAgentDrawer({
label: `下载:${task.name}`,
label: `下载:${task.name}`,
downloadTaskId: task.id,
goal: task.status === "stalled" || task.status === "failed" ? "排查这个下载为什么没有进展" : "",
})
@@ -131,12 +131,12 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
{task.pauseReason === "threshold" ? (
<p className="flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
缓存卷达到硬阈值,任务已暂停,水位回落到恢复线后自动恢复。手动恢复和 Agent 都不能绕过阈值。
缓存卷达到硬阈值,任务已暂停,水位回落到恢复线后自动恢复。手动恢复和 Agent 都不能绕过阈值。
</p>
) : null}
{task.noProgressSince ? (
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
自 {formatDateTime(task.noProgressSince)} 起无进展,已记入待处理,不会自动换源。
自 {formatDateTime(task.noProgressSince)} 起无进展,已记入待处理,不会自动换源。
</p>
) : null}
+35 -29
View File
@@ -184,14 +184,14 @@ export function MkdirDialog({
// ─── 重命名 ─────────────────────────────────────────────────────
const SUGGEST_REASON: Record<string, string> = {
identity_uncertain: "识别不可信,没有推荐名",
identity_uncertain: "识别不可信,没有推荐名",
};
const TEMPLATE_SOURCE: Record<string, string> = { entity: "作品覆盖", root: "库根", global: "全局", override: "覆写" };
function suggestReasonText(r: string | null): string {
if (!r) return "没有推荐名";
if (SUGGEST_REASON[r]) return SUGGEST_REASON[r] as string;
if (r.startsWith("naming_failed")) return `命名模板渲染失败:${r.replace(/^naming_failed:\s*/, "")}`;
if (r.startsWith("naming_failed")) return `命名模板渲染失败:${r.replace(/^naming_failed:\s*/, "")}`;
return r;
}
@@ -291,13 +291,13 @@ export function RenameDialog({
ev.preventDefault();
if (problem) return setErr(problem);
if (!changed) return onClose();
if (taken) return setErr("同名已存在,不会覆盖");
if (taken) return setErr("同名已存在,不会覆盖");
exec.mutate(
{ idempotencyKey: key, root: root.id, path: entry.path, newName: name.trim(), withSidecars, dryRun: false },
{
onSuccess: (r) => {
toast.success(
`已重命名${r.renames.length > 1 ? `(含 ${r.renames.length - 1} 个附属文件)` : ""}`,
`已重命名${r.renames.length > 1 ? `(含 ${r.renames.length - 1} 个附属文件)` : ""}`,
r.recordId
? {
action: {
@@ -313,9 +313,9 @@ export function RenameDialog({
const reason = filesErrorReason(e);
setErr(
reason === "exists"
? "同名已存在,不会覆盖"
? "同名已存在,不会覆盖"
: reason === "download_active"
? "做种中,改名会让下载器找不到文件"
? "做种中,改名会让下载器找不到文件"
: filesErrorText(e),
);
},
@@ -338,7 +338,7 @@ export function RenameDialog({
<FormField
label="新名称"
htmlFor="rename-name"
error={err ?? (problem && changed ? problem : taken ? "同名已存在,不会覆盖" : null)}
error={err ?? (problem && changed ? problem : taken ? "同名已存在,不会覆盖" : null)}
>
<div className="flex gap-2">
<Input
@@ -378,9 +378,9 @@ export function RenameDialog({
? "当前名称与命名模板一致"
: usingSuggestion
? "已填入推荐名"
: `推荐名:${suggested.name}`}
: `推荐名:${suggested.name}`}
{suggested.templateSource
? `· 模板来源:${TEMPLATE_SOURCE[suggested.templateSource] ?? suggested.templateSource}`
? `· 模板来源:${TEMPLATE_SOURCE[suggested.templateSource] ?? suggested.templateSource}`
: ""}
{usingSuggestion && name.trim() !== entry.name ? (
<button
@@ -434,10 +434,10 @@ export function RenameDialog({
))}
</ul>
{pv.assetLocations > 0 ? (
<Notice tone="neutral">同步 {pv.assetLocations} 条资产位置,写一条整理记录。</Notice>
<Notice tone="neutral">同步 {pv.assetLocations} 条资产位置,写一条整理记录。</Notice>
) : null}
{pv.bindings.length > 0 ? (
<Notice tone="warning">这是作品或季的绑定目录。改名后旧绑定标为已替代,之后入库用新目录。</Notice>
<Notice tone="warning">这是作品或季的绑定目录。改名后旧绑定标为已替代,之后入库用新目录。</Notice>
) : null}
</>
) : null}
@@ -532,7 +532,7 @@ export function TransferDialog({
onError: (e) => {
const reason = filesErrorReason(e);
if (reason === "plan_stale") {
setErr("源文件在预览后有变化,已重新预览");
setErr("源文件在预览后有变化,已重新预览");
runPreview(target, mode);
} else setErr(filesErrorText(e));
},
@@ -613,13 +613,15 @@ export function TransferDialog({
{plan.freeBytes != null ? ` · 剩余 ${formatBytes(plan.freeBytes, 1)}` : ""}
</StatusBadge>
{conflicts.length ? (
<StatusBadge tone="warning">冲突 {conflicts.length}(跳过,不覆盖)</StatusBadge>
<StatusBadge tone="warning">冲突 {conflicts.length}(跳过,不覆盖)</StatusBadge>
) : null}
{plan.pairs.some((p) => p.registered) ? (
<StatusBadge>含已登记资产,执行后同步位置</StatusBadge>
) : null}
{plan.pairs.some((p) => p.registered) ? <StatusBadge>含已登记资产,执行后同步位置</StatusBadge> : null}
</div>
{hnrBlocked ? (
<Notice tone="destructive">
{plan.hnrBlocks.length} 项做种中且 H&R 未满足,不能移动。改用复制,或等 H&R 满足后再移动。
{plan.hnrBlocks.length} 项做种中且 H&R 未满足,不能移动。改用复制,或等 H&R 满足后再移动。
<button
type="button"
className="ml-1 underline"
@@ -633,7 +635,9 @@ export function TransferDialog({
</button>
</Notice>
) : null}
{plan.truncated ? <Notice tone="warning">源文件过多,预览只列出前一部分,执行时处理全部。</Notice> : null}
{plan.truncated ? (
<Notice tone="warning">源文件过多,预览只列出前一部分,执行时处理全部。</Notice>
) : null}
<ul className="space-y-1.5">
{plan.pairs.map((p) => {
const sr = rootById.get(p.source.root);
@@ -761,7 +765,7 @@ export function DeleteDialog({
reason === "permanent_disabled"
? "设置里未开启永久删除(files.allowPermanentDelete)"
: reason === "approval_required"
? "用 API Key 删除,每次都要审批"
? "用 API Key 删除,每次都要审批"
: filesErrorText(e),
);
},
@@ -772,7 +776,7 @@ export function DeleteDialog({
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[92dvh] grid-rows-[auto_minmax(0,1fr)_auto] sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{single ? `删除 ${single}?` : `删除 ${items.length} 项?`}</DialogTitle>
<DialogTitle>{single ? `删除 ${single}?` : `删除 ${items.length} 项?`}</DialogTitle>
<DialogDescription>已检查做种任务、已登记资产和路径绑定。下载器里的任务不受影响。</DialogDescription>
</DialogHeader>
<div className="min-h-0 space-y-3 overflow-y-auto">
@@ -820,15 +824,15 @@ export function DeleteDialog({
</p>
))}
{c.assets.length ? (
<p>已登记资产 {c.assets.length} 个(删除后退役,已满足的版本目标退回待处理)</p>
<p>已登记资产 {c.assets.length} 个(删除后退役,已满足的版本目标退回待处理)</p>
) : null}
{c.bindings.map((bd) => (
<p key={bd.bindingId}>
路径绑定:{bd.title}
路径绑定:{bd.title}
{bd.seasonNumber != null ? ` 第 ${bd.seasonNumber} 季` : ""}
</p>
))}
{c.sharedInode ? <p>共享 inode(硬链接):只删这一处链接,其他位置不受影响</p> : null}
{c.sharedInode ? <p>共享 inode(硬链接):只删这一处链接,其他位置不受影响</p> : null}
</div>
</li>
);
@@ -841,7 +845,7 @@ export function DeleteDialog({
<span>
同时退役资产
<span className="block text-meta text-muted-foreground">
资产位置标为已移除,资产退役;已满足的版本目标退回待处理。
资产位置标为已移除,资产退役;已满足的版本目标退回待处理。
</span>
</span>
</label>
@@ -851,7 +855,9 @@ export function DeleteDialog({
<input type="checkbox" className="mt-1" checked={unbind} onChange={(e) => setUnbind(e.target.checked)} />
<span>
解除路径绑定
<span className="block text-meta text-muted-foreground">绑定标为已替代,该作品下次入库重新选目录。</span>
<span className="block text-meta text-muted-foreground">
绑定标为已替代,该作品下次入库重新选目录。
</span>
</span>
</label>
) : null}
@@ -931,8 +937,8 @@ export function CopyPathDialog({ root, entry, onClose }: { root: FileRoot; entry
const local = `${root.hostPath}${entry.path ? `/${entry.path}` : ""}`;
const rows = [
{ label: "本机路径", value: local },
{ label: "真实路径(解析链接后)", value: stat.data?.realPath ?? null },
{ label: "登记目录内相对路径", value: entry.path || "(登记目录本身)" },
{ label: "真实路径(解析链接后)", value: stat.data?.realPath ?? null },
{ label: "登记目录内相对路径", value: entry.path || "(登记目录本身)" },
];
const copy = async (v: string) => {
try {
@@ -969,7 +975,7 @@ export function CopyPathDialog({ root, entry, onClose }: { root: FileRoot; entry
</li>
))}
</ul>
<p className="text-meta text-muted-foreground">下载器与媒体服务器看到的路径按“设置 → 路径映射”换算。</p>
<p className="text-meta text-muted-foreground">下载器与媒体服务器看到的路径按「设置 → 路径映射」换算。</p>
</DialogContent>
</Dialog>
);
@@ -994,7 +1000,7 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
onSuccess: (r) => {
setResult(r);
(r.skipped.length ? toast.warning : toast.success)(
`已提交 ${r.submitted.length} 个作品,锁定字段不变${r.skipped.length ? `;${r.skipped.length} 个未提交` : ""}`,
`已提交 ${r.submitted.length} 个作品,锁定字段不变${r.skipped.length ? `;${r.skipped.length} 个未提交` : ""}`,
);
},
onError: (e) => toast.error(filesErrorText(e, "提交失败")),
@@ -1005,7 +1011,7 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
<DialogContent>
<DialogHeader>
<DialogTitle>刷新元数据{single ? ` · ${single.title}` : ` · ${entities.length} 个作品`}</DialogTitle>
<DialogDescription>按字段刷新,锁定的字段和路径不变。即 MP 的“刮削”。</DialogDescription>
<DialogDescription>按字段刷新,锁定的字段和路径不变。即 MP 的「刮削」。</DialogDescription>
</DialogHeader>
{!single && !result ? (
<ul className="max-h-40 space-y-0.5 overflow-auto text-caption">
@@ -1028,7 +1034,7 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
</Link>
</p>
<p className="text-meta text-muted-foreground">
{s.lockedFields.length ? `锁定字段:${s.lockedFields.join("、")}` : "没有锁定字段"}
{s.lockedFields.length ? `锁定字段:${s.lockedFields.join("、")}` : "没有锁定字段"}
</p>
</div>
))}
@@ -132,7 +132,7 @@ export function FileDetail({
label: "链接数",
value:
stat.data?.nlink != null
? `${stat.data.nlink}${stat.data.sharedInode ? "(共享 inode,不能原地改写)" : ""}`
? `${stat.data.nlink}${stat.data.sharedInode ? "(共享 inode,不能原地改写)" : ""}`
: null,
},
{ label: "本机路径", value: `${root.hostPath}${e.path ? `/${e.path}` : ""}`, mono: true, wide: true },
@@ -223,7 +223,7 @@ export function FileDetail({
</li>
))}
</ul>
<p className="mt-1 text-meta text-muted-foreground">改名或移动此目录会同步更新绑定;刷新元数据不改路径。</p>
<p className="mt-1 text-meta text-muted-foreground">改名或移动此目录会同步更新绑定;刷新元数据不改路径。</p>
</DetailSection>
) : null}
@@ -253,7 +253,7 @@ export function FileDetail({
{
onSuccess: (r) => {
const it = r.items[0];
if (it?.status !== "ok") toast.warning(`探测不到媒体信息:${it?.reason ?? "未知原因"}`);
if (it?.status !== "ok") toast.warning(`探测不到媒体信息:${it?.reason ?? "未知原因"}`);
},
onError: (err) => toast.error(filesErrorText(err, "探测失败")),
},
@@ -286,7 +286,7 @@ export function FileDetail({
/>
) : (
<p className="text-caption text-muted-foreground">
未登记文件不自动探测。点“探测”读取 ffprobe 摘要,结果缓存 24 小时。
未登记文件不自动探测。点「探测」读取 ffprobe 摘要,结果缓存 24 小时。
</p>
)}
</DetailSection>
+1 -1
View File
@@ -94,7 +94,7 @@ function Subline({ e }: { e: FileEntry }) {
"block truncate text-meta",
s.tone === "hint" ? "text-muted-foreground/70" : "text-muted-foreground",
)}
title={s.tone === "hint" ? `按文件名解析:${s.text}` : s.text}
title={s.tone === "hint" ? `按文件名解析:${s.text}` : s.text}
>
{s.text}
</span>
@@ -1,7 +1,7 @@
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/components/ui/dialog.tsx";
const KEYS: [string, string][] = [
["↑ ↓ / Home End", "移动焦点(Shift 扩选)"],
["↑ ↓ / Home End", "移动焦点(Shift 扩选)"],
["Enter", "打开目录 / 看详情"],
["Backspace / Alt+↑", "上一级"],
["Space", "勾选当前行"],
@@ -9,11 +9,11 @@ const KEYS: [string, string][] = [
["Ctrl/⌘+A", "全选"],
["Ctrl/⌘+Enter", "整理选中项"],
["F2", "重命名"],
["Del", "删除(先进回收站)"],
["Del", "删除(先进回收站)"],
["/", "搜索当前目录"],
["Esc", "清除选择"],
["Shift+F10 / 菜单键", "打开操作菜单"],
["拖到目录", "移动到(按住 Alt 复制),先出预览"],
["拖到目录", "移动到(按住 Alt 复制),先出预览"],
["?", "显示这张表"],
];
@@ -87,7 +87,7 @@ export function IdentityDialog({
setErrors({});
set.mutate(r.data, {
onSuccess: () => {
toast.success(`已改为 TMDB ${r.data.entityType}:${r.data.externalId}${r.data.lock ? ",并加人工锁" : ""}`);
toast.success(`已改为 TMDB ${r.data.entityType}:${r.data.externalId}${r.data.lock ? ",并加人工锁" : ""}`);
onOpenChange(false);
},
onError: (err) => toast.error(errorMessage(err, "修改失败")),
@@ -102,7 +102,7 @@ export function IdentityDialog({
<DialogHeader>
<DialogTitle>修改外部身份</DialogTitle>
<DialogDescription>
同类型的旧 TMDB 身份会退役,保留修订记录。加人工锁后,自动匹配和刷新不再改动。
同类型的旧 TMDB 身份会退役,保留修订记录。加人工锁后,自动匹配和刷新不再改动。
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="grid gap-3">
@@ -249,7 +249,7 @@ export function FieldLockDialog({
<Dialog open={!!field} onOpenChange={(o) => !lock.isPending && onOpenChange(o)}>
<DialogContent>
<DialogHeader>
<DialogTitle>锁定字段:{field ? (ENTITY_FIELD_LABEL[field] ?? field) : ""}</DialogTitle>
<DialogTitle>锁定字段:{field ? (ENTITY_FIELD_LABEL[field] ?? field) : ""}</DialogTitle>
<DialogDescription>锁定后刷新元数据不改这个字段。已绑定的路径不变。</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="grid gap-3">
@@ -383,7 +383,7 @@ export function MergeDialog({
<DialogHeader>
<DialogTitle>合并作品</DialogTitle>
<DialogDescription>
「{entity.title}」会标为已合并,旧 ID 跳转到目标作品,订阅、资产和历史记录一并转过去。合并错了可以再修订。
「{entity.title}」会标为已合并,旧 ID 跳转到目标作品,订阅、资产和历史记录一并转过去。合并错了可以再修订。
</DialogDescription>
</DialogHeader>
<EntityPicker value={target} onChange={setTarget} excludeId={entity.id} />
@@ -411,10 +411,10 @@ export function MergeDialog({
setConfirm(o);
if (!o && !merge.isSuccess) return;
}}
title="合并作品?"
title="合并作品?"
description={
target
? `把「${entity.title}」合并到「${target.title}${target.year ? ` (${target.year})` : ""}」,完成后跳转到目标作品。`
? `把「${entity.title}」合并到「${target.title}${target.year ? ` (${target.year})` : ""}」,完成后跳转到目标作品。`
: ""
}
confirmLabel="合并"
@@ -80,7 +80,7 @@ function AddAlias({ entity }: { entity: PickedEntity }) {
value={f.alias}
aria-invalid={!!errors.alias}
onChange={(e) => setF((x) => ({ ...x, alias: e.target.value }))}
placeholder="如:沙丘2"
placeholder="如:沙丘 2"
/>
</FormField>
<FormField label="类型" htmlFor="alias-kind">
@@ -170,8 +170,8 @@ export function AliasesPanel() {
<Trash2 />
</Button>
}
title={`删除别名「${a.alias}」?`}
description="删除后不再用这个别名匹配作品,已确认的外部身份不受影响。"
title={`删除别名「${a.alias}」?`}
description="删除后不再用这个别名匹配作品,已确认的外部身份不受影响。"
confirmLabel="删除"
destructive
onConfirm={async () => {
@@ -14,7 +14,7 @@ export type PickedEntity = { id: string; title: string; year: number | null; kin
export function EntityPicker({
value,
onChange,
placeholder = "搜索本地作品(标题/原名)",
placeholder = "搜索本地作品(标题/原名)",
id,
}: {
value: PickedEntity | null;
@@ -127,7 +127,7 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
>
{queued ? (
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
已进待处理,在那里选择候选或忽略。
已进待处理,在那里选择候选或忽略。
</p>
) : null}
<KvList
@@ -156,7 +156,7 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
)}
</DetailSection>
<JsonView label="解析条件(原始)" value={item.query} defaultOpen={false} />
<JsonView label="解析条件(原始)" value={item.query} defaultOpen={false} />
</DetailSheet>
);
}
@@ -58,7 +58,7 @@ export function PoolTab() {
<div className="min-w-0">
<p className="font-mono text-caption break-all text-foreground">{r.release.title}</p>
<p className="mt-0.5 truncate text-meta text-muted-foreground">
{r.release.subtitle ? `${r.release.subtitle} · ` : ""}解析:{querySummary(r.query)}
{r.release.subtitle ? `${r.release.subtitle} · ` : ""}解析:{querySummary(r.query)}
</p>
</div>
),
@@ -149,7 +149,7 @@ export function PoolTab() {
empty={{
icon: SearchX,
title: filtered ? "没有符合条件的待识别资源" : "没有待识别资源",
description: "证据不足或冲突的资源会出现在这里,有新证据时自动重试。",
description: "证据不足或冲突的资源会出现在这里,有新证据时自动重试。",
}}
hasMore={pool.hasNextPage}
onLoadMore={() => pool.fetchNextPage()}
+4 -4
View File
@@ -128,7 +128,7 @@ export const CANDIDATE_DECISION: Record<string, ToneLabel> = {
reject: { label: "拒绝", tone: "destructive" },
unknown_field: { label: "字段未知", tone: "warning" },
skipped: { label: "跳过", tone: "neutral" },
deferred: { label: "延后:已有计划在途", tone: "warning" },
deferred: { label: "延后:已有计划在途", tone: "warning" },
};
export const DECISION_KIND: Record<DecisionKind, string> = {
@@ -174,7 +174,7 @@ export const POOL_STATUS: Record<string, ToneLabel> = {
};
export const PREF_KIND: Record<PreferenceKind, { label: string; hint: string }> = {
soft: { label: "软偏好", hint: "影响排序,不排除" },
soft: { label: "软偏好", hint: "影响排序,不排除" },
hard: { label: "硬规则", hint: "命中即排除" },
version: { label: "版本偏好", hint: "同一作品内选哪种版本" },
target: { label: "版本目标", hint: "为作品新建一个版本目标" },
@@ -214,7 +214,7 @@ export const PRIORITY: Record<AcquisitionPriority, string> = {
export const SUB_STATUS: Record<SubscriptionStatus, ToneLabel> = {
active: { label: "进行中", tone: "success" },
paused: { label: "已暂停", tone: "warning" },
pending: { label: "待定(元数据未就绪)", tone: "warning" },
pending: { label: "待定(元数据未就绪)", tone: "warning" },
completed: { label: "已完成", tone: "neutral" },
cancelled: { label: "已取消", tone: "neutral" },
};
@@ -268,7 +268,7 @@ export const opts = <K extends string>(rec: Record<K, string | { label: string }
/** 版本目标 / 候选的原因串(如 `blocked:no_main_video`、`deferred:slot_busy;inflight:<id>`)→ 中文;不认识的原样显示 */
const SLOT_REASON: Record<string, string> = {
"blocked:no_main_video": "没有可入库的主视频",
"deferred:slot_busy": "已有计划在途,等它结束再评估",
"deferred:slot_busy": "已有计划在途,等它结束再评估",
verified: "已核验入库",
verified_partial: "部分核验入库",
hard_conditions_met: "硬条件全部满足",
@@ -31,7 +31,7 @@ const jsonObject = (name: string) =>
} catch {
// 落到下面的错误
}
ctx.addIssue({ code: "custom", message: `${name}必须是 JSON 对象,如 {"mediaKinds":["movie"]}` });
ctx.addIssue({ code: "custom", message: `${name}必须是 JSON 对象,如 {"mediaKinds":["movie"]}` });
return z.NEVER;
});
@@ -108,8 +108,8 @@ export function PreferenceFormDialog({
<DialogTitle>{tag ? "编辑偏好" : "添加偏好"}</DialogTitle>
<DialogDescription>
{tag
? "修改作用范围或规则会留下旧语义的抑制记录,Agent 不会按旧证据加回。"
: "手动添加等同对话确认。之前取消过的偏好,重新添加会解除抑制。"}
? "修改作用范围或规则会留下旧语义的抑制记录,Agent 不会按旧证据加回。"
: "手动添加等同对话确认。之前取消过的偏好,重新添加会解除抑制。"}
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="grid gap-2" noValidate>
@@ -120,7 +120,7 @@ export function PreferenceFormDialog({
value={draft.label}
aria-invalid={!!errors.label}
onChange={(e) => set("label", e.target.value)}
placeholder="如:偏爱科幻"
placeholder="如:偏爱科幻"
/>
</FormField>
<FormField label="类型" htmlFor="pref-kind" hint={PREF_KIND[draft.kind].hint}>
@@ -157,7 +157,7 @@ export function PreferenceFormDialog({
onChange={(e) => set("scope", e.target.value)}
/>
</FormField>
<FormField label="规则(JSON)" htmlFor="pref-rule" error={errors.rule} hint="结构化条件,由代码执行">
<FormField label="规则(JSON)" htmlFor="pref-rule" error={errors.rule} hint="结构化条件,由代码执行">
<Textarea
id="pref-rule"
rows={4}
@@ -78,7 +78,7 @@ export function SlotCard({
</dl>
{latest.length ? (
<div className="mt-3 border-t border-border/60 pt-3">
<p className="mb-2 text-meta text-muted-foreground">最近候选及选用理由(每个资源最近一次评估)</p>
<p className="mb-2 text-meta text-muted-foreground">最近候选及选用理由(每个资源最近一次评估)</p>
<ul className="space-y-2">
{latest.map((c) => (
<li key={c.sourceReleaseId} className="min-w-0">
+16 -16
View File
@@ -42,8 +42,8 @@ export const CUTS = [
] as const;
export const CONTENT_TYPES = ["main", "extra", "behind_the_scenes", "performance_only", "preview", "reaction"] as const;
const SOURCE_LABEL: Record<string, string> = { BluRay: "BluRay(压制)", "BluRay-Disc": "原盘" };
const CUT_LABEL: Record<string, string> = { default: "默认(无剪辑标记)" };
const SOURCE_LABEL: Record<string, string> = { BluRay: "BluRay(压制)", "BluRay-Disc": "原盘" };
const CUT_LABEL: Record<string, string> = { default: "默认(无剪辑标记)" };
const CT_LABEL: Record<string, string> = {
main: "正片",
extra: "花絮",
@@ -52,7 +52,7 @@ const CT_LABEL: Record<string, string> = {
preview: "预告",
reaction: "reaction",
};
const UNKNOWN_LABEL = { hold: "等待补充(不下载)", reject: "拒绝", accept: "放行" } as const;
const UNKNOWN_LABEL = { hold: "等待补充(不下载)", reject: "拒绝", accept: "放行" } as const;
const GB = 1024 ** 3;
const o = <T extends string>(list: readonly T[], labels: Record<string, string> = {}) =>
@@ -235,7 +235,7 @@ export function summarizeHard(j: JsonObject | undefined, fallback?: JsonObject):
if (!h) return ["规则无法解析"];
if (h.resolutions?.length) out.push(`分辨率 ${h.resolutions.join(" / ")}`);
if (h.sources?.length) out.push(`来源 ${h.sources.map((s) => SOURCE_LABEL[s] ?? s).join(" / ")}`);
if (h.requireEncode) out.push("仅蓝光压制(不含原盘/Remux)");
if (h.requireEncode) out.push("仅蓝光压制(不含原盘/Remux)");
if (h.minSizeBytes !== undefined || h.maxSizeBytes !== undefined)
out.push(
`体积 ${h.minSizeBytes !== undefined ? formatBytes(h.minSizeBytes, 1) : "0"} – ${
@@ -282,7 +282,7 @@ export function summarizeUpgrade(j: JsonObject | undefined): string[] {
export function summarizeCompletion(j: JsonObject | undefined): string {
const c = Completion.safeParse(j ?? {}).data;
if (!c) return "未设置";
return c.kind === "movie" ? "按内容版本核对(电影)" : "按内容单元覆盖核对(剧集/综艺)";
return c.kind === "movie" ? "按内容版本核对(电影)" : "按内容单元覆盖核对(剧集/综艺)";
}
// ── 编辑器 ──
@@ -312,7 +312,7 @@ export function SlotRulesEditor({
onChange={(v) => set("resolutions", v)}
/>
</FormField>
<FormField label="交付来源" hint="BluRay 指蓝光压制;原盘与 Remux 单列">
<FormField label="交付来源" hint="BluRay 指蓝光压制;原盘与 Remux 单列">
<ChipToggleGroup
label="交付来源"
options={o(SOURCES, SOURCE_LABEL)}
@@ -354,7 +354,7 @@ export function SlotRulesEditor({
</FormField>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<FormField label="必需字幕语言" htmlFor={`${id}-subs`} hint="逗号分隔;zh 可被 zh-Hans/zh-Hant 满足">
<FormField label="必需字幕语言" htmlFor={`${id}-subs`} hint="逗号分隔;zh 可被 zh-Hans/zh-Hant 满足">
<Input
id={`${id}-subs`}
value={value.requiredSubtitles}
@@ -370,7 +370,7 @@ export function SlotRulesEditor({
/>
</FormField>
</div>
<FormField label="内容版本(剪辑)" hint="不选即不限">
<FormField label="内容版本(剪辑)" hint="不选即不限">
<ChipToggleGroup
label="内容版本"
options={o(CUTS, CUT_LABEL)}
@@ -386,7 +386,7 @@ export function SlotRulesEditor({
onChange={(v) => set("contentTypes", v)}
/>
</FormField>
<FormField label="字段未知时" hint="资源缺少判断字段(如字幕)时">
<FormField label="字段未知时" hint="资源缺少判断字段(如字幕)时">
<Select
value={value.unknownPolicy || "__none"}
onValueChange={(v) => set("unknownPolicy", v === "__none" ? "" : (v as SlotRulesDraft["unknownPolicy"]))}
@@ -408,7 +408,7 @@ export function SlotRulesEditor({
<fieldset className="space-y-3 border-t border-border/60 pt-4">
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
软偏好 · 通过硬条件后排序,越靠前越优先
软偏好 · 通过硬条件后排序,越靠前越优先
</legend>
<FormField label="HDR">
<ChipToggleGroup
@@ -437,14 +437,14 @@ export function SlotRulesEditor({
onChange={(v) => set("softCodecs", v)}
/>
</FormField>
<FormField label="偏好发布组" htmlFor={`${id}-grp`} hint="逗号分隔,越靠前越好">
<FormField label="偏好发布组" htmlFor={`${id}-grp`} hint="逗号分隔,越靠前越好">
<Input id={`${id}-grp`} value={value.softGroups} onChange={(e) => set("softGroups", e.target.value)} />
</FormField>
</fieldset>
<fieldset className="space-y-3 border-t border-border/60 pt-4">
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
升级 · 生成替换计划,不直接覆盖旧文件
升级 · 生成替换计划,不直接覆盖旧文件
</legend>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<p className="text-caption font-medium text-foreground">允许自动升级</p>
@@ -459,7 +459,7 @@ export function SlotRulesEditor({
label="最小来源等级提升"
htmlFor={`${id}-tier`}
error={errors.minTierSteps}
hint="默认 1;同等级不反复更换"
hint="默认 1;同等级不反复更换"
>
<Input
id={`${id}-tier`}
@@ -481,7 +481,7 @@ export function SlotRulesEditor({
/>
</FormField>
</div>
<FormField label="合格压制组名单" htmlFor={`${id}-allow`} hint="设置后,名单外的压制不触发自动升级">
<FormField label="合格压制组名单" htmlFor={`${id}-allow`} hint="设置后,名单外的压制不触发自动升级">
<Input
id={`${id}-allow`}
value={value.encodeGroupAllowlist}
@@ -511,8 +511,8 @@ export function SlotRulesEditor({
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="__none">未设置</SelectItem>
<SelectItem value="movie">按内容版本核对(电影)</SelectItem>
<SelectItem value="units">按内容单元覆盖核对(剧集/综艺)</SelectItem>
<SelectItem value="movie">按内容版本核对(电影)</SelectItem>
<SelectItem value="units">按内容单元覆盖核对(剧集/综艺)</SelectItem>
</SelectContent>
</Select>
{errors.form ? <p className="text-meta text-destructive">{errors.form}</p> : null}
@@ -70,7 +70,7 @@ function EntityNamingDialog({ entityId, kind, onClose }: { entityId: string; kin
} catch (err) {
const split = splitNamingErrors(errorMessage(err));
if (split) setErrors(split);
toast.error(split ? "模板有错误,未保存" : errorMessage(err, "保存失败"));
toast.error(split ? "模板有错误,未保存" : errorMessage(err, "保存失败"));
}
};
@@ -89,7 +89,7 @@ function EntityNamingDialog({ entityId, kind, onClose }: { entityId: string; kin
<div className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={saved[kind] ? "success" : "neutral"}>
当前:{saved[kind] ? "作品覆盖" : `沿用${inheritedSource}`}
当前:{saved[kind] ? "作品覆盖" : `沿用${inheritedSource}`}
</StatusBadge>
</div>
<NamingTemplateEditor
+1 -1
View File
@@ -11,7 +11,7 @@ export function splitNamingErrors(message: string): Partial<Record<NamingKind, s
for (const m of message.matchAll(/(movie|tv|variety):\s*([^;]+)/g)) {
const [, k, msg] = m;
const pos = /^(\d+):(\d+)\s*(.*)$/.exec((msg ?? "").trim());
out[k as NamingKind] = pos ? `第 ${pos[1]} 行第 ${pos[2]} 列:${pos[3]}` : (msg ?? "").trim();
out[k as NamingKind] = pos ? `第 ${pos[1]} 行第 ${pos[2]} 列:${pos[3]}` : (msg ?? "").trim();
}
return Object.keys(out).length ? out : null;
}
@@ -31,7 +31,7 @@ const ROLE: Record<NamingPreviewResult["segments"][number]["role"], { label: str
file: { label: "文件名", className: "text-foreground font-medium" },
};
export const NAMING_NOTE = "已建立目录的作品不受模板影响,模板只决定新目录和新文件名。";
export const NAMING_NOTE = "已建立目录的作品不受模板影响,模板只决定新目录和新文件名。";
function useDebounced<T>(v: T, ms = 300): T {
const [d, setD] = useState(v);
@@ -166,8 +166,8 @@ export function NamingTemplateEditor({
<div className="flex flex-wrap items-center justify-between gap-2 text-meta text-muted-foreground">
<span>
{override && !value.trim()
? `留空:沿用${inherited?.[active]?.source ?? "上级"}模板`
: "/ 分目录,最后一段是文件名,须以 {{fileExt}} 结尾"}
? `留空:沿用${inherited?.[active]?.source ?? "上级"}模板`
: "/ 分目录,最后一段是文件名,须以 {{fileExt}} 结尾"}
</span>
<span className="tabular-nums">{value.length} / 1024</span>
</div>
@@ -268,9 +268,9 @@ function NamingPreview({
/>
) : null}
{!input ? (
<p className="text-caption text-muted-foreground">模板为空,没有可预览的内容。</p>
<p className="text-caption text-muted-foreground">模板为空,没有可预览的内容。</p>
) : q.isError && !r ? (
<p className="text-caption text-destructive">预览失败:{(q.error as Error).message}</p>
<p className="text-caption text-destructive">预览失败:{(q.error as Error).message}</p>
) : !r ? (
<p className="text-caption text-muted-foreground">预览中…</p>
) : (
@@ -293,7 +293,7 @@ function PreviewBody({
return (
<>
<p className="text-meta break-all text-muted-foreground">
样本:<span className="font-mono">{r.sample.fileName}</span>
样本:<span className="font-mono">{r.sample.fileName}</span>
{r.sample.entityTitle ? ` · 作品 ${r.sample.entityTitle}` : ""}
</p>
{r.errors.length ? (
@@ -317,10 +317,10 @@ function PreviewBody({
))}
</div>
)}
{r.usesBinding ? <Note>这部作品已有目录,目录沿用已建立的路径,模板只决定文件名。</Note> : null}
{r.usesBinding ? <Note>这部作品已有目录,目录沿用已建立的路径,模板只决定文件名。</Note> : null}
{r.missing.length ? (
<p className="text-meta text-muted-foreground">
无值的变量(输出为空):<span className="font-mono">{r.missing.join("、")}</span>
无值的变量(输出为空):<span className="font-mono">{r.missing.join("、")}</span>
</p>
) : null}
{r.warnings.map((w) => (
@@ -422,7 +422,7 @@ function VariablePalette({
{loading ? (
<p className="text-meta text-muted-foreground">加载中…</p>
) : error ? (
<p className="text-meta text-destructive">变量表加载失败:{(error as Error).message}</p>
<p className="text-meta text-destructive">变量表加载失败:{(error as Error).message}</p>
) : (
<div className="max-h-96 space-y-3 overflow-y-auto pr-1">
{GROUP_ORDER.map((g) => {
@@ -478,7 +478,7 @@ function VariablePalette({
{filtersOpen ? (
<ul className="mt-2 space-y-1.5">
<li className="text-meta text-muted-foreground">
<span className="font-mono text-foreground">{"{% if 变量 %}…{% endif %}"}</span> 条件;
<span className="font-mono text-foreground">{"{% if 变量 %}…{% endif %}"}</span> 条件;
<span className="font-mono text-foreground">{"{{ a or b }}"}</span> 取第一个有值的
</li>
{data.filters.map((f) => (
@@ -259,7 +259,7 @@ export function EntityPicker({
</ul>
{!hits.local.length && !hits.tmdb.length && !search.isFetching ? (
<p className="px-2 py-1.5 text-meta text-muted-foreground">
没有匹配的作品{hits.tmdbConfigured ? "" : ";TMDB 未配置,只搜了本地"}
没有匹配的作品{hits.tmdbConfigured ? "" : ";TMDB 未配置,只搜了本地"}
</p>
) : null}
</>
@@ -56,7 +56,7 @@ export function rowProblem(r: WizardRow): string | null {
if (r.excluded) return null;
if (!r.entity) return "未选择作品";
const eps = parseEpisodes(r.episodes);
if (eps === null) return "集号格式不对,如 3、3-5、3,4";
if (eps === null) return "集号格式不对,如 3、3-5、3,4";
if (r.season.trim() && !/^\d{1,4}$/.test(r.season.trim())) return "季号必须是数字";
if (!isMovieKind(r.entity.kind) && !r.season.trim()) return "剧集需要季号";
return null;
@@ -98,7 +98,7 @@ export function WizardBulkBar({
value={offset}
onChange={(e) => setOffset(e.target.value)}
placeholder="±N"
aria-label="集号偏移量(可为负)"
aria-label="集号偏移量(可为负)"
className="h-8 w-16 text-center"
/>
<Button
@@ -131,7 +131,7 @@ export function WizardBulkBar({
)}
{stats.excluded ? (
<StatusBadge>
{stats.excluded} 项已排除{stats.bySize ? `(${stats.bySize} 项低于最小体积)` : ""}
{stats.excluded} 项已排除{stats.bySize ? `(${stats.bySize} 项低于最小体积)` : ""}
</StatusBadge>
) : null}
</div>
@@ -270,7 +270,7 @@ export function StepFiles(p: StepFilesProps) {
)}
{p.truncated ? (
<p className="rounded-lg border border-warning/35 bg-warning/10 px-3 py-1.5 text-meta text-warning">
目录里视频超过 200 个,只识别了前 200 个。进入子目录分批整理。
目录里视频超过 200 个,只识别了前 200 个。进入子目录分批整理。
</p>
) : null}
@@ -86,8 +86,8 @@ export function StepOptions({
if (r.mediaKind === "mixed" || r.mediaKind === "other" || !r.mediaKind) return null;
const hasMovie = [...kinds].some((k) => k === "movie");
const hasShow = [...kinds].some((k) => k !== "movie");
if (r.mediaKind === "movie" && hasShow) return "电影库,本次含剧集";
if (r.mediaKind !== "movie" && hasMovie) return "剧集库,本次含电影";
if (r.mediaKind === "movie" && hasShow) return "电影库,本次含剧集";
if (r.mediaKind !== "movie" && hasMovie) return "剧集库,本次含电影";
return null;
};
const t = options.target;
@@ -100,14 +100,14 @@ export function StepOptions({
checked={options.action === "copy"}
onSelect={() => set({ action: "copy" })}
title="复制"
hint="保留源文件,不影响做种"
hint="保留源文件,不影响做种"
/>
<Choice
checked={options.action === "move"}
onSelect={() => set({ action: "move" })}
disabled={hnrCount ? `${hnrCount} 项做种中且 H&R 未满足,不能移动` : null}
disabled={hnrCount ? `${hnrCount} 项做种中且 H&R 未满足,不能移动` : null}
title="移动"
hint="入库后删源;跨卷时按复制 → 核验 → 删源"
hint="入库后删源;跨卷时按复制 → 核验 → 删源"
/>
<Choice checked={false} onSelect={() => {}} disabled="还不支持硬链接" title="硬链接" />
</div>
@@ -119,19 +119,19 @@ export function StepOptions({
checked={options.onExisting === "conflict"}
onSelect={() => set({ onExisting: "conflict" })}
title="交给待处理"
hint="该组不执行,冲突进待处理"
hint="该组不执行,冲突进待处理"
/>
<Choice
checked={options.onExisting === "skip"}
onSelect={() => set({ onExisting: "skip" })}
title="跳过"
hint="只跳过已存在的文件,其余照常整理"
hint="只跳过已存在的文件,其余照常整理"
/>
<Choice
checked={options.onExisting === "replace_with_verify"}
onSelect={() => set({ onExisting: "replace_with_verify" })}
title="替换"
hint="新文件核验通过后,旧文件移入回收站"
hint="新文件核验通过后,旧文件移入回收站"
/>
</div>
</Section>
@@ -144,7 +144,7 @@ export function StepOptions({
checked={t.mode === "auto"}
onSelect={() => set({ target: { mode: "auto" } })}
title="自动"
hint="已绑定的作品沿用原目录,否则按作品类型选库根"
hint="已绑定的作品沿用原目录,否则按作品类型选库根"
/>
{libRoots.map((r) => (
<Choice
@@ -165,7 +165,7 @@ export function StepOptions({
checked={t.mode === "dir"}
onSelect={() => set({ target: { mode: "dir", ref: t.mode === "dir" ? t.ref : { root: "", path: "" } } })}
title="指定目录"
hint="文件按模板命名后直接放进这个目录,不建路径绑定"
hint="文件按模板命名后直接放进这个目录,不建路径绑定"
/>
{t.mode === "dir" ? (
<DirPicker
@@ -186,7 +186,7 @@ export function StepOptions({
title="按命名模板"
hint={
<>
模板按 作品覆盖 → 库根 → 全局 取第一个,预览里显示实际来源。
模板按 作品覆盖 → 库根 → 全局 取第一个,预览里显示实际来源。
<Link to="/console/settings#library/naming" className="ml-1 underline underline-offset-2">
去设置
</Link>
@@ -206,7 +206,7 @@ export function StepOptions({
<div className="flex items-start justify-between gap-3 rounded-xl border border-border px-3 py-2">
<span className="text-body">
刷新元数据
<span className="block text-meta text-muted-foreground">按字段刷新,锁定的字段不变</span>
<span className="block text-meta text-muted-foreground">按字段刷新,锁定的字段不变</span>
</span>
<Switch
checked={options.refreshMetadata}
@@ -214,7 +214,7 @@ export function StepOptions({
aria-label="入库后刷新元数据"
/>
</div>
<p className="text-meta text-muted-foreground">同名字幕、NFO、图片随主文件一起处理,预览里列出。</p>
<p className="text-meta text-muted-foreground">同名字幕、NFO、图片随主文件一起处理,预览里列出。</p>
</Section>
</div>
</div>
@@ -27,10 +27,10 @@ import { PathSegments } from "./path-segments.tsx";
export type GroupTitle = (g: FilesOrganizeGroup) => string;
const ERROR_TEXT: Record<string, string> = {
plan_blocked: "目标已存在,按“交给待处理”不执行",
plan_blocked: "目标已存在,按「交给待处理」不执行",
plan_stale: "源文件在预览后有变化",
approval_required: "移动需要审批或持续授权",
download_active: "做种中且 H&R 未满足,不能移动",
download_active: "做种中且 H&R 未满足,不能移动",
};
/** 服务端原因码(message 以 code: 开头)→ 文案 */
@@ -187,7 +187,7 @@ export function PreviewGroupCard({
{result && record ? `${record.counts.succeeded}/${record.counts.total} 成功 · ` : ""}
{g.files.length} 个文件 · {formatBytes(size, 1)}
{g.action ? ` · ${g.action === "move" ? "移动" : "复制"}` : ""}
{g.naming ? ` · 命名:${TEMPLATE_SOURCE[g.naming.templateSource] ?? g.naming.templateSource}` : ""}
{g.naming ? ` · 命名:${TEMPLATE_SOURCE[g.naming.templateSource] ?? g.naming.templateSource}` : ""}
</span>
{result && g.recordId ? (
<Link className="ml-auto text-meta underline underline-offset-2" to={`/console/organize/${g.recordId}`}>
@@ -210,7 +210,7 @@ export function PreviewGroupCard({
</p>
))}
{!result ? <TargetLine g={g} roots={roots} /> : null}
{g.truncated ? <p className="text-caption text-warning">文件过多,预览只列出前一部分。</p> : null}
{g.truncated ? <p className="text-caption text-warning">文件过多,预览只列出前一部分。</p> : null}
{!result && nested.length ? (
<ul className="divide-y divide-border/50">
{nested.map((n) => (
@@ -227,7 +227,7 @@ export function PreviewGroupCard({
) : null}
{!result && g.excluded?.length ? (
<p className="text-meta text-muted-foreground">
排除 {g.excluded.length} 个:
排除 {g.excluded.length} 个:
{g.excluded
.slice(0, 6)
.map((e) => `${baseName(e.source)}(${EXCLUDE_REASON[e.reason] ?? e.reason})`)
@@ -258,7 +258,9 @@ export function PreviewList({
const shown = onlyProblems ? groups.filter(groupHasProblem) : groups;
const pg = pageOf(shown, page);
if (!shown.length)
return <p className="py-8 text-center text-caption text-muted-foreground">没有问题项。关闭“只看问题”查看全部。</p>;
return (
<p className="py-8 text-center text-caption text-muted-foreground">没有问题项。关闭「只看问题」查看全部。</p>
);
return (
<div className="space-y-3">
{pg.items.map((g, i) => (
@@ -100,7 +100,7 @@ export function RuleGroupEditor({
ladderProblem(form.ladder),
...form.excludes.map((e, i) => {
const p = ladderProblem(e.state);
return p ? `第 ${i + 1} 条硬排除:${p}` : null;
return p ? `第 ${i + 1} 条硬排除:${p}` : null;
}),
].filter((p): p is string => !!p);
const blocked = problems[0] ?? null;
@@ -144,7 +144,7 @@ export function RuleGroupEditor({
}
} catch (err) {
if (err instanceof ApiRequestError && err.code === "conflict") {
if (/revision/.test(err.message)) setConflict("这个规则组刚被别处改过,没有保存。载入最新版本后再改。");
if (/revision/.test(err.message)) setConflict("这个规则组刚被别处改过,没有保存。载入最新版本后再改。");
else setNameError(err.message);
} else toast.error(errorMessage(err, "保存失败"));
}
@@ -173,7 +173,7 @@ export function RuleGroupEditor({
) : null}
</span>
) : (
"层从上到下依次匹配,先命中的层优先级高。"
"层从上到下依次匹配,先命中的层优先级高。"
)
}
footer={
@@ -186,7 +186,7 @@ export function RuleGroupEditor({
删除
</Button>
}
title={`删除「${group.name}」?`}
title={`删除「${group.name}」?`}
description={`${SUPPRESS_NOTE}被版本目标或订阅引用时不能删除。`}
confirmLabel="删除"
destructive
@@ -221,7 +221,7 @@ export function RuleGroupEditor({
</div>
) : null}
{inDefault ? (
<p className="text-meta text-muted-foreground">在默认链里的组不能删除,先在“默认值”里移出。</p>
<p className="text-meta text-muted-foreground">在默认链里的组不能删除,先在「默认值」里移出。</p>
) : null}
<div className="grid gap-x-4 sm:grid-cols-2">
@@ -263,7 +263,7 @@ export function RuleGroupEditor({
label="作用范围"
hint={
group && (group.scope.entityIds?.length || group.scope.siteIds?.length || group.scope.subscriptionId)
? `另有:${scopeText({ ...group.scope, mediaTypes: undefined, mediaKinds: undefined } as RuleGroupScope)}(此处不改)`
? `另有:${scopeText({ ...group.scope, mediaTypes: undefined, mediaKinds: undefined } as RuleGroupScope)}(此处不改)`
: "不选 = 全部类型"
}
className="sm:col-span-2"
@@ -279,7 +279,7 @@ export function RuleGroupEditor({
<div className="min-w-0">
<p className="text-caption font-medium text-foreground">一层都不中时拒绝</p>
<p className="text-meta text-muted-foreground">
{form.ladderRequired ? "资源至少要命中一层" : "只影响排序,不中的资源 pri 为 0"}
{form.ladderRequired ? "资源至少要命中一层" : "只影响排序,不中的资源 pri 为 0"}
</p>
</div>
<Switch
@@ -302,7 +302,7 @@ export function RuleGroupEditor({
</DetailSection>
<DetailSection title="硬排除">
<p className="mb-2 text-meta text-muted-foreground">任一条命中即拒绝,每条写一层条件。</p>
<p className="mb-2 text-meta text-muted-foreground">任一条命中即拒绝,每条写一层条件。</p>
<ul className="space-y-2">
{form.excludes.map((e, i) => (
<li key={e.key} className="flex items-start gap-2">
@@ -409,7 +409,7 @@ function Revisions({ id }: { id: string }) {
</div>
{r.changedFields.length ? (
<p className="mt-1 text-meta text-muted-foreground">
改动:{r.changedFields.map((f) => FIELD_LABEL[f] ?? f).join("、")}
改动:{r.changedFields.map((f) => FIELD_LABEL[f] ?? f).join("、")}
</p>
) : null}
</li>
+8 -8
View File
@@ -22,7 +22,7 @@ function useToggle() {
toggle: async (g: RuleGroup) => {
try {
await update.mutateAsync({ id: g.id, enabled: !g.enabled, expectedRevision: g.revision });
toast.success(g.enabled ? "已停用,Agent 不会自动加回" : "已启用");
toast.success(g.enabled ? "已停用,Agent 不会自动加回" : "已启用");
} catch (err) {
toast.error(errorMessage(err));
}
@@ -38,7 +38,7 @@ function RefsCell({ g, refs }: { g: RuleGroup; refs: RuleRefs | undefined }) {
if (subs.length) parts.push(`${subs.length} 个订阅`);
if (slots.length) parts.push(`${slots.length} 个版本目标`);
if (!parts.length) return <span className="text-meta text-muted-foreground">{refs ? "未引用" : "—"}</span>;
const title = [...subs.map((s) => `订阅:${s.title}`), ...slots.map((s) => `版本目标:${s.name}`)].join("\n");
const title = [...subs.map((s) => `订阅:${s.title}`), ...slots.map((s) => `版本目标:${s.name}`)].join("\n");
return (
<span className="text-meta text-foreground" title={title || undefined}>
{parts.join(" · ")}
@@ -140,7 +140,7 @@ export function RuleGroupTable({
empty={{
icon: ListChecks,
title: filtered ? "没有符合条件的规则组" : "还没有规则组",
description: filtered ? undefined : "新建一个规则组,或在 Agent 会话里说明长期偏好。",
description: filtered ? undefined : "新建一个规则组,或在 Agent 会话里说明长期偏好。",
action: filtered ? undefined : (
<Button size="sm" onClick={onCreate}>
新建规则组
@@ -197,7 +197,7 @@ function RuleTag({ g, onOpen }: { g: RuleGroup; onOpen: () => void }) {
停用
</Button>
}
title={`停用「${g.name}」?`}
title={`停用「${g.name}」?`}
description={SUPPRESS_NOTE}
confirmLabel="停用"
onConfirm={() => toggle(g)}
@@ -213,14 +213,14 @@ function RuleTag({ g, onOpen }: { g: RuleGroup; onOpen: () => void }) {
<Trash2 />
</Button>
}
title={`删除「${g.name}」?`}
title={`删除「${g.name}」?`}
description={`${SUPPRESS_NOTE}被版本目标或订阅引用时不能删除。`}
confirmLabel="删除"
destructive
onConfirm={async () => {
try {
await del.mutateAsync(g.id);
toast.success("已删除,Agent 不会自动加回");
toast.success("已删除,Agent 不会自动加回");
} catch (err) {
toast.error(errorMessage(err, "删除失败"));
throw err;
@@ -237,7 +237,7 @@ export function RuleTags({ rows, onOpen }: { rows: RuleGroup[]; onOpen: (g: Rule
return (
<Panel
title="偏好"
description="单条规则,多由对话或 Agent 提出。停用或删除后 Agent 不会自动加回。"
description="单条规则,多由对话或 Agent 提出。停用或删除后 Agent 不会自动加回。"
actions={<span className="text-meta text-muted-foreground tabular-nums">{rows.length} 条</span>}
>
{rows.length === 0 ? (
@@ -245,7 +245,7 @@ export function RuleTags({ rows, onOpen }: { rows: RuleGroup[]; onOpen: (g: Rule
plain
icon={Tags}
title="还没有偏好"
description="在 Agent 会话里说明长期偏好,会出现在这里。"
description="在 Agent 会话里说明长期偏好,会出现在这里。"
className="py-6"
/>
) : (
+2 -2
View File
@@ -10,7 +10,7 @@ export const RULE_KIND: Record<RuleGroupKind, { label: string; hint: string }> =
search: { label: "搜索", hint: "手动搜索与 Agent 搜索资源时用" },
};
export const SUPPRESS_NOTE = "停用或删除会留下抑制记录,Agent 不会自动加回;重新启用或手动新建同样的规则后解除。";
export const SUPPRESS_NOTE = "停用或删除会留下抑制记录,Agent 不会自动加回;重新启用或手动新建同样的规则后解除。";
export const RULE_SOURCE: Record<RuleGroupSource, string> = {
manual: "手动",
@@ -59,5 +59,5 @@ export function ruleSummary(g: RuleGroup): string {
parts.push(`${g.ladderRequired ? "要求" : "偏好"} ${g.layers.map((l) => exprToString(l.expr)).join(" > ")}`);
for (const h of g.hardExcludes) parts.push(`排除 ${exprToString(h)}`);
if (parts.length) return parts.join(";");
return g.legacyKind ? "旧偏好标签,没有可评估的条件" : "没有条件";
return g.legacyKind ? "旧偏好标签,没有可评估的条件" : "没有条件";
}
@@ -180,7 +180,7 @@ export function useRuleTextParser(
if (single && r.layers.length > 1)
errors.push({
code: "unexpected_token",
message: "硬排除只能写一层,不能用 >",
message: "硬排除只能写一层,不能用 >",
position: text.indexOf(">"),
layer: 1,
});
@@ -253,7 +253,7 @@ export function RuleStringField({
<li key={`${e.code}-${e.position}-${i}`} className="text-meta text-destructive">
<ErrorCaret text={state.text} position={e.position} />
<span>
第 {e.position + 1} 个字符{single ? "" : `(第 ${e.layer + 1} 层)`}:{e.message}
第 {e.position + 1} 个字符{single ? "" : `(第 ${e.layer + 1} 层)`}:{e.message}
</span>
{e.suggestions?.length ? (
<span className="ml-1 inline-flex flex-wrap gap-1 align-middle">
@@ -396,7 +396,7 @@ function LayerCardView({
<Input
value={card.name ?? ""}
onChange={(e) => onChange({ ...card, name: e.target.value || undefined })}
placeholder="层名称(可选)"
placeholder="层名称(可选)"
aria-label={`第 ${index + 1} 层名称`}
maxLength={40}
className="h-7 w-36 text-meta"
@@ -530,11 +530,11 @@ export function LadderEditor({ state, onChange }: { state: LadderState; onChange
<div className="space-y-3">
<RuleStringField state={state} onChange={onChange} ariaLabel="规则串" />
{state.errors.length ? (
<p className="text-meta text-muted-foreground">规则串改对后同步到下面的层;现在显示的是上次解析成功的结果。</p>
<p className="text-meta text-muted-foreground">规则串改对后同步到下面的层;现在显示的是上次解析成功的结果。</p>
) : null}
{state.cards === null ? (
<p className="rounded-lg border border-border/70 px-3 py-2 text-meta text-muted-foreground">
嵌套超过两层,只能在规则串里改。
嵌套超过两层,只能在规则串里改。
</p>
) : (
<>
@@ -559,7 +559,7 @@ export function LadderEditor({ state, onChange }: { state: LadderState; onChange
))}
</ol>
) : state.errors.length ? null : (
<p className="text-meta text-muted-foreground">没有层:只用硬排除过滤,不影响排序。</p>
<p className="text-meta text-muted-foreground">没有层:只用硬排除过滤,不影响排序。</p>
)}
<Button
type="button"
@@ -88,13 +88,13 @@ export function RulePreviewPanel({
<Textarea
value={titles}
onChange={(e) => setTitles(e.target.value)}
placeholder={"每行一个资源标题,最多 50 行\nDune.Part.Two.2024.2160p.UHD.BluRay.REMUX.HDR.DV-FraMeSToR"}
placeholder={"每行一个资源标题,最多 50 行\nDune.Part.Two.2024.2160p.UHD.BluRay.REMUX.HDR.DV-FraMeSToR"}
aria-label="资源标题"
rows={4}
className="font-mono text-meta"
/>
) : null}
{blocked ? <p className="text-meta text-muted-foreground">{blocked},改好后再试算。</p> : null}
{blocked ? <p className="text-meta text-muted-foreground">{blocked},改好后再试算。</p> : null}
{result ? <PreviewResult result={result} /> : null}
</div>
);
+16 -15
View File
@@ -139,10 +139,10 @@ function AtomDialog({
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{atom ? `编辑条件 ${atom.key}` : "新建自定义条件"}</DialogTitle>
<DialogDescription>字段同 MoviePilot 自定义规则。正则不区分大小写,多条之间是“或”。</DialogDescription>
<DialogDescription>字段同 MoviePilot 自定义规则。正则不区分大小写,多条之间是「或」。</DialogDescription>
</DialogHeader>
<div className="grid gap-x-4 sm:grid-cols-2">
<FormField label="关键字" htmlFor={`${uid}-key`} required hint="大写字母和数字;不能与内置关键字同名">
<FormField label="关键字" htmlFor={`${uid}-key`} required hint="大写字母和数字;不能与内置关键字同名">
<Input
id={`${uid}-key`}
value={f.key}
@@ -158,7 +158,7 @@ function AtomDialog({
<FormField label="说明" htmlFor={`${uid}-desc`} className="sm:col-span-2">
<Input id={`${uid}-desc`} value={f.description} onChange={(e) => set("description", e.target.value)} />
</FormField>
<FormField label="包含(正则,每行一条)" htmlFor={`${uid}-inc`}>
<FormField label="包含(正则,每行一条)" htmlFor={`${uid}-inc`}>
<Textarea
id={`${uid}-inc`}
rows={3}
@@ -167,7 +167,7 @@ function AtomDialog({
className="font-mono text-meta"
/>
</FormField>
<FormField label="排除(正则,每行一条)" htmlFor={`${uid}-exc`}>
<FormField label="排除(正则,每行一条)" htmlFor={`${uid}-exc`}>
<Textarea
id={`${uid}-exc`}
rows={3}
@@ -184,7 +184,7 @@ function AtomDialog({
onChange={(v) => set("matchFields", v)}
/>
</FormField>
<FormField label="大小(MB,剧集按每集)">
<FormField label="大小(MB,剧集按每集)">
<div className="flex items-center gap-2">
<Input
inputMode="numeric"
@@ -203,7 +203,7 @@ function AtomDialog({
/>
</div>
</FormField>
<FormField label="发布后(分钟)">
<FormField label="发布后(分钟)">
<div className="flex items-center gap-2">
<Input
inputMode="numeric"
@@ -322,7 +322,7 @@ export function AtomsTab() {
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-caption text-muted-foreground">
内置关键字不够用时,用正则、大小、做种数等定义新条件,在规则串里按关键字引用。
内置关键字不够用时,用正则、大小、做种数等定义新条件,在规则串里按关键字引用。
</p>
<Button size="sm" onClick={() => setDialog({ open: true, atom: null })}>
<Plus />
@@ -339,7 +339,7 @@ export function AtomsTab() {
onRowClick={(a) => setDialog({ open: true, atom: a })}
empty={{ icon: FlaskConical, title: "还没有自定义条件", description: "内置关键字覆盖不到时再新建。" }}
/>
<Panel title="内置关键字" description="对齐 MoviePilot;判定依据按解析字段优先">
<Panel title="内置关键字" description="对齐 MoviePilot;判定依据按解析字段优先">
{keywords.isPending ? (
<LoadingState rows={3} />
) : keywords.isError ? (
@@ -350,7 +350,7 @@ export function AtomsTab() {
<li key={k.id} className="flex min-w-0 items-baseline gap-2 text-caption">
<span className="w-16 shrink-0 font-mono font-medium text-foreground">{k.id}</span>
<span className="min-w-0 text-muted-foreground">
{k.supported ? k.description || k.name : "不支持,规则串里出现会报错"}
{k.supported ? k.description || k.name : "不支持,规则串里出现会报错"}
</span>
</li>
))}
@@ -385,7 +385,7 @@ export function DefaultsTab({ groups }: { groups: RuleGroup[] | undefined }) {
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-caption text-muted-foreground">
订阅和版本目标没指定规则组时用这里的链。链上的组按顺序串联,任一组拒绝即拒绝,优先级取最后一个带层的组。
订阅和版本目标没指定规则组时用这里的链。链上的组按顺序串联,任一组拒绝即拒绝,优先级取最后一个带层的组。
</p>
<div className="flex items-center gap-2">
{dirty ? <StatusBadge tone="warning">未保存</StatusBadge> : null}
@@ -419,7 +419,7 @@ export function DefaultsTab({ groups }: { groups: RuleGroup[] | undefined }) {
return (
<Panel key={k} title={DEFAULT_CHAIN_LABEL[k]} description={RULE_KIND[k].hint}>
{ids.length === 0 ? (
<p className="text-meta text-muted-foreground">链为空:不按规则组过滤。</p>
<p className="text-meta text-muted-foreground">链为空:不按规则组过滤。</p>
) : (
<ol className="space-y-1.5">
{ids.map((id, i) => {
@@ -434,7 +434,7 @@ export function DefaultsTab({ groups }: { groups: RuleGroup[] | undefined }) {
{id === lastLadder ? (
<span className="text-micro text-muted-foreground">决定优先级</span>
) : null}
{g && !g.enabled ? <span className="text-micro text-warning">已停用,评估时跳过</span> : null}
{g && !g.enabled ? <span className="text-micro text-warning">已停用,评估时跳过</span> : null}
</span>
<Button
variant="ghost"
@@ -514,7 +514,8 @@ export function SuppressionsTab() {
return (
<Panel title="抑制记录" description={sups.data ? `${active} 条生效中` : undefined}>
<p className="mb-3 text-caption text-muted-foreground">
停用、删除,或改掉对话 / Agent 提出的规则,都会留下抑制记录。Agent 再提出同样的规则会被直接拒绝,解除后才能再提。
停用、删除,或改掉对话 / Agent 提出的规则,都会留下抑制记录。Agent
再提出同样的规则会被直接拒绝,解除后才能再提。
</p>
{sups.isPending ? (
<LoadingState rows={2} />
@@ -549,8 +550,8 @@ export function SuppressionsTab() {
解除抑制
</Button>
}
title="解除这条抑制?"
description="解除后 Agent 可以再次提出这条规则,仍需审批。规则组本身不会恢复。"
title="解除这条抑制?"
description="解除后 Agent 可以再次提出这条规则,仍需审批。规则组本身不会恢复。"
confirmLabel="解除"
onConfirm={async () => {
try {
@@ -142,7 +142,7 @@ export function AgentCheckDialog({
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{job ? "编辑巡检" : "新建巡检"}</DialogTitle>
<DialogDescription>到点按目标发起一次 Agent 会话,只在白名单和预算内执行。</DialogDescription>
<DialogDescription>到点按目标发起一次 Agent 会话,只在白名单和预算内执行。</DialogDescription>
</DialogHeader>
<form
className="grid gap-1"
@@ -161,13 +161,13 @@ export function AgentCheckDialog({
onChange={(e) => set("name", e.target.value)}
/>
</FormField>
<FormField label="目标" htmlFor="ac-goal" required error={errors.goal} hint={`可用占位符:${PLACEHOLDERS}`}>
<FormField label="目标" htmlFor="ac-goal" required error={errors.goal} hint={`可用占位符:${PLACEHOLDERS}`}>
<Textarea
id="ac-goal"
rows={4}
value={d.goal}
aria-invalid={!!errors.goal}
placeholder="检查 {{openDecisions}} 条待处理事项,给出处理建议"
placeholder="检查 {{openDecisions}} 条待处理事项,给出处理建议"
onChange={(e) => set("goal", e.target.value)}
/>
</FormField>
@@ -207,7 +207,7 @@ export function AgentCheckDialog({
onChange={(e) => set("maxToolCalls", e.target.value)}
/>
</FormField>
<FormField label="最长时长(秒)" htmlFor="ac-secs" error={errors.maxDurationSec}>
<FormField label="最长时长(秒)" htmlFor="ac-secs" error={errors.maxDurationSec}>
<Input
id="ac-secs"
type="number"
@@ -217,7 +217,7 @@ export function AgentCheckDialog({
onChange={(e) => set("maxDurationSec", e.target.value)}
/>
</FormField>
<FormField label="触发条件" htmlFor="ac-trigger" hint="条件不满足时跳过,不唤醒模型">
<FormField label="触发条件" htmlFor="ac-trigger" hint="条件不满足时跳过,不唤醒模型">
<Select value={d.trigger} onValueChange={(v) => set("trigger", v as AgentCheckTrigger)}>
<SelectTrigger id="ac-trigger" className="w-full">
<SelectValue />
@@ -270,7 +270,7 @@ export function AgentCheckDialog({
<label htmlFor="ac-recover" className="flex items-center justify-between gap-3 text-caption">
<span>
允许授权内恢复
<span className="ml-2 text-meta text-muted-foreground">需有效持续授权,否则只读</span>
<span className="ml-2 text-meta text-muted-foreground">需有效持续授权,否则只读</span>
</span>
<Switch id="ac-recover" checked={d.allowRecover} onCheckedChange={(v) => set("allowRecover", v)} />
</label>
@@ -37,7 +37,7 @@ export function IntervalEditor({
const save = () => {
if (schedule.type === "cron") {
const c = cron.trim();
if (c.split(/\s+/).length !== 5) return setError("cron 需要 5 段:分 时 日 月 周");
if (c.split(/\s+/).length !== 5) return setError("cron 需要 5 段:分 时 日 月 周");
onSave({ type: "cron", cron: c });
} else {
const n = Number(value);
@@ -19,7 +19,7 @@ export function RunsSheet({ job, onClose }: { job: ScheduledJob | null; onClose:
onOpenChange={(o) => !o && onClose()}
title={job ? `${job.name} · 运行记录` : "运行记录"}
description={
job ? `${describeSchedule(job.schedule)} · 累计 ${job.runCount} 次,失败 ${job.failCount} 次` : undefined
job ? `${describeSchedule(job.schedule)} · 累计 ${job.runCount} 次,失败 ${job.failCount} 次` : undefined
}
>
{runs.isPending ? (
@@ -93,7 +93,7 @@ export function ScheduleBoard({ highlightJob }: { highlightJob?: string }) {
className="mb-4 flex items-center gap-2 rounded-tight border border-warning/30 bg-warning/10 px-3 py-2 text-caption text-warning"
>
<AlertTriangle className="h-4 w-4 shrink-0" />
调度器未在线,任务不会按时运行。
调度器未在线,任务不会按时运行。
</p>
) : (
<div className="mb-4" />
@@ -38,12 +38,12 @@ function AgentCheckSummary({ config }: { config: Partial<AgentCheckConfig> }) {
{config.goalTemplate ?? "—"}
</p>
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
<span>条件:{CHECK_TRIGGER_LABEL[config.triggerCondition ?? "always"]}</span>
<span>条件:{CHECK_TRIGGER_LABEL[config.triggerCondition ?? "always"]}</span>
<span>
预算:{budget.maxToolCalls ? `${budget.maxToolCalls} 次调用` : "默认"}
预算:{budget.maxToolCalls ? `${budget.maxToolCalls} 次调用` : "默认"}
{budget.maxDurationSec ? ` · ${budget.maxDurationSec} 秒` : ""}
</span>
<span>能力:{allow.length ? `${allow.length} 项` : "全部只读能力"}</span>
<span>能力:{allow.length ? `${allow.length} 项` : "全部只读能力"}</span>
{config.allowRecover ? <span>允许授权内恢复</span> : null}
{config.reportAsDecision ? <span>写入待处理</span> : null}
</div>
@@ -200,8 +200,8 @@ export function ScheduleCard({
<Trash2 />
</Button>
}
title={`删除「${job.name}」?`}
description="运行记录一并删除,已产生的 Agent 会话保留。"
title={`删除「${job.name}」?`}
description="运行记录一并删除,已产生的 Agent 会话保留。"
confirmLabel="删除巡检"
destructive
onConfirm={() =>
@@ -66,7 +66,7 @@ export function CommandPalette() {
)}
>
<DialogTitle className="sr-only">搜索</DialogTitle>
<DialogDescription className="sr-only">搜索作品、TMDB 和页面;回车在资源中搜索</DialogDescription>
<DialogDescription className="sr-only">搜索作品、TMDB 和页面;回车在资源中搜索</DialogDescription>
{open ? <PaletteBody initial={initial} /> : null}
</DialogContent>
</Dialog>
@@ -114,7 +114,7 @@ function PaletteBody({ initial }: { initial: string }) {
在资源中搜索 <span className="font-medium">“{term}”</span>
</span>
),
meta: "查询全部已启用站点,最慢约 15 秒",
meta: "查询全部已启用站点,最慢约 15 秒",
hint: "打开",
run: () => go(searchPagePath(term)),
});
@@ -236,14 +236,14 @@ function PaletteBody({ initial }: { initial: string }) {
})}
{!stale && media.data && tmdb && !tmdb.configured ? (
<p className="px-3 py-2 text-meta text-muted-foreground">
TMDB 未配置,只搜本地作品。
TMDB 未配置,只搜本地作品。
<Link to={settingsPath("metadata")} onClick={close} className="ml-1 underline underline-offset-4">
去配置
</Link>
</p>
) : null}
{!stale && tmdb?.error ? (
<p className="px-3 py-2 text-meta text-warning">TMDB 查询失败:{tmdb.error}</p>
<p className="px-3 py-2 text-meta text-warning">TMDB 查询失败:{tmdb.error}</p>
) : null}
{media.isError || actions.isError ? (
<p className="px-3 py-2 text-meta text-destructive">
@@ -134,7 +134,7 @@ export function ReleaseResults({
<div className="pg-slab min-w-0 overflow-hidden rounded-xl">
{res.isPending ? (
<div className="p-4">
<p className="mb-3 text-meta text-muted-foreground">正在查询各站点,最慢约 15 秒…</p>
<p className="mb-3 text-meta text-muted-foreground">正在查询各站点,最慢约 15 秒…</p>
<LoadingState rows={4} />
</div>
) : res.isError && !rows.length ? (
@@ -144,7 +144,7 @@ export function ReleaseResults({
plain
icon={PackageSearch}
title={filtered ? "没有符合筛选条件的资源" : "各站点都没有找到资源"}
description={filtered ? "放宽筛选条件再看。" : "换个关键词,或到站点工作台检查站点状态。"}
description={filtered ? "放宽筛选条件再看。" : "换个关键词,或到站点工作台检查站点状态。"}
className="py-12"
/>
) : (
@@ -180,7 +180,7 @@ function CoverageNotice({ page }: { page: ReleaseSearchPage }) {
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<div className="min-w-0">
<p>
{page.failedSites.length} 个站点没有返回结果,下面只有 {ok} 个站点的资源。
{page.failedSites.length} 个站点没有返回结果,下面只有 {ok} 个站点的资源。
</p>
<ul className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-meta text-muted-foreground">
{page.failedSites.map((s) => (
@@ -323,7 +323,7 @@ function Filters({
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label={desc ? "降序,点击改为升序" : "升序,点击改为降序"}
aria-label={desc ? "降序,点击改为升序" : "升序,点击改为降序"}
title={desc ? "降序" : "升序"}
onClick={() => setFilter("order", desc ? "asc" : "")}
>
@@ -23,8 +23,8 @@ import { formatBytes } from "~/lib/utils.ts";
// 与站点工作台同一套下载说明;请求走 POST /search/releases/{siteId}/{siteReleaseId}/plan(同 /acquisition-plans 流程)
const STEPS = [
"生成下载计划,预留版本目标和预算;每个版本目标同时只有一个在途计划",
"已有相同资源或相同 hash 的任务时直接接管,不重复提交",
"生成下载计划,预留版本目标和预算;每个版本目标同时只有一个在途计划",
"已有相同资源或相同 hash 的任务时直接接管,不重复提交",
"缓存卷超过硬阈值或读不到磁盘状态时无法提交",
"提交前复核免费、做种等易变字段",
];
@@ -32,13 +32,13 @@ const STEPS = [
/** 委托 Agent:和工作台一样带上资源、作品、站点 */
export function delegateToAgent(r: SearchRelease) {
openAgentDrawer({
label: `资源:${r.title}`,
label: `资源:${r.title}`,
releaseId: r.sourceReleaseId ?? undefined,
entityId: r.entity?.id,
siteId: r.siteId,
goal: r.sourceReleaseId
? "判断这个资源是否值得下载;合适就按正常流程提交"
: `判断站点 ${r.siteName} 的资源「${r.title}」是否值得下载;合适就按正常流程提交`,
? "判断这个资源是否值得下载;合适就按正常流程提交"
: `判断站点 ${r.siteName} 的资源「${r.title}」是否值得下载;合适就按正常流程提交`,
});
}
@@ -72,7 +72,7 @@ export function SearchDownloadDialog({
if (r.submitted && r.plan) {
setRejected(null);
setDone(r.plan.id);
toast.success(`已提交${slotName(r.slotId) ? `(${slotName(r.slotId)})` : ""},进度见“下载队列”`);
toast.success(`已提交${slotName(r.slotId) ? `(${slotName(r.slotId)})` : ""},进度见「下载队列」`);
} else {
setRejected(r.rejected);
toast.warning("没有生成下载计划");
@@ -107,7 +107,7 @@ export function SearchDownloadDialog({
))}
</ol>
{!entityId ? (
<p className="text-meta text-muted-foreground">资源未关联本地作品。先在“身份与元数据”指定。</p>
<p className="text-meta text-muted-foreground">资源未关联本地作品。先在「身份与元数据」指定。</p>
) : slots.isPending ? (
<p className="text-meta text-muted-foreground">正在读取版本目标…</p>
) : slotList.length ? (
@@ -127,11 +127,11 @@ export function SearchDownloadDialog({
<p className="text-meta text-muted-foreground">这个作品没有版本目标。先在订阅里新建。</p>
)}
{release.entityMatch === "alias" ? (
<p className="text-meta text-warning">作品关联来自标题别名,尚未确认。</p>
<p className="text-meta text-warning">作品关联来自标题别名,尚未确认。</p>
) : null}
{rejected ? (
<div className="rounded-xl border border-warning/40 bg-warning/10 px-3 py-2.5 text-caption text-warning">
<p className="mb-1">没有生成下载计划:</p>
<p className="mb-1">没有生成下载计划:</p>
<ul className="list-disc space-y-0.5 pl-5">
{rejected.map((r, i) => (
<li key={`${r.slotId}-${r.code}-${i}`} className="break-words">
@@ -130,14 +130,14 @@ export function MediaSection({ q }: { q: string }) {
)}
{tmdb && !tmdb.configured ? (
<p className="border-t border-border/60 px-4 py-2.5 text-meta text-muted-foreground">
TMDB 未配置,只搜了本地作品。
TMDB 未配置,只搜了本地作品。
<Link to={settingsPath("metadata")} className="ml-1 underline underline-offset-4">
去配置
</Link>
</p>
) : null}
{tmdb?.error ? (
<p className="border-t border-border/60 px-4 py-2.5 text-meta text-warning">TMDB 查询失败:{tmdb.error}</p>
<p className="border-t border-border/60 px-4 py-2.5 text-meta text-warning">TMDB 查询失败:{tmdb.error}</p>
) : null}
</div>
</section>
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff. Show more