Files
easyai-ai-gateway/apps/web/src/pages/admin/SystemSettingsPanel.tsx
T
wangbo edf7e66941 feat(cluster): 增加跨节点任务执行与请求素材场景
新增 AI_GATEWAY_ASYNC_QUEUE_WORKER_ENABLED,默认保持启用;关闭时仅停止 River 异步任务执行器,HTTP、健康检查、结算与其他后台任务继续运行。

新增 0089 迁移并同步管理端默认场景,将 request_asset 加入现有上传渠道,确保 multipart 请求素材先转换为跨节点共享 URL。

验证:gofmt、go test ./... -count=1、go vet ./...、pnpm lint、pnpm test、pnpm build、迁移测试。
2026-07-28 04:36:58 +08:00

538 lines
22 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, Settings2, ShieldCheck, Trash2 } from 'lucide-react';
import type { ClientCustomizationSettings, ClientCustomizationSettingsUpdateRequest, 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';
import { UnifiedIdentityPanel } from './UnifiedIdentityPanel';
type SystemSettingsTab = 'fileStorage' | 'clientCustomization' | 'identity';
type ClientCustomizationForm = {
clientEnglishName: string;
clientName: string;
iconPath: string;
platformName: string;
};
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', 'request_asset'];
const sceneOptions = [
{ value: 'upload', label: '上传', description: 'OpenAPI / 管理端主动上传文件' },
{ value: 'image_result', label: '生成媒体结果', description: '模型返回 base64 / buffer 图片、音频或视频后的转存' },
{ value: 'request_asset', label: '请求素材', description: '将 multipart / base64 输入转换为跨节点可读取的公共 URL' },
];
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 临时写入本地静态文件;数据库仅保存占位符,默认 24 小时内按需恢复' },
];
export function SystemSettingsPanel(props: {
token: string;
channels: FileStorageChannel[];
clientCustomizationSettings: ClientCustomizationSettings | null;
message: string;
settings: FileStorageSettings | null;
state: LoadState;
onDeleteFileStorageChannel: (channelId: string) => Promise<void>;
onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => 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 [clientCustomizationForm, setClientCustomizationForm] = useState<ClientCustomizationForm>(() => clientCustomizationSettingsToForm(props.clientCustomizationSettings));
const [settingsPolicy, setSettingsPolicy] = useState(() => normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
const [localError, setLocalError] = useState('');
useEffect(() => {
setSettingsPolicy(normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
}, [props.settings?.resultUploadPolicy]);
useEffect(() => {
setClientCustomizationForm(clientCustomizationSettingsToForm(props.clientCustomizationSettings));
}, [props.clientCustomizationSettings]);
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 : '文件存储全局策略保存失败');
}
}
async function saveClientCustomization() {
setLocalError('');
try {
await props.onSaveClientCustomizationSettings(clientCustomizationFormToPayload(clientCustomizationForm));
} 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} /> },
{ value: 'clientCustomization', label: '客户端自定义', icon: <Settings2 size={15} /> },
{ value: 'identity', label: '统一认证', icon: <ShieldCheck size={15} /> },
]}
onValueChange={setActiveTab}
/>
</CardContent>
</Card>
{activeTab === 'fileStorage' && (
<section className="fileStoragePanel">
<div className="fileStorageSettingsCard">
<div>
<strong>全局生成媒体转存策略</strong>
<span>对图片、音频和视频生成结果统一生效;启用转存但没有可用渠道时,回退到保留 24 小时的网关本地静态资源。</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>
)}
{activeTab === 'clientCustomization' && (
<section className="fileStoragePanel">
<div className="fileStorageSettingsCard">
<div>
<strong>客户端自定义</strong>
<span>配置公开展示用的客户端名称、平台名和图标路径;公开 API 无需鉴权读取。</span>
</div>
<div className="formGrid two spanTwo">
<Label>
客户端名
<Input value={clientCustomizationForm.clientName} onChange={(event) => setClientCustomizationForm({ ...clientCustomizationForm, clientName: event.target.value })} placeholder="EasyAI AI Gateway" />
</Label>
<Label>
客户端英文名
<Input value={clientCustomizationForm.clientEnglishName} onChange={(event) => setClientCustomizationForm({ ...clientCustomizationForm, clientEnglishName: event.target.value })} placeholder="EasyAI AI Gateway" />
</Label>
<Label>
平台名
<Input value={clientCustomizationForm.platformName} onChange={(event) => setClientCustomizationForm({ ...clientCustomizationForm, platformName: event.target.value })} placeholder="EasyAI" />
</Label>
<Label>
Icon 路径/URL
<Input value={clientCustomizationForm.iconPath} onChange={(event) => setClientCustomizationForm({ ...clientCustomizationForm, iconPath: event.target.value })} placeholder="/logo.svg 或 https://..." />
</Label>
</div>
<Button type="button" onClick={saveClientCustomization} disabled={props.state === 'loading'}>
<Save size={15} />
保存客户端自定义
</Button>
</div>
</section>
)}
{activeTab === 'identity' && <UnifiedIdentityPanel token={props.token} />}
<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 defaultClientCustomizationForm(): ClientCustomizationForm {
return {
clientEnglishName: 'EasyAI AI Gateway',
clientName: 'EasyAI AI Gateway',
iconPath: '',
platformName: 'EasyAI',
};
}
function clientCustomizationSettingsToForm(settings: ClientCustomizationSettings | null): ClientCustomizationForm {
const defaults = defaultClientCustomizationForm();
return {
clientEnglishName: settings?.clientEnglishName ?? defaults.clientEnglishName,
clientName: settings?.clientName ?? defaults.clientName,
iconPath: settings?.iconPath ?? defaults.iconPath,
platformName: settings?.platformName ?? defaults.platformName,
};
}
function clientCustomizationFormToPayload(form: ClientCustomizationForm): ClientCustomizationSettingsUpdateRequest {
return {
clientEnglishName: form.clientEnglishName.trim(),
clientName: form.clientName.trim(),
iconPath: form.iconPath.trim(),
platformName: form.platformName.trim(),
};
}
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;
}