ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vue后台管理系统实战:登录鉴权、列表详情、ECharts图表与地图可视化完整搭建

Vue后台管理系统实战:登录鉴权、列表详情、ECharts图表与地图可视化完整搭建

简介:这是一套基于 Vue.js 的 Web 管理系统完整示例,面向希望掌握前端工程化与常见业务模块实现的前端开发者及学习者,覆盖登录鉴权、列表与详情页、ECharts 数据可视化、地图集成等典型场景,可作为课程设计或练手项目的参考模板。压缩包共 2002 个文件,以 1087 个 md 文档、831 个 js 脚本、78 个 json 配置为主,另有少量 html 页面与说明文件,整体约 96.56MB,目录结构清晰,便于按模块查阅源码与笔记。已有 61 人学习下载。读者可从中获取登录状态管理、数据驱动列表渲染、详情页数据绑定、ECharts 组件化封装以及地图 API 嵌入等实现思路,并借助配套文档理解各模块的组织方式与依赖关系,适合作为 Vue 项目实战的对照参考。

1. 一个后台管理系统到底要写多少东西:从登录到地图的完整拼图

很多人第一次接手「VueWeb 管理系统」时,脑子里想的是一张 CRUD 表格加一个侧边栏,结果真开工才发现,登录态怎么存、列表分页怎么和后端对齐、详情页刷新会不会白屏、ECharts 图表在容器隐藏时初始化尺寸为 0、地图组件加载慢到用户以为卡死——每一个都是独立的坑。这个标题讲的不是某个具体框架版本,而是一套完整的管理系统骨架:登录鉴权、列表查询、详情展示、ECharts 图表、地图可视化,五块拼在一起才是一个能交付的后台。它适合两类人:一是刚从前端页面切到中后台业务、需要一份可复现路径的开发者;二是已经写过零散页面、但没把路由守卫、请求封装、图表生命周期串成一条线的人。下面按我实际搭项目的顺序,把每一块拆开讲清楚。

2. 登录与路由守卫:把 token 这件事一次做对

2.1 为什么登录不是「调个接口存个 token」就完了

登录在管理系统里承担的不只是身份校验,它是整个前端权限体系的起点。常见做法是:登录接口返回 token 和用户基础信息,前端把 token 存起来,后续每个请求带上它,路由跳转前检查它是否存在、是否过期。听起来简单,但真正翻车的地方在于「存哪里」和「什么时候清」。

存 localStorage 的好处是刷新不丢、多标签页共享,坏处是 XSS 一旦发生 token 直接暴露;存 sessionStorage 相对安全一点,但新开标签页就没了;存内存变量最安全,但刷新即丢,需要配合 refresh token 做静默续期。我一般会选 localStorage 存 token,同时把过期时间也存进去,路由守卫里做一次本地过期判断,避免每次跳转都打接口验证。

另一个容易忽略的点是「登录页本身不能被守卫拦截」。如果守卫逻辑写成「没有 token 就跳登录」,而登录页也在路由表里,就会死循环。正确做法是在路由 meta 里标记requiresAuth,只对需要鉴权的路由做检查。

2.2 路由守卫与请求拦截器的配合写法

下面是一段可直接抄的守卫逻辑,基于 Vue Router 的全局前置守卫:

// router/index.js import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/login', name: 'Login', component: () => import('@/views/Login.vue') }, { path: '/', component: () => import('@/layout/Index.vue'), redirect: '/dashboard', children: [ { path: 'dashboard', name: 'Dashboard', component: () => import('@/views/Dashboard.vue'), meta: { requiresAuth: true } }, { path: 'list', name: 'List', component: () => import('@/views/List.vue'), meta: { requiresAuth: true } }, { path: 'detail/:id', name: 'Detail', component: () => import('@/views/Detail.vue'), meta: { requiresAuth: true } } ] } ] const router = createRouter({ history: createWebHistory(), routes }) router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const expireAt = Number(localStorage.getItem('expireAt') || 0) // 只对标记了 requiresAuth 的路由做鉴权 if (to.meta.requiresAuth) { if (!token || Date.now() > expireAt) { localStorage.removeItem('token') localStorage.removeItem('expireAt') next({ name: 'Login', query: { redirect: to.fullPath } }) return } } // 已登录用户访问登录页,直接送回首页 if (to.name === 'Login' && token && Date.now() < expireAt) { next({ name: 'Dashboard' }) return } next() }) export default router

