增加自动亮色/暗色主题切换 + 清理 pyc 文件

- 新建 store/theme.tsx: 亮色/暗色/跟随系统三种模式
- App.tsx: ThemeProvider + ConfigProvider 暗色算法
- MainLayout: 侧边栏主题切换按钮,Sider/Content 随主题变化
- 所有页面硬编码颜色替换为 antd token
- AssignmentsPage: Tag 用预设颜色名适配暗色,未选中背景用 colorBgContainer
- LoginPage: 暗色深色渐变背景
- 从 git 移除所有 __pycache__/*.pyc 文件

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
yml2213
2026-06-22 20:09:57 +08:00
co-authored by Claude Fable 5
parent 2f9b1a8eb2
commit 4522421293
26 changed files with 212 additions and 73 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+21 -3
View File
@@ -1,6 +1,6 @@
import { useState, useCallback } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ConfigProvider } from 'antd';
import { ConfigProvider, theme } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import LoginPage from './pages/LoginPage';
import MainLayout from './layouts/MainLayout';
@@ -12,15 +12,25 @@ import ProxyPage from './pages/ProxyPage';
import UsersPage from './pages/UsersPage';
import CookiePage from './pages/CookiePage';
import { getToken } from './store/auth';
import { ThemeProvider, useTheme } from './store/theme';
function App() {
function AppContent() {
// 用 state 驱动重渲染,登录/登出时调 refreshAuth()
const [authVersion, setAuthVersion] = useState(0);
const refreshAuth = useCallback(() => setAuthVersion((v) => v + 1), []);
const isLoggedIn = !!getToken();
const { isDark } = useTheme();
return (
<ConfigProvider locale={zhCN}>
<ConfigProvider
locale={zhCN}
theme={{
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: {
borderRadius: 6,
},
}}
>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage onLogin={refreshAuth} />} />
@@ -43,4 +53,12 @@ function App() {
);
}
function App() {
return (
<ThemeProvider>
<AppContent />
</ThemeProvider>
);
}
export default App;
+5
View File
@@ -6,4 +6,9 @@
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
transition: background-color 0.3s, color 0.3s;
}
html[data-theme='dark'] body {
color-scheme: dark;
}
+69 -28
View File
@@ -1,13 +1,15 @@
import { useEffect, useState } from 'react';
import { Layout, Menu, Avatar, Space, Typography, Button, Modal } from 'antd';
import { Layout, Menu, Avatar, Space, Typography, Button, Modal, Dropdown } from 'antd';
import {
DashboardOutlined, UserOutlined, LogoutOutlined,
CloudServerOutlined, TeamOutlined, ApiOutlined, KeyOutlined,
MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined,
SunOutlined, MoonOutlined, DesktopOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import { getUser, clearAuth, hasPerm, type AuthUser } from '../store/auth';
import { authApi } from '../api/modules';
import { useTheme, type ThemeMode } from '../store/theme';
const { Sider, Content } = Layout;
const { Text } = Typography;
@@ -18,11 +20,18 @@ const ROLE_LABELS: Record<string, string> = {
support: '客服',
};
const THEME_OPTIONS: { key: ThemeMode; label: string; icon: React.ReactNode }[] = [
{ key: 'light', label: '亮色', icon: <SunOutlined /> },
{ key: 'dark', label: '暗色', icon: <MoonOutlined /> },
{ key: 'system', label: '跟随系统', icon: <DesktopOutlined /> },
];
export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
const navigate = useNavigate();
const location = useLocation();
const [user] = useState<AuthUser | null>(getUser());
const [collapsed, setCollapsed] = useState(false);
const { mode, isDark, setMode } = useTheme();
useEffect(() => {
if (!user) navigate('/login');
@@ -84,17 +93,21 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
});
};
const siderTheme = isDark ? 'dark' : 'light';
const siderTextColor = isDark ? '#fff' : undefined;
return (
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
<Sider trigger={null} collapsed={collapsed} onCollapse={setCollapsed}>
<Sider trigger={null} collapsed={collapsed} onCollapse={setCollapsed} theme={siderTheme}>
<div style={{
display: 'flex', flexDirection: 'column', height: '100%',
}}>
<div style={{
height: 48, margin: '12px 12px 0', color: '#fff', fontSize: 16,
height: 48, margin: '12px 12px 0', fontSize: 16,
textAlign: 'center', lineHeight: '48px', fontWeight: 'bold',
whiteSpace: 'nowrap', overflow: 'hidden', flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
color: siderTextColor,
}}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{collapsed ? '鱼' : '斗鱼登录后台'}
@@ -104,11 +117,11 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
size="small"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
style={{ color: '#fff', flexShrink: 0 }}
style={{ color: siderTextColor, flexShrink: 0 }}
/>
</div>
<Menu
theme="dark"
theme={siderTheme}
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
@@ -116,47 +129,75 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
style={{ flex: 1, overflow: 'auto', marginTop: 8 }}
/>
<div style={{
borderTop: '1px solid rgba(255,255,255,0.1)',
borderTop: isDark ? '1px solid rgba(255,255,255,0.1)' : '1px solid rgba(0,0,0,0.06)',
padding: '12px',
flexShrink: 0,
}}>
<Space style={{ color: '#fff', width: '100%', marginBottom: collapsed ? 0 : 8 }}>
<Space style={{ color: siderTextColor, width: '100%', marginBottom: collapsed ? 0 : 8 }}>
<Avatar icon={<UserOutlined />} size="small" />
{!collapsed && (
<>
<Text style={{ color: '#fff' }}>{user.username}</Text>
<Text style={{ color: 'rgba(255,255,255,0.65)', fontSize: 12 }}>
<Text style={{ color: siderTextColor }}>{user.username}</Text>
<Text style={{ color: isDark ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.45)', fontSize: 12 }}>
({ROLE_LABELS[user.role] || user.role})
</Text>
</>
)}
</Space>
{collapsed ? (
<Button
block
type="text"
size="small"
icon={<LogoutOutlined />}
onClick={handleLogout}
style={{ color: 'rgba(255,255,255,0.65)' }}
/>
<Space direction="vertical" style={{ width: '100%' }}>
<Dropdown
menu={{ items: THEME_OPTIONS.map(o => ({ key: o.key, label: o.label, icon: o.icon, onClick: () => setMode(o.key) })) }}
trigger={['click']}
>
<Button
block
type="text"
size="small"
icon={isDark ? <MoonOutlined /> : <SunOutlined />}
style={{ color: isDark ? 'rgba(255,255,255,0.65)' : undefined }}
/>
</Dropdown>
<Button
block
type="text"
size="small"
icon={<LogoutOutlined />}
onClick={handleLogout}
style={{ color: isDark ? 'rgba(255,255,255,0.65)' : undefined }}
/>
</Space>
) : (
<Button
block
type="text"
size="small"
icon={<LogoutOutlined />}
onClick={handleLogout}
style={{ color: 'rgba(255,255,255,0.65)', justifyContent: 'flex-start' }}
>
退
</Button>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Dropdown
menu={{ items: THEME_OPTIONS.map(o => ({ key: o.key, label: o.label, icon: o.icon, onClick: () => setMode(o.key) })) }}
trigger={['click']}
>
<Button
type="text"
size="small"
icon={isDark ? <MoonOutlined /> : <SunOutlined />}
style={{ color: isDark ? 'rgba(255,255,255,0.65)' : undefined }}
>
{THEME_OPTIONS.find(o => o.key === mode)?.label}
</Button>
</Dropdown>
<Button
type="text"
size="small"
icon={<LogoutOutlined />}
onClick={handleLogout}
style={{ color: isDark ? 'rgba(255,255,255,0.65)' : undefined }}
>
退
</Button>
</Space>
)}
</div>
</div>
</Sider>
<Layout>
<Content style={{ margin: 16, padding: 24, background: '#fff', borderRadius: 8, overflow: 'auto' }}>
<Content style={{ margin: 16, padding: 24, borderRadius: 8, overflow: 'auto' }}>
<Outlet />
</Content>
</Layout>
+12 -11
View File
@@ -1,7 +1,7 @@
import { useEffect, useState, useMemo } from 'react';
import {
Card, Table, Button, Select, Input, Tag, Row, Col, Statistic,
message, Space, Tabs, Typography, Badge,
message, Space, Tabs, Typography, Badge, theme,
} from 'antd';
import {
UserOutlined, CheckCircleOutlined, TeamOutlined,
@@ -28,6 +28,7 @@ interface AccountItem {
}
export default function AssignmentsPage() {
const { token } = theme.useToken();
const [accounts, setAccounts] = useState<AccountItem[]>([]);
const [supportUsers, setSupportUsers] = useState<SupportUser[]>([]);
const [selectedUser, setSelectedUser] = useState<SupportUser | null>(null);
@@ -219,12 +220,12 @@ export default function AssignmentsPage() {
</Col>
<Col span={6}>
<Card size="small">
<Statistic title="已分配" value={assignedCount} valueStyle={{ color: '#3f8600' }} prefix={<CheckCircleOutlined />} />
<Statistic title="已分配" value={assignedCount} valueStyle={{ color: token.colorSuccess }} prefix={<CheckCircleOutlined />} />
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic title="未分配" value={unassignedCount} valueStyle={{ color: '#cf1322' }} prefix={<SwapOutlined />} />
<Statistic title="未分配" value={unassignedCount} valueStyle={{ color: token.colorError }} prefix={<SwapOutlined />} />
</Card>
</Col>
<Col span={6}>
@@ -258,22 +259,22 @@ export default function AssignmentsPage() {
padding: '10px 14px',
borderRadius: 6,
cursor: 'pointer',
border: `1px solid ${isSelected ? '#1677ff' : '#f0f0f0'}`,
background: isSelected ? '#e6f4ff' : '#fff',
border: `1px solid ${isSelected ? token.colorPrimary : token.colorBorderSecondary}`,
background: isSelected ? token.colorPrimaryBg : token.colorBgContainer,
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
if (!isSelected) (e.currentTarget as HTMLElement).style.borderColor = '#1677ff';
if (!isSelected) (e.currentTarget as HTMLElement).style.borderColor = token.colorPrimary;
}}
onMouseLeave={(e) => {
if (!isSelected) (e.currentTarget as HTMLElement).style.borderColor = '#f0f0f0';
if (!isSelected) (e.currentTarget as HTMLElement).style.borderColor = token.colorBorderSecondary;
}}
>
<Space>
<UserOutlined style={{ color: '#1677ff' }} />
<UserOutlined style={{ color: token.colorPrimary }} />
<Text strong>{user.username}</Text>
</Space>
<Badge count={user.assigned_count} showZero style={{ backgroundColor: '#1677ff' }} />
<Badge count={user.assigned_count} showZero style={{ backgroundColor: token.colorPrimary }} />
</div>
);
})}
@@ -295,7 +296,7 @@ export default function AssignmentsPage() {
{selectedUser ? (
<>
<span></span>
<Tag color="#1677ff" style={{ fontSize: 14, padding: '2px 10px' }}>
<Tag color="blue" style={{ fontSize: 14, padding: '2px 10px' }}>
{selectedUser.username} {selectedUser.assigned_count}
</Tag>
</>
@@ -308,7 +309,7 @@ export default function AssignmentsPage() {
bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, padding: 0 }}
>
{/* 筛选栏 */}
<div style={{ padding: '8px 16px', borderBottom: '1px solid #f0f0f0', display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
<div style={{ padding: '8px 16px', borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
<Input.Search
placeholder="搜索用户名"
allowClear
+4 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Table, Button, Card, Row, Col, Statistic, message, Tag, Popconfirm, Space, Typography, Input } from 'antd';
import { Table, Button, Card, Row, Col, Statistic, message, Tag, Popconfirm, Space, Typography, Input, theme } from 'antd';
import { DownloadOutlined, DeleteOutlined, CopyOutlined, SearchOutlined } from '@ant-design/icons';
import { cookieApi } from '../api/modules';
import { getUser, hasPerm } from '../store/auth';
@@ -8,6 +8,7 @@ import { formatTime } from '../utils/time';
const { Text } = Typography;
export default function CookiePage() {
const { token } = theme.useToken();
const [cookies, setCookies] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
@@ -184,7 +185,7 @@ export default function CookiePage() {
<Statistic
title="已分配"
value={assignedCount}
valueStyle={{ color: '#3f8600' }}
valueStyle={{ color: token.colorSuccess }}
/>
</Card>
</Col>
@@ -193,7 +194,7 @@ export default function CookiePage() {
<Statistic
title="未分配"
value={unassignedCount}
valueStyle={{ color: '#cf1322' }}
valueStyle={{ color: token.colorError }}
/>
</Card>
</Col>
+4 -3
View File
@@ -1,8 +1,9 @@
import { Card, Col, Row, Statistic } from 'antd';
import { Card, Col, Row, Statistic, theme } from 'antd';
import { useEffect, useState } from 'react';
import { accountApi, loginApi } from '../api/modules';
export default function DashboardPage() {
const { token } = theme.useToken();
const [stats, setStats] = useState({ accounts: 0, tasks: 0, success: 0, failed: 0 });
useEffect(() => {
@@ -34,12 +35,12 @@ export default function DashboardPage() {
</Col>
<Col span={6}>
<Card>
<Statistic title="成功" value={stats.success} valueStyle={{ color: '#3f8600' }} />
<Statistic title="成功" value={stats.success} valueStyle={{ color: token.colorSuccess }} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="失败" value={stats.failed} valueStyle={{ color: '#cf1322' }} />
<Statistic title="失败" value={stats.failed} valueStyle={{ color: token.colorError }} />
</Card>
</Col>
</Row>
+7 -3
View File
@@ -4,12 +4,14 @@ import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { authApi } from '../api/modules';
import { setAuth, type AuthUser } from '../store/auth';
import { useTheme } from '../store/theme';
const { Title } = Typography;
export default function LoginPage({ onLogin }: { onLogin?: () => void }) {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { isDark } = useTheme();
const onFinish = async (values: { username: string; password: string }) => {
setLoading(true);
@@ -38,9 +40,11 @@ export default function LoginPage({ onLogin }: { onLogin?: () => void }) {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
background: isDark
? 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)'
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}>
<Card style={{ width: 380, boxShadow: '0 8px 24px rgba(0,0,0,0.15)' }}>
<Card style={{ width: 380, boxShadow: isDark ? '0 8px 24px rgba(0,0,0,0.4)' : '0 8px 24px rgba(0,0,0,0.15)' }}>
<Title level={3} style={{ textAlign: 'center', marginBottom: 32 }}>
</Title>
@@ -57,7 +61,7 @@ export default function LoginPage({ onLogin }: { onLogin?: () => void }) {
</Button>
</Form.Item>
</Form>
<div style={{ textAlign: 'center', color: '#999', fontSize: 12 }}>
<div style={{ textAlign: 'center', fontSize: 12 }}>
默认管理员: admin / admin123
</div>
</Card>
+17 -15
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import {
Table, Button, Select, message, Tag, Space, Spin, InputNumber, Tooltip, Popconfirm,
Table, Button, Select, message, Tag, Space, Spin, InputNumber, Tooltip, Popconfirm, theme,
} from 'antd';
import { PlayCircleOutlined, StopOutlined, FilterOutlined, ThunderboltOutlined, ReloadOutlined, DownOutlined, UpOutlined, DeleteOutlined } from '@ant-design/icons';
import { accountApi, loginApi } from '../api/modules';
@@ -39,6 +39,8 @@ export default function LoginTasksPage() {
const logEndRef = useRef<HTMLDivElement | null>(null);
const user = getUser();
const { token } = theme.useToken();
const canBatch = hasPerm(user, 'login:batch');
// 从账号中提取所有标签
@@ -259,7 +261,7 @@ export default function LoginTasksPage() {
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* 标题 + 筛选栏 */}
<div style={{ flexShrink: 0, paddingBottom: 8, borderBottom: '1px solid #f0f0f0' }}>
<div style={{ flexShrink: 0, paddingBottom: 8, borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
<h2 style={{ marginTop: 0, marginBottom: 6 }}></h2>
{canBatch && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
@@ -314,7 +316,7 @@ export default function LoginTasksPage() {
}}
dropdownRender={(menu) => (
<>
<div style={{ padding: '4px 8px', borderBottom: '1px solid #f0f0f0', display: 'flex', gap: 8 }}>
<div style={{ padding: '4px 8px', borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', gap: 8 }}>
<Button size="small" type="link" onClick={() => { setSelectedIds(accounts.map((a) => a.id)); setSelectedTags([]); }}>
({accounts.length})
</Button>
@@ -327,13 +329,13 @@ export default function LoginTasksPage() {
)}
/>
{selectedTags.length > 0 && (
<span style={{ color: '#888', fontSize: 12, whiteSpace: 'nowrap' }}>
<span style={{ color: token.colorTextSecondary, fontSize: 12, whiteSpace: 'nowrap' }}>
{accounts.filter((a) => selectedTags.includes((a.tag || '').trim())).length}
</span>
)}
<Tooltip title="同时登录的账号数,1为顺序执行">
<Space size={4}>
<ThunderboltOutlined style={{ color: '#888' }} />
<ThunderboltOutlined style={{ color: token.colorTextSecondary }} />
<InputNumber
min={1}
max={10}
@@ -366,10 +368,10 @@ export default function LoginTasksPage() {
{/* 概览 + 任务列表区域 */}
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', paddingTop: 6 }}>
{/* 概览 */}
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center', gap: 16, fontSize: 13, color: '#666', padding: '4px 0' }}>
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center', gap: 16, fontSize: 13, color: token.colorTextSecondary, padding: '4px 0' }}>
<span> <b>{tasks.length}</b> </span>
<span> <b style={{ color: '#3f8600' }}>{successCount}</b></span>
<span> <b style={{ color: '#cf1322' }}>{failedCount}</b></span>
<span> <b style={{ color: token.colorSuccess }}>{successCount}</b></span>
<span> <b style={{ color: token.colorError }}>{failedCount}</b></span>
{batchId && <span>: <b>{batchId}</b></span>}
<div style={{ flex: 1 }} />
{selectedRowKeys.length > 0 && (
@@ -410,14 +412,14 @@ export default function LoginTasksPage() {
</div>
{/* 实时日志 - 底部可折叠 */}
<div style={{ flexShrink: 0, borderTop: '1px solid #f0f0f0', marginTop: 4 }}>
<div style={{ flexShrink: 0, borderTop: `1px solid ${token.colorBorderSecondary}`, marginTop: 4 }}>
<div
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', padding: '4px 0', userSelect: 'none' }}
onClick={() => setLogVisible((v) => !v)}
>
<span style={{ fontWeight: 500, fontSize: 13 }}></span>
{logVisible ? <UpOutlined style={{ marginLeft: 6, fontSize: 10 }} /> : <DownOutlined style={{ marginLeft: 6, fontSize: 10 }} />}
{logs.length > 0 && <span style={{ marginLeft: 8, fontSize: 12, color: '#999' }}>{logs.length} </span>}
{logs.length > 0 && <span style={{ marginLeft: 8, fontSize: 12, color: token.colorTextTertiary }}>{logs.length} </span>}
{wsConnected && <Tag color="processing" style={{ marginLeft: 8 }}></Tag>}
</div>
{logVisible && (
@@ -428,7 +430,7 @@ export default function LoginTasksPage() {
fontFamily: 'monospace',
fontSize: 12,
padding: 4,
backgroundColor: '#fafafa',
backgroundColor: token.colorBgLayout,
borderRadius: 4,
}}
>
@@ -440,10 +442,10 @@ export default function LoginTasksPage() {
key={i}
style={{
color:
log.level === 'error' ? '#ff4d4f' :
log.level === 'success' ? '#52c41a' :
log.level === 'warning' ? '#fa8c16' :
'rgba(0,0,0,0.65)',
log.level === 'error' ? token.colorError :
log.level === 'success' ? token.colorSuccess :
log.level === 'warning' ? token.colorWarning :
token.colorText,
}}
>
{log.message}
+8 -7
View File
@@ -1,10 +1,11 @@
import { useEffect, useState, useRef } from 'react';
import { Form, Input, Switch, Button, Card, message, Row, Col } from 'antd';
import { Form, Input, Switch, Button, Card, message, Row, Col, theme } from 'antd';
import { proxyApi } from '../api/modules';
const WS_BASE = `ws://${window.location.hostname}:8000`;
export default function ProxyPage() {
const { token } = theme.useToken();
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [testing, setTesting] = useState(false);
@@ -100,10 +101,10 @@ export default function ProxyPage() {
const logColors: Record<string, string> = {
error: '#ff4d4f',
success: '#52c41a',
warning: '#faad14',
info: '#333',
error: token.colorError,
success: token.colorSuccess,
warning: token.colorWarning,
info: token.colorText,
};
return (
@@ -171,10 +172,10 @@ export default function ProxyPage() {
styles={{ body: { height: '100%', overflow: 'auto', fontFamily: 'monospace', fontSize: 12, padding: '8px 16px' } }}
>
{logs.length === 0 ? (
<span style={{ color: '#999' }}>"测试代理""测试白名单"</span>
<span style={{ color: token.colorTextTertiary }}>"测试代理""测试白名单"</span>
) : (
logs.map((log, i) => (
<div key={i} style={{ color: logColors[log.level] || '#333', lineHeight: '20px' }}>
<div key={i} style={{ color: logColors[log.level] || token.colorText, lineHeight: '20px' }}>
{log.message}
</div>
))
+65
View File
@@ -0,0 +1,65 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
export type ThemeMode = 'light' | 'dark' | 'system';
const STORAGE_KEY = 'theme_mode';
const DEFAULT_MODE: ThemeMode = 'system';
interface ThemeContextValue {
mode: ThemeMode;
isDark: boolean;
setMode: (mode: ThemeMode) => void;
toggle: () => void;
}
const ThemeContext = createContext<ThemeContextValue>({
mode: DEFAULT_MODE,
isDark: false,
setMode: () => {},
toggle: () => {},
});
export function ThemeProvider({ children }: { children: ReactNode }) {
const [mode, setModeState] = useState<ThemeMode>(() => {
const saved = localStorage.getItem(STORAGE_KEY);
return (saved as ThemeMode) || DEFAULT_MODE;
});
// 监听系统主题变化
const [systemIsDark, setSystemIsDark] = useState(() =>
window.matchMedia('(prefers-color-scheme: dark)').matches
);
useEffect(() => {
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => setSystemIsDark(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
const isDark = mode === 'dark' || (mode === 'system' && systemIsDark);
const setMode = useCallback((newMode: ThemeMode) => {
setModeState(newMode);
localStorage.setItem(STORAGE_KEY, newMode);
}, []);
const toggle = useCallback(() => {
setMode(isDark ? 'light' : 'dark');
}, [isDark, setMode]);
// 同步到 HTML root,方便 CSS 使用
useEffect(() => {
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
}, [isDark]);
return (
<ThemeContext.Provider value={{ mode, isDark, setMode, toggle }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}