From 7f493aa921ac85887f695637949f0310af1a0acb Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Fri, 27 Mar 2026 12:27:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=E5=85=A8=E5=B1=80=20?= =?UTF-8?q?Header=20=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 AppHeader 组件(Logo + 台入口 + 用户状态) - 新增 UserDropdown 组件(用户下拉菜单) - 新增 AppLayout 布局组件 - 移除 SidebarBrand 和 SidebarUser 组件 - 修改各台页面,移除侧边栏中的品牌区和用户区 - 修改 HomePage,移除独立 header/footer - 修改 Layout 组件,简化为 sidebar + content - 账户设置改为弹框形式,不中断用户操作 - 更新 README.md 布局系统说明 - 同步 delta specs 到主 specs --- README.md | 28 ++- openspec/specs/layout-system/spec.md | 44 ++-- openspec/specs/unified-header/spec.md | 111 ++++++++++ src/App.jsx | 11 +- src/components/Layout.jsx | 11 +- src/components/account/AccountPage.jsx | 155 -------------- src/components/common/Modal.jsx | 24 ++- src/components/layout/AppHeader.jsx | 114 ++++++++++ src/components/layout/AppLayout.jsx | 15 ++ src/components/layout/SidebarBrand.jsx | 23 --- src/components/layout/SidebarUser.jsx | 34 --- src/components/layout/UserDropdown.jsx | 222 ++++++++++++++++++++ src/pages/AdminPage.jsx | 28 --- src/pages/ConsolePage.jsx | 9 - src/pages/DeveloperPage.jsx | 13 -- src/pages/HomePage.jsx | 28 +-- src/styles/components/_index.scss | 1 + src/styles/components/header/_index.scss | 251 +++++++++++++++++++++++ src/styles/layouts/_admin-layout.scss | 40 ---- src/styles/layouts/_app-shell.scss | 74 +------ src/styles/layouts/_chat-layout.scss | 35 ---- src/styles/pages/_home.scss | 130 +----------- 22 files changed, 793 insertions(+), 608 deletions(-) create mode 100644 openspec/specs/unified-header/spec.md delete mode 100644 src/components/account/AccountPage.jsx create mode 100644 src/components/layout/AppHeader.jsx create mode 100644 src/components/layout/AppLayout.jsx delete mode 100644 src/components/layout/SidebarBrand.jsx delete mode 100644 src/components/layout/SidebarUser.jsx create mode 100644 src/components/layout/UserDropdown.jsx create mode 100644 src/styles/components/header/_index.scss diff --git a/README.md b/README.md index b6687aa..92acd2f 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,8 @@ pnpm build # 验证打包(不运行pnpm dev,会挂起流程) src/ ├── components/ # 组件库 │ ├── common/ # 通用组件 (Modal, Toast, EmptyState等) -│ ├── layout/ # 布局组件 (SidebarBrand, SidebarUser等) -│ ├── Layout.jsx # 主布局组件 +│ ├── layout/ # 布局组件 (AppHeader, AppLayout, UserDropdown等) +│ ├── Layout.jsx # 主布局组件(sidebar + content) │ └── ListSelector.jsx # 列表选择器 │ ├── contexts/ # 全局状态 (UserContext) @@ -264,14 +264,15 @@ export default Example; | 组件 | 路径 | 用途 | |------|------|------| -| Layout | `components/Layout.jsx` | 主布局(sidebar+header+main) | +| Layout | `components/Layout.jsx` | 主布局(sidebar + content) | +| AppLayout | `components/layout/AppLayout.jsx` | 全局布局(header + main) | +| AppHeader | `components/layout/AppHeader.jsx` | 统一导航头部 | +| UserDropdown | `components/layout/UserDropdown.jsx` | 用户下拉菜单 | | Modal | `components/common/Modal.jsx` | 确认弹窗 | | Toast | `components/common/Toast.jsx` | 消息提示 | | EmptyState | `components/common/EmptyState.jsx` | 空状态展示 | | SearchBar | `components/common/SearchBar.jsx` | 搜索框 | | StatusBadge | `components/common/StatusBadge.jsx` | 状态标签 | -| SidebarBrand | `components/layout/SidebarBrand.jsx` | 侧边栏品牌 | -| SidebarUser | `components/layout/SidebarUser.jsx` | 侧边栏用户信息 | | SidebarNavItem | `components/layout/SidebarNavItem.jsx` | 侧边栏导航项 | --- @@ -284,14 +285,21 @@ export default Example; // App.jsx - } /> - } /> - } /> - } /> + } /> + }> + } /> + } /> + } /> + } /> + ``` +**说明**: +- `AppLayout` 包裹所有需要统一 Header 的页面 +- 登录页独立,不使用 `AppLayout` + ### 子页面路由 每个主页面内部管理子页面: @@ -425,4 +433,4 @@ api.logs.filter({ user, type, status }); --- -*最后更新:2026-03-26* +*最后更新:2026-03-27* diff --git a/openspec/specs/layout-system/spec.md b/openspec/specs/layout-system/spec.md index 7a15242..6bc355f 100644 --- a/openspec/specs/layout-system/spec.md +++ b/openspec/specs/layout-system/spec.md @@ -1,15 +1,25 @@ -## ADDED Requirements +## Purpose +布局系统提供应用级外壳布局、聊天页面布局、管理台布局等核心页面骨架。 + +## Requirements ### Requirement: 应用外壳布局 -布局系统 SHALL 提供应用级外壳布局,包含侧边栏、顶部栏、主内容区。 +布局系统 SHALL 提供应用级外壳布局,包含全局头部、可选侧边栏、主内容区。 #### Scenario: 基础应用布局 - **WHEN** 开发者需要管理控制台布局 -- **THEN** 系统 SHALL 提供 `.app-shell` 类,包含 `.app-shell__sidebar`、`.app-shell__header`、`.app-shell__main` 区域 +- **THEN** 系统 SHALL 提供 `.app-layout` 类,包含 `.app-layout__header`、`.app-layout__sidebar`(可选)、`.app-layout__main` 区域 -#### Scenario: 侧边栏结构 -- **WHEN** 侧边栏需要品牌区、导航区、用户区 -- **THEN** 系统 SHALL 提供 `.sidebar__brand`、`.sidebar__nav`、`.sidebar__user` 元素类 +#### Scenario: 全局头部 +- **WHEN** 页面需要统一导航 +- **THEN** 系统 SHALL 在 `.app-layout__header` 区域渲染 `AppHeader` 组件 +- **AND** 头部固定在页面顶部 + +#### Scenario: 侧边栏可选 +- **WHEN** 页面需要侧边栏(如工作台、开发台、管理台) +- **THEN** 系统 SHALL 渲染 `.app-layout__sidebar` 区域 +- **WHEN** 页面不需要侧边栏(如主页) +- **THEN** 系统 SHALL 不渲染侧边栏区域 #### Scenario: 导航项 - **WHEN** 侧边栏需要导航菜单 @@ -27,16 +37,13 @@ - **WHEN** 导航项需要文本和额外信息 - **THEN** 系统 SHALL 提供 `.nav-item__text` 和 `.nav-item__meta` 元素类 -#### Scenario: 顶部栏结构 -- **WHEN** 顶部栏需要左侧标题区和右侧操作区 -- **THEN** 系统 SHALL 提供 `.header__left`、`.header__right` 元素类 - ### Requirement: 聊天页面布局 -布局系统 SHALL 提供聊天页面专用布局。 +布局系统 SHALL 提供聊天页面专用布局,侧边栏不包含品牌区和用户区。 #### Scenario: 聊天布局容器 - **WHEN** 开发者需要聊天界面布局 -- **THEN** 系统 SHALL 提供 `.chat-layout` 类,包含 `.chat-layout__header`、`.chat-layout__sidebar`、`.chat-layout__content` +- **THEN** 系统 SHALL 提供 `.chat-layout` 类,包含 `.chat-layout__sidebar`、`.chat-layout__content` +- **AND** 侧边栏不包含品牌区(`.sidebar-brand`) #### Scenario: 会话列表 - **WHEN** 侧边栏需要展示会话列表 @@ -46,12 +53,23 @@ - **WHEN** 需要展示消息和输入区 - **THEN** 系统 SHALL 提供 `.chat-content__messages` 和 `.chat-content__input` 区域 +#### Scenario: 侧边栏不含用户区 +- **WHEN** 聊天页面侧边栏渲染时 +- **THEN** 系统 SHALL 不显示用户状态区域 +- **AND** 用户状态统一在全局头部显示 + ### Requirement: 管理台布局 -布局系统 SHALL 提供管理台页面布局。 +布局系统 SHALL 提供管理台页面布局,侧边栏不包含品牌区和用户区。 #### Scenario: 管理台侧边栏 - **WHEN** 管理台需要独立导航结构 - **THEN** 系统 SHALL 提供 `.admin-layout` 类,包含 `.admin-layout__sidebar` 和 `.admin-layout__content` +- **AND** 侧边栏不包含品牌区(`.admin-sidebar-header`)和用户区(`.admin-sidebar-user`) + +#### Scenario: 侧边栏不含用户区 +- **WHEN** 管理台侧边栏渲染时 +- **THEN** 系统 SHALL 不显示用户状态区域 +- **AND** 用户状态统一在全局头部显示 ### Requirement: 页面内容区 布局系统 SHALL 提供标准化的页面内容容器。 diff --git a/openspec/specs/unified-header/spec.md b/openspec/specs/unified-header/spec.md new file mode 100644 index 0000000..58c0089 --- /dev/null +++ b/openspec/specs/unified-header/spec.md @@ -0,0 +1,111 @@ +## Purpose +统一的全局导航头部,包含品牌标识、台入口切换和用户状态管理。 + +## Requirements + +### Requirement: 统一导航头部 +系统 SHALL 提供统一的全局导航头部组件,包含品牌标识、台入口切换和用户状态。 + +#### Scenario: 头部基础结构 +- **WHEN** 页面需要显示导航头部 +- **THEN** 系统 SHALL 提供 `AppHeader` 组件 +- **AND** 组件包含左侧品牌区和右侧功能区 + +#### Scenario: 品牌区展示 +- **WHEN** 用户查看头部左侧 +- **THEN** 系统 SHALL 显示 Logo 图标和 "GrandClaw" 标题 +- **AND** Logo 使用 `.sidebar-logo-icon` 样式类 + +### Requirement: 台入口切换 +系统 SHALL 在头部提供三个台的入口导航,支持切换确认和高亮状态。 + +#### Scenario: 台入口展示 +- **WHEN** 用户查看头部右侧 +- **THEN** 系统 SHALL 显示"工作台"、"开发台"、"管理台"三个入口 +- **AND** 每个入口包含图标和文字 + +#### Scenario: 当前台高亮 +- **WHEN** 用户在某个台内(如工作台) +- **THEN** 系统 SHALL 为当前台入口添加高亮样式 +- **AND** 其他台入口保持默认样式 + +#### Scenario: 台切换确认 +- **WHEN** 用户点击非当前台的入口 +- **THEN** 系统 SHALL 显示确认对话框 +- **AND** 对话框提示"切换到[台名称]?" +- **AND** 用户确认后执行跳转 + +#### Scenario: 台入口路由 +- **WHEN** 用户确认切换 +- **THEN** 系统 SHALL 跳转到对应的路由 +- **AND** 工作台路由为 `/console` +- **AND** 开发台路由为 `/developer` +- **AND** 管理台路由为 `/admin` + +### Requirement: 用户状态区域 +系统 SHALL 在头部右侧显示当前用户状态,支持已登录和未登录两种状态。 + +#### Scenario: 已登录状态展示 +- **WHEN** 用户已登录 +- **THEN** 系统 SHALL 显示用户头像、用户名和下拉图标 +- **AND** 头像使用 `user-avatar` 样式类 + +#### Scenario: 未登录状态展示 +- **WHEN** 用户未登录 +- **THEN** 系统 SHALL 显示"登录"按钮 +- **AND** 点击后跳转到登录页 + +### Requirement: 用户下拉菜单 +系统 SHALL 提供用户下拉菜单,包含账户设置和退出登录选项。 + +#### Scenario: 下拉菜单展开 +- **WHEN** 用户点击用户状态区域 +- **THEN** 系统 SHALL 展开下拉菜单 +- **AND** 菜单包含"账户设置"和"退出登录"选项 + +#### Scenario: 账户设置入口 +- **WHEN** 用户点击"账户设置" +- **THEN** 系统 SHALL 打开账户设置弹框 +- **AND** 下拉菜单收起 +- **AND** 弹框宽度为 720px +- **AND** 弹框内按钮靠右对齐 + +#### Scenario: 退出登录 +- **WHEN** 用户点击"退出登录" +- **THEN** 系统 SHALL 执行退出操作 +- **AND** 跳转到登录页 + +#### Scenario: 点击外部关闭 +- **WHEN** 下拉菜单展开时 +- **AND** 用户点击菜单外部区域 +- **THEN** 系统 SHALL 收起下拉菜单 + +### Requirement: 头部移动端适配 +系统 SHALL 在移动端适配头部布局,将台入口收起到汉堡菜单。 + +#### Scenario: 移动端台入口收起 +- **WHEN** 屏幕宽度小于 768px +- **THEN** 系统 SHALL 隐藏台入口的直接显示 +- **AND** 显示汉堡菜单按钮 + +#### Scenario: 移动端菜单展开 +- **WHEN** 用户点击汉堡菜单按钮 +- **THEN** 系统 SHALL 展开移动端菜单 +- **AND** 菜单包含三个台入口选项 + +#### Scenario: 移动端用户状态保留 +- **WHEN** 屏幕宽度小于 768px +- **THEN** 系统 SHALL 保持用户状态区域可见 +- **AND** 用户状态不收起到汉堡菜单中 + +### Requirement: 头部布局路由控制 +系统 SHALL 通过路由配置控制头部的显示与隐藏。 + +#### Scenario: 需要 Header 的页面 +- **WHEN** 用户访问主页、工作台、开发台、管理台 +- **THEN** 系统 SHALL 显示统一头部 + +#### Scenario: 不需要 Header 的页面 +- **WHEN** 用户访问登录页 +- **THEN** 系统 SHALL 不显示头部 +- **AND** 页面使用独立布局 diff --git a/src/App.jsx b/src/App.jsx index 5784847..18ec7b0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,6 @@ import { HashRouter as Router, Routes, Route } from 'react-router-dom'; import { UserProvider } from './contexts/UserContext.jsx'; +import AppLayout from './components/layout/AppLayout.jsx'; import HomePage from './pages/HomePage.jsx'; import LoginPage from './pages/LoginPage.jsx'; import ConsolePage from './pages/ConsolePage.jsx'; @@ -11,11 +12,13 @@ function App() { - } /> } /> - } /> - } /> - } /> + }> + } /> + } /> + } /> + } /> + diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index 9f2d53f..064a2fc 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -1,7 +1,6 @@ import { useState } from 'react'; -import { FiMenu } from 'react-icons/fi'; -function Layout({ sidebar, headerTitle, children, sidebarClassName = 'sidebar', contentClassName = '' }) { +function Layout({ sidebar, children, sidebarClassName = 'sidebar', contentClassName = '' }) { const [sidebarOpen, setSidebarOpen] = useState(false); const toggleSidebar = () => { @@ -17,14 +16,6 @@ function Layout({ sidebar, headerTitle, children, sidebarClassName = 'sidebar', {sidebar}
-
-
-
- -
-
{headerTitle}
-
-
{children}
diff --git a/src/components/account/AccountPage.jsx b/src/components/account/AccountPage.jsx deleted file mode 100644 index de96659..0000000 --- a/src/components/account/AccountPage.jsx +++ /dev/null @@ -1,155 +0,0 @@ -import { useState } from 'react'; -import { useUserContext } from '../../contexts/UserContext.jsx'; -import Toast from '../common/Toast.jsx'; - -function AccountPage() { - const { user } = useUserContext(); - const [profileToast, setProfileToast] = useState(null); - const [passwordErrors, setPasswordErrors] = useState({}); - const [passwordForm, setPasswordForm] = useState({ - currentPassword: '', - newPassword: '', - confirmPassword: '', - }); - - const handleProfileSave = () => { - setProfileToast({ type: 'success', message: '保存成功' }); - setTimeout(() => setProfileToast(null), 3000); - }; - - const handlePasswordChange = (field, value) => { - setPasswordForm(prev => ({ ...prev, [field]: value })); - setPasswordErrors(prev => ({ ...prev, [field]: '' })); - }; - - const handlePasswordSubmit = () => { - const errors = {}; - if (!passwordForm.currentPassword) { - errors.currentPassword = '请输入当前密码'; - } - if (!passwordForm.newPassword) { - errors.newPassword = '请输入新密码'; - } - if (!passwordForm.confirmPassword) { - errors.confirmPassword = '请再次输入新密码'; - } else if (passwordForm.newPassword !== passwordForm.confirmPassword) { - errors.confirmPassword = '两次输入的密码不一致'; - } - setPasswordErrors(errors); - }; - - return ( - <> -
-
-
账号信息
-
-
-
-
{user.avatar}
- -
-
-
-
- - -
-
-
-
- - -
-
-
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
- -
-
-
-
-
修改密码
-
-
-
- - handlePasswordChange('currentPassword', e.target.value)} - /> - {passwordErrors.currentPassword && ( -
{passwordErrors.currentPassword}
- )} -
-
- - handlePasswordChange('newPassword', e.target.value)} - /> - {passwordErrors.newPassword && ( -
{passwordErrors.newPassword}
- )} -
-
- - handlePasswordChange('confirmPassword', e.target.value)} - /> - {passwordErrors.confirmPassword && ( -
{passwordErrors.confirmPassword}
- )} -
- -
-
- setProfileToast(null)} - /> - - ); -} - -export default AccountPage; diff --git a/src/components/common/Modal.jsx b/src/components/common/Modal.jsx index cd012d6..6b28f85 100644 --- a/src/components/common/Modal.jsx +++ b/src/components/common/Modal.jsx @@ -1,11 +1,21 @@ import { FiX } from 'react-icons/fi'; -function Modal({ visible, title, children, onConfirm, onCancel, confirmText = '确定', cancelText = '取消' }) { +function Modal({ + visible, + title, + children, + onConfirm, + onCancel, + confirmText = '确定', + cancelText = '取消', + showConfirm = true, + width +}) { if (!visible) return null; return (
-
e.stopPropagation()}> +
e.stopPropagation()} style={width ? { width } : undefined}>
{title}
@@ -15,10 +25,12 @@ function Modal({ visible, title, children, onConfirm, onCancel, confirmText = '
{children}
-
- - -
+ {showConfirm && ( +
+ + +
+ )}
); diff --git a/src/components/layout/AppHeader.jsx b/src/components/layout/AppHeader.jsx new file mode 100644 index 0000000..fe1843c --- /dev/null +++ b/src/components/layout/AppHeader.jsx @@ -0,0 +1,114 @@ +import { useState, useEffect, useRef } from 'react'; +import { useLocation, useNavigate, Link } from 'react-router-dom'; +import { FiSettings, FiCode, FiUsers, FiMenu, FiX } from 'react-icons/fi'; +import Modal from '../common/Modal.jsx'; +import UserDropdown from './UserDropdown.jsx'; + +const PLATFORMS = [ + { id: 'console', name: '工作台', path: '/console', icon: FiSettings }, + { id: 'developer', name: '开发台', path: '/developer', icon: FiCode }, + { id: 'admin', name: '管理台', path: '/admin', icon: FiUsers }, +]; + +function AppHeader() { + const location = useLocation(); + const navigate = useNavigate(); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [confirmModal, setConfirmModal] = useState({ visible: false, platform: null }); + const mobileMenuRef = useRef(null); + + const currentPlatform = PLATFORMS.find(p => location.pathname.startsWith(p.path))?.id || null; + + useEffect(() => { + function handleClickOutside(event) { + if (mobileMenuRef.current && !mobileMenuRef.current.contains(event.target)) { + setMobileMenuOpen(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handlePlatformClick = (platform) => { + if (platform.id === currentPlatform) return; + setConfirmModal({ visible: true, platform }); + setMobileMenuOpen(false); + }; + + const handleConfirmSwitch = () => { + if (confirmModal.platform) { + navigate(confirmModal.platform.path); + } + setConfirmModal({ visible: false, platform: null }); + }; + + return ( +
+
+ +
+ + +
+ GrandClaw + +
+ +
+ + + + +
+
setMobileMenuOpen(!mobileMenuOpen)}> + {mobileMenuOpen ? : } +
+ {mobileMenuOpen && ( +
+ {PLATFORMS.map(platform => { + const Icon = platform.icon; + return ( +
handlePlatformClick(platform)} + > + + {platform.name} +
+ ); + })} +
+ )} +
+
+ + setConfirmModal({ visible: false, platform: null })} + confirmText="确定" + cancelText="取消" + > +

切换到{confirmModal.platform?.name}?

+
+
+ ); +} + +export default AppHeader; diff --git a/src/components/layout/AppLayout.jsx b/src/components/layout/AppLayout.jsx new file mode 100644 index 0000000..9ae1da7 --- /dev/null +++ b/src/components/layout/AppLayout.jsx @@ -0,0 +1,15 @@ +import { Outlet } from 'react-router-dom'; +import AppHeader from './AppHeader.jsx'; + +function AppLayout() { + return ( +
+ +
+ +
+
+ ); +} + +export default AppLayout; diff --git a/src/components/layout/SidebarBrand.jsx b/src/components/layout/SidebarBrand.jsx deleted file mode 100644 index 7afba79..0000000 --- a/src/components/layout/SidebarBrand.jsx +++ /dev/null @@ -1,23 +0,0 @@ -/** - * SidebarBrand - 侧边栏品牌区域组件 - * 统一显示 GrandClaw 品牌标识和副标题 - * - * @param {Object} props - 组件属性 - * @param {string} [props.subtitle] - 副标题文本(如"企业级AI平台"、"运营管理台"、"技能开发台") - */ -function SidebarBrand({ subtitle = '企业级AI平台' }) { - return ( -
-
- - -
-
-
GrandClaw
-
{subtitle}
-
-
- ); -} - -export default SidebarBrand; diff --git a/src/components/layout/SidebarUser.jsx b/src/components/layout/SidebarUser.jsx deleted file mode 100644 index 0b4a95a..0000000 --- a/src/components/layout/SidebarUser.jsx +++ /dev/null @@ -1,34 +0,0 @@ -import { useUserContext } from '../../contexts/UserContext.jsx'; - -/** - * SidebarUser - 侧边栏用户信息组件 - * 从 UserContext 获取用户信息并显示 - * - * @param {Object} props - 组件属性 - * @param {Function} [props.onClick] - 点击回调函数 - * @param {string} [props.wrapperClassName] - 包装器类名(如"chat-sidebar-user"、"admin-sidebar-user") - * @param {string} [props.infoClassName] - 信息容器类名(如"chat-sidebar-user-info"、"admin-sidebar-user-info") - * @param {string} [props.nameClassName] - 姓名容器类名(如"chat-sidebar-user-name"、"admin-sidebar-user-name") - * @param {string} [props.roleClassName] - 角色容器类名(如"chat-sidebar-user-role"、"admin-sidebar-user-role") - */ -function SidebarUser({ - onClick, - wrapperClassName = 'chat-sidebar-user', - infoClassName = 'chat-sidebar-user-info', - nameClassName = 'chat-sidebar-user-name', - roleClassName = 'chat-sidebar-user-role', -}) { - const { user } = useUserContext(); - - return ( -
-
{user.avatar}
-
-
{user.name}
-
{user.role}
-
-
- ); -} - -export default SidebarUser; diff --git a/src/components/layout/UserDropdown.jsx b/src/components/layout/UserDropdown.jsx new file mode 100644 index 0000000..94f2c38 --- /dev/null +++ b/src/components/layout/UserDropdown.jsx @@ -0,0 +1,222 @@ +import { useState, useRef, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { FiChevronDown, FiUser, FiLogOut } from 'react-icons/fi'; +import { useUserContext } from '../../contexts/UserContext.jsx'; +import Modal from '../common/Modal.jsx'; +import Toast from '../common/Toast.jsx'; + +function UserDropdown() { + const navigate = useNavigate(); + const { user } = useUserContext(); + const [isOpen, setIsOpen] = useState(false); + const [showAccountModal, setShowAccountModal] = useState(false); + const [profileToast, setProfileToast] = useState(null); + const [passwordErrors, setPasswordErrors] = useState({}); + const [passwordForm, setPasswordForm] = useState({ + currentPassword: '', + newPassword: '', + confirmPassword: '', + }); + const dropdownRef = useRef(null); + + useEffect(() => { + function handleClickOutside(event) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setIsOpen(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleAccountClick = () => { + setIsOpen(false); + setShowAccountModal(true); + }; + + const handleLogout = () => { + setIsOpen(false); + navigate('/login'); + }; + + const handleProfileSave = () => { + setProfileToast({ type: 'success', message: '保存成功' }); + setTimeout(() => setProfileToast(null), 3000); + }; + + const handlePasswordChange = (field, value) => { + setPasswordForm(prev => ({ ...prev, [field]: value })); + setPasswordErrors(prev => ({ ...prev, [field]: '' })); + }; + + const handlePasswordSubmit = () => { + const errors = {}; + if (!passwordForm.currentPassword) { + errors.currentPassword = '请输入当前密码'; + } + if (!passwordForm.newPassword) { + errors.newPassword = '请输入新密码'; + } + if (!passwordForm.confirmPassword) { + errors.confirmPassword = '请再次输入新密码'; + } else if (passwordForm.newPassword !== passwordForm.confirmPassword) { + errors.confirmPassword = '两次输入的密码不一致'; + } + + if (Object.keys(errors).length === 0) { + setProfileToast({ type: 'success', message: '密码更新成功' }); + setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' }); + setTimeout(() => setProfileToast(null), 3000); + } else { + setPasswordErrors(errors); + } + }; + + return ( + <> +
+
setIsOpen(!isOpen)}> +
{user.avatar}
+ {user.name} + +
+ {isOpen && ( +
+
+ + 账户设置 +
+
+ + 退出登录 +
+
+ )} +
+ + setShowAccountModal(false)} + showConfirm={false} + width="720px" + > +
+
+
{user.avatar}
+ +
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ + +
+ +
+ +
+ +
+

修改密码

+ +
+ + handlePasswordChange('currentPassword', e.target.value)} + /> + {passwordErrors.currentPassword && ( +
{passwordErrors.currentPassword}
+ )} +
+ +
+ + handlePasswordChange('newPassword', e.target.value)} + /> + {passwordErrors.newPassword && ( +
{passwordErrors.newPassword}
+ )} +
+ +
+ + handlePasswordChange('confirmPassword', e.target.value)} + /> + {passwordErrors.confirmPassword && ( +
{passwordErrors.confirmPassword}
+ )} +
+ +
+ +
+
+
+
+ + setProfileToast(null)} + /> + + ); +} + +export default UserDropdown; diff --git a/src/pages/AdminPage.jsx b/src/pages/AdminPage.jsx index 6e0f0f0..d0bc7f0 100644 --- a/src/pages/AdminPage.jsx +++ b/src/pages/AdminPage.jsx @@ -2,8 +2,6 @@ import { useState, useEffect } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { FiHome, FiBarChart2, FiUsers, FiList, FiCheckCircle, FiActivity, FiSettings } from 'react-icons/fi'; import Layout from '../components/Layout.jsx'; -import SidebarBrand from '../components/layout/SidebarBrand.jsx'; -import SidebarUser from '../components/layout/SidebarUser.jsx'; import SidebarNavItem from '../components/layout/SidebarNavItem.jsx'; import usePageState from '../hooks/usePageState.js'; import { ADMIN_PAGES } from '../constants/pages.js'; @@ -20,7 +18,6 @@ import ConsoleReviewListPage from './console/ConsoleReviewListPage.jsx'; import ConsoleReviewDetailPage from './console/ConsoleReviewDetailPage.jsx'; import ModelConfigsPage from './admin/ModelConfigsPage.jsx'; import AddModelConfigPage from './admin/AddModelConfigPage.jsx'; -import AccountPage from '../components/account/AccountPage.jsx'; function AdminPage() { const location = useLocation(); @@ -107,30 +104,13 @@ function AdminPage() { onBack={() => navigateTo('modelConfigs')} editData={editData} />; - case 'account': - return ; default: return
Page not found
; } }; - const getPageTitle = () => { - if (editData && (currentPage === 'addDepartment' || currentPage === 'addUser' || currentPage === 'addProject' || currentPage === 'addModelConfig')) { - const prefix = '编辑'; - const nameMap = { addDepartment: '部门', addUser: '用户', addProject: '项目', addModelConfig: '配置' }; - return prefix + nameMap[currentPage]; - } - if (currentPage === 'reviewDetail') { - return reviewType === 'version' ? '版本审核' : '下架审核'; - } - return ADMIN_PAGES[currentPage]?.title || ''; - }; - const sidebar = ( <> -
- -
- navigateTo('account')} - wrapperClassName="admin-sidebar-user" - infoClassName="admin-sidebar-user-info" - nameClassName="admin-sidebar-user-name" - roleClassName="admin-sidebar-user-role" - /> ); return ( {renderPage()} diff --git a/src/pages/ConsolePage.jsx b/src/pages/ConsolePage.jsx index be3aebc..ef664a4 100644 --- a/src/pages/ConsolePage.jsx +++ b/src/pages/ConsolePage.jsx @@ -3,8 +3,6 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { FiPlus, FiClock, FiList, FiUsers, FiBox } from 'react-icons/fi'; import { FaPuzzlePiece } from 'react-icons/fa'; import Layout from '../components/Layout.jsx'; -import SidebarBrand from '../components/layout/SidebarBrand.jsx'; -import SidebarUser from '../components/layout/SidebarUser.jsx'; import SidebarNavItem from '../components/layout/SidebarNavItem.jsx'; import usePageState from '../hooks/usePageState.js'; import { CONSOLE_PAGES } from '../constants/pages.js'; @@ -18,7 +16,6 @@ import SkillConfigPage from './console/SkillConfigPage.jsx'; import LogsPage from './console/LogsPage.jsx'; import TasksPage from './console/TasksPage.jsx'; import TaskDetailPage from './console/TaskDetailPage.jsx'; -import AccountPage from '../components/account/AccountPage.jsx'; import ProjectsPage from './console/ProjectsPage.jsx'; import MemberConfigPage from './console/MemberConfigPage.jsx'; import AddMemberPage from './console/AddMemberPage.jsx'; @@ -120,8 +117,6 @@ function ConsolePage() { taskId={currentTaskId} onBack={() => switchPage('scheduledTasks')} />; - case 'account': - return ; case 'projects': return switchPage('addMember')} />; case 'memberConfig': @@ -149,8 +144,6 @@ function ConsolePage() { const sidebar = ( <>
- -
- switchPage('account')} /> ); return ( diff --git a/src/pages/DeveloperPage.jsx b/src/pages/DeveloperPage.jsx index 21092a3..2c0ebfd 100644 --- a/src/pages/DeveloperPage.jsx +++ b/src/pages/DeveloperPage.jsx @@ -3,8 +3,6 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { FiPlus, FiTerminal, FiHome } from 'react-icons/fi'; import { FaPuzzlePiece } from 'react-icons/fa'; import Layout from '../components/Layout.jsx'; -import SidebarBrand from '../components/layout/SidebarBrand.jsx'; -import SidebarUser from '../components/layout/SidebarUser.jsx'; import SidebarNavItem from '../components/layout/SidebarNavItem.jsx'; import usePageState from '../hooks/usePageState.js'; import { DEVELOPER_PAGES } from '../constants/pages.js'; @@ -15,7 +13,6 @@ import MySkillsPage from './developer/MySkillsPage.jsx'; import UploadSkillPage from './developer/UploadSkillPage.jsx'; import NewVersionPage from './developer/NewVersionPage.jsx'; import DevDocsPage from './developer/DevDocsPage.jsx'; -import AccountPage from '../components/account/AccountPage.jsx'; import SkillEditorPage from './developer/SkillEditorPage.jsx'; import UpdateSkillInfoPage from './developer/UpdateSkillInfoPage.jsx'; import UploadVersionPage from './developer/UploadVersionPage.jsx'; @@ -103,8 +100,6 @@ function DeveloperPage() { return switchPage('mySkills')} />; case 'devDocs': return ; - case 'devAccount': - return ; case 'skillEditor': return { - return DEVELOPER_PAGES[currentPage]?.title || ''; - }; - const sidebar = ( <>
- -
- switchPage('devAccount')} /> ); return ( {renderPage()} diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 12155c3..c82f71b 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -1,33 +1,10 @@ import { Link } from 'react-router-dom'; -import { FiSettings, FiCode, FiUsers, FiMonitor, FiList, FiLogIn } from 'react-icons/fi'; +import { FiMonitor, FiList } from 'react-icons/fi'; import { FaRobot, FaPuzzlePiece } from 'react-icons/fa'; function HomePage() { return (
-
-
-
- - -
- GrandClaw -
- -
@@ -66,9 +43,6 @@ function HomePage() {
- ); } diff --git a/src/styles/components/_index.scss b/src/styles/components/_index.scss index 793b025..fe95e64 100644 --- a/src/styles/components/_index.scss +++ b/src/styles/components/_index.scss @@ -16,6 +16,7 @@ @forward 'password-input'; @forward 'search-bar'; @forward 'stat-card'; +@forward 'header'; .page-back-btn { display: inline-flex; diff --git a/src/styles/components/header/_index.scss b/src/styles/components/header/_index.scss new file mode 100644 index 0000000..d6935d4 --- /dev/null +++ b/src/styles/components/header/_index.scss @@ -0,0 +1,251 @@ +// AppHeader 组件样式 + +@use '../../tokens' as *; + +// 全局布局容器 +.app-layout { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; + background: var(--color-bg-1); +} + +.app-layout__main { + flex: 1; + display: flex; + flex-direction: column; + overflow-y: auto; + min-height: 0; +} + +// 头部容器 +.app-header { + height: var(--header-height); + background: var(--color-bg-1); + border-bottom: 1px solid var(--color-border-2); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 24px; + position: sticky; + top: 0; + z-index: $z-index-header; + flex-shrink: 0; +} + +// 左侧品牌区 +.app-header__left { + display: flex; + align-items: center; +} + +.app-header__brand { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + color: var(--color-text-1); + cursor: pointer; +} + +.app-header__title { + font-size: 18px; + font-weight: 700; + letter-spacing: -0.3px; +} + +// 右侧功能区 +.app-header__right { + display: flex; + align-items: center; + gap: 16px; +} + +// 导航区 +.app-header__nav { + display: flex; + align-items: center; + gap: 4px; +} + +.app-header__nav-item { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + color: var(--color-text-2); + font-size: 14px; + font-weight: 600; + cursor: pointer; + border-radius: var(--radius-md); + transition: all 0.2s; + + svg { + width: 18px; + height: 18px; + } + + &:hover { + color: var(--color-text-1); + background: var(--color-bg-2); + } + + &.active { + color: var(--color-primary); + background: var(--color-primary-light); + } +} + +// 用户状态区 +.app-header__user { + position: relative; +} + +.app-header__user-trigger { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + cursor: pointer; + border-radius: var(--radius-md); + transition: background 0.2s; + + &:hover { + background: var(--color-bg-2); + } +} + +.app-header__user-name { + font-size: 14px; + font-weight: 600; + color: var(--color-text-1); +} + +.app-header__user-arrow { + width: 16px; + height: 16px; + color: var(--color-text-3); + transition: transform 0.2s; + + &.open { + transform: rotate(180deg); + } +} + +// 下拉菜单 +.app-header__dropdown { + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + min-width: 160px; + background: var(--color-bg-1); + border: 1px solid var(--color-border-2); + border-radius: var(--radius-md); + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.1); + overflow: hidden; + z-index: $z-index-modal; +} + +.app-header__dropdown-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + font-size: 14px; + color: var(--color-text-2); + cursor: pointer; + transition: background 0.15s; + + svg { + width: 16px; + height: 16px; + } + + &:hover { + background: var(--color-bg-2); + color: var(--color-text-1); + } +} + +// 移动端菜单 +.app-header__mobile { + display: none; +} + +.app-header__mobile-btn { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: var(--radius-md); + + svg { + width: 20px; + height: 20px; + color: var(--color-text-2); + } + + &:hover { + background: var(--color-bg-2); + } +} + +.app-header__mobile-menu { + position: absolute; + top: 100%; + right: 16px; + margin-top: 4px; + min-width: 160px; + background: var(--color-bg-1); + border: 1px solid var(--color-border-2); + border-radius: var(--radius-md); + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.1); + overflow: hidden; + z-index: $z-index-modal; +} + +.app-header__mobile-item { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + font-size: 14px; + font-weight: 500; + color: var(--color-text-2); + cursor: pointer; + transition: background 0.15s; + + svg { + width: 18px; + height: 18px; + } + + &:hover { + background: var(--color-bg-2); + color: var(--color-text-1); + } + + &.active { + color: var(--color-primary); + background: var(--color-primary-light); + } +} + +// 响应式 - 移动端 +@include mobile { + .app-header__nav { + display: none; + } + + .app-header__mobile { + display: block; + } + + .app-header__user-name { + display: none; + } +} diff --git a/src/styles/layouts/_admin-layout.scss b/src/styles/layouts/_admin-layout.scss index 176232e..d502cc0 100644 --- a/src/styles/layouts/_admin-layout.scss +++ b/src/styles/layouts/_admin-layout.scss @@ -19,11 +19,6 @@ height: 100%; } -.admin-sidebar-header { - padding: 16px; - border-bottom: 1px solid var(--color-border-2); -} - .admin-sidebar-nav { flex: 1; overflow-y: auto; @@ -68,41 +63,6 @@ flex: 1; } -// 管理台用户区域 -.admin-sidebar-user { - display: flex; - align-items: center; - gap: 12px; - padding: 16px; - border-top: 1px solid var(--color-border-2); - background: var(--color-bg-2); - cursor: pointer; - transition: background 0.2s; - - &:hover { - background: var(--color-bg-3); - } -} - -.admin-sidebar-user-info { - flex: 1; - min-width: 0; -} - -.admin-sidebar-user-name { - font-size: 14px; - font-weight: 600; - color: var(--color-text-1); - margin-bottom: 2px; - @include text-truncate; -} - -.admin-sidebar-user-role { - font-size: 12px; - color: var(--color-text-3); - @include text-truncate; -} - // 管理台内容区 .admin-layout__content { flex: 1; diff --git a/src/styles/layouts/_app-shell.scss b/src/styles/layouts/_app-shell.scss index 57e221a..a240616 100644 --- a/src/styles/layouts/_app-shell.scss +++ b/src/styles/layouts/_app-shell.scss @@ -6,7 +6,7 @@ .app-shell, .layout { display: flex; - height: 100vh; + height: 100%; overflow: hidden; } @@ -16,7 +16,7 @@ background: var(--color-bg-1); border-right: 1px solid var(--color-border-2); position: fixed; - height: 100vh; + height: 100%; overflow-y: auto; overflow-x: hidden; z-index: $z-index-sidebar; @@ -24,23 +24,6 @@ flex-direction: column; } -// 侧边栏头部 -.sidebar-header { - height: var(--header-height); - display: flex; - align-items: center; - padding: 0 20px; - border-bottom: 1px solid var(--color-border-2); - flex-shrink: 0; -} - -// 品牌区 -.sidebar-brand { - display: flex; - align-items: center; - gap: 10px; -} - .sidebar-logo-icon { width: 28px; height: 28px; @@ -87,20 +70,6 @@ } } -.sidebar-brand-text { - display: flex; - flex-direction: column; - gap: 2px; -} - -.sidebar-logo { - font-size: 18px; - font-weight: 700; - color: var(--color-text-1); - letter-spacing: -0.3px; - line-height: 1.2; -} - // 导航区 .sidebar__nav, .sidebar-menu { @@ -114,13 +83,6 @@ margin: 12px 0; } -.sidebar-subtitle { - font-size: 13px; - color: var(--color-text-3); - font-weight: 600; - line-height: 1.2; -} - // 导航项 - 统一使用 .nav-item .nav-item, .menu-item { @@ -178,34 +140,6 @@ overflow: hidden; } -// 顶部栏 -.app-shell__header, -.header { - height: var(--header-height); - background: var(--color-bg-1); - border-bottom: 1px solid var(--color-border-2); - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 24px; - position: sticky; - top: 0; - z-index: $z-index-header; -} - -.header__left, -.header-left { - display: flex; - align-items: center; - gap: 16px; -} - -.header-title { - font-size: 15px; - font-weight: 600; - color: var(--color-text-1); -} - // 用户头像 .user-avatar { width: 34px; @@ -290,10 +224,6 @@ padding: 16px; } - .header-title { - display: none; - } - .mobile-menu-btn { display: flex !important; } diff --git a/src/styles/layouts/_chat-layout.scss b/src/styles/layouts/_chat-layout.scss index b58338a..cfd7b4a 100644 --- a/src/styles/layouts/_chat-layout.scss +++ b/src/styles/layouts/_chat-layout.scss @@ -177,41 +177,6 @@ color: var(--color-text-3); } -// 侧边栏用户状态区域 -.chat-sidebar-user { - display: flex; - align-items: center; - gap: 12px; - padding: 16px; - border-top: 1px solid var(--color-border-2); - background: var(--color-bg-1); - cursor: pointer; - transition: background 0.2s; - - &:hover { - background: var(--color-bg-2); - } -} - -.chat-sidebar-user-info { - flex: 1; - min-width: 0; -} - -.chat-sidebar-user-name { - font-size: 14px; - font-weight: 600; - color: var(--color-text-1); - margin-bottom: 2px; - @include text-truncate; -} - -.chat-sidebar-user-role { - font-size: 12px; - color: var(--color-text-3); - @include text-truncate; -} - // 侧边栏项目切换区域 .chat-sidebar-project { padding: 16px; diff --git a/src/styles/pages/_home.scss b/src/styles/pages/_home.scss index 12c0973..d0c92e0 100644 --- a/src/styles/pages/_home.scss +++ b/src/styles/pages/_home.scss @@ -3,7 +3,7 @@ @use '../tokens' as *; .home-layout { - min-height: 100vh; + flex: 1; display: flex; flex-direction: column; background: linear-gradient(180deg, #F8FAFC 0%, #FFFFFF 100%); @@ -33,116 +33,6 @@ } } -.home-header { - padding: 0 48px; - height: 68px; - display: flex; - justify-content: space-between; - align-items: center; - position: relative; - z-index: 1; - border-bottom: 1px solid var(--color-border-2); - background: rgba(255, 255, 255, 0.8); - backdrop-filter: blur(12px); -} - -.home-logo { - font-size: 18px; - font-weight: 800; - color: var(--color-text-1); - display: flex; - align-items: center; - gap: 10px; - letter-spacing: -0.3px; - - .sidebar-logo-icon { - width: 28px; - height: 28px; - background: linear-gradient(135deg, #3B82F6 0%, #8B5CF6 100%); - border-radius: 6px; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - position: relative; - overflow: hidden; - - &::before { - content: ''; - position: absolute; - width: 14px; - height: 10px; - background: rgba(255, 255, 255, 0.95); - border-radius: 4px 4px 2px 2px; - top: 5px; - } - - &::after { - content: ''; - position: absolute; - width: 18px; - height: 10px; - background: rgba(255, 255, 255, 0.85); - border-radius: 2px 2px 4px 4px; - bottom: 4px; - } - - span { - position: absolute; - width: 3px; - height: 3px; - background: rgba(59, 130, 246, 0.9); - border-radius: 50%; - top: 9px; - z-index: 1; - - &:nth-child(1) { left: 9px; } - &:nth-child(2) { right: 9px; } - } - } -} - -.home-nav { - display: flex; - gap: 6px; - - a { - color: var(--color-text-2); - text-decoration: none; - padding: 9px 16px; - border-radius: 8px; - font-weight: 600; - font-size: 14px; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - display: flex; - align-items: center; - gap: 6px; - - &:hover { - color: var(--color-text-1); - background: var(--color-bg-2); - } - - svg, .home-icon { - width: 18px; - height: 18px; - display: flex; - align-items: center; - justify-content: center; - } - - &.home-nav-login { - color: #3B82F6; - font-weight: 600; - - &:hover { - color: #2563EB; - background: #EFF6FF; - } - } - } -} - .home-main { flex: 1; display: flex; @@ -300,22 +190,8 @@ line-height: 1.6; } -.home-footer { - padding: 28px; - text-align: center; - color: var(--color-text-4); - font-size: 13px; - font-weight: 500; - position: relative; - z-index: 1; -} - // Responsive @include mobile { - .home-header { - padding: 0 16px; - } - .home-title { font-size: 36px; } @@ -338,8 +214,4 @@ .home-features { grid-template-columns: 1fr; } - - .home-nav { - display: none; - } }