3
0

4 Commits 4e6532c09a ... 255229712e

Autor SHA1 Nachricht Datum
  Carl 255229712e Feature #TASK_QT-34190 init vor 1 Tag
  Carl 8ed771f24b Feature #TASK_QT-34190 init vor 2 Tagen
  Carl 6decef0852 Feature #TASK_QT-34190 init vor 2 Tagen
  Carl 993a78e68e Feature #TASK_QT-34190 init vor 2 Tagen
15 geänderte Dateien mit 2654 neuen und 1426 gelöschten Zeilen
  1. 112 0
      README.md
  2. 15 0
      database/add_heartbeat_column.sql
  3. 9 0
      database/add_remark_column.sql
  4. 5 3
      database/init.sql
  5. 3 1
      database/schema.sql
  6. 449 81
      handlers/license.go
  7. 31 1
      handlers/verify.go
  8. 12 7
      main.go
  9. 54 7
      models/license.go
  10. 633 0
      web/css/index.css
  11. 137 0
      web/css/login.css
  12. 88 1094
      web/index.html
  13. 1014 0
      web/js/index.js
  14. 90 0
      web/js/login.js
  15. 2 232
      web/login.html

+ 112 - 0
README.md

@@ -125,6 +125,8 @@ Content-Type: application/json
 ```
 
 **响应:**
+
+**成功响应:**
 ```json
 {
   "code": 0,
@@ -135,6 +137,116 @@ Content-Type: application/json
 }
 ```
 
+**验证失败场景:**
+
+1. **请求参数错误** (code: 400)
+   - 缺少必填参数 `key` 或 `device_id`
+   ```json
+   {
+     "code": 400,
+     "msg": "请求参数错误: Key: 'VerifyRequest.Key' Error:Field validation for 'Key' failed on the 'required' tag",
+     "data": {
+       "valid": false
+     }
+   }
+   ```
+
+2. **无效的激活码** (code: 400)
+   - 激活码不存在于数据库中
+   ```json
+   {
+     "code": 400,
+     "msg": "无效的激活码",
+     "data": {
+       "valid": false
+     }
+   }
+   ```
+
+3. **设备数已满** (code: 400)
+   - 激活码已绑定设备数达到最大设备数限制
+   ```json
+   {
+     "code": 400,
+     "msg": "设备数已满",
+     "data": {
+       "valid": false
+     }
+   }
+   ```
+
+4. **数据库查询错误** (code: 500)
+   - 数据库连接或查询异常
+   ```json
+   {
+     "code": 500,
+     "msg": "数据库查询错误: connection refused",
+     "data": {
+       "valid": false
+     }
+   }
+   ```
+
+5. **解析绑定设备列表失败** (code: 500)
+   - 绑定设备数据格式错误,JSON 解析失败
+   ```json
+   {
+     "code": 500,
+     "msg": "解析绑定设备列表失败: invalid character",
+     "data": {
+       "valid": false
+     }
+   }
+   ```
+
+6. **更新心跳时间失败** (code: 500)
+   - 已绑定设备更新心跳时间时发生错误
+   ```json
+   {
+     "code": 500,
+     "msg": "更新心跳时间失败: ...",
+     "data": {
+       "valid": false
+     }
+   }
+   ```
+
+7. **保存心跳时间失败** (code: 500)
+   - 保存心跳时间到数据库时发生错误
+   ```json
+   {
+     "code": 500,
+     "msg": "保存心跳时间失败: ...",
+     "data": {
+       "valid": false
+     }
+   }
+   ```
+
+8. **绑定设备失败** (code: 500)
+   - 添加新设备到绑定列表时发生错误
+   ```json
+   {
+     "code": 500,
+     "msg": "绑定设备失败: ...",
+     "data": {
+       "valid": false
+     }
+   }
+   ```
+
+9. **保存绑定信息失败** (code: 500)
+   - 保存设备绑定信息到数据库时发生错误
+   ```json
+   {
+     "code": 500,
+     "msg": "保存绑定信息失败: ...",
+     "data": {
+       "valid": false
+     }
+   }
+   ```
+
 #### 2. 登录
 ```http
 POST /api/login

+ 15 - 0
database/add_heartbeat_column.sql

@@ -0,0 +1,15 @@
+-- 添加设备心跳时间字段
+-- 用于区分激活时间和心跳时间
+
+USE `license_admin`;
+
+-- 添加 device_heartbeats 字段
+ALTER TABLE `licenses` 
+ADD COLUMN `device_heartbeats` text COMMENT '设备心跳时间(JSON对象,最后验证时间)' 
+AFTER `device_activations`;
+
+-- 初始化现有数据:将激活时间复制到心跳时间(仅作为初始值)
+UPDATE `licenses` 
+SET `device_heartbeats` = `device_activations` 
+WHERE `device_heartbeats` IS NULL OR `device_heartbeats` = '';
+

+ 9 - 0
database/add_remark_column.sql

@@ -0,0 +1,9 @@
+-- 添加备注字段
+-- 用于存储License的备注信息
+
+USE `license_admin`;
+
+-- 添加 remark 字段
+ALTER TABLE `licenses` 
+ADD COLUMN `remark` text COMMENT '备注信息' 
+AFTER `max_devices`;

+ 5 - 3
database/init.sql

@@ -14,8 +14,10 @@ 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 '最大设备数',
+  `remark` text COMMENT '备注信息',
   `created_at` datetime(3) DEFAULT NULL COMMENT '创建时间',
   `updated_at` datetime(3) DEFAULT NULL COMMENT '更新时间',
   PRIMARY KEY (`id`),
@@ -24,8 +26,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();
 

+ 3 - 1
database/schema.sql

