mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-18 10:40:50 +08:00
refactor(nav): move Config Panel and remove Settings/API Keys pages
This commit is contained in:
@@ -19,12 +19,10 @@ import {
|
||||
IconChartLine,
|
||||
IconFileText,
|
||||
IconInfo,
|
||||
IconKey,
|
||||
IconLayoutDashboard,
|
||||
IconScrollText,
|
||||
IconSettings,
|
||||
IconShield,
|
||||
IconSlidersHorizontal,
|
||||
IconTimer,
|
||||
} from '@/components/ui/icons';
|
||||
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||
@@ -40,8 +38,6 @@ import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
|
||||
const sidebarIcons: Record<string, ReactNode> = {
|
||||
dashboard: <IconLayoutDashboard size={18} />,
|
||||
settings: <IconSlidersHorizontal size={18} />,
|
||||
apiKeys: <IconKey size={18} />,
|
||||
aiProviders: <IconBot size={18} />,
|
||||
authFiles: <IconFileText size={18} />,
|
||||
oauth: <IconShield size={18} />,
|
||||
@@ -357,14 +353,12 @@ export function MainLayout() {
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: t('nav.dashboard'), icon: sidebarIcons.dashboard },
|
||||
{ path: '/settings', label: t('nav.basic_settings'), icon: sidebarIcons.settings },
|
||||
{ path: '/api-keys', label: t('nav.api_keys'), icon: sidebarIcons.apiKeys },
|
||||
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
|
||||
{ path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders },
|
||||
{ path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles },
|
||||
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
|
||||
{ path: '/quota', label: t('nav.quota_management'), icon: sidebarIcons.quota },
|
||||
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
|
||||
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
|
||||
...(config?.loggingToFile
|
||||
? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
|
||||
: []),
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
"oauth": "OAuth Login",
|
||||
"quota_management": "Quota Management",
|
||||
"usage_stats": "Usage Statistics",
|
||||
"config_management": "Config Management",
|
||||
"config_management": "Config Panel",
|
||||
"logs": "Logs Viewer",
|
||||
"system_info": "Management Center Info"
|
||||
},
|
||||
@@ -812,7 +812,7 @@
|
||||
"upgrade_required_desc": "The current server version does not support the logs viewing feature. Please upgrade to the latest version of CLI Proxy API to use this feature."
|
||||
},
|
||||
"config_management": {
|
||||
"title": "Config Management",
|
||||
"title": "Config Panel",
|
||||
"editor_title": "Configuration File",
|
||||
"reload": "Reload",
|
||||
"save": "Save",
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
"oauth": "OAuth 登录",
|
||||
"quota_management": "配额管理",
|
||||
"usage_stats": "使用统计",
|
||||
"config_management": "配置管理",
|
||||
"config_management": "配置面板",
|
||||
"logs": "日志查看",
|
||||
"system_info": "中心信息"
|
||||
},
|
||||
@@ -417,7 +417,7 @@
|
||||
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。",
|
||||
"prefix_proxy_saved_success": "已更新 \"{{name}}\"",
|
||||
"card_tools_title": "配置管理",
|
||||
"card_tools_title": "配置面板",
|
||||
"quota_refresh_single": "刷新额度",
|
||||
"quota_refresh_hint": "仅刷新当前凭证的额度数据",
|
||||
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
|
||||
@@ -812,7 +812,7 @@
|
||||
"upgrade_required_desc": "当前服务器版本不支持日志查看功能,请升级到最新版本的 CLI Proxy API 以使用此功能。"
|
||||
},
|
||||
"config_management": {
|
||||
"title": "配置管理",
|
||||
"title": "配置面板",
|
||||
"editor_title": "配置文件",
|
||||
"reload": "重新加载",
|
||||
"save": "保存",
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
@use '../styles/mixins' as *;
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
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';
|
||||
import { isValidApiKeyCharset } from '@/utils/validation';
|
||||
import styles from './ApiKeysPage.module.scss';
|
||||
|
||||
export function ApiKeysPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification, showConfirmation } = 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 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();
|
||||
}, [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;
|
||||
}
|
||||
if (!isValidApiKeyCharset(trimmed)) {
|
||||
showNotification(t('notification.api_key_invalid_chars'), '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 = (index: number) => {
|
||||
const apiKeyToDelete = apiKeys[index];
|
||||
if (!apiKeyToDelete) {
|
||||
showNotification(t('notification.delete_failed'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showConfirmation({
|
||||
title: t('common.delete'),
|
||||
message: t('api_keys.delete_confirm'),
|
||||
variant: 'danger',
|
||||
onConfirm: async () => {
|
||||
const latestKeys = useConfigStore.getState().config?.apiKeys;
|
||||
const currentKeys = Array.isArray(latestKeys) ? latestKeys : [];
|
||||
const deleteIndex =
|
||||
currentKeys[index] === apiKeyToDelete
|
||||
? index
|
||||
: currentKeys.findIndex((key) => key === apiKeyToDelete);
|
||||
|
||||
if (deleteIndex < 0) {
|
||||
showNotification(t('notification.delete_failed'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiKeysApi.delete(deleteIndex);
|
||||
const nextKeys = currentKeys.filter((_, idx) => idx !== deleteIndex);
|
||||
setApiKeys(nextKeys);
|
||||
updateConfigValue('api-keys', nextKeys);
|
||||
clearCache('api-keys');
|
||||
showNotification(t('notification.api_key_deleted'), 'success');
|
||||
} catch (err: any) {
|
||||
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const actionButtons = (
|
||||
<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 (
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.pageTitle}>{t('api_keys.title')}</h1>
|
||||
|
||||
<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}
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,28 +24,50 @@
|
||||
|
||||
.tabBar {
|
||||
display: flex;
|
||||
gap: $spacing-xs;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
margin-bottom: $spacing-lg;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: $radius-full;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
width: 100%;
|
||||
|
||||
.tabItem {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabItem {
|
||||
@include button-reset;
|
||||
padding: 12px 20px;
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $radius-full;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -53,16 +75,21 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--primary-color);
|
||||
border-bottom-color: var(--primary-color);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--border-color);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.content {
|
||||
|
||||
@@ -172,12 +172,12 @@ export function DashboardPage() {
|
||||
|
||||
const quickStats: QuickStat[] = [
|
||||
{
|
||||
label: t('nav.api_keys'),
|
||||
label: t('dashboard.management_keys'),
|
||||
value: stats.apiKeys ?? '-',
|
||||
icon: <IconKey size={24} />,
|
||||
path: '/api-keys',
|
||||
path: '/config',
|
||||
loading: loading && stats.apiKeys === null,
|
||||
sublabel: t('dashboard.management_keys')
|
||||
sublabel: t('nav.config_management')
|
||||
},
|
||||
{
|
||||
label: t('nav.ai_providers'),
|
||||
@@ -309,7 +309,7 @@ export function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link to="/settings" className={styles.viewMoreLink}>
|
||||
<Link to="/config" className={styles.viewMoreLink}>
|
||||
{t('dashboard.edit_settings')} →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
@use '../../styles/mixins' as *;
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.retryRow {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: $spacing-md;
|
||||
flex-wrap: wrap;
|
||||
|
||||
:global(.form-group) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.retryRowAligned {
|
||||
align-items: flex-start;
|
||||
|
||||
.retryButton {
|
||||
margin-top: calc(1.5em + #{$spacing-xs});
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
align-items: stretch;
|
||||
|
||||
.retryButton {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.retryRowInputGrow {
|
||||
:global(.form-group) {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.retryInput {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.retryInput {
|
||||
width: 140px;
|
||||
|
||||
@include mobile {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.retryButton {
|
||||
@include mobile {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,477 +0,0 @@
|
||||
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';
|
||||
import styles from './Settings/Settings.module.scss';
|
||||
|
||||
type PendingKey =
|
||||
| 'debug'
|
||||
| 'proxy'
|
||||
| 'retry'
|
||||
| 'logsMaxSize'
|
||||
| 'forceModelPrefix'
|
||||
| 'routingStrategy'
|
||||
| 'switchProject'
|
||||
| 'switchPreview'
|
||||
| 'usage'
|
||||
| '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 [logsMaxTotalSizeMb, setLogsMaxTotalSizeMb] = useState(0);
|
||||
const [routingStrategy, setRoutingStrategy] = useState('round-robin');
|
||||
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 [configResult, logsResult, prefixResult, routingResult] = await Promise.allSettled([
|
||||
fetchConfig(),
|
||||
configApi.getLogsMaxTotalSizeMb(),
|
||||
configApi.getForceModelPrefix(),
|
||||
configApi.getRoutingStrategy(),
|
||||
]);
|
||||
|
||||
if (configResult.status !== 'fulfilled') {
|
||||
throw configResult.reason;
|
||||
}
|
||||
|
||||
const data = configResult.value as Config;
|
||||
setProxyValue(data?.proxyUrl ?? '');
|
||||
setRetryValue(typeof data?.requestRetry === 'number' ? data.requestRetry : 0);
|
||||
|
||||
if (logsResult.status === 'fulfilled' && Number.isFinite(logsResult.value)) {
|
||||
setLogsMaxTotalSizeMb(Math.max(0, Number(logsResult.value)));
|
||||
updateConfigValue('logs-max-total-size-mb', Math.max(0, Number(logsResult.value)));
|
||||
}
|
||||
|
||||
if (prefixResult.status === 'fulfilled') {
|
||||
updateConfigValue('force-model-prefix', Boolean(prefixResult.value));
|
||||
}
|
||||
|
||||
if (routingResult.status === 'fulfilled' && routingResult.value) {
|
||||
setRoutingStrategy(String(routingResult.value));
|
||||
updateConfigValue('routing/strategy', String(routingResult.value));
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message || t('notification.refresh_failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
}, [fetchConfig, t, updateConfigValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setProxyValue(config.proxyUrl ?? '');
|
||||
if (typeof config.requestRetry === 'number') {
|
||||
setRetryValue(config.requestRetry);
|
||||
}
|
||||
if (typeof config.logsMaxTotalSizeMb === 'number') {
|
||||
setLogsMaxTotalSizeMb(config.logsMaxTotalSizeMb);
|
||||
}
|
||||
if (config.routingStrategy) {
|
||||
setRoutingStrategy(config.routingStrategy);
|
||||
}
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const setPendingFlag = (key: PendingKey, value: boolean) => {
|
||||
setPending((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const toggleSetting = async (
|
||||
section: PendingKey,
|
||||
rawKey: 'debug' | 'usage-statistics-enabled' | 'logging-to-file' | 'ws-auth' | 'force-model-prefix',
|
||||
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 'logging-to-file':
|
||||
return config?.loggingToFile ?? false;
|
||||
case 'ws-auth':
|
||||
return config?.wsAuth ?? false;
|
||||
case 'force-model-prefix':
|
||||
return config?.forceModelPrefix ?? 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 handleLogsMaxTotalSizeUpdate = async () => {
|
||||
const previous = config?.logsMaxTotalSizeMb ?? 0;
|
||||
const parsed = Number(logsMaxTotalSizeMb);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
showNotification(t('login.error_invalid'), 'error');
|
||||
setLogsMaxTotalSizeMb(previous);
|
||||
return;
|
||||
}
|
||||
const normalized = Math.max(0, parsed);
|
||||
setPendingFlag('logsMaxSize', true);
|
||||
updateConfigValue('logs-max-total-size-mb', normalized);
|
||||
try {
|
||||
await configApi.updateLogsMaxTotalSizeMb(normalized);
|
||||
clearCache('logs-max-total-size-mb');
|
||||
showNotification(t('notification.logs_max_total_size_updated'), 'success');
|
||||
} catch (err: any) {
|
||||
setLogsMaxTotalSizeMb(previous);
|
||||
updateConfigValue('logs-max-total-size-mb', previous);
|
||||
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setPendingFlag('logsMaxSize', false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoutingStrategyUpdate = async () => {
|
||||
const strategy = routingStrategy.trim();
|
||||
if (!strategy) {
|
||||
showNotification(t('login.error_invalid'), 'error');
|
||||
return;
|
||||
}
|
||||
const previous = config?.routingStrategy ?? 'round-robin';
|
||||
setPendingFlag('routingStrategy', true);
|
||||
updateConfigValue('routing/strategy', strategy);
|
||||
try {
|
||||
await configApi.updateRoutingStrategy(strategy);
|
||||
clearCache('routing/strategy');
|
||||
showNotification(t('notification.routing_strategy_updated'), 'success');
|
||||
} catch (err: any) {
|
||||
setRoutingStrategy(previous);
|
||||
updateConfigValue('routing/strategy', previous);
|
||||
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setPendingFlag('routingStrategy', false);
|
||||
}
|
||||
};
|
||||
|
||||
const quotaSwitchProject = config?.quotaExceeded?.switchProject ?? false;
|
||||
const quotaSwitchPreview = config?.quotaExceeded?.switchPreviewModel ?? false;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.pageTitle}>{t('basic_settings.title')}</h1>
|
||||
|
||||
<div className={styles.grid}>
|
||||
<Card>
|
||||
{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.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')
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.force_model_prefix_enable')}
|
||||
checked={config?.forceModelPrefix ?? false}
|
||||
disabled={disableControls || pending.forceModelPrefix || loading}
|
||||
onChange={(value) =>
|
||||
toggleSetting(
|
||||
'forceModelPrefix',
|
||||
'force-model-prefix',
|
||||
value,
|
||||
configApi.updateForceModelPrefix,
|
||||
t('notification.force_model_prefix_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>
|
||||
</Card>
|
||||
|
||||
<Card title={t('basic_settings.retry_title')}>
|
||||
<div className={styles.retryRow}>
|
||||
<Input
|
||||
label={t('basic_settings.retry_count_label')}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
step={1}
|
||||
value={retryValue}
|
||||
onChange={(e) => setRetryValue(Number(e.target.value))}
|
||||
disabled={disableControls || loading}
|
||||
className={styles.retryInput}
|
||||
/>
|
||||
<Button
|
||||
className={styles.retryButton}
|
||||
onClick={handleRetryUpdate}
|
||||
loading={pending.retry}
|
||||
disabled={disableControls || loading}
|
||||
>
|
||||
{t('basic_settings.retry_update')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title={t('basic_settings.logs_max_total_size_title')}>
|
||||
<div className={`${styles.retryRow} ${styles.retryRowAligned} ${styles.retryRowInputGrow}`}>
|
||||
<Input
|
||||
label={t('basic_settings.logs_max_total_size_label')}
|
||||
hint={t('basic_settings.logs_max_total_size_hint')}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
step={1}
|
||||
value={logsMaxTotalSizeMb}
|
||||
onChange={(e) => setLogsMaxTotalSizeMb(Number(e.target.value))}
|
||||
disabled={disableControls || loading}
|
||||
className={styles.retryInput}
|
||||
/>
|
||||
<Button
|
||||
className={styles.retryButton}
|
||||
onClick={handleLogsMaxTotalSizeUpdate}
|
||||
loading={pending.logsMaxSize}
|
||||
disabled={disableControls || loading}
|
||||
>
|
||||
{t('basic_settings.logs_max_total_size_update')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title={t('basic_settings.routing_title')}>
|
||||
<div className={`${styles.retryRow} ${styles.retryRowAligned} ${styles.retryRowInputGrow}`}>
|
||||
<div className="form-group">
|
||||
<label>{t('basic_settings.routing_strategy_label')}</label>
|
||||
<select
|
||||
className="input"
|
||||
value={routingStrategy}
|
||||
onChange={(e) => setRoutingStrategy(e.target.value)}
|
||||
disabled={disableControls || loading}
|
||||
>
|
||||
<option value="round-robin">{t('basic_settings.routing_strategy_round_robin')}</option>
|
||||
<option value="fill-first">{t('basic_settings.routing_strategy_fill_first')}</option>
|
||||
</select>
|
||||
<div className="hint">{t('basic_settings.routing_strategy_hint')}</div>
|
||||
</div>
|
||||
<Button
|
||||
className={styles.retryButton}
|
||||
onClick={handleRoutingStrategyUpdate}
|
||||
loading={pending.routingStrategy}
|
||||
disabled={disableControls || loading}
|
||||
>
|
||||
{t('basic_settings.routing_strategy_update')}
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Navigate, useRoutes, type Location } from 'react-router-dom';
|
||||
import { DashboardPage } from '@/pages/DashboardPage';
|
||||
import { SettingsPage } from '@/pages/SettingsPage';
|
||||
import { ApiKeysPage } from '@/pages/ApiKeysPage';
|
||||
import { AiProvidersPage } from '@/pages/AiProvidersPage';
|
||||
import { AiProvidersAmpcodeEditPage } from '@/pages/AiProvidersAmpcodeEditPage';
|
||||
import { AiProvidersClaudeEditPage } from '@/pages/AiProvidersClaudeEditPage';
|
||||
@@ -24,8 +22,8 @@ import { SystemPage } from '@/pages/SystemPage';
|
||||
const mainRoutes = [
|
||||
{ path: '/', element: <DashboardPage /> },
|
||||
{ path: '/dashboard', element: <DashboardPage /> },
|
||||
{ path: '/settings', element: <SettingsPage /> },
|
||||
{ path: '/api-keys', element: <ApiKeysPage /> },
|
||||
{ path: '/settings', element: <Navigate to="/config" replace /> },
|
||||
{ path: '/api-keys', element: <Navigate to="/config" replace /> },
|
||||
{ path: '/ai-providers/gemini/new', element: <AiProvidersGeminiEditPage /> },
|
||||
{ path: '/ai-providers/gemini/:index', element: <AiProvidersGeminiEditPage /> },
|
||||
{ path: '/ai-providers/codex/new', element: <AiProvidersCodexEditPage /> },
|
||||
|
||||
Reference in New Issue
Block a user