index.html 41 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>License 管理平台</title>
  7. <style>
  8. * {
  9. margin: 0;
  10. padding: 0;
  11. box-sizing: border-box;
  12. }
  13. body {
  14. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  15. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  16. min-height: 100vh;
  17. padding: 20px;
  18. }
  19. .container {
  20. max-width: 1200px;
  21. margin: 0 auto;
  22. }
  23. .header {
  24. background: white;
  25. padding: 30px;
  26. border-radius: 10px;
  27. box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  28. margin-bottom: 20px;
  29. display: flex;
  30. justify-content: space-between;
  31. align-items: center;
  32. }
  33. .header h1 {
  34. color: #333;
  35. font-size: 28px;
  36. }
  37. .btn {
  38. padding: 10px 20px;
  39. border: none;
  40. border-radius: 5px;
  41. cursor: pointer;
  42. font-size: 14px;
  43. transition: all 0.3s;
  44. font-weight: 500;
  45. }
  46. .btn-primary {
  47. background: #667eea;
  48. color: white;
  49. }
  50. .btn-primary:hover {
  51. background: #5568d3;
  52. transform: translateY(-2px);
  53. box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3);
  54. }
  55. .btn-danger {
  56. background: #ef4444;
  57. color: white;
  58. }
  59. .btn-danger:hover {
  60. background: #dc2626;
  61. }
  62. .btn-secondary {
  63. background: #6b7280;
  64. color: white;
  65. }
  66. .btn-secondary:hover {
  67. background: #4b5563;
  68. }
  69. .card {
  70. background: white;
  71. border-radius: 10px;
  72. box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  73. padding: 30px;
  74. margin-bottom: 20px;
  75. }
  76. .table-container {
  77. overflow-x: auto;
  78. }
  79. table {
  80. width: 100%;
  81. border-collapse: collapse;
  82. }
  83. th, td {
  84. padding: 12px;
  85. text-align: left;
  86. border-bottom: 1px solid #e5e7eb;
  87. }
  88. th {
  89. background: #f9fafb;
  90. font-weight: 600;
  91. color: #374151;
  92. }
  93. tr:hover {
  94. background: #f9fafb;
  95. }
  96. .badge {
  97. display: inline-block;
  98. padding: 4px 12px;
  99. border-radius: 12px;
  100. font-size: 12px;
  101. font-weight: 500;
  102. }
  103. .badge-success {
  104. background: #d1fae5;
  105. color: #065f46;
  106. }
  107. .badge-warning {
  108. background: #fef3c7;
  109. color: #92400e;
  110. }
  111. .modal {
  112. display: none;
  113. position: fixed;
  114. z-index: 1000;
  115. left: 0;
  116. top: 0;
  117. width: 100%;
  118. height: 100%;
  119. background: rgba(0, 0, 0, 0.5);
  120. animation: fadeIn 0.3s;
  121. }
  122. .modal.show {
  123. display: flex;
  124. align-items: center;
  125. justify-content: center;
  126. }
  127. @keyframes fadeIn {
  128. from { opacity: 0; }
  129. to { opacity: 1; }
  130. }
  131. .modal-content {
  132. background: white;
  133. border-radius: 10px;
  134. padding: 30px;
  135. width: 90%;
  136. max-width: 500px;
  137. animation: slideDown 0.3s;
  138. }
  139. @keyframes slideDown {
  140. from {
  141. transform: translateY(-50px);
  142. opacity: 0;
  143. }
  144. to {
  145. transform: translateY(0);
  146. opacity: 1;
  147. }
  148. }
  149. .modal-header {
  150. display: flex;
  151. justify-content: space-between;
  152. align-items: center;
  153. margin-bottom: 20px;
  154. }
  155. .modal-header h2 {
  156. color: #333;
  157. font-size: 24px;
  158. }
  159. .close {
  160. font-size: 28px;
  161. font-weight: bold;
  162. color: #999;
  163. cursor: pointer;
  164. border: none;
  165. background: none;
  166. }
  167. .close:hover {
  168. color: #333;
  169. }
  170. .form-group {
  171. margin-bottom: 20px;
  172. }
  173. .form-group label {
  174. display: block;
  175. margin-bottom: 8px;
  176. color: #374151;
  177. font-weight: 500;
  178. }
  179. .form-group input {
  180. width: 100%;
  181. padding: 10px;
  182. border: 1px solid #d1d5db;
  183. border-radius: 5px;
  184. font-size: 14px;
  185. }
  186. .form-group input:focus {
  187. outline: none;
  188. border-color: #667eea;
  189. box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
  190. }
  191. .form-actions {
  192. display: flex;
  193. gap: 10px;
  194. justify-content: flex-end;
  195. margin-top: 20px;
  196. }
  197. .pagination {
  198. display: flex;
  199. justify-content: center;
  200. align-items: center;
  201. gap: 10px;
  202. margin-top: 20px;
  203. }
  204. .pagination button {
  205. padding: 8px 16px;
  206. border: 1px solid #d1d5db;
  207. background: white;
  208. border-radius: 5px;
  209. cursor: pointer;
  210. }
  211. .pagination button:hover:not(:disabled) {
  212. background: #f9fafb;
  213. }
  214. .pagination button:disabled {
  215. opacity: 0.5;
  216. cursor: not-allowed;
  217. }
  218. .pagination .page-info {
  219. color: #6b7280;
  220. }
  221. .loading {
  222. text-align: center;
  223. padding: 40px;
  224. color: #6b7280;
  225. }
  226. .empty-state {
  227. text-align: center;
  228. padding: 60px 20px;
  229. color: #6b7280;
  230. }
  231. .empty-state svg {
  232. width: 100px;
  233. height: 100px;
  234. margin-bottom: 20px;
  235. opacity: 0.5;
  236. }
  237. .device-list {
  238. font-size: 12px;
  239. color: #6b7280;
  240. max-width: 200px;
  241. overflow: hidden;
  242. text-overflow: ellipsis;
  243. white-space: nowrap;
  244. }
  245. .actions {
  246. display: flex;
  247. gap: 8px;
  248. }
  249. .btn-sm {
  250. padding: 6px 12px;
  251. font-size: 12px;
  252. }
  253. .license-key-cell {
  254. display: flex;
  255. align-items: center;
  256. gap: 8px;
  257. max-width: 200px;
  258. }
  259. .license-key-text {
  260. flex: 1;
  261. overflow: hidden;
  262. text-overflow: ellipsis;
  263. white-space: nowrap;
  264. font-weight: 600;
  265. }
  266. .copy-btn {
  267. padding: 6px 12px;
  268. font-size: 12px;
  269. background: #667eea;
  270. color: white;
  271. border: none;
  272. border-radius: 5px;
  273. cursor: pointer;
  274. transition: all 0.2s;
  275. flex-shrink: 0;
  276. font-weight: 500;
  277. display: flex;
  278. align-items: center;
  279. gap: 4px;
  280. }
  281. .copy-btn:hover {
  282. background: #5568d3;
  283. transform: translateY(-1px);
  284. box-shadow: 0 2px 4px rgba(102, 126, 234, 0.3);
  285. }
  286. .copy-btn:active {
  287. transform: translateY(0);
  288. }
  289. /* Toast 通知样式 */
  290. .toast-container {
  291. position: fixed;
  292. top: 20px;
  293. right: 20px;
  294. z-index: 10000;
  295. display: flex;
  296. flex-direction: column;
  297. gap: 10px;
  298. }
  299. .toast {
  300. background: white;
  301. padding: 16px 20px;
  302. border-radius: 8px;
  303. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  304. display: flex;
  305. align-items: center;
  306. gap: 12px;
  307. min-width: 300px;
  308. max-width: 400px;
  309. animation: slideInRight 0.3s ease-out;
  310. position: relative;
  311. }
  312. @keyframes slideInRight {
  313. from {
  314. transform: translateX(100%);
  315. opacity: 0;
  316. }
  317. to {
  318. transform: translateX(0);
  319. opacity: 1;
  320. }
  321. }
  322. .toast.success {
  323. border-left: 4px solid #10b981;
  324. }
  325. .toast.error {
  326. border-left: 4px solid #ef4444;
  327. }
  328. .toast.warning {
  329. border-left: 4px solid #f59e0b;
  330. }
  331. .toast.info {
  332. border-left: 4px solid #3b82f6;
  333. }
  334. .toast-icon {
  335. font-size: 20px;
  336. flex-shrink: 0;
  337. }
  338. .toast.success .toast-icon {
  339. color: #10b981;
  340. }
  341. .toast.error .toast-icon {
  342. color: #ef4444;
  343. }
  344. .toast.warning .toast-icon {
  345. color: #f59e0b;
  346. }
  347. .toast.info .toast-icon {
  348. color: #3b82f6;
  349. }
  350. .toast-message {
  351. flex: 1;
  352. color: #374151;
  353. font-size: 14px;
  354. line-height: 1.5;
  355. }
  356. .toast-close {
  357. background: none;
  358. border: none;
  359. font-size: 18px;
  360. color: #9ca3af;
  361. cursor: pointer;
  362. padding: 0;
  363. width: 20px;
  364. height: 20px;
  365. display: flex;
  366. align-items: center;
  367. justify-content: center;
  368. flex-shrink: 0;
  369. }
  370. .toast-close:hover {
  371. color: #374151;
  372. }
  373. /* 确认对话框样式 */
  374. .confirm-dialog {
  375. display: none;
  376. position: fixed;
  377. z-index: 10001;
  378. left: 0;
  379. top: 0;
  380. width: 100%;
  381. height: 100%;
  382. background: rgba(0, 0, 0, 0.5);
  383. align-items: center;
  384. justify-content: center;
  385. animation: fadeIn 0.3s;
  386. }
  387. .confirm-dialog.show {
  388. display: flex;
  389. }
  390. .confirm-content {
  391. background: white;
  392. border-radius: 10px;
  393. padding: 30px;
  394. width: 90%;
  395. max-width: 400px;
  396. box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
  397. animation: slideDown 0.3s;
  398. }
  399. .confirm-icon {
  400. font-size: 48px;
  401. text-align: center;
  402. margin-bottom: 20px;
  403. }
  404. .confirm-title {
  405. font-size: 20px;
  406. font-weight: 600;
  407. color: #374151;
  408. text-align: center;
  409. margin-bottom: 15px;
  410. }
  411. .confirm-message {
  412. color: #6b7280;
  413. text-align: center;
  414. margin-bottom: 25px;
  415. line-height: 1.6;
  416. }
  417. .confirm-actions {
  418. display: flex;
  419. gap: 10px;
  420. justify-content: center;
  421. }
  422. .confirm-actions .btn {
  423. min-width: 100px;
  424. }
  425. </style>
  426. </head>
  427. <body>
  428. <!-- Toast 通知容器 -->
  429. <div class="toast-container" id="toast-container"></div>
  430. <!-- 确认对话框 -->
  431. <div id="confirmDialog" class="confirm-dialog">
  432. <div class="confirm-content">
  433. <div class="confirm-icon">⚠️</div>
  434. <div class="confirm-title" id="confirm-title">确认操作</div>
  435. <div class="confirm-message" id="confirm-message"></div>
  436. <div class="confirm-actions">
  437. <button class="btn btn-secondary" onclick="closeConfirmDialog(false)">取消</button>
  438. <button class="btn btn-danger" onclick="closeConfirmDialog(true)" id="confirm-ok-btn">确定</button>
  439. </div>
  440. </div>
  441. </div>
  442. <div class="container">
  443. <div class="header">
  444. <h1>🔑 License 管理平台</h1>
  445. <div style="display: flex; gap: 10px;">
  446. <button class="btn btn-primary" onclick="openBatchModal()">📦 批量生成</button>
  447. <button class="btn btn-primary" onclick="openCreateModal()">+ 创建 License</button>
  448. </div>
  449. </div>
  450. <div class="card">
  451. <div id="loading" class="loading" style="display: none;">加载中...</div>
  452. <div id="empty-state" class="empty-state" style="display: none;">
  453. <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
  454. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
  455. </svg>
  456. <p>暂无 License 数据</p>
  457. </div>
  458. <div class="table-container" id="table-container" style="display: none;">
  459. <div style="margin-bottom: 15px; display: flex; justify-content: space-between; align-items: center;">
  460. <div style="display: flex; align-items: center; gap: 10px;">
  461. <input type="checkbox" id="select-all" onchange="toggleSelectAll()" style="width: 18px; height: 18px; cursor: pointer;">
  462. <label for="select-all" style="cursor: pointer; user-select: none;">全选</label>
  463. <span id="selected-count" style="color: #6b7280; font-size: 14px;">已选择 0 项</span>
  464. </div>
  465. <button class="btn btn-danger" id="batch-delete-btn" onclick="batchDeleteLicenses()" style="display: none;">
  466. 批量删除
  467. </button>
  468. </div>
  469. <table>
  470. <thead>
  471. <tr>
  472. <th style="width: 50px;">
  473. <input type="checkbox" id="select-all-header" onchange="toggleSelectAll()" style="width: 18px; height: 18px; cursor: pointer;">
  474. </th>
  475. <th>ID</th>
  476. <th>激活码</th>
  477. <th>最大设备数</th>
  478. <th>已绑定设备</th>
  479. <th>设备激活时间</th>
  480. <th>绑定设备数</th>
  481. <th>创建时间</th>
  482. <th>操作</th>
  483. </tr>
  484. </thead>
  485. <tbody id="license-table-body">
  486. </tbody>
  487. </table>
  488. <div class="pagination" id="pagination"></div>
  489. </div>
  490. </div>
  491. </div>
  492. <!-- 创建/编辑 Modal -->
  493. <div id="licenseModal" class="modal">
  494. <div class="modal-content">
  495. <div class="modal-header">
  496. <h2 id="modal-title">创建 License</h2>
  497. <button class="close" onclick="closeModal()">&times;</button>
  498. </div>
  499. <form id="licenseForm" onsubmit="handleSubmit(event)">
  500. <input type="hidden" id="license-id">
  501. <div class="form-group">
  502. <label for="license-key">激活码 *</label>
  503. <input type="text" id="license-key" required placeholder="例如: VIP-8888">
  504. </div>
  505. <div class="form-group">
  506. <label for="license-max-devices">最大设备数 *</label>
  507. <input type="number" id="license-max-devices" required min="1" value="2">
  508. </div>
  509. <div class="form-group">
  510. <label for="license-bound-devices">已绑定设备 (JSON 数组,可选)</label>
  511. <input type="text" id="license-bound-devices" placeholder='例如: ["device-1", "device-2"]'>
  512. </div>
  513. <div class="form-actions">
  514. <button type="button" class="btn btn-secondary" onclick="closeModal()">取消</button>
  515. <button type="submit" class="btn btn-primary">保存</button>
  516. </div>
  517. </form>
  518. </div>
  519. </div>
  520. <!-- 批量生成 Modal -->
  521. <div id="batchModal" class="modal">
  522. <div class="modal-content">
  523. <div class="modal-header">
  524. <h2>批量生成 License</h2>
  525. <button class="close" onclick="closeBatchModal()">&times;</button>
  526. </div>
  527. <form id="batchForm" onsubmit="handleBatchSubmit(event)">
  528. <div class="form-group">
  529. <label for="batch-prefix">激活码前缀 *</label>
  530. <input type="text" id="batch-prefix" required placeholder="例如: VIP" value="VIP">
  531. <small style="color: #6b7280; font-size: 12px; margin-top: 4px; display: block;">
  532. 生成的激活码格式:前缀-随机32位字符串(如:VIP-A3B9C2D4E5F6G7H8I9J0K1L2M3N4O5P6)
  533. </small>
  534. </div>
  535. <div class="form-group">
  536. <label for="batch-count">生成数量 *</label>
  537. <input type="number" id="batch-count" required min="1" max="1000" value="10">
  538. <small style="color: #6b7280; font-size: 12px; margin-top: 4px; display: block;">
  539. 一次最多可生成 1000 个
  540. </small>
  541. </div>
  542. <div class="form-group">
  543. <label for="batch-max-devices">最大设备数 *</label>
  544. <input type="number" id="batch-max-devices" required min="1" value="2">
  545. </div>
  546. <div class="form-actions">
  547. <button type="button" class="btn btn-secondary" onclick="closeBatchModal()">取消</button>
  548. <button type="submit" class="btn btn-primary">生成</button>
  549. </div>
  550. </form>
  551. </div>
  552. </div>
  553. <script>
  554. // 使用相对路径,自动适配当前域名
  555. const API_BASE = '/api';
  556. let currentPage = 1;
  557. let pageSize = 10;
  558. let total = 0;
  559. let editingId = null;
  560. // Toast 通知函数
  561. function showToast(message, type = 'info', duration = 3000) {
  562. const container = document.getElementById('toast-container');
  563. const toast = document.createElement('div');
  564. toast.className = `toast ${type}`;
  565. const icons = {
  566. success: '✅',
  567. error: '❌',
  568. warning: '⚠️',
  569. info: 'ℹ️'
  570. };
  571. toast.innerHTML = `
  572. <span class="toast-icon">${icons[type] || icons.info}</span>
  573. <span class="toast-message">${message}</span>
  574. <button class="toast-close" onclick="this.parentElement.remove()">&times;</button>
  575. `;
  576. container.appendChild(toast);
  577. // 自动移除
  578. setTimeout(() => {
  579. if (toast.parentElement) {
  580. toast.style.animation = 'slideInRight 0.3s ease-out reverse';
  581. setTimeout(() => {
  582. if (toast.parentElement) {
  583. toast.remove();
  584. }
  585. }, 300);
  586. }
  587. }, duration);
  588. }
  589. // 确认对话框函数
  590. let confirmCallback = null;
  591. function showConfirmDialog(message, title = '确认操作', okText = '确定', okType = 'danger') {
  592. return new Promise((resolve) => {
  593. document.getElementById('confirm-title').textContent = title;
  594. document.getElementById('confirm-message').textContent = message;
  595. const okBtn = document.getElementById('confirm-ok-btn');
  596. okBtn.textContent = okText;
  597. okBtn.className = `btn btn-${okType}`;
  598. confirmCallback = resolve;
  599. document.getElementById('confirmDialog').classList.add('show');
  600. });
  601. }
  602. function closeConfirmDialog(confirmed) {
  603. document.getElementById('confirmDialog').classList.remove('show');
  604. if (confirmCallback) {
  605. confirmCallback(confirmed);
  606. confirmCallback = null;
  607. }
  608. }
  609. // 复制激活码到剪贴板
  610. async function copyLicenseKey(key) {
  611. try {
  612. await navigator.clipboard.writeText(key);
  613. showToast('激活码已复制到剪贴板', 'success', 2000);
  614. } catch (err) {
  615. // 降级方案:使用传统方法
  616. const textArea = document.createElement('textarea');
  617. textArea.value = key;
  618. textArea.style.position = 'fixed';
  619. textArea.style.left = '-999999px';
  620. document.body.appendChild(textArea);
  621. textArea.select();
  622. try {
  623. document.execCommand('copy');
  624. showToast('激活码已复制到剪贴板', 'success', 2000);
  625. } catch (err) {
  626. showToast('复制失败,请手动复制', 'error');
  627. }
  628. document.body.removeChild(textArea);
  629. }
  630. }
  631. // 全选/取消全选
  632. function toggleSelectAll() {
  633. const selectAll = document.getElementById('select-all');
  634. const selectAllHeader = document.getElementById('select-all-header');
  635. const checkboxes = document.querySelectorAll('.license-checkbox');
  636. const isChecked = selectAll.checked || selectAllHeader.checked;
  637. checkboxes.forEach(checkbox => {
  638. checkbox.checked = isChecked;
  639. });
  640. // 同步两个全选复选框
  641. selectAll.checked = isChecked;
  642. selectAllHeader.checked = isChecked;
  643. updateSelectedCount();
  644. }
  645. // 更新选中数量
  646. function updateSelectedCount() {
  647. const checkboxes = document.querySelectorAll('.license-checkbox:checked');
  648. const count = checkboxes.length;
  649. const selectedCountEl = document.getElementById('selected-count');
  650. const batchDeleteBtn = document.getElementById('batch-delete-btn');
  651. selectedCountEl.textContent = `已选择 ${count} 项`;
  652. if (count > 0) {
  653. batchDeleteBtn.style.display = 'block';
  654. } else {
  655. batchDeleteBtn.style.display = 'none';
  656. }
  657. // 更新全选复选框状态
  658. const allCheckboxes = document.querySelectorAll('.license-checkbox');
  659. const allChecked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length;
  660. document.getElementById('select-all').checked = allChecked;
  661. document.getElementById('select-all-header').checked = allChecked;
  662. }
  663. // 批量删除 License
  664. async function batchDeleteLicenses() {
  665. const checkboxes = document.querySelectorAll('.license-checkbox:checked');
  666. const selectedIds = Array.from(checkboxes).map(cb => parseInt(cb.value));
  667. if (selectedIds.length === 0) {
  668. showToast('请至少选择一个 License', 'warning');
  669. return;
  670. }
  671. const confirmed = await showConfirmDialog(
  672. `确定要删除选中的 ${selectedIds.length} 个 License 吗?此操作不可恢复!`,
  673. '确认批量删除',
  674. '删除',
  675. 'danger'
  676. );
  677. if (!confirmed) {
  678. return;
  679. }
  680. try {
  681. const response = await apiRequest(`${API_BASE}/licenses/batch`, {
  682. method: 'DELETE',
  683. body: JSON.stringify({
  684. ids: selectedIds
  685. })
  686. });
  687. if (!response) return;
  688. const result = await response.json();
  689. if (result.code === 0) {
  690. showToast(result.msg, 'success');
  691. loadLicenses(currentPage);
  692. } else {
  693. showToast('批量删除失败: ' + result.msg, 'error');
  694. }
  695. } catch (error) {
  696. showToast('请求失败: ' + error.message, 'error');
  697. }
  698. }
  699. // 获取认证token
  700. function getAuthToken() {
  701. return localStorage.getItem('auth_token');
  702. }
  703. // 检查是否已登录
  704. function checkAuth() {
  705. const token = getAuthToken();
  706. if (!token) {
  707. window.location.href = '/web/login.html';
  708. return false;
  709. }
  710. return true;
  711. }
  712. // 获取API请求头(包含token)
  713. function getAuthHeaders() {
  714. const token = getAuthToken();
  715. return {
  716. 'Content-Type': 'application/json',
  717. 'Authorization': `Bearer ${token}`
  718. };
  719. }
  720. // 处理API错误响应
  721. async function handleApiError(response) {
  722. if (response.status === 401) {
  723. // 未授权,清除token并跳转到登录页
  724. localStorage.removeItem('auth_token');
  725. showToast('登录已过期,请重新登录', 'error');
  726. setTimeout(() => {
  727. window.location.href = '/web/login.html';
  728. }, 1000);
  729. return true;
  730. }
  731. return false;
  732. }
  733. // 统一的API请求函数
  734. async function apiRequest(url, options = {}) {
  735. const headers = getAuthHeaders();
  736. if (options.headers) {
  737. Object.assign(headers, options.headers);
  738. }
  739. const response = await fetch(url, {
  740. ...options,
  741. headers: headers
  742. });
  743. if (await handleApiError(response)) {
  744. return null;
  745. }
  746. return response;
  747. }
  748. // 页面加载时检查登录状态
  749. window.onload = () => {
  750. if (checkAuth()) {
  751. loadLicenses();
  752. }
  753. };
  754. // 加载 License 列表
  755. async function loadLicenses(page = 1) {
  756. currentPage = page;
  757. const loadingEl = document.getElementById('loading');
  758. const tableContainer = document.getElementById('table-container');
  759. const emptyState = document.getElementById('empty-state');
  760. loadingEl.style.display = 'block';
  761. tableContainer.style.display = 'none';
  762. emptyState.style.display = 'none';
  763. try {
  764. const response = await apiRequest(`${API_BASE}/licenses?page=${page}&page_size=${pageSize}`);
  765. if (!response) return;
  766. const result = await response.json();
  767. if (result.code === 0) {
  768. total = result.total;
  769. const licenses = result.data;
  770. if (licenses.length === 0) {
  771. loadingEl.style.display = 'none';
  772. emptyState.style.display = 'block';
  773. return;
  774. }
  775. renderTable(licenses);
  776. renderPagination();
  777. loadingEl.style.display = 'none';
  778. tableContainer.style.display = 'block';
  779. } else {
  780. showToast('加载失败: ' + result.msg, 'error');
  781. loadingEl.style.display = 'none';
  782. }
  783. } catch (error) {
  784. showToast('请求失败: ' + error.message, 'error');
  785. loadingEl.style.display = 'none';
  786. }
  787. }
  788. // 渲染表格
  789. function renderTable(licenses) {
  790. const tbody = document.getElementById('license-table-body');
  791. tbody.innerHTML = licenses.map(license => {
  792. let boundDevices = [];
  793. try {
  794. boundDevices = JSON.parse(license.bound_devices || '[]');
  795. } catch (e) {
  796. boundDevices = [];
  797. }
  798. // 解析设备激活时间
  799. let deviceActivations = {};
  800. try {
  801. const activationsStr = JSON.parse(license.device_activations || '{}');
  802. deviceActivations = activationsStr;
  803. } catch (e) {
  804. deviceActivations = {};
  805. }
  806. // 构建设备激活时间显示
  807. let activationTimesHtml = '无';
  808. if (boundDevices.length > 0) {
  809. activationTimesHtml = boundDevices.map(deviceId => {
  810. const activationTime = deviceActivations[deviceId];
  811. if (activationTime) {
  812. const date = new Date(activationTime);
  813. return `${deviceId}<br><small style="color: #6b7280;">${date.toLocaleString('zh-CN')}</small>`;
  814. }
  815. return `${deviceId}<br><small style="color: #9ca3af;">未记录</small>`;
  816. }).join('<br><br>');
  817. }
  818. const boundCount = boundDevices.length;
  819. const isFull = boundCount >= license.max_devices;
  820. const createdDate = new Date(license.created_at).toLocaleString('zh-CN');
  821. // 限制激活码显示长度为10个字符
  822. const displayKey = license.key.length > 10 ? license.key.substring(0, 10) + '...' : license.key;
  823. return `
  824. <tr>
  825. <td style="text-align: center;">
  826. <input type="checkbox" class="license-checkbox" value="${license.id}" onchange="updateSelectedCount()" style="width: 18px; height: 18px; cursor: pointer;">
  827. </td>
  828. <td>${license.id}</td>
  829. <td>
  830. <div class="license-key-cell" title="${license.key}">
  831. <span class="license-key-text">${displayKey}</span>
  832. <button class="copy-btn" onclick="copyLicenseKey('${license.key}')" title="复制激活码">
  833. <span>复制</span>
  834. </button>
  835. </div>
  836. </td>
  837. <td>${license.max_devices}</td>
  838. <td class="device-list" title="${boundDevices.join(', ') || '无'}">
  839. ${boundDevices.length > 0 ? boundDevices.join(', ') : '无'}
  840. </td>
  841. <td style="font-size: 12px; line-height: 1.6; max-width: 300px;">
  842. ${activationTimesHtml}
  843. </td>
  844. <td>
  845. <span class="badge ${isFull ? 'badge-warning' : 'badge-success'}">
  846. ${boundCount} / ${license.max_devices}
  847. </span>
  848. </td>
  849. <td>${createdDate}</td>
  850. <td>
  851. <div class="actions">
  852. <button class="btn btn-primary btn-sm" onclick="editLicense(${license.id})">编辑</button>
  853. <button class="btn btn-danger btn-sm" onclick="deleteLicense(${license.id}, '${license.key}')">删除</button>
  854. </div>
  855. </td>
  856. </tr>
  857. `;
  858. }).join('');
  859. }
  860. // 渲染分页
  861. function renderPagination() {
  862. const pagination = document.getElementById('pagination');
  863. const totalPages = Math.ceil(total / pageSize);
  864. if (totalPages <= 1) {
  865. pagination.innerHTML = '';
  866. return;
  867. }
  868. pagination.innerHTML = `
  869. <button onclick="loadLicenses(${currentPage - 1})" ${currentPage === 1 ? 'disabled' : ''}>
  870. 上一页
  871. </button>
  872. <span class="page-info">第 ${currentPage} / ${totalPages} 页 (共 ${total} 条)</span>
  873. <button onclick="loadLicenses(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}>
  874. 下一页
  875. </button>
  876. `;
  877. }
  878. // 打开创建 Modal
  879. function openCreateModal() {
  880. editingId = null;
  881. document.getElementById('modal-title').textContent = '创建 License';
  882. document.getElementById('license-id').value = '';
  883. document.getElementById('license-key').value = '';
  884. document.getElementById('license-max-devices').value = '2';
  885. document.getElementById('license-bound-devices').value = '';
  886. document.getElementById('license-key').disabled = false;
  887. document.getElementById('licenseModal').classList.add('show');
  888. }
  889. // 编辑 License
  890. async function editLicense(id) {
  891. try {
  892. const response = await apiRequest(`${API_BASE}/licenses/${id}`);
  893. if (!response) return;
  894. const result = await response.json();
  895. if (result.code === 0) {
  896. const license = result.data;
  897. editingId = id;
  898. document.getElementById('modal-title').textContent = '编辑 License';
  899. document.getElementById('license-id').value = id;
  900. document.getElementById('license-key').value = license.key;
  901. document.getElementById('license-max-devices').value = license.max_devices;
  902. document.getElementById('license-bound-devices').value = license.bound_devices || '[]';
  903. document.getElementById('license-key').disabled = false;
  904. document.getElementById('licenseModal').classList.add('show');
  905. } else {
  906. showToast('加载失败: ' + result.msg, 'error');
  907. }
  908. } catch (error) {
  909. showToast('请求失败: ' + error.message, 'error');
  910. }
  911. }
  912. // 关闭 Modal
  913. function closeModal() {
  914. document.getElementById('licenseModal').classList.remove('show');
  915. }
  916. // 提交表单
  917. async function handleSubmit(event) {
  918. event.preventDefault();
  919. const id = document.getElementById('license-id').value;
  920. const key = document.getElementById('license-key').value;
  921. const maxDevices = parseInt(document.getElementById('license-max-devices').value);
  922. const boundDevices = document.getElementById('license-bound-devices').value || '[]';
  923. // 验证 boundDevices 是否为有效 JSON
  924. try {
  925. JSON.parse(boundDevices);
  926. } catch (e) {
  927. showToast('已绑定设备必须是有效的 JSON 数组格式', 'error');
  928. return;
  929. }
  930. try {
  931. let response;
  932. if (editingId) {
  933. // 更新
  934. const updateData = {
  935. max_devices: maxDevices
  936. };
  937. if (boundDevices) {
  938. updateData.bound_devices = boundDevices;
  939. }
  940. response = await apiRequest(`${API_BASE}/licenses/${id}`, {
  941. method: 'PUT',
  942. body: JSON.stringify(updateData)
  943. });
  944. } else {
  945. // 创建
  946. response = await apiRequest(`${API_BASE}/licenses`, {
  947. method: 'POST',
  948. body: JSON.stringify({
  949. key: key,
  950. max_devices: maxDevices,
  951. bound_devices: boundDevices
  952. })
  953. });
  954. }
  955. if (!response) return;
  956. const result = await response.json();
  957. if (result.code === 0) {
  958. showToast(editingId ? '更新成功' : '创建成功', 'success');
  959. closeModal();
  960. loadLicenses(currentPage);
  961. } else {
  962. showToast('操作失败: ' + result.msg, 'error');
  963. }
  964. } catch (error) {
  965. showToast('请求失败: ' + error.message, 'error');
  966. }
  967. }
  968. // 删除 License
  969. async function deleteLicense(id, key) {
  970. const confirmed = await showConfirmDialog(
  971. `确定要删除 License "${key}" 吗?此操作不可恢复!`,
  972. '确认删除',
  973. '删除',
  974. 'danger'
  975. );
  976. if (!confirmed) {
  977. return;
  978. }
  979. try {
  980. const response = await apiRequest(`${API_BASE}/licenses/${id}`, {
  981. method: 'DELETE'
  982. });
  983. if (!response) return;
  984. const result = await response.json();
  985. if (result.code === 0) {
  986. showToast('删除成功', 'success');
  987. loadLicenses(currentPage);
  988. } else {
  989. showToast('删除失败: ' + result.msg, 'error');
  990. }
  991. } catch (error) {
  992. showToast('请求失败: ' + error.message, 'error');
  993. }
  994. }
  995. // 打开批量生成 Modal
  996. function openBatchModal() {
  997. document.getElementById('batch-prefix').value = 'VIP';
  998. document.getElementById('batch-count').value = '10';
  999. document.getElementById('batch-max-devices').value = '2';
  1000. document.getElementById('batchModal').classList.add('show');
  1001. }
  1002. // 关闭批量生成 Modal
  1003. function closeBatchModal() {
  1004. document.getElementById('batchModal').classList.remove('show');
  1005. }
  1006. // 批量生成提交
  1007. async function handleBatchSubmit(event) {
  1008. event.preventDefault();
  1009. const prefix = document.getElementById('batch-prefix').value.trim();
  1010. const count = parseInt(document.getElementById('batch-count').value);
  1011. const maxDevices = parseInt(document.getElementById('batch-max-devices').value);
  1012. if (!prefix) {
  1013. showToast('请输入激活码前缀', 'warning');
  1014. return;
  1015. }
  1016. if (count <= 0 || count > 1000) {
  1017. showToast('生成数量必须在 1-1000 之间', 'warning');
  1018. return;
  1019. }
  1020. const confirmed = await showConfirmDialog(
  1021. `确定要批量生成 ${count} 个激活码吗?`,
  1022. '确认批量生成',
  1023. '生成',
  1024. 'primary'
  1025. );
  1026. if (!confirmed) {
  1027. return;
  1028. }
  1029. try {
  1030. const response = await apiRequest(`${API_BASE}/licenses/batch`, {
  1031. method: 'POST',
  1032. body: JSON.stringify({
  1033. prefix: prefix,
  1034. count: count,
  1035. max_devices: maxDevices
  1036. })
  1037. });
  1038. if (!response) return;
  1039. const result = await response.json();
  1040. if (result.code === 0) {
  1041. showToast(result.msg, 'success', 4000);
  1042. closeBatchModal();
  1043. loadLicenses(1); // 重新加载第一页
  1044. } else {
  1045. showToast('批量生成失败: ' + result.msg, 'error');
  1046. }
  1047. } catch (error) {
  1048. showToast('请求失败: ' + error.message, 'error');
  1049. }
  1050. }
  1051. // 点击 Modal 外部关闭
  1052. window.onclick = function(event) {
  1053. const licenseModal = document.getElementById('licenseModal');
  1054. const batchModal = document.getElementById('batchModal');
  1055. const confirmDialog = document.getElementById('confirmDialog');
  1056. if (event.target === licenseModal) {
  1057. closeModal();
  1058. }
  1059. if (event.target === batchModal) {
  1060. closeBatchModal();
  1061. }
  1062. if (event.target === confirmDialog) {
  1063. closeConfirmDialog(false);
  1064. }
  1065. }
  1066. </script>
  1067. </body>
  1068. </html>