Compare commits

...

7 Commits

9 changed files with 1034 additions and 809 deletions

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { HashRouter, Route, Routes } from 'react-router-dom'; import { HashRouter, Route, Routes } from 'react-router-dom';
import { LoginPage } from '@/pages/LoginPage'; import { LoginPage } from '@/pages/LoginPage';
import { NotificationContainer } from '@/components/common/NotificationContainer'; import { NotificationContainer } from '@/components/common/NotificationContainer';
import { ConfirmationModal } from '@/components/common/ConfirmationModal';
import { SplashScreen } from '@/components/common/SplashScreen'; import { SplashScreen } from '@/components/common/SplashScreen';
import { MainLayout } from '@/components/layout/MainLayout'; import { MainLayout } from '@/components/layout/MainLayout';
import { ProtectedRoute } from '@/router/ProtectedRoute'; import { ProtectedRoute } from '@/router/ProtectedRoute';
@@ -61,6 +62,7 @@ function App() {
return ( return (
<HashRouter> <HashRouter>
<NotificationContainer /> <NotificationContainer />
<ConfirmationModal />
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route <Route

View File

@@ -0,0 +1,61 @@
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { useNotificationStore } from '@/stores';
export function ConfirmationModal() {
const { t } = useTranslation();
const confirmation = useNotificationStore((state) => state.confirmation);
const hideConfirmation = useNotificationStore((state) => state.hideConfirmation);
const setConfirmationLoading = useNotificationStore((state) => state.setConfirmationLoading);
const { isOpen, isLoading, options } = confirmation;
if (!isOpen || !options) {
return null;
}
const { title, message, onConfirm, onCancel, confirmText, cancelText, variant = 'primary' } = options;
const handleConfirm = async () => {
try {
setConfirmationLoading(true);
await onConfirm();
hideConfirmation();
} catch (error) {
console.error('Confirmation action failed:', error);
// Optional: show error notification here if needed,
// but usually the calling component handles specific errors.
} finally {
setConfirmationLoading(false);
}
};
const handleCancel = () => {
if (isLoading) {
return;
}
if (onCancel) {
onCancel();
}
hideConfirmation();
};
return (
<Modal open={isOpen} onClose={handleCancel} title={title} closeDisabled={isLoading}>
<p style={{ margin: '1rem 0' }}>{message}</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '1rem', marginTop: '2rem' }}>
<Button variant="ghost" onClick={handleCancel} disabled={isLoading}>
{cancelText || t('common.cancel')}
</Button>
<Button
variant={variant}
onClick={handleConfirm}
loading={isLoading}
>
{confirmText || t('common.confirm')}
</Button>
</div>
</Modal>
);
}

View File

@@ -18,7 +18,7 @@ import type {
GeminiCliQuotaBucketState, GeminiCliQuotaBucketState,
GeminiCliQuotaState GeminiCliQuotaState
} from '@/types'; } from '@/types';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api'; import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
import { import {
ANTIGRAVITY_QUOTA_URLS, ANTIGRAVITY_QUOTA_URLS,
ANTIGRAVITY_REQUEST_HEADERS, ANTIGRAVITY_REQUEST_HEADERS,
@@ -55,6 +55,8 @@ type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli'; type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
export interface QuotaStore { export interface QuotaStore {
antigravityQuota: Record<string, AntigravityQuotaState>; antigravityQuota: Record<string, AntigravityQuotaState>;
codexQuota: Record<string, CodexQuotaState>; codexQuota: Record<string, CodexQuotaState>;
@@ -82,6 +84,43 @@ export interface QuotaConfig<TState, TData> {
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode; renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
} }
const resolveAntigravityProjectId = async (file: AuthFileItem): Promise<string> => {
try {
const text = await authFilesApi.downloadText(file.name);
const trimmed = text.trim();
if (!trimmed) return DEFAULT_ANTIGRAVITY_PROJECT_ID;
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
const topLevel = normalizeStringValue(parsed.project_id ?? parsed.projectId);
if (topLevel) return topLevel;
const installed =
parsed.installed && typeof parsed.installed === 'object' && parsed.installed !== null
? (parsed.installed as Record<string, unknown>)
: null;
const installedProjectId = installed
? normalizeStringValue(installed.project_id ?? installed.projectId)
: null;
if (installedProjectId) return installedProjectId;
const web =
parsed.web && typeof parsed.web === 'object' && parsed.web !== null
? (parsed.web as Record<string, unknown>)
: null;
const webProjectId = web ? normalizeStringValue(web.project_id ?? web.projectId) : null;
if (webProjectId) return webProjectId;
} catch {
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
}
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
};
const isAntigravityUnknownFieldError = (message: string): boolean => {
const normalized = message.toLowerCase();
return normalized.includes('unknown name') && normalized.includes('cannot find field');
};
const fetchAntigravityQuota = async ( const fetchAntigravityQuota = async (
file: AuthFileItem, file: AuthFileItem,
t: TFunction t: TFunction
@@ -92,52 +131,64 @@ const fetchAntigravityQuota = async (
throw new Error(t('antigravity_quota.missing_auth_index')); throw new Error(t('antigravity_quota.missing_auth_index'));
} }
const projectId = await resolveAntigravityProjectId(file);
const requestBodies = [JSON.stringify({ projectId }), JSON.stringify({ project: projectId })];
let lastError = ''; let lastError = '';
let lastStatus: number | undefined; let lastStatus: number | undefined;
let priorityStatus: number | undefined; let priorityStatus: number | undefined;
let hadSuccess = false; let hadSuccess = false;
for (const url of ANTIGRAVITY_QUOTA_URLS) { for (const url of ANTIGRAVITY_QUOTA_URLS) {
try { for (let attempt = 0; attempt < requestBodies.length; attempt++) {
const result = await apiCallApi.request({ try {
authIndex, const result = await apiCallApi.request({
method: 'POST', authIndex,
url, method: 'POST',
header: { ...ANTIGRAVITY_REQUEST_HEADERS }, url,
data: '{}' header: { ...ANTIGRAVITY_REQUEST_HEADERS },
}); data: requestBodies[attempt]
});
if (result.statusCode < 200 || result.statusCode >= 300) { if (result.statusCode < 200 || result.statusCode >= 300) {
lastError = getApiCallErrorMessage(result); lastError = getApiCallErrorMessage(result);
lastStatus = result.statusCode; lastStatus = result.statusCode;
if (result.statusCode === 403 || result.statusCode === 404) { if (result.statusCode === 403 || result.statusCode === 404) {
priorityStatus ??= result.statusCode; priorityStatus ??= result.statusCode;
}
if (
result.statusCode === 400 &&
isAntigravityUnknownFieldError(lastError) &&
attempt < requestBodies.length - 1
) {
continue;
}
break;
} }
continue;
}
hadSuccess = true; hadSuccess = true;
const payload = parseAntigravityPayload(result.body ?? result.bodyText); const payload = parseAntigravityPayload(result.body ?? result.bodyText);
const models = payload?.models; const models = payload?.models;
if (!models || typeof models !== 'object' || Array.isArray(models)) { if (!models || typeof models !== 'object' || Array.isArray(models)) {
lastError = t('antigravity_quota.empty_models'); lastError = t('antigravity_quota.empty_models');
continue; continue;
} }
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload); const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
if (groups.length === 0) { if (groups.length === 0) {
lastError = t('antigravity_quota.empty_models'); lastError = t('antigravity_quota.empty_models');
continue; continue;
} }
return groups; return groups;
} catch (err: unknown) { } catch (err: unknown) {
lastError = err instanceof Error ? err.message : t('common.unknown_error'); lastError = err instanceof Error ? err.message : t('common.unknown_error');
const status = getStatusFromError(err); const status = getStatusFromError(err);
if (status) { if (status) {
lastStatus = status; lastStatus = status;
if (status === 403 || status === 404) { if (status === 403 || status === 404) {
priorityStatus ??= status; priorityStatus ??= status;
}
} }
} }
} }

View File

@@ -8,6 +8,7 @@ interface ModalProps {
onClose: () => void; onClose: () => void;
footer?: ReactNode; footer?: ReactNode;
width?: number | string; width?: number | string;
closeDisabled?: boolean;
} }
const CLOSE_ANIMATION_DURATION = 350; const CLOSE_ANIMATION_DURATION = 350;
@@ -32,7 +33,15 @@ const unlockScroll = () => {
} }
}; };
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) { export function Modal({
open,
title,
onClose,
footer,
width = 520,
closeDisabled = false,
children
}: PropsWithChildren<ModalProps>) {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -106,7 +115,13 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P
const modalContent = ( const modalContent = (
<div className={overlayClass}> <div className={overlayClass}>
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true"> <div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
<button className="modal-close-floating" onClick={handleClose} aria-label="Close"> <button
type="button"
className="modal-close-floating"
onClick={closeDisabled ? undefined : handleClose}
aria-label="Close"
disabled={closeDisabled}
>
<IconX size={20} /> <IconX size={20} />
</button> </button>
<div className="modal-header"> <div className="modal-header">

View File

@@ -14,7 +14,7 @@ import styles from './ApiKeysPage.module.scss';
export function ApiKeysPage() { export function ApiKeysPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification, showConfirmation } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus); const connectionStatus = useAuthStore((state) => state.connectionStatus);
const config = useConfigStore((state) => state.config); const config = useConfigStore((state) => state.config);
@@ -29,7 +29,6 @@ export function ApiKeysPage() {
const [editingIndex, setEditingIndex] = useState<number | null>(null); const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deletingIndex, setDeletingIndex] = useState<number | null>(null);
const disableControls = useMemo(() => connectionStatus !== 'connected', [connectionStatus]); const disableControls = useMemo(() => connectionStatus !== 'connected', [connectionStatus]);
@@ -115,21 +114,42 @@ export function ApiKeysPage() {
} }
}; };
const handleDelete = async (index: number) => { const handleDelete = (index: number) => {
if (!window.confirm(t('api_keys.delete_confirm'))) return; const apiKeyToDelete = apiKeys[index];
setDeletingIndex(index); if (!apiKeyToDelete) {
try { showNotification(t('notification.delete_failed'), 'error');
await apiKeysApi.delete(index); return;
const nextKeys = apiKeys.filter((_, idx) => idx !== index);
setApiKeys(nextKeys);
updateConfigValue('api-keys', nextKeys);
clearCache('api-keys');
showNotification(t('notification.api_key_deleted'), 'success');
} catch (err: any) {
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
} finally {
setDeletingIndex(null);
} }
showConfirmation({
title: t('common.delete'),
message: t('api_keys.delete_confirm'),
variant: 'danger',
onConfirm: async () => {
const latestKeys = useConfigStore.getState().config?.apiKeys;
const currentKeys = Array.isArray(latestKeys) ? latestKeys : [];
const deleteIndex =
currentKeys[index] === apiKeyToDelete
? index
: currentKeys.findIndex((key) => key === apiKeyToDelete);
if (deleteIndex < 0) {
showNotification(t('notification.delete_failed'), 'error');
return;
}
try {
await apiKeysApi.delete(deleteIndex);
const nextKeys = currentKeys.filter((_, idx) => idx !== deleteIndex);
setApiKeys(nextKeys);
updateConfigValue('api-keys', nextKeys);
clearCache('api-keys');
showNotification(t('notification.api_key_deleted'), 'success');
} catch (err: any) {
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
}
}
});
}; };
const actionButtons = ( const actionButtons = (
@@ -181,8 +201,7 @@ export function ApiKeysPage() {
variant="danger" variant="danger"
size="sm" size="sm"
onClick={() => handleDelete(index)} onClick={() => handleDelete(index)}
disabled={disableControls || deletingIndex === index} disabled={disableControls}
loading={deletingIndex === index}
> >
{t('common.delete')} {t('common.delete')}
</Button> </Button>

File diff suppressed because it is too large Load Diff

View File

@@ -56,6 +56,14 @@ export const authFilesApi = {
deleteAll: () => apiClient.delete('/auth-files', { params: { all: true } }), deleteAll: () => apiClient.delete('/auth-files', { params: { all: true } }),
downloadText: async (name: string): Promise<string> => {
const response = await apiClient.getRaw(`/auth-files/download?name=${encodeURIComponent(name)}`, {
responseType: 'blob'
});
const blob = response.data as Blob;
return blob.text();
},
// OAuth 排除模型 // OAuth 排除模型
async getOauthExcludedModels(): Promise<Record<string, string[]>> { async getOauthExcludedModels(): Promise<Record<string, string[]>> {
const data = await apiClient.get('/oauth-excluded-models'); const data = await apiClient.get('/oauth-excluded-models');
@@ -73,8 +81,8 @@ export const authFilesApi = {
// OAuth 模型映射 // OAuth 模型映射
async getOauthModelMappings(): Promise<Record<string, OAuthModelMappingEntry[]>> { async getOauthModelMappings(): Promise<Record<string, OAuthModelMappingEntry[]>> {
const data = await apiClient.get('/oauth-model-mappings'); const data = await apiClient.get('/oauth-model-alias');
const payload = (data && (data['oauth-model-mappings'] ?? data.items ?? data)) as any; const payload = (data && (data['oauth-model-alias'] ?? data.items ?? data)) as any;
if (!payload || typeof payload !== 'object') return {}; if (!payload || typeof payload !== 'object') return {};
const result: Record<string, OAuthModelMappingEntry[]> = {}; const result: Record<string, OAuthModelMappingEntry[]> = {};
Object.entries(payload).forEach(([channel, mappings]) => { Object.entries(payload).forEach(([channel, mappings]) => {
@@ -97,10 +105,10 @@ export const authFilesApi = {
}, },
saveOauthModelMappings: (channel: string, mappings: OAuthModelMappingEntry[]) => saveOauthModelMappings: (channel: string, mappings: OAuthModelMappingEntry[]) =>
apiClient.patch('/oauth-model-mappings', { channel, mappings }), apiClient.patch('/oauth-model-alias', { channel, aliases: mappings }),
deleteOauthModelMappings: (channel: string) => deleteOauthModelMappings: (channel: string) =>
apiClient.delete(`/oauth-model-mappings?channel=${encodeURIComponent(channel)}`), apiClient.delete(`/oauth-model-alias?channel=${encodeURIComponent(channel)}`),
// 获取认证凭证支持的模型 // 获取认证凭证支持的模型
async getModelsForAuthFile(name: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> { async getModelsForAuthFile(name: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {

View File

@@ -8,15 +8,38 @@ import type { Notification, NotificationType } from '@/types';
import { generateId } from '@/utils/helpers'; import { generateId } from '@/utils/helpers';
import { NOTIFICATION_DURATION_MS } from '@/utils/constants'; import { NOTIFICATION_DURATION_MS } from '@/utils/constants';
interface ConfirmationOptions {
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'primary' | 'secondary';
onConfirm: () => void | Promise<void>;
onCancel?: () => void;
}
interface NotificationState { interface NotificationState {
notifications: Notification[]; notifications: Notification[];
confirmation: {
isOpen: boolean;
isLoading: boolean;
options: ConfirmationOptions | null;
};
showNotification: (message: string, type?: NotificationType, duration?: number) => void; showNotification: (message: string, type?: NotificationType, duration?: number) => void;
removeNotification: (id: string) => void; removeNotification: (id: string) => void;
clearAll: () => void; clearAll: () => void;
showConfirmation: (options: ConfirmationOptions) => void;
hideConfirmation: () => void;
setConfirmationLoading: (loading: boolean) => void;
} }
export const useNotificationStore = create<NotificationState>((set) => ({ export const useNotificationStore = create<NotificationState>((set) => ({
notifications: [], notifications: [],
confirmation: {
isOpen: false,
isLoading: false,
options: null
},
showNotification: (message, type = 'info', duration = NOTIFICATION_DURATION_MS) => { showNotification: (message, type = 'info', duration = NOTIFICATION_DURATION_MS) => {
const id = generateId(); const id = generateId();
@@ -49,5 +72,34 @@ export const useNotificationStore = create<NotificationState>((set) => ({
clearAll: () => { clearAll: () => {
set({ notifications: [] }); set({ notifications: [] });
},
showConfirmation: (options) => {
set({
confirmation: {
isOpen: true,
isLoading: false,
options
}
});
},
hideConfirmation: () => {
set((state) => ({
confirmation: {
...state.confirmation,
isOpen: false,
options: null // Cleanup
}
}));
},
setConfirmationLoading: (loading) => {
set((state) => ({
confirmation: {
...state.confirmation,
isLoading: loading
}
}));
} }
})); }));

View File

@@ -453,6 +453,18 @@ textarea {
&:active { &:active {
transform: scale(0.95); transform: scale(0.95);
} }
&:disabled {
cursor: not-allowed;
opacity: 0.6;
transform: none;
}
&:disabled:hover {
color: var(--text-secondary);
background: var(--bg-secondary);
transform: none;
}
} }
.modal-header { .modal-header {