EMI智慧SOP看板(安灯系统)— 面试问答
项目时间:2024年9月 - 2024年11月 | 提升纺织行业生产效率30%+ 技术栈:Vue3 + TypeScript + Vite + Pinia + Element Plus + TailwindCSS + WebSocket + Web Serial API + MediaDevices API + qrcodejs2 线上地址:https://emi.goomaker.com/andon-system
Q1: WebSocket 实时双向通信
面试官可能问:"说说 WebSocket 在这个项目中是怎么设计的?"
回答:
WebSocket 是整个系统的通信中枢。项目是一个工业产线的安灯(Andon)系统——产线工人遇到问题(缺料、设备故障、质量问题等)时,在平板上点一下对应的安灯类型,就会实时通知对应的处理人员。
整个 WebSocket 通信基于事件驱动模型,定义了多种消息类型:
incomingRequest:有新请求来了 → 如果当前在处理页面就直接刷新列表,否则弹窗提示"是否跳转到处理页面"并 5 秒倒计时自动跳转pendingRequest:登录后发现有待处理的请求 → 同样的提示逻辑towerLight:控制 PLC 塔灯的信号 → 解析十六进制指令,通过串口发给塔灯serialPort:串口连接状态通知 → 未连接时自动跳转设置页并引导用户连接heart:心跳包,45 秒一次保活close:服务端主动断开(被挤下线)→ 清空 session 并引导重新登录
心跳 + 重连机制:WebSocket 连接后每 45 秒发一次心跳包({ type: "heart", msg: "💖" }),检测 readyState === 1(已连接)才发。断开时指数退避重连——1s → 2s → 4s → 8s …… 直到最大 30s。意外断开时弹窗引导用户点击"确认重连"刷新页面。
追问:"WebSocket 连接携带的 ID 是什么?"
是工位 ID。员工在设置页点击"当前工位"登录后,后端返回该工位的人员信息并存到 sessionStorage。WebSocket 连接时 URL 拼上工位 ID(如 ws://xxx/websocket/123),后端就知道哪个工位在线,能把请求精准推送给对应工位的处理人员。切换工位时自动断开旧连接、建立新连接。
Q2: Web Serial API 与 PLC 塔灯控制
面试官可能问:"怎么通过浏览器控制硬件塔灯的?"
回答:
用 Web Serial API 实现浏览器与 PLC 控制塔灯的串口通信。
完整流程:
- 选择设备:
navigator.serial.requestPort()弹出浏览器串口设备选择器,用户选择"USB2.0-Serial"设备 - 打开串口:
serialPort.open({ baudRate: 9600 })以 9600 波特率打开 - 发送指令:通过
serialPort.writable.getWriter()获取写入器,发送十六进制指令数组
塔灯控制协议用了很简单的私有协议——每个指令是两位十六进制数:
- 红灯:
11亮 /41闪烁 /21灭 - 黄灯:
12亮 /42闪烁 /22灭 - 绿灯:
14亮 /44闪烁 /24灭 - 蜂鸣器:
18响 /48循环响 /28停 - 全关:
00
第一位表示操作类型(1=亮、4=闪烁、2=灭),第二位表示目标(1=红、2=黄、4=绿、8=蜂鸣器)。
两种触发方式:
- 服务端触发:后端通过 WebSocket 发
{ type: "towerLight", msg: "11,12" }→ 前端解析后自动调sendMessageToSerialPort - 手动触发:设置页的串口调试面板,选择预设指令组合,一键发送
容错处理:发送前做三层检查——是否有选择串口设备、串口是否已打开、writable 是否可用。每层失败都给对应提示并引导用户去设置页操作,失败后提示框支持"不再提示"。
追问:"为什么用 Web Serial API 而不是 Node.js 的 serialport 库?"
这是纯前端 Vue3 SPA,没有 Electron 主进程。Web Serial API 是浏览器原生 API,Chrome 89+ 就支持,不需要任何 Native 依赖。而且工厂平板用的就是 Chrome 浏览器,兼容性没问题。如果需要更高的可靠性,可以后续考虑 Electron 方案,但当时 Web Serial API 已经满足需求了。
Q3: 多摄像头管理与拍照上传
面试官可能问:"多摄像头是怎么管理的?拍照后怎么上传?"
回答:
用 MediaDevices API 实现多摄像头管理与实时预览。
摄像头管理:
navigator.mediaDevices.enumerateDevices()枚举所有视频输入设备 → 过滤出kind === 'videoinput'的摄像头列表- 以 Radio Button 形式展示,用户可随时切换摄像头
- 切换时先
stop()关闭当前流,再用新deviceId重新调用getUserMedia - 支持刷新按钮重新枚举(用户插拔摄像头后)
实时预览 + 拍照:
navigator.mediaDevices.getUserMedia({ video: { deviceId: xxx } })获取摄像头流 → 绑定到<video>元素 →video.play()实时预览- 拍照时:Canvas
drawImage(video, 0, 0)把当前帧画到 Canvas →canvas.toDataURL("image/png")展示预览 →canvas.toBlob()获取二进制数据 - 确认上传:
FormData包装 Blob →axios.post("/andon/request/image/upload")上传 → 后端返回图片 URL
用户体验细节:
- 加载摄像头时显示 Loading 遮罩:"正在加载摄像头..."
- 摄像头开启失败时显示空状态(Empty 组件)+ 错误原因 + "点我刷新"按钮
- 错误原因分类:设备未找到、权限被拒绝、其他异常,每种给不同提示
- 拍照后按钮变"重拍"+"确认上传"双按钮,重拍重新打开摄像头流
- 组件暴露
openCamera和stop方法给父组件调用
追问:"拍照后为什么用 Canvas 中转而不是直接用 video 截图?"
Canvas 是获取视频帧的标准方式。drawImage 能精确控制截取的尺寸和位置(这里用 videoWidth/videoHeight 保持原始分辨率)。而且 toBlob 可以指定压缩格式和质量,比直接截取 video 帧更灵活。后续如果要加裁剪、标注功能,Canvas 也天然支持。
Q4: 动态二维码与微信扫码绑定
面试官可能问:"微信扫码绑定是怎么实现的?二维码怎么生成的?"
回答:
用 qrcodejs2-fix 库在浏览器端生成动态二维码,配合微信 OAuth 实现扫码绑定。
生成流程:
- 从 sessionStorage 取当前登录用户信息(工位名称、工位编号、人员)
- 拼接微信 OAuth 授权 URL:
https://open.weixin.qq.com/connect/oauth2/authorize ?appid={wechatAppId} &redirect_uri={baseURL}#/wechat?stationName=xxx&stationNo=xxx&personnel=xxx &response_type=code &scope=snsapi_userinfo &state=STATE#wechat_redirect - 调用
new QRCode(element, { text: url, width: 300, height: 300 })生成二维码 - Canvas 转
toDataURL→ 用<el-image>展示,支持点击放大
绑定流程:
- 用户微信扫码 → 微信服务器回调带
code参数重定向到/wechat路由 - 前端从 URL 取
code+ 路由 query 中的工位信息 → 展示绑定表单(昵称、真实姓名、手机号等) - 填写后调
/andon/wechat/bind接口完成绑定 - 绑定后用户就能在微信上收到安灯请求的推送通知
追问:"二维码的 redirect_uri 为什么拼了 Hash 路由参数?"
项目用的是 createWebHashHistory,路由在 # 后面。微信 OAuth 的 redirect_uri 需要完整的目标地址,但微信服务器只认 ? 之前的 path,# 之后的部分会被忽略。所以把工位参数放在 ? query 里,路由信息放在 # 后面,两边都不丢。二维码 URL 会随着当前登录的工位动态变化。
追问:"原来绑定要 5 分钟,怎么缩短到 20 秒的?"
以前是人工登记——工人在纸质表上填微信、工位等信息,管理员手动录入系统。现在工人扫码 → 微信自动带出 openid → 表单预填工位信息 → 补充手机号姓名 → 确认,整个过程 20 秒内完成。
Q5: 工业平板的屏幕适配方案
面试官可能问:"工业平板分辨率五花八门,怎么适配的?"
回答:
用 v-scale-screen 实现等比缩放适配,固定设计尺寸 1080×1920(竖屏工业平板)。
核心原理:v-scale-screen 监听容器的 resize 事件,根据实际分辨率与设计尺寸算出缩放比例,用 CSS transform: scale() 等比缩放整个应用。不管平板是 1280×800 还是 1920×1080,界面都等比例展示,不会出现布局错乱。
移动端特殊处理:移动端点击输入框时会弹出软键盘,页面被顶上去。我在 App.vue 里做了处理:
function handleScreen() {
const { clientWidth, clientHeight } = document.documentElement;
setTimeout(() => {
const vScreenBox = document.querySelector(".v-screen-box");
vScreenBox.style.minWidth = `${clientWidth}px`;
vScreenBox.style.minHeight = `${clientHeight}px`;
}, 300);
}给 v-screen-box 设置 minWidth/minHeight 为实际屏幕尺寸,防止键盘弹出时页面容器被挤压变小导致缩放异常。移动端不监听 resize(键盘弹出会触发 resize),只在 DOM 加载完成后设置一次。
追问:"为什么选 1080×1920 而不是 1920×1080?"
工业平板多数是竖屏使用,工人站在产线旁操作,竖屏更符合人体工学。而且安灯类型的按钮是纵向排列的,竖屏一行能展示更多选项。
Q6: Pinia WebSocket Store 设计
面试官可能问:"Pinia Store 里管了 WebSocket、串口、消息路由,怎么组织的?"
回答:
WebSocket Store 是整个应用的状态中心,用 Pinia 的 Composition API 风格(defineStore + setup 函数)组织,把所有与通信相关的状态和逻辑集中管理。
状态管理:
ws:WebSocket 实例,ref(null)存储serialPort:串口设备对象,ref(null)存储flags:一组控制标志(refreshRequestMap触发请求列表刷新、openSerialPortDevTools触发串口调试面板打开、webSocketUserIdChanged标记用户切换、isUnloading标记页面卸载中等)interval/timeout:心跳定时器和重连定时器的引用,用于清理
核心方法:
webSocketConnect(id):建立连接,判断 ID 是否切换决定是否断开旧连接sendMessageToSerialPort(msg, source):向串口发送十六进制指令,区分来源(ws服务端触发 vsweb手动触发),不同来源给不同提示openSerialPortConnection()/closeSerialPortConnection():打开/关闭串口,带详细错误处理startSerialPortConnection():弹出浏览器设备选择器,用户选择 USB 串口设备
消息路由:onmessage 根据 type 分发——incomingRequest / pendingRequest 刷新列表或弹窗引导跳转;towerLight 解析指令转为十六进制数组发给串口;serialPort 处理串口状态反馈;close 处理被挤下线。
追问:"flags 为什么用 ref 包一个对象而不是多个独立的 ref?"
因为需要用 storeToRefs 解构后在组件里用 watch 监听变化。如果 flags 的每个属性都是独立 ref,解构后 watch 写起来会很繁琐。包在一个对象里,watch flags.value.refreshRequestMap 就可以精确监听单个标志位变化。
追问:"用什么做页面卸载时的清理?"
window.onbeforeunload 里做了:关闭 WebSocket(ws.close())、清除心跳定时器(clearInterval(interval))、清除重连定时器(clearTimeout(timeout))。设置 isUnloading = true 防止 onclose 回调里的重连逻辑触发。
Q7: 多环境 API 配置架构
面试官可能问:"API 地址怎么管理开发和线上不同环境?"
回答:
用 process.env.NODE_ENV 做环境判断,通过工厂函数动态返回不同环境的 API 地址。
在 src/api/utils.ts 里封装了四个工厂函数:
baseURLApi():REST API 基地址(用于 Axios 实例的 baseURL)wsApi():WebSocket 地址baseURL():前端静态页面基地址(用于二维码 URL 拼接)wechatAppId():微信 AppID(开发/正式环境不同)
每个函数内部用 switch-case 匹配 development / test / production / demo / trial 五种环境,返回对应地址。比如开发环境 API 走 http://192.168.2.109:8152/andon-test-api/,线上走 https://emi.goomaker.com/andon-test-api/。
Axios 封装:创建实例时设 baseURL: baseURLApi() + timeout: 60000。请求/响应拦截器目前只做透传,但预留了扩展点——后续可以加 Token 注入、401 自动刷新等。
追问:"API 和前端页面为什么用不同的 baseURL?"
因为部署架构不同。前端是纯静态文件部署到 /andon-system/ 路径下,API 是独立的后端服务部署到 /andon-test-api/。而且开发环境 API 可能在本机或局域网其他机器上(192.168.2.109),前端页面跑在 localhost:8888,必须分开配置。通过 ip.json 文件统一管理局域网 IP 地址,方便团队协作时切换。
追问:"ip.json 是干什么的?"
团队开发时,后端 API 可能跑在不同同事的电脑上。把 IP 地址抽到 ip.json 里(gitignore 掉),每个人配置自己的 { "local": "192.168.x.x" },不用每次改代码。
Q8: 自定义 MediaViewer 组件
面试官可能问:"图片/视频预览组件是怎么设计的?"
回答:
基于 Element Plus 的 el-image-viewer 源码做了改造,自研了一个支持图片+视频混合预览的 MediaViewer 组件。
为什么不用 el-image-viewer:el-image-viewer 只支持图片,但安灯类型的"类型说明"需要支持视频上传和预览。另外 el-image-viewer 的 z-index 和 DOM 挂载位置不够灵活。
主要改造:
- 图片+视频混播:
urlList里的每项带type字段(image/video),根据当前项的 type 切换<img>和<video>标签,用v-show控制显示 - 视频自动播放:video 加了
autoplay、loop、muted属性,切换到视频项时自动循环静音播放 - 键盘 + 滚轮交互:键盘
← →切换前后项,↑ ↓缩放,Esc关闭,Space切换 contain/original 模式。鼠标滚轮缩放(用requestAnimationFrame节流防抖) - 拖拽移动:
mousedown→mousemove→mouseup,图片放大后可以拖拽查看不同区域 - Portal 挂载:用 Vue
<teleport to="body">把组件挂到 body 下,z-index 设 9999,不受父容器 overflow/z-index 影响
追问:"为什么不用现成的 viewer.js 或 photoswipe?"
这两个都不支持视频。而且工业平板环境对包体积有要求,viewer.js 有 jQuery 依赖,photoswipe 比较重。自研组件只用了原生的 Canvas、video、鼠标事件,零额外依赖,体积可控。
Q9: SOP 模板库与拖拽排序
面试官可能问:"SOP 模板库和安灯类型的拖拽排序是怎么实现的?"
回答:
SOP 模板库:用于管理多套安灯类型方案。比如"织造车间模板"包含"断纱/缺纬/停台/质量异常"四种类型,"印染车间模板"包含"色差/缸差/色渍/沾色"。可以在后台切换模板,前端的安灯类型按钮会跟着变。
数据结构是树形的——templateTableData 每一项有 children 数组,子项对应类型列表。用 Element Plus 的 el-table 的 tree-props 属性实现树形表格展示。
拖拽排序:用 SortableJS 实现安灯类型列表的行拖拽排序。
关键代码:
Sortable.create(typeTable, {
animation: 150,
handle: ".type-table-handle",
onEnd(event) {
editAndonType({
id: typeTableData[event.oldIndex].id,
oldIndex: event.oldIndex,
newIndex: event.newIndex,
});
},
});拖拽手柄是排序列的旋转图标,只允许通过手柄拖拽(handle 配置)。拖拽结束后调后端接口更新 oldIndex → newIndex 的顺序。和 v-scale-screen 的冲突解决:SortableJS 的 ghost 元素在 CSS transform 环境下定位会异常,通过全局 CSS 覆盖 sortable-chosen/ghost 的 position: relative !important 解决。
追问:"排序为什么不用 Element Plus 自带的拖拽?"
Element Plus 表格没有内置行拖拽功能,只有列拖拽。SortableJS 是这方面的标准方案,API 简单、动画流畅、兼容性好。而且它还支持 disabled 状态——当表格中有行处于编辑状态时,通过 CSS pointer-events-none 禁用拖拽,防止编辑过程中误拖。
行为面试常见题快速答
"为什么做这个项目?"
公司做纺织工厂的智能制造改造。传统的安灯(Andon)系统靠物理按钮+对讲机——产线工人遇到问题时按物理按钮,用对讲机喊人。响应慢、没记录、无法追溯。老板提需求要做一套数字化的安灯系统,工人点平板就能发起请求,处理人员实时收到通知,所有请求有记录可查。我从零搭建前端,独立开发完成。
"遇到的最大难点?"
Web Serial API 调试。浏览器控制硬件串口是一个比较小众的场景——网上参考资料很少,错误提示也不够友好。比如权限被拒绝、设备被占用、波特率不匹配……每种错误的表现和解决方案都不一样。花了很多时间在产线上反复测试,把各种异常情况枚举出来,写了详细的错误分类处理和用户引导。还有 v-scale-screen 和 SortableJS 的 CSS transform 冲突,调试了几个小时才发现是 ghost 元素的 position 问题。
"有什么遗憾?"
- 没有写单元测试——WebSocket 和 Web Serial API 的测试需要 Mock 环境,当时没搭。
- 浏览器强依赖——Web Serial API 只有 Chrome 支持,如果工厂换了其他浏览器就没法控制塔灯。后续如果有时间会考虑迁移到 Electron 方案。
- 消息推送目前只支持微信,但微信模板消息有频率限制。如果产线并发量大,可能有延迟。后续应该加一个页面内语音播报的兜底方案。
"跟后端怎么协作的?"
后端是 Java 写的。我在开发前先和后端对齐了 WebSocket 消息格式({ type, msg } 结构)和所有 REST API 的接口定义(请求参数、响应格式)。开发时用本地 Mock 数据 + Vite 代理调试,后端接口好了再联调。
"为什么叫'安灯系统'?"
Andon(安灯)来自丰田生产方式——产线上方装一排灯,不同颜色代表不同状态。工人遇到问题拉下灯绳,灯亮起,班组长看到就过来处理。我们这个系统做的就是把物理安灯数字化,把"拉绳"变成"点击平板"。
上一篇:04-惠东移动(微信小程序) 下一篇:06-EMI数字孪生平台