index.html 51 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453
  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. .device-detail-cell {
  246. cursor: pointer;
  247. color: #667eea;
  248. font-size: 14px;
  249. padding: 8px 12px;
  250. border-radius: 5px;
  251. transition: all 0.2s;
  252. max-width: 300px;
  253. }
  254. .device-detail-cell:hover {
  255. background: #f3f4f6;
  256. color: #5568d3;
  257. }
  258. .device-detail-cell .device-count {
  259. font-weight: 600;
  260. color: #667eea;
  261. }
  262. .device-detail-cell .device-preview {
  263. font-size: 12px;
  264. color: #6b7280;
  265. margin-top: 4px;
  266. }
  267. /* 设备列表弹框样式 */
  268. .device-list-modal .modal-content {
  269. max-width: 700px;
  270. }
  271. .device-list-table {
  272. width: 100%;
  273. border-collapse: collapse;
  274. margin-top: 20px;
  275. }
  276. .device-list-table th,
  277. .device-list-table td {
  278. padding: 12px;
  279. text-align: left;
  280. border-bottom: 1px solid #e5e7eb;
  281. }
  282. .device-list-table th {
  283. background: #f9fafb;
  284. font-weight: 600;
  285. color: #374151;
  286. }
  287. .device-list-table tr:hover {
  288. background: #f9fafb;
  289. }
  290. .device-list-empty {
  291. text-align: center;
  292. padding: 40px;
  293. color: #6b7280;
  294. }
  295. .actions {
  296. display: flex;
  297. gap: 8px;
  298. }
  299. .btn-sm {
  300. padding: 6px 12px;
  301. font-size: 12px;
  302. }
  303. .license-key-cell {
  304. display: flex;
  305. align-items: center;
  306. gap: 8px;
  307. max-width: 200px;
  308. }
  309. .license-key-text {
  310. flex: 1;
  311. overflow: hidden;
  312. text-overflow: ellipsis;
  313. white-space: nowrap;
  314. font-weight: 600;
  315. }
  316. .copy-btn {
  317. padding: 6px 12px;
  318. font-size: 12px;
  319. background: #667eea;
  320. color: white;
  321. border: none;
  322. border-radius: 5px;
  323. cursor: pointer;
  324. transition: all 0.2s;
  325. flex-shrink: 0;
  326. font-weight: 500;
  327. display: flex;
  328. align-items: center;
  329. gap: 4px;
  330. }
  331. .copy-btn:hover {
  332. background: #5568d3;
  333. transform: translateY(-1px);
  334. box-shadow: 0 2px 4px rgba(102, 126, 234, 0.3);
  335. }
  336. .copy-btn:active {
  337. transform: translateY(0);
  338. }
  339. /* Toast 通知样式 */
  340. .toast-container {
  341. position: fixed;
  342. top: 20px;
  343. right: 20px;
  344. z-index: 10000;
  345. display: flex;
  346. flex-direction: column;
  347. gap: 10px;
  348. }
  349. .toast {
  350. background: white;
  351. padding: 16px 20px;
  352. border-radius: 8px;
  353. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  354. display: flex;
  355. align-items: center;
  356. gap: 12px;
  357. min-width: 300px;
  358. max-width: 400px;
  359. animation: slideInRight 0.3s ease-out;
  360. position: relative;
  361. }
  362. @keyframes slideInRight {
  363. from {
  364. transform: translateX(100%);
  365. opacity: 0;
  366. }
  367. to {
  368. transform: translateX(0);
  369. opacity: 1;
  370. }
  371. }
  372. .toast.success {
  373. border-left: 4px solid #10b981;
  374. }
  375. .toast.error {
  376. border-left: 4px solid #ef4444;
  377. }
  378. .toast.warning {
  379. border-left: 4px solid #f59e0b;
  380. }
  381. .toast.info {
  382. border-left: 4px solid #3b82f6;
  383. }
  384. .toast-icon {
  385. font-size: 20px;
  386. flex-shrink: 0;
  387. }
  388. .toast.success .toast-icon {
  389. color: #10b981;
  390. }
  391. .toast.error .toast-icon {
  392. color: #ef4444;
  393. }
  394. .toast.warning .toast-icon {
  395. color: #f59e0b;
  396. }
  397. .toast.info .toast-icon {
  398. color: #3b82f6;
  399. }
  400. .toast-message {
  401. flex: 1;
  402. color: #374151;
  403. font-size: 14px;
  404. line-height: 1.5;
  405. }
  406. .toast-close {
  407. background: none;
  408. border: none;
  409. font-size: 18px;
  410. color: #9ca3af;
  411. cursor: pointer;
  412. padding: 0;
  413. width: 20px;
  414. height: 20px;
  415. display: flex;
  416. align-items: center;
  417. justify-content: center;
  418. flex-shrink: 0;
  419. }
  420. .toast-close:hover {
  421. color: #374151;
  422. }
  423. /* 确认对话框样式 */
  424. .confirm-dialog {
  425. display: none;
  426. position: fixed;
  427. z-index: 10001;
  428. left: 0;
  429. top: 0;
  430. width: 100%;
  431. height: 100%;
  432. background: rgba(0, 0, 0, 0.5);
  433. align-items: center;
  434. justify-content: center;
  435. animation: fadeIn 0.3s;
  436. }
  437. .confirm-dialog.show {
  438. display: flex;
  439. }
  440. .confirm-content {
  441. background: white;
  442. border-radius: 10px;
  443. padding: 30px;
  444. width: 90%;
  445. max-width: 400px;
  446. box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
  447. animation: slideDown 0.3s;
  448. }
  449. .confirm-icon {
  450. font-size: 48px;
  451. text-align: center;
  452. margin-bottom: 20px;
  453. }
  454. .confirm-title {
  455. font-size: 20px;
  456. font-weight: 600;
  457. color: #374151;
  458. text-align: center;
  459. margin-bottom: 15px;
  460. }
  461. .confirm-message {
  462. color: #6b7280;
  463. text-align: center;
  464. margin-bottom: 25px;
  465. line-height: 1.6;
  466. }
  467. .confirm-actions {
  468. display: flex;
  469. gap: 10px;
  470. justify-content: center;
  471. }
  472. .confirm-actions .btn {
  473. min-width: 100px;
  474. }
  475. </style>
  476. </head>
  477. <body>
  478. <!-- Toast 通知容器 -->
  479. <div class="toast-container" id="toast-container"></div>
  480. <!-- 确认对话框 -->
  481. <div id="confirmDialog" class="confirm-dialog">
  482. <div class="confirm-content">
  483. <div class="confirm-icon">⚠️</div>
  484. <div class="confirm-title" id="confirm-title">确认操作</div>
  485. <div class="confirm-message" id="confirm-message"></div>
  486. <div class="confirm-actions">
  487. <button class="btn btn-secondary" onclick="closeConfirmDialog(false)">取消</button>
  488. <button class="btn btn-danger" onclick="closeConfirmDialog(true)" id="confirm-ok-btn">确定</button>
  489. </div>
  490. </div>
  491. </div>
  492. <div class="container">
  493. <div class="header">
  494. <h1>🔑 License 管理平台</h1>
  495. <div style="display: flex; gap: 10px;">
  496. <button class="btn btn-primary" onclick="openBatchModal()">📦 批量生成</button>
  497. <button class="btn btn-primary" onclick="openCreateModal()">+ 创建 License</button>
  498. </div>
  499. </div>
  500. <div class="card">
  501. <div id="loading" class="loading" style="display: none;">加载中...</div>
  502. <div id="empty-state" class="empty-state" style="display: none;">
  503. <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
  504. <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>
  505. </svg>
  506. <p>暂无 License 数据</p>
  507. </div>
  508. <div class="table-container" id="table-container" style="display: none;">
  509. <div style="margin-bottom: 15px; display: flex; justify-content: space-between; align-items: center;">
  510. <div style="display: flex; align-items: center; gap: 10px;">
  511. <input type="checkbox" id="select-all" onchange="toggleSelectAll()" style="width: 18px; height: 18px; cursor: pointer;">
  512. <label for="select-all" style="cursor: pointer; user-select: none;">全选</label>
  513. <span id="selected-count" style="color: #6b7280; font-size: 14px;">已选择 0 项</span>
  514. </div>
  515. <button class="btn btn-danger" id="batch-delete-btn" onclick="batchDeleteLicenses()" style="display: none;">
  516. 批量删除
  517. </button>
  518. </div>
  519. <table>
  520. <thead>
  521. <tr>
  522. <th style="width: 50px;">
  523. <input type="checkbox" id="select-all-header" onchange="toggleSelectAll()" style="width: 18px; height: 18px; cursor: pointer;">
  524. </th>
  525. <th>ID</th>
  526. <th>激活码</th>
  527. <th>设备详情</th>
  528. <th>绑定设备数</th>
  529. <th>创建时间</th>
  530. <th>操作</th>
  531. </tr>
  532. </thead>
  533. <tbody id="license-table-body">
  534. </tbody>
  535. </table>
  536. <div class="pagination" id="pagination"></div>
  537. </div>
  538. </div>
  539. </div>
  540. <!-- 创建/编辑 Modal -->
  541. <div id="licenseModal" class="modal">
  542. <div class="modal-content">
  543. <div class="modal-header">
  544. <h2 id="modal-title">创建 License</h2>
  545. <button class="close" onclick="closeModal()">&times;</button>
  546. </div>
  547. <form id="licenseForm" onsubmit="handleSubmit(event)">
  548. <input type="hidden" id="license-id">
  549. <div class="form-group">
  550. <label for="license-key">激活码 *</label>
  551. <input type="text" id="license-key" required placeholder="例如: VIP-8888">
  552. </div>
  553. <div class="form-group">
  554. <label for="license-max-devices">最大设备数 *</label>
  555. <input type="number" id="license-max-devices" required min="1" value="2">
  556. </div>
  557. <div class="form-group">
  558. <label for="license-bound-devices">已绑定设备 (JSON 数组,可选)</label>
  559. <input type="text" id="license-bound-devices" placeholder='例如: ["device-1", "device-2"]'>
  560. </div>
  561. <div class="form-actions">
  562. <button type="button" class="btn btn-secondary" onclick="closeModal()">取消</button>
  563. <button type="submit" class="btn btn-primary">保存</button>
  564. </div>
  565. </form>
  566. </div>
  567. </div>
  568. <!-- 批量生成 Modal -->
  569. <div id="batchModal" class="modal">
  570. <div class="modal-content">
  571. <div class="modal-header">
  572. <h2>批量生成 License</h2>
  573. <button class="close" onclick="closeBatchModal()">&times;</button>
  574. </div>
  575. <form id="batchForm" onsubmit="handleBatchSubmit(event)">
  576. <div class="form-group">
  577. <label for="batch-prefix">激活码前缀 *</label>
  578. <input type="text" id="batch-prefix" required placeholder="例如: VIP" value="VIP">
  579. <small style="color: #6b7280; font-size: 12px; margin-top: 4px; display: block;">
  580. 生成的激活码格式:前缀-随机32位字符串(如:VIP-A3B9C2D4E5F6G7H8I9J0K1L2M3N4O5P6)
  581. </small>
  582. </div>
  583. <div class="form-group">
  584. <label for="batch-count">生成数量 *</label>
  585. <input type="number" id="batch-count" required min="1" max="1000" value="10">
  586. <small style="color: #6b7280; font-size: 12px; margin-top: 4px; display: block;">
  587. 一次最多可生成 1000 个
  588. </small>
  589. </div>
  590. <div class="form-group">
  591. <label for="batch-max-devices">最大设备数 *</label>
  592. <input type="number" id="batch-max-devices" required min="1" value="2">
  593. </div>
  594. <div class="form-actions">
  595. <button type="button" class="btn btn-secondary" onclick="closeBatchModal()">取消</button>
  596. <button type="submit" class="btn btn-primary">生成</button>
  597. </div>
  598. </form>
  599. </div>
  600. </div>
  601. <!-- 设备列表 Modal -->
  602. <div id="deviceListModal" class="modal device-list-modal">
  603. <div class="modal-content">
  604. <div class="modal-header">
  605. <h2 id="device-list-title">设备列表</h2>
  606. <button class="close" onclick="closeDeviceListModal()">&times;</button>
  607. </div>
  608. <div id="device-list-content">
  609. <div class="device-list-empty">加载中...</div>
  610. </div>
  611. </div>
  612. </div>
  613. <script>
  614. // 使用相对路径,自动适配当前域名
  615. const API_BASE = '/api';
  616. let currentPage = 1;
  617. let pageSize = 10;
  618. let total = 0;
  619. let editingId = null;
  620. // Toast 通知函数
  621. function showToast(message, type = 'info', duration = 3000) {
  622. const container = document.getElementById('toast-container');
  623. const toast = document.createElement('div');
  624. toast.className = `toast ${type}`;
  625. const icons = {
  626. success: '✅',
  627. error: '❌',
  628. warning: '⚠️',
  629. info: 'ℹ️'
  630. };
  631. toast.innerHTML = `
  632. <span class="toast-icon">${icons[type] || icons.info}</span>
  633. <span class="toast-message">${message}</span>
  634. <button class="toast-close" onclick="this.parentElement.remove()">&times;</button>
  635. `;
  636. container.appendChild(toast);
  637. // 自动移除
  638. setTimeout(() => {
  639. if (toast.parentElement) {
  640. toast.style.animation = 'slideInRight 0.3s ease-out reverse';
  641. setTimeout(() => {
  642. if (toast.parentElement) {
  643. toast.remove();
  644. }
  645. }, 300);
  646. }
  647. }, duration);
  648. }
  649. // 确认对话框函数
  650. let confirmCallback = null;
  651. function showConfirmDialog(message, title = '确认操作', okText = '确定', okType = 'danger') {
  652. return new Promise((resolve) => {
  653. document.getElementById('confirm-title').textContent = title;
  654. document.getElementById('confirm-message').textContent = message;
  655. const okBtn = document.getElementById('confirm-ok-btn');
  656. okBtn.textContent = okText;
  657. okBtn.className = `btn btn-${okType}`;
  658. confirmCallback = resolve;
  659. document.getElementById('confirmDialog').classList.add('show');
  660. });
  661. }
  662. function closeConfirmDialog(confirmed) {
  663. document.getElementById('confirmDialog').classList.remove('show');
  664. if (confirmCallback) {
  665. confirmCallback(confirmed);
  666. confirmCallback = null;
  667. }
  668. }
  669. // 复制激活码到剪贴板
  670. async function copyLicenseKey(key) {
  671. try {
  672. await navigator.clipboard.writeText(key);
  673. showToast('激活码已复制到剪贴板', 'success', 2000);
  674. } catch (err) {
  675. // 降级方案:使用传统方法
  676. const textArea = document.createElement('textarea');
  677. textArea.value = key;
  678. textArea.style.position = 'fixed';
  679. textArea.style.left = '-999999px';
  680. document.body.appendChild(textArea);
  681. textArea.select();
  682. try {
  683. document.execCommand('copy');
  684. showToast('激活码已复制到剪贴板', 'success', 2000);
  685. } catch (err) {
  686. showToast('复制失败,请手动复制', 'error');
  687. }
  688. document.body.removeChild(textArea);
  689. }
  690. }
  691. // 全选/取消全选
  692. function toggleSelectAll() {
  693. const selectAll = document.getElementById('select-all');
  694. const selectAllHeader = document.getElementById('select-all-header');
  695. const checkboxes = document.querySelectorAll('.license-checkbox');
  696. const isChecked = selectAll.checked || selectAllHeader.checked;
  697. checkboxes.forEach(checkbox => {
  698. checkbox.checked = isChecked;
  699. });
  700. // 同步两个全选复选框
  701. selectAll.checked = isChecked;
  702. selectAllHeader.checked = isChecked;
  703. updateSelectedCount();
  704. }
  705. // 更新选中数量
  706. function updateSelectedCount() {
  707. const checkboxes = document.querySelectorAll('.license-checkbox:checked');
  708. const count = checkboxes.length;
  709. const selectedCountEl = document.getElementById('selected-count');
  710. const batchDeleteBtn = document.getElementById('batch-delete-btn');
  711. selectedCountEl.textContent = `已选择 ${count} 项`;
  712. if (count > 0) {
  713. batchDeleteBtn.style.display = 'block';
  714. } else {
  715. batchDeleteBtn.style.display = 'none';
  716. }
  717. // 更新全选复选框状态
  718. const allCheckboxes = document.querySelectorAll('.license-checkbox');
  719. const allChecked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length;
  720. document.getElementById('select-all').checked = allChecked;
  721. document.getElementById('select-all-header').checked = allChecked;
  722. }
  723. // 批量删除 License
  724. async function batchDeleteLicenses() {
  725. const checkboxes = document.querySelectorAll('.license-checkbox:checked');
  726. const selectedIds = Array.from(checkboxes).map(cb => parseInt(cb.value));
  727. if (selectedIds.length === 0) {
  728. showToast('请至少选择一个 License', 'warning');
  729. return;
  730. }
  731. const confirmed = await showConfirmDialog(
  732. `确定要删除选中的 ${selectedIds.length} 个 License 吗?此操作不可恢复!`,
  733. '确认批量删除',
  734. '删除',
  735. 'danger'
  736. );
  737. if (!confirmed) {
  738. return;
  739. }
  740. try {
  741. const response = await apiRequest(`${API_BASE}/licenses/batch`, {
  742. method: 'DELETE',
  743. body: JSON.stringify({
  744. ids: selectedIds
  745. })
  746. });
  747. if (!response) return;
  748. const result = await response.json();
  749. if (result.code === 0) {
  750. showToast(result.msg, 'success');
  751. loadLicenses(currentPage);
  752. } else {
  753. showToast('批量删除失败: ' + result.msg, 'error');
  754. }
  755. } catch (error) {
  756. showToast('请求失败: ' + error.message, 'error');
  757. }
  758. }
  759. // 获取认证token
  760. function getAuthToken() {
  761. return localStorage.getItem('auth_token');
  762. }
  763. // 检查是否已登录
  764. function checkAuth() {
  765. const token = getAuthToken();
  766. if (!token) {
  767. window.location.href = '/web/login.html';
  768. return false;
  769. }
  770. return true;
  771. }
  772. // 获取API请求头(包含token)
  773. function getAuthHeaders() {
  774. const token = getAuthToken();
  775. return {
  776. 'Content-Type': 'application/json',
  777. 'Authorization': `Bearer ${token}`
  778. };
  779. }
  780. // 处理API错误响应
  781. async function handleApiError(response) {
  782. if (response.status === 401) {
  783. // 未授权,清除token并跳转到登录页
  784. localStorage.removeItem('auth_token');
  785. showToast('登录已过期,请重新登录', 'error');
  786. setTimeout(() => {
  787. window.location.href = '/web/login.html';
  788. }, 1000);
  789. return true;
  790. }
  791. return false;
  792. }
  793. // 统一的API请求函数
  794. async function apiRequest(url, options = {}) {
  795. const headers = getAuthHeaders();
  796. if (options.headers) {
  797. Object.assign(headers, options.headers);
  798. }
  799. const response = await fetch(url, {
  800. ...options,
  801. headers: headers
  802. });
  803. if (await handleApiError(response)) {
  804. return null;
  805. }
  806. return response;
  807. }
  808. // 页面加载时检查登录状态
  809. window.onload = () => {
  810. if (checkAuth()) {
  811. loadLicenses();
  812. }
  813. };
  814. // 加载 License 列表
  815. async function loadLicenses(page = 1) {
  816. currentPage = page;
  817. const loadingEl = document.getElementById('loading');
  818. const tableContainer = document.getElementById('table-container');
  819. const emptyState = document.getElementById('empty-state');
  820. loadingEl.style.display = 'block';
  821. tableContainer.style.display = 'none';
  822. emptyState.style.display = 'none';
  823. try {
  824. const response = await apiRequest(`${API_BASE}/licenses?page=${page}&page_size=${pageSize}`);
  825. if (!response) return;
  826. const result = await response.json();
  827. if (result.code === 0) {
  828. total = result.total;
  829. const licenses = result.data;
  830. if (licenses.length === 0) {
  831. loadingEl.style.display = 'none';
  832. emptyState.style.display = 'block';
  833. return;
  834. }
  835. renderTable(licenses);
  836. renderPagination();
  837. loadingEl.style.display = 'none';
  838. tableContainer.style.display = 'block';
  839. } else {
  840. showToast('加载失败: ' + result.msg, 'error');
  841. loadingEl.style.display = 'none';
  842. }
  843. } catch (error) {
  844. showToast('请求失败: ' + error.message, 'error');
  845. loadingEl.style.display = 'none';
  846. }
  847. }
  848. // 渲染表格
  849. function renderTable(licenses) {
  850. const tbody = document.getElementById('license-table-body');
  851. tbody.innerHTML = licenses.map(license => {
  852. let boundDevices = [];
  853. try {
  854. boundDevices = JSON.parse(license.bound_devices || '[]');
  855. } catch (e) {
  856. boundDevices = [];
  857. }
  858. // 解析设备激活时间
  859. let deviceActivations = {};
  860. try {
  861. const activationsStr = JSON.parse(license.device_activations || '{}');
  862. deviceActivations = activationsStr;
  863. } catch (e) {
  864. deviceActivations = {};
  865. }
  866. const boundCount = boundDevices.length;
  867. const isFull = boundCount >= license.max_devices;
  868. const createdDate = new Date(license.created_at).toLocaleString('zh-CN');
  869. // 限制激活码显示长度为10个字符
  870. const displayKey = license.key.length > 10 ? license.key.substring(0, 10) + '...' : license.key;
  871. // 构建设备详情显示
  872. let deviceDetailHtml = '';
  873. if (boundDevices.length === 0) {
  874. deviceDetailHtml = '<span style="color: #9ca3af;">无设备</span>';
  875. } else {
  876. // 显示前2个设备作为预览
  877. const previewDevices = boundDevices.slice(0, 2);
  878. const previewText = previewDevices.map(deviceId => {
  879. const activationTime = deviceActivations[deviceId];
  880. if (activationTime) {
  881. const date = new Date(activationTime);
  882. return `${deviceId} (${date.toLocaleString('zh-CN')})`;
  883. }
  884. return `${deviceId} (未记录)`;
  885. }).join('、');
  886. const moreCount = boundDevices.length - 2;
  887. // 使用 data 属性安全传递 licenseKey
  888. const escapedKey = license.key.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
  889. deviceDetailHtml = `
  890. <div class="device-detail-cell" data-license-id="${license.id}" data-license-key="${escapedKey}" onclick="showDeviceListFromElement(this)">
  891. <div class="device-count">${boundCount} 个设备</div>
  892. <div class="device-preview">${previewText}${moreCount > 0 ? ` 等${moreCount}个...` : ''}</div>
  893. </div>
  894. `;
  895. }
  896. return `
  897. <tr>
  898. <td style="text-align: center;">
  899. <input type="checkbox" class="license-checkbox" value="${license.id}" onchange="updateSelectedCount()" style="width: 18px; height: 18px; cursor: pointer;">
  900. </td>
  901. <td>${license.id}</td>
  902. <td>
  903. <div class="license-key-cell" title="${license.key}">
  904. <span class="license-key-text">${displayKey}</span>
  905. <button class="copy-btn" onclick="copyLicenseKey('${license.key}')" title="复制激活码">
  906. <span>复制</span>
  907. </button>
  908. </div>
  909. </td>
  910. <td>
  911. ${deviceDetailHtml}
  912. </td>
  913. <td>
  914. <span class="badge ${isFull ? 'badge-warning' : 'badge-success'}">
  915. ${boundCount} / ${license.max_devices}
  916. </span>
  917. </td>
  918. <td>${createdDate}</td>
  919. <td>
  920. <div class="actions">
  921. <button class="btn btn-primary btn-sm" onclick="editLicense(${license.id})">编辑</button>
  922. <button class="btn btn-danger btn-sm" onclick="deleteLicense(${license.id}, '${license.key}')">删除</button>
  923. </div>
  924. </td>
  925. </tr>
  926. `;
  927. }).join('');
  928. }
  929. // 渲染分页
  930. function renderPagination() {
  931. const pagination = document.getElementById('pagination');
  932. const totalPages = Math.ceil(total / pageSize);
  933. if (totalPages <= 1) {
  934. pagination.innerHTML = '';
  935. return;
  936. }
  937. pagination.innerHTML = `
  938. <button onclick="loadLicenses(${currentPage - 1})" ${currentPage === 1 ? 'disabled' : ''}>
  939. 上一页
  940. </button>
  941. <span class="page-info">第 ${currentPage} / ${totalPages} 页 (共 ${total} 条)</span>
  942. <button onclick="loadLicenses(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}>
  943. 下一页
  944. </button>
  945. `;
  946. }
  947. // 打开创建 Modal
  948. function openCreateModal() {
  949. editingId = null;
  950. document.getElementById('modal-title').textContent = '创建 License';
  951. document.getElementById('license-id').value = '';
  952. document.getElementById('license-key').value = '';
  953. document.getElementById('license-max-devices').value = '2';
  954. document.getElementById('license-bound-devices').value = '';
  955. document.getElementById('license-key').disabled = false;
  956. document.getElementById('licenseModal').classList.add('show');
  957. }
  958. // 编辑 License
  959. async function editLicense(id) {
  960. try {
  961. const response = await apiRequest(`${API_BASE}/licenses/${id}`);
  962. if (!response) return;
  963. const result = await response.json();
  964. if (result.code === 0) {
  965. const license = result.data;
  966. editingId = id;
  967. document.getElementById('modal-title').textContent = '编辑 License';
  968. document.getElementById('license-id').value = id;
  969. document.getElementById('license-key').value = license.key;
  970. document.getElementById('license-max-devices').value = license.max_devices;
  971. document.getElementById('license-bound-devices').value = license.bound_devices || '[]';
  972. document.getElementById('license-key').disabled = false;
  973. document.getElementById('licenseModal').classList.add('show');
  974. } else {
  975. showToast('加载失败: ' + result.msg, 'error');
  976. }
  977. } catch (error) {
  978. showToast('请求失败: ' + error.message, 'error');
  979. }
  980. }
  981. // 关闭 Modal
  982. function closeModal() {
  983. document.getElementById('licenseModal').classList.remove('show');
  984. }
  985. // 提交表单
  986. async function handleSubmit(event) {
  987. event.preventDefault();
  988. const id = document.getElementById('license-id').value;
  989. const key = document.getElementById('license-key').value;
  990. const maxDevices = parseInt(document.getElementById('license-max-devices').value);
  991. const boundDevices = document.getElementById('license-bound-devices').value || '[]';
  992. // 验证 boundDevices 是否为有效 JSON
  993. try {
  994. JSON.parse(boundDevices);
  995. } catch (e) {
  996. showToast('已绑定设备必须是有效的 JSON 数组格式', 'error');
  997. return;
  998. }
  999. try {
  1000. let response;
  1001. if (editingId) {
  1002. // 更新
  1003. const updateData = {
  1004. max_devices: maxDevices
  1005. };
  1006. if (boundDevices) {
  1007. updateData.bound_devices = boundDevices;
  1008. }
  1009. response = await apiRequest(`${API_BASE}/licenses/${id}`, {
  1010. method: 'PUT',
  1011. body: JSON.stringify(updateData)
  1012. });
  1013. } else {
  1014. // 创建
  1015. response = await apiRequest(`${API_BASE}/licenses`, {
  1016. method: 'POST',
  1017. body: JSON.stringify({
  1018. key: key,
  1019. max_devices: maxDevices,
  1020. bound_devices: boundDevices
  1021. })
  1022. });
  1023. }
  1024. if (!response) return;
  1025. const result = await response.json();
  1026. if (result.code === 0) {
  1027. showToast(editingId ? '更新成功' : '创建成功', 'success');
  1028. closeModal();
  1029. loadLicenses(currentPage);
  1030. } else {
  1031. showToast('操作失败: ' + result.msg, 'error');
  1032. }
  1033. } catch (error) {
  1034. showToast('请求失败: ' + error.message, 'error');
  1035. }
  1036. }
  1037. // 删除 License
  1038. async function deleteLicense(id, key) {
  1039. const confirmed = await showConfirmDialog(
  1040. `确定要删除 License "${key}" 吗?此操作不可恢复!`,
  1041. '确认删除',
  1042. '删除',
  1043. 'danger'
  1044. );
  1045. if (!confirmed) {
  1046. return;
  1047. }
  1048. try {
  1049. const response = await apiRequest(`${API_BASE}/licenses/${id}`, {
  1050. method: 'DELETE'
  1051. });
  1052. if (!response) return;
  1053. const result = await response.json();
  1054. if (result.code === 0) {
  1055. showToast('删除成功', 'success');
  1056. loadLicenses(currentPage);
  1057. } else {
  1058. showToast('删除失败: ' + result.msg, 'error');
  1059. }
  1060. } catch (error) {
  1061. showToast('请求失败: ' + error.message, 'error');
  1062. }
  1063. }
  1064. // 打开批量生成 Modal
  1065. function openBatchModal() {
  1066. document.getElementById('batch-prefix').value = 'VIP';
  1067. document.getElementById('batch-count').value = '10';
  1068. document.getElementById('batch-max-devices').value = '2';
  1069. document.getElementById('batchModal').classList.add('show');
  1070. }
  1071. // 关闭批量生成 Modal
  1072. function closeBatchModal() {
  1073. document.getElementById('batchModal').classList.remove('show');
  1074. }
  1075. // 批量生成提交
  1076. async function handleBatchSubmit(event) {
  1077. event.preventDefault();
  1078. const prefix = document.getElementById('batch-prefix').value.trim();
  1079. const count = parseInt(document.getElementById('batch-count').value);
  1080. const maxDevices = parseInt(document.getElementById('batch-max-devices').value);
  1081. if (!prefix) {
  1082. showToast('请输入激活码前缀', 'warning');
  1083. return;
  1084. }
  1085. if (count <= 0 || count > 1000) {
  1086. showToast('生成数量必须在 1-1000 之间', 'warning');
  1087. return;
  1088. }
  1089. const confirmed = await showConfirmDialog(
  1090. `确定要批量生成 ${count} 个激活码吗?`,
  1091. '确认批量生成',
  1092. '生成',
  1093. 'primary'
  1094. );
  1095. if (!confirmed) {
  1096. return;
  1097. }
  1098. try {
  1099. const response = await apiRequest(`${API_BASE}/licenses/batch`, {
  1100. method: 'POST',
  1101. body: JSON.stringify({
  1102. prefix: prefix,
  1103. count: count,
  1104. max_devices: maxDevices
  1105. })
  1106. });
  1107. if (!response) return;
  1108. const result = await response.json();
  1109. if (result.code === 0) {
  1110. showToast(result.msg, 'success', 4000);
  1111. closeBatchModal();
  1112. loadLicenses(1); // 重新加载第一页
  1113. } else {
  1114. showToast('批量生成失败: ' + result.msg, 'error');
  1115. }
  1116. } catch (error) {
  1117. showToast('请求失败: ' + error.message, 'error');
  1118. }
  1119. }
  1120. // 从元素获取数据并显示设备列表弹框
  1121. function showDeviceListFromElement(element) {
  1122. const licenseId = parseInt(element.getAttribute('data-license-id'));
  1123. const licenseKey = element.getAttribute('data-license-key');
  1124. showDeviceList(licenseId, licenseKey);
  1125. }
  1126. // 显示设备列表弹框
  1127. async function showDeviceList(licenseId, licenseKey) {
  1128. try {
  1129. const response = await apiRequest(`${API_BASE}/licenses/${licenseId}`);
  1130. if (!response) return;
  1131. const result = await response.json();
  1132. if (result.code === 0) {
  1133. const license = result.data;
  1134. let boundDevices = [];
  1135. try {
  1136. boundDevices = JSON.parse(license.bound_devices || '[]');
  1137. } catch (e) {
  1138. boundDevices = [];
  1139. }
  1140. // 解析设备激活时间
  1141. let deviceActivations = {};
  1142. try {
  1143. const activationsStr = JSON.parse(license.device_activations || '{}');
  1144. deviceActivations = activationsStr;
  1145. } catch (e) {
  1146. deviceActivations = {};
  1147. }
  1148. // 解析设备心跳时间
  1149. let deviceHeartbeats = {};
  1150. try {
  1151. const heartbeatsStr = JSON.parse(license.device_heartbeats || '{}');
  1152. deviceHeartbeats = heartbeatsStr;
  1153. } catch (e) {
  1154. deviceHeartbeats = {};
  1155. }
  1156. // 设置标题
  1157. document.getElementById('device-list-title').textContent = `设备列表 - ${licenseKey}`;
  1158. // 渲染设备列表
  1159. const contentEl = document.getElementById('device-list-content');
  1160. if (boundDevices.length === 0) {
  1161. contentEl.innerHTML = `
  1162. <div class="device-list-empty">
  1163. <p>暂无绑定设备</p>
  1164. </div>
  1165. `;
  1166. } else {
  1167. const tableHtml = `
  1168. <table class="device-list-table">
  1169. <thead>
  1170. <tr>
  1171. <th>序号</th>
  1172. <th>设备ID</th>
  1173. <th>激活时间</th>
  1174. <th>最近心跳时间</th>
  1175. </tr>
  1176. </thead>
  1177. <tbody>
  1178. ${boundDevices.map((deviceId, index) => {
  1179. const activationTime = deviceActivations[deviceId];
  1180. let timeDisplay = '<span style="color: #9ca3af;">未记录</span>';
  1181. if (activationTime) {
  1182. const date = new Date(activationTime);
  1183. timeDisplay = date.toLocaleString('zh-CN');
  1184. }
  1185. // 处理心跳时间显示
  1186. const heartbeatTime = deviceHeartbeats[deviceId];
  1187. let heartbeatDisplay = '<span style="color: #9ca3af;">未记录</span>';
  1188. if (heartbeatTime) {
  1189. const heartbeatDate = new Date(heartbeatTime);
  1190. const now = new Date();
  1191. const diff = now - heartbeatDate;
  1192. const seconds = Math.floor(diff / 1000);
  1193. const minutes = Math.floor(seconds / 60);
  1194. const hours = Math.floor(minutes / 60);
  1195. const days = Math.floor(hours / 24);
  1196. // 格式化相对时间
  1197. let relativeTime = '';
  1198. let heartbeatColor = '#6b7280'; // 默认灰色
  1199. if (days > 0) {
  1200. relativeTime = `${days}天前`;
  1201. heartbeatColor = '#ef4444'; // 红色 - 很久没心跳
  1202. } else if (hours > 0) {
  1203. relativeTime = `${hours}小时前`;
  1204. heartbeatColor = '#f59e0b'; // 橙色 - 较久
  1205. } else if (minutes > 0) {
  1206. relativeTime = `${minutes}分钟前`;
  1207. heartbeatColor = minutes < 10 ? '#10b981' : '#f59e0b'; // 绿色(10分钟内)或橙色
  1208. } else if (seconds > 0) {
  1209. relativeTime = `${seconds}秒前`;
  1210. heartbeatColor = '#10b981'; // 绿色
  1211. } else {
  1212. relativeTime = '刚刚';
  1213. heartbeatColor = '#10b981'; // 绿色
  1214. }
  1215. heartbeatDisplay = `${heartbeatDate.toLocaleString('zh-CN')} <span style="color: ${heartbeatColor}; font-size: 11px; font-weight: 400; margin-left: 6px;">(${relativeTime})</span>`;
  1216. }
  1217. return `
  1218. <tr>
  1219. <td>${index + 1}</td>
  1220. <td><strong>${deviceId}</strong></td>
  1221. <td>${timeDisplay}</td>
  1222. <td>${heartbeatDisplay}</td>
  1223. </tr>
  1224. `;
  1225. }).join('')}
  1226. </tbody>
  1227. </table>
  1228. <div style="margin-top: 15px; color: #6b7280; font-size: 14px; text-align: right;">
  1229. 共 ${boundDevices.length} 个设备
  1230. </div>
  1231. `;
  1232. contentEl.innerHTML = tableHtml;
  1233. }
  1234. document.getElementById('deviceListModal').classList.add('show');
  1235. } else {
  1236. showToast('加载失败: ' + result.msg, 'error');
  1237. }
  1238. } catch (error) {
  1239. showToast('请求失败: ' + error.message, 'error');
  1240. }
  1241. }
  1242. // 关闭设备列表弹框
  1243. function closeDeviceListModal() {
  1244. document.getElementById('deviceListModal').classList.remove('show');
  1245. }
  1246. // 点击 Modal 外部关闭
  1247. window.onclick = function(event) {
  1248. const licenseModal = document.getElementById('licenseModal');
  1249. const batchModal = document.getElementById('batchModal');
  1250. const deviceListModal = document.getElementById('deviceListModal');
  1251. const confirmDialog = document.getElementById('confirmDialog');
  1252. if (event.target === licenseModal) {
  1253. closeModal();
  1254. }
  1255. if (event.target === batchModal) {
  1256. closeBatchModal();
  1257. }
  1258. if (event.target === deviceListModal) {
  1259. closeDeviceListModal();
  1260. }
  1261. if (event.target === confirmDialog) {
  1262. closeConfirmDialog(false);
  1263. }
  1264. }
  1265. </script>
  1266. </body>
  1267. </html>