通过Express + Vue3从零构建一个用户认证与授权系统(三)前端应用工程构建

前言 {#content_views}

接下来,我们将使用 Vue 3TypeScriptVite 构建一个前端应用,与之前构建的后端 API 无缝对接。此前端将处理用户认证、显示用户数据、管理角色和权限,并确保与后端的安全通信。首先,我们来构建一个满足基本开发的前端应用工程。

1.项目初始化

首先,使用 Vite 快速创建一个 Vue 3 + TypeScript 项目,我这里使用是npm。

java 复制代码
# 使用 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

进入项目目录:

java 复制代码
cd frontend

项目结构如下:

javascript 复制代码
/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. 安装依赖

TypeScript 复制代码
// 必要的运行时依赖
// 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

javascript 复制代码
// \tsconfig.json
{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ]
}

tsconfig.app.json

javascript 复制代码
// \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

javascript 复制代码
// \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格式化工具 {#BSKql}

安装prettier{#NhZyM}

javascript 复制代码
npm install prettier -D

配置.prettierrc文件,根目录下新建.prettierrc.js文件

javascript 复制代码
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文件

javascript 复制代码
/dist/*
.local
.output.js
/node_modules/**

**/*.svg
**/*.sh

/public/*
ESLint检测工具

安装插件eslint-plugin-prettier 、eslint-config-prettier

javascript 复制代码
npm i eslint-plugin-prettier eslint-config-prettier -D

配置eslintrc.js文件,根目录下新建.eslintrc.js文件

javascript 复制代码
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文件

javascript 复制代码
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)。

java 复制代码
npm install normalize.css // 安装 normalize.css:

npm install element-plus // 安装 Element Plus

npm install -D sass // 安装 CSS 预处理器 Sass 

项目中引入 Element Plus

TypeScript 复制代码
// 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 的默认主题变量。

  • 定义了一些全局样式,如 bodyabuttontable.page_main_container 等。

css 复制代码
// 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

css 复制代码
// 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

TypeScript 复制代码
// 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:

TypeScript 复制代码
// 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

TypeScript 复制代码
// 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:

TypeScript 复制代码
// 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:

TypeScript 复制代码
// 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

TypeScript 复制代码
// 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

TypeScript 复制代码
// 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

TypeScript 复制代码
// 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

TypeScript 复制代码
// 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

TypeScript 复制代码
// 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

html 复制代码
<!-- 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

html 复制代码
<!-- 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

html 复制代码
<!-- 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

html 复制代码
<!-- 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

html 复制代码
<!-- 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

html 复制代码
<!-- 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

html 复制代码
<!-- 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

html 复制代码
<!-- 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

TypeScript 复制代码
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

TypeScript 复制代码
// 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. 启动项目

html 复制代码
npm run dev

登录页面截图:

首页:

角色管理: 权限管理:

总结

  • 登录、注册、权限、角色、用户管理等模块:已经分别实现了对应页面的增删查改功能,以及角色和权限关联。
  • 状态管理 :使用了 Pinia 进行状态管理,使用 localStorage + pinia-plugin-persistedstate 插件实现用户信息和权限的持久化。
  • **Vue Router:**使用了vue router进行路由管理

下一步计划

  1. 进一步完善功能:增加其他内容
  2. 部署:项目的打包和部署方案
  3. 其他

项目地址:vue3+node+typescript全栈用户认证与授权系统https://gitee.com/zzqlyx/manage-system-demo

相关推荐
梦兮林夕13 天前
Next.js 技术选型攻略:最佳实践与工具推荐
前端·全栈·next.js
梦兮林夕14 天前
提升用户体验:Next.js 中的 Loading UI 和流式渲染
前端·全栈·next.js
Xiaobaiforgod15 天前
后端技术进阶知识总结
前端·后端·ai·大模型·llm·全栈
moonless022224 天前
【GISer精英计划_06】WebGIS全栈技术核心以防汛系统为例
websocket·gis·全栈
竺梓君1 个月前
JavaScript内存管理机制解析
javascript·全栈
进击的松鼠1 个月前
【Nuxt 实战】02-集成 Pinia、新增导航栏、暗黑模式
前端·全栈·nuxt.js
用户3157476081351 个月前
成为程序员的必经之路” Git “,你学会了吗?
面试·github·全栈
柳叶寒2 个月前
医院信息化与智能化系统(17)
java·nacos·gateway·全栈·项目
柳叶寒2 个月前
医院信息化与智能化系统(15)
java·数据库·全栈·项目
kevinyan2 个月前
Go项目Error的统一规划管理和处理策略
前端·go·全栈