Merge pull request #4593 from StarryKira/fix/image-storage-env-unreachable

fix: 异步生图开关配了却不生效(环境变量被静默丢弃 + 迁移到后台开关)
This commit is contained in:
Wesley Liddick
2026-07-20 09:20:15 +08:00
committed by GitHub
18 changed files with 1324 additions and 35 deletions
+150 -2
View File
@@ -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')