mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 生成的媒体文件将自动上传到存储',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user