Files
codex-console/static/js/accounts_overview.js

1051 lines
38 KiB
JavaScript

/**
* 账号总览页面脚本
* 上半区:原有总览布局
* 下半区:卡片管理布局(按设计图增强)
*/
const VIEW_MODE_STORAGE_KEY = 'accounts_overview_view_mode';
const overviewState = {
summary: null,
cards: [],
filteredCards: [],
selectedCardIds: new Set(),
addableCards: [],
viewMode: storage.get(VIEW_MODE_STORAGE_KEY, 'grid') || 'grid',
planFilter: 'all',
sortMode: 'created_desc',
cardRefreshIntervalMin: 7,
cardRefreshTimer: null,
cardCountdownTimer: null,
cardNextRefreshAt: null,
};
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text ?? '';
return div.innerHTML;
}
function setText(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = format.number(value || 0);
}
function toSortedEntries(data) {
return Object.entries(data || {}).sort((a, b) => Number(b[1] || 0) - Number(a[1] || 0));
}
const SERVICE_DIST_PALETTE = [
'#3b82f6', // blue
'#10b981', // emerald
'#f59e0b', // amber
'#8b5cf6', // violet
'#ef4444', // red
'#06b6d4', // cyan
'#84cc16', // lime
'#f97316', // orange
];
function hashText(text) {
const str = String(text || '');
let hash = 0;
for (let i = 0; i < str.length; i += 1) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
function getDistributionBarColor(containerId, key, index) {
const value = String(key || '').trim().toLowerCase();
if (containerId === 'dist-subscription') {
if (value.includes('team')) return '#8b5cf6'; // team: purple
if (value.includes('plus')) return '#10b981';
if (value.includes('pro')) return '#0ea5e9';
if (value.includes('free')) return '#94a3b8';
}
if (containerId === 'dist-status') {
if (value === 'active') return '#f59e0b'; // active: orange
if (value === 'failed' || value === 'banned') return '#ef4444';
if (value === 'expired') return '#64748b';
return '#3b82f6';
}
if (containerId === 'dist-service') {
const paletteIndex = hashText(value || index) % SERVICE_DIST_PALETTE.length;
return SERVICE_DIST_PALETTE[paletteIndex];
}
if (containerId === 'dist-source') {
if (value === 'register') return '#3b82f6';
if (value === 'login') return '#10b981';
}
return '#3b82f6';
}
function renderDistribution(containerId, data, labelFormatter) {
const container = document.getElementById(containerId);
if (!container) return;
const entries = toSortedEntries(data);
if (!entries.length) {
container.innerHTML = '<div class="empty-state" style="padding: 16px 0;">暂无数据</div>';
return;
}
const maxValue = Math.max(...entries.map(([, count]) => Number(count || 0)), 1);
container.innerHTML = entries.map(([key, count], index) => {
const value = Number(count || 0);
const width = Math.max((value / maxValue) * 100, 2);
const label = labelFormatter ? labelFormatter(key) : key;
const barColor = getDistributionBarColor(containerId, key, index);
return `
<div class="distribution-row">
<div class="distribution-label" title="${escapeHtml(label)}">${escapeHtml(label)}</div>
<div class="distribution-bar-wrap"><div class="distribution-bar" style="width:${width}%;background:${barColor};"></div></div>
<div class="distribution-value">${format.number(value)}</div>
</div>
`;
}).join('');
}
function formatSource(value) {
const sourceMap = {
register: '注册',
login: '登录',
unknown: '未知',
};
return sourceMap[value] || value || '-';
}
function formatSubscription(value) {
const key = (value || 'free').toLowerCase();
const map = {
free: '免费',
plus: 'Plus',
team: 'Team',
pro: 'Pro',
};
return map[key] || key;
}
function normalizePlan(planType) {
const value = String(planType || '').toLowerCase();
if (value.includes('team')) return 'team';
if (value.includes('plus')) return 'plus';
if (value.includes('pro')) return 'pro';
if (value.includes('free') || value.includes('basic')) return 'free';
return 'free';
}
function getPlanText(planType) {
const plan = normalizePlan(planType);
if (plan === 'team') return 'TEAM';
if (plan === 'plus') return 'PLUS';
if (plan === 'pro') return 'PRO';
return 'FREE';
}
function getPlanClass(planType) {
const plan = normalizePlan(planType);
if (plan === 'team') return 'team';
if (plan === 'plus' || plan === 'pro') return 'plus';
return 'free';
}
function parsePercent(quota) {
const raw = quota?.percentage;
if (raw === null || raw === undefined || Number.isNaN(Number(raw))) return null;
return Math.max(0, Math.min(100, Number(raw)));
}
function getProgressTone(percentage) {
if (percentage === null) {
return { valueClass: 'tone-gray', barClass: 'tone-gray-bar' };
}
if (percentage < 10) {
return { valueClass: 'tone-red', barClass: 'tone-red-bar' };
}
if (percentage >= 90) {
return { valueClass: 'tone-green', barClass: 'tone-green-bar' };
}
return { valueClass: 'tone-orange', barClass: 'tone-orange-bar' };
}
function parseTimeMs(value) {
const ms = Date.parse(value || '');
return Number.isNaN(ms) ? null : ms;
}
function formatMinuteCountdown(targetMs) {
if (!targetMs) return '-';
const diffMs = targetMs - Date.now();
if (diffMs <= 0) return '0分';
const totalMinutes = Math.max(1, Math.ceil(diffMs / 60000));
const days = Math.floor(totalMinutes / 1440);
const hours = Math.floor((totalMinutes % 1440) / 60);
const mins = totalMinutes % 60;
if (days > 0) return `${days}天${hours}小时${mins}分`;
if (hours > 0) return `${hours}小时${mins}分`;
return `${mins}分`;
}
function formatQuotaResetText(quota) {
const resetAt = parseTimeMs(quota?.reset_at);
const percent = parsePercent(quota);
if (resetAt) {
const absolute = format.date(new Date(resetAt).toISOString());
if (percent !== null && percent <= 0) {
return `已用尽,下次 ${absolute}`;
}
return `${formatMinuteCountdown(resetAt)} (${absolute})`;
}
return quota?.reset_in_text || '-';
}
function renderQuotaItem(title, quota) {
const percent = parsePercent(quota);
const tone = getProgressTone(percent);
const showPercent = percent === null ? '--' : `${Math.round(percent)}%`;
const width = percent === null ? 0 : percent;
const resetText = formatQuotaResetText(quota);
return `
<div class="quota-item">
<div class="quota-row">
<span>${escapeHtml(title)}</span>
<span class="quota-value ${tone.valueClass}">${showPercent}</span>
</div>
<div class="quota-bar"><span class="${tone.barClass}" style="width:${width}%"></span></div>
<div class="quota-reset">重置: ${escapeHtml(resetText)}</div>
</div>
`;
}
function renderRecentAccounts(accounts) {
const tbody = document.getElementById('recent-accounts-table');
if (!tbody) return;
if (!accounts || !accounts.length) {
tbody.innerHTML = `
<tr>
<td colspan="8"><div class="empty-state">暂无账号数据</div></td>
</tr>
`;
return;
}
tbody.innerHTML = accounts.map((item) => `
<tr>
<td>${item.id || '-'}</td>
<td>${escapeHtml(item.email || '-')}</td>
<td><span class="status-badge ${getStatusClass('account', item.status)}">${escapeHtml(getStatusText('account', item.status) || '-')}</span></td>
<td>${escapeHtml(getServiceTypeText(item.email_service || '') || '-')}</td>
<td>${escapeHtml(formatSource(item.source))}</td>
<td>${escapeHtml(formatSubscription(item.subscription_type))}</td>
<td>${format.date(item.created_at)}</td>
<td>${format.date(item.last_refresh)}</td>
</tr>
`).join('');
}
async function loadLegacyOverview() {
try {
const data = await api.get('/accounts/stats/overview');
overviewState.summary = data;
setText('ov-total', data.total);
setText('ov-active', data.active_count);
setText('ov-access-token', data.token_stats?.with_access_token || 0);
setText('ov-cpa-uploaded', data.cpa_uploaded_count);
renderDistribution('dist-status', data.by_status, (status) => getStatusText('account', status));
renderDistribution('dist-service', data.by_email_service, (service) => getServiceTypeText(service));
renderDistribution('dist-subscription', data.by_subscription, formatSubscription);
renderDistribution('dist-source', data.by_source, formatSource);
renderRecentAccounts(data.recent_accounts || []);
} catch (error) {
toast.error(`加载总览统计失败: ${error.message || '未知错误'}`);
}
}
function updatePlanFilterOptions() {
const select = document.getElementById('card-plan-filter');
if (!select) return;
const sourceCards = overviewState.cards;
const total = sourceCards.length;
const freeCount = sourceCards.filter((item) => normalizePlan(item.plan_type) === 'free').length;
const plusCount = sourceCards.filter((item) => normalizePlan(item.plan_type) === 'plus').length;
const teamCount = sourceCards.filter((item) => normalizePlan(item.plan_type) === 'team').length;
const currentCount = sourceCards.filter((item) => Boolean(item.current)).length;
const currentValue = overviewState.planFilter;
select.innerHTML = `
<option value="all">全部 (${total})</option>
<option value="free">Free (${freeCount})</option>
<option value="plus">Plus (${plusCount})</option>
<option value="team">Team (${teamCount})</option>
<option value="current">当前账号 (${currentCount})</option>
`;
select.value = ['all', 'free', 'plus', 'team', 'current'].includes(currentValue) ? currentValue : 'all';
}
function syncSelectedCardIds() {
const existing = new Set(
overviewState.cards
.map((item) => Number(item.id))
.filter((id) => Number.isFinite(id))
);
const next = new Set();
for (const id of overviewState.selectedCardIds) {
if (existing.has(id)) next.add(id);
}
overviewState.selectedCardIds = next;
}
function updateSelectionInfo() {
const info = document.getElementById('card-selection-info');
if (!info) return;
const selected = overviewState.selectedCardIds.size;
const total = overviewState.filteredCards.length;
info.textContent = `已选择 ${selected} 个账号 / 当前列表 ${total} 个`;
}
function parseDateScore(value) {
const ts = Date.parse(value || '');
return Number.isNaN(ts) ? 0 : ts;
}
function sortCards(cards) {
const rows = [...cards];
const sortMode = overviewState.sortMode;
rows.sort((a, b) => {
if (sortMode === 'created_asc') {
return parseDateScore(a.created_at) - parseDateScore(b.created_at);
}
if (sortMode === 'hourly_desc') {
return (parsePercent(b.hourly_quota) ?? -1) - (parsePercent(a.hourly_quota) ?? -1);
}
if (sortMode === 'weekly_desc') {
return (parsePercent(b.weekly_quota) ?? -1) - (parsePercent(a.weekly_quota) ?? -1);
}
if (sortMode === 'email_asc') {
return String(a.email || '').localeCompare(String(b.email || ''), 'zh-CN');
}
return parseDateScore(b.created_at) - parseDateScore(a.created_at);
});
return rows;
}
function applyCardFilters() {
const keyword = (document.getElementById('card-search-input')?.value || '').trim().toLowerCase();
const planFilter = overviewState.planFilter;
let rows = [...overviewState.cards];
if (keyword) {
rows = rows.filter((item) => String(item.email || '').toLowerCase().includes(keyword));
}
if (planFilter === 'free') {
rows = rows.filter((item) => normalizePlan(item.plan_type) === 'free');
} else if (planFilter === 'plus') {
rows = rows.filter((item) => normalizePlan(item.plan_type) === 'plus');
} else if (planFilter === 'team') {
rows = rows.filter((item) => normalizePlan(item.plan_type) === 'team');
} else if (planFilter === 'current') {
rows = rows.filter((item) => Boolean(item.current));
}
overviewState.filteredCards = sortCards(rows);
renderPlanCards();
updateSelectionInfo();
}
function setViewMode(mode) {
overviewState.viewMode = mode === 'list' ? 'list' : 'grid';
storage.set(VIEW_MODE_STORAGE_KEY, overviewState.viewMode);
const listBtn = document.getElementById('view-list-btn');
const gridBtn = document.getElementById('view-grid-btn');
const container = document.getElementById('plan-cards-container');
if (listBtn) listBtn.classList.toggle('active', overviewState.viewMode === 'list');
if (gridBtn) gridBtn.classList.toggle('active', overviewState.viewMode === 'grid');
if (container) container.classList.toggle('view-list', overviewState.viewMode === 'list');
}
function renderPlanCards() {
const container = document.getElementById('plan-cards-container');
const empty = document.getElementById('plan-cards-empty');
if (!container || !empty) return;
setViewMode(overviewState.viewMode);
if (!overviewState.filteredCards.length) {
container.innerHTML = '';
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
container.innerHTML = overviewState.filteredCards.map((account) => {
const accountId = Number(account.id);
const checked = overviewState.selectedCardIds.has(accountId) ? 'checked' : '';
const codeReviewQuota = account.code_review_quota || null;
const hasCodeReviewQuota = Boolean(
codeReviewQuota &&
(
codeReviewQuota.status === 'ok' ||
codeReviewQuota.percentage !== null && codeReviewQuota.percentage !== undefined ||
codeReviewQuota.reset_at ||
(codeReviewQuota.reset_in_text && codeReviewQuota.reset_in_text !== '-')
)
);
const codeReviewHtml = hasCodeReviewQuota
? renderQuotaItem('Code Review', codeReviewQuota)
: '';
return `
<article class="quota-card ${account.current ? 'is-current' : ''}">
<div class="quota-card-head">
<label class="card-check">
<input type="checkbox" data-role="select-card" data-id="${accountId}" ${checked}>
</label>
<div class="quota-card-email" title="${escapeHtml(account.email || '-')}">${escapeHtml(account.email || '-')}</div>
<div class="quota-badges">
${account.current ? '<span class="status-pill current">当前</span>' : ''}
<span class="plan-badge ${getPlanClass(account.plan_type)}">${getPlanText(account.plan_type)}</span>
</div>
</div>
${renderQuotaItem('5小时配额', account.hourly_quota)}
${renderQuotaItem('周配额', account.weekly_quota)}
${codeReviewHtml}
<div class="quota-divider"></div>
<div class="quota-card-foot">
<span class="quota-time">${format.date(account.created_at)}</span>
<div class="card-actions">
<button class="card-action-btn" data-action="refresh" data-id="${accountId}" title="刷新配额">⟳</button>
<button class="card-action-btn" data-action="export" data-id="${accountId}" title="导出该卡片配置">⇪</button>
<button class="card-action-btn danger" data-action="remove" data-id="${accountId}" title="从卡片删除">🗑</button>
</div>
</div>
</article>
`;
}).join('');
}
async function loadPlanCards(forceRefresh = false) {
try {
const data = await api.get('/accounts/overview/cards');
overviewState.cards = Array.isArray(data?.accounts) ? data.accounts : [];
syncSelectedCardIds();
updatePlanFilterOptions();
applyCardFilters();
} catch (error) {
overviewState.cards = [];
overviewState.filteredCards = [];
overviewState.selectedCardIds = new Set();
updatePlanFilterOptions();
renderPlanCards();
updateSelectionInfo();
toast.error(`加载订阅卡片失败: ${error?.message || '未知错误'}`);
}
}
async function refreshSelectedOrAll(force = true, silent = false) {
const selectedIds = Array.from(overviewState.selectedCardIds);
const visibleIds = overviewState.filteredCards
.map((item) => Number(item.id))
.filter((id) => Number.isFinite(id));
const targetIds = selectedIds.length ? selectedIds : visibleIds;
try {
if (!targetIds.length) {
await loadPlanCards(false);
if (!silent) toast.info('当前没有可刷新的卡片');
return;
}
await api.post('/accounts/overview/refresh', {
ids: targetIds,
force,
select_all: false,
});
await loadPlanCards(false);
if (!silent) toast.success(`已刷新 ${targetIds.length} 个账号配额`);
} catch (error) {
if (!silent) toast.error(`刷新失败: ${error.message || '未知错误'}`);
}
}
function setCardRefreshLoading(button, loading) {
if (!button) return;
button.classList.toggle('is-loading', Boolean(loading));
button.disabled = Boolean(loading);
button.setAttribute('aria-busy', loading ? 'true' : 'false');
if (!loading) button.blur();
}
async function refreshSingleCard(accountId, button = null) {
if (!Number.isFinite(Number(accountId))) return;
if (button?.classList.contains('is-loading')) return;
setCardRefreshLoading(button, true);
toast.info(`正在刷新账号 #${accountId} 配额...`, 1500);
try {
const result = await api.post('/accounts/overview/refresh', {
ids: [accountId],
force: true,
select_all: false,
});
const details = Array.isArray(result?.details) ? result.details : [];
const currentDetail = details.find((item) => Number(item?.id) === Number(accountId));
if (currentDetail && currentDetail.success === false) {
toast.warning(
`刷新完成但未拿到新配额: ${currentDetail.error || '未知原因'}`,
4500
);
} else {
toast.success(`账号 #${accountId} 配额刷新完成`);
}
await loadPlanCards(false);
} catch (error) {
toast.error(`刷新失败: ${error?.message || '未知错误'}`);
} finally {
setCardRefreshLoading(button, false);
}
}
async function removeSingleCard(accountId) {
const id = Number(accountId);
if (!Number.isFinite(id)) return;
const ok = await confirm('确认从卡片列表删除该账号吗?(不会删除账号管理数据)', '删除卡片');
if (!ok) return;
await api.post('/accounts/overview/cards/remove', {
ids: [id],
select_all: false,
});
overviewState.selectedCardIds.delete(id);
await loadPlanCards(false);
await loadAddableAccounts();
toast.success('卡片已删除,可在“添加账号”里重新添加');
}
async function loadAddableAccounts() {
try {
const data = await api.get('/accounts/overview/cards/selectable');
overviewState.addableCards = Array.isArray(data?.accounts) ? data.accounts : [];
} catch (error) {
overviewState.addableCards = [];
console.warn('load addable cards failed', error);
}
renderAddableAccounts();
}
function renderAddableAccounts() {
const select = document.getElementById('overview-add-existing-select');
if (!select) return;
const options = overviewState.addableCards.map((item) => {
const plan = getPlanText(item.subscription_type || 'free');
const tokenTag = item.has_access_token ? '有Token' : '无Token';
return `<option value="${Number(item.id)}">${escapeHtml(item.email || '-')} (${plan}/${tokenTag})</option>`;
});
select.innerHTML = `<option value="">请选择账号</option>${options.join('')}`;
}
function getSelectedExistingAccount() {
const select = document.getElementById('overview-add-existing-select');
const id = Number(select?.value || 0);
if (!Number.isFinite(id) || id <= 0) return null;
return overviewState.addableCards.find((item) => Number(item.id) === id) || null;
}
function fillAddFormFromExistingSelection() {
const selected = getSelectedExistingAccount();
if (!selected) return;
setFieldValue('overview-add-email', selected.email || '');
setFieldValue('overview-add-password', selected.password || '');
setFieldValue('overview-add-email-service', selected.email_service || 'manual');
setFieldValue('overview-add-subscription', normalizePlan(selected.subscription_type || 'free'));
setFieldValue('overview-add-client-id', selected.client_id || '');
setFieldValue('overview-add-account-id', selected.account_id || '');
setFieldValue('overview-add-workspace-id', selected.workspace_id || '');
setFieldValue('overview-add-status', selected.status || 'active');
}
async function restoreSelectedAddableAccount() {
const select = document.getElementById('overview-add-existing-select');
const id = Number(select?.value || 0);
if (!Number.isFinite(id) || id <= 0) {
toast.warning('请先选择一个账号');
return;
}
await api.post(`/accounts/overview/cards/${id}/attach`, {});
await loadAddableAccounts();
await loadPlanCards(false);
await loadLegacyOverview();
toast.success('账号已添加到卡片');
}
function extractFilenameFromDisposition(contentDisposition) {
const raw = String(contentDisposition || '');
const match = raw.match(/filename="?([^"]+)"?/i);
return match ? match[1] : '';
}
async function downloadAccountsExportJson(payload, fallbackNamePrefix) {
const resp = await fetch('/api/accounts/export/json', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
let detail = '';
try {
const errData = await resp.json();
detail = errData?.detail || '';
} catch {
detail = '';
}
throw new Error(detail || `HTTP ${resp.status}`);
}
const blob = await resp.blob();
const contentDisposition = resp.headers.get('Content-Disposition') || '';
const filename =
extractFilenameFromDisposition(contentDisposition) ||
`${fallbackNamePrefix}_${Date.now()}.json`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function getSelectedIds() {
return Array.from(overviewState.selectedCardIds).filter((id) => Number.isFinite(Number(id)));
}
function openModalById(id) {
const modal = document.getElementById(id);
if (modal) modal.classList.add('active');
}
function closeModalById(id) {
const modal = document.getElementById(id);
if (modal) modal.classList.remove('active');
}
function resetAddModalFields() {
setFieldValue('overview-add-email', '');
setFieldValue('overview-add-password', '');
setFieldValue('overview-add-email-service', 'manual');
setFieldValue('overview-add-subscription', '');
setFieldValue('overview-add-client-id', '');
setFieldValue('overview-add-account-id', '');
setFieldValue('overview-add-workspace-id', '');
setFieldValue('overview-add-status', 'active');
setFieldValue('overview-add-existing-select', '');
}
function setFieldValue(id, value) {
const node = document.getElementById(id);
if (!node) return;
node.value = value ?? '';
}
async function submitAddAccount() {
const selectedExisting = getSelectedExistingAccount();
if (selectedExisting) {
await api.post(`/accounts/overview/cards/${Number(selectedExisting.id)}/attach`, {});
closeModalById('overview-add-modal');
resetAddModalFields();
await loadAddableAccounts();
await loadPlanCards(false);
await loadLegacyOverview();
toast.success('已从账号管理直接添加到卡片');
return;
}
const email = (document.getElementById('overview-add-email')?.value || '').trim();
const password = (document.getElementById('overview-add-password')?.value || '').trim();
const emailService = (document.getElementById('overview-add-email-service')?.value || 'manual').trim() || 'manual';
const subscriptionType = (document.getElementById('overview-add-subscription')?.value || '').trim();
const clientId = (document.getElementById('overview-add-client-id')?.value || '').trim();
const accountId = (document.getElementById('overview-add-account-id')?.value || '').trim();
const workspaceId = (document.getElementById('overview-add-workspace-id')?.value || '').trim();
const status = (document.getElementById('overview-add-status')?.value || 'active').trim() || 'active';
if (!email || !password) {
toast.warning('邮箱和密码为必填项');
return;
}
const payload = {
email,
password,
email_service: emailService,
subscription_type: subscriptionType || null,
client_id: clientId || null,
account_id: accountId || null,
workspace_id: workspaceId || null,
status,
source: 'manual',
};
await api.post('/accounts', payload);
closeModalById('overview-add-modal');
resetAddModalFields();
await loadAddableAccounts();
await loadPlanCards(false);
await loadLegacyOverview();
toast.success('账号已添加');
}
async function submitImportAccounts() {
const input = (document.getElementById('overview-import-json')?.value || '').trim();
const overwrite = Boolean(document.getElementById('overview-import-overwrite')?.checked);
if (!input) {
toast.warning('请先粘贴 JSON');
return;
}
let parsed;
try {
parsed = JSON.parse(input);
} catch (error) {
toast.error(`JSON 解析失败: ${error.message || '格式错误'}`);
return;
}
const accounts = Array.isArray(parsed)
? parsed
: (Array.isArray(parsed?.accounts) ? parsed.accounts : []);
if (!accounts.length) {
toast.warning('JSON 必须是非空数组');
return;
}
const result = await api.post('/accounts/import', {
accounts,
overwrite,
});
closeModalById('overview-import-modal');
toast.success(
`导入完成:新增 ${result?.created || 0},更新 ${result?.updated || 0},跳过 ${result?.skipped || 0},失败 ${result?.failed || 0}`
);
await loadPlanCards(false);
await loadLegacyOverview();
await loadAddableAccounts();
}
async function removeSelectedAccounts() {
let ids = getSelectedIds();
if (!ids.length) {
ids = overviewState.filteredCards
.map((item) => Number(item.id))
.filter((id) => Number.isFinite(id));
if (!ids.length) {
toast.warning('当前没有可删除的账号');
return;
}
}
const ok = await confirm(`确认从卡片列表删除 ${ids.length} 个账号吗?(不会删除账号管理数据)`, '批量删除卡片');
if (!ok) return;
const result = await api.post('/accounts/overview/cards/remove', { ids, select_all: false });
const removedCount = Number(result?.removed_count || 0);
toast.success(`删除完成:${removedCount} 个卡片`);
overviewState.selectedCardIds.clear();
await loadAddableAccounts();
await loadPlanCards(false);
}
async function exportAllVisibleAccounts() {
const ids = overviewState.filteredCards
.map((item) => Number(item.id))
.filter((id) => Number.isFinite(id));
if (!ids.length) {
toast.warning('当前没有可导出的账号');
return;
}
await downloadAccountsExportJson({ ids, select_all: false }, 'overview_all_accounts');
toast.success(`已导出 ${ids.length} 个账号`);
}
async function exportSingleCard(accountId) {
const id = Number(accountId);
if (!Number.isFinite(id)) return;
await downloadAccountsExportJson({ ids: [id], select_all: false }, `overview_account_${id}`);
toast.success(`账号 #${id} 配置已导出`);
}
function updateCardNextRefreshText() {
const el = document.getElementById('card-next-refresh');
if (!el) return;
if (!overviewState.cardNextRefreshAt) {
el.textContent = '下次刷新 --';
return;
}
const remainSec = Math.max(0, Math.floor((overviewState.cardNextRefreshAt - Date.now()) / 1000));
const min = Math.floor(remainSec / 60);
const sec = remainSec % 60;
el.textContent = `下次刷新 ${min}:${String(sec).padStart(2, '0')}`;
}
function restartCardAutoRefresh() {
if (overviewState.cardRefreshTimer) {
clearInterval(overviewState.cardRefreshTimer);
overviewState.cardRefreshTimer = null;
}
if (overviewState.cardCountdownTimer) {
clearInterval(overviewState.cardCountdownTimer);
overviewState.cardCountdownTimer = null;
}
const intervalMs = overviewState.cardRefreshIntervalMin * 60 * 1000;
overviewState.cardNextRefreshAt = Date.now() + intervalMs;
updateCardNextRefreshText();
overviewState.cardRefreshTimer = setInterval(async () => {
await refreshSelectedOrAll(true, true);
overviewState.cardNextRefreshAt = Date.now() + intervalMs;
updateCardNextRefreshText();
}, intervalMs);
let ticks = 0;
overviewState.cardCountdownTimer = setInterval(() => {
updateCardNextRefreshText();
ticks += 1;
if (ticks % 30 === 0 && overviewState.filteredCards.length) {
renderPlanCards();
}
}, 1000);
}
function bindEvents() {
const legacyRefreshBtn = document.getElementById('legacy-refresh-btn');
if (legacyRefreshBtn) {
legacyRefreshBtn.addEventListener('click', async () => {
await loadLegacyOverview();
await loadPlanCards(true);
});
}
const cardSearchInput = document.getElementById('card-search-input');
if (cardSearchInput) {
cardSearchInput.addEventListener('input', debounce(applyCardFilters, 240));
}
const planFilter = document.getElementById('card-plan-filter');
if (planFilter) {
planFilter.addEventListener('change', () => {
overviewState.planFilter = planFilter.value || 'all';
applyCardFilters();
});
}
const sortMode = document.getElementById('card-sort-mode');
if (sortMode) {
sortMode.addEventListener('change', () => {
overviewState.sortMode = sortMode.value || 'created_desc';
applyCardFilters();
});
}
const viewListBtn = document.getElementById('view-list-btn');
const viewGridBtn = document.getElementById('view-grid-btn');
if (viewListBtn) {
viewListBtn.addEventListener('click', () => {
setViewMode('list');
renderPlanCards();
});
}
if (viewGridBtn) {
viewGridBtn.addEventListener('click', () => {
setViewMode('grid');
renderPlanCards();
});
}
const cardRefreshSelect = document.getElementById('card-refresh-interval');
if (cardRefreshSelect) {
cardRefreshSelect.value = String(overviewState.cardRefreshIntervalMin);
cardRefreshSelect.addEventListener('change', () => {
const value = Number(cardRefreshSelect.value || 7);
overviewState.cardRefreshIntervalMin = [5, 7, 10].includes(value) ? value : 7;
restartCardAutoRefresh();
});
}
const addBtn = document.getElementById('card-add-btn');
if (addBtn) {
addBtn.addEventListener('click', async () => {
resetAddModalFields();
await loadAddableAccounts();
openModalById('overview-add-modal');
});
}
const toolbarRefreshBtn = document.getElementById('card-refresh-btn');
if (toolbarRefreshBtn) {
toolbarRefreshBtn.addEventListener('click', async () => {
try {
await refreshSelectedOrAll(true, true);
await Promise.all([
loadLegacyOverview(),
loadAddableAccounts(),
]);
toast.success('账号总览已刷新');
} catch (error) {
toast.error(`刷新失败: ${error?.message || '未知错误'}`);
}
});
}
const importBtn = document.getElementById('card-import-btn');
if (importBtn) {
importBtn.addEventListener('click', () => openModalById('overview-import-modal'));
}
const deleteBtn = document.getElementById('card-delete-btn');
if (deleteBtn) {
deleteBtn.addEventListener('click', async () => {
try {
await removeSelectedAccounts();
} catch (error) {
toast.error(`删除失败: ${error?.message || '未知错误'}`);
}
});
}
const exportAllBtn = document.getElementById('card-export-all-btn');
if (exportAllBtn) {
exportAllBtn.addEventListener('click', async () => {
try {
await exportAllVisibleAccounts();
} catch (error) {
toast.error(`导出失败: ${error?.message || '未知错误'}`);
}
});
}
const addModalIds = ['overview-add-modal', 'overview-import-modal'];
addModalIds.forEach((modalId) => {
const modal = document.getElementById(modalId);
if (!modal) return;
modal.addEventListener('click', (event) => {
if (event.target === modal) {
closeModalById(modalId);
}
});
});
document.getElementById('overview-add-close')?.addEventListener('click', () => closeModalById('overview-add-modal'));
document.getElementById('overview-add-cancel')?.addEventListener('click', () => closeModalById('overview-add-modal'));
document.getElementById('overview-add-existing-refresh')?.addEventListener('click', async () => {
try {
await loadAddableAccounts();
toast.success('已刷新账号管理列表');
} catch (error) {
toast.error(`刷新失败: ${error?.message || '未知错误'}`);
}
});
document.getElementById('overview-add-existing-select')?.addEventListener('change', () => {
fillAddFormFromExistingSelection();
});
document.getElementById('overview-add-existing-submit')?.addEventListener('click', async () => {
try {
await restoreSelectedAddableAccount();
} catch (error) {
toast.error(`恢复失败: ${error?.message || '未知错误'}`);
}
});
document.getElementById('overview-add-submit')?.addEventListener('click', async () => {
try {
await submitAddAccount();
} catch (error) {
toast.error(`添加失败: ${error?.message || '未知错误'}`);
}
});
document.getElementById('overview-import-close')?.addEventListener('click', () => closeModalById('overview-import-modal'));
document.getElementById('overview-import-cancel')?.addEventListener('click', () => closeModalById('overview-import-modal'));
document.getElementById('overview-import-submit')?.addEventListener('click', async () => {
try {
await submitImportAccounts();
} catch (error) {
toast.error(`导入失败: ${error?.message || '未知错误'}`);
}
});
const cardContainer = document.getElementById('plan-cards-container');
if (cardContainer) {
cardContainer.addEventListener('change', (event) => {
const checkbox = event.target.closest('input[data-role="select-card"]');
if (!checkbox) return;
const id = Number(checkbox.dataset.id);
if (!Number.isFinite(id)) return;
if (checkbox.checked) {
overviewState.selectedCardIds.add(id);
} else {
overviewState.selectedCardIds.delete(id);
}
updateSelectionInfo();
});
cardContainer.addEventListener('click', async (event) => {
const button = event.target.closest('button[data-action]');
if (!button) return;
const action = button.dataset.action;
const accountId = Number(button.dataset.id);
if (!Number.isFinite(accountId)) return;
if (action === 'refresh') {
await refreshSingleCard(accountId, button);
return;
}
if (action === 'export') {
await exportSingleCard(accountId);
return;
}
if (action === 'remove') {
await removeSingleCard(accountId);
}
});
}
}
document.addEventListener('DOMContentLoaded', async () => {
const sortMode = document.getElementById('card-sort-mode');
if (sortMode) sortMode.value = overviewState.sortMode;
setViewMode(overviewState.viewMode);
bindEvents();
const initResults = await Promise.allSettled([
loadLegacyOverview(),
loadPlanCards(false),
loadAddableAccounts(),
]);
initResults.forEach((item, index) => {
if (item.status === 'rejected') {
const target = index === 0 ? '总览统计' : '卡片列表';
toast.warning(`${target}初始化失败,已降级显示`);
}
});
restartCardAutoRefresh();
});