基于vue-element-admin升级的Vue3+TS+Element-Plus版本正式开源,有来开源组织又一精心力作

基于vue-element-admin升级的Vue3+TS+Element-Plus版本正式开源,有来开源组织又一精心力作,第1张

项目简介

vue3-element-admin 是基于 vue-element-admin 升级的 Vue3 + Element Plus 版本的后台管理前端解决方案,是 有来技术团队 继 youlai-mall 全栈开源商城项目的又一开源力作。

项目使用 Vue3 + Vite2 + TypeScript + Element Plus + Vue Router + Pinia + Volar 等前端主流技术栈,基于此项目模板完成有来商城管理前端的 Vue3 版本。

本篇先对本项目功能、技术栈进行整体概述,再细节的讲述从0到1搭建 vue3-element-admin,在希望大家对本项目有个完完整整整了解的同时也能够在学 Vue3 + TypeScript 等技术栈少花些时间,少走些弯路,这样团队在毫无保留开源才有些许意义。

功能清单

技术栈清单
技术栈描述官网
Vue3渐进式 JavaScript 框架https://v3.cn.vuejs.org/
TypeScript微软新推出的一种语言,是 JavaScript 的超集https://www.tslang.cn/
Vite2前端开发与构建工具https://cn.vitejs.dev/
Element Plus基于 Vue 3,面向设计师和开发者的组件库https://element-plus.gitee.io/zh-CN/
Pinia新一代状态管理工具https://pinia.vuejs.org/
Vue RouterVue.js 的官方路由https://router.vuejs.org/zh/
wangEditorTypescript 开发的 Web 富文本编辑器https://www.wangeditor.com/
Echarts一个基于 JavaScript 的开源可视化图表库https://echarts.apache.org/zh/
项目预览

在线预览地址:www.youlai.tech

以下截图是来自有来商城管理前端 mall-admin-web ,是基于 vue3-element-admin 为基础开发的具有一套完整的系统权限管理的商城管理系统,数据均为线上真实的而非Mock。

国际化

已实现 Element Plus 组件和菜单路由的国际化,不过只做了少量国际化工作,国际化大部分是体力活,如果你有国际化的需求,会在下文从0到1实现Element Plus组件和菜单路由的国际化。

主题设置

大小切换

角色管理

菜单管理

商品上架

库存设置

微信小程序/ APP/ H5 显示上架商品效果

启动部署 项目启动
npm install 
npm run dev

浏览器访问 http://localhost:3000

项目部署
npm run build:prod 

生成的静态文件在工程根目录 dist 文件夹

项目从0到1构建

安装第三方插件请注意项目源码的package.json版本号,有些升级不考虑兼容性的插件在 install 的时候我会带上具体版本号,例如 npm install [email protected] 和 npm i [email protected] -D

环境准备

1. 运行环境Node

Node下载地址: http://nodejs.cn/download/

根据本机环境选择对应版本下载,安装过程可视化 *** 作非常简便,静默安装即可。

安装完成后命令行终端 node -v 查看版本号以验证是否安装成功:

2. 开发工具VSCode

下载地址:https://code.visualstudio.com/Download

3. 必装插件Volar

VSCode 插件市场搜索 Volar (就排在第一位的骷髅头),且要禁用默认的 Vetur.

项目初始化

1. Vite 是什么?

Vite是一种新型前端构建工具,能够显著提升前端开发体验。

Vite 官方中文文档:https://cn.vitejs.dev/guide/

2. 初始化项目

npm init vite@latest vue3-element-admin --template vue-ts
vue3-element-admin:项目名称vue-ts : Vue + TypeScript 的模板,除此还有vue,react,react-ts模板

3. 启动项目

cd vue3-element-admin
npm install
npm run dev

浏览器访问: http://localhost:3000

整合Element-Plus

1.本地安装Element Plus和图标组件

npm install element-plus
npm install @element-plus/icons-vue

2.全局注册组件

// main.ts
import ElementPlus from 'element-plus'
import 'element-plus/theme-chalk/index.css'

createApp(App)
    .use(ElementPlus)
    .mount('#app')

3. Element Plus全局组件类型声明

// tsconfig.json
{
  "compilerOptions": {
    // ...
    "types": ["element-plus/global"]
  }
}

