Compare commits

...

5 Commits

Author SHA1 Message Date
Supra4E8C
10f2262753 fix(ai-providers): gate Claude models input and refine excluded tag styles 2025-12-19 23:54:26 +08:00
Supra4E8C
39d86d133a feat(oauth): add callback URL submission and require Gemini CLI project ID 2025-12-19 18:04:14 +08:00
Supra4E8C
ddbd7d00bd fix 2025-12-18 17:49:59 +08:00
Supra4E8C
e44beb541f feat 2025-12-18 12:36:17 +08:00
Supra4E8C
aecd5875d6 feat(usage): add loading overlay and 60s API timeout 2025-12-18 01:03:12 +08:00
14 changed files with 797 additions and 281 deletions

2
.gitignore vendored
View File

@@ -8,6 +8,8 @@ pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
api.md api.md
usage.json usage.json
CLAUDE.md
AGENTS.md
node_modules node_modules
dist dist

View File

@@ -424,9 +424,10 @@
"gemini_cli_oauth_title": "Gemini CLI OAuth", "gemini_cli_oauth_title": "Gemini CLI OAuth",
"gemini_cli_oauth_button": "Start Gemini CLI Login", "gemini_cli_oauth_button": "Start Gemini CLI Login",
"gemini_cli_oauth_hint": "Login to Google Gemini CLI service through OAuth flow, automatically obtain and save authentication files.", "gemini_cli_oauth_hint": "Login to Google Gemini CLI service through OAuth flow, automatically obtain and save authentication files.",
"gemini_cli_project_id_label": "Google Cloud Project ID (Optional):", "gemini_cli_project_id_label": "Google Cloud Project ID:",
"gemini_cli_project_id_placeholder": "Enter Google Cloud Project ID (optional)", "gemini_cli_project_id_placeholder": "Enter Google Cloud Project ID",
"gemini_cli_project_id_hint": "If a project ID is specified, authentication information for that project will be used.", "gemini_cli_project_id_hint": "Project ID is required for Gemini CLI OAuth.",
"gemini_cli_project_id_required": "Please enter a Google Cloud project ID.",
"gemini_cli_oauth_url_label": "Authorization URL:", "gemini_cli_oauth_url_label": "Authorization URL:",
"gemini_cli_open_link": "Open Link", "gemini_cli_open_link": "Open Link",
"gemini_cli_copy_link": "Copy Link", "gemini_cli_copy_link": "Copy Link",
@@ -446,6 +447,16 @@
"qwen_oauth_status_error": "Authentication failed:", "qwen_oauth_status_error": "Authentication failed:",
"qwen_oauth_start_error": "Failed to start Qwen OAuth:", "qwen_oauth_start_error": "Failed to start Qwen OAuth:",
"qwen_oauth_polling_error": "Failed to check authentication status:", "qwen_oauth_polling_error": "Failed to check authentication status:",
"oauth_callback_label": "Callback URL",
"oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...",
"oauth_callback_hint": "Remote browser mode: after the provider redirects to http://localhost:..., copy the full URL and submit it here.",
"oauth_callback_button": "Submit Callback URL",
"oauth_callback_required": "Please paste the full redirect URL first.",
"oauth_callback_success": "Callback URL submitted. Continue waiting for authentication.",
"oauth_callback_error": "Failed to submit callback URL:",
"oauth_callback_upgrade_hint": "Please update CLI Proxy API or check the connection.",
"oauth_callback_status_success": "Callback URL submitted, waiting for authentication...",
"oauth_callback_status_error": "Callback URL submission failed:",
"missing_state": "Unable to retrieve authentication state parameter", "missing_state": "Unable to retrieve authentication state parameter",
"iflow_oauth_title": "iFlow OAuth", "iflow_oauth_title": "iFlow OAuth",
"iflow_oauth_button": "Start iFlow Login", "iflow_oauth_button": "Start iFlow Login",
@@ -571,11 +582,15 @@
"auto_refresh_disabled": "Auto refresh disabled", "auto_refresh_disabled": "Auto refresh disabled",
"load_more_hint": "Scroll up to load more", "load_more_hint": "Scroll up to load more",
"hidden_lines": "Hidden: {{count}} lines", "hidden_lines": "Hidden: {{count}} lines",
"hide_management_logs": "Hide {{prefix}} logs",
"search_placeholder": "Search logs by content or keyword", "search_placeholder": "Search logs by content or keyword",
"search_empty_title": "No matching logs found", "search_empty_title": "No matching logs found",
"search_empty_desc": "Try a different keyword or clear the search filter.", "search_empty_desc": "Try a different keyword or clear the filters.",
"double_click_copy_hint": "Double-click to copy raw log line",
"copy_success": "Log copied to clipboard",
"copy_failed": "Copy failed",
"lines": "lines", "lines": "lines",
"removed": "Removed", "removed": "Filtered",
"upgrade_required_title": "Please Upgrade CLI Proxy API", "upgrade_required_title": "Please Upgrade CLI Proxy API",
"upgrade_required_desc": "The current server version does not support the logs viewing feature. Please upgrade to the latest version of CLI Proxy API to use this feature." "upgrade_required_desc": "The current server version does not support the logs viewing feature. Please upgrade to the latest version of CLI Proxy API to use this feature."
}, },

View File