逻辑说明:beforeEach里先取 token 和过期时间,requiresAuth为 true 且 token 缺失或已过期时,清掉本地存储并跳登录页,同时把目标路径塞进 query,登录成功后可以跳回去。第二个判断处理「已登录还去登录页」的情况,避免用户手动改地址栏造成状态混乱。

参数说明:expireAt建议由后端返回的过期秒数在前端换算成时间戳存储,不要直接存「过期时间字符串」,否则每次比较都要解析,容易出时区问题。redirect参数在登录页读取后要用decodeURIComponent处理一次,否则带参数的路径会丢。

请求拦截器这边,常见做法是在 axios 实例里统一加 Authorization 头,并处理 401:

// utils/request.js import axios from 'axios' import router from '@/router' const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) service.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') localStorage.removeItem('expireAt') router.replace({ name: 'Login' }) } return Promise.reject(error) } ) export default service

这里的关键是 401 处理要和路由守卫用同一套清理逻辑,否则会出现「token 清了但页面还停在需要鉴权的路由上」的中间态。timeout设 10 秒是经验值,管理系统里超过 10 秒的接口基本可以认为有问题,早断早提示。

3. 列表与详情:分页、查询条件和刷新白屏

3.1 列表页的三个必调参数

列表页看起来最简单,但真正决定好不好用的是三个参数:当前页、每页条数、查询条件。很多新手会把查询条件直接绑在输入框上,输入一个字就发一次请求,结果后端被打爆、页面闪烁。正确做法是加防抖,或者用「查询按钮 + 回车触发」的方式。

分页参数建议统一命名:page从 1 开始,pageSize默认 10 或 20,后端返回total和list。不要用offset/limit和page/pageSize混着来,否则每接一个新接口都要重新对字段。

下面是一个列表页的核心逻辑:

// views/List.vue 的 script 部分 import { ref, reactive, onMounted } from 'vue' import request from '@/utils/request' const loading = ref(false) const tableData = ref([]) const total = ref(0) const query = reactive({ keyword: '', status: '', page: 1, pageSize: 10 }) async function fetchList() { loading.value = true try { const res = await request.get('/user/list', { params: query }) tableData.value = res.list total.value = res.total } finally { loading.value = false } } function handleSearch() { query.page = 1 // 查询条件变化时必须重置到第一页 fetchList() } function handlePageChange(page) { query.page = page fetchList() } onMounted(fetchList)

逻辑说明:handleSearch里把page重置为 1 是必须的,否则用户在第 5 页改了查询条件,结果集只有 2 页,就会拿到空数据,用户以为系统坏了。loading用finally关掉,避免请求报错后 loading 一直转。

参数说明:pageSize如果做成用户可切换的选项,切换时同样要重置page。keyword如果做实时搜索,防抖时间建议 300ms,低于这个值意义不大,高于 500ms 用户会觉得迟钝。

3.2 详情页刷新白屏的根因和修法

详情页最常见的翻车场景是:从列表点进详情一切正常,用户一按 F5 刷新,页面白了。原因通常有两个:一是详情数据靠路由跳转时传的state或全局变量,刷新后丢了;二是路由参数id没取对,请求发了但参数是 undefined。

修法很简单:详情页永远只依赖路由参数id,进入页面时用id重新请求数据。不要依赖「从列表页带过来的那一条数据」,那只能用来做首屏优化,不能作为唯一数据源。

// views/Detail.vue import { ref, onMounted } from 'vue' import { useRoute } from 'vue-router' import request from '@/utils/request' const route = useRoute() const detail = ref(null) async function fetchDetail() { const id = route.params.id if (!id) return detail.value = await request.get(`/user/detail/${id}`) } onMounted(fetchDetail)

