前言
接下来,我们将使用 Vue 3、TypeScript 和 Vite 构建一个前端应用,与之前构建的后端 API 无缝对接。此前端将处理用户认证、显示用户数据、管理角色和权限,并确保与后端的安全通信。首先,我们来构建一个满足基本开发的前端应用工程。
1.项目初始化
首先,使用 Vite 快速创建一个 Vue 3 + TypeScript 项目,我这里使用是npm。
# 使用 npm
npm create vite@latest frontend -- --template vue-ts
# 或者使用 yarn
yarn create vite frontend --template vue-ts
# 或者使用 pnpm
pnpm create vite frontend -- --template vue-ts
进入项目目录:
cd frontend
项目结构如下:
/frontend
├── public/ # 静态资源
├── src/
│ ├── assets/ # 静态资源(图片、样式等)
│ ├── components/ # 公共组件
│ ├── layouts/ # 布局组件
│ ├── views/ # 各页面视图
│ ├── router/ # 路由配置
│ ├── store/ # 状态管理(Vuex/Pinia)
│ ├── services/ # 接口请求服务 (Axios等)
│ ├── utils/ # 工具函数
│ ├── App.vue # 根组件
│ └── main.ts # 入口文件
├── package.json
├── tsconfig.app.json # TypeScript 配置
├── tsconfig.json # TypeScript 配置
├── tsconfig.node.json # TypeScript 配置
└── vite.config.js # Vite 配置
2. 安装依赖
// 必要的运行时依赖
// Vue Router用于管理前端路由, Pinia是Vue3推荐的状态管理库, Axios基于promise的HTTP库,
// pinia-plugin-persistedstate状态持久化插件
npm install axios pinia vue-router@4 pinia-plugin-persistedstate
// 开发依赖
// 提供Node.js的类型定义文件
npm install -D @types/node
3.配置 TypeScript
tsconfig.json
// \tsconfig.json
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
tsconfig.app.json
// \tsconfig.app.json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"ignoreDeprecations": "5.0",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"] // 将 '@/*' 映射到 'src/*'
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}
tsconfig.node.json
// \tsconfig.node.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
// 其他配置项...
"types": ["node"],
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
/* Bundler mode */
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
3.代码规范
代码规范是指定编程风格、程序结构和编码标准的文件,旨在提高代码的可读性、一致性和可维护性。使用prettier插件格式化代码,ESLint插件检测代码,参考如下配置:
prettier格式化工具
安装prettier
npm install prettier -D
配置.prettierrc文件,根目录下新建.prettierrc.js文件
module.exports = {
printWidth: 100, // 每行最多显示100个字符
tabWidth: 2, // 缩进2个字符
semi: true, // 是否加分号
vueIndentScriptAndStyle: true, // 缩进Vue文件中的脚本和样式标签
singleQuote: true, // js中使用单引号
quoteProps: "as-needed", // 仅在需要时在对象属性周围添加引号
bracketSpacing: true, // 花括号空格
trailingComma: "es5", // none - 无尾逗号 es5 - 添加es5中被支持的尾逗号 all - 所有可能的地方都被添加尾逗号
jsxBracketSameLine: false, // 使html 标签的末尾> 单独一行
jsxSingleQuote: false, // JSX中使用双引号
arrowParens: "always", // 为单行箭头函数的参数添加圆括号 (x) => x
insertPragma: false, // 不在顶部插入 @format
proseWrap: "never",
htmlWhitespaceSensitivity: "strict", // html中空格被认为是敏感的
endOfLine: "auto", // 保持现有的行尾
rangeStart: 0,
};
配置.prettierignore忽略文件,根目录下新建.prettierignore文件
/dist/*
.local
.output.js
/node_modules/**
**/*.svg
**/*.sh
/public/*
ESLint检测工具
安装插件eslint-plugin-prettier 、eslint-config-prettier
npm i eslint-plugin-prettier eslint-config-prettier -D
配置eslintrc.js文件,根目录下新建.eslintrc.js文件
module.exports = {
root: true,
"env": {
"browser": true,
"es6": true,
"node": true
},
"globals": {
"process": true,
"Plyr": true,
"AMap": true
},
"parser": "babel-eslint",
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"experimentalObjectRestSpread": true
}
},
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/typescript/recommended',
'@vue/prettier',
'@vue/prettier/@typescript-eslint',
'plugin:prettier/recommended'
],
"plugins": [
'html' // 插件,此插件用于识别文件中的js代码,没有MIME类型标识没有script标签也可以识别到,因此拿来识别.vue文件中的js代码
],
"rules": {
/**
* 代码中可能的错误或逻辑错误
*/
"no-cond-assign": ["error", "always"], // 禁止条件表达式中出现赋值操作符
"no-console": ["error", { allow: ["warn", "error"] }], // 禁用 console
"no-constant-condition": ["error", { "checkLoops": true }], // 禁止在条件中使用常量表达式
"no-control-regex": ["error"], // 禁止在正则表达式中使用控制字符
"no-debugger": ["error"], // 禁用 debugger
"no-dupe-args": ["error"], // 禁止 function 定义中出现重名参数
"no-dupe-keys": ["error"], // 禁止对象字面量中出现重复的 key
"no-duplicate-case": ["error"], // 禁止出现重复的 case 标签
"no-empty": ["error", { "allowEmptyCatch": true }], // 禁止出现空语句块
"no-empty-character-class": ["error"], // 禁止在正则表达式中使用空字符集
"no-ex-assign": ["error"], // 禁止对 catch 子句的参数重新赋值
"no-extra-boolean-cast": ["error"], // 禁止不必要的布尔转换
"no-extra-semi": ["error"], // 禁止不必要的分号
"no-func-assign": ["warn"], // 禁止对 function 声明重新赋值
"no-inner-declarations": ["error"], // 禁止在嵌套的块中出现变量声明或 function 声明
"no-invalid-regexp": ["error", { "allowConstructorFlags": [] }], // 禁止 RegExp 构造函数中存在无效的正则表达式字符串
"no-irregular-whitespace": ["error"], // 禁止在字符串和注释之外不规则的空白
"no-obj-calls": ["error"], // 禁止把全局对象作为函数调用
"no-regex-spaces": ["error"], // 禁止正则表达式字面量中出现多个空格
"no-sparse-arrays": ["error"], // 禁用稀疏数组
"no-unexpected-multiline": ["error"], // 禁止出现令人困惑的多行表达式
"no-unsafe-finally": ["error"], // 禁止在 finally 语句块中出现控制流语句
"no-unsafe-negation": ["error"], // 禁止对关系运算符的左操作数使用否定操作符
"use-isnan": ["error"], // 要求使用 isNaN() 检查 NaN
/**
* 最佳实践
*/
"default-case": ["error"], // 要求 switch 语句中有 default 分支
"dot-notation": ["error"], // 强制尽可能地使用点号
"eqeqeq": ["warn"], // 要求使用 === 和 !==
"no-caller": ["error"], // 禁用 arguments.caller 或 arguments.callee
"no-case-declarations": ["error"], // 不允许在 case 子句中使用词法声明
"no-empty-function": ["error"], // 禁止出现空函数
"no-empty-pattern": ["error"], // 禁止使用空解构模式
"no-eval": ["error"], // 禁用 eval()
"no-global-assign": ["error"], // 禁止对原生对象或只读的全局对象进行赋值
// "no-magic-numbers": ["error", { "ignoreArrayIndexes": true }], // 禁用魔术数字
"no-redeclare": ["error", { "builtinGlobals": true }], // 禁止重新声明变量
"no-self-assign": ["error", { props: true }], // 禁止自我赋值
"no-unused-labels": ["error"], // 禁用出现未使用过的标
"no-useless-escape": ["error"], // 禁用不必要的转义字符
"radix": ["error"], // 强制在parseInt()使用基数参数
/**
* 变量声明
*/
"no-delete-var": ["error"], // 禁止删除变量
"no-undef": ["error"], // 禁用未声明的变量,除非它们在 /*global */ 注释中被提到
"no-unused-vars": ["error"], // 禁止出现未使用过的变量
"no-use-before-define": ["error"], // 禁止在变量定义之前使用它们
/**
* 风格指南
*/
"array-bracket-newline": ["error", { "multiline": true }], // 在数组开括号后和闭括号前强制换行
"array-bracket-spacing": ["error", "never"], // 强制数组方括号中使用一致的空2
"block-spacing": ["error", "never"], // 禁止或强制在代码块中开括号前和闭括号后有空格
"brace-style": ["error", "1tbs",], // 强制在代码块中使用一致的大括号风格
"comma-dangle": ["error", "never"], // 要求或禁止末尾逗号
"comma-spacing": ["error", { "before": false, "after": true }], // 强制在逗号前后使用一致的空格
"comma-style": ["error", "last"], // 强制使用一致的逗号风格
"computed-property-spacing": ["error", "never"], // 强制在计算的属性的方括号中使用一致的空格
"consistent-this": ["error", "that"], // 当获取当前执行环境的上下文时,强制使用一致的命名
"eol-last": ["error", "always"], // 要求或禁止文件末尾存在空行
"func-call-spacing": ["error", "never"], // 要求或禁止在函数标识符和其调用之间有空格
"func-names": ["error", "always"], // 要求或禁止使用命名的 function 表达式
"func-style": ["error", "declaration", { "allowArrowFunctions": true }], // 强制一致地使用 function 声明或表达式
"function-paren-newline": ["error", "multiline"], // 强制在函数括号内使用一致的换行
"implicit-arrow-linebreak": ["error", "beside"], // 强制隐式返回的箭头函数体的位置
"indent": ["error", 2, { "SwitchCase": 1 }], // 两个空格缩进
"jsx-quotes": ["error", "prefer-double"], // 强制在 JSX 属性中一致地使用双引号或单引号
"key-spacing": ["error", { "beforeColon": false, "afterColon": true }], // 强制在对象字面量的属性中键和值之间使用一致的间距
"line-comment-position": ["error", { "position": "above", "ignorePattern": "ETC" }], // 强制行注释的位置
"linebreak-style": ["error", "unix"], // 换行符风格
"max-depth": ["error", 4], // 强制可嵌套的块的最大深度
"max-nested-callbacks": ["error", 3], // 强制回调函数最大嵌套深度
"max-params": ["error", 6], // 强制函数定义中最多允许的参数数量
"multiline-comment-style": ["error", "starred-block"], // 强制对多行注释使用特定风格
"multiline-ternary": ["error", "always-multiline"], // 要求或禁止在三元操作数中间换行
"new-cap": ["error", { "capIsNew": false }], // 要求构造函数首字母大写
"no-array-constructor": ["error"], // 禁用 Array 构造函数
"no-mixed-operators": ["error"], // 禁止混合使用不同的操作符
"no-mixed-spaces-and-tabs": ["error"], // 禁止空格和 tab 的混合缩进
"no-multiple-empty-lines": ["error"], // 禁止出现多行空行
"no-new-object": ["error"], // 禁用 Object 的构造函数
"no-tabs": ["error"], // 禁用 tab
"no-trailing-spaces": ["error", { "skipBlankLines": false, "ignoreComments": false }], // 禁用行尾空白
"no-whitespace-before-property": ["error"], // 禁止属性前有空白
"nonblock-statement-body-position": ["error", "beside"], // 强制单个语句的位置
"object-curly-spacing": ["error", "never"], // 强制在大括号中使用一致的空格
"operator-linebreak": ["error", "after"], // 强制操作符使用一致的换行符
"quotes": ["error", "single"], // 使用单引号
"semi": ["error", "always"], // 要求或禁止使用分号代替 ASI
"semi-spacing": ["error", { "before": false, "after": true }], // 强制分号之前和之后使用一致的空格
"space-before-function-paren": ["error", "never"], // 强制在 function的左括号之前使用一致的空格
"space-in-parens": ["error", "never"], // 强制在圆括号内使用一致的空格
"space-infix-ops": ["error"], // 要求操作符周围有空格
"space-unary-ops": ["error", { "words": true, "nonwords": false }], // 强制在一元操作符前后使用一致的空格
"spaced-comment": ["error", "always"], // 强制在注释中 // 或 /* 使用一致的空格
/**
* ECMAScript 6
*/
"arrow-spacing": ["error", { "before": true, "after": true }], // 强制箭头函数的箭头前后使用一致的空格
"no-var": ["error"], // 要求使用 let 或 const 而不是 var
"object-shorthand": ["error", "always"], // 要求或禁止对象字面量中方法和属性使用简写语法
"prefer-arrow-callback": ["error", { "allowNamedFunctions": false }], // 要求回调函数使用箭头函数
}
};
配置.eslintignore忽略文件,根目录下新建.eslintignore文件
build/*.js
src/assets
public
dist
4.UI 库和 css重置
安装相关依赖
css重置 可以帮助你消除不同浏览器默认样式的差异,确保在不同环境下应用样式的一致性。常用的CS 重置库有 normalize.css 和 reset.css。这里我们使用 normalize.css
。
UI组件库我使用的Element Plus。
如果希望使用更高级的 CSS 功能,如变量、嵌套、混入(mixins)等,可以引入 CSS 预处理器,如 Sass(SCSS)。
npm install normalize.css // 安装 normalize.css:
npm install element-plus // 安装 Element Plus
npm install -D sass // 安装 CSS 预处理器 Sass
项目中引入 Element Plus
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import ElementPlus from 'element-plus'
// 引入 Element Plus 的 SCSS
import 'element-plus/theme-chalk/src/index.scss';
const app = createApp(App);
app.use(ElementPlus, { size: 'default' });
app.mount('#app');
CSS重置和自定义Element Plus 主题
src/assets 目录下新建styles目录,新建global.scss和variables.scss文件。
global.scss
说明:
-
首先引入
normalize.css
以消除默认样式差异。 -
然后引入自定义的
element-variables.scss
文件,以覆盖 Element Plus 的默认主题变量。 -
定义了一些全局样式,如
body
、a
、button
、table
和.
page_main_container 等。
// src/assets/styles/global.scss
/* 引入 normalize.css */
@import 'normalize.css';
/* 引入 Element Plus 自定义变量 */
@import './variables.scss';
body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
background-color: $background_color;
color: $main_text;
margin: 0;
padding: 0;
}
.page_main_container {
max-width: $content_width;
margin: 0 auto;
padding: 1rem;
}
variables.scss 自定义主题变量和全局bian
// src/assets/styles/variables.scss
$primary: #19be6b;
$info: #2db7f5;
$warning: #ff9900;
$danger: #ed4014;
$title_text: #17233d; // 标题文字颜色
$main_text: #515a6e; // 内容文字颜色
$sub_text: #808695; // 次要文字颜色
$disabled: #c5c8ce; // 禁用颜色
$border_color: #dcdee2; // 边框颜色
$divider_color: #e8eaec; // 分割线颜色
$background_color: #f8f8f9; // 背景颜色
@forward "element-plus/theme-chalk/src/common/var.scss" with (
$colors: (
"primary": (
"base": $primary,
),
"warning": (
"base": $warning,
),
"info": (
"base": $info,
),
"danger": (
"base": $danger,
),
)
);
$header_height: 60px;
$content_width: 1280px;
配置 Vite以支持全局SCSS
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import path from 'path'; // 确保没有拼写错误
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'), // 设置路径别名
},
},
css: {
preprocessorOptions: {
scss: {
additionalData: `
@use "@/assets/styles/global.scss" as *;
`,
},
},
},
});
验证主题修改成功
5. Vue Router
通过Vue Router 来管理前端路由,创建src/router/index.ts:
// src/router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import Layout from '@/components/Layouts/index.vue';
const routes: Array<RouteRecordRaw> = [
{
path: '/',
component: Layout,
meta: { requiresAuth: true },
children: [
{
path: '/',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue')
},
{
path: '/users',
name: 'Users',
component: () => import('@/views/users/index.vue')
},
{
path: '/roles',
name: 'Roles',
component: () => import('@/views/roles/index.vue')
},
{
path: '/permissions',
name: 'Permissions',
component: () => import('@/views/permissions/index.vue')
},
]
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue')
},
{
path: '/register',
name: 'Register',
component: () => import('@/views/register/index.vue')
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/errorPage/404.vue')
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
// 导航守卫
router.beforeEach((to, from, next) => {
const isAuthenticated = localStorage.getItem('accessToken');
if (to.meta.requiresAuth && !isAuthenticated) {
next({ name: 'Login' });
} else {
next();
}
});
export default router;
6. 状态管理:Pinia
Pinia 是 Vue 3 推荐的状态管理库。 在 src/store
目录下创建 auth.ts
:
// src/store/auth.ts
import { defineStore } from 'pinia';
import authService from '@/api/authService';
import { User } from '@/types/api';
import router from '@/router';
import { ElNotification } from 'element-plus';
interface AuthState {
user: User | null;
accessToken: string | null;
refreshToken: any;
}
export const useAuthStore = defineStore('auth', {
state: (): AuthState => ({
user: null,
accessToken: localStorage.getItem('accessToken'),
refreshToken: localStorage.getItem('refreshToken'),
}),
getters: {
isAuthenticated: (state) => !!state.accessToken && !!state.user,
},
persist: true, // 状态持久化
actions: {
async login(params: { username: string; password: string }) {
try {
const { data } = await authService.login(params);
this.accessToken = data.token;
this.refreshToken = data.refreshToken;
localStorage.setItem('accessToken', this.accessToken);
localStorage.setItem('refreshToken', this.refreshToken);
await this.fetchUser();
ElNotification({
title: '提示',
message: '欢迎进入系统!',
type: 'success',
});
router.push({ name: 'Dashboard' });
} catch (error) {
ElNotification({
title: '登录失败',
message: '请检查用户名和密码。',
type: 'error',
});
throw error;
}
},
async register(params: { username: string; email: string; password: string }) {
try {
await authService.register(params);
ElNotification({
title: '成功',
message: '注册成功!请登录。',
type: 'success',
});
} catch (error) {
throw error;
}
},
async fetchUser() {
try {
const { data } = await authService.getUser();
this.user = data || {};
} catch (error) {
console.error('获取用户信息失败', error);
}
},
logout() {
this.user = null;
this.accessToken = null;
this.refreshToken = null;
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
router.push({ name: 'Login' });
},
async refreshToken() {
try {
const { data } = await authService.refreshToken(this.accessToken || '');
this.accessToken = data.token;
this.refreshToken = data.refreshToken;
localStorage.setItem('accessToken', this.accessToken);
localStorage.setItem('refreshToken', this.refreshToken);
await this.fetchUser();
} catch (error) {
throw error;
}
},
},
});
7. Axios 配置
创建 Axios 实例,src/services目录下创建axiosInstance.ts:
// src/services/axiosInstance.ts
import axios, { AxiosInstance, AxiosError, AxiosResponse } from 'axios';
import { useAuthStore } from '@/store/auth';
import router from '@/router';
import { ElNotification } from 'element-plus';
const axiosInstance: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api',
// baseURL: '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// 请求拦截器
axiosInstance.interceptors.request.use(
(config) => {
const authStore = useAuthStore();
const token = authStore.accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器
axiosInstance.interceptors.response.use(
(response: AxiosResponse) => {
return response.data;
},
async (error: AxiosError) => {
const authStore = useAuthStore();
const originalRequest = error.config as any;
// 如果响应状态码为 401,尝试刷新令牌
if (error.response && error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
await authStore.refreshToken(); // 调用刷新令牌的方法
originalRequest.headers['Authorization'] = `Bearer ${authStore.accessToken}`;
return axiosInstance(originalRequest);
} catch (err) {
authStore.logout();
router.push({ name: 'Login' });
ElNotification({
title: '登录过期',
message: '请重新登录。',
type: 'error',
});
return Promise.reject(err);
}
}
// 返回业务逻辑错误信息
if (error.response && error.response.data) {
return Promise.reject(error.response.data);
}
return Promise.reject(error);
}
);
export default axiosInstance;
封装http请求,src/services目录下创建apiClient.ts:
// src/services/apiClient.ts
import axiosInstance from './axiosInstance';
interface RequestOptions {
headers?: Record<string, string>;
params?: Record<string, any>;
data?: Record<string, any>;
[key: string]: any;
}
interface ApiResponse<T> {
data: T;
status: number;
statusText: string;
headers: any;
config: any;
request?: any;
}
const apiClient = {
async get<T>(url: string, options?: RequestOptions): Promise<ApiResponse<T>> {
const response = await axiosInstance.get<T>(url, {
...options,
params: options?.params,
});
return response;
},
async post<T>(url: string, data?: any, options?: RequestOptions): Promise<ApiResponse<T>> {
const response = await axiosInstance.post<T>(url, data, options);
return response;
},
async put<T>(url: string, data?: any, options?: RequestOptions): Promise<ApiResponse<T>> {
const response = await axiosInstance.put<T>(url, data, options);
return response;
},
async delete<T>(url: string, options?: RequestOptions): Promise<ApiResponse<T>> {
const response = await axiosInstance.delete<T>(url, options);
return response;
},
async patch<T>(url: string, data?: any, options?: RequestOptions): Promise<ApiResponse<T>> {
const response = await axiosInstance.patch<T>(url, data, options);
return response;
},
// 其他 HTTP 方法(如 HEAD, OPTIONS)可以根据需要添加
};
export default apiClient;
8. API接口文件
统一管理api接口,在 src/api 目录下创建接口文件xxxx.ts:
authService.ts
// src/api/authService.ts
import apiClient from '@/services/apiClient';
import { LoginResponse, RegisterResponse, RefreshTokenResponse, User } from '@/types/api';
const authService = {
login(params: { username: string; password: string }) {
return apiClient.post<LoginResponse>('/login', params);
},
register(params: { username: string; password: string; email: string }) {
return apiClient.post<RegisterResponse>('/register', params);
},
refreshToken(refreshToken: string) {
return apiClient.post<RefreshTokenResponse>('/refreshToken', { refreshToken });
},
logout() {
return apiClient.post<void>('/logout');
},
getUser() {
return apiClient.get<User>('/getUserInfo');
},
};
export default authService;
usersService.ts
// src/api/usersService.ts
import apiClient from '@/services/apiClient';
import { User, PaginatedResponse } from '@/types/api';
interface GetUsersParams {
pageNo: number;
pageSize: number;
username?: string;
email?: string;
phone?: string;
[prop: string]: any;
}
const usersService = {
// 获取用户列表
getUsers(params: GetUsersParams) {
return apiClient.get<PaginatedResponse<User>>('/users', { params });
},
// 添加用户
addUser(user: Partial<User> & { password: string }) {
return apiClient.post<User>('/users', user);
},
// 更新用户
updateUser(id: number, user: Partial<User>) {
return apiClient.put<User>(`/users/${id}`, user);
},
// 删除用户
deleteUser(id: number) {
return apiClient.delete(`/users/${id}`);
},
}
export default usersService;
roleService.ts
// src/api/rolesService.ts
import apiClient from '@/services/apiClient';
import { Role } from '@/types/api';
interface GetRolesParams {
name?: string;
}
const rolesService = {
getRoles(params: GetRolesParams) {
return apiClient.get<Role[]>('/roles', { params });
},
addRole(role: Partial<Role>) {
return apiClient.post<Role>('/roles', role);
},
updateRole(id: number, role: Partial<Role>) {
return apiClient.put<Role>(`/roles/${id}`, role);
},
deleteRole(id: number) {
return apiClient.delete(`/roles/${id}`);
},
};
export default rolesService;
permissionsService.ts
// src/api/rolesService.ts
import apiClient from '@/services/apiClient';
import { Role } from '@/types/api';
interface GetRolesParams {
name?: string;
}
const rolesService = {
getRoles(params: GetRolesParams) {
return apiClient.get<Role[]>('/roles', { params });
},
addRole(role: Partial<Role>) {
return apiClient.post<Role>('/roles', role);
},
updateRole(id: number, role: Partial<Role>) {
return apiClient.put<Role>(`/roles/${id}`, role);
},
deleteRole(id: number) {
return apiClient.delete(`/roles/${id}`);
},
};
export default rolesService;
9. API类型文件
/src/types/api.d.ts
// src/types/api.d.ts
// 认证相关
export interface LoginResponse {
token: string;
refreshToken: string;
[prop: string]: any;
}
export interface RegisterResponse {
message: string;
[prop: string]: any;
}
export interface RefreshTokenResponse {
token: string;
refreshToken: string;
[prop: string]: any;
}
// 首页统计数据
export interface DashboardStats {
userCount: number;
activeUserCount: string;
permissionCount: number;
[prop: string]: any;
}
// 分页返回数据类型
export interface PaginatedResponse<T> {
rows: T[];
total: number;
page: number;
pageSize: number;
[prop: string]: any;
}
// 用户相关
export interface User {
id: number;
username: string;
email: string;
role_id: number;
avatar: string;
phone: string;
createdAt: string;
updatedAt: string;
[prop: string]: any;
}
// 角色相关
export interface Role {
id: number;
name: string;
description?: string;
createdAt: string;
updatedAt: string;
[prop: string]: any;
}
// 权限相关
export interface Permission {
id: number;
name: string;
description?: string;
createdAt: string;
updatedAt: string;
[prop: string]: any;
}
// 其他类型定义...
10. 页面组件实现
Layouts公共布局组件,创建src/components/Layout/index.vue
<!-- src/components/Layout/index.vue -->
<template>
<div class="layout">
<Header />
<div class="page-container">
<Sidebar />
<main class="main-content">
<router-view />
</main>
</div>
<!-- <Footer /> -->
</div>
</template>
<script lang="ts" setup>
import Header from './Header.vue';
import Sidebar from './Sidebar.vue';
// import Footer from './Footer.vue';
</script>
<style scoped>
.layout {
width: 100%;
display: flex;
flex-direction: column;
}
.page-container {
width: 100%;
height: calc(100vh - 60px);
display: flex;
}
.main-content {
flex: 1;
padding: 1rem;
box-sizing: border-box;
}
</style>
头部公共组件 src/components/Layout/Header.vue
<!-- src/components/Layout/Header.vue -->
<template>
<el-header height="60px" class="header">
<div class="header-content">
<!-- 左侧系统名称 -->
<div class="logo">
<h2>用户权限系统</h2>
</div>
<!-- 右侧用户信息和操作 -->
<div class="user-info">
<el-dropdown trigger="click">
<span class="el-dropdown-link">
<el-avatar
:src="user?.avatar || defaultAvatar"
:size="36"
class="avatar"
></el-avatar>
<span class="username">{{ user?.username }}</span>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleLogout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</el-header>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useAuthStore } from '@/store/auth';
import { ElMessageBox, ElMessage } from 'element-plus';
const authStore = useAuthStore();
// 获取用户信息
const user = computed(() => authStore.user);
// 默认头像
const defaultAvatar = 'http://img.zzqlyx.com/20240903/yk.png';
// 处理退出登录
const handleLogout = () => {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info',
})
.then(() => {
authStore.logout();
})
.catch(() => {
// 用户取消退出
});
};
</script>
<style lang="scss" scoped>
.header {
background-color: $primary; /* Element Plus 默认主题色 */
display: flex;
align-items: center;
padding: 0 20px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.header-content {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo h2 {
color: white;
margin: 0;
font-size: 1.5rem;
}
.user-info {
display: flex;
align-items: center;
}
.el-dropdown-link {
display: flex;
align-items: center;
}
.avatar {
margin-right: 8px;
}
.username {
color: white;
margin-right: 16px;
cursor: pointer;
}
.guest-info {
display: flex;
align-items: center;
}
.guest-info .btn-text {
color: white;
margin-left: 8px;
}
.guest-info .btn-text:hover {
color: #ffd04b;
}
</style>
左侧菜单树公共组件 src/components/Layout/Sidebar.vue
<!-- src/components/Layout/Sidebar.vue -->
<template>
<el-aside class="sidebar">
<el-menu
:default-active="activeMenu"
class="el-menu-vertical"
router
>
<el-menu-item
v-for="item in menuItems"
:key="item.path"
:index="item.path"
>
<!-- <el-icon :component="item.icon"></el-icon> -->
<component class="el-icon" :is="item.icon"></component>
<span>{{ item.title }}</span>
</el-menu-item>
</el-menu>
</el-aside>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
House,
User,
Setting, // 使用存在的图标名称
Lock,
} from '@element-plus/icons-vue';
import type { Component } from 'vue';
interface MenuItem {
path: string;
name: string;
icon: Component;
title: string;
[prop: string]: any;
}
const menuItems: MenuItem[] = [
{ path: '/', name: 'Dashboard', icon: House, title: '仪表盘' },
{ path: '/users', name: 'Users', icon: User, title: '用户管理' },
{ path: '/roles', name: 'Roles', icon: Setting, title: '角色管理' }, // 确保使用正确的图标名称
{ path: '/permissions', name: 'Permissions', icon: Lock, title: '权限管理' },
];
const route = useRoute();
const router = useRouter();
const activeMenu = computed(() => route.path);
// 导航方法(可选)
const navigate = (path: string) => {
router.push(path);
};
</script>
<style lang="scss" scoped>
.sidebar {
width: $sidebar_width;
height: calc(100vh - $header_height); /* 减去 Header 的高度 */
}
.el-menu-vertical {
height: 100%;
border-right: none;
}
.el-menu-vertical .el-menu-item {
display: flex;
align-items: center;
}
.el-menu-vertical .el-icon {
font-size: 1rem;
}
/* 响应式设计:在小屏幕下隐藏侧边栏 */
@media (max-width: 768px) {
.sidebar {
display: none;
}
}
</style>
登录页面src/views/login.vue
<!-- src/views/login.vue -->
<template>
<div class="login-container">
<el-card class="login-card">
<h2 class="login-title">登录</h2>
<el-form :model="form" :rules="rules" ref="loginForm" label-width="80px">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="请输入用户名"></el-input>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleLogin" :loading="loading">
登录
</el-button>
</el-form-item>
</el-form>
<p class="register-link">
还没有账号?<router-link type="primary" to="/register">注册</router-link>
</p>
</el-card>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { useAuthStore } from '@/store/auth';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { FormInstance } from 'element-plus';
// 定义表单模型
interface LoginForm {
username: string;
password: string;
}
const form = ref<LoginForm>({
username: '',
password: '',
});
// 表单验证规则
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 30, message: '用户名长度在3到30个字符', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度至少6个字符', trigger: 'blur' },
],
};
const loginForm = ref<FormInstance>();
const authStore = useAuthStore();
const router = useRouter();
const loading = ref(false);
const handleLogin = async () => {
try {
await loginForm.value?.validate();
loading.value = true;
const params = { username: form.value.username, password: form.value.password };
await authStore.login(params);
router.push({ name: 'Dashboard' });
} catch (error: any) {
if (error instanceof Error) {
ElMessage.error(error.message);
} else {
// ElMessage.error('登录失败,请稍后重试');
}
} finally {
loading.value = false;
}
};
</script>
<style lang="scss" scoped>
.login-container {
width: 100vw;
height: 100vh;
// background-image: url('//img.zzqlyx.com/user-system/background.jpg'); /* 本地图片路径 */
/* 如果使用在线图片,示例:
background-image: url('https://source.unsplash.com/random/1920x1080');
*/
background-size: cover;
background-position: center;
display: flex;
justify-content: center;
align-items: center;
}
.login-card {
width: 380px;
padding: 2rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-radius: 6px;
background-color: rgba(255, 255, 255, 1); /* 半透明背景以提升可读性 */
}
.login-title {
text-align: center;
margin-bottom: 1.5rem;
font-size: 1.5rem;
}
.register-link {
font-size: 12px;
text-align: center;
margin-top: 1rem;
}
.register-link a {
color: $primary;
text-decoration: none;
}
.register-link a:hover {
text-decoration: underline;
}
</style>
首页仪表盘页面 src/views/dashboard/index.vue
<!-- src/views/dashboard/index.vue -->
<template>
<div class="dashboard">
<!-- 欢迎卡片 -->
<el-card class="box-card">
<div class="user-info">
<el-avatar :src="user.avatar || defaultAvatar" :size="258" />
<div class="user-details">
<h2>欢迎, {{ user.username }}!</h2>
<el-descriptions title="基本信息" :column="1">
<el-descriptions-item label="邮箱:">{{ user.email }}</el-descriptions-item>
<el-descriptions-item label="角色:">{{ getRoleName(user.role_id) }}</el-descriptions-item>
<el-descriptions-item label="电话:">{{ user.phone }}</el-descriptions-item>
<el-descriptions-item label="注册时间:">{{ formatDate(user.createdAt) }}</el-descriptions-item>
</el-descriptions>
</div>
</div>
</el-card>
<!-- 统计信息卡片 -->
<el-row :gutter="20" class="stat-row">
<el-col :span="8">
<el-card>
<div class="stat-card">
<el-icon><User /></el-icon>
<div class="stat-content">
<h3>总用户数</h3>
<p>{{ dashboardStats?.userCount || 0 }}</p>
</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card>
<div class="stat-card">
<el-icon><User /></el-icon>
<div class="stat-content">
<h3>活跃用户</h3>
<p>{{ dashboardStats?.activeUserCount || 0 }}</p>
</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card>
<div class="stat-card">
<el-icon><Lock /></el-icon>
<div class="stat-content">
<h3>权限数量</h3>
<p>{{ dashboardStats?.permissionCount || 0 }}</p>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useAuthStore } from '@/store/auth';
import { User, Lock } from '@element-plus/icons-vue';
import statsService from '@/api/statsService';
import type { DashboardStats } from '@/types/api';
const authStore = useAuthStore();
// 获取用户信息
const user = computed(() => authStore.user || {
username: '未登录',
email: '未登录',
role_id: 0,
phone: '未登录',
avatar: '',
createdAt: '',
});
const dashboardStats = ref<DashboardStats>();
// 默认头像
const defaultAvatar = 'http://img.zzqlyx.com/20240903/yk.png';
// 模拟角色名称映射(实际应从后端获取或在 store 中定义)
enum roles {
'超级管理员',
'管理员',
'普通用户',
};
// 获取角色名称
const getRoleName = (roleId: number): string => {
return roles[roleId] || '未知角色';
};
// 格式化日期
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleString();
};
// 获取统计数据
const getDashboardStats = async ():Promise<void> => {
try {
const { data } = await statsService.getDashboardStats();
console.log(data);
dashboardStats.value = data ?? {};
} catch (error) {
}
}
onMounted(() => {
getDashboardStats();
})
</script>
<style scoped lang="scss">
.dashboard {
.box-card {
margin-bottom: 1rem;
// padding: 1rem;
.user-info {
display: flex;
align-items: center;
.user-details {
margin-left: 1rem;
}
}
}
.stat-row {
.el-col {
.el-card {
padding: 1rem;
.stat-card {
display: flex;
align-items: center;
.el-icon {
font-size: 2rem;
margin-right: 1rem;
}
.stat-content {
h3 {
margin: 0;
font-size: 1rem;
}
p {
margin: 5px 0 0;
font-size: 1.5rem;
font-weight: bold;
color: $primary;
}
}
}
}
}
}
}
</style>
用户管理页面 src/views/users//index.vue
<!-- src/views/users.vue -->
<template>
<div class="users-container">
<!-- 搜索表单 -->
<el-card class="search-card">
<el-form :model="searchForm" inline>
<el-form-item label="用户名">
<el-input v-model="searchForm.username" placeholder="请输入用户名"></el-input>
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="searchForm.phone" placeholder="请输入手机号"></el-input>
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="searchForm.email" placeholder="请输入邮箱"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button type="primary" @click="openAddUserDialog">添加用户</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作按钮 -->
<el-card>
<!-- 用户列表表格 -->
<el-table
:data="users"
style="width: 100%"
:loading="loading"
stripe
border
>
<el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column prop="username" label="用户名" min-width="150"></el-table-column>
<el-table-column prop="phone" label="手机号" min-width="150"></el-table-column>
<el-table-column prop="email" label="邮箱" min-width="150"></el-table-column>
<el-table-column prop="createdAt" label="注册时间" min-width="150">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openEditUserDialog(row)">编辑</el-button>
<el-button type="danger" link size="small" @click="handleDeleteUser(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页组件 -->
<div class="pagination">
<el-pagination
background
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:current-page="pagination.pageNo"
:total="pagination.total"
@current-change="handlePageChange"
@size-change="handleSizeChange"
></el-pagination>
</div>
</el-card>
<!-- 添加/编辑用户的弹窗 -->
<el-dialog
:title="isEdit ? '编辑用户' : '添加用户'"
v-model="userDialogVisible"
width="500px"
@close="handleCloseDialog"
>
<el-form :model="userForm" :rules="userFormRules" ref="userFormRef" label-width="80px">
<el-form-item label="用户名" prop="username">
<el-input v-model="userForm.username" placeholder="请输入用户名"></el-input>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="userForm.email" placeholder="请输入邮箱"></el-input>
</el-form-item>
<el-form-item label="电话" prop="phone">
<el-input v-model="userForm.phone" placeholder="请输入电话"></el-input>
</el-form-item>
<el-form-item label="密码" prop="password" v-if="!isEdit">
<el-input
v-model="userForm.password"
type="password"
placeholder="请输入密码"
></el-input>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleCloseDialog">取消</el-button>
<el-button type="primary" @click="handleSubmitUser">确认</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue';
import usersService from '@/api/usersService';
import { User } from '@/types/api';
import { ElMessage, ElMessageBox } from 'element-plus';
// 搜索表单数据
const searchForm = reactive({
username: '',
email: '',
phone: '',
});
// 用户表单数据(用于添加/编辑)
const userForm = reactive({
id: null as number | null,
username: '',
email: '',
phone: '',
password: '',
});
// 表单验证规则
const userFormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '长度在 3 到 20 个字符', trigger: 'blur' },
],
email: [
{ required: false, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱格式', trigger: ['blur', 'change'] },
],
phone: [
{ required: false, message: '请输入电话', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入有效的手机号码', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少6位', trigger: 'blur' },
],
};
// 控制弹窗显示
const userDialogVisible = ref(false);
const isEdit = ref(false);
// 表单引用
const userFormRef = ref();
// 用户列表和相关状态
const users = ref<User[]>([]);
const loading = ref(false);
// 分页信息
const pagination = reactive({
pageNo: 1,
pageSize: 10,
total: 0,
});
// 格式化日期
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleString();
};
// 获取用户列表
const fetchUsers = async () => {
loading.value = true;
try {
const params = {
pageNo: pagination.pageNo,
pageSize: pagination.pageSize,
username: searchForm.username,
email: searchForm.email,
};
const { data } = await usersService.getUsers(params);
users.value = data?.rows?.length ? data.rows : [];
pagination.total = data.total;
} catch (error: any) {
ElMessage.error(error.response?.data?.message || '获取用户列表失败');
} finally {
loading.value = false;
}
};
// 搜索用户
const handleSearch = () => {
pagination.pageNo = 1;
fetchUsers();
};
// 重置搜索表单
const handleReset = () => {
searchForm.username = '';
searchForm.email = '';
searchForm.phone = '';
pagination.pageNo = 1;
fetchUsers();
};
// 打开添加用户弹窗
const openAddUserDialog = () => {
isEdit.value = false;
userForm.id = null;
userForm.username = '';
userForm.email = '';
userForm.phone = '';
userForm.password = '';
userDialogVisible.value = true;
};
// 打开编辑用户弹窗
const openEditUserDialog = (user: User) => {
isEdit.value = true;
userForm.id = user.id;
userForm.username = user.username;
userForm.email = user.email;
userForm.phone = user.phone;
userForm.password = ''; // 不显示密码
userDialogVisible.value = true;
};
// 提交用户表单(添加或编辑)
const handleSubmitUser = () => {
userFormRef.value?.validate(async (valid: boolean) => {
if (valid) {
if (isEdit.value) {
// 编辑用户
try {
await usersService.updateUser(userForm.id!, {
username: userForm.username,
email: userForm.email,
phone: userForm.phone,
});
ElMessage.success('用户编辑成功');
userDialogVisible.value = false;
fetchUsers();
} catch (error: any) {
ElMessage.error(error.response?.data?.message || '编辑用户失败');
}
} else {
// 添加用户
try {
await usersService.addUser({
username: userForm.username,
email: userForm.email,
phone: userForm.phone,
password: userForm.password,
});
ElMessage.success('用户添加成功');
userDialogVisible.value = false;
fetchUsers();
} catch (error: any) {
ElMessage.error(error.response?.data?.message || '添加用户失败');
}
}
} else {
ElMessage.error('请正确填写表单');
return false;
}
});
};
// 关闭弹窗
const handleCloseDialog = () => {
userDialogVisible.value = false;
};
// 删除用户
const handleDeleteUser = (id: number) => {
ElMessageBox.confirm('确定要删除该用户吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
try {
await usersService.deleteUser(id);
ElMessage.success('用户删除成功');
fetchUsers();
} catch (error: any) {
ElMessage.error(error.response?.data?.message || '删除用户失败');
}
})
.catch(() => {
// 取消删除
});
};
const handleSizeChange = (val: number) => {
pagination.pageSize = val;
fetchUsers();
}
// 分页更改
const handlePageChange = (val: number) => {
pagination.pageNo = val;
fetchUsers();
};
// 在组件挂载时获取用户列表
onMounted(() => {
fetchUsers();
});
</script>
<style scoped lang="scss">
.users-container {
// padding: 20px;
.search-card {
margin-bottom: 1rem;
}
.pagination {
margin-top: 1.5rem;
width: 100%;
display: flex;
justify-content: flex-end;
}
.el-table .el-button {
margin-right: 5px;
}
.el-dialog {
/* 可根据需要自定义弹窗样式 */
}
}
</style>
角色管理页面 src/views/roles//index.vue
<!-- src/views/Roles.vue -->
<template>
<div class="roles-container">
<!-- 搜索表单 -->
<el-card class="search-card">
<el-form :model="searchForm" label-width="80px" inline>
<el-form-item label="角色名称">
<el-input v-model="searchForm.name" placeholder="请输入角色名称"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button type="primary" @click="openAddRoleDialog">添加角色</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card>
<!-- 角色列表表格 -->
<el-table
:data="roles"
style="width: 100%"
:loading="loading"
stripe
border
>
<el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column prop="name" label="角色名称" width="150"></el-table-column>
<el-table-column prop="description" label="描述" min-width="200"></el-table-column>
<el-table-column prop="createdAt" label="权限" min-width="180">
<template #default="{ row }">
{{ formatPermissions(row.permissions) }}
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="更新时间" width="180">
<template #default="{ row }">
{{ formatDate(row.updatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="openEditRoleDialog(row)">编辑</el-button>
<el-button type="danger" link @click="handleDeleteRole(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 添加/编辑角色的弹窗 -->
<el-dialog
:title="isEdit ? '编辑角色' : '添加角色'"
v-model="roleDialogVisible"
width="500px"
@close="handleCloseDialog"
>
<el-form :model="roleForm" :rules="roleFormRules" ref="roleFormRef" label-width="80px">
<el-form-item label="角色名称" prop="name">
<el-input v-model="roleForm.name" placeholder="请输入角色名称"></el-input>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input :rows="2" type="textarea" v-model="roleForm.description" maxlength="100" show-word-limit placeholder="请输入描述"></el-input>
</el-form-item>
<el-form-item label="权限" prop="permissions">
<el-select
v-model="roleForm.permissions"
multiple
placeholder="请选择权限"
filterable
clearable
collapse-tags
popper-class="custom-header"
:max-collapse-tags="1"
>
<template #header>
<el-checkbox
v-model="checkAll"
:indeterminate="indeterminate"
@change="handleCheckAll"
>
全选
</el-checkbox>
</template>
<el-option
v-for="permission in permissions"
:key="permission.id"
:label="permission.name"
:value="permission.id"
></el-option>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleCloseDialog">取消</el-button>
<el-button type="primary" @click="handleSubmitRole">确认</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, watch, onMounted } from 'vue';
import rolesService from '@/api/roleService';
import permissionsService from '@/api/permissionsService';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { CheckboxValueType } from 'element-plus'
// 定义角色类型
interface Role {
id: number;
name: string;
description?: string;
createdAt: string;
updatedAt: string;
[prop: string]: any;
}
// 定义权限类型
interface Permission {
id: number;
name: string;
description?: string;
[prop: string]: any;
}
// 搜索表单数据
const searchForm = reactive({
name: '',
});
// 角色表单数据(用于添加/编辑)
const roleForm = reactive({
id: null as number | null,
name: '',
description: '',
permissions: [] as number[],
});
// 表单验证规则
const roleFormRules = {
name: [
{ required: true, message: '请输入角色名称', trigger: 'blur' },
{ min: 3, max: 20, message: '长度在 3 到 20 个字符', trigger: 'blur' },
],
description: [
{ max: 100, message: '描述最多100个字符', trigger: 'blur' },
],
permissions: [
{ type: 'array', required: true, message: '请选择至少一个权限', trigger: 'change' },
],
};
// 控制弹窗显示
const roleDialogVisible = ref(false);
const isEdit = ref(false);
// 表单引用
const roleFormRef = ref();
// 角色列表和相关状态
const roles = ref<Role[]>([]);
const loading = ref(false);
// 权限列表
const permissions = ref<Permission[]>([]);
// 格式化日期
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleString();
};
const formatPermissions = (permissions: Permission[]) => {
return permissions.map((_) => _.name).join(', ');
};
const checkAll = ref(false)
const indeterminate = ref(false)
watch(() => roleForm.permissions, (val:any) => {
if (val.length === 0) {
checkAll.value = false
indeterminate.value = false
} else if (val.length === permissions.value.length) {
checkAll.value = true
indeterminate.value = false
} else {
indeterminate.value = true
}
})
const handleCheckAll = (val: CheckboxValueType) => {
indeterminate.value = false
if (val) {
roleForm.permissions = permissions.value.map((_) => _.id)
console.log(roleForm.permissions)
} else {
roleForm.permissions = []
}
}
// 获取角色列表
const fetchRoles = async () => {
loading.value = true;
try {
const params = {
name: searchForm.name,
};
const { data } = await rolesService.getRoles(params);
roles.value = data?.length ? data : [];
} catch (error: any) {
ElMessage.error(error.response?.data?.message || '获取角色列表失败');
} finally {
loading.value = false;
}
};
// 获取权限列表
const fetchPermissions = async () => {
try {
const params = {
pageNo: 1,
pageSize: 9999,
};
const { data } = await permissionsService.getPermissions(params);
permissions.value = data?.rows?.length ? data.rows : [];
} catch (error: any) {
ElMessage.error(error.response?.data?.message || '获取权限列表失败');
}
};
// 搜索角色
const handleSearch = () => {
fetchRoles();
};
// 重置搜索表单
const handleReset = () => {
searchForm.name = '';
fetchRoles();
};
// 打开添加角色弹窗
const openAddRoleDialog = () => {
isEdit.value = false;
roleForm.id = null;
roleForm.name = '';
roleForm.description = '';
roleForm.permissions = [];
roleDialogVisible.value = true;
};
// 打开编辑角色弹窗
const openEditRoleDialog = (role: Role) => {
isEdit.value = true;
roleForm.id = role.id;
roleForm.name = role.name;
roleForm.description = role.description || '';
roleForm.permissions = role.permissions.map((_: any) => _.id);
roleDialogVisible.value = true;
};
// 提交角色表单(添加或编辑)
const handleSubmitRole = () => {
roleFormRef.value?.validate(async (valid: boolean) => {
if (valid) {
if (isEdit.value) {
// 编辑角色
try {
await rolesService.updateRole(roleForm.id!, {
name: roleForm.name,
description: roleForm.description,
permissionIds: roleForm.permissions,
});
ElMessage.success('角色编辑成功');
roleDialogVisible.value = false;
fetchRoles();
} catch (error: any) {
ElMessage.error(error.response?.data?.message || '编辑角色失败');
}
} else {
// 添加角色
try {
await rolesService.addRole({
name: roleForm.name,
description: roleForm.description,
});
ElMessage.success('角色添加成功');
roleDialogVisible.value = false;
fetchRoles();
} catch (error: any) {
ElMessage.error(error.response?.data?.message || '添加角色失败');
}
}
} else {
ElMessage.error('请正确填写表单');
return false;
}
});
};
// 关闭弹窗
const handleCloseDialog = () => {
roleDialogVisible.value = false;
};
// 删除角色
const handleDeleteRole = (id: number) => {
ElMessageBox.confirm('确定要删除该角色吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
try {
await rolesService.deleteRole(id);
ElMessage.success('角色删除成功');
fetchRoles();
} catch (error: any) {
ElMessage.error(error.response?.data?.message || '删除角色失败');
}
})
.catch(() => {
// 取消删除
});
};
// 在组件挂载时获取角色列表
onMounted(() => {
fetchRoles();
fetchPermissions();
});
</script>
<style scoped lang="scss">
.roles-container {
.search-card {
margin-bottom: 1rem;
}
.pagination {
margin-top: 1.5rem;
width: 100%;
display: flex;
justify-content: flex-end;
}
.el-table .el-button {
margin-right: 5px;
}
.el-dialog {
/* 可根据需要自定义弹窗样式 */
}
}
</style>
权限管理页面 src/views/permissions/index.vue
<!-- src/views/Permissions.vue -->
<template>
<div class="permissions-container">
<!-- 搜索表单 -->
<el-card class="search-card">
<el-form :model="searchForm" label-width="80px" inline>
<el-form-item label="权限名称">
<el-input v-model="searchForm.name" placeholder="请输入权限名称"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button type="primary" @click="openAddPermissionDialog">添加权限</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作按钮 -->
<el-card class="action-card">
<!-- 权限列表表格 -->
<el-table
:data="permissions"
style="width: 100%"
:loading="loading"
stripe
border
>
<el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column prop="name" label="权限名称" width="150"></el-table-column>
<el-table-column prop="description" label="描述" min-width="200"></el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="更新时间" width="180">
<template #default="{ row }">
{{ formatDate(row.updatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="openEditPermissionDialog(row)">编辑</el-button>
<el-button type="danger" link @click="handleDeletePermission(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页组件 -->
<div class="pagination">
<el-pagination
background
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:current-page="pagination.pageNo"
:total="pagination.total"
@current-change="handlePageChange"
@size-change="handleSizeChange"
></el-pagination>
</div>
</el-card>
<!-- 添加/编辑权限的弹窗 -->
<el-dialog
:title="isEdit ? '编辑权限' : '添加权限'"
v-model="permissionDialogVisible"
width="500px"
@close="handleCloseDialog"
>
<el-form :model="permissionForm" :rules="permissionFormRules" ref="permissionFormRef" label-width="80px">
<el-form-item label="权限名称" prop="name">
<el-input v-model="permissionForm.name" placeholder="请输入权限名称"></el-input>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="permissionForm.description" placeholder="请输入描述"></el-input>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleCloseDialog">取消</el-button>
<el-button type="primary" @click="handleSubmitPermission">确认</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import permissionsService from '@/api/permissionsService';
// 定义权限类型
interface Permission {
id: number;
name: string;
description?: string;
createdAt: string;
updatedAt: string;
}
// 搜索表单数据
const searchForm = reactive({
name: '',
});
// 权限表单数据(用于添加/编辑)
const permissionForm = reactive({
id: null as number | null,
name: '',
description: '',
});
// 表单验证规则
const permissionFormRules = {
name: [
{ required: true, message: '请输入权限名称', trigger: 'blur' },
{ min: 3, max: 50, message: '长度在 3 到 50 个字符', trigger: 'blur' },
],
description: [
{ max: 200, message: '描述最多200个字符', trigger: 'blur' },
],
};
// 控制弹窗显示
const permissionDialogVisible = ref(false);
const isEdit = ref(false);
// 表单引用
const permissionFormRef = ref();
// 权限列表和相关状态
const permissions = ref<Permission[]>([]);
const loading = ref(false);
// 分页信息
const pagination = reactive({
pageNo: 1,
pageSize: 10,
total: 0,
});
// 格式化日期
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleString();
};
// 获取权限列表
const fetchPermissions = async () => {
loading.value = true;
try {
const params = {
pageNo: pagination.pageNo,
pageSize: pagination.pageSize,
name: searchForm.name,
};
const { data } = await permissionsService.getPermissions(params);
permissions.value = data.rows;
pagination.total = data.total;
} catch (error: any) {
ElMessage.error(error.response?.data?.message || '获取权限列表失败');
} finally {
loading.value = false;
}
};
// 搜索权限
const handleSearch = () => {
pagination.pageNo = 1;
fetchPermissions();
};
// 重置搜索表单
const handleReset = () => {
searchForm.name = '';
pagination.pageNo = 1;
fetchPermissions();
};
// 打开添加权限弹窗
const openAddPermissionDialog = () => {
isEdit.value = false;
permissionForm.id = null;
permissionForm.name = '';
permissionForm.description = '';
permissionDialogVisible.value = true;
};
// 打开编辑权限弹窗
const openEditPermissionDialog = (permission: Permission) => {
isEdit.value = true;
permissionForm.id = permission.id;
permissionForm.name = permission.name;
permissionForm.description = permission.description || '';
permissionDialogVisible.value = true;
};
// 提交权限表单(添加或编辑)
const handleSubmitPermission = () => {
permissionFormRef.value?.validate(async (valid: boolean) => {
if (valid) {
if (isEdit.value) {
// 编辑权限
try {
await permissionsService.updatePermission(permissionForm.id!, {
name: permissionForm.name,
description: permissionForm.description,
});
ElMessage.success('权限编辑成功');
permissionDialogVisible.value = false;
fetchPermissions();
} catch (error: any) {
ElMessage.error(error.response?.data?.message || '编辑权限失败');
}
} else {
// 添加权限
try {
await permissionsService.addPermission({
name: permissionForm.name,
description: permissionForm.description,
});
ElMessage.success('权限添加成功');
permissionDialogVisible.value = false;
fetchPermissions();
} catch (error: any) {
ElMessage.error(error.response?.data?.message || '添加权限失败');
}
}
} else {
ElMessage.error('请正确填写表单');
return false;
}
});
};
// 关闭弹窗
const handleCloseDialog = () => {
permissionDialogVisible.value = false;
};
// 删除权限
const handleDeletePermission = (id: number) => {
ElMessageBox.confirm('确定要删除该权限吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
try {
await permissionsService.deletePermission(id);
ElMessage.success('权限删除成功');
fetchPermissions();
} catch (error: any) {
ElMessage.error(error.response?.data?.message || '删除权限失败');
}
})
.catch(() => {
// 取消删除
});
};
const handleSizeChange = (val: number) => {
pagination.pageSize = val;
fetchPermissions();
}
// 分页更改
const handlePageChange = (val: number) => {
pagination.pageNo = val;
fetchPermissions();
};
// 在组件挂载时获取权限列表
onMounted(() => {
fetchPermissions();
});
</script>
<style scoped lang="scss">
.permissions-container {
.search-card {
margin-bottom: 1rem;
}
.pagination {
margin-top: 1.5rem;
width: 100%;
display: flex;
justify-content: flex-end;
}
.el-table .el-button {
margin-right: 5px;
}
.el-dialog {
/* 可根据需要自定义弹窗样式 */
}
}
</style>
11. 入口文件main.ts
import { createApp } from 'vue'
import App from './App.vue'
import ElementPlus from 'element-plus'
import 'element-plus/theme-chalk/src/index.scss'; // 引入 Element Plus 的 SCSS
import router from './router';
import { createPinia } from 'pinia';
import createPersistedState from 'pinia-plugin-persistedstate' // 状态持久化
const pinia = createPinia();
pinia.use(createPersistedState);
const app = createApp(App);
app.use(ElementPlus, { size: 'default' });
app.use(pinia);
app.use(router);
app.mount('#app');
11. vite.config.ts
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import path from 'path'; // 确保没有拼写错误
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'), // 设置路径别名
},
},
server: {
port: 8888,
proxy: {
// 代理 /api 开头的请求到后端服务器
'/api': {
target: 'http://localhost:3000/', // 后端服务器地址
changeOrigin: true, // 是否改变源头
rewrite: (path) => path.replace(/^\/api/, '/api'), // 重写路径,如果需要
secure: false, // 如果后端使用了自签名证书,可以设置为 false
},
},
},
css: {
preprocessorOptions: {
scss: {
additionalData: `
@use "@/assets/styles/global.scss" as *;
`,
api: 'modern-compiler', // or 'modern'
},
},
},
});
12. 启动项目
npm run dev
登录页面截图:
首页:
角色管理: 权限管理:
总结
- 登录、注册、权限、角色、用户管理等模块:已经分别实现了对应页面的增删查改功能,以及角色和权限关联。
- 状态管理:使用了 Pinia 进行状态管理,使用
localStorage
+ pinia-plugin-persistedstate 插件实现用户信息和权限的持久化。 - Vue Router: 使用了vue router进行路由管理
下一步计划
- 进一步完善功能:增加其他内容
- 部署:项目的打包和部署方案
- 其他
项目地址:vue3+node+typescript全栈用户认证与授权系统https://gitee.com/zzqlyx/manage-system-demo