Skip to content

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 控制塔灯的串口通信。

完整流程

  1. 选择设备navigator.serial.requestPort() 弹出浏览器串口设备选择器,用户选择"USB2.0-Serial"设备
  2. 打开串口serialPort.open({ baudRate: 9600 }) 以 9600 波特率打开
  3. 发送指令:通过 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 组件)+ 错误原因 + "点我刷新"按钮
  • 错误原因分类:设备未找到、权限被拒绝、其他异常,每种给不同提示
  • 拍照后按钮变"重拍"+"确认上传"双按钮,重拍重新打开摄像头流
  • 组件暴露 openCamerastop 方法给父组件调用

追问:"拍照后为什么用 Canvas 中转而不是直接用 video 截图?"

Canvas 是获取视频帧的标准方式。drawImage 能精确控制截取的尺寸和位置(这里用 videoWidth/videoHeight 保持原始分辨率)。而且 toBlob 可以指定压缩格式和质量,比直接截取 video 帧更灵活。后续如果要加裁剪、标注功能,Canvas 也天然支持。

Q4: 动态二维码与微信扫码绑定

面试官可能问:"微信扫码绑定是怎么实现的?二维码怎么生成的?"

回答

用 qrcodejs2-fix 库在浏览器端生成动态二维码,配合微信 OAuth 实现扫码绑定。

生成流程

  1. 从 sessionStorage 取当前登录用户信息(工位名称、工位编号、人员)
  2. 拼接微信 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
  3. 调用 new QRCode(element, { text: url, width: 300, height: 300 }) 生成二维码
  4. 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 里做了处理:

typescript
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 服务端触发 vs web 手动触发),不同来源给不同提示
  • 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 挂载位置不够灵活。

主要改造

  1. 图片+视频混播urlList 里的每项带 type 字段(image / video),根据当前项的 type 切换 <img><video> 标签,用 v-show 控制显示
  2. 视频自动播放:video 加了 autoplayloopmuted 属性,切换到视频项时自动循环静音播放
  3. 键盘 + 滚轮交互:键盘 ← → 切换前后项,↑ ↓ 缩放,Esc 关闭,Space 切换 contain/original 模式。鼠标滚轮缩放(用 requestAnimationFrame 节流防抖)
  4. 拖拽移动mousedownmousemovemouseup,图片放大后可以拖拽查看不同区域
  5. 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-tabletree-props 属性实现树形表格展示。

拖拽排序:用 SortableJS 实现安灯类型列表的行拖拽排序。

关键代码:

javascript
Sortable.create(typeTable, {
  animation: 150,
  handle: ".type-table-handle",
  onEnd(event) {
    editAndonType({
      id: typeTableData[event.oldIndex].id,
      oldIndex: event.oldIndex,
      newIndex: event.newIndex,
    });
  },
});

拖拽手柄是排序列的旋转图标,只允许通过手柄拖拽(handle 配置)。拖拽结束后调后端接口更新 oldIndexnewIndex 的顺序。和 v-scale-screen 的冲突解决:SortableJS 的 ghost 元素在 CSS transform 环境下定位会异常,通过全局 CSS 覆盖 sortable-chosen/ghostposition: 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 问题。

"有什么遗憾?"

  1. 没有写单元测试——WebSocket 和 Web Serial API 的测试需要 Mock 环境,当时没搭。
  2. 浏览器强依赖——Web Serial API 只有 Chrome 支持,如果工厂换了其他浏览器就没法控制塔灯。后续如果有时间会考虑迁移到 Electron 方案。
  3. 消息推送目前只支持微信,但微信模板消息有频率限制。如果产线并发量大,可能有延迟。后续应该加一个页面内语音播报的兜底方案。

"跟后端怎么协作的?"

后端是 Java 写的。我在开发前先和后端对齐了 WebSocket 消息格式({ type, msg } 结构)和所有 REST API 的接口定义(请求参数、响应格式)。开发时用本地 Mock 数据 + Vite 代理调试,后端接口好了再联调。

"为什么叫'安灯系统'?"

Andon(安灯)来自丰田生产方式——产线上方装一排灯,不同颜色代表不同状态。工人遇到问题拉下灯绳,灯亮起,班组长看到就过来处理。我们这个系统做的就是把物理安灯数字化,把"拉绳"变成"点击平板"。

上一篇:04-惠东移动(微信小程序) 下一篇:06-EMI数字孪生平台

前端开发工程师 · 面试准备