From b7460d5d0a76f1146821f87f25db1d8dde2e174f Mon Sep 17 00:00:00 2001
From: root
Date: Tue, 6 Oct 2026 18:01:19 +0800
Subject: [PATCH 1/4] =?UTF-8?q?feat(cache):=20=E9=80=9A=E7=94=A8=E7=BC=93?=
=?UTF-8?q?=E5=AD=98=E6=8C=89=E5=8C=BA=E7=BB=9F=E8=AE=A1=E4=B8=8E=E6=B8=85?=
=?UTF-8?q?=E7=A9=BA=E6=8E=A5=E5=8F=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
GET /cache/regions 列出 kv_cache 各区的未过期条数、已过期条数、占用字节、最近写入;
POST /cache/regions/{region}/clear 清空一个区(admin)。contracts 加区名中文映射
cacheRegionLabel,web 加对应 hooks。
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
---
apps/server/src/modules/cache/index.ts | 35 +++++++++++-
apps/server/test/cache/region-admin.test.ts | 61 +++++++++++++++++++++
apps/web/app/api/cache.ts | 29 ++++++++--
packages/contracts/src/api/platform.ts | 15 +++++
packages/contracts/src/cache.ts | 56 +++++++++++++++++++
5 files changed, 190 insertions(+), 6 deletions(-)
create mode 100644 apps/server/test/cache/region-admin.test.ts
diff --git a/apps/server/src/modules/cache/index.ts b/apps/server/src/modules/cache/index.ts
index 9bf7cda5..097133d9 100644
--- a/apps/server/src/modules/cache/index.ts
+++ b/apps/server/src/modules/cache/index.ts
@@ -8,6 +8,7 @@ import {
type TmdbCacheItem,
type TmdbCacheList,
} from "@umt/contracts";
+import { sql } from "@umt/db";
import type { z } from "zod";
import { dataPaths } from "../../config.ts";
import { externalFetch } from "../../egress.ts";
@@ -40,10 +41,11 @@ type Infer = T extends z.ZodType ? z.infer : Record;
function route(
app: ModuleApp,
def: D,
- handler: (i: { query: Infer }) => Promise,
+ handler: (i: { query: Infer; params: Infer }) => Promise,
) {
const request: RouteConfig["request"] = {};
if (def.query) request.query = def.query as never;
+ if (def.params) request.params = def.params as never;
const r = createRoute({
method: def.method,
path: def.path,
@@ -57,7 +59,12 @@ function route(
});
// biome-ignore lint/suspicious/noExplicitAny: 路由泛型在封装里擦除,入参已由 zod-openapi 校验
app.openapi(r, (async (c: any) => {
- const data = def.response.parse(await handler({ query: def.query ? c.req.valid("query") : {} } as never));
+ const data = def.response.parse(
+ await handler({
+ query: def.query ? c.req.valid("query") : {},
+ params: def.params ? c.req.valid("param") : {},
+ } as never),
+ );
return c.json({ ok: true, data }, 200);
// biome-ignore lint/suspicious/noExplicitAny: 同上
}) as any);
@@ -178,6 +185,30 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
};
});
route(app, Api.cache.imagesClear, async () => ({ deleted: await images(ctx).clear() }));
+ // 通用缓存按区统计与清空(设置 → 缓存);图片缓存在磁盘上,走上面两个接口
+ route(app, Api.cache.regions, async () => {
+ const r = await sql<{ region: string; entries: number; expired: number; bytes: string; last: Date | null }>`
+ select region,
+ count(*) filter (where expires_at > now())::int as entries,
+ count(*) filter (where expires_at <= now())::int as expired,
+ coalesce(sum(pg_column_size(value) + pg_column_size(key)), 0)::text as bytes,
+ max(updated_at) as last
+ from kv_cache group by region order by region`.execute(ctx.db);
+ return {
+ items: r.rows.map((x) => ({
+ region: x.region,
+ entries: Number(x.entries),
+ expired: Number(x.expired),
+ bytes: Number(x.bytes),
+ lastWriteAt: x.last ? new Date(x.last).toISOString() : null,
+ })),
+ };
+ });
+ route(app, Api.cache.regionClear, async ({ params }) => {
+ const deleted = await sharedCache(ctx).clear(params.region);
+ ctx.logger.info({ region: params.region, deleted }, "缓存区已清空");
+ return { deleted };
+ });
},
registerTasks() {},
start(ctx: ServerContext) {
diff --git a/apps/server/test/cache/region-admin.test.ts b/apps/server/test/cache/region-admin.test.ts
new file mode 100644
index 00000000..be5a5efe
--- /dev/null
+++ b/apps/server/test/cache/region-admin.test.ts
@@ -0,0 +1,61 @@
+import { PgCache } from "@umt/db";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { createCacheModule } from "../../src/modules/cache/index.ts";
+import { bearer, createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts";
+import { DEV_KEY, json } from "../helpers.ts";
+
+// 设置 → 缓存:通用缓存(kv_cache)按区统计与单区清空(GET /cache/regions、POST /cache/regions/{region}/clear)
+
+const hasPg = await pgAvailable();
+
+describe.skipIf(!hasPg)("通用缓存按区管理(真实 PG)", () => {
+ let h: Harness;
+ let admin: ReturnType;
+
+ beforeAll(async () => {
+ h = await createHarness({ extraModules: [createCacheModule()] });
+ admin = bearer(h.app, DEV_KEY);
+ });
+ afterAll(async () => {
+ await h?.close();
+ });
+
+ it("按区列出未过期条数、已过期条数、占用字节、最近写入;清空一个区不影响其它区", async () => {
+ const cache = new PgCache(h.db);
+ await cache.set("douban.subject", "1", { title: "a" });
+ await cache.set("douban.subject", "2", { title: "b" });
+ await cache.set("site.detail", "x", { body: "y".repeat(200) });
+ // 写入后立刻过期的一条(ttl 1 秒,等它过期)
+ await cache.set("site.detail", "old", { v: 1 }, { ttl: 1 });
+ await new Promise((r) => setTimeout(r, 1100));
+
+ const res = await admin("GET", "/cache/regions");
+ expect(res.status).toBe(200);
+ const items = (await json(res)).data.items as {
+ region: string;
+ entries: number;
+ expired: number;
+ bytes: number;
+ lastWriteAt: string | null;
+ }[];
+ const douban = items.find((x) => x.region === "douban.subject")!;
+ const site = items.find((x) => x.region === "site.detail")!;
+ expect(douban).toMatchObject({ entries: 2, expired: 0 });
+ expect(douban.bytes).toBeGreaterThan(0);
+ expect(Date.parse(douban.lastWriteAt!)).toBeGreaterThan(Date.now() - 60_000);
+ expect(site).toMatchObject({ entries: 1, expired: 1 });
+ expect(site.bytes).toBeGreaterThan(200);
+
+ const cleared = await json(await admin("POST", "/cache/regions/site.detail/clear"));
+ expect(cleared.data.deleted).toBe(2);
+ const after = (await json(await admin("GET", "/cache/regions"))).data.items as { region: string }[];
+ expect(after.map((x) => x.region)).toContain("douban.subject");
+ expect(after.map((x) => x.region)).not.toContain("site.detail");
+ expect(await cache.count("douban.subject")).toBe(2);
+ });
+
+ it("区名不合法时拒绝(422),不执行删除", async () => {
+ const res = await admin("POST", "/cache/regions/BAD%20NAME/clear");
+ expect(res.status).toBe(422);
+ });
+});
diff --git a/apps/web/app/api/cache.ts b/apps/web/app/api/cache.ts
index d7784407..240ea083 100644
--- a/apps/web/app/api/cache.ts
+++ b/apps/web/app/api/cache.ts
@@ -1,10 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import type { CacheClearResult, ImageCacheStatus, TmdbCacheList } from "@umt/contracts";
+import type { CacheClearResult, CacheRegionList, ImageCacheStatus, TmdbCacheList } from "@umt/contracts";
import { apiData } from "~/lib/api.ts";
-// C2-A 缓存管理:TMDB 缓存(条数 / 上限 / TTL / 清空)、图片代理缓存(用量 / 清空)
+// C2-A 缓存管理:TMDB 缓存(条数 / 上限 / TTL / 清空)、图片代理缓存(用量 / 清空)、通用缓存按区统计与清空
-const keys = { tmdb: ["cache", "tmdb"] as const, images: ["cache", "images"] as const };
+const keys = {
+ tmdb: ["cache", "tmdb"] as const,
+ images: ["cache", "images"] as const,
+ regions: ["cache", "regions"] as const,
+};
export function useTmdbCache() {
return useQuery({
@@ -17,7 +21,7 @@ export function useClearTmdbCache() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiData("/cache/tmdb/clear", { method: "POST" }),
- onSuccess: () => qc.invalidateQueries({ queryKey: keys.tmdb }),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["cache"] }),
});
}
@@ -35,3 +39,20 @@ export function useClearImageCache() {
onSuccess: () => qc.invalidateQueries({ queryKey: keys.images }),
});
}
+
+export function useCacheRegions() {
+ return useQuery({
+ queryKey: keys.regions,
+ queryFn: ({ signal }) => apiData("/cache/regions", { signal }),
+ });
+}
+
+/** 清空一个区;TMDB 两个区也在这里清,顺带刷新 TMDB 缓存计数 */
+export function useClearCacheRegion() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (region: string) =>
+ apiData(`/cache/regions/${encodeURIComponent(region)}/clear`, { method: "POST" }),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["cache"] }),
+ });
+}
diff --git a/packages/contracts/src/api/platform.ts b/packages/contracts/src/api/platform.ts
index f2305f5f..30527e0b 100644
--- a/packages/contracts/src/api/platform.ts
+++ b/packages/contracts/src/api/platform.ts
@@ -95,6 +95,21 @@ export const platformApi = {
summary: "清空图片代理缓存",
response: cache.CacheClearResult,
}),
+ regions: ep({
+ method: "get",
+ path: "/cache/regions",
+ scope: "admin",
+ summary: "通用缓存(kv_cache)按区统计:条目数、已过期数、占用字节、最近写入",
+ response: cache.CacheRegionList,
+ }),
+ regionClear: ep({
+ method: "post",
+ path: "/cache/regions/{region}/clear",
+ scope: "admin",
+ summary: "清空通用缓存的一个区",
+ params: cache.CacheRegionParam,
+ response: cache.CacheClearResult,
+ }),
},
// ── 网页搜索(WEB-1,docs/runbook/websearch.md) ──
diff --git a/packages/contracts/src/cache.ts b/packages/contracts/src/cache.ts
index 0e81f501..2b748e57 100644
--- a/packages/contracts/src/cache.ts
+++ b/packages/contracts/src/cache.ts
@@ -75,3 +75,59 @@ export function tmdbImageUrl(path: string | null | undefined, size: TmdbImageSiz
if (!/^[A-Za-z0-9_-]+\.(jpg|jpeg|png|webp)$/.test(p)) return null;
return `/api/v1/img/tmdb/${size}/${p}`;
}
+
+// ── 通用缓存(kv_cache)按 region 管理(设置 → 缓存) ──
+
+/** region 名:小写字母、数字、`.`、`_`、`-` */
+export const CacheRegionName = z.string().regex(/^[a-z0-9][a-z0-9._-]{0,63}$/, "缓存区名不合法");
+export const CacheRegionParam = z.object({ region: CacheRegionName });
+
+export const CacheRegionStat = z.object({
+ region: z.string(),
+ /** 未过期条目数 */
+ entries: NonNegInt,
+ /** 已过期、等清理任务删除的条目数 */
+ expired: NonNegInt,
+ /** 键与值占用字节(pg_column_size 之和,含已过期) */
+ bytes: NonNegInt,
+ /** 最近一次写入 */
+ lastWriteAt: Timestamp.nullable(),
+});
+export type CacheRegionStat = z.infer;
+
+export const CacheRegionList = z.object({ items: z.array(CacheRegionStat) });
+export type CacheRegionList = z.infer;
+
+/** region → 界面名;精确匹配优先,其次按前缀(`bangumi.*`);都不中返回 null(界面原样显示 region) */
+const CACHE_REGION_LABELS: Record = {
+ "tmdb.request": "TMDB 请求",
+ "tmdb.recognize": "TMDB 识别",
+ "douban.request": "豆瓣请求",
+ "douban.subject": "豆瓣条目",
+ "bangumi.request": "Bangumi 请求",
+ "bangumi.subject": "Bangumi 条目",
+ "meta.subject_miss": "豆瓣 / Bangumi 查无结果",
+ "discover.list": "发现列表",
+ "site.detail": "站点详情页",
+ "identity.detail_ids": "识别抓详情页计数",
+ enrich: "元数据补全",
+ "enrich.robots": "元数据补全 robots.txt",
+ websearch: "网页搜索",
+ "websearch.allow": "可读取的网页",
+ images: "图片",
+};
+const CACHE_REGION_PREFIX_LABELS: [string, string][] = [
+ ["tmdb.", "TMDB"],
+ ["douban.", "豆瓣"],
+ ["bangumi.", "Bangumi"],
+ ["discover.", "发现"],
+ ["site.", "站点"],
+ ["enrich.", "元数据补全"],
+ ["websearch.", "网页搜索"],
+];
+export function cacheRegionLabel(region: string): string | null {
+ const exact = CACHE_REGION_LABELS[region];
+ if (exact) return exact;
+ const hit = CACHE_REGION_PREFIX_LABELS.find(([p]) => region.startsWith(p));
+ return hit ? `${hit[1]}(${region.slice(hit[0].length)})` : null;
+}
From 93a6c8a9102a694058b0b3ab654f20ff4ebec4cb Mon Sep 17 00:00:00 2001
From: root
Date: Tue, 6 Oct 2026 18:01:19 +0800
Subject: [PATCH 2/4] =?UTF-8?q?feat(settings):=20=E8=AE=BE=E7=BD=AE?=
=?UTF-8?q?=E5=88=86=E5=8C=BA=E6=8C=89=E6=8E=A5=E5=85=A5=20/=20=E6=B5=81?=
=?UTF-8?q?=E7=A8=8B=20/=20=E7=B3=BB=E7=BB=9F=E4=B8=89=E7=BB=84=E9=87=8D?=
=?UTF-8?q?=E6=8E=92?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
SETTINGS_SECTIONS 改为 12 个分区(下载器、媒体服务器、站点与网络、元数据、获取、媒体库与目录、
整理与刮削、智能体、系统、缓存、账户与安全、界面),加分组、节别名(搜索用)、旧 hash 别名
SETTINGS_HASH_ALIASES 与 resolveSettingsHash;retention.logRetentionDays 无人读取,登记为弃用。
不改存储结构与缺省值。
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
---
packages/contracts/src/settings.ts | 368 ++++++++++++------
.../contracts/test/settings-sections.test.ts | 87 ++++-
2 files changed, 339 insertions(+), 116 deletions(-)
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index a604c1f8..d4b311b8 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -848,7 +848,7 @@ export const SettingEntry = z.object({
});
export type SettingEntry = z.infer;
-/** 已弃用、不归属任何设置分区的旧字段(见 PiSettings 注释) */
+/** 已弃用、不归属任何设置分区的旧字段(见 PiSettings 注释);设置页不显示 */
export const DEPRECATED_SETTINGS_FIELDS = [
"pi.provider",
"pi.baseUrl",
@@ -859,6 +859,8 @@ export const DEPRECATED_SETTINGS_FIELDS = [
"cache.tmdbTtlHours",
"cache.emptyResultTtlSec",
"identity.poolArchiveDays",
+ // CACHE-2 起流水数据按 storage.retentionDays 分类清理,这个总的日志保留天数没有任何地方读取
+ "retention.logRetentionDays",
// 2026-10-06:智能体不设硬性预算上限,只保留单次工具调用次数与时长两个防跑飞限制
"budget.agentMaxRunsPerDay",
"budget.agentMaxCostUsdPerDay",
@@ -866,32 +868,52 @@ export const DEPRECATED_SETTINGS_FIELDS = [
"agent.maxCostUsdPerRun",
] as const;
-// ── 设置页分区(docs/design/console-ia.md §3.4) ──
-// 只是"展示分区 → 字段"的映射,不改存储 key;除 DEPRECATED_SETTINGS_FIELDS 外每个字段恰好归属一个分区(测试断言)。
-// 非 settings 表的对象(实例、站点、API Key、Pi 上游、账户)用 resources 标注,不进 fields。
+// ── 设置页分区(docs/design/console-ia.md §3.4,2026-10-06 重排) ──
+// 只是"展示分区 → 字段"的映射,不改存储 key 与缺省值;除 DEPRECATED_SETTINGS_FIELDS 外每个字段恰好归属一个分区(测试断言)。
+// 非 settings 表的对象(实例、站点、API Key、模型服务、账户)用 resources 标注,不进 fields。
+// 分区按三组排:接入对象(外部服务与数据源)→ 业务环节(获取 → 落盘 → 整理 → 智能体)→ 系统与账户。
export const SettingsSectionId = z.enum([
- "automation",
- "sites",
"downloaders",
- "library",
"media-servers",
+ "sites",
"metadata",
+ "acquire",
+ "library",
+ "organize",
"agent",
- "api-keys",
- "interface",
+ "system",
+ "cache",
"account",
+ "interface",
]);
export type SettingsSectionId = z.infer;
+export const SettingsGroupId = z.enum(["connect", "flow", "system"]);
+export type SettingsGroupId = z.infer;
+export const SETTINGS_GROUPS: readonly { id: SettingsGroupId; label: string }[] = [
+ { id: "connect", label: "接入" },
+ { id: "flow", label: "流程" },
+ { id: "system", label: "系统" },
+];
+
+export type SettingsAnchorDef = {
+ id: string;
+ label: string;
+ /** 搜索用的别名:不在表单字段里的设置(实例抽屉的字段、登录方式等) */
+ keywords?: string[];
+};
+
export type SettingsSectionDef = {
id: SettingsSectionId;
+ group: SettingsGroupId;
label: string;
+ /** 一句说明:这里管什么 */
description: string;
/** 旧 `?tab=` 值 → 本分区(可带分区内锚点) */
legacyTabs: { tab: string; anchor?: string }[];
/** 分区内锚点(节),顺序即页面顺序 */
- anchors: { id: string; label: string }[];
+ anchors: SettingsAnchorDef[];
/** 归属本分区的 settings 字段(`key.field`,compaction 这类嵌套对象按整体算) */
fields: string[];
/** 本分区管理的非 settings 对象 */
@@ -900,44 +922,58 @@ export type SettingsSectionDef = {
export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
{
- id: "automation",
- label: "自动化",
- description: "总开关、下载盘、每日预算、保留天数",
- legacyTabs: [{ tab: "automation" }, { tab: "storage", anchor: "cache" }],
+ id: "downloaders",
+ group: "connect",
+ label: "下载器",
+ description: "qBittorrent 实例与路径映射;下载盘水位和保种天数的缺省值,实例里可以单独改",
+ legacyTabs: [{ tab: "downloaders" }, { tab: "path-mappings" }, { tab: "storage", anchor: "cache" }],
anchors: [
- { id: "switches", label: "开关" },
- { id: "cache", label: "下载盘" },
- { id: "budget", label: "每日预算" },
- { id: "retention", label: "保留" },
- { id: "advanced", label: "高级" },
+ {
+ id: "instances",
+ label: "实例",
+ keywords: [
+ "qBittorrent",
+ "WebUI",
+ "管理标签",
+ "缓存卷标识",
+ "默认下载器",
+ "路径映射",
+ "H&R 未知也可清理",
+ "认证方式",
+ ],
+ },
+ { id: "cache", label: "下载盘", keywords: ["水位", "缓存盘"] },
+ { id: "seeding", label: "保种", keywords: ["做种"] },
],
fields: [
- "automation.enabled",
- "automation.readOnlyReconcile",
- "automation.workerLanes",
"cache.hardThresholdPercent",
"cache.resumePercent",
"cache.checkIntervalSec",
"cache.cleanupTargetPercent",
- "budget.maxNewTitlesPerDay",
- "budget.maxDownloadBytesPerDay",
- "budget.maxConcurrentDownloads",
"retention.seedRetentionDays",
- "retention.logRetentionDays",
- "storage.budgets",
- "storage.retentionDays",
- "storage.cacheTtl",
],
- resources: [],
+ resources: ["downloader-instances", "path-mappings"],
+ },
+ {
+ id: "media-servers",
+ group: "connect",
+ label: "媒体服务器",
+ description: "Emby / Jellyfin 实例与路径映射,只用来查作品有没有",
+ legacyTabs: [{ tab: "media-servers" }],
+ anchors: [{ id: "instances", label: "实例", keywords: ["Emby", "Jellyfin", "用户 ID"] }],
+ fields: [],
+ resources: ["media-server-instances", "path-mappings"],
},
{
id: "sites",
- label: "站点默认",
- description: "新站点的限流、快照周期、UA 和代理,以及原站视图用的浏览器服务;站点和凭证在站点页管理",
+ group: "connect",
+ label: "站点与网络",
+ description: "站点缺省值、User-Agent 与代理、浏览器服务;站点、凭证和 Cloudflare 在站点页",
legacyTabs: [{ tab: "site-credentials" }],
anchors: [
- { id: "defaults", label: "站点默认" },
- { id: "browser", label: "浏览器服务" },
+ { id: "defaults", label: "站点缺省值", keywords: ["限流", "抓取周期", "健康检测"] },
+ { id: "network", label: "UA 与代理", keywords: ["User-Agent", "代理", "出口"] },
+ { id: "browser", label: "浏览器服务", keywords: ["原站视图", "Cloudflare"] },
],
fields: [
"sites.defaultRateLimit",
@@ -948,43 +984,70 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
"sites.defaultSnapshotMaxPages",
"sites.defaultHealthCheckIntervalSec",
"sites.defaultDetailCacheSec",
- "search.defaultSiteMode",
- "search.siteIds",
+ ],
+ resources: ["browser-providers"],
+ },
+ {
+ id: "metadata",
+ group: "connect",
+ label: "元数据",
+ description: "TMDB 接入、语言回退与特别篇;缓存在「缓存」里管理",
+ legacyTabs: [],
+ anchors: [
+ { id: "tmdb", label: "TMDB", keywords: ["API Key", "Base URL"] },
+ { id: "language", label: "语言与季集", keywords: ["季 0", "特别篇", "简介"] },
+ ],
+ fields: [
+ "metadata.tmdbApiKeySecretRef",
+ "metadata.tmdbBaseUrl",
+ "metadata.languages",
+ "metadata.overviewNonChineseFallback",
+ "metadata.includeSpecials",
],
resources: [],
},
{
- id: "downloaders",
- label: "下载器",
- description: "qBittorrent 实例、连通测试、路径映射",
- legacyTabs: [{ tab: "downloaders" }, { tab: "path-mappings" }],
- anchors: [{ id: "instances", label: "实例" }],
- fields: [],
- resources: ["downloader-instances", "path-mappings"],
+ id: "acquire",
+ group: "flow",
+ label: "获取",
+ description: "每日下载预算、搜索站点、资源识别;订阅和规则在各自页面",
+ legacyTabs: [],
+ anchors: [
+ { id: "budget", label: "每日预算", keywords: ["并发下载"] },
+ { id: "search", label: "搜索站点", keywords: ["默认参与搜索"] },
+ { id: "identity", label: "资源识别", keywords: ["元数据补全", "TMDB"] },
+ ],
+ fields: [
+ "budget.maxNewTitlesPerDay",
+ "budget.maxDownloadBytesPerDay",
+ "budget.maxConcurrentDownloads",
+ "search.defaultSiteMode",
+ "search.siteIds",
+ "identity.eagerTmdbLookup",
+ "identity.autoEnrich",
+ "identity.enrichDailyBudget",
+ "identity.enrichDomains",
+ "identity.enrichSnippetAllSites",
+ "identity.detailIdsPerSiteHour",
+ ],
+ resources: [],
},
{
id: "library",
+ group: "flow",
label: "媒体库与目录",
- description: "媒体库目录、默认整理方式、刮削、命名模板",
+ description: "媒体库目录、整理来源、分类规则、命名模板",
legacyTabs: [{ tab: "library-roots", anchor: "roots" }],
anchors: [
- { id: "roots", label: "媒体库目录" },
- { id: "classification", label: "分类规则" },
+ { id: "roots", label: "媒体库目录", keywords: ["标记文件", "检查目录"] },
{ id: "sources", label: "整理来源" },
- { id: "organize", label: "整理方式" },
- { id: "scrape", label: "刮削" },
- { id: "naming", label: "命名模板" },
+ { id: "classification", label: "分类规则", keywords: ["二级分类"] },
+ { id: "naming", label: "命名模板", keywords: ["重命名"] },
+ { id: "naming-roots", label: "目录命名覆盖" },
],
fields: [
"organize.classificationYaml",
"organize.sources",
- "organize.defaultMode",
- "organize.allowCrossVolumeMove",
- "organize.minSizeBytes",
- "organize.scrapeAfterImport",
- "organize.scrapeMode",
- "organize.scrapeImages",
- "organize.scrapeEpisodeThumbs",
"naming.movie",
"naming.tv",
"naming.variety",
@@ -993,57 +1056,41 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
resources: ["library-roots"],
},
{
- id: "media-servers",
- label: "媒体服务器",
- description: "Emby / Jellyfin 实例、连通测试、路径映射",
- legacyTabs: [{ tab: "media-servers" }],
- anchors: [{ id: "instances", label: "实例" }],
- fields: [],
- resources: ["media-server-instances", "path-mappings"],
- },
- {
- id: "metadata",
- label: "元数据",
- description: "TMDB API Key 与地址、TMDB 缓存、资源识别、图片代理",
+ id: "organize",
+ group: "flow",
+ label: "整理与刮削",
+ description: "整理方式、最小视频体积、整理后自动刮削和已有文件的覆盖方式",
legacyTabs: [],
anchors: [
- { id: "tmdb", label: "TMDB" },
- { id: "tmdb-cache", label: "TMDB 缓存" },
- { id: "identity", label: "资源识别" },
- { id: "images", label: "图片代理与缓存" },
+ { id: "mode", label: "整理方式", keywords: ["复制", "移动", "跨卷"] },
+ { id: "scrape", label: "刮削", keywords: ["NFO", "海报", "剧照", "更新", "只补缺失", "全部覆盖"] },
],
fields: [
- "metadata.tmdbApiKeySecretRef",
- "metadata.tmdbBaseUrl",
- "metadata.languages",
- "metadata.overviewNonChineseFallback",
- "metadata.includeSpecials",
- "cache.tmdbMaxSize",
- "identity.eagerTmdbLookup",
- "identity.autoEnrich",
- "identity.enrichDailyBudget",
- "identity.enrichDomains",
- "identity.enrichSnippetAllSites",
- "identity.detailIdsPerSiteHour",
- "cache.globalImageCache",
- "cache.globalImageCacheDays",
+ "organize.defaultMode",
+ "organize.allowCrossVolumeMove",
+ "organize.minSizeBytes",
+ "organize.scrapeAfterImport",
+ "organize.scrapeMode",
+ "organize.scrapeImages",
+ "organize.scrapeEpisodeThumbs",
],
resources: [],
},
{
id: "agent",
+ group: "flow",
label: "智能体",
- description: "模型服务、用量与限制、自动调查、网页搜索",
+ description: "模型服务、用量与防跑飞限制、自动处理、网页搜索、MCP",
legacyTabs: [{ tab: "pi" }],
anchors: [
- { id: "upstream", label: "模型服务" },
- { id: "runtime", label: "上下文与子会话" },
- { id: "budget", label: "用量与限制" },
- { id: "investigate", label: "自动调查" },
- { id: "websearch", label: "网页搜索" },
- { id: "advanced", label: "高级" },
+ { id: "upstream", label: "模型服务", keywords: ["默认模型服务", "订阅登录"] },
+ { id: "budget", label: "用量与限制", keywords: ["花费", "预算"] },
+ { id: "investigate", label: "自动处理", keywords: ["自动调查", "识别失败"] },
+ { id: "runtime", label: "上下文与子会话", keywords: ["压缩"] },
+ { id: "websearch", label: "网页搜索", keywords: ["Brave"] },
{ id: "mcp", label: "MCP 服务端" },
{ id: "capabilities", label: "能力清单" },
+ { id: "advanced", label: "高级", keywords: ["方案", "审批"] },
],
fields: [
"pi.defaultUpstreamId",
@@ -1071,37 +1118,74 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
resources: ["pi-upstreams"],
},
{
- id: "api-keys",
- label: "API Key",
- description: "签发给外部程序的 Key",
- legacyTabs: [{ tab: "api-keys" }],
- anchors: [{ id: "keys", label: "API Key" }],
- fields: [],
- resources: ["api-keys"],
+ id: "system",
+ group: "system",
+ label: "系统",
+ description: "自动化总开关、只读对账、后台任务并发、存储预算、数据保留",
+ legacyTabs: [{ tab: "automation" }],
+ anchors: [
+ { id: "switches", label: "自动化", keywords: ["总开关", "暂停"] },
+ { id: "readonly", label: "只读对账" },
+ { id: "worker-lanes", label: "后台任务并发" },
+ { id: "storage", label: "存储预算" },
+ { id: "storage-retention", label: "数据保留", keywords: ["日志保留"] },
+ ],
+ fields: [
+ "automation.enabled",
+ "automation.readOnlyReconcile",
+ "automation.workerLanes",
+ "storage.budgets",
+ "storage.retentionDays",
+ ],
+ resources: [],
},
{
- id: "interface",
- label: "界面",
- description: "只影响当前浏览器",
+ id: "cache",
+ group: "system",
+ label: "缓存",
+ description: "各类缓存的条数、占用和有效期,可以按区清空",
legacyTabs: [],
- anchors: [{ id: "display", label: "显示" }],
- fields: [],
- resources: ["ui-preferences"],
+ anchors: [
+ {
+ id: "regions",
+ label: "缓存区",
+ keywords: ["清空缓存", "豆瓣", "Bangumi", "站点详情", "发现", "网页搜索"],
+ },
+ { id: "tmdb-cache", label: "TMDB 缓存", keywords: ["条数上限"] },
+ { id: "images", label: "图片缓存", keywords: ["海报"] },
+ { id: "ttl", label: "缓存有效期" },
+ ],
+ fields: ["cache.tmdbMaxSize", "cache.globalImageCache", "cache.globalImageCacheDays", "storage.cacheTtl"],
+ resources: ["kv-cache", "image-cache"],
},
{
id: "account",
+ group: "system",
label: "账户与安全",
- description: "密码、二次验证、会话、快捷键",
- legacyTabs: [],
+ description: "密码、二次验证、通行密钥、会话与本机 Token、API Key",
+ legacyTabs: [{ tab: "api-keys", anchor: "api-keys" }],
anchors: [
{ id: "password", label: "密码" },
- { id: "totp", label: "二次验证" },
- { id: "passkeys", label: "通行密钥" },
- { id: "session", label: "会话" },
+ { id: "totp", label: "二次验证", keywords: ["TOTP", "恢复码"] },
+ { id: "passkeys", label: "通行密钥", keywords: ["Passkey"] },
+ { id: "session", label: "会话", keywords: ["本机 Token", "退出登录"] },
+ { id: "api-keys", label: "API Key", keywords: ["外部程序"] },
+ ],
+ fields: [],
+ resources: ["auth", "api-keys"],
+ },
+ {
+ id: "interface",
+ group: "system",
+ label: "界面",
+ description: "只影响当前浏览器",
+ legacyTabs: [],
+ anchors: [
+ { id: "display", label: "显示", keywords: ["技术信息"] },
{ id: "shortcuts", label: "快捷键" },
],
fields: [],
- resources: ["auth"],
+ resources: ["ui-preferences"],
},
];
@@ -1113,3 +1197,63 @@ export function settingsSectionForLegacyTab(tab: string): { section: SettingsSec
}
return null;
}
+
+/**
+ * 旧 hash(2026-10-06 重排前的 `#分区[/节]`)→ 新位置。先查整条,再查只有分区的那一条;
+ * 新旧同名的(`#downloaders`、`#sites/browser`、`#agent/budget` 等)不用登记。
+ */
+export const SETTINGS_HASH_ALIASES: Readonly> = {
+ automation: "system",
+ "automation/switches": "system/switches",
+ "automation/cache": "downloaders/cache",
+ "automation/budget": "acquire/budget",
+ "automation/retention": "downloaders/seeding",
+ "automation/advanced": "system/readonly",
+ "automation/readonly": "system/readonly",
+ "automation/worker-lanes": "system/worker-lanes",
+ "automation/storage": "system/storage",
+ "automation/storage-retention": "system/storage-retention",
+ "automation/storage-ttl": "cache/ttl",
+ "system/storage-ttl": "cache/ttl",
+ "metadata/tmdb-cache": "cache/tmdb-cache",
+ "metadata/images": "cache/images",
+ "library/organize": "organize/mode",
+ "library/scrape": "organize/scrape",
+ "metadata/identity": "acquire/identity",
+ "api-keys": "account/api-keys",
+ "api-keys/keys": "account/api-keys",
+ "account/shortcuts": "interface/shortcuts",
+ "sites/accounts": "sites",
+};
+
+/**
+ * 解析设置页 hash:`#分区[/节]`,旧 hash 按 SETTINGS_HASH_ALIASES 换成新位置(moved = true 时调用方应 replace 地址栏);
+ * 不认识的分区返回 null 分区(调用方落到第一个分区),不认识的节丢掉。
+ */
+export function resolveSettingsHash(hash: string): {
+ section: SettingsSectionId | null;
+ anchor: string | null;
+ moved: boolean;
+} {
+ let raw = hash.replace(/^#/, "");
+ try {
+ raw = decodeURIComponent(raw);
+ } catch {
+ // 保持原样
+ }
+ const [head = "", tail] = raw.split("/");
+ const full = tail ? `${head}/${tail}` : head;
+ const alias = SETTINGS_HASH_ALIASES[full];
+ let target = alias ?? full;
+ let moved = alias !== undefined;
+ if (!alias && SETTINGS_HASH_ALIASES[head] && !SETTINGS_SECTIONS.some((s) => s.id === head)) {
+ // 旧分区里没登记的节:落到新分区顶部
+ target = SETTINGS_HASH_ALIASES[head] as string;
+ moved = true;
+ }
+ const [sid, anchor] = target.split("/");
+ const section = SETTINGS_SECTIONS.find((s) => s.id === sid);
+ if (!section) return { section: null, anchor: null, moved: false };
+ const known = anchor && section.anchors.some((a) => a.id === anchor) ? anchor : null;
+ return { section: section.id, anchor: known, moved };
+}
diff --git a/packages/contracts/test/settings-sections.test.ts b/packages/contracts/test/settings-sections.test.ts
index e8ccc109..e65e41a2 100644
--- a/packages/contracts/test/settings-sections.test.ts
+++ b/packages/contracts/test/settings-sections.test.ts
@@ -2,17 +2,28 @@ import { describe, expect, it } from "vitest";
import {
DEFAULT_SETTINGS,
DEPRECATED_SETTINGS_FIELDS,
+ resolveSettingsHash,
+ SETTINGS_GROUPS,
+ SETTINGS_HASH_ALIASES,
SETTINGS_SECTIONS,
SettingsSectionId,
settingsSectionForLegacyTab,
} from "../src/settings.ts";
describe("SETTINGS_SECTIONS", () => {
- it("10 个分区,顺序与 SettingsSectionId 一致", () => {
+ it("分区顺序与 SettingsSectionId 一致,按接入 → 流程 → 系统三组排", () => {
expect(SETTINGS_SECTIONS.map((s) => s.id)).toEqual(SettingsSectionId.options);
+ const groups = SETTINGS_SECTIONS.map((s) => s.group);
+ const order = SETTINGS_GROUPS.map((g) => g.id);
+ expect([...groups].sort((a, b) => order.indexOf(a) - order.indexOf(b))).toEqual(groups);
+ for (const s of SETTINGS_SECTIONS) {
+ expect(s.description, s.id).not.toBe("");
+ expect(s.anchors.length, s.id).toBeGreaterThan(0);
+ expect(new Set(s.anchors.map((a) => a.id)).size, s.id).toBe(s.anchors.length);
+ }
});
- it("8 个存储 key 的每个字段(弃用字段除外)恰好归属一个分区", () => {
+ it("每个存储 key 的每个字段(弃用字段除外)恰好归属一个分区", () => {
const deprecated = new Set(DEPRECATED_SETTINGS_FIELDS);
const all = Object.entries(DEFAULT_SETTINGS)
.flatMap(([key, v]) => Object.keys(v).map((f) => `${key}.${f}`))
@@ -22,6 +33,23 @@ describe("SETTINGS_SECTIONS", () => {
expect([...owned].sort()).toEqual([...all].sort());
});
+ it("近期新增的设置归到用户找得到的分区", () => {
+ const owner = (f: string) => SETTINGS_SECTIONS.find((s) => s.fields.includes(f))?.id;
+ expect(owner("organize.scrapeMode")).toBe("organize");
+ expect(owner("organize.scrapeAfterImport")).toBe("organize");
+ expect(owner("organize.minSizeBytes")).toBe("organize");
+ expect(owner("agent.autoIdentifyOrganize")).toBe("agent");
+ expect(owner("agent.maxToolCalls")).toBe("agent");
+ expect(owner("metadata.includeSpecials")).toBe("metadata");
+ expect(owner("cache.hardThresholdPercent")).toBe("downloaders");
+ expect(owner("retention.seedRetentionDays")).toBe("downloaders");
+ expect(owner("budget.maxConcurrentDownloads")).toBe("acquire");
+ expect(owner("sites.proxyUrl")).toBe("sites");
+ expect(owner("storage.cacheTtl")).toBe("cache");
+ expect(owner("cache.tmdbMaxSize")).toBe("cache");
+ expect(DEPRECATED_SETTINGS_FIELDS).toContain("retention.logRetentionDays");
+ });
+
it("旧 ?tab= 值都能落到分区", () => {
for (const tab of [
"automation",
@@ -36,8 +64,59 @@ describe("SETTINGS_SECTIONS", () => {
]) {
expect(settingsSectionForLegacyTab(tab), tab).not.toBeNull();
}
- expect(settingsSectionForLegacyTab("storage")).toEqual({ section: "automation", anchor: "cache" });
- expect(settingsSectionForLegacyTab("path-mappings")).toEqual({ section: "downloaders", anchor: undefined });
+ expect(settingsSectionForLegacyTab("storage")).toEqual({ section: "downloaders", anchor: "cache" });
+ expect(settingsSectionForLegacyTab("api-keys")).toEqual({ section: "account", anchor: "api-keys" });
expect(settingsSectionForLegacyTab("nope")).toBeNull();
});
});
+
+describe("resolveSettingsHash", () => {
+ it("新 hash 原样解析", () => {
+ expect(resolveSettingsHash("#agent/budget")).toEqual({ section: "agent", anchor: "budget", moved: false });
+ expect(resolveSettingsHash("#downloaders")).toEqual({ section: "downloaders", anchor: null, moved: false });
+ expect(resolveSettingsHash("")).toEqual({ section: null, anchor: null, moved: false });
+ expect(resolveSettingsHash("#nope")).toEqual({ section: null, anchor: null, moved: false });
+ // 不认识的节丢掉
+ expect(resolveSettingsHash("#agent/zzz")).toEqual({ section: "agent", anchor: null, moved: false });
+ });
+
+ it("旧锚点跳到新位置", () => {
+ const cases: [string, string, string | null][] = [
+ ["#agent", "agent", null],
+ ["#downloaders", "downloaders", null],
+ ["#automation", "system", null],
+ ["#automation/cache", "downloaders", "cache"],
+ ["#automation/budget", "acquire", "budget"],
+ ["#automation/retention", "downloaders", "seeding"],
+ ["#automation/advanced", "system", "readonly"],
+ ["#automation/storage-ttl", "cache", "ttl"],
+ ["#automation/whatever", "system", null],
+ ["#library/organize", "organize", "mode"],
+ ["#library/scrape", "organize", "scrape"],
+ ["#metadata/identity", "acquire", "identity"],
+ ["#metadata/tmdb-cache", "cache", "tmdb-cache"],
+ ["#metadata/images", "cache", "images"],
+ ["#api-keys", "account", "api-keys"],
+ ["#api-keys/keys", "account", "api-keys"],
+ ["#account/shortcuts", "interface", "shortcuts"],
+ ];
+ for (const [hash, section, anchor] of cases) {
+ const r = resolveSettingsHash(hash);
+ expect({ section: r.section, anchor: r.anchor }, hash).toEqual({ section, anchor });
+ }
+ expect(resolveSettingsHash("#automation/cache").moved).toBe(true);
+ });
+
+ it("别名的目标都是存在的分区与节", () => {
+ for (const [from, to] of Object.entries(SETTINGS_HASH_ALIASES)) {
+ const [sid, anchor] = to.split("/");
+ const s = SETTINGS_SECTIONS.find((x) => x.id === sid);
+ expect(s, from).toBeDefined();
+ if (anchor)
+ expect(
+ s?.anchors.map((a) => a.id),
+ from,
+ ).toContain(anchor);
+ }
+ });
+});
From 8071ad33f96c85cb691861aaacd19b79bbfa7b5b Mon Sep 17 00:00:00 2001
From: root
Date: Tue, 6 Oct 2026 18:01:26 +0800
Subject: [PATCH 3/4] =?UTF-8?q?feat(web):=20=E8=AE=BE=E7=BD=AE=E9=A1=B5?=
=?UTF-8?q?=E6=8C=89=E6=96=B0=E5=88=86=E7=B1=BB=E9=87=8D=E6=8E=92,?=
=?UTF-8?q?=E5=8A=A0=E6=90=9C=E7=B4=A2=E3=80=81=E7=BC=93=E5=AD=98=E5=88=86?=
=?UTF-8?q?=E5=8C=BA=E4=B8=8E=E6=97=A7=E9=94=9A=E7=82=B9=E8=B7=B3=E8=BD=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 左栏三组分区 + 当前分区的节;窄屏为搜索框、分组下拉和节的横排胶囊
- 搜索框按分区、节、节别名和字段名定位,选中字段时滚到该字段并聚焦
- 旧 hash(#automation/cache、#api-keys、#library/scrape 等)replace 到新位置
- 下载器分区放下载盘水位与保种缺省值,名字与实例抽屉统一(暂停线 / 恢复线 / 保种天数)
- 新增获取、整理与刮削、系统、缓存分区;API Key 并入账户与安全,快捷键移到界面
- 补上缺的入口:最小视频体积、子会话并发与层数、搜索站点;日志保留天数(已废弃)不再显示
- 缓存分区按区列出条数、占用、最近写入,单区清空二次确认;TMDB 与图片缓存并入
- 保存提示统一为「已保存「节名」」;按入库口径把相关文案改成「整理」
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
---
apps/web/app/components/settings/account.tsx | 19 +-
apps/web/app/components/settings/acquire.tsx | 213 ++++++++++++++
apps/web/app/components/settings/agent.tsx | 51 +++-
apps/web/app/components/settings/api-keys.tsx | 8 +-
.../components/settings/browser-providers.tsx | 2 +-
apps/web/app/components/settings/cache.tsx | 266 +++++++++++++++++
.../app/components/settings/downloaders.tsx | 68 +++++
.../web/app/components/settings/instances.tsx | 26 +-
.../web/app/components/settings/interface.tsx | 18 +-
apps/web/app/components/settings/library.tsx | 105 +------
apps/web/app/components/settings/metadata.tsx | 273 ++----------------
apps/web/app/components/settings/naming.tsx | 6 +-
.../components/settings/organize-sources.tsx | 15 +-
apps/web/app/components/settings/organize.tsx | 120 ++++++++
.../app/components/settings/pi-upstreams.tsx | 2 +-
.../app/components/settings/search-index.ts | 128 ++++++++
apps/web/app/components/settings/sections.tsx | 62 ++--
.../app/components/settings/settings-card.tsx | 2 +-
.../components/settings/settings-page.test.ts | 216 ++++++++++++++
.../components/settings/settings-search.tsx | 116 ++++++++
apps/web/app/components/settings/sites.tsx | 75 +++--
.../settings/{automation.tsx => system.tsx} | 171 ++---------
apps/web/app/routes/console/settings.tsx | 179 ++++++++----
23 files changed, 1499 insertions(+), 642 deletions(-)
create mode 100644 apps/web/app/components/settings/acquire.tsx
create mode 100644 apps/web/app/components/settings/cache.tsx
create mode 100644 apps/web/app/components/settings/downloaders.tsx
create mode 100644 apps/web/app/components/settings/organize.tsx
create mode 100644 apps/web/app/components/settings/search-index.ts
create mode 100644 apps/web/app/components/settings/settings-page.test.ts
create mode 100644 apps/web/app/components/settings/settings-search.tsx
rename apps/web/app/components/settings/{automation.tsx => system.tsx} (50%)
diff --git a/apps/web/app/components/settings/account.tsx b/apps/web/app/components/settings/account.tsx
index 8d7e5ff6..2fbaf3bc 100644
--- a/apps/web/app/components/settings/account.tsx
+++ b/apps/web/app/components/settings/account.tsx
@@ -4,7 +4,6 @@ import { useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import { useChangePassword, useLogout, useSession } from "~/api/auth.ts";
-import { AgentShortcutsHelp } from "~/components/console/agent-launcher.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { SecretInput } from "~/components/console/secret-input.tsx";
import { Button } from "~/components/ui/button.tsx";
@@ -13,9 +12,11 @@ import { clearAuth, errorMessage, getToken, LOGIN_PATH, setToken } from "~/lib/a
import { formatDateTime } from "~/lib/utils.ts";
import { PasskeyBlock } from "./account-passkeys.tsx";
import { TotpBlock } from "./account-totp.tsx";
-import { anchorId, SettingsBlock } from "./settings-block.tsx";
+import { ApiKeysSection } from "./api-keys.tsx";
+import { SettingsBlock } from "./settings-block.tsx";
-// 账户与安全:改密、二次验证(TOTP,account-totp.tsx)、Passkey(account-passkeys.tsx)、当前会话与本机 Token、快捷键
+// 账户与安全:改密、二次验证(TOTP,account-totp.tsx)、Passkey(account-passkeys.tsx)、当前会话与本机 Token、
+// 签发给外部程序的 API Key(api-keys.tsx)。快捷键在「界面」。
const PasswordForm = PasswordChangeRequest.extend({ confirm: z.string() }).refine((v) => v.newPassword === v.confirm, {
message: "两次输入的新密码不一致",
@@ -122,7 +123,12 @@ function SessionBlock() {
};
return (
-
+
- 身份
-
@@ -194,10 +200,7 @@ export function AccountSection() {
- {/* 快捷键说明自带标题,只包卡片与锚点 */}
-
+
);
}
diff --git a/apps/web/app/components/settings/acquire.tsx b/apps/web/app/components/settings/acquire.tsx
new file mode 100644
index 00000000..4359f53c
--- /dev/null
+++ b/apps/web/app/components/settings/acquire.tsx
@@ -0,0 +1,213 @@
+import { ArrowRight } from "lucide-react";
+import { useState } from "react";
+import { Link } from "react-router";
+import { useEnrichStats } from "~/api/identity.ts";
+import { useSettings } from "~/api/settings.ts";
+import { useSites } from "~/api/sites.ts";
+import { DataTable } from "~/components/console/data-table.tsx";
+import { consolePath } from "~/components/console/nav.ts";
+import { ENRICH_SOURCE_KIND } from "~/components/library/labels.ts";
+import { SearchDefaultDialog } from "~/components/sites/search-default-dialog.tsx";
+import { Button } from "~/components/ui/button.tsx";
+import { num } from "./resource-section.tsx";
+import { SettingsBlock } from "./settings-block.tsx";
+import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
+
+// 获取分区:每日下载预算(budget)、搜索站点(search,编辑沿用站点页的对话框)、资源识别(identity)。
+// 订阅、规则、站点本身在各自页面;这里只放它们共用的缺省值。
+
+const GiB = 1024 ** 3;
+
+export const BUDGET_CARD: CardSpec = {
+ anchor: "budget",
+ title: "每日预算",
+ description: "任一项到上限后不再开始新的下载;前两项按自然日计。",
+ fields: [
+ { name: "budget.maxNewTitlesPerDay", label: "每日新作品数", kind: "number" },
+ { name: "budget.maxDownloadGiBPerDay", label: "每日下载量(GiB)", kind: "number" },
+ { name: "budget.maxConcurrentDownloads", label: "最大并发下载", kind: "number", hint: "1–100" },
+ ],
+ fromSettings: (s) => ({
+ "budget.maxNewTitlesPerDay": n(s.budget.maxNewTitlesPerDay),
+ "budget.maxDownloadGiBPerDay": n(Math.round((s.budget.maxDownloadBytesPerDay / GiB) * 100) / 100),
+ "budget.maxConcurrentDownloads": n(s.budget.maxConcurrentDownloads),
+ }),
+ toPatch: (v) => {
+ const gib = num(v["budget.maxDownloadGiBPerDay"]);
+ return {
+ budget: {
+ maxNewTitlesPerDay: num(v["budget.maxNewTitlesPerDay"]),
+ maxDownloadBytesPerDay: gib === undefined ? undefined : Math.round(gib * GiB),
+ maxConcurrentDownloads: num(v["budget.maxConcurrentDownloads"]),
+ },
+ };
+ },
+};
+
+const SEARCH_MODE_LABEL = { default: "默认站点", all: "全部站点", custom: "指定站点" } as const;
+
+/** 搜索站点(settings.search):显示当前范围,修改用站点页同一个对话框 */
+function SearchSitesBlock() {
+ const settings = useSettings();
+ const sites = useSites();
+ const [open, setOpen] = useState(false);
+ const cfg = settings.data?.search;
+ const mode = cfg?.defaultSiteMode ?? "default";
+ return (
+ setOpen(true)}>
+ 修改
+
+ }
+ >
+
+
+ {SEARCH_MODE_LABEL[mode]}
+ {mode === "custom" ? (
+ {cfg?.siteIds.length ?? 0} 个站点
+ ) : null}
+
+
+
+ {sites.data ? : null}
+
+ );
+}
+
+// 身份识别(DEC-1 惰性识别,对照 MP:RSS / 列表资源只在匹配订阅或用户搜索时识别)
+export const IDENTITY_CARD: CardSpec = {
+ anchor: "identity",
+ title: "资源识别",
+ description: "默认只在资源命中订阅、作品别名或新作品规则,或你手动识别和搜索时查 TMDB;其余放进未识别,不进待处理。",
+ fields: [
+ {
+ name: "identity.eagerTmdbLookup",
+ label: "每条新资源都查 TMDB",
+ kind: "switch",
+ hint: "每条新资源都查一次 TMDB。RSS 量大时每天可能上千次请求,站点独有的短剧和综艺多半查不到",
+ wide: true,
+ },
+ {
+ name: "identity.detailIdsPerSiteHour",
+ label: "识别时每站每小时最多抓详情页",
+ kind: "number",
+ hint: "标题认不准时,抓站点详情页取 IMDb、TMDB 或豆瓣 ID 再识别;只在搜索、订阅相关资源、补全和手动重新识别时抓,RSS 收进来的资源不抓。0 = 不抓",
+ },
+ {
+ name: "identity.autoEnrich",
+ label: "TMDB 查不到时自动补全元数据",
+ kind: "switch",
+ hint: "资源命中订阅或新作品规则、TMDB 却查不到时,搜索网页出一份补全建议(每条资源一次,不建作品)。每次最多读 3 个网页,调用一次模型服务",
+ wide: true,
+ },
+ {
+ name: "identity.enrichDailyBudget",
+ label: "元数据补全每日上限",
+ kind: "number",
+ hint: "手动和自动合计;7 天内同一标题直接用上次结果,不计次。0 = 停用",
+ },
+ {
+ name: "identity.enrichDomains",
+ label: "补全可读取的网站",
+ kind: "textarea",
+ hint: "每行一个域名,含子域名;只放平台和百科类网站。豆瓣只用搜索摘要,不读页面;robots.txt 不允许的页面不读。已登记站点的域名一律不读",
+ wide: true,
+ },
+ {
+ name: "identity.enrichSnippetAllSites",
+ label: "也用其它网站的搜索摘要",
+ kind: "switch",
+ hint: "短剧多半只有聚合站收录。打开后,白名单以外网站的搜索摘要也用来抽年份、集数、主演、简介(只看搜索引擎给的摘要,不打开这些网页)",
+ wide: true,
+ },
+ ],
+ fromSettings: (s) => ({
+ "identity.eagerTmdbLookup": s.identity.eagerTmdbLookup,
+ "identity.autoEnrich": s.identity.autoEnrich,
+ "identity.enrichDailyBudget": n(s.identity.enrichDailyBudget),
+ "identity.enrichDomains": s.identity.enrichDomains.join("\n"),
+ "identity.enrichSnippetAllSites": s.identity.enrichSnippetAllSites ?? true,
+ "identity.detailIdsPerSiteHour": n(s.identity.detailIdsPerSiteHour ?? 10),
+ }),
+ toPatch: (v) => ({
+ identity: {
+ eagerTmdbLookup: v["identity.eagerTmdbLookup"] === true,
+ autoEnrich: v["identity.autoEnrich"] === true,
+ enrichDailyBudget: Number(v["identity.enrichDailyBudget"]),
+ enrichDomains: String(v["identity.enrichDomains"] ?? "")
+ .split(/[\s,,]+/)
+ .map((d) => d.trim().toLowerCase())
+ .filter(Boolean),
+ enrichSnippetAllSites: v["identity.enrichSnippetAllSites"] === true,
+ detailIdsPerSiteHour: Number(v["identity.detailIdsPerSiteHour"]),
+ },
+ }),
+ extra: () => ,
+};
+
+type StatRow = { kind: string; withResults: number; hits: number; rate: number | null };
+
+/** 最近 30 天补全命中率(按来源类别,META-2) */
+function EnrichStatsTable() {
+ const q = useEnrichStats(30);
+ const d = q.data;
+ return (
+
+
+ {d
+ ? `最近 ${d.days} 天元数据补全 ${d.total} 次,出了建议 ${d.suggested} 次${d.tracked < d.total ? `;有来源明细的 ${d.tracked} 次` : ""}`
+ : "读取补全命中率…"}
+
+ {d && d.tracked > 0 ? (
+
+ rows={d.sources}
+ rowKey={(r) => r.kind}
+ empty={{ title: "还没有补全记录" }}
+ columns={[
+ { key: "kind", header: "来源", cell: (r) => ENRICH_SOURCE_KIND[r.kind] ?? r.kind },
+ {
+ key: "withResults",
+ header: "有结果",
+ align: "right",
+ cell: (r) => {r.withResults},
+ },
+ {
+ key: "hits",
+ header: "命中",
+ align: "right",
+ cell: (r) => {r.hits},
+ },
+ {
+ key: "rate",
+ header: "命中率",
+ align: "right",
+ cell: (r) => (
+ {r.rate === null ? "—" : `${Math.round(r.rate * 100)}%`}
+ ),
+ },
+ ]}
+ />
+ ) : null}
+
+ );
+}
+
+export function AcquireSection() {
+ return (
+
+ {(s) => }
+
+ {(s) => }
+
+ );
+}
diff --git a/apps/web/app/components/settings/agent.tsx b/apps/web/app/components/settings/agent.tsx
index 3d732b16..fbae8051 100644
--- a/apps/web/app/components/settings/agent.tsx
+++ b/apps/web/app/components/settings/agent.tsx
@@ -7,9 +7,10 @@ import type { FieldSpec } from "./resource-section.tsx";
import { num, str } from "./resource-section.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
-// Agent 分区:上游(pi_upstream)+ 上下文与子代理(pi)+ 用量与限制(单次防跑飞上限 + 花费统计)+ 自动调查 + 网页搜索 + 高级
+// 智能体分区:模型服务(pi_upstream)→ 用量与限制(不设预算,只有单次防跑飞上限 + 花费统计)→ 自动处理(识别失败自动派智能体、
+// 自动调查)→ 上下文与子会话 → 网页搜索 → MCP 服务端与能力清单(agent-tools.tsx)→ 高级
-const RUNTIME_CARD: CardSpec = {
+export const RUNTIME_CARD: CardSpec = {
anchor: "runtime",
title: "上下文与子会话",
fields: [
@@ -20,7 +21,26 @@ const RUNTIME_CARD: CardSpec = {
tokens: { min: 4000, max: 2_000_000, required: true },
hint: "4K–2000K,超过模型窗口时按窗口算",
},
- { name: "pi.subagentsEnabled", label: "允许子会话", kind: "switch" },
+ {
+ name: "pi.subagentsEnabled",
+ label: "允许子会话",
+ kind: "switch",
+ hint: "智能体可以把独立的小目标交给子会话并行做",
+ },
+ {
+ name: "pi.subagents.maxConcurrent",
+ label: "同时运行的子会话",
+ kind: "number",
+ hint: "一个会话最多同时跑几个子会话;1–8",
+ visible: (v) => v["pi.subagentsEnabled"] === true,
+ },
+ {
+ name: "pi.subagents.maxDepth",
+ label: "派生层数",
+ kind: "number",
+ hint: "1 表示子会话不能再派生;1–3",
+ visible: (v) => v["pi.subagentsEnabled"] === true,
+ },
{ name: "pi.compaction.enabled", label: "上下文压缩", kind: "switch" },
{
name: "pi.compaction.thresholdRatio",
@@ -32,7 +52,9 @@ const RUNTIME_CARD: CardSpec = {
],
fromSettings: (s) => ({
"pi.contextBudget": tokensToK(s.pi.contextBudget),
- "pi.subagentsEnabled": s.pi.subagentsEnabled,
+ "pi.subagentsEnabled": s.pi.subagents?.enabled ?? s.pi.subagentsEnabled,
+ "pi.subagents.maxConcurrent": n(s.pi.subagents?.maxConcurrent),
+ "pi.subagents.maxDepth": n(s.pi.subagents?.maxDepth),
"pi.compaction.enabled": s.pi.compaction.enabled,
"pi.compaction.thresholdRatio": n(s.pi.compaction.thresholdRatio),
}),
@@ -40,6 +62,11 @@ const RUNTIME_CARD: CardSpec = {
pi: {
contextBudget: kToTokens(str(v["pi.contextBudget"])),
subagentsEnabled: v["pi.subagentsEnabled"] === true,
+ subagents: {
+ enabled: v["pi.subagentsEnabled"] === true,
+ maxConcurrent: num(v["pi.subagents.maxConcurrent"]),
+ maxDepth: num(v["pi.subagents.maxDepth"]),
+ },
compaction: {
enabled: v["pi.compaction.enabled"] === true,
thresholdRatio: num(v["pi.compaction.thresholdRatio"]),
@@ -50,7 +77,7 @@ const RUNTIME_CARD: CardSpec = {
// 2026-10-06:不设每日次数 / 每日花费 / 单次花费 / 单次 token 上限(旧值兼容读取、不生效),
// 只保留单次工具调用次数与时长两个防跑飞限制;花费在这里与运行状态里可见
-const BUDGET_CARD: CardSpec = {
+export const BUDGET_CARD: CardSpec = {
anchor: "budget",
title: "用量与限制",
description: "不限次数和花费。单次运行只限制工具调用次数和时长,防止跑飞。",
@@ -75,10 +102,10 @@ const BUDGET_CARD: CardSpec = {
),
};
-const INVESTIGATE_CARD: CardSpec = {
+export const INVESTIGATE_CARD: CardSpec = {
anchor: "investigate",
- title: "自动调查",
- description: "下载完识别不到作品时叫智能体识别;下载失败、集数对不上等问题出现时,自动发起一次有界调查。",
+ title: "自动处理",
+ description: "整理时识别不到作品就叫智能体识别;下载失败、集数对不上等问题出现时,自动发起一次有界调查。",
fields: [
{
name: "agent.autoIdentifyOrganize",
@@ -144,13 +171,13 @@ const INVESTIGATE_CARD: CardSpec = {
const DEV_ONLY_FIELDS = new Set(["agent.sessionDir", "agent.fakeScript"]);
-const ADVANCED_CARD: CardSpec = {
+export const ADVANCED_CARD: CardSpec = {
anchor: "advanced",
title: "高级",
// 会话目录与 fake 脚本是开发调试项,生产构建不显示(DEAI-2)
fields: (
[
- { name: "agent.goalPollSec", label: "目标轮询间隔(秒)", kind: "number", hint: "等下载、入库结束时的检查间隔" },
+ { name: "agent.goalPollSec", label: "目标轮询间隔(秒)", kind: "number", hint: "等下载、整理结束时的检查间隔" },
{
name: "agent.goalWaitHours",
label: "目标最长等待(小时)",
@@ -223,15 +250,15 @@ export function AgentSection() {
{(s) => (
<>
-
+
-
>
)}
+ {(s) => }
);
}
diff --git a/apps/web/app/components/settings/api-keys.tsx b/apps/web/app/components/settings/api-keys.tsx
index c39e9d9c..29a800be 100644
--- a/apps/web/app/components/settings/api-keys.tsx
+++ b/apps/web/app/components/settings/api-keys.tsx
@@ -102,10 +102,10 @@ export function ApiKeysSection() {
return (
setOpen(true)}>
diff --git a/apps/web/app/components/settings/browser-providers.tsx b/apps/web/app/components/settings/browser-providers.tsx
index 417b0f20..a906222b 100644
--- a/apps/web/app/components/settings/browser-providers.tsx
+++ b/apps/web/app/components/settings/browser-providers.tsx
@@ -174,7 +174,7 @@ export function BrowserProvidersBlock() {
},
{
name: "accessKey",
- label: "访问凭据",
+ label: "访问凭证",
kind: "secret",
secret: { set: (v) => v["accessKey:set"] === true, clearable: true },
hint: "camofox 的 CAMOFOX_ACCESS_KEY;没设就留空",
diff --git a/apps/web/app/components/settings/cache.tsx b/apps/web/app/components/settings/cache.tsx
new file mode 100644
index 00000000..a6c5bf27
--- /dev/null
+++ b/apps/web/app/components/settings/cache.tsx
@@ -0,0 +1,266 @@
+import { cacheRegionLabel } from "@umt/contracts";
+import { useState } from "react";
+import { toast } from "sonner";
+import {
+ useCacheRegions,
+ useClearCacheRegion,
+ useClearImageCache,
+ useClearTmdbCache,
+ useImageCache,
+ useTmdbCache,
+} from "~/api/cache.ts";
+import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
+import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
+import { Button } from "~/components/ui/button.tsx";
+import { errorMessage } from "~/lib/api.ts";
+import { formatRelative } from "~/lib/labels.ts";
+import { formatBytes } from "~/lib/utils.ts";
+import { num } from "./resource-section.tsx";
+import { SettingsBlock } from "./settings-block.tsx";
+import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
+
+// 缓存分区:通用缓存(kv_cache)按区统计与清空 + 图片缓存(磁盘)同表展示;TMDB 缓存条数上限、图片缓存开关、各类有效期。
+// 清空只删缓存,下次用到时重新请求;不会删作品、资源或文件。
+
+/** 图片缓存在磁盘上,不在 kv_cache 里;表里用这个保留名占一行 */
+const IMAGES_ROW = "images";
+
+export type CacheRow = {
+ region: string;
+ label: string;
+ entries: number;
+ expired: number;
+ bytes: number;
+ lastWriteAt: string | null;
+};
+
+/** 区名 → 行:有中文名的用中文名,没有的原样显示 region */
+export function cacheRow(r: Omit): CacheRow {
+ return { ...r, label: cacheRegionLabel(r.region) ?? r.region };
+}
+
+function CacheRegionsBlock() {
+ const regions = useCacheRegions();
+ const images = useImageCache();
+ const clearRegion = useClearCacheRegion();
+ const clearImages = useClearImageCache();
+ const [target, setTarget] = useState(null);
+
+ const rows: CacheRow[] | undefined = regions.data
+ ? [
+ ...regions.data.items.map(cacheRow),
+ ...(images.data
+ ? [
+ cacheRow({
+ region: IMAGES_ROW,
+ entries: images.data.files,
+ expired: 0,
+ bytes: images.data.usedBytes,
+ lastWriteAt: null,
+ }),
+ ]
+ : []),
+ ]
+ : undefined;
+
+ const columns: DataColumn[] = [
+ {
+ key: "label",
+ header: "缓存",
+ cell: (r) => (
+
+
{r.label}
+ {r.label !== r.region ?
{r.region}
: null}
+
+ ),
+ },
+ {
+ key: "entries",
+ header: "条数",
+ align: "right",
+ cell: (r) => (
+
+ {r.entries}
+ {r.expired > 0 ?
另有 {r.expired} 条已过期
: null}
+
+ ),
+ },
+ {
+ key: "bytes",
+ header: "占用",
+ align: "right",
+ cell: (r) => {formatBytes(r.bytes, 1)},
+ },
+ {
+ key: "last",
+ header: "最近写入",
+ hideBelow: "md",
+ cell: (r) => {formatRelative(r.lastWriteAt)},
+ },
+ {
+ key: "actions",
+ header: 操作,
+ align: "right",
+ cell: (r) => (
+
+ ),
+ },
+ ];
+
+ return (
+
+
+ rows={rows}
+ rowKey={(r) => r.region}
+ loading={regions.isPending}
+ error={regions.error}
+ onRetry={() => regions.refetch()}
+ empty={{ title: "还没有缓存", description: "用到 TMDB、豆瓣、站点详情等数据后会出现在这里。" }}
+ columns={columns}
+ />
+ !o && setTarget(null)}
+ title={`清空「${target?.label ?? ""}」?`}
+ description={`删除 ${target ? target.entries + target.expired : 0} 条缓存,之后用到时重新请求。`}
+ confirmLabel="清空"
+ destructive
+ onConfirm={async () => {
+ if (!target) return;
+ try {
+ const r =
+ target.region === IMAGES_ROW
+ ? await clearImages.mutateAsync()
+ : await clearRegion.mutateAsync(target.region);
+ toast.success(`已清空「${target.label}」,删除 ${r.deleted} 条`);
+ setTarget(null);
+ } catch (err) {
+ toast.error(errorMessage(err, "清空失败"));
+ throw err;
+ }
+ }}
+ />
+
+ );
+}
+
+// TMDB 缓存(对照 MP:CONF.tmdb 条数;有效期在下面「缓存有效期」)
+export const TMDB_CACHE_CARD: CardSpec = {
+ anchor: "tmdb-cache",
+ title: "TMDB 缓存",
+ description: "请求响应与识别结果存在数据库里,三个进程共用。超过条数上限时删最久未用的条目。",
+ fields: [{ name: "cache.tmdbMaxSize", label: "条数上限", kind: "number", hint: "请求缓存和识别缓存各自计数" }],
+ fromSettings: (s) => ({ "cache.tmdbMaxSize": n(s.cache.tmdbMaxSize) }),
+ toPatch: (v) => ({ cache: { tmdbMaxSize: num(v["cache.tmdbMaxSize"]) } }),
+ extra: () => ,
+};
+
+function TmdbCacheStatus() {
+ const q = useTmdbCache();
+ const clear = useClearTmdbCache();
+ const c = q.data?.counts;
+ return (
+
+
+ {c ? `识别 ${c.recognize} 条 · 请求 ${c.request} 条 · 上限各 ${q.data?.maxSize} 条` : "读取中…"}
+
+
+ 清空
+
+ }
+ title="清空 TMDB 缓存?"
+ description="请求响应和识别结果一起删除,之后用到时重新请求 TMDB。"
+ confirmLabel="清空"
+ destructive
+ onConfirm={async () => {
+ try {
+ const r = await clear.mutateAsync();
+ toast.success(`已清空 ${r.deleted} 条 TMDB 缓存`);
+ } catch (err) {
+ toast.error(errorMessage(err, "清空失败"));
+ throw err;
+ }
+ }}
+ />
+
+ );
+}
+
+// 图片代理与缓存(对照 MP GLOBAL_IMAGE_CACHE / GLOBAL_IMAGE_CACHE_DAYS);用量与清空在上面的缓存区表里
+export const IMAGES_CARD: CardSpec = {
+ anchor: "images",
+ title: "图片缓存",
+ description: "海报经服务端从 TMDB 取回。开启缓存后保存到数据目录,下次直接读取;容量上限在「系统 → 存储预算」。",
+ fields: [
+ { name: "cache.globalImageCache", label: "缓存图片", kind: "switch" },
+ { name: "cache.globalImageCacheDays", label: "保留天数", kind: "number", hint: "超过天数的图片每天清理一次" },
+ ],
+ fromSettings: (s) => ({
+ "cache.globalImageCache": s.cache.globalImageCache,
+ "cache.globalImageCacheDays": n(s.cache.globalImageCacheDays),
+ }),
+ toPatch: (v) => ({
+ cache: {
+ globalImageCache: v["cache.globalImageCache"] === true,
+ globalImageCacheDays: num(v["cache.globalImageCacheDays"]),
+ },
+ }),
+};
+
+const TTL_KEYS = ["tmdbSearchDays", "tmdbDetailDays", "tmdbMissDays", "siteDetailDays", "webSearchDays"] as const;
+const ttlField = (k: string, label: string, hint?: string) => ({
+ name: `storage.cacheTtl.${k}`,
+ label,
+ kind: "number" as const,
+ hint,
+});
+export const TTL_CARD: CardSpec = {
+ anchor: "ttl",
+ title: "缓存有效期",
+ description: "单位天。追踪中的作品命中 TMDB 详情缓存时自动续期。",
+ fields: [
+ ttlField("tmdbSearchDays", "TMDB 搜索与识别结果"),
+ ttlField("tmdbDetailDays", "TMDB 详情"),
+ ttlField("tmdbMissDays", "TMDB 空结果", "搜索无结果、未识别时的负缓存"),
+ ttlField("siteDetailDays", "站点详情页内容"),
+ ttlField("webSearchDays", "网页搜索结果"),
+ ],
+ fromSettings: (s) => Object.fromEntries(TTL_KEYS.map((k) => [`storage.cacheTtl.${k}`, n(s.storage.cacheTtl[k])])),
+ toPatch: (v) => ({
+ storage: { cacheTtl: Object.fromEntries(TTL_KEYS.map((k) => [k, num(v[`storage.cacheTtl.${k}`])])) },
+ }),
+};
+
+export function CacheSection() {
+ return (
+
+
+
+ {(s) => (
+ <>
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/web/app/components/settings/downloaders.tsx b/apps/web/app/components/settings/downloaders.tsx
new file mode 100644
index 00000000..8a7eb492
--- /dev/null
+++ b/apps/web/app/components/settings/downloaders.tsx
@@ -0,0 +1,68 @@
+import { DownloadersTable } from "./instances.tsx";
+import { num } from "./resource-section.tsx";
+import { SettingsBlock } from "./settings-block.tsx";
+import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
+
+// 下载器分区:qB 实例(抽屉里有路径映射和本实例的水位 / 保种 / H&R)+ 下载盘水位与保种天数的缺省值。
+// 实例上留空的项用这里的缺省值,所以两处用同一组名字:暂停线、恢复线、清理目标、保种天数。
+
+export const CACHE_CARD: CardSpec = {
+ anchor: "cache",
+ title: "下载盘",
+ description:
+ "下载盘用量到暂停线时不再新增下载,并暂停本工具的下载任务;降到恢复线以下只恢复因此暂停的任务。实例里可以单独设置。",
+ fields: [
+ { name: "cache.hardThresholdPercent", label: "暂停线(%)", kind: "number" },
+ { name: "cache.resumePercent", label: "恢复线(%)", kind: "number", hint: "低于暂停线" },
+ { name: "cache.cleanupTargetPercent", label: "清理目标(%)", kind: "number", hint: "不高于恢复线" },
+ { name: "cache.checkIntervalSec", label: "检查间隔(秒)", kind: "number", hint: "10–3600" },
+ ],
+ fromSettings: (s) => ({
+ "cache.hardThresholdPercent": n(s.cache.hardThresholdPercent),
+ "cache.resumePercent": n(s.cache.resumePercent),
+ "cache.cleanupTargetPercent": n(s.cache.cleanupTargetPercent),
+ "cache.checkIntervalSec": n(s.cache.checkIntervalSec),
+ }),
+ toPatch: (v) => ({
+ cache: {
+ hardThresholdPercent: num(v["cache.hardThresholdPercent"]),
+ resumePercent: num(v["cache.resumePercent"]),
+ cleanupTargetPercent: num(v["cache.cleanupTargetPercent"]),
+ checkIntervalSec: num(v["cache.checkIntervalSec"]),
+ },
+ }),
+};
+
+export const SEEDING_CARD: CardSpec = {
+ anchor: "seeding",
+ title: "保种",
+ description: "复制整理后,下载源留在下载盘继续做种。站点的 H&R 要求另外遵守;实例里可以单独设置。",
+ fields: [
+ {
+ name: "retention.seedRetentionDays",
+ label: "保种天数",
+ kind: "number",
+ hint: "到期后才允许清理;0–365",
+ },
+ ],
+ fromSettings: (s) => ({ "retention.seedRetentionDays": n(s.retention.seedRetentionDays) }),
+ toPatch: (v) => ({ retention: { seedRetentionDays: num(v["retention.seedRetentionDays"]) } }),
+};
+
+export function DownloadersSection() {
+ return (
+
+
+
+
+
+ {(s) => (
+ <>
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/web/app/components/settings/instances.tsx b/apps/web/app/components/settings/instances.tsx
index e9f6b474..28d9ca89 100644
--- a/apps/web/app/components/settings/instances.tsx
+++ b/apps/web/app/components/settings/instances.tsx
@@ -82,15 +82,7 @@ function authKindOf(r: DownloaderInstance): string {
return r.username ? "password" : r.secretRef ? "api_key" : "none";
}
-export function DownloadersSection() {
- return (
-
-
-
- );
-}
-
-/** 下载器自己的水位 / 保种(settings.policy):表单里是字符串,空 = 用全局 */
+/** 下载器自己的水位 / 保种(settings.policy):表单里是字符串,空 = 用「下载器」分区的缺省值 */
const POLICY_FIELDS = [
["hardPct", "hardThresholdPercent"],
["resumePct", "resumePercent"],
@@ -114,7 +106,7 @@ function policyBody(v: FormValues): Record {
return out;
}
-function DownloadersTable() {
+export function DownloadersTable() {
const list = downloaders.useList();
const create = downloaders.useCreate();
const update = downloaders.useUpdate();
@@ -192,19 +184,19 @@ function DownloadersTable() {
{ name: "cacheVolumeKey", label: "缓存卷标识", kind: "text", placeholder: "cache-1" },
{
name: "hardPct",
- label: "硬阈值(%)",
+ label: "暂停线(%)",
kind: "number",
- placeholder: "留空用全局",
- hint: "这个下载器的缓存盘用到多少就停止新增下载;多个下载器共用一块盘时取最严格的",
+ placeholder: "留空用缺省值",
+ hint: "这个下载器的下载盘用到多少就停止新增下载;多个下载器共用一块盘时取最严格的",
},
- { name: "resumePct", label: "恢复水位(%)", kind: "number", placeholder: "留空用全局" },
- { name: "targetPct", label: "清理目标(%)", kind: "number", placeholder: "留空用全局" },
+ { name: "resumePct", label: "恢复线(%)", kind: "number", placeholder: "留空用缺省值" },
+ { name: "targetPct", label: "清理目标(%)", kind: "number", placeholder: "留空用缺省值" },
{
name: "seedDays",
label: "保种天数",
kind: "number",
- placeholder: "留空用全局",
- hint: "入库后至少保留几天再允许清理;公网 BT 可填 0(站点 H&R 要求仍然遵守)",
+ placeholder: "留空用缺省值",
+ hint: "整理后至少保留几天再允许清理;公网 BT 可填 0(站点 H&R 要求仍然遵守)",
},
{
name: "hnrUnknownCleanable",
diff --git a/apps/web/app/components/settings/interface.tsx b/apps/web/app/components/settings/interface.tsx
index d78cdb61..c07cf245 100644
--- a/apps/web/app/components/settings/interface.tsx
+++ b/apps/web/app/components/settings/interface.tsx
@@ -1,10 +1,23 @@
+import { AgentShortcutsHelp } from "~/components/console/agent-launcher.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { setUiPref, useUiPrefs } from "~/lib/ui-prefs.ts";
-import { SettingsBlock } from "./settings-block.tsx";
+import { anchorId, SettingsBlock } from "./settings-block.tsx";
-// 界面:本机显示偏好(存浏览器,不进 settings 表)
+// 界面:本机显示偏好(存浏览器,不进 settings 表)与快捷键说明
export function InterfaceSection() {
+ return (
+
+
+ {/* 快捷键说明自带标题,只包卡片与锚点 */}
+
+
+ );
+}
+
+function DisplayBlock() {
const prefs = useUiPrefs();
return (
@@ -18,6 +31,7 @@ export function InterfaceSection() {
setUiPref("techInfoExpanded", v)}
diff --git a/apps/web/app/components/settings/library.tsx b/apps/web/app/components/settings/library.tsx
index dfdf130d..fd9ac570 100644
--- a/apps/web/app/components/settings/library.tsx
+++ b/apps/web/app/components/settings/library.tsx
@@ -1,110 +1,25 @@
import { LibraryRootsTable } from "./instances.tsx";
import { NamingSection } from "./naming.tsx";
import { ClassificationSection, OrganizeSourcesSection } from "./organize-sources.tsx";
-import { str } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
-import { type CardSpec, SettingsCard, SettingsGate } from "./settings-card.tsx";
-// 媒体库与目录:库根(含哨兵状态)+ 默认整理方式 + 刮削 + 命名模板
-
-const ORGANIZE_CARD: CardSpec = {
- anchor: "organize",
- title: "整理方式",
- description: "复制入库时源文件留在缓存继续做种,保留天数在自动化分区设置。",
- fields: [
- {
- name: "organize.defaultMode",
- label: "默认整理方式",
- kind: "select",
- options: [
- { value: "copy", label: "复制(源文件继续做种)" },
- { value: "move", label: "移动(移走源文件)" },
- ],
- },
- {
- name: "organize.allowCrossVolumeMove",
- label: "允许跨卷移动",
- kind: "switch",
- hint: "跨卷时按复制 → 核验 → 删源执行",
- },
- ],
- fromSettings: (s) => ({
- "organize.defaultMode": s.organize.defaultMode,
- "organize.allowCrossVolumeMove": s.organize.allowCrossVolumeMove,
- }),
- toPatch: (v) => ({
- organize: {
- defaultMode: str(v["organize.defaultMode"]) as "copy" | "move",
- allowCrossVolumeMove: v["organize.allowCrossVolumeMove"] === true,
- },
- }),
-};
-
-// SCR-1 刮削:入库后写 NFO 与图片到媒体库目录(Emby / Jellyfin / Kodi 读取)
-const SCRAPE_CARD: CardSpec = {
- anchor: "scrape",
- title: "刮削",
- description: "把作品信息写成 NFO,连同海报、背景图放进媒体库目录,Emby、Jellyfin、Kodi 直接读取。",
- fields: [
- {
- name: "organize.scrapeAfterImport",
- label: "入库后自动刮削",
- kind: "switch",
- hint: "关闭时可在作品详情或文件管理里手动刮削",
- },
- {
- name: "organize.scrapeMode",
- label: "已有文件",
- kind: "select",
- options: [
- { value: "update", label: "更新" },
- { value: "missing", label: "只补缺失" },
- { value: "overwrite", label: "全部覆盖" },
- ],
- hint: "更新只替换本工具写过、之后没被改过的文件;全部覆盖会替换目录里已有的 NFO 和图片,包括其他工具写的",
- },
- {
- name: "organize.scrapeImages",
- label: "下载图片",
- kind: "switch",
- hint: "海报、背景图、季海报",
- },
- {
- name: "organize.scrapeEpisodeThumbs",
- label: "下载剧照",
- kind: "switch",
- hint: "每集一张,存为「视频名-thumb.jpg」;关闭下载图片时不生效",
- },
- ],
- fromSettings: (s) => ({
- "organize.scrapeAfterImport": s.organize.scrapeAfterImport,
- "organize.scrapeMode": s.organize.scrapeMode,
- "organize.scrapeImages": s.organize.scrapeImages,
- "organize.scrapeEpisodeThumbs": s.organize.scrapeEpisodeThumbs,
- }),
- toPatch: (v) => ({
- organize: {
- scrapeAfterImport: v["organize.scrapeAfterImport"] === true,
- scrapeMode: scrapeModeOf(str(v["organize.scrapeMode"])),
- scrapeImages: v["organize.scrapeImages"] === true,
- scrapeEpisodeThumbs: v["organize.scrapeEpisodeThumbs"] === true,
- },
- }),
-};
-
-const scrapeModeOf = (v: string | null | undefined): "missing" | "overwrite" | "update" =>
- v === "overwrite" || v === "missing" ? v : "update";
+// 媒体库与目录:媒体库目录(含标记文件检查)、整理来源、分类规则、命名模板(全局 + 目录覆盖)。
+// 整理方式与刮削在「整理与刮削」。
export function LibrarySection() {
return (
-
+
- {(s) => }
- {(s) => }
-
+
);
diff --git a/apps/web/app/components/settings/metadata.tsx b/apps/web/app/components/settings/metadata.tsx
index 42562ce7..a4ffe8eb 100644
--- a/apps/web/app/components/settings/metadata.tsx
+++ b/apps/web/app/components/settings/metadata.tsx
@@ -1,16 +1,9 @@
import { DEFAULT_SETTINGS } from "@umt/contracts";
-import { toast } from "sonner";
-import { useClearImageCache, useClearTmdbCache, useImageCache, useTmdbCache } from "~/api/cache.ts";
-import { useEnrichStats } from "~/api/identity.ts";
-import { DataTable } from "~/components/console/data-table.tsx";
-import { ENRICH_SOURCE_KIND } from "~/components/library/labels.ts";
-import { Button } from "~/components/ui/button.tsx";
-import { errorMessage } from "~/lib/api.ts";
import { secretPatch } from "~/lib/secret.ts";
import { str } from "./resource-section.tsx";
-import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
+import { type CardSpec, SettingsCard, SettingsGate } from "./settings-card.tsx";
-// 元数据:TMDB API Key(只写,存 secret)、Base URL、语言回退链
+// 元数据:TMDB 接入(API Key 只写,存 secret;Base URL)与语言回退、特别篇。缓存在「缓存」分区,资源识别在「获取」分区。
const DEFAULT_LANGUAGES = DEFAULT_SETTINGS.metadata.languages;
@@ -28,7 +21,7 @@ function parseLanguages(v: string): string[] {
return out.length ? [...new Set(out)].slice(0, 8) : [...DEFAULT_LANGUAGES];
}
-const TMDB_CARD: CardSpec = {
+export const TMDB_CARD: CardSpec = {
anchor: "tmdb",
title: "TMDB",
description: "留空时用环境变量 TMDB_API_KEY / TMDB_ACCESS_TOKEN / TMDB_BASE_URL。",
@@ -49,6 +42,28 @@ const TMDB_CARD: CardSpec = {
hint: "自建反代时填写;留空用官方地址",
wide: true,
},
+ ],
+ fromSettings: (s) => ({
+ "metadata.tmdbApiKey": "",
+ "metadata.tmdbApiKey:set": !!s.metadata.tmdbApiKeySecretRef,
+ "metadata.tmdbBaseUrl": s.metadata.tmdbBaseUrl ?? "",
+ }),
+ toPatch: (v) => {
+ const key = secretPatch(v["metadata.tmdbApiKey"]);
+ return {
+ metadata: {
+ tmdbBaseUrl: str(v["metadata.tmdbBaseUrl"]) || null,
+ ...(key !== undefined ? { tmdbApiKey: key } : {}),
+ },
+ };
+ },
+};
+
+export const LANGUAGE_CARD: CardSpec = {
+ anchor: "language",
+ title: "语言与季集",
+ description: "作品、季、集的标题和简介按语言回退链取值;特别篇决定是否拉取季 0。",
+ fields: [
{
name: "metadata.languages",
label: "语言回退链",
@@ -73,254 +88,26 @@ const TMDB_CARD: CardSpec = {
},
],
fromSettings: (s) => ({
- "metadata.tmdbApiKey": "",
- "metadata.tmdbApiKey:set": !!s.metadata.tmdbApiKeySecretRef,
- "metadata.tmdbBaseUrl": s.metadata.tmdbBaseUrl ?? "",
"metadata.languages": (s.metadata.languages ?? DEFAULT_LANGUAGES).join(", "),
"metadata.includeSpecials": s.metadata.includeSpecials !== false,
"metadata.overviewNonChineseFallback": s.metadata.overviewNonChineseFallback === true,
}),
- toPatch: (v) => {
- const key = secretPatch(v["metadata.tmdbApiKey"]);
- return {
- metadata: {
- tmdbBaseUrl: str(v["metadata.tmdbBaseUrl"]) || null,
- languages: parseLanguages(str(v["metadata.languages"])),
- includeSpecials: v["metadata.includeSpecials"] === true,
- overviewNonChineseFallback: v["metadata.overviewNonChineseFallback"] === true,
- ...(key !== undefined ? { tmdbApiKey: key } : {}),
- },
- };
- },
-};
-
-// TMDB 缓存(对照 MP:META_CACHE_EXPIRE / CONF.tmdb / EMPTY_RESULT_CACHE_TTL)
-const TMDB_CACHE_CARD: CardSpec = {
- anchor: "tmdb-cache",
- title: "TMDB 缓存",
- description:
- "请求响应与识别结果存在数据库里,三个进程共用。超过条数上限时删最久未用的条目;有效期在「自动化 → 存储与保留」里设。",
- fields: [{ name: "cache.tmdbMaxSize", label: "条数上限", kind: "number", hint: "请求缓存和识别缓存各自计数" }],
- fromSettings: (s) => ({
- "cache.tmdbMaxSize": n(s.cache.tmdbMaxSize),
- }),
toPatch: (v) => ({
- cache: {
- tmdbMaxSize: Number(v["cache.tmdbMaxSize"]),
+ metadata: {
+ languages: parseLanguages(str(v["metadata.languages"])),
+ includeSpecials: v["metadata.includeSpecials"] === true,
+ overviewNonChineseFallback: v["metadata.overviewNonChineseFallback"] === true,
},
}),
- extra: () => ,
};
-function TmdbCacheStatus() {
- const q = useTmdbCache();
- const clear = useClearTmdbCache();
- const c = q.data?.counts;
- return (
-
-
- {c ? `识别 ${c.recognize} 条 · 请求 ${c.request} 条 · 上限各 ${q.data?.maxSize} 条` : "读取中…"}
-
-
-
- );
-}
-
-// 图片代理与缓存(对照 MP GLOBAL_IMAGE_CACHE / GLOBAL_IMAGE_CACHE_DAYS)
-const IMAGES_CARD: CardSpec = {
- anchor: "images",
- title: "图片代理与缓存",
- description: "海报经服务端从 TMDB 取回。开启缓存后保存到数据目录,下次直接读取。",
- fields: [
- { name: "cache.globalImageCache", label: "缓存图片", kind: "switch" },
- { name: "cache.globalImageCacheDays", label: "保留天数", kind: "number", hint: "超过天数的图片每天清理一次" },
- ],
- fromSettings: (s) => ({
- "cache.globalImageCache": s.cache.globalImageCache,
- "cache.globalImageCacheDays": n(s.cache.globalImageCacheDays),
- }),
- toPatch: (v) => ({
- cache: {
- globalImageCache: v["cache.globalImageCache"] === true,
- globalImageCacheDays: Number(v["cache.globalImageCacheDays"]),
- },
- }),
- extra: () => ,
-};
-
-function ImageCacheStatus() {
- const q = useImageCache();
- const clear = useClearImageCache();
- const d = q.data;
- return (
-
-
- {d ? `${d.files} 张 · ${(d.usedBytes / 1024 / 1024).toFixed(1)} MiB` : "读取中…"}
-
-
-
- );
-}
-
-// 身份识别(DEC-1 惰性识别,对照 MP:RSS / 列表资源只在匹配订阅或用户搜索时识别)
-const IDENTITY_CARD: CardSpec = {
- anchor: "identity",
- title: "资源识别",
- description: "默认只在资源命中订阅、作品别名或新作品规则,或你手动识别和搜索时查 TMDB;其余放进未识别,不进待处理。",
- fields: [
- {
- name: "identity.eagerTmdbLookup",
- label: "每条新资源都查 TMDB",
- kind: "switch",
- hint: "每条新资源都查一次 TMDB。RSS 量大时每天可能上千次请求,站点独有的短剧和综艺多半查不到",
- wide: true,
- },
- {
- name: "identity.detailIdsPerSiteHour",
- label: "识别时每站每小时最多抓详情页",
- kind: "number",
- hint: "标题认不准时,抓站点详情页取 IMDb、TMDB 或豆瓣 ID 再识别;只在搜索、订阅相关资源、补全和手动重新识别时抓,RSS 入库不抓。0 = 不抓",
- },
- {
- name: "identity.autoEnrich",
- label: "TMDB 查不到时自动补全元数据",
- kind: "switch",
- hint: "资源命中订阅或新作品规则、TMDB 却查不到时,搜索网页出一份补全建议(每条资源一次,不建作品)。每次最多读 3 个网页,调用一次模型服务",
- wide: true,
- },
- {
- name: "identity.enrichDailyBudget",
- label: "元数据补全每日上限",
- kind: "number",
- hint: "手动和自动合计;7 天内同一标题直接用上次结果,不计次。0 = 停用",
- },
- {
- name: "identity.enrichDomains",
- label: "补全可读取的网站",
- kind: "textarea",
- hint: "每行一个域名,含子域名;只放平台和百科类网站。豆瓣只用搜索摘要,不读页面;robots.txt 不允许的页面不读。已登记站点的域名一律不读",
- wide: true,
- },
- {
- name: "identity.enrichSnippetAllSites",
- label: "也用其它网站的搜索摘要",
- kind: "switch",
- hint: "短剧多半只有聚合站收录。打开后,白名单以外网站的搜索摘要也用来抽年份、集数、主演、简介(只看搜索引擎给的摘要,不打开这些网页)",
- wide: true,
- },
- ],
- fromSettings: (s) => ({
- "identity.eagerTmdbLookup": s.identity.eagerTmdbLookup,
- "identity.autoEnrich": s.identity.autoEnrich,
- "identity.enrichDailyBudget": n(s.identity.enrichDailyBudget),
- "identity.enrichDomains": s.identity.enrichDomains.join("\n"),
- "identity.enrichSnippetAllSites": s.identity.enrichSnippetAllSites ?? true,
- "identity.detailIdsPerSiteHour": n(s.identity.detailIdsPerSiteHour ?? 10),
- }),
- toPatch: (v) => ({
- identity: {
- eagerTmdbLookup: v["identity.eagerTmdbLookup"] === true,
- autoEnrich: v["identity.autoEnrich"] === true,
- enrichDailyBudget: Number(v["identity.enrichDailyBudget"]),
- enrichDomains: String(v["identity.enrichDomains"] ?? "")
- .split(/[\s,,]+/)
- .map((d) => d.trim().toLowerCase())
- .filter(Boolean),
- enrichSnippetAllSites: v["identity.enrichSnippetAllSites"] === true,
- detailIdsPerSiteHour: Number(v["identity.detailIdsPerSiteHour"]),
- },
- }),
- extra: () => ,
-};
-
-type StatRow = { kind: string; withResults: number; hits: number; rate: number | null };
-
-/** 最近 30 天补全命中率(按来源类别,META-2) */
-function EnrichStatsTable() {
- const q = useEnrichStats(30);
- const d = q.data;
- return (
-
-
- {d
- ? `最近 ${d.days} 天元数据补全 ${d.total} 次,出了建议 ${d.suggested} 次${d.tracked < d.total ? `;有来源明细的 ${d.tracked} 次` : ""}`
- : "读取补全命中率…"}
-
- {d && d.tracked > 0 ? (
-
- rows={d.sources}
- rowKey={(r) => r.kind}
- empty={{ title: "还没有补全记录" }}
- columns={[
- { key: "kind", header: "来源", cell: (r) => ENRICH_SOURCE_KIND[r.kind] ?? r.kind },
- {
- key: "withResults",
- header: "有结果",
- align: "right",
- cell: (r) => {r.withResults},
- },
- {
- key: "hits",
- header: "命中",
- align: "right",
- cell: (r) => {r.hits},
- },
- {
- key: "rate",
- header: "命中率",
- align: "right",
- cell: (r) => (
- {r.rate === null ? "—" : `${Math.round(r.rate * 100)}%`}
- ),
- },
- ]}
- />
- ) : null}
-
- );
-}
-
export function MetadataSection() {
return (
{(s) => (
<>
-
-
-
+
>
)}
diff --git a/apps/web/app/components/settings/naming.tsx b/apps/web/app/components/settings/naming.tsx
index fb05c366..5a558a46 100644
--- a/apps/web/app/components/settings/naming.tsx
+++ b/apps/web/app/components/settings/naming.tsx
@@ -52,7 +52,7 @@ function GlobalNaming() {
const r = await save.mutateAsync({ ...draft, lockFileNameForBound: lock });
setDraft({ movie: r.movie, tv: r.tv, variety: r.variety });
setLock(r.lockFileNameForBound);
- toast.success("命名模板已保存");
+ toast.success("已保存「命名模板」");
} catch (err) {
const split = splitNamingErrors(errorMessage(err));
if (split) setErrors(split);
@@ -120,7 +120,7 @@ function RootNaming() {
{roots.isPending ? (
@@ -177,7 +177,7 @@ function RootNamingEditor({ root }: { root: LibraryRoot }) {
try {
await save.mutateAsync({ id: root.id, naming: body });
setDraft({ ...(body ?? {}) });
- toast.success(body ? "媒体库目录命名覆盖已保存" : "已清除媒体库目录命名覆盖");
+ toast.success(body ? "已保存「目录命名覆盖」" : "已清除「目录命名覆盖」");
} catch (err) {
const split = splitNamingErrors(errorMessage(err));
if (split) setErrors(split);
diff --git a/apps/web/app/components/settings/organize-sources.tsx b/apps/web/app/components/settings/organize-sources.tsx
index 38656809..4410f7e8 100644
--- a/apps/web/app/components/settings/organize-sources.tsx
+++ b/apps/web/app/components/settings/organize-sources.tsx
@@ -85,22 +85,25 @@ export function ClassificationSection() {
2020-2025 范围)。
-
+ {dirty ? (
+
+ ) : null}
@@ -219,7 +222,7 @@ function SourceEditor({ initial, onClose }: { initial: OrganizeSourceView | null
onChange={(e) => patch({ action: e.target.value as "copy" | "move" })}
>
-
+
diff --git a/apps/web/app/components/settings/organize.tsx b/apps/web/app/components/settings/organize.tsx
new file mode 100644
index 00000000..abae5672
--- /dev/null
+++ b/apps/web/app/components/settings/organize.tsx
@@ -0,0 +1,120 @@
+import { num, str } from "./resource-section.tsx";
+import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
+
+// 整理与刮削分区:整理方式(复制 / 移动、跨卷、最小视频体积)与刮削(整理后自动刮削、已有文件的覆盖方式、图片)。
+// 放哪个目录、怎么命名在「媒体库与目录」。
+
+const MiB = 1024 ** 2;
+
+export const ORGANIZE_CARD: CardSpec = {
+ anchor: "mode",
+ title: "整理方式",
+ description: "复制整理时源文件留在下载盘继续做种,保种天数在「下载器」里设置。",
+ fields: [
+ {
+ name: "organize.defaultMode",
+ label: "默认整理方式",
+ kind: "select",
+ options: [
+ { value: "copy", label: "复制(源文件继续做种)" },
+ { value: "move", label: "移动(移走源文件)" },
+ ],
+ },
+ {
+ name: "organize.allowCrossVolumeMove",
+ label: "允许跨卷移动",
+ kind: "switch",
+ hint: "跨卷时按复制 → 核验 → 删源执行",
+ },
+ {
+ name: "organize.minSizeMiB",
+ label: "最小视频体积(MiB)",
+ kind: "number",
+ hint: "整理和文件管理识别时,小于这个体积的视频当作样片跳过;整理向导里单独填的值优先",
+ },
+ ],
+ fromSettings: (s) => ({
+ "organize.defaultMode": s.organize.defaultMode,
+ "organize.allowCrossVolumeMove": s.organize.allowCrossVolumeMove,
+ "organize.minSizeMiB": n(Math.round((s.organize.minSizeBytes / MiB) * 100) / 100),
+ }),
+ toPatch: (v) => {
+ const mib = num(v["organize.minSizeMiB"]);
+ return {
+ organize: {
+ defaultMode: str(v["organize.defaultMode"]) as "copy" | "move",
+ allowCrossVolumeMove: v["organize.allowCrossVolumeMove"] === true,
+ minSizeBytes: mib === undefined ? undefined : Math.round(mib * MiB),
+ },
+ };
+ },
+};
+
+// SCR-1 刮削:整理后写 NFO 与图片到媒体库目录(Emby / Jellyfin / Kodi 读取)
+export const SCRAPE_CARD: CardSpec = {
+ anchor: "scrape",
+ title: "刮削",
+ description:
+ "把作品信息写成 NFO,连同海报、背景图放进媒体库目录,Emby、Jellyfin、Kodi 直接读取。作品详情和文件管理里也能手动刮削。",
+ fields: [
+ {
+ name: "organize.scrapeAfterImport",
+ label: "整理后自动刮削",
+ kind: "switch",
+ hint: "只对认出作品(有 TMDB ID 或本地元数据)的整理排刮削;没刮削的在整理记录上标「未刮削」",
+ },
+ {
+ name: "organize.scrapeMode",
+ label: "已有文件",
+ kind: "select",
+ options: [
+ { value: "update", label: "更新" },
+ { value: "missing", label: "只补缺失" },
+ { value: "overwrite", label: "全部覆盖" },
+ ],
+ hint: "更新只替换本工具写过、之后没被改过的文件;全部覆盖会替换目录里已有的 NFO 和图片,包括其他工具写的",
+ },
+ {
+ name: "organize.scrapeImages",
+ label: "下载图片",
+ kind: "switch",
+ hint: "海报、背景图、季海报",
+ },
+ {
+ name: "organize.scrapeEpisodeThumbs",
+ label: "下载剧照",
+ kind: "switch",
+ hint: "每集一张,存为「视频名-thumb.jpg」;关闭下载图片时不生效",
+ },
+ ],
+ fromSettings: (s) => ({
+ "organize.scrapeAfterImport": s.organize.scrapeAfterImport,
+ "organize.scrapeMode": s.organize.scrapeMode,
+ "organize.scrapeImages": s.organize.scrapeImages,
+ "organize.scrapeEpisodeThumbs": s.organize.scrapeEpisodeThumbs,
+ }),
+ toPatch: (v) => ({
+ organize: {
+ scrapeAfterImport: v["organize.scrapeAfterImport"] === true,
+ scrapeMode: scrapeModeOf(str(v["organize.scrapeMode"])),
+ scrapeImages: v["organize.scrapeImages"] === true,
+ scrapeEpisodeThumbs: v["organize.scrapeEpisodeThumbs"] === true,
+ },
+ }),
+};
+
+const scrapeModeOf = (v: string | null | undefined): "missing" | "overwrite" | "update" =>
+ v === "overwrite" || v === "missing" ? v : "update";
+
+export function OrganizeSection() {
+ return (
+
+ {(s) => (
+ <>
+
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/web/app/components/settings/pi-upstreams.tsx b/apps/web/app/components/settings/pi-upstreams.tsx
index c626e4ac..c74e688f 100644
--- a/apps/web/app/components/settings/pi-upstreams.tsx
+++ b/apps/web/app/components/settings/pi-upstreams.tsx
@@ -174,7 +174,7 @@ function UpstreamDrawer({
try {
await update.mutateAsync({ id: upstream.id, body: parsed.data });
setV((s) => ({ ...s, apiKey: "" }));
- toast.success(`${v.name.trim()} 已保存`);
+ toast.success(`已保存「${v.name.trim()}」`);
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
diff --git a/apps/web/app/components/settings/search-index.ts b/apps/web/app/components/settings/search-index.ts
new file mode 100644
index 00000000..578268b0
--- /dev/null
+++ b/apps/web/app/components/settings/search-index.ts
@@ -0,0 +1,128 @@
+import { SETTINGS_SECTIONS, type SettingsSectionId } from "@umt/contracts";
+import { BUDGET_CARD, IDENTITY_CARD } from "./acquire.tsx";
+import { ADVANCED_CARD, BUDGET_CARD as AGENT_BUDGET_CARD, INVESTIGATE_CARD, RUNTIME_CARD } from "./agent.tsx";
+import { WEBSEARCH_CARD } from "./agent-websearch.tsx";
+import { IMAGES_CARD, TMDB_CACHE_CARD, TTL_CARD } from "./cache.tsx";
+import { CACHE_CARD, SEEDING_CARD } from "./downloaders.tsx";
+import { LANGUAGE_CARD, TMDB_CARD } from "./metadata.tsx";
+import { ORGANIZE_CARD, SCRAPE_CARD } from "./organize.tsx";
+import type { CardSpec } from "./settings-card.tsx";
+import { DEFAULTS_CARD, NETWORK_CARD } from "./sites.tsx";
+import { STORAGE_BUDGET_CARD, STORAGE_RETENTION_CARD, WORKER_LANES_CARD } from "./system.tsx";
+
+// 设置页搜索:分区名、节名、节的别名(contracts keywords)、各节表单字段名。
+// 选中字段时页面滚到该节并聚焦字段(控件 id = `f-<字段名>`)。
+
+/** 表单卡片 → 所在分区;新增卡片时在这里登记,测试会核对锚点存在且字段都在页面上 */
+export const SETTINGS_CARDS: readonly [SettingsSectionId, CardSpec][] = [
+ ["downloaders", CACHE_CARD],
+ ["downloaders", SEEDING_CARD],
+ ["sites", DEFAULTS_CARD],
+ ["sites", NETWORK_CARD],
+ ["metadata", TMDB_CARD],
+ ["metadata", LANGUAGE_CARD],
+ ["acquire", BUDGET_CARD],
+ ["acquire", IDENTITY_CARD],
+ ["organize", ORGANIZE_CARD],
+ ["organize", SCRAPE_CARD],
+ ["agent", AGENT_BUDGET_CARD],
+ ["agent", INVESTIGATE_CARD],
+ ["agent", RUNTIME_CARD],
+ ["agent", WEBSEARCH_CARD],
+ ["agent", ADVANCED_CARD],
+ ["system", WORKER_LANES_CARD],
+ ["system", STORAGE_BUDGET_CARD],
+ ["system", STORAGE_RETENTION_CARD],
+ ["cache", TMDB_CACHE_CARD],
+ ["cache", IMAGES_CARD],
+ ["cache", TTL_CARD],
+];
+
+export type SettingsSearchHit = {
+ key: string;
+ /** 命中的名字(字段名 / 节名 / 分区名) */
+ label: string;
+ section: SettingsSectionId;
+ anchor: string | null;
+ /** 表单字段名(控件 id 为 `f-`) */
+ field: string | null;
+ /** 位置:分区 › 节 */
+ where: string;
+};
+
+type Entry = SettingsSearchHit & { terms: string[] };
+
+function buildIndex(): Entry[] {
+ const out: Entry[] = [];
+ for (const s of SETTINGS_SECTIONS) {
+ out.push({
+ key: s.id,
+ label: s.label,
+ section: s.id,
+ anchor: null,
+ field: null,
+ where: s.description,
+ terms: [s.label],
+ });
+ for (const a of s.anchors) {
+ out.push({
+ key: `${s.id}/${a.id}`,
+ label: a.label,
+ section: s.id,
+ anchor: a.id,
+ field: null,
+ where: s.label,
+ terms: [a.label, ...(a.keywords ?? [])],
+ });
+ }
+ }
+ for (const [sid, card] of SETTINGS_CARDS) {
+ const s = SETTINGS_SECTIONS.find((x) => x.id === sid);
+ const a = s?.anchors.find((x) => x.id === card.anchor);
+ for (const f of card.fields) {
+ out.push({
+ key: `${sid}/${card.anchor}/${f.name}`,
+ label: f.label,
+ section: sid,
+ anchor: card.anchor,
+ field: f.name,
+ where: `${s?.label ?? sid} › ${a?.label ?? card.title}`,
+ terms: [f.label],
+ });
+ }
+ }
+ return out;
+}
+
+let INDEX: Entry[] | null = null;
+
+const norm = (v: string) => v.toLowerCase().replace(/[\s()()「」::、,,]/g, "");
+
+/** 按名字找设置:名字开头命中排前,其次包含;同一位置只留一条 */
+export function searchSettings(query: string, limit = 8): SettingsSearchHit[] {
+ const q = norm(query);
+ if (!q) return [];
+ INDEX ??= buildIndex();
+ const scored: [number, Entry][] = [];
+ for (const e of INDEX) {
+ let best = -1;
+ for (const [i, t] of e.terms.entries()) {
+ const n = norm(t);
+ const score = n === q ? 3 : n.startsWith(q) ? 2 : n.includes(q) ? 1 : -1;
+ // 别名命中比名字本身命中低一档
+ const adj = score < 0 ? -1 : i === 0 ? score + 0.5 : score;
+ best = Math.max(best, adj);
+ }
+ if (best >= 0) scored.push([best, e]);
+ }
+ scored.sort((a, b) => b[0] - a[0]);
+ const seen = new Set();
+ const hits: SettingsSearchHit[] = [];
+ for (const [, { terms: _t, ...hit }] of scored) {
+ if (seen.has(hit.key)) continue;
+ seen.add(hit.key);
+ hits.push(hit);
+ if (hits.length >= limit) break;
+ }
+ return hits;
+}
diff --git a/apps/web/app/components/settings/sections.tsx b/apps/web/app/components/settings/sections.tsx
index 6cb5d110..b3dee462 100644
--- a/apps/web/app/components/settings/sections.tsx
+++ b/apps/web/app/components/settings/sections.tsx
@@ -1,51 +1,73 @@
-import { SETTINGS_SECTIONS, type SettingsSectionDef, type SettingsSectionId } from "@umt/contracts";
+import {
+ resolveSettingsHash,
+ SETTINGS_GROUPS,
+ SETTINGS_SECTIONS,
+ type SettingsSectionDef,
+ type SettingsSectionId,
+} from "@umt/contracts";
import {
Bot,
+ Database,
+ Download,
FolderTree,
- KeyRound,
+ Globe,
type LucideIcon,
Monitor,
- Server,
+ Search,
+ Settings2,
Shield,
Tags,
Tv,
- UserCog,
- Zap,
+ Wand2,
} from "lucide-react";
import type { ReactNode } from "react";
import { AccountSection } from "./account.tsx";
+import { AcquireSection } from "./acquire.tsx";
import { AgentSection } from "./agent.tsx";
-import { ApiKeysSection } from "./api-keys.tsx";
-import { AutomationSection } from "./automation.tsx";
-import { DownloadersSection, MediaServersSection } from "./instances.tsx";
+import { CacheSection } from "./cache.tsx";
+import { DownloadersSection } from "./downloaders.tsx";
+import { MediaServersSection } from "./instances.tsx";
import { InterfaceSection } from "./interface.tsx";
import { LibrarySection } from "./library.tsx";
import { MetadataSection } from "./metadata.tsx";
+import { OrganizeSection } from "./organize.tsx";
import { SitesSection } from "./sites.tsx";
+import { SystemSection } from "./system.tsx";
-// 设置分区:文案与字段归属在 contracts 的 SETTINGS_SECTIONS,这里只挂图标与渲染
+// 设置分区:文案、分组与字段归属在 contracts 的 SETTINGS_SECTIONS,这里只挂图标与渲染
const UI: Record ReactNode }> = {
- automation: { icon: Zap, render: () => },
- sites: { icon: UserCog, render: () => },
- downloaders: { icon: Server, render: () => },
- library: { icon: FolderTree, render: () => },
+ downloaders: { icon: Download, render: () => },
"media-servers": { icon: Tv, render: () => },
+ sites: { icon: Globe, render: () => },
metadata: { icon: Tags, render: () => },
+ acquire: { icon: Search, render: () => },
+ library: { icon: FolderTree, render: () => },
+ organize: { icon: Wand2, render: () => },
agent: { icon: Bot, render: () => },
- "api-keys": { icon: KeyRound, render: () => },
- interface: { icon: Monitor, render: () => },
+ system: { icon: Settings2, render: () => },
+ cache: { icon: Database, render: () => },
account: { icon: Shield, render: () => },
+ interface: { icon: Monitor, render: () => },
};
export type SectionView = SettingsSectionDef & { icon: LucideIcon; render: () => ReactNode };
export const SECTIONS: SectionView[] = SETTINGS_SECTIONS.map((s) => ({ ...s, ...UI[s.id] }));
-/** URL hash:`#<分区>` 或 `#<分区>/<节>`;不认识的落到默认分区 */
-export function parseSettingsHash(hash: string): { section: SectionView; anchor: string | null } {
- const [id, anchor] = decodeURIComponent(hash.replace(/^#/, "")).split("/");
- const section = SECTIONS.find((s) => s.id === id) ?? (SECTIONS[0] as SectionView);
- return { section, anchor: section.id === id && anchor ? anchor : null };
+/** 左栏分组:接入 / 流程 / 系统 */
+export const SECTION_GROUPS = SETTINGS_GROUPS.map((g) => ({
+ ...g,
+ sections: SECTIONS.filter((s) => s.group === g.id),
+}));
+
+/**
+ * URL hash:`#<分区>` 或 `#<分区>/<节>`;旧 hash 换成新位置(moved = true 时页面用 replace 改地址栏),
+ * 不认识的落到第一个分区
+ */
+export function parseSettingsHash(hash: string): { section: SectionView; anchor: string | null; moved: boolean } {
+ const r = resolveSettingsHash(hash);
+ const section = SECTIONS.find((s) => s.id === r.section) ?? (SECTIONS[0] as SectionView);
+ return { section, anchor: r.section ? r.anchor : null, moved: r.moved };
}
export const settingsHash = (section: string, anchor?: string | null) => `#${section}${anchor ? `/${anchor}` : ""}`;
diff --git a/apps/web/app/components/settings/settings-card.tsx b/apps/web/app/components/settings/settings-card.tsx
index 07bd88b9..196f497b 100644
--- a/apps/web/app/components/settings/settings-card.tsx
+++ b/apps/web/app/components/settings/settings-card.tsx
@@ -79,7 +79,7 @@ export function SettingsCard({
try {
const next = await update.mutateAsync(patch);
setValues(spec.fromSettings(next));
- toast.success(`${spec.title} 已保存`);
+ toast.success(`已保存「${spec.title}」`);
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
diff --git a/apps/web/app/components/settings/settings-page.test.ts b/apps/web/app/components/settings/settings-page.test.ts
new file mode 100644
index 00000000..f6a94599
--- /dev/null
+++ b/apps/web/app/components/settings/settings-page.test.ts
@@ -0,0 +1,216 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { DEFAULT_SETTINGS, DEPRECATED_SETTINGS_FIELDS, type GlobalSettings, SETTINGS_SECTIONS } from "@umt/contracts";
+import { createElement } from "react";
+import { renderToString } from "react-dom/server";
+import { MemoryRouter } from "react-router";
+import { describe, expect, it } from "vitest";
+import { qk } from "~/lib/query-keys.ts";
+import { cacheRow } from "./cache.tsx";
+import { SETTINGS_CARDS, searchSettings } from "./search-index.ts";
+import { parseSettingsHash, SECTION_GROUPS, SECTIONS, settingsHash } from "./sections.tsx";
+import { anchorId } from "./settings-block.tsx";
+
+// 设置页重排(2026-10-06):分组导航、旧锚点跳转、搜索定位、各分区渲染不丢字段。
+// 渲染用 react-dom/server + 预置的设置数据;列表类接口在服务端渲染里保持加载态,只核对节锚点。
+
+/** 表单字段名 → 它写回的存储字段(换了单位 / 拆了子字段的在这里对上) */
+const FIELD_ALIASES: Record = {
+ "budget.maxDownloadGiBPerDay": "budget.maxDownloadBytesPerDay",
+ "organize.minSizeMiB": "organize.minSizeBytes",
+ "metadata.tmdbApiKey": "metadata.tmdbApiKeySecretRef",
+};
+
+/** 不走表单卡片、由专门组件渲染的字段 → 所在节(只核对节在页面上) */
+const CUSTOM_FIELDS: Record = {
+ "organize.classificationYaml": "library/classification",
+ "organize.sources": "library/sources",
+ "naming.movie": "library/naming",
+ "naming.tv": "library/naming",
+ "naming.variety": "library/naming",
+ "naming.lockFileNameForBound": "library/naming",
+ "pi.defaultUpstreamId": "agent/upstream",
+ "search.defaultSiteMode": "acquire/search",
+ "search.siteIds": "acquire/search",
+ "automation.enabled": "system/switches",
+ "automation.readOnlyReconcile": "system/readonly",
+};
+
+/** 打开所有"开了才显示后续字段"的开关,让条件字段也渲染出来 */
+const ALL_ON: GlobalSettings = {
+ ...DEFAULT_SETTINGS,
+ pi: {
+ ...DEFAULT_SETTINGS.pi,
+ subagentsEnabled: true,
+ subagents: { ...DEFAULT_SETTINGS.pi.subagents, enabled: true },
+ },
+ agent: {
+ ...DEFAULT_SETTINGS.agent,
+ autoInvestigate: true,
+ autoInvestigateMode: "recover",
+ webSearch: { ...DEFAULT_SETTINGS.agent.webSearch, provider: "brave" },
+ },
+};
+
+function render(sectionId: string, settings: GlobalSettings = DEFAULT_SETTINGS): string {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } });
+ qc.setQueryData(qk.settings, settings);
+ const section = SECTIONS.find((s) => s.id === sectionId)!;
+ return renderToString(
+ createElement(
+ QueryClientProvider,
+ { client: qc },
+ createElement(MemoryRouter, { initialEntries: [`/console/settings#${sectionId}`] }, section.render()),
+ ),
+ );
+}
+
+/** 存储字段(`key.field`)是否被某个渲染出来的控件 id(`f-<表单字段名>`)覆盖 */
+function covered(field: string, controlIds: string[]): boolean {
+ return controlIds.some((id) => {
+ const target = FIELD_ALIASES[id] ?? id;
+ return target === field || target.startsWith(`${field}.`);
+ });
+}
+
+describe("设置页分组导航", () => {
+ it("三组按 接入 → 流程 → 系统 排,每个分区都在某一组里,带标题和一句说明", () => {
+ expect(SECTION_GROUPS.map((g) => g.label)).toEqual(["接入", "流程", "系统"]);
+ expect(SECTION_GROUPS.flatMap((g) => g.sections.map((s) => s.id))).toEqual(SECTIONS.map((s) => s.id));
+ for (const s of SECTIONS) {
+ expect(s.label).not.toBe("");
+ expect(s.description).not.toBe("");
+ expect(typeof s.render).toBe("function");
+ }
+ });
+
+ it("接入对象与参数分开:实例类分区的第一节是对象列表", () => {
+ const first = (id: string) => SECTIONS.find((s) => s.id === id)?.anchors[0]?.id;
+ expect(first("downloaders")).toBe("instances");
+ expect(first("media-servers")).toBe("instances");
+ expect(first("library")).toBe("roots");
+ expect(first("agent")).toBe("upstream");
+ });
+});
+
+describe("旧锚点跳转", () => {
+ it.each([
+ ["#automation/cache", "downloaders", "cache"],
+ ["#automation/budget", "acquire", "budget"],
+ ["#automation/advanced", "system", "readonly"],
+ ["#automation", "system", null],
+ ["#library/scrape", "organize", "scrape"],
+ ["#metadata/identity", "acquire", "identity"],
+ ["#metadata/tmdb-cache", "cache", "tmdb-cache"],
+ ["#api-keys", "account", "api-keys"],
+ ["#account/shortcuts", "interface", "shortcuts"],
+ ])("%s → #%s/%s,并标记需要替换地址", (hash, section, anchor) => {
+ const r = parseSettingsHash(hash);
+ expect(r.section.id).toBe(section);
+ expect(r.anchor).toBe(anchor);
+ expect(r.moved).toBe(true);
+ });
+
+ it("新锚点不跳;#agent 等同名分区保持;不认识的落到第一个分区", () => {
+ expect(parseSettingsHash("#agent")).toMatchObject({ anchor: null, moved: false });
+ expect(parseSettingsHash("#agent").section.id).toBe("agent");
+ expect(parseSettingsHash("#downloaders/instances").section.id).toBe("downloaders");
+ expect(parseSettingsHash("#nope").section.id).toBe(SECTIONS[0]!.id);
+ expect(settingsHash("cache", "regions")).toBe("#cache/regions");
+ });
+});
+
+describe("搜索定位", () => {
+ it.each([
+ ["H&R", "downloaders", "instances", null],
+ ["刮削", "organize", "scrape", null],
+ ["整理后自动刮削", "organize", "scrape", "organize.scrapeAfterImport"],
+ ["已有文件", "organize", "scrape", "organize.scrapeMode"],
+ ["单次工具调用上限", "agent", "budget", "agent.maxToolCalls"],
+ ["整理识别不到作品", "agent", "investigate", "agent.autoIdentifyOrganize"],
+ ["特别篇", "metadata", "language", "metadata.includeSpecials"],
+ ["代理地址", "sites", "network", "sites.proxyUrl"],
+ ["Cloudflare", "sites", "browser", null],
+ ["保种天数", "downloaders", "seeding", "retention.seedRetentionDays"],
+ ["豆瓣", "cache", "regions", null],
+ ["Passkey", "account", "passkeys", null],
+ ["API Key", "account", "api-keys", null],
+ ])("「%s」能找到 %s/%s", (q, section, anchor, field) => {
+ const hits = searchSettings(q, 20);
+ const hit = hits.find((h) => h.section === section && h.anchor === anchor && (field === null || h.field === field));
+ expect(hit, JSON.stringify(hits.map((h) => h.key))).toBeDefined();
+ });
+
+ it("空查询没有结果;找不到时返回空", () => {
+ expect(searchSettings(" ")).toEqual([]);
+ expect(searchSettings("完全不存在的设置名")).toEqual([]);
+ });
+
+ it("搜索登记的卡片锚点都是所在分区的节", () => {
+ for (const [sid, card] of SETTINGS_CARDS) {
+ const s = SETTINGS_SECTIONS.find((x) => x.id === sid);
+ expect(
+ s?.anchors.map((a) => a.id),
+ `${sid}/${card.anchor}`,
+ ).toContain(card.anchor);
+ }
+ });
+});
+
+describe("各分区渲染(不丢字段)", () => {
+ const html = Object.fromEntries(SECTIONS.map((s) => [s.id, render(s.id)]));
+ const htmlAllOn = Object.fromEntries(SECTIONS.map((s) => [s.id, render(s.id, ALL_ON)]));
+
+ it.each(SECTIONS.map((s) => [s.id]))("%s:每一节都渲染出来", (id) => {
+ const s = SECTIONS.find((x) => x.id === id)!;
+ for (const a of s.anchors) expect(html[id], `${id}/${a.id}`).toContain(`id="${anchorId(id, a.id)}"`);
+ });
+
+ it("每个在用的存储字段都出现在它所属分区的页面上", () => {
+ const deprecated = new Set(DEPRECATED_SETTINGS_FIELDS);
+ const missing: string[] = [];
+ for (const s of SECTIONS) {
+ const page = `${html[s.id] ?? ""}\n${htmlAllOn[s.id] ?? ""}`;
+ const controlIds = [...page.matchAll(/id="f-([^"]+)"/g)].map((m) => m[1] as string);
+ for (const f of s.fields) {
+ if (deprecated.has(f)) continue;
+ const custom = CUSTOM_FIELDS[f];
+ if (custom) {
+ const [sid, anchor] = custom.split("/") as [string, string];
+ if (sid !== s.id || !page.includes(`id="${anchorId(sid, anchor)}"`)) missing.push(`${s.id}:${f}`);
+ continue;
+ }
+ if (!covered(f, controlIds)) missing.push(`${s.id}:${f}`);
+ }
+ }
+ expect(missing).toEqual([]);
+ });
+
+ it("近期新增的设置都在页面上:智能体防跑飞限制与用量、识别失败自动派智能体、刮削「更新」、整理后自动刮削、H&R 未知", () => {
+ expect(html.agent).toContain('id="f-agent.maxToolCalls"');
+ expect(html.agent).toContain('id="f-agent.maxDurationSec"');
+ expect(html.agent).toContain('id="f-agent.autoIdentifyOrganize"');
+ expect(html.organize).toContain('id="f-organize.scrapeAfterImport"');
+ expect(html.organize).toContain("更新");
+ // 实例抽屉的字段在打开抽屉后才渲染;这里核对搜索能定位到实例节
+ expect(searchSettings("H&R 未知也可清理")[0]).toMatchObject({ section: "downloaders", anchor: "instances" });
+ });
+
+ it("废弃字段不再显示", () => {
+ const all = Object.values(html).join("\n");
+ for (const f of DEPRECATED_SETTINGS_FIELDS) expect(all).not.toContain(`id="f-${f}"`);
+ expect(all).not.toContain("日志保留天数");
+ expect(all).not.toContain("每日费用");
+ });
+});
+
+describe("缓存区名", () => {
+ it("已知区显示中文名,未知区原样显示", () => {
+ const base = { entries: 1, expired: 0, bytes: 10, lastWriteAt: null };
+ expect(cacheRow({ region: "tmdb.request", ...base }).label).toBe("TMDB 请求");
+ expect(cacheRow({ region: "douban.subject", ...base }).label).toBe("豆瓣条目");
+ expect(cacheRow({ region: "bangumi.calendar", ...base }).label).toBe("Bangumi(calendar)");
+ expect(cacheRow({ region: "site.detail", ...base }).label).toBe("站点详情页");
+ expect(cacheRow({ region: "images", ...base }).label).toBe("图片");
+ expect(cacheRow({ region: "zzz.custom", ...base }).label).toBe("zzz.custom");
+ });
+});
diff --git a/apps/web/app/components/settings/settings-search.tsx b/apps/web/app/components/settings/settings-search.tsx
new file mode 100644
index 00000000..d79020fc
--- /dev/null
+++ b/apps/web/app/components/settings/settings-search.tsx
@@ -0,0 +1,116 @@
+import { Search, X } from "lucide-react";
+import { useId, useMemo, useRef, useState } from "react";
+import { useNavigate } from "react-router";
+import { Input } from "~/components/ui/input.tsx";
+import { cn } from "~/lib/utils.ts";
+import { type SettingsSearchHit, searchSettings } from "./search-index.ts";
+import { settingsHash } from "./sections.tsx";
+
+/** 设置页搜索框:按设置名、节名找位置;选中后跳到该节,字段会被聚焦 */
+export function SettingsSearch({ className }: { className?: string }) {
+ const navigate = useNavigate();
+ const listId = useId();
+ const [q, setQ] = useState("");
+ const [open, setOpen] = useState(false);
+ const [active, setActive] = useState(0);
+ const inputRef = useRef(null);
+ const hits = useMemo(() => searchSettings(q), [q]);
+ const show = open && q.trim() !== "";
+
+ const pick = (h: SettingsSearchHit) => {
+ setOpen(false);
+ setQ("");
+ navigate({ hash: settingsHash(h.section, h.anchor) }, { state: h.field ? { field: h.field } : null });
+ };
+
+ return (
+
+
+
{
+ setQ(e.target.value);
+ setActive(0);
+ setOpen(true);
+ }}
+ onFocus={() => setOpen(true)}
+ onBlur={() => window.setTimeout(() => setOpen(false), 120)}
+ onKeyDown={(e) => {
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setActive((i) => Math.min(i + 1, hits.length - 1));
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setActive((i) => Math.max(i - 1, 0));
+ } else if (e.key === "Enter") {
+ const h = hits[active];
+ if (h) {
+ e.preventDefault();
+ pick(h);
+ }
+ } else if (e.key === "Escape") {
+ setQ("");
+ setOpen(false);
+ }
+ }}
+ />
+ {q ? (
+
+ ) : null}
+ {show ? (
+
+ {hits.length === 0 ? (
+
没有匹配的设置
+ ) : (
+ hits.map((h, i) => (
+
+ ))
+ )}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/app/components/settings/sites.tsx b/apps/web/app/components/settings/sites.tsx
index 761c765d..4c13be28 100644
--- a/apps/web/app/components/settings/sites.tsx
+++ b/apps/web/app/components/settings/sites.tsx
@@ -6,12 +6,12 @@ import { BrowserProvidersBlock } from "./browser-providers.tsx";
import { num, str } from "./resource-section.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
-// 站点默认(site-center §8):站点没单独设置时用的限流、快照周期、UA、代理;浏览器服务(BR-1)。
-// 站点、凭证、账号在「站点」页管理;站点级覆盖在站点的"编辑"里。
+// 站点与网络(site-center §8):站点没单独设置时用的限流、抓取周期等缺省值;UA 与代理;浏览器服务(BR-1)。
+// 站点、凭证、账号、Cloudflare 处理在「站点」页管理;站点级覆盖在站点的「编辑」里。
-const DEFAULTS_CARD: CardSpec = {
+export const DEFAULTS_CARD: CardSpec = {
anchor: "defaults",
- title: "默认值",
+ title: "站点缺省值",
description: "站点没有单独设置时使用。限流不会比站点定义自带的更快。",
fields: [
{
@@ -51,22 +51,6 @@ const DEFAULTS_CARD: CardSpec = {
kind: "number",
hint: "拉取用户数据时最多翻几页做种列表;1–50",
},
- {
- name: "sites.userAgent",
- label: "User-Agent",
- kind: "text",
- placeholder: "跟随浏览器",
- hint: "留空跟随浏览器(推荐),站点请求和浏览器用同一个 UA。填了则账号没单独填 UA 时都用它,和浏览器不一致",
- wide: true,
- },
- {
- name: "sites.proxyUrl",
- label: "代理地址",
- kind: "url",
- placeholder: "http://127.0.0.1:7890",
- hint: "只对开了「使用代理」的站点生效;留空不用代理",
- wide: true,
- },
],
fromSettings: (s) => ({
"sites.defaultRateLimit.requestsPerMinute": n(s.sites.defaultRateLimit.requestsPerMinute),
@@ -76,8 +60,6 @@ const DEFAULTS_CARD: CardSpec = {
"sites.defaultSnapshotMaxPages": n(s.sites.defaultSnapshotMaxPages),
"sites.defaultHealthCheckIntervalSec": n(s.sites.defaultHealthCheckIntervalSec / 3600),
"sites.defaultDetailCacheSec": n(s.sites.defaultDetailCacheSec / 3600),
- "sites.userAgent": s.sites.userAgent,
- "sites.proxyUrl": s.sites.proxyUrl ?? "",
}),
toPatch: (v, s) => {
const min = num(v["sites.defaultSnapshotIntervalSec"]);
@@ -97,20 +79,52 @@ const DEFAULTS_CARD: CardSpec = {
defaultSnapshotMaxPages: num(v["sites.defaultSnapshotMaxPages"]),
defaultHealthCheckIntervalSec: healthH === undefined ? undefined : Math.round(healthH * 3600),
defaultDetailCacheSec: detailH === undefined ? undefined : Math.round(detailH * 3600),
- // 空串 = 跟随浏览器(要能清空,不能用 undefined 表示"不改")
- userAgent: str(v["sites.userAgent"]),
- proxyUrl: str(v["sites.proxyUrl"]) || null,
},
};
},
};
+export const NETWORK_CARD: CardSpec = {
+ anchor: "network",
+ title: "UA 与代理",
+ description: "站点请求用的 User-Agent 和代理。单个站点在「编辑」里决定用不用代理。",
+ fields: [
+ {
+ name: "sites.userAgent",
+ label: "User-Agent",
+ kind: "text",
+ placeholder: "跟随浏览器",
+ hint: "留空跟随浏览器(推荐),站点请求和浏览器用同一个 UA。填了则账号没单独填 UA 时都用它,和浏览器不一致",
+ wide: true,
+ },
+ {
+ name: "sites.proxyUrl",
+ label: "代理地址",
+ kind: "url",
+ placeholder: "http://127.0.0.1:7890",
+ hint: "只对开了「使用代理」的站点生效;留空不用代理",
+ wide: true,
+ },
+ ],
+ fromSettings: (s) => ({
+ "sites.userAgent": s.sites.userAgent,
+ "sites.proxyUrl": s.sites.proxyUrl ?? "",
+ }),
+ toPatch: (v) => ({
+ sites: {
+ // 空串 = 跟随浏览器(要能清空,不能用 undefined 表示"不改")
+ userAgent: str(v["sites.userAgent"]),
+ proxyUrl: str(v["sites.proxyUrl"]) || null,
+ },
+ }),
+};
+
export function SitesSection() {
return (
- 站点和凭证在「站点」页管理;单个站点的限流、周期、代理在站点的"编辑"里改。
+ 站点、凭证和 Cloudflare 验证在「站点」页管理;单个站点的限流、周期、代理在站点的「编辑」里改。
-
{(s) => }
+
+ {(s) => (
+ <>
+
+
+ >
+ )}
+
);
diff --git a/apps/web/app/components/settings/automation.tsx b/apps/web/app/components/settings/system.tsx
similarity index 50%
rename from apps/web/app/components/settings/automation.tsx
rename to apps/web/app/components/settings/system.tsx
index 1b2ad5d2..e1abee29 100644
--- a/apps/web/app/components/settings/automation.tsx
+++ b/apps/web/app/components/settings/system.tsx
@@ -1,103 +1,25 @@
-import { ChevronDown, Pause, Play, ShieldAlert } from "lucide-react";
-import { type ReactNode, useEffect, useState } from "react";
-import { useLocation } from "react-router";
+import { Pause, Play, ShieldAlert } from "lucide-react";
+import type { ReactNode } from "react";
import { toast } from "sonner";
import { useUpdateSettings } from "~/api/settings.ts";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { type FormValues, num } from "./resource-section.tsx";
-import { anchorId, SettingsBlock } from "./settings-block.tsx";
+import { SettingsBlock } from "./settings-block.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
-// 自动化分区:总开关、只读对账、缓存水位、每日下载预算、保留天数、后台任务并发(WK-1)、存储预算与保留(CACHE-2)
+// 系统分区:自动化总开关、只读对账、后台任务并发(WK-1)、存储预算与流水数据保留(CACHE-2)。
+// 下载盘水位与保种在「下载器」,每日下载预算在「获取」,缓存有效期在「缓存」。
-const GiB = 1024 ** 3;
-
-const CACHE_CARD: CardSpec = {
- anchor: "cache",
- title: "下载盘",
- description: "下载盘用量到暂停线时不再新增下载,并暂停本工具的下载任务;降到恢复线以下只恢复因此暂停的任务。",
- fields: [
- { name: "cache.hardThresholdPercent", label: "暂停线(%)", kind: "number" },
- { name: "cache.resumePercent", label: "恢复线(%)", kind: "number", hint: "低于暂停线" },
- { name: "cache.cleanupTargetPercent", label: "清理目标(%)", kind: "number", hint: "不高于恢复线" },
- { name: "cache.checkIntervalSec", label: "检查间隔(秒)", kind: "number", hint: "10–3600" },
- ],
- fromSettings: (s) => ({
- "cache.hardThresholdPercent": n(s.cache.hardThresholdPercent),
- "cache.resumePercent": n(s.cache.resumePercent),
- "cache.cleanupTargetPercent": n(s.cache.cleanupTargetPercent),
- "cache.checkIntervalSec": n(s.cache.checkIntervalSec),
- }),
- toPatch: (v) => ({
- cache: {
- hardThresholdPercent: num(v["cache.hardThresholdPercent"]),
- resumePercent: num(v["cache.resumePercent"]),
- cleanupTargetPercent: num(v["cache.cleanupTargetPercent"]),
- checkIntervalSec: num(v["cache.checkIntervalSec"]),
- },
- }),
-};
-
-const BUDGET_CARD: CardSpec = {
- anchor: "budget",
- title: "每日预算",
- description: "任一项到上限后不再开始新的下载;前两项按自然日计。",
- fields: [
- { name: "budget.maxNewTitlesPerDay", label: "每日新作品数", kind: "number" },
- { name: "budget.maxDownloadGiBPerDay", label: "每日下载量(GiB)", kind: "number" },
- { name: "budget.maxConcurrentDownloads", label: "最大并发下载", kind: "number", hint: "1–100" },
- ],
- fromSettings: (s) => ({
- "budget.maxNewTitlesPerDay": n(s.budget.maxNewTitlesPerDay),
- "budget.maxDownloadGiBPerDay": n(Math.round((s.budget.maxDownloadBytesPerDay / GiB) * 100) / 100),
- "budget.maxConcurrentDownloads": n(s.budget.maxConcurrentDownloads),
- }),
- toPatch: (v) => {
- const gib = num(v["budget.maxDownloadGiBPerDay"]);
- return {
- budget: {
- maxNewTitlesPerDay: num(v["budget.maxNewTitlesPerDay"]),
- maxDownloadBytesPerDay: gib === undefined ? undefined : Math.round(gib * GiB),
- maxConcurrentDownloads: num(v["budget.maxConcurrentDownloads"]),
- },
- };
- },
-};
-
-const RETENTION_CARD: CardSpec = {
- anchor: "retention",
- title: "保留",
- fields: [
- {
- name: "retention.seedRetentionDays",
- label: "源文件保留天数",
- kind: "number",
- hint: "复制入库后下载源在缓存里至少留这么久;0–365",
- },
- { name: "retention.logRetentionDays", label: "日志保留天数", kind: "number" },
- ],
- fromSettings: (s) => ({
- "retention.seedRetentionDays": n(s.retention.seedRetentionDays),
- "retention.logRetentionDays": n(s.retention.logRetentionDays),
- }),
- toPatch: (v) => ({
- retention: {
- seedRetentionDays: num(v["retention.seedRetentionDays"]),
- logRetentionDays: num(v["retention.logRetentionDays"]),
- },
- }),
-};
-
-// CACHE-2:存储预算、流水保留期、缓存有效期(settings.storage,docs/runbook/storage.md)
+// CACHE-2:存储预算、流水保留期(settings.storage,docs/runbook/storage.md)
const gbField = (k: string, label: string, hint?: string) => ({
name: `storage.budgets.${k}`,
label,
kind: "number" as const,
hint,
});
-const dayField = (group: "retentionDays" | "cacheTtl", k: string, label: string, hint?: string) => ({
+const dayField = (group: "retentionDays", k: string, label: string, hint?: string) => ({
name: `storage.${group}.${k}`,
label,
kind: "number" as const,
@@ -108,7 +30,7 @@ const pick = (v: FormValues, prefix: string, keys: readonly string[]) =>
// WK-1:io worker 三条道的并发(settings.automation.workerLanes,docs/runbook/dev.md「IO Worker 分道」)
const LANE_KEYS = ["file", "network", "maintenance"] as const;
-const WORKER_LANES_CARD: CardSpec = {
+export const WORKER_LANES_CARD: CardSpec = {
anchor: "worker-lanes",
title: "后台任务并发",
description:
@@ -134,7 +56,7 @@ const WORKER_LANES_CARD: CardSpec = {
};
const BUDGET_KEYS = ["pgDataGb", "pgDataWarnGb", "imagesGb", "backupsGb", "sessionsEvidenceGb"] as const;
-const STORAGE_BUDGET_CARD: CardSpec = {
+export const STORAGE_BUDGET_CARD: CardSpec = {
anchor: "storage",
title: "存储预算",
description: "超过预算时健康页告警;图片缓存超出容量按最久未访问删除。单位 GB。",
@@ -159,9 +81,9 @@ const RETENTION_KEYS = [
"capabilityStats",
"identityPoolArchive",
] as const;
-const STORAGE_RETENTION_CARD: CardSpec = {
+export const STORAGE_RETENTION_CARD: CardSpec = {
anchor: "storage-retention",
- title: "流水数据保留",
+ title: "数据保留",
description: "到期由定时清理任务分批删除。单位天,心跳为小时。",
fields: [
dayField("retentionDays", "observation", "RSS 抓取记录"),
@@ -183,22 +105,6 @@ const STORAGE_RETENTION_CARD: CardSpec = {
toPatch: (v) => ({ storage: { retentionDays: pick(v, "storage.retentionDays", RETENTION_KEYS) } }),
};
-const TTL_KEYS = ["tmdbSearchDays", "tmdbDetailDays", "tmdbMissDays", "siteDetailDays", "webSearchDays"] as const;
-const STORAGE_TTL_CARD: CardSpec = {
- anchor: "storage-ttl",
- title: "缓存有效期",
- description: "单位天。追踪中的作品命中 TMDB 详情缓存时自动续期。",
- fields: [
- dayField("cacheTtl", "tmdbSearchDays", "TMDB 搜索与识别结果"),
- dayField("cacheTtl", "tmdbDetailDays", "TMDB 详情"),
- dayField("cacheTtl", "tmdbMissDays", "TMDB 空结果", "搜索无结果、未识别时的负缓存"),
- dayField("cacheTtl", "siteDetailDays", "站点详情页内容"),
- dayField("cacheTtl", "webSearchDays", "网页搜索结果"),
- ],
- fromSettings: (s) => Object.fromEntries(TTL_KEYS.map((k) => [`storage.cacheTtl.${k}`, n(s.storage.cacheTtl[k])])),
- toPatch: (v) => ({ storage: { cacheTtl: pick(v, "storage.cacheTtl", TTL_KEYS) } }),
-};
-
function ToggleRow({
id,
icon,
@@ -239,46 +145,23 @@ function ToggleRow({
);
}
-/** 高级:只读对账、后台任务并发、存储预算、流水保留、缓存有效期;默认收起,从 #automation/advanced 进来时展开 */
-function Advanced({ children }: { children: ReactNode }) {
- const location = useLocation();
- const [open, setOpen] = useState(() => location.hash.endsWith("/advanced"));
- useEffect(() => {
- if (location.hash.endsWith("/advanced")) setOpen(true);
- }, [location.hash]);
- return (
-
-
-
- 只读对账、后台任务并发、存储预算、数据保留和缓存有效期。一般不用改。
-
- {open ? {children}
: null}
-
- );
-}
-
function ReadOnlySwitch() {
const update = useUpdateSettings();
return (
{(s) => (
-
+
}
tone={s.automation.readOnlyReconcile ? "warning" : "success"}
title={`只读对账 · ${s.automation.readOnlyReconcile ? "开启" : "关闭"}`}
- description="从备份恢复后先开启:只核对库存与路径,不做自动写入。核对完再关闭。"
+ description="只核对库存与路径,不做自动写入。"
checked={s.automation.readOnlyReconcile}
disabled={update.isPending}
onChange={(v) =>
@@ -307,7 +190,7 @@ function Switches() {
return (
{(s) => (
-
+
+
{(s) => (
<>
-
-
-
-
-
-
-
-
-
-
+
+
+
>
)}
diff --git a/apps/web/app/routes/console/settings.tsx b/apps/web/app/routes/console/settings.tsx
index 508dee79..fba42b21 100644
--- a/apps/web/app/routes/console/settings.tsx
+++ b/apps/web/app/routes/console/settings.tsx
@@ -2,9 +2,18 @@ import { settingsSectionForLegacyTab } from "@umt/contracts";
import { useEffect } from "react";
import { Link, redirect, useLocation, useNavigate } from "react-router";
import { PageHeader } from "~/components/console/page-header.tsx";
-import { parseSettingsHash, SECTIONS, settingsHash } from "~/components/settings/sections.tsx";
+import { parseSettingsHash, SECTION_GROUPS, settingsHash } from "~/components/settings/sections.tsx";
import { anchorId } from "~/components/settings/settings-block.tsx";
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
+import { SettingsSearch } from "~/components/settings/settings-search.tsx";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from "~/components/ui/select.tsx";
import { cn } from "~/lib/utils.ts";
export const meta = () => [{ title: "设置 · UHD 媒体自动化控制台" }];
@@ -20,21 +29,32 @@ export function clientLoader({ request }: { request: Request }) {
return redirect(`${url.pathname}${rest ? `?${rest}` : ""}${hit ? settingsHash(hit.section, hit.anchor) : ""}`);
}
+/** 搜索选中字段时经 location.state 带过来,对齐到节后聚焦这个字段 */
+type NavState = { field?: string } | null;
+
export default function SettingsPage() {
const location = useLocation();
const navigate = useNavigate();
- const { section, anchor } = parseSettingsHash(location.hash);
+ const { section, anchor, moved } = parseSettingsHash(location.hash);
+ const field = (location.state as NavState)?.field ?? null;
- // 切分区回到顶部;带节锚点时对齐到该节。节内容多是异步加载的(上游列表、能力清单),
+ // 旧锚点(重排前的 #automation/cache 等)换成新位置,不留历史记录
+ useEffect(() => {
+ if (moved) navigate({ hash: settingsHash(section.id, anchor) }, { replace: true, state: location.state });
+ }, [moved, section.id, anchor, navigate, location.state]);
+
+ // 切分区回到顶部;带节锚点时对齐到该节(有字段时对齐到字段并聚焦)。节内容多是异步加载的(上游列表、能力清单),
// 上方内容变高会把目标挤下去,所以在 2.5 秒内反复校正,直到用户自己滚动为止。
// biome-ignore lint/correctness/useExhaustiveDependencies: location.key 让重复点同一锚点也能再跳一次
useEffect(() => {
+ if (moved) return;
if (!anchor) {
window.scrollTo({ top: 0 });
return;
}
const started = performance.now();
let stopped = false;
+ let focused = false;
let timer = 0;
const stop = () => {
stopped = true;
@@ -43,10 +63,20 @@ export default function SettingsPage() {
for (const e of events) window.addEventListener(e, stop, { passive: true });
const align = () => {
if (stopped) return;
- const el = document.getElementById(anchorId(section.id, anchor));
- if (el) {
- const margin = Number.parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
- if (Math.abs(el.getBoundingClientRect().top - margin) > 2) el.scrollIntoView({ block: "start" });
+ const fieldEl = field ? document.getElementById(`f-${field}`) : null;
+ if (fieldEl) {
+ const r = fieldEl.getBoundingClientRect();
+ if (r.top < 80 || r.bottom > window.innerHeight - 40) fieldEl.scrollIntoView({ block: "center" });
+ if (!focused) {
+ focused = true;
+ fieldEl.focus({ preventScroll: true });
+ }
+ } else {
+ const el = document.getElementById(anchorId(section.id, anchor));
+ if (el) {
+ const margin = Number.parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
+ if (Math.abs(el.getBoundingClientRect().top - margin) > 2) el.scrollIntoView({ block: "start" });
+ }
}
if (performance.now() - started < 2500) timer = window.setTimeout(align, 120);
};
@@ -55,72 +85,111 @@ export default function SettingsPage() {
window.clearTimeout(timer);
for (const e of events) window.removeEventListener(e, stop);
};
- }, [section.id, anchor, location.key]);
+ }, [section.id, anchor, field, moved, location.key]);
return (
<>
-
-
- {/* 窄屏:顶部下拉 */}
-
+
+
+ {/* 窄屏:搜索 + 分区下拉 + 节横排 */}
+
+
-
{section.description}
+
{section.description}
+ {section.anchors.length > 1 ? (
+
+ ) : null}
- {/* 宽屏:左侧分区导航 */}
-