mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 03:10:50 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66c6073bbc | ||
|
|
2dd3f233d3 | ||
|
|
7a65e03ad3 | ||
|
|
589a5bad4c | ||
|
|
bcaa0c8545 | ||
|
|
312a06a8b8 | ||
|
|
24861dabd2 | ||
|
|
ea1bdc3ac1 | ||
|
|
46701b40ad | ||
|
|
c9fc22bae5 | ||
|
|
961cc802b2 | ||
|
|
5f7df33469 |
@@ -31,10 +31,11 @@ function App() {
|
||||
const [authReady, setAuthReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
initializeTheme();
|
||||
const cleanupTheme = initializeTheme();
|
||||
void restoreSession().finally(() => {
|
||||
setAuthReady(true);
|
||||
});
|
||||
return cleanupTheme;
|
||||
}, [initializeTheme, restoreSession]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { ReactNode, SVGProps, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ReactNode,
|
||||
SVGProps,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -14,10 +22,16 @@ import {
|
||||
IconScrollText,
|
||||
IconSettings,
|
||||
IconShield,
|
||||
IconSlidersHorizontal
|
||||
IconSlidersHorizontal,
|
||||
} from '@/components/ui/icons';
|
||||
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||
import { useAuthStore, useConfigStore, useLanguageStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||
import {
|
||||
useAuthStore,
|
||||
useConfigStore,
|
||||
useLanguageStore,
|
||||
useNotificationStore,
|
||||
useThemeStore,
|
||||
} from '@/stores';
|
||||
import { configApi, versionApi } from '@/services/api';
|
||||
|
||||
const sidebarIcons: Record<string, ReactNode> = {
|
||||
@@ -30,7 +44,7 @@ const sidebarIcons: Record<string, ReactNode> = {
|
||||
usage: <IconChartLine size={18} />,
|
||||
config: <IconSettings size={18} />,
|
||||
logs: <IconScrollText size={18} />,
|
||||
system: <IconInfo size={18} />
|
||||
system: <IconInfo size={18} />,
|
||||
};
|
||||
|
||||
// Header action icons - smaller size for header buttons
|
||||
@@ -44,7 +58,7 @@ const headerIconProps: SVGProps<SVGSVGElement> = {
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
'aria-hidden': 'true',
|
||||
focusable: 'false'
|
||||
focusable: 'false',
|
||||
};
|
||||
|
||||
const headerIcons = {
|
||||
@@ -97,19 +111,38 @@ const headerIcons = {
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
</svg>
|
||||
),
|
||||
moon: (
|
||||
<svg {...headerIconProps}>
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />
|
||||
</svg>
|
||||
),
|
||||
logout: (
|
||||
<svg {...headerIconProps}>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="m16 17 5-5-5-5" />
|
||||
<path d="M21 12H9" />
|
||||
</svg>
|
||||
)
|
||||
};
|
||||
moon: (
|
||||
<svg {...headerIconProps}>
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />
|
||||
</svg>
|
||||
),
|
||||
autoTheme: (
|
||||
<svg {...headerIconProps}>
|
||||
<defs>
|
||||
<clipPath id="mainLayoutAutoThemeSunLeftHalf">
|
||||
<rect x="0" y="0" width="12" height="24" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<circle cx="12" cy="12" r="4" clipPath="url(#mainLayoutAutoThemeSunLeftHalf)" fill="currentColor" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="M4.93 4.93l1.41 1.41" />
|
||||
<path d="M17.66 17.66l1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="M6.34 17.66l-1.41 1.41" />
|
||||
<path d="M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
),
|
||||
logout: (
|
||||
<svg {...headerIconProps}>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="m16 17 5-5-5-5" />
|
||||
<path d="M21 12H9" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
const parseVersionSegments = (version?: string | null) => {
|
||||
if (!version) return null;
|
||||
@@ -153,7 +186,7 @@ export function MainLayout() {
|
||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const toggleTheme = useThemeStore((state) => state.toggleTheme);
|
||||
const cycleTheme = useThemeStore((state) => state.cycleTheme);
|
||||
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
@@ -187,7 +220,9 @@ export function MainLayout() {
|
||||
updateHeaderHeight();
|
||||
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver !== 'undefined' && headerRef.current ? new ResizeObserver(updateHeaderHeight) : null;
|
||||
typeof ResizeObserver !== 'undefined' && headerRef.current
|
||||
? new ResizeObserver(updateHeaderHeight)
|
||||
: null;
|
||||
if (resizeObserver && headerRef.current) {
|
||||
resizeObserver.observe(headerRef.current);
|
||||
}
|
||||
@@ -320,8 +355,10 @@ export function MainLayout() {
|
||||
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
|
||||
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
|
||||
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
|
||||
...(config?.loggingToFile ? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }] : []),
|
||||
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system }
|
||||
...(config?.loggingToFile
|
||||
? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
|
||||
: []),
|
||||
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system },
|
||||
];
|
||||
|
||||
const handleRefreshAll = async () => {
|
||||
@@ -370,7 +407,11 @@ export function MainLayout() {
|
||||
<button
|
||||
className="sidebar-toggle-header"
|
||||
onClick={() => setSidebarCollapsed((prev) => !prev)}
|
||||
title={sidebarCollapsed ? t('sidebar.expand', { defaultValue: '展开' }) : t('sidebar.collapse', { defaultValue: '收起' })}
|
||||
title={
|
||||
sidebarCollapsed
|
||||
? t('sidebar.expand', { defaultValue: '展开' })
|
||||
: t('sidebar.collapse', { defaultValue: '收起' })
|
||||
}
|
||||
>
|
||||
{sidebarCollapsed ? headerIcons.chevronRight : headerIcons.chevronLeft}
|
||||
</button>
|
||||
@@ -400,20 +441,40 @@ export function MainLayout() {
|
||||
</div>
|
||||
|
||||
<div className="header-actions">
|
||||
<Button className="mobile-menu-btn" variant="ghost" size="sm" onClick={() => setSidebarOpen((prev) => !prev)}>
|
||||
<Button
|
||||
className="mobile-menu-btn"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSidebarOpen((prev) => !prev)}
|
||||
>
|
||||
{headerIcons.menu}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleRefreshAll} title={t('header.refresh_all')}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefreshAll}
|
||||
title={t('header.refresh_all')}
|
||||
>
|
||||
{headerIcons.refresh}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleVersionCheck} loading={checkingVersion} title={t('system_info.version_check_button')}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleVersionCheck}
|
||||
loading={checkingVersion}
|
||||
title={t('system_info.version_check_button')}
|
||||
>
|
||||
{headerIcons.update}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={toggleLanguage} title={t('language.switch')}>
|
||||
{headerIcons.language}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={toggleTheme} title={t('theme.switch')}>
|
||||
{theme === 'dark' ? headerIcons.sun : headerIcons.moon}
|
||||
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
|
||||
{theme === 'auto'
|
||||
? headerIcons.autoTheme
|
||||
: theme === 'dark'
|
||||
? headerIcons.moon
|
||||
: headerIcons.sun}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
|
||||
{headerIcons.logout}
|
||||
@@ -423,7 +484,9 @@ export function MainLayout() {
|
||||
</header>
|
||||
|
||||
<div className="main-body">
|
||||
<aside className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}>
|
||||
<aside
|
||||
className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}
|
||||
>
|
||||
<div className="nav-section">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
@@ -454,7 +517,9 @@ export function MainLayout() {
|
||||
</span>
|
||||
<span>
|
||||
{t('footer.build_date')}:{' '}
|
||||
{serverBuildDate ? new Date(serverBuildDate).toLocaleString(i18n.language) : t('system_info.version_unknown')}
|
||||
{serverBuildDate
|
||||
? new Date(serverBuildDate).toLocaleString(i18n.language)
|
||||
: t('system_info.version_unknown')}
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -534,6 +534,11 @@
|
||||
"by_hour": "By Hour",
|
||||
"by_day": "By Day",
|
||||
"refresh": "Refresh",
|
||||
"export": "Export",
|
||||
"import": "Import",
|
||||
"export_success": "Usage export downloaded",
|
||||
"import_success": "Import complete: added {{added}}, skipped {{skipped}}, total {{total}}, failed {{failed}}",
|
||||
"import_invalid": "Invalid usage export file",
|
||||
"chart_line_label_1": "Line 1",
|
||||
"chart_line_label_2": "Line 2",
|
||||
"chart_line_label_3": "Line 3",
|
||||
@@ -589,12 +594,18 @@
|
||||
"error_log_button": "Select Error Log",
|
||||
"error_logs_modal_title": "Error Request Logs",
|
||||
"error_logs_description": "Pick an error request log file to download (only generated when request logging is off).",
|
||||
"error_logs_request_log_enabled": "Request logging is enabled, so this list will always be empty. Disable request logging and refresh to view error logs.",
|
||||
"error_logs_empty": "No error request log files found",
|
||||
"error_logs_load_error": "Failed to load error log list",
|
||||
"error_logs_size": "Size",
|
||||
"error_logs_modified": "Last modified",
|
||||
"error_logs_download": "Download",
|
||||
"error_log_download_success": "Error log downloaded successfully",
|
||||
"request_log_download_title": "Download Request Log",
|
||||
"request_log_download_confirm": "Download request log for ID {{id}}?",
|
||||
"request_log_download_success": "Request log downloaded successfully",
|
||||
"action_hint": "Double-click a log line to copy the raw text. Long-press a line with a request ID to download the request log.",
|
||||
"action_hint_disabled": "Double-click a log line to copy the raw text. Enable request logging to long-press a line with a request ID and download the request log.",
|
||||
"empty_title": "No Logs Available",
|
||||
"empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here",
|
||||
"log_content": "Log Content",
|
||||
|
||||
@@ -534,6 +534,11 @@
|
||||
"by_hour": "按小时",
|
||||
"by_day": "按天",
|
||||
"refresh": "刷新",
|
||||
"export": "导出数据",
|
||||
"import": "导入数据",
|
||||
"export_success": "使用统计已导出",
|
||||
"import_success": "导入完成:新增 {{added}},跳过 {{skipped}},总请求 {{total}},失败 {{failed}}",
|
||||
"import_invalid": "导入文件格式不正确",
|
||||
"chart_line_label_1": "曲线 1",
|
||||
"chart_line_label_2": "曲线 2",
|
||||
"chart_line_label_3": "曲线 3",
|
||||
@@ -589,12 +594,18 @@
|
||||
"error_log_button": "选择错误日志",
|
||||
"error_logs_modal_title": "错误请求日志",
|
||||
"error_logs_description": "请选择要下载的错误请求日志文件(仅在关闭请求日志时生成)。",
|
||||
"error_logs_request_log_enabled": "当前已开启请求日志,按接口约定错误请求日志列表会始终为空。关闭请求日志后再刷新即可查看。",
|
||||
"error_logs_empty": "暂无错误请求日志文件",
|
||||
"error_logs_load_error": "加载错误日志列表失败",
|
||||
"error_logs_size": "大小",
|
||||
"error_logs_modified": "最后修改",
|
||||
"error_logs_download": "下载",
|
||||
"error_log_download_success": "错误日志下载成功",
|
||||
"request_log_download_title": "下载报文",
|
||||
"request_log_download_confirm": "是否要下载id为{{id}}的报文?",
|
||||
"request_log_download_success": "报文下载成功",
|
||||
"action_hint": "双击日志行可复制原文,长按带有请求 ID 的日志可下载报文。",
|
||||
"action_hint_disabled": "双击日志行可复制原文,启用请求日志后可长按带请求 ID 的日志下载报文。",
|
||||
"empty_title": "暂无日志记录",
|
||||
"empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里",
|
||||
"log_content": "日志内容",
|
||||
|
||||
@@ -17,6 +17,7 @@ import styles from './AuthFilesPage.module.scss';
|
||||
|
||||
type ThemeColors = { bg: string; text: string; border?: string };
|
||||
type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
|
||||
type ResolvedTheme = 'light' | 'dark';
|
||||
|
||||
// 标签类型颜色配置(对齐重构前 styles.css 的 file-type-badge 颜色)
|
||||
const TYPE_COLORS: Record<string, TypeColorSet> = {
|
||||
@@ -129,7 +130,7 @@ export function AuthFilesPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
|
||||
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -488,7 +489,7 @@ export function AuthFilesPage() {
|
||||
// 获取类型颜色
|
||||
const getTypeColor = (type: string): ThemeColors => {
|
||||
const set = TYPE_COLORS[type] || TYPE_COLORS.unknown;
|
||||
return theme === 'dark' && set.dark ? set.dark : set.light;
|
||||
return resolvedTheme === 'dark' && set.dark ? set.dark : set.light;
|
||||
};
|
||||
|
||||
// OAuth 排除相关方法
|
||||
@@ -547,7 +548,7 @@ export function AuthFilesPage() {
|
||||
{existingTypes.map((type) => {
|
||||
const isActive = filter === type;
|
||||
const color = type === 'all' ? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' } : getTypeColor(type);
|
||||
const activeTextColor = theme === 'dark' ? '#111827' : '#fff';
|
||||
const activeTextColor = resolvedTheme === 'dark' ? '#111827' : '#fff';
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
|
||||
@@ -134,8 +134,8 @@
|
||||
|
||||
.editorWrapper {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
flex: 0 0 auto;
|
||||
height: 480px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-lg;
|
||||
overflow: hidden;
|
||||
@@ -238,3 +238,26 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 820px) {
|
||||
.pageTitle {
|
||||
font-size: 24px;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.configCard {
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.editorWrapper {
|
||||
height: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export function ConfigPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -289,7 +289,7 @@ export function ConfigPage() {
|
||||
value={content}
|
||||
onChange={handleChange}
|
||||
extensions={extensions}
|
||||
theme={theme === 'dark' ? 'dark' : 'light'}
|
||||
theme={resolvedTheme}
|
||||
editable={!disableControls && !loading}
|
||||
placeholder={t('config_management.editor_placeholder')}
|
||||
height="100%"
|
||||
|
||||
@@ -242,7 +242,11 @@ export function DashboardPage() {
|
||||
</div>
|
||||
<div className={styles.connectionInfo}>
|
||||
<span className={styles.serverUrl}>{apiBase || '-'}</span>
|
||||
{serverVersion && <span className={styles.serverVersion}>v{serverVersion}</span>}
|
||||
{serverVersion && (
|
||||
<span className={styles.serverVersion}>
|
||||
v{serverVersion.trim().replace(/^[vV]+/, '')}
|
||||
</span>
|
||||
)}
|
||||
{serverBuildDate && (
|
||||
<span className={styles.buildDate}>
|
||||
{new Date(serverBuildDate).toLocaleDateString(i18n.language)}
|
||||
|
||||
@@ -161,8 +161,8 @@
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
flex: 0 0 auto;
|
||||
height: 480px;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
@@ -170,6 +170,13 @@
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.errorPanel {
|
||||
height: 480px;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.loadMoreBanner {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -371,3 +378,34 @@
|
||||
color: var(--text-secondary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-height: 820px) {
|
||||
.pageTitle {
|
||||
font-size: 24px;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.tabBar {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.tabItem {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.logCard {
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.logPanel {
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.errorPanel {
|
||||
height: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import {
|
||||
IconDownload,
|
||||
@@ -14,7 +16,7 @@ import {
|
||||
IconTrash2,
|
||||
IconX,
|
||||
} from '@/components/ui/icons';
|
||||
import { useNotificationStore, useAuthStore } from '@/stores';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import { logsApi } from '@/services/api/logs';
|
||||
import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
|
||||
import { formatUnixTimestamp } from '@/utils/format';
|
||||
@@ -38,6 +40,8 @@ const INITIAL_DISPLAY_LINES = 100;
|
||||
const LOAD_MORE_LINES = 200;
|
||||
const MAX_BUFFER_LINES = 10000;
|
||||
const LOAD_MORE_THRESHOLD_PX = 72;
|
||||
const LONG_PRESS_MS = 650;
|
||||
const LONG_PRESS_MOVE_THRESHOLD = 10;
|
||||
|
||||
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const;
|
||||
type HttpMethod = (typeof HTTP_METHODS)[number];
|
||||
@@ -361,6 +365,7 @@ export function LogsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const requestLogEnabled = useConfigStore((state) => state.config?.requestLog ?? false);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>('logs');
|
||||
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
|
||||
@@ -369,13 +374,22 @@ export function LogsPage() {
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||
const [hideManagementLogs, setHideManagementLogs] = useState(false);
|
||||
const [hideManagementLogs, setHideManagementLogs] = useState(true);
|
||||
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
|
||||
const [loadingErrors, setLoadingErrors] = useState(false);
|
||||
const [errorLogsError, setErrorLogsError] = useState('');
|
||||
const [requestLogId, setRequestLogId] = useState<string | null>(null);
|
||||
const [requestLogDownloading, setRequestLogDownloading] = useState(false);
|
||||
|
||||
const logViewerRef = useRef<HTMLDivElement | null>(null);
|
||||
const pendingScrollToBottomRef = useRef(false);
|
||||
const pendingPrependScrollRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
|
||||
const longPressRef = useRef<{
|
||||
timer: number | null;
|
||||
startX: number;
|
||||
startY: number;
|
||||
fired: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// 保存最新时间戳用于增量获取
|
||||
const latestTimestampRef = useRef<number>(0);
|
||||
@@ -488,14 +502,18 @@ export function LogsPage() {
|
||||
}
|
||||
|
||||
setLoadingErrors(true);
|
||||
setErrorLogsError('');
|
||||
try {
|
||||
const res = await logsApi.fetchErrorLogs();
|
||||
// API 返回 { files: [...] }
|
||||
setErrorLogs(Array.isArray(res.files) ? res.files : []);
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to load error logs:', err);
|
||||
// 静默失败,不影响主日志显示
|
||||
setErrorLogs([]);
|
||||
const message = getErrorMessage(err);
|
||||
setErrorLogsError(
|
||||
message ? `${t('logs.error_logs_load_error')}: ${message}` : t('logs.error_logs_load_error')
|
||||
);
|
||||
} finally {
|
||||
setLoadingErrors(false);
|
||||
}
|
||||
@@ -525,11 +543,17 @@ export function LogsPage() {
|
||||
if (connectionStatus === 'connected') {
|
||||
latestTimestampRef.current = 0;
|
||||
loadLogs(false);
|
||||
loadErrorLogs();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [connectionStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'errors') return;
|
||||
if (connectionStatus !== 'connected') return;
|
||||
void loadErrorLogs();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab, connectionStatus, requestLogEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh || connectionStatus !== 'connected') {
|
||||
return;
|
||||
@@ -635,6 +659,85 @@ export function LogsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const clearLongPressTimer = () => {
|
||||
if (longPressRef.current?.timer) {
|
||||
window.clearTimeout(longPressRef.current.timer);
|
||||
longPressRef.current.timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startLongPress = (event: ReactPointerEvent<HTMLDivElement>, id?: string) => {
|
||||
if (!requestLogEnabled) return;
|
||||
if (!id) return;
|
||||
if (requestLogId) return;
|
||||
clearLongPressTimer();
|
||||
longPressRef.current = {
|
||||
timer: window.setTimeout(() => {
|
||||
setRequestLogId(id);
|
||||
if (longPressRef.current) {
|
||||
longPressRef.current.fired = true;
|
||||
longPressRef.current.timer = null;
|
||||
}
|
||||
}, LONG_PRESS_MS),
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
fired: false,
|
||||
};
|
||||
};
|
||||
|
||||
const cancelLongPress = () => {
|
||||
clearLongPressTimer();
|
||||
longPressRef.current = null;
|
||||
};
|
||||
|
||||
const handleLongPressMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const current = longPressRef.current;
|
||||
if (!current || current.timer === null || current.fired) return;
|
||||
const deltaX = Math.abs(event.clientX - current.startX);
|
||||
const deltaY = Math.abs(event.clientY - current.startY);
|
||||
if (deltaX > LONG_PRESS_MOVE_THRESHOLD || deltaY > LONG_PRESS_MOVE_THRESHOLD) {
|
||||
cancelLongPress();
|
||||
}
|
||||
};
|
||||
|
||||
const closeRequestLogModal = () => {
|
||||
if (requestLogDownloading) return;
|
||||
setRequestLogId(null);
|
||||
};
|
||||
|
||||
const downloadRequestLog = async (id: string) => {
|
||||
setRequestLogDownloading(true);
|
||||
try {
|
||||
const response = await logsApi.downloadRequestLogById(id);
|
||||
const blob = new Blob([response.data], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `request-${id}.log`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
showNotification(t('logs.request_log_download_success'), 'success');
|
||||
setRequestLogId(null);
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
showNotification(
|
||||
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
setRequestLogDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (longPressRef.current?.timer) {
|
||||
window.clearTimeout(longPressRef.current.timer);
|
||||
longPressRef.current.timer = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
|
||||
@@ -748,6 +851,10 @@ export function LogsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hint">
|
||||
{requestLogEnabled ? t('logs.action_hint') : t('logs.action_hint_disabled')}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="hint">{t('logs.loading')}</div>
|
||||
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (
|
||||
@@ -783,6 +890,11 @@ export function LogsPage() {
|
||||
onDoubleClick={() => {
|
||||
void copyLogLine(line.raw);
|
||||
}}
|
||||
onPointerDown={(event) => startLongPress(event, line.requestId)}
|
||||
onPointerUp={cancelLongPress}
|
||||
onPointerLeave={cancelLongPress}
|
||||
onPointerCancel={cancelLongPress}
|
||||
onPointerMove={handleLongPressMove}
|
||||
title={t('logs.double_click_copy_hint', {
|
||||
defaultValue: 'Double-click to copy',
|
||||
})}
|
||||
@@ -877,40 +989,89 @@ export function LogsPage() {
|
||||
{activeTab === 'errors' && (
|
||||
<Card
|
||||
extra={
|
||||
<Button variant="secondary" size="sm" onClick={loadErrorLogs} loading={loadingErrors}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={loadErrorLogs}
|
||||
loading={loadingErrors}
|
||||
disabled={disableControls}
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{errorLogs.length === 0 ? (
|
||||
<div className="hint">{t('logs.error_logs_empty')}</div>
|
||||
) : (
|
||||
<div className="item-list">
|
||||
{errorLogs.map((item) => (
|
||||
<div key={item.name} className="item-row">
|
||||
<div className="item-meta">
|
||||
<div className="item-title">{item.name}</div>
|
||||
<div className="item-subtitle">
|
||||
{item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''}{' '}
|
||||
{item.modified ? formatUnixTimestamp(item.modified) : ''}
|
||||
<div className="stack">
|
||||
<div className="hint">{t('logs.error_logs_description')}</div>
|
||||
|
||||
{requestLogEnabled && (
|
||||
<div>
|
||||
<div className="status-badge warning">{t('logs.error_logs_request_log_enabled')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorLogsError && <div className="error-box">{errorLogsError}</div>}
|
||||
|
||||
<div className={styles.errorPanel}>
|
||||
{loadingErrors ? (
|
||||
<div className="hint">{t('common.loading')}</div>
|
||||
) : errorLogs.length === 0 ? (
|
||||
<div className="hint">{t('logs.error_logs_empty')}</div>
|
||||
) : (
|
||||
<div className="item-list">
|
||||
{errorLogs.map((item) => (
|
||||
<div key={item.name} className="item-row">
|
||||
<div className="item-meta">
|
||||
<div className="item-title">{item.name}</div>
|
||||
<div className="item-subtitle">
|
||||
{item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''}{' '}
|
||||
{item.modified ? formatUnixTimestamp(item.modified) : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="item-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => downloadErrorLog(item.name)}
|
||||
disabled={disableControls}
|
||||
>
|
||||
{t('logs.error_logs_download')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => downloadErrorLog(item.name)}
|
||||
>
|
||||
{t('logs.error_logs_download')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={Boolean(requestLogId)}
|
||||
onClose={closeRequestLogModal}
|
||||
title={t('logs.request_log_download_title')}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={closeRequestLogModal} disabled={requestLogDownloading}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (requestLogId) {
|
||||
void downloadRequestLog(requestLogId);
|
||||
}
|
||||
}}
|
||||
loading={requestLogDownloading}
|
||||
disabled={!requestLogId}
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{requestLogId ? t('logs.request_log_download_confirm', { id: requestLogId }) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ const getIcon = (icon: string | { light: string; dark: string }, theme: 'light'
|
||||
export function OAuthPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const { theme } = useThemeStore();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
|
||||
const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false });
|
||||
const timers = useRef<Record<string, number>>({});
|
||||
@@ -229,7 +229,11 @@ export function OAuthPage() {
|
||||
<Card
|
||||
title={
|
||||
<span className={styles.cardTitle}>
|
||||
<img src={getIcon(provider.icon, theme)} alt="" className={styles.cardTitleIcon} />
|
||||
<img
|
||||
src={getIcon(provider.icon, resolvedTheme)}
|
||||
alt=""
|
||||
className={styles.cardTitleIcon}
|
||||
/>
|
||||
{t(provider.titleKey)}
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -18,6 +18,13 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback, useMemo, type CSSProperties } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo, useRef, type CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
@@ -19,7 +19,7 @@ import { Input } from '@/components/ui/Input';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { useThemeStore } from '@/stores';
|
||||
import { useNotificationStore, useThemeStore } from '@/stores';
|
||||
import { usageApi } from '@/services/api/usage';
|
||||
import {
|
||||
formatTokensInMillions,
|
||||
@@ -63,14 +63,18 @@ interface UsagePayload {
|
||||
|
||||
export function UsagePage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const isDark = theme === 'dark';
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const isDark = resolvedTheme === 'dark';
|
||||
|
||||
const [usage, setUsage] = useState<UsagePayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Model price form state
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
@@ -107,6 +111,77 @@ export function UsagePage() {
|
||||
setModelPrices(loadModelPrices());
|
||||
}, [loadUsage]);
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const data = await usageApi.exportUsage();
|
||||
const exportedAt =
|
||||
typeof data?.exported_at === 'string' ? new Date(data.exported_at) : new Date();
|
||||
const safeTimestamp = Number.isNaN(exportedAt.getTime())
|
||||
? new Date().toISOString()
|
||||
: exportedAt.toISOString();
|
||||
const filename = `usage-export-${safeTimestamp.replace(/[:.]/g, '-')}.json`;
|
||||
const blob = new Blob([JSON.stringify(data ?? {}, null, 2)], { type: 'application/json' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
showNotification(t('usage_stats.export_success'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
showNotification(
|
||||
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
importInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleImportChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const text = await file.text();
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
showNotification(t('usage_stats.import_invalid'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await usageApi.importUsage(payload);
|
||||
showNotification(
|
||||
t('usage_stats.import_success', {
|
||||
added: result?.added ?? 0,
|
||||
skipped: result?.skipped ?? 0,
|
||||
total: result?.total_requests ?? 0,
|
||||
failed: result?.failed_requests ?? 0
|
||||
}),
|
||||
'success'
|
||||
);
|
||||
await loadUsage();
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
showNotification(
|
||||
`${t('notification.upload_failed')}${message ? `: ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate derived data
|
||||
const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 };
|
||||
const rateStats = usage
|
||||
@@ -527,14 +602,41 @@ export function UsagePage() {
|
||||
)}
|
||||
<div className={styles.header}>
|
||||
<h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={loadUsage}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? t('common.loading') : t('usage_stats.refresh')}
|
||||
</Button>
|
||||
<div className={styles.headerActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
loading={exporting}
|
||||
disabled={loading || importing}
|
||||
>
|
||||
{t('usage_stats.export')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleImportClick}
|
||||
loading={importing}
|
||||
disabled={loading || exporting}
|
||||
>
|
||||
{t('usage_stats.import')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={loadUsage}
|
||||
disabled={loading || exporting || importing}
|
||||
>
|
||||
{loading ? t('common.loading') : t('usage_stats.refresh')}
|
||||
</Button>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleImportChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
|
||||
@@ -39,4 +39,10 @@ export const logsApi = {
|
||||
responseType: 'blob',
|
||||
timeout: LOGS_TIMEOUT_MS
|
||||
}),
|
||||
|
||||
downloadRequestLogById: (id: string) =>
|
||||
apiClient.getRaw(`/request-log-by-id/${encodeURIComponent(id)}`, {
|
||||
responseType: 'blob',
|
||||
timeout: LOGS_TIMEOUT_MS
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -7,12 +7,38 @@ import { computeKeyStats, KeyStats } from '@/utils/usage';
|
||||
|
||||
const USAGE_TIMEOUT_MS = 60 * 1000;
|
||||
|
||||
export interface UsageExportPayload {
|
||||
version?: number;
|
||||
exported_at?: string;
|
||||
usage?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UsageImportResponse {
|
||||
added?: number;
|
||||
skipped?: number;
|
||||
total_requests?: number;
|
||||
failed_requests?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const usageApi = {
|
||||
/**
|
||||
* 获取使用统计原始数据
|
||||
*/
|
||||
getUsage: () => apiClient.get('/usage', { timeout: USAGE_TIMEOUT_MS }),
|
||||
|
||||
/**
|
||||
* 导出使用统计快照
|
||||
*/
|
||||
exportUsage: () => apiClient.get<UsageExportPayload>('/usage/export', { timeout: USAGE_TIMEOUT_MS }),
|
||||
|
||||
/**
|
||||
* 导入使用统计快照
|
||||
*/
|
||||
importUsage: (payload: unknown) =>
|
||||
apiClient.post<UsageImportResponse>('/usage/import', payload, { timeout: USAGE_TIMEOUT_MS }),
|
||||
|
||||
/**
|
||||
* 计算密钥成功/失败统计,必要时会先获取 usage 数据
|
||||
*/
|
||||
|
||||
@@ -8,63 +8,79 @@ import { persist } from 'zustand/middleware';
|
||||
import type { Theme } from '@/types';
|
||||
import { STORAGE_KEY_THEME } from '@/utils/constants';
|
||||
|
||||
type ResolvedTheme = 'light' | 'dark';
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme;
|
||||
resolvedTheme: ResolvedTheme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
initializeTheme: () => void;
|
||||
cycleTheme: () => void;
|
||||
initializeTheme: () => () => void;
|
||||
}
|
||||
|
||||
const getSystemTheme = (): ResolvedTheme => {
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark';
|
||||
}
|
||||
return 'light';
|
||||
};
|
||||
|
||||
const applyTheme = (resolved: ResolvedTheme) => {
|
||||
if (resolved === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
};
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
theme: 'light',
|
||||
theme: 'auto',
|
||||
resolvedTheme: 'light',
|
||||
|
||||
setTheme: (theme) => {
|
||||
// 应用主题到 DOM
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
|
||||
set({ theme });
|
||||
const resolved: ResolvedTheme = theme === 'auto' ? getSystemTheme() : theme;
|
||||
applyTheme(resolved);
|
||||
set({ theme, resolvedTheme: resolved });
|
||||
},
|
||||
|
||||
toggleTheme: () => {
|
||||
cycleTheme: () => {
|
||||
const { theme, setTheme } = get();
|
||||
const newTheme: Theme = theme === 'light' ? 'dark' : 'light';
|
||||
setTheme(newTheme);
|
||||
const order: Theme[] = ['light', 'dark', 'auto'];
|
||||
const currentIndex = order.indexOf(theme);
|
||||
const nextTheme = order[(currentIndex + 1) % order.length];
|
||||
setTheme(nextTheme);
|
||||
},
|
||||
|
||||
initializeTheme: () => {
|
||||
const { theme, setTheme } = get();
|
||||
|
||||
// 检查系统偏好
|
||||
if (
|
||||
!localStorage.getItem(STORAGE_KEY_THEME) &&
|
||||
window.matchMedia &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
) {
|
||||
setTheme('dark');
|
||||
return;
|
||||
}
|
||||
|
||||
// 应用已保存的主题
|
||||
setTheme(theme);
|
||||
|
||||
// 监听系统主题变化(仅在用户未手动设置时)
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (!localStorage.getItem(STORAGE_KEY_THEME)) {
|
||||
setTheme(e.matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
// 监听系统主题变化(仅在 auto 模式下生效)
|
||||
if (!window.matchMedia) {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const listener = () => {
|
||||
const { theme: currentTheme } = get();
|
||||
if (currentTheme === 'auto') {
|
||||
const resolved = getSystemTheme();
|
||||
applyTheme(resolved);
|
||||
set({ resolvedTheme: resolved });
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', listener);
|
||||
|
||||
return () => mediaQuery.removeEventListener('change', listener);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: STORAGE_KEY_THEME
|
||||
name: STORAGE_KEY_THEME,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -331,13 +331,11 @@
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
flex: 1 0 auto;
|
||||
padding: $spacing-lg;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 通用类型定义
|
||||
*/
|
||||
|
||||
export type Theme = 'light' | 'dark';
|
||||
export type Theme = 'light' | 'dark' | 'auto';
|
||||
|
||||
export type Language = 'zh-CN' | 'en';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user