@@ -12,8 +12,10 @@ CREATE TABLE `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 '最大设备数',
+  `remark` text COMMENT '备注信息',
   `created_at` datetime(3) DEFAULT NULL COMMENT '创建时间',
   `updated_at` datetime(3) DEFAULT NULL COMMENT '更新时间',
   PRIMARY KEY (`id`),

+ 449 - 81
handlers/license.go

@@ -1,11 +1,14 @@
 package handlers
 
 import (
+	"encoding/csv"
 	"encoding/json"
 	"license-admin/models"
 	"math/rand"
 	"net/http"
 	"strconv"
+	"strings"
+	"time"
 
 	"github.com/gin-gonic/gin"
 	"gorm.io/gorm"
@@ -13,30 +16,32 @@ import (
 
 // CreateLicenseRequest 创建 License 请求结构
 type CreateLicenseRequest struct {
-	Key        string `json:"key" binding:"required"`        // 激活码(必填)
-	MaxDevices int    `json:"max_devices"`                    // 最大设备数(可选,默认2)
-	BoundDevices string `json:"bound_devices,omitempty"`      // 初始绑定设备列表(可选,默认为空数组)
+	Key          string `json:"key" binding:"required"`  // 激活码(必填)
+	MaxDevices   int    `json:"max_devices"`             // 最大设备数(可选,默认2)
+	BoundDevices string `json:"bound_devices,omitempty"` // 初始绑定设备列表(可选,默认为空数组)
+	Remark       string `json:"remark,omitempty"`        // 备注信息(可选)
 }
 
 // UpdateLicenseRequest 更新 License 请求结构
 type UpdateLicenseRequest struct {
-	Key        string `json:"key,omitempty"`        // 激活码(可选)
-	MaxDevices *int   `json:"max_devices,omitempty"` // 最大设备数(可选,使用指针以区分零值)
-	BoundDevices string `json:"bound_devices,omitempty"` // 绑定设备列表(可选)
+	Key          string  `json:"key,omitempty"`           // 激活码(可选)
+	MaxDevices   *int    `json:"max_devices,omitempty"`   // 最大设备数(可选,使用指针以区分零值)
+	BoundDevices string  `json:"bound_devices,omitempty"` // 绑定设备列表(可选)
+	Remark       *string `json:"remark,omitempty"`        // 备注信息(可选,使用指针以区分空字符串和未提供)
 }
 
 // LicenseResponse License 响应结构
 type LicenseResponse struct {
-	Code int             `json:"code"` // 状态码:0 表示成功,非0 表示失败
-	Msg  string          `json:"msg"`  // 响应消息
+	Code int             `json:"code"`           // 状态码:0 表示成功,非0 表示失败
+	Msg  string          `json:"msg"`            // 响应消息
 	Data *models.License `json:"data,omitempty"` // License 数据
 }
 
 // LicenseListResponse License 列表响应结构
 type LicenseListResponse struct {
-	Code int               `json:"code"` // 状态码:0 表示成功,非0 表示失败
-	Msg  string            `json:"msg"`  // 响应消息
-	Data []models.License  `json:"data"` // License 列表
+	Code  int              `json:"code"`  // 状态码:0 表示成功,非0 表示失败
+	Msg   string           `json:"msg"`   // 响应消息
+	Data  []models.License `json:"data"`  // License 列表
 	Total int64            `json:"total"` // 总数
 }
 
@@ -53,10 +58,17 @@ func CreateLicense(db *gorm.DB) gin.HandlerFunc {
 			return
 		}
 
-		// 检查 Key 是否已存在
-		var existingLicense models.License
-		result := db.Where("license_key = ?", req.Key).First(&existingLicense)
-		if result.Error == nil {
+		// 检查 Key 是否已存在(使用 Count 更高效,不会产生 record not found 日志)
+		var count int64
+		if err := db.Model(&models.License{}).Where("license_key = ?", req.Key).Count(&count).Error; err != nil {
+			// 数据库查询错误
+			c.JSON(http.StatusInternalServerError, LicenseResponse{
+				Code: 500,
+				Msg:  "数据库查询错误: " + err.Error(),
+			})
+			return
+		}
+		if count > 0 {
 			// Key 已存在
 			c.JSON(http.StatusBadRequest, LicenseResponse{
 				Code: 400,
@@ -64,14 +76,6 @@ func CreateLicense(db *gorm.DB) gin.HandlerFunc {
 			})
 			return
 		}
-		if result.Error != gorm.ErrRecordNotFound {
-			// 数据库查询错误
-			c.JSON(http.StatusInternalServerError, LicenseResponse{
-				Code: 500,
-				Msg:  "数据库查询错误: " + result.Error.Error(),
-			})
-			return
-		}
 
 		// 设置默认值
 		if req.MaxDevices <= 0 {
@@ -86,6 +90,7 @@ func CreateLicense(db *gorm.DB) gin.HandlerFunc {
 			LicenseKey:   req.Key,
 			MaxDevices:   req.MaxDevices,
 			BoundDevices: req.BoundDevices,
+			Remark:       req.Remark,
 		}
 
 		if err := db.Create(&license).Error; err != nil {
@@ -105,12 +110,14 @@ func CreateLicense(db *gorm.DB) gin.HandlerFunc {
 }
 
 // GetLicenseList 获取 License 列表
-// GET /api/licenses?page=1&page_size=10
+// GET /api/licenses?page=1&page_size=10&search=keyword
 func GetLicenseList(db *gorm.DB) gin.HandlerFunc {
 	return func(c *gin.Context) {
 		// 解析分页参数
 		page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
 		pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
+		search := c.Query("search") // 搜索关键词
+		status := c.Query("status") // 状态筛选:activated(已激活)、unactivated(未激活)
 
 		if page < 1 {
 			page = 1
@@ -122,33 +129,69 @@ func GetLicenseList(db *gorm.DB) gin.HandlerFunc {
 		var licenses []models.License
 		var total int64
 
+		// 构建查询
+		query := db.Model(&models.License{})
+
+		// 如果有搜索关键词,添加搜索条件(按 license_key 模糊匹配)
+		if search != "" {
+			query = query.Where("license_key LIKE ?", "%"+search+"%")
+		}
+
+		// 状态筛选
+		if status == "activated" {
+			// 已激活:有绑定设备
+			query = query.Where("JSON_LENGTH(COALESCE(bound_devices, '[]')) > 0")
+		} else if status == "unactivated" {
+			// 未激活:没有绑定设备
+			query = query.Where("JSON_LENGTH(COALESCE(bound_devices, '[]')) = 0 OR bound_devices IS NULL OR bound_devices = ''")
+		}
+
 		// 获取总数
-		if err := db.Model(&models.License{}).Count(&total).Error; err != nil {
+		if err := query.Count(&total).Error; err != nil {
 			c.JSON(http.StatusInternalServerError, LicenseListResponse{
-				Code: 500,
-				Msg:  "查询总数失败: " + err.Error(),
-				Data: []models.License{},
+				Code:  500,
+				Msg:   "查询总数失败: " + err.Error(),
+				Data:  []models.License{},
 				Total: 0,
 			})
 			return
 		}
 
+		// 使用 SQL 排序:
+		// 1. 有绑定设备的优先(bound_devices 不为空且不是 '[]')
+		// 2. 然后按心跳时间最新的排序(从 device_heartbeats JSON 中提取最新时间)
+		//
+		// 使用 MySQL JSON 函数来提取最新的心跳时间:
+		// - 对于 MySQL 5.7+,我们可以使用 JSON_EXTRACT 和 JSON_KEYS 来遍历
+		// - 但由于提取所有值并找最大值在 SQL 中比较复杂,我们使用一个更实用的方法:
+		//   使用 updated_at 字段,因为每次心跳更新(UpdateDeviceHeartbeat)都会更新 updated_at
+		//   这样 updated_at 可以准确反映最后心跳时间
+		//
+		// 排序 SQL:
+		// 1. 有设备的排在前面:CASE WHEN JSON_LENGTH(COALESCE(bound_devices, '[]')) > 0 THEN 0 ELSE 1 END
+		// 2. 然后按 updated_at 倒序(最新的心跳会更新 updated_at)
+		orderBy := `CASE 
+			WHEN JSON_LENGTH(COALESCE(bound_devices, '[]')) > 0 THEN 0 
+			ELSE 1 
+		END ASC, 
+		updated_at DESC`
+
 		// 分页查询
 		offset := (page - 1) * pageSize
-		if err := db.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&licenses).Error; err != nil {
+		if err := query.Offset(offset).Limit(pageSize).Order(orderBy).Find(&licenses).Error; err != nil {
 			c.JSON(http.StatusInternalServerError, LicenseListResponse{
-				Code: 500,
-				Msg:  "查询 License 列表失败: " + err.Error(),
-				Data: []models.License{},
+				Code:  500,
+				Msg:   "查询 License 列表失败: " + err.Error(),
+				Data:  []models.License{},
 				Total: 0,
 			})
 			return
 		}
 
 		c.JSON(http.StatusOK, LicenseListResponse{
-			Code: 0,
-			Msg:  "success",
-			Data: licenses,
+			Code:  0,
+			Msg:   "success",
+			Data:  licenses,
 			Total: total,
 		})
 	}
@@ -235,20 +278,19 @@ func UpdateLicense(db *gorm.DB) gin.HandlerFunc {
 
 		// 更新字段
 		if req.Key != "" {
-			// 检查新 Key 是否与其他 License 冲突
-			var existingLicense models.License
-			checkResult := db.Where("license_key = ? AND id != ?", req.Key, id).First(&existingLicense)
-			if checkResult.Error == nil {
-				c.JSON(http.StatusBadRequest, LicenseResponse{
-					Code: 400,
-					Msg:  "激活码已存在",
+			// 检查新 Key 是否与其他 License 冲突(使用 Count 更高效)
+			var count int64
+			if err := db.Model(&models.License{}).Where("license_key = ? AND id != ?", req.Key, id).Count(&count).Error; err != nil {
+				c.JSON(http.StatusInternalServerError, LicenseResponse{
+					Code: 500,
+					Msg:  "检查激活码失败: " + err.Error(),
 				})
 				return
 			}
-			if checkResult.Error != gorm.ErrRecordNotFound {
-				c.JSON(http.StatusInternalServerError, LicenseResponse{
-					Code: 500,
-					Msg:  "检查激活码失败: " + checkResult.Error.Error(),
+			if count > 0 {
+				c.JSON(http.StatusBadRequest, LicenseResponse{
+					Code: 400,
+					Msg:  "激活码已存在",
 				})
 				return
 			}
@@ -279,6 +321,11 @@ func UpdateLicense(db *gorm.DB) gin.HandlerFunc {
 			license.BoundDevices = req.BoundDevices
 		}
 
+		// 更新备注(如果提供了)
+		if req.Remark != nil {
+			license.Remark = *req.Remark
+		}
+
 		// 保存更新
 		if err := db.Save(&license).Error; err != nil {
 			c.JSON(http.StatusInternalServerError, LicenseResponse{
@@ -350,9 +397,9 @@ type BatchDeleteLicenseRequest struct {
 
 // BatchDeleteLicenseResponse 批量删除响应结构
 type BatchDeleteLicenseResponse struct {
-	Code int    `json:"code"` // 状态码:0 表示成功,非0 表示失败
-	Msg  string `json:"msg"`  // 响应消息
-	Total int   `json:"total"` // 成功删除的数量
+	Code  int    `json:"code"`  // 状态码:0 表示成功,非0 表示失败
+	Msg   string `json:"msg"`   // 响应消息
+	Total int    `json:"total"` // 成功删除的数量
 }
 
 // BatchDeleteLicense 批量删除 License
@@ -362,8 +409,8 @@ func BatchDeleteLicense(db *gorm.DB) gin.HandlerFunc {
 		var req BatchDeleteLicenseRequest
 		if err := c.ShouldBindJSON(&req); err != nil {
 			c.JSON(http.StatusBadRequest, BatchDeleteLicenseResponse{
-				Code: 400,
-				Msg:  "请求参数错误: " + err.Error(),
+				Code:  400,
+				Msg:   "请求参数错误: " + err.Error(),
 				Total: 0,
 			})
 			return
@@ -372,8 +419,8 @@ func BatchDeleteLicense(db *gorm.DB) gin.HandlerFunc {
 		// 验证参数
 		if len(req.IDs) == 0 {
 			c.JSON(http.StatusBadRequest, BatchDeleteLicenseResponse{
-				Code: 400,
-				Msg:  "请至少选择一个 License",
+				Code:  400,
+				Msg:   "请至少选择一个 License",
 				Total: 0,
 			})
 			return
@@ -381,8 +428,8 @@ func BatchDeleteLicense(db *gorm.DB) gin.HandlerFunc {
 
 		if len(req.IDs) > 100 {
 			c.JSON(http.StatusBadRequest, BatchDeleteLicenseResponse{
-				Code: 400,
-				Msg:  "一次最多只能删除 100 个 License",
+				Code:  400,
+				Msg:   "一次最多只能删除 100 个 License",
 				Total: 0,
 			})
 			return
@@ -392,8 +439,8 @@ func BatchDeleteLicense(db *gorm.DB) gin.HandlerFunc {
 		result := db.Where("id IN ?", req.IDs).Delete(&models.License{})
 		if result.Error != nil {
 			c.JSON(http.StatusInternalServerError, BatchDeleteLicenseResponse{
-				Code: 500,
-				Msg:  "批量删除失败: " + result.Error.Error(),
+				Code:  500,
+				Msg:   "批量删除失败: " + result.Error.Error(),
 				Total: 0,
 			})
 			return
@@ -401,8 +448,8 @@ func BatchDeleteLicense(db *gorm.DB) gin.HandlerFunc {
 
 		deletedCount := int(result.RowsAffected)
 		c.JSON(http.StatusOK, BatchDeleteLicenseResponse{
-			Code: 0,
-			Msg:  "成功删除 " + strconv.Itoa(deletedCount) + " 个 License",
+			Code:  0,
+			Msg:   "成功删除 " + strconv.Itoa(deletedCount) + " 个 License",
 			Total: deletedCount,
 		})
 	}
@@ -410,16 +457,16 @@ func BatchDeleteLicense(db *gorm.DB) gin.HandlerFunc {
 
 // BatchCreateLicenseRequest 批量创建 License 请求结构
 type BatchCreateLicenseRequest struct {
-	Prefix     string `json:"prefix" binding:"required"`     // 激活码前缀(必填)
-	Count      int    `json:"count" binding:"required"`      // 生成数量(必填)
-	MaxDevices int    `json:"max_devices"`                    // 最大设备数(可选,默认2)
+	Prefix     string `json:"prefix" binding:"required"` // 激活码前缀(必填)
+	Count      int    `json:"count" binding:"required"`  // 生成数量(必填)
+	MaxDevices int    `json:"max_devices"`               // 最大设备数(可选,默认2)
 }
 
 // BatchCreateLicenseResponse 批量创建响应结构
 type BatchCreateLicenseResponse struct {
-	Code int               `json:"code"` // 状态码:0 表示成功,非0 表示失败
-	Msg  string            `json:"msg"`  // 响应消息
-	Data []models.License  `json:"data"` // 创建的 License 列表
+	Code  int              `json:"code"`  // 状态码:0 表示成功,非0 表示失败
+	Msg   string           `json:"msg"`   // 响应消息
+	Data  []models.License `json:"data"`  // 创建的 License 列表
 	Total int              `json:"total"` // 成功创建的数量
 }
 
@@ -430,9 +477,9 @@ func BatchCreateLicense(db *gorm.DB) gin.HandlerFunc {
 		var req BatchCreateLicenseRequest
 		if err := c.ShouldBindJSON(&req); err != nil {
 			c.JSON(http.StatusBadRequest, BatchCreateLicenseResponse{
-				Code: 400,
-				Msg:  "请求参数错误: " + err.Error(),
-				Data: []models.License{},
+				Code:  400,
+				Msg:   "请求参数错误: " + err.Error(),
+				Data:  []models.License{},
 				Total: 0,
 			})
 			return
@@ -441,9 +488,9 @@ func BatchCreateLicense(db *gorm.DB) gin.HandlerFunc {
 		// 验证参数
 		if req.Count <= 0 || req.Count > 1000 {
 			c.JSON(http.StatusBadRequest, BatchCreateLicenseResponse{
-				Code: 400,
-				Msg:  "生成数量必须在 1-1000 之间",
-				Data: []models.License{},
+				Code:  400,
+				Msg:   "生成数量必须在 1-1000 之间",
+				Data:  []models.License{},
 				Total: 0,
 			})
 			return
@@ -463,16 +510,15 @@ func BatchCreateLicense(db *gorm.DB) gin.HandlerFunc {
 			// 生成激活码:前缀 + 32位随机字符串
 			key := req.Prefix + "-" + generateRandomKey(32)
 
-			// 检查 Key 是否已存在
-			var existingLicense models.License
-			result := db.Where("license_key = ?", key).First(&existingLicense)
-			if result.Error == nil {
-				// Key 已存在,跳过并记录
+			// 检查 Key 是否已存在(使用 Count 更高效,不会产生 record not found 日志)
+			var count int64
+			if err := db.Model(&models.License{}).Where("license_key = ?", key).Count(&count).Error; err != nil {
+				// 数据库查询错误
 				failedKeys = append(failedKeys, key)
 				continue
 			}
-			if result.Error != gorm.ErrRecordNotFound {
-				// 数据库查询错误
+			if count > 0 {
+				// Key 已存在,跳过并记录
 				failedKeys = append(failedKeys, key)
 				continue
 			}
@@ -500,9 +546,9 @@ func BatchCreateLicense(db *gorm.DB) gin.HandlerFunc {
 		}
 
 		c.JSON(http.StatusOK, BatchCreateLicenseResponse{
-			Code: 0,
-			Msg:  msg,
-			Data: licenses,
+			Code:  0,
+			Msg:   msg,
+			Data:  licenses,
 			Total: successCount,
 		})
 	}
@@ -523,3 +569,325 @@ func getRandomInt(max int) int {
 	return rand.Intn(max)
 }
 
+
+// ExportLicenseRequest 导出请求结构
+type ExportLicenseRequest struct {
+	Format string `json:"format"` // csv 或 json
+}
+
+// ExportLicenses 导出 License 列表
+// GET /api/licenses/export?format=csv
+func ExportLicenses(db *gorm.DB) gin.HandlerFunc {
+	return func(c *gin.Context) {
+		format := c.DefaultQuery("format", "csv")
+
+		var licenses []models.License
+
+		// 使用与列表相同的排序逻辑
+		orderBy := `CASE 
+			WHEN JSON_LENGTH(COALESCE(bound_devices, '[]')) > 0 THEN 0 
+			ELSE 1 
+		END ASC, 
+		updated_at DESC`
+
+		if err := db.Order(orderBy).Find(&licenses).Error; err != nil {
+			c.JSON(http.StatusInternalServerError, LicenseListResponse{
+				Code:  500,
+				Msg:   "查询失败: " + err.Error(),
+				Data:  []models.License{},
+				Total: 0,
+			})
+			return
+		}
+
+		if format == "csv" {
+			// 导出 CSV
+			c.Header("Content-Type", "text/csv; charset=utf-8")
+			c.Header("Content-Disposition", "attachment; filename=licenses_"+time.Now().Format("20060102_150405")+".csv")
+			c.Header("Content-Transfer-Encoding", "binary")
+
+			writer := csv.NewWriter(c.Writer)
+			defer writer.Flush()
+
+			// 写入表头
+			headers := []string{"ID", "激活码", "最大设备数", "已绑定设备数", "绑定设备列表", "创建时间", "更新时间"}
+			writer.Write(headers)
+
+			// 写入数据
+			for _, license := range licenses {
+				devices, _ := license.GetBoundDeviceList()
+				deviceList := strings.Join(devices, "; ")
+				row := []string{
+					strconv.FormatUint(uint64(license.ID), 10),
+					license.LicenseKey,
+					strconv.Itoa(license.MaxDevices),
+					strconv.Itoa(len(devices)),
+					deviceList,
+					license.CreatedAt.Format("2006-01-02 15:04:05"),
+					license.UpdatedAt.Format("2006-01-02 15:04:05"),
+				}
+				writer.Write(row)
+			}
+		} else {
+			// 导出 JSON
+			c.Header("Content-Type", "application/json; charset=utf-8")
+			c.Header("Content-Disposition", "attachment; filename=licenses_"+time.Now().Format("20060102_150405")+".json")
+			c.JSON(http.StatusOK, LicenseListResponse{
+				Code:  0,
+				Msg:   "success",
+				Data:  licenses,
+				Total: int64(len(licenses)),
+			})
+		}
+	}
+}
+
+// BatchUpdateMaxDevicesRequest 批量修改最大设备数请求结构
+type BatchUpdateMaxDevicesRequest struct {
+	IDs        []uint `json:"ids" binding:"required"`
+	MaxDevices int    `json:"max_devices" binding:"required,min=1"`
+}
+
+// BatchUpdateMaxDevices 批量修改最大设备数
+// PUT /api/licenses/batch/max-devices
+func BatchUpdateMaxDevices(db *gorm.DB) gin.HandlerFunc {
+	return func(c *gin.Context) {
+		var req BatchUpdateMaxDevicesRequest
+		if err := c.ShouldBindJSON(&req); err != nil {
+			c.JSON(http.StatusBadRequest, LicenseResponse{
+				Code: 400,
+				Msg:  "请求参数错误: " + err.Error(),
+			})
+			return
+		}
+
+		if len(req.IDs) == 0 {
+			c.JSON(http.StatusBadRequest, LicenseResponse{
+				Code: 400,
+				Msg:  "请至少选择一个 License",
+			})
+			return
+		}
+
+		// 批量更新
+		result := db.Model(&models.License{}).
+			Where("id IN ?", req.IDs).
+			Update("max_devices", req.MaxDevices)
+
+		if result.Error != nil {
+			c.JSON(http.StatusInternalServerError, LicenseResponse{
+				Code: 500,
+				Msg:  "批量更新失败: " + result.Error.Error(),
+			})
+			return
+		}
+
+		c.JSON(http.StatusOK, LicenseResponse{
+			Code: 0,
+			Msg:  "成功更新 " + strconv.FormatInt(result.RowsAffected, 10) + " 个 License 的最大设备数",
+		})
+	}
+}
+
+// UnbindDeviceRequest 解绑设备请求结构
+type UnbindDeviceRequest struct {
+	DeviceID string `json:"device_id" binding:"required"`
+}
+
+
+
+
+// UnbindDevice 解绑设备
+// DELETE /api/licenses/:id/devices/:device_id
+func UnbindDevice(db *gorm.DB) gin.HandlerFunc {
+	return func(c *gin.Context) {
+		id, err := strconv.ParseUint(c.Param("id"), 10, 32)
+		if err != nil {
+			c.JSON(http.StatusBadRequest, LicenseResponse{
+				Code: 400,
+				Msg:  "无效的 ID 参数",
+			})
+			return
+		}
+
+		deviceID := c.Param("device_id")
+		if deviceID == "" {
+			c.JSON(http.StatusBadRequest, LicenseResponse{
+				Code: 400,
+				Msg:  "设备ID不能为空",
+			})
+			return
+		}
+
+		var license models.License
+		if err := db.First(&license, id).Error; err != nil {
+			if err == gorm.ErrRecordNotFound {
+				c.JSON(http.StatusNotFound, LicenseResponse{
+					Code: 404,
+					Msg:  "License 不存在",
+				})
+				return
+			}
+			c.JSON(http.StatusInternalServerError, LicenseResponse{
+				Code: 500,
+				Msg:  "查询失败: " + err.Error(),
+			})
+			return
+		}
+
+		// 获取设备列表
+		devices, err := license.GetBoundDeviceList()
+		if err != nil {
+			c.JSON(http.StatusInternalServerError, LicenseResponse{
+				Code: 500,
+				Msg:  "解析设备列表失败: " + err.Error(),
+			})
+			return
+		}
+
+		// 移除设备
+		newDevices := []string{}
+		found := false
+		for _, d := range devices {
+			if d != deviceID {
+				newDevices = append(newDevices, d)
+			} else {
+				found = true
+			}
+		}
+
+		if !found {
+			c.JSON(http.StatusBadRequest, LicenseResponse{
+				Code: 400,
+				Msg:  "设备未绑定",
+			})
+			return
+		}
+
+		// 更新设备列表
+		if err := license.SetBoundDeviceList(newDevices); err != nil {
+			c.JSON(http.StatusInternalServerError, LicenseResponse{
+				Code: 500,
+				Msg:  "更新设备列表失败: " + err.Error(),
+			})
+			return
+		}
+
+		// 同时清理激活时间和心跳时间
+		var activations map[string]string
+		if license.DeviceActivations != "" {
+			json.Unmarshal([]byte(license.DeviceActivations), &activations)
+		} else {
+			activations = make(map[string]string)
+		}
+		delete(activations, deviceID)
+		if activationsData, err := json.Marshal(activations); err == nil {
+			license.DeviceActivations = string(activationsData)
+		}
+
+		var heartbeats map[string]string
+		if license.DeviceHeartbeats != "" {
+			json.Unmarshal([]byte(license.DeviceHeartbeats), &heartbeats)
+		} else {
+			heartbeats = make(map[string]string)
+		}
+		delete(heartbeats, deviceID)
+		if heartbeatsData, err := json.Marshal(heartbeats); err == nil {
+			license.DeviceHeartbeats = string(heartbeatsData)
+		}
+
+		// 保存到数据库
+		if err := db.Save(&license).Error; err != nil {
+			c.JSON(http.StatusInternalServerError, LicenseResponse{
+				Code: 500,
+				Msg:  "保存失败: " + err.Error(),
+			})
+			return
+		}
+
+		c.JSON(http.StatusOK, LicenseResponse{
+			Code: 0,
+			Msg:  "成功解绑设备",
+			Data: &license,
+		})
+	}
+}
+
+// StatisticsResponse 统计信息响应结构
+type StatisticsResponse struct {
+	Code int                    `json:"code"` // 状态码:0 表示成功,非0 表示失败
+	Msg  string                 `json:"msg"`  // 响应消息
+	Data *StatisticsData        `json:"data"` // 统计数据
+}
+
+// StatisticsData 统计数据
+type StatisticsData struct {
+	Total       int64 `json:"total"`        // 总License数
+	Activated   int64 `json:"activated"`    // 已激活数(有绑定设备)
+	Unactivated int64 `json:"unactivated"`  // 未激活数(无绑定设备)
+	TotalDevices int64 `json:"total_devices"` // 总绑定设备数
+}
+
+// GetStatistics 获取统计信息
+// GET /api/licenses/statistics
+func GetStatistics(db *gorm.DB) gin.HandlerFunc {
+	return func(c *gin.Context) {
+		var stats StatisticsData
+
+		// 获取总数
+		if err := db.Model(&models.License{}).Count(&stats.Total).Error; err != nil {
+			c.JSON(http.StatusInternalServerError, StatisticsResponse{
+				Code: 500,
+				Msg:  "查询总数失败: " + err.Error(),
+			})
+			return
+		}
+
+		// 获取已激活数(有绑定设备)
+		if err := db.Model(&models.License{}).
+			Where("JSON_LENGTH(COALESCE(bound_devices, '[]')) > 0").
+			Count(&stats.Activated).Error; err != nil {
+			c.JSON(http.StatusInternalServerError, StatisticsResponse{
+				Code: 500,
+				Msg:  "查询已激活数失败: " + err.Error(),
+			})
+			return
+		}
+
+		// 获取未激活数(无绑定设备)
+		if err := db.Model(&models.License{}).
+			Where("JSON_LENGTH(COALESCE(bound_devices, '[]')) = 0 OR bound_devices IS NULL OR bound_devices = ''").
+			Count(&stats.Unactivated).Error; err != nil {
+			c.JSON(http.StatusInternalServerError, StatisticsResponse{
+				Code: 500,
+				Msg:  "查询未激活数失败: " + err.Error(),
+			})
+			return
+		}
+
+		// 计算总绑定设备数(需要遍历所有License的bound_devices)
+		var licenses []models.License
+		if err := db.Find(&licenses).Error; err != nil {
+			c.JSON(http.StatusInternalServerError, StatisticsResponse{
+				Code: 500,
+				Msg:  "查询License列表失败: " + err.Error(),
+			})
+			return
+		}
+
+		var totalDevices int64
+		for _, license := range licenses {
+			devices, err := license.GetBoundDeviceList()
+			if err == nil {
+				totalDevices += int64(len(devices))
+			}
+		}
+		stats.TotalDevices = totalDevices
+
+		c.JSON(http.StatusOK, StatisticsResponse{
+			Code: 0,
+			Msg:  "success",
+			Data: &stats,
+		})
+	}
+}

+ 31 - 1
handlers/verify.go

@@ -93,7 +93,7 @@ func VerifyLicense(db *gorm.DB) gin.HandlerFunc {
 			return
 		}
 
-		// 情况A: 如果 device_id 已经在 BoundDevices 列表中 -> 验证成功
+		// 情况A: 如果 device_id 已经在 BoundDevices 列表中 -> 验证成功,更新心跳时间
 		isBound := false
 		for _, deviceID := range boundDevices {
 			if deviceID == req.DeviceID {
@@ -103,6 +103,36 @@ func VerifyLicense(db *gorm.DB) gin.HandlerFunc {
 		}
 
 		if isBound {
+			// 设备已激活,更新心跳时间(不更新激活时间)
+			err = license.UpdateDeviceHeartbeat(req.DeviceID)
+			if err != nil {
+				c.JSON(http.StatusInternalServerError, VerifyResponse{
+					Code: 500,
+					Msg:  "更新心跳时间失败: " + err.Error(),
+					Data: struct {
+						Valid bool `json:"valid"`
+					}{
+						Valid: false,
+					},
+				})
+				return
+			}
+
+			// 保存心跳时间到数据库
+			result = db.Save(&license)
+			if result.Error != nil {
+				c.JSON(http.StatusInternalServerError, VerifyResponse{
+					Code: 500,
+					Msg:  "保存心跳时间失败: " + result.Error.Error(),
+					Data: struct {
+						Valid bool `json:"valid"`
+					}{
+						Valid: false,
+					},
+				})
+				return
+			}
+
 			c.JSON(http.StatusOK, VerifyResponse{
 				Code: 0,
 				Msg:  "success",

+ 12 - 7
main.go

@@ -91,14 +91,19 @@ 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.GET("/statistics", handlers.GetStatistics(database.DB))                // 获取统计信息
+				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))  // 解绑设备
 			}
+
 		}
 	}
 

+ 54 - 7
models/license.go

@@ -10,8 +10,10 @@ 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"`
+	Remark          string    `gorm:"type:text" json:"remark"`               // 备注信息
 	CreatedAt       time.Time `json:"created_at"`
 	UpdatedAt       time.Time `json:"updated_at"`
 }
