vue - 项目实战3
0 一些遇到的问题
1 如果vite项目,JS项目(不是TS),“快捷修复” 没有导入 ref 功能
checkJs:对 JavaScript 文件也进行类型检查(结合 JSDoc 注释生效)
jsconfig.json
{
"compilerOptions": {
"checkJs": true,
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}1 登陆存储token
src\utils\request.js
import axios from 'axios'
import { ElMessage } from 'element-plus'
//console.log(import.meta.env.VITE_BASE_API)
// 创建axios实例
const service = axios.create({
timeout: 10000, // 请求超时时间
// baseURL: import.meta.env.VITE_BASE_API
baseURL: ''
})
service.interceptors.request.use(
function (config) {
// 在发送请求之前做些什么
return config
},
function (error) {
// 对请求错误做些什么
return Promise.reject(error)
}
)
service.interceptors.response.use(
(res) => {
const { success, data, message } = res.data
if (success) {
return data
} else {
const NETWORK_ERROR = '网络请求出错'
ElMessage.error(message || NETWORK_ERROR)
return Promise.reject(message || NETWORK_ERROR)
}
},
(error) => {
// 处理网络错误、超时等情况
if (error.response) {
// 服务器返回了错误状态码
ElMessage.error(`请求失败: ${error.response.status}`)
} else if (error.request) {
// 请求已发送但没有收到响应
ElMessage.error('网络连接失败')
} else {
// 设置请求时发生错误
ElMessage.error('请求配置错误')
}
return Promise.reject(error)
}
)
function request(options) {
options.method = options.method || 'get'
return service(options)
}
export default request
src\constant\index.js
export const TOKEN = 'token';
src\utils\localstorage.js
/**
* 存储数据
*/
export const setItem = (key, value) => {
//复杂数据
if (typeof value === 'object') {
value = JSON.stringify(value)
}
localStorage.setItem(key, value)
}
/**
* 获取数据
*/
export const getItem = (key) => {
const value = localStorage.getItem(key)
try {
return JSON.parse(value)
} catch (error) {
console.warn(error);
return value;
}
}
/**
* 删除指定数据
*/
export const removeItem = (key) => {
localStorage.removeItem(key)
}
/**
* 删除所有数据
*/
export const clearItem = () => {
localStorage.clear()
}
src\store\modules\userStore.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
import md5 from 'md5'
import { login } from '@/api/sys'
import { setItem, getItem, removeItem, clearItem } from '@/utils/localstorage'
import { TOKEN } from '@/constant'
export const useUserStore = defineStore('userStore', () => {
const state = ref({
token: getItem(TOKEN) || ''
})
function setToken(token) {
state.value.token = token //存储到 pinia
setItem(TOKEN, token) //存储到 localstorage
}
function uselogin(context, userinfo) {
const { username, password } = userinfo
return new Promise((resolve, reject) => {
login({ username, password: md5(password) })
.then((res) => {
setToken(res.token)
resolve(res)
})
.then(() => {
resolve()
})
.catch((err) => {
reject(err)
})
})
}
return { state, uselogin }
})
2 全局路由鉴权
import router from './index'
// import { getItem } from '../utils/storege'
import { useUserStore } from '@/store'
router.beforeEach((to, from, next) => {
// console.log(router.getRoutes())
const store = useUserStore()
const token = store.state.token
// 如果访问的不是登录页且没有token,则重定向到登录页
if (to.path !== '/login' && !token) {
next({
path: '/login'
})
return
}
// 如果已经登录了还要访问登录页,则重定向到首页
// if (to.path === '/login' && token) {
// next({
// path: '/home'
// })
// return
// }
// 其他情况正常放行
next()
})
导入 main.js
import '@/utils/permisson' //全局路由守卫
JS 与 scss 共享变量,在 scss 中通过 :export 进行导出 -> *.module.scss
在 js 中可通过 ESM 进行导入
在 Vite 中不使用插件时,SCSS 文件需要命名为 *.module.scss 才能被当作 CSS Modules 处理,这是 Vite 内置的模块处理规则,主要原因如下:
1. 区分普通样式和模块化样式
Vite 遵循 CSS Modules 规范,通过文件名后缀 .module 来区分两种处理方式:
-
普通 SCSS 文件(如
style.scss):全局生效,类名不会被哈希处理 -
模块化 SCSS 文件
(如
variables.module.scss):- 类名会被编译为哈希值(避免样式冲突)
- 支持通过
:export导出变量到 JS - 导入后会返回一个包含映射关系的对象
2. 内置处理逻辑的限制
Vite 的内置 CSS 处理器(基于 PostCSS)默认只对 .module 后缀的文件启用模块化特性,包括:
- 变量导出功能(
export语法) - 作用域隔离(类名哈希)
- 类型自动推导(对 TS 友好)
如果不使用 .module 后缀,Vite 会将其视为普通样式文件,export 导出的变量无法被 JS 正确识别。
src\styles\sass\variables.module.scss
// sidebar
$menuText: #bfcbd9;
$menuActiveText: #ffffff;
$subMenuActiveText: #f4f4f5;
$menuBg: #304156;
$menuHover: #263445;
$subMenuBg: #1f2d3d;
$subMenuHover: #001528;
$sideBarWidth: 210px;
// https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass
// JS 与 scss 共享变量,在 scss 中通过 :export 进行导出,在 js 中可通过 ESM 进行导入
:export {
menuText: $menuText;
menuActiveText: $menuActiveText;
subMenuActiveText: $subMenuActiveText;
menuBg: $menuBg;
menuHover: $menuHover;
subMenuBg: $subMenuBg;
subMenuHover: $subMenuHover;
sideBarWidth: $sideBarWidth;
}
src\styles\sass\index.scss
@use './variables.module.scss' as *;
@use './mixin.scss' as *;
@use './sidebar.scss' as *;
@use './reset.scss' as *;
3 vite 解决vue导入scss不认识 @的问题
例如下面的例子
<template>
<div class="app-wrapper">
<!-- 左侧 menu -->
<sidebar id="guide-sidebar" class="sidebar-container" :style="{ backgroundColor: variables.menuBg }" />
<div class="main-container">
<div class="fixed-header">
<!-- 顶部的 navbar -->
<navbar />
</div>
<!-- 内容区 -->
<app-main />
</div>
</div>
</template>
<script setup>
import Navbar from './components/navbar.vue';
import Sidebar from './components/sidebar/index.vue';
import AppMain from './components/appMain.vue';
import variables from '@/styles/sass/variables.module.scss';
const { sideBarWidth } = variables;
console.log('SCSS Variables:', variables)
console.log('Sidebar Width:', sideBarWidth);
</script>
<style lang="scss" scoped>
@use '@/styles/sass/variables.module.scss' as *;
@use '@/styles/sass/mixin.scss' as *;
.app-wrapper {
@include clearfix;
position: relative;
height: 100%;
width: 100%;
}
.fixed-header {
position: fixed;
top: 0;
right: 0;
z-index: 9;
width: calc(100% - #{$sideBarWidth});
}
</style>解决方法
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
createSvgIconsPlugin({
iconDirs: [path.resolve(process.cwd(), 'src/assets/icons/svg')],
symbolId: 'icon-[name]'
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
css: {
preprocessorOptions: {
scss: {
api: 'modern-compiler'
},
// additionalData: `@use "@/styles/sass/variables.module.scss" as *;@use "@/styles/sass/mixin.scss" as *;`
// 为 SCSS 配置别名(关键配置)
importer: [
(url) => {
// 处理以 @/ 开头的路径
if (url.startsWith('@/')) {
return {
file: path.resolve(__dirname, './src', url.slice(2))
}
}
return null
}
]
}
}最后为了方便使用(按照自己的变量来)
src\store\modules\useVariableStore.js
import { defineStore } from 'pinia'
import variables from '@/styles/sass/variables.module.scss'
import { ref } from 'vue'
export const useVarCssStore = defineStore('varCssStore', () => {
const state = ref({
menuText: variables.menuText,
menuActiveText: variables.menuActiveText,
subMenuActiveText: variables.subMenuActiveText,
menuBg: variables.menuBg,
menuHover: variables.menuHover,
subMenuBg: variables.subMenuBg,
subMenuHover: variables.subMenuHover,
sideBarWidth: variables.sideBarWidth
})
return { state }
})
获取用户信息,pinia 管理
获取的时机,在权限拦截时触发动作
src\utils\permisson.js
import router from '../router/index'
// import { getItem } from '../utils/storege'
import { useUserStore } from '@/store'
router.beforeEach(async (to, from, next) => {
// console.log(router.getRoutes())
const store = useUserStore()
const token = store.state.token
// 如果访问的不是登录页且没有token,则重定向到登录页
if (to.path !== '/login' && !token) {
next({
path: '/login'
})
return
}
//获取用户信息
if (to.path !== '/login' && token) {
if (!store.state.hasUserInfo) {
await store.getUserInfo()
}
}
// 其他情况正常放行
next()
})src\store\modules\userStore.js
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import md5 from 'md5'
import { login, profile } from '@/api/sys'
import { setItem, getItem, removeItem, clearItem } from '@/utils/localstorage'
import { TOKEN } from '@/constant'
export const useUserStore = defineStore('userStore', () => {
const state = ref({
token: getItem(TOKEN) || '',
userInfo: ref({}),
hasUserInfo: false
})
function setToken(token) {
state.value.token = token //存储到 pinia
setItem(TOKEN, token) //存储到 localstorage
}
function uselogin(context, userinfo) {
const { username, password } = userinfo
return new Promise((resolve, reject) => {
login({ username, password: md5(password) })
.then((res) => {
setToken(res.token)
resolve(res)
})
.then(() => {
resolve()
})
.catch((err) => {
reject(err)
})
})
}
//#region 监听userInfo变化,如果userInfo为空,则hasUserInfo为false,否则为true
watch(
() => state.value.userInfo,
() => {
if (!state.value.userInfo) {
state.value.hasUserInfo = false
} else {
state.value.hasUserInfo =
Object.keys(state.value.userInfo).length > 0 &&
JSON.stringify(state.value.userInfo) !== '{}'
}
return state.value.hasUserInfo
}
)
async function getUserInfo() {
const res = await profile()
state.value.userInfo = res
return res
}
//#endregion
return { state, uselogin, getUserInfo }
})logout 退出
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import md5 from 'md5'
import { login, profile } from '@/api/sys'
import { setItem, getItem, removeItem, clearItem } from '@/utils/localstorage'
import { TOKEN } from '@/constant'
import router from '@/router'
export const useUserStore = defineStore('userStore', () => {
const state = ref({
token: getItem(TOKEN) || '',
userInfo: ref({}),
hasUserInfo: false
})
function setToken(token) {
state.value.token = token //存储到 pinia
setItem(TOKEN, token) //存储到 localstorage
}
function uselogin(context, userinfo) {
const { username, password } = userinfo
return new Promise((resolve, reject) => {
login({ username, password: md5(password) })
.then((res) => {
setToken(res.token)
resolve(res)
})
.then(() => {
resolve()
})
.catch((err) => {
reject(err)
})
})
}
//#region 监听userInfo变化,如果userInfo为空,则hasUserInfo为false,否则为true
watch(
() => state.value.userInfo,
() => {
if (!state.value.userInfo) {
state.value.hasUserInfo = false
} else {
state.value.hasUserInfo =
Object.keys(state.value.userInfo).length > 0 &&
JSON.stringify(state.value.userInfo) !== '{}'
}
return state.value.hasUserInfo
}
)
async function getUserInfo() {
const res = await profile()
state.value.userInfo = res
return res
}
//#endregion
function logout() {
state.value.token = ''
state.value.userInfo = {}
state.value.hasUserInfo = false
// clearItem()
removeItem('token')
// todo 退出登录,清理权限配置
router.push('/login')
}
return { state, uselogin, logout, getUserInfo }
})SVG 处理 - 针对自己SVG和外部SVG的情况
有以下三种:
1 外部链接的SVG
2 内部自己的SVG
3 elementPlus 的svg
1 导入 vite-plugin-svg-icons
npm vite-plugin-svg-icons -D
2 vite 设置
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
createSvgIconsPlugin({
iconDirs: [path.resolve(process.cwd(), 'src/assets/icons/svg')],
symbolId: 'icon-[name]'
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
...3 导入svg

4 注册
src\assets\icons\index.js
import SvgIcon from '@/libs/svg-icons/index.vue'
/**
* 全局注册SvgIcon组件
* @param {Object} app - Vue应用实例
*/
export function setupSvgIcon(app) {
app.component('SvgIcon', SvgIcon)
}
// 注意:SVG文件现在通过vite-plugin-svg-icons插件处理,不需要手动导入
main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import '@/styles/sass/index.scss'
import App from './App.vue'
import router from './router'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import { setupSvgIcon } from '@/assets/icons'
import 'virtual:svg-icons-register' // 引入SVG注册脚本
import '@/api/mock' //mock.js
import '@/utils/permisson' //全局路由守卫
const app = createApp(App)
setupSvgIcon(app)
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')5 显示
src\utils\validate.js 判断是否为外部svg
/**
* 判断是否为外部资源
* @param {*} path
* @returns
*/
export function isExternal(path) {
return /^(https?:|mailto:|tel:)/.test(path)
}
src\libs\svg-icons\index.vue
<template>
<!-- 外部图标 -->
<div v-if="isExternalIcon" :style="styleExternalIcon" class="svg-icon-external svg-icon"></div>
<!-- 内部图标 -->
<svg v-else :class="className" aria-hidden="true">
<use :xlink:href="iconName"></use>
</svg>
</template>
<script setup>
import { isExternal } from '@/utils/validate';
import { computed } from 'vue';
const props = defineProps({
icon: {
type: String,
required: true
},
className: {
type: String,
default: ''
},
});
/**
* 判断是否是外部图标
*/
const isExternalIcon = computed(() => isExternal(props.icon));
/**
* 外部图标样式
*/
const styleExternalIcon = computed(() => (
{
'mask': `url(${props.icon}) no-repeat 50% 50%`,
'-webkit-mask': `url(${props.icon}) no - repeat 50% 50%`
})
)
/**
* 内部图标
*/
const iconName = computed(() => `#icon-${props.icon}`)
</script>
<style lang="scss" scoped>
.svg-icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}
.svg-icon-external {
background-color: currentColor;
mask-size: cover !important;
display: inline-block;
}
</style>
6 使用方法
//外部连接和自己的svg文件
<SvgIcon :icon="icon" className="svg" />
7 扩展-全局
src\components\SystemIcons\index.vue
<template>
<div v-if="icon.includes('el-icon')">
<!-- elementPlus 图标-->
<el-icon>
<component :is="iconComponent" />
</el-icon>
</div>
<div v-else>
<!-- 外部图标/内部图标 -->
<el-icon>
<SvgIcon :icon="icon" className="svg" />
</el-icon>
</div>
</template>
<script setup>
import SvgIcon from '@/libs/svg-icons/index.vue'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import { computed } from 'vue'
const props = defineProps({
icon: {
type: String,
required: true
}
})
const iconComponent = computed(() => {
if (props.icon.startsWith('el-icon-')) {
// 移除 el-icon- 前缀并转换为大驼峰命名
const name = props.icon.substring(8)
const componentName = name.replace(/(^\w|-\w)/g, (match) =>
match.replace('-', '').toUpperCase()
)
// 返回对应的图标组件
return ElementPlusIconsVue[componentName]
}
return props.icon
})
</script>
<style lang="scss" scoped></style>
使用elementPlus 的图标
import SystemIcons from '@/components/SystemIcons/index.vue'
<SystemIcons icon="el-icon-UploadFilled" />
Router 路由处理 Menu
1.如果 meta && meta.title && meta.icon:则显示在 menu 菜单中,其中 title 为显示的内容, icon 为显示的图标
1.如果存在children:则以 el-sub-menu(子菜单)展示
2.否则:则以el-menu-item(菜单项)展示
2.否则:不显示在 menu 菜单中3 重复路由要处理

import { createRouter, createWebHistory } from 'vue-router'
import layout from '@/layout/index.vue'
const privateRoutes = [
{
path: '/user',
component: layout,
redirect: '/user/manage',
meta: {
title: 'user',
icon: 'personnel'
},
children: [
{
path: '/user/manage',
name: 'userManage',
component: () => import('@/views/user-manage/index.vue'),
meta: {
title: 'userManage',
icon: 'personnel-manage'
}
},
{
path: '/user/role',
name: 'userRole',
component: () => import('@/views/role-list/index.vue'),
meta: {
title: 'roleList',
icon: 'role'
}
},
{
path: '/user/permission',
name: 'userPermission',
component: () => import('@/views/permission-list/index.vue'),
meta: {
title: 'permissionList',
icon: 'permission'
}
},
{
path: '/user/info/:id',
name: 'userInfo',
component: () => import('@/views/user-info/index.vue'),
meta: {
title: 'userInfo'
}
},
{
path: '/user/import',
name: 'userImport',
component: () => import('@/views/import/index.vue'),
meta: {
title: 'excelImport'
}
}
]
},
{
path: '/article',
component: layout,
redirect: '/article/ranking',
meta: {
title: 'article',
icon: 'article'
},
children: [
{
path: '/article/ranking',
name: 'articleRanking',
component: () => import('@/views/article-ranking/index.vue'),
meta: {
title: 'articleRanking',
icon: 'article-ranking'
}
},
{
path: '/article/:id',
name: 'articleDetail',
component: () => import('@/views/article-detail/index.vue'),
meta: {
title: 'articleDetail'
}
},
{
path: '/article/create',
name: 'articleCreate',
component: () => import('@/views/article-create/index.vue'),
meta: {
title: 'articleCreate',
icon: 'article-create'
}
},
{
path: '/article/editor/:id',
name: 'articleEditor',
component: () => import('@/views/article-create/index.vue'),
meta: {
title: 'articleEditor'
}
}
]
}
]
const publicRoutes = [
{
path: '/',
redirect: '/profile',
component: () => import('@/layout/index.vue'),
children: [
{
path: '/profile',
name: 'profile',
component: () => import('@/views/profile/index.vue'),
meta: {
title: '个人中心',
icon: 'el-icon-user'
}
},
{
path: '/404',
name: '404',
component: () => import('@/views/error-page/404.vue')
},
{
path: '/401',
name: '401',
component: () => import('@/views/error-page/401.vue')
}
]
},
{
path: '/login',
name: 'login',
component: () => import('@/views/login/index.vue')
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [...publicRoutes, ...privateRoutes]
})
export default router
src\utils\routerHelper.js
/**
* 1.筛选「有效路由」,排除无用路由
* @param {} rawRoutes
* @returns
*/
export const getOptimalRoute = (rawRoutes) => {
// 原始路由数组(假设命名为 rawRoutes)
// const rawRoutes = [/* 你的原始路由数据 */];
// 1. 筛选有效路由:排除登录、404等非菜单路由,且必须有 meta.title(避免无标题菜单)
const validRoutes = rawRoutes.filter((route) => {
// 排除非菜单路由:path 为 /login、/404、/401 的路由
const isInvalidPath = ['/login', '/404', '/401'].includes(route.path)
// 必须有 meta.title(菜单需要显示标题)
const hasTitle = route.meta && route.meta.title && route.meta.icon
return !isInvalidPath && hasTitle
})
return validRoutes
}
/**
* 步骤 2:提取「一级菜单路由」(父路由)
* 规则:有 redirect + 有 children 的是父路由;/profile 是独立一级路由
*
* @param {*} validRoutes
* @returns
*/
export const getMenuList = (validRoutes) => {
// 先筛选出符合条件的父路由
const parentRoutes = validRoutes.filter((route) => {
const isParentWithRedirect =
route.redirect && Array.isArray(route.children) && route.children.length > 0
const isIndependentParent = route.path === '/profile'
return isParentWithRedirect || isIndependentParent
})
// 对每个父路由的children进行同样的有效性过滤
return parentRoutes.map((route) => {
// 如果有子路由,递归过滤子路由
if (route.children && route.children.length > 0) {
return {
...route,
// 递归应用有效路由过滤规则到子路由
children: getOptimalRoute(route.children)
}
}
return route
})
}
实现Menu显示图标和文本

这里因为要和elementPlus的图标设置一样的大小,所以也包装以下<el-icon>
<!-- 外部图标/内部图标 -->
<el-icon>
<SvgIcon :icon="icon" className="svg" />
</el-icon>
src\layout\components\sidebar\menuIcon.vue
<template>
<div v-if="icon.includes('el-icon')">
<!-- elementPlus 图标-->
<el-icon>
<component :is="iconComponent" />
</el-icon>
</div>
<div v-else>
<!-- 外部图标/内部图标 -->
<el-icon>
<SvgIcon :icon="icon" className="svg" />
</el-icon>
</div>
<span>{{ title }}</span>
</template>
<script setup>
import SvgIcon from '@/libs/svg-icons/index.vue'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import { computed } from 'vue'
const props = defineProps({
icon: {
type: String,
required: true
},
title: {
type: String,
required: true
}
})
const iconComponent = computed(() => {
if (props.icon.startsWith('el-icon-')) {
// 移除 el-icon- 前缀并转换为大驼峰命名
const name = props.icon.substring(8)
const componentName = name.replace(/(^\w|-\w)/g, (match) =>
match.replace('-', '').toUpperCase()
)
// 返回对应的图标组件
return ElementPlusIconsVue[componentName]
}
return props.icon
})
</script>
<style lang="scss" scoped></style>样式问题
<el-menu
:background-color="$store.getters.cssVar.menuBg"
:text-color="$store.getters.cssVar.menuText"
:active-text-color="$store.getters.cssVar.menuActiveText"
:unique-opened="true"
>
路由跳转问题
| 属性名 | 说明 | 类型 | Default |
|---|---|---|---|
| router | 是否启用 vue-router 模式。 启用该模式会在激活导航时以 index 作为 path 进行路由跳转 使用 default-active 来设置加载时的激活项。 |
boolean |
false |
<el-menu
...
router
>
默认激活项
import { useRouter } from 'vue-router'
const router = useRouter()
const activeMenu = computed(() => {
const { path } = router.currentRoute.value
return path
})
src\layout\components\sidebar\menu.vue
<template>
<el-menu :uniqueOpened="true" :default-active="activeMenu" :background-color="store.state.menuBg"
:text-color="store.state.menuText" :active-text-color="store.state.menuActiveText" router>
<!-- 包含子集 menu 菜单 -->
<template v-for="item in menuList" :key="item.path">
<el-sub-menu :index="item.path" v-if="item.children && item.children.length > 0">
<template #title>
<MenuIcon :icon="item.meta.icon" :title="item.meta.title" />
</template>
<el-menu-item v-for="children in item.children" :key="children.path" :index="children.path">
<MenuIcon :icon="children.meta.icon" :title="children.meta.title" />
</el-menu-item>
</el-sub-menu>
<!-- 一级 menu 菜单 -->
<el-menu-item v-else :index="item.path">
<MenuIcon :icon="item.meta.icon" :title="item.meta.title" />
</el-menu-item>
</template>
</el-menu>
</template>
<script setup>
// import router from '@/router';
import { getOptimalRoute, getMenuList } from '@/utils/routerHelper'
import { useRouter } from 'vue-router'
import MenuIcon from './menuIcon.vue'
import { useVarCssStore } from '@/store/modules/useVariableStore'
import { computed } from 'vue'
const store = useVarCssStore()
const router = useRouter()
const routes = router.getRoutes()
// console.log(routes)
const optimalRoutes = getOptimalRoute(routes)
// console.log('optimalRoutes', optimalRoutes)
const menuList = getMenuList(optimalRoutes)
// console.log('getMenu', menuList)
/**
* 获取当前激活的菜单
*/
const activeMenu = computed(() => {
const { path } = router.currentRoute.value
return path
})
</script>
<style lang="scss" scoped>
.subMenuIconItem {
display: flex;
}
</style>
左侧菜单伸缩功能实现,动画逻辑

添加一个变量hiddenSideBarWidth
src\styles\sass\variables.module.scss
...
$sideBarWidth: 210px;
$hiddenSideBarWidth: 54px;
// https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass
// JS 与 scss 共享变量,在 scss 中通过 :export 进行导出,在 js 中可通过 ESM 进行导入
:export {
menuText: $menuText;
menuActiveText: $menuActiveText;
subMenuActiveText: $subMenuActiveText;
menuBg: $menuBg;
menuHover: $menuHover;
subMenuBg: $subMenuBg;
subMenuHover: $subMenuHover;
sideBarWidth: $sideBarWidth;
hiddenSideBarWidth: $hiddenSideBarWidth;
}创建一个获取宽度的sotre
src\store\modules\useCommonStore.js
import { defineStore } from 'pinia'
import { useVarCssStore } from '@/store/modules/useVariableStore'
import { computed, ref } from 'vue'
export const useCommonStore = defineStore('commonStore', () => {
const store = useVarCssStore()
const { sideBarWidth, hiddenSideBarWidth } = store.state
const state = ref({
/**
* 侧边栏是否展开
*/
isOpenMenu: true,
/**
* 侧边栏容器宽度,根据isOpenMenu的值来决定
*/
sidebarContainerWidth: computed(() =>
state.value.isOpenMenu ? sideBarWidth : hiddenSideBarWidth
),
/**
* 主要内容容器宽度,根据isOpenMenu的值来决定
*/
mainContainerWidth: computed(() => `calc(100% - ${state.value.sidebarContainerWidth})`)
})
/**
* 侧边栏展开和关闭
*/
function OpenOrCloseMenu() {
state.value.isOpenMenu = !state.value.isOpenMenu
}
return { state, OpenOrCloseMenu }
})
menu收缩
src\layout\components\sidebar\menu.vue
<template>
<el-menu :uniqueOpened="true" :default-active="activeMenu" :background-color="store.state.menuBg"
:text-color="store.state.menuText" :active-text-color="store.state.menuActiveText"
:collapse="!commonStore.state.isOpenMenu" router>
<!-- 包含子集 menu 菜单 -->
<template v-for="item in menuList" :key="item.path">
<el-sub-menu :index="item.path" v-if="item.children && item.children.length > 0">
<template #title>
<MenuIcon :icon="item.meta.icon" :title="item.meta.title" />
</template>
<el-menu-item v-for="children in item.children" :key="children.path" :index="children.path">
<MenuIcon :icon="children.meta.icon" :title="children.meta.title" />
</el-menu-item>
</el-sub-menu>
<!-- 一级 menu 菜单 -->
<el-menu-item v-else :index="item.path">
<MenuIcon :icon="item.meta.icon" :title="item.meta.title" />
</el-menu-item>
</template>
</el-menu>
</template>
<script setup>
// import router from '@/router';
import { getOptimalRoute, getMenuList } from '@/utils/routerHelper'
import { useRouter } from 'vue-router'
import MenuIcon from './menuIcon.vue'
import { useVarCssStore } from '@/store/modules/useVariableStore'
import { computed } from 'vue'
import { useCommonStore } from '@/store/modules/useCommonStore'
const commonStore = useCommonStore()
const store = useVarCssStore()
const router = useRouter()
const routes = router.getRoutes()
// console.log(routes)
const optimalRoutes = getOptimalRoute(routes)
// console.log('optimalRoutes', optimalRoutes)
const menuList = getMenuList(optimalRoutes)
// console.log('getMenu', menuList)
/**
* 获取当前激活的菜单
*/
const activeMenu = computed(() => {
const { path } = router.currentRoute.value
return path
})
</script>
<style lang="scss" scoped>
.subMenuIconItem {
display: flex;
}
</style>顶部图标和样式
src\layout\components\navbar.vue
<template>
<div class="navbar">
<div class="left-menu">
<div class="hamburger" @click="toggleSidebar">
<SvgIcon icon="hamburger-opened" class="hamburger-icon" v-show="commonStore.state.isOpenMenu" />
<SvgIcon icon="hamburger-closed" class="hamburger-icon" v-show="!commonStore.state.isOpenMenu" />
</div>
</div>
<div class="right-menu">
<!-- 头像 -->
<el-dropdown class="avatar-container" trigger="click">
<div class="avatar-wrapper">
<el-avatar shape="square" :size="40" :src="store.state.userInfo.avatar" />
<i class="el-icon-s-tools"></i>
</div>
<template #dropdown>
<el-dropdown-menu class="user-dropdown">
<router-link to="/">
<el-dropdown-item> 首页 </el-dropdown-item>
</router-link>
<a target="_blank" href="">
<el-dropdown-item>课程主页</el-dropdown-item>
</a>
<el-dropdown-item divided @click="logout">
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<script setup>
import { useUserStore } from '@/store'
import { useCommonStore } from '@/store/modules/useCommonStore'
const commonStore = useCommonStore()
const store = useUserStore()
function logout() {
store.logout()
}
function toggleSidebar() {
commonStore.OpenOrCloseMenu()
}
</script>
<style lang="scss" scoped>
.navbar {
height: 50px;
overflow: hidden;
position: relative;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
.right-menu {
display: flex;
align-items: center;
float: right;
padding-right: 16px;
::v-deep .avatar-container {
cursor: pointer;
.avatar-wrapper {
margin-top: 5px;
position: relative;
.el-avatar {
--el-avatar-background-color: none;
margin-right: 12px;
}
}
}
}
.left-menu {
display: inline-flex;
.hamburger {
width: 50px;
padding: 12px;
cursor: pointer;
&:hover {
background: rgba(0, 0, 0, .1);
}
.hamburger-icon {
width: 20px;
height: 20px;
}
}
}
}
</style>左侧容器宽度和右侧容器宽度
src\layout\index.vue

navBar的面包屑

route.matched - 路由记录数组
在 Vue Router 中,route.matched 是 当前路由对应的「路由记录数组」,核心作用是存储从「根路由」到「当前路由」的所有嵌套路由规则,是实现嵌套路由渲染、权限控制、面包屑导航等功能的关键属性。
- 本质:一个数组,包含「从根路由到当前路由」的所有 路由记录(Route Record)。
- 顺序:数组第一项是「根路由记录」,最后一项是「当前路由的直接父路由记录」,倒数第二项是「当前路由记录」(若有嵌套则按层级排列)。
- 触发时机:路由切换时,
matched会自动更新为当前路由链的所有路由记录。
src\layout\components\breadcrumb\index.vue
<template>
<el-breadcrumb separator="/">
<el-breadcrumb-item v-for="(item, index) in matched" :key="item.path"
:to="index === matched.length - 1 ? undefined : { path: item.path }">
<span :style="{ color: index === matched.length - 1 ? '#97a8be' : 'black' }">{{ item.meta.title }}</span>
</el-breadcrumb-item>
</el-breadcrumb>
</template>
<script setup>
import { useRoute } from 'vue-router';
import { computed } from 'vue';
const route = useRoute();
// 计算面包屑数据
const matched = computed(() => {
// 过滤掉没有标题的路由,并排除根路径
return route.matched.filter(item => {
return item.meta && item.meta.title && item.meta.title !== 'Root';
});
});
</script>
<style lang="scss" scoped></style>
添加一个动画效果 - TransitionGroup

在 Vue 中,<TransitionGroup> 是专门用于列表过渡动画的组件,它能为列表中的新增、删除、排序变化等操作添加平滑过渡效果,是对 <Transition> 组件的补充(<Transition> 主要用于单个元素 / 组件的过渡)。
一、核心特性
- 针对列表:专门处理
v-for渲染的列表,支持动态新增 / 删除列表项、排序变化等场景。 - 包裹多个元素:与
<Transition>不同,<TransitionGroup>可以直接包裹多个元素(无需单一根节点)。 - 真实元素:默认会渲染为一个
<span>标签,可通过tag属性指定其他标签(如<ul>、<div>)。 - 过渡类名:与
<Transition>类似,但新增了移动过渡类名(用于排序变化)。
1. 基本结构
<template>
<TransitionGroup tag="ul" name="list">
<li
v-for="item in items"
:key="item.id" <!-- 必须有唯一key -->
>
{{ item.name }}
</li>
</TransitionGroup>
</template>
tag:指定渲染的容器标签(默认<span>),如列表常用tag="ul"。name:过渡类名的前缀(与<Transition>一致)。- 必须为列表项添加唯一
key(否则无法正确检测变化)。
2. 过渡类名
<TransitionGroup> 包含两类过渡类名:
- 进入 / 离开类:与
<Transition>相同,用于元素新增 / 删除时的动画:name-enter-from:进入开始状态name-enter-active:进入活跃状态name-enter-to:进入结束状态name-leave-from:离开开始状态name-leave-active:离开活跃状态name-leave-to:离开结束状态
- 移动类(特有):用于元素位置变化(如排序)时的动画:
name-move:元素移动过程中应用的类(通常配合transform实现平滑位移)
因为面包屑下面是 v-for 的结构
src\layout\components\breadcrumb\index.vue
<template>
<el-breadcrumb separator="/">
<TransitionGroup name="breadcrumb">
<el-breadcrumb-item v-for="(item, index) in matched" :key="item.path"
:to="index === matched.length - 1 ? undefined : { path: item.path }">
<span :style="{ color: index === matched.length - 1 ? '#97a8be' : 'black' }">{{ item.meta.title }}</span>
</el-breadcrumb-item>
</TransitionGroup>
</el-breadcrumb>
</template>
<script setup>
import { useRoute } from 'vue-router';
import { computed } from 'vue';
const route = useRoute();
// 计算面包屑数据
const matched = computed(() => {
// 过滤掉没有标题的路由,并排除根路径
return route.matched.filter(item => {
return item.meta && item.meta.title;
});
});
</script>
<style lang="scss" scoped>
.breadcrumb-enter-active,
.breadcrumb-leave-active {
transition: all 0.5s ease-in-out;
}
.breadcrumb-enter-from,
.breadcrumb-leave-to {
opacity: 0;
transform: translateX(30px);
}
.breadcrumb-leave-active {
position: absolute;
}
</style>国际化 vue-i18n
使用 vue-i18n 进行实现,看看当前用哪个版本。
安装
npm install vue-i18n@11
src\i18n\index.js
import { createI18n } from 'vue-i18n'
const messages = {
en: {
msg: {
hello: 'hello world'
}
},
zh: {
msg: {
hello: '你好,世界'
}
}
}
const locale = 'en'
const i18n = createI18n({
// 使用 Composition API 模式,则需要将其设置为false
legacy: false,
// 全局注入 $t 函数
globalInjection: true,
locale,
messages
})
export default i18n
在 main.js 中导入
// i18n (PS:导入放到 APP.vue 导入之前,因为后面我们会在 app.vue 中使用国际化内容)
import i18n from '@/i18n'
...
app.use(i18n)
使用 i18n
<h1 class="logo-title" v-if="$store.getters.sidebarOpened">
{{ $t('msg.test') }}
</h1>
elementPlus 国际化

创建插件
src\plugins\elementPlus.js
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import en from 'element-plus/es/locale/lang/en'
import { useCommonStore } from '@/store/modules/useCommonStore'
export default (app) => {
const { state } = useCommonStore()
const locale = state.language === 'zh' ? zhCn : en
app.use(ElementPlus, {
locale: locale
})
}
main.js
1 注释掉前面注册的 ElementPlus
2 重新使用带本地化的 ElementPlus 注册
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import '@/styles/sass/index.scss'
import App from './App.vue'
import router from './router'
// import ElementPlus from 'element-plus' // -- 注释掉
import 'element-plus/dist/index.css'
import { setupSvgIcon } from '@/assets/icons'
import 'virtual:svg-icons-register' // 引入SVG注册脚本
import '@/api/mock' //mock.js
import '@/utils/permisson' //全局路由守卫
import i18n from '@/i18n'
import elementPlusLocales from '@/plugins/elementPlus'
const app = createApp(App)
setupSvgIcon(app)
app.use(createPinia())
app.use(router)
// app.use(ElementPlus) // -- 注释掉
app.use(i18n) //i18n 语言包使用
elementPlusLocales(app) //element-plus 语言包使用
app.mount('#app')公共的 pinia ,不要忘记 getItem 和 setItem 是存 localStore
src\store\modules\useCommonStore.js
import { defineStore } from 'pinia'
import { useVarCssStore } from '@/store/modules/useVariableStore'
import { computed, ref } from 'vue'
import { getItem, setItem } from '@/utils/localstorage'
export const useCommonStore = defineStore('commonStore', () => {
const store = useVarCssStore()
const { sideBarWidth, hiddenSideBarWidth } = store.state
const state = ref({
/**
* 侧边栏是否展开
*/
isOpenMenu: true,
/**
* 侧边栏容器宽度,根据isOpenMenu的值来决定
*/
sidebarContainerWidth: computed(() =>
state.value.isOpenMenu ? sideBarWidth : hiddenSideBarWidth
),
/**
* 主要内容容器宽度,根据isOpenMenu的值来决定
*/
mainContainerWidth: computed(() => `calc(100% - ${state.value.sidebarContainerWidth})`),
language: getItem('language') || 'zh'
})
/**
* 侧边栏展开和关闭
*/
function OpenOrCloseMenu() {
state.value.isOpenMenu = !state.value.isOpenMenu
}
/**
* 切换语言
* @param {*} lang
*/
function toggleLanguage(lang) {
const newLanguage = lang
setItem('language', newLanguage)
state.value.language = newLanguage
}
return { state, OpenOrCloseMenu, toggleLanguage }
})实际切换应用
<template>
<div class="navbar">
<div class="left-menu">
<div class="hamburger" @click="toggleSidebar">
<SvgIcon icon="hamburger-opened" class="hamburger-icon" v-show="commonStore.state.isOpenMenu" />
<SvgIcon icon="hamburger-closed" class="hamburger-icon" v-show="!commonStore.state.isOpenMenu" />
</div>
<div class="breadcrumb-container">
<Breadcrumb></Breadcrumb>
</div>
</div>
<div class="right-menu">
<!-- 国际化 切换语言 -->
<el-dropdown trigger="click" class="international" @command="toggleLanguage">
<div>
<el-tooltip content="国际化">
<SvgIcon icon="language" class="language-icon" />
</el-tooltip>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item :disabled="commonStore.state.language === 'zh'" command="zh">
中文
</el-dropdown-item>
<el-dropdown-item :disabled="commonStore.state.language === 'en'" command="en">
English
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 头像 -->
<el-dropdown class="avatar-container" trigger="click">
<div class="avatar-wrapper">
<el-avatar shape="square" :size="40" :src="store.state.userInfo.avatar" />
<i class="el-icon-s-tools"></i>
</div>
<template #dropdown>
<el-dropdown-menu class="user-dropdown">
<router-link to="/">
<el-dropdown-item> 首页 </el-dropdown-item>
</router-link>
<a target="_blank" href="">
<el-dropdown-item>课程主页</el-dropdown-item>
</a>
<el-dropdown-item divided @click="logout">
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<script setup>
import { useUserStore } from '@/store'
import { useCommonStore } from '@/store/modules/useCommonStore'
import Breadcrumb from '@/layout/components/breadcrumb/index.vue'
import { ElMessage } from 'element-plus'
import SvgIcon from '@/libs/svg-icons/index.vue'
import { useI18n } from 'vue-i18n'
const commonStore = useCommonStore()
const store = useUserStore()
const i18n = useI18n()
function logout() {
store.logout()
}
function toggleSidebar() {
commonStore.OpenOrCloseMenu()
}
// 切换语言的方法
function toggleLanguage(lang) {
i18n.locale.value = lang
commonStore.toggleLanguage(lang)
ElMessage.success('更新成功')
}
</script>
<style lang="scss" scoped>
.navbar {
height: 50px;
overflow: hidden;
position: relative;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
.right-menu {
display: flex;
align-items: center;
float: right;
padding-right: 16px;
.language-icon {
width: 24px;
height: 24px;
margin-right: 12px;
cursor: pointer;
}
:deep(.avatar-container) {
cursor: pointer;
.avatar-wrapper {
margin-top: 5px;
position: relative;
.el-avatar {
--el-avatar-background-color: none;
margin-right: 12px;
}
}
}
}
.left-menu {
display: inline-flex;
line-height: 50px;
align-items: center;
.hamburger {
width: 50px;
padding: 0px 12px;
cursor: pointer;
&:hover {
background: rgba(0, 0, 0, .1);
}
.hamburger-icon {
width: 20px;
height: 20px;
}
}
.breadcrumb-container {
height: 50px;
padding-top: 14px;
}
}
}
</style>自定义国际化
- 组件模板 / 选项式 API →
$t() - 组件外部(工具函数等)→
i18n.t()(需导入实例, 一般在 setup) - Vue 3 组合式 API(未使用
useI18n时)→i18n.global.t()
一、创建对应的语言
src\i18n\lang\en.js
src\i18n\lang\zh.js

src\router\index.js
locale: 'zh' 先给一个默认值,然后创建一个异步更新 updateI18nLanguage
import { createI18n } from 'vue-i18n'
import localen from './lang/en'
import localzh from './lang/zh'
const messages = {
en: {
msg: {
...localen
}
},
zh: {
msg: {
...localzh
}
}
}
// 创建 i18n 实例时使用默认语言
const i18n = createI18n({
// 使用 Composition API 模式,则需要将其设置为false
legacy: false,
// 全局注入 $t 函数
globalInjection: true,
locale: 'zh', // 默认使用中文
messages
})
// 导出一个函数,用于在 Pinia 初始化后更新语言设置
export async function updateI18nLanguage() {
try {
// 动态导入 store,避免在 i18n 初始化时就加载
// 使用 ES 模块的动态导入语法代替 require
const { useCommonStore } = await import('@/store/modules/useCommonStore')
const store = useCommonStore()
if (store?.state?.language) {
i18n.global.locale.value = store.state.language
}
} catch (error) {
console.error('更新语言设置失败:', error)
}
}
export default i18nmain.js 调用这个异步
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import '@/styles/sass/index.scss'
import App from './App.vue'
import router from './router'
// import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import { setupSvgIcon } from '@/assets/icons'
import 'virtual:svg-icons-register' // 引入SVG注册脚本
import '@/api/mock' //mock.js
import '@/utils/permisson' //全局路由守卫
import i18n, { updateI18nLanguage } from '@/i18n'
import elementPlusLocales from '@/plugins/elementPlus'
const app = createApp(App)
setupSvgIcon(app)
app.use(createPinia())
app.use(router)
// app.use(ElementPlus)
app.use(i18n) //i18n 语言包使用
// 在 Pinia 初始化后更新 i18n 语言设置
// 不等待异步操作完成,避免阻塞应用启动
updateI18nLanguage().catch((err) => {
console.error('初始化语言设置失败:', err)
})
elementPlusLocales(app) //element-plus 语言包使用
app.mount('#app')二、更新路由
创建一个显示title的方法
src\utils\i18n.js
import i18n from '@/i18n'
export function generateRouteTitle(title) {
return i18n.global.t('msg.route.' + title)
}
src\layout\components\sidebar\menu.vue
<template>
<el-menu :uniqueOpened="true" :default-active="activeMenu" :background-color="store.state.menuBg"
:text-color="store.state.menuText" :active-text-color="store.state.menuActiveText"
:collapse="!commonStore.state.isOpenMenu" router>
<!-- 包含子集 menu 菜单 -->
<template v-for="item in menuList" :key="item.path">
<el-sub-menu :index="item.path" v-if="item.children && item.children.length > 0">
<template #title>
<MenuIcon :icon="item.meta.icon" :title="generateRouteTitle(item.meta.title)" />
</template>
<el-menu-item v-for="children in item.children" :key="children.path" :index="children.path">
<MenuIcon :icon="children.meta.icon" :title="generateRouteTitle(children.meta.title)" />
</el-menu-item>
</el-sub-menu>
<!-- 一级 menu 菜单 -->
<el-menu-item v-else :index="item.path">
<MenuIcon :icon="item.meta.icon" :title="generateRouteTitle(item.meta.title)" />
</el-menu-item>
</template>
</el-menu>
....主题颜色替换
elementPlus 主题变化

变量src\constant\index.js
export const TOKEN = 'token'
//主题颜色
export const THEME_COLOR = '#409eff'
//默认颜色
export const DEFAULT_COLOR = '#409eff'
创建一个
src\store\modules\themeStore.js
import { defineStore } from 'pinia'
import { DEFAULT_COLOR } from '@/constant'
import { ref } from 'vue'
import { getItem, setItem } from '@/utils/localstorage'
export const useThemeStore = defineStore('themeStore', () => {
const state = ref({
themeColor: getItem('THEME_COLOR') || DEFAULT_COLOR
})
/**
* 设置主题颜色
* @param {*} color
*/
function setThemeColor(color) {
document.documentElement.style.setProperty('--el-color-primary', color) //html 设置主题颜色
// const style = document.createElement('style')
// style.innerHTML = `:root {
// --el-color-primary: ${color};
// }`
// document.head.appendChild(style)
state.value.themeColor = color //pinia 设置主题颜色
setItem('THEME_COLOR', color) //localStorage 设置主题颜色
}
return { state, setThemeColor }
})
添加到navBar.vue
<template>
<div class="navbar">
<div class="left-menu">
<div class="hamburger" @click="toggleSidebar">
<SvgIcon icon="hamburger-opened" class="hamburger-icon" v-show="commonStore.state.isOpenMenu" />
<SvgIcon icon="hamburger-closed" class="hamburger-icon" v-show="!commonStore.state.isOpenMenu" />
</div>
<div class="breadcrumb-container">
<Breadcrumb></Breadcrumb>
</div>
</div>
<div class="right-menu">
<!-- 主题切换 -->
<Selectheme></Selectheme>
<!-- 国际化 切换语言 -->
<el-dropdown trigger="click" class="international" @command="toggleLanguage">
<div>
<el-tooltip content="国际化">
<SvgIcon icon="language" class="language-icon" />
</el-tooltip>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item :disabled="commonStore.state.language === 'zh'" command="zh">
中文
</el-dropdown-item>
<el-dropdown-item :disabled="commonStore.state.language === 'en'" command="en">
English
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 头像 -->
<el-dropdown class="avatar-container" trigger="click">
<div class="avatar-wrapper">
<el-avatar shape="square" :size="40" :src="store.state.userInfo.avatar" />
<i class="el-icon-s-tools"></i>
</div>
<template #dropdown>
<el-dropdown-menu class="user-dropdown">
<router-link to="/">
<el-dropdown-item> {{ $t('msg.navBar.home') }} </el-dropdown-item>
</router-link>
<a target="_blank" href="">
<el-dropdown-item>{{ $t('msg.navBar.course') }} </el-dropdown-item>
</a>
<el-dropdown-item divided @click="logout">
{{ $t('msg.navBar.logout') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<script setup>
...修改主题
<template>
<el-select v-model="currentTheme" @change="changeTheme" placeholder="Select" class="select-popper">
<el-option label="默认主题" value="#409eff"></el-option>
<el-option label="深色主题" value="#B2203F"></el-option>
<el-option label="红色主题" value="red"></el-option>
</el-select>
<div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useThemeStore } from '@/store/modules/themeStore'
import { DEFAULT_COLOR } from '@/constant/index'
const currentTheme = ref(DEFAULT_COLOR)
const themeSotre = useThemeStore();
// 切换主题
const changeTheme = (theme) => {
themeSotre.setThemeColor(theme);
}
</script>
<style lang="scss" scoped>
.select-popper {
min-width: 110px;
padding-right: 10px;
/* 根据需求调整 */
}
</style>
初始化主题
src\App.vue
<script setup>
import { onMounted } from 'vue'
import { useThemeStore } from '@/store/modules/themeStore'
const themeSotre = useThemeStore();
// 初始化主题
onMounted(() => {
themeSotre.setThemeColor(themeSotre.state.themeColor);
})
</script>
<template>
<RouterView></RouterView>
</template>
<style scoped></style>
读取EXCEL

选择上传
xlsx 解析EXCEL
https://www.npmjs.com/package/xlsx
安装
npm i xlsx
src\views\import\index.vue
<template>
<upload-excel :onSuccess="onSuccess"></upload-excel>
</template>
<script setup>
import {} from 'vue'
import uploadExcel from '@/components/UploadExcel/index.vue'
const onSuccess = (res) => {
console.log('res', res)
}
</script>
<style lang="scss" scoped></style>
src\components\UploadExcel\utils.js
import * as XLSX from 'xlsx'
/**
* 获取表头(通用方式)
*/
export const getHeaderRow = (sheet) => {
const headers = []
const range = XLSX.utils.decode_range(sheet['!ref'])
let C
const R = range.s.r
/* start in the first row */
for (C = range.s.c; C <= range.e.c; ++C) {
/* walk every column in the range */
const cell = sheet[XLSX.utils.encode_cell({ c: C, r: R })]
/* find the cell in the first row */
let hdr = 'UNKNOWN ' + C // <-- replace with your desired default
if (cell && cell.t) hdr = XLSX.utils.format_cell(cell)
headers.push(hdr)
}
return headers
}
src\components\UploadExcel\index.vue
<template>
<div class="upload-excel">
<div class="btn-upload">
<el-button size="small" type="primary" @click="handleUpload">{{
$t('msg.uploadExcel.upload')
}}</el-button>
</div>
<input
ref="excelUploadInput"
type="file"
class="excel-upload-input"
accept=".xlsx, .xls"
@change="handleChange"
/>
<div
class="drop"
@drop.stop.prevent="handleDrop"
@dragover.stop.prevent="handleDragover"
@dragenter.stop.prevent="handleDragover"
>
<el-icon>
<SystemIcons icon="el-icon-UploadFilled" />
</el-icon>
<span>{{ $t('msg.uploadExcel.drop') }}</span>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import SystemIcons from '@/components/SystemIcons/index.vue'
import { getHeaderRow } from './utils.js'
import * as XLSX from 'xlsx'
const props = defineProps({
// 上传前回调
beforeUpload: Function,
// 成功回调
onSuccess: Function
})
const loading = ref(false)
const excelUploadInput = ref(null)
// #region 点击上传触发
/**
* 点击上传
*/
const handleUpload = () => {
excelUploadInput.value.click()
}
/**
* 选择文件后触发
* @param {*} e
*/
const handleChange = (e) => {
console.log('handleChange')
const files = e.target.files
if (files.length === 0) {
return
}
const rawFile = files[0]
uploadExcel(rawFile)
console.log(rawFile)
}
// #endregion
// #region 处理 excel 文件
const uploadExcel = (rawFile) => {
console.log('uploadExcel')
// 如果没有指定上传前回调的话
if (!props.beforeUpload) {
readerData(rawFile)
console.log('readerData-1')
return
}
// 如果指定了上传前回调,那么只有返回 true 才会执行后续操作
const before = props.beforeUpload(rawFile)
if (before) {
readerData(rawFile)
console.log('readerData-2')
}
}
/**
* 读取数据(异步)
*/
const readerData = (rawFile) => {
loading.value = true
return new Promise((resolve, reject) => {
// https://developer.mozilla.org/zh-CN/docs/Web/API/FileReader
const reader = new FileReader()
// 该事件在读取操作完成时触发
// https://developer.mozilla.org/zh-CN/docs/Web/API/FileReader/onload
reader.onload = (e) => {
// 1. 获取解析到的数据
const data = e.target.result
// 2. 利用 XLSX 对数据进行解析
const workbook = XLSX.read(data, { type: 'array' })
// 3. 获取第一张表格(工作簿)名称
const firstSheetName = workbook.SheetNames[0]
// 4. 只读取 Sheet1(第一张表格)的数据
const worksheet = workbook.Sheets[firstSheetName]
// 5. 解析数据表头
const header = getHeaderRow(worksheet)
// 6. 解析数据体
const results = XLSX.utils.sheet_to_json(worksheet)
// 7. 传入解析之后的数据
generateData({ header, results })
// 8. loading 处理
loading.value = false
// 9. 异步完成
resolve()
}
// 启动读取指定的 Blob 或 File 内容
reader.readAsArrayBuffer(rawFile)
})
}
/**
* 根据导入内容,生成数据
*/
const generateData = (excelData) => {
props.onSuccess && props.onSuccess(excelData)
}
// #endregion
// #region 拖放上传
const handleDrop = (e) => {
console.log('handleChange')
}
const handleDragover = (e) => {
console.log('handleChange')
}
// #endregion
</script>
<style lang="scss" scoped>
.upload-excel {
display: flex;
justify-content: center;
margin-top: 100px;
.excel-upload-input {
display: none;
z-index: -9999;
}
.btn-upload,
.drop {
border: 1px dashed #bbb;
width: 350px;
height: 160px;
text-align: center;
line-height: 160px;
}
.drop {
line-height: 60px;
display: flex;
flex-direction: column;
justify-content: center;
color: #bbb;
i {
font-size: 60px;
display: block;
}
.el-icon {
margin: 0 auto;
}
}
}
</style>
HTML 拖放
https://developer.mozilla.org/zh-CN/docs/Web/API/HTML_Drag_and_Drop_API
- drop (en-US):当元素或选中的文本在可释放目标上被释放时触发
- dragover (en-US):当元素或选中的文本被拖到一个可释放目标上时触发
- dragenter (en-US):当拖拽元素或选中的文本到一个可释放目标时触发
dropEffect 4 种类型
在 DragEvent.dataTransfer 中,dropEffect 用于定义拖拽操作的 “视觉反馈类型”,它决定了浏览器在拖拽过程中显示的鼠标图标样式,同时暗示拖拽操作的最终结果(如 “复制”“移动”“链接”)。dropEffect 的值需与 effectAllowed(定义拖拽源允许的操作类型)配合使用,仅当二者兼容时,对应的视觉反馈才会生效。
| 类型值 | 核心含义 | 兼容的 effectAllowed 值 |
典型鼠标图标(主流浏览器) | 适用场景 |
|---|---|---|---|---|
copy |
复制:拖拽元素的 “副本” 到目标 | copy、copyLink、copyMove、all |
带 “+” 号的箭头(表示 “复制一份”) | 文件复制、文本复制、图片另存到目标位置 |
move |
移动:拖拽元素 “本身” 到目标 | move、copyMove、linkMove、all |
带 “移动” 符号的箭头(表示 “转移位置”) | 文件移动、列表项调整顺序、任务从 A 列表移到 B 列表 |
link |
链接:目标与拖拽元素建立 “关联” | link、copyLink、linkMove、all |
带 “链接” 符号的箭头(表示 “创建关联”) | 拖拽 URL 到文本框生成链接、文件快捷方式创建 |
none |
禁止:目标不允许接收拖拽元素 | 所有值(但最终无有效反馈) | 带 “禁止” 符号的圆圈(表示 “不可放置”) | 目标区域不支持当前拖拽操作(如拖拽文件到纯文本输入框) |

<template>
<div class="upload-excel">
<div class="btn-upload">
<el-button size="small" type="primary" @click="handleUpload">{{
$t('msg.uploadExcel.upload')
}}</el-button>
</div>
<input
ref="excelUploadInput"
type="file"
class="excel-upload-input"
accept=".xlsx, .xls"
@change="handleChange"
/>
<div
class="drop"
@drop.stop.prevent="handleDrop"
@dragover.stop.prevent="handleDragover"
@dragenter.stop.prevent="handleDragover"
>
<el-icon>
<SystemIcons icon="el-icon-UploadFilled" />
</el-icon>
<span>{{ $t('msg.uploadExcel.drop') }}</span>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import SystemIcons from '@/components/SystemIcons/index.vue'
import { getHeaderRow, isExcel } from './utils.js'
import * as XLSX from 'xlsx'
import { ElMessage } from 'element-plus'
const props = defineProps({
// 上传前回调
beforeUpload: Function,
// 成功回调
onSuccess: Function
})
const loading = ref(false)
const excelUploadInput = ref(null)
// #region 点击上传触发
/**
* 点击上传
*/
const handleUpload = () => {
excelUploadInput.value.click()
}
/**
* 选择文件后触发
* @param {*} e
*/
const handleChange = (e) => {
console.log('handleChange')
const files = e.target.files
if (files.length === 0) {
return
}
const rawFile = files[0]
uploadExcel(rawFile)
console.log(rawFile)
}
// #endregion
// #region 处理 excel 文件
const uploadExcel = (rawFile) => {
console.log('uploadExcel')
// 如果没有指定上传前回调的话
if (!props.beforeUpload) {
readerData(rawFile)
console.log('readerData-1')
return
}
// 如果指定了上传前回调,那么只有返回 true 才会执行后续操作
const before = props.beforeUpload(rawFile)
if (before) {
readerData(rawFile)
console.log('readerData-2')
}
}
/**
* 读取数据(异步)
*/
const readerData = (rawFile) => {
loading.value = true
return new Promise((resolve, reject) => {
// https://developer.mozilla.org/zh-CN/docs/Web/API/FileReader
const reader = new FileReader()
// 该事件在读取操作完成时触发
// https://developer.mozilla.org/zh-CN/docs/Web/API/FileReader/onload
reader.onload = (e) => {
// 1. 获取解析到的数据
const data = e.target.result
// 2. 利用 XLSX 对数据进行解析
const workbook = XLSX.read(data, { type: 'array' })
// 3. 获取第一张表格(工作簿)名称
const firstSheetName = workbook.SheetNames[0]
// 4. 只读取 Sheet1(第一张表格)的数据
const worksheet = workbook.Sheets[firstSheetName]
// 5. 解析数据表头
const header = getHeaderRow(worksheet)
// 6. 解析数据体
const results = XLSX.utils.sheet_to_json(worksheet)
// 7. 传入解析之后的数据
generateData({ header, results })
// 8. loading 处理
loading.value = false
// 9. 异步完成
resolve()
}
// 启动读取指定的 Blob 或 File 内容
reader.readAsArrayBuffer(rawFile)
})
}
/**
* 根据导入内容,生成数据
*/
const generateData = (excelData) => {
props.onSuccess && props.onSuccess(excelData)
}
// #endregion
// #region 拖放上传
const handleDrop = (e) => {
// 上传中跳过
if (loading.value) return
const files = e.dataTransfer.files
if (files.length !== 1) {
ElMessage.error('必须要有一个文件')
return
}
const rawFile = files[0]
if (!isExcel(rawFile)) {
ElMessage.error('文件必须是 .xlsx, .xls, .csv 格式')
return false
}
// 触发上传事件
uploadExcel(rawFile)
}
const handleDragover = (dragEvent) => {
dragEvent.dataTransfer.dropEffect = 'copy'
}
// #endregion
</script>
<style lang="scss" scoped>
.upload-excel {
display: flex;
justify-content: center;
margin-top: 100px;
.excel-upload-input {
display: none;
z-index: -9999;
}
.btn-upload,
.drop {
border: 1px dashed #bbb;
width: 350px;
height: 160px;
text-align: center;
line-height: 160px;
}
.drop {
line-height: 60px;
display: flex;
flex-direction: column;
justify-content: center;
color: #bbb;
i {
font-size: 60px;
display: block;
}
.el-icon {
margin: 0 auto;
}
}
}
</style>export const isExcel = (file) => {
return /\.(xlsx|xls|csv)$/.test(file.name)
}

浙公网安备 33010602011771号