From 0343dba916e9d22451020f6f870982dd1f8b9be1 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Sun, 19 Jul 2026 00:56:33 -0700 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E5=A4=87=E4=BB=BD=E9=A1=B5?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=BC=82=E6=AD=A5=E7=94=9F=E5=9B=BE=E5=AF=B9?= =?UTF-8?q?=E8=B1=A1=E5=AD=98=E5=82=A8=E9=85=8D=E7=BD=AE=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对应后端 /admin/backups/image-storage。放在备份页而非系统设置页,是因为它与 数据库备份共用同一套 S3 客户端与凭证——用户的心智就是"在备份的 S3 上加个开关"。 - 默认勾选"复用备份 S3",此时隐藏端点/密钥字段,只留 bucket 与 prefix, bucket 留空则沿用备份桶。 - 保存走 useStepUp,与备份 S3 配置的保存路径一致。 - 补中英文案。 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VHreE5pzCkSYz7J45fmd2Y --- frontend/src/api/admin/backup.ts | 49 ++++++ .../src/i18n/locales/en/admin/overview.ts | 13 ++ .../src/i18n/locales/zh/admin/overview.ts | 13 ++ frontend/src/views/admin/BackupView.vue | 152 +++++++++++++++++- 4 files changed, 225 insertions(+), 2 deletions(-) diff --git a/frontend/src/api/admin/backup.ts b/frontend/src/api/admin/backup.ts index bccb1f8023..f8e978b007 100644 --- a/frontend/src/api/admin/backup.ts +++ b/frontend/src/api/admin/backup.ts @@ -60,6 +60,52 @@ export async function testS3Connection(config: BackupS3Config): Promise { + const { data } = await apiClient.get('/admin/backups/image-storage') + return data +} + +export async function updateImageStorageConfig( + config: ImageStorageConfig, +): Promise { + const { data } = await apiClient.put('/admin/backups/image-storage', config) + return data +} + +export async function testImageStorageConnection( + config: ImageStorageConfig, +): Promise { + const { data } = await apiClient.post( + '/admin/backups/image-storage/test', + config, + ) + return data +} + // Schedule export async function getSchedule(): Promise { const { data } = await apiClient.get('/admin/backups/schedule') @@ -106,6 +152,9 @@ export const backupAPI = { getS3Config, updateS3Config, testS3Connection, + getImageStorageConfig, + updateImageStorageConfig, + testImageStorageConnection, getSchedule, updateSchedule, createBackup, diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 9c56819fb4..e35bfe09ce 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 7e4834ec2f..2fde589193 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -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: '配置自动定时备份', diff --git a/frontend/src/views/admin/BackupView.vue b/frontend/src/views/admin/BackupView.vue index a2223f9412..f0c3e5b72f 100644 --- a/frontend/src/views/admin/BackupView.vue +++ b/frontend/src/views/admin/BackupView.vue @@ -54,6 +54,81 @@ + +
+
+
+

+ {{ t('admin.backup.imageStorage.title') }} +

+

+ {{ t('admin.backup.imageStorage.description') }} +

+
+ +
+ + + +
+
+ + +
+
+ + +
+ + + +
+ + +
+
+ + +
+
+ +
+ + +
+
+
@@ -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({ + 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({ 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')