diff --git a/.tmp/采集表_new.doc b/.tmp/采集表_new.doc new file mode 100644 index 0000000..dc21d8d Binary files /dev/null and b/.tmp/采集表_new.doc differ diff --git a/.workbuddy/memory/2026-11-06.md b/.workbuddy/memory/2026-11-06.md new file mode 100644 index 0000000..deadae7 --- /dev/null +++ b/.workbuddy/memory/2026-11-06.md @@ -0,0 +1,10 @@ +# 2026-11-06 + +## 软著《采集表》填写(机器人智慧服务系统) + +- 文件:`C:/Users/15563/Desktop/软著/软著-机器人智慧服务系统/采集表.doc`(实为 docx 内容、扩展名 .doc),两列表格共 20 行项目。 +- 任务:依据《用户操作手册-机器人智慧服务系统-完整版.docx》重写「软件的主要功能」「软件的主要特点」两栏。 +- 结果:功能栏 2252 字(8 条:登录与权限/装备总览/机器人任务/机器人控制/地图编辑/告警中心/实时监控与数据统计/工单联动);特点栏 818 字(8 条:一张图指挥/四通道实时通信/任务全流程闭环/双模遥控/多重安全/告警工单闭环/权限与数据隔离/跨平台免安装)。 +- 原表「软件的主要功能」是广口径描述(含清洗优化、报表中心等手册未展开的模块),本次改为严格对齐手册章节。 +- 脚本:`scripts/fill_collect.py`(ET 解析 document.xml,按「表格行 → 第2个 tc」定位,沿用单元格首个带 rPr 的 run 作字体模板,小标题加粗;可重复执行)。 +- 注意:源文件被 Word 占用时写入 Permission denied,会回退输出到 `.tmp/采集表_new.doc` 并复制为同目录 `采集表-已完善.doc`。 diff --git a/scripts/fill_collect.py b/scripts/fill_collect.py new file mode 100644 index 0000000..1871c2e --- /dev/null +++ b/scripts/fill_collect.py @@ -0,0 +1,240 @@ +# -*- coding: utf-8 -*- +"""填充《采集表》中「软件的主要功能」「软件的主要特点」两栏。 +源文件:C:/Users/15563/Desktop/软著/软著-机器人智慧服务系统/采集表.doc +内容依据:《用户操作手册-机器人智慧服务系统-完整版.docx》 +""" +import zipfile, os, shutil, copy, re, time +from xml.etree import ElementTree as ET + +W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' +def w(t): return '{%s}%s' % (W, t) +ET.register_namespace('w', W) +for pfx, uri in ( + ('w14', 'http://schemas.microsoft.com/office/word/2010/wordml'), + ('mc', 'http://schemas.openxmlformats.org/markup-compatibility/2006'), + ('v', 'urn:schemas-microsoft-com:vml'), + ('wp', 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing'), + ('r', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'), + ('m', 'http://schemas.openxmlformats.org/officeDocument/2006/math'), + ('wps', 'http://schemas.microsoft.com/office/word/2010/wordprocessingShape'), + ('wpg', 'http://schemas.microsoft.com/office/word/2010/wordprocessingGroup'), + ('wp14', 'http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing'), + ('w10', 'urn:schemas-microsoft-com:office:word'), + ('w15', 'http://schemas.microsoft.com/office/word/2012/wordml'), +): + try: ET.register_namespace(pfx, uri) + except Exception: pass + +SRC = r'C:/Users/15563/Desktop/软著/软著-机器人智慧服务系统/采集表.doc' +TMP = r'D:/code/机器人智慧服务系统/.tmp/采集表_new.doc' +os.makedirs(os.path.dirname(TMP), exist_ok=True) + +# ---------------- 内容 ---------------- +FUNC_INTRO = ("本系统是面向光伏电站场景的机器人智能运维平台,采用前后端分离的 B/S 架构," + "以三维电子地图为统一空间底座,把巡检机器人、除草机器人、无人机机场、清洗无人机等" + "智能装备的接入、调度、控制、作业与告警处置集中到统一的 Web 控制台," + "实现“一张图指挥、一套流程闭环”。主要功能包括:") + +FUNC = [ + ("(1)系统登录与权限管理:", + "支持账号密码登录与用户协议确认,未登录访问任意业务页面自动重定向至登录页," + "登录态超时后提示并跳回登录页,重新登录可回到原页面。系统采用 RBAC 三级角色权限" + "(管理员、操作员、观察者),权限差异同时体现在菜单是否可见与按钮是否可操作两个层面;" + "并支持按组织与场站限定数据可见范围,跨场站数据不在列表中呈现。顶部栏提供场站切换、" + "全屏控制、中英文切换、用户头像与退出登录入口。"), + ("(2)装备总览:", + "以五张状态统计卡(无人机机场状态、巡检机器人、除草机器人、清洗无人机、今日任务执行)" + "按场站汇总装备数量、在线台数与任务完成率;提供基于 Cesium 三维地图与场站正射影像的" + "装备协同态势图,叠加设备位置、作业路径、分区、障碍物、返航点等图层,支持图层开关与" + "“定位到机器人”一键聚焦;装备调度列表展示装备名称、类型、当前状态、电量、位置与通讯状态," + "支持刷新、编辑设备 SN(可一键复制)、设备类型与设备别名,并可打开设备参数设置弹窗;" + "设备控制面板展示在线状态、电量、产品类型、SN、激活时间、固件版本,提供控制、回充、返航、" + "生成任务等快捷操作,无人机展示风速/温度/降雨等飞行条件,地面机器人展示累计作业里程、" + "面积与时长;另提供 AI 分析与操作建议、控制日志与告警记录、任务执行时间线,便于回溯与研判。"), + ("(3)机器人任务:", + "顶部六张任务统计卡展示当日作业指标并给出较昨日的变化趋势;机器人路径规划与监控地图" + "实时显示机器人位置、规划路线与历史轨迹,支持控制模式(本地/自动任务/远程)显示与切换、" + "定位图层与视频开关、寻车与返航快捷按钮,以及清空实时轨迹、清空规划区域等视图清理操作;" + "任务下发支持按路线下发(单选,点击路线地图自动飞至该路线)与按区域下发(可多选," + "多条路径可拖拽调节执行顺序)两种方式,任务类型分为立即执行与定时任务,定时任务可按天、" + "按周、按月重复并设置执行日期、执行时间与周期范围,提交前自动校验路径、执行机器人与" + "定时时间;任务列表分任务池、任务执行计划、历史任务三个页签,支持按状态、机器人与日期" + "范围筛选,行内提供暂停、恢复、取消(普通取消/强制取消)、删除与继续作业操作," + "任务状态覆盖待执行、执行中、已暂停、已完成、执行失败、已取消六种。"), + ("(4)机器人控制:", + "设备状态面板以列表形式实时刷新电机温度、电流、电压、电量、速度、航向与姿态等十四项" + "运行参数,每项标注单位与含义;实时视频监控通过 WebRTC 拉取机载摄像头画面,支持前视角、" + "左视角、右视角、后视角与全景五个视角切换、播放与停止控制,后视角自动叠加倒车辅助线;" + "设备态势地图实时显示航向角、横滚角与俯仰角,支持清空轨迹与定位到机器人;手柄控制支持" + "游戏手柄按键映射、控制器连接状态显示以及请求控制、释放控制与权限状态标签;方向盘控制" + "提供前进、后退、左转、右转与中键停止的屏幕虚拟方向盘,行驶速度可在 0–100% 之间调节," + "控制指令以 100 毫秒为周期下发,并集成点火/熄火、底盘升起/下降、割刀加速/减速等执行机构" + "按钮;功能控制区提供五项辅助开关与红色急停按钮,急停触发后立即下发停车指令并自动补发" + "复位帧。"), + ("(5)地图编辑:", + "提供返航点、分区、障碍物点、路径生成四种绘制模式,以及点选工具、机器人选择、撤销与清空" + "操作,地图区实时显示操作提示与已选点位数量;返航点支持在地图上标注多点、核对坐标后保存," + "并可在已配置列表中编辑与删除;分区需不少于三个点位闭合边界,保存时可填写分区名称、" + "分区编码、默认线路与绑定线路(可多选),用于按区域编排任务;障碍物点用于标记支架基础、" + "水塘、电杆等固定障碍,标记后路径规划与自动作业自动绕开;路径生成可配置路径名称、" + "规划类型与作业模式,保存后进入预设路线列表,供任务下发页面直接引用。"), + ("(6)告警中心:", + "顶部四张统计卡展示今日总告警、今日严重告警、今日待处理与今日已关闭数量并附昨日对比与" + "升降趋势,下方以折线图、饼图与柱状图分别展示告警趋势、告警类型比例与告警状态统计;" + "筛选栏支持按告警级别、处理状态、设备 ID 与时间范围组合查询,并提供重置、查询与结果导出;" + "告警列表展示告警编号、标题、级别、类型、来源设备、发生时间、处理状态等字段;" + "点击查看可从右侧展开告警详情抽屉,展示告警完整描述并提供联系处理人与一键生成工单入口," + "填写处理状态、处理结果、根原因与处理备注后确认处理,告警由待处理流转为已关闭;" + "告警分提示、一般、重要、严重四级,支持修改告警标题、级别与类型并保留操作记录。"), + ("(7)实时监控与数据统计:", + "基于 Cesium 三维地图与 MQTT 遥测通道,对设备位置、状态与运动轨迹进行实时渲染与回放," + "支持电子围栏与场站可视化监控;历史任务、控制日志与告警记录长期留存并支持多维查询与" + "趋势分析,运营数据可导出为报表,为作业计划编排、绩效考核与管理决策提供数据支撑。"), + ("(8)工单联动处置:", + "对需要现场处置的告警可一键转为运维工单,系统自动带出告警编号、设备与告警描述," + "补充处理人与期望完成时间后提交;工单按待处理、处理中、待验收、已完成状态流转," + "验收关闭后对应告警同步标记为已关闭,形成“发现—派单—处置—验收”的完整闭环。"), +] + +FEAT_INTRO = "本系统在架构设计与人机交互上具备以下特点:" +FEAT = [ + ("(1)一张图指挥:", + "以 Cesium 三维地图与场站正射影像为统一空间底座,将所有可动作装备、航线、分区、障碍物与" + "实时位置叠加在同一张态势图上,图层可独立开关、目标可一键定位,运维人员无需在多系统间" + "切换即可掌握全场站态势。"), + ("(2)四通道实时通信:", + "HTTP/REST 承载业务接口,MQTT over WebSocket 上行设备遥测,WebSocket 下行控制指令," + "WebRTC 回传机载视频,四条通道职责清晰、互不阻塞,兼顾业务可靠性与遥控低延迟。"), + ("(3)任务全流程闭环:", + "从地图要素配置、路线与区域编排、立即或定时下发,到执行监控、异常处置与统计回溯," + "作业全链路可追溯;失败或取消的任务可基于原编排“继续作业”,无需重新规划路径。"), + ("(4)双模遥控与低延迟指令:", + "同时支持游戏手柄与屏幕虚拟方向盘两种操控方式,控制指令以 100 毫秒为周期打包下发," + "结合速度分级调节,既满足精细作业的稳定操控,也支持突发情况下的快速接管。"), + ("(5)多重安全保护机制:", + "提供急停(自动补发复位帧)、控制权独占、管理员强制抢占、会话保活、避障开关、倾斜保护、" + "低电量自动返航等多重保护,控制权申请与释放过程全程可见,避免多操作员指令冲突与设备" + "被长期占用在远程模式。"), + ("(6)告警与工单联动闭环:", + "告警处理结果与工单状态双向同步,告警可一键转工单,工单验收后告警自动关闭," + "使异常处置从“被动发现”转为“流程闭环、责任到人”。"), + ("(7)精细化权限与数据隔离:", + "三级 RBAC 角色权限同时约束菜单可见性与按钮可用性,并按组织与场站隔离数据可见范围," + "满足多场站、多班组协同运维的管理要求。"), + ("(8)跨平台免安装部署:", + "前后端分离的 B/S 架构,前端基于 React 19 + TypeScript + Vite 6 构建单页应用," + "服务端可在 Windows/Linux 环境部署,客户端仅需现代浏览器即可访问,无需安装任何插件;" + "界面支持中英文双语切换。"), +] + +# ---------------- 写入 ---------------- +zin = zipfile.ZipFile(SRC) +doc = ET.fromstring(zin.read('word/document.xml')) + + +def cell_text(tc): + return ''.join(t.text or '' for t in tc.iter(w('t'))) + + +def fill_cell(tc, intro, items): + """用 intro + 分条内容重写单元格,沿用原单元格字体与段落格式。""" + paras = tc.findall(w('p')) + tpl_p = paras[0] + pPr = tpl_p.find(w('pPr')) + # 取第一个带文字 run 的 rPr 作为字体模板 + rPr = None + for r in tpl_p.findall(w('r')): + rp = r.find(w('rPr')) + if rp is not None: + rPr = rp + break + if rPr is None: + for r in tpl_p.iter(w('r')): + rp = r.find(w('rPr')) + if rp is not None: + rPr = rp + break + # 清空原有段落 + for p in paras: + tc.remove(p) + + def add(text, bold=False): + p = ET.SubElement(tc, w('p')) + if pPr is not None: + newpPr = copy.deepcopy(pPr) + # 去重:段落级段落标记 rPr 保留 + p.append(newpPr) + r = ET.SubElement(p, w('r')) + if rPr is not None: + newrPr = copy.deepcopy(rPr) + if bold: + b = ET.SubElement(newrPr, w('b')) + r.append(newrPr) + t = ET.SubElement(r, w('t')) + t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve') + t.text = text + return p + + add(intro) + for head, body in items: + # 每条一段:小标题加粗 + 正文 + p = ET.SubElement(tc, w('p')) + if pPr is not None: + p.append(copy.deepcopy(pPr)) + r1 = ET.SubElement(p, w('r')) + if rPr is not None: + rp = copy.deepcopy(rPr) + ET.SubElement(rp, w('b')) + r1.append(rp) + t1 = ET.SubElement(r1, w('t')) + t1.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve') + t1.text = head + r2 = ET.SubElement(p, w('r')) + if rPr is not None: + r2.append(copy.deepcopy(rPr)) + t2 = ET.SubElement(r2, w('t')) + t2.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve') + t2.text = body + + +done = [] +for tbl in doc.iter(w('tbl')): + for tr in tbl.findall(w('tr')): + tcs = tr.findall(w('tc')) + if len(tcs) < 2: + continue + label = cell_text(tcs[0]).strip().replace(' ', '') + if label == '软件的主要功能': + fill_cell(tcs[1], FUNC_INTRO, FUNC) + done.append('功能') + elif label == '软件的主要特点': + fill_cell(tcs[1], FEAT_INTRO, FEAT) + done.append('特点') + +print('已填充:', done) +assert len(done) == 2, '未找到目标单元格' + +new_doc = ET.tostring(doc, encoding='UTF-8', xml_declaration=True) + +zout = zipfile.ZipFile(TMP, 'w', zipfile.ZIP_DEFLATED) +for item in zin.namelist(): + if item == 'word/document.xml': + continue + zout.writestr(item, zin.read(item)) +zout.writestr('word/document.xml', new_doc) +zout.close() +zin.close() +print('生成:', TMP, os.path.getsize(TMP)) + +# 尝试覆盖源文件 +ok = False +for i in range(3): + try: + shutil.copyfile(TMP, SRC) + ok = True + break + except Exception as e: + print('写入失败', i, e) + time.sleep(1.5) +print('覆盖源文件:', '成功' if ok else '源文件被占用,已保留新文件') +print('OUT=' + (SRC if ok else TMP)) diff --git a/src/components/devices/deviceController.js b/src/components/devices/deviceController.js index aa889f9..6d74e34 100644 --- a/src/components/devices/deviceController.js +++ b/src/components/devices/deviceController.js @@ -144,6 +144,9 @@ export default class DeviceController { hasAlarm: !!it?.ha || !!it?.hasAlarm, updateTime: dayjs().format('YYYY-MM-DD HH:mm:ss'), controlMode: it?.cm ?? it?.controlMode, + progress: it?.p ?? it?.progress, + workName: it?.wk ?? null, + }; }); console.log('[DeviceController] 收到全量设备状态', next); diff --git a/src/pages/TaskMonitorPage.tsx b/src/pages/TaskMonitorPage.tsx index 0e4d8e1..45a6db2 100644 --- a/src/pages/TaskMonitorPage.tsx +++ b/src/pages/TaskMonitorPage.tsx @@ -79,6 +79,8 @@ const TaskMonitorPage: React.FC = () => { const [realtimeMap, setRealtimeMap] = useState({}); // 当前选中的车辆:列表高亮与地图信息浮窗共用此状态 const [selectedDeviceSerial, setSelectedDeviceSerial] = useState(null); + // 实时画面视角:与 DeviceStatusPage 保持一致(front/back/left/right) + const [videoView, setVideoView] = useState<'front' | 'back' | 'left' | 'right'>('front'); const [focusLocation, setFocusLocation] = useState(null); const [messageApi, contextHolder] = message.useMessage(); const [taskPoolList, setTaskPoolList] = useState([]); @@ -87,10 +89,40 @@ const TaskMonitorPage: React.FC = () => { const controllerRef = useRef(null); const mapRef = useRef(null); + // 从全量实时数据(mapPush)里按设备标识匹配记录,realtimeMap 结构为 { [设备ID]: 记录 } + const pickRealtimeRecord = (rtMap: any, device: any) => { + for (const key of [device?.serialNumber, device?.device_sn, device?.sn, device?.id, device?.deviceId]) { + if (key == null || String(key) === '') continue; + const hit = rtMap?.[String(key)]; + if (hit) return hit; + } + return null; + }; + + // 电量优先取实时数据,实时没有时回退到设备结构里的 lastRunningStatus / locationMessage + const getDeviceBattery = (device: any): number | undefined => { + const rt = pickRealtimeRecord(realtimeMap, device); + const candidates = [rt?.battery, device?.lastRunningStatus?.batteryLevel, device?.lastRunningStatus?.battery, device?.locationMessage?.batteryLevel, device?.locationMessage?.battery]; + for (const value of candidates) { + if (value === undefined || value === null || value === '') continue; + const num = Number(value); + if (Number.isFinite(num)) return num; + } + return undefined; + }; + + // 供 WebSocket 回调始终调用最新的 fetchData + const fetchDataRef = useRef<() => any>(() => { }); + // 初始化实时数据控制器 useEffect(() => { if (!userInfo || !stationId) return; const controller = new DeviceController(userInfo, messageApi, stationId); + // 与 RobotTaskPage 保持一致:任务状态发生变化后重新拉取接口数据 + const refreshData = () => fetchDataRef.current?.(); + controller.onIsFinished = refreshData; + controller.onIsExecuting = refreshData; + controller.deviceStatusChangedMsg = refreshData; controller.initMapPush((rtMap: any) => setRealtimeMap(rtMap || {})); controllerRef.current = controller; return () => { @@ -136,6 +168,8 @@ const TaskMonitorPage: React.FC = () => { } }; + fetchDataRef.current = fetchData; + useEffect(() => { @@ -203,13 +237,21 @@ const TaskMonitorPage: React.FC = () => { const deviceStats = useMemo(() => { const total = devices.length; - const online = devices.filter(d => d.onlineStatus === 1).length; + // 在线数以实时数据为准:当前设备列表中存在实时记录的即在线 + const online = devices.filter(d => !!pickRealtimeRecord(realtimeMap, d)).length; // feStatus: 1=作业中, 2=空闲 const working = devices.filter(d => String(d.feStatus) === '1').length; const idle = devices.filter(d => String(d.feStatus) === '2').length; - const alarm = alarmList.length; - return { total, online, working, idle, alarm }; - }, [devices, realtimeMap, alarmList]); + console.log(realtimeMap, "realtimeMap") + // 告警车辆数量以实时数据里的 hasAlarm 为准 + const alarmDevices = devices.filter(d => !!pickRealtimeRecord(realtimeMap, d)?.hasAlarm); + console.log(alarmDevices, "alarmDevices") + const alarm = alarmDevices.length; + // 与 DeviceStatusPage 一致:有实时数据即在线,兜底接口 onlineStatus + const alarmOnline = alarmDevices.filter(d => pickRealtimeRecord(realtimeMap, d) || d?.onlineStatus == 1).length; + const alarmOffline = total - online; + return { total, online, working, idle, alarm, alarmOnline, alarmOffline }; + }, [devices, realtimeMap]); @@ -218,6 +260,7 @@ const TaskMonitorPage: React.FC = () => { [devices, selectedDeviceSerial] ); const selectedRealtime: any = selectedDevice ? (realtimeMap[selectedDevice.serialNumber] || {}) : {}; + const selectedBattery = getDeviceBattery(selectedDevice); const trendData = useMemo(() => { const countMap: Record = {}; @@ -388,7 +431,7 @@ const TaskMonitorPage: React.FC = () => { {[ { label: '机器总数', value: deviceStats.total, online: deviceStats.online, offline: deviceStats.total - deviceStats.online, icon: , bg: '#e6f7ff' }, { label: '在线总数', value: deviceStats.online, subLeft: { label: '空闲', value: deviceStats.idle, color: '#1890ff' }, subRight: { label: '工作中', value: deviceStats.working, color: '#52c41a' }, icon: , bg: '#f6ffed' }, - { label: '告警车辆', value: deviceStats.alarm, online: deviceStats.alarm, offline: 0, icon: , bg: '#fff1f0' } + { label: '告警车辆', value: deviceStats.alarm, online: deviceStats.online, offline: deviceStats.alarmOffline, icon: , bg: '#fff1f0' } ].map((item, index) => (
@@ -442,14 +485,16 @@ const TaskMonitorPage: React.FC = () => {
-
当前状态{selectedRealtime.status === 'EXECUTING' ? '执行中' : '待命'}
-
剩余电量{selectedRealtime.battery ?? 85}%
-
当前任务{selectedRealtime.currentTaskName || '常规巡检'}
-
设备类型{selectedDevice.deviceTypeName || selectedDevice.deviceType || '车辆'}
-
任务进度{selectedRealtime.taskProgress ?? 0}%
+
当前状态{selectedRealtime.status === '1' ? '进行中' : selectedRealtime.status === '2' ? '待命' : '离线'}
+
剩余电量{selectedBattery ?? 85}%
+
当前任务{selectedRealtime.workName || "无"}
+ {/*
设备类型{selectedDevice.deviceTypeName || selectedDevice.deviceType || '车辆'}
*/} +
任务进度{selectedRealtime.progress ?? 0}%
)} + {/*{rt.status == '1' ? '进行中' : rt.status == '2' ? '待命' : '离线'}*/} + {/* 地图上的图例 */}
@@ -477,6 +522,7 @@ const TaskMonitorPage: React.FC = () => { dataSource={devices.filter(d => d.deviceType !== 'CAMERA')} renderItem={(item: any) => { const rt = realtimeMap[item.serialNumber] || {}; + const battery = getDeviceBattery(item); return ( {
{item.deviceAlias || item.deviceName} - - {rt.status === 'EXECUTING' ? '进行中' : '待命'} + + {rt.status == '1' ? '进行中' : rt.status == '2' ? '待命' : '离线'} +
{ color: '#8c8c8c', minWidth: 0, }} - > 任务: {rt.currentTaskName || '常规巡检'} + > + 任务: {rt.workName || '无'} { }} > - {rt.battery ?? 85}% + {battery ?? 85}%
- + +
@@ -557,14 +609,19 @@ const TaskMonitorPage: React.FC = () => { }}> d.deviceType === 'ROBOT' || d.deviceType === 'CLEANING')?.serialNumber} - idx={1} + idx={videoView === 'back' ? 2 : videoView === 'left' ? 3 : videoView === 'right' ? 4 : 1} className="w-full h-full object-cover" />
- {['前视', '后视', '左视', '右视'].map((v, i) => ( - + {[ + { key: 'front', label: '前视' }, + { key: 'back', label: '后视' }, + { key: 'left', label: '左视' }, + { key: 'right', label: '右视' }, + ].map((v) => ( + ))}