feat: 合并设置页表单化与紧凑模式

This commit is contained in:
2026-06-06 23:29:24 +08:00
15 changed files with 383 additions and 57 deletions

View File

@@ -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 ThemeConfigalgorithm、cssVar、token、components.Layout |
| `use-current-project` | `shared/hooks/use-current-project.ts` | 当前工作台项目 + ProjectContext需在 ProjectProvider 内) |
| `use-materials.ts` | `shared/hooks/use-materials.ts` | 素材 CRUDcreate/delete/fetch/list + Query hooks |
| 文件 | 导出 |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `theme/theme-config.ts` | `buildThemeConfig({ compact, effectiveTheme })` — 构建 antd ThemeConfigalgorithm 数组、cssVar、token、components.Layoutcompact 时组合 compactAlgorithm 并降低 controlHeight |
| `use-current-project` | `shared/hooks/use-current-project.ts` | 当前工作台项目 + ProjectContext需在 ProjectProvider 内) |
| `use-materials.ts` | `shared/hooks/use-materials.ts` | 素材 CRUDcreate/delete/fetch/list + Query hooks |
### 共享工具函数

View File

@@ -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<SettingsData>;
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<SettingsData>, _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;

View File

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

View File

@@ -238,6 +238,7 @@ export type ProviderType = "anthropic" | "openai" | "openai-compatible";
export type RuntimeMode = "development" | "production" | "test";
export interface SettingsData {
compact?: boolean;
theme: ThemePreference;
}

View File

@@ -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 (
<Card extra={isUpdating ? "保存中..." : undefined} title="主题" type="inner">
<Segmented
block
onChange={(value) => handleThemeChange(value)}
options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
value={preference}
/>
<Card title="主题" type="inner">
<Form
className="settings-form"
colon={false}
disabled={isUpdating}
labelAlign="left"
labelCol={{ flex: "120px" }}
layout="horizontal"
>
<Form.Item colon={false} help="选择跟随系统将自动适配操作系统的深浅色偏好" label="主题模式">
<Radio.Group
buttonStyle="solid"
onChange={(e) => handleThemeChange(parseThemePreference(e.target.value))}
optionType="button"
options={THEME_OPTIONS.map((o) => ({ label: o.label, value: o.value }))}
value={preference}
/>
</Form.Item>
<Form.Item colon={false} help="开启后控件间距和高度变小,显示更多内容" label="紧凑模式">
<Switch checked={compact} onChange={handleCompactChange} />
</Form.Item>
</Form>
</Card>
);
}

View File

@@ -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 (
<XProvider locale={locale} theme={buildThemeConfig(effectiveTheme)}>
<XProvider locale={locale} theme={buildThemeConfig({ compact, effectiveTheme })}>
<AntApp>
<Layout className="app-layout">
<Header className="app-header">

View File

@@ -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<ThemePreference>(() => readThemePreference());
const [compact, setCompactState] = useState<boolean>(() => 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 等环境兼容
}
}

View File

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

View File

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

View File

@@ -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" });
});
});
});

View File

@@ -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" });
});
});
});

View File

@@ -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", () => {

View File

@@ -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();
});
});
});

View File

@@ -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<string, string>();
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();
});
});

View File

@@ -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");
});
});