
简介基于Reactantd打造的环境监测网站课程设计项目采用B/S架构覆盖数据分析、数据监控、用户工作台、财务管理、项目管理和人员管理六大功能模块。项目实现环境数据上传查看、项目分配与工作分类等核心操作配合mock数据及本地保存机制无需后端即可演示。压缩包共98个文件、约3.96MB其中37个jsx前端组件、13个css样式、3个js配置及webpack构建文件构成主体另有31张png界面截图和1份毕业论文docx文档层级清晰便于按模块查阅。已有176人学习下载。读者可据此掌握Reactantd组件化开发、Redux状态管理与mock数据模拟思路理解B/S环境监测系统的完整设计流程是课程设计、毕业设计或前端练手的实用参考。1. 环境监测网站真正难的不是页面是数据节奏环境监测类网站和外网常见的后台管理系统有一个明显差异它的表格和图表永远在“动”。站点状态从正常切到告警、PM2.5 数值每五分钟跳一次、空气等级从良变轻度污染——这决定了你选 Reactantd 并不是因为组件好看而是因为它能在一个数据高频变化的中后台场景里压得住重渲染。React 负责响应式更新antd 负责把表格、表单、卡片、反馈组件快速拼装起来剩下真正决定体验上限的是数据流、轮询策略和渲染边界这三件事。适合读这篇文章的人分两种一是刚接手类似“监控大屏/环境在线监测”需求、想知道整套工程怎么搭的前端开发二是项目已经跑起来但遇到表格卡顿、轮询造成重复渲染、页面在窄屏下布局错乱想找具体排查路径的工程师。我会从工程初始化、数据层设计、antd 组件落地、性能优化到动态合并列和离线导出这条完整链路逐步展开中间给出的都是可以照着落的方案。2. Reactantd 项目初始化版本选型与目录划分2.1 用 Vite 起一个 ReactTS 工程环境监测网站通常要接手存量接口、对接多套设备协议代码体积和类型约束都会直接影响后续迭代。常见做法是直接用 React TypeScript 起步不用 JavaScript。工程脚手架我一般选 Vite开发服务启动快、构建逻辑比 Webpack 配置透明很多而且对 antd 的按需加载支持不需要额外处理。创建一个工程并安装核心依赖npm create vitelatest env-monitor -- --template react-ts cd env-monitor npm install antd ant-design/icons npm install react-router-dom zustand echarts npm install axios dayjs依赖里需要说明几个选择理由react-router-dom 承担站点列表、详情页、大屏页之间的路由zustand 负责监测数据的全局存取比 Redux 少很多样板代码尤其适合需要配合轮询做高频更新的场景echarts 负责环境趋势图虽然 antd 体系里有图表生态但 ECharts 对时间轴大数据量的渲染成熟度更高dayjs 用来处理时间戳体积小且和 antd 的日期组件默认值体系天然配合。axios 可保留也可换成内置 fetch但在拦截器统一处理 token 和请求失败重试时axios 的拦截器语法更直白。2.2 antd 按需加载与主题定制Vite 工程中 antd 默认只打包用到的组件样式自动按需引入。真正需要留意的是主题需要与监测类型的视觉语义保持一致例如正常态绿色、告警态红色、离线态灰色。在入口文件接入 antd 主题令牌import { ConfigProvider } from antd; const theme { token: { colorPrimary: #1677ff, colorSuccess: #52c41a, colorError: #ff4d4f, fontSize: 14, }, components: { Table: { headerBg: #fafafa, rowHoverBg: #f5f5f5, }, }, }; ReactDOM.createRoot(document.getElementById(root)!).render( ConfigProvider theme{theme} App / /ConfigProvider );注意 ConfigProvider 必须包裹在整个应用最外层如果后续接入国际化比如监测平台要切中英文也是在这个组件上统一传 locale。antd 的 theme 令牌在升级到 5.x 之后已经不需要再用primary-color这类变量覆写直接在 token 层改即可。2.3 目录划分与模块边界监测网站的功能模块通常包含站点管理、实时数据、历史查询、告警记录偶尔还有一个投到大屏上展示的视图。建议按业务域而不是按组件类型建目录src/ api/ # 接口定义与请求函数 types/ # 站点、指标、告警的 TypeScript 类型 store/ # zustand 数据状态 pages/ realtime/ # 实时数据页 history/ # 历史查询页 dashboard/ # 大屏展示页 components/ # 通用业务组件 hooks/ # 轮询、可见性监听等自定义 hook utils/ # 日期格式化、阈值判断等纯函数把类型单独放一个目录是因为环境监测的接口字段很多例如空气站会有 SO2、NO2、PM2.5、PM10、O3、CO 六项污染物再加上温度湿度风速风向不同接口返回字段重复性强集中定义后可供表格列配置和图表 series 复用。store 目录只持久化该全局共享的数据页面内部私有的筛选条件不要塞进去。3. 监测数据模型与接口层类型、查询与轮询刷新3.1 用 TypeScript 固定站点与指标结构环境监测网站里的数据接口通常返回嵌套结构站点包含一系列指标每个指标包含监测值和状态。先把类型定义为复用基础export type PollutantCode | PM25 | PM10 | SO2 | NO2 | O3 | CO; export interface PollutantValue { code: PollutantCode; name: string; value: number; unit: string; status: normal | warning | danger; updatedAt: string; } export interface Station { id: string; name: string; address: string; longitude: number; latitude: number; pollutants: PollutantValue[]; online: boolean; } export interface HistoryQueryParams { stationId: string; codes: PollutantCode[]; startTime: string; endTime: string; }status 字段是环境监测网站区别于普通管理系统的关键它是前端做阈值染色、告警提示、数据大屏状态闪烁的统一依据。接口返回原始数值前端根据国家标准或项目自定义阈值计算 status这样更换阈值口径时只需要改一处纯函数不需要后端配合改接口。3.2 对 axios 做二次封装并接 Mock 数据监测网站在开发阶段往往等不到真实设备数据常见的做法是在请求层做一个开关开发环境直接返回本地 mock json生产环境走真实接口。import axios from axios; import type { Station } from ../types; const isDev import.meta.env.DEV; export const getStationList async (): PromiseStation[] { if (isDev) { return import(../mock/stationList.json).then((m) m.default); } const { data } await axios.get(/api/env/stations, { timeout: 10000, }); return data; };参数说明timeout 设 10 秒是环境监测接口的常见阈值设备上报链路长时容易出现超过 5 秒的返回太短的 timeout 会导致页面频繁弹请求失败提示mock 使用import()动态加载是为了让 vite 在构建生产包时自动剔除 mock 模块避免测试数据泄漏到正式环境。接口返回建议统一走{ code, data, message }外壳拦截器里先判断业务 code 再向下抛。3.3 用 zustand 管理监测数据的存取环境监测页面里站点列表要共享给左侧导航树、顶部下拉框和中间表格轮询拿到新数据后要同时驱动图表重绘和统计卡片更新。此时把数据收敛到一个全局 store让组件各自订阅所需片段可以避免 props 层层传递。import { create } from zustand; interface StationStore { stations: Station[]; selectedStationId: string | null; loading: boolean; lastUpdatedAt: number; setStations: (stations: Station[]) void; setSelectedStation: (id: string) void; } export const useStationStore createStationStore((set) ({ stations: [], selectedStationId: null, loading: false, lastUpdatedAt: 0, setStations: (stations) set({ stations, lastUpdatedAt: Date.now() }), setSelectedStation: (id) set({ selectedStationId: id }), }));注意 zustand 的订阅粒度问题组件里如果写const stations useStationStore((s) s.stations)那么只有 stations 引用变化时该组件才会重新渲染。轮询回来后新旧数据引用不同所有订阅 stations 的组件都会刷新所以高频区域要尽量拆小后面第五章会具体讲怎么用 memo 和 useMemo 隔离渲染边界。3.4 轮询刷新策略与不可见页面暂停环境监测数据通常每 1 到 5 分钟刷新一次轮询是这个场景的标准方案比 WebSocket 更适合低频变化数据。轮询不应该只写成 setInterval 套在组件里需要同时处理卸载清理、页面隐藏时暂停、请求失败重试三件事import { useEffect, useRef } from react; export function usePolling(fetcher: () Promisevoid, intervalMs 60000) { const timerRef useRefnumber(); const fetchRef useRef(fetcher); fetchRef.current fetcher; useEffect(() { const run async () { try { await fetchRef.current(); } catch (e) { console.warn(polling request failed, wait next round, e); } }; run(); const timer window.setInterval(run, intervalMs); timerRef.current timer; const onVisibilityChange () { if (document.hidden) { window.clearInterval(timerRef.current); } else { const timer window.setInterval(run, intervalMs); timerRef.current timer; run(); } }; document.addEventListener(visibilitychange, onVisibilityChange); return () { window.clearInterval(timerRef.current); document.removeEventListener(visibilitychange, onVisibilityChange); }; }, [intervalMs]); }这个实现里有一个容易忽略的细节visibilitychange 切回页面时不仅恢复轮询还要立即触发一次 run。原因是页面在后台被浏览器挂起时 setInterval 并不准确切回后需要马上拿一次最新数据。run 内部先执行再设置下一次定时器能避免首次进入页面等满一个间隔才有数据。用户切到其他标签页超过一小时再回来这些处理决定首页打开是显示真实监测值还是陈旧的缓存值。4. 监测面板落地antd 卡片、阈值状态与表格联动4.1 指标卡片组Statistic 与 Badge 组合环境监测网站最常见的首页形式是上排指标卡片、中间实时表格、下方趋势图。每张卡片要同时呈现三项信息指标数值、单位、运行状态。antd 的 Statistic 负责数值格式化Badge 负责状态提示。import { Card, Badge, Statistic } from antd; interface PollutantCardProps { item: PollutantValue; } const statusMap { normal: { text: 正常, color: success }, warning: { text: 轻度超标, color: warning }, danger: { text: 严重超标, color: error }, }; export function PollutantCard({ item }: PollutantCardProps) { const status statusMap[item.status]; return ( Card sizesmall div style{{ display: flex, justifyContent: space-between }} span{item.name}/span Badge status{status.color} text{status.text} / /div Statistic value{item.value} precision{1} suffix{item.unit} / /Card ); }这段代码里 precision 固定为 1 位小数适合空气颗粒物浓度展示。如果项目里包含噪声监测等整数指标建议把 precision 并入 PollutantValue 类型定义不要写死在组件里。statusMap 放在组件外部定义是利用模块级常量复用避免每次渲染都重新创建对象。4.2 筛选区与 Table 数据联动实时监测表格通常带三组筛选条件站点名称、指标类型、状态。antd 的 Form 与 Table 联动时数据流应当是一条单向链表单提交时更新查询参数查询参数变化触发数据重新拉取再进入 store 更新表格数据源。import { Form, Select, Table, Button } from antd; import type { ColumnsType } from antd/es/table; import { useStationStore } from ../store/station; interface StationTableProps { stations: Station[]; } const columns: ColumnsTypeStation [ { title: 站点名称, dataIndex: name, fixed: left, width: 160 }, { title: 地址, dataIndex: address, ellipsis: true }, { title: PM2.5, key: pm25, width: 120, render: (_, record) { const pm record.pollutants.find((p) p.code PM25); return pm ? ${pm.value} ${pm.unit} : -; }, }, { title: 更新时间, dataIndex: updatedAt, width: 180 }, ]; export function StationTable({ stations }: StationTableProps) { const [form] Form.useForm(); const handleFilter () { const values form.getFieldsValue(); // 这里通过 setSelectedStation 联动 store 中的数据过滤 useStationStore.getState().setSelectedStation(values.stationId); }; return ( div style{{ display: flex, flexDirection: column, gap: 12 }} Form form{form} layoutinline onFinish{handleFilter} Form.Item namestationId label站点 Select allowClear placeholder请选择站点 style{{ width: 180 }} options{stations.map((s) ({ label: s.name, value: s.id }))} / /Form.Item Form.Item namestatus label状态 Select allowClear placeholder全部状态 style{{ width: 140 }} options{[ { label: 正常, value: normal }, { label: 告警, value: warning }, { label: 严重, value: danger }, ]} / /Form.Item Form.Item Button typeprimary htmlTypesubmit 查询 /Button /Form.Item /Form Table rowKeyid columns{columns} dataSource{stations} scroll{{ x: 1000 }} pagination{{ pageSize: 10, showTotal: (t) 共 ${t} 条 }} / /div ); }一个值得注意的细节筛选条件如果放页面私有组件的 Form 中脱离页面后筛选状态即销毁切换菜单再回来就重置了。多数环境监测项目会要求站点切换后条件保留建议把筛选值放入 zustand store 或用 URLSearchParams 同步到路由。4.3 ECharts 趋势图挂到 antd 布局中趋势图需要展示选定站点近 24 小时的污染物变化曲线。ECharts 在 React 中的使用切记不要把图表实例放进 useState正确做法是 useRef 持有实例useEffect 负责初始化与销毁import * as echarts from echarts; import { useEffect, useRef } from react; import { Card } from antd; interface TrendChartProps { timeAxis: string[]; seriesData: number[]; pollutantName: string; } export function TrendChart({ timeAxis, seriesData, pollutantName }: TrendChartProps) { const chartRef useRefHTMLDivElement(null); const chartInstance useRefecharts.ECharts(); useEffect(() { const chart echarts.init(chartRef.current!); chartInstance.current chart; return () chart.dispose(); }, []); useEffect(() { chartInstance.current?.setOption({ tooltip: { trigger: axis }, grid: { top: 30, right: 20, bottom: 30, left: 50 }, xAxis: { type: category, data: timeAxis }, yAxis: { type: value, name: pollutantName }, series: [ { type: line, data: seriesData, smooth: true, showSymbol: false, animationDuration: 200, }, ], }); }, [timeAxis, seriesData, pollutantName]); return Card title{${pollutantName} 24小时趋势}div ref{chartRef} style{{ height: 300 }} //Card; }setOption 默认是增量合并模式就是说轮询时只更新 series.data不会重建整个图表。组件卸载时没有 dispose 导致的内存泄漏是这块最常见的坑上面的 useEffect cleanup 中调用 chart.dispose 可以规避。注意 div 容器在初始化时必须已有高度和宽度所以 style 里给 height 300px拿不到容器宽高时 echarts 会打印警告。5. 监测页面性能优化防重复渲染、响应式适配与排查点5.1 用 memo 与 useMemo 隔离高频更新区域轮询每 60 秒更新数据如果整个实时页作为一个组件重新渲染antd Table 的列计算、图表 setOption、卡片 value 动画会全部重复执行数据量上去后页面帧率直接掉。隔离更新的核心规则让订阅数据的组件尽量靠近叶子节点。对高频图表组件直接包 memo并配合 useMemo 构造数据参数import { memo, useMemo } from react; import { useStationStore } from ../store/station; const MemoTrendChart memo(TrendChart); export function RealTimePage() { const selectedStationId useStationStore((s) s.selectedStationId); const station useStationStore((s) s.stations.find((item) item.id selectedStationId) ); const chartData useMemo(() { if (!station) return { timeAxis: [], seriesData: [] }; const pm station.pollutants.find((p) p.code PM25); return { timeAxis: pm ? [pm.updatedAt] : [], seriesData: pm ? [pm.value] : [], }; }, [station]); if (!station) return null; return ( MemoTrendChart timeAxis{chartData.timeAxis} seriesData{chartData.seriesData} pollutantNamePM2.5 / ); }这里有几个容易犯错的点.find()在数据更新后返回的如果是新对象memo 会重新渲染这是期望行为但如果数据层做了原地修改保持引用不变memo 会失效。最佳实践是保持 store 中的不可变更新setStations 时整体替换 stations 数组让状态变化可预测。5.2 大屏适配vw/vh 方案与缩放兜底监测网站常需要投到指挥中心大屏页面宽高比与设计稿不一致是标配问题。常见做法是开发时以 1920x1080 设计稿为基准用缩放方案适配。缩放方案比纯 vw/vh 的优点是内部布局仍按设计稿绝对数值写不拆散组件布局逻辑import { useEffect } from react; export function useScalePage(targetWidth 1920, targetHeight 1080) { useEffect(() { const doScale () { const scaleX window.innerWidth / targetWidth; const scaleY window.innerHeight / targetHeight; const scale Math.min(scaleX, scaleY); const root document.getElementById(dashboard-root); if (root) { root.style.transform scale(${scale}); root.style.transformOrigin left top; } }; doScale(); window.addEventListener(resize, doScale); return () window.removeEventListener(resize, doScale); }, [targetWidth, targetHeight]); }细节说明transformOrigin 必须设为 left top否则缩放后容器会居中偏移scale 取 min(scaleX, scaleY) 是为了保持宽高比不变避免图表被拉伸变形。用这种方案时外层容器高度要设置为 100vh 并 overflow hidden防止缩放后出现滚动条。大屏和普通端如果共用一套组件建议把 dashboard 单独作为路由页面与其他布局脱离开否则表格的滚动条在大屏上的体验会非常别扭。5.3 antd Table 大数据量下的固定列与分页环境监测的历史查询页可能涉及一个站点半年每分钟一条的记录数据量几十万行级别。此时不要试图一次性把数据全塞给 Table标准做法是接口分页加虚拟滚动二选一。antd 的 Table 在 5.x 已经原生支持 virtual 属性但接口分页更常见也更容易维护。开启接口分页后需要同步处理三项属性pagination 中的 current、pageSize、total 需要受控sort 和 filter 的受控值也不要写在列级配置里全部收敛到页面 state。const [page, setPage] useState({ current: 1, pageSize: 20, total: 0 }); const loadHistory useCallback(async (pageParams) { const data await fetchHistory({ ...pageParams }); setPage({ current: pageParams.current, pageSize: pageParams.pageSize, total: data.total, }); }, []);把 loadHistory 用 useCallback 包起来是有必要的Table 的 onChange 每次触发都会重建函数引用如果不包会造成 pagination 配置对象重复变化进而引发请求死循环。实际接入时可以在 onChange 里打日志观察每触发一次接口请求就排查一次依赖。5.4 轮询产生重复请求的 3 个排查点排查轮询相关 bug代码上最常出问题的集中在三个位置第一useEffect 的依赖数组漏项导致每次渲染重新创建 setInterval出现多个定时器并发请求第二fetch 请求没有异常捕获接口报错时 unhandled rejection 会把组件卸载路径打断第三切后台恢复时没有立即刷新页面上显示的数据是旧值。排查时可以打开 Network 面板观察请求间隔是否稳定多次请求夹杂出现通常就是定时器重复创建还有一种常见情况是 React StrictMode 在开发模式下的双执行 effect实测确认线上没有这个行为即可。6. 动态合并列与离线导出环境监测场景的两个加分技巧6.1 antd 表格动态合并同站点单元格环境监测的历史查询里同一个站点会有多个污染指标如果表格按指标展开为多行站名、地址这些列会出现重复。环境监测的历史报表最常要求的展示方式是同一个站点纵向合并单元格。antd 实现动态合并列不复杂关键是利用 columns 里 column 配置的 onCell 返回 rowSpanconst columns: ColumnsTypeHistoryRow [ { title: 站点, dataIndex: stationName, width: 160, onCell: (record, index) { if (index undefined) return { rowSpan: 1 }; const rowSpan record.rowSpan; // 该字段在数据层计算 return rowSpan ? { rowSpan } : { rowSpan: 0 }; }, }, ];rowSpan 的计算属于纯函数逻辑放在渲染之前做数据预处理时算好不要放在 onCell 内部实时算否则每次渲染都要全量遍历数据。以站点分组为例若某站点有 6 个指标行第一行的 rowSpan 为 6其余 5 行 rowSpan 为 0。注意 rowSpan 为 0 的行 antd 不会渲染该单元格但整行的其他列不受影响。开启这个特性后要同时关闭该列的固定属性固定列和合并行同时使用在 antd 里有已知的渲染错位问题这一点在 antd 表格动态合并列相关的实现里是最常见的坑。6.2 导出 CSV 时的 BOM 与文件命名监测数据的导出需求频率不低报表要给环保部门做留档。后端导出是正统方案但前端导出 CSV 在数据量不大时更省联调时间。导出时最容易踩的坑是 Excel 打开 CSV 中文乱码需要写入 BOM 头const exportCsv (rows: Station[], fileName: string) { const header [站点, 地址, PM2.5, 更新时间]; const body rows.map((s) { const pm s.pollutants.find((p) p.code PM25); return [s.name, s.address, pm?.value ?? , s.updatedAt].join(,); }); const csv \uFEFF [header.join(,), ...body].join(\n); const blob new Blob([csv], { type: text/csv;charsetutf-8; }); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download ${fileName}_${Date.now()}.csv; a.click(); URL.revokeObjectURL(url); };参数说明\uFEFF 是 UTF-8 BOMExcel 识别到它会使用 UTF-8 解码否则默认按 ANSI 读会出现中文乱码a.click() 之后需要 revokeObjectURL 释放内存否则连续导出多次会累积内存占用。文件名里带上时间戳是为了满足环境监测报告对追溯的要求导出后放到下载列表里也方便阅读者通过文件名识别批次。验证功能是否完整的标准很简单把页面切到站点列表改几个指标数值等待一轮轮询看卡片是否更新、表格是否刷新、趋势图是否位移、控制台有没有红色报错。这四个动作全部正常再开启无痕窗口模拟新用户访问确认详情页和大屏页路由没有依赖上一个页面留下的全局状态。这套链路跑通剩下的就是数据质量的细节问题。本文还有配套的精品资源点击获取