feat: add file storage settings and uploads

This commit is contained in:
2026-05-13 20:23:45 +08:00
parent 0d0d0b9115
commit fc5dfd6bc5
21 changed files with 3401 additions and 72 deletions
@@ -0,0 +1,444 @@
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: '返图结果原样保存,不触发文件存储上传' },
];
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;
}