index.html 69 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870
  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. /* 统计信息卡片样式 */
  476. .stats-container {
  477. display: grid;
  478. grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  479. gap: 20px;
  480. margin-bottom: 20px;
  481. }
  482. .stat-card {
  483. background: white;
  484. border-radius: 10px;
  485. padding: 20px;
  486. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  487. display: flex;
  488. flex-direction: column;
  489. }
  490. .stat-label {
  491. color: #6b7280;
  492. font-size: 14px;
  493. margin-bottom: 8px;
  494. }
  495. .stat-value {
  496. color: #111827;
  497. font-size: 28px;
  498. font-weight: 600;
  499. }
  500. .stat-card.primary .stat-value {
  501. color: #667eea;
  502. }
  503. .stat-card.success .stat-value {
  504. color: #10b981;
  505. }
  506. .stat-card.warning .stat-value {
  507. color: #f59e0b;
  508. }
  509. .stat-card.danger .stat-value {
  510. color: #ef4444;
  511. }
  512. /* 筛选和操作栏样式 */
  513. .filter-bar {
  514. display: flex;
  515. justify-content: space-between;
  516. align-items: center;
  517. margin-bottom: 20px;
  518. flex-wrap: wrap;
  519. gap: 15px;
  520. }
  521. .filter-group {
  522. display: flex;
  523. align-items: center;
  524. gap: 10px;
  525. }
  526. .filter-group label {
  527. color: #374151;
  528. font-weight: 500;
  529. font-size: 14px;
  530. }
  531. .filter-select {
  532. padding: 8px 12px;
  533. border: 1px solid #d1d5db;
  534. border-radius: 5px;
  535. font-size: 14px;
  536. background: white;
  537. cursor: pointer;
  538. min-width: 120px;
  539. }
  540. .filter-select:focus {
  541. outline: none;
  542. border-color: #667eea;
  543. box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
  544. }
  545. </style>
  546. </head>
  547. <body>
  548. <!-- Toast 通知容器 -->
  549. <div class="toast-container" id="toast-container"></div>
  550. <!-- 确认对话框 -->
  551. <div id="confirmDialog" class="confirm-dialog">
  552. <div class="confirm-content">
  553. <div class="confirm-icon">⚠️</div>
  554. <div class="confirm-title" id="confirm-title">确认操作</div>
  555. <div class="confirm-message" id="confirm-message"></div>
  556. <div class="confirm-actions">
  557. <button class="btn btn-secondary" onclick="closeConfirmDialog(false)">取消</button>
  558. <button class="btn btn-danger" onclick="closeConfirmDialog(true)" id="confirm-ok-btn">确定</button>
  559. </div>
  560. </div>
  561. </div>
  562. <div class="container">
  563. <div class="header">
  564. <h1>🔑 License 管理平台</h1>
  565. <div style="display: flex; gap: 10px;">
  566. <button class="btn btn-primary" onclick="openBatchModal()">📦 批量生成</button>
  567. <button class="btn btn-primary" onclick="exportToCSV()">📥 导出 CSV</button>
  568. <button class="btn btn-primary" onclick="openCreateModal()">+ 创建 License</button>
  569. </div>
  570. </div>
  571. <!-- 统计信息卡片 -->
  572. <div class="stats-container" id="stats-container" style="display: none;">
  573. <div class="stat-card primary">
  574. <div class="stat-label">总 License 数</div>
  575. <div class="stat-value" id="stat-total">0</div>
  576. </div>
  577. <div class="stat-card success">
  578. <div class="stat-label">已激活</div>
  579. <div class="stat-value" id="stat-activated">0</div>
  580. </div>
  581. <div class="stat-card warning">
  582. <div class="stat-label">未激活</div>
  583. <div class="stat-value" id="stat-unactivated">0</div>
  584. </div>
  585. <div class="stat-card danger">
  586. <div class="stat-label">总设备数</div>
  587. <div class="stat-value" id="stat-devices">0</div>
  588. </div>
  589. </div>
  590. <div class="card">
  591. <div id="loading" class="loading" style="display: none;">加载中...</div>
  592. <div id="empty-state" class="empty-state" style="display: none;">
  593. <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
  594. <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>
  595. </svg>
  596. <p>暂无 License 数据</p>
  597. </div>
  598. <div class="table-container" id="table-container" style="display: none;">
  599. <!-- 筛选和操作栏 -->
  600. <div class="filter-bar">
  601. <div class="filter-group">
  602. <label for="status-filter">状态筛选:</label>
  603. <select id="status-filter" class="filter-select" onchange="handleStatusFilter()">
  604. <option value="">全部</option>
  605. <option value="activated">已激活</option>
  606. <option value="unactivated">未激活</option>
  607. </select>
  608. </div>
  609. <div style="display: flex; gap: 10px;">
  610. <button class="btn btn-primary" id="batch-update-btn" onclick="openBatchUpdateModal()" style="display: none;">
  611. ⚙️ 批量修改最大设备数
  612. </button>
  613. <button class="btn btn-danger" id="batch-delete-btn" onclick="batchDeleteLicenses()" style="display: none;">
  614. 批量删除
  615. </button>
  616. </div>
  617. </div>
  618. <div style="margin-bottom: 15px; display: flex; align-items: center; gap: 10px;">
  619. <input type="checkbox" id="select-all" onchange="toggleSelectAll()" style="width: 18px; height: 18px; cursor: pointer;">
  620. <label for="select-all" style="cursor: pointer; user-select: none;">全选</label>
  621. <span id="selected-count" style="color: #6b7280; font-size: 14px;">已选择 0 项</span>
  622. </div>
  623. <table>
  624. <thead>
  625. <tr>
  626. <th style="width: 50px;">
  627. <input type="checkbox" id="select-all-header" onchange="toggleSelectAll()" style="width: 18px; height: 18px; cursor: pointer;">
  628. </th>
  629. <th>ID</th>
  630. <th>激活码</th>
  631. <th>设备详情</th>
  632. <th>绑定设备数</th>
  633. <th>备注</th>
  634. <th>创建时间</th>
  635. <th>操作</th>
  636. </tr>
  637. </thead>
  638. <tbody id="license-table-body">
  639. </tbody>
  640. </table>
  641. <div class="pagination" id="pagination"></div>
  642. </div>
  643. </div>
  644. </div>
  645. <!-- 创建/编辑 Modal -->
  646. <div id="licenseModal" class="modal">
  647. <div class="modal-content">
  648. <div class="modal-header">
  649. <h2 id="modal-title">创建 License</h2>
  650. <button class="close" onclick="closeModal()">&times;</button>
  651. </div>
  652. <form id="licenseForm" onsubmit="handleSubmit(event)">
  653. <input type="hidden" id="license-id">
  654. <div class="form-group">
  655. <label for="license-key">激活码 *</label>
  656. <input type="text" id="license-key" required placeholder="例如: VIP-8888">
  657. </div>
  658. <div class="form-group">
  659. <label for="license-max-devices">最大设备数 *</label>
  660. <input type="number" id="license-max-devices" required min="1" value="2">
  661. </div>
  662. <div class="form-group">
  663. <label for="license-bound-devices">已绑定设备 (JSON 数组,可选)</label>
  664. <input type="text" id="license-bound-devices" placeholder='例如: ["device-1", "device-2"]'>
  665. </div>
  666. <div class="form-group">
  667. <label for="license-remark">备注 (可选)</label>
  668. <textarea id="license-remark" rows="3" placeholder="请输入备注信息" style="width: 100%; padding: 10px; border: 1px solid #d1d5db; border-radius: 5px; font-size: 14px; font-family: inherit; resize: vertical;"></textarea>
  669. </div>
  670. <div class="form-actions">
  671. <button type="button" class="btn btn-secondary" onclick="closeModal()">取消</button>
  672. <button type="submit" class="btn btn-primary">保存</button>
  673. </div>
  674. </form>
  675. </div>
  676. </div>
  677. <!-- 批量生成 Modal -->
  678. <div id="batchModal" class="modal">
  679. <div class="modal-content">
  680. <div class="modal-header">
  681. <h2>批量生成 License</h2>
  682. <button class="close" onclick="closeBatchModal()">&times;</button>
  683. </div>
  684. <form id="batchForm" onsubmit="handleBatchSubmit(event)">
  685. <div class="form-group">
  686. <label for="batch-prefix">激活码前缀 *</label>
  687. <input type="text" id="batch-prefix" required placeholder="例如: VIP" value="VIP">
  688. <small style="color: #6b7280; font-size: 12px; margin-top: 4px; display: block;">
  689. 生成的激活码格式:前缀-随机32位字符串(如:VIP-A3B9C2D4E5F6G7H8I9J0K1L2M3N4O5P6)
  690. </small>
  691. </div>
  692. <div class="form-group">
  693. <label for="batch-count">生成数量 *</label>
  694. <input type="number" id="batch-count" required min="1" max="1000" value="10">
  695. <small style="color: #6b7280; font-size: 12px; margin-top: 4px; display: block;">
  696. 一次最多可生成 1000 个
  697. </small>
  698. </div>
  699. <div class="form-group">
  700. <label for="batch-max-devices">最大设备数 *</label>
  701. <input type="number" id="batch-max-devices" required min="1" value="2">
  702. </div>
  703. <div class="form-actions">
  704. <button type="button" class="btn btn-secondary" onclick="closeBatchModal()">取消</button>
  705. <button type="submit" class="btn btn-primary">生成</button>
  706. </div>
  707. </form>
  708. </div>
  709. </div>
  710. <!-- 设备列表 Modal -->
  711. <div id="deviceListModal" class="modal device-list-modal">
  712. <div class="modal-content">
  713. <div class="modal-header">
  714. <h2 id="device-list-title">设备列表</h2>
  715. <button class="close" onclick="closeDeviceListModal()">&times;</button>
  716. </div>
  717. <div id="device-list-content">
  718. <div class="device-list-empty">加载中...</div>
  719. </div>
  720. </div>
  721. </div>
  722. <!-- 批量修改最大设备数 Modal -->
  723. <div id="batchUpdateModal" class="modal">
  724. <div class="modal-content">
  725. <div class="modal-header">
  726. <h2>批量修改最大设备数</h2>
  727. <button class="close" onclick="closeBatchUpdateModal()">&times;</button>
  728. </div>
  729. <form id="batchUpdateForm" onsubmit="handleBatchUpdateSubmit(event)">
  730. <div class="form-group">
  731. <label>已选择 <span id="batch-update-count">0</span> 个 License</label>
  732. </div>
  733. <div class="form-group">
  734. <label for="batch-update-max-devices">新的最大设备数 *</label>
  735. <input type="number" id="batch-update-max-devices" required min="1" value="2">
  736. <small style="color: #6b7280; font-size: 12px; margin-top: 4px; display: block;">
  737. 将把选中 License 的最大设备数统一修改为此值
  738. </small>
  739. </div>
  740. <div class="form-actions">
  741. <button type="button" class="btn btn-secondary" onclick="closeBatchUpdateModal()">取消</button>
  742. <button type="submit" class="btn btn-primary">确认修改</button>
  743. </div>
  744. </form>
  745. </div>
  746. </div>
  747. <script>
  748. // 使用相对路径,自动适配当前域名
  749. const API_BASE = '/api';
  750. let currentPage = 1;
  751. let pageSize = 10;
  752. let total = 0;
  753. let editingId = null;
  754. let currentStatusFilter = ''; // 当前状态筛选:''(全部)、'activated'(已激活)、'unactivated'(未激活)
  755. let allLicenses = []; // 存储所有License数据用于统计
  756. // Toast 通知函数
  757. function showToast(message, type = 'info', duration = 3000) {
  758. const container = document.getElementById('toast-container');
  759. const toast = document.createElement('div');
  760. toast.className = `toast ${type}`;
  761. const icons = {
  762. success: '✅',
  763. error: '❌',
  764. warning: '⚠️',
  765. info: 'ℹ️'
  766. };
  767. toast.innerHTML = `
  768. <span class="toast-icon">${icons[type] || icons.info}</span>
  769. <span class="toast-message">${message}</span>
  770. <button class="toast-close" onclick="this.parentElement.remove()">&times;</button>
  771. `;
  772. container.appendChild(toast);
  773. // 自动移除
  774. setTimeout(() => {
  775. if (toast.parentElement) {
  776. toast.style.animation = 'slideInRight 0.3s ease-out reverse';
  777. setTimeout(() => {
  778. if (toast.parentElement) {
  779. toast.remove();
  780. }
  781. }, 300);
  782. }
  783. }, duration);
  784. }
  785. // 确认对话框函数
  786. let confirmCallback = null;
  787. function showConfirmDialog(message, title = '确认操作', okText = '确定', okType = 'danger') {
  788. return new Promise((resolve) => {
  789. document.getElementById('confirm-title').textContent = title;
  790. document.getElementById('confirm-message').textContent = message;
  791. const okBtn = document.getElementById('confirm-ok-btn');
  792. okBtn.textContent = okText;
  793. okBtn.className = `btn btn-${okType}`;
  794. confirmCallback = resolve;
  795. document.getElementById('confirmDialog').classList.add('show');
  796. });
  797. }
  798. function closeConfirmDialog(confirmed) {
  799. document.getElementById('confirmDialog').classList.remove('show');
  800. if (confirmCallback) {
  801. confirmCallback(confirmed);
  802. confirmCallback = null;
  803. }
  804. }
  805. // 复制激活码到剪贴板
  806. async function copyLicenseKey(key) {
  807. try {
  808. await navigator.clipboard.writeText(key);
  809. showToast('激活码已复制到剪贴板', 'success', 2000);
  810. } catch (err) {
  811. // 降级方案:使用传统方法
  812. const textArea = document.createElement('textarea');
  813. textArea.value = key;
  814. textArea.style.position = 'fixed';
  815. textArea.style.left = '-999999px';
  816. document.body.appendChild(textArea);
  817. textArea.select();
  818. try {
  819. document.execCommand('copy');
  820. showToast('激活码已复制到剪贴板', 'success', 2000);
  821. } catch (err) {
  822. showToast('复制失败,请手动复制', 'error');
  823. }
  824. document.body.removeChild(textArea);
  825. }
  826. }
  827. // 全选/取消全选
  828. function toggleSelectAll() {
  829. const selectAll = document.getElementById('select-all');
  830. const selectAllHeader = document.getElementById('select-all-header');
  831. const checkboxes = document.querySelectorAll('.license-checkbox');
  832. // 检查当前是否所有复选框都已选中
  833. const allChecked = checkboxes.length > 0 &&
  834. Array.from(checkboxes).every(checkbox => checkbox.checked);
  835. // 如果全部已选中,则取消全选;否则全选
  836. const isChecked = !allChecked;
  837. checkboxes.forEach(checkbox => {
  838. checkbox.checked = isChecked;
  839. });
  840. // 同步两个全选复选框
  841. selectAll.checked = isChecked;
  842. selectAllHeader.checked = isChecked;
  843. updateSelectedCount();
  844. }
  845. // 更新选中数量
  846. function updateSelectedCount() {
  847. const checkboxes = document.querySelectorAll('.license-checkbox:checked');
  848. const count = checkboxes.length;
  849. const selectedCountEl = document.getElementById('selected-count');
  850. const batchDeleteBtn = document.getElementById('batch-delete-btn');
  851. selectedCountEl.textContent = `已选择 ${count} 项`;
  852. if (count > 0) {
  853. batchDeleteBtn.style.display = 'block';
  854. } else {
  855. batchDeleteBtn.style.display = 'none';
  856. }
  857. // 更新批量操作按钮
  858. updateBatchButtons();
  859. // 更新全选复选框状态
  860. const allCheckboxes = document.querySelectorAll('.license-checkbox');
  861. const allChecked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length;
  862. document.getElementById('select-all').checked = allChecked;
  863. document.getElementById('select-all-header').checked = allChecked;
  864. }
  865. // 批量删除 License
  866. async function batchDeleteLicenses() {
  867. const checkboxes = document.querySelectorAll('.license-checkbox:checked');
  868. const selectedIds = Array.from(checkboxes).map(cb => parseInt(cb.value));
  869. if (selectedIds.length === 0) {
  870. showToast('请至少选择一个 License', 'warning');
  871. return;
  872. }
  873. const confirmed = await showConfirmDialog(
  874. `确定要删除选中的 ${selectedIds.length} 个 License 吗?此操作不可恢复!`,
  875. '确认批量删除',
  876. '删除',
  877. 'danger'
  878. );
  879. if (!confirmed) {
  880. return;
  881. }
  882. try {
  883. const response = await apiRequest(`${API_BASE}/licenses/batch`, {
  884. method: 'DELETE',
  885. body: JSON.stringify({
  886. ids: selectedIds
  887. })
  888. });
  889. if (!response) return;
  890. const result = await response.json();
  891. if (result.code === 0) {
  892. showToast(result.msg, 'success');
  893. loadStatistics(); // 重新加载统计信息
  894. loadLicenses(currentPage);
  895. } else {
  896. showToast('批量删除失败: ' + result.msg, 'error');
  897. }
  898. } catch (error) {
  899. showToast('请求失败: ' + error.message, 'error');
  900. }
  901. }
  902. // 获取认证token
  903. function getAuthToken() {
  904. return localStorage.getItem('auth_token');
  905. }
  906. // 检查是否已登录
  907. function checkAuth() {
  908. const token = getAuthToken();
  909. if (!token) {
  910. window.location.href = '/web/login.html';
  911. return false;
  912. }
  913. return true;
  914. }
  915. // 获取API请求头(包含token)
  916. function getAuthHeaders() {
  917. const token = getAuthToken();
  918. return {
  919. 'Content-Type': 'application/json',
  920. 'Authorization': `Bearer ${token}`
  921. };
  922. }
  923. // 处理API错误响应
  924. async function handleApiError(response) {
  925. if (response.status === 401) {
  926. // 未授权,清除token并跳转到登录页
  927. localStorage.removeItem('auth_token');
  928. showToast('登录已过期,请重新登录', 'error');
  929. setTimeout(() => {
  930. window.location.href = '/web/login.html';
  931. }, 1000);
  932. return true;
  933. }
  934. return false;
  935. }
  936. // 统一的API请求函数
  937. async function apiRequest(url, options = {}) {
  938. const headers = getAuthHeaders();
  939. if (options.headers) {
  940. Object.assign(headers, options.headers);
  941. }
  942. const response = await fetch(url, {
  943. ...options,
  944. headers: headers
  945. });
  946. if (await handleApiError(response)) {
  947. return null;
  948. }
  949. return response;
  950. }
  951. // 页面加载时检查登录状态
  952. window.onload = () => {
  953. if (checkAuth()) {
  954. loadStatistics();
  955. loadLicenses();
  956. }
  957. };
  958. // 加载统计信息
  959. async function loadStatistics() {
  960. try {
  961. const response = await apiRequest(`${API_BASE}/licenses/statistics`);
  962. if (!response) return;
  963. const result = await response.json();
  964. if (result.code === 0 && result.data) {
  965. const stats = result.data;
  966. document.getElementById('stat-total').textContent = stats.total || 0;
  967. document.getElementById('stat-activated').textContent = stats.activated || 0;
  968. document.getElementById('stat-unactivated').textContent = stats.unactivated || 0;
  969. document.getElementById('stat-devices').textContent = stats.total_devices || 0;
  970. document.getElementById('stats-container').style.display = 'grid';
  971. }
  972. } catch (error) {
  973. console.error('加载统计信息失败:', error);
  974. }
  975. }
  976. // 加载 License 列表
  977. async function loadLicenses(page = 1) {
  978. currentPage = page;
  979. const loadingEl = document.getElementById('loading');
  980. const tableContainer = document.getElementById('table-container');
  981. const emptyState = document.getElementById('empty-state');
  982. loadingEl.style.display = 'block';
  983. tableContainer.style.display = 'none';
  984. emptyState.style.display = 'none';
  985. try {
  986. // 构建查询URL
  987. let url = `${API_BASE}/licenses?page=${page}&page_size=${pageSize}`;
  988. if (currentStatusFilter) {
  989. url += `&status=${currentStatusFilter}`;
  990. }
  991. const response = await apiRequest(url);
  992. if (!response) return;
  993. const result = await response.json();
  994. if (result.code === 0) {
  995. total = result.total;
  996. const licenses = result.data;
  997. if (licenses.length === 0) {
  998. loadingEl.style.display = 'none';
  999. emptyState.style.display = 'block';
  1000. return;
  1001. }
  1002. renderTable(licenses);
  1003. renderPagination();
  1004. loadingEl.style.display = 'none';
  1005. tableContainer.style.display = 'block';
  1006. // 更新批量操作按钮显示
  1007. updateBatchButtons();
  1008. } else {
  1009. showToast('加载失败: ' + result.msg, 'error');
  1010. loadingEl.style.display = 'none';
  1011. }
  1012. } catch (error) {
  1013. showToast('请求失败: ' + error.message, 'error');
  1014. loadingEl.style.display = 'none';
  1015. }
  1016. }
  1017. // 状态筛选处理
  1018. function handleStatusFilter() {
  1019. const filterSelect = document.getElementById('status-filter');
  1020. currentStatusFilter = filterSelect.value;
  1021. loadLicenses(1); // 重置到第一页
  1022. }
  1023. // 更新批量操作按钮显示
  1024. function updateBatchButtons() {
  1025. const checkboxes = document.querySelectorAll('.license-checkbox:checked');
  1026. const batchUpdateBtn = document.getElementById('batch-update-btn');
  1027. if (checkboxes.length > 0) {
  1028. batchUpdateBtn.style.display = 'block';
  1029. } else {
  1030. batchUpdateBtn.style.display = 'none';
  1031. }
  1032. }
  1033. // 渲染表格
  1034. function renderTable(licenses) {
  1035. const tbody = document.getElementById('license-table-body');
  1036. tbody.innerHTML = licenses.map(license => {
  1037. let boundDevices = [];
  1038. try {
  1039. boundDevices = JSON.parse(license.bound_devices || '[]');
  1040. } catch (e) {
  1041. boundDevices = [];
  1042. }
  1043. // 解析设备激活时间
  1044. let deviceActivations = {};
  1045. try {
  1046. const activationsStr = JSON.parse(license.device_activations || '{}');
  1047. deviceActivations = activationsStr;
  1048. } catch (e) {
  1049. deviceActivations = {};
  1050. }
  1051. const boundCount = boundDevices.length;
  1052. const isFull = boundCount >= license.max_devices;
  1053. const createdDate = new Date(license.created_at).toLocaleString('zh-CN');
  1054. // 限制激活码显示长度为10个字符
  1055. const displayKey = license.key.length > 10 ? license.key.substring(0, 10) + '...' : license.key;
  1056. // 构建设备详情显示
  1057. let deviceDetailHtml = '';
  1058. if (boundDevices.length === 0) {
  1059. deviceDetailHtml = '<span style="color: #9ca3af;">无设备</span>';
  1060. } else {
  1061. // 显示前2个设备作为预览
  1062. const previewDevices = boundDevices.slice(0, 2);
  1063. const previewText = previewDevices.map(deviceId => {
  1064. const activationTime = deviceActivations[deviceId];
  1065. if (activationTime) {
  1066. const date = new Date(activationTime);
  1067. return `${deviceId} (${date.toLocaleString('zh-CN')})`;
  1068. }
  1069. return `${deviceId} (未记录)`;
  1070. }).join('、');
  1071. const moreCount = boundDevices.length - 2;
  1072. // 使用 data 属性安全传递 licenseKey
  1073. const escapedKey = license.key.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
  1074. deviceDetailHtml = `
  1075. <div class="device-detail-cell" data-license-id="${license.id}" data-license-key="${escapedKey}" onclick="showDeviceListFromElement(this)">
  1076. <div class="device-count">${boundCount} 个设备</div>
  1077. <div class="device-preview">${previewText}${moreCount > 0 ? ` 等${moreCount}个...` : ''}</div>
  1078. </div>
  1079. `;
  1080. }
  1081. return `
  1082. <tr>
  1083. <td style="text-align: center;">
  1084. <input type="checkbox" class="license-checkbox" value="${license.id}" onchange="updateSelectedCount()" style="width: 18px; height: 18px; cursor: pointer;">
  1085. </td>
  1086. <td>${license.id}</td>
  1087. <td>
  1088. <div class="license-key-cell" title="${license.key}">
  1089. <span class="license-key-text">${displayKey}</span>
  1090. <button class="copy-btn" onclick="copyLicenseKey('${license.key}')" title="复制激活码">
  1091. <span>复制</span>
  1092. </button>
  1093. </div>
  1094. </td>
  1095. <td>
  1096. ${deviceDetailHtml}
  1097. </td>
  1098. <td>
  1099. <span class="badge ${isFull ? 'badge-warning' : 'badge-success'}">
  1100. ${boundCount} / ${license.max_devices}
  1101. </span>
  1102. </td>
  1103. <td>
  1104. <div style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${license.remark || ''}">
  1105. ${license.remark || '<span style="color: #9ca3af;">无</span>'}
  1106. </div>
  1107. </td>
  1108. <td>${createdDate}</td>
  1109. <td>
  1110. <div class="actions">
  1111. <button class="btn btn-primary btn-sm" onclick="editLicense(${license.id})">编辑</button>
  1112. <button class="btn btn-danger btn-sm" onclick="deleteLicense(${license.id}, '${license.key}')">删除</button>
  1113. </div>
  1114. </td>
  1115. </tr>
  1116. `;
  1117. }).join('');
  1118. }
  1119. // 渲染分页
  1120. function renderPagination() {
  1121. const pagination = document.getElementById('pagination');
  1122. const totalPages = Math.ceil(total / pageSize);
  1123. if (totalPages <= 1) {
  1124. pagination.innerHTML = '';
  1125. return;
  1126. }
  1127. pagination.innerHTML = `
  1128. <button onclick="loadLicenses(${currentPage - 1})" ${currentPage === 1 ? 'disabled' : ''}>
  1129. 上一页
  1130. </button>
  1131. <span class="page-info">第 ${currentPage} / ${totalPages} 页 (共 ${total} 条)</span>
  1132. <button onclick="loadLicenses(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}>
  1133. 下一页
  1134. </button>
  1135. `;
  1136. }
  1137. // 打开创建 Modal
  1138. function openCreateModal() {
  1139. editingId = null;
  1140. document.getElementById('modal-title').textContent = '创建 License';
  1141. document.getElementById('license-id').value = '';
  1142. document.getElementById('license-key').value = '';
  1143. document.getElementById('license-max-devices').value = '2';
  1144. document.getElementById('license-bound-devices').value = '';
  1145. document.getElementById('license-remark').value = '';
  1146. document.getElementById('license-key').disabled = false;
  1147. document.getElementById('licenseModal').classList.add('show');
  1148. }
  1149. // 编辑 License
  1150. async function editLicense(id) {
  1151. try {
  1152. const response = await apiRequest(`${API_BASE}/licenses/${id}`);
  1153. if (!response) return;
  1154. const result = await response.json();
  1155. if (result.code === 0) {
  1156. const license = result.data;
  1157. editingId = id;
  1158. document.getElementById('modal-title').textContent = '编辑 License';
  1159. document.getElementById('license-id').value = id;
  1160. document.getElementById('license-key').value = license.key;
  1161. document.getElementById('license-max-devices').value = license.max_devices;
  1162. document.getElementById('license-bound-devices').value = license.bound_devices || '[]';
  1163. document.getElementById('license-remark').value = license.remark || '';
  1164. document.getElementById('license-key').disabled = false;
  1165. document.getElementById('licenseModal').classList.add('show');
  1166. } else {
  1167. showToast('加载失败: ' + result.msg, 'error');
  1168. }
  1169. } catch (error) {
  1170. showToast('请求失败: ' + error.message, 'error');
  1171. }
  1172. }
  1173. // 关闭 Modal
  1174. function closeModal() {
  1175. document.getElementById('licenseModal').classList.remove('show');
  1176. }
  1177. // 提交表单
  1178. async function handleSubmit(event) {
  1179. event.preventDefault();
  1180. const id = document.getElementById('license-id').value;
  1181. const key = document.getElementById('license-key').value;
  1182. const maxDevices = parseInt(document.getElementById('license-max-devices').value);
  1183. const boundDevices = document.getElementById('license-bound-devices').value || '[]';
  1184. const remark = document.getElementById('license-remark').value || '';
  1185. // 验证 boundDevices 是否为有效 JSON
  1186. try {
  1187. JSON.parse(boundDevices);
  1188. } catch (e) {
  1189. showToast('已绑定设备必须是有效的 JSON 数组格式', 'error');
  1190. return;
  1191. }
  1192. try {
  1193. let response;
  1194. if (editingId) {
  1195. // 更新
  1196. const updateData = {
  1197. max_devices: maxDevices,
  1198. remark: remark // 总是发送remark字段,即使是空字符串
  1199. };
  1200. if (boundDevices) {
  1201. updateData.bound_devices = boundDevices;
  1202. }
  1203. response = await apiRequest(`${API_BASE}/licenses/${id}`, {
  1204. method: 'PUT',
  1205. body: JSON.stringify(updateData)
  1206. });
  1207. } else {
  1208. // 创建
  1209. response = await apiRequest(`${API_BASE}/licenses`, {
  1210. method: 'POST',
  1211. body: JSON.stringify({
  1212. key: key,
  1213. max_devices: maxDevices,
  1214. bound_devices: boundDevices,
  1215. remark: remark
  1216. })
  1217. });
  1218. }
  1219. if (!response) return;
  1220. const result = await response.json();
  1221. if (result.code === 0) {
  1222. showToast(editingId ? '更新成功' : '创建成功', 'success');
  1223. closeModal();
  1224. loadStatistics(); // 重新加载统计信息
  1225. loadLicenses(currentPage);
  1226. } else {
  1227. showToast('操作失败: ' + result.msg, 'error');
  1228. }
  1229. } catch (error) {
  1230. showToast('请求失败: ' + error.message, 'error');
  1231. }
  1232. }
  1233. // 删除 License
  1234. async function deleteLicense(id, key) {
  1235. const confirmed = await showConfirmDialog(
  1236. `确定要删除 License "${key}" 吗?此操作不可恢复!`,
  1237. '确认删除',
  1238. '删除',
  1239. 'danger'
  1240. );
  1241. if (!confirmed) {
  1242. return;
  1243. }
  1244. try {
  1245. const response = await apiRequest(`${API_BASE}/licenses/${id}`, {
  1246. method: 'DELETE'
  1247. });
  1248. if (!response) return;
  1249. const result = await response.json();
  1250. if (result.code === 0) {
  1251. showToast('删除成功', 'success');
  1252. loadStatistics(); // 重新加载统计信息
  1253. loadLicenses(currentPage);
  1254. } else {
  1255. showToast('删除失败: ' + result.msg, 'error');
  1256. }
  1257. } catch (error) {
  1258. showToast('请求失败: ' + error.message, 'error');
  1259. }
  1260. }
  1261. // 打开批量生成 Modal
  1262. function openBatchModal() {
  1263. document.getElementById('batch-prefix').value = 'VIP';
  1264. document.getElementById('batch-count').value = '10';
  1265. document.getElementById('batch-max-devices').value = '2';
  1266. document.getElementById('batchModal').classList.add('show');
  1267. }
  1268. // 关闭批量生成 Modal
  1269. function closeBatchModal() {
  1270. document.getElementById('batchModal').classList.remove('show');
  1271. }
  1272. // 批量生成提交
  1273. async function handleBatchSubmit(event) {
  1274. event.preventDefault();
  1275. const prefix = document.getElementById('batch-prefix').value.trim();
  1276. const count = parseInt(document.getElementById('batch-count').value);
  1277. const maxDevices = parseInt(document.getElementById('batch-max-devices').value);
  1278. if (!prefix) {
  1279. showToast('请输入激活码前缀', 'warning');
  1280. return;
  1281. }
  1282. if (count <= 0 || count > 1000) {
  1283. showToast('生成数量必须在 1-1000 之间', 'warning');
  1284. return;
  1285. }
  1286. const confirmed = await showConfirmDialog(
  1287. `确定要批量生成 ${count} 个激活码吗?`,
  1288. '确认批量生成',
  1289. '生成',
  1290. 'primary'
  1291. );
  1292. if (!confirmed) {
  1293. return;
  1294. }
  1295. try {
  1296. const response = await apiRequest(`${API_BASE}/licenses/batch`, {
  1297. method: 'POST',
  1298. body: JSON.stringify({
  1299. prefix: prefix,
  1300. count: count,
  1301. max_devices: maxDevices
  1302. })
  1303. });
  1304. if (!response) return;
  1305. const result = await response.json();
  1306. if (result.code === 0) {
  1307. showToast(result.msg, 'success', 4000);
  1308. closeBatchModal();
  1309. loadStatistics(); // 重新加载统计信息
  1310. loadLicenses(1); // 重新加载第一页
  1311. } else {
  1312. showToast('批量生成失败: ' + result.msg, 'error');
  1313. }
  1314. } catch (error) {
  1315. showToast('请求失败: ' + error.message, 'error');
  1316. }
  1317. }
  1318. // 从元素获取数据并显示设备列表弹框
  1319. function showDeviceListFromElement(element) {
  1320. const licenseId = parseInt(element.getAttribute('data-license-id'));
  1321. const licenseKey = element.getAttribute('data-license-key');
  1322. showDeviceList(licenseId, licenseKey);
  1323. }
  1324. // 显示设备列表弹框
  1325. async function showDeviceList(licenseId, licenseKey) {
  1326. try {
  1327. const response = await apiRequest(`${API_BASE}/licenses/${licenseId}`);
  1328. if (!response) return;
  1329. const result = await response.json();
  1330. if (result.code === 0) {
  1331. const license = result.data;
  1332. let boundDevices = [];
  1333. try {
  1334. boundDevices = JSON.parse(license.bound_devices || '[]');
  1335. } catch (e) {
  1336. boundDevices = [];
  1337. }
  1338. // 解析设备激活时间
  1339. let deviceActivations = {};
  1340. try {
  1341. const activationsStr = JSON.parse(license.device_activations || '{}');
  1342. deviceActivations = activationsStr;
  1343. } catch (e) {
  1344. deviceActivations = {};
  1345. }
  1346. // 解析设备心跳时间
  1347. let deviceHeartbeats = {};
  1348. try {
  1349. const heartbeatsStr = JSON.parse(license.device_heartbeats || '{}');
  1350. deviceHeartbeats = heartbeatsStr;
  1351. } catch (e) {
  1352. deviceHeartbeats = {};
  1353. }
  1354. // 设置标题
  1355. document.getElementById('device-list-title').textContent = `设备列表 - ${licenseKey}`;
  1356. // 渲染设备列表
  1357. const contentEl = document.getElementById('device-list-content');
  1358. if (boundDevices.length === 0) {
  1359. contentEl.innerHTML = `
  1360. <div class="device-list-empty">
  1361. <p>暂无绑定设备</p>
  1362. </div>
  1363. `;
  1364. } else {
  1365. const tableHtml = `
  1366. <table class="device-list-table">
  1367. <thead>
  1368. <tr>
  1369. <th>序号</th>
  1370. <th>设备ID</th>
  1371. <th>激活时间</th>
  1372. <th>最近心跳时间</th>
  1373. </tr>
  1374. </thead>
  1375. <tbody>
  1376. ${boundDevices.map((deviceId, index) => {
  1377. const activationTime = deviceActivations[deviceId];
  1378. let timeDisplay = '<span style="color: #9ca3af;">未记录</span>';
  1379. if (activationTime) {
  1380. const date = new Date(activationTime);
  1381. timeDisplay = date.toLocaleString('zh-CN');
  1382. }
  1383. // 处理心跳时间显示
  1384. const heartbeatTime = deviceHeartbeats[deviceId];
  1385. let heartbeatDisplay = '<span style="color: #9ca3af;">未记录</span>';
  1386. if (heartbeatTime) {
  1387. const heartbeatDate = new Date(heartbeatTime);
  1388. const now = new Date();
  1389. const diff = now - heartbeatDate;
  1390. const seconds = Math.floor(diff / 1000);
  1391. const minutes = Math.floor(seconds / 60);
  1392. const hours = Math.floor(minutes / 60);
  1393. const days = Math.floor(hours / 24);
  1394. // 格式化相对时间
  1395. let relativeTime = '';
  1396. let heartbeatColor = '#6b7280'; // 默认灰色
  1397. if (days > 0) {
  1398. relativeTime = `${days}天前`;
  1399. heartbeatColor = '#ef4444'; // 红色 - 很久没心跳
  1400. } else if (hours > 0) {
  1401. relativeTime = `${hours}小时前`;
  1402. heartbeatColor = '#f59e0b'; // 橙色 - 较久
  1403. } else if (minutes > 0) {
  1404. relativeTime = `${minutes}分钟前`;
  1405. heartbeatColor = minutes < 10 ? '#10b981' : '#f59e0b'; // 绿色(10分钟内)或橙色
  1406. } else if (seconds > 0) {
  1407. relativeTime = `${seconds}秒前`;
  1408. heartbeatColor = '#10b981'; // 绿色
  1409. } else {
  1410. relativeTime = '刚刚';
  1411. heartbeatColor = '#10b981'; // 绿色
  1412. }
  1413. heartbeatDisplay = `${heartbeatDate.toLocaleString('zh-CN')} <span style="color: ${heartbeatColor}; font-size: 11px; font-weight: 400; margin-left: 6px;">(${relativeTime})</span>`;
  1414. }
  1415. return `
  1416. <tr>
  1417. <td>${index + 1}</td>
  1418. <td><strong>${deviceId}</strong></td>
  1419. <td>${timeDisplay}</td>
  1420. <td>${heartbeatDisplay}</td>
  1421. </tr>
  1422. `;
  1423. }).join('')}
  1424. </tbody>
  1425. </table>
  1426. <div style="margin-top: 15px; color: #6b7280; font-size: 14px; text-align: right;">
  1427. 共 ${boundDevices.length} 个设备
  1428. </div>
  1429. `;
  1430. contentEl.innerHTML = tableHtml;
  1431. }
  1432. document.getElementById('deviceListModal').classList.add('show');
  1433. } else {
  1434. showToast('加载失败: ' + result.msg, 'error');
  1435. }
  1436. } catch (error) {
  1437. showToast('请求失败: ' + error.message, 'error');
  1438. }
  1439. }
  1440. // 关闭设备列表弹框
  1441. function closeDeviceListModal() {
  1442. document.getElementById('deviceListModal').classList.remove('show');
  1443. }
  1444. // 导出CSV
  1445. async function exportToCSV() {
  1446. try {
  1447. // 如果有筛选条件,先获取筛选后的数据,然后前端生成CSV
  1448. if (currentStatusFilter) {
  1449. showToast('正在导出筛选后的数据...', 'info');
  1450. const listResponse = await apiRequest(`${API_BASE}/licenses?page=1&page_size=10000&status=${currentStatusFilter}`);
  1451. if (!listResponse) return;
  1452. const listResult = await listResponse.json();
  1453. if (listResult.code === 0 && listResult.data.length > 0) {
  1454. const licenses = listResult.data;
  1455. const headers = ['ID', '激活码', '最大设备数', '已绑定设备数', '绑定设备列表', '创建时间', '更新时间'];
  1456. const rows = licenses.map(license => {
  1457. let boundDevices = [];
  1458. try {
  1459. boundDevices = JSON.parse(license.bound_devices || '[]');
  1460. } catch (e) {
  1461. boundDevices = [];
  1462. }
  1463. return [
  1464. license.id,
  1465. license.key,
  1466. license.max_devices,
  1467. boundDevices.length,
  1468. boundDevices.join('; '),
  1469. new Date(license.created_at).toLocaleString('zh-CN'),
  1470. new Date(license.updated_at).toLocaleString('zh-CN')
  1471. ];
  1472. });
  1473. function escapeCSVField(field) {
  1474. if (field === null || field === undefined) return '';
  1475. const str = String(field);
  1476. if (str.includes(',') || str.includes('"') || str.includes('\n')) {
  1477. return '"' + str.replace(/"/g, '""') + '"';
  1478. }
  1479. return str;
  1480. }
  1481. const csvContent = [
  1482. headers.map(escapeCSVField).join(','),
  1483. ...rows.map(row => row.map(escapeCSVField).join(','))
  1484. ].join('\n');
  1485. const BOM = '\uFEFF';
  1486. const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
  1487. const link = document.createElement('a');
  1488. const url = URL.createObjectURL(blob);
  1489. link.setAttribute('href', url);
  1490. const now = new Date();
  1491. const dateStr = now.toISOString().slice(0, 10).replace(/-/g, '');
  1492. const timeStr = now.toTimeString().slice(0, 8).replace(/:/g, '');
  1493. const statusStr = currentStatusFilter ? `_${currentStatusFilter}` : '';
  1494. link.setAttribute('download', `licenses${statusStr}_${dateStr}_${timeStr}.csv`);
  1495. link.style.visibility = 'hidden';
  1496. document.body.appendChild(link);
  1497. link.click();
  1498. document.body.removeChild(link);
  1499. showToast(`成功导出 ${licenses.length} 条记录`, 'success');
  1500. } else {
  1501. showToast('没有数据可导出', 'warning');
  1502. }
  1503. } else {
  1504. // 没有筛选条件时,使用后端导出接口
  1505. const token = getAuthToken();
  1506. const url = `${API_BASE}/licenses/export?format=csv`;
  1507. // 创建一个隐藏的表单来提交带token的请求
  1508. const form = document.createElement('form');
  1509. form.method = 'GET';
  1510. form.action = url;
  1511. form.style.display = 'none';
  1512. // 添加token到请求头(通过fetch下载)
  1513. const response = await fetch(url, {
  1514. headers: getAuthHeaders()
  1515. });
  1516. if (!response.ok) {
  1517. showToast('导出失败', 'error');
  1518. return;
  1519. }
  1520. const blob = await response.blob();
  1521. const downloadUrl = URL.createObjectURL(blob);
  1522. const link = document.createElement('a');
  1523. link.href = downloadUrl;
  1524. const now = new Date();
  1525. const dateStr = now.toISOString().slice(0, 10).replace(/-/g, '');
  1526. const timeStr = now.toTimeString().slice(0, 8).replace(/:/g, '');
  1527. link.setAttribute('download', `licenses_${dateStr}_${timeStr}.csv`);
  1528. link.style.visibility = 'hidden';
  1529. document.body.appendChild(link);
  1530. link.click();
  1531. document.body.removeChild(link);
  1532. URL.revokeObjectURL(downloadUrl);
  1533. showToast('导出成功', 'success');
  1534. }
  1535. } catch (error) {
  1536. showToast('导出失败: ' + error.message, 'error');
  1537. }
  1538. }
  1539. // 打开批量修改最大设备数弹框
  1540. function openBatchUpdateModal() {
  1541. const checkboxes = document.querySelectorAll('.license-checkbox:checked');
  1542. const count = checkboxes.length;
  1543. if (count === 0) {
  1544. showToast('请至少选择一个 License', 'warning');
  1545. return;
  1546. }
  1547. document.getElementById('batch-update-count').textContent = count;
  1548. document.getElementById('batch-update-max-devices').value = '2';
  1549. document.getElementById('batchUpdateModal').classList.add('show');
  1550. }
  1551. // 关闭批量修改弹框
  1552. function closeBatchUpdateModal() {
  1553. document.getElementById('batchUpdateModal').classList.remove('show');
  1554. }
  1555. // 批量修改最大设备数提交
  1556. async function handleBatchUpdateSubmit(event) {
  1557. event.preventDefault();
  1558. const checkboxes = document.querySelectorAll('.license-checkbox:checked');
  1559. const selectedIds = Array.from(checkboxes).map(cb => parseInt(cb.value));
  1560. const maxDevices = parseInt(document.getElementById('batch-update-max-devices').value);
  1561. if (selectedIds.length === 0) {
  1562. showToast('请至少选择一个 License', 'warning');
  1563. return;
  1564. }
  1565. if (maxDevices < 1) {
  1566. showToast('最大设备数必须大于0', 'warning');
  1567. return;
  1568. }
  1569. const confirmed = await showConfirmDialog(
  1570. `确定要将选中的 ${selectedIds.length} 个 License 的最大设备数修改为 ${maxDevices} 吗?`,
  1571. '确认批量修改',
  1572. '确认',
  1573. 'primary'
  1574. );
  1575. if (!confirmed) {
  1576. return;
  1577. }
  1578. try {
  1579. // 使用批量更新接口
  1580. const response = await apiRequest(`${API_BASE}/licenses/batch/max-devices`, {
  1581. method: 'PUT',
  1582. body: JSON.stringify({
  1583. ids: selectedIds,
  1584. max_devices: maxDevices
  1585. })
  1586. });
  1587. if (!response) return;
  1588. const result = await response.json();
  1589. if (result.code === 0) {
  1590. showToast(result.msg, 'success');
  1591. closeBatchUpdateModal();
  1592. // 清除所有选中状态
  1593. checkboxes.forEach(cb => cb.checked = false);
  1594. updateSelectedCount();
  1595. loadStatistics(); // 重新加载统计信息
  1596. loadLicenses(currentPage); // 重新加载列表
  1597. } else {
  1598. showToast('批量修改失败: ' + result.msg, 'error');
  1599. }
  1600. } catch (error) {
  1601. showToast('请求失败: ' + error.message, 'error');
  1602. }
  1603. }
  1604. // 点击 Modal 外部关闭
  1605. window.onclick = function(event) {
  1606. const licenseModal = document.getElementById('licenseModal');
  1607. const batchModal = document.getElementById('batchModal');
  1608. const batchUpdateModal = document.getElementById('batchUpdateModal');
  1609. const deviceListModal = document.getElementById('deviceListModal');
  1610. const confirmDialog = document.getElementById('confirmDialog');
  1611. if (event.target === licenseModal) {
  1612. closeModal();
  1613. }
  1614. if (event.target === batchModal) {
  1615. closeBatchModal();
  1616. }
  1617. if (event.target === batchUpdateModal) {
  1618. closeBatchUpdateModal();
  1619. }
  1620. if (event.target === deviceListModal) {
  1621. closeDeviceListModal();
  1622. }
  1623. if (event.target === confirmDialog) {
  1624. closeConfirmDialog(false);
  1625. }
  1626. }
  1627. </script>
  1628. </body>
  1629. </html>