| <!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="cache.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; |
| } |
| .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(4, minmax(0, 1fr)); gap:12px; margin-bottom:30px } |
| .stat-cell { min-height:96px; 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:22px; height:22px; display:inline-flex; align-items:center; justify-content:center; color:#a3a3a3; flex:0 0 auto } |
| .stat-icon svg { width:14px; height:14px; stroke:currentColor } |
| .stat-num { font-size:22px; font-weight:600; line-height:1; letter-spacing:-.02em; margin-top:auto } |
| .stat-sub { |
| margin-top:-4px; |
| font-size:11px; |
| color:#8a8a8a; |
| font-variant-numeric:tabular-nums; |
| min-height:14px; |
| } |
| .stat-progress { |
| height:5px; |
| margin-top:-2px; |
| border-radius:999px; |
| background:#efefef; |
| overflow:hidden; |
| } |
| .stat-progress-fill { |
| display:block; |
| height:100%; |
| width:0; |
| border-radius:999px; |
| background:#d1d5db; |
| transition:width .2s ease, background-color .2s ease; |
| } |
| .stat-progress-fill.image { background:#2563eb } |
| .stat-progress-fill.video { background:#7c3aed } |
| .stat-progress-fill.over { background:#dc2626 } |
| |
| |
| .sec-tabs { |
| display:flex; |
| align-items:center; |
| gap:6px; |
| align-self:flex-start; |
| flex-wrap:wrap; |
| padding:0; |
| } |
| .sec-tab { |
| height:30px; padding:0 12px; border-radius:999px; |
| font-size:12px; font-weight:500; color:#8f8f8f; |
| background:#f5f5f5; cursor:pointer; |
| transition:background .15s, color .15s; |
| } |
| .sec-tab:hover { color:#555; background:#f1f1f1 } |
| .sec-tab.active { background:#111; color:#fff; font-weight:600 } |
| .sec-panel { display:none } |
| .sec-panel.active { display:block } |
| |
| |
| .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-page-size { |
| width:96px !important; |
| height:28px; |
| border-radius:999px; |
| font:inherit; |
| color:inherit; |
| background:#fafafa; |
| border-color:transparent; |
| flex:0 0 auto; |
| } |
| .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:disabled { opacity:.35; pointer-events:none } |
| .pagi-meta { display:flex; align-items:center; gap:0; min-height:28px; min-width:0; color:inherit; font:inherit; flex-wrap:nowrap } |
| .pagi-page { min-width:0; padding:0 4px; font:inherit; color:inherit; font-variant-numeric:tabular-nums; white-space:nowrap } |
| .pagi-dot { width:4px; height:4px; border-radius:50%; background:#d4d4d4; flex:0 0 auto } |
| .pagi-info { font-size:11px; color:#a3a3a3 } |
| .asset-summary { font-size:12px; color:#a3a3a3 } |
| .cb { |
| width:14px; |
| height:14px; |
| accent-color:#111; |
| vertical-align:middle; |
| } |
| |
| |
| .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; 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-empty { text-align:center; color:#bbb; font-size:13px; padding:40px 16px } |
| .row-name { |
| display:flex; |
| align-items:center; |
| gap:10px; |
| min-width:0; |
| } |
| .row-icon { |
| width:18px; |
| height:18px; |
| display:inline-flex; |
| align-items:center; |
| justify-content:center; |
| color:#9a9a9a; |
| flex:0 0 auto; |
| } |
| .row-icon svg { |
| width:13px; |
| height:13px; |
| stroke:currentColor; |
| stroke-linecap:round; |
| stroke-linejoin:round; |
| fill:none; |
| } |
| .row-value { |
| min-width:0; |
| font-family:'Geist Mono','Courier New',monospace; |
| font-size:12px; |
| word-break:break-all; |
| } |
| .row-actions { |
| display:flex; |
| align-items:center; |
| gap:8px; |
| flex-wrap:nowrap; |
| white-space:nowrap; |
| } |
| .row-action { |
| color:#929292; |
| width:22px; |
| height:22px; |
| display:inline-flex; |
| align-items:center; |
| justify-content:center; |
| flex:0 0 auto; |
| } |
| .row-action:hover { color:#555 } |
| .row-action-danger { color:#b7726a } |
| .row-action svg { |
| width:13px; |
| height:13px; |
| stroke:currentColor; |
| stroke-linecap:round; |
| stroke-linejoin:round; |
| fill:none; |
| } |
| .meta-badge { |
| display:inline-flex; |
| align-items:center; |
| height:20px; |
| padding:0 8px; |
| border-radius:999px; |
| font-size:11px; |
| font-weight:500; |
| color:#6d6d6d; |
| background:#f7f7f7; |
| } |
| |
| |
| |
| .sub-row td { background:#fafafa; padding:9px 16px 9px 36px; font-size:12px; color:#666; border-top:1px solid #f5f5f5 } |
| .sub-row:hover td { background:#f5f5f5 } |
| .expand-btn { width:22px; height:22px; display:inline-flex; align-items:center; justify-content:center; background:none; color:#9a9a9a; transition:transform .15s, color .15s } |
| .expand-btn:hover { color:#555 } |
| .expand-btn.open { transform:rotate(90deg) } |
| .asset-count-badge { display:inline-flex; align-items:center; height:20px; padding:0 8px; border-radius:999px; font-size:11px; font-weight:500; background:#f7f7f7; color:#6d6d6d } |
| .asset-count-badge.has-items { background:#f7f7f7; color:#6d6d6d } |
| .asset-err { font-size:11px; color:#dc2626 } |
| @keyframes spin { to { transform:rotate(360deg) } } |
| .progress-bar-outer { height:6px; background:#f0f0f0; border-radius:999px; overflow:hidden } |
| .progress-bar-inner { height:100%; background:#111; border-radius:999px; transition:width .3s } |
| |
| @media (max-width: 840px) { |
| .page-hd, |
| .section-head { |
| flex-direction:column; |
| align-items:flex-start; |
| } |
| .table-toolbar { |
| flex-wrap:nowrap; |
| overflow-x:auto; |
| overflow-y:hidden; |
| -webkit-overflow-scrolling:touch; |
| } |
| .table-toolbar-left, |
| .table-toolbar-actions { |
| flex-wrap:nowrap; |
| flex-shrink:0; |
| } |
| .stat-grid { |
| grid-template-columns:repeat(2, minmax(0, 1fr)); |
| } |
| } |
| </style> |
| </head> |
| <body> |
|
|
| |
| <div id="admin-header" data-active="/admin/cache"></div> |
|
|
| <main class="admin-main" style="padding-bottom:60px"> |
|
|
| |
| <div class="page-hd"> |
| <div> |
| <div class="page-title" data-i18n="cache.pageHeading">缓存管理</div> |
| <div class="page-sub" data-i18n="cache.pageSubtitle">统一查看本地缓存文件、容量上限与账户在线资产。</div> |
| </div> |
| </div> |
|
|
| <div class="section-head"> |
| <div class="section-title" data-i18n="cache.sectionOverview">缓存概览</div> |
| </div> |
|
|
| |
| <div class="stat-grid"> |
| <div class="stat-cell"> |
| <div class="stat-top"> |
| <div class="stat-label" data-i18n="cache.statImageCount">图片缓存数量</div> |
| <span class="stat-icon" style="color:#2563eb"> |
| <svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> |
| </span> |
| </div> |
| <div class="stat-num" id="s-img-count" style="color:#2563eb">—</div> |
| </div> |
| <div class="stat-cell"> |
| <div class="stat-top"> |
| <div class="stat-label" data-i18n="cache.statVideoCount">视频缓存数量</div> |
| <span class="stat-icon" style="color:#7c3aed"> |
| <svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg> |
| </span> |
| </div> |
| <div class="stat-num" id="s-vid-count" style="color:#7c3aed">—</div> |
| </div> |
| <div class="stat-cell"> |
| <div class="stat-top"> |
| <div class="stat-label" data-i18n="cache.statImageSize">图片缓存大小</div> |
| <span class="stat-icon" style="color:#2563eb"> |
| <svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><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> |
| </div> |
| <div class="stat-num" id="s-img-size" style="color:#2563eb">—</div> |
| <div class="stat-sub" id="s-img-usage">—</div> |
| <div class="stat-progress" aria-hidden="true"> |
| <span class="stat-progress-fill image" id="s-img-progress"></span> |
| </div> |
| </div> |
| <div class="stat-cell"> |
| <div class="stat-top"> |
| <div class="stat-label" data-i18n="cache.statVideoSize">视频缓存大小</div> |
| <span class="stat-icon" style="color:#7c3aed"> |
| <svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><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> |
| </div> |
| <div class="stat-num" id="s-vid-size" style="color:#7c3aed">—</div> |
| <div class="stat-sub" id="s-vid-usage">—</div> |
| <div class="stat-progress" aria-hidden="true"> |
| <span class="stat-progress-fill video" id="s-vid-progress"></span> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="section-head"> |
| <div class="section-title" data-i18n="cache.sectionDetails">缓存明细</div> |
| </div> |
| <div class="sec-tabs" style="margin:-2px 0 14px"> |
| <button class="sec-tab active" onclick="switchView('image')" data-cache-view="image" data-i18n="cache.tabLocalImage">本地图片</button> |
| <button class="sec-tab" onclick="switchView('video')" data-cache-view="video" data-i18n="cache.tabLocalVideo">本地视频</button> |
| <button class="sec-tab" onclick="switchView('online')" data-cache-view="online" data-i18n="cache.tabOnline">在线资产</button> |
| </div> |
|
|
| |
| <div class="sec-panel active" id="panel-local"> |
| <div class="table-toolbar"> |
| <div class="table-toolbar-left" style="display:flex;align-items:center;gap:6px"> |
| <button class="pagi-btn" onclick="prevPage()" id="btn-prev" disabled> |
| <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 class="pagi-btn" onclick="nextPage()" id="btn-next" disabled> |
| <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"> |
| <button class="toolbar-icon-btn toolbar-icon-btn-danger" onclick="clearLocal()" id="btn-clear"> |
| <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 class="toolbar-icon-btn toolbar-icon-btn-danger" onclick="deleteSelectedLocal()" id="btn-local-delete-selected" 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> |
| <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-local-all" onchange="toggleAllLocal(this.checked)"></th> |
| <th data-i18n="cache.colFileName">文件名</th> |
| <th style="width:100px" data-i18n="cache.colSize">大小</th> |
| <th style="width:160px" data-i18n="cache.colModified">修改时间</th> |
| <th style="width:56px" data-i18n="cache.colActions">操作</th> |
| </tr> |
| </thead> |
| <tbody id="cache-tbody"></tbody> |
| </table> |
| </div> |
| </div> |
|
|
| |
| <div class="sec-panel" id="panel-online"> |
| <div class="table-toolbar" style="margin-bottom:14px"> |
| <div class="table-toolbar-left"> |
| <span id="asset-summary" class="asset-summary"></span> |
| </div> |
| <div class="table-toolbar-actions"> |
| <button class="toolbar-icon-btn" onclick="loadAssets()" id="btn-load-assets"> |
| <svg viewBox="0 0 24 24" stroke-width="1.8"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg> |
| </button> |
| <button class="toolbar-icon-btn toolbar-icon-btn-danger" onclick="clearAllAssets()" id="btn-clear-all-assets" 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 class="toolbar-icon-btn toolbar-icon-btn-danger" onclick="clearSelectedAssets()" id="btn-clear-selected-assets" 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 class="toolbar-icon-btn toolbar-icon-btn-danger" onclick="cancelAssetClear()" id="btn-asset-cancel" 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> |
| </div> |
| </div> |
|
|
|
|
| <div class="table-card table-freeze-last" id="assets-table-wrap"> |
| <table> |
| <thead> |
| <tr> |
| <th style="width:36px"><input type="checkbox" class="cb" id="cb-asset-all" onchange="toggleAllAssets(this.checked)"></th> |
| <th style="width:32px"></th> |
| <th data-i18n="cache.colToken">Token</th> |
| <th style="width:72px" data-i18n="cache.colFileCount">文件数</th> |
| <th style="width:56px" data-i18n="cache.colActions">操作</th> |
| </tr> |
| </thead> |
| <tbody id="assets-tbody"></tbody> |
| </table> |
| </div> |
| </div> |
|
|
| </main> |
|
|
| <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.cache.page_size'; |
| const PAGE_SIZE_OPTIONS = [50, 100, 200, 500, 1000, 2000]; |
| const REFRESH_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>'; |
| const SPINNER_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" style="animation:spin .8s linear infinite"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>'; |
| |
| let _type = 'image'; |
| let _view = 'image'; |
| let _page = 1; |
| let _pageSize = loadSavedPageSize(); |
| let _total = 0; |
| let _localItems = []; |
| const _localSel = new Set(); |
| let _assetData = []; |
| let _assetTotal = 0; |
| let _expanded = {}; |
| const _assetSel = new Set(); |
| let _assetTaskId = null; |
| let _assetEs = null; |
| let _assetLoaded = false; |
| let _localViewVersion = 0; |
| let _localViewCacheVersion = -1; |
| let _localViewCache = null; |
| let _assetViewVersion = 0; |
| let _assetViewCacheVersion = -1; |
| let _assetViewCache = null; |
| |
| function tr(key, params, fallback) { |
| const value = t(key, params); |
| return value === key ? (fallback ?? key) : value; |
| } |
| |
| function waitI18n() { |
| return new Promise((resolve) => I18n.onReady(resolve)); |
| } |
| |
| function getLocale() { |
| return I18n.getLang() === 'en' ? 'en-US' : 'zh-CN'; |
| } |
| |
| function getTypeNoun(type = _type) { |
| return tr(type === 'image' ? 'cache.typeImageNoun' : 'cache.typeVideoNoun', null, type === 'image' ? '图片' : '视频'); |
| } |
| |
| function loadSavedPageSize() { |
| const raw = Number(localStorage.getItem(PAGE_SIZE_KEY)); |
| return PAGE_SIZE_OPTIONS.includes(raw) ? raw : 100; |
| } |
| |
| function _fmtRatio(value) { |
| const num = Number(value); |
| if (!Number.isFinite(num)) return tr('cache.na', null, '—'); |
| return `${num.toFixed(1)}%`; |
| } |
| |
| function _usageSummary(stats) { |
| const used = _fmtBytes(stats.size_bytes ?? 0); |
| if (!stats?.limited || !stats?.limit_bytes) { |
| return tr('cache.usageSummaryUnlimited', { used }, `${used} · 未限制`); |
| } |
| const limit = _fmtBytes(stats.limit_bytes); |
| const percent = _fmtRatio(stats.usage_percent); |
| return tr( |
| 'cache.usageSummaryLimited', |
| { used, limit, percent }, |
| `${used} / ${limit} · ${percent}` |
| ); |
| } |
| |
| function _applyUsageBar(id, type, stats) { |
| const el = document.getElementById(id); |
| if (!el) return; |
| const limited = !!stats?.limited && Number(stats.limit_bytes) > 0; |
| const percent = limited ? Number(stats.usage_percent ?? 0) : 0; |
| const safe = Number.isFinite(percent) ? percent : 0; |
| el.style.width = `${Math.max(0, Math.min(safe, 100))}%`; |
| el.classList.toggle('over', limited && safe > 100); |
| el.classList.toggle('image', type === 'image'); |
| el.classList.toggle('video', type === 'video'); |
| } |
| |
| function invalidateLocalView() { |
| _localViewVersion += 1; |
| } |
| |
| function invalidateAssetView() { |
| _assetViewVersion += 1; |
| } |
| |
| function getLocalView() { |
| if (_localViewCache && _localViewCacheVersion === _localViewVersion) return _localViewCache; |
| |
| const selectedNames = []; |
| for (const item of _localItems) { |
| if (_localSel.has(item.name)) selectedNames.push(item.name); |
| } |
| const selectedCount = selectedNames.length; |
| |
| _localViewCacheVersion = _localViewVersion; |
| _localViewCache = { |
| items: _localItems, |
| selectedNames, |
| totalItems: _localItems.length, |
| selectedCount, |
| hasSelection: selectedCount > 0, |
| allSelected: _localItems.length > 0 && selectedCount === _localItems.length, |
| someSelected: selectedCount > 0 && selectedCount < _localItems.length, |
| }; |
| return _localViewCache; |
| } |
| |
| function getAssetView() { |
| if (_assetViewCache && _assetViewCacheVersion === _assetViewVersion) return _assetViewCache; |
| |
| const rows = []; |
| const clearableTokens = []; |
| const selectedClearableTokens = []; |
| let countedAssets = 0; |
| let selectedCount = 0; |
| |
| for (const row of _assetData) { |
| const masked = row.masked || _maskToken(row.token || ''); |
| const count = row.count || 0; |
| const hasItems = count > 0; |
| const isSelected = _assetSel.has(row.token); |
| const assets = Array.isArray(row.assets) ? row.assets : []; |
| |
| countedAssets += count; |
| if (isSelected) selectedCount += 1; |
| if (hasItems) clearableTokens.push(row.token); |
| if (isSelected && hasItems) selectedClearableTokens.push(row.token); |
| |
| rows.push({ |
| row, |
| masked, |
| assets, |
| hasItems, |
| isOpen: !!_expanded[masked], |
| isSelected, |
| }); |
| } |
| |
| const totalRows = rows.length; |
| const selectedClearableCount = selectedClearableTokens.length; |
| |
| _assetViewCacheVersion = _assetViewVersion; |
| _assetViewCache = { |
| loaded: _assetLoaded, |
| rows, |
| totalRows, |
| totalAccounts: totalRows, |
| totalAssets: _assetTotal || countedAssets, |
| selectedCount, |
| hasSelection: selectedCount > 0, |
| allSelected: totalRows > 0 && selectedCount === totalRows, |
| someSelected: selectedCount > 0 && selectedCount < totalRows, |
| clearableTokens, |
| hasClearable: clearableTokens.length > 0, |
| selectedClearableTokens, |
| selectedClearableCount, |
| }; |
| return _assetViewCache; |
| } |
| |
| function setLoadAssetsButton(labelKey, fallback, spinning = false) { |
| const btn = document.getElementById('btn-load-assets'); |
| if (!btn) return; |
| btn.innerHTML = spinning ? SPINNER_ICON : REFRESH_ICON; |
| const label = tr(labelKey, null, fallback); |
| btn.setAttribute('data-tip', label); |
| btn.setAttribute('aria-label', label); |
| btn.setAttribute('title', label); |
| } |
| |
| function applyToolbarI18n() { |
| const tips = [ |
| ['btn-clear', 'cache.clearCurrent', '清空当前缓存'], |
| ['btn-local-delete-selected', 'cache.deleteSelected', '删除选中'], |
| ['btn-load-assets', 'cache.loadAssets', '加载资产列表'], |
| ['btn-clear-all-assets', 'cache.clearAll', '清理全部'], |
| ['btn-clear-selected-assets', 'cache.clearSelected', '清理选中'], |
| ['btn-asset-cancel', 'cache.cancel', '取消'], |
| ]; |
| tips.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); |
| }); |
| } |
| |
| function updateAssetSummary(view = getAssetView()) { |
| if (!view.loaded) { |
| document.getElementById('asset-summary').textContent = tr('cache.assetSummaryIdle', null, '点击右侧获取在线资产'); |
| updateAssetBatchButtons(view); |
| return; |
| } |
| document.getElementById('asset-summary').textContent = tr( |
| 'cache.assetSummary', |
| { accounts: view.totalAccounts, files: view.totalAssets }, |
| `共 ${view.totalAccounts} 个账户 · ${view.totalAssets} 个文件` |
| ); |
| updateAssetBatchButtons(view); |
| } |
| |
| function applyCacheI18n() { |
| document.title = tr('cache.pageTitle', null, 'Grok2API - 缓存管理'); |
| document.querySelectorAll('#page-size-sel option').forEach((opt) => { |
| opt.textContent = tr('cache.pageSizeOption', { n: opt.value }, `${opt.value} / 页`); |
| }); |
| const pageSizeSel = document.getElementById('page-size-sel'); |
| if (pageSizeSel) pageSizeSel.value = String(_pageSize); |
| applyToolbarI18n(); |
| setLoadAssetsButton('cache.loadAssets', '加载资产列表'); |
| _renderPagi(); |
| if (_assetLoaded) { |
| _renderAssets(); |
| } |
| updateAssetSummary(); |
| } |
| |
| 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(); |
| } |
| |
| async function loadStats() { |
| try { |
| const d = await _api('GET', '/cache'); |
| const img = d.local_image || {}; |
| const vid = d.local_video || {}; |
| document.getElementById('s-img-count').textContent = img.count ?? 0; |
| document.getElementById('s-img-size').textContent = _fmtMb(img.size_mb); |
| document.getElementById('s-img-usage').textContent = _usageSummary(img); |
| _applyUsageBar('s-img-progress', 'image', img); |
| document.getElementById('s-vid-count').textContent = vid.count ?? 0; |
| document.getElementById('s-vid-size').textContent = _fmtMb(vid.size_mb); |
| document.getElementById('s-vid-usage').textContent = _usageSummary(vid); |
| _applyUsageBar('s-vid-progress', 'video', vid); |
| } catch (e) { |
| console.warn('stats error', e); |
| } |
| } |
| |
| async function loadList() { |
| try { |
| const d = await _api('GET', `/cache/list?type=${_type}&page=${_page}&page_size=${_pageSize}`); |
| _total = d.total ?? 0; |
| _localItems = d.items || []; |
| invalidateLocalView(); |
| _reconcileLocalSelection(); |
| _renderTable(); |
| _renderPagi(); |
| updateLocalBatchButtons(); |
| } catch (e) { |
| showToast(tr('cache.loadFailed', { message: e.message }, `加载失败: ${e.message}`), 'error'); |
| } |
| } |
| |
| function _renderTable(view = getLocalView()) { |
| const tbody = document.getElementById('cache-tbody'); |
| const items = view.items; |
| syncLocalSelectAll(view); |
| if (!items.length) { |
| tbody.innerHTML = `<tr><td colspan="5" class="table-empty">${_esc(tr('cache.emptyLocal', null, '暂无缓存文件'))}</td></tr>`; |
| return; |
| } |
| tbody.innerHTML = items.map((f) => ` |
| <tr> |
| <td><input type="checkbox" class="cb local-row-cb" data-name="${_esc(f.name)}" ${_localSel.has(f.name) ? 'checked' : ''} onchange="toggleLocalRow(this)"></td> |
| <td> |
| <div class="row-name"> |
| <span class="row-icon" aria-hidden="true"> |
| ${_type === 'image' |
| ? `<svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8.5" cy="9" r="1.5"/><path d="M21 15l-4.5-4.5L7 20"/></svg>` |
| : `<svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><polygon points="16 12 10 8.5 10 15.5 16 12"/><rect x="4" y="5" width="16" height="14" rx="2"/></svg>` |
| } |
| </span> |
| <span class="row-value">${_esc(f.name)}</span> |
| </div> |
| </td> |
| <td>${_fmtBytes(f.size_bytes)}</td> |
| <td>${_fmtTime(f.modified_at)}</td> |
| <td><div class="row-actions"><button class="row-action" onclick="viewFile('${_esc(f.name)}')" title="${_esc(tr('cache.actionView', null, '查看'))}" aria-label="${_esc(tr('cache.actionView', null, '查看'))}"><svg viewBox="0 0 24 24" stroke-width="1.8"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6Z"/><circle cx="12" cy="12" r="3"/></svg></button><button class="row-action row-action-danger" onclick="deleteFile('${_esc(f.name)}')" title="${_esc(tr('cache.actionDelete', null, '删除'))}" aria-label="${_esc(tr('cache.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>`).join(''); |
| syncLocalSelectAll(view); |
| } |
| |
| function _renderPagi() { |
| const pages = Math.max(1, Math.ceil(_total / _pageSize)); |
| document.getElementById('pagi-page').textContent = tr('cache.pageIndicator', { current: _page, total: pages }, `第 ${_page} / ${pages} 页`); |
| document.getElementById('btn-prev').disabled = _page <= 1; |
| document.getElementById('btn-next').disabled = _page >= pages; |
| } |
| |
| function prevPage() { |
| if (_page > 1) { |
| _page -= 1; |
| loadList(); |
| } |
| } |
| |
| function nextPage() { |
| const pages = Math.max(1, Math.ceil(_total / _pageSize)); |
| if (_page < pages) { |
| _page += 1; |
| loadList(); |
| } |
| } |
| |
| function changePageSize(value) { |
| const next = Number(value); |
| _pageSize = PAGE_SIZE_OPTIONS.includes(next) ? next : 100; |
| localStorage.setItem(PAGE_SIZE_KEY, String(_pageSize)); |
| _page = 1; |
| loadList(); |
| } |
| |
| function switchView(view) { |
| _view = view; |
| const isOnline = view === 'online'; |
| document.querySelectorAll('[data-cache-view]').forEach((el) => { |
| el.classList.toggle('active', el.dataset.cacheView === view); |
| }); |
| document.getElementById('panel-local').classList.toggle('active', !isOnline); |
| document.getElementById('panel-online').classList.toggle('active', isOnline); |
| if (isOnline) { |
| clearLocalSelection(false); |
| return; |
| } |
| clearAssetSelection(false); |
| _type = view; |
| _page = 1; |
| loadList(); |
| } |
| |
| async function clearLocal() { |
| if (!confirm(tr('cache.clearLocalConfirm', { type: getTypeNoun() }, `确定要清空所有本地${getTypeNoun()}缓存吗?`))) return; |
| try { |
| const d = await _api('POST', '/cache/clear', { type: _type }); |
| const removed = d.result?.removed ?? 0; |
| showToast(tr('cache.clearLocalDone', { count: removed, type: getTypeNoun() }, `已清除 ${removed} 个${getTypeNoun()}文件`)); |
| _page = 1; |
| loadStats(); |
| loadList(); |
| } catch (e) { |
| showToast(tr('cache.clearFailed', { message: e.message }, `清除失败: ${e.message}`), 'error'); |
| } |
| } |
| |
| async function deleteFile(name) { |
| try { |
| await _api('POST', '/cache/item/delete', { type: _type, name }); |
| _localSel.delete(name); |
| showToast(tr('cache.deleted', null, '已删除')); |
| loadStats(); |
| loadList(); |
| } catch (e) { |
| showToast(tr('cache.deleteFailed', { message: e.message }, `删除失败: ${e.message}`), 'error'); |
| } |
| } |
| |
| function viewFile(name) { |
| const url = localFileUrl(name); |
| if (!url) { |
| showToast(tr('cache.viewUnavailable', null, '当前文件暂不支持查看'), 'error'); |
| return; |
| } |
| window.open(url, '_blank', 'noopener'); |
| } |
| |
| function localFileUrl(name) { |
| const raw = String(name || ''); |
| const dot = raw.lastIndexOf('.'); |
| if (dot <= 0) return ''; |
| const id = raw.slice(0, dot); |
| return _type === 'video' |
| ? `/v1/files/video?id=${encodeURIComponent(id)}` |
| : `/v1/files/image?id=${encodeURIComponent(id)}`; |
| } |
| |
| async function loadAssets() { |
| const btn = document.getElementById('btn-load-assets'); |
| btn.disabled = true; |
| setLoadAssetsButton('cache.loading', '加载中…', true); |
| try { |
| const d = await _api('GET', '/assets'); |
| _assetLoaded = true; |
| _assetData = d.tokens || []; |
| _assetTotal = d.total_assets ?? _assetData.reduce((sum, row) => sum + (row.count || 0), 0); |
| _expanded = {}; |
| invalidateAssetView(); |
| _reconcileAssetSelection(); |
| _renderAssets(); |
| updateAssetSummary(); |
| } catch (e) { |
| _assetLoaded = false; |
| _assetData = []; |
| _assetTotal = 0; |
| _expanded = {}; |
| _assetSel.clear(); |
| invalidateAssetView(); |
| _renderAssets(); |
| updateAssetSummary(); |
| showToast(tr('cache.loadFailed', { message: e.message }, `加载失败: ${e.message}`), 'error'); |
| } finally { |
| btn.disabled = false; |
| setLoadAssetsButton('cache.refreshList', '刷新列表'); |
| } |
| } |
| |
| function _renderAssets(view = getAssetView()) { |
| const tbody = document.getElementById('assets-tbody'); |
| syncAssetSelectAll(view); |
| if (!view.loaded) { |
| tbody.innerHTML = `<tr><td colspan="5" class="table-empty">${_esc(tr('cache.emptyAssetsIdle', null, '点击右上角获取在线资产列表'))}</td></tr>`; |
| return; |
| } |
| if (!view.totalRows) { |
| tbody.innerHTML = `<tr><td colspan="5" class="table-empty">${_esc(tr('cache.emptyAccounts', null, '暂无账户数据'))}</td></tr>`; |
| return; |
| } |
| |
| const expandLabel = tr('cache.expand', null, '展开'); |
| const collapseLabel = tr('cache.collapse', null, '收起'); |
| const clearLabel = tr('cache.actionClear', null, '清理'); |
| const deleteLabel = tr('cache.actionDelete', null, '删除'); |
| const naLabel = tr('cache.na', null, '—'); |
| |
| const rows = []; |
| for (const { row, masked, assets, hasItems, isOpen, isSelected } of view.rows) { |
| rows.push(` |
| <tr> |
| <td><input type="checkbox" class="cb asset-row-cb" data-token="${_esc(row.token)}" ${isSelected ? 'checked' : ''} onchange="toggleAssetRow(this)"></td> |
| <td style="padding:10px 8px 10px 14px"> |
| ${hasItems ? `<button class="expand-btn${isOpen ? ' open' : ''}" onclick="toggleExpand('${_esc(masked)}')" title="${_esc(isOpen ? collapseLabel : expandLabel)}"> |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 18 15 12 9 6"/></svg> |
| </button>` : '<span style="width:22px;display:inline-block"></span>'} |
| </td> |
| <td> |
| <div class="row-name"> |
| <span class="row-icon" aria-hidden="true"> |
| <svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><path d="M7 10V8a5 5 0 0 1 10 0v2"/><rect x="4" y="10" width="16" height="10" rx="2"/><circle cx="12" cy="15" r="1"/></svg> |
| </span> |
| <span class="row-value">${_esc(masked)}</span> |
| </div> |
| ${row.error ? `<span class="asset-err" title="${_esc(row.error)}">⚠ ${_esc(row.error)}</span>` : ''} |
| </td> |
| <td> |
| <span class="asset-count-badge${hasItems ? ' has-items' : ''}">${row.count}</span> |
| </td> |
| <td> |
| ${hasItems ? `<div class="row-actions"><button class="row-action row-action-danger" onclick="clearTokenAssets('${_esc(row.token)}','${_esc(masked)}')" title="${_esc(clearLabel)}" aria-label="${_esc(clearLabel)}"><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>`); |
| |
| if (isOpen && hasItems) { |
| for (const asset of assets) { |
| rows.push(` |
| <tr class="sub-row"> |
| <td></td> |
| <td></td> |
| <td> |
| <div class="row-name"> |
| <span class="row-icon" aria-hidden="true"> |
| <svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><path d="M8 3h6l5 5v13H8a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M14 3v5h5"/></svg> |
| </span> |
| <span class="row-value" style="font-size:11px">${_esc(asset.name || asset.id)}</span> |
| ${asset.content_type ? `<span style="color:#aaa;font-size:11px">${_esc(asset.content_type)}</span>` : ''} |
| </div> |
| </td> |
| <td style="color:#aaa;font-size:11px">${asset.size ? _fmtBytes(asset.size) : _esc(naLabel)}</td> |
| <td> |
| <div class="row-actions"><button class="row-action row-action-danger" onclick="deleteAssetItem('${_esc(row.token)}','${_esc(masked)}','${_esc(asset.id)}')" title="${_esc(deleteLabel)}" aria-label="${_esc(deleteLabel)}"><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>`); |
| } |
| } |
| } |
| tbody.innerHTML = rows.join(''); |
| syncAssetSelectAll(view); |
| updateAssetBatchButtons(view); |
| } |
| |
| function toggleExpand(masked) { |
| _expanded[masked] = !_expanded[masked]; |
| invalidateAssetView(); |
| _renderAssets(); |
| } |
| |
| async function clearTokenAssets(token, masked) { |
| if (!confirm(tr('cache.clearTokenConfirm', { token: masked }, `确定要清理 ${masked} 的全部在线资产吗?`))) return; |
| try { |
| const d = await _api('POST', '/assets/clear-token', { token }); |
| showToast(tr('cache.clearTokenDone', { count: d.deleted ?? 0 }, `已删除 ${d.deleted ?? 0} 个文件`)); |
| await loadAssets(); |
| } catch (e) { |
| showToast(tr('cache.clearFailed', { message: e.message }, `清理失败: ${e.message}`), 'error'); |
| } |
| } |
| |
| async function deleteAssetItem(token, masked, assetId) { |
| try { |
| await _api('POST', '/assets/delete-item', { token, asset_id: assetId }); |
| showToast(tr('cache.deleted', null, '已删除')); |
| const row = _assetData.find((item) => item.token === token); |
| if (row) { |
| row.assets = row.assets.filter((asset) => asset.id !== assetId); |
| row.count = row.assets.length; |
| } |
| _assetTotal = _assetData.reduce((sum, item) => sum + (item.count || 0), 0); |
| invalidateAssetView(); |
| _renderAssets(); |
| updateAssetSummary(); |
| } catch (e) { |
| showToast(tr('cache.deleteFailed', { message: e.message }, `删除失败: ${e.message}`), 'error'); |
| } |
| } |
| |
| function _reconcileLocalSelection() { |
| const names = new Set(_localItems.map((item) => item.name)); |
| let changed = false; |
| [..._localSel].forEach((name) => { |
| if (!names.has(name)) { |
| _localSel.delete(name); |
| changed = true; |
| } |
| }); |
| if (changed) invalidateLocalView(); |
| } |
| |
| function syncLocalSelectAll(view = getLocalView()) { |
| const cbAll = document.getElementById('cb-local-all'); |
| if (!cbAll) return; |
| if (!view.totalItems) { |
| cbAll.checked = false; |
| cbAll.indeterminate = false; |
| return; |
| } |
| cbAll.checked = view.allSelected; |
| cbAll.indeterminate = view.someSelected; |
| } |
| |
| function toggleAllLocal(checked) { |
| const view = getLocalView(); |
| view.items.forEach((item) => { |
| if (checked) _localSel.add(item.name); |
| else _localSel.delete(item.name); |
| }); |
| invalidateLocalView(); |
| document.querySelectorAll('.local-row-cb').forEach((el) => { el.checked = checked; }); |
| syncLocalSelectAll(); |
| updateLocalBatchButtons(); |
| } |
| |
| function toggleLocalRow(el) { |
| const name = el.dataset.name; |
| if (!name) return; |
| if (el.checked) _localSel.add(name); |
| else _localSel.delete(name); |
| invalidateLocalView(); |
| syncLocalSelectAll(); |
| updateLocalBatchButtons(); |
| } |
| |
| function clearLocalSelection(render = true) { |
| _localSel.clear(); |
| invalidateLocalView(); |
| syncLocalSelectAll(); |
| updateLocalBatchButtons(); |
| if (render) { |
| document.querySelectorAll('.local-row-cb').forEach((el) => { el.checked = false; }); |
| } |
| } |
| |
| function updateLocalBatchButtons(view = getLocalView()) { |
| const show = view.hasSelection; |
| const btnDelete = document.getElementById('btn-local-delete-selected'); |
| const btnClear = document.getElementById('btn-clear'); |
| if (btnDelete) btnDelete.style.display = show ? '' : 'none'; |
| if (btnClear) btnClear.style.display = show ? 'none' : ''; |
| } |
| |
| async function deleteSelectedLocal() { |
| const names = getLocalView().selectedNames; |
| if (!names.length) return; |
| if (!confirm(tr('cache.deleteSelectedConfirm', { n: names.length }, `确定要删除选中的 ${names.length} 个文件吗?`))) return; |
| try { |
| const d = await _api('POST', '/cache/items/delete', { type: _type, names }); |
| const deleted = d.result?.deleted ?? names.length; |
| showToast(tr('cache.deleteSelectedDone', { count: deleted }, `已删除 ${deleted} 个文件`), 'success'); |
| clearLocalSelection(); |
| loadStats(); |
| loadList(); |
| } catch (e) { |
| showToast(tr('cache.deleteFailed', { message: e.message }, `删除失败: ${e.message}`), 'error'); |
| } |
| } |
| |
| function _reconcileAssetSelection() { |
| const tokens = new Set(_assetData.map((row) => row.token)); |
| let changed = false; |
| [..._assetSel].forEach((token) => { |
| if (!tokens.has(token)) { |
| _assetSel.delete(token); |
| changed = true; |
| } |
| }); |
| if (changed) invalidateAssetView(); |
| } |
| |
| function syncAssetSelectAll(view = getAssetView()) { |
| const cbAll = document.getElementById('cb-asset-all'); |
| if (!cbAll) return; |
| if (!view.totalRows) { |
| cbAll.checked = false; |
| cbAll.indeterminate = false; |
| return; |
| } |
| cbAll.checked = view.allSelected; |
| cbAll.indeterminate = view.someSelected; |
| } |
| |
| function toggleAllAssets(checked) { |
| const view = getAssetView(); |
| view.rows.forEach(({ row }) => { |
| if (checked) _assetSel.add(row.token); |
| else _assetSel.delete(row.token); |
| }); |
| invalidateAssetView(); |
| document.querySelectorAll('.asset-row-cb').forEach((el) => { el.checked = checked; }); |
| syncAssetSelectAll(); |
| updateAssetBatchButtons(); |
| } |
| |
| function toggleAssetRow(el) { |
| const token = el.dataset.token; |
| if (!token) return; |
| if (el.checked) _assetSel.add(token); |
| else _assetSel.delete(token); |
| invalidateAssetView(); |
| syncAssetSelectAll(); |
| updateAssetBatchButtons(); |
| } |
| |
| function clearAssetSelection(render = true) { |
| _assetSel.clear(); |
| invalidateAssetView(); |
| syncAssetSelectAll(); |
| updateAssetBatchButtons(); |
| if (render) { |
| document.querySelectorAll('.asset-row-cb').forEach((el) => { el.checked = false; }); |
| } |
| } |
| |
| function updateAssetBatchButtons(view = getAssetView()) { |
| const btnClearAll = document.getElementById('btn-clear-all-assets'); |
| const btnClearSelected = document.getElementById('btn-clear-selected-assets'); |
| if (btnClearSelected) btnClearSelected.style.display = view.selectedClearableCount > 0 ? '' : 'none'; |
| if (btnClearAll) btnClearAll.style.display = !view.hasSelection && view.hasClearable ? '' : 'none'; |
| } |
| |
| async function clearSelectedAssets() { |
| const tokens = getAssetView().selectedClearableTokens; |
| if (!tokens.length) return; |
| await runAssetClear(tokens); |
| } |
| |
| async function clearAllAssets() { |
| const tokens = getAssetView().clearableTokens; |
| if (!tokens.length) return; |
| await runAssetClear(tokens); |
| } |
| |
| async function runAssetClear(tokens) { |
| const allTokens = getAssetView().clearableTokens; |
| const isAll = tokens.length === allTokens.length; |
| if (isAll) { |
| if (!confirm(tr('cache.clearAllConfirm', { n: tokens.length }, `确定要清理全部 ${tokens.length} 个账户的在线资产吗?此操作不可撤销。`))) return; |
| } else { |
| if (!confirm(tr('cache.clearSelectedConfirm', { n: tokens.length }, `确定要清理选中的 ${tokens.length} 个账户在线资产吗?`))) return; |
| } |
| |
| const btnClearAll = document.getElementById('btn-clear-all-assets'); |
| const btnClearSelected = document.getElementById('btn-clear-selected-assets'); |
| const btnCancel = document.getElementById('btn-asset-cancel'); |
| |
| if (btnClearAll) btnClearAll.style.display = 'none'; |
| if (btnClearSelected) btnClearSelected.style.display = 'none'; |
| if (btnCancel) btnCancel.style.display = ''; |
| const progress = showProgressToast(tr('cache.clearingAssets', null, '正在清理在线资产…')); |
| let finalReceived = false; |
| |
| function cleanup() { |
| if (btnCancel) btnCancel.style.display = 'none'; |
| updateAssetBatchButtons(); |
| } |
| |
| function done(es) { |
| finalReceived = true; |
| es.close(); |
| _assetEs = null; |
| cleanup(); |
| } |
| |
| try { |
| const d = await _api('POST', '/batch/cache-clear?async=true', { tokens }); |
| _assetTaskId = d.task_id; |
| const total = d.total || tokens.length; |
| const key = await adminKey.get(); |
| const es = new EventSource(`${ADMIN_API}/batch/${_assetTaskId}/stream?app_key=${encodeURIComponent(key)}`); |
| _assetEs = es; |
| |
| es.onmessage = (event) => { |
| const ev = JSON.parse(event.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 summary = ev.result?.summary || {}; |
| progress.finish( |
| tr('cache.clearAllDone', { ok: summary.ok ?? 0, fail: summary.fail ?? 0 }, `清理完成:成功 ${summary.ok ?? 0},失败 ${summary.fail ?? 0}`), |
| (summary.fail ?? 0) > 0 ? 'error' : 'success' |
| ); |
| clearAssetSelection(false); |
| loadAssets(); |
| } else if (ev.type === 'cancelled') { |
| progress.finish(tr('cache.cancelled', null, '已取消'), 'error'); |
| updateAssetBatchButtons(); |
| } else { |
| progress.finish(ev.error || tr('cache.clearFailedPlain', null, '清理失败'), 'error'); |
| updateAssetBatchButtons(); |
| } |
| } |
| }; |
| |
| es.onerror = function() { |
| if (finalReceived) { |
| _assetEs = null; |
| return; |
| } |
| if (this.readyState === EventSource.CLOSED) { |
| _assetEs = null; |
| cleanup(); |
| progress.finish(tr('cache.connectionInterrupted', null, '连接中断'), 'error'); |
| } |
| }; |
| } catch (e) { |
| cleanup(); |
| progress.finish(tr('cache.startFailed', { message: e.message }, `启动失败: ${e.message}`), 'error'); |
| } |
| } |
| |
| async function cancelAssetClear() { |
| if (!_assetTaskId) return; |
| showToast(tr('cache.cancelInProgress', null, '已暂停,请耐心等待本批次完成…'), 'info'); |
| try { |
| await _api('POST', `/batch/${_assetTaskId}/cancel`); |
| } catch {} |
| } |
| |
| function _fmtMb(mb) { |
| if (mb == null) return tr('cache.na', null, '—'); |
| if (mb < 1) return `${Math.round(mb * 1024)} KB`; |
| return `${mb.toFixed(1)} MB`; |
| } |
| |
| function _fmtBytes(bytes) { |
| if (!bytes) return '0 B'; |
| if (bytes < 1024) return `${bytes} B`; |
| if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`; |
| return `${(bytes / 1048576).toFixed(1)} MB`; |
| } |
| |
| function _fmtTime(ts) { |
| if (!ts) return tr('cache.na', null, '—'); |
| return new Date(ts * 1000).toLocaleString(getLocale(), { hour12: false }); |
| } |
| |
| function _maskToken(token) { |
| return token.length > 16 ? `${token.slice(0, 8)}…${token.slice(-4)}` : token; |
| } |
| |
| function _esc(value) { |
| return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); |
| } |
| |
| (async () => { |
| await waitI18n(); |
| applyCacheI18n(); |
| await renderAdminHeader?.(); |
| await renderSiteFooter?.(); |
| const key = await adminKey.get(); |
| if (!key || !await verifyKey(ADMIN_API + '/verify', key).catch(() => false)) { |
| location.href = '/admin/login'; |
| return; |
| } |
| loadStats(); |
| loadList(); |
| _renderAssets(); |
| })(); |
| </script> |
| </body> |
| </html> |
|
|