refactor: 项目管理菜单改造为下拉导航组

- 新增 SidebarNavGroup 组件支持可展开导航组
- 路由从 /console/projects 调整为 /console/project/*
- 成员管理页面独立为子菜单
- 新增权限配置、技能配置占位页面
- URL 驱动展开状态,刷新保持
- 更新 README.md 和 specs
This commit is contained in:
2026-03-30 14:11:31 +08:00
parent ea81a714bb
commit 4f2faa3e8d
12 changed files with 278 additions and 19 deletions

View File

@@ -128,7 +128,7 @@ src/
| 模块 | 路由 | 功能 |
|------|------|------|
| 首页 | `/` | 品牌展示、登录入口 |
| 工作台 | `/console` | 聊天、技能市场、定时任务、项目管理 |
| 工作台 | `/console` | 聊天、技能市场、定时任务、项目管理(成员/权限/技能配置) |
| 管理台 | `/admin` | 部门/用户/项目管理、模型配置 |
| 开发台 | `/developer` | 技能开发、版本管理 |
@@ -272,6 +272,7 @@ export default Example;
| SearchBar | `components/common/SearchBar.jsx` | 搜索框 |
| StatusBadge | `components/common/StatusBadge.jsx` | 状态标签 |
| SidebarNavItem | `components/layout/SidebarNavItem.jsx` | 侧边栏导航项 |
| SidebarNavGroup | `components/layout/SidebarNavGroup.jsx` | 可展开侧边栏导航组 |
---
@@ -295,6 +296,12 @@ export default Example;
<Route path="chat/:scene" element={<ChatPage />} />
<Route path="skills" element={<SkillsPage />} />
<Route path="skills/:skillId" element={<SkillDetailPage />} />
<Route path="project" element={<Navigate to="members" replace />} />
<Route path="project/members" element={<MembersPage />} />
<Route path="project/members/add" element={<AddMemberPage />} />
<Route path="project/members/:memberId/config" element={<MemberConfigPage />} />
<Route path="project/permissions" element={<PermissionsPage />} />
<Route path="project/skills" element={<SkillsConfigPage />} />
{/* ...更多子路由 */}
</Route>
@@ -434,4 +441,4 @@ api.logs.filter({ user, type, status });
---
*最后更新2026-03-27*
*最后更新2026-03-30*

View File

@@ -182,3 +182,33 @@
#### Scenario: 页面样式文件内容结构
- **WHEN** 查看页面样式文件
- **THEN** 该文件包含页面特定的布局、组件、状态等样式,使用清晰的注释分节
### Requirement: 可展开导航组组件
系统 SHALL 提供可展开的导航组组件 `SidebarNavGroup`,用于组织多个相关导航项。
#### Scenario: 导航组基本结构
- **WHEN** 开发者需要创建可展开的导航组
- **THEN** 系统 SHALL 提供 `SidebarNavGroup` 组件
- **AND** 组件接受 `icon``label``children` 属性
- **AND** 组件内部使用 `SidebarNavItem` 渲染子菜单项
#### Scenario: 导航组头部交互
- **WHEN** 用户点击导航组头部
- **THEN** 系统切换展开/收起状态
- **AND** 头部显示展开/收起箭头图标
#### Scenario: 导航组样式
- **WHEN** 导航组渲染时
- **THEN** 系统 SHALL 提供以下 BEM 类名:
- `.nav-group` 容器类
- `.nav-group__header` 头部类
- `.nav-group__icon` 图标类
- `.nav-group__label` 标签类
- `.nav-group__arrow` 箭头类
- `.nav-group__children` 子菜单容器类
- `.nav-group--expanded` 展开状态修饰符
#### Scenario: 子菜单项缩进
- **WHEN** 导航组展开显示子菜单
- **THEN** 子菜单项相对于父级有左侧缩进
- **AND** 子菜单项使用 `SidebarNavItem` 组件渲染

View File

