MeteorCat / 前端搭建 - 加强版

Created Sun, 26 Jan 2025 20:52:24 +0800 Modified Wed, 29 Oct 2025 23:25:05 +0800
5584 Words

初始项目搭建

这里实际上参照之前 Vue+ElementUI脚手架搭建 速成搭建项目, 但是需要对自身定制化处理时候就太过复杂, 裁剪之后发现太多没用的依赖并且业务依赖也比较复杂, 所以审视之后发现还是自己重新初始化再编写业务方便.

另外默认 ElementUI 是没有 CssBaseline 的 CSS 初始化组件, 并且找到模板也是依赖 iframe 渲染

基本上先引入以下这些已经足够编写基础框架, 后续就是按照自己方法扩展:

# 构建个Vue项目, 这里构建 fusion-admin 基础库
npm create vue@latest

# 正式依赖
npm install mitt vue-router pinia pinia-plugin-persistedstate axios echarts lodash-es --save 

# 开发依赖
npm install @tsconfig/node22 @types/node @vitejs/plugin-vue @vue/eslint-config-prettier @vue/eslint-config-typescript @vue/tsconfig eslint eslint-plugin-vue jiti npm-run-all2 prettier typescript vite vite-plugin-vue-devtools vue-tsc unplugin-auto-import unplugin-vue-components @types/lodash-es esbuild  --save-dev

最后生成主要配置 package.json :

{
  "name": "fusion-admin",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "run-p type-check \"build-only {@}\" --",
    "preview": "vite preview",
    "build-only": "vite build",
    "type-check": "vue-tsc --build",
    "lint": "eslint . --fix",
    "format": "prettier --write src/"
  },
  "dependencies": {
    "pinia": "^2.3.1",
    "vue": "^3.5.13",
    "vue-router": "^4.5.0"
  },
  "devDependencies": {
    "@tsconfig/node22": "^22.0.0",
    "@types/node": "^22.10.7",
    "@vitejs/plugin-vue": "^5.2.1",
    "@vue/eslint-config-prettier": "^10.1.0",
    "@vue/eslint-config-typescript": "^14.3.0",
    "@vue/tsconfig": "^0.7.0",
    "eslint": "^9.18.0",
    "eslint-plugin-vue": "^9.32.0",
    "jiti": "^2.4.2",
    "npm-run-all2": "^7.0.2",
    "prettier": "^3.4.2",
    "typescript": "~5.7.3",
    "vite": "^6.0.11",
    "vite-plugin-vue-devtools": "^7.7.0",
    "vue-tsc": "^2.2.0"
  }
}

这里实际上不需要处理其他配置, 主要 vite.config.ts 需要改动下:

import {fileURLToPath, URL} from 'node:url'

