ggload / app /static /admin /js /cache.js
f2d90b38's picture
Upload 120 files
8cdca00 verified
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(
'清空',
`<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>`,
() => 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 = `<tr><td colspan="5">加载中...</td></tr>`;
try {
const params = new URLSearchParams({ type, page: '1', page_size: '1000' });
const res = await fetch(`/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" class="table-empty">暂无文件</td></tr>`;
syncLocalSelectAllState(type);
return;
}
const selected = selectedLocal[type];
const fragment = document.createDocumentFragment();
items.forEach(item => {
const tr = document.createElement('tr');
const isSelected = selected.has(item.name);
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-name', item.name);
checkbox.onchange = () => toggleLocalSelect(type, item.name, checkbox);
tdCheck.appendChild(checkbox);
const tdName = document.createElement('td');
tdName.className = 'text-left';
const nameWrap = document.createElement('div');
nameWrap.className = 'flex items-center gap-2';
if (item.preview_url) {
const img = document.createElement('img');
img.src = item.preview_url;
img.alt = '';
img.className = 'cache-preview';
nameWrap.appendChild(img);
}
const nameText = document.createElement('span');
nameText.className = 'font-mono text-xs text-gray-500';
nameText.textContent = item.name;
nameWrap.appendChild(nameText);
tdName.appendChild(nameWrap);
const tdSize = document.createElement('td');
tdSize.className = 'text-left';
tdSize.textContent = formatSize(item.size_bytes);
const tdTime = document.createElement('td');
tdTime.className = 'text-left text-xs text-gray-500';
tdTime.textContent = formatTime(item.mtime_ms);
const tdActions = document.createElement('td');
tdActions.className = 'text-center';
tdActions.innerHTML = `
<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>
`;
tr.appendChild(tdCheck);
tr.appendChild(tdName);
tr.appendChild(tdSize);
tr.appendChild(tdTime);
tr.appendChild(tdActions);
fragment.appendChild(tr);
});
body.replaceChildren(fragment);
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('/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(options = {}) {
if (!isBatchLoading) return;
isBatchLoading = false;
isLoadPaused = false;
currentBatchAction = null;
batchQueue = [];
BatchSSE.close(batchEventSource);
batchEventSource = null;
currentBatchTaskId = null;
setOnlineStatus('已终止', 'text-xs text-[var(--accents-4)] mt-1');
updateLoadButton();
refreshBatchUI();
if (!options.silent) showToast('已终止剩余加载请求', 'info');
}
function stopBatchDelete(options = {}) {
if (!isBatchDeleting) return;
isBatchDeleting = false;
isDeletePaused = false;
currentBatchAction = null;
batchQueue = [];
BatchSSE.close(batchEventSource);
batchEventSource = null;
currentBatchTaskId = null;
updateDeleteButton();
refreshBatchUI();
if (!options.silent) showToast('已终止剩余清理请求', 'info');
}
function togglePause() {
if (isBatchLoading || isBatchDeleting) {
showToast('当前批量任务不支持暂停', 'info');
}
}
function stopActiveBatch() {
if (isBatchLoading) {
BatchSSE.cancel(currentBatchTaskId, apiKey);
stopBatchLoad();
} else if (isBatchDeleting) {
BatchSSE.cancel(currentBatchTaskId, apiKey);
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);
}
}
async 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();
if (accountMap.size > 0) {
renderAccountTable({ online_accounts: Array.from(accountMap.values()), online_details: [], online: {} });
}
refreshBatchUI();
try {
const res = await fetch('/v1/admin/cache/online/load/async', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildAuthHeaders(apiKey)
},
body: JSON.stringify({ tokens })
});
const data = await res.json();
if (!res.ok || data.status !== 'success') {
throw new Error(data.detail || '请求失败');
}
currentBatchTaskId = data.task_id;
BatchSSE.close(batchEventSource);
batchEventSource = BatchSSE.open(currentBatchTaskId, apiKey, {
onMessage: (msg) => {
if (msg.type === 'snapshot' || msg.type === 'progress') {
if (typeof msg.total === 'number') batchTotal = msg.total;
if (typeof msg.processed === 'number') batchProcessed = msg.processed;
updateBatchProgress();
} else if (msg.type === 'done') {
if (typeof msg.total === 'number') batchTotal = msg.total;
batchProcessed = batchTotal;
updateBatchProgress();
const result = msg.result;
if (result) {
applyStatsData(result, true);
const details = Array.isArray(result.online_details) ? result.online_details : [];
loadFailed.clear();
details.forEach(detail => {
if (detail.status !== 'ok') loadFailed.set(detail.token, detail.status);
});
}
finishBatchLoad();
if (msg.warning) {
showToast(`加载完成\n⚠️ ${msg.warning}`, 'warning');
}
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
} else if (msg.type === 'cancelled') {
stopBatchLoad({ silent: true });
showToast('已终止加载', 'info');
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
} else if (msg.type === 'error') {
stopBatchLoad({ silent: true });
showToast('加载失败: ' + (msg.error || '未知错误'), 'error');
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
}
},
onError: () => {
stopBatchLoad({ silent: true });
showToast('连接中断', 'error');
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
}
});
} catch (e) {
stopBatchLoad({ silent: true });
showToast(e.message || '请求失败', 'error');
}
}
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();
refreshBatchUI();
}
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));
}
async 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();
refreshBatchUI();
try {
const res = await fetch('/v1/admin/cache/online/clear/async', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildAuthHeaders(apiKey)
},
body: JSON.stringify({ tokens })
});
const data = await res.json();
if (!res.ok || data.status !== 'success') {
throw new Error(data.detail || '请求失败');
}
currentBatchTaskId = data.task_id;
BatchSSE.close(batchEventSource);
batchEventSource = BatchSSE.open(currentBatchTaskId, apiKey, {
onMessage: (msg) => {
if (msg.type === 'snapshot' || msg.type === 'progress') {
if (typeof msg.total === 'number') deleteTotal = msg.total;
if (typeof msg.processed === 'number') deleteProcessed = msg.processed;
updateBatchProgress();
} else if (msg.type === 'done') {
if (typeof msg.total === 'number') deleteTotal = msg.total;
deleteProcessed = deleteTotal;
updateBatchProgress();
const result = msg.result;
deleteFailed.clear();
if (result && result.results) {
Object.entries(result.results).forEach(([token, res]) => {
if (res.status !== 'success') {
deleteFailed.set(token, res.error || '清理失败');
}
});
}
finishBatchDelete();
if (msg.warning) {
showToast(`清理完成\n⚠️ ${msg.warning}`, 'warning');
}
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
} else if (msg.type === 'cancelled') {
stopBatchDelete({ silent: true });
showToast('已终止清理', 'info');
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
} else if (msg.type === 'error') {
stopBatchDelete({ silent: true });
showToast('清理失败: ' + (msg.error || '未知错误'), 'error');
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
}
},
onError: () => {
stopBatchDelete({ silent: true });
showToast('连接中断', 'error');
currentBatchTaskId = null;
BatchSSE.close(batchEventSource);
batchEventSource = null;
}
});
} catch (e) {
stopBatchDelete({ silent: true });
showToast(e.message || '请求失败', 'error');
}
}
function finishBatchDelete() {
isBatchDeleting = false;
isDeletePaused = false;
currentBatchAction = null;
updateDeleteButton();
refreshBatchUI();
showToast('批量清理完成', 'success');
loadStats();
}
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('/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;