mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-19 19:20:49 +08:00
feat: add visual configuration editor and YAML handling
- Implemented a new hook `useVisualConfig` for managing visual configuration state and YAML parsing. - Added types for visual configuration in `visualConfig.ts`. - Enhanced `ConfigPage` to support switching between visual and source editors. - Introduced floating action buttons for save and reload actions. - Updated translations for tab labels in English and Chinese. - Styled the configuration page with new tab and floating action button styles.
This commit is contained in:
17
package-lock.json
generated
17
package-lock.json
generated
@@ -19,6 +19,7 @@
|
|||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.2.1",
|
||||||
"react-i18next": "^16.4.0",
|
"react-i18next": "^16.4.0",
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.10.1",
|
||||||
|
"yaml": "^2.8.2",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -4241,6 +4242,22 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.2.1",
|
||||||
"react-i18next": "^16.4.0",
|
"react-i18next": "^16.4.0",
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.10.1",
|
||||||
|
"yaml": "^2.8.2",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
1100
src/components/config/VisualConfigEditor.tsx
Normal file
1100
src/components/config/VisualConfigEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
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 }>;
|
||||||
@@ -833,7 +833,11 @@
|
|||||||
"search_button": "Search",
|
"search_button": "Search",
|
||||||
"search_no_results": "No results",
|
"search_no_results": "No results",
|
||||||
"search_prev": "Previous",
|
"search_prev": "Previous",
|
||||||
"search_next": "Next"
|
"search_next": "Next",
|
||||||
|
"tabs": {
|
||||||
|
"visual": "Visual Editor",
|
||||||
|
"source": "Source Editor"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"quota_management": {
|
"quota_management": {
|
||||||
"title": "Quota Management",
|
"title": "Quota Management",
|
||||||
|
|||||||
@@ -833,7 +833,11 @@
|
|||||||
"search_button": "搜索",
|
"search_button": "搜索",
|
||||||
"search_no_results": "无结果",
|
"search_no_results": "无结果",
|
||||||
"search_prev": "上一个",
|
"search_prev": "上一个",
|
||||||
"search_next": "下一个"
|
"search_next": "下一个",
|
||||||
|
"tabs": {
|
||||||
|
"visual": "可视化编辑",
|
||||||
|
"source": "源代码编辑"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"quota_management": {
|
"quota_management": {
|
||||||
"title": "配额管理",
|
"title": "配额管理",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
padding-bottom: calc(var(--config-action-bar-height, 0px) + #{$spacing-lg});
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageTitle {
|
.pageTitle {
|
||||||
@@ -21,6 +22,49 @@
|
|||||||
margin: 0 0 $spacing-xl 0;
|
margin: 0 0 $spacing-xl 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tabBar {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabItem {
|
||||||
|
@include button-reset;
|
||||||
|
padding: 12px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color 0.15s ease,
|
||||||
|
border-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabActive {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -242,6 +286,130 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.floatingActionContainer {
|
||||||
|
position: fixed;
|
||||||
|
left: 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: 10px;
|
||||||
|
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: 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: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
text-align: center;
|
||||||
|
max-width: min(360px, 52vw);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floatingActionButton {
|
||||||
|
@include button-reset;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
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: 9px;
|
||||||
|
right: 9px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
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: 40px;
|
||||||
|
height: 40px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-height: 820px) {
|
@media (max-height: 820px) {
|
||||||
.pageTitle {
|
.pageTitle {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
||||||
import { yaml } from '@codemirror/lang-yaml';
|
import { yaml } from '@codemirror/lang-yaml';
|
||||||
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||||
@@ -7,17 +8,35 @@ import { keymap } from '@codemirror/view';
|
|||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
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 { useNotificationStore, useAuthStore, useThemeStore } from '@/stores';
|
||||||
import { configFileApi } from '@/services/api/configFile';
|
import { configFileApi } from '@/services/api/configFile';
|
||||||
import styles from './ConfigPage.module.scss';
|
import styles from './ConfigPage.module.scss';
|
||||||
|
|
||||||
|
type ConfigEditorTab = 'visual' | 'source';
|
||||||
|
|
||||||
export function ConfigPage() {
|
export function ConfigPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
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 [content, setContent] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -31,8 +50,10 @@ export function ConfigPage() {
|
|||||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||||
const floatingControlsRef = useRef<HTMLDivElement>(null);
|
const floatingControlsRef = useRef<HTMLDivElement>(null);
|
||||||
const editorWrapperRef = useRef<HTMLDivElement>(null);
|
const editorWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const floatingActionsRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const disableControls = connectionStatus !== 'connected';
|
const disableControls = connectionStatus !== 'connected';
|
||||||
|
const isDirty = dirty || visualDirty;
|
||||||
|
|
||||||
const loadConfig = useCallback(async () => {
|
const loadConfig = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -41,13 +62,14 @@ export function ConfigPage() {
|
|||||||
const data = await configFileApi.fetchConfigYaml();
|
const data = await configFileApi.fetchConfigYaml();
|
||||||
setContent(data);
|
setContent(data);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
|
loadVisualValuesFromYaml(data);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : t('notification.refresh_failed');
|
const message = err instanceof Error ? err.message : t('notification.refresh_failed');
|
||||||
setError(message);
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, [loadVisualValuesFromYaml, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConfig();
|
loadConfig();
|
||||||
@@ -56,8 +78,11 @@ export function ConfigPage() {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await configFileApi.saveConfigYaml(content);
|
const nextContent = activeTab === 'visual' ? applyVisualChangesToYaml(content) : content;
|
||||||
|
await configFileApi.saveConfigYaml(nextContent);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
|
setContent(nextContent);
|
||||||
|
loadVisualValuesFromYaml(nextContent);
|
||||||
showNotification(t('config_management.save_success'), 'success');
|
showNotification(t('config_management.save_success'), 'success');
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : '';
|
const message = err instanceof Error ? err.message : '';
|
||||||
@@ -72,6 +97,23 @@ export function ConfigPage() {
|
|||||||
setDirty(true);
|
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
|
// Search functionality
|
||||||
const performSearch = useCallback((query: string, direction: 'next' | 'prev' = 'next') => {
|
const performSearch = useCallback((query: string, direction: 'next' | 'prev' = 'next') => {
|
||||||
if (!query || !editorRef.current?.view) return;
|
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.
|
// Keep floating controls from covering editor content by syncing its height to a CSS variable.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
if (activeTab !== 'source') return;
|
||||||
|
|
||||||
const controlsEl = floatingControlsRef.current;
|
const controlsEl = floatingControlsRef.current;
|
||||||
const wrapperEl = editorWrapperRef.current;
|
const wrapperEl = editorWrapperRef.current;
|
||||||
if (!controlsEl || !wrapperEl) return;
|
if (!controlsEl || !wrapperEl) return;
|
||||||
@@ -192,6 +236,31 @@ export function ConfigPage() {
|
|||||||
ro?.disconnect();
|
ro?.disconnect();
|
||||||
window.removeEventListener('resize', updatePadding);
|
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
|
// CodeMirror extensions
|
||||||
@@ -208,131 +277,181 @@ export function ConfigPage() {
|
|||||||
if (loading) return t('config_management.status_loading');
|
if (loading) return t('config_management.status_loading');
|
||||||
if (error) return t('config_management.status_load_failed');
|
if (error) return t('config_management.status_load_failed');
|
||||||
if (saving) return t('config_management.status_saving');
|
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');
|
return t('config_management.status_loaded');
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusClass = () => {
|
const getStatusClass = () => {
|
||||||
if (error) return styles.error;
|
if (error) return styles.error;
|
||||||
if (dirty) return styles.modified;
|
if (isDirty) return styles.modified;
|
||||||
if (!loading && !saving) return styles.saved;
|
if (!loading && !saving) return styles.saved;
|
||||||
return '';
|
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={18} />
|
||||||
|
</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={18} />
|
||||||
|
{isDirty && <span className={styles.dirtyDot} aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<h1 className={styles.pageTitle}>{t('config_management.title')}</h1>
|
<h1 className={styles.pageTitle}>{t('config_management.title')}</h1>
|
||||||
<p className={styles.description}>{t('config_management.description')}</p>
|
<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}>
|
<Card className={styles.configCard}>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{/* Editor */}
|
|
||||||
{error && <div className="error-box">{error}</div>}
|
{error && <div className="error-box">{error}</div>}
|
||||||
<div className={styles.editorWrapper} ref={editorWrapperRef}>
|
|
||||||
{/* Floating search controls */}
|
{activeTab === 'visual' ? (
|
||||||
<div className={styles.floatingControls} ref={floatingControlsRef}>
|
<VisualConfigEditor
|
||||||
<div className={styles.searchInputWrapper}>
|
values={visualValues}
|
||||||
<Input
|
disabled={disableControls || loading}
|
||||||
value={searchQuery}
|
onChange={setVisualValues}
|
||||||
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>
|
) : (
|
||||||
|
<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 */}
|
{/* Controls */}
|
||||||
<div className={styles.controls}>
|
<div className={styles.controls}>
|
||||||
<span className={`${styles.status} ${getStatusClass()}`}>
|
<span className={`${styles.status} ${getStatusClass()}`}>
|
||||||
{getStatusText()}
|
{getStatusText()}
|
||||||
</span>
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{typeof document !== 'undefined' ? createPortal(floatingActions, document.body) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user