refactor(web): 工作区布局与技能库 - 完成侧栏导航、分页与卡片一致性改造

This commit is contained in:
ddCat
2026-08-20 10:15:44 +08:00
16 changed files with 1331 additions and 650 deletions

View File

@@ -1,47 +1,32 @@
import { Home, Infinity as InfinityIcon } from "lucide-react";
import { Fragment, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Link, useLocation, useNavigate } from "react-router";
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
import { useLocation, useNavigate } from "react-router";
import { ModelSetupGuide } from "@/components/layout/model-setup-guide";
import { WorkspaceSidebarFooter } from "@/components/layout/workspace-sidebar-footer";
import { WorkspaceCommandPalette } from "@/components/layout/workspace-command-palette";
import { WorkspaceSidebarNav } from "@/components/layout/workspace-sidebar-nav";
import { WorkspaceTopBar } from "@/components/layout/workspace-top-bar";
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
import { cn } from "@/lib/utils";
import { refreshFeatureAvailability } from "@/lib/user-session";
import { isSpatialWorkbenchPath } from "@/lib/workspace-routes";
import { preloadWorkspaceRoute } from "@/lib/workspace-route-modules";
import { useUserStore } from "@/stores/use-user-store";
export function AppWorkspaceShell({ children }: { children: ReactNode }) {
const { pathname } = useLocation();
const navigate = useNavigate();
const user = useUserStore((state) => state.user);
const features = useUserStore((state) => state.features);
const [mobileSidebarExpanded, setMobileSidebarExpanded] = useState(false);
const [desktopSidebarCollapsed, setDesktopSidebarCollapsed] = useState(false);
const scrollRef = useRef<HTMLElement>(null);
const [scrollState, setScrollState] = useState({
hasTopFade: false,
hasBottomFade: false,
});
const [paletteOpen, setPaletteOpen] = useState(false);
const hideChrome = pathname.startsWith("/admin") || /^\/canvas\/[^/]+/.test(pathname);
const showGlobalTopBar = !hideChrome;
const spatialWorkbench = isSpatialWorkbenchPath(pathname);
const creationWorkspace = pathname === "/create";
const slug = pathname.split("/").filter(Boolean)[0];
const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined;
const visibleNavigationTools = (spatialWorkbench ? navigationTools : navigationTools.filter((tool) => tool.section === "创作空间"))
.filter((tool) => {
if (tool.slug === "projects") return features.shortDramaEnabled;
if (tool.slug === "tasks") return features.taskCenterEnabled;
if (tool.slug === "wallet") return features.creditsEnabled;
return true;
});
const topRailNavTools = visibleNavigationTools.filter((tool) => tool.section === "创作空间" || tool.slug === "skills");
const bottomRailNavTools = visibleNavigationTools.filter((tool) => tool.slug === "wallet");
const isMobileViewport = () => window.innerWidth < 1024;
const toggleSidebar = () => {
if (window.innerWidth < 1024) {
if (isMobileViewport()) {
setMobileSidebarExpanded((current) => !current);
return;
}
@@ -49,41 +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 flex min-h-12 w-full flex-col items-center justify-center gap-1 rounded-md px-1 py-1 text-[var(--fs-tiny)] leading-none transition-colors",
active ? "is-active font-medium" : "",
)}
>
<Icon className="app-workspace-nav-icon shrink-0" strokeWidth={1.8} />
<span className="truncate">{tool.label.slice(0, 2)}</span>
</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) => {
@@ -96,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));
@@ -113,94 +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="app-workspace-rail-tile flex min-h-12 w-10 shrink-0 flex-col items-center justify-center gap-1 rounded-md px-1 py-1 text-[var(--fs-tiny)] leading-none text-foreground/55 transition-colors hover:bg-surface-hover hover:text-foreground" title="工作台首页">
<Home className="app-workspace-nav-icon" strokeWidth={1.8} />
<span>首页</span>
</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-4">
{topRailNavTools.map((tool) => renderSidebarLink(tool))}
</nav>
<nav className="mt-4 flex w-full flex-col items-center gap-4 pb-3">
{bottomRailNavTools.map((tool) => renderSidebarLink(tool))}
</nav>
</div>
<div className="app-workspace-rail-footer shrink-0 px-2 pb-4 pt-3">
<WorkspaceSidebarFooter
showAnnouncement={false}
expandedClassName="hidden"
collapsedClassName="flex h-10 w-10 items-center justify-center rounded-md"
accountClassName="justify-center rounded-md"
/>
</div>
</div>
{!hideChrome ? (
<aside
className={cn(
"app-workspace-sidebar flex h-full shrink-0 flex-col overflow-hidden",
mobileSidebarExpanded && "is-mobile-expanded",
desktopSidebarCollapsed && "is-collapsed",
)}
style={desktopSidebarCollapsed ? ({ "--workspace-sidebar-width": "0px" } as CSSProperties) : undefined}
>
<WorkspaceSidebarNav onNavigate={handleNavClick} onOpenSearch={() => setPaletteOpen(true)} />
</aside>
) : null}
{/* 移动端:抽屉完整导航 */}
<div className="app-workspace-mobile-nav flex min-h-0 flex-1 flex-col overflow-hidden lg:hidden">
<div className="flex h-14 shrink-0 items-center gap-2 px-3">
<Link to="/" className="flex min-w-0 items-center gap-2" title="影策">
<span className="app-workspace-brand-mark grid size-7 shrink-0 place-items-center rounded-md bg-foreground text-background"><InfinityIcon className="size-4" /></span>
<span className="truncate text-[var(--fs-body)] font-semibold">影策</span>
</Link>
</div>
<nav
ref={scrollRef}
onScroll={handleScroll}
className={cn(
"app-workspace-sidebar-scroll-area flex min-h-0 flex-1 flex-col px-2 py-3",
scrollState.hasTopFade && "has-top-fade",
scrollState.hasBottomFade && "has-bottom-fade",
)}
>
{visibleNavigationTools.map((tool, index) => {
const Icon = tool.icon;
const active = tool.slug === activeToolSlug;
const showSection = index === 0 || tool.section !== visibleNavigationTools[index - 1]?.section;
return (
<Fragment key={tool.slug}>
{showSection ? <div className="mb-2 px-2 text-[var(--fs-tiny)] font-medium text-foreground/34">{tool.section}</div> : null}
<Link
to={`/${tool.slug}`}
title={tool.label}
onClick={handleNavClick}
onFocus={() => preloadWorkspaceRoute(tool.slug)}
onPointerDown={() => preloadWorkspaceRoute(tool.slug)}
onPointerEnter={() => preloadWorkspaceRoute(tool.slug)}
className={cn("app-workspace-nav-link relative mb-1 flex h-11 shrink-0 items-center gap-3 rounded-md px-2.5 text-[var(--fs-body)] transition-colors", active ? "is-active font-medium" : "text-foreground/55 hover:bg-surface-hover hover:text-foreground/85")}
>
<Icon className="app-workspace-nav-icon shrink-0" strokeWidth={1.8} />
<span className="truncate">{tool.label}</span>
</Link>
</Fragment>
);
})}
</nav>
<div className="shrink-0 p-2">
<WorkspaceSidebarFooter
showAnnouncement={false}
expandedClassName="flex"
collapsedClassName="hidden"
accountClassName="flex-row gap-2 px-2"
/>
</div>
</div>
</aside>
) : null}
<div className="app-workspace-stage relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{showGlobalTopBar ? <WorkspaceTopBar sidebarOpen={isMobileViewport() ? mobileSidebarExpanded : !desktopSidebarCollapsed} onToggleSidebar={toggleSidebar} /> : null}
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden">{children}</div>
</div>
</div>
<div className="app-workspace-stage relative min-h-0 min-w-0 flex-1 overflow-hidden">
{children}
</div>
</div>
<WorkspaceCommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
</div>
<ModelSetupGuide hidden={pathname === "/login" || pathname === "/register" || pathname.startsWith("/admin")} />
</>

