feat(sora): improve storage management page with split columns, test buttons, quota and video stats

- Split compound "Profile" column into separate Profile ID and Name columns
- Replace "Endpoint" column with "Storage Path" showing bucket/prefix or folder info
- Add GDrive quota display (capacity/used) via new backend API endpoint
- Add video count statistics per storage type via new backend API endpoint
- Add inline test button per profile with 15s timeout and per-profile loading state
- Backend: GetQuotaInfo, CountByStorageType, GetGDriveQuota, GetStorageVideoStats

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
erio
2026-03-06 23:58:27 +08:00
co-authored by Claude Opus 4.6
parent 6a7bcabb6f
commit 9e107fae73
13 changed files with 336 additions and 26 deletions
+1 -1
View File
@@ -175,7 +175,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
soraGenerationRepository := repository.NewSoraGenerationRepository(db)
soraQuotaService := service.NewSoraQuotaService(userRepository, groupRepository, settingService)
soraGenerationService := service.NewSoraGenerationService(soraGenerationRepository, soraStorageRouter, soraQuotaService)
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, soraS3Storage)
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, soraS3Storage, soraGDriveStorage, soraGenerationService)
soraGDriveOAuthService := service.NewSoraGDriveOAuthService(settingService)
gdriveOAuthHandler := admin.NewGDriveOAuthHandler(settingService, soraGDriveOAuthService, soraGDriveStorage)
opsHandler := admin.NewOpsHandler(opsService)
@@ -37,21 +37,25 @@ func generateMenuItemID() (string, error) {
// SettingHandler 系统设置处理器
type SettingHandler struct {
settingService *service.SettingService
emailService *service.EmailService
turnstileService *service.TurnstileService
opsService *service.OpsService
soraS3Storage *service.SoraS3Storage
settingService *service.SettingService
emailService *service.EmailService
turnstileService *service.TurnstileService
opsService *service.OpsService
soraS3Storage *service.SoraS3Storage
soraGDriveStorage *service.SoraGDriveStorage
soraGenerationService *service.SoraGenerationService
}
// NewSettingHandler 创建系统设置处理器
func NewSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService, soraS3Storage *service.SoraS3Storage) *SettingHandler {
func NewSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService, soraS3Storage *service.SoraS3Storage, soraGDriveStorage *service.SoraGDriveStorage, soraGenerationService *service.SoraGenerationService) *SettingHandler {
return &SettingHandler{
settingService: settingService,
emailService: emailService,
turnstileService: turnstileService,
opsService: opsService,
soraS3Storage: soraS3Storage,
settingService: settingService,
emailService: emailService,
turnstileService: turnstileService,
opsService: opsService,
soraS3Storage: soraS3Storage,
soraGDriveStorage: soraGDriveStorage,
soraGenerationService: soraGenerationService,
}
}
@@ -1456,3 +1460,44 @@ func (h *SettingHandler) UpdateStreamTimeoutSettings(c *gin.Context) {
ThresholdWindowMinutes: updatedSettings.ThresholdWindowMinutes,
})
}
// GetGDriveQuota 获取 Google Drive 配额信息。
// GET /api/v1/admin/settings/sora-storage/gdrive-quota
func (h *SettingHandler) GetGDriveQuota(c *gin.Context) {
if h.soraGDriveStorage == nil {
response.Error(c, http.StatusServiceUnavailable, "GDrive storage not configured")
return
}
quota, err := h.soraGDriveStorage.GetQuotaInfo(c.Request.Context())
if err != nil {
response.Error(c, http.StatusInternalServerError, fmt.Sprintf("failed to get GDrive quota: %v", err))
return
}
response.Success(c, quota)
}
// GetStorageVideoStats 获取各存储类型的视频统计信息。
// GET /api/v1/admin/settings/sora-storage/video-stats
func (h *SettingHandler) GetStorageVideoStats(c *gin.Context) {
if h.soraGenerationService == nil {
response.Error(c, http.StatusServiceUnavailable, "generation service not configured")
return
}
storageTypes := []string{service.SoraStorageTypeS3, service.SoraStorageTypeGDrive}
result := make(map[string]*service.StorageVideoStats, len(storageTypes))
for _, st := range storageTypes {
completed, inProgress, err := h.soraGenerationService.CountByStorageType(c.Request.Context(), st)
if err != nil {
log.Printf("[SettingHandler] CountByStorageType(%s) error: %v", st, err)
continue
}
result[st] = &service.StorageVideoStats{
Completed: completed,
InProgress: inProgress,
}
}
response.Success(c, result)
}
@@ -417,3 +417,22 @@ func (r *soraGenerationRepository) CountByUserAndStatus(ctx context.Context, use
err := r.sql.QueryRowContext(ctx, query, args...).Scan(&count)
return count, err
}
// CountByStorageType 按存储类型和状态统计生成记录数。
func (r *soraGenerationRepository) CountByStorageType(ctx context.Context, storageType string, statuses []string) (int64, error) {
if len(statuses) == 0 {
return 0, nil
}
placeholders := make([]string, len(statuses))
args := []any{storageType}
for i, s := range statuses {
placeholders[i] = fmt.Sprintf("$%d", i+2)
args = append(args, s)
}
var count int64
query := fmt.Sprintf("SELECT COUNT(*) FROM sora_generations WHERE storage_type = $1 AND status IN (%s)", strings.Join(placeholders, ","))
err := r.sql.QueryRowContext(ctx, query, args...).Scan(&count)
return count, err
}
+1 -1
View File
@@ -647,7 +647,7 @@ func newContractDeps(t *testing.T) *contractDeps {
authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil)
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil, nil)
adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil, nil, nil, nil)
adminAccountHandler := adminhandler.NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
jwtAuth := func(c *gin.Context) {
+3
View File
@@ -414,6 +414,9 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
adminSettings.POST("/sora-storage/gdrive-oauth/start", h.Admin.GDriveOAuth.StartOAuth)
adminSettings.POST("/sora-storage/gdrive-oauth/callback", h.Admin.GDriveOAuth.OAuthCallback)
adminSettings.POST("/sora-storage/gdrive-test", h.Admin.GDriveOAuth.TestGDriveStorage)
// Sora 存储统计
adminSettings.GET("/sora-storage/gdrive-quota", h.Admin.Setting.GetGDriveQuota)
adminSettings.GET("/sora-storage/video-stats", h.Admin.Setting.GetStorageVideoStats)
}
}
@@ -175,6 +175,12 @@ func (s *SoraGDriveStorage) RefreshClient() {
logger.LegacyPrintf("service.sora_gdrive", "[SoraGDrive] 客户端缓存已清除")
}
// GDriveQuotaInfo 包含 Google Drive 配额信息。
type GDriveQuotaInfo struct {
LimitBytes int64 `json:"limit_bytes"`
UsedBytes int64 `json:"used_bytes"`
}
// TestConnection 测试 Google Drive 连接。
func (s *SoraGDriveStorage) TestConnection(ctx context.Context) error {
srv, _, err := s.getService(ctx)
@@ -188,6 +194,25 @@ func (s *SoraGDriveStorage) TestConnection(ctx context.Context) error {
return nil
}
// GetQuotaInfo 获取 Google Drive 配额信息(总量和已用量)。
func (s *SoraGDriveStorage) GetQuotaInfo(ctx context.Context) (*GDriveQuotaInfo, error) {
srv, _, err := s.getService(ctx)
if err != nil {
return nil, err
}
about, err := srv.About.Get().Fields("storageQuota").Context(ctx).Do()
if err != nil {
return nil, fmt.Errorf("gdrive About.Get failed: %w", err)
}
if about.StorageQuota == nil {
return nil, fmt.Errorf("storageQuota not available")
}
return &GDriveQuotaInfo{
LimitBytes: about.StorageQuota.Limit,
UsedBytes: about.StorageQuota.Usage,
}, nil
}
// TestFullCycle 执行完整的上传→获取链接→删除测试。
func (s *SoraGDriveStorage) TestFullCycle(ctx context.Context) (map[string]any, error) {
srv, cfg, err := s.getService(ctx)
@@ -61,4 +61,5 @@ type SoraGenerationRepository interface {
Delete(ctx context.Context, id int64) error
List(ctx context.Context, params SoraGenerationListParams) ([]*SoraGeneration, int64, error)
CountByUserAndStatus(ctx context.Context, userID int64, statuses []string) (int64, error)
CountByStorageType(ctx context.Context, storageType string, statuses []string) (int64, error)
}
@@ -330,3 +330,22 @@ func (s *SoraGenerationService) ResolveMediaURLs(ctx context.Context, gen *SoraG
return nil
}
// StorageVideoStats 各存储类型的视频统计。
type StorageVideoStats struct {
Completed int64 `json:"completed"`
InProgress int64 `json:"in_progress"`
}
// CountByStorageType 按存储类型统计视频数量(completed 和 in_progress)。
func (s *SoraGenerationService) CountByStorageType(ctx context.Context, storageType string) (completed, inProgress int64, err error) {
completed, err = s.genRepo.CountByStorageType(ctx, storageType, []string{SoraGenStatusCompleted})
if err != nil {
return 0, 0, fmt.Errorf("count completed: %w", err)
}
inProgress, err = s.genRepo.CountByStorageType(ctx, storageType, []string{SoraGenStatusPending, SoraGenStatusGenerating})
if err != nil {
return 0, 0, fmt.Errorf("count in_progress: %w", err)
}
return completed, inProgress, nil
}
@@ -115,6 +115,25 @@ func (r *stubGenRepo) CountByUserAndStatus(_ context.Context, userID int64, stat
return count, nil
}
func (r *stubGenRepo) CountByStorageType(_ context.Context, storageType string, statuses []string) (int64, error) {
if r.countErr != nil {
return 0, r.countErr
}
var count int64
statusSet := make(map[string]struct{})
for _, s := range statuses {
statusSet[s] = struct{}{}
}
for _, gen := range r.gens {
if gen.StorageType == storageType {
if _, ok := statusSet[gen.Status]; ok {
count++
}
}
}
return count, nil
}
// ==================== Stub: UserRepository (用于 SoraQuotaService) ====================
var _ UserRepository = (*stubUserRepoForQuota)(nil)
+22 -1
View File
@@ -487,6 +487,25 @@ export async function testGDriveStorage(): Promise<TestGDriveStorageResponse> {
return data
}
export interface GDriveQuotaInfo {
limit_bytes: number
used_bytes: number
}
export interface StorageVideoStats {
[type: string]: { completed: number; in_progress: number }
}
export async function getGDriveQuota(): Promise<GDriveQuotaInfo> {
const { data } = await apiClient.get<GDriveQuotaInfo>('/admin/settings/sora-storage/gdrive-quota')
return data
}
export async function getStorageVideoStats(): Promise<StorageVideoStats> {
const { data } = await apiClient.get<StorageVideoStats>('/admin/settings/sora-storage/video-stats')
return data
}
export const settingsAPI = {
getSettings,
updateSettings,
@@ -507,7 +526,9 @@ export const settingsAPI = {
setActiveSoraS3Profile,
startGDriveOAuth,
exchangeGDriveOAuthCode,
testGDriveStorage
testGDriveStorage,
getGDriveQuota,
getStorageVideoStats
}
export default settingsAPI
+13 -1
View File
@@ -3932,12 +3932,24 @@ export default {
deleteConfirm: 'Delete storage profile {profileID}?',
columns: {
profile: 'Profile',
profileId: 'Profile ID',
name: 'Name',
provider: 'Type',
active: 'Active',
endpoint: 'Endpoint',
storagePath: 'Storage Path',
capacityUsage: 'Capacity / Used',
capacityUnlimited: 'Unlimited',
videoCount: 'Videos',
videoCompleted: 'completed',
videoInProgress: 'in progress',
quota: 'Default Quota',
updatedAt: 'Updated At',
actions: 'Actions'
actions: 'Actions',
rootFolder: 'Root folder',
testInTable: 'Test',
testingInTable: 'Testing...',
testTimeout: 'Test timed out (15s)'
},
enabled: 'Enable Storage',
enabledHint: 'When enabled, Sora generated media files will be automatically uploaded',
+13 -1
View File
@@ -4092,12 +4092,24 @@ export default {
deleteConfirm: '确定删除存储配置 {profileID} 吗?',
columns: {
profile: '配置',
profileId: 'Profile ID',
name: '名称',
provider: '存储类型',
active: '生效状态',
endpoint: '端点',
storagePath: '存储路径',
capacityUsage: '容量 / 已用',
capacityUnlimited: '无限制',
videoCount: '视频数',
videoCompleted: '完成',
videoInProgress: '进行中',
quota: '默认配额',
updatedAt: '更新时间',
actions: '操作'
actions: '操作',
rootFolder: '根目录',
testInTable: '测试',
testingInTable: '测试中...',
testTimeout: '测试超时(15秒)'
},
enabled: '启用存储',
enabledHint: '启用后,Sora 生成的媒体文件将自动上传到存储',
+144 -10
View File
@@ -22,13 +22,16 @@
</div>
<div class="overflow-x-auto">
<table class="w-full min-w-[1000px] text-sm">
<table class="w-full min-w-[1400px] text-sm">
<thead>
<tr class="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500 dark:border-dark-700 dark:text-gray-400">
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.profile') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.profileId') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.name') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.provider') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.active') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.endpoint') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.storagePath') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.capacityUsage') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.videoCount') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.quota') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.updatedAt') }}</th>
<th class="py-2">{{ t('admin.settings.soraS3.columns.actions') }}</th>
@@ -38,8 +41,8 @@
<tr v-for="profile in soraS3Profiles" :key="profile.profile_id" class="border-b border-gray-100 align-top dark:border-dark-800">
<td class="py-3 pr-4">
<div class="font-mono text-xs">{{ profile.profile_id }}</div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">{{ profile.name }}</div>
</td>
<td class="py-3 pr-4 text-xs">{{ profile.name }}</td>
<td class="py-3 pr-4">
<span
class="rounded px-2 py-0.5 text-xs"
@@ -59,17 +62,55 @@
<td class="py-3 pr-4 text-xs">
<template v-if="(profile.provider || 's3') === 's3'">
<div>{{ profile.endpoint || '-' }}</div>
<div class="mt-1 text-gray-500 dark:text-gray-400">{{ profile.region || '-' }}</div>
<div class="mt-1 text-gray-500 dark:text-gray-400">{{ [profile.bucket, profile.prefix].filter(Boolean).join('/') || '-' }}</div>
</template>
<template v-else>
<div>Google Drive</div>
<div class="mt-1 text-gray-500 dark:text-gray-400">{{ profile.auth_type === 'service_account' ? t('admin.settings.soraS3.gdrive.serviceAccount') : 'OAuth2' }}</div>
<div>{{ profile.folder_id ? `folder: ${profile.folder_id}` : t('admin.settings.soraS3.columns.rootFolder') }}</div>
</template>
</td>
<td class="py-3 pr-4 text-xs">
<template v-if="(profile.provider || 's3') === 'gdrive'">
<template v-if="gdriveQuota">
<div>{{ formatBytes(gdriveQuota.used_bytes) }} / {{ formatBytes(gdriveQuota.limit_bytes) }}</div>
</template>
<template v-else-if="loadingGDriveQuota">
<div class="text-gray-400">{{ t('common.loading') }}</div>
</template>
<template v-else>
<div class="text-gray-400">-</div>
</template>
</template>
<template v-else>
<div class="text-gray-400">{{ t('admin.settings.soraS3.columns.capacityUnlimited') }}</div>
</template>
</td>
<td class="py-3 pr-4 text-xs">
<template v-if="videoStats && videoStats[profile.provider || 's3']">
<div>{{ videoStats[profile.provider || 's3'].completed }} {{ t('admin.settings.soraS3.columns.videoCompleted') }}</div>
<div v-if="videoStats[profile.provider || 's3'].in_progress > 0" class="mt-0.5 text-gray-500 dark:text-gray-400">
{{ videoStats[profile.provider || 's3'].in_progress }} {{ t('admin.settings.soraS3.columns.videoInProgress') }}
</div>
</template>
<template v-else-if="loadingVideoStats">
<div class="text-gray-400">{{ t('common.loading') }}</div>
</template>
<template v-else>
<div class="text-gray-400">-</div>
</template>
</td>
<td class="py-3 pr-4 text-xs">{{ formatStorageQuotaGB(profile.default_storage_quota_bytes) }}</td>
<td class="py-3 pr-4 text-xs">{{ formatDate(profile.updated_at) }}</td>
<td class="py-3 text-xs">
<div class="flex flex-wrap gap-2">
<button
type="button"
class="btn btn-secondary btn-xs"
:disabled="testingProfiles[profile.profile_id]
|| ((profile.provider || 's3') === 'gdrive' && !profile.is_active)"
@click="testProfileInTable(profile)"
>
{{ testingProfiles[profile.profile_id] ? t('admin.settings.soraS3.columns.testingInTable') : t('admin.settings.soraS3.columns.testInTable') }}
</button>
<button type="button" class="btn btn-secondary btn-xs" @click="editSoraProfile(profile.profile_id)">
{{ t('common.edit') }}
</button>
@@ -94,7 +135,7 @@
</td>
</tr>
<tr v-if="soraS3Profiles.length === 0">
<td colspan="7" class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">
<td colspan="10" class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.soraS3.empty') }}
</td>
</tr>
@@ -327,10 +368,10 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import AppLayout from '@/components/layout/AppLayout.vue'
import type { SoraS3Profile } from '@/api/admin/settings'
import type { SoraS3Profile, GDriveQuotaInfo, StorageVideoStats } from '@/api/admin/settings'
import { adminAPI } from '@/api'
import { useAppStore } from '@/stores'
@@ -346,6 +387,11 @@ const creatingSoraProfile = ref(false)
const soraProfileDrawerOpen = ref(false)
const providerSelectOpen = ref(false)
const startingOAuth = ref(false)
const loadingGDriveQuota = ref(false)
const loadingVideoStats = ref(false)
const testingProfiles: Record<string, boolean> = reactive({})
const gdriveQuota = ref<GDriveQuotaInfo | null>(null)
const videoStats = ref<StorageVideoStats | null>(null)
const soraS3Profiles = ref<SoraS3Profile[]>([])
const selectedSoraProfileID = ref('')
@@ -406,6 +452,9 @@ async function loadSoraS3Profiles() {
}
syncSoraProfileFormWithSelection()
}
// Async load quota and video stats (non-blocking)
loadGDriveQuotaIfNeeded()
loadVideoStats()
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
@@ -413,6 +462,79 @@ async function loadSoraS3Profiles() {
}
}
const testProfileTimeoutMs = 15000
async function testProfileInTable(profile: SoraS3Profile) {
const pid = profile.profile_id
if (testingProfiles[pid]) return
testingProfiles[pid] = true
const provider = profile.provider || 's3'
try {
if (provider === 'gdrive') {
const result = await Promise.race([
adminAPI.settings.testGDriveStorage(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(t('admin.settings.soraS3.columns.testTimeout'))), testProfileTimeoutMs))
])
const msg = result.status === 'ok'
? t('admin.settings.soraS3.gdrive.testSuccess')
: t('admin.settings.soraS3.gdrive.testFailed')
appStore.showSuccess(msg)
} else {
await Promise.race([
adminAPI.settings.testSoraS3Connection({
profile_id: pid,
enabled: profile.enabled,
endpoint: profile.endpoint || '',
region: profile.region || '',
bucket: profile.bucket || '',
access_key_id: profile.access_key_id || '',
prefix: profile.prefix || '',
force_path_style: Boolean(profile.force_path_style),
cdn_url: profile.cdn_url || '',
default_storage_quota_bytes: profile.default_storage_quota_bytes || 0
}),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(t('admin.settings.soraS3.columns.testTimeout'))), testProfileTimeoutMs))
])
appStore.showSuccess(t('admin.settings.soraS3.testSuccess'))
}
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
testingProfiles[pid] = false
}
}
async function loadGDriveQuotaIfNeeded() {
const hasGDrive = soraS3Profiles.value.some(
(p) => (p.provider || 's3') === 'gdrive' && p.is_active
)
if (!hasGDrive) {
gdriveQuota.value = null
return
}
loadingGDriveQuota.value = true
try {
gdriveQuota.value = await adminAPI.settings.getGDriveQuota()
} catch {
gdriveQuota.value = null
} finally {
loadingGDriveQuota.value = false
}
}
async function loadVideoStats() {
loadingVideoStats.value = true
try {
videoStats.value = await adminAPI.settings.getStorageVideoStats()
} catch {
videoStats.value = null
} finally {
loadingVideoStats.value = false
}
}
function startCreateSoraProfile() {
providerSelectOpen.value = true
}
@@ -693,6 +815,18 @@ function formatStorageQuotaGB(bytes: number): string {
return `${gb.toFixed(gb >= 10 ? 0 : 1)} GB`
}
function formatBytes(bytes: number): string {
if (!bytes || bytes <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
let idx = 0
let val = bytes
while (val >= 1024 && idx < units.length - 1) {
val /= 1024
idx++
}
return `${val.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`
}
function pickPreferredSoraProfileID(): string {
const active = soraS3Profiles.value.find((item) => item.is_active)
if (active) return active.profile_id