@@ -424,9 +424,10 @@
"gemini_cli_oauth_title": "Gemini CLI OAuth", "gemini_cli_oauth_title": "Gemini CLI OAuth",
"gemini_cli_oauth_button": "开始 Gemini CLI 登录", "gemini_cli_oauth_button": "开始 Gemini CLI 登录",
"gemini_cli_oauth_hint": "通过 OAuth 流程登录 Google Gemini CLI 服务,自动获取并保存认证文件。", "gemini_cli_oauth_hint": "通过 OAuth 流程登录 Google Gemini CLI 服务,自动获取并保存认证文件。",
"gemini_cli_project_id_label": "Google Cloud 项目 ID (可选):", "gemini_cli_project_id_label": "Google Cloud 项目 ID:",
"gemini_cli_project_id_placeholder": "输入 Google Cloud 项目 ID (可选)", "gemini_cli_project_id_placeholder": "输入 Google Cloud 项目 ID",
"gemini_cli_project_id_hint": "如果指定了项目 ID将使用该项目的认证信息。", "gemini_cli_project_id_hint": "请填写项目 ID用于 Gemini CLI OAuth 登录。",
"gemini_cli_project_id_required": "请填写 Google Cloud 项目 ID。",
"gemini_cli_oauth_url_label": "授权链接:", "gemini_cli_oauth_url_label": "授权链接:",
"gemini_cli_open_link": "打开链接", "gemini_cli_open_link": "打开链接",
"gemini_cli_copy_link": "复制链接", "gemini_cli_copy_link": "复制链接",
@@ -446,6 +447,16 @@
"qwen_oauth_status_error": "认证失败:", "qwen_oauth_status_error": "认证失败:",
"qwen_oauth_start_error": "启动 Qwen OAuth 失败:", "qwen_oauth_start_error": "启动 Qwen OAuth 失败:",
"qwen_oauth_polling_error": "检查认证状态失败:", "qwen_oauth_polling_error": "检查认证状态失败:",
"oauth_callback_label": "回调 URL",
"oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...",
"oauth_callback_hint": "远程浏览器模式:当授权跳转到 http://localhost:... 后,复制完整 URL 并提交到这里。",
"oauth_callback_button": "提交回调 URL",
"oauth_callback_required": "请先粘贴完整的回调 URL。",
"oauth_callback_success": "回调 URL 已提交,请继续等待认证。",
"oauth_callback_error": "提交回调 URL 失败:",
"oauth_callback_upgrade_hint": "请更新CLI Proxy API或检查连接",
"oauth_callback_status_success": "回调 URL 已提交,等待认证中...",
"oauth_callback_status_error": "回调 URL 提交失败:",
"missing_state": "无法获取认证状态参数", "missing_state": "无法获取认证状态参数",
"iflow_oauth_title": "iFlow OAuth", "iflow_oauth_title": "iFlow OAuth",
"iflow_oauth_button": "开始 iFlow 登录", "iflow_oauth_button": "开始 iFlow 登录",
@@ -571,11 +582,15 @@
"auto_refresh_disabled": "自动刷新已关闭", "auto_refresh_disabled": "自动刷新已关闭",
"load_more_hint": "向上滚动加载更多", "load_more_hint": "向上滚动加载更多",
"hidden_lines": "已隐藏 {{count}} 行", "hidden_lines": "已隐藏 {{count}} 行",
"hide_management_logs": "屏蔽 {{prefix}} 日志",
"search_placeholder": "搜索日志内容或关键字", "search_placeholder": "搜索日志内容或关键字",
"search_empty_title": "未找到匹配的日志", "search_empty_title": "未找到匹配的日志",
"search_empty_desc": "尝试更换关键字或清空搜索条件。", "search_empty_desc": "尝试更换关键字或清空筛选条件。",
"double_click_copy_hint": "双击复制日志原文",
"copy_success": "已复制日志原文",
"copy_failed": "复制失败",
"lines": "行", "lines": "行",
"removed": "已删除", "removed": "已过滤",
"upgrade_required_title": "需要升级 CLI Proxy API", "upgrade_required_title": "需要升级 CLI Proxy API",
"upgrade_required_desc": "当前服务器版本不支持日志查看功能,请升级到最新版本的 CLI Proxy API 以使用此功能。" "upgrade_required_desc": "当前服务器版本不支持日志查看功能,请升级到最新版本的 CLI Proxy API 以使用此功能。"
}, },

View File

@@ -404,8 +404,17 @@
} }
.excludedModelTag { .excludedModelTag {
background: rgba(251, 191, 36, 0.2); background: rgba(251, 191, 36, 0.22);
border-color: rgba(251, 191, 36, 0.4); border-color: rgba(251, 191, 36, 0.55);
color: #fde68a;
.modelName {
color: #fde68a;
}
}
.excludedModelsLabel {
color: #fde68a;
} }
.apiKeyEntryCard { .apiKeyEntryCard {

View File

@@ -1897,19 +1897,21 @@ export function AiProvidersPage() {
keyPlaceholder={t('common.custom_headers_key_placeholder')} keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')} valuePlaceholder={t('common.custom_headers_value_placeholder')}
/> />
<div className="form-group"> {modal?.type === 'claude' && (
<label>{t('ai_providers.claude_models_label')}</label> <div className="form-group">
<ModelInputList <label>{t('ai_providers.claude_models_label')}</label>
entries={providerForm.modelEntries} <ModelInputList
onChange={(entries) => entries={providerForm.modelEntries}
setProviderForm((prev) => ({ ...prev, modelEntries: entries })) onChange={(entries) =>
} setProviderForm((prev) => ({ ...prev, modelEntries: entries }))
addLabel={t('ai_providers.claude_models_add_btn')} }
namePlaceholder={t('common.model_name_placeholder')} addLabel={t('ai_providers.claude_models_add_btn')}
aliasPlaceholder={t('common.model_alias_placeholder')} namePlaceholder={t('common.model_name_placeholder')}
disabled={saving} aliasPlaceholder={t('common.model_alias_placeholder')}
/> disabled={saving}
</div> />
</div>
)}
<div className="form-group"> <div className="form-group">
<label>{t('ai_providers.excluded_models_label')}</label> <label>{t('ai_providers.excluded_models_label')}</label>
<textarea <textarea

View File

@@ -28,6 +28,62 @@
} }
} }
.filters {
display: flex;
align-items: center;
gap: $spacing-md;
flex-wrap: wrap;
margin-bottom: $spacing-md;
:global(.form-group) {
margin: 0;
}
}
.searchWrapper {
flex: 1;
min-width: 220px;
max-width: 420px;
}
.searchInput {
padding-right: 44px !important;
}
.searchIcon {
color: var(--text-tertiary);
pointer-events: none;
}
.searchClear {
@include button-reset;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: $radius-full;
color: var(--text-secondary);
&:hover {
background: var(--bg-secondary);
}
}
.filterStats {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
}
.removedCount {
color: var(--text-tertiary);
}
.actionButton { .actionButton {
white-space: nowrap; white-space: nowrap;
} }
@@ -93,7 +149,9 @@
padding: 10px 12px; padding: 10px 12px;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
border-left: 3px solid transparent; border-left: 3px solid transparent;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', cursor: copy;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace; monospace;
font-size: 12.5px; font-size: 12.5px;
line-height: 1.45; line-height: 1.45;

View File

