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>
);
}

View File

@@ -0,0 +1,19 @@
const workspaceRouteLoaders = {
assets: () => import("@/pages/assets"),
canvas: () => import("@/pages/canvas"),
create: () => import("@/pages/create"),
projects: () => import("@/pages/projects"),
wallet: () => import("@/pages/wallet"),
};
export const loadAssetsPage = workspaceRouteLoaders.assets;
export const loadCanvasPage = workspaceRouteLoaders.canvas;
export const loadCreatePage = workspaceRouteLoaders.create;
export const loadProjectsPage = workspaceRouteLoaders.projects;
export const loadWalletPage = workspaceRouteLoaders.wallet;
export function preloadWorkspaceRoute(pathnameOrSlug: string) {
const slug = pathnameOrSlug.replace(/^\//, "").split("/", 1)[0] as keyof typeof workspaceRouteLoaders;
const load = workspaceRouteLoaders[slug];
if (load) void load();
}

View File

@@ -142,26 +142,20 @@ export default function WalletPage() {
</div>
<section className="library-feature-grid mt-6 grid gap-4 lg:grid-cols-[minmax(0,1.35fr)_minmax(340px,0.65fr)]">
<section className="credit-balance-card overflow-hidden rounded-lg">
<div className="wallet-balance-inner flex min-h-[210px] flex-col justify-between p-5 sm:p-6">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 text-xs font-medium opacity-60">
<Coins className="size-4" />
可用创作积分
</div>
<div className="wallet-balance-number mt-4 flex items-baseline gap-2">
<span className="text-5xl font-semibold tabular-nums">{formatCredits(account?.availableMicrocredits || 0, 6)}</span>
<span className="text-sm opacity-55">积分</span>
</div>
<div className="mt-2 text-xs opacity-45">最近更新 {formatTime(account?.updatedAt)}</div>
<section className="credit-balance-card">
<div className="wallet-balance-inner">
<div className="wallet-balance-primary">
<div className="wallet-balance-heading">
<span className="wallet-balance-icon"><Coins /></span>
<div><strong>可用创作积分</strong><span>最近更新 {formatTime(account?.updatedAt)}</span></div>
</div>
<div className="wallet-balance-number">
<strong>{formatCredits(account?.availableMicrocredits || 0, 6)}</strong>
<span>积分</span>
</div>
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-2.5 py-1 text-[var(--fs-label)] font-medium opacity-70">
<ShieldCheck className="size-3.5" />
账户正常
</span>
</div>
<div className="grid grid-cols-2 gap-3 pt-4">
<div className="wallet-balance-details">
<span className="wallet-account-status"><ShieldCheck />账户正常</span>
<BalanceMetric label="冻结积分" description="调用中或待核对" value={account?.reservedMicrocredits || 0} icon={<TicketCheck className="size-4" />} />
<BalanceMetric label="账户总额" description="可用与冻结合计" value={totalMicrocredits} icon={<Coins className="size-4" />} />
</div>
@@ -234,13 +228,9 @@ export default function WalletPage() {
function BalanceMetric({ label, description, value, icon }: { label: string; description: string; value: number; icon: ReactNode }) {
return (
<div className="flex min-w-0 items-center justify-between gap-3 px-1 py-2.5">
<div className="min-w-0">
<div className="text-[var(--fs-tiny)] opacity-48">{label}</div>
<div className="mt-0.5 truncate text-base font-semibold tabular-nums">{formatCredits(value, 6)}</div>
<div className="truncate text-[var(--fs-tiny)] opacity-35">{description}</div>
</div>
<span className="shrink-0 opacity-30">{icon}</span>
<div className="wallet-balance-metric">
<span className="wallet-balance-metric-icon">{icon}</span>
<div><span>{label}</span><strong>{formatCredits(value, 6)}</strong><small>{description}</small></div>
</div>
);
}

View File

@@ -3,7 +3,8 @@ 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 { FullScreenLoader, WorkspaceRouteLoader } from "@/components/ui/aceternity/full-screen-loader";
import { loadAssetsPage, loadCanvasPage, loadCreatePage, loadProjectsPage, loadWalletPage } from "@/lib/workspace-route-modules";
import UserLayout from "@/layouts/user-layout";
import { AuthScene } from "@/pages/auth/auth-scene";
import RouteErrorPage from "@/pages/route-error";
@@ -23,25 +24,29 @@ const DrawingEngineSettingsPage = lazy(() => import("@/pages/admin/settings/draw
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 AssetsPage = lazy(loadAssetsPage);
const LoginPage = lazy(() => import("@/pages/auth/login"));
const RegisterPage = lazy(() => import("@/pages/auth/register"));
const CanvasPage = lazy(() => import("@/pages/canvas"));
const CanvasPage = lazy(loadCanvasPage);
const CanvasProjectPage = lazy(() => import("@/pages/canvas/project"));
const SharedCanvasPage = lazy(() => import("@/pages/canvas/shared"));
const CreatePage = lazy(() => import("@/pages/create"));
const CreatePage = lazy(loadCreatePage);
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 WalletPage = lazy(loadWalletPage);
const ProjectsPage = lazy(loadProjectsPage);
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 <Suspense fallback={<FullScreenLoader label="正在打开页面" detail="仅加载当前工作区所需内容" />}>{element}</Suspense>;
return <Suspense fallback={<WorkspaceRouteLoader />}>{element}</Suspense>;
}
function fullScreenDeferred(element: ReactNode) {
return <Suspense fallback={<FullScreenLoader label="正在打开创作空间" detail="准备当前页面" />}>{element}</Suspense>;
}
export const router = createBrowserRouter([
@@ -49,11 +54,11 @@ export const router = createBrowserRouter([
element: <AuthScene />,
errorElement: <RouteErrorPage />,
children: [
{ path: "/login", element: deferred(<LoginPage />) },
{ path: "/register", element: deferred(<RegisterPage />) },
{ path: "/login", element: fullScreenDeferred(<LoginPage />) },
{ path: "/register", element: fullScreenDeferred(<RegisterPage />) },
],
},
{ path: "/share/canvas/:token", element: deferred(<SharedCanvasPage />), errorElement: <RouteErrorPage /> },
{ path: "/share/canvas/:token", element: fullScreenDeferred(<SharedCanvasPage />), errorElement: <RouteErrorPage /> },
{
element: (
<UserLayout>
@@ -102,5 +107,5 @@ export const router = createBrowserRouter([
},
],
},
{ path: "*", element: deferred(<NotFound />) },
{ path: "*", element: fullScreenDeferred(<NotFound />) },
]);

View File

@@ -1559,6 +1559,168 @@
border-radius: 4px;
}
/* 全屏仅用于应用启动;骨架延续真实工作区结构,路由加载只占内容舞台。 */
.full-screen-loader {
position: fixed;
inset: 0;
z-index: var(--loader-z);
min-height: 100dvh;
overflow: hidden;
background: var(--workspace-page);
color: var(--foreground);
animation: loader-surface-enter var(--motion-panel) var(--motion-ease-out) both;
}
.full-screen-loader-shell {
display: flex;
width: 100%;
height: 100dvh;
flex-direction: column;
}
.full-screen-loader-topbar {
display: flex;
height: var(--page-topbar-h);
flex: 0 0 var(--page-topbar-h);
align-items: center;
justify-content: space-between;
padding-inline: var(--space-5);
background: var(--workspace-navigation);
}
.full-screen-loader-topbar-group {
display: flex;
align-items: center;
gap: var(--space-2);
}
.loader-placeholder {
display: block;
background: color-mix(in srgb, var(--foreground) 6%, transparent);
}
.loader-placeholder-control,
.loader-placeholder-mark,
.loader-placeholder-rail-item {
border-radius: var(--r-md);
}
.loader-placeholder-control { width: 36px; height: 36px; }
.loader-placeholder-mark { width: 28px; height: 28px; }
.loader-placeholder-wordmark { width: 40px; height: 10px; border-radius: var(--r-full); }
.loader-placeholder-action { width: 88px; height: 32px; border-radius: var(--r-md); }
.full-screen-loader-main {
display: flex;
min-height: 0;
flex: 1 1 auto;
}
.full-screen-loader-rail {
display: flex;
width: 64px;
flex: 0 0 64px;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding-top: calc(var(--space-12) + var(--space-3));
background: var(--workspace-navigation);
}
.loader-placeholder-rail-item { width: 40px; height: 40px; }
.full-screen-loader-stage {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
padding: var(--space-8);
background: var(--workspace-content);
}
.full-screen-loader-stage-header {
display: flex;
min-height: var(--space-10);
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.full-screen-loader-status,
.workspace-route-loader-content {
display: flex;
align-items: center;
gap: var(--space-3);
color: color-mix(in srgb, var(--foreground) 54%, transparent);
font-size: var(--fs-caption);
}
.loading-signal {
width: 20px;
height: 20px;
flex: 0 0 20px;
border: 2px solid color-mix(in srgb, var(--foreground) 12%, transparent);
border-top-color: color-mix(in srgb, var(--foreground) 72%, transparent);
border-radius: var(--r-full);
animation: loading-signal-spin 900ms linear infinite;
}
.full-screen-loader-stage-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-4);
margin-top: var(--space-6);
}
.full-screen-loader-surface { min-width: 0; }
.loader-placeholder-media { aspect-ratio: 16 / 10; border-radius: var(--r-lg); }
.loader-placeholder-title { width: 58%; height: 10px; margin-top: var(--space-3); border-radius: var(--r-full); }
.loader-placeholder-meta { width: 34%; height: 8px; margin-top: var(--space-2); border-radius: var(--r-full); opacity: 0.72; }
.workspace-route-loader {
position: absolute;
inset: 0;
display: grid;
min-height: 100%;
place-items: center;
background: var(--workspace-page);
color: var(--foreground);
opacity: 0;
transition: opacity var(--motion-instant) ease-out;
}
.workspace-route-loader.is-visible { opacity: 1; }
.workspace-route-loader-content { justify-content: center; }
@keyframes loader-surface-enter {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes loading-signal-spin {
to { transform: rotate(360deg); }
}
@media (max-width: 1023px) {
.full-screen-loader-rail { display: none; }
.full-screen-loader-stage { padding: var(--space-6); }
.full-screen-loader-stage-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 639px) {
.full-screen-loader-topbar { padding-inline: var(--space-4); }
.full-screen-loader-topbar-group:last-child .loader-placeholder-control:not(:last-child) { display: none; }
.full-screen-loader-stage { padding: var(--space-5) var(--space-4); }
.full-screen-loader-stage-grid { grid-template-columns: 1fr; }
.full-screen-loader-stage-grid .full-screen-loader-surface:nth-child(n + 3) { display: none; }
.loader-placeholder-action { width: var(--space-10); }
}
@media (prefers-reduced-motion: reduce) {
.full-screen-loader { animation: none; }
.loading-signal { animation: none; }
.workspace-route-loader { transition: none; }
}
:where(.ant-btn) {
letter-spacing: 0;
transition:
@@ -3405,13 +3567,6 @@
box-shadow: 0 18px 54px color-mix(in srgb, var(--workspace-canvas) 62%, transparent), inset 0 1px 0 color-mix(in srgb, white 7%, transparent);
}
.credit-balance-card {
border: 0;
background: var(--btn-solid-bg);
color: var(--btn-solid-fg);
box-shadow: none;
}
.app-collection-card {
position: relative;
overflow: hidden;
@@ -4200,10 +4355,50 @@
.skill-card-added-count { flex: 0 0 auto; color: color-mix(in srgb, var(--foreground) 36%, transparent); font-size: var(--fs-label); font-variant-numeric: tabular-nums; }
.skill-card-owner-flag { flex: 1 1 auto; color: color-mix(in srgb, var(--foreground) 46%, transparent); font-size: var(--fs-caption); }
/* 积分页使用同一工作台表面,通过排版而非反色卡片表达资产层级。 */
.wallet-library-page .app-page-header { padding-bottom: 0; }
.wallet-library-page .app-workspace-surface { border: 0; background: color-mix(in srgb, var(--workspace-surface-strong) 76%, transparent); box-shadow: none; }
.wallet-library-page .wallet-credit-meta { gap: 6px; }
.wallet-library-page .wallet-credit-meta svg { color: currentColor; }
.credit-balance-card { overflow: hidden; border: 0; border-radius: var(--r-lg); background: var(--library-surface); color: var(--foreground); box-shadow: none; }
.wallet-balance-inner { display: grid; min-height: 210px; grid-template-columns: minmax(0, 1.15fr) minmax(280px, .85fr); gap: 32px; padding: 24px; }
.wallet-balance-primary { display: flex; min-width: 0; flex-direction: column; justify-content: space-between; }
.wallet-balance-heading { display: flex; min-width: 0; align-items: center; gap: 12px; }
.wallet-balance-heading > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.wallet-balance-heading strong { font-size: var(--fs-caption); font-weight: 600; }
.wallet-balance-heading div > span { overflow: hidden; color: color-mix(in srgb, var(--foreground) 44%, transparent); font-size: var(--fs-label); text-overflow: ellipsis; white-space: nowrap; }
.wallet-balance-icon { display: grid; width: 36px; height: 36px; flex: 0 0 auto; place-items: center; border-radius: var(--r-md); background: var(--library-icon-surface); color: color-mix(in srgb, var(--foreground) 68%, transparent); }
.wallet-balance-icon svg { width: 17px; height: 17px; }
.wallet-balance-number { display: flex; min-width: 0; align-items: baseline; gap: 8px; margin-top: 28px; }
.wallet-balance-number strong { min-width: 0; overflow-wrap: anywhere; font-size: var(--fs-display); font-variant-numeric: tabular-nums; font-weight: 620; line-height: 1; }
.wallet-balance-number > span { flex: 0 0 auto; color: color-mix(in srgb, var(--foreground) 48%, transparent); font-size: var(--fs-caption); }
.wallet-balance-details { display: grid; min-width: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); align-content: end; gap: 20px; }
.wallet-account-status { display: inline-flex; grid-column: 1 / -1; align-items: center; justify-self: end; gap: 6px; color: color-mix(in srgb, var(--foreground) 58%, transparent); font-size: var(--fs-label); font-weight: 560; }
.wallet-account-status svg { width: 15px; height: 15px; }
.wallet-balance-metric { display: grid; min-width: 0; grid-template-columns: 28px minmax(0, 1fr); align-items: start; gap: 9px; }
.wallet-balance-metric-icon { display: grid; width: 28px; height: 28px; place-items: center; border-radius: var(--r-sm); background: color-mix(in srgb, var(--foreground) 5%, transparent); color: color-mix(in srgb, var(--foreground) 42%, transparent); }
.wallet-balance-metric-icon svg { width: 14px; height: 14px; }
.wallet-balance-metric > div { display: flex; min-width: 0; flex-direction: column; }
.wallet-balance-metric span { color: color-mix(in srgb, var(--foreground) 50%, transparent); font-size: var(--fs-tiny); }
.wallet-balance-metric strong { margin-top: 3px; overflow: hidden; font-size: var(--fs-heading); font-variant-numeric: tabular-nums; font-weight: 620; text-overflow: ellipsis; white-space: nowrap; }
.wallet-balance-metric small { margin-top: 2px; overflow: hidden; color: color-mix(in srgb, var(--foreground) 35%, transparent); font-size: var(--fs-tiny); text-overflow: ellipsis; white-space: nowrap; }
.wallet-library-page .wallet-redeem-icon { background: color-mix(in srgb, var(--foreground) 6%, transparent); color: color-mix(in srgb, var(--foreground) 72%, transparent); }
.wallet-library-page .wallet-ledger-panel { margin-top: 28px; }
.wallet-library-page .wallet-ledger-panel .app-table-surface { border: 0; box-shadow: none; }
.wallet-library-page .wallet-ledger-table .ant-table-thead > tr > th { height: 38px; padding: 8px 12px !important; background: color-mix(in srgb, var(--foreground) 4%, var(--background)) !important; color: color-mix(in srgb, var(--foreground) 52%, transparent) !important; font-size: var(--fs-label); font-weight: 500; }
.wallet-library-page .wallet-ledger-table .ant-table-tbody > tr > td { padding: 10px 12px !important; transition: background-color var(--motion-instant) ease-out; }
.wallet-library-page .wallet-ledger-table .ant-table-tbody > tr:hover > td { background: color-mix(in srgb, var(--foreground) 2.5%, var(--background)) !important; }
@media (max-width: 1120px) {
.wallet-balance-inner { grid-template-columns: minmax(0, 1fr); gap: 24px; }
.wallet-balance-details { align-content: start; }
.wallet-account-status { grid-row: 2; grid-column: 1 / -1; justify-self: start; }
}
@media (max-width: 520px) {
.wallet-balance-inner { min-height: 0; gap: 22px; padding: 20px; }
.wallet-balance-number { margin-top: 32px; }
.wallet-balance-details { grid-template-columns: minmax(0, 1fr); gap: 16px; }
.wallet-account-status { grid-row: auto; grid-column: auto; }
}
.library-loading-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 26px 18px; margin-top: 20px; }
/* 画布骨架屏 - 使用新token */
@@ -10068,39 +10263,6 @@ html:not(.dark) .creation-home .storyboard-workbench-bar-action:hover { backgrou
.assets-cover.is-light .assets-cover-more:hover { background: color-mix(in srgb, var(--foreground) 9%, transparent); color: var(--foreground); }
.assets-cover.is-light .assets-cover-play::before { border-color: color-mix(in srgb, var(--foreground) 30%, transparent); background: color-mix(in srgb, var(--surface-card) 78%, transparent); }
/* ===== 积分中心页重构 ===== */
/* 1. 页头 · 统一 studio band(全局 .studio-band 提供) */
.wallet-library-page .wallet-credit-meta { gap: 6px; }
.wallet-library-page .wallet-credit-meta svg { color: currentColor; }
/* 2. 余额卡与其他工作台卡片一样不使用装饰边线和侧条。 */
.wallet-library-page .wallet-balance-inner { padding-left: 0; }
.wallet-library-page .wallet-balance-number { letter-spacing: 0; }
/* 3. 兑换面板 · 去琥珀图标块 */
.wallet-library-page .wallet-redeem-icon {
background: color-mix(in srgb, var(--foreground) 6%, transparent);
color: color-mix(in srgb, var(--foreground) 72%, transparent);
}
/* 4. 流水表 · 表头收敛 + 行 hover 节奏 */
.wallet-library-page .wallet-ledger-table .ant-table-thead > tr > th {
height: 38px;
padding: 8px 12px !important;
background: color-mix(in srgb, var(--foreground) 4%, var(--background)) !important;
color: color-mix(in srgb, var(--foreground) 52%, transparent) !important;
font-size: 11px;
font-weight: 500;
}
.wallet-library-page .wallet-ledger-table .ant-table-tbody > tr > td {
padding: 10px 12px !important;
transition: background-color var(--motion-instant) ease-out;
}
.wallet-library-page .wallet-ledger-table .ant-table-tbody > tr:hover > td {
background: color-mix(in srgb, var(--foreground) 2.5%, var(--background)) !important;
}
/* ===== AI Markdown ===== */
.ai-message-markdown {

View File

@@ -0,0 +1,57 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
function source(path: string) {
return readFileSync(resolve(import.meta.dir, path), "utf8");
}
describe("workspace route loading", () => {
test("keeps lazy workspace routes inside the workspace stage", () => {
const router = source("../src/router.tsx");
const deferred = router.slice(router.indexOf("function deferred"), router.indexOf("function fullScreenDeferred"));
expect(deferred).toContain("<WorkspaceRouteLoader />");
expect(deferred).not.toContain("FullScreenLoader");
expect(router).toContain("fullScreenDeferred(<LoginPage />)");
expect(router).toContain("fullScreenDeferred(<SharedCanvasPage />)");
});
test("preloads the reported workspace routes before navigation", () => {
const modules = source("../src/lib/workspace-route-modules.ts");
const navigation = source("../src/components/layout/app-top-nav.tsx");
for (const route of ["projects", "canvas", "assets", "wallet", "create"]) {
expect(modules).toContain(`${route}: () => import`);
}
expect(navigation).toContain("onPointerEnter={() => preloadWorkspaceRoute(tool.slug)}");
expect(navigation).toContain("onFocus={() => preloadWorkspaceRoute(tool.slug)}");
});
test("uses a quiet workspace skeleton for initial hydration", () => {
const loader = source("../src/components/ui/aceternity/full-screen-loader.tsx");
const css = source("../src/styles/globals.css");
expect(loader).toContain("full-screen-loader-topbar");
expect(loader).toContain("full-screen-loader-rail");
expect(loader).toContain("LoadingSignal");
expect(loader).not.toContain("YINGCE STUDIO");
expect(loader).not.toContain("loading-cue");
expect(css).toContain("@keyframes loading-signal-spin");
expect(css).toContain("@media (prefers-reduced-motion: reduce)");
expect(css).not.toContain("@keyframes loading-cue-pulse");
});
});
describe("wallet balance summary", () => {
test("uses the workspace surface instead of an inverted primary button surface", () => {
const css = source("../src/styles/globals.css");
const rule = css.match(/\.credit-balance-card \{[^}]+}/)?.[0] || "";
expect(rule).toContain("background: var(--library-surface)");
expect(rule).toContain("color: var(--foreground)");
expect(rule).not.toContain("--btn-solid-bg");
expect(css.match(/\.wallet-balance-inner \{/g)).toHaveLength(3);
expect(css).not.toContain(".wallet-library-page .wallet-balance-inner { padding-left: 0; }");
});
});