perf(*): 创作工作台 - 优化首页按需加载与画布交互并修复历史迁移冲突
This commit is contained in:
66
web/src/components/layout/app-changelog-dialog.tsx
Normal file
66
web/src/components/layout/app-changelog-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")} />
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user