Compare commits

...

9 Commits

20 changed files with 812 additions and 194 deletions
+149 -8
View File
@@ -35,6 +35,7 @@ import { versionApi } from '@/services/api';
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
import { isSupportedLanguage } from '@/utils/language';
import type { Theme } from '@/types';
const sidebarIcons: Record<string, ReactNode> = {
dashboard: <IconLayoutDashboard size={18} />,
@@ -117,6 +118,12 @@ const headerIcons = {
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />
</svg>
),
whiteTheme: (
<svg {...headerIconProps}>
<circle cx="12" cy="12" r="7" />
<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none" />
</svg>
),
autoTheme: (
<svg {...headerIconProps}>
<defs>
@@ -145,6 +152,39 @@ const headerIcons = {
),
};
const THEME_CARDS: Array<{
key: Theme;
labelKey: string;
colors: { bg: string; card: string; border: string; text: string; textMuted: string };
}> = [
{
key: 'auto',
labelKey: 'theme.auto',
colors: {
bg: 'linear-gradient(135deg, #faf9f5 0 50%, #151412 50% 100%)',
card: 'linear-gradient(135deg, #f0eee8 0 50%, #1d1b18 50% 100%)',
border: '#bdb6ae',
text: '#2d2a26',
textMuted: 'linear-gradient(135deg, #a29c95 0 50%, #9c958d 50% 100%)',
},
},
{
key: 'white',
labelKey: 'theme.white',
colors: { bg: '#ffffff', card: '#ffffff', border: '#e5e5e5', text: '#2d2a26', textMuted: '#a29c95' },
},
{
key: 'light',
labelKey: 'theme.light',
colors: { bg: '#faf9f5', card: '#f0eee8', border: '#e3e1db', text: '#2d2a26', textMuted: '#a29c95' },
},
{
key: 'dark',
labelKey: 'theme.dark',
colors: { bg: '#151412', card: '#1d1b18', border: '#3a3530', text: '#f6f4f1', textMuted: '#9c958d' },
},
];
const parseVersionSegments = (version?: string | null) => {
if (!version) return null;
const cleaned = version.trim().replace(/^v/i, '');
@@ -186,7 +226,7 @@ export function MainLayout() {
const clearCache = useConfigStore((state) => state.clearCache);
const theme = useThemeStore((state) => state.theme);
const cycleTheme = useThemeStore((state) => state.cycleTheme);
const setTheme = useThemeStore((state) => state.setTheme);
const language = useLanguageStore((state) => state.language);
const setLanguage = useLanguageStore((state) => state.setLanguage);
@@ -194,9 +234,11 @@ export function MainLayout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [checkingVersion, setCheckingVersion] = useState(false);
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
const [themeMenuOpen, setThemeMenuOpen] = useState(false);
const [brandExpanded, setBrandExpanded] = useState(true);
const contentRef = useRef<HTMLDivElement | null>(null);
const languageMenuRef = useRef<HTMLDivElement | null>(null);
const themeMenuRef = useRef<HTMLDivElement | null>(null);
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const headerRef = useRef<HTMLElement | null>(null);
@@ -304,6 +346,32 @@ export function MainLayout() {
};
}, [languageMenuOpen]);
useEffect(() => {
if (!themeMenuOpen) {
return;
}
const handlePointerDown = (event: MouseEvent) => {
if (!themeMenuRef.current?.contains(event.target as Node)) {
setThemeMenuOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setThemeMenuOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('keydown', handleEscape);
};
}, [themeMenuOpen]);
const handleBrandClick = useCallback(() => {
if (!brandExpanded) {
setBrandExpanded(true);
@@ -319,8 +387,22 @@ export function MainLayout() {
const toggleLanguageMenu = useCallback(() => {
setLanguageMenuOpen((prev) => !prev);
setThemeMenuOpen(false);
}, []);
const toggleThemeMenu = useCallback(() => {
setThemeMenuOpen((prev) => !prev);
setLanguageMenuOpen(false);
}, []);
const handleThemeSelect = useCallback(
(nextTheme: Theme) => {
setTheme(nextTheme);
setThemeMenuOpen(false);
},
[setTheme]
);
const handleLanguageSelect = useCallback(
(nextLanguage: string) => {
if (!isSupportedLanguage(nextLanguage)) {
@@ -566,13 +648,72 @@ export function MainLayout() {
</div>
)}
</div>
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
{theme === 'auto'
? headerIcons.autoTheme
: theme === 'dark'
? headerIcons.moon
: headerIcons.sun}
</Button>
<div className={`theme-menu ${themeMenuOpen ? 'open' : ''}`} ref={themeMenuRef}>
<Button
variant="ghost"
size="sm"
onClick={toggleThemeMenu}
title={t('theme.switch')}
aria-label={t('theme.switch')}
aria-haspopup="menu"
aria-expanded={themeMenuOpen}
>
{theme === 'auto'
? headerIcons.autoTheme
: theme === 'dark'
? headerIcons.moon
: theme === 'white'
? headerIcons.whiteTheme
: headerIcons.sun}
</Button>
{themeMenuOpen && (
<div className="notification entering theme-menu-popover" role="menu" aria-label={t('theme.switch')}>
{THEME_CARDS.map((tc) => (
<button
key={tc.key}
type="button"
className={`theme-card ${theme === tc.key ? 'active' : ''}`}
onClick={() => handleThemeSelect(tc.key)}
role="menuitemradio"
aria-checked={theme === tc.key}
>
<div
className="theme-card-preview"
style={{ background: tc.colors.bg, border: `1px solid ${tc.colors.border}` }}
>
<div
className="theme-card-header"
style={{
background: tc.colors.card,
borderBottom: `1px solid ${tc.colors.border}`,
}}
/>
<div className="theme-card-body">
<div
className="theme-card-sidebar"
style={{
background: tc.colors.card,
borderRight: `1px solid ${tc.colors.border}`,
}}
/>
<div className="theme-card-content" style={{ background: tc.colors.bg }}>
<div
className="theme-card-line"
style={{ background: tc.colors.textMuted }}
/>
<div
className="theme-card-line short"
style={{ background: tc.colors.textMuted }}
/>
</div>
</div>
</div>
<span className="theme-card-label">{t(tc.labelKey)}</span>
</button>
))}
</div>
)}
</div>
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
{headerIcons.logout}
</Button>
@@ -18,6 +18,7 @@ import { formatFileSize } from '@/utils/format';
import {
QUOTA_PROVIDER_TYPES,
formatModified,
getAuthFileStatusMessage,
getTypeColor,
getTypeLabel,
isRuntimeOnlyAuthFile,
@@ -105,7 +106,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
const authIndexKey = normalizeAuthIndex(rawAuthIndex);
const statusData =
(authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
const rawStatusMessage = String(file['status_message'] ?? file.statusMessage ?? '').trim();
const rawStatusMessage = getAuthFileStatusMessage(file);
const hasStatusWarning =
Boolean(rawStatusMessage) && !HEALTHY_STATUS_MESSAGES.has(rawStatusMessage.toLowerCase());
+10
View File
@@ -93,6 +93,16 @@ export const resolveQuotaErrorMessage = (
export const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
export const getAuthFileStatusMessage = (file: AuthFileItem): string => {
const raw = file['status_message'] ?? file.statusMessage;
if (typeof raw === 'string') return raw.trim();
if (raw == null) return '';
return String(raw).trim();
};
export const hasAuthFileStatusMessage = (file: AuthFileItem): boolean =>
getAuthFileStatusMessage(file).length > 0;
export const getTypeLabel = (t: TFunction, type: string): string => {
const key = `auth_files.filter_${type}`;
const translated = t(key);
@@ -1,17 +1,23 @@
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { authFilesApi } from '@/services/api';
import { authFilesApi, isAuthFileInvalidJsonObjectError } from '@/services/api';
import { apiClient } from '@/services/api/client';
import { useNotificationStore } from '@/stores';
import type { AuthFileItem } from '@/types';
import { formatFileSize } from '@/utils/format';
import { MAX_AUTH_FILE_SIZE } from '@/utils/constants';
import { downloadBlob } from '@/utils/download';
import { getTypeLabel, isRuntimeOnlyAuthFile } from '@/features/authFiles/constants';
import {
getTypeLabel,
hasAuthFileStatusMessage,
isRuntimeOnlyAuthFile,
} from '@/features/authFiles/constants';
type DeleteAllOptions = {
filter: string;
problemOnly: boolean;
onResetFilterToAll: () => void;
onResetProblemOnly: () => void;
};
export type UseAuthFilesDataResult = {
@@ -60,6 +66,16 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
const fileInputRef = useRef<HTMLInputElement | null>(null);
const selectionCount = selectedFiles.size;
const resolveStatusUpdateErrorMessage = useCallback(
(err: unknown) => {
if (isAuthFileInvalidJsonObjectError(err)) {
return t('auth_files.prefix_proxy_invalid_json');
}
return err instanceof Error ? err.message : '';
},
[t]
);
const toggleSelect = useCallback((name: string) => {
setSelectedFiles((prev) => {
const next = new Set(prev);
@@ -223,12 +239,17 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
const handleDeleteAll = useCallback(
(deleteAllOptions: DeleteAllOptions) => {
const { filter, onResetFilterToAll } = deleteAllOptions;
const { filter, problemOnly, onResetFilterToAll, onResetProblemOnly } = deleteAllOptions;
const isFiltered = filter !== 'all';
const isProblemOnly = problemOnly === true;
const typeLabel = isFiltered ? getTypeLabel(t, filter) : t('auth_files.filter_all');
const confirmMessage = isFiltered
? t('auth_files.delete_filtered_confirm', { type: typeLabel })
: t('auth_files.delete_all_confirm');
const confirmMessage = isProblemOnly
? isFiltered
? t('auth_files.delete_problem_filtered_confirm', { type: typeLabel })
: t('auth_files.delete_problem_confirm')
: isFiltered
? t('auth_files.delete_filtered_confirm', { type: typeLabel })
: t('auth_files.delete_all_confirm');
showConfirmation({
title: t('auth_files.delete_all_title', { defaultValue: 'Delete All Files' }),
@@ -238,18 +259,26 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
onConfirm: async () => {
setDeletingAll(true);
try {
if (!isFiltered) {
if (!isFiltered && !isProblemOnly) {
await authFilesApi.deleteAll();
showNotification(t('auth_files.delete_all_success'), 'success');
setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file)));
deselectAll();
} else {
const filesToDelete = files.filter(
(f) => f.type === filter && !isRuntimeOnlyAuthFile(f)
);
const filesToDelete = files.filter((file) => {
if (isRuntimeOnlyAuthFile(file)) return false;
if (isFiltered && file.type !== filter) return false;
if (isProblemOnly && !hasAuthFileStatusMessage(file)) return false;
return true;
});
if (filesToDelete.length === 0) {
showNotification(t('auth_files.delete_filtered_none', { type: typeLabel }), 'info');
const emptyMessage = isProblemOnly
? isFiltered
? t('auth_files.delete_problem_filtered_none', { type: typeLabel })
: t('auth_files.delete_problem_none')
: t('auth_files.delete_filtered_none', { type: typeLabel });
showNotification(emptyMessage, 'info');
setDeletingAll(false);
return;
}
@@ -284,18 +313,45 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
return changed ? next : prev;
});
if (failed === 0) {
if (failed === 0 && isProblemOnly) {
showNotification(
isFiltered
? t('auth_files.delete_problem_filtered_success', {
count: success,
type: typeLabel,
})
: t('auth_files.delete_problem_success', { count: success }),
'success'
);
} else if (failed === 0) {
showNotification(
t('auth_files.delete_filtered_success', { count: success, type: typeLabel }),
'success'
);
} else if (isProblemOnly) {
showNotification(
isFiltered
? t('auth_files.delete_problem_filtered_partial', {
success,
failed,
type: typeLabel,
})
: t('auth_files.delete_problem_partial', { success, failed }),
'warning'
);
} else {
showNotification(
t('auth_files.delete_filtered_partial', { success, failed, type: typeLabel }),
'warning'
);
}
onResetFilterToAll();
if (isFiltered) {
onResetFilterToAll();
}
if (isProblemOnly) {
onResetProblemOnly();
}
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
@@ -337,10 +393,9 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
setFiles((prev) => prev.map((f) => (f.name === name ? { ...f, disabled: nextDisabled } : f)));
try {
const res = await authFilesApi.setStatus(name, nextDisabled);
setFiles((prev) =>
prev.map((f) => (f.name === name ? { ...f, disabled: res.disabled } : f))
);
await authFilesApi.setStatus(name, nextDisabled);
await loadFiles();
void refreshKeyStats().catch(() => {});
showNotification(
enabled
? t('auth_files.status_enabled_success', { name })
@@ -348,7 +403,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
'success'
);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
const errorMessage = resolveStatusUpdateErrorMessage(err);
setFiles((prev) =>
prev.map((f) => (f.name === name ? { ...f, disabled: previousDisabled } : f))
);
@@ -362,7 +417,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
});
}
},
[showNotification, t]
[loadFiles, refreshKeyStats, resolveStatusUpdateErrorMessage, showNotification, t]
);
const batchSetStatus = useCallback(
@@ -372,6 +427,9 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
const targetNames = new Set(uniqueNames);
const nextDisabled = !enabled;
const previousDisabled = new Map(
files.map((file) => [file.name, file.disabled === true] as const)
);
setFiles((prev) =>
prev.map((file) =>
@@ -385,31 +443,26 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
let successCount = 0;
let failCount = 0;
const failedNames = new Set<string>();
const confirmedDisabled = new Map<string, boolean>();
results.forEach((result, index) => {
const name = uniqueNames[index];
results.forEach((result) => {
if (result.status === 'fulfilled') {
successCount++;
confirmedDisabled.set(name, result.value.disabled);
} else {
failCount++;
failedNames.add(name);
}
});
setFiles((prev) =>
prev.map((file) => {
if (failedNames.has(file.name)) {
return { ...file, disabled: !nextDisabled };
}
if (confirmedDisabled.has(file.name)) {
return { ...file, disabled: confirmedDisabled.get(file.name) };
}
return file;
})
);
if (successCount > 0) {
await loadFiles();
void refreshKeyStats().catch(() => {});
} else {
setFiles((prev) =>
prev.map((file) => {
if (!targetNames.has(file.name)) return file;
return { ...file, disabled: previousDisabled.get(file.name) === true };
})
);
}
if (failCount === 0) {
showNotification(t('auth_files.batch_status_success', { count: successCount }), 'success');
@@ -422,7 +475,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
deselectAll();
},
[deselectAll, showNotification, t]
[deselectAll, files, loadFiles, refreshKeyStats, showNotification, t]
);
const batchDelete = useCallback(
@@ -260,8 +260,7 @@ export function useAuthFilesPrefixProxyEditor(
});
try {
const file = new File([payload], name, { type: 'application/json' });
await authFilesApi.upload(file);
await authFilesApi.saveText(name, payload);
showNotification(t('auth_files.prefix_proxy_saved_success', { name }), 'success');
await loadFiles();
await loadKeyStats();
+1 -1
View File
@@ -1,5 +1,6 @@
export type AuthFilesUiState = {
filter?: string;
problemOnly?: boolean;
search?: string;
page?: number;
pageSize?: number;
@@ -27,4 +28,3 @@ export const writeAuthFilesUiState = (state: AuthFilesUiState) => {
// ignore
}
};
+17
View File
@@ -469,6 +469,10 @@
"delete_confirm": "Are you sure you want to delete file",
"delete_all_confirm": "Are you sure you want to delete all auth files? This operation cannot be undone!",
"delete_filtered_confirm": "Are you sure you want to delete all {{type}} auth files? This operation cannot be undone!",
"delete_problem_button": "Delete Problem Files",
"delete_problem_button_with_type": "Delete Problematic {{type}} Files",
"delete_problem_confirm": "Are you sure you want to delete all problematic auth files? This operation cannot be undone!",
"delete_problem_filtered_confirm": "Are you sure you want to delete all problematic {{type}} auth files? This operation cannot be undone!",
"upload_error_json": "Only JSON files are allowed",
"upload_error_size": "File size cannot exceed {{maxSize}}",
"upload_success": "File uploaded successfully",
@@ -478,12 +482,20 @@
"delete_filtered_success": "Deleted {{count}} {{type}} auth files successfully",
"delete_filtered_partial": "{{type}} auth files deletion finished: {{success}} succeeded, {{failed}} failed",
"delete_filtered_none": "No deletable auth files under the current filter ({{type}})",
"delete_problem_success": "Deleted {{count}} problematic auth files successfully",
"delete_problem_filtered_success": "Deleted {{count}} problematic {{type}} auth files successfully",
"delete_problem_partial": "Problematic auth files deletion finished: {{success}} succeeded, {{failed}} failed",
"delete_problem_filtered_partial": "Problematic {{type}} auth files deletion finished: {{success}} succeeded, {{failed}} failed",
"delete_problem_none": "No deletable problematic auth files under the current filter",
"delete_problem_filtered_none": "No deletable problematic {{type}} auth files under the current filter",
"files_count": "files",
"pagination_prev": "Previous",
"pagination_next": "Next",
"pagination_info": "Page {{current}} / {{total}} · {{count}} files",
"search_label": "Search configs",
"search_placeholder": "Filter by name, type, or provider",
"problem_filter_label": "Problem Filter",
"problem_filter_only": "Only show problematic credentials",
"page_size_label": "Per page",
"page_size_unit": "items",
"view_mode_paged": "Paged",
@@ -1031,6 +1043,10 @@
"show_raw_logs": "Show Raw Logs",
"show_raw_logs_hint": "Show original log text for easier multi-line copy",
"search_placeholder": "Search logs by content or keyword",
"filter_panel_title": "Structured Filters",
"filter_panel_expand": "Expand structured filters",
"filter_panel_collapse": "Collapse structured filters",
"filter_panel_active_count": "{{count}} active",
"filter_method": "Method",
"filter_status": "Status",
"filter_path": "Path",
@@ -1400,6 +1416,7 @@
"theme": {
"switch": "Theme",
"light": "Light",
"white": "Pure White",
"dark": "Dark",
"switch_to_light": "Switch to light mode",
"switch_to_dark": "Switch to dark mode",
+17
View File
@@ -469,6 +469,10 @@
"delete_confirm": "Удалить файл",
"delete_all_confirm": "Удалить все файлы авторизации? Это действие нельзя отменить!",
"delete_filtered_confirm": "Удалить все файлы авторизации {{type}}? Это действие нельзя отменить!",
"delete_problem_button": "Удалить проблемные",
"delete_problem_button_with_type": "Удалить проблемные файлы {{type}}",
"delete_problem_confirm": "Удалить все проблемные файлы авторизации? Это действие нельзя отменить!",
"delete_problem_filtered_confirm": "Удалить все проблемные файлы авторизации {{type}}? Это действие нельзя отменить!",
"upload_error_json": "Допустимы только файлы JSON",
"upload_error_size": "Размер файла не может превышать {{maxSize}}",
"upload_success": "Файл успешно загружен",
@@ -478,12 +482,20 @@
"delete_filtered_success": "Удалено файлов {{type}}: {{count}}",
"delete_filtered_partial": "Удаление файлов {{type}} завершено: успешных {{success}}, ошибок {{failed}}",
"delete_filtered_none": "Нет файлов {{type}} для удаления при текущем фильтре",
"delete_problem_success": "Удалено проблемных файлов авторизации: {{count}}",
"delete_problem_filtered_success": "Удалено проблемных файлов авторизации {{type}}: {{count}}",
"delete_problem_partial": "Удаление проблемных файлов авторизации завершено: успешных {{success}}, ошибок {{failed}}",
"delete_problem_filtered_partial": "Удаление проблемных файлов авторизации {{type}} завершено: успешных {{success}}, ошибок {{failed}}",
"delete_problem_none": "Нет проблемных файлов авторизации для удаления при текущем фильтре",
"delete_problem_filtered_none": "Нет проблемных файлов авторизации {{type}} для удаления при текущем фильтре",
"files_count": "файлов",
"pagination_prev": "Предыдущая",
"pagination_next": "Следующая",
"pagination_info": "Страница {{current}} / {{total}} · {{count}} файлов",
"search_label": "Поиск конфигов",
"search_placeholder": "Фильтр по имени, типу или провайдеру",
"problem_filter_label": "Фильтр проблем",
"problem_filter_only": "Показывать только проблемные учётные данные",
"page_size_label": "На странице",
"page_size_unit": "элементов",
"view_mode_paged": "Постранично",
@@ -1034,6 +1046,10 @@
"show_raw_logs": "Показать исходные журналы",
"show_raw_logs_hint": "Показать текст журнала без обработки для удобного копирования в несколько строк",
"search_placeholder": "Искать по содержимому или ключевым словам",
"filter_panel_title": "Структурные фильтры",
"filter_panel_expand": "Развернуть структурные фильтры",
"filter_panel_collapse": "Свернуть структурные фильтры",
"filter_panel_active_count": "Активно: {{count}}",
"filter_method": "Метод",
"filter_status": "Статус",
"filter_path": "Путь",
@@ -1405,6 +1421,7 @@
"theme": {
"switch": "Тема",
"light": "Светлая",
"white": "Чисто-белая",
"dark": "Тёмная",
"switch_to_light": "Переключиться на светлую тему",
"switch_to_dark": "Переключиться на тёмную тему",
+17
View File
@@ -469,6 +469,10 @@
"delete_confirm": "确定要删除文件",
"delete_all_confirm": "确定要删除所有认证文件吗?此操作不可恢复!",
"delete_filtered_confirm": "确定要删除筛选出的 {{type}} 认证文件吗?此操作不可恢复!",
"delete_problem_button": "删除问题凭证",
"delete_problem_button_with_type": "删除 {{type}} 问题凭证",
"delete_problem_confirm": "确定要删除所有有问题的认证文件吗?此操作不可恢复!",
"delete_problem_filtered_confirm": "确定要删除筛选出的有问题的 {{type}} 认证文件吗?此操作不可恢复!",
"upload_error_json": "只能上传JSON文件",
"upload_error_size": "文件大小不能超过 {{maxSize}}",
"upload_success": "文件上传成功",
@@ -478,12 +482,20 @@
"delete_filtered_success": "成功删除 {{count}} 个 {{type}} 认证文件",
"delete_filtered_partial": "{{type}} 认证文件删除完成,成功 {{success}} 个,失败 {{failed}} 个",
"delete_filtered_none": "当前筛选类型 ({{type}}) 下没有可删除的认证文件",
"delete_problem_success": "成功删除 {{count}} 个有问题的认证文件",
"delete_problem_filtered_success": "成功删除 {{count}} 个有问题的 {{type}} 认证文件",
"delete_problem_partial": "有问题认证文件删除完成,成功 {{success}} 个,失败 {{failed}} 个",
"delete_problem_filtered_partial": "有问题的 {{type}} 认证文件删除完成,成功 {{success}} 个,失败 {{failed}} 个",
"delete_problem_none": "当前没有可删除的有问题认证文件",
"delete_problem_filtered_none": "当前筛选类型 ({{type}}) 下没有可删除的有问题认证文件",
"files_count": "个文件",
"pagination_prev": "上一页",
"pagination_next": "下一页",
"pagination_info": "第 {{current}} / {{total}} 页 · 共 {{count}} 个文件",
"search_label": "搜索配置文件",
"search_placeholder": "输入名称、类型或提供方关键字",
"problem_filter_label": "问题筛选",
"problem_filter_only": "仅显示有问题凭证",
"page_size_label": "单页数量",
"page_size_unit": "个/页",
"view_mode_paged": "按页显示",
@@ -1031,6 +1043,10 @@
"show_raw_logs": "显示原始日志",
"show_raw_logs_hint": "直接显示原始日志文本,方便多行复制",
"search_placeholder": "搜索日志内容或关键字",
"filter_panel_title": "结构化筛选",
"filter_panel_expand": "展开结构化筛选",
"filter_panel_collapse": "收起结构化筛选",
"filter_panel_active_count": "已选 {{count}} 项",
"filter_method": "请求方法",
"filter_status": "状态码",
"filter_path": "路径",
@@ -1400,6 +1416,7 @@
"theme": {
"switch": "主题",
"light": "亮色",
"white": "纯白",
"dark": "暗色",
"switch_to_light": "切换到亮色模式",
"switch_to_dark": "切换到暗色模式",
+19
View File
@@ -161,6 +161,25 @@
}
}
.filterToggleItem {
min-width: 220px;
}
.filterToggle {
display: flex;
align-items: center;
min-height: 38px;
}
.filterToggleLabel {
display: inline-flex;
align-items: center;
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
white-space: nowrap;
}
.pageSizeSelect {
padding: 8px 12px;
border: 1px solid var(--border-color);
+51 -11
View File
@@ -19,6 +19,7 @@ import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { EmptyState } from '@/components/ui/EmptyState';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { copyToClipboard } from '@/utils/clipboard';
import {
MAX_CARD_PAGE_SIZE,
@@ -27,6 +28,7 @@ import {
clampCardPageSize,
getTypeColor,
getTypeLabel,
hasAuthFileStatusMessage,
isRuntimeOnlyAuthFile,
normalizeProviderKey,
type QuotaProviderType,
@@ -64,6 +66,7 @@ export function AuthFilesPage() {
const navigate = useNavigate();
const [filter, setFilter] = useState<'all' | string>('all');
const [problemOnly, setProblemOnly] = useState(false);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(9);
@@ -162,6 +165,9 @@ export function AuthFilesPage() {
if (typeof persisted.filter === 'string' && persisted.filter.trim()) {
setFilter(persisted.filter);
}
if (typeof persisted.problemOnly === 'boolean') {
setProblemOnly(persisted.problemOnly);
}
if (typeof persisted.search === 'string') {
setSearch(persisted.search);
}
@@ -174,8 +180,8 @@ export function AuthFilesPage() {
}, []);
useEffect(() => {
writeAuthFilesUiState({ filter, search, page, pageSize });
}, [filter, search, page, pageSize]);
writeAuthFilesUiState({ filter, problemOnly, search, page, pageSize });
}, [filter, problemOnly, search, page, pageSize]);
useEffect(() => {
setPageSizeInput(String(pageSize));
@@ -248,17 +254,22 @@ export function AuthFilesPage() {
return Array.from(types);
}, [files]);
const filesMatchingProblemFilter = useMemo(
() => (problemOnly ? files.filter(hasAuthFileStatusMessage) : files),
[files, problemOnly]
);
const typeCounts = useMemo(() => {
const counts: Record<string, number> = { all: files.length };
files.forEach((file) => {
const counts: Record<string, number> = { all: filesMatchingProblemFilter.length };
filesMatchingProblemFilter.forEach((file) => {
if (!file.type) return;
counts[file.type] = (counts[file.type] || 0) + 1;
});
return counts;
}, [files]);
}, [filesMatchingProblemFilter]);
const filtered = useMemo(() => {
return files.filter((item) => {
return filesMatchingProblemFilter.filter((item) => {
const matchType = filter === 'all' || item.type === filter;
const term = search.trim().toLowerCase();
const matchSearch =
@@ -268,7 +279,7 @@ export function AuthFilesPage() {
(item.provider || '').toString().toLowerCase().includes(term);
return matchType && matchSearch;
});
}, [files, filter, search]);
}, [filesMatchingProblemFilter, filter, search]);
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const currentPage = Math.min(page, totalPages);
@@ -456,6 +467,14 @@ export function AuthFilesPage() {
</div>
);
const deleteAllButtonLabel = problemOnly
? filter === 'all'
? t('auth_files.delete_problem_button')
: t('auth_files.delete_problem_button_with_type', { type: getTypeLabel(t, filter) })
: filter === 'all'
? t('auth_files.delete_all_button')
: `${t('common.delete')} ${getTypeLabel(t, filter)}`;
return (
<div className={styles.container}>
<div className={styles.pageHeader}>
@@ -482,14 +501,17 @@ export function AuthFilesPage() {
variant="danger"
size="sm"
onClick={() =>
handleDeleteAll({ filter, onResetFilterToAll: () => setFilter('all') })
handleDeleteAll({
filter,
problemOnly,
onResetFilterToAll: () => setFilter('all'),
onResetProblemOnly: () => setProblemOnly(false),
})
}
disabled={disableControls || loading || deletingAll}
loading={deletingAll}
>
{filter === 'all'
? t('auth_files.delete_all_button')
: `${t('common.delete')} ${getTypeLabel(t, filter)}`}
{deleteAllButtonLabel}
</Button>
<input
ref={fileInputRef}
@@ -537,6 +559,24 @@ export function AuthFilesPage() {
}}
/>
</div>
<div className={`${styles.filterItem} ${styles.filterToggleItem}`}>
<label>{t('auth_files.problem_filter_label')}</label>
<div className={styles.filterToggle}>
<ToggleSwitch
checked={problemOnly}
onChange={(value) => {
setProblemOnly(value);
setPage(1);
}}
ariaLabel={t('auth_files.problem_filter_only')}
label={
<span className={styles.filterToggleLabel}>
{t('auth_files.problem_filter_only')}
</span>
}
/>
</div>
</div>
</div>
</div>
+8 -28
View File
@@ -169,22 +169,8 @@
// 语言下拉选择
.languageSelect {
white-space: nowrap;
border: 1px solid var(--border-color);
border-radius: $radius-md;
padding: 10px 12px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
height: 40px;
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba($primary-color, 0.18);
}
min-width: 108px;
flex: 0 0 auto;
}
// 连接信息框
@@ -218,19 +204,13 @@
.toggleAdvanced {
display: flex;
justify-content: flex-start;
align-items: center;
gap: $spacing-xs;
width: 100%;
}
.toggleLabel {
color: var(--text-secondary);
cursor: pointer;
input[type='checkbox'] {
cursor: pointer;
}
label {
cursor: pointer;
font-size: 14px;
}
font-size: 14px;
font-weight: 500;
}
// 错误提示框
+24 -22
View File
@@ -3,6 +3,8 @@ import { Navigate, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconEye, IconEyeOff } from '@/components/ui/icons';
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
@@ -89,9 +91,16 @@ export function LoginPage() {
const [error, setError] = useState('');
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
const languageOptions = useMemo(
() =>
LANGUAGE_ORDER.map((lang) => ({
value: lang,
label: t(LANGUAGE_LABEL_KEYS[lang])
})),
[t]
);
const handleLanguageChange = useCallback(
(event: React.ChangeEvent<HTMLSelectElement>) => {
const selectedLanguage = event.target.value;
(selectedLanguage: string) => {
if (!isSupportedLanguage(selectedLanguage)) {
return;
}
@@ -205,19 +214,14 @@ export function LoginPage() {
<div className={styles.loginHeader}>
<div className={styles.titleRow}>
<div className={styles.title}>{t('title.login')}</div>
<select
<Select
className={styles.languageSelect}
value={language}
options={languageOptions}
onChange={handleLanguageChange}
title={t('language.switch')}
aria-label={t('language.switch')}
>
{LANGUAGE_ORDER.map((lang) => (
<option key={lang} value={lang}>
{t(LANGUAGE_LABEL_KEYS[lang])}
</option>
))}
</select>
fullWidth={false}
ariaLabel={t('language.switch')}
/>
</div>
<div className={styles.subtitle}>{t('login.subtitle')}</div>
</div>
@@ -229,13 +233,12 @@ export function LoginPage() {
</div>
<div className={styles.toggleAdvanced}>
<input
id="custom-connection-toggle"
type="checkbox"
<ToggleSwitch
checked={showCustomBase}
onChange={(e) => setShowCustomBase(e.target.checked)}
onChange={setShowCustomBase}
ariaLabel={t('login.custom_connection_label')}
label={<span className={styles.toggleLabel}>{t('login.custom_connection_label')}</span>}
/>
<label htmlFor="custom-connection-toggle">{t('login.custom_connection_label')}</label>
</div>
{showCustomBase && (
@@ -278,13 +281,12 @@ export function LoginPage() {
/>
<div className={styles.toggleAdvanced}>
<input
id="remember-password-toggle"
type="checkbox"
<ToggleSwitch
checked={rememberPassword}
onChange={(e) => setRememberPassword(e.target.checked)}
onChange={setRememberPassword}
ariaLabel={t('login.remember_password_label')}
label={<span className={styles.toggleLabel}>{t('login.remember_password_label')}</span>}
/>
<label htmlFor="remember-password-toggle">{t('login.remember_password_label')}</label>
</div>
<Button fullWidth onClick={handleSubmit} loading={loading}>
+41
View File
@@ -121,6 +121,33 @@
max-width: 420px;
}
.filterPanelHeader {
display: flex;
align-items: center;
flex: 1 1 100%;
}
.filterPanelToggle {
white-space: nowrap;
}
.filterPanelButtonContent {
display: inline-flex;
align-items: center;
gap: 6px;
}
.filterPanelCount {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: $radius-full;
background: rgba($primary-color, 0.12);
color: var(--primary-color);
font-size: 12px;
line-height: 1.2;
}
.structuredFilters {
display: flex;
flex-direction: column;
@@ -200,6 +227,20 @@
}
@include mobile {
.filterPanelHeader {
width: 100%;
}
.filterPanelToggle {
width: 100%;
}
.filterPanelButtonContent {
width: 100%;
justify-content: space-between;
flex-wrap: wrap;
}
.filterChipGroup {
flex-direction: column;
gap: 6px;
+120 -75
View File
@@ -8,16 +8,20 @@ import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import {
IconDownload,
IconChevronDown,
IconChevronUp,
IconCode,
IconDownload,
IconEyeOff,
IconRefreshCw,
IconSearch,
IconSlidersHorizontal,
IconTimer,
IconTrash2,
IconX,
} from '@/components/ui/icons';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { logsApi } from '@/services/api/logs';
import { copyToClipboard } from '@/utils/clipboard';
@@ -79,6 +83,10 @@ export function LogsPage() {
const deferredSearchQuery = useDeferredValue(searchQuery);
const [hideManagementLogs, setHideManagementLogs] = useState(true);
const [showRawLogs, setShowRawLogs] = useState(false);
const [structuredFiltersExpanded, setStructuredFiltersExpanded] = useLocalStorage(
'logsPage.structuredFiltersExpanded',
true
);
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
const [loadingErrors, setLoadingErrors] = useState(false);
const [errorLogsError, setErrorLogsError] = useState('');
@@ -305,6 +313,9 @@ export function LogsPage() {
}, [baseLines, hideManagementLogs, trimmedSearchQuery]);
const filters = useLogFilters({ parsedLines: parsedSearchLines });
const structuredFiltersPanelId = 'logs-structured-filters';
const structuredFilterCount =
filters.methodFilters.length + filters.statusFilters.length + filters.pathFilters.length;
const { filteredParsedLines, filteredLines, removedCount } = useMemo(() => {
const filteredParsed = parsedSearchLines.filter((line) => {
@@ -498,86 +509,120 @@ export function LogsPage() {
/>
</div>
<div className={styles.structuredFilters}>
<div className={styles.filterChipGroup}>
<span className={styles.filterChipLabel}>{t('logs.filter_method')}</span>
<div className={styles.filterChipList}>
{HTTP_METHODS.map((method) => {
const active = filters.methodFilters.includes(method);
const count = filters.methodCounts[method] ?? 0;
return (
<button
key={method}
type="button"
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
onClick={() => filters.toggleMethodFilter(method)}
disabled={count === 0 && !active}
aria-pressed={active}
>
{method} ({count})
</button>
);
})}
</div>
</div>
<div className={styles.filterChipGroup}>
<span className={styles.filterChipLabel}>{t('logs.filter_status')}</span>
<div className={styles.filterChipList}>
{STATUS_GROUPS.map((statusGroup) => {
const active = filters.statusFilters.includes(statusGroup);
const count = filters.statusCounts[statusGroup] ?? 0;
return (
<button
key={statusGroup}
type="button"
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
onClick={() => filters.toggleStatusFilter(statusGroup)}
disabled={count === 0 && !active}
aria-pressed={active}
>
{t(`logs.filter_status_${statusGroup}`)} ({count})
</button>
);
})}
</div>
</div>
<div className={styles.filterChipGroup}>
<span className={styles.filterChipLabel}>{t('logs.filter_path')}</span>
<div className={styles.filterChipList}>
{filters.pathOptions.length === 0 ? (
<span className={styles.filterChipHint}>{t('logs.filter_path_empty')}</span>
) : (
filters.pathOptions.map(({ path, count }) => {
const active = filters.pathFilters.includes(path);
return (
<button
key={path}
type="button"
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
onClick={() => filters.togglePathFilter(path)}
aria-pressed={active}
title={path}
>
{path} ({count})
</button>
);
})
)}
</div>
</div>
<div className={styles.filterPanelHeader}>
<Button
variant="ghost"
type="button"
variant="secondary"
size="sm"
onClick={filters.clearStructuredFilters}
disabled={!filters.hasStructuredFilters}
className={styles.filterPanelToggle}
onClick={() => setStructuredFiltersExpanded((prev) => !prev)}
aria-expanded={structuredFiltersExpanded}
aria-controls={structuredFiltersPanelId}
title={
structuredFiltersExpanded
? t('logs.filter_panel_collapse')
: t('logs.filter_panel_expand')
}
>
{t('logs.clear_filters')}
<span className={styles.filterPanelButtonContent}>
<IconSlidersHorizontal size={16} />
<span>{t('logs.filter_panel_title')}</span>
{structuredFilterCount > 0 && (
<span className={styles.filterPanelCount}>
{t('logs.filter_panel_active_count', { count: structuredFilterCount })}
</span>
)}
{structuredFiltersExpanded ? (
<IconChevronUp size={16} />
) : (
<IconChevronDown size={16} />
)}
</span>
</Button>
</div>
{structuredFiltersExpanded && (
<div id={structuredFiltersPanelId} className={styles.structuredFilters}>
<div className={styles.filterChipGroup}>
<span className={styles.filterChipLabel}>{t('logs.filter_method')}</span>
<div className={styles.filterChipList}>
{HTTP_METHODS.map((method) => {
const active = filters.methodFilters.includes(method);
const count = filters.methodCounts[method] ?? 0;
return (
<button
key={method}
type="button"
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
onClick={() => filters.toggleMethodFilter(method)}
disabled={count === 0 && !active}
aria-pressed={active}
>
{method} ({count})
</button>
);
})}
</div>
</div>
<div className={styles.filterChipGroup}>
<span className={styles.filterChipLabel}>{t('logs.filter_status')}</span>
<div className={styles.filterChipList}>
{STATUS_GROUPS.map((statusGroup) => {
const active = filters.statusFilters.includes(statusGroup);
const count = filters.statusCounts[statusGroup] ?? 0;
return (
<button
key={statusGroup}
type="button"
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
onClick={() => filters.toggleStatusFilter(statusGroup)}
disabled={count === 0 && !active}
aria-pressed={active}
>
{t(`logs.filter_status_${statusGroup}`)} ({count})
</button>
);
})}
</div>
</div>
<div className={styles.filterChipGroup}>
<span className={styles.filterChipLabel}>{t('logs.filter_path')}</span>
<div className={styles.filterChipList}>
{filters.pathOptions.length === 0 ? (
<span className={styles.filterChipHint}>{t('logs.filter_path_empty')}</span>
) : (
filters.pathOptions.map(({ path, count }) => {
const active = filters.pathFilters.includes(path);
return (
<button
key={path}
type="button"
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
onClick={() => filters.togglePathFilter(path)}
aria-pressed={active}
title={path}
>
{path} ({count})
</button>
);
})
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={filters.clearStructuredFilters}
disabled={!filters.hasStructuredFilters}
>
{t('logs.clear_filters')}
</Button>
</div>
)}
<ToggleSwitch
checked={hideManagementLogs}
onChange={setHideManagementLogs}
+49 -2
View File
@@ -9,12 +9,39 @@ import type { OAuthModelAliasEntry } from '@/types';
type StatusError = { status?: number };
type AuthFileStatusResponse = { status: string; disabled: boolean };
export const AUTH_FILE_INVALID_JSON_OBJECT_ERROR = 'AUTH_FILE_INVALID_JSON_OBJECT';
const getStatusCode = (err: unknown): number | undefined => {
if (!err || typeof err !== 'object') return undefined;
if ('status' in err) return (err as StatusError).status;
return undefined;
};
const parseAuthFileJsonObject = (rawText: string): Record<string, unknown> => {
const trimmed = rawText.trim();
let parsed: unknown;
try {
parsed = JSON.parse(trimmed) as unknown;
} catch {
throw new Error(AUTH_FILE_INVALID_JSON_OBJECT_ERROR);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(AUTH_FILE_INVALID_JSON_OBJECT_ERROR);
}
return { ...(parsed as Record<string, unknown>) };
};
const saveAuthFileText = async (name: string, text: string) => {
const file = new File([text], name, { type: 'application/json' });
await authFilesApi.upload(file);
};
export const isAuthFileInvalidJsonObjectError = (err: unknown): boolean =>
err instanceof Error && err.message === AUTH_FILE_INVALID_JSON_OBJECT_ERROR;
const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]> => {
if (!payload || typeof payload !== 'object') return {};
@@ -105,8 +132,18 @@ const OAUTH_MODEL_ALIAS_ENDPOINT = '/oauth-model-alias';
export const authFilesApi = {
list: () => apiClient.get<AuthFilesResponse>('/auth-files'),
setStatus: (name: string, disabled: boolean) =>
apiClient.patch<AuthFileStatusResponse>('/auth-files/status', { name, disabled }),
async setStatus(name: string, disabled: boolean): Promise<AuthFileStatusResponse> {
const json = await authFilesApi.downloadJsonObject(name);
if (disabled) {
json.disabled = true;
} else {
delete json.disabled;
}
await authFilesApi.saveJsonObject(name, json);
return { status: disabled ? 'disabled' : 'enabled', disabled };
},
upload: (file: File) => {
const formData = new FormData();
@@ -126,6 +163,16 @@ export const authFilesApi = {
return blob.text();
},
async downloadJsonObject(name: string): Promise<Record<string, unknown>> {
const rawText = await authFilesApi.downloadText(name);
return parseAuthFileJsonObject(rawText);
},
saveText: (name: string, text: string) => saveAuthFileText(name, text),
saveJsonObject: (name: string, json: Record<string, unknown>) =>
saveAuthFileText(name, JSON.stringify(json)),
// OAuth 排除模型
async getOauthExcludedModels(): Promise<Record<string, string[]>> {
const data = await apiClient.get('/oauth-excluded-models');
+25 -6
View File
@@ -25,12 +25,28 @@ const getSystemTheme = (): ResolvedTheme => {
return 'light';
};
const applyTheme = (resolved: ResolvedTheme) => {
const resolveTheme = (theme: Theme): ResolvedTheme | 'white' => {
if (theme === 'auto') {
return getSystemTheme();
}
if (theme === 'white') {
return 'white';
}
return theme;
};
const applyTheme = (resolved: ResolvedTheme | 'white') => {
if (resolved === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-theme');
return;
}
if (resolved === 'white') {
document.documentElement.setAttribute('data-theme', 'white');
return;
}
document.documentElement.removeAttribute('data-theme');
};
export const useThemeStore = create<ThemeState>()(
@@ -40,14 +56,17 @@ export const useThemeStore = create<ThemeState>()(
resolvedTheme: 'light',
setTheme: (theme) => {
const resolved: ResolvedTheme = theme === 'auto' ? getSystemTheme() : theme;
const resolved = resolveTheme(theme);
applyTheme(resolved);
set({ theme, resolvedTheme: resolved });
set({
theme,
resolvedTheme: resolved === 'white' ? 'light' : resolved,
});
},
cycleTheme: () => {
const { theme, setTheme } = get();
const order: Theme[] = ['light', 'dark', 'auto'];
const order: Theme[] = ['light', 'white', 'dark', 'auto'];
const currentIndex = order.indexOf(theme);
const nextTheme = order[(currentIndex + 1) % order.length];
setTheme(nextTheme);
+117
View File
@@ -251,6 +251,123 @@
}
}
.theme-menu {
position: relative;
display: inline-flex;
align-items: center;
.theme-menu-popover {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: $z-dropdown;
padding: $spacing-sm $spacing-sm $spacing-xs;
display: flex;
gap: $spacing-xs;
width: max-content;
max-width: calc(100vw - 16px);
}
.theme-card {
border: 2px solid transparent;
border-radius: $radius-md;
background: transparent;
cursor: pointer;
padding: 6px 6px 4px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
transition: border-color $transition-fast, background-color $transition-fast;
&:hover {
background: var(--bg-secondary);
}
&:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba($primary-color, 0.22);
}
&.active {
border-color: var(--primary-color);
}
}
.theme-card-preview {
width: 72px;
height: 52px;
border-radius: $radius-sm;
overflow: hidden;
display: flex;
flex-direction: column;
}
.theme-card-header {
height: 10px;
flex-shrink: 0;
}
.theme-card-body {
flex: 1;
display: flex;
min-height: 0;
}
.theme-card-sidebar {
width: 16px;
flex-shrink: 0;
}
.theme-card-content {
flex: 1;
padding: 5px 8px;
display: flex;
flex-direction: column;
gap: 4px;
justify-content: center;
}
.theme-card-line {
height: 3px;
border-radius: 1px;
&.short {
width: 60%;
}
}
.theme-card-label {
font-size: 11px;
color: var(--text-primary);
font-weight: 500;
white-space: nowrap;
}
@media (max-width: $breakpoint-mobile) {
.theme-menu-popover {
right: 0;
left: auto;
transform: none;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
justify-content: stretch;
width: min(188px, calc(100vw - 16px));
}
.theme-card {
width: 100%;
min-width: 0;
}
.theme-card-label {
white-space: normal;
text-align: center;
line-height: 1.2;
}
}
}
svg {
display: block;
}
+53
View File
@@ -56,6 +56,59 @@
--accent-tertiary: var(--bg-tertiary);
}
// 纯白主题
[data-theme='white'] {
--bg-secondary: #ffffff;
--bg-primary: #ffffff;
--bg-tertiary: #f6f6f6;
--bg-hover: var(--bg-tertiary);
--bg-quinary: #ffffff;
--bg-error-light: rgba(198, 87, 70, 0.08);
--text-primary: #2d2a26;
--text-secondary: #6d6760;
--text-tertiary: #a29c95;
--text-quaternary: #c0bab3;
--text-muted: var(--text-tertiary);
--border-color: #e5e5e5;
--border-secondary: var(--border-color);
--border-primary: #d9d9d9;
--border-hover: #cccccc;
--primary-color: #8b8680;
--primary-hover: #7f7a74;
--primary-active: #726d67;
--primary-contrast: #ffffff;
--success-color: #10b981;
--warning-color: #c65746;
--error-color: #c65746;
--danger-color: var(--error-color);
--info-color: var(--primary-color);
--warning-bg: rgba(198, 87, 70, 0.12);
--warning-border: rgba(198, 87, 70, 0.35);
--warning-text: var(--warning-color);
--success-badge-bg: #d1fae5;
--success-badge-text: #065f46;
--success-badge-border: #6ee7b7;
--failure-badge-bg: rgba(198, 87, 70, 0.14);
--failure-badge-text: #8a3a30;
--failure-badge-border: rgba(198, 87, 70, 0.35);
--count-badge-bg: rgba(139, 134, 128, 0.18);
--count-badge-text: var(--primary-active);
--shadow: 0 1px 2px 0 rgb(0 0 0 / 0.08);
--shadow-lg: 0 10px 18px -3px rgb(0 0 0 / 0.1);
--radius-md: 8px;
--accent-tertiary: var(--bg-tertiary);
}
// 深色主题(#191919
[data-theme='dark'] {
// 极简暖灰:深色模式(提升对比度与层级)
+1 -1
View File
@@ -2,7 +2,7 @@
* 通用类型定义
*/
export type Theme = 'light' | 'dark' | 'auto';
export type Theme = 'light' | 'white' | 'dark' | 'auto';
export type Language = 'zh-CN' | 'en' | 'ru';