diff --git a/docs/development/frontend.md b/docs/development/frontend.md index 4a45e28..2b708fe 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` 配色);侧边栏折叠。主题切换已迁移至设置页(`/settings`)。Header 显示品牌名、版本号和布局标题。 +ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Sider/Content),主题配置由 `shared/theme/theme-config.ts` 的 `buildThemeConfig({ compact, effectiveTheme })` 集中构建(含 `algorithm` 数组组合 `compactAlgorithm`、`cssVar`、`borderRadius`、`controlHeight`、`components.Layout` 配色);侧边栏折叠。主题与紧凑模式切换在设置页(`/settings`)。Header 显示品牌名、版本号和布局标题。 `Sidebar`(`src/web/shared/components/Sidebar/`)纯展示组件,通过 `menuItems` props 接收配置。 @@ -31,7 +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` 持久化,悲观更新策略。 | +| 设置 | `/settings` | `features/settings/index.tsx` — 卡片式布局分区管理平台业务设置。"主题"卡片使用 antd Form 水平布局,包含主题模式(Radio.Group 按钮风:系统/明亮/黑暗)和紧凑模式(Switch 开关),使用 `useSettings` hook 通过 `GET/PUT /api/settings` 实时保存,`message` toast 反馈。 | | 聊天室 | `/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` | @@ -68,18 +68,18 @@ 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 + API 同步) | +| `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` | 当前是否暗色主题 | ### 共享主题配置 -| 文件 | 导出 | -| ----------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | -| `theme/theme-config.ts` | `buildThemeConfig(effectiveTheme)` — 构建 antd ThemeConfig(algorithm、cssVar、token、components.Layout) | -| `use-current-project` | `shared/hooks/use-current-project.ts` | 当前工作台项目 + ProjectContext(需在 ProjectProvider 内) | -| `use-materials.ts` | `shared/hooks/use-materials.ts` | 素材 CRUD(create/delete/fetch/list + Query hooks) | +| 文件 | 导出 | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| `theme/theme-config.ts` | `buildThemeConfig({ compact, effectiveTheme })` — 构建 antd ThemeConfig(algorithm 数组、cssVar、token、components.Layout),compact 时组合 compactAlgorithm 并降低 controlHeight | +| `use-current-project` | `shared/hooks/use-current-project.ts` | 当前工作台项目 + ProjectContext(需在 ProjectProvider 内) | +| `use-materials.ts` | `shared/hooks/use-materials.ts` | 素材 CRUD(create/delete/fetch/list + Query hooks) | ### 共享工具函数 diff --git a/src/server/db/settings.ts b/src/server/db/settings.ts index 5483e6e..79c7ae5 100644 --- a/src/server/db/settings.ts +++ b/src/server/db/settings.ts @@ -19,16 +19,17 @@ export function getSettings(raw: Database): SettingsData { .get(); if (!row) { - return { theme: "system" }; + return { compact: false, theme: "system" }; } try { const parsed = JSON.parse(row.data) as Partial; return { + compact: typeof parsed.compact === "boolean" ? parsed.compact : false, theme: parsed.theme === "dark" || parsed.theme === "light" || parsed.theme === "system" ? parsed.theme : "system", }; } catch { - return { theme: "system" }; + return { compact: false, theme: "system" }; } } @@ -40,7 +41,7 @@ export function updateSettings(raw: Database, data: Partial, _logg .where(and(eq(settings.id, SETTINGS_ID), notDeleted(settings))) .get(); - let currentData: SettingsData = { theme: "system" }; + let currentData: SettingsData = { compact: false, theme: "system" }; if (existing) { try { currentData = JSON.parse(existing.data) as SettingsData; diff --git a/src/server/routes/settings.ts b/src/server/routes/settings.ts index 0d282b0..d123001 100644 --- a/src/server/routes/settings.ts +++ b/src/server/routes/settings.ts @@ -32,6 +32,10 @@ export async function handleUpdateSettings( return jsonResponse(createApiError("theme 仅支持 dark、light、system", 400), { mode, status: 400 }); } + if (body.compact !== undefined && typeof body.compact !== "boolean") { + return jsonResponse(createApiError("compact 必须为布尔值", 400), { mode, status: 400 }); + } + const result = updateSettings(db, body, logger); logger.info({ data: result }, "设置已更新"); return jsonResponse(result, { mode }); diff --git a/src/shared/api.ts b/src/shared/api.ts index 5a0ed3f..8a56ccd 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -238,6 +238,7 @@ export type ProviderType = "anthropic" | "openai" | "openai-compatible"; export type RuntimeMode = "development" | "production" | "test"; export interface SettingsData { + compact?: boolean; theme: ThemePreference; } diff --git a/src/web/features/settings/index.tsx b/src/web/features/settings/index.tsx index ea3a9a6..760d779 100644 --- a/src/web/features/settings/index.tsx +++ b/src/web/features/settings/index.tsx @@ -1,5 +1,6 @@ -import { Card, Segmented } from "antd"; +import { App as AntApp, Card, Form, Radio, Switch } from "antd"; +import type { ThemePreference } from "../../../shared/api"; import { useSettings } from "../../shared/hooks/use-settings"; import { parseThemePreference, useThemePreference } from "../../shared/hooks/use-theme-preference"; @@ -9,30 +10,68 @@ const THEME_OPTIONS = [ { label: "黑暗", value: "dark" }, ] as const; +const SAVE_MESSAGE_KEY = "settings-save"; + export function SettingsPage() { - const { preference, setPreference } = useThemePreference(); + const { message } = AntApp.useApp(); + const { compact, preference, setCompact, setPreference } = useThemePreference(); const { isUpdating, updateSettings } = useSettings(); - const handleThemeChange = (value: string) => { - const theme = parseThemePreference(value); + const handleThemeChange = (value: ThemePreference) => { + message.open({ content: "保存中...", duration: 0, key: SAVE_MESSAGE_KEY, type: "loading" }); updateSettings( - { theme }, + { theme: value }, { + onError: () => { + void message.open({ content: "保存失败", duration: 2, key: SAVE_MESSAGE_KEY, type: "error" }); + }, onSuccess: () => { - setPreference(theme); + setPreference(value); + void message.open({ content: "已保存", duration: 1.5, key: SAVE_MESSAGE_KEY, type: "success" }); + }, + }, + ); + }; + + const handleCompactChange = (checked: boolean) => { + message.open({ content: "保存中...", duration: 0, key: SAVE_MESSAGE_KEY, type: "loading" }); + updateSettings( + { compact: checked }, + { + onError: () => { + void message.open({ content: "保存失败", duration: 2, key: SAVE_MESSAGE_KEY, type: "error" }); + }, + onSuccess: () => { + setCompact(checked); + void message.open({ content: "已保存", duration: 1.5, key: SAVE_MESSAGE_KEY, type: "success" }); }, }, ); }; return ( - - handleThemeChange(value)} - options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))} - value={preference} - /> + +
+ + handleThemeChange(parseThemePreference(e.target.value))} + optionType="button" + options={THEME_OPTIONS.map((o) => ({ label: o.label, value: o.value }))} + value={preference} + /> + + + + +
); } diff --git a/src/web/shared/components/ConsoleShell/ConsoleShell.tsx b/src/web/shared/components/ConsoleShell/ConsoleShell.tsx index e9259d0..d1f8972 100644 --- a/src/web/shared/components/ConsoleShell/ConsoleShell.tsx +++ b/src/web/shared/components/ConsoleShell/ConsoleShell.tsx @@ -18,7 +18,7 @@ import { ConsoleOutlet } from "./ConsoleOutlet"; const { Content, Header, Sider } = Layout; export function ConsoleShell({ headerExtra, menuItems, title }: ConsoleShellProps) { - const { effectiveTheme } = useThemePreference(); + const { compact, effectiveTheme } = useThemePreference(); const { collapsed, setCollapsed } = useSidebarCollapsed(); const { data: meta } = useMeta(); @@ -26,7 +26,7 @@ export function ConsoleShell({ headerExtra, menuItems, title }: ConsoleShellProp const locale = useMemo(() => ({ ...zhCN, ...zhCN_X }), []); return ( - +
diff --git a/src/web/shared/hooks/use-theme-preference.ts b/src/web/shared/hooks/use-theme-preference.ts index f7c0299..12f8c28 100644 --- a/src/web/shared/hooks/use-theme-preference.ts +++ b/src/web/shared/hooks/use-theme-preference.ts @@ -6,10 +6,13 @@ export type EffectiveTheme = "dark" | "light"; export type ThemePreference = "dark" | "light" | "system"; const PREFERENCE_CHANGE_EVENT = "theme-preference-change"; +const COMPACT_CHANGE_EVENT = "theme-compact-change"; export const THEME_PREFERENCE_STORAGE_KEY = "theme.preference"; export const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)"; +export const COMPACT_STORAGE_KEY = "theme.compact"; + export function getSystemPrefersDark(matchMedia: Window["matchMedia"] = window.matchMedia): boolean { try { return matchMedia(THEME_MEDIA_QUERY).matches; @@ -30,6 +33,14 @@ export function readThemePreference(storage: Storage = window.localStorage): The } } +export function readCompactPreference(storage: Storage = window.localStorage): boolean { + try { + return storage.getItem(COMPACT_STORAGE_KEY) === "true"; + } catch { + return false; + } +} + export function resolveEffectiveTheme(preference: ThemePreference, systemPrefersDark: boolean): EffectiveTheme { if (preference === "dark" || preference === "light") return preference; return systemPrefersDark ? "dark" : "light"; @@ -37,6 +48,7 @@ export function resolveEffectiveTheme(preference: ThemePreference, systemPrefers export function useThemePreference() { const [preference, setPreferenceState] = useState(() => readThemePreference()); + const [compact, setCompactState] = useState(() => readCompactPreference()); const [systemPrefersDark, setSystemPrefersDark] = useState(() => getSystemPrefersDark()); const effectiveTheme = resolveEffectiveTheme(preference, systemPrefersDark); @@ -52,6 +64,9 @@ export function useThemePreference() { const apiTheme = parseThemePreference(data.theme); setPreferenceState((prev) => (prev !== apiTheme ? apiTheme : prev)); writeThemePreference(apiTheme); + const apiCompact = typeof data.compact === "boolean" ? data.compact : false; + setCompactState((prev) => (prev !== apiCompact ? apiCompact : prev)); + writeCompactPreference(apiCompact); }) .catch(() => { // API 不可用时维持 localStorage 缓存值 @@ -78,12 +93,26 @@ export function useThemePreference() { return () => window.removeEventListener(PREFERENCE_CHANGE_EVENT, handleStorageEvent as EventListener); }, []); + useEffect(() => { + const handleCompactEvent = (event: CustomEvent) => { + const next = typeof event.detail === "boolean" ? event.detail : false; + setCompactState((prev) => (prev !== next ? next : prev)); + }; + window.addEventListener(COMPACT_CHANGE_EVENT, handleCompactEvent as EventListener); + return () => window.removeEventListener(COMPACT_CHANGE_EVENT, handleCompactEvent as EventListener); + }, []); + const setPreference = useCallback((nextPreference: ThemePreference) => { setPreferenceState(nextPreference); writeThemePreference(nextPreference); }, []); - return { effectiveTheme, preference, setPreference }; + const setCompact = useCallback((nextCompact: boolean) => { + setCompactState(nextCompact); + writeCompactPreference(nextCompact); + }, []); + + return { compact, effectiveTheme, preference, setCompact, setPreference }; } export function writeThemePreference(preference: ThemePreference, storage: Storage = window.localStorage) { @@ -98,3 +127,16 @@ export function writeThemePreference(preference: ThemePreference, storage: Stora // jsdom 等环境可能不支持 CustomEvent } } + +export function writeCompactPreference(value: boolean, storage: Storage = window.localStorage): void { + try { + storage.setItem(COMPACT_STORAGE_KEY, String(value)); + } catch { + // 存储不可用时不阻断 + } + try { + window.dispatchEvent(new CustomEvent(COMPACT_CHANGE_EVENT, { detail: value })); + } catch { + // jsdom 等环境兼容 + } +} diff --git a/src/web/shared/theme/theme-config.ts b/src/web/shared/theme/theme-config.ts index 4a0cb37..fbed1e8 100644 --- a/src/web/shared/theme/theme-config.ts +++ b/src/web/shared/theme/theme-config.ts @@ -2,9 +2,15 @@ import { theme } from "antd"; import type { EffectiveTheme } from "../hooks/use-theme-preference"; -export function buildThemeConfig(effectiveTheme: EffectiveTheme) { +interface BuildThemeConfigOptions { + compact?: boolean; + effectiveTheme: EffectiveTheme; +} + +export function buildThemeConfig({ compact = false, effectiveTheme }: BuildThemeConfigOptions) { const isDark = effectiveTheme === "dark"; - const algorithm = isDark ? theme.darkAlgorithm : theme.defaultAlgorithm; + const baseAlgorithm = isDark ? theme.darkAlgorithm : theme.defaultAlgorithm; + const algorithm = compact ? [baseAlgorithm, theme.compactAlgorithm] : [baseAlgorithm]; return { algorithm, @@ -27,7 +33,7 @@ export function buildThemeConfig(effectiveTheme: EffectiveTheme) { borderRadius: 10, colorLink: isDark ? "#a3a3a3" : "#0a0a0a", colorPrimary: isDark ? "#525252" : "#0a0a0a", - controlHeight: 36, + controlHeight: compact ? 28 : 36, }, }; } diff --git a/src/web/styles.css b/src/web/styles.css index bf84fc2..cc27e99 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -455,3 +455,8 @@ body { .markdown-table tbody tr:hover td { background: var(--ant-color-fill-quaternary); } + +/* 设置页表单:最后一项无底边距 */ +.settings-form .ant-form-item:last-child { + margin-bottom: 0; +} diff --git a/tests/server/db/settings.test.ts b/tests/server/db/settings.test.ts index 43c1cee..d750d9d 100644 --- a/tests/server/db/settings.test.ts +++ b/tests/server/db/settings.test.ts @@ -20,17 +20,17 @@ describe("设置数据访问层", () => { test("getSettings 无数据时返回默认值", () => { withSettingsDb((db) => { const result = getSettings(db); - expect(result).toEqual({ theme: "system" }); + expect(result).toEqual({ compact: false, theme: "system" }); }); }); test("updateSettings 写入并读取", () => { withSettingsDb((db) => { const updated = updateSettings(db, { theme: "dark" }, createNoopLogger()); - expect(updated).toEqual({ theme: "dark" }); + expect(updated).toEqual({ compact: false, theme: "dark" }); const read = getSettings(db); - expect(read).toEqual({ theme: "dark" }); + expect(read).toEqual({ compact: false, theme: "dark" }); }); }); @@ -38,7 +38,7 @@ describe("设置数据访问层", () => { withSettingsDb((db) => { updateSettings(db, { theme: "dark" }, createNoopLogger()); const result = updateSettings(db, { theme: "light" }, createNoopLogger()); - expect(result).toEqual({ theme: "light" }); + expect(result).toEqual({ compact: false, theme: "light" }); }); }); @@ -48,7 +48,7 @@ describe("设置数据访问层", () => { "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" }); + expect(result).toEqual({ compact: false, theme: "system" }); }); }); @@ -58,7 +58,7 @@ describe("设置数据访问层", () => { "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" }); + expect(result).toEqual({ compact: false, theme: "system" }); }); }); @@ -66,8 +66,8 @@ describe("设置数据访问层", () => { 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" }); + expect(a).toEqual({ compact: false, theme: "dark" }); + expect(b).toEqual({ compact: false, theme: "dark" }); const row = db .query("SELECT COUNT(*) as cnt FROM settings WHERE id = 'default' AND deleted_at IS NULL") @@ -75,4 +75,42 @@ describe("设置数据访问层", () => { expect(row.cnt).toBe(1); }); }); + + test("getSettings 无 compact 字段时默认 false", () => { + 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\":\"dark\"}')", + ); + const result = getSettings(db); + expect(result).toEqual({ compact: false, theme: "dark" }); + }); + }); + + test("updateSettings 写入 compact 并读取", () => { + withSettingsDb((db) => { + const updated = updateSettings(db, { compact: true }, createNoopLogger()); + expect(updated).toEqual({ compact: true, theme: "system" }); + + const read = getSettings(db); + expect(read).toEqual({ compact: true, theme: "system" }); + }); + }); + + test("updateSettings compact 与 theme 合并", () => { + withSettingsDb((db) => { + updateSettings(db, { theme: "dark" }, createNoopLogger()); + const result = updateSettings(db, { compact: true }, createNoopLogger()); + expect(result).toEqual({ compact: true, theme: "dark" }); + }); + }); + + test("getSettings compact 为非布尔值时回退 false", () => { + 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\":\"dark\",\"compact\":\"yes\"}')", + ); + const result = getSettings(db); + expect(result).toEqual({ compact: false, theme: "dark" }); + }); + }); }); diff --git a/tests/server/routes/settings.test.ts b/tests/server/routes/settings.test.ts index 325eaca..0fafc25 100644 --- a/tests/server/routes/settings.test.ts +++ b/tests/server/routes/settings.test.ts @@ -36,8 +36,8 @@ describe("设置 API 路由", () => { 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" }); + const body = (await res.json()) as { compact: boolean; theme: string }; + expect(body).toEqual({ compact: false, theme: "system" }); }); }); @@ -50,14 +50,14 @@ describe("设置 API 路由", () => { }); 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 putBody = (await putRes.json()) as { compact: boolean; theme: string }; + expect(putBody).toEqual({ compact: false, 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" }); + const getBody = (await getRes.json()) as { compact: boolean; theme: string }; + expect(getBody).toEqual({ compact: false, theme: "dark" }); }); }); @@ -77,8 +77,8 @@ describe("设置 API 路由", () => { }); const res2 = await updateSettingsViaHandler(req2, db); expect(res2.status).toBe(200); - const body = (await res2.json()) as { theme: string }; - expect(body).toEqual({ theme: "light" }); + const body = (await res2.json()) as { compact: boolean; theme: string }; + expect(body).toEqual({ compact: false, theme: "light" }); }); }); @@ -139,8 +139,56 @@ describe("设置 API 路由", () => { }); const res = await updateSettingsViaHandler(req, db); expect(res.status).toBe(200); - const body = (await res.json()) as { theme: string }; - expect(body).toEqual({ theme: "system" }); + const body = (await res.json()) as { compact: boolean; theme: string }; + expect(body).toEqual({ compact: false, theme: "system" }); + }); + }); + + test("PUT /api/settings compact 为非布尔值返回 400", async () => { + await withRouteDb(async (db) => { + const req = new Request("http://localhost/api/settings", { + body: JSON.stringify({ compact: "true" }), + headers: { "Content-Type": "application/json" }, + method: "PUT", + }); + const res = await updateSettingsViaHandler(req, db); + expect(res.status).toBe(400); + }); + }); + + test("PUT /api/settings compact=true 合法", async () => { + await withRouteDb(async (db) => { + const req = new Request("http://localhost/api/settings", { + body: JSON.stringify({ compact: true }), + headers: { "Content-Type": "application/json" }, + method: "PUT", + }); + const res = await updateSettingsViaHandler(req, db); + expect(res.status).toBe(200); + const body = (await res.json()) as { compact: boolean; theme: string }; + expect(body.compact).toBe(true); + expect(body.theme).toBe("system"); + }); + }); + + test("PUT /api/settings compact 与 theme 合并持久化", 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({ compact: true }), + headers: { "Content-Type": "application/json" }, + method: "PUT", + }); + const res2 = await updateSettingsViaHandler(req2, db); + expect(res2.status).toBe(200); + const body = (await res2.json()) as { compact: boolean; theme: string }; + expect(body).toEqual({ compact: true, theme: "dark" }); }); }); }); diff --git a/tests/web/components/ConsoleShell.test.tsx b/tests/web/components/ConsoleShell.test.tsx index 1d2895e..835af20 100644 --- a/tests/web/components/ConsoleShell.test.tsx +++ b/tests/web/components/ConsoleShell.test.tsx @@ -6,7 +6,7 @@ import { ConsoleShell } from "../../../src/web/shared/components/ConsoleShell/Co import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "../test-utils"; function mockSettingsResponse(): Response { - return jsonResponse({ theme: "system" }); + return jsonResponse({ compact: false, theme: "system" }); } describe("ConsoleShell", () => { diff --git a/tests/web/features/settings/SettingsPage.test.tsx b/tests/web/features/settings/SettingsPage.test.tsx index 51af860..54a760c 100644 --- a/tests/web/features/settings/SettingsPage.test.tsx +++ b/tests/web/features/settings/SettingsPage.test.tsx @@ -5,8 +5,8 @@ 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 }); +function mockSettingsResponse(theme = "system", compact = false): Response { + return jsonResponse({ compact, theme }); } describe("SettingsPage", () => { @@ -21,7 +21,7 @@ describe("SettingsPage", () => { expect(screen.getByText("主题")).not.toBeNull(); }); - test("渲染主题 Segmented 选项", () => { + test("渲染主题模式 Radio.Group 选项", () => { installFetchMock((call) => { if (call.url.includes("/api/settings")) return mockSettingsResponse(); return jsonResponse({}); @@ -34,7 +34,41 @@ describe("SettingsPage", () => { expect(screen.getByText("黑暗")).not.toBeNull(); }); - test("API 加载中时不显示保存状态", () => { + test("渲染紧凑模式标签和开关", () => { + installFetchMock((call) => { + if (call.url.includes("/api/settings")) return mockSettingsResponse(); + return jsonResponse({}); + }); + + renderWithProviders(createElement(SettingsPage)); + + expect(screen.getByText("紧凑模式")).not.toBeNull(); + }); + + test("渲染水平表单结构", () => { + installFetchMock((call) => { + if (call.url.includes("/api/settings")) return mockSettingsResponse(); + return jsonResponse({}); + }); + + renderWithProviders(createElement(SettingsPage)); + + const form = document.querySelector(".ant-form"); + expect(form).not.toBeNull(); + }); + + test("不再使用 Segmented", () => { + installFetchMock((call) => { + if (call.url.includes("/api/settings")) return mockSettingsResponse(); + return jsonResponse({}); + }); + + renderWithProviders(createElement(SettingsPage)); + + expect(document.querySelector(".ant-segmented")).toBeNull(); + }); + + test("不显示保存状态文本(已迁移到 toast)", () => { installFetchMock((call) => { if (call.url.includes("/api/settings")) return mockSettingsResponse(); return jsonResponse({}); @@ -45,17 +79,17 @@ describe("SettingsPage", () => { expect(screen.queryByText("保存中...")).toBeNull(); }); - test("GET /api/settings 获取已保存主题", async () => { + test("GET /api/settings 获取已保存主题和紧凑设置", async () => { installFetchMock((call) => { - if (call.url.includes("/api/settings")) return mockSettingsResponse("dark"); + if (call.url.includes("/api/settings")) return mockSettingsResponse("dark", true); return jsonResponse({}); }); renderWithProviders(createElement(SettingsPage)); await waitFor(() => { - const segmented = document.querySelector(".ant-segmented"); - expect(segmented).not.toBeNull(); + const radioGroup = document.querySelector(".ant-radio-group"); + expect(radioGroup).not.toBeNull(); }); }); }); diff --git a/tests/web/hooks/use-theme-preference.test.ts b/tests/web/hooks/use-theme-preference.test.ts index 18caf30..51d9bf8 100644 --- a/tests/web/hooks/use-theme-preference.test.ts +++ b/tests/web/hooks/use-theme-preference.test.ts @@ -1,12 +1,15 @@ import { describe, expect, test } from "bun:test"; import { + COMPACT_STORAGE_KEY, getSystemPrefersDark, parseThemePreference, + readCompactPreference, readThemePreference, resolveEffectiveTheme, THEME_MEDIA_QUERY, THEME_PREFERENCE_STORAGE_KEY, + writeCompactPreference, writeThemePreference, } from "../../../src/web/shared/hooks/use-theme-preference"; @@ -75,3 +78,60 @@ describe("theme preference 纯逻辑", () => { expect(resolveEffectiveTheme("system", false)).toBe("light"); }); }); + +function createCompactStorage(initial?: string): Storage { + const values = new Map(); + if (initial !== undefined) values.set(COMPACT_STORAGE_KEY, initial); + return { + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => Array.from(values.keys())[index] ?? null, + get length() { + return values.size; + }, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + }; +} + +describe("compact preference 纯逻辑", () => { + test("读取 compact 默认值 false", () => { + const storage = createCompactStorage(); + expect(readCompactPreference(storage)).toBe(false); + }); + + test("读取 compact=true", () => { + const storage = createCompactStorage("true"); + expect(readCompactPreference(storage)).toBe(true); + }); + + test("读取 compact=false", () => { + const storage = createCompactStorage("false"); + expect(readCompactPreference(storage)).toBe(false); + }); + + test("读取 compact 非法值回退 false", () => { + const storage = createCompactStorage("yes"); + expect(readCompactPreference(storage)).toBe(false); + }); + + test("写入 compact 值", () => { + const storage = createCompactStorage(); + writeCompactPreference(true, storage); + expect(storage.getItem(COMPACT_STORAGE_KEY)).toBe("true"); + }); + + test("storage 异常时不抛错", () => { + const brokenStorage = { + getItem: () => { + throw new Error("blocked"); + }, + setItem: () => { + throw new Error("blocked"); + }, + } as unknown as Storage; + + expect(readCompactPreference(brokenStorage)).toBe(false); + expect(() => writeCompactPreference(true, brokenStorage)).not.toThrow(); + }); +}); diff --git a/tests/web/shared/theme-config.test.ts b/tests/web/shared/theme-config.test.ts new file mode 100644 index 0000000..2576077 --- /dev/null +++ b/tests/web/shared/theme-config.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; +import { theme } from "antd"; + +import { buildThemeConfig } from "../../../src/web/shared/theme/theme-config"; + +describe("buildThemeConfig", () => { + test("compact=false 浅色:algorithm 仅 defaultAlgorithm,controlHeight=36", () => { + const config = buildThemeConfig({ compact: false, effectiveTheme: "light" }); + expect(config.algorithm).toEqual([theme.defaultAlgorithm]); + expect(config.token.controlHeight).toBe(36); + }); + + test("compact=false 深色:algorithm 仅 darkAlgorithm,controlHeight=36", () => { + const config = buildThemeConfig({ compact: false, effectiveTheme: "dark" }); + expect(config.algorithm).toEqual([theme.darkAlgorithm]); + expect(config.token.controlHeight).toBe(36); + }); + + test("compact=true 浅色:algorithm 包含 defaultAlgorithm + compactAlgorithm,controlHeight=28", () => { + const config = buildThemeConfig({ compact: true, effectiveTheme: "light" }); + expect(config.algorithm).toEqual([theme.defaultAlgorithm, theme.compactAlgorithm]); + expect(config.token.controlHeight).toBe(28); + }); + + test("compact=true 深色:algorithm 包含 darkAlgorithm + compactAlgorithm,controlHeight=28", () => { + const config = buildThemeConfig({ compact: true, effectiveTheme: "dark" }); + expect(config.algorithm).toEqual([theme.darkAlgorithm, theme.compactAlgorithm]); + expect(config.token.controlHeight).toBe(28); + }); + + test("compact 缺省时等同于 false", () => { + const config = buildThemeConfig({ effectiveTheme: "light" }); + expect(config.algorithm).toEqual([theme.defaultAlgorithm]); + expect(config.token.controlHeight).toBe(36); + }); + + test("深色主题配色保持不变", () => { + const config = buildThemeConfig({ compact: false, effectiveTheme: "dark" }); + expect(config.token.colorPrimary).toBe("#525252"); + expect(config.token.colorLink).toBe("#a3a3a3"); + }); + + test("浅色主题配色保持不变", () => { + const config = buildThemeConfig({ compact: false, effectiveTheme: "light" }); + expect(config.token.colorPrimary).toBe("#0a0a0a"); + expect(config.token.colorLink).toBe("#0a0a0a"); + }); +});