mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
5 Commits
@@ -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());
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,11 +7,17 @@ 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 = {
|
||||
@@ -233,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' }),
|
||||
@@ -248,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;
|
||||
}
|
||||
@@ -294,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 : '';
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Путь",
|
||||
|
||||
@@ -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": "路径",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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}>
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
+17
-5
@@ -346,12 +346,24 @@
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.theme-menu-popover {
|
||||
right: auto;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
right: 0;
|
||||
left: auto;
|
||||
transform: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, max-content);
|
||||
justify-content: center;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user