Files
UHD_Media_Tool/apps/web/app/routes/console/layout.tsx
T
rootandClaude Opus 5.5 c9b4ceb301 fix(web): 控制台界面细节与 uhd_web 控件规范对齐
- 顶栏搜索移进右侧玻璃胶囊;输入框改淡玻璃(边线 0.46 → 0.16,悬停加深,聚焦 / 出错保留光晕)
- 新增 SegmentedControl(滑动 seg-thumb),替换站点、下载、订阅、文件、智能体列表及对话框里的实心选中块;Tabs 改为同一枚滑块
- 开关型按钮统一 outline + aria-pressed 选中外观;页头 / 工具栏次操作描边、图标钮描边圆;文件排序改 Select;下载总速度加标签
- 分页一页放得下时只留总数、空列表不渲染、按容器宽度收起按钮
- 运行状态存储区重排;智能体列表页与会话页共用外壳路由,标题与左栏不再重挂载
- 站点卡片「⋯」不再单独换行;设置导航、状态计数、多选条件改玻璃选中态

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 12:02:48 +08:00

287 lines
12 KiB
TypeScript

import type { SessionInfo } from "@umt/contracts";
import { Clapperboard, LoaderCircle, Menu, PanelLeftClose, PanelLeftOpen } 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 { LiquidSurface } from "~/components/liquid-surface.tsx";
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({ className }: { className?: string }) {
return (
<Link to="/console" className={cn("flex items-center gap-2 transition-opacity hover:opacity-80", className)}>
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg pill-selected">
<Clapperboard className="h-4 w-4" />
</span>
<p className="truncate 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" />
{/* 桌面端侧栏 */}
{/* 侧栏贴边,不做浮起卡片;右边不画硬分隔线,由 pg-chrome-side 给内侧细亮边、外侧细暗线和淡投影 */}
<aside
className={cn(
"pg-chrome pg-chrome-side fixed inset-y-0 left-0 z-30 hidden flex-col transition-all duration-layout select-none xl:flex",
collapsed ? "w-[60px]" : "w-60",
)}
>
{/* 标志行 + 折叠开关:展开时开关在名称右侧;收起后只剩标志,悬停换成展开图标,点一下展开 */}
<div
className={cn(
"flex h-14 shrink-0 items-center border-b border-[var(--shell-border)] transition-all duration-layout",
collapsed ? "justify-center px-0" : "gap-2 pr-2 pl-4",
)}
>
{collapsed ? (
<button
type="button"
onClick={() => setCollapsed(false)}
title="展开侧边栏"
aria-label="展开侧边栏"
className="group/logo relative grid h-9 w-9 cursor-pointer place-items-center rounded-full transition-colors duration-base hover:bg-foreground/[0.06]"
>
<span className="grid h-7 w-7 place-items-center rounded-lg pill-selected transition-opacity duration-base group-hover/logo:opacity-0">
<Clapperboard className="h-4 w-4" />
</span>
<PanelLeftOpen className="absolute h-[18px] w-[18px] text-muted-foreground opacity-0 transition-opacity duration-base group-hover/logo:opacity-100" />
</button>
) : (
<>
<Brand className="min-w-0 flex-1" />
<button
type="button"
onClick={() => setCollapsed(true)}
title="收起侧边栏"
aria-label="收起侧边栏"
className="grid h-8 w-8 shrink-0 cursor-pointer place-items-center rounded-full text-muted-foreground transition-colors duration-base hover:bg-foreground/[0.06] hover:text-foreground"
>
<PanelLeftClose className="h-[18px] w-[18px]" />
</button>
</>
)}
</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="sticky top-0 z-20 flex h-14 items-center bg-gradient-to-b from-background/90 via-background/60 to-transparent 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">
<LiquidSurface tier="regular" radius={20} noRefraction style={{ width: "auto", height: "auto" }}>
<div className="flex items-center gap-0.5 p-1">
<SearchTrigger className="mr-1" />
<InboxBell bare />
{/* 桌面有侧栏主入口与右下角悬浮按钮,顶栏的智能体按钮只在窄屏保留 */}
<AgentDrawerTrigger className="xl:hidden" />
<ThemeToggle bare />
<AccountMenu session={session} bare />
</div>
</LiquidSurface>
</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>
);
}