445 lines
17 KiB
TypeScript
445 lines
17 KiB
TypeScript
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;
|
||
}
|