Compare commits

...

5 Commits

10 changed files with 249 additions and 175 deletions
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { authFilesApi, isAuthFileInvalidJsonObjectError } from '@/services/api';
import { authFilesApi } from '@/services/api';
import { apiClient } from '@/services/api/client';
import { useNotificationStore } from '@/stores';
import type { AuthFileItem } from '@/types';
@@ -65,17 +65,6 @@ 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);
@@ -393,9 +382,10 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
setFiles((prev) => prev.map((f) => (f.name === name ? { ...f, disabled: nextDisabled } : f)));
try {
await authFilesApi.setStatus(name, nextDisabled);
await loadFiles();
void refreshKeyStats().catch(() => {});
const res = await authFilesApi.setStatus(name, nextDisabled);
setFiles((prev) =>
prev.map((f) => (f.name === name ? { ...f, disabled: res.disabled } : f))
);
showNotification(
enabled
? t('auth_files.status_enabled_success', { name })
@@ -403,7 +393,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
'success'
);
} catch (err: unknown) {
const errorMessage = resolveStatusUpdateErrorMessage(err);
const errorMessage = err instanceof Error ? err.message : '';
setFiles((prev) =>
prev.map((f) => (f.name === name ? { ...f, disabled: previousDisabled } : f))
);
@@ -417,7 +407,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
});
}
},
[loadFiles, refreshKeyStats, resolveStatusUpdateErrorMessage, showNotification, t]
[showNotification, t]
);
const batchSetStatus = useCallback(
@@ -427,9 +417,6 @@ 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) =>
@@ -443,26 +430,31 @@ 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) => {
results.forEach((result, index) => {
const name = uniqueNames[index];
if (result.status === 'fulfilled') {
successCount++;
confirmedDisabled.set(name, result.value.disabled);
} else {
failCount++;
failedNames.add(name);
}
});
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 };
})
);
}
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 (failCount === 0) {
showNotification(t('auth_files.batch_status_success', { count: successCount }), 'success');
@@ -475,7 +467,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
deselectAll();
},
[deselectAll, files, loadFiles, refreshKeyStats, showNotification, t]
[deselectAll, showNotification, t]
);
const batchDelete = useCallback(
+4
View File
@@ -1043,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",
+4
View File
@@ -1046,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": "Путь",
+4
View File
@@ -1043,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": "路径",
+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}
+2 -12
View File
@@ -132,18 +132,8 @@ const OAUTH_MODEL_ALIAS_ENDPOINT = '/oauth-model-alias';
export const authFilesApi = {
list: () => apiClient.get<AuthFilesResponse>('/auth-files'),
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 };
},
setStatus: (name: string, disabled: boolean) =>
apiClient.patch<AuthFileStatusResponse>('/auth-files/status', { name, disabled }),
upload: (file: File) => {
const formData = new FormData();
+17 -5
View File
@@ -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;
}
}
}