@@ -15,6 +15,10 @@ type ClientCustomizationForm = {
} ;
type FileStorageChannelForm = {
accessKeyId : string ;
accessKeyIdPreview : string ;
accessKeySecret : string ;
accessKeySecretPreview : string ;
apiKey : string ;
apiKeyPreview : string ;
channelKey : string ;
@@ -23,13 +27,22 @@ type FileStorageChannelForm = {
priority : string ;
provider : string ;
retryPolicyJson : string ;
sessionToken : string ;
sessionTokenPreview : string ;
scenes : string [ ] ;
status : string ;
uploadUrl : string ;
} ;
const defaultUploadUrl = 'http://127.0.0.1:3001/v1/files/upload' ;
const defaultRetryPolicy = {
const defaultObjectStorageRetryPolicy = {
enabled : true ,
maxRetries : 2 ,
backoffSeconds : [ 0.25 , 1 ] ,
strategy : 'exponential' ,
} ;
const defaultServerMainRetryPolicy = {
enabled : true ,
maxRetries : 3 ,
backoffSeconds : [ 60 , 120 , 180 ] ,
@@ -39,7 +52,7 @@ const defaultRetryPolicy = {
const providerOptions = [
{ value : 'server_main_openapi' , label : 'server-main OpenAPI' } ,
{ value : 'aliyun_oss' , label : '阿里云 OSS' } ,
{ value : 'tencent_co s' , label : '腾讯云 COS ' } ,
{ value : 's3 ' , label : 'S3 / S3 兼容存储 ' } ,
] ;
const defaultScenes = [ 'upload' , 'image_result' , 'request_asset' ] ;
@@ -52,7 +65,6 @@ const sceneOptions = [
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 : {
@@ -66,6 +78,7 @@ export function SystemSettingsPanel(props: {
onSaveClientCustomizationSettings : ( input : ClientCustomizationSettingsUpdateRequest ) = > Promise < void > ;
onSaveFileStorageChannel : ( input : FileStorageChannelUpsertRequest , channelId? : string ) = > Promise < void > ;
onSaveFileStorageSettings : ( input : FileStorageSettingsUpdateRequest ) = > Promise < void > ;
onTestFileStorageChannel : ( channelId : string ) = > Promise < void > ;
} ) {
const [ activeTab , setActiveTab ] = useState < SystemSettingsTab > ( 'fileStorage' ) ;
const [ dialogOpen , setDialogOpen ] = useState ( false ) ;
@@ -75,6 +88,7 @@ export function SystemSettingsPanel(props: {
const [ clientCustomizationForm , setClientCustomizationForm ] = useState < ClientCustomizationForm > ( ( ) = > clientCustomizationSettingsToForm ( props . clientCustomizationSettings ) ) ;
const [ settingsPolicy , setSettingsPolicy ] = useState ( ( ) = > normalizeResultUploadPolicy ( props . settings ? . resultUploadPolicy ) ) ;
const [ localError , setLocalError ] = useState ( '' ) ;
const [ testingChannelId , setTestingChannelId ] = useState ( '' ) ;
useEffect ( ( ) = > {
setSettingsPolicy ( normalizeResultUploadPolicy ( props . settings ? . resultUploadPolicy ) ) ;
@@ -130,6 +144,18 @@ export function SystemSettingsPanel(props: {
}
}
async function testChannel ( channel : FileStorageChannel ) {
setLocalError ( '' ) ;
setTestingChannelId ( channel . id ) ;
try {
await props . onTestFileStorageChannel ( channel . id ) ;
} catch ( err ) {
setLocalError ( err instanceof Error ? err . message : '对象存储连接测试失败' ) ;
} finally {
setTestingChannelId ( '' ) ;
}
}
async function saveSettings() {
setLocalError ( '' ) ;
try {
@@ -154,7 +180,7 @@ export function SystemSettingsPanel(props: {
< CardHeader >
< div >
< CardTitle > 系 统 设 置 < / CardTitle >
< p className = "mutedText" > 集 中 维 护 网 关 级 配 置 ; 文 件 存 储 渠 道 按 优 先 级 轮 转 , 单 渠 道 使 用 60 / 120 / 180 秒 退 避 重 试 。 < / p >
< p className = "mutedText" > 集 中 维 护 网 关 级 配 置 ; 文 件 存 储 渠 道 按 优 先 级 轮 转 , 对 象 存 储 默 认 使 用 250 ms / 1 s 退 避 。 < / p >
< / div >
< Badge variant = "secondary" > { props . channels . length } 个 文 件 渠 道 < / Badge >
< / CardHeader >
@@ -177,7 +203,7 @@ export function SystemSettingsPanel(props: {
< div className = "fileStorageSettingsCard" >
< div >
< strong > 全 局 生 成 媒 体 转 存 策 略 < / strong >
< span > 对 图 片 、 音 频 和 视 频 生 成 结 果 统 一 生 效 ; 启 用 转 存 但 没 有 可 用 渠 道 时 , 回 退 到 保 留 24 小 时 的 网 关 本 地 静 态 资 源 。 < / span >
< span > 对 图 片 、 音 频 和 视 频 生 成 结 果 统 一 生 效 ; 没 有 可 用 对 象 存 储 时 任 务 会 安 全 失 败 , 不 再 写 入 网 关 本 机 目 录 。 < / span >
< / div >
< Label >
策 略
@@ -195,7 +221,7 @@ export function SystemSettingsPanel(props: {
< div className = "fileStorageToolbar" >
< div >
< strong > 文 件 存 储 渠 道 < / strong >
< span > server - main OpenAPI 渠 道 只 需 要 上 传 路 由 和 API Key 。 < / span >
< span > 支 持 server - main OpenAPI 、 阿 里 云 OSS 与 S3 协 议 ; 按 优 先 级 重 试 下 一 个 渠 道 。 < / span >
< / div >
< Button type = "button" onClick = { openCreateDialog } >
< Plus size = { 15 } / >
@@ -224,6 +250,12 @@ export function SystemSettingsPanel(props: {
{ channel . lastError && < span > 最 近 错 误 : { channel . lastError } < / span > }
< / div >
< footer >
{ channel . provider !== 'server_main_openapi' && (
< Button type = "button" variant = "outline" size = "sm" disabled = { testingChannelId === channel . id } onClick = { ( ) = > testChannel ( channel ) } >
< ShieldCheck size = { 14 } / >
{ testingChannelId === channel . id ? '测试中…' : '连接测试' }
< / Button >
) }
< Button type = "button" variant = "outline" size = "sm" onClick = { ( ) = > editChannel ( channel ) } >
< Pencil size = { 14 } / >
修 改
@@ -312,7 +344,16 @@ export function SystemSettingsPanel(props: {
< / Label >
< Label >
渠 道 类 型
< Select value = { form . provider } onChange = { ( event ) = > setForm ( { . . . form , provider : event.target.value } ) } >
< Select value = { form . provider } onChange = { ( event ) = > {
const provider = event . target . value ;
setForm ( {
. . . form ,
provider ,
retryPolicyJson : editingChannel
? form . retryPolicyJson
: stringifyJson ( defaultRetryPolicyForProvider ( provider ) ) ,
} ) ;
} } >
{ providerOptions . map ( ( item ) = > < option value = { item . value } key = { item . value } > { item . label } < / option > ) }
< / Select >
< / Label >
@@ -337,15 +378,29 @@ export function SystemSettingsPanel(props: {
) ) }
< / div >
< / Label >
< Label className = "spanTwo" >
{ form . provider === 'server_main_openapi' && < Label className = "spanTwo" >
上 传 路 由
< Input value = { form . uploadUrl } onChange = { ( event ) = > setForm ( { . . . form , uploadUrl : event.target.value } ) } placeholder = { defaultUploadUrl } / >
< / Label >
< Label className = "platformCredentialField" >
< / Label > }
{ form . provider === 'server_main_openapi' && < 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 > }
{ form . provider !== 'server_main_openapi' && < >
< Label >
Access Key ID
< Input value = { form . accessKeyId } onChange = { ( event ) = > setForm ( { . . . form , accessKeyId : event.target.value } ) } placeholder = { credentialInputPlaceholder ( form . accessKeyIdPreview ) } / >
< / Label >
< Label >
Access Key Secret
< Input type = "password" value = { form . accessKeySecret } onChange = { ( event ) = > setForm ( { . . . form , accessKeySecret : event.target.value } ) } placeholder = { credentialInputPlaceholder ( form . accessKeySecretPreview ) } / >
< / Label >
< Label className = "spanTwo" >
Session Token ( 可 选 )
< Input type = "password" value = { form . sessionToken } onChange = { ( event ) = > setForm ( { . . . form , sessionToken : event.target.value } ) } placeholder = { credentialInputPlaceholder ( form . sessionTokenPreview ) } / >
< / Label >
< / > }
< Label >
优 先 级
< Input type = "number" min = { 1 } value = { form . priority } onChange = { ( event ) = > setForm ( { . . . form , priority : event.target.value } ) } / >
@@ -403,6 +458,10 @@ function clientCustomizationFormToPayload(form: ClientCustomizationForm): Client
function defaultChannelForm ( channelKey = '' ) : FileStorageChannelForm {
return {
accessKeyId : '' ,
accessKeyIdPreview : '' ,
accessKeySecret : '' ,
accessKeySecretPreview : '' ,
apiKey : '' ,
apiKeyPreview : '' ,
channelKey ,
@@ -410,7 +469,9 @@ function defaultChannelForm(channelKey = ''): FileStorageChannelForm {
name : 'server-main OpenAPI' ,
priority : '100' ,
provider : 'server_main_openapi' ,
retryPolicyJson : stringifyJson ( defaultRetryPolicy ) ,
retryPolicyJson : stringifyJson ( defaultServerMainRetryPolicy ) ,
sessionToken : '' ,
sessionTokenPreview : '' ,
scenes : defaultScenes ,
status : 'disabled' ,
uploadUrl : defaultUploadUrl ,
@@ -420,6 +481,10 @@ function defaultChannelForm(channelKey = ''): FileStorageChannelForm {
function channelToForm ( channel : FileStorageChannel ) : FileStorageChannelForm {
const preview = apiKeyPreview ( channel ) ;
return {
accessKeyId : credentialPreview ( channel , 'accessKeyId' ) ,
accessKeyIdPreview : credentialPreview ( channel , 'accessKeyId' ) ,
accessKeySecret : credentialPreview ( channel , 'accessKeySecret' ) ,
accessKeySecretPreview : credentialPreview ( channel , 'accessKeySecret' ) ,
apiKey : preview ,
apiKeyPreview : preview ,
channelKey : channel.channelKey ,
@@ -427,7 +492,9 @@ function channelToForm(channel: FileStorageChannel): FileStorageChannelForm {
name : channel.name ,
priority : String ( channel . priority || 100 ) ,
provider : channel.provider || 'server_main_openapi' ,
retryPolicyJson : stringifyJson ( channel . retryPolicy ? ? defaultRetryPolicy ) ,
retryPolicyJson : stringifyJson ( channel . retryPolicy ? ? defaultRetryPolicyForProvider ( channel . provider ) ) ,
sessionToken : credentialPreview ( channel , 'sessionToken' ) ,
sessionTokenPreview : credentialPreview ( channel , 'sessionToken' ) ,
scenes : normalizeScenes ( channel . scenes ) ,
status : channel.status || 'disabled' ,
uploadUrl : channel.uploadUrl || defaultUploadUrl ,
@@ -436,6 +503,8 @@ function channelToForm(channel: FileStorageChannel): FileStorageChannelForm {
function formToPayload ( form : FileStorageChannelForm ) : FileStorageChannelUpsertRequest {
return {
accessKeyId : credentialPayloadValue ( form . accessKeyId , form . accessKeyIdPreview ) ,
accessKeySecret : credentialPayloadValue ( form . accessKeySecret , form . accessKeySecretPreview ) ,
apiKey : apiKeyPayloadValue ( form ) ,
channelKey : form.channelKey.trim ( ) ,
config : parseJsonObject ( form . configJson , '扩展配置 JSON' ) ,
@@ -443,6 +512,7 @@ function formToPayload(form: FileStorageChannelForm): FileStorageChannelUpsertRe
priority : Number ( form . priority ) || 100 ,
provider : form.provider ,
retryPolicy : parseJsonObject ( form . retryPolicyJson , '重试策略 JSON' ) ,
sessionToken : credentialPayloadValue ( form . sessionToken , form . sessionTokenPreview ) ,
scenes : normalizeScenes ( form . scenes ) ,
status : form.status ,
uploadUrl : form.uploadUrl.trim ( ) ,
@@ -495,8 +565,8 @@ function nextScenes(current: string[], scene: string, checked: boolean) {
}
function retryPolicySummary ( policy? : Record < string , unknown > ) {
const maxRetries = numberFromUnknown ( policy ? . maxRetries ) || 3 ;
const backoff = Array . isArray ( policy ? . backoffSeconds ) ? policy ? . backoffSeconds . join ( '/' ) : '60/120/180 ' ;
const maxRetries = numberFromUnknown ( policy ? . maxRetries ) || 2 ;
const backoff = Array . isArray ( policy ? . backoffSeconds ) ? policy ? . backoffSeconds . join ( '/' ) : '0.25/1 ' ;
return ` ${ maxRetries } 次 · ${ backoff } s ` ;
}
@@ -506,9 +576,22 @@ function apiKeyPreview(channel: FileStorageChannel) {
}
function apiKeyPayloadValue ( form : FileStorageChannelForm ) {
const value = form . apiKey . trim ( ) ;
if ( form . apiKeyPreview && value === form . apiKeyPreview ) return undefined ;
return value || ( form . apiKeyPreview ? '' : undefined ) ;
return credentialPayloadValue ( form . apiKey , form . apiKeyPreview ) ;
}
function credentialPreview ( channel : FileStorageChannel , key : string ) {
const value = channel . credentialsPreview ? . [ key ] ;
return typeof value === 'string' ? value : '' ;
}
function credentialPayloadValue ( value : string , preview : string ) {
const normalized = value . trim ( ) ;
if ( preview && normalized === preview ) return undefined ;
return normalized || ( preview ? '' : undefined ) ;
}
function defaultRetryPolicyForProvider ( provider : string ) {
return provider === 'server_main_openapi' ? defaultServerMainRetryPolicy : defaultObjectStorageRetryPolicy ;
}
function credentialInputPlaceholder ( preview : string ) {