简介这是一份面向前端学习者与房地产数据应用开发者的完整前端源码包围绕房地产大数据分析平台的界面呈现与交互需求提供可参考的工程实践样本。压缩包共2000个文件以1378个JavaScript源码为主辅以467个Markdown说明、147个JSON配置与少量HTML、XML及TXT文件整体约18.08MB目录结构便于按模块查阅。已有105人学习适合希望了解大型数据平台前端组织方式的中级开发者。源码覆盖组件化开发、数据可视化图表接入、HTTP接口交互、全局状态管理、响应式布局与模块打包等环节也能看到测试调试和代码规范相关的工程配置。读者可从中梳理数据密集型应用的前端分层、接口调用与状态流转思路并对照自身项目查漏补缺对房地产数据分析场景感兴趣者还可借此理解业务信息如何转化为直观图表与交互界面。1. 从一份前端组代码说起这套房地产大数据平台到底在解决什么问题接手这份「房地产大数据分析平台——前端组代码」的时候我最先打开的不是首页而是src/views/market目录。一个数据平台靠不靠谱看它怎么组织指标比看它怎么画图表准得多。这套前端组代码覆盖市场行情、楼盘详情、区域对比、成交走势和后台权限五块图表密、表格宽、筛选条件多是典型的中后台数据看板形态。适合两类人手上正接同类看板需求、想找现成骨架改的前端以及要从零搭数据平台、又不想在图表自适应和按钮权限上反复踩坑的开发者。代码里最值钱的部分是那些不太显眼的封装统一的请求层、能自适应宽度的图表组件、一套按后端菜单生成路由的逻辑。2. 技术选型与工程结构把前端组代码拆到目录这一层拿到一份别人写的中后台代码先看目录比先跑命令划算。目录能告诉你作者的分层习惯也能提前暴露哪些地方是硬编码、哪些地方留了扩展口。这一套的目录结构相当规矩api和views严格按业务域对齐是后面所有改造能顺利进行的前提。2.1 技术栈清单与选型理由先把用到的库摆出来后面所有代码都建立在这套组合上层次选型在这套代码里的实际作用框架Vue 3 script setup单文件组件里写组合式逻辑图表和表格的状态都收在 setup 内语言TypeScript接口返回结构全部有类型改字段时编译期就能报警构建Vite冷启动秒开本地代理直接写在vite.config.ts路由Vue Router 4history 模式配合后端菜单动态addRoute状态Pinia登录态、当前城市、字典缓存、权限码四块状态组件库Element Plus表格、表单、日期范围选择器、分页占界面 70%图表ECharts折线、柱状、饼图、地图散点全部走一个封装组件请求Axios统一拦截器处理 token、城市维度、错误提示时间Day.js按月聚合的 X 轴、环比同比计算选 Vue 3 而不是别的主要原因是这个项目里图表和表格的状态耦合很重切换城市要同时刷新四个图表和一张宽表用响应式去驱动比手动setOption省事太多。Element Plus 的表格虽然在大数据量下需要额外处理但它的筛选、排序、分页 API 足够稳定前端组不用自己造轮子。ECharts 挑大梁没有悬念房地产数据离不开地图和堆叠柱状图。要留意的是 TypeScript 在这套代码里不是摆设。api/types.ts里把PageResult、TrendPoint、DistrictStat全部定义成接口views层引用这些类型一旦后端改字段名vue-tsc直接报错比上线后看到一片空白强得多。2.2 目录结构与模块边界展开后的目录大致是这样src/ ├── api/ # 按业务域拆的接口函数只做请求不发散 │ ├── market.ts # 行情走势、排行、对比 │ ├── project.ts # 楼盘列表、详情、竞品 │ ├── region.ts # 区域下钻、热力 │ ├── system.ts # 登录、用户、菜单 │ └── types.ts # 所有响应体类型定义 ├── components/ # 跨页面复用的通用件 │ ├── BaseChart/ # ECharts 封装 │ ├── FilterBar/ # 城市 时间 业态的统一筛选条 │ └── StatCard/ # 指标卡 ├── composables/ # useTable / useChartTheme / useDict ├── directives/ # v-permission 按钮级权限 ├── layouts/ # 后台骨架侧边菜单 顶栏 面包屑 ├── router/ # 静态路由 动态路由生成 ├── stores/ # user / dict / app ├── styles/ # 变量、重置、ECharts 配色 ├── utils/ # request.ts、format.ts、export.ts └── views/ ├── dashboard/ # 首页总览 ├── market/ # 市场行情 ├── project/ # 楼盘详情 ├── region/ # 区域分析 └── system/ # 用户、角色、菜单这套划分的关键在于api和views的对应关系一个页面目录基本对应一个api文件接口函数只负责拼参数、发请求、返回带类型的数据不做任何数据加工。数据加工放在composables里页面组件只负责渲染。这样的好处是写新页面时有明确的三步走先在api里加函数再在composables里做聚合最后在views里拼 UI。坏处是文件数量多新手第一天容易在market.ts和market/index.vue之间来回找。components里的FilterBar值得单独提一句。房地产平台的筛选条件高度重复——城市、区域、时间范围、新房还是二手房——如果每个页面各写一遍改一个默认值要动十几个文件。代码里的做法是把筛选条抽成组件通过v-model双向绑定一个 query 对象页面拿到 query 后自己决定怎么请求。这种「筛选与渲染解耦」的写法在中后台里非常实用。2.3 本地启动与环境变量跑起来的门槛不高Node 18 以上即可# 安装依赖锁文件是 pnpm-lock.yaml用 pnpm 更稳 pnpm install # 开发模式默认 5173 端口 pnpm dev # 打包产物在 dist/ pnpm build # 本地预览打包结果验证路由 history 模式 pnpm preview环境变量放在根目录的.env.development和.env.production命名统一带VITE_前缀# .env.development VITE_APP_TITLE房产数据平台 VITE_API_BASE_URL/api VITE_PROXY_TARGEThttp://127.0.0.1:8080VITE_API_BASE_URL之所以写成/api而不是完整域名是为了走 Vite 的代理避开本地开发的跨域问题。代理配置在vite.config.ts里// vite.config.ts import { defineConfig, loadEnv } from vite import vue from vitejs/plugin-vue import { fileURLToPath, URL } from node:url export default defineConfig(({ mode }) { const env loadEnv(mode, process.cwd()) return { plugins: [vue()], resolve: { alias: { : fileURLToPath(new URL(./src, import.meta.url)) } }, server: { port: 5173, proxy: { // 只有以 /api 开头的请求才转发静态资源仍然走 Vite 自己 /api: { target: env.VITE_PROXY_TARGET, changeOrigin: true, // 后端接口本身不带 /api 前缀这里重写掉 rewrite: (path) path.replace(/^\/api/, ) } } } } })changeOrigin: true会把请求头里的 Host 改成目标地址很多内网网关靠 Host 做路由少了这一行就是 404。rewrite是否需要取决于后端有没有统一的/api前缀这套代码的后端没有所以要去掉。这两点是最常见的本地起不来的原因后面避坑章节还会展开。2.4 别名、规范与提交约束指向src的别名在vite.config.ts和tsconfig.json里必须同时配置只配一边的后果是编辑器不报错但编译报错// tsconfig.json 片段 { compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }代码规范上用的是 ESLint Prettier规则里关掉了vue/multi-word-component-names因为views下的页面组件大量用index.vue命名。这个选择有争议但对按目录组织的项目来说market/index.vue比MarketIndex.vue更好找。提交前跑一次pnpm lint能挡掉大部分低级问题。3. 数据看板核心链路一个楼盘指标从接口到图表渲染的全过程一个指标从后端到屏幕,要过四道关:请求层拼参数、类型层定义结构、图表层渲染、交互层响应用户切换。这四道关里任何一道写松了,后面改需求都会难受。这一章按顺序拆开看。3.1 请求层从 axios 实例到业务 api 函数utils/request.ts是整个前端的数据入口拦截器里做了三件事注入登录态、透传城市维度、统一处理业务码。// src/utils/request.ts import axios from axios import { ElMessage } from element-plus import { useUserStore } from /stores/user const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000, headers: { Content-Type: application/json } }) // 请求拦截登录态 城市维度 service.interceptors.request.use((config) { const user useUserStore() if (user.token) config.headers.Authorization Bearer ${user.token} // 平台所有查询都带城市维度放 header 里避免每个 api 函数重复传 if (user.cityCode) config.headers[X-City-Code] user.cityCode return config }) // 响应拦截后端统一包装 { code, data, msg } service.interceptors.response.use( (res) { const body res.data if (body.code 0) return body.data if (body.code 401) { useUserStore().logout() return Promise.reject(body) } ElMessage.error(body.msg || 接口返回异常) return Promise.reject(body) }, (err) { // 超时单独提示报表类接口超时是高频问题 const tip err.code ECONNABORTED ? 查询超时请缩小时间范围 : 网络异常请稍后重试 ElMessage.error(tip) return Promise.reject(err) } ) export default servicebaseURL走环境变量本地是/api走代理线上是网关域名。timeout给 15 秒是折中值行情类接口通常 300ms 内返回但成交明细的聚合查询偶尔会跑到 8 秒以上所以导出类接口会单独传timeout: 60000覆盖。响应拦截器直接把body.data返回出去好处是业务函数里不用每次都.data.data代价是拿不到原始响应头如果哪天要做文件下载就得另开一个实例。有了实例api层只写薄薄一层// src/api/market.ts import request from /utils/request import type { PageResult, TrendPoint, DistrictStat } from ./types export interface TrendQuery { startMonth: string // 格式 2023-01 endMonth: string type: new | second // 新房 / 二手房 } // 成交均价走势返回按月聚合的点数组 export const getPriceTrend (params: TrendQuery) request.getany, TrendPoint[](/market/price-trend, { params }) // 区域成交排行分页返回 export const getDistrictRank (params: { page: number; size: number }) request.getany, PageResultDistrictStat(/market/district-rank, { params }) // 新房二手房对比一次拿两组 export const getCompare (params: { month: string }) request.getany, { newAvg: number; secondAvg: number; ratio: number }( /market/compare, { params } )request.getany, TrendPoint[]里的两个泛型参数是 Axios 的固定签名第一个是响应体原始类型第二个才是函数实际返回的类型。因为拦截器已经把body.data剥出来了所以第二个泛型写业务类型编辑器里res[0].month就能直接提示。这个写法新人第一次看会懵但在整个项目里统一之后改接口字段时能省掉大量排查。3.2 图表封装BaseChart 收口 ECharts 的初始化与自适应ECharts 在这个项目里出现了二十多次如果每个页面都写echarts.init和window.addEventListener(resize)代码会迅速失控。代码里有一个BaseChart组件专门收口这件事!-- src/components/BaseChart/index.vue -- template div refel classbase-chart :style{ height }/div /template script setup langts import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from vue import * as echarts from echarts const props withDefaults(defineProps{ option: echarts.EChartsOption height?: string autoResize?: boolean }(), { height: 320px, autoResize: true }) const el refHTMLDivElement() // 用 shallowRefECharts 实例内部结构很深深度代理会拖慢渲染甚至报错 const chart shallowRefecharts.ECharts() let ro: ResizeObserver | null null const render () { if (!chart.value) return // notMerge 传 true切换指标时不残留旧 series chart.value.setOption(props.option, true) } onMounted(() { chart.value echarts.init(el.value!) render() if (props.autoResize) { // ResizeObserver 盯容器比 window.resize 精准侧边栏折叠也能捕获 ro new ResizeObserver(() chart.value?.resize()) ro.observe(el.value!) } }) watch(() props.option, render, { deep: true }) onBeforeUnmount(() { ro?.disconnect() chart.value?.dispose() // 必须 dispose否则切页面后实例常驻内存 }) /script三个细节值得说。第一shallowRef而不是refECharts 实例上挂着大量 DOM 引用和内部对象Vue 的深度响应式代理会给它套一层 Proxy轻则卡顿重则某些内部方法拿不到正确的this图表直接白屏。第二用ResizeObserver而不是监听window.resize因为后台布局里侧边菜单可以折叠窗口尺寸没变但容器宽度变了只监听 window 的话图会一直保持旧宽度。第三dispose()放在onBeforeUnmount里这是内存泄漏最常见的来源。setOption的第二个参数notMerge设成true意思是完全替换而不是合并。默认的合并模式在同一个组件里切换不同指标时会出问题——比如从「成交套数柱状图」切到「均价折线图」旧的柱状 series 会留在图上出现柱线叠加的怪画面。设成true每次全量替换配合watch深度监听option切换指标就很干净。3.3 宽表格分页、筛选与导出的组合房地产数据的表格特点是列多、数字多、还要能导出。代码里的做法是抽一个useTable组合式函数把列表、总数、加载态、查询对象、翻页、重置全部收进去// src/composables/useTable.ts import { reactive, ref } from vue import type { PageResult } from /api/types export function useTableT( fetcher: (q: Recordstring, any) PromisePageResultT, initQuery: Recordstring, any {} ) { const list refT[]([]) const total ref(0) const loading ref(false) const query reactive({ page: 1, size: 20, ...initQuery }) const load async () { loading.value true try { const res await fetcher({ ...query }) list.value res.records total.value res.total } finally { loading.value false // finally 保证异常时不会一直转圈 } } // 改筛选条件后必须回到第一页否则会查到空页 const search () { query.page 1; load() } const reset () { Object.assign(query, { page: 1, size: 20, ...initQuery }) load() } return { list, total, loading, query, load, search, reset } }页面里用起来就三行// src/views/market/district-rank.vue 片段 const { list, total, loading, query, load, search, reset } useTable(getDistrictRank, { rankType: amount }) onMounted(load)finally里关 loading 这个细节很关键。早期版本写在then里接口一报错转圈就停不下来用户只能刷新页面血泪经验。search里强制回第一页也是必须的用户在第 8 页把区域从「全市」改成「某区」结果只有 2 页不改 page 就会看到空表格还以为是数据没出来。导出走的是前端xlsx拼表再file-saver下载。这里有个折中导出当前页还是导出全部。代码里保留了两种按钮导出全部会单独调一个size: 5000的请求。这个上限是拍脑袋定的超过 5000 行建议走后端异步导出前端拼表会明显卡顿。3.4 地图下钻与区域联动区域分析页是整个平台交互最重的地方。地图上展示各区的成交热度点某个区下降一层右侧图表跟着刷新。实现上分三块地图数据、点击事件、联动状态。地图本身用的 GeoJSON 存放在assets/map/下按城市分文件echarts.registerMap注册一次就能复用。点击事件通过chart.on(click, params ...)拿到区域名然后触发cityCode districtCode的查询。这里不需要额外的事件总线因为BaseChart已经把实例封装了但页面拿不到实例。代码里的做法是给BaseChart加一个ready事件把实例抛出来给父组件用!-- 父组件用法 -- BaseChart :optionmapOption height520px readyonChartReady / script setup langts const currentDistrict refstring() const onChartReady (chart: echarts.ECharts) { chart.on(click, (params: any) { if (params.componentType ! series) return currentDistrict.value params.name loadDistrictDetail(params.name) // 触发右侧图表刷新 }) } /script下钻时要注意地图层级切换必须重新registerMap并且setOption要带notMerge: true否则上一层的散点会残留在新地图上。另外下钻深度建议限制在两级市 → 区再往下数据颗粒度不够做三层下钻的用户体验反而更差。4. 状态与权限登录态、动态菜单和按钮级权限怎么落地中后台的权限设计千篇一律又处处是坑。这套代码的思路是登录拿 token 和权限码菜单由后端下发路由动态注册按钮用指令控制。4.1 Pinia 用户态token、城市与权限码// src/stores/user.ts import { ref } from vue import { defineStore } from pinia import { login as loginApi, getProfile } from /api/system import type { MenuNode, LoginForm } from /api/types export const useUserStore defineStore(user, () { const token ref(localStorage.getItem(rt_token) || ) const cityCode ref(310100) // 当前城市默认主城区 const menus refMenuNode[]([]) const perms refstring[]([]) // 形如 market:export const login async (form: LoginForm) { const data await loginApi(form) token.value data.token // 存 localStorage刷新页面不丢登录态 localStorage.setItem(rt_token, data.token) } // 刷新后菜单和权限是空的必须重新拉一次 const fetchProfile async () { const data await getProfile() menus.value data.menus perms.value data.perms cityCode.value data.defaultCity || cityCode.value } const logout () { token.value perms.value [] menus.value [] localStorage.removeItem(rt_token) } return { token, cityCode, menus, perms, login, fetchProfile, logout } })城市放在 store 里而不是每个页面自己维护是这套代码里很聪明的一处设计。切换城市后请求拦截器自动带X-City-Code所有页面重新请求就自动切了城市不用挨个传参。perms存的是扁平字符串数组形如market:export、project:edit判断时用includes够用且好读。4.2 路由守卫动态菜单转路由的两个坑// src/router/guard.ts import router from ./index import { useUserStore } from /stores/user import { buildDynamicRoutes } from ./dynamic router.beforeEach(async (to) { const user useUserStore() if (to.meta.public) return true // 登录页、错误页放行 if (!user.token) { return { path: /login, query: { redirect: to.fullPath } } } // 刷新页面后 store 是空的补一次 profile if (!user.menus.length) { await user.fetchProfile() buildDynamicRoutes(user.menus) // 关键重新导航一次否则首次进入会命中 404 return { ...to, replace: true } } return true })return { ...to, replace: true }这一行是动态路由的经典写法。addRoute注册完成后当前这次导航是拿不到新路由的必须重新触发一次。少了这行用户刷新页面会看到 404但从别的页面点进去又正常属于第一眼很难定位的问题。replace: true是为了不在历史记录里留一条无效记录。buildDynamicRoutes负责把后端菜单树摊平成路由配置// src/router/dynamic.ts import type { RouteRecordRaw } from vue-router import type { MenuNode } from /api/types const modules import.meta.glob(../views/**/*.vue) export function buildDynamicRoutes(menus: MenuNode[]) { const walk (nodes: MenuNode[], parentPath ) { nodes.forEach((node) { if (node.children?.length) { walk(node.children, ${parentPath}/${node.path}) return } // 后端只给组件路径字符串这里映射到真正的懒加载模块 const key ../views/${node.component}.vue if (!modules[key]) { console.warn([route] 组件文件不存在, key) return } router.addRoute(layout, { path: node.path, name: node.name, component: modules[key], meta: { title: node.title, icon: node.icon, keepAlive: node.cache } }) }) } walk(menus) }import.meta.glob是 Vite 提供的批量导入能力它会把这批.vue全部打成懒加载 chunk。这里加了一行console.warn是因为后端配错组件路径是极高频的事没有这行警告就只能看到白屏然后去翻网络面板。4.3 按钮级权限与字典缓存按钮权限用一个自定义指令解决// src/directives/permission.ts import type { Directive } from vue import { useUserStore } from /stores/user export const permission: DirectiveHTMLElement, string | string[] { mounted(el, binding) { const need ([] as string[]).concat(binding.value) const user useUserStore() const has need.some((p) user.perms.includes(p)) // 直接摘掉节点不要用 display:none if (!has) el.parentNode?.removeChild(el) } }用法是el-button v-permissionmarket:export导出/el-button。之所以移除节点而不是隐藏是因为隐藏的按钮仍然能被脚本触发 click权限校验等于没做。当然前端权限只是体验层真正的拦截必须在后端这一点不能含糊。字典缓存是为了减少重复请求。新房/二手房、面积段、价格区间这些枚举在十几个页面都会用到stores/dict.ts里做了一次内存缓存加 localStorage 持久化首次进入拉全量之后直接读缓存。缓存加了 12 小时过期避免后端改了枚举前端还显示旧值。5. 踩坑与排查前端组血泪经验汇总下面这几条都是这套代码在真实联调和上线过程中遇到过的症状看起来玄学原因都很具体。5.1 图表在 Tab 切换后变成一条线或者空白现象从「成交走势」切到「区域排行」再切回来图表的宽度变成 0或者所有柱子挤在左边。原因display: none的容器宽度是 0ECharts 在初始化时读到 0 宽就会按 0 计算画布尺寸。切回来时容器有宽度了但实例里存的还是 0。解决Tab 切换后手动调一次resize()。如果用el-tabs可以在tab-change事件里延迟一帧再 resizeconst onTabChange () { // nextTick 之后再 resize此时容器已有实际宽度 nextTick(() chartRef.value?.resize()) }更彻底的做法是把lazy加在 Tab 面板上或者用v-if控制图表组件切回来重新挂载。这套代码选了前者懒加载加 resize改动最小。5.2 成交金额显示成科学计数法或末位对不上现象总价显示1.2345678901234568e9或者前端求和的结果和后端报表差几十块。原因JavaScript 的Number是双精度浮点数超过 2^53 的整数会丢精度。成交金额动辄十亿级用Number存必然出问题。解决金额字段从一开始就让后端返回字符串单位元或分前端只做展示不做运算。确实需要排序或求和时用整数分计算再格式化// 后端返回字符串避免 JSON.parse 阶段就丢精度 const formatAmount (raw: string, unit: yuan | fen yuan) { const fen unit fen ? BigInt(raw) : BigInt(raw) * 100n const wan Number(fen / 1000000n) / 100 // 转万元保留两位 return wan 10000 ? ${(wan / 10000).toFixed(2)} 亿 : ${wan.toFixed(2)} 万 }BigInt在这个场景里是必须的parseFloat加toFixed解决不了精度问题只是把误差藏起来。5.3 刷新页面 404从菜单点进去却正常现象本地pnpm dev一切正常部署到测试环境后按 F5 刷新任意子路由直接白屏 404。原因Router 用的是 history 模式URL 是/market/trend这样的真实路径。服务器找不到这个文件又没有配置回退到index.html就返回 404。开发环境 Vite 自动处理了这件事所以本地看不出来。解决在部署层加回退规则。Nginx 的写法是location / { root /usr/share/nginx/html; index index.html; # 找不到文件就交给前端路由 try_files $uri $uri/ /index.html; }pnpm preview能在本地模拟这个行为上线前跑一遍能提前发现。如果不想动服务器配置另一个选择是改成 hash 模式代价是 URL 里多个#。5.4 代理配置了但接口还是 404 或跨域现象本地请求/api/market/price-trend返回 404或者浏览器控制台报 CORS。原因通常有两个一是rewrite写错了把不该去的/api前缀也去掉了二是baseURL配成了完整域名请求根本没走代理。前者表现为 404后者表现为跨域。解决先看网络面板里请求的实际 URL。如果 URL 是http://127.0.0.1:8080/market/...说明代理生效且 rewrite 正确如果是http://localhost:5173/api/...且返回 404说明 rewrite 没匹配上如果直接指向了后端域名说明baseURL没走环境变量。排查顺序固定成「看 URL → 看 rewrite → 看 baseURL」比乱改配置快得多。5.5 切页面后定时器和监听还在跑现象首页有个 30 秒自动刷新的轮询进详情页待一会儿再回首页发现接口请求频率翻倍了。原因setInterval在组件卸载时没有清理。Vue 不会自动帮你清路由切换只是卸载了 DOM。解决所有副作用成对出现。轮询用onUnmounted清理事件监听用onBeforeUnmount移除ResizeObserver和IntersectionObserver记得disconnect。let timer: number | null null onMounted(() { load() timer window.setInterval(load, 30000) }) onUnmounted(() { if (timer) { clearInterval(timer); timer null } })判断有没有泄漏有个简单办法在浏览器 Performance 面板录一段「首页 → 详情 → 首页」的操作看内存曲线是否阶梯式上升。上升就说明有东西没释放。6. 二次开发换数据源、加指标页、抽公共组件的三条改造路径这份代码拿来二次开发最常见的三种诉求是后端换了要改接口、业务要加新指标页、以及想把图表主题统一。三条路径的改动量差别很大先给个对照改造目标主要改动文件预估工作量风险点换后端数据源utils/request.ts、api/*.ts、api/types.ts半天到两天响应包装结构不同拦截器要重写新增指标页api/xxx.ts、views/xxx/index.vue、后端菜单配置一天一页忘记配菜单导致路由 404统一图表主题styles/chart-theme.ts、components/BaseChart两小时老页面硬编码的 color 数组要一起清换数据源那条最省事的做法是只动拦截器。如果新后端的返回结构不是{ code, data, msg }就把响应拦截器里的判断换掉业务层一行不用改// 换后端时只改这一段api 层和页面层保持不动 service.interceptors.response.use( (res) { const body res.data // 新后端约定{ success: true, result: any, message: string } if (body.success) return body.result ElMessage.error(body.message || 请求失败) return Promise.reject(body) }, (err) Promise.reject(err) )这里的前提是api/types.ts里的字段名和新后端对得上。如果字段命名风格不同比如驼峰换下划线加一层映射函数比在每个页面里改res.avg_price划算// src/api/adapter.ts const snakeToCamel (obj: any): any { if (Array.isArray(obj)) return obj.map(snakeToCamel) if (obj typeof obj object) { return Object.fromEntries( Object.entries(obj).map(([k, v]) [ k.replace(/_([a-z])/g, (_, c) c.toUpperCase()), snakeToCamel(v) ]) ) } return obj }新增指标页的流程建议固化成三步别跳步第一步在api里加函数并补类型第二步在views下建目录写页面第三步去后端菜单表插一条记录component字段填market/new-metric这样的相对路径。第三步最容易忘忘了就是登录后看不到菜单然后在代码里翻半天。图表主题统一这件事我的建议是尽早做。这套代码里各个页面自己写了 color 数组同一张「成交均价」在首页是蓝色、在行情页是绿色用户会以为是两组不同的数据。抽一个chart-theme.ts导出统一色板在BaseChart初始化时echarts.registerTheme注册之后所有图表自动一致。改的时候把老页面的硬编码 color 一起删掉留着会覆盖主题。最后说个验证习惯。改完任何一处接口或路由我都会强制走一遍「登录 → 进首页 → 切城市 → 下钻一个区 → 导出一次 → 按 F5 刷新」这六个动作覆盖了 token、城市维度、动态路由、导出和刷新补数据五条链路任何一条断了都会暴露。有次偷懒跳过了刷新那步上线后才发现动态路由没做二次导航用户按 F5 全是 404被追着修了两小时。从那以后我每次交付前都强制走一遍这套动作不管改动多小。希望帮到你。本文还有配套的精品资源点击获取