diff --git a/backend/internal/handler/user_data.go b/backend/internal/handler/user_data.go index 4c88973d5..4a4818cef 100644 --- a/backend/internal/handler/user_data.go +++ b/backend/internal/handler/user_data.go @@ -106,6 +106,19 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, gin.H{"resources": resources}) }) + r.GET("/resources/storage-usage", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + usage, err := svc.AccountFileStorageUsage(user.ID) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"usage": usage}) + }) r.POST("/resources", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { diff --git a/backend/internal/service/storage_quota.go b/backend/internal/service/storage_quota.go index b10978c23..1cff18080 100644 --- a/backend/internal/service/storage_quota.go +++ b/backend/internal/service/storage_quota.go @@ -8,6 +8,26 @@ import ( "infinite-canvas/backend/internal/repository" ) +type AccountFileStorageUsage struct { + UsedBytes int64 `json:"usedBytes"` + TotalBytes int64 `json:"totalBytes"` +} + +func (s *Service) AccountFileStorageUsage(userID string) (*AccountFileStorageUsage, error) { + policy, err := s.RuntimePolicy() + if err != nil { + return nil, err + } + usedBytes, err := s.repo.UserStoredFileBytes(userID) + if err != nil { + return nil, err + } + return &AccountFileStorageUsage{ + UsedBytes: usedBytes, + TotalBytes: gigabytes(policy.Resource.StoredFileGB), + }, nil +} + func structuredBytes(usage repository.UserStorageUsage) int64 { return usage.AssetBytes + usage.CanvasBytes + usage.SessionBytes } diff --git a/backend/internal/service/upload_quota_test.go b/backend/internal/service/upload_quota_test.go index 53d5e3f23..d301bb9c9 100644 --- a/backend/internal/service/upload_quota_test.go +++ b/backend/internal/service/upload_quota_test.go @@ -67,3 +67,20 @@ func TestReserveUserUploadQuotaRejectsTotalStoredFilesAtLimit(t *testing.T) { t.Fatalf("reserveUserUploadQuota() error = %v", err) } } + +func TestAccountFileStorageUsageUsesStoredFilePolicy(t *testing.T) { + svc := newResourceTestService(t) + if err := svc.repo.Create(&model.Resource{ID: "resource-1", UserID: "user-1", Status: model.ResourceStatusReady, Size: 3 << 20}); err != nil { + t.Fatal(err) + } + if err := svc.repo.Create(&model.SessionFile{ID: "session-file-1", UserID: "user-1", SessionID: "session-1", Size: 2 << 20}); err != nil { + t.Fatal(err) + } + usage, err := svc.AccountFileStorageUsage("user-1") + if err != nil { + t.Fatal(err) + } + if usage.UsedBytes != 5<<20 || usage.TotalBytes != gigabytes(defaultRuntimePolicy().Resource.StoredFileGB) { + t.Fatalf("AccountFileStorageUsage() = %#v", usage) + } +} diff --git a/web/src/pages/assets/asset-storage-usage.tsx b/web/src/pages/assets/asset-storage-usage.tsx new file mode 100644 index 000000000..b1694627f --- /dev/null +++ b/web/src/pages/assets/asset-storage-usage.tsx @@ -0,0 +1,60 @@ +import { useQuery } from "@tanstack/react-query"; +import { HardDrive } from "lucide-react"; + +import { formatBytes } from "@/lib/image-utils"; +import { getAccountFileStorageUsage } from "@/services/api/resources"; + +export const assetStorageUsageQueryKey = ["account-file-storage-usage"] as const; + +export function AssetStorageUsage() { + const query = useQuery({ + queryKey: assetStorageUsageQueryKey, + queryFn: getAccountFileStorageUsage, + refetchOnMount: "always", + }); + const usage = query.data; + const percent = usage?.totalBytes ? Math.min(100, (usage.usedBytes / usage.totalBytes) * 100) : 0; + const percentLabel = usage?.usedBytes && percent < 0.1 ? "<0.1%" : `${Math.round(percent * 10) / 10}%`; + const full = Boolean(usage && usage.usedBytes >= usage.totalBytes); + + return ( +
+ + 账号容量 + {usage ? ( + <> + + {storageBytes(usage.usedBytes)} / {storageBytes(usage.totalBytes)} + + + + + {percentLabel} + + ) : query.isError ? ( + + 容量统计暂时不可用。 + + + ) : ( + 正在统计已用容量… + )} +
+ ); +} + +function storageBytes(value: number) { + return formatBytes(value) || "0 B"; +} diff --git a/web/src/pages/assets/index.tsx b/web/src/pages/assets/index.tsx index a4e6afa79..9615e07ff 100644 --- a/web/src/pages/assets/index.tsx +++ b/web/src/pages/assets/index.tsx @@ -1,5 +1,6 @@ import { AudioLines, Box, CheckCheck, Clapperboard, Copy, Download, FileText, FileUp, Image as ImageIcon, Link2, MoreHorizontal, PencilLine, Play, Plus, Search, Trash2, Upload, type LucideIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { App, Button, Drawer, Dropdown, Form, Input, Modal, Select, Space, Tag, Typography } from "antd"; import type { MenuProps } from "antd"; import { useNavigate } from "react-router"; @@ -17,6 +18,7 @@ import { uploadImage } from "@/services/image-storage"; import { uploadMediaFile } from "@/services/file-storage"; import { useAssetStore, type Asset, type AssetCategory, type AssetKind, type ImageAsset } from "@/stores/use-asset-store"; import { exportAssets, readAssetPackage } from "./asset-transfer"; +import { AssetStorageUsage, assetStorageUsageQueryKey } from "./asset-storage-usage"; import { deleteAssetWithRemoteSync } from "@/services/user-data-sync"; type LibraryAsset = Exclude; @@ -65,6 +67,7 @@ const assetKindIcons: Record = { export default function AssetsPage() { const { message } = App.useApp(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const copyText = useCopyText(); const [form] = Form.useForm(); const coverInputRef = useRef(null); @@ -188,6 +191,7 @@ export default function AssetsPage() { const readImageFile = async (file?: File) => { if (!file || !file.type.startsWith("image/")) return; const image = await uploadImage(file); + void queryClient.invalidateQueries({ queryKey: assetStorageUsageQueryKey }); const draft = { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType }; setImageDraft(draft); if (!form.getFieldValue("coverUrl")) form.setFieldValue("coverUrl", draft.dataUrl); @@ -197,6 +201,7 @@ export default function AssetsPage() { const readModelFile = async (file?: File) => { if (!file || !/\.(glb|gltf)$/i.test(file.name)) return; const uploaded = await uploadMediaFile(file, "model"); + void queryClient.invalidateQueries({ queryKey: assetStorageUsageQueryKey }); addAsset({ kind: "model", title: file.name.replace(/\.(glb|gltf)$/i, ""), coverUrl: "", tags: ["3D模型"], source: "手动上传", data: { url: uploaded.url, storageKey: uploaded.storageKey, bytes: uploaded.bytes, mimeType: uploaded.mimeType, fileName: file.name }, metadata: { source: "manual" } }); message.success("3D 模型已保存"); }; @@ -277,13 +282,16 @@ export default function AssetsPage() { description="管理文本、图片、视频、音频和 3D 模型素材。" meta={{validAssets.length} 个素材} actions={( - <> - - +