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:
1 parent
459991421e
commit
c67f6a9682
25 files changed
+3842
-942
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
SETTINGS_SECTIONS,
|
||||
SettingsSectionId,
|
||||
settingsSectionForLegacyTab,
|
||||
} from "../src/settings.ts";
|
||||
|
||||
describe("SETTINGS_SECTIONS", () => {
|
||||
it("9 个分区,顺序与 SettingsSectionId 一致", () => {
|
||||
expect(SETTINGS_SECTIONS.map((s) => s.id)).toEqual(SettingsSectionId.options);
|
||||
});
|
||||
|
||||
it("8 个存储 key 的每个字段恰好归属一个分区", () => {
|
||||
const all = Object.entries(DEFAULT_SETTINGS).flatMap(([key, v]) => Object.keys(v).map((f) => `${key}.${f}`));
|
||||
const owned = SETTINGS_SECTIONS.flatMap((s) => s.fields);
|
||||
expect(new Set(owned).size).toBe(owned.length);
|
||||
expect([...owned].sort()).toEqual([...all].sort());
|
||||
});
|
||||
|
||||
it("旧 ?tab= 值都能落到分区", () => {
|
||||
for (const tab of [
|
||||
"automation",
|
||||
"downloaders",
|
||||
"media-servers",
|
||||
"library-roots",
|
||||
"path-mappings",
|
||||
"site-credentials",
|
||||
"api-keys",
|
||||
"pi",
|
||||
"storage",
|
||||
]) {
|
||||
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("nope")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user