mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 03:10:50 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ca6d31a26 | ||
|
|
66c6073bbc | ||
|
|
2dd3f233d3 | ||
|
|
7a65e03ad3 |
1
src/assets/icons/vertex.svg
Normal file
1
src/assets/icons/vertex.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24px" height="24px"><path d="M20,13.89A.77.77,0,0,0,19,13.73l-7,5.14v.22a.72.72,0,1,1,0,1.43v0a.74.74,0,0,0,.45-.15l7.41-5.47A.76.76,0,0,0,20,13.89Z" style="fill:#669df6"/><path d="M12,20.52a.72.72,0,0,1,0-1.43h0v-.22L5,13.73a.76.76,0,0,0-1,.16.74.74,0,0,0,.16,1l7.41,5.47a.73.73,0,0,0,.44.15v0Z" style="fill:#aecbfa"/><path d="M12,18.34a1.47,1.47,0,1,0,1.47,1.47A1.47,1.47,0,0,0,12,18.34Zm0,2.18a.72.72,0,1,1,.72-.71A.71.71,0,0,1,12,20.52Z" style="fill:#4285f4"/><path d="M6,6.11a.76.76,0,0,1-.75-.75V3.48a.76.76,0,1,1,1.51,0V5.36A.76.76,0,0,1,6,6.11Z" style="fill:#aecbfa"/><circle cx="5.98" cy="12" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="9.79" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="7.57" r="0.76" style="fill:#aecbfa"/><path d="M18,8.31a.76.76,0,0,1-.75-.76V5.67a.75.75,0,1,1,1.5,0V7.55A.75.75,0,0,1,18,8.31Z" style="fill:#4285f4"/><circle cx="18.02" cy="12.01" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="9.76" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="3.48" r="0.76" style="fill:#4285f4"/><path d="M12,15a.76.76,0,0,1-.75-.75V12.34a.76.76,0,0,1,1.51,0v1.89A.76.76,0,0,1,12,15Z" style="fill:#669df6"/><circle cx="12" cy="16.45" r="0.76" style="fill:#669df6"/><circle cx="12" cy="10.14" r="0.76" style="fill:#669df6"/><circle cx="12" cy="7.92" r="0.76" style="fill:#669df6"/><path d="M15,10.54a.76.76,0,0,1-.75-.75V7.91a.76.76,0,1,1,1.51,0V9.79A.76.76,0,0,1,15,10.54Z" style="fill:#4285f4"/><circle cx="15.01" cy="5.69" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="14.19" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="11.97" r="0.76" style="fill:#4285f4"/><circle cx="8.99" cy="14.19" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="7.92" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="5.69" r="0.76" style="fill:#aecbfa"/><path d="M9,12.73A.76.76,0,0,1,8.24,12V10.1a.75.75,0,1,1,1.5,0V12A.75.75,0,0,1,9,12.73Z" style="fill:#aecbfa"/></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -358,7 +358,7 @@
|
||||
"models_excluded_hint": "This model is excluded by OAuth"
|
||||
},
|
||||
"vertex_import": {
|
||||
"title": "Vertex AI Credential Import",
|
||||
"title": "Vertex JSON Login",
|
||||
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.",
|
||||
"location_label": "Region (optional)",
|
||||
"location_placeholder": "us-central1",
|
||||
@@ -534,6 +534,11 @@
|
||||
"by_hour": "By Hour",
|
||||
"by_day": "By Day",
|
||||
"refresh": "Refresh",
|
||||
"export": "Export",
|
||||
"import": "Import",
|
||||
"export_success": "Usage export downloaded",
|
||||
"import_success": "Import complete: added {{added}}, skipped {{skipped}}, total {{total}}, failed {{failed}}",
|
||||
"import_invalid": "Invalid usage export file",
|
||||
"chart_line_label_1": "Line 1",
|
||||
"chart_line_label_2": "Line 2",
|
||||
"chart_line_label_3": "Line 3",
|
||||
@@ -596,6 +601,11 @@
|
||||
"error_logs_modified": "Last modified",
|
||||
"error_logs_download": "Download",
|
||||
"error_log_download_success": "Error log downloaded successfully",
|
||||
"request_log_download_title": "Download Request Log",
|
||||
"request_log_download_confirm": "Download request log for ID {{id}}?",
|
||||
"request_log_download_success": "Request log downloaded successfully",
|
||||
"action_hint": "Double-click a log line to copy the raw text. Long-press a line with a request ID to download the request log.",
|
||||
"action_hint_disabled": "Double-click a log line to copy the raw text. Enable request logging to long-press a line with a request ID and download the request log.",
|
||||
"empty_title": "No Logs Available",
|
||||
"empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here",
|
||||
"log_content": "Log Content",
|
||||
|
||||
@@ -358,7 +358,7 @@
|
||||
"models_excluded_hint": "此模型已被 OAuth 排除"
|
||||
},
|
||||
"vertex_import": {
|
||||
"title": "Vertex AI 凭证导入",
|
||||
"title": "Vertex JSON 登录",
|
||||
"description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
|
||||
"location_label": "目标区域 (可选)",
|
||||
"location_placeholder": "us-central1",
|
||||
@@ -534,6 +534,11 @@
|
||||
"by_hour": "按小时",
|
||||
"by_day": "按天",
|
||||
"refresh": "刷新",
|
||||
"export": "导出数据",
|
||||
"import": "导入数据",
|
||||
"export_success": "使用统计已导出",
|
||||
"import_success": "导入完成:新增 {{added}},跳过 {{skipped}},总请求 {{total}},失败 {{failed}}",
|
||||
"import_invalid": "导入文件格式不正确",
|
||||
"chart_line_label_1": "曲线 1",
|
||||
"chart_line_label_2": "曲线 2",
|
||||
"chart_line_label_3": "曲线 3",
|
||||
@@ -596,6 +601,11 @@
|
||||
"error_logs_modified": "最后修改",
|
||||
"error_logs_download": "下载",
|
||||
"error_log_download_success": "错误日志下载成功",
|
||||
"request_log_download_title": "下载报文",
|
||||
"request_log_download_confirm": "是否要下载id为{{id}}的报文?",
|
||||
"request_log_download_success": "报文下载成功",
|
||||
"action_hint": "双击日志行可复制原文,长按带有请求 ID 的日志可下载报文。",
|
||||
"action_hint_disabled": "双击日志行可复制原文,启用请求日志后可长按带请求 ID 的日志下载报文。",
|
||||
"empty_title": "暂无日志记录",
|
||||
"empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里",
|
||||
"log_content": "日志内容",
|
||||
|
||||
@@ -242,7 +242,11 @@ export function DashboardPage() {
|
||||
</div>
|
||||
<div className={styles.connectionInfo}>
|
||||
<span className={styles.serverUrl}>{apiBase || '-'}</span>
|
||||
{serverVersion && <span className={styles.serverVersion}>v{serverVersion}</span>}
|
||||
{serverVersion && (
|
||||
<span className={styles.serverVersion}>
|
||||
v{serverVersion.trim().replace(/^[vV]+/, '')}
|
||||
</span>
|
||||
)}
|
||||
{serverBuildDate && (
|
||||
<span className={styles.buildDate}>
|
||||
{new Date(serverBuildDate).toLocaleDateString(i18n.language)}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import {
|
||||
IconDownload,
|
||||
@@ -38,6 +40,8 @@ const INITIAL_DISPLAY_LINES = 100;
|
||||
const LOAD_MORE_LINES = 200;
|
||||
const MAX_BUFFER_LINES = 10000;
|
||||
const LOAD_MORE_THRESHOLD_PX = 72;
|
||||
const LONG_PRESS_MS = 650;
|
||||
const LONG_PRESS_MOVE_THRESHOLD = 10;
|
||||
|
||||
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const;
|
||||
type HttpMethod = (typeof HTTP_METHODS)[number];
|
||||
@@ -370,14 +374,22 @@ export function LogsPage() {
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||
const [hideManagementLogs, setHideManagementLogs] = useState(false);
|
||||
const [hideManagementLogs, setHideManagementLogs] = useState(true);
|
||||
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
|
||||
const [loadingErrors, setLoadingErrors] = useState(false);
|
||||
const [errorLogsError, setErrorLogsError] = useState('');
|
||||
const [requestLogId, setRequestLogId] = useState<string | null>(null);
|
||||
const [requestLogDownloading, setRequestLogDownloading] = useState(false);
|
||||
|
||||
const logViewerRef = useRef<HTMLDivElement | null>(null);
|
||||
const pendingScrollToBottomRef = useRef(false);
|
||||
const pendingPrependScrollRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
|
||||
const longPressRef = useRef<{
|
||||
timer: number | null;
|
||||
startX: number;
|
||||
startY: number;
|
||||
fired: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// 保存最新时间戳用于增量获取
|
||||
const latestTimestampRef = useRef<number>(0);
|
||||
@@ -647,6 +659,85 @@ export function LogsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const clearLongPressTimer = () => {
|
||||
if (longPressRef.current?.timer) {
|
||||
window.clearTimeout(longPressRef.current.timer);
|
||||
longPressRef.current.timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startLongPress = (event: ReactPointerEvent<HTMLDivElement>, id?: string) => {
|
||||
if (!requestLogEnabled) return;
|
||||
if (!id) return;
|
||||
if (requestLogId) return;
|
||||
clearLongPressTimer();
|
||||
longPressRef.current = {
|
||||
timer: window.setTimeout(() => {
|
||||
setRequestLogId(id);
|
||||
if (longPressRef.current) {
|
||||
longPressRef.current.fired = true;
|
||||
longPressRef.current.timer = null;
|
||||
}
|
||||
}, LONG_PRESS_MS),
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
fired: false,
|
||||
};
|
||||
};
|
||||
|
||||
const cancelLongPress = () => {
|
||||
clearLongPressTimer();
|
||||
longPressRef.current = null;
|
||||
};
|
||||
|
||||
const handleLongPressMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const current = longPressRef.current;
|
||||
if (!current || current.timer === null || current.fired) return;
|
||||
const deltaX = Math.abs(event.clientX - current.startX);
|
||||
const deltaY = Math.abs(event.clientY - current.startY);
|
||||
if (deltaX > LONG_PRESS_MOVE_THRESHOLD || deltaY > LONG_PRESS_MOVE_THRESHOLD) {
|
||||
cancelLongPress();
|
||||
}
|
||||
};
|
||||
|
||||
const closeRequestLogModal = () => {
|
||||
if (requestLogDownloading) return;
|
||||
setRequestLogId(null);
|
||||
};
|
||||
|
||||
const downloadRequestLog = async (id: string) => {
|
||||
setRequestLogDownloading(true);
|
||||
try {
|
||||
const response = await logsApi.downloadRequestLogById(id);
|
||||
const blob = new Blob([response.data], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `request-${id}.log`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
showNotification(t('logs.request_log_download_success'), 'success');
|
||||
setRequestLogId(null);
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
showNotification(
|
||||
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
setRequestLogDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (longPressRef.current?.timer) {
|
||||
window.clearTimeout(longPressRef.current.timer);
|
||||
longPressRef.current.timer = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
|
||||
@@ -760,6 +851,10 @@ export function LogsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hint">
|
||||
{requestLogEnabled ? t('logs.action_hint') : t('logs.action_hint_disabled')}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="hint">{t('logs.loading')}</div>
|
||||
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (
|
||||
@@ -795,6 +890,11 @@ export function LogsPage() {
|
||||
onDoubleClick={() => {
|
||||
void copyLogLine(line.raw);
|
||||
}}
|
||||
onPointerDown={(event) => startLongPress(event, line.requestId)}
|
||||
onPointerUp={cancelLongPress}
|
||||
onPointerLeave={cancelLongPress}
|
||||
onPointerCancel={cancelLongPress}
|
||||
onPointerMove={handleLongPressMove}
|
||||
title={t('logs.double_click_copy_hint', {
|
||||
defaultValue: 'Double-click to copy',
|
||||
})}
|
||||
@@ -946,6 +1046,32 @@ export function LogsPage() {
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={Boolean(requestLogId)}
|
||||
onClose={closeRequestLogModal}
|
||||
title={t('logs.request_log_download_title')}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={closeRequestLogModal} disabled={requestLogDownloading}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (requestLogId) {
|
||||
void downloadRequestLog(requestLogId);
|
||||
}
|
||||
}}
|
||||
loading={requestLogDownloading}
|
||||
disabled={!requestLogId}
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{requestLogId ? t('logs.request_log_download_confirm', { id: requestLogId }) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,3 +114,25 @@
|
||||
gap: $spacing-sm;
|
||||
margin-top: $spacing-sm;
|
||||
}
|
||||
|
||||
.filePicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fileName {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.fileNamePlaceholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useNotificationStore, useThemeStore } from '@/stores';
|
||||
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
|
||||
import { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
|
||||
import styles from './OAuthPage.module.scss';
|
||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||
@@ -13,6 +14,7 @@ import iconAntigravity from '@/assets/icons/antigravity.svg';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import iconQwen from '@/assets/icons/qwen.svg';
|
||||
import iconIflow from '@/assets/icons/iflow.svg';
|
||||
import iconVertex from '@/assets/icons/vertex.svg';
|
||||
|
||||
interface ProviderState {
|
||||
url?: string;
|
||||
@@ -36,6 +38,22 @@ interface IFlowCookieState {
|
||||
errorType?: 'error' | 'warning';
|
||||
}
|
||||
|
||||
interface VertexImportResult {
|
||||
projectId?: string;
|
||||
email?: string;
|
||||
location?: string;
|
||||
authFile?: string;
|
||||
}
|
||||
|
||||
interface VertexImportState {
|
||||
file?: File;
|
||||
fileName: string;
|
||||
location: string;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
result?: VertexImportResult;
|
||||
}
|
||||
|
||||
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [
|
||||
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconOpenaiLight, dark: iconOpenaiDark } },
|
||||
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
|
||||
@@ -57,7 +75,13 @@ export function OAuthPage() {
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
|
||||
const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false });
|
||||
const [vertexState, setVertexState] = useState<VertexImportState>({
|
||||
fileName: '',
|
||||
location: '',
|
||||
loading: false
|
||||
});
|
||||
const timers = useRef<Record<string, number>>({});
|
||||
const vertexFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -216,6 +240,64 @@ export function OAuthPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleVertexFilePick = () => {
|
||||
vertexFileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleVertexFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file.name.endsWith('.json')) {
|
||||
showNotification(t('vertex_import.file_required'), 'warning');
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
setVertexState((prev) => ({
|
||||
...prev,
|
||||
file,
|
||||
fileName: file.name,
|
||||
error: undefined,
|
||||
result: undefined
|
||||
}));
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const handleVertexImport = async () => {
|
||||
if (!vertexState.file) {
|
||||
const message = t('vertex_import.file_required');
|
||||
setVertexState((prev) => ({ ...prev, error: message }));
|
||||
showNotification(message, 'warning');
|
||||
return;
|
||||
}
|
||||
const location = vertexState.location.trim();
|
||||
setVertexState((prev) => ({ ...prev, loading: true, error: undefined, result: undefined }));
|
||||
try {
|
||||
const res: VertexImportResponse = await vertexApi.importCredential(
|
||||
vertexState.file,
|
||||
location || undefined
|
||||
);
|
||||
const result: VertexImportResult = {
|
||||
projectId: res.project_id,
|
||||
email: res.email,
|
||||
location: res.location,
|
||||
authFile: res['auth-file'] ?? res.auth_file
|
||||
};
|
||||
setVertexState((prev) => ({ ...prev, loading: false, result }));
|
||||
showNotification(t('vertex_import.success'), 'success');
|
||||
} catch (err: any) {
|
||||
const message = err?.message || '';
|
||||
setVertexState((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: message || t('notification.upload_failed')
|
||||
}));
|
||||
const notification = message
|
||||
? `${t('notification.upload_failed')}: ${message}`
|
||||
: t('notification.upload_failed');
|
||||
showNotification(notification, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.pageTitle}>{t('nav.oauth', { defaultValue: 'OAuth' })}</h1>
|
||||
@@ -328,6 +410,94 @@ export function OAuthPage() {
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Vertex JSON 登录 */}
|
||||
<Card
|
||||
title={
|
||||
<span className={styles.cardTitle}>
|
||||
<img src={iconVertex} alt="" className={styles.cardTitleIcon} />
|
||||
{t('vertex_import.title')}
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<Button onClick={handleVertexImport} loading={vertexState.loading}>
|
||||
{t('vertex_import.import_button')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="hint">{t('vertex_import.description')}</div>
|
||||
<Input
|
||||
label={t('vertex_import.location_label')}
|
||||
hint={t('vertex_import.location_hint')}
|
||||
value={vertexState.location}
|
||||
onChange={(e) =>
|
||||
setVertexState((prev) => ({
|
||||
...prev,
|
||||
location: e.target.value
|
||||
}))
|
||||
}
|
||||
placeholder={t('vertex_import.location_placeholder')}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t('vertex_import.file_label')}</label>
|
||||
<div className={styles.filePicker}>
|
||||
<Button variant="secondary" size="sm" onClick={handleVertexFilePick}>
|
||||
{t('vertex_import.choose_file')}
|
||||
</Button>
|
||||
<div
|
||||
className={`${styles.fileName} ${
|
||||
vertexState.fileName ? '' : styles.fileNamePlaceholder
|
||||
}`.trim()}
|
||||
>
|
||||
{vertexState.fileName || t('vertex_import.file_placeholder')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hint">{t('vertex_import.file_hint')}</div>
|
||||
<input
|
||||
ref={vertexFileInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleVertexFileChange}
|
||||
/>
|
||||
</div>
|
||||
{vertexState.error && (
|
||||
<div className="status-badge error" style={{ marginTop: 8 }}>
|
||||
{vertexState.error}
|
||||
</div>
|
||||
)}
|
||||
{vertexState.result && (
|
||||
<div className="connection-box" style={{ marginTop: 12 }}>
|
||||
<div className="label">{t('vertex_import.result_title')}</div>
|
||||
<div className="key-value-list">
|
||||
{vertexState.result.projectId && (
|
||||
<div className="key-value-item">
|
||||
<span className="key">{t('vertex_import.result_project')}</span>
|
||||
<span className="value">{vertexState.result.projectId}</span>
|
||||
</div>
|
||||
)}
|
||||
{vertexState.result.email && (
|
||||
<div className="key-value-item">
|
||||
<span className="key">{t('vertex_import.result_email')}</span>
|
||||
<span className="value">{vertexState.result.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{vertexState.result.location && (
|
||||
<div className="key-value-item">
|
||||
<span className="key">{t('vertex_import.result_location')}</span>
|
||||
<span className="value">{vertexState.result.location}</span>
|
||||
</div>
|
||||
)}
|
||||
{vertexState.result.authFile && (
|
||||
<div className="key-value-item">
|
||||
<span className="key">{t('vertex_import.result_file')}</span>
|
||||
<span className="value">{vertexState.result.authFile}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* iFlow Cookie 登录 */}
|
||||
<Card
|
||||
title={
|
||||
|
||||
@@ -18,6 +18,13 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback, useMemo, type CSSProperties } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo, useRef, type CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
@@ -19,7 +19,7 @@ import { Input } from '@/components/ui/Input';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { useThemeStore } from '@/stores';
|
||||
import { useNotificationStore, useThemeStore } from '@/stores';
|
||||
import { usageApi } from '@/services/api/usage';
|
||||
import {
|
||||
formatTokensInMillions,
|
||||
@@ -63,6 +63,7 @@ interface UsagePayload {
|
||||
|
||||
export function UsagePage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const isDark = resolvedTheme === 'dark';
|
||||
@@ -71,6 +72,9 @@ export function UsagePage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Model price form state
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
@@ -107,6 +111,77 @@ export function UsagePage() {
|
||||
setModelPrices(loadModelPrices());
|
||||
}, [loadUsage]);
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const data = await usageApi.exportUsage();
|
||||
const exportedAt =
|
||||
typeof data?.exported_at === 'string' ? new Date(data.exported_at) : new Date();
|
||||
const safeTimestamp = Number.isNaN(exportedAt.getTime())
|
||||
? new Date().toISOString()
|
||||
: exportedAt.toISOString();
|
||||
const filename = `usage-export-${safeTimestamp.replace(/[:.]/g, '-')}.json`;
|
||||
const blob = new Blob([JSON.stringify(data ?? {}, null, 2)], { type: 'application/json' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
showNotification(t('usage_stats.export_success'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
showNotification(
|
||||
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
importInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleImportChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const text = await file.text();
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
showNotification(t('usage_stats.import_invalid'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await usageApi.importUsage(payload);
|
||||
showNotification(
|
||||
t('usage_stats.import_success', {
|
||||
added: result?.added ?? 0,
|
||||
skipped: result?.skipped ?? 0,
|
||||
total: result?.total_requests ?? 0,
|
||||
failed: result?.failed_requests ?? 0
|
||||
}),
|
||||
'success'
|
||||
);
|
||||
await loadUsage();
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
showNotification(
|
||||
`${t('notification.upload_failed')}${message ? `: ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate derived data
|
||||
const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 };
|
||||
const rateStats = usage
|
||||
@@ -527,14 +602,41 @@ export function UsagePage() {
|
||||
)}
|
||||
<div className={styles.header}>
|
||||
<h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={loadUsage}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? t('common.loading') : t('usage_stats.refresh')}
|
||||
</Button>
|
||||
<div className={styles.headerActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
loading={exporting}
|
||||
disabled={loading || importing}
|
||||
>
|
||||
{t('usage_stats.export')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleImportClick}
|
||||
loading={importing}
|
||||
disabled={loading || exporting}
|
||||
>
|
||||
{t('usage_stats.import')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={loadUsage}
|
||||
disabled={loading || exporting || importing}
|
||||
>
|
||||
{loading ? t('common.loading') : t('usage_stats.refresh')}
|
||||
</Button>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleImportChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
|
||||
@@ -11,3 +11,4 @@ export * from './logs';
|
||||
export * from './version';
|
||||
export * from './models';
|
||||
export * from './transformers';
|
||||
export * from './vertex';
|
||||
|
||||
@@ -39,4 +39,10 @@ export const logsApi = {
|
||||
responseType: 'blob',
|
||||
timeout: LOGS_TIMEOUT_MS
|
||||
}),
|
||||
|
||||
downloadRequestLogById: (id: string) =>
|
||||
apiClient.getRaw(`/request-log-by-id/${encodeURIComponent(id)}`, {
|
||||
responseType: 'blob',
|
||||
timeout: LOGS_TIMEOUT_MS
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -7,12 +7,38 @@ import { computeKeyStats, KeyStats } from '@/utils/usage';
|
||||
|
||||
const USAGE_TIMEOUT_MS = 60 * 1000;
|
||||
|
||||
export interface UsageExportPayload {
|
||||
version?: number;
|
||||
exported_at?: string;
|
||||
usage?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UsageImportResponse {
|
||||
added?: number;
|
||||
skipped?: number;
|
||||
total_requests?: number;
|
||||
failed_requests?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const usageApi = {
|
||||
/**
|
||||
* 获取使用统计原始数据
|
||||
*/
|
||||
getUsage: () => apiClient.get('/usage', { timeout: USAGE_TIMEOUT_MS }),
|
||||
|
||||
/**
|
||||
* 导出使用统计快照
|
||||
*/
|
||||
exportUsage: () => apiClient.get<UsageExportPayload>('/usage/export', { timeout: USAGE_TIMEOUT_MS }),
|
||||
|
||||
/**
|
||||
* 导入使用统计快照
|
||||
*/
|
||||
importUsage: (payload: unknown) =>
|
||||
apiClient.post<UsageImportResponse>('/usage/import', payload, { timeout: USAGE_TIMEOUT_MS }),
|
||||
|
||||
/**
|
||||
* 计算密钥成功/失败统计,必要时会先获取 usage 数据
|
||||
*/
|
||||
|
||||
25
src/services/api/vertex.ts
Normal file
25
src/services/api/vertex.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Vertex credential import API
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface VertexImportResponse {
|
||||
status: 'ok';
|
||||
project_id?: string;
|
||||
email?: string;
|
||||
location?: string;
|
||||
'auth-file'?: string;
|
||||
auth_file?: string;
|
||||
}
|
||||
|
||||
export const vertexApi = {
|
||||
importCredential: (file: File, location?: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (location) {
|
||||
formData.append('location', location);
|
||||
}
|
||||
return apiClient.postForm<VertexImportResponse>('/vertex/import', formData);
|
||||
}
|
||||
};
|
||||
@@ -331,7 +331,7 @@
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 0 0 auto;
|
||||
flex: 1 0 auto;
|
||||
padding: $spacing-lg;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user