4. 页面使用 Element Plus 组件和图标





5. 效果预览

路径别名配置

使用 @ 代替 src

1. Vite配置

// 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("./src") // 相对路径别名配置,使用 @ 代替 src
        }
    }
})

2. 安装@types/node

import path from 'path'编译器报错:TS2307: Cannot find module ‘path’ or its corresponding type declarations.

本地安装 Node 的 TypeScript 类型描述文件即可解决编译器报错

npm install @types/node --save-dev

3. TypeScript 编译配置

同样还是import path from 'path' 编译报错: TS1259: Module ‘“path”’ can only be default-imported using the ‘allowSyntheticDefaultImports’ flag

因为 typescript 特殊的 import 方式 , 需要配置允许默认导入的方式,还有路径别名的配置

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": "./", // 解析非相对模块的基地址,默认是当前目录
    "paths": { //路径映射,相对于baseUrl
      "@/*": ["src/*"] 
    },
    "allowSyntheticDefaultImports": true // 允许默认导入
  }
}

4.别名使用

// App.vue
import HelloWorld from '/src/components/HelloWorld.vue'
												↓
import HelloWorld from '@/components/HelloWorld.vue'
环境变量

官方教程: https://cn.vitejs.dev/guide/env-and-mode.html

1. env配置文件

项目根目录分别添加 开发、生产和模拟环境配置

开发环境配置:.env.development

# 变量必须以 VITE_ 为前缀才能暴露给外部读取
VITE_APP_TITLE = 'vue3-element-admin'
VITE_APP_PORT = 3000
VITE_APP_BASE_API = '/dev-api'

生产环境配置:.env.production

VITE_APP_TITLE = 'vue3-element-admin'
VITE_APP_PORT = 3000
VITE_APP_BASE_API = '/prod-api'

模拟生产环境配置:.env.staging

VITE_APP_TITLE = 'vue3-element-admin'
VITE_APP_PORT = 3000
VITE_APP_BASE_API = '/prod--api'

2.环境变量智能提示

添加环境变量类型声明

