@@ -21,16 +21,24 @@ type FileStorageChannelForm = {
accessKeySecretPreview : string ;
apiKey : string ;
apiKeyPreview : string ;
bucket : string ;
channelKey : string ;
configJson : string ;
endpoint : string ;
forcePathStyle : boolean ;
name : string ;
objectPrefix : string ;
priority : string ;
provider : string ;
publicBaseUrl : string ;
region : string ;
retryPolicyJson : string ;
sessionToken : string ;
sessionTokenPreview : string ;
signedUrlExpiresMinutes : string ;
scenes : string [ ] ;
status : string ;
temporaryFileExpirePolicy : string ;
uploadUrl : string ;
} ;
@@ -55,6 +63,14 @@ const providerOptions = [
{ value : 's3' , label : 'S3 / S3 兼容存储' } ,
] ;
const expirationPolicyOptions = [
{ value : 'never' , label : '永久保留' } ,
{ value : '1d' , label : '1 天' } ,
{ value : '1m' , label : '1 个月(30 天)' } ,
{ value : '3m' , label : '3 个月(90 天)' } ,
{ value : '6m' , label : '6 个月(180 天)' } ,
] ;
const defaultScenes = [ 'upload' , 'image_result' , 'request_asset' ] ;
const sceneOptions = [
{ value : 'upload' , label : '上传' , description : 'OpenAPI / 管理端主动上传文件' } ,
@@ -126,6 +142,15 @@ export function SystemSettingsPanel(props: {
setLocalError ( '请至少选择一个适用场景。' ) ;
return ;
}
if ( form . provider !== 'server_main_openapi' && ( ! form . endpoint . trim ( ) || ! form . region . trim ( ) || ! form . bucket . trim ( ) ) ) {
setLocalError ( '对象存储渠道必须填写 Endpoint、Region 和 Bucket。' ) ;
return ;
}
const signedUrlExpiresMinutes = Number ( form . signedUrlExpiresMinutes ) ;
if ( form . provider !== 'server_main_openapi' && ( ! Number . isInteger ( signedUrlExpiresMinutes ) || signedUrlExpiresMinutes < 1 || signedUrlExpiresMinutes > 10080 ) ) {
setLocalError ( '私有链接有效期必须是 1 至 10080 分钟之间的整数。' ) ;
return ;
}
try {
await props . onSaveFileStorageChannel ( formToPayload ( form ) , editingChannel ? . id ) ;
closeDialog ( ) ;
@@ -245,6 +270,10 @@ export function SystemSettingsPanel(props: {
< span > 场 景 : { sceneSummary ( channel . scenes ) } < / span >
< span > 优 先 级 : { channel . priority } < / span >
< span > 重 试 : { retryPolicySummary ( channel . retryPolicy ) } < / span >
{ channel . provider !== 'server_main_openapi' && configString ( channel . config , 'region' ) && < span > Region : { configString ( channel . config , 'region' ) } < / span > }
{ channel . provider !== 'server_main_openapi' && configString ( channel . config , 'bucket' ) && < span > Bucket : { configString ( channel . config , 'bucket' ) } < / span > }
{ channel . provider !== 'server_main_openapi' && publicBaseUrlFromConfig ( channel . config ) && < span > CDN : { publicBaseUrlFromConfig ( channel . config ) } < / span > }
{ channel . provider !== 'server_main_openapi' && < span > 临 时 文 件 : { expirationPolicyLabel ( expirationPolicyFromConfig ( channel . config ) ) } < / span > }
{ channel . uploadUrl && < span > 上 传 路 由 : { channel . uploadUrl } < / span > }
{ apiKeyPreview ( channel ) && < span > API Key : { apiKeyPreview ( channel ) } < / span > }
{ channel . lastError && < span > 最 近 错 误 : { channel . lastError } < / span > }
@@ -348,6 +377,7 @@ export function SystemSettingsPanel(props: {
const provider = event . target . value ;
setForm ( {
. . . form ,
name : editingChannel ? form.name : providerLabel ( provider ) ,
provider ,
retryPolicyJson : editingChannel
? form.retryPolicyJson
@@ -388,6 +418,35 @@ export function SystemSettingsPanel(props: {
< small > 保 持 脱 敏 值 不 变 表 示 不 修 改 ; 填 写 新 值 表 示 覆 盖 ; 清 空 后 保 存 表 示 清 除 。 < / small >
< / Label > }
{ form . provider !== 'server_main_openapi' && < >
< Label className = "spanTwo" >
上 传 Endpoint
< Input value = { form . endpoint } onChange = { ( event ) = > setForm ( { . . . form , endpoint : event.target.value } ) } placeholder = { form . provider === 'aliyun_oss' ? 'https://oss-cn-shanghai.aliyuncs.com' : 'https://s3.example.com' } / >
< small > 服 务 端 写 入 对 象 使 用 的 地 址 , 可 填 写 同 地 域 内 网 Endpoint ; 不 要 填 写 CDN 域 名 。 < / small >
< / Label >
< Label >
Region
< Input value = { form . region } onChange = { ( event ) = > setForm ( { . . . form , region : event.target.value } ) } placeholder = { form . provider === 'aliyun_oss' ? 'cn-shanghai' : 'us-east-1' } / >
< / Label >
< Label >
Bucket
< Input value = { form . bucket } onChange = { ( event ) = > setForm ( { . . . form , bucket : event.target.value } ) } placeholder = "easyai-media" / >
< / Label >
< Label className = "spanTwo" >
CDN / 公 网 访 问 域 名 ( 可 选 )
< Input value = { form . publicBaseUrl } onChange = { ( event ) = > setForm ( { . . . form , publicBaseUrl : event.target.value } ) } placeholder = "https://cdn.example.com" / >
< small > 生 成 媒 体 将 返 回 此 域 名 ; 请 求 素 材 仍 使 用 短 期 私 有 签 名 URL 。 < / small >
< / Label >
< Label >
对 象 路 径 前 缀 ( 可 选 )
< Input value = { form . objectPrefix } onChange = { ( event ) = > setForm ( { . . . form , objectPrefix : event.target.value } ) } placeholder = "gateway" / >
< / Label >
< Label >
临 时 文 件 保 留 时 间
< Select value = { form . temporaryFileExpirePolicy } onChange = { ( event ) = > setForm ( { . . . form , temporaryFileExpirePolicy : event.target.value } ) } >
{ expirationPolicyOptions . map ( ( item ) = > < option value = { item . value } key = { item . value } > { item . label } < / option > ) }
< / Select >
< small > 仅 用 于 生 成 媒 体 和 请 求 素 材 ; 云 端 生 命 周 期 按 天 异 步 删 除 。 < / small >
< / Label >
< Label >
Access Key ID
< Input value = { form . accessKeyId } onChange = { ( event ) = > setForm ( { . . . form , accessKeyId : event.target.value } ) } placeholder = { credentialInputPlaceholder ( form . accessKeyIdPreview ) } / >
@@ -396,23 +455,43 @@ export function SystemSettingsPanel(props: {
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 } ) } / >
< / 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 >
< details className = "fileStorageAdvanced spanTwo" >
< summary > 高 级 配 置 < / summary >
< div className = "fileStorageAdvancedGrid" >
{ form . provider !== 'server_main_openapi' && < >
< 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 } max = { 10080 } value = { form . signedUrlExpiresMinutes } onChange = { ( event ) = > setForm ( { . . . form , signedUrlExpiresMinutes : event.target.value } ) } / >
< small > 只 控 制 签 名 URL , 不 会 删 除 对 象 。 < / small >
< / Label >
{ form . provider === 's3' && < Label >
Bucket 寻 址 方 式
< Select value = { form . forcePathStyle ? 'path' : 'virtual' } onChange = { ( event ) = > setForm ( { . . . form , forcePathStyle : event.target.value === 'path' } ) } >
< option value = "virtual" > Virtual Host ( 推 荐 ) < / option >
< option value = "path" > Path Style < / option >
< / Select >
< / 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 } ) } / >
< small > 仅 保 存 未 被 上 方 结 构 化 字 段 管 理 的 扩 展 项 。 < / small >
< / Label >
< / div >
< / details >
< / FormDialog >
< ConfirmDialog
@@ -464,22 +543,31 @@ function defaultChannelForm(channelKey = ''): FileStorageChannelForm {
accessKeySecretPreview : '' ,
apiKey : '' ,
apiKeyPreview : '' ,
bucket : '' ,
channelKey ,
configJson : '{}' ,
endpoint : '' ,
forcePathStyle : false ,
name : 'server-main OpenAPI' ,
objectPrefix : '' ,
priority : '100' ,
provider : 'server_main_openapi' ,
publicBaseUrl : '' ,
region : '' ,
retryPolicyJson : stringifyJson ( defaultServerMainRetryPolicy ) ,
sessionToken : '' ,
sessionTokenPreview : '' ,
signedUrlExpiresMinutes : '15' ,
scenes : defaultScenes ,
status : 'disabled' ,
temporaryFileExpirePolicy : 'never' ,
uploadUrl : defaultUploadUrl ,
} ;
}
function channelToForm ( channel : FileStorageChannel ) : FileStorageChannelForm {
export function channelToForm ( channel : FileStorageChannel ) : FileStorageChannelForm {
const preview = apiKeyPreview ( channel ) ;
const config = channel . config ? ? { } ;
return {
accessKeyId : credentialPreview ( channel , 'accessKeyId' ) ,
accessKeyIdPreview : credentialPreview ( channel , 'accessKeyId' ) ,
@@ -487,27 +575,36 @@ function channelToForm(channel: FileStorageChannel): FileStorageChannelForm {
accessKeySecretPreview : credentialPreview ( channel , 'accessKeySecret' ) ,
apiKey : preview ,
apiKeyPreview : preview ,
bucket : configString ( config , 'bucket' ) ,
channelKey : channel.channelKey ,
configJson : stringifyJson ( channel . config ? ? { } ) ,
configJson : stringifyJson ( channel . provider === 'server_main_openapi' ? config : unmanagedObjectStorageConfig ( config ) ) ,
endpoint : configString ( config , 'endpoint' ) ,
forcePathStyle : configBoolean ( config , 'forcePathStyle' ) ,
name : channel.name ,
objectPrefix : configString ( config , 'objectPrefix' ) ,
priority : String ( channel . priority || 100 ) ,
provider : channel.provider || 'server_main_openapi' ,
publicBaseUrl : publicBaseUrlFromConfig ( config ) ,
region : configString ( config , 'region' ) ,
retryPolicyJson : stringifyJson ( channel . retryPolicy ? ? defaultRetryPolicyForProvider ( channel . provider ) ) ,
sessionToken : credentialPreview ( channel , 'sessionToken' ) ,
sessionTokenPreview : credentialPreview ( channel , 'sessionToken' ) ,
signedUrlExpiresMinutes : String ( Math . max ( 1 , Math . round ( configNumber ( config , 'signedUrlExpiresSeconds' , 900 ) / 60 ) ) ) ,
scenes : normalizeScenes ( channel . scenes ) ,
status : channel.status || 'disabled' ,
temporaryFileExpirePolicy : expirationPolicyFromConfig ( config ) ,
uploadUrl : channel.uploadUrl || defaultUploadUrl ,
} ;
}
function formToPayload ( form : FileStorageChannelForm ) : FileStorageChannelUpsertRequest {
export function formToPayload ( form : FileStorageChannelForm ) : FileStorageChannelUpsertRequest {
const config = parseJsonObject ( form . configJson , '其他扩展配置 JSON' ) ;
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' ) ,
config : form.provider === 'server_main_openapi' ? config : objectStorageConfigPayload ( form , config ) ,
name : form.name.trim ( ) ,
priority : Number ( form . priority ) || 100 ,
provider : form.provider ,
@@ -519,6 +616,73 @@ function formToPayload(form: FileStorageChannelForm): FileStorageChannelUpsertRe
} ;
}
const managedObjectStorageConfigKeys = new Set ( [
'apiReturnExpirePolicy' ,
'bucket' ,
'cdnDomain' ,
'endpoint' ,
'forcePathStyle' ,
'objectPrefix' ,
'publicBaseURL' ,
'publicBaseUrl' ,
'publicDomain' ,
'region' ,
'signedUrlExpiresSeconds' ,
'temporaryFileExpirePolicy' ,
] ) ;
function unmanagedObjectStorageConfig ( config : Record < string , unknown > ) {
return Object . fromEntries ( Object . entries ( config ) . filter ( ( [ key ] ) = > ! managedObjectStorageConfigKeys . has ( key ) ) ) ;
}
function objectStorageConfigPayload ( form : FileStorageChannelForm , extras : Record < string , unknown > ) {
const config : Record < string , unknown > = { . . . unmanagedObjectStorageConfig ( extras ) } ;
config . endpoint = form . endpoint . trim ( ) ;
config . region = form . region . trim ( ) ;
config . bucket = form . bucket . trim ( ) ;
config . temporaryFileExpirePolicy = normalizeExpirationPolicy ( form . temporaryFileExpirePolicy ) ;
config . signedUrlExpiresSeconds = Math . max ( 1 , Math . round ( Number ( form . signedUrlExpiresMinutes ) || 15 ) ) * 60 ;
if ( form . publicBaseUrl . trim ( ) ) config . publicBaseUrl = form . publicBaseUrl . trim ( ) . replace ( /\/+$/ , '' ) ;
if ( form . objectPrefix . trim ( ) ) config . objectPrefix = form . objectPrefix . trim ( ) . replace ( /^\/+|\/+$/g , '' ) ;
if ( form . provider === 's3' ) config . forcePathStyle = form . forcePathStyle ;
return config ;
}
function configString ( config : Record < string , unknown > | undefined , key : string ) {
const value = config ? . [ key ] ;
return typeof value === 'string' ? value . trim ( ) : '' ;
}
function configBoolean ( config : Record < string , unknown > | undefined , key : string ) {
return config ? . [ key ] === true ;
}
function configNumber ( config : Record < string , unknown > | undefined , key : string , fallback : number ) {
const value = Number ( config ? . [ key ] ) ;
return Number . isFinite ( value ) && value > 0 ? value : fallback ;
}
function publicBaseUrlFromConfig ( config : Record < string , unknown > | undefined ) {
return configString ( config , 'publicBaseUrl' )
|| configString ( config , 'publicBaseURL' )
|| configString ( config , 'cdnDomain' )
|| configString ( config , 'publicDomain' ) ;
}
function expirationPolicyFromConfig ( config : Record < string , unknown > | undefined ) {
return normalizeExpirationPolicy ( configString ( config , 'temporaryFileExpirePolicy' ) || configString ( config , 'apiReturnExpirePolicy' ) ) ;
}
function normalizeExpirationPolicy ( value : string ) {
const normalized = value . trim ( ) . toLowerCase ( ) ;
return expirationPolicyOptions . some ( ( item ) = > item . value === normalized ) ? normalized : 'never' ;
}
function expirationPolicyLabel ( value : string ) {
const normalized = normalizeExpirationPolicy ( value ) ;
return expirationPolicyOptions . find ( ( item ) = > item . value === normalized ) ? . label ? ? '永久保留' ;
}
function parseJsonObject ( value : string , label : string ) {
try {
const parsed = JSON . parse ( value || '{}' ) as unknown ;