| <!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title data-i18n="account.pageTitle">Grok2API - ่ดฆๆท็ฎก็</title> |
| <link rel="icon" href="/favicon.ico?v={{APP_VERSION}}"> |
| <link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-sans/style.css" rel="stylesheet"> |
| <link href="/static/css/app.css?v={{APP_VERSION}}" rel="stylesheet"> |
| <style> |
| body { background: #FAF9F5 } |
| |
| |
| .page-hd { display:flex; align-items:flex-start; justify-content:space-between; margin-bottom:20px } |
| .page-title{ font-size:22px; font-weight:700; line-height:1.2 } |
| .page-sub { font-size:13px; color:var(--fg-muted); margin-top:5px } |
| .page-actions { display:flex; gap:8px; align-items:center; flex-shrink:0 } |
| .section-head { |
| display:flex; |
| align-items:baseline; |
| justify-content:space-between; |
| gap:12px; |
| margin:28px 0 14px; |
| } |
| .section-title { |
| font-size:13px; |
| font-weight:600; |
| color:#222; |
| letter-spacing:.01em; |
| } |
| .section-title-row { |
| display:inline-flex; |
| align-items:center; |
| gap:8px; |
| } |
| .section-count-badge { |
| min-width:20px; |
| height:20px; |
| padding:0 7px; |
| border-radius:999px; |
| display:inline-flex; |
| align-items:center; |
| justify-content:center; |
| background:#f1ece2; |
| color:#6a6459; |
| font-size:11px; |
| font-weight:600; |
| font-variant-numeric:tabular-nums; |
| line-height:1; |
| } |
| .page-action-btn { |
| height:32px; |
| padding:0 14px; |
| border-radius:999px; |
| display:inline-flex; |
| align-items:center; |
| justify-content:center; |
| gap:6px; |
| font-size:13px; |
| font-weight:600; |
| border:1px solid #e6e6e6; |
| background:#fafafa; |
| color:#444; |
| } |
| .page-action-btn:hover { |
| background:#f3f3f3; |
| border-color:#dcdcdc; |
| } |
| .page-action-btn-primary { |
| background:#111; |
| border-color:#111; |
| color:#fff; |
| } |
| .page-action-btn-primary:hover { |
| background:#222; |
| border-color:#222; |
| } |
| |
| |
| .stat-grid { |
| display:grid; |
| grid-template-columns:repeat(5, minmax(0, 1fr)); |
| gap:12px; |
| margin-bottom:20px; |
| } |
| .stat-grid.mode-random { |
| grid-template-columns:repeat(4, minmax(0, 1fr)); |
| } |
| .stat-cell { |
| min-height:88px; |
| padding:14px 16px; |
| border-radius:12px; |
| background:#fff; |
| display:flex; |
| flex-direction:column; |
| gap:12px; |
| } |
| .stat-top { |
| display:flex; |
| align-items:center; |
| justify-content:space-between; |
| gap:10px; |
| } |
| .stat-label { |
| font-size:11px; |
| color:#8a8a8a; |
| letter-spacing:.01em; |
| white-space:nowrap; |
| } |
| .stat-icon { |
| width:24px; |
| height:24px; |
| display:inline-flex; |
| align-items:center; |
| justify-content:center; |
| color:#a3a3a3; |
| flex:0 0 auto; |
| } |
| .stat-icon svg { |
| width:15px; |
| height:15px; |
| stroke:currentColor; |
| } |
| .stat-num { |
| font-size:22px; |
| font-weight:600; |
| line-height:1; |
| letter-spacing:-.02em; |
| margin-top:auto; |
| } |
| .stat-muted { color:#a1a1aa !important; } |
| [data-random-only] { display:none } |
| .stat-grid.mode-random [data-random-only] { display:flex } |
| .stat-grid.mode-random [data-stat-card="active"] { order:1 } |
| .stat-grid.mode-random [data-stat-card="cooling"] { order:2 } |
| .stat-grid.mode-random [data-stat-card="invalid"] { order:3 } |
| .stat-grid.mode-random [data-stat-card="disabled"] { order:4 } |
| .stat-grid.mode-random [data-stat-card="total"] { order:5 } |
| .stat-grid.mode-random [data-stat-card="calls"] { order:6 } |
| .stat-grid.mode-random [data-stat-card="success"] { order:7 } |
| .stat-grid.mode-random [data-stat-card="rate"] { order:8 } |
| |
| |
| .filter-bar { |
| display:flex; |
| flex-direction:column; |
| align-items:flex-start; |
| gap:10px; |
| margin-bottom:16px; |
| width:100%; |
| } |
| .filter-group { |
| display:flex; |
| align-items:center; |
| gap:8px; |
| flex-wrap:wrap; |
| } |
| .filter-label { |
| width:52px; |
| flex:0 0 52px; |
| font-size:11px; |
| font-weight:600; |
| color:#9a9a9a; |
| letter-spacing:.01em; |
| text-align:right; |
| white-space:nowrap; |
| } |
| .filter-chip { |
| height:30px; |
| min-width:84px; |
| padding:0 12px; |
| border-radius:999px; |
| font-size:12px; |
| font-weight:500; |
| color:#8f8f8f; |
| background:#f5f5f5; |
| display:flex; |
| align-items:center; |
| justify-content:space-between; |
| gap:6px; |
| transition:background .15s, color .15s; |
| white-space:nowrap; |
| flex:0 0 auto; |
| } |
| .filter-chip:hover { color:#555; background:#f1f1f1 } |
| .filter-chip.active { |
| background:#111; |
| color:#fff; |
| } |
| .filter-chip-count { |
| min-width:18px; |
| height:18px; |
| padding:0 5px; |
| border-radius:999px; |
| background:rgba(255,255,255,.72); |
| font-size:11px; |
| font-weight:600; |
| color:inherit; |
| display:inline-flex; |
| align-items:center; |
| justify-content:center; |
| font-variant-numeric:tabular-nums; |
| } |
| .filter-chip.active .filter-chip-count { |
| background:rgba(255,255,255,.16); |
| color:#fff; |
| } |
| .filter-chip-count.is-green, |
| .filter-chip-count.is-orange, |
| .filter-chip-count.is-red, |
| .filter-chip-count.is-purple { color:inherit } |
| |
| |
| .table-toolbar { |
| display:flex; |
| align-items:center; |
| gap:8px; |
| margin-bottom:8px; |
| flex-wrap:wrap; |
| color:#8f8f8f; |
| font-size:12px; |
| font-weight:500; |
| font-family:inherit; |
| } |
| .table-toolbar-left { flex:1 } |
| .table-toolbar-actions { |
| display:flex; |
| align-items:center; |
| gap:8px; |
| flex-wrap:wrap; |
| } |
| .toolbar-icon-btn { |
| width:28px; |
| height:28px; |
| display:inline-flex; |
| align-items:center; |
| justify-content:center; |
| color:inherit; |
| flex:0 0 auto; |
| font:inherit; |
| } |
| .toolbar-icon-btn:hover { |
| color:#555; |
| } |
| .toolbar-icon-btn svg { |
| width:14px; |
| height:14px; |
| stroke:currentColor; |
| stroke-linecap:round; |
| stroke-linejoin:round; |
| fill:none; |
| } |
| .toolbar-icon-btn-danger { color:inherit; } |
| .toolbar-icon-btn-danger:hover { color:#555; } |
| .toolbar-icon-btn.is-active { |
| color:#111; |
| background:#f3f3f3; |
| border-radius:999px; |
| } |
| .toolbar-page-size { |
| width:94px !important; |
| height:28px; |
| border-radius:999px; |
| font:inherit; |
| color:inherit; |
| background:#fafafa; |
| border-color:transparent; |
| } |
| .toolbar-dropdown { |
| position:relative; |
| display:inline-flex; |
| align-items:center; |
| } |
| .toolbar-dropdown-panel { |
| position:absolute; |
| top:calc(100% + 10px); |
| right:0; |
| width:min(416px, calc(100vw - 24px)); |
| padding:12px; |
| border-radius:16px; |
| background:#FAF9F5; |
| border:1px solid #ece3d6; |
| box-shadow:0 12px 28px rgba(22,20,18,.07); |
| display:none; |
| z-index:30; |
| } |
| .toolbar-dropdown.open .toolbar-dropdown-panel { |
| display:block; |
| } |
| .toolbar-dropdown-panel .filter-bar { |
| gap:10px; |
| margin-bottom:0; |
| } |
| .toolbar-dropdown-panel .filter-group { |
| align-items:flex-start; |
| gap:8px; |
| width:100%; |
| flex-wrap:nowrap; |
| } |
| .filter-chip-list { |
| display:flex; |
| align-items:center; |
| gap:6px; |
| flex-wrap:nowrap; |
| min-width:0; |
| flex:1 1 auto; |
| overflow-x:auto; |
| overflow-y:hidden; |
| scrollbar-width:none; |
| -ms-overflow-style:none; |
| } |
| .filter-chip-list::-webkit-scrollbar { |
| display:none; |
| } |
| .toolbar-dropdown-panel .filter-label { |
| width:34px; |
| flex:0 0 34px; |
| padding-top:6px; |
| text-align:left; |
| } |
| .toolbar-dropdown-panel .filter-chip { |
| height:26px; |
| min-width:66px; |
| padding:0 10px; |
| font-size:11px; |
| gap:5px; |
| } |
| .toolbar-dropdown-panel .filter-chip-count { |
| min-width:16px; |
| height:16px; |
| padding:0 4px; |
| font-size:10px; |
| } |
| @media (max-width: 720px) { |
| .toolbar-dropdown-panel { |
| right:-8px; |
| width:min(320px, calc(100vw - 20px)); |
| } |
| .toolbar-dropdown-panel .filter-label { |
| width:32px; |
| flex:0 0 32px; |
| } |
| } |
| |
| |
| .table-card { |
| background:#fff; |
| border:none; |
| border-radius:14px; |
| overflow-x:auto; |
| overflow-y:hidden; |
| -webkit-overflow-scrolling:touch; |
| } |
| .table-card > table { width:max-content; min-width:100%; border-collapse:collapse } |
| .table-freeze-last { |
| --table-sticky-bg:#fff; |
| --table-sticky-hover-bg:#fdfdfd; |
| } |
| table { width:100%; border-collapse:collapse } |
| th { |
| background:#fff; |
| font-size:11px; |
| font-weight:500; |
| color:#9b9b9b; |
| padding:11px 16px; |
| text-align:left; |
| border-bottom:none; |
| letter-spacing:.01em; |
| } |
| td { |
| font-size:13px; |
| padding:13px 16px; |
| border-bottom:none; |
| vertical-align:middle; |
| color:#3f3f3f; |
| } |
| tr:last-child td { border-bottom:none } |
| tr:hover td { background:#fdfdfd } |
| .table-card .badge { |
| display:inline-flex; |
| align-items:center; |
| height:20px; |
| padding:0 8px; |
| border-radius:999px; |
| font-size:11px; |
| font-weight:500; |
| border:none; |
| } |
| .table-card .badge-basic { |
| color:#6d6d6d; |
| background:#f7f7f7; |
| } |
| .table-card .badge-super { |
| color:#4c76b2; |
| background:#f1f6fc; |
| } |
| .table-card .badge-heavy { |
| color:#b07a45; |
| background:#fbf5ed; |
| } |
| .table-card .badge-active { |
| color:#3e8f69; |
| background:#f2f8f4; |
| } |
| .table-card .badge-cooling { |
| color:#b47a3d; |
| background:#fbf5ed; |
| } |
| .table-card .badge-expired, |
| .table-card .badge-invalid, |
| .table-card .badge-failed { |
| color:#b66a63; |
| background:#fbf3f2; |
| } |
| .table-card .badge-disabled { |
| color:#6f675d; |
| background:#f1ece4; |
| } |
| .table-center { text-align:center } |
| .table-center .badge { margin:0 auto } |
| .token-stack { |
| display:flex; |
| align-items:center; |
| gap:8px; |
| min-width:0; |
| } |
| .row-actions { |
| display:flex; |
| align-items:center; |
| gap:10px; |
| flex-wrap:wrap; |
| } |
| .row-icon-btn { |
| width:22px; |
| height:22px; |
| display:inline-flex; |
| align-items:center; |
| justify-content:center; |
| color:#9a9a9a; |
| flex:0 0 auto; |
| } |
| .row-icon-btn svg { |
| width:13px; |
| height:13px; |
| stroke:currentColor; |
| stroke-linecap:round; |
| stroke-linejoin:round; |
| fill:none; |
| } |
| .row-icon-btn.row-icon-btn-state svg { |
| width:14.5px; |
| height:14.5px; |
| } |
| .row-icon-btn:hover { color:#555 } |
| .row-icon-btn.is-loading { |
| color:#555; |
| pointer-events:none; |
| } |
| .row-icon-btn.is-loading svg { |
| animation:spin .85s linear infinite; |
| } |
| .row-action { |
| color:#929292; |
| font-size:11px; |
| font-weight:500; |
| display:inline-flex; |
| align-items:center; |
| justify-content:center; |
| } |
| .row-action:hover { color:#555 } |
| .row-action:disabled { |
| opacity:1; |
| cursor:default; |
| } |
| .row-action-danger { |
| color:#b7726a; |
| } |
| .row-action-danger:hover { color:#92514b } |
| .row-icon-danger { color:#b7726a } |
| .row-icon-danger:hover { color:#92514b } |
| .row-action-static { |
| color:#8b6bc7; |
| } |
| .admin-tooltip { |
| position:fixed; |
| left:0; |
| top:0; |
| z-index:500; |
| max-width:220px; |
| padding:6px 8px; |
| border-radius:8px; |
| background:rgba(17,17,17,.94); |
| color:#fff; |
| font-size:11px; |
| line-height:1.35; |
| white-space:normal; |
| pointer-events:none; |
| opacity:0; |
| transform:translate3d(0,0,0); |
| transition:opacity .08s ease; |
| } |
| .admin-tooltip.show { opacity:1 } |
| .row-nsfw-btn { |
| width:22px; |
| height:22px; |
| display:inline-flex; |
| align-items:center; |
| justify-content:center; |
| color:#9a9a9a; |
| flex:0 0 auto; |
| } |
| .row-nsfw-btn:hover { color:#555 } |
| .row-nsfw-btn.is-on { color:#b7726a } |
| .row-nsfw-btn.is-on:hover { color:#92514b } |
| .row-nsfw-btn svg { |
| width:15px; |
| height:15px; |
| overflow:visible; |
| } |
| .row-nsfw-btn circle, |
| .row-nsfw-btn path { |
| stroke:currentColor; |
| stroke-width:1.7; |
| stroke-linecap:round; |
| stroke-linejoin:round; |
| fill:none; |
| } |
| .row-nsfw-btn text { |
| fill:currentColor; |
| font-family:var(--font-sans); |
| font-size:7.2px; |
| font-weight:700; |
| text-anchor:middle; |
| dominant-baseline:central; |
| } |
| .table-card .empty-state { |
| padding:48px 16px; |
| color:#8a8a8a; |
| font-size:13px; |
| } |
| @keyframes spin { |
| from { transform:rotate(0deg) } |
| to { transform:rotate(360deg) } |
| } |
| |
| |
| .quota-cell { display:flex; gap:8px; align-items:center } |
| .q-pill { |
| height:20px; |
| font-size:11px; |
| font-weight:500; |
| padding:0 7px; |
| border-radius:999px; |
| display:inline-flex; |
| align-items:center; |
| } |
| .q-a { background:#f1f6fd; color:#5d7fb2 } |
| .q-f { background:#f2f8f4; color:#4c9168 } |
| .q-e { background:#f7f3fb; color:#8d6bbd } |
| .q-h { background:#fff4ed; color:#b0632a } |
| .q-b { background:#fdf2f7; color:#be3f7f } |
| .q-z { background:#f6f6f6; color:#a0a0a6 } |
| |
| |
| .tok { font-family:ui-monospace,monospace; font-size:12px; color:#333 } |
| |
| |
| .pagi { display:flex; align-items:center; gap:6px; padding:12px 16px; border-top:none } |
| .pagi-meta { |
| display:flex; |
| align-items:center; |
| gap:0; |
| color:inherit; |
| font:inherit; |
| margin:0 -2px; |
| } |
| .pagi-page { |
| min-width:0; |
| height:28px; |
| padding:0 4px; |
| font:inherit; |
| color:inherit; |
| display:inline-flex; |
| align-items:center; |
| justify-content:center; |
| font-variant-numeric:tabular-nums; |
| } |
| .pagi-btn { |
| width:28px; |
| height:28px; |
| border-radius:6px; |
| display:inline-flex; |
| align-items:center; |
| justify-content:center; |
| color:inherit; |
| transition:background .1s, color .1s; |
| font:inherit; |
| } |
| .pagi-btn:hover { background:var(--border) } |
| .pagi-btn.on { background:var(--fg); color:#fff } |
| .pagi-btn:disabled { opacity:.35; pointer-events:none } |
| |
| |
| #modal-import .modal, |
| #modal-import-file .modal { width:min(440px,92vw) } |
| #modal-export .modal { |
| width:min(440px,92vw); |
| padding-top:14px; |
| } |
| #modal-export .modal-title { margin-bottom:6px } |
| #modal-export .dialog-help { |
| margin-top:0; |
| margin-bottom:12px; |
| } |
| #modal-export .dialog-body { gap:10px } |
| .export-option-btn { |
| width:100%; |
| min-height:34px; |
| height:auto; |
| padding:6px 12px; |
| display:flex; |
| align-items:center; |
| justify-content:center; |
| text-align:center; |
| white-space:normal; |
| line-height:1.35; |
| } |
| .modal { box-shadow:none } |
| .dialog-body { display:grid; gap:12px } |
| .dialog-help { |
| font-size:12px; |
| color:var(--fg-muted); |
| margin-top:6px; |
| margin-bottom:2px; |
| } |
| .dialog-field { display:flex; align-items:center; gap:10px } |
| .dialog-label { |
| width:40px; |
| font-size:13px; |
| color:var(--fg-muted); |
| white-space:nowrap; |
| flex:0 0 auto; |
| } |
| .dialog-textarea { |
| min-height:140px; |
| padding:10px 12px; |
| border-radius:12px; |
| resize:vertical; |
| } |
| .dialog-actions { |
| display:flex; |
| justify-content:flex-end; |
| gap:8px; |
| margin-top:18px; |
| } |
| .dialog-btn { |
| height:32px; |
| padding:0 14px; |
| border-radius:999px; |
| background:#fafafa; |
| color:#444; |
| font-size:13px; |
| font-weight:600; |
| } |
| .dialog-btn:hover { |
| background:#f3f3f3; |
| } |
| .dialog-btn-primary { |
| background:#111; |
| color:#fff; |
| } |
| .dialog-btn-primary:hover { |
| background:#222; |
| } |
| .dialog-btn-danger { |
| color:#b42318; |
| background:#fff5f4; |
| } |
| .dialog-btn-danger:hover { |
| background:#feeceb; |
| } |
| |
| |
| .import-file-input { position:absolute; width:1px; height:1px; opacity:0; pointer-events:none } |
| .import-file-shell { |
| height:34px; |
| display:flex; |
| align-items:center; |
| padding:0 10px; |
| border-radius:8px; |
| background:#f7f7f7; |
| cursor:pointer; |
| transition:background .15s; |
| flex:1; |
| min-width:0; |
| } |
| .import-file-shell:hover { |
| background:#f2f2f2; |
| } |
| .import-file-name { |
| font-size:12px; |
| color:#666; |
| white-space:nowrap; |
| overflow:hidden; |
| text-overflow:ellipsis; |
| min-width:0; |
| flex:1; |
| } |
| .import-file-name.empty { color:#999 } |
| </style> |
| </head> |
| <body> |
|
|
| |
| <div id="admin-header" data-active="/admin/account"></div> |
|
|
| |
| <main class="admin-main"> |
|
|
| |
| <div class="page-hd"> |
| <div> |
| <div class="page-title" data-i18n="account.pageHeading">่ดฆๆทๅ่กจ</div> |
| <div class="page-sub" data-i18n="account.pageSubtitle">็ฎก็ Grok2API ็ xAI ๆๅก่ดฆๆทๆฑ ใ</div> |
| </div> |
| <div class="page-actions"> |
| <button onclick="openImport()" class="page-action-btn"> |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> |
| <span data-i18n="account.actionImportShort">ๅฏผๅ
ฅ</span> |
| </button> |
| <button onclick="openAdd()" class="page-action-btn page-action-btn-primary"> |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> |
| <span data-i18n="account.actionAddShort">ๆฐๅข</span> |
| </button> |
| </div> |
| </div> |
|
|
| <div class="section-head"> |
| <div class="section-title" data-i18n="account.sectionOverview">่ดฆๆทๆฆ่ง</div> |
| </div> |
|
|
| |
| <div class="stat-grid" id="overview-stats"> |
| <div class="stat-cell" data-stat-card="total"> |
| <div class="stat-top"> |
| <div class="stat-label" data-i18n="account.statTotal">่ดฆๆทๆปๆฐ</div> |
| <span class="stat-icon"> |
| <svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><path d="M4 19a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4"/><circle cx="12" cy="8" r="4"/></svg> |
| </span> |
| </div> |
| <div class="stat-num" id="s-total">0</div> |
| </div> |
| <div class="stat-cell" data-stat-card="active"> |
| <div class="stat-top"> |
| <div class="stat-label" data-i18n="account.statActive">ๆญฃๅธธ่ดฆๆท</div> |
| <span class="stat-icon" style="color:#16a34a"> |
| <svg viewBox="0 0 24 24" fill="none" stroke-width="1.9"><circle cx="12" cy="12" r="8"/><path d="m8.5 12 2.4 2.4 4.8-4.8"/></svg> |
| </span> |
| </div> |
| <div class="stat-num" id="s-active" style="color:#16a34a">0</div> |
| </div> |
| <div class="stat-cell" data-stat-card="cooling"> |
| <div class="stat-top"> |
| <div class="stat-label" data-i18n="account.statCooling">้ๆต่ดฆๆท</div> |
| <span class="stat-icon" style="color:#ea580c"> |
| <svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><path d="M12 6v6l4 2"/><circle cx="12" cy="12" r="8"/></svg> |
| </span> |
| </div> |
| <div class="stat-num" id="s-cooling" style="color:#ea580c">0</div> |
| </div> |
| <div class="stat-cell" data-stat-card="invalid"> |
| <div class="stat-top"> |
| <div class="stat-label" data-i18n="account.statInvalid">ๅผๅธธ่ดฆๆท</div> |
| <span class="stat-icon" style="color:#dc2626"> |
| <svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><path d="m15 9-6 6m0-6 6 6"/><circle cx="12" cy="12" r="8"/></svg> |
| </span> |
| </div> |
| <div class="stat-num" id="s-invalid" style="color:#dc2626">0</div> |
| </div> |
| <div class="stat-cell" data-stat-card="disabled"> |
| <div class="stat-top"> |
| <div class="stat-label" data-i18n="account.statDisabled">็ฆ็จ่ดฆๆท</div> |
| <span class="stat-icon" style="color:#6f675d"> |
| <svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><circle cx="12" cy="12" r="8"/><path d="M8.5 8.5 15.5 15.5"/></svg> |
| </span> |
| </div> |
| <div class="stat-num" id="s-disabled" style="color:#6f675d">0</div> |
| </div> |
| <div class="stat-cell" data-stat-card="calls"> |
| <div class="stat-top"> |
| <div class="stat-label" data-i18n="account.statCalls">่ฐ็จๆปๆฐ</div> |
| <span class="stat-icon" style="color:#111"> |
| <svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><path d="M5 19h14"/><path d="M8 16V10"/><path d="M12 16V6"/><path d="M16 16v-3"/></svg> |
| </span> |
| </div> |
| <div class="stat-num" id="s-calls" style="color:#111">0</div> |
| </div> |
| <div class="stat-cell" data-stat-card="success" data-random-only> |
| <div class="stat-top"> |
| <div class="stat-label" data-i18n="account.statSuccessTotal">ๆๅๆปๆฐ</div> |
| <span class="stat-icon" style="color:#2563eb"> |
| <svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><path d="M7 12.5 10 15.5 17 8.5"/><circle cx="12" cy="12" r="8"/></svg> |
| </span> |
| </div> |
| <div class="stat-num" id="s-success" style="color:#2563eb">0</div> |
| </div> |
| <div class="stat-cell" data-stat-card="rate" data-random-only> |
| <div class="stat-top"> |
| <div class="stat-label" data-i18n="account.colRate">ๆๅ็</div> |
| <span class="stat-icon" style="color:#7c3aed"> |
| <svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><path d="M6 17 10 13 13 16 18 9"/><path d="M18 9h-4"/><path d="M18 9v4"/></svg> |
| </span> |
| </div> |
| <div class="stat-num" id="s-rate" style="color:#7c3aed">0%</div> |
| </div> |
| <div class="stat-cell" data-quota-only> |
| <div class="stat-top"> |
| <div class="stat-label" data-i18n="account.statAuto">Auto ไฝ้ข</div> |
| <span class="stat-icon" style="color:#2563eb"> |
| <svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><path d="M6 19h12"/><path d="M12 16V9"/></svg> |
| </span> |
| </div> |
| <div class="stat-num" id="s-qa" style="color:#2563eb">0</div> |
| </div> |
| <div class="stat-cell" data-quota-only> |
| <div class="stat-top"> |
| <div class="stat-label" data-i18n="account.statFast">Fast ไฝ้ข</div> |
| <span class="stat-icon" style="color:#16a34a"> |
| <svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><path d="M6 19h12"/><path d="M10 16v-4"/><path d="M14 16V8"/></svg> |
| </span> |
| </div> |
| <div class="stat-num" id="s-qf" style="color:#16a34a">0</div> |
| </div> |
| <div class="stat-cell" data-quota-only> |
| <div class="stat-top"> |
| <div class="stat-label" data-i18n="account.statExpert">Expert ไฝ้ข</div> |
| <span class="stat-icon" style="color:#7c3aed"> |
| <svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><path d="M6 19h12"/><path d="M8 16v-2"/><path d="M12 16v-6"/><path d="M16 16V6"/></svg> |
| </span> |
| </div> |
| <div class="stat-num" id="s-qe" style="color:#7c3aed">0</div> |
| </div> |
| <div class="stat-cell" data-quota-only> |
| <div class="stat-top"> |
| <div class="stat-label" data-i18n="account.statHeavy">Heavy ไฝ้ข</div> |
| <span class="stat-icon" style="color:#b0632a"> |
| <svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><path d="M5 19h14"/><path d="M7 16v-1"/><path d="M10 16v-4"/><path d="M14 16V8"/><path d="M17 16V5"/></svg> |
| </span> |
| </div> |
| <div class="stat-num" id="s-qh" style="color:#b0632a">0</div> |
| </div> |
| </div> |
|
|
| <div class="section-head" style="margin-top:40px"> |
| <div class="section-title-row"> |
| <div class="section-title" data-i18n="account.sectionTable">่ดฆๆทๆ็ป</div> |
| <span class="section-count-badge" id="section-table-count">0</span> |
| </div> |
| </div> |
|
|
| |
| <div class="table-toolbar"> |
| <div class="table-toolbar-left" style="display:flex;align-items:center;gap:6px"> |
| <button onclick="prevPage()" class="pagi-btn" id="btn-prev"> |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg> |
| </button> |
| <div class="pagi-meta"> |
| <span class="pagi-page" id="pagi-page">0 / 0</span> |
| </div> |
| <button onclick="nextPage()" class="pagi-btn" id="btn-next"> |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg> |
| </button> |
| </div> |
| <div class="table-toolbar-actions"> |
| <div class="toolbar-dropdown" id="filter-menu"> |
| <button type="button" id="btn-filter" class="toolbar-icon-btn" aria-haspopup="menu" aria-expanded="false"> |
| <svg viewBox="0 0 24 24" stroke-width="1.8"><path d="M4 6h16"/><path d="M7 12h10"/><path d="M10 18h4"/></svg> |
| </button> |
| <div class="toolbar-dropdown-panel" id="filter-panel" role="menu" aria-labelledby="btn-filter"> |
| <div class="filter-bar"> |
| <div class="filter-group"> |
| <span class="filter-label" data-i18n="account.filterStatusLabel">็ถๆ</span> |
| <div class="filter-chip-list"> |
| <button type="button" class="filter-chip active" data-status="all" onclick="switchStatus('all')"> |
| <span data-i18n="account.filterStatusAll">ๅ
จ้จ</span> |
| <span class="filter-chip-count" id="fc-status-all">0</span> |
| </button> |
| <button type="button" class="filter-chip" data-status="active" onclick="switchStatus('active')"> |
| <span data-i18n="account.filterStatusActive">ๆญฃๅธธ</span> |
| <span class="filter-chip-count is-green" id="fc-status-active">0</span> |
| </button> |
| <button type="button" class="filter-chip" data-status="cooling" onclick="switchStatus('cooling')"> |
| <span data-i18n="account.filterStatusCooling">้ๆต</span> |
| <span class="filter-chip-count is-orange" id="fc-status-cooling">0</span> |
| </button> |
| <button type="button" class="filter-chip" data-status="invalid" onclick="switchStatus('invalid')"> |
| <span data-i18n="account.filterStatusInvalid">ๅผๅธธ</span> |
| <span class="filter-chip-count is-red" id="fc-status-invalid">0</span> |
| </button> |
| <button type="button" class="filter-chip" data-status="disabled" onclick="switchStatus('disabled')"> |
| <span data-i18n="account.filterStatusDisabled">็ฆ็จ</span> |
| <span class="filter-chip-count" id="fc-status-disabled">0</span> |
| </button> |
| </div> |
| </div> |
| <div class="filter-group" id="pool-filter-group"> |
| <span class="filter-label" data-i18n="account.filterTypeLabel">็ฑปๅ</span> |
| <div class="filter-chip-list" id="pool-filter-chips"></div> |
| </div> |
| <div class="filter-group"> |
| <span class="filter-label" data-i18n="account.filterNsfwLabel">NSFW</span> |
| <div class="filter-chip-list"> |
| <button type="button" class="filter-chip active" data-nsfw="all" onclick="switchNsfw('all')"> |
| <span data-i18n="account.filterNsfwAll">ๅ
จ้จ</span> |
| <span class="filter-chip-count" id="fc-nsfw-all">0</span> |
| </button> |
| <button type="button" class="filter-chip" data-nsfw="enabled" onclick="switchNsfw('enabled')"> |
| <span data-i18n="account.filterNsfwEnabled">ๅผๅฏ</span> |
| <span class="filter-chip-count is-purple" id="fc-nsfw-enabled">0</span> |
| </button> |
| <button type="button" class="filter-chip" data-nsfw="disabled" onclick="switchNsfw('disabled')"> |
| <span data-i18n="account.filterNsfwDisabled">ๅ
ณ้ญ</span> |
| <span class="filter-chip-count" id="fc-nsfw-disabled">0</span> |
| </button> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| <button type="button" onclick="doExport()" id="btn-export" class="toolbar-icon-btn"> |
| <svg viewBox="0 0 24 24" stroke-width="1.8"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/></svg> |
| </button> |
| <button type="button" onclick="batchNSFW()" id="btn-nsfw" class="row-nsfw-btn" style="display:none"> |
| <svg viewBox="0 0 20 20" aria-hidden="true"> |
| <circle cx="10" cy="10" r="7.25"/> |
| <text x="10" y="10.4">18</text> |
| </svg> |
| </button> |
| <button type="button" onclick="batchRefreshSel()" id="btn-refresh" class="toolbar-icon-btn" style="display:none"> |
| <svg viewBox="0 0 24 24" stroke-width="1.8"><path d="M20 11a8 8 0 0 0-14.6-4.6"/><path d="M4 4v5h5"/><path d="M4 13a8 8 0 0 0 14.6 4.6"/><path d="M20 20v-5h-5"/></svg> |
| </button> |
| <button type="button" onclick="batchDisableSel()" id="btn-disable" class="toolbar-icon-btn" style="display:none"> |
| <svg viewBox="0 0 24 24" stroke-width="1.8"><circle cx="12" cy="12" r="8"/><path d="M8.5 8.5 15.5 15.5"/></svg> |
| </button> |
| <button type="button" onclick="batchRestoreSel()" id="btn-restore" class="toolbar-icon-btn" style="display:none"> |
| <svg viewBox="0 0 24 24" stroke-width="1.8"><path d="M3 12a9 9 0 1 0 3-6.708"/><path d="M3 4v5h5"/></svg> |
| </button> |
| <button type="button" onclick="batchDeleteSel()" id="btn-delete" class="toolbar-icon-btn toolbar-icon-btn-danger" style="display:none"> |
| <svg viewBox="0 0 24 24" stroke-width="1.8"><path d="M5 7h14"/><path d="M9 7V4h6v3"/><path d="M8 10v7"/><path d="M12 10v7"/><path d="M16 10v7"/><path d="M7 7l1 13h8l1-13"/></svg> |
| </button> |
| <button type="button" onclick="cancelBatch()" id="btn-batch-cancel" class="toolbar-icon-btn toolbar-icon-btn-danger" style="display:none"> |
| <svg viewBox="0 0 24 24" stroke-width="1.8"><path d="M6 6 18 18"/><path d="M18 6 6 18"/></svg> |
| </button> |
| <select class="input toolbar-page-size" id="page-size-sel" onchange="changePageSize(this.value)"> |
| <option value="50">50 / ้กต</option> |
| <option value="100">100 / ้กต</option> |
| <option value="200">200 / ้กต</option> |
| <option value="500">500 / ้กต</option> |
| <option value="1000">1000 / ้กต</option> |
| <option value="2000">2000 / ้กต</option> |
| </select> |
| </div> |
| </div> |
|
|
| |
| <div class="table-card table-freeze-last"> |
| <table> |
| <thead> |
| <tr> |
| <th style="width:36px"><input type="checkbox" class="cb" id="cb-all" onchange="toggleAll(this.checked)"></th> |
| <th style="width:200px" data-i18n="account.colToken">Token</th> |
| <th class="table-center" style="width:70px" data-i18n="account.colType">็ฑปๅ</th> |
| <th class="table-center" style="width:70px" data-i18n="account.colStatus">็ถๆ</th> |
| <th data-quota-only> |
| <span data-i18n="account.colQuota">้ขๅบฆ</span> |
| <span style="margin-left:6px;display:inline-flex;align-items:center;gap:3px;vertical-align:middle"> |
| <span class="q-pill q-a">A</span> |
| <span class="q-pill q-f">F</span> |
| <span class="q-pill q-e">E</span> |
| <span class="q-pill q-h">H</span> |
| </span> |
| </th> |
| <th class="table-center" data-i18n="account.colSuccess">ๆๅ</th> |
| <th class="table-center" data-i18n="account.colFail">ๅคฑ่ดฅ</th> |
| <th class="table-center" data-i18n="account.colRate">ๆๅ็</th> |
| <th data-i18n="account.colLastUsed">ๆ่ฟไฝฟ็จ</th> |
| <th data-i18n="account.colActions">ๆไฝ</th> |
| </tr> |
| </thead> |
| <tbody id="tbody"></tbody> |
| </table> |
| </div> |
|
|
| </main> |
|
|
| |
| <div class="modal-overlay" id="modal-import"> |
| <div class="modal"> |
| <div class="modal-title" data-i18n="account.addTitle">ๆทปๅ ่ดฆๆท</div> |
| <div class="dialog-body"> |
| <div> |
| <div class="dialog-help" data-i18n="account.addHint">ๆฏ่กไธไธช Token๏ผๅทฒๅญๅจ็ไผ่ชๅจ่ทณ่ฟ</div> |
| <textarea class="input dialog-textarea" id="import-tokens" rows="8" placeholder="็ฒ่ดด Token๏ผๆฏ่กไธไธช..." data-i18n-placeholder="account.addPlaceholder"></textarea> |
| </div> |
| <div class="dialog-field"> |
| <span class="dialog-label" data-i18n="account.labelType">็ฑปๅ</span> |
| <select class="input" id="import-pool" style="flex:1"></select> |
| </div> |
| </div> |
| <div class="dialog-actions"> |
| <button onclick="closeModal('modal-import')" class="dialog-btn" data-i18n="account.cancel">ๅๆถ</button> |
| <button onclick="doAdd()" class="dialog-btn dialog-btn-primary" data-i18n="account.actionAdd">ๆทปๅ </button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="modal-overlay" id="modal-import-file"> |
| <div class="modal"> |
| <div class="modal-title" data-i18n="account.importTitle">ๅฏผๅ
ฅๆไปถ</div> |
| <div class="dialog-body"> |
| <div class="dialog-help" id="import-file-hint" data-i18n="account.importHintDefault">ๆฏๆ .txt๏ผๆฏ่กไธไธช Token๏ผๆ .json๏ผ{pool:[tokens]} ๆ ผๅผ๏ผ</div> |
| <div class="dialog-field"> |
| <span class="dialog-label" data-i18n="account.labelFile">ๆไปถ</span> |
| <input class="import-file-input" id="import-file" type="file" accept=".txt,.json,text/plain,application/json" onchange="updateImportFileState()"> |
| <label for="import-file" class="import-file-shell"> |
| <div class="import-file-name empty" id="import-file-name" data-i18n="account.noFile">ๆช้ๆฉๆไปถ</div> |
| </label> |
| </div> |
| <div class="dialog-field" id="import-file-pool-row"> |
| <span class="dialog-label" data-i18n="account.labelType">็ฑปๅ</span> |
| <select class="input" id="import-file-pool" style="flex:1"></select> |
| </div> |
| </div> |
| <div class="dialog-actions"> |
| <button onclick="closeModal('modal-import-file')" class="dialog-btn" data-i18n="account.cancel">ๅๆถ</button> |
| <button onclick="doImport()" class="dialog-btn dialog-btn-primary" data-i18n="account.actionImport">ๅฏผๅ
ฅ</button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="modal-overlay" id="modal-edit"> |
| <div class="modal"> |
| <div class="modal-title" data-i18n="account.editTitle">็ผ่พ่ดฆๆท</div> |
| <div class="dialog-body"> |
| <div class="dialog-field"> |
| <span class="dialog-label" data-i18n="account.colToken">Token</span> |
| <input class="input" id="edit-token" placeholder="่พๅ
ฅๆฐ็ Token" data-i18n-placeholder="account.editTokenPlaceholder"> |
| </div> |
| <div class="dialog-field"> |
| <span class="dialog-label" data-i18n="account.labelType">็ฑปๅ</span> |
| <select class="input" id="edit-pool" style="flex:1"></select> |
| </div> |
| </div> |
| <div class="dialog-actions"> |
| <button onclick="closeModal('modal-edit')" class="dialog-btn" data-i18n="account.cancel">ๅๆถ</button> |
| <button onclick="doEdit()" class="dialog-btn dialog-btn-primary" data-i18n="account.save">ไฟๅญ</button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="modal-overlay" id="modal-export"> |
| <div class="modal" style="max-width:360px"> |
| <div class="modal-title" data-i18n="account.exportTitle">ๅฏผๅบๆ ผๅผ</div> |
| <div class="dialog-help"><span data-i18n="account.exportCountPrefix">ๅ
ฑ </span><b id="exp-count">0</b><span data-i18n="account.exportCountSuffix"> ไธช Token</span></div> |
| <div class="dialog-body" style="gap:8px"> |
| <button id="exp-json-btn" class="dialog-btn dialog-btn-primary export-option-btn" data-i18n="account.exportJson">JSONๆ ผๅผ๏ผๅฎๆดๅฏผๅบ๏ผ</button> |
| <button id="exp-txt-btn" class="dialog-btn export-option-btn" data-i18n="account.exportTxt">TXTๆ ผๅผ๏ผไป
ๅฏผๅบ Token๏ผ</button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="modal-overlay" id="modal-confirm"> |
| <div class="modal" style="max-width:400px"> |
| <div class="modal-title" id="confirm-title" data-i18n="account.confirmTitle">็กฎ่ฎค</div> |
| <div id="confirm-body" class="dialog-help"></div> |
| <div class="dialog-actions"> |
| <button onclick="closeModal('modal-confirm')" class="dialog-btn" data-i18n="account.cancel">ๅๆถ</button> |
| <button onclick="_cb&&_cb()" class="dialog-btn dialog-btn-danger" data-i18n="account.confirm">็กฎ่ฎค</button> |
| </div> |
| </div> |
| </div> |
|
|
| <script src="/static/js/i18n.js?v={{APP_VERSION}}"></script> |
| <script src="/static/js/auth.js?v={{APP_VERSION}}"></script> |
| <script src="/static/js/admin-header.js?v={{APP_VERSION}}"></script> |
| <script src="/static/js/toast.js?v={{APP_VERSION}}"></script> |
| <script src="/static/js/footer.js?v={{APP_VERSION}}"></script> |
| <script> |
| |
| const PAGE_SIZE_KEY = 'admin.account.page_size'; |
| const PAGE_SIZE_OPTIONS = [50, 100, 200, 500, 1000, 2000]; |
| |
| let allTokens = [], curStatus = 'all', curNsfw = 'all', curPool = 'all', curPage = 1, pageSize = loadSavedPageSize(); |
| const sel = new Set(); |
| const refreshingTokens = new Set(); |
| let _cb = null; |
| let _editingToken = ''; |
| let _filterMenuBound = false; |
| let _tokenViewVersion = 0; |
| let _tokenViewCacheKey = ''; |
| let _tokenViewCache = null; |
| |
| function invalidateTokenView() { |
| _tokenViewVersion += 1; |
| _tokenViewCacheKey = ''; |
| _tokenViewCache = null; |
| } |
| |
| function tr(key, params, fallback) { |
| const value = t(key, params); |
| return value === key ? (fallback ?? key) : value; |
| } |
| |
| function applyAccountI18n() { |
| document.title = tr('account.pageTitle', null, 'Grok2API - ่ดฆๆท็ฎก็'); |
| document.querySelectorAll('#page-size-sel option').forEach(opt => { |
| opt.textContent = tr('account.pageSizeOption', { n: opt.value }, `${opt.value} / ้กต`); |
| }); |
| const toolbarTips = [ |
| ['btn-filter', 'account.filter', '็ญ้'], |
| ['btn-export', 'account.export', 'ๅฏผๅบๆฐๆฎ'], |
| ['btn-nsfw', 'account.batchNsfw', 'ๅผๅฏ NSFW'], |
| ['btn-refresh', 'account.batchRefresh', 'ๅทๆฐ้ไธญ'], |
| ['btn-disable', 'account.batchDisable', '็ฆ็จ้ไธญ'], |
| ['btn-restore', 'account.batchRestore', 'ๆขๅค้ไธญ'], |
| ['btn-delete', 'account.batchDelete', 'ๅ ้ค้ไธญ'], |
| ['btn-batch-cancel', 'account.cancel', 'ๅๆถ'], |
| ]; |
| toolbarTips.forEach(([id, key, fallback]) => { |
| const el = document.getElementById(id); |
| if (!el) return; |
| const label = tr(key, null, fallback); |
| el.setAttribute('data-tip', label); |
| el.setAttribute('aria-label', label); |
| el.setAttribute('title', label); |
| }); |
| const pageSizeSel = document.getElementById('page-size-sel'); |
| if (pageSizeSel) pageSizeSel.value = String(pageSize); |
| updateImportFileState(); |
| render(); |
| } |
| |
| function waitI18n() { |
| return new Promise((resolve) => I18n.onReady(resolve)); |
| } |
| |
| |
| (async () => { |
| await waitI18n(); |
| applyAccountI18n(); |
| await renderAdminHeader?.(); |
| await renderSiteFooter?.(); |
| initFilterMenu(); |
| const key = await adminKey.get(); |
| if (!key || !await verifyKey(ADMIN_API + '/verify', key).catch(() => false)) |
| return location.href = '/admin/login'; |
| load(); |
| })(); |
| |
| |
| async function _api(method, path, body) { |
| const key = await adminKey.get(); |
| const r = await fetch(ADMIN_API + path, { |
| method, |
| headers: { ...(body != null && { 'Content-Type': 'application/json' }), Authorization: `Bearer ${key}` }, |
| ...(body != null && { body: JSON.stringify(body) }), |
| }); |
| if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.detail || r.status); } |
| return r.json(); |
| } |
| |
| |
| |
| let selectionStrategy = 'quota'; |
| |
| |
| async function load() { |
| try { |
| const [data, status] = await Promise.all([ |
| _api('GET', '/tokens'), |
| _api('GET', '/status').catch(() => null), |
| ]); |
| if (status && typeof status.selection_strategy === 'string') { |
| selectionStrategy = status.selection_strategy; |
| } |
| allTokens = Array.isArray(data.tokens) |
| ? data.tokens |
| : Object.entries(data.tokens || {}).flatMap(([pool, items]) => |
| Array.isArray(items) ? items.map(t => ({ ...t, pool: t.pool || pool })) : []); |
| applyStrategyUI(); |
| invalidateTokenView(); |
| render(); |
| } catch (e) { showToast(`${tr('account.loadFailed', null, 'ๅ ่ฝฝๅคฑ่ดฅ')}: ${e.message}`, 'error'); } |
| } |
| |
| function applyStrategyUI() { |
| |
| const isRandom = selectionStrategy === 'random'; |
| const statsGrid = document.getElementById('overview-stats'); |
| if (statsGrid) statsGrid.classList.toggle('mode-random', isRandom); |
| document.querySelectorAll('[data-quota-only]').forEach(el => { |
| el.style.display = isRandom ? 'none' : ''; |
| }); |
| } |
| |
| |
| function render() { |
| const view = getTokenView(); |
| renderStats(view); |
| renderFilters(view); |
| renderTable(view); |
| applyStrategyUI(); |
| } |
| |
| function getTokenView() { |
| const cacheKey = `${_tokenViewVersion}|${curStatus}|${curNsfw}|${curPool}`; |
| if (_tokenViewCache && _tokenViewCacheKey === cacheKey) return _tokenViewCache; |
| |
| const stats = { |
| active: 0, |
| cooling: 0, |
| invalid: 0, |
| disabled: 0, |
| calls: 0, |
| success: 0, |
| fail: 0, |
| qa: 0, |
| qf: 0, |
| qe: 0, |
| qh: 0, |
| }; |
| const statusCounts = { all: 0, active: 0, cooling: 0, invalid: 0, disabled: 0 }; |
| const nsfwCounts = { all: 0, enabled: 0, disabled: 0 }; |
| const poolCounts = new Map([['all', 0]]); |
| const poolsSet = new Set(['basic', 'super', 'heavy']); |
| const filteredItems = []; |
| |
| for (const token of allTokens) { |
| const pool = token.pool || 'basic'; |
| const quota = token.quota || {}; |
| const nsfwEnabled = (token.tags || []).includes('nsfw'); |
| const invalid = isInvalidStatus(token.status); |
| const disabled = isDisabledStatus(token.status); |
| const matchesPool = curPool === 'all' || pool === curPool; |
| const matchesStatus = curStatus === 'all' |
| || (curStatus === 'invalid' ? invalid : curStatus === 'disabled' ? disabled : token.status === curStatus); |
| const matchesNsfw = curNsfw === 'all' || (curNsfw === 'enabled' ? nsfwEnabled : !nsfwEnabled); |
| |
| poolsSet.add(pool); |
| const successCount = token.use_count || 0; |
| const failCount = token.fail_count || 0; |
| stats.success += successCount; |
| stats.fail += failCount; |
| stats.calls += successCount + failCount; |
| stats.qa += quota.auto?.remaining || 0; |
| stats.qf += quota.fast?.remaining || 0; |
| stats.qe += quota.expert?.remaining || 0; |
| stats.qh += quota.heavy?.remaining || 0; |
| |
| if (token.status === 'active') stats.active += 1; |
| if (token.status === 'cooling') stats.cooling += 1; |
| if (invalid) stats.invalid += 1; |
| if (disabled) stats.disabled += 1; |
| |
| if (matchesPool && matchesNsfw) { |
| statusCounts.all += 1; |
| if (token.status === 'active') statusCounts.active += 1; |
| if (token.status === 'cooling') statusCounts.cooling += 1; |
| if (invalid) statusCounts.invalid += 1; |
| if (disabled) statusCounts.disabled += 1; |
| } |
| |
| if (matchesPool && matchesStatus) { |
| nsfwCounts.all += 1; |
| if (nsfwEnabled) nsfwCounts.enabled += 1; |
| else nsfwCounts.disabled += 1; |
| } |
| |
| if (matchesStatus && matchesNsfw) { |
| poolCounts.set('all', poolCounts.get('all') + 1); |
| poolCounts.set(pool, (poolCounts.get(pool) || 0) + 1); |
| } |
| |
| if (matchesPool && matchesStatus && matchesNsfw) filteredItems.push(token); |
| } |
| |
| if (curPool !== 'all' && !poolsSet.has(curPool)) { |
| curPool = 'all'; |
| return getTokenView(); |
| } |
| |
| _tokenViewCacheKey = `${_tokenViewVersion}|${curStatus}|${curNsfw}|${curPool}`; |
| _tokenViewCache = { |
| stats, |
| statusCounts, |
| nsfwCounts, |
| poolCounts, |
| pools: Array.from(poolsSet), |
| filteredItems, |
| }; |
| return _tokenViewCache; |
| } |
| |
| function poolLabel(pool) { |
| return tr(`account.pool.${pool}`, null, pool); |
| } |
| |
| function isDisabledStatus(status) { |
| return status === 'disabled'; |
| } |
| |
| function isInvalidStatus(status) { |
| return !['active', 'cooling', 'disabled'].includes(status); |
| } |
| |
| function renderStats(view = getTokenView()) { |
| const { stats } = view; |
| $('s-total', allTokens.length); |
| $('s-active', stats.active); |
| $('s-cooling', stats.cooling); |
| $('s-invalid', stats.invalid); |
| $('s-disabled', stats.disabled); |
| $('s-calls', fmt(stats.calls)); |
| $('s-success', fmt(stats.success)); |
| $('s-rate', fmtRate(stats.success, stats.fail)); |
| $('s-qa', fmt(stats.qa)); $('s-qf', fmt(stats.qf)); $('s-qe', fmt(stats.qe)); $('s-qh', fmt(stats.qh)); |
| } |
| |
| function renderFilters(view = getTokenView()) { |
| renderStatusFilters(view); |
| renderNsfwFilters(view); |
| renderPoolFilters(view); |
| syncFilterTrigger(); |
| } |
| |
| function initFilterMenu() { |
| if (_filterMenuBound) return; |
| const menu = document.getElementById('filter-menu'); |
| const trigger = document.getElementById('btn-filter'); |
| const panel = document.getElementById('filter-panel'); |
| if (!menu || !trigger || !panel) return; |
| _filterMenuBound = true; |
| |
| const close = () => { |
| menu.classList.remove('open'); |
| trigger.setAttribute('aria-expanded', 'false'); |
| syncFilterTrigger(); |
| }; |
| |
| trigger.addEventListener('click', (event) => { |
| event.stopPropagation(); |
| const open = !menu.classList.contains('open'); |
| menu.classList.toggle('open', open); |
| trigger.setAttribute('aria-expanded', open ? 'true' : 'false'); |
| syncFilterTrigger(); |
| }); |
| |
| panel.addEventListener('click', (event) => event.stopPropagation()); |
| document.addEventListener('click', (event) => { |
| const target = event.target; |
| if (!(target instanceof Node) || !menu.contains(target)) close(); |
| }); |
| document.addEventListener('keydown', (event) => { |
| if (event.key === 'Escape') close(); |
| }); |
| } |
| |
| function syncFilterTrigger() { |
| const menu = document.getElementById('filter-menu'); |
| const trigger = document.getElementById('btn-filter'); |
| if (!menu || !trigger) return; |
| const active = curStatus !== 'all' || curPool !== 'all' || curNsfw !== 'all' || menu.classList.contains('open'); |
| trigger.classList.toggle('is-active', active); |
| } |
| |
| function renderStatusFilters(view = getTokenView()) { |
| setFilterCount('fc-status-all', view.statusCounts.all); |
| setFilterCount('fc-status-active', view.statusCounts.active); |
| setFilterCount('fc-status-cooling', view.statusCounts.cooling); |
| setFilterCount('fc-status-invalid', view.statusCounts.invalid); |
| setFilterCount('fc-status-disabled', view.statusCounts.disabled); |
| document.querySelectorAll('[data-status]').forEach(el => el.classList.toggle('active', el.dataset.status === curStatus)); |
| } |
| |
| function renderNsfwFilters(view = getTokenView()) { |
| setFilterCount('fc-nsfw-all', view.nsfwCounts.all); |
| setFilterCount('fc-nsfw-enabled', view.nsfwCounts.enabled); |
| setFilterCount('fc-nsfw-disabled', view.nsfwCounts.disabled); |
| document.querySelectorAll('[data-nsfw]').forEach(el => el.classList.toggle('active', el.dataset.nsfw === curNsfw)); |
| } |
| |
| function renderPoolFilters(view = getTokenView()) { |
| const wrap = document.getElementById('pool-filter-chips'); |
| if (!wrap) return; |
| const pools = view.pools; |
| wrap.innerHTML = [ |
| `<button type="button" class="filter-chip${curPool === 'all' ? ' active' : ''}" data-pool="all" onclick="switchPool('all')"><span>${tr('account.filterTypeAll', null, 'ๅ
จ้จ')}</span><span class="filter-chip-count">${view.poolCounts.get('all') || 0}</span></button>`, |
| ...pools.map(pool => { |
| const count = view.poolCounts.get(pool) || 0; |
| return `<button type="button" class="filter-chip${curPool === pool ? ' active' : ''}" data-pool="${xe(pool)}" onclick="switchPool('${xe(pool)}')"><span>${xe(poolLabel(pool))}</span><span class="filter-chip-count">${count}</span></button>`; |
| }), |
| ].join(''); |
| } |
| |
| function setFilterCount(id, value) { |
| const el = document.getElementById(id); |
| if (el) el.textContent = value; |
| } |
| |
| function applyPoolFilter(items, pool = curPool) { |
| if (pool === 'all') return items; |
| return items.filter(t => (t.pool || 'basic') === pool); |
| } |
| |
| function applyStatusFilter(items, status = curStatus) { |
| if (status === 'all') return items; |
| if (status === 'invalid') return items.filter(t => isInvalidStatus(t.status)); |
| if (status === 'disabled') return items.filter(t => isDisabledStatus(t.status)); |
| return items.filter(t => t.status === status); |
| } |
| |
| function applyNsfwFilter(items, mode = curNsfw) { |
| if (mode === 'all') return items; |
| if (mode === 'enabled') return items.filter(t => (t.tags || []).includes('nsfw')); |
| return items.filter(t => !(t.tags || []).includes('nsfw')); |
| } |
| |
| function filtered() { |
| return getTokenView().filteredItems; |
| } |
| |
| function preserveScroll(fn) { |
| const y = window.scrollY; |
| fn(); |
| requestAnimationFrame(() => window.scrollTo({ top: y, behavior: 'auto' })); |
| } |
| |
| function renderTable(view = getTokenView()) { |
| const items = view.filteredItems; |
| const total = items.length; |
| const pages = Math.max(1, Math.ceil(total / pageSize)); |
| if (curPage > pages) curPage = pages; |
| const slice = items.slice((curPage - 1) * pageSize, curPage * pageSize); |
| |
| document.getElementById('tbody').innerHTML = slice.length |
| ? slice.map(rowHtml).join('') |
| : `<tr><td colspan="10" class="empty-state">${tr('account.empty', null, 'ๆๆ Token๏ผ่ฏท็นๅปๅณไธ่งๅฏผๅ
ฅๆๆทปๅ ใ')}</td></tr>`; |
| |
| $('pagi-page', tr('account.pageIndicator', { current: total ? curPage : 0, total: pages }, `็ฌฌ ${total ? curPage : 0} / ${pages} ้กต`)); |
| $('section-table-count', total); |
| const prev = document.getElementById('btn-prev'), next = document.getElementById('btn-next'); |
| prev.disabled = curPage <= 1; next.disabled = curPage >= pages; |
| updateBatchBtns(); |
| } |
| |
| function rowHtml(t) { |
| const q = t.quota || {}; |
| const isNsfw = (t.tags || []).includes('nsfw'); |
| const isDisabled = t.status === 'disabled'; |
| const isRefreshing = refreshingTokens.has(t.token); |
| const success = t.use_count || 0; |
| const fail = t.fail_count || 0; |
| const nsfwBtn = isNsfw |
| ? `<button type="button" class="row-nsfw-btn is-on" data-nsfw-token="${xe(t.token)}" data-nsfw-enabled="false" data-tip="${xe(tr('account.batchNsfwDisable', null, 'ๅ
ณ้ญ NSFW'))}" aria-label="${xe(tr('account.batchNsfwDisable', null, 'ๅ
ณ้ญ NSFW'))}"> |
| <svg viewBox="0 0 20 20" aria-hidden="true"> |
| <circle cx="10" cy="10" r="7.25"/> |
| <text x="10" y="10.4">18</text> |
| </svg> |
| </button>` |
| : `<button type="button" class="row-nsfw-btn" data-nsfw-token="${xe(t.token)}" data-nsfw-enabled="true" data-tip="${xe(tr('account.batchNsfw', null, 'ๅผๅฏ NSFW'))}" aria-label="${xe(tr('account.batchNsfw', null, 'ๅผๅฏ NSFW'))}"> |
| <svg viewBox="0 0 20 20" aria-hidden="true"> |
| <circle cx="10" cy="10" r="7.25"/> |
| <text x="10" y="10.4">18</text> |
| <path d="M14.8 5.2 5.2 14.8"/> |
| </svg> |
| </button>`; |
| return `<tr> |
| <td><input type="checkbox" class="cb row-cb" data-token="${xe(t.token)}" ${sel.has(t.token)?'checked':''} onchange="toggleRow(this)"></td> |
| <td><div class="token-stack"> |
| <span class="tok">${mask(t.token)}</span> |
| <button onclick="copy('${xe(t.token)}')" class="row-icon-btn" data-tip="${xe(tr('account.actionCopy', null, 'ๅคๅถ'))}" aria-label="${xe(tr('account.actionCopy', null, 'ๅคๅถ'))}"> |
| <svg viewBox="0 0 24 24" stroke-width="1.8"><rect x="9" y="9" width="10" height="10" rx="2"/><path d="M7 15H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v1"/></svg> |
| </button> |
| </div></td> |
| <td class="table-center"><span class="badge badge-${t.pool||'basic'}">${xe(poolLabel(t.pool || 'basic'))}</span></td> |
| <td class="table-center">${statusBadge(t.status)}</td> |
| <td data-quota-only><div class="quota-cell">${qpills(q)}</div></td> |
| <td class="table-center" style="font-variant-numeric:tabular-nums;color:#8f8f8f">${success}</td> |
| <td class="table-center" style="font-variant-numeric:tabular-nums;color:#9a9a9a">${fail}</td> |
| <td class="table-center" style="font-variant-numeric:tabular-nums;color:#9a9a9a">${fmtRate(success, fail)}</td> |
| <td style="font-size:12px;color:#9a9a9a">${fmtDate(t.last_used_at)}</td> |
| <td><div class="row-actions"> |
| ${nsfwBtn} |
| <button onclick="${isDisabled ? `restoreOne('${xe(t.token)}')` : `disableOne('${xe(t.token)}')`}" class="row-icon-btn row-icon-btn-state" data-tip="${xe(isDisabled ? tr('account.actionRestore', null, 'ๆขๅค่ดฆๅท') : tr('account.actionDisable', null, '็ฆ็จ่ดฆๅท'))}" aria-label="${xe(isDisabled ? tr('account.actionRestore', null, 'ๆขๅค่ดฆๅท') : tr('account.actionDisable', null, '็ฆ็จ่ดฆๅท'))}"> |
| ${isDisabled |
| ? '<svg viewBox="0 0 24 24" stroke-width="1.8"><path d="M3 12a9 9 0 1 0 3-6.708"/><path d="M3 4v5h5"/></svg>' |
| : '<svg viewBox="0 0 24 24" stroke-width="1.8"><circle cx="12" cy="12" r="8"/><path d="M8.5 8.5 15.5 15.5"/></svg>'} |
| </button> |
| <button onclick="refreshOne('${xe(t.token)}')" class="row-icon-btn${isRefreshing ? ' is-loading' : ''}" data-tip="${xe(isRefreshing ? tr('account.refreshing', { n: 1 }, 'ๆญฃๅจๅทๆฐ 1 ไธช่ดฆๆทโฆ') : tr('account.actionRefresh', null, 'ๅทๆฐ Usage'))}" aria-label="${xe(tr('account.actionRefresh', null, 'ๅทๆฐ Usage'))}"> |
| <svg viewBox="0 0 24 24" stroke-width="1.8"><path d="M20 11a8 8 0 0 0-14.6-4.6"/><path d="M4 4v5h5"/><path d="M4 13a8 8 0 0 0 14.6 4.6"/><path d="M20 20v-5h-5"/></svg> |
| </button> |
| <button onclick="openEdit('${xe(t.token)}')" class="row-icon-btn" data-tip="${xe(tr('account.actionEdit', null, '็ผ่พ'))}" aria-label="${xe(tr('account.actionEdit', null, '็ผ่พ'))}"> |
| <svg viewBox="0 0 24 24" stroke-width="1.8"><path d="m4 20 4.5-1 9.5-9.5-3.5-3.5L5 15.5z"/><path d="m13.5 5.5 3.5 3.5"/></svg> |
| </button> |
| <button onclick="doDelete(['${xe(t.token)}'])" class="row-icon-btn row-icon-danger" data-tip="${xe(tr('account.actionDelete', null, 'ๅ ้ค'))}" aria-label="${xe(tr('account.actionDelete', null, 'ๅ ้ค'))}"> |
| <svg viewBox="0 0 24 24" stroke-width="1.8"><path d="M5 7h14"/><path d="M9 7V4h6v3"/><path d="M8 10v7"/><path d="M12 10v7"/><path d="M16 10v7"/><path d="M7 7l1 13h8l1-13"/></svg> |
| </button> |
| </div></td> |
| </tr>`; |
| } |
| |
| function qpills(q) { |
| const modes = [ |
| ['auto', 'A', 'q-a', true], |
| ['fast', 'F', 'q-f', true], |
| ['expert', 'E', 'q-e', true], |
| ['heavy', 'H', 'q-h', true], |
| ['grok_4_3', 'B', 'q-b', false], |
| ]; |
| return modes.map(([m,l,c,showEmpty]) => { |
| const win = q[m]; |
| if (win == null) { |
| if (!showEmpty) return ''; |
| return `<span class="q-pill q-z" data-tip="${xe(tr(`account.mode.${m}`, null, m))}: 0">${l}:0</span>`; |
| } |
| const r = win.remaining ?? null; |
| return `<span class="q-pill ${r ? c : 'q-z'}" data-tip="${xe(`${tr(`account.mode.${m}`, null, m)}: ${r ?? tr('account.unknown', null, 'ๆช็ฅ')}/${win.total ?? '?'}`)}">${l}:${r ?? '?'}</span>`; |
| }).join(''); |
| } |
| |
| |
| function switchStatus(status) { |
| preserveScroll(() => { |
| curStatus = status || 'all'; |
| curPage = 1; |
| sel.clear(); |
| renderFilters(); |
| renderTable(); |
| }); |
| } |
| function switchNsfw(mode) { |
| preserveScroll(() => { |
| curNsfw = mode || 'all'; |
| curPage = 1; |
| sel.clear(); |
| renderFilters(); |
| renderTable(); |
| }); |
| } |
| function switchPool(pool) { |
| preserveScroll(() => { |
| curPool = pool || 'all'; |
| curPage = 1; |
| sel.clear(); |
| renderFilters(); |
| renderTable(); |
| }); |
| } |
| function prevPage() { if (curPage > 1) { curPage--; renderTable(); } } |
| function nextPage() { |
| const pages = Math.max(1, Math.ceil(getTokenView().filteredItems.length / pageSize)); |
| if (curPage < pages) { curPage++; renderTable(); } |
| } |
| function changePageSize(v) { |
| const next = Number(v); |
| pageSize = PAGE_SIZE_OPTIONS.includes(next) ? next : 50; |
| localStorage.setItem(PAGE_SIZE_KEY, String(pageSize)); |
| curPage = 1; |
| renderTable(); |
| } |
| |
| function toggleAll(checked) { |
| getTokenView().filteredItems.slice((curPage-1)*pageSize, curPage*pageSize).forEach(t => checked ? sel.add(t.token) : sel.delete(t.token)); |
| document.querySelectorAll('.row-cb').forEach(el => el.checked = checked); |
| updateBatchBtns(); |
| } |
| function toggleRow(el) { |
| el.checked ? sel.add(el.dataset.token) : sel.delete(el.dataset.token); |
| const page = getTokenView().filteredItems.slice((curPage-1)*pageSize, curPage*pageSize); |
| const cbAll = document.getElementById('cb-all'); |
| cbAll.checked = page.every(t => sel.has(t.token)); |
| cbAll.indeterminate = !cbAll.checked && sel.size > 0; |
| updateBatchBtns(); |
| } |
| function updateBatchBtns() { |
| const show = sel.size > 0; |
| ['btn-nsfw','btn-refresh','btn-disable','btn-restore','btn-delete'].forEach(id => |
| document.getElementById(id).style.display = show ? '' : 'none'); |
| } |
| |
| |
| function $(id, v) { const el = document.getElementById(id); if (el) el.textContent = v; } |
| function fmt(n) { return n >= 10000 ? (n/1000).toFixed(1)+'k' : n; } |
| function loadSavedPageSize() { |
| const raw = Number(localStorage.getItem(PAGE_SIZE_KEY) || 0); |
| return PAGE_SIZE_OPTIONS.includes(raw) ? raw : 50; |
| } |
| function fmtRate(success, fail) { |
| const total = success + fail; |
| if (!total) return tr('account.na', null, 'โ'); |
| return `${Math.round(success / total * 100)}%`; |
| } |
| function mask(t) { return t.length > 20 ? t.slice(0,8) + 'โฆ' + t.slice(-8) : t; } |
| function xe(s) { return s.replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'"'); } |
| function fmtDate(d) { |
| if (!d) return tr('account.na', null, 'โ'); |
| const dt = new Date(typeof d === 'number' ? d : d); |
| return isNaN(dt) |
| ? tr('account.na', null, 'โ') |
| : dt.toLocaleDateString(I18n.getLang() === 'en' ? 'en-US' : 'zh-CN', {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}); |
| } |
| function statusBadge(s) { |
| const m = { |
| active:['badge-active', tr('account.status.active', null, 'ๆญฃๅธธ')], |
| cooling:['badge-cooling', tr('account.status.cooling', null, '้ๆต')], |
| expired:['badge-expired', tr('account.status.expired', null, '่ฟๆ')], |
| disabled:['badge-disabled', tr('account.status.disabled', null, '็ฆ็จ')], |
| invalid:['badge-invalid', tr('account.status.invalid', null, 'ๅผๅธธ')], |
| }; |
| const [cls, label] = m[s] || ['badge-invalid', s || tr('account.unknown', null, 'ๆช็ฅ')]; |
| return `<span class="badge ${cls}">${label}</span>`; |
| } |
| async function copy(t) { |
| try { await navigator.clipboard.writeText(t); showToast(tr('account.copied', null, 'ๅทฒๅคๅถ'), 'success'); } |
| catch { showToast(tr('account.copyFailed', null, 'ๅคๅถๅคฑ่ดฅ'), 'error'); } |
| } |
| |
| |
| |
| |
| function fillImportPoolOptions(id) { |
| const known = [...new Set(['basic', 'super', 'heavy', ...allTokens.map(t => t.pool || 'basic')])]; |
| const opts = [ |
| `<option value="auto">${xe(tr('account.pool.autoRecommended', null, '่ชๅจๆฃๆต๏ผๆจ่๏ผ'))}</option>`, |
| ...known.map(p => `<option value="${p}">${xe(poolLabel(p))}</option>`), |
| ]; |
| document.getElementById(id).innerHTML = opts.join(''); |
| } |
| |
| |
| function fillPoolOptions(id) { |
| const pools = [...new Set(['basic', 'super', 'heavy', ...allTokens.map(t => t.pool || 'basic')])]; |
| document.getElementById(id).innerHTML = pools.map(p => `<option value="${p}">${xe(poolLabel(p))}</option>`).join(''); |
| } |
| |
| function openAdd() { |
| document.getElementById('import-tokens').value = ''; |
| fillImportPoolOptions('import-pool'); |
| openModal('modal-import'); |
| } |
| |
| function openImport() { |
| document.getElementById('import-file').value = ''; |
| updateImportFileState(); |
| fillImportPoolOptions('import-file-pool'); |
| openModal('modal-import-file'); |
| } |
| |
| function openEdit(token) { |
| const item = allTokens.find(t => t.token === token); |
| if (!item) return showToast(tr('account.notFound', null, '่ดฆๆทไธๅญๅจ'), 'error'); |
| _editingToken = item.token; |
| document.getElementById('edit-token').value = item.token; |
| fillPoolOptions('edit-pool'); |
| document.getElementById('edit-pool').value = item.pool || 'basic'; |
| openModal('modal-edit'); |
| } |
| |
| async function doAdd() { |
| const raw = document.getElementById('import-tokens').value; |
| const pool = document.getElementById('import-pool').value; |
| const newToks = raw.split('\n').map(s => s.trim()).filter(Boolean); |
| if (!newToks.length) return showToast(tr('account.enterToken', null, '่ฏท่พๅ
ฅ Token'), 'error'); |
| return saveTokens( |
| pool, |
| newToks, |
| 'modal-import', |
| tr('account.adding', { n: newToks.length }, `ๆญฃๅจๆทปๅ ${newToks.length} ไธช่ดฆๆทโฆ`), |
| tr('account.addDone', null, 'ๆทปๅ ๅฎๆ'), |
| tr('account.addFailed', null, 'ๆทปๅ ๅคฑ่ดฅ'), |
| ); |
| } |
| |
| async function doImport() { |
| const file = document.getElementById('import-file').files?.[0]; |
| if (!file) return showToast(tr('account.selectFile', null, '่ฏท้ๆฉๆไปถ'), 'error'); |
| const isJson = file.name.toLowerCase().endsWith('.json'); |
| let raw = ''; |
| try { raw = await file.text(); } catch { return showToast(tr('account.readFileFailed', null, '่ฏปๅๆไปถๅคฑ่ดฅ'), 'error'); } |
| |
| if (isJson) { |
| let parsed; |
| try { parsed = JSON.parse(raw); } catch { return showToast(tr('account.jsonInvalid', null, 'JSON ๆ ผๅผ้่ฏฏ'), 'error'); } |
| if (typeof parsed !== 'object' || Array.isArray(parsed)) return showToast(tr('account.jsonShapeInvalid', null, 'JSON ๆ ผๅผไธๆญฃ็กฎ๏ผๅบไธบ {pool:[tokens]} ็ปๆ'), 'error'); |
| let total = 0; |
| Object.values(parsed).forEach(arr => { if (Array.isArray(arr)) total += arr.length; }); |
| if (!total) return showToast(tr('account.jsonEmpty', null, 'JSON ๆไปถไธญๆฒกๆๅฏๅฏผๅ
ฅ็ Token'), 'error'); |
| try { |
| showToast(tr('account.importing', { n: total }, `ๆญฃๅจๅฏผๅ
ฅ ${total} ไธช่ดฆๆทโฆ`), 'info'); |
| await _api('POST', '/tokens', parsed); |
| closeModal('modal-import-file'); |
| showToast(tr('account.importJsonDone', { n: total }, `ๅฏผๅ
ฅๅฎๆ๏ผๅ
ฑ ${total} ไธช`), 'success'); |
| load(); |
| } catch (e) { showToast(`${tr('account.importFailed', null, 'ๅฏผๅ
ฅๅคฑ่ดฅ')}: ${e.message}`, 'error'); } |
| } else { |
| |
| const pool = document.getElementById('import-file-pool').value; |
| const newToks = raw.split(/\r?\n/).map(s => s.trim()).filter(Boolean); |
| if (!newToks.length) return showToast(tr('account.fileEmpty', null, 'ๆไปถไธญๆฒกๆๅฏๅฏผๅ
ฅ็ Token'), 'error'); |
| return saveTokens( |
| pool, |
| newToks, |
| 'modal-import-file', |
| tr('account.importing', { n: newToks.length }, `ๆญฃๅจๅฏผๅ
ฅ ${newToks.length} ไธช่ดฆๆทโฆ`), |
| tr('account.importDone', null, 'ๅฏผๅ
ฅๅฎๆ'), |
| tr('account.importFailed', null, 'ๅฏผๅ
ฅๅคฑ่ดฅ'), |
| ); |
| } |
| } |
| |
| async function doEdit() { |
| const token = document.getElementById('edit-token').value.trim(); |
| const pool = document.getElementById('edit-pool').value; |
| if (!_editingToken) return; |
| if (!token) return showToast(tr('account.enterToken', null, '่ฏท่พๅ
ฅ Token'), 'error'); |
| try { |
| showToast(tr('account.saving', null, 'ๆญฃๅจไฟๅญไฟฎๆนโฆ'), 'info'); |
| await _api('PUT', '/tokens/edit', { old_token: _editingToken, token, pool }); |
| closeModal('modal-edit'); |
| _editingToken = ''; |
| showToast(tr('account.editDone', null, '็ผ่พๅฎๆ'), 'success'); |
| load(); |
| } catch (e) { showToast(`${tr('account.editFailed', null, '็ผ่พๅคฑ่ดฅ')}: ${e.message}`, 'error'); } |
| } |
| |
| function updateImportFileState() { |
| const file = document.getElementById('import-file').files?.[0]; |
| const el = document.getElementById('import-file-name'); |
| if (!el) return; |
| el.textContent = file ? file.name : tr('account.noFile', null, 'ๆช้ๆฉๆไปถ'); |
| el.classList.toggle('empty', !file); |
| const isJson = file && file.name.toLowerCase().endsWith('.json'); |
| const poolRow = document.getElementById('import-file-pool-row'); |
| const hint = document.getElementById('import-file-hint'); |
| if (poolRow) poolRow.style.display = isJson ? 'none' : ''; |
| if (hint) hint.textContent = isJson |
| ? tr('account.importHintJson', null, 'JSON ๆไปถๅทฒๅ
ๅซ pool ไฟกๆฏ๏ผๆ ้้ๆฉ็ฑปๅ') |
| : tr('account.importHintTxt', null, 'ๆฏ่กไธไธช Token๏ผๅฐๆทปๅ ๅฐๆ้็ฑปๅ'); |
| } |
| |
| async function saveTokens(pool, newToks, modalId, pendingMessage, successPrefix, errorPrefix) { |
| const unique = [...new Set(newToks)]; |
| try { |
| showToast(pendingMessage, 'info'); |
| const d = await _api('POST', '/tokens/add', { pool, tokens: unique }); |
| closeModal(modalId); |
| const msg = d.skipped |
| ? tr('account.saveResultSkipped', { prefix: successPrefix, count: d.count, skipped: d.skipped }, `${successPrefix}๏ผๆฐๅข ${d.count}๏ผๅทฒๅญๅจ่ทณ่ฟ ${d.skipped}`) |
| : tr('account.saveResult', { prefix: successPrefix, count: d.count }, `${successPrefix}๏ผๆฐๅข ${d.count} ไธช`); |
| showToast(msg, 'success'); |
| load(); |
| } catch (e) { showToast(`${errorPrefix}: ${e.message}`, 'error'); } |
| } |
| |
| |
| function doExport() { |
| const tokens = filtered(); |
| if (!tokens.length) return showToast(tr('account.exportEmpty', null, 'ๅฝๅๆฒกๆๅฏๅฏผๅบ็ Token'), 'error'); |
| openModal('modal-export'); |
| document.getElementById('exp-count').textContent = tokens.length; |
| document.getElementById('exp-json-btn').onclick = () => { |
| closeModal('modal-export'); |
| const out = {}; |
| tokens.forEach(t => (out[t.pool||'basic'] = out[t.pool||'basic']||[]).push({token:t.token,tags:t.tags||[]})); |
| _download(JSON.stringify(out,null,2), `tokens-${currentFilterSuffix()}.json`, 'application/json'); |
| }; |
| document.getElementById('exp-txt-btn').onclick = () => { |
| closeModal('modal-export'); |
| _download(tokens.map(t => t.token).join('\n'), `tokens-${currentFilterSuffix()}.txt`, 'text/plain'); |
| }; |
| } |
| |
| function currentFilterSuffix() { |
| return [curStatus, curNsfw, curPool].filter(Boolean).join('-'); |
| } |
| |
| function _download(content, filename, mime) { |
| const a = document.createElement('a'); |
| a.href = URL.createObjectURL(new Blob([content], {type: mime})); |
| a.download = filename; a.click(); |
| } |
| |
| |
| function doDelete(tokens) { |
| const n = tokens.length; |
| openConfirm(tr('account.deleteConfirmTitle', null, '็กฎ่ฎคๅ ้ค'), |
| n === 1 |
| ? tr('account.deleteConfirmOne', { token: `<code>${mask(tokens[0])}</code>` }, `็กฎ่ฎคๅ ้ค <code>${mask(tokens[0])}</code>๏ผ<br><small style="color:var(--error)">ๆญคๆไฝไธๅฏๆค้ใ</small>`) |
| : tr('account.deleteConfirmMany', { n }, `็กฎ่ฎคๅ ้ค <b>${n}</b> ไธช่ดฆๆท๏ผ<br><small style="color:var(--error)">ๆญคๆไฝไธๅฏๆค้ใ</small>`), |
| async () => { |
| try { |
| showToast(tr('account.deleting', { n }, `ๆญฃๅจๅ ้ค ${n} ไธช่ดฆๆทโฆ`), 'info'); |
| await _api('DELETE', '/tokens', tokens); |
| tokens.forEach(t => sel.delete(t)); |
| showToast(tr('account.deleteDone', { n }, `ๅทฒๅ ้ค ${n} ไธช`), 'success'); |
| load(); |
| } catch (e) { showToast(`${tr('account.deleteFailed', null, 'ๅ ้คๅคฑ่ดฅ')}: ${e.message}`, 'error'); } |
| }); |
| } |
| function batchDeleteSel() { if (sel.size) doDelete([...sel]); } |
| |
| |
| let _batchTaskId = null; |
| let _batchEs = null; |
| |
| async function _runBatch(endpoint, tokens, label, onDone) { |
| const btnCancel = document.getElementById('btn-batch-cancel'); |
| ['btn-nsfw','btn-refresh','btn-disable','btn-restore','btn-delete'].forEach(id => |
| document.getElementById(id).style.display = 'none'); |
| btnCancel.style.display = ''; |
| |
| const progress = showProgressToast(label); |
| let _finalReceived = false; |
| |
| function _cleanup() { |
| btnCancel.style.display = 'none'; |
| updateBatchBtns(); |
| } |
| function _done(es) { |
| _finalReceived = true; |
| es.close(); _batchEs = null; |
| _cleanup(); |
| } |
| |
| try { |
| const d = await _api('POST', `${endpoint}?async=true`, { tokens }); |
| _batchTaskId = d.task_id; |
| const total = d.total || tokens.length; |
| |
| const key = await adminKey.get(); |
| const es = new EventSource(`${ADMIN_API}/batch/${_batchTaskId}/stream?app_key=${encodeURIComponent(key)}`); |
| _batchEs = es; |
| |
| es.onmessage = (e) => { |
| const ev = JSON.parse(e.data); |
| if (ev.type === 'snapshot' || ev.type === 'progress') { |
| progress.update(ev.processed || 0, total); |
| } |
| if (['done', 'error', 'cancelled'].includes(ev.type)) { |
| _done(es); |
| if (ev.type === 'done') { |
| const s = ev.result?.summary || {}; |
| const ok = s.ok ?? 0, fail = s.fail ?? 0; |
| progress.finish(`${label.replace('โฆ','').replace('ๆญฃๅจ','')} ๅฎๆ๏ผๆๅ ${ok}๏ผๅคฑ่ดฅ ${fail}`, fail > 0 ? 'error' : 'success'); |
| onDone(ok, fail); |
| load(); |
| } else if (ev.type === 'cancelled') { |
| progress.finish('ๅทฒๅๆถ', 'error'); |
| } else { |
| progress.finish(ev.error || 'ๆไฝๅคฑ่ดฅ', 'error'); |
| } |
| } |
| }; |
| es.onerror = function() { |
| if (_finalReceived) { _batchEs = null; return; } |
| |
| if (this.readyState === EventSource.CLOSED) { |
| _batchEs = null; |
| _cleanup(); |
| progress.finish('่ฟๆฅไธญๆญ', 'error'); |
| } |
| |
| }; |
| } catch (e) { |
| _cleanup(); |
| progress.finish(`ๅฏๅจๅคฑ่ดฅ: ${e.message}`, 'error'); |
| } |
| } |
| |
| async function cancelBatch() { |
| if (!_batchTaskId) return; |
| showToast('ๅทฒๆๅ๏ผ่ฏท่ๅฟ็ญๅพ
ๆฌๆนๆฌกๅฎๆโฆ', 'info'); |
| try { await _api('POST', `/batch/${_batchTaskId}/cancel`); } catch {} |
| } |
| |
| |
| async function refreshOne(token) { |
| if (refreshingTokens.has(token)) return; |
| refreshingTokens.add(token); |
| renderTable(); |
| try { |
| showToast(tr('account.refreshing', { n: 1 }, 'ๆญฃๅจๅทๆฐ 1 ไธช่ดฆๆทโฆ'), 'info'); |
| const d = await _api('POST', '/batch/refresh', { tokens:[token] }); |
| showToast(tr('account.refreshDone', { ok: d.summary?.ok ?? 0, fail: d.summary?.fail ?? 0 }, `ๅทๆฐๅฎๆ๏ผๆๅ ${d.summary?.ok??0}๏ผๅคฑ่ดฅ ${d.summary?.fail??0}`), 'success'); |
| await load(); |
| } catch (e) { showToast(`${tr('account.refreshFailed', null, 'ๅทๆฐๅคฑ่ดฅ')}: ${e.message}`, 'error'); } |
| finally { |
| refreshingTokens.delete(token); |
| renderTable(); |
| } |
| } |
| |
| async function setDisabled(token, disabled) { |
| const masked = mask(token); |
| const title = disabled |
| ? tr('account.disableConfirmTitle', null, '็ฆ็จ่ดฆๅท') |
| : tr('account.restoreConfirmTitle', null, 'ๆขๅค่ดฆๅท'); |
| const body = disabled |
| ? tr('account.disableConfirmBody', { token: `<code>${masked}</code>` }, `็กฎ่ฎค็ฆ็จ <code>${masked}</code>๏ผ<br><small style="color:var(--fg-muted)">็ฆ็จๅ่ฏฅ่ดฆๅทไธไผๅไธ่ฏทๆฑๅ้
ใ</small>`) |
| : tr('account.restoreConfirmBody', { token: `<code>${masked}</code>` }, `็กฎ่ฎคๆขๅค <code>${masked}</code>๏ผ<br><small style="color:var(--fg-muted)">ๆขๅคๅ่ฏฅ่ดฆๅทๅฐ้ๆฐๅไธ่ฏทๆฑๅ้
ใ</small>`); |
| |
| openConfirm(title, body, async () => { |
| try { |
| showToast( |
| disabled |
| ? tr('account.disablingOne', null, 'ๆญฃๅจ็ฆ็จ่ดฆๅทโฆ') |
| : tr('account.restoringOne', null, 'ๆญฃๅจๆขๅค่ดฆๅทโฆ'), |
| 'info', |
| ); |
| await _api('POST', '/tokens/disabled', { token, disabled }); |
| showToast( |
| disabled |
| ? tr('account.disableDone', null, '่ดฆๅทๅทฒ็ฆ็จ') |
| : tr('account.restoreDone', null, '่ดฆๅทๅทฒๆขๅค'), |
| 'success', |
| ); |
| await load(); |
| } catch (e) { |
| showToast(`${tr('account.operationFailed', null, 'ๆไฝๅคฑ่ดฅ')}: ${e.message}`, 'error'); |
| } |
| }); |
| } |
| |
| function disableOne(token) { setDisabled(token, true); } |
| function restoreOne(token) { setDisabled(token, false); } |
| |
| function batchSetDisabled(disabled) { |
| if (!sel.size) return; |
| const tokens = [...sel]; |
| const n = tokens.length; |
| const title = disabled |
| ? tr('account.batchDisableConfirmTitle', null, 'ๆน้็ฆ็จ่ดฆๅท') |
| : tr('account.batchRestoreConfirmTitle', null, 'ๆน้ๆขๅค่ดฆๅท'); |
| const body = disabled |
| ? tr('account.batchDisableConfirmBody', { n }, `็กฎ่ฎค็ฆ็จ้ไธญ็ <b>${n}</b> ไธช่ดฆๆท๏ผ<br><small style="color:var(--fg-muted)">็ฆ็จๅ่ฟไบ่ดฆๅทไธไผๅไธ่ฏทๆฑๅ้
๏ผไฝๅฏ้ๆถๆขๅคใ</small>`) |
| : tr('account.batchRestoreConfirmBody', { n }, `็กฎ่ฎคๆขๅค้ไธญ็ <b>${n}</b> ไธช่ดฆๆท๏ผ<br><small style="color:var(--fg-muted)">ๆขๅคๅ่ฟไบ่ดฆๅทๅฐ้ๆฐๅไธ่ฏทๆฑๅ้
ใ</small>`); |
| |
| openConfirm(title, body, async () => { |
| try { |
| showToast( |
| disabled |
| ? tr('account.disablingMany', { n }, `ๆญฃๅจ็ฆ็จ ${n} ไธช่ดฆๆทโฆ`) |
| : tr('account.restoringMany', { n }, `ๆญฃๅจๆขๅค ${n} ไธช่ดฆๆทโฆ`), |
| 'info', |
| ); |
| const d = await _api('POST', '/tokens/disabled/batch', { tokens, disabled }); |
| const ok = d.summary?.ok ?? 0; |
| const fail = d.summary?.fail ?? 0; |
| showToast( |
| disabled |
| ? tr('account.disableManyDone', { ok, fail }, `็ฆ็จๅฎๆ๏ผๆๅ ${ok} ไธช๏ผๅคฑ่ดฅ ${fail} ไธช`) |
| : tr('account.restoreManyDone', { ok, fail }, `ๆขๅคๅฎๆ๏ผๆๅ ${ok} ไธช๏ผๅคฑ่ดฅ ${fail} ไธช`), |
| fail > 0 ? 'error' : 'success', |
| ); |
| sel.clear(); |
| await load(); |
| } catch (e) { |
| showToast(`${tr('account.operationFailed', null, 'ๆไฝๅคฑ่ดฅ')}: ${e.message}`, 'error'); |
| } |
| }); |
| } |
| |
| function batchDisableSel() { batchSetDisabled(true); } |
| function batchRestoreSel() { batchSetDisabled(false); } |
| |
| async function batchRefreshSel() { |
| if (!sel.size) return; |
| await _runBatch('/batch/refresh', [...sel], |
| tr('account.refreshing', { n: sel.size }, `ๆญฃๅจๅทๆฐ ${sel.size} ไธช่ดฆๆทโฆ`), |
| (ok, fail) => showToast(tr('account.refreshDone', { ok, fail }, `ๅทๆฐๅฎๆ๏ผๆๅ ${ok}๏ผๅคฑ่ดฅ ${fail}`), 'success') |
| ); |
| } |
| |
| |
| async function nsfwOne(token, enabled = true) { |
| try { |
| showToast(enabled |
| ? tr('account.nsfwEnablingOne', null, 'ๆญฃๅจๅผๅฏ NSFWโฆ') |
| : tr('account.nsfwDisablingOne', null, 'ๆญฃๅจๅ
ณ้ญ NSFWโฆ'), 'info'); |
| const d = await _api('POST', `/batch/nsfw?enabled=${enabled ? 'true' : 'false'}`, { tokens: [token] }); |
| showToast(enabled |
| ? tr('account.nsfwDone', { ok: d.summary?.ok ?? 0, fail: d.summary?.fail ?? 0 }, `NSFW ๅทฒๅผๅฏ๏ผๆๅ ${d.summary?.ok??0}๏ผๅคฑ่ดฅ ${d.summary?.fail??0}`) |
| : tr('account.nsfwDisableDone', { ok: d.summary?.ok ?? 0, fail: d.summary?.fail ?? 0 }, `NSFW ๅทฒๅ
ณ้ญ๏ผๆๅ ${d.summary?.ok??0}๏ผๅคฑ่ดฅ ${d.summary?.fail??0}`), |
| 'success'); |
| load(); |
| } catch (e) { showToast(`${tr('account.operationFailed', null, 'ๆไฝๅคฑ่ดฅ')}: ${e.message}`, 'error'); } |
| } |
| |
| function batchNSFW() { |
| if (!sel.size) return; |
| const tokens = [...sel]; |
| openConfirm( |
| tr('account.nsfwConfirmTitle', null, 'ๅผๅฏ NSFW'), |
| tr('account.nsfwConfirmBody', { n: tokens.length }, `ไธบ้ไธญ็ <b>${tokens.length}</b> ไธช่ดฆๆทๅผๅฏ NSFW๏ผ`), |
| async () => _runBatch('/batch/nsfw', tokens, |
| tr('account.nsfwEnabling', { n: tokens.length }, `ๆญฃๅจไธบ ${tokens.length} ไธช่ดฆๆทๅผๅฏ NSFWโฆ`), |
| (ok, fail) => showToast(tr('account.nsfwDone', { ok, fail }, `NSFW ๅฎๆ๏ผๆๅ ${ok}๏ผๅคฑ่ดฅ ${fail}`), 'success') |
| ) |
| ); |
| } |
| |
| |
| function openModal(id) { document.getElementById(id).classList.add('open'); } |
| function closeModal(id) { document.getElementById(id).classList.remove('open'); } |
| function openConfirm(title, body, cb) { |
| _cb = async () => { closeModal('modal-confirm'); await cb(); }; |
| document.getElementById('confirm-title').textContent = title; |
| document.getElementById('confirm-body').innerHTML = body; |
| openModal('modal-confirm'); |
| } |
| document.querySelectorAll('.modal-overlay').forEach(el => |
| el.addEventListener('click', e => { if (e.target === el) el.classList.remove('open'); })); |
| |
| const _tooltipEl = document.createElement('div'); |
| _tooltipEl.className = 'admin-tooltip'; |
| document.body.appendChild(_tooltipEl); |
| let _tooltipTarget = null; |
| |
| function _placeTooltip(x, y) { |
| const pad = 12; |
| const rect = _tooltipEl.getBoundingClientRect(); |
| const maxX = window.innerWidth - rect.width - pad; |
| const maxY = window.innerHeight - rect.height - pad; |
| const left = Math.max(pad, Math.min(x + 14, maxX)); |
| const top = Math.max(pad, Math.min(y + 16, maxY)); |
| _tooltipEl.style.left = `${left}px`; |
| _tooltipEl.style.top = `${top}px`; |
| } |
| |
| function _showTooltip(target, x, y) { |
| const text = target?.getAttribute('data-tip'); |
| if (!text) return; |
| _tooltipTarget = target; |
| _tooltipEl.textContent = text; |
| _tooltipEl.classList.add('show'); |
| _placeTooltip(x, y); |
| } |
| |
| function _hideTooltip() { |
| _tooltipTarget = null; |
| _tooltipEl.classList.remove('show'); |
| } |
| |
| document.addEventListener('mouseover', (e) => { |
| const target = e.target.closest('[data-tip]'); |
| if (!target) return _hideTooltip(); |
| _showTooltip(target, e.clientX, e.clientY); |
| }); |
| |
| document.addEventListener('mousemove', (e) => { |
| if (_tooltipTarget) _placeTooltip(e.clientX, e.clientY); |
| }); |
| |
| document.addEventListener('mouseout', (e) => { |
| if (!_tooltipTarget) return; |
| const next = e.relatedTarget; |
| if (next && _tooltipTarget.contains(next)) return; |
| if (e.target.closest('[data-tip]') === _tooltipTarget) _hideTooltip(); |
| }); |
| |
| document.addEventListener('focusin', (e) => { |
| const target = e.target.closest('[data-tip]'); |
| if (!target) return; |
| const rect = target.getBoundingClientRect(); |
| _showTooltip(target, rect.left + rect.width / 2, rect.top); |
| }); |
| |
| document.addEventListener('focusout', (e) => { |
| if (e.target.closest('[data-tip]')) _hideTooltip(); |
| }); |
| |
| document.addEventListener('click', (e) => { |
| const btn = e.target.closest('[data-nsfw-token]'); |
| if (!btn) return; |
| e.preventDefault(); |
| const token = btn.getAttribute('data-nsfw-token'); |
| const enabled = btn.getAttribute('data-nsfw-enabled') === 'true'; |
| if (token) nsfwOne(token, enabled); |
| }); |
| </script> |
| </body> |
| </html> |
|
|