From b0d77dba09364013e87dc7918f7dc2ed9946d73c Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sat, 29 Aug 2026 15:29:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E5=AD=98=E5=82=A8=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20-=20=E6=96=B0=E5=A2=9E=E8=B5=84=E6=BA=90=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E6=9F=A5=E8=AF=A2=E3=80=81=E7=BB=9F=E8=AE=A1=E4=B8=8E?= =?UTF-8?q?=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapted from ddcat-ai/open-ai-canvas#339 Original commit: fcdbe2aa833f31f683f709b7f409a0534ed78e20 Refs #339 Co-authored-by: 宇熙 <101980758+yuxiwl@users.noreply.github.com> --- backend/cmd/server/main.go | 1 + backend/internal/handler/admin_storage.go | 72 +++++++ backend/internal/repository/admin_storage.go | 190 ++++++++++++++++++ backend/internal/service/admin_storage.go | 188 +++++++++++++++++ .../internal/service/admin_storage_test.go | 109 ++++++++++ web/src/pages/admin/admin-route-pages.tsx | 5 + .../pages/admin/components/admin-shell.tsx | 2 + .../components/storage-resources-panel.tsx | 167 +++++++++++++++ web/src/router.tsx | 2 + web/src/services/api/admin-storage.ts | 84 ++++++++ 10 files changed, 820 insertions(+) create mode 100644 backend/internal/handler/admin_storage.go create mode 100644 backend/internal/repository/admin_storage.go create mode 100644 backend/internal/service/admin_storage.go create mode 100644 backend/internal/service/admin_storage_test.go create mode 100644 web/src/pages/admin/components/storage-resources-panel.tsx create mode 100644 web/src/services/api/admin-storage.ts diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index ed5469ca3..278c96f2d 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -84,6 +84,7 @@ func main() { handler.RegisterResponseInterceptionRoutes(api, svc) handler.RegisterAdminRoutes(api, svc) handler.RegisterAdminAnalyticsRoutes(api, svc) + handler.RegisterAdminStorageRoutes(api, svc) handler.RegisterAnnouncementRoutes(api, svc) handler.RegisterFinanceRoutes(api, svc) handler.RegisterLibTVRoutes(api, svc) diff --git a/backend/internal/handler/admin_storage.go b/backend/internal/handler/admin_storage.go new file mode 100644 index 000000000..79f3cfdf3 --- /dev/null +++ b/backend/internal/handler/admin_storage.go @@ -0,0 +1,72 @@ +package handler + +import ( + "strconv" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func RegisterAdminStorageRoutes(r *gin.RouterGroup, svc *service.Service) { + r.GET("/admin/storage/stats", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + stats, err := svc.AdminStorageStats(user) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"stats": stats}) + }) + + r.GET("/admin/resources", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) + result, err := svc.AdminResourcePage(user, service.AdminResourceQuery{ + Keyword: c.Query("keyword"), Kind: c.Query("kind"), Status: c.Query("status"), + Provider: c.Query("provider"), UserID: c.Query("userId"), Page: page, Limit: limit, + }) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) + + r.GET("/admin/resources/:id/file", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + stream, err := svc.OpenResourceRangeAsAdmin(user, c.Param("id"), c.GetHeader("Range")) + if err != nil { + failService(c, err) + return + } + defer stream.Body.Close() + mimeType := stream.Resource.MimeType + if mimeType == "" { + mimeType = "application/octet-stream" + } + c.Header("Cache-Control", "private, no-cache") + c.Header("Accept-Ranges", stream.AcceptRanges) + c.Header("X-Content-Type-Options", "nosniff") + if stream.ContentRange != "" { + c.Header("Content-Range", stream.ContentRange) + } + if c.Query("download") == "1" { + c.Header("Content-Disposition", "attachment") + } + c.DataFromReader(stream.StatusCode, stream.ContentLength, mimeType, stream.Body, nil) + }) +} diff --git a/backend/internal/repository/admin_storage.go b/backend/internal/repository/admin_storage.go new file mode 100644 index 000000000..d2f9d094d --- /dev/null +++ b/backend/internal/repository/admin_storage.go @@ -0,0 +1,190 @@ +package repository + +import ( + "strings" + + "infinite-canvas/backend/internal/model" +) + +type AdminResourceFilter struct { + Kind string + Status string + Provider string + UserID string + Keyword string + Limit int + Offset int +} + +type ResourceKindStat struct { + Kind string `json:"kind" gorm:"column:kind"` + Count int64 `json:"count" gorm:"column:count"` + LogicalBytes int64 `json:"logicalBytes" gorm:"column:logical_bytes"` + PhysicalBytes int64 `json:"physicalBytes" gorm:"column:physical_bytes"` +} + +type ResourceProviderStat struct { + Provider string `json:"provider" gorm:"column:provider"` + Count int64 `json:"count" gorm:"column:count"` + LogicalBytes int64 `json:"logicalBytes" gorm:"column:logical_bytes"` + PhysicalBytes int64 `json:"physicalBytes" gorm:"column:physical_bytes"` +} + +type ResourceStorageSummary struct { + ResourceCount int64 `json:"resourceCount" gorm:"column:resource_count"` + ReadyCount int64 `json:"readyCount" gorm:"column:ready_count"` + LogicalBytes int64 `json:"logicalBytes" gorm:"column:logical_bytes"` + PhysicalBytes int64 `json:"physicalBytes" gorm:"column:physical_bytes"` + LocalBytes int64 `json:"localBytes" gorm:"column:local_bytes"` + RemoteBytes int64 `json:"remoteBytes" gorm:"column:remote_bytes"` +} + +func (r *Repository) AdminResources(filter AdminResourceFilter) ([]model.Resource, int64, error) { + var resources []model.Resource + var total int64 + query := r.db.Model(&model.Resource{}) + if filter.Kind != "" { + query = query.Where("kind = ?", filter.Kind) + } + if filter.Status != "" { + query = query.Where("status = ?", filter.Status) + } + if filter.Provider != "" { + query = query.Where(resourceProviderExpression()+" = ?", filter.Provider) + } + if filter.UserID != "" { + query = query.Where("user_id = ?", filter.UserID) + } + if filter.Keyword != "" { + pattern := "%" + strings.ToLower(filter.Keyword) + "%" + query = query.Where("lower(id) LIKE ? OR lower(object_key) LIKE ?", pattern, pattern) + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := query.Order("created_at desc, id desc").Limit(filter.Limit).Offset(filter.Offset).Find(&resources).Error; err != nil { + return nil, 0, err + } + return resources, total, nil +} + +func (r *Repository) UsersByIDs(ids []string) (map[string]model.User, error) { + if len(ids) == 0 { + return map[string]model.User{}, nil + } + var users []model.User + if err := r.db.Select("id", "username", "display_name").Where("id IN ?", ids).Find(&users).Error; err != nil { + return nil, err + } + result := make(map[string]model.User, len(users)) + for _, user := range users { + result[user.ID] = user + } + return result, nil +} + +func (r *Repository) ResourceStorageSummary() (ResourceStorageSummary, error) { + var summary ResourceStorageSummary + err := r.db.Model(&model.Resource{}).Select(` + COUNT(*) AS resource_count, + COALESCE(SUM(CASE WHEN status = 'ready' THEN 1 ELSE 0 END), 0) AS ready_count, + COALESCE(SUM(size), 0) AS logical_bytes + `).Scan(&summary).Error + if err != nil { + return summary, err + } + physicalBytes, err := r.resourcePhysicalBytes("") + if err != nil { + return summary, err + } + localBytes, err := r.resourcePhysicalBytes(resourceProviderExpression()+" = ?", "local") + if err != nil { + return summary, err + } + summary.PhysicalBytes = physicalBytes + summary.LocalBytes = localBytes + summary.RemoteBytes = physicalBytes - localBytes + return summary, nil +} + +func (r *Repository) resourcePhysicalBytes(where string, args ...any) (int64, error) { + query := r.db.Model(&model.Resource{}). + Select("MAX(size) AS size"). + Where("status = ?", model.ResourceStatusReady) + if where != "" { + query = query.Where(where, args...) + } + query = query.Group(resourceProviderExpression() + ", endpoint, bucket, object_key") + var total struct { + Bytes int64 `gorm:"column:bytes"` + } + err := r.db.Table("(?) AS physical_resources", query). + Select("COALESCE(SUM(size), 0) AS bytes"). + Scan(&total).Error + return total.Bytes, err +} + +func (r *Repository) ResourceKindStats() ([]ResourceKindStat, error) { + var stats []ResourceKindStat + err := r.db.Model(&model.Resource{}). + Select("kind, COUNT(*) AS count, COALESCE(SUM(size), 0) AS logical_bytes"). + Group("kind"). + Order("logical_bytes desc, kind asc"). + Scan(&stats).Error + if err != nil { + return nil, err + } + physical, err := r.resourcePhysicalStats("kind") + if err != nil { + return nil, err + } + for index := range stats { + stats[index].PhysicalBytes = physical[stats[index].Kind] + } + return stats, err +} + +func (r *Repository) ResourceProviderStats() ([]ResourceProviderStat, error) { + var stats []ResourceProviderStat + provider := resourceProviderExpression() + err := r.db.Model(&model.Resource{}). + Select(provider + " AS provider, COUNT(*) AS count, COALESCE(SUM(size), 0) AS logical_bytes"). + Group(provider). + Order("logical_bytes desc, provider asc"). + Scan(&stats).Error + if err != nil { + return nil, err + } + physical, err := r.resourcePhysicalStats(provider) + if err != nil { + return nil, err + } + for index := range stats { + stats[index].PhysicalBytes = physical[stats[index].Provider] + } + return stats, err +} + +func (r *Repository) resourcePhysicalStats(dimensionExpression string) (map[string]int64, error) { + query := r.db.Model(&model.Resource{}). + Select(dimensionExpression+" AS dimension, MAX(size) AS size"). + Where("status = ?", model.ResourceStatusReady). + Group(dimensionExpression + ", " + resourceProviderExpression() + ", endpoint, bucket, object_key") + var rows []struct { + Dimension string `gorm:"column:dimension"` + Bytes int64 `gorm:"column:bytes"` + } + err := r.db.Table("(?) AS physical_resources", query). + Select("dimension, COALESCE(SUM(size), 0) AS bytes"). + Group("dimension"). + Scan(&rows).Error + result := make(map[string]int64, len(rows)) + for _, row := range rows { + result[row.Dimension] = row.Bytes + } + return result, err +} + +func resourceProviderExpression() string { + return "COALESCE(NULLIF(provider, ''), 'local')" +} diff --git a/backend/internal/service/admin_storage.go b/backend/internal/service/admin_storage.go new file mode 100644 index 000000000..49a743ed2 --- /dev/null +++ b/backend/internal/service/admin_storage.go @@ -0,0 +1,188 @@ +package service + +import ( + "errors" + "strings" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/gorm" +) + +type AdminResourceQuery struct { + Keyword string + Kind string + Status string + Provider string + UserID string + Page int + Limit int +} + +type AdminStorageResourceView struct { + ID string `json:"id"` + UserID string `json:"userId"` + UserName string `json:"userName"` + Kind string `json:"kind"` + Status model.ResourceStatus `json:"status"` + Provider string `json:"provider"` + Bucket string `json:"bucket,omitempty"` + ObjectKey string `json:"objectKey"` + MimeType string `json:"mimeType"` + Size int64 `json:"size"` + PhysicalBytes int64 `json:"physicalBytes"` + Width int `json:"width"` + Height int `json:"height"` + DurationMs int64 `json:"durationMs"` + FileURL string `json:"fileUrl"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type AdminResourcePage struct { + Items []AdminStorageResourceView `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +type AdminStorageStats struct { + repository.ResourceStorageSummary + ByKind []repository.ResourceKindStat `json:"byKind"` + ByProvider []repository.ResourceProviderStat `json:"byProvider"` +} + +func (s *Service) AdminResourcePage(actor *model.User, query AdminResourceQuery) (*AdminResourcePage, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + filter, page, limit, err := normalizeAdminResourceQuery(query) + if err != nil { + return nil, err + } + resources, total, err := s.repo.AdminResources(filter) + if err != nil { + return nil, err + } + userIDs := make([]string, 0, len(resources)) + seen := make(map[string]struct{}, len(resources)) + for _, resource := range resources { + if _, exists := seen[resource.UserID]; exists { + continue + } + seen[resource.UserID] = struct{}{} + userIDs = append(userIDs, resource.UserID) + } + users, err := s.repo.UsersByIDs(userIDs) + if err != nil { + return nil, err + } + items := make([]AdminStorageResourceView, 0, len(resources)) + for _, resource := range resources { + provider := normalizedResourceProvider(resource.Provider) + items = append(items, AdminStorageResourceView{ + ID: resource.ID, UserID: resource.UserID, UserName: adminResourceUserName(users[resource.UserID]), + Kind: resource.Kind, Status: resource.Status, Provider: provider, Bucket: resource.Bucket, + ObjectKey: resource.ObjectKey, MimeType: resource.MimeType, Size: resource.Size, + PhysicalBytes: readyResourceBytes(resource), Width: resource.Width, Height: resource.Height, DurationMs: resource.DurationMs, + FileURL: "/api/admin/resources/" + resource.ID + "/file", CreatedAt: resource.CreatedAt, UpdatedAt: resource.UpdatedAt, + }) + } + return &AdminResourcePage{Items: items, Total: total, Page: page, Limit: limit}, nil +} + +func (s *Service) AdminStorageStats(actor *model.User) (*AdminStorageStats, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + summary, err := s.repo.ResourceStorageSummary() + if err != nil { + return nil, err + } + byKind, err := s.repo.ResourceKindStats() + if err != nil { + return nil, err + } + byProvider, err := s.repo.ResourceProviderStats() + if err != nil { + return nil, err + } + return &AdminStorageStats{ResourceStorageSummary: summary, ByKind: byKind, ByProvider: byProvider}, nil +} + +func (s *Service) OpenResourceRangeAsAdmin(actor *model.User, id string, rangeHeader string) (*ResourceStream, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + resource, err := s.repo.Resource(strings.TrimSpace(id)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, NotFound("资源不存在") + } + return nil, err + } + resource.Provider = normalizedResourceProvider(resource.Provider) + return s.openResourceRange(resource.UserID, resource, rangeHeader) +} + +func normalizeAdminResourceQuery(query AdminResourceQuery) (repository.AdminResourceFilter, int, int, error) { + page, limit := normalizeAdminPage(query.Page, query.Limit) + filter := repository.AdminResourceFilter{ + Keyword: strings.TrimSpace(query.Keyword), + Kind: strings.ToLower(strings.TrimSpace(query.Kind)), + Status: strings.ToLower(strings.TrimSpace(query.Status)), + Provider: strings.ToLower(strings.TrimSpace(query.Provider)), + UserID: strings.TrimSpace(query.UserID), + Limit: limit, + Offset: (page - 1) * limit, + } + if filter.Kind != "" && !oneOf(filter.Kind, "image", "video", "audio", "file") { + return repository.AdminResourceFilter{}, 0, 0, BadAuthRequest("资源类型筛选无效") + } + if filter.Status != "" && !oneOf(filter.Status, string(model.ResourceStatusPending), string(model.ResourceStatusReady), string(model.ResourceStatusFailed), string(model.ResourceStatusDeleted)) { + return repository.AdminResourceFilter{}, 0, 0, BadAuthRequest("资源状态筛选无效") + } + if filter.Provider != "" && !oneOf(filter.Provider, "local", aliyunOSSProvider, tencentCOSProvider, qiniuKodoProvider, s3Provider) { + return repository.AdminResourceFilter{}, 0, 0, BadAuthRequest("资源存储位置筛选无效") + } + if len(filter.Keyword) > 200 || len(filter.UserID) > 64 { + return repository.AdminResourceFilter{}, 0, 0, BadAuthRequest("资源筛选条件过长") + } + return filter, page, limit, nil +} + +func normalizedResourceProvider(provider string) string { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" { + return "local" + } + return provider +} + +func readyResourceBytes(resource model.Resource) int64 { + if resource.Status != model.ResourceStatusReady { + return 0 + } + return resource.Size +} + +func adminResourceUserName(user model.User) string { + if value := strings.TrimSpace(user.DisplayName); value != "" { + return value + } + if value := strings.TrimSpace(user.Username); value != "" { + return value + } + return user.ID +} + +func oneOf(value string, allowed ...string) bool { + for _, item := range allowed { + if value == item { + return true + } + } + return false +} diff --git a/backend/internal/service/admin_storage_test.go b/backend/internal/service/admin_storage_test.go new file mode 100644 index 000000000..6495e2630 --- /dev/null +++ b/backend/internal/service/admin_storage_test.go @@ -0,0 +1,109 @@ +package service + +import ( + "io" + "os" + "path/filepath" + "testing" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestAdminStorageListStatsAndPreview(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + sqlDB.SetMaxOpenConns(1) + if err := db.AutoMigrate(&model.User{}, &model.Resource{}, &model.UserOSSSetting{}, &model.StorageLocation{}, &model.SystemSetting{}); err != nil { + t.Fatal(err) + } + users := []model.User{ + {ID: "admin", Username: "admin", DisplayName: "管理员", Role: model.UserRoleAdmin, Status: model.UserStatusActive}, + {ID: "user-1", Username: "creator", DisplayName: "创作者", Role: model.UserRoleUser, Status: model.UserStatusActive}, + } + if err := db.Create(&users).Error; err != nil { + t.Fatal(err) + } + now := time.Now() + resources := []model.Resource{ + {ID: "image-ready", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady, Provider: "", ObjectKey: "users/user-1/image/ready.png", MimeType: "image/png", Size: 128, CreatedAt: now.Add(-time.Minute), UpdatedAt: now}, + {ID: "image-failed", UserID: "user-1", Kind: "image", Status: model.ResourceStatusFailed, Provider: "local", ObjectKey: "users/user-1/image/failed.png", MimeType: "image/png", Size: 64, CreatedAt: now.Add(-2 * time.Minute), UpdatedAt: now}, + {ID: "video-ready", UserID: "admin", Kind: "video", Status: model.ResourceStatusReady, Provider: "s3", Bucket: "media", ObjectKey: "video/ready.mp4", MimeType: "video/mp4", Size: 256, CreatedAt: now.Add(-3 * time.Minute), UpdatedAt: now}, + {ID: "image-shared", UserID: "admin", Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "users/user-1/image/ready.png", MimeType: "image/png", Size: 128, CreatedAt: now.Add(-4 * time.Minute), UpdatedAt: now}, + } + if err := db.Create(&resources).Error; err != nil { + t.Fatal(err) + } + dataDir := t.TempDir() + filePath := filepath.Join(dataDir, "resources", filepath.FromSlash(resources[0].ObjectKey)) + if err := os.MkdirAll(filepath.Dir(filePath), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filePath, []byte("image-data"), 0o640); err != nil { + t.Fatal(err) + } + svc := New(repository.New(db), dataDir) + admin := &users[0] + + page, err := svc.AdminResourcePage(admin, AdminResourceQuery{Kind: "image", Provider: "local", Page: 1, Limit: 20}) + if err != nil { + t.Fatal(err) + } + if page.Total != 3 || len(page.Items) != 3 || page.Items[0].UserName != "创作者" { + t.Fatalf("page = %+v", page) + } + if page.Items[0].PhysicalBytes != 128 || page.Items[1].PhysicalBytes != 0 { + t.Fatalf("physical bytes = %+v", page.Items) + } + + stats, err := svc.AdminStorageStats(admin) + if err != nil { + t.Fatal(err) + } + if stats.ResourceCount != 4 || stats.ReadyCount != 3 || stats.LogicalBytes != 576 || stats.PhysicalBytes != 384 || stats.LocalBytes != 128 || stats.RemoteBytes != 256 { + t.Fatalf("stats = %+v", stats) + } + if len(stats.ByKind) != 2 || stats.ByKind[0].Kind != "image" || stats.ByKind[0].PhysicalBytes != 128 { + t.Fatalf("kind stats = %+v", stats.ByKind) + } + + stream, err := svc.OpenResourceRangeAsAdmin(admin, resources[0].ID, "") + if err != nil { + t.Fatal(err) + } + defer stream.Body.Close() + data, err := io.ReadAll(stream.Body) + if err != nil { + t.Fatal(err) + } + if string(data) != "image-data" { + t.Fatalf("preview data = %q", data) + } +} + +func TestAdminStorageRejectsNonAdminAndInvalidFilters(t *testing.T) { + svc := &Service{} + user := &model.User{ID: "user", Role: model.UserRoleUser, Status: model.UserStatusActive} + if _, err := svc.AdminResourcePage(user, AdminResourceQuery{}); err == nil { + t.Fatal("expected non-admin resource list to be rejected") + } + if _, _, _, err := normalizeAdminResourceQuery(AdminResourceQuery{Provider: "unknown"}); err == nil { + t.Fatal("expected invalid provider filter to be rejected") + } + if _, _, _, err := normalizeAdminResourceQuery(AdminResourceQuery{Status: "unknown"}); err == nil { + t.Fatal("expected invalid status filter to be rejected") + } + if _, err := svc.AdminStorageStats(user); err == nil { + t.Fatal("expected non-admin stats to be rejected") + } +} diff --git a/web/src/pages/admin/admin-route-pages.tsx b/web/src/pages/admin/admin-route-pages.tsx index b787c3c9c..9b8c6cfa1 100644 --- a/web/src/pages/admin/admin-route-pages.tsx +++ b/web/src/pages/admin/admin-route-pages.tsx @@ -9,6 +9,7 @@ const CreditOperationsPanel = lazy(() => import("./components/credit-operations- const AccessSettingsPanel = lazy(() => import("./components/access-settings-panel")); const EmailSettingsPanel = lazy(() => import("./components/email-settings-panel")); const FeatureAvailabilityPanel = lazy(() => import("./components/feature-availability-panel")); +const StorageResourcesPanel = lazy(() => import("./components/storage-resources-panel")); function PageFallback({ label }: { label: string }) { return
正在读取{label}...
; @@ -39,3 +40,7 @@ export function EmailSettingsPage() { export function FeatureAvailabilityPage() { return }>; } + +export function StorageResourcesPage() { + return }>; +} diff --git a/web/src/pages/admin/components/admin-shell.tsx b/web/src/pages/admin/components/admin-shell.tsx index 27ad1b3e0..3bca8d40d 100644 --- a/web/src/pages/admin/components/admin-shell.tsx +++ b/web/src/pages/admin/components/admin-shell.tsx @@ -5,6 +5,7 @@ import { BellRing, CloudUpload, Coins, + Database, FileClock, HardDrive, Home, @@ -57,6 +58,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> = { path: "/admin/models", label: "前台模型", description: "展示、线路与用户价格", icon: , requireFeature: "frontendModelsEnabled" }, { path: "/admin/plugins", label: "插件管理", description: "平台可用性、上传与卸载", icon: }, { path: "/admin/prompt-templates", label: "提示词模板", description: "平台创作策略版本", icon: }, + { path: "/admin/resources", label: "存储资源", description: "资源列表、容量与预览", icon: }, ], }, { diff --git a/web/src/pages/admin/components/storage-resources-panel.tsx b/web/src/pages/admin/components/storage-resources-panel.tsx new file mode 100644 index 000000000..4ad219351 --- /dev/null +++ b/web/src/pages/admin/components/storage-resources-panel.tsx @@ -0,0 +1,167 @@ +import { App, Button, Input, Modal, Select } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import { Download, Eye, Search } from "lucide-react"; +import { saveAs } from "file-saver"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useSearchParams } from "react-router"; + +import { PaginationBar } from "@/components/layout/workspace-page"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; +import { + adminResourceFileUrl, + downloadAdminResource, + getAdminStorageStats, + listAdminResources, + type AdminStorageResource, + type AdminStorageStats, +} from "@/services/api/admin-storage"; +import { AdminDataTable, AdminFilterChip, AdminStatTile, AdminStatusBadge, AdminTableEmpty } from "./admin-ui"; + +const pageSizes = [20, 50, 100]; + +export default function StorageResourcesPanel() { + const { message } = App.useApp(); + const [searchParams, setSearchParams] = useSearchParams(); + const keyword = searchParams.get("filter") || ""; + const kind = normalizeOption(searchParams.get("kind"), ["image", "video", "audio", "file"]); + const status = normalizeOption(searchParams.get("status"), ["pending", "ready", "failed", "deleted"]); + const provider = normalizeOption(searchParams.get("provider"), ["local", "aliyun", "tencent", "qiniu", "s3"]); + const userId = searchParams.get("userId") || ""; + const page = positiveInt(searchParams.get("page"), 1); + const pageSize = normalizePageSize(searchParams.get("pageSize")); + const debouncedKeyword = useDebouncedValue(keyword); + const debouncedUserId = useDebouncedValue(userId); + const [resources, setResources] = useState([]); + const [stats, setStats] = useState(null); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [previewing, setPreviewing] = useState(null); + const [downloadingId, setDownloadingId] = useState(""); + const requestSequence = useRef(0); + const hasFilters = Boolean(keyword || userId || kind !== "all" || status !== "all" || provider !== "all"); + + const updateUrl = (patch: Record, replace = false) => { + const next = new URLSearchParams(searchParams); + Object.entries(patch).forEach(([key, value]) => { + const isDefault = (key === "filter" || key === "userId") && value === "" + || ["kind", "status", "provider"].includes(key) && value === "all" + || key === "page" && value === 1 + || key === "pageSize" && value === 20; + if (isDefault) next.delete(key); + else next.set(key, String(value)); + }); + setSearchParams(next, { replace }); + }; + + useEffect(() => { + const controller = new AbortController(); + void getAdminStorageStats(controller.signal) + .then(setStats) + .catch((error) => { + if (error instanceof DOMException && error.name === "AbortError") return; + message.error(error instanceof Error ? error.message : "读取存储统计失败"); + }); + return () => controller.abort(); + }, []); + + useEffect(() => { + const sequence = ++requestSequence.current; + const controller = new AbortController(); + setLoading(true); + void listAdminResources({ + keyword: debouncedKeyword || undefined, + kind: kind === "all" ? undefined : kind, + status: status === "all" ? undefined : status, + provider: provider === "all" ? undefined : provider, + userId: debouncedUserId || undefined, + page, + limit: pageSize, + }, controller.signal) + .then((result) => { + if (sequence !== requestSequence.current) return; + setResources(result.items); + setTotal(result.total); + if (result.total > 0 && result.items.length === 0 && page > 1) updateUrl({ page: 1 }, true); + }) + .catch((error) => { + if (error instanceof DOMException && error.name === "AbortError") return; + if (sequence === requestSequence.current) message.error(error instanceof Error ? error.message : "读取资源列表失败"); + }) + .finally(() => sequence === requestSequence.current && setLoading(false)); + return () => controller.abort(); + }, [debouncedKeyword, debouncedUserId, kind, status, provider, page, pageSize]); + + const columns = useMemo>(() => [ + { title: "资源", width: 260, render: (_, resource) =>
{fileName(resource.objectKey) || resource.id}
{resource.id}
}, + { title: "用户", width: 170, render: (_, resource) =>
{resource.userName || resource.userId}
{resource.userId}
}, + { title: "类型", dataIndex: "kind", width: 92, render: (value) => kindLabel(value) }, + { title: "状态", dataIndex: "status", width: 104, render: (value) => }, + { title: "存储", dataIndex: "provider", width: 110, render: (value) => providerLabel(value) }, + { title: "大小", dataIndex: "size", width: 110, render: (value) => {formatBytes(value)} }, + { title: "规格", width: 135, render: (_, resource) => resourceDimensions(resource) }, + { title: "创建时间", dataIndex: "createdAt", width: 170, render: formatTime }, + { title: "操作", width: 154, align: "right", fixed: "right", render: (_, resource) =>
}, + ], [downloadingId]); + + const download = async (resource: AdminStorageResource) => { + setDownloadingId(resource.id); + try { + const blob = await downloadAdminResource(resource); + saveAs(blob, fileName(resource.objectKey) || resource.id); + } catch (error) { + message.error(error instanceof Error ? error.message : "下载资源失败"); + } finally { + setDownloadingId(""); + } + }; + + return ( +
+
+ + + + +
+ } value={keyword} placeholder="搜索资源 ID 或对象路径" onChange={(event) => updateUrl({ filter: event.target.value, page: 1 }, true)} /> updateUrl({ userId: event.target.value, page: 1 }, true)} />} + toolbarFilters={<> updateUrl({ status: value, page: 1 })} options={statusOptions} />