diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 87376b6..7c6e47a 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -31,35 +31,45 @@ Request -> Bun.serve routes 声明式匹配 -> routes/*.ts handler -> helpers/ - 共享类型在 `src/shared/`。 - 前端禁止 import `src/server/` 运行时实现;后端禁止依赖 `src/web/` 运行时代码(HTML import 集成除外)。 +## 配置定位 + +| 配置类型 | 来源 | 内容 | 可变性 | +| -------- | ------------------------------------------------------------------------ | ------------------------------ | ---------- | +| 启动配置 | CLI 传入的 `config.yaml` | 监听地址、端口、日志、数据目录 | 进程内只读 | +| 业务设置 | 管理台 `/settings` 页面 → `GET/PUT /api/settings` → SQLite `settings` 表 | 主题偏好等 UI/业务开关 | 运行时可变 | + +启动配置由 `src/server/config.ts` 的 `loadServerConfig()` 在启动时加载并校验,运行时不可更改。业务设置通过独立 API 持久化,与启动配置在存储和生命周期上完全解耦。 + ## 主要模块 -| 模块 | 职责 | -| ------------------------- | -------------------------------------------------------- | -| `src/server/bootstrap.ts` | 统一启动引导、DB 初始化、shutdown 编排 | -| `src/server/config.ts` | 配置加载入口:YAML 解析、规范化、契约校验、运行时校验 | -| `src/server/config/` | 配置子系统:types、variables、issues、normalizer、schema | -| `src/server/logger.ts` | Logger 接口 + Pino 实现 + 测试替身 | -| `src/server/server.ts` | Bun HTTP server + routes 注册 | -| `src/server/routes/` | API handler,按资源端点拆分 | -| `src/server/db/` | SQLite 连接、schema、migration、data access | -| `src/server/ai/` | AI Provider Registry + Agent + 工具 | -| `src/server/helpers/` | 响应格式化、URL 工具 | -| `src/server/middleware/` | 参数校验 + 错误处理 | -| `src/web/layouts/` | 前端布局组件(AdminLayout / WorkbenchLayout) | -| `src/web/features/` | 前端功能模块(dashboard / projects / models / chat) | -| `src/web/shared/` | 前端共享代码(components / hooks / utils) | -| `src/shared/api.ts` | 前后端共享 API 类型 | -| `src/shared/app.ts` | 应用全局常量 | +| 模块 | 职责 | +| ------------------------- | --------------------------------------------------------------- | +| `src/server/bootstrap.ts` | 统一启动引导、DB 初始化、shutdown 编排 | +| `src/server/config.ts` | 配置加载入口:YAML 解析、规范化、契约校验、运行时校验 | +| `src/server/config/` | 配置子系统:types、variables、issues、normalizer、schema | +| `src/server/logger.ts` | Logger 接口 + Pino 实现 + 测试替身 | +| `src/server/server.ts` | Bun HTTP server + routes 注册 | +| `src/server/routes/` | API handler,按资源端点拆分 | +| `src/server/db/` | SQLite 连接、schema、migration、data access | +| `src/server/ai/` | AI Provider Registry + Agent + 工具 | +| `src/server/helpers/` | 响应格式化、URL 工具 | +| `src/server/middleware/` | 参数校验 + 错误处理 | +| `src/web/layouts/` | 前端布局组件(AdminLayout / WorkbenchLayout) | +| `src/web/features/` | 前端功能模块(dashboard / projects / models / chat / settings) | +| `src/web/shared/` | 前端共享代码(components / hooks / utils) | +| `src/shared/api.ts` | 前后端共享 API 类型 | +| `src/shared/app.ts` | 应用全局常量 | ## 路由分组 -| 资源 | 路径前缀 | 文件目录 | -| --------- | ----------------------------------------------- | ------------------- | -| meta | `/api/meta` | `routes/meta.ts` | -| providers | `/api/providers` | `routes/providers/` | -| models | `/api/models` | `routes/models/` | -| projects | `/api/projects` | `routes/projects/` | -| chat | `/api/projects/:id/conversations` 和 `:id/chat` | `routes/chat/` | +| 资源 | 路径前缀 | 文件目录 | +| --------- | ----------------------------------------------- | -------------------- | +| meta | `/api/meta` | `routes/meta.ts` | +| providers | `/api/providers` | `routes/providers/` | +| models | `/api/models` | `routes/models/` | +| projects | `/api/projects` | `routes/projects/` | +| chat | `/api/projects/:id/conversations` 和 `:id/chat` | `routes/chat/` | +| settings | `/api/settings` | `routes/settings.ts` | ## 更新触发条件 diff --git a/docs/development/frontend.md b/docs/development/frontend.md index 50f134b..4a45e28 100644 --- a/docs/development/frontend.md +++ b/docs/development/frontend.md @@ -9,7 +9,7 @@ - **AdminLayout**(`src/web/layouts/admin-layout/`):路由 `/`(总览)、`/projects`、`/models`、`/models/providers`。 - **WorkbenchLayout**(`src/web/layouts/workbench-layout/`):路由 `/workbench/:projectId`、`/workbench/:projectId/chat`。`WorkbenchProjectGate` 从 URL 读 projectId,通过 `ProjectContext` 提供项目上下文,仅 active 项目渲染。 -ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Sider/Content) + 主题切换(明亮/黑暗/系统),主题配置由 `shared/theme/theme-config.ts` 的 `buildThemeConfig(effectiveTheme)` 集中构建(含 `cssVar`、`borderRadius`、`controlHeight`、`components.Layout` 配色);侧边栏折叠。Header 显示品牌名、版本号和布局标题。 +ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Sider/Content),主题配置由 `shared/theme/theme-config.ts` 的 `buildThemeConfig(effectiveTheme)` 集中构建(含 `cssVar`、`borderRadius`、`controlHeight`、`components.Layout` 配色);侧边栏折叠。主题切换已迁移至设置页(`/settings`)。Header 显示品牌名、版本号和布局标题。 `Sidebar`(`src/web/shared/components/Sidebar/`)纯展示组件,通过 `menuItems` props 接收配置。 @@ -22,6 +22,7 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si | 模型管理 | `features/models/` | 供应商/模型管理、连通性测试 | | 聊天 | `features/chat/` | 会话管理、消息渲染、AI 对话 | | 收集箱 | `features/inbox/` | 素材 CRUD、持久化、列表管理 | +| 设置 | `features/settings/` | 平台业务设置,卡片式布局 | ## 页面 @@ -30,6 +31,7 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si | 总览 | `/` | `features/dashboard/index.tsx` | | 项目管理 | `/projects` | `features/projects/index.tsx` — FilterToolbar(状态 Select + 搜索 + 新建/归档恢复删除) + ProjectTable + ProjectFormModal。支持创建/编辑/归档/恢复/删除、列表排序、URL 同步筛选参数。 | | 模型管理 | `/models` 和 `/models/providers` | 独立路由页面:`ModelListPage.tsx`(FilterToolbar + ModelTable) + `ProviderListPage.tsx`(FilterToolbar + ProviderTable)。模型支持供应商/能力筛选和列表排序,供应商支持类型筛选和列表排序。模型表单使用 `GET /api/providers/options`。供应商表单支持预保存连通性测试(`POST /api/providers/test`)。 | +| 设置 | `/settings` | `features/settings/index.tsx` — 卡片式布局分区管理平台业务设置。当前包含"主题配置"卡片(Segmented 切换系统/明亮/黑暗),使用 `useSettings` hook 通过 `GET/PUT /api/settings` 持久化,悲观更新策略。 | | 聊天室 | `/workbench/:id` | `features/chat/index.tsx` | | 收集箱 | `/workbench/:id/inbox` | `features/inbox/index.tsx` — 协调层(selectedId + modalOpen)+ MaterialSidebar(列表容器)+ MaterialDetailPanel(详情容器)+ AddMaterialModal。素材 CRUD 通过 TanStack Query hooks 接入后端 API。 | | 404 | `*` | `features/not-found/index.tsx` | @@ -66,7 +68,8 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si | `use-projects.ts` | `shared/hooks/use-projects.ts` | 项目 CRUD + archive/restore | | `use-conversations.ts` | `shared/hooks/use-conversations.ts` | 会话和消息 fetch 函数(不含 Query hooks) | | `use-logger` | `shared/hooks/use-logger.ts` | Logger hook(组件内使用) | -| `use-theme-preference` | `shared/hooks/use-theme-preference.ts` | 主题偏好 localStorage 持久化 | +| `use-theme-preference` | `shared/hooks/use-theme-preference.ts` | 主题偏好管理(localStorage + API 同步) | +| `use-settings` | `shared/hooks/use-settings.ts` | 平台设置读写(react-query: GET/PUT /api/settings) | | `use-sidebar-collapsed` | `shared/hooks/use-sidebar-collapsed.ts` | 侧边栏折叠 localStorage 持久化 | | `use-is-dark` | `shared/hooks/use-is-dark.ts` | 当前是否暗色主题 | diff --git a/drizzle/0005_add_settings.sql b/drizzle/0005_add_settings.sql new file mode 100644 index 0000000..edd61b0 --- /dev/null +++ b/drizzle/0005_add_settings.sql @@ -0,0 +1,7 @@ +CREATE TABLE settings ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + data TEXT NOT NULL DEFAULT '{}' +); diff --git a/src/server/db/index.ts b/src/server/db/index.ts index c14b0a1..d0b15f9 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -20,4 +20,5 @@ export { listModels, updateModel, } from "./models"; -export { conversations, messages, projects, schemaMigrations } from "./schema"; +export { conversations, messages, projects, schemaMigrations, settings } from "./schema"; +export { getSettings, updateSettings } from "./settings"; diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 0a161e9..6438338 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -86,3 +86,8 @@ export const schemaMigrations = sqliteTable("schema_migrations", { checksum: text("checksum").notNull(), id: text("id").primaryKey(), }); + +export const settings = sqliteTable("settings", { + ...baseColumns, + data: text("data").notNull().default("{}"), +}); diff --git a/src/server/db/settings.ts b/src/server/db/settings.ts new file mode 100644 index 0000000..5483e6e --- /dev/null +++ b/src/server/db/settings.ts @@ -0,0 +1,72 @@ +import type Database from "bun:sqlite"; + +import { and, eq } from "drizzle-orm"; + +import type { SettingsData } from "../../shared/api"; +import type { Logger } from "../logger"; + +import { notDeleted, timestamp, wrap } from "./connection"; +import { settings } from "./schema"; + +const SETTINGS_ID = "default"; + +export function getSettings(raw: Database): SettingsData { + const db = wrap(raw); + const row = db + .select() + .from(settings) + .where(and(eq(settings.id, SETTINGS_ID), notDeleted(settings))) + .get(); + + if (!row) { + return { theme: "system" }; + } + + try { + const parsed = JSON.parse(row.data) as Partial; + return { + theme: parsed.theme === "dark" || parsed.theme === "light" || parsed.theme === "system" ? parsed.theme : "system", + }; + } catch { + return { theme: "system" }; + } +} + +export function updateSettings(raw: Database, data: Partial, _logger: Logger): SettingsData { + const db = wrap(raw); + const existing = db + .select() + .from(settings) + .where(and(eq(settings.id, SETTINGS_ID), notDeleted(settings))) + .get(); + + let currentData: SettingsData = { theme: "system" }; + if (existing) { + try { + currentData = JSON.parse(existing.data) as SettingsData; + } catch { + // 解析失败时使用默认值 + } + } + + const merged: SettingsData = { ...currentData, ...data }; + + if (existing) { + db.update(settings) + .set({ data: JSON.stringify(merged), updatedAt: timestamp() }) + .where(eq(settings.id, SETTINGS_ID)) + .run(); + } else { + const now = timestamp(); + db.insert(settings) + .values({ + createdAt: now, + data: JSON.stringify(merged), + id: SETTINGS_ID, + updatedAt: now, + }) + .run(); + } + + return merged; +} diff --git a/src/server/routes/settings.ts b/src/server/routes/settings.ts new file mode 100644 index 0000000..0d282b0 --- /dev/null +++ b/src/server/routes/settings.ts @@ -0,0 +1,38 @@ +import type Database from "bun:sqlite"; + +import type { RuntimeMode, SettingsData } from "../../shared/api"; +import type { Logger } from "../logger"; + +import { getSettings, updateSettings } from "../db/settings"; +import { createApiError, jsonResponse } from "../helpers"; + +export function handleGetSettings(_req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response { + const data = getSettings(db); + return jsonResponse(data, { mode }); +} + +export async function handleUpdateSettings( + req: Request, + db: Database, + mode: RuntimeMode, + logger: Logger, +): Promise { + let body: Partial; + try { + body = (await req.json()) as Partial; + } catch { + return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 }); + } + + if (body.theme !== undefined && typeof body.theme !== "string") { + return jsonResponse(createApiError("theme must be a string", 400), { mode, status: 400 }); + } + + if (body.theme !== undefined && body.theme !== "dark" && body.theme !== "light" && body.theme !== "system") { + return jsonResponse(createApiError("theme 仅支持 dark、light、system", 400), { mode, status: 400 }); + } + + const result = updateSettings(db, body, logger); + logger.info({ data: result }, "设置已更新"); + return jsonResponse(result, { mode }); +} diff --git a/src/server/server.ts b/src/server/server.ts index 0f97313..8e05cff 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -330,6 +330,24 @@ export function startServer(options: StartServerOptions) { logger, ), }, + "/api/settings": { + GET: withErrorHandler( + async (req) => { + const { handleGetSettings } = await import("./routes/settings"); + return handleGetSettings(req, db, mode, logger); + }, + mode, + logger, + ), + PUT: withErrorHandler( + async (req) => { + const { handleUpdateSettings } = await import("./routes/settings"); + return handleUpdateSettings(req, db, mode, logger); + }, + mode, + logger, + ), + }, }, }); diff --git a/src/shared/api.ts b/src/shared/api.ts index 3373993..5a0ed3f 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -237,11 +237,17 @@ export type ProviderType = "anthropic" | "openai" | "openai-compatible"; export type RuntimeMode = "development" | "production" | "test"; +export interface SettingsData { + theme: ThemePreference; +} + export interface TestModelRequest { externalId: string; providerId: string; } +export type ThemePreference = "dark" | "light" | "system"; + export interface UpdateModelRequest { capabilities?: ModelCapability[]; contextLength?: null | number; diff --git a/src/web/features/settings/index.tsx b/src/web/features/settings/index.tsx new file mode 100644 index 0000000..ea3a9a6 --- /dev/null +++ b/src/web/features/settings/index.tsx @@ -0,0 +1,38 @@ +import { Card, Segmented } from "antd"; + +import { useSettings } from "../../shared/hooks/use-settings"; +import { parseThemePreference, useThemePreference } from "../../shared/hooks/use-theme-preference"; + +const THEME_OPTIONS = [ + { label: "系统", value: "system" }, + { label: "明亮", value: "light" }, + { label: "黑暗", value: "dark" }, +] as const; + +export function SettingsPage() { + const { preference, setPreference } = useThemePreference(); + const { isUpdating, updateSettings } = useSettings(); + + const handleThemeChange = (value: string) => { + const theme = parseThemePreference(value); + updateSettings( + { theme }, + { + onSuccess: () => { + setPreference(theme); + }, + }, + ); + }; + + return ( + + handleThemeChange(value)} + options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))} + value={preference} + /> + + ); +} diff --git a/src/web/layouts/admin-layout/menu.tsx b/src/web/layouts/admin-layout/menu.tsx index a873844..399ce2b 100644 --- a/src/web/layouts/admin-layout/menu.tsx +++ b/src/web/layouts/admin-layout/menu.tsx @@ -1,4 +1,11 @@ -import { ApiOutlined, CloudServerOutlined, DashboardOutlined, FolderOutlined, RobotOutlined } from "@ant-design/icons"; +import { + ApiOutlined, + CloudServerOutlined, + DashboardOutlined, + FolderOutlined, + RobotOutlined, + SettingOutlined, +} from "@ant-design/icons"; import { createElement } from "react"; import type { MenuItemConfig } from "../../menu"; @@ -16,4 +23,5 @@ export const ADMIN_MENU_ITEMS: readonly MenuItemConfig[] = [ path: "", value: "model-management", }, + { icon: createElement(SettingOutlined), label: "设置", path: "/settings", value: "settings" }, ] as const; diff --git a/src/web/routes.tsx b/src/web/routes.tsx index b8d8f39..d226285 100644 --- a/src/web/routes.tsx +++ b/src/web/routes.tsx @@ -7,6 +7,7 @@ import { ModelListPage } from "./features/models/ModelListPage"; import { ProviderListPage } from "./features/models/ProviderListPage"; import { NotFoundPage } from "./features/not-found"; import { ProjectsPage } from "./features/projects"; +import { SettingsPage } from "./features/settings"; import { AdminConsoleLayout } from "./layouts/admin-layout/AdminConsoleLayout"; import { WorkbenchProjectGate } from "./layouts/workbench-layout/WorkbenchProjectGate"; import { RouteError } from "./shared/components/RouteError"; @@ -19,6 +20,7 @@ export function AppRoutes() { } path="/projects" /> } path="/models" /> } path="/models/providers" /> + } path="/settings" /> } errorElement={} path="/workbench/:projectId"> } path="" /> diff --git a/src/web/shared/components/ConsoleShell/ConsoleShell.tsx b/src/web/shared/components/ConsoleShell/ConsoleShell.tsx index c10200d..e9259d0 100644 --- a/src/web/shared/components/ConsoleShell/ConsoleShell.tsx +++ b/src/web/shared/components/ConsoleShell/ConsoleShell.tsx @@ -1,7 +1,7 @@ import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons"; import { XProvider } from "@ant-design/x"; import zhCN_X from "@ant-design/x/locale/zh_CN"; -import { App as AntApp, Layout, Segmented } from "antd"; +import { App as AntApp, Layout } from "antd"; import zhCN from "antd/locale/zh_CN"; import { useMemo } from "react"; @@ -17,14 +17,8 @@ import { ConsoleOutlet } from "./ConsoleOutlet"; const { Content, Header, Sider } = Layout; -const THEME_OPTIONS = [ - { label: "系统", value: "system" }, - { label: "明亮", value: "light" }, - { label: "黑暗", value: "dark" }, -] as const; - export function ConsoleShell({ headerExtra, menuItems, title }: ConsoleShellProps) { - const { effectiveTheme, preference: themePreference, setPreference: setThemePreference } = useThemePreference(); + const { effectiveTheme } = useThemePreference(); const { collapsed, setCollapsed } = useSidebarCollapsed(); const { data: meta } = useMeta(); @@ -43,14 +37,7 @@ export function ConsoleShell({ headerExtra, menuItems, title }: ConsoleShellProp {title} -
- {headerExtra} - setThemePreference(value)} - options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))} - value={themePreference} - /> -
+
{headerExtra}
{ + queryClient.setQueryData(["settings"], data); + }, + }); + + return { + data: query.data, + error: query.error, + isLoading: query.isLoading, + isUpdating: mutation.isPending, + updateSettings: mutation.mutate, + }; +} + +async function fetchSettings(): Promise { + const response = await fetch("/api/settings"); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + +async function updateSettings(data: Partial): Promise { + const response = await fetch("/api/settings", { + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + method: "PUT", + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} diff --git a/src/web/shared/hooks/use-theme-preference.ts b/src/web/shared/hooks/use-theme-preference.ts index 1f6e74a..f7c0299 100644 --- a/src/web/shared/hooks/use-theme-preference.ts +++ b/src/web/shared/hooks/use-theme-preference.ts @@ -1,5 +1,7 @@ import { useCallback, useEffect, useState } from "react"; +import type { SettingsData } from "../../../shared/api"; + export type EffectiveTheme = "dark" | "light"; export type ThemePreference = "dark" | "light" | "system"; @@ -38,6 +40,27 @@ export function useThemePreference() { const [systemPrefersDark, setSystemPrefersDark] = useState(() => getSystemPrefersDark()); const effectiveTheme = resolveEffectiveTheme(preference, systemPrefersDark); + useEffect(() => { + let cancelled = false; + fetch("/api/settings") + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json() as Promise; + }) + .then((data) => { + if (cancelled) return; + const apiTheme = parseThemePreference(data.theme); + setPreferenceState((prev) => (prev !== apiTheme ? apiTheme : prev)); + writeThemePreference(apiTheme); + }) + .catch(() => { + // API 不可用时维持 localStorage 缓存值 + }); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { const mediaQueryList = window.matchMedia(THEME_MEDIA_QUERY); diff --git a/src/web/shared/theme/theme-config.ts b/src/web/shared/theme/theme-config.ts index 99b428c..4a0cb37 100644 --- a/src/web/shared/theme/theme-config.ts +++ b/src/web/shared/theme/theme-config.ts @@ -12,7 +12,7 @@ export function buildThemeConfig(effectiveTheme: EffectiveTheme) { Layout: { bodyBg: isDark ? "#0a0a0a" : "transparent", headerBg: "transparent", - siderBg: isDark ? "#0a0a0a" : "#ffffff", + siderBg: isDark ? "#141414" : "#ffffff", triggerBg: isDark ? "#141414" : "#ffffff", }, Menu: { diff --git a/src/web/styles.css b/src/web/styles.css index f826b87..bf84fc2 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -65,6 +65,7 @@ body { color: var(--ant-color-text-secondary); font-size: var(--ant-font-size); font-weight: 400; + line-height: 1; } .app-unavailable { diff --git a/tests/server/db/settings.test.ts b/tests/server/db/settings.test.ts new file mode 100644 index 0000000..43c1cee --- /dev/null +++ b/tests/server/db/settings.test.ts @@ -0,0 +1,78 @@ +import type Database from "bun:sqlite"; + +import { describe, expect, test } from "bun:test"; + +import { getSettings, updateSettings } from "../../../src/server/db/settings"; +import { createNoopLogger } from "../../../src/server/logger"; +import { createMigratedTestDatabase } from "../../helpers"; + +function withSettingsDb(callback: (db: Database) => void): void { + const handle = createMigratedTestDatabase("settings-test"); + try { + callback(handle.db); + handle.close(); + } finally { + handle.cleanup(); + } +} + +describe("设置数据访问层", () => { + test("getSettings 无数据时返回默认值", () => { + withSettingsDb((db) => { + const result = getSettings(db); + expect(result).toEqual({ theme: "system" }); + }); + }); + + test("updateSettings 写入并读取", () => { + withSettingsDb((db) => { + const updated = updateSettings(db, { theme: "dark" }, createNoopLogger()); + expect(updated).toEqual({ theme: "dark" }); + + const read = getSettings(db); + expect(read).toEqual({ theme: "dark" }); + }); + }); + + test("updateSettings 部分更新合并", () => { + withSettingsDb((db) => { + updateSettings(db, { theme: "dark" }, createNoopLogger()); + const result = updateSettings(db, { theme: "light" }, createNoopLogger()); + expect(result).toEqual({ theme: "light" }); + }); + }); + + test("getSettings 解析非法 JSON 返回默认值", () => { + withSettingsDb((db) => { + db.run( + "INSERT INTO settings (id, created_at, updated_at, data) VALUES ('default', '2024-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z', 'not-json')", + ); + const result = getSettings(db); + expect(result).toEqual({ theme: "system" }); + }); + }); + + test("getSettings 未知 theme 值返回默认值", () => { + withSettingsDb((db) => { + db.run( + "INSERT INTO settings (id, created_at, updated_at, data) VALUES ('default', '2024-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z', '{\"theme\":\"unknown\"}')", + ); + const result = getSettings(db); + expect(result).toEqual({ theme: "system" }); + }); + }); + + test("updateSettings 幂等覆盖", () => { + withSettingsDb((db) => { + const a = updateSettings(db, { theme: "dark" }, createNoopLogger()); + const b = updateSettings(db, { theme: "dark" }, createNoopLogger()); + expect(a).toEqual({ theme: "dark" }); + expect(b).toEqual({ theme: "dark" }); + + const row = db + .query("SELECT COUNT(*) as cnt FROM settings WHERE id = 'default' AND deleted_at IS NULL") + .get() as { cnt: number }; + expect(row.cnt).toBe(1); + }); + }); +}); diff --git a/tests/server/routes/settings.test.ts b/tests/server/routes/settings.test.ts new file mode 100644 index 0000000..325eaca --- /dev/null +++ b/tests/server/routes/settings.test.ts @@ -0,0 +1,146 @@ +import type Database from "bun:sqlite"; + +import { describe, expect, test } from "bun:test"; + +import type { RuntimeMode } from "../../../src/shared/api"; + +import { createNoopLogger } from "../../../src/server/logger"; +import { createMigratedMemoryTestDatabase } from "../../helpers"; + +const MODE: RuntimeMode = "test"; +const LOG = createNoopLogger(); + +async function getSettingsViaHandler(req: Request, db: Database): Promise { + const { handleGetSettings: h } = await import("../../../src/server/routes/settings"); + return h(req, db, MODE, LOG); +} + +async function updateSettingsViaHandler(req: Request, db: Database): Promise { + const { handleUpdateSettings: h } = await import("../../../src/server/routes/settings"); + return h(req, db, MODE, LOG); +} + +async function withRouteDb(callback: (db: Database) => Promise): Promise { + const handle = createMigratedMemoryTestDatabase("route-settings-test"); + try { + await callback(handle.db); + handle.close(); + } finally { + handle.cleanup(); + } +} + +describe("设置 API 路由", () => { + test("GET /api/settings 返回默认值", async () => { + await withRouteDb(async (db) => { + const req = new Request("http://localhost/api/settings"); + const res = await getSettingsViaHandler(req, db); + expect(res.status).toBe(200); + const body = (await res.json()) as { theme: string }; + expect(body).toEqual({ theme: "system" }); + }); + }); + + test("PUT /api/settings 写入后 GET 验证一致性", async () => { + await withRouteDb(async (db) => { + const putReq = new Request("http://localhost/api/settings", { + body: JSON.stringify({ theme: "dark" }), + headers: { "Content-Type": "application/json" }, + method: "PUT", + }); + const putRes = await updateSettingsViaHandler(putReq, db); + expect(putRes.status).toBe(200); + const putBody = (await putRes.json()) as { theme: string }; + expect(putBody).toEqual({ theme: "dark" }); + + const getReq = new Request("http://localhost/api/settings"); + const getRes = await getSettingsViaHandler(getReq, db); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { theme: string }; + expect(getBody).toEqual({ theme: "dark" }); + }); + }); + + test("PUT /api/settings 部分更新", async () => { + await withRouteDb(async (db) => { + const req1 = new Request("http://localhost/api/settings", { + body: JSON.stringify({ theme: "dark" }), + headers: { "Content-Type": "application/json" }, + method: "PUT", + }); + await updateSettingsViaHandler(req1, db); + + const req2 = new Request("http://localhost/api/settings", { + body: JSON.stringify({ theme: "light" }), + headers: { "Content-Type": "application/json" }, + method: "PUT", + }); + const res2 = await updateSettingsViaHandler(req2, db); + expect(res2.status).toBe(200); + const body = (await res2.json()) as { theme: string }; + expect(body).toEqual({ theme: "light" }); + }); + }); + + test("PUT /api/settings 非法 JSON 返回 400", async () => { + await withRouteDb(async (db) => { + const req = new Request("http://localhost/api/settings", { + body: "not-json", + headers: { "Content-Type": "application/json" }, + method: "PUT", + }); + const res = await updateSettingsViaHandler(req, db); + expect(res.status).toBe(400); + }); + }); + + test("PUT /api/settings theme 非字符串返回 400", async () => { + await withRouteDb(async (db) => { + const req = new Request("http://localhost/api/settings", { + body: JSON.stringify({ theme: 123 }), + headers: { "Content-Type": "application/json" }, + method: "PUT", + }); + const res = await updateSettingsViaHandler(req, db); + expect(res.status).toBe(400); + }); + }); + + test("PUT /api/settings 非法 theme 值返回 400", async () => { + await withRouteDb(async (db) => { + const req = new Request("http://localhost/api/settings", { + body: JSON.stringify({ theme: "auto" }), + headers: { "Content-Type": "application/json" }, + method: "PUT", + }); + const res = await updateSettingsViaHandler(req, db); + expect(res.status).toBe(400); + }); + }); + + test("PUT /api/settings theme=system 合法", async () => { + await withRouteDb(async (db) => { + const req = new Request("http://localhost/api/settings", { + body: JSON.stringify({ theme: "system" }), + headers: { "Content-Type": "application/json" }, + method: "PUT", + }); + const res = await updateSettingsViaHandler(req, db); + expect(res.status).toBe(200); + }); + }); + + test("PUT /api/settings 空 body 不报错", async () => { + await withRouteDb(async (db) => { + const req = new Request("http://localhost/api/settings", { + body: JSON.stringify({}), + headers: { "Content-Type": "application/json" }, + method: "PUT", + }); + const res = await updateSettingsViaHandler(req, db); + expect(res.status).toBe(200); + const body = (await res.json()) as { theme: string }; + expect(body).toEqual({ theme: "system" }); + }); + }); +}); diff --git a/tests/web/App.test.tsx b/tests/web/App.test.tsx index 2ce2012..1f44568 100644 --- a/tests/web/App.test.tsx +++ b/tests/web/App.test.tsx @@ -4,23 +4,30 @@ import { createElement } from "react"; import { APP } from "../../src/shared/app"; import { App } from "../../src/web/app"; -import { installFetchMock, mockMetaResponse, renderWithProviders } from "./test-utils"; +import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "./test-utils"; + +function mockSettingsResponse(): Response { + return jsonResponse({ theme: "system" }); +} describe("App", () => { - test("渲染管理台入口、品牌和主题切换项", () => { - installFetchMock(() => mockMetaResponse()); + test("渲染管理台入口和品牌", () => { + installFetchMock((call) => { + if (call.url.includes("/api/settings")) return mockSettingsResponse(); + return mockMetaResponse(); + }); renderWithProviders(createElement(App)); expect(screen.getByText(APP.title)).not.toBeNull(); expect(screen.getByText("管理台")).not.toBeNull(); - expect(screen.getByText("系统")).not.toBeNull(); - expect(screen.getByText("明亮")).not.toBeNull(); - expect(screen.getByText("黑暗")).not.toBeNull(); }); - test("渲染 Admin 导航菜单项", () => { - installFetchMock(() => mockMetaResponse()); + test("渲染管理台侧边栏菜单项", () => { + installFetchMock((call) => { + if (call.url.includes("/api/settings")) return mockSettingsResponse(); + return mockMetaResponse(); + }); renderWithProviders(createElement(App)); diff --git a/tests/web/components/ConsoleShell.test.tsx b/tests/web/components/ConsoleShell.test.tsx new file mode 100644 index 0000000..82f0b9b --- /dev/null +++ b/tests/web/components/ConsoleShell.test.tsx @@ -0,0 +1,36 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, test } from "bun:test"; +import { createElement } from "react"; + +import { ConsoleShell } from "../../../src/web/shared/components/ConsoleShell/ConsoleShell"; +import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "../test-utils"; + +function mockSettingsResponse(): Response { + return jsonResponse({ theme: "system" }); +} + +describe("ConsoleShell", () => { + test("Header 不再渲染主题 Segmented", () => { + installFetchMock((call) => { + if (call.url.includes("/api/settings")) return mockSettingsResponse(); + if (call.url.includes("/api/meta")) return mockMetaResponse(); + return jsonResponse({}); + }); + + renderWithProviders(createElement(ConsoleShell, { menuItems: [], title: "测试" })); + + expect(screen.queryByText("系统")).toBeNull(); + }); + + test("渲染品牌标题", () => { + installFetchMock((call) => { + if (call.url.includes("/api/settings")) return mockSettingsResponse(); + if (call.url.includes("/api/meta")) return mockMetaResponse(); + return jsonResponse({}); + }); + + renderWithProviders(createElement(ConsoleShell, { menuItems: [], title: "控制台" })); + + expect(screen.getByText("控制台")).not.toBeNull(); + }); +}); diff --git a/tests/web/features/settings/SettingsPage.test.tsx b/tests/web/features/settings/SettingsPage.test.tsx new file mode 100644 index 0000000..81d79de --- /dev/null +++ b/tests/web/features/settings/SettingsPage.test.tsx @@ -0,0 +1,61 @@ +import { screen, waitFor } from "@testing-library/react"; +import { describe, expect, test } from "bun:test"; +import { createElement } from "react"; + +import { SettingsPage } from "../../../../src/web/features/settings/index"; +import { installFetchMock, jsonResponse, renderWithProviders } from "../../test-utils"; + +function mockSettingsResponse(theme = "system"): Response { + return jsonResponse({ theme }); +} + +describe("SettingsPage", () => { + test("渲染主题卡片", () => { + installFetchMock((call) => { + if (call.url.includes("/api/settings")) return mockSettingsResponse(); + return jsonResponse({}); + }); + + renderWithProviders(createElement(SettingsPage)); + + expect(screen.getByText("主题")).not.toBeNull(); + }); + + test("渲染主题 Segmented 选项", () => { + installFetchMock((call) => { + if (call.url.includes("/api/settings")) return mockSettingsResponse(); + return jsonResponse({}); + }); + + renderWithProviders(createElement(SettingsPage)); + + expect(screen.getByText("系统")).not.toBeNull(); + expect(screen.getByText("明亮")).not.toBeNull(); + expect(screen.getByText("黑暗")).not.toBeNull(); + }); + + test("API 加载中时不显示保存状态", () => { + installFetchMock((call) => { + if (call.url.includes("/api/settings")) return mockSettingsResponse(); + return jsonResponse({}); + }); + + renderWithProviders(createElement(SettingsPage)); + + expect(screen.queryByText("保存中...")).toBeNull(); + }); + + test("GET /api/settings 获取已保存主题", async () => { + installFetchMock((call) => { + if (call.url.includes("/api/settings")) return mockSettingsResponse("dark"); + return jsonResponse({}); + }); + + renderWithProviders(createElement(SettingsPage)); + + await waitFor(() => { + const segmented = document.querySelector(".ant-segmented"); + expect(segmented).not.toBeNull(); + }); + }); +}); diff --git a/tests/web/routes/workbench.test.tsx b/tests/web/routes/workbench.test.tsx index 3b64dbf..7cb5480 100644 --- a/tests/web/routes/workbench.test.tsx +++ b/tests/web/routes/workbench.test.tsx @@ -18,6 +18,12 @@ function createMockHandler(overrides?: { status?: "active" | "archived" }) { const project = { ...MOCK_PROJECT, ...overrides }; const handler = (input: RequestInfo | URL) => { const url = input instanceof Request ? input.url : typeof input === "string" ? input : input.toString(); + if (url.includes("/api/settings")) { + return new Response(JSON.stringify({ theme: "system" }), { + headers: { "Content-Type": "application/json" }, + status: 200, + }); + } if (url.includes("/api/meta")) { return new Response( JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }), @@ -38,7 +44,8 @@ function createMockHandler(overrides?: { status?: "active" | "archived" }) { } return new Response(JSON.stringify({ error: "Not Found" }), { status: 404 }); }; - const mocked = handler as unknown as typeof fetch; + const mocked = ((input: RequestInfo | URL, _init?: RequestInit) => + Promise.resolve(handler(input))) as unknown as typeof fetch; globalThis.fetch = mocked; window.fetch = mocked; }