fix(platform): 工作区体验 - 避免页面切换全屏加载并优化启动状态与积分摘要

This commit is contained in:
ddCat
2026-08-15 13:51:15 +08:00
parent b2a7eef35d
commit d3dda8998c
8 changed files with 397 additions and 133 deletions

View File

@@ -4,6 +4,7 @@ import { useEffect } from "react";
import { applyUserSession } from "@/lib/user-session";
import { getAuthSession } from "@/services/api/auth";
import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader";
import { preloadWorkspaceRoute } from "@/lib/workspace-route-modules";
import { useUserStore } from "@/stores/use-user-store";
export function AuthSessionHydrator({ children }: { children: ReactNode }) {
@@ -11,6 +12,8 @@ export function AuthSessionHydrator({ children }: { children: ReactNode }) {
useEffect(() => {
let cancelled = false;
// 登录态与当前工作区 chunk 并行恢复,避免进入应用后再出现一次页面级等待。
preloadWorkspaceRoute(window.location.pathname);
getAuthSession()
.then(async (payload) => {
if (!cancelled) await applyUserSession(payload);

View File

@@ -9,6 +9,7 @@ import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-
import { cn } from "@/lib/utils";
import { refreshFeatureAvailability } from "@/lib/user-session";
import { isSpatialWorkbenchPath } from "@/lib/workspace-routes";
import { preloadWorkspaceRoute } from "@/lib/workspace-route-modules";
import { useUserStore } from "@/stores/use-user-store";
export function AppWorkspaceShell({ children }: { children: ReactNode }) {
@@ -60,6 +61,9 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
to={`/${tool.slug}`}
title={tool.label}
onClick={handleNavClick}
onFocus={() => preloadWorkspaceRoute(tool.slug)}
onPointerDown={() => preloadWorkspaceRoute(tool.slug)}
onPointerEnter={() => preloadWorkspaceRoute(tool.slug)}
className={cn(
"app-workspace-nav-link app-workspace-rail-tile grid size-10 place-items-center rounded-md text-[var(--fs-tiny)] transition-colors",
active ? "is-active font-medium" : "",
@@ -167,6 +171,9 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
to={`/${tool.slug}`}
title={tool.label}
onClick={handleNavClick}
onFocus={() => preloadWorkspaceRoute(tool.slug)}
onPointerDown={() => preloadWorkspaceRoute(tool.slug)}
onPointerEnter={() => preloadWorkspaceRoute(tool.slug)}
className={cn("app-workspace-nav-link relative mb-1 flex h-11 shrink-0 items-center gap-3 rounded-md px-2.5 text-[var(--fs-body)] transition-colors", active ? "is-active font-medium" : "text-foreground/55 hover:bg-surface-hover hover:text-foreground/85")}
>
<Icon className="app-workspace-nav-icon shrink-0" strokeWidth={1.8} />

View File

@@ -1,6 +1,5 @@
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { cn } from "@/lib/utils";
type FullScreenLoaderProps = {
@@ -9,69 +8,91 @@ type FullScreenLoaderProps = {
className?: string;
};
export function FullScreenLoader({ label = "正在恢复创作空间", detail = "同步账号、模型和项目数据", className }: FullScreenLoaderProps) {
const reducedMotion = useReducedMotion();
export function FullScreenLoader({ label = "正在恢复工作区", detail = "同步账号、模型和项目数据", className }: FullScreenLoaderProps) {
return (
<motion.div
<div
data-full-screen-loader
role="status"
aria-live="polite"
aria-label={`${label},${detail}`}
initial={reducedMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: aceternityMotion.duration.state, ease: aceternityMotion.easing.enter }}
className={cn("fixed inset-0 z-[var(--loader-z)] grid min-h-dvh place-items-center overflow-hidden bg-[#f5f6f8] px-6 text-stone-950 dark:bg-[#0b0c0f] dark:text-white", className)}
className={cn("full-screen-loader", className)}
>
<div
aria-hidden
className="absolute inset-0 bg-[linear-gradient(rgba(15,23,42,.045)_1px,transparent_1px),linear-gradient(90deg,rgba(15,23,42,.045)_1px,transparent_1px)] bg-[size:44px_44px] dark:bg-[linear-gradient(rgba(255,255,255,.035)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,.035)_1px,transparent_1px)]"
/>
<div aria-hidden className="absolute inset-0 overflow-hidden opacity-70">
{[0, 1, 2].map((index) => (
<motion.span
key={index}
className="absolute left-[-20%] h-px w-[140%] origin-center bg-current opacity-[0.08] dark:opacity-[0.12]"
style={{ top: `${30 + index * 20}%`, rotate: index % 2 ? -7 : 7 }}
animate={reducedMotion ? undefined : { x: ["-8%", "8%", "-8%"] }}
transition={{ duration: 4.8 + index * 0.8, repeat: Infinity, ease: "easeInOut", delay: index * -1.1 }}
/>
))}
</div>
<div className="full-screen-loader-shell">
<header className="full-screen-loader-topbar" aria-hidden="true">
<div className="full-screen-loader-topbar-group">
<span className="loader-placeholder loader-placeholder-control" />
<span className="loader-placeholder loader-placeholder-mark" />
<span className="loader-placeholder loader-placeholder-wordmark" />
</div>
<div className="full-screen-loader-topbar-group">
<span className="loader-placeholder loader-placeholder-control" />
<span className="loader-placeholder loader-placeholder-control" />
<span className="loader-placeholder loader-placeholder-control" />
</div>
</header>
<div className="relative flex w-full max-w-sm flex-col items-center text-center">
<div className="relative grid size-24 place-items-center">
<motion.span
aria-hidden
className="absolute inset-1 rounded-[28px] border border-cyan-500/28 dark:border-cyan-300/22"
animate={reducedMotion ? undefined : { rotate: 360 }}
transition={{ duration: 3.2, repeat: Infinity, ease: "linear" }}
style={{ clipPath: "polygon(0 0, 68% 0, 68% 2px, 0 2px, 0 100%)" }}
/>
<motion.span
aria-hidden
className="absolute inset-3 rounded-[var(--panel-radius-wide-tight)] border border-amber-500/30 dark:border-amber-300/22"
animate={reducedMotion ? undefined : { rotate: -360 }}
transition={{ duration: 4.1, repeat: Infinity, ease: "linear" }}
style={{ clipPath: "polygon(32% 0, 100% 0, 100% 100%, 98% 100%, 98% 2px, 32% 2px)" }}
/>
<motion.span
className="size-10 bg-current"
style={{ mask: "url(/logo.svg) center / contain no-repeat", WebkitMask: "url(/logo.svg) center / contain no-repeat" }}
animate={reducedMotion ? undefined : { opacity: [0.58, 1, 0.58], scale: [0.96, 1, 0.96] }}
transition={{ duration: 1.8, repeat: Infinity, ease: "easeInOut" }}
/>
</div>
<div className="full-screen-loader-main">
<aside className="full-screen-loader-rail" aria-hidden="true">
<span className="loader-placeholder loader-placeholder-rail-item" />
<span className="loader-placeholder loader-placeholder-rail-item" />
<span className="loader-placeholder loader-placeholder-rail-item" />
<span className="loader-placeholder loader-placeholder-rail-item" />
<span className="loader-placeholder loader-placeholder-rail-item" />
</aside>
<p className="mt-6 text-base font-semibold tracking-normal">{label}</p>
<p className="mt-2 text-sm text-stone-500 dark:text-white/42">{detail}</p>
<div aria-hidden className="relative mt-6 h-px w-44 overflow-hidden bg-stone-900/10 dark:bg-white/10">
<motion.span
className="absolute inset-y-0 left-0 w-16 bg-cyan-500 dark:bg-cyan-300"
animate={reducedMotion ? { x: 56 } : { x: [-64, 176] }}
transition={reducedMotion ? undefined : { duration: 1.15, repeat: Infinity, ease: aceternityMotion.easing.enter }}
/>
<main className="full-screen-loader-stage">
<div className="full-screen-loader-stage-header">
<div className="full-screen-loader-status">
<LoadingSignal />
<span>{label}</span>
</div>
<span className="loader-placeholder loader-placeholder-action" aria-hidden="true" />
</div>
<div className="full-screen-loader-stage-grid" aria-hidden="true">
<LoaderSurface />
<LoaderSurface />
<LoaderSurface />
<LoaderSurface />
<LoaderSurface />
<LoaderSurface />
<LoaderSurface />
<LoaderSurface />
</div>
</main>
</div>
</div>
</motion.div>
</div>
);
}
export function WorkspaceRouteLoader({ label = "正在打开页面" }: { label?: string }) {
const [visible, setVisible] = useState(false);
useEffect(() => {
const timer = window.setTimeout(() => setVisible(true), 140);
return () => window.clearTimeout(timer);
}, []);
return (
<section data-workspace-route-loader className={cn("workspace-route-loader", visible && "is-visible")} role="status" aria-live="polite" aria-label={label}>
<div className="workspace-route-loader-content">
<LoadingSignal />
<span>{label}</span>
</div>
</section>
);
}
function LoadingSignal() {
return <span className="loading-signal" aria-hidden="true" />;
}
function LoaderSurface() {
return (
<div className="full-screen-loader-surface">
<span className="loader-placeholder loader-placeholder-media" />
<span className="loader-placeholder loader-placeholder-title" />
<span className="loader-placeholder loader-placeholder-meta" />
</div>
);
}