@@ -1,12 +1,22 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState'; import { EmptyState } from '@/components/ui/EmptyState';
import { Input } from '@/components/ui/Input';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconDownload, IconRefreshCw, IconTimer, IconTrash2 } from '@/components/ui/icons'; import {
IconDownload,
IconEyeOff,
IconRefreshCw,
IconSearch,
IconTimer,
IconTrash2,
IconX,
} from '@/components/ui/icons';
import { useNotificationStore, useAuthStore } from '@/stores'; import { useNotificationStore, useAuthStore } from '@/stores';
import { logsApi } from '@/services/api/logs'; import { logsApi } from '@/services/api/logs';
import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
import { formatUnixTimestamp } from '@/utils/format'; import { formatUnixTimestamp } from '@/utils/format';
import styles from './LogsPage.module.scss'; import styles from './LogsPage.module.scss';
@@ -40,13 +50,15 @@ const LOG_LATENCY_REGEX = /\b(\d+(?:\.\d+)?)(?:\s*)(µs|us|ms|s)\b/i;
const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/; const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;
const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i; const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i;
const LOG_TIME_OF_DAY_REGEX = /^\d{1,2}:\d{2}:\d{2}(?:\.\d{1,3})?$/; const LOG_TIME_OF_DAY_REGEX = /^\d{1,2}:\d{2}:\d{2}(?:\.\d{1,3})?$/;
const GIN_TIMESTAMP_SEGMENT_REGEX =
/^\[GIN\]\s+(\d{4})\/(\d{2})\/(\d{2})\s*-\s*(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s*$/;
const HTTP_STATUS_PATTERNS: RegExp[] = [ const HTTP_STATUS_PATTERNS: RegExp[] = [
/\|\s*([1-5]\d{2})\s*\|/, /\|\s*([1-5]\d{2})\s*\|/,
/\b([1-5]\d{2})\s*-/, /\b([1-5]\d{2})\s*-/,
new RegExp(`\\b(?:${HTTP_METHODS.join('|')})\\s+\\S+\\s+([1-5]\\d{2})\\b`), new RegExp(`\\b(?:${HTTP_METHODS.join('|')})\\s+\\S+\\s+([1-5]\\d{2})\\b`),
/\b(?:status|code|http)[:\s]+([1-5]\d{2})\b/i, /\b(?:status|code|http)[:\s]+([1-5]\d{2})\b/i,
/\b([1-5]\d{2})\s+(?:OK|Created|Accepted|No Content|Moved|Found|Bad Request|Unauthorized|Forbidden|Not Found|Method Not Allowed|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)\b/i /\b([1-5]\d{2})\s+(?:OK|Created|Accepted|No Content|Moved|Found|Bad Request|Unauthorized|Forbidden|Not Found|Method Not Allowed|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)\b/i,
]; ];
const detectHttpStatusCode = (text: string): number | undefined => { const detectHttpStatusCode = (text: string): number | undefined => {
@@ -78,6 +90,13 @@ const extractIp = (text: string): string | undefined => {
return candidate; return candidate;
}; };
const normalizeTimestampToSeconds = (value: string): string => {
const trimmed = value.trim();
const match = trimmed.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/);
if (!match) return trimmed;
return `${match[1]} ${match[2]}`;
};
type ParsedLogLine = { type ParsedLogLine = {
raw: string; raw: string;
timestamp?: string; timestamp?: string;
@@ -163,6 +182,23 @@ const parseLogLine = (raw: string): ParsedLogLine => {
.filter(Boolean); .filter(Boolean);
const consumed = new Set<number>(); const consumed = new Set<number>();
const ginIndex = segments.findIndex((segment) => GIN_TIMESTAMP_SEGMENT_REGEX.test(segment));
if (ginIndex >= 0) {
const match = segments[ginIndex].match(GIN_TIMESTAMP_SEGMENT_REGEX);
if (match) {
const ginTimestamp = `${match[1]}-${match[2]}-${match[3]} ${match[4]}`;
const normalizedGin = normalizeTimestampToSeconds(ginTimestamp);
const normalizedParsed = timestamp ? normalizeTimestampToSeconds(timestamp) : undefined;
if (!timestamp) {
timestamp = ginTimestamp;
consumed.add(ginIndex);
} else if (normalizedParsed === normalizedGin) {
consumed.add(ginIndex);
}
}
}
// status code // status code
const statusIndex = segments.findIndex((segment) => /^\d{3}\b/.test(segment)); const statusIndex = segments.findIndex((segment) => /^\d{3}\b/.test(segment));
if (statusIndex >= 0) { if (statusIndex >= 0) {
@@ -187,9 +223,7 @@ const parseLogLine = (raw: string): ParsedLogLine => {
} }
// ip // ip
const ipIndex = segments.findIndex( const ipIndex = segments.findIndex((segment) => Boolean(extractIp(segment)));
(segment) => Boolean(extractIp(segment))
);
if (ipIndex >= 0) { if (ipIndex >= 0) {
const extracted = extractIp(segments[ipIndex]); const extracted = extractIp(segments[ipIndex]);
if (extracted) { if (extracted) {
@@ -226,6 +260,17 @@ const parseLogLine = (raw: string): ParsedLogLine => {
if (!level) level = inferLogLevel(raw); if (!level) level = inferLogLevel(raw);
if (message) {
const match = message.match(GIN_TIMESTAMP_SEGMENT_REGEX);
if (match) {
const ginTimestamp = `${match[1]}-${match[2]}-${match[3]} ${match[4]}`;
if (!timestamp) timestamp = ginTimestamp;
if (normalizeTimestampToSeconds(timestamp) === normalizeTimestampToSeconds(ginTimestamp)) {
message = '';
}
}
}
return { return {
raw, raw,
timestamp, timestamp,
@@ -236,10 +281,44 @@ const parseLogLine = (raw: string): ParsedLogLine => {
ip, ip,
method, method,
path, path,
message message,
}; };
}; };
const getErrorMessage = (err: unknown): string => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
if (typeof err !== 'object' || err === null) return '';
if (!('message' in err)) return '';
const message = (err as { message?: unknown }).message;
return typeof message === 'string' ? message : '';
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
return ok;
} catch {
return false;
}
}
};
export function LogsPage() { export function LogsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification } = useNotificationStore();
@@ -249,6 +328,9 @@ export function LogsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [autoRefresh, setAutoRefresh] = useState(false); const [autoRefresh, setAutoRefresh] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const deferredSearchQuery = useDeferredValue(searchQuery);
const [hideManagementLogs, setHideManagementLogs] = useState(false);
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]); const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
const [loadingErrors, setLoadingErrors] = useState(false); const [loadingErrors, setLoadingErrors] = useState(false);
@@ -287,9 +369,8 @@ export function LogsPage() {
try { try {
pendingScrollToBottomRef.current = !incremental || isNearBottom(logViewerRef.current); pendingScrollToBottomRef.current = !incremental || isNearBottom(logViewerRef.current);
const params = incremental && latestTimestampRef.current > 0 const params =
? { after: latestTimestampRef.current } incremental && latestTimestampRef.current > 0 ? { after: latestTimestampRef.current } : {};
: {};
const data = await logsApi.fetchLogs(params); const data = await logsApi.fetchLogs(params);
// 更新时间戳 // 更新时间戳
@@ -321,10 +402,10 @@ export function LogsPage() {
const visibleFrom = Math.max(buffer.length - INITIAL_DISPLAY_LINES, 0); const visibleFrom = Math.max(buffer.length - INITIAL_DISPLAY_LINES, 0);
setLogState({ buffer, visibleFrom }); setLogState({ buffer, visibleFrom });
} }
} catch (err: any) { } catch (err: unknown) {
console.error('Failed to load logs:', err); console.error('Failed to load logs:', err);
if (!incremental) { if (!incremental) {
setError(err?.message || t('logs.load_error')); setError(getErrorMessage(err) || t('logs.load_error'));
} }
} finally { } finally {
if (!incremental) { if (!incremental) {
@@ -340,8 +421,12 @@ export function LogsPage() {
setLogState({ buffer: [], visibleFrom: 0 }); setLogState({ buffer: [], visibleFrom: 0 });
latestTimestampRef.current = 0; latestTimestampRef.current = 0;
showNotification(t('logs.clear_success'), 'success'); showNotification(t('logs.clear_success'), 'success');
} catch (err: any) { } catch (err: unknown) {
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error'); const message = getErrorMessage(err);
showNotification(
`${t('notification.delete_failed')}${message ? `: ${message}` : ''}`,
'error'
);
} }
}; };
@@ -367,16 +452,8 @@ export function LogsPage() {
try { try {
const res = await logsApi.fetchErrorLogs(); const res = await logsApi.fetchErrorLogs();
// API 返回 { files: [...] } // API 返回 { files: [...] }
const files = (res as any)?.files; setErrorLogs(Array.isArray(res.files) ? res.files : []);
const list: ErrorLogItem[] = Array.isArray(files) } catch (err: unknown) {
? files.map((f: any) => ({
name: f.name,
size: f.size,
modified: f.modified
}))
: [];
setErrorLogs(list);
} catch (err: any) {
console.error('Failed to load error logs:', err); console.error('Failed to load error logs:', err);
// 静默失败,不影响主日志显示 // 静默失败,不影响主日志显示
setErrorLogs([]); setErrorLogs([]);
@@ -396,8 +473,12 @@ export function LogsPage() {
a.click(); a.click();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
showNotification(t('logs.error_log_download_success'), 'success'); showNotification(t('logs.error_log_download_success'), 'success');
} catch (err: any) { } catch (err: unknown) {
showNotification(`${t('notification.download_failed')}: ${err?.message || ''}`, 'error'); const message = getErrorMessage(err);
showNotification(
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
'error'
);
} }
}; };
@@ -434,23 +515,65 @@ export function LogsPage() {
() => logState.buffer.slice(logState.visibleFrom), () => logState.buffer.slice(logState.visibleFrom),
[logState.buffer, logState.visibleFrom] [logState.buffer, logState.visibleFrom]
); );
const trimmedSearchQuery = deferredSearchQuery.trim();
const isSearching = trimmedSearchQuery.length > 0;
const baseLines = isSearching ? logState.buffer : visibleLines;
const { filteredLines, removedCount } = useMemo(() => {
let working = baseLines;
let removed = 0;
if (hideManagementLogs) {
const next: string[] = [];
for (const line of working) {
if (line.includes(MANAGEMENT_API_PREFIX)) {
removed += 1;
} else {
next.push(line);
}
}
working = next;
}
if (trimmedSearchQuery) {
const queryLowered = trimmedSearchQuery.toLowerCase();
const next: string[] = [];
for (const line of working) {
if (line.toLowerCase().includes(queryLowered)) {
next.push(line);
} else {
removed += 1;
}
}
working = next;
}
return { filteredLines: working, removedCount: removed };
}, [baseLines, hideManagementLogs, trimmedSearchQuery]);
const parsedVisibleLines = useMemo( const parsedVisibleLines = useMemo(
() => visibleLines.map((line) => parseLogLine(line)), () => filteredLines.map((line) => parseLogLine(line)),
[visibleLines] [filteredLines]
); );
const canLoadMore = logState.visibleFrom > 0;
const canLoadMore = !isSearching && logState.visibleFrom > 0;
const handleLogScroll = () => { const handleLogScroll = () => {
const node = logViewerRef.current; const node = logViewerRef.current;
if (!node) return; if (!node) return;
if (isSearching) return;
if (!canLoadMore) return; if (!canLoadMore) return;
if (pendingPrependScrollRef.current) return; if (pendingPrependScrollRef.current) return;
if (node.scrollTop > LOAD_MORE_THRESHOLD_PX) return; if (node.scrollTop > LOAD_MORE_THRESHOLD_PX) return;
pendingPrependScrollRef.current = { scrollHeight: node.scrollHeight, scrollTop: node.scrollTop }; pendingPrependScrollRef.current = {
scrollHeight: node.scrollHeight,
scrollTop: node.scrollTop,
};
setLogState((prev) => ({ setLogState((prev) => ({
...prev, ...prev,
visibleFrom: Math.max(prev.visibleFrom - LOAD_MORE_LINES, 0) visibleFrom: Math.max(prev.visibleFrom - LOAD_MORE_LINES, 0),
})); }));
}; };
@@ -464,185 +587,264 @@ export function LogsPage() {
pendingPrependScrollRef.current = null; pendingPrependScrollRef.current = null;
}, [logState.visibleFrom]); }, [logState.visibleFrom]);
const copyLogLine = async (raw: string) => {
const ok = await copyToClipboard(raw);
if (ok) {
showNotification(t('logs.copy_success', { defaultValue: 'Copied to clipboard' }), 'success');
} else {
showNotification(t('logs.copy_failed', { defaultValue: 'Copy failed' }), 'error');
}
};
return ( return (
<div className={styles.container}> <div className={styles.container}>
<h1 className={styles.pageTitle}>{t('logs.title')}</h1> <h1 className={styles.pageTitle}>{t('logs.title')}</h1>
<div className={styles.content}> <div className={styles.content}>
<Card <Card
title={t('logs.log_content')} title={t('logs.log_content')}
extra={ extra={
<div className={styles.toolbar}> <div className={styles.toolbar}>
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={() => loadLogs(false)} onClick={() => loadLogs(false)}
disabled={disableControls || loading} disabled={disableControls || loading}
className={styles.actionButton} className={styles.actionButton}
> >
<span className={styles.buttonContent}> <span className={styles.buttonContent}>
<IconRefreshCw size={16} /> <IconRefreshCw size={16} />
{t('logs.refresh_button')} {t('logs.refresh_button')}
</span> </span>
</Button> </Button>
<ToggleSwitch
checked={autoRefresh}
onChange={(value) => setAutoRefresh(value)}
disabled={disableControls}
label={
<span className={styles.switchLabel}>
<IconTimer size={16} />
{t('logs.auto_refresh')}
</span>
}
/>
<Button
variant="secondary"
size="sm"
onClick={downloadLogs}
disabled={logState.buffer.length === 0}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconDownload size={16} />
{t('logs.download_button')}
</span>
</Button>
<Button
variant="danger"
size="sm"
onClick={clearLogs}
disabled={disableControls}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconTrash2 size={16} />
{t('logs.clear_button')}
</span>
</Button>
</div>
}
>
{error && <div className="error-box">{error}</div>}
<div className={styles.filters}>
<div className={styles.searchWrapper}>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('logs.search_placeholder')}
className={styles.searchInput}
rightElement={
searchQuery ? (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearchQuery('')}
title="Clear"
aria-label="Clear"
>
<IconX size={16} />
</button>
) : (
<IconSearch size={16} className={styles.searchIcon} />
)
}
/>
</div>
<ToggleSwitch <ToggleSwitch
checked={autoRefresh} checked={hideManagementLogs}
onChange={(value) => setAutoRefresh(value)} onChange={setHideManagementLogs}
disabled={disableControls}
label={ label={
<span className={styles.switchLabel}> <span className={styles.switchLabel}>
<IconTimer size={16} /> <IconEyeOff size={16} />
{t('logs.auto_refresh')} {t('logs.hide_management_logs', { prefix: MANAGEMENT_API_PREFIX })}
</span> </span>
} }
/> />
<Button
variant="secondary" <div className={styles.filterStats}>
size="sm" <span>
onClick={downloadLogs} {parsedVisibleLines.length} {t('logs.lines')}
disabled={logState.buffer.length === 0}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconDownload size={16} />
{t('logs.download_button')}
</span> </span>
</Button> {removedCount > 0 && (
<Button <span className={styles.removedCount}>
variant="danger" {t('logs.removed')} {removedCount}
size="sm"
onClick={clearLogs}
disabled={disableControls}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconTrash2 size={16} />
{t('logs.clear_button')}
</span>
</Button>
</div>
}
>
{error && <div className="error-box">{error}</div>}
{loading ? (
<div className="hint">{t('logs.loading')}</div>
) : logState.buffer.length > 0 ? (
<div ref={logViewerRef} className={styles.logPanel} onScroll={handleLogScroll}>
{canLoadMore && (
<div className={styles.loadMoreBanner}>
<span>{t('logs.load_more_hint')}</span>
<span className={styles.loadMoreCount}>
{t('logs.hidden_lines', { count: logState.visibleFrom })}
</span> </span>
</div> )}
)}
<div className={styles.logList}>
{parsedVisibleLines.map((line, index) => {
const rowClassNames = [styles.logRow];
if (line.level === 'warn') rowClassNames.push(styles.rowWarn);
if (line.level === 'error' || line.level === 'fatal') rowClassNames.push(styles.rowError);
return (
<div key={`${logState.visibleFrom + index}-${line.raw}`} className={rowClassNames.join(' ')}>
<div className={styles.timestamp}>{line.timestamp || ''}</div>
<div className={styles.rowMain}>
<div className={styles.rowMeta}>
{line.level && (
<span
className={[
styles.badge,
line.level === 'info' ? styles.levelInfo : '',
line.level === 'warn' ? styles.levelWarn : '',
line.level === 'error' || line.level === 'fatal' ? styles.levelError : '',
line.level === 'debug' ? styles.levelDebug : '',
line.level === 'trace' ? styles.levelTrace : ''
]
.filter(Boolean)
.join(' ')}
>
{line.level.toUpperCase()}
</span>
)}
{line.source && (
<span className={styles.source} title={line.source}>
{line.source}
</span>
)}
{typeof line.statusCode === 'number' && (
<span
className={[
styles.badge,
styles.statusBadge,
line.statusCode >= 200 && line.statusCode < 300
? styles.statusSuccess
: line.statusCode >= 300 && line.statusCode < 400
? styles.statusInfo
: line.statusCode >= 400 && line.statusCode < 500
? styles.statusWarn
: styles.statusError
].join(' ')}
>
{line.statusCode}
</span>
)}
{line.latency && <span className={styles.pill}>{line.latency}</span>}
{line.ip && <span className={styles.pill}>{line.ip}</span>}
{line.method && (
<span className={[styles.badge, styles.methodBadge].join(' ')}>
{line.method}
</span>
)}
{line.path && (
<span className={styles.path} title={line.path}>
{line.path}
</span>
)}
</div>
{line.message && <div className={styles.message}>{line.message}</div>}
</div>
</div>
);
})}
</div> </div>
</div> </div>
) : (
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
)}
</Card>
<Card {loading ? (
title={t('logs.error_logs_modal_title')} <div className="hint">{t('logs.loading')}</div>
extra={ ) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (
<Button variant="secondary" size="sm" onClick={loadErrorLogs} loading={loadingErrors}> <div ref={logViewerRef} className={styles.logPanel} onScroll={handleLogScroll}>
{t('common.refresh')} {canLoadMore && (
</Button> <div className={styles.loadMoreBanner}>
} <span>{t('logs.load_more_hint')}</span>
> <span className={styles.loadMoreCount}>
{errorLogs.length === 0 ? ( {t('logs.hidden_lines', { count: logState.visibleFrom })}
<div className="hint">{t('logs.error_logs_empty')}</div> </span>
) : ( </div>
<div className="item-list"> )}
{errorLogs.map((item) => ( <div className={styles.logList}>
<div key={item.name} className="item-row"> {parsedVisibleLines.map((line, index) => {
<div className="item-meta"> const rowClassNames = [styles.logRow];
<div className="item-title">{item.name}</div> if (line.level === 'warn') rowClassNames.push(styles.rowWarn);
<div className="item-subtitle"> if (line.level === 'error' || line.level === 'fatal')
{item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''}{' '} rowClassNames.push(styles.rowError);
{item.modified ? formatUnixTimestamp(item.modified) : ''} return (
<div
key={`${logState.visibleFrom + index}-${line.raw}`}
className={rowClassNames.join(' ')}
onDoubleClick={() => {
void copyLogLine(line.raw);
}}
title={t('logs.double_click_copy_hint', {
defaultValue: 'Double-click to copy',
})}
>
<div className={styles.timestamp}>{line.timestamp || ''}</div>
<div className={styles.rowMain}>
<div className={styles.rowMeta}>
{line.level && (
<span
className={[
styles.badge,
line.level === 'info' ? styles.levelInfo : '',
line.level === 'warn' ? styles.levelWarn : '',
line.level === 'error' || line.level === 'fatal'
? styles.levelError
: '',
line.level === 'debug' ? styles.levelDebug : '',
line.level === 'trace' ? styles.levelTrace : '',
]
.filter(Boolean)
.join(' ')}
>
{line.level.toUpperCase()}
</span>
)}
{line.source && (
<span className={styles.source} title={line.source}>
{line.source}
</span>
)}
{typeof line.statusCode === 'number' && (
<span
className={[
styles.badge,
styles.statusBadge,
line.statusCode >= 200 && line.statusCode < 300
? styles.statusSuccess
: line.statusCode >= 300 && line.statusCode < 400
? styles.statusInfo
: line.statusCode >= 400 && line.statusCode < 500
? styles.statusWarn
: styles.statusError,
].join(' ')}
>
{line.statusCode}
</span>
)}
{line.latency && <span className={styles.pill}>{line.latency}</span>}
{line.ip && <span className={styles.pill}>{line.ip}</span>}
{line.method && (
<span className={[styles.badge, styles.methodBadge].join(' ')}>
{line.method}
</span>
)}
{line.path && (
<span className={styles.path} title={line.path}>
{line.path}
</span>
)}
</div>
{line.message && <div className={styles.message}>{line.message}</div>}
</div>
</div>
);
})}
</div>
</div>
) : logState.buffer.length > 0 ? (
<EmptyState
title={t('logs.search_empty_title')}
description={t('logs.search_empty_desc')}
/>
) : (
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
)}
</Card>
<Card
title={t('logs.error_logs_modal_title')}
extra={
<Button variant="secondary" size="sm" onClick={loadErrorLogs} loading={loadingErrors}>
{t('common.refresh')}
</Button>
}
>
{errorLogs.length === 0 ? (
<div className="hint">{t('logs.error_logs_empty')}</div>
) : (
<div className="item-list">
{errorLogs.map((item) => (
<div key={item.name} className="item-row">
<div className="item-meta">
<div className="item-title">{item.name}</div>
<div className="item-subtitle">
{item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''}{' '}
{item.modified ? formatUnixTimestamp(item.modified) : ''}
</div>
</div>
<div className="item-actions">
<Button
variant="secondary"
size="sm"
onClick={() => downloadErrorLog(item.name)}
>
{t('logs.error_logs_download')}
</Button>
</div> </div>
</div> </div>
<div className="item-actions"> ))}
<Button variant="secondary" size="sm" onClick={() => downloadErrorLog(item.name)}> </div>
{t('logs.error_logs_download')} )}
</Button> </Card>
</div>
</div>
))}
</div>
)}
</Card>
</div> </div>
</div> </div>
); );

