perf(*): 创作工作台 - 优化首页按需加载与画布交互并修复历史迁移冲突

This commit is contained in:
ddCat
2026-09-05 13:24:10 +08:00
parent 165b4443ab
commit 58f01d1eb6
107 changed files with 3025 additions and 1089 deletions

View File

@@ -0,0 +1,66 @@
import { Modal } from "antd";
import { motion, useReducedMotion } from "motion/react";
import { ScrollText } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { aceternityMotion } from "@/lib/aceternity-motion";
export function AppChangelogDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const reducedMotion = useReducedMotion();
const version = `v${__APP_VERSION__.replace(/^v/, "")}`;
return (
<Modal
rootClassName="app-spatial-modal app-changelog-modal"
title={
<div className="app-changelog-heading">
<span className="app-changelog-heading-icon">
<ScrollText className="size-4" />
</span>
<div className="app-changelog-heading-copy">
<div className="app-changelog-heading-title">更新日志</div>
<div className="app-changelog-heading-description">按版本查看产品能力、交互与稳定性变化</div>
</div>
<span className="app-changelog-current-version">当前版本 {version}</span>
</div>
}
open={open}
width={820}
footer={null}
centered
onCancel={onClose}
styles={{ body: { padding: 0 } }}
modalRender={(node) => (
<motion.div initial={reducedMotion ? false : { opacity: 0, y: 14, scale: 0.975 }} animate={{ opacity: 1, y: 0, scale: 1 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }}>
{node}
</motion.div>
)}
>
<div className="app-changelog-scroll thin-scrollbar">
<ReactMarkdown
components={{
h1: () => null,
h2: ({ children }) => {
const label = String(children);
const latest = label === "Unreleased";
return (
<h3 className={`app-changelog-section-heading${latest ? " is-latest" : ""}`}>
<span className="app-changelog-section-marker" aria-hidden="true" />
<span>{latest ? "开发中" : label}</span>
{latest ? <span className="app-changelog-latest-badge">最新</span> : null}
</h3>
);
},
ul: ({ children }) => <ul className="app-changelog-list">{children}</ul>,
li: ({ children }) => <li>{children}</li>,
p: ({ children }) => <p className="app-changelog-paragraph">{children}</p>,
code: ({ children }) => <code className="app-changelog-code">{children}</code>,
}}
>
{__APP_CHANGELOG__}
</ReactMarkdown>
</div>
</Modal>
);
}

View File

@@ -1,10 +1,7 @@
import { Modal } from "antd";
import { motion, useReducedMotion } from "motion/react";
import { ScrollText } from "lucide-react";
import { useState, type CSSProperties, type ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import { lazy, Suspense, useState, type CSSProperties, type ReactNode } from "react";
import { aceternityMotion } from "@/lib/aceternity-motion";
const AppChangelogDialog = lazy(() => import("@/components/layout/app-changelog-dialog").then((module) => ({ default: module.AppChangelogDialog })));
export const APP_VERSION = __APP_VERSION__;
@@ -29,67 +26,7 @@ export function AppChangelogButton({ className, style, showVersion = false, show
{showLabel ? <span className={`whitespace-nowrap ${labelClassName || ""}`}>{label}</span> : null}
{showVersion ? <span className={versionClassName}>v{APP_VERSION.replace(/^v/, "")}</span> : null}
</button>
<AppChangelogModal open={open} onClose={() => setOpen(false)} />
{open ? <Suspense fallback={null}><AppChangelogDialog open onClose={() => setOpen(false)} /></Suspense> : null}
</>
);
}
function AppChangelogModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const reducedMotion = useReducedMotion();
const version = `v${APP_VERSION.replace(/^v/, "")}`;
return (
<Modal
rootClassName="app-spatial-modal app-changelog-modal"
title={
<div className="app-changelog-heading">
<span className="app-changelog-heading-icon">
<ScrollText className="size-4" />
</span>
<div className="app-changelog-heading-copy">
<div className="app-changelog-heading-title">更新日志</div>
<div className="app-changelog-heading-description">按版本查看产品能力、交互与稳定性变化</div>
</div>
<span className="app-changelog-current-version">当前版本 {version}</span>
</div>
}
open={open}
width={820}
footer={null}
centered
onCancel={onClose}
styles={{ body: { padding: 0 } }}
modalRender={(node) => (
<motion.div initial={reducedMotion ? false : { opacity: 0, y: 14, scale: 0.975 }} animate={{ opacity: 1, y: 0, scale: 1 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }}>
{node}
</motion.div>
)}
>
<div className="app-changelog-scroll thin-scrollbar">
<ReactMarkdown
components={{
h1: () => null,
h2: ({ children }) => {
const label = String(children);
const latest = label === "Unreleased";
return (
<h3 className={`app-changelog-section-heading${latest ? " is-latest" : ""}`}>
<span className="app-changelog-section-marker" aria-hidden="true" />
<span>{latest ? "开发中" : label}</span>
{latest ? <span className="app-changelog-latest-badge">最新</span> : null}
</h3>
);
},
ul: ({ children }) => <ul className="app-changelog-list">{children}</ul>,
li: ({ children }) => <li>{children}</li>,
p: ({ children }) => <p className="app-changelog-paragraph">{children}</p>,
code: ({ children }) => <code className="app-changelog-code">{children}</code>,
}}
>
{__APP_CHANGELOG__}
</ReactMarkdown>
</div>
</Modal>
);
}

