feat: 实现多主题系统,支持6套主题切换和设置页面
重构 ThemeContext 为多主题模型(themeId + followSystem + systemIsDark), 新增设置页面(主题下拉栏 + 跟随系统开关),移除旧 ThemeToggle 按钮, 引入 antd-style 和 clsx 依赖支持 MUI/shadcn/Bootstrap/玻璃主题。
This commit is contained in:
@@ -3,11 +3,8 @@ import { describe, it, expect, vi } from 'vitest';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
import { AppLayout } from '@/components/AppLayout';
|
||||
|
||||
const mockToggleTheme = vi.fn();
|
||||
const mockSetTheme = vi.fn();
|
||||
|
||||
vi.mock('@/contexts/ThemeContext', () => ({
|
||||
useTheme: vi.fn(() => ({ mode: 'light', toggleTheme: mockToggleTheme, setTheme: mockSetTheme })),
|
||||
useTheme: vi.fn(() => ({ effectiveThemeId: 'default', themeId: 'default', followSystem: false, systemIsDark: false, setThemeId: vi.fn(), setFollowSystem: vi.fn() })),
|
||||
}));
|
||||
|
||||
const renderWithRouter = (component: React.ReactNode) => {
|
||||
@@ -29,27 +26,17 @@ describe('AppLayout', () => {
|
||||
expect(screen.getByText('用量统计')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders theme toggle button with visible color in sidebar', () => {
|
||||
it('renders settings menu item', () => {
|
||||
renderWithRouter(<AppLayout />);
|
||||
|
||||
const themeButton = screen.getByRole('button', { name: 'moon' });
|
||||
expect(themeButton).toBeInTheDocument();
|
||||
expect(themeButton.style.color).toBe('rgba(255, 255, 255, 0.85)');
|
||||
expect(screen.getByText('设置')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders theme toggle button visible in dark mode', async () => {
|
||||
const { useTheme } = await import('@/contexts/ThemeContext');
|
||||
vi.mocked(useTheme).mockReturnValue({
|
||||
mode: 'dark',
|
||||
toggleTheme: mockToggleTheme,
|
||||
setTheme: mockSetTheme,
|
||||
});
|
||||
|
||||
it('does not render theme toggle button', () => {
|
||||
renderWithRouter(<AppLayout />);
|
||||
|
||||
const themeButton = screen.getByRole('button', { name: 'sun' });
|
||||
expect(themeButton).toBeInTheDocument();
|
||||
expect(themeButton.style.color).toBe('rgba(255, 255, 255, 0.85)');
|
||||
expect(screen.queryByRole('button', { name: 'moon' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'sun' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders content outlet', () => {
|
||||
@@ -69,4 +56,20 @@ describe('AppLayout', () => {
|
||||
|
||||
expect(container.querySelector('.ant-layout-header')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses effectiveThemeId for header background in dark mode', async () => {
|
||||
const { useTheme } = await import('@/contexts/ThemeContext');
|
||||
vi.mocked(useTheme).mockReturnValue({
|
||||
effectiveThemeId: 'dark',
|
||||
themeId: 'dark',
|
||||
followSystem: false,
|
||||
systemIsDark: false,
|
||||
setThemeId: vi.fn(),
|
||||
setFollowSystem: vi.fn(),
|
||||
});
|
||||
|
||||
const { container } = renderWithRouter(<AppLayout />);
|
||||
const header = container.querySelector('.ant-layout-header') as HTMLElement;
|
||||
expect(header.style.background).toBe('#141414');
|
||||
});
|
||||
});
|
||||
|
||||
123
frontend/src/__tests__/contexts/ThemeContext.test.tsx
Normal file
123
frontend/src/__tests__/contexts/ThemeContext.test.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { ThemeProvider, useTheme } from '@/contexts/ThemeContext';
|
||||
|
||||
describe('ThemeContext', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('returns default theme on initial load', () => {
|
||||
const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
|
||||
|
||||
expect(result.current.themeId).toBe('default');
|
||||
expect(result.current.followSystem).toBe(false);
|
||||
expect(result.current.systemIsDark).toBe(false);
|
||||
expect(result.current.effectiveThemeId).toBe('default');
|
||||
});
|
||||
|
||||
it('restores themeId from localStorage', () => {
|
||||
localStorage.setItem('nex-theme-id', 'mui');
|
||||
|
||||
const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
|
||||
|
||||
expect(result.current.themeId).toBe('mui');
|
||||
});
|
||||
|
||||
it('restores followSystem from localStorage', () => {
|
||||
localStorage.setItem('nex-follow-system', 'true');
|
||||
|
||||
const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
|
||||
|
||||
expect(result.current.followSystem).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to default for invalid themeId', () => {
|
||||
localStorage.setItem('nex-theme-id', 'invalid-theme');
|
||||
|
||||
const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
|
||||
|
||||
expect(result.current.themeId).toBe('default');
|
||||
});
|
||||
|
||||
it('setThemeId updates themeId and persists to localStorage', () => {
|
||||
const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
|
||||
|
||||
act(() => {
|
||||
result.current.setThemeId('shadcn');
|
||||
});
|
||||
|
||||
expect(result.current.themeId).toBe('shadcn');
|
||||
expect(localStorage.getItem('nex-theme-id')).toBe('shadcn');
|
||||
});
|
||||
|
||||
it('setFollowSystem updates followSystem and persists to localStorage', () => {
|
||||
const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
|
||||
|
||||
act(() => {
|
||||
result.current.setFollowSystem(true);
|
||||
});
|
||||
|
||||
expect(result.current.followSystem).toBe(true);
|
||||
expect(localStorage.getItem('nex-follow-system')).toBe('true');
|
||||
});
|
||||
|
||||
it('computes effectiveThemeId as dark when followSystem and systemIsDark', () => {
|
||||
localStorage.setItem('nex-theme-id', 'mui');
|
||||
localStorage.setItem('nex-follow-system', 'true');
|
||||
|
||||
const mediaQuerySpy = vi.spyOn(window, 'matchMedia').mockReturnValue({
|
||||
matches: true,
|
||||
media: '(prefers-color-scheme: dark)',
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
|
||||
|
||||
expect(result.current.effectiveThemeId).toBe('dark');
|
||||
|
||||
mediaQuerySpy.mockRestore();
|
||||
});
|
||||
|
||||
it('computes effectiveThemeId as themeId when followSystem but system is light', () => {
|
||||
localStorage.setItem('nex-theme-id', 'mui');
|
||||
localStorage.setItem('nex-follow-system', 'true');
|
||||
|
||||
const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
|
||||
|
||||
expect(result.current.effectiveThemeId).toBe('mui');
|
||||
});
|
||||
|
||||
it('computes effectiveThemeId as themeId when followSystem is off', () => {
|
||||
localStorage.setItem('nex-theme-id', 'glass');
|
||||
localStorage.setItem('nex-follow-system', 'false');
|
||||
|
||||
const mediaQuerySpy = vi.spyOn(window, 'matchMedia').mockReturnValue({
|
||||
matches: true,
|
||||
media: '(prefers-color-scheme: dark)',
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
|
||||
|
||||
expect(result.current.effectiveThemeId).toBe('glass');
|
||||
|
||||
mediaQuerySpy.mockRestore();
|
||||
});
|
||||
|
||||
it('throws error when useTheme is used outside ThemeProvider', () => {
|
||||
expect(() => {
|
||||
renderHook(() => useTheme());
|
||||
}).toThrow('useTheme must be used within ThemeProvider');
|
||||
});
|
||||
});
|
||||
74
frontend/src/__tests__/pages/Settings.test.tsx
Normal file
74
frontend/src/__tests__/pages/Settings.test.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
import { SettingsPage } from '@/pages/Settings';
|
||||
|
||||
const mockSetThemeId = vi.fn();
|
||||
const mockSetFollowSystem = vi.fn();
|
||||
|
||||
vi.mock('@/contexts/ThemeContext', () => ({
|
||||
useTheme: vi.fn(() => ({
|
||||
themeId: 'default',
|
||||
followSystem: false,
|
||||
systemIsDark: false,
|
||||
effectiveThemeId: 'default',
|
||||
setThemeId: mockSetThemeId,
|
||||
setFollowSystem: mockSetFollowSystem,
|
||||
})),
|
||||
}));
|
||||
|
||||
const renderWithRouter = (component: React.ReactNode) => {
|
||||
return render(<BrowserRouter>{component}</BrowserRouter>);
|
||||
};
|
||||
|
||||
describe('SettingsPage', () => {
|
||||
it('renders theme card', () => {
|
||||
renderWithRouter(<SettingsPage />);
|
||||
|
||||
expect(screen.getByText('跟随系统')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders theme select with all 6 options', async () => {
|
||||
renderWithRouter(<SettingsPage />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseDown(select);
|
||||
|
||||
expect(screen.getByText('暗黑')).toBeInTheDocument();
|
||||
expect(screen.getByText('MUI')).toBeInTheDocument();
|
||||
expect(screen.getByText('shadcn')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bootstrap')).toBeInTheDocument();
|
||||
expect(screen.getByText('玻璃')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders follow system switch', () => {
|
||||
renderWithRouter(<SettingsPage />);
|
||||
|
||||
expect(screen.getByText('跟随系统')).toBeInTheDocument();
|
||||
const switchEl = screen.getByRole('switch');
|
||||
expect(switchEl).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls setThemeId when selecting a theme', async () => {
|
||||
renderWithRouter(<SettingsPage />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(select);
|
||||
|
||||
const option = screen.getByText('MUI');
|
||||
fireEvent.click(option);
|
||||
|
||||
expect(mockSetThemeId).toHaveBeenCalledWith('mui');
|
||||
});
|
||||
|
||||
it('calls setFollowSystem when toggling the switch', () => {
|
||||
renderWithRouter(<SettingsPage />);
|
||||
|
||||
const switchEl = screen.getByRole('switch');
|
||||
fireEvent.click(switchEl);
|
||||
|
||||
expect(mockSetFollowSystem).toHaveBeenCalledWith(true, expect.anything());
|
||||
});
|
||||
});
|
||||
61
frontend/src/__tests__/themes/index.test.ts
Normal file
61
frontend/src/__tests__/themes/index.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { themeOptions, isValidThemeId, useThemeConfig, type ThemeId } from '@/themes';
|
||||
|
||||
describe('Theme Registry', () => {
|
||||
it('contains all 6 theme options', () => {
|
||||
expect(themeOptions).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('has correct theme IDs and labels', () => {
|
||||
const expected: Array<{ id: ThemeId; label: string }> = [
|
||||
{ id: 'default', label: '默认' },
|
||||
{ id: 'dark', label: '暗黑' },
|
||||
{ id: 'mui', label: 'MUI' },
|
||||
{ id: 'shadcn', label: 'shadcn' },
|
||||
{ id: 'bootstrap', label: 'Bootstrap' },
|
||||
{ id: 'glass', label: '玻璃' },
|
||||
];
|
||||
|
||||
expected.forEach(({ id, label }) => {
|
||||
const option = themeOptions.find((o) => o.id === id);
|
||||
expect(option).toBeDefined();
|
||||
expect(option!.label).toBe(label);
|
||||
});
|
||||
});
|
||||
|
||||
it('isValidThemeId returns true for valid IDs', () => {
|
||||
expect(isValidThemeId('default')).toBe(true);
|
||||
expect(isValidThemeId('dark')).toBe(true);
|
||||
expect(isValidThemeId('mui')).toBe(true);
|
||||
expect(isValidThemeId('shadcn')).toBe(true);
|
||||
expect(isValidThemeId('bootstrap')).toBe(true);
|
||||
expect(isValidThemeId('glass')).toBe(true);
|
||||
});
|
||||
|
||||
it('isValidThemeId returns false for invalid IDs', () => {
|
||||
expect(isValidThemeId('invalid')).toBe(false);
|
||||
expect(isValidThemeId('')).toBe(false);
|
||||
expect(isValidThemeId('LIGHT')).toBe(false);
|
||||
});
|
||||
|
||||
it('useThemeConfig returns config for default theme', () => {
|
||||
const { result } = renderHook(() => useThemeConfig('default'));
|
||||
expect(result.current).toBeDefined();
|
||||
expect(result.current.theme).toBeDefined();
|
||||
});
|
||||
|
||||
it('useThemeConfig returns config for dark theme with darkAlgorithm', () => {
|
||||
const { result } = renderHook(() => useThemeConfig('dark'));
|
||||
expect(result.current).toBeDefined();
|
||||
expect(result.current.theme).toBeDefined();
|
||||
expect(result.current.theme!.algorithm).toBeDefined();
|
||||
});
|
||||
|
||||
it('useThemeConfig returns config for mui theme', () => {
|
||||
const { result } = renderHook(() => useThemeConfig('mui'));
|
||||
expect(result.current).toBeDefined();
|
||||
expect(result.current.theme).toBeDefined();
|
||||
expect(result.current.theme!.token).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user