Merge pull request #4593 from StarryKira/fix/image-storage-env-unreachable
fix: 异步生图开关配了却不生效(环境变量被静默丢弃 + 迁移到后台开关)
This commit is contained in:
@@ -60,6 +60,52 @@ export async function testS3Connection(config: BackupS3Config): Promise<TestS3Re
|
||||
return data
|
||||
}
|
||||
|
||||
// Async image object storage
|
||||
//
|
||||
// Shares the S3 client with backups, so `reuse_backup_s3` borrows the endpoint and
|
||||
// credentials configured above and only keeps its own bucket/prefix.
|
||||
export interface ImageStorageConfig {
|
||||
enabled: boolean
|
||||
reuse_backup_s3: boolean
|
||||
bucket: string
|
||||
prefix: string
|
||||
public_base_url: string
|
||||
presign_expiry_hours: number
|
||||
max_download_bytes: number
|
||||
endpoint: string
|
||||
region: string
|
||||
access_key_id: string
|
||||
secret_access_key?: string
|
||||
force_path_style: boolean
|
||||
}
|
||||
|
||||
export interface ImageStorageConfigResponse {
|
||||
config: ImageStorageConfig
|
||||
secret_configured: boolean
|
||||
}
|
||||
|
||||
export async function getImageStorageConfig(): Promise<ImageStorageConfigResponse> {
|
||||
const { data } = await apiClient.get<ImageStorageConfigResponse>('/admin/backups/image-storage')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateImageStorageConfig(
|
||||
config: ImageStorageConfig,
|
||||
): Promise<ImageStorageConfig> {
|
||||
const { data } = await apiClient.put<ImageStorageConfig>('/admin/backups/image-storage', config)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function testImageStorageConnection(
|
||||
config: ImageStorageConfig,
|
||||
): Promise<TestS3Response> {
|
||||
const { data } = await apiClient.post<TestS3Response>(
|
||||
'/admin/backups/image-storage/test',
|
||||
config,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
// Schedule
|
||||
export async function getSchedule(): Promise<BackupScheduleConfig> {
|
||||
const { data } = await apiClient.get<BackupScheduleConfig>('/admin/backups/schedule')
|
||||
@@ -106,6 +152,9 @@ export const backupAPI = {
|
||||
getS3Config,
|
||||
updateS3Config,
|
||||
testS3Connection,
|
||||
getImageStorageConfig,
|
||||
updateImageStorageConfig,
|
||||
testImageStorageConnection,
|
||||
getSchedule,
|
||||
updateSchedule,
|
||||
createBackup,
|
||||
|
||||
@@ -102,6 +102,19 @@ export default {
|
||||
testFailed: 'S3 connection test failed',
|
||||
saved: 'S3 configuration saved'
|
||||
},
|
||||
imageStorage: {
|
||||
title: 'Async image object storage',
|
||||
description: 'Enables the asynchronous image endpoints and offloads generated images to object storage, keeping only short links in Redis. Shares the S3 client with backups and takes effect on save — no restart needed.',
|
||||
enabled: 'Enable async image tasks',
|
||||
reuseBackupS3: 'Reuse the backup S3 configuration above (different bucket/prefix only)',
|
||||
bucket: 'Bucket',
|
||||
bucketInherited: 'Leave empty to use the backup bucket',
|
||||
prefix: 'Key prefix',
|
||||
publicBaseUrl: 'Public base URL',
|
||||
publicBaseUrlPlaceholder: 'Leave empty to return presigned links',
|
||||
presignExpiryHours: 'Presigned link TTL (hours)',
|
||||
saved: 'Async image object storage saved'
|
||||
},
|
||||
schedule: {
|
||||
title: 'Scheduled Backup',
|
||||
description: 'Configure automatic scheduled backups',
|
||||
|
||||
@@ -102,6 +102,19 @@ export default {
|
||||
testFailed: 'S3 连接测试失败',
|
||||
saved: 'S3 配置已保存'
|
||||
},
|
||||
imageStorage: {
|
||||
title: '异步生图对象存储',
|
||||
description: '开启后,异步生图接口可用,生成结果转存到对象存储,只把短链接写入 Redis。与备份共用同一套 S3 客户端,保存后立即生效,无需重启。',
|
||||
enabled: '启用异步生图',
|
||||
reuseBackupS3: '复用上方备份的 S3 配置(只用不同的存储桶/前缀)',
|
||||
bucket: '存储桶',
|
||||
bucketInherited: '留空则沿用备份存储桶',
|
||||
prefix: 'Key 前缀',
|
||||
publicBaseUrl: '公开访问域名',
|
||||
publicBaseUrlPlaceholder: '留空则返回预签名临时链接',
|
||||
presignExpiryHours: '预签名链接有效期(小时)',
|
||||
saved: '异步生图对象存储配置已保存'
|
||||
},
|
||||
schedule: {
|
||||
title: '定时备份',
|
||||
description: '配置自动定时备份',
|
||||
|
||||
@@ -54,6 +54,81 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Async image object storage -->
|
||||
<div class="card p-6">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.backup.imageStorage.title') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.backup.imageStorage.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="imageStorageForm.enabled" type="checkbox" />
|
||||
<span>{{ t('admin.backup.imageStorage.enabled') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="imageStorageForm.reuse_backup_s3" type="checkbox" />
|
||||
<span>{{ t('admin.backup.imageStorage.reuseBackupS3') }}</span>
|
||||
</label>
|
||||
|
||||
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.imageStorage.bucket') }}</label>
|
||||
<input v-model="imageStorageForm.bucket" class="input w-full" :placeholder="imageStorageForm.reuse_backup_s3 ? t('admin.backup.imageStorage.bucketInherited') : ''" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.imageStorage.prefix') }}</label>
|
||||
<input v-model="imageStorageForm.prefix" class="input w-full" placeholder="images/" />
|
||||
</div>
|
||||
|
||||
<template v-if="!imageStorageForm.reuse_backup_s3">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.s3.endpoint') }}</label>
|
||||
<input v-model="imageStorageForm.endpoint" class="input w-full" placeholder="https://<account_id>.r2.cloudflarestorage.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.s3.region') }}</label>
|
||||
<input v-model="imageStorageForm.region" class="input w-full" placeholder="auto" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.s3.accessKeyId') }}</label>
|
||||
<input v-model="imageStorageForm.access_key_id" class="input w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.s3.secretAccessKey') }}</label>
|
||||
<input v-model="imageStorageForm.secret_access_key" type="password" class="input w-full" :placeholder="imageStorageSecretConfigured ? t('admin.backup.s3.secretConfigured') : ''" />
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 md:col-span-2">
|
||||
<input v-model="imageStorageForm.force_path_style" type="checkbox" />
|
||||
<span>{{ t('admin.backup.s3.forcePathStyle') }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.imageStorage.publicBaseUrl') }}</label>
|
||||
<input v-model="imageStorageForm.public_base_url" class="input w-full" :placeholder="t('admin.backup.imageStorage.publicBaseUrlPlaceholder')" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.imageStorage.presignExpiryHours') }}</label>
|
||||
<input v-model.number="imageStorageForm.presign_expiry_hours" type="number" min="1" class="input w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="testingImageStorage" @click="testImageStorage">
|
||||
{{ testingImageStorage ? t('common.loading') : t('admin.backup.s3.testConnection') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" :disabled="savingImageStorage" @click="saveImageStorageConfig">
|
||||
{{ savingImageStorage ? t('common.loading') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedule Config -->
|
||||
<div class="card p-6">
|
||||
<div class="mb-4">
|
||||
@@ -284,7 +359,12 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api'
|
||||
import { useAppStore } from '@/stores'
|
||||
import type { BackupS3Config, BackupScheduleConfig, BackupRecord } from '@/api/admin/backup'
|
||||
import type {
|
||||
BackupS3Config,
|
||||
BackupScheduleConfig,
|
||||
BackupRecord,
|
||||
ImageStorageConfig,
|
||||
} from '@/api/admin/backup'
|
||||
import { useStepUp, isStepUpBlocked, isStepUpCancelled, stepUpBlockReason } from '@/composables/useStepUp'
|
||||
import TotpStepUpDialog from '@/components/auth/TotpStepUpDialog.vue'
|
||||
|
||||
@@ -317,6 +397,26 @@ const s3SecretConfigured = ref(false)
|
||||
const savingS3 = ref(false)
|
||||
const testingS3 = ref(false)
|
||||
|
||||
// Async image object storage. Shares the S3 client with backups, so the default is
|
||||
// to reuse the credentials configured above and only differ by prefix.
|
||||
const imageStorageForm = ref<ImageStorageConfig>({
|
||||
enabled: false,
|
||||
reuse_backup_s3: true,
|
||||
bucket: '',
|
||||
prefix: 'images/',
|
||||
public_base_url: '',
|
||||
presign_expiry_hours: 24,
|
||||
max_download_bytes: 33554432,
|
||||
endpoint: '',
|
||||
region: 'auto',
|
||||
access_key_id: '',
|
||||
secret_access_key: '',
|
||||
force_path_style: false,
|
||||
})
|
||||
const imageStorageSecretConfigured = ref(false)
|
||||
const savingImageStorage = ref(false)
|
||||
const testingImageStorage = ref(false)
|
||||
|
||||
// Schedule config
|
||||
const scheduleForm = ref<BackupScheduleConfig>({
|
||||
enabled: false,
|
||||
@@ -485,6 +585,54 @@ async function saveS3Config() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadImageStorageConfig() {
|
||||
try {
|
||||
const { config, secret_configured } = await adminAPI.backup.getImageStorageConfig()
|
||||
imageStorageForm.value = {
|
||||
...config,
|
||||
prefix: config.prefix || 'images/',
|
||||
region: config.region || 'auto',
|
||||
secret_access_key: '',
|
||||
}
|
||||
imageStorageSecretConfigured.value = secret_configured
|
||||
} catch (error) {
|
||||
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
|
||||
}
|
||||
}
|
||||
|
||||
async function saveImageStorageConfig() {
|
||||
savingImageStorage.value = true
|
||||
try {
|
||||
await backupStepUp.run(() => adminAPI.backup.updateImageStorageConfig(imageStorageForm.value))
|
||||
appStore.showSuccess(t('admin.backup.imageStorage.saved'))
|
||||
await loadImageStorageConfig()
|
||||
} catch (error) {
|
||||
if (isStepUpCancelled(error)) {
|
||||
savingImageStorage.value = false
|
||||
return
|
||||
}
|
||||
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
|
||||
} finally {
|
||||
savingImageStorage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testImageStorage() {
|
||||
testingImageStorage.value = true
|
||||
try {
|
||||
const result = await adminAPI.backup.testImageStorageConnection(imageStorageForm.value)
|
||||
if (result.ok) {
|
||||
appStore.showSuccess(result.message || t('admin.backup.s3.testSuccess'))
|
||||
} else {
|
||||
appStore.showError(result.message || t('admin.backup.s3.testFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
|
||||
} finally {
|
||||
testingImageStorage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testS3() {
|
||||
testingS3.value = true
|
||||
try {
|
||||
@@ -642,7 +790,7 @@ function formatDate(value?: string): string {
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
await Promise.all([loadS3Config(), loadSchedule(), loadBackups()])
|
||||
await Promise.all([loadS3Config(), loadImageStorageConfig(), loadSchedule(), loadBackups()])
|
||||
|
||||
// 如果有正在 running 的备份,恢复轮询
|
||||
const runningBackup = backups.value.find(r => r.status === 'running')
|
||||
|
||||
Reference in New Issue
Block a user