refactor(web): 工作区布局 - 侧栏改 260px 可折叠导航、顶栏移入主区并统一账户按钮
- 侧栏由 64px 图标 rail 改为 260px 可折叠导航:分组、设置分区子项、钱包余额徽章、⌘K 命令面板入口、激活分支自动展开 - 顶栏移入主内容区(侧栏右侧):面包屑、主题切换、公告中心、账户菜单;移除冗余搜索按钮(保留侧栏搜索与 ⌘K) - 账户按钮与其余图标按钮视觉对齐:24px 圆角头像、占位图标复用统一图标合同、移除触发按钮角标 - 新增命令面板与登出 hook;浮层背景不透明化并收敛到唯一源规则
This commit is contained in:
@@ -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")} />
|
||||
</>
|
||||
|
||||
106
web/src/components/layout/workspace-account-menu.tsx
Normal file
106
web/src/components/layout/workspace-account-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
web/src/components/layout/workspace-command-palette.tsx
Normal file
155
web/src/components/layout/workspace-command-palette.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
352
web/src/components/layout/workspace-sidebar-nav.tsx
Normal file
352
web/src/components/layout/workspace-sidebar-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user