菜单布局等初步完成

This commit is contained in:
2025-10-08 14:11:54 +08:00
parent d9ea2e842b
commit 4bc587ecf5
29 changed files with 4472 additions and 11977 deletions

View File

@@ -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');

View 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)
- ✅ 初始版本发布
- ✅ 实现三层导航架构
- ✅ 支持动态路由生成
- ✅ 支持状态持久化
- ✅ 完整的文档和示例
---
## 许可证
请根据项目实际情况添加许可证信息。
---
**祝使用愉快!如有问题,请参考文档或联系开发团队。**

View 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
}
]
}
]

View File

@@ -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
View 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>

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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;
a {
font-weight: bold;
color: #2c3e50;
&.router-link-exact-active {
color: #42b983;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
}
</style>

View File

@@ -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);
}
}

View 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>

View File

@@ -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: "🚪"; }

View File

@@ -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 {

View File

@@ -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>

View 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>

View File

@@ -8,11 +8,32 @@ import store from "./store";
import { setupRouterGuards, setupTokenRefresh } from "@/utils/permission";
import { setupPermissionDirectives } from "@/directives/permission";
// 初始化应用
async function initApp() {
const app = createApp(App);
// 使用插件
app.use(ElementPlus);
app.use(store);
// 在路由初始化前,尝试恢复登录状态并生成动态路由
const authState = (store.state as any).auth;
console.log('[应用初始化] 检查登录状态...');
console.log('[应用初始化] Token:', !!authState.token);
console.log('[应用初始化] 菜单数量:', authState.menus?.length || 0);
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('[应用初始化] 无需生成动态路由(未登录或无菜单)');
}
app.use(router);
// 设置权限指令
@@ -25,3 +46,10 @@ setupRouterGuards(router, store);
setupTokenRefresh(store);
app.mount("#app");
console.log('[应用初始化] 应用挂载完成');
}
// 启动应用
initApp().catch(error => {
console.error('[应用初始化] 应用启动失败:', error);
});

View File

@@ -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,
},
}
],
},
// 捕获所有未匹配的路由
{

View File

@@ -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);
}
/**
* 重置路由

View File

@@ -50,12 +50,14 @@ export enum Gender {
* 菜单类型枚举
*/
export enum MenuType {
/** 目录 */
DIRECTORY = 0,
/** 菜单 */
MENU = 1,
/** 侧边栏 */
SIDEBAR = 0,
/** 导航栏 */
NAVIGATION = 1,
/** 按钮 */
BUTTON = 2
BUTTON = 2,
/** 独立页面(不显示在菜单中,如首页、个人中心等) */
PAGE = 3
}
/**

View File

@@ -27,7 +27,7 @@ export interface SysMenu extends BaseDTO {
icon?: string;
/** 菜单顺序 */
orderNum?: number;
/** 菜单类型 0-目录 1-菜单 2-按钮 */
/** 菜单类型 0-侧边栏 1-导航栏 2-按钮 */
type?: MenuType;
/** 创建人 */
creator?: string;

View File

@@ -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');

View File

@@ -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,16 +206,72 @@ 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;
}
/**
* 构建菜单树结构
* @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";
}

View File

@@ -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>

View File

@@ -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) {

View File

@@ -0,0 +1,13 @@
<template>
<div>
部门管理
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
</style>

View File

@@ -11,9 +11,9 @@
"forceConsistentCasingInFileNames": true,
"useDefineForClassFields": true,
"sourceMap": true,
"baseUrl": "",
"baseUrl": ".",
"types": [
"webpack-env"
"vite/client"
],
"paths": {
"@/*": [

View 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: ''
}
}
}
})

View File

@@ -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

View File

@@ -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