grok2api / app /statics /admin /account.html
FUCAT's picture
Deploy grok2api to HF Spaces (Docker)
7e55e53
Raw
History Blame Contribute Delete
82.6 kB
<!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 header */
.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;
}
/* Stats */
.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 }
/* Filters */
.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 }
/* Toolbar row above table */
.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 */
.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 pills in table cell */
.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 }
/* Token monospace */
.tok { font-family:ui-monospace,monospace; font-size:12px; color:#333 }
/* Pagination */
.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 }
/* Dialogs */
#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 modal */
.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>
<!-- โ”€โ”€ Header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -->
<div id="admin-header" data-active="/admin/account"></div>
<!-- โ”€โ”€ Main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -->
<main class="admin-main">
<!-- Page heading -->
<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>
<!-- Stats -->
<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>
<!-- Toolbar -->
<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>
<!-- Table -->
<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>
<!-- โ”€โ”€ Add Modal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -->
<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>
<!-- โ”€โ”€ Import Modal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -->
<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>
<!-- โ”€โ”€ Edit Modal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -->
<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>
<!-- โ”€โ”€ Export Modal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -->
<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>
<!-- โ”€โ”€ Confirm Modal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -->
<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>
// โ”€โ”€ State โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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));
}
// โ”€โ”€ Boot โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
(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();
})();
// โ”€โ”€ API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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();
}
// Selection strategy reported by /status โ€” "quota" | "random".
// Controls whether quota columns / stats are rendered (hidden in random mode).
let selectionStrategy = 'quota';
// โ”€โ”€ Load โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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() {
// Random strategy: hide quota-related stat cells and the table column.
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' : '';
});
}
// โ”€โ”€ Render โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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('');
}
// โ”€โ”€ Nav & Pagination โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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');
}
// โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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,'&quot;'); }
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'); }
}
// โ”€โ”€ Add / Import โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// For import modals: includes "auto๏ผˆ่‡ชๅŠจๆฃ€ๆต‹๏ผ‰" as the first / default option.
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('');
}
// For edit modal: shows real pool names only (no "auto").
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 {
// TXT ๆ ผๅผ: ๆฏ่กŒไธ€ไธช token
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'); }
}
// โ”€โ”€ Export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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();
}
// โ”€โ”€ Delete โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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]); }
// โ”€โ”€ Batch SSE runner โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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; }
// readyState CLOSED (2) = server rejected reconnect (e.g. 401/404), give up
if (this.readyState === EventSource.CLOSED) {
_batchEs = null;
_cleanup();
progress.finish('่ฟžๆŽฅไธญๆ–ญ', 'error');
}
// readyState CONNECTING (0) = browser is auto-reconnecting, let it
};
} catch (e) {
_cleanup();
progress.finish(`ๅฏๅŠจๅคฑ่ดฅ: ${e.message}`, 'error');
}
}
async function cancelBatch() {
if (!_batchTaskId) return;
showToast('ๅทฒๆš‚ๅœ๏ผŒ่ฏท่€ๅฟƒ็ญ‰ๅพ…ๆœฌๆ‰นๆฌกๅฎŒๆˆโ€ฆ', 'info');
try { await _api('POST', `/batch/${_batchTaskId}/cancel`); } catch {}
}
// โ”€โ”€ Refresh โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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')
);
}
// โ”€โ”€ NSFW โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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')
)
);
}
// โ”€โ”€ Modal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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>