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,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>`) */
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<string>();
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;
}