mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-06 12:50:50 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26fa1ea98e | ||
|
|
e568e4a2b5 | ||
|
|
4a0386472d | ||
|
|
b9001c27c5 | ||
|
|
e6e62e2992 | ||
|
|
f53d333198 | ||
|
|
adcf0b6582 | ||
|
|
11c2498be6 |
17
package-lock.json
generated
17
package-lock.json
generated
@@ -19,6 +19,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": {
|
||||
@@ -4241,6 +4242,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,
|
||||
|
||||
@@ -23,6 +23,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": {
|
||||
|
||||
22
src/components/config/ConfigSection.tsx
Normal file
22
src/components/config/ConfigSection.tsx
Normal file
@@ -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<ConfigSectionProps>) {
|
||||
return (
|
||||
<Card title={title} className={className}>
|
||||
{description && (
|
||||
<p style={{ margin: '-4px 0 16px 0', color: 'var(--text-secondary)', fontSize: 13 }}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
1120
src/components/config/VisualConfigEditor.tsx
Normal file
1120
src/components/config/VisualConfigEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -19,12 +19,10 @@ import {
|
||||
IconChartLine,
|
||||
IconFileText,
|
||||
IconInfo,
|
||||
IconKey,
|
||||
IconLayoutDashboard,
|
||||
IconScrollText,
|
||||
IconSettings,
|
||||
IconShield,
|
||||
IconSlidersHorizontal,
|
||||
IconTimer,
|
||||
} from '@/components/ui/icons';
|
||||
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||
@@ -40,8 +38,6 @@ import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
|
||||
const sidebarIcons: Record<string, ReactNode> = {
|
||||
dashboard: <IconLayoutDashboard size={18} />,
|
||||
settings: <IconSlidersHorizontal size={18} />,
|
||||
apiKeys: <IconKey size={18} />,
|
||||
aiProviders: <IconBot size={18} />,
|
||||
authFiles: <IconFileText size={18} />,
|
||||
oauth: <IconShield size={18} />,
|
||||
@@ -245,6 +241,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(() => {
|
||||
@@ -357,14 +384,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 }]
|
||||
: []),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -41,6 +41,7 @@ export function ProviderNav() {
|
||||
const [activeProvider, setActiveProvider] = useState<ProviderId | null>(null);
|
||||
const contentScrollerRef = useRef<HTMLElement | null>(null);
|
||||
const navListRef = useRef<HTMLDivElement | null>(null);
|
||||
const navContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const itemRefs = useRef<Record<ProviderId, HTMLButtonElement | null>>({
|
||||
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 = (
|
||||
<div className={styles.navContainer}>
|
||||
<div className={styles.navContainer} ref={navContainerRef}>
|
||||
<div className={styles.navList} ref={navListRef}>
|
||||
<div
|
||||
className={[
|
||||
|
||||
@@ -39,10 +39,18 @@ export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardP
|
||||
<span className={styles.apiEndpoint}>{api.endpoint}</span>
|
||||
<div className={styles.apiStats}>
|
||||
<span className={styles.apiBadge}>
|
||||
{t('usage_stats.requests_count')}: {api.totalRequests}
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>
|
||||
{t('usage_stats.requests_count')}: {api.totalRequests.toLocaleString()}
|
||||
</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{api.successCount.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{api.failureCount.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.apiBadge}>
|
||||
Tokens: {formatTokensInMillions(api.totalTokens)}
|
||||
{t('usage_stats.tokens_count')}: {formatTokensInMillions(api.totalTokens)}
|
||||
</span>
|
||||
{hasPrices && api.totalCost > 0 && (
|
||||
<span className={styles.apiBadge}>
|
||||
@@ -61,7 +69,13 @@ export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardP
|
||||
<div key={model} className={styles.modelRow}>
|
||||
<span className={styles.modelName}>{model}</span>
|
||||
<span className={styles.modelStat}>
|
||||
{stats.requests} {t('usage_stats.requests_count')}
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>{stats.requests.toLocaleString()}</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{stats.successCount.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{stats.failureCount.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.modelStat}>{formatTokensInMillions(stats.tokens)}</span>
|
||||
</div>
|
||||
|
||||
447
src/hooks/useVisualConfig.ts
Normal file
447
src/hooks/useVisualConfig.ts
Normal file
@@ -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<string, unknown> {
|
||||
return obj !== null && typeof obj === 'object' && Object.prototype.hasOwnProperty.call(obj, key);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>, key: string): Record<string, unknown> {
|
||||
const existing = asRecord(parent[key]);
|
||||
if (existing) return existing;
|
||||
const next: Record<string, unknown> = {};
|
||||
parent[key] = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
function deleteIfEmpty(parent: Record<string, unknown>, key: string): void {
|
||||
const value = asRecord(parent[key]);
|
||||
if (!value) return;
|
||||
if (Object.keys(value).length === 0) delete parent[key];
|
||||
}
|
||||
|
||||
function setBoolean(obj: Record<string, unknown>, key: string, value: boolean): void {
|
||||
if (value) {
|
||||
obj[key] = true;
|
||||
return;
|
||||
}
|
||||
if (hasOwn(obj, key)) obj[key] = false;
|
||||
}
|
||||
|
||||
function setString(obj: Record<string, unknown>, 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<string, unknown>, 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<T>(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<string, unknown>).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<string, any> = { name: m.name.trim() };
|
||||
if (m.protocol) obj.protocol = m.protocol;
|
||||
return obj;
|
||||
});
|
||||
|
||||
const params: Record<string, any> = {};
|
||||
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<string, any> = { 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<VisualConfigValues>({
|
||||
...DEFAULT_VISUAL_VALUES,
|
||||
});
|
||||
|
||||
const baselineValues = useRef<VisualConfigValues>({ ...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<string, unknown>;
|
||||
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<VisualConfigValues>) => {
|
||||
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 }>;
|
||||
@@ -102,7 +102,7 @@
|
||||
"oauth": "OAuth Login",
|
||||
"quota_management": "Quota Management",
|
||||
"usage_stats": "Usage Statistics",
|
||||
"config_management": "Config Management",
|
||||
"config_management": "Config Panel",
|
||||
"logs": "Logs Viewer",
|
||||
"system_info": "Management Center Info"
|
||||
},
|
||||
@@ -812,11 +812,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",
|
||||
@@ -833,7 +833,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",
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
"oauth": "OAuth 登录",
|
||||
"quota_management": "配额管理",
|
||||
"usage_stats": "使用统计",
|
||||
"config_management": "配置管理",
|
||||
"config_management": "配置面板",
|
||||
"logs": "日志查看",
|
||||
"system_info": "中心信息"
|
||||
},
|
||||
@@ -417,7 +417,7 @@
|
||||
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。",
|
||||
"prefix_proxy_saved_success": "已更新 \"{{name}}\"",
|
||||
"card_tools_title": "配置管理",
|
||||
"card_tools_title": "配置面板",
|
||||
"quota_refresh_single": "刷新额度",
|
||||
"quota_refresh_hint": "仅刷新当前凭证的额度数据",
|
||||
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
|
||||
@@ -812,11 +812,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": "配置已加载",
|
||||
@@ -833,7 +833,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": "配额管理",
|
||||
|
||||
@@ -27,10 +27,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xl;
|
||||
|
||||
@include mobile {
|
||||
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 {
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
@use '../styles/mixins' as *;
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 $spacing-xl 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
text-align: center;
|
||||
padding: $spacing-2xl;
|
||||
color: var(--text-secondary);
|
||||
|
||||
i {
|
||||
font-size: 48px;
|
||||
margin-bottom: $spacing-md;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 $spacing-sm 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import { apiKeysApi } from '@/services/api';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { isValidApiKeyCharset } from '@/utils/validation';
|
||||
import styles from './ApiKeysPage.module.scss';
|
||||
|
||||
export function ApiKeysPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification, showConfirmation } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
|
||||
const [apiKeys, setApiKeys] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const disableControls = useMemo(() => connectionStatus !== 'connected', [connectionStatus]);
|
||||
|
||||
const loadApiKeys = useCallback(
|
||||
async (force = false) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = (await fetchConfig('api-keys', force)) as string[] | undefined;
|
||||
const list = Array.isArray(result) ? result : [];
|
||||
setApiKeys(list);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || t('notification.refresh_failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[fetchConfig, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadApiKeys();
|
||||
}, [loadApiKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Array.isArray(config?.apiKeys)) {
|
||||
setApiKeys(config.apiKeys);
|
||||
}
|
||||
}, [config?.apiKeys]);
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingIndex(null);
|
||||
setInputValue('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (index: number) => {
|
||||
setEditingIndex(index);
|
||||
setInputValue(apiKeys[index] ?? '');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalOpen(false);
|
||||
setInputValue('');
|
||||
setEditingIndex(null);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmed = inputValue.trim();
|
||||
if (!trimmed) {
|
||||
showNotification(`${t('notification.please_enter')} ${t('notification.api_key')}`, 'error');
|
||||
return;
|
||||
}
|
||||
if (!isValidApiKeyCharset(trimmed)) {
|
||||
showNotification(t('notification.api_key_invalid_chars'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const isEdit = editingIndex !== null;
|
||||
const nextKeys = isEdit
|
||||
? apiKeys.map((key, idx) => (idx === editingIndex ? trimmed : key))
|
||||
: [...apiKeys, trimmed];
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit && editingIndex !== null) {
|
||||
await apiKeysApi.update(editingIndex, trimmed);
|
||||
showNotification(t('notification.api_key_updated'), 'success');
|
||||
} else {
|
||||
await apiKeysApi.replace(nextKeys);
|
||||
showNotification(t('notification.api_key_added'), 'success');
|
||||
}
|
||||
|
||||
setApiKeys(nextKeys);
|
||||
updateConfigValue('api-keys', nextKeys);
|
||||
clearCache('api-keys');
|
||||
closeModal();
|
||||
} catch (err: any) {
|
||||
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
const apiKeyToDelete = apiKeys[index];
|
||||
if (!apiKeyToDelete) {
|
||||
showNotification(t('notification.delete_failed'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showConfirmation({
|
||||
title: t('common.delete'),
|
||||
message: t('api_keys.delete_confirm'),
|
||||
variant: 'danger',
|
||||
onConfirm: async () => {
|
||||
const latestKeys = useConfigStore.getState().config?.apiKeys;
|
||||
const currentKeys = Array.isArray(latestKeys) ? latestKeys : [];
|
||||
const deleteIndex =
|
||||
currentKeys[index] === apiKeyToDelete
|
||||
? index
|
||||
: currentKeys.findIndex((key) => key === apiKeyToDelete);
|
||||
|
||||
if (deleteIndex < 0) {
|
||||
showNotification(t('notification.delete_failed'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiKeysApi.delete(deleteIndex);
|
||||
const nextKeys = currentKeys.filter((_, idx) => idx !== deleteIndex);
|
||||
setApiKeys(nextKeys);
|
||||
updateConfigValue('api-keys', nextKeys);
|
||||
clearCache('api-keys');
|
||||
showNotification(t('notification.api_key_deleted'), 'success');
|
||||
} catch (err: any) {
|
||||
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const actionButtons = (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button variant="secondary" size="sm" onClick={() => loadApiKeys(true)} disabled={loading}>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={openAddModal} disabled={disableControls}>
|
||||
{t('api_keys.add_button')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.pageTitle}>{t('api_keys.title')}</h1>
|
||||
|
||||
<Card title={t('api_keys.proxy_auth_title')} extra={actionButtons}>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex-center" style={{ padding: '24px 0' }}>
|
||||
<LoadingSpinner size={28} />
|
||||
</div>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('api_keys.empty_title')}
|
||||
description={t('api_keys.empty_desc')}
|
||||
action={
|
||||
<Button onClick={openAddModal} disabled={disableControls}>
|
||||
{t('api_keys.add_button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="item-list">
|
||||
{apiKeys.map((key, index) => (
|
||||
<div key={index} className="item-row">
|
||||
<div className="item-meta">
|
||||
<div className="pill">#{index + 1}</div>
|
||||
<div className="item-title">{t('api_keys.item_title')}</div>
|
||||
<div className="item-subtitle">{maskApiKey(String(key || ''))}</div>
|
||||
</div>
|
||||
<div className="item-actions">
|
||||
<Button variant="secondary" size="sm" onClick={() => openEditModal(index)} disabled={disableControls}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(index)}
|
||||
disabled={disableControls}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
onClose={closeModal}
|
||||
title={editingIndex !== null ? t('api_keys.edit_modal_title') : t('api_keys.add_modal_title')}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={closeModal} disabled={saving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} loading={saving}>
|
||||
{editingIndex !== null ? t('common.update') : t('common.add')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={
|
||||
editingIndex !== null ? t('api_keys.edit_modal_key_label') : t('api_keys.add_modal_key_label')
|
||||
}
|
||||
placeholder={
|
||||
editingIndex !== null
|
||||
? t('api_keys.edit_modal_key_label')
|
||||
: t('api_keys.add_modal_key_placeholder')
|
||||
}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</Modal>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ConfigEditorTab>(() => {
|
||||
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<ReactCodeMirrorRef>(null);
|
||||
const floatingControlsRef = useRef<HTMLDivElement>(null);
|
||||
const editorWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const floatingActionsRef = useRef<HTMLDivElement>(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 = (
|
||||
<div className={styles.floatingActionContainer} ref={floatingActionsRef}>
|
||||
<div className={styles.floatingActionList}>
|
||||
<div className={`${styles.floatingStatus} ${styles.status} ${getStatusClass()}`}>{getStatusText()}</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.floatingActionButton}
|
||||
onClick={loadConfig}
|
||||
disabled={loading}
|
||||
title={t('config_management.reload')}
|
||||
aria-label={t('config_management.reload')}
|
||||
>
|
||||
<IconRefreshCw size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.floatingActionButton}
|
||||
onClick={handleSave}
|
||||
disabled={disableControls || loading || saving || !isDirty}
|
||||
title={t('config_management.save')}
|
||||
aria-label={t('config_management.save')}
|
||||
>
|
||||
<IconCheck size={16} />
|
||||
{isDirty && <span className={styles.dirtyDot} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.pageTitle}>{t('config_management.title')}</h1>
|
||||
<p className={styles.description}>{t('config_management.description')}</p>
|
||||
|
||||
<div className={styles.tabBar}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.tabItem} ${activeTab === 'visual' ? styles.tabActive : ''}`}
|
||||
onClick={() => handleTabChange('visual')}
|
||||
disabled={saving || loading}
|
||||
>
|
||||
{t('config_management.tabs.visual', { defaultValue: '可视化编辑' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.tabItem} ${activeTab === 'source' ? styles.tabActive : ''}`}
|
||||
onClick={() => handleTabChange('source')}
|
||||
disabled={saving || loading}
|
||||
>
|
||||
{t('config_management.tabs.source', { defaultValue: '源代码编辑' })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Card className={styles.configCard}>
|
||||
<div className={styles.content}>
|
||||
{/* Editor */}
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
<div className={styles.editorWrapper} ref={editorWrapperRef}>
|
||||
{/* Floating search controls */}
|
||||
<div className={styles.floatingControls} ref={floatingControlsRef}>
|
||||
<div className={styles.searchInputWrapper}>
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder={t('config_management.search_placeholder', {
|
||||
defaultValue: '搜索配置内容...'
|
||||
})}
|
||||
disabled={disableControls || loading}
|
||||
className={styles.searchInput}
|
||||
rightElement={
|
||||
<div className={styles.searchRight}>
|
||||
{searchQuery && lastSearchedQuery === searchQuery && (
|
||||
<span className={styles.searchCount}>
|
||||
{searchResults.total > 0
|
||||
? `${searchResults.current} / ${searchResults.total}`
|
||||
: t('config_management.search_no_results', { defaultValue: '无结果' })}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.searchButton}
|
||||
onClick={() => executeSearch('next')}
|
||||
disabled={!searchQuery || disableControls || loading}
|
||||
title={t('config_management.search_button', { defaultValue: '搜索' })}
|
||||
>
|
||||
<IconSearch size={16} />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.searchActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handlePrevMatch}
|
||||
disabled={!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0}
|
||||
title={t('config_management.search_prev', { defaultValue: '上一个' })}
|
||||
>
|
||||
<IconChevronUp size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleNextMatch}
|
||||
disabled={!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0}
|
||||
title={t('config_management.search_next', { defaultValue: '下一个' })}
|
||||
>
|
||||
<IconChevronDown size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={content}
|
||||
onChange={handleChange}
|
||||
extensions={extensions}
|
||||
theme={resolvedTheme}
|
||||
editable={!disableControls && !loading}
|
||||
placeholder={t('config_management.editor_placeholder')}
|
||||
height="100%"
|
||||
style={{ height: '100%' }}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
highlightActiveLineGutter: true,
|
||||
highlightActiveLine: true,
|
||||
foldGutter: true,
|
||||
dropCursor: true,
|
||||
allowMultipleSelections: true,
|
||||
indentOnInput: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: false,
|
||||
rectangularSelection: true,
|
||||
crosshairCursor: false,
|
||||
highlightSelectionMatches: true,
|
||||
closeBracketsKeymap: true,
|
||||
searchKeymap: true,
|
||||
foldKeymap: true,
|
||||
completionKeymap: false,
|
||||
lintKeymap: true
|
||||
}}
|
||||
|
||||
{activeTab === 'visual' ? (
|
||||
<VisualConfigEditor
|
||||
values={visualValues}
|
||||
disabled={disableControls || loading}
|
||||
onChange={setVisualValues}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.editorWrapper} ref={editorWrapperRef}>
|
||||
{/* Floating search controls */}
|
||||
<div className={styles.floatingControls} ref={floatingControlsRef}>
|
||||
<div className={styles.searchInputWrapper}>
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder={t('config_management.search_placeholder', {
|
||||
defaultValue: '搜索配置内容...'
|
||||
})}
|
||||
disabled={disableControls || loading}
|
||||
className={styles.searchInput}
|
||||
rightElement={
|
||||
<div className={styles.searchRight}>
|
||||
{searchQuery && lastSearchedQuery === searchQuery && (
|
||||
<span className={styles.searchCount}>
|
||||
{searchResults.total > 0
|
||||
? `${searchResults.current} / ${searchResults.total}`
|
||||
: t('config_management.search_no_results', { defaultValue: '无结果' })}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.searchButton}
|
||||
onClick={() => executeSearch('next')}
|
||||
disabled={!searchQuery || disableControls || loading}
|
||||
title={t('config_management.search_button', { defaultValue: '搜索' })}
|
||||
>
|
||||
<IconSearch size={16} />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.searchActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handlePrevMatch}
|
||||
disabled={!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0}
|
||||
title={t('config_management.search_prev', { defaultValue: '上一个' })}
|
||||
>
|
||||
<IconChevronUp size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleNextMatch}
|
||||
disabled={!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0}
|
||||
title={t('config_management.search_next', { defaultValue: '下一个' })}
|
||||
>
|
||||
<IconChevronDown size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={content}
|
||||
onChange={handleChange}
|
||||
extensions={extensions}
|
||||
theme={resolvedTheme}
|
||||
editable={!disableControls && !loading}
|
||||
placeholder={t('config_management.editor_placeholder')}
|
||||
height="100%"
|
||||
style={{ height: '100%' }}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
highlightActiveLineGutter: true,
|
||||
highlightActiveLine: true,
|
||||
foldGutter: true,
|
||||
dropCursor: true,
|
||||
allowMultipleSelections: true,
|
||||
indentOnInput: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: false,
|
||||
rectangularSelection: true,
|
||||
crosshairCursor: false,
|
||||
highlightSelectionMatches: true,
|
||||
closeBracketsKeymap: true,
|
||||
searchKeymap: true,
|
||||
foldKeymap: true,
|
||||
completionKeymap: false,
|
||||
lintKeymap: true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className={styles.controls}>
|
||||
<span className={`${styles.status} ${getStatusClass()}`}>
|
||||
{getStatusText()}
|
||||
</span>
|
||||
<div className={styles.actions}>
|
||||
<Button variant="secondary" size="sm" onClick={loadConfig} disabled={loading}>
|
||||
{t('config_management.reload')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} loading={saving} disabled={disableControls || loading || !dirty}>
|
||||
{t('config_management.save')}
|
||||
</Button>
|
||||
</div>
|
||||
{!isLoadedStatus && (
|
||||
<span className={`${styles.status} ${getStatusClass()}`}>
|
||||
{getStatusText()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{typeof document !== 'undefined' ? createPortal(floatingActions, document.body) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,12 +172,12 @@ export function DashboardPage() {
|
||||
|
||||
const quickStats: QuickStat[] = [
|
||||
{
|
||||
label: t('nav.api_keys'),
|
||||
label: t('dashboard.management_keys'),
|
||||
value: stats.apiKeys ?? '-',
|
||||
icon: <IconKey size={24} />,
|
||||
path: '/api-keys',
|
||||
path: '/config',
|
||||
loading: loading && stats.apiKeys === null,
|
||||
sublabel: t('dashboard.management_keys')
|
||||
sublabel: t('nav.config_management')
|
||||
},
|
||||
{
|
||||
label: t('nav.ai_providers'),
|
||||
@@ -309,7 +309,7 @@ export function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link to="/settings" className={styles.viewMoreLink}>
|
||||
<Link to="/config" className={styles.viewMoreLink}>
|
||||
{t('dashboard.edit_settings')} →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,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) {
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
@use '../../styles/mixins' as *;
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 $spacing-xl 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: $spacing-lg;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.settingRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.settingInfo {
|
||||
flex: 1;
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 $spacing-xs 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 52px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
|
||||
input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
|
||||
&:checked + .slider {
|
||||
background-color: var(--primary-color);
|
||||
|
||||
&:before {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus + .slider {
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--border-color);
|
||||
transition: $transition-fast;
|
||||
border-radius: $radius-full;
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background-color: white;
|
||||
transition: $transition-fast;
|
||||
border-radius: $radius-full;
|
||||
}
|
||||
}
|
||||
|
||||
.formGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.retryRow {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: $spacing-md;
|
||||
flex-wrap: wrap;
|
||||
|
||||
:global(.form-group) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.retryRowAligned {
|
||||
align-items: flex-start;
|
||||
|
||||
.retryButton {
|
||||
margin-top: calc(1.5em + #{$spacing-xs});
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
align-items: stretch;
|
||||
|
||||
.retryButton {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.retryRowInputGrow {
|
||||
:global(.form-group) {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.retryInput {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.retryInput {
|
||||
width: 140px;
|
||||
|
||||
@include mobile {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.retryButton {
|
||||
@include mobile {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,477 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import { configApi } from '@/services/api';
|
||||
import type { Config } from '@/types';
|
||||
import styles from './Settings/Settings.module.scss';
|
||||
|
||||
type PendingKey =
|
||||
| 'debug'
|
||||
| 'proxy'
|
||||
| 'retry'
|
||||
| 'logsMaxSize'
|
||||
| 'forceModelPrefix'
|
||||
| 'routingStrategy'
|
||||
| 'switchProject'
|
||||
| 'switchPreview'
|
||||
| 'usage'
|
||||
| 'loggingToFile'
|
||||
| 'wsAuth';
|
||||
|
||||
export function SettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [proxyValue, setProxyValue] = useState('');
|
||||
const [retryValue, setRetryValue] = useState(0);
|
||||
const [logsMaxTotalSizeMb, setLogsMaxTotalSizeMb] = useState(0);
|
||||
const [routingStrategy, setRoutingStrategy] = useState('round-robin');
|
||||
const [pending, setPending] = useState<Record<PendingKey, boolean>>({} as Record<PendingKey, boolean>);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [configResult, logsResult, prefixResult, routingResult] = await Promise.allSettled([
|
||||
fetchConfig(),
|
||||
configApi.getLogsMaxTotalSizeMb(),
|
||||
configApi.getForceModelPrefix(),
|
||||
configApi.getRoutingStrategy(),
|
||||
]);
|
||||
|
||||
if (configResult.status !== 'fulfilled') {
|
||||
throw configResult.reason;
|
||||
}
|
||||
|
||||
const data = configResult.value as Config;
|
||||
setProxyValue(data?.proxyUrl ?? '');
|
||||
setRetryValue(typeof data?.requestRetry === 'number' ? data.requestRetry : 0);
|
||||
|
||||
if (logsResult.status === 'fulfilled' && Number.isFinite(logsResult.value)) {
|
||||
setLogsMaxTotalSizeMb(Math.max(0, Number(logsResult.value)));
|
||||
updateConfigValue('logs-max-total-size-mb', Math.max(0, Number(logsResult.value)));
|
||||
}
|
||||
|
||||
if (prefixResult.status === 'fulfilled') {
|
||||
updateConfigValue('force-model-prefix', Boolean(prefixResult.value));
|
||||
}
|
||||
|
||||
if (routingResult.status === 'fulfilled' && routingResult.value) {
|
||||
setRoutingStrategy(String(routingResult.value));
|
||||
updateConfigValue('routing/strategy', String(routingResult.value));
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message || t('notification.refresh_failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
}, [fetchConfig, t, updateConfigValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setProxyValue(config.proxyUrl ?? '');
|
||||
if (typeof config.requestRetry === 'number') {
|
||||
setRetryValue(config.requestRetry);
|
||||
}
|
||||
if (typeof config.logsMaxTotalSizeMb === 'number') {
|
||||
setLogsMaxTotalSizeMb(config.logsMaxTotalSizeMb);
|
||||
}
|
||||
if (config.routingStrategy) {
|
||||
setRoutingStrategy(config.routingStrategy);
|
||||
}
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const setPendingFlag = (key: PendingKey, value: boolean) => {
|
||||
setPending((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const toggleSetting = async (
|
||||
section: PendingKey,
|
||||
rawKey: 'debug' | 'usage-statistics-enabled' | 'logging-to-file' | 'ws-auth' | 'force-model-prefix',
|
||||
value: boolean,
|
||||
updater: (val: boolean) => Promise<any>,
|
||||
successMessage: string
|
||||
) => {
|
||||
const previous = (() => {
|
||||
switch (rawKey) {
|
||||
case 'debug':
|
||||
return config?.debug ?? false;
|
||||
case 'usage-statistics-enabled':
|
||||
return config?.usageStatisticsEnabled ?? false;
|
||||
case 'logging-to-file':
|
||||
return config?.loggingToFile ?? false;
|
||||
case 'ws-auth':
|
||||
return config?.wsAuth ?? false;
|
||||
case 'force-model-prefix':
|
||||
return config?.forceModelPrefix ?? false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
setPendingFlag(section, true);
|
||||
updateConfigValue(rawKey, value);
|
||||
|
||||
try {
|
||||
await updater(value);
|
||||
clearCache(rawKey);
|
||||
showNotification(successMessage, 'success');
|
||||
} catch (err: any) {
|
||||
updateConfigValue(rawKey, previous);
|
||||
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setPendingFlag(section, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProxyUpdate = async () => {
|
||||
const previous = config?.proxyUrl ?? '';
|
||||
setPendingFlag('proxy', true);
|
||||
updateConfigValue('proxy-url', proxyValue);
|
||||
try {
|
||||
await configApi.updateProxyUrl(proxyValue.trim());
|
||||
clearCache('proxy-url');
|
||||
showNotification(t('notification.proxy_updated'), 'success');
|
||||
} catch (err: any) {
|
||||
setProxyValue(previous);
|
||||
updateConfigValue('proxy-url', previous);
|
||||
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setPendingFlag('proxy', false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProxyClear = async () => {
|
||||
const previous = config?.proxyUrl ?? '';
|
||||
setPendingFlag('proxy', true);
|
||||
updateConfigValue('proxy-url', '');
|
||||
try {
|
||||
await configApi.clearProxyUrl();
|
||||
clearCache('proxy-url');
|
||||
setProxyValue('');
|
||||
showNotification(t('notification.proxy_cleared'), 'success');
|
||||
} catch (err: any) {
|
||||
setProxyValue(previous);
|
||||
updateConfigValue('proxy-url', previous);
|
||||
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setPendingFlag('proxy', false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetryUpdate = async () => {
|
||||
const previous = config?.requestRetry ?? 0;
|
||||
const parsed = Number(retryValue);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
showNotification(t('login.error_invalid'), 'error');
|
||||
setRetryValue(previous);
|
||||
return;
|
||||
}
|
||||
setPendingFlag('retry', true);
|
||||
updateConfigValue('request-retry', parsed);
|
||||
try {
|
||||
await configApi.updateRequestRetry(parsed);
|
||||
clearCache('request-retry');
|
||||
showNotification(t('notification.retry_updated'), 'success');
|
||||
} catch (err: any) {
|
||||
setRetryValue(previous);
|
||||
updateConfigValue('request-retry', previous);
|
||||
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setPendingFlag('retry', false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogsMaxTotalSizeUpdate = async () => {
|
||||
const previous = config?.logsMaxTotalSizeMb ?? 0;
|
||||
const parsed = Number(logsMaxTotalSizeMb);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
showNotification(t('login.error_invalid'), 'error');
|
||||
setLogsMaxTotalSizeMb(previous);
|
||||
return;
|
||||
}
|
||||
const normalized = Math.max(0, parsed);
|
||||
setPendingFlag('logsMaxSize', true);
|
||||
updateConfigValue('logs-max-total-size-mb', normalized);
|
||||
try {
|
||||
await configApi.updateLogsMaxTotalSizeMb(normalized);
|
||||
clearCache('logs-max-total-size-mb');
|
||||
showNotification(t('notification.logs_max_total_size_updated'), 'success');
|
||||
} catch (err: any) {
|
||||
setLogsMaxTotalSizeMb(previous);
|
||||
updateConfigValue('logs-max-total-size-mb', previous);
|
||||
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setPendingFlag('logsMaxSize', false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoutingStrategyUpdate = async () => {
|
||||
const strategy = routingStrategy.trim();
|
||||
if (!strategy) {
|
||||
showNotification(t('login.error_invalid'), 'error');
|
||||
return;
|
||||
}
|
||||
const previous = config?.routingStrategy ?? 'round-robin';
|
||||
setPendingFlag('routingStrategy', true);
|
||||
updateConfigValue('routing/strategy', strategy);
|
||||
try {
|
||||
await configApi.updateRoutingStrategy(strategy);
|
||||
clearCache('routing/strategy');
|
||||
showNotification(t('notification.routing_strategy_updated'), 'success');
|
||||
} catch (err: any) {
|
||||
setRoutingStrategy(previous);
|
||||
updateConfigValue('routing/strategy', previous);
|
||||
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setPendingFlag('routingStrategy', false);
|
||||
}
|
||||
};
|
||||
|
||||
const quotaSwitchProject = config?.quotaExceeded?.switchProject ?? false;
|
||||
const quotaSwitchPreview = config?.quotaExceeded?.switchPreviewModel ?? false;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.pageTitle}>{t('basic_settings.title')}</h1>
|
||||
|
||||
<div className={styles.grid}>
|
||||
<Card>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.debug_enable')}
|
||||
checked={config?.debug ?? false}
|
||||
disabled={disableControls || pending.debug || loading}
|
||||
onChange={(value) =>
|
||||
toggleSetting('debug', 'debug', value, configApi.updateDebug, t('notification.debug_updated'))
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.usage_statistics_enable')}
|
||||
checked={config?.usageStatisticsEnabled ?? false}
|
||||
disabled={disableControls || pending.usage || loading}
|
||||
onChange={(value) =>
|
||||
toggleSetting(
|
||||
'usage',
|
||||
'usage-statistics-enabled',
|
||||
value,
|
||||
configApi.updateUsageStatistics,
|
||||
t('notification.usage_statistics_updated')
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.logging_to_file_enable')}
|
||||
checked={config?.loggingToFile ?? false}
|
||||
disabled={disableControls || pending.loggingToFile || loading}
|
||||
onChange={(value) =>
|
||||
toggleSetting(
|
||||
'loggingToFile',
|
||||
'logging-to-file',
|
||||
value,
|
||||
configApi.updateLoggingToFile,
|
||||
t('notification.logging_to_file_updated')
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.ws_auth_enable')}
|
||||
checked={config?.wsAuth ?? false}
|
||||
disabled={disableControls || pending.wsAuth || loading}
|
||||
onChange={(value) =>
|
||||
toggleSetting(
|
||||
'wsAuth',
|
||||
'ws-auth',
|
||||
value,
|
||||
configApi.updateWsAuth,
|
||||
t('notification.ws_auth_updated')
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.force_model_prefix_enable')}
|
||||
checked={config?.forceModelPrefix ?? false}
|
||||
disabled={disableControls || pending.forceModelPrefix || loading}
|
||||
onChange={(value) =>
|
||||
toggleSetting(
|
||||
'forceModelPrefix',
|
||||
'force-model-prefix',
|
||||
value,
|
||||
configApi.updateForceModelPrefix,
|
||||
t('notification.force_model_prefix_updated')
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title={t('basic_settings.proxy_title')}>
|
||||
<Input
|
||||
label={t('basic_settings.proxy_url_label')}
|
||||
placeholder={t('basic_settings.proxy_url_placeholder')}
|
||||
value={proxyValue}
|
||||
onChange={(e) => setProxyValue(e.target.value)}
|
||||
disabled={disableControls || loading}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Button variant="secondary" onClick={handleProxyClear} disabled={disableControls || pending.proxy || loading}>
|
||||
{t('basic_settings.proxy_clear')}
|
||||
</Button>
|
||||
<Button onClick={handleProxyUpdate} loading={pending.proxy} disabled={disableControls || loading}>
|
||||
{t('basic_settings.proxy_update')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title={t('basic_settings.retry_title')}>
|
||||
<div className={styles.retryRow}>
|
||||
<Input
|
||||
label={t('basic_settings.retry_count_label')}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
step={1}
|
||||
value={retryValue}
|
||||
onChange={(e) => setRetryValue(Number(e.target.value))}
|
||||
disabled={disableControls || loading}
|
||||
className={styles.retryInput}
|
||||
/>
|
||||
<Button
|
||||
className={styles.retryButton}
|
||||
onClick={handleRetryUpdate}
|
||||
loading={pending.retry}
|
||||
disabled={disableControls || loading}
|
||||
>
|
||||
{t('basic_settings.retry_update')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title={t('basic_settings.logs_max_total_size_title')}>
|
||||
<div className={`${styles.retryRow} ${styles.retryRowAligned} ${styles.retryRowInputGrow}`}>
|
||||
<Input
|
||||
label={t('basic_settings.logs_max_total_size_label')}
|
||||
hint={t('basic_settings.logs_max_total_size_hint')}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
step={1}
|
||||
value={logsMaxTotalSizeMb}
|
||||
onChange={(e) => setLogsMaxTotalSizeMb(Number(e.target.value))}
|
||||
disabled={disableControls || loading}
|
||||
className={styles.retryInput}
|
||||
/>
|
||||
<Button
|
||||
className={styles.retryButton}
|
||||
onClick={handleLogsMaxTotalSizeUpdate}
|
||||
loading={pending.logsMaxSize}
|
||||
disabled={disableControls || loading}
|
||||
>
|
||||
{t('basic_settings.logs_max_total_size_update')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title={t('basic_settings.routing_title')}>
|
||||
<div className={`${styles.retryRow} ${styles.retryRowAligned} ${styles.retryRowInputGrow}`}>
|
||||
<div className="form-group">
|
||||
<label>{t('basic_settings.routing_strategy_label')}</label>
|
||||
<select
|
||||
className="input"
|
||||
value={routingStrategy}
|
||||
onChange={(e) => setRoutingStrategy(e.target.value)}
|
||||
disabled={disableControls || loading}
|
||||
>
|
||||
<option value="round-robin">{t('basic_settings.routing_strategy_round_robin')}</option>
|
||||
<option value="fill-first">{t('basic_settings.routing_strategy_fill_first')}</option>
|
||||
</select>
|
||||
<div className="hint">{t('basic_settings.routing_strategy_hint')}</div>
|
||||
</div>
|
||||
<Button
|
||||
className={styles.retryButton}
|
||||
onClick={handleRoutingStrategyUpdate}
|
||||
loading={pending.routingStrategy}
|
||||
disabled={disableControls || loading}
|
||||
>
|
||||
{t('basic_settings.routing_strategy_update')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title={t('basic_settings.quota_title')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.quota_switch_project')}
|
||||
checked={quotaSwitchProject}
|
||||
disabled={disableControls || pending.switchProject || loading}
|
||||
onChange={(value) =>
|
||||
(async () => {
|
||||
const previous = config?.quotaExceeded?.switchProject ?? false;
|
||||
const nextQuota = { ...(config?.quotaExceeded || {}), switchProject: value };
|
||||
setPendingFlag('switchProject', true);
|
||||
updateConfigValue('quota-exceeded', nextQuota);
|
||||
try {
|
||||
await configApi.updateSwitchProject(value);
|
||||
clearCache('quota-exceeded');
|
||||
showNotification(t('notification.quota_switch_project_updated'), 'success');
|
||||
} catch (err: any) {
|
||||
updateConfigValue('quota-exceeded', { ...(config?.quotaExceeded || {}), switchProject: previous });
|
||||
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setPendingFlag('switchProject', false);
|
||||
}
|
||||
})()
|
||||
}
|
||||
/>
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.quota_switch_preview')}
|
||||
checked={quotaSwitchPreview}
|
||||
disabled={disableControls || pending.switchPreview || loading}
|
||||
onChange={(value) =>
|
||||
(async () => {
|
||||
const previous = config?.quotaExceeded?.switchPreviewModel ?? false;
|
||||
const nextQuota = { ...(config?.quotaExceeded || {}), switchPreviewModel: value };
|
||||
setPendingFlag('switchPreview', true);
|
||||
updateConfigValue('quota-exceeded', nextQuota);
|
||||
try {
|
||||
await configApi.updateSwitchPreviewModel(value);
|
||||
clearCache('quota-exceeded');
|
||||
showNotification(t('notification.quota_switch_preview_updated'), 'success');
|
||||
} catch (err: any) {
|
||||
updateConfigValue('quota-exceeded', { ...(config?.quotaExceeded || {}), switchPreviewModel: previous });
|
||||
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setPendingFlag('switchPreview', false);
|
||||
}
|
||||
})()
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Navigate, useRoutes, type Location } from 'react-router-dom';
|
||||
import { DashboardPage } from '@/pages/DashboardPage';
|
||||
import { SettingsPage } from '@/pages/SettingsPage';
|
||||
import { ApiKeysPage } from '@/pages/ApiKeysPage';
|
||||
import { AiProvidersPage } from '@/pages/AiProvidersPage';
|
||||
import { AiProvidersAmpcodeEditPage } from '@/pages/AiProvidersAmpcodeEditPage';
|
||||
import { AiProvidersClaudeEditPage } from '@/pages/AiProvidersClaudeEditPage';
|
||||
@@ -24,8 +22,8 @@ import { SystemPage } from '@/pages/SystemPage';
|
||||
const mainRoutes = [
|
||||
{ path: '/', element: <DashboardPage /> },
|
||||
{ path: '/dashboard', element: <DashboardPage /> },
|
||||
{ path: '/settings', element: <SettingsPage /> },
|
||||
{ path: '/api-keys', element: <ApiKeysPage /> },
|
||||
{ path: '/settings', element: <Navigate to="/config" replace /> },
|
||||
{ path: '/api-keys', element: <Navigate to="/config" replace /> },
|
||||
{ path: '/ai-providers/gemini/new', element: <AiProvidersGeminiEditPage /> },
|
||||
{ path: '/ai-providers/gemini/:index', element: <AiProvidersGeminiEditPage /> },
|
||||
{ path: '/ai-providers/codex/new', element: <AiProvidersCodexEditPage /> },
|
||||
|
||||
105
src/types/visualConfig.ts
Normal file
105
src/types/visualConfig.ts
Normal file
@@ -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: '',
|
||||
},
|
||||
};
|
||||
@@ -54,9 +54,11 @@ export interface UsageDetail {
|
||||
export interface ApiStats {
|
||||
endpoint: string;
|
||||
totalRequests: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
models: Record<string, { requests: number; tokens: number }>;
|
||||
models: Record<string, { requests: number; successCount: number; failureCount: number; tokens: number }>;
|
||||
}
|
||||
|
||||
const TOKENS_PER_PRICE_UNIT = 1_000_000;
|
||||
@@ -542,28 +544,65 @@ export function getApiStats(usageData: any, modelPrices: Record<string, ModelPri
|
||||
const result: ApiStats[] = [];
|
||||
|
||||
Object.entries(apis as Record<string, any>).forEach(([endpoint, apiData]) => {
|
||||
const models: Record<string, { requests: number; tokens: number }> = {};
|
||||
const models: Record<string, { requests: number; successCount: number; failureCount: number; tokens: number }> = {};
|
||||
let derivedSuccessCount = 0;
|
||||
let derivedFailureCount = 0;
|
||||
let totalCost = 0;
|
||||
|
||||
const modelsData = apiData?.models || {};
|
||||
Object.entries(modelsData as Record<string, any>).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
|
||||
|
||||
Reference in New Issue
Block a user