refactor(web): 工作区布局与技能库 - 完成侧栏导航、分页与卡片一致性改造
This commit is contained in:
@@ -1,47 +1,32 @@
|
||||
import { Home, Infinity as InfinityIcon } from "lucide-react";
|
||||
import { Fragment, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router";
|
||||
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
|
||||
import { ModelSetupGuide } from "@/components/layout/model-setup-guide";
|
||||
import { WorkspaceSidebarFooter } from "@/components/layout/workspace-sidebar-footer";
|
||||
import { WorkspaceCommandPalette } from "@/components/layout/workspace-command-palette";
|
||||
import { WorkspaceSidebarNav } from "@/components/layout/workspace-sidebar-nav";
|
||||
import { WorkspaceTopBar } from "@/components/layout/workspace-top-bar";
|
||||
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { refreshFeatureAvailability } from "@/lib/user-session";
|
||||
import { isSpatialWorkbenchPath } from "@/lib/workspace-routes";
|
||||
import { preloadWorkspaceRoute } from "@/lib/workspace-route-modules";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const user = useUserStore((state) => state.user);
|
||||
const features = useUserStore((state) => state.features);
|
||||
const [mobileSidebarExpanded, setMobileSidebarExpanded] = useState(false);
|
||||
const [desktopSidebarCollapsed, setDesktopSidebarCollapsed] = useState(false);
|
||||
const scrollRef = useRef<HTMLElement>(null);
|
||||
const [scrollState, setScrollState] = useState({
|
||||
hasTopFade: false,
|
||||
hasBottomFade: false,
|
||||
});
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
|
||||
const hideChrome = pathname.startsWith("/admin") || /^\/canvas\/[^/]+/.test(pathname);
|
||||
const showGlobalTopBar = !hideChrome;
|
||||
const spatialWorkbench = isSpatialWorkbenchPath(pathname);
|
||||
const creationWorkspace = pathname === "/create";
|
||||
const slug = pathname.split("/").filter(Boolean)[0];
|
||||
const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined;
|
||||
const visibleNavigationTools = (spatialWorkbench ? navigationTools : navigationTools.filter((tool) => tool.section === "创作空间"))
|
||||
.filter((tool) => {
|
||||
if (tool.slug === "projects") return features.shortDramaEnabled;
|
||||
if (tool.slug === "tasks") return features.taskCenterEnabled;
|
||||
if (tool.slug === "wallet") return features.creditsEnabled;
|
||||
return true;
|
||||
});
|
||||
const topRailNavTools = visibleNavigationTools.filter((tool) => tool.section === "创作空间" || tool.slug === "skills");
|
||||
const bottomRailNavTools = visibleNavigationTools.filter((tool) => tool.slug === "wallet");
|
||||
|
||||
const isMobileViewport = () => window.innerWidth < 1024;
|
||||
|
||||
const toggleSidebar = () => {
|
||||
if (window.innerWidth < 1024) {
|
||||
if (isMobileViewport()) {
|
||||
setMobileSidebarExpanded((current) => !current);
|
||||
return;
|
||||
}
|
||||
@@ -49,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")} />
|
||||
</>
|
||||
|
||||
106
web/src/components/layout/workspace-account-menu.tsx
Normal file
106
web/src/components/layout/workspace-account-menu.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Popover, Switch } from "antd";
|
||||
import { ChevronRight, CircleUserRound, LogIn, LogOut, Moon, ShieldCheck, Sun } from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { AppChangelogButton } from "@/components/layout/app-changelog-modal";
|
||||
import { IdentityProviderBadge } from "@/components/layout/identity-provider-badge";
|
||||
import { useWalletBalance } from "@/hooks/use-wallet-balance";
|
||||
import { useWorkspaceLogout } from "@/hooks/use-workspace-logout";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore, type LocalUser } from "@/stores/use-user-store";
|
||||
|
||||
/** 顶部栏头像账户菜单:账户信息、管理入口、版本、深色模式与退出登录。 */
|
||||
export function WorkspaceAccountMenu() {
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const user = useUserStore((state) => state.user);
|
||||
const hydrated = useUserStore((state) => state.hydrated);
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const { availableMicrocredits } = useWalletBalance(user?.id, creditsEnabled);
|
||||
const { handleLogout } = useWorkspaceLogout();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const balance = availableMicrocredits === null
|
||||
? "--"
|
||||
: (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||
|
||||
if (!hydrated) {
|
||||
return <span className="size-9 animate-pulse rounded-[var(--r-md)] bg-foreground/[.06]" aria-hidden />;
|
||||
}
|
||||
|
||||
return user ? (
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
open={menuOpen}
|
||||
onOpenChange={setMenuOpen}
|
||||
content={(
|
||||
<div className="w-56 py-0.5">
|
||||
<div className="flex items-center gap-3 border-b border-border/65 px-1 pb-3">
|
||||
<UserAvatar user={user} className="size-8" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5"><span className="truncate text-sm font-medium">{user.displayName || user.username}</span><IdentityProviderBadge user={user} /></div>
|
||||
{creditsEnabled ? <div className="mt-0.5 truncate text-[var(--fs-label)] tabular-nums text-foreground/45">可用 {balance} 积分</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.role === "admin" ? (
|
||||
<nav className="py-2" aria-label="管理工具">
|
||||
<MenuLink to="/admin" icon={<ShieldCheck />} label="管理员后台" onNavigate={() => setMenuOpen(false)} />
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<div className="border-y border-border/65 py-2">
|
||||
<AppChangelogButton className="flex h-8 w-full items-center gap-2 rounded px-2 text-[var(--fs-label)] text-foreground/58 hover:bg-surface-hover hover:text-foreground [&_svg]:size-3.5" showLabel showVersion versionClassName="ml-auto text-[var(--fs-micro)] tabular-nums text-foreground/32" />
|
||||
</div>
|
||||
|
||||
<div className="flex h-10 items-center px-2">
|
||||
{theme === "dark" ? <Moon className="size-3.5 text-foreground/45" /> : <Sun className="size-3.5 text-foreground/45" />}
|
||||
<span className="ml-2 flex-1 text-xs text-foreground/65">深色模式</span>
|
||||
<Switch size="small" checked={theme === "dark"} onChange={(checked) => setTheme(checked ? "dark" : "light")} aria-label="深色模式" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-full items-center gap-2 rounded px-2 text-xs text-foreground/55 hover:bg-surface-hover hover:text-foreground"
|
||||
onClick={() => void handleLogout()}
|
||||
>
|
||||
<LogOut className="size-3.5" />退出登录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<button type="button" className="app-workspace-topbar-icon-button 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>
|
||||
);
|
||||
}
|
||||
157
web/src/components/layout/workspace-command-palette.tsx
Normal file
157
web/src/components/layout/workspace-command-palette.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
414
web/src/components/layout/workspace-sidebar-nav.tsx
Normal file
414
web/src/components/layout/workspace-sidebar-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,50 @@
|
||||
import { Infinity as InfinityIcon, PanelLeftClose, PanelLeftOpen, Settings } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
|
||||
import { SystemAnnouncementCenter } from "@/components/layout/system-announcement-center";
|
||||
import { WorkspaceAccountMenu } from "@/components/layout/workspace-account-menu";
|
||||
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
const PAGE_TITLES: Record<string, string> = {
|
||||
home: "首页",
|
||||
create: "创作",
|
||||
projects: "短剧创作",
|
||||
canvas: "画布",
|
||||
tasks: "任务",
|
||||
assets: "素材",
|
||||
skills: "技能库",
|
||||
wallet: "积分中心",
|
||||
settings: "设置",
|
||||
};
|
||||
|
||||
export function WorkspaceTopBar({ sidebarOpen, onToggleSidebar }: { sidebarOpen: boolean; onToggleSidebar: () => void }) {
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const user = useUserStore((state) => state.user);
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const slug = pathname.split("/").filter(Boolean)[0];
|
||||
const pageTitle = (slug && PAGE_TITLES[slug]) || "影策";
|
||||
|
||||
return (
|
||||
<header className="app-workspace-topbar flex shrink-0 items-center justify-between gap-3 px-4 sm:px-5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<header className="app-workspace-topbar flex shrink-0 items-center justify-between gap-3 px-3 sm:px-4">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<button type="button" className="app-workspace-topbar-icon-button" aria-label={sidebarOpen ? "收起侧栏" : "展开侧栏"} onClick={onToggleSidebar}>
|
||||
{sidebarOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
|
||||
</button>
|
||||
<Link to="/" className="app-workspace-brand-link inline-flex min-w-0 items-center gap-2 text-foreground" title="影策工作台">
|
||||
<span className="app-workspace-brand-mark grid size-7 shrink-0 place-items-center rounded-md bg-foreground text-background">
|
||||
<InfinityIcon className="size-4" />
|
||||
</span>
|
||||
<span className="app-workspace-brand-wordmark truncate text-[var(--fs-body)] font-semibold">影策</span>
|
||||
</Link>
|
||||
<nav className="flex min-w-0 items-center gap-2 text-[var(--fs-caption)] text-foreground/50" aria-label="当前位置">
|
||||
<Link to="/" className="shrink-0 font-medium text-foreground/70 transition-colors hover:text-foreground">影策</Link>
|
||||
<span className="shrink-0 text-foreground/30">/</span>
|
||||
<span className="truncate font-medium text-foreground">{pageTitle}</span>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{user ? <SystemAnnouncementCenter userId={user.id} className="app-workspace-topbar-icon-button" /> : null}
|
||||
<AnimatedThemeToggler className="app-workspace-topbar-icon-button" theme={theme} onThemeChange={setTheme} aria-label="切换主题" />
|
||||
<Link to="/settings" className="app-workspace-topbar-icon-button" aria-label="设置" title="设置">
|
||||
<Settings className="size-4" />
|
||||
</Link>
|
||||
<WorkspaceAccountMenu />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
33
web/src/hooks/use-workspace-logout.ts
Normal file
33
web/src/hooks/use-workspace-logout.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { App } from "antd";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import { applyUserSession } from "@/lib/user-session";
|
||||
import { logout } from "@/services/api/auth";
|
||||
|
||||
/**
|
||||
* 工作区退出登录:供侧栏底部与顶部账户菜单共用。
|
||||
* 写路径强校验:失败必须明确提示,不得静默吞错。
|
||||
*/
|
||||
export function useWorkspaceLogout() {
|
||||
const navigate = useNavigate();
|
||||
const { message } = App.useApp();
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (loggingOut) return;
|
||||
setLoggingOut(true);
|
||||
try {
|
||||
await logout();
|
||||
await applyUserSession({ user: null, systemChannels: [] });
|
||||
message.success("已退出登录");
|
||||
navigate("/login", { replace: true });
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "退出失败");
|
||||
} finally {
|
||||
setLoggingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { handleLogout, loggingOut };
|
||||
}
|
||||
@@ -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)";
|
||||
// 黑白主题使用边框表达焦点,避免输入控件周围出现蓝紫色光圈。
|
||||
|
||||
@@ -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>;
|
||||
})}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user