View File

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

View File

@@ -0,0 +1,157 @@
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="p-3 pb-2">
<div className="flex h-10 items-center gap-2 rounded-[var(--r-md)] border border-[var(--workspace-border)] bg-foreground/5 px-3 transition-colors hover:border-[var(--workspace-border-strong)] focus-within:border-[var(--workspace-border-strong)] focus-within:bg-foreground/[.06]">
<Search className="size-4 shrink-0 text-foreground/45" strokeWidth={1.6} />
<input
ref={inputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
className="min-w-0 flex-1 bg-transparent text-[var(--fs-body)] outline-none placeholder:text-foreground/45"
placeholder="搜索页面或操作…"
/>
<kbd
onClick={onClose}
className="hidden h-5 shrink-0 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"
>
⌘K
</kbd>
<button
type="button"
onClick={onClose}
className="ml-0.5 shrink-0 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>
<div className="max-h-80 overflow-y-auto p-2">
{filtered.length ? (
<ul className="flex flex-col gap-0.5">
{filtered.map((entry, index) => {
const Icon = entry.icon;
return (
<li key={entry.id}>
<button
type="button"
onMouseEnter={() => setHighlight(index)}
onClick={() => {
onClose();
navigate(entry.to);
}}
className={cn(
"flex w-full items-center gap-2.5 rounded-[var(--r-sm)] px-3 py-2.5 text-left text-[var(--fs-body)] transition-colors",
index === highlight ? "bg-surface-hover text-foreground" : "text-foreground/65",
)}
>
<Icon className={cn("size-4 shrink-0", index === highlight ? "text-foreground" : "text-foreground/55")} strokeWidth={1.6} />
<span className="truncate">{entry.title}</span>
</button>
</li>
);
})}
</ul>
) : (
<div className="flex flex-col items-center justify-center py-8">
<Command className="mb-2 size-6 text-foreground/30" strokeWidth={1.5} />
<p className="text-[var(--fs-caption)] font-medium text-foreground/55">未找到「{query}」相关页面</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { Button, Pagination } from "antd";
import { ListFilter, RotateCcw } from "lucide-react";
import { Button, Select } from "antd";
import { ChevronLeft, ChevronRight, ListFilter, RotateCcw } from "lucide-react";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
@@ -67,24 +67,39 @@ export function CollectionGrid({ children, className }: { children: ReactNode; c
return <div className={cn("mt-4 grid grid-cols-1 gap-4 sm:grid-cols-[repeat(auto-fill,minmax(248px,1fr))]", className)}>{children}</div>;
}
/* 自研轻量分页:页码胶囊 + 省略号 + 每页条数 + 总数,替代 AntD Pagination(无 AntD 残留样式,与工具栏容器同语言)。 */
function pageItems(current: number, pages: number): (number | "…")[] {
if (pages <= 7) return Array.from({ length: pages }, (_, i) => i + 1);
if (current <= 4) return [1, 2, 3, 4, 5, "…", pages];
if (current >= pages - 3) return [1, "…", pages - 4, pages - 3, pages - 2, pages - 1, pages];
return [1, "…", current - 1, current, current + 1, "…", pages];
}
export function PaginationBar({ current, pageSize, total, onChange, pageSizeOptions = [20, 50, 100], alwaysShow = false }: { current: number; pageSize: number; total: number; onChange: (page: number, pageSize: number) => void; pageSizeOptions?: number[]; alwaysShow?: boolean }) {
if (!alwaysShow && total <= pageSize && current === 1) return null;
const pages = Math.max(1, Math.ceil(total / pageSize));
const start = total === 0 ? 0 : (current - 1) * pageSize + 1;
const end = total === 0 ? 0 : Math.min(current * pageSize, total);
const end = total === 0 ? 0 : Math.min(total, current * pageSize);
const items = pageItems(current, pages);
return (
<div className="app-pagination-bar admin-pagination-bar min-w-0">
<div className="app-pagination-bar admin-pagination-bar mt-4 flex min-h-10 min-w-0 items-center justify-end gap-2 px-2 py-1.5">
<span className="admin-pagination-total">{total === 0 ? "共 0 条" : `${start}-${end} / 共 ${total} 条`}</span>
<Pagination
<Select
size="small"
current={current}
pageSize={pageSize}
total={total}
showSizeChanger={pageSizeOptions.length > 1}
responsive
showLessItems
pageSizeOptions={pageSizeOptions.map(String)}
onChange={onChange}
value={pageSize}
className="app-pagination-size"
options={pageSizeOptions.map((size) => ({ value: size, label: `${size} 条/页` }))}
onChange={(value) => onChange(1, Number(value))}
/>
<div className="app-pagination-pages" role="navigation" aria-label="分页">
<button type="button" className="app-pagination-btn app-pagination-prev" disabled={current <= 1} aria-label="上一页" onClick={() => onChange(current - 1, pageSize)}><ChevronLeft className="size-4" /></button>
{items.map((item) => item === "…" ? (
<span key={`ellipsis-${items.indexOf(item)}`} className="app-pagination-ellipsis">…</span>
) : (
<button key={item} type="button" className={`app-pagination-btn${item === current ? " is-active" : ""}`} aria-current={item === current ? "page" : undefined} onClick={() => onChange(item, pageSize)}>{item}</button>
))}
<button type="button" className="app-pagination-btn app-pagination-next" disabled={current >= pages} aria-label="下一页" onClick={() => onChange(current + 1, pageSize)}><ChevronRight className="size-4" /></button>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,414 @@
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: "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 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>
);
}
function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout }: {
group: WorkspaceNavGroup;
activeId: string;
onNavigate: () => void;
onOpenSearch: () => void;
onLogout: () => void;
}) {
const [isOpen, setIsOpen] = useState(true);
const hasActive = group.items.some((item) => item.id === activeId || (item.id === "settings" && activeId.startsWith("settings:")));
// 激活项所在分组自动展开,保证当前位置可见。
useEffect(() => {
if (hasActive) setIsOpen(true);
}, [hasActive]);
const content = (
<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={onLogout}
/>
))}
</div>
);
// 无标题分组(核心导航入口)常驻展示,不做折叠。
if (!group.heading) {
return <div className="flex shrink-0 flex-col">{content}</div>;
}
return (
<div className="flex shrink-0 flex-col">
<button
type="button"
onClick={() => setIsOpen((open) => !open)}
aria-expanded={isOpen}
className="app-workspace-nav-group-toggle select-none"
>
<span className="app-workspace-nav-group-label">{group.heading}</span>
<ChevronRight
className={cn("size-3.5 shrink-0 text-foreground/35 transition-transform duration-200", isOpen && "rotate-90")}
strokeWidth={2}
/>
</button>
<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="min-h-0 overflow-hidden pt-0.5">{content}</div>
</div>
</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 className="shrink-0 px-3 pb-1 pt-2">
<button
type="button"
onClick={onOpenSearch}
className="group flex h-9 w-full items-center gap-2 rounded-[var(--r-lg)] bg-foreground/5 px-3 text-left text-[var(--fs-caption)] text-muted-foreground transition-colors hover:bg-foreground/[.07] hover:text-foreground/70"
>
<Search className="size-4 shrink-0 text-muted-foreground group-hover:text-foreground/70" strokeWidth={1.6} />
<span className="flex-1 truncate">快速搜索</span>
<kbd className="flex h-5 shrink-0 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">
⌘K
</kbd>
</button>
</div>
<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) => (
<NavGroup
key={index}
group={group}
activeId={activeId}
onNavigate={onNavigate}
onOpenSearch={onOpenSearch}
onLogout={() => void handleLogout()}
/>
))}
</div>
<div className="app-workspace-sidebar-footer shrink-0 px-3 py-3">
<div className="flex flex-col gap-0.5">
{footer.map((item) => (
<NavItem
key={item.id}
item={item}
activeId={activeId}
onSelect={onNavigate}
onOpenSearch={onOpenSearch}
onLogout={() => void handleLogout()}
/>
))}
</div>
</div>
</div>
);
}

