let apiKey = ''; let currentScope = 'none'; let currentToken = ''; let currentSection = 'image'; const accountMap = new Map(); const selectedTokens = new Set(); const selectedLocal = { image: new Set(), video: new Set() }; const ui = {}; const byId = (id) => document.getElementById(id); const loadFailed = new Map(); const deleteFailed = new Map(); let currentBatchAction = null; let lastBatchAction = null; let isLocalDeleting = false; const cacheListState = { image: { loaded: false, visible: false, items: [] }, video: { loaded: false, visible: false, items: [] } }; const UI_MAP = { imgCount: 'img-count', imgSize: 'img-size', videoCount: 'video-count', videoSize: 'video-size', onlineCount: 'online-count', onlineStatus: 'online-status', onlineLastClear: 'online-last-clear', accountTableBody: 'account-table-body', accountEmpty: 'account-empty', selectAll: 'select-all', localImageSelectAll: 'local-image-select-all', localVideoSelectAll: 'local-video-select-all', selectedCount: 'selected-count', batchActions: 'batch-actions', loadBtn: 'btn-load-stats', deleteBtn: 'btn-delete-assets', localCacheLists: 'local-cache-lists', localImageList: 'local-image-list', localVideoList: 'local-video-list', localImageBody: 'local-image-body', localVideoBody: 'local-video-body', onlineAssetsTable: 'online-assets-table', batchProgress: 'batch-progress', batchProgressText: 'batch-progress-text', pauseActionBtn: 'btn-pause-action', stopActionBtn: 'btn-stop-action', failureDetailsBtn: 'btn-failure-details', confirmDialog: 'confirm-dialog', confirmMessage: 'confirm-message', confirmOk: 'confirm-ok', confirmCancel: 'confirm-cancel', failureDialog: 'failure-dialog', failureList: 'failure-list', failureClose: 'failure-close', failureRetry: 'failure-retry' }; function setText(el, text) { if (el) el.textContent = text; } function resolveOnlineStatus(status) { if (status === 'ok') { return { text: '连接正常', className: 'text-xs text-green-600 mt-1' }; } if (status === 'no_token') { return { text: '无可用 Token', className: 'text-xs text-orange-500 mt-1' }; } if (status === 'not_loaded') { return { text: '未加载', className: 'text-xs text-[var(--accents-4)] mt-1' }; } return { text: '无法连接', className: 'text-xs text-red-500 mt-1' }; } function createIconButton(title, svg, onClick) { const btn = document.createElement('button'); btn.className = 'cache-icon-button'; btn.title = title; btn.innerHTML = svg; btn.addEventListener('click', onClick); return btn; } async function init() { apiKey = await ensureAdminKey(); if (apiKey === null) return; cacheUI(); setupCacheCards(); setupConfirmDialog(); setupFailureDialog(); setupBatchControls(); await loadStats(); await showCacheSection('image'); } function setupCacheCards() { if (!ui.cacheCards) return; ui.cacheCards.forEach(card => { card.addEventListener('click', () => { const type = card.getAttribute('data-type'); if (type) toggleCacheList(type); }); }); } function cacheUI() { Object.entries(UI_MAP).forEach(([key, id]) => { ui[key] = byId(id); }); ui.cacheCards = document.querySelectorAll('.cache-card'); } function ensureUI() { if (!ui.batchActions) cacheUI(); } let confirmResolver = null; function setupConfirmDialog() { const dialog = ui.confirmDialog; if (!dialog) return; dialog.addEventListener('close', () => { if (!confirmResolver) return; const ok = dialog.returnValue === 'ok'; confirmResolver(ok); confirmResolver = null; }); dialog.addEventListener('cancel', (event) => { event.preventDefault(); dialog.close('cancel'); }); dialog.addEventListener('click', (event) => { if (event.target === dialog) { dialog.close('cancel'); } }); if (ui.confirmOk) { ui.confirmOk.addEventListener('click', () => dialog.close('ok')); } if (ui.confirmCancel) { ui.confirmCancel.addEventListener('click', () => dialog.close('cancel')); } } function setupFailureDialog() { const dialog = ui.failureDialog; if (!dialog) return; if (ui.failureClose) { ui.failureClose.addEventListener('click', () => dialog.close()); } if (ui.failureRetry) { ui.failureRetry.addEventListener('click', () => retryFailed()); } dialog.addEventListener('click', (event) => { if (event.target === dialog) { dialog.close(); } }); } function setupBatchControls() { if (ui.pauseActionBtn) { ui.pauseActionBtn.addEventListener('click', () => togglePause()); } if (ui.stopActionBtn) { ui.stopActionBtn.addEventListener('click', () => stopActiveBatch()); } if (ui.failureDetailsBtn) { ui.failureDetailsBtn.addEventListener('click', () => showFailureDetails()); } } function confirmAction(message, options = {}) { ensureUI(); const dialog = ui.confirmDialog; if (!dialog || typeof dialog.showModal !== 'function') { return Promise.resolve(window.confirm(message)); } if (ui.confirmMessage) ui.confirmMessage.textContent = message; if (ui.confirmOk) ui.confirmOk.textContent = options.okText || '确定'; if (ui.confirmCancel) ui.confirmCancel.textContent = options.cancelText || '取消'; return new Promise(resolve => { confirmResolver = resolve; dialog.showModal(); }); } function formatTime(ms) { if (!ms) return ''; const dt = new Date(ms); return dt.toLocaleString('zh-CN', { hour12: false }); } function calcPercent(processed, total) { return total ? Math.floor((processed / total) * 100) : 0; } const accountStates = new Map(); let isBatchLoading = false; let isLoadPaused = false; let batchQueue = []; let batchTokens = []; let batchTotal = 0; let batchProcessed = 0; let isBatchDeleting = false; let isDeletePaused = false; let deleteTotal = 0; let deleteProcessed = 0; let currentBatchTaskId = null; let batchEventSource = null; async function loadStats(options = {}) { try { ensureUI(); const merge = options.merge === true; const silent = options.silent === true; const params = new URLSearchParams(); if (options.tokens && options.tokens.length) { params.set('tokens', options.tokens.join(',')); currentScope = 'selected'; } else if (options.scope === 'all') { params.set('scope', 'all'); currentScope = 'all'; } else if (currentToken) { params.set('token', currentToken); currentScope = 'single'; } else { currentScope = 'none'; } const url = `/v1/admin/cache${params.toString() ? `?${params.toString()}` : ''}`; const res = await fetch(url, { headers: buildAuthHeaders(apiKey) }); if (res.status === 401) { logout(); return; } const data = await res.json(); applyStatsData(data, merge); return data; } catch (e) { if (!silent) showToast('加载统计失败', 'error'); return null; } } function applyStatsData(data, merge = false) { if (!merge) { accountStates.clear(); } setText(ui.imgCount, data.local_image.count); setText(ui.imgSize, `${data.local_image.size_mb} MB`); setText(ui.videoCount, data.local_video.count); setText(ui.videoSize, `${data.local_video.size_mb} MB`); setText(ui.onlineCount, data.online.count); const online = data.online || {}; const status = resolveOnlineStatus(online.status); setOnlineStatus(status.text, status.className); // Update master accounts list updateAccountSelect(data.online_accounts || []); // Update dynamic states const details = Array.isArray(data.online_details) ? data.online_details : []; details.forEach(detail => { accountStates.set(detail.token, { count: detail.count, status: detail.status, last_asset_clear_at: detail.last_asset_clear_at }); }); if (online?.token) { accountStates.set(online.token, { count: online.count, status: online.status, last_asset_clear_at: online.last_asset_clear_at }); } if (data.online_scope === 'all') { currentScope = 'all'; currentToken = ''; } else if (data.online_scope === 'selected') { currentScope = 'selected'; } else if (online.token) { currentScope = 'single'; currentToken = online.token; } else { currentScope = 'none'; } const timeText = formatTime(online.last_asset_clear_at); setText(ui.onlineLastClear, timeText ? `上次清空:${timeText}` : ''); renderAccountTable(data); } function updateAccountSelect(accounts) { accountMap.clear(); accounts.forEach(account => { accountMap.set(account.token, account); }); } function renderAccountTable(data) { const tbody = ui.accountTableBody; const empty = ui.accountEmpty; if (!tbody || !empty) return; const details = Array.isArray(data.online_details) ? data.online_details : []; const accounts = Array.isArray(data.online_accounts) ? data.online_accounts : []; const detailsMap = new Map(details.map(item => [item.token, item])); let rows = []; if (accounts.length > 0) { rows = accounts.map(item => { const detail = detailsMap.get(item.token); const state = accountStates.get(item.token); let count = '-'; let status = 'not_loaded'; let last_asset_clear_at = item.last_asset_clear_at; if (detail) { count = detail.count; status = detail.status; last_asset_clear_at = detail.last_asset_clear_at ?? last_asset_clear_at; } else if (item.token === data.online?.token) { count = data.online.count; status = data.online.status; last_asset_clear_at = data.online.last_asset_clear_at ?? last_asset_clear_at; } else if (state) { count = state.count; status = state.status; last_asset_clear_at = state.last_asset_clear_at ?? last_asset_clear_at; } return { token: item.token, token_masked: item.token_masked, pool: item.pool, count, status, last_asset_clear_at }; }); } else if (details.length > 0) { rows = details.map(item => ({ token: item.token, token_masked: item.token_masked, pool: (accountMap.get(item.token) || {}).pool || '-', count: item.count, status: item.status, last_asset_clear_at: item.last_asset_clear_at })); } if (rows.length === 0) { tbody.replaceChildren(); empty.classList.remove('hidden'); return; } empty.classList.add('hidden'); const selected = selectedTokens; const fragment = document.createDocumentFragment(); rows.forEach(row => { const tr = document.createElement('tr'); const isSelected = selected.has(row.token); if (isSelected) tr.classList.add('row-selected'); const tdCheck = document.createElement('td'); tdCheck.className = 'text-center'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.className = 'checkbox'; checkbox.checked = isSelected; checkbox.setAttribute('data-token', row.token); checkbox.addEventListener('change', () => toggleSelect(row.token, checkbox)); tdCheck.appendChild(checkbox); const tdToken = document.createElement('td'); tdToken.className = 'text-left'; const tokenWrap = document.createElement('div'); tokenWrap.className = 'flex items-center gap-2'; const tokenText = document.createElement('span'); tokenText.className = 'font-mono text-xs text-gray-500'; tokenText.title = row.token; tokenText.textContent = row.token_masked || row.token; tokenWrap.appendChild(tokenText); tdToken.appendChild(tokenWrap); const tdPool = document.createElement('td'); tdPool.className = 'text-center'; const poolBadge = document.createElement('span'); poolBadge.className = 'badge badge-gray'; poolBadge.textContent = row.pool || '-'; tdPool.appendChild(poolBadge); const tdCount = document.createElement('td'); tdCount.className = 'text-center'; const countBadge = document.createElement('span'); countBadge.className = 'badge badge-gray'; countBadge.textContent = row.count === '-' ? '未加载' : row.count; tdCount.appendChild(countBadge); const tdLast = document.createElement('td'); tdLast.className = 'text-left text-xs text-gray-500'; tdLast.textContent = formatTime(row.last_asset_clear_at) || '-'; const tdActions = document.createElement('td'); tdActions.className = 'text-center'; const actionsWrap = document.createElement('div'); actionsWrap.className = 'flex items-center justify-center gap-2'; actionsWrap.appendChild(createIconButton( '清空', ``, () => clearOnlineCache(row.token) )); tdActions.appendChild(actionsWrap); tr.appendChild(tdCheck); tr.appendChild(tdToken); tr.appendChild(tdPool); tr.appendChild(tdCount); tr.appendChild(tdLast); tr.appendChild(tdActions); fragment.appendChild(tr); }); tbody.replaceChildren(fragment); syncSelectAllState(); updateSelectedCount(); updateBatchActionsVisibility(); } async function clearCache(type) { const ok = await confirmAction(`确定要清空本地${type === 'image' ? '图片' : '视频'}缓存吗?`, { okText: '清空' }); if (!ok) return; try { const res = await fetch('/v1/admin/cache/clear', { method: 'POST', headers: { 'Content-Type': 'application/json', ...buildAuthHeaders(apiKey) }, body: JSON.stringify({ type }) }); const data = await res.json(); if (data.status === 'success') { showToast(`清理成功,释放 ${data.result.size_mb} MB`, 'success'); const state = cacheListState[type]; if (state) { state.items = []; state.loaded = true; } if (selectedLocal[type]) selectedLocal[type].clear(); if (state && state.visible) { renderLocalCacheList(type, []); } else { syncLocalSelectAllState(type); updateSelectedCount(); } loadStats(); } else { showToast('清理失败', 'error'); } } catch (e) { showToast('请求失败', 'error'); } } function toggleSelect(token, checkbox) { if (checkbox && checkbox.checked) { selectedTokens.add(token); } else { selectedTokens.delete(token); } if (checkbox) { const row = checkbox.closest('tr'); if (row) row.classList.toggle('row-selected', checkbox.checked); } syncSelectAllState(); updateSelectedCount(); } function toggleSelectAll(checkbox) { const shouldSelect = checkbox.checked; selectedTokens.clear(); if (shouldSelect) { accountMap.forEach((_, token) => selectedTokens.add(token)); } syncRowCheckboxes(); updateSelectedCount(); } function toggleLocalSelect(type, name, checkbox) { const set = selectedLocal[type]; if (!set) return; if (checkbox && checkbox.checked) { set.add(name); } else { set.delete(name); } if (checkbox) { const row = checkbox.closest('tr'); if (row) row.classList.toggle('row-selected', checkbox.checked); } syncLocalSelectAllState(type); updateSelectedCount(); } function toggleLocalSelectAll(type, checkbox) { const set = selectedLocal[type]; if (!set) return; const shouldSelect = checkbox && checkbox.checked; set.clear(); if (shouldSelect) { const items = cacheListState[type]?.items || []; items.forEach(item => { if (item && item.name) set.add(item.name); }); } syncLocalRowCheckboxes(type); updateSelectedCount(); } function syncLocalRowCheckboxes(type) { const body = type === 'image' ? ui.localImageBody : ui.localVideoBody; if (!body) return; const set = selectedLocal[type]; const checkboxes = body.querySelectorAll('input[type="checkbox"].checkbox'); checkboxes.forEach(cb => { const name = cb.getAttribute('data-name'); if (!name) return; cb.checked = set.has(name); const row = cb.closest('tr'); if (row) row.classList.toggle('row-selected', cb.checked); }); syncLocalSelectAllState(type); } function syncLocalSelectAllState(type) { const selectAll = type === 'image' ? ui.localImageSelectAll : ui.localVideoSelectAll; if (!selectAll) return; const total = cacheListState[type]?.items?.length || 0; const selected = selectedLocal[type]?.size || 0; selectAll.checked = total > 0 && selected === total; selectAll.indeterminate = selected > 0 && selected < total; } function syncRowCheckboxes() { const tbody = ui.accountTableBody; if (!tbody) return; const checkboxes = tbody.querySelectorAll('input[type="checkbox"].checkbox'); checkboxes.forEach(cb => { const token = cb.getAttribute('data-token'); if (!token) return; cb.checked = selectedTokens.has(token); const row = cb.closest('tr'); if (row) row.classList.toggle('row-selected', cb.checked); }); } function syncSelectAllState() { const selectAll = ui.selectAll; if (!selectAll) return; const total = accountMap.size; const selected = selectedTokens.size; selectAll.checked = total > 0 && selected === total; selectAll.indeterminate = selected > 0 && selected < total; } function updateSelectedCount() { const el = ui.selectedCount; const selected = getActiveSelectedSet().size; if (el) el.textContent = String(selected); setActionButtonsState(); updateBatchActionsVisibility(); } function updateBatchActionsVisibility() { const bar = ui.batchActions; if (!bar) return; bar.classList.remove('hidden'); } function updateLoadButton() { const btn = ui.loadBtn; if (!btn) return; if (currentSection === 'online') { btn.textContent = '加载'; btn.title = ''; } else { btn.textContent = '刷新'; btn.title = ''; } } function updateDeleteButton() { const btn = ui.deleteBtn; if (!btn) return; if (currentSection === 'online') { btn.textContent = '清理'; btn.title = ''; } else { btn.textContent = '删除'; btn.title = ''; } } function setActionButtonsState() { const loadBtn = ui.loadBtn; const deleteBtn = ui.deleteBtn; const disabled = isBatchLoading || isBatchDeleting || isLocalDeleting; const noSelection = getActiveSelectedSet().size === 0; if (loadBtn) { if (currentSection === 'online') { loadBtn.disabled = disabled || noSelection; } else { loadBtn.disabled = disabled; } } if (deleteBtn) { if (currentSection === 'online') { deleteBtn.disabled = disabled || noSelection; } else { deleteBtn.disabled = disabled || noSelection; } } } function updateBatchProgress() { const container = ui.batchProgress; if (!container || !ui.batchProgressText) return; if (currentSection !== 'online') { container.classList.add('hidden'); if (ui.pauseActionBtn) ui.pauseActionBtn.classList.add('hidden'); if (ui.stopActionBtn) ui.stopActionBtn.classList.add('hidden'); return; } if (!isBatchLoading && !isBatchDeleting) { container.classList.add('hidden'); if (ui.pauseActionBtn) ui.pauseActionBtn.classList.add('hidden'); if (ui.stopActionBtn) ui.stopActionBtn.classList.add('hidden'); return; } const isLoading = isBatchLoading; const processed = isLoading ? batchProcessed : deleteProcessed; const total = isLoading ? batchTotal : deleteTotal; const percent = calcPercent(processed, total); ui.batchProgressText.textContent = `${percent}%`; container.classList.remove('hidden'); if (ui.pauseActionBtn) { ui.pauseActionBtn.classList.add('hidden'); } if (ui.stopActionBtn) { ui.stopActionBtn.classList.remove('hidden'); } } function refreshBatchUI() { setActionButtonsState(); updateBatchActionsVisibility(); updateBatchProgress(); } function setOnlineStatus(text, className) { const statusEl = ui.onlineStatus; if (!statusEl) return; statusEl.textContent = text; statusEl.className = className; } function getActiveSelectedSet() { if (currentSection === 'online') return selectedTokens; return selectedLocal[currentSection] || new Set(); } function updateToolbarForSection() { updateLoadButton(); updateDeleteButton(); updateSelectedCount(); updateBatchProgress(); } function updateOnlineCountFromTokens(tokens) { let total = 0; tokens.forEach(token => { const state = accountStates.get(token); if (state && typeof state.count === 'number') { total += state.count; } }); setText(ui.onlineCount, String(total)); } function formatSize(bytes) { if (bytes === 0 || bytes === null || bytes === undefined) return '-'; const kb = 1024; const mb = kb * 1024; if (bytes >= mb) return `${(bytes / mb).toFixed(2)} MB`; if (bytes >= kb) return `${(bytes / kb).toFixed(1)} KB`; return `${bytes} B`; } async function showCacheSection(type) { ensureUI(); currentSection = type; if (ui.cacheCards) { ui.cacheCards.forEach(card => { const cardType = card.getAttribute('data-type'); card.classList.toggle('selected', cardType === type); }); } if (type === 'image') { cacheListState.image.visible = true; cacheListState.video.visible = false; if (cacheListState.image.loaded) renderLocalCacheList('image', cacheListState.image.items); else await loadLocalCacheList('image'); if (ui.localCacheLists) ui.localCacheLists.classList.remove('hidden'); if (ui.localImageList) ui.localImageList.classList.remove('hidden'); if (ui.localVideoList) ui.localVideoList.classList.add('hidden'); if (ui.onlineAssetsTable) ui.onlineAssetsTable.classList.add('hidden'); updateToolbarForSection(); return; } if (type === 'video') { cacheListState.video.visible = true; cacheListState.image.visible = false; if (cacheListState.video.loaded) renderLocalCacheList('video', cacheListState.video.items); else await loadLocalCacheList('video'); if (ui.localCacheLists) ui.localCacheLists.classList.remove('hidden'); if (ui.localVideoList) ui.localVideoList.classList.remove('hidden'); if (ui.localImageList) ui.localImageList.classList.add('hidden'); if (ui.onlineAssetsTable) ui.onlineAssetsTable.classList.add('hidden'); updateToolbarForSection(); return; } if (type === 'online') { cacheListState.image.visible = false; cacheListState.video.visible = false; if (ui.localCacheLists) ui.localCacheLists.classList.add('hidden'); if (ui.localImageList) ui.localImageList.classList.add('hidden'); if (ui.localVideoList) ui.localVideoList.classList.add('hidden'); if (ui.onlineAssetsTable) ui.onlineAssetsTable.classList.remove('hidden'); updateToolbarForSection(); } } async function toggleCacheList(type) { await showCacheSection(type); } async function loadLocalCacheList(type) { const body = type === 'image' ? ui.localImageBody : ui.localVideoBody; if (!body) return; body.innerHTML = `