|
@@ -286,6 +286,66 @@
|
|
|
white-space: nowrap;
|
|
white-space: nowrap;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ .device-detail-cell {
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ color: #667eea;
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ padding: 8px 12px;
|
|
|
|
|
+ border-radius: 5px;
|
|
|
|
|
+ transition: all 0.2s;
|
|
|
|
|
+ max-width: 300px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .device-detail-cell:hover {
|
|
|
|
|
+ background: #f3f4f6;
|
|
|
|
|
+ color: #5568d3;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .device-detail-cell .device-count {
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ color: #667eea;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .device-detail-cell .device-preview {
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ color: #6b7280;
|
|
|
|
|
+ margin-top: 4px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* 设备列表弹框样式 */
|
|
|
|
|
+ .device-list-modal .modal-content {
|
|
|
|
|
+ max-width: 700px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .device-list-table {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ border-collapse: collapse;
|
|
|
|
|
+ margin-top: 20px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .device-list-table th,
|
|
|
|
|
+ .device-list-table td {
|
|
|
|
|
+ padding: 12px;
|
|
|
|
|
+ text-align: left;
|
|
|
|
|
+ border-bottom: 1px solid #e5e7eb;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .device-list-table th {
|
|
|
|
|
+ background: #f9fafb;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ color: #374151;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .device-list-table tr:hover {
|
|
|
|
|
+ background: #f9fafb;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .device-list-empty {
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ padding: 40px;
|
|
|
|
|
+ color: #6b7280;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
.actions {
|
|
.actions {
|
|
|
display: flex;
|
|
display: flex;
|
|
|
gap: 8px;
|
|
gap: 8px;
|
|
@@ -550,9 +610,7 @@
|
|
|
</th>
|
|
</th>
|
|
|
<th>ID</th>
|
|
<th>ID</th>
|
|
|
<th>激活码</th>
|
|
<th>激活码</th>
|
|
|
- <th>最大设备数</th>
|
|
|
|
|
- <th>已绑定设备</th>
|
|
|
|
|
- <th>设备激活时间</th>
|
|
|
|
|
|
|
+ <th>设备详情</th>
|
|
|
<th>绑定设备数</th>
|
|
<th>绑定设备数</th>
|
|
|
<th>创建时间</th>
|
|
<th>创建时间</th>
|
|
|
<th>操作</th>
|
|
<th>操作</th>
|
|
@@ -629,6 +687,19 @@
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
|
|
+ <!-- 设备列表 Modal -->
|
|
|
|
|
+ <div id="deviceListModal" class="modal device-list-modal">
|
|
|
|
|
+ <div class="modal-content">
|
|
|
|
|
+ <div class="modal-header">
|
|
|
|
|
+ <h2 id="device-list-title">设备列表</h2>
|
|
|
|
|
+ <button class="close" onclick="closeDeviceListModal()">×</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div id="device-list-content">
|
|
|
|
|
+ <div class="device-list-empty">加载中...</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
<script>
|
|
<script>
|
|
|
// 使用相对路径,自动适配当前域名
|
|
// 使用相对路径,自动适配当前域名
|
|
|
const API_BASE = '/api';
|
|
const API_BASE = '/api';
|
|
@@ -926,19 +997,6 @@
|
|
|
deviceActivations = {};
|
|
deviceActivations = {};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 构建设备激活时间显示
|
|
|
|
|
- let activationTimesHtml = '无';
|
|
|
|
|
- if (boundDevices.length > 0) {
|
|
|
|
|
- activationTimesHtml = boundDevices.map(deviceId => {
|
|
|
|
|
- const activationTime = deviceActivations[deviceId];
|
|
|
|
|
- if (activationTime) {
|
|
|
|
|
- const date = new Date(activationTime);
|
|
|
|
|
- return `${deviceId}<br><small style="color: #6b7280;">${date.toLocaleString('zh-CN')}</small>`;
|
|
|
|
|
- }
|
|
|
|
|
- return `${deviceId}<br><small style="color: #9ca3af;">未记录</small>`;
|
|
|
|
|
- }).join('<br><br>');
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
const boundCount = boundDevices.length;
|
|
const boundCount = boundDevices.length;
|
|
|
const isFull = boundCount >= license.max_devices;
|
|
const isFull = boundCount >= license.max_devices;
|
|
|
const createdDate = new Date(license.created_at).toLocaleString('zh-CN');
|
|
const createdDate = new Date(license.created_at).toLocaleString('zh-CN');
|
|
@@ -946,6 +1004,33 @@
|
|
|
// 限制激活码显示长度为10个字符
|
|
// 限制激活码显示长度为10个字符
|
|
|
const displayKey = license.key.length > 10 ? license.key.substring(0, 10) + '...' : license.key;
|
|
const displayKey = license.key.length > 10 ? license.key.substring(0, 10) + '...' : license.key;
|
|
|
|
|
|
|
|
|
|
+ // 构建设备详情显示
|
|
|
|
|
+ let deviceDetailHtml = '';
|
|
|
|
|
+ if (boundDevices.length === 0) {
|
|
|
|
|
+ deviceDetailHtml = '<span style="color: #9ca3af;">无设备</span>';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 显示前2个设备作为预览
|
|
|
|
|
+ const previewDevices = boundDevices.slice(0, 2);
|
|
|
|
|
+ const previewText = previewDevices.map(deviceId => {
|
|
|
|
|
+ const activationTime = deviceActivations[deviceId];
|
|
|
|
|
+ if (activationTime) {
|
|
|
|
|
+ const date = new Date(activationTime);
|
|
|
|
|
+ return `${deviceId} (${date.toLocaleString('zh-CN')})`;
|
|
|
|
|
+ }
|
|
|
|
|
+ return `${deviceId} (未记录)`;
|
|
|
|
|
+ }).join('、');
|
|
|
|
|
+
|
|
|
|
|
+ const moreCount = boundDevices.length - 2;
|
|
|
|
|
+ // 使用 data 属性安全传递 licenseKey
|
|
|
|
|
+ const escapedKey = license.key.replace(/"/g, '"').replace(/'/g, ''');
|
|
|
|
|
+ deviceDetailHtml = `
|
|
|
|
|
+ <div class="device-detail-cell" data-license-id="${license.id}" data-license-key="${escapedKey}" onclick="showDeviceListFromElement(this)">
|
|
|
|
|
+ <div class="device-count">${boundCount} 个设备</div>
|
|
|
|
|
+ <div class="device-preview">${previewText}${moreCount > 0 ? ` 等${moreCount}个...` : ''}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
return `
|
|
return `
|
|
|
<tr>
|
|
<tr>
|
|
|
<td style="text-align: center;">
|
|
<td style="text-align: center;">
|
|
@@ -960,12 +1045,8 @@
|
|
|
</button>
|
|
</button>
|
|
|
</div>
|
|
</div>
|
|
|
</td>
|
|
</td>
|
|
|
- <td>${license.max_devices}</td>
|
|
|
|
|
- <td class="device-list" title="${boundDevices.join(', ') || '无'}">
|
|
|
|
|
- ${boundDevices.length > 0 ? boundDevices.join(', ') : '无'}
|
|
|
|
|
- </td>
|
|
|
|
|
- <td style="font-size: 12px; line-height: 1.6; max-width: 300px;">
|
|
|
|
|
- ${activationTimesHtml}
|
|
|
|
|
|
|
+ <td>
|
|
|
|
|
+ ${deviceDetailHtml}
|
|
|
</td>
|
|
</td>
|
|
|
<td>
|
|
<td>
|
|
|
<span class="badge ${isFull ? 'badge-warning' : 'badge-success'}">
|
|
<span class="badge ${isFull ? 'badge-warning' : 'badge-success'}">
|
|
@@ -1206,10 +1287,151 @@
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // 从元素获取数据并显示设备列表弹框
|
|
|
|
|
+ function showDeviceListFromElement(element) {
|
|
|
|
|
+ const licenseId = parseInt(element.getAttribute('data-license-id'));
|
|
|
|
|
+ const licenseKey = element.getAttribute('data-license-key');
|
|
|
|
|
+ showDeviceList(licenseId, licenseKey);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 显示设备列表弹框
|
|
|
|
|
+ async function showDeviceList(licenseId, licenseKey) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const response = await apiRequest(`${API_BASE}/licenses/${licenseId}`);
|
|
|
|
|
+ if (!response) return;
|
|
|
|
|
+
|
|
|
|
|
+ const result = await response.json();
|
|
|
|
|
+
|
|
|
|
|
+ if (result.code === 0) {
|
|
|
|
|
+ const license = result.data;
|
|
|
|
|
+ let boundDevices = [];
|
|
|
|
|
+ try {
|
|
|
|
|
+ boundDevices = JSON.parse(license.bound_devices || '[]');
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ boundDevices = [];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 解析设备激活时间
|
|
|
|
|
+ let deviceActivations = {};
|
|
|
|
|
+ try {
|
|
|
|
|
+ const activationsStr = JSON.parse(license.device_activations || '{}');
|
|
|
|
|
+ deviceActivations = activationsStr;
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ deviceActivations = {};
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 解析设备心跳时间
|
|
|
|
|
+ let deviceHeartbeats = {};
|
|
|
|
|
+ try {
|
|
|
|
|
+ const heartbeatsStr = JSON.parse(license.device_heartbeats || '{}');
|
|
|
|
|
+ deviceHeartbeats = heartbeatsStr;
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ deviceHeartbeats = {};
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 设置标题
|
|
|
|
|
+ document.getElementById('device-list-title').textContent = `设备列表 - ${licenseKey}`;
|
|
|
|
|
+
|
|
|
|
|
+ // 渲染设备列表
|
|
|
|
|
+ const contentEl = document.getElementById('device-list-content');
|
|
|
|
|
+ if (boundDevices.length === 0) {
|
|
|
|
|
+ contentEl.innerHTML = `
|
|
|
|
|
+ <div class="device-list-empty">
|
|
|
|
|
+ <p>暂无绑定设备</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const tableHtml = `
|
|
|
|
|
+ <table class="device-list-table">
|
|
|
|
|
+ <thead>
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ <th>序号</th>
|
|
|
|
|
+ <th>设备ID</th>
|
|
|
|
|
+ <th>激活时间</th>
|
|
|
|
|
+ <th>最近心跳时间</th>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </thead>
|
|
|
|
|
+ <tbody>
|
|
|
|
|
+ ${boundDevices.map((deviceId, index) => {
|
|
|
|
|
+ const activationTime = deviceActivations[deviceId];
|
|
|
|
|
+ let timeDisplay = '<span style="color: #9ca3af;">未记录</span>';
|
|
|
|
|
+ if (activationTime) {
|
|
|
|
|
+ const date = new Date(activationTime);
|
|
|
|
|
+ timeDisplay = date.toLocaleString('zh-CN');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 处理心跳时间显示
|
|
|
|
|
+ const heartbeatTime = deviceHeartbeats[deviceId];
|
|
|
|
|
+ let heartbeatDisplay = '<span style="color: #9ca3af;">未记录</span>';
|
|
|
|
|
+ if (heartbeatTime) {
|
|
|
|
|
+ const heartbeatDate = new Date(heartbeatTime);
|
|
|
|
|
+ const now = new Date();
|
|
|
|
|
+ const diff = now - heartbeatDate;
|
|
|
|
|
+ const seconds = Math.floor(diff / 1000);
|
|
|
|
|
+ const minutes = Math.floor(seconds / 60);
|
|
|
|
|
+ const hours = Math.floor(minutes / 60);
|
|
|
|
|
+ const days = Math.floor(hours / 24);
|
|
|
|
|
+
|
|
|
|
|
+ // 格式化相对时间
|
|
|
|
|
+ let relativeTime = '';
|
|
|
|
|
+ let heartbeatColor = '#6b7280'; // 默认灰色
|
|
|
|
|
+ if (days > 0) {
|
|
|
|
|
+ relativeTime = `${days}天前`;
|
|
|
|
|
+ heartbeatColor = '#ef4444'; // 红色 - 很久没心跳
|
|
|
|
|
+ } else if (hours > 0) {
|
|
|
|
|
+ relativeTime = `${hours}小时前`;
|
|
|
|
|
+ heartbeatColor = '#f59e0b'; // 橙色 - 较久
|
|
|
|
|
+ } else if (minutes > 0) {
|
|
|
|
|
+ relativeTime = `${minutes}分钟前`;
|
|
|
|
|
+ heartbeatColor = minutes < 10 ? '#10b981' : '#f59e0b'; // 绿色(10分钟内)或橙色
|
|
|
|
|
+ } else if (seconds > 0) {
|
|
|
|
|
+ relativeTime = `${seconds}秒前`;
|
|
|
|
|
+ heartbeatColor = '#10b981'; // 绿色
|
|
|
|
|
+ } else {
|
|
|
|
|
+ relativeTime = '刚刚';
|
|
|
|
|
+ heartbeatColor = '#10b981'; // 绿色
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ heartbeatDisplay = `${heartbeatDate.toLocaleString('zh-CN')} <span style="color: ${heartbeatColor}; font-size: 11px; font-weight: 400; margin-left: 6px;">(${relativeTime})</span>`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return `
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ <td>${index + 1}</td>
|
|
|
|
|
+ <td><strong>${deviceId}</strong></td>
|
|
|
|
|
+ <td>${timeDisplay}</td>
|
|
|
|
|
+ <td>${heartbeatDisplay}</td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ `;
|
|
|
|
|
+ }).join('')}
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ <div style="margin-top: 15px; color: #6b7280; font-size: 14px; text-align: right;">
|
|
|
|
|
+ 共 ${boundDevices.length} 个设备
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+ contentEl.innerHTML = tableHtml;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ document.getElementById('deviceListModal').classList.add('show');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ showToast('加载失败: ' + result.msg, 'error');
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ showToast('请求失败: ' + error.message, 'error');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 关闭设备列表弹框
|
|
|
|
|
+ function closeDeviceListModal() {
|
|
|
|
|
+ document.getElementById('deviceListModal').classList.remove('show');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// 点击 Modal 外部关闭
|
|
// 点击 Modal 外部关闭
|
|
|
window.onclick = function(event) {
|
|
window.onclick = function(event) {
|
|
|
const licenseModal = document.getElementById('licenseModal');
|
|
const licenseModal = document.getElementById('licenseModal');
|
|
|
const batchModal = document.getElementById('batchModal');
|
|
const batchModal = document.getElementById('batchModal');
|
|
|
|
|
+ const deviceListModal = document.getElementById('deviceListModal');
|
|
|
const confirmDialog = document.getElementById('confirmDialog');
|
|
const confirmDialog = document.getElementById('confirmDialog');
|
|
|
|
|
|
|
|
if (event.target === licenseModal) {
|
|
if (event.target === licenseModal) {
|
|
@@ -1218,6 +1440,9 @@
|
|
|
if (event.target === batchModal) {
|
|
if (event.target === batchModal) {
|
|
|
closeBatchModal();
|
|
closeBatchModal();
|
|
|
}
|
|
}
|
|
|
|
|
+ if (event.target === deviceListModal) {
|
|
|
|
|
+ closeDeviceListModal();
|
|
|
|
|
+ }
|
|
|
if (event.target === confirmDialog) {
|
|
if (event.target === confirmDialog) {
|
|
|
closeConfirmDialog(false);
|
|
closeConfirmDialog(false);
|
|
|
}
|
|
}
|