@@ -85,7 +87,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 +97,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
 }
 

+ 633 - 0
web/css/index.css

@@ -0,0 +1,633 @@
+* {
+    margin: 0;
+    padding: 0;
+    box-sizing: border-box;
+}
+
+body {
+    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+    min-height: 100vh;
+    padding: 20px;
+}
+
+.container {
+    max-width: 1200px;
+    margin: 0 auto;
+}
+
+.header {
+    background: white;
+    padding: 30px;
+    border-radius: 10px;
+    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+    margin-bottom: 20px;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+}
+
+.header h1 {
+    color: #333;
+    font-size: 28px;
+}
+
+.btn {
+    padding: 10px 20px;
+    border: none;
+    border-radius: 5px;
+    cursor: pointer;
+    font-size: 14px;
+    transition: all 0.3s;
+    font-weight: 500;
+}
+
+.btn-primary {
+    background: #667eea;
+    color: white;
+}
+
+.btn-primary:hover {
+    background: #5568d3;
+    transform: translateY(-2px);
+    box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3);
+}
+
+.btn-danger {
+    background: #ef4444;
+    color: white;
+}
+
+.btn-danger:hover {
+    background: #dc2626;
+}
+
+.btn-secondary {
+    background: #6b7280;
+    color: white;
+}
+
+.btn-secondary:hover {
+    background: #4b5563;
+}
+
+.card {
+    background: white;
+    border-radius: 10px;
+    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+    padding: 30px;
+    margin-bottom: 20px;
+}
+
+.table-container {
+    overflow-x: auto;
+}
+
+table {
+    width: 100%;
+    border-collapse: collapse;
+}
+
+th, td {
+    padding: 12px;
+    text-align: left;
+    border-bottom: 1px solid #e5e7eb;
+}
+
+th {
+    background: #f9fafb;
+    font-weight: 600;
+    color: #374151;
+}
+
+tr:hover {
+    background: #f9fafb;
+}
+
+.badge {
+    display: inline-block;
+    padding: 4px 12px;
+    border-radius: 12px;
+    font-size: 12px;
+    font-weight: 500;
+}
+
+.badge-success {
+    background: #d1fae5;
+    color: #065f46;
+}
+
+.badge-warning {
+    background: #fef3c7;
+    color: #92400e;
+}
+
+.modal {
+    display: none;
+    position: fixed;
+    z-index: 1000;
+    left: 0;
+    top: 0;
+    width: 100%;
+    height: 100%;
+    background: rgba(0, 0, 0, 0.5);
+    animation: fadeIn 0.3s;
+}
+
+.modal.show {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+
+@keyframes fadeIn {
+    from { opacity: 0; }
+    to { opacity: 1; }
+}
+
+.modal-content {
+    background: white;
+    border-radius: 10px;
+    padding: 30px;
+    width: 90%;
+    max-width: 500px;
+    animation: slideDown 0.3s;
+}
+
+@keyframes slideDown {
+    from {
+        transform: translateY(-50px);
+        opacity: 0;
+    }
+    to {
+        transform: translateY(0);
+        opacity: 1;
+    }
+}
+
+.modal-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 20px;
+}
+
+.modal-header h2 {
+    color: #333;
+    font-size: 24px;
+}
+
+.close {
+    font-size: 28px;
+    font-weight: bold;
+    color: #999;
+    cursor: pointer;
+    border: none;
+    background: none;
+}
+
+.close:hover {
+    color: #333;
+}
+
+.form-group {
+    margin-bottom: 20px;
+}
+
+.form-group label {
+    display: block;
+    margin-bottom: 8px;
+    color: #374151;
+    font-weight: 500;
+}
+
+.form-group input {
+    width: 100%;
+    padding: 10px;
+    border: 1px solid #d1d5db;
+    border-radius: 5px;
+    font-size: 14px;
+}
+
+.form-group input:focus {
+    outline: none;
+    border-color: #667eea;
+    box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+}
+
+.form-actions {
+    display: flex;
+    gap: 10px;
+    justify-content: flex-end;
+    margin-top: 20px;
+}
+
+.pagination {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    gap: 10px;
+    margin-top: 20px;
+}
+
+.pagination button {
+    padding: 8px 16px;
+    border: 1px solid #d1d5db;
+    background: white;
+    border-radius: 5px;
+    cursor: pointer;
+}
+
+.pagination button:hover:not(:disabled) {
+    background: #f9fafb;
+}
+
+.pagination button:disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+}
+
+.pagination .page-info {
+    color: #6b7280;
+}
+
+.loading {
+    text-align: center;
+    padding: 40px;
+    color: #6b7280;
+}
+
+.empty-state {
+    text-align: center;
+    padding: 60px 20px;
+    color: #6b7280;
+}
+
+.empty-state svg {
+    width: 100px;
+    height: 100px;
+    margin-bottom: 20px;
+    opacity: 0.5;
+}
+
+.device-list {
+    font-size: 12px;
+    color: #6b7280;
+    max-width: 200px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    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;
+}
+
+.btn-sm {
+    padding: 6px 12px;
+    font-size: 12px;
+}
+
+.license-key-cell {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    max-width: 200px;
+}
+
+.license-key-text {
+    flex: 1;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    font-weight: 600;
+}
+
+.copy-btn {
+    padding: 6px 12px;
+    font-size: 12px;
+    background: #667eea;
+    color: white;
+    border: none;
+    border-radius: 5px;
+    cursor: pointer;
+    transition: all 0.2s;
+    flex-shrink: 0;
+    font-weight: 500;
+    display: flex;
+    align-items: center;
+    gap: 4px;
+}
+
+.copy-btn:hover {
+    background: #5568d3;
+    transform: translateY(-1px);
+    box-shadow: 0 2px 4px rgba(102, 126, 234, 0.3);
+}
+
+.copy-btn:active {
+    transform: translateY(0);
+}
+
+/* Toast 通知样式 */
+.toast-container {
+    position: fixed;
+    top: 20px;
+    right: 20px;
+    z-index: 10000;
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+}
+
+.toast {
+    background: white;
+    padding: 16px 20px;
+    border-radius: 8px;
+    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    min-width: 300px;
+    max-width: 400px;
+    animation: slideInRight 0.3s ease-out;
+    position: relative;
+}
+
+@keyframes slideInRight {
+    from {
+        transform: translateX(100%);
+        opacity: 0;
+    }
+    to {
+        transform: translateX(0);
+        opacity: 1;
+    }
+}
+
+.toast.success {
+    border-left: 4px solid #10b981;
+}
+
+.toast.error {
+    border-left: 4px solid #ef4444;
+}
+
+.toast.warning {
+    border-left: 4px solid #f59e0b;
+}
+
+.toast.info {
+    border-left: 4px solid #3b82f6;
+}
+
+.toast-icon {
+    font-size: 20px;
+    flex-shrink: 0;
+}
+
+.toast.success .toast-icon {
+    color: #10b981;
+}
+
+.toast.error .toast-icon {
+    color: #ef4444;
+}
+
+.toast.warning .toast-icon {
+    color: #f59e0b;
+}
+
+.toast.info .toast-icon {
+    color: #3b82f6;
+}
+
+.toast-message {
+    flex: 1;
+    color: #374151;
+    font-size: 14px;
+    line-height: 1.5;
+}
+
+.toast-close {
+    background: none;
+    border: none;
+    font-size: 18px;
+    color: #9ca3af;
+    cursor: pointer;
+    padding: 0;
+    width: 20px;
+    height: 20px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    flex-shrink: 0;
+}
+
+.toast-close:hover {
+    color: #374151;
+}
+
+/* 确认对话框样式 */
+.confirm-dialog {
+    display: none;
+    position: fixed;
+    z-index: 10001;
+    left: 0;
+    top: 0;
+    width: 100%;
+    height: 100%;
+    background: rgba(0, 0, 0, 0.5);
+    align-items: center;
+    justify-content: center;
+    animation: fadeIn 0.3s;
+}
+
+.confirm-dialog.show {
+    display: flex;
+}
+
+.confirm-content {
+    background: white;
+    border-radius: 10px;
+    padding: 30px;
+    width: 90%;
+    max-width: 400px;
+    box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
+    animation: slideDown 0.3s;
+}
+
+.confirm-icon {
+    font-size: 48px;
+    text-align: center;
+    margin-bottom: 20px;
+}
+
+.confirm-title {
+    font-size: 20px;
+    font-weight: 600;
+    color: #374151;
+    text-align: center;
+    margin-bottom: 15px;
+}
+
+.confirm-message {
+    color: #6b7280;
+    text-align: center;
+    margin-bottom: 25px;
+    line-height: 1.6;
+}
+
+.confirm-actions {
+    display: flex;
+    gap: 10px;
+    justify-content: center;
+}
+
+.confirm-actions .btn {
+    min-width: 100px;
+}
+
+/* 统计信息卡片样式 */
+.stats-container {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+    gap: 20px;
+    margin-bottom: 20px;
+}
+
+.stat-card {
+    background: white;
+    border-radius: 10px;
+    padding: 20px;
+    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+    display: flex;
+    flex-direction: column;
+}
+
+.stat-label {
+    color: #6b7280;
+    font-size: 14px;
+    margin-bottom: 8px;
+}
+
+.stat-value {
+    color: #111827;
+    font-size: 28px;
+    font-weight: 600;
+}
+
+.stat-card.primary .stat-value {
+    color: #667eea;
+}
+
+.stat-card.success .stat-value {
+    color: #10b981;
+}
+
+.stat-card.warning .stat-value {
+    color: #f59e0b;
+}
+
+.stat-card.danger .stat-value {
+    color: #ef4444;
+}
+
+/* 筛选和操作栏样式 */
+.filter-bar {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 20px;
+    flex-wrap: wrap;
+    gap: 15px;
+}
+
+.filter-group {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+}
+
+.filter-group label {
+    color: #374151;
+    font-weight: 500;
+    font-size: 14px;
+}
+
+.filter-select {
+    padding: 8px 12px;
+    border: 1px solid #d1d5db;
+    border-radius: 5px;
+    font-size: 14px;
+    background: white;
+    cursor: pointer;
+    min-width: 120px;
+}
+
+.filter-select:focus {
+    outline: none;
+    border-color: #667eea;
+    box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+}

