Files
grok2api/app/static/cache/cache.js
T

1244 lines
39 KiB
JavaScript

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 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: [] }
};
async function init() {
apiKey = await ensureApiKey();
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() {
ui.imgCount = document.getElementById('img-count');
ui.imgSize = document.getElementById('img-size');
ui.videoCount = document.getElementById('video-count');
ui.videoSize = document.getElementById('video-size');
ui.onlineCount = document.getElementById('online-count');
ui.onlineStatus = document.getElementById('online-status');
ui.onlineLastClear = document.getElementById('online-last-clear');
ui.accountTableBody = document.getElementById('account-table-body');
ui.accountEmpty = document.getElementById('account-empty');
ui.selectAll = document.getElementById('select-all');
ui.localImageSelectAll = document.getElementById('local-image-select-all');
ui.localVideoSelectAll = document.getElementById('local-video-select-all');
ui.selectedCount = document.getElementById('selected-count');
ui.batchActions = document.getElementById('batch-actions');
ui.loadBtn = document.getElementById('btn-load-stats');
ui.deleteBtn = document.getElementById('btn-delete-assets');
ui.localCacheLists = document.getElementById('local-cache-lists');
ui.localImageList = document.getElementById('local-image-list');
ui.localVideoList = document.getElementById('local-video-list');
ui.localImageBody = document.getElementById('local-image-body');
ui.localVideoBody = document.getElementById('local-video-body');
ui.cacheCards = document.querySelectorAll('.cache-card');
ui.onlineAssetsTable = document.getElementById('online-assets-table');
ui.batchProgress = document.getElementById('batch-progress');
ui.batchProgressText = document.getElementById('batch-progress-text');
ui.pauseActionBtn = document.getElementById('btn-pause-action');
ui.stopActionBtn = document.getElementById('btn-stop-action');
ui.failureDetailsBtn = document.getElementById('btn-failure-details');
ui.confirmDialog = document.getElementById('confirm-dialog');
ui.confirmMessage = document.getElementById('confirm-message');
ui.confirmOk = document.getElementById('confirm-ok');
ui.confirmCancel = document.getElementById('confirm-cancel');
ui.failureDialog = document.getElementById('failure-dialog');
ui.failureList = document.getElementById('failure-list');
ui.failureClose = document.getElementById('failure-close');
ui.failureRetry = document.getElementById('failure-retry');
}
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;
const BATCH_SIZE = 10;
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 = `/api/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();
if (!merge) {
accountStates.clear();
}
if (ui.imgCount) ui.imgCount.textContent = data.local_image.count;
if (ui.imgSize) ui.imgSize.textContent = `${data.local_image.size_mb} MB`;
if (ui.videoCount) ui.videoCount.textContent = data.local_video.count;
if (ui.videoSize) ui.videoSize.textContent = `${data.local_video.size_mb} MB`;
if (ui.onlineCount) ui.onlineCount.textContent = data.online.count;
const statusEl = ui.onlineStatus;
const lastClearEl = ui.onlineLastClear;
if (data.online.status === 'ok') {
statusEl.textContent = '连接正常';
statusEl.className = 'text-xs text-green-600 mt-1';
} else if (data.online.status === 'no_token') {
statusEl.textContent = '无可用 Token';
statusEl.className = 'text-xs text-orange-500 mt-1';
} else if (data.online.status === 'not_loaded') {
statusEl.textContent = '未加载';
statusEl.className = 'text-xs text-[var(--accents-4)] mt-1';
} else {
statusEl.textContent = '无法连接';
statusEl.className = 'text-xs text-red-500 mt-1';
}
// 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 (data.online?.token) {
accountStates.set(data.online.token, {
count: data.online.count,
status: data.online.status,
last_asset_clear_at: data.online.last_asset_clear_at
});
}
if (data.online_scope === 'all') {
currentScope = 'all';
currentToken = '';
} else if (data.online_scope === 'selected') {
currentScope = 'selected';
} else if (data.online.token) {
currentScope = 'single';
currentToken = data.online.token;
} else {
currentScope = 'none';
}
if (lastClearEl) {
const timeText = formatTime(data.online.last_asset_clear_at);
lastClearEl.textContent = timeText ? `上次清空:${timeText}` : '';
}
renderAccountTable(data);
return data;
} catch (e) {
if (!silent) showToast('加载统计失败', 'error');
return null;
}
}
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.innerHTML = '';
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
tbody.innerHTML = rows.map(row => {
let statusClass = 'badge-gray';
let statusText = '未加载';
if (row.status === 'ok') {
statusClass = 'badge-green';
statusText = '正常';
} else if (row.status === 'not_loaded') {
statusClass = 'badge-gray';
statusText = '未加载';
} else {
statusClass = 'badge-red';
statusText = '异常';
}
const lastClear = formatTime(row.last_asset_clear_at) || '-';
// Use token as identifier, but we might want index for simpler toggling if we switch to array-based state like token.js
// For now, keep Set-based logic but update UI
const checked = selectedTokens.has(row.token) ? 'checked' : '';
// Shorten token for display if not already masked, though backend gives masked
// We'll use the masked version from backend
const rowClass = selectedTokens.has(row.token) ? 'row-selected' : '';
const countText = row.count === '-' ? '未加载' : row.count;
return `
<tr class="${rowClass}">
<td class="text-center">
<input type="checkbox" class="checkbox" data-token="${row.token}" ${checked} onchange="toggleSelect('${row.token}', this)">
</td>
<td class="text-left">
<div class="flex items-center gap-2">
<span class="font-mono text-xs text-gray-500" title="${row.token}">${row.token_masked}</span>
</div>
</td>
<td class="text-center"><span class="badge badge-gray">${row.pool || '-'}</span></td>
<td class="text-center"><span class="badge badge-gray">${countText}</span></td>
<td class="text-left text-xs text-gray-500">${lastClear}</td>
<td class="text-center">
<div class="flex items-center justify-center gap-2">
<button class="cache-icon-button" onclick="clearOnlineCache('${row.token}')" title="清空">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
</button>
</div>
</td>
</tr>
`;
}).join('');
syncSelectAllState();
updateSelectedCount();
updateBatchActionsVisibility();
}
async function clearCache(type) {
const ok = await confirmAction(`确定要清空本地${type === 'image' ? '图片' : '视频'}缓存吗?`, { okText: '清空' });
if (!ok) return;
try {
const res = await fetch('/api/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');
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) {
const paused = isLoading ? isLoadPaused : isDeletePaused;
ui.pauseActionBtn.textContent = paused ? '继续' : '暂停';
ui.pauseActionBtn.classList.remove('hidden');
}
if (ui.stopActionBtn) {
ui.stopActionBtn.classList.remove('hidden');
}
}
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) {
const el = ui.onlineCount;
if (!el) return;
let total = 0;
tokens.forEach(token => {
const state = accountStates.get(token);
if (state && typeof state.count === 'number') {
total += state.count;
}
});
el.textContent = 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 = `<tr><td colspan="5">加载中...</td></tr>`;
try {
const params = new URLSearchParams({ type, page: '1', page_size: '1000' });
const res = await fetch(`/api/v1/admin/cache/list?${params.toString()}`, {
headers: buildAuthHeaders(apiKey)
});
if (!res.ok) {
body.innerHTML = `<tr><td colspan="5">加载失败</td></tr>`;
return;
}
const data = await res.json();
const items = Array.isArray(data.items) ? data.items : [];
cacheListState[type].items = items;
cacheListState[type].loaded = true;
const keep = new Set(items.map(item => item.name));
const selected = selectedLocal[type];
Array.from(selected).forEach(name => {
if (!keep.has(name)) selected.delete(name);
});
renderLocalCacheList(type, items);
} catch (e) {
body.innerHTML = `<tr><td colspan="5">加载失败</td></tr>`;
}
}
function renderLocalCacheList(type, items) {
const body = type === 'image' ? ui.localImageBody : ui.localVideoBody;
if (!body) return;
if (!items || items.length === 0) {
body.innerHTML = `<tr><td colspan="5">暂无文件</td></tr>`;
syncLocalSelectAllState(type);
return;
}
const selected = selectedLocal[type];
body.innerHTML = items.map(item => {
const timeText = formatTime(item.mtime_ms);
const preview = item.preview_url ? `<img src="${item.preview_url}" alt="" class="cache-preview">` : '';
const checked = selected.has(item.name) ? 'checked' : '';
const rowClass = selected.has(item.name) ? 'row-selected' : '';
return `
<tr class="${rowClass}">
<td class="text-center">
<input type="checkbox" class="checkbox" data-name="${item.name}" ${checked} onchange="toggleLocalSelect('${type}', '${item.name}', this)">
</td>
<td class="text-left">
<div class="flex items-center gap-2">
${preview}
<span class="font-mono text-xs text-gray-500">${item.name}</span>
</div>
</td>
<td class="text-left">${formatSize(item.size_bytes)}</td>
<td class="text-left text-xs text-gray-500">${timeText}</td>
<td class="text-center">
<div class="cache-list-actions">
<button class="cache-icon-button" onclick="viewLocalFile('${type}', '${item.name}')" title="查看">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
<button class="cache-icon-button" onclick="deleteLocalFile('${type}', '${item.name}')" title="删除">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
</td>
</tr>
`;
}).join('');
syncLocalSelectAllState(type);
updateSelectedCount();
}
function viewLocalFile(type, name) {
const safeName = encodeURIComponent(name);
const url = type === 'image' ? `/v1/files/image/${safeName}` : `/v1/files/video/${safeName}`;
window.open(url, '_blank');
}
async function deleteLocalFile(type, name) {
const ok = await confirmAction(`确定要删除该文件吗?`, { okText: '删除' });
if (!ok) return;
const okDelete = await requestDeleteLocalFile(type, name);
if (!okDelete) return;
showToast('删除成功', 'success');
const state = cacheListState[type];
if (state && Array.isArray(state.items)) {
state.items = state.items.filter(item => item.name !== name);
state.loaded = true;
selectedLocal[type]?.delete(name);
if (state.visible) renderLocalCacheList(type, state.items);
}
await loadStats();
}
async function requestDeleteLocalFile(type, name) {
try {
const res = await fetch('/api/v1/admin/cache/item/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildAuthHeaders(apiKey)
},
body: JSON.stringify({ type, name })
});
return res.ok;
} catch (e) {
return false;
}
}
async function deleteSelectedLocal(type) {
const selected = selectedLocal[type];
const names = selected ? Array.from(selected) : [];
if (names.length === 0) {
showToast('未选择文件', 'info');
return;
}
const ok = await confirmAction(`确定要删除选中的 ${names.length} 个文件吗?`, { okText: '删除' });
if (!ok) return;
isLocalDeleting = true;
setActionButtonsState();
let success = 0;
let failed = 0;
const batchSize = 10;
for (let i = 0; i < names.length; i += batchSize) {
const chunk = names.slice(i, i + batchSize);
const results = await Promise.all(chunk.map(name => requestDeleteLocalFile(type, name)));
results.forEach((ok, idx) => {
if (ok) {
success += 1;
} else {
failed += 1;
}
});
}
const state = cacheListState[type];
if (state && Array.isArray(state.items)) {
const toRemove = new Set(names);
state.items = state.items.filter(item => !toRemove.has(item.name));
state.loaded = true;
}
selectedLocal[type].clear();
if (state && state.visible) renderLocalCacheList(type, state.items);
await loadStats();
isLocalDeleting = false;
setActionButtonsState();
if (failed === 0) {
showToast(`已删除 ${success} 个文件`, 'success');
} else {
showToast(`删除完成:成功 ${success},失败 ${failed}`, 'info');
}
}
function handleLoadClick() {
ensureUI();
if (isBatchLoading || isBatchDeleting) {
showToast('当前有任务进行中', 'info');
return;
}
if (currentSection === 'online') {
loadSelectedAccounts();
} else {
loadLocalCacheList(currentSection);
}
}
function handleDeleteClick() {
ensureUI();
if (isBatchLoading || isBatchDeleting) {
showToast('当前有任务进行中', 'info');
return;
}
if (currentSection === 'online') {
clearSelectedAccounts();
} else {
deleteSelectedLocal(currentSection);
}
}
function stopBatchLoad() {
if (!isBatchLoading) return;
isBatchLoading = false;
isLoadPaused = false;
currentBatchAction = null;
batchQueue = [];
setOnlineStatus('已终止', 'text-xs text-[var(--accents-4)] mt-1');
updateLoadButton();
setActionButtonsState();
updateBatchActionsVisibility();
updateBatchProgress();
showToast('已终止剩余加载请求', 'info');
}
function stopBatchDelete() {
if (!isBatchDeleting) return;
isBatchDeleting = false;
isDeletePaused = false;
currentBatchAction = null;
batchQueue = [];
updateDeleteButton();
setActionButtonsState();
updateBatchActionsVisibility();
updateBatchProgress();
showToast('已终止剩余清理请求', 'info');
}
function togglePause() {
if (isBatchLoading) {
isLoadPaused = !isLoadPaused;
if (isLoadPaused) {
setOnlineStatus('已暂停', 'text-xs text-[var(--accents-4)] mt-1');
} else {
setOnlineStatus('加载中', 'text-xs text-blue-600 mt-1');
processBatchQueue();
}
} else if (isBatchDeleting) {
isDeletePaused = !isDeletePaused;
if (!isDeletePaused) {
processDeleteQueue();
}
}
updateBatchProgress();
}
function stopActiveBatch() {
if (isBatchLoading) {
stopBatchLoad();
} else if (isBatchDeleting) {
stopBatchDelete();
}
}
function getMaskedToken(token) {
const meta = accountMap.get(token);
if (meta && meta.token_masked) return meta.token_masked;
if (!token) return '';
return token.length > 12 ? `${token.slice(0, 6)}...${token.slice(-4)}` : token;
}
function showFailureDetails() {
ensureUI();
const dialog = ui.failureDialog;
if (!dialog || !ui.failureList) return;
let action = currentBatchAction || lastBatchAction;
if (!action) {
action = deleteFailed.size > 0 ? 'delete' : 'load';
}
const failures = action === 'delete' ? deleteFailed : loadFailed;
ui.failureList.innerHTML = '';
failures.forEach((reason, token) => {
const item = document.createElement('div');
item.className = 'failure-item';
const tokenEl = document.createElement('div');
tokenEl.className = 'failure-token';
tokenEl.textContent = getMaskedToken(token);
const reasonEl = document.createElement('div');
reasonEl.textContent = reason;
item.appendChild(tokenEl);
item.appendChild(reasonEl);
ui.failureList.appendChild(item);
});
dialog.showModal();
}
function retryFailed() {
const action = currentBatchAction || lastBatchAction || (deleteFailed.size > 0 ? 'delete' : 'load');
const failures = action === 'delete' ? deleteFailed : loadFailed;
const tokens = Array.from(failures.keys());
if (tokens.length === 0) return;
if (isBatchLoading || isBatchDeleting) {
showToast('请等待当前任务结束', 'info');
return;
}
if (ui.failureDialog) ui.failureDialog.close();
if (action === 'delete') {
startBatchDelete(tokens);
} else {
startBatchLoad(tokens);
}
}
function startBatchLoad(tokens) {
if (isBatchLoading) {
showToast('正在加载中,请稍候', 'info');
return;
}
if (isBatchDeleting) {
showToast('正在清理中,请稍候', 'info');
return;
}
if (!tokens || tokens.length === 0) return;
isBatchLoading = true;
isLoadPaused = false;
currentBatchAction = 'load';
lastBatchAction = 'load';
loadFailed.clear();
batchTokens = tokens.slice();
batchQueue = tokens.slice();
batchTotal = batchQueue.length;
batchProcessed = 0;
batchTokens.forEach(token => accountStates.delete(token));
updateOnlineCountFromTokens(batchTokens);
setOnlineStatus('加载中', 'text-xs text-blue-600 mt-1');
updateLoadButton();
setActionButtonsState();
if (accountMap.size > 0) {
renderAccountTable({ online_accounts: Array.from(accountMap.values()), online_details: [], online: {} });
}
updateBatchActionsVisibility();
updateBatchProgress();
processBatchQueue();
}
async function processBatchQueue() {
if (!isBatchLoading || isLoadPaused) return;
if (batchQueue.length === 0) {
finishBatchLoad();
return;
}
const chunk = batchQueue.splice(0, BATCH_SIZE);
const data = await loadStats({ tokens: chunk, merge: true, silent: true });
if (!data) {
chunk.forEach(token => loadFailed.set(token, '请求失败'));
} else {
const details = Array.isArray(data.online_details) ? data.online_details : [];
const detailMap = new Map(details.map(item => [item.token, item]));
chunk.forEach(token => {
const detail = detailMap.get(token);
if (!detail) {
loadFailed.set(token, '返回为空');
return;
}
if (detail.status !== 'ok') {
loadFailed.set(token, detail.status);
} else {
loadFailed.delete(token);
}
});
}
batchProcessed += chunk.length;
updateOnlineCountFromTokens(batchTokens);
updateLoadButton();
setOnlineStatus('加载中', 'text-xs text-blue-600 mt-1');
updateBatchProgress();
setTimeout(() => {
processBatchQueue();
}, 300);
}
function finishBatchLoad() {
isBatchLoading = false;
isLoadPaused = false;
currentBatchAction = null;
updateOnlineCountFromTokens(batchTokens);
const hasError = batchTokens.some(token => {
const state = accountStates.get(token);
return !state || (state.status && state.status !== 'ok');
});
if (batchTokens.length === 0) {
setOnlineStatus('未加载', 'text-xs text-[var(--accents-4)] mt-1');
} else if (hasError) {
setOnlineStatus('部分异常', 'text-xs text-orange-500 mt-1');
} else {
setOnlineStatus('连接正常', 'text-xs text-green-600 mt-1');
}
updateLoadButton();
setActionButtonsState();
updateBatchActionsVisibility();
updateBatchProgress();
}
async function loadSelectedAccounts() {
if (selectedTokens.size === 0) {
showToast('请选择要加载的账号', 'error');
return;
}
startBatchLoad(Array.from(selectedTokens));
}
async function loadAllAccounts() {
const tokens = Array.from(accountMap.keys());
if (tokens.length === 0) {
showToast('暂无可用账号', 'error');
return;
}
startBatchLoad(tokens);
}
async function clearSelectedAccounts() {
if (selectedTokens.size === 0) {
showToast('请选择要清空的账号', 'error');
return;
}
if (isBatchDeleting) {
showToast('正在清理中,请稍候', 'info');
return;
}
if (isBatchLoading) {
showToast('正在加载中,请稍候', 'info');
return;
}
const ok = await confirmAction(`确定要清空选中的 ${selectedTokens.size} 个账号在线资产吗?`, { okText: '清空' });
if (!ok) return;
startBatchDelete(Array.from(selectedTokens));
}
function startBatchDelete(tokens) {
if (!tokens || tokens.length === 0) return;
isBatchDeleting = true;
isDeletePaused = false;
currentBatchAction = 'delete';
lastBatchAction = 'delete';
deleteFailed.clear();
deleteTotal = tokens.length;
deleteProcessed = 0;
batchQueue = tokens.slice();
showToast('正在批量清理在线资产,请稍候...', 'info');
updateDeleteButton();
setActionButtonsState();
updateBatchActionsVisibility();
updateBatchProgress();
processDeleteQueue();
}
async function processDeleteQueue() {
if (!isBatchDeleting || isDeletePaused) return;
if (batchQueue.length === 0) {
finishBatchDelete();
return;
}
const chunk = batchQueue.splice(0, BATCH_SIZE);
const results = await clearOnlineCacheBatch(chunk);
if (results && results.status === 'success' && results.results) {
Object.entries(results.results).forEach(([token, result]) => {
if (result.status !== 'success') {
deleteFailed.set(token, result.error || '清理失败');
} else {
deleteFailed.delete(token);
}
});
} else {
chunk.forEach(token => deleteFailed.set(token, '请求失败'));
}
deleteProcessed += chunk.length;
updateDeleteButton();
updateBatchProgress();
setTimeout(() => {
processDeleteQueue();
}, 300);
}
function finishBatchDelete() {
isBatchDeleting = false;
isDeletePaused = false;
currentBatchAction = null;
updateDeleteButton();
setActionButtonsState();
updateBatchActionsVisibility();
updateBatchProgress();
showToast('批量清理完成', 'success');
loadStats();
}
async function clearOnlineCacheBatch(tokens = []) {
if (!tokens || tokens.length === 0) return;
try {
const res = await fetch('/api/v1/admin/cache/online/clear', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildAuthHeaders(apiKey)
},
body: JSON.stringify({ tokens })
});
const data = await res.json();
if (!res.ok || data.status !== 'success') {
showToast('批量清理失败', 'error');
}
return data;
} catch (e) {
showToast('请求超时或失败', 'error');
return null;
}
}
async function clearOnlineCache(targetToken = '', skipConfirm = false) {
const tokenToClear = targetToken || (currentScope === 'all' ? '' : currentToken);
if (!tokenToClear) {
showToast('请选择要清空的账号', 'error');
return;
}
const meta = accountMap.get(tokenToClear);
const label = meta ? meta.token_masked : tokenToClear;
if (!skipConfirm) {
const ok = await confirmAction(`确定要清空账号 ${label} 的在线资产吗?`, { okText: '清空' });
if (!ok) return;
}
showToast('正在清理在线资产,请稍候...', 'info');
try {
const res = await fetch('/api/v1/admin/cache/online/clear', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildAuthHeaders(apiKey)
},
body: JSON.stringify({ token: tokenToClear })
});
const data = await res.json();
if (data.status === 'success') {
showToast(`清理完成 (成功: ${data.result.success}, 失败: ${data.result.failed})`, 'success');
} else {
showToast('清理失败', 'error');
}
} catch (e) {
showToast('请求超时或失败', 'error');
}
}
window.onload = init;