From 7065e5329a9d30ea8df98f4be09d338b283aae63 Mon Sep 17 00:00:00 2001 From: molong Date: Wed, 14 Jan 2026 14:49:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 2 +- package.json | 2 + src/assets/css/auth.css | 154 ++++++ src/assets/css/nprogress.css | 76 +++ src/config/index.js | 1 + src/config/routes.js | 18 + src/i18n/locales/en-US.js | 42 +- src/i18n/locales/zh-CN.js | 42 +- src/layouts/other/404.vue | 401 ++++++++++++++++ src/layouts/other/empty.vue | 288 ++++++++++++ src/main.js | 3 + src/pages/home/{Dashboard.vue => index.vue} | 0 src/pages/login/index.vue | 494 +++++--------------- src/pages/login/resetPassword.vue | 196 ++++++++ src/pages/login/userRegister.vue | 162 +++++++ src/router/index.js | 331 +++++++++++-- src/router/scrollBehavior.js | 30 ++ src/router/systemRouter.js | 36 ++ src/stores/modules/layout.js | 30 ++ src/stores/modules/user.js | 24 + src/utils/request.js | 3 +- src/utils/tool.js | 180 +++++++ yarn.lock | 134 ++++++ 23 files changed, 2236 insertions(+), 413 deletions(-) create mode 100644 src/assets/css/auth.css create mode 100644 src/assets/css/nprogress.css create mode 100644 src/config/routes.js create mode 100644 src/layouts/other/404.vue create mode 100644 src/layouts/other/empty.vue rename src/pages/home/{Dashboard.vue => index.vue} (100%) create mode 100644 src/pages/login/resetPassword.vue create mode 100644 src/pages/login/userRegister.vue create mode 100644 src/router/scrollBehavior.js create mode 100644 src/router/systemRouter.js create mode 100644 src/utils/tool.js diff --git a/.editorconfig b/.editorconfig index 8065bf2..17e22af 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,4 +5,4 @@ indent_style = tab insert_final_newline = true trim_trailing_whitespace = true end_of_line = lf -max_line_length = 100 +max_line_length = 260 diff --git a/package.json b/package.json index cb65b76..f345c54 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "format": "prettier --write --experimental-cli src/" }, "dependencies": { + "@element-plus/icons-vue": "^2.3.2", "axios": "^1.13.2", + "element-plus": "^2.13.1", "nprogress": "^0.2.0", "pinia": "^3.0.4", "vue": "^3.5.26", diff --git a/src/assets/css/auth.css b/src/assets/css/auth.css new file mode 100644 index 0000000..2bd4c04 --- /dev/null +++ b/src/assets/css/auth.css @@ -0,0 +1,154 @@ +/* 认证页面统一样式 */ +.auth-container { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + padding: 20px; + position: relative; + overflow: hidden; +} + +.auth-container::before { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 50%); + animation: rotate 20s linear infinite; +} + +@keyframes rotate { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.auth-card { + width: 100%; + max-width: 420px; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + border-radius: 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + padding: 40px; + position: relative; + z-index: 1; +} + +.auth-header { + text-align: center; + margin-bottom: 32px; +} + +.auth-header .logo { + font-size: 48px; + margin-bottom: 16px; + color: #667eea; +} + +.auth-header h1 { + font-size: 28px; + font-weight: 600; + color: #2c3e50; + margin: 0 0 8px 0; +} + +.auth-header p { + font-size: 14px; + color: #7f8c8d; + margin: 0; +} + +.auth-form { + margin-bottom: 24px; +} + +.auth-actions { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.auth-actions .forgot-link { + color: #667eea; + text-decoration: none; + font-size: 14px; + transition: color 0.3s ease; +} + +.auth-actions .forgot-link:hover { + color: #764ba2; +} + +.auth-footer { + text-align: center; + padding-top: 24px; + border-top: 1px solid #eaeaea; +} + +.auth-footer .link { + color: #667eea; + text-decoration: none; + font-size: 14px; + font-weight: 500; + transition: color 0.3s ease; +} + +.auth-footer .link:hover { + color: #764ba2; +} + +.auth-footer .text { + color: #7f8c8d; + font-size: 14px; + margin: 0 4px; +} + +.code-button { + width: 100%; + height: 44px; + border-radius: 8px; + border: 1px solid #667eea; + background: transparent; + color: #667eea; + font-size: 14px; + transition: all 0.3s ease; +} + +.code-button:hover:not(:disabled) { + background: rgba(102, 126, 234, 0.05); + color: #764ba2; + border-color: #764ba2; +} + +.code-button:disabled { + border-color: #dcdfe6; + color: #c0c4cc; + cursor: not-allowed; +} + +.language-switcher { + position: absolute; + top: 20px; + right: 20px; + z-index: 10; +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .auth-card { + padding: 30px 20px; + } + + .auth-header h1 { + font-size: 24px; + } +} diff --git a/src/assets/css/nprogress.css b/src/assets/css/nprogress.css new file mode 100644 index 0000000..26bcd47 --- /dev/null +++ b/src/assets/css/nprogress.css @@ -0,0 +1,76 @@ +/* 自定义 NProgress 样式 */ +#nprogress { + pointer-events: none; +} + +#nprogress .bar { + background: linear-gradient(90deg, #00d4ff 0%, #7c4dff 100%); + position: fixed; + z-index: 9999; + top: 0; + left: 0; + width: 100%; + height: 3px; + box-shadow: 0 0 10px rgba(0, 212, 255, 0.5); +} + +/* 螺旋加载器样式 */ +#nprogress .peg { + display: block; + position: absolute; + right: 0; + width: 100px; + height: 100%; + box-shadow: + 0 0 10px #00d4ff, + 0 0 5px #7c4dff; + opacity: 1; + transform: rotate(3deg) translate(0px, -4px); +} + +/* 进度条动画 */ +@keyframes nprogress-spinner { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +/* 如果启用加载器时的样式 */ +#nprogress .spinner { + display: block; + position: fixed; + z-index: 9999; + top: 15px; + right: 15px; +} + +#nprogress .spinner-icon { + width: 18px; + height: 18px; + box-sizing: border-box; + border: solid 2px transparent; + border-top-color: #00d4ff; + border-left-color: #7c4dff; + border-radius: 50%; + animation: nprogress-spinner 400ms linear infinite; +} + +/* 进度条闪烁效果 */ +@keyframes nprogress-flicker { + 0% { + opacity: 1; + } + 50% { + opacity: 0.5; + } + 100% { + opacity: 1; + } +} + +#nprogress .bar { + animation: nprogress-flicker 0.5s ease-in-out; +} diff --git a/src/config/index.js b/src/config/index.js index 3ad4b5f..003977f 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -1,4 +1,5 @@ export default { app_title: 'vueadmin', + DASHBOARD_URL: '/home', baseURL: '' } diff --git a/src/config/routes.js b/src/config/routes.js new file mode 100644 index 0000000..cf2bb94 --- /dev/null +++ b/src/config/routes.js @@ -0,0 +1,18 @@ +/** + * 用户静态路由配置 + * 这些路由会根据用户角色进行过滤后添加到路由中 + */ +const userRoutes = [ + { + path: '/home', + name: 'home', + component: 'home', + meta: { + title: 'dashboard', + icon: 'el-icon-odometer', + role: ['admin'] + } + } +] + +export default userRoutes diff --git a/src/i18n/locales/en-US.js b/src/i18n/locales/en-US.js index 0a1ef77..7e25d76 100644 --- a/src/i18n/locales/en-US.js +++ b/src/i18n/locales/en-US.js @@ -70,7 +70,47 @@ export default { usernamePlaceholder: 'Please enter username', passwordPlaceholder: 'Please enter password', noAccount: "Don't have an account?", - registerNow: 'Register Now' + registerNow: 'Register Now', + forgotPassword: 'Forgot Password?', + rememberMe: 'Remember Me' + }, + register: { + title: 'User Registration', + subtitle: 'Create your account and get started', + registerButton: 'Register', + registerSuccess: 'Registration Successful', + registerFailed: 'Registration Failed', + usernamePlaceholder: 'Please enter username', + emailPlaceholder: 'Please enter email address', + passwordPlaceholder: 'Please enter password', + confirmPasswordPlaceholder: 'Please enter password again', + usernameRule: 'Username length between 3 to 20 characters', + emailRule: 'Please enter a valid email address', + passwordRule: 'Password length between 6 to 20 characters', + agreeRule: 'Please agree to the user agreement', + agreeTerms: 'I have read and agree to the', + terms: 'User Agreement', + hasAccount: 'Already have an account?', + loginNow: 'Login Now' + }, + resetPassword: { + title: 'Reset Password', + subtitle: 'Reset your password via email verification code', + resetButton: 'Reset Password', + resetSuccess: 'Password reset successful', + resetFailed: 'Reset failed', + emailPlaceholder: 'Please enter email address', + codePlaceholder: 'Please enter verification code', + newPasswordPlaceholder: 'Please enter new password', + confirmPasswordPlaceholder: 'Please enter new password again', + emailRule: 'Please enter a valid email address', + codeRule: 'Verification code must be 6 characters', + passwordRule: 'Password length between 6 to 20 characters', + sendCode: 'Send Code', + codeSent: 'Verification code has been sent to your email', + resendCode: 'Resend in {seconds} seconds', + sendCodeFirst: 'Please enter email address first', + backToLogin: 'Back to Login' }, layout: { toggleSidebar: 'Toggle Sidebar', diff --git a/src/i18n/locales/zh-CN.js b/src/i18n/locales/zh-CN.js index c86871d..3e21999 100644 --- a/src/i18n/locales/zh-CN.js +++ b/src/i18n/locales/zh-CN.js @@ -70,7 +70,47 @@ export default { usernamePlaceholder: '请输入用户名', passwordPlaceholder: '请输入密码', noAccount: '还没有账户?', - registerNow: '立即注册' + registerNow: '立即注册', + forgotPassword: '忘记密码?', + rememberMe: '记住我' + }, + register: { + title: '用户注册', + subtitle: '创建您的账户,开始使用', + registerButton: '注册', + registerSuccess: '注册成功', + registerFailed: '注册失败', + usernamePlaceholder: '请输入用户名', + emailPlaceholder: '请输入邮箱地址', + passwordPlaceholder: '请输入密码', + confirmPasswordPlaceholder: '请再次输入密码', + usernameRule: '用户名长度在 3 到 20 个字符', + emailRule: '请输入正确的邮箱地址', + passwordRule: '密码长度在 6 到 20 个字符', + agreeRule: '请同意用户协议', + agreeTerms: '我已阅读并同意', + terms: '用户协议', + hasAccount: '已有账户?', + loginNow: '立即登录' + }, + resetPassword: { + title: '重置密码', + subtitle: '通过邮箱验证码重置您的密码', + resetButton: '重置密码', + resetSuccess: '密码重置成功', + resetFailed: '重置失败', + emailPlaceholder: '请输入邮箱地址', + codePlaceholder: '请输入验证码', + newPasswordPlaceholder: '请输入新密码', + confirmPasswordPlaceholder: '请再次输入新密码', + emailRule: '请输入正确的邮箱地址', + codeRule: '验证码长度为6位', + passwordRule: '密码长度在 6 到 20 个字符', + sendCode: '发送验证码', + codeSent: '验证码已发送到您的邮箱', + resendCode: '{seconds}秒后重新发送', + sendCodeFirst: '请先输入邮箱地址', + backToLogin: '返回登录' }, layout: { toggleSidebar: '切换侧边栏', diff --git a/src/layouts/other/404.vue b/src/layouts/other/404.vue new file mode 100644 index 0000000..e076afb --- /dev/null +++ b/src/layouts/other/404.vue @@ -0,0 +1,401 @@ + + + + + diff --git a/src/layouts/other/empty.vue b/src/layouts/other/empty.vue new file mode 100644 index 0000000..bdf1bc8 --- /dev/null +++ b/src/layouts/other/empty.vue @@ -0,0 +1,288 @@ + + + + + diff --git a/src/main.js b/src/main.js index 1082a3a..c2ddecf 100644 --- a/src/main.js +++ b/src/main.js @@ -1,5 +1,7 @@ import { createApp } from 'vue' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' import App from './App.vue' import router from './router' import pinia from './stores' @@ -8,6 +10,7 @@ import { useI18nStore } from './stores/modules/i18n' const app = createApp(App) +app.use(ElementPlus) app.use(router) app.use(pinia) app.use(i18n) diff --git a/src/pages/home/Dashboard.vue b/src/pages/home/index.vue similarity index 100% rename from src/pages/home/Dashboard.vue rename to src/pages/home/index.vue diff --git a/src/pages/login/index.vue b/src/pages/login/index.vue index 6fdef84..e817032 100644 --- a/src/pages/login/index.vue +++ b/src/pages/login/index.vue @@ -1,399 +1,147 @@ - - diff --git a/src/pages/login/resetPassword.vue b/src/pages/login/resetPassword.vue new file mode 100644 index 0000000..e9619ec --- /dev/null +++ b/src/pages/login/resetPassword.vue @@ -0,0 +1,196 @@ + + + diff --git a/src/pages/login/userRegister.vue b/src/pages/login/userRegister.vue new file mode 100644 index 0000000..6898a8a --- /dev/null +++ b/src/pages/login/userRegister.vue @@ -0,0 +1,162 @@ + + + diff --git a/src/router/index.js b/src/router/index.js index 0df551a..7354a51 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -1,57 +1,316 @@ import { createRouter, createWebHistory } from 'vue-router' import { useUserStore } from '@/stores/modules/user' -import Layout from '@/layouts/index.vue' +import { ElNotification } from 'element-plus' +import systemRouter from './systemRouter' +import userRoutes from '@/config/routes' +import NProgress from 'nprogress' +import tool from '@/utils/tool' +import i18n from '@/i18n' +import { beforeEach, afterEach } from './scrollBehavior' +import '@/assets/css/nprogress.css' -const routes = [ - { - path: '/login', - name: 'Login', - component: () => import('@/pages/login/index.vue'), - meta: { requiresAuth: false }, - }, - { - path: '/', - name: 'Dashboard', - component: Layout, - redirect: '/home', - meta: { requiresAuth: true }, - children: [ - { - path: '/home', - name: 'Home', - component: () => import('@/pages/home/Dashboard.vue'), - meta: { requiresAuth: true }, - }, - ], - }, -] +// 配置 NProgress +NProgress.configure({ + easing: 'ease', + speed: 500, + showSpinner: false, + trickleSpeed: 200, + minimum: 0.3 +}) + +// 匹配pages里面所有的.vue文件 +const modules = import.meta.glob('@/pages/**/*.vue') + +// 特殊路由模块 +const otherModules = { + '404': () => import('@/layouts/other/404.vue'), + empty: () => import('@/layouts/other/empty.vue') +} + +// 系统路由 +const routes = systemRouter + +// 是否已加载过动态/静态路由 +let isGetRouter = false + +// 404路由移除函数 +let routes_404_r = null const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), - routes: routes, + routes: routes }) +/** + * 设置页面标题 + * @param {Object} meta - 路由元信息 + */ +const setPageTitle = (meta) => { + const title = 'VueAdmin' + if (meta?.title) { + try { + const translatedTitle = i18n.global.te(meta.title) ? i18n.global.t(meta.title) : meta.title + document.title = `${translatedTitle} - ${title}` + } catch (error) { + document.title = `${meta.title} - ${title}` + } + } else { + document.title = title + } +} + +/** + * 检查路由是否需要认证 + * @param {Object} to - 目标路由 + * @returns {boolean} + */ +const checkAuthRequired = (to) => { + return to.matched.some((record) => record.meta.requiresAuth !== false) +} + +/** + * 移除404路由 + */ +const remove404Route = () => { + if (routes_404_r) { + router.removeRoute('404') + routes_404_r = null + } +} + +/** + * 添加404路由 + */ +const add404Route = () => { + if (!routes_404_r) { + routes_404_r = router.addRoute({ + path: '/:pathMatch(.*)*', + name: '404', + hidden: true, + component: otherModules['404'] + }) + } +} + +/** + * 加载动态路由 + * @param {Object} to - 目标路由对象 + * @returns {boolean} 是否加载成功 + */ +const loadDynamicRoutes = async (to) => { + try { + // 从 store 获取菜单和用户信息 + const userStore = useUserStore() + + const apiMenu = userStore.getMenu() || [] + const userInfo = userStore.userInfo + + // 如果没有用户信息,不做处理 + if (!userInfo) { + return false + } + + // 根据用户角色过滤静态路由 + const userMenu = treeFilter(userRoutes, (node) => { + return node.meta.role + ? node.meta.role.some((item) => userInfo.role?.includes(item)) + : true + }) + + // 合并静态路由和API菜单 + const menu = [...userMenu, ...apiMenu] + + // 转换异步路由 + const menuRouter = filterAsyncRouter(menu) + + // 将树形路由转平铺 + const flatMenuRouter = tool.tree_to_list(menuRouter) + + // 添加所有路由 + flatMenuRouter.forEach((item) => { + router.addRoute('layout', item) + }) + + // 添加404路由(必须在所有路由之后) + add404Route() + + isGetRouter = true + + // 检查目标路由是否存在 + const hasRoute = router.hasRoute(to.name) || to.matched.length > 0 + + if (!hasRoute && to.name !== '404') { + return false + } + + return true + } catch (error) { + console.error('加载动态路由失败:', error) + return false + } +} + // 路由拦截器 - 全局前置守卫 -router.beforeEach((to, from, next) => { +router.beforeEach(async (to, from, next) => { + // 开始进度条 + NProgress.start() + + // 设置页面标题 + setPageTitle(to.meta) + const userStore = useUserStore() const isLoggedIn = userStore.isLoggedIn() + const requiresAuth = checkAuthRequired(to) - // 检查路由是否需要认证 - const requiresAuth = to.matched.some((record) => record.meta.requiresAuth !== false) + // 处理404页面 + if (to.name === '404') { + next() + return + } + // 处理登录页 + if (to.path === '/login') { + isGetRouter = false + remove404Route() + next() + return + } + + // 如果是系统路由,直接放行 + if (routes.some((r) => r.path === to.path)) { + next() + return + } + + // 需要认证但未登录,重定向到登录页 if (requiresAuth && !isLoggedIn) { - // 需要认证但未登录,重定向到登录页 + NProgress.done() + isGetRouter = false + userStore.clearMenu() next({ path: '/login', - query: { redirect: to.fullPath }, // 保存目标路径,登录后可以跳转回去 + query: { redirect: to.fullPath } }) - } else if (to.path === '/login' && isLoggedIn) { - // 已登录但访问登录页,重定向到首页 - next({ path: '/' }) - } else { - // 其他情况正常跳转 - next() + return } + + // 整页路由处理 + if (to.meta.fullpage) { + to.matched = [to.matched[to.matched.length - 1]] + } + + // 调用前置守卫 + beforeEach(to, from) + + // 加载动态/静态路由 + if (!isGetRouter) { + const loadSuccess = await loadDynamicRoutes(to) + + // 如果路由加载失败,跳转到404 + if (!loadSuccess) { + next({ name: '404', replace: true }) + return + } + } + + // 检查路由是否存在(动态路由已加载后) + if (isGetRouter) { + const hasRoute = router.hasRoute(to.name) + // 如果路由不存在且不是404页面,跳转到404 + if (!hasRoute && to.name !== '404' && to.matched.length === 0) { + next({ name: '404', replace: true }) + return + } + } + + next() }) +// 全局后置钩子 +router.afterEach((to, from) => { + // 调用后置钩子 + afterEach(to, from) + + // 结束进度条 + NProgress.done() +}) + +// 路由错误处理 +router.onError((error) => { + NProgress.done() + ElNotification.error({ + title: '路由错误', + message: error.message + }) +}) + +/** + * 动态加载组件 + * @param {string} component - 组件路径 + * @returns {Function} + */ +function loadComponent(component) { + if (!component) { + return otherModules.empty + } + + for (const path in modules) { + const dir = path.split('pages/')[1]?.split('.vue')[0] + if (dir === component || dir === `${component}/index`) { + return () => modules[path]() + } + } + + return otherModules.empty +} + +/** + * 转换异步路由 + * @param {Array} routerMap - 路由映射 + * @returns {Array} 转换后的路由数组 + */ +function filterAsyncRouter(routerMap) { + if (!routerMap || !Array.isArray(routerMap)) { + return [] + } + + return routerMap.map((item) => { + const meta = item.meta || {} + + // 处理外部链接特殊路由 + if (meta.type === 'iframe') { + meta.url = item.path + item.path = `/i/${item.name}` + } + + return { + path: item.path, + name: item.name, + meta: meta, + redirect: item.redirect, + children: item.children ? filterAsyncRouter(item.children) : undefined, + component: loadComponent(item.component) + } + }) +} + +/** + * 过滤树结构 + * @param {Array} tree - 树结构数据 + * @param {Function} func - 过滤函数 + * @returns {Array} 过滤后的树结构 + */ +function treeFilter(tree, func) { + if (!tree || !Array.isArray(tree)) { + return [] + } + + return tree + .map((node) => ({ ...node })) + .filter((node) => { + node.children = node.children ? treeFilter(node.children, func) : undefined + return func(node) || (node.children && node.children.length > 0) + }) +} + export default router diff --git a/src/router/scrollBehavior.js b/src/router/scrollBehavior.js new file mode 100644 index 0000000..62029bc --- /dev/null +++ b/src/router/scrollBehavior.js @@ -0,0 +1,30 @@ +import { useLayoutStore } from '@/stores/modules/layout' +import { nextTick } from 'vue' + +export function beforeEach(to, from) { + const adminMain = document.querySelector('#adminui-main') + if (!adminMain) { + return false + } + + const layoutStore = useLayoutStore() + layoutStore.updateViewTags({ + fullPath: from.fullPath, + scrollTop: adminMain.scrollTop + }) +} + +export function afterEach(to) { + const adminMain = document.querySelector('#adminui-main') + if (!adminMain) { + return false + } + + nextTick(() => { + const layoutStore = useLayoutStore() + const beforeRoute = layoutStore.viewTags.find((v) => v.fullPath === to.fullPath) + if (beforeRoute) { + adminMain.scrollTop = beforeRoute.scrollTop || 0 + } + }) +} diff --git a/src/router/systemRouter.js b/src/router/systemRouter.js new file mode 100644 index 0000000..6af073f --- /dev/null +++ b/src/router/systemRouter.js @@ -0,0 +1,36 @@ +import config from "@/config"; + +//系统路由 +const routes = [ + { + name: "layout", + path: "/", + component: () => import("@/layouts/index.vue"), + redirect: config.DASHBOARD_URL || "/dashboard", + children: [], + }, + { + path: "/login", + component: () => + import("@/pages/login/index.vue"), + meta: { + title: "登录", + }, + }, + { + path: "/register", + component: () => import("@/pages/login/userRegister.vue"), + meta: { + title: "注册", + }, + }, + { + path: "/reset-password", + component: () => import("@/pages/login/resetPassword.vue"), + meta: { + title: "重置密码", + }, + }, +]; + +export default routes; diff --git a/src/stores/modules/layout.js b/src/stores/modules/layout.js index dd18eee..43f2d5a 100644 --- a/src/stores/modules/layout.js +++ b/src/stores/modules/layout.js @@ -8,6 +8,9 @@ export const useLayoutStore = defineStore('layout', () => { // 侧边栏折叠状态 const sidebarCollapsed = ref(false) + // 视图标签页(用于记录页面滚动位置) + const viewTags = ref([]) + // 切换侧边栏折叠 const toggleSidebar = () => { sidebarCollapsed.value = !sidebarCollapsed.value @@ -18,10 +21,37 @@ export const useLayoutStore = defineStore('layout', () => { layoutMode.value = mode } + // 更新视图标签 + const updateViewTags = (tag) => { + const index = viewTags.value.findIndex((item) => item.fullPath === tag.fullPath) + if (index !== -1) { + viewTags.value[index] = tag + } else { + viewTags.value.push(tag) + } + } + + // 移除视图标签 + const removeViewTags = (fullPath) => { + const index = viewTags.value.findIndex((item) => item.fullPath === fullPath) + if (index !== -1) { + viewTags.value.splice(index, 1) + } + } + + // 清空视图标签 + const clearViewTags = () => { + viewTags.value = [] + } + return { layoutMode, sidebarCollapsed, + viewTags, toggleSidebar, setLayoutMode, + updateViewTags, + removeViewTags, + clearViewTags, } }) diff --git a/src/stores/modules/user.js b/src/stores/modules/user.js index ffb5c12..f4de056 100644 --- a/src/stores/modules/user.js +++ b/src/stores/modules/user.js @@ -5,6 +5,7 @@ export const useUserStore = defineStore('user', () => { const token = ref(localStorage.getItem('token') || '') const refreshToken = ref(localStorage.getItem('refreshToken') || '') const userInfo = ref(JSON.parse(localStorage.getItem('userInfo') || 'null')) + const menu = ref(JSON.parse(localStorage.getItem('MENU') || '[]')) // 设置 token function setToken(newToken) { @@ -24,14 +25,33 @@ export const useUserStore = defineStore('user', () => { localStorage.setItem('userInfo', JSON.stringify(info)) } + // 设置菜单 + function setMenu(newMenu) { + menu.value = newMenu + localStorage.setItem('MENU', JSON.stringify(newMenu)) + } + + // 获取菜单 + function getMenu() { + return menu.value + } + + // 清除菜单 + function clearMenu() { + menu.value = [] + localStorage.removeItem('MENU') + } + // 登出 function logout() { token.value = '' refreshToken.value = '' userInfo.value = null + menu.value = [] localStorage.removeItem('token') localStorage.removeItem('refreshToken') localStorage.removeItem('userInfo') + localStorage.removeItem('MENU') } // 检查是否已登录 @@ -43,9 +63,13 @@ export const useUserStore = defineStore('user', () => { token, refreshToken, userInfo, + menu, setToken, setRefreshToken, setUserInfo, + setMenu, + getMenu, + clearMenu, logout, isLoggedIn, } diff --git a/src/utils/request.js b/src/utils/request.js index 7b7a627..857efcf 100644 --- a/src/utils/request.js +++ b/src/utils/request.js @@ -132,7 +132,8 @@ async function refreshToken() { // 这里需要根据实际的刷新 token 接口进行修改 // 假设刷新接口是 /auth/refresh const refreshUrl = config.baseURL + '/auth/refresh' - const refreshTokenValue = localStorage.getItem('refreshToken') + const userStore = useUserStore() + const refreshTokenValue = userStore.refreshToken const response = await axios.post(refreshUrl, { refreshToken: refreshTokenValue diff --git a/src/utils/tool.js b/src/utils/tool.js new file mode 100644 index 0000000..889abb9 --- /dev/null +++ b/src/utils/tool.js @@ -0,0 +1,180 @@ +/** + * 工具类 + */ +const tool = { + /** + * 本地存储操作 + */ + data: { + /** + * 设置本地存储 + * @param {string} key - 键名 + * @param {*} value - 值 + */ + set(key, value) { + if (typeof value === 'object') { + localStorage.setItem(key, JSON.stringify(value)) + } else { + localStorage.setItem(key, value) + } + }, + + /** + * 获取本地存储 + * @param {string} key - 键名 + * @param {*} defaultValue - 默认值 + * @returns {*} + */ + get(key, defaultValue = null) { + const value = localStorage.getItem(key) + if (!value) { + return defaultValue + } + try { + return JSON.parse(value) + } catch (e) { + return value + } + }, + + /** + * 删除本地存储 + * @param {string} key - 键名 + */ + remove(key) { + localStorage.removeItem(key) + }, + + /** + * 清空本地存储 + */ + clear() { + localStorage.clear() + } + }, + + /** + * 树形结构转列表 + * @param {Array} tree - 树形结构数据 + * @param {string} childrenKey - 子节点键名 + * @returns {Array} 扁平化后的数组 + */ + tree_to_list(tree, childrenKey = 'children') { + if (!tree || !Array.isArray(tree)) { + return [] + } + + const result = [] + + const traverse = (nodes) => { + if (!nodes || !Array.isArray(nodes)) { + return + } + nodes.forEach((node) => { + result.push(node) + if (node[childrenKey] && node[childrenKey].length > 0) { + traverse(node[childrenKey]) + } + }) + } + + traverse(tree) + return result + }, + + /** + * 列表转树形结构 + * @param {Array} list - 列表数据 + * @param {string} idKey - ID键名 + * @param {string} parentIdKey - 父ID键名 + * @param {string} childrenKey - 子节点键名 + * @returns {Array} 树形结构数据 + */ + list_to_tree(list, idKey = 'id', parentIdKey = 'parentId', childrenKey = 'children') { + if (!list || !Array.isArray(list)) { + return [] + } + + const map = {} + const roots = [] + + // 创建映射表 + list.forEach((item) => { + map[item[idKey]] = { ...item, [childrenKey]: [] } + }) + + // 构建树形结构 + list.forEach((item) => { + const node = map[item[idKey]] + const parentId = item[parentIdKey] + + if (parentId && map[parentId]) { + map[parentId][childrenKey].push(node) + } else { + roots.push(node) + } + }) + + return roots + }, + + /** + * 深拷贝 + * @param {*} obj - 要拷贝的对象 + * @returns {*} 拷贝后的对象 + */ + deepClone(obj) { + if (obj === null || typeof obj !== 'object') { + return obj + } + + if (Array.isArray(obj)) { + return obj.map((item) => this.deepClone(item)) + } + + const cloned = {} + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + cloned[key] = this.deepClone(obj[key]) + } + } + + return cloned + }, + + /** + * 防抖函数 + * @param {Function} func - 要防抖的函数 + * @param {number} wait - 等待时间 + * @returns {Function} + */ + debounce(func, wait = 300) { + let timeout + return function (...args) { + clearTimeout(timeout) + timeout = setTimeout(() => { + func.apply(this, args) + }, wait) + } + }, + + /** + * 节流函数 + * @param {Function} func - 要节流的函数 + * @param {number} wait - 等待时间 + * @returns {Function} + */ + throttle(func, wait = 300) { + let timeout + return function (...args) { + if (!timeout) { + timeout = setTimeout(() => { + func.apply(this, args) + timeout = null + }, wait) + } + } + } +} + +export default tool diff --git a/yarn.lock b/yarn.lock index 7f8f697..ebde940 100644 --- a/yarn.lock +++ b/yarn.lock @@ -253,6 +253,16 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" +"@ctrl/tinycolor@^3.4.1": + version "3.6.1" + resolved "https://registry.yarnpkg.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz#b6c75a56a1947cc916ea058772d666a2c8932f31" + integrity sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA== + +"@element-plus/icons-vue@^2.3.2": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz#7e9cb231fb738b2056f33e22c3a29e214b538dcf" + integrity sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A== + "@esbuild/aix-ppc64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz#521cbd968dcf362094034947f76fa1b18d2d403c" @@ -451,6 +461,26 @@ "@eslint/core" "^0.17.0" levn "^0.4.1" +"@floating-ui/core@^1.7.3": + version "1.7.3" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.3.tgz#462d722f001e23e46d86fd2bd0d21b7693ccb8b7" + integrity sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w== + dependencies: + "@floating-ui/utils" "^0.2.10" + +"@floating-ui/dom@^1.0.1": + version "1.7.4" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.7.4.tgz#ee667549998745c9c3e3e84683b909c31d6c9a77" + integrity sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA== + dependencies: + "@floating-ui/core" "^1.7.3" + "@floating-ui/utils" "^0.2.10" + +"@floating-ui/utils@^0.2.10": + version "0.2.10" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.10.tgz#a2a1e3812d14525f725d011a73eceb41fef5bc1c" + integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ== + "@humanfs/core@^0.19.1": version "0.19.1" resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" @@ -539,6 +569,11 @@ resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1" integrity sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww== +"@popperjs/core@npm:@sxzz/popperjs-es@^2.11.7": + version "2.11.7" + resolved "https://registry.yarnpkg.com/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz#a7f69e3665d3da9b115f9e71671dae1b97e13671" + integrity sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ== + "@rolldown/pluginutils@1.0.0-beta.53": version "1.0.0-beta.53" resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz#c57a5234ae122671aff6fe72e673a7ed90f03f87" @@ -679,6 +714,23 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== +"@types/lodash-es@^4.17.12": + version "4.17.12" + resolved "https://registry.yarnpkg.com/@types/lodash-es/-/lodash-es-4.17.12.tgz#65f6d1e5f80539aa7cfbfc962de5def0cf4f341b" + integrity sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*", "@types/lodash@^4.17.20": + version "4.17.23" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.23.tgz#c1bb06db218acc8fc232da0447473fc2fb9d9841" + integrity sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA== + +"@types/web-bluetooth@^0.0.20": + version "0.0.20" + resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz#f066abfcd1cbe66267cdbbf0de010d8a41b41597" + integrity sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow== + "@vitejs/plugin-vue@^6.0.3": version "6.0.3" resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz#b857c5dcbc5cfb30bf5d7f9d6e274afcca2d46d1" @@ -869,6 +921,28 @@ resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.26.tgz#1e02ef2d64aced818cd31d81ce5175711dc90a9f" integrity sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A== +"@vueuse/core@^10.11.0": + version "10.11.1" + resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-10.11.1.tgz#15d2c0b6448d2212235b23a7ba29c27173e0c2c6" + integrity sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww== + dependencies: + "@types/web-bluetooth" "^0.0.20" + "@vueuse/metadata" "10.11.1" + "@vueuse/shared" "10.11.1" + vue-demi ">=0.14.8" + +"@vueuse/metadata@10.11.1": + version "10.11.1" + resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-10.11.1.tgz#209db7bb5915aa172a87510b6de2ca01cadbd2a7" + integrity sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw== + +"@vueuse/shared@10.11.1": + version "10.11.1" + resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-10.11.1.tgz#62b84e3118ae6e1f3ff38f4fbe71b0c5d0f10938" + integrity sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA== + dependencies: + vue-demi ">=0.14.8" + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -906,6 +980,11 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +async-validator@^4.2.5: + version "4.2.5" + resolved "https://registry.yarnpkg.com/async-validator/-/async-validator-4.2.5.tgz#c96ea3332a521699d0afaaceed510a54656c6339" + integrity sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg== + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -1047,6 +1126,11 @@ csstype@^3.2.3: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== +dayjs@^1.11.19: + version "1.11.19" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.19.tgz#15dc98e854bb43917f12021806af897c58ae2938" + integrity sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw== + debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.4.1: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" @@ -1096,6 +1180,26 @@ electron-to-chromium@^1.5.263: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz#5d84f2df8cdb6bfe7e873706bb21bd4bfb574dc7" integrity sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw== +element-plus@^2.13.1: + version "2.13.1" + resolved "https://registry.yarnpkg.com/element-plus/-/element-plus-2.13.1.tgz#2cc6059da0f0f217f27d657f5140a45ecb0fd221" + integrity sha512-eG4BDBGdAsUGN6URH1PixzZb0ngdapLivIk1meghS1uEueLvQ3aljSKrCt5x6sYb6mUk8eGtzTQFgsPmLavQcA== + dependencies: + "@ctrl/tinycolor" "^3.4.1" + "@element-plus/icons-vue" "^2.3.2" + "@floating-ui/dom" "^1.0.1" + "@popperjs/core" "npm:@sxzz/popperjs-es@^2.11.7" + "@types/lodash" "^4.17.20" + "@types/lodash-es" "^4.17.12" + "@vueuse/core" "^10.11.0" + async-validator "^4.2.5" + dayjs "^1.11.19" + lodash "^4.17.21" + lodash-es "^4.17.21" + lodash-unified "^1.0.3" + memoize-one "^6.0.0" + normalize-wheel-es "^1.2.0" + entities@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.0.tgz#2ae4e443f3f17d152d3f5b0f79b932c1e59deb7a" @@ -1578,11 +1682,26 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash-es@^4.17.21: + version "4.17.22" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.22.tgz#eb7d123ec2470d69b911abe34f85cb694849b346" + integrity sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q== + +lodash-unified@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/lodash-unified/-/lodash-unified-1.0.3.tgz#80b1eac10ed2eb02ed189f08614a29c27d07c894" + integrity sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ== + lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" @@ -1602,6 +1721,11 @@ math-intrinsics@^1.1.0: resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== +memoize-one@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045" + integrity sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw== + mime-db@1.52.0: version "1.52.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" @@ -1656,6 +1780,11 @@ node-releases@^2.0.27: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e" integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA== +normalize-wheel-es@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz#0fa2593d619f7245a541652619105ab076acf09e" + integrity sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw== + nprogress@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/nprogress/-/nprogress-0.2.0.tgz#cb8f34c53213d895723fcbab907e9422adbcafb1" @@ -2035,6 +2164,11 @@ vite@^7.3.0: optionalDependencies: fsevents "~2.3.3" +vue-demi@>=0.14.8: + version "0.14.10" + resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04" + integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg== + vue-i18n@^11.2.8: version "11.2.8" resolved "https://registry.yarnpkg.com/vue-i18n/-/vue-i18n-11.2.8.tgz#f431b583134776dcf59e59250c5231e4eaed8404"