拓冰建站拓冰建站
首页 / 资讯中心 / 正文

HMI人机界面全解析:从设备数据采集到控制回写的工程实践

在 Hacker News 上看到 “Show HN: Human-Machine Interface” 这个标题时我第一反应不是“又一个仪表盘项目”而是想到了一个问题2025 年为什么还有人愿意专门把一个 HMI 项目发布出来答案其实并不复杂——因为 HMI 这件事在真正接触过工业设备、边缘网关、无人值守终端的人眼里依然是常年踩坑的领域。画几个按钮很容易但让现场人员敢用、能用、不出事故是另一套工程问题。这篇文章不打算只评价某个具体仓库而是借这个标题聊透“人机界面”背后的技术本质和落地方法。我们会从 HMI 的核心概念讲起分析它与普通 Web 前端的差异然后用一套最小可运行的系统带你走通从设备数据采集、通信协议、实时页面到控制回写的完整链路。过程中会给出完整代码、验证方式以及我在工程实践里认为最值得注意的坑。如果你正在做设备监控、工业 IoT 平台、边缘网关管理或者只是好奇“人机界面”到底难在哪里这篇文章值得读完。读完你会知道一个可用的 HMI绝不是 UI 的问题而是数据链路、安全边界和人机协作方式的问题。1. 这篇文章真正要解决的问题先给一个明确判断HMI 之所以难不是因为界面复杂而是因为它的评价标准复杂。普通 Web 页面追求的是转化率和体验HMI 追求的却是“在正确的时间用正确的方式让人做出正确的操作”。一个按钮按下去背后可能是一台电机启动、一个阀门开闭、甚至是一条产线的启停。界面做得好不好看现场师傅不会给你打绩效但按错一次后果很直接。从材料来看这个 “Show HN: Human-Machine Interface” 的项目并没有限定到某一个具体行业它更像是一个通用方向的产物。这也正好切中了很多团队的现状设备层已经完成了联网数据也能采上来但最终交付给用户时还需要一个既能看状态、又能操作、还要稳定运行的“界面层”。这一层就是 HMI 的职责。读完这篇文章你会得到三样东西一套 HMI 的系统性认知不再把 HMI 等同于“做个网页”。一套可以本地跑通的最小代码涵盖设备模拟、消息通信、实时渲染、控制回写。一组从现场部署角度总结的排查方法和工程建议帮你少走弯路。至于什么样的读者最该读我认为至少包括四类人正在为设备做监控面板的前端工程师、想把设备接入平台的后端开发者、负责边缘网关产品化的硬件工程师以及刚接触工业 IoT、想补齐“交互层”知识的学生或转行者。2. 基础概念与核心原理2.1 什么是 HMIHMI 的全称是 Human-Machine Interface也就是人机界面。它最早是工业控制领域的概念用于连接操作人员和机器设备。经典的形态是工控机上的组态软件比如下位机 PLC 配上 WinCC、组态王、InTouch 这类软件在一台触摸屏或工业平板上显示设备状态提供按钮、数值输入、报警弹窗等交互。但今天所说的 HMI 已经远远超出工业范畴。智能家居的中控屏、充电桩的触屏、AGV 小车的调度面板、楼宇自控的监控平台本质上都属于广义 HMI。它的共同特点是背后连接着真实世界中的设备或系统人的一次操作会产生物理结果而不是仅仅改变软件状态。这个区别非常重要。很多从互联网背景转过来的开发者容易把 HMI 当成“数据可视化大屏”来做。数据可视化主要消费数据HMI 既要消费数据也要回写控制指令这就引入了权限、确认机制、状态同步、失败回滚等一系列问题。2.2 HMI、SCADA 与普通 Web 前端的区别为了把概念说清楚我用一个表格来对比 HMI 和普通 Web 前端以及经常被混为一谈的 SCADA维度普通 Web 前端HMI 人机界面SCADA 监控系统数据来源业务数据库、API设备实时数据、网关数据大规模设备采集网络交互结果改变软件状态影响物理设备动作监控为主少量远程控制实时性要求秒级可接受毫秒到亚秒级秒级但强调历史趋势可靠性要求相对宽松高操作不可误触高数据不可丢失网络环境云端、办公网局域网、边缘网络、偶尔跨公网工业专网、混合网络典型技术Vue、ReactWebSocket、MQTT、Canvas/SVG时序库、组态软件、专用网关可以看到普通前端和 HMI 之间的核心差异不在技术栈而在“交互后果”和“运行环境”。一个页面崩溃了用户会刷新但设备控制页面卡死三秒现场操作员就会陷入不安。2.3 HMI 的典型架构一套完整的 HMI 系统按数据流可以拆成四层设备层传感器、PLC、控制器、边缘网关。采集与通信层通过 Modbus、OPC UA、MQTT、HTTPS 等协议把数据取出来。应用服务层负责数据清洗、存储、鉴权、指令下发。呈现与交互层浏览器、触摸屏、桌面端应用负责展示和操作。材料中的 “Show HN: Human-Machine Interface” 如果落在技术栈上最有可能的切入点就是第 3 层和第 4 层。这也是大多数中小企业自研 HMI 时最实际的选择设备层用现成硬件第 2 层用标准协议把精力集中在上层应用和界面。2.4 为什么说“界面不是最难的部分”很多人第一次做 HMI会花大量时间调按钮样式、切页面动效。实际上对于 HMI 而言界面只占产品价值的三到四成。更核心的是下面三件事数据能不能稳定、实时地到达页面。控制指令能不能准确、安全地到达设备。通信中断、设备异常时系统能不能明确告诉操作者发生了什么。想清楚这一点再回来看 HMI 项目的代码结构你就能抓住重点消息通道和状态管理比组件库重要得多。3. 环境准备与前置条件从这一节开始我们进入实操。下面这套最小 HMI 示例会模仿一个典型的边缘场景设备通过 MQTT 上报状态后端负责接入数据并通过 WebSocket 推送给浏览器前端提供实时显示和控制按钮。3.1 技术选型说明为了让示例足够轻量我选择以下技术栈后端Python FastAPI提供 WebSocket 服务和 HTTP API。通信MQTT使用 paho-mqtt 库连接本地或远端 Broker。前端原生 HTML JavaScript SVG 绘制界面不引入前端框架。设备模拟Node.js 或 Python 脚本定时发送模拟数据。这套组合的好处是依赖少、原理透明不需要搭建完整的前端工程。在实际项目中你也可以把后端换成 Node.js、Go前端换成 Vue 或 React核心思路是一样的。3.2 安装软件本文演示环境以 Ubuntu 22.04 为例Windows 和 macOS 的差异会在代码注释里说明。版本号请以实际安装为准我们重点演示通用思路。安装 Python 与依赖sudo apt update sudo apt install -y python3 python3-pip python3-venv创建虚拟环境并安装库mkdir hmiexample cd hmiexample python3 -m venv venv source venv/bin/activate pip install fastapi uvicorn[standard] paho-mqtt安装 MQTT Broker本机测试用sudo apt install -y mosquitto mosquitto-clients如果是在 Windows 上你可以到 Mosquitto 官网下载安装包或者使用 Docker 启动一个临时 Broker。安装完成后启动服务sudo systemctl enable mosquitto sudo systemctl start mosquitto检查 Broker 是否运行mosquitto_sub -t test -C 1 -W 1没有报错就说明 Broker 服务正常。4. 核心流程拆解下面我们把 HMI 的最小闭环拆成四个步骤。每一步都有必要少一环系统就跑不通。4.1 第一步模拟设备数据上报设备层不能直接在浏览器里运行所以第一步要有一个程序扮演“设备”周期性地发布数据。这里的数据可以是温度、转速、开关状态等任意值。我们会写一个 Python 脚本每隔 1 秒向 MQTT 主题hmi/device/status发布一个 JSON 数据包。这一步的作用是验证 MQTT 链路。如果模拟数据能稳定发出来后面接真实设备只是换数据来源的问题。4.2 第二步后端订阅 MQTT 并转为 WebSocket浏览器不能直接连接 MQTT Broker尤其在生产环境中MQTT 的端口和凭据不应暴露给前端。所以后端的作用是订阅 MQTT 主题收到设备数据后把它转换成 WebSocket 消息推送给浏览器。这一步是 HMI 架构中的关键转折数据从设备协议域进入了 Web 域前端只需要关心 WebSocket不需要知道 MQTT 的存在。4.3 第三步前端渲染实时状态前端页面建立 WebSocket 连接后收到数据就更新 SVG 仪表盘上的数值和颜色。这里用 SVG 的原因是它简单、可控、不依赖 Canvas 的绘制上下文适合演示实时状态渲染。4.4 第四步控制回写HMI 不能只看不控。前端按钮点击后会向后端发送一条 HTTP 请求后端再把这个控制指令发布到 MQTT 主题hmi/device/control模拟设备订阅这个主题收到指令后改变自身状态并再次上报状态。这样一个完整的闭环就形成了设备上报 → 后端推送 → 页面显示 → 用户操作 → 后端下发 → 设备响应。5. 完整示例与代码实现下面给出所有关键代码。建议按文件路径创建文件保持目录结构清晰。5.1 目录结构.hmiexample/ ├── device_simulator.py # 模拟设备 ├── backend.py # FastAPI 后端 ├── static/ │ └── index.html # HMI 页面 └── requirements.txt # 依赖清单5.2 模拟设备代码文件路径device_simulator.pyimport json import random import time import paho.mqtt.client as mqtt BROKER_HOST localhost BROKER_PORT 1883 STATUS_TOPIC hmi/device/status CONTROL_TOPIC hmi/device/control device_state {running: False, temperature: 24.0, speed: 0} def on_connect(client, userdata, flags, rc): print(设备已连接到 MQTT Broker结果码:, rc) client.subscribe(CONTROL_TOPIC) def on_message(client, userdata, msg): try: payload json.loads(msg.payload.decode(utf-8)) action payload.get(action) if action start: device_state[running] True print(收到启动指令) elif action stop: device_state[running] False print(收到停止指令) elif action set_speed: device_state[speed] int(payload.get(value, 0)) print(收到速度设定:, device_state[speed]) except Exception as e: print(指令解析失败:, e) client mqtt.Client() client.on_connect on_connect client.on_message on_message client.connect(BROKER_HOST, BROKER_PORT, 60) client.loop_start() try: while True: if device_state[running]: device_state[temperature] round( device_state[temperature] random.uniform(-0.5, 0.5), 1 ) device_state[speed] min(3000, max(0, device_state[speed])) status {ts: int(time.time() * 1000), **device_state} client.publish(STATUS_TOPIC, json.dumps(status)) time.sleep(1) except KeyboardInterrupt: print(设备模拟器已停止) client.loop_stop() client.disconnect()这段代码里真正需要注意的是状态机的控制方式。设备收到“启动”指令后只是把running置位随后温度、转速才发生变化。这样设计符合真实设备逻辑控制指令改变设备模式设备状态是模式运行的结果而不是按钮的直接产物。5.3 后端服务代码文件路径backend.pyimport asyncio import json import paho.mqtt.client as mqtt from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.staticfiles import StaticFiles import uvicorn BROKER_HOST localhost BROKER_PORT 1883 STATUS_TOPIC hmi/device/status CONTROL_TOPIC hmi/device/control app FastAPI() app.mount(/static, StaticFiles(directorystatic), namestatic) clients: set[WebSocket] set() mqtt_client mqtt.Client() app.on_event(startup) async def startup(): mqtt_client.on_connect on_mqtt_connect mqtt_client.on_message on_mqtt_message mqtt_client.connect(BROKER_HOST, BROKER_PORT, 60) mqtt_client.loop_start() app.on_event(shutdown) async def shutdown(): mqtt_client.loop_stop() mqtt_client.disconnect() def on_mqtt_connect(client, userdata, flags, rc): print(后端已连接 MQTT Broker结果码:, rc) client.subscribe(STATUS_TOPIC) def on_mqtt_message(client, userdata, msg): payload msg.payload.decode(utf-8) asyncio.run(broadcast(payload)) async def broadcast(message: str): if not clients: return for ws in list(clients): try: await ws.send_text(message) except Exception: clients.discard(ws) app.post(/api/control) async def control(payload: dict): action payload.get(action) value payload.get(value) control_msg {action: action, value: value} mqtt_client.publish(CONTROL_TOPIC, json.dumps(control_msg)) return {ok: True} app.websocket(/ws/status) async def websocket_endpoint(websocket: WebSocket): await websocket.accept() clients.add(websocket) try: while True: await websocket.receive_text() except WebSocketDisconnect: clients.discard(websocket) if __name__ __main__: uvicorn.run(app, host0.0.0.0, port8000)这里有几个工程细节需要解释用全局clients集合维护 WebSocket 连接广播时遍历所有人断开的连接直接移除。MQTT 回调中是同步代码需要通过asyncio.run(broadcast(payload))把消息送进 FastAPI 的事件循环。这只是演示做法高并发场景下更推荐用独立的异步 MQTT 客户端或消息队列。控制接口没有加鉴权生产环境必须补上后文会专门说明。如果你在 Windows 上运行asyncio.run()在部分事件循环中可能不适用。一个更稳妥的做法是把广播请求丢进asyncio.create_task但需要先拿到运行中的 loop。这里为了保持示例简单只在 POSIX 环境下演示。5.4 前端页面代码文件路径static/index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / titleHMI 演示面板/title style body { font-family: Microsoft YaHei, Arial, sans-serif; background: #1c1f22; color: #e6e6e6; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; } .panel { background: #2a2e33; border-radius: 16px; padding: 32px; width: 520px; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4); } .status-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } .metric { background: #363c43; border-radius: 10px; padding: 16px; text-align: center; flex: 1; margin: 0 6px; } .metric .value { font-size: 28px; font-weight: bold; color: #4da3ff; } .metric .label { font-size: 13px; color: #aab2bc; margin-top: 6px; } button { background: #4da3ff; border: none; color: #fff; padding: 12px 24px; border-radius: 8px; font-size: 15px; cursor: pointer; margin: 6px; } button.stop { background: #e74c3c; } .console { background: #111417; border-radius: 8px; padding: 12px; height: 120px; overflow-y: auto; font-size: 13px; margin-top: 16px; } .online { color: #2ecc71; font-size: 13px; } .offline { color: #e74c3c; font-size: 13px; } /style /head body div classpanel div classstatus-row h2设备状态/h2 span idconnState classoffline未连接/span /div div styledisplay: flex; div classmetric div classvalue idtemperature--/div div classlabel温度 °C/div /div div classmetric div classvalue idspeed--/div div classlabel转速 rpm/div /div div classmetric div classvalue idrunState--/div div classlabel运行状态/div /div /div div styletext-align: center; margin-top: 20px; button idbtnStart启动/button button idbtnStop classstop停止/button button idbtnSetSpeed设定转速 1500/button /div div classconsole idconsole/div /div script const consoleEl document.getElementById(console); const connStateEl document.getElementById(connState); function log(text) { const line document.createElement(div); line.textContent [ new Date().toLocaleTimeString() ] text; consoleEl.appendChild(line); consoleEl.scrollTop consoleEl.scrollHeight; } function connect() { const ws new WebSocket(ws:// location.host /ws/status); ws.onopen function () { connStateEl.className online; connStateEl.textContent 已连接; log(WebSocket 已连接); }; ws.onclose function () { connStateEl.className offline; connStateEl.textContent 未连接; log(WebSocket 连接断开3 秒后重连); setTimeout(connect, 3000); }; ws.onmessage function (event) { const data JSON.parse(event.data); document.getElementById(temperature).textContent data.temperature || --; document.getElementById(speed).textContent data.speed || --; document.getElementById(runState).textContent data.running ? 运行中 : 已停止; log(收到状态: JSON.stringify(data)); }; } async function sendControl(action, value) { try { const resp await fetch(/api/control, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ action: action, value: value }) }); const result await resp.json(); log(指令已下发: action (value || )); } catch (e) { log(指令下发失败: e.message); } } document.getElementById(btnStart).addEventListener(click, function () { sendControl(start); }); document.getElementById(btnStop).addEventListener(click, function () { sendControl(stop); }); document.getElementById(btnSetSpeed).addEventListener(click, function () { sendControl(set_speed, 1500); }); connect(); /script /body /html页面的核心逻辑不多但有两个细节值得注意。第一WebSocket 断线重连。HMI 运行在现场时网络抖动是常态不能指望用户手动刷新页面。这个示例在onclose里 3 秒后重连是最简单的自我恢复机制。第二按钮直接触发 HTTP 请求没有做二次确认。这是示例为了保持简单做的取舍真实项目里“启动”“停止”这类操作几乎都必须有确认弹窗防止误触。后文最佳实践会再展开。5.5 依赖清单文件路径requirements.txtfastapi uvicorn[standard] paho-mqtt安装依赖pip install -r requirements.txt6. 运行结果与效果验证6.1 启动顺序按 HMI 的数据流启动顺序是先启动 MQTT Broker再启动模拟设备最后启动后端服务。# 终端 1启动 Broker sudo systemctl start mosquitto # 终端 2启动设备模拟器 source venv/bin/activate python device_simulator.py # 终端 3启动后端 source venv/bin/activate python backend.py启动后打开浏览器访问http://localhost:8000/static/index.html但注意这里有一个路径问题。我们在 FastAPI 中挂载了/static目录同时页面里请求的 WebSocket 路径是/ws/status控制接口是/api/control。直接访问http://localhost:8000/static/index.html是可以的但如果你希望用户访问根路径就看到页面可以加一个根路由。这里为了简洁直接用静态目录路径。6.2 预期输出正常情况下你应该看到页面左上角“未连接”变成“已连接”。温度值每秒变化一次波动范围在 24°C 左右。转速和运行状态保持“已停止 / 0”。点击“启动”运行状态变为“运行中”温度开始上下波动转速变成 1500。点击“停止”运行状态变回“已停止”温度波动和转速都停止。后端终端会输出收到的设备状态和下发指令日志。如果页面上的“已连接”一直没有出现优先检查后端是否启动成功以及浏览器控制台有没有报错。6.3 如何判断系统真正跑通判断 HMI 系统是否合格不只是看页面显示。更关键的是做两个验证验证一拔掉数据源。把device_simulator.py用 CtrlC 停掉观察页面。连接状态不应立即消失但状态数据会在几秒后停止刷新。如果页面设计得当还应该有“数据超时”或“设备离线”的提示。验证二操作回写链路。点击“停止”按钮然后立刻查看后端日志。日志里应该能看到hmi/device/control主题收到的消息以及模拟设备打印的“收到停止指令”。如果只看到 HTTP 返回成功但设备没有反应说明 MQTT 发布或订阅有问题。这两个验证覆盖了 HMI 最重要的两条链路数据上行链路和控制下行链路。两条都通这个最小闭环才算真正成立。7. 常见问题与排查思路HMI 系统一旦上了现场问题排查往往比开发更耗时间。下面这张表里是我认为自研 HMI 时出现频率最高的问题。问题现象可能原因排查方式解决方案页面显示“未连接”后端服务未启动或 WebSocket 路径错误查看浏览器控制台检查 ws 地址用 curl 测试/ws/status确认后端运行核对前端 ws 路径WebSocket 频繁断开网络不稳定或服务端异常退出查看服务端异常日志在 onclose 中输出关闭码增加断线重连检查反向代理超时配置页面收到数据但不更新JSON 字段名不匹配对比后端推送的数据结构和前端取值的字段名统一字段命名推荐 snake_case控制按钮无响应后端控制接口未加 CORS或 MQTT 未成功 publish用 Postman 调/api/control观察是否返回 ok查看 MQTT 日志添加 CORS 中间件检查 Mosquitto 日志换台电脑访问不了页面后端监听地址为localhost或防火墙拦截确认 uvicorn host 为0.0.0.0检查防火墙端口修改 host放行8000端口MQTT 连接报错 5Broker 未启动或用户名密码错误用mosquitto_pub手动发布测试启动 Broker检查凭据配置数据更新延迟明显前端每一条消息都做大量 DOM 操作观察后端日志时间和页面更新时间前端做批量渲染或节流避免每帧重建 DOM生产环境页面无法打开Nginx 未配置 WebSocket 升级检查 Nginx access log 和错误日志配置Proxy WebSocket upgrade相关头每个项目还会有自己的特殊问题但排查思路大体一致先确认通道通不通再确认数据对不对最后才怀疑渲染逻辑。把链路分层逐段验证能大幅降低排查时间。8. 最佳实践与工程建议8.1 画面前先定协议我见过太多项目先画页面后定义数据字段最后联调时发现前端要sensorTemp后端传temp设备返回temperature三个人各写各的。HMI 的第一份设计文档应该是协议文档而不是设计稿。状态上报协议应包含设备编号、时间戳、运行状态、关键数值、告警位。控制指令协议应包含指令类型、目标值、操作人、时间戳、唯一指令 ID。定义好协议前后端并行开发就不会打架。8.2 控制类操作必须有确认和安全边界在 HMI 里操作安全是第一优先级。至少要有这几道防线前端二次确认弹窗防止手滑。后端权限校验确认当前用户具备操作权限。指令幂等设计重复点击不应导致重复动作。操作日志记录谁在什么时间做了什么操作全部留痕。针对危险操作增加“当前状态二次确认”比如设备已启动时不允许重复启动。示例代码为了简洁没有加这些但它只是演示。生产环境里控制接口裸奔等于把机房钥匙挂在门口。8.3 分辨率和触摸适配工业 HMI 最常见的显示设备是 19 寸触摸一体机、10 寸平板分辨率常常是 1366x768、1024x768甚至 800x600。设计页面时不要只拿自己 4K 显示器做测试。建议在开发早期就锁定目标分辨率重要按钮不小于 48x48 像素按钮间距要足够大防止误触。字体方面中文字体在工业设备上往往缺失建议使用系统自带字体栈或者把需要的字体打包进前端资源。8.4 离线不只是显示“未连接”HMI 系统最怕的不是设备故障而是故障时操作者不知道发生了什么。所以离线状态处理必须分层通信断开提示网络异常不要假装数据还在刷新。设备停止上报超过一定时间没有数据自动标记“数据超时”。设备故障根据上报的告警位显示故障类型。页面上的状态颜色要符合现场习惯绿色代表正常运行黄色代表警告红色代表故障或停止。这个直觉色约定比花哨主题重要得多。8.5 日志与审计HMI 的日志和普通 Web 应用日志不同它不仅要记录系统异常还要记录操作者行为。建议至少保存以下几类信息登录日志谁什么时候从哪个 IP 登录。操作日志操作内容、操作前设备状态、操作后设备状态。系统日志WebSocket 连接状态、MQTT 消息流量、后端异常。设备日志设备上报时间戳、字段异常记录。日志留存周期建议满足实际项目的合规要求。在自研系统中至少保留 90 天以上。8.6 从单机演示走向多设备管理先把这套最小系统跑通你会意识到它在多设备场景下完全不够用。设备多了以后至少要做三件事引入设备注册中心动态管理设备列表。后端为每个设备维护独立的 WebSocket 通道而不是全局广播。引入时序数据库保存历史数据为页面上的趋势曲线提供数据源。更进一步可以把设备状态建模成一个“影子设备”或“数字孪生”后端维护设备最新状态前端查询时直接读影子而不是每次穿透到设备层。这样能大幅缓解设备侧的并发压力。9. 总结与后续学习方向从 “Show HN: Human-Machine Interface” 这个题目延伸出来的其实是一条非常完整的工程链路。很多人看到 HMI 两个字以为这是一个前端项目但真正把它做明白你需要懂 MQTT 通信、WebSocket 实时推送、设备状态建模、操作安全设计还要熟悉现场环境的约束。这篇文章用一套最小示例跑通了这条链路从设备模拟到前端控制都给了可运行的代码你可以直接照着搭建本地环境。下一步我建议你做三件事把示例里的 MQTT 主题改成自己的业务主题接入真实设备或真实网关。给后端补上 Token 鉴权和操作日志体会一下“安全边界”对 HMI 的真正意义。用 Vue 或 React 重写前端同时引入 ECharts 为设备状态增加趋势曲线。如果你身边的团队正准备自研一套设备监控或控制面板把这篇文章里的工程建议作为评审清单会少走很多弯路。HMI 这个领域看起来旧但每一次交互设备升级、每一轮边缘计算落地它都会被重新需要。真正稀缺的是既能写出流畅界面又懂设备通信和现场风险的人。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门