通过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

相关推荐
用户3157476081358 天前
成为程序员的必经之路” Git “,你学会了吗?
面试·github·全栈
柳叶寒16 天前
医院信息化与智能化系统(17)
java·nacos·gateway·全栈·项目
柳叶寒20 天前
医院信息化与智能化系统(15)
java·数据库·全栈·项目
kevinyan22 天前
Go项目Error的统一规划管理和处理策略
前端·go·全栈
柳叶寒1 个月前
医院信息化与智能化系统(8)
java·数据库·全栈·项目
柳叶寒1 个月前
医院信息化与智能化系统(6)
java·全栈·项目
余生H1 个月前
前端全栈混合之路Deno篇:Deno2.0如何快速创建http一个 restfulapi/静态文件托管应用及oak框架介绍
javascript·http·restful·全栈·deno
kevinyan1 个月前
Go日志门面的设计与实现-自动注入追踪ID标记代码位置、简化日志操作
vue.js·go·全栈
柳叶寒1 个月前
医院信息化与智能化系统(5)
java·数据库·全栈
余生H1 个月前
大模型进阶微调篇(一):以定制化3B模型为例,各种微调方法对比-选LoRA还是PPO,所需显存内存资源为多少?
人工智能·深度学习·机器学习·全栈·模型微调