refactor(web): 工作区布局 - 侧栏改 260px 可折叠导航、顶栏移入主区并统一账户按钮

- 侧栏由 64px 图标 rail 改为 260px 可折叠导航:分组、设置分区子项、钱包余额徽章、⌘K 命令面板入口、激活分支自动展开
- 顶栏移入主内容区(侧栏右侧):面包屑、主题切换、公告中心、账户菜单;移除冗余搜索按钮(保留侧栏搜索与 ⌘K)
- 账户按钮与其余图标按钮视觉对齐:24px 圆角头像、占位图标复用统一图标合同、移除触发按钮角标
- 新增命令面板与登出 hook;浮层背景不透明化并收敛到唯一源规则
This commit is contained in:
sikongyue
2026-08-19 23:25:32 +08:00
parent 85aa4b3ee1
commit 54d1e6b693
8 changed files with 802 additions and 521 deletions

View File

@@ -1,47 +1,32 @@
import { Home, Infinity as InfinityIcon } from "lucide-react";
import { Fragment, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Link, useLocation, useNavigate } from "react-router";
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
import { useLocation, useNavigate } from "react-router";
import { ModelSetupGuide } from "@/components/layout/model-setup-guide";
import { WorkspaceSidebarFooter } from "@/components/layout/workspace-sidebar-footer";
import { WorkspaceCommandPalette } from "@/components/layout/workspace-command-palette";
import { WorkspaceSidebarNav } from "@/components/layout/workspace-sidebar-nav";
import { WorkspaceTopBar } from "@/components/layout/workspace-top-bar";
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
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 }) {
const { pathname } = useLocation();
const navigate = useNavigate();
const user = useUserStore((state) => state.user);
const features = useUserStore((state) => state.features);
const [mobileSidebarExpanded, setMobileSidebarExpanded] = useState(false);
const [desktopSidebarCollapsed, setDesktopSidebarCollapsed] = useState(false);
const scrollRef = useRef<HTMLElement>(null);
const [scrollState, setScrollState] = useState({
hasTopFade: false,
hasBottomFade: false,
});
const [paletteOpen, setPaletteOpen] = useState(false);
const hideChrome = pathname.startsWith("/admin") || /^\/canvas\/[^/]+/.test(pathname);
const showGlobalTopBar = !hideChrome;
const spatialWorkbench = isSpatialWorkbenchPath(pathname);
const creationWorkspace = pathname === "/create";
const slug = pathname.split("/").filter(Boolean)[0];
const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined;
const visibleNavigationTools = (spatialWorkbench ? navigationTools : navigationTools.filter((tool) => tool.section === "创作空间"))
.filter((tool) => {
if (tool.slug === "projects") return features.shortDramaEnabled;
if (tool.slug === "tasks") return features.taskCenterEnabled;
if (tool.slug === "wallet") return features.creditsEnabled;
return true;
});
const topRailNavTools = visibleNavigationTools.filter((tool) => tool.section === "创作空间" || tool.slug === "skills");
const bottomRailNavTools = visibleNavigationTools.filter((tool) => tool.slug === "wallet");
const isMobileViewport = () => window.innerWidth < 1024;
const toggleSidebar = () => {
if (window.innerWidth < 1024) {
if (isMobileViewport()) {
setMobileSidebarExpanded((current) => !current);
return;
}
@@ -49,40 +34,20 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
};
const handleNavClick = () => {
if (window.innerWidth < 1024) setMobileSidebarExpanded(false);
if (isMobileViewport()) setMobileSidebarExpanded(false);
};
const renderSidebarLink = (tool: (typeof navigationTools)[number]) => {
const Icon = tool.icon;
const active = tool.slug === activeToolSlug;
return (
<Link
key={tool.slug}
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" : "",
)}
>
<Icon className="app-workspace-nav-icon shrink-0" strokeWidth={1.8} />
</Link>
);
};
const handleScroll = () => {
const element = scrollRef.current;
if (!element) return;
const { scrollTop, scrollHeight, clientHeight } = element;
setScrollState({
hasTopFade: scrollTop > 0,
hasBottomFade: scrollTop + clientHeight < scrollHeight - 1,
});
};
// ⌘K / Ctrl+K 全局呼出搜索面板。
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setPaletteOpen((open) => !open);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
useEffect(() => {
const handleWorkspaceNavigation = (rawEvent: Event) => {
@@ -95,10 +60,6 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
return () => window.removeEventListener("workspace:navigate", handleWorkspaceNavigation);
}, [navigate]);
useEffect(() => {
handleScroll();
}, [visibleNavigationTools.length, mobileSidebarExpanded]);
useEffect(() => {
if (!user) return;
const refresh = () => void refreshFeatureAvailability().catch((error) => console.warn("功能开放状态刷新失败", error));
@@ -112,93 +73,30 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
return (
<>
<div className={cn("app-workspace-shell flex h-dvh min-h-0 w-full flex-col overflow-hidden", spatialWorkbench && "is-spatial", creationWorkspace && "is-creation-workspace")} data-sidebar-collapsed={desktopSidebarCollapsed || undefined}>
<div className={cn("app-workspace-shell flex h-dvh min-h-0 w-full flex-col overflow-hidden", spatialWorkbench && "is-spatial", creationWorkspace && "is-creation-workspace")}>
{!hideChrome && mobileSidebarExpanded ? <button type="button" className="app-workspace-sidebar-scrim lg:hidden" aria-label="收起侧栏" onClick={() => setMobileSidebarExpanded(false)} /> : null}
{showGlobalTopBar ? <WorkspaceTopBar sidebarOpen={window.innerWidth < 1024 ? mobileSidebarExpanded : !desktopSidebarCollapsed} onToggleSidebar={toggleSidebar} /> : null}
<div className="app-workspace-main-row flex min-h-0 min-w-0 flex-1 overflow-hidden">
{!hideChrome ? (
<aside className={cn("app-workspace-sidebar flex shrink-0 flex-col overflow-hidden transition-all duration-200", mobileSidebarExpanded ? "is-mobile-expanded w-[196px]" : "w-0 lg:w-[var(--workspace-sidebar-width)] lg:shrink-0")} style={{ "--workspace-sidebar-width": desktopSidebarCollapsed ? "0px" : "64px" } as CSSProperties}>
{/* 桌面:64px 电影胶片条式轨道 */}
<div className="app-workspace-rail hidden min-h-0 flex-col overflow-y-auto lg:flex">
<div className="app-workspace-rail-header flex h-16 shrink-0 items-center justify-center">
<Link to="/home" className="grid size-10 shrink-0 place-items-center rounded-md text-foreground/55 transition-colors hover:bg-surface-hover hover:text-foreground" title="工作台首页">
<Home className="app-workspace-nav-icon" strokeWidth={1.8} />
</Link>
</div>
<div className="app-workspace-rail-middle flex min-h-0 w-full flex-1 flex-col items-center justify-start overflow-y-auto px-3 pt-3">
<nav className="flex w-full flex-col items-center gap-2">
{topRailNavTools.map((tool) => renderSidebarLink(tool))}
</nav>
<nav className="mt-4 flex w-full flex-col items-center gap-2 pb-3">
{bottomRailNavTools.map((tool) => renderSidebarLink(tool))}
</nav>
</div>
<div className="app-workspace-rail-footer shrink-0 px-2 pb-4 pt-3">
<WorkspaceSidebarFooter
showAnnouncement={false}
expandedClassName="hidden"
collapsedClassName="flex h-10 w-10 items-center justify-center rounded-md"
accountClassName="justify-center rounded-md"
/>
</div>
</div>
{!hideChrome ? (
<aside
className={cn(
"app-workspace-sidebar flex h-full shrink-0 flex-col overflow-hidden",
mobileSidebarExpanded && "is-mobile-expanded",
desktopSidebarCollapsed && "is-collapsed",
)}
style={desktopSidebarCollapsed ? ({ "--workspace-sidebar-width": "0px" } as CSSProperties) : undefined}
>
<WorkspaceSidebarNav onNavigate={handleNavClick} onOpenSearch={() => setPaletteOpen(true)} />
</aside>
) : null}
{/* 移动端:抽屉完整导航 */}
<div className="app-workspace-mobile-nav flex min-h-0 flex-1 flex-col overflow-hidden lg:hidden">
<div className="flex h-14 shrink-0 items-center gap-2 px-3">
<Link to="/" className="flex min-w-0 items-center gap-2" title="影策">
<span className="app-workspace-brand-mark grid size-7 shrink-0 place-items-center rounded-md bg-foreground text-background"><InfinityIcon className="size-4" /></span>
<span className="truncate text-[var(--fs-body)] font-semibold">影策</span>
</Link>
</div>
<nav
ref={scrollRef}
onScroll={handleScroll}
className={cn(
"app-workspace-sidebar-scroll-area flex min-h-0 flex-1 flex-col px-2 py-3",
scrollState.hasTopFade && "has-top-fade",
scrollState.hasBottomFade && "has-bottom-fade",
)}
>
{visibleNavigationTools.map((tool, index) => {
const Icon = tool.icon;
const active = tool.slug === activeToolSlug;
const showSection = index === 0 || tool.section !== visibleNavigationTools[index - 1]?.section;
return (
<Fragment key={tool.slug}>
{showSection ? <div className="mb-2 px-2 text-[var(--fs-tiny)] font-medium text-foreground/34">{tool.section}</div> : null}
<Link
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} />
<span className="truncate">{tool.label}</span>
</Link>
</Fragment>
);
})}
</nav>
<div className="shrink-0 p-2">
<WorkspaceSidebarFooter
showAnnouncement={false}
expandedClassName="flex"
collapsedClassName="hidden"
accountClassName="flex-row gap-2 px-2"
/>
</div>
</div>
</aside>
) : null}
<div className="app-workspace-stage relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{showGlobalTopBar ? <WorkspaceTopBar sidebarOpen={isMobileViewport() ? mobileSidebarExpanded : !desktopSidebarCollapsed} onToggleSidebar={toggleSidebar} /> : null}
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden">{children}</div>
</div>
</div>
<div className="app-workspace-stage relative min-h-0 min-w-0 flex-1 overflow-hidden">
{children}
</div>
</div>
<WorkspaceCommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
</div>
<ModelSetupGuide hidden={pathname === "/login" || pathname === "/register" || pathname.startsWith("/admin")} />
</>

View File

@@ -0,0 +1,106 @@
import { Popover, Switch } from "antd";
import { ChevronRight, CircleUserRound, LogIn, LogOut, Moon, ShieldCheck, Sun } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { Link } from "react-router";
import { AppChangelogButton } from "@/components/layout/app-changelog-modal";
import { IdentityProviderBadge } from "@/components/layout/identity-provider-badge";
import { useWalletBalance } from "@/hooks/use-wallet-balance";
import { useWorkspaceLogout } from "@/hooks/use-workspace-logout";
import { cn } from "@/lib/utils";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore, type LocalUser } from "@/stores/use-user-store";
/** 顶部栏头像账户菜单:账户信息、管理入口、版本、深色模式与退出登录。 */
export function WorkspaceAccountMenu() {
const theme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const user = useUserStore((state) => state.user);
const hydrated = useUserStore((state) => state.hydrated);
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
const { availableMicrocredits } = useWalletBalance(user?.id, creditsEnabled);
const { handleLogout } = useWorkspaceLogout();
const [menuOpen, setMenuOpen] = useState(false);
const balance = availableMicrocredits === null
? "--"
: (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
if (!hydrated) {
return <span className="size-9 animate-pulse rounded-[var(--r-md)] bg-foreground/[.06]" aria-hidden />;
}
return user ? (
<Popover
trigger="click"
placement="bottomRight"
open={menuOpen}
onOpenChange={setMenuOpen}
content={(
<div className="w-56 py-0.5">
<div className="flex items-center gap-3 border-b border-border/65 px-1 pb-3">
<UserAvatar user={user} className="size-8" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5"><span className="truncate text-sm font-medium">{user.displayName || user.username}</span><IdentityProviderBadge user={user} /></div>
{creditsEnabled ? <div className="mt-0.5 truncate text-[var(--fs-label)] tabular-nums text-foreground/45">可用 {balance} 积分</div> : null}
</div>
</div>
{user.role === "admin" ? (
<nav className="py-2" aria-label="管理工具">
<MenuLink to="/admin" icon={<ShieldCheck />} label="管理员后台" onNavigate={() => setMenuOpen(false)} />
</nav>
) : null}
<div className="border-y border-border/65 py-2">
<AppChangelogButton className="flex h-8 w-full items-center gap-2 rounded px-2 text-[var(--fs-label)] text-foreground/58 hover:bg-surface-hover hover:text-foreground [&_svg]:size-3.5" showLabel showVersion versionClassName="ml-auto text-[var(--fs-micro)] tabular-nums text-foreground/32" />
</div>
<div className="flex h-10 items-center px-2">
{theme === "dark" ? <Moon className="size-3.5 text-foreground/45" /> : <Sun className="size-3.5 text-foreground/45" />}
<span className="ml-2 flex-1 text-xs text-foreground/65">深色模式</span>
<Switch size="small" checked={theme === "dark"} onChange={(checked) => setTheme(checked ? "dark" : "light")} aria-label="深色模式" />
</div>
<button
type="button"
className="flex h-9 w-full items-center gap-2 rounded px-2 text-xs text-foreground/55 hover:bg-surface-hover hover:text-foreground"
onClick={() => void handleLogout()}
>
<LogOut className="size-3.5" />退出登录
</button>
</div>
)}
>
<button type="button" className="app-workspace-topbar-icon-button" aria-label="账户菜单" title={user.displayName || user.username}>
<UserAvatar user={user} className="size-6" />
</button>
</Popover>
) : (
<Link to="/login" className="app-workspace-topbar-icon-button" aria-label="登录" title="登录">
<LogIn />
</Link>
);
}
function MenuLink({ to, icon, label, onNavigate }: { to: string; icon: ReactNode; label: string; onNavigate: () => void }) {
return <Link to={to} onClick={onNavigate} className="flex h-9 items-center gap-2.5 rounded px-2 text-xs text-foreground/62 hover:bg-surface-hover hover:text-foreground [&_svg]:size-3.5 [&_svg]:shrink-0">{icon}<span className="flex-1">{label}</span><ChevronRight className="!size-3 text-foreground/25" /></Link>;
}
function UserAvatar({ user, className }: { user: LocalUser; className?: string }) {
const [failed, setFailed] = useState(false);
const avatarUrl = /^https?:\/\//i.test(user.avatarUrl || "") ? user.avatarUrl : "";
useEffect(() => setFailed(false), [avatarUrl]);
// 结构保持 button > span > svg:占位图标自动复用顶栏图标按钮的 18px/1.8 描边与配色合同。
return (
<span className={cn("grid shrink-0 place-items-center overflow-hidden rounded-[var(--r-sm)] bg-foreground/[.06]", className)}>
{avatarUrl && !failed ? (
<img src={avatarUrl} alt="" referrerPolicy="no-referrer" className="size-full object-cover" onError={() => setFailed(true)} />
) : (
<CircleUserRound className="size-full" aria-hidden />
)}
</span>
);
}

View File

@@ -0,0 +1,155 @@
import { Command, Home, Search, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ComponentType } from "react";
import { useNavigate } from "react-router";
import { navigationTools } from "@/constant/navigation-tools";
import { cn } from "@/lib/utils";
import { useUserStore } from "@/stores/use-user-store";
type PaletteEntry = {
id: string;
title: string;
icon: ComponentType<{ className?: string; strokeWidth?: number }>;
to: string;
};
/** 顶栏搜索 / ⌘K 命令面板:按功能开关过滤当前可用页面入口。 */
export function WorkspaceCommandPalette({ open, onClose }: { open: boolean; onClose: () => void }) {
const navigate = useNavigate();
const features = useUserStore((state) => state.features);
const [query, setQuery] = useState("");
const [highlight, setHighlight] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const entries = useMemo<PaletteEntry[]>(() => {
const toolEntry = (slug: string, to: string): PaletteEntry => {
const tool = navigationTools.find((item) => item.slug === slug);
return { id: slug, title: tool?.label ?? slug, icon: tool?.icon ?? Home, to };
};
return [
{ id: "home", title: "首页", icon: Home, to: "/home" },
toolEntry("create", "/create"),
...(features.shortDramaEnabled ? [toolEntry("projects", "/projects")] : []),
toolEntry("canvas", "/canvas"),
...(features.taskCenterEnabled ? [toolEntry("tasks", "/tasks")] : []),
toolEntry("assets", "/assets"),
toolEntry("skills", "/skills"),
...(features.creditsEnabled ? [toolEntry("wallet", "/wallet")] : []),
toolEntry("settings", "/settings"),
];
}, [features]);
const filtered = useMemo(() => {
const keyword = query.trim().toLowerCase();
if (!keyword) return entries;
return entries.filter((entry) => entry.title.toLowerCase().includes(keyword));
}, [entries, query]);
useEffect(() => {
if (open) {
setQuery("");
window.setTimeout(() => inputRef.current?.focus(), 20);
}
}, [open]);
useEffect(() => {
setHighlight(0);
}, [filtered.length, open]);
useEffect(() => {
if (!open) return;
const handler = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
setHighlight((index) => Math.min(index + 1, Math.max(filtered.length - 1, 0)));
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setHighlight((index) => Math.max(index - 1, 0));
return;
}
if (event.key === "Enter" && filtered[highlight]) {
event.preventDefault();
const target = filtered[highlight];
onClose();
navigate(target.to);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, filtered, highlight, navigate, onClose]);
if (!open) return null;
return (
<div className="absolute inset-0 z-50 flex items-start justify-center bg-background/40 px-4 pt-28 backdrop-blur-sm lg:pt-32">
<div className="absolute inset-0" onClick={onClose} />
<div className="relative w-full max-w-xl overflow-hidden rounded-xl border border-[var(--workspace-border)] bg-[var(--workspace-surface-strong)] shadow-2xl animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center border-b border-[var(--workspace-border)] px-4">
<Search className="mr-3 size-4 shrink-0 text-foreground/45" strokeWidth={1.6} />
<input
ref={inputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
className="flex-1 bg-transparent py-4 text-[var(--fs-body)] outline-none placeholder:text-foreground/45"
placeholder="搜索页面或操作…"
/>
<kbd
onClick={onClose}
className="hidden h-5 cursor-pointer items-center justify-center rounded-sm border border-[var(--workspace-border)] bg-background/60 px-1.5 font-mono text-[var(--fs-tiny)] font-medium text-foreground/60 transition-colors hover:bg-surface-hover hover:text-foreground sm:inline-flex"
>
ESC
</kbd>
<button
type="button"
onClick={onClose}
className="ml-3 rounded-md p-1 text-foreground/50 transition-colors hover:bg-surface-hover hover:text-foreground"
aria-label="关闭搜索"
>
<X className="size-4" strokeWidth={1.6} />
</button>
</div>
<div className="max-h-80 overflow-y-auto p-2">
{filtered.length ? (
<ul className="flex flex-col gap-0.5">
{filtered.map((entry, index) => {
const Icon = entry.icon;
return (
<li key={entry.id}>
<button
type="button"
onMouseEnter={() => setHighlight(index)}
onClick={() => {
onClose();
navigate(entry.to);
}}
className={cn(
"flex w-full items-center gap-2.5 rounded-[var(--r-sm)] px-3 py-2.5 text-left text-[var(--fs-body)] transition-colors",
index === highlight ? "bg-surface-hover text-foreground" : "text-foreground/65",
)}
>
<Icon className={cn("size-4 shrink-0", index === highlight ? "text-foreground" : "text-foreground/55")} strokeWidth={1.6} />
<span className="truncate">{entry.title}</span>
</button>
</li>
);
})}
</ul>
) : (
<div className="flex flex-col items-center justify-center py-8">
<Command className="mb-2 size-6 text-foreground/30" strokeWidth={1.5} />
<p className="text-[var(--fs-caption)] font-medium text-foreground/55">未找到「{query}」相关页面</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,137 +0,0 @@
import { App, Popover, Switch } from "antd";
import { ChevronRight, CircleUserRound, LogIn, LogOut, Moon, ShieldCheck, Sun } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { Link, useNavigate } from "react-router";
import { AppChangelogButton } from "@/components/layout/app-changelog-modal";
import { IdentityProviderBadge } from "@/components/layout/identity-provider-badge";
import { SystemAnnouncementCenter } from "@/components/layout/system-announcement-center";
import { useWalletBalance } from "@/hooks/use-wallet-balance";
import { applyUserSession } from "@/lib/user-session";
import { cn } from "@/lib/utils";
import { logout } from "@/services/api/auth";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore, type LocalUser } from "@/stores/use-user-store";
type WorkspaceSidebarFooterProps = {
expandedClassName: string;
collapsedClassName: string;
accountClassName: string;
showAnnouncement?: boolean;
};
export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName, accountClassName, showAnnouncement = true }: WorkspaceSidebarFooterProps) {
const theme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const user = useUserStore((state) => state.user);
const hydrated = useUserStore((state) => state.hydrated);
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
const navigate = useNavigate();
const { message } = App.useApp();
const [menuOpen, setMenuOpen] = useState(false);
const { availableMicrocredits } = useWalletBalance(user?.id, creditsEnabled);
const balance = availableMicrocredits === null
? "--"
: (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
const handleLogout = async () => {
try {
await logout();
await applyUserSession({ user: null, systemChannels: [] });
setMenuOpen(false);
message.success("已退出登录");
navigate("/login", { replace: true });
} catch (error) {
message.error(error instanceof Error ? error.message : "退出失败");
}
};
if (!hydrated) return <div className="h-24 animate-pulse rounded-md bg-foreground/[.035]" />;
return (
<div>
{user && showAnnouncement ? (
<SystemAnnouncementCenter
userId={user.id}
className={cn("relative mb-1 flex h-9 w-full items-center rounded-md text-xs text-foreground/55 transition-colors hover:bg-surface-hover hover:text-foreground", collapsedClassName)}
showLabel
labelClassName={expandedClassName}
staticMotion
/>
) : null}
{user ? (
<Popover
trigger="click"
placement="rightBottom"
open={menuOpen}
onOpenChange={setMenuOpen}
content={(
<div className="w-[232px] py-0.5">
<div className="flex items-center gap-3 border-b border-border/65 px-1 pb-3">
<UserAvatar user={user} className="size-8" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5"><span className="truncate text-sm font-medium">{user.displayName || user.username}</span><IdentityProviderBadge user={user} /></div>
{creditsEnabled ? <div className="mt-0.5 truncate text-[var(--fs-label)] tabular-nums text-foreground/45">可用 {balance} 积分</div> : null}
</div>
</div>
{user.role === "admin" ? (
<nav className="py-2" aria-label="管理工具">
<MenuLink to="/admin" icon={<ShieldCheck />} label="管理员后台" onNavigate={() => setMenuOpen(false)} />
</nav>
) : null}
<div className="border-y border-border/65 py-2">
<AppChangelogButton className="flex h-8 w-full items-center gap-2 rounded px-2 text-[var(--fs-label)] text-foreground/58 hover:bg-surface-hover hover:text-foreground [&_svg]:size-3.5" showLabel showVersion versionClassName="ml-auto text-[var(--fs-micro)] tabular-nums text-foreground/32" />
</div>
<div className="flex h-10 items-center px-2">
{theme === "dark" ? <Moon className="size-3.5 text-foreground/45" /> : <Sun className="size-3.5 text-foreground/45" />}
<span className="ml-2 flex-1 text-xs text-foreground/65">深色模式</span>
<Switch size="small" checked={theme === "dark"} onChange={(checked) => setTheme(checked ? "dark" : "light")} aria-label="深色模式" />
</div>
<button type="button" className="flex h-9 w-full items-center gap-2 rounded px-2 text-xs text-foreground/55 hover:bg-surface-hover hover:text-foreground" onClick={() => void handleLogout()}><LogOut className="size-3.5" />退出登录</button>
</div>
)}
>
<button type="button" className={cn("app-workspace-account-button flex min-h-10 w-full min-w-0 items-center overflow-hidden rounded-md text-left transition-colors hover:bg-surface-hover", accountClassName)} title={creditsEnabled ? `${user.displayName || user.username} · ${balance} 积分` : user.displayName || user.username}>
<UserAvatar user={user} className="size-7" />
<span className={cn("min-w-0 flex-1 flex-col", expandedClassName)}>
<span className="truncate text-xs font-medium">{user.displayName || user.username}</span>
{creditsEnabled ? <span className="mt-0.5 block truncate text-[var(--fs-micro)] tabular-nums text-foreground/42">{balance} 积分</span> : null}
</span>
<ChevronRight className={cn("size-3.5 shrink-0 text-foreground/30", expandedClassName)} />
</button>
</Popover>
) : (
<Link to="/login" className={cn("flex h-10 items-center rounded-md text-xs text-foreground/65 hover:bg-surface-hover hover:text-foreground", collapsedClassName)} title="登录">
<LogIn className="size-4 shrink-0" /><span className={expandedClassName}>登录</span>
</Link>
)}
</div>
);
}
function MenuLink({ to, icon, label, onNavigate }: { to: string; icon: ReactNode; label: string; onNavigate: () => void }) {
return <Link to={to} onClick={onNavigate} className="flex h-9 items-center gap-2.5 rounded px-2 text-xs text-foreground/62 hover:bg-surface-hover hover:text-foreground [&_svg]:size-3.5 [&_svg]:shrink-0">{icon}<span className="flex-1">{label}</span><ChevronRight className="!size-3 text-foreground/25" /></Link>;
}
function UserAvatar({ user, className }: { user: LocalUser; className: string }) {
const [failed, setFailed] = useState(false);
const avatarUrl = /^https?:\/\//i.test(user.avatarUrl || "") ? user.avatarUrl : "";
useEffect(() => setFailed(false), [avatarUrl]);
return (
<span className={`relative grid shrink-0 place-items-center ${className}`}>
<span className="grid size-full place-items-center overflow-hidden rounded-md bg-transparent text-foreground/55">
{avatarUrl && !failed ? (
<img src={avatarUrl} alt="" referrerPolicy="no-referrer" className="size-full object-cover" onError={() => setFailed(true)} />
) : (
<CircleUserRound className="app-workspace-account-icon" aria-hidden />
)}
</span>
<IdentityProviderBadge user={user} compact className="absolute -bottom-1 -right-1" />
</span>
);
}

View File

@@ -0,0 +1,352 @@
import { ChevronDown, ChevronRight, Home, Infinity as InfinityIcon, LogOut, Plus, Search } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from "react";
import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
import { useWalletBalance } from "@/hooks/use-wallet-balance";
import { useWorkspaceLogout } from "@/hooks/use-workspace-logout";
import { cn } from "@/lib/utils";
import { preloadWorkspaceRoute } from "@/lib/workspace-route-modules";
import { useUserStore, type FeatureAvailability } from "@/stores/use-user-store";
export type WorkspaceNavItem = {
id: string;
title: string;
icon?: ComponentType<{ className?: string; strokeWidth?: number }>;
to?: string;
shortcut?: string;
badge?: string | number;
action?: "search" | "logout";
children?: WorkspaceNavItem[];
};
type WorkspaceNavGroup = {
heading?: string;
items: WorkspaceNavItem[];
};
/** 设置页分区子项,沿用 /settings?section=<key> 路由合同。 */
const SETTINGS_SECTIONS: Array<{ key: string; label: string }> = [
{ key: "local-cli", label: "本机工具" },
{ key: "channels", label: "自定义渠道" },
{ key: "models", label: "模型选择" },
{ key: "preferences", label: "生成偏好" },
{ key: "prompts", label: "提示词偏好" },
{ key: "storage", label: "我的对象存储" },
];
function toolItem(slug: NavigationToolSlug, to: string): WorkspaceNavItem {
const tool = navigationTools.find((item) => item.slug === slug);
return { id: slug, title: tool?.label ?? slug, icon: tool?.icon, to };
}
function buildNav(features: FeatureAvailability, balance: string): { groups: WorkspaceNavGroup[]; footer: WorkspaceNavItem[] } {
const groups: WorkspaceNavGroup[] = [
{
items: [
{ id: "search", title: "搜索", icon: Search, shortcut: "⌘K", action: "search" },
{ id: "home", title: "首页", icon: Home, to: "/home" },
toolItem("create", "/create"),
...(features.shortDramaEnabled ? [toolItem("projects", "/projects")] : []),
toolItem("canvas", "/canvas"),
...(features.taskCenterEnabled ? [toolItem("tasks", "/tasks")] : []),
toolItem("assets", "/assets"),
],
},
{
heading: "工作台管理",
items: [
toolItem("skills", "/skills"),
...(features.creditsEnabled ? [{ ...toolItem("wallet", "/wallet"), badge: balance }] : []),
],
},
];
const settingsSections = SETTINGS_SECTIONS.filter((section) => section.key !== "channels" || features.customChannelsEnabled);
const footer: WorkspaceNavItem[] = [
{
...toolItem("settings", "/settings"),
children: settingsSections.map((section) => ({
id: `settings:${section.key}`,
title: section.label,
to: `/settings?section=${section.key}`,
})),
},
{ id: "logout", title: "退出登录", icon: LogOut, action: "logout" },
];
return { groups, footer };
}
function WorkspaceSwitcher({ onNavigate }: { onNavigate: () => void }) {
const [isOpen, setIsOpen] = useState(false);
const navigate = useNavigate();
const go = (to: string) => {
setIsOpen(false);
onNavigate();
navigate(to);
};
return (
<div className="relative shrink-0 px-3 pt-3">
<button
type="button"
onClick={() => setIsOpen((open) => !open)}
aria-haspopup="listbox"
aria-expanded={isOpen}
className="group flex w-full items-center justify-between rounded-[var(--r-sm)] px-2 py-2 text-left transition-colors select-none hover:bg-surface-hover"
>
<span className="flex min-w-0 items-center gap-3">
<span className="app-workspace-brand-mark grid size-8 shrink-0 place-items-center rounded-[var(--r-sm)] shadow-sm">
<InfinityIcon className="size-4" strokeWidth={2} />
</span>
<span className="flex min-w-0 flex-col">
<span className="app-workspace-brand-wordmark truncate text-[var(--fs-body)] leading-none font-medium">影策</span>
<span className="mt-1 truncate text-[var(--fs-label)] leading-none text-foreground/42">创作工作台</span>
</span>
</span>
<ChevronDown className="size-4 shrink-0 text-foreground/40 transition-colors group-hover:text-foreground/70" strokeWidth={1.5} />
</button>
{isOpen ? (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
<div className="app-workspace-nav-popover absolute left-3 right-3 top-full z-50 mt-1 overflow-hidden rounded-lg border border-[var(--workspace-border)] bg-[var(--workspace-surface-strong)] py-1 shadow-xl animate-in fade-in zoom-in-95 duration-100">
<div className="px-3 py-2.5">
<div className="truncate text-[var(--fs-body)] font-semibold">影策</div>
<div className="mt-0.5 truncate text-[var(--fs-label)] text-foreground/45">创作工作台</div>
</div>
<div className="mx-2 my-1 h-px bg-[var(--workspace-border)]" />
{[
{ label: "首页", to: "/home" },
{ label: "画布", to: "/canvas" },
{ label: "设置", to: "/settings" },
].map((entry) => (
<button
key={entry.to}
type="button"
onClick={() => go(entry.to)}
className="flex w-full items-center gap-2 px-3 py-2 text-[var(--fs-body)] text-foreground/80 transition-colors hover:bg-surface-hover hover:text-foreground"
>
{entry.label}
</button>
))}
<div className="mx-2 my-1 h-px bg-[var(--workspace-border)]" />
<button
type="button"
onClick={() => go("/canvas?mode=new")}
className="flex w-full items-center gap-2 px-3 py-2 text-[var(--fs-body)] text-foreground/45 transition-colors hover:bg-surface-hover hover:text-foreground"
>
<Plus className="size-3.5" /> 新建画布
</button>
</div>
</>
) : null}
</div>
);
}
function NavItem({ item, activeId, onSelect, onOpenSearch, onLogout, level = 0 }: {
item: WorkspaceNavItem;
activeId: string;
onSelect: (id: string) => void;
onOpenSearch: () => void;
onLogout: () => void;
level?: number;
}) {
const isActive = activeId === item.id || (item.id === "settings" && activeId.startsWith("settings:"));
const hasChildren = Boolean(item.children?.length);
const [isOpen, setIsOpen] = useState(false);
// 激活分支自动展开(如设置分区子项),保证当前位置可见。
useEffect(() => {
if (isActive && hasChildren) setIsOpen(true);
}, [isActive, hasChildren]);
const Icon = item.icon;
const rowStyle = { paddingLeft: `${level * 12 + 10}px` } as CSSProperties;
const rowContent = (
<>
<span className="flex min-w-0 items-center gap-2.5">
{Icon ? (
<Icon className={cn("size-4 shrink-0", isActive ? "text-foreground" : "text-foreground/60 group-hover:text-foreground/80")} strokeWidth={1.6} />
) : (
<span className="size-1.5 shrink-0 rounded-full bg-current opacity-40" aria-hidden />
)}
<span className="truncate">{item.title}</span>
</span>
<span className="flex shrink-0 items-center gap-2">
{item.shortcut ? (
<kbd className="hidden h-5 items-center justify-center rounded-sm border border-[var(--workspace-border)] bg-background/50 px-1.5 font-mono text-[var(--fs-tiny)] font-medium text-foreground/55 group-hover:inline-flex">
{item.shortcut}
</kbd>
) : null}
{item.badge !== undefined ? (
<span className="flex min-w-5 items-center justify-center rounded-full bg-primary/10 px-1.5 py-0.5 text-[var(--fs-tiny)] font-medium tabular-nums text-primary">
{item.badge}
</span>
) : null}
{hasChildren ? (
<ChevronRight className={cn("size-3.5 shrink-0 text-foreground/40 transition-transform duration-200", isOpen && "rotate-90")} strokeWidth={2} />
) : null}
</span>
</>
);
const rowClassName = cn(
"app-workspace-nav-link group flex min-h-9 w-full items-center justify-between gap-2 rounded-[var(--r-sm)] px-2.5 py-2 text-[var(--fs-body)] transition-colors duration-200 select-none",
isActive ? "is-active font-medium" : "text-foreground/62 hover:bg-surface-hover hover:text-foreground",
);
const handleClick = () => {
if (item.action === "search") {
onOpenSearch();
return;
}
if (item.action === "logout") {
onLogout();
return;
}
if (hasChildren) {
setIsOpen((open) => !open);
return;
}
onSelect(item.id);
};
// 局部 const:闭包内 TS 保留窄化,供 preload 回调使用。
const linkTo = item.to;
return (
<div className="flex w-full flex-col">
{linkTo ? (
<Link
to={linkTo}
className={rowClassName}
style={rowStyle}
onClick={handleClick}
onFocus={() => preloadWorkspaceRoute(linkTo)}
onPointerDown={() => preloadWorkspaceRoute(linkTo)}
onPointerEnter={() => preloadWorkspaceRoute(linkTo)}
>
{rowContent}
</Link>
) : (
<button
type="button"
className={rowClassName}
style={rowStyle}
onClick={handleClick}
aria-expanded={hasChildren ? isOpen : undefined}
>
{rowContent}
</button>
)}
{hasChildren ? (
<div className={cn("grid transition-[grid-template-rows,opacity] duration-300 ease-in-out", isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0")}>
<div className="relative flex min-h-0 flex-col gap-0.5 overflow-hidden pt-0.5">
<span className="app-workspace-nav-guide-line" style={{ left: `${(level + 1) * 12 + 12.5}px` }} />
{item.children!.map((child) => (
<NavItem
key={child.id}
item={child}
activeId={activeId}
onSelect={onSelect}
onOpenSearch={onOpenSearch}
onLogout={onLogout}
level={level + 1}
/>
))}
</div>
</div>
) : null}
</div>
);
}
export function WorkspaceSidebarNav({ onNavigate, onOpenSearch }: { onNavigate: () => void; onOpenSearch: () => void }) {
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const features = useUserStore((state) => state.features);
const user = useUserStore((state) => state.user);
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
const { availableMicrocredits } = useWalletBalance(user?.id, creditsEnabled);
const { handleLogout } = useWorkspaceLogout();
const balance = availableMicrocredits === null
? "--"
: (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
const { groups, footer } = useMemo(() => buildNav(features, balance), [features, balance]);
const slug = pathname.split("/").filter(Boolean)[0] || "create";
const section = searchParams.get("section");
const activeId = slug === "settings" && section ? `settings:${section}` : slug;
const scrollRef = useRef<HTMLDivElement>(null);
const [scrollState, setScrollState] = useState({ hasTopFade: false, hasBottomFade: false });
const handleScroll = () => {
const element = scrollRef.current;
if (!element) return;
const { scrollTop, scrollHeight, clientHeight } = element;
setScrollState({
hasTopFade: scrollTop > 0,
hasBottomFade: scrollTop + clientHeight < scrollHeight - 1,
});
};
useEffect(() => {
handleScroll();
}, [groups]);
return (
<div className="flex h-full w-[var(--workspace-sidebar-nav-width)] shrink-0 flex-col">
<WorkspaceSwitcher onNavigate={onNavigate} />
<div
ref={scrollRef}
onScroll={handleScroll}
className={cn(
"app-workspace-sidebar-scroll-area flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 pb-3 pt-2",
scrollState.hasTopFade && "has-top-fade",
scrollState.hasBottomFade && "has-bottom-fade",
)}
>
{groups.map((group, index) => (
<div key={index} className="flex shrink-0 flex-col">
{group.heading ? <span className="app-workspace-nav-group-label">{group.heading}</span> : null}
<div className="flex flex-col gap-0.5">
{group.items.map((item) => (
<NavItem
key={item.id}
item={item}
activeId={activeId}
onSelect={onNavigate}
onOpenSearch={onOpenSearch}
onLogout={() => void handleLogout()}
/>
))}
</div>
</div>
))}
</div>
<div className="app-workspace-sidebar-footer shrink-0 border-t border-[var(--workspace-border)] px-3 py-3">
<div className="flex flex-col gap-0.5">
{footer.map((item) => (
<NavItem
key={item.id}
item={item}
activeId={activeId}
onSelect={onNavigate}
onOpenSearch={onOpenSearch}
onLogout={() => void handleLogout()}
/>
))}
</div>
</div>
</div>
);
}

View File

@@ -1,35 +1,50 @@
import { Infinity as InfinityIcon, PanelLeftClose, PanelLeftOpen, Settings } from "lucide-react";
import { Link } from "react-router";
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { Link, useLocation } from "react-router";
import { SystemAnnouncementCenter } from "@/components/layout/system-announcement-center";
import { WorkspaceAccountMenu } from "@/components/layout/workspace-account-menu";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
const PAGE_TITLES: Record<string, string> = {
home: "首页",
create: "创作",
projects: "短剧创作",
canvas: "画布",
tasks: "任务",
assets: "素材",
skills: "技能库",
wallet: "积分中心",
settings: "设置",
};
export function WorkspaceTopBar({ sidebarOpen, onToggleSidebar }: { sidebarOpen: boolean; onToggleSidebar: () => void }) {
const theme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const user = useUserStore((state) => state.user);
const { pathname } = useLocation();
const slug = pathname.split("/").filter(Boolean)[0];
const pageTitle = (slug && PAGE_TITLES[slug]) || "影策";
return (
<header className="app-workspace-topbar flex shrink-0 items-center justify-between gap-3 px-4 sm:px-5">
<div className="flex min-w-0 items-center gap-2">
<header className="app-workspace-topbar flex shrink-0 items-center justify-between gap-3 px-3 sm:px-4">
<div className="flex min-w-0 items-center gap-1.5">
<button type="button" className="app-workspace-topbar-icon-button" aria-label={sidebarOpen ? "收起侧栏" : "展开侧栏"} onClick={onToggleSidebar}>
{sidebarOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
</button>
<Link to="/" className="app-workspace-brand-link inline-flex min-w-0 items-center gap-2 text-foreground" title="影策工作台">
<span className="app-workspace-brand-mark grid size-7 shrink-0 place-items-center rounded-md bg-foreground text-background">
<InfinityIcon className="size-4" />
</span>
<span className="app-workspace-brand-wordmark truncate text-[var(--fs-body)] font-semibold">影策</span>
</Link>
<nav className="flex min-w-0 items-center gap-2 text-[var(--fs-caption)] text-foreground/50" aria-label="当前位置">
<Link to="/" className="shrink-0 font-medium text-foreground/70 transition-colors hover:text-foreground">影策</Link>
<span className="shrink-0 text-foreground/30">/</span>
<span className="truncate font-medium text-foreground">{pageTitle}</span>
</nav>
</div>
<div className="flex shrink-0 items-center gap-1">
{user ? <SystemAnnouncementCenter userId={user.id} className="app-workspace-topbar-icon-button" /> : null}
<AnimatedThemeToggler className="app-workspace-topbar-icon-button" theme={theme} onThemeChange={setTheme} aria-label="切换主题" />
<Link to="/settings" className="app-workspace-topbar-icon-button" aria-label="设置" title="设置">
<Settings className="size-4" />
</Link>
<WorkspaceAccountMenu />
</div>
</header>
);