菜单布局等初步完成
This commit is contained in:
@@ -99,10 +99,11 @@ CREATE TABLE `tb_sys_permission` (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
|
||||
|
||||
INSERT INTO `tb_sys_permission` (id,permission_id, name, code, description) VALUES
|
||||
('1','perm_system_dept_manage', '系统部门查看', 'system:dept:manage', '系统部门查看权限'),
|
||||
('2','perm_system_menu_manage', '系统菜单查看', 'system:menu:manage', '系统菜单查看权限'),
|
||||
('3','perm_system_permission_manage', '系统权限查看', 'system:permission:manage', '系统权限查看权限'),
|
||||
('4','perm_system_role_manage', '系统角色查看', 'system:role:manage', '系统角色查看权限');
|
||||
('1','perm_system_manage', '系统管理', 'system:manage', '系统管理权限'),
|
||||
('2','perm_system_dept_manage', '系统部门查看', 'system:dept:manage', '系统部门查看权限'),
|
||||
('3','perm_system_menu_manage', '系统菜单查看', 'system:menu:manage', '系统菜单查看权限'),
|
||||
('4','perm_system_permission_manage', '系统权限查看', 'system:permission:manage', '系统权限查看权限'),
|
||||
('5','perm_system_role_manage', '系统角色查看', 'system:role:manage', '系统角色查看权限');
|
||||
|
||||
-- 角色-权限关联
|
||||
DROP TABLE IF EXISTS `tb_sys_role_permission`;
|
||||
@@ -121,10 +122,11 @@ CREATE TABLE `tb_sys_role_permission` (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
|
||||
|
||||
INSERT INTO `tb_sys_role_permission` (id, role_id, permission_id) VALUES
|
||||
('1', 'admin', 'perm_system_dept_manage'),
|
||||
('2', 'admin', 'perm_system_menu_manage'),
|
||||
('3', 'admin', 'perm_system_permission_manage'),
|
||||
('4', 'admin', 'perm_system_role_manage');
|
||||
('1', 'admin', 'perm_system_manage'),
|
||||
('2', 'admin', 'perm_system_dept_manage'),
|
||||
('3', 'admin', 'perm_system_menu_manage'),
|
||||
('4', 'admin', 'perm_system_permission_manage'),
|
||||
('5', 'admin', 'perm_system_role_manage');
|
||||
|
||||
-- 菜单表
|
||||
DROP TABLE IF EXISTS `tb_sys_menu`;
|
||||
@@ -151,10 +153,11 @@ CREATE TABLE `tb_sys_menu` (
|
||||
|
||||
-- Insert default menus
|
||||
INSERT INTO `tb_sys_menu` (id,menu_id, name, parent_id, url, component, icon, order_num, type) VALUES
|
||||
('1','menu_system_dept', '部门管理', NULL, '/manage/system/dept', 'manage/system/DeptManageView', 'el-icon-office-building', 1, 1),
|
||||
('2','menu_system_menu', '菜单管理', NULL, '/manage/system/menu', 'manage/system/MenuManageView', 'el-icon-menu', 2, 1),
|
||||
('3','menu_system_permission', '权限管理', NULL, '/manage/system/permission', 'manage/system/PermissionManageView', 'el-icon-key', 3, 1),
|
||||
('4','menu_system_role', '角色管理', NULL, '/manage/system/role', 'manage/system/RoleManageView', 'el-icon-user', 4, 1);
|
||||
('1','menu_system_manage', '系统管理', NULL, '/manage/system/manage', 'manage/system/ManageView', 'el-icon-setting', 1, 1),
|
||||
('2','menu_system_dept', '部门管理', 'menu_system_manage', '/manage/system/dept', 'manage/system/DeptManageView', 'el-icon-office-building', 2, 1),
|
||||
('3','menu_system_menu', '菜单管理', 'menu_system_manage', '/manage/system/menu', 'manage/system/MenuManageView', 'el-icon-menu', 2, 1),
|
||||
('4','menu_system_permission', '权限管理', 'menu_system_manage', '/manage/system/permission', 'manage/system/PermissionManageView', 'el-icon-key', 3, 1),
|
||||
('5','menu_system_role', '角色管理', 'menu_system_manage', '/manage/system/role', 'manage/system/RoleManageView', 'el-icon-user', 4, 1);
|
||||
|
||||
DROP TABLE IF EXISTS `tb_sys_menu_permission`;
|
||||
CREATE TABLE `tb_sys_menu_permission` (
|
||||
@@ -172,7 +175,8 @@ CREATE TABLE `tb_sys_menu_permission` (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
|
||||
-- Insert menu-permission associations
|
||||
INSERT INTO `tb_sys_menu_permission` (id, permission_id, menu_id) VALUES
|
||||
('1', 'perm_system_dept_manage', 'menu_system_dept'),
|
||||
('2', 'perm_system_menu_manage', 'menu_system_menu'),
|
||||
('3', 'perm_system_permission_manage', 'menu_system_permission'),
|
||||
('4', 'perm_system_role_manage', 'menu_system_role');
|
||||
('1', 'perm_system_manage', 'menu_system_manage'),
|
||||
('2', 'perm_system_dept_manage', 'menu_system_dept'),
|
||||
('3', 'perm_system_menu_manage', 'menu_system_menu'),
|
||||
('4', 'perm_system_permission_manage', 'menu_system_permission'),
|
||||
('5', 'perm_system_role_manage', 'menu_system_role');
|
||||
662
schoolNewsWeb/.docs/完整系统文档.md
Normal file
662
schoolNewsWeb/.docs/完整系统文档.md
Normal file
@@ -0,0 +1,662 @@
|
||||
# 校园新闻管理系统 - 完整技术文档
|
||||
|
||||
## 项目概述
|
||||
|
||||
校园新闻管理系统是一个基于 Vue 3 + TypeScript 的现代化前端应用,实现了基于角色和权限的动态路由、三层导航架构以及状态持久化功能。
|
||||
|
||||
## 目录
|
||||
|
||||
1. [快速开始](#快速开始)
|
||||
2. [系统架构](#系统架构)
|
||||
3. [导航系统](#导航系统)
|
||||
4. [权限控制](#权限控制)
|
||||
5. [开发指南](#开发指南)
|
||||
6. [配置示例](#配置示例)
|
||||
7. [常见问题](#常见问题)
|
||||
8. [技术栈](#技术栈)
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js 16+
|
||||
- npm 或 yarn
|
||||
|
||||
### 安装和运行
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
yarn install
|
||||
|
||||
# 开发环境
|
||||
yarn serve
|
||||
|
||||
# 生产构建
|
||||
yarn build
|
||||
|
||||
# 代码检查
|
||||
yarn lint
|
||||
```
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── apis/ # API 接口
|
||||
├── assets/ # 静态资源
|
||||
├── components/ # 公共组件
|
||||
│ ├── TopNavigation.vue # 顶部导航栏
|
||||
│ ├── MenuNav.vue # 侧边栏菜单
|
||||
│ ├── MenuItem.vue # 菜单项
|
||||
│ ├── Breadcrumb.vue # 面包屑
|
||||
│ └── UserDropdown.vue # 用户下拉菜单
|
||||
├── directives/ # 自定义指令
|
||||
├── layouts/ # 布局组件
|
||||
│ ├── NavigationLayout.vue # 导航布局
|
||||
│ ├── BasicLayout.vue # 基础布局
|
||||
│ ├── BlankLayout.vue # 空白布局
|
||||
│ └── PageLayout.vue # 页面布局
|
||||
├── router/ # 路由配置
|
||||
├── store/ # Vuex 状态管理
|
||||
├── types/ # TypeScript 类型定义
|
||||
├── utils/ # 工具函数
|
||||
└── views/ # 页面组件
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 系统架构
|
||||
|
||||
### 核心设计理念
|
||||
|
||||
系统采用三层导航架构,通过 `MenuType` 枚举实现灵活的菜单显示控制:
|
||||
|
||||
```typescript
|
||||
export enum MenuType {
|
||||
SIDEBAR = 0, // 侧边栏菜单
|
||||
NAVIGATION = 1, // 顶部导航菜单
|
||||
BUTTON = 2, // 按钮(不生成路由)
|
||||
PAGE = 3 // 独立页面(不使用 NavigationLayout)
|
||||
}
|
||||
```
|
||||
|
||||
### 布局判断逻辑
|
||||
|
||||
系统根据路由的 `meta.menuType` 自动决定布局:
|
||||
|
||||
```typescript
|
||||
// App.vue
|
||||
const useNavigationLayout = computed(() => {
|
||||
const menuType = route.meta?.menuType;
|
||||
return menuType !== MenuType.PAGE; // 只有 PAGE 类型不使用布局
|
||||
});
|
||||
```
|
||||
|
||||
**规则**:
|
||||
- ✅ `menuType !== 3 (PAGE)` → 使用 NavigationLayout(有顶部导航栏)
|
||||
- ❌ `menuType === 3 (PAGE)` → 不使用布局(独立页面,如登录页)
|
||||
|
||||
### 页面渲染流程
|
||||
|
||||
```
|
||||
用户访问页面
|
||||
↓
|
||||
App.vue 检查 route.meta.menuType
|
||||
↓
|
||||
┌─────────────────────┬──────────────────────┐
|
||||
│ menuType === 3 │ menuType !== 3 │
|
||||
│ (PAGE) │ (其他类型) │
|
||||
└─────────────────────┴──────────────────────┘
|
||||
↓ ↓
|
||||
独立渲染页面 使用 NavigationLayout
|
||||
(无导航栏) (有导航栏)
|
||||
↓ ↓
|
||||
router-view TopNavigation
|
||||
↓
|
||||
根据 menuType 显示:
|
||||
- NAVIGATION → 顶部导航项
|
||||
- SIDEBAR → 左侧边栏
|
||||
↓
|
||||
router-view
|
||||
```
|
||||
|
||||
### 状态管理
|
||||
|
||||
#### 认证状态持久化
|
||||
|
||||
```typescript
|
||||
// 从 localStorage 恢复状态
|
||||
function getStoredState(): Partial<AuthState> {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const loginDomainStr = localStorage.getItem('loginDomain');
|
||||
const menusStr = localStorage.getItem('menus');
|
||||
const permissionsStr = localStorage.getItem('permissions');
|
||||
|
||||
return {
|
||||
token: token || null,
|
||||
loginDomain: loginDomainStr ? JSON.parse(loginDomainStr) : null,
|
||||
menus: menusStr ? JSON.parse(menusStr) : [],
|
||||
permissions: permissionsStr ? JSON.parse(permissionsStr) : [],
|
||||
routesLoaded: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('从localStorage恢复状态失败:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 状态恢复机制
|
||||
|
||||
```typescript
|
||||
async restoreAuth({ state, commit, dispatch }) {
|
||||
try {
|
||||
if (!state.token) return false;
|
||||
|
||||
if (state.loginDomain && state.menus.length > 0) {
|
||||
console.log('从localStorage恢复登录状态');
|
||||
await dispatch('generateRoutes');
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('恢复登录状态失败:', error);
|
||||
commit('CLEAR_AUTH');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 导航系统
|
||||
|
||||
### 三层导航架构
|
||||
|
||||
#### 第一层:顶部导航栏(MenuType.NAVIGATION)
|
||||
- 显示在所有页面的顶部
|
||||
- 作为主要的一级导航
|
||||
- 支持下拉菜单
|
||||
|
||||
#### 第二层:下拉菜单 / 侧边栏
|
||||
- **下拉菜单**:如果子菜单类型是 `MenuType.NAVIGATION`,则作为顶部导航的下拉选项
|
||||
- **侧边栏**:如果子菜单类型是 `MenuType.SIDEBAR`,则显示在页面左侧作为侧边栏
|
||||
|
||||
#### 第三层及更深:侧边栏子菜单
|
||||
- 在侧边栏中显示
|
||||
- 支持多层嵌套
|
||||
|
||||
### 菜单结构示例
|
||||
|
||||
#### 示例 1:新闻管理(带侧边栏)
|
||||
|
||||
```json
|
||||
{
|
||||
"menuID": "news",
|
||||
"name": "新闻管理",
|
||||
"url": "/news",
|
||||
"type": 1, // NAVIGATION - 显示在顶部导航栏
|
||||
"component": "NavigationLayout",
|
||||
"children": [
|
||||
{
|
||||
"menuID": "news-article",
|
||||
"name": "文章管理",
|
||||
"url": "/news/article",
|
||||
"type": 0, // SIDEBAR - 显示在左侧边栏
|
||||
"component": "manage/news/ArticleManage.vue",
|
||||
"children": [
|
||||
{
|
||||
"menuID": "news-article-list",
|
||||
"name": "文章列表",
|
||||
"url": "/news/article/list",
|
||||
"type": 0, // SIDEBAR - 侧边栏子菜单
|
||||
"component": "manage/news/ArticleList.vue"
|
||||
},
|
||||
{
|
||||
"menuID": "news-article-create",
|
||||
"name": "创建文章",
|
||||
"url": "/news/article/create",
|
||||
"type": 0, // SIDEBAR
|
||||
"component": "manage/news/ArticleCreate.vue"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"menuID": "news-category",
|
||||
"name": "分类管理",
|
||||
"url": "/news/category",
|
||||
"type": 0, // SIDEBAR - 显示在左侧边栏
|
||||
"component": "manage/news/CategoryManage.vue"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**效果**:
|
||||
- "新闻管理" 显示在顶部导航栏
|
||||
- 左侧侧边栏显示:
|
||||
- 文章管理
|
||||
- 文章列表
|
||||
- 创建文章
|
||||
- 分类管理
|
||||
|
||||
#### 示例 2:系统管理(带下拉菜单)
|
||||
|
||||
```json
|
||||
{
|
||||
"menuID": "system",
|
||||
"name": "系统管理",
|
||||
"url": "/system",
|
||||
"type": 1, // NAVIGATION - 显示在顶部导航栏
|
||||
"component": "NavigationLayout",
|
||||
"children": [
|
||||
{
|
||||
"menuID": "system-user",
|
||||
"name": "用户管理",
|
||||
"url": "/system/user",
|
||||
"type": 1, // NAVIGATION - 作为下拉菜单
|
||||
"component": "manage/system/UserManage.vue"
|
||||
},
|
||||
{
|
||||
"menuID": "system-role",
|
||||
"name": "角色管理",
|
||||
"url": "/system/role",
|
||||
"type": 1, // NAVIGATION - 作为下拉菜单
|
||||
"component": "manage/system/RoleManage.vue"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**效果**:
|
||||
- "系统管理" 显示在顶部导航栏,鼠标悬停显示下拉菜单
|
||||
- 下拉菜单包含:用户管理、角色管理
|
||||
- 没有侧边栏
|
||||
|
||||
### 路由生成流程
|
||||
|
||||
```
|
||||
后端返回菜单数据
|
||||
↓
|
||||
Vuex Store 保存到 state.auth.menus
|
||||
↓
|
||||
持久化到 localStorage
|
||||
↓
|
||||
generateRoutes() 函数处理
|
||||
↓
|
||||
根据 type 和层级决定使用的布局
|
||||
↓
|
||||
生成 Vue Router 路由配置
|
||||
↓
|
||||
router.addRoute() 动态添加路由
|
||||
↓
|
||||
设置 routesLoaded = true
|
||||
```
|
||||
|
||||
### 静态路由与动态路由合并
|
||||
|
||||
#### buildMenuTree 流程
|
||||
|
||||
```typescript
|
||||
function buildMenuTree(menus: SysMenu[]) {
|
||||
// 1. 将静态路由转换为菜单项
|
||||
const staticMenus = convertRoutesToMenus(routes);
|
||||
|
||||
// 2. 合并静态菜单和动态菜单
|
||||
const allMenus = [...staticMenus, ...menus];
|
||||
|
||||
// 3. 构建树结构并排序(按 orderNum)
|
||||
return sortMenus(rootMenus);
|
||||
}
|
||||
```
|
||||
|
||||
#### 避免路由重复
|
||||
|
||||
```typescript
|
||||
function convertRoutesToMenus(routes: RouteRecordRaw[]) {
|
||||
routes.forEach(route => {
|
||||
if (route.meta?.menuType !== undefined) {
|
||||
const menu: SysMenu = {
|
||||
// ...
|
||||
component: '__STATIC_ROUTE__', // 特殊标记
|
||||
};
|
||||
menus.push(menu);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function generateRouteFromMenu(menu: SysMenu) {
|
||||
// 跳过按钮类型
|
||||
if (menu.type === MenuType.BUTTON) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 跳过静态路由(避免重复添加)
|
||||
if (menu.component === '__STATIC_ROUTE__') {
|
||||
return null; // ✅ 不生成路由
|
||||
}
|
||||
|
||||
// 正常生成动态路由
|
||||
return route;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 权限控制
|
||||
|
||||
### 权限指令使用
|
||||
|
||||
#### v-permission 指令
|
||||
|
||||
```vue
|
||||
<!-- 单个权限 -->
|
||||
<el-button v-permission="'user:create'">新增用户</el-button>
|
||||
|
||||
<!-- 多个权限(任意一个) -->
|
||||
<el-button v-permission="['user:create', 'user:edit']">操作</el-button>
|
||||
|
||||
<!-- 多个权限(必须全部拥有) -->
|
||||
<el-button v-permission.all="['user:create', 'user:edit']">操作</el-button>
|
||||
```
|
||||
|
||||
#### v-role 指令
|
||||
|
||||
```vue
|
||||
<!-- 单个角色 -->
|
||||
<div v-role="'admin'">管理员内容</div>
|
||||
|
||||
<!-- 多个角色 -->
|
||||
<div v-role="['admin', 'moderator']">管理内容</div>
|
||||
```
|
||||
|
||||
### Composition API 使用
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { usePermission } from '@/directives/permission';
|
||||
|
||||
const { hasPermission, hasAnyPermission, hasRole } = usePermission();
|
||||
|
||||
// 检查权限
|
||||
if (hasPermission('user:create')) {
|
||||
// 有权限的逻辑
|
||||
}
|
||||
|
||||
// 检查角色
|
||||
if (hasRole('admin')) {
|
||||
// 管理员逻辑
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 路由守卫
|
||||
|
||||
系统自动设置了路由守卫:
|
||||
|
||||
```typescript
|
||||
async function handleRouteGuard(
|
||||
to: RouteLocationNormalized,
|
||||
from: RouteLocationNormalized,
|
||||
next: NavigationGuardNext,
|
||||
store: Store<any>
|
||||
) {
|
||||
const authState: AuthState = store.state.auth;
|
||||
const isAuthenticated = store.getters['auth/isAuthenticated'];
|
||||
|
||||
if (isInWhiteList(to.path)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (authState.token && !isAuthenticated) {
|
||||
try {
|
||||
const restored = await store.dispatch('auth/restoreAuth');
|
||||
if (restored) {
|
||||
return next({ ...to, replace: true });
|
||||
} else {
|
||||
return next({ path: '/login', query: { redirect: to.fullPath } });
|
||||
}
|
||||
} catch (error) {
|
||||
return next({ path: '/login', query: { redirect: to.fullPath } });
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return next({ path: '/login', query: { redirect: to.fullPath } });
|
||||
}
|
||||
|
||||
if (!authState.routesLoaded) {
|
||||
try {
|
||||
await store.dispatch('auth/generateRoutes');
|
||||
return next({ ...to, replace: true });
|
||||
} catch (error) {
|
||||
store.commit('auth/CLEAR_AUTH');
|
||||
return next('/login');
|
||||
}
|
||||
}
|
||||
|
||||
const hasPermission = await checkPagePermission(to, store);
|
||||
if (!hasPermission) {
|
||||
return next('/403');
|
||||
}
|
||||
next();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 组件说明
|
||||
|
||||
#### TopNavigation.vue
|
||||
顶部导航栏组件,功能包括:
|
||||
- 显示 Logo
|
||||
- 显示一级 NAVIGATION 菜单
|
||||
- 支持下拉菜单(二级 NAVIGATION 菜单)
|
||||
- 显示用户信息和登出功能
|
||||
|
||||
#### NavigationLayout.vue
|
||||
导航布局组件,功能包括:
|
||||
- 包含 TopNavigation
|
||||
- 根据当前路由动态显示侧边栏(SIDEBAR 类型的子菜单)
|
||||
- 支持侧边栏折叠
|
||||
- 显示面包屑导航
|
||||
|
||||
#### MenuNav.vue
|
||||
菜单导航组件,用于渲染侧边栏菜单树
|
||||
|
||||
### 开发建议
|
||||
|
||||
1. **第一层菜单**:使用 `MenuType.NAVIGATION`,这是顶部导航
|
||||
2. **第二层菜单**:
|
||||
- 如果是简单的操作页面(如用户管理、角色管理),使用 `MenuType.NAVIGATION` 作为下拉菜单
|
||||
- 如果需要更复杂的子菜单结构,使用 `MenuType.SIDEBAR` 显示侧边栏
|
||||
3. **第三层及更深**:使用 `MenuType.SIDEBAR`,在侧边栏中嵌套显示
|
||||
4. **按钮**:不需要路由的权限按钮使用 `MenuType.BUTTON`
|
||||
|
||||
### 菜单配置规则
|
||||
|
||||
1. **第一层菜单**:
|
||||
- 必须使用 `type: 1`(NAVIGATION)
|
||||
- 会显示在顶部导航栏
|
||||
|
||||
2. **第二层菜单**:
|
||||
- `type: 1`(NAVIGATION)→ 显示为下拉菜单选项
|
||||
- `type: 0`(SIDEBAR)→ 显示在侧边栏
|
||||
|
||||
3. **第三层及更深**:
|
||||
- 通常使用 `type: 0`(SIDEBAR)
|
||||
- 在侧边栏中嵌套显示
|
||||
|
||||
---
|
||||
|
||||
## 配置示例
|
||||
|
||||
### 静态路由配置(src/router/index.ts)
|
||||
|
||||
```typescript
|
||||
export const routes = [
|
||||
// PAGE 类型 - 不使用布局
|
||||
{
|
||||
path: "/login",
|
||||
meta: { menuType: 3 }
|
||||
},
|
||||
{
|
||||
path: "/404",
|
||||
meta: { menuType: 3 }
|
||||
},
|
||||
|
||||
// NAVIGATION 类型 - 使用布局,显示在导航栏
|
||||
{
|
||||
path: "/home",
|
||||
meta: { menuType: 1, orderNum: -1 }
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### 动态路由数据(后端返回)
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"menuID": "dashboard",
|
||||
"name": "工作台",
|
||||
"type": 1,
|
||||
"url": "/dashboard/workplace",
|
||||
"orderNum": 0
|
||||
},
|
||||
{
|
||||
"menuID": "news",
|
||||
"name": "新闻管理",
|
||||
"type": 1,
|
||||
"url": "/news",
|
||||
"orderNum": 1,
|
||||
"children": [
|
||||
{
|
||||
"menuID": "news-article",
|
||||
"name": "文章管理",
|
||||
"type": 0,
|
||||
"url": "/news/article"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 数据库字段说明
|
||||
|
||||
```sql
|
||||
CREATE TABLE sys_menu (
|
||||
menu_id VARCHAR(50) PRIMARY KEY COMMENT '菜单ID',
|
||||
parent_id VARCHAR(50) COMMENT '父菜单ID',
|
||||
name VARCHAR(50) NOT NULL COMMENT '菜单名称',
|
||||
description VARCHAR(200) COMMENT '菜单描述',
|
||||
url VARCHAR(200) COMMENT '菜单URL',
|
||||
component VARCHAR(200) COMMENT '组件路径',
|
||||
icon VARCHAR(50) COMMENT '菜单图标',
|
||||
order_num INT DEFAULT 0 COMMENT '排序号',
|
||||
type TINYINT NOT NULL DEFAULT 0 COMMENT '菜单类型:0-侧边栏 1-导航栏 2-按钮 3-独立页面',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
creator VARCHAR(50) COMMENT '创建人',
|
||||
updater VARCHAR(50) COMMENT '更新人'
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 顶部导航没有显示
|
||||
- 检查菜单数据是否正确加载到 Vuex store
|
||||
- 检查第一层菜单的 `type` 是否为 `1`(NAVIGATION)
|
||||
- 打开浏览器控制台,执行:
|
||||
```js
|
||||
console.log($store.getters['auth/menuTree'])
|
||||
```
|
||||
|
||||
### Q2: 侧边栏没有显示
|
||||
- 检查第二层菜单的 `type` 是否为 `0`(SIDEBAR)
|
||||
- 如果第二层都是 `type: 1`(NAVIGATION),则不会显示侧边栏(这是预期行为)
|
||||
|
||||
### Q3: 点击菜单没有跳转
|
||||
- 检查菜单的 `url` 字段是否正确
|
||||
- 检查对应的组件 `component` 字段是否存在
|
||||
- 检查组件文件是否真实存在
|
||||
|
||||
### Q4: 页面刷新后侧边栏消失
|
||||
- 检查 localStorage 中是否保存了菜单数据
|
||||
- 检查路由守卫是否正确恢复了登录状态
|
||||
- 打开控制台查看是否有错误信息
|
||||
|
||||
### Q5: 路由生成失败
|
||||
- 检查菜单数据结构是否正确
|
||||
- 检查 `component` 字段指向的组件文件是否存在
|
||||
- 查看控制台的错误信息
|
||||
|
||||
### Q6: 如何隐藏某个页面的侧边栏?
|
||||
A: 将该菜单及其兄弟菜单都设置为 `MenuType.NAVIGATION` 类型。
|
||||
|
||||
### Q7: 如何让某个菜单在顶部和侧边栏都显示?
|
||||
A: 在第二层使用 `MenuType.NAVIGATION`(显示在下拉菜单),然后它的子菜单使用 `MenuType.SIDEBAR`(显示在侧边栏)。
|
||||
|
||||
### Q8: 可以有多少层菜单?
|
||||
A: 理论上无限制,但建议不超过4层以保持良好的用户体验。
|
||||
|
||||
### Q9: 按钮类型的菜单有什么用?
|
||||
A: `MenuType.BUTTON` 类型的菜单不会生成路由,主要用于页面内的操作按钮权限控制,例如"删除"、"编辑"等按钮。
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **Vue 3**:Composition API + `<script setup>`
|
||||
- **TypeScript**:类型安全
|
||||
- **Vue Router 4**:动态路由
|
||||
- **Vuex**:状态管理
|
||||
- **SCSS**:样式预处理
|
||||
- **Element Plus**:UI 组件库
|
||||
|
||||
---
|
||||
|
||||
## 性能优化
|
||||
|
||||
1. **组件懒加载**:所有页面组件都使用动态导入
|
||||
2. **状态缓存**:登录信息缓存到 localStorage,减少重复请求
|
||||
3. **路由缓存**:动态路由只在必要时重新生成
|
||||
4. **CSS 优化**:使用 scoped 样式,避免全局污染
|
||||
|
||||
---
|
||||
|
||||
## 浏览器兼容性
|
||||
|
||||
- ✅ Chrome 90+
|
||||
- ✅ Firefox 88+
|
||||
- ✅ Safari 14+
|
||||
- ✅ Edge 90+
|
||||
|
||||
---
|
||||
|
||||
## 版本历史
|
||||
|
||||
### v1.0.0 (2025-10-08)
|
||||
- ✅ 初始版本发布
|
||||
- ✅ 实现三层导航架构
|
||||
- ✅ 支持动态路由生成
|
||||
- ✅ 支持状态持久化
|
||||
- ✅ 完整的文档和示例
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
请根据项目实际情况添加许可证信息。
|
||||
|
||||
---
|
||||
|
||||
**祝使用愉快!如有问题,请参考文档或联系开发团队。**
|
||||
239
schoolNewsWeb/.docs/菜单配置示例.json
Normal file
239
schoolNewsWeb/.docs/菜单配置示例.json
Normal file
@@ -0,0 +1,239 @@
|
||||
[
|
||||
{
|
||||
"menuID": "news",
|
||||
"parentID": "0",
|
||||
"name": "新闻管理",
|
||||
"description": "新闻内容管理",
|
||||
"url": "/news",
|
||||
"component": "NavigationLayout",
|
||||
"icon": "📰",
|
||||
"orderNum": 1,
|
||||
"type": 1,
|
||||
"children": [
|
||||
{
|
||||
"menuID": "news-article",
|
||||
"parentID": "news",
|
||||
"name": "文章管理",
|
||||
"description": "管理新闻文章",
|
||||
"url": "/news/article",
|
||||
"icon": "📝",
|
||||
"orderNum": 1,
|
||||
"type": 0,
|
||||
"children": [
|
||||
{
|
||||
"menuID": "news-article-list",
|
||||
"parentID": "news-article",
|
||||
"name": "文章列表",
|
||||
"url": "/news/article/list",
|
||||
"component": "manage/news/ArticleList",
|
||||
"icon": "📋",
|
||||
"orderNum": 1,
|
||||
"type": 0
|
||||
},
|
||||
{
|
||||
"menuID": "news-article-create",
|
||||
"parentID": "news-article",
|
||||
"name": "创建文章",
|
||||
"url": "/news/article/create",
|
||||
"component": "manage/news/ArticleCreate",
|
||||
"icon": "➕",
|
||||
"orderNum": 2,
|
||||
"type": 0
|
||||
},
|
||||
{
|
||||
"menuID": "news-article-edit",
|
||||
"parentID": "news-article",
|
||||
"name": "编辑文章",
|
||||
"url": "/news/article/edit",
|
||||
"component": "manage/news/ArticleEdit",
|
||||
"icon": "✏️",
|
||||
"orderNum": 3,
|
||||
"type": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"menuID": "news-category",
|
||||
"parentID": "news",
|
||||
"name": "分类管理",
|
||||
"url": "/news/category",
|
||||
"component": "manage/news/CategoryManage",
|
||||
"icon": "📂",
|
||||
"orderNum": 2,
|
||||
"type": 0
|
||||
},
|
||||
{
|
||||
"menuID": "news-tag",
|
||||
"parentID": "news",
|
||||
"name": "标签管理",
|
||||
"url": "/news/tag",
|
||||
"component": "manage/news/TagManage",
|
||||
"icon": "🏷️",
|
||||
"orderNum": 3,
|
||||
"type": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"menuID": "content",
|
||||
"parentID": "0",
|
||||
"name": "内容管理",
|
||||
"description": "网站内容管理",
|
||||
"url": "/content",
|
||||
"component": "NavigationLayout",
|
||||
"icon": "📦",
|
||||
"orderNum": 2,
|
||||
"type": 1,
|
||||
"children": [
|
||||
{
|
||||
"menuID": "content-article",
|
||||
"parentID": "content",
|
||||
"name": "文章审核",
|
||||
"description": "审核待发布文章",
|
||||
"url": "/content/article",
|
||||
"icon": "✅",
|
||||
"orderNum": 1,
|
||||
"type": 1,
|
||||
"children": [
|
||||
{
|
||||
"menuID": "content-article-pending",
|
||||
"parentID": "content-article",
|
||||
"name": "待审核",
|
||||
"url": "/content/article/pending",
|
||||
"component": "content/ArticlePending",
|
||||
"icon": "⏳",
|
||||
"orderNum": 1,
|
||||
"type": 0
|
||||
},
|
||||
{
|
||||
"menuID": "content-article-approved",
|
||||
"parentID": "content-article",
|
||||
"name": "已通过",
|
||||
"url": "/content/article/approved",
|
||||
"component": "content/ArticleApproved",
|
||||
"icon": "✔️",
|
||||
"orderNum": 2,
|
||||
"type": 0
|
||||
},
|
||||
{
|
||||
"menuID": "content-article-rejected",
|
||||
"parentID": "content-article",
|
||||
"name": "已拒绝",
|
||||
"url": "/content/article/rejected",
|
||||
"component": "content/ArticleRejected",
|
||||
"icon": "❌",
|
||||
"orderNum": 3,
|
||||
"type": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"menuID": "content-comment",
|
||||
"parentID": "content",
|
||||
"name": "评论管理",
|
||||
"url": "/content/comment",
|
||||
"component": "content/CommentManage",
|
||||
"icon": "💬",
|
||||
"orderNum": 2,
|
||||
"type": 1
|
||||
},
|
||||
{
|
||||
"menuID": "content-media",
|
||||
"parentID": "content",
|
||||
"name": "媒体库",
|
||||
"url": "/content/media",
|
||||
"component": "content/MediaLibrary",
|
||||
"icon": "🖼️",
|
||||
"orderNum": 3,
|
||||
"type": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"menuID": "system",
|
||||
"parentID": "0",
|
||||
"name": "系统管理",
|
||||
"description": "系统设置和管理",
|
||||
"url": "/system",
|
||||
"component": "NavigationLayout",
|
||||
"icon": "⚙️",
|
||||
"orderNum": 3,
|
||||
"type": 1,
|
||||
"children": [
|
||||
{
|
||||
"menuID": "system-user",
|
||||
"parentID": "system",
|
||||
"name": "用户管理",
|
||||
"url": "/system/user",
|
||||
"component": "manage/system/UserManageView",
|
||||
"icon": "👤",
|
||||
"orderNum": 1,
|
||||
"type": 1
|
||||
},
|
||||
{
|
||||
"menuID": "system-role",
|
||||
"parentID": "system",
|
||||
"name": "角色管理",
|
||||
"url": "/system/role",
|
||||
"component": "manage/system/RoleManageView",
|
||||
"icon": "👥",
|
||||
"orderNum": 2,
|
||||
"type": 1
|
||||
},
|
||||
{
|
||||
"menuID": "system-dept",
|
||||
"parentID": "system",
|
||||
"name": "部门管理",
|
||||
"url": "/system/dept",
|
||||
"component": "manage/system/DeptManageView",
|
||||
"icon": "🏢",
|
||||
"orderNum": 3,
|
||||
"type": 1
|
||||
},
|
||||
{
|
||||
"menuID": "system-menu",
|
||||
"parentID": "system",
|
||||
"name": "菜单管理",
|
||||
"url": "/system/menu",
|
||||
"component": "manage/system/MenuManageView",
|
||||
"icon": "📋",
|
||||
"orderNum": 4,
|
||||
"type": 1
|
||||
},
|
||||
{
|
||||
"menuID": "system-permission",
|
||||
"parentID": "system",
|
||||
"name": "权限管理",
|
||||
"url": "/system/permission",
|
||||
"component": "manage/system/PermissionManageView",
|
||||
"icon": "🔐",
|
||||
"orderNum": 5,
|
||||
"type": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"menuID": "dashboard",
|
||||
"parentID": "0",
|
||||
"name": "工作台",
|
||||
"description": "数据概览和统计",
|
||||
"url": "/dashboard",
|
||||
"component": "NavigationLayout",
|
||||
"icon": "📊",
|
||||
"orderNum": 0,
|
||||
"type": 1,
|
||||
"children": [
|
||||
{
|
||||
"menuID": "dashboard-workplace",
|
||||
"parentID": "dashboard",
|
||||
"name": "工作台首页",
|
||||
"url": "/dashboard/workplace",
|
||||
"component": "dashboard/Workplace",
|
||||
"icon": "🏠",
|
||||
"orderNum": 1,
|
||||
"type": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# schoolnewsweb
|
||||
|
||||
## Project setup
|
||||
```
|
||||
yarn install
|
||||
```
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
```
|
||||
yarn serve
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
```
|
||||
yarn build
|
||||
```
|
||||
|
||||
### Lints and fixes files
|
||||
```
|
||||
yarn lint
|
||||
```
|
||||
|
||||
### Customize configuration
|
||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
||||
18
schoolNewsWeb/index.html
Normal file
18
schoolNewsWeb/index.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="/schoolNewsWeb/favicon.ico">
|
||||
<title>校园新闻管理系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but 校园新闻管理系统 doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
8726
schoolNewsWeb/package-lock.json
generated
8726
schoolNewsWeb/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,11 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint"
|
||||
"dev": "vite",
|
||||
"serve": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint --ext .ts,.tsx,.vue src"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
@@ -20,18 +22,13 @@
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^5.4.0",
|
||||
"@typescript-eslint/parser": "^5.4.0",
|
||||
"@vue/cli-plugin-babel": "~5.0.0",
|
||||
"@vue/cli-plugin-eslint": "~5.0.0",
|
||||
"@vue/cli-plugin-pwa": "~5.0.0",
|
||||
"@vue/cli-plugin-router": "~5.0.0",
|
||||
"@vue/cli-plugin-typescript": "~5.0.0",
|
||||
"@vue/cli-plugin-vuex": "~5.0.0",
|
||||
"@vue/cli-service": "~5.0.0",
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"@vue/eslint-config-typescript": "^9.1.0",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-plugin-vue": "^8.0.3",
|
||||
"sass": "^1.32.7",
|
||||
"sass-loader": "^12.0.0",
|
||||
"typescript": "~4.5.5"
|
||||
"typescript": "~4.5.5",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-pwa": "^0.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
<template>
|
||||
<div>
|
||||
<div id="app">
|
||||
<!-- 直接渲染路由,由路由配置决定使用什么布局 -->
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from "vue-router";
|
||||
// App.vue 只负责渲染路由出口
|
||||
// 具体的布局(NavigationLayout, BlankLayout等)由路由配置决定
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
#app {
|
||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-align: center;
|
||||
color: #2c3e50;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
nav {
|
||||
padding: 30px;
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
|
||||
&.router-link-exact-active {
|
||||
color: #42b983;
|
||||
}
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -109,7 +109,7 @@ function toggleExpanded() {
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (props.menu.type === MenuType.MENU && props.menu.url) {
|
||||
if (props.menu.type === MenuType.NAVIGATION && props.menu.url) {
|
||||
emit('menu-click', props.menu);
|
||||
}
|
||||
}
|
||||
|
||||
292
schoolNewsWeb/src/components/TopNavigation.vue
Normal file
292
schoolNewsWeb/src/components/TopNavigation.vue
Normal file
@@ -0,0 +1,292 @@
|
||||
<template>
|
||||
<nav class="top-navigation">
|
||||
<div class="nav-container">
|
||||
<!-- Logo区域 -->
|
||||
<div class="nav-logo">
|
||||
<img src="@/assets/logo.png" alt="Logo" />
|
||||
<span class="logo-text">校园新闻</span>
|
||||
</div>
|
||||
|
||||
<!-- 导航菜单 -->
|
||||
<div class="nav-menu">
|
||||
<div
|
||||
v-for="menu in navigationMenus"
|
||||
:key="menu.menuID"
|
||||
class="nav-item"
|
||||
:class="{ active: isActive(menu) }"
|
||||
@mouseenter="handleMouseEnter(menu)"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<div class="nav-link" @click="handleNavClick(menu)">
|
||||
<i v-if="menu.icon" :class="menu.icon" class="nav-icon"></i>
|
||||
<span>{{ menu.name }}</span>
|
||||
<i v-if="hasNavigationChildren(menu)" class="arrow-down">▼</i>
|
||||
</div>
|
||||
|
||||
<!-- 下拉菜单 -->
|
||||
<div
|
||||
v-if="hasNavigationChildren(menu)"
|
||||
class="dropdown-menu"
|
||||
:class="{ show: activeDropdown === menu.menuID }"
|
||||
>
|
||||
<div
|
||||
v-for="child in getNavigationChildren(menu)"
|
||||
:key="child.menuID"
|
||||
class="dropdown-item"
|
||||
:class="{ active: isActive(child) }"
|
||||
@click="handleNavClick(child)"
|
||||
>
|
||||
<i v-if="child.icon" :class="child.icon" class="dropdown-icon"></i>
|
||||
<span>{{ child.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧用户区域 -->
|
||||
<div class="nav-right">
|
||||
<UserDropdown :user="userInfo" @logout="handleLogout" />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import type { SysMenu } from '@/types';
|
||||
import { MenuType } from '@/types/enums';
|
||||
// @ts-ignore - Vue 3.5 组件导入兼容性
|
||||
import UserDropdown from './UserDropdown.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
|
||||
const activeDropdown = ref<string | null>(null);
|
||||
|
||||
// 获取所有菜单
|
||||
const allMenus = computed(() => store.getters['auth/menuTree']);
|
||||
const userInfo = computed(() => store.getters['auth/userInfo']);
|
||||
|
||||
// 获取第一层的导航菜单(MenuType.NAVIGATION)
|
||||
const navigationMenus = computed(() => {
|
||||
return allMenus.value.filter((menu: SysMenu) => menu.type === MenuType.NAVIGATION);
|
||||
});
|
||||
|
||||
// 检查菜单是否有导航类型的子菜单
|
||||
function hasNavigationChildren(menu: SysMenu): boolean {
|
||||
return !!(menu.children && menu.children.some(child => child.type === MenuType.NAVIGATION));
|
||||
}
|
||||
|
||||
// 获取导航类型的子菜单
|
||||
function getNavigationChildren(menu: SysMenu): SysMenu[] {
|
||||
if (!menu.children) return [];
|
||||
return menu.children.filter(child => child.type === MenuType.NAVIGATION);
|
||||
}
|
||||
|
||||
// 判断菜单是否激活
|
||||
function isActive(menu: SysMenu): boolean {
|
||||
if (!menu.url) return false;
|
||||
|
||||
// 精确匹配
|
||||
if (route.path === menu.url) return true;
|
||||
|
||||
// 检查是否是子路由
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
return isMenuOrChildActive(menu);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 递归检查菜单或其子菜单是否激活
|
||||
function isMenuOrChildActive(menu: SysMenu): boolean {
|
||||
if (route.path === menu.url) return true;
|
||||
|
||||
if (menu.children) {
|
||||
return menu.children.some(child => isMenuOrChildActive(child));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 处理鼠标进入
|
||||
function handleMouseEnter(menu: SysMenu) {
|
||||
if (hasNavigationChildren(menu)) {
|
||||
activeDropdown.value = menu.menuID || null;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理鼠标离开
|
||||
function handleMouseLeave() {
|
||||
activeDropdown.value = null;
|
||||
}
|
||||
|
||||
// 处理导航点击
|
||||
function handleNavClick(menu: SysMenu) {
|
||||
activeDropdown.value = null;
|
||||
|
||||
if (menu.url) {
|
||||
router.push(menu.url);
|
||||
} else if (menu.children && menu.children.length > 0) {
|
||||
// 如果没有url但有子菜单,跳转到第一个子菜单
|
||||
const firstChild = menu.children.find(child => child.url);
|
||||
if (firstChild?.url) {
|
||||
router.push(firstChild.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理登出
|
||||
function handleLogout() {
|
||||
store.dispatch('auth/logout');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.top-navigation {
|
||||
height: 64px;
|
||||
background: #001529;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
max-width: 1920px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 48px;
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
.nav-link {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 20px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.arrow-down {
|
||||
font-size: 10px;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
&:hover .arrow-down {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-10px);
|
||||
transition: all 0.3s;
|
||||
z-index: 1001;
|
||||
|
||||
&.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
<template>
|
||||
<div class="user-dropdown" @click="toggleDropdown" v-click-outside="closeDropdown">
|
||||
<!-- 用户头像和信息 -->
|
||||
<div class="user-info">
|
||||
<!-- 未登录状态 -->
|
||||
<div class="login-info" v-if="!isLoggedIn">
|
||||
<div class="login-text">
|
||||
<i class="login-icon">👤</i>
|
||||
<span v-if="!collapsed">登录/注册</span>
|
||||
</div>
|
||||
<i class="dropdown-icon" :class="{ 'open': dropdownVisible }"></i>
|
||||
</div>
|
||||
|
||||
<!-- 已登录状态 -->
|
||||
<div class="user-info" v-else>
|
||||
<div class="user-avatar">
|
||||
<img :src="userAvatar" :alt="user?.username" v-if="userAvatar">
|
||||
<span class="avatar-placeholder" v-else>{{ avatarText }}</span>
|
||||
@@ -16,6 +25,20 @@
|
||||
<!-- 下拉菜单 -->
|
||||
<transition name="dropdown">
|
||||
<div class="dropdown-menu" v-if="dropdownVisible">
|
||||
<!-- 未登录时的菜单 -->
|
||||
<template v-if="!isLoggedIn">
|
||||
<div class="dropdown-item" @click="goToLogin">
|
||||
<i class="item-icon icon-login"></i>
|
||||
<span>登录</span>
|
||||
</div>
|
||||
<div class="dropdown-item" @click="goToRegister">
|
||||
<i class="item-icon icon-register"></i>
|
||||
<span>注册</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 已登录时的菜单 -->
|
||||
<template v-else>
|
||||
<div class="dropdown-item" @click="goToProfile">
|
||||
<i class="item-icon icon-profile"></i>
|
||||
<span>个人资料</span>
|
||||
@@ -29,6 +52,7 @@
|
||||
<i class="item-icon icon-logout"></i>
|
||||
<span>退出登录</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
@@ -37,6 +61,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import type { UserVO } from '@/types';
|
||||
|
||||
// Props
|
||||
@@ -57,8 +82,13 @@ const dropdownVisible = ref(false);
|
||||
|
||||
// Composition API
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
// 计算属性
|
||||
const isLoggedIn = computed(() => {
|
||||
return store.getters['auth/isAuthenticated'];
|
||||
});
|
||||
|
||||
const userAvatar = computed(() => {
|
||||
return props.user?.avatar || '';
|
||||
});
|
||||
@@ -82,6 +112,18 @@ function closeDropdown() {
|
||||
dropdownVisible.value = false;
|
||||
}
|
||||
|
||||
// 未登录时的操作
|
||||
function goToLogin() {
|
||||
closeDropdown();
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
function goToRegister() {
|
||||
closeDropdown();
|
||||
router.push('/register');
|
||||
}
|
||||
|
||||
// 已登录时的操作
|
||||
function goToProfile() {
|
||||
closeDropdown();
|
||||
router.push('/profile');
|
||||
@@ -116,11 +158,13 @@ const vClickOutside = {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-dropdown {
|
||||
color: #ffffff;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.login-info,
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -130,6 +174,17 @@ const vClickOutside = {
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
color: #000000;
|
||||
}
|
||||
}
|
||||
|
||||
.login-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.login-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +312,8 @@ const vClickOutside = {
|
||||
}
|
||||
|
||||
/* 图标字体类 */
|
||||
.icon-login::before { content: "🔑"; }
|
||||
.icon-register::before { content: "📝"; }
|
||||
.icon-profile::before { content: "👤"; }
|
||||
.icon-settings::before { content: "⚙️"; }
|
||||
.icon-logout::before { content: "🚪"; }
|
||||
|
||||
12
schoolNewsWeb/src/env.d.ts
vendored
12
schoolNewsWeb/src/env.d.ts
vendored
@@ -1,5 +1,17 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// Vite 环境变量类型定义
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_APP_TITLE: string
|
||||
readonly VITE_APP_MODE?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
// 兼容旧的 process.env 写法(如果代码中有使用)
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
<template>
|
||||
<div class="basic-layout">
|
||||
<!-- 顶部导航栏 -->
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<button class="sidebar-toggle" @click="toggleSidebar">
|
||||
<i class="icon-menu"></i>
|
||||
</button>
|
||||
|
||||
<!-- 面包屑导航 -->
|
||||
<Breadcrumb :items="breadcrumbItems" />
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<!-- 用户信息 -->
|
||||
<UserDropdown :user="userInfo" @logout="handleLogout" />
|
||||
</div>
|
||||
</header>
|
||||
<!-- 主内容区域 -->
|
||||
<div class="main-wrapper">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="sidebar" :class="{ 'collapsed': sidebarCollapsed }">
|
||||
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed }">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<img src="@/assets/logo.png" alt="Logo" v-if="!sidebarCollapsed">
|
||||
<img src="@/assets/logo.png" alt="Logo" v-if="!sidebarCollapsed" />
|
||||
<span class="logo-text" v-if="!sidebarCollapsed">校园新闻</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -18,32 +36,6 @@
|
||||
/>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div class="main-wrapper">
|
||||
<!-- 顶部导航栏 -->
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<button
|
||||
class="sidebar-toggle"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<i class="icon-menu"></i>
|
||||
</button>
|
||||
|
||||
<!-- 面包屑导航 -->
|
||||
<Breadcrumb :items="breadcrumbItems" />
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<!-- 用户信息 -->
|
||||
<UserDropdown
|
||||
:user="userInfo"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 页面内容 -->
|
||||
<main class="content">
|
||||
<router-view />
|
||||
@@ -60,17 +52,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import type { SysMenu } from '@/types';
|
||||
import { getMenuPath } from '@/utils/route-generator';
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useStore } from "vuex";
|
||||
import type { SysMenu } from "@/types";
|
||||
import { getMenuPath } from "@/utils/route-generator";
|
||||
// @ts-ignore - Vue 3.5 defineOptions支持
|
||||
import MenuNav from '@/components/MenuNav.vue';
|
||||
import MenuNav from "@/components/MenuNav.vue";
|
||||
// @ts-ignore - Vue 3.5 组件导入兼容性
|
||||
import Breadcrumb from '@/components/Breadcrumb.vue';
|
||||
import Breadcrumb from "@/components/Breadcrumb.vue";
|
||||
// @ts-ignore - Vue 3.5 组件导入兼容性
|
||||
import UserDropdown from '@/components/UserDropdown.vue';
|
||||
import UserDropdown from "@/components/UserDropdown.vue";
|
||||
|
||||
// 响应式状态
|
||||
const sidebarCollapsed = ref(false);
|
||||
@@ -81,16 +73,16 @@ const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
// 计算属性
|
||||
const menuTree = computed(() => store.getters['auth/menuTree']);
|
||||
const userInfo = computed(() => store.getters['auth/userInfo']);
|
||||
const menuTree = computed(() => store.getters["auth/menuTree"]);
|
||||
const userInfo = computed(() => store.getters["auth/userInfo"]);
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
if (!route.meta?.menuId) return [];
|
||||
|
||||
const menuPath = getMenuPath(menuTree.value, route.meta.menuId as string);
|
||||
return menuPath.map(menu => ({
|
||||
title: menu.name || '',
|
||||
path: menu.url || ''
|
||||
return menuPath.map((menu) => ({
|
||||
title: menu.name || "",
|
||||
path: menu.url || "",
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -98,7 +90,7 @@ const breadcrumbItems = computed(() => {
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('sidebarCollapsed', String(sidebarCollapsed.value));
|
||||
localStorage.setItem("sidebarCollapsed", String(sidebarCollapsed.value));
|
||||
}
|
||||
|
||||
function handleMenuClick(menu: SysMenu) {
|
||||
@@ -108,19 +100,22 @@ function handleMenuClick(menu: SysMenu) {
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
store.dispatch('auth/logout');
|
||||
store.dispatch("auth/logout");
|
||||
}
|
||||
|
||||
// 监听路由变化,自动展开对应菜单
|
||||
watch(() => route.path, () => {
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
// 可以在这里实现菜单自动展开逻辑
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// 组件挂载时恢复侧边栏状态
|
||||
onMounted(() => {
|
||||
const savedState = localStorage.getItem('sidebarCollapsed');
|
||||
const savedState = localStorage.getItem("sidebarCollapsed");
|
||||
if (savedState !== null) {
|
||||
sidebarCollapsed.value = savedState === 'true';
|
||||
sidebarCollapsed.value = savedState === "true";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
252
schoolNewsWeb/src/layouts/NavigationLayout.vue
Normal file
252
schoolNewsWeb/src/layouts/NavigationLayout.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<div class="navigation-layout">
|
||||
<!-- 顶部导航栏 -->
|
||||
<TopNavigation />
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div class="layout-content">
|
||||
<!-- 面包屑 -->
|
||||
<div class="breadcrumb-wrapper" v-if="breadcrumbItems.length > 0">
|
||||
<Breadcrumb :items="breadcrumbItems" />
|
||||
</div>
|
||||
|
||||
<!-- 侧边栏和内容 -->
|
||||
<div class="content-wrapper" v-if="hasSidebarMenus">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed }">
|
||||
<div class="sidebar-toggle-btn" @click="toggleSidebar">
|
||||
<i class="toggle-icon">{{ sidebarCollapsed ? '▶' : '◀' }}</i>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<MenuNav
|
||||
:menus="sidebarMenus"
|
||||
:collapsed="sidebarCollapsed"
|
||||
@menu-click="handleMenuClick"
|
||||
/>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 页面内容 -->
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 没有侧边栏时直接显示内容 -->
|
||||
<div class="content-wrapper-full" v-else>
|
||||
<main class="main-content-full">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import type { SysMenu } from '@/types';
|
||||
import { MenuType } from '@/types/enums';
|
||||
import { getMenuPath } from '@/utils/route-generator';
|
||||
// @ts-ignore - Vue 3.5 组件导入兼容性
|
||||
import TopNavigation from '@/components/TopNavigation.vue';
|
||||
// @ts-ignore - Vue 3.5 组件导入兼容性
|
||||
import MenuNav from '@/components/MenuNav.vue';
|
||||
// @ts-ignore - Vue 3.5 组件导入兼容性
|
||||
import Breadcrumb from '@/components/Breadcrumb.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const sidebarCollapsed = ref(false);
|
||||
|
||||
// 获取所有菜单
|
||||
const allMenus = computed(() => store.getters['auth/menuTree']);
|
||||
|
||||
// 获取当前激活的顶层导航菜单
|
||||
const activeTopMenu = computed(() => {
|
||||
const path = route.path;
|
||||
|
||||
// 找到匹配的顶层菜单
|
||||
for (const menu of allMenus.value) {
|
||||
if (menu.type === MenuType.NAVIGATION) {
|
||||
if (isPathUnderMenu(path, menu)) {
|
||||
return menu;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
// 获取当前页面的侧边栏菜单(SIDEBAR类型的子菜单)
|
||||
const sidebarMenus = computed(() => {
|
||||
if (!activeTopMenu.value || !activeTopMenu.value.children) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 返回SIDEBAR类型的子菜单
|
||||
return activeTopMenu.value.children.filter((child: SysMenu) => child.type === MenuType.SIDEBAR);
|
||||
});
|
||||
|
||||
// 是否有侧边栏菜单
|
||||
const hasSidebarMenus = computed(() => sidebarMenus.value.length > 0);
|
||||
|
||||
// 面包屑数据
|
||||
const breadcrumbItems = computed(() => {
|
||||
if (!route.meta?.menuId) return [];
|
||||
|
||||
const menuPath = getMenuPath(allMenus.value, route.meta.menuId as string);
|
||||
return menuPath.map((menu) => ({
|
||||
title: menu.name || '',
|
||||
path: menu.url || '',
|
||||
}));
|
||||
});
|
||||
|
||||
// 判断路径是否在菜单下
|
||||
function isPathUnderMenu(path: string, menu: SysMenu): boolean {
|
||||
if (menu.url === path) return true;
|
||||
|
||||
if (menu.children) {
|
||||
for (const child of menu.children) {
|
||||
if (isPathUnderMenu(path, child)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 切换侧边栏
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
localStorage.setItem('sidebarCollapsed', String(sidebarCollapsed.value));
|
||||
}
|
||||
|
||||
// 处理菜单点击
|
||||
function handleMenuClick(menu: SysMenu) {
|
||||
if (menu.url && menu.url !== route.path) {
|
||||
router.push(menu.url);
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复侧边栏状态
|
||||
const savedState = localStorage.getItem('sidebarCollapsed');
|
||||
if (savedState !== null) {
|
||||
sidebarCollapsed.value = savedState === 'true';
|
||||
}
|
||||
|
||||
// 监听路由变化
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
// 路由变化时可以做一些处理
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.navigation-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.breadcrumb-wrapper {
|
||||
background: white;
|
||||
padding: 16px 24px;
|
||||
margin: 16px 16px 0 16px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
margin: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
transition: width 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
&.collapsed {
|
||||
width: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-toggle-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -12px;
|
||||
width: 24px;
|
||||
height: 48px;
|
||||
background: white;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 0 12px 12px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content-wrapper-full {
|
||||
flex: 1;
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.main-content-full {
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
padding: 24px;
|
||||
min-height: calc(100vh - 64px - 48px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,20 +8,48 @@ import store from "./store";
|
||||
import { setupRouterGuards, setupTokenRefresh } from "@/utils/permission";
|
||||
import { setupPermissionDirectives } from "@/directives/permission";
|
||||
|
||||
const app = createApp(App);
|
||||
// 初始化应用
|
||||
async function initApp() {
|
||||
const app = createApp(App);
|
||||
|
||||
// 使用插件
|
||||
app.use(ElementPlus);
|
||||
app.use(store);
|
||||
app.use(router);
|
||||
// 使用插件
|
||||
app.use(ElementPlus);
|
||||
app.use(store);
|
||||
|
||||
// 设置权限指令
|
||||
setupPermissionDirectives(app, store);
|
||||
// 在路由初始化前,尝试恢复登录状态并生成动态路由
|
||||
const authState = (store.state as any).auth;
|
||||
console.log('[应用初始化] 检查登录状态...');
|
||||
console.log('[应用初始化] Token:', !!authState.token);
|
||||
console.log('[应用初始化] 菜单数量:', authState.menus?.length || 0);
|
||||
|
||||
// 设置路由守卫
|
||||
setupRouterGuards(router, store);
|
||||
if (authState.token && authState.menus && authState.menus.length > 0) {
|
||||
try {
|
||||
console.log('[应用初始化] 开始生成动态路由...');
|
||||
await store.dispatch('auth/generateRoutes');
|
||||
console.log('[应用初始化] 动态路由生成成功');
|
||||
} catch (error) {
|
||||
console.error('[应用初始化] 动态路由生成失败:', error);
|
||||
}
|
||||
} else {
|
||||
console.log('[应用初始化] 无需生成动态路由(未登录或无菜单)');
|
||||
}
|
||||
|
||||
// 设置Token自动刷新
|
||||
setupTokenRefresh(store);
|
||||
app.use(router);
|
||||
|
||||
app.mount("#app");
|
||||
// 设置权限指令
|
||||
setupPermissionDirectives(app, store);
|
||||
|
||||
// 设置路由守卫
|
||||
setupRouterGuards(router, store);
|
||||
|
||||
// 设置Token自动刷新
|
||||
setupTokenRefresh(store);
|
||||
|
||||
app.mount("#app");
|
||||
console.log('[应用初始化] 应用挂载完成');
|
||||
}
|
||||
|
||||
// 启动应用
|
||||
initApp().catch(error => {
|
||||
console.error('[应用初始化] 应用启动失败:', error);
|
||||
});
|
||||
|
||||
@@ -3,87 +3,125 @@ import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
|
||||
/**
|
||||
* 基础路由配置(无需权限)
|
||||
*/
|
||||
const routes: Array<RouteRecordRaw> = [
|
||||
export const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: "/",
|
||||
redirect: "/dashboard",
|
||||
redirect: "/home",
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
component: () => import("@/layouts/BlankLayout.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "Login",
|
||||
component: () => import("@/views/login/Login.vue"),
|
||||
meta: {
|
||||
title: "登录",
|
||||
requiresAuth: false,
|
||||
menuType: 3,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/register",
|
||||
component: () => import("@/layouts/BlankLayout.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "Register",
|
||||
component: () => import("@/views/login/Register.vue"),
|
||||
meta: {
|
||||
title: "注册",
|
||||
requiresAuth: false,
|
||||
menuType: 3,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/forgot-password",
|
||||
component: () => import("@/layouts/BlankLayout.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "ForgotPassword",
|
||||
component: () => import("@/views/login/ForgotPassword.vue"),
|
||||
meta: {
|
||||
title: "忘记密码",
|
||||
requiresAuth: false,
|
||||
menuType: 3,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
// 主应用布局(需要权限,动态路由会添加到这里)
|
||||
// 首页(显示在导航栏)
|
||||
{
|
||||
path: "/dashboard",
|
||||
name: "Dashboard",
|
||||
component: () => import("@/layouts/BasicLayout.vue"),
|
||||
redirect: "/dashboard/workplace",
|
||||
meta: {
|
||||
title: "工作台",
|
||||
requiresAuth: true,
|
||||
},
|
||||
path: "/home",
|
||||
component: () => import("@/layouts/NavigationLayout.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "workplace",
|
||||
name: "DashboardWorkplace",
|
||||
component: () => import("@/views/dashboard/Workplace.vue"),
|
||||
path: "",
|
||||
name: "Home",
|
||||
component: () => import("@/views/Home.vue"),
|
||||
meta: {
|
||||
title: "工作台",
|
||||
requiresAuth: true,
|
||||
},
|
||||
title: "首页",
|
||||
requiresAuth: false,
|
||||
menuType: 1, // NAVIGATION 类型,显示在顶部导航栏
|
||||
orderNum: -1, // 排在动态路由之前
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
// 错误页面
|
||||
{
|
||||
path: "/403",
|
||||
component: () => import("@/layouts/BlankLayout.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "Forbidden",
|
||||
component: () => import("@/views/error/403.vue"),
|
||||
meta: {
|
||||
title: "403 - 无权限访问",
|
||||
requiresAuth: false,
|
||||
menuType: 3,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/404",
|
||||
component: () => import("@/layouts/BlankLayout.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "NotFound",
|
||||
component: () => import("@/views/error/404.vue"),
|
||||
meta: {
|
||||
title: "404 - 页面不存在",
|
||||
requiresAuth: false,
|
||||
menuType: 3,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/500",
|
||||
component: () => import("@/layouts/BlankLayout.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "ServerError",
|
||||
component: () => import("@/views/error/500.vue"),
|
||||
meta: {
|
||||
title: "500 - 服务器错误",
|
||||
requiresAuth: false,
|
||||
menuType: 3,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
// 捕获所有未匹配的路由
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Module } from 'vuex';
|
||||
import { LoginDomain, SysMenu, SysPermission } from '@/types';
|
||||
import { authApi } from '@/apis/auth';
|
||||
import router from '@/router';
|
||||
import { getFirstAccessibleMenuUrl, buildMenuTree } from '@/utils/route-generator';
|
||||
|
||||
// State接口定义
|
||||
export interface AuthState {
|
||||
@@ -23,17 +24,48 @@ export interface AuthState {
|
||||
routesLoaded: boolean;
|
||||
}
|
||||
|
||||
// 从localStorage恢复状态的辅助函数
|
||||
function getStoredState(): Partial<AuthState> {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const loginDomainStr = localStorage.getItem('loginDomain');
|
||||
const menusStr = localStorage.getItem('menus');
|
||||
const permissionsStr = localStorage.getItem('permissions');
|
||||
|
||||
return {
|
||||
token: token || null,
|
||||
loginDomain: loginDomainStr ? JSON.parse(loginDomainStr) : null,
|
||||
menus: menusStr ? JSON.parse(menusStr) : [],
|
||||
permissions: permissionsStr ? JSON.parse(permissionsStr) : [],
|
||||
routesLoaded: false, // 路由始终需要重新加载
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('从localStorage恢复状态失败:', error);
|
||||
return {
|
||||
token: null,
|
||||
loginDomain: null,
|
||||
menus: [],
|
||||
permissions: [],
|
||||
routesLoaded: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 认证模块
|
||||
const authModule: Module<AuthState, any> = {
|
||||
namespaced: true,
|
||||
|
||||
state: (): AuthState => ({
|
||||
loginDomain: null,
|
||||
token: localStorage.getItem('token') || null,
|
||||
menus: [],
|
||||
permissions: [],
|
||||
state: (): AuthState => {
|
||||
// 从localStorage恢复状态
|
||||
const storedState = getStoredState();
|
||||
return {
|
||||
loginDomain: storedState.loginDomain || null,
|
||||
token: storedState.token || null,
|
||||
menus: storedState.menus || [],
|
||||
permissions: storedState.permissions || [],
|
||||
routesLoaded: false,
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
getters: {
|
||||
// 是否已登录
|
||||
@@ -85,10 +117,19 @@ const authModule: Module<AuthState, any> = {
|
||||
state.menus = loginDomain.menus || [];
|
||||
state.permissions = loginDomain.permissions || [];
|
||||
|
||||
// 存储token到localStorage
|
||||
// 持久化到localStorage
|
||||
if (state.token) {
|
||||
localStorage.setItem('token', state.token);
|
||||
}
|
||||
if (loginDomain) {
|
||||
localStorage.setItem('loginDomain', JSON.stringify(loginDomain));
|
||||
}
|
||||
if (state.menus.length > 0) {
|
||||
localStorage.setItem('menus', JSON.stringify(state.menus));
|
||||
}
|
||||
if (state.permissions.length > 0) {
|
||||
localStorage.setItem('permissions', JSON.stringify(state.permissions));
|
||||
}
|
||||
},
|
||||
|
||||
// 设置Token
|
||||
@@ -104,11 +145,17 @@ const authModule: Module<AuthState, any> = {
|
||||
// 设置菜单
|
||||
SET_MENUS(state, menus: SysMenu[]) {
|
||||
state.menus = menus;
|
||||
if (menus.length > 0) {
|
||||
localStorage.setItem('menus', JSON.stringify(menus));
|
||||
}
|
||||
},
|
||||
|
||||
// 设置权限
|
||||
SET_PERMISSIONS(state, permissions: SysPermission[]) {
|
||||
state.permissions = permissions;
|
||||
if (permissions.length > 0) {
|
||||
localStorage.setItem('permissions', JSON.stringify(permissions));
|
||||
}
|
||||
},
|
||||
|
||||
// 设置路由加载状态
|
||||
@@ -123,13 +170,18 @@ const authModule: Module<AuthState, any> = {
|
||||
state.menus = [];
|
||||
state.permissions = [];
|
||||
state.routesLoaded = false;
|
||||
|
||||
// 清除localStorage
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('loginDomain');
|
||||
localStorage.removeItem('menus');
|
||||
localStorage.removeItem('permissions');
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
// 登录
|
||||
async login({ commit, dispatch }, loginParam) {
|
||||
async login({ commit, dispatch, state }, loginParam) {
|
||||
try {
|
||||
const loginDomain = await authApi.login(loginParam);
|
||||
|
||||
@@ -138,8 +190,14 @@ const authModule: Module<AuthState, any> = {
|
||||
|
||||
// 生成动态路由
|
||||
await dispatch('generateRoutes');
|
||||
console.log(router.getRoutes())
|
||||
// 获取第一个可访问的菜单URL,用于登录后跳转
|
||||
const firstMenuUrl = getFirstAccessibleMenuUrl(state.menus);
|
||||
|
||||
return Promise.resolve(loginDomain);
|
||||
return Promise.resolve({
|
||||
loginDomain,
|
||||
redirectUrl: firstMenuUrl || '/home' // 如果没有菜单,跳转到首页
|
||||
});
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
@@ -163,11 +221,49 @@ const authModule: Module<AuthState, any> = {
|
||||
}
|
||||
},
|
||||
|
||||
// 恢复登录状态(页面刷新时使用)
|
||||
async restoreAuth({ state, commit, dispatch }) {
|
||||
try {
|
||||
// 如果没有token,无法恢复
|
||||
if (!state.token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果已经有完整的登录信息,直接生成路由
|
||||
if (state.loginDomain && state.menus.length > 0) {
|
||||
console.log('从localStorage恢复登录状态');
|
||||
await dispatch('generateRoutes');
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果只有token,需要从后端重新获取用户信息
|
||||
// console.log('Token存在,重新获取用户信息');
|
||||
// const loginDomain = await authApi.getUserInfo(); // 需要后端提供这个接口
|
||||
|
||||
// commit('SET_LOGIN_DOMAIN', loginDomain);
|
||||
// await dispatch('generateRoutes');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('恢复登录状态失败:', error);
|
||||
// 恢复失败,清除认证信息
|
||||
commit('CLEAR_AUTH');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// 生成动态路由
|
||||
async generateRoutes({ state, commit }) {
|
||||
try {
|
||||
// 如果路由已经加载,避免重复生成
|
||||
if (state.routesLoaded) {
|
||||
console.log('路由已加载,跳过生成');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.menus || state.menus.length === 0) {
|
||||
console.warn('用户菜单为空,无法生成路由');
|
||||
commit('SET_ROUTES_LOADED', true); // 标记为已加载,避免重复尝试
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -206,56 +302,6 @@ const authModule: Module<AuthState, any> = {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 构建菜单树结构
|
||||
* @param menus 菜单列表
|
||||
* @returns 菜单树
|
||||
*/
|
||||
function buildMenuTree(menus: SysMenu[]): SysMenu[] {
|
||||
if (!menus || menus.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const menuMap = new Map<string, SysMenu>();
|
||||
const rootMenus: SysMenu[] = [];
|
||||
|
||||
// 创建菜单映射
|
||||
menus.forEach(menu => {
|
||||
if (menu.menuID) {
|
||||
menuMap.set(menu.menuID, { ...menu, children: [] });
|
||||
}
|
||||
});
|
||||
|
||||
// 构建树结构
|
||||
menus.forEach(menu => {
|
||||
const menuNode = menuMap.get(menu.menuID!);
|
||||
if (!menuNode) return;
|
||||
|
||||
if (!menu.parentID || menu.parentID === '0') {
|
||||
// 根菜单
|
||||
rootMenus.push(menuNode);
|
||||
} else {
|
||||
// 子菜单
|
||||
const parent = menuMap.get(menu.parentID);
|
||||
if (parent) {
|
||||
parent.children = parent.children || [];
|
||||
parent.children.push(menuNode);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 按orderNum排序
|
||||
const sortMenus = (menus: SysMenu[]): SysMenu[] => {
|
||||
return menus
|
||||
.sort((a, b) => (a.orderNum || 0) - (b.orderNum || 0))
|
||||
.map(menu => ({
|
||||
...menu,
|
||||
children: menu.children ? sortMenus(menu.children) : []
|
||||
}));
|
||||
};
|
||||
|
||||
return sortMenus(rootMenus);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置路由
|
||||
|
||||
@@ -50,12 +50,14 @@ export enum Gender {
|
||||
* 菜单类型枚举
|
||||
*/
|
||||
export enum MenuType {
|
||||
/** 目录 */
|
||||
DIRECTORY = 0,
|
||||
/** 菜单 */
|
||||
MENU = 1,
|
||||
/** 侧边栏 */
|
||||
SIDEBAR = 0,
|
||||
/** 导航栏 */
|
||||
NAVIGATION = 1,
|
||||
/** 按钮 */
|
||||
BUTTON = 2
|
||||
BUTTON = 2,
|
||||
/** 独立页面(不显示在菜单中,如首页、个人中心等) */
|
||||
PAGE = 3
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface SysMenu extends BaseDTO {
|
||||
icon?: string;
|
||||
/** 菜单顺序 */
|
||||
orderNum?: number;
|
||||
/** 菜单类型 0-目录 1-菜单 2-按钮 */
|
||||
/** 菜单类型 0-侧边栏 1-导航栏 2-按钮 */
|
||||
type?: MenuType;
|
||||
/** 创建人 */
|
||||
creator?: string;
|
||||
|
||||
@@ -15,6 +15,7 @@ const WHITE_LIST = [
|
||||
'/login',
|
||||
'/register',
|
||||
'/forgot-password',
|
||||
'/home',
|
||||
'/404',
|
||||
'/403',
|
||||
'/500'
|
||||
@@ -68,7 +69,7 @@ async function handleRouteGuard(
|
||||
store: Store<any>
|
||||
) {
|
||||
const authState: AuthState = store.state.auth;
|
||||
const { isAuthenticated } = store.getters['auth/isAuthenticated'];
|
||||
const isAuthenticated = store.getters['auth/isAuthenticated'];
|
||||
|
||||
// 检查是否在白名单中
|
||||
if (isInWhiteList(to.path)) {
|
||||
@@ -87,15 +88,19 @@ async function handleRouteGuard(
|
||||
}
|
||||
|
||||
// 用户已登录,检查是否需要生成动态路由
|
||||
if (!authState.routesLoaded) {
|
||||
// 注意:通常情况下路由应该在 main.ts 初始化时就已经生成
|
||||
// 这里主要处理登录后首次生成路由的情况
|
||||
if (!authState.routesLoaded && authState.menus && authState.menus.length > 0) {
|
||||
try {
|
||||
console.log('[路由守卫] 路由未加载,开始生成动态路由');
|
||||
// 生成动态路由
|
||||
await store.dispatch('auth/generateRoutes');
|
||||
|
||||
console.log('[路由守卫] 动态路由生成成功,重新导航');
|
||||
// 重新导航到目标路由
|
||||
return next({ ...to, replace: true });
|
||||
} catch (error) {
|
||||
console.error('生成动态路由失败:', error);
|
||||
console.error('[路由守卫] 生成动态路由失败:', error);
|
||||
// 清除认证信息并跳转到登录页
|
||||
store.commit('auth/CLEAR_AUTH');
|
||||
return next('/login');
|
||||
|
||||
@@ -7,13 +7,16 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
import type { SysMenu } from '@/types';
|
||||
import { MenuType } from '@/types/enums';
|
||||
import { routes } from '@/router';
|
||||
|
||||
/**
|
||||
* 布局组件映射
|
||||
*/
|
||||
const LAYOUT_MAP: Record<string, () => Promise<any>> = {
|
||||
// 基础布局
|
||||
// 基础布局(旧版,带侧边栏)
|
||||
'BasicLayout': () => import('@/layouts/BasicLayout.vue'),
|
||||
// 导航布局(新版,顶部导航+动态侧边栏)
|
||||
'NavigationLayout': () => import('@/layouts/NavigationLayout.vue'),
|
||||
// 空白布局
|
||||
'BlankLayout': () => import('@/layouts/BlankLayout.vue'),
|
||||
// 页面布局
|
||||
@@ -49,14 +52,20 @@ export function generateRoutes(menus: SysMenu[]): RouteRecordRaw[] {
|
||||
/**
|
||||
* 根据单个菜单生成路由
|
||||
* @param menu 菜单对象
|
||||
* @param isTopLevel 是否是顶层菜单
|
||||
* @returns 路由配置
|
||||
*/
|
||||
function generateRouteFromMenu(menu: SysMenu): RouteRecordRaw | null {
|
||||
// 只处理目录和菜单类型,忽略按钮类型
|
||||
function generateRouteFromMenu(menu: SysMenu, isTopLevel = true): RouteRecordRaw | null {
|
||||
// 跳过按钮类型
|
||||
if (menu.type === MenuType.BUTTON) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 跳过静态路由(已经在 router 中定义,不需要再次添加)
|
||||
if (menu.component === '__STATIC_ROUTE__') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const route: any = {
|
||||
path: menu.url || `/${menu.menuID}`,
|
||||
name: menu.menuID,
|
||||
@@ -72,37 +81,71 @@ function generateRouteFromMenu(menu: SysMenu): RouteRecordRaw | null {
|
||||
}
|
||||
};
|
||||
|
||||
// 根据菜单类型处理组件
|
||||
if (menu.type === MenuType.DIRECTORY) {
|
||||
// 目录类型 - 使用布局组件
|
||||
// 如果有子菜单,使用布局组件
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
// 如果是顶层的NAVIGATION类型菜单,使用NavigationLayout
|
||||
if (isTopLevel && menu.type === MenuType.NAVIGATION) {
|
||||
route.component = getComponent(menu.component || 'NavigationLayout');
|
||||
} else if (menu.type === MenuType.SIDEBAR) {
|
||||
// SIDEBAR类型的菜单使用BlankLayout,避免嵌套布局
|
||||
// BlankLayout 只是一个纯容器,不会添加额外的导航栏或面包屑
|
||||
route.component = getComponent(menu.component || 'BlankLayout');
|
||||
} else {
|
||||
// 其他情况使用BasicLayout
|
||||
route.component = getComponent(menu.component || 'BasicLayout');
|
||||
}
|
||||
} else {
|
||||
// 没有子菜单,使用具体的页面组件
|
||||
if (menu.component) {
|
||||
route.component = getComponent(menu.component);
|
||||
} else {
|
||||
// 如果没有指定组件,使用BlankLayout作为默认
|
||||
route.component = getComponent('BlankLayout');
|
||||
}
|
||||
}
|
||||
|
||||
// 处理子菜单
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
route.children = [];
|
||||
menu.children.forEach(child => {
|
||||
const childRoute = generateRouteFromMenu(child);
|
||||
const childRoute = generateRouteFromMenu(child, false);
|
||||
if (childRoute) {
|
||||
route.children!.push(childRoute);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 如果是目录但没有子菜单,设置重定向
|
||||
route.redirect = route.path + '/index';
|
||||
}
|
||||
|
||||
} else if (menu.type === MenuType.MENU) {
|
||||
// 菜单类型 - 使用页面组件
|
||||
if (!menu.component) {
|
||||
console.warn(`菜单 ${menu.name} 缺少component字段`);
|
||||
return null;
|
||||
// 如果没有设置重定向,自动重定向到第一个有URL的子菜单
|
||||
if (!route.redirect && route.children.length > 0) {
|
||||
const firstChildWithUrl = findFirstMenuWithUrl(menu.children);
|
||||
if (firstChildWithUrl?.url) {
|
||||
route.redirect = firstChildWithUrl.url;
|
||||
}
|
||||
}
|
||||
route.component = getComponent(menu.component);
|
||||
}
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找第一个有URL的菜单
|
||||
* @param menus 菜单列表
|
||||
* @returns 第一个有URL的菜单
|
||||
*/
|
||||
function findFirstMenuWithUrl(menus: SysMenu[]): SysMenu | null {
|
||||
for (const menu of menus) {
|
||||
if (menu.type !== MenuType.BUTTON) {
|
||||
if (menu.url) {
|
||||
return menu;
|
||||
}
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const found = findFirstMenuWithUrl(menu.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据组件名称获取组件
|
||||
* @param componentName 组件名称/路径
|
||||
@@ -119,28 +162,38 @@ function getComponent(componentName: string) {
|
||||
|
||||
// 如果不是以@/开头的完整路径,则添加@/views/前缀
|
||||
if (!componentPath.startsWith('@/')) {
|
||||
// 如果不是以/开头,添加/
|
||||
// 确保路径以/开头
|
||||
if (!componentPath.startsWith('/')) {
|
||||
componentPath = '/' + componentPath;
|
||||
}
|
||||
// 添加@/views前缀
|
||||
componentPath = '@/views' + componentPath;
|
||||
}
|
||||
|
||||
// 将@/别名转换为相对路径,因为Vite动态导入可能无法正确解析别名
|
||||
if (componentPath.startsWith('@/')) {
|
||||
componentPath = componentPath.replace('@/', '../');
|
||||
}
|
||||
|
||||
// 如果没有.vue扩展名,添加它
|
||||
if (!componentPath.endsWith('.vue')) {
|
||||
componentPath += '.vue';
|
||||
}
|
||||
|
||||
// 动态导入组件
|
||||
return () => import(/* @vite-ignore */ componentPath).catch((error) => {
|
||||
return () => {
|
||||
try {
|
||||
// 使用动态导入,Vite 会自动处理路径解析
|
||||
return import(/* @vite-ignore */ componentPath);
|
||||
} catch (error) {
|
||||
console.warn(`组件加载失败: ${componentPath}`, error);
|
||||
// 返回404组件或空组件
|
||||
// 返回404组件
|
||||
return import('@/views/error/404.vue').catch(() =>
|
||||
Promise.resolve({
|
||||
template: `<div class="component-error">
|
||||
<h3>组件加载失败</h3>
|
||||
<p>无法加载组件: ${componentPath}</p>
|
||||
<p>错误: ${error.message}</p>
|
||||
<p>错误: ${error instanceof Error ? error.message : String(error)}</p>
|
||||
</div>`,
|
||||
style: `
|
||||
.component-error {
|
||||
@@ -153,7 +206,57 @@ function getComponent(componentName: string) {
|
||||
`
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将静态路由转换为菜单项
|
||||
* @param routes 静态路由数组
|
||||
* @returns 菜单项数组
|
||||
*/
|
||||
function convertRoutesToMenus(routes: RouteRecordRaw[]): SysMenu[] {
|
||||
const menus: SysMenu[] = [];
|
||||
|
||||
routes.forEach(route => {
|
||||
// 处理有子路由的情况(现在静态路由都有布局组件)
|
||||
if (route.children && route.children.length > 0) {
|
||||
route.children.forEach(child => {
|
||||
// 只处理有 meta.menuType 的子路由
|
||||
if (child.meta?.menuType !== undefined) {
|
||||
const menu: SysMenu = {
|
||||
menuID: child.name as string || child.path.replace(/\//g, '-'),
|
||||
parentID: '0',
|
||||
name: child.meta.title as string || child.name as string,
|
||||
url: route.path, // 使用父路由的路径
|
||||
type: child.meta.menuType as MenuType,
|
||||
orderNum: (child.meta.orderNum as number) || -1,
|
||||
// 标记为静态路由,避免重复生成路由
|
||||
component: '__STATIC_ROUTE__', // 特殊标记
|
||||
};
|
||||
|
||||
menus.push(menu);
|
||||
}
|
||||
});
|
||||
}
|
||||
// 处理没有子路由的情况(兼容性保留)
|
||||
else if (route.meta?.menuType !== undefined) {
|
||||
const menu: SysMenu = {
|
||||
menuID: route.name as string || route.path.replace(/\//g, '-'),
|
||||
parentID: '0',
|
||||
name: route.meta.title as string || route.name as string,
|
||||
url: route.path,
|
||||
type: route.meta.menuType as MenuType,
|
||||
orderNum: (route.meta.orderNum as number) || -1,
|
||||
// 标记为静态路由,避免重复生成路由
|
||||
component: '__STATIC_ROUTE__', // 特殊标记
|
||||
};
|
||||
|
||||
menus.push(menu);
|
||||
}
|
||||
});
|
||||
|
||||
return menus;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,8 +264,14 @@ function getComponent(componentName: string) {
|
||||
* @param menus 菜单列表
|
||||
* @returns 菜单树
|
||||
*/
|
||||
function buildMenuTree(menus: SysMenu[]): SysMenu[] {
|
||||
if (!menus || menus.length === 0) {
|
||||
export function buildMenuTree(menus: SysMenu[]): SysMenu[] {
|
||||
// 将静态路由转换为菜单项
|
||||
const staticMenus = convertRoutesToMenus(routes);
|
||||
|
||||
// 合并动态菜单和静态菜单
|
||||
const allMenus = [...staticMenus, ...menus];
|
||||
|
||||
if (allMenus.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -170,14 +279,14 @@ function buildMenuTree(menus: SysMenu[]): SysMenu[] {
|
||||
const rootMenus: SysMenu[] = [];
|
||||
|
||||
// 创建菜单映射
|
||||
menus.forEach(menu => {
|
||||
allMenus.forEach(menu => {
|
||||
if (menu.menuID) {
|
||||
menuMap.set(menu.menuID, { ...menu, children: [] });
|
||||
}
|
||||
});
|
||||
|
||||
// 构建树结构
|
||||
menus.forEach(menu => {
|
||||
allMenus.forEach(menu => {
|
||||
const menuNode = menuMap.get(menu.menuID!);
|
||||
if (!menuNode) return;
|
||||
|
||||
@@ -293,3 +402,16 @@ export function getMenuPath(menus: SysMenu[], targetMenuId: string): SysMenu[] {
|
||||
findPath(menus);
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取第一个可访问的菜单URL(用于登录后跳转)
|
||||
* @param menus 菜单树
|
||||
* @returns 第一个可访问的菜单URL,如果没有则返回 null
|
||||
*/
|
||||
export function getFirstAccessibleMenuUrl(menus: SysMenu[]): string | null {
|
||||
if (!menus || menus.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return "/home";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
|
||||
<!-- 主横幅 -->
|
||||
<section class="hero-section">
|
||||
<div class="container">
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title">校园新闻管理系统</h1>
|
||||
<p class="hero-subtitle">及时发布、高效管理、便捷浏览</p>
|
||||
<div class="hero-actions">
|
||||
<el-button type="primary" size="large" @click="exploreNews">
|
||||
浏览新闻
|
||||
</el-button>
|
||||
<el-button size="large" @click="goToLogin" v-if="!isLoggedIn">
|
||||
开始使用
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 功能特性 -->
|
||||
<section class="features-section">
|
||||
<div class="container">
|
||||
<h2 class="section-title">核心功能</h2>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📰</div>
|
||||
<h3>新闻发布</h3>
|
||||
<p>快速发布校园新闻,支持富文本编辑,图文并茂</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔐</div>
|
||||
<h3>权限管理</h3>
|
||||
<p>细粒度权限控制,安全可靠的用户管理体系</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📊</div>
|
||||
<h3>数据统计</h3>
|
||||
<p>实时统计新闻浏览量,数据可视化展示</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">💬</div>
|
||||
<h3>评论互动</h3>
|
||||
<p>支持新闻评论,增强师生互动交流</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔍</div>
|
||||
<h3>智能搜索</h3>
|
||||
<p>全文搜索,快速定位所需新闻内容</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📱</div>
|
||||
<h3>响应式设计</h3>
|
||||
<p>完美适配各种设备,随时随地浏览</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 最新新闻 -->
|
||||
<section class="news-section">
|
||||
<div class="container">
|
||||
<h2 class="section-title">最新新闻</h2>
|
||||
<div class="news-grid">
|
||||
<div class="news-card" v-for="item in latestNews" :key="item.id">
|
||||
<div class="news-image">
|
||||
<img :src="item.image" :alt="item.title" />
|
||||
</div>
|
||||
<div class="news-content">
|
||||
<span class="news-category">{{ item.category }}</span>
|
||||
<h3 class="news-title">{{ item.title }}</h3>
|
||||
<p class="news-excerpt">{{ item.excerpt }}</p>
|
||||
<div class="news-meta">
|
||||
<span class="news-date">{{ item.date }}</span>
|
||||
<span class="news-views">👁️ {{ item.views }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="news-more">
|
||||
<el-button @click="exploreNews">查看更多新闻</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 页脚 -->
|
||||
<footer class="home-footer">
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
<div class="footer-section">
|
||||
<h4>关于我们</h4>
|
||||
<p>校园新闻管理系统致力于为学校提供高效、便捷的新闻发布和管理平台。</p>
|
||||
</div>
|
||||
<div class="footer-section">
|
||||
<h4>快速链接</h4>
|
||||
<ul>
|
||||
<li><a href="#home">首页</a></li>
|
||||
<li><a href="#news">新闻中心</a></li>
|
||||
<li><a href="#about">关于我们</a></li>
|
||||
<li><a href="#contact">联系我们</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-section">
|
||||
<h4>联系方式</h4>
|
||||
<ul>
|
||||
<li>📧 Email: info@school-news.com</li>
|
||||
<li>📞 电话: 123-456-7890</li>
|
||||
<li>📍 地址: XX市XX区XX路XX号</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<p>© 2025 校园新闻管理系统. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
// 计算属性
|
||||
const isLoggedIn = computed(() => store.getters['auth/isAuthenticated']);
|
||||
const userName = computed(() => {
|
||||
const userInfo = store.getters['auth/userInfo'];
|
||||
return userInfo?.userName || userInfo?.nickName || '用户';
|
||||
});
|
||||
|
||||
// 最新新闻数据(示例)
|
||||
const latestNews = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: '我校在全国大学生创新创业大赛中获得金奖',
|
||||
excerpt: '在刚刚结束的第十届全国大学生创新创业大赛中,我校代表队凭借优异的表现,荣获金奖...',
|
||||
category: '校园动态',
|
||||
image: 'https://picsum.photos/400/250?random=1',
|
||||
date: '2025-10-08',
|
||||
views: 1523
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '校园文化艺术节圆满落幕',
|
||||
excerpt: '历时一周的校园文化艺术节于昨日圆满落幕,本次艺术节共举办了20余场精彩活动...',
|
||||
category: '文化活动',
|
||||
image: 'https://picsum.photos/400/250?random=2',
|
||||
date: '2025-10-07',
|
||||
views: 2341
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '学校图书馆新增电子资源数据库',
|
||||
excerpt: '为了更好地服务师生科研和学习,学校图书馆新增了多个电子资源数据库...',
|
||||
category: '通知公告',
|
||||
image: 'https://picsum.photos/400/250?random=3',
|
||||
date: '2025-10-06',
|
||||
views: 987
|
||||
}
|
||||
]);
|
||||
|
||||
// 方法
|
||||
function goToLogin() {
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
function goToRegister() {
|
||||
router.push('/register');
|
||||
}
|
||||
|
||||
function goToDashboard() {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await store.dispatch('auth/logout');
|
||||
ElMessage.success('已退出登录');
|
||||
} catch (error) {
|
||||
console.error('退出失败:', error);
|
||||
ElMessage.error('退出失败');
|
||||
}
|
||||
}
|
||||
|
||||
function exploreNews() {
|
||||
// TODO: 跳转到新闻列表页
|
||||
ElMessage.info('新闻列表功能开发中...');
|
||||
}
|
||||
|
||||
// 页面加载
|
||||
onMounted(() => {
|
||||
// 可以在这里加载真实的新闻数据
|
||||
console.log('Home page mounted');
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
/* 主横幅 */
|
||||
.hero-section {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 100px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 24px;
|
||||
margin: 0 0 40px 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 功能特性 */
|
||||
.features-section {
|
||||
padding: 80px 0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 60px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
margin: 0 0 12px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 最新新闻 */
|
||||
.news-section {
|
||||
padding: 80px 0;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.news-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 32px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.news-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.news-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
&:hover img {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.news-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.news-category {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.news-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 12px 0;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.news-excerpt {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 16px 0;
|
||||
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.news-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.news-more {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 页脚 */
|
||||
.home-footer {
|
||||
background: #001529;
|
||||
color: white;
|
||||
padding: 60px 0 20px 0;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 40px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.footer-section {
|
||||
h4 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 20px 0;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
p {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
margin-bottom: 12px;
|
||||
|
||||
a {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
text-align: center;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.nav-menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.features-grid,
|
||||
.news-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -141,12 +141,12 @@ const handleLogin = async () => {
|
||||
loginLoading.value = true;
|
||||
|
||||
// 调用store中的登录action
|
||||
await store.dispatch('auth/login', loginForm);
|
||||
const result = await store.dispatch('auth/login', loginForm);
|
||||
|
||||
ElMessage.success('登录成功!');
|
||||
|
||||
// 获取重定向路径
|
||||
const redirectPath = (route.query.redirect as string) || '/dashboard';
|
||||
// 优先使用 query 中的 redirect,其次使用返回的 redirectUrl,最后使用默认首页
|
||||
const redirectPath = (route.query.redirect as string) || result.redirectUrl || '/home';
|
||||
router.push(redirectPath);
|
||||
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
部门管理
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
@@ -11,9 +11,9 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"useDefineForClassFields": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": "",
|
||||
"baseUrl": ".",
|
||||
"types": [
|
||||
"webpack-env"
|
||||
"vite/client"
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
|
||||
84
schoolNewsWeb/vite.config.js
Normal file
84
schoolNewsWeb/vite.config.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
workbox: {
|
||||
cleanupOutdatedCaches: true,
|
||||
skipWaiting: true,
|
||||
clientsClaim: true
|
||||
}
|
||||
})
|
||||
],
|
||||
|
||||
// 基础路径
|
||||
base: '/schoolNewsWeb/',
|
||||
|
||||
// 输出目录
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'static',
|
||||
sourcemap: false,
|
||||
chunkSizeWarningLimit: 1500,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vue-vendor': ['vue', 'vue-router', 'vuex'],
|
||||
'element-plus': ['element-plus']
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 路径别名
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
},
|
||||
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue']
|
||||
},
|
||||
|
||||
// 环境变量
|
||||
define: {
|
||||
'process.env.BASE_URL': JSON.stringify('/schoolNewsWeb/'),
|
||||
'process.env.VITE_API_BASE_URL': JSON.stringify('/api'),
|
||||
'process.env.VITE_APP_TITLE': JSON.stringify('校园新闻管理系统')
|
||||
},
|
||||
|
||||
// 开发服务器配置
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 8080,
|
||||
open: true,
|
||||
|
||||
// 代理配置
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8081/schoolNewsServ',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
configure: (proxy, options) => {
|
||||
proxy.on('proxyReq', (proxyReq, req, res) => {
|
||||
console.log('代理请求:', req.method, req.url, '->', proxyReq.path);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// CSS 配置
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
additionalData: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { defineConfig } from '@vue/cli-service'
|
||||
|
||||
export default defineConfig({
|
||||
transpileDependencies: true,
|
||||
publicPath: '/schoolNewsWeb/',
|
||||
outputDir: 'dist',
|
||||
assetsDir: 'static',
|
||||
|
||||
chainWebpack: (config) => {
|
||||
// 设置环境变量
|
||||
config.plugin('define').tap((definitions) => {
|
||||
Object.assign(definitions[0], {
|
||||
'process.env.BASE_URL': JSON.stringify('/schoolNewsWeb/'),
|
||||
'process.env.VITE_API_BASE_URL': JSON.stringify('/api'),
|
||||
'process.env.VITE_APP_TITLE': JSON.stringify('校园新闻管理系统'),
|
||||
})
|
||||
return definitions
|
||||
})
|
||||
|
||||
// 设置静态资源路径
|
||||
config.output.set('publicPath', '/schoolNewsWeb/')
|
||||
},
|
||||
|
||||
devServer: {
|
||||
host: '0.0.0.0',
|
||||
port: 8080,
|
||||
open: true,
|
||||
hot: true,
|
||||
historyApiFallback: {
|
||||
rewrites: [
|
||||
{ from: /^\/schoolNewsWeb/, to: '/schoolNewsWeb/index.html' }
|
||||
]
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8081/schoolNewsServ',
|
||||
changeOrigin: true,
|
||||
pathRewrite: { '^/api': '' },
|
||||
logLevel: 'debug',
|
||||
onProxyReq: (proxyReq, req, res) => {
|
||||
console.log('代理请求:', req.method, req.url, '->', proxyReq.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,127 +0,0 @@
|
||||
# 校园新闻管理系统 - 权限控制系统使用说明
|
||||
|
||||
## 系统概述
|
||||
|
||||
该系统实现了基于角色和权限的动态路由和组件加载功能,根据用户登录后返回的菜单权限信息自动生成路由配置。
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 1. 动态路由生成
|
||||
- 根据菜单表中的 `component` 字段自动加载对应的Vue组件
|
||||
- 支持目录和菜单两种类型的路由
|
||||
- 自动构建菜单树结构
|
||||
|
||||
### 2. 权限控制
|
||||
- 路由级权限控制
|
||||
- 组件级权限控制
|
||||
- 指令级权限控制
|
||||
|
||||
### 3. 用户认证
|
||||
- JWT Token管理
|
||||
- 自动刷新Token
|
||||
- 登录状态维护
|
||||
|
||||
## 菜单表component字段说明
|
||||
|
||||
在菜单表中,`component` 字段用于指定路由对应的组件:
|
||||
|
||||
### 布局组件
|
||||
- `BasicLayout` - 基础布局(侧边栏+顶部导航)
|
||||
- `BlankLayout` - 空白布局(仅显示内容)
|
||||
- `PageLayout` - 页面布局(带页面头部)
|
||||
|
||||
### 页面组件
|
||||
- 可以使用相对路径:`dashboard/Workplace`
|
||||
- 可以使用绝对路径:`@/views/dashboard/Workplace.vue`
|
||||
- 系统会自动添加 `@/views/` 前缀和 `.vue` 扩展名
|
||||
|
||||
## 权限指令使用
|
||||
|
||||
### v-permission 指令
|
||||
```vue
|
||||
<!-- 单个权限 -->
|
||||
<el-button v-permission="'user:create'">新增用户</el-button>
|
||||
|
||||
<!-- 多个权限(任意一个) -->
|
||||
<el-button v-permission="['user:create', 'user:edit']">操作</el-button>
|
||||
|
||||
<!-- 多个权限(必须全部拥有) -->
|
||||
<el-button v-permission.all="['user:create', 'user:edit']">操作</el-button>
|
||||
```
|
||||
|
||||
### v-role 指令
|
||||
```vue
|
||||
<!-- 单个角色 -->
|
||||
<div v-role="'admin'">管理员内容</div>
|
||||
|
||||
<!-- 多个角色 -->
|
||||
<div v-role="['admin', 'moderator']">管理内容</div>
|
||||
```
|
||||
|
||||
## Composition API 使用
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { usePermission } from '@/directives/permission';
|
||||
|
||||
const { hasPermission, hasAnyPermission, hasRole } = usePermission();
|
||||
|
||||
// 检查权限
|
||||
if (hasPermission('user:create')) {
|
||||
// 有权限的逻辑
|
||||
}
|
||||
|
||||
// 检查角色
|
||||
if (hasRole('admin')) {
|
||||
// 管理员逻辑
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 菜单配置示例
|
||||
|
||||
```sql
|
||||
-- 目录类型菜单
|
||||
INSERT INTO tb_sys_menu (menuID, parentID, name, url, component, type, orderNum)
|
||||
VALUES ('system', '0', '系统管理', '/system', 'BasicLayout', 0, 1);
|
||||
|
||||
-- 菜单类型
|
||||
INSERT INTO tb_sys_menu (menuID, parentID, name, url, component, type, orderNum)
|
||||
VALUES ('system-user', 'system', '用户管理', '/system/user', 'system/User', 1, 1);
|
||||
```
|
||||
|
||||
## 登录流程
|
||||
|
||||
1. 用户登录成功后,系统获取 `LoginDomain` 对象
|
||||
2. 从 `LoginDomain` 中提取菜单权限信息
|
||||
3. 根据菜单信息动态生成路由配置
|
||||
4. 将生成的路由添加到Vue Router中
|
||||
5. 用户可以访问有权限的页面
|
||||
|
||||
## 路由守卫
|
||||
|
||||
系统自动设置了路由守卫:
|
||||
- 检查用户登录状态
|
||||
- 验证页面访问权限
|
||||
- 自动重定向到登录页或错误页
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 404页面:路由不存在
|
||||
- 403页面:无权限访问
|
||||
- 500页面:服务器错误
|
||||
- 组件加载失败时显示错误信息
|
||||
|
||||
## 开发建议
|
||||
|
||||
1. 菜单表中的 `component` 字段应该对应实际存在的组件文件
|
||||
2. 权限控制粒度可以到按钮级别
|
||||
3. 合理使用布局组件来保持UI一致性
|
||||
4. 定期检查和更新权限配置
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 确保菜单表中的URL路径与实际路由匹配
|
||||
- 组件文件名应该与 `component` 字段对应
|
||||
- 权限代码应该与后端保持一致
|
||||
- 测试时注意清除本地存储的Token
|
||||
Reference in New Issue
Block a user