View File

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

View 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 };
}

View File

@@ -71,7 +71,8 @@ const controlTheme = {
export function getAntThemeConfig(dark: boolean): ThemeConfig {
const color = dark ? controlTheme.dark : controlTheme.light;
const elevatedBackground = dark ? "rgba(31, 31, 32, 0.96)" : "rgba(255, 255, 255, 0.96)";
// 浮层背景必须不透明(0.96 会透出下层内容);与侧栏切换器等自定义浮层的实底背景保持一致
const elevatedBackground = dark ? "rgba(31, 31, 32, 1)" : "rgba(255, 255, 255, 1)";
const subtleBackground = dark ? "rgba(255, 255, 255, 0.055)" : "rgba(17, 17, 17, 0.035)";
const interactiveBorder = dark ? "rgba(255, 255, 255, 0.18)" : "rgba(17, 17, 17, 0.18)";
// 黑白主题使用边框表达焦点,避免输入控件周围出现蓝紫色光圈。

View File

@@ -1289,7 +1289,7 @@ function CreationEmptySuggest({ onStartPrompt, onOpenLibrary }: { onStartPrompt:
{creationEmptySuggestions.map((item) => {
const Icon = item.icon;
return <button key={item.title} type="button" className="suggest-card" onClick={() => { if (item.openLibrary) onOpenLibrary(); else onStartPrompt(item.mode, item.prompt); }}>
<span className={`suggest-icon is-${item.mode}`}><Icon size={15} strokeWidth={2} /></span>
<span className={`library-icon-tile suggest-icon is-${item.mode}`}><Icon size={15} strokeWidth={2} /></span>
<span className="suggest-copy"><strong>{item.title}</strong><span>{item.hint}</span></span>
</button>;
})}

View File

@@ -1,11 +1,12 @@
import { App, Button, Dropdown, Input, Select, Tooltip } from "antd";
import { Check, Heart, Library, LoaderCircle, MoreHorizontal, Plus, RotateCcw, Search, Sparkles, UserRound } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Boxes, Check, Clapperboard, Heart, Library, LoaderCircle, Megaphone, MoreHorizontal, Palette, Plus, Puzzle, Search, ShoppingBag, Sparkles, UserRound } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { PaginationBar, PageHeader, WorkspacePage } from "@/components/layout/workspace-page";
import { ListToolbar, PaginationBar, PageHeader, WorkspacePage } from "@/components/layout/workspace-page";
import { WorkspaceErrorState, WorkspaceState } from "@/components/layout/workspace-state";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { fallbackSkillCategories, formatSkillCount, groupSkills } from "@/pages/skills/skill-catalog";
import { fallbackSkillCategories, formatSkillCount, groupSkills, skillCategoryLabel } from "@/pages/skills/skill-catalog";
import { SkillDetailDrawer } from "@/pages/skills/skill-detail-drawer";
import { SkillEditorDrawer } from "@/pages/skills/skill-editor-drawer";
import { addSkill, deleteSkill, getSkill, likeSkill, listSkills, removeSkill, unlikeSkill, type Skill, type SkillCategory, type SkillScope, type SkillSort } from "@/services/api/skills";
@@ -17,6 +18,16 @@ const scopeOptions = [
{ label: "我的收藏", value: "favorites", icon: Heart },
];
/* 分类图标映射:画廊卡片顶部的图标块,未知分类回退 Boxes。 */
const categoryIcons: Record<string, LucideIcon> = {
drama: Clapperboard,
ecommerce: ShoppingBag,
creative: Palette,
social: Megaphone,
others: Puzzle,
};
const categoryIconOf = (value: string) => categoryIcons[value] ?? Boxes;
const sortOptions: { label: string; value: SkillSort }[] = [
{ label: "最多加入", value: "popular" },
{ label: "最新发布", value: "new" },
@@ -35,6 +46,17 @@ export default function SkillsPage() {
const [skills, setSkills] = useState<Skill[]>([]);
const [categories, setCategories] = useState<SkillCategory[]>(fallbackSkillCategories);
const [total, setTotal] = useState(0);
const [counts, setCounts] = useState<Partial<Record<SkillScope, number>>>({});
const tabsRef = useRef<HTMLDivElement>(null);
const indicatorRef = useRef<HTMLSpanElement>(null);
useLayoutEffect(() => {
const tabs = tabsRef.current;
const indicator = indicatorRef.current;
const active = tabs?.querySelector<HTMLButtonElement>('[aria-selected="true"]');
if (!tabs || !indicator || !active) return;
indicator.style.left = `${active.offsetLeft}px`;
indicator.style.width = `${active.offsetWidth}px`;
}, [scope, counts]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
const [reloadKey, setReloadKey] = useState(0);
@@ -55,6 +77,7 @@ export default function SkillsPage() {
if (cancelled) return;
setSkills(result.skills);
setTotal(result.total_count);
setCounts((prev) => ({ ...prev, [scope]: result.total_count }));
if (result.categories.length) setCategories(result.categories);
})
.catch((error) => {
@@ -73,6 +96,7 @@ export default function SkillsPage() {
const groupedSkills = useMemo(() => groupSkills(skills, categories), [categories, skills]);
const filtersActive = Boolean(search || tag !== "all" || sort !== "popular");
const resetFilters = useCallback(() => { setSearch(""); setTag("all"); setSort("popular"); setPage(1); }, []);
const openSkill = async (skill: Skill) => {
setActiveSkill(skill);
@@ -163,57 +187,64 @@ export default function SkillsPage() {
return (
<>
<WorkspacePage className="library-page skills-library-page" grid>
<div className="studio-band">
<PageHeader title="技能库" description="把常用的提示词、角色设定和创作方法收进自己的工具架。" meta={<span className="text-xs text-foreground/45">{total} 个技能</span>} actions={null} />
</div>
<section className="skills-hero" aria-labelledby="skills-hero-title">
<div className="skills-hero-inner">
<span className="skills-hero-badge"><Sparkles className="size-3.5" />技能广场</span>
<h1 id="skills-hero-title" className="skills-hero-title">技能库</h1>
<p className="skills-hero-description">把常用的提示词、角色设定和创作方法收进自己的工具架。</p>
<span className="skills-hero-meta">{total} 个技能</span>
</div>
</section>
<div className="mt-1 flex flex-col border-b border-border/75 xl:flex-row xl:items-end xl:justify-between">
<nav className="thin-scrollbar -mb-px flex min-w-0 overflow-x-auto" aria-label="技能库范围" role="tablist">
<ListToolbar className="library-toolbar skills-toolbar mt-7" active={filtersActive} onReset={resetFilters}>
<div className="skills-tabs" ref={tabsRef} role="tablist" aria-label="技能库范围">
<span className="skills-tabs-indicator" ref={indicatorRef} aria-hidden="true" />
{scopeOptions.map((option) => {
const Icon = option.icon;
const active = scope === option.value;
const count = counts[option.value as SkillScope];
return (
<button
key={option.value}
type="button"
role="tab"
aria-selected={active}
className={`relative inline-flex h-12 shrink-0 items-center gap-2 border-b-2 px-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ${active ? "border-foreground font-medium text-foreground" : "border-transparent text-foreground/48 hover:text-foreground/76"}`}
className={`skills-tab${active ? " is-active" : ""}`}
onClick={() => { setScope(option.value as SkillScope); setPage(1); }}
>
<Icon className="size-4" />
{option.label}
<span>{option.label}</span>
{count !== undefined ? <span className="skills-tab-count">{count}</span> : null}
</button>
);
})}
</nav>
<div className="library-toolbar-controls flex min-w-0 flex-wrap items-center gap-2 py-2.5 xl:flex-nowrap xl:justify-end">
<Input className="w-full sm:!w-72" prefix={<Search className="size-4 text-foreground/38" />} value={search} allowClear placeholder="搜索技能或作者" onChange={(event) => { setSearch(event.target.value); setPage(1); }} />
<Select className="w-[136px]" value={tag} options={[{ value: "all", label: "全部分类" }, ...categories]} onChange={(value) => { setTag(value); setPage(1); }} />
<Select className="w-[124px]" value={sort} options={sortOptions} onChange={(value) => { setSort(value); setPage(1); }} />
{filtersActive ? (
<Tooltip title="重置筛选">
<Button type="text" aria-label="重置筛选" icon={<RotateCcw className="size-4" />} onClick={() => { setSearch(""); setTag("all"); setSort("popular"); setPage(1); }} />
</Tooltip>
) : null}
</div>
</div>
{loading ? <SkillSkeleton /> : loadError ? <WorkspaceErrorState compact description={loadError} onRetry={reload} /> : groupedSkills.length ? (
<div className="space-y-9 py-6">
{groupedSkills.map((group) => (
<section key={group.value} aria-labelledby={`skill-category-${group.value}`}>
<div className="mb-3 flex items-baseline justify-between px-0.5">
<h2 id={`skill-category-${group.value}`} className="text-sm font-medium text-foreground/62">{group.label}</h2>
<span className="text-[var(--fs-label)] text-foreground/32">{group.skills.length} 个</span>
</div>
<div className="library-grid skill-library-grid">
{groupedSkills[0] === group ? <button type="button" className="library-create-card" onClick={() => void openEditor()}><span className="library-create-cover"><Plus className="size-8" /></span><span className="library-create-title">创建技能</span><span className="library-create-meta">把一套方法变成可复用能力</span></button> : null}
{group.skills.map((skill) => <SkillCard key={skill.skill_id} skill={skill} loading={mutatingID === skill.skill_id} onOpen={() => void openSkill(skill)} onAdd={() => void toggleAdded(skill)} onLike={() => void toggleLiked(skill)} onEdit={() => void openEditor(skill)} onDelete={() => confirmDelete(skill)} />)}
</div>
</section>
))}
<Input className="min-w-0 sm:!w-56" prefix={<Search className="size-4 text-foreground/38" />} value={search} allowClear placeholder="搜索技能或作者" onChange={(event) => { setSearch(event.target.value); setPage(1); }} />
<Select className="w-28" value={tag} options={[{ value: "all", label: "全部分类" }, ...categories]} onChange={(value) => { setTag(value); setPage(1); }} />
<Select className="w-24" value={sort} options={sortOptions} onChange={(value) => { setSort(value); setPage(1); }} />
</ListToolbar>
{loading && !skills.length ? <SkillSkeleton /> : loadError ? <WorkspaceErrorState compact description={loadError} onRetry={reload} /> : groupedSkills.length ? (
<div key={`${scope}-${page}`} className="skills-scope-panel space-y-9 py-6">
{groupedSkills.map((group) => {
const GroupIcon = categoryIconOf(group.value);
return (
<section key={group.value} aria-labelledby={`skill-category-${group.value}`}>
<div className="mb-3 flex items-center justify-between px-0.5">
<h2 id={`skill-category-${group.value}`} className="flex items-center gap-2 text-base font-semibold text-foreground/75">
<span className="skill-group-icon"><GroupIcon /></span>
{group.label}
</h2>
<span className="text-[var(--fs-label)] text-foreground/32">{group.skills.length} 个</span>
</div>
<div className="library-grid skill-library-grid">
{groupedSkills[0] === group ? <button type="button" className="library-create-card" onClick={() => void openEditor()}><span className="library-create-cover"><Plus className="size-8" /></span><span className="library-create-title">创建技能</span><span className="library-create-meta">把一套方法变成可复用能力</span></button> : null}
{group.skills.map((skill, index) => <SkillCard key={skill.skill_id} skill={skill} categories={categories} loading={mutatingID === skill.skill_id} style={{ animationDelay: `${Math.min(index, 10) * 45}ms` }} onOpen={() => void openSkill(skill)} onAdd={() => void toggleAdded(skill)} onLike={() => void toggleLiked(skill)} onEdit={() => void openEditor(skill)} onDelete={() => confirmDelete(skill)} />)}
</div>
</section>
);
})}
</div>
) : (
<WorkspaceState
@@ -239,9 +270,11 @@ export default function SkillsPage() {
);
}
function SkillCard({ skill, loading, onOpen, onAdd, onLike, onEdit, onDelete }: { skill: Skill; loading: boolean; onOpen: () => void; onAdd: () => void; onLike: () => void; onEdit: () => void; onDelete: () => void }) {
function SkillCard({ skill, categories, loading, style, onOpen, onAdd, onLike, onEdit, onDelete }: { skill: Skill; categories: SkillCategory[]; loading: boolean; style?: CSSProperties; onOpen: () => void; onAdd: () => void; onLike: () => void; onEdit: () => void; onDelete: () => void }) {
const CategoryIcon = categoryIconOf(skill.tag);
return (
<article className={`library-card library-card-surface skill-library-card group${skill.is_added ? " is-selected is-added" : ""}`}>
<article style={style} className={`library-card library-card-surface skill-library-card group${skill.is_added ? " is-selected is-added" : ""}`}>
<span className="library-icon-tile skill-card-icon" aria-hidden="true"><CategoryIcon /></span>
<div className="skill-card-top">
<button type="button" className="skill-card-title-button" onClick={onOpen}>
<h3>{skill.skill_name}</h3>
@@ -272,6 +305,7 @@ function SkillCard({ skill, loading, onOpen, onAdd, onLike, onEdit, onDelete }:
<span>{formatSkillCount(skill.like_count)}</span>
</button>
<span className="skill-card-author">{skill.effective_user.name || "未知用户"}</span>
<span className="skill-card-tag">{skillCategoryLabel(skill.tag, categories)}</span>
{skill.is_private ? <span className="skill-card-flag">仅自己</span> : null}
</div>
{/* 加入是这个页面的主行为,给它完整的按钮 + 文案 + 已加入人数,不再藏在角落的加号里。 */}
@@ -291,5 +325,5 @@ function SkillCard({ skill, loading, onOpen, onAdd, onLike, onEdit, onDelete }:
}
function SkillSkeleton() {
return <div className="library-grid skill-library-grid py-6">{Array.from({ length: 8 }, (_, index) => <div key={index} className="h-[218px] animate-pulse rounded-[var(--r-xl)] bg-foreground/[.035]" />)}</div>;
return <div className="library-grid skill-library-grid py-6">{Array.from({ length: 8 }, (_, index) => <div key={index} className="h-[260px] animate-pulse rounded-[var(--r-xl)] bg-foreground/[.035]" />)}</div>;
}

View File

@@ -146,7 +146,7 @@ export default function WalletPage() {
<div className="wallet-balance-inner">
<div className="wallet-balance-primary">
<div className="wallet-balance-heading">
<span className="wallet-balance-icon"><Coins /></span>
<span className="library-icon-tile wallet-balance-icon"><Coins /></span>
<div><strong>可用创作积分</strong><span>最近更新 {formatTime(account?.updatedAt)}</span></div>
</div>
<div className="wallet-balance-number">

View File

@@ -403,6 +403,12 @@
--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; /* 侧栏导航内容宽度,不受折叠状态影响 */
--workspace-shell-gutter: 6px; /* 工作区外壳画布留白与卡片间隙(组件 token)*/
--workspace-card-gap: 4px; /* 侧边栏与主区卡片之间间隙(组件 token)*/
/* --- Semantic 层(引用 Primitive,随主题切换)--- */
/* 通用语义色(引用 Primitive neutral 色板)*/
@@ -690,8 +696,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);
@@ -885,12 +889,12 @@
--workspace-navigation: var(--workspace-page);
--workspace-content: var(--workspace-page);
--workspace-surface: rgba(255, 255, 255, 0.74);
--workspace-surface-strong: rgba(255, 255, 255, 0.94);
--workspace-surface-strong: rgba(255, 255, 255, 1);
--workspace-border: rgba(17, 17, 17, 0.1);
--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);
@@ -906,21 +910,16 @@
--workspace-navigation: var(--workspace-page);
--workspace-content: var(--workspace-page);
--workspace-surface: rgba(18, 18, 18, 0.76);
--workspace-surface-strong: rgba(22, 22, 22, 0.94);
--workspace-surface-strong: rgba(22, 22, 22, 1);
--workspace-border: rgba(255, 255, 255, 0.09);
--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);
}
.app-workspace-shell.is-spatial {
position: relative;
gap: 0;
padding: 0;
}
.app-workspace-sidebar-scrim {
position: absolute;
@@ -931,22 +930,65 @@
backdrop-filter: blur(2px);
}
.app-workspace-shell.is-spatial .app-workspace-sidebar {
border: 0;
border-radius: 0;
background: var(--workspace-sidebar);
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: 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;
}
.dark .app-workspace-shell.is-spatial .app-workspace-sidebar {
/* 分组标题:小号大写标签,弱化但可读;有标题的分组支持折叠,padding/间距由折叠按钮接管 */
.app-workspace-nav-group-label {
display: block;
font-size: var(--fs-label);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: color-mix(in srgb, var(--foreground) 36%, transparent);
transition: color var(--motion-instant) ease-out;
}
.app-workspace-nav-group-toggle {
display: flex;
width: 100%;
min-height: 28px;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 8px;
border-radius: var(--r-sm);
text-align: left;
color: inherit;
transition: background-color var(--motion-instant) ease-out;
}
.app-workspace-nav-group-toggle:hover,
.app-workspace-nav-group-toggle:focus-visible {
background: var(--surface-hover);
}
.app-workspace-nav-group-toggle:hover .app-workspace-nav-group-label,
.app-workspace-nav-group-toggle:focus-visible .app-workspace-nav-group-label {
color: color-mix(in srgb, var(--foreground) 55%, transparent);
}
.app-workspace-nav-group-toggle:hover svg,
.app-workspace-nav-group-toggle:focus-visible svg {
color: color-mix(in srgb, var(--foreground) 70%, transparent);
}
/* 工作区切换器下拉浮层 */
.app-workspace-nav-popover {
box-shadow: none;
}
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 +998,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);
@@ -969,7 +1011,7 @@
body.app-spatial-overlays :where(
.ant-dropdown-menu,
.ant-select-dropdown,
.ant-popover-inner,
.ant-popover-container,
.ant-picker-dropdown .ant-picker-panel-container,
.ant-cascader-menus,
.ant-mentions-dropdown,
@@ -980,27 +1022,23 @@
.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) {
body.app-spatial-overlays :where(.ant-dropdown-menu, .ant-select-dropdown, .ant-popover-container, .ant-cascader-menus, .ant-mentions-dropdown) {
border-radius: 8px !important;
}
/* 展开菜单(下拉/弹出)取消阴影:层级由边框 + 背景对比表达 */
body.app-spatial-overlays :where(.ant-dropdown-menu, .ant-select-dropdown, .ant-popover-container, .ant-cascader-menus, .ant-mentions-dropdown) {
box-shadow: none !important;
}
body.app-spatial-overlays :where(
.ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled),
.ant-dropdown-menu-submenu-title:not(.ant-dropdown-menu-item-disabled),
@@ -1033,8 +1071,8 @@
body.app-spatial-overlays :where(
.ant-dropdown-menu-item-divider,
.ant-popover-inner-content > .border-b,
.ant-popover-inner-content > .border-y,
.ant-popover-content > .border-b,
.ant-popover-content > .border-y,
.ant-modal-header,
.ant-modal-footer,
.ant-drawer-header
@@ -1053,25 +1091,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,20 +1099,7 @@
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;
background: var(--workspace-content);
}
/* 工作区保留滚轮与触控板滚动,只隐藏会形成额外竖向分割的浏览器轨道。 */
.app-spatial-workspace .app-workspace-scroll {
@@ -1183,16 +1189,11 @@
}
@media (max-width: 639px) {
.app-workspace-shell.is-spatial {
gap: 6px;
padding: 6px;
}
.app-workspace-shell.is-spatial .app-workspace-sidebar,
.app-workspace-shell.is-spatial .app-workspace-stage {
border-radius: 6px;
/* 窄屏下侧栏常驻,收缩到图标主轨,避免遮挡主内容 */
.app-workspace-shell.is-spatial .app-workspace-sidebar.is-collapsed {
width: 0 !important;
}
}
@media (max-width: 1023px) {
@@ -1200,14 +1201,21 @@
left: 0;
}
.app-workspace-shell .app-workspace-sidebar {
width: 0 !important;
border: 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) 7%, transparent);
border-radius: var(--r-xl);
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,13 +1223,71 @@
}
.app-workspace-shell {
position: relative;
padding: var(--workspace-shell-gutter);
background: var(--workspace-page);
}
.app-workspace-sidebar {
/* 画布编辑器与运营后台(hideChrome 契约):保持全屏连体,不套圆角卡片 */
.app-workspace-shell:not(.is-spatial) {
padding: 0;
}
.app-workspace-shell:not(.is-spatial) .app-workspace-stage {
border: 0;
border-radius: 0;
box-shadow: none;
}
/* 工作区卡片(侧栏 / 主区)共享外观:边框、圆角、阴影(深浅主题对称) */
.app-workspace-sidebar,
.app-workspace-stage {
border: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent);
border-radius: var(--r-xl);
box-shadow:
0 14px 44px color-mix(in srgb, var(--foreground) 12%, transparent),
inset 0 1px 0 color-mix(in srgb, white 6%, transparent);
}
.dark .app-workspace-sidebar,
.dark .app-workspace-stage {
box-shadow:
0 14px 44px rgba(0, 0, 0, 0.45),
inset 0 1px 0 color-mix(in srgb, white 6%, transparent);
}
.app-workspace-sidebar {
width: var(--workspace-sidebar-width, 260px);
background: var(--workspace-sidebar);
transition: width var(--motion-state) var(--ease-aceternity);
transition: width var(--motion-state) var(--ease-aceternity), border-color var(--motion-instant) ease-out, box-shadow var(--motion-instant) ease-out;
}
.app-workspace-shell .app-workspace-sidebar.is-collapsed {
width: 0 !important;
border: 0;
box-shadow: none;
}
/* 侧栏折叠后主区四周贴齐视口:取消外壳留白、行间距与卡片圆角,四周无间隙 */
.app-workspace-shell:has(.app-workspace-sidebar.is-collapsed) {
padding: 0;
}
.app-workspace-shell:has(.app-workspace-sidebar.is-collapsed) .app-workspace-main-row {
gap: 0;
}
.app-workspace-shell:has(.app-workspace-sidebar.is-collapsed) .app-workspace-stage {
border-radius: 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,
@@ -1230,6 +1296,7 @@
background: var(--workspace-content);
}
.app-workspace-page {
background: var(--workspace-content);
}
@@ -1298,6 +1365,7 @@
.app-workspace-main-row {
display: flex;
gap: var(--workspace-card-gap);
min-height: 0;
min-width: 0;
flex: 1 1 auto;
@@ -1307,11 +1375,13 @@
.app-workspace-topbar {
position: relative;
z-index: var(--z-toolbar);
height: var(--page-topbar-h);
background: var(--workspace-navigation);
height: 44px;
border: 0;
border-radius: 0;
background: transparent;
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,13 +1397,17 @@
}
.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;
}
/* 账户触发按钮为身份标识,占位图标默认不弱化(与其他工具图标区分) */
.app-workspace-account-trigger > span > svg {
color: var(--foreground);
}
.app-workspace-topbar-icon-button:hover {
background: var(--surface-hover);
color: var(--foreground);
@@ -1344,124 +1418,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: auto !important;
min-height: 40px;
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;
@@ -1540,12 +1510,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 {
@@ -3290,60 +3260,6 @@
.creation-user-message { max-width: 82%; }
}
@media (min-width: 1024px) {
.app-workspace-shell.is-spatial {
gap: 0;
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) {
.creation-new-button {
@@ -3736,6 +3652,40 @@
padding-inline: 0;
}
/* 技能中心 hero:GalleryGridBlock 式标题区——徽章 + 大标题 + 描述居中,背景点阵 + 双光斑,底部淡出到页面背景。仅技能页。 */
.skills-hero { position: relative; overflow: hidden; margin-top: 4px; border-radius: var(--r-xl); background: color-mix(in srgb, var(--foreground) 2%, transparent); }
.skills-hero::before { position: absolute; inset: 0; background-image: radial-gradient(circle at 16% 0%, color-mix(in srgb, var(--primary) 13%, transparent), transparent 42%), radial-gradient(circle at 84% 8%, color-mix(in srgb, var(--primary) 7%, transparent), transparent 40%), radial-gradient(circle at 50% 130%, color-mix(in srgb, var(--foreground) 10%, transparent), transparent 46%), radial-gradient(circle, color-mix(in srgb, var(--foreground) 4.5%, transparent) 1px, transparent 1.6px); background-size: 100% 100%, 100% 100%, 100% 100%, 22px 22px; content: ""; pointer-events: none; }
.skills-hero::after { position: absolute; right: 0; bottom: 0; left: 0; height: 40px; background: linear-gradient(to bottom, transparent, color-mix(in srgb, var(--workspace-content) 92%, transparent)); content: ""; pointer-events: none; }
.skills-hero-inner { position: relative; display: flex; min-height: 128px; flex-direction: column; align-items: center; justify-content: center; padding: 20px 24px 24px; text-align: center; }
.skills-hero-badge { display: inline-flex; align-items: center; gap: 6px; margin-bottom: 8px; border-radius: 999px; background: color-mix(in srgb, var(--primary) 12%, transparent); padding: 4px 12px; color: var(--primary); font-size: var(--fs-caption); font-weight: 560; line-height: 1.4; }
.skills-hero-badge svg { width: 14px; height: 14px; stroke-width: 1.8; }
.skills-hero-title { color: var(--foreground); font-size: var(--fs-display); font-weight: 720; letter-spacing: -0.02em; line-height: 1.15; }
.skills-hero-description { margin-top: 6px; max-width: 460px; color: color-mix(in srgb, var(--foreground) 56%, transparent); font-size: var(--fs-body); line-height: 1.6; }
.skills-hero-meta { margin-top: 8px; display: inline-flex; align-items: center; gap: 6px; border-radius: 999px; background: color-mix(in srgb, var(--foreground) 6%, transparent); padding: 4px 12px; color: color-mix(in srgb, var(--foreground) 46%, transparent); font-size: var(--fs-label); font-variant-numeric: tabular-nums; }
/* 移动端收窄 hero 高度,避免抢占首屏。 */
@media (max-width: 640px) { .skills-hero-inner { min-height: 112px; padding: 16px 18px 20px; } .skills-hero-title { font-size: var(--fs-title); } }
/* 技能页工具栏与 hero 圆角容器之间留出小间隙(共享 .library-toolbar 的 margin-top 8px 会被 Tailwind mt-7 覆盖,故用更高特异性局部类固定 20px)。 */
.library-toolbar.skills-toolbar { margin-top: 20px; }
/* 技能中心 scope 切换:shadcn TabsList 语言——底托 + 激活胶囊,图标 + 计数徽章;内容区切换淡入上移。 */
.skills-tabs { position: relative; display: inline-flex; min-width: 0; flex: 0 1 auto; max-width: 100%; align-items: center; gap: 2px; overflow-x: auto; border-radius: var(--r-xl); background: color-mix(in srgb, var(--foreground) 5%, transparent); padding: 4px; }
/* tab 按钮须为定位元素(DOM 顺序在指示器之后)以渲染在胶囊之上,否则 absolute 指示器会遮住文字与图标。 */
.skills-tab { position: relative; display: inline-flex; height: 32px; flex: 0 0 auto; align-items: center; gap: 5px; border: 0; border-radius: var(--r-lg); background: transparent; padding-inline: 10px; color: color-mix(in srgb, var(--foreground) 48%, transparent); font-size: var(--fs-body); font-weight: 520; transition: color 160ms ease-out; }
.skills-tab:hover { color: color-mix(in srgb, var(--foreground) 76%, transparent); }
.skills-tab.is-active { color: var(--foreground); font-weight: 560; }
.skills-tabs-indicator { position: absolute; top: 4px; bottom: 4px; left: 0; border-radius: var(--r-lg); background: var(--workspace-content); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); transition: left 300ms cubic-bezier(0.16, 1, 0.3, 1), width 300ms cubic-bezier(0.16, 1, 0.3, 1); }
/* 深主题:激活胶囊须亮于底托(--workspace-content 在深色下与页面底色相同,会形成黑块),用白色叠加提亮,白字在亮灰胶囊上保持可读。 */
.dark .skills-tabs-indicator { background: color-mix(in srgb, white 10%, var(--workspace-content)); }
.skills-tab:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; }
.skills-tab svg { width: 16px; height: 16px; stroke-width: 1.8; }
.skills-tab-count { display: inline-flex; min-width: 18px; height: 18px; align-items: center; justify-content: center; border-radius: 999px; background: color-mix(in srgb, var(--foreground) 9%, transparent); padding-inline: 5px; color: color-mix(in srgb, var(--foreground) 44%, transparent); font-size: var(--fs-label); font-variant-numeric: tabular-nums; line-height: 1; }
/* 激活徽章用实底 primary + primary-foreground:明暗主题均为一对已定义的前景/背景组合,避免深主题下浅底亮字对比不足。 */
.skills-tab.is-active .skills-tab-count { background: var(--primary); color: var(--primary-foreground); }
@keyframes skills-panel-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
.skills-scope-panel { animation: skills-panel-in 280ms cubic-bezier(0.16, 1, 0.3, 1) both; }
.skill-group-icon { display: inline-flex; width: 20px; height: 20px; align-items: center; justify-content: center; border-radius: var(--r-sm); background: color-mix(in srgb, var(--foreground) 6%, transparent); color: color-mix(in srgb, var(--foreground) 55%, transparent); }
.skill-group-icon svg { width: 13px; height: 13px; stroke-width: 1.8; }
.canvas-library-page .studio-band .canvas-library-discovery {
border: 0;
border-radius: 0;
@@ -4042,19 +3992,25 @@
.library-toolbar { min-height: 50px; margin-top: 8px; border-bottom: 0; padding: 7px 0 10px; }
.library-toolbar .ant-input-affix-wrapper,
.library-toolbar .ant-select,
.library-toolbar .ant-segmented { border: 0 !important; background: color-mix(in srgb, var(--foreground) 5%, transparent) !important; box-shadow: none !important; }
.library-toolbar .ant-select-selector,
.library-toolbar .ant-segmented { border: 0 !important; background: color-mix(in srgb, var(--foreground) 5%, transparent) !important; box-shadow: none !important; }
.library-toolbar .ant-input-affix-wrapper { border-radius: var(--r-lg); }
.library-toolbar .ant-select-selector { border-radius: var(--r-md) !important; }
.library-toolbar .ant-segmented { border-radius: var(--r-md); }
.library-toolbar .ant-input-affix-wrapper { border-radius: var(--r-xl) !important; height: 40px; padding: 4px 10px; }
/* 搜索框作为与 tab 容器同构的圆角容器:外壳 12px 圆角 + 浅底 + 内边距,内部输入区透明融入。 */
.library-toolbar .ant-input-affix-wrapper .ant-input { border: 0; background: transparent; box-shadow: none; }
/* AntD v6 单选 Select 的边框/圆角/背景/高度全部在 .ant-select 根元素上(.ant-select-selector 类已不存在),覆盖必须命中根元素并带 !important。 */
.library-toolbar .ant-select { border-radius: var(--r-xl) !important; display: flex; height: 40px !important; align-items: center !important; padding-inline: 10px !important; }
/* tab 切换后搜索框输入与下拉选中文字与 tab 激活态(--foreground)同色;占位符与图标维持各自 token 不变。 */
.library-toolbar .ant-input,
.library-toolbar .ant-select { color: var(--foreground) !important; }
.library-toolbar .ant-segmented { border-radius: var(--r-xl); }
.library-toolbar .ant-btn { border: 0; background: transparent; color: color-mix(in srgb, var(--foreground) 52%, transparent); }
.library-toolbar .ant-btn:hover { background: color-mix(in srgb, var(--foreground) 7%, transparent); color: var(--foreground); }
.library-toolbar-controls .ant-input-affix-wrapper,
.library-toolbar-controls .ant-select-selector { border: 0 !important; background: color-mix(in srgb, var(--foreground) 5%, transparent) !important; box-shadow: none !important; }
.library-toolbar-controls .ant-input-affix-wrapper { border-radius: var(--r-lg); }
.library-toolbar-controls .ant-select-selector { border-radius: var(--r-md) !important; }
.library-grid { display: grid; align-items: start; gap: 26px 18px; }
.library-card { position: relative; min-width: 0; overflow: hidden; cursor: pointer; border: 0; border-radius: var(--r-2xl); background: transparent; color: var(--foreground); transition: transform var(--motion-state) var(--motion-ease-out), background-color var(--motion-state) ease-out; }
/* 库页图标瓦片共享基础:grid 居中 + 浅底;尺寸/圆角/颜色由各用例类覆盖(skill-card-icon、wallet-balance-icon、suggest-icon 共用)。 */
.library-icon-tile { display: grid; flex: 0 0 auto; place-items: center; background: var(--library-icon-surface); }
.library-card-surface { background: var(--library-surface); box-shadow: none; }
.library-card-surface:hover, .library-card-surface:focus-within { background: var(--library-surface-hover); box-shadow: none; }
.library-card-surface.is-selected { box-shadow: inset 0 0 0 1px var(--library-selected-stroke); }
@@ -4070,7 +4026,7 @@
.library-create-title { margin-top: 15px; font-size: var(--fs-heading-lg); font-weight: 620; line-height: 1.3; }
.library-create-meta { margin-top: 6px; color: color-mix(in srgb, var(--foreground) 38%, transparent); font-size: var(--fs-caption); }
/* 技能网格里的创建卡要和技能卡等高,不然首格会短一截。 */
.skill-library-grid .library-create-card { justify-content: center; min-height: 218px; border-radius: var(--r-xl); background: var(--card-surface); padding: 16px; box-shadow: none; }
.skill-library-grid .library-create-card { justify-content: center; min-height: 260px; border-radius: var(--r-xl); background: var(--card-surface); padding: 16px; box-shadow: none; }
.skill-library-grid .library-create-cover { aspect-ratio: auto; min-height: 96px; flex: 1 1 auto; border-radius: var(--r-lg); }
.skill-library-grid .library-create-title { margin-top: 13px; font-size: var(--fs-body-lg); }
@@ -4421,31 +4377,39 @@
}
/* 技能卡与其他作品库卡片共用无框表面,仅选中态显示状态描边。 */
.skill-library-card { display: flex; min-height: 218px; flex-direction: column; border-radius: var(--r-xl); padding: 16px 16px 14px; }
.skill-library-card { display: flex; min-height: 260px; flex-direction: column; padding: 16px 16px 14px; animation: skill-card-in 420ms cubic-bezier(0.16, 1, 0.3, 1) backwards; }
/* 画廊入场:切换 scope/翻页时卡片依次淡入上浮(stagger 由内联 animation-delay 控制),入场后保持 transform 复位以免影响 hover。 */
@keyframes skill-card-in { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
.skill-card-icon { width: 48px; height: 48px; margin-bottom: 12px; border-radius: var(--r-lg); color: color-mix(in srgb, var(--foreground) 64%, transparent); transition: background-color var(--motion-state) ease-out, color var(--motion-state) ease-out, transform var(--motion-state) ease-out; transform: translateY(0); }
.skill-card-icon svg { width: 22px; height: 22px; stroke-width: 1.8; }
.skill-library-card:hover .skill-card-icon, .skill-library-card:focus-within .skill-card-icon { background: color-mix(in srgb, var(--foreground) 8%, transparent); color: var(--foreground); transform: translateY(-2px); }
.skill-card-top { display: flex; min-height: 0; align-items: flex-start; gap: 8px; }
.skill-card-title-button { min-width: 0; flex: 1 1 auto; border: 0; background: transparent; padding: 0; color: inherit; text-align: left; }
.skill-card-title-button:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; border-radius: var(--r-xs); }
.skill-library-card h3 { display: -webkit-box; overflow: hidden; color: var(--foreground); font-size: var(--fs-body-lg); font-weight: 620; line-height: 1.4; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.skill-library-card h3 { overflow: hidden; color: var(--foreground); font-size: var(--fs-body-lg); font-weight: 620; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; }
.skill-card-more { display: inline-flex; width: 28px; height: 28px; flex: 0 0 auto; align-items: center; justify-content: center; margin: -3px -5px 0 0; border: 0; border-radius: var(--r-sm); background: transparent; color: color-mix(in srgb, var(--foreground) 40%, transparent); opacity: 0; transition: opacity 180ms ease-out, background-color 160ms ease-out; }
.skill-library-card:hover .skill-card-more, .skill-card-more:focus-visible, .skill-library-card:focus-within .skill-card-more { opacity: 1; }
.skill-card-more:hover { background: color-mix(in srgb, var(--foreground) 8%, transparent); color: var(--foreground); }
.skill-card-description { min-height: 0; flex: 1 1 auto; margin-top: 8px; border: 0; background: transparent; padding: 0; text-align: left; }
.skill-card-description:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; border-radius: var(--r-xs); }
.skill-card-description p { display: -webkit-box; overflow: hidden; color: color-mix(in srgb, var(--foreground) 50%, transparent); font-size: var(--fs-caption); line-height: 1.6; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
.skill-card-description p { display: -webkit-box; overflow: hidden; color: color-mix(in srgb, var(--foreground) 50%, transparent); font-size: var(--fs-caption); line-height: 1.6; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.skill-card-footer { display: flex; min-width: 0; align-items: center; gap: 10px; margin-top: 12px; color: color-mix(in srgb, var(--foreground) 40%, transparent); font-size: var(--fs-label); }
.skill-card-like { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 4px; border: 0; background: transparent; padding: 0; color: inherit; font-variant-numeric: tabular-nums; transition: color 160ms ease-out; }
.skill-card-like:hover { color: var(--foreground); }
.skill-card-like:disabled { cursor: wait; }
.skill-card-author { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.skill-card-author { min-width: 0; flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.skill-card-tag { flex: 0 0 auto; max-width: 76px; overflow: hidden; border-radius: var(--r-sm); background: color-mix(in srgb, var(--foreground) 7%, transparent); padding: 2px 7px; color: color-mix(in srgb, var(--foreground) 50%, transparent); font-size: var(--fs-micro); text-overflow: ellipsis; white-space: nowrap; }
.skill-card-flag { margin-left: auto; flex: 0 0 auto; color: color-mix(in srgb, var(--foreground) 52%, transparent); }
.skill-card-action { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
.skill-card-join { display: inline-flex; min-width: 0; height: 32px; flex: 1 1 auto; align-items: center; justify-content: center; gap: 6px; overflow: hidden; border: 1px solid color-mix(in srgb, var(--foreground) 34%, transparent); border-radius: var(--r-md); background: transparent; padding-inline: 12px; color: var(--foreground); font-size: var(--fs-caption); font-weight: 560; white-space: nowrap; transition: border-color 160ms ease-out, background-color 160ms ease-out, color 160ms ease-out; }
/* 加入按钮:无边框浅底(与 tab/搜索容器同语言),is-added 态弱化表达已加入。 */
.skill-card-join { display: inline-flex; min-width: 0; height: 32px; flex: 1 1 auto; align-items: center; justify-content: center; gap: 6px; overflow: hidden; border: 0; border-radius: var(--r-md); background: color-mix(in srgb, var(--foreground) 6%, transparent); padding-inline: 12px; color: var(--foreground); font-size: var(--fs-caption); font-weight: 560; white-space: nowrap; transition: background-color 160ms ease-out, color 160ms ease-out; }
.skill-card-join svg { color: currentColor; }
.skill-card-join:hover { border-color: color-mix(in srgb, var(--foreground) 58%, transparent); background: color-mix(in srgb, var(--foreground) 7%, transparent); color: var(--foreground); }
.skill-card-join:hover { background: color-mix(in srgb, var(--foreground) 10%, transparent); color: var(--foreground); }
.skill-card-join:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.skill-card-join:disabled { cursor: wait; opacity: .6; }
.skill-card-join.is-added { border-color: color-mix(in srgb, var(--foreground) 18%, transparent); background: color-mix(in srgb, var(--foreground) 8%, transparent); color: color-mix(in srgb, var(--foreground) 66%, transparent); }
.skill-card-join.is-added:hover { border-color: color-mix(in srgb, var(--foreground) 30%, transparent); background: color-mix(in srgb, var(--foreground) 12%, transparent); color: var(--foreground); }
.skill-card-join.is-added { background: color-mix(in srgb, var(--foreground) 8%, transparent); color: color-mix(in srgb, var(--foreground) 66%, transparent); }
.skill-card-join.is-added:hover { background: color-mix(in srgb, var(--foreground) 12%, transparent); color: var(--foreground); }
.skill-card-added-count { flex: 0 0 auto; color: color-mix(in srgb, var(--foreground) 36%, transparent); font-size: var(--fs-label); font-variant-numeric: tabular-nums; }
.skill-card-owner-flag { flex: 1 1 auto; color: color-mix(in srgb, var(--foreground) 46%, transparent); font-size: var(--fs-caption); }
@@ -4461,7 +4425,7 @@
.wallet-balance-heading > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.wallet-balance-heading strong { font-size: var(--fs-caption); font-weight: 600; }
.wallet-balance-heading div > span { overflow: hidden; color: color-mix(in srgb, var(--foreground) 44%, transparent); font-size: var(--fs-label); text-overflow: ellipsis; white-space: nowrap; }
.wallet-balance-icon { display: grid; width: 36px; height: 36px; flex: 0 0 auto; place-items: center; border-radius: var(--r-md); background: var(--library-icon-surface); color: color-mix(in srgb, var(--foreground) 68%, transparent); }
.wallet-balance-icon { width: 36px; height: 36px; border-radius: var(--r-md); color: color-mix(in srgb, var(--foreground) 68%, transparent); }
.wallet-balance-icon svg { width: 17px; height: 17px; }
.wallet-balance-number { display: flex; min-width: 0; align-items: baseline; gap: 8px; margin-top: 28px; }
.wallet-balance-number strong { min-width: 0; overflow-wrap: anywhere; font-size: var(--fs-display); font-variant-numeric: tabular-nums; font-weight: 620; line-height: 1; }
@@ -4616,13 +4580,16 @@
white-space: nowrap;
}
.app-pagination-bar .ant-pagination {
display: flex;
min-width: 0;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-1) 0;
}
/* 自研分页:页码胶囊 28px、r-md 圆角、无边框、hover 浅底、激活实底胶囊。选中用“普通选中”色角色(workspace-content 实底 + foreground 文字,与 skills-tabs 激活胶囊同语言),不占用 primary(主操作按钮专属)。 */
.app-pagination-total { flex: 0 0 auto; color: color-mix(in srgb, var(--foreground) 46%, transparent); font-size: var(--fs-caption); font-variant-numeric: tabular-nums; white-space: nowrap; }
.app-pagination-size.ant-select { display: flex; height: 28px !important; align-items: center !important; border-radius: var(--r-md) !important; padding-inline: 8px !important; }
.app-pagination-pages { display: inline-flex; align-items: center; gap: 2px; }
.app-pagination-btn { display: inline-flex; height: 28px; min-width: 28px; align-items: center; justify-content: center; border: 0; border-radius: var(--r-md); background: transparent; padding-inline: 6px; color: color-mix(in srgb, var(--foreground) 62%, transparent); font-size: var(--fs-body); line-height: 1; font-variant-numeric: tabular-nums; transition: background-color 160ms ease-out, color 160ms ease-out; }
.app-pagination-btn:hover:not(:disabled) { background: color-mix(in srgb, var(--foreground) 7%, transparent); color: var(--foreground); }
.app-pagination-btn.is-active, .app-pagination-btn.is-active:hover { background: var(--workspace-content); color: var(--foreground); }
.dark .app-pagination-btn.is-active, .dark .app-pagination-btn.is-active:hover { background: color-mix(in srgb, white 10%, var(--workspace-content)); }
.app-pagination-btn:disabled { color: color-mix(in srgb, var(--foreground) 30%, transparent); cursor: not-allowed; }
.app-pagination-ellipsis { display: inline-flex; height: 28px; min-width: 16px; align-items: center; justify-content: center; color: color-mix(in srgb, var(--foreground) 30%, transparent); font-size: var(--fs-body); }
:where(.ant-modal-content, .ant-drawer-content) {
border: 1px solid var(--border);
@@ -8742,17 +8709,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) {
@@ -8781,12 +8737,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 {
@@ -10527,13 +10477,9 @@ html:not(.dark) .creation-home .storyboard-workbench-bar-action:hover { backgrou
}
.creation-home .creation-empty-suggest .suggest-icon {
display: grid;
width: 36px;
height: 36px;
flex: 0 0 auto;
place-items: center;
border-radius: var(--r-sm);
background: var(--library-icon-surface);
color: color-mix(in srgb, var(--creation-text) 82%, transparent);
}