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; }