merge: ui2/w-settings
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
commit
83bb4c939a
38 files changed
+2088
-817
No files matched your search
@@ -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) ──
|
||||
|
||||
@@ -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<typeof CacheRegionStat>;
|
||||
|
||||
export const CacheRegionList = z.object({ items: z.array(CacheRegionStat) });
|
||||
export type CacheRegionList = z.infer<typeof CacheRegionList>;
|
||||
|
||||
/** region → 界面名;精确匹配优先,其次按前缀(`bangumi.*`);都不中返回 null(界面原样显示 region) */
|
||||
const CACHE_REGION_LABELS: Record<string, string> = {
|
||||
"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;
|
||||
}
|
||||
+256
-112
@@ -848,7 +848,7 @@ export const SettingEntry = z.object({
|
||||
});
|
||||
export type SettingEntry = z.infer<typeof SettingEntry>;
|
||||
|
||||
/** 已弃用、不归属任何设置分区的旧字段(见 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<typeof SettingsSectionId>;
|
||||
|
||||
export const SettingsGroupId = z.enum(["connect", "flow", "system"]);
|
||||
export type SettingsGroupId = z.infer<typeof SettingsGroupId>;
|
||||
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<Record<string, string>> = {
|
||||
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 };
|
||||
}
|
||||
@@ -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<string>(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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user