mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2025-12-17 04:51:10 +00:00
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 %}
|