+ 137 - 0
web/css/login.css

@@ -0,0 +1,137 @@
+* {
+    margin: 0;
+    padding: 0;
+    box-sizing: border-box;
+}
+
+body {
+    font-family:
+        -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+    min-height: 100vh;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 20px;
+}
+
+.login-container {
+    background: white;
+    border-radius: 10px;
+    box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
+    padding: 40px;
+    width: 100%;
+    max-width: 400px;
+    animation: slideDown 0.3s;
+}
+
+@keyframes slideDown {
+    from {
+        transform: translateY(-50px);
+        opacity: 0;
+    }
+    to {
+        transform: translateY(0);
+        opacity: 1;
+    }
+}
+
+.login-header {
+    text-align: center;
+    margin-bottom: 30px;
+}
+
+.login-header h1 {
+    color: #333;
+    font-size: 28px;
+    margin-bottom: 10px;
+}
+
+.login-header p {
+    color: #6b7280;
+    font-size: 14px;
+}
+
+.form-group {
+    margin-bottom: 20px;
+}
+
+.form-group label {
+    display: block;
+    margin-bottom: 8px;
+    color: #374151;
+    font-weight: 500;
+    font-size: 14px;
+}
+
+.form-group input {
+    width: 100%;
+    padding: 12px;
+    border: 1px solid #d1d5db;
+    border-radius: 5px;
+    font-size: 14px;
+    transition: all 0.3s;
+}
+
+.form-group input:focus {
+    outline: none;
+    border-color: #667eea;
+    box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+}
+
+.btn {
+    width: 100%;
+    padding: 12px;
+    border: none;
+    border-radius: 5px;
+    font-size: 16px;
+    font-weight: 500;
+    cursor: pointer;
+    transition: all 0.3s;
+}
+
+.btn-primary {
+    background: #667eea;
+    color: white;
+}
+
+.btn-primary:hover {
+    background: #5568d3;
+    transform: translateY(-2px);
+    box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3);
+}
+
+.btn-primary:active {
+    transform: translateY(0);
+}
+
+.btn-primary:disabled {
+    background: #9ca3af;
+    cursor: not-allowed;
+    transform: none;
+}
+
+.error-message {
+    background: #fee2e2;
+    color: #dc2626;
+    padding: 12px;
+    border-radius: 5px;
+    margin-bottom: 20px;
+    font-size: 14px;
+    display: none;
+}
+
+.error-message.show {
+    display: block;
+}
+
+.loading {
+    display: none;
+    text-align: center;
+    color: #6b7280;
+    margin-top: 10px;
+}
+
+.loading.show {
+    display: block;
+}

+ 88 - 1094
web/index.html

@@ -4,498 +4,7 @@
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <title>License 管理平台</title>
-    <style>
-        * {
-            margin: 0;
-            padding: 0;
-            box-sizing: border-box;
-        }
-
-        body {
-            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-            min-height: 100vh;
-            padding: 20px;
-        }
-
-        .container {
-            max-width: 1200px;
-            margin: 0 auto;
-        }
-
-        .header {
-            background: white;
-            padding: 30px;
-            border-radius: 10px;
-            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
-            margin-bottom: 20px;
-            display: flex;
-            justify-content: space-between;
-            align-items: center;
-        }
-
-        .header h1 {
-            color: #333;
-            font-size: 28px;
-        }
-
-        .btn {
-            padding: 10px 20px;
-            border: none;
-            border-radius: 5px;
-            cursor: pointer;
-            font-size: 14px;
-            transition: all 0.3s;
-            font-weight: 500;
-        }
-
-        .btn-primary {
-            background: #667eea;
-            color: white;
-        }
-
-        .btn-primary:hover {
-            background: #5568d3;
-            transform: translateY(-2px);
-            box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3);
-        }
-
-        .btn-danger {
-            background: #ef4444;
-            color: white;
-        }
-
-        .btn-danger:hover {
-            background: #dc2626;
-        }
-
-        .btn-secondary {
-            background: #6b7280;
-            color: white;
-        }
-
-        .btn-secondary:hover {
-            background: #4b5563;
-        }
-
-        .card {
-            background: white;
-            border-radius: 10px;
-            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
-            padding: 30px;
-            margin-bottom: 20px;
-        }
-
-        .table-container {
-            overflow-x: auto;
-        }
-
-        table {
-            width: 100%;
-            border-collapse: collapse;
-        }
-
-        th, td {
-            padding: 12px;
-            text-align: left;
-            border-bottom: 1px solid #e5e7eb;
-        }
-
-        th {
-            background: #f9fafb;
-            font-weight: 600;
-            color: #374151;
-        }
-
-        tr:hover {
-            background: #f9fafb;
-        }
-
-        .badge {
-            display: inline-block;
-            padding: 4px 12px;
-            border-radius: 12px;
-            font-size: 12px;
-            font-weight: 500;
-        }
-
-        .badge-success {
-            background: #d1fae5;
-            color: #065f46;
-        }
-
-        .badge-warning {
-            background: #fef3c7;
-            color: #92400e;
-        }
-
-        .modal {
-            display: none;
-            position: fixed;
-            z-index: 1000;
-            left: 0;
-            top: 0;
-            width: 100%;
-            height: 100%;
-            background: rgba(0, 0, 0, 0.5);
-            animation: fadeIn 0.3s;
-        }
-
-        .modal.show {
-            display: flex;
-            align-items: center;
-            justify-content: center;
-        }
-
-        @keyframes fadeIn {
-            from { opacity: 0; }
-            to { opacity: 1; }
-        }
-
-        .modal-content {
-            background: white;
-            border-radius: 10px;
-            padding: 30px;
-            width: 90%;
-            max-width: 500px;
-            animation: slideDown 0.3s;
-        }
-
-        @keyframes slideDown {
-            from {
-                transform: translateY(-50px);
-                opacity: 0;
-            }
-            to {
-                transform: translateY(0);
-                opacity: 1;
-            }
-        }
-
-        .modal-header {
-            display: flex;
-            justify-content: space-between;
-            align-items: center;
-            margin-bottom: 20px;
-        }
-
-        .modal-header h2 {
-            color: #333;
-            font-size: 24px;
-        }
-
-        .close {
-            font-size: 28px;
-            font-weight: bold;
-            color: #999;
-            cursor: pointer;
-            border: none;
-            background: none;
-        }
-
-        .close:hover {
-            color: #333;
-        }
-
-        .form-group {
-            margin-bottom: 20px;
-        }
-
-        .form-group label {
-            display: block;
-            margin-bottom: 8px;
-            color: #374151;
-            font-weight: 500;
-        }
-
-        .form-group input {
-            width: 100%;
-            padding: 10px;
-            border: 1px solid #d1d5db;
-            border-radius: 5px;
-            font-size: 14px;
-        }
-
-        .form-group input:focus {
-            outline: none;
-            border-color: #667eea;
-            box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
-        }
-
-        .form-actions {
-            display: flex;
-            gap: 10px;
-            justify-content: flex-end;
-            margin-top: 20px;
-        }
-
-        .pagination {
-            display: flex;
-            justify-content: center;
-            align-items: center;
-            gap: 10px;
-            margin-top: 20px;
-        }
-
-        .pagination button {
-            padding: 8px 16px;
-            border: 1px solid #d1d5db;
-            background: white;
-            border-radius: 5px;
-            cursor: pointer;
-        }
-
-        .pagination button:hover:not(:disabled) {
-            background: #f9fafb;
-        }
-
-        .pagination button:disabled {
-            opacity: 0.5;
-            cursor: not-allowed;
-        }
-
-        .pagination .page-info {
-            color: #6b7280;
-        }
-
-        .loading {
-            text-align: center;
-            padding: 40px;
-            color: #6b7280;
-        }
-
-        .empty-state {
-            text-align: center;
-            padding: 60px 20px;
-            color: #6b7280;
-        }
-
-        .empty-state svg {
-            width: 100px;
-            height: 100px;
-            margin-bottom: 20px;
-            opacity: 0.5;
-        }
-
-        .device-list {
-            font-size: 12px;
-            color: #6b7280;
-            max-width: 200px;
-            overflow: hidden;
-            text-overflow: ellipsis;
-            white-space: nowrap;
-        }
-
-        .actions {
-            display: flex;
-            gap: 8px;
-        }
-
-        .btn-sm {
-            padding: 6px 12px;
-            font-size: 12px;
-        }
-
-        .license-key-cell {
-            display: flex;
-            align-items: center;
-            gap: 8px;
-            max-width: 200px;
-        }
-
-        .license-key-text {
-            flex: 1;
-            overflow: hidden;
-            text-overflow: ellipsis;
-            white-space: nowrap;
-            font-weight: 600;
-        }
-
-        .copy-btn {
-            padding: 6px 12px;
-            font-size: 12px;
-            background: #667eea;
-            color: white;
-            border: none;
-            border-radius: 5px;
-            cursor: pointer;
-            transition: all 0.2s;
-            flex-shrink: 0;
-            font-weight: 500;
-            display: flex;
-            align-items: center;
-            gap: 4px;
-        }
-
-        .copy-btn:hover {
-            background: #5568d3;
-            transform: translateY(-1px);
-            box-shadow: 0 2px 4px rgba(102, 126, 234, 0.3);
-        }
-
-        .copy-btn:active {
-            transform: translateY(0);
-        }
-
-        /* Toast 通知样式 */
-        .toast-container {
-            position: fixed;
-            top: 20px;
-            right: 20px;
-            z-index: 10000;
-            display: flex;
-            flex-direction: column;
-            gap: 10px;
-        }
-
-        .toast {
-            background: white;
-            padding: 16px 20px;
-            border-radius: 8px;
-            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
-            display: flex;
-            align-items: center;
-            gap: 12px;
-            min-width: 300px;
-            max-width: 400px;
-            animation: slideInRight 0.3s ease-out;
-            position: relative;
-        }
-
-        @keyframes slideInRight {
-            from {
-                transform: translateX(100%);
-                opacity: 0;
-            }
-            to {
-                transform: translateX(0);
-                opacity: 1;
-            }
-        }
-
-        .toast.success {
-            border-left: 4px solid #10b981;
-        }
-
-        .toast.error {
-            border-left: 4px solid #ef4444;
-        }
-
-        .toast.warning {
-            border-left: 4px solid #f59e0b;
-        }
-
-        .toast.info {
-            border-left: 4px solid #3b82f6;
-        }
-
-        .toast-icon {
-            font-size: 20px;
-            flex-shrink: 0;
-        }
-
-        .toast.success .toast-icon {
-            color: #10b981;
-        }
-
-        .toast.error .toast-icon {
-            color: #ef4444;
-        }
-
-        .toast.warning .toast-icon {
-            color: #f59e0b;
-        }
-
-        .toast.info .toast-icon {
-            color: #3b82f6;
-        }
-
-        .toast-message {
-            flex: 1;
-            color: #374151;
-            font-size: 14px;
-            line-height: 1.5;
-        }
-
-        .toast-close {
-            background: none;
-            border: none;
-            font-size: 18px;
-            color: #9ca3af;
-            cursor: pointer;
-            padding: 0;
-            width: 20px;
-            height: 20px;
-            display: flex;
-            align-items: center;
-            justify-content: center;
-            flex-shrink: 0;
-        }
-
-        .toast-close:hover {
-            color: #374151;
-        }
-
-        /* 确认对话框样式 */
-        .confirm-dialog {
-            display: none;
-            position: fixed;
-            z-index: 10001;
-            left: 0;
-            top: 0;
-            width: 100%;
-            height: 100%;
-            background: rgba(0, 0, 0, 0.5);
-            align-items: center;
-            justify-content: center;
-            animation: fadeIn 0.3s;
-        }
-
-        .confirm-dialog.show {
-            display: flex;
-        }
-
-        .confirm-content {
-            background: white;
-            border-radius: 10px;
-            padding: 30px;
-            width: 90%;
-            max-width: 400px;
-            box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
-            animation: slideDown 0.3s;
-        }
-
-        .confirm-icon {
-            font-size: 48px;
-            text-align: center;
-            margin-bottom: 20px;
-        }
-
-        .confirm-title {
-            font-size: 20px;
-            font-weight: 600;
-            color: #374151;
-            text-align: center;
-            margin-bottom: 15px;
-        }
-
-        .confirm-message {
-            color: #6b7280;
-            text-align: center;
-            margin-bottom: 25px;
-            line-height: 1.6;
-        }
-
-        .confirm-actions {
-            display: flex;
-            gap: 10px;
-            justify-content: center;
-        }
-
-        .confirm-actions .btn {
-            min-width: 100px;
-        }
-    </style>
+    <link rel="stylesheet" href="/web/css/index.css">
 </head>
 <body>
     <!-- Toast 通知容器 -->