View File

@@ -59,3 +59,47 @@
color: #3b82f6; color: #3b82f6;
} }
} }
.callbackSection {
margin-top: $spacing-md;
display: flex;
flex-direction: column;
gap: $spacing-xs;
}
.callbackActions {
display: flex;
gap: $spacing-md;
}
.authUrlBox {
background: var(--bg-secondary);
border: 1px dashed var(--border-color);
border-radius: $radius-md;
padding: $spacing-md;
display: flex;
flex-direction: column;
gap: $spacing-xs;
}
.authUrlLabel {
color: var(--text-secondary);
font-size: 14px;
}
.authUrlValue {
font-weight: 700;
color: var(--text-primary);
word-break: break-all;
overflow-wrap: anywhere;
line-height: 1.5;
max-width: 100%;
}
.authUrlActions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $spacing-sm;
margin-top: $spacing-sm;
}

View File

@@ -1,11 +1,10 @@
import { useEffect, useRef, useState, useMemo } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { useNotificationStore } from '@/stores'; import { useNotificationStore } from '@/stores';
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth'; import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
import { isLocalhost } from '@/utils/connection';
import styles from './OAuthPage.module.scss'; import styles from './OAuthPage.module.scss';
interface ProviderState { interface ProviderState {
@@ -14,6 +13,12 @@ interface ProviderState {
status?: 'idle' | 'waiting' | 'success' | 'error'; status?: 'idle' | 'waiting' | 'success' | 'error';
error?: string; error?: string;
polling?: boolean; polling?: boolean;
projectId?: string;
projectIdError?: string;
callbackUrl?: string;
callbackSubmitting?: boolean;
callbackStatus?: 'success' | 'error';
callbackError?: string;
} }
interface IFlowCookieState { interface IFlowCookieState {
@@ -33,6 +38,8 @@ const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabe
{ id: 'iflow', titleKey: 'auth_login.iflow_oauth_title', hintKey: 'auth_login.iflow_oauth_hint', urlLabelKey: 'auth_login.iflow_oauth_url_label' } { id: 'iflow', titleKey: 'auth_login.iflow_oauth_title', hintKey: 'auth_login.iflow_oauth_hint', urlLabelKey: 'auth_login.iflow_oauth_url_label' }
]; ];
const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli', 'iflow'];
export function OAuthPage() { export function OAuthPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification } = useNotificationStore();
@@ -40,15 +47,19 @@ export function OAuthPage() {
const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false }); const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false });
const timers = useRef<Record<string, number>>({}); const timers = useRef<Record<string, number>>({});
// 检测是否为本地访问
const isLocal = useMemo(() => isLocalhost(window.location.hostname), []);
useEffect(() => { useEffect(() => {
return () => { return () => {
Object.values(timers.current).forEach((timer) => window.clearInterval(timer)); Object.values(timers.current).forEach((timer) => window.clearInterval(timer));
}; };
}, []); }, []);
const updateProviderState = (provider: OAuthProvider, next: Partial<ProviderState>) => {
setStates((prev) => ({
...prev,
[provider]: { ...(prev[provider] ?? {}), ...next }
}));
};
const startPolling = (provider: OAuthProvider, state: string) => { const startPolling = (provider: OAuthProvider, state: string) => {
if (timers.current[provider]) { if (timers.current[provider]) {
clearInterval(timers.current[provider]); clearInterval(timers.current[provider]);
@@ -57,27 +68,18 @@ export function OAuthPage() {
try { try {
const res = await oauthApi.getAuthStatus(state); const res = await oauthApi.getAuthStatus(state);
if (res.status === 'ok') { if (res.status === 'ok') {
setStates((prev) => ({ updateProviderState(provider, { status: 'success', polling: false });
...prev,
[provider]: { ...prev[provider], status: 'success', polling: false }
}));
showNotification(t('auth_login.codex_oauth_status_success'), 'success'); showNotification(t('auth_login.codex_oauth_status_success'), 'success');
window.clearInterval(timer); window.clearInterval(timer);
delete timers.current[provider]; delete timers.current[provider];
} else if (res.status === 'error') { } else if (res.status === 'error') {
setStates((prev) => ({ updateProviderState(provider, { status: 'error', error: res.error, polling: false });
...prev,
[provider]: { ...prev[provider], status: 'error', error: res.error, polling: false }
}));
showNotification(`${t('auth_login.codex_oauth_status_error')} ${res.error || ''}`, 'error'); showNotification(`${t('auth_login.codex_oauth_status_error')} ${res.error || ''}`, 'error');
window.clearInterval(timer); window.clearInterval(timer);
delete timers.current[provider]; delete timers.current[provider];
} }
} catch (err: any) { } catch (err: any) {
setStates((prev) => ({ updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
...prev,
[provider]: { ...prev[provider], status: 'error', error: err?.message, polling: false }
}));
window.clearInterval(timer); window.clearInterval(timer);
delete timers.current[provider]; delete timers.current[provider];
} }
@@ -86,24 +88,35 @@ export function OAuthPage() {
}; };
const startAuth = async (provider: OAuthProvider) => { const startAuth = async (provider: OAuthProvider) => {
setStates((prev) => ({ const projectId = provider === 'gemini-cli' ? (states[provider]?.projectId || '').trim() : undefined;
...prev, if (provider === 'gemini-cli' && !projectId) {
[provider]: { ...prev[provider], status: 'waiting', polling: true, error: undefined } const message = t('auth_login.gemini_cli_project_id_required');
})); updateProviderState(provider, { projectIdError: message });
showNotification(message, 'warning');
return;
}
if (provider === 'gemini-cli') {
updateProviderState(provider, { projectIdError: undefined });
}
updateProviderState(provider, {
status: 'waiting',
polling: true,
error: undefined,
callbackStatus: undefined,
callbackError: undefined,
callbackUrl: ''
});
try { try {
const res = await oauthApi.startAuth(provider); const res = await oauthApi.startAuth(
setStates((prev) => ({ provider,
...prev, provider === 'gemini-cli' ? { projectId: projectId! } : undefined
[provider]: { ...prev[provider], url: res.url, state: res.state, status: 'waiting', polling: true } );
})); updateProviderState(provider, { url: res.url, state: res.state, status: 'waiting', polling: true });
if (res.state) { if (res.state) {
startPolling(provider, res.state); startPolling(provider, res.state);
} }
} catch (err: any) { } catch (err: any) {
setStates((prev) => ({ updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
...prev,
[provider]: { ...prev[provider], status: 'error', error: err?.message, polling: false }
}));
showNotification(`${t('auth_login.codex_oauth_start_error')} ${err?.message || ''}`, 'error'); showNotification(`${t('auth_login.codex_oauth_start_error')} ${err?.message || ''}`, 'error');
} }
}; };
@@ -118,6 +131,40 @@ export function OAuthPage() {
} }
}; };
const submitCallback = async (provider: OAuthProvider) => {
const redirectUrl = (states[provider]?.callbackUrl || '').trim();
if (!redirectUrl) {
showNotification(t('auth_login.oauth_callback_required'), 'warning');
return;
}
updateProviderState(provider, {
callbackSubmitting: true,
callbackStatus: undefined,
callbackError: undefined
});
try {
await oauthApi.submitCallback(provider, redirectUrl);
updateProviderState(provider, { callbackSubmitting: false, callbackStatus: 'success' });
showNotification(t('auth_login.oauth_callback_success'), 'success');
} catch (err: any) {
const errorMessage =
err?.status === 404
? t('auth_login.oauth_callback_upgrade_hint', {
defaultValue: 'Please update CLI Proxy API or check the connection.'
})
: err?.message;
updateProviderState(provider, {
callbackSubmitting: false,
callbackStatus: 'error',
callbackError: errorMessage
});
const notificationMessage = errorMessage
? `${t('auth_login.oauth_callback_error')} ${errorMessage}`
: t('auth_login.oauth_callback_error');
showNotification(notificationMessage, 'error');
}
};
const submitIflowCookie = async () => { const submitIflowCookie = async () => {
const cookie = iflowCookie.cookie.trim(); const cookie = iflowCookie.cookie.trim();
if (!cookie) { if (!cookie) {
@@ -164,36 +211,38 @@ export function OAuthPage() {
<div className={styles.content}> <div className={styles.content}>
{PROVIDERS.map((provider) => { {PROVIDERS.map((provider) => {
const state = states[provider.id] || {}; const state = states[provider.id] || {};
// 非本地访问时禁用所有 OAuth 登录方式 const canSubmitCallback = CALLBACK_SUPPORTED.includes(provider.id) && Boolean(state.url);
const isDisabled = !isLocal;
return ( return (
<div <div key={provider.id}>
key={provider.id}
style={isDisabled ? { opacity: 0.6, pointerEvents: 'none' } : undefined}
>
<Card <Card
title={t(provider.titleKey)} title={t(provider.titleKey)}
extra={ extra={
<Button <Button onClick={() => startAuth(provider.id)} loading={state.polling}>
onClick={() => startAuth(provider.id)}
loading={state.polling}
disabled={isDisabled}
>
{t('common.login')} {t('common.login')}
</Button> </Button>
} }
> >
<div className="hint">{t(provider.hintKey)}</div> <div className="hint">{t(provider.hintKey)}</div>
{isDisabled && ( {provider.id === 'gemini-cli' && (
<div className="status-badge warning" style={{ marginTop: 8 }}> <Input
{t('auth_login.remote_access_disabled')} label={t('auth_login.gemini_cli_project_id_label')}
</div> hint={t('auth_login.gemini_cli_project_id_hint')}
value={state.projectId || ''}
error={state.projectIdError}
onChange={(e) =>
updateProviderState(provider.id, {
projectId: e.target.value,
projectIdError: undefined
})
}
placeholder={t('auth_login.gemini_cli_project_id_placeholder')}
/>
)} )}
{!isDisabled && state.url && ( {state.url && (
<div className="connection-box"> <div className={`connection-box ${styles.authUrlBox}`}>
<div className="label">{t(provider.urlLabelKey)}</div> <div className={styles.authUrlLabel}>{t(provider.urlLabelKey)}</div>
<div className="value">{state.url}</div> <div className={styles.authUrlValue}>{state.url}</div>
<div className="item-actions" style={{ marginTop: 8 }}> <div className={styles.authUrlActions}>
<Button variant="secondary" size="sm" onClick={() => copyLink(state.url!)}> <Button variant="secondary" size="sm" onClick={() => copyLink(state.url!)}>
{t('auth_login.codex_copy_link')} {t('auth_login.codex_copy_link')}
</Button> </Button>
@@ -207,7 +256,44 @@ export function OAuthPage() {
</div> </div>
</div> </div>
)} )}
{!isDisabled && state.status && state.status !== 'idle' && ( {canSubmitCallback && (
<div className={styles.callbackSection}>
<Input
label={t('auth_login.oauth_callback_label')}
hint={t('auth_login.oauth_callback_hint')}
value={state.callbackUrl || ''}
onChange={(e) =>
updateProviderState(provider.id, {
callbackUrl: e.target.value,
callbackStatus: undefined,
callbackError: undefined
})
}
placeholder={t('auth_login.oauth_callback_placeholder')}
/>
<div className={styles.callbackActions}>
<Button
variant="secondary"
size="sm"
onClick={() => submitCallback(provider.id)}
loading={state.callbackSubmitting}
>
{t('auth_login.oauth_callback_button')}
</Button>
</div>
{state.callbackStatus === 'success' && (
<div className="status-badge success" style={{ marginTop: 8 }}>
{t('auth_login.oauth_callback_status_success')}
</div>
)}
{state.callbackStatus === 'error' && (
<div className="status-badge error" style={{ marginTop: 8 }}>
{t('auth_login.oauth_callback_status_error')} {state.callbackError || ''}
</div>
)}
</div>
)}
{state.status && state.status !== 'idle' && (
<div className="status-badge" style={{ marginTop: 8 }}> <div className="status-badge" style={{ marginTop: 8 }}>
{state.status === 'success' {state.status === 'success'
? t('auth_login.codex_oauth_status_success') ? t('auth_login.codex_oauth_status_success')

View File

@@ -3,9 +3,11 @@
.container { .container {
width: 100%; width: 100%;
min-height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 20px;
position: relative;
} }
.header { .header {
@@ -39,6 +41,44 @@
padding: 16px; padding: 16px;
} }
.loadingOverlay {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
background: rgba(243, 244, 246, 0.75);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
:global([data-theme='dark']) .loadingOverlay {
background: rgba(25, 25, 25, 0.72);
}
.loadingOverlayContent {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: $radius-lg;
border: 1px solid var(--border-color);
background: var(--bg-primary);
box-shadow: var(--shadow-lg);
:global(.loading-spinner) {
border-color: rgba(59, 130, 246, 0.25);
border-top-color: var(--primary-color);
}
}
.loadingOverlayText {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
// Stats Grid // Stats Grid
.statsGrid { .statsGrid {
display: grid; display: grid;

View File

@@ -16,6 +16,7 @@ import { Line } from 'react-chartjs-2';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons'; import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useThemeStore } from '@/stores'; import { useThemeStore } from '@/stores';
@@ -516,6 +517,14 @@ export function UsagePage() {
return ( return (
<div className={styles.container}> <div className={styles.container}>
{loading && !usage && (
<div className={styles.loadingOverlay} aria-busy="true">
<div className={styles.loadingOverlayContent}>
<LoadingSpinner size={28} />
<span className={styles.loadingOverlayText}>{t('common.loading')}</span>
</div>
</div>
)}
<div className={styles.header}> <div className={styles.header}>
<h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1> <h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1>
<Button <Button

View File

@@ -14,16 +14,25 @@ export interface LogsResponse {
'latest-timestamp': number; 'latest-timestamp': number;
} }
export interface ErrorLogFile {
name: string;
size?: number;
modified?: number;
}
export interface ErrorLogsResponse {
files?: ErrorLogFile[];
}
export const logsApi = { export const logsApi = {
fetchLogs: (params: LogsQuery = {}): Promise<LogsResponse> => fetchLogs: (params: LogsQuery = {}): Promise<LogsResponse> => apiClient.get('/logs', { params }),
apiClient.get('/logs', { params }),
clearLogs: () => apiClient.delete('/logs'), clearLogs: () => apiClient.delete('/logs'),
fetchErrorLogs: () => apiClient.get('/request-error-logs'), fetchErrorLogs: (): Promise<ErrorLogsResponse> => apiClient.get('/request-error-logs'),
downloadErrorLog: (filename: string) => downloadErrorLog: (filename: string) =>
apiClient.getRaw(`/request-error-logs/${encodeURIComponent(filename)}`, { apiClient.getRaw(`/request-error-logs/${encodeURIComponent(filename)}`, {
responseType: 'blob' responseType: 'blob',
}) }),
}; };

View File

@@ -17,6 +17,10 @@ export interface OAuthStartResponse {
state?: string; state?: string;
} }
export interface OAuthCallbackResponse {
status: 'ok';
}
export interface IFlowCookieAuthResponse { export interface IFlowCookieAuthResponse {
status: 'ok' | 'error'; status: 'ok' | 'error';
error?: string; error?: string;
@@ -27,18 +31,37 @@ export interface IFlowCookieAuthResponse {
} }
const WEBUI_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli', 'iflow']; const WEBUI_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli', 'iflow'];
const CALLBACK_PROVIDER_MAP: Partial<Record<OAuthProvider, string>> = {
'gemini-cli': 'gemini'
};
export const oauthApi = { export const oauthApi = {
startAuth: (provider: OAuthProvider) => startAuth: (provider: OAuthProvider, options?: { projectId?: string }) => {
apiClient.get<OAuthStartResponse>(`/${provider}-auth-url`, { const params: Record<string, string | boolean> = {};
params: WEBUI_SUPPORTED.includes(provider) ? { is_webui: true } : undefined if (WEBUI_SUPPORTED.includes(provider)) {
}), params.is_webui = true;
}
if (provider === 'gemini-cli' && options?.projectId) {
params.project_id = options.projectId;
}
return apiClient.get<OAuthStartResponse>(`/${provider}-auth-url`, {
params: Object.keys(params).length ? params : undefined
});
},
getAuthStatus: (state: string) => getAuthStatus: (state: string) =>
apiClient.get<{ status: 'ok' | 'wait' | 'error'; error?: string }>(`/get-auth-status`, { apiClient.get<{ status: 'ok' | 'wait' | 'error'; error?: string }>(`/get-auth-status`, {
params: { state } params: { state }
}), }),
submitCallback: (provider: OAuthProvider, redirectUrl: string) => {
const callbackProvider = CALLBACK_PROVIDER_MAP[provider] ?? provider;
return apiClient.post<OAuthCallbackResponse>('/oauth-callback', {
provider: callbackProvider,
redirect_url: redirectUrl
});
},
/** iFlow cookie 认证 */ /** iFlow cookie 认证 */
iflowCookieAuth: (cookie: string) => iflowCookieAuth: (cookie: string) =>
apiClient.post<IFlowCookieAuthResponse>('/iflow-auth-url', { cookie }) apiClient.post<IFlowCookieAuthResponse>('/iflow-auth-url', { cookie })

View File

@@ -5,11 +5,13 @@
import { apiClient } from './client'; import { apiClient } from './client';
import { computeKeyStats, KeyStats } from '@/utils/usage'; import { computeKeyStats, KeyStats } from '@/utils/usage';
const USAGE_TIMEOUT_MS = 60 * 1000;
export const usageApi = { export const usageApi = {
/** /**
* 获取使用统计原始数据 * 获取使用统计原始数据
*/ */
getUsage: () => apiClient.get('/usage'), getUsage: () => apiClient.get('/usage', { timeout: USAGE_TIMEOUT_MS }),
/** /**
* 计算密钥成功/失败统计,必要时会先获取 usage 数据 * 计算密钥成功/失败统计,必要时会先获取 usage 数据
@@ -17,7 +19,7 @@ export const usageApi = {
async getKeyStats(usageData?: any): Promise<KeyStats> { async getKeyStats(usageData?: any): Promise<KeyStats> {
let payload = usageData; let payload = usageData;
if (!payload) { if (!payload) {
const response = await apiClient.get('/usage'); const response = await apiClient.get('/usage', { timeout: USAGE_TIMEOUT_MS });
payload = response?.usage ?? response; payload = response?.usage ?? response;
} }
return computeKeyStats(payload); return computeKeyStats(payload);