Browse Source

Feature #TASK_QT-34190 init

Carl 1 day ago
parent
commit
255229712e
10 changed files with 2017 additions and 1883 deletions
  1. 112 0
      README.md
  2. 15 0
      database/add_heartbeat_column.sql
  3. 9 0
      database/add_remark_column.sql
  4. 3 1
      database/schema.sql
  5. 633 0
      web/css/index.css
  6. 137 0
      web/css/login.css
  7. 2 1650
      web/index.html
  8. 1014 0
      web/js/index.js
  9. 90 0
      web/js/login.js
  10. 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`;

+ 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`),

+ 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;
+}

+ 2 - 1650
web/index.html

@@ -4,641 +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;
-        }
-
-        .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);
-        }
-    </style>
+    <link rel="stylesheet" href="/web/css/index.css">
 </head>
 <body>
     <!-- Toast 通知容器 -->
@@ -849,1022 +215,8 @@
         </div>
     </div>
 
-    <script>
-        // 使用相对路径,自动适配当前域名
-        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');
-            }
-        }
+    <script src="/web/js/index.js"></script>
 
-        // 点击 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);
-            }
-        }
-    </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>
-
-