View File

@@ -1,74 +1,30 @@
import type { ReactNode } from "react";
import { useEffect } from "react";
import { lazy, Suspense, useEffect } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { App, ConfigProvider } from "antd";
import zhCN from "antd/locale/zh_CN";
import { AuthSessionHydrator } from "@/components/auth/auth-session-hydrator";
import { ClientRootInit } from "@/components/layout/client-root-init";
import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader";
import { getAntThemeConfig } from "@/lib/app-theme";
import { applySkinTheme } from "@/lib/skin-themes";
import { appQueryClient } from "@/lib/query-client";
import { useThemeStore } from "@/stores/use-theme-store";
import { applyAppearanceMetadata, useAppearanceStore } from "@/stores/use-appearance-store";
import { listRegisteredPlugins } from "@/lib/plugins/plugin-registry";
import { usePluginStore } from "@/stores/use-plugin-store";
import { fetchPluginRuntimeState, setUserPluginEnabled } from "@/services/api/plugins";
import { useUserStore } from "@/stores/use-user-store";
const ClientRootInit = lazy(() => import("@/components/layout/client-root-init").then((module) => ({ default: module.ClientRootInit })));
function ClientRootBoundary({ children }: { children: ReactNode }) {
const authenticated = useUserStore((state) => Boolean(state.user));
if (!authenticated) return children;
return <Suspense fallback={<FullScreenLoader label="正在准备创作环境" detail="连接本地能力与模型配置" />}><ClientRootInit>{children}</ClientRootInit></Suspense>;
}
export function AppProviders({ children }: { children: ReactNode }) {
const theme = useThemeStore((state) => state.theme);
const dark = theme === "dark";
const appearance = useAppearanceStore((state) => state.appearance);
const ensurePlugin = usePluginStore((state) => state.ensurePlugin);
const setRuntimeStatuses = usePluginStore((state) => state.setRuntimeStatuses);
const setPluginStates = usePluginStore((state) => state.setPluginStates);
const pluginStoreHydrated = usePluginStore((state) => state.hydrated);
const userId = useUserStore((state) => state.user?.id);
useEffect(() => {
if (!pluginStoreHydrated) return;
for (const plugin of listRegisteredPlugins()) ensurePlugin(plugin.manifest);
}, [ensurePlugin, pluginStoreHydrated, userId]);
useEffect(() => {
if (!userId) {
setRuntimeStatuses({});
setPluginStates({});
return;
}
if (!pluginStoreHydrated) return;
let cancelled = false;
void fetchPluginRuntimeState()
.then(async ({ statuses, states }) => {
const legacyEnabledIds = usePluginStore
.getState()
.installations.filter((installation) => installation.enabled && states[installation.manifest.id]?.canToggle && !states[installation.manifest.id]?.userConfigured)
.map((installation) => installation.manifest.id);
if (legacyEnabledIds.length) {
try {
const migrated = await Promise.all(legacyEnabledIds.map((pluginId) => setUserPluginEnabled(pluginId, true)));
for (const state of migrated) states[state.pluginId] = state;
for (const pluginId of legacyEnabledIds) statuses[pluginId] = states[pluginId]?.effectiveEnabled ? "enabled" : "disabled";
} catch (error) {
console.warn("迁移用户插件启用状态失败,已保留服务端状态", error);
}
}
if (!cancelled) {
setRuntimeStatuses(statuses);
setPluginStates(states);
}
})
.catch(() => {
if (!cancelled) {
setRuntimeStatuses({});
setPluginStates({});
}
});
return () => {
cancelled = true;
};
}, [pluginStoreHydrated, setPluginStates, setRuntimeStatuses, userId]);
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
@@ -90,7 +46,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
children
) : (
<AuthSessionHydrator>
<ClientRootInit>{children}</ClientRootInit>
<ClientRootBoundary>{children}</ClientRootBoundary>
</AuthSessionHydrator>
)}
</QueryClientProvider>

View File

