feat(admin): 存储管理 - 新增资源只读查询、统计与预览
Adapted from ddcat-ai/open-ai-canvas#339 Original commit: fcdbe2aa833f31f683f709b7f409a0534ed78e20 Refs #339 Co-authored-by: 宇熙 <101980758+yuxiwl@users.noreply.github.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
72
backend/internal/handler/admin_storage.go
Normal file
72
backend/internal/handler/admin_storage.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
190
backend/internal/repository/admin_storage.go
Normal file
190
backend/internal/repository/admin_storage.go
Normal file
@@ -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')"
|
||||
}
|
||||
188
backend/internal/service/admin_storage.go
Normal file
188
backend/internal/service/admin_storage.go
Normal file
@@ -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
|
||||
}
|
||||
109
backend/internal/service/admin_storage_test.go
Normal file
109
backend/internal/service/admin_storage_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 <div className="py-16 text-center text-sm text-foreground/50">正在读取{label}...</div>;
|
||||
@@ -39,3 +40,7 @@ export function EmailSettingsPage() {
|
||||
export function FeatureAvailabilityPage() {
|
||||
return <AdminPageFrame title="功能开放" description="控制用户工作台入口、渠道、插件与计费模式" scroll><Suspense fallback={<PageFallback label="功能开放配置" />}><FeatureAvailabilityPanel /></Suspense></AdminPageFrame>;
|
||||
}
|
||||
|
||||
export function StorageResourcesPage() {
|
||||
return <AdminPageFrame title="存储资源" description="只读查看资源记录、容量分布与文件预览" scroll><Suspense fallback={<PageFallback label="存储资源" />}><StorageResourcesPanel /></Suspense></AdminPageFrame>;
|
||||
}
|
||||
|
||||
@@ -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: <Layers3 className="size-4" />, requireFeature: "frontendModelsEnabled" },
|
||||
{ path: "/admin/plugins", label: "插件管理", description: "平台可用性、上传与卸载", icon: <PlugZap className="size-4" /> },
|
||||
{ path: "/admin/prompt-templates", label: "提示词模板", description: "平台创作策略版本", icon: <MessageSquareText className="size-4" /> },
|
||||
{ path: "/admin/resources", label: "存储资源", description: "资源列表、容量与预览", icon: <Database className="size-4" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
167
web/src/pages/admin/components/storage-resources-panel.tsx
Normal file
167
web/src/pages/admin/components/storage-resources-panel.tsx
Normal file
@@ -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<AdminStorageResource[]>([]);
|
||||
const [stats, setStats] = useState<AdminStorageStats | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [previewing, setPreviewing] = useState<AdminStorageResource | null>(null);
|
||||
const [downloadingId, setDownloadingId] = useState("");
|
||||
const requestSequence = useRef(0);
|
||||
const hasFilters = Boolean(keyword || userId || kind !== "all" || status !== "all" || provider !== "all");
|
||||
|
||||
const updateUrl = (patch: Record<string, string | number>, 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<ColumnsType<AdminStorageResource>>(() => [
|
||||
{ title: "资源", width: 260, render: (_, resource) => <div className="min-w-0"><div className="truncate font-medium text-foreground/85" title={resource.objectKey}>{fileName(resource.objectKey) || resource.id}</div><div className="admin-monospace truncate text-foreground/42" title={resource.id}>{resource.id}</div></div> },
|
||||
{ title: "用户", width: 170, render: (_, resource) => <div className="min-w-0"><div className="truncate text-foreground/78">{resource.userName || resource.userId}</div><div className="admin-monospace truncate text-foreground/38" title={resource.userId}>{resource.userId}</div></div> },
|
||||
{ title: "类型", dataIndex: "kind", width: 92, render: (value) => kindLabel(value) },
|
||||
{ title: "状态", dataIndex: "status", width: 104, render: (value) => <AdminStatusBadge label={statusLabel(value)} tone={statusTone(value)} /> },
|
||||
{ title: "存储", dataIndex: "provider", width: 110, render: (value) => providerLabel(value) },
|
||||
{ title: "大小", dataIndex: "size", width: 110, render: (value) => <span className="tabular-nums">{formatBytes(value)}</span> },
|
||||
{ title: "规格", width: 135, render: (_, resource) => resourceDimensions(resource) },
|
||||
{ title: "创建时间", dataIndex: "createdAt", width: 170, render: formatTime },
|
||||
{ title: "操作", width: 154, align: "right", fixed: "right", render: (_, resource) => <div className="flex justify-end gap-1"><Button type="text" size="small" icon={<Eye className="size-3.5" />} disabled={resource.status !== "ready"} onClick={() => setPreviewing(resource)}>预览</Button><Button type="text" size="small" icon={<Download className="size-3.5" />} loading={downloadingId === resource.id} disabled={resource.status !== "ready"} onClick={() => void download(resource)}>下载</Button></div> },
|
||||
], [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 (
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="grid overflow-hidden rounded-md border border-border sm:grid-cols-2 xl:grid-cols-4">
|
||||
<AdminStatTile label="资源记录" value={stats ? stats.resourceCount.toLocaleString() : "--"} detail={stats ? `可用 ${stats.readyCount.toLocaleString()} 项` : undefined} />
|
||||
<AdminStatTile label="逻辑体积" value={stats ? formatBytes(stats.logicalBytes) : "--"} detail="包含失败与待处理记录" />
|
||||
<AdminStatTile label="实际可用体积" value={stats ? formatBytes(stats.physicalBytes) : "--"} detail="仅统计已就绪资源" />
|
||||
<AdminStatTile label="存储分布" value={stats ? formatBytes(stats.remoteBytes) : "--"} detail={stats ? `本地 ${formatBytes(stats.localBytes)} · 远端` : undefined} />
|
||||
</div>
|
||||
<AdminDataTable
|
||||
toolbar={<><Input aria-label="搜索资源" autoComplete="off" allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索资源 ID 或对象路径" onChange={(event) => updateUrl({ filter: event.target.value, page: 1 }, true)} /><Input aria-label="按用户 ID 筛选" autoComplete="off" allowClear className="w-48" value={userId} placeholder="用户 ID" onChange={(event) => updateUrl({ userId: event.target.value, page: 1 }, true)} /></>}
|
||||
toolbarFilters={<><Select className="w-32" value={kind} onChange={(value) => updateUrl({ kind: value, page: 1 })} options={kindOptions} /><Select className="w-32" value={status} onChange={(value) => updateUrl({ status: value, page: 1 })} options={statusOptions} /><Select className="w-36" value={provider} onChange={(value) => updateUrl({ provider: value, page: 1 })} options={providerOptions} /></>}
|
||||
toolbarActiveFilters={<>{keyword ? <AdminFilterChip label={`搜索:${keyword}`} onRemove={() => updateUrl({ filter: "", page: 1 })} /> : null}{userId ? <AdminFilterChip label={`用户:${userId}`} onRemove={() => updateUrl({ userId: "", page: 1 })} /> : null}{kind !== "all" ? <AdminFilterChip label={`类型:${kindLabel(kind)}`} onRemove={() => updateUrl({ kind: "all", page: 1 })} /> : null}{status !== "all" ? <AdminFilterChip label={`状态:${statusLabel(status)}`} onRemove={() => updateUrl({ status: "all", page: 1 })} /> : null}{provider !== "all" ? <AdminFilterChip label={`存储:${providerLabel(provider)}`} onRemove={() => updateUrl({ provider: "all", page: 1 })} /> : null}</>}
|
||||
toolbarActive={hasFilters}
|
||||
onReset={() => updateUrl({ filter: "", userId: "", kind: "all", status: "all", provider: "all", page: 1 })}
|
||||
skeletonColumns={9}
|
||||
table={{ className: "app-data-table", size: "small", rowKey: "id", loading, columns, dataSource: resources, pagination: false, scroll: { x: 1320 } }}
|
||||
empty={<AdminTableEmpty filtered={hasFilters} title={hasFilters ? undefined : "暂无资源记录"} description={hasFilters ? undefined : "资源上传或生成后会显示在这里。"} />}
|
||||
footer={<PaginationBar alwaysShow current={page} pageSize={pageSize} total={total} onChange={(nextPage, nextSize) => updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize })} />}
|
||||
/>
|
||||
<Modal title={previewing ? fileName(previewing.objectKey) || "资源预览" : "资源预览"} open={Boolean(previewing)} width={880} onCancel={() => setPreviewing(null)} footer={previewing ? <Button icon={<Download className="size-4" />} loading={downloadingId === previewing.id} onClick={() => void download(previewing)}>下载原文件</Button> : null} destroyOnHidden>
|
||||
{previewing ? <ResourcePreview resource={previewing} /> : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourcePreview({ resource }: { resource: AdminStorageResource }) {
|
||||
const url = adminResourceFileUrl(resource.id);
|
||||
if (resource.kind === "image" || resource.mimeType.startsWith("image/")) return <img className="mx-auto max-h-[65vh] max-w-full object-contain" src={url} alt={fileName(resource.objectKey) || "资源预览"} />;
|
||||
if (resource.kind === "video" || resource.mimeType.startsWith("video/")) return <video className="mx-auto max-h-[65vh] max-w-full bg-black" src={url} controls playsInline />;
|
||||
if (resource.kind === "audio" || resource.mimeType.startsWith("audio/")) return <div className="py-12"><audio className="w-full" src={url} controls /></div>;
|
||||
return <div className="rounded-md border border-border bg-muted/20 px-5 py-10 text-center text-sm text-foreground/55">该文件类型不支持内嵌预览,请下载后查看。</div>;
|
||||
}
|
||||
|
||||
const kindOptions = [{ label: "全部类型", value: "all" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }, { label: "音频", value: "audio" }, { label: "文件", value: "file" }];
|
||||
const statusOptions = [{ label: "全部状态", value: "all" }, { label: "待处理", value: "pending" }, { label: "已就绪", value: "ready" }, { label: "失败", value: "failed" }, { label: "已删除", value: "deleted" }];
|
||||
const providerOptions = [{ label: "全部存储", value: "all" }, { label: "本地", value: "local" }, { label: "阿里云 OSS", value: "aliyun" }, { label: "腾讯云 COS", value: "tencent" }, { label: "七牛云 Kodo", value: "qiniu" }, { label: "S3", value: "s3" }];
|
||||
|
||||
function normalizeOption(value: string | null, values: string[]) { return value && values.includes(value) ? value : "all"; }
|
||||
function positiveInt(value: string | null, fallback: number) { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; }
|
||||
function normalizePageSize(value: string | null) { const parsed = positiveInt(value, 20); return pageSizes.includes(parsed) ? parsed : 20; }
|
||||
function fileName(objectKey: string) { return objectKey.split("/").filter(Boolean).at(-1) || ""; }
|
||||
function kindLabel(kind: string) { return ({ image: "图片", video: "视频", audio: "音频", file: "文件" } as Record<string, string>)[kind] || kind || "未知"; }
|
||||
function statusLabel(status: string) { return ({ pending: "待处理", ready: "已就绪", failed: "失败", deleted: "已删除" } as Record<string, string>)[status] || status || "未知"; }
|
||||
function statusTone(status: string): "neutral" | "success" | "warning" | "error" { return status === "ready" ? "success" : status === "pending" ? "warning" : status === "failed" ? "error" : "neutral"; }
|
||||
function providerLabel(provider: string) { return ({ local: "本地", aliyun: "阿里云 OSS", tencent: "腾讯云 COS", qiniu: "七牛云 Kodo", s3: "S3" } as Record<string, string>)[provider] || provider || "本地"; }
|
||||
function formatBytes(bytes: number) { if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); const value = bytes / 1024 ** unit; return `${value >= 100 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`; }
|
||||
function formatTime(value: string) { const date = new Date(value); return Number.isNaN(date.getTime()) ? "--" : date.toLocaleString("zh-CN", { hour12: false }); }
|
||||
function resourceDimensions(resource: AdminStorageResource) { if (resource.width > 0 && resource.height > 0) return <span className="tabular-nums">{resource.width} × {resource.height}</span>; if (resource.durationMs > 0) return <span className="tabular-nums">{formatDuration(resource.durationMs)}</span>; return <span className="text-foreground/30">--</span>; }
|
||||
function formatDuration(durationMs: number) { const seconds = Math.round(durationMs / 1000); const minutes = Math.floor(seconds / 60); return minutes > 0 ? `${minutes}:${String(seconds % 60).padStart(2, "0")}` : `${seconds} 秒`; }
|
||||
@@ -12,6 +12,7 @@ import RouteErrorPage from "@/pages/route-error";
|
||||
const AdminPage = lazy(() => import("@/pages/admin"));
|
||||
const AnalyticsPage = lazy(() => import("@/pages/admin/admin-route-pages").then((module) => ({ default: module.AnalyticsPage })));
|
||||
const AnnouncementsPage = lazy(() => import("@/pages/admin/admin-route-pages").then((module) => ({ default: module.AnnouncementsPage })));
|
||||
const StorageResourcesPage = lazy(() => import("@/pages/admin/admin-route-pages").then((module) => ({ default: module.StorageResourcesPage })));
|
||||
const CreditOperationsPage = lazy(() => import("@/pages/admin/admin-route-pages").then((module) => ({ default: module.CreditOperationsPage })));
|
||||
const AccessSettingsPage = lazy(() => import("@/pages/admin/admin-route-pages").then((module) => ({ default: module.AccessSettingsPage })));
|
||||
const EmailSettingsPage = lazy(() => import("@/pages/admin/admin-route-pages").then((module) => ({ default: module.EmailSettingsPage })));
|
||||
@@ -171,6 +172,7 @@ export const router = createBrowserRouter([
|
||||
{ path: "prompt-templates", element: deferred(<StoryboardPromptsPage />) },
|
||||
{ path: "storyboard-prompts", element: <Navigate to="/admin/prompt-templates" replace /> },
|
||||
{ path: "announcements", element: deferred(<AnnouncementsPage />) },
|
||||
{ path: "resources", element: deferred(<StorageResourcesPage />) },
|
||||
{ path: "credit-operations", element: deferred(<CreditOperationsPage />) },
|
||||
{ path: "redemption-codes", element: deferred(<RedemptionCodesPage />) },
|
||||
{ path: "logs", element: deferred(<LogsPage />) },
|
||||
|
||||
84
web/src/services/api/admin-storage.ts
Normal file
84
web/src/services/api/admin-storage.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { apiBaseURL, apiClient, compactApiParams, request } from "@/services/api/request";
|
||||
|
||||
export type AdminStorageResource = {
|
||||
id: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
kind: "image" | "video" | "audio" | "file" | string;
|
||||
status: "pending" | "ready" | "failed" | "deleted" | string;
|
||||
provider: string;
|
||||
bucket?: string;
|
||||
objectKey: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
physicalBytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
durationMs: number;
|
||||
fileUrl: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AdminResourcePage = {
|
||||
items: AdminStorageResource[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export type AdminStorageDimensionStat = {
|
||||
count: number;
|
||||
logicalBytes: number;
|
||||
physicalBytes: number;
|
||||
};
|
||||
|
||||
export type AdminStorageStats = {
|
||||
resourceCount: number;
|
||||
readyCount: number;
|
||||
logicalBytes: number;
|
||||
physicalBytes: number;
|
||||
localBytes: number;
|
||||
remoteBytes: number;
|
||||
byKind: Array<AdminStorageDimensionStat & { kind: string }>;
|
||||
byProvider: Array<AdminStorageDimensionStat & { provider: string }>;
|
||||
};
|
||||
|
||||
export type AdminResourceQuery = {
|
||||
keyword?: string;
|
||||
kind?: string;
|
||||
status?: string;
|
||||
provider?: string;
|
||||
userId?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export async function listAdminResources(query: AdminResourceQuery, signal?: AbortSignal) {
|
||||
return request<AdminResourcePage>(apiClient.get("/admin/resources", { params: compactApiParams(query), signal }));
|
||||
}
|
||||
|
||||
export async function getAdminStorageStats(signal?: AbortSignal) {
|
||||
const result = await request<{ stats: AdminStorageStats }>(apiClient.get("/admin/storage/stats", { signal }));
|
||||
return result.stats;
|
||||
}
|
||||
|
||||
export function adminResourceFileUrl(id: string, download = false) {
|
||||
const base = String(apiBaseURL).replace(/\/+$/, "");
|
||||
return `${base}/admin/resources/${encodeURIComponent(id)}/file${download ? "?download=1" : ""}`;
|
||||
}
|
||||
|
||||
export async function downloadAdminResource(resource: AdminStorageResource) {
|
||||
const response = await fetch(adminResourceFileUrl(resource.id, true), { credentials: "include" });
|
||||
if (!response.ok) {
|
||||
let message = "下载资源失败";
|
||||
try {
|
||||
const body = (await response.json()) as { msg?: string };
|
||||
if (body.msg) message = body.msg;
|
||||
} catch {
|
||||
// 文件接口失败时不保证返回 JSON。
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
Reference in New Issue
Block a user