@@ -519,10 +28,31 @@
             <h1>🔑 License 管理平台</h1>
             <div style="display: flex; gap: 10px;">
                 <button class="btn btn-primary" onclick="openBatchModal()">📦 批量生成</button>
+                <button class="btn btn-primary" onclick="exportToCSV()">📥 导出 CSV</button>
                 <button class="btn btn-primary" onclick="openCreateModal()">+ 创建 License</button>
             </div>
         </div>
 
+        <!-- 统计信息卡片 -->
+        <div class="stats-container" id="stats-container" style="display: none;">
+            <div class="stat-card primary">
+                <div class="stat-label">总 License 数</div>
+                <div class="stat-value" id="stat-total">0</div>
+            </div>
+            <div class="stat-card success">
+                <div class="stat-label">已激活</div>
+                <div class="stat-value" id="stat-activated">0</div>
+            </div>
+            <div class="stat-card warning">
+                <div class="stat-label">未激活</div>
+                <div class="stat-value" id="stat-unactivated">0</div>
+            </div>
+            <div class="stat-card danger">
+                <div class="stat-label">总设备数</div>
+                <div class="stat-value" id="stat-devices">0</div>
+            </div>
+        </div>
+
         <div class="card">
             <div id="loading" class="loading" style="display: none;">加载中...</div>
             <div id="empty-state" class="empty-state" style="display: none;">
@@ -532,15 +62,29 @@
                 <p>暂无 License 数据</p>
             </div>
             <div class="table-container" id="table-container" style="display: none;">
-                <div style="margin-bottom: 15px; display: flex; justify-content: space-between; align-items: center;">
-                    <div style="display: flex; align-items: center; gap: 10px;">
-                        <input type="checkbox" id="select-all" onchange="toggleSelectAll()" style="width: 18px; height: 18px; cursor: pointer;">
-                        <label for="select-all" style="cursor: pointer; user-select: none;">全选</label>
-                        <span id="selected-count" style="color: #6b7280; font-size: 14px;">已选择 0 项</span>
+                <!-- 筛选和操作栏 -->
+                <div class="filter-bar">
+                    <div class="filter-group">
+                        <label for="status-filter">状态筛选:</label>
+                        <select id="status-filter" class="filter-select" onchange="handleStatusFilter()">
+                            <option value="">全部</option>
+                            <option value="activated">已激活</option>
+                            <option value="unactivated">未激活</option>
+                        </select>
                     </div>
-                    <button class="btn btn-danger" id="batch-delete-btn" onclick="batchDeleteLicenses()" style="display: none;">
-                        批量删除
-                    </button>
+                    <div style="display: flex; gap: 10px;">
+                        <button class="btn btn-primary" id="batch-update-btn" onclick="openBatchUpdateModal()" style="display: none;">
+                            ⚙️ 批量修改最大设备数
+                        </button>
+                        <button class="btn btn-danger" id="batch-delete-btn" onclick="batchDeleteLicenses()" style="display: none;">
+                            批量删除
+                        </button>
+                    </div>
+                </div>
+                <div style="margin-bottom: 15px; display: flex; align-items: center; gap: 10px;">
+                    <input type="checkbox" id="select-all" onchange="toggleSelectAll()" style="width: 18px; height: 18px; cursor: pointer;">
+                    <label for="select-all" style="cursor: pointer; user-select: none;">全选</label>
+                    <span id="selected-count" style="color: #6b7280; font-size: 14px;">已选择 0 项</span>
                 </div>
                 <table>
                     <thead>
@@ -550,10 +94,9 @@
                             </th>
                             <th>ID</th>
                             <th>激活码</th>
-                            <th>最大设备数</th>
-                            <th>已绑定设备</th>
-                            <th>设备激活时间</th>
+                            <th>设备详情</th>
                             <th>绑定设备数</th>
+                            <th>备注</th>
                             <th>创建时间</th>
                             <th>操作</th>
                         </tr>
@@ -587,6 +130,10 @@
                     <label for="license-bound-devices">已绑定设备 (JSON 数组,可选)</label>
                     <input type="text" id="license-bound-devices" placeholder='例如: ["device-1", "device-2"]'>
                 </div>
+                <div class="form-group">
+                    <label for="license-remark">备注 (可选)</label>
+                    <textarea id="license-remark" rows="3" placeholder="请输入备注信息" style="width: 100%; padding: 10px; border: 1px solid #d1d5db; border-radius: 5px; font-size: 14px; font-family: inherit; resize: vertical;"></textarea>
+                </div>
                 <div class="form-actions">
                     <button type="button" class="btn btn-secondary" onclick="closeModal()">取消</button>
                     <button type="submit" class="btn btn-primary">保存</button>
@@ -629,600 +176,47 @@
         </div>
     </div>
 
