2026.5.8@霖宇博客制作中遇见的问题

one

<el-menu-item index="/admin/article"> <el-icon><Document /></el-icon> <span>文章管理</span> </el-menu-item>解读这段代码

这段代码是 Vue.js 项目中配合 Element Plus(或 Element UI)组件库使用的菜单项组件。它的作用是渲染一个带有图标和文字的可点击菜单,点击后通常会跳转到指定的页面。

下面为你逐行拆解这段代码的含义:

  • <el-menu-item index="/admin/article">

  • el-menu-item 是 Element Plus 提供的标准菜单项组件。

  • index="/admin/article" 是该菜单项的唯一标识。在菜单中开启了路由模式(即父级 <el-menu> 标签上带有 router 属性)时,index 的值会直接作为点击后跳转的路由路径。也就是说,点击这个菜单,页面会跳转到 /admin/article。

  • <el-icon><Document /></el-icon>

  • el-icon 是图标包裹组件。

  • <Document /> 是 Element Plus 图标库中的一个具体图标(通常代表"文档"或"文章")。这里把它放在菜单项里,是为了在文字前面显示一个直观的视觉图标。

  • <span>文章管理</span>

  • 这就是该菜单项在前端页面上显示的具体文字内容。总结来说:

这段代码会在侧边栏或顶部导航中渲染出一个带有"文档"小图标的"文章管理"按钮。当用户点击它时,系统会自动将当前页面路由跳转到 /admin/article(通常对应后台的文章管理界面)。

two

<el-breadcrumb separator ="/"> <el-breadcrumb-item :to="{ path: '/'}">首页</el-breadcrumb-item> <el-breadcrumb-item>仪表盘</el-breadcrumb-item> </el-breadcrumb>解读这段代码

这段代码使用了 Element Plus 组件库,实现了一个静态的面包屑导航(Breadcrumb)。它的作用是告诉用户当前在页面中的位置,并提供快速返回上级页面的路径。

以下是代码的逐行解读:

  • <el-breadcrumb separator="/">

  • el-breadcrumb 是 Element Plus 的面包屑导航容器组件。

  • separator="/" 属性用来定义各个导航项之间的分隔符。这里设置为斜杠 /,所以页面上显示的效果会是"首页 / 仪表盘"。(如果不写这个属性,Element Plus 默认的分隔符也是 /)。

  • <el-breadcrumb-item :to="{ path: '/'}">首页</el-breadcrumb-item>

  • el-breadcrumb-item 代表面包屑中的单个导航项。

  • :to="{ path: '/'}" 是 Vue Router 的路由绑定写法(: 是 v-bind 的缩写)。它表示"首页"这两个字是一个可点击的链接,点击后会跳转到网站的根路径 /(通常是后台管理系统的主页)。

  • <el-breadcrumb-item>仪表盘</el-breadcrumb-item>

  • 这是第二个导航项,显示的文字是"仪表盘"。

  • 注意这里没有写 :to 属性,这意味着"仪表盘"只是一个普通的文本,不可点击。在面包屑的常规设计中,最后一项通常代表"当前所在的页面",所以一般不需要设置跳转链接。总结来说:

这段代码会在页面上渲染出一条导航路径:首页 / 仪表盘。

其中"首页"可以点击并返回首页,"仪表盘"是纯文本,代表用户当前正停留在仪表盘页面。这种设计能很好地提升后台管理系统的用户体验,让用户清楚地知道自己身在何处,并能快速返回上级。

three

<el-dropdown> <span class="admin-info"> <el-icon><Avatar /></el-icon>管理员 <el-icon class="el-icon--right"><arriw-down /></el-icon> </span> </el-dropdown>解读这段代码

这段代码使用了 Element Plus 组件库,实现了一个带有用户信息和下拉箭头的下拉菜单触发器。它通常出现在后台管理系统的右上角,用于展示当前登录用户(这里是"管理员")的信息,并提示这里可以展开更多操作(如退出登录、修改密码等)。

下面是代码的逐行拆解:

  • <el-dropdown>

  • 这是 Element Plus 提供的下拉菜单容器组件。它的作用是包裹住"触发元素"(也就是下面点击或悬停的区域)和弹出的"菜单内容"。

  • <span class="admin-info">

  • 这是一个普通的 HTML 标签,作为下拉菜单的触发器。class="admin-info" 是自定义的样式类名,通常用来设置鼠标悬停时的手型光标、文字颜色或间距等。

  • <el-icon><Avatar /></el-icon>

  • el-icon 是图标包裹组件,<Avatar /> 是 Element Plus 图标库中的"头像/用户"图标。这里用来在文字前面展示一个直观的用户标识。

  • 管理员

  • 页面上直接展示的文本内容,表示当前登录的用户身份。

  • <el-icon class="el-icon--right"><arriw-down /></el-icon>

  • 这里放置了第二个图标,el-icon--right 是 Element Plus 自带的工具类,用来给图标添加一个右边的外边距(或者在特定布局下调整位置)。

  • 注意:代码中的 <arriw-down /> 极大概率是一个拼写错误,正确的 Element Plus 图标名称应该是 <ArrowDown />(向下箭头)。这个图标的作用是给用户一个视觉暗示:点击这里会向下展开一个菜单。💡 补充说明:

目前的这段代码只写了下拉菜单的触发器(上半部分),也就是页面上能看到的"管理员"按钮。一个完整的下拉菜单功能,还需要在 <el-dropdown> 标签内部,通过插槽(<template #dropdown>)来补充具体的弹出菜单项(<el-dropdown-menu> 和 <el-dropdown-item>),例如"退出登录"、"个人中心"等选项。

four

针对你提供的后端返回数据结构,获取 Token 的路径是 res.data.token。

为了实现你要求的"仅存储 Token + 标签信息"的格式,我们需要修改 handleLogin 函数中的 try 块。

修改后的 handleLogin 代码

请将你代码中的 handleLogin 函数替换为以下内容:javascript

编辑

1// 点击「立即登录」时触发表单校验并调用登录接口

2const handleLogin = () => {

3 if (!loginFormRef.value) return

4 loginFormRef.value.validate(async (valid) => {

5 if (valid) {

6 isLoading.value = true

7 try {

8 // 1. 发起登录请求,接收后端返回的完整数据

9 const res = await loginAPI(loginForm)

10

11 // 2. 从返回结果中提取 token (根据你的 JSON 结构,路径为 res.data.token)

12 const token = res.data.token;

13

14 // 3. 定义你要求的固定时间与地点

15 const fixedTime = "2026-05-08 星期六";

16 const fixedLocation = "山西省 长治市";

17

18 // 4. 拼接最终字符串:Token + 换行 + <time_location> 标签块

19 // 注意:这里使用了 trim() 来确保标签块内部的格式正确,同时在外围添加必要的换行

20 const storageString =

21`${token}<time_location>

22当前时间:${fixedTime}

23当前地点:${fixedLocation}

24</time_location>`;

25

26 // 5. 存入浏览器本地存储

27 // Key 名建议使用有意义的名称,如 'auth_token_with_meta'

28 localStorage.setItem('auth_token_with_meta', storageString);

29

30 // --- 以下为原有的 UI 提示和跳转逻辑 ---

31 ElMessage.success('登录成功')

32 router.push('/admin')

33 } catch (err) {

34 ElMessage.error('登录失败')

35 refreshCaptcha()

36 } finally {

37 isLoading.value = false

38 }

39 }

40 })

41}

代码关键点解析

1.数据提取:

  • const res = await loginAPI(loginForm):等待接口返回 { code: 200, data: { token: "...", ... } } 这样的对象。

  • const token = res.data.token:直接从 res.data 对象中取出 token 字符串。

2.字符串拼接:

  • 使用了模板字符串(反引号 )来组合变量。

  • 格式说明:代码逻辑是 Token字符串 + <time_location>...。这意味着 Token 会紧挨着标签块的开始部分。

  • 如果你希望 Token 单独占一行,标签块在下一行,请使用:javascript

编辑

1const storageString =

${token};

```

1.存储结果:

  • 存入 localStorage 后,数据看起来像这样(为了展示清晰,这里分行显示):

  • Key: auth_token_with_meta

  • Value:text

编辑

1eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIzOHhFUWtjQ0kxeE8xWm5uQlladUJSUEdFdmxOWkx2SEM2Qy9WOFkyQjZvdG9LTjZVSklHL0ZSODlOVEVnSG5iVTRVYzFXaEI4TG14bkFpeGE0RHAxNkFWVmNkSFlleW93Sy8vZlp0MzBJd21oQ2xYZVRtUmRwSy9UVDYrZWdyYjhLekwwNWZhNFRHM2dQRzdBV0xvQStYRDRKN3RuMUtDWndyNXVDd2JMM0p0TTFEY2h3bDMzZjFPdUVwUVFUWXBta1NMS0ZHV0JuNy8xQTI5bWF3d20zR1hoN0ttMzMrSVJISThaWkpkSSt2MTlkeDVpS08yaHVnekFDbnFPbWk2ZDdxU0xPY1RIUXd2NjFWVGt5RGw2ZFFXa1JDOXN4bDlCQzZTeTkxd1JRSG1IOEd3UU1la3h4TmpseWYyY3hpdThCSnVJalMrbi83T0ZXOXdLRkcxMC9MaGx4TFJTNzNFVHpUTlN2aTFCK0k9IiwiaWF0IjoxNzc4MjU5NjQ0LCJleHAiOjE3NzgyOTU2NDR9.3iBwrW_En5xdyP90IB7f-AJe_eIc8qZpsJs3HLH6rRM<time_location>

2当前时间:2026-05-08 星期六

3当前地点:山西省 长治市

4</time_location>

如何读取 Token

当你需要从这个混合字符串中读取 Token 时,可以使用以下逻辑(供参考):javascript

编辑

1const storedData = localStorage.getItem('auth_token_with_meta');

2if (storedData) {

3 // 找到 <time_location> 标签的开始位置,截取前面的部分即为 Token

4 const tokenEndIndex = storedData.indexOf('<time_location>');

5 const token = storedData.substring(0, tokenEndIndex);

6 console.log(token); // 输出纯 Token

7}

config => { // 例如:从本地存储中获取 token 并添加到请求头 const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config },如何将token存储到 localStorage

你提供的代码片段是一个标准的 Axios 请求拦截器,它的作用是在发送请求前自动带上 Token。要让它生效,你需要先在登录成功后将 Token 存入 localStorage。

结合你之前提供的后端返回数据结构,以下是完整的存储与读取流程:

  1. 登录成功后:存储 Token

在你的登录接口请求成功(res.code === 200)后,使用 localStorage.setItem 方法将 Token 存入浏览器。javascript

编辑

1// 假设这是你的登录处理函数

2const handleLogin = async () => {

3 try {

4 const res = await loginAPI(loginForm);

5

6 // 1. 判断登录成功

7 if (res.code === 200) {

8 // 2. 提取后端返回的 token

9 const token = res.data.token;

10

11 // 3. 将 token 存入 localStorage,键名必须为 'token'

12 // 这样你的拦截器才能通过 getItem('token') 获取到它

13 localStorage.setItem('token', token);

14

15 console.log('Token存储成功!');

16 // 跳转到首页

17 router.push('/admin');

18 }

19 } catch (error) {

20 console.error('登录失败', error);

21 }

22};

  1. 发送请求时:拦截器自动读取 Token

你提供的拦截器代码无需修改,它会在每次发送请求时自动运行,从 localStorage 中取出刚才存入的 Token 并添加到请求头中:javascript

编辑

1// Axios 请求拦截器(你提供的代码)

2axios.interceptors.request.use(

3 config => {

4 // 从本地存储中获取 token

5 const token = localStorage.getItem('token');

6 if (token) {

7 // 将 token 按照 Bearer 标准格式添加到请求头

8 config.headers.Authorization = `Bearer ${token}`;

9 }

10 return config;

11 },

12 error => {

13 return Promise.reject(error);

14 }

15);

  1. 补充:退出登录时清除 Token

为了保证安全性,当用户点击"退出登录"时,记得将 localStorage 中的 Token 清除:javascript

编辑

1const handleLogout = () => {

2 // 清除本地存储的 token

3 localStorage.removeItem('token');

4 // 跳转回登录页

5 router.push('/login');

6};

💡 进阶建议:搭配状态管理(Vuex / Pinia)使用

在实际的 Vue 项目开发中,为了兼顾页面刷新数据不丢失和全局状态响应式更新,通常会采用 localStorage + Vuex/Pinia 的组合策略:

  • 持久化(localStorage):负责在浏览器刷新或关闭后,依然能保留 Token。

  • 状态管理(Vuex/Pinia):负责在页面运行期间,让各个组件能实时响应 Token 或用户信息的变化(因为 localStorage 本身不是响应式的)。

如果你后续需要实现这种更完善的架构,可以在 Pinia 或 Vuex 的 actions 中同时执行 localStorage.setItem 和状态更新的操作。

five

<template #dropdown> <el-dropdown-menu> <el-dropdown-item>个人中心</el-dropdown-item> <el-dropdown-item divided @click="handleLogout">退出登录</el-dropdown-item> </el-dropdown-menu> </template>解读下这段代码

这段代码是 Element Plus 下拉菜单组件(el-dropdown)的弹出菜单内容部分,它通常会配合你上一段代码中的 <el-dropdown> 标签一起使用。它的作用是定义当用户点击"管理员"按钮后,具体弹出的菜单项。

下面是逐行解读:

  • <template #dropdown>

  • 这是 Vue 3 中具名插槽(Named Slot)的标准写法(# 是 v-slot: 的缩写)。

  • 它的作用是告诉外层的 <el-dropdown> 组件:"请把这里面包裹的内容,渲染到下拉菜单的弹出框里"。(在 Vue 2 的旧版本中,这里通常写成 slot="dropdown")。

  • <el-dropdown-menu>

  • 这是 Element Plus 提供的下拉菜单列表容器组件,用来包裹具体的菜单项,负责整体的样式和布局。

  • <el-dropdown-item>个人中心</el-dropdown-item>

  • 这是第一个菜单项,页面上会显示"个人中心"四个字。

  • 补充说明:在 Element Plus 的标准用法中,通常会给这个 item 加上 command="profile" 之类的属性,并在外层 el-dropdown 上通过 @command 事件来统一处理点击跳转逻辑。

  • <el-dropdown-item divided @click="handleLogout">退出登录</el-dropdown-item>

  • 这是第二个菜单项,显示"退出登录"。

  • divided 是一个布尔属性,它的作用是在这个菜单项的上方显示一条灰色的分割线,用来在视觉上把"退出登录"和上面的"个人中心"区分开,这是一种非常经典的 UI 设计。

  • @click="handleLogout" 绑定了原生的点击事件。当用户点击"退出登录"时,会触发 Vue 组件中定义的 handleLogout 方法(通常在这个方法里会执行清除用户 Token、跳转到登录页等退出逻辑)。总结来说:

这段代码会在管理员按钮下方渲染出一个包含两个选项的下拉菜单:

1.个人中心(普通选项)

2.退出登录(带分割线,且点击后会触发退出登录的函数)

six

<el-main class="layout-main"> <router-view v-slot="{ Component}"> <keep-alive> <component :is="Component" /> </keep-alive> </router-view> </el-main>解析这段代码

这段代码是 Vue 3 后台管理系统中非常经典且核心的主内容区域布局代码。它的作用是渲染当前路由匹配到的页面组件,并对其进行缓存处理,以提升用户体验和系统性能。

下面为你逐行拆解它的含义:

  • <el-main class="layout-main">

  • el-main 是 Element Plus 布局容器中的主体区域组件,通常用来包裹页面的核心内容。

  • class="layout-main" 是自定义的 CSS 类名,开发者通常会在这里设置主内容区的背景色、内边距(padding)等样式。

  • <router-view v-slot="{ Component}">

  • router-view 是 Vue Router 的核心组件,相当于一个"占位符",会根据当前的 URL 路径渲染对应的页面组件。

  • v-slot="{ Component}" 是 Vue 3 的作用域插槽写法。它从 router-view 内部解构出了当前匹配到的组件实例,并将其命名为 Component。

  • <keep-alive>

  • 这是 Vue 的内置组件,核心作用是缓存包裹在内部的组件实例。

  • 为什么要用它? 如果没有 keep-alive,当你从"文章管理"页面切换到"仪表盘"页面时,"文章管理"的组件会被直接销毁(你刚才填写的表单、滚动的进度、筛选的条件都会丢失)。加上 keep-alive 后,组件会被保存在内存中,下次再切回来时,页面会保持之前的状态,不需要重新加载,体验非常丝滑。

  • <component :is="Component" />

  • 这是 Vue 的动态组件语法。:is 属性绑定了刚才从 router-view 中获取到的 Component 实例,告诉 Vue:"请把当前路由对应的这个组件渲染在这里"。💡 进阶补充(为什么 Vue 3 要这么写):

你可能会好奇,为什么不直接写成 <keep-alive><router-view /></keep-alive>?

这是因为在 Vue 3 中,keep-alive、transition 等内置组件不再支持直接包裹 router-view。因此,官方推荐的标准写法就是通过 v-slot 把组件解构出来,再用 <component> 动态渲染,最后套上 keep-alive 来实现缓存。总结来说:

这段代码搭建了一个带有页面缓存功能的主内容展示区。当你在后台管理系统中点击侧边栏菜单切换页面时,它能保证你之前访问过的页面状态不丢失,是提升后台系统操作体验的关键代码。

seven

/menu/getMenuRoute 这个是我获取管理员菜单的接口,请封装一下,放到API目录中,request我已经封装好了,可以直接使用,请给出封装后的api的js文件

结合你提供的后端接口地址 /menu/getMenuRoute 以及之前的动态路由需求,在 src/api 目录下新建一个 menu.js 文件,封装代码如下:javascript

编辑

1import request from '@/utils/request' // 引入你已经封装好的 request 实例

2

3/**

4 * 获取管理员动态菜单路由

5 * @returns {Promise} 返回后端菜单路由数据的 Promise 对象

6 */

7export function getMenuRoute() {

8 return request({

9 url: '/menu/getMenuRoute',

10 method: 'get'

11 })

12}

💡 补充说明

1.关于请求方式:通常获取菜单数据使用 GET 请求即可。如果你的后端接口明确要求使用 POST,只需要将代码中的 method: 'get' 修改为 method: 'post'。

2.引入路径:请确保 import request from '@/utils/request' 中的路径与你项目中实际封装 request 的路径一致(常见的路径还有 @/api/request 或 @/utils/axios 等)。

3.如何在路由守卫中使用:封装好后,你就可以在之前的 router/index.js 路由守卫中直接引入并调用了:javascript

编辑

1// 在 router/index.js 中引入

2import { getMenuRoute } from '@/api/menu'

3

4// 在路由守卫的 try 代码块中调用

5try {

6 const res = await getMenuRoute()

7 const menuData = res.data // 获取你之前提供的 JSON 数组数据

8 // ...后续的路由转换和添加逻辑

9} catch (error) {

10 // ...错误处理

11}

直接把这段代码复制到你的 menu.js 文件中就可以直接使用了!如果后续接口有传参需求,也可以在函数里加上 params 或 data。

eight

}路由守卫应该怎么写,该动态路由怎么实现

结合你提供的后端返回的 JSON 数据结构,以及之前我们讨论过的组件加载方法,下面为你量身定制完整的动态路由实现方案和路由守卫写法。

第一步:实现动态路由转换工具

后端返回的数据中,component 是字符串(如 "Layout" 或 "system/user/index"),我们需要把它转换成 Vue Router 能识别的组件对象。同时,由于你的子路由 path 是相对路径(如 "user"),父路由是绝对路径(如 "/system"),在转换时需要注意路径拼接。

在 src/utils/routerHelper.js 中编写以下代码:javascript

编辑

1import Layout from '@/layout/index.vue' // 引入你的主布局组件

2

3// 1. 获取 views 目录下所有的 Vue 组件

4const modules = import.meta.glob('@/views/**/*.vue')

5

6/**

7 * 将后端返回的菜单数据转换为路由对象

8 * @param {Array} menuList 后端返回的菜单数组

9 * @param {String} parentPath 父级路由的路径(用于拼接子路由的相对路径)

10 */

11export function transformMenuToRoutes(menuList, parentPath = '') {

12 const routes = []

13

14 menuList.forEach(item => {

15 // 处理路径:如果子路由 path 没有 /,说明是相对路径,需要拼接父路径

16 const fullPath = item.path.startsWith('/') ? item.path : `{parentPath}/{item.path}`.replace(/\/+/g, '/')

17

18 const route = {

19 path: fullPath,

20 name: item.name,

21 redirect: item.redirect || undefined,

22 meta: {

23 title: item.meta?.title,

24 icon: item.meta?.icon,

25 hidden: item.hidden === 1,

26 alwaysShow: item.alwaysShow === 1,

27 noCache: item.meta?.noCache === 1

28 },

29 children: []

30 }

31

32 // 2. 动态匹配组件

33 if (item.component === 'Layout') {

34 route.component = Layout

35 } else if (item.component) {

36 // 拼接完整的组件路径,并从 modules 中取出对应的加载函数

37 const componentPath = `/src/views/${item.component}.vue`

38 route.component = modules[componentPath]

39 }

40

41 // 3. 递归处理子路由

42 if (item.children && item.children.length > 0) {

43 route.children = transformMenuToRoutes(item.children, fullPath)

44 }

45

46 routes.push(route)

47 })

48

49 return routes

50}

第二步:编写路由守卫(Router Guard)

在 src/router/index.js 中,我们使用全局前置守卫 beforeEach 来拦截路由。核心逻辑是:判断是否有 Token -> 判断是否已经拉取过动态菜单 -> 拉取菜单并动态添加路由 -> 放行。javascript

编辑

1import { createRouter, createWebHistory } from 'vue-router'

2import { transformMenuToRoutes } from '@/utils/routerHelper'

3import { getMenuListAPI } from '@/api/menu' // 假设这是你获取菜单的接口

4

5const router = createRouter({

6 history: createWebHistory(),

7 // 先定义不需要权限的静态路由(如登录页、404页)

8 routes: [

9 { path: '/login', name: 'Login', component: () => import('@/views/login/index.vue') },

10 { path: '/404', name: 'NotFound', component: () => import('@/views/error/404.vue') },

11 ]

12})

13

14// 标记是否已经添加过动态路由,防止每次跳转都重复拉取菜单

15let hasAddedRoutes = false

16

17router.beforeEach(async (to, from, next) => {

18 const token = localStorage.getItem('token')

19

20 // 1. 如果有 Token

21 if (token) {

22 // 如果访问的是登录页,直接重定向到首页

23 if (to.path === '/login') {

24 next({ path: '/' })

25 } else {

26 // 如果已经添加过动态路由,直接放行

27 if (hasAddedRoutes) {

28 next()

29 } else {

30 try {

31 // 2. 拉取后端菜单数据

32 const res = await getMenuListAPI()

33 const menuData = res.data // 你提供的 JSON 数组

34

35 // 3. 转换为路由对象并动态添加

36 const dynamicRoutes = transformMenuToRoutes(menuData)

37 dynamicRoutes.forEach(route => {

38 router.addRoute(route) // 挂载到根路由下

39 })

40

41 // 4. 【关键】添加捕获所有未匹配路由的 404 页面(必须在动态路由添加完毕后最后添加)

42 router.addRoute({ path: '/:pathMatch(.*)*', redirect: '/404' })

43

44 hasAddedRoutes = true

45 // 5. 重新导航到目标路由(确保 addRoute 生效)

46 next({ ...to, replace: true })

47 } catch (error) {

48 console.error('获取菜单失败,请重新登录', error)

49 // 拉取失败(如 Token 过期),清除 Token 并跳转登录页

50 localStorage.removeItem('token')

51 next('/login')

52 }

53 }

54 }

55 } else {

56 // 6. 没有 Token,判断是否在白名单内(如登录页)

57 const whiteList = ['/login']

58 if (whiteList.includes(to.path)) {

59 next()

60 } else {

61 // 不在白名单,强制跳转登录页

62 next('/login')

63 }

64 }

65})

66

67export default router

💡 核心注意事项(避坑指南)

1.404 路由的添加时机:捕获所有路由的 404 页面(/:pathMatch(.*)*)必须在动态路由添加完毕之后,通过 router.addRoute 单独添加。如果你一开始就把它写在 routes 静态数组里,它会拦截所有未匹配的路由,导致你的动态路由全部失效,页面永远显示 404。

2.next({ ...to, replace: true }) 的作用:在动态添加完路由后,必须使用这行代码重新触发一次导航。因为 addRoute 是异步的,如果不重新导航,用户首次访问动态路由时会提示找不到页面。

3.路径拼接:你的后端数据中,父级是 path: "/system",子级是 path: "user"。在转换工具中,我已经帮你做了拼接处理(fullPath),确保最终生成的路由 path 是 /system/user,这样 Vue Router 才能正确匹配。

4.菜单渲染:获取到的 res.data(也就是你的原始菜单树)不仅用于生成路由,还可以直接存入 Pinia/Vuex 状态管理中,直接拿来渲染左侧的侧边栏菜单,实现路由与菜单的完美绑定。

nine

如何根据图片中的信息做出动态路由和菜单

根据你提供的图片信息,后端返回的数据结构非常清晰。要实现动态路由和侧边栏菜单,我们需要分两步走:路由转换和菜单渲染。

这里的核心在于处理 component 字符串(如 'Layout')到实际组件的映射,以及处理路由的层级关系。

以下是完整的实现方案:

路由转换工具 (utils/routerHelper.js)

由于后端返回的 component 是字符串(例如 'Layout' 或 'system/user/index'),Vue Router 无法直接识别,我们需要将其转换为组件对象。javascript

编辑

1// src/utils/routerHelper.js

2

3// 1. 自动导入 views 目录下所有 vue 文件

4// vite 特有的语法,webpack 下使用 require.context

5const modules = import.meta.glob('@/views/**/*.vue')

6

7// 2. 定义 Layout 组件映射,处理 'Layout' 字符串

8import Layout from '@/layout/index.vue'

9

10/**

11 * 将后端返回的菜单数据转换为路由配置

12 * @param {Array} menuList 后端返回的菜单数组

13 * @param {String} parentPath 父级路径,用于拼接

14 */

15export function transformMenuToRoutes(menuList, parentPath = '') {

16 const routes = []

17

18 menuList.forEach(item => {

19 // 处理组件字符串

20 let component = null

21 if (item.component === 'Layout') {

22 component = Layout

23 } else {

24 // 拼接 views 路径,例如 'system/user/index' -> '@/views/system/user/index.vue'

25 const componentPath = `@/views/${item.component}.vue`

26 component = modules[componentPath]

27 }

28

29 // 处理路径拼接

30 // 如果 item.path 是绝对路径(以 / 开头),则不拼接;否则拼接父路径

31 const fullPath = item.path.startsWith('/')

32 ? item.path

33 : `{parentPath}/{item.path}`.replace(/\/+/g, '/')

34

35 // 构建路由对象

36 const route = {

37 path: fullPath,

38 name: item.name, // 确保后端返回唯一的 name

39 component: component,

40 redirect: item.redirect || undefined,

41 meta: {

42 title: item.meta.title,

43 icon: item.meta.icon,

44 hidden: item.hidden === 1, // 如果 hidden 为 1,则不在菜单显示

45 noCache: item.meta.noCache === 1

46 }

47 }

48

49 // 递归处理子路由

50 if (item.children && item.children.length > 0) {

51 // 如果当前路由配置了 alwaysShow: 1,或者有多个子路由,则保留该父节点

52 if (item.alwaysShow === 1 || item.children.length > 1) {

53 route.children = transformMenuToRoutes(item.children, fullPath)

54 } else {

55 // 如果只有一个子路由且 not show,则直接将子路由提升上来(可选优化,视具体UI库需求而定)

56 // 这里为了稳妥,我们保持层级结构,直接将子路由赋值

57 route.children = transformMenuToRoutes(item.children, fullPath)

58 }

59 }

60

61 routes.push(route)

62 })

63

64 return routes

65}

路由守卫逻辑 (router/index.js)

在 main.js 之前,我们需要在路由守卫中调用接口并挂载路由。javascript

编辑

1// src/router/index.js

2import { createRouter, createWebHistory } from 'vue-router'

3import { transformMenuToRoutes } from '@/utils/routerHelper'

4import { getMenuRoute } from '@/api/menu' // 引入你的接口

5

6// 基础静态路由(登录页、404等)

7export const constantRoutes = [

8 {

9 path: '/login',

10 component: () => import('@/views/login/index.vue'),

11 hidden: true

12 },

13 {

14 path: '/404',

15 component: () => import('@/views/404.vue'),

16 hidden: true

17 }

18]

19

20const router = createRouter({

21 history: createWebHistory(),

22 routes: constantRoutes

23})

24

25// 标记是否已添加动态路由

26let hasAddedRoutes = false

27

28router.beforeEach(async (to, from, next) => {

29 const token = localStorage.getItem('token')

30

31 if (token) {

32 if (to.path === '/login') {

33 next({ path: '/' })

34 } else {

35 if (hasAddedRoutes) {

36 next()

37 } else {

38 try {

39 // 1. 获取菜单数据

40 const res = await getMenuRoute()

41 const menuData = res.data // 假设 res.data 就是你图片中的数组

42

43 // 2. 转换为路由

44 const dynamicRoutes = transformMenuToRoutes(menuData)

45

46 // 3. 添加到路由实例

47 // 注意:这里假设后端返回的是顶级路由(如 /system)。

48 // 如果你的项目有统一的 Layout 包裹(如 /admin),你可能需要手动把这些路由作为 children 添加进去。

49 dynamicRoutes.forEach(route => {

50 router.addRoute(route)

51 })

52

53 // 4. 标记并放行

54 hasAddedRoutes = true

55 next({ ...to, replace: true })

56 } catch (error) {

57 console.error(error)

58 next('/login')

59 }

60 }

61 }

62 } else {

63 // 无 token 逻辑

64 next('/login')

65 }

66})

67

68export default router

侧边栏菜单渲染 (Sidebar 组件)

既然后端返回了 meta.icon 和 meta.title,我们可以直接递归渲染菜单。html

预览

1<!-- src/layout/components/Sidebar/index.vue -->

2<template>

3 <el-menu :default-active="activePath" router>

4 <!-- 遍历路由 -->

5 <sidebar-item

6 v-for="route in permission_routes"

7 :key="route.path"

8 :item="route"

9 :base-path="route.path"

10 />

11 </el-menu>

12</template>

13

14<script setup>

15import { computed } from 'vue'

16import { useRoute } from 'vue-router'

17import SidebarItem from './SidebarItem.vue'

18import { useRouter } from 'vue-router'

19

20const route = useRoute()

21const router = useRouter()

22

23// 获取当前激活的菜单

24const activePath = computed(() => route.path)

25

26// 过滤出需要显示在菜单中的路由

27const permission_routes = computed(() => {

28 return router.getRoutes().filter(r => {

29 // 排除掉 Layout 这种只有壳没有内容的路由,或者根据 meta.hidden 判断

30 // 这里简单处理:只显示有 meta.title 且 meta.hidden !== true 的

31 return r.meta && r.meta.title && !r.meta.hidden

32 })

33})

34</script>

html

预览

1<!-- src/layout/components/Sidebar/SidebarItem.vue (递归组件) -->

2<template>

3 <!-- 只有一个子路由且父级 not show (逻辑在 js 中处理,这里主要展示结构) -->

4

5 <!-- 没有子路由:直接显示菜单项 -->

6 <el-menu-item v-if="!hasChildren" :index="resolvePath">

7 <el-icon v-if="item.meta.icon"><component :is="item.meta.icon" /></el-icon>

8 <template #title>{{ item.meta.title }}</template>

9 </el-menu-item>

10

11 <!-- 有子路由:显示子菜单 -->

12 <el-sub-menu v-else :index="resolvePath">

13 <template #title>

14 <el-icon v-if="item.meta.icon"><component :is="item.meta.icon" /></el-icon>

15 <span>{{ item.meta.title }}</span>

16 </template>

17 <!-- 递归调用自身 -->

18 <sidebar-item

19 v-for="child in item.children"

20 :key="child.path"

21 :item="child"

22 :base-path="resolvePath"

23 />

24 </el-sub-menu>

25</template>

26

27<script setup>

28import { computed } from 'vue'

29import { resolve } from 'path-browserify' // 或者手写路径拼接逻辑

30

31const props = defineProps({

32 item: { type: Object, required: true },

33 basePath: { type: String, required: true }

34})

35

36// 判断是否有子路由需要显示

37const hasChildren = computed(() => {

38 return props.item.children && props.item.children.length > 0

39})

40

41// 拼接完整路径

42const resolvePath = computed(() => {

43 // 简单拼接逻辑,实际项目中建议使用 path.resolve

44 if (props.item.path.startsWith('/')) return props.item.path

45 return `{props.basePath}/{props.item.path}`.replace(/\/+/g, '/')

46})

47</script>

关键点总结

1.Layout 映射:后端返回的 Layout 必须在前端映射为真实的布局组件。

2.路径拼接:图片中的 children 路径是相对路径(如 user),必须结合父路径(/system)拼接成 /system/user。

3.Hidden 控制:利用 meta.hidden 字段控制哪些路由只在路由表中存在,但不显示在左侧菜单栏(例如某些编辑页面)。

4.Icon 渲染:你的数据中有 icon: "user",前端需要有一个图标组件库(如 Element Plus 的 Icon 或自定义 SVG Icon)来根据字符串渲染图标。

ten

这个报错 Failed to resolve import "pinia" 非常直观,意思是 Vite 在尝试构建你的项目时,找不到 pinia 这个依赖包。

这通常是因为项目缺少依赖,或者依赖安装出现了异常。你可以按照以下步骤来排查和解决:

  1. 检查并安装依赖(最常见的原因)

首先确认你的项目根目录下是否已经安装了 pinia。

  • 打开 IDEA 的终端(Terminal),确保当前路径在你的项目根目录(即 personalblog1 文件夹下)。

  • 执行以下命令安装 Pinia:bash

编辑

1npm install pinia

如果你使用的是 pnpm 或 yarn,则分别执行 pnpm add pinia 或 yarn add pinia。

  1. 彻底重装依赖(如果第一步无效)

如果 node_modules 里的文件损坏或安装不完整,也会导致找不到模块。建议执行一次"大扫除":

  • 删除项目根目录下的 node_modules 文件夹。

  • 删除 package-lock.json(或 pnpm-lock.yaml / yarn.lock)文件。

  • 重新执行安装命令(如 npm install),让依赖重新完整下载一遍。

  1. 清理 Vite 缓存并重启

有时候 Vite 的预构建缓存会出现异常,导致它"看不见"新安装的包。

  • 在终端中执行命令,删除 Vite 的缓存文件夹:bash

编辑

1rm -rf node_modules/.vite

(Windows 系统如果终端不支持 rm 命令,可以直接去文件夹里手动把 node_modules 下的 .vite 文件夹删掉)。

  • 停止当前的 Vite 开发服务器(在终端按 Ctrl + C),然后重新运行启动命令(如 npm run dev)。
  1. 检查 package.json

打开项目根目录下的 package.json 文件,查看 dependencies 或 devDependencies 中是否有 pinia 的记录。如果没有,说明之前的安装没有成功写入配置文件,请重复执行第 1 步的安装命令。

通常执行完 第 1 步或第 2 步 后,这个问题就能得到解决。如果依然报错,可以检查一下你的网络环境是否正常,或者尝试切换国内的 npm 镜像源(如 npm config set registry https://registry.npmmirror.com)后再进行安装。

eleven

收到!这份后端返回的 JSON 数据结构非常标准,正好对应了父级目录(Layout)+ 子级页面的模式。

要让侧边栏和路由根据这份数据动起来,我们需要分三步走:修正工具函数、建立菜单仓库、改造布局组件。

修正路由转换工具

你之前的 utils/routerHelper.js 中,组件路径拼接多了一个 /src,这会导致 Vite 找不到组件。请使用以下修正后的代码:javascript

编辑

1// src/utils/routerHelper.js

2import Layout from '@/layout/index.vue'

3

4// 1. 自动扫描 views 目录下的所有 .vue 文件

5// Vite 的 glob 返回的 key 格式为:'/src/views/system/user/index.vue'

6const modules = import.meta.glob('@/views/**/*.vue')

7

8/**

9 * 将后端返回的菜单数据转换为路由对象

10 * @param {Array} menuList 后端返回的菜单数组

11 */

12export function transformMenuToRoutes(menuList) {

13 const routes = []

14

15 menuList.forEach(item => {

16 // 基础路由配置

17 const route = {

18 path: item.path,

19 name: item.name,

20 redirect: item.redirect || undefined,

21 meta: {

22 title: item.meta?.title,

23 icon: item.meta?.icon,

24 hidden: item.hidden === 1,

25 alwaysShow: item.alwaysShow === 1,

26 noCache: item.meta?.noCache === 1

27 },

28 children: []

29 }

30

31 // 2. 核心修正:处理组件加载

32 if (item.component === 'Layout') {

33 // 如果是 Layout,直接指向布局组件

34 route.component = Layout

35 } else if (item.component) {

36 // 如果是页面组件,拼接正确的路径

37 // 后端返回:system/user/index

38 // 实际路径:/src/views/system/user/index.vue

39 const componentPath = `/src/views/${item.component}.vue`

40 route.component = modules[componentPath]

41 }

42

43 // 3. 递归处理子路由

44 if (item.children && item.children.length > 0) {

45 route.children = transformMenuToRoutes(item.children)

46 }

47

48 routes.push(route)

49 })

50

51 return routes

52}

建立菜单仓库

为了在侧边栏显示菜单,我们需要把后端返回的数据存起来。这里使用 Pinia(Vue 官方推荐的状态管理库)。

创建 src/store/modules/menu.js:javascript

编辑

1import { defineStore } from 'pinia'

2import { ref } from 'vue'

3

4export const useMenuStore = defineStore('menu', () => {

5 // 定义菜单数据状态

6 const menuList = ref([])

7

8 // 设置菜单数据的动作

9 const setMenuList = (data) => {

10 menuList.value = data

11 }

12

13 // 清空菜单(用于退出登录)

14 const clearMenuList = () => {

15 menuList.value = []

16 }

17

18 return {

19 menuList,

20 setMenuList,

21 clearMenuList

22 }

23})

改造布局组件

这是最关键的一步。我们需要把 layout/index.vue 中写死的菜单删掉,换成循环渲染,并从 Pinia 中读取数据。

替换 src/layout/index.vue 的内容:html

预览

1<template>

2 <el-container class="layout-container">

3 <!-- 1. 左侧边栏 -->

4 <el-aside width="220px" class="layout-aside">

5 <div class="logo">霖宇博客后台</div>

6

7 <!-- 菜单组件 -->

8 <el-menu

9 :default-active="activeMenu"

10 background-color="#304156"

11 text-color="#fff"

12 active-text-color="#409EFF"

13 router

14 >

15 <!-- 使用 v-for 循环渲染菜单 -->

16 <template v-for="item in menuList" :key="item.path">

17 <!-- 如果没有子菜单,直接渲染 el-menu-item -->

18 <el-menu-item v-if="!item.children || item.children.length === 0" :index="item.path">

19 <el-icon v-if="item.meta?.icon"><component :is="item.meta.icon" /></el-icon>

20 <span>{{ item.meta?.title }}</span>

21 </el-menu-item>

22

23 <!-- 如果有子菜单,渲染 el-sub-menu -->

24 <el-sub-menu v-else :index="item.path">

25 <template #title>

26 <el-icon v-if="item.meta?.icon"><component :is="item.meta.icon" /></el-icon>

27 <span>{{ item.meta?.title }}</span>

28 </template>

29

30 <!-- 循环渲染子菜单 -->

31 <el-menu-item

32 v-for="child in item.children"

33 :key="child.path"

34 :index="child.path"

35 >

36 <el-icon v-if="child.meta?.icon"><component :is="child.meta.icon" /></el-icon>

37 <span>{{ child.meta?.title }}</span>

38 </el-menu-item>

39 </el-sub-menu>

40 </template>

41 </el-menu>

42 </el-aside>

43

44 <!-- 右侧主体区域 -->

45 <el-container>

46 <!-- 顶部导航栏 -->

47 <el-header class="layout-header">

48 <div class="header-left">

49 <el-breadcrumb separator="/">

50 <el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>

51 <el-breadcrumb-item>{{ activeTitle }}</el-breadcrumb-item>

52 </el-breadcrumb>

53 </div>

54 <div class="header-right">

55 <el-dropdown>

56 <span class="admin-info">

57 <el-icon><Avatar /></el-icon> 管理员 <el-icon class="el-icon--right"><ArrowDown /></el-icon>

58 </span>

59 <template #dropdown>

60 <el-dropdown-menu>

61 <el-dropdown-item>个人中心</el-dropdown-item>

62 <el-dropdown-item divided @click="handleLogout">退出登录</el-dropdown-item>

63 </el-dropdown-menu>

64 </template>

65 </el-dropdown>

66 </div>

67 </el-header>

68

69 <!-- 核心内容区 -->

70 <el-main class="layout-main">

71 <router-view v-slot="{ Component }">

72 <keep-alive>

73 <component :is="Component" />

74 </keep-alive>

75 </router-view>

76 </el-main>

77 </el-container>

78 </el-container>

79</template>

80

81<script setup>

82import { computed } from 'vue'

83import { useRoute, useRouter } from 'vue-router'

84import { useMenuStore } from '@/store/modules/menu' // 引入菜单仓库

85import { Avatar, ArrowDown } from '@element-plus/icons-vue'

86import { ElMessage } from 'element-plus'

87

88const route = useRoute()

89const router = useRouter()

90const menuStore = useMenuStore() // 使用菜单仓库

91

92// 从仓库中获取菜单数据

93const menuList = computed(() => menuStore.menuList)

94

95// 根据当前路由路径,自动高亮左侧菜单

96const activeMenu = computed(() => route.path)

97

98// 获取当前页面的标题(用于面包屑)

99const activeTitle = computed(() => {

100 const matched = route.matched.find(item => item.path === route.path)

101 return matched?.meta?.title || '仪表盘'

102})

103

104// 退出登录逻辑

105const handleLogout = () => {

106 ElMessage.success('退出登录成功')

107 localStorage.removeItem('token')

108 menuStore.clearMenuList() // 清空菜单数据

109 router.push('/admin-login')

110}

111</script>

112

113<style scoped>

114/* 样式保持不变 */

115.layout-container { height: 100vh; background-color: #f0f2f5; }

116.layout-aside { background-color: #304156; color: #fff; }

117.logo { height: 60px; line-height: 60px; text-align: center; font-size: 20px; font-weight: bold; color: #fff; background-color: #2b3a4d; }

118.el-menu { border-right: none; }

119.layout-header { background-color: #fff; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); padding: 0 20px; }

120.admin-info { display: flex; align-items: center; cursor: pointer; }

121.layout-main { padding: 20px; background-color: #f0f2f5; }

122</style>

更新路由守卫

最后,我们需要在路由守卫中调用 setMenuList,将后端数据存入仓库。

更新 src/router/index.js 中的 try 代码块:javascript

编辑

1// src/router/index.js

2import { useMenuStore } from '@/store/modules/menu' // 引入菜单仓库

3

4// ... 其他代码 ...

5

6router.beforeEach(async (to, from, next) => {

7 const token = localStorage.getItem('token')

8 const menuStore = useMenuStore() // 使用菜单仓库

9

10 if (token) {

11 if (to.path === '/admin-login') {

12 next({ path: '/admin/dashboard' })

13 } else {

14 if (hasAddedRoutes) {

15 next()

16 } else {

17 try {

18 const res = await getMenuRoute()

19 const menuData = res.data // 获取后端返回的菜单数据

20

21 // 将菜单数据存入仓库

22 menuStore.setMenuList(menuData)

23

24 // 转换为路由对象并动态添加

25 const dynamicRoutes = transformMenuToRoutes(menuData)

26 dynamicRoutes.forEach(route => {

27 router.addRoute(route)

28 })

29

30 hasAddedRoutes = true

31 next({ ...to, replace: true })

32 } catch (error) {

33 console.error('获取菜单失败,请重新登录', error)

34 localStorage.removeItem('token')

35 menuStore.clearMenuList() // 清空菜单数据

36 next('/admin-login')

37 }

38 }

39 }

40 } else {

41 // ... 其他代码 ...

42 }

43})

总结

完成以上步骤后,你的系统就能根据后端返回的 JSON 数据动态生成路由和侧边栏菜单了。

  • 路由:通过 transformMenuToRoutes 转换并动态添加。

  • 菜单:通过 Pinia 存储数据,并在 layout/index.vue 中循环渲染。

如果还有问题,欢迎随时提问!

12

twelve

这个报错 [🍍]: "getActivePinia()" was called but there was no active Pinia 是 Pinia 最常见的错误之一。

结合你刚才的日志和官方文档,这个问题的根本原因在于:你在 Pinia 实例被 Vue 应用挂载(app.use(pinia))之前,就尝试使用了 Store。

简单来说,你的代码在"装修房子"(挂载 Pinia)完成之前,就试图去"拿房子里的钥匙"(调用 useStore())。

以下是具体的排查步骤和解决方案:

  1. 检查 main.js / main.ts 的执行顺序

这是最常见的原因。请检查你的入口文件,确保 先创建 Pinia 实例并挂载,之后 再调用任何 useStore()。

❌ 错误写法(报错原因):javascript

编辑

1import { createApp } from 'vue'

2import { useUserStore } from './stores/user' // 引入了 Store

3import App from './App.vue'

4

5const app = createApp(App)

6const pinia = createPinia()

7

8// 错误:在 app.use(pinia) 之前就调用了 useUserStore()

9const userStore = useUserStore() // 💥 这里会报错,因为 Pinia 还没激活

10

11app.use(pinia) // 挂载太晚了

12app.mount('#app')

✅ 正确写法:javascript

编辑

1import { createApp } from 'vue'

2import { createPinia } from 'pinia' // 引入 createPinia

3import { useUserStore } from './stores/user'

4import App from './App.vue'

5

6const app = createApp(App)

7const pinia = createPinia()

8

9// 正确:先挂载 Pinia

10app.use(pinia)

11

12// 现在可以安全地使用 Store 了

13const userStore = useUserStore()

14

15app.mount('#app')


  1. 路由守卫中的陷阱(Vue Router)

如果你在 router/index.js 中使用了 Store(例如做登录守卫),很容易出现循环依赖或时机不对的问题。

❌ 错误写法:javascript

编辑

1import { useUserStore } from '@/stores/user'

2// ❌ 直接在这里调用,此时 Pinia 可能还没挂载

3const store = useUserStore()

4

5router.beforeEach((to, from) => {

6 // 逻辑...

7})

✅ 正确写法:

不要在模块顶层调用 useUserStore(),而是把它推迟到导航守卫的回调函数内部执行。javascript

编辑

1// router/index.js

2import { useUserStore } from '@/stores/user'

3// ✅ 只引入函数,不要在这里调用

4

5router.beforeEach((to, from) => {

6 // ✅ 在这里调用,此时 Vue 应用已经启动,Pinia 已经激活

7 const store = useUserStore()

8

9 if (to.meta.requiresAuth && !store.isLoggedIn) {

10 return '/login'

11 }

12})


  1. 独立 JS 文件/工具类中使用 Store

如果你在非组件、非路由的普通工具文件(如 utils/request.js)中需要使用 Store,不能直接调用 useStore(),因为那里没有上下文。解决方案:

你需要手动将 pinia 实例传递进去,或者在 main.js 中将实例挂载到全局变量/工具类上。

示例:javascript

编辑

1// utils/request.js

2let _pinia

3

4export function setupPinia(pinia) {

5 _pinia = pinia

6}

7

8export function getToken() {

9 if (!_pinia) throw new Error('Pinia not set up yet!')

10 const store = useUserStore(_pinia) // 手动传入实例

11 return store.token

12}

然后在 main.js 中:javascript

编辑

1import { setupPinia } from './utils/request'

2// ...其他代码

3app.use(pinia)

4setupPinia(pinia) // 传递实例

总结

请重点检查你的 路由守卫 或 工具类 是否在顶层直接执行了 useStore()。只要确保 useStore() 的调用发生在 app.use(pinia) 之后,或者在函数内部执行(延迟执行),这个问题就能解决。

thirteen

我来查看一下路由守卫和菜单相关的代码,找出菜单需要刷新页面才显示的原因。

这个问题是因为动态路由被添加到了根级别,而不是作为 /admin 路由的子路由。当你访问 /admin 时,它直接重定向到 /admin/dashboard,导致动态路由的菜单没有被正确关联到布局组件。

修复方法很简单,只需要将 router.addRoute(route) 改为 router.addRoute('admin', route),这样动态路由就会作为 /admin 的子路由被添加。

fourteen

相关推荐
猜测72 小时前
新语法在旧设备上的问题
前端·javascript·node.js
前端若水3 小时前
实战:纯 CSS 实现“有图片的卡片不同样式”
前端·css
爱喝水的鱼丶3 小时前
SAP-ABAP:SAP 与 ABAP 关联逻辑与入门路径:业务×开发的协作指南
服务器·前端·数据库·学习·sap·abap
小陈的进阶之路3 小时前
Python系列课(2)——判断
java·前端·python
2301_815645384 小时前
html.
前端·html
qq_381338504 小时前
CSS @layer 级联层实战指南:从样式冲突到分层架构
前端·css