import {ConfigEnv, defineConfig, loadEnv, UserConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'

// https://vite.dev/config/
const viteConfig = ({mode}: ConfigEnv): UserConfig => {
    const {VITE_PORT, VITE_OPEN, VITE_BASE_PATH, VITE_OUT_DIR} = loadEnv(mode, process.cwd())
    return defineConfig({
        plugins: [
            vue(),
            vueDevTools(),
        ],
        root: process.cwd(),
        base: VITE_BASE_PATH,
        server: {
            port: parseInt(VITE_PORT, 10),
            open: VITE_OPEN != 'false',
        },
        resolve: {
            alias: {
                '@': fileURLToPath(new URL('./src', import.meta.url)),
            },
        },
        build: {
            outDir: VITE_OUT_DIR,
        },
    })
}

// export configuration
export default viteConfig

之后创建 .env.development 测试配置:

# port 端口号, 正式版不需要
VITE_PORT = 3000

# open 运行 npm run dev 时自动打开浏览器, 正式版不需要
VITE_OPEN = false

# base路径
VITE_BASE_PATH = './'

# 测试环境接口地址 - 尾部无需带'/'
VITE_AXIOS_BASE_URL = 'http://localhost:8080'

一般正式版本配置不需要放入版本库, 自动打包时候配置复制之后自动打包写入( .env.production ):

# base路径
VITE_BASE_PATH = '/'

# 导出路径
VITE_OUT_DIR = 'dist'

# 线上环境接口地址
VITE_AXIOS_BASE_URL = 'https://api.meteorcat.com'

这样启动之后就没问题了, 这样其实默认处理化就足够了.

UI选择

再多次评估之后发现目前 ElementUI 缺失太多了, 很多组件都比较简陋且官方样例也比较少, 所以在考虑多次最终采用更加高度集成的 Ant Design of Vue.

# 安装最新版本 ant-design-vue
npm install [email protected] @ant-design/icons-vue --save

# 安装按需引入插件
npm install unplugin-vue-components -D

最后引入插件配置的 vite.config.ts 文件:

import {fileURLToPath, URL} from 'node:url'

import {ConfigEnv, defineConfig, loadEnv, UserConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import Components from 'unplugin-vue-components/vite'
import {AntDesignVueResolver} from 'unplugin-vue-components/resolvers'

// https://vite.dev/config/
const viteConfig = ({mode}: ConfigEnv): UserConfig => {
    const {VITE_PORT, VITE_OPEN, VITE_BASE_PATH, VITE_OUT_DIR} = loadEnv(mode, process.cwd())
    return defineConfig({
        plugins: [
            vue(),
            vueDevTools(),
            Components({
                resolvers: [
                    AntDesignVueResolver({
                        importStyle: false // css in js
                    })
                ]
            })
        ],
        root: process.cwd(),
        base: VITE_BASE_PATH,
        server: {
            port: parseInt(VITE_PORT, 10),
            open: VITE_OPEN != 'false'
        },
        resolve: {
            alias: {
                '@': fileURLToPath(new URL('./src', import.meta.url))
            }
        },
        build: {
            outDir: VITE_OUT_DIR
        }
    })
}

// export configuration
export default viteConfig

现在可以保存版本库作为初始化了, 这就是最原始脚手架版本, 后续可以用来扩展编写.

业务初始化

这里需要针对 src 的目录做具体的初始化功能分配( 不存在目录需要自己创建 ):

  • api: 网络请求目录
  • assets: 静态资源目录
  • components: 自定义组件目录
  • router: 功能页面路由目录
  • stores: 全局 pinia 状态管理库
  • views: 业务页面目录
  • utils: 全局共享工具目录
  • layouts: 分块布局目录, 一般也只有 dashboard 面板需要

这里就是基础的功能目录划分, 内部的的文件可以直接删除清空只保留 App.vue|main.ts|src/router/index.ts.

当然删除会直接报错, 所以现在可以处理下这些报错问题( src/main.ts 需要追加些依赖 )

import {createApp} from 'vue'
import {createPinia} from 'pinia'

import App from './App.vue'
import router from './router'
import mitt from 'mitt'

const app = createApp(App)

app.use(createPinia())
app.use(router)

// 采用 mitt 作为全局事件处理
app.config.globalProperties.eventBus = mitt()

app.mount('#app')

路由配置直接清空不存在的组件( src/router/index.ts ):

import {createRouter, createWebHistory} from 'vue-router'

// import.meta.env.BASE_URL 需要扩展外部配置
const router = createRouter({
    history: createWebHistory(import.meta.env.BASE_URL),
    routes: [],
})

export default router

这里是 env.d.ts 扩展技巧, 可以从外部定义全局配置环境:

/// <reference types="vite/client" />

// 扩展 env 配置参数
interface ImportMetaEnv {
    readonly VITE_BASE_URL: string // 页面路径
    readonly VITE_BASE_TITLE: string // 后台title
}

interface ImportMeta {
    readonly env: ImportMetaEnv
}

后续就是在 .env 对应配置当中设置配置即可:

# 标题内容
VITE_BASE_TITLE = '测试后台'

# base-http请求路径, 正式环境切换成正式域名
VITE_BASE_URL = 'http://localhost:3000'

按照官方 antd-vue 配置个初始化功能( App.vue ) :


<script lang="ts" setup>
  // 引入样式全局初始化
  import 'ant-design-vue/dist/reset.css'
</script>

<!-- 模板渲染 -->
<template>
  <a-config-provider :component-size="componentSize">
    <a-button @click="tick">ClickMe</a-button>
    <p v-show="show">{{ message }}</p>
  </a-config-provider>
</template>

<script lang="ts">

  export default {
    data() {
      return {
        show: false,
        componentSize: 'middle',
        message: 'Hello,World',
      }
    },
    methods: {
      tick: function () {
        this.componentSize = this.show ? 'middle' : 'large'
        this.show = !this.show
      },
    },
  }
</script>

OK, 运行之后没问就可以上传版本库了, 初始化已经完成了!

界面设计

如果不是长期从事前端开发可能这时候就被界面设计给拦住, 因为目前 Ant-Vue 的并没有太多关于 vue 示例, 之前大部分都是基于 react 设计出来的样例, 没办法直接迅速铺设模板之后上手来用.

但是需要知道 css 并不是某个开发语言独有的, 可以参考其他语言的模板来套用, 之后从重新vue复写功能就行了.

这里先编写 Login 界面功能处理, 按照之前了解的如果是页面就是在 views 构建分类组件, 这里想构建几个后台系统必有的界面:

  • src/views/dashboardView.vue: 面板
  • src/views/loginView.vue: 登录
  • src/views/common/error/error404View.vue: 404错误

App.vue 需要处理路由渲染功能:


<script lang="ts" setup>
  // 引入样式全局初始化
  import 'ant-design-vue/dist/reset.css'
</script>

<!-- 模板渲染 -->
<template>
  <a-config-provider>
    <RouterView/>
  </a-config-provider>
</template>

之后路由文件处理下:

import {createRouter, createWebHistory} from 'vue-router'
import LoginView from '@/views/loginView.vue'
import DashboardView from '@/views/dashboardView.vue'
import Error404View from '@/views/common/error/error404View.vue'

const router = createRouter({
    history: createWebHistory(import.meta.env.VITE_BASE_URL),
    routes: [
        {
            path: '/',
            redirect: function (to) {
                return {
                    name: 'login',
                    params: {
                        to: JSON.stringify({
                            path: to.path,
                            query: to.query,
                        }),
                    },
                }
            },
        },
        {
            name: 'login',
            path: '/login',
            component: LoginView,
        },
        {
            name: 'dashboard',
            path: '/dashboard',
            component: DashboardView,
        },

        // 404 页面匹配
        {
            path: '/:path(.*)*',
            redirect: '/404',
        },
        {
            path: '/404',
            name: 'notFound',
            component: Error404View,
            meta: {
                title: '页面不存在', // 页面不存在
            },
        },
    ],
})

export default router

现在需要做的就是在 loginView 之中负责处理登录界面编写了, 现在默认访问界面都是跳转 /login.

这里登录首页渲染其实也可以直接找 antd 官方自带的 样例 即可:

<!-- 模板 -->
<template>
  <div>
    <a-form
        :model="formState"
        name="loginForm"
        class="loginForm"
        @finish="onFinish"
        @finishFailed="onFinishFailed"
    >
      <a-form-item
          label="账号"
          name="username"
          :rules="[{ required: true, message: '请输入账号!' }]"
      >
        <a-input v-model:value="formState.username">
          <template #prefix>
            <UserOutlined class="site-form-item-icon"/>
          </template>
        </a-input>
      </a-form-item>

      <a-form-item
          label="密码"
          name="password"
          :rules="[{ required: true, message: '请输入账号密码!' }]"
      >
        <a-input-password v-model:value="formState.password">
          <template #prefix>
            <LockOutlined class="site-form-item-icon"/>
          </template>
        </a-input-password>
      </a-form-item>

      <a-form-item>
        <a-button :disabled="disabled" type="primary" html-type="submit" class="loginFormButton">
          登录
        </a-button>
      </a-form-item>
    </a-form>
  </div>
</template>

<!-- 启动引入 -->
<script setup lang="ts">
  import {UserOutlined, LockOutlined} from '@ant-design/icons-vue'
  import {computed, reactive} from 'vue'

  // 表单状态结构
  interface FormState {
    username: string
    password: string
  }

  // 表单默认状态
  const formState = reactive<FormState>({
    username: '',
    password: '',
  })

  // 校验成功回调
  const onFinish = (values: typeof formState) => {
    console.log('Success:', values)
  }

  // 校验错误
  const onFinishFailed = (errorInfo: typeof formState) => {
    console.log('Failed:', errorInfo)
  }

  // 禁用登录, 不允许输入空账户和密码
  const disabled = computed(() => {
    return !(formState.username && formState.password)
  })
</script>

<!-- 本地样式 -->
<style scoped>
  .loginForm {
    max-width: 300px;
  }

  .loginFormButton {
    width: 100%;
  }
</style>

之后就能看到对应的账号密码输入页面, 虽然目前功能漂浮在左上角, 但是基本上已经满足业务功能, 接下来就是让界面居中显示并且展示底框让其看起来更加美观, 改改之后应该就能直接用:

<!-- 模板 -->
<template>
  <a-space direction="vertical" :style="{ width: '100%' }">
    <a-layout>
      <a-layout-header
          :style="{
          textAlign: 'center',
          fontWeight: '400 !important',
          height: 64,
          paddingInline: 50,
          lineHeight: '64px',
          backgroundColor: '#f5f5f5',
        }"
      >
        <h1>{{ formTitle }}</h1>
      </a-layout-header>
      <a-layout-content>
        <a-form
            :model="formState"
            name="loginForm"
            class="loginForm"
            @finish="onFinish"
            @finishFailed="onFinishFailed"
        >
          <a-form-item
              label="账号"
              name="username"
              class="loginFormItem"
              :rules="[{ required: true, message: '请输入账号!' }]"
          >
            <a-input v-model:value="formState.username">
              <template #prefix>
                <UserOutlined class="site-form-item-icon"/>
              </template>
            </a-input>
          </a-form-item>

          <a-form-item
              label="密码"
              name="password"
              class="loginFormItem"
              :rules="[{ required: true, message: '请输入账号密码!' }]"
          >
            <a-input-password v-model:value="formState.password">
              <template #prefix>
                <LockOutlined class="site-form-item-icon"/>
              </template>
            </a-input-password>
          </a-form-item>

          <a-form-item>
            <a-button
                :disabled="disabled"
                type="primary"
                size="large"
                html-type="submit"
                class="loginFormButton"
            >
              登录
            </a-button>
          </a-form-item>
        </a-form>
      </a-layout-content>

      <a-divider/>
      <a-layout-footer>
        <span>Copyright © 2025 MeteorCat</span>
      </a-layout-footer>
    </a-layout>
  </a-space>
</template>

<!-- 启动引入 -->
<script setup lang="ts">
  import {UserOutlined, LockOutlined} from '@ant-design/icons-vue'
  import {computed, reactive} from 'vue'

  // 外部定义的后台 title
  const formTitle = import.meta.env.VITE_BASE_TITLE

  // 表单状态结构
  interface FormState {
    username: string
    password: string
  }

  // 表单默认状态
  const formState = reactive<FormState>({
    username: '',
    password: '',
  })

  // 校验成功回调
  const onFinish = (values: typeof formState) => {
    console.log('Success:', values)
  }

  // 校验错误
  const onFinishFailed = (errorInfo: typeof formState) => {
    console.log('Failed:', errorInfo)
  }

  // 禁用登录, 不允许输入空账户和密码
  const disabled = computed(() => {
    return !(formState.username && formState.password)
  })
</script>

<!-- 本地样式需要切换外部影响样式才能影响 #app 节点 -->
<style>
  html,
  body {
    height: 100%;
  }

  body {
    display: -ms-flexbox;
    display: flex;
    -ms-flex-align: center;
    align-items: center;
    padding-top: 40px;
    padding-bottom: 40px;
    background-color: #f5f5f5;
  }

  #app {
    width: 100%;
    max-width: 360px;
    padding: 15px;
    margin: auto;
  }

  .loginFormItem {
    position: relative;
    margin-bottom: 1rem;
  }

  .loginFormButton {
    width: 100%;
    margin-top: 40px;
  }
