前端启动成功
This commit is contained in:
@@ -139,6 +139,7 @@ CREATE TABLE `tb_sys_menu` (
|
|||||||
`name` VARCHAR(100) NOT NULL COMMENT '菜单名称',
|
`name` VARCHAR(100) NOT NULL COMMENT '菜单名称',
|
||||||
`parent_id` VARCHAR(50) DEFAULT NULL COMMENT '父菜单ID',
|
`parent_id` VARCHAR(50) DEFAULT NULL COMMENT '父菜单ID',
|
||||||
`url` VARCHAR(255) DEFAULT NULL COMMENT '菜单URL',
|
`url` VARCHAR(255) DEFAULT NULL COMMENT '菜单URL',
|
||||||
|
`component` VARCHAR(255) DEFAULT NULL COMMENT '菜单组件',
|
||||||
`icon` VARCHAR(100) DEFAULT NULL COMMENT '菜单图标',
|
`icon` VARCHAR(100) DEFAULT NULL COMMENT '菜单图标',
|
||||||
`order_num` INT(4) DEFAULT 0 COMMENT '菜单排序号',
|
`order_num` INT(4) DEFAULT 0 COMMENT '菜单排序号',
|
||||||
`type` INT(4) DEFAULT 0 COMMENT '菜单类型(0目录 1菜单 2按钮)',
|
`type` INT(4) DEFAULT 0 COMMENT '菜单类型(0目录 1菜单 2按钮)',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8081
|
||||||
servlet:
|
servlet:
|
||||||
context-path: /schoolNewsServ
|
context-path: /schoolNewsServ
|
||||||
encoding:
|
encoding:
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ public class TbSysMenu extends BaseDTO {
|
|||||||
*/
|
*/
|
||||||
private String url;
|
private String url;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 菜单组件
|
||||||
|
* @author yslg
|
||||||
|
* @since 2024-06
|
||||||
|
*/
|
||||||
|
private String component;
|
||||||
/**
|
/**
|
||||||
* @description 菜单图标
|
* @description 菜单图标
|
||||||
* @author yslg
|
* @author yslg
|
||||||
@@ -121,6 +127,14 @@ public class TbSysMenu extends BaseDTO {
|
|||||||
this.url = url;
|
this.url = url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getComponent() {
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setComponent(String component) {
|
||||||
|
this.component = component;
|
||||||
|
}
|
||||||
|
|
||||||
public String getIcon() {
|
public String getIcon() {
|
||||||
return icon;
|
return icon;
|
||||||
}
|
}
|
||||||
@@ -170,6 +184,7 @@ public class TbSysMenu extends BaseDTO {
|
|||||||
", name='" + name + '\'' +
|
", name='" + name + '\'' +
|
||||||
", description='" + description + '\'' +
|
", description='" + description + '\'' +
|
||||||
", url='" + url + '\'' +
|
", url='" + url + '\'' +
|
||||||
|
", component='" + component + '\'' +
|
||||||
", icon='" + icon + '\'' +
|
", icon='" + icon + '\'' +
|
||||||
", orderNum=" + orderNum +
|
", orderNum=" + orderNum +
|
||||||
", type=" + type +
|
", type=" + type +
|
||||||
|
|||||||
43
schoolNewsWeb/.eslintrc.cjs
Normal file
43
schoolNewsWeb/.eslintrc.cjs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
node: true,
|
||||||
|
browser: true,
|
||||||
|
es2021: true,
|
||||||
|
},
|
||||||
|
extends: [
|
||||||
|
"plugin:vue/vue3-essential",
|
||||||
|
"eslint:recommended",
|
||||||
|
"@vue/typescript/recommended",
|
||||||
|
],
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
sourceType: "module",
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
// Vue 3 编译宏
|
||||||
|
defineProps: "readonly",
|
||||||
|
defineEmits: "readonly",
|
||||||
|
defineExpose: "readonly",
|
||||||
|
withDefaults: "readonly",
|
||||||
|
defineOptions: "readonly",
|
||||||
|
// 其他全局变量
|
||||||
|
process: "readonly",
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||||
|
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||||
|
// 允许单词组件名(如404、500等错误页面组件)
|
||||||
|
"vue/multi-word-component-names": "off",
|
||||||
|
// 允许未使用的变量(开发阶段常见)
|
||||||
|
"@typescript-eslint/no-unused-vars": "warn",
|
||||||
|
// 允许any类型(减少严格限制)
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
// 允许非空断言(!)
|
||||||
|
"@typescript-eslint/no-non-null-assertion": "off",
|
||||||
|
// 允许@ts-ignore注释
|
||||||
|
"@typescript-eslint/ban-ts-comment": "off",
|
||||||
|
// 允许未使用的导入
|
||||||
|
"@typescript-eslint/no-unused-imports": "off",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
env: {
|
|
||||||
node: true,
|
|
||||||
},
|
|
||||||
extends: [
|
|
||||||
"plugin:vue/vue3-essential",
|
|
||||||
"eslint:recommended",
|
|
||||||
"@vue/typescript/recommended",
|
|
||||||
],
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
|
||||||
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
3
schoolNewsWeb/babel.config.cjs
Normal file
3
schoolNewsWeb/babel.config.cjs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: ["@vue/cli-plugin-babel/preset"],
|
||||||
|
};
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { ConfigAPI, TransformOptions } from "@babel/core";
|
|
||||||
|
|
||||||
export default function (api: ConfigAPI): TransformOptions {
|
|
||||||
return {
|
|
||||||
presets: ["@vue/cli-plugin-babel/preset"],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
14476
schoolNewsWeb/package-lock.json
generated
Normal file
14476
schoolNewsWeb/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,9 @@
|
|||||||
"core-js": "^3.8.3",
|
"core-js": "^3.8.3",
|
||||||
"element-plus": "^2.11.4",
|
"element-plus": "^2.11.4",
|
||||||
"register-service-worker": "^1.7.2",
|
"register-service-worker": "^1.7.2",
|
||||||
"vue": "^3.2.13",
|
"vue": "^3.5.22",
|
||||||
"vue-router": "^4.0.3",
|
"vue-router": "^4.5.1",
|
||||||
"vuex": "^4.0.0"
|
"vuex": "^4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "^5.4.0",
|
"@typescript-eslint/eslint-plugin": "^5.4.0",
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import axios, { AxiosRequestConfig, AxiosResponse, AxiosError } from "axios";
|
import axios, { AxiosResponse, AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||||
import { ElLoading, ElMessage } from "element-plus";
|
import { ElLoading, ElMessage } from "element-plus";
|
||||||
import type { ResultDomain } from "@/types";
|
import type { ResultDomain } from "@/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扩展AxiosRequestConfig以支持自定义配置
|
* 扩展AxiosRequestConfig以支持自定义配置
|
||||||
*/
|
*/
|
||||||
interface CustomAxiosRequestConfig extends AxiosRequestConfig {
|
interface CustomAxiosRequestConfig extends Partial<InternalAxiosRequestConfig> {
|
||||||
/** 是否显示加载动画 */
|
/** 是否显示加载动画 */
|
||||||
showLoading?: boolean;
|
showLoading?: boolean;
|
||||||
/** 是否显示错误提示 */
|
/** 是否显示错误提示 */
|
||||||
@@ -43,7 +43,7 @@ export const TokenManager = {
|
|||||||
* 创建axios实例
|
* 创建axios实例
|
||||||
*/
|
*/
|
||||||
const request = axios.create({
|
const request = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL || "/api",
|
baseURL: process.env.VITE_API_BASE_URL || "/api",
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json;charset=UTF-8',
|
'Content-Type': 'application/json;charset=UTF-8',
|
||||||
@@ -56,9 +56,11 @@ let loadingInstance: ReturnType<typeof ElLoading.service> | null = null;
|
|||||||
* 请求拦截器
|
* 请求拦截器
|
||||||
*/
|
*/
|
||||||
request.interceptors.request.use(
|
request.interceptors.request.use(
|
||||||
(config: CustomAxiosRequestConfig) => {
|
(config: InternalAxiosRequestConfig) => {
|
||||||
|
const customConfig = config as CustomAxiosRequestConfig;
|
||||||
|
|
||||||
// 显示加载动画
|
// 显示加载动画
|
||||||
if (config.showLoading !== false) {
|
if (customConfig.showLoading !== false) {
|
||||||
loadingInstance = ElLoading.service({
|
loadingInstance = ElLoading.service({
|
||||||
lock: true,
|
lock: true,
|
||||||
text: "加载中...",
|
text: "加载中...",
|
||||||
|
|||||||
92
schoolNewsWeb/src/components/Breadcrumb.vue
Normal file
92
schoolNewsWeb/src/components/Breadcrumb.vue
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<template>
|
||||||
|
<div class="breadcrumb" v-if="items && items.length > 0">
|
||||||
|
<span v-for="(item, index) in items" :key="index" class="breadcrumb-wrapper">
|
||||||
|
<span class="breadcrumb-item">
|
||||||
|
<!-- 最后一个项目不可点击 -->
|
||||||
|
<template v-if="index === items.length - 1">
|
||||||
|
<span class="breadcrumb-text current">{{ item.title }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 其他项目可点击 -->
|
||||||
|
<template v-else>
|
||||||
|
<router-link
|
||||||
|
:to="item.path"
|
||||||
|
class="breadcrumb-link"
|
||||||
|
v-if="item.path"
|
||||||
|
>
|
||||||
|
{{ item.title }}
|
||||||
|
</router-link>
|
||||||
|
<span class="breadcrumb-text" v-else>{{ item.title }}</span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- 分隔符 -->
|
||||||
|
<span class="breadcrumb-separator" v-if="index < items.length - 1">
|
||||||
|
<i class="separator-icon"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// 面包屑项目接口
|
||||||
|
interface BreadcrumbItem {
|
||||||
|
title: string;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Props
|
||||||
|
interface Props {
|
||||||
|
items: BreadcrumbItem[];
|
||||||
|
separator?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
separator: '/'
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link {
|
||||||
|
color: #1890ff;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #40a9ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-text {
|
||||||
|
color: inherit;
|
||||||
|
|
||||||
|
&.current {
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-separator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 8px;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator-icon::before {
|
||||||
|
content: "/";
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="hello">
|
|
||||||
<h1>{{ msg }}</h1>
|
|
||||||
<p>
|
|
||||||
For a guide and recipes on how to configure / customize this project,<br />
|
|
||||||
check out the
|
|
||||||
<a href="https://cli.vuejs.org" target="_blank" rel="noopener"
|
|
||||||
>vue-cli documentation</a
|
|
||||||
>.
|
|
||||||
</p>
|
|
||||||
<h3>Installed CLI Plugins</h3>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>babel</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-pwa"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>pwa</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-router"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>router</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-vuex"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>vuex</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>eslint</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-typescript"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>typescript</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Essential Links</h3>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://forum.vuejs.org" target="_blank" rel="noopener"
|
|
||||||
>Forum</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener"
|
|
||||||
>Community Chat</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://twitter.com/vuejs" target="_blank" rel="noopener"
|
|
||||||
>Twitter</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Ecosystem</h3>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a href="https://router.vuejs.org" target="_blank" rel="noopener"
|
|
||||||
>vue-router</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://github.com/vuejs/vue-devtools#vue-devtools"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>vue-devtools</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener"
|
|
||||||
>vue-loader</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://github.com/vuejs/awesome-vue"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>awesome-vue</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { defineComponent } from "vue";
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
name: "HelloWorld",
|
|
||||||
props: {
|
|
||||||
msg: String,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
|
||||||
<style scoped lang="scss">
|
|
||||||
h3 {
|
|
||||||
margin: 40px 0 0;
|
|
||||||
}
|
|
||||||
ul {
|
|
||||||
list-style-type: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
li {
|
|
||||||
display: inline-block;
|
|
||||||
margin: 0 10px;
|
|
||||||
}
|
|
||||||
a {
|
|
||||||
color: #42b983;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
228
schoolNewsWeb/src/components/MenuItem.vue
Normal file
228
schoolNewsWeb/src/components/MenuItem.vue
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
<template>
|
||||||
|
<div class="menu-item">
|
||||||
|
<!-- 有子菜单的情况 -->
|
||||||
|
<template v-if="hasChildren">
|
||||||
|
<div
|
||||||
|
class="menu-item-content"
|
||||||
|
:class="{
|
||||||
|
'active': isActive,
|
||||||
|
'collapsed': collapsed
|
||||||
|
}"
|
||||||
|
@click="toggleExpanded"
|
||||||
|
>
|
||||||
|
<div class="menu-item-inner">
|
||||||
|
<i class="menu-icon" :class="menu.icon || 'icon-folder'"></i>
|
||||||
|
<span class="menu-title" v-if="!collapsed">{{ menu.name }}</span>
|
||||||
|
<i
|
||||||
|
class="expand-icon"
|
||||||
|
:class="{ 'expanded': expanded }"
|
||||||
|
v-if="!collapsed"
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 子菜单 -->
|
||||||
|
<transition name="submenu">
|
||||||
|
<div
|
||||||
|
class="submenu"
|
||||||
|
v-if="expanded && !collapsed"
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
v-for="child in menu.children"
|
||||||
|
:key="child.menuID"
|
||||||
|
:menu="child"
|
||||||
|
:collapsed="false"
|
||||||
|
:level="level + 1"
|
||||||
|
@menu-click="$emit('menu-click', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 没有子菜单的情况 -->
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
class="menu-item-content"
|
||||||
|
:class="{
|
||||||
|
'active': isActive,
|
||||||
|
'collapsed': collapsed
|
||||||
|
}"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
|
<div class="menu-item-inner">
|
||||||
|
<i class="menu-icon" :class="menu.icon || 'icon-file'"></i>
|
||||||
|
<span class="menu-title" v-if="!collapsed">{{ menu.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import type { SysMenu } from '@/types';
|
||||||
|
import { MenuType } from '@/types/enums';
|
||||||
|
|
||||||
|
// 递归组件需要声明名称(Vue 3.5+)
|
||||||
|
defineOptions({
|
||||||
|
name: 'MenuItem'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Props
|
||||||
|
interface Props {
|
||||||
|
menu: SysMenu;
|
||||||
|
collapsed?: boolean;
|
||||||
|
level?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
level: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'menu-click': [menu: SysMenu];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const expanded = ref(false);
|
||||||
|
|
||||||
|
// Composition API
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const hasChildren = computed(() => {
|
||||||
|
return props.menu.children && props.menu.children.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isActive = computed(() => {
|
||||||
|
// 检查当前路由是否匹配此菜单
|
||||||
|
return route.path === props.menu.url;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 方法 - 使用 function 声明
|
||||||
|
function toggleExpanded() {
|
||||||
|
if (hasChildren.value) {
|
||||||
|
expanded.value = !expanded.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
if (props.menu.type === MenuType.MENU && props.menu.url) {
|
||||||
|
emit('menu-click', props.menu);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.menu-item {
|
||||||
|
margin: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-content {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: #1890ff;
|
||||||
|
|
||||||
|
.menu-item-inner {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.collapsed {
|
||||||
|
margin: 4px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
.collapsed & {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
margin-right: 12px;
|
||||||
|
|
||||||
|
.collapsed & {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-title {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
margin-left: auto;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "▶";
|
||||||
|
}
|
||||||
|
|
||||||
|
&.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu {
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 4px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.menu-item-content {
|
||||||
|
margin: 0 4px;
|
||||||
|
|
||||||
|
.menu-item-inner {
|
||||||
|
padding-left: 48px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动画效果 */
|
||||||
|
.submenu-enter-active,
|
||||||
|
.submenu-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-enter-from,
|
||||||
|
.submenu-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图标字体类(简单实现,实际项目中使用图标库) */
|
||||||
|
.icon-folder::before { content: "📁"; }
|
||||||
|
.icon-file::before { content: "📄"; }
|
||||||
|
.icon-dashboard::before { content: "📊"; }
|
||||||
|
.icon-user::before { content: "👤"; }
|
||||||
|
.icon-news::before { content: "📰"; }
|
||||||
|
.icon-settings::before { content: "⚙️"; }
|
||||||
|
</style>
|
||||||
36
schoolNewsWeb/src/components/MenuNav.vue
Normal file
36
schoolNewsWeb/src/components/MenuNav.vue
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<div class="menu-nav">
|
||||||
|
<MenuItem
|
||||||
|
v-for="menu in menus"
|
||||||
|
:key="menu.menuID"
|
||||||
|
:menu="menu"
|
||||||
|
:collapsed="collapsed"
|
||||||
|
@menu-click="$emit('menu-click', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { SysMenu } from '@/types';
|
||||||
|
// @ts-ignore - Vue 3.5 defineOptions支持递归组件
|
||||||
|
import MenuItem from './MenuItem.vue';
|
||||||
|
|
||||||
|
// Props
|
||||||
|
interface Props {
|
||||||
|
menus: SysMenu[];
|
||||||
|
collapsed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>();
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
defineEmits<{
|
||||||
|
'menu-click': [menu: SysMenu];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.menu-nav {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
263
schoolNewsWeb/src/components/UserDropdown.vue
Normal file
263
schoolNewsWeb/src/components/UserDropdown.vue
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
<template>
|
||||||
|
<div class="user-dropdown" @click="toggleDropdown" v-click-outside="closeDropdown">
|
||||||
|
<!-- 用户头像和信息 -->
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-avatar">
|
||||||
|
<img :src="userAvatar" :alt="user?.username" v-if="userAvatar">
|
||||||
|
<span class="avatar-placeholder" v-else>{{ avatarText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="user-details" v-if="!collapsed">
|
||||||
|
<div class="user-name">{{ user?.realName || user?.username }}</div>
|
||||||
|
<div class="user-role">{{ primaryRole }}</div>
|
||||||
|
</div>
|
||||||
|
<i class="dropdown-icon" :class="{ 'open': dropdownVisible }"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 下拉菜单 -->
|
||||||
|
<transition name="dropdown">
|
||||||
|
<div class="dropdown-menu" v-if="dropdownVisible">
|
||||||
|
<div class="dropdown-item" @click="goToProfile">
|
||||||
|
<i class="item-icon icon-profile"></i>
|
||||||
|
<span>个人资料</span>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-item" @click="goToSettings">
|
||||||
|
<i class="item-icon icon-settings"></i>
|
||||||
|
<span>账户设置</span>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<div class="dropdown-item danger" @click="handleLogout">
|
||||||
|
<i class="item-icon icon-logout"></i>
|
||||||
|
<span>退出登录</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import type { UserVO } from '@/types';
|
||||||
|
|
||||||
|
// Props
|
||||||
|
interface Props {
|
||||||
|
user?: UserVO | null;
|
||||||
|
collapsed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
const emit = defineEmits<{
|
||||||
|
logout: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const dropdownVisible = ref(false);
|
||||||
|
|
||||||
|
// Composition API
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const userAvatar = computed(() => {
|
||||||
|
return props.user?.avatar || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const avatarText = computed(() => {
|
||||||
|
const name = props.user?.realName || props.user?.username || '';
|
||||||
|
return name.charAt(0).toUpperCase();
|
||||||
|
});
|
||||||
|
|
||||||
|
const primaryRole = computed(() => {
|
||||||
|
// 这里可以从store中获取用户角色信息
|
||||||
|
return '管理员'; // 暂时硬编码
|
||||||
|
});
|
||||||
|
|
||||||
|
// 方法 - 使用 function 声明
|
||||||
|
function toggleDropdown() {
|
||||||
|
dropdownVisible.value = !dropdownVisible.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
dropdownVisible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToProfile() {
|
||||||
|
closeDropdown();
|
||||||
|
router.push('/profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToSettings() {
|
||||||
|
closeDropdown();
|
||||||
|
router.push('/profile/settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
closeDropdown();
|
||||||
|
emit('logout');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击外部关闭指令
|
||||||
|
const vClickOutside = {
|
||||||
|
mounted(el: any, binding: any) {
|
||||||
|
el._clickOutside = (event: Event) => {
|
||||||
|
if (!(el === event.target || el.contains(event.target as Node))) {
|
||||||
|
binding.value();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('click', el._clickOutside);
|
||||||
|
},
|
||||||
|
unmounted(el: any) {
|
||||||
|
document.removeEventListener('click', el._clickOutside);
|
||||||
|
delete el._clickOutside;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.user-dropdown {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-right: 12px;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 2px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-icon {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "▼";
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
min-width: 160px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
color: #ff4d4f;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #fff2f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-icon {
|
||||||
|
width: 16px;
|
||||||
|
margin-right: 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-divider {
|
||||||
|
height: 1px;
|
||||||
|
background-color: #e8e8e8;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动画效果 */
|
||||||
|
.dropdown-enter-active,
|
||||||
|
.dropdown-leave-active {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-enter-from,
|
||||||
|
.dropdown-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图标字体类 */
|
||||||
|
.icon-profile::before { content: "👤"; }
|
||||||
|
.icon-settings::before { content: "⚙️"; }
|
||||||
|
.icon-logout::before { content: "🚪"; }
|
||||||
|
</style>
|
||||||
235
schoolNewsWeb/src/directives/permission.ts
Normal file
235
schoolNewsWeb/src/directives/permission.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* @description Vue权限指令
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-10-07
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { App, DirectiveBinding } from 'vue';
|
||||||
|
import type { Store } from 'vuex';
|
||||||
|
|
||||||
|
let store: Store<any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限指令实现
|
||||||
|
*/
|
||||||
|
const permission = {
|
||||||
|
/**
|
||||||
|
* 指令挂载时执行
|
||||||
|
*/
|
||||||
|
mounted(el: HTMLElement, binding: DirectiveBinding) {
|
||||||
|
const { value, modifiers } = binding;
|
||||||
|
|
||||||
|
if (!checkPermission(value, modifiers || {})) {
|
||||||
|
// 无权限时移除元素
|
||||||
|
removeElement(el);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 指令更新时执行
|
||||||
|
*/
|
||||||
|
updated(el: HTMLElement, binding: DirectiveBinding) {
|
||||||
|
const { value, modifiers } = binding;
|
||||||
|
|
||||||
|
if (!checkPermission(value, modifiers || {})) {
|
||||||
|
// 无权限时移除元素
|
||||||
|
removeElement(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色指令实现
|
||||||
|
*/
|
||||||
|
const role = {
|
||||||
|
/**
|
||||||
|
* 指令挂载时执行
|
||||||
|
*/
|
||||||
|
mounted(el: HTMLElement, binding: DirectiveBinding) {
|
||||||
|
const { value, modifiers } = binding;
|
||||||
|
|
||||||
|
if (!checkRole(value, modifiers || {})) {
|
||||||
|
// 无权限时移除元素
|
||||||
|
removeElement(el);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 指令更新时执行
|
||||||
|
*/
|
||||||
|
updated(el: HTMLElement, binding: DirectiveBinding) {
|
||||||
|
const { value, modifiers } = binding;
|
||||||
|
|
||||||
|
if (!checkRole(value, modifiers || {})) {
|
||||||
|
// 无权限时移除元素
|
||||||
|
removeElement(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查权限
|
||||||
|
* @param value 权限值(字符串或字符串数组)
|
||||||
|
* @param modifiers 修饰符
|
||||||
|
* @returns 是否有权限
|
||||||
|
*/
|
||||||
|
function checkPermission(value: string | string[], modifiers: Partial<Record<string, boolean>>): boolean {
|
||||||
|
if (!store) {
|
||||||
|
console.warn('Store未初始化,权限检查失败');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
console.warn('权限指令缺少权限值');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = Array.isArray(value) ? value : [value];
|
||||||
|
|
||||||
|
// 检查修饰符
|
||||||
|
if (modifiers?.all) {
|
||||||
|
// 需要所有权限
|
||||||
|
return store.getters['auth/hasAllPermissions'](permissions);
|
||||||
|
} else {
|
||||||
|
// 需要任意一个权限(默认行为)
|
||||||
|
return store.getters['auth/hasAnyPermission'](permissions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查角色
|
||||||
|
* @param value 角色值(字符串或字符串数组)
|
||||||
|
* @param modifiers 修饰符
|
||||||
|
* @returns 是否有角色
|
||||||
|
*/
|
||||||
|
function checkRole(value: string | string[], modifiers: Partial<Record<string, boolean>>): boolean {
|
||||||
|
if (!store) {
|
||||||
|
console.warn('Store未初始化,角色检查失败');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
console.warn('角色指令缺少角色值');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles = Array.isArray(value) ? value : [value];
|
||||||
|
const userRoles = store.getters['auth/userRoles'] || [];
|
||||||
|
|
||||||
|
// 检查修饰符
|
||||||
|
if (modifiers?.all) {
|
||||||
|
// 需要所有角色
|
||||||
|
return roles.every(role =>
|
||||||
|
userRoles.some((userRole: any) => userRole.code === role)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 需要任意一个角色(默认行为)
|
||||||
|
return roles.some(role =>
|
||||||
|
userRoles.some((userRole: any) => userRole.code === role)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除DOM元素
|
||||||
|
* @param el 要移除的元素
|
||||||
|
*/
|
||||||
|
function removeElement(el: HTMLElement) {
|
||||||
|
try {
|
||||||
|
if (el.parentNode) {
|
||||||
|
// 使用更安全的方式移除元素
|
||||||
|
el.style.display = 'none';
|
||||||
|
// 延迟移除,避免Range错误
|
||||||
|
setTimeout(() => {
|
||||||
|
if (el.parentNode) {
|
||||||
|
el.parentNode.removeChild(el);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('移除元素时发生错误:', error);
|
||||||
|
// 如果移除失败,至少隐藏元素
|
||||||
|
el.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册权限指令
|
||||||
|
* @param app Vue应用实例
|
||||||
|
* @param storeInstance Vuex store实例
|
||||||
|
*/
|
||||||
|
export function setupPermissionDirectives(app: App, storeInstance: Store<any>) {
|
||||||
|
store = storeInstance;
|
||||||
|
|
||||||
|
// 注册v-permission指令
|
||||||
|
app.directive('permission', permission);
|
||||||
|
|
||||||
|
// 注册v-role指令
|
||||||
|
app.directive('role', role);
|
||||||
|
|
||||||
|
// 注册v-auth指令(permission的别名)
|
||||||
|
app.directive('auth', permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限检查Composition API
|
||||||
|
*/
|
||||||
|
export function usePermission() {
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* 检查是否有指定权限
|
||||||
|
*/
|
||||||
|
hasPermission: (permissionCode: string): boolean => {
|
||||||
|
if (!store) return false;
|
||||||
|
return store.getters['auth/hasPermission'](permissionCode);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有任意一个权限
|
||||||
|
*/
|
||||||
|
hasAnyPermission: (permissionCodes: string[]): boolean => {
|
||||||
|
if (!store) return false;
|
||||||
|
return store.getters['auth/hasAnyPermission'](permissionCodes);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有所有权限
|
||||||
|
*/
|
||||||
|
hasAllPermissions: (permissionCodes: string[]): boolean => {
|
||||||
|
if (!store) return false;
|
||||||
|
return store.getters['auth/hasAllPermissions'](permissionCodes);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有指定角色
|
||||||
|
*/
|
||||||
|
hasRole: (roleCode: string): boolean => {
|
||||||
|
if (!store) return false;
|
||||||
|
const userRoles = store.getters['auth/userRoles'] || [];
|
||||||
|
return userRoles.some((role: any) => role.code === roleCode);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有任意一个角色
|
||||||
|
*/
|
||||||
|
hasAnyRole: (roleCodes: string[]): boolean => {
|
||||||
|
if (!store) return false;
|
||||||
|
const userRoles = store.getters['auth/userRoles'] || [];
|
||||||
|
return roleCodes.some(code =>
|
||||||
|
userRoles.some((role: any) => role.code === code)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有所有角色
|
||||||
|
*/
|
||||||
|
hasAllRoles: (roleCodes: string[]): boolean => {
|
||||||
|
if (!store) return false;
|
||||||
|
const userRoles = store.getters['auth/userRoles'] || [];
|
||||||
|
return roleCodes.every(code =>
|
||||||
|
userRoles.some((role: any) => role.code === code)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
14
schoolNewsWeb/src/env.d.ts
vendored
Normal file
14
schoolNewsWeb/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace NodeJS {
|
||||||
|
interface ProcessEnv {
|
||||||
|
readonly BASE_URL?: string
|
||||||
|
readonly VITE_API_BASE_URL?: string
|
||||||
|
readonly VITE_APP_TITLE?: string
|
||||||
|
readonly VITE_APP_MODE?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
257
schoolNewsWeb/src/layouts/BasicLayout.vue
Normal file
257
schoolNewsWeb/src/layouts/BasicLayout.vue
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
<template>
|
||||||
|
<div class="basic-layout">
|
||||||
|
<!-- 侧边栏 -->
|
||||||
|
<aside class="sidebar" :class="{ 'collapsed': sidebarCollapsed }">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<div class="logo">
|
||||||
|
<img src="@/assets/logo.png" alt="Logo" v-if="!sidebarCollapsed">
|
||||||
|
<span class="logo-text" v-if="!sidebarCollapsed">校园新闻</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 菜单导航 -->
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<MenuNav
|
||||||
|
:menus="menuTree"
|
||||||
|
:collapsed="sidebarCollapsed"
|
||||||
|
@menu-click="handleMenuClick"
|
||||||
|
/>
|
||||||
|
</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 />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- 页脚 -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="footer-content">
|
||||||
|
© 2025 校园新闻管理系统. All rights reserved.
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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';
|
||||||
|
// @ts-ignore - Vue 3.5 defineOptions支持
|
||||||
|
import MenuNav from '@/components/MenuNav.vue';
|
||||||
|
// @ts-ignore - Vue 3.5 组件导入兼容性
|
||||||
|
import Breadcrumb from '@/components/Breadcrumb.vue';
|
||||||
|
// @ts-ignore - Vue 3.5 组件导入兼容性
|
||||||
|
import UserDropdown from '@/components/UserDropdown.vue';
|
||||||
|
|
||||||
|
// 响应式状态
|
||||||
|
const sidebarCollapsed = ref(false);
|
||||||
|
|
||||||
|
// Composition API
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
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 || ''
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 方法 - 使用 function 声明
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
store.dispatch('auth/logout');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听路由变化,自动展开对应菜单
|
||||||
|
watch(() => route.path, () => {
|
||||||
|
// 可以在这里实现菜单自动展开逻辑
|
||||||
|
});
|
||||||
|
|
||||||
|
// 组件挂载时恢复侧边栏状态
|
||||||
|
onMounted(() => {
|
||||||
|
const savedState = localStorage.getItem('sidebarCollapsed');
|
||||||
|
if (savedState !== null) {
|
||||||
|
sidebarCollapsed.value = savedState === 'true';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.basic-layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 260px;
|
||||||
|
background: #001529;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.collapsed {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: #002140;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
height: 64px;
|
||||||
|
background: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 24px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.sidebar-toggle {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-right: 16px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-menu {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: white;
|
||||||
|
margin: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: white;
|
||||||
|
border-top: 1px solid #e8e8e8;
|
||||||
|
margin: 0 16px 16px 16px;
|
||||||
|
border-radius: 0 0 4px 4px;
|
||||||
|
|
||||||
|
.footer-content {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图标字体类(这里使用简单的CSS,实际项目中可以使用图标库) */
|
||||||
|
.icon-menu::before {
|
||||||
|
content: "☰";
|
||||||
|
}
|
||||||
|
</style>
|
||||||
18
schoolNewsWeb/src/layouts/BlankLayout.vue
Normal file
18
schoolNewsWeb/src/layouts/BlankLayout.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<div class="blank-layout">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// 空白布局,仅渲染路由内容,适用于登录页、404页等
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.blank-layout {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
101
schoolNewsWeb/src/layouts/PageLayout.vue
Normal file
101
schoolNewsWeb/src/layouts/PageLayout.vue
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-layout">
|
||||||
|
<!-- 页面头部 -->
|
||||||
|
<div class="page-header" v-if="showHeader">
|
||||||
|
<div class="page-header-content">
|
||||||
|
<div class="page-title">
|
||||||
|
<h1>{{ pageTitle }}</h1>
|
||||||
|
<p class="page-description" v-if="pageDescription">{{ pageDescription }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 页面操作按钮 -->
|
||||||
|
<div class="page-actions" v-if="$slots.actions">
|
||||||
|
<slot name="actions"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 页面内容 -->
|
||||||
|
<div class="page-content">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
// Props
|
||||||
|
interface Props {
|
||||||
|
showHeader?: boolean;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
showHeader: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Composition API
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const pageTitle = computed(() => {
|
||||||
|
return props.title || route.meta?.title || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageDescription = computed(() => {
|
||||||
|
return props.description || route.meta?.description || '';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
padding: 16px 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-description {
|
||||||
|
margin: 8px 0 0 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,10 +5,23 @@ import App from "./App.vue";
|
|||||||
import "./registerServiceWorker";
|
import "./registerServiceWorker";
|
||||||
import router from "./router";
|
import router from "./router";
|
||||||
import store from "./store";
|
import store from "./store";
|
||||||
|
import { setupRouterGuards, setupTokenRefresh } from "@/utils/permission";
|
||||||
|
import { setupPermissionDirectives } from "@/directives/permission";
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|
||||||
|
// 使用插件
|
||||||
app.use(ElementPlus);
|
app.use(ElementPlus);
|
||||||
app.use(store);
|
app.use(store);
|
||||||
app.use(router);
|
app.use(router);
|
||||||
|
|
||||||
|
// 设置权限指令
|
||||||
|
setupPermissionDirectives(app, store);
|
||||||
|
|
||||||
|
// 设置路由守卫
|
||||||
|
setupRouterGuards(router, store);
|
||||||
|
|
||||||
|
// 设置Token自动刷新
|
||||||
|
setupTokenRefresh(store);
|
||||||
|
|
||||||
app.mount("#app");
|
app.mount("#app");
|
||||||
|
|||||||
@@ -1,19 +1,109 @@
|
|||||||
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
|
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基础路由配置(无需权限)
|
||||||
|
*/
|
||||||
const routes: Array<RouteRecordRaw> = [
|
const routes: Array<RouteRecordRaw> = [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
redirect: "/dashboard",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/login",
|
||||||
|
name: "Login",
|
||||||
|
component: () => import("@/views/Login.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "登录",
|
||||||
|
requiresAuth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/register",
|
||||||
|
name: "Register",
|
||||||
|
component: () => import("@/views/Register.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "注册",
|
||||||
|
requiresAuth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/forgot-password",
|
||||||
|
name: "ForgotPassword",
|
||||||
|
component: () => import("@/views/ForgotPassword.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "忘记密码",
|
||||||
|
requiresAuth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 主应用布局(需要权限,动态路由会添加到这里)
|
||||||
|
{
|
||||||
|
path: "/dashboard",
|
||||||
|
name: "Dashboard",
|
||||||
|
component: () => import("@/layouts/BasicLayout.vue"),
|
||||||
|
redirect: "/dashboard/workplace",
|
||||||
|
meta: {
|
||||||
|
title: "工作台",
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "workplace",
|
||||||
|
name: "DashboardWorkplace",
|
||||||
|
component: () => import("@/views/dashboard/Workplace.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "工作台",
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// 错误页面
|
||||||
|
{
|
||||||
|
path: "/403",
|
||||||
|
name: "Forbidden",
|
||||||
|
component: () => import("@/views/error/403.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "403 - 无权限访问",
|
||||||
|
requiresAuth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/404",
|
||||||
|
name: "NotFound",
|
||||||
|
component: () => import("@/views/error/404.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "404 - 页面不存在",
|
||||||
|
requiresAuth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/500",
|
||||||
|
name: "ServerError",
|
||||||
|
component: () => import("@/views/error/500.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "500 - 服务器错误",
|
||||||
|
requiresAuth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 旧的about路由,保持兼容
|
||||||
{
|
{
|
||||||
path: "/about",
|
path: "/about",
|
||||||
name: "about",
|
name: "About",
|
||||||
// route level code-splitting
|
component: () => import("@/views/AboutView.vue"),
|
||||||
// this generates a separate chunk (about.[hash].js) for this route
|
meta: {
|
||||||
// which is lazy-loaded when the route is visited.
|
title: "关于",
|
||||||
component: () =>
|
requiresAuth: false,
|
||||||
import(/* webpackChunkName: "about" */ "../views/AboutView.vue"),
|
},
|
||||||
|
},
|
||||||
|
// 捕获所有未匹配的路由
|
||||||
|
{
|
||||||
|
path: "/:pathMatch(.*)*",
|
||||||
|
redirect: "/404",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(process.env.BASE_URL),
|
history: createWebHistory('/schoolNewsWeb/'),
|
||||||
routes,
|
routes,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { createStore } from "vuex";
|
import { createStore } from "vuex";
|
||||||
|
import authModule from './modules/auth';
|
||||||
|
|
||||||
export default createStore({
|
export default createStore({
|
||||||
state: {},
|
state: {},
|
||||||
getters: {},
|
getters: {},
|
||||||
mutations: {},
|
mutations: {},
|
||||||
actions: {},
|
actions: {},
|
||||||
modules: {},
|
modules: {
|
||||||
|
auth: authModule
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
270
schoolNewsWeb/src/store/modules/auth.ts
Normal file
270
schoolNewsWeb/src/store/modules/auth.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* @description 认证相关状态管理
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-10-07
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Module } from 'vuex';
|
||||||
|
import { LoginDomain, SysMenu, SysPermission } from '@/types';
|
||||||
|
import { authApi } from '@/apis/auth';
|
||||||
|
import router from '@/router';
|
||||||
|
|
||||||
|
// State接口定义
|
||||||
|
export interface AuthState {
|
||||||
|
// 用户信息
|
||||||
|
loginDomain: LoginDomain | null;
|
||||||
|
// 用户Token
|
||||||
|
token: string | null;
|
||||||
|
// 用户菜单
|
||||||
|
menus: SysMenu[];
|
||||||
|
// 用户权限
|
||||||
|
permissions: SysPermission[];
|
||||||
|
// 动态路由是否已加载
|
||||||
|
routesLoaded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 认证模块
|
||||||
|
const authModule: Module<AuthState, any> = {
|
||||||
|
namespaced: true,
|
||||||
|
|
||||||
|
state: (): AuthState => ({
|
||||||
|
loginDomain: null,
|
||||||
|
token: localStorage.getItem('token') || null,
|
||||||
|
menus: [],
|
||||||
|
permissions: [],
|
||||||
|
routesLoaded: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
// 是否已登录
|
||||||
|
isAuthenticated: (state): boolean => {
|
||||||
|
return !!(state.token && state.loginDomain);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
userInfo: (state) => {
|
||||||
|
return state.loginDomain?.user || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取用户角色
|
||||||
|
userRoles: (state) => {
|
||||||
|
return state.loginDomain?.roles || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
// 检查是否有指定权限
|
||||||
|
hasPermission: (state) => (permissionCode: string): boolean => {
|
||||||
|
if (!state.permissions || state.permissions.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return state.permissions.some(permission =>
|
||||||
|
permission.code === permissionCode
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 检查是否有任意一个权限
|
||||||
|
hasAnyPermission: (state, getters) => (permissionCodes: string[]): boolean => {
|
||||||
|
return permissionCodes.some(code => getters.hasPermission(code));
|
||||||
|
},
|
||||||
|
|
||||||
|
// 检查是否有所有权限
|
||||||
|
hasAllPermissions: (state, getters) => (permissionCodes: string[]): boolean => {
|
||||||
|
return permissionCodes.every(code => getters.hasPermission(code));
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取菜单树结构
|
||||||
|
menuTree: (state): SysMenu[] => {
|
||||||
|
return buildMenuTree(state.menus);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mutations: {
|
||||||
|
// 设置登录信息
|
||||||
|
SET_LOGIN_DOMAIN(state, loginDomain: LoginDomain) {
|
||||||
|
state.loginDomain = loginDomain;
|
||||||
|
state.token = loginDomain.token || null;
|
||||||
|
state.menus = loginDomain.menus || [];
|
||||||
|
state.permissions = loginDomain.permissions || [];
|
||||||
|
|
||||||
|
// 存储token到localStorage
|
||||||
|
if (state.token) {
|
||||||
|
localStorage.setItem('token', state.token);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置Token
|
||||||
|
SET_TOKEN(state, token: string | null) {
|
||||||
|
state.token = token;
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置菜单
|
||||||
|
SET_MENUS(state, menus: SysMenu[]) {
|
||||||
|
state.menus = menus;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置权限
|
||||||
|
SET_PERMISSIONS(state, permissions: SysPermission[]) {
|
||||||
|
state.permissions = permissions;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置路由加载状态
|
||||||
|
SET_ROUTES_LOADED(state, loaded: boolean) {
|
||||||
|
state.routesLoaded = loaded;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清除认证信息
|
||||||
|
CLEAR_AUTH(state) {
|
||||||
|
state.loginDomain = null;
|
||||||
|
state.token = null;
|
||||||
|
state.menus = [];
|
||||||
|
state.permissions = [];
|
||||||
|
state.routesLoaded = false;
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
// 登录
|
||||||
|
async login({ commit, dispatch }, loginParam) {
|
||||||
|
try {
|
||||||
|
const loginDomain = await authApi.login(loginParam);
|
||||||
|
|
||||||
|
// 保存登录信息
|
||||||
|
commit('SET_LOGIN_DOMAIN', loginDomain);
|
||||||
|
|
||||||
|
// 生成动态路由
|
||||||
|
await dispatch('generateRoutes');
|
||||||
|
|
||||||
|
return Promise.resolve(loginDomain);
|
||||||
|
} catch (error) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 登出
|
||||||
|
async logout({ commit }) {
|
||||||
|
try {
|
||||||
|
await authApi.logout();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('登出接口调用失败:', error);
|
||||||
|
} finally {
|
||||||
|
// 清除认证信息
|
||||||
|
commit('CLEAR_AUTH');
|
||||||
|
|
||||||
|
// 重置路由
|
||||||
|
resetRouter();
|
||||||
|
|
||||||
|
// 跳转到登录页
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 生成动态路由
|
||||||
|
async generateRoutes({ state, commit }) {
|
||||||
|
try {
|
||||||
|
if (!state.menus || state.menus.length === 0) {
|
||||||
|
console.warn('用户菜单为空,无法生成路由');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据菜单生成路由
|
||||||
|
const { generateRoutes } = await import('@/utils/route-generator');
|
||||||
|
const routes = generateRoutes(state.menus);
|
||||||
|
|
||||||
|
// 添加路由到router
|
||||||
|
routes.forEach((route: any) => {
|
||||||
|
router.addRoute(route);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 标记路由已加载
|
||||||
|
commit('SET_ROUTES_LOADED', true);
|
||||||
|
|
||||||
|
console.log('动态路由生成完成', routes);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('生成动态路由失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 刷新Token
|
||||||
|
async refreshToken({ commit }) {
|
||||||
|
try {
|
||||||
|
const newToken = await authApi.refreshToken();
|
||||||
|
commit('SET_TOKEN', newToken);
|
||||||
|
return Promise.resolve(newToken);
|
||||||
|
} catch (error) {
|
||||||
|
// 刷新失败,清除认证信息
|
||||||
|
commit('CLEAR_AUTH');
|
||||||
|
router.push('/login');
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建菜单树结构
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置路由
|
||||||
|
*/
|
||||||
|
function resetRouter() {
|
||||||
|
// 这里需要根据实际路由配置来重置
|
||||||
|
// 由于Vue Router 4没有直接的重置方法,我们需要重新创建router实例
|
||||||
|
// 或者记录动态添加的路由并逐个移除
|
||||||
|
location.reload(); // 临时方案,后续可以优化
|
||||||
|
}
|
||||||
|
|
||||||
|
export default authModule;
|
||||||
@@ -21,6 +21,8 @@ export interface SysMenu extends BaseDTO {
|
|||||||
description?: string;
|
description?: string;
|
||||||
/** 菜单URL/路径 */
|
/** 菜单URL/路径 */
|
||||||
url?: string;
|
url?: string;
|
||||||
|
/** 菜单组件 */
|
||||||
|
component?: string;
|
||||||
/** 菜单图标 */
|
/** 菜单图标 */
|
||||||
icon?: string;
|
icon?: string;
|
||||||
/** 菜单顺序 */
|
/** 菜单顺序 */
|
||||||
|
|||||||
@@ -49,11 +49,31 @@ export interface SysUserInfo extends BaseDTO {
|
|||||||
/**
|
/**
|
||||||
* 用户VO - 用于前端展示
|
* 用户VO - 用于前端展示
|
||||||
*/
|
*/
|
||||||
export interface UserVO {
|
export interface UserVO extends BaseDTO {
|
||||||
/** 用户基本信息 */
|
/** 用户名 */
|
||||||
user?: SysUser;
|
username?: string;
|
||||||
/** 用户详细信息 */
|
/** 邮箱 */
|
||||||
userInfo?: SysUserInfo;
|
email?: string;
|
||||||
|
/** 手机号 */
|
||||||
|
phone?: string;
|
||||||
|
/** 微信ID */
|
||||||
|
wechatID?: string;
|
||||||
|
/** 用户状态 0-正常 1-禁用 */
|
||||||
|
status?: number;
|
||||||
|
/** 真实姓名 */
|
||||||
|
realName?: string;
|
||||||
|
/** 昵称 */
|
||||||
|
nickname?: string;
|
||||||
|
/** 头像URL */
|
||||||
|
avatar?: string;
|
||||||
|
/** 性别 0-未知 1-男 2-女 */
|
||||||
|
gender?: number;
|
||||||
|
/** 出生日期 */
|
||||||
|
birthday?: string;
|
||||||
|
/** 个人简介 */
|
||||||
|
bio?: string;
|
||||||
|
/** 地址 */
|
||||||
|
address?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
255
schoolNewsWeb/src/utils/permission.ts
Normal file
255
schoolNewsWeb/src/utils/permission.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* @description 路由守卫和权限检查
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-10-07
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Router, NavigationGuardNext, RouteLocationNormalized } from 'vue-router';
|
||||||
|
import type { Store } from 'vuex';
|
||||||
|
import { AuthState } from '@/store/modules/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 白名单路由 - 无需登录即可访问
|
||||||
|
*/
|
||||||
|
const WHITE_LIST = [
|
||||||
|
'/login',
|
||||||
|
'/register',
|
||||||
|
'/forgot-password',
|
||||||
|
'/404',
|
||||||
|
'/403',
|
||||||
|
'/500'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置路由守卫
|
||||||
|
* @param router Vue Router实例
|
||||||
|
* @param store Vuex Store实例
|
||||||
|
*/
|
||||||
|
export function setupRouterGuards(router: Router, store: Store<any>) {
|
||||||
|
|
||||||
|
// 全局前置守卫
|
||||||
|
router.beforeEach(async (to, from, next) => {
|
||||||
|
// 开始页面加载进度条
|
||||||
|
startProgress();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleRouteGuard(to, from, next, store);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('路由守卫执行失败:', error);
|
||||||
|
// 发生错误时跳转到500页面
|
||||||
|
next('/500');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 全局后置钩子
|
||||||
|
|
||||||
|
router.afterEach((to) => {
|
||||||
|
// 结束页面加载进度条
|
||||||
|
finishProgress();
|
||||||
|
|
||||||
|
// 设置页面标题
|
||||||
|
setPageTitle(to.meta?.title as string);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 全局解析守卫(在导航被确认之前,同时在所有组件内守卫和异步路由组件被解析之后调用)
|
||||||
|
router.beforeResolve(async (to, from, next) => {
|
||||||
|
// 这里可以处理一些最终的权限检查或数据预加载
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理路由守卫逻辑
|
||||||
|
*/
|
||||||
|
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 (!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) {
|
||||||
|
console.error('生成动态路由失败:', error);
|
||||||
|
// 清除认证信息并跳转到登录页
|
||||||
|
store.commit('auth/CLEAR_AUTH');
|
||||||
|
return next('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查页面权限
|
||||||
|
const hasPermission = await checkPagePermission(to, store);
|
||||||
|
if (!hasPermission) {
|
||||||
|
// 无权限访问,跳转到403页面
|
||||||
|
return next('/403');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有检查通过,继续导航
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查路径是否在白名单中
|
||||||
|
*/
|
||||||
|
function isInWhiteList(path: string): boolean {
|
||||||
|
return WHITE_LIST.some(whitePath => {
|
||||||
|
if (whitePath.endsWith('*')) {
|
||||||
|
// 支持通配符匹配
|
||||||
|
const prefix = whitePath.slice(0, -1);
|
||||||
|
return path.startsWith(prefix);
|
||||||
|
}
|
||||||
|
return path === whitePath;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查页面权限
|
||||||
|
*/
|
||||||
|
async function checkPagePermission(
|
||||||
|
route: RouteLocationNormalized,
|
||||||
|
store: Store<any>
|
||||||
|
): Promise<boolean> {
|
||||||
|
// 如果路由元信息中没有要求权限,则允许访问
|
||||||
|
if (route.meta?.requiresAuth === false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查路由是否需要特定权限
|
||||||
|
const requiredPermissions = route.meta?.permissions as string[] | undefined;
|
||||||
|
|
||||||
|
if (!requiredPermissions || requiredPermissions.length === 0) {
|
||||||
|
// 无特定权限要求,但需要登录,已经在前面检查过登录状态
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户是否有所需权限
|
||||||
|
const hasPermission = store.getters['auth/hasAnyPermission'];
|
||||||
|
return hasPermission(requiredPermissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置页面标题
|
||||||
|
*/
|
||||||
|
function setPageTitle(title?: string) {
|
||||||
|
const appTitle = '校园新闻管理系统';
|
||||||
|
document.title = title ? `${title} - ${appTitle}` : appTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始进度条(可以集成 NProgress 或其他进度条库)
|
||||||
|
*/
|
||||||
|
function startProgress() {
|
||||||
|
// TODO: 集成进度条库,如 NProgress
|
||||||
|
// NProgress.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成进度条
|
||||||
|
*/
|
||||||
|
function finishProgress() {
|
||||||
|
// TODO: 集成进度条库,如 NProgress
|
||||||
|
// NProgress.done();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token自动刷新中间件
|
||||||
|
*/
|
||||||
|
export function setupTokenRefresh(store: Store<any>) {
|
||||||
|
// 设置定时器自动刷新Token
|
||||||
|
setInterval(async () => {
|
||||||
|
const authState: AuthState = store.state.auth;
|
||||||
|
|
||||||
|
if (!authState.token || !authState.loginDomain) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查Token是否快要过期(例如:提前5分钟刷新)
|
||||||
|
const tokenExpireTime = authState.loginDomain.tokenExpireTime;
|
||||||
|
if (tokenExpireTime) {
|
||||||
|
const expireTime = new Date(tokenExpireTime).getTime();
|
||||||
|
const currentTime = Date.now();
|
||||||
|
const fiveMinutes = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
if (expireTime - currentTime <= fiveMinutes) {
|
||||||
|
try {
|
||||||
|
await store.dispatch('auth/refreshToken');
|
||||||
|
console.log('Token自动刷新成功');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token自动刷新失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 60 * 1000); // 每分钟检查一次
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限检查工具函数
|
||||||
|
*/
|
||||||
|
export class PermissionChecker {
|
||||||
|
private store: Store<any>;
|
||||||
|
|
||||||
|
constructor(store: Store<any>) {
|
||||||
|
this.store = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有指定权限
|
||||||
|
*/
|
||||||
|
hasPermission(permissionCode: string): boolean {
|
||||||
|
return this.store.getters['auth/hasPermission'](permissionCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有任意一个权限
|
||||||
|
*/
|
||||||
|
hasAnyPermission(permissionCodes: string[]): boolean {
|
||||||
|
return this.store.getters['auth/hasAnyPermission'](permissionCodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有所有权限
|
||||||
|
*/
|
||||||
|
hasAllPermissions(permissionCodes: string[]): boolean {
|
||||||
|
return this.store.getters['auth/hasAllPermissions'](permissionCodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有指定角色
|
||||||
|
*/
|
||||||
|
hasRole(roleCode: string): boolean {
|
||||||
|
const userRoles = this.store.getters['auth/userRoles'];
|
||||||
|
return userRoles.some((role: any) => role.code === roleCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有任意一个角色
|
||||||
|
*/
|
||||||
|
hasAnyRole(roleCodes: string[]): boolean {
|
||||||
|
return roleCodes.some(code => this.hasRole(code));
|
||||||
|
}
|
||||||
|
}
|
||||||
295
schoolNewsWeb/src/utils/route-generator.ts
Normal file
295
schoolNewsWeb/src/utils/route-generator.ts
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
/**
|
||||||
|
* @description 动态路由生成器
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-10-07
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteRecordRaw } from 'vue-router';
|
||||||
|
import type { SysMenu } from '@/types';
|
||||||
|
import { MenuType } from '@/types/enums';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 布局组件映射
|
||||||
|
*/
|
||||||
|
const LAYOUT_MAP: Record<string, () => Promise<any>> = {
|
||||||
|
// 基础布局
|
||||||
|
'BasicLayout': () => import('@/layouts/BasicLayout.vue'),
|
||||||
|
// 空白布局
|
||||||
|
'BlankLayout': () => import('@/layouts/BlankLayout.vue'),
|
||||||
|
// 页面布局
|
||||||
|
'PageLayout': () => import('@/layouts/PageLayout.vue'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据菜单生成路由配置
|
||||||
|
* @param menus 用户菜单列表
|
||||||
|
* @returns Vue Router路由配置数组
|
||||||
|
*/
|
||||||
|
export function generateRoutes(menus: SysMenu[]): RouteRecordRaw[] {
|
||||||
|
if (!menus || menus.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [];
|
||||||
|
|
||||||
|
// 构建菜单树
|
||||||
|
const menuTree = buildMenuTree(menus);
|
||||||
|
|
||||||
|
// 生成路由
|
||||||
|
menuTree.forEach(menu => {
|
||||||
|
const route = generateRouteFromMenu(menu);
|
||||||
|
if (route) {
|
||||||
|
routes.push(route);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据单个菜单生成路由
|
||||||
|
* @param menu 菜单对象
|
||||||
|
* @returns 路由配置
|
||||||
|
*/
|
||||||
|
function generateRouteFromMenu(menu: SysMenu): RouteRecordRaw | null {
|
||||||
|
// 只处理目录和菜单类型,忽略按钮类型
|
||||||
|
if (menu.type === MenuType.BUTTON) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const route: any = {
|
||||||
|
path: menu.url || `/${menu.menuID}`,
|
||||||
|
name: menu.menuID,
|
||||||
|
meta: {
|
||||||
|
title: menu.name,
|
||||||
|
icon: menu.icon,
|
||||||
|
menuId: menu.menuID,
|
||||||
|
parentId: menu.parentID,
|
||||||
|
orderNum: menu.orderNum,
|
||||||
|
type: menu.type,
|
||||||
|
hideInMenu: false,
|
||||||
|
requiresAuth: true,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 根据菜单类型处理组件
|
||||||
|
if (menu.type === MenuType.DIRECTORY) {
|
||||||
|
// 目录类型 - 使用布局组件
|
||||||
|
route.component = getComponent(menu.component || 'BasicLayout');
|
||||||
|
|
||||||
|
// 处理子菜单
|
||||||
|
if (menu.children && menu.children.length > 0) {
|
||||||
|
route.children = [];
|
||||||
|
menu.children.forEach(child => {
|
||||||
|
const childRoute = generateRouteFromMenu(child);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
route.component = getComponent(menu.component);
|
||||||
|
}
|
||||||
|
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据组件名称获取组件
|
||||||
|
* @param componentName 组件名称/路径
|
||||||
|
* @returns 组件异步加载函数
|
||||||
|
*/
|
||||||
|
function getComponent(componentName: string) {
|
||||||
|
// 检查是否是布局组件
|
||||||
|
if (LAYOUT_MAP[componentName]) {
|
||||||
|
return LAYOUT_MAP[componentName];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理页面组件路径
|
||||||
|
let componentPath = componentName;
|
||||||
|
|
||||||
|
// 如果不是以@/开头的完整路径,则添加@/views/前缀
|
||||||
|
if (!componentPath.startsWith('@/')) {
|
||||||
|
// 如果不是以/开头,添加/
|
||||||
|
if (!componentPath.startsWith('/')) {
|
||||||
|
componentPath = '/' + componentPath;
|
||||||
|
}
|
||||||
|
componentPath = '@/views' + componentPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有.vue扩展名,添加它
|
||||||
|
if (!componentPath.endsWith('.vue')) {
|
||||||
|
componentPath += '.vue';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动态导入组件
|
||||||
|
return () => import(/* @vite-ignore */ componentPath).catch((error) => {
|
||||||
|
console.warn(`组件加载失败: ${componentPath}`, error);
|
||||||
|
// 返回404组件或空组件
|
||||||
|
return import('@/views/error/404.vue').catch(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
template: `<div class="component-error">
|
||||||
|
<h3>组件加载失败</h3>
|
||||||
|
<p>无法加载组件: ${componentPath}</p>
|
||||||
|
<p>错误: ${error.message}</p>
|
||||||
|
</div>`,
|
||||||
|
style: `
|
||||||
|
.component-error {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #f56565;
|
||||||
|
background: #fed7d7;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建菜单树结构
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据权限过滤菜单
|
||||||
|
* @param menus 菜单列表
|
||||||
|
* @param permissions 用户权限列表
|
||||||
|
* @returns 过滤后的菜单列表
|
||||||
|
*/
|
||||||
|
export function filterMenusByPermissions(
|
||||||
|
menus: SysMenu[],
|
||||||
|
permissions: string[]
|
||||||
|
): SysMenu[] {
|
||||||
|
if (!menus || menus.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return menus
|
||||||
|
.filter(() => {
|
||||||
|
// 如果菜单没有设置权限要求,则默认显示
|
||||||
|
// 这里可以根据实际业务需求调整权限检查逻辑
|
||||||
|
return true; // 暂时返回true,后续可以根据菜单的权限字段进行过滤
|
||||||
|
})
|
||||||
|
.map(menu => {
|
||||||
|
if (menu.children && menu.children.length > 0) {
|
||||||
|
return {
|
||||||
|
...menu,
|
||||||
|
children: filterMenusByPermissions(menu.children, permissions)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return menu;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找路由路径对应的菜单
|
||||||
|
* @param menus 菜单树
|
||||||
|
* @param path 路由路径
|
||||||
|
* @returns 匹配的菜单
|
||||||
|
*/
|
||||||
|
export function findMenuByPath(menus: SysMenu[], path: string): SysMenu | null {
|
||||||
|
for (const menu of menus) {
|
||||||
|
if (menu.url === path) {
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (menu.children && menu.children.length > 0) {
|
||||||
|
const found = findMenuByPath(menu.children, path);
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取菜单路径数组(面包屑导航用)
|
||||||
|
* @param menus 菜单树
|
||||||
|
* @param targetMenuId 目标菜单ID
|
||||||
|
* @returns 菜单路径数组
|
||||||
|
*/
|
||||||
|
export function getMenuPath(menus: SysMenu[], targetMenuId: string): SysMenu[] {
|
||||||
|
const path: SysMenu[] = [];
|
||||||
|
|
||||||
|
function findPath(menuList: SysMenu[]): boolean {
|
||||||
|
for (const menu of menuList) {
|
||||||
|
path.push(menu);
|
||||||
|
|
||||||
|
if (menu.menuID === targetMenuId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (menu.children && menu.children.length > 0) {
|
||||||
|
if (findPath(menu.children)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
findPath(menus);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
101
schoolNewsWeb/src/views/ForgotPassword.vue
Normal file
101
schoolNewsWeb/src/views/ForgotPassword.vue
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<template>
|
||||||
|
<div class="forgot-password-container">
|
||||||
|
<div class="forgot-password-box">
|
||||||
|
<div class="forgot-password-header">
|
||||||
|
<div class="logo">
|
||||||
|
<img src="@/assets/logo.png" alt="Logo" />
|
||||||
|
</div>
|
||||||
|
<h1 class="title">找回密码</h1>
|
||||||
|
<p class="subtitle">重置您的账户密码</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="forgot-password-form">
|
||||||
|
<!-- 找回密码表单内容 -->
|
||||||
|
<div class="form-placeholder">
|
||||||
|
<p>找回密码功能开发中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="forgot-password-footer">
|
||||||
|
<p>
|
||||||
|
想起密码了?
|
||||||
|
<el-link type="primary" @click="goToLogin">返回登录</el-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function goToLogin() {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.forgot-password-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password-box {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
|
||||||
|
.logo img {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password-form {
|
||||||
|
.form-placeholder {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password-footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
286
schoolNewsWeb/src/views/Login.vue
Normal file
286
schoolNewsWeb/src/views/Login.vue
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-box">
|
||||||
|
<div class="login-header">
|
||||||
|
<div class="logo">
|
||||||
|
<img src="@/assets/logo.png" alt="Logo" />
|
||||||
|
</div>
|
||||||
|
<h1 class="title">校园新闻管理系统</h1>
|
||||||
|
<p class="subtitle">登录您的账户</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form
|
||||||
|
ref="loginFormRef"
|
||||||
|
:model="loginForm"
|
||||||
|
:rules="loginRules"
|
||||||
|
class="login-form"
|
||||||
|
size="large"
|
||||||
|
@submit.prevent="handleLogin"
|
||||||
|
>
|
||||||
|
<el-form-item prop="username">
|
||||||
|
<el-input
|
||||||
|
v-model="loginForm.username"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
prefix-icon="User"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item prop="password">
|
||||||
|
<el-input
|
||||||
|
v-model="loginForm.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
prefix-icon="Lock"
|
||||||
|
show-password
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item prop="captcha" v-if="showCaptcha">
|
||||||
|
<div class="captcha-input">
|
||||||
|
<el-input
|
||||||
|
v-model="loginForm.captcha"
|
||||||
|
placeholder="请输入验证码"
|
||||||
|
prefix-icon="PictureRounded"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<div class="captcha-image" @click="refreshCaptcha">
|
||||||
|
<img :src="captchaImage" alt="验证码" v-if="captchaImage" />
|
||||||
|
<div class="captcha-loading" v-else>加载中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<div class="login-options">
|
||||||
|
<el-checkbox v-model="loginForm.rememberMe">
|
||||||
|
记住我
|
||||||
|
</el-checkbox>
|
||||||
|
<el-link type="primary" @click="goToForgotPassword">
|
||||||
|
忘记密码?
|
||||||
|
</el-link>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
:loading="loginLoading"
|
||||||
|
@click="handleLogin"
|
||||||
|
class="login-button"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="login-footer">
|
||||||
|
<p>
|
||||||
|
还没有账户?
|
||||||
|
<el-link type="primary" @click="goToRegister">立即注册</el-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
||||||
|
import type { LoginParam } from '@/types';
|
||||||
|
import { authApi } from '@/apis/auth';
|
||||||
|
|
||||||
|
// 响应式引用
|
||||||
|
const loginFormRef = ref<FormInstance>();
|
||||||
|
const loginLoading = ref(false);
|
||||||
|
const showCaptcha = ref(false);
|
||||||
|
const captchaImage = ref('');
|
||||||
|
|
||||||
|
// Composition API
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const loginForm = reactive<LoginParam>({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
captcha: '',
|
||||||
|
captchaId: '',
|
||||||
|
rememberMe: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表单验证规则
|
||||||
|
const loginRules: FormRules = {
|
||||||
|
username: [
|
||||||
|
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||||
|
{ min: 3, max: 20, message: '用户名长度为3-20个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
password: [
|
||||||
|
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||||
|
{ min: 6, message: '密码至少6个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
captcha: [
|
||||||
|
{ required: true, message: '请输入验证码', trigger: 'blur' },
|
||||||
|
{ len: 4, message: '验证码为4位', trigger: 'blur' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!loginFormRef.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const valid = await loginFormRef.value.validate();
|
||||||
|
if (!valid) return;
|
||||||
|
|
||||||
|
loginLoading.value = true;
|
||||||
|
|
||||||
|
// 调用store中的登录action
|
||||||
|
await store.dispatch('auth/login', loginForm);
|
||||||
|
|
||||||
|
ElMessage.success('登录成功!');
|
||||||
|
|
||||||
|
// 获取重定向路径
|
||||||
|
const redirectPath = (route.query.redirect as string) || '/dashboard';
|
||||||
|
router.push(redirectPath);
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('登录失败:', error);
|
||||||
|
ElMessage.error(error.message || '登录失败,请检查用户名和密码');
|
||||||
|
|
||||||
|
// 登录失败后显示验证码
|
||||||
|
showCaptcha.value = true;
|
||||||
|
refreshCaptcha();
|
||||||
|
} finally {
|
||||||
|
loginLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshCaptcha = async () => {
|
||||||
|
try {
|
||||||
|
const captchaData = await authApi.getCaptcha();
|
||||||
|
captchaImage.value = captchaData.captchaImage;
|
||||||
|
loginForm.captchaId = captchaData.captchaId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取验证码失败:', error);
|
||||||
|
ElMessage.error('获取验证码失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function goToRegister() {
|
||||||
|
router.push('/register');
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToForgotPassword() {
|
||||||
|
router.push('/forgot-password');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件挂载时检查是否需要显示验证码
|
||||||
|
onMounted(() => {
|
||||||
|
// 可以根据需要决定是否默认显示验证码
|
||||||
|
// refreshCaptcha();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.login-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
|
||||||
|
.logo img {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
.captcha-input {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.el-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-image {
|
||||||
|
width: 100px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-loading {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-options {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
101
schoolNewsWeb/src/views/Register.vue
Normal file
101
schoolNewsWeb/src/views/Register.vue
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<template>
|
||||||
|
<div class="register-container">
|
||||||
|
<div class="register-box">
|
||||||
|
<div class="register-header">
|
||||||
|
<div class="logo">
|
||||||
|
<img src="@/assets/logo.png" alt="Logo" />
|
||||||
|
</div>
|
||||||
|
<h1 class="title">注册账户</h1>
|
||||||
|
<p class="subtitle">创建您的校园新闻管理系统账户</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="register-form">
|
||||||
|
<!-- 注册表单内容 -->
|
||||||
|
<div class="form-placeholder">
|
||||||
|
<p>注册功能开发中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="register-footer">
|
||||||
|
<p>
|
||||||
|
已有账户?
|
||||||
|
<el-link type="primary" @click="goToLogin">立即登录</el-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function goToLogin() {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.register-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-box {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
|
||||||
|
.logo img {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-form {
|
||||||
|
.form-placeholder {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
466
schoolNewsWeb/src/views/dashboard/Workplace.vue
Normal file
466
schoolNewsWeb/src/views/dashboard/Workplace.vue
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
<template>
|
||||||
|
<div class="workplace">
|
||||||
|
<div class="workplace-header">
|
||||||
|
<div class="welcome-info">
|
||||||
|
<h1 class="welcome-title">
|
||||||
|
欢迎回来,{{ userInfo?.realName || userInfo?.username }}!
|
||||||
|
</h1>
|
||||||
|
<p class="welcome-subtitle">
|
||||||
|
今天是 {{ currentDate }},{{ greetingText }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="user-stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">📝</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-number">{{ todayNews }}</div>
|
||||||
|
<div class="stat-label">今日新闻</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">👥</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-number">{{ onlineUsers }}</div>
|
||||||
|
<div class="stat-label">在线用户</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card" v-permission="'system:news:view'">
|
||||||
|
<div class="stat-icon">📊</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-number">{{ totalViews }}</div>
|
||||||
|
<div class="stat-label">总阅读量</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="workplace-content">
|
||||||
|
<!-- 快捷操作 -->
|
||||||
|
<div class="quick-actions">
|
||||||
|
<h2 class="section-title">快捷操作</h2>
|
||||||
|
<div class="action-grid">
|
||||||
|
<div class="action-card" @click="goToCreateNews" v-permission="'news:create'">
|
||||||
|
<div class="action-icon">✏️</div>
|
||||||
|
<div class="action-title">发布新闻</div>
|
||||||
|
<div class="action-desc">创建新的校园新闻</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-card" @click="goToUserManage" v-permission="'system:user:view'">
|
||||||
|
<div class="action-icon">👤</div>
|
||||||
|
<div class="action-title">用户管理</div>
|
||||||
|
<div class="action-desc">管理系统用户</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-card" @click="goToSystemSettings" v-permission="'system:settings:view'">
|
||||||
|
<div class="action-icon">⚙️</div>
|
||||||
|
<div class="action-title">系统设置</div>
|
||||||
|
<div class="action-desc">配置系统参数</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-card" @click="goToProfile">
|
||||||
|
<div class="action-icon">📋</div>
|
||||||
|
<div class="action-title">个人资料</div>
|
||||||
|
<div class="action-desc">编辑个人信息</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 最近活动 -->
|
||||||
|
<div class="recent-activities">
|
||||||
|
<h2 class="section-title">最近活动</h2>
|
||||||
|
<div class="activity-list">
|
||||||
|
<div class="activity-item" v-for="activity in recentActivities" :key="activity.id">
|
||||||
|
<div class="activity-icon" :class="activity.type">
|
||||||
|
{{ getActivityIcon(activity.type) }}
|
||||||
|
</div>
|
||||||
|
<div class="activity-content">
|
||||||
|
<div class="activity-title">{{ activity.title }}</div>
|
||||||
|
<div class="activity-time">{{ formatTime(activity.time) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 系统公告 -->
|
||||||
|
<div class="system-notice">
|
||||||
|
<h2 class="section-title">系统公告</h2>
|
||||||
|
<div class="notice-list">
|
||||||
|
<div class="notice-item" v-for="notice in systemNotices" :key="notice.id">
|
||||||
|
<div class="notice-type" :class="notice.type">
|
||||||
|
{{ getNoticeTypeText(notice.type) }}
|
||||||
|
</div>
|
||||||
|
<div class="notice-content">
|
||||||
|
<div class="notice-title">{{ notice.title }}</div>
|
||||||
|
<div class="notice-time">{{ formatTime(notice.time) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
// import { usePermission } from '@/directives/permission'; // 暂时注释,后续权限功能开发时启用
|
||||||
|
|
||||||
|
// 数据
|
||||||
|
const todayNews = ref(12);
|
||||||
|
const onlineUsers = ref(56);
|
||||||
|
const totalViews = ref(8924);
|
||||||
|
|
||||||
|
const recentActivities = ref([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'news',
|
||||||
|
title: '发布了新闻《学校举办科技创新大赛》',
|
||||||
|
time: new Date(Date.now() - 10 * 60 * 1000) // 10分钟前
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: 'user',
|
||||||
|
title: '新用户注册:张三',
|
||||||
|
time: new Date(Date.now() - 30 * 60 * 1000) // 30分钟前
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
type: 'system',
|
||||||
|
title: '系统维护完成',
|
||||||
|
time: new Date(Date.now() - 2 * 60 * 60 * 1000) // 2小时前
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const systemNotices = ref([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'info',
|
||||||
|
title: '系统将于本周日进行例行维护',
|
||||||
|
time: new Date(Date.now() - 24 * 60 * 60 * 1000) // 1天前
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: 'warning',
|
||||||
|
title: '请及时更新个人资料信息',
|
||||||
|
time: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000) // 3天前
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Composition API
|
||||||
|
const router = useRouter();
|
||||||
|
const store = useStore();
|
||||||
|
// const { hasPermission } = usePermission(); // 暂时注释,后续权限功能开发时启用
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const userInfo = computed(() => store.getters['auth/userInfo']);
|
||||||
|
|
||||||
|
const currentDate = computed(() => {
|
||||||
|
return new Date().toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
weekday: 'long'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const greetingText = computed(() => {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
if (hour < 6) return '夜深了,注意休息';
|
||||||
|
if (hour < 12) return '早上好';
|
||||||
|
if (hour < 18) return '下午好';
|
||||||
|
return '晚上好';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
function goToCreateNews() {
|
||||||
|
router.push('/news/create');
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToUserManage() {
|
||||||
|
router.push('/system/user');
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToSystemSettings() {
|
||||||
|
router.push('/system/settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToProfile() {
|
||||||
|
router.push('/profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivityIcon(type: string) {
|
||||||
|
const icons = {
|
||||||
|
news: '📰',
|
||||||
|
user: '👤',
|
||||||
|
system: '⚙️'
|
||||||
|
};
|
||||||
|
return icons[type as keyof typeof icons] || '📝';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNoticeTypeText(type: string) {
|
||||||
|
const types = {
|
||||||
|
info: '通知',
|
||||||
|
warning: '提醒',
|
||||||
|
error: '警告'
|
||||||
|
};
|
||||||
|
return types[type as keyof typeof types] || '公告';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(time: Date) {
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - time.getTime();
|
||||||
|
|
||||||
|
if (diff < 60 * 1000) return '刚刚';
|
||||||
|
if (diff < 60 * 60 * 1000) return `${Math.floor(diff / 60 / 1000)}分钟前`;
|
||||||
|
if (diff < 24 * 60 * 60 * 1000) return `${Math.floor(diff / 60 / 60 / 1000)}小时前`;
|
||||||
|
if (diff < 7 * 24 * 60 * 60 * 1000) return `${Math.floor(diff / 24 / 60 / 60 / 1000)}天前`;
|
||||||
|
|
||||||
|
return time.toLocaleDateString('zh-CN');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生命周期
|
||||||
|
onMounted(() => {
|
||||||
|
// 可以在这里加载统计数据
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.workplace {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workplace-header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.welcome-info {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.welcome-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-subtitle {
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 150px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-content {
|
||||||
|
.stat-number {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.workplace-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
background: white;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
.action-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card {
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1890ff;
|
||||||
|
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.2);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-activities {
|
||||||
|
background: white;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
grid-column: 1;
|
||||||
|
|
||||||
|
.activity-list {
|
||||||
|
.activity-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&.news { background: #e6f7ff; }
|
||||||
|
&.user { background: #f6ffed; }
|
||||||
|
&.system { background: #fff7e6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-content {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.activity-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-notice {
|
||||||
|
background: white;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
grid-column: 2;
|
||||||
|
|
||||||
|
.notice-list {
|
||||||
|
.notice-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-type {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&.info {
|
||||||
|
background: #e6f7ff;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warning {
|
||||||
|
background: #fff7e6;
|
||||||
|
color: #fa8c16;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
background: #fff2f0;
|
||||||
|
color: #f5222d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-content {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.notice-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
90
schoolNewsWeb/src/views/error/403.vue
Normal file
90
schoolNewsWeb/src/views/error/403.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<div class="error-page">
|
||||||
|
<div class="error-content">
|
||||||
|
<div class="error-icon">
|
||||||
|
<span class="error-number">403</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="error-info">
|
||||||
|
<h1 class="error-title">无权限访问</h1>
|
||||||
|
<p class="error-description">
|
||||||
|
抱歉,您没有权限访问此页面。请联系管理员获取相应权限。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="error-actions">
|
||||||
|
<el-button type="primary" @click="goHome">
|
||||||
|
返回首页
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="goBack">
|
||||||
|
返回上页
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
router.push('/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
router.go(-1);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.error-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-content {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
|
||||||
|
.error-number {
|
||||||
|
font-size: 120px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fa8c16;
|
||||||
|
line-height: 1;
|
||||||
|
text-shadow: 0 4px 8px rgba(250, 140, 22, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-info {
|
||||||
|
.error-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-description {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #666;
|
||||||
|
margin: 0 0 32px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
90
schoolNewsWeb/src/views/error/404.vue
Normal file
90
schoolNewsWeb/src/views/error/404.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<div class="error-page">
|
||||||
|
<div class="error-content">
|
||||||
|
<div class="error-icon">
|
||||||
|
<span class="error-number">404</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="error-info">
|
||||||
|
<h1 class="error-title">页面不存在</h1>
|
||||||
|
<p class="error-description">
|
||||||
|
抱歉,您访问的页面不存在或已被删除。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="error-actions">
|
||||||
|
<el-button type="primary" @click="goHome">
|
||||||
|
返回首页
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="goBack">
|
||||||
|
返回上页
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
router.push('/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
router.go(-1);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.error-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-content {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
|
||||||
|
.error-number {
|
||||||
|
font-size: 120px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1890ff;
|
||||||
|
line-height: 1;
|
||||||
|
text-shadow: 0 4px 8px rgba(24, 144, 255, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-info {
|
||||||
|
.error-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-description {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #666;
|
||||||
|
margin: 0 0 32px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
90
schoolNewsWeb/src/views/error/500.vue
Normal file
90
schoolNewsWeb/src/views/error/500.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<div class="error-page">
|
||||||
|
<div class="error-content">
|
||||||
|
<div class="error-icon">
|
||||||
|
<span class="error-number">500</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="error-info">
|
||||||
|
<h1 class="error-title">服务器错误</h1>
|
||||||
|
<p class="error-description">
|
||||||
|
抱歉,服务器出现内部错误。请稍后重试,或联系技术支持。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="error-actions">
|
||||||
|
<el-button type="primary" @click="refresh">
|
||||||
|
刷新页面
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="goHome">
|
||||||
|
返回首页
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
router.push('/dashboard');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.error-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-content {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
|
||||||
|
.error-number {
|
||||||
|
font-size: 120px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f5222d;
|
||||||
|
line-height: 1;
|
||||||
|
text-shadow: 0 4px 8px rgba(245, 34, 45, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-info {
|
||||||
|
.error-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-description {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #666;
|
||||||
|
margin: 0 0 32px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
46
schoolNewsWeb/vue.config.js
Normal file
46
schoolNewsWeb/vue.config.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { defineConfig } from "@vue/cli-service";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
transpileDependencies: true,
|
|
||||||
devServer: {
|
|
||||||
host: "0.0.0.0",
|
|
||||||
port: 8080,
|
|
||||||
open: true,
|
|
||||||
hot: true,
|
|
||||||
historyApiFallback: true,
|
|
||||||
proxy: {
|
|
||||||
"/api": {
|
|
||||||
target: "http://localhost:8080",
|
|
||||||
ws: true,
|
|
||||||
changeOrigin: true,
|
|
||||||
pathRewrite: {
|
|
||||||
"^/api": "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
127
schoolNewsWeb/权限控制系统使用说明.md
Normal file
127
schoolNewsWeb/权限控制系统使用说明.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# 校园新闻管理系统 - 权限控制系统使用说明
|
||||||
|
|
||||||
|
## 系统概述
|
||||||
|
|
||||||
|
该系统实现了基于角色和权限的动态路由和组件加载功能,根据用户登录后返回的菜单权限信息自动生成路由配置。
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
### 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