easyai-ai-gateway/apps/web/src/pages/admin/SystemSettingsPanel.tsx

445 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState, type FormEvent } from 'react';
import { Database, Pencil, Plus, RotateCcw, Save, ServerCog, Trash2 } from 'lucide-react';
import type { FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select, Tabs, Textarea } from '../../components/ui';
import type { LoadState } from '../../types';
type SystemSettingsTab = 'fileStorage';
type FileStorageChannelForm = {
apiKey: string;
apiKeyPreview: string;
channelKey: string;
configJson: string;
name: string;
priority: string;
provider: string;
retryPolicyJson: string;
scenes: string[];
status: string;
uploadUrl: string;
};
const defaultUploadUrl = 'http://127.0.0.1:3001/v1/files/upload';
const defaultRetryPolicy = {
enabled: true,
maxRetries: 3,
backoffSeconds: [60, 120, 180],
strategy: 'exponential',
};
const providerOptions = [
{ value: 'server_main_openapi', label: 'server-main OpenAPI' },
{ value: 'aliyun_oss', label: '阿里云 OSS' },
{ value: 'tencent_cos', label: '腾讯云 COS' },
];
const defaultScenes = ['upload', 'image_result'];
const sceneOptions = [
{ value: 'upload', label: '上传', description: 'OpenAPI / 管理端主动上传文件' },
{ value: 'image_result', label: '返图', description: '模型返回 base64 / buffer 图片或视频后的转存' },
];
const resultUploadPolicyOptions = [
{ value: 'default', label: '默认:仅非链接资源转存', description: 'URL 结果直接保存base64 / buffer 等结果转存后保存 URL' },
{ value: 'upload_all', label: '全部转存', description: 'URL、base64、buffer 等返图结果都会转存到当前文件渠道' },
{ value: 'upload_none', label: '全部不转存', description: '链接结果直接保存base64 / buffer 结果写入网关本地静态托管后保存 URL' },
];
export function SystemSettingsPanel(props: {
channels: FileStorageChannel[];
message: string;
settings: FileStorageSettings | null;
state: LoadState;
onDeleteFileStorageChannel: (channelId: string) => Promise<void>;
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
}) {
const [activeTab, setActiveTab] = useState<SystemSettingsTab>('fileStorage');
const [dialogOpen, setDialogOpen] = useState(false);
const [editingChannel, setEditingChannel] = useState<FileStorageChannel | null>(null);
const [pendingDeleteChannel, setPendingDeleteChannel] = useState<FileStorageChannel | null>(null);
const [form, setForm] = useState<FileStorageChannelForm>(() => defaultChannelForm());
const [settingsPolicy, setSettingsPolicy] = useState(() => normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
const [localError, setLocalError] = useState('');
useEffect(() => {
setSettingsPolicy(normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
}, [props.settings?.resultUploadPolicy]);
function openCreateDialog() {
setEditingChannel(null);
setForm(defaultChannelForm(`server-main-${Date.now().toString(36)}`));
setLocalError('');
setDialogOpen(true);
}
function editChannel(channel: FileStorageChannel) {
setEditingChannel(channel);
setForm(channelToForm(channel));
setLocalError('');
setDialogOpen(true);
}
function closeDialog() {
setEditingChannel(null);
setForm(defaultChannelForm());
setLocalError('');
setDialogOpen(false);
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLocalError('');
if (form.scenes.length === 0) {
setLocalError('请至少选择一个适用场景。');
return;
}
try {
await props.onSaveFileStorageChannel(formToPayload(form), editingChannel?.id);
closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '文件存储渠道保存失败');
}
}
async function deleteChannel(channel: FileStorageChannel) {
try {
await props.onDeleteFileStorageChannel(channel.id);
setPendingDeleteChannel(null);
if (editingChannel?.id === channel.id) closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '文件存储渠道删除失败');
}
}
async function saveSettings() {
setLocalError('');
try {
await props.onSaveFileStorageSettings({ resultUploadPolicy: normalizeResultUploadPolicy(settingsPolicy) });
} catch (err) {
setLocalError(err instanceof Error ? err.message : '文件存储全局策略保存失败');
}
}
return (
<div className="pageStack">
<Card>
<CardHeader>
<div>
<CardTitle></CardTitle>
<p className="mutedText">使 60/120/180 退</p>
</div>
<Badge variant="secondary">{props.channels.length} </Badge>
</CardHeader>
<CardContent>
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
<Tabs
value={activeTab}
tabs={[{ value: 'fileStorage', label: '文件存储', icon: <Database size={15} /> }]}
onValueChange={setActiveTab}
/>
</CardContent>
</Card>
{activeTab === 'fileStorage' && (
<section className="fileStoragePanel">
<div className="fileStorageSettingsCard">
<div>
<strong></strong>
<span></span>
</div>
<Label>
<Select value={settingsPolicy} onChange={(event) => setSettingsPolicy(event.target.value)}>
{resultUploadPolicyOptions.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
</Select>
<small>{resultUploadPolicyDescription(settingsPolicy)}</small>
</Label>
<Button type="button" onClick={saveSettings} disabled={props.state === 'loading'}>
<Save size={15} />
</Button>
</div>
<div className="fileStorageToolbar">
<div>
<strong></strong>
<span>server-main OpenAPI API Key</span>
</div>
<Button type="button" onClick={openCreateDialog}>
<Plus size={15} />
</Button>
</div>
<div className="fileStorageGrid">
{props.channels.map((channel) => (
<article className="fileStorageCard" key={channel.id}>
<header>
<div className="iconBox"><ServerCog size={18} /></div>
<div>
<strong>{channel.name}</strong>
<span>{channel.channelKey}</span>
</div>
<Badge variant={channel.status === 'enabled' ? 'success' : 'secondary'}>{channel.status}</Badge>
</header>
<div className="fileStorageMeta">
<span>: {providerLabel(channel.provider)}</span>
<span>: {sceneSummary(channel.scenes)}</span>
<span>: {channel.priority}</span>
<span>: {retryPolicySummary(channel.retryPolicy)}</span>
{channel.uploadUrl && <span>: {channel.uploadUrl}</span>}
{apiKeyPreview(channel) && <span>API Key: {apiKeyPreview(channel)}</span>}
{channel.lastError && <span>: {channel.lastError}</span>}
</div>
<footer>
<Button type="button" variant="outline" size="sm" onClick={() => editChannel(channel)}>
<Pencil size={14} />
</Button>
<Button type="button" variant="destructive" size="sm" onClick={() => setPendingDeleteChannel(channel)}>
<Trash2 size={14} />
</Button>
</footer>
</article>
))}
{!props.channels.length && (
<Card>
<CardContent className="emptyState">
<strong></strong>
</CardContent>
</Card>
)}
</div>
</section>
)}
<FormDialog
ariaLabel={editingChannel ? '编辑文件存储渠道' : '新增文件存储渠道'}
bodyClassName="fileStorageDialogBody"
eyebrow={editingChannel ? 'Edit Storage Channel' : 'New Storage Channel'}
footer={(
<>
<Button type="submit" disabled={props.state === 'loading'}>
{editingChannel ? <Save size={15} /> : <Plus size={15} />}
{editingChannel ? '保存渠道' : '新增渠道'}
</Button>
<Button type="button" variant="outline" onClick={closeDialog}>
<RotateCcw size={15} />
</Button>
</>
)}
open={dialogOpen}
title={editingChannel ? '编辑文件存储渠道' : '新增文件存储渠道'}
onClose={closeDialog}
onSubmit={submit}
>
<Label>
<Input value={form.channelKey} onChange={(event) => setForm({ ...form, channelKey: event.target.value })} placeholder="server-main-openapi" />
</Label>
<Label>
<Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="server-main OpenAPI" />
</Label>
<Label>
<Select value={form.provider} onChange={(event) => setForm({ ...form, provider: event.target.value })}>
{providerOptions.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
</Select>
</Label>
<Label>
<Select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>
<option value="enabled">enabled</option>
<option value="disabled">disabled</option>
</Select>
</Label>
<Label className="spanTwo">
<div className="fileStorageSceneGrid">
{sceneOptions.map((scene) => (
<FileStorageSceneToggle
checked={form.scenes.includes(scene.value)}
description={scene.description}
key={scene.value}
label={scene.label}
onChange={(checked) => setForm({ ...form, scenes: nextScenes(form.scenes, scene.value, checked) })}
/>
))}
</div>
</Label>
<Label className="spanTwo">
<Input value={form.uploadUrl} onChange={(event) => setForm({ ...form, uploadUrl: event.target.value })} placeholder={defaultUploadUrl} />
</Label>
<Label className="platformCredentialField">
API Key
<Input value={form.apiKey} onChange={(event) => setForm({ ...form, apiKey: event.target.value })} placeholder={credentialInputPlaceholder(form.apiKeyPreview)} />
<small></small>
</Label>
<Label>
<Input type="number" min={1} value={form.priority} onChange={(event) => setForm({ ...form, priority: event.target.value })} />
</Label>
<Label className="spanTwo">
JSON
<Textarea value={form.retryPolicyJson} onChange={(event) => setForm({ ...form, retryPolicyJson: event.target.value })} />
</Label>
<Label className="spanTwo">
JSON
<Textarea value={form.configJson} onChange={(event) => setForm({ ...form, configJson: event.target.value })} />
</Label>
</FormDialog>
<ConfirmDialog
confirmLabel="删除渠道"
description="删除后该文件存储渠道不会再参与上传轮转。"
loading={props.state === 'loading'}
open={Boolean(pendingDeleteChannel)}
title={`确认删除文件存储渠道 ${pendingDeleteChannel?.name ?? ''}`}
onCancel={() => setPendingDeleteChannel(null)}
onConfirm={() => pendingDeleteChannel ? deleteChannel(pendingDeleteChannel) : undefined}
/>
</div>
);
}
function defaultChannelForm(channelKey = ''): FileStorageChannelForm {
return {
apiKey: '',
apiKeyPreview: '',
channelKey,
configJson: '{}',
name: 'server-main OpenAPI',
priority: '100',
provider: 'server_main_openapi',
retryPolicyJson: stringifyJson(defaultRetryPolicy),
scenes: defaultScenes,
status: 'disabled',
uploadUrl: defaultUploadUrl,
};
}
function channelToForm(channel: FileStorageChannel): FileStorageChannelForm {
const preview = apiKeyPreview(channel);
return {
apiKey: preview,
apiKeyPreview: preview,
channelKey: channel.channelKey,
configJson: stringifyJson(channel.config ?? {}),
name: channel.name,
priority: String(channel.priority || 100),
provider: channel.provider || 'server_main_openapi',
retryPolicyJson: stringifyJson(channel.retryPolicy ?? defaultRetryPolicy),
scenes: normalizeScenes(channel.scenes),
status: channel.status || 'disabled',
uploadUrl: channel.uploadUrl || defaultUploadUrl,
};
}
function formToPayload(form: FileStorageChannelForm): FileStorageChannelUpsertRequest {
return {
apiKey: apiKeyPayloadValue(form),
channelKey: form.channelKey.trim(),
config: parseJsonObject(form.configJson, '扩展配置 JSON'),
name: form.name.trim(),
priority: Number(form.priority) || 100,
provider: form.provider,
retryPolicy: parseJsonObject(form.retryPolicyJson, '重试策略 JSON'),
scenes: normalizeScenes(form.scenes),
status: form.status,
uploadUrl: form.uploadUrl.trim(),
};
}
function parseJsonObject(value: string, label: string) {
try {
const parsed = JSON.parse(value || '{}') as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`${label} 必须是对象`);
}
return parsed as Record<string, unknown>;
} catch (err) {
if (err instanceof Error && err.message.includes(label)) throw err;
throw new Error(`${label} 格式不正确`);
}
}
function stringifyJson(value: unknown) {
return JSON.stringify(value ?? {}, null, 2);
}
function providerLabel(provider: string) {
return providerOptions.find((item) => item.value === provider)?.label ?? provider;
}
function sceneSummary(scenes: string[] | undefined) {
return normalizeScenes(scenes).map((scene) => sceneOptions.find((item) => item.value === scene)?.label ?? scene).join(' / ');
}
function normalizeResultUploadPolicy(value: string | undefined) {
const normalized = (value || 'default').trim();
return resultUploadPolicyOptions.some((item) => item.value === normalized) ? normalized : 'default';
}
function resultUploadPolicyDescription(value: string | undefined) {
const normalized = normalizeResultUploadPolicy(value);
return resultUploadPolicyOptions.find((item) => item.value === normalized)?.description ?? '';
}
function normalizeScenes(scenes: string[] | undefined) {
const next = Array.from(new Set((scenes ?? []).map((scene) => scene.trim()).filter(Boolean)));
return next.length ? next : [...defaultScenes];
}
function nextScenes(current: string[], scene: string, checked: boolean) {
if (checked) return normalizeScenes([...current, scene]);
return current.filter((item) => item !== scene);
}
function retryPolicySummary(policy?: Record<string, unknown>) {
const maxRetries = numberFromUnknown(policy?.maxRetries) || 3;
const backoff = Array.isArray(policy?.backoffSeconds) ? policy?.backoffSeconds.join('/') : '60/120/180';
return `${maxRetries} 次 · ${backoff}s`;
}
function apiKeyPreview(channel: FileStorageChannel) {
const value = channel.credentialsPreview?.apiKey;
return typeof value === 'string' ? value : '';
}
function apiKeyPayloadValue(form: FileStorageChannelForm) {
const value = form.apiKey.trim();
if (form.apiKeyPreview && value === form.apiKeyPreview) return undefined;
return value || (form.apiKeyPreview ? '' : undefined);
}
function credentialInputPlaceholder(preview: string) {
return preview ? '填写新凭证以覆盖当前值' : 'sk-...';
}
function FileStorageSceneToggle(props: { checked: boolean; description: string; label: string; onChange: (checked: boolean) => void }) {
return (
<label className="platformToggle">
<input type="checkbox" checked={props.checked} onChange={(event) => props.onChange(event.target.checked)} />
<span>
<strong>{props.label}</strong>
<small>{props.description}</small>
</span>
</label>
);
}
function numberFromUnknown(value: unknown) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return 0;
}