</style>

启动之后就可以看到比较朴素的登录授权界面, 并且后台登录界面居中看起来也还行, 之后准备封装 API 请求.

API封装

API的封装首选需要和后端确定好响应数据和响应格式, 我这边内部系统返回采用 RestApi 高度集成;
以响应码来做区分, 比如 200 代表通用成功, 400 代表通用失败, 401 代表授权失效, 并且以 Bearer Token 头信息传递授权 Token 来处理登录状态.

响应格式常规如下:

{
  // 标准 UTC 时间
  "datetime": "2022-08-15T12:30:45.678Z",
  "message": "OK",
  // 如果带有数据就是 object, 如果没有则是null
  "data": null
}

确定好具体格式就可以处理对应的 axios 封装功能, 需要说明之前element模板当中的 createAxios 函数对我来说感觉有点封装过度的问题, 他把 adminBaseRoute 这种入口业务代码集成其中很容易导致功能耦合.

理想的封装就是直接把 antd 的全局 messsage 组件绑定加载中弹窗而后暴露外层 Promise 让后续业务去再封装.

import type {AxiosRequestConfig} from 'axios'
import axios from 'axios'
import {message} from 'ant-design-vue'
import type {ConfigOnClose, MessageType} from 'ant-design-vue/es/message'
import type {VueNode} from 'ant-design-vue/es/_util/type'

