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:
Supra4E8C
2025-12-07 11:32:31 +08:00
parent 8e4132200d
commit 450964fb1a
144 changed files with 14223 additions and 21647 deletions

View 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;
}
}

View 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>
);
}

View 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
View 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>
);
}

View 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
View 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>
);
}

View 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
View 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>
);
}

View 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
View 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>
);
}

View 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
View 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>
);
}

View 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
View 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>
);
}

View 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;
}
}

View 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>
);
}

View 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
View 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>
);
}

View 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
View 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>
);
}

View 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
View 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>
);
}