@@ -0,0 +1,53 @@
# Capability: 项目管理导航
## Purpose
项目管理导航提供工作台侧边栏中项目管理功能的下拉导航组,包含成员管理、权限配置、技能配置等子菜单入口,支持 URL 驱动的展开状态。
## Requirements
### Requirement: 项目管理下拉导航组
系统 SHALL 提供可展开的项目管理导航组,包含成员管理、权限配置、技能配置三个子菜单入口。
#### Scenario: 默认收起状态
- **WHEN** 用户访问非项目管理相关页面(如聊天、技能市场)
- **THEN** 项目管理导航组显示为收起状态
- **AND** 仅显示项目管理的图标和标签
#### Scenario: URL 驱动自动展开
- **WHEN** 用户访问 `/console/project/members``/console/project/permissions``/console/project/skills` 及其子路径
- **THEN** 项目管理导航组自动展开
- **AND** 显示三个子菜单项:成员管理、权限配置、技能配置
- **AND** 当前访问的子菜单项高亮
#### Scenario: 点击头部展开收起
- **WHEN** 用户点击项目管理导航组的头部区域
- **THEN** 切换展开/收起状态
#### Scenario: 子菜单项导航
- **WHEN** 用户点击"成员管理"子菜单项
- **THEN** 导航至 `/console/project/members`
- **WHEN** 用户点击"权限配置"子菜单项
- **THEN** 导航至 `/console/project/permissions`
- **WHEN** 用户点击"技能配置"子菜单项
- **THEN** 导航至 `/console/project/skills`
#### Scenario: 页面刷新保持展开状态
- **WHEN** 用户在项目管理子页面刷新浏览器
- **THEN** 项目管理导航组保持展开状态
- **AND** 当前子菜单项保持高亮
### Requirement: 项目管理子菜单高亮
系统 SHALL 在导航组中高亮当前访问的子菜单项。
#### Scenario: 成员管理高亮
- **WHEN** 用户访问 `/console/project/members` 或其子路径(如 `/console/project/members/add`
- **THEN** "成员管理"子菜单项显示激活状态
#### Scenario: 权限配置高亮
- **WHEN** 用户访问 `/console/project/permissions`
- **THEN** "权限配置"子菜单项显示激活状态
#### Scenario: 技能配置高亮
- **WHEN** 用户访问 `/console/project/skills`
- **THEN** "技能配置"子菜单项显示激活状态

View File

@@ -16,9 +16,11 @@ import SkillConfigPage from './pages/console/SkillConfigPage.jsx';
import LogsPage from './pages/console/LogsPage.jsx';
import TasksPage from './pages/console/TasksPage.jsx';
import TaskDetailPage from './pages/console/TaskDetailPage.jsx';
import ProjectsPage from './pages/console/ProjectsPage.jsx';
import MembersPage from './pages/console/MembersPage.jsx';
import MemberConfigPage from './pages/console/MemberConfigPage.jsx';
import AddMemberPage from './pages/console/AddMemberPage.jsx';
import PermissionsPage from './pages/console/PermissionsPage.jsx';
import SkillsConfigPage from './pages/console/SkillsConfigPage.jsx';
import ConsoleReviewListPage from './pages/console/ConsoleReviewListPage.jsx';
import ConsoleReviewDetailPage from './pages/console/ConsoleReviewDetailPage.jsx';
@@ -63,9 +65,12 @@ function App() {
<Route path="logs" element={<LogsPage />} />
<Route path="tasks" element={<TasksPage />} />
<Route path="tasks/:taskId" element={<TaskDetailPage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/members/add" element={<AddMemberPage />} />
<Route path="projects/members/:memberId/config" element={<MemberConfigPage />} />
<Route path="project" element={<Navigate to="members" replace />} />
<Route path="project/members" element={<MembersPage />} />
<Route path="project/members/add" element={<AddMemberPage />} />
<Route path="project/members/:memberId/config" element={<MemberConfigPage />} />
<Route path="project/permissions" element={<PermissionsPage />} />
<Route path="project/skills" element={<SkillsConfigPage />} />
</Route>
<Route path="/admin" element={<AdminLayout />}>

View File

@@ -1,10 +1,11 @@
import { useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { FiPlus, FiClock, FiList, FiUsers, FiBox } from 'react-icons/fi';
import { FiPlus, FiClock, FiList, FiUsers, FiBox, FiFolder, FiShield, FiSettings } from 'react-icons/fi';
import { FiTrash2 } from 'react-icons/fi';
import { FaPuzzlePiece } from 'react-icons/fa';
import Layout from '../Layout.jsx';
import SidebarNavItem from './SidebarNavItem.jsx';
import SidebarNavGroup from './SidebarNavGroup.jsx';
import Modal from '../common/Modal.jsx';
import api from '../../services/api.js';
@@ -101,12 +102,30 @@ function ConsoleLayout() {
active={isPathActive('/console/logs')}
onClick={() => navigate('/console/logs')}
/>
<SidebarNavItem
icon={<FiUsers />}
<SidebarNavGroup
icon={<FiFolder />}
label="项目管理"
active={isPathActive('/console/projects')}
onClick={() => navigate('/console/projects')}
/>
paths={['/console/project']}
>
<SidebarNavItem
icon={<FiUsers />}
label="成员管理"
active={isPathActive('/console/project/members')}
onClick={() => navigate('/console/project/members')}
/>
<SidebarNavItem
icon={<FiShield />}
label="权限配置"
active={isPathActive('/console/project/permissions')}
onClick={() => navigate('/console/project/permissions')}
/>
<SidebarNavItem
icon={<FiSettings />}
label="技能配置"
active={isPathActive('/console/project/skills')}
onClick={() => navigate('/console/project/skills')}
/>
</SidebarNavGroup>
</div>
<Modal
visible={!!deleteTarget}

View File

@@ -0,0 +1,44 @@
import { useState } from 'react';
import { useLocation } from 'react-router-dom';
import { FiChevronDown, FiChevronRight } from 'react-icons/fi';
/**
* SidebarNavGroup - 可展开的侧边栏导航组组件
* 用于组织多个相关导航项
*
* @param {Object} props - 组件属性
* @param {React.ReactNode} props.icon - 主图标组件
* @param {string} props.label - 导航组标签文本
* @param {Array<string>} props.paths - 用于判断展开状态的路径前缀数组
* @param {React.ReactNode} props.children - 子菜单项SidebarNavItem 组件)
*/
function SidebarNavGroup({ icon, label, paths = [], children }) {
const location = useLocation();
const [manualExpanded, setManualExpanded] = useState(null);
const isUrlActive = paths.some(path => location.pathname.startsWith(path));
const isExpanded = manualExpanded !== null ? manualExpanded : isUrlActive;
const handleHeaderClick = () => {
setManualExpanded(!isExpanded);
};
return (
<div className={`nav-group ${isExpanded ? 'nav-group--expanded' : ''}`}>
<div className="nav-group__header" onClick={handleHeaderClick}>
<span className="nav-group__icon">{icon}</span>
<span className="nav-group__label">{label}</span>
<span className="nav-group__arrow">
{isExpanded ? <FiChevronDown /> : <FiChevronRight />}
</span>
</div>
{isExpanded && (
<div className="nav-group__children">
{children}
</div>
)}
</div>
);
}
export default SidebarNavGroup;

View File

@@ -33,7 +33,7 @@ function AddMemberPage() {
return (
<>
<div className="page-back-btn" onClick={() => navigate('/console/projects')}>
<div className="page-back-btn" onClick={() => navigate('/console/project/members')}>
<span></span>
<span>返回成员列表</span>
</div>
@@ -53,7 +53,7 @@ function AddMemberPage() {
onClearSelected={() => setSelectedMembers([])}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', marginTop: '16px' }}>
<button className="btn btn-secondary" onClick={() => navigate('/console/projects')}>取消</button>
<button className="btn btn-secondary" onClick={() => navigate('/console/project/members')}>取消</button>
<button className="btn btn-primary" onClick={handleAdd} disabled={selectedMembers.length === 0}>
添加选中成员 ({selectedMembers.length})
</button>

View File

@@ -6,7 +6,7 @@ function MemberConfigPage() {
return (
<>
<div className="page-back-btn" onClick={() => navigate('/console/projects')}>
<div className="page-back-btn" onClick={() => navigate('/console/project/members')}>
<span></span>
<span>返回成员列表</span>
</div>

View File

@@ -5,7 +5,7 @@ import { projectMembers } from '../../data/members.js';
import EmptyState from '../../components/common/EmptyState.jsx';
import Modal from '../../components/common/Modal.jsx';
function ProjectsPage() {
function MembersPage() {
const navigate = useNavigate();
const [members, setMembers] = useState(projectMembers);
const [removeTarget, setRemoveTarget] = useState(null);
@@ -84,7 +84,7 @@ function ProjectsPage() {
<div className="card">
<div className="card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="card-title">成员列表</div>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/console/projects/members/add')}>增加成员</button>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/console/project/members/add')}>增加成员</button>
</div>
<div className="card-body">
{filteredMembers.length > 0 ? (
@@ -112,7 +112,7 @@ function ProjectsPage() {
<td><span className={`status ${member.role === '管理员' ? 'role-admin' : 'role-member'}`}>{member.role}</span></td>
<td className="col-actions--narrow">
<div className="table-actions">
<button className="text-btn text-btn-primary" onClick={() => navigate(`/console/projects/members/${member.id}/config`)}>配置</button>
<button className="text-btn text-btn-primary" onClick={() => navigate(`/console/project/members/${member.id}/config`)}>配置</button>
<button className="text-btn text-btn-danger" onClick={() => handleRemoveClick(member)}>移除</button>
</div>
</td>
@@ -150,4 +150,4 @@ function ProjectsPage() {
);
}
export default ProjectsPage;
export default MembersPage;

View File

@@ -0,0 +1,21 @@
import { FiShield } from 'react-icons/fi';
import EmptyState from '../../components/common/EmptyState.jsx';
function PermissionsPage() {
return (
<div className="card">
<div className="card-header">
<div className="card-title">权限配置</div>
</div>
<div className="card-body">
<EmptyState
icon={<FiShield size={48} />}
message="权限配置功能开发中"
description="该功能即将上线,敬请期待"
/>
</div>
</div>
);
}
export default PermissionsPage;

View File

@@ -0,0 +1,21 @@
import { FiSettings } from 'react-icons/fi';
import EmptyState from '../../components/common/EmptyState.jsx';
function SkillsConfigPage() {
return (
<div className="card">
<div className="card-header">
<div className="card-title">技能配置</div>
</div>
<div className="card-body">
<EmptyState
icon={<FiSettings size={48} />}
message="技能配置功能开发中"
description="该功能即将上线,敬请期待"
/>
</div>
</div>
);
}
export default SkillsConfigPage;

View File

@@ -46,3 +46,62 @@
font-size: $font-size-sm;
color: var(--color-text-3);
}
// 可展开导航组
.nav-group {
margin-bottom: 4px;
}
.nav-group__header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-md);
cursor: pointer;
transition: background 0.2s;
color: var(--color-text-2);
font-size: $font-size-base;
font-weight: $font-weight-medium;
&:hover {
background: var(--color-bg-2);
color: var(--color-text-1);
}
}
.nav-group__icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
font-size: $font-size-lg;
flex-shrink: 0;
}
.nav-group__label {
flex: 1;
}
.nav-group__arrow {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: $font-size-sm;
color: var(--color-text-3);
transition: transform 0.2s;
}
.nav-group__children {
margin-top: 4px;
padding-left: 12px;
}
.nav-group--expanded {
.nav-group__header {
color: var(--color-text-1);
}
}