vue - 项目实战3

0 一些遇到的问题

1 如果vite项目,JS项目(不是TS),“快捷修复” 没有导入 ref 功能

  • checkJs:对 JavaScript 文件也进行类型检查(结合 JSDoc 注释生效)
  • image

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

image

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 重复路由要处理

image

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显示图标和文本

image

这里因为要和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>

image

左侧菜单伸缩功能实现,动画逻辑

PixPin_2025-10-05_19-23-43

添加一个变量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

image

navBar的面包屑

image

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

_2025-10-06_00-12-54

在 Vue 中,<TransitionGroup> 是专门用于列表过渡动画的组件,它能为列表中的新增、删除、排序变化等操作添加平滑过渡效果,是对 <Transition> 组件的补充(<Transition> 主要用于单个元素 / 组件的过渡)。

一、核心特性

  1. 针对列表:专门处理 v-for 渲染的列表,支持动态新增 / 删除列表项、排序变化等场景。
  2. 包裹多个元素:与 <Transition> 不同,<TransitionGroup> 可以直接包裹多个元素(无需单一根节点)。
  3. 真实元素:默认会渲染为一个 <span> 标签,可通过 tag 属性指定其他标签(如 <ul>、<div>)。
  4. 过渡类名:与 <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 国际化

2025-10-06_22-29-23

创建插件

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

image

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 i18n

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' //全局路由守卫
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 主题变化

PixPin_2025-10-09_16-58-55

变量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

image

选择上传

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 禁止:目标不允许接收拖拽元素 所有值(但最终无有效反馈) 带 “禁止” 符号的圆圈(表示 “不可放置”) 目标区域不支持当前拖拽操作(如拖拽文件到纯文本输入框)

image

<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)
}
posted @ 2025-10-03 14:39  【唐】三三  阅读(72)  评论(0)    收藏  举报