@@ -1,8 +1,7 @@
import { useEffect, useState, type ReactNode } from "react";
import { lazy, Suspense, useEffect, useState, type ReactNode } from "react";
import { useLocation, useNavigate } from "react-router";
import { ModelSetupGuide } from "@/components/layout/model-setup-guide";
import { WorkspaceCommandPalette } from "@/components/layout/workspace-command-palette";
import { WorkspaceSidebarNav } from "@/components/layout/workspace-sidebar-nav";
import { readWorkspaceSidebarCollapsed, writeWorkspaceSidebarCollapsed } from "@/components/layout/workspace-sidebar-state";
import { WorkspaceTopBar } from "@/components/layout/workspace-top-bar";
@@ -10,6 +9,8 @@ import { WorkspaceTopBarExtensionProvider } from "@/components/layout/workspace-
import { cn } from "@/lib/utils";
import { isSpatialWorkbenchPath } from "@/lib/workspace-routes";
const WorkspaceCommandPalette = lazy(() => import("@/components/layout/workspace-command-palette").then((module) => ({ default: module.WorkspaceCommandPalette })));
export function AppWorkspaceShell({ children }: { children: ReactNode }) {
const { pathname } = useLocation();
const navigate = useNavigate();
@@ -98,7 +99,7 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
</div>
</div>
<WorkspaceCommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
{paletteOpen ? <Suspense fallback={null}><WorkspaceCommandPalette open onClose={() => setPaletteOpen(false)} /></Suspense> : null}
</div>
</WorkspaceTopBarExtensionProvider>
<ModelSetupGuide hidden={pathname === "/login" || pathname === "/register" || pathname.startsWith("/admin")} />

View File

@@ -7,7 +7,10 @@ import { navigateToSettings } from "@/lib/settings-navigation";
import { useLocalDreaminaModelBootstrap } from "@/stores/use-local-dreamina-model-store";
import { useLocalRuntimeBootstrap } from "@/stores/use-local-runtime-store";
import { initializeClientDiagnostics, setDiagnosticUserScope } from "@/services/diagnostics/client-diagnostics";
import { fetchPluginRuntimeState, setUserPluginEnabled } from "@/services/api/plugins";
import { usePluginStore } from "@/stores/use-plugin-store";
import { useUserStore } from "@/stores/use-user-store";
import { appQueryClient } from "@/lib/query-client";
export function ClientRootInit({ children }: { children: ReactNode }) {
const config = useConfigStore((state) => state.config);
@@ -18,6 +21,51 @@ export function ClientRootInit({ children }: { children: ReactNode }) {
const { message } = App.useApp();
const handledConfigParams = useRef(false);
const updateConfig = useConfigStore((state) => state.updateConfig);
const setRuntimeStatuses = usePluginStore((state) => state.setRuntimeStatuses);
const setPluginStates = usePluginStore((state) => state.setPluginStates);
const pluginStoreHydrated = usePluginStore((state) => state.hydrated);
useEffect(() => () => {
usePluginStore.getState().setRuntimeStatuses({});
usePluginStore.getState().setPluginStates({});
}, []);
useEffect(() => {
if (!userId || !pluginStoreHydrated) return;
let cancelled = false;
void appQueryClient.fetchQuery({ queryKey: ["plugin-runtime", userId], queryFn: fetchPluginRuntimeState, staleTime: 30_000 })
.then(async (runtime) => {
if (cancelled || useUserStore.getState().user?.id !== userId) return;
const statuses = { ...runtime.statuses };
const states = { ...runtime.states };
const legacyEnabledIds = usePluginStore
.getState()
.installations.filter((installation) => installation.enabled && states[installation.manifest.id]?.canToggle && !states[installation.manifest.id]?.userConfigured)
.map((installation) => installation.manifest.id);
if (legacyEnabledIds.length) {
try {
const migrated = await Promise.all(legacyEnabledIds.map((pluginId) => setUserPluginEnabled(pluginId, true)));
for (const state of migrated) states[state.pluginId] = state;
for (const pluginId of legacyEnabledIds) statuses[pluginId] = states[pluginId]?.effectiveEnabled ? "enabled" : "disabled";
} catch (error) {
console.warn("迁移用户插件启用状态失败,已保留服务端状态", error);
}
}
if (!cancelled && useUserStore.getState().user?.id === userId) {
setRuntimeStatuses(statuses);
setPluginStates(states);
}
})
.catch(() => {
if (!cancelled) {
setRuntimeStatuses({});
setPluginStates({});
}
});
return () => {
cancelled = true;
};
}, [pluginStoreHydrated, setPluginStates, setRuntimeStatuses, userId]);
useEffect(() => {
initializeClientDiagnostics();

View File

@@ -1,13 +1,14 @@
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Tag } from "antd";
import { Bell } from "lucide-react";
import { useState, type CSSProperties } from "react";
import { lazy, Suspense, useState, type CSSProperties } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AnnouncementTimelineModal } from "@/components/ui/aceternity/announcement-timeline-modal";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { getAnnouncementFeed, markAnnouncementsRead } from "@/services/api/announcements";
const AnnouncementTimelineModal = lazy(() => import("@/components/ui/aceternity/announcement-timeline-modal").then((module) => ({ default: module.AnnouncementTimelineModal })));
const ANNOUNCEMENT_REFRESH_INTERVAL_MS = 5 * 60_000;
const ANNOUNCEMENT_CACHE_TTL_MS = 60_000;
@@ -90,7 +91,11 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel =
</span>
) : null}
</motion.button>
<AnnouncementTimelineModal open={open} announcements={announcements} loading={feedQuery.isFetching} error={announcements.length ? "" : error} onClose={() => setOpen(false)} onRetry={() => void feedQuery.refetch()} />
{open ? (
<Suspense fallback={null}>
<AnnouncementTimelineModal open announcements={announcements} loading={feedQuery.isFetching} error={announcements.length ? "" : error} onClose={() => setOpen(false)} onRetry={() => void feedQuery.refetch()} />
</Suspense>
) : null}
</>
);
}