mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-18 18:50:49 +08:00
feat: initialize new React application structure with TypeScript, ESLint, and Prettier configurations, while removing legacy files and adding new components and pages for enhanced functionality
This commit is contained in:
46
src/pages/AiProvidersPage.module.scss
Normal file
46
src/pages/AiProvidersPage.module.scss
Normal file
@@ -0,0 +1,46 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 $spacing-xl 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xl;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.providerList {
|
||||
display: grid;
|
||||
gap: $spacing-md;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
782
src/pages/AiProvidersPage.tsx
Normal file
782
src/pages/AiProvidersPage.tsx
Normal file
@@ -0,0 +1,782 @@
|
||||
import { Fragment, useEffect, useMemo, useState, type ReactNode } 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 { Modal } from '@/components/ui/Modal';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import { providersApi } from '@/services/api';
|
||||
import type {
|
||||
GeminiKeyConfig,
|
||||
ProviderKeyConfig,
|
||||
OpenAIProviderConfig,
|
||||
ApiKeyEntry,
|
||||
ModelAlias
|
||||
} from '@/types';
|
||||
import { headersToEntries, buildHeaderObject, type HeaderEntry } from '@/utils/headers';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
|
||||
type ProviderModal =
|
||||
| { type: 'gemini'; index: number | null }
|
||||
| { type: 'codex'; index: number | null }
|
||||
| { type: 'claude'; index: number | null }
|
||||
| { type: 'openai'; index: number | null };
|
||||
|
||||
interface OpenAIFormState {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
headers: HeaderEntry[];
|
||||
priority?: number;
|
||||
testModel?: string;
|
||||
modelsText: string;
|
||||
apiKeyEntries: ApiKeyEntry[];
|
||||
}
|
||||
|
||||
const parseModelsText = (value: string): ModelAlias[] => {
|
||||
return value
|
||||
.split(/\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [namePart, aliasPart] = line.split(',').map((item) => item.trim());
|
||||
if (!namePart) return null;
|
||||
const entry: ModelAlias = { name: namePart };
|
||||
if (aliasPart && aliasPart !== namePart) entry.alias = aliasPart;
|
||||
return entry;
|
||||
})
|
||||
.filter(Boolean) as ModelAlias[];
|
||||
};
|
||||
|
||||
const modelsToText = (models?: ModelAlias[]) =>
|
||||
Array.isArray(models)
|
||||
? models
|
||||
.map((m) => (m.alias && m.alias !== m.name ? `${m.name}, ${m.alias}` : m.name))
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
: '';
|
||||
|
||||
const parseExcludedModels = (text: string): string[] =>
|
||||
text
|
||||
.split(/[\n,]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const excludedModelsToText = (models?: string[]) => (Array.isArray(models) ? models.join('\n') : '');
|
||||
|
||||
const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({
|
||||
apiKey: input?.apiKey ?? '',
|
||||
proxyUrl: input?.proxyUrl ?? '',
|
||||
headers: input?.headers ?? {}
|
||||
});
|
||||
|
||||
export function AiProvidersPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [geminiKeys, setGeminiKeys] = useState<GeminiKeyConfig[]>([]);
|
||||
const [codexConfigs, setCodexConfigs] = useState<ProviderKeyConfig[]>([]);
|
||||
const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>([]);
|
||||
const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>([]);
|
||||
|
||||
const [modal, setModal] = useState<ProviderModal | null>(null);
|
||||
|
||||
const [geminiForm, setGeminiForm] = useState<GeminiKeyConfig & { excludedText: string }>({
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
headers: {},
|
||||
excludedModels: [],
|
||||
excludedText: ''
|
||||
});
|
||||
const [providerForm, setProviderForm] = useState<ProviderKeyConfig & { modelsText: string }>({
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
proxyUrl: '',
|
||||
headers: {},
|
||||
models: [],
|
||||
modelsText: ''
|
||||
});
|
||||
const [openaiForm, setOpenaiForm] = useState<OpenAIFormState>({
|
||||
name: '',
|
||||
baseUrl: '',
|
||||
headers: [],
|
||||
apiKeyEntries: [buildApiKeyEntry()],
|
||||
modelsText: ''
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const disableControls = useMemo(() => connectionStatus !== 'connected', [connectionStatus]);
|
||||
|
||||
const loadConfigs = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await fetchConfig(undefined, true);
|
||||
setGeminiKeys(data?.geminiApiKeys || []);
|
||||
setCodexConfigs(data?.codexApiKeys || []);
|
||||
setClaudeConfigs(data?.claudeApiKeys || []);
|
||||
setOpenaiProviders(data?.openaiCompatibility || []);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || t('notification.refresh_failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigs();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys);
|
||||
if (config?.codexApiKeys) setCodexConfigs(config.codexApiKeys);
|
||||
if (config?.claudeApiKeys) setClaudeConfigs(config.claudeApiKeys);
|
||||
if (config?.openaiCompatibility) setOpenaiProviders(config.openaiCompatibility);
|
||||
}, [config?.geminiApiKeys, config?.codexApiKeys, config?.claudeApiKeys, config?.openaiCompatibility]);
|
||||
|
||||
const closeModal = () => {
|
||||
setModal(null);
|
||||
setGeminiForm({
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
headers: {},
|
||||
excludedModels: [],
|
||||
excludedText: ''
|
||||
});
|
||||
setProviderForm({
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
proxyUrl: '',
|
||||
headers: {},
|
||||
models: [],
|
||||
modelsText: ''
|
||||
});
|
||||
setOpenaiForm({
|
||||
name: '',
|
||||
baseUrl: '',
|
||||
headers: [],
|
||||
apiKeyEntries: [buildApiKeyEntry()],
|
||||
modelsText: '',
|
||||
priority: undefined,
|
||||
testModel: undefined
|
||||
});
|
||||
};
|
||||
|
||||
const openGeminiModal = (index: number | null) => {
|
||||
if (index !== null) {
|
||||
const entry = geminiKeys[index];
|
||||
setGeminiForm({
|
||||
...entry,
|
||||
excludedText: excludedModelsToText(entry?.excludedModels)
|
||||
});
|
||||
}
|
||||
setModal({ type: 'gemini', index });
|
||||
};
|
||||
|
||||
const openProviderModal = (type: 'codex' | 'claude', index: number | null) => {
|
||||
const source = type === 'codex' ? codexConfigs : claudeConfigs;
|
||||
if (index !== null) {
|
||||
const entry = source[index];
|
||||
setProviderForm({
|
||||
...entry,
|
||||
modelsText: modelsToText(entry?.models)
|
||||
});
|
||||
}
|
||||
setModal({ type, index });
|
||||
};
|
||||
|
||||
const openOpenaiModal = (index: number | null) => {
|
||||
if (index !== null) {
|
||||
const entry = openaiProviders[index];
|
||||
setOpenaiForm({
|
||||
name: entry.name,
|
||||
baseUrl: entry.baseUrl,
|
||||
headers: headersToEntries(entry.headers),
|
||||
priority: entry.priority,
|
||||
testModel: entry.testModel,
|
||||
modelsText: modelsToText(entry.models),
|
||||
apiKeyEntries: entry.apiKeyEntries?.length ? entry.apiKeyEntries : [buildApiKeyEntry()]
|
||||
});
|
||||
}
|
||||
setModal({ type: 'openai', index });
|
||||
};
|
||||
|
||||
const saveGemini = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: GeminiKeyConfig = {
|
||||
apiKey: geminiForm.apiKey.trim(),
|
||||
baseUrl: geminiForm.baseUrl?.trim() || undefined,
|
||||
headers: buildHeaderObject(headersToEntries(geminiForm.headers as any)),
|
||||
excludedModels: parseExcludedModels(geminiForm.excludedText)
|
||||
};
|
||||
const nextList =
|
||||
modal?.type === 'gemini' && modal.index !== null
|
||||
? geminiKeys.map((item, idx) => (idx === modal.index ? payload : item))
|
||||
: [...geminiKeys, payload];
|
||||
|
||||
await providersApi.saveGeminiKeys(nextList);
|
||||
setGeminiKeys(nextList);
|
||||
updateConfigValue('gemini-api-key', nextList);
|
||||
clearCache('gemini-api-key');
|
||||
const message =
|
||||
modal?.index !== null ? t('notification.gemini_key_updated') : t('notification.gemini_key_added');
|
||||
showNotification(message, 'success');
|
||||
closeModal();
|
||||
} catch (err: any) {
|
||||
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGemini = async (apiKey: string) => {
|
||||
if (!window.confirm(t('ai_providers.gemini_delete_confirm'))) return;
|
||||
try {
|
||||
await providersApi.deleteGeminiKey(apiKey);
|
||||
const next = geminiKeys.filter((item) => item.apiKey !== apiKey);
|
||||
setGeminiKeys(next);
|
||||
updateConfigValue('gemini-api-key', next);
|
||||
clearCache('gemini-api-key');
|
||||
showNotification(t('notification.gemini_key_deleted'), 'success');
|
||||
} catch (err: any) {
|
||||
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const saveProvider = async (type: 'codex' | 'claude') => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: ProviderKeyConfig = {
|
||||
apiKey: providerForm.apiKey.trim(),
|
||||
baseUrl: providerForm.baseUrl?.trim() || undefined,
|
||||
proxyUrl: providerForm.proxyUrl?.trim() || undefined,
|
||||
headers: buildHeaderObject(headersToEntries(providerForm.headers as any)),
|
||||
models: parseModelsText(providerForm.modelsText)
|
||||
};
|
||||
|
||||
const source = type === 'codex' ? codexConfigs : claudeConfigs;
|
||||
const nextList =
|
||||
modal?.type === type && modal.index !== null
|
||||
? source.map((item, idx) => (idx === modal.index ? payload : item))
|
||||
: [...source, payload];
|
||||
|
||||
if (type === 'codex') {
|
||||
await providersApi.saveCodexConfigs(nextList);
|
||||
setCodexConfigs(nextList);
|
||||
updateConfigValue('codex-api-key', nextList);
|
||||
clearCache('codex-api-key');
|
||||
const message =
|
||||
modal?.index !== null ? t('notification.codex_config_updated') : t('notification.codex_config_added');
|
||||
showNotification(message, 'success');
|
||||
} else {
|
||||
await providersApi.saveClaudeConfigs(nextList);
|
||||
setClaudeConfigs(nextList);
|
||||
updateConfigValue('claude-api-key', nextList);
|
||||
clearCache('claude-api-key');
|
||||
const message =
|
||||
modal?.index !== null ? t('notification.claude_config_updated') : t('notification.claude_config_added');
|
||||
showNotification(message, 'success');
|
||||
}
|
||||
|
||||
closeModal();
|
||||
} catch (err: any) {
|
||||
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteProviderEntry = async (type: 'codex' | 'claude', apiKey: string) => {
|
||||
if (!window.confirm(t(`ai_providers.${type}_delete_confirm` as any))) return;
|
||||
try {
|
||||
if (type === 'codex') {
|
||||
await providersApi.deleteCodexConfig(apiKey);
|
||||
const next = codexConfigs.filter((item) => item.apiKey !== apiKey);
|
||||
setCodexConfigs(next);
|
||||
updateConfigValue('codex-api-key', next);
|
||||
clearCache('codex-api-key');
|
||||
showNotification(t('notification.codex_config_deleted'), 'success');
|
||||
} else {
|
||||
await providersApi.deleteClaudeConfig(apiKey);
|
||||
const next = claudeConfigs.filter((item) => item.apiKey !== apiKey);
|
||||
setClaudeConfigs(next);
|
||||
updateConfigValue('claude-api-key', next);
|
||||
clearCache('claude-api-key');
|
||||
showNotification(t('notification.claude_config_deleted'), 'success');
|
||||
}
|
||||
} catch (err: any) {
|
||||
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const saveOpenai = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: OpenAIProviderConfig = {
|
||||
name: openaiForm.name.trim(),
|
||||
baseUrl: openaiForm.baseUrl.trim(),
|
||||
headers: buildHeaderObject(openaiForm.headers),
|
||||
apiKeyEntries: openaiForm.apiKeyEntries.map((entry) => ({
|
||||
apiKey: entry.apiKey.trim(),
|
||||
proxyUrl: entry.proxyUrl?.trim() || undefined,
|
||||
headers: entry.headers
|
||||
}))
|
||||
};
|
||||
if (openaiForm.priority !== undefined) payload.priority = openaiForm.priority;
|
||||
if (openaiForm.testModel) payload.testModel = openaiForm.testModel.trim();
|
||||
const models = parseModelsText(openaiForm.modelsText);
|
||||
if (models.length) payload.models = models;
|
||||
|
||||
const nextList =
|
||||
modal?.type === 'openai' && modal.index !== null
|
||||
? openaiProviders.map((item, idx) => (idx === modal.index ? payload : item))
|
||||
: [...openaiProviders, payload];
|
||||
|
||||
await providersApi.saveOpenAIProviders(nextList);
|
||||
setOpenaiProviders(nextList);
|
||||
updateConfigValue('openai-compatibility', nextList);
|
||||
clearCache('openai-compatibility');
|
||||
const message =
|
||||
modal?.index !== null ? t('notification.openai_provider_updated') : t('notification.openai_provider_added');
|
||||
showNotification(message, 'success');
|
||||
closeModal();
|
||||
} catch (err: any) {
|
||||
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteOpenai = async (name: string) => {
|
||||
if (!window.confirm(t('ai_providers.openai_delete_confirm'))) return;
|
||||
try {
|
||||
await providersApi.deleteOpenAIProvider(name);
|
||||
const next = openaiProviders.filter((item) => item.name !== name);
|
||||
setOpenaiProviders(next);
|
||||
updateConfigValue('openai-compatibility', next);
|
||||
clearCache('openai-compatibility');
|
||||
showNotification(t('notification.openai_provider_deleted'), 'success');
|
||||
} catch (err: any) {
|
||||
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const renderKeyEntries = (entries: ApiKeyEntry[]) => {
|
||||
const list = entries.length ? entries : [buildApiKeyEntry()];
|
||||
const updateEntry = (idx: number, field: keyof ApiKeyEntry, value: string) => {
|
||||
const next = list.map((entry, i) => (i === idx ? { ...entry, [field]: value } : entry));
|
||||
setOpenaiForm((prev) => ({ ...prev, apiKeyEntries: next }));
|
||||
};
|
||||
|
||||
const removeEntry = (idx: number) => {
|
||||
const next = list.filter((_, i) => i !== idx);
|
||||
setOpenaiForm((prev) => ({ ...prev, apiKeyEntries: next.length ? next : [buildApiKeyEntry()] }));
|
||||
};
|
||||
|
||||
const addEntry = () => {
|
||||
setOpenaiForm((prev) => ({ ...prev, apiKeyEntries: [...list, buildApiKeyEntry()] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
{list.map((entry, index) => (
|
||||
<div key={index} className="item-row">
|
||||
<div className="item-meta">
|
||||
<Input
|
||||
label={`${t('common.api_key')} #${index + 1}`}
|
||||
value={entry.apiKey}
|
||||
onChange={(e) => updateEntry(index, 'apiKey', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t('common.proxy_url')}
|
||||
value={entry.proxyUrl ?? ''}
|
||||
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="item-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeEntry(index)}
|
||||
disabled={list.length <= 1 || saving}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="secondary" size="sm" onClick={addEntry} disabled={saving}>
|
||||
{t('ai_providers.openai_keys_add_btn')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderList = <T,>(
|
||||
items: T[],
|
||||
keyField: (item: T) => string,
|
||||
renderContent: (item: T, index: number) => ReactNode,
|
||||
onEdit: (index: number) => void,
|
||||
onDelete: (item: T) => void,
|
||||
addLabel: string,
|
||||
deleteLabel?: string
|
||||
) => {
|
||||
if (loading) {
|
||||
return <div className="hint">{t('common.loading')}</div>;
|
||||
}
|
||||
|
||||
if (!items.length) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t('common.info')}
|
||||
description={t('ai_providers.gemini_empty_desc')}
|
||||
action={
|
||||
<Button onClick={() => onEdit(-1)} disabled={disableControls}>
|
||||
{addLabel}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="item-list">
|
||||
{items.map((item, index) => (
|
||||
<div key={keyField(item)} className="item-row">
|
||||
<div className="item-meta">{renderContent(item, index)}</div>
|
||||
<div className="item-actions">
|
||||
<Button variant="secondary" size="sm" onClick={() => onEdit(index)} disabled={disableControls}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => onDelete(item)} disabled={disableControls}>
|
||||
{deleteLabel || t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
|
||||
<Card
|
||||
title={t('ai_providers.gemini_title')}
|
||||
extra={
|
||||
<Button size="sm" onClick={() => openGeminiModal(null)} disabled={disableControls}>
|
||||
{t('ai_providers.gemini_add_button')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{renderList<GeminiKeyConfig>(
|
||||
geminiKeys,
|
||||
(item) => item.apiKey,
|
||||
(item, index) => (
|
||||
<Fragment>
|
||||
<div className="item-title">
|
||||
{t('ai_providers.gemini_item_title')} #{index + 1}
|
||||
</div>
|
||||
<div className="item-subtitle">{maskApiKey(item.apiKey)}</div>
|
||||
{item.baseUrl && <div className="pill">{item.baseUrl}</div>}
|
||||
{item.excludedModels?.length ? (
|
||||
<div className="item-subtitle">
|
||||
{t('ai_providers.excluded_models_count', { count: item.excludedModels.length })}
|
||||
</div>
|
||||
) : null}
|
||||
</Fragment>
|
||||
),
|
||||
(index) => openGeminiModal(index),
|
||||
(item) => deleteGemini(item.apiKey),
|
||||
t('ai_providers.gemini_add_button')
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={t('ai_providers.codex_title')}
|
||||
extra={
|
||||
<Button size="sm" onClick={() => openProviderModal('codex', null)} disabled={disableControls}>
|
||||
{t('ai_providers.codex_add_button')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{renderList<ProviderKeyConfig>(
|
||||
codexConfigs,
|
||||
(item) => item.apiKey,
|
||||
(item) => (
|
||||
<Fragment>
|
||||
<div className="item-title">{item.baseUrl || t('ai_providers.codex_item_title')}</div>
|
||||
<div className="item-subtitle">{maskApiKey(item.apiKey)}</div>
|
||||
{item.proxyUrl && <div className="pill">{item.proxyUrl}</div>}
|
||||
</Fragment>
|
||||
),
|
||||
(index) => openProviderModal('codex', index),
|
||||
(item) => deleteProviderEntry('codex', item.apiKey),
|
||||
t('ai_providers.codex_add_button')
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={t('ai_providers.claude_title')}
|
||||
extra={
|
||||
<Button size="sm" onClick={() => openProviderModal('claude', null)} disabled={disableControls}>
|
||||
{t('ai_providers.claude_add_button')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{renderList<ProviderKeyConfig>(
|
||||
claudeConfigs,
|
||||
(item) => item.apiKey,
|
||||
(item) => (
|
||||
<Fragment>
|
||||
<div className="item-title">{item.baseUrl || t('ai_providers.claude_item_title')}</div>
|
||||
<div className="item-subtitle">{maskApiKey(item.apiKey)}</div>
|
||||
{item.proxyUrl && <div className="pill">{item.proxyUrl}</div>}
|
||||
{item.models?.length ? (
|
||||
<div className="item-subtitle">
|
||||
{t('ai_providers.claude_models_count')}: {item.models.length}
|
||||
</div>
|
||||
) : null}
|
||||
</Fragment>
|
||||
),
|
||||
(index) => openProviderModal('claude', index),
|
||||
(item) => deleteProviderEntry('claude', item.apiKey),
|
||||
t('ai_providers.claude_add_button')
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={t('ai_providers.openai_title')}
|
||||
extra={
|
||||
<Button size="sm" onClick={() => openOpenaiModal(null)} disabled={disableControls}>
|
||||
{t('ai_providers.openai_add_button')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{renderList<OpenAIProviderConfig>(
|
||||
openaiProviders,
|
||||
(item) => item.name,
|
||||
(item) => (
|
||||
<Fragment>
|
||||
<div className="item-title">{item.name}</div>
|
||||
<div className="item-subtitle">{item.baseUrl}</div>
|
||||
<div className="pill">
|
||||
{t('ai_providers.openai_keys_count')}: {item.apiKeyEntries?.length || 0}
|
||||
</div>
|
||||
<div className="pill">
|
||||
{t('ai_providers.openai_models_count')}: {item.models?.length || 0}
|
||||
</div>
|
||||
{item.priority !== undefined && <div className="pill">Priority: {item.priority}</div>}
|
||||
{item.testModel && <div className="pill">{item.testModel}</div>}
|
||||
</Fragment>
|
||||
),
|
||||
(index) => openOpenaiModal(index),
|
||||
(item) => deleteOpenai(item.name),
|
||||
t('ai_providers.openai_add_button'),
|
||||
t('ai_providers.openai_delete_confirm')
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Gemini Modal */}
|
||||
<Modal
|
||||
open={modal?.type === 'gemini'}
|
||||
onClose={closeModal}
|
||||
title={
|
||||
modal?.index !== null ? t('ai_providers.gemini_edit_modal_title') : t('ai_providers.gemini_add_modal_title')
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={closeModal} disabled={saving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={saveGemini} loading={saving}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={t('ai_providers.gemini_add_modal_key_label')}
|
||||
placeholder={t('ai_providers.gemini_add_modal_key_placeholder')}
|
||||
value={geminiForm.apiKey}
|
||||
onChange={(e) => setGeminiForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.gemini_base_url_placeholder')}
|
||||
placeholder={t('ai_providers.gemini_base_url_placeholder')}
|
||||
value={geminiForm.baseUrl ?? ''}
|
||||
onChange={(e) => setGeminiForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||
/>
|
||||
<HeaderInputList
|
||||
entries={headersToEntries(geminiForm.headers as any)}
|
||||
onChange={(entries) => setGeminiForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))}
|
||||
addLabel={t('common.custom_headers_add')}
|
||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
||||
value={geminiForm.excludedText}
|
||||
onChange={(e) => setGeminiForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
||||
rows={4}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Codex / Claude Modal */}
|
||||
<Modal
|
||||
open={modal?.type === 'codex' || modal?.type === 'claude'}
|
||||
onClose={closeModal}
|
||||
title={
|
||||
modal?.type === 'codex'
|
||||
? modal.index !== null
|
||||
? t('ai_providers.codex_edit_modal_title')
|
||||
: t('ai_providers.codex_add_modal_title')
|
||||
: modal?.type === 'claude' && modal.index !== null
|
||||
? t('ai_providers.claude_edit_modal_title')
|
||||
: t('ai_providers.claude_add_modal_title')
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={closeModal} disabled={saving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => saveProvider(modal?.type as 'codex' | 'claude')} loading={saving}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={
|
||||
modal?.type === 'codex'
|
||||
? t('ai_providers.codex_add_modal_key_label')
|
||||
: t('ai_providers.claude_add_modal_key_label')
|
||||
}
|
||||
value={providerForm.apiKey}
|
||||
onChange={(e) => setProviderForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label={
|
||||
modal?.type === 'codex'
|
||||
? t('ai_providers.codex_add_modal_url_label')
|
||||
: t('ai_providers.claude_add_modal_url_label')
|
||||
}
|
||||
value={providerForm.baseUrl ?? ''}
|
||||
onChange={(e) => setProviderForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label={
|
||||
modal?.type === 'codex'
|
||||
? t('ai_providers.codex_add_modal_proxy_label')
|
||||
: t('ai_providers.claude_add_modal_proxy_label')
|
||||
}
|
||||
value={providerForm.proxyUrl ?? ''}
|
||||
onChange={(e) => setProviderForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||
/>
|
||||
<HeaderInputList
|
||||
entries={headersToEntries(providerForm.headers as any)}
|
||||
onChange={(entries) => setProviderForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))}
|
||||
addLabel={t('common.custom_headers_add')}
|
||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.claude_models_label')}</label>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder={t('ai_providers.claude_models_hint')}
|
||||
value={providerForm.modelsText}
|
||||
onChange={(e) => setProviderForm((prev) => ({ ...prev, modelsText: e.target.value }))}
|
||||
rows={4}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.claude_models_hint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* OpenAI Modal */}
|
||||
<Modal
|
||||
open={modal?.type === 'openai'}
|
||||
onClose={closeModal}
|
||||
title={
|
||||
modal?.index !== null ? t('ai_providers.openai_edit_modal_title') : t('ai_providers.openai_add_modal_title')
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={closeModal} disabled={saving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={saveOpenai} loading={saving}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={t('ai_providers.openai_add_modal_name_label')}
|
||||
value={openaiForm.name}
|
||||
onChange={(e) => setOpenaiForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.openai_add_modal_url_label')}
|
||||
value={openaiForm.baseUrl}
|
||||
onChange={(e) => setOpenaiForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label="Priority"
|
||||
type="number"
|
||||
value={openaiForm.priority ?? ''}
|
||||
onChange={(e) =>
|
||||
setOpenaiForm((prev) => ({ ...prev, priority: e.target.value ? Number(e.target.value) : undefined }))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.openai_test_model_placeholder')}
|
||||
value={openaiForm.testModel ?? ''}
|
||||
onChange={(e) => setOpenaiForm((prev) => ({ ...prev, testModel: e.target.value }))}
|
||||
/>
|
||||
|
||||
<HeaderInputList
|
||||
entries={openaiForm.headers}
|
||||
onChange={(entries) => setOpenaiForm((prev) => ({ ...prev, headers: entries }))}
|
||||
addLabel={t('common.custom_headers_add')}
|
||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.openai_models_fetch_title')}</label>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder={t('ai_providers.openai_models_hint')}
|
||||
value={openaiForm.modelsText}
|
||||
onChange={(e) => setOpenaiForm((prev) => ({ ...prev, modelsText: e.target.value }))}
|
||||
rows={4}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.openai_models_hint')}</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
|
||||
{renderKeyEntries(openaiForm.apiKeyEntries)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/pages/ApiKeysPage.module.scss
Normal file
54
src/pages/ApiKeysPage.module.scss
Normal file
@@ -0,0 +1,54 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 $spacing-xl 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
text-align: center;
|
||||
padding: $spacing-2xl;
|
||||
color: var(--text-secondary);
|
||||
|
||||
i {
|
||||
font-size: 48px;
|
||||
margin-bottom: $spacing-md;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 $spacing-sm 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
217
src/pages/ApiKeysPage.tsx
Normal file
217
src/pages/ApiKeysPage.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useCallback, useEffect, useMemo, useState } 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 { Modal } from '@/components/ui/Modal';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import { apiKeysApi } from '@/services/api';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
|
||||
export function ApiKeysPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
|
||||
const [apiKeys, setApiKeys] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
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]);
|
||||
|
||||
const loadApiKeys = useCallback(
|
||||
async (force = false) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = (await fetchConfig('api-keys', force)) as string[] | undefined;
|
||||
const list = Array.isArray(result) ? result : [];
|
||||
setApiKeys(list);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || t('notification.refresh_failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[fetchConfig, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadApiKeys(true);
|
||||
}, [loadApiKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Array.isArray(config?.apiKeys)) {
|
||||
setApiKeys(config.apiKeys);
|
||||
}
|
||||
}, [config?.apiKeys]);
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingIndex(null);
|
||||
setInputValue('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (index: number) => {
|
||||
setEditingIndex(index);
|
||||
setInputValue(apiKeys[index] ?? '');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalOpen(false);
|
||||
setInputValue('');
|
||||
setEditingIndex(null);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmed = inputValue.trim();
|
||||
if (!trimmed) {
|
||||
showNotification(`${t('notification.please_enter')} ${t('notification.api_key')}`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const isEdit = editingIndex !== null;
|
||||
const nextKeys = isEdit
|
||||
? apiKeys.map((key, idx) => (idx === editingIndex ? trimmed : key))
|
||||
: [...apiKeys, trimmed];
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit && editingIndex !== null) {
|
||||
await apiKeysApi.update(editingIndex, trimmed);
|
||||
showNotification(t('notification.api_key_updated'), 'success');
|
||||
} else {
|
||||
await apiKeysApi.replace(nextKeys);
|
||||
showNotification(t('notification.api_key_added'), 'success');
|
||||
}
|
||||
|
||||
setApiKeys(nextKeys);
|
||||
updateConfigValue('api-keys', nextKeys);
|
||||
clearCache('api-keys');
|
||||
closeModal();
|
||||
} catch (err: any) {
|
||||
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 actionButtons = (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button variant="secondary" size="sm" onClick={() => loadApiKeys(true)} disabled={loading}>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={openAddModal} disabled={disableControls}>
|
||||
{t('api_keys.add_button')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card title={t('api_keys.proxy_auth_title')} extra={actionButtons}>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex-center" style={{ padding: '24px 0' }}>
|
||||
<LoadingSpinner size={28} />
|
||||
</div>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('api_keys.empty_title')}
|
||||
description={t('api_keys.empty_desc')}
|
||||
action={
|
||||
<Button onClick={openAddModal} disabled={disableControls}>
|
||||
{t('api_keys.add_button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="item-list">
|
||||
{apiKeys.map((key, index) => (
|
||||
<div key={index} className="item-row">
|
||||
<div className="item-meta">
|
||||
<div className="pill">#{index + 1}</div>
|
||||
<div className="item-title">{t('api_keys.item_title')}</div>
|
||||
<div className="item-subtitle">{maskApiKey(String(key || ''))}</div>
|
||||
</div>
|
||||
<div className="item-actions">
|
||||
<Button variant="secondary" size="sm" onClick={() => openEditModal(index)} disabled={disableControls}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(index)}
|
||||
disabled={disableControls || deletingIndex === index}
|
||||
loading={deletingIndex === index}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
onClose={closeModal}
|
||||
title={editingIndex !== null ? t('api_keys.edit_modal_title') : t('api_keys.add_modal_title')}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={closeModal} disabled={saving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} loading={saving}>
|
||||
{editingIndex !== null ? t('common.update') : t('common.add')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={
|
||||
editingIndex !== null ? t('api_keys.edit_modal_key_label') : t('api_keys.add_modal_key_label')
|
||||
}
|
||||
placeholder={
|
||||
editingIndex !== null
|
||||
? t('api_keys.edit_modal_key_label')
|
||||
: t('api_keys.add_modal_key_placeholder')
|
||||
}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
58
src/pages/AuthFilesPage.module.scss
Normal file
58
src/pages/AuthFilesPage.module.scss
Normal file
@@ -0,0 +1,58 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 $spacing-md 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 $spacing-xl 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fileGrid {
|
||||
display: grid;
|
||||
gap: $spacing-md;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
margin-top: $spacing-lg;
|
||||
}
|
||||
416
src/pages/AuthFilesPage.tsx
Normal file
416
src/pages/AuthFilesPage.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
import { useEffect, useMemo, useRef, useState } 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 { Modal } from '@/components/ui/Modal';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { useAuthStore, useNotificationStore } from '@/stores';
|
||||
import { authFilesApi } from '@/services/api';
|
||||
import { apiClient } from '@/services/api/client';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import { formatFileSize } from '@/utils/format';
|
||||
|
||||
interface ExcludedFormState {
|
||||
provider: string;
|
||||
modelsText: string;
|
||||
}
|
||||
|
||||
export function AuthFilesPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
|
||||
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [filter, setFilter] = useState<'all' | string>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
|
||||
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
|
||||
const [excludedModalOpen, setExcludedModalOpen] = useState(false);
|
||||
const [excludedForm, setExcludedForm] = useState<ExcludedFormState>({ provider: '', modelsText: '' });
|
||||
const [savingExcluded, setSavingExcluded] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
|
||||
const loadFiles = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await authFilesApi.list();
|
||||
setFiles(data?.files || []);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || t('notification.refresh_failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadExcluded = async () => {
|
||||
try {
|
||||
const res = await authFilesApi.getOauthExcludedModels();
|
||||
setExcluded(res || {});
|
||||
} catch (err) {
|
||||
// ignore silently
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadFiles();
|
||||
loadExcluded();
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return files.filter((item) => {
|
||||
const matchType = filter === 'all' || item.type === filter;
|
||||
const term = search.trim().toLowerCase();
|
||||
const matchSearch =
|
||||
!term ||
|
||||
item.name.toLowerCase().includes(term) ||
|
||||
(item.type || '').toString().toLowerCase().includes(term) ||
|
||||
(item.provider || '').toString().toLowerCase().includes(term);
|
||||
return matchType && matchSearch;
|
||||
});
|
||||
}, [files, filter, search]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
const pageItems = filtered.slice(start, start + pageSize);
|
||||
|
||||
const totalSize = useMemo(() => files.reduce((sum, item) => sum + (item.size || 0), 0), [files]);
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
await authFilesApi.upload(file);
|
||||
showNotification(t('auth_files.upload_success'), 'success');
|
||||
await loadFiles();
|
||||
} catch (err: any) {
|
||||
showNotification(`${t('notification.upload_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (name: string) => {
|
||||
if (!window.confirm(t('auth_files.delete_confirm'))) return;
|
||||
setDeleting(name);
|
||||
try {
|
||||
await authFilesApi.deleteFile(name);
|
||||
showNotification(t('auth_files.delete_success'), 'success');
|
||||
setFiles((prev) => prev.filter((item) => item.name !== name));
|
||||
} catch (err: any) {
|
||||
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAll = async () => {
|
||||
if (!window.confirm(t('auth_files.delete_all_confirm'))) return;
|
||||
try {
|
||||
await authFilesApi.deleteAll();
|
||||
showNotification(t('auth_files.delete_all_success'), 'success');
|
||||
setFiles([]);
|
||||
} catch (err: any) {
|
||||
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (name: string) => {
|
||||
try {
|
||||
const response = await apiClient.getRaw(`/auth-files/${encodeURIComponent(name)}`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
const blob = new Blob([response.data]);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
showNotification(t('auth_files.download_success'), 'success');
|
||||
} catch (err: any) {
|
||||
showNotification(`${t('notification.download_failed')}: ${err?.message || ''}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const openExcludedModal = (provider?: string) => {
|
||||
const models = provider ? excluded[provider] : [];
|
||||
setExcludedForm({
|
||||
provider: provider || '',
|
||||
modelsText: Array.isArray(models) ? models.join('\n') : ''
|
||||
});
|
||||
setExcludedModalOpen(true);
|
||||
};
|
||||
|
||||
const saveExcludedModels = async () => {
|
||||
const provider = excludedForm.provider.trim();
|
||||
if (!provider) {
|
||||
showNotification(t('oauth_excluded.provider_required'), 'error');
|
||||
return;
|
||||
}
|
||||
const models = excludedForm.modelsText
|
||||
.split(/[\n,]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
setSavingExcluded(true);
|
||||
try {
|
||||
if (models.length) {
|
||||
await authFilesApi.saveOauthExcludedModels(provider, models);
|
||||
} else {
|
||||
await authFilesApi.deleteOauthExcludedEntry(provider);
|
||||
}
|
||||
await loadExcluded();
|
||||
showNotification(t('oauth_excluded.save_success'), 'success');
|
||||
setExcludedModalOpen(false);
|
||||
} catch (err: any) {
|
||||
showNotification(`${t('oauth_excluded.save_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setSavingExcluded(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteExcluded = async (provider: string) => {
|
||||
if (!window.confirm(t('oauth_excluded.delete_confirm', { provider }))) return;
|
||||
try {
|
||||
await authFilesApi.deleteOauthExcludedEntry(provider);
|
||||
await loadExcluded();
|
||||
showNotification(t('oauth_excluded.delete_success'), 'success');
|
||||
} catch (err: any) {
|
||||
showNotification(`${t('oauth_excluded.delete_failed')}: ${err?.message || ''}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const typeOptions: { value: string; label: string }[] = [
|
||||
{ value: 'all', label: t('auth_files.filter_all') },
|
||||
{ value: 'qwen', label: t('auth_files.filter_qwen') },
|
||||
{ value: 'gemini', label: t('auth_files.filter_gemini') },
|
||||
{ value: 'gemini-cli', label: t('auth_files.filter_gemini-cli') },
|
||||
{ value: 'aistudio', label: t('auth_files.filter_aistudio') },
|
||||
{ value: 'claude', label: t('auth_files.filter_claude') },
|
||||
{ value: 'codex', label: t('auth_files.filter_codex') },
|
||||
{ value: 'antigravity', label: t('auth_files.filter_antigravity') },
|
||||
{ value: 'iflow', label: t('auth_files.filter_iflow') },
|
||||
{ value: 'vertex', label: t('auth_files.filter_vertex') },
|
||||
{ value: 'empty', label: t('auth_files.filter_empty') },
|
||||
{ value: 'unknown', label: t('auth_files.filter_unknown') }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<Card
|
||||
title={t('auth_files.title')}
|
||||
extra={
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Button variant="secondary" size="sm" onClick={loadFiles} disabled={loading}>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={handleDeleteAll} disabled={disableControls || loading}>
|
||||
{t('auth_files.delete_all_button')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleUploadClick} disabled={disableControls || uploading}>
|
||||
{t('auth_files.upload_button')}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
|
||||
<div className="filters">
|
||||
<div className="filter-item">
|
||||
<label>{t('auth_files.search_label')}</label>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('auth_files.search_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="filter-item">
|
||||
<label>{t('auth_files.page_size_label')}</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
value={pageSize}
|
||||
onChange={(e) => setPageSize(Number(e.target.value) || 10)}
|
||||
/>
|
||||
</div>
|
||||
<div className="filter-item">
|
||||
<label>{t('common.info')}</label>
|
||||
<div className="pill">
|
||||
{files.length} {t('auth_files.files_count')} · {formatFileSize(totalSize)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="filter-item">
|
||||
<label>{t('auth_files.filter_all')}</label>
|
||||
<select className="input" value={filter} onChange={(e) => setFilter(e.target.value)}>
|
||||
{typeOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="hint">{t('common.loading')}</div>
|
||||
) : pageItems.length === 0 ? (
|
||||
<EmptyState title={t('auth_files.search_empty_title')} description={t('auth_files.search_empty_desc')} />
|
||||
) : (
|
||||
<div className="table">
|
||||
<div className="table-header">
|
||||
<div>{t('auth_files.title_section')}</div>
|
||||
<div>{t('auth_files.file_size')}</div>
|
||||
<div>{t('auth_files.file_modified')}</div>
|
||||
<div>Actions</div>
|
||||
</div>
|
||||
{pageItems.map((item) => (
|
||||
<div key={item.name} className="table-row">
|
||||
<div className="cell">
|
||||
<div className="item-title">{item.name}</div>
|
||||
<div className="item-subtitle">
|
||||
{item.type || t('auth_files.type_unknown')} {item.provider ? `· ${item.provider}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="cell">{item.size ? formatFileSize(item.size) : '-'}</div>
|
||||
<div className="cell">
|
||||
{item.modified ? new Date(item.modified).toLocaleString() : t('auth_files.file_modified')}
|
||||
</div>
|
||||
<div className="cell">
|
||||
<div className="item-actions">
|
||||
<Button variant="secondary" size="sm" onClick={() => handleDownload(item.name)} disabled={disableControls}>
|
||||
{t('auth_files.download_button')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(item.name)}
|
||||
loading={deleting === item.name}
|
||||
disabled={disableControls}
|
||||
>
|
||||
{t('auth_files.delete_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pagination">
|
||||
<Button variant="secondary" size="sm" onClick={() => setPage(Math.max(1, currentPage - 1))}>
|
||||
{t('auth_files.pagination_prev')}
|
||||
</Button>
|
||||
<div className="pill">
|
||||
{t('auth_files.pagination_info', {
|
||||
current: currentPage,
|
||||
total: totalPages,
|
||||
count: filtered.length
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage(Math.min(totalPages, currentPage + 1))}
|
||||
>
|
||||
{t('auth_files.pagination_next')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={t('oauth_excluded.title')}
|
||||
extra={
|
||||
<Button size="sm" onClick={() => openExcludedModal()} disabled={disableControls}>
|
||||
{t('oauth_excluded.add')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{Object.keys(excluded).length === 0 ? (
|
||||
<EmptyState title={t('oauth_excluded.list_empty_all')} />
|
||||
) : (
|
||||
<div className="item-list">
|
||||
{Object.entries(excluded).map(([provider, models]) => (
|
||||
<div key={provider} className="item-row">
|
||||
<div className="item-meta">
|
||||
<div className="item-title">{provider}</div>
|
||||
<div className="item-subtitle">
|
||||
{models?.length
|
||||
? t('oauth_excluded.model_count', { count: models.length })
|
||||
: t('oauth_excluded.no_models')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="item-actions">
|
||||
<Button variant="secondary" size="sm" onClick={() => openExcludedModal(provider)}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => deleteExcluded(provider)}>
|
||||
{t('oauth_excluded.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={excludedModalOpen}
|
||||
onClose={() => setExcludedModalOpen(false)}
|
||||
title={t('oauth_excluded.add_title')}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setExcludedModalOpen(false)} disabled={savingExcluded}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={saveExcludedModels} loading={savingExcluded}>
|
||||
{t('oauth_excluded.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={t('oauth_excluded.provider_label')}
|
||||
placeholder={t('oauth_excluded.provider_placeholder')}
|
||||
value={excludedForm.provider}
|
||||
onChange={(e) => setExcludedForm((prev) => ({ ...prev, provider: e.target.value }))}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t('oauth_excluded.models_label')}</label>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={4}
|
||||
placeholder={t('oauth_excluded.models_placeholder')}
|
||||
value={excludedForm.modelsText}
|
||||
onChange={(e) => setExcludedForm((prev) => ({ ...prev, modelsText: e.target.value }))}
|
||||
/>
|
||||
<div className="hint">{t('oauth_excluded.models_hint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
src/pages/ConfigPage.module.scss
Normal file
65
src/pages/ConfigPage.module.scss
Normal file
@@ -0,0 +1,65 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 $spacing-md 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 $spacing-xl 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
&.modified {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
&.saved {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.editorWrapper {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-lg;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
86
src/pages/ConfigPage.tsx
Normal file
86
src/pages/ConfigPage.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useNotificationStore, useAuthStore } from '@/stores';
|
||||
import { configFileApi } from '@/services/api/configFile';
|
||||
|
||||
export function ConfigPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
|
||||
const loadConfig = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await configFileApi.fetchConfigYaml();
|
||||
setContent(data);
|
||||
setDirty(false);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || t('notification.refresh_failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await configFileApi.saveConfigYaml(content);
|
||||
setDirty(false);
|
||||
showNotification(t('notification.saved_success'), 'success');
|
||||
} catch (err: any) {
|
||||
showNotification(`${t('notification.save_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={t('nav.config_management')}
|
||||
extra={
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button variant="secondary" size="sm" onClick={loadConfig} disabled={loading}>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} loading={saving} disabled={disableControls || loading || !dirty}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
<div className="form-group">
|
||||
<label>{t('nav.config_management')}</label>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={20}
|
||||
value={content}
|
||||
onChange={(e) => {
|
||||
setContent(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
disabled={disableControls}
|
||||
placeholder="config.yaml"
|
||||
/>
|
||||
<div className="hint">
|
||||
{dirty ? t('system_info.version_current_missing') : loading ? t('common.loading') : t('system_info.version_is_latest')}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
124
src/pages/Login/Login.module.scss
Normal file
124
src/pages/Login/Login.module.scss
Normal file
@@ -0,0 +1,124 @@
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, #1d4ed8 100%);
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: $spacing-md 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
@include button-reset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border-radius: $radius-md;
|
||||
transition: background-color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.loginBox {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 450px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background-color: white;
|
||||
border-radius: $radius-full;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: $spacing-lg;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin: 0 0 $spacing-sm 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin: 0 0 $spacing-2xl 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form {
|
||||
width: 100%;
|
||||
background-color: var(--bg-primary);
|
||||
padding: $spacing-2xl;
|
||||
border-radius: $radius-lg;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.detectedInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
padding: $spacing-md;
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
border-radius: $radius-md;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
i {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: $spacing-xl;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.version {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin: 0;
|
||||
}
|
||||
150
src/pages/LoginPage.tsx
Normal file
150
src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useAuthStore, useNotificationStore } from '@/stores';
|
||||
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
|
||||
|
||||
export function LoginPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const restoreSession = useAuthStore((state) => state.restoreSession);
|
||||
const storedBase = useAuthStore((state) => state.apiBase);
|
||||
const storedKey = useAuthStore((state) => state.managementKey);
|
||||
|
||||
const [apiBase, setApiBase] = useState('');
|
||||
const [managementKey, setManagementKey] = useState('');
|
||||
const [showCustomBase, setShowCustomBase] = useState(false);
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [autoLoading, setAutoLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
const autoLoggedIn = await restoreSession();
|
||||
if (!autoLoggedIn) {
|
||||
setApiBase(storedBase || detectedBase);
|
||||
setManagementKey(storedKey || '');
|
||||
}
|
||||
} finally {
|
||||
setAutoLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
}, [detectedBase, restoreSession, storedBase, storedKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
const redirect = (location.state as any)?.from?.pathname || '/';
|
||||
navigate(redirect, { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, navigate, location.state]);
|
||||
|
||||
const handleUseCurrent = () => {
|
||||
setApiBase(detectedBase);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!managementKey.trim()) {
|
||||
setError(t('login.error_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
const baseToUse = apiBase ? normalizeApiBase(apiBase) : detectedBase;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await login({ apiBase: baseToUse, managementKey: managementKey.trim() });
|
||||
showNotification(t('common.connected_status'), 'success');
|
||||
navigate('/', { replace: true });
|
||||
} catch (err: any) {
|
||||
const message = err?.message || t('login.error_invalid');
|
||||
setError(message);
|
||||
showNotification(`${t('notification.login_failed')}: ${message}`, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<div className="title">{t('title.login')}</div>
|
||||
<div className="subtitle">{t('login.subtitle')}</div>
|
||||
</div>
|
||||
|
||||
<div className="connection-box">
|
||||
<div className="label">{t('login.connection_current')}</div>
|
||||
<div className="value">{apiBase || detectedBase}</div>
|
||||
<div className="hint">{t('login.connection_auto_hint')}</div>
|
||||
</div>
|
||||
|
||||
<div className="toggle-advanced">
|
||||
<input
|
||||
id="custom-connection-toggle"
|
||||
type="checkbox"
|
||||
checked={showCustomBase}
|
||||
onChange={(e) => setShowCustomBase(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="custom-connection-toggle">{t('login.custom_connection_label')}</label>
|
||||
</div>
|
||||
|
||||
{showCustomBase && (
|
||||
<Input
|
||||
label={t('login.custom_connection_label')}
|
||||
placeholder={t('login.custom_connection_placeholder')}
|
||||
value={apiBase}
|
||||
onChange={(e) => setApiBase(e.target.value)}
|
||||
hint={t('login.custom_connection_hint')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label={t('login.management_key_label')}
|
||||
placeholder={t('login.management_key_placeholder')}
|
||||
type={showKey ? 'text' : 'password'}
|
||||
value={managementKey}
|
||||
onChange={(e) => setManagementKey(e.target.value)}
|
||||
rightElement={
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setShowKey((prev) => !prev)}
|
||||
>
|
||||
{showKey ? '🙈' : '👁️'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<Button variant="secondary" onClick={handleUseCurrent}>
|
||||
{t('login.use_current_address')}
|
||||
</Button>
|
||||
<Button fullWidth onClick={handleSubmit} loading={loading}>
|
||||
{loading ? t('login.submitting') : t('login.submit_button')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
|
||||
{autoLoading && (
|
||||
<div className="connection-box">
|
||||
<div className="label">{t('auto_login.title')}</div>
|
||||
<div className="value">{t('auto_login.message')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
src/pages/LogsPage.module.scss
Normal file
81
src/pages/LogsPage.module.scss
Normal file
@@ -0,0 +1,81 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 $spacing-xl 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
|
||||
@include mobile {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.logViewer {
|
||||
background-color: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: $spacing-lg;
|
||||
border-radius: $radius-lg;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.searchBox {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
text-align: center;
|
||||
padding: $spacing-2xl;
|
||||
color: var(--text-secondary);
|
||||
|
||||
i {
|
||||
font-size: 48px;
|
||||
margin-bottom: $spacing-md;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 $spacing-sm 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
198
src/pages/LogsPage.tsx
Normal file
198
src/pages/LogsPage.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useEffect, useState } 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 { useNotificationStore, useAuthStore } from '@/stores';
|
||||
import { logsApi } from '@/services/api/logs';
|
||||
|
||||
interface ErrorLogItem {
|
||||
name: string;
|
||||
size?: number;
|
||||
modified?: number;
|
||||
}
|
||||
|
||||
export function LogsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
|
||||
const [logs, setLogs] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [intervalId, setIntervalId] = useState<number | null>(null);
|
||||
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
|
||||
const [loadingErrors, setLoadingErrors] = useState(false);
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
|
||||
const loadLogs = async () => {
|
||||
if (connectionStatus !== 'connected') {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await logsApi.fetchLogs({ limit: 500 });
|
||||
const text = Array.isArray(data) ? data.join('\n') : data?.logs || data || '';
|
||||
setLogs(text);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load logs:', err);
|
||||
setError(err?.message || t('logs.load_error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearLogs = async () => {
|
||||
if (!window.confirm(t('logs.clear_confirm'))) return;
|
||||
try {
|
||||
await logsApi.clearLogs();
|
||||
setLogs('');
|
||||
showNotification(t('logs.clear_success'), 'success');
|
||||
} catch (err: any) {
|
||||
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const downloadLogs = () => {
|
||||
const blob = new Blob([logs], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'logs.txt';
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
showNotification(t('logs.download_success'), 'success');
|
||||
};
|
||||
|
||||
const loadErrorLogs = async () => {
|
||||
if (connectionStatus !== 'connected') {
|
||||
setLoadingErrors(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingErrors(true);
|
||||
try {
|
||||
const res = await logsApi.fetchErrorLogs();
|
||||
const list: ErrorLogItem[] = Array.isArray(res)
|
||||
? res
|
||||
: Object.entries(res || {}).map(([name, meta]) => ({
|
||||
name,
|
||||
size: (meta as any)?.size,
|
||||
modified: (meta as any)?.modified
|
||||
}));
|
||||
setErrorLogs(list);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load error logs:', err);
|
||||
// 静默失败,不影响主日志显示
|
||||
setErrorLogs([]);
|
||||
} finally {
|
||||
setLoadingErrors(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadErrorLog = async (name: string) => {
|
||||
try {
|
||||
const response = await logsApi.downloadErrorLog(name);
|
||||
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 = name;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
showNotification(t('logs.error_log_download_success'), 'success');
|
||||
} catch (err: any) {
|
||||
showNotification(`${t('notification.download_failed')}: ${err?.message || ''}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionStatus === 'connected') {
|
||||
loadLogs();
|
||||
loadErrorLogs();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [connectionStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoRefresh) {
|
||||
const id = window.setInterval(loadLogs, 8000);
|
||||
setIntervalId(id);
|
||||
return () => window.clearInterval(id);
|
||||
}
|
||||
if (intervalId) {
|
||||
window.clearInterval(intervalId);
|
||||
setIntervalId(null);
|
||||
}
|
||||
}, [autoRefresh]);
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<Card
|
||||
title={t('logs.title')}
|
||||
extra={
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Button variant="secondary" size="sm" onClick={loadLogs} disabled={loading}>
|
||||
{t('logs.refresh_button')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setAutoRefresh((v) => !v)}>
|
||||
{t('logs.auto_refresh')}: {autoRefresh ? t('common.yes') : t('common.no')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={downloadLogs} disabled={!logs}>
|
||||
{t('logs.download_button')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={clearLogs} disabled={disableControls}>
|
||||
{t('logs.clear_button')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{loading ? (
|
||||
<div className="hint">{t('logs.loading')}</div>
|
||||
) : logs ? (
|
||||
<pre className="log-viewer">{logs}</pre>
|
||||
) : (
|
||||
<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 ? new Date(item.modified).toLocaleString() : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="item-actions">
|
||||
<Button variant="secondary" size="sm" onClick={() => downloadErrorLog(item.name)}>
|
||||
{t('logs.error_logs_download')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
src/pages/OAuthPage.module.scss
Normal file
59
src/pages/OAuthPage.module.scss
Normal file
@@ -0,0 +1,59 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 $spacing-xl 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xl;
|
||||
}
|
||||
|
||||
.oauthSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.oauthGrid {
|
||||
display: grid;
|
||||
gap: $spacing-lg;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.oauthCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.oauthStatus {
|
||||
padding: $spacing-md;
|
||||
border-radius: $radius-md;
|
||||
font-size: 14px;
|
||||
|
||||
&.success {
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&.waiting {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
160
src/pages/OAuthPage.tsx
Normal file
160
src/pages/OAuthPage.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { oauthApi, type OAuthProvider } from '@/services/api/oauth';
|
||||
import { isLocalhost } from '@/utils/connection';
|
||||
|
||||
interface ProviderState {
|
||||
url?: string;
|
||||
state?: string;
|
||||
status?: 'idle' | 'waiting' | 'success' | 'error';
|
||||
error?: string;
|
||||
polling?: boolean;
|
||||
}
|
||||
|
||||
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string }[] = [
|
||||
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label' },
|
||||
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label' },
|
||||
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label' },
|
||||
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label' },
|
||||
{ id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label' },
|
||||
{ id: 'iflow', titleKey: 'auth_login.iflow_oauth_title', hintKey: 'auth_login.iflow_oauth_hint', urlLabelKey: 'auth_login.iflow_oauth_url_label' }
|
||||
];
|
||||
|
||||
export function OAuthPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
|
||||
const timers = useRef<Record<string, number>>({});
|
||||
|
||||
const isLocal = useMemo(() => isLocalhost(window.location.hostname), []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Object.values(timers.current).forEach((timer) => window.clearInterval(timer));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startPolling = (provider: OAuthProvider, state: string) => {
|
||||
if (timers.current[provider]) {
|
||||
clearInterval(timers.current[provider]);
|
||||
}
|
||||
const timer = window.setInterval(async () => {
|
||||
try {
|
||||
const res = await oauthApi.getAuthStatus(state);
|
||||
if (res.status === 'ok') {
|
||||
setStates((prev) => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], status: 'success', polling: false }
|
||||
}));
|
||||
showNotification(t('auth_login.codex_oauth_status_success'), 'success');
|
||||
window.clearInterval(timer);
|
||||
delete timers.current[provider];
|
||||
} else if (res.status === 'error') {
|
||||
setStates((prev) => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], status: 'error', error: res.error, polling: false }
|
||||
}));
|
||||
showNotification(`${t('auth_login.codex_oauth_status_error')} ${res.error || ''}`, 'error');
|
||||
window.clearInterval(timer);
|
||||
delete timers.current[provider];
|
||||
}
|
||||
} catch (err: any) {
|
||||
setStates((prev) => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], status: 'error', error: err?.message, polling: false }
|
||||
}));
|
||||
window.clearInterval(timer);
|
||||
delete timers.current[provider];
|
||||
}
|
||||
}, 3000);
|
||||
timers.current[provider] = timer;
|
||||
};
|
||||
|
||||
const startAuth = async (provider: OAuthProvider) => {
|
||||
setStates((prev) => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], status: 'waiting', polling: true, error: undefined }
|
||||
}));
|
||||
try {
|
||||
const res = await oauthApi.startAuth(provider);
|
||||
setStates((prev) => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], url: res.url, state: res.state, status: 'waiting', polling: true }
|
||||
}));
|
||||
if (res.state) {
|
||||
startPolling(provider, res.state);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setStates((prev) => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], status: 'error', error: err?.message, polling: false }
|
||||
}));
|
||||
showNotification(`${t('auth_login.codex_oauth_start_error')} ${err?.message || ''}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const copyLink = async (url?: string) => {
|
||||
if (!url) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
showNotification(t('notification.link_copied'), 'success');
|
||||
} catch {
|
||||
showNotification('Copy failed', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
if (!isLocal) {
|
||||
return <Card title="OAuth">OAuth is only available on localhost.</Card>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
{PROVIDERS.map((provider) => {
|
||||
const state = states[provider.id] || {};
|
||||
return (
|
||||
<Card
|
||||
key={provider.id}
|
||||
title={t(provider.titleKey)}
|
||||
extra={
|
||||
<Button onClick={() => startAuth(provider.id)} loading={state.polling}>
|
||||
{t('common.login')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="hint">{t(provider.hintKey)}</div>
|
||||
{state.url && (
|
||||
<div className="connection-box">
|
||||
<div className="label">{t(provider.urlLabelKey)}</div>
|
||||
<div className="value">{state.url}</div>
|
||||
<div className="item-actions" style={{ marginTop: 8 }}>
|
||||
<Button variant="secondary" size="sm" onClick={() => copyLink(state.url!)}>
|
||||
{t('auth_login.codex_copy_link')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => window.open(state.url, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
{t('auth_login.codex_open_link')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="status-badge" style={{ marginTop: 8 }}>
|
||||
{state.status === 'success'
|
||||
? t('auth_login.codex_oauth_status_success')
|
||||
: state.status === 'error'
|
||||
? `${t('auth_login.codex_oauth_status_error')} ${state.error || ''}`
|
||||
: state.status === 'waiting'
|
||||
? t('auth_login.codex_oauth_status_waiting')
|
||||
: t('common.info')}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
src/pages/PlaceholderPage.module.scss
Normal file
32
src/pages/PlaceholderPage.module.scss
Normal file
@@ -0,0 +1,32 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
text-align: center;
|
||||
padding: $spacing-2xl;
|
||||
|
||||
.icon {
|
||||
font-size: 64px;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.5;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 $spacing-md 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
12
src/pages/PlaceholderPage.tsx
Normal file
12
src/pages/PlaceholderPage.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
|
||||
export function PlaceholderPage({ titleKey }: { titleKey: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card title={t(titleKey)}>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>{t('common.loading')}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
105
src/pages/Settings/Settings.module.scss
Normal file
105
src/pages/Settings/Settings.module.scss
Normal file
@@ -0,0 +1,105 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 $spacing-xl 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: $spacing-lg;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.settingRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.settingInfo {
|
||||
flex: 1;
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 $spacing-xs 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 52px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
|
||||
input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
|
||||
&:checked + .slider {
|
||||
background-color: var(--primary-color);
|
||||
|
||||
&:before {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus + .slider {
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--border-color);
|
||||
transition: $transition-fast;
|
||||
border-radius: $radius-full;
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background-color: white;
|
||||
transition: $transition-fast;
|
||||
border-radius: $radius-full;
|
||||
}
|
||||
}
|
||||
|
||||
.formGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
326
src/pages/SettingsPage.tsx
Normal file
326
src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import { configApi } from '@/services/api';
|
||||
import type { Config } from '@/types';
|
||||
|
||||
type PendingKey =
|
||||
| 'debug'
|
||||
| 'proxy'
|
||||
| 'retry'
|
||||
| 'switchProject'
|
||||
| 'switchPreview'
|
||||
| 'usage'
|
||||
| 'requestLog'
|
||||
| 'loggingToFile'
|
||||
| 'wsAuth';
|
||||
|
||||
export function SettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [proxyValue, setProxyValue] = useState('');
|
||||
const [retryValue, setRetryValue] = useState(0);
|
||||
const [pending, setPending] = useState<Record<PendingKey, boolean>>({} as Record<PendingKey, boolean>);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = (await fetchConfig(undefined, true)) as Config;
|
||||
setProxyValue(data?.proxyUrl ?? '');
|
||||
setRetryValue(typeof data?.requestRetry === 'number' ? data.requestRetry : 0);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || t('notification.refresh_failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
}, [fetchConfig, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setProxyValue(config.proxyUrl ?? '');
|
||||
if (typeof config.requestRetry === 'number') {
|
||||
setRetryValue(config.requestRetry);
|
||||
}
|
||||
}
|
||||
}, [config?.proxyUrl, config?.requestRetry]);
|
||||
|
||||
const setPendingFlag = (key: PendingKey, value: boolean) => {
|
||||
setPending((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const toggleSetting = async (
|
||||
section: PendingKey,
|
||||
rawKey: 'debug' | 'usage-statistics-enabled' | 'request-log' | 'logging-to-file' | 'ws-auth',
|
||||
value: boolean,
|
||||
updater: (val: boolean) => Promise<any>,
|
||||
successMessage: string
|
||||
) => {
|
||||
const previous = (() => {
|
||||
switch (rawKey) {
|
||||
case 'debug':
|
||||
return config?.debug ?? false;
|
||||
case 'usage-statistics-enabled':
|
||||
return config?.usageStatisticsEnabled ?? false;
|
||||
case 'request-log':
|
||||
return config?.requestLog ?? false;
|
||||
case 'logging-to-file':
|
||||
return config?.loggingToFile ?? false;
|
||||
case 'ws-auth':
|
||||
return config?.wsAuth ?? false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
setPendingFlag(section, true);
|
||||
updateConfigValue(rawKey, value);
|
||||
|
||||
try {
|
||||
await updater(value);
|
||||
clearCache(rawKey);
|
||||
showNotification(successMessage, 'success');
|
||||
} catch (err: any) {
|
||||
updateConfigValue(rawKey, previous);
|
||||
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setPendingFlag(section, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProxyUpdate = async () => {
|
||||
const previous = config?.proxyUrl ?? '';
|
||||
setPendingFlag('proxy', true);
|
||||
updateConfigValue('proxy-url', proxyValue);
|
||||
try {
|
||||
await configApi.updateProxyUrl(proxyValue.trim());
|
||||
clearCache('proxy-url');
|
||||
showNotification(t('notification.proxy_updated'), 'success');
|
||||
} catch (err: any) {
|
||||
setProxyValue(previous);
|
||||
updateConfigValue('proxy-url', previous);
|
||||
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setPendingFlag('proxy', false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProxyClear = async () => {
|
||||
const previous = config?.proxyUrl ?? '';
|
||||
setPendingFlag('proxy', true);
|
||||
updateConfigValue('proxy-url', '');
|
||||
try {
|
||||
await configApi.clearProxyUrl();
|
||||
clearCache('proxy-url');
|
||||
setProxyValue('');
|
||||
showNotification(t('notification.proxy_cleared'), 'success');
|
||||
} catch (err: any) {
|
||||
setProxyValue(previous);
|
||||
updateConfigValue('proxy-url', previous);
|
||||
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setPendingFlag('proxy', false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetryUpdate = async () => {
|
||||
const previous = config?.requestRetry ?? 0;
|
||||
const parsed = Number(retryValue);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
showNotification(t('login.error_invalid'), 'error');
|
||||
setRetryValue(previous);
|
||||
return;
|
||||
}
|
||||
setPendingFlag('retry', true);
|
||||
updateConfigValue('request-retry', parsed);
|
||||
try {
|
||||
await configApi.updateRequestRetry(parsed);
|
||||
clearCache('request-retry');
|
||||
showNotification(t('notification.retry_updated'), 'success');
|
||||
} catch (err: any) {
|
||||
setRetryValue(previous);
|
||||
updateConfigValue('request-retry', previous);
|
||||
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setPendingFlag('retry', false);
|
||||
}
|
||||
};
|
||||
|
||||
const quotaSwitchProject = config?.quotaExceeded?.switchProject ?? false;
|
||||
const quotaSwitchPreview = config?.quotaExceeded?.switchPreviewModel ?? false;
|
||||
|
||||
return (
|
||||
<div className="grid cols-2">
|
||||
<Card title={t('basic_settings.title')}>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.debug_enable')}
|
||||
checked={config?.debug ?? false}
|
||||
disabled={disableControls || pending.debug || loading}
|
||||
onChange={(value) =>
|
||||
toggleSetting('debug', 'debug', value, configApi.updateDebug, t('notification.debug_updated'))
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.usage_statistics_enable')}
|
||||
checked={config?.usageStatisticsEnabled ?? false}
|
||||
disabled={disableControls || pending.usage || loading}
|
||||
onChange={(value) =>
|
||||
toggleSetting(
|
||||
'usage',
|
||||
'usage-statistics-enabled',
|
||||
value,
|
||||
configApi.updateUsageStatistics,
|
||||
t('notification.usage_statistics_updated')
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.request_log_enable')}
|
||||
checked={config?.requestLog ?? false}
|
||||
disabled={disableControls || pending.requestLog || loading}
|
||||
onChange={(value) =>
|
||||
toggleSetting(
|
||||
'requestLog',
|
||||
'request-log',
|
||||
value,
|
||||
configApi.updateRequestLog,
|
||||
t('notification.request_log_updated')
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.logging_to_file_enable')}
|
||||
checked={config?.loggingToFile ?? false}
|
||||
disabled={disableControls || pending.loggingToFile || loading}
|
||||
onChange={(value) =>
|
||||
toggleSetting(
|
||||
'loggingToFile',
|
||||
'logging-to-file',
|
||||
value,
|
||||
configApi.updateLoggingToFile,
|
||||
t('notification.logging_to_file_updated')
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.ws_auth_enable')}
|
||||
checked={config?.wsAuth ?? false}
|
||||
disabled={disableControls || pending.wsAuth || loading}
|
||||
onChange={(value) =>
|
||||
toggleSetting(
|
||||
'wsAuth',
|
||||
'ws-auth',
|
||||
value,
|
||||
configApi.updateWsAuth,
|
||||
t('notification.ws_auth_updated')
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title={t('basic_settings.proxy_title')}>
|
||||
<Input
|
||||
label={t('basic_settings.proxy_url_label')}
|
||||
placeholder={t('basic_settings.proxy_url_placeholder')}
|
||||
value={proxyValue}
|
||||
onChange={(e) => setProxyValue(e.target.value)}
|
||||
disabled={disableControls || loading}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Button variant="secondary" onClick={handleProxyClear} disabled={disableControls || pending.proxy || loading}>
|
||||
{t('basic_settings.proxy_clear')}
|
||||
</Button>
|
||||
<Button onClick={handleProxyUpdate} loading={pending.proxy} disabled={disableControls || loading}>
|
||||
{t('basic_settings.proxy_update')}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
label={t('basic_settings.retry_count_label')}
|
||||
type="number"
|
||||
value={retryValue}
|
||||
onChange={(e) => setRetryValue(Number(e.target.value))}
|
||||
disabled={disableControls || loading}
|
||||
/>
|
||||
<Button onClick={handleRetryUpdate} loading={pending.retry} disabled={disableControls || loading} fullWidth>
|
||||
{t('basic_settings.retry_update')}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<Card title={t('basic_settings.quota_title')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.quota_switch_project')}
|
||||
checked={quotaSwitchProject}
|
||||
disabled={disableControls || pending.switchProject || loading}
|
||||
onChange={(value) =>
|
||||
(async () => {
|
||||
const previous = config?.quotaExceeded?.switchProject ?? false;
|
||||
const nextQuota = { ...(config?.quotaExceeded || {}), switchProject: value };
|
||||
setPendingFlag('switchProject', true);
|
||||
updateConfigValue('quota-exceeded', nextQuota);
|
||||
try {
|
||||
await configApi.updateSwitchProject(value);
|
||||
clearCache('quota-exceeded');
|
||||
showNotification(t('notification.quota_switch_project_updated'), 'success');
|
||||
} catch (err: any) {
|
||||
updateConfigValue('quota-exceeded', { ...(config?.quotaExceeded || {}), switchProject: previous });
|
||||
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setPendingFlag('switchProject', false);
|
||||
}
|
||||
})()
|
||||
}
|
||||
/>
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.quota_switch_preview')}
|
||||
checked={quotaSwitchPreview}
|
||||
disabled={disableControls || pending.switchPreview || loading}
|
||||
onChange={(value) =>
|
||||
(async () => {
|
||||
const previous = config?.quotaExceeded?.switchPreviewModel ?? false;
|
||||
const nextQuota = { ...(config?.quotaExceeded || {}), switchPreviewModel: value };
|
||||
setPendingFlag('switchPreview', true);
|
||||
updateConfigValue('quota-exceeded', nextQuota);
|
||||
try {
|
||||
await configApi.updateSwitchPreviewModel(value);
|
||||
clearCache('quota-exceeded');
|
||||
showNotification(t('notification.quota_switch_preview_updated'), 'success');
|
||||
} catch (err: any) {
|
||||
updateConfigValue('quota-exceeded', { ...(config?.quotaExceeded || {}), switchPreviewModel: previous });
|
||||
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setPendingFlag('switchPreview', false);
|
||||
}
|
||||
})()
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
src/pages/SystemPage.module.scss
Normal file
109
src/pages/SystemPage.module.scss
Normal file
@@ -0,0 +1,109 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 $spacing-xl 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xl;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 $spacing-md 0;
|
||||
}
|
||||
|
||||
.sectionDescription {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 $spacing-md 0;
|
||||
}
|
||||
|
||||
.infoGrid {
|
||||
display: grid;
|
||||
gap: $spacing-sm;
|
||||
|
||||
.infoRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: $radius-md;
|
||||
|
||||
.label {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--text-primary);
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modelsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modelItem {
|
||||
padding: $spacing-sm $spacing-md;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: $radius-md;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.versionCheck {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.versionInfo {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: $spacing-md;
|
||||
|
||||
.versionItem {
|
||||
padding: $spacing-md;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: $radius-md;
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
|
||||
.version {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
}
|
||||
}
|
||||
}
|
||||
113
src/pages/SystemPage.tsx
Normal file
113
src/pages/SystemPage.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import { modelsApi } from '@/services/api/models';
|
||||
import { classifyModels, type ModelInfo } from '@/utils/models';
|
||||
|
||||
export function SystemPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const auth = useAuthStore();
|
||||
const configStore = useConfigStore();
|
||||
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const openaiProviders = configStore.config?.openaiCompatibility || [];
|
||||
const primaryProvider = openaiProviders[0];
|
||||
const primaryKey = primaryProvider?.apiKeyEntries?.[0]?.apiKey;
|
||||
|
||||
const groupedModels = useMemo(() => classifyModels(models, { otherLabel: 'Other' }), [models]);
|
||||
|
||||
const fetchModels = async () => {
|
||||
if (!primaryProvider?.baseUrl) {
|
||||
showNotification('No OpenAI provider configured for model fetch', 'warning');
|
||||
return;
|
||||
}
|
||||
setLoadingModels(true);
|
||||
setError('');
|
||||
try {
|
||||
const list = await modelsApi.fetchModels(primaryProvider.baseUrl, primaryKey);
|
||||
setModels(list);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || t('notification.refresh_failed'));
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
configStore.fetchConfig().catch(() => {
|
||||
// ignore
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<Card
|
||||
title={t('nav.system_info')}
|
||||
extra={
|
||||
<Button variant="secondary" size="sm" onClick={() => configStore.fetchConfig(undefined, true)}>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="grid cols-2">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">{t('connection.server_address')}</div>
|
||||
<div className="stat-value">{auth.apiBase || '-'}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">{t('footer.api_version')}</div>
|
||||
<div className="stat-value">{auth.serverVersion || t('system_info.version_unknown')}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">{t('footer.build_date')}</div>
|
||||
<div className="stat-value">
|
||||
{auth.serverBuildDate ? new Date(auth.serverBuildDate).toLocaleString() : t('system_info.version_unknown')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">{t('connection.status')}</div>
|
||||
<div className="stat-value">{t(`common.${auth.connectionStatus}_status` as any)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Models"
|
||||
extra={
|
||||
<Button variant="secondary" size="sm" onClick={fetchModels} loading={loadingModels}>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{loadingModels ? (
|
||||
<div className="hint">{t('common.loading')}</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className="hint">{t('usage_stats.no_data')}</div>
|
||||
) : (
|
||||
<div className="item-list">
|
||||
{groupedModels.map((group) => (
|
||||
<div key={group.id} className="item-row">
|
||||
<div className="item-meta">
|
||||
<div className="item-title">
|
||||
{group.label} ({group.items.length})
|
||||
</div>
|
||||
<div className="item-subtitle">
|
||||
{group.items.map((model) => model.name).slice(0, 5).join(', ')}
|
||||
{group.items.length > 5 ? '…' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
src/pages/UsagePage.module.scss
Normal file
71
src/pages/UsagePage.module.scss
Normal file
@@ -0,0 +1,71 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 $spacing-xl 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xl;
|
||||
}
|
||||
|
||||
.statsGrid {
|
||||
display: grid;
|
||||
gap: $spacing-md;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.statCard {
|
||||
padding: $spacing-lg;
|
||||
background-color: var(--bg-primary);
|
||||
border-radius: $radius-lg;
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.chartSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.chartControls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.chartWrapper {
|
||||
padding: $spacing-lg;
|
||||
background-color: var(--bg-primary);
|
||||
border-radius: $radius-lg;
|
||||
border: 1px solid var(--border-color);
|
||||
min-height: 300px;
|
||||
}
|
||||
108
src/pages/UsagePage.tsx
Normal file
108
src/pages/UsagePage.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { usageApi } from '@/services/api/usage';
|
||||
import type { KeyStats } from '@/utils/usage';
|
||||
|
||||
interface UsagePayload {
|
||||
total_requests?: number;
|
||||
success_requests?: number;
|
||||
failed_requests?: number;
|
||||
total_tokens?: number;
|
||||
cached_tokens?: number;
|
||||
reasoning_tokens?: number;
|
||||
rpm_30m?: number;
|
||||
tpm_30m?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export function UsagePage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [usage, setUsage] = useState<UsagePayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [keyStats, setKeyStats] = useState<KeyStats | null>(null);
|
||||
|
||||
const loadUsage = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await usageApi.getUsage();
|
||||
const payload = data?.usage ?? data;
|
||||
setUsage(payload);
|
||||
const stats = await usageApi.getKeyStats(payload);
|
||||
setKeyStats(stats);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || t('usage_stats.loading_error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadUsage();
|
||||
}, []);
|
||||
|
||||
const overviewItems = [
|
||||
{ label: t('usage_stats.total_requests'), value: usage?.total_requests },
|
||||
{ label: t('usage_stats.success_requests'), value: usage?.success_requests },
|
||||
{ label: t('usage_stats.failed_requests'), value: usage?.failed_requests },
|
||||
{ label: t('usage_stats.total_tokens'), value: usage?.total_tokens },
|
||||
{ label: t('usage_stats.cached_tokens'), value: usage?.cached_tokens },
|
||||
{ label: t('usage_stats.reasoning_tokens'), value: usage?.reasoning_tokens },
|
||||
{ label: t('usage_stats.rpm_30m'), value: usage?.rpm_30m },
|
||||
{ label: t('usage_stats.tpm_30m'), value: usage?.tpm_30m }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<Card
|
||||
title={t('usage_stats.title')}
|
||||
extra={
|
||||
<Button variant="secondary" size="sm" onClick={loadUsage} disabled={loading}>
|
||||
{t('usage_stats.refresh')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{loading ? (
|
||||
<div className="hint">{t('common.loading')}</div>
|
||||
) : (
|
||||
<div className="grid cols-2">
|
||||
{overviewItems.map((item) => (
|
||||
<div key={item.label} className="stat-card">
|
||||
<div className="stat-label">{item.label}</div>
|
||||
<div className="stat-value">{item.value ?? '-'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title={t('usage_stats.api_details')}>
|
||||
{loading ? (
|
||||
<div className="hint">{t('common.loading')}</div>
|
||||
) : keyStats && Object.keys(keyStats.bySource || {}).length ? (
|
||||
<div className="table">
|
||||
<div className="table-header">
|
||||
<div>{t('usage_stats.api_endpoint')}</div>
|
||||
<div>{t('stats.success')}</div>
|
||||
<div>{t('stats.failure')}</div>
|
||||
</div>
|
||||
{Object.entries(keyStats.bySource || {}).map(([source, bucket]) => (
|
||||
<div key={source} className="table-row">
|
||||
<div className="cell item-subtitle">{source}</div>
|
||||
<div className="cell">{bucket.success}</div>
|
||||
<div className="cell">{bucket.failure}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="hint">{t('usage_stats.no_data')}</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user