模拟障碍物 继续作业 连续作业 初始化

This commit is contained in:
mmc
2026-11-04 09:59:18 +08:00
parent 36b6856a90
commit 48a7537d24
15 changed files with 1355 additions and 240 deletions

View File

@@ -0,0 +1,19 @@
# 2026-11-03
## 软著用户手册生成(机器人智慧服务系统)
- 参考 `C:\Users\15563\Desktop\软著\新建文件夹\用户手册-光伏机器人环境感知服务平台.docx` 的版式(封面居中标题/版本行、heading1=styleId 3、heading2=styleId 4、List Bullet=styleId 16、图注居中灰 10pt、正文微软雅黑/宋体 11pt、Letter 页面、上下 2cm 左右 2.54cm、页眉含页码+系统名)。
- 生成脚本:`scripts/build_manual.py`——克隆参考 docx 包,重写 word/document.xml + document.xml.rels,替换 6 张截图到 word/media/manualN.png,页眉旧平台名替换为「机器人智慧服务系统」。
- 截图:用 Playwright(channel=msedge) 登录本地 dev(vite 3001,后端 1.95.137.212:59003,账号 admin),截 login + 装备总览/机器人任务/机器人控制/地图编辑/告警中心,存于 `shots/`。
- 产物:`C:\Users\15563\Desktop\软著\新建文件夹\用户操作手册-机器人智慧服务系统.docx`(8 章 / 34 小节 / 6 图 / 7.4MB)。
- 坑:editor_sdk 打开该 docx 后 doc_get_document_content 报 "document is not open",改用 python zipfile 直读 XML 提取样式;脚本删除输出文件触发 safe-delete 钩子,改用 zipfile 'w' 直接覆盖。
- 注意:截图顶栏系统名来自后端 systemName 配置,当前仍显示「光伏机器人环境感知服务平台」,如需一致可在系统设置-基础设置改 systemName。
## 手册扩充(详版)
- 按用户要求把文字/功能描述大幅扩写:8 章 / 44 小节 / 72 条项目符号 / 16 张说明表格 / 1.27 万字(首版约 0.6 万字)。
- 新增内容:系统特点、界面通用交互约定、态势图图层表、设备控制按钮表、AI 指标表、任务统计卡表、任务状态表、
设备状态参数表(含单位)、手柄按键映射表、功能控制表、地图模式表、路径字段表、告警字段表、处理结果表、
告警级别表、常见问题排查表,以及典型操作流程、控制权限与安全机制、任务执行监控与异常处理等小节。
- 坑:docx 被宿主预览占用时 Python 写入报 PermissionError(读可、写不可,os.replace 也失败)。
已在 build_manual.py 内做「先写 .building 临时文件 → os.replace 重试 10 次 → 失败则另存 -完整版」的兜底。
本次因此产出 `用户操作手册-机器人智慧服务系统-完整版.docx`,原短版仍被占用无法覆盖/重命名。

871
scripts/build_manual.py Normal file
View File

