feat(web): settings with 9 sidebar sections and Agent upstream management

- /console/settings#<section>[/<anchor>]; legacy ?tab= redirected in clientLoader
- SETTINGS_SECTIONS in contracts with field-ownership test
- Agent section: upstream list, create wizard (provider -> credentials/model -> login/test/default),
  edit drawer with OAuth browser/device login, test, set default, delete
- sites section with site registration, snapshot/rate-limit drawer and account credentials
- path mappings moved into downloader / media-server drawers; metadata and account sections

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 03:05:51 +08:00
1 parent 459991421e
commit c67f6a9682
25 files changed
+3842 -942

No files matched your search

+184
View File
@@ -289,3 +289,187 @@ export const SettingEntry = z.object({
updatedAt: Timestamp,
});
export type SettingEntry = z.infer<typeof SettingEntry>;
// ── 设置页分区(docs/design/console-ia.md §3.4) ──
// 只是"展示分区 → 字段"的映射,不改存储 key;每个字段恰好归属一个分区(测试断言)。
// 非 settings 表的对象(实例、站点、API Key、Pi 上游、账户)用 resources 标注,不进 fields。
export const SettingsSectionId = z.enum([
"automation",
"sites",
"downloaders",
"library",
"media-servers",
"metadata",
"agent",
"api-keys",
"account",
]);
export type SettingsSectionId = z.infer<typeof SettingsSectionId>;
export type SettingsSectionDef = {
id: SettingsSectionId;
label: string;
description: string;
/** 旧 `?tab=` 值 → 本分区(可带分区内锚点) */
legacyTabs: { tab: string; anchor?: string }[];
/** 分区内锚点(节),顺序即页面顺序 */
anchors: { id: string; label: string }[];
/** 归属本分区的 settings 字段(`key.field`,compaction 这类嵌套对象按整体算) */
fields: string[];
/** 本分区管理的非 settings 对象 */
resources: string[];
};
export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
{
id: "automation",
label: "自动化",
description: "总开关、缓存水位、每日预算、保留天数",
legacyTabs: [{ tab: "automation" }, { tab: "storage", anchor: "cache" }],
anchors: [
{ id: "switches", label: "开关" },
{ id: "cache", label: "缓存水位" },
{ id: "budget", label: "每日预算" },
{ id: "retention", label: "保留" },
],
fields: [
"automation.enabled",
"automation.readOnlyReconcile",
"cache.hardThresholdPercent",
"cache.resumePercent",
"cache.checkIntervalSec",
"cache.cleanupTargetPercent",
"budget.maxNewTitlesPerDay",
"budget.maxDownloadBytesPerDay",
"budget.maxConcurrentDownloads",
"retention.seedRetentionDays",
"retention.logRetentionDays",
],
resources: [],
},
{
id: "sites",
label: "站点与账号",
description: "站点启停、快照周期、限流、账号凭证",
legacyTabs: [{ tab: "site-credentials" }],
anchors: [
{ id: "sites", label: "站点" },
{ id: "accounts", label: "站点账号" },
],
fields: [],
resources: ["sites", "site-accounts"],
},
{
id: "downloaders",
label: "下载器",
description: "qBittorrent 实例、连通测试、路径映射",
legacyTabs: [{ tab: "downloaders" }, { tab: "path-mappings" }],
anchors: [{ id: "instances", label: "实例" }],
fields: [],
resources: ["downloader-instances", "path-mappings"],
},
{
id: "library",
label: "媒体库与目录",
description: "库根、哨兵状态、默认整理方式",
legacyTabs: [{ tab: "library-roots", anchor: "roots" }],
anchors: [
{ id: "roots", label: "库根" },
{ id: "organize", label: "整理方式" },
],
fields: ["organize.defaultMode", "organize.allowCrossVolumeMove"],
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 与地址",
legacyTabs: [],
anchors: [{ id: "tmdb", label: "TMDB" }],
fields: ["metadata.tmdbApiKeySecretRef", "metadata.tmdbBaseUrl"],
resources: [],
},
{
id: "agent",
label: "Agent",
description: "Agent 上游、运行预算、自动调查",
legacyTabs: [{ tab: "pi" }],
anchors: [
{ id: "upstream", label: "上游" },
{ id: "runtime", label: "上下文与子代理" },
{ id: "budget", label: "预算" },
{ id: "investigate", label: "自动调查" },
{ id: "advanced", label: "高级" },
],
fields: [
"pi.defaultUpstreamId",
"pi.provider",
"pi.baseUrl",
"pi.model",
"pi.thinkingLevel",
"pi.contextBudget",
"pi.compaction",
"pi.subagentsEnabled",
"pi.subagents",
"pi.apiKeySecretRef",
"budget.agentMaxRunsPerDay",
"budget.agentMaxCostUsdPerDay",
"agent.autoInvestigate",
"agent.autoInvestigateMode",
"agent.investigateMaxAttempts",
"agent.recoverVerifySec",
"agent.maxToolCalls",
"agent.maxDurationSec",
"agent.maxTokensPerRun",
"agent.maxCostUsdPerRun",
"agent.investigateMaxToolCalls",
"agent.goalPollSec",
"agent.goalWaitHours",
"agent.fakeScript",
"agent.sessionDir",
],
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: "account",
label: "账户与安全",
description: "密码、二次验证、会话、快捷键",
legacyTabs: [],
anchors: [
{ id: "password", label: "密码" },
{ id: "totp", label: "二次验证" },
{ id: "session", label: "会话" },
{ id: "shortcuts", label: "快捷键" },
],
fields: [],
resources: ["auth"],
},
];
/** 旧 `?tab=` → 分区与锚点;不认识的返回 null */
export function settingsSectionForLegacyTab(tab: string): { section: SettingsSectionId; anchor?: string } | null {
for (const s of SETTINGS_SECTIONS) {
const hit = s.legacyTabs.find((t) => t.tab === tab);
if (hit) return { section: s.id, anchor: hit.anchor };
}
return null;
}