// src/ env.d.ts
// 环境变量类型声明
interface ImportMetaEnv {
  VITE_APP_TITLE: string,
  VITE_APP_PORT: string,
  VITE_APP_BASE_API: string
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

后面在使用自定义环境变量就会有智能提示,环境变量使用请参考下一节。

浏览器跨域处理

1. 跨域原理

浏览器同源策略: 协议、域名和端口都相同是同源,浏览器会限制非同源请求读取响应结果。

解决浏览器跨域限制大体分为后端和前端两个方向:

后端:开启 CORS 资源共享;前端:使用反向代理欺骗浏览器误认为是同源请求;

2. 前端反向代理解决跨域

Vite 配置反向代理解决跨域,因为需要读取环境变量,故写法和上文的出入较大,这里贴出完整的 vite.config.ts 配置。

// vite.config.ts
import {UserConfig, ConfigEnv, loadEnv} from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default ({command, mode}: ConfigEnv): UserConfig => {
    // 获取 .env 环境配置文件
    const env = loadEnv(mode, process.cwd())

    return (
        {
            plugins: [
                vue()
            ],
            // 本地反向代理解决浏览器跨域限制
            server: {
                host: 'localhost', 
                port: Number(env.VITE_APP_PORT), 
                open: true, // 启动是否自动打开浏览器
                proxy: {
                    [env.VITE_APP_BASE_API]: { 
                        target: 'http://www.youlai.tech:9999', // 有来商城线上接口地址
                        changeOrigin: true,
                        rewrite: path => path.replace(new RegExp('^' + env.VITE_APP_BASE_API), '')
                    }
                }
            },
            resolve: {
                alias: {
                    "@": path.resolve("./src") // 相对路径别名配置,使用 @ 代替 src
                }
            }
        }
    )
}
SVG图标

官方教程: https://github.com/vbenjs/vite-plugin-svg-icons/blob/main/README.zh_CN.md

Element Plus 图标库往往满足不了实际开发需求,可以引用和使用第三方例如 iconfont 的图标,本节通过整合 vite-plugin-svg-icons 插件使用第三方图标库。

1. 安装 vite-plugin-svg-icons

npm i [email protected] -D
npm i [email protected] -D

2. 创建图标文件夹

​ 项目创建 src/assets/icons 文件夹,存放 iconfont 下载的 SVG 图标

3. main.ts 引入注册脚本

// main.ts
import 'virtual:svg-icons-register';

4. vite.config.ts 插件配置

// vite.config.ts
import {UserConfig, ConfigEnv, loadEnv} from 'vite'
import vue from '@vitejs/plugin-vue'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons';

export default ({command, mode}: ConfigEnv): UserConfig => {
    // 获取 .env 环境配置文件
    const env = loadEnv(mode, process.cwd())

    return (
        {
            plugins: [
                vue(),
                createSvgIconsPlugin({
                    // 指定需要缓存的图标文件夹
                    iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
                    // 指定symbolId格式
                    symbolId: 'icon-[dir]-[name]',
                })
            ]
        }
    )
}

5. TypeScript支持

// tsconfig.json
{
  "compilerOptions": {
    "types": ["vite-plugin-svg-icons/client"]
  }
}

6. 组件封装







7. 使用案例



  
Pinia状态管理

Pinia 是 Vue.js 的轻量级状态管理库,Vuex 的替代方案。

尤雨溪于2021.11.24 在 Twitter 上宣布:Pinia 正式成为 vuejs 官方的状态库,意味着 Pinia 就是 Vuex 5 。

1. 安装Pinia

npm install pinia

2. Pinia全局注册

// src/main.ts
import { createPinia } from "pinia"
app.use(createPinia())
   .mount('#app')

3. Pinia模块封装

// src/store/modules/user.ts
// 用户状态模块
import { defineStore } from "pinia";
import { UserState } from "@/types"; // 用户state的TypeScript类型声明,文件路径 src/types/store/user.d.ts

const useUserStore = defineStore({
    id: "user",
    state: (): UserState => ({
        token:'',
        nickname: ''
    }),
    actions: {
      getUserInfo() {
      	return new Promise(((resolve, reject) => {
          ...
          resolve(data)
          ...
        }))
      }
    }
})

export default useUserStore;
// src/store/index.ts
import useUserStore from './modules/user'
const useStore = () => ({
    user: useUserStore()
})
export default useStore

4. 使用Pinia

import useStore from "@/store";

const { user } = useStore()
// state
const token = user.token
// action
user.getUserInfo().then(({data})=>{
	console.log(data)
})
Axios网络请求库封装

1. axios工具封装

//  src/utils/request.ts
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
import { ElMessage, ElMessageBox } from "element-plus";
import { localStorage } from "@/utils/storage";
import useStore from "@/store"; // pinia

// 创建 axios 实例
const service = axios.create({
    baseURL: import.meta.env.VITE_APP_BASE_API,
    timeout: 50000,
    headers: { 'Content-Type': 'application/json;charset=utf-8' }
})

// 请求拦截器
service.interceptors.request.use(
    (config: AxiosRequestConfig) => {
        if (!config.headers) {
            throw new Error(`Expected 'config' and 'config.headers' not to be undefined`);
        }
        const { user } = useStore()
        if (user.token) {
            config.headers.Authorization = `${localStorage.get('token')}`;
        }
        return config
    }, (error) => {
        return Promise.reject(error);
    }
)

// 响应拦截器
service.interceptors.response.use(
    (response: AxiosResponse) => {
        const { code, msg } = response.data;
        if (code === '00000') {
            return response.data;
        } else {
            ElMessage({
                message: msg || '系统出错',
                type: 'error'
            })
            return Promise.reject(new Error(msg || 'Error'))
        }
    },
    (error) => {
        const { code, msg } = error.response.data
        if (code === 'A0230') {  // token 过期
            localStorage.clear(); // 清除浏览器全部缓存
            window.location.href = '/'; // 跳转登录页
            ElMessageBox.alert('当前页面已失效,请重新登录', '提示', {})
                .then(() => {
                })
                .catch(() => {
                });
        } else {
            ElMessage({
                message: msg || '系统出错',
                type: 'error'
            })
        }
        return Promise.reject(new Error(msg || 'Error'))
    }
);

// 导出 axios 实例
export default service

2. API封装

以登录成功后获取用户信息(昵称、头像、角色集合和权限集合)的接口为案例,演示如何通过封装的 axios 工具类请求后端接口,其中响应数据

// src/api/system/user.ts
import request from "@/utils/request";
import { AxiosPromise } from "axios";
import { UserInfo } from "@/types"; // 用户信息返回数据的TypeScript类型声明,文件路径 src/types/api/system/user.d.ts

/**
 * 登录成功后获取用户信息(昵称、头像、权限集合和角色集合)
 */
export function getUserInfo(): AxiosPromise {
    return request({
        url: '/youlai-admin/api/v1/users/me',
        method: 'get'
    })
}

3. API调用

// src/store/modules/user.ts
import { getUserInfo } from "@/api/system/user";

// 获取登录用户信息
getUserInfo().then(({ data }) => {
	const { nickname, avatar, roles, perms } = data
  ...
})
动态权限路由

官方文档: https://router.vuejs.org/zh/api/

1. 安装 vue-router

npm install vue-router@next

2. 创建路由实例

创建路由实例并导出,其中包括静态路由数据,动态路由后面将通过接口从后端获取并整合用户角色的权限控制。

// src/router/index.ts
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
import useStore from "@/store";

export const Layout = () => import('@/layout/index.vue')

// 静态路由
export const constantRoutes: Array = [
    {
        path: '/redirect',
        component: Layout,
        meta: { hidden: true },
        children: [
            {
                path: '/redirect/:path(.*)',
                component: () => import('@/views/redirect/index.vue')
            }
        ]
    },
    {
        path: '/login',
        component: () => import('@/views/login/index.vue'),
        meta: { hidden: true }
    },
    {
        path: '/404',
        component: () => import('@/views/error-page/404.vue'),
        meta: { hidden: true }
    },
    {
        path: '/401',
        component: () => import('@/views/error-page/401.vue'),
        meta: { hidden: true }
    },
    {
        path: '/',
        component: Layout,
        redirect: '/dashboard',
        children: [
            {
                path: 'dashboard',
                component: () => import('@/views/dashboard/index.vue'),
                name: 'Dashboard',
                meta: { title: 'dashboard', icon: 'dashboard', affix: true }
            }
        ]
    }
]

// 创建路由实例
const router = createRouter({
    history: createWebHashHistory(),
    routes: constantRoutes as RouteRecordRaw[],
    // 刷新时,滚动条位置还原
    scrollBehavior: () => ({ left: 0, top: 0 })
})

// 重置路由
export function resetRouter() {
    const { permission } = useStore()
    permission.routes.forEach((route) => {
        const name = route.name
        if (name) {
            router.hasRoute(name) && router.removeRoute(name)
        }
    })
}

export default router

3. 路由实例全局注册

// main.ts
import router from "@/router";

app.use(router)
   .mount('#app')

4. 动态权限路由

// src/permission.ts
import router from "@/router";
import { ElMessage } from "element-plus";
import useStore from "@/store";
import NProgress from 'nprogress';
import 'nprogress/nprogress.css'
NProgress.configure({ showSpinner: false }) // 进度环显示/隐藏


// 白名单路由
const whiteList = ['/login', '/auth-redirect']

router.beforeEach(async (to, form, next) => {
    NProgress.start()
    const { user, permission } = useStore()
    const hasToken = user.token
    if (hasToken) {
        // 登录成功,跳转到首页
        if (to.path === '/login') {
            next({ path: '/' })
            NProgress.done()
        } else {
            const hasGetUserInfo = user.roles.length > 0
            if (hasGetUserInfo) {
                next()
            } else {
                try {
                    await user.getUserInfo()
                    const roles = user.roles
                    // 用户拥有权限的路由集合(accessRoutes) 
                    const accessRoutes: any = await permission.generateRoutes(roles)
                    accessRoutes.forEach((route: any) => {
                        router.addRoute(route)
                    })
                    next({ ...to, replace: true })
                } catch (error) {
                    // 移除 token 并跳转登录页
                    await user.resetToken()
                    ElMessage.error(error as any || 'Has Error')
                    next(`/login?redirect=${to.path}`)
                    NProgress.done()
                }
            }
        }
    } else {
        // 未登录可以访问白名单页面(登录页面)
        if (whiteList.indexOf(to.path) !== -1) {
            next()
        } else {
            next(`/login?redirect=${to.path}`)
            NProgress.done()
        }
    }
})

router.afterEach(() => {
    NProgress.done()
})

其中 const accessRoutes: any = await permission.generateRoutes(roles)是根据用户角色获取拥有权限的路由(静态路由+动态路由),核心代码如下:

// src/store/modules/permission.ts 
import { constantRoutes } from '@/router';
import { listRoutes } from "@/api/system/menu";

const usePermissionStore = defineStore({
    id: "permission",
    state: (): PermissionState => ({
        routes: [],
        addRoutes: []
    }),
    actions: {
        setRoutes(routes: RouteRecordRaw[]) {
            this.addRoutes = routes
          	// 静态路由 + 动态路由
            this.routes = constantRoutes.concat(routes)
        },
        generateRoutes(roles: string[]) {
            return new Promise((resolve, reject) => {
              	// API 获取动态路由
                listRoutes().then(response => {
                    const asyncRoutes = response.data
                    let accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
                    this.setRoutes(accessedRoutes)
                    resolve(accessedRoutes)
                }).catch(error => {
                    reject(error)
                })
            })
        }
    }
})

export default usePermissionStore;
按钮权限

1. Directive 自定义指令

// src/directive/permission/index.ts

import useStore from "@/store";
import { Directive, DirectiveBinding } from "vue";

/**
 * 按钮权限校验
 */
export const hasPerm: Directive = {
    mounted(el: HTMLElement, binding: DirectiveBinding) {
        // 「超级管理员」拥有所有的按钮权限
        const { user } = useStore()
        const roles = user.roles;
        if (roles.includes('ROOT')) {
            return true
        }
        // 「其他角色」按钮权限校验
        const { value } = binding;
        if (value) {
            const requiredPerms = value; // DOM绑定需要的按钮权限标识

            const hasPerm = user.perms?.some(perm => {
                return requiredPerms.includes(perm)
            })

            if (!hasPerm) {
                el.parentNode && el.parentNode.removeChild(el);
            }
        } else {
            throw new Error("need perms! Like v-has-perm=\"['sys:user:add','sys:user:edit']\"");
        }
    }
};

2. 自定义指令全局注册

​
// src/main.ts

const app = createApp(App)
// 自定义指令
import * as directive from "@/directive";

Object.keys(directive).forEach(key => {
    app.directive(key, (directive as { [key: string]: Directive })[key]);
});

3. 指令使用

​
// src/views/system/user/index.vue
新增
删除
Element-Plus国际化

官方教程:https://element-plus.gitee.io/zh-CN/guide/i18n.html

Element Plus 官方提供全局配置 Config Provider实现国际化

​
//  src/App.vue


自定义国际化

i18n 英文全拼 internationalization ,国际化的意思,英文 i 和 n 中间18个英文字母

1. 安装 vue-i18n

npm install [email protected]

2. 语言包

创建 src/lang 语言包目录,中文语言包 zh-cn.ts,英文语言包 en.ts

​// src/lang/en.ts
export default {
    // 路由国际化
    route: {
        dashboard: 'Dashboard',
        document: 'Document'
    },
    // 登录页面国际化
    login: {
        title: 'youlai-mall management system',
        username: 'Username',
        password: 'Password',
        login: 'Login',
        code: 'Verification Code',
        copyright: 'Copyright © 2020 - 2022 youlai.tech All Rights Reserved. ',
        icp: ''
    },
    // 导航栏国际化
    navbar:{
        dashboard: 'Dashboard',
        logout:'Logout',
        document:'Document',
        gitee:'Gitee'
    }
}

3. 创建i18n实例

​
// src/lang/index.ts

// 自定义国际化配置
import {createI18n} from 'vue-i18n'
import {localStorage} from '@/utils/storage'

// 本地语言包
import enLocale from './en'
import zhCnLocale from './zh-cn'

const messages = {
    'zh-cn': {
        ...zhCnLocale
    },
    en: {
        ...enLocale
    }
}

/**
 * 获取当前系统使用语言字符串
 * 
 * @returns zh-cn|en ...
 */
export const getLanguage = () => {
    // 本地缓存获取
    let language = localStorage.get('language')
    if (language) {
        return language
    }
     // 浏览器使用语言
    language = navigator.language.toLowerCase()
    const locales = Object.keys(messages)
    for (const locale of locales) {
        if (language.indexOf(locale) > -1) {
            return locale
        }
    }
    return 'zh-cn'
}

const i18n = createI18n({
    locale: getLanguage(),
    messages: messages
})

export default i18n

4. i18n 全局注册

​// main.ts

// 国际化
import i18n from "@/lang/index";

app.use(i18n)
   .mount('#app');

5. 静态页面国际化

$t 是 i18n 提供的根据 key 从语言包翻译对应的 value 方法

{{ $t("login.title") }}

6. 动态路由国际化

i18n 工具类,主要使用 i18n 的 te (判断语言包是否存在key) 和 t (翻译) 两个方法

//  src/utils/i18n.ts
import i18n from "@/lang/index";

export function generateTitle(title: any) {
    // 判断是否存在国际化配置,如果没有原生返回
    const hasKey = i18n.global.te('route.' + title)
    if (hasKey) {
        const translatedTitle = i18n.global.t('route.' + title)
        return translatedTitle
    }
    return title
}

页面使用

​// src/components/Breadcrumb/index.vue


 
wangEditor富文本编辑器

推荐教程:Vue3 官方示例

1. 安装wangEditor和Vue3组件

​npm install @wangeditor/editor --save
npm install @wangeditor/editor-for-vue@next --save

2. wangEditor组件封装






3. 使用案例






Echarts图表

1. 安装 Echarts

npm install echarts

2. Echarts 自适应大小工具类

侧边栏、浏览器窗口大小切换都会触发图表的 resize() 方法来进行自适应

​
// src/utils/resize.ts
import { ref } from 'vue'
export default function() {
    const chart = ref()
    const sidebarElm = ref()

    const chartResizeHandler = () => {
        if (chart.value) {
            chart.value.resize()
        }
    }

    const sidebarResizeHandler = (e: TransitionEvent) => {
        if (e.propertyName === 'width') {
            chartResizeHandler()
        }
    }

    const initResizeEvent = () => {
        window.addEventListener('resize', chartResizeHandler)
    }

    const destroyResizeEvent = () => {
        window.removeEventListener('resize', chartResizeHandler)
    }

    const initSidebarResizeEvent = () => {
        sidebarElm.value = document.getElementsByClassName('sidebar-container')[0]
        if (sidebarElm.value) {
            sidebarElm.value.addEventListener('transitionend', sidebarResizeHandler as EventListener)
        }
    }

    const destroySidebarResizeEvent = () => {
        if (sidebarElm.value) {
            sidebarElm.value.removeEventListener('transitionend', sidebarResizeHandler as EventListener)
        }
    }

    const mounted = () => {
        initResizeEvent()
        initSidebarResizeEvent()
    }

    const beforeDestroy = () => {
        destroyResizeEvent()
        destroySidebarResizeEvent()
    }

    const activated = () => {
        initResizeEvent()
        initSidebarResizeEvent()
    }

    const deactivated = () => {
        destroyResizeEvent()
        destroySidebarResizeEvent()
    }

    return {
        chart,
        mounted,
        beforeDestroy,
        activated,
        deactivated
    }
}

​

3. Echarts使用

官方示例: https://echarts.apache.org/examples/zh/index.html

官方的示例文档丰富和详细,且涵盖了 JavaScript 和 TypeScript 版本,使用非常简单。

 



项目源码
GiteeGithub
vue3-element-adminvue3-element-admin: 基于 vue-element-admin 升级的 Vue3 版本管理前端解决方案,技术栈: Vue3 + Vite2 + TypeScript + Element Plus + Pinia 。GitHub - youlaitech/vue3-element-admin: 基于 vue-element-admin 升级的 Vue3 版本管理前端解决方案,技术栈: Vue3 + Vite2 + TypeScript + Element Plus + Pinia 。

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/web/1321468.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-06-12
下一篇 2022-06-12

发表评论

登录后才能评论

评论列表(0条)

保存