+ {/* 宽屏:搜索 + 左侧分组导航 */}
+
+
+
+
+
+
{/* 分区页头:标题 + 一句说明;窄屏由上方下拉承担,标题只留给读屏 */}
diff --git a/docs/design/console-ia.md b/docs/design/console-ia.md
index 2039059b..5e3d4c55 100644
--- a/docs/design/console-ia.md
+++ b/docs/design/console-ia.md
@@ -147,33 +147,32 @@
重定向统一写在各自路由模块的 `clientLoader` 里(或新增 `routes/console/redirects.ts` 集中表),`nav.test.ts` 增加"旧链接都能落到新路由"的用例。
-### 3.4 设置页分区(按接入对象)
+### 3.4 设置页分区(2026-10-06 重排,替换 IA-2 方案)
-| 分区 `section` | 字段 | 来自现有 | 存储 |
-| --- | --- | --- | --- |
-| 自动化 `automation` | 总开关、只读对账;**缓存**:硬阈值/恢复/清理目标/检查间隔;**每日预算**:新作品数、下载量、并发;**保留**:做种保留天数、日志保留天数 | 自动化 + 缓存与入库(①③ + 保留) | `automation` `cache` `budget.下载三项` `retention` |
-| 站点与账号 `sites` | 站点登记(名称、类型、地址、启用)、账号凭证(标签/类型/登录名/凭证)、浏览器服务(provider、能力 supported/verified 只读) | 站点凭证 + **新增站点表单**(C1)+ `browser.ts` | sites / secrets / browser API |
-| 下载器 `downloaders` | qB 实例全字段 + 健康 + 测试 | 下载器 | instances |
-| 媒体库与目录 `library` | 锚点 `#roots` 库根;`#mappings` 路径映射(下挂在库根下展示);`#organize` 整理默认:默认 Copy/Move、跨卷 Move | 库根 + 路径映射 + 缓存与入库② | instances + `organize` |
-| 媒体服务器 `media-servers` | Emby/Jellyfin 实例 | 媒体服务器 | instances |
-| 元数据 `metadata` | TMDB Key(只写)、TMDB Base URL、状态(已配置 / 回退环境变量 / 未配置) | **新增 UI**(C5) | `metadata` |
-| Agent `agent` | `#upstream` 上游与模型(provider/model/baseUrl/Key/思考/上下文/压缩/子代理);`#budget` 每日次数/费用 + 单次上限;`#investigate` 自动调查开关/模式/次数/核实时长/工具上限;`#advanced` 目标轮询、会话目录、fake 脚本 | Pi 上游(3 卡) | `pi` `agent` `budget.agent*` |
-| API Key `api-keys` | 签发给外部程序的 Key(scope、撤销);页首说明"浏览器自用 token 在右上角账户菜单"(C9) | API Key | api-keys |
-| 账户与安全 `account` | 修改密码、会话注销、本机 token | 账户菜单(菜单保留快捷入口) | auth |
+沿用 MP 的两条逻辑:**按「接入对象 / 业务环节」分区**,**对象管理与参数设定分开**;不照抄它「系统」Tab 过载的做法(下载器、媒体服务器、AI、TMDB 全塞一页再套子 Tab)。左栏三组,组内按用户找东西的顺序排;每个分区一句说明;接入对象类分区第一节是对象列表(实例 / 目录 / 模型服务),之后才是全局参数。分区、节、字段归属登记在 `packages/contracts/src/settings.ts` 的 `SETTINGS_SECTIONS`,测试断言每个在用的存储字段恰好归属一个分区、且出现在该分区页面上。
+
+| 组 | 分区 `id` | 一句说明 | 节(锚点) | 存储 / 对象 |
+| --- | --- | --- | --- | --- |
+| 接入 | 下载器 `downloaders` | qBittorrent 实例与路径映射;下载盘水位和保种天数的缺省值,实例里可以单独改 | instances 实例 · cache 下载盘 · seeding 保种 | 实例(含路径映射、单实例暂停线 / 恢复线 / 清理目标 / 保种天数 / H&R 未知也可清理);`cache` 水位四项;`retention.seedRetentionDays` |
+| 接入 | 媒体服务器 `media-servers` | Emby / Jellyfin 实例与路径映射,只用来查作品有没有 | instances | 实例 |
+| 接入 | 站点与网络 `sites` | 站点缺省值、User-Agent 与代理、浏览器服务;站点、凭证和 Cloudflare 在站点页 | defaults 站点缺省值 · network UA 与代理 · browser 浏览器服务 | `sites.*`;浏览器服务 |
+| 接入 | 元数据 `metadata` | TMDB 接入、语言回退与特别篇 | tmdb · language 语言与季集 | `metadata.*` |
+| 流程 | 获取 `acquire` | 每日下载预算、搜索站点、资源识别;订阅和规则在各自页面 | budget 每日预算 · search 搜索站点 · identity 资源识别 | `budget.*`、`search.*`、`identity.*` |
+| 流程 | 媒体库与目录 `library` | 媒体库目录、整理来源、分类规则、命名模板 | roots · sources · classification · naming · naming-roots | 媒体库目录;`organize.classificationYaml / sources`;`naming.*` |
+| 流程 | 整理与刮削 `organize` | 整理方式、最小视频体积、整理后自动刮削和已有文件的覆盖方式 | mode 整理方式 · scrape 刮削 | `organize.defaultMode / allowCrossVolumeMove / minSizeBytes / scrape*`(覆盖方式:更新 / 只补缺失 / 全部覆盖) |
+| 流程 | 智能体 `agent` | 模型服务、用量与防跑飞限制、自动处理、网页搜索、MCP | upstream · budget 用量与限制 · investigate 自动处理 · runtime 上下文与子会话 · websearch · mcp · capabilities · advanced | 模型服务;`pi.*`;`agent.*`(不设预算,只有单次工具调用次数与时长两个上限 + 花费统计;识别失败自动派智能体在「自动处理」) |
+| 系统 | 系统 `system` | 自动化总开关、只读对账、后台任务并发、存储预算、数据保留 | switches 自动化 · readonly · worker-lanes · storage · storage-retention | `automation.*`、`storage.budgets / retentionDays` |
+| 系统 | 缓存 `cache` | 各类缓存的条数、占用和有效期,可以按区清空 | regions 缓存区 · tmdb-cache · images · ttl 缓存有效期 | kv_cache 各区 + 图片缓存(`GET /cache/regions`、`POST /cache/regions/{region}/clear`、`/cache/tmdb*`、`/cache/images*`);`cache.tmdbMaxSize / globalImageCache*`;`storage.cacheTtl` |
+| 系统 | 账户与安全 `account` | 密码、二次验证、通行密钥、会话与本机 Token、API Key | password · totp · passkeys · session · api-keys | auth;API Key |
+| 系统 | 界面 `interface` | 只影响当前浏览器 | display · shortcuts | 浏览器本地偏好 |
说明:
-- "上游列表 / 默认上游"目前 contracts 只有单一 `pi.provider`,本次只把它放进 Agent 分区,多上游等 Agent 侧需求确定再做。
-- 备份与系统信息(版本、数据目录、备份/恢复入口)暂无后端,先不建空分区;只读对账已在"自动化"。
-- "预算"按**消耗谁**归属:下载预算归自动化,Agent 预算归 Agent,不再跨 Tab 拆半。
-
-### 3.5 `contracts/settings.ts` 调整建议
-
-**不改存储 key**(8 个 key 已在线上 settings 表,PATCH 语义按 key 合并)。改动只在"展示分区":
-
-1. 新增 `SETTINGS_SECTIONS` 常量(UI 与服务端共用):`{ id, label, description, legacyTab[], fields: ["cache.hardThresholdPercent", …] }`,前端表单和重定向都读它,测试断言 8 个 key 的每个字段恰好归属一个分区。
-2. `budget` 保留单 key,但在 `SETTINGS_SECTIONS` 里按前缀拆成两处展示(`maxNewTitlesPerDay/maxDownloadBytesPerDay/maxConcurrentDownloads` → 自动化;`agentMax*` → Agent)。
-3. 若后续确要把 `budget.agentMax*` 挪到 `agent.daily.*`:加一次读时迁移(`{...DEFAULT, ...stored}` 之前把旧字段搬过去并写回),`GlobalSettingsUpdate` 两个版本各保留一个发行周期,审计里记一条 `settings.migrated`。本次**不做**。
-4. `pi` 在 UI 文案一律叫"Agent 上游",key 名不变(避免 secret 引用与 PATCH 兼容问题)。
+- **对象 vs 参数**:实例上可覆盖的项(下载盘水位、保种天数)的缺省值放在同一分区的对象列表下面,两处用同一组名字(暂停线、恢复线、清理目标、保种天数)。
+- **预算按消耗谁归属**:下载预算归「获取」;智能体不设预算,只有防跑飞限制,归「智能体」。
+- **不进设置页**:站点凭证与 Cloudflare 处理(站点页)、订阅与规则(各自页面)、主密钥(部署环境变量)、通知(暂无渠道设置,不建空分区)。
+- **废弃字段**(`DEPRECATED_SETTINGS_FIELDS`)不显示:`pi` 旧上游字段、`cache.tmdbTtlHours / emptyResultTtlSec`、`identity.poolArchiveDays`、`retention.logRetentionDays`、智能体四个预算字段。
+- **搜索**:左栏顶部(窄屏在下拉上方)的搜索框按分区名、节名、节别名(`anchors[].keywords`,如「H&R」「Cloudflare」「Passkey」)和表单字段名定位;选中字段时滚到该字段并聚焦。
+- **旧锚点**:`SETTINGS_HASH_ALIASES` 把重排前的 hash 换到新位置(地址栏 replace,不留历史),见 §6。
### 3.6 页面内布局规范
@@ -270,24 +269,32 @@
- `components/agent/quick-goals.ts` 里的"待识别池"文案(W7-B 范围)。
- 原站视图仍是占位。
-## 6. 设置分区已实施对照(IA-2,2026-09-28)
+## 6. 设置分区已实施对照(2026-10-06 重排;替换 IA-2)
-路由保持 `/console/settings`(routes.ts 归 IA-1),分区用 URL hash 定位:`#<分区>` 或 `#<分区>/<节>`;旧 `?tab=` 由设置页 `clientLoader` 跳到对应 hash。
-分区定义在 `packages/contracts/src/settings.ts` 的 `SETTINGS_SECTIONS`(label、说明、锚点、字段、`legacyTabs`),测试断言 8 个存储 key 的每个字段恰好归属一个分区。组件在 `apps/web/app/components/settings/`。
+路由仍是 `/console/settings`,分区用 URL hash 定位:`#<分区>` 或 `#<分区>/<节>`;旧 `?tab=` 由设置页 `clientLoader` 跳到对应 hash,旧 hash 由 `resolveSettingsHash` 换到新位置。分区与说明见 §3.4;组件在 `apps/web/app/components/settings/`(每个分区一个文件:`downloaders` `instances`(媒体服务器)`sites` `metadata` `acquire` `library` `organize` `agent` `system` `cache` `account` `interface`),搜索索引在 `search-index.ts`。
-| 分区 | 节(锚点) | 实际内容 | 与 §3.4 的差异 |
-| --- | --- | --- | --- |
-| 自动化 `automation` | switches / cache / budget / retention | 总开关、只读对账(切换即生效);缓存水位 4 项;每日新作品数、下载量、并发;源文件保留天数、日志保留天数 | 无 |
-| 站点与账号 `sites` | sites / accounts | 站点列表(启停开关、快照周期与新鲜度、账号数、健康);抽屉改地址、定时快照、快照周期、限流三项,并录入本站凭证;从内置站点定义登记新站点;账号列表(启停、删除)+ 录入凭证抽屉 | 浏览器服务能力未放进来(无对应写接口) |
-| 下载器 `downloaders` | instances | qB 实例 CRUD + 连通测试;路径映射并入实例抽屉(只列本实例的映射,可加、校验、删) | §3.4 把映射放在"媒体库与目录",按 IA-2 任务改为挂在实例抽屉;`?tab=path-mappings` → `#downloaders` |
-| 媒体库与目录 `library` | roots / organize | 库根 CRUD + 哨兵检查;默认整理方式(复制/移动)、允许跨卷移动 | 没有命名模板字段,"整理默认"只有这两项 |
-| 媒体服务器 `media-servers` | instances | Emby/Jellyfin CRUD + 测试;抽屉里同样挂本实例的路径映射 | 无 |
-| 元数据 `metadata` | tmdb | TMDB API Key(只写,显示已保存/清除)、Base URL | 语言、刷新周期在 contracts 里没有字段,未做 |
-| Agent `agent` | upstream / runtime / budget / investigate / advanced | 上游列表(名称、provider、认证、模型、状态、默认)+ 新建向导(选 provider → 凭据与模型 → 登录/测试/设默认)+ 编辑抽屉(改配置、登录/重新登录/退出、测试、设默认、删除);上下文预算、压缩、子代理;每日与单次预算;自动调查;高级(轮询、等待、会话目录、fake 脚本) | 多上游已由 W7-A 落地,§3.4 说明里"多上游等 Agent 侧需求"作废;旧 `pi.provider/model/baseUrl/apiKey` 字段不再展示 |
-| API Key `api-keys` | keys | 列表、创建(明文只显示一次)、撤销、scope 说明 | 无 |
-| 账户与安全 `account` | password / totp / session / shortcuts | 改密、TOTP 开启/关闭、当前会话到期与退出、本机 Token、Agent 快捷键 | 后端没有会话列表/吊销接口,只能看当前会话;改密会注销其它会话 |
+页面:宽屏左栏 = 搜索框 + 三组分区(当前分区展开节列表),右侧分区标题 + 一句说明 + 各节;窄屏 = 搜索框 + 分组下拉 + 一句说明 + 节的横排胶囊。每节有标题与说明;全局设置类的节有「未保存」标记、节尾保存条(还原 / 保存),保存后提示「已保存「节名」」;实例、站点、模型服务类在抽屉里保存;危险操作(清空缓存、删除)二次确认。
-每节独立保存:全局设置类的节有"未保存"标记、保存/还原按钮,保存后 sonner 一句;实例、站点、上游类在抽屉里保存。窄屏左栏折叠为顶部下拉。
+旧锚点映射(`SETTINGS_HASH_ALIASES`;同名的 `#downloaders`、`#agent/*`、`#sites/*`、`#library/{roots,sources,classification,naming}`、`#metadata/tmdb` 不变):
+
+| 旧 | 新 |
+| --- | --- |
+| `#automation`、`#automation/switches` | `#system`、`#system/switches` |
+| `#automation/cache`(`?tab=storage`) | `#downloaders/cache` |
+| `#automation/budget` | `#acquire/budget` |
+| `#automation/retention` | `#downloaders/seeding`(日志保留天数已废弃) |
+| `#automation/advanced`、`#automation/readonly` | `#system/readonly` |
+| `#automation/{worker-lanes,storage,storage-retention}` | `#system/` 同名节 |
+| `#automation/storage-ttl` | `#cache/ttl` |
+| `#automation/<其它>` | `#system` |
+| `#library/organize`、`#library/scrape` | `#organize/mode`、`#organize/scrape` |
+| `#metadata/identity` | `#acquire/identity` |
+| `#metadata/tmdb-cache`、`#metadata/images` | `#cache/tmdb-cache`、`#cache/images` |
+| `#api-keys`、`#api-keys/keys`(`?tab=api-keys`) | `#account/api-keys` |
+| `#account/shortcuts` | `#interface/shortcuts` |
+| `#sites/accounts` | `#sites` |
+
+遗留:`components/console/nav.ts` 的 `SettingsSection` 类型仍是旧分区名(`automation`、`api-keys` 靠别名跳转可用),新分区 `acquire / organize / system / cache / interface` 要等 nav.ts 的 owner 补进类型后才能用 `settingsPath()` 直接深链。
## 7. IA-3 已实施对照(FE-1,2026-09-28)
diff --git a/docs/runbook/backup-restore.md b/docs/runbook/backup-restore.md
index a69b54b2..7874b67e 100644
--- a/docs/runbook/backup-restore.md
+++ b/docs/runbook/backup-restore.md
@@ -62,7 +62,7 @@ docker compose --env-file .env.deploy up -d app
2. 下载器:下载页核对 qB 里的任务与库里的下载任务(托管标签)一致;路径映射"验证"。
3. 整理记录 / 资产位置:抽查最近的整理记录,目标文件确实存在。
4. 凭证:打开一个站点/下载器"测试",能通过说明主密钥一致。
-5. 确认无误后关闭:设置 → 自动化 → 关闭"只读对账",或
+5. 确认无误后关闭:设置 → 系统 → 关闭「只读对账」,或
`PATCH /api/v1/settings {"automation":{"readOnlyReconcile":false}}`。
## 开发机上用
diff --git a/docs/runbook/cache.md b/docs/runbook/cache.md
index 176bd935..0e6b2ab6 100644
--- a/docs/runbook/cache.md
+++ b/docs/runbook/cache.md
@@ -53,7 +53,7 @@ API:`GET /api/v1/cache/tmdb?kind=recognize|request&limit=` 列出条目(键 /
- 清理:定时任务 `cache.images_clean`(每天)删除 mtime 早于 `cache.globalImageCacheDays`(缺省 7)天的文件。
- 容量 LRU(CACHE-2):`cache.images_lru`(30 分钟)在总量超过 `storage.budgets.imagesGb`(缺省 20 GB)时按最久未访问删到 90%;
读盘命中会刷新文件 atime。见 [storage.md](storage.md)。
-- 用量 / 清空:`GET /api/v1/cache/images`、`POST /api/v1/cache/images/clear`(仅 admin);设置 → 元数据 → 图片代理与缓存。
+- 用量 / 清空:`GET /api/v1/cache/images`、`POST /api/v1/cache/images/clear`(仅 admin);设置 → 缓存(缓存区表里的「图片」行可清空;「图片缓存」节开关与保留天数)。按区统计与清空通用缓存:`GET /api/v1/cache/regions`、`POST /api/v1/cache/regions/{region}/clear`(仅 admin),设置 → 缓存 → 缓存区。
前端:作品库列表、作品详情、搜索面板的 TMDB 候选在有 `posterPath`(metadata.posterPath / TMDB 候选)时显示海报,
加载失败回退原来的无彩占位。TMDB 详情刷新会把 posterPath / backdropPath 写进 metadata_field。
diff --git a/docs/runbook/deploy.md b/docs/runbook/deploy.md
index f340442f..be861f02 100644
--- a/docs/runbook/deploy.md
+++ b/docs/runbook/deploy.md
@@ -127,7 +127,7 @@ docker compose --env-file .env.deploy logs app | grep 'setup token'
| `PUID` / `PGID` | `10001` | 运行用户 |
| `DATA_DIR` | `/data` | 应用数据目录(开发机缺省 `./data`) |
| `AGENT_SESSION_DIR` | `DATA_DIR/agent-sessions` | Agent 会话目录 |
-| `WORKER_FILE_CONCURRENCY` / `WORKER_NETWORK_CONCURRENCY` / `WORKER_MAINTENANCE_CONCURRENCY` | 空 | io worker 文件 / 网络 / 维护三条道的并发;空 = 用 设置 → 自动化 → 高级 →「后台任务并发」(缺省 2 / 4 / 1,热生效),设了以变量为准。见 dev.md「IO Worker 分道」 |
+| `WORKER_FILE_CONCURRENCY` / `WORKER_NETWORK_CONCURRENCY` / `WORKER_MAINTENANCE_CONCURRENCY` | 空 | io worker 文件 / 网络 / 维护三条道的并发;空 = 用 设置 → 系统 →「后台任务并发」(缺省 2 / 4 / 1,热生效),设了以变量为准。见 dev.md「IO Worker 分道」 |
| `WORKER_CONCURRENCY` | 空 | 旧变量(原 io 组总并发),现映射到网络道(`WORKER_NETWORK_CONCURRENCY` 未设时) |
| `WORKER_AGENT_CONCURRENCY` | `2` | agent 组 worker 并发槽(每个 Agent 会话占一槽直到结束) |
| `ORGANIZE_WALK_MAX_FILES` | `5000` | 手动按路径整理时递归遍历的文件数上限;dryRun 超出返回 `truncated: true`,正式执行拒绝 |
diff --git a/docs/runbook/dev.md b/docs/runbook/dev.md
index 2f8c4bd7..21b45ad1 100644
--- a/docs/runbook/dev.md
+++ b/docs/runbook/dev.md
@@ -78,7 +78,7 @@ io worker 进程内按任务声明的 `lane` 起三个引擎 worker,各自独立
判断有 1s 滞后,够用且简单。
- **调度器内联的维护任务**:`ScheduledJobDef.lane = "maintenance"` 的定时任务(下表)在调度器里直接执行,与维护道共用并发与让路规则:
进不了维护道就保持到期、下一轮再试;在计划页点「立即执行」不让路。其余定时任务只入队或很快结束,不占道。
-- **并发配置**:设置 → 自动化 → 高级 →「后台任务并发」(`settings.automation.workerLanes`),保存后约 5 秒内热生效(每道开 16 个槽,
+- **并发配置**:设置 → 系统 →「后台任务并发」(`settings.automation.workerLanes`),保存后约 5 秒内热生效(每道开 16 个槽,
按设置值收)。环境变量 `WORKER_FILE_CONCURRENCY` / `WORKER_NETWORK_CONCURRENCY` / `WORKER_MAINTENANCE_CONCURRENCY` 设了就以变量为准
(运行状态页悬停提示"由环境变量指定")。旧变量 `WORKER_CONCURRENCY` 映射到**网络道**(`WORKER_NETWORK_CONCURRENCY` 未设时):它原来
是 io 组总并发,缺省 4,与网络道缺省相同;文件 / 维护道按设置页走。compose 不再给 `WORKER_CONCURRENCY` 缺省值。
diff --git a/docs/runbook/identity.md b/docs/runbook/identity.md
index 8de5ba66..37131186 100644
--- a/docs/runbook/identity.md
+++ b/docs/runbook/identity.md
@@ -73,7 +73,7 @@
| RSS / 列表批量入库(`observed`) | 否。订阅相关的推迟(`not_now:batch_ingest`,15 分钟内复查时再抓);其余不抓 |
| 上次因限额推迟(`pending`)的复查 | 是 |
-每站每小时上限 `identity.detailIdsPerSiteHour`(设置 → 元数据 → 资源识别「识别时每站每小时最多抓详情页」,缺省 10,0 = 不抓),
+每站每小时上限 `identity.detailIdsPerSiteHour`(设置 → 获取 → 资源识别「识别时每站每小时最多抓详情页」,缺省 10,0 = 不抓),
计数在 `kv_cache` region `identity.detail_ids`(key `:<小时>`,原子自增,2 小时过期)。达到上限记 `deferred:cap_reached`、
15 分钟内复查。这些详情请求同样计入站点请求统计(`site_request_count`)。
@@ -221,7 +221,7 @@ TMDB 默认季有时和发布者、媒体服务器的分季不一样(最常见:
## 设置
-设置 → 元数据 → 资源识别:
+设置 → 获取 → 资源识别:
- `identity.eagerTmdbLookup`(缺省关):打开即旧行为,每条新资源都查 TMDB。每条约 1–2 次搜索 + 命中时 1 次详情
(识别缓存命中为 0);每小时 100 条的 RSS 约每天 2400–7200 次请求,站点独有的短剧 / 综艺多半查不到。
@@ -344,7 +344,7 @@ TMDB 查不到的作品(短剧、平台独播剧)按标题查网页,出一份带
- 网页:网页标题(去掉「_高清完整版…」「_百度百科」等后缀)必须对得上;只看本作品主体(「相关短剧」「热门短剧」「猜你喜欢」之后是别的作品);集数取正文句式,没有时取选集列表「1 2 … N」;平台页的平台名按域名;海报只取 og:image。
- 模型(可选):默认 Agent 上游做一次补全,输入是读过的网页和用上的摘要;模型给的值必须以本次的网页或摘要为出处,原文里有的按该来源置信度减 0.05,改写 / 归纳的按 0.3;海报不是网页 og:image 的丢弃。fake 上游、没有上游、失败或输出不是 JSON 时只用规则抽取(`extractor: rule`)。
8. 合并:每个字段把候选值按归一化值分组,同组不同网站的置信度按 noisy-OR 叠加(同一网站只算一次),取最高的一组,低于 0.3 的丢弃;主演 / 类型 / 别名保留过线的前 10 / 5 / 10 个。每个值带 `sourceKind`、`confidence`、`hosts`(给出同一值的网站数)。证据等级:原文里有、由规则抽出且出处不是「搜索摘要」类 → `rule_confirmed`;两个以上不同网站给出 → `multi_evidence`;其余 `needs_review`。
-9. 命中率:`usage.attempts` 记每类来源的 `results`(结果标题对得上的条数)、`fetched`、`hit`(建议里至少一个字段有它支持)、`fields`。`GET /identity/enrich-stats?days=30`(read)返回最近 N 天发起过搜索的补全次数、出建议次数、各类来源的「有结果 / 命中 / 命中率」;设置 → 元数据 → 资源识别 下方有这张表。命中 7 天缓存的不新建记录,不计入。
+9. 命中率:`usage.attempts` 记每类来源的 `results`(结果标题对得上的条数)、`fetched`、`hit`(建议里至少一个字段有它支持)、`fields`。`GET /identity/enrich-stats?days=30`(read)返回最近 N 天发起过搜索的补全次数、出建议次数、各类来源的「有结果 / 命中 / 命中率」;设置 → 获取 → 资源识别 下方有这张表。命中 7 天缓存的不新建记录,不计入。
10. 存 `metadata_enrichment`(status suggested / failed、suggestion、usage:搜索 / 读取次数与 token),不写作品表。
`web.fetch` 输出新增 `image`(页面 og:image / twitter:image,绝对地址,已脱敏)。
@@ -366,7 +366,7 @@ TMDB 查不到的作品(短剧、平台独播剧)按标题查网页,出一份带
作品详情「元数据」里来源显示「网页」/「Agent」,下面一行是证据等级和出处链接;网页海报经 `GET /api/v1/img/enrich/{记录id}` 代理(只取该记录里白名单网页给出的图片,出处仍须在白名单;不接受任意 URL;地址守卫同 web.fetch,最多 3 跳、5 MB、只收图片)。
-### 设置(设置 → 元数据 → 资源识别)
+### 设置(设置 → 获取 → 资源识别)
| 字段 | 缺省 | 说明 |
|---|---|---|
diff --git a/docs/runbook/metadata.md b/docs/runbook/metadata.md
index 3962cec1..894f0c3f 100644
--- a/docs/runbook/metadata.md
+++ b/docs/runbook/metadata.md
@@ -2,10 +2,10 @@
## 语言回退链
-- 设置 → 元数据 → TMDB →「语言回退链」,即 `settings.metadata.languages`,缺省 `zh-CN, zh-SG, zh-TW, zh-HK, en-US`。
+- 设置 → 元数据 → 语言与季集 →「语言回退链」,即 `settings.metadata.languages`,缺省 `zh-CN, zh-SG, zh-TW, zh-HK, en-US`。
- 作品标题:按链逐个语言查 TMDB `translations`(语言 + 地区精确匹配);该语言译名为空时查同地区、文字相符的 `alternative_titles`(类型为空的优先);原始语言等于链中语言时原名也算。全部落空用请求语言下的 name / 原名。对照 MP `__update_tmdbinfo_cn_title`(中文名 → CN 别名 → SG 译名)。
- 中文名 `titleZh`:只在链里的中文语言中按顺序取(zh-CN 优先),值必须含汉字。链首改成 en-US 时标题变英文,`titleZh` 不变。
-- 简介 / 标语(MD-2):只按「简介链」取 `translations` 的第一个非空值,取不到留空(界面显示「暂无简介」)。简介链 = 回退链里第一个中文之前的语言 + 链里全部中文,之后的非中文去掉;缺省链得到 `zh-CN, zh-SG, zh-TW, zh-HK`。链里没有中文时简介链就是原链。设置 → 元数据 → TMDB「简介回退到非中文」(`metadata.overviewNonChineseFallback`,缺省关)打开后简介链 = 原链(旧行为)。原因:TMDB 上很多剧集(如《心动的信号》103147)只有英文"简介",而且常是标题英译;MP 每集信息只按配置语言取,简介为空就为空。请求语言下的原值若等于链外某语言的译文(TMDB / 反代把缺失字段回退了),不当作链内的值。标题 / 别名不受影响。
+- 简介 / 标语(MD-2):只按「简介链」取 `translations` 的第一个非空值,取不到留空(界面显示「暂无简介」)。简介链 = 回退链里第一个中文之前的语言 + 链里全部中文,之后的非中文去掉;缺省链得到 `zh-CN, zh-SG, zh-TW, zh-HK`。链里没有中文时简介链就是原链。设置 → 元数据 → 语言与季集「简介回退到非中文」(`metadata.overviewNonChineseFallback`,缺省关)打开后简介链 = 原链(旧行为)。原因:TMDB 上很多剧集(如《心动的信号》103147)只有英文"简介",而且常是标题英译;MP 每集信息只按配置语言取,简介为空就为空。请求语言下的原值若等于链外某语言的译文(TMDB / 反代把缺失字段回退了),不当作链内的值。标题 / 别名不受影响。
- 季 / 集:先按链首请求 `/tv/{id}/season/{n}`;已播出的集缺标题(TMDB 占位名"第 N 集""Episode N")或缺简介时,按链逐个语言补拉同一端点,补齐即停(简介只从简介链里的语言取;只缺简介且剩下的语言都不在简介链里时不再补拉);链走完仍没有标题的集保留占位名(语言记为空)。未播出的集不触发补拉。
- 每个字段的来源值写 `metadata_field`,`language` 记实际取到的语言;同字段语言变了,旧语言的 tmdb 行删掉。
- `translations` 与 `alternative_titles` 写 `title_alias`(来源 tmdb,标语言;别名只在地区与文字相符时标,如 CN + 汉字 → zh-CN)。已有同文本别名(繁简归一化后同键)不重复登记,只给语言为空的 tmdb 行补语言。
@@ -21,7 +21,7 @@
- 新建档时标题 / 简介当场按语言链取(富详情失败沿用基础详情),每集信息由随后入队的 full 刷新补齐,不用手动刷新。自动识别(RSS)新建的作品不入队 full,仍按追踪刷新。
- 刷新是后台任务 `refreshMetadata`(输入 `{mediaEntityId, full?}`),结果里 `titles` 是本次取到的标题 / 语言 / 中文名,`episodes[]` 是各季写入统计(`created` 新建单元数、`changedUnits` 有变化的单元数)。
- 增量:来源值和语言都没变的字段不写;只投影有变化的单元。不改编号、路径绑定和资产覆盖。
-- 季 0(特别篇,DH-1):设置 → 元数据 → TMDB「包含特别篇(季 0)」(`metadata.includeSpecials`,缺省开)。开时 full 刷新和存量回刷
+- 季 0(特别篇,DH-1):设置 → 元数据 → 语言与季集「包含特别篇(季 0)」(`metadata.includeSpecials`,缺省开)。开时 full 刷新和存量回刷
在 TMDB 有季 0 时一并拉取每集信息并建季 0 的季 / 集单元(对照 MP);季 0 不进正片季总集数账本,不影响订阅完成判定。关掉后
已建的季 0 单元保留,只是不再主动拉取。
- 临时转正式(DH-1):刷新时与 TMDB 季集结构对得上的临时(provisional)季 / 集单元转正式(`status = confirmed`、`source = tmdb`),
@@ -70,8 +70,8 @@ DATABASE_URL=… deno run -A scripts/overview-recompute.ts --apply
## TMDB 请求与缓存
-- 富详情一次请求:`/{type}/{id}?language=链首&append_to_response=alternative_titles,credits,external_ids,translations`;季详情每语言一次。都走 `tmdb.request` PG 缓存(TTL / 空结果短 TTL / 条数上限见设置 → 元数据 → TMDB 缓存;对照 MP `@cached`:成功才缓存、业务失败不缓存)。
-- 想立刻拿到 TMDB 刚改的数据:设置 → 元数据 → TMDB 缓存「清空」,再刷新。
+- 富详情一次请求:`/{type}/{id}?language=链首&append_to_response=alternative_titles,credits,external_ids,translations`;季详情每语言一次。都走 `tmdb.request` PG 缓存(TTL / 空结果短 TTL / 条数上限见设置 → 缓存 → TMDB 缓存;对照 MP `@cached`:成功才缓存、业务失败不缓存)。
+- 想立刻拿到 TMDB 刚改的数据:设置 → 缓存 → TMDB 缓存「清空」,再刷新。
- 仅 TMDB 作品详情页(`/console/media/tmdb/{type}/{id}`)的季在展开时拉 `GET /api/v1/media/tmdb/tv/{id}/season/{n}`,不落库。
## 媒体详情页
diff --git a/docs/runbook/storage.md b/docs/runbook/storage.md
index d9a0e319..c8b469f2 100644
--- a/docs/runbook/storage.md
+++ b/docs/runbook/storage.md
@@ -5,7 +5,7 @@
## 设置 `settings.storage`
-三组,都在「设置 → 自动化 → 存储与保留」(分区锚点 `storage`,UI 待接)。PATCH 可以只给子字段:
+三组,在「设置 → 系统」(存储预算、数据保留)与「设置 → 缓存」(缓存有效期)。PATCH 可以只给子字段:
```sh
PATCH /api/v1/settings {"storage":{"retentionDays":{"observation":60}}}
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;
+}
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);
+ }
+ });
+});