mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2025-12-14 13:14:39 +00:00
* Cleanup registry to be easy-understanding (#76) * delete deprecated mock devices * rename categories * combine chromatographic devices * rename rviz simulation nodes * organic virtual devices * parse vessel_id * run registry completion before merge --------- Co-authored-by: Xuwznln <18435084+Xuwznln@users.noreply.github.com> * fix: workstation handlers and vessel_id parsing * fix: working dir error when input config path feat: report publish topic when error * modify default discovery_interval to 15s * feat: add trace log level * feat: 添加ChinWe设备控制类,支持串口通信和电机控制功能 (#79) * fix: drop_tips not using auto resource select * fix: discard_tips error * fix: discard_tips * fix: prcxi_res * add: prcxi res fix: startup slow * feat: workstation example * fix pumps and liquid_handler handle * feat: 优化protocol node节点运行日志 * fix all protocol_compilers and remove deprecated devices * feat: 新增use_remote_resource参数 * fix and remove redundant info * bugfixes on organic protocols * fix filter protocol * fix protocol node * 临时兼容错误的driver写法 * fix: prcxi import error * use call_async in all service to avoid deadlock * fix: figure_resource * Update recipe.yaml * add workstation template and battery example * feat: add sk & ak * update workstation base * Create workstation_architecture.md * refactor: workstation_base 重构为仅含业务逻辑,通信和子设备管理交给 ProtocolNode * refactor: ProtocolNode→WorkstationNode * Add:msgs.action (#83) * update: Workstation dev 将版本号从 0.10.3 更新为 0.10.4 (#84) * Add:msgs.action * update: 将版本号从 0.10.3 更新为 0.10.4 * simplify resource system * uncompleted refactor * example for use WorkstationBase * feat: websocket * feat: websocket test * feat: workstation example * feat: action status * fix: station自己的方法注册错误 * fix: 还原protocol node处理方法 * fix: build * fix: missing job_id key * ws test version 1 * ws test version 2 * ws protocol * 增加物料关系上传日志 * 增加物料关系上传日志 * 修正物料关系上传 * 修复工站的tracker实例追踪失效问题 * 增加handle检测,增加material edge关系上传 * 修复event loop错误 * 修复edge上报错误 * 修复async错误 * 更新schema的title字段 * 主机节点信息等支持自动刷新 * 注册表编辑器 * 修复status密集发送时,消息出错 * 增加addr参数 * fix: addr param * fix: addr param * 取消labid 和 强制config输入 * Add action definitions for LiquidHandlerSetGroup and LiquidHandlerTransferGroup - Created LiquidHandlerSetGroup.action with fields for group name, wells, and volumes. - Created LiquidHandlerTransferGroup.action with fields for source and target group names and unit volume. - Both actions include response fields for return information and success status. * Add LiquidHandlerSetGroup and LiquidHandlerTransferGroup actions to CMakeLists * Add set_group and transfer_group methods to PRCXI9300Handler and update liquid_handler.yaml * result_info改为字典类型 * 新增uat的地址替换 * runze multiple pump support (cherry picked from commit49354fcf39) * remove runze multiple software obtainer (cherry picked from commit8bcc92a394) * support multiple backbone (cherry picked from commit4771ff2347) * Update runze pump format * Correct runze multiple backbone * Update runze_multiple_backbone * Correct runze pump multiple receive method. * Correct runze pump multiple receive method. * 对于PRCXI9320的transfer_group,一对多和多对多 * 移除MQTT,更新launch文档,提供注册表示例文件,更新到0.10.5 * fix import error * fix dupe upload registry * refactor ws client * add server timeout * Fix: run-column with correct vessel id (#86) * fix run_column * Update run_column_protocol.py (cherry picked from commite5aa4d940a) * resource_update use resource_add * 新增版位推荐功能 * 重新规定了版位推荐的入参 * update registry with nested obj * fix protocol node log_message, added create_resource return value * fix protocol node log_message, added create_resource return value * try fix add protocol * fix resource_add * 修复移液站错误的aspirate注册表 * Feature/xprbalance-zhida (#80) * feat(devices): add Zhida GC/MS pretreatment automation workstation * feat(devices): add mettler_toledo xpr balance * balance * 重新补全zhida注册表 * PRCXI9320 json * PRCXI9320 json * PRCXI9320 json * fix resource download * remove class for resource * bump version to 0.10.6 * 更新所有注册表 * 修复protocolnode的兼容性 * 修复protocolnode的兼容性 * Update install md * Add Defaultlayout * 更新物料接口 * fix dict to tree/nested-dict converter * coin_cell_station draft * refactor: rename "station_resource" to "deck" * add standardized BIOYOND resources: bottle_carrier, bottle * refactor and add BIOYOND resources tests * add BIOYOND deck assignment and pass all tests * fix: update resource with correct structure; remove deprecated liquid_handler set_group action * feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92) * feat: 新威电池测试系统驱动与注册文件 * feat: bring neware driver & battery.json into workstation_dev_YB2 * add bioyond studio draft * bioyond station with communication init and resource sync * fix bioyond station and registry * fix: update resource with correct structure; remove deprecated liquid_handler set_group action * frontend_docs * create/update resources with POST/PUT for big amount/ small amount data * create/update resources with POST/PUT for big amount/ small amount data * refactor: add itemized_carrier instead of carrier consists of ResourceHolder * create warehouse by factory func * update bioyond launch json * add child_size for itemized_carrier * fix bioyond resource io * Workstation templates: Resources and its CRUD, and workstation tasks (#95) * coin_cell_station draft * refactor: rename "station_resource" to "deck" * add standardized BIOYOND resources: bottle_carrier, bottle * refactor and add BIOYOND resources tests * add BIOYOND deck assignment and pass all tests * fix: update resource with correct structure; remove deprecated liquid_handler set_group action * feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92) * feat: 新威电池测试系统驱动与注册文件 * feat: bring neware driver & battery.json into workstation_dev_YB2 * add bioyond studio draft * bioyond station with communication init and resource sync * fix bioyond station and registry * create/update resources with POST/PUT for big amount/ small amount data * refactor: add itemized_carrier instead of carrier consists of ResourceHolder * create warehouse by factory func * update bioyond launch json * add child_size for itemized_carrier * fix bioyond resource io --------- Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com> Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com> * 更新物料接口 * Workstation dev yb2 (#100) * Refactor and extend reaction station action messages * Refactor dispensing station tasks to enhance parameter clarity and add batch processing capabilities - Updated `create_90_10_vial_feeding_task` to include detailed parameters for 90%/10% vial feeding, improving clarity and usability. - Introduced `create_batch_90_10_vial_feeding_task` for batch processing of 90%/10% vial feeding tasks with JSON formatted input. - Added `create_batch_diamine_solution_task` for batch preparation of diamine solution, also utilizing JSON formatted input. - Refined `create_diamine_solution_task` to include additional parameters for better task configuration. - Enhanced schema descriptions and default values for improved user guidance. * 修复to_plr_resources * add update remove * 支持选择器注册表自动生成 支持转运物料 * 修复资源添加 * 修复transfer_resource_to_another生成 * 更新transfer_resource_to_another参数,支持spot入参 * 新增test_resource动作 * fix host_node error * fix host_node test_resource error * fix host_node test_resource error * 过滤本地动作 * 移动内部action以兼容host node * 修复同步任务报错不显示的bug * feat: 允许返回非本节点物料,后面可以通过decoration进行区分,就不进行warning了 * update todo * modify bioyond/plr converter, bioyond resource registry, and tests * pass the tests * update todo * add conda-pack-build.yml * add auto install script for conda-pack-build.yml (cherry picked from commit172599adcf) * update conda-pack-build.yml * update conda-pack-build.yml * update conda-pack-build.yml * update conda-pack-build.yml * update conda-pack-build.yml * Add version in __init__.py Update conda-pack-build.yml Add create_zip_archive.py * Update conda-pack-build.yml * Update conda-pack-build.yml (with mamba) * Update conda-pack-build.yml * Fix FileNotFoundError * Try fix 'charmap' codec can't encode characters in position 16-23: character maps to <undefined> * Fix unilabos msgs search error * Fix environment_check.py * Update recipe.yaml * Update registry. Update uuid loop figure method. Update install docs. * Fix nested conda pack * Fix one-key installation path error * Bump version to 0.10.7 * Workshop bj (#99) * Add LaiYu Liquid device integration and tests Introduce LaiYu Liquid device implementation, including backend, controllers, drivers, configuration, and resource files. Add hardware connection, tip pickup, and simplified test scripts, as well as experiment and registry configuration for LaiYu Liquid. Documentation and .gitignore for the device are also included. * feat(LaiYu_Liquid): 重构设备模块结构并添加硬件文档 refactor: 重新组织LaiYu_Liquid模块目录结构 docs: 添加SOPA移液器和步进电机控制指令文档 fix: 修正设备配置中的最大体积默认值 test: 新增工作台配置测试用例 chore: 删除过时的测试脚本和配置文件 * add * 重构: 将 LaiYu_Liquid.py 重命名为 laiyu_liquid_main.py 并更新所有导入引用 - 使用 git mv 将 LaiYu_Liquid.py 重命名为 laiyu_liquid_main.py - 更新所有相关文件中的导入引用 - 保持代码功能不变,仅改善命名一致性 - 测试确认所有导入正常工作 * 修复: 在 core/__init__.py 中添加 LaiYuLiquidBackend 导出 - 添加 LaiYuLiquidBackend 到导入列表 - 添加 LaiYuLiquidBackend 到 __all__ 导出列表 - 确保所有主要类都可以正确导入 * 修复大小写文件夹名字 * 电池装配工站二次开发教程(带目录)上传至dev (#94) * 电池装配工站二次开发教程 * Update intro.md * 物料教程 * 更新物料教程,json格式注释 * Update prcxi driver & fix transfer_liquid mix_times (#90) * Update prcxi driver & fix transfer_liquid mix_times * fix: correct mix_times type * Update liquid_handler registry * test: prcxi.py * Update registry from pr * fix ony-key script not exist * clean files --------- Co-authored-by: Junhan Chang <changjh@dp.tech> Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com> Co-authored-by: Guangxin Zhang <guangxin.zhang.bio@gmail.com> Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com> Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com> Co-authored-by: LccLink <1951855008@qq.com> Co-authored-by: lixinyu1011 <61094742+lixinyu1011@users.noreply.github.com> Co-authored-by: shiyubo0410 <shiyubo@dp.tech>
2379 lines
67 KiB
HTML
2379 lines
67 KiB
HTML
{% extends "base.html" %} {% block title %}UniLab System Status{% endblock %} {%
|
||
block header %}UniLab System Status{% endblock %} {% block top_info %}
|
||
<!-- 页面导航 -->
|
||
<div class="nav-tabs" style="margin-bottom: 15px">
|
||
<a href="/" class="nav-tab" target="_blank">主页</a>
|
||
<a
|
||
href="/status"
|
||
class="nav-tab"
|
||
style="background-color: #4caf50; color: white"
|
||
target="_blank"
|
||
>状态</a
|
||
>
|
||
<a href="/registry-editor" class="nav-tab" target="_blank">注册表编辑</a>
|
||
</div>
|
||
|
||
<!-- 系统模式显示 -->
|
||
<div class="system-mode-banner">
|
||
<div
|
||
class="mode-indicator {% if is_host_mode %}host-mode{% else %}slave-mode{% endif %}"
|
||
>
|
||
系统模式:
|
||
<strong
|
||
>{{ "主机模式 (HOST)" if is_host_mode else "从机模式 (SLAVE)" }}</strong
|
||
>
|
||
</div>
|
||
</div>
|
||
|
||
{% if registry_info %}
|
||
<div class="registry-info">
|
||
{% if registry_info.paths %}
|
||
<div class="registry-path">
|
||
<strong>注册表路径:</strong>
|
||
<ul class="path-list">
|
||
{% for path in registry_info.paths %}
|
||
<li>
|
||
<span class="path">{{ path }}</span>
|
||
<span
|
||
class="folder-link"
|
||
onclick="openFolder('{{ path }}')"
|
||
title="打开文件夹"
|
||
>📁</span
|
||
>
|
||
</li>
|
||
{% endfor %}
|
||
</ul>
|
||
</div>
|
||
{% endif %} {% if registry_info.devices_paths %}
|
||
<div class="registry-path">
|
||
<strong>设备目录:</strong>
|
||
<ul class="path-list">
|
||
{% for path in registry_info.devices_paths %}
|
||
<li>
|
||
<span class="path">{{ path }}</span>
|
||
<span
|
||
class="folder-link"
|
||
onclick="openFolder('{{ path }}')"
|
||
title="打开文件夹"
|
||
>📁</span
|
||
>
|
||
</li>
|
||
{% endfor %}
|
||
</ul>
|
||
</div>
|
||
{% endif %} {% if registry_info.device_comms_paths %}
|
||
<div class="registry-path">
|
||
<strong>设备通信目录:</strong>
|
||
<ul class="path-list">
|
||
{% for path in registry_info.device_comms_paths %}
|
||
<li>
|
||
<span class="path">{{ path }}</span>
|
||
<span
|
||
class="folder-link"
|
||
onclick="openFolder('{{ path }}')"
|
||
title="打开文件夹"
|
||
>📁</span
|
||
>
|
||
</li>
|
||
{% endfor %}
|
||
</ul>
|
||
</div>
|
||
{% endif %} {% if registry_info.resources_paths %}
|
||
<div class="registry-path">
|
||
<strong>资源目录:</strong>
|
||
<ul class="path-list">
|
||
{% for path in registry_info.resources_paths %}
|
||
<li>
|
||
<span class="path">{{ path }}</span>
|
||
<span
|
||
class="folder-link"
|
||
onclick="openFolder('{{ path }}')"
|
||
title="打开文件夹"
|
||
>📁</span
|
||
>
|
||
</li>
|
||
{% endfor %}
|
||
</ul>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
<div class="nav-tabs">
|
||
{% if is_host_mode and host_node_info.available %}
|
||
<a href="#host-node-section" class="nav-tab">主机节点</a>
|
||
{% endif %}
|
||
<a href="#online-devices-section" class="nav-tab">本地设备</a>
|
||
<a href="#devices-section" class="nav-tab">设备类型</a>
|
||
<a href="#resources-section" class="nav-tab">资源类型</a>
|
||
<a href="#modules-section" class="nav-tab">转换器模块</a>
|
||
</div>
|
||
{% endblock %} {% block content %}
|
||
<!-- 主机节点信息部分 -->
|
||
{% if is_host_mode and host_node_info.available %}
|
||
<div class="card" id="host-node-section">
|
||
<h2>主机节点信息</h2>
|
||
|
||
<!-- 主机控制的设备 -->
|
||
<div class="host-section">
|
||
<h3>
|
||
已管理设备
|
||
<span class="count-badge">{{ host_node_info.devices|length }}</span>
|
||
</h3>
|
||
<table class="responsive-table">
|
||
<tr>
|
||
<th>设备ID</th>
|
||
<th>命名空间</th>
|
||
<th>机器名称</th>
|
||
<th>状态</th>
|
||
</tr>
|
||
{% for device_id, device_info in host_node_info.devices.items() %}
|
||
<tr>
|
||
<td>{{ device_id }}</td>
|
||
<td>{{ device_info.namespace }}</td>
|
||
<td>{{ device_info.machine_name }}</td>
|
||
<td>
|
||
<span class="status-badge online"
|
||
>{{ "在线" if device_info.is_online else "离线" }}</span
|
||
>
|
||
</td>
|
||
</tr>
|
||
{% else %}
|
||
<tr>
|
||
<td colspan="4" class="empty-state">没有发现已管理的设备</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</table>
|
||
</div>
|
||
|
||
<!-- 主机的动作客户端 -->
|
||
<div class="host-section">
|
||
<h3>
|
||
动作客户端
|
||
<span class="count-badge"
|
||
>{{ host_node_info.action_clients|length }}</span
|
||
>
|
||
</h3>
|
||
<table class="responsive-table collapsible-table">
|
||
<h4>已接纳动作:</h4>
|
||
<table class="inner-table">
|
||
<tr>
|
||
<th>话题</th>
|
||
<th>类型</th>
|
||
<th></th>
|
||
</tr>
|
||
{% for action_name, action_info in host_node_info.action_clients.items()
|
||
%}
|
||
<tr
|
||
class="action-row collapsible-sub-row"
|
||
data-target="action-cmd-{{ loop.index }}-{{ device_loop_index }}"
|
||
>
|
||
<td>{{ action_name }}</td>
|
||
<td>{{ action_info.type_name }}</td>
|
||
<td><span class="toggle-sub-indicator">▼</span></td>
|
||
</tr>
|
||
<tr
|
||
id="action-cmd-{{ loop.index }}-{{ device_loop_index }}"
|
||
class="cmd-row"
|
||
style="display: none"
|
||
>
|
||
<td colspan="5">
|
||
<div class="cmd-block">
|
||
<strong>发送命令:</strong>
|
||
<div class="cmd-line">
|
||
<pre>
|
||
ros2 action send_goal {{ action_info.action_path }} {{ action_info.type_name_convert }} "{{ action_info.goal_info }}"</pre
|
||
>
|
||
<button
|
||
class="copy-btn"
|
||
onclick="copyToClipboard(this.previousElementSibling.textContent, event)"
|
||
>
|
||
复制
|
||
</button>
|
||
</div>
|
||
|
||
<p class="goal-tip">提示: 根据目标结构修改命令参数</p>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</table>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- 主机已订阅的主题 -->
|
||
<div class="host-section">
|
||
<h3>
|
||
已订阅主题
|
||
<span class="count-badge"
|
||
>{{ host_node_info.subscribed_topics|length }}</span
|
||
>
|
||
</h3>
|
||
<div class="topics-container">
|
||
{% if host_node_info.subscribed_topics %}
|
||
<div class="topics-list">
|
||
{% for topic in host_node_info.subscribed_topics %}
|
||
<div class="topic-item">
|
||
<span class="topic-name">{{ topic }}</span>
|
||
<button
|
||
class="copy-btn small"
|
||
onclick="copyToClipboard('{{ topic }}', event)"
|
||
title="复制主题名"
|
||
>
|
||
复制
|
||
</button>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<div class="empty-state">没有发现已订阅的主题</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 设备状态 -->
|
||
{% if host_node_info.device_status %}
|
||
<div class="host-section">
|
||
<h3>设备状态</h3>
|
||
<table class="responsive-table">
|
||
<tr>
|
||
<th>设备ID</th>
|
||
<th>属性</th>
|
||
<th>值</th>
|
||
<th>最后更新</th>
|
||
</tr>
|
||
{% for device_id, properties in host_node_info.device_status.items() %} {%
|
||
for prop_name, prop_value in properties.items() %}
|
||
<tr>
|
||
{% if loop.first %}
|
||
<td rowspan="{{ properties|length }}">{{ device_id }}</td>
|
||
{% endif %}
|
||
<td>{{ prop_name }}</td>
|
||
<td>{{ prop_value }}</td>
|
||
<td>
|
||
{% if device_id in host_node_info.device_status_timestamps and
|
||
prop_name in host_node_info.device_status_timestamps[device_id] %} {%
|
||
set ts_info =
|
||
host_node_info.device_status_timestamps[device_id][prop_name] %} {% if
|
||
ts_info.elapsed >= 0 %}
|
||
<span class="timestamp" title="{{ ts_info.timestamp }}"
|
||
>{{ ts_info.elapsed }} 秒前</span
|
||
>
|
||
{% else %}
|
||
<span class="timestamp not-updated">未更新</span>
|
||
{% endif %} {% else %}
|
||
<span class="timestamp not-updated">无数据</span>
|
||
{% endif %}
|
||
</td>
|
||
</tr>
|
||
{% endfor %} {% else %}
|
||
<tr>
|
||
<td colspan="4" class="empty-state">没有设备状态数据</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</table>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- 当前中断设备部分 -->
|
||
<div class="card" id="online-devices-section">
|
||
<h2>Local Devices</h2>
|
||
<table class="responsive-table">
|
||
<tr>
|
||
<th>Device ID</th>
|
||
<th>节点名称</th>
|
||
<th>命名空间</th>
|
||
<th>机器名称</th>
|
||
<th>状态项</th>
|
||
<th>动作数</th>
|
||
</tr>
|
||
{% for device_id, device_info in ros_node_info.registered_devices.items() %}
|
||
{% set device_loop_index = loop.index %}
|
||
<tr
|
||
class="collapsible-row device-row"
|
||
data-target="device-detail-{{ loop.index }}"
|
||
>
|
||
<td>{{ device_id }}</td>
|
||
<td>{{ device_info.node_name }}</td>
|
||
<td>{{ device_info.namespace }}</td>
|
||
<td>{{ device_info.machine_name|default("本地") }}</td>
|
||
<td>{{ ros_node_info.device_topics.get(device_id, {})|length }}</td>
|
||
<td>
|
||
{{ ros_node_info.device_actions.get(device_id, {})|length }}
|
||
<span class="toggle-indicator">▼</span>
|
||
</td>
|
||
</tr>
|
||
<tr
|
||
id="device-detail-{{ loop.index }}"
|
||
class="detail-row"
|
||
style="display: none"
|
||
>
|
||
<td colspan="5">
|
||
<div class="content-full">
|
||
UUID: {{ device_info.uuid }} {% if device_id in
|
||
ros_node_info.device_topics %}
|
||
<h4>已发布状态:</h4>
|
||
<table class="inner-table">
|
||
<tr>
|
||
<th>名称</th>
|
||
<th>类型</th>
|
||
<th>话题</th>
|
||
<th>间隔</th>
|
||
<th></th>
|
||
</tr>
|
||
{% for status_name, status_info in
|
||
ros_node_info.device_topics[device_id].items() %}
|
||
<tr
|
||
class="topic-row collapsible-sub-row"
|
||
data-target="topic-cmd-{{ loop.index }}-{{ device_loop_index }}"
|
||
>
|
||
<td>{{ status_name }}</td>
|
||
<td>{{ status_info.type_name }}</td>
|
||
<td>{{ status_info.topic_path }}</td>
|
||
<td>{{ status_info.timer_period }}</td>
|
||
<td><span class="toggle-sub-indicator">▼</span></td>
|
||
</tr>
|
||
<tr
|
||
id="topic-cmd-{{ loop.index }}-{{ device_loop_index }}"
|
||
class="cmd-row"
|
||
style="display: none"
|
||
>
|
||
<td colspan="5">
|
||
<div class="cmd-block">
|
||
<strong>订阅命令:</strong>
|
||
<div class="cmd-line">
|
||
<pre>ros2 topic echo {{ status_info.topic_path }}</pre>
|
||
<button
|
||
class="copy-btn"
|
||
onclick="copyToClipboard(this.previousElementSibling.textContent, event)"
|
||
>
|
||
复制
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</table>
|
||
{% endif %} {% if device_id in ros_node_info.device_actions %}
|
||
<h4>已发布动作:</h4>
|
||
<table class="inner-table">
|
||
<tr>
|
||
<th>名称</th>
|
||
<th>类型</th>
|
||
<th>话题</th>
|
||
<th></th>
|
||
</tr>
|
||
{% for action_name, action_info in
|
||
ros_node_info.device_actions[device_id].items() %}
|
||
<tr
|
||
class="action-row collapsible-sub-row"
|
||
data-target="action-cmd-{{ loop.index }}-{{ device_loop_index }}"
|
||
>
|
||
<td>{{ action_name }}</td>
|
||
<td>{{ action_info.type_name }}</td>
|
||
<td>{{ action_info.action_path }}</td>
|
||
<td><span class="toggle-sub-indicator">▼</span></td>
|
||
</tr>
|
||
<tr
|
||
id="action-cmd-{{ loop.index }}-{{ device_loop_index }}"
|
||
class="cmd-row"
|
||
style="display: none"
|
||
>
|
||
<td colspan="5">
|
||
<div class="cmd-block">
|
||
<strong>发送命令:</strong>
|
||
<div class="cmd-line">
|
||
<pre>
|
||
ros2 action send_goal {{ action_info.action_path }} {{ action_info.type_name_convert }} "{{ action_info.goal_info }}"</pre
|
||
>
|
||
<button
|
||
class="copy-btn"
|
||
onclick="copyToClipboard(this.previousElementSibling.textContent, event)"
|
||
>
|
||
复制
|
||
</button>
|
||
</div>
|
||
|
||
<p class="goal-tip">提示: 根据目标结构修改命令参数</p>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</table>
|
||
{% endif %}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</table>
|
||
</div>
|
||
|
||
<!-- 设备部分 -->
|
||
<div class="card" id="devices-section">
|
||
<h2 class="collapsible-header" data-target="devices-table">
|
||
Device Types
|
||
<span class="toggle-indicator">▼</span>
|
||
</h2>
|
||
<div id="devices-table" class="collapsible-content" style="display: none">
|
||
<table class="responsive-table">
|
||
<tr>
|
||
<th>ID</th>
|
||
<th>Name</th>
|
||
<th>File Path</th>
|
||
<th></th>
|
||
</tr>
|
||
{% for device in devices %}
|
||
<tr class="collapsible-row" data-target="device-info-{{ loop.index }}">
|
||
<td>{{ device.id }}</td>
|
||
<td>{{ device.name }}</td>
|
||
<td class="file-path">
|
||
{{ device.file_path }}
|
||
<span
|
||
class="folder-link"
|
||
onclick="openFolder('{{ device.file_path }}'); event.stopPropagation();"
|
||
title="打开文件夹"
|
||
>📁</span
|
||
>
|
||
</td>
|
||
<td><span class="toggle-indicator">▼</span></td>
|
||
</tr>
|
||
<tr
|
||
id="device-info-{{ loop.index }}"
|
||
class="detail-row"
|
||
style="display: none"
|
||
>
|
||
<td colspan="5">
|
||
<div class="content-full">
|
||
{% if device.class %}
|
||
<pre>{{ device.class | tojson(indent=4) }}</pre>
|
||
{% else %}
|
||
<!-- 这里可以放占位内容,比如 -->
|
||
<pre>// No data</pre>
|
||
{% endif %} {% if device.is_online %}
|
||
<div class="status-badge">
|
||
<span class="online-status">在线</span>
|
||
</div>
|
||
{% endif %} {% if device.is_online and device.status_publishers %}
|
||
<h4>状态发布者:</h4>
|
||
<ul class="detail-list">
|
||
{% for status_name, status_info in
|
||
device.status_publishers.items() %}
|
||
<li>
|
||
<strong>{{ status_name }}</strong> - 类型: {{ status_info.type
|
||
}} <br />话题: {{ status_info.topic }}
|
||
</li>
|
||
{% endfor %}
|
||
</ul>
|
||
{% endif %} {% if device.is_online and device.actions %}
|
||
<h4>可用动作:</h4>
|
||
<ul class="detail-list">
|
||
{% for action_name, action_info in device.actions.items() %}
|
||
<li>
|
||
<strong>{{ action_name }}</strong> - 类型: {{ action_info.type
|
||
}} <br />话题: {{ action_info.topic }}
|
||
<br />
|
||
<div class="cmd-block">
|
||
<strong>发送命令:</strong>
|
||
<div class="cmd-line">
|
||
<pre>{{ action_info.command }}</pre>
|
||
<button
|
||
class="copy-btn"
|
||
onclick="copyToClipboard(this.previousElementSibling.textContent, event)"
|
||
>
|
||
复制
|
||
</button>
|
||
<button
|
||
class="debug-btn"
|
||
onclick="toggleDebugInfo(this, event)"
|
||
>
|
||
调试
|
||
</button>
|
||
<div class="debug-info" style="display: none">
|
||
{% if action_info %}
|
||
<pre>{{ action_info | tojson(indent=4) }}</pre>
|
||
{% else %}
|
||
<!-- 这里可以放占位内容,比如 -->
|
||
<pre>// No data</pre>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<p class="goal-tip">提示: 根据目标结构修改命令参数</p>
|
||
</div>
|
||
</li>
|
||
{% endfor %}
|
||
</ul>
|
||
{% endif %}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 资源部分 -->
|
||
<div class="card" id="resources-section">
|
||
<h2 class="collapsible-header" data-target="resources-table">
|
||
Resource Types
|
||
<span class="toggle-indicator">▼</span>
|
||
</h2>
|
||
<div id="resources-table" class="collapsible-content" style="display: none">
|
||
<table class="responsive-table">
|
||
<tr>
|
||
<th>ID</th>
|
||
<th>Name</th>
|
||
<th>File Path</th>
|
||
</tr>
|
||
{% for resource in resources %}
|
||
<tr>
|
||
<td>{{ resource.id }}</td>
|
||
<td>{{ resource.name }}</td>
|
||
<td class="file-path">
|
||
{{ resource.file_path }}
|
||
<span
|
||
class="folder-link"
|
||
onclick="openFolder('{{ resource.file_path }}')"
|
||
title="打开文件夹"
|
||
>📁</span
|
||
>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 模块部分 -->
|
||
<div class="card" id="modules-section">
|
||
<h2 class="collapsible-header" data-target="modules-content">
|
||
Converter Modules
|
||
<span class="toggle-indicator">▼</span>
|
||
</h2>
|
||
<div id="modules-content" class="collapsible-content" style="display: none">
|
||
<h3>Loaded Modules</h3>
|
||
<table class="responsive-table">
|
||
<tr>
|
||
<th>Module Path</th>
|
||
</tr>
|
||
{% for module in modules.names %}
|
||
<tr>
|
||
<td>{{ module }}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</table>
|
||
|
||
<h3>
|
||
Available Classes
|
||
<span class="classes-count">({{ modules.total_count }})</span>
|
||
</h3>
|
||
<table class="responsive-table">
|
||
<tr>
|
||
<th>Class Name</th>
|
||
</tr>
|
||
{% for class_name in modules.classes %}
|
||
<tr>
|
||
<td>{{ class_name }}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 返回顶部按钮 -->
|
||
<button id="back-to-top" title="返回顶部">↑</button>
|
||
{% endblock %} {% block scripts %} {{ super() }}
|
||
<script>
|
||
// 在页面加载完成后执行初始化
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
initFolderOpener();
|
||
initCollapsibleRows();
|
||
initCollapsibleHeaders();
|
||
initScrollToSections();
|
||
initBackToTop();
|
||
initWebSocket();
|
||
restoreCollapsibleStates(); // 恢复折叠状态
|
||
});
|
||
|
||
// 折叠状态管理
|
||
function saveCollapsibleState(elementId, isExpanded) {
|
||
try {
|
||
const states = JSON.parse(
|
||
localStorage.getItem('collapsibleStates') || '{}'
|
||
);
|
||
states[elementId] = isExpanded;
|
||
localStorage.setItem('collapsibleStates', JSON.stringify(states));
|
||
} catch (e) {
|
||
console.warn('无法保存折叠状态:', e);
|
||
}
|
||
}
|
||
|
||
function getCollapsibleState(elementId) {
|
||
try {
|
||
const states = JSON.parse(
|
||
localStorage.getItem('collapsibleStates') || '{}'
|
||
);
|
||
return states[elementId];
|
||
} catch (e) {
|
||
console.warn('无法读取折叠状态:', e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function restoreCollapsibleStates() {
|
||
try {
|
||
// 恢复折叠行状态
|
||
const collapsibleRows = document.querySelectorAll(
|
||
'.collapsible-row[data-target]'
|
||
);
|
||
collapsibleRows.forEach((row) => {
|
||
const targetId = row.getAttribute('data-target');
|
||
const savedState = getCollapsibleState(targetId);
|
||
if (savedState !== null) {
|
||
const content = document.getElementById(targetId);
|
||
if (content) {
|
||
if (savedState) {
|
||
// 展开
|
||
row.classList.add('active');
|
||
content.style.display = 'table-row';
|
||
const indicator = row.querySelector('.toggle-indicator');
|
||
if (indicator) indicator.textContent = '▲';
|
||
} else {
|
||
// 折叠
|
||
row.classList.remove('active');
|
||
content.style.display = 'none';
|
||
const indicator = row.querySelector('.toggle-indicator');
|
||
if (indicator) indicator.textContent = '▼';
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// 恢复折叠标题状态
|
||
const collapsibleHeaders = document.querySelectorAll(
|
||
'.collapsible-header[data-target]'
|
||
);
|
||
collapsibleHeaders.forEach((header) => {
|
||
const targetId = header.getAttribute('data-target');
|
||
const savedState = getCollapsibleState(targetId);
|
||
if (savedState !== null) {
|
||
const content = document.getElementById(targetId);
|
||
if (content) {
|
||
const indicator = header.querySelector('.toggle-indicator');
|
||
if (savedState) {
|
||
// 展开
|
||
content.style.display = 'block';
|
||
if (indicator) indicator.textContent = '▲';
|
||
} else {
|
||
// 折叠
|
||
content.style.display = 'none';
|
||
if (indicator) indicator.textContent = '▼';
|
||
}
|
||
}
|
||
}
|
||
});
|
||
} catch (e) {
|
||
console.warn('恢复折叠状态时出错:', e);
|
||
}
|
||
}
|
||
|
||
// WebSocket连接
|
||
let ws = null;
|
||
let reconnectAttempts = 0;
|
||
const maxReconnectAttempts = 5;
|
||
const reconnectDelay = 3000; // 3秒
|
||
let connectionStatusElement = null;
|
||
|
||
function showConnectionStatus(status, message) {
|
||
// 如果状态元素不存在,创建一个
|
||
if (!connectionStatusElement) {
|
||
connectionStatusElement = document.createElement('div');
|
||
connectionStatusElement.className = 'connection-status';
|
||
document.body.appendChild(connectionStatusElement);
|
||
}
|
||
|
||
connectionStatusElement.className = 'connection-status ' + status;
|
||
connectionStatusElement.innerHTML = `<span class="status-dot"></span><span class="status-text">${message}</span>`;
|
||
|
||
// 3秒后自动隐藏
|
||
if (status === 'connected') {
|
||
setTimeout(() => {
|
||
connectionStatusElement.style.opacity = '0';
|
||
}, 3000);
|
||
} else {
|
||
connectionStatusElement.style.opacity = '1';
|
||
}
|
||
}
|
||
|
||
function initWebSocket() {
|
||
// 获取WebSocket URL - 连接到状态页面专用端点
|
||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/status_page`;
|
||
|
||
showConnectionStatus('connecting', '正在连接服务器...');
|
||
|
||
ws = new WebSocket(wsUrl);
|
||
|
||
ws.onopen = function () {
|
||
console.log('状态页面WebSocket连接已建立');
|
||
showConnectionStatus('connected', '已连接到服务器');
|
||
reconnectAttempts = 0;
|
||
|
||
// 显示实时更新指示器
|
||
showLiveUpdateIndicator();
|
||
|
||
// 发送心跳保持连接
|
||
setInterval(() => {
|
||
if (ws.readyState === WebSocket.OPEN) {
|
||
ws.send('heartbeat');
|
||
}
|
||
}, 30000); // 每30秒发送一次心跳
|
||
};
|
||
|
||
ws.onmessage = function (event) {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
if (data.type === 'device_status') {
|
||
updateDeviceStatus(data.data);
|
||
} else if (data.type === 'static_data_init') {
|
||
// 初始化静态数据(只接收一次)
|
||
initStaticData(data.data);
|
||
updateLastRefreshTime();
|
||
} else if (data.type === 'incremental_update') {
|
||
// 处理增量更新
|
||
handleIncrementalUpdate(data.data);
|
||
updateLastRefreshTime();
|
||
}
|
||
} catch (error) {
|
||
console.error('处理WebSocket消息时出错:', error);
|
||
}
|
||
};
|
||
|
||
ws.onclose = function (event) {
|
||
console.log(
|
||
`WebSocket连接已关闭,代码: ${event.code}, 原因: ${event.reason}`
|
||
);
|
||
|
||
if (event.wasClean) {
|
||
showConnectionStatus('disconnected', '连接已正常关闭');
|
||
} else {
|
||
showConnectionStatus('error', '连接意外断开');
|
||
}
|
||
|
||
if (reconnectAttempts < maxReconnectAttempts) {
|
||
showConnectionStatus(
|
||
'reconnecting',
|
||
`正在尝试重新连接 (${
|
||
reconnectAttempts + 1
|
||
}/${maxReconnectAttempts})...`
|
||
);
|
||
setTimeout(initWebSocket, reconnectDelay);
|
||
reconnectAttempts++;
|
||
} else {
|
||
showConnectionStatus('error', '重连失败,请刷新页面重试');
|
||
}
|
||
};
|
||
|
||
ws.onerror = function (error) {
|
||
console.error('WebSocket错误:', error);
|
||
showConnectionStatus('error', 'WebSocket连接错误');
|
||
};
|
||
}
|
||
|
||
function updateDeviceStatus(data) {
|
||
const { device_status, device_status_timestamps } = data;
|
||
|
||
// 查找设备状态表格 - 在host-node-section下,寻找具有"设备状态"标题的host-section
|
||
const deviceStatusSections = Array.from(
|
||
document.querySelectorAll('#host-node-section .host-section h3')
|
||
)
|
||
.filter((h3) => h3.textContent.trim() === '设备状态')
|
||
.map((h3) => h3.parentElement);
|
||
|
||
if (deviceStatusSections.length === 0) {
|
||
console.log('未找到设备状态部分');
|
||
return;
|
||
}
|
||
|
||
const statusSection = deviceStatusSections[0];
|
||
const table = statusSection.querySelector('table.responsive-table');
|
||
|
||
if (!table) {
|
||
console.log('未找到设备状态表格');
|
||
return;
|
||
}
|
||
|
||
// 保存表头
|
||
const header = table.rows[0].cloneNode(true);
|
||
|
||
// 清空表格(保留表头)
|
||
while (table.rows.length > 1) {
|
||
table.deleteRow(1);
|
||
}
|
||
|
||
// 没有数据时显示空状态行
|
||
if (Object.keys(device_status).length === 0) {
|
||
const emptyRow = table.insertRow();
|
||
const emptyCell = emptyRow.insertCell();
|
||
emptyCell.colSpan = 4;
|
||
emptyCell.className = 'empty-state';
|
||
emptyCell.textContent = '没有设备状态数据';
|
||
return;
|
||
}
|
||
|
||
// 添加数据行
|
||
for (const [device_id, properties] of Object.entries(device_status)) {
|
||
const propNames = Object.keys(properties);
|
||
|
||
for (let i = 0; i < propNames.length; i++) {
|
||
const prop_name = propNames[i];
|
||
const prop_value = properties[prop_name];
|
||
|
||
const row = table.insertRow();
|
||
|
||
// 如果是设备的第一个属性,添加设备ID单元格
|
||
if (i === 0) {
|
||
const deviceCell = row.insertCell();
|
||
deviceCell.rowSpan = propNames.length;
|
||
deviceCell.textContent = device_id;
|
||
}
|
||
|
||
// 添加属性名称单元格
|
||
const propCell = row.insertCell();
|
||
propCell.textContent = prop_name;
|
||
|
||
// 添加属性值单元格
|
||
const valueCell = row.insertCell();
|
||
valueCell.textContent = prop_value;
|
||
|
||
// 添加时间戳单元格
|
||
const timestampCell = row.insertCell();
|
||
const tsInfo = device_status_timestamps[device_id]?.[prop_name];
|
||
|
||
if (tsInfo && tsInfo.elapsed >= 0) {
|
||
const timestampSpan = document.createElement('span');
|
||
timestampSpan.className = 'timestamp';
|
||
timestampSpan.setAttribute('title', tsInfo.timestamp);
|
||
timestampSpan.textContent = `${Math.round(tsInfo.elapsed)} 秒前`;
|
||
timestampCell.appendChild(timestampSpan);
|
||
} else {
|
||
const notUpdatedSpan = document.createElement('span');
|
||
notUpdatedSpan.className = 'timestamp not-updated';
|
||
notUpdatedSpan.textContent = '未更新';
|
||
timestampCell.appendChild(notUpdatedSpan);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 处理主机节点可用性状态变化
|
||
function handleHostNodeAvailabilityChange(isHostMode, hostNodeInfo) {
|
||
const hostSection = document.getElementById('host-node-section');
|
||
const hostNavLink = document.querySelector('a[href="#host-node-section"]');
|
||
|
||
// 检查是否需要显示主机节点部分
|
||
const shouldShowHostNode =
|
||
isHostMode && hostNodeInfo && hostNodeInfo.available;
|
||
|
||
if (shouldShowHostNode && !hostSection) {
|
||
// 主机节点从不可用变为可用,需要创建HTML
|
||
console.log('创建主机节点部分HTML');
|
||
createHostNodeSection(hostNodeInfo);
|
||
|
||
// 添加导航链接
|
||
if (!hostNavLink) {
|
||
addHostNodeNavLink();
|
||
}
|
||
} else if (!shouldShowHostNode && hostSection) {
|
||
// 主机节点从可用变为不可用,隐藏或移除
|
||
console.log('隐藏主机节点部分');
|
||
hostSection.style.display = 'none';
|
||
|
||
// 移除导航链接
|
||
if (hostNavLink) {
|
||
hostNavLink.remove();
|
||
}
|
||
} else if (shouldShowHostNode && hostSection) {
|
||
// 主机节点仍然可用,确保显示
|
||
hostSection.style.display = 'block';
|
||
|
||
// 确保导航链接存在
|
||
if (!hostNavLink) {
|
||
addHostNodeNavLink();
|
||
}
|
||
}
|
||
}
|
||
|
||
// 创建主机节点部分HTML
|
||
function createHostNodeSection(hostNodeInfo) {
|
||
const onlineDevicesSection = document.getElementById(
|
||
'online-devices-section'
|
||
);
|
||
if (!onlineDevicesSection) {
|
||
console.warn('找不到插入位置');
|
||
return;
|
||
}
|
||
|
||
const hostSectionHtml = `
|
||
<div class="card" id="host-node-section">
|
||
<h2>主机节点信息</h2>
|
||
|
||
<!-- 主机控制的设备 -->
|
||
<div class="host-section">
|
||
<h3>
|
||
已管理设备
|
||
<span class="count-badge">0</span>
|
||
</h3>
|
||
<table class="responsive-table">
|
||
<tr>
|
||
<th>设备ID</th>
|
||
<th>命名空间</th>
|
||
<th>机器名称</th>
|
||
<th>状态</th>
|
||
</tr>
|
||
<tr>
|
||
<td colspan="4" class="empty-state">没有发现已管理的设备</td>
|
||
</tr>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- 主机的动作客户端 -->
|
||
<div class="host-section">
|
||
<h3>
|
||
动作客户端
|
||
<span class="count-badge">0</span>
|
||
</h3>
|
||
<div class="collapsible-table">
|
||
<h4>已接纳动作:</h4>
|
||
<table class="inner-table">
|
||
<tr>
|
||
<th>话题</th>
|
||
<th>类型</th>
|
||
<th></th>
|
||
</tr>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主机已订阅的主题 -->
|
||
<div class="host-section">
|
||
<h3>
|
||
已订阅主题
|
||
<span class="count-badge">0</span>
|
||
</h3>
|
||
<div class="topics-container">
|
||
<div class="empty-state">没有发现已订阅的主题</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 设备状态 -->
|
||
<div class="host-section">
|
||
<h3>设备状态</h3>
|
||
<table class="responsive-table">
|
||
<tr>
|
||
<th>设备ID</th>
|
||
<th>属性</th>
|
||
<th>值</th>
|
||
<th>最后更新</th>
|
||
</tr>
|
||
<tr>
|
||
<td colspan="4" class="empty-state">没有设备状态数据</td>
|
||
</tr>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 在本地设备部分之前插入主机节点部分
|
||
onlineDevicesSection.insertAdjacentHTML('beforebegin', hostSectionHtml);
|
||
|
||
console.log('主机节点部分HTML已创建');
|
||
}
|
||
|
||
// 添加主机节点导航链接
|
||
function addHostNodeNavLink() {
|
||
const navTabs = document.querySelector('.nav-tabs');
|
||
const onlineDevicesNavLink = document.querySelector(
|
||
'a[href="#online-devices-section"]'
|
||
);
|
||
|
||
if (navTabs && onlineDevicesNavLink) {
|
||
const hostNavLink = document.createElement('a');
|
||
hostNavLink.href = '#host-node-section';
|
||
hostNavLink.className = 'nav-tab';
|
||
hostNavLink.textContent = '主机节点';
|
||
|
||
// 在"本地设备"链接之前插入
|
||
navTabs.insertBefore(hostNavLink, onlineDevicesNavLink);
|
||
|
||
// 添加点击事件
|
||
hostNavLink.addEventListener('click', function (e) {
|
||
e.preventDefault();
|
||
const targetElement = document.querySelector('#host-node-section');
|
||
if (targetElement) {
|
||
targetElement.scrollIntoView({ behavior: 'smooth' });
|
||
}
|
||
});
|
||
|
||
console.log('主机节点导航链接已添加');
|
||
}
|
||
}
|
||
|
||
// 初始化静态数据(只接收一次)
|
||
function initStaticData(data) {
|
||
console.log('初始化静态数据', data);
|
||
|
||
// 更新设备类型表格
|
||
if (data.devices) {
|
||
updateDevicesTable(data.devices);
|
||
}
|
||
|
||
// 更新资源类型表格
|
||
if (data.resources) {
|
||
updateResourcesTable(data.resources);
|
||
}
|
||
|
||
// 更新模块信息表格
|
||
if (data.modules) {
|
||
updateModulesTable(data.modules);
|
||
}
|
||
|
||
// 初始化主机节点信息(如果是主机模式)
|
||
if (data.host_node_info && data.is_host_mode) {
|
||
const isHostMode = data.is_host_mode;
|
||
handleHostNodeAvailabilityChange(isHostMode, data.host_node_info);
|
||
|
||
// 初始化主机节点各个部分
|
||
if (data.host_node_info.available) {
|
||
if (data.host_node_info.devices) {
|
||
updateHostDevicesTable(data.host_node_info.devices);
|
||
}
|
||
if (data.host_node_info.action_clients) {
|
||
updateHostActionClients(data.host_node_info.action_clients);
|
||
}
|
||
if (data.host_node_info.subscribed_topics) {
|
||
updateHostSubscribedTopics(data.host_node_info.subscribed_topics);
|
||
}
|
||
if (data.host_node_info.device_status) {
|
||
updateDeviceStatus({
|
||
device_status: data.host_node_info.device_status,
|
||
device_status_timestamps:
|
||
data.host_node_info.device_status_timestamps,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// 初始化本地设备信息
|
||
if (data.ros_node_info) {
|
||
updateLocalDevicesTable(data.ros_node_info);
|
||
}
|
||
|
||
// 这些是真正的静态数据,页面生命周期内不会变化
|
||
console.log('静态数据初始化完成');
|
||
}
|
||
|
||
// 处理增量更新
|
||
function handleIncrementalUpdate(data) {
|
||
console.log('处理增量更新', data);
|
||
|
||
// 检查并处理主机节点信息变化(只处理包含的字段)
|
||
if (data.host_node_info) {
|
||
// 获取当前是否为主机模式(从页面初始数据获取)
|
||
const isHostMode =
|
||
document.querySelector('.mode-indicator.host-mode') !== null;
|
||
|
||
// 检查可用性变化
|
||
if ('available' in data.host_node_info) {
|
||
handleHostNodeAvailabilityChange(isHostMode, data.host_node_info);
|
||
}
|
||
|
||
// 只更新包含的字段,避免不必要的DOM操作
|
||
if ('device_status' in data.host_node_info) {
|
||
updateDeviceStatus({
|
||
device_status: data.host_node_info.device_status,
|
||
device_status_timestamps:
|
||
data.host_node_info.device_status_timestamps,
|
||
});
|
||
}
|
||
|
||
if ('devices' in data.host_node_info) {
|
||
updateHostDevicesTable(data.host_node_info.devices);
|
||
}
|
||
|
||
if ('action_clients' in data.host_node_info) {
|
||
updateHostActionClients(data.host_node_info.action_clients);
|
||
}
|
||
|
||
if ('subscribed_topics' in data.host_node_info) {
|
||
updateHostSubscribedTopics(data.host_node_info.subscribed_topics);
|
||
}
|
||
}
|
||
|
||
// 只有当ros_node_info存在时才更新本地设备(表示发生了变更)
|
||
if (data.ros_node_info && data.ros_node_info.registered_devices) {
|
||
console.log('更新本地设备表格');
|
||
updateLocalDevicesTable(data.ros_node_info);
|
||
}
|
||
}
|
||
|
||
// 更新设备类型表格
|
||
function updateDevicesTable(devices) {
|
||
const table = document.querySelector('#devices-table .responsive-table');
|
||
if (!table) return;
|
||
|
||
// 保存表头
|
||
const header = table.rows[0].cloneNode(true);
|
||
|
||
// 清空表格(保留表头)
|
||
while (table.rows.length > 1) {
|
||
table.deleteRow(1);
|
||
}
|
||
|
||
// 添加设备数据行
|
||
devices.forEach((device, index) => {
|
||
const row = table.insertRow();
|
||
row.className = 'collapsible-row';
|
||
row.setAttribute('data-target', `device-info-${index + 1}`);
|
||
|
||
row.innerHTML = `
|
||
<td>${device.id}</td>
|
||
<td>${device.name || '未命名'}</td>
|
||
<td class="file-path">
|
||
${device.file_path || ''}
|
||
<span class="folder-link" onclick="openFolder('${
|
||
device.file_path
|
||
}'); event.stopPropagation();" title="打开文件夹">📁</span>
|
||
</td>
|
||
<td><span class="toggle-indicator">▼</span></td>
|
||
`;
|
||
|
||
// 添加详情行
|
||
const detailRow = table.insertRow();
|
||
detailRow.id = `device-info-${index + 1}`;
|
||
detailRow.className = 'detail-row';
|
||
detailRow.style.display = 'none';
|
||
|
||
const detailCell = detailRow.insertCell();
|
||
detailCell.colSpan = 5;
|
||
|
||
let deviceClassInfo = '';
|
||
if (device.class) {
|
||
try {
|
||
deviceClassInfo = JSON.stringify(device.class, null, 4);
|
||
} catch (e) {
|
||
deviceClassInfo = '// 数据格式错误';
|
||
}
|
||
} else {
|
||
deviceClassInfo = '// No data';
|
||
}
|
||
|
||
detailCell.innerHTML = `
|
||
<div class="content-full">
|
||
<pre>${deviceClassInfo}</pre>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
// 延迟恢复状态,确保DOM更新完成
|
||
setTimeout(restoreCollapsibleStates, 100);
|
||
}
|
||
|
||
// 更新资源类型表格
|
||
function updateResourcesTable(resources) {
|
||
const table = document.querySelector('#resources-table .responsive-table');
|
||
if (!table) return;
|
||
|
||
// 清空表格(保留表头)
|
||
while (table.rows.length > 1) {
|
||
table.deleteRow(1);
|
||
}
|
||
|
||
// 添加资源数据行
|
||
resources.forEach((resource) => {
|
||
const row = table.insertRow();
|
||
row.innerHTML = `
|
||
<td>${resource.id}</td>
|
||
<td>${resource.name}</td>
|
||
<td class="file-path">
|
||
${resource.file_path}
|
||
<span class="folder-link" onclick="openFolder('${resource.file_path}')" title="打开文件夹">📁</span>
|
||
</td>
|
||
`;
|
||
});
|
||
}
|
||
|
||
// 更新模块信息表格
|
||
function updateModulesTable(modules) {
|
||
// 更新模块列表
|
||
const moduleTable = document.querySelector(
|
||
'#modules-content table:first-of-type'
|
||
);
|
||
if (moduleTable) {
|
||
while (moduleTable.rows.length > 1) {
|
||
moduleTable.deleteRow(1);
|
||
}
|
||
|
||
modules.names.forEach((moduleName) => {
|
||
const row = moduleTable.insertRow();
|
||
row.innerHTML = `<td>${moduleName}</td>`;
|
||
});
|
||
}
|
||
|
||
// 更新类列表和计数
|
||
const classesHeader = document.querySelector(
|
||
'#modules-content h3 .classes-count'
|
||
);
|
||
if (classesHeader) {
|
||
classesHeader.textContent = `(${modules.total_count})`;
|
||
}
|
||
|
||
const classTable = document.querySelector(
|
||
'#modules-content table:last-of-type'
|
||
);
|
||
if (classTable) {
|
||
while (classTable.rows.length > 1) {
|
||
classTable.deleteRow(1);
|
||
}
|
||
|
||
modules.classes.forEach((className) => {
|
||
const row = classTable.insertRow();
|
||
row.innerHTML = `<td>${className}</td>`;
|
||
});
|
||
}
|
||
}
|
||
|
||
// 更新主机设备管理表格
|
||
function updateHostDevicesTable(devices) {
|
||
// 查找已管理设备部分的表格
|
||
const hostSection = document.getElementById('host-node-section');
|
||
if (!hostSection) {
|
||
console.log('主机节点部分不存在');
|
||
return;
|
||
}
|
||
|
||
const deviceManagementSection = Array.from(
|
||
hostSection.querySelectorAll('.host-section')
|
||
).find((section) => {
|
||
const h3 = section.querySelector('h3');
|
||
return h3 && h3.textContent.includes('已管理设备');
|
||
});
|
||
|
||
if (!deviceManagementSection) {
|
||
console.log('已管理设备部分不存在');
|
||
return;
|
||
}
|
||
|
||
const table = deviceManagementSection.querySelector('.responsive-table');
|
||
if (!table) {
|
||
console.log('已管理设备表格不存在');
|
||
return;
|
||
}
|
||
|
||
// 清空表格(保留表头)
|
||
while (table.rows.length > 1) {
|
||
table.deleteRow(1);
|
||
}
|
||
|
||
// 更新设备数量显示
|
||
const countBadge = deviceManagementSection.querySelector('.count-badge');
|
||
const deviceCount = Object.keys(devices).length;
|
||
if (countBadge) {
|
||
countBadge.textContent = deviceCount;
|
||
}
|
||
|
||
if (deviceCount === 0) {
|
||
const emptyRow = table.insertRow();
|
||
const emptyCell = emptyRow.insertCell();
|
||
emptyCell.colSpan = 4;
|
||
emptyCell.className = 'empty-state';
|
||
emptyCell.textContent = '没有发现已管理的设备';
|
||
return;
|
||
}
|
||
|
||
// 添加设备数据行
|
||
for (const [deviceId, deviceInfo] of Object.entries(devices)) {
|
||
const row = table.insertRow();
|
||
row.innerHTML = `
|
||
<td>${deviceId}</td>
|
||
<td>${deviceInfo.namespace || ''}</td>
|
||
<td>${deviceInfo.machine_name || ''}</td>
|
||
<td><span class="status-badge online">${
|
||
deviceInfo.is_online ? '在线' : '离线'
|
||
}</span></td>
|
||
`;
|
||
}
|
||
}
|
||
|
||
// 更新主机动作客户端
|
||
function updateHostActionClients(actionClients) {
|
||
const hostSection = document.getElementById('host-node-section');
|
||
if (!hostSection) return;
|
||
|
||
// 查找动作客户端部分
|
||
const actionClientsSection = Array.from(
|
||
hostSection.querySelectorAll('.host-section')
|
||
).find((section) => {
|
||
const h3 = section.querySelector('h3');
|
||
return h3 && h3.textContent.includes('动作客户端');
|
||
});
|
||
|
||
if (!actionClientsSection) return;
|
||
|
||
// 更新计数显示
|
||
const countBadge = actionClientsSection.querySelector('.count-badge');
|
||
const actionCount = Object.keys(actionClients).length;
|
||
if (countBadge) {
|
||
countBadge.textContent = actionCount;
|
||
}
|
||
|
||
// 查找内部表格
|
||
const innerTable = actionClientsSection.querySelector('.inner-table');
|
||
if (!innerTable) return;
|
||
|
||
// 清空表格(保留表头)
|
||
while (innerTable.rows.length > 1) {
|
||
innerTable.deleteRow(1);
|
||
}
|
||
|
||
// 添加动作客户端数据
|
||
if (actionCount === 0) {
|
||
const emptyRow = innerTable.insertRow();
|
||
const emptyCell = emptyRow.insertCell();
|
||
emptyCell.colSpan = 3;
|
||
emptyCell.className = 'empty-state';
|
||
emptyCell.textContent = '没有动作客户端';
|
||
return;
|
||
}
|
||
|
||
let index = 1;
|
||
for (const [actionName, actionInfo] of Object.entries(actionClients)) {
|
||
// 主行
|
||
const row = innerTable.insertRow();
|
||
row.className = 'action-row collapsible-sub-row';
|
||
row.setAttribute('data-target', `host-action-cmd-${index}`);
|
||
row.innerHTML = `
|
||
<td>${actionName}</td>
|
||
<td>${actionInfo.type_name || ''}</td>
|
||
<td><span class="toggle-sub-indicator">▼</span></td>
|
||
`;
|
||
|
||
// 命令行
|
||
const cmdRow = innerTable.insertRow();
|
||
cmdRow.id = `host-action-cmd-${index}`;
|
||
cmdRow.className = 'cmd-row';
|
||
cmdRow.style.display = 'none';
|
||
cmdRow.innerHTML = `
|
||
<td colspan="5">
|
||
<div class="cmd-block">
|
||
<strong>发送命令:</strong>
|
||
<div class="cmd-line">
|
||
<pre>ros2 action send_goal ${actionInfo.action_path} ${actionInfo.type_name_convert} "${actionInfo.goal_info}"</pre>
|
||
<button class="copy-btn" onclick="copyToClipboard(this.previousElementSibling.textContent, event)">复制</button>
|
||
</div>
|
||
<p class="goal-tip">提示: 根据目标结构修改命令参数</p>
|
||
</div>
|
||
</td>
|
||
`;
|
||
|
||
index++;
|
||
}
|
||
|
||
// 事件委托已经处理了动态添加的元素,不需要重新初始化
|
||
}
|
||
|
||
// 更新主机订阅主题
|
||
function updateHostSubscribedTopics(subscribedTopics) {
|
||
const hostSection = document.getElementById('host-node-section');
|
||
if (!hostSection) return;
|
||
|
||
// 查找已订阅主题部分
|
||
const topicsSection = Array.from(
|
||
hostSection.querySelectorAll('.host-section')
|
||
).find((section) => {
|
||
const h3 = section.querySelector('h3');
|
||
return h3 && h3.textContent.includes('已订阅主题');
|
||
});
|
||
|
||
if (!topicsSection) return;
|
||
|
||
// 更新计数显示
|
||
const countBadge = topicsSection.querySelector('.count-badge');
|
||
const topicsCount = subscribedTopics.length;
|
||
if (countBadge) {
|
||
countBadge.textContent = topicsCount;
|
||
}
|
||
|
||
// 查找主题容器
|
||
const topicsContainer = topicsSection.querySelector('.topics-container');
|
||
if (!topicsContainer) return;
|
||
|
||
// 清空容器
|
||
topicsContainer.innerHTML = '';
|
||
|
||
if (topicsCount === 0) {
|
||
topicsContainer.innerHTML =
|
||
'<div class="empty-state">没有发现已订阅的主题</div>';
|
||
return;
|
||
}
|
||
|
||
// 添加主题列表
|
||
const topicsList = document.createElement('div');
|
||
topicsList.className = 'topics-list';
|
||
|
||
subscribedTopics.forEach((topic) => {
|
||
const topicItem = document.createElement('div');
|
||
topicItem.className = 'topic-item';
|
||
topicItem.innerHTML = `
|
||
<span class="topic-name">${topic}</span>
|
||
<button class="copy-btn small" onclick="copyToClipboard('${topic}', event)" title="复制主题名">复制</button>
|
||
`;
|
||
topicsList.appendChild(topicItem);
|
||
});
|
||
|
||
topicsContainer.appendChild(topicsList);
|
||
}
|
||
|
||
// 更新本地设备表格
|
||
function updateLocalDevicesTable(rosNodeInfo) {
|
||
const table = document.querySelector(
|
||
'#online-devices-section .responsive-table'
|
||
);
|
||
if (!table) return;
|
||
|
||
// 清空表格(保留表头)
|
||
while (table.rows.length > 1) {
|
||
table.deleteRow(1);
|
||
}
|
||
|
||
const devices = rosNodeInfo.registered_devices || {};
|
||
const deviceTopics = rosNodeInfo.device_topics || {};
|
||
const deviceActions = rosNodeInfo.device_actions || {};
|
||
|
||
if (Object.keys(devices).length === 0) {
|
||
const emptyRow = table.insertRow();
|
||
const emptyCell = emptyRow.insertCell();
|
||
emptyCell.colSpan = 6;
|
||
emptyCell.className = 'empty-state';
|
||
emptyCell.textContent = '没有发现本地设备';
|
||
return;
|
||
}
|
||
|
||
// 添加设备数据行
|
||
let deviceIndex = 0;
|
||
for (const [deviceId, deviceInfo] of Object.entries(devices)) {
|
||
deviceIndex++;
|
||
|
||
// 主行
|
||
const row = table.insertRow();
|
||
row.className = 'collapsible-row device-row';
|
||
row.setAttribute('data-target', `device-detail-${deviceIndex}`);
|
||
|
||
const topicsCount = Object.keys(deviceTopics[deviceId] || {}).length;
|
||
const actionsCount = Object.keys(deviceActions[deviceId] || {}).length;
|
||
|
||
row.innerHTML = `
|
||
<td>${deviceId}</td>
|
||
<td>${deviceInfo.node_name || ''}</td>
|
||
<td>${deviceInfo.namespace || ''}</td>
|
||
<td>${deviceInfo.machine_name || '本地'}</td>
|
||
<td>${topicsCount}</td>
|
||
<td>${actionsCount} <span class="toggle-indicator">▼</span></td>
|
||
`;
|
||
|
||
// 详情行
|
||
const detailRow = table.insertRow();
|
||
detailRow.id = `device-detail-${deviceIndex}`;
|
||
detailRow.className = 'detail-row';
|
||
detailRow.style.display = 'none';
|
||
|
||
const detailCell = detailRow.insertCell();
|
||
detailCell.colSpan = 5;
|
||
|
||
let detailContent = `<div class="content-full">UUID: ${
|
||
deviceInfo.uuid || ''
|
||
}`;
|
||
|
||
// 添加状态发布者信息
|
||
if (
|
||
deviceTopics[deviceId] &&
|
||
Object.keys(deviceTopics[deviceId]).length > 0
|
||
) {
|
||
detailContent += '<h4>已发布状态:</h4><table class="inner-table">';
|
||
detailContent +=
|
||
'<tr><th>名称</th><th>类型</th><th>话题</th><th>间隔</th><th></th></tr>';
|
||
|
||
Object.entries(deviceTopics[deviceId]).forEach(
|
||
([statusName, statusInfo], statusIndex) => {
|
||
detailContent += `
|
||
<tr class="topic-row collapsible-sub-row" data-target="topic-cmd-${
|
||
statusIndex + 1
|
||
}-${deviceIndex}">
|
||
<td>${statusName}</td>
|
||
<td>${statusInfo.type_name || ''}</td>
|
||
<td>${statusInfo.topic_path || ''}</td>
|
||
<td>${statusInfo.timer_period || ''}</td>
|
||
<td><span class="toggle-sub-indicator">▼</span></td>
|
||
</tr>
|
||
<tr id="topic-cmd-${
|
||
statusIndex + 1
|
||
}-${deviceIndex}" class="cmd-row" style="display: none;">
|
||
<td colspan="5">
|
||
<div class="cmd-block">
|
||
<strong>订阅命令:</strong>
|
||
<div class="cmd-line">
|
||
<pre>ros2 topic echo ${statusInfo.topic_path}</pre>
|
||
<button class="copy-btn" onclick="copyToClipboard(this.previousElementSibling.textContent, event)">复制</button>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
);
|
||
|
||
detailContent += '</table>';
|
||
}
|
||
|
||
// 添加已发布动作信息
|
||
if (
|
||
deviceActions[deviceId] &&
|
||
Object.keys(deviceActions[deviceId]).length > 0
|
||
) {
|
||
detailContent += '<h4>已发布动作:</h4><table class="inner-table">';
|
||
detailContent +=
|
||
'<tr><th>名称</th><th>类型</th><th>话题</th><th></th></tr>';
|
||
|
||
Object.entries(deviceActions[deviceId]).forEach(
|
||
([actionName, actionInfo], actionIndex) => {
|
||
detailContent += `
|
||
<tr class="action-row collapsible-sub-row" data-target="action-cmd-${
|
||
actionIndex + 1
|
||
}-${deviceIndex}">
|
||
<td>${actionName}</td>
|
||
<td>${actionInfo.type_name || ''}</td>
|
||
<td>${actionInfo.action_path || ''}</td>
|
||
<td><span class="toggle-sub-indicator">▼</span></td>
|
||
</tr>
|
||
<tr id="action-cmd-${
|
||
actionIndex + 1
|
||
}-${deviceIndex}" class="cmd-row" style="display: none;">
|
||
<td colspan="5">
|
||
<div class="cmd-block">
|
||
<strong>发送命令:</strong>
|
||
<div class="cmd-line">
|
||
<pre>ros2 action send_goal ${actionInfo.action_path} ${
|
||
actionInfo.type_name_convert
|
||
} "${actionInfo.goal_info}"</pre>
|
||
<button class="copy-btn" onclick="copyToClipboard(this.previousElementSibling.textContent, event)">复制</button>
|
||
</div>
|
||
<p class="goal-tip">提示: 根据目标结构修改命令参数</p>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
);
|
||
|
||
detailContent += '</table>';
|
||
}
|
||
|
||
detailContent += '</div>';
|
||
detailCell.innerHTML = detailContent;
|
||
}
|
||
|
||
// 延迟恢复状态,确保DOM更新完成
|
||
setTimeout(restoreCollapsibleStates, 100);
|
||
}
|
||
|
||
// 文件夹打开功能
|
||
function initFolderOpener() {
|
||
function openFolder(path) {
|
||
if (!path) {
|
||
alert('路径为空');
|
||
return;
|
||
}
|
||
|
||
// 对于Windows路径,确保格式正确
|
||
const formattedPath = path.includes(':\\')
|
||
? path
|
||
: path.replace(':', ':\\');
|
||
|
||
fetch(`/open-folder?path=${encodeURIComponent(formattedPath)}`)
|
||
.then((response) => response.json())
|
||
.then((data) => {
|
||
if (data.status === 'error') {
|
||
alert('错误: ' + data.message);
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
console.error('Error:', error);
|
||
alert('请求失败: ' + error);
|
||
});
|
||
}
|
||
|
||
// 确保openFolder函数在全局范围内可用
|
||
window.openFolder = openFolder;
|
||
}
|
||
|
||
// 折叠行功能 - 使用事件委托避免重复绑定问题
|
||
function initCollapsibleRows() {
|
||
// 移除旧的事件监听器(如果存在)
|
||
document.removeEventListener('click', handleCollapsibleRowClick);
|
||
document.removeEventListener('click', handleCollapsibleSubRowClick);
|
||
|
||
// 使用事件委托处理主折叠行点击
|
||
document.addEventListener('click', handleCollapsibleRowClick);
|
||
|
||
// 使用事件委托处理子行点击
|
||
document.addEventListener('click', handleCollapsibleSubRowClick);
|
||
}
|
||
|
||
// 处理主折叠行点击
|
||
function handleCollapsibleRowClick(e) {
|
||
const row = e.target.closest('.collapsible-row:not(.collapsible-sub-row)');
|
||
if (!row) return;
|
||
|
||
const targetId = row.getAttribute('data-target');
|
||
if (!targetId) return;
|
||
|
||
const content = document.getElementById(targetId);
|
||
if (!content) return;
|
||
|
||
// 阻止事件冒泡到其他处理器
|
||
e.stopPropagation();
|
||
|
||
row.classList.toggle('active');
|
||
|
||
const isExpanding = content.style.display !== 'table-row';
|
||
|
||
if (content.style.display === 'table-row') {
|
||
content.style.display = 'none';
|
||
const indicator = row.querySelector('.toggle-indicator');
|
||
if (indicator) indicator.textContent = '▼';
|
||
saveCollapsibleState(targetId, false); // 保存折叠状态
|
||
} else {
|
||
content.style.display = 'table-row';
|
||
const indicator = row.querySelector('.toggle-indicator');
|
||
if (indicator) indicator.textContent = '▲';
|
||
saveCollapsibleState(targetId, true); // 保存展开状态
|
||
}
|
||
}
|
||
|
||
// 处理子行点击(状态和动作行)
|
||
function handleCollapsibleSubRowClick(e) {
|
||
const subRow = e.target.closest('.collapsible-sub-row');
|
||
if (!subRow) return;
|
||
|
||
const targetId = subRow.getAttribute('data-target');
|
||
if (!targetId) return;
|
||
|
||
const content = document.getElementById(targetId);
|
||
if (!content) return;
|
||
|
||
// 阻止事件冒泡
|
||
e.stopPropagation();
|
||
|
||
if (content.style.display === 'table-row' || content.style.display === '') {
|
||
content.style.display = 'none';
|
||
const indicator = subRow.querySelector('.toggle-sub-indicator');
|
||
if (indicator) indicator.textContent = '▼';
|
||
saveCollapsibleState(targetId, false); // 保存折叠状态
|
||
} else {
|
||
content.style.display = 'table-row';
|
||
const indicator = subRow.querySelector('.toggle-sub-indicator');
|
||
if (indicator) indicator.textContent = '▲';
|
||
saveCollapsibleState(targetId, true); // 保存展开状态
|
||
}
|
||
}
|
||
|
||
// 折叠标题功能
|
||
function initCollapsibleHeaders() {
|
||
const headers = document.querySelectorAll('.collapsible-header');
|
||
headers.forEach((header) => {
|
||
header.addEventListener('click', function () {
|
||
const targetId = this.getAttribute('data-target');
|
||
if (!targetId) return;
|
||
|
||
const content = document.getElementById(targetId);
|
||
if (!content) return;
|
||
|
||
const indicator = this.querySelector('.toggle-indicator');
|
||
|
||
if (content.style.display === 'none') {
|
||
content.style.display = 'block';
|
||
if (indicator) indicator.textContent = '▲';
|
||
saveCollapsibleState(targetId, true); // 保存展开状态
|
||
} else {
|
||
content.style.display = 'none';
|
||
if (indicator) indicator.textContent = '▼';
|
||
saveCollapsibleState(targetId, false); // 保存折叠状态
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// 初始化导航标签跳转
|
||
function initScrollToSections() {
|
||
const navTabs = document.querySelectorAll('.nav-tab');
|
||
navTabs.forEach((tab) => {
|
||
tab.addEventListener('click', function (e) {
|
||
const targetId = this.getAttribute('href');
|
||
// 只对内部锚链接(以#开头)应用滚动行为
|
||
if (targetId && targetId.startsWith('#')) {
|
||
e.preventDefault();
|
||
const targetElement = document.querySelector(targetId);
|
||
if (targetElement) {
|
||
targetElement.scrollIntoView({
|
||
behavior: 'smooth',
|
||
});
|
||
}
|
||
}
|
||
// 对于外部链接(如 /status, /registry-editor),不调用preventDefault(),让浏览器处理默认行为
|
||
});
|
||
});
|
||
}
|
||
|
||
// 初始化返回顶部按钮
|
||
function initBackToTop() {
|
||
const backToTopBtn = document.getElementById('back-to-top');
|
||
if (!backToTopBtn) return;
|
||
|
||
// 显示/隐藏按钮
|
||
window.addEventListener('scroll', function () {
|
||
if (
|
||
document.body.scrollTop > 20 ||
|
||
document.documentElement.scrollTop > 20
|
||
) {
|
||
backToTopBtn.style.display = 'block';
|
||
} else {
|
||
backToTopBtn.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
// 点击返回顶部
|
||
backToTopBtn.addEventListener('click', function () {
|
||
window.scrollTo({
|
||
top: 0,
|
||
behavior: 'smooth',
|
||
});
|
||
});
|
||
}
|
||
|
||
// 复制功能
|
||
function copyToClipboard(text, event) {
|
||
if (event) event.stopPropagation();
|
||
const textToCopy = text.trim();
|
||
|
||
// 使用现代API复制
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard
|
||
.writeText(textToCopy)
|
||
.then(() => {
|
||
showCopySuccess(event.target);
|
||
})
|
||
.catch((err) => {
|
||
console.error('复制失败: ', err);
|
||
fallbackCopy(textToCopy, event.target);
|
||
});
|
||
} else {
|
||
fallbackCopy(textToCopy, event.target);
|
||
}
|
||
}
|
||
|
||
// 复制功能的后备方案
|
||
function fallbackCopy(text, button) {
|
||
const textarea = document.createElement('textarea');
|
||
textarea.value = text;
|
||
textarea.style.position = 'fixed';
|
||
textarea.style.left = '-9999px';
|
||
document.body.appendChild(textarea);
|
||
textarea.select();
|
||
|
||
try {
|
||
const successful = document.execCommand('copy');
|
||
if (successful) {
|
||
showCopySuccess(button);
|
||
} else {
|
||
alert('复制失败,请手动复制');
|
||
}
|
||
} catch (err) {
|
||
console.error('复制失败: ', err);
|
||
alert('复制失败,请手动复制');
|
||
}
|
||
|
||
document.body.removeChild(textarea);
|
||
}
|
||
|
||
// 显示复制成功
|
||
function showCopySuccess(button) {
|
||
const originalText = button.textContent;
|
||
button.textContent = '已复制!';
|
||
button.classList.add('copy-success');
|
||
|
||
setTimeout(() => {
|
||
button.textContent = originalText;
|
||
button.classList.remove('copy-success');
|
||
}, 1500);
|
||
}
|
||
|
||
// 调试信息切换
|
||
function toggleDebugInfo(btn, event) {
|
||
event.stopPropagation();
|
||
const debugInfo = btn.nextElementSibling;
|
||
if (debugInfo.style.display === 'none' || debugInfo.style.display === '') {
|
||
debugInfo.style.display = 'block';
|
||
btn.textContent = '隐藏调试';
|
||
} else {
|
||
debugInfo.style.display = 'none';
|
||
btn.textContent = '调试';
|
||
}
|
||
}
|
||
|
||
// 显示实时更新指示器
|
||
function showLiveUpdateIndicator() {
|
||
// 创建实时更新指示器
|
||
const indicator = document.createElement('div');
|
||
indicator.id = 'live-update-indicator';
|
||
indicator.innerHTML = `
|
||
<div class="live-indicator">
|
||
<span class="live-dot"></span>
|
||
<span class="live-text">实时更新</span>
|
||
<span class="last-update" id="last-update-time">刚刚</span>
|
||
</div>
|
||
`;
|
||
|
||
// 插入到页面头部
|
||
const header = document.querySelector('h1');
|
||
if (header) {
|
||
header.parentNode.insertBefore(indicator, header.nextSibling);
|
||
}
|
||
}
|
||
|
||
// 更新最后刷新时间
|
||
function updateLastRefreshTime() {
|
||
const lastUpdateElement = document.getElementById('last-update-time');
|
||
if (lastUpdateElement) {
|
||
const now = new Date();
|
||
lastUpdateElement.textContent = `最后更新: ${now.toLocaleTimeString()}`;
|
||
}
|
||
}
|
||
</script>
|
||
<style>
|
||
/* 基础样式改进 */
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||
}
|
||
|
||
.card {
|
||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||
border-radius: 8px;
|
||
margin-bottom: 30px;
|
||
padding: 20px;
|
||
background-color: #fff;
|
||
transition: all 0.3s ease;
|
||
scroll-margin-top: 20px; /* 为锚点添加滚动边距 */
|
||
}
|
||
|
||
.card h2 {
|
||
margin-top: 0;
|
||
color: #333;
|
||
border-bottom: 2px solid #eee;
|
||
padding-bottom: 10px;
|
||
}
|
||
|
||
/* 导航标签美化 */
|
||
.nav-tabs {
|
||
display: flex;
|
||
margin-bottom: 20px;
|
||
border-bottom: 1px solid #ddd;
|
||
padding-bottom: 5px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.nav-tab {
|
||
padding: 8px 16px;
|
||
margin-right: 5px;
|
||
background-color: #f5f5f5;
|
||
border-radius: 5px 5px 0 0;
|
||
text-decoration: none;
|
||
color: #555;
|
||
transition: all 0.2s ease;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.nav-tab:hover {
|
||
background-color: #e0e0e0;
|
||
color: #333;
|
||
}
|
||
|
||
/* 表格样式美化 */
|
||
.responsive-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
margin-bottom: 20px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.responsive-table th {
|
||
background-color: #f5f5f5;
|
||
color: #333;
|
||
font-weight: 600;
|
||
text-align: left;
|
||
padding: 12px 15px;
|
||
border-bottom: 2px solid #ddd;
|
||
}
|
||
|
||
.responsive-table td {
|
||
padding: 10px 15px;
|
||
border-bottom: 1px solid #eee;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
/* 可折叠标题样式 */
|
||
.collapsible-header {
|
||
cursor: pointer;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
padding: 10px 0;
|
||
border-bottom: 2px solid #eee;
|
||
transition: color 0.2s ease;
|
||
}
|
||
|
||
.collapsible-header:hover {
|
||
color: #4285f4;
|
||
}
|
||
|
||
.collapsible-content {
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
/* 可折叠行美化 */
|
||
.collapsible-row {
|
||
transition: background-color 0.2s ease;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.collapsible-row:hover {
|
||
background-color: #f9f9f9;
|
||
}
|
||
|
||
.collapsible-row.active {
|
||
background-color: #f0f7ff;
|
||
border-left: 3px solid #4285f4;
|
||
}
|
||
|
||
.toggle-indicator,
|
||
.toggle-sub-indicator {
|
||
font-size: 12px;
|
||
color: #888;
|
||
float: right;
|
||
transition: transform 0.2s ease;
|
||
}
|
||
|
||
.collapsible-row.active .toggle-indicator {
|
||
transform: rotate(180deg);
|
||
color: #4285f4;
|
||
}
|
||
|
||
/* 详情行样式 */
|
||
.detail-row td {
|
||
padding: 0;
|
||
}
|
||
|
||
.content-full {
|
||
padding: 15px;
|
||
background-color: #fafafa;
|
||
border-top: 1px solid #eee;
|
||
}
|
||
|
||
/* 子表格(内部表格)样式 */
|
||
.inner-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
margin: 10px 0;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.inner-table th {
|
||
background-color: #eef2f7;
|
||
padding: 8px 10px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.inner-table td {
|
||
padding: 8px 10px;
|
||
border-bottom: 1px solid #eee;
|
||
}
|
||
|
||
/* 状态和动作行样式 */
|
||
.topic-row,
|
||
.action-row {
|
||
background-color: #f9f9f9;
|
||
cursor: pointer;
|
||
transition: background-color 0.2s ease;
|
||
}
|
||
|
||
.topic-row:hover,
|
||
.action-row:hover {
|
||
background-color: #f0f0f0;
|
||
}
|
||
|
||
/* 复制按钮美化 */
|
||
.toggle-cmd-btn {
|
||
background: #f0f0f0;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
padding: 4px 8px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.toggle-cmd-btn:hover {
|
||
background: #e0e0e0;
|
||
}
|
||
|
||
.copy-btn {
|
||
background: #eef;
|
||
border: 1px solid #ccf;
|
||
border-radius: 4px;
|
||
padding: 4px 10px;
|
||
cursor: pointer;
|
||
margin-left: 10px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.copy-btn:hover {
|
||
background: #ddf;
|
||
}
|
||
|
||
.copy-success {
|
||
background: #dffddf !important;
|
||
color: #2c7c2c !important;
|
||
}
|
||
|
||
.debug-btn {
|
||
background: #fee;
|
||
border: 1px solid #fcc;
|
||
border-radius: 4px;
|
||
padding: 4px 10px;
|
||
cursor: pointer;
|
||
margin-left: 5px;
|
||
font-size: 12px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.debug-btn:hover {
|
||
background: #fdd;
|
||
}
|
||
|
||
/* 命令块样式优化 */
|
||
.cmd-block {
|
||
background: #f8f8f8;
|
||
border: 1px solid #eee;
|
||
border-radius: 4px;
|
||
padding: 12px;
|
||
margin: 8px 0;
|
||
}
|
||
|
||
.cmd-line {
|
||
display: flex;
|
||
align-items: center;
|
||
margin: 8px 0;
|
||
background: #fff;
|
||
padding: 8px 12px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
overflow-x: auto;
|
||
font-family: 'Consolas', 'Monaco', monospace;
|
||
}
|
||
|
||
.cmd-line pre {
|
||
margin: 0;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
flex: 1;
|
||
}
|
||
|
||
/* 路径和文件夹链接样式 */
|
||
.folder-link {
|
||
cursor: pointer;
|
||
margin-left: 5px;
|
||
color: #4285f4;
|
||
transition: transform 0.2s ease;
|
||
}
|
||
|
||
.folder-link:hover {
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
.path-list {
|
||
list-style-type: none;
|
||
padding-left: 10px;
|
||
}
|
||
|
||
.path-list li {
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
/* 返回顶部按钮 */
|
||
#back-to-top {
|
||
display: none;
|
||
position: fixed;
|
||
bottom: 20px;
|
||
right: 20px;
|
||
z-index: 99;
|
||
font-size: 18px;
|
||
border: none;
|
||
outline: none;
|
||
background-color: #4285f4;
|
||
color: white;
|
||
cursor: pointer;
|
||
padding: 10px 15px;
|
||
border-radius: 50%;
|
||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
#back-to-top:hover {
|
||
background-color: #3367d6;
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
/* 在线状态标签 */
|
||
.online-status {
|
||
display: inline-block;
|
||
background-color: #4caf50;
|
||
color: white;
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* 详情列表样式 */
|
||
.detail-list {
|
||
padding-left: 15px;
|
||
}
|
||
|
||
.detail-list li {
|
||
margin-bottom: 15px;
|
||
padding-bottom: 12px;
|
||
border-bottom: 1px dashed #eee;
|
||
}
|
||
|
||
/* 提示文本样式 */
|
||
.goal-tip {
|
||
font-size: 12px;
|
||
color: #666;
|
||
margin-top: 8px;
|
||
font-style: italic;
|
||
background: #fffde7;
|
||
padding: 5px 8px;
|
||
border-radius: 3px;
|
||
border-left: 3px solid #ffd54f;
|
||
}
|
||
|
||
/* 调试信息样式 */
|
||
.debug-info {
|
||
margin-top: 8px;
|
||
background: #fffde7;
|
||
border: 1px solid #fff9c4;
|
||
border-radius: 4px;
|
||
padding: 10px;
|
||
font-size: 12px;
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
/* 时间戳样式 */
|
||
.timestamp {
|
||
font-size: 13px;
|
||
background-color: #f5f5f5;
|
||
padding: 3px 6px;
|
||
border-radius: 3px;
|
||
color: #555;
|
||
transition: background-color 0.3s ease;
|
||
}
|
||
|
||
.timestamp.not-updated {
|
||
background-color: #fff3e0;
|
||
color: #e65100;
|
||
}
|
||
|
||
.timestamp[title]:hover::after {
|
||
content: '时间戳: ' attr(title);
|
||
position: absolute;
|
||
background: #333;
|
||
color: #fff;
|
||
padding: 5px 10px;
|
||
border-radius: 4px;
|
||
z-index: 10;
|
||
font-size: 12px;
|
||
margin-top: 5px;
|
||
margin-left: -10px;
|
||
}
|
||
|
||
/* 响应式样式优化 */
|
||
@media screen and (max-width: 768px) {
|
||
.responsive-table {
|
||
width: 100%;
|
||
overflow-x: auto;
|
||
display: block;
|
||
}
|
||
|
||
.nav-tabs {
|
||
overflow-x: auto;
|
||
white-space: nowrap;
|
||
display: block;
|
||
padding-bottom: 10px;
|
||
}
|
||
|
||
.nav-tab {
|
||
display: inline-block;
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
.file-path {
|
||
max-width: 150px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.cmd-block {
|
||
padding: 10px;
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.cmd-block pre {
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.device-row td:nth-child(3) {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
/* 动画效果 */
|
||
@keyframes fadeIn {
|
||
from {
|
||
opacity: 0;
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
.detail-row {
|
||
animation: fadeIn 0.3s ease;
|
||
}
|
||
|
||
/* 连接状态样式 */
|
||
.connection-status {
|
||
position: fixed;
|
||
top: 20px;
|
||
right: 20px;
|
||
padding: 10px 15px;
|
||
border-radius: 4px;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||
background-color: #f5f5f5;
|
||
z-index: 1000;
|
||
display: flex;
|
||
align-items: center;
|
||
font-size: 14px;
|
||
transition: opacity 0.3s ease;
|
||
}
|
||
|
||
.connection-status .status-dot {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
margin-right: 8px;
|
||
}
|
||
|
||
.connection-status.connecting .status-dot {
|
||
background-color: #ffc107;
|
||
animation: pulse 1.5s infinite;
|
||
}
|
||
|
||
.connection-status.connected .status-dot {
|
||
background-color: #4caf50;
|
||
}
|
||
|
||
.connection-status.disconnected .status-dot {
|
||
background-color: #9e9e9e;
|
||
}
|
||
|
||
.connection-status.reconnecting .status-dot {
|
||
background-color: #ff9800;
|
||
animation: pulse 1.5s infinite;
|
||
}
|
||
|
||
.connection-status.error .status-dot {
|
||
background-color: #f44336;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0% {
|
||
opacity: 0.5;
|
||
}
|
||
50% {
|
||
opacity: 1;
|
||
}
|
||
100% {
|
||
opacity: 0.5;
|
||
}
|
||
}
|
||
|
||
/* 实时更新指示器样式 */
|
||
#live-update-indicator {
|
||
position: fixed;
|
||
top: 70px;
|
||
right: 20px;
|
||
z-index: 1000;
|
||
background-color: #f8f9fa;
|
||
border: 1px solid #dee2e6;
|
||
border-radius: 6px;
|
||
padding: 8px 12px;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.live-indicator {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.live-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background-color: #4caf50;
|
||
animation: pulse 2s infinite;
|
||
}
|
||
|
||
.live-text {
|
||
color: #555;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.last-update {
|
||
color: #777;
|
||
font-size: 12px;
|
||
padding: 2px 6px;
|
||
border-radius: 3px;
|
||
transition: all 0.3s ease;
|
||
}
|
||
</style>
|
||
{% endblock %}
|