feat(*): 工作区体验 - 将管理员入口下沉到左侧栏并优化画布目录卡片
This commit is contained in:
129
web/src/components/canvas/canvas-folder-card.tsx
Normal file
129
web/src/components/canvas/canvas-folder-card.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Dropdown, Input } from "antd";
|
||||
import { Download, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
|
||||
import { ProjectPreview, formatProjectTime } from "@/components/canvas/canvas-project-card";
|
||||
import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
|
||||
import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CanvasFolderCardProps = {
|
||||
project: CanvasProject;
|
||||
projectName?: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
/** 画布库中的文件夹封面:单一卡片表面承载预览和信息,避免相邻卡片互相侵入。 */
|
||||
export function CanvasFolderCard({ project, projectName, onClick }: CanvasFolderCardProps) {
|
||||
const renameProject = useCanvasStore((state) => state.renameProject);
|
||||
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
|
||||
const editingId = useCanvasUiStore((state) => state.editingProjectId);
|
||||
const editingTitle = useCanvasUiStore((state) => state.editingProjectTitle);
|
||||
const startEditing = useCanvasUiStore((state) => state.startEditingProject);
|
||||
const setEditingTitle = useCanvasUiStore((state) => state.setEditingProjectTitle);
|
||||
const stopEditing = useCanvasUiStore((state) => state.stopEditingProject);
|
||||
const toggleSelected = useCanvasUiStore((state) => state.toggleSelectedProjectId);
|
||||
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
|
||||
const editing = editingId === project.id;
|
||||
const selected = selectedIds.includes(project.id);
|
||||
|
||||
const saveTitle = () => {
|
||||
renameProject(project.id, editingTitle);
|
||||
stopEditing();
|
||||
};
|
||||
|
||||
const handleOpenKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
if (!editing) onClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<article className={cn("canvas-folder-card", selected && "is-selected", editing && "is-editing")}>
|
||||
<div className="canvas-folder-open" role="button" tabIndex={0} aria-label={`打开画布 ${project.title}`} onClick={() => !editing && onClick()} onKeyDown={handleOpenKeyDown}>
|
||||
<div className="canvas-folder-preview" aria-hidden="true">
|
||||
<ProjectPreview project={project} preferLatestImage />
|
||||
</div>
|
||||
<div className="canvas-folder-body">
|
||||
<div className="canvas-folder-heading-row">
|
||||
{editing ? (
|
||||
<Input
|
||||
className="canvas-folder-title-input"
|
||||
value={editingTitle}
|
||||
onChange={(event) => setEditingTitle(event.target.value)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onBlur={saveTitle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") saveTitle();
|
||||
if (event.key === "Escape") stopEditing();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span className="canvas-folder-title">{project.title}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="canvas-folder-meta">
|
||||
<span className="canvas-folder-meta-item">{projectName || "自由画布"}</span>
|
||||
<span className="canvas-folder-meta-separator" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<span className="canvas-folder-meta-item">{project.nodes.length} 节点</span>
|
||||
<span className="canvas-folder-meta-separator" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<time className="canvas-folder-meta-item" dateTime={project.updatedAt}>
|
||||
{formatProjectTime(project.updatedAt).replace(/\s*修改$/, "")}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className={cn("canvas-folder-select", selected && "is-visible")} onClick={(event) => event.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={(event) => toggleSelected(project.id, event.target.checked)}
|
||||
aria-label={`选择 ${project.title}`}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<div className="canvas-folder-actions" onClick={(event) => event.stopPropagation()}>
|
||||
{!editing ? (
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-folder-rename"
|
||||
aria-label={`重命名 ${project.title}`}
|
||||
title="重命名"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
startEditing(project.id, project.title);
|
||||
}}
|
||||
>
|
||||
<Pencil />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
placement="bottomRight"
|
||||
menu={{
|
||||
onClick: ({ domEvent }) => domEvent.stopPropagation(),
|
||||
items: [
|
||||
{ key: "export", icon: <Download className="size-3.5" />, label: "导出画布", onClick: () => void exportCanvasProjects([project], project.title || "影策画布") },
|
||||
{ type: "divider" },
|
||||
{ key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除", onClick: () => setDeleteIds([project.id]) },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<button type="button" className="canvas-folder-action" aria-label={`${project.title} 画布操作`} title="更多操作" onClick={(event) => event.stopPropagation()}>
|
||||
<MoreHorizontal />
|
||||
</button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -105,14 +105,17 @@ export function CanvasProjectCard({ project, projectName, variant = "library", r
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectPreview({ project }: { project: CanvasProject }) {
|
||||
export function ProjectPreview({ project, preferLatestImage = false }: { project: CanvasProject; preferLatestImage?: boolean }) {
|
||||
const mediaNodes = project.nodes
|
||||
.flatMap((node) => {
|
||||
if (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) return [];
|
||||
const url = getNodeMediaUrl(node);
|
||||
return isPreviewUrl(url) ? [{ node, url, storageKey: node.metadata?.storageKey }] : [];
|
||||
});
|
||||
const media = mediaNodes.find(({ node }) => node.type === CanvasNodeType.Image) || mediaNodes[0];
|
||||
const imageNodes = mediaNodes.filter(({ node }) => node.type === CanvasNodeType.Image);
|
||||
const media = preferLatestImage
|
||||
? imageNodes[imageNodes.length - 1] || mediaNodes[mediaNodes.length - 1]
|
||||
: imageNodes[0] || mediaNodes[0];
|
||||
if (media) {
|
||||
const { node, url, storageKey } = media;
|
||||
return (
|
||||
@@ -145,7 +148,7 @@ function ProjectPreview({ project }: { project: CanvasProject }) {
|
||||
function getNodeMediaUrl(node: CanvasNodeData) {
|
||||
const resourceId = resourceIdFromStorageKey(node.metadata?.storageKey);
|
||||
if (resourceId) return resourceFileUrl(resourceId);
|
||||
return resolveBackendApiUrl(node.metadata?.content || "");
|
||||
return resolveBackendApiUrl(node.metadata?.previewContent || node.metadata?.content || "");
|
||||
}
|
||||
|
||||
function buildNodePreviewLayout(nodes: CanvasNodeData[]) {
|
||||
@@ -191,7 +194,7 @@ function isPreviewUrl(value?: string) {
|
||||
return Boolean(value && (/^(https?:|blob:|data:image\/|data:video\/|\/api\/)/.test(value)));
|
||||
}
|
||||
|
||||
function formatProjectTime(value: string) {
|
||||
export function formatProjectTime(value: string) {
|
||||
const timestamp = new Date(value).getTime();
|
||||
if (!Number.isFinite(timestamp)) return "刚刚修改";
|
||||
const elapsed = Math.max(0, Date.now() - timestamp);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
|
||||
import { ModelSetupGuide } from "@/components/layout/model-setup-guide";
|
||||
@@ -84,9 +84,13 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
mobileSidebarExpanded && "is-mobile-expanded",
|
||||
desktopSidebarCollapsed && "is-collapsed",
|
||||
)}
|
||||
style={desktopSidebarCollapsed ? ({ "--workspace-sidebar-width": "0px" } as CSSProperties) : undefined}
|
||||
>
|
||||
<WorkspaceSidebarNav onNavigate={handleNavClick} onOpenSearch={() => setPaletteOpen(true)} />
|
||||
<WorkspaceSidebarNav
|
||||
collapsed={desktopSidebarCollapsed}
|
||||
onNavigate={handleNavClick}
|
||||
onOpenSearch={() => setPaletteOpen(true)}
|
||||
onExpand={() => setDesktopSidebarCollapsed(false)}
|
||||
/>
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { Popover, Switch } from "antd";
|
||||
import { ChevronRight, CircleUserRound, LogIn, LogOut, Moon, ShieldCheck, Sun } from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { CircleUserRound, LogIn, Moon, Sun } from "lucide-react";
|
||||
import { useEffect, useState } 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);
|
||||
@@ -19,7 +18,6 @@ export function WorkspaceAccountMenu() {
|
||||
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
|
||||
@@ -34,11 +32,12 @@ export function WorkspaceAccountMenu() {
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
rootClassName="workspace-account-popover"
|
||||
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">
|
||||
<div className="flex items-center gap-3 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>
|
||||
@@ -46,13 +45,7 @@ export function WorkspaceAccountMenu() {
|
||||
</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">
|
||||
<div className="border-t border-border/35 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>
|
||||
|
||||
@@ -61,14 +54,6 @@ export function WorkspaceAccountMenu() {
|
||||
<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>
|
||||
)}
|
||||
>
|
||||
@@ -83,10 +68,6 @@ export function WorkspaceAccountMenu() {
|
||||
);
|
||||
}
|
||||
|
||||
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 : "";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronDown, ChevronRight, Home, Infinity as InfinityIcon, LogOut, Plus, Search } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Home, Infinity as InfinityIcon, LogOut, PanelLeftOpen, Plus, Search, ShieldCheck } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from "react";
|
||||
import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
@@ -40,7 +40,7 @@ function toolItem(slug: NavigationToolSlug, to: string): WorkspaceNavItem {
|
||||
return { id: slug, title: tool?.label ?? slug, icon: tool?.icon, to };
|
||||
}
|
||||
|
||||
function buildNav(features: FeatureAvailability, balance: string): { groups: WorkspaceNavGroup[]; footer: WorkspaceNavItem[] } {
|
||||
function buildNav(features: FeatureAvailability, balance: string, isAdmin: boolean): { groups: WorkspaceNavGroup[]; footer: WorkspaceNavItem[] } {
|
||||
const groups: WorkspaceNavGroup[] = [
|
||||
{
|
||||
items: [
|
||||
@@ -63,6 +63,7 @@ function buildNav(features: FeatureAvailability, balance: string): { groups: Wor
|
||||
|
||||
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) => ({
|
||||
@@ -77,7 +78,7 @@ function buildNav(features: FeatureAvailability, balance: string): { groups: Wor
|
||||
return { groups, footer };
|
||||
}
|
||||
|
||||
function WorkspaceSwitcher({ onNavigate }: { onNavigate: () => void }) {
|
||||
function WorkspaceSwitcher({ collapsed, onNavigate, onExpand }: { collapsed: boolean; onNavigate: () => void; onExpand: () => void }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -87,6 +88,22 @@ function WorkspaceSwitcher({ onNavigate }: { onNavigate: () => void }) {
|
||||
navigate(to);
|
||||
};
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="app-workspace-sidebar-rail-header shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className="app-workspace-sidebar-rail-button"
|
||||
aria-label="展开侧栏菜单"
|
||||
title="展开侧栏菜单"
|
||||
onClick={onExpand}
|
||||
>
|
||||
<PanelLeftOpen className="size-4" strokeWidth={1.7} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0 px-3 pt-3">
|
||||
<button
|
||||
@@ -146,13 +163,14 @@ function WorkspaceSwitcher({ onNavigate }: { onNavigate: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({ item, activeId, onSelect, onOpenSearch, onLogout, level = 0 }: {
|
||||
function NavItem({ item, activeId, onSelect, onOpenSearch, onLogout, level = 0, collapsed = false }: {
|
||||
item: WorkspaceNavItem;
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
onOpenSearch: () => void;
|
||||
onLogout: () => void;
|
||||
level?: number;
|
||||
collapsed?: boolean;
|
||||
}) {
|
||||
const isActive = activeId === item.id || (item.id === "settings" && activeId.startsWith("settings:"));
|
||||
const hasChildren = Boolean(item.children?.length);
|
||||
@@ -164,19 +182,19 @@ function NavItem({ item, activeId, onSelect, onOpenSearch, onLogout, level = 0 }
|
||||
}, [isActive, hasChildren]);
|
||||
|
||||
const Icon = item.icon;
|
||||
const rowStyle = { paddingLeft: `${level * 12 + 10}px` } as CSSProperties;
|
||||
const rowStyle = collapsed ? undefined : ({ paddingLeft: `${level * 12 + 10}px` } as CSSProperties);
|
||||
|
||||
const rowContent = (
|
||||
<>
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
<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="size-1.5 shrink-0 rounded-full bg-current opacity-40" aria-hidden />
|
||||
)}
|
||||
<span className="truncate">{item.title}</span>
|
||||
<span className="app-workspace-nav-title truncate">{item.title}</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<span className="app-workspace-nav-meta 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}
|
||||
@@ -196,6 +214,7 @@ function NavItem({ item, activeId, onSelect, onOpenSearch, onLogout, level = 0 }
|
||||
|
||||
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",
|
||||
collapsed && "is-collapsed",
|
||||
isActive ? "is-active font-medium" : "text-foreground/62 hover:bg-surface-hover hover:text-foreground",
|
||||
);
|
||||
|
||||
@@ -225,6 +244,8 @@ function NavItem({ item, activeId, onSelect, onOpenSearch, onLogout, level = 0 }
|
||||
to={linkTo}
|
||||
className={rowClassName}
|
||||
style={rowStyle}
|
||||
aria-label={collapsed ? item.title : undefined}
|
||||
title={collapsed ? item.title : undefined}
|
||||
onClick={handleClick}
|
||||
onFocus={() => preloadWorkspaceRoute(linkTo)}
|
||||
onPointerDown={() => preloadWorkspaceRoute(linkTo)}
|
||||
@@ -237,6 +258,8 @@ function NavItem({ item, activeId, onSelect, onOpenSearch, onLogout, level = 0 }
|
||||
type="button"
|
||||
className={rowClassName}
|
||||
style={rowStyle}
|
||||
aria-label={collapsed ? item.title : undefined}
|
||||
title={collapsed ? item.title : undefined}
|
||||
onClick={handleClick}
|
||||
aria-expanded={hasChildren ? isOpen : undefined}
|
||||
>
|
||||
@@ -244,7 +267,7 @@ function NavItem({ item, activeId, onSelect, onOpenSearch, onLogout, level = 0 }
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasChildren ? (
|
||||
{hasChildren && !collapsed ? (
|
||||
<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` }} />
|
||||
@@ -257,6 +280,7 @@ function NavItem({ item, activeId, onSelect, onOpenSearch, onLogout, level = 0 }
|
||||
onOpenSearch={onOpenSearch}
|
||||
onLogout={onLogout}
|
||||
level={level + 1}
|
||||
collapsed={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -266,12 +290,13 @@ function NavItem({ item, activeId, onSelect, onOpenSearch, onLogout, level = 0 }
|
||||
);
|
||||
}
|
||||
|
||||
function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout }: {
|
||||
function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout, collapsed }: {
|
||||
group: WorkspaceNavGroup;
|
||||
activeId: string;
|
||||
onNavigate: () => void;
|
||||
onOpenSearch: () => void;
|
||||
onLogout: () => void;
|
||||
collapsed: boolean;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const hasActive = group.items.some((item) => item.id === activeId || (item.id === "settings" && activeId.startsWith("settings:")));
|
||||
@@ -291,13 +316,14 @@ function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout }: {
|
||||
onSelect={onNavigate}
|
||||
onOpenSearch={onOpenSearch}
|
||||
onLogout={onLogout}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 无标题分组(核心导航入口)常驻展示,不做折叠。
|
||||
if (!group.heading) {
|
||||
if (!group.heading || collapsed) {
|
||||
return <div className="flex shrink-0 flex-col">{content}</div>;
|
||||
}
|
||||
|
||||
@@ -322,7 +348,7 @@ function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout }: {
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceSidebarNav({ onNavigate, onOpenSearch }: { onNavigate: () => void; onOpenSearch: () => void }) {
|
||||
export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExpand }: { collapsed: boolean; onNavigate: () => void; onOpenSearch: () => void; onExpand: () => void }) {
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const features = useUserStore((state) => state.features);
|
||||
@@ -335,7 +361,7 @@ export function WorkspaceSidebarNav({ onNavigate, onOpenSearch }: { onNavigate:
|
||||
? "--"
|
||||
: (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||
|
||||
const { groups, footer } = useMemo(() => buildNav(features, balance), [features, balance]);
|
||||
const { groups, footer } = useMemo(() => buildNav(features, balance, user?.role === "admin"), [features, balance, user?.role]);
|
||||
|
||||
const slug = pathname.split("/").filter(Boolean)[0] || "create";
|
||||
const section = searchParams.get("section");
|
||||
@@ -357,18 +383,20 @@ export function WorkspaceSidebarNav({ onNavigate, onOpenSearch }: { onNavigate:
|
||||
}, [groups]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-[var(--workspace-sidebar-nav-width)] shrink-0 flex-col">
|
||||
<WorkspaceSwitcher onNavigate={onNavigate} />
|
||||
<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="shrink-0 px-3 pb-1 pt-2">
|
||||
<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="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">
|
||||
<span className="app-workspace-sidebar-search-label flex-1 truncate">快速搜索</span>
|
||||
<kbd className="app-workspace-sidebar-search-shortcut 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>
|
||||
@@ -379,6 +407,7 @@ export function WorkspaceSidebarNav({ onNavigate, onOpenSearch }: { onNavigate:
|
||||
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",
|
||||
)}
|
||||
@@ -391,6 +420,7 @@ export function WorkspaceSidebarNav({ onNavigate, onOpenSearch }: { onNavigate:
|
||||
onNavigate={onNavigate}
|
||||
onOpenSearch={onOpenSearch}
|
||||
onLogout={() => void handleLogout()}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -405,6 +435,7 @@ export function WorkspaceSidebarNav({ onNavigate, onOpenSearch }: { onNavigate:
|
||||
onSelect={onNavigate}
|
||||
onOpenSearch={onOpenSearch}
|
||||
onLogout={() => void handleLogout()}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import CodeMirror, { EditorView } from "@uiw/react-codemirror";
|
||||
import { forwardRef, useImperativeHandle, useRef } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
Reference in New Issue
Block a user