-    <script>
-        // 使用相对路径,自动适配当前域名
-        const API_BASE = '/api';
-        let currentPage = 1;
-        let pageSize = 10;
-        let total = 0;
-        let editingId = null;
-
-        // Toast 通知函数
-        function showToast(message, type = 'info', duration = 3000) {
-            const container = document.getElementById('toast-container');
-            const toast = document.createElement('div');
-            toast.className = `toast ${type}`;
-            
-            const icons = {
-                success: '✅',
-                error: '❌',
-                warning: '⚠️',
-                info: 'ℹ️'
-            };
-            
-            toast.innerHTML = `
-                <span class="toast-icon">${icons[type] || icons.info}</span>
-                <span class="toast-message">${message}</span>
-                <button class="toast-close" onclick="this.parentElement.remove()">&times;</button>
-            `;
-            
-            container.appendChild(toast);
-            
-            // 自动移除
-            setTimeout(() => {
-                if (toast.parentElement) {
-                    toast.style.animation = 'slideInRight 0.3s ease-out reverse';
-                    setTimeout(() => {
-                        if (toast.parentElement) {
-                            toast.remove();
-                        }
-                    }, 300);
-                }
-            }, duration);
-        }
-
-        // 确认对话框函数
-        let confirmCallback = null;
-        
-        function showConfirmDialog(message, title = '确认操作', okText = '确定', okType = 'danger') {
-            return new Promise((resolve) => {
-                document.getElementById('confirm-title').textContent = title;
-                document.getElementById('confirm-message').textContent = message;
-                const okBtn = document.getElementById('confirm-ok-btn');
-                okBtn.textContent = okText;
-                okBtn.className = `btn btn-${okType}`;
-                
-                confirmCallback = resolve;
-                document.getElementById('confirmDialog').classList.add('show');
-            });
-        }
-        
-        function closeConfirmDialog(confirmed) {
-            document.getElementById('confirmDialog').classList.remove('show');
-            if (confirmCallback) {
-                confirmCallback(confirmed);
-                confirmCallback = null;
-            }
-        }
-
-        // 复制激活码到剪贴板
-        async function copyLicenseKey(key) {
-            try {
-                await navigator.clipboard.writeText(key);
-                showToast('激活码已复制到剪贴板', 'success', 2000);
-            } catch (err) {
-                // 降级方案:使用传统方法
-                const textArea = document.createElement('textarea');
-                textArea.value = key;
-                textArea.style.position = 'fixed';
-                textArea.style.left = '-999999px';
-                document.body.appendChild(textArea);
-                textArea.select();
-                try {
-                    document.execCommand('copy');
-                    showToast('激活码已复制到剪贴板', 'success', 2000);
-                } catch (err) {
-                    showToast('复制失败,请手动复制', 'error');
-                }
-                document.body.removeChild(textArea);
-            }
-        }
-
-        // 全选/取消全选
-        function toggleSelectAll() {
-            const selectAll = document.getElementById('select-all');
-            const selectAllHeader = document.getElementById('select-all-header');
-            const checkboxes = document.querySelectorAll('.license-checkbox');
-            const isChecked = selectAll.checked || selectAllHeader.checked;
-            
-            checkboxes.forEach(checkbox => {
-                checkbox.checked = isChecked;
-            });
-            
-            // 同步两个全选复选框
-            selectAll.checked = isChecked;
-            selectAllHeader.checked = isChecked;
-            
-            updateSelectedCount();
-        }
-
-        // 更新选中数量
-        function updateSelectedCount() {
-            const checkboxes = document.querySelectorAll('.license-checkbox:checked');
-            const count = checkboxes.length;
-            const selectedCountEl = document.getElementById('selected-count');
-            const batchDeleteBtn = document.getElementById('batch-delete-btn');
-            
-            selectedCountEl.textContent = `已选择 ${count} 项`;
-            
-            if (count > 0) {
-                batchDeleteBtn.style.display = 'block';
-            } else {
-                batchDeleteBtn.style.display = 'none';
-            }
-            
-            // 更新全选复选框状态
-            const allCheckboxes = document.querySelectorAll('.license-checkbox');
-            const allChecked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length;
-            document.getElementById('select-all').checked = allChecked;
-            document.getElementById('select-all-header').checked = allChecked;
-        }
-
-        // 批量删除 License
-        async function batchDeleteLicenses() {
-            const checkboxes = document.querySelectorAll('.license-checkbox:checked');
-            const selectedIds = Array.from(checkboxes).map(cb => parseInt(cb.value));
-            
-            if (selectedIds.length === 0) {
-                showToast('请至少选择一个 License', 'warning');
-                return;
-            }
-            
-            const confirmed = await showConfirmDialog(
-                `确定要删除选中的 ${selectedIds.length} 个 License 吗?此操作不可恢复!`,
-                '确认批量删除',
-                '删除',
-                'danger'
-            );
-            
-            if (!confirmed) {
-                return;
-            }
-            
-            try {
-                const response = await apiRequest(`${API_BASE}/licenses/batch`, {
-                    method: 'DELETE',
-                    body: JSON.stringify({
-                        ids: selectedIds
-                    })
-                });
-                if (!response) return;
-                
-                const result = await response.json();
-                
-                if (result.code === 0) {
-                    showToast(result.msg, 'success');
-                    loadLicenses(currentPage);
-                } else {
-                    showToast('批量删除失败: ' + result.msg, 'error');
-                }
-            } catch (error) {
-                showToast('请求失败: ' + error.message, 'error');
-            }
-        }
-
-        // 获取认证token
-        function getAuthToken() {
-            return localStorage.getItem('auth_token');
-        }
-
-        // 检查是否已登录
-        function checkAuth() {
-            const token = getAuthToken();
-            if (!token) {
-                window.location.href = '/web/login.html';
-                return false;
-            }
-            return true;
-        }
-
-        // 获取API请求头(包含token)
-        function getAuthHeaders() {
-            const token = getAuthToken();
-            return {
-                'Content-Type': 'application/json',
-                'Authorization': `Bearer ${token}`
-            };
-        }
-
-        // 处理API错误响应
-        async function handleApiError(response) {
-            if (response.status === 401) {
-                // 未授权,清除token并跳转到登录页
-                localStorage.removeItem('auth_token');
-                showToast('登录已过期,请重新登录', 'error');
-                setTimeout(() => {
-                    window.location.href = '/web/login.html';
-                }, 1000);
-                return true;
-            }
-            return false;
-        }
-
-        // 统一的API请求函数
-        async function apiRequest(url, options = {}) {
-            const headers = getAuthHeaders();
-            if (options.headers) {
-                Object.assign(headers, options.headers);
-            }
-            
-            const response = await fetch(url, {
-                ...options,
-                headers: headers
-            });
-            
-            if (await handleApiError(response)) {
-                return null;
-            }
-            
-            return response;
-        }
-
-        // 页面加载时检查登录状态
-        window.onload = () => {
-            if (checkAuth()) {
-                loadLicenses();
-            }
-        };
-
-        // 加载 License 列表
-        async function loadLicenses(page = 1) {
-            currentPage = page;
-            const loadingEl = document.getElementById('loading');
-            const tableContainer = document.getElementById('table-container');
-            const emptyState = document.getElementById('empty-state');
-
-            loadingEl.style.display = 'block';
-            tableContainer.style.display = 'none';
-            emptyState.style.display = 'none';
-
-            try {
-                const response = await apiRequest(`${API_BASE}/licenses?page=${page}&page_size=${pageSize}`);
-                if (!response) return;
-                
-                const result = await response.json();
-
-                if (result.code === 0) {
-                    total = result.total;
-                    const licenses = result.data;
-
-                    if (licenses.length === 0) {
-                        loadingEl.style.display = 'none';
-                        emptyState.style.display = 'block';
-                        return;
-                    }
-
-                    renderTable(licenses);
-                    renderPagination();
-                    loadingEl.style.display = 'none';
-                    tableContainer.style.display = 'block';
-                } else {
-                    showToast('加载失败: ' + result.msg, 'error');
-                    loadingEl.style.display = 'none';
-                }
-            } catch (error) {
-                showToast('请求失败: ' + error.message, 'error');
-                loadingEl.style.display = 'none';
-            }
-        }
-
-        // 渲染表格
-        function renderTable(licenses) {
-            const tbody = document.getElementById('license-table-body');
-            tbody.innerHTML = licenses.map(license => {
-                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 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');
-
-                // 限制激活码显示长度为10个字符
-                const displayKey = license.key.length > 10 ? license.key.substring(0, 10) + '...' : license.key;
-                
-                return `
-                    <tr>
-                        <td style="text-align: center;">
-                            <input type="checkbox" class="license-checkbox" value="${license.id}" onchange="updateSelectedCount()" style="width: 18px; height: 18px; cursor: pointer;">
-                        </td>
-                        <td>${license.id}</td>
-                        <td>
-                            <div class="license-key-cell" title="${license.key}">
-                                <span class="license-key-text">${displayKey}</span>
-                                <button class="copy-btn" onclick="copyLicenseKey('${license.key}')" title="复制激活码">
-                                    <span>复制</span>
-                                </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>
-                        <td>
-                            <span class="badge ${isFull ? 'badge-warning' : 'badge-success'}">
-                                ${boundCount} / ${license.max_devices}
-                            </span>
-                        </td>
-                        <td>${createdDate}</td>
-                        <td>
-                            <div class="actions">
-                                <button class="btn btn-primary btn-sm" onclick="editLicense(${license.id})">编辑</button>
-                                <button class="btn btn-danger btn-sm" onclick="deleteLicense(${license.id}, '${license.key}')">删除</button>
-                            </div>
-                        </td>
-                    </tr>
-                `;
-            }).join('');
-        }
-
-        // 渲染分页
-        function renderPagination() {
-            const pagination = document.getElementById('pagination');
-            const totalPages = Math.ceil(total / pageSize);
-
-            if (totalPages <= 1) {
-                pagination.innerHTML = '';
-                return;
-            }
-
-            pagination.innerHTML = `
-                <button onclick="loadLicenses(${currentPage - 1})" ${currentPage === 1 ? 'disabled' : ''}>
-                    上一页
-                </button>
-                <span class="page-info">第 ${currentPage} / ${totalPages} 页 (共 ${total} 条)</span>
-                <button onclick="loadLicenses(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}>
-                    下一页
-                </button>
-            `;
-        }
-
-        // 打开创建 Modal
-        function openCreateModal() {
-            editingId = null;
-            document.getElementById('modal-title').textContent = '创建 License';
-            document.getElementById('license-id').value = '';
-            document.getElementById('license-key').value = '';
-            document.getElementById('license-max-devices').value = '2';
-            document.getElementById('license-bound-devices').value = '';
-            document.getElementById('license-key').disabled = false;
-            document.getElementById('licenseModal').classList.add('show');
-        }
-
-        // 编辑 License
-        async function editLicense(id) {
-            try {
-                const response = await apiRequest(`${API_BASE}/licenses/${id}`);
-                if (!response) return;
-                
-                const result = await response.json();
-
-                if (result.code === 0) {
-                    const license = result.data;
-                    editingId = id;
-                    document.getElementById('modal-title').textContent = '编辑 License';
-                    document.getElementById('license-id').value = id;
-                    document.getElementById('license-key').value = license.key;
-                    document.getElementById('license-max-devices').value = license.max_devices;
-                    document.getElementById('license-bound-devices').value = license.bound_devices || '[]';
-                    document.getElementById('license-key').disabled = false;
-                    document.getElementById('licenseModal').classList.add('show');
-                } else {
-                    showToast('加载失败: ' + result.msg, 'error');
-                }
-            } catch (error) {
-                showToast('请求失败: ' + error.message, 'error');
-            }
-        }
-
-        // 关闭 Modal
-        function closeModal() {
-            document.getElementById('licenseModal').classList.remove('show');
-        }
-
-        // 提交表单
-        async function handleSubmit(event) {
-            event.preventDefault();
-
-            const id = document.getElementById('license-id').value;
-            const key = document.getElementById('license-key').value;
-            const maxDevices = parseInt(document.getElementById('license-max-devices').value);
-            const boundDevices = document.getElementById('license-bound-devices').value || '[]';
-
-            // 验证 boundDevices 是否为有效 JSON
-            try {
-                JSON.parse(boundDevices);
-            } catch (e) {
-                showToast('已绑定设备必须是有效的 JSON 数组格式', 'error');
-                return;
-            }
-
-            try {
-                let response;
-                if (editingId) {
-                    // 更新
-                    const updateData = {
-                        max_devices: maxDevices
-                    };
-                    if (boundDevices) {
-                        updateData.bound_devices = boundDevices;
-                    }
-                    response = await apiRequest(`${API_BASE}/licenses/${id}`, {
-                        method: 'PUT',
-                        body: JSON.stringify(updateData)
-                    });
-                } else {
-                    // 创建
-                    response = await apiRequest(`${API_BASE}/licenses`, {
-                        method: 'POST',
-                        body: JSON.stringify({
-                            key: key,
-                            max_devices: maxDevices,
-                            bound_devices: boundDevices
-                        })
-                    });
-                }
-
-                if (!response) return;
-                
-                const result = await response.json();
-
-                if (result.code === 0) {
-                    showToast(editingId ? '更新成功' : '创建成功', 'success');
-                    closeModal();
-                    loadLicenses(currentPage);
-                } else {
-                    showToast('操作失败: ' + result.msg, 'error');
-                }
-            } catch (error) {
-                showToast('请求失败: ' + error.message, 'error');
-            }
-        }
-
-        // 删除 License
-        async function deleteLicense(id, key) {
-            const confirmed = await showConfirmDialog(
-                `确定要删除 License "${key}" 吗?此操作不可恢复!`,
-                '确认删除',
-                '删除',
-                'danger'
-            );
-            
-            if (!confirmed) {
-                return;
-            }
-
-            try {
-                const response = await apiRequest(`${API_BASE}/licenses/${id}`, {
-                    method: 'DELETE'
-                });
-                if (!response) return;
-                
-                const result = await response.json();
-
-                if (result.code === 0) {
-                    showToast('删除成功', 'success');
-                    loadLicenses(currentPage);
-                } else {
-                    showToast('删除失败: ' + result.msg, 'error');
-                }
-            } catch (error) {
-                showToast('请求失败: ' + error.message, 'error');
-            }
-        }
-
-        // 打开批量生成 Modal
-        function openBatchModal() {
-            document.getElementById('batch-prefix').value = 'VIP';
-            document.getElementById('batch-count').value = '10';
-            document.getElementById('batch-max-devices').value = '2';
-            document.getElementById('batchModal').classList.add('show');
-        }
-
-        // 关闭批量生成 Modal
-        function closeBatchModal() {
-            document.getElementById('batchModal').classList.remove('show');
-        }
-
-        // 批量生成提交
-        async function handleBatchSubmit(event) {
-            event.preventDefault();
-
-            const prefix = document.getElementById('batch-prefix').value.trim();
-            const count = parseInt(document.getElementById('batch-count').value);
-            const maxDevices = parseInt(document.getElementById('batch-max-devices').value);
-
-            if (!prefix) {
-                showToast('请输入激活码前缀', 'warning');
-                return;
-            }
-
-            if (count <= 0 || count > 1000) {
-                showToast('生成数量必须在 1-1000 之间', 'warning');
-                return;
-            }
-
-            const confirmed = await showConfirmDialog(
-                `确定要批量生成 ${count} 个激活码吗?`,
-                '确认批量生成',
-                '生成',
-                'primary'
-            );
-            
-            if (!confirmed) {
-                return;
-            }
-
-            try {
-                const response = await apiRequest(`${API_BASE}/licenses/batch`, {
-                    method: 'POST',
-                    body: JSON.stringify({
-                        prefix: prefix,
-                        count: count,
-                        max_devices: maxDevices
-                    })
-                });
-                if (!response) return;
+    <!-- 设备列表 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>
 
-                const result = await response.json();
+    <!-- 批量修改最大设备数 Modal -->
+    <div id="batchUpdateModal" class="modal">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h2>批量修改最大设备数</h2>
+                <button class="close" onclick="closeBatchUpdateModal()">&times;</button>
+            </div>
+            <form id="batchUpdateForm" onsubmit="handleBatchUpdateSubmit(event)">
+                <div class="form-group">
+                    <label>已选择 <span id="batch-update-count">0</span> 个 License</label>
+                </div>
+                <div class="form-group">
+                    <label for="batch-update-max-devices">新的最大设备数 *</label>
+                    <input type="number" id="batch-update-max-devices" required min="1" value="2">
+                    <small style="color: #6b7280; font-size: 12px; margin-top: 4px; display: block;">
+                        将把选中 License 的最大设备数统一修改为此值
+                    </small>
+                </div>
+                <div class="form-actions">
+                    <button type="button" class="btn btn-secondary" onclick="closeBatchUpdateModal()">取消</button>
+                    <button type="submit" class="btn btn-primary">确认修改</button>
+                </div>
+            </form>
+        </div>
+    </div>
 
-                if (result.code === 0) {
-                    showToast(result.msg, 'success', 4000);
-                    closeBatchModal();
-                    loadLicenses(1); // 重新加载第一页
-                } else {
-                    showToast('批量生成失败: ' + result.msg, 'error');
-                }
-            } catch (error) {
-                showToast('请求失败: ' + error.message, 'error');
-            }
-        }
+    <script src="/web/js/index.js"></script>
 
-        // 点击 Modal 外部关闭
-        window.onclick = function(event) {
-            const licenseModal = document.getElementById('licenseModal');
-            const batchModal = document.getElementById('batchModal');
-            const confirmDialog = document.getElementById('confirmDialog');
-            
-            if (event.target === licenseModal) {
-                closeModal();
-            }
-            if (event.target === batchModal) {
-                closeBatchModal();
-            }
-            if (event.target === confirmDialog) {
-                closeConfirmDialog(false);
-            }
-        }
-    </script>
 </body>
 </html>
 

+ 1014 - 0
web/js/index.js

