Jelajahi Sumber

Feature #TASK_QT-34190 init

Carl 2 hari lalu
induk
melakukan
993a78e68e
4 mengubah file dengan 315 tambahan dan 39 penghapusan
  1. 4 3
      database/init.sql
  2. 11 7
      main.go
  3. 53 7
      models/license.go
  4. 247 22
      web/index.html

+ 4 - 3
database/init.sql

@@ -14,7 +14,8 @@ CREATE TABLE IF NOT EXISTS `licenses` (
   `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
   `license_key` varchar(255) NOT NULL COMMENT '激活码',
   `bound_devices` text COMMENT '已绑定设备列表(JSON数组)',
-  `device_activations` text COMMENT '设备激活时间(JSON对象)',
+  `device_activations` text COMMENT '设备首次激活时间(JSON对象)',
+  `device_heartbeats` text COMMENT '设备心跳时间(JSON对象,最后验证时间)',
   `max_devices` int NOT NULL DEFAULT '2' COMMENT '最大设备数',
   `created_at` datetime(3) DEFAULT NULL COMMENT '创建时间',
   `updated_at` datetime(3) DEFAULT NULL COMMENT '更新时间',
@@ -24,8 +25,8 @@ CREATE TABLE IF NOT EXISTS `licenses` (
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='许可证表';
 
 -- 插入测试数据(可选)
-INSERT INTO `licenses` (`license_key`, `bound_devices`, `device_activations`, `max_devices`, `created_at`, `updated_at`)
+INSERT INTO `licenses` (`license_key`, `bound_devices`, `device_activations`, `device_heartbeats`, `max_devices`, `created_at`, `updated_at`)
 VALUES 
-  ('TEST-KEY-123456', '[]', '{}', 2, NOW(), NOW())
+  ('TEST-KEY-123456', '[]', '{}', '{}', 2, NOW(), NOW())
 ON DUPLICATE KEY UPDATE `updated_at` = NOW();
 

+ 11 - 7
main.go

@@ -91,14 +91,18 @@ func main() {
 			// License 管理接口(CRUD)
 			licenses := api.Group("/licenses")
 			{
-				licenses.POST("", handlers.CreateLicense(database.DB))              // 创建 License
-				licenses.POST("/batch", handlers.BatchCreateLicense(database.DB))   // 批量创建 License
-				licenses.DELETE("/batch", handlers.BatchDeleteLicense(database.DB)) // 批量删除 License
-				licenses.GET("", handlers.GetLicenseList(database.DB))              // 获取 License 列表
-				licenses.GET("/:id", handlers.GetLicense(database.DB))              // 获取单个 License
-				licenses.PUT("/:id", handlers.UpdateLicense(database.DB))           // 更新 License
-				licenses.DELETE("/:id", handlers.DeleteLicense(database.DB))        // 删除 License
+				licenses.POST("", handlers.CreateLicense(database.DB))                          // 创建 License
+				licenses.POST("/batch", handlers.BatchCreateLicense(database.DB))               // 批量创建 License
+				licenses.DELETE("/batch", handlers.BatchDeleteLicense(database.DB))             // 批量删除 License
+				licenses.PUT("/batch/max-devices", handlers.BatchUpdateMaxDevices(database.DB)) // 批量修改最大设备数
+				licenses.GET("", handlers.GetLicenseList(database.DB))                          // 获取 License 列表
+				licenses.GET("/export", handlers.ExportLicenses(database.DB))                   // 导出 License 列表
+				licenses.GET("/:id", handlers.GetLicense(database.DB))                          // 获取单个 License
+				licenses.PUT("/:id", handlers.UpdateLicense(database.DB))                       // 更新 License
+				licenses.DELETE("/:id", handlers.DeleteLicense(database.DB))                    // 删除 License
+				licenses.DELETE("/:id/devices/:device_id", handlers.UnbindDevice(database.DB))  // 解绑设备
 			}
+
 		}
 	}
 

+ 53 - 7
models/license.go

@@ -10,7 +10,8 @@ type License struct {
 	ID              uint      `gorm:"primarykey" json:"id"`
 	LicenseKey      string    `gorm:"column:license_key;uniqueIndex;not null" json:"key"`
 	BoundDevices    string    `gorm:"type:text" json:"bound_devices"`        // JSON 数组字符串,例如 '["uuid-1", "uuid-2"]'
-	DeviceActivations string  `gorm:"type:text" json:"device_activations"`  // JSON 对象字符串,例如 '{"uuid-1": "2024-01-01T00:00:00Z", "uuid-2": "2024-01-02T00:00:00Z"}'
+	DeviceActivations string  `gorm:"type:text" json:"device_activations"`  // JSON 对象字符串,记录首次激活时间,例如 '{"uuid-1": "2024-01-01T00:00:00Z", "uuid-2": "2024-01-02T00:00:00Z"}'
+	DeviceHeartbeats  string  `gorm:"type:text" json:"device_heartbeats"`   // JSON 对象字符串,记录心跳时间(最后验证时间),例如 '{"uuid-1": "2024-01-01T12:00:00Z", "uuid-2": "2024-01-02T12:00:00Z"}'
 	MaxDevices      int       `gorm:"default:2" json:"max_devices"`
 	CreatedAt       time.Time `json:"created_at"`
 	UpdatedAt       time.Time `json:"updated_at"`
@@ -85,7 +86,7 @@ func (l *License) GetDeviceActivations() (map[string]time.Time, error) {
 	return result, nil
 }
 
-// RecordDeviceActivation 记录设备激活时间
+// RecordDeviceActivation 记录设备激活时间(仅在首次激活时记录)
 func (l *License) RecordDeviceActivation(deviceID string) error {
 	activations, err := l.GetDeviceActivations()
 	if err != nil {
@@ -95,19 +96,64 @@ func (l *License) RecordDeviceActivation(deviceID string) error {
 	// 如果设备已存在,不更新激活时间;如果不存在,记录当前时间
 	if _, exists := activations[deviceID]; !exists {
 		activations[deviceID] = time.Now()
+		
+		// 转换为字符串格式存储
+		activationsStr := make(map[string]string)
+		for id, t := range activations {
+			activationsStr[id] = t.Format(time.RFC3339)
+		}
+		
+		data, err := json.Marshal(activationsStr)
+		if err != nil {
+			return err
+		}
+		l.DeviceActivations = string(data)
+	}
+	return nil
+}
+
+// GetDeviceHeartbeats 获取设备心跳时间映射
+func (l *License) GetDeviceHeartbeats() (map[string]time.Time, error) {
+	if l.DeviceHeartbeats == "" {
+		return make(map[string]time.Time), nil
+	}
+	var heartbeats map[string]string
+	if err := json.Unmarshal([]byte(l.DeviceHeartbeats), &heartbeats); err != nil {
+		return nil, err
+	}
+	
+	result := make(map[string]time.Time)
+	for deviceID, timeStr := range heartbeats {
+		t, err := time.Parse(time.RFC3339, timeStr)
+		if err != nil {
+			return nil, err
+		}
+		result[deviceID] = t
 	}
+	return result, nil
+}
+
+// UpdateDeviceHeartbeat 更新设备心跳时间(每次验证时更新)
+func (l *License) UpdateDeviceHeartbeat(deviceID string) error {
+	heartbeats, err := l.GetDeviceHeartbeats()
+	if err != nil {
+		heartbeats = make(map[string]time.Time)
+	}
+	
+	// 更新心跳时间为当前时间
+	heartbeats[deviceID] = time.Now()
 	
 	// 转换为字符串格式存储
-	activationsStr := make(map[string]string)
-	for id, t := range activations {
-		activationsStr[id] = t.Format(time.RFC3339)
+	heartbeatsStr := make(map[string]string)
+	for id, t := range heartbeats {
+		heartbeatsStr[id] = t.Format(time.RFC3339)
 	}
 	
-	data, err := json.Marshal(activationsStr)
+	data, err := json.Marshal(heartbeatsStr)
 	if err != nil {
 		return err
 	}
-	l.DeviceActivations = string(data)
+	l.DeviceHeartbeats = string(data)
 	return nil
 }
 

+ 247 - 22
web/index.html

@@ -286,6 +286,66 @@
             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 {
             display: flex;
             gap: 8px;
@@ -550,9 +610,7 @@
                             </th>
                             <th>ID</th>
                             <th>激活码</th>
-                            <th>最大设备数</th>
-                            <th>已绑定设备</th>
-                            <th>设备激活时间</th>
+                            <th>设备详情</th>
                             <th>绑定设备数</th>
                             <th>创建时间</th>
                             <th>操作</th>
@@ -629,6 +687,19 @@
         </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()">&times;</button>
+            </div>
+            <div id="device-list-content">
+                <div class="device-list-empty">加载中...</div>
+            </div>
+        </div>
+    </div>
+
     <script>
         // 使用相对路径,自动适配当前域名
         const API_BASE = '/api';
@@ -926,19 +997,6 @@
                     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 isFull = boundCount >= license.max_devices;
                 const createdDate = new Date(license.created_at).toLocaleString('zh-CN');
@@ -946,6 +1004,33 @@
                 // 限制激活码显示长度为10个字符
                 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, '&quot;').replace(/'/g, '&#39;');
+                    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 `
                     <tr>
                         <td style="text-align: center;">
@@ -960,12 +1045,8 @@
                                 </button>
                             </div>
                         </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>
                             <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 外部关闭
         window.onclick = function(event) {
             const licenseModal = document.getElementById('licenseModal');
             const batchModal = document.getElementById('batchModal');
+            const deviceListModal = document.getElementById('deviceListModal');
             const confirmDialog = document.getElementById('confirmDialog');
             
             if (event.target === licenseModal) {
@@ -1218,6 +1440,9 @@
             if (event.target === batchModal) {
                 closeBatchModal();
             }
+            if (event.target === deviceListModal) {
+                closeDeviceListModal();
+            }
             if (event.target === confirmDialog) {
                 closeConfirmDialog(false);
             }