From e2afc17f22df9dbd7fb65dbdac97261e53c902df Mon Sep 17 00:00:00 2001 From: kongkongyo Date: Fri, 6 Feb 2026 14:20:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8C=E6=AD=A5=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20-=20=E5=8F=AF=E8=A7=86=E5=8C=96=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E7=BC=96=E8=BE=91=E5=99=A8=E4=B8=8E=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat(config): 添加可视化配置编辑器和 YAML 处理 - feat(i18n): 可视化配置编辑器国际化支持 - feat(logs): 优化日志加载,支持自动预加载 - feat(ui): API 使用统计显示成功/失败状态 - feat(ui): Payload 规则编辑器空状态显示 - refactor(nav): Config Panel 导航重构,移除 Settings/API Keys 页面 - fix(ui): Config Panel 操作栏居中,ProviderNav 移至底部 Co-Authored-By: Claude Sonnet 4.5 --- package-lock.json | 17 + package.json | 1 + src/components/config/ConfigSection.tsx | 22 + src/components/config/VisualConfigEditor.tsx | 1120 +++++++++++++++++ src/components/layout/MainLayout.tsx | 39 +- .../ProviderNav/ProviderNav.module.scss | 60 +- .../providers/ProviderNav/ProviderNav.tsx | 28 +- src/components/usage/ApiDetailsCard.tsx | 20 +- src/hooks/useVisualConfig.ts | 447 +++++++ src/i18n/locales/en.json | 146 ++- src/i18n/locales/zh-CN.json | 148 ++- src/pages/AiProvidersPage.module.scss | 9 +- src/pages/ApiKeysPage.module.scss | 56 - src/pages/ApiKeysPage.tsx | 246 ---- src/pages/ConfigPage.module.scss | 197 +++ src/pages/ConfigPage.tsx | 331 +++-- src/pages/DashboardPage.tsx | 8 +- src/pages/LogsPage.tsx | 81 +- src/pages/Settings/Settings.module.scss | 164 --- src/pages/SettingsPage.tsx | 477 ------- src/router/MainRoutes.tsx | 6 +- src/types/visualConfig.ts | 105 ++ src/utils/usage.ts | 57 +- 23 files changed, 2650 insertions(+), 1135 deletions(-) create mode 100644 src/components/config/ConfigSection.tsx create mode 100644 src/components/config/VisualConfigEditor.tsx create mode 100644 src/hooks/useVisualConfig.ts delete mode 100644 src/pages/ApiKeysPage.module.scss delete mode 100644 src/pages/ApiKeysPage.tsx delete mode 100644 src/pages/Settings/Settings.module.scss delete mode 100644 src/pages/SettingsPage.tsx create mode 100644 src/types/visualConfig.ts diff --git a/package-lock.json b/package-lock.json index 4a2c9c1..8dc4d23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "react-dom": "^19.2.1", "react-i18next": "^16.4.0", "react-router-dom": "^7.10.1", + "yaml": "^2.8.2", "zustand": "^5.0.9" }, "devDependencies": { @@ -4269,6 +4270,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "dev": true, diff --git a/package.json b/package.json index f143ce5..f397206 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "react-dom": "^19.2.1", "react-i18next": "^16.4.0", "react-router-dom": "^7.10.1", + "yaml": "^2.8.2", "zustand": "^5.0.9" }, "devDependencies": { diff --git a/src/components/config/ConfigSection.tsx b/src/components/config/ConfigSection.tsx new file mode 100644 index 0000000..53e1da6 --- /dev/null +++ b/src/components/config/ConfigSection.tsx @@ -0,0 +1,22 @@ +import type { PropsWithChildren, ReactNode } from 'react'; +import { Card } from '@/components/ui/Card'; + +interface ConfigSectionProps { + title: ReactNode; + description?: ReactNode; + className?: string; +} + +export function ConfigSection({ title, description, className, children }: PropsWithChildren) { + return ( + + {description && ( +

+ {description} +

+ )} + {children} +
+ ); +} + diff --git a/src/components/config/VisualConfigEditor.tsx b/src/components/config/VisualConfigEditor.tsx new file mode 100644 index 0000000..eff48ed --- /dev/null +++ b/src/components/config/VisualConfigEditor.tsx @@ -0,0 +1,1120 @@ +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Modal } from '@/components/ui/Modal'; +import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; +import { IconChevronDown } from '@/components/ui/icons'; +import { ConfigSection } from '@/components/config/ConfigSection'; +import type { + PayloadFilterRule, + PayloadModelEntry, + PayloadParamEntry, + PayloadParamValueType, + PayloadRule, + VisualConfigValues, +} from '@/types/visualConfig'; +import { makeClientId } from '@/types/visualConfig'; +import { + VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS, + VISUAL_CONFIG_PROTOCOL_OPTIONS, +} from '@/hooks/useVisualConfig'; +import { maskApiKey } from '@/utils/format'; +import { isValidApiKeyCharset } from '@/utils/validation'; + +interface VisualConfigEditorProps { + values: VisualConfigValues; + disabled?: boolean; + onChange: (values: Partial) => void; +} + +type ToggleRowProps = { + title: string; + description?: string; + checked: boolean; + disabled?: boolean; + onChange: (value: boolean) => void; +}; + +function ToggleRow({ title, description, checked, disabled, onChange }: ToggleRowProps) { + return ( +
+
+
{title}
+ {description && ( +
+ {description} +
+ )} +
+ +
+ ); +} + +function SectionGrid({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function Divider() { + return
; +} + +type ToastSelectOption = { value: string; label: string }; + +function ToastSelect({ + value, + options, + disabled, + ariaLabel, + onChange, +}: { + value: string; + options: ReadonlyArray; + disabled?: boolean; + ariaLabel: string; + onChange: (value: string) => void; +}) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + + const selectedOption = options.find((opt) => opt.value === value) ?? options[0]; + + useEffect(() => { + if (!open) return; + const handleClickOutside = (event: MouseEvent) => { + if (!containerRef.current) return; + if (!containerRef.current.contains(event.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [open]); + + return ( +
+ + + {open && !disabled && ( +
+ {options.map((opt) => { + const active = opt.value === value; + return ( + + ); + })} +
+ )} +
+ ); +} + +function ApiKeysCardEditor({ + value, + disabled, + onChange, +}: { + value: string; + disabled?: boolean; + onChange: (nextValue: string) => void; +}) { + const { t } = useTranslation(); + const apiKeys = useMemo( + () => + value + .split('\n') + .map((key) => key.trim()) + .filter(Boolean), + [value] + ); + + const [modalOpen, setModalOpen] = useState(false); + const [editingIndex, setEditingIndex] = useState(null); + const [inputValue, setInputValue] = useState(''); + const [formError, setFormError] = useState(''); + + const openAddModal = () => { + setEditingIndex(null); + setInputValue(''); + setFormError(''); + setModalOpen(true); + }; + + const openEditModal = (index: number) => { + setEditingIndex(index); + setInputValue(apiKeys[index] ?? ''); + setFormError(''); + setModalOpen(true); + }; + + const closeModal = () => { + setModalOpen(false); + setInputValue(''); + setEditingIndex(null); + setFormError(''); + }; + + const updateApiKeys = (nextKeys: string[]) => { + onChange(nextKeys.join('\n')); + }; + + const handleDelete = (index: number) => { + updateApiKeys(apiKeys.filter((_, i) => i !== index)); + }; + + const handleSave = () => { + const trimmed = inputValue.trim(); + if (!trimmed) { + setFormError(t('config_management.visual.api_keys.error_empty')); + return; + } + if (!isValidApiKeyCharset(trimmed)) { + setFormError(t('config_management.visual.api_keys.error_invalid')); + return; + } + + const nextKeys = + editingIndex === null + ? [...apiKeys, trimmed] + : apiKeys.map((key, idx) => (idx === editingIndex ? trimmed : key)); + updateApiKeys(nextKeys); + closeModal(); + }; + + return ( +
+
+ + +
+ + {apiKeys.length === 0 ? ( +
+ {t('config_management.visual.api_keys.empty')} +
+ ) : ( +
+ {apiKeys.map((key, index) => ( +
+
+
#{index + 1}
+
API Key
+
{maskApiKey(String(key || ''))}
+
+
+ + +
+
+ ))} +
+ )} + +
{t('config_management.visual.api_keys.hint')}
+ + + + + + } + > + setInputValue(e.target.value)} + disabled={disabled} + error={formError || undefined} + hint={t('config_management.visual.api_keys.input_hint')} + /> + +
+ ); +} + +function StringListEditor({ + value, + disabled, + placeholder, + onChange, +}: { + value: string[]; + disabled?: boolean; + placeholder?: string; + onChange: (next: string[]) => void; +}) { + const { t } = useTranslation(); + const items = value.length ? value : []; + + const updateItem = (index: number, nextValue: string) => + onChange(items.map((item, i) => (i === index ? nextValue : item))); + const addItem = () => onChange([...items, '']); + const removeItem = (index: number) => onChange(items.filter((_, i) => i !== index)); + + return ( +
+ {items.map((item, index) => ( +
+ updateItem(index, e.target.value)} + disabled={disabled} + style={{ flex: 1 }} + /> + +
+ ))} +
+ +
+
+ ); +} + +function PayloadRulesEditor({ + value, + disabled, + protocolFirst = false, + onChange, +}: { + value: PayloadRule[]; + disabled?: boolean; + protocolFirst?: boolean; + onChange: (next: PayloadRule[]) => void; +}) { + const { t } = useTranslation(); + const rules = value.length ? value : []; + + const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]); + const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex)); + + const updateRule = (ruleIndex: number, patch: Partial) => + onChange(rules.map((rule, i) => (i === ruleIndex ? { ...rule, ...patch } : rule))); + + const addModel = (ruleIndex: number) => { + const rule = rules[ruleIndex]; + const nextModel: PayloadModelEntry = { id: makeClientId(), name: '', protocol: undefined }; + updateRule(ruleIndex, { models: [...rule.models, nextModel] }); + }; + + const removeModel = (ruleIndex: number, modelIndex: number) => { + const rule = rules[ruleIndex]; + updateRule(ruleIndex, { models: rule.models.filter((_, i) => i !== modelIndex) }); + }; + + const updateModel = (ruleIndex: number, modelIndex: number, patch: Partial) => { + const rule = rules[ruleIndex]; + updateRule(ruleIndex, { + models: rule.models.map((m, i) => (i === modelIndex ? { ...m, ...patch } : m)), + }); + }; + + const addParam = (ruleIndex: number) => { + const rule = rules[ruleIndex]; + const nextParam: PayloadParamEntry = { + id: makeClientId(), + path: '', + valueType: 'string', + value: '', + }; + updateRule(ruleIndex, { params: [...rule.params, nextParam] }); + }; + + const removeParam = (ruleIndex: number, paramIndex: number) => { + const rule = rules[ruleIndex]; + updateRule(ruleIndex, { params: rule.params.filter((_, i) => i !== paramIndex) }); + }; + + const updateParam = (ruleIndex: number, paramIndex: number, patch: Partial) => { + const rule = rules[ruleIndex]; + updateRule(ruleIndex, { + params: rule.params.map((p, i) => (i === paramIndex ? { ...p, ...patch } : p)), + }); + }; + + const getValuePlaceholder = (valueType: PayloadParamValueType) => { + switch (valueType) { + case 'string': + return t('config_management.visual.payload_rules.value_string'); + case 'number': + return t('config_management.visual.payload_rules.value_number'); + case 'boolean': + return t('config_management.visual.payload_rules.value_boolean'); + case 'json': + return t('config_management.visual.payload_rules.value_json'); + default: + return t('config_management.visual.payload_rules.value_default'); + } + }; + + return ( +
+ {rules.map((rule, ruleIndex) => ( +
+
+
{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}
+ +
+ +
+
{t('config_management.visual.payload_rules.models')}
+ {(rule.models.length ? rule.models : []).map((model, modelIndex) => ( +
+ {protocolFirst ? ( + <> + + updateModel(ruleIndex, modelIndex, { + protocol: (nextValue || undefined) as PayloadModelEntry['protocol'], + }) + } + /> + updateModel(ruleIndex, modelIndex, { name: e.target.value })} + disabled={disabled} + /> + + ) : ( + <> + updateModel(ruleIndex, modelIndex, { name: e.target.value })} + disabled={disabled} + /> + + updateModel(ruleIndex, modelIndex, { + protocol: (nextValue || undefined) as PayloadModelEntry['protocol'], + }) + } + /> + + )} + +
+ ))} +
+ +
+
+ +
+
{t('config_management.visual.payload_rules.params')}
+ {(rule.params.length ? rule.params : []).map((param, paramIndex) => ( +
+ updateParam(ruleIndex, paramIndex, { path: e.target.value })} + disabled={disabled} + /> + + updateParam(ruleIndex, paramIndex, { valueType: nextValue as PayloadParamValueType }) + } + /> + updateParam(ruleIndex, paramIndex, { value: e.target.value })} + disabled={disabled} + /> + +
+ ))} +
+ +
+
+
+ ))} + + {rules.length === 0 && ( +
+ {t('config_management.visual.payload_rules.no_rules')} +
+ )} + +
+ +
+
+ ); +} + +function PayloadFilterRulesEditor({ + value, + disabled, + onChange, +}: { + value: PayloadFilterRule[]; + disabled?: boolean; + onChange: (next: PayloadFilterRule[]) => void; +}) { + const { t } = useTranslation(); + const rules = value.length ? value : []; + + const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]); + const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex)); + + const updateRule = (ruleIndex: number, patch: Partial) => + onChange(rules.map((rule, i) => (i === ruleIndex ? { ...rule, ...patch } : rule))); + + const addModel = (ruleIndex: number) => { + const rule = rules[ruleIndex]; + const nextModel: PayloadModelEntry = { id: makeClientId(), name: '', protocol: undefined }; + updateRule(ruleIndex, { models: [...rule.models, nextModel] }); + }; + + const removeModel = (ruleIndex: number, modelIndex: number) => { + const rule = rules[ruleIndex]; + updateRule(ruleIndex, { models: rule.models.filter((_, i) => i !== modelIndex) }); + }; + + const updateModel = (ruleIndex: number, modelIndex: number, patch: Partial) => { + const rule = rules[ruleIndex]; + updateRule(ruleIndex, { + models: rule.models.map((m, i) => (i === modelIndex ? { ...m, ...patch } : m)), + }); + }; + + return ( +
+ {rules.map((rule, ruleIndex) => ( +
+
+
{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}
+ +
+ +
+
{t('config_management.visual.payload_rules.models')}
+ {rule.models.map((model, modelIndex) => ( +
+ updateModel(ruleIndex, modelIndex, { name: e.target.value })} + disabled={disabled} + /> + + updateModel(ruleIndex, modelIndex, { + protocol: (nextValue || undefined) as PayloadModelEntry['protocol'], + }) + } + /> + +
+ ))} +
+ +
+
+ +
+
{t('config_management.visual.payload_rules.remove_params')}
+ updateRule(ruleIndex, { params })} + /> +
+
+ ))} + + {rules.length === 0 && ( +
+ {t('config_management.visual.payload_rules.no_rules')} +
+ )} + +
+ +
+
+ ); +} + +export function VisualConfigEditor({ values, disabled = false, onChange }: VisualConfigEditorProps) { + const { t } = useTranslation(); + const isKeepaliveDisabled = values.streaming.keepaliveSeconds === '' || values.streaming.keepaliveSeconds === '0'; + const isNonstreamKeepaliveDisabled = + values.streaming.nonstreamKeepaliveInterval === '' || values.streaming.nonstreamKeepaliveInterval === '0'; + + return ( +
+ + + onChange({ host: e.target.value })} + disabled={disabled} + /> + onChange({ port: e.target.value })} + disabled={disabled} + /> + + + + +
+ onChange({ tlsEnable })} + /> + {values.tlsEnable && ( + <> + + + onChange({ tlsCert: e.target.value })} + disabled={disabled} + /> + onChange({ tlsKey: e.target.value })} + disabled={disabled} + /> + + + )} +
+
+ + +
+ onChange({ rmAllowRemote })} + /> + onChange({ rmDisableControlPanel })} + /> + + onChange({ rmSecretKey: e.target.value })} + disabled={disabled} + /> + onChange({ rmPanelRepo: e.target.value })} + disabled={disabled} + /> + +
+
+ + +
+ onChange({ authDir: e.target.value })} + disabled={disabled} + hint={t('config_management.visual.sections.auth.auth_dir_hint')} + /> + onChange({ apiKeysText })} + /> +
+
+ + +
+ + onChange({ debug })} + /> + onChange({ commercialMode })} + /> + onChange({ loggingToFile })} + /> + onChange({ usageStatisticsEnabled })} + /> + + + + onChange({ logsMaxTotalSizeMb: e.target.value })} + disabled={disabled} + /> + onChange({ usageRecordsRetentionDays: e.target.value })} + disabled={disabled} + hint={t('config_management.visual.sections.system.usage_retention_hint')} + /> + +
+
+ + +
+ + onChange({ proxyUrl: e.target.value })} + disabled={disabled} + /> + onChange({ requestRetry: e.target.value })} + disabled={disabled} + /> + onChange({ maxRetryInterval: e.target.value })} + disabled={disabled} + /> +
+ + + onChange({ routingStrategy: nextValue as VisualConfigValues['routingStrategy'] }) + } + /> +
{t('config_management.visual.sections.network.routing_strategy_hint')}
+
+
+ + onChange({ forceModelPrefix })} + /> + onChange({ wsAuth })} + /> +
+
+ + +
+ onChange({ quotaSwitchProject })} + /> + onChange({ quotaSwitchPreviewModel })} + /> +
+
+ + +
+ +
+ +
+ + onChange({ streaming: { ...values.streaming, keepaliveSeconds: e.target.value } }) + } + disabled={disabled} + /> + {isKeepaliveDisabled && ( + + {t('config_management.visual.sections.streaming.disabled')} + + )} +
+
{t('config_management.visual.sections.streaming.keepalive_hint')}
+
+ onChange({ streaming: { ...values.streaming, bootstrapRetries: e.target.value } })} + disabled={disabled} + hint={t('config_management.visual.sections.streaming.bootstrap_hint')} + /> +
+ + +
+ +
+ + onChange({ + streaming: { ...values.streaming, nonstreamKeepaliveInterval: e.target.value }, + }) + } + disabled={disabled} + /> + {isNonstreamKeepaliveDisabled && ( + + {t('config_management.visual.sections.streaming.disabled')} + + )} +
+
+ {t('config_management.visual.sections.streaming.nonstream_keepalive_hint')} +
+
+
+
+
+ + +
+
+
{t('config_management.visual.sections.payload.default_rules')}
+
+ {t('config_management.visual.sections.payload.default_rules_desc')} +
+ onChange({ payloadDefaultRules })} + /> +
+ +
+
{t('config_management.visual.sections.payload.override_rules')}
+
+ {t('config_management.visual.sections.payload.override_rules_desc')} +
+ onChange({ payloadOverrideRules })} + /> +
+ +
+
{t('config_management.visual.sections.payload.filter_rules')}
+
+ {t('config_management.visual.sections.payload.filter_rules_desc')} +
+ onChange({ payloadFilterRules })} + /> +
+
+
+
+ ); +} diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 051f143..81c6311 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -19,12 +19,10 @@ import { IconChartLine, IconFileText, IconInfo, - IconKey, IconLayoutDashboard, IconScrollText, IconSettings, IconShield, - IconSlidersHorizontal, IconTimer, IconActivity, } from '@/components/ui/icons'; @@ -41,8 +39,6 @@ import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh'; const sidebarIcons: Record = { dashboard: , - settings: , - apiKeys: , aiProviders: , authFiles: , oauth: , @@ -247,6 +243,37 @@ export function MainLayout() { }; }, []); + // 将主内容区的中心点写入 CSS 变量,供底部浮层(如配置面板操作栏)对齐到内容区而非整窗 + useLayoutEffect(() => { + const updateContentCenter = () => { + const el = contentRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + document.documentElement.style.setProperty('--content-center-x', `${centerX}px`); + }; + + updateContentCenter(); + + const resizeObserver = + typeof ResizeObserver !== 'undefined' && contentRef.current + ? new ResizeObserver(updateContentCenter) + : null; + + if (resizeObserver && contentRef.current) { + resizeObserver.observe(contentRef.current); + } + + window.addEventListener('resize', updateContentCenter); + + return () => { + if (resizeObserver) { + resizeObserver.disconnect(); + } + window.removeEventListener('resize', updateContentCenter); + }; + }, []); + // 5秒后自动收起品牌名称 useEffect(() => { brandCollapseTimer.current = setTimeout(() => { @@ -359,14 +386,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 }] : []), diff --git a/src/components/providers/ProviderNav/ProviderNav.module.scss b/src/components/providers/ProviderNav/ProviderNav.module.scss index 310a857..7c169d7 100644 --- a/src/components/providers/ProviderNav/ProviderNav.module.scss +++ b/src/components/providers/ProviderNav/ProviderNav.module.scss @@ -2,25 +2,34 @@ .navContainer { position: fixed; - right: 24px; - top: 50%; - transform: translateY(-50%); + left: var(--content-center-x, 50%); + bottom: calc(12px + env(safe-area-inset-bottom)); + transform: translateX(-50%); z-index: 50; pointer-events: auto; + width: fit-content; + max-width: calc(100vw - 24px); } .navList { position: relative; - display: flex; - flex-direction: column; - gap: 8px; - padding: 12px 8px; + display: inline-flex; + flex-direction: row; + gap: 6px; + padding: 10px 12px; background: rgba(255, 255, 255, 0.7); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 16px; + border-radius: 999px; box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1); + overflow-x: auto; + scrollbar-width: none; + max-width: inherit; + + &::-webkit-scrollbar { + display: none; + } } .indicator { @@ -29,7 +38,7 @@ left: 0; pointer-events: none; opacity: 0; - border-radius: 10px; + border-radius: 999px; background: rgba(59, 130, 246, 0.15); box-shadow: inset 0 0 0 2px var(--primary-color); transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1), @@ -58,9 +67,10 @@ padding: 0; border: none; background: transparent; - border-radius: 10px; + border-radius: 999px; cursor: pointer; transition: background-color 0.2s ease, transform 0.15s ease; + flex: 0 0 auto; &:hover { background: rgba(0, 0, 0, 0.06); @@ -80,8 +90,8 @@ } .icon { - width: 28px; - height: 28px; + width: 24px; + height: 24px; object-fit: contain; } @@ -110,42 +120,20 @@ } } -// 小屏幕改为底部横向浮层 +// 小屏幕进一步收紧尺寸 @media (max-width: 1200px) { .navContainer { - top: auto; - right: auto; - left: 50%; - bottom: calc(12px + env(safe-area-inset-bottom)); - transform: translateX(-50%); - width: fit-content; - max-width: calc(100vw - 24px); + max-width: calc(100vw - 16px); } .navList { - display: inline-flex; - flex-direction: row; gap: 6px; padding: 8px 10px; - border-radius: 999px; - overflow-x: auto; - scrollbar-width: none; - max-width: inherit; - - &::-webkit-scrollbar { - display: none; - } - } - - .indicator { - border-radius: 999px; } .navItem { width: 36px; height: 36px; - border-radius: 999px; - flex: 0 0 auto; } .icon { diff --git a/src/components/providers/ProviderNav/ProviderNav.tsx b/src/components/providers/ProviderNav/ProviderNav.tsx index c58c6db..1dd7960 100644 --- a/src/components/providers/ProviderNav/ProviderNav.tsx +++ b/src/components/providers/ProviderNav/ProviderNav.tsx @@ -41,6 +41,7 @@ export function ProviderNav() { const [activeProvider, setActiveProvider] = useState(null); const contentScrollerRef = useRef(null); const navListRef = useRef(null); + const navContainerRef = useRef(null); const itemRefs = useRef>({ gemini: null, codex: null, @@ -170,6 +171,31 @@ export function ProviderNav() { updateIndicator(activeProvider); }, [activeProvider, shouldShow, updateIndicator]); + // Expose overlay height to the page, so it can reserve bottom padding and avoid being covered. + useLayoutEffect(() => { + if (!shouldShow) return; + + const el = navContainerRef.current; + if (!el) return; + + const updateHeight = () => { + const height = el.getBoundingClientRect().height; + document.documentElement.style.setProperty('--provider-nav-height', `${height}px`); + }; + + updateHeight(); + window.addEventListener('resize', updateHeight); + + const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateHeight); + ro?.observe(el); + + return () => { + ro?.disconnect(); + window.removeEventListener('resize', updateHeight); + document.documentElement.style.removeProperty('--provider-nav-height'); + }; + }, [shouldShow]); + const scrollToProvider = (providerId: ProviderId) => { const container = getScrollContainer(); const element = document.getElementById(`provider-${providerId}`); @@ -204,7 +230,7 @@ export function ProviderNav() { }, [activeProvider, shouldShow, updateIndicator]); const navContent = ( -
+
{api.endpoint}
- {t('usage_stats.requests_count')}: {api.totalRequests} + + + {t('usage_stats.requests_count')}: {api.totalRequests.toLocaleString()} + + + ({api.successCount.toLocaleString()}{' '} + {api.failureCount.toLocaleString()}) + + - Tokens: {formatTokensInMillions(api.totalTokens)} + {t('usage_stats.tokens_count')}: {formatTokensInMillions(api.totalTokens)} {hasPrices && api.totalCost > 0 && ( @@ -61,7 +69,13 @@ export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardP
{model} - {stats.requests} {t('usage_stats.requests_count')} + + {stats.requests.toLocaleString()} + + ({stats.successCount.toLocaleString()}{' '} + {stats.failureCount.toLocaleString()}) + + {formatTokensInMillions(stats.tokens)}
diff --git a/src/hooks/useVisualConfig.ts b/src/hooks/useVisualConfig.ts new file mode 100644 index 0000000..1f7ed30 --- /dev/null +++ b/src/hooks/useVisualConfig.ts @@ -0,0 +1,447 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import type { + PayloadFilterRule, + PayloadParamValueType, + PayloadRule, + VisualConfigValues, +} from '@/types/visualConfig'; +import { DEFAULT_VISUAL_VALUES } from '@/types/visualConfig'; + +function hasOwn(obj: unknown, key: string): obj is Record { + return obj !== null && typeof obj === 'object' && Object.prototype.hasOwnProperty.call(obj, key); +} + +function asRecord(value: unknown): Record | null { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +function extractApiKeyValue(raw: unknown): string | null { + if (typeof raw === 'string') { + const trimmed = raw.trim(); + return trimmed ? trimmed : null; + } + + const record = asRecord(raw); + if (!record) return null; + + const candidates = [record['api-key'], record.apiKey, record.key, record.Key]; + for (const candidate of candidates) { + if (typeof candidate === 'string') { + const trimmed = candidate.trim(); + if (trimmed) return trimmed; + } + } + + return null; +} + +function parseApiKeysText(raw: unknown): string { + if (!Array.isArray(raw)) return ''; + + const keys: string[] = []; + for (const item of raw) { + const key = extractApiKeyValue(item); + if (key) keys.push(key); + } + return keys.join('\n'); +} + +function ensureRecord(parent: Record, key: string): Record { + const existing = asRecord(parent[key]); + if (existing) return existing; + const next: Record = {}; + parent[key] = next; + return next; +} + +function deleteIfEmpty(parent: Record, key: string): void { + const value = asRecord(parent[key]); + if (!value) return; + if (Object.keys(value).length === 0) delete parent[key]; +} + +function setBoolean(obj: Record, key: string, value: boolean): void { + if (value) { + obj[key] = true; + return; + } + if (hasOwn(obj, key)) obj[key] = false; +} + +function setString(obj: Record, key: string, value: unknown): void { + const safe = typeof value === 'string' ? value : ''; + const trimmed = safe.trim(); + if (trimmed !== '') { + obj[key] = safe; + return; + } + if (hasOwn(obj, key)) delete obj[key]; +} + +function setIntFromString(obj: Record, key: string, value: unknown): void { + const safe = typeof value === 'string' ? value : ''; + const trimmed = safe.trim(); + if (trimmed === '') { + if (hasOwn(obj, key)) delete obj[key]; + return; + } + + const parsed = Number.parseInt(trimmed, 10); + if (Number.isFinite(parsed)) { + obj[key] = parsed; + return; + } + + if (hasOwn(obj, key)) delete obj[key]; +} + +function deepClone(value: T): T { + if (typeof structuredClone === 'function') return structuredClone(value); + return JSON.parse(JSON.stringify(value)) as T; +} + +function parsePayloadRules(rules: unknown): PayloadRule[] { + if (!Array.isArray(rules)) return []; + + return rules.map((rule, index) => ({ + id: `payload-rule-${index}`, + models: Array.isArray((rule as any)?.models) + ? ((rule as any).models as unknown[]).map((model: any, modelIndex: number) => ({ + id: `model-${index}-${modelIndex}`, + name: typeof model === 'string' ? model : model?.name || '', + protocol: typeof model === 'object' ? (model?.protocol as any) : undefined, + })) + : [], + params: (rule as any)?.params + ? Object.entries((rule as any).params as Record).map(([path, value], pIndex) => ({ + id: `param-${index}-${pIndex}`, + path, + valueType: + typeof value === 'number' + ? 'number' + : typeof value === 'boolean' + ? 'boolean' + : typeof value === 'object' + ? 'json' + : 'string', + value: String(value), + })) + : [], + })); +} + +function parsePayloadFilterRules(rules: unknown): PayloadFilterRule[] { + if (!Array.isArray(rules)) return []; + + return rules.map((rule, index) => ({ + id: `payload-filter-rule-${index}`, + models: Array.isArray((rule as any)?.models) + ? ((rule as any).models as unknown[]).map((model: any, modelIndex: number) => ({ + id: `filter-model-${index}-${modelIndex}`, + name: typeof model === 'string' ? model : model?.name || '', + protocol: typeof model === 'object' ? (model?.protocol as any) : undefined, + })) + : [], + params: Array.isArray((rule as any)?.params) ? ((rule as any).params as unknown[]).map(String) : [], + })); +} + +function serializePayloadRulesForYaml(rules: PayloadRule[]): any[] { + return rules + .map((rule) => { + const models = (rule.models || []) + .filter((m) => m.name?.trim()) + .map((m) => { + const obj: Record = { name: m.name.trim() }; + if (m.protocol) obj.protocol = m.protocol; + return obj; + }); + + const params: Record = {}; + for (const param of rule.params || []) { + if (!param.path?.trim()) continue; + let value: any = param.value; + if (param.valueType === 'number') { + const num = Number(param.value); + value = Number.isFinite(num) ? num : param.value; + } else if (param.valueType === 'boolean') { + value = param.value === 'true'; + } else if (param.valueType === 'json') { + try { + value = JSON.parse(param.value); + } catch { + value = param.value; + } + } + params[param.path.trim()] = value; + } + + return { models, params }; + }) + .filter((rule) => rule.models.length > 0); +} + +function serializePayloadFilterRulesForYaml(rules: PayloadFilterRule[]): any[] { + return rules + .map((rule) => { + const models = (rule.models || []) + .filter((m) => m.name?.trim()) + .map((m) => { + const obj: Record = { name: m.name.trim() }; + if (m.protocol) obj.protocol = m.protocol; + return obj; + }); + + const params = (Array.isArray(rule.params) ? rule.params : []) + .map((path) => String(path).trim()) + .filter(Boolean); + + return { models, params }; + }) + .filter((rule) => rule.models.length > 0); +} + +export function useVisualConfig() { + const [visualValues, setVisualValuesState] = useState({ + ...DEFAULT_VISUAL_VALUES, + }); + + const baselineValues = useRef({ ...DEFAULT_VISUAL_VALUES }); + + const visualDirty = useMemo(() => { + return JSON.stringify(visualValues) !== JSON.stringify(baselineValues.current); + }, [visualValues]); + + const loadVisualValuesFromYaml = useCallback((yamlContent: string) => { + try { + const parsed: any = parseYaml(yamlContent) || {}; + + const newValues: VisualConfigValues = { + host: parsed.host || '', + port: String(parsed.port || ''), + + tlsEnable: Boolean(parsed.tls?.enable), + tlsCert: parsed.tls?.cert || '', + tlsKey: parsed.tls?.key || '', + + rmAllowRemote: Boolean(parsed['remote-management']?.['allow-remote']), + rmSecretKey: parsed['remote-management']?.['secret-key'] || '', + rmDisableControlPanel: Boolean(parsed['remote-management']?.['disable-control-panel']), + rmPanelRepo: + parsed['remote-management']?.['panel-github-repository'] ?? + parsed['remote-management']?.['panel-repo'] ?? + '', + + authDir: parsed['auth-dir'] || '', + apiKeysText: parseApiKeysText(parsed['api-keys']), + + debug: Boolean(parsed.debug), + commercialMode: Boolean(parsed['commercial-mode']), + loggingToFile: Boolean(parsed['logging-to-file']), + logsMaxTotalSizeMb: String(parsed['logs-max-total-size-mb'] || ''), + usageStatisticsEnabled: Boolean(parsed['usage-statistics-enabled']), + usageRecordsRetentionDays: String(parsed['usage-records-retention-days'] ?? ''), + + proxyUrl: parsed['proxy-url'] || '', + forceModelPrefix: Boolean(parsed['force-model-prefix']), + requestRetry: String(parsed['request-retry'] || ''), + maxRetryInterval: String(parsed['max-retry-interval'] || ''), + wsAuth: Boolean(parsed['ws-auth']), + + quotaSwitchProject: Boolean(parsed['quota-exceeded']?.['switch-project'] ?? true), + quotaSwitchPreviewModel: Boolean( + parsed['quota-exceeded']?.['switch-preview-model'] ?? true + ), + + routingStrategy: (parsed.routing?.strategy || 'round-robin') as 'round-robin' | 'fill-first', + + payloadDefaultRules: parsePayloadRules(parsed.payload?.default), + payloadOverrideRules: parsePayloadRules(parsed.payload?.override), + payloadFilterRules: parsePayloadFilterRules(parsed.payload?.filter), + + streaming: { + keepaliveSeconds: String(parsed.streaming?.['keepalive-seconds'] ?? ''), + bootstrapRetries: String(parsed.streaming?.['bootstrap-retries'] ?? ''), + nonstreamKeepaliveInterval: String(parsed['nonstream-keepalive-interval'] ?? ''), + }, + }; + + setVisualValuesState(newValues); + baselineValues.current = deepClone(newValues); + } catch { + setVisualValuesState({ ...DEFAULT_VISUAL_VALUES }); + baselineValues.current = deepClone(DEFAULT_VISUAL_VALUES); + } + }, []); + + const applyVisualChangesToYaml = useCallback( + (currentYaml: string): string => { + try { + const parsed = (parseYaml(currentYaml) || {}) as Record; + const values = visualValues; + + setString(parsed, 'host', values.host); + setIntFromString(parsed, 'port', values.port); + + if ( + hasOwn(parsed, 'tls') || + values.tlsEnable || + values.tlsCert.trim() || + values.tlsKey.trim() + ) { + const tls = ensureRecord(parsed, 'tls'); + setBoolean(tls, 'enable', values.tlsEnable); + setString(tls, 'cert', values.tlsCert); + setString(tls, 'key', values.tlsKey); + deleteIfEmpty(parsed, 'tls'); + } + + if ( + hasOwn(parsed, 'remote-management') || + values.rmAllowRemote || + values.rmSecretKey.trim() || + values.rmDisableControlPanel || + values.rmPanelRepo.trim() + ) { + const rm = ensureRecord(parsed, 'remote-management'); + setBoolean(rm, 'allow-remote', values.rmAllowRemote); + setString(rm, 'secret-key', values.rmSecretKey); + setBoolean(rm, 'disable-control-panel', values.rmDisableControlPanel); + setString(rm, 'panel-github-repository', values.rmPanelRepo); + if (hasOwn(rm, 'panel-repo')) delete rm['panel-repo']; + deleteIfEmpty(parsed, 'remote-management'); + } + + setString(parsed, 'auth-dir', values.authDir); + if (values.apiKeysText !== baselineValues.current.apiKeysText) { + const apiKeys = values.apiKeysText + .split('\n') + .map((key) => key.trim()) + .filter(Boolean); + if (apiKeys.length > 0) { + parsed['api-keys'] = apiKeys; + } else if (hasOwn(parsed, 'api-keys')) { + delete parsed['api-keys']; + } + } + + setBoolean(parsed, 'debug', values.debug); + + setBoolean(parsed, 'commercial-mode', values.commercialMode); + setBoolean(parsed, 'logging-to-file', values.loggingToFile); + setIntFromString(parsed, 'logs-max-total-size-mb', values.logsMaxTotalSizeMb); + setBoolean(parsed, 'usage-statistics-enabled', values.usageStatisticsEnabled); + setIntFromString( + parsed, + 'usage-records-retention-days', + values.usageRecordsRetentionDays + ); + + setString(parsed, 'proxy-url', values.proxyUrl); + setBoolean(parsed, 'force-model-prefix', values.forceModelPrefix); + setIntFromString(parsed, 'request-retry', values.requestRetry); + setIntFromString(parsed, 'max-retry-interval', values.maxRetryInterval); + setBoolean(parsed, 'ws-auth', values.wsAuth); + + if (hasOwn(parsed, 'quota-exceeded') || !values.quotaSwitchProject || !values.quotaSwitchPreviewModel) { + const quota = ensureRecord(parsed, 'quota-exceeded'); + quota['switch-project'] = values.quotaSwitchProject; + quota['switch-preview-model'] = values.quotaSwitchPreviewModel; + deleteIfEmpty(parsed, 'quota-exceeded'); + } + + if (hasOwn(parsed, 'routing') || values.routingStrategy !== 'round-robin') { + const routing = ensureRecord(parsed, 'routing'); + routing.strategy = values.routingStrategy; + deleteIfEmpty(parsed, 'routing'); + } + + const keepaliveSeconds = + typeof values.streaming?.keepaliveSeconds === 'string' ? values.streaming.keepaliveSeconds : ''; + const bootstrapRetries = + typeof values.streaming?.bootstrapRetries === 'string' ? values.streaming.bootstrapRetries : ''; + const nonstreamKeepaliveInterval = + typeof values.streaming?.nonstreamKeepaliveInterval === 'string' + ? values.streaming.nonstreamKeepaliveInterval + : ''; + + const streamingDefined = + hasOwn(parsed, 'streaming') || keepaliveSeconds.trim() || bootstrapRetries.trim(); + if (streamingDefined) { + const streaming = ensureRecord(parsed, 'streaming'); + setIntFromString(streaming, 'keepalive-seconds', keepaliveSeconds); + setIntFromString(streaming, 'bootstrap-retries', bootstrapRetries); + deleteIfEmpty(parsed, 'streaming'); + } + + setIntFromString(parsed, 'nonstream-keepalive-interval', nonstreamKeepaliveInterval); + + if ( + hasOwn(parsed, 'payload') || + values.payloadDefaultRules.length > 0 || + values.payloadOverrideRules.length > 0 || + values.payloadFilterRules.length > 0 + ) { + const payload = ensureRecord(parsed, 'payload'); + if (values.payloadDefaultRules.length > 0) { + payload.default = serializePayloadRulesForYaml(values.payloadDefaultRules); + } else if (hasOwn(payload, 'default')) { + delete payload.default; + } + if (values.payloadOverrideRules.length > 0) { + payload.override = serializePayloadRulesForYaml(values.payloadOverrideRules); + } else if (hasOwn(payload, 'override')) { + delete payload.override; + } + if (values.payloadFilterRules.length > 0) { + payload.filter = serializePayloadFilterRulesForYaml(values.payloadFilterRules); + } else if (hasOwn(payload, 'filter')) { + delete payload.filter; + } + deleteIfEmpty(parsed, 'payload'); + } + + return stringifyYaml(parsed, { indent: 2, lineWidth: 120, minContentWidth: 0 }); + } catch { + return currentYaml; + } + }, + [visualValues] + ); + + const setVisualValues = useCallback((newValues: Partial) => { + setVisualValuesState((prev) => { + const next: VisualConfigValues = { ...prev, ...newValues } as VisualConfigValues; + if (newValues.streaming) { + next.streaming = { ...prev.streaming, ...newValues.streaming }; + } + return next; + }); + }, []); + + return { + visualValues, + visualDirty, + loadVisualValuesFromYaml, + applyVisualChangesToYaml, + setVisualValues, + }; +} + +export const VISUAL_CONFIG_PROTOCOL_OPTIONS = [ + { value: '', label: '默认' }, + { value: 'openai', label: 'OpenAI' }, + { value: 'gemini', label: 'Gemini' }, + { value: 'claude', label: 'Claude' }, + { value: 'codex', label: 'Codex' }, + { value: 'antigravity', label: 'Antigravity' }, +] as const; + +export const VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS = [ + { value: 'string', label: '字符串' }, + { value: 'number', label: '数字' }, + { value: 'boolean', label: '布尔' }, + { value: 'json', label: 'JSON' }, +] as const satisfies ReadonlyArray<{ value: PayloadParamValueType; label: string }>; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5f896dc..ec990b5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", "monitor": "Monitor Center" @@ -854,11 +854,11 @@ "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", - "description": "View and edit the server-side config.yaml file. Validate the syntax before saving.", + "description": "Edit config.yaml via visual editor or source file", "status_idle": "Waiting for action", "status_loading": "Loading configuration...", "status_loaded": "Configuration loaded", @@ -875,7 +875,145 @@ "search_button": "Search", "search_no_results": "No results", "search_prev": "Previous", - "search_next": "Next" + "search_next": "Next", + "tabs": { + "visual": "Visual Editor", + "source": "Source File Editor" + }, + "visual": { + "sections": { + "server": { + "title": "Server Configuration", + "description": "Basic server settings", + "host": "Host Address", + "port": "Port" + }, + "tls": { + "title": "TLS/SSL Configuration", + "description": "HTTPS secure connection settings", + "enable": "Enable TLS", + "enable_desc": "Enable HTTPS secure connection", + "cert": "Certificate File Path", + "key": "Private Key File Path" + }, + "remote": { + "title": "Remote Management", + "description": "Remote access and control panel settings", + "allow_remote": "Allow Remote Access", + "allow_remote_desc": "Allow management access from other hosts", + "disable_panel": "Disable Control Panel", + "disable_panel_desc": "Disable the built-in web control panel", + "secret_key": "Management Key", + "secret_key_placeholder": "Set management key", + "panel_repo": "Panel Repository" + }, + "auth": { + "title": "Authentication Configuration", + "description": "API keys and authentication directory settings", + "auth_dir": "Auth Directory (auth-dir)", + "auth_dir_hint": "Directory path for authentication files (supports ~)" + }, + "system": { + "title": "System Configuration", + "description": "Debug, logging, statistics, and performance settings", + "debug": "Debug Mode", + "debug_desc": "Enable verbose debug logging", + "commercial_mode": "Commercial Mode", + "commercial_mode_desc": "Disable high-overhead middleware to reduce memory under high concurrency", + "logging_to_file": "Log to File", + "logging_to_file_desc": "Save logs to rotating files", + "usage_statistics": "Usage Statistics", + "usage_statistics_desc": "Collect usage statistics", + "logs_max_size": "Log File Size Limit (MB)", + "usage_retention_days": "Usage Records Retention Days", + "usage_retention_hint": "0 means no limit (no cleanup)" + }, + "network": { + "title": "Network Configuration", + "description": "Proxy, retry, and routing settings", + "proxy_url": "Proxy URL", + "request_retry": "Request Retry Count", + "max_retry_interval": "Max Retry Interval (seconds)", + "routing_strategy": "Routing Strategy", + "routing_strategy_hint": "Select credential selection strategy", + "strategy_round_robin": "Round Robin", + "strategy_fill_first": "Fill First", + "force_model_prefix": "Force Model Prefix", + "force_model_prefix_desc": "Unprefixed model requests only use credentials without prefix", + "ws_auth": "WebSocket Authentication", + "ws_auth_desc": "Enable WebSocket authentication (/v1/ws)" + }, + "quota": { + "title": "Quota Fallback", + "description": "Fallback strategy when quota is exceeded", + "switch_project": "Switch Project", + "switch_project_desc": "Automatically switch to another project when quota is exceeded", + "switch_preview_model": "Switch to Preview Model", + "switch_preview_model_desc": "Switch to preview model version when quota is exceeded" + }, + "streaming": { + "title": "Streaming Configuration", + "description": "Keepalive and bootstrap retry settings", + "keepalive_seconds": "Keepalive Seconds", + "keepalive_hint": "Set to 0 or leave empty to disable keepalive", + "bootstrap_retries": "Bootstrap Retries", + "bootstrap_hint": "Number of retries during stream startup (before first byte)", + "nonstream_keepalive": "Non-stream Keepalive Interval (seconds)", + "nonstream_keepalive_hint": "Send blank lines every N seconds for non-streaming responses to prevent idle timeout, set to 0 or leave empty to disable", + "disabled": "Disabled" + }, + "payload": { + "title": "Payload Configuration", + "description": "Default values, override rules, and filter rules", + "default_rules": "Default Rules", + "default_rules_desc": "Use these default values when parameters are not specified in the request", + "override_rules": "Override Rules", + "override_rules_desc": "Force override parameter values in the request", + "filter_rules": "Filter Rules", + "filter_rules_desc": "Pre-filter upstream request body via JSON Path, automatically remove non-compliant/redundant parameters (Request Sanitization)" + } + }, + "api_keys": { + "label": "API Keys List (api-keys)", + "add": "Add API Key", + "empty": "No API keys", + "hint": "Each entry represents an API key (consistent with 'API Key Management' page style)", + "edit_title": "Edit API Key", + "add_title": "Add API Key", + "input_label": "API Key", + "input_placeholder": "Paste your API key", + "input_hint": "This only modifies the local config file content, it will not sync to the API Key Management interface", + "error_empty": "Please enter an API key", + "error_invalid": "API key contains invalid characters" + }, + "payload_rules": { + "rule": "Rule", + "models": "Applicable Models", + "model_name": "Model Name", + "provider_type": "Provider Type", + "add_model": "Add Model", + "params": "Parameter Settings", + "remove_params": "Remove Parameters", + "json_path": "JSON Path (e.g., temperature)", + "json_path_filter": "JSON Path (gjson/sjson), e.g., generationConfig.thinkingConfig.thinkingBudget", + "param_type": "Parameter Type", + "add_param": "Add Parameter", + "no_rules": "No rules", + "add_rule": "Add Rule", + "value_string": "String value", + "value_number": "Number value (e.g., 0.7)", + "value_boolean": "true or false", + "value_json": "JSON value", + "value_default": "Value" + }, + "common": { + "edit": "Edit", + "delete": "Delete", + "cancel": "Cancel", + "update": "Update", + "add": "Add" + } + } }, "quota_management": { "title": "Quota Management", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index b47ef3e..ec08b18 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -102,7 +102,7 @@ "oauth": "OAuth 登录", "quota_management": "配额管理", "usage_stats": "使用统计", - "config_management": "配置管理", + "config_management": "配置面板", "logs": "日志查看", "system_info": "中心信息", "monitor": "监控中心" @@ -423,7 +423,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}}\" 的额度", @@ -854,11 +854,11 @@ "upgrade_required_desc": "当前服务器版本不支持日志查看功能,请升级到最新版本的 CLI Proxy API 以使用此功能。" }, "config_management": { - "title": "配置管理", + "title": "配置面板", "editor_title": "配置文件", "reload": "重新加载", "save": "保存", - "description": "查看并编辑服务器上的 config.yaml 配置文件。保存前请确认语法正确。", + "description": "通过可视化或者源文件方式编辑 config.yaml 配置文件", "status_idle": "等待操作", "status_loading": "加载配置中...", "status_loaded": "配置已加载", @@ -875,7 +875,145 @@ "search_button": "搜索", "search_no_results": "无结果", "search_prev": "上一个", - "search_next": "下一个" + "search_next": "下一个", + "tabs": { + "visual": "可视化编辑", + "source": "源文件编辑" + }, + "visual": { + "sections": { + "server": { + "title": "服务器配置", + "description": "基础服务器设置", + "host": "主机地址", + "port": "端口" + }, + "tls": { + "title": "TLS/SSL 配置", + "description": "HTTPS 安全连接设置", + "enable": "启用 TLS", + "enable_desc": "启用 HTTPS 安全连接", + "cert": "证书文件路径", + "key": "私钥文件路径" + }, + "remote": { + "title": "远程管理", + "description": "远程访问和控制面板设置", + "allow_remote": "允许远程访问", + "allow_remote_desc": "允许从其他主机访问管理接口", + "disable_panel": "禁用控制面板", + "disable_panel_desc": "禁用内置的 Web 控制面板", + "secret_key": "管理密钥", + "secret_key_placeholder": "设置管理密钥", + "panel_repo": "面板仓库" + }, + "auth": { + "title": "认证配置", + "description": "API 密钥与认证文件目录设置", + "auth_dir": "认证文件目录 (auth-dir)", + "auth_dir_hint": "存放认证文件的目录路径(支持 ~)" + }, + "system": { + "title": "系统配置", + "description": "调试、日志、统计与性能调试设置", + "debug": "调试模式", + "debug_desc": "启用详细的调试日志", + "commercial_mode": "商业模式", + "commercial_mode_desc": "禁用高开销中间件以减少高并发内存", + "logging_to_file": "写入日志文件", + "logging_to_file_desc": "将日志保存到滚动文件", + "usage_statistics": "使用统计", + "usage_statistics_desc": "收集使用统计信息", + "logs_max_size": "日志文件大小限制 (MB)", + "usage_retention_days": "使用记录保留天数", + "usage_retention_hint": "0 为无限制(不清理)" + }, + "network": { + "title": "网络配置", + "description": "代理、重试和路由设置", + "proxy_url": "代理 URL", + "request_retry": "请求重试次数", + "max_retry_interval": "最大重试间隔 (秒)", + "routing_strategy": "路由策略", + "routing_strategy_hint": "选择凭据选择策略", + "strategy_round_robin": "轮询 (Round Robin)", + "strategy_fill_first": "填充优先 (Fill First)", + "force_model_prefix": "强制模型前缀", + "force_model_prefix_desc": "未带前缀的模型请求只使用无前缀凭据", + "ws_auth": "WebSocket 认证", + "ws_auth_desc": "启用 WebSocket 连接认证 (/v1/ws)" + }, + "quota": { + "title": "配额回退", + "description": "配额耗尽时的回退策略", + "switch_project": "切换项目", + "switch_project_desc": "配额耗尽时自动切换到其他项目", + "switch_preview_model": "切换预览模型", + "switch_preview_model_desc": "配额耗尽时切换到预览版本模型" + }, + "streaming": { + "title": "流式传输配置", + "description": "Keepalive 与 bootstrap 重试设置", + "keepalive_seconds": "Keepalive 秒数", + "keepalive_hint": "设置为 0 或留空表示禁用 keepalive", + "bootstrap_retries": "Bootstrap 重试次数", + "bootstrap_hint": "流式传输启动时(首包前)的重试次数", + "nonstream_keepalive": "非流式 Keepalive 间隔 (秒)", + "nonstream_keepalive_hint": "非流式响应时每隔 N 秒发送空行以防止空闲超时,设置为 0 或留空表示禁用", + "disabled": "已禁用" + }, + "payload": { + "title": "Payload 配置", + "description": "默认值、覆盖规则与过滤规则", + "default_rules": "默认规则", + "default_rules_desc": "当请求中未指定参数时,使用这些默认值", + "override_rules": "覆盖规则", + "override_rules_desc": "强制覆盖请求中的参数值", + "filter_rules": "过滤规则", + "filter_rules_desc": "通过 JSON Path 预过滤上游请求体,自动剔除不合规/冗余参数(Request Sanitization)" + } + }, + "api_keys": { + "label": "API 密钥列表 (api-keys)", + "add": "添加 API 密钥", + "empty": "暂无 API 密钥", + "hint": "每个条目代表一个 API 密钥(与 「API 密钥管理」 页面样式一致)", + "edit_title": "编辑 API 密钥", + "add_title": "添加 API 密钥", + "input_label": "API 密钥", + "input_placeholder": "粘贴你的 API 密钥", + "input_hint": "此处仅修改本地配置文件内容,不会自动同步到 API 密钥管理接口", + "error_empty": "请输入 API 密钥", + "error_invalid": "API 密钥包含无效字符" + }, + "payload_rules": { + "rule": "规则", + "models": "适用模型", + "model_name": "模型名称", + "provider_type": "供应商类型", + "add_model": "添加模型", + "params": "参数设置", + "remove_params": "移除参数", + "json_path": "JSON 路径 (如 temperature)", + "json_path_filter": "JSON 路径 (gjson/sjson),如 generationConfig.thinkingConfig.thinkingBudget", + "param_type": "参数类型", + "add_param": "添加参数", + "no_rules": "暂无规则", + "add_rule": "添加规则", + "value_string": "字符串值", + "value_number": "数字值 (如 0.7)", + "value_boolean": "true 或 false", + "value_json": "JSON 值", + "value_default": "值" + }, + "common": { + "edit": "编辑", + "delete": "删除", + "cancel": "取消", + "update": "更新", + "add": "添加" + } + } }, "quota_management": { "title": "配额管理", diff --git a/src/pages/AiProvidersPage.module.scss b/src/pages/AiProvidersPage.module.scss index 3c57fb5..57d2e70 100644 --- a/src/pages/AiProvidersPage.module.scss +++ b/src/pages/AiProvidersPage.module.scss @@ -73,12 +73,9 @@ display: flex; flex-direction: column; gap: $spacing-xl; - padding-right: 80px; // 为右侧 ProviderNav 导航条预留空间 - - @include mobile { - padding-right: 0; // 移动端导航在底部,不需要右侧预留 - padding-bottom: calc(72px + env(safe-area-inset-bottom)); - } + padding-bottom: calc( + var(--provider-nav-height, 60px) + 12px + env(safe-area-inset-bottom) + #{$spacing-md} + ); } .section { diff --git a/src/pages/ApiKeysPage.module.scss b/src/pages/ApiKeysPage.module.scss deleted file mode 100644 index 224958f..0000000 --- a/src/pages/ApiKeysPage.module.scss +++ /dev/null @@ -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; - } -} diff --git a/src/pages/ApiKeysPage.tsx b/src/pages/ApiKeysPage.tsx deleted file mode 100644 index 61573ed..0000000 --- a/src/pages/ApiKeysPage.tsx +++ /dev/null @@ -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([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - const [modalOpen, setModalOpen] = useState(false); - const [editingIndex, setEditingIndex] = useState(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 = ( -
- - -
- ); - - return ( -
-

{t('api_keys.title')}

- - - {error &&
{error}
} - - {loading ? ( -
- -
- ) : apiKeys.length === 0 ? ( - - {t('api_keys.add_button')} - - } - /> - ) : ( -
- {apiKeys.map((key, index) => ( -
-
-
#{index + 1}
-
{t('api_keys.item_title')}
-
{maskApiKey(String(key || ''))}
-
-
- - -
-
- ))} -
- )} - - - - - - } - > - setInputValue(e.target.value)} - disabled={saving} - /> - -
-
- ); -} diff --git a/src/pages/ConfigPage.module.scss b/src/pages/ConfigPage.module.scss index aa5abb0..1d3386a 100644 --- a/src/pages/ConfigPage.module.scss +++ b/src/pages/ConfigPage.module.scss @@ -6,6 +6,9 @@ display: flex; flex-direction: column; overflow-y: auto; + padding-bottom: calc( + var(--config-action-bar-height, 0px) + 16px + env(safe-area-inset-bottom) + #{$spacing-md} + ); } .pageTitle { @@ -21,6 +24,76 @@ margin: 0 0 $spacing-xl 0; } +.tabBar { + display: flex; + align-items: center; + gap: 4px; + padding: 4px; + margin-bottom: $spacing-lg; + 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: 10px 16px; + font-size: 14px; + font-weight: 600; + color: var(--text-secondary); + background: transparent; + 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, + box-shadow 0.15s ease; + + &:hover:not(:disabled) { + color: var(--text-primary); + background: var(--bg-tertiary); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &:focus { + outline: none; + } + + &:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } +} + +.tabActive { + color: var(--text-primary); + background: var(--bg-primary); + border-color: var(--border-color); + box-shadow: var(--shadow); +} + .content { display: flex; flex-direction: column; @@ -242,6 +315,130 @@ } } +.floatingActionContainer { + position: fixed; + left: var(--content-center-x, 50%); + bottom: calc(16px + env(safe-area-inset-bottom)); + transform: translateX(-50%); + z-index: 50; + pointer-events: auto; + width: fit-content; + max-width: calc(100vw - 24px); +} + +.floatingActionList { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 999px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1); + max-width: inherit; + overflow-x: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} + +.floatingStatus { + font-size: 11px; + font-weight: 600; + padding: 5px 8px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.06); + text-align: center; + max-width: min(280px, 46vw); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.floatingActionButton { + @include button-reset; + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 999px; + cursor: pointer; + color: var(--text-primary); + transition: background-color 0.2s ease, transform 0.15s ease; + + &:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.06); + transform: scale(1.08); + } + + &:active:not(:disabled) { + transform: scale(0.95); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + } +} + +.dirtyDot { + position: absolute; + top: 8px; + right: 8px; + width: 7px; + height: 7px; + border-radius: 999px; + background: #f59e0b; + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.25); +} + +:global([data-theme='dark']) { + .floatingActionList { + background: rgba(30, 30, 30, 0.7); + border-color: rgba(255, 255, 255, 0.1); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); + } + + .floatingStatus { + background: rgba(255, 255, 255, 0.08); + } + + .floatingActionButton { + &:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); + } + } +} + +@media (max-width: 1200px) { + .floatingActionContainer { + bottom: calc(12px + env(safe-area-inset-bottom)); + max-width: calc(100vw - 16px); + } + + .floatingActionList { + gap: 6px; + padding: 8px 10px; + } + + .floatingStatus { + display: none; + } + + .floatingActionButton { + width: 38px; + height: 38px; + flex: 0 0 auto; + } +} + @media (max-height: 820px) { .pageTitle { font-size: 24px; diff --git a/src/pages/ConfigPage.tsx b/src/pages/ConfigPage.tsx index 58d597b..77e3a00 100644 --- a/src/pages/ConfigPage.tsx +++ b/src/pages/ConfigPage.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { createPortal } from 'react-dom'; import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror'; import { yaml } from '@codemirror/lang-yaml'; import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search'; @@ -7,17 +8,35 @@ import { keymap } from '@codemirror/view'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; -import { IconChevronDown, IconChevronUp, IconSearch } from '@/components/ui/icons'; +import { IconCheck, IconChevronDown, IconChevronUp, IconRefreshCw, IconSearch } from '@/components/ui/icons'; +import { VisualConfigEditor } from '@/components/config/VisualConfigEditor'; +import { useVisualConfig } from '@/hooks/useVisualConfig'; import { useNotificationStore, useAuthStore, useThemeStore } from '@/stores'; import { configFileApi } from '@/services/api/configFile'; import styles from './ConfigPage.module.scss'; +type ConfigEditorTab = 'visual' | 'source'; + export function ConfigPage() { const { t } = useTranslation(); const { showNotification } = useNotificationStore(); const connectionStatus = useAuthStore((state) => state.connectionStatus); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); + const { + visualValues, + visualDirty, + loadVisualValuesFromYaml, + applyVisualChangesToYaml, + setVisualValues + } = useVisualConfig(); + + const [activeTab, setActiveTab] = useState(() => { + const saved = localStorage.getItem('config-management:tab'); + if (saved === 'visual' || saved === 'source') return saved; + return 'visual'; + }); + const [content, setContent] = useState(''); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -31,8 +50,10 @@ export function ConfigPage() { const editorRef = useRef(null); const floatingControlsRef = useRef(null); const editorWrapperRef = useRef(null); + const floatingActionsRef = useRef(null); const disableControls = connectionStatus !== 'connected'; + const isDirty = dirty || visualDirty; const loadConfig = useCallback(async () => { setLoading(true); @@ -41,13 +62,14 @@ export function ConfigPage() { const data = await configFileApi.fetchConfigYaml(); setContent(data); setDirty(false); + loadVisualValuesFromYaml(data); } catch (err: unknown) { const message = err instanceof Error ? err.message : t('notification.refresh_failed'); setError(message); } finally { setLoading(false); } - }, [t]); + }, [loadVisualValuesFromYaml, t]); useEffect(() => { loadConfig(); @@ -56,8 +78,11 @@ export function ConfigPage() { const handleSave = async () => { setSaving(true); try { - await configFileApi.saveConfigYaml(content); + const nextContent = activeTab === 'visual' ? applyVisualChangesToYaml(content) : content; + await configFileApi.saveConfigYaml(nextContent); setDirty(false); + setContent(nextContent); + loadVisualValuesFromYaml(nextContent); showNotification(t('config_management.save_success'), 'success'); } catch (err: unknown) { const message = err instanceof Error ? err.message : ''; @@ -72,6 +97,23 @@ export function ConfigPage() { setDirty(true); }, []); + const handleTabChange = useCallback((tab: ConfigEditorTab) => { + if (tab === activeTab) return; + + if (tab === 'source') { + const nextContent = applyVisualChangesToYaml(content); + if (nextContent !== content) { + setContent(nextContent); + setDirty(true); + } + } else { + loadVisualValuesFromYaml(content); + } + + setActiveTab(tab); + localStorage.setItem('config-management:tab', tab); + }, [activeTab, applyVisualChangesToYaml, content, loadVisualValuesFromYaml]); + // Search functionality const performSearch = useCallback((query: string, direction: 'next' | 'prev' = 'next') => { if (!query || !editorRef.current?.view) return; @@ -173,6 +215,8 @@ export function ConfigPage() { // Keep floating controls from covering editor content by syncing its height to a CSS variable. useLayoutEffect(() => { + if (activeTab !== 'source') return; + const controlsEl = floatingControlsRef.current; const wrapperEl = editorWrapperRef.current; if (!controlsEl || !wrapperEl) return; @@ -192,6 +236,31 @@ export function ConfigPage() { ro?.disconnect(); window.removeEventListener('resize', updatePadding); }; + }, [activeTab]); + + // Keep bottom floating actions from covering page content by syncing its height to a CSS variable. + useLayoutEffect(() => { + if (typeof window === 'undefined') return; + + const actionsEl = floatingActionsRef.current; + if (!actionsEl) return; + + const updatePadding = () => { + const height = actionsEl.getBoundingClientRect().height; + document.documentElement.style.setProperty('--config-action-bar-height', `${height}px`); + }; + + updatePadding(); + window.addEventListener('resize', updatePadding); + + const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updatePadding); + ro?.observe(actionsEl); + + return () => { + ro?.disconnect(); + window.removeEventListener('resize', updatePadding); + document.documentElement.style.removeProperty('--config-action-bar-height'); + }; }, []); // CodeMirror extensions @@ -208,131 +277,185 @@ export function ConfigPage() { if (loading) return t('config_management.status_loading'); if (error) return t('config_management.status_load_failed'); if (saving) return t('config_management.status_saving'); - if (dirty) return t('config_management.status_dirty'); + if (isDirty) return t('config_management.status_dirty'); return t('config_management.status_loaded'); }; + const isLoadedStatus = !disableControls && !loading && !error && !saving && !isDirty; + const getStatusClass = () => { if (error) return styles.error; - if (dirty) return styles.modified; + if (isDirty) return styles.modified; if (!loading && !saving) return styles.saved; return ''; }; + const floatingActions = ( +
+
+
{getStatusText()}
+ + +
+
+ ); + return (

{t('config_management.title')}

{t('config_management.description')}

+
+ + +
+
- {/* Editor */} {error &&
{error}
} -
- {/* Floating search controls */} -
-
- handleSearchChange(e.target.value)} - onKeyDown={handleSearchKeyDown} - placeholder={t('config_management.search_placeholder', { - defaultValue: '搜索配置内容...' - })} - disabled={disableControls || loading} - className={styles.searchInput} - rightElement={ -
- {searchQuery && lastSearchedQuery === searchQuery && ( - - {searchResults.total > 0 - ? `${searchResults.current} / ${searchResults.total}` - : t('config_management.search_no_results', { defaultValue: '无结果' })} - - )} - -
- } - /> -
-
- - -
-
- -
+ ) : ( +
+ {/* Floating search controls */} +
+
+ handleSearchChange(e.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder={t('config_management.search_placeholder', { + defaultValue: '搜索配置内容...' + })} + disabled={disableControls || loading} + className={styles.searchInput} + rightElement={ +
+ {searchQuery && lastSearchedQuery === searchQuery && ( + + {searchResults.total > 0 + ? `${searchResults.current} / ${searchResults.total}` + : t('config_management.search_no_results', { defaultValue: '无结果' })} + + )} + +
+ } + /> +
+
+ + +
+
+ +
+ )} {/* Controls */}
- - {getStatusText()} - -
- - -
+ {!isLoadedStatus && ( + + {getStatusText()} + + )}
+ + {typeof document !== 'undefined' ? createPortal(floatingActions, document.body) : null}
); } diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index b941a54..8054bfc 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -172,12 +172,12 @@ export function DashboardPage() { const quickStats: QuickStat[] = [ { - label: t('nav.api_keys'), + label: t('dashboard.management_keys'), value: stats.apiKeys ?? '-', icon: , - 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() {
)}
- + {t('dashboard.edit_settings')} →
diff --git a/src/pages/LogsPage.tsx b/src/pages/LogsPage.tsx index e98792e..80821f5 100644 --- a/src/pages/LogsPage.tsx +++ b/src/pages/LogsPage.tsx @@ -1,4 +1,4 @@ -import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { PointerEvent as ReactPointerEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { Card } from '@/components/ui/Card'; @@ -643,6 +643,29 @@ export function LogsPage() { const canLoadMore = !isSearching && logState.visibleFrom > 0; + const prependVisibleLines = useCallback(() => { + const node = logViewerRef.current; + if (!node) return; + if (pendingPrependScrollRef.current) return; + if (isSearching) return; + + setLogState((prev) => { + if (prev.visibleFrom <= 0) { + return prev; + } + + pendingPrependScrollRef.current = { + scrollHeight: node.scrollHeight, + scrollTop: node.scrollTop, + }; + + return { + ...prev, + visibleFrom: Math.max(prev.visibleFrom - LOAD_MORE_LINES, 0), + }; + }); + }, [isSearching]); + const handleLogScroll = () => { const node = logViewerRef.current; if (!node) return; @@ -651,14 +674,7 @@ export function LogsPage() { if (pendingPrependScrollRef.current) return; if (node.scrollTop > LOAD_MORE_THRESHOLD_PX) return; - pendingPrependScrollRef.current = { - scrollHeight: node.scrollHeight, - scrollTop: node.scrollTop, - }; - setLogState((prev) => ({ - ...prev, - visibleFrom: Math.max(prev.visibleFrom - LOAD_MORE_LINES, 0), - })); + prependVisibleLines(); }; useLayoutEffect(() => { @@ -671,6 +687,53 @@ export function LogsPage() { pendingPrependScrollRef.current = null; }, [logState.visibleFrom]); + const tryAutoLoadMoreUntilScrollable = useCallback(() => { + const node = logViewerRef.current; + if (!node) return; + if (!canLoadMore) return; + if (isSearching) return; + if (pendingPrependScrollRef.current) return; + + const hasVerticalOverflow = node.scrollHeight > node.clientHeight + 1; + if (hasVerticalOverflow) return; + + prependVisibleLines(); + }, [canLoadMore, isSearching, prependVisibleLines]); + + useEffect(() => { + if (loading) return; + if (activeTab !== 'logs') return; + + const raf = window.requestAnimationFrame(() => { + tryAutoLoadMoreUntilScrollable(); + }); + return () => { + window.cancelAnimationFrame(raf); + }; + }, [ + activeTab, + loading, + tryAutoLoadMoreUntilScrollable, + filteredLines.length, + showRawLogs, + logState.visibleFrom, + ]); + + useEffect(() => { + if (activeTab !== 'logs') return; + + const onResize = () => { + window.requestAnimationFrame(() => { + tryAutoLoadMoreUntilScrollable(); + }); + }; + + window.addEventListener('resize', onResize); + return () => { + window.removeEventListener('resize', onResize); + }; + }, [activeTab, tryAutoLoadMoreUntilScrollable]); + const copyLogLine = async (raw: string) => { const ok = await copyToClipboard(raw); if (ok) { diff --git a/src/pages/Settings/Settings.module.scss b/src/pages/Settings/Settings.module.scss deleted file mode 100644 index 291d500..0000000 --- a/src/pages/Settings/Settings.module.scss +++ /dev/null @@ -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%; - } -} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx deleted file mode 100644 index 85ccc89..0000000 --- a/src/pages/SettingsPage.tsx +++ /dev/null @@ -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>({} as Record); - 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, - 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 ( -
-

{t('basic_settings.title')}

- -
- - {error &&
{error}
} -
- - toggleSetting('debug', 'debug', value, configApi.updateDebug, t('notification.debug_updated')) - } - /> - - - toggleSetting( - 'usage', - 'usage-statistics-enabled', - value, - configApi.updateUsageStatistics, - t('notification.usage_statistics_updated') - ) - } - /> - - - toggleSetting( - 'loggingToFile', - 'logging-to-file', - value, - configApi.updateLoggingToFile, - t('notification.logging_to_file_updated') - ) - } - /> - - - toggleSetting( - 'wsAuth', - 'ws-auth', - value, - configApi.updateWsAuth, - t('notification.ws_auth_updated') - ) - } - /> - - - toggleSetting( - 'forceModelPrefix', - 'force-model-prefix', - value, - configApi.updateForceModelPrefix, - t('notification.force_model_prefix_updated') - ) - } - /> -
-
- - - setProxyValue(e.target.value)} - disabled={disableControls || loading} - /> -
- - -
-
- - -
- setRetryValue(Number(e.target.value))} - disabled={disableControls || loading} - className={styles.retryInput} - /> - -
-
- - -
- setLogsMaxTotalSizeMb(Number(e.target.value))} - disabled={disableControls || loading} - className={styles.retryInput} - /> - -
-
- - -
-
- - -
{t('basic_settings.routing_strategy_hint')}
-
- -
-
- - -
- - (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); - } - })() - } - /> - - (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); - } - })() - } - /> -
-
-
-
- ); -} diff --git a/src/router/MainRoutes.tsx b/src/router/MainRoutes.tsx index 67421d3..9741f74 100644 --- a/src/router/MainRoutes.tsx +++ b/src/router/MainRoutes.tsx @@ -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'; @@ -25,8 +23,8 @@ import { MonitorPage } from '@/pages/MonitorPage'; const mainRoutes = [ { path: '/', element: }, { path: '/dashboard', element: }, - { path: '/settings', element: }, - { path: '/api-keys', element: }, + { path: '/settings', element: }, + { path: '/api-keys', element: }, { path: '/ai-providers/gemini/new', element: }, { path: '/ai-providers/gemini/:index', element: }, { path: '/ai-providers/codex/new', element: }, diff --git a/src/types/visualConfig.ts b/src/types/visualConfig.ts new file mode 100644 index 0000000..86efcf9 --- /dev/null +++ b/src/types/visualConfig.ts @@ -0,0 +1,105 @@ +export type PayloadParamValueType = 'string' | 'number' | 'boolean' | 'json'; + +export type PayloadParamEntry = { + id: string; + path: string; + valueType: PayloadParamValueType; + value: string; +}; + +export type PayloadModelEntry = { + id: string; + name: string; + protocol?: 'openai' | 'gemini' | 'claude' | 'codex' | 'antigravity'; +}; + +export type PayloadRule = { + id: string; + models: PayloadModelEntry[]; + params: PayloadParamEntry[]; +}; + +export type PayloadFilterRule = { + id: string; + models: PayloadModelEntry[]; + params: string[]; +}; + +export interface StreamingConfig { + keepaliveSeconds: string; + bootstrapRetries: string; + nonstreamKeepaliveInterval: string; +} + +export type VisualConfigValues = { + host: string; + port: string; + tlsEnable: boolean; + tlsCert: string; + tlsKey: string; + rmAllowRemote: boolean; + rmSecretKey: string; + rmDisableControlPanel: boolean; + rmPanelRepo: string; + authDir: string; + apiKeysText: string; + debug: boolean; + commercialMode: boolean; + loggingToFile: boolean; + logsMaxTotalSizeMb: string; + usageStatisticsEnabled: boolean; + usageRecordsRetentionDays: string; + proxyUrl: string; + forceModelPrefix: boolean; + requestRetry: string; + maxRetryInterval: string; + quotaSwitchProject: boolean; + quotaSwitchPreviewModel: boolean; + routingStrategy: 'round-robin' | 'fill-first'; + wsAuth: boolean; + payloadDefaultRules: PayloadRule[]; + payloadOverrideRules: PayloadRule[]; + payloadFilterRules: PayloadFilterRule[]; + streaming: StreamingConfig; +}; + +export const makeClientId = () => { + if (typeof globalThis.crypto?.randomUUID === 'function') return globalThis.crypto.randomUUID(); + return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; +}; + +export const DEFAULT_VISUAL_VALUES: VisualConfigValues = { + host: '', + port: '', + tlsEnable: false, + tlsCert: '', + tlsKey: '', + rmAllowRemote: false, + rmSecretKey: '', + rmDisableControlPanel: false, + rmPanelRepo: '', + authDir: '', + apiKeysText: '', + debug: false, + commercialMode: false, + loggingToFile: false, + logsMaxTotalSizeMb: '', + usageStatisticsEnabled: false, + usageRecordsRetentionDays: '', + proxyUrl: '', + forceModelPrefix: false, + requestRetry: '', + maxRetryInterval: '', + quotaSwitchProject: true, + quotaSwitchPreviewModel: true, + routingStrategy: 'round-robin', + wsAuth: false, + payloadDefaultRules: [], + payloadOverrideRules: [], + payloadFilterRules: [], + streaming: { + keepaliveSeconds: '', + bootstrapRetries: '', + nonstreamKeepaliveInterval: '', + }, +}; diff --git a/src/utils/usage.ts b/src/utils/usage.ts index 48bf20c..ca50ed2 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -54,9 +54,11 @@ export interface UsageDetail { export interface ApiStats { endpoint: string; totalRequests: number; + successCount: number; + failureCount: number; totalTokens: number; totalCost: number; - models: Record; + models: Record; } const TOKENS_PER_PRICE_UNIT = 1_000_000; @@ -542,28 +544,65 @@ export function getApiStats(usageData: any, modelPrices: Record).forEach(([endpoint, apiData]) => { - const models: Record = {}; + const models: Record = {}; + let derivedSuccessCount = 0; + let derivedFailureCount = 0; let totalCost = 0; const modelsData = apiData?.models || {}; Object.entries(modelsData as Record).forEach(([modelName, modelData]) => { - models[modelName] = { - requests: modelData.total_requests || 0, - tokens: modelData.total_tokens || 0 - }; + const details = Array.isArray(modelData.details) ? modelData.details : []; + const hasExplicitCounts = + typeof modelData.success_count === 'number' || typeof modelData.failure_count === 'number'; + + let successCount = 0; + let failureCount = 0; + if (hasExplicitCounts) { + successCount += Number(modelData.success_count) || 0; + failureCount += Number(modelData.failure_count) || 0; + } const price = modelPrices[modelName]; - if (price) { - const details = Array.isArray(modelData.details) ? modelData.details : []; + if (details.length > 0 && (!hasExplicitCounts || price)) { details.forEach((detail: any) => { - totalCost += calculateCost({ ...detail, __modelName: modelName }, modelPrices); + if (!hasExplicitCounts) { + if (detail?.failed === true) { + failureCount += 1; + } else { + successCount += 1; + } + } + + if (price) { + totalCost += calculateCost({ ...detail, __modelName: modelName }, modelPrices); + } }); } + + models[modelName] = { + requests: modelData.total_requests || 0, + successCount, + failureCount, + tokens: modelData.total_tokens || 0 + }; + derivedSuccessCount += successCount; + derivedFailureCount += failureCount; }); + const hasApiExplicitCounts = + typeof apiData?.success_count === 'number' || typeof apiData?.failure_count === 'number'; + const successCount = hasApiExplicitCounts + ? (Number(apiData?.success_count) || 0) + : derivedSuccessCount; + const failureCount = hasApiExplicitCounts + ? (Number(apiData?.failure_count) || 0) + : derivedFailureCount; + result.push({ endpoint: maskUsageSensitiveValue(endpoint) || endpoint, totalRequests: apiData.total_requests || 0, + successCount, + failureCount, totalTokens: apiData.total_tokens || 0, totalCost, models