@@ -0,0 +1,1014 @@
+// 使用相对路径,自动适配当前域名
+const API_BASE = '/api';
+let currentPage = 1;
+let pageSize = 10;
+let total = 0;
+let editingId = null;
+let currentStatusFilter = ''; // 当前状态筛选:''(全部)、'activated'(已激活)、'unactivated'(未激活)
+let allLicenses = []; // 存储所有License数据用于统计
+
+// Toast 通知函数
+function showToast(message, type = 'info', duration = 3000) {
+    const container = document.getElementById('toast-container');
+    const toast = document.createElement('div');
+    toast.className = `toast ${type}`;
+    
+    const icons = {
+        success: '✅',
+        error: '❌',
+        warning: '⚠️',
+        info: 'ℹ️'
+    };
+    
+    toast.innerHTML = `
+        <span class="toast-icon">${icons[type] || icons.info}</span>
+        <span class="toast-message">${message}</span>
+        <button class="toast-close" onclick="this.parentElement.remove()">&times;</button>
+    `;
+    
+    container.appendChild(toast);
+    
+    // 自动移除
+    setTimeout(() => {
+        if (toast.parentElement) {
+            toast.style.animation = 'slideInRight 0.3s ease-out reverse';
+            setTimeout(() => {
+                if (toast.parentElement) {
+                    toast.remove();
+                }
+            }, 300);
+        }
+    }, duration);
+}
+
+// 确认对话框函数
+let confirmCallback = null;
+
+function showConfirmDialog(message, title = '确认操作', okText = '确定', okType = 'danger') {
+    return new Promise((resolve) => {
+        document.getElementById('confirm-title').textContent = title;
+        document.getElementById('confirm-message').textContent = message;
+        const okBtn = document.getElementById('confirm-ok-btn');
+        okBtn.textContent = okText;
+        okBtn.className = `btn btn-${okType}`;
+        
+        confirmCallback = resolve;
+        document.getElementById('confirmDialog').classList.add('show');
+    });
+}
+
+function closeConfirmDialog(confirmed) {
+    document.getElementById('confirmDialog').classList.remove('show');
+    if (confirmCallback) {
+        confirmCallback(confirmed);
+        confirmCallback = null;
+    }
+}
+
+// 复制激活码到剪贴板
+async function copyLicenseKey(key) {
+    try {
+        await navigator.clipboard.writeText(key);
+        showToast('激活码已复制到剪贴板', 'success', 2000);
+    } catch (err) {
+        // 降级方案:使用传统方法
+        const textArea = document.createElement('textarea');
+        textArea.value = key;
+        textArea.style.position = 'fixed';
+        textArea.style.left = '-999999px';
+        document.body.appendChild(textArea);
+        textArea.select();
+        try {
+            document.execCommand('copy');
+            showToast('激活码已复制到剪贴板', 'success', 2000);
+        } catch (err) {
+            showToast('复制失败,请手动复制', 'error');
+        }
+        document.body.removeChild(textArea);
+    }
+}
+
+// 全选/取消全选
+function toggleSelectAll() {
+    const selectAll = document.getElementById('select-all');
+    const selectAllHeader = document.getElementById('select-all-header');
+    const checkboxes = document.querySelectorAll('.license-checkbox');
+    
+    // 检查当前是否所有复选框都已选中
+    const allChecked = checkboxes.length > 0 && 
+        Array.from(checkboxes).every(checkbox => checkbox.checked);
+    
+    // 如果全部已选中,则取消全选;否则全选
+    const isChecked = !allChecked;
+    
+    checkboxes.forEach(checkbox => {
+        checkbox.checked = isChecked;
+    });
+    
+    // 同步两个全选复选框
+    selectAll.checked = isChecked;
+    selectAllHeader.checked = isChecked;
+    
+    updateSelectedCount();
+}
+
+// 更新选中数量
+function updateSelectedCount() {
+    const checkboxes = document.querySelectorAll('.license-checkbox:checked');
+    const count = checkboxes.length;
+    const selectedCountEl = document.getElementById('selected-count');
+    const batchDeleteBtn = document.getElementById('batch-delete-btn');
+    
+    selectedCountEl.textContent = `已选择 ${count} 项`;
+    
+    if (count > 0) {
+        batchDeleteBtn.style.display = 'block';
+    } else {
+        batchDeleteBtn.style.display = 'none';
+    }
+    
+    // 更新批量操作按钮
+    updateBatchButtons();
+    
+    // 更新全选复选框状态
+    const allCheckboxes = document.querySelectorAll('.license-checkbox');
+    const allChecked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length;
+    document.getElementById('select-all').checked = allChecked;
+    document.getElementById('select-all-header').checked = allChecked;
+}
+
+// 批量删除 License
+async function batchDeleteLicenses() {
+    const checkboxes = document.querySelectorAll('.license-checkbox:checked');
+    const selectedIds = Array.from(checkboxes).map(cb => parseInt(cb.value));
+    
+    if (selectedIds.length === 0) {
+        showToast('请至少选择一个 License', 'warning');
+        return;
+    }
+    
+    const confirmed = await showConfirmDialog(
+        `确定要删除选中的 ${selectedIds.length} 个 License 吗?此操作不可恢复!`,
+        '确认批量删除',
+        '删除',
+        'danger'
+    );
+    
+    if (!confirmed) {
+        return;
+    }
+    
+    try {
+        const response = await apiRequest(`${API_BASE}/licenses/batch`, {
+            method: 'DELETE',
+            body: JSON.stringify({
+                ids: selectedIds
+            })
+        });
+        if (!response) return;
+        
+        const result = await response.json();
+        
+        if (result.code === 0) {
+            showToast(result.msg, 'success');
+            loadStatistics(); // 重新加载统计信息
+            loadLicenses(currentPage);
+        } else {
+            showToast('批量删除失败: ' + result.msg, 'error');
+        }
+    } catch (error) {
+        showToast('请求失败: ' + error.message, 'error');
+    }
+}
+
+// 获取认证token
+function getAuthToken() {
+    return localStorage.getItem('auth_token');
+}
+
+// 检查是否已登录
+function checkAuth() {
+    const token = getAuthToken();
+    if (!token) {
+        window.location.href = '/web/login.html';
+        return false;
+    }
+    return true;
+}
+
+// 获取API请求头(包含token)
+function getAuthHeaders() {
+    const token = getAuthToken();
+    return {
+        'Content-Type': 'application/json',
+        'Authorization': `Bearer ${token}`
+    };
+}
+
+// 处理API错误响应
+async function handleApiError(response) {
+    if (response.status === 401) {
+        // 未授权,清除token并跳转到登录页
+        localStorage.removeItem('auth_token');
+        showToast('登录已过期,请重新登录', 'error');
+        setTimeout(() => {
+            window.location.href = '/web/login.html';
+        }, 1000);
+        return true;
+    }
+    return false;
+}
+
+// 统一的API请求函数
+async function apiRequest(url, options = {}) {
+    const headers = getAuthHeaders();
+    if (options.headers) {
+        Object.assign(headers, options.headers);
+    }
+    
+    const response = await fetch(url, {
+        ...options,
+        headers: headers
+    });
+    
+    if (await handleApiError(response)) {
+        return null;
+    }
+    
+    return response;
+}
+
+// 页面加载时检查登录状态
+window.onload = () => {
+    if (checkAuth()) {
+        loadStatistics();
+        loadLicenses();
+    }
+};
+
+// 加载统计信息
+async function loadStatistics() {
+    try {
+        const response = await apiRequest(`${API_BASE}/licenses/statistics`);
+        if (!response) return;
+        
+        const result = await response.json();
+        if (result.code === 0 && result.data) {
+            const stats = result.data;
+            document.getElementById('stat-total').textContent = stats.total || 0;
+            document.getElementById('stat-activated').textContent = stats.activated || 0;
+            document.getElementById('stat-unactivated').textContent = stats.unactivated || 0;
+            document.getElementById('stat-devices').textContent = stats.total_devices || 0;
+            document.getElementById('stats-container').style.display = 'grid';
+        }
+    } catch (error) {
+        console.error('加载统计信息失败:', error);
+    }
+}
+
+// 加载 License 列表
+async function loadLicenses(page = 1) {
+    currentPage = page;
+    const loadingEl = document.getElementById('loading');
+    const tableContainer = document.getElementById('table-container');
+    const emptyState = document.getElementById('empty-state');
+
+    loadingEl.style.display = 'block';
+    tableContainer.style.display = 'none';
+    emptyState.style.display = 'none';
+
+    try {
+        // 构建查询URL
+        let url = `${API_BASE}/licenses?page=${page}&page_size=${pageSize}`;
+        if (currentStatusFilter) {
+            url += `&status=${currentStatusFilter}`;
+        }
+        
+        const response = await apiRequest(url);
+        if (!response) return;
+        
+        const result = await response.json();
+
+        if (result.code === 0) {
+            total = result.total;
+            const licenses = result.data;
+
+            if (licenses.length === 0) {
+                loadingEl.style.display = 'none';
+                emptyState.style.display = 'block';
+                return;
+            }
+
+            renderTable(licenses);
+            renderPagination();
+            loadingEl.style.display = 'none';
+            tableContainer.style.display = 'block';
+            
+            // 更新批量操作按钮显示
+            updateBatchButtons();
+        } else {
+            showToast('加载失败: ' + result.msg, 'error');
+            loadingEl.style.display = 'none';
+        }
+    } catch (error) {
+        showToast('请求失败: ' + error.message, 'error');
+        loadingEl.style.display = 'none';
+    }
+}
+
+// 状态筛选处理
+function handleStatusFilter() {
+    const filterSelect = document.getElementById('status-filter');
+    currentStatusFilter = filterSelect.value;
+    loadLicenses(1); // 重置到第一页
+}
+
+// 更新批量操作按钮显示
+function updateBatchButtons() {
+    const checkboxes = document.querySelectorAll('.license-checkbox:checked');
+    const batchUpdateBtn = document.getElementById('batch-update-btn');
+    if (checkboxes.length > 0) {
+        batchUpdateBtn.style.display = 'block';
+    } else {
+        batchUpdateBtn.style.display = 'none';
+    }
+}
+
+// 渲染表格
+function renderTable(licenses) {
+    const tbody = document.getElementById('license-table-body');
+    tbody.innerHTML = licenses.map(license => {
+        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 = {};
+        }
+        
+        const boundCount = boundDevices.length;
+        const isFull = boundCount >= license.max_devices;
+        const createdDate = new Date(license.created_at).toLocaleString('zh-CN');
+
+        // 限制激活码显示长度为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;">
+                    <input type="checkbox" class="license-checkbox" value="${license.id}" onchange="updateSelectedCount()" style="width: 18px; height: 18px; cursor: pointer;">
+                </td>
+                <td>${license.id}</td>
+                <td>
+                    <div class="license-key-cell" title="${license.key}">
+                        <span class="license-key-text">${displayKey}</span>
+                        <button class="copy-btn" onclick="copyLicenseKey('${license.key}')" title="复制激活码">
+                            <span>复制</span>
+                        </button>
+                    </div>
+                </td>
+                <td>
+                    ${deviceDetailHtml}
+                </td>
+                <td>
+                    <span class="badge ${isFull ? 'badge-warning' : 'badge-success'}">
+                        ${boundCount} / ${license.max_devices}
+                    </span>
+                </td>
+                <td>
+                    <div style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${license.remark || ''}">
+                        ${license.remark || '<span style="color: #9ca3af;">无</span>'}
+                    </div>
+                </td>
+                <td>${createdDate}</td>
+                <td>
+                    <div class="actions">
+                        <button class="btn btn-primary btn-sm" onclick="editLicense(${license.id})">编辑</button>
+                        <button class="btn btn-danger btn-sm" onclick="deleteLicense(${license.id}, '${license.key}')">删除</button>
+                    </div>
+                </td>
+            </tr>
+        `;
+    }).join('');
+}
+
+// 渲染分页
+function renderPagination() {
+    const pagination = document.getElementById('pagination');
+    const totalPages = Math.ceil(total / pageSize);
+
+    if (totalPages <= 1) {
+        pagination.innerHTML = '';
+        return;
+    }
+
+    pagination.innerHTML = `
+        <button onclick="loadLicenses(${currentPage - 1})" ${currentPage === 1 ? 'disabled' : ''}>
+            上一页
+        </button>
+        <span class="page-info">第 ${currentPage} / ${totalPages} 页 (共 ${total} 条)</span>
+        <button onclick="loadLicenses(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}>
+            下一页
+        </button>
+    `;
+}
+
+// 打开创建 Modal
+function openCreateModal() {
+    editingId = null;
+    document.getElementById('modal-title').textContent = '创建 License';
+    document.getElementById('license-id').value = '';
+    document.getElementById('license-key').value = '';
+    document.getElementById('license-max-devices').value = '2';
+    document.getElementById('license-bound-devices').value = '';
+    document.getElementById('license-remark').value = '';
+    document.getElementById('license-key').disabled = false;
+    document.getElementById('licenseModal').classList.add('show');
+}
+
+// 编辑 License
+async function editLicense(id) {
+    try {
+        const response = await apiRequest(`${API_BASE}/licenses/${id}`);
+        if (!response) return;
+        
+        const result = await response.json();
+
+        if (result.code === 0) {
+            const license = result.data;
+            editingId = id;
+            document.getElementById('modal-title').textContent = '编辑 License';
+            document.getElementById('license-id').value = id;
+            document.getElementById('license-key').value = license.key;
+            document.getElementById('license-max-devices').value = license.max_devices;
+            document.getElementById('license-bound-devices').value = license.bound_devices || '[]';
+            document.getElementById('license-remark').value = license.remark || '';
+            document.getElementById('license-key').disabled = false;
+            document.getElementById('licenseModal').classList.add('show');
+        } else {
+            showToast('加载失败: ' + result.msg, 'error');
+        }
+    } catch (error) {
+        showToast('请求失败: ' + error.message, 'error');
+    }
+}
+
+// 关闭 Modal
+function closeModal() {
+    document.getElementById('licenseModal').classList.remove('show');
+}
+
+// 提交表单
+async function handleSubmit(event) {
+    event.preventDefault();
+
+    const id = document.getElementById('license-id').value;
+    const key = document.getElementById('license-key').value;
+    const maxDevices = parseInt(document.getElementById('license-max-devices').value);
+    const boundDevices = document.getElementById('license-bound-devices').value || '[]';
+    const remark = document.getElementById('license-remark').value || '';
+
+    // 验证 boundDevices 是否为有效 JSON
+    try {
+        JSON.parse(boundDevices);
+    } catch (e) {
+        showToast('已绑定设备必须是有效的 JSON 数组格式', 'error');
+        return;
+    }
+
+    try {
+        let response;
+        if (editingId) {
+            // 更新
+            const updateData = {
+                max_devices: maxDevices,
+                remark: remark  // 总是发送remark字段,即使是空字符串
+            };
+            if (boundDevices) {
+                updateData.bound_devices = boundDevices;
+            }
+            response = await apiRequest(`${API_BASE}/licenses/${id}`, {
+                method: 'PUT',
+                body: JSON.stringify(updateData)
+            });
+        } else {
+            // 创建
+            response = await apiRequest(`${API_BASE}/licenses`, {
+                method: 'POST',
+                body: JSON.stringify({
+                    key: key,
+                    max_devices: maxDevices,
+                    bound_devices: boundDevices,
+                    remark: remark
+                })
+            });
+        }
+
+        if (!response) return;
+        
+        const result = await response.json();
+
+        if (result.code === 0) {
+            showToast(editingId ? '更新成功' : '创建成功', 'success');
+            closeModal();
+            loadStatistics(); // 重新加载统计信息
+            loadLicenses(currentPage);
+        } else {
+            showToast('操作失败: ' + result.msg, 'error');
+        }
+    } catch (error) {
+        showToast('请求失败: ' + error.message, 'error');
+    }
+}
+
+// 删除 License
+async function deleteLicense(id, key) {
+    const confirmed = await showConfirmDialog(
+        `确定要删除 License "${key}" 吗?此操作不可恢复!`,
+        '确认删除',
+        '删除',
+        'danger'
+    );
+    
+    if (!confirmed) {
+        return;
+    }
+
+    try {
+        const response = await apiRequest(`${API_BASE}/licenses/${id}`, {
+            method: 'DELETE'
+        });
+        if (!response) return;
+        
+        const result = await response.json();
+
+        if (result.code === 0) {
+            showToast('删除成功', 'success');
+            loadStatistics(); // 重新加载统计信息
+            loadLicenses(currentPage);
+        } else {
+            showToast('删除失败: ' + result.msg, 'error');
+        }
+    } catch (error) {
+        showToast('请求失败: ' + error.message, 'error');
+    }
+}
+
+// 打开批量生成 Modal
+function openBatchModal() {
+    document.getElementById('batch-prefix').value = 'VIP';
+    document.getElementById('batch-count').value = '10';
+    document.getElementById('batch-max-devices').value = '2';
+    document.getElementById('batchModal').classList.add('show');
+}
+
+// 关闭批量生成 Modal
+function closeBatchModal() {
+    document.getElementById('batchModal').classList.remove('show');
+}
+
+// 批量生成提交
+async function handleBatchSubmit(event) {
+    event.preventDefault();
+
+    const prefix = document.getElementById('batch-prefix').value.trim();
+    const count = parseInt(document.getElementById('batch-count').value);
+    const maxDevices = parseInt(document.getElementById('batch-max-devices').value);
+
+    if (!prefix) {
+        showToast('请输入激活码前缀', 'warning');
+        return;
+    }
+
+    if (count <= 0 || count > 1000) {
+        showToast('生成数量必须在 1-1000 之间', 'warning');
+        return;
+    }
+
+    const confirmed = await showConfirmDialog(
+        `确定要批量生成 ${count} 个激活码吗?`,
+        '确认批量生成',
+        '生成',
+        'primary'
+    );
+    
+    if (!confirmed) {
+        return;
+    }
+
+    try {
+        const response = await apiRequest(`${API_BASE}/licenses/batch`, {
+            method: 'POST',
+            body: JSON.stringify({
+                prefix: prefix,
+                count: count,
+                max_devices: maxDevices
+            })
+        });
+        if (!response) return;
+
+        const result = await response.json();
+
+        if (result.code === 0) {
+            showToast(result.msg, 'success', 4000);
+            closeBatchModal();
+            loadStatistics(); // 重新加载统计信息
+            loadLicenses(1); // 重新加载第一页
+        } else {
+            showToast('批量生成失败: ' + result.msg, 'error');
+        }
+    } catch (error) {
+        showToast('请求失败: ' + error.message, 'error');
+    }
+}
+
+// 从元素获取数据并显示设备列表弹框
+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');
+}
+
+// 导出CSV
+async function exportToCSV() {
+    try {
+        // 如果有筛选条件,先获取筛选后的数据,然后前端生成CSV
+        if (currentStatusFilter) {
+            showToast('正在导出筛选后的数据...', 'info');
+            
+            const listResponse = await apiRequest(`${API_BASE}/licenses?page=1&page_size=10000&status=${currentStatusFilter}`);
+            if (!listResponse) return;
+            
+            const listResult = await listResponse.json();
+            if (listResult.code === 0 && listResult.data.length > 0) {
+                const licenses = listResult.data;
+                const headers = ['ID', '激活码', '最大设备数', '已绑定设备数', '绑定设备列表', '创建时间', '更新时间'];
+                const rows = licenses.map(license => {
+                    let boundDevices = [];
+                    try {
+                        boundDevices = JSON.parse(license.bound_devices || '[]');
+                    } catch (e) {
+                        boundDevices = [];
+                    }
+                    
+                    return [
+                        license.id,
+                        license.key,
+                        license.max_devices,
+                        boundDevices.length,
+                        boundDevices.join('; '),
+                        new Date(license.created_at).toLocaleString('zh-CN'),
+                        new Date(license.updated_at).toLocaleString('zh-CN')
+                    ];
+                });
+                
+                function escapeCSVField(field) {
+                    if (field === null || field === undefined) return '';
+                    const str = String(field);
+                    if (str.includes(',') || str.includes('"') || str.includes('\n')) {
+                        return '"' + str.replace(/"/g, '""') + '"';
+                    }
+                    return str;
+                }
+                
+                const csvContent = [
+                    headers.map(escapeCSVField).join(','),
+                    ...rows.map(row => row.map(escapeCSVField).join(','))
+                ].join('\n');
+                
+                const BOM = '\uFEFF';
+                const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
+                const link = document.createElement('a');
+                const url = URL.createObjectURL(blob);
+                link.setAttribute('href', url);
+                
+                const now = new Date();
+                const dateStr = now.toISOString().slice(0, 10).replace(/-/g, '');
+                const timeStr = now.toTimeString().slice(0, 8).replace(/:/g, '');
+                const statusStr = currentStatusFilter ? `_${currentStatusFilter}` : '';
+                link.setAttribute('download', `licenses${statusStr}_${dateStr}_${timeStr}.csv`);
+                
+                link.style.visibility = 'hidden';
+                document.body.appendChild(link);
+                link.click();
+                document.body.removeChild(link);
+                
+                showToast(`成功导出 ${licenses.length} 条记录`, 'success');
+            } else {
+                showToast('没有数据可导出', 'warning');
+            }
+        } else {
+            // 没有筛选条件时,使用后端导出接口
+            const token = getAuthToken();
+            const url = `${API_BASE}/licenses/export?format=csv`;
+            
+            // 创建一个隐藏的表单来提交带token的请求
+            const form = document.createElement('form');
+            form.method = 'GET';
+            form.action = url;
+            form.style.display = 'none';
+            
+            // 添加token到请求头(通过fetch下载)
+            const response = await fetch(url, {
+                headers: getAuthHeaders()
+            });
+            
+            if (!response.ok) {
+                showToast('导出失败', 'error');
+                return;
+            }
+            
+            const blob = await response.blob();
+            const downloadUrl = URL.createObjectURL(blob);
+            const link = document.createElement('a');
+            link.href = downloadUrl;
+            
+            const now = new Date();
+            const dateStr = now.toISOString().slice(0, 10).replace(/-/g, '');
+            const timeStr = now.toTimeString().slice(0, 8).replace(/:/g, '');
+            link.setAttribute('download', `licenses_${dateStr}_${timeStr}.csv`);
+            
+            link.style.visibility = 'hidden';
+            document.body.appendChild(link);
+            link.click();
+            document.body.removeChild(link);
+            URL.revokeObjectURL(downloadUrl);
+            
+            showToast('导出成功', 'success');
+        }
+    } catch (error) {
+        showToast('导出失败: ' + error.message, 'error');
+    }
+}
+
+// 打开批量修改最大设备数弹框
+function openBatchUpdateModal() {
+    const checkboxes = document.querySelectorAll('.license-checkbox:checked');
+    const count = checkboxes.length;
+    
+    if (count === 0) {
+        showToast('请至少选择一个 License', 'warning');
+        return;
+    }
+    
+    document.getElementById('batch-update-count').textContent = count;
+    document.getElementById('batch-update-max-devices').value = '2';
+    document.getElementById('batchUpdateModal').classList.add('show');
+}
+
+// 关闭批量修改弹框
+function closeBatchUpdateModal() {
+    document.getElementById('batchUpdateModal').classList.remove('show');
+}
+
+// 批量修改最大设备数提交
+async function handleBatchUpdateSubmit(event) {
+    event.preventDefault();
+    
+    const checkboxes = document.querySelectorAll('.license-checkbox:checked');
+    const selectedIds = Array.from(checkboxes).map(cb => parseInt(cb.value));
+    const maxDevices = parseInt(document.getElementById('batch-update-max-devices').value);
+    
+    if (selectedIds.length === 0) {
+        showToast('请至少选择一个 License', 'warning');
+        return;
+    }
+    
+    if (maxDevices < 1) {
+        showToast('最大设备数必须大于0', 'warning');
+        return;
+    }
+    
+    const confirmed = await showConfirmDialog(
+        `确定要将选中的 ${selectedIds.length} 个 License 的最大设备数修改为 ${maxDevices} 吗?`,
+        '确认批量修改',
+        '确认',
+        'primary'
+    );
+    
+    if (!confirmed) {
+        return;
+    }
+    
+    try {
+        // 使用批量更新接口
+        const response = await apiRequest(`${API_BASE}/licenses/batch/max-devices`, {
+            method: 'PUT',
+            body: JSON.stringify({
+                ids: selectedIds,
+                max_devices: maxDevices
+            })
+        });
+        
+        if (!response) return;
+        
+        const result = await response.json();
+        
+        if (result.code === 0) {
+            showToast(result.msg, 'success');
+            closeBatchUpdateModal();
+            // 清除所有选中状态
+            checkboxes.forEach(cb => cb.checked = false);
+            updateSelectedCount();
+            loadStatistics(); // 重新加载统计信息
+            loadLicenses(currentPage); // 重新加载列表
+        } else {
+            showToast('批量修改失败: ' + result.msg, 'error');
+        }
+    } catch (error) {
+        showToast('请求失败: ' + error.message, 'error');
+    }
+}
+
+// 点击 Modal 外部关闭
+window.onclick = function(event) {
+    const licenseModal = document.getElementById('licenseModal');
+    const batchModal = document.getElementById('batchModal');
+    const batchUpdateModal = document.getElementById('batchUpdateModal');
+    const deviceListModal = document.getElementById('deviceListModal');
+    const confirmDialog = document.getElementById('confirmDialog');
+    
+    if (event.target === licenseModal) {
+        closeModal();
+    }
+    if (event.target === batchModal) {
+        closeBatchModal();
+    }
+    if (event.target === batchUpdateModal) {
+        closeBatchUpdateModal();
+    }
+    if (event.target === deviceListModal) {
+        closeDeviceListModal();
+    }
+    if (event.target === confirmDialog) {
+        closeConfirmDialog(false);
+    }
+}

+ 90 - 0
web/js/login.js

@@ -0,0 +1,90 @@
+// 使用相对路径,自动适配当前域名
+const API_BASE = "/api";
+
+// 检查是否已登录
+window.onload = () => {
+    const token = localStorage.getItem("auth_token");
+    if (token) {
+        // 验证token是否有效
+        verifyToken(token);
+    }
+};
+
+// 验证token
+async function verifyToken(token) {
+    try {
+        const response = await fetch(`${API_BASE}/login`, {
+            method: "POST",
+            headers: {
+                "Content-Type": "application/json"
+            },
+            body: JSON.stringify({ token: token })
+        });
+
+        const result = await response.json();
+        if (result.code === 0) {
+            // token有效,跳转到管理页面
+            window.location.href = "/web/index.html";
+        } else {
+            // token无效,清除本地存储
+            localStorage.removeItem("auth_token");
+        }
+    } catch (error) {
+        // 网络错误,清除本地存储
+        localStorage.removeItem("auth_token");
+    }
+}
+
+// 处理登录
+async function handleLogin(event) {
+    event.preventDefault();
+
+    const tokenInput = document.getElementById("token");
+    const token = tokenInput.value.trim();
+    const loginBtn = document.getElementById("login-btn");
+    const loading = document.getElementById("loading");
+    const errorMsg = document.getElementById("error-message");
+
+    if (!token) {
+        showError("请输入 Token");
+        return;
+    }
+
+    // 显示加载状态
+    loginBtn.disabled = true;
+    loading.classList.add("show");
+    errorMsg.classList.remove("show");
+
+    try {
+        const response = await fetch(`${API_BASE}/login`, {
+            method: "POST",
+            headers: {
+                "Content-Type": "application/json"
+            },
+            body: JSON.stringify({ token: token })
+        });
+
+        const result = await response.json();
+
+        if (result.code === 0) {
+            // 登录成功,保存token
+            localStorage.setItem("auth_token", token);
+            // 跳转到管理页面
+            window.location.href = "/web/index.html";
+        } else {
+            showError(result.msg || "登录失败");
+        }
+    } catch (error) {
+        showError("网络错误: " + error.message);
+    } finally {
+        loginBtn.disabled = false;
+        loading.classList.remove("show");
+    }
+}
+
+// 显示错误信息
+function showError(message) {
+    const errorMsg = document.getElementById("error-message");
+    errorMsg.textContent = message;
+    errorMsg.classList.add("show");
+}

+ 2 - 232
web/login.html

@@ -4,144 +4,7 @@
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <title>License 管理平台 - 登录</title>
-    <style>
-        * {
-            margin: 0;
-            padding: 0;
-            box-sizing: border-box;
-        }
-
-        body {
-            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-            min-height: 100vh;
-            display: flex;
-            align-items: center;
-            justify-content: center;
-            padding: 20px;
-        }
-
-        .login-container {
-            background: white;
-            border-radius: 10px;
-            box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
-            padding: 40px;
-            width: 100%;
-            max-width: 400px;
-            animation: slideDown 0.3s;
-        }
-
-        @keyframes slideDown {
-            from {
-                transform: translateY(-50px);
-                opacity: 0;
-            }
-            to {
-                transform: translateY(0);
-                opacity: 1;
-            }
-        }
-
-        .login-header {
-            text-align: center;
-            margin-bottom: 30px;
-        }
-
-        .login-header h1 {
-            color: #333;
-            font-size: 28px;
-            margin-bottom: 10px;
-        }
-
-        .login-header p {
-            color: #6b7280;
-            font-size: 14px;
-        }
-
-        .form-group {
-            margin-bottom: 20px;
-        }
-
-        .form-group label {
-            display: block;
-            margin-bottom: 8px;
-            color: #374151;
-            font-weight: 500;
-            font-size: 14px;
-        }
-
-        .form-group input {
-            width: 100%;
-            padding: 12px;
-            border: 1px solid #d1d5db;
-            border-radius: 5px;
-            font-size: 14px;
-            transition: all 0.3s;
-        }
-
-        .form-group input:focus {
-            outline: none;
-            border-color: #667eea;
-            box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
-        }
-
-        .btn {
-            width: 100%;
-            padding: 12px;
-            border: none;
-            border-radius: 5px;
-            font-size: 16px;
-            font-weight: 500;
-            cursor: pointer;
-            transition: all 0.3s;
-        }
-
-        .btn-primary {
-            background: #667eea;
-            color: white;
-        }
-
-        .btn-primary:hover {
-            background: #5568d3;
-            transform: translateY(-2px);
-            box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3);
-        }
-
-        .btn-primary:active {
-            transform: translateY(0);
-        }
-
-        .btn-primary:disabled {
-            background: #9ca3af;
-            cursor: not-allowed;
-            transform: none;
-        }
-
-        .error-message {
-            background: #fee2e2;
-            color: #dc2626;
-            padding: 12px;
-            border-radius: 5px;
-            margin-bottom: 20px;
-            font-size: 14px;
-            display: none;
-        }
-
-        .error-message.show {
-            display: block;
-        }
-
-        .loading {
-            display: none;
-            text-align: center;
-            color: #6b7280;
-            margin-top: 10px;
-        }
-
-        .loading.show {
-            display: block;
-        }
-    </style>
+    <link rel="stylesheet" href="/web/css/login.css">
 </head>
 <body>
     <div class="login-container">
@@ -171,99 +34,6 @@
         </form>
     </div>
 
-    <script>
-        // 使用相对路径,自动适配当前域名
-        const API_BASE = '/api';
-
-        // 检查是否已登录
-        window.onload = () => {
-            const token = localStorage.getItem('auth_token');
-            if (token) {
-                // 验证token是否有效
-                verifyToken(token);
-            }
-        };
-
-        // 验证token
-        async function verifyToken(token) {
-            try {
-                const response = await fetch(`${API_BASE}/login`, {
-                    method: 'POST',
-                    headers: {
-                        'Content-Type': 'application/json'
-                    },
-                    body: JSON.stringify({ token: token })
-                });
-
-                const result = await response.json();
-                if (result.code === 0) {
-                    // token有效,跳转到管理页面
-                    window.location.href = '/web/index.html';
-                } else {
-                    // token无效,清除本地存储
-                    localStorage.removeItem('auth_token');
-                }
-            } catch (error) {
-                // 网络错误,清除本地存储
-                localStorage.removeItem('auth_token');
-            }
-        }
-
-        // 处理登录
-        async function handleLogin(event) {
-            event.preventDefault();
-
-            const tokenInput = document.getElementById('token');
-            const token = tokenInput.value.trim();
-            const loginBtn = document.getElementById('login-btn');
-            const loading = document.getElementById('loading');
-            const errorMsg = document.getElementById('error-message');
-
-            if (!token) {
-                showError('请输入 Token');
-                return;
-            }
-
-            // 显示加载状态
-            loginBtn.disabled = true;
-            loading.classList.add('show');
-            errorMsg.classList.remove('show');
-
-            try {
-                const response = await fetch(`${API_BASE}/login`, {
-                    method: 'POST',
-                    headers: {
-                        'Content-Type': 'application/json'
-                    },
-                    body: JSON.stringify({ token: token })
-                });
-
-                const result = await response.json();
-
-                if (result.code === 0) {
-                    // 登录成功,保存token
-                    localStorage.setItem('auth_token', token);
-                    // 跳转到管理页面
-                    window.location.href = '/web/index.html';
-                } else {
-                    showError(result.msg || '登录失败');
-                }
-            } catch (error) {
-                showError('网络错误: ' + error.message);
-            } finally {
-                loginBtn.disabled = false;
-                loading.classList.remove('show');
-            }
-        }
-
-        // 显示错误信息
-        function showError(message) {
-            const errorMsg = document.getElementById('error-message');
-            errorMsg.textContent = message;
-            errorMsg.classList.add('show');
-        }
-    </script>
+    <script src="/web/js/login.js"></script>
 </body>
 </html>
-
-