/**
 * 服务端 Rest 响应格式
 */
export interface RestResponse<T = object | null> {
    message: string
    datetime: string
    data: T
}

/**
 * 响应异步类型
 */
export type RestPromise<T = object | null> = Promise<RestResponse<T>>

/**
 * 扩展 Axios 配置
 */
interface AxiosOptions {
    /**
     * 请求的超时毫秒, 默认 10000
     */
    timeout?: number

    /**
     * 请求的全局标识Key, 默认每次调用会生成唯一key
     */
    key?: string

    /**
     *  响应类型, 默认为 json
     */
    responseType?: string

    /**
     * 是否开启loading层效果, 默认为true
     */
    loading?: boolean
}

/**
 * 扩展 Axios 弹窗
 */
interface AxiosMessage {
    /**
     * 全局标识Key, 用于自定义销毁
     */
    key: string | null

    /**
     * 弹窗内容, 默认正在 '正在请求......'
     */
    content: string | (() => VueNode) | VueNode

    /**
     * 关闭回调
     */
    onClose?: ConfigOnClose

    /**
     * 关闭之后处理
     */
    afterClose?: MessageType
}

/**
 * 根据运行环境获取基础请求URL
 */
export const getUrl = (): string => {
    const value = import.meta.env.VITE_AXIOS_BASE_URL
    return typeof value == 'string'
        ? (value as string)
        : window.location.protocol + '//' + window.location.host
}

/**
 * 随机生成请求Key
 */
export const getRequestKey = (): string => {
    const rand = Math.random().toString(36).substring(2, 15)
    return `axios-request-${Date.now()}-${rand}`
}

/**
 * Axios句柄
 * @param axiosConfig
 * @param options
 * @param loading
 */
export function createAxios<Data = object | null, T = RestPromise<Data>>(
    axiosConfig: AxiosRequestConfig,
    options: AxiosOptions = {},
    loading: AxiosMessage | null = null,
): T {
    // 合并Axios参数
    options = Object.assign(
        {
            key: getRequestKey(),
            timeout: 10000,
            loading: true,
            responseType: 'json',
        },
        options,
    )

    // 构建 Axios配置
    const Axios = axios.create({
        baseURL: getUrl(),
        timeout: options.timeout,
        headers: {
            server: true,
        },
        responseType: options.responseType,
    })

    // 合并 message 参数
    loading = options.loading
        ? Object.assign(
            {
                key: options.key,
                content: '正在请求......',
            },
            loading,
        )
        : {}

    // 请求设置
    Axios.interceptors.request.use(
        (config) => {
            // 是否展示loading设置
            if (options.loading) {
                // 调用弹窗
                message
                    .open({
                        type: 'loading',
                        duration: options.timeout / 1000, // 转为秒单位
                        key: loading.key,
                        content: loading.content,
                    })
                    .then(loading.afterClose)
            }
            return config
        },
        (error) => {
            if (options.loading) {
                // 销毁弹窗
                message.destroy(loading.key)
            }
            return Promise.reject(error)
        },
    )

    // 响应拦截
    Axios.interceptors.response.use(
        (response) => {
            // 关闭加载窗口
            if (options.loading) {
                message.destroy(loading.key as string)
            }
            return response
        },
        (error) => {
            // 错误响应
            if (options.loading && loading != null) {
                message.destroy(loading.key as string)
            }
            return Promise.reject(error)
        },
    )

    return Axios(axiosConfig) as T
}

这样直接调用就能看到具体效果:

// 创建网络请求
createAxios({
    url: 'http://localhost:3000',
    method: 'get',
}).then(
    (response) => {
        // 成功回调
        console.log(response)
        message.success('登录成功')
    },
    (error) => {
        // 失败回调
        message.error(error)
    },
)

后续把请求接口封装在 src/api 目录之中就行了, 创建 src/api/common.ts 文件用于 api 目录其他文件调用, 内部封装所需的各种授权标识字段等信息, 包括文件上传提交获取共用信息数据接口也可以集成其中.

首先封装最基本的授权处理业务模块 src/api/authority.ts:

import {createAxios} from '@/utils/axios.ts'

/**
 * 请求路径
 */
export const url = '/authority'

/**
 * 登录请求
 */
export function login(params: object = {}) {
    return createAxios({
        method: 'POST',
        url: `${url}/login`,
        data: params,
    })
}

/**
 * 登出请求
 */
export function logout() {
    return createAxios({
        method: 'POST',
        url: `${url}/logout`,
    })
}

最后改改 loginView 渲染层文件内部脚本处理就行了:


<script lang="ts" setup>
  import {LockOutlined, UserOutlined} from '@ant-design/icons-vue'
  import {computed, reactive} from 'vue'
  import {message} from 'ant-design-vue'
  import {login} from '@/api/authority.ts'

  // 外部定义的后台 title
  const formTitle = import.meta.env.VITE_BASE_TITLE

  // 表单状态结构
  interface FormState {
    username: string
    password: string
  }

  // 表单默认状态
  const formState = reactive<FormState>({
    username: '',
    password: '',
  })

  // 禁用登录, 不允许输入空账户和密码
  const disabled = computed(() => {
    return !(formState.username && formState.password)
  })

  // 校验成功回调
  const onFinish = (values: typeof formState) => {
    // 创建网络请求
    login({
      username: values.username,
      password: values.password,
    }).then(
        (response) => {
          // 成功回调, 准备写入会话对象
          console.log(response)
          message.success('登录成功')
        },
        (error) => {
          // 失败回调
          const data = error.response.data
          const msg = data.message || error.message
          message.error(msg)
        },
    )
  }

  // 校验错误
  const onFinishFailed = (values: typeof formState) => {
    console.log(values)
  }
</script>

测试输入不存在的账号密码给API打开 devtool 看看网络请求就可以看到具体发起的网络请求功能.

全局状态

一般采用本地缓存保存的清空都是采用以下方式:

  • window.sessionStorage: 会话级别保存持久, 关闭浏览器销毁
  • window.localStorage: 应用级别保存持久, 直到手动清除本地缓存才会被清楚

这里一般采用 window.localStorage 保存, 但是直接提供两个封装方法工具就行了(src/utils/storage.ts):

/**
 * window.localStorage
 * @method set 设置
 * @method get 获取
 * @method remove 移除
 * @method clear 移除全部
 */
export const Local = {
        set(key: string, val: unknown) {
            window.localStorage.setItem(key, JSON.stringify(val))
        },
        get(key: string) {
            const json: unknown = window.localStorage.getItem(key)
            return JSON.parse(json as string)
        },
        remove(key: string) {
            window.localStorage.removeItem(key)
        },
        clear() {
            window.localStorage.clear()
        },
    }

/**
 * window.sessionStorage
 * @method set 设置会话缓存
 * @method get 获取会话缓存
 * @method remove 移除会话缓存
 * @method clear 移除全部会话缓存
 */
export const Session = {
    set(key: string, val: unknown) {
        window.sessionStorage.setItem(key, JSON.stringify(val))
    },
    get(key: string) {
        const json: unknown = window.sessionStorage.getItem(key)
        return JSON.parse(json as string)
    },
    remove(key: string) {
        window.sessionStorage.removeItem(key)
    },
    clear() {
        window.sessionStorage.clear()
    },
}

现在需要设置全局 pinia 管理全局状态, 不过在此之前就是需要明确返回的管理员结构信息:

// 具体完整的响应结构
{
  "datetime": "2025-01-28T14:29:21.365323428",
  "message": "登录成功",
  "data": {
    // 管理员ID
    "id": 1,
    // 管理员账号
    "username": "admin",
    // 管理员昵称
    "nickname": "admin",
    // 授权Token
    "token": "134xdfb62ccc2e74db8087e88d2406d9",
    // 是否为最高管理员
    "system": true,
    // 授权的角色组: number[]
    "roles": [],
    // 授权的渠道列表: number[]
    "channels": [],
    // 授权的允许访问 API 地址: string[]
    "authorities": [],
    // 授权的访问菜单路由: string[] 
    "menus": [],
    // 授权的访问服务器组列表: number[]
    "servers": []
  }
}

这里需要提取的 data 响应作为数据实体对象( entity ), 首先需要是定实体对象 src/stores/entities/index.ts:

/**
 * 用户实体
 */
export interface UserEntity {
    // 管理员ID
    id: number
    // 用户名
    username: string
    // 昵称
    nickname: string
    //授权凭证
    token: string
    // 是否为系统最高管理员
    system: boolean
    // 管理员角色ID组
    roles: number[]
    // 管理员菜单组
    menus: string[]
    // 管理员权限组
    authorities: string[]
    // 管理员渠道
    channel: number[]
    // 管理员服务器
    servers: number[]
}

这里前置装备已经处理好了, 后续就是 pinia 状态保存部分.

Pinia状态保存

Pinia 主要是提供内部集中的全局状态管理, 这样就不会出现授权状态需要层层组件识别是否已经授权; 不过内部管理本身刷新页面的时候就实际丢失状态了, 所以需要结合 LocalStorage 之类功能进行持久化管理.

这里需要先构建扩展 pinia 功能, 引入 piniaPluginPersistedState 提供封装内部状态方法, 需要构建文件 src/stores/index.ts

import {createPinia} from 'pinia'
import piniaPluginPersistedState from 'pinia-plugin-persistedstate'

/**
 * 扩展 pinia功能
 */
const pinia = createPinia()
pinia.use(piniaPluginPersistedState)

export default pinia

之后就是在 main.ts 之中重新引入插件写入:

import {createApp} from 'vue'

import App from './App.vue'
import router from './router'
import mitt from 'mitt'
import pinia from '@/stores'

const app = createApp(App)

app.use(pinia) // 引入 pinia 及其扩展
app.use(router) // 引入 vue-router

// 采用 mitt 作为全局事件处理
app.config.globalProperties.eventBus = mitt()

app.mount('#app')

这样基本上就完成系统装备, 现在就是登录之后将授权写入全部组件状态了, 不过在此之前需要说明下授权登录逻辑区别:

  • token: 单会话 token 机制, 登录之后返回标识让 axiosheader 之中追加 authorization
  • refreshToken: 登录之后返回 tokenrefreshToken 两个凭据, 需要接口提供 tokenRefresh 进行Token登录

token 机制对于后台系统在于简单干脆, 和后台账号密码登录保存 session 机制一样, 每次授权都直接单纯刷新账号 token; refreshToken 则是可以精确控制授权, 如果首次授权完之后拿到刷新token, 下次再次登录提交刷新token更新, 如果其他人其他设备登录直接会更新重置掉原来 tokenrefreshToken.

其实我个人感觉如果是后台系统开发, 更加推荐采用单 token 让渡给后台去做管控验证, 没必要按照 app 授权复杂化系统开发.

全局维护我这边只需要记录全局 token 和返回的的权限信息放置持久化, 将业务集中于后台功能开发; 这里就是需要创建单独管理器来处理写入持久层 ( src/stores/userEntity.ts ) :

import {defineStore} from 'pinia'
import type {UserEntity} from '@/stores/entities'
import {CacheKeys} from '@/stores/keys.ts'

/**
 * 玩家状态实体
 */
export const useUserEntity = defineStore('userEntity', {
    /**
     * 初始状态
     */
    state: (): UserEntity => {
        return {
            id: 0,
            username: '',
            nickname: '',
            system: false,
            token: '',
            authorities: [],
            channel: [],
            menus: [],
            roles: [],
            servers: [],
        }
    },
    /**
     * 行为方法
     */
    actions: {
        /**
         * 保存全局状态
         * @param state UserEntity
         */
        save(state: UserEntity) {
            this.$state = {...this.$state, ...state}
        },
        /**
         * 清理登录Token
         */
        cleanToken() {
            this.token = ''
        },
        /**
         * 设置登录Token
         * @param token
         */
        setToken(token: string) {
            this.token = token
        },
        /**
         * 获取登录Token
         */
        getToken() {
            return this.token
        },
    },

    /**
     * 设置持久化保存
     */
    persist: {
        key: CacheKeys.UserEntity,
    },
})

定义全局所有持久层的表名Key( src/stores/keys.ts ):

/**
 * 本地持久化缓存KEY
 */
export const CacheKeys = {
        /**
         * 登录授权实体Key
         */
        UserEntity: 'CacheUserEntityKey',
    }

最后就只需要在登录逻辑文件 (src/views/loginView.vue) 写入即可:

// 校验成功回调
const userEntity = useUserEntity() // pinia 全局状态
const onFinish = (values: typeof formState) => {
    // 创建网络请求
    login({
        username: values.username,
        password: values.password,
    }).then(
        (response) => {
            // 成功回调, 准备写入会话对象
            console.log(response)
            userEntity.save(response.data as UserEntity)
            // 设置完成可以3秒跳转 dashboard 路由
            message.success('登录成功', 3, () => {
                console.log('跳转菜单')
                router.push({path: '/dashboard'})
            })
        },
        (error) => {
            // 失败回调
            const data = error.response.data
            const msg = data.message || error.message
            message.error(msg)
        },
    )
}

pinia 会自动把状态数据写入本地持久层, 可以在 F12 控制台 -> Application -> Local storage 当中找到域名写入的持久化数据, 里面 CacheUserEntityKey 标识就是之前写入的全局状态数据.