mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2025-12-17 13:01:12 +00:00
1245 lines
41 KiB
HTML
1245 lines
41 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}UniLab System Status{% endblock %}
|
||
|
||
{% block header %}UniLab System Status{% endblock %}
|
||
|
||
{% block top_info %}
|
||
<!-- 系统模式显示 -->
|
||
<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>Device Types</h2>
|
||
<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">
|
||
<pre>{{ device.class|tojson(indent=4) }}</pre>
|
||
|
||
{% 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;">
|
||
<pre>{{ action_info|tojson(indent=2) }}</pre>
|
||
</div>
|
||
</div>
|
||
|
||
<p class="goal-tip">提示: 根据目标结构修改命令参数</p>
|
||
</div>
|
||
</li>
|
||
{% endfor %}
|
||
</ul>
|
||
{% endif %}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</table>
|
||
</div>
|
||
|
||
<!-- 资源部分 -->
|
||
<div class="card" id="resources-section">
|
||
<h2>Resource Types</h2>
|
||
<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 class="card" id="modules-section">
|
||
<h2>Converter Modules</h2>
|
||
<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>
|
||
|
||
<!-- 返回顶部按钮 -->
|
||
<button id="back-to-top" title="返回顶部">↑</button>
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
{{ super() }}
|
||
<script>
|
||
// 在页面加载完成后执行初始化
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
initFolderOpener();
|
||
initCollapsibleRows();
|
||
initScrollToSections();
|
||
initBackToTop();
|
||
initWebSocket();
|
||
});
|
||
|
||
// 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/device_status`;
|
||
|
||
showConnectionStatus('connecting', '正在连接服务器...');
|
||
|
||
ws = new WebSocket(wsUrl);
|
||
|
||
ws.onopen = function() {
|
||
console.log('WebSocket连接已建立');
|
||
showConnectionStatus('connected', '已连接到服务器');
|
||
reconnectAttempts = 0;
|
||
};
|
||
|
||
ws.onmessage = function(event) {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
if (data.type === 'device_status') {
|
||
updateDeviceStatus(data.data);
|
||
}
|
||
} 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 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() {
|
||
// 设备行点击事件
|
||
const deviceRows = document.querySelectorAll('.collapsible-row');
|
||
deviceRows.forEach(row => {
|
||
row.addEventListener("click", function() {
|
||
const targetId = this.getAttribute('data-target');
|
||
if (!targetId) return;
|
||
|
||
const content = document.getElementById(targetId);
|
||
if (!content) return;
|
||
|
||
this.classList.toggle("active");
|
||
|
||
if (content.style.display === "table-row") {
|
||
content.style.display = "none";
|
||
const indicator = this.querySelector('.toggle-indicator');
|
||
if (indicator) indicator.textContent = "▼";
|
||
} else {
|
||
content.style.display = "table-row";
|
||
const indicator = this.querySelector('.toggle-indicator');
|
||
if (indicator) indicator.textContent = "▲";
|
||
}
|
||
});
|
||
});
|
||
|
||
// 子行点击事件(状态和动作行)
|
||
const subRows = document.querySelectorAll('.collapsible-sub-row');
|
||
subRows.forEach(row => {
|
||
row.addEventListener("click", function(e) {
|
||
const targetId = this.getAttribute('data-target');
|
||
if (!targetId) return;
|
||
|
||
const content = document.getElementById(targetId);
|
||
if (!content) return;
|
||
|
||
if (content.style.display === "table-row" || content.style.display === "") {
|
||
content.style.display = "none";
|
||
const indicator = this.querySelector('.toggle-sub-indicator');
|
||
if (indicator) indicator.textContent = "▼";
|
||
} else {
|
||
content.style.display = "table-row";
|
||
const indicator = this.querySelector('.toggle-sub-indicator');
|
||
if (indicator) indicator.textContent = "▲";
|
||
}
|
||
|
||
// 阻止事件冒泡
|
||
e.stopPropagation();
|
||
});
|
||
});
|
||
}
|
||
|
||
// 初始化导航标签跳转
|
||
function initScrollToSections() {
|
||
const navTabs = document.querySelectorAll('.nav-tab');
|
||
navTabs.forEach(tab => {
|
||
tab.addEventListener('click', function(e) {
|
||
e.preventDefault();
|
||
const targetId = this.getAttribute('href');
|
||
if (targetId && targetId.startsWith('#')) {
|
||
const targetElement = document.querySelector(targetId);
|
||
if (targetElement) {
|
||
targetElement.scrollIntoView({
|
||
behavior: 'smooth'
|
||
});
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// 初始化返回顶部按钮
|
||
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 = "调试";
|
||
}
|
||
}
|
||
</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-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; }
|
||
}
|
||
</style>
|
||
{% endblock %} |