feat: 实现多主题系统,支持6套主题切换和设置页面
重构 ThemeContext 为多主题模型(themeId + followSystem + systemIsDark), 新增设置页面(主题下拉栏 + 跟随系统开关),移除旧 ThemeToggle 按钮, 引入 antd-style 和 clsx 依赖支持 MUI/shadcn/Bootstrap/玻璃主题。
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
import { ConfigProvider, theme } from 'antd';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import { AppRoutes } from '@/routes';
|
||||
import { ThemeProvider, useTheme } from '@/contexts/ThemeContext';
|
||||
import { useThemeConfig } from '@/themes';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -15,14 +16,11 @@ const queryClient = new QueryClient({
|
||||
});
|
||||
|
||||
function ThemedApp() {
|
||||
const { mode } = useTheme();
|
||||
const { effectiveThemeId } = useTheme();
|
||||
const configProps = useThemeConfig(effectiveThemeId);
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: mode === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
}}
|
||||
>
|
||||
<ConfigProvider {...configProps}>
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,26 @@
|
||||
import { useState } from 'react';
|
||||
import { Layout, Menu } from 'antd';
|
||||
import { CloudServerOutlined, BarChartOutlined } from '@ant-design/icons';
|
||||
import { CloudServerOutlined, BarChartOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/providers', label: '供应商管理', icon: <CloudServerOutlined /> },
|
||||
{ key: '/stats', label: '用量统计', icon: <BarChartOutlined /> },
|
||||
{ type: 'divider' as const },
|
||||
{ key: '/settings', label: '设置', icon: <SettingOutlined /> },
|
||||
];
|
||||
|
||||
export function AppLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { mode } = useTheme();
|
||||
const { effectiveThemeId } = useTheme();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const getPageTitle = () => {
|
||||
if (location.pathname === '/providers') return '供应商管理';
|
||||
if (location.pathname === '/stats') return '用量统计';
|
||||
if (location.pathname === '/settings') return '设置';
|
||||
return 'AI Gateway';
|
||||
};
|
||||
|
||||
@@ -62,27 +64,16 @@ export function AppLayout() {
|
||||
onClick={({ key }) => navigate(key)}
|
||||
style={{ flex: 1, overflow: 'auto' }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 0',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</Layout.Sider>
|
||||
<Layout style={{ marginLeft: collapsed ? 80 : 200, transition: 'all 0.2s' }}>
|
||||
<Layout.Header
|
||||
style={{
|
||||
padding: '0 2rem',
|
||||
background: mode === 'dark' ? '#141414' : '#fff',
|
||||
background: effectiveThemeId === 'dark' ? '#141414' : '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderBottom: mode === 'dark' ? '1px solid #303030' : '1px solid #f0f0f0',
|
||||
borderBottom: effectiveThemeId === 'dark' ? '1px solid #303030' : '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<h1 style={{ margin: 0, fontSize: '1.25rem' }}>{getPageTitle()}</h1>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { SunOutlined, MoonOutlined } from '@ant-design/icons';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { mode, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<Tooltip title={mode === 'light' ? '切换到暗色模式' : '切换到亮色模式'}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={mode === 'light' ? <MoonOutlined /> : <SunOutlined />}
|
||||
onClick={toggleTheme}
|
||||
style={{ color: 'rgba(255, 255, 255, 0.85)' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||
import { createContext, useContext, useState, useEffect, useMemo, useCallback, type ReactNode } from 'react';
|
||||
import type { ThemeId } from '@/themes';
|
||||
import { isValidThemeId } from '@/themes';
|
||||
|
||||
type ThemeMode = 'light' | 'dark';
|
||||
const STORAGE_KEY_THEME = 'nex-theme-id';
|
||||
const STORAGE_KEY_FOLLOW = 'nex-follow-system';
|
||||
const DEFAULT_THEME: ThemeId = 'default';
|
||||
|
||||
interface ThemeContextValue {
|
||||
mode: ThemeMode;
|
||||
toggleTheme: () => void;
|
||||
setTheme: (mode: ThemeMode) => void;
|
||||
themeId: ThemeId;
|
||||
followSystem: boolean;
|
||||
systemIsDark: boolean;
|
||||
effectiveThemeId: ThemeId;
|
||||
setThemeId: (id: ThemeId) => void;
|
||||
setFollowSystem: (follow: boolean) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
@@ -15,26 +22,59 @@ interface ThemeProviderProps {
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: ThemeProviderProps) {
|
||||
// 从 localStorage 恢复主题
|
||||
const [mode, setMode] = useState<ThemeMode>(() => {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
const saved = localStorage.getItem('theme-mode');
|
||||
return (saved as ThemeMode) || 'light';
|
||||
const [themeId, setThemeIdState] = useState<ThemeId>(() => {
|
||||
if (typeof window === 'undefined') return DEFAULT_THEME;
|
||||
const saved = localStorage.getItem(STORAGE_KEY_THEME);
|
||||
if (saved && isValidThemeId(saved)) return saved;
|
||||
return DEFAULT_THEME;
|
||||
});
|
||||
|
||||
// 持久化主题
|
||||
useEffect(() => {
|
||||
localStorage.setItem('theme-mode', mode);
|
||||
// 更新 document class(方便全局样式判断)
|
||||
document.documentElement.classList.toggle('dark', mode === 'dark');
|
||||
}, [mode]);
|
||||
const [followSystem, setFollowSystemState] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return localStorage.getItem(STORAGE_KEY_FOLLOW) === 'true';
|
||||
});
|
||||
|
||||
const toggleTheme = () => {
|
||||
setMode(prev => prev === 'light' ? 'dark' : 'light');
|
||||
};
|
||||
const [systemIsDark, setSystemIsDark] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
setSystemIsDark(e.matches);
|
||||
};
|
||||
mediaQuery.addEventListener('change', handler);
|
||||
return () => mediaQuery.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY_THEME, themeId);
|
||||
}, [themeId]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY_FOLLOW, String(followSystem));
|
||||
}, [followSystem]);
|
||||
|
||||
const effectiveThemeId = useMemo<ThemeId>(() => {
|
||||
if (followSystem && systemIsDark) return 'dark';
|
||||
return themeId;
|
||||
}, [themeId, followSystem, systemIsDark]);
|
||||
|
||||
const setThemeId = useCallback((id: ThemeId) => {
|
||||
setThemeIdState(id);
|
||||
}, []);
|
||||
|
||||
const setFollowSystem = useCallback((follow: boolean) => {
|
||||
setFollowSystemState(follow);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', effectiveThemeId === 'dark');
|
||||
}, [effectiveThemeId]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ mode, toggleTheme, setTheme: setMode }}>
|
||||
<ThemeContext.Provider value={{ themeId, followSystem, systemIsDark, effectiveThemeId, setThemeId, setFollowSystem }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
|
||||
40
frontend/src/pages/Settings/index.tsx
Normal file
40
frontend/src/pages/Settings/index.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Card, Select, Switch, Space, Typography } from 'antd';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { themeOptions, type ThemeId } from '@/themes';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export function SettingsPage() {
|
||||
const { themeId, followSystem, setThemeId, setFollowSystem } = useTheme();
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Card title="主题">
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<Text strong>主题</Text>
|
||||
</div>
|
||||
<Select
|
||||
value={themeId}
|
||||
onChange={(value: ThemeId) => setThemeId(value)}
|
||||
style={{ width: 180 }}
|
||||
options={themeOptions.map((opt) => ({
|
||||
value: opt.id,
|
||||
label: opt.label,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<Text strong>跟随系统</Text>
|
||||
<br />
|
||||
<Text type="secondary">开启后自动跟随系统暗色模式</Text>
|
||||
</div>
|
||||
<Switch checked={followSystem} onChange={setFollowSystem} />
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from 'react-router';
|
||||
import { AppLayout } from '@/components/AppLayout';
|
||||
import { ProvidersPage } from '@/pages/Providers';
|
||||
import { StatsPage } from '@/pages/Stats';
|
||||
import { SettingsPage } from '@/pages/Settings';
|
||||
import { NotFound } from '@/pages/NotFound';
|
||||
|
||||
export function AppRoutes() {
|
||||
@@ -11,6 +12,7 @@ export function AppRoutes() {
|
||||
<Route index element={<Navigate to="/providers" replace />} />
|
||||
<Route path="providers" element={<ProvidersPage />} />
|
||||
<Route path="stats" element={<StatsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
180
frontend/src/themes/bootstrap.ts
Normal file
180
frontend/src/themes/bootstrap.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useMemo } from 'react';
|
||||
import { theme } from 'antd';
|
||||
import type { ConfigProviderProps } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const useStyles = createStyles(({ css, cssVar }) => {
|
||||
return {
|
||||
boxBorder: css({
|
||||
border: `${cssVar.lineWidth} ${cssVar.lineType} color-mix(in srgb,${cssVar.colorBorder} 80%, #000)`,
|
||||
}),
|
||||
alertRoot: css({
|
||||
color: cssVar.colorInfoText,
|
||||
textShadow: `0 1px 0 rgba(255, 255, 255, 0.8)`,
|
||||
}),
|
||||
modalContainer: css({
|
||||
padding: 0,
|
||||
borderRadius: cssVar.borderRadiusLG,
|
||||
}),
|
||||
modalHeader: css({
|
||||
borderBottom: `${cssVar.lineWidth} ${cssVar.lineType} ${cssVar.colorSplit}`,
|
||||
padding: `${cssVar.padding} ${cssVar.paddingLG}`,
|
||||
}),
|
||||
modalBody: css({
|
||||
padding: `${cssVar.padding} ${cssVar.paddingLG}`,
|
||||
}),
|
||||
modalFooter: css({
|
||||
borderTop: `${cssVar.lineWidth} ${cssVar.lineType} ${cssVar.colorSplit}`,
|
||||
padding: `${cssVar.padding} ${cssVar.paddingLG}`,
|
||||
backgroundColor: cssVar.colorBgContainerDisabled,
|
||||
boxShadow: `inset 0 1px 0 ${cssVar.colorBgContainer}`,
|
||||
}),
|
||||
buttonRoot: css({
|
||||
backgroundImage: `linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.2))`,
|
||||
boxShadow: `inset 0 1px 0 rgba(255, 255, 255, 0.15)`,
|
||||
transition: 'none',
|
||||
borderColor: `rgba(0, 0, 0, 0.3)`,
|
||||
textShadow: `0 -1px 0 rgba(0, 0, 0, 0.2)`,
|
||||
'&:hover, &:active': {
|
||||
backgroundImage: `linear-gradient(rgba(0, 0, 0, 0.15) 100%)`,
|
||||
},
|
||||
'&:active': {
|
||||
boxShadow: `inset 0 1px 3px rgba(0, 0, 0, 0.15)`,
|
||||
},
|
||||
}),
|
||||
buttonColorDefault: css({
|
||||
textShadow: 'none',
|
||||
color: cssVar.colorText,
|
||||
borderBottomColor: 'rgba(0, 0, 0, 0.5)',
|
||||
}),
|
||||
popupBox: css({
|
||||
borderRadius: cssVar.borderRadiusLG,
|
||||
backgroundColor: cssVar.colorBgContainer,
|
||||
ul: {
|
||||
paddingInline: 0,
|
||||
},
|
||||
}),
|
||||
dropdownItem: css({
|
||||
borderRadius: 0,
|
||||
transition: 'none',
|
||||
paddingBlock: cssVar.paddingXXS,
|
||||
paddingInline: cssVar.padding,
|
||||
'&:hover, &:active, &:focus': {
|
||||
backgroundImage: `linear-gradient(to bottom, ${cssVar.colorPrimaryHover}, ${cssVar.colorPrimary})`,
|
||||
color: cssVar.colorTextLightSolid,
|
||||
},
|
||||
}),
|
||||
selectPopupRoot: css({
|
||||
paddingInline: 0,
|
||||
}),
|
||||
switchRoot: css({
|
||||
boxShadow: `inset 0 1px 3px rgba(0, 0, 0, 0.4)`,
|
||||
}),
|
||||
progressTrack: css({
|
||||
backgroundImage: `linear-gradient(to bottom, ${cssVar.colorPrimaryHover}, ${cssVar.colorPrimary})`,
|
||||
borderRadius: cssVar.borderRadiusSM,
|
||||
}),
|
||||
progressRail: css({
|
||||
borderRadius: cssVar.borderRadiusSM,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const useBootstrapTheme = () => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
return useMemo<ConfigProviderProps>(
|
||||
() => ({
|
||||
theme: {
|
||||
algorithm: theme.defaultAlgorithm,
|
||||
token: {
|
||||
borderRadius: 4,
|
||||
borderRadiusLG: 6,
|
||||
colorInfo: '#3a87ad',
|
||||
},
|
||||
components: {
|
||||
Tooltip: {
|
||||
fontSize: 12,
|
||||
},
|
||||
Checkbox: {
|
||||
colorBorder: '#666',
|
||||
borderRadius: 2,
|
||||
algorithm: true,
|
||||
},
|
||||
Radio: {
|
||||
colorBorder: '#666',
|
||||
borderRadius: 2,
|
||||
algorithm: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
wave: {
|
||||
showEffect: () => {},
|
||||
},
|
||||
modal: {
|
||||
classNames: {
|
||||
container: clsx(styles.boxBorder, styles.modalContainer),
|
||||
header: styles.modalHeader,
|
||||
body: styles.modalBody,
|
||||
footer: styles.modalFooter,
|
||||
},
|
||||
},
|
||||
button: {
|
||||
classNames: ({ props }) => ({
|
||||
root: clsx(styles.buttonRoot, props.color === 'default' && styles.buttonColorDefault),
|
||||
}),
|
||||
},
|
||||
alert: {
|
||||
className: styles.alertRoot,
|
||||
},
|
||||
colorPicker: {
|
||||
classNames: {
|
||||
root: styles.boxBorder,
|
||||
popup: {
|
||||
root: clsx(styles.boxBorder, styles.popupBox),
|
||||
},
|
||||
},
|
||||
arrow: false,
|
||||
},
|
||||
checkbox: {
|
||||
classNames: {},
|
||||
},
|
||||
dropdown: {
|
||||
classNames: {
|
||||
root: clsx(styles.boxBorder, styles.popupBox),
|
||||
item: styles.dropdownItem,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
classNames: {
|
||||
root: styles.boxBorder,
|
||||
popup: {
|
||||
root: clsx(styles.boxBorder, styles.selectPopupRoot),
|
||||
listItem: styles.dropdownItem,
|
||||
},
|
||||
},
|
||||
},
|
||||
switch: {
|
||||
classNames: {
|
||||
root: styles.switchRoot,
|
||||
},
|
||||
},
|
||||
progress: {
|
||||
classNames: {
|
||||
track: styles.progressTrack,
|
||||
rail: styles.progressRail,
|
||||
},
|
||||
styles: {
|
||||
rail: {
|
||||
height: 20,
|
||||
},
|
||||
track: { height: 20 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
};
|
||||
|
||||
export default useBootstrapTheme;
|
||||
10
frontend/src/themes/dark.ts
Normal file
10
frontend/src/themes/dark.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { theme } from 'antd';
|
||||
import type { ConfigProviderProps } from 'antd';
|
||||
|
||||
const useDarkTheme = (): ConfigProviderProps => ({
|
||||
theme: {
|
||||
algorithm: theme.darkAlgorithm,
|
||||
},
|
||||
});
|
||||
|
||||
export default useDarkTheme;
|
||||
10
frontend/src/themes/default.ts
Normal file
10
frontend/src/themes/default.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { theme } from 'antd';
|
||||
import type { ConfigProviderProps } from 'antd';
|
||||
|
||||
const useDefaultTheme = (): ConfigProviderProps => ({
|
||||
theme: {
|
||||
algorithm: theme.defaultAlgorithm,
|
||||
},
|
||||
});
|
||||
|
||||
export default useDefaultTheme;
|
||||
214
frontend/src/themes/glass.ts
Normal file
214
frontend/src/themes/glass.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useMemo } from 'react';
|
||||
import { theme } from 'antd';
|
||||
import type { ConfigProviderProps } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const useStyles = createStyles(({ css, cssVar }) => {
|
||||
const glassBorder = {
|
||||
boxShadow: [
|
||||
`${cssVar.boxShadowSecondary}`,
|
||||
`inset 0 0 5px 2px rgba(255, 255, 255, 0.3)`,
|
||||
`inset 0 5px 2px rgba(255, 255, 255, 0.2)`,
|
||||
].join(','),
|
||||
};
|
||||
|
||||
const glassBox = {
|
||||
...glassBorder,
|
||||
background: `color-mix(in srgb, ${cssVar.colorBgContainer} 15%, transparent)`,
|
||||
backdropFilter: 'blur(12px)',
|
||||
};
|
||||
|
||||
return {
|
||||
glassBorder,
|
||||
glassBox,
|
||||
notBackdropFilter: css({
|
||||
backdropFilter: 'none',
|
||||
}),
|
||||
app: css({
|
||||
textShadow: '0 1px rgba(0,0,0,0.1)',
|
||||
}),
|
||||
cardRoot: css({
|
||||
...glassBox,
|
||||
backgroundColor: `color-mix(in srgb, ${cssVar.colorBgContainer} 40%, transparent)`,
|
||||
}),
|
||||
modalContainer: css({
|
||||
...glassBox,
|
||||
backdropFilter: 'none',
|
||||
}),
|
||||
buttonRoot: css({
|
||||
...glassBorder,
|
||||
}),
|
||||
buttonRootDefaultColor: css({
|
||||
background: 'transparent',
|
||||
color: cssVar.colorText,
|
||||
'&:hover': {
|
||||
background: 'rgba(255,255,255,0.2)',
|
||||
color: `color-mix(in srgb, ${cssVar.colorText} 90%, transparent)`,
|
||||
},
|
||||
'&:active': {
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
color: `color-mix(in srgb, ${cssVar.colorText} 80%, transparent)`,
|
||||
},
|
||||
}),
|
||||
dropdownRoot: css({
|
||||
...glassBox,
|
||||
borderRadius: cssVar.borderRadiusLG,
|
||||
ul: {
|
||||
background: 'transparent',
|
||||
},
|
||||
}),
|
||||
switchRoot: css({ ...glassBorder, border: 'none' }),
|
||||
segmentedRoot: css({
|
||||
...glassBorder,
|
||||
background: 'transparent',
|
||||
backdropFilter: 'none',
|
||||
'& .ant-segmented-thumb': {
|
||||
...glassBox,
|
||||
},
|
||||
'& .ant-segmented-item-selected': {
|
||||
...glassBox,
|
||||
},
|
||||
}),
|
||||
radioButtonRoot: css({
|
||||
'&.ant-radio-button-wrapper': {
|
||||
...glassBorder,
|
||||
background: 'transparent',
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: cssVar.colorText,
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(255, 255, 255, 0.24)',
|
||||
color: cssVar.colorText,
|
||||
},
|
||||
'&.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled)': {
|
||||
...glassBox,
|
||||
borderColor: 'rgba(255, 255, 255, 0.28)',
|
||||
color: cssVar.colorText,
|
||||
'&::before': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.18)',
|
||||
},
|
||||
'&:hover': {
|
||||
color: cssVar.colorText,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const useGlassTheme = () => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
return useMemo<ConfigProviderProps>(
|
||||
() => ({
|
||||
theme: {
|
||||
algorithm: theme.defaultAlgorithm,
|
||||
token: {
|
||||
borderRadius: 12,
|
||||
borderRadiusLG: 12,
|
||||
borderRadiusSM: 12,
|
||||
borderRadiusXS: 12,
|
||||
motionDurationSlow: '0.2s',
|
||||
motionDurationMid: '0.1s',
|
||||
motionDurationFast: '0.05s',
|
||||
},
|
||||
},
|
||||
app: {
|
||||
className: styles.app,
|
||||
},
|
||||
card: {
|
||||
classNames: {
|
||||
root: styles.cardRoot,
|
||||
},
|
||||
},
|
||||
modal: {
|
||||
classNames: {
|
||||
container: styles.modalContainer,
|
||||
},
|
||||
},
|
||||
button: {
|
||||
classNames: ({ props }) => ({
|
||||
root: clsx(
|
||||
styles.buttonRoot,
|
||||
(props.variant !== 'solid' || props.color === 'default' || props.type === 'default') &&
|
||||
styles.buttonRootDefaultColor,
|
||||
),
|
||||
}),
|
||||
},
|
||||
alert: {
|
||||
className: clsx(styles.glassBox, styles.notBackdropFilter),
|
||||
},
|
||||
colorPicker: {
|
||||
classNames: {
|
||||
root: clsx(styles.glassBox, styles.notBackdropFilter),
|
||||
},
|
||||
arrow: false,
|
||||
},
|
||||
dropdown: {
|
||||
classNames: {
|
||||
root: styles.dropdownRoot,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
classNames: {
|
||||
root: clsx(styles.glassBox, styles.notBackdropFilter),
|
||||
popup: {
|
||||
root: styles.glassBox,
|
||||
},
|
||||
},
|
||||
},
|
||||
datePicker: {
|
||||
classNames: {
|
||||
root: clsx(styles.glassBox, styles.notBackdropFilter),
|
||||
popup: {
|
||||
container: styles.glassBox,
|
||||
},
|
||||
},
|
||||
},
|
||||
input: {
|
||||
classNames: {
|
||||
root: clsx(styles.glassBox, styles.notBackdropFilter),
|
||||
},
|
||||
},
|
||||
inputNumber: {
|
||||
classNames: {
|
||||
root: clsx(styles.glassBox, styles.notBackdropFilter),
|
||||
},
|
||||
},
|
||||
popover: {
|
||||
classNames: {
|
||||
container: styles.glassBox,
|
||||
},
|
||||
},
|
||||
switch: {
|
||||
classNames: {
|
||||
root: styles.switchRoot,
|
||||
},
|
||||
},
|
||||
radio: {
|
||||
classNames: {
|
||||
root: styles.radioButtonRoot,
|
||||
},
|
||||
},
|
||||
segmented: {
|
||||
className: styles.segmentedRoot,
|
||||
},
|
||||
progress: {
|
||||
classNames: {
|
||||
track: styles.glassBorder,
|
||||
},
|
||||
styles: {
|
||||
track: {
|
||||
height: 12,
|
||||
},
|
||||
rail: {
|
||||
height: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
};
|
||||
|
||||
export default useGlassTheme;
|
||||
49
frontend/src/themes/index.ts
Normal file
49
frontend/src/themes/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { ConfigProviderProps } from 'antd';
|
||||
import useDefaultTheme from './default';
|
||||
import useDarkTheme from './dark';
|
||||
import useMuiTheme from './mui';
|
||||
import useShadcnTheme from './shadcn';
|
||||
import useBootstrapTheme from './bootstrap';
|
||||
import useGlassTheme from './glass';
|
||||
|
||||
export type ThemeId = 'default' | 'dark' | 'mui' | 'shadcn' | 'bootstrap' | 'glass';
|
||||
|
||||
export interface ThemeOption {
|
||||
id: ThemeId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const themeOptions: ThemeOption[] = [
|
||||
{ id: 'default', label: '默认' },
|
||||
{ id: 'dark', label: '暗黑' },
|
||||
{ id: 'mui', label: 'MUI' },
|
||||
{ id: 'shadcn', label: 'shadcn' },
|
||||
{ id: 'bootstrap', label: 'Bootstrap' },
|
||||
{ id: 'glass', label: '玻璃' },
|
||||
];
|
||||
|
||||
const themeIdSet = new Set<ThemeId>(themeOptions.map((opt) => opt.id));
|
||||
|
||||
export function useThemeConfig(themeId: ThemeId): ConfigProviderProps {
|
||||
const defaultConfig = useDefaultTheme();
|
||||
const darkConfig = useDarkTheme();
|
||||
const muiConfig = useMuiTheme();
|
||||
const shadcnConfig = useShadcnTheme();
|
||||
const bootstrapConfig = useBootstrapTheme();
|
||||
const glassConfig = useGlassTheme();
|
||||
|
||||
const configs: Record<ThemeId, ConfigProviderProps> = {
|
||||
default: defaultConfig,
|
||||
dark: darkConfig,
|
||||
mui: muiConfig,
|
||||
shadcn: shadcnConfig,
|
||||
bootstrap: bootstrapConfig,
|
||||
glass: glassConfig,
|
||||
};
|
||||
|
||||
return configs[themeId] ?? configs.default;
|
||||
}
|
||||
|
||||
export function isValidThemeId(value: string): value is ThemeId {
|
||||
return themeIdSet.has(value as ThemeId);
|
||||
}
|
||||
281
frontend/src/themes/mui.ts
Normal file
281
frontend/src/themes/mui.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { useMemo } from 'react';
|
||||
import raf from '@rc-component/util/lib/raf';
|
||||
import { theme } from 'antd';
|
||||
import type { ConfigProviderProps, GetProp } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type WaveConfig = GetProp<ConfigProviderProps, 'wave'>;
|
||||
|
||||
const createHolder = (node: HTMLElement) => {
|
||||
const { borderWidth } = getComputedStyle(node);
|
||||
const borderWidthNum = Number.parseInt(borderWidth, 10);
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.style.position = 'absolute';
|
||||
div.style.inset = `-${borderWidthNum}px`;
|
||||
div.style.borderRadius = 'inherit';
|
||||
div.style.background = 'transparent';
|
||||
div.style.zIndex = '999';
|
||||
div.style.pointerEvents = 'none';
|
||||
div.style.overflow = 'hidden';
|
||||
node.appendChild(div);
|
||||
|
||||
return div;
|
||||
};
|
||||
|
||||
const createDot = (holder: HTMLElement, color: string, left: number, top: number, size = 0) => {
|
||||
const dot = document.createElement('div');
|
||||
dot.style.position = 'absolute';
|
||||
dot.style.left = `${left}px`;
|
||||
dot.style.top = `${top}px`;
|
||||
dot.style.width = `${size}px`;
|
||||
dot.style.height = `${size}px`;
|
||||
dot.style.borderRadius = '50%';
|
||||
dot.style.background = color;
|
||||
dot.style.transform = 'translate3d(-50%, -50%, 0)';
|
||||
dot.style.transition = 'all 1s ease-out';
|
||||
holder.appendChild(dot);
|
||||
return dot;
|
||||
};
|
||||
|
||||
const showInsetEffect: WaveConfig['showEffect'] = (node, { event, component }) => {
|
||||
if (component !== 'Button') {
|
||||
return;
|
||||
}
|
||||
|
||||
const holder = createHolder(node);
|
||||
const rect = holder.getBoundingClientRect();
|
||||
const left = event.clientX - rect.left;
|
||||
const top = event.clientY - rect.top;
|
||||
const dot = createDot(holder, 'rgba(255, 255, 255, 0.65)', left, top);
|
||||
|
||||
raf(() => {
|
||||
dot.ontransitionend = () => {
|
||||
holder.remove();
|
||||
};
|
||||
|
||||
dot.style.width = '200px';
|
||||
dot.style.height = '200px';
|
||||
dot.style.opacity = '0';
|
||||
});
|
||||
};
|
||||
|
||||
const useStyles = createStyles(({ css }) => {
|
||||
return {
|
||||
buttonPrimary: css({
|
||||
backgroundColor: '#1976d2',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.02857em',
|
||||
boxShadow:
|
||||
'0px 3px 1px -2px rgba(0,0,0,0.2), 0px 2px 2px 0px rgba(0,0,0,0.14), 0px 1px 5px 0px rgba(0,0,0,0.12)',
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}),
|
||||
buttonDefault: css({
|
||||
backgroundColor: '#ffffff',
|
||||
color: 'rgba(0, 0, 0, 0.87)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.23)',
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.02857em',
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}),
|
||||
buttonDanger: css({
|
||||
backgroundColor: '#d32f2f',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.02857em',
|
||||
boxShadow:
|
||||
'0px 3px 1px -2px rgba(0,0,0,0.2), 0px 2px 2px 0px rgba(0,0,0,0.14), 0px 1px 5px 0px rgba(0,0,0,0.12)',
|
||||
}),
|
||||
inputRoot: css({
|
||||
borderColor: 'rgba(0, 0, 0, 0.23)',
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}),
|
||||
inputElement: css({
|
||||
color: 'rgba(0, 0, 0, 0.87)',
|
||||
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
||||
}),
|
||||
inputError: css({
|
||||
borderColor: '#d32f2f',
|
||||
}),
|
||||
selectRoot: css({
|
||||
borderColor: 'rgba(0, 0, 0, 0.23)',
|
||||
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
||||
}),
|
||||
selectPopup: css({
|
||||
borderRadius: '4px',
|
||||
boxShadow:
|
||||
'0px 5px 5px -3px rgba(0,0,0,0.2), 0px 8px 10px 1px rgba(0,0,0,0.14), 0px 3px 14px 2px rgba(0,0,0,0.12)',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const useMuiTheme = () => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
return useMemo<ConfigProviderProps>(
|
||||
() => ({
|
||||
theme: {
|
||||
algorithm: theme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: '#1976d2',
|
||||
colorSuccess: '#2e7d32',
|
||||
colorWarning: '#ed6c02',
|
||||
colorError: '#d32f2f',
|
||||
colorInfo: '#0288d1',
|
||||
colorTextBase: '#212121',
|
||||
colorBgBase: '#fafafa',
|
||||
colorPrimaryBg: '#e3f2fd',
|
||||
colorPrimaryBgHover: '#bbdefb',
|
||||
colorPrimaryBorder: '#90caf9',
|
||||
colorPrimaryBorderHover: '#64b5f6',
|
||||
colorPrimaryHover: '#42a5f5',
|
||||
colorPrimaryActive: '#1565c0',
|
||||
colorPrimaryText: '#1976d2',
|
||||
colorPrimaryTextHover: '#42a5f5',
|
||||
colorPrimaryTextActive: '#1565c0',
|
||||
colorSuccessBg: '#e8f5e9',
|
||||
colorSuccessBgHover: '#c8e6c9',
|
||||
colorSuccessBorder: '#a5d6a7',
|
||||
colorSuccessBorderHover: '#81c784',
|
||||
colorSuccessHover: '#4caf50',
|
||||
colorSuccessActive: '#1b5e20',
|
||||
colorSuccessText: '#2e7d32',
|
||||
colorSuccessTextHover: '#4caf50',
|
||||
colorSuccessTextActive: '#1b5e20',
|
||||
colorWarningBg: '#fff3e0',
|
||||
colorWarningBgHover: '#ffe0b2',
|
||||
colorWarningBorder: '#ffcc02',
|
||||
colorWarningBorderHover: '#ffb74d',
|
||||
colorWarningHover: '#ff9800',
|
||||
colorWarningActive: '#e65100',
|
||||
colorWarningText: '#ed6c02',
|
||||
colorWarningTextHover: '#ff9800',
|
||||
colorWarningTextActive: '#e65100',
|
||||
colorErrorBg: '#ffebee',
|
||||
colorErrorBgHover: '#ffcdd2',
|
||||
colorErrorBorder: '#ef9a9a',
|
||||
colorErrorBorderHover: '#e57373',
|
||||
colorErrorHover: '#ef5350',
|
||||
colorErrorActive: '#c62828',
|
||||
colorErrorText: '#d32f2f',
|
||||
colorErrorTextHover: '#ef5350',
|
||||
colorErrorTextActive: '#c62828',
|
||||
colorInfoBg: '#e1f5fe',
|
||||
colorInfoBgHover: '#b3e5fc',
|
||||
colorInfoBorder: '#81d4fa',
|
||||
colorInfoBorderHover: '#4fc3f7',
|
||||
colorInfoHover: '#03a9f4',
|
||||
colorInfoActive: '#01579b',
|
||||
colorInfoText: '#0288d1',
|
||||
colorInfoTextHover: '#03a9f4',
|
||||
colorInfoTextActive: '#01579b',
|
||||
colorText: 'rgba(33, 33, 33, 0.87)',
|
||||
colorTextSecondary: 'rgba(33, 33, 33, 0.6)',
|
||||
colorTextTertiary: 'rgba(33, 33, 33, 0.38)',
|
||||
colorTextQuaternary: 'rgba(33, 33, 33, 0.26)',
|
||||
colorTextDisabled: 'rgba(33, 33, 33, 0.38)',
|
||||
colorBgContainer: '#ffffff',
|
||||
colorBgElevated: '#ffffff',
|
||||
colorBgLayout: '#f5f5f5',
|
||||
colorBgSpotlight: 'rgba(33, 33, 33, 0.85)',
|
||||
colorBgMask: 'rgba(33, 33, 33, 0.5)',
|
||||
colorBorder: '#e0e0e0',
|
||||
colorBorderSecondary: '#eeeeee',
|
||||
borderRadius: 4,
|
||||
borderRadiusXS: 1,
|
||||
borderRadiusSM: 2,
|
||||
borderRadiusLG: 6,
|
||||
padding: 16,
|
||||
paddingSM: 8,
|
||||
paddingLG: 24,
|
||||
margin: 16,
|
||||
marginSM: 8,
|
||||
marginLG: 24,
|
||||
boxShadow:
|
||||
'0px 2px 1px -1px rgba(0,0,0,0.2),0px 1px 1px 0px rgba(0,0,0,0.14),0px 1px 3px 0px rgba(0,0,0,0.12)',
|
||||
boxShadowSecondary:
|
||||
'0px 3px 3px -2px rgba(0,0,0,0.2),0px 3px 4px 0px rgba(0,0,0,0.14),0px 1px 8px 0px rgba(0,0,0,0.12)',
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
primaryShadow:
|
||||
'0px 3px 1px -2px rgba(0,0,0,0.2), 0px 2px 2px 0px rgba(0,0,0,0.14), 0px 1px 5px 0px rgba(0,0,0,0.12)',
|
||||
defaultShadow:
|
||||
'0px 3px 1px -2px rgba(0,0,0,0.2), 0px 2px 2px 0px rgba(0,0,0,0.14), 0px 1px 5px 0px rgba(0,0,0,0.12)',
|
||||
dangerShadow:
|
||||
'0px 3px 1px -2px rgba(0,0,0,0.2), 0px 2px 2px 0px rgba(0,0,0,0.14), 0px 1px 5px 0px rgba(0,0,0,0.12)',
|
||||
fontWeight: 500,
|
||||
defaultBorderColor: 'rgba(0, 0, 0, 0.23)',
|
||||
defaultColor: 'rgba(0, 0, 0, 0.87)',
|
||||
defaultBg: '#ffffff',
|
||||
defaultHoverBg: 'rgba(25, 118, 210, 0.04)',
|
||||
defaultHoverBorderColor: 'rgba(0, 0, 0, 0.23)',
|
||||
paddingInline: 16,
|
||||
paddingBlock: 6,
|
||||
contentFontSize: 14,
|
||||
borderRadius: 4,
|
||||
},
|
||||
Alert: {
|
||||
borderRadiusLG: 4,
|
||||
},
|
||||
Modal: {
|
||||
borderRadiusLG: 4,
|
||||
},
|
||||
Progress: {
|
||||
defaultColor: '#1976d2',
|
||||
remainingColor: 'rgba(25, 118, 210, 0.12)',
|
||||
},
|
||||
Steps: {
|
||||
iconSize: 24,
|
||||
},
|
||||
Checkbox: {
|
||||
borderRadiusSM: 2,
|
||||
},
|
||||
Slider: {
|
||||
trackBg: 'rgba(25, 118, 210, 0.26)',
|
||||
trackHoverBg: 'rgba(25, 118, 210, 0.38)',
|
||||
handleSize: 20,
|
||||
handleSizeHover: 20,
|
||||
railSize: 4,
|
||||
},
|
||||
ColorPicker: {
|
||||
borderRadius: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
wave: {
|
||||
showEffect: showInsetEffect,
|
||||
},
|
||||
button: {
|
||||
classNames: ({ props }) => ({
|
||||
root: clsx(
|
||||
props.type === 'primary' && styles.buttonPrimary,
|
||||
props.type === 'default' && styles.buttonDefault,
|
||||
props.danger && styles.buttonDanger,
|
||||
),
|
||||
}),
|
||||
},
|
||||
input: {
|
||||
classNames: ({ props }) => ({
|
||||
root: clsx(styles.inputRoot, props.status === 'error' && styles.inputError),
|
||||
input: styles.inputElement,
|
||||
}),
|
||||
},
|
||||
select: {
|
||||
classNames: {
|
||||
root: styles.selectRoot,
|
||||
},
|
||||
},
|
||||
}),
|
||||
[styles],
|
||||
);
|
||||
};
|
||||
|
||||
export default useMuiTheme;
|
||||
221
frontend/src/themes/shadcn.ts
Normal file
221
frontend/src/themes/shadcn.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { useMemo } from 'react';
|
||||
import { theme } from 'antd';
|
||||
import type { ConfigProviderProps } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const useStyles = createStyles(({ css }) => {
|
||||
return {
|
||||
buttonPrimary: css({
|
||||
backgroundColor: '#18181b',
|
||||
color: '#ffffff',
|
||||
border: '1px solid #18181b',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}),
|
||||
buttonDefault: css({
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#18181b',
|
||||
border: '1px solid #e4e4e7',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}),
|
||||
buttonDanger: css({
|
||||
backgroundColor: '#dc2626',
|
||||
color: '#ffffff',
|
||||
border: '1px solid #dc2626',
|
||||
fontWeight: 500,
|
||||
}),
|
||||
inputRoot: css({
|
||||
borderColor: '#e4e4e7',
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}),
|
||||
inputElement: css({
|
||||
color: '#18181b',
|
||||
}),
|
||||
inputError: css({
|
||||
borderColor: '#dc2626',
|
||||
}),
|
||||
selectRoot: css({
|
||||
borderColor: '#e4e4e7',
|
||||
}),
|
||||
selectPopup: css({
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const useShadcnTheme = () => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
return useMemo<ConfigProviderProps>(
|
||||
() => ({
|
||||
theme: {
|
||||
algorithm: theme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: '#262626',
|
||||
colorSuccess: '#22c55e',
|
||||
colorWarning: '#f97316',
|
||||
colorError: '#ef4444',
|
||||
colorInfo: '#262626',
|
||||
colorTextBase: '#262626',
|
||||
colorBgBase: '#ffffff',
|
||||
colorPrimaryBg: '#f5f5f5',
|
||||
colorPrimaryBgHover: '#e5e5e5',
|
||||
colorPrimaryBorder: '#d4d4d4',
|
||||
colorPrimaryBorderHover: '#a3a3a3',
|
||||
colorPrimaryHover: '#404040',
|
||||
colorPrimaryActive: '#171717',
|
||||
colorPrimaryText: '#262626',
|
||||
colorPrimaryTextHover: '#404040',
|
||||
colorPrimaryTextActive: '#171717',
|
||||
colorSuccessBg: '#f0fdf4',
|
||||
colorSuccessBgHover: '#dcfce7',
|
||||
colorSuccessBorder: '#bbf7d0',
|
||||
colorSuccessBorderHover: '#86efac',
|
||||
colorSuccessHover: '#16a34a',
|
||||
colorSuccessActive: '#15803d',
|
||||
colorSuccessText: '#16a34a',
|
||||
colorSuccessTextHover: '#16a34a',
|
||||
colorSuccessTextActive: '#15803d',
|
||||
colorWarningBg: '#fff7ed',
|
||||
colorWarningBgHover: '#fed7aa',
|
||||
colorWarningBorder: '#fdba74',
|
||||
colorWarningBorderHover: '#fb923c',
|
||||
colorWarningHover: '#ea580c',
|
||||
colorWarningActive: '#c2410c',
|
||||
colorWarningText: '#ea580c',
|
||||
colorWarningTextHover: '#ea580c',
|
||||
colorWarningTextActive: '#c2410c',
|
||||
colorErrorBg: '#fef2f2',
|
||||
colorErrorBgHover: '#fecaca',
|
||||
colorErrorBorder: '#fca5a5',
|
||||
colorErrorBorderHover: '#f87171',
|
||||
colorErrorHover: '#dc2626',
|
||||
colorErrorActive: '#b91c1c',
|
||||
colorErrorText: '#dc2626',
|
||||
colorErrorTextHover: '#dc2626',
|
||||
colorErrorTextActive: '#b91c1c',
|
||||
colorInfoBg: '#f5f5f5',
|
||||
colorInfoBgHover: '#e5e5e5',
|
||||
colorInfoBorder: '#d4d4d4',
|
||||
colorInfoBorderHover: '#a3a3a3',
|
||||
colorInfoHover: '#404040',
|
||||
colorInfoActive: '#171717',
|
||||
colorInfoText: '#262626',
|
||||
colorInfoTextHover: '#404040',
|
||||
colorInfoTextActive: '#171717',
|
||||
colorText: '#262626',
|
||||
colorTextSecondary: '#525252',
|
||||
colorTextTertiary: '#737373',
|
||||
colorTextQuaternary: '#a3a3a3',
|
||||
colorTextDisabled: '#a3a3a3',
|
||||
colorBgContainer: '#ffffff',
|
||||
colorBgElevated: '#ffffff',
|
||||
colorBgLayout: '#fafafa',
|
||||
colorBgSpotlight: 'rgba(38, 38, 38, 0.85)',
|
||||
colorBgMask: 'rgba(38, 38, 38, 0.45)',
|
||||
colorBorder: '#e5e5e5',
|
||||
colorBorderSecondary: '#f5f5f5',
|
||||
borderRadius: 10,
|
||||
borderRadiusXS: 2,
|
||||
borderRadiusSM: 6,
|
||||
borderRadiusLG: 14,
|
||||
padding: 16,
|
||||
paddingSM: 12,
|
||||
paddingLG: 24,
|
||||
margin: 16,
|
||||
marginSM: 12,
|
||||
marginLG: 24,
|
||||
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)',
|
||||
boxShadowSecondary:
|
||||
'0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
primaryShadow: 'none',
|
||||
defaultShadow: 'none',
|
||||
dangerShadow: 'none',
|
||||
defaultBorderColor: '#e4e4e7',
|
||||
defaultColor: '#18181b',
|
||||
defaultBg: '#ffffff',
|
||||
defaultHoverBg: '#f4f4f5',
|
||||
defaultHoverBorderColor: '#d4d4d8',
|
||||
defaultHoverColor: '#18181b',
|
||||
defaultActiveBg: '#e4e4e7',
|
||||
defaultActiveBorderColor: '#d4d4d8',
|
||||
borderRadius: 6,
|
||||
},
|
||||
Input: {
|
||||
activeShadow: 'none',
|
||||
hoverBorderColor: '#a1a1aa',
|
||||
activeBorderColor: '#18181b',
|
||||
borderRadius: 6,
|
||||
},
|
||||
Select: {
|
||||
optionSelectedBg: '#f4f4f5',
|
||||
optionActiveBg: '#fafafa',
|
||||
optionSelectedFontWeight: 500,
|
||||
borderRadius: 6,
|
||||
},
|
||||
Alert: {
|
||||
borderRadiusLG: 8,
|
||||
},
|
||||
Modal: {
|
||||
borderRadiusLG: 12,
|
||||
},
|
||||
Progress: {
|
||||
defaultColor: '#18181b',
|
||||
remainingColor: '#f4f4f5',
|
||||
},
|
||||
Steps: {
|
||||
iconSize: 32,
|
||||
},
|
||||
Switch: {
|
||||
trackHeight: 24,
|
||||
trackMinWidth: 44,
|
||||
innerMinMargin: 4,
|
||||
innerMaxMargin: 24,
|
||||
},
|
||||
Checkbox: {
|
||||
borderRadiusSM: 4,
|
||||
},
|
||||
Slider: {
|
||||
trackBg: '#f4f4f5',
|
||||
trackHoverBg: '#e4e4e7',
|
||||
handleSize: 18,
|
||||
handleSizeHover: 20,
|
||||
railSize: 6,
|
||||
},
|
||||
ColorPicker: {
|
||||
borderRadius: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
button: {
|
||||
classNames: ({ props }) => ({
|
||||
root: clsx(
|
||||
props.type === 'primary' && styles.buttonPrimary,
|
||||
props.type === 'default' && styles.buttonDefault,
|
||||
props.danger && styles.buttonDanger,
|
||||
),
|
||||
}),
|
||||
},
|
||||
input: {
|
||||
classNames: ({ props }) => ({
|
||||
root: clsx(styles.inputRoot, props.status === 'error' && styles.inputError),
|
||||
input: styles.inputElement,
|
||||
}),
|
||||
},
|
||||
select: {
|
||||
classNames: {
|
||||
root: styles.selectRoot,
|
||||
},
|
||||
},
|
||||
}),
|
||||
[styles],
|
||||
);
|
||||
};
|
||||
|
||||
export default useShadcnTheme;
|
||||
Reference in New Issue
Block a user