逻辑说明:route.params.id在刷新后依然存在,因为它是 URL 的一部分。如果详情页路由写成/detail?id=1的 query 形式,就用route.query.id,两者不要混。

参数说明:如果详情页有编辑功能,保存成功后不要直接改本地detail对象就完事,建议重新拉一次详情,保证和服务端一致。多花一次请求,省掉后面「为什么改了没生效」的排查时间。

4. ECharts 与地图:容器尺寸、按需引入和加载时机

4.1 ECharts 图表不显示的三个原因

ECharts 在管理系统里最玄学的问题就是「代码没错,图不出来」。按出现频率排,原因有三个:

第一,容器没有宽高。ECharts 初始化时读取容器的clientWidth和clientHeight,如果容器是display: none或者高度为 0,图表就渲染成 0×0。常见于 tab 切换场景,第一个 tab 正常,切到第二个 tab 图表空白。

第二,初始化时机太早。DOM 还没挂载就调init,拿不到容器。Vue 里要放在onMounted之后,且确保ref已经绑定。

第三,数据更新了但没调setOption,或者调了但notMerge参数用错,导致旧配置残留。

修法:容器给固定高度,比如height: 360px;tab 切换时用resize()或重新setOption;数据异步到达后再初始化图表。

// components/ChartPanel.vue import { ref, onMounted, onBeforeUnmount, watch } from 'vue' import * as echarts from 'echarts' const chartRef = ref(null) let chartInstance = null function renderChart(data) { if (!chartInstance) { chartInstance = echarts.init(chartRef.value) } chartInstance.setOption({ tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: data.map(i => i.date) }, yAxis: { type: 'value' }, series: [{ type: 'line', data: data.map(i => i.value), smooth: true }] }) } function handleResize() { chartInstance && chartInstance.resize() } onMounted(() => { window.addEventListener('resize', handleResize) }) onBeforeUnmount(() => { window.removeEventListener('resize', handleResize) chartInstance && chartInstance.dispose() chartInstance = null }) watch(() => props.data, val => { if (val && val.length) renderChart(val) }, { immediate: true })

逻辑说明:dispose在组件销毁时必须调,否则反复进出页面会内存泄漏,图表实例越积越多。resize监听窗口变化,侧边栏折叠时也要手动触发一次,因为窗口尺寸没变但容器宽度变了。

参数说明:smooth: true让折线平滑,适合趋势展示;如果强调精确数值,设为 false。trigger: 'axis'适合多系列对比,单系列可以用item。

4.2 地图组件的加载与坐标绑定

地图这块,常见做法是用 ECharts 的 geo 或 map 系列,配合一份区域 GeoJSON。关键点有三个:GeoJSON 要提前注册、坐标要对应、地图容器同样要有高度。

import * as echarts from 'echarts' import geoJson from '@/assets/map/region.json' echarts.registerMap('region', geoJson) function renderMap(chartDom, points) { const chart = echarts.init(chartDom) chart.setOption({ geo: { map: 'region', roam: true, label: { show: false }, itemStyle: { areaColor: '#eef3f8', borderColor: '#c9d6e2' } }, series: [{ type: 'scatter', coordinateSystem: 'geo', data: points, // [{ name: 'A点', value: [经度, 纬度, 数值] }] symbolSize: val => Math.max(6, val[2] / 10) }] }) return chart }

逻辑说明:registerMap只需执行一次,放在模块顶层即可。coordinateSystem: 'geo'表示散点用地理坐标,value数组前两位是经纬度,第三位可以映射到点的大小或颜色。

参数说明:roam: true允许缩放拖动,管理系统里如果只是展示,可以设为 false 避免用户误操作。symbolSize用函数动态计算时,注意val[2]为 0 的情况,加个下限避免点消失。

地图数据量大的时候,散点图性能会下降,超过 2000 个点建议换成effectScatter或做聚合。GeoJSON 文件不要超过 1MB,否则首屏加载明显变慢,可以按需拆分区域。

5. 避坑与排查:那些让我加班到凌晨的细节

5.1 刷新后 404:history 模式的服务端配置

现象:本地开发一切正常,打包部署后,从首页点进详情没问题,一刷新就 404。

原因:Vue Router 的 history 模式依赖服务端把所有路径都指向index.html,如果服务端没配,刷新时浏览器直接请求/detail/1,服务端找不到这个文件就返回 404。

解决:在服务端加一条 fallback 规则,把所有非静态资源请求重写到index.html。不同服务器配置方式不同,核心思路一致。如果没法改服务端,就退回 hash 模式,代价是 URL 里多个#。

5.2 图表在弹窗里尺寸为 0

现象:图表放在弹窗组件里,弹窗打开后图表空白,关掉再开又正常。

原因:弹窗首次打开时,容器从display: none变为可见,但 ECharts 初始化发生在可见之前,读到的高度是 0。

解决:在弹窗的opened事件里再调一次chart.resize(),或者用nextTick延迟初始化。更稳的做法是监听弹窗可见状态,可见后再init。

5.3 列表查询条件带空格导致查不到

现象:用户输入「张三 」,查询无结果,手动删掉空格再查就有了。

原因:输入框的值直接传给了后端,后端做了精确匹配,尾部空格导致不匹配。

解决:提交前对字符串类查询条件做trim(),这是最省事的做法。如果业务允许模糊匹配,后端用like而不是=。

5.4 token 过期后多个请求同时 401

现象:token 过期后,页面同时发了好几个请求,每个都触发 401,导致跳转登录页多次,甚至出现路由报错。

原因:响应拦截器里每个 401 都执行了跳转,没有做去重。

解决:加一个标志位,第一个 401 触发跳转后,后续 401 在标志位重置前不再重复跳转。或者用 Promise 队列,等刷新 token 后重放请求。管理系统里简单加标志位就够了。

5.5 地图 GeoJSON 版本和坐标对不上

现象:地图能渲染,但散点位置偏移,或者某些区域显示不出来。

原因:GeoJSON 的坐标系和散点用的坐标系不一致,常见于数据来源不同。

解决:统一用 WGS84 或 GCJ02,不要混用。如果数据源无法统一,在前端做一次坐标转换,但转换会损失精度,能统一就统一。

6. 把五块拼成一个可维护的骨架:我的目录习惯和验证方法

写到这儿,五块功能都能跑了,但能不能维护是另一回事。我踩过最大的坑不是某个 API 用错,而是项目跑到后期,请求散落在各个组件里、路由守卫和拦截器各写一套清理逻辑、图表实例没人销毁。后来我固定了一套目录习惯,这里直接给出来。

src/ api/ # 按模块拆的接口函数,组件不直接写 url user.js chart.js utils/ request.js # axios 实例,统一拦截 auth.js # token 存取、过期判断 router/ index.js # 路由表 + 守卫 layout/ Index.vue # 侧边栏 + 顶栏 + 内容区 views/ Login.vue List.vue Detail.vue Dashboard.vue components/ ChartPanel.vue MapPanel.vue

api层的作用是把 URL 和参数收敛到一处,组件里只调getUserList(params),不关心底层是 GET 还是 POST。这样后端改路径时只改一个文件,不用全局搜索。

验证方法我一般分三步走。第一步,清掉 localStorage,直接访问一个需要鉴权的路由,看是否跳登录页且带 redirect。第二步,登录后进列表,翻到第 3 页,改查询条件,看是否回到第 1 页且数据正确。第三步,进详情页按 F5,看数据是否重新加载;切到图表页,缩放窗口,看图表是否跟随 resize;打开地图,拖动缩放,看散点是否跟随。

这三步能过,基本交付没问题。剩下的就是接口联调时的字段对齐,那个只能靠和后端坐一起对文档。

最后说一个我自己的习惯:每次新接一个管理系统,我会先把登录和路由守卫写完,再写一个最简单的列表页跑通请求链路,然后才去碰图表和地图。因为图表和地图的调试成本高,如果请求层有问题,会误以为是图表配置错了,白白浪费时间。这个顺序帮我省过很多次「以为是 ECharts 的锅,其实是 token 没带上」的排查。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进