feat(web): 用户工作台 - 产品化壳层、内容库布局与积分入口
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,16 +1,19 @@
|
||||
import { ChevronDown, ChevronRight, Home, Infinity as InfinityIcon, LogOut, PanelLeftOpen, Plus, Search, ShieldCheck } from "lucide-react";
|
||||
import { Popover } from "antd";
|
||||
import { Bell, ChevronDown, ChevronRight, CircleUserRound, History as HistoryIcon, Infinity as InfinityIcon, PanelLeftClose, PanelLeftOpen, Plus } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from "react";
|
||||
import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
import { BrandLogoFrame } from "@/components/brand/brand-logo";
|
||||
import { Kbd } from "@/components/ui/base/kbd";
|
||||
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
|
||||
import { useWalletBalance } from "@/hooks/use-wallet-balance";
|
||||
import { useWorkspaceLogout } from "@/hooks/use-workspace-logout";
|
||||
import { SystemAnnouncementCenter } from "@/components/layout/system-announcement-center";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { preloadWorkspaceRoute } from "@/lib/workspace-route-modules";
|
||||
import { useUserStore, type FeatureAvailability } from "@/stores/use-user-store";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
import { WorkspaceAccountCard } from "./workspace-account-card";
|
||||
import { WorkspaceWalletModal } from "./workspace-wallet-modal";
|
||||
|
||||
export type WorkspaceNavItem = {
|
||||
id: string;
|
||||
@@ -28,64 +31,65 @@ type WorkspaceNavGroup = {
|
||||
items: WorkspaceNavItem[];
|
||||
};
|
||||
|
||||
/** 设置页分区子项,沿用 /settings?section=<key> 路由合同。 */
|
||||
const SETTINGS_SECTIONS: Array<{ key: string; label: string }> = [
|
||||
{ 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, isAdmin: boolean): { groups: WorkspaceNavGroup[]; footer: WorkspaceNavItem[] } {
|
||||
function buildNav(features: FeatureAvailability, isAdmin: boolean): { groups: WorkspaceNavGroup[]; footer: WorkspaceNavItem[] } {
|
||||
const groups: WorkspaceNavGroup[] = [
|
||||
{
|
||||
items: [
|
||||
{ id: "home", title: "首页", icon: Home, to: "/" },
|
||||
toolItem("projects", "/projects"),
|
||||
toolItem("canvas", "/canvas"),
|
||||
...(features.taskCenterEnabled ? [toolItem("tasks", "/tasks")] : []),
|
||||
toolItem("assets", "/assets"),
|
||||
{ ...toolItem("create", "/"), id: "home", title: "创作" },
|
||||
{ ...toolItem("projects", "/projects"), title: "短剧 Agent" },
|
||||
{ ...toolItem("canvas", "/canvas"), title: "自由画布" },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "工作台管理",
|
||||
items: [toolItem("skills", "/skills"), ...(features.pluginCenterEnabled || isAdmin ? [toolItem("plugins", "/plugins")] : []), ...(features.creditsEnabled ? [{ ...toolItem("wallet", "/wallet"), badge: balance }] : [])],
|
||||
heading: "资源与工具",
|
||||
items: [{ ...toolItem("assets", "/assets"), title: "资产" }, { ...toolItem("skills", "/skills"), title: "技能" }, ...(features.pluginCenterEnabled || isAdmin ? [{ ...toolItem("plugins", "/plugins"), title: "插件" }] : [])],
|
||||
},
|
||||
...(features.taskCenterEnabled ? [{ items: [{ ...toolItem("tasks", "/tasks"), title: "创作历史", icon: HistoryIcon }] }] : []),
|
||||
];
|
||||
|
||||
const settingsSections = SETTINGS_SECTIONS.filter((section) => section.key !== "channels" || features.customChannelsEnabled);
|
||||
const footer: WorkspaceNavItem[] = [
|
||||
...(isAdmin ? [{ id: "admin", title: "管理员后台", icon: ShieldCheck, to: "/admin" }] : []),
|
||||
{
|
||||
...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 };
|
||||
// 管理、设置和退出登录不再占据参考站式侧栏底部,而是通过用户卡片菜单进入。
|
||||
// 路由和写操作仍保留,避免把用户端导航变成无法访问的装饰。
|
||||
return { groups, footer: [] };
|
||||
}
|
||||
|
||||
function WorkspaceSwitcher({ collapsed, onNavigate, onExpand }: { collapsed: boolean; onNavigate: () => void; onExpand: () => void }) {
|
||||
const appearance = useAppearanceStore((state) => state.appearance);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
function WorkspaceSidebarProfile({ collapsed, user }: { collapsed: boolean; user: NonNullable<ReturnType<typeof useUserStore.getState>["user"]> | null }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [walletOpen, setWalletOpen] = useState(false);
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const avatarUrl = /^https?:\/\//i.test(user?.avatarUrl || "") ? user?.avatarUrl : "";
|
||||
const profileName = user?.displayName || user?.username || "未登录";
|
||||
|
||||
const go = (to: string) => {
|
||||
setIsOpen(false);
|
||||
onNavigate();
|
||||
navigate(to);
|
||||
};
|
||||
useEffect(() => setFailed(false), [avatarUrl]);
|
||||
|
||||
if (!user) {
|
||||
return <Link to="/login" className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} aria-label="登录" title="登录"><CircleUserRound className="size-5" /><span>登录</span></Link>;
|
||||
}
|
||||
|
||||
const avatar = avatarUrl && !failed ? <img src={avatarUrl} alt="" referrerPolicy="no-referrer" onError={() => setFailed(true)} /> : <CircleUserRound aria-hidden />;
|
||||
const content = <WorkspaceAccountCard onNavigate={() => setMenuOpen(false)} onWallet={() => { setMenuOpen(false); setWalletOpen(true); }} />;
|
||||
|
||||
return (
|
||||
<div className={cn("app-workspace-sidebar-profile-row", collapsed && "is-collapsed")}>
|
||||
<Popover open={menuOpen} onOpenChange={setMenuOpen} trigger="click" placement="topLeft" rootClassName="workspace-account-popover" content={content}>
|
||||
<button type="button" className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} aria-label="打开账户菜单" title={profileName}>
|
||||
<span className="app-workspace-sidebar-profile-avatar">{avatar}</span>
|
||||
{!collapsed ? <span className="app-workspace-sidebar-profile-copy"><strong>{profileName}</strong><span>创作工作台</span></span> : null}
|
||||
</button>
|
||||
</Popover>
|
||||
{creditsEnabled ? <WorkspaceWalletModal open={walletOpen} onClose={() => setWalletOpen(false)} /> : null}
|
||||
{!collapsed ? <SystemAnnouncementCenter userId={user.id} className="app-workspace-sidebar-notification" /> : <span className="app-workspace-sidebar-notification-spacer" aria-hidden />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceSwitcher({ collapsed, onNavigate, onExpand, onCollapse }: { collapsed: boolean; onNavigate: () => void; onExpand: () => void; onCollapse: () => void }) {
|
||||
const appearance = useAppearanceStore((state) => state.appearance);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
@@ -98,52 +102,19 @@ function WorkspaceSwitcher({ collapsed, onNavigate, onExpand }: { collapsed: boo
|
||||
}
|
||||
|
||||
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">
|
||||
<BrandLogoFrame className="app-workspace-brand-mark grid size-8 shrink-0 place-items-center rounded-[var(--r-sm)] shadow-sm" logoClassName="size-5 object-contain" alt="" fallback={<InfinityIcon className="size-4" strokeWidth={2} />} />
|
||||
<div className="app-workspace-sidebar-brand-row relative shrink-0 px-3 pt-3">
|
||||
<Link to="/" onClick={onNavigate} className="app-workspace-sidebar-brand-button group" aria-label={`${appearance.brandName}首页`}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<BrandLogoFrame className="app-workspace-brand-mark grid size-8 shrink-0 place-items-center rounded-[var(--r-sm)] shadow-sm" logoClassName="size-5 object-contain" alt="" fallback={<InfinityIcon className="size-4" strokeWidth={2.2} />} />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="app-workspace-brand-wordmark truncate text-[var(--fs-body)] leading-none font-medium">{appearance.brandName}</span>
|
||||
<span className="mt-1 truncate text-[var(--fs-label)] leading-none text-foreground/42">创作工作台</span>
|
||||
<span className="app-workspace-brand-wordmark truncate text-[var(--fs-body)] leading-none font-semibold">{appearance.brandName}</span>
|
||||
<span className="mt-1 truncate text-[var(--fs-label)] leading-none text-foreground/60">创作工作台</span>
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className="size-4 shrink-0 text-foreground/40 transition-colors group-hover:text-foreground/70" strokeWidth={1.5} />
|
||||
</Link>
|
||||
<button type="button" className="app-workspace-sidebar-collapse-button" aria-label="收起侧栏" title="收起侧栏" onClick={onCollapse}>
|
||||
<PanelLeftClose className="size-4" strokeWidth={1.7} />
|
||||
</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">{appearance.brandName}</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)]" />
|
||||
<a href="/welcome" 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">
|
||||
品牌首页
|
||||
</a>
|
||||
{[
|
||||
{ label: "首页", to: "/" },
|
||||
{ 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>
|
||||
);
|
||||
}
|
||||
@@ -177,15 +148,18 @@ function NavItem({
|
||||
const Icon = item.icon;
|
||||
const rowStyle = collapsed ? undefined : ({ paddingLeft: `${level * 12 + 10}px` } as CSSProperties);
|
||||
|
||||
const collapsedTitle = item.id === "home" ? "创作" : item.id === "projects" ? "短剧" : item.id === "canvas" ? "画布" : item.id === "assets" ? "资产" : item.id === "skills" ? "技能" : item.id === "plugins" ? "插件" : item.id === "tasks" ? "历史" : item.title.slice(0, 2);
|
||||
const rowContent = (
|
||||
<>
|
||||
<span className="app-workspace-nav-main 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="app-workspace-nav-icon" aria-hidden>
|
||||
<Icon className="size-4 shrink-0" strokeWidth={1.8} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-current opacity-40" aria-hidden />
|
||||
)}
|
||||
<span className="app-workspace-nav-title truncate">{item.title}</span>
|
||||
<span className="app-workspace-nav-title truncate">{collapsed ? collapsedTitle : item.title}</span>
|
||||
</span>
|
||||
<span className="app-workspace-nav-meta flex shrink-0 items-center gap-2">
|
||||
{item.shortcut ? (
|
||||
@@ -198,7 +172,7 @@ function NavItem({
|
||||
);
|
||||
|
||||
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",
|
||||
"app-workspace-nav-link group flex min-h-11 w-full items-center justify-between gap-2 rounded-[var(--r-md)] px-3 py-2 text-[var(--fs-body)] transition-[background-color,color,transform] duration-200 select-none",
|
||||
collapsed && "is-collapsed",
|
||||
isActive ? "is-active font-medium" : "text-foreground/62 hover:bg-surface-hover hover:text-foreground",
|
||||
);
|
||||
@@ -228,6 +202,7 @@ function NavItem({
|
||||
<Link
|
||||
to={linkTo}
|
||||
className={rowClassName}
|
||||
data-nav-id={item.id}
|
||||
style={rowStyle}
|
||||
aria-label={collapsed ? item.title : undefined}
|
||||
title={collapsed ? item.title : undefined}
|
||||
@@ -239,7 +214,7 @@ function NavItem({
|
||||
{rowContent}
|
||||
</Link>
|
||||
) : (
|
||||
<button type="button" className={rowClassName} style={rowStyle} aria-label={collapsed ? item.title : undefined} title={collapsed ? item.title : undefined} onClick={handleClick} aria-expanded={hasChildren ? isOpen : undefined}>
|
||||
<button type="button" className={rowClassName} data-nav-id={item.id} style={rowStyle} aria-label={collapsed ? item.title : undefined} title={collapsed ? item.title : undefined} onClick={handleClick} aria-expanded={hasChildren ? isOpen : undefined}>
|
||||
{rowContent}
|
||||
</button>
|
||||
)}
|
||||
@@ -268,7 +243,7 @@ function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout, collaps
|
||||
}, [hasActive]);
|
||||
|
||||
const content = (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex flex-col gap-2">
|
||||
{group.items.map((item) => (
|
||||
<NavItem key={item.id} item={item} activeId={activeId} onSelect={onNavigate} onOpenSearch={onOpenSearch} onLogout={onLogout} collapsed={collapsed} />
|
||||
))}
|
||||
@@ -277,11 +252,11 @@ function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout, collaps
|
||||
|
||||
// 无标题分组(核心导航入口)常驻展示,不做折叠。
|
||||
if (!group.heading || collapsed) {
|
||||
return <div className="flex shrink-0 flex-col">{content}</div>;
|
||||
return <div className="app-workspace-nav-group flex shrink-0 flex-col" data-nav-group-heading={group.heading || ""}>{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col">
|
||||
<div className="app-workspace-nav-group flex shrink-0 flex-col" data-nav-group-heading={group.heading}>
|
||||
<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} />
|
||||
@@ -293,18 +268,13 @@ function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout, collaps
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExpand }: { collapsed: boolean; onNavigate: () => void; onOpenSearch: () => void; onExpand: () => void }) {
|
||||
export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExpand, onCollapse }: { collapsed: boolean; onNavigate: () => void; onOpenSearch: () => void; onExpand: () => void; onCollapse: () => 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, user?.role === "admin"), [features, balance, user?.role]);
|
||||
const { groups, footer } = useMemo(() => buildNav(features, user?.role === "admin"), [features, user?.role]);
|
||||
|
||||
const slug = pathname.split("/").filter(Boolean)[0] || "home";
|
||||
const section = searchParams.get("section");
|
||||
@@ -327,26 +297,12 @@ export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExp
|
||||
|
||||
return (
|
||||
<div className={cn("app-workspace-sidebar-nav flex h-full shrink-0 flex-col", collapsed && "is-collapsed")}>
|
||||
<WorkspaceSwitcher collapsed={collapsed} onNavigate={onNavigate} onExpand={onExpand} />
|
||||
|
||||
<div className="app-workspace-sidebar-search 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"
|
||||
aria-label={collapsed ? "快速搜索" : undefined}
|
||||
title={collapsed ? "快速搜索" : undefined}
|
||||
>
|
||||
<Search className="size-4 shrink-0 text-muted-foreground group-hover:text-foreground/70" strokeWidth={1.6} />
|
||||
<span className="app-workspace-sidebar-search-label flex-1 truncate">快速搜索</span>
|
||||
<Kbd className="app-workspace-sidebar-search-shortcut">⌘K</Kbd>
|
||||
</button>
|
||||
</div>
|
||||
<WorkspaceSwitcher collapsed={collapsed} onNavigate={onNavigate} onExpand={onExpand} onCollapse={onCollapse} />
|
||||
|
||||
<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", collapsed && "is-collapsed", scrollState.hasTopFade && "has-top-fade", scrollState.hasBottomFade && "has-bottom-fade")}
|
||||
className={cn("app-workspace-sidebar-scroll-area flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-3 pb-3 pt-7", collapsed && "is-collapsed", 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()} collapsed={collapsed} />
|
||||
@@ -354,11 +310,12 @@ export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExp
|
||||
</div>
|
||||
|
||||
<div className="app-workspace-sidebar-footer shrink-0 px-3 py-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<WorkspaceSidebarProfile collapsed={collapsed} user={user} />
|
||||
{footer.length ? <div className="mt-2 flex flex-col gap-0.5">
|
||||
{footer.map((item) => (
|
||||
<NavItem key={item.id} item={item} activeId={activeId} onSelect={onNavigate} onOpenSearch={onOpenSearch} onLogout={() => void handleLogout()} collapsed={collapsed} />
|
||||
))}
|
||||
</div>
|
||||
</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user