Files
UHD_Media_Tool/apps/web/app/routes/console/layout.tsx
T
rootandClaude Opus 5.5 177d21c26b fix(agent): 切换会话不再重挂载整页、不重播入场动画
控制台主内容区按 pathname 重挂载,每换一个会话整页透明 + 模糊再淡入,
左栏滚动、分页、展开状态全部丢失。智能体列表页与会话页共用一个 key。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 17:56:52 +08:00

266 lines
11 KiB
TypeScript

import type { SessionInfo } from "@umt/contracts";
import { ChevronLeft, ChevronRight, Clapperboard, LoaderCircle, Menu } from "lucide-react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Link, Outlet, useLocation, useMatches } from "react-router";
import { useSession } from "~/api/auth.ts";
import { AgentDrawer, AgentDrawerTrigger } from "~/components/agent/agent-drawer.tsx";
import { outletKey } from "~/components/agent/agent-store.ts";
import { AccountMenu } from "~/components/console/account-menu.tsx";
import { AgentLauncher, AgentNavEntry } from "~/components/console/agent-launcher.tsx";
import { ErrorState } from "~/components/console/empty-state.tsx";
import { InboxBell } from "~/components/console/inbox-bell.tsx";
import { findNavItem, NAV_GROUPS, type NavGroup, pageTitle } from "~/components/console/nav.ts";
import PrismLeak from "~/components/prism-leak.tsx";
import { CommandPalette } from "~/components/search/command-palette.tsx";
import { SearchTrigger } from "~/components/search/search-trigger.tsx";
import { ThemeToggle } from "~/components/theme-toggle.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "~/components/ui/sheet.tsx";
import { redirectToLogin } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
const PORTAL_NAME = "媒体自动化";
// ─── 展开态导航:选中项由一枚玻璃滑块(seg-thumb)滑到激活链接背后 ───
function ExpandedNav({ groups, onNavigate }: { groups: NavGroup[]; onNavigate?: () => void }) {
const location = useLocation();
const activeTo = findNavItem(location.pathname)?.to;
const navRef = useRef<HTMLElement>(null);
const [pill, setPill] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const measure = useCallback(() => {
const nav = navRef.current;
if (!nav) return;
const active = nav.querySelector<HTMLElement>('[aria-current="page"]');
if (!active) {
setPill(null);
return;
}
const navRect = nav.getBoundingClientRect();
const rect = active.getBoundingClientRect();
setPill({ x: rect.left - navRect.left, y: rect.top - navRect.top + nav.scrollTop, w: rect.width, h: rect.height });
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: 路由变化时重新量测
useLayoutEffect(() => {
measure();
}, [measure, location.pathname]);
useEffect(() => {
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, [measure]);
return (
<nav ref={navRef} className="relative flex-1 space-y-4 overflow-y-auto px-2.5 py-4">
{pill ? (
<span
aria-hidden
className="tab-slider seg-thumb pointer-events-none absolute top-0 left-0 -z-[1] rounded-lg"
style={{ transform: `translate(${pill.x}px, ${pill.y}px)`, width: pill.w, height: pill.h }}
/>
) : null}
<AgentNavEntry onNavigate={onNavigate} />
{groups.map((group) => (
<div key={group.title}>
<p className="mb-1 px-2 text-micro font-semibold tracking-[0.15em] text-muted-foreground/75 uppercase">
{group.title}
</p>
<div className="space-y-0.5">
{group.items.map((item) => {
const active = item.to === activeTo;
return (
<Link
key={item.to}
to={item.to}
onClick={onNavigate}
aria-current={active ? "page" : undefined}
className={cn(
"flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-base",
active
? "text-foreground"
: "text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground",
)}
>
<item.icon className={cn("h-4 w-4 shrink-0", active && "text-primary")} />
<span className="flex-1 truncate">{item.label}</span>
</Link>
);
})}
</div>
</div>
))}
</nav>
);
}
// ─── 折叠态:只显示图标,组间细线分隔 ───
function CollapsedNav() {
const location = useLocation();
const activeTo = findNavItem(location.pathname)?.to;
return (
<nav className="flex flex-1 flex-col items-center gap-1 overflow-y-auto py-4">
<AgentNavEntry collapsed />
{NAV_GROUPS.map((group, gi) => (
<div key={group.title} className="flex flex-col items-center gap-1">
{gi > 0 ? <span aria-hidden className="my-1 h-px w-6 bg-border" /> : null}
{group.items.map((item) => {
const active = item.to === activeTo;
return (
<Link
key={item.to}
to={item.to}
title={item.label}
aria-current={active ? "page" : undefined}
className={cn(
"relative flex h-10 w-10 items-center justify-center rounded-xl transition-colors duration-micro",
active
? "seg-thumb text-primary"
: "text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground",
)}
>
<item.icon className="h-[18px] w-[18px]" />
</Link>
);
})}
</div>
))}
</nav>
);
}
function Brand({ collapsed = false }: { collapsed?: boolean }) {
return (
<Link
to="/console"
className={cn(
"flex items-center transition-opacity hover:opacity-80",
collapsed ? "w-full justify-center" : "gap-2",
)}
>
<span className="grid h-7 w-7 place-items-center rounded-lg pill-selected">
<Clapperboard className="h-4 w-4" />
</span>
{!collapsed && <p className="text-xs font-bold tracking-wide text-foreground">UHD Media Tool</p>}
</Link>
);
}
/** 会话探测:未登录跳登录页;探测中/失败给全屏占位 */
export default function ConsoleLayout() {
const session = useSession();
const unauthenticated = session.data && !session.data.authenticated;
useEffect(() => {
if (unauthenticated) redirectToLogin();
}, [unauthenticated]);
if (session.isError) {
return (
<div className="grid min-h-screen place-items-center bg-background px-4">
<ErrorState title="无法获取登录状态" error={session.error} onRetry={() => session.refetch()} />
</div>
);
}
if (!session.data || unauthenticated) {
return (
<div className="grid min-h-screen place-items-center bg-background" role="status" aria-label="正在确认登录状态">
<LoaderCircle className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
return <ConsoleShell session={session.data} />;
}
function ConsoleShell({ session }: { session: SessionInfo }) {
const location = useLocation();
const [collapsed, setCollapsed] = useState(false);
const [sheetOpen, setSheetOpen] = useState(false);
const activeLabel = pageTitle(location.pathname) ?? PORTAL_NAME;
// 路由 handle.layout === "wide"(文件管理):内容区不限最大宽度、不留外边距,页面自己排版
const wide = useMatches().some((m) => (m.handle as { layout?: string } | undefined)?.layout === "wide");
return (
<div className="app-scrollbars relative isolate flex min-h-screen overflow-x-clip bg-background">
<div className="pointer-events-none fixed inset-0 -z-10 bg-page-glow" />
<PrismLeak className="-z-10" />
{/* 桌面端侧栏 */}
<aside
className={cn(
"pg-chrome pg-chrome-side fixed inset-y-0 left-0 z-30 hidden flex-col border-r transition-all duration-layout select-none xl:flex",
collapsed ? "w-[60px]" : "w-60",
)}
>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="absolute top-3 -right-4 z-40 flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border border-[var(--shell-border)] bg-background text-muted-foreground shadow-md transition-all duration-base hover:scale-110 hover:bg-foreground/[0.04] hover:text-foreground"
title={collapsed ? "展开侧边栏" : "收起侧边栏"}
>
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
</button>
<div
className={cn(
"flex h-14 shrink-0 items-center border-b border-[var(--shell-border)]",
collapsed ? "justify-center px-0" : "px-4",
)}
>
<Brand collapsed={collapsed} />
</div>
{collapsed ? <CollapsedNav /> : <ExpandedNav groups={NAV_GROUPS} />}
</aside>
{/* 主内容区 */}
<div
className={cn(
"relative flex min-h-screen min-w-0 flex-1 flex-col transition-all duration-layout",
collapsed ? "xl:ml-[60px]" : "xl:ml-60",
)}
>
<header className="pg-chrome sticky top-0 z-20 flex h-14 items-center border-b px-4 md:px-6">
<div className="flex min-w-0 items-center gap-3">
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 xl:hidden" aria-label="打开导航">
<Menu className="h-4 w-4" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-64 p-0 select-none">
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4">
<SheetTitle className="text-sm">
<Brand />
</SheetTitle>
</SheetHeader>
<ExpandedNav groups={NAV_GROUPS} onNavigate={() => setSheetOpen(false)} />
</SheetContent>
</Sheet>
<span className="truncate text-sm font-semibold text-foreground">{activeLabel}</span>
</div>
<div className="ml-auto flex items-center gap-2">
<SearchTrigger />
{/* 桌面有侧栏主入口与右下角悬浮按钮,顶栏小按钮只在窄屏保留 */}
<InboxBell />
<AgentDrawerTrigger className="xl:hidden" />
<ThemeToggle />
<AccountMenu session={session} />
</div>
</header>
{/* 底部留出悬浮按钮的高度,页面末尾的内容能滚到它上面 */}
<main className={cn("relative flex-1 overflow-x-clip", !wide && "px-4 pt-6 pb-24 md:px-6 md:pt-8")}>
<div
key={outletKey(location.pathname)}
className={cn(!wide && "mx-auto max-w-6xl", "motion-safe:animate-blur-in")}
>
<Outlet />
</div>
</main>
<AgentDrawer />
<AgentLauncher />
<CommandPalette />
</div>
</div>
);
}