@@ -0,0 +1,871 @@
# -*- coding: utf-8 -*-
"""按参考手册样式生成《机器人智慧服务系统 用户操作手册》docx(详版)"""
import zipfile, re, os, struct
from xml.sax.saxutils import escape
REF = r"C:/Users/15563/Desktop/软著/新建文件夹/用户手册-光伏机器人环境感知服务平台.docx"
OUT = r"C:/Users/15563/Desktop/软著/新建文件夹/用户操作手册-机器人智慧服务系统.docx"
SHOTS = r"D:/code/机器人智慧服务系统/shots"
W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'
# ---------- 通用 XML 片段(与参考文档逐字对齐) ----------
PPR = ('<w:pageBreakBefore w:val="0"/><w:widowControl/><w:kinsoku/><w:wordWrap/>'
'<w:overflowPunct/><w:topLinePunct w:val="0"/><w:autoSpaceDE/><w:autoSpaceDN/>'
'<w:bidi w:val="0"/><w:adjustRightInd/><w:snapToGrid/>'
'<w:spacing w:after="0"/><w:textAlignment w:val="auto"/>')
PPR_LINE = PPR.replace('<w:spacing w:after="0"/>',
'<w:spacing w:after="0" w:line="324" w:lineRule="auto"/>')
EMPTY_P = '<w:p><w:pPr>%s</w:pPr></w:p>' % PPR
def runs(segs):
if isinstance(segs, str):
segs = [(segs, False)]
out = []
for t, b in segs:
rpr = '<w:rPr><w:b/></w:rPr>' if b else ''
out.append('<w:r>%s<w:t xml:space="preserve">%s</w:t></w:r>' % (rpr, escape(t)))
return ''.join(out)
def p_title(text):
return ('<w:p><w:pPr>%s<w:jc w:val="center"/></w:pPr>'
'<w:r><w:rPr><w:b/><w:color w:val="auto"/><w:sz w:val="56"/></w:rPr>'
'<w:t>%s</w:t></w:r></w:p>') % (PPR, escape(text))
def p_subtitle(text):
return ('<w:p><w:pPr>%s<w:jc w:val="center"/></w:pPr>'
'<w:r><w:rPr><w:color w:val="505050"/><w:sz w:val="40"/></w:rPr>'
'<w:t>%s</w:t></w:r></w:p>') % (PPR, escape(text))
def p_version(text):
return ('<w:p><w:pPr>%s<w:jc w:val="center"/></w:pPr>'
'<w:r><w:rPr><w:color w:val="787878"/><w:sz w:val="24"/></w:rPr>'
'<w:t>%s</w:t></w:r></w:p>') % (PPR, escape(text))
def p_break():
return '<w:p><w:pPr>%s</w:pPr><w:r><w:br w:type="page"/></w:r></w:p>' % PPR
def p_h1(text):
return ('<w:p><w:pPr><w:pStyle w:val="3"/>%s</w:pPr>'
'<w:r><w:t>%s</w:t></w:r></w:p>') % (
PPR.replace('<w:spacing w:after="0"/>', '<w:spacing w:before="0" w:after="0"/>'), escape(text))
def p_h2(text):
return ('<w:p><w:pPr><w:pStyle w:val="4"/>%s</w:pPr>'
'<w:r><w:t>%s</w:t></w:r></w:p>') % (
PPR.replace('<w:spacing w:after="0"/>', '<w:spacing w:before="0" w:after="0"/>'), escape(text))
def p_body(segs):
return '<w:p><w:pPr>%s</w:pPr>%s</w:p>' % (PPR_LINE, runs(segs))
def p_caption(text):
return ('<w:p><w:pPr>%s<w:jc w:val="center"/>'
'<w:rPr><w:i w:val="0"/><w:iCs/></w:rPr></w:pPr>'
'<w:r><w:rPr><w:i w:val="0"/><w:iCs/><w:color w:val="646464"/>'
'<w:sz w:val="20"/></w:rPr><w:t>%s</w:t></w:r></w:p>') % (PPR, escape(text))
def png_size(path):
with open(path, 'rb') as f:
head = f.read(24)
return struct.unpack('>II', head[16:24])
def p_image(rid, path, doc_id):
w, h = png_size(path)
cx = 5943600
cy = int(cx * h / w)
return ('<w:p><w:pPr>%s<w:jc w:val="center"/></w:pPr><w:r><w:drawing>'
'<wp:inline distT="0" distB="0" distL="114300" distR="114300">'
'<wp:extent cx="%d" cy="%d"/><wp:effectExtent l="0" t="0" r="9525" b="0"/>'
'<wp:docPr id="%d" name="图片 %d"/>'
'<wp:cNvGraphicFramePr><a:graphicFrameLocks '
'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" noChangeAspect="1"/>'
'</wp:cNvGraphicFramePr>'
'<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
'<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">'
'<pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">'
'<pic:nvPicPr><pic:cNvPr id="%d" name="图片 %d"/>'
'<pic:cNvPicPr><a:picLocks noChangeAspect="1"/></pic:cNvPicPr></pic:nvPicPr>'
'<pic:blipFill><a:blip r:embed="%s"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>'
'<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="%d" cy="%d"/></a:xfrm>'
'<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/>'
'<a:ln><a:noFill/></a:ln></pic:spPr></pic:pic></a:graphicData></a:graphic>'
'</wp:inline></w:drawing></w:r></w:p>'
) % (PPR, cx, cy, doc_id, doc_id, doc_id, doc_id, rid, cx, cy)
TBL_BORDER = ('<w:tblBorders><w:top w:val="single" w:color="4F81BD" w:themeColor="accent1" w:sz="8" w:space="0"/>'
'<w:left w:val="single" w:color="4F81BD" w:themeColor="accent1" w:sz="8" w:space="0"/>'
'<w:bottom w:val="single" w:color="4F81BD" w:themeColor="accent1" w:sz="8" w:space="0"/>'
'<w:right w:val="single" w:color="4F81BD" w:themeColor="accent1" w:sz="8" w:space="0"/>'
'<w:insideH w:val="single" w:color="4F81BD" w:themeColor="accent1" w:sz="8" w:space="0"/>'
'<w:insideV w:val="single" w:color="4F81BD" w:themeColor="accent1" w:sz="8" w:space="0"/></w:tblBorders>')
TBL_CELLMAR = ('<w:tblCellMar><w:top w:w="0" w:type="dxa"/><w:left w:w="108" w:type="dxa"/>'
'<w:bottom w:w="0" w:type="dxa"/><w:right w:w="108" w:type="dxa"/></w:tblCellMar>')
CELL_PPR = ('<w:pPr><w:pageBreakBefore w:val="0"/><w:widowControl/><w:kinsoku/><w:wordWrap/>'
'<w:overflowPunct/><w:topLinePunct w:val="0"/><w:autoSpaceDE/><w:autoSpaceDN/>'
'<w:bidi w:val="0"/><w:adjustRightInd/><w:snapToGrid/>'
'<w:spacing w:after="0" w:line="240" w:lineRule="auto"/><w:textAlignment w:val="auto"/></w:pPr>')
def table(rows):
n = len(rows[0])
total = 9360
colw = total // n
grid = ''.join('<w:gridCol w:w="%d"/>' % colw for _ in range(n))
xml = ['<w:tbl><w:tblPr><w:tblStyle w:val="49"/><w:tblW w:w="0" w:type="auto"/>'
'<w:jc w:val="center"/>' + TBL_BORDER +
'<w:tblLayout w:type="autofit"/>' + TBL_CELLMAR + '</w:tblPr>',
'<w:tblGrid>%s</w:tblGrid>' % grid]
for ri, row in enumerate(rows):
cells = []
for val in row:
rpr = '<w:rPr><w:b/></w:rPr>' if ri == 0 else ''
cells.append('<w:tc><w:tcPr><w:tcW w:w="%d" w:type="dxa"/></w:tcPr>'
'<w:p>%s<w:r>%s<w:t xml:space="preserve">%s</w:t></w:r></w:p></w:tc>'
% (colw, CELL_PPR, rpr, escape(val)))
xml.append('<w:tr><w:tblPrEx>' + TBL_BORDER + TBL_CELLMAR + '</w:tblPrEx>'
'<w:trPr><w:jc w:val="center"/></w:trPr>' + ''.join(cells) + '</w:tr>')
xml.append('</w:tbl>')
return ''.join(xml)
# ---------- 内容辅助 ----------
B = True
imgs = []
doc_id = [100]
def seg(*parts):
out = []
def push(x):
if isinstance(x, bool):
if out:
out[-1] = (out[-1][0], x)
elif isinstance(x, str):
out.append((x, False))
elif isinstance(x, tuple):
out.append((x[0], bool(x[1])))
elif isinstance(x, list):
for y in x:
push(y)
for p in parts:
push(p)
return out
def p_bullet(*segs):
return ('<w:p><w:pPr><w:pStyle w:val="16"/>%s</w:pPr>%s</w:p>') % (PPR, runs(seg(*segs)))
content = []
add = content.append
def img_paragraph(path, rid):
doc_id[0] += 1
imgs.append(path)
return p_image(rid, path, doc_id[0])
def bullets(items):
"""items 为列表:若首元素是 seg(...) 返回的分段列表,则整条合并为一个项目符号;
否则列表中每个元素各自成为一个项目符号。"""
if items and isinstance(items[0], list):
add(p_bullet(items))
else:
for it in items:
add(p_bullet(it))
# ================= 封面 =================
for _ in range(6):
add(EMPTY_P)
add(p_title('机器人智慧服务系统'))
add(p_subtitle('用户操作手册'))
add(EMPTY_P)
add(p_version('版本 V1.0   2026年11月'))
add(EMPTY_P)
add(p_break())
# ================= 一、系统概述 =================
add(p_h1('一、系统概述'))
add(p_body('机器人智慧服务系统是一个面向光伏电站场景的综合性智能运维平台。系统将巡检机器人、除草机器人、'
'无人机机巢、清洗无人机、视频监控、告警处理与运维工单整合到统一的 Web 控制台中,'
'为运维人员提供装备总览、机器人控制、机器人任务、地图编辑、实时监控、告警管理、AI 诊断分析、'
'清洗优化、报表中心与系统设置等一站式能力。'))
add(p_body('系统以"一张图指挥、一套流程闭环"为设计思路:以三维电子地图为统一空间底座,'
'把场站内所有可动作的装备集中呈现在同一张态势图上;以任务为业务主线,'
'把路径规划、任务下发、执行监控与结果统计串联成完整流程;以告警为运维触发点,'
'可通过一键转工单把异常处置纳入闭环管理。运维人员无需在多个系统之间来回切换,'
'即可完成从"发现异常"到"派发任务"再到"处置验收"的全过程。'))
add(p_h2('1.1 系统架构'))
add(p_body('系统采用前后端分离的 B/S 架构,前端基于 React 19 + TypeScript + Vite 6 构建为单页应用(SPA),'
'通过多条独立通信通道对接后端服务,各通道职责清晰、互不阻塞:'))
add(p_bullet(seg('HTTP / REST:', B), ('Axios 统一实例,负责用户认证、设备管理、任务下发、地图要素维护、'
'告警、工单、报表等业务接口的请求与响应,统一处理令牌携带、超时与错误提示', False)))
add(p_bullet(seg('MQTT over WebSocket:', B), ('设备遥测数据通道,上行推送机器人实时位置、电量、速度、'
'电机状态与传感器数据,前端订阅后驱动地图图标与状态面板实时刷新', False)))
add(p_bullet(seg('WebSocket:', B), ('设备控制通道,负责下发行走与执行机构指令、申请与释放控制权、'
'控制会话保活与冲突通知,是遥控操作的低延迟链路', False)))
add(p_bullet(seg('WebRTC:', B), ('视频回传通道,拉取机器人/无人机机载摄像头实时画面,支持多视角切换', False)))
add(p_bullet(seg('Cesium + GeoServer:', B), ('三维 GIS 底图与场站正射影像渲染,承载设备位置、作业路径、'
'分区与障碍物等空间要素的绘制与交互', False)))
add(p_body('一次完整的"远程操控"数据流为:操作员在控制页发起控制权申请 → WebSocket 上行申请帧 → '
'服务端校验权限与占用状态后下发授权 → 操作员操作方向盘或手柄 → 前端以 100 毫秒为周期打包按钮与摇杆数据帧'
' → 服务端转发至设备 → 设备回传遥测与视频 → 前端刷新状态面板与地图,形成"下发—执行—反馈"的实时闭环。'))
add(p_h2('1.2 技术栈'))
add(table([
['层级', '技术选型'],
['前端框架', 'React 19 + TypeScript'],
['构建工具', 'Vite 6'],
['UI 组件库', 'Ant Design 5 + Tailwind CSS'],
['三维地图引擎', 'Cesium + GeoServer 影像服务'],
['图表组件', 'ECharts / AntV Charts'],
['实时通信', 'MQTT 5(遥测)、WebSocket(控制)、WebRTC(视频)'],
['状态管理', 'Redux Toolkit'],
['国际化', 'i18next(中 / 英双语)'],
]))
add(p_h2('1.3 角色权限'))
add(p_body('系统支持三级角色权限控制,权限差异同时体现在"菜单是否可见"和"按钮是否可操作"两个层面:'))
add(p_bullet(seg('管理员:', B), ('拥有全部功能权限,可进行系统设置、用户管理、角色管理、菜单配置、组织与场站管理、'
'设备接入与系统维护,并可在控制会话冲突时强制抢占设备控制权', False)))
add(p_bullet(seg('操作员:', B), ('可进行设备控制、任务下发、地图要素维护、告警确认与处理、工单处理等日常业务操作,'
'控制会话被占用时需等待原操作员释放', False)))
add(p_bullet(seg('观察者:', B), ('只读权限,仅可查看总览、实时监控、告警等页面,不可操作设备、不可下发任务、'
'不可修改任何数据', False)))
add(p_body('除角色权限外,系统还支持按组织与场站限定数据可见范围:用户只能看到其所属场站下的设备、任务与告警数据,'
'跨场站数据在列表中不呈现。'))
add(p_h2('1.4 系统特点'))
bullets([
seg('一张图指挥:', B), ('所有装备、航线、分区、障碍物与实时位置集中在同一张三维态势图上,支持图层开关与一键定位', False),
])
bullets([
seg('任务全流程:', B), ('从地图要素配置、路线/区域编排、立即或定时下发,到执行监控与历史回溯,全链路可追溯', False),
])
bullets([
seg('双模遥控:', B), ('同时支持游戏手柄与屏幕虚拟方向盘两种操控方式,覆盖精细作业与快速接管两类场景', False),
])
bullets([
seg('安全优先:', B), ('提供急停、避障、低电量自动返航、倾斜保护等多重保护机制,控制权申请与释放过程全程可见', False),
])
bullets([
seg('闭环运维:', B), ('告警可一键生成工单,工单处理结果回写告警,形成"告警—工单—验收"的完整闭环', False),
])
# ================= 二、登录与系统导航 =================
add(p_h1('二、登录与系统导航'))
add(img_paragraph(SHOTS + '/00-login.png', 'rIdImg1'))
add(p_caption('图 2-1 系统登录界面'))
add(p_body('登录页左侧为账号密码登录表单,右侧为产品形象图。系统未登录状态下访问任意业务页面,'
'均会被重定向至本页。'))
add(p_h2('2.1 登录操作'))
add(p_body('打开系统后进入登录页面,操作步骤如下:'))
bullets([
('在"账号"输入框中输入用户名;', False),
])
bullets([
('在"密码"输入框中输入登录密码,密码以掩码形式显示;', False),
])
bullets([
('勾选"我已阅读并同意用户协议和隐私政策"复选框;', False),
])
bullets([
('点击蓝色"登录"按钮提交。', False),
])
add(p_body('登录成功后,系统自动跳转至首页总览页面,并按当前用户的角色加载对应的侧边栏菜单。'
'若账号或密码有误、账号被停用或未勾选协议,页面会在表单上方给出对应提示,'
'修正后可再次提交。登录态有效期由服务端控制;登录态超时或失效时,'
'系统会自动弹出提示并跳回登录页,重新登录后回到原页面。'))
add(p_h2('2.2 系统导航'))
add(p_body('登录后进入主界面。左侧为动态侧边栏菜单(按角色权限加载),顶部栏包含当前时间、场站选择下拉框、'
'全屏控制按钮、语言切换(中文 / English)、用户头像与退出登录入口。主要导航模块说明如下:'))
for name, desc in [
('总览首页', '聚合展示设备状态、告警、作业统计等概览数据'),
('装备控制', '装备总览、机器人控制、机器人任务、地图编辑等核心作业模块'),
('实时监控', '基于 Cesium 三维地图的设备轨迹与状态实时渲染'),
('告警中心', '告警查询、处理与统计分析'),
('任务监控', '任务执行过程的集中监控'),
('AI 诊断分析', '基于热成像等数据的 AI 分析与缺陷识别'),
('算法分析', '算法识别结果的查看与复核'),
('清洗优化', '光伏板清洗任务规划与优化'),
('视频监控', '多路无人机 / 机器人视频回传与在线查看'),
('工单任务', '运维工单的全生命周期管理'),
('报表中心', '运行数据统计报表的查看与导出'),
('系统设置', '基础设置、用户与角色、菜单、组织、场站、设备接入、系统维护、日志等'),
]:
add(p_bullet(seg(name + ':', B), (desc, False)))
add(p_h2('2.3 界面通用交互约定'))
add(p_body('为便于快速上手,系统中各业务页面遵循统一的交互约定:'))
bullets([
seg('筛选区:', B), ('位于列表上方,通常包含下拉框、输入框与时间范围选择器,右侧固定"重置""查询"按钮,'
'点击"查询"后按当前条件刷新列表', False),
])
bullets([
seg('列表区:', B), ('表头固定,行内提供操作按钮;部分列表采用滚动加载而不分页,数据较多时可直接滚动浏览', False),
])
bullets([
seg('详情查看:', B), ('轻量信息使用弹窗(Modal)展示,信息量大或需要填写表单的详情使用抽屉(Drawer)从右侧展开', False),
])
bullets([
seg('操作反馈:', B), ('保存、下发、删除等操作完成后,页面右上角会给出成功或失败的轻量提示;删除类操作均需二次确认', False),
])
bullets([
seg('状态标识:', B), ('在线状态、任务状态、告警级别统一用彩色标签表示,颜色含义在各页面保持一致', False),
])
# ================= 三、装备总览 =================
add(p_h1('三、装备总览'))
add(img_paragraph(SHOTS + '/01-装备总览.png', 'rIdImg2'))
add(p_caption('图 3-1 装备总览——装备协同态势图与设备控制面板'))
add(p_body('装备总览页面(装备协同态势图)是运维人员的日常指挥入口,采用"顶部统计 + 左侧态势与列表 + 右侧控制面板"'
'的多栏布局,把场站内无人机机巢、巡检机器人、除草机器人、清洗无人机的实时状态集中呈现,'
'并对当日任务执行情况与 AI 分析结论做汇总,便于快速判断"现在场站在发生什么、需不需要人介入"。'))
add(p_h2('3.1 状态统计卡'))
add(p_body('页面顶部排列五张统计卡,从左至右依次为:'))
bullets([
seg('无人机机场状态:', B), ('显示已接入机场数量与可调度架数,用于判断无人机是否具备起飞条件', False),
])
bullets([
seg('巡检机器人:', B), ('显示机器人总数与当前在线台数', False),
])
bullets([
seg('除草机器人:', B), ('显示设备数量与执行中的台数', False),
])
bullets([
seg('清洗无人机:', B), ('显示设备数量与待命架数', False),
])
bullets([
seg('今日任务执行:', B), ('显示今日已执行任务数与完成率', False),
])
add(p_body('所有统计数值均基于顶部场站选择框当前选中的场站进行过滤,切换场站后统计与下方列表同步刷新。'))
add(p_h2('3.2 装备协同态势图'))
add(p_body('态势图基于 Cesium 三维地图与场站正射影像渲染,把空间要素与装备位置叠加在同一视图中。'
'左上角为图层图例,可对照识别各类要素;右上角为视图设置与"定位到机器人"快捷按钮,'
'点击后可一键将视角聚焦至指定机器人,便于快速找到目标设备。'))
add(table([
['图层要素', '含义与用途'],
['巡检航线', '无人机巡检任务的预设飞行路线'],
['机器人路径', '地面机器人作业路径,含规划路线与实时轨迹'],
['清洗路径', '清洗作业的覆盖路径'],
['机器位置', '机器人实时位置图标,随遥测数据动态刷新'],
['无人机位置', '无人机实时位置图标'],
['补给 / 充电点', '充电桩与补给点位置,用于返航与补能决策'],
['障碍 / 禁入区', '障碍物与禁止进入区域,规划路径时自动避让'],
]))
add(p_h2('3.3 装备调度列表'))
add(p_body('态势图下方为装备调度列表,以表格形式汇总当前场站的所有装备,字段包括装备名称、类型、当前状态、'
'电量、位置、通讯状态与操作。电量以进度条加百分比显示,通讯状态用"良好 / 一般 / 离线"等标签区分,'
'可据此快速筛出需要关注的设备。'))
add(p_body('列表右上角提供刷新按钮,可手动获取最新状态。点击行内"编辑"打开发起编辑弹窗,'
'可修改设备 SN(支持一键复制)、设备类型与设备别名,其中设备别名必填且不超过 30 个字符;'
'点击齿轮图标可打开"设备参数设置"弹窗,对设备的运行参数进行配置。'))
add(p_h2('3.4 设备控制面板'))
add(p_body('右侧设备控制面板用于对单台装备进行快速操控。选择设备后,面板展示在线状态标签、电量、产品类型、'
'设备 SN、激活时间、固件版本与设备类型等基础信息,下方为操作按钮区:'))
add(table([
['按钮', '功能说明'],
['启动', '向设备下发启动指令,开始执行当前任务'],
['暂停', '暂停设备当前动作,设备保持就位'],
['返航', '下发返航指令,设备按预设返航点返回'],
['回充', '下发回充指令,设备自动前往充电点补能'],
['实时画面', '调取机场视频或无人机视频,查看现场画面'],
['切换手动', '把设备从自动任务模式切换为手动控制模式'],
['急停', '立即下发急停指令,设备紧急制动,用于异常工况'],
]))
add(p_body('对于无人机,面板下方还会展示环境与飞行条件(风速、温度、降雨)并给出"适宜执行·可正常作业"等结论;'
'对于地面机器人,则替换为机器人作业状态,展示累计作业里程、累计作业面积与累计作业时长。'))
add(p_h2('3.5 AI 分析与操作建议'))
add(p_body('AI 分析区把巡检影像与运行数据的分析结论提炼为四类指标,并配套给出可直接执行的操作建议,'
'让"发现问题"与"处理问题"在同一个面板内完成:'))
add(table([
['指标', '含义'],
['热斑疑似', '疑似存在热斑缺陷的组件位置与数量'],
['杂草高发区', '植被生长密集、需要安排除草的区域数量'],
['组件风险区', '存在遮挡、破损或积灰等风险的区域数量'],
['围栏异常', '围栏破损或越界告警的点位数量'],
]))
add(p_body('指标下方为建议操作区,系统按优先级给出建议,例如"优先执行巡检航线 P-03""先行安排除草任务 G-02"'
'"建议生成清洗工单"。每条建议右侧提供快捷按钮:点击"生成任务"可直接跳转任务下发流程;'
'点击"AI 优化路径"由算法重新计算作业路径;点击"一键联动作业"可把巡检、除草、清洗等任务按建议顺序联动下发。'))
add(p_h2('3.6 控制日志 / 告警记录'))
add(p_body('该区域按时间倒序记录设备控制与告警事件,字段包括时间、设备、类型与描述,'
'例如"起飞指令下发,开始作业""返回待命状态""电量较低,建议回充"。右侧"全部级别"下拉可筛选日志级别,'
'点击"查看更多日志"进入完整日志列表,便于回溯某次操作的执行情况。'))
add(p_h2('3.7 机器人作业状态与任务执行时间线'))
add(p_body('页面右下方展示机器人作业状态,包括累计作业里程、累计作业面积与累计作业时长三项汇总指标;'
'下方为任务执行时间线,按时间顺序列出当日各装备开始执行的任务,点击"查看更多"可展开完整的执行记录。'))
add(p_h2('3.8 典型操作流程'))
add(p_body('以"发现某区域杂草高发并安排除草作业"为例,完整操作流程如下:'))
bullets([
('在顶部统计卡与 AI 分析区确认"杂草高发区"数量与建议内容;', False),
])
bullets([
('查看装备协同态势图,通过"定位到机器人"确认可用除草机器人位置与电量;', False),
])
bullets([
('在装备调度列表中核对该设备的通讯状态,必要时先下发"回充"补能;', False),
])
bullets([
('点击"生成任务"或在"机器人任务"页面按区域下发除草任务;', False),
])
bullets([
('任务开始后在本页任务执行时间线与"控制日志"中跟踪执行情况;', False),
])
bullets([
('作业结束后在"机器人作业状态"中核对累计作业面积与时长。', False),
])
# ================= 四、机器人任务 =================
add(p_h1('四、机器人任务'))
add(img_paragraph(SHOTS + '/02-机器人任务.png', 'rIdImg3'))
add(p_caption('图 4-1 机器人任务——任务下发与执行监控'))
add(p_body('机器人任务页面面向地面作业机器人的任务编排、下发与执行监控,是"让机器人干活"的主入口。'
'页面自上而下分为任务统计、路径规划监控、任务下发与任务列表四个区域,'
'左侧看"人在哪、走哪条路",右侧做"派谁去、干什么、什么时候干"。'))
add(p_h2('4.1 任务统计概览'))
add(p_body('顶部六张统计卡横向排列,展示当日机器人作业的关键指标,每张卡片下方均给出较昨日的变化趋势,'
'便于判断作业量是否异常波动:'))
add(table([
['统计卡', '含义'],
['今日机器人计划(条)', '当日计划执行的任务条数'],
['作业中机器人(台)', '当前处于执行状态的机器人数'],
['累计作业里程(km)', '当日累计作业行驶里程'],
['任务完成率(%)', '已完成任务占计划任务的比例'],
['异常告警(次)', '当日任务执行过程中产生的异常告警次数'],
['在线率(%)', '当前在线设备占设备总数的比例'],
]))
add(p_h2('4.2 机器人路径规划与监控'))
add(p_body('左上区域为"机器人路径规划与监控"地图,基于 Cesium 实时显示机器人位置、规划路线与历史轨迹。'
'地图右上角浮层提供控制模式显示与切换(关闭 / 本地 / 自动任务 / 远程)、定位图层开关与视频开关,'
'并提供"寻车""返航"两个快捷按钮:前者用于让机器人前往指定点位,后者用于让机器人返回预设返航点。'))
add(p_body('地图上方两个按钮用于清理视图:"清空实时轨迹"清除已绘制的历史轨迹线,"清空规划区域"'
'清除当前显示的规划区域范围,便于重新观察干净的地图。'))
add(p_h2('4.3 任务下发'))
add(p_body('右侧"任务下发"卡片是任务编排的核心区域,按"下发方式 → 选择路径 → 选择任务类型 → 执行机器人"'
'的顺序依次设置,最后点击底部主按钮提交。'))
add(p_bullet(seg('下发方式:', B), ('提供"按路线下发"与"按区域下发"两种模式。按路线下发为单选,'
'点击某条路线后地图自动飞至该路线所在位置,便于确认起止点;按区域下发支持多选,'
'可勾选同一区域下的多条路径,选择超过一条时会出现"调节执行顺序"面板,'
'可通过拖拽调整各路径的执行先后', False)))
add(p_bullet(seg('任务类型:', B), ('"立即执行任务"表示下发后机器人立刻开始执行;"定时任务"表示按计划在未来某个时间'
'自动执行,选择后展开定时表单', False)))
add(p_bullet(seg('定时表单:', B), ('包含任务名称、重复计划(按天 / 按周 / 按月)、按周时可勾选周一至周日、'
'按月时可选择 1–31 日、执行日期、执行时间与执行周期范围', False)))
add(p_bullet(seg('执行机器人:', B), ('下拉列表以"别名(SN)"的形式展示当前场站可用机器人,'
'选择后即绑定该设备作为任务执行主体', False)))
add(p_bullet(seg('提交与取消:', B), ('立即执行任务点击"下发任务",定时任务点击"保存定时任务";'
'点击"取消"清空本次编排内容', False)))
add(p_body('提交前系统会校验路径是否已选择、执行机器人是否已指定、定时时间是否早于当前时间,'
'校验不通过时在对应位置给出提示,避免误下发。'))
add(p_h2('4.4 作业编排建议与设备环境'))
add(p_body('左下区域为"作业编排建议",以时间轴形式给出当日建议的作业节奏,例如 09:30 安排除草任务、'
'11:00 安排巡检任务、14:00 安排设备例行维护,并标注预计耗时与当前进度;'
'点击"智能生成今日计划"可由系统按设备状态、环境条件与历史作业情况自动生成一版建议计划。'))
add(p_body('时间轴下方为"设备环境与状态",展示当前温度、环境湿度、定位精度与网络延迟四项环境指标,'
'并在存在作业风险时给出提示,例如"区域 B 存在积水风险,建议避开"。'
'编排任务时应结合环境指标与实际作业条件综合判断,避免在定位精度不足或网络延迟过高时下发精细作业任务。'))
add(p_h2('4.5 任务看板'))
add(p_body('右下方"任务看板"以"待执行 / 进行中 / 已完成"三列看板形式展示任务流转情况。'
'任务卡片上标注设备名称、任务名称与实时状态(如"正在执行…""已暂停"),卡片会随任务状态变化在三列之间移动,'
'可直观看到当天任务的整体进度与堆积情况。'))
add(p_h2('4.6 任务列表管理'))
add(p_body('页面下方为任务列表,提供"任务池 / 任务执行计划 / 历史任务"三个页签,分别对应待执行任务、'
'已编排的执行计划与已结束任务。每个页签上方提供重置、状态下拉、机器人下拉与日期范围筛选,'
'右侧刷新按钮可手动拉取最新数据。'))
add(table([
['页签', '主要内容与字段'],
['任务池', '设备、任务 ID、路线 ID、状态、创建时间、操作'],
['任务执行计划', '任务名称、设备、创建者、创建时间、执行计划、执行周期、状态、操作'],
['历史任务', '设备、状态、开始时间、结束时间、创建时间、操作'],
]))
add(p_body('任务状态共六种,含义如下:'))
add(table([
['状态', '含义'],
['新建(NEW)', '任务已创建,尚未下发给设备'],
['执行中(EXECUTING)', '任务已下发,设备正在执行'],
['暂停中(PAUSE)', '任务被人工暂停,设备保持当前状态'],
['执行成功(FINISH)', '任务全部执行完成'],
['执行失败(FAILED)', '任务因异常中断,未完成'],
['取消中(CANCELING)', '任务正在执行取消流程'],
]))
add(p_body('行内操作包括暂停、恢复、取消与删除:"暂停"使执行中的任务暂停、"恢复"让暂停的任务继续;'
'"取消"需在弹窗中选择"普通取消"或"强制取消",强制取消用于设备无响应时由管理员执行;'
'"删除"需二次确认,删除后任务不再出现在列表中。对于执行失败或已取消的任务,行内提供"继续作业"按钮,'
'可基于原任务重新下发,避免重新编排路径。'))
add(p_h2('4.7 任务执行监控与异常处理'))
add(p_body('任务下发后,可通过"路径规划与监控"地图观察机器人是否按规划路线行进;若轨迹明显偏离,'
'应先在"机器人控制"页面确认定位与网络状态。出现设备无响应、卡滞或姿态异常时,'
'建议依次执行:暂停任务 → 在控制页触发急停 → 排查设备状态与通讯状态 → 处理后通过"继续作业"恢复任务。'
'任务结束后,系统会把本次作业的里程、面积与时长计入统计,可在本页统计卡与历史任务中核对。'))
# ================= 五、机器人控制 =================
add(p_h1('五、机器人控制'))
add(img_paragraph(SHOTS + '/03-机器人控制.png', 'rIdImg4'))
add(p_caption('图 5-1 机器人控制——实时状态、视频与遥控操作'))
add(p_body('机器人控制页面是人工介入设备作业的核心操作界面,集成设备状态监测、实时视频、态势地图、'
'手柄遥控与方向盘遥控五大能力,适用于自动任务无法完成、需要人工临时接管设备的场景。'))
add(p_h2('5.1 设备选择'))
add(p_body('页面顶部"选择设备"下拉按机器人 / 无人机分组列出当前场站所有已接入设备,选中后右侧以标签形式'
'显示设备类型(如"割草机""巡检机器人")与在线 / 离线状态。离线设备仍可查看最后上报的状态数据,'
'但无法下发控制指令。'))
add(p_h2('5.2 设备状态'))
add(p_body('左侧"设备状态"面板以列表形式实时刷新设备运行参数,每一项均有明确的单位与状态含义,'
'是判断设备是否适合继续作业的主要依据:'))
add(table([
['参数', '单位 / 取值', '说明'],
['电量', '%', '电池剩余电量,过低时触发低电量返航'],
['电压', 'V', '电池组实时电压'],
['前进速度', 'm/s', '设备当前前进方向速度'],
['转向速度', 'rad/s', '设备当前转向角速度'],
['左 / 右电机电流', 'A', '左右驱动电机工作电流,异常升高提示受阻'],
['左 / 右电机温度', '℃', '电机温度,过高需停机散热'],
['主控温度', '℃', '主控制器温度'],
['模式', '—', '控制模式:关闭 / 本地控制 / 自动任务 / 远程控制'],
['运动状态', '—', '行进中 / 已停止 / 受阻'],
['割刀转速', 'r/min', '作业机构转速,反映当前是否在作业'],
['急停状态', '—', '已触发 / 正常'],
['左 / 右电机转速', 'rpm', '驱动电机实时转速'],
['累计作业', 'm / ㎡ / h', '累计作业里程、面积与时长'],
]))
add(p_body('参数面板整体随遥测数据刷新,无需手动刷新;当出现电机温度过高、电流异常、运动状态为"受阻"等情况时,'
'应先停机检查,确认无异常后再恢复作业。'))
add(p_h2('5.3 实时视频监控'))
add(p_body('中间"实时视频监控"区域通过 WebRTC 拉取机载摄像头画面。播放状态下左上角显示"直播中"标识,'
'右上角提供播放 / 停止控制;上方提供前视角、左视角、右视角、后视角、全景五个视角切换按钮,'
'可根据作业场景选择合适视角。未播放时区域居中显示"视频已停止,点击播放"提示。'))
add(p_body('后视角会自动叠加倒车辅助线,用于判断车体与后方障碍物的相对位置,倒车或倒退作业时应切换至该视角。'
'若视频长时间无法加载,通常是网络带宽不足或设备侧推流异常,可先切换视角或重新播放,仍无法恢复时'
'应暂停远程作业并改用现场处置。'))
add(p_h2('5.4 设备态势地图'))
add(p_body('右侧"设备态势地图"以 Cesium 显示设备实时位置,左上角浮层实时显示航向角、横滚角与俯仰角,'
'用于判断车体姿态是否异常(例如倾斜角过大时应触发倾斜保护)。地图上方"清空实时轨迹"'
'"清空规划区域"按钮用于清理视图,"定位到机器人"按钮可将视角快速拉回设备所在位置。'))
add(p_h2('5.5 手柄控制'))
add(p_body('左下"手柄控制"区域支持通过游戏手柄操控设备。面板顶部显示控制器连接状态'
'("控制器已连接 / 未连接"),并提供"请求控制"与"释放控制"两个按钮,'
'以及在控制中 / 无控制权限两种状态标签。默认按键映射如下:'))
add(table([
['按键', '功能'],
['左摇杆', '控制行进方向'],
['右摇杆', '控制转向'],
['A 键', '升起底盘'],
['B 键', '降下底盘'],
['X 键', '割刀加速'],
['Y 键', '割刀减速'],
['LB', '点火'],
['RB', '熄火'],
['急停键', '方向盘上的红色盾牌键,触发急停'],
]))
add(p_body('使用手柄前需先点击"请求控制"获取控制权,未取得控制权时按键不会下发指令,'
'这一机制用于防止多名操作员同时操控同一台设备。操作完成后应点击"释放控制",'
'把设备交还自动任务或让其他操作员接管。'))
add(p_h2('5.6 方向盘控制'))
add(p_body('底部"方向盘控制"区域提供屏幕虚拟方向盘,适合没有手柄时快速接管设备。中间方向盘包含前进、后退、'
'左转、右转四个方向键与中键停止按钮;下方速度滑块可在 0–100% 之间调节行驶速度,默认 50%,'
'精细作业建议降至 30% 以下。键盘方向键与 WASD 组合键同样可用,便于双手配合操作。'))
add(p_body('控制指令以 100 毫秒为周期持续下发,按住方向键期间设备持续按该方向运动,松开即停止;'
'方向盘两侧集中布置了点火 / 熄火、底盘升起 / 下降、割刀加速 / 减速等执行机构按钮,'
'不需要来回切换区域即可完成"行进 + 作业"的组合操作。'))
add(p_h2('5.7 功能控制与急停'))
add(p_body('功能控制区提供五项辅助功能开关,用于在不同作业场景下调整设备的安全策略与作业行为:'))
add(table([
['功能', '作用'],
['避障', '开启后设备在行进过程中自动识别并绕开障碍物'],
['低电量自动返航', '电量低于阈值时自动返回返航点或充电点'],
['倾斜保护', '车体倾斜角超过安全范围时自动停止作业'],
['返航', '手动下发返航指令,设备按预设返航点返回'],
['寻车', '让设备前往指定点位或呼叫设备返回操作员位置'],
]))
add(p_body('急停按钮(停车)以红色突出显示在功能控制区右侧。触发后系统立即下发急停指令,'
'并自动补发复位帧,确保指令可靠送达;急停触发后设备进入停止状态,需人工确认现场安全后'
'重新点火或恢复任务。任何异常工况下,急停都应作为第一处置动作。'))
add(p_h2('5.8 控制权限与安全机制'))
add(p_body('远程控制涉及多操作员协作与设备安全,系统在链路层做了以下约束:'))
add(p_bullet(seg('控制权独占:', B), ('同一台设备在同一时刻只允许一个控制会话,其他操作员申请时会被拒绝并提示设备被占用', False)))
add(p_bullet(seg('强制抢占:', B), ('仅管理员可在设备被占用时强制抢占,原会话收到被抢占通知后关闭,避免双方指令冲突', False)))
add(p_bullet(seg('会话保活:', B), ('控制会话需持续发送保活帧,长时间无操作或网络中断时会话自动失效,防止设备被"遗忘"在远程模式', False)))
add(p_bullet(seg('模式可见:', B), ('设备当前处于本地、自动任务还是远程控制,在状态面板与地图浮层中均可查看,避免误操作', False)))
add(p_bullet(seg('指令确认:', B), ('关键指令(返航、回充、急停)下发后,状态面板会同步反馈执行结果,未生效时可重发', False)))
# ================= 六、地图编辑 =================
add(p_h1('六、地图编辑'))
add(img_paragraph(SHOTS + '/04-地图编辑.png', 'rIdImg5'))
add(p_caption('图 6-1 地图编辑——返航点、分区与路径绘制'))
add(p_body('地图编辑页面用于维护机器人作业所需的电子地图要素,包括返航点、分区、障碍物点与作业路径。'
'这些要素是任务下发的前置条件:没有返航点,设备无法安全返回;没有分区与路径,就无法按区域编排任务。'
'因此新场站接入后,应先在本页完成地图要素配置,再开展任务作业。'))
add(p_h2('6.1 工具栏与绘制模式'))
add(p_body('页面顶部工具栏提供四种绘制模式、点选工具与机器人选择下拉,右侧为撤销与清空操作:'))
add(table([
['模式 / 工具', '用途'],
['返航点', '在地图上点选返航点位置,供设备任务结束后返回'],
['分区', '在地图上点选或框选区域边界,形成作业分区'],
['障碍物点', '标记场站内的障碍物位置,路径规划时自动避让'],
['路径生成', '按点位顺序生成作业路径并保存为预设路线'],
['点选', '选中并编辑已绘制的地图要素'],
['选择机器人', '指定本次规划所针对的设备型号'],
['撤销 / 清空', '撤销上一步绘制,或清空当前未保存的绘制内容'],
]))
add(p_body('地图区会根据当前模式给出操作提示:"点击地图添加路径点,按顺序生成路线"或'
'"点击地图或者框选区域添加点位";左下角实时显示"已选点位:n",便于确认点数是否满足要求。'
'绘制过程中若点位放置有误,可点击"撤销"回退,撤销仅影响未保存的内容。'))
add(p_h2('6.2 返航点'))
add(p_body('返航点是设备任务结束、低电量或收到返航指令后自动返回的点位。操作步骤:'))
bullets([
('在右侧"选择类型"中选择"返航点"卡片;', False),
])
bullets([
('在地图上点击目标位置添加点位,可添加多个候选点;', False),
])
bullets([
('在"返航点列表"中核对点位坐标,点击"保存"完成配置;', False),
])
bullets([
('已配置的返航点在页面下方"已配置返航点"区域展示,支持行内编辑坐标与删除。', False),
])
add(p_body('返航点应设置在场地内平坦、无障碍、通讯良好的位置,并避开积水与松软地面,'
'以免设备返航后陷入或无法再次启动。'))
add(p_h2('6.3 分区'))
add(p_body('分区用于把场站划分为若干作业区域,并为每个分区绑定规划路线,是"按区域下发任务"的基础。操作步骤:'))
bullets([
('选择"分区"类型,点击地图或框选区域添加边界点位,点位数量不得少于 3 个;', False),
])
bullets([
('点击"保存"打开"保存分区"弹窗,填写分区名称(必填)、分区编码、默认线路、绑定线路(可多选)与备注;', False),
])
bullets([
('弹窗中会显示"当前区域范围点数",用于核对边界是否闭合完整;', False),
])
bullets([
('保存后分区出现在"分区列表(已选 n 个)"与"已配置分区"区域,支持编辑、绑定线路调整与删除。', False),
])
add(p_body('点位少于 3 个时系统会提示"最少三个点"并阻止保存。分区边界应贴合地块实际轮廓,'
'相邻分区之间避免大面积重叠,否则同一区域可能被重复安排作业。'))
add(p_h2('6.4 障碍物点'))
add(p_body('障碍物点用于标记场站内的固定障碍(如支架基础、水塘、电杆、堆料区)。选择"障碍物点"类型后'
'在地图上点击标记,在"障碍物点列表"中保存。标记后,系统在路径规划与自动作业时会自动绕开该位置,'
'减少设备碰撞与卡滞风险。障碍物发生变化(移除或新增)时应及时更新,保持地图与实际场地一致。'))
add(p_h2('6.5 路径生成与预设路线'))
add(p_body('选择"路径生成"模式后,右侧出现路径配置卡片,填写路径名称,选择路径规划类型与作业模式,'
'并确认点位顺序后保存:'))
add(table([
['字段', '说明'],
['路径名称', '路线的唯一名称,建议包含区域与用途,便于下发时快速识别'],
['路径规划类型', '割草任务 / 巡检任务 / 其他类型,决定该路线适用的作业场景'],
['模式', '覆盖模式(按区域完整覆盖作业)/ 单点模式(按点位依次到达)'],
['路径点列表', '按点击顺序排列的点位,可在列表中核对与调整'],
]))
add(p_body('保存后的路线进入"预设路线"列表,展示名称、模式与类型,可被任务下发页面直接引用;'
'删除预设路线时需二次确认,删除后已引用该路线的定时任务将无法执行,需重新编排。'))
add(p_h2('6.6 地图要素与任务的关联关系'))
add(p_body('四类地图要素在业务上环环相扣:返航点保障设备"回得来",分区决定任务"按哪块地派",'
'路径决定设备"怎么走",障碍物点保证设备"走得安全"。因此修改任一类要素后,'
'建议重新检查已编排的定时任务是否仍然有效,避免因点位或分区变更导致任务执行异常。'))
# ================= 七、告警中心 =================
add(p_h1('七、告警中心'))
add(img_paragraph(SHOTS + '/05-告警中心.png', 'rIdImg6'))
add(p_caption('图 7-1 告警中心——告警查询与统计'))
add(p_body('告警中心提供告警的全生命周期管理,覆盖告警产生、查询、处理与统计分析,'
'是运维人员每天必看的页面之一。页面自上而下分为统计概览、筛选查询、告警列表与统计图表四个区域。'))
add(p_h2('7.1 告警统计概览'))
add(p_body('顶部四张统计卡分别展示今日总告警、今日严重告警、今日待处理与今日已关闭的数量,'
'卡片下方标注昨日数值并以"上升 / 下降 / 无变更"提示变化趋势,便于快速判断当日设备健康状况。'
'若"今日严重告警"数量明显高于历史水平,应优先进入列表逐条处理。'))
add(p_body('页面下方提供三张可视化图表卡:告警趋势以折线展示一段时间内的告警数量变化,'
'告警类型比例以饼图展示各类型告警的构成,告警状态统计以柱状图对比待处理与已关闭数量,'
'可用于分析高频故障类型与处理效率。'))
add(p_h2('7.2 告警查询与列表'))
add(p_body('筛选栏支持四个条件的组合查询:告警级别、处理状态(待处理 / 已关闭)、设备 ID 与时间范围;'
'右侧提供"重置""查询""导出"三个按钮,分别用于清空条件、按条件刷新列表与导出查询结果。'))
add(p_body('告警列表字段如下:'))
add(table([
['字段', '说明'],
['告警编号', '告警的唯一标识,可用于追溯与沟通'],
['告警标题', '告警的简要标题'],
['告警内容', '告警的详细描述信息'],
['报表值', '触发告警时该监控项的实测数值'],
['设备名称', '产生告警的设备'],
['告警类型', '告警所属分类'],
['告警级别', '严重 / 重要 / 一般 / 提示'],
['告警时间', '告警发生时间'],
['处理状态', '待处理 / 已关闭'],
['操作', '查看、修改'],
]))
add(p_h2('7.3 告警详情与处理'))
add(p_body('点击行内"查看"从右侧展开"告警详情"抽屉,上半部分展示告警编号、来源设备、告警类型、发生时间与'
'处理状态,以及完整的告警描述;中部"建议操作"提供两个快捷入口:点击"联系处理人"可查看并联系'
'该设备的责任人,点击"生成工单"可直接把本条告警转为运维工单,带上告警编号与描述信息。'))
add(p_body('下半部分为"处理操作"表单,需填写处理状态、处理结果、根原因与处理备注,'
'填写完成后点击"确认处理"完成闭环。处理结果的可选值如下:'))
add(table([
['处理结果', '适用场景'],
['已处理', '已完成处置,设备恢复正常'],
['已恢复', '异常自行消失,经核实无需处置'],
['已忽略', '经判断为不影响运行的可忽略事件'],
['无法处理', '现场条件不具备,暂无法处置,需后续跟进'],
['厂家处理中', '已联系设备厂家,等待厂家处理'],
['误报', '经核实为误报,非真实异常'],
]))
add(p_body('处理完成后,告警状态由"待处理"变为"已关闭",并从待处理统计中移除。建议在处理备注中写明'
'处置措施与验证结果,便于后续复盘与同类问题追溯。'))
add(p_h2('7.4 告警级别与类型说明'))
add(p_body('告警级别分为四级,用于区分处置优先级:'))
add(table([
['级别', '含义与处置要求'],
['严重', '影响设备安全或导致作业中断,需立即处置'],
['重要', '影响作业效率或存在潜在风险,需当日处置'],
['一般', '轻微异常,可安排在例行维护中处理'],
['提示', '状态变化提示,仅需知悉,无需处置'],
]))
add(p_body('告警类型涵盖无人机故障、割草机故障、其他设备故障、服务器故障与系统错误等,'
'不同类型对应不同的责任人与处置路径。点击行内"修改"可在弹窗中调整告警标题、告警级别与告警类型,'
'用于修正误分级或补充说明,修改记录会保留在操作日志中。'))
add(p_h2('7.5 告警与工单的联动'))
add(p_body('对于需要现场处置的告警,推荐走"告警 → 工单"的联动路径:在告警详情中点击"生成工单",'
'系统自动带出告警编号、设备与描述,补充处理人与期望完成时间后提交;'
'工单在"工单任务"页面按待处理、处理中、待验收、已完成的状态流转,'
'验收关闭后告警同步标记为已关闭,实现"发现—派单—处置—验收"的完整闭环。'))
# ================= 八、日常使用建议 =================
add(p_h1('八、日常使用建议'))
add(p_h2('8.1 日常巡检与作业'))
bullets([
seg('每日例行:', B), ('上班后先查看总览首页与告警中心,优先处理"严重""重要"级别告警,再安排当日作业计划', False),
])
bullets([
seg('任务下发前:', B), ('先在地图编辑页面完成返航点、分区与作业路径配置,确保要素完整后再下发任务', False),
])
bullets([
seg('作业过程中:', B), ('通过机器人任务的路径监控与设备状态面板跟踪执行情况,出现偏离或受阻及时介入', False),
])
bullets([
seg('数据留存:', B), ('定期通过报表中心导出运营数据;历史任务与告警记录保留在系统中,便于复盘', False),
])
add(p_h2('8.2 安全作业规范'))
bullets([
seg('控制权管理:', B), ('远程控制前先"请求控制",操作完毕及时"释放控制",避免长时间占用设备控制会话', False),
])
bullets([
seg('速度控制:', B), ('复杂地形与精细作业时将速度降至 30% 以下,空旷区域可适当提高', False),
])
bullets([
seg('视角选择:', B), ('倒车与倒退作业务必切换至后视角,借助倒车辅助线判断距离', False),
])
bullets([
seg('急停优先:', B), ('发现设备失控、碰撞、姿态异常或人员进入作业区时,第一时间触发急停', False),
])
bullets([
seg('保护开关:', B), ('作业期间保持避障、倾斜保护、低电量自动返航等功能开启,非必要不关闭', False),
])
add(p_h2('8.3 常见问题排查'))
add(table([
['现象', '可能原因', '处理建议'],
['视频画面无法加载', '网络带宽不足或设备侧推流异常', '切换视角或重新播放;仍无效则暂停远程作业'],
['控制指令无响应', '未取得控制权或控制会话已失效', '重新"请求控制";确认设备在线与模式为远程'],
['提示设备被占用', '其他操作员正持有控制权', '联系原操作员释放控制;必要时由管理员强制抢占'],
['任务下发失败', '未选路径、未选机器人和时间冲突', '按表单提示补齐必填项,检查定时时间是否已过'],
['地图要素无法保存', '点位不足(如分区少于 3 个点)', '补充点位至满足要求后重新保存'],
['定位漂移、轨迹异常', '定位精度不足或信号遮挡', '查看定位精度与网络延迟,待恢复后再作业'],
]))
add(EMPTY_P)
# ---------- 组装 document.xml ----------
ref_zip = zipfile.ZipFile(REF)
ref_doc = ref_zip.read('word/document.xml').decode('utf8')
sect = re.search(r'<w:sectPr>.*?</w:sectPr>', ref_doc, re.S).group(0)
document = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<w:document %s xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingPicture">'
'<w:body>%s%s</w:body></w:document>'
) % (W, ''.join(content), sect)
rels = ref_zip.read('word/_rels/document.xml.rels').decode('utf8')
keep = [m for m in re.findall(r'<Relationship\b[^>]*/>', rels) if '/image' not in m]
uniq = []
seen = set()
for pth in imgs:
if pth not in seen:
seen.add(pth)
uniq.append(pth)
img_rels = ['<Relationship Id="rIdImg%d" Type="http://schemas.openxmlformats.org/officeDocument/2006/'
'relationships/image" Target="media/manual%d.png"/>' % (i, i)
for i in range(1, len(uniq) + 1)]
new_rels = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
+ ''.join(keep) + ''.join(img_rels) + '</Relationships>')
# ---------- 写出(先写临时文件,再尝试替换;目标被预览占用时另存为新文件) ----------
import time
BUILD = OUT + ".building"
zin = zipfile.ZipFile(REF)
zout = zipfile.ZipFile(BUILD, 'w', zipfile.ZIP_DEFLATED)
for item in zin.namelist():
if item in ('word/document.xml', 'word/_rels/document.xml.rels'):
continue
if item.startswith('word/media/'):
continue
data = zin.read(item)
if item == 'word/header1.xml':
data = (data.decode('utf8')
.replace('<w:t>光伏</w:t>', '')
.replace('>机器人环境感知服务平台<', '>机器人智慧服务系统<')
.encode('utf8'))
zout.writestr(item, data)
zout.writestr('word/document.xml', document)
zout.writestr('word/_rels/document.xml.rels', new_rels)
for i, pth in enumerate(uniq, 1):
with open(pth, 'rb') as f:
zout.writestr('word/media/manual%d.png' % i, f.read())
zout.close()
final = OUT
for _ in range(10):
try:
os.replace(BUILD, final)
break
except PermissionError:
time.sleep(2)
else:
final = OUT.replace('.docx', '-完整版.docx')
os.replace(BUILD, final)
print('!! 原文件被预览进程占用,已另存为:', final)
print('OK ->', final, os.path.getsize(final), 'bytes, images:', len(uniq))

15
scripts/probe_login.mjs Normal file
View File

@@ -0,0 +1,15 @@
import { chromium } from 'playwright';
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const page = await browser.newPage({ viewport: { width: 1680, height: 1050 } });
await page.goto('http://127.0.0.1:3001/login', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
const info = await page.evaluate(() => {
const inputs = [...document.querySelectorAll('input')].map(i => ({
type: i.type, id: i.id, name: i.name, ph: i.placeholder, cls: i.className.slice(0, 60)
}));
const btns = [...document.querySelectorAll('button')].map(b => ({ text: b.innerText.trim().slice(0, 20), cls: b.className.slice(0, 60) }));
return { inputs, btns, title: document.title, url: location.href };
});
console.log(JSON.stringify(info, null, 2));
await browser.close();

BIN
shots/00-login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

BIN
shots/01-装备总览.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 767 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
shots/04-地图编辑.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 KiB

BIN
shots/05-告警中心.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState, useMemo, useImperativeHandle, forwardRef, useCallback } from 'react';
import React, { useEffect, useRef, useState, useMemo, useImperativeHandle, forwardRef, useCallback } from 'react';
import carImg from '../assets/images/carplane.png';
import planeImg from '../assets/images/planeicon.png';
import cameraImg from '../assets/images/camera.png';
@@ -16,6 +16,7 @@ import * as Cesium from 'cesium';
import 'cesium/Build/Cesium/Widgets/widgets.css';
const CesiumMap = React.forwardRef(({ ifClear, ifClearPlanLine, ifClearNavLine, finishPoint, drawRealTime, realtimePosition, realtimeType, pathPoints = [], devices = [], onCameraClick, focusLocation, points = [], markedPoints = [], onPathChange, from, onClearDraw, onMarkPoint, routeMode = '', polyPoints, mapLayer, activeType, overlayRefreshKey = 0, focusOverlay = null, onObstacleRadiusChange, obstacleShapes = [], onObstacleRectResize, siteMapInfoId, detectReportInfo }, ref) => {
const containerRef = useRef(null);
const viewerRef = useRef(null);
@@ -280,55 +281,65 @@ const CesiumMap = React.forwardRef(({ ifClear, ifClearPlanLine, ifClearNavLine,
if (lastFlightPromiseRef.current) viewer.camera.cancelFlight();
};
}, []);
function updateLiveObstacleBoxes(
viewer,
message,
) {
const viewer = viewerRef.current;
if (!viewer || !Cesium) return;
console.log(message, "障碍物信息")
return;
const payload = message.data ?? message;
const objects = payload.objects ?? [];
const payload = message?.data ?? message;
const objects = Array.isArray(payload) ? payload : (payload?.objects ?? []);
if (objects.length === 0) return;
// 记录第一个有效障碍物的位置,用于视角跳转
let firstPos = null;
objects.forEach((obj, index) => {
const lat = Number(obj.latitude);
const lon = Number(obj.longitude);
const sx = Number(obj.size_x);
const sy = Number(obj.size_y);
const sz = Number(obj.size_z);
const lat = Number(obj.latitude ?? obj.lat);
const lon = Number(obj.longitude ?? obj.lng ?? obj.lon);
if (
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
!Number.isFinite(sx) ||
!Number.isFinite(sy) ||
!Number.isFinite(sz) ||
sx <= 0 ||
sy <= 0 ||
sz <= 0
) {
return;
}
// 根据类型设置默认尺寸和颜色
const type = (obj.class_name ?? obj.name ?? '').toLowerCase();
const isPerson = type.includes('person') || type.includes('行人') || type.includes('人');
const isVehicle = type.includes('car') || type.includes('车辆') || type.includes('车') || type.includes('truck');
const isFence = type.includes('fence') || type.includes('围挡') || type.includes('障碍');
const id = `live-obstacle-${obj.track_id ?? index}`;
const sx = Number(obj.size_x ?? obj.sizeX ?? (isPerson ? 0.6 : isVehicle ? 1.8 : 1.2));
const sy = Number(obj.size_y ?? obj.sizeY ?? (isPerson ? 0.6 : isVehicle ? 4.2 : 1.2));
const sz = Number(obj.size_z ?? obj.sizeZ ?? (isPerson ? 1.7 : isVehicle ? 1.5 : 1.5));
// size_x、size_y、size_z 作为长、宽、高,单位按米处理
const position = Cesium.Cartesian3.fromDegrees(
lon,
lat,
sz / 2,
);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
const id = `live-obstacle-${obj.track_id ?? obj.id ?? index}`;
const position = Cesium.Cartesian3.fromDegrees(lon, lat, sz / 2);
if (!firstPos) firstPos = { lon, lat };
const orientation = Cesium.Transforms.headingPitchRollQuaternion(
position,
new Cesium.HeadingPitchRoll(
Cesium.Math.toRadians(Number(obj.angle ?? 0)),
0,
0,
),
new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(Number(obj.angle ?? obj.yaw ?? 0)), 0, 0)
);
const labelText =
`${obj.class_name ?? '障碍物'} #${obj.track_id ?? index}`;
// 设置视觉差异
let boxColor = Cesium.Color.RED;
let typeEmoji = '⚠️';
let iconImage = null; // 用于存储图标
if (isPerson) {
boxColor = Cesium.Color.YELLOW;
typeEmoji = '🚶';
iconImage = 'https://img.icons8.com/color/96/walking--v1.png'; // 行人图标
} else if (isVehicle) {
boxColor = Cesium.Color.CYAN;
typeEmoji = '🚗';
iconImage = 'https://img.icons8.com/color/96/car--v1.png'; // 车辆图标
}
const labelText = `${typeEmoji} ${obj.class_name ?? '障碍物'} #${obj.track_id ?? obj.id ?? index} (${sx.toFixed(1)}x${sy.toFixed(1)}m)`;
let entity = viewer.entities.getById(id);
@@ -337,44 +348,118 @@ const CesiumMap = React.forwardRef(({ ifClear, ifClearPlanLine, ifClearNavLine,
id,
position,
orientation,
// 外部包围盒,显示物理占用空间
box: {
dimensions: new Cesium.Cartesian3(sx, sy, sz),
material: Cesium.Color.RED.withAlpha(0.18),
material: boxColor.withAlpha(0.2),
outline: true,
outlineColor: Cesium.Color.RED,
outlineColor: boxColor,
outlineWidth: 1,
},
// 内部图标,直观显示物体类型
billboard: iconImage ? {
image: iconImage,
width: isPerson ? 24 : 32,
height: isPerson ? 24 : 32,
heightReference: Cesium.HeightReference.RELATIVE_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
} : undefined,
label: {
text: labelText,
font: '14px sans-serif',
font: 'bold 14px sans-serif',
fillColor: Cesium.Color.WHITE,
showBackground: true,
backgroundColor: Cesium.Color.RED.withAlpha(0.85),
pixelOffset: new Cesium.Cartesian2(0, -20),
backgroundColor: boxColor.withAlpha(0.85),
pixelOffset: new Cesium.Cartesian2(0, -40),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
});
} else {
entity.position = position;
entity.orientation = orientation;
if (entity.box) {
entity.box.dimensions = new Cesium.Cartesian3(sx, sy, sz);
entity.box.material = boxColor.withAlpha(0.2);
entity.box.outlineColor = boxColor;
}
if (entity.billboard && iconImage) {
entity.billboard.image = iconImage;
}
if (entity.label) {
entity.label.text = labelText;
entity.label.backgroundColor = boxColor.withAlpha(0.85);
}
}
liveObstacleEntities.set(id, entity);
});
// 自动跳转到第一个障碍物
//if (firstPos) {
// flyToView(viewer, 100, {
// destination: Cesium.Cartesian3.fromDegrees(firstPos.lon, firstPos.lat, 60),
// orientation: {
// heading: Cesium.Math.toRadians(0),
// pitch: Cesium.Math.toRadians(-40),
// roll: 0
// },
// duration: 2.0
// }, 'live-obstacle-jump');
//}
viewer.scene.requestRender();
}
// 模拟触发实时障碍物(仅用于演示)
//useEffect(() => {
// const viewer = viewerRef.current;
// if (!viewer || !Cesium) return;
// const timer = setTimeout(() => {
// console.log("[Mock] 触发模拟实时障碍物渲染 (人+车+围挡)");
// const mockMsg = {
// objects: [
// {
// track_id: "obs-v-001",
// latitude: 31.2989,
// longitude: 120.5853,
// size_x: 1.8,
// size_y: 4.2,
// size_z: 1.5,
// angle: 30,
// class_name: "车辆"
// },
// {
// track_id: "obs-p-002",
// latitude: 31.2992,
// longitude: 120.5856,
// size_x: 0.6,
// size_y: 0.6,
// size_z: 1.7,
// angle: 0,
// class_name: "行人"
// },
// //{
// // track_id: "obs-f-003",
// // latitude: 31.2987,
// // longitude: 120.5850,
// // size_x: 3.0,
// // size_y: 0.5,
// // size_z: 2.0,
// // angle: 90,
// // class_name: "施工围挡"
// //}
// ]
// };
// updateLiveObstacleBoxes(mockMsg);
// }, 3000);
// return () => clearTimeout(timer);
//}, [stationId]);
const handleDetectFn = (res) => {
console.log(res, "监测到障碍物")
const viewer = viewerRef.current;
updateLiveObstacleBoxes(viewer, res);
if (!res) return;
updateLiveObstacleBoxes(res);
};

View File

@@ -300,7 +300,7 @@ export default function AlarmCenterPage() {
},
{
title: t('alerts.reportValue'),
dataIndex: 'time_values',
dataIndex: 'timeValues',
ellipsis: true
},
{
@@ -849,9 +849,9 @@ export default function AlarmCenterPage() {
</Row>
{/* 告警详情抽屉 */}
<Drawer
title={t('alerts.alertDetail')}
open={drawerVisible}
<Drawer
title={t('alerts.alertDetail')}
open={drawerVisible}
onClose={() => setDrawerVisible(false)}
width={480}
destroyOnClose
@@ -878,9 +878,9 @@ export default function AlarmCenterPage() {
{/* 告警基础信息 */}
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label={t('alerts.sourceDevice')}>{currentAlarm.deviceName || '-'}</Descriptions.Item>
<Descriptions.Item label={t('alerts.alertType')}>{alarmTypeMap[currentAlarm.alarmType] || '-'}</Descriptions.Item>
<Descriptions.Item label={t('alerts.occurTime')}>{dayjs(currentAlarm.alarmTime).format('YYYY-MM-DD HH:mm:ss')}</Descriptions.Item>
<Descriptions.Item label={t('alerts.sourceDevice')}>{currentAlarm.deviceName || '-'}</Descriptions.Item>
<Descriptions.Item label={t('alerts.alertType')}>{alarmTypeMap[currentAlarm.alarmType] || '-'}</Descriptions.Item>
<Descriptions.Item label={t('alerts.occurTime')}>{dayjs(currentAlarm.alarmTime).format('YYYY-MM-DD HH:mm:ss')}</Descriptions.Item>
<Descriptions.Item label={t('alerts.handleStatus')}>
{(() => {
let color = 'default';
@@ -924,32 +924,32 @@ export default function AlarmCenterPage() {
{/* 处理操作 */}
<Card size="small" title={t('alerts.handleOperation')} style={{ marginBottom: 16 }}>
<Form.Item label={t('alerts.handleStatus')} name="handleStatus" rules={[{ required: true, message: t('alerts.pleaseSelectHandleStatus') }]}>
<Select>
<Option value={1}>{t('constants.pending')}</Option>
<Form.Item label={t('alerts.handleStatus')} name="handleStatus" rules={[{ required: true, message: t('alerts.pleaseSelectHandleStatus') }]}>
<Select>
<Option value={1}>{t('constants.pending')}</Option>
<Option value={2}>{t('constants.closed')}</Option>
</Select>
</Form.Item>
<Form.Item label={t('alerts.handleResult')} name="handleResult" rules={[{ required: true, message: t('alerts.pleaseSelectHandleResult') }]}>
<Select
placeholder={t('alerts.pleaseSelectHandleResult')}
options={alarmHandleResultOptions}
/>
</Form.Item>
<Form.Item label={t('alerts.rootCause')} name="rootCause">
<Input.TextArea rows={2} placeholder={t('alerts.pleaseInputHandleRemark')} />
</Form.Item>
<Form.Item label={t('alerts.handleRemark')} name="handleRemark">
<Input.TextArea rows={2} placeholder={t('alerts.pleaseInputHandleRemark')} />
</Form.Item>
<Option value={2}>{t('constants.closed')}</Option>
</Select>
</Form.Item>
<Form.Item label={t('alerts.handleResult')} name="handleResult" rules={[{ required: true, message: t('alerts.pleaseSelectHandleResult') }]}>
<Select
placeholder={t('alerts.pleaseSelectHandleResult')}
options={alarmHandleResultOptions}
/>
</Form.Item>
<Form.Item label={t('alerts.rootCause')} name="rootCause">
<Input.TextArea rows={2} placeholder={t('alerts.pleaseInputHandleRemark')} />
</Form.Item>
<Form.Item label={t('alerts.handleRemark')} name="handleRemark">
<Input.TextArea rows={2} placeholder={t('alerts.pleaseInputHandleRemark')} />
</Form.Item>
</Card>
</Card>
{/* 底部按钮 */}
<Button type="primary" block htmlType="submit">{t('alerts.confirmHandle')}</Button>
{/* 底部按钮 */}
<Button type="primary" block htmlType="submit">{t('alerts.confirmHandle')}</Button>
</>
</Form>
@@ -958,9 +958,9 @@ export default function AlarmCenterPage() {
</Drawer>
{/* ====================== 修改告警弹窗 ====================== */}
<Modal
title={t('alerts.modifyAlert')}
open={editModalVisible}
<Modal
title={t('alerts.modifyAlert')}
open={editModalVisible}
onCancel={() => setEditModalVisible(false)}
footer={null}
width={520}

View File

@@ -456,23 +456,7 @@ export default function DeviceStatusPage() {
// 开关切换回调
const handleChange = (checked: boolean) => {
setIsLoopMode(checked);
if (checked) {
console.log('当前作业方式:回字');
} else {
console.log('当前作业方式:单点');
}
};
@@ -1016,32 +1000,33 @@ export default function DeviceStatusPage() {
}, [device, wsDeviceInfo, isUAV, locationInfo]);
// 统一在线状态判断:优先实时数据,兜底接口数据
const isDeviceOnline = useMemo(() => {
// 1. 优先检查实时数据源(WebSocket 推送的 wsDeviceInfo / locationInfo)
const hasWsData = wsDeviceInfo && Object.keys(wsDeviceInfo).length > 0;
const hasLocData = locationInfo && Object.keys(locationInfo).length > 0;
if (hasWsData || hasLocData) {
return true;
}
// 2. 检查 runningStatus 里是否有有效经纬度或实时状态字段
const runningLon = runningStatus?.longitude;
const runningLat = runningStatus?.latitude;
const hasValidCoords = runningLon !== undefined && runningLon !== null && runningLon !== 0
&& runningLat !== undefined && runningLat !== null && runningLat !== 0;
const hasFeStatus = runningStatus?.feStatus !== 3;
const hasStatusField = runningStatus?.status !== 0;
if (hasValidCoords || hasFeStatus || hasStatusField) {
return true;
}
// 3. 兜底用接口返回的 onlineStatus
return device?.onlineStatus === 1;
}, [device, wsDeviceInfo, locationInfo, runningStatus]);
// 统一在线状态判断:优先实时数据,兜底接口数据
const isDeviceOnline = useMemo(() => {
// 1. 优先检查实时数据源(WebSocket 推送的 wsDeviceInfo / locationInfo)
const hasWsData = wsDeviceInfo && Object.keys(wsDeviceInfo).length > 0;
const hasLocData = locationInfo && Object.keys(locationInfo).length > 0;
if (hasWsData || hasLocData) {
return true;
}
//// 2. 检查 runningStatus 里是否有有效经纬度或实时状态字段
//const runningLon = runningStatus?.longitude;
//const runningLat = runningStatus?.latitude;
//const hasValidCoords = runningLon !== undefined && runningLon !== null && runningLon !== 0
// && runningLat !== undefined && runningLat !== null && runningLat !== 0;
//const hasFeStatus = runningStatus?.feStatus !== 3;
//const hasStatusField = runningStatus?.status !== 0;
//if (hasValidCoords || hasFeStatus || hasStatusField) {
// return true;
//}
// 3. 兜底用接口返回的 onlineStatus
return device?.onlineStatus == 1;
}, [device, wsDeviceInfo, locationInfo,]);

View File

@@ -57,6 +57,7 @@ import { MowerAllView } from './mowerAllView.jsx';
import { useWebRTCPlayer } from '../useWebRTCStream.js';
import { useTranslation } from 'react-i18next';
import { getStationRegions } from '@/api/stationManage/index.js';
import { Reorder } from 'motion/react';
const { Text, Title } = Typography;
const { RangePicker } = DatePicker;
@@ -73,7 +74,7 @@ export default function RobotTaskPage() {
const { modal } = App.useApp();
const [polyPoints, setPolyPoints] = useState([]);
const [detectReportInfo, setDeportInfo] = useState<any>(null);
const [selectedDeviceMap, setSelectedDeviceMap] = useState<Record<string, string>>({});
@@ -896,7 +897,10 @@ export default function RobotTaskPage() {
utils.message.error(t('devices.fixed2ResumeTaskFixed2AbnormalFixed2'));
}
};
const handleContinueWord = (record: any, selectSn: string) => {
console.log('当前行选中设备sn:', selectSn, record);
// 你的原有业务逻辑
};
useEffect(() => {
@@ -1156,7 +1160,7 @@ export default function RobotTaskPage() {
{
title: t('devices.device'),
dataIndex: 'deviceId',
width: 320,
width: 220,
},
{
title: t('roles.status'),
@@ -1189,6 +1193,49 @@ export default function RobotTaskPage() {
title: t('common.createTime'),
dataIndex: 'createTime',
width: 170,
}, {
title: t('common.operation'),
width: 170,
render: (_text, record) => {
// 哪些状态显示【继续作业】:FINISH 完成 / FAILED 失败 / CANCEL 取消
const showContinueBtn = ['FAILED', 'CANCELED'].includes(record.taskStaus);
if (!showContinueBtn) return null;
const currentSn = selectedDeviceMap[record.taskId] ?? record.serialNumber;
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Select
placeholder={t('devices.taskSelectRobot')}
value={currentSn}
onChange={(val) => {
setSelectedDeviceMap(prev => ({
...prev,
[record.taskId]: val
}));
}}
szie="small"
style={{ flex: 1, minWidth: 100 }}
options={(devices || []).map(item => ({
value: item.serialNumber,
label: `${item.deviceAlias || item.deviceName} (${item.serialNumber})`
}))}
/>
<Button
type="link"
size="small"
onClick={() => {
const chooseSn = selectedDeviceMap[record.taskId] ?? record.serialNumber;
handleContinueWord(record, chooseSn);
}}
>
{t('devices.continueWork')}
</Button>
</div>
);
},
}
];
@@ -1256,6 +1303,21 @@ export default function RobotTaskPage() {
return (routeList || []).filter(rt => ids.has(rt.id));
}, [regionList, dispatchRegionIds, routeList]);
// 选中的路径对象列表(用于拖拽排序显示)
const selectedRouteObjects = useMemo(() => {
// 根据 dispatchRouteIds 的顺序返回路径对象
return dispatchRouteIds
.map(id => routeList.find(r => r.id === id))
.filter(Boolean);
}, [dispatchRouteIds, routeList]);
// 监听路径顺序变化并打印
useEffect(() => {
if (dispatchRouteIds.length > 0) {
console.log('当前已选路径 ID 顺序:', dispatchRouteIds);
}
}, [dispatchRouteIds]);
// 定时任务:周/月选项
const weekDays = [
{ label: t('devices.wdSun'), value: 'SUNDAY' },
@@ -2162,136 +2224,211 @@ export default function RobotTaskPage() {
)}
</div>
{dispatchRegionIds.length > 0 && (
<div>
<div style={{ fontWeight: 600, marginBottom: 8 }}>{t('devices.taskSelectRoute')}</div>
{regionAvailableRoutes.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('devices.noRouteInRegion')} />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<div style={{ fontWeight: 600, marginBottom: 8 }}>{t('devices.taskSelectRoute')}</div>
{regionAvailableRoutes.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('devices.noRouteInRegion')} />
) : (
<div
style={{
width: '100%',
boxSizing: 'border-box',
padding: 8,
border: '1px solid #e6edf5',
borderRadius: 6,
background: '#fff',
}}
>
{/* 顶部提示文字 */}
<div
style={{
marginBottom: 8,
color: '#8c9bb0',
fontSize: 11,
lineHeight: '16px',
}}
>
选择需要执行任务的路线(可多选)
</div>
{/* 路线选择卡片 */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
gap: 8,
width: '100%',
}}
>
{regionAvailableRoutes.map((route, index) => {
const checked = dispatchRouteIds.includes(route.id);
const regionColors = [
'#1677ff',
'#13b879',
'#fa8c16',
'#a33cff',
];
const color = regionColors[index % regionColors.length];
return (
<Tag.CheckableTag
key={route.id}
checked={checked}
onChange={nextChecked => {
setDispatchRouteIds(prev =>
nextChecked
? [...prev, route.id]
: prev.filter(id => id !== route.id)
);
if (nextChecked) {
flyToRoute(route.id);
}
}}
style={{
display: 'flex',
alignItems: 'center',
gap: 5,
width: '100%',
minWidth: 0,
height: 40,
boxSizing: 'border-box',
margin: 0,
padding: '0 8px',
color: '#344054',
background: '#fff',
border: '1px solid #dce5f2',
borderRadius: 4,
fontSize: 12,
lineHeight: 'normal',
cursor: 'pointer',
overflow: 'hidden',
}}
>
{/* 自定义复选框 */}
<span
style={{
display: 'inline-flex',
flex: '0 0 12px',
width: 12,
height: 12,
boxSizing: 'border-box',
alignItems: 'center',
justifyContent: 'center',
border: `1px solid ${checked ? color : '#cbd5e1'}`,
borderRadius: 3,
background: checked ? color : '#fff',
color: '#fff',
fontSize: 10,
fontWeight: 700,
}}
>
{checked ? '✓' : ''}
</span>
{/* 路线定位图标 */}
<EnvironmentFilled
style={{
flex: '0 0 auto',
color,
fontSize: 13,
}}
/>
{/* 路线名称 */}
<span
style={{
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: '#344054',
}}
>
{route.workName || route.name}
</span>
</Tag.CheckableTag>
);
})}
</div>
</div>
)}
</div>
{/* 已选路径顺序调节 */}
{dispatchRouteIds.length > 1 && (
<div
style={{
width: '100%',
boxSizing: 'border-box',
padding: 8,
border: '1px solid #e6edf5',
borderRadius: 6,
background: '#fff',
padding: '12px',
background: '#f8faff',
border: '1px dashed #d6e4ff',
borderRadius: 8,
}}
>
{/* 顶部提示文字 */}
<div
style={{
marginBottom: 8,
color: '#8c9bb0',
fontSize: 11,
lineHeight: '16px',
}}
>
选择需要执行任务的路线(可多选)
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 12 }}>
<TrendingUp size={16} style={{ color: '#1677ff' }} />
<span style={{ fontWeight: 600, fontSize: 13, color: '#1d2129' }}>调节执行顺序 (拖拽排序)</span>
<Tooltip title="机器人将按照下方列表从上到下的顺序依次执行路径任务">
<AlertCircle size={14} style={{ color: '#86909c', cursor: 'help' }} />
</Tooltip>
</div>
{/* 路线选择卡片 */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
gap: 8,
width: '100%',
}}
<Reorder.Group
axis="y"
values={dispatchRouteIds}
onReorder={setDispatchRouteIds}
style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 8 }}
>
{regionAvailableRoutes.map((route, index) => {
const checked = dispatchRouteIds.includes(route.id);
const regionColors = [
'#1677ff',
'#13b879',
'#fa8c16',
'#a33cff',
];
const color = regionColors[index % regionColors.length];
return (
<Tag.CheckableTag
key={route.id}
checked={checked}
onChange={nextChecked => {
setDispatchRouteIds(prev =>
nextChecked
? [...prev, route.id]
: prev.filter(id => id !== route.id)
);
if (nextChecked) {
flyToRoute(route.id);
}
}}
{selectedRouteObjects.map((route, idx) => (
<Reorder.Item
key={route.id}
value={route.id}
style={{
cursor: 'grab',
background: '#fff',
border: '1px solid #e6edf5',
borderRadius: 6,
padding: '8px 12px',
display: 'flex',
alignItems: 'center',
gap: 12,
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
}}
whileDrag={{
scale: 1.02,
boxShadow: '0 8px 16px rgba(0,0,0,0.1)',
cursor: 'grabbing',
}}
>
<div
style={{
width: 20,
height: 20,
background: '#1677ff',
color: '#fff',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
gap: 5,
width: '100%',
minWidth: 0,
height: 40,
boxSizing: 'border-box',
margin: 0,
padding: '0 8px',
color: '#344054',
background: '#fff',
border: '1px solid #dce5f2',
borderRadius: 4,
fontSize: 12,
lineHeight: 'normal',
cursor: 'pointer',
overflow: 'hidden',
justifyContent: 'center',
fontSize: 11,
fontWeight: 700,
}}
>
{/* 自定义复选框 */}
<span
style={{
display: 'inline-flex',
flex: '0 0 12px',
width: 12,
height: 12,
boxSizing: 'border-box',
alignItems: 'center',
justifyContent: 'center',
border: `1px solid ${checked ? color : '#cbd5e1'}`,
borderRadius: 3,
background: checked ? color : '#fff',
color: '#fff',
fontSize: 10,
fontWeight: 700,
}}
>
{checked ? '✓' : ''}
</span>
{/* 路线定位图标 */}
<EnvironmentFilled
style={{
flex: '0 0 auto',
color,
fontSize: 13,
}}
/>
{/* 路线名称 */}
<span
style={{
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: '#344054',
}}
>
{route.workName || route.name}
</span>
</Tag.CheckableTag>
);
})}
</div>
{idx + 1}
</div>
<span style={{ flex: 1, fontSize: 13, fontWeight: 500, color: '#344054' }}>
{route.workName || route.name}
</span>
<div style={{ color: '#bfbfbf' }}>
<LayoutGrid size={14} />
</div>
</Reorder.Item>
))}
</Reorder.Group>
</div>
)}
</div>

View File

@@ -1251,6 +1251,7 @@
fixed2Battery: 'Battery /Heading',
fixed2Enable: ' Enable Remote Control',
continueWork: 'Continue Work',
tcp: 'TCP Link',

View File

@@ -1471,6 +1471,8 @@ const zh = {
fixed2OthersControlFixed2: '其他端正在控制',
failedFixed22: '权限校验失败',
continueWork: '继续作业',
noneControl: '无控制权限',