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>
|
||||
);
|
||||
|
||||
33
web/src/hooks/use-workspace-logout.ts
Normal file
33
web/src/hooks/use-workspace-logout.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { App } from "antd";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import { applyUserSession } from "@/lib/user-session";
|
||||
import { logout } from "@/services/api/auth";
|
||||
|
||||
/**
|
||||
* 工作区退出登录:供侧栏底部与顶部账户菜单共用。
|
||||
* 写路径强校验:失败必须明确提示,不得静默吞错。
|
||||
*/
|
||||
export function useWorkspaceLogout() {
|
||||
const navigate = useNavigate();
|
||||
const { message } = App.useApp();
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (loggingOut) return;
|
||||
setLoggingOut(true);
|
||||
try {
|
||||
await logout();
|
||||
await applyUserSession({ user: null, systemChannels: [] });
|
||||
message.success("已退出登录");
|
||||
navigate("/login", { replace: true });
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "退出失败");
|
||||
} finally {
|
||||
setLoggingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { handleLogout, loggingOut };
|
||||
}
|
||||
@@ -403,6 +403,8 @@
|
||||
--workspace-sidebar: var(--workspace-navigation);
|
||||
--workspace-accent: #171717;
|
||||
--workspace-accent-soft: rgba(17, 17, 17, 0.07);
|
||||
--workspace-sidebar-width: 260px; /* 侧栏展开宽度(组件 token)*/
|
||||
--workspace-sidebar-nav-width: 260px; /* 侧栏导航内容宽度,不受折叠状态影响 */
|
||||
|
||||
/* --- Semantic 层(引用 Primitive,随主题切换)--- */
|
||||
/* 通用语义色(引用 Primitive neutral 色板)*/
|
||||
@@ -690,8 +692,6 @@
|
||||
--page-topbar-surface: var(--bg-secondary);
|
||||
--page-topbar-border: var(--workspace-border);
|
||||
--canvas-topbar-surface: var(--workspace-glass-surface);
|
||||
--rail-icon-size: 18px;
|
||||
|
||||
/* Canvas 创建菜单:固定行高保证图标、徽标和单行标签互不挤占 */
|
||||
--canvas-create-node-height: var(--space-12);
|
||||
--canvas-create-resource-height: var(--space-8);
|
||||
@@ -890,7 +890,7 @@
|
||||
--workspace-border-strong: rgba(17, 17, 17, 0.2);
|
||||
--workspace-grid-line: rgba(17, 17, 17, 0.035);
|
||||
--workspace-nav: var(--workspace-navigation);
|
||||
--workspace-sidebar: var(--workspace-navigation);
|
||||
--workspace-sidebar: color-mix(in srgb, var(--workspace-surface) 55%, var(--workspace-navigation));
|
||||
--workspace-accent: #171717;
|
||||
--workspace-accent-soft: rgba(17, 17, 17, 0.065);
|
||||
background-color: var(--workspace-canvas);
|
||||
@@ -911,7 +911,7 @@
|
||||
--workspace-border-strong: rgba(255, 255, 255, 0.18);
|
||||
--workspace-grid-line: rgba(255, 255, 255, 0.026);
|
||||
--workspace-nav: var(--workspace-navigation);
|
||||
--workspace-sidebar: var(--workspace-navigation);
|
||||
--workspace-sidebar: color-mix(in srgb, var(--workspace-surface) 55%, var(--workspace-navigation));
|
||||
--workspace-accent: #f5f5f5;
|
||||
--workspace-accent-soft: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
@@ -932,7 +932,9 @@
|
||||
}
|
||||
|
||||
.app-workspace-shell.is-spatial .app-workspace-sidebar {
|
||||
width: var(--workspace-sidebar-width, 260px) !important;
|
||||
border: 0;
|
||||
border-right: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent);
|
||||
border-radius: 0;
|
||||
background: var(--workspace-sidebar);
|
||||
box-shadow: none;
|
||||
@@ -944,9 +946,35 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 侧栏导航子项展开引导线:替代每组子项自行维护的分割线 */
|
||||
.app-workspace-nav-guide-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 0;
|
||||
border-left: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 分组标题:小号大写标签,弱化但可读 */
|
||||
.app-workspace-nav-group-label {
|
||||
display: block;
|
||||
padding: 0 10px 6px;
|
||||
font-size: var(--fs-label);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--foreground) 36%, transparent);
|
||||
}
|
||||
|
||||
/* 工作区切换器下拉浮层 */
|
||||
.app-workspace-nav-popover {
|
||||
box-shadow: 0 18px 48px color-mix(in srgb, var(--foreground) 12%, transparent), 0 2px 10px color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
}
|
||||
|
||||
body.app-spatial-overlays {
|
||||
--workspace-overlay-bg: rgba(244, 244, 245, 0.78);
|
||||
--workspace-overlay-bg-strong: rgba(250, 250, 250, 0.88);
|
||||
--workspace-overlay-bg: rgba(244, 244, 245, 1);
|
||||
--workspace-overlay-bg-strong: rgba(250, 250, 250, 1);
|
||||
--workspace-overlay-border: rgba(17, 17, 17, 0.22);
|
||||
--workspace-overlay-divider: rgba(17, 17, 17, 0.1);
|
||||
--workspace-overlay-highlight: rgba(255, 255, 255, 0.86);
|
||||
@@ -956,8 +984,8 @@
|
||||
}
|
||||
|
||||
.dark body.app-spatial-overlays {
|
||||
--workspace-overlay-bg: rgba(10, 10, 11, 0.78);
|
||||
--workspace-overlay-bg-strong: rgba(15, 15, 16, 0.88);
|
||||
--workspace-overlay-bg: rgba(10, 10, 11, 1);
|
||||
--workspace-overlay-bg-strong: rgba(15, 15, 16, 1);
|
||||
--workspace-overlay-border: rgba(255, 255, 255, 0.22);
|
||||
--workspace-overlay-divider: rgba(255, 255, 255, 0.105);
|
||||
--workspace-overlay-highlight: rgba(255, 255, 255, 0.2);
|
||||
@@ -980,21 +1008,12 @@
|
||||
.ant-notification-notice
|
||||
) {
|
||||
border: 1px solid var(--workspace-overlay-border) !important;
|
||||
background:
|
||||
linear-gradient(145deg, var(--workspace-overlay-highlight), transparent 24%, transparent 76%, rgba(158, 178, 214, 0.055)),
|
||||
var(--workspace-overlay-bg) !important;
|
||||
box-shadow:
|
||||
var(--workspace-overlay-shadow),
|
||||
inset 0 1px 0 var(--workspace-overlay-highlight),
|
||||
inset 1px 0 0 color-mix(in srgb, var(--workspace-overlay-highlight) 36%, transparent) !important;
|
||||
backdrop-filter: blur(30px) saturate(138%) !important;
|
||||
-webkit-backdrop-filter: blur(30px) saturate(138%) !important;
|
||||
background: var(--workspace-overlay-bg-strong) !important;
|
||||
box-shadow: var(--workspace-overlay-shadow) !important;
|
||||
}
|
||||
|
||||
body.app-spatial-overlays :where(.ant-modal-content, .ant-drawer-content, .ant-notification-notice) {
|
||||
background:
|
||||
linear-gradient(145deg, var(--workspace-overlay-highlight), transparent 20%, transparent 82%, rgba(158, 178, 214, 0.05)),
|
||||
var(--workspace-overlay-bg-strong) !important;
|
||||
background: var(--workspace-overlay-bg-strong) !important;
|
||||
}
|
||||
|
||||
body.app-spatial-overlays :where(.ant-dropdown-menu, .ant-select-dropdown, .ant-popover-inner, .ant-cascader-menus, .ant-mentions-dropdown) {
|
||||
@@ -1053,25 +1072,6 @@
|
||||
-webkit-backdrop-filter: blur(7px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
body.app-spatial-overlays :where(
|
||||
.ant-dropdown-menu,
|
||||
.ant-select-dropdown,
|
||||
.ant-popover-inner,
|
||||
.ant-picker-dropdown .ant-picker-panel-container,
|
||||
.ant-cascader-menus,
|
||||
.ant-mentions-dropdown,
|
||||
.ant-tooltip-inner,
|
||||
.ant-modal-content,
|
||||
.ant-drawer-content,
|
||||
.ant-message-notice-content,
|
||||
.ant-notification-notice
|
||||
) {
|
||||
background: var(--workspace-overlay-bg-strong) !important;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.app-workspace-shell.is-spatial .app-workspace-brand-mark {
|
||||
border: 1px solid color-mix(in srgb, var(--workspace-accent) 36%, transparent);
|
||||
@@ -1080,15 +1080,6 @@
|
||||
box-shadow: 0 8px 24px color-mix(in srgb, var(--workspace-accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.app-workspace-shell.is-spatial .app-workspace-nav-link.is-active {
|
||||
background: var(--workspace-accent-soft);
|
||||
color: var(--foreground);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app-workspace-shell.is-spatial .app-workspace-nav-link.is-active::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-workspace-shell.is-spatial .app-workspace-stage {
|
||||
border-radius: 0;
|
||||
@@ -1184,15 +1175,18 @@
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.app-workspace-shell.is-spatial {
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-workspace-shell.is-spatial .app-workspace-sidebar,
|
||||
.app-workspace-shell.is-spatial .app-workspace-stage {
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* 窄屏下侧栏常驻,收缩到图标主轨,避免遮挡主内容 */
|
||||
.app-workspace-shell.is-spatial .app-workspace-sidebar.is-collapsed {
|
||||
width: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
@@ -1200,14 +1194,21 @@
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.app-workspace-shell .app-workspace-sidebar {
|
||||
width: 0 !important;
|
||||
border-right: 0 !important;
|
||||
transition: width var(--motion-state) var(--ease-aceternity);
|
||||
}
|
||||
|
||||
.app-workspace-shell .app-workspace-sidebar.is-mobile-expanded {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
inset: 6px auto 6px 6px;
|
||||
width: min(196px, calc(100vw - 58px));
|
||||
box-shadow: 22px 0 70px rgba(0, 0, 0, 0.58), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
width: min(264px, calc(100vw - 58px)) !important;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
|
||||
border-radius: 12px;
|
||||
box-shadow: 22px 0 70px rgba(0, 0, 0, 0.42), inset 0 1px 0 color-mix(in srgb, white 7%, transparent);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.app-user-workspace {
|
||||
@@ -1215,6 +1216,7 @@
|
||||
}
|
||||
|
||||
.app-workspace-shell {
|
||||
position: relative;
|
||||
background: var(--workspace-page);
|
||||
}
|
||||
|
||||
@@ -1224,6 +1226,23 @@
|
||||
transition: width var(--motion-state) var(--ease-aceternity);
|
||||
}
|
||||
|
||||
.app-workspace-shell.is-spatial .app-workspace-sidebar {
|
||||
border-right: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent);
|
||||
}
|
||||
|
||||
.app-workspace-shell.is-spatial .app-workspace-sidebar.is-collapsed {
|
||||
width: 0 !important;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.app-workspace-sidebar .app-workspace-sidebar-scroll-area {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.app-workspace-sidebar .app-workspace-sidebar-scroll-area::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-workspace-stage,
|
||||
.app-user-content,
|
||||
.app-workspace-grid {
|
||||
@@ -1308,10 +1327,13 @@
|
||||
position: relative;
|
||||
z-index: var(--z-toolbar);
|
||||
height: var(--page-topbar-h);
|
||||
background: var(--workspace-navigation);
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--workspace-border);
|
||||
border-radius: 0;
|
||||
background: var(--workspace-surface-strong);
|
||||
box-shadow: none;
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.app-workspace-topbar-icon-button {
|
||||
@@ -1327,8 +1349,7 @@
|
||||
}
|
||||
|
||||
.app-workspace-topbar-icon-button > svg,
|
||||
.app-workspace-topbar-icon-button > span > svg,
|
||||
.app-workspace-nav-icon {
|
||||
.app-workspace-topbar-icon-button > span > svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke-width: 1.8;
|
||||
@@ -1344,123 +1365,20 @@
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* 品牌区:∞ 标 + 影策 字标,hover 加深强调 */
|
||||
.app-workspace-brand-link {
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
|
||||
.app-workspace-brand-mark {
|
||||
transition: box-shadow var(--motion-instant) ease-out;
|
||||
}
|
||||
|
||||
/* 品牌字标:弱化但可读,hover 加深 */
|
||||
.app-workspace-brand-wordmark {
|
||||
color: color-mix(in srgb, var(--foreground) 78%, transparent);
|
||||
letter-spacing: 0.01em;
|
||||
transition: color var(--motion-instant) ease-out;
|
||||
}
|
||||
|
||||
.app-workspace-brand-link:hover .app-workspace-brand-wordmark,
|
||||
.app-workspace-brand-link:focus-visible .app-workspace-brand-wordmark {
|
||||
/* 侧栏激活项:主操作角色成对定义(浅色/深色主题同一 token) */
|
||||
.app-workspace-nav-link.is-active {
|
||||
background: var(--workspace-accent-soft);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.app-workspace-brand-link:hover .app-workspace-brand-mark {
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--foreground) 24%, transparent);
|
||||
}
|
||||
|
||||
.app-workspace-brand-link:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--workspace-accent) 46%, transparent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.app-workspace-rail {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.app-workspace-rail-header,
|
||||
.app-workspace-rail-footer {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.app-workspace-rail-middle {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.app-workspace-shell.is-spatial .app-workspace-rail .app-workspace-rail-tile {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
border-radius: var(--r-md);
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.app-workspace-rail nav {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.app-workspace-rail nav + nav {
|
||||
margin-top: var(--space-4) !important;
|
||||
}
|
||||
|
||||
.app-workspace-rail [title*="积分"] {
|
||||
border-radius: var(--r-md) !important;
|
||||
}
|
||||
|
||||
.app-workspace-rail .app-workspace-rail-tile svg,
|
||||
.app-workspace-rail .app-workspace-account-icon {
|
||||
width: var(--rail-icon-size);
|
||||
height: var(--rail-icon-size);
|
||||
}
|
||||
|
||||
/* 侧栏轨道:默认 / 悬停 / 激活三态成对定义 */
|
||||
.app-workspace-rail .app-workspace-nav-link {
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
transition: background-color var(--motion-instant) ease-out, color var(--motion-instant) ease-out;
|
||||
}
|
||||
|
||||
.app-workspace-rail .app-workspace-nav-link:hover:not(.is-active) {
|
||||
background: var(--surface-hover);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.app-workspace-rail .app-workspace-nav-link.is-active {
|
||||
background: var(--workspace-accent-soft) !important;
|
||||
color: var(--foreground) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.app-workspace-rail .app-workspace-nav-link.is-active::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 底部账号区:40px 触点 + 头像 hover 反馈增强 */
|
||||
.app-workspace-rail-footer button:hover > span:first-child {
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--foreground) 22%, transparent);
|
||||
}
|
||||
|
||||
.app-workspace-rail-footer button:hover .app-workspace-account-icon {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.app-workspace-account-button:focus-visible {
|
||||
outline: 2px solid var(--control-focus-ring);
|
||||
outline-offset: 2px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app-workspace-account-button :where(span, svg):focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.app-workspace-nav-link.is-active {
|
||||
background: var(--surface-hover);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.app-workspace-nav-link.is-active::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-workspace-icon-button {
|
||||
display: inline-flex;
|
||||
@@ -1539,12 +1457,12 @@
|
||||
|
||||
.app-workspace-sidebar-scroll-area.has-top-fade::before {
|
||||
top: 0;
|
||||
background: var(--workspace-navigation);
|
||||
background: linear-gradient(to bottom, var(--workspace-sidebar), transparent);
|
||||
}
|
||||
|
||||
.app-workspace-sidebar-scroll-area.has-bottom-fade::after {
|
||||
bottom: 0;
|
||||
background: var(--workspace-navigation);
|
||||
background: linear-gradient(to top, var(--workspace-sidebar), transparent);
|
||||
}
|
||||
|
||||
.workspace-signal-icon {
|
||||
@@ -3295,53 +3213,11 @@
|
||||
padding: 0;
|
||||
background: var(--workspace-canvas);
|
||||
}
|
||||
.app-workspace-shell.is-spatial .app-workspace-sidebar {
|
||||
width: var(--workspace-sidebar-width, 88px) !important;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: var(--workspace-canvas);
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
.dark .app-workspace-shell.is-spatial .app-workspace-sidebar { box-shadow: none; }
|
||||
|
||||
.app-workspace-shell.is-spatial .app-workspace-stage {
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.app-workspace-shell.is-spatial .app-workspace-sidebar > div:first-child {
|
||||
height: 96px;
|
||||
border-bottom: 0;
|
||||
}
|
||||
.app-workspace-shell.is-spatial .app-workspace-sidebar > div:first-child a > span:not(.app-workspace-brand-mark) { display: none; }
|
||||
.app-workspace-shell.is-spatial .app-workspace-sidebar .app-workspace-brand-mark {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.app-workspace-shell.is-spatial .app-workspace-sidebar nav { padding: 18px 10px; }
|
||||
.app-workspace-shell.is-spatial .app-workspace-sidebar nav > div { display: none; }
|
||||
.app-workspace-shell.is-spatial .app-workspace-nav-link {
|
||||
height: 62px !important;
|
||||
flex-direction: column;
|
||||
justify-content: center !important;
|
||||
gap: 5px !important;
|
||||
margin: 0 0 9px;
|
||||
padding: 0 !important;
|
||||
color: var(--foreground);
|
||||
font-size: 10px;
|
||||
}
|
||||
.app-workspace-shell.is-spatial .app-workspace-nav-link > svg { width: 20px; height: 20px; }
|
||||
.app-workspace-shell.is-spatial .app-workspace-nav-link > span { display: block !important; font-size: 10px; line-height: 1.1; }
|
||||
.app-workspace-shell.is-spatial .app-workspace-nav-link.is-active { background: transparent; color: var(--foreground); box-shadow: none; }
|
||||
.app-workspace-shell.is-spatial .app-workspace-nav-link.is-active::before { display: none; }
|
||||
.app-workspace-shell.is-spatial .app-workspace-sidebar > div:last-child { border-top: 0; padding: 12px 10px 18px; }
|
||||
.app-workspace-shell.is-spatial[data-sidebar-collapsed="true"] .app-workspace-nav-link > span,
|
||||
.app-workspace-shell.is-spatial[data-sidebar-collapsed="true"] .app-workspace-sidebar > div:first-child a > span:not(.app-workspace-brand-mark) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
@@ -8036,17 +7912,6 @@ html:not(.dark) .creation-history-drawer-root {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
body.app-spatial-overlays :where(.ant-dropdown-menu, .ant-select-dropdown, .ant-popover-inner, .ant-modal-content, .ant-drawer-content, .ant-message-notice-content, .ant-notification-notice) {
|
||||
background: var(--popover) !important;
|
||||
background-image: none !important;
|
||||
box-shadow: var(--shadow-xl) !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
body.app-spatial-overlays :where(.ant-modal-mask, .ant-drawer-mask) {
|
||||
background: color-mix(in srgb, var(--foreground) 48%, transparent) !important;
|
||||
backdrop-filter: blur(3px) !important;
|
||||
}
|
||||
|
||||
/* 鼠标点击不留下“选中框”;键盘导航仍通过 :focus-visible 保留可发现的焦点提示。 */
|
||||
:where(button:not(:disabled), a[href], [role="button"]:not([aria-disabled="true"]), label[for], input[type="checkbox"]:not(:disabled), input[type="radio"]:not(:disabled), .ant-btn:not(:disabled), .ant-switch:not(.ant-switch-disabled), .ant-select:not(.ant-select-disabled), .ant-dropdown-trigger, .ant-tabs-tab, .ant-segmented-item, .ant-pagination-item, .ant-pagination-prev, .ant-pagination-next, .ant-dropdown-menu-item, .ant-menu-item, .ant-select-item-option, .ant-checkbox-wrapper, .ant-radio-wrapper, .ant-upload, .ant-collapse-header, .ant-slider, .ant-rate-star, .ant-tag-checkable) {
|
||||
@@ -8068,12 +7933,6 @@ body.app-spatial-overlays :where(.ant-modal-mask, .ant-drawer-mask) {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.app-workspace-shell.is-spatial .app-workspace-sidebar {
|
||||
background: var(--workspace-sidebar) !important;
|
||||
background-image: none !important;
|
||||
box-shadow: none !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
/* 显式兜底:Tailwind 生成的 hover 背景在部分层叠顺序下未生效,这里保持语义 token 一致。 */
|
||||
.hover\:bg-surface-hover:hover {
|
||||
|
||||
Reference in New Issue
Block a user