feat(web): 设置页按新分类重排,加搜索、缓存分区与旧锚点跳转

- 左栏三组分区 + 当前分区的节;窄屏为搜索框、分组下拉和节的横排胶囊
- 搜索框按分区、节、节别名和字段名定位,选中字段时滚到该字段并聚焦
- 旧 hash(#automation/cache、#api-keys、#library/scrape 等)replace 到新位置
- 下载器分区放下载盘水位与保种缺省值,名字与实例抽屉统一(暂停线 / 恢复线 / 保种天数)
- 新增获取、整理与刮削、系统、缓存分区;API Key 并入账户与安全,快捷键移到界面
- 补上缺的入口:最小视频体积、子会话并发与层数、搜索站点;日志保留天数(已废弃)不再显示
- 缓存分区按区列出条数、占用、最近写入,单区清空二次确认;TMDB 与图片缓存并入
- 保存提示统一为「已保存「节名」」;按入库口径把相关文案改成「整理」

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 18:01:26 +08:00
1 parent 93a6c8a910
commit 8071ad33f9
23 files changed
+1499 -642

No files matched your search

@@ -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<string, string> = {
"budget.maxDownloadGiBPerDay": "budget.maxDownloadBytesPerDay",
"organize.minSizeMiB": "organize.minSizeBytes",
"metadata.tmdbApiKey": "metadata.tmdbApiKeySecretRef",
};
/** 不走表单卡片、由专门组件渲染的字段 → 所在节(只核对节在页面上) */
const CUSTOM_FIELDS: Record<string, string> = {
"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<string>(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");
});
});