feat: Header 顶部二三级菜单与 AI 写作表单收起优化

This commit is contained in:
fengxiangdebaozi committed 2026-06-12 17:53:52 +08:00
1 parent 9d73f27146
commit 768c6860f5
5 files changed
+270 -27

No files matched your search

+152 -1
View File
@@ -320,4 +320,155 @@
max-width: 64px;
opacity: 1;
transform: translateX(0);
}
}
.sidebar-contnet--top-submenu .item-nav:hover::before,
.sidebar-contnet--top-submenu .item-nav-active::before,
.sidebar-contnet--top-submenu .item-nav-hovered::before {
display: none;
}
.top-submenu-bar {
position: relative;
z-index: 35;
height: 64px;
min-width: 0;
flex: 1 1 auto;
align-self: stretch;
margin-left: 34px;
background: transparent;
border: 0;
box-shadow: none;
overflow-x: auto;
overflow-y: hidden;
}
.top-submenu-list {
min-width: max-content;
height: 64px;
display: flex;
align-items: stretch;
gap: 0;
padding: 0;
}
.top-submenu-item {
position: relative;
min-width: 122px;
height: 64px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
padding: 0 18px;
background: transparent;
color: rgba(224, 242, 255, 0.9);
font-family: Noto Sans S Chinese;
font-size: 16px;
line-height: 1;
font-weight: 500;
letter-spacing: 0;
white-space: nowrap;
cursor: pointer;
overflow: hidden;
transition: color 0.18s ease, text-shadow 0.18s ease, background 0.18s ease;
}
.top-submenu-item:hover,
.top-submenu-item--active {
color: #ffffff;
text-shadow: 0 0 10px rgba(91, 210, 255, 0.45);
background:
linear-gradient(135deg, rgba(15, 84, 169, 0.52) 0%, rgba(5, 43, 103, 0.34) 76%),
linear-gradient(180deg, rgba(39, 126, 230, 0.2) 0%, rgba(0, 50, 122, 0.26) 100%);
box-shadow: inset 0 1px 0 rgba(98, 180, 255, 0.16), inset 0 -18px 22px rgba(0, 31, 79, 0.24);
}
.top-submenu-item--active::before {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: linear-gradient(90deg, rgba(59, 162, 255, 0) 0%, #35d7ff 50%, rgba(59, 162, 255, 0) 100%);
box-shadow: 0 0 12px rgba(53, 215, 255, 0.9), 0 -6px 18px rgba(33, 143, 255, 0.36);
}
.top-submenu-item--active::after {
content: '';
position: absolute;
left: 16px;
right: 16px;
bottom: 0;
height: 16px;
background: radial-gradient(ellipse at bottom, rgba(37, 189, 255, 0.34) 0%, rgba(37, 189, 255, 0) 68%);
pointer-events: none;
}
.third-submenu-bar {
position: relative;
z-index: 34;
height: 42px;
margin-left: 118px;
flex-shrink: 0;
background:
linear-gradient(180deg, rgba(4, 34, 78, 0.96) 0%, rgba(2, 24, 58, 0.96) 100%),
radial-gradient(ellipse at top left, rgba(45, 149, 255, 0.18) 0%, rgba(45, 149, 255, 0) 42%);
border-top: 1px solid rgba(79, 154, 255, 0.18);
border-bottom: 1px solid rgba(43, 143, 254, 0.26);
box-shadow: inset 0 1px 0 rgba(115, 194, 255, 0.08), inset 0 -10px 18px rgba(0, 26, 70, 0.22);
overflow-x: auto;
overflow-y: hidden;
}
.third-submenu-list {
min-width: max-content;
height: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 0 24px;
}
.third-submenu-item {
position: relative;
height: 28px;
min-width: 86px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(90, 169, 255, 0.2);
border-radius: 2px;
padding: 0 16px;
background: rgba(2, 28, 65, 0.36);
color: rgba(221, 242, 255, 0.78);
font-family: Noto Sans S Chinese;
font-size: 14px;
line-height: 1;
font-weight: 500;
letter-spacing: 0;
white-space: nowrap;
cursor: pointer;
transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
}
.third-submenu-item:hover,
.third-submenu-item--active {
color: #ffffff;
border-color: rgba(82, 205, 255, 0.55);
background: linear-gradient(180deg, rgba(14, 84, 165, 0.54) 0%, rgba(4, 45, 104, 0.58) 100%);
box-shadow: inset 0 0 12px rgba(0, 185, 255, 0.14), 0 0 10px rgba(35, 167, 255, 0.14);
text-shadow: 0 0 8px rgba(87, 215, 255, 0.34);
}
.third-submenu-item--active::after {
content: '';
position: absolute;
left: 12px;
right: 12px;
bottom: -1px;
height: 2px;
background: linear-gradient(90deg, rgba(62, 178, 255, 0) 0%, #4ee6ff 50%, rgba(62, 178, 255, 0) 100%);
box-shadow: 0 0 10px rgba(78, 230, 255, 0.72);
}
@@ -3,6 +3,7 @@ import { useNavigate, useLocation } from "react-router-dom";
import { PanelLeftOpen, PanelLeftClose } from "lucide-react";
import { getStoredAuth } from "@/core/auth";
import { isWujieEnv } from "@/core/embed/wujie";
import {
defaultExpandedIds,
type MenuItem,
@@ -19,6 +20,7 @@ type PageSidebarDarkProps = {
export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDarkProps) {
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
const inWujieEnv = isWujieEnv();
// defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变)
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set(defaultExpandedIds));
const navigate = useNavigate();
@@ -159,6 +161,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
})}
</div>
);
// collapsed 状态:sidebar 主体完全消失(包括占据的宽度),只渲染左下角浮动入口
if (collapsed) {
return (
@@ -176,16 +179,16 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
}
return (
<aside className="sidebar-contnet">
<aside className={`sidebar-contnet ${inWujieEnv ? "" : "sidebar-contnet--top-submenu"}`}>
<div className="sidebar-contents">
{visibleMenuItems.map((item) => (
<div
key={item.id}
className="item-nav-group"
onMouseEnter={() => handleNavHoverEnter(item.id)}
onMouseLeave={scheduleNavPanelClose}
onMouseEnter={inWujieEnv ? () => handleNavHoverEnter(item.id) : undefined}
onMouseLeave={inWujieEnv ? scheduleNavPanelClose : undefined}
>
{item.children?.length ? (
{inWujieEnv && item.children?.length ? (
<div
className={`item-nav-content ${hoveredTopId === item.id ? "item-nav-content--visible" : ""}`}
>
@@ -200,7 +203,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
>
<span className="item-nav__label">{item.label}</span>
</button>
{item.children?.length && hoveredTopId === item.id ? (
{inWujieEnv && item.children?.length && hoveredTopId === item.id ? (
<span className="item-nav-hover-bridge" aria-hidden="true" />
) : null}
</div>
@@ -172,7 +172,7 @@ export const sidebarMenuItems: MenuItem[] = [
},
{
id: "light-app-center",
label: "轻应用中心",
label: "应用中心",
icon: Grid3X3,
children: [
{
@@ -82,7 +82,7 @@
* ──────────────────────────────────────────────────────────────────────────
*/
import { useRef } from 'react';
import { ChevronLeft } from 'lucide-react';
import { PanelRightOpen } from 'lucide-react';
import { TooltipProvider } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import type { GroupImperativeHandle } from 'react-resizable-panels';
@@ -146,34 +146,34 @@ function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumen
// 竖向展开栏;问答侧点「应用到表单」(applyProposal) 会自动把 formExpanded 置 true 展开。
if (state.status === 'idle' && !isHistoryMode) {
return (
<div className="flex h-full">
<div className="relative flex h-full">
{/* 左侧:写作助手 Q&A —— 恒为第一个子节点,位置稳定不重挂 */}
<div className="min-h-0 flex-1 overflow-hidden">
<WritingSetupChat />
</div>
{/* 右侧:写作配置表单整块。容器持久存在,宽度随 formExpanded 平滑过渡(展开/收起
都有动画);收起时只剩一条竖向「展开」栏,收起按钮则在表单头部右上角。 */}
{/* 右侧:写作配置表单整块。容器持久存在(表单始终挂载、保留状态),宽度随 formExpanded
平滑过渡——收起时宽度归零、整块滑出,右侧完全让给问答区。 */}
<div
className={cn(
'relative flex min-h-0 shrink-0 flex-col overflow-hidden border-l transition-[width,min-width] duration-300 ease-in-out',
formExpanded ? 'w-[36%] min-w-[340px] max-w-[600px]' : 'w-10 min-w-10',
'flex min-h-0 shrink-0 flex-col overflow-hidden transition-[width,min-width] duration-300 ease-in-out',
formExpanded ? 'w-[36%] min-w-[340px] max-w-[600px] border-l' : 'w-0 min-w-0',
)}
>
{formExpanded ? (
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
) : (
<button
type="button"
onClick={() => setFormExpanded(true)}
title="展开写作配置"
className="flex h-full w-full flex-col items-center justify-center gap-3 bg-muted/30 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<ChevronLeft className="size-4" />
<span className="[writing-mode:vertical-rl] text-xs tracking-widest">写作配置</span>
</button>
)}
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
</div>
{/* 收起态:右上角浮动展开图标按钮(与展开态头部的收起按钮位置呼应) */}
{!formExpanded && (
<button
type="button"
onClick={() => setFormExpanded(true)}
title="展开写作配置"
className="absolute right-3 top-2 z-10 flex h-7 w-7 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground"
>
<PanelRightOpen className="size-4" />
</button>
)}
</div>
);
}
@@ -1,12 +1,18 @@
import { Bell, ChevronDown, FileText, Moon, Palette, User } from "lucide-react";
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { Button } from "@/components/ui/button";
import { getStoredAuth } from "@/core/auth";
import { isWujieEnv } from "@/core/embed/wujie";
import { useUnreadCount } from "@/core/notifications";
import type { MenuItem } from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
import { cn } from "@/lib/utils";
import { useTheme } from "../contexts/ThemeContext";
import "@/components/page-sdebar.css";
const iconButtonClass = (isDark: boolean) =>
cn(
@@ -25,6 +31,49 @@ const Header = ({ collapsed }: HeaderProps) => {
const isDark = theme === "dark";
const [notificationOpen, setNotificationOpen] = useState(false);
const { data: unreadCount = 0 } = useUnreadCount();
const navigate = useNavigate();
const { pathname } = useLocation();
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
const showTopSubmenu = isDark && !collapsed && !isWujieEnv();
const hasActiveChild = (item: MenuItem): boolean => {
if (item.path && pathname === item.path) {
return true;
}
return item.children?.some(hasActiveChild) ?? false;
};
const findFirstRoutePath = (items?: MenuItem[]): string | null => {
if (!items?.length) {
return null;
}
for (const child of items) {
if (child.path) {
return child.path;
}
const nestedPath = findFirstRoutePath(child.children);
if (nestedPath) {
return nestedPath;
}
}
return null;
};
const activeTopItem = visibleMenuItems.find(hasActiveChild);
const topSubNavItems = activeTopItem?.children ?? [];
const activeSecondItem = topSubNavItems.find(hasActiveChild);
const thirdSubNavItems = activeSecondItem?.children ?? [];
const handleTopSubNavClick = (item: MenuItem) => {
const path = item.path ?? findFirstRoutePath(item.children);
if (path) {
navigate(path);
}
};
return (
<>
@@ -38,7 +87,7 @@ const Header = ({ collapsed }: HeaderProps) => {
isDark ? "border-blue-800/30" : "border-[var(--brand-950)]",
)}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex h-full min-w-0 flex-1 items-center gap-3">
<div
className={cn(
"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg",
@@ -54,6 +103,26 @@ const Header = ({ collapsed }: HeaderProps) => {
CM助手
</span>
)}
{showTopSubmenu && topSubNavItems.length ? (
<nav className="top-submenu-bar" aria-label="二级菜单">
<div className="top-submenu-list">
{topSubNavItems.map((item) => {
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
return (
<button
key={item.id}
type="button"
className={`top-submenu-item ${isSelected ? "top-submenu-item--active" : ""}`}
onClick={() => handleTopSubNavClick(item)}
>
{item.label}
</button>
);
})}
</div>
</nav>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
@@ -150,6 +219,26 @@ const Header = ({ collapsed }: HeaderProps) => {
</Button>
</div>
</header>
{showTopSubmenu && thirdSubNavItems.length ? (
<nav className="third-submenu-bar" aria-label="三级菜单">
<div className="third-submenu-list">
{thirdSubNavItems.map((item) => {
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
return (
<button
key={item.id}
type="button"
className={`third-submenu-item ${isSelected ? "third-submenu-item--active" : ""}`}
onClick={() => handleTopSubNavClick(item)}
>
{item.label}
</button>
);
})}
</div>
</nav>
) : null}
</>
);
};