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
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d79ccc480d | ||
|
|
7b0d6dc7e9 | ||
|
|
b8d7b8997c | ||
|
|
0bb34ca74b | ||
|
|
99c4fbc30d |
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { HashRouter, Route, Routes } from 'react-router-dom';
|
||||
import { LoginPage } from '@/pages/LoginPage';
|
||||
import { NotificationContainer } from '@/components/common/NotificationContainer';
|
||||
import { ConfirmationModal } from '@/components/common/ConfirmationModal';
|
||||
import { SplashScreen } from '@/components/common/SplashScreen';
|
||||
import { MainLayout } from '@/components/layout/MainLayout';
|
||||
import { ProtectedRoute } from '@/router/ProtectedRoute';
|
||||
@@ -61,6 +62,7 @@ function App() {
|
||||
return (
|
||||
<HashRouter>
|
||||
<NotificationContainer />
|
||||
<ConfirmationModal />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
|
||||
61
src/components/common/ConfirmationModal.tsx
Normal file
61
src/components/common/ConfirmationModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ interface ModalProps {
|
||||
onClose: () => void;
|
||||
footer?: ReactNode;
|
||||
width?: number | string;
|
||||
closeDisabled?: boolean;
|
||||
}
|
||||
|
||||
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 [isClosing, setIsClosing] = useState(false);
|
||||
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 = (
|
||||
<div className={overlayClass}>
|
||||
<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} />
|
||||
</button>
|
||||
<div className="modal-header">
|
||||
|
||||
@@ -14,7 +14,7 @@ import styles from './ApiKeysPage.module.scss';
|
||||
|
||||
export function ApiKeysPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const { showNotification, showConfirmation } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
|
||||
const config = useConfigStore((state) => state.config);
|
||||
@@ -29,7 +29,6 @@ export function ApiKeysPage() {
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingIndex, setDeletingIndex] = useState<number | null>(null);
|
||||
|
||||
const disableControls = useMemo(() => connectionStatus !== 'connected', [connectionStatus]);
|
||||
|
||||
@@ -115,21 +114,42 @@ export function ApiKeysPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (index: number) => {
|
||||
if (!window.confirm(t('api_keys.delete_confirm'))) return;
|
||||
setDeletingIndex(index);
|
||||
try {
|
||||
await apiKeysApi.delete(index);
|
||||
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);
|
||||
const handleDelete = (index: number) => {
|
||||
const apiKeyToDelete = apiKeys[index];
|
||||
if (!apiKeyToDelete) {
|
||||
showNotification(t('notification.delete_failed'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
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 = (
|
||||
@@ -181,8 +201,7 @@ export function ApiKeysPage() {
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(index)}
|
||||
disabled={disableControls || deletingIndex === index}
|
||||
loading={deletingIndex === index}
|
||||
disabled={disableControls}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { AuthFileItem, OAuthModelMappingEntry } from '@/types';
|
||||
import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage';
|
||||
import { collectUsageDetails, calculateStatusBarData } from '@/utils/usage';
|
||||
import { formatFileSize } from '@/utils/format';
|
||||
import { generateId } from '@/utils/helpers';
|
||||
import styles from './AuthFilesPage.module.scss';
|
||||
|
||||
type ThemeColors = { bg: string; text: string; border?: string };
|
||||
@@ -91,12 +92,15 @@ interface ExcludedFormState {
|
||||
modelsText: string;
|
||||
}
|
||||
|
||||
type OAuthModelMappingFormEntry = OAuthModelMappingEntry & { id: string };
|
||||
|
||||
interface ModelMappingsFormState {
|
||||
provider: string;
|
||||
mappings: OAuthModelMappingEntry[];
|
||||
mappings: OAuthModelMappingFormEntry[];
|
||||
}
|
||||
|
||||
const buildEmptyMappingEntry = (): OAuthModelMappingEntry => ({
|
||||
const buildEmptyMappingEntry = (): OAuthModelMappingFormEntry => ({
|
||||
id: generateId(),
|
||||
name: '',
|
||||
alias: '',
|
||||
fork: false
|
||||
@@ -728,11 +732,12 @@ export function AuthFilesPage() {
|
||||
};
|
||||
|
||||
// OAuth 模型映射相关方法
|
||||
const normalizeMappingEntries = (entries?: OAuthModelMappingEntry[]) => {
|
||||
const normalizeMappingEntries = (entries?: OAuthModelMappingEntry[]): OAuthModelMappingFormEntry[] => {
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
return [buildEmptyMappingEntry()];
|
||||
}
|
||||
return entries.map((entry) => ({
|
||||
id: generateId(),
|
||||
name: entry.name ?? '',
|
||||
alias: entry.alias ?? '',
|
||||
fork: Boolean(entry.fork),
|
||||
@@ -1399,15 +1404,15 @@ export function AuthFilesPage() {
|
||||
>
|
||||
<div className={styles.providerField}>
|
||||
<Input
|
||||
id="oauth-model-mappings-provider"
|
||||
list="oauth-model-mappings-provider-options"
|
||||
id="oauth-model-alias-provider"
|
||||
list="oauth-model-alias-provider-options"
|
||||
label={t('oauth_model_mappings.provider_label')}
|
||||
hint={t('oauth_model_mappings.provider_hint')}
|
||||
placeholder={t('oauth_model_mappings.provider_placeholder')}
|
||||
value={mappingForm.provider}
|
||||
onChange={(e) => setMappingForm((prev) => ({ ...prev, provider: e.target.value }))}
|
||||
/>
|
||||
<datalist id="oauth-model-mappings-provider-options">
|
||||
<datalist id="oauth-model-alias-provider-options">
|
||||
{providerOptions.map((provider) => (
|
||||
<option key={provider} value={provider} />
|
||||
))}
|
||||
@@ -1437,7 +1442,7 @@ export function AuthFilesPage() {
|
||||
<div className="header-input-list">
|
||||
{(mappingForm.mappings.length ? mappingForm.mappings : [buildEmptyMappingEntry()]).map(
|
||||
(entry, index) => (
|
||||
<div key={`${entry.name}-${entry.alias}-${index}`} className={styles.mappingRow}>
|
||||
<div key={entry.id} className={styles.mappingRow}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('oauth_model_mappings.mapping_name_placeholder')}
|
||||
|
||||
@@ -81,8 +81,8 @@ export const authFilesApi = {
|
||||
|
||||
// OAuth 模型映射
|
||||
async getOauthModelMappings(): Promise<Record<string, OAuthModelMappingEntry[]>> {
|
||||
const data = await apiClient.get('/oauth-model-mappings');
|
||||
const payload = (data && (data['oauth-model-mappings'] ?? data.items ?? data)) as any;
|
||||
const data = await apiClient.get('/oauth-model-alias');
|
||||
const payload = (data && (data['oauth-model-alias'] ?? data.items ?? data)) as any;
|
||||
if (!payload || typeof payload !== 'object') return {};
|
||||
const result: Record<string, OAuthModelMappingEntry[]> = {};
|
||||
Object.entries(payload).forEach(([channel, mappings]) => {
|
||||
@@ -105,10 +105,10 @@ export const authFilesApi = {
|
||||
},
|
||||
|
||||
saveOauthModelMappings: (channel: string, mappings: OAuthModelMappingEntry[]) =>
|
||||
apiClient.patch('/oauth-model-mappings', { channel, mappings }),
|
||||
apiClient.patch('/oauth-model-alias', { channel, aliases: mappings }),
|
||||
|
||||
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 }[]> {
|
||||
|
||||
@@ -8,15 +8,38 @@ import type { Notification, NotificationType } from '@/types';
|
||||
import { generateId } from '@/utils/helpers';
|
||||
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 {
|
||||
notifications: Notification[];
|
||||
confirmation: {
|
||||
isOpen: boolean;
|
||||
isLoading: boolean;
|
||||
options: ConfirmationOptions | null;
|
||||
};
|
||||
showNotification: (message: string, type?: NotificationType, duration?: number) => void;
|
||||
removeNotification: (id: string) => void;
|
||||
clearAll: () => void;
|
||||
showConfirmation: (options: ConfirmationOptions) => void;
|
||||
hideConfirmation: () => void;
|
||||
setConfirmationLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const useNotificationStore = create<NotificationState>((set) => ({
|
||||
notifications: [],
|
||||
confirmation: {
|
||||
isOpen: false,
|
||||
isLoading: false,
|
||||
options: null
|
||||
},
|
||||
|
||||
showNotification: (message, type = 'info', duration = NOTIFICATION_DURATION_MS) => {
|
||||
const id = generateId();
|
||||
@@ -49,5 +72,34 @@ export const useNotificationStore = create<NotificationState>((set) => ({
|
||||
|
||||
clearAll: () => {
|
||||
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
|
||||
}
|
||||
}));
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -453,6 +453,18 @@ textarea {
|
||||
&:active {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user