新增 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、迁移测试。
538 lines
22 KiB
TypeScript
538 lines
22 KiB
TypeScript
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;
|
||
}
|