hermesinho
Initial import of grok2api with Dockerfile for HF Spaces
bdc2878
<!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 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;
}
.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(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 }
/* Section tabs */
.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 }
/* Toolbar */
.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 */
.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;
}
/* Online asset panel */
/* asset sub-rows */
.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>
<!-- ── Header (rendered by admin-header.js) ─────────────────────────────── -->
<div id="admin-header" data-active="/admin/cache"></div>
<main class="admin-main" style="padding-bottom:60px">
<!-- Page header -->
<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>
<!-- Stats -->
<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>
<!-- ── 本地缓存 panel ─────────────────────────────────────────── -->
<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>
<!-- ── 在线资产 panel ──────────────────────────────────────────── -->
<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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
(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>