From 9973c656a64be08f2fae8383dca2612441af589c Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Fri, 14 Aug 2026 18:35:17 +0800 Subject: [PATCH] =?UTF-8?q?perf(platform):=20=E5=88=9B=E4=BD=9C=E9=A1=B5?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=20-=20=E5=90=88=E5=B9=B6=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E4=B8=8E=E4=BD=99=E9=A2=9D=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E5=B9=B6=E6=8C=89=E8=B7=AF=E7=94=B1=E5=BB=B6=E8=BF=9F=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/user_data.go | 13 +++ backend/internal/service/user_data.go | 17 +++ web/src/components/layout/app-top-nav.tsx | 1 + web/src/hooks/use-wallet-balance.ts | 88 +++++---------- web/src/router.tsx | 130 ++++++++++++---------- web/src/services/api/user-data.ts | 8 ++ web/src/services/file-storage.ts | 7 +- web/src/services/image-storage.ts | 7 +- web/src/services/user-data-sync.ts | 37 +++--- 9 files changed, 160 insertions(+), 148 deletions(-) diff --git a/backend/internal/handler/user_data.go b/backend/internal/handler/user_data.go index d9b326aa9..4c88973d5 100644 --- a/backend/internal/handler/user_data.go +++ b/backend/internal/handler/user_data.go @@ -299,6 +299,19 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, gin.H{"assets": assets}) }) + r.GET("/user-data/snapshot", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + snapshot, err := svc.UserDataSnapshot(user.ID) + if err != nil { + failService(c, err) + return + } + ok(c, snapshot) + }) r.GET("/assets/:id", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { diff --git a/backend/internal/service/user_data.go b/backend/internal/service/user_data.go index d539bf52b..17b22facd 100644 --- a/backend/internal/service/user_data.go +++ b/backend/internal/service/user_data.go @@ -29,6 +29,23 @@ type UserDataSummary struct { UpdatedAt time.Time `json:"updatedAt"` } +type UserDataSnapshot struct { + Assets []json.RawMessage `json:"assets"` + Projects []json.RawMessage `json:"projects"` +} + +func (s *Service) UserDataSnapshot(userID string) (UserDataSnapshot, error) { + assets, err := s.UserAssets(userID) + if err != nil { + return UserDataSnapshot{}, err + } + projects, err := s.UserCanvasProjects(userID) + if err != nil { + return UserDataSnapshot{}, err + } + return UserDataSnapshot{Assets: assets, Projects: projects}, nil +} + func (s *Service) UserAssetSummaries(userID string) ([]UserDataSummary, error) { assets, err := s.repo.AssetSummaries(userID) if err != nil { diff --git a/web/src/components/layout/app-top-nav.tsx b/web/src/components/layout/app-top-nav.tsx index f846b86ea..f20799198 100644 --- a/web/src/components/layout/app-top-nav.tsx +++ b/web/src/components/layout/app-top-nav.tsx @@ -178,6 +178,7 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
["wallet-balance", userId] as const; export function useWalletBalance(userId?: string, enabled = true) { const activeUserId = enabled ? userId || "" : ""; - const requestSequence = useRef(0); - const [snapshot, setSnapshot] = useState({ userId: "", availableMicrocredits: null, refreshing: false }); - - const refresh = useCallback(async () => { - const requestedUserId = activeUserId; - const sequence = ++requestSequence.current; - if (!requestedUserId) { - setSnapshot({ userId: "", availableMicrocredits: null, refreshing: false }); - return; - } - setSnapshot((current) => ({ - userId: requestedUserId, - availableMicrocredits: current.userId === requestedUserId ? current.availableMicrocredits : null, - refreshing: true, - })); - try { + const queryClient = useQueryClient(); + const queryKey = walletBalanceQueryKey(activeUserId); + const query = useQuery({ + queryKey, + enabled: Boolean(activeUserId), + queryFn: async () => { const wallet = await getWallet(1, 1); - if (sequence !== requestSequence.current) return; - if (wallet.account.userId !== requestedUserId) throw new Error("积分账户与当前用户不一致"); - setSnapshot({ userId: requestedUserId, availableMicrocredits: wallet.account.availableMicrocredits, refreshing: false }); - } catch (error) { - if (sequence !== requestSequence.current) return; - console.warn("积分余额刷新失败", error); - setSnapshot((current) => ({ - userId: requestedUserId, - availableMicrocredits: current.userId === requestedUserId ? current.availableMicrocredits : null, - refreshing: false, - })); - } - }, [activeUserId]); + if (wallet.account.userId !== activeUserId) throw new Error("积分账户与当前用户不一致"); + return wallet.account.availableMicrocredits; + }, + staleTime: 15_000, + refetchInterval: WALLET_REFRESH_INTERVAL_MS, + refetchOnWindowFocus: true, + }); useEffect(() => { - if (!activeUserId) { - requestSequence.current += 1; - setSnapshot({ userId: "", availableMicrocredits: null, refreshing: false }); - return; - } - void refresh(); - const timer = window.setInterval(() => void refresh(), WALLET_REFRESH_INTERVAL_MS); - const handleFocus = () => void refresh(); - const handleVisibility = () => { - if (document.visibilityState === "visible") void refresh(); - }; - window.addEventListener("focus", handleFocus); - window.addEventListener("wallet:updated", handleFocus); - document.addEventListener("visibilitychange", handleVisibility); + if (!activeUserId) return; + const handleUpdated = () => void queryClient.invalidateQueries({ queryKey: walletBalanceQueryKey(activeUserId) }); + window.addEventListener("wallet:updated", handleUpdated); return () => { - requestSequence.current += 1; - window.clearInterval(timer); - window.removeEventListener("focus", handleFocus); - window.removeEventListener("wallet:updated", handleFocus); - document.removeEventListener("visibilitychange", handleVisibility); + window.removeEventListener("wallet:updated", handleUpdated); }; - }, [activeUserId, refresh]); + }, [activeUserId, queryClient]); + + useEffect(() => { + if (query.error) console.warn("积分余额刷新失败", query.error); + }, [query.error]); - const snapshotMatchesUser = snapshot.userId === activeUserId; return { - availableMicrocredits: snapshotMatchesUser ? snapshot.availableMicrocredits : null, - refreshing: snapshotMatchesUser ? snapshot.refreshing : Boolean(activeUserId), - refresh, + availableMicrocredits: query.data ?? null, + refreshing: query.isFetching, + refresh: query.refetch, }; } diff --git a/web/src/router.tsx b/web/src/router.tsx index 00849c9be..e9555ba6f 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -1,47 +1,59 @@ +import { lazy, Suspense, type ReactNode } from "react"; import { createBrowserRouter, Navigate, Outlet } from "react-router"; import { RequireAuth } from "@/components/auth/require-auth"; import { RequireFeature } from "@/components/auth/require-feature"; +import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader"; import UserLayout from "@/layouts/user-layout"; -import AdminPage from "@/pages/admin"; -import { AccessSettingsPage, AnalyticsPage, AnnouncementsPage, CreditOperationsPage, EmailSettingsPage, FeatureAvailabilityPage } from "@/pages/admin/admin-route-pages"; -import ChannelsPage from "@/pages/admin/channels/channels-page"; -import LogsPage from "@/pages/admin/logs/logs-page"; -import RedemptionCodesPage from "@/pages/admin/redemption-codes/redemption-codes-page"; -import RuntimePolicySettingsPage from "@/pages/admin/settings/runtime-policy-settings-page"; -import DrawingEngineSettingsPage from "@/pages/admin/settings/drawing-engine-settings-page"; -import StorageSettingsPage from "@/pages/admin/settings/storage-settings-page"; -import StoryboardPromptsPage from "@/pages/admin/storyboard-prompts/storyboard-prompts-page"; -import UsersPage from "@/pages/admin/users/users-page"; -import AssetsPage from "@/pages/assets"; import { AuthScene } from "@/pages/auth/auth-scene"; -import LoginPage from "@/pages/auth/login"; -import RegisterPage from "@/pages/auth/register"; -import CanvasPage from "@/pages/canvas"; -import CanvasProjectPage from "@/pages/canvas/project"; -import SharedCanvasPage from "@/pages/canvas/shared"; -import CreatePage from "@/pages/create"; -import HomePage from "@/pages/home"; -import NotFound from "@/pages/not-found"; import RouteErrorPage from "@/pages/route-error"; -import SkillsPage from "@/pages/skills"; -import TasksPage from "@/pages/tasks"; -import WalletPage from "@/pages/wallet"; -import ProjectsPage from "@/pages/projects"; -import ProjectDetailPage from "@/pages/projects/detail"; -import SettingsPage from "@/pages/settings"; -import TestVoiceRecording from "@/pages/test-voice-recording"; + +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 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 }))); +const FeatureAvailabilityPage = lazy(() => import("@/pages/admin/admin-route-pages").then((module) => ({ default: module.FeatureAvailabilityPage }))); +const ChannelsPage = lazy(() => import("@/pages/admin/channels/channels-page")); +const LogsPage = lazy(() => import("@/pages/admin/logs/logs-page")); +const RedemptionCodesPage = lazy(() => import("@/pages/admin/redemption-codes/redemption-codes-page")); +const RuntimePolicySettingsPage = lazy(() => import("@/pages/admin/settings/runtime-policy-settings-page")); +const DrawingEngineSettingsPage = lazy(() => import("@/pages/admin/settings/drawing-engine-settings-page")); +const StorageSettingsPage = lazy(() => import("@/pages/admin/settings/storage-settings-page")); +const StoryboardPromptsPage = lazy(() => import("@/pages/admin/storyboard-prompts/storyboard-prompts-page")); +const UsersPage = lazy(() => import("@/pages/admin/users/users-page")); +const AssetsPage = lazy(() => import("@/pages/assets")); +const LoginPage = lazy(() => import("@/pages/auth/login")); +const RegisterPage = lazy(() => import("@/pages/auth/register")); +const CanvasPage = lazy(() => import("@/pages/canvas")); +const CanvasProjectPage = lazy(() => import("@/pages/canvas/project")); +const SharedCanvasPage = lazy(() => import("@/pages/canvas/shared")); +const CreatePage = lazy(() => import("@/pages/create")); +const HomePage = lazy(() => import("@/pages/home")); +const NotFound = lazy(() => import("@/pages/not-found")); +const SkillsPage = lazy(() => import("@/pages/skills")); +const TasksPage = lazy(() => import("@/pages/tasks")); +const WalletPage = lazy(() => import("@/pages/wallet")); +const ProjectsPage = lazy(() => import("@/pages/projects")); +const ProjectDetailPage = lazy(() => import("@/pages/projects/detail")); +const SettingsPage = lazy(() => import("@/pages/settings")); +const TestVoiceRecording = lazy(() => import("@/pages/test-voice-recording")); + +function deferred(element: ReactNode) { + return }>{element}; +} export const router = createBrowserRouter([ { element: , errorElement: , children: [ - { path: "/login", element: }, - { path: "/register", element: }, + { path: "/login", element: deferred() }, + { path: "/register", element: deferred() }, ], }, - { path: "/share/canvas/:token", element: , errorElement: }, + { path: "/share/canvas/:token", element: deferred(), errorElement: }, { element: ( @@ -51,44 +63,44 @@ export const router = createBrowserRouter([ errorElement: , children: [ { path: "/", element: }, - { path: "/create", element: }, - { path: "/home", element: }, - { path: "/tasks", element: }, - { path: "/assets", element: }, - { path: "/skills", element: }, - { path: "/wallet", element: }, - { path: "/settings", element: }, - { path: "/test-voice-recording", element: }, - { path: "/projects", element: }, - { path: "/projects/:projectId", element: }, - { path: "/projects/:projectId/:view", element: }, - { path: "/projects/:projectId/chapters/:chapterId", element: }, - { path: "/canvas", element: }, - { path: "/canvas/:id", element: }, + { path: "/create", element: {deferred()} }, + { path: "/home", element: deferred() }, + { path: "/tasks", element: {deferred()} }, + { path: "/assets", element: {deferred()} }, + { path: "/skills", element: {deferred()} }, + { path: "/wallet", element: {deferred()} }, + { path: "/settings", element: {deferred()} }, + { path: "/test-voice-recording", element: {deferred()} }, + { path: "/projects", element: {deferred()} }, + { path: "/projects/:projectId", element: {deferred()} }, + { path: "/projects/:projectId/:view", element: {deferred()} }, + { path: "/projects/:projectId/chapters/:chapterId", element: {deferred()} }, + { path: "/canvas", element: {deferred()} }, + { path: "/canvas/:id", element: {deferred()} }, { path: "/admin", - element: , + element: {deferred()}, children: [ - { index: true, element: }, - { path: "users", element: }, - { path: "channels", element: }, - { path: "prompt-templates", element: }, + { index: true, element: deferred() }, + { path: "users", element: deferred() }, + { path: "channels", element: deferred() }, + { path: "prompt-templates", element: deferred() }, { path: "storyboard-prompts", element: }, - { path: "announcements", element: }, - { path: "credit-operations", element: }, - { path: "redemption-codes", element: }, - { path: "logs", element: }, + { path: "announcements", element: deferred() }, + { path: "credit-operations", element: deferred() }, + { path: "redemption-codes", element: deferred() }, + { path: "logs", element: deferred() }, { path: "settings", element: }, - { path: "settings/drawing-engine", element: }, + { path: "settings/drawing-engine", element: deferred() }, { path: "settings/concurrency", element: }, - { path: "settings/runtime-policy", element: }, - { path: "settings/features", element: }, - { path: "settings/access", element: }, - { path: "settings/email", element: }, - { path: "settings/storage", element: }, + { path: "settings/runtime-policy", element: deferred() }, + { path: "settings/features", element: deferred() }, + { path: "settings/access", element: deferred() }, + { path: "settings/email", element: deferred() }, + { path: "settings/storage", element: deferred() }, ], }, ], }, - { path: "*", element: }, + { path: "*", element: deferred() }, ]); diff --git a/web/src/services/api/user-data.ts b/web/src/services/api/user-data.ts index 7ac4c8be2..80ca28d34 100644 --- a/web/src/services/api/user-data.ts +++ b/web/src/services/api/user-data.ts @@ -12,6 +12,14 @@ export type RemoteUserDataSummary = { updatedAt: string; }; +export type RemoteUserDataSnapshot = { + assets: Asset[]; + projects: CanvasProject[]; +}; + +export function getRemoteUserDataSnapshot() { + return request(api.get("/user-data/snapshot")); +} export function listRemoteAssets() { return request<{ assets: RemoteUserDataSummary[] }>(api.get("/assets")); diff --git a/web/src/services/file-storage.ts b/web/src/services/file-storage.ts index 5d916779e..5a6ec9cc9 100644 --- a/web/src/services/file-storage.ts +++ b/web/src/services/file-storage.ts @@ -2,7 +2,7 @@ import localforage from "localforage"; import { nanoid } from "nanoid"; import { getActiveUserScope } from "@/lib/user-scope"; -import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, resolveResourceUrl, uploadResourceFile } from "@/services/api/resources"; +import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources"; import { getCachedResourceBlob, getCachedResourceObjectUrl, primeResourceBlobCache } from "@/services/resource-blob-cache"; export type UploadedFile = { url: string; storageKey: string; bytes: number; mimeType: string; width?: number; height?: number; durationMs?: number }; @@ -32,9 +32,10 @@ export async function uploadMediaFile(input: string | Blob, prefix = "file"): Pr export async function resolveMediaUrl(storageKey?: string, fallback = "") { if (!storageKey) return fallback; - if (resourceIdFromStorageKey(storageKey)) { + const resourceId = resourceIdFromStorageKey(storageKey); + if (resourceId) { const cached = await getCachedResourceObjectUrl(storageKey).catch(() => ""); - return cached || resolveResourceUrl(storageKey, fallback); + return cached || resourceFileUrl(resourceId); } const cached = objectUrls.get(storageKey); if (cached) return cached; diff --git a/web/src/services/image-storage.ts b/web/src/services/image-storage.ts index 1951ef293..77c255d50 100644 --- a/web/src/services/image-storage.ts +++ b/web/src/services/image-storage.ts @@ -3,7 +3,7 @@ import localforage from "localforage"; import { nanoid } from "nanoid"; import { readImageMeta } from "@/lib/image-utils"; import { getActiveUserScope } from "@/lib/user-scope"; -import { importResourceFromUrl, isResourceUrl, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, resolveResourceUrl, uploadResourceFile } from "@/services/api/resources"; +import { importResourceFromUrl, isResourceUrl, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources"; import { cacheResourceObjectUrl, getCachedResourceBlob, getCachedResourceObjectUrl, primeResourceBlobCache } from "@/services/resource-blob-cache"; export type UploadedImage = { @@ -65,14 +65,15 @@ function shouldImportRemoteImage(input: string) { export async function resolveImageUrl(storageKey?: string, fallback = "", options?: { cacheMiss?: boolean }) { if (!storageKey) return fallback; - if (resourceIdFromStorageKey(storageKey)) { + const resourceId = resourceIdFromStorageKey(storageKey); + if (resourceId) { const cached = await getCachedResourceObjectUrl(storageKey).catch(() => ""); if (cached) return cached; if (options?.cacheMiss) { const populated = await cacheResourceObjectUrl(storageKey).catch(() => ""); if (populated) return populated; } - return resolveResourceUrl(storageKey, fallback); + return resourceFileUrl(resourceId); } const cached = objectUrls.get(storageKey); if (cached) return cached; diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts index d8d007afd..545e4c339 100644 --- a/web/src/services/user-data-sync.ts +++ b/web/src/services/user-data-sync.ts @@ -1,6 +1,6 @@ import { getMediaBlob } from "@/services/file-storage"; import { getImageBlob, resolveImageUrl } from "@/services/image-storage"; -import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteAsset, getRemoteCanvasProject, listRemoteAssets, listRemoteCanvasProjects, upsertRemoteAsset, upsertRemoteCanvasProject, type RemoteUserDataSummary } from "@/services/api/user-data"; +import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteUserDataSnapshot, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data"; import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources"; import type { Asset } from "@/stores/use-asset-store"; import { useAssetStore } from "@/stores/use-asset-store"; @@ -23,17 +23,13 @@ export async function syncRemoteUserData(userId?: string | null) { if (!activeRemoteUserId) return; applyingRemoteState = true; try { - const [remoteCanvas, remoteAssets] = await Promise.all([listRemoteCanvasProjects(), listRemoteAssets()]); - remoteProjectVersions = versionMap(remoteCanvas.projects); - remoteAssetVersions = versionMap(remoteAssets.assets); + const remoteSnapshot = await getRemoteUserDataSnapshot(); + remoteProjectVersions = versionMap(remoteSnapshot.projects); + remoteAssetVersions = versionMap(remoteSnapshot.assets); const localProjects = useCanvasStore.getState().projects; const localAssets = useAssetStore.getState().assets; - const [changedProjects, changedAssets] = await Promise.all([ - fetchNewerRemoteItems(localProjects, remoteCanvas.projects, async (id) => (await getRemoteCanvasProject(id)).project), - fetchNewerRemoteItems(localAssets, remoteAssets.assets, async (id) => (await getRemoteAsset(id)).asset), - ]); - const mergedProjects = mergeById(localProjects, changedProjects); - const mergedAssets = mergeById(localAssets, await hydrateAssets(changedAssets)); + const mergedProjects = mergeById(localProjects, remoteSnapshot.projects); + const mergedAssets = mergeById(localAssets, await hydrateAssets(remoteSnapshot.assets)); useCanvasStore.getState().replaceProjects(mergedProjects); useAssetStore.getState().replaceAssets(mergedAssets); } finally { @@ -128,8 +124,8 @@ async function saveRemoteUserDataBatch() { try { const currentProjects = useCanvasStore.getState().projects; const currentAssets = useAssetStore.getState().assets; - const dirtyProjects = currentProjects.filter((item) => remoteProjectVersions.get(item.id) !== item.updatedAt); - const dirtyAssets = currentAssets.filter((item) => remoteAssetVersions.get(item.id) !== item.updatedAt); + const dirtyProjects = currentProjects.filter((item) => !sameVersion(remoteProjectVersions.get(item.id), item.updatedAt)); + const dirtyAssets = currentAssets.filter((item) => !sameVersion(remoteAssetVersions.get(item.id), item.updatedAt)); const deletedProjectIds = missingIds(remoteProjectVersions, currentProjects); const deletedAssetIds = missingIds(remoteAssetVersions, currentAssets); if (!dirtyProjects.length && !dirtyAssets.length && !deletedProjectIds.length && !deletedAssetIds.length) return; @@ -278,17 +274,8 @@ function mergeById(local: T[], re return Array.from(items.values()).sort((a, b) => timeValue(b.updatedAt) - timeValue(a.updatedAt)); } -async function fetchNewerRemoteItems(local: T[], remote: RemoteUserDataSummary[], fetchItem: (id: string) => Promise) { - const localById = new Map(local.map((item) => [item.id, item])); - const pending = remote.filter((item) => { - const current = localById.get(item.id); - return !current || timeValue(item.updatedAt) > timeValue(current.updatedAt); - }); - return Promise.all(pending.map((item) => fetchItem(item.id))); -} - -function versionMap(items: RemoteUserDataSummary[]) { - return new Map(items.map((item) => [item.id, item.updatedAt])); +function versionMap(items: Array<{ id: string; updatedAt?: string }>) { + return new Map(items.map((item) => [item.id, item.updatedAt || ""])); } function missingIds(remote: Map, local: T[]) { @@ -306,6 +293,10 @@ function timeValue(value?: string) { return Number.isFinite(time) ? time : 0; } +function sameVersion(remote?: string, local?: string) { + return Boolean(remote && local) && timeValue(remote) === timeValue(local); +} + function isLocalStorageKey(value: string) { return LOCAL_STORAGE_KEY_PATTERN.test(value) && !resourceIdFromStorageKey(value); }