初始项目搭建
这里实际上参照之前 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机制, 登录之后返回标识让axios在header之中追加authorizationrefreshToken: 登录之后返回token和refreshToken两个凭据, 需要接口提供tokenRefresh进行Token登录
单 token 机制对于后台系统在于简单干脆, 和后台账号密码登录保存 session 机制一样, 每次授权都直接单纯刷新账号 token;
refreshToken 则是可以精确控制授权, 如果首次授权完之后拿到刷新token, 下次再次登录提交刷新token更新,
如果其他人其他设备登录直接会更新重置掉原来 token 和 refreshToken.
其实我个人感觉如果是后台系统开发, 更加推荐采用单
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 标识就是之前写入的全局状态数据.