Add a '🔑 注册' button to account panel header; show only when a non-temp group selected; on successful OAuth exchange auto-open Add Account modal and pre-fill client_id + refresh_token. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2398 lines
103 KiB
JavaScript
2398 lines
103 KiB
JavaScript
// 全局状态
|
||
let csrfToken = null;
|
||
let currentAccount = null;
|
||
let currentGroupId = null;
|
||
let currentEmails = [];
|
||
let currentMethod = 'graph';
|
||
let currentFolder = 'inbox';
|
||
let isListVisible = true;
|
||
let groups = [];
|
||
let accountsCache = {};
|
||
let editingGroupId = null;
|
||
let selectedColor = '#B85C38';
|
||
let isTempEmailGroup = false;
|
||
let tempEmailGroupId = null;
|
||
let isLoadingMore = false;
|
||
let hasMoreEmails = true;
|
||
let currentSkip = 0;
|
||
let lastRefreshTime = null;
|
||
|
||
// 缓存与信任模式
|
||
let emailListCache = {};
|
||
let currentEmailDetail = null;
|
||
let isTrustedMode = false;
|
||
|
||
// 轮询相关
|
||
let pollingTimer = null;
|
||
let pollingCount = 0;
|
||
let maxPollingCount = 5;
|
||
let pollingInterval = 10;
|
||
let isPolling = false;
|
||
let knownEmailIds = new Set();
|
||
|
||
// 导航状态
|
||
let currentPage = 'dashboard';
|
||
|
||
// ==================== 主题 & 导航 ====================
|
||
|
||
function applyTheme(theme) {
|
||
document.documentElement.dataset.theme = theme;
|
||
localStorage.setItem('ol_theme', theme);
|
||
const btn = document.getElementById('themeToggleBtn');
|
||
if (btn) btn.textContent = theme === 'dark' ? '☀ 浅色模式' : '☾ 深色模式';
|
||
}
|
||
|
||
function toggleTheme() {
|
||
const current = document.documentElement.dataset.theme || 'light';
|
||
applyTheme(current === 'dark' ? 'light' : 'dark');
|
||
}
|
||
|
||
function navigate(page) {
|
||
currentPage = page;
|
||
// Hide all pages
|
||
document.querySelectorAll('.page').forEach(p => p.classList.add('page-hidden'));
|
||
const target = document.getElementById('page-' + page);
|
||
if (target) {
|
||
target.classList.remove('page-hidden');
|
||
target.style.display = '';
|
||
}
|
||
// Update nav active state
|
||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||
const navBtn = document.querySelector(`.nav-item[data-page="${page}"]`);
|
||
if (navBtn) navBtn.classList.add('active');
|
||
// Update topbar
|
||
updateTopbar(page);
|
||
// Close mobile sidebar
|
||
closeSidebar();
|
||
// Load page data
|
||
if (page === 'dashboard') loadDashboard();
|
||
if (page === 'mailbox') {
|
||
if (groups.length === 0) {
|
||
loadGroups();
|
||
} else if (currentGroupId) {
|
||
loadAccountsByGroup(currentGroupId);
|
||
}
|
||
}
|
||
if (page === 'temp-emails' && typeof loadTempEmails === 'function') loadTempEmails(true);
|
||
if (page === 'settings') loadSettings();
|
||
if (page === 'refresh-log') loadRefreshLogPage();
|
||
if (page === 'audit') loadAuditLogPage();
|
||
}
|
||
|
||
function updateTopbar(page) {
|
||
const titleEl = document.getElementById('topbarTitle');
|
||
const subtitleEl = document.getElementById('topbarSubtitle');
|
||
const actionsEl = document.getElementById('topbar-actions');
|
||
const titles = {
|
||
'dashboard': ['仪表盘', '系统概览'],
|
||
'mailbox': ['账号管理', '管理邮箱账号与查看邮件'],
|
||
'temp-emails': ['临时邮箱', '创建和管理临时邮箱'],
|
||
'refresh-log': ['刷新日志', 'Token 刷新历史记录'],
|
||
'settings': ['系统设置', '配置系统参数'],
|
||
'audit': ['审计日志', '系统操作记录']
|
||
};
|
||
const t = titles[page] || [page, ''];
|
||
if (titleEl) titleEl.textContent = t[0];
|
||
if (subtitleEl) subtitleEl.textContent = t[1];
|
||
// Context actions
|
||
if (actionsEl) {
|
||
if (page === 'mailbox') {
|
||
actionsEl.innerHTML = `
|
||
<button class="btn btn-sm btn-ghost" onclick="showExportModal()">📤 导出</button>
|
||
<button class="btn btn-sm btn-success" onclick="showRefreshModal()">🔄 全量刷新 Token</button>
|
||
<button class="btn btn-sm btn-primary" onclick="showAddAccountModal()">+ 添加账号</button>
|
||
`;
|
||
} else if (page === 'temp-emails') {
|
||
actionsEl.innerHTML = `
|
||
<button class="btn btn-sm btn-primary" onclick="generateTempEmail()">+ 创建邮箱</button>
|
||
`;
|
||
} else {
|
||
actionsEl.innerHTML = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
function toggleSidebar() {
|
||
const isMobile = window.innerWidth <= 768;
|
||
if (isMobile) {
|
||
// Mobile: toggle drawer
|
||
const sidebar = document.getElementById('sidebar');
|
||
const backdrop = document.getElementById('sidebarBackdrop');
|
||
sidebar.classList.toggle('mob-open');
|
||
backdrop.classList.toggle('show');
|
||
} else {
|
||
// Desktop: toggle collapsed state
|
||
const app = document.getElementById('app');
|
||
app.classList.toggle('sidebar-collapsed');
|
||
try {
|
||
localStorage.setItem('ol_sidebar_collapsed', app.classList.contains('sidebar-collapsed'));
|
||
} catch(e) {}
|
||
}
|
||
}
|
||
|
||
function closeSidebar() {
|
||
const sidebar = document.getElementById('sidebar');
|
||
const backdrop = document.getElementById('sidebarBackdrop');
|
||
if (sidebar) sidebar.classList.remove('mob-open');
|
||
if (backdrop) backdrop.classList.remove('show');
|
||
}
|
||
|
||
function logout() {
|
||
if (!confirm('确认退出登录?')) return;
|
||
fetch('/logout', { method: 'POST' })
|
||
.then(() => window.location.href = '/login')
|
||
.catch(() => window.location.href = '/login');
|
||
}
|
||
|
||
// ==================== Dashboard ====================
|
||
|
||
async function loadDashboard() {
|
||
try {
|
||
const [groupsRes, tempRes] = await Promise.all([
|
||
fetch('/api/groups'),
|
||
fetch('/api/temp-emails').catch(() => ({ json: () => ({ success: false }) }))
|
||
]);
|
||
const groupsData = await groupsRes.json();
|
||
const tempData = await tempRes.json();
|
||
|
||
let totalAccounts = 0, validTokens = 0, expiredTokens = 0;
|
||
const groupSummary = [];
|
||
|
||
if (groupsData.success && groupsData.groups) {
|
||
for (const g of groupsData.groups) {
|
||
const accRes = await fetch(`/api/accounts?group_id=${g.id}`);
|
||
const accData = await accRes.json();
|
||
const accounts = accData.success ? (accData.accounts || []) : [];
|
||
totalAccounts += accounts.length;
|
||
accounts.forEach(a => {
|
||
if (a.last_refresh_status === 'failed') expiredTokens++;
|
||
else validTokens++;
|
||
});
|
||
groupSummary.push({ id: g.id, name: g.name, color: g.color || '#666', count: accounts.length, isTempGroup: g.name === '临时邮箱' });
|
||
}
|
||
}
|
||
|
||
// Update stat cards
|
||
const el = (id, val) => { const e = document.getElementById(id); if (e) e.textContent = val; };
|
||
el('statTotalAccounts', totalAccounts);
|
||
el('statValidTokens', validTokens);
|
||
el('statExpiredTokens', expiredTokens);
|
||
el('statTempEmails', tempData.success ? (tempData.emails || []).length : 0);
|
||
|
||
// Update topbar subtitle with summary
|
||
const sub = document.getElementById('topbarSubtitle');
|
||
if (sub && currentPage === 'dashboard') {
|
||
sub.textContent = `共 ${totalAccounts} 个账号 · ${validTokens} 个 Token 有效`;
|
||
}
|
||
|
||
// Group summary list
|
||
const groupListEl = document.getElementById('dashboardGroupList');
|
||
if (groupListEl) {
|
||
if (groupSummary.length === 0) {
|
||
groupListEl.innerHTML = '<li style="color:var(--text-muted);padding:1rem;">暂无分组</li>';
|
||
} else {
|
||
groupListEl.innerHTML = groupSummary.map(g => {
|
||
const clickAction = g.isTempGroup
|
||
? `navigate('temp-emails')`
|
||
: `navigate('mailbox'); setTimeout(function(){ selectGroup(${g.id}); }, 100)`;
|
||
return `<li style="cursor:pointer;" onclick="${clickAction}"><span style="display:flex;align-items:center;gap:0.5rem;"><span class="group-color-dot" style="background:${escapeHtml(g.color)};"></span>${escapeHtml(g.name)}</span><span class="badge badge-gray">${g.count}</span></li>`;
|
||
}).join('');
|
||
}
|
||
}
|
||
|
||
// Refresh log summary
|
||
const refreshListEl = document.getElementById('dashboardRefreshList');
|
||
if (refreshListEl) {
|
||
try {
|
||
const refreshRes = await fetch('/api/accounts/refresh-status');
|
||
const refreshData = await refreshRes.json();
|
||
if (refreshData.success && refreshData.refresh_status) {
|
||
const rs = refreshData.refresh_status;
|
||
refreshListEl.innerHTML = `
|
||
<li><span>上次刷新时间</span><span class="badge badge-gray">${rs.last_refresh_time || '暂无'}</span></li>
|
||
<li><span>成功账号数</span><span class="badge" style="background:var(--clr-jade);color:white;">${rs.success_count}</span></li>
|
||
<li><span>失败账号数</span><span class="badge" style="background:${rs.failed_count > 0 ? 'var(--clr-danger)' : 'var(--clr-jade)'};color:white;">${rs.failed_count}</span></li>
|
||
`;
|
||
} else {
|
||
refreshListEl.innerHTML = '<li style="color:var(--text-muted);padding:1rem;">暂无刷新记录</li>';
|
||
}
|
||
} catch (e) {
|
||
refreshListEl.innerHTML = '<li style="color:var(--text-muted);padding:1rem;">前往刷新日志查看详情</li>';
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('Dashboard load error:', e);
|
||
}
|
||
}
|
||
|
||
// ==================== 分组搜索过滤 ====================
|
||
|
||
function filterGroups(query) {
|
||
const items = document.querySelectorAll('#groupList .group-item');
|
||
const q = query.toLowerCase();
|
||
items.forEach(item => {
|
||
const name = item.querySelector('.group-name');
|
||
if (name && name.textContent.toLowerCase().includes(q)) {
|
||
item.style.display = '';
|
||
} else {
|
||
item.style.display = q ? 'none' : '';
|
||
}
|
||
});
|
||
}
|
||
|
||
// ==================== 三栏拖拽调整 ====================
|
||
|
||
function initResizeHandles() {
|
||
document.querySelectorAll('.resize-handle').forEach(handle => {
|
||
handle.addEventListener('mousedown', function(e) {
|
||
e.preventDefault();
|
||
const leftId = this.dataset.left;
|
||
const rightId = this.dataset.right;
|
||
const leftPanel = document.getElementById(leftId);
|
||
const rightPanel = document.getElementById(rightId);
|
||
if (!leftPanel) return;
|
||
|
||
this.classList.add('active');
|
||
document.body.style.cursor = 'col-resize';
|
||
document.body.style.userSelect = 'none';
|
||
|
||
const startX = e.clientX;
|
||
const startWidth = leftPanel.offsetWidth;
|
||
|
||
function onMouseMove(ev) {
|
||
const delta = ev.clientX - startX;
|
||
const newWidth = Math.max(120, Math.min(startWidth + delta, 500));
|
||
leftPanel.style.width = newWidth + 'px';
|
||
}
|
||
|
||
function onMouseUp() {
|
||
handle.classList.remove('active');
|
||
document.body.style.cursor = '';
|
||
document.body.style.userSelect = '';
|
||
document.removeEventListener('mousemove', onMouseMove);
|
||
document.removeEventListener('mouseup', onMouseUp);
|
||
// Save widths to localStorage
|
||
try {
|
||
const widths = {};
|
||
document.querySelectorAll('.groups-column, .accounts-column').forEach(col => {
|
||
widths[col.id] = col.style.width;
|
||
});
|
||
localStorage.setItem('ol_column_widths', JSON.stringify(widths));
|
||
} catch(e) {}
|
||
}
|
||
|
||
document.addEventListener('mousemove', onMouseMove);
|
||
document.addEventListener('mouseup', onMouseUp);
|
||
});
|
||
});
|
||
|
||
// Restore saved widths
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem('ol_column_widths') || '{}');
|
||
Object.entries(saved).forEach(([id, width]) => {
|
||
const el = document.getElementById(id);
|
||
if (el && width) el.style.width = width;
|
||
});
|
||
} catch(e) {}
|
||
}
|
||
|
||
// ==================== 邮件详情显示控制 ====================
|
||
|
||
function showEmailDetailSection() {
|
||
const section = document.getElementById('emailDetailSection');
|
||
if (section) section.style.display = 'flex';
|
||
}
|
||
|
||
function hideEmailDetailSection() {
|
||
const section = document.getElementById('emailDetailSection');
|
||
if (section) section.style.display = 'none';
|
||
}
|
||
|
||
function stopRefresh() {
|
||
// Placeholder for stopping a bulk refresh operation
|
||
showToast('刷新已停止', 'warn');
|
||
const bar = document.getElementById('refreshProgressBar');
|
||
if (bar) bar.style.display = 'none';
|
||
}
|
||
|
||
// ==================== CSRF 防护 ====================
|
||
|
||
// 初始化 CSRF Token
|
||
async function initCSRFToken() {
|
||
try {
|
||
const response = await fetch('/api/csrf-token');
|
||
const data = await response.json();
|
||
csrfToken = data.csrf_token;
|
||
if (data.csrf_disabled) {
|
||
console.warn('CSRF protection is disabled. Install flask-wtf for better security.');
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to initialize CSRF token:', error);
|
||
}
|
||
}
|
||
|
||
// 包装 fetch 请求,自动添加 CSRF Token
|
||
const originalFetch = window.fetch;
|
||
window.fetch = function (url, options = {}) {
|
||
// 只对非 GET 请求添加 CSRF Token
|
||
if (options.method && options.method.toUpperCase() !== 'GET' && csrfToken) {
|
||
options.headers = options.headers || {};
|
||
if (options.headers instanceof Headers) {
|
||
options.headers.append('X-CSRFToken', csrfToken);
|
||
} else {
|
||
options.headers['X-CSRFToken'] = csrfToken;
|
||
}
|
||
}
|
||
return originalFetch(url, options);
|
||
};
|
||
|
||
// 初始化
|
||
document.addEventListener('DOMContentLoaded', async function () {
|
||
// 应用保存的主题
|
||
applyTheme(localStorage.getItem('ol_theme') || 'light');
|
||
|
||
// 恢复侧边栏折叠状态
|
||
try {
|
||
if (localStorage.getItem('ol_sidebar_collapsed') === 'true') {
|
||
document.getElementById('app').classList.add('sidebar-collapsed');
|
||
}
|
||
} catch(e) {}
|
||
|
||
// 初始化 CSRF Token
|
||
await initCSRFToken();
|
||
|
||
closeAllModals();
|
||
loadGroups();
|
||
if (typeof loadTags === 'function') {
|
||
loadTags();
|
||
}
|
||
initColorPicker();
|
||
initEmailListScroll();
|
||
initResizeHandles();
|
||
|
||
// 初始化轮询设置
|
||
initPollingSettings();
|
||
|
||
// 请求浏览器通知权限
|
||
if ('Notification' in window && Notification.permission === 'default') {
|
||
Notification.requestPermission();
|
||
}
|
||
|
||
// 绑定搜索框事件
|
||
const searchInput = document.getElementById('globalSearch');
|
||
if (searchInput) {
|
||
const debouncedSearch = debounce((e) => {
|
||
searchAccounts(e.target.value);
|
||
}, 300);
|
||
searchInput.addEventListener('input', debouncedSearch);
|
||
}
|
||
|
||
// 加载仪表盘
|
||
loadDashboard();
|
||
});
|
||
|
||
// 初始化颜色选择器
|
||
function initColorPicker() {
|
||
document.querySelectorAll('.color-option').forEach(option => {
|
||
option.addEventListener('click', function () {
|
||
document.querySelectorAll('.color-option').forEach(o => o.classList.remove('selected'));
|
||
this.classList.add('selected');
|
||
selectedColor = this.dataset.color;
|
||
// 同步更新自定义颜色输入框
|
||
document.getElementById('customColorInput').value = selectedColor;
|
||
document.getElementById('customColorHex').value = selectedColor;
|
||
});
|
||
});
|
||
}
|
||
|
||
// 初始化邮件列表滚动监听
|
||
function initEmailListScroll() {
|
||
const emailList = document.getElementById('emailList');
|
||
emailList.addEventListener('scroll', function () {
|
||
// 检查是否滚动到底部
|
||
if (emailList.scrollHeight - emailList.scrollTop <= emailList.clientHeight + 50) {
|
||
if (!isLoadingMore && hasMoreEmails && currentAccount && !isTempEmailGroup) {
|
||
loadMoreEmails();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// 加载更多邮件
|
||
async function loadMoreEmails() {
|
||
if (isLoadingMore || !hasMoreEmails) return;
|
||
|
||
isLoadingMore = true;
|
||
currentSkip += 20; // 每页20封
|
||
|
||
// 在列表底部显示加载状态
|
||
const emailList = document.getElementById('emailList');
|
||
const loadingDiv = document.createElement('div');
|
||
loadingDiv.className = 'loading-overlay';
|
||
loadingDiv.id = 'loadingMore';
|
||
loadingDiv.innerHTML = '<span class="spinner"></span> 加载更多…';
|
||
emailList.appendChild(loadingDiv);
|
||
|
||
// 禁用按钮
|
||
const refreshBtn = document.querySelector('.refresh-btn');
|
||
const folderTabs = document.querySelectorAll('.email-tab');
|
||
if (refreshBtn) {
|
||
refreshBtn.disabled = true;
|
||
}
|
||
folderTabs.forEach(tab => tab.disabled = true);
|
||
|
||
try {
|
||
const response = await fetch(
|
||
`/api/emails/${encodeURIComponent(currentAccount)}?method=${currentMethod}&folder=${currentFolder}&skip=${currentSkip}&top=20`
|
||
);
|
||
const data = await response.json();
|
||
|
||
if (data.success && data.emails.length > 0) {
|
||
// 追加新邮件到列表
|
||
currentEmails = currentEmails.concat(data.emails);
|
||
hasMoreEmails = data.has_more;
|
||
|
||
// 移除加载状态
|
||
const loadingEl = document.getElementById('loadingMore');
|
||
if (loadingEl) loadingEl.remove();
|
||
|
||
// 重新渲染邮件列表
|
||
renderEmailList(currentEmails);
|
||
|
||
// 更新邮件数量
|
||
document.getElementById('emailCount').textContent = `(${currentEmails.length})`;
|
||
|
||
// 更新缓存
|
||
if (currentAccount && !isTempEmailGroup) {
|
||
const cacheKey = `${currentAccount}_${currentFolder}`;
|
||
if (emailListCache[cacheKey]) {
|
||
emailListCache[cacheKey].emails = currentEmails;
|
||
emailListCache[cacheKey].has_more = hasMoreEmails;
|
||
emailListCache[cacheKey].skip = currentSkip;
|
||
}
|
||
}
|
||
} else {
|
||
hasMoreEmails = false;
|
||
// 显示"没有更多邮件"
|
||
const loadingEl = document.getElementById('loadingMore');
|
||
if (loadingEl) {
|
||
loadingEl.innerHTML = '<div style="text-align:center;padding:20px;color:#999;font-size:13px;">没有更多邮件了</div>';
|
||
}
|
||
}
|
||
} catch (error) {
|
||
const loadingEl = document.getElementById('loadingMore');
|
||
if (loadingEl) loadingEl.remove();
|
||
showToast('加载失败', 'error');
|
||
} finally {
|
||
isLoadingMore = false;
|
||
// 启用按钮
|
||
if (refreshBtn) {
|
||
refreshBtn.disabled = false;
|
||
}
|
||
folderTabs.forEach(tab => tab.disabled = false);
|
||
}
|
||
}
|
||
|
||
// 切换文件夹(不触发查询)
|
||
function switchFolder(folder) {
|
||
if (currentFolder === folder) return;
|
||
|
||
currentFolder = folder;
|
||
|
||
// 更新按钮状态
|
||
document.querySelectorAll('.email-tab').forEach(tab => {
|
||
tab.classList.toggle('active', tab.dataset.folder === folder);
|
||
});
|
||
|
||
const cacheKey = `${currentAccount}_${folder}`;
|
||
|
||
// 检查是否有缓存
|
||
if (emailListCache[cacheKey]) {
|
||
const cache = emailListCache[cacheKey];
|
||
currentEmails = cache.emails;
|
||
hasMoreEmails = cache.has_more;
|
||
currentSkip = cache.skip;
|
||
currentMethod = cache.method || 'graph';
|
||
|
||
// 恢复 UI
|
||
const methodTag = document.getElementById('methodTag');
|
||
methodTag.textContent = currentMethod;
|
||
methodTag.style.display = 'inline';
|
||
document.getElementById('emailCount').textContent = `(${currentEmails.length})`;
|
||
|
||
renderEmailList(currentEmails);
|
||
} else {
|
||
// 清空邮件列表,显示提示
|
||
document.getElementById('emailList').innerHTML = `
|
||
<div class="empty-state">
|
||
<span class="empty-icon">📬</span>
|
||
<p>点击"获取邮件"按钮获取${folder === 'inbox' ? '收件箱' : '垃圾邮件'}</p>
|
||
</div>
|
||
`;
|
||
document.getElementById('emailCount').textContent = '';
|
||
document.getElementById('methodTag').style.display = 'none';
|
||
|
||
// 重置分页状态
|
||
currentEmails = [];
|
||
currentSkip = 0;
|
||
hasMoreEmails = true;
|
||
}
|
||
}
|
||
|
||
// 选择自定义颜色(颜色选择器)
|
||
function selectCustomColor(color) {
|
||
selectedColor = color;
|
||
document.getElementById('customColorHex').value = color;
|
||
// 取消预设颜色的选中状态
|
||
document.querySelectorAll('.color-option').forEach(o => o.classList.remove('selected'));
|
||
}
|
||
|
||
// 选择自定义颜色(十六进制输入)
|
||
function selectCustomColorHex(value) {
|
||
// 验证十六进制颜色格式
|
||
const hexPattern = /^#[0-9A-Fa-f]{6}$/;
|
||
if (hexPattern.test(value)) {
|
||
selectedColor = value;
|
||
document.getElementById('customColorInput').value = value;
|
||
// 取消预设颜色的选中状态
|
||
document.querySelectorAll('.color-option').forEach(o => o.classList.remove('selected'));
|
||
} else {
|
||
showToast('请输入有效的十六进制颜色(如 #FF5500)', 'error');
|
||
}
|
||
}
|
||
|
||
// 显示消息提示
|
||
function showToast(message, type = 'info', errorDetail = null) {
|
||
let container = document.getElementById('toast-container');
|
||
if (!container) {
|
||
container = document.createElement('div');
|
||
container.id = 'toast-container';
|
||
container.setAttribute('aria-live', 'polite');
|
||
document.body.appendChild(container);
|
||
}
|
||
|
||
const toast = document.createElement('div');
|
||
toast.className = 'toast ' + type;
|
||
|
||
const messageSpan = document.createElement('span');
|
||
messageSpan.textContent = message;
|
||
toast.appendChild(messageSpan);
|
||
|
||
if (errorDetail && type === 'error') {
|
||
const detailLink = document.createElement('a');
|
||
detailLink.href = 'javascript:void(0)';
|
||
detailLink.textContent = ' [详情]';
|
||
detailLink.style.cssText = 'color:var(--clr-danger);text-decoration:underline;margin-left:8px;';
|
||
detailLink.onclick = function (e) {
|
||
e.stopPropagation();
|
||
showErrorDetailModal(errorDetail);
|
||
};
|
||
toast.appendChild(detailLink);
|
||
}
|
||
|
||
container.appendChild(toast);
|
||
|
||
const duration = (errorDetail && type === 'error') ? 8000 : 3000;
|
||
setTimeout(() => {
|
||
toast.style.opacity = '0';
|
||
toast.style.transform = 'translateX(30px)';
|
||
setTimeout(() => toast.remove(), 300);
|
||
}, duration);
|
||
}
|
||
|
||
// 显示刷新错误信息
|
||
function showRefreshError(accountId, errorMessage, accountEmail) {
|
||
document.getElementById('refreshErrorModal').classList.add('show');
|
||
document.getElementById('refreshErrorEmail').textContent = `账号:${accountEmail || '未知'}`;
|
||
document.getElementById('refreshErrorMessage').textContent = errorMessage;
|
||
document.getElementById('editAccountFromErrorBtn').onclick = function () {
|
||
hideRefreshErrorModal();
|
||
showEditAccountModal(accountId);
|
||
};
|
||
}
|
||
|
||
// 隐藏刷新错误模态框
|
||
function hideRefreshErrorModal() {
|
||
document.getElementById('refreshErrorModal').classList.remove('show');
|
||
}
|
||
|
||
// ==================== 统一错误处理相关 ====================
|
||
|
||
// 显示统一错误详情模态框
|
||
function showErrorDetailModal(error) {
|
||
document.getElementById('errorDetailModal').classList.add('show');
|
||
document.getElementById('errorModalUserMessage').textContent = error.message || '发生未知错误';
|
||
document.getElementById('errorModalCode').textContent = error.code || '-';
|
||
document.getElementById('errorModalType').textContent = error.type || '-';
|
||
document.getElementById('errorModalStatus').textContent = error.status || '-';
|
||
document.getElementById('errorModalTraceId').textContent = error.trace_id || '-';
|
||
|
||
const detailsEl = document.getElementById('errorModalDetails');
|
||
const detailsContainer = document.getElementById('errorModalDetailsContainer');
|
||
const toggleBtn = document.getElementById('toggleTraceBtn');
|
||
|
||
detailsEl.textContent = error.details || '暂无详细技术堆栈信息';
|
||
|
||
// 重置堆栈显示状态
|
||
detailsContainer.style.display = 'none';
|
||
toggleBtn.textContent = '显示堆栈/细节';
|
||
}
|
||
|
||
// 隐藏统一错误详情模态框
|
||
function hideErrorDetailModal() {
|
||
document.getElementById('errorDetailModal').classList.remove('show');
|
||
}
|
||
|
||
// 邮件获取失败详情弹框
|
||
function showEmailFetchErrorModal(details) {
|
||
if (!details) return;
|
||
|
||
const methodNames = {
|
||
'graph': 'Graph API',
|
||
'imap_new': 'IMAP(新服务器)',
|
||
'imap_old': 'IMAP(旧服务器)'
|
||
};
|
||
|
||
function translateError(err) {
|
||
if (!err) return '未知错误';
|
||
// err 可能是 string 或 object
|
||
if (typeof err === 'string') return err;
|
||
|
||
const code = err.code || '';
|
||
const details = typeof err.details === 'string' ? err.details : JSON.stringify(err.details || '');
|
||
const msg = err.message || '';
|
||
|
||
// 翻译常见错误
|
||
if (code === 'GRAPH_TOKEN_EXCEPTION' && details.includes('ProxyError')) {
|
||
return '代理连接失败:无法连接到代理服务器,请检查代理地址是否正确以及代理是否在运行';
|
||
}
|
||
if (code === 'GRAPH_TOKEN_FAILED' || code === 'IMAP_TOKEN_FAILED') {
|
||
if (details.includes('invalid_grant')) {
|
||
return 'Token 已失效或权限不足:请重新授权登录或更换 refresh_token';
|
||
}
|
||
if (details.includes('invalid_client')) {
|
||
return 'Client ID 无效:请检查 client_id 配置是否正确';
|
||
}
|
||
return `令牌获取失败:${msg}`;
|
||
}
|
||
if (code === 'EMAIL_FETCH_FAILED') {
|
||
return `获取邮件失败:${msg}`;
|
||
}
|
||
if (code === 'IMAP_CONNECTION_FAILED') {
|
||
return 'IMAP 连接失败:无法连接到邮件服务器';
|
||
}
|
||
return msg || details || '未知错误';
|
||
}
|
||
|
||
let html = '';
|
||
const methods = ['graph', 'imap_new', 'imap_old'];
|
||
methods.forEach(method => {
|
||
const err = details[method];
|
||
if (err !== undefined) {
|
||
const name = methodNames[method] || method;
|
||
const reason = translateError(err);
|
||
const codeText = (err && typeof err === 'object') ? (err.code || '-') : '-';
|
||
html += `
|
||
<div style="background: #fff5f5; border: 1px solid #fde2e2; border-radius: 8px; padding: 14px 16px; margin-bottom: 12px;">
|
||
<div style="font-weight: 600; color: #dc3545; margin-bottom: 6px; font-size: 14px;">${name}</div>
|
||
<div style="color: #333; font-size: 13px; line-height: 1.6;">${reason}</div>
|
||
<div style="color: #999; font-size: 12px; margin-top: 4px;">错误代码: ${codeText}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
});
|
||
|
||
if (!html) {
|
||
html = '<div style="color:#666;">无详细错误信息</div>';
|
||
}
|
||
|
||
document.getElementById('emailFetchErrorContent').innerHTML = html;
|
||
document.getElementById('emailFetchErrorModal').classList.add('show');
|
||
}
|
||
|
||
function hideEmailFetchErrorModal() {
|
||
document.getElementById('emailFetchErrorModal').classList.remove('show');
|
||
}
|
||
|
||
// 切换堆栈信息的显示/隐藏
|
||
function toggleStackTrace() {
|
||
const container = document.getElementById('errorModalDetailsContainer');
|
||
const btn = document.getElementById('toggleTraceBtn');
|
||
|
||
if (container.style.display === 'none') {
|
||
container.style.display = 'block';
|
||
btn.textContent = '隐藏堆栈/细节';
|
||
} else {
|
||
container.style.display = 'none';
|
||
btn.textContent = '显示堆栈/细节';
|
||
}
|
||
}
|
||
|
||
// 复制错误详情到剪贴板
|
||
function copyErrorDetails() {
|
||
const userMessage = document.getElementById('errorModalUserMessage').textContent;
|
||
const details = document.getElementById('errorModalDetails').textContent;
|
||
const code = document.getElementById('errorModalCode').textContent;
|
||
const type = document.getElementById('errorModalType').textContent;
|
||
const status = document.getElementById('errorModalStatus').textContent;
|
||
const traceId = document.getElementById('errorModalTraceId').textContent;
|
||
|
||
const fullErrorText = `
|
||
【用户错误信息】
|
||
${userMessage}
|
||
|
||
【错误详情】
|
||
Code: ${code}
|
||
Type: ${type}
|
||
Status: ${status}
|
||
Trace ID: ${traceId}
|
||
|
||
【技术堆栈/细节】
|
||
${details}
|
||
`.trim();
|
||
|
||
navigator.clipboard.writeText(fullErrorText).then(() => {
|
||
showToast('错误详情已复制', 'success');
|
||
}).catch(() => {
|
||
// 降级方案
|
||
const textarea = document.createElement('textarea');
|
||
textarea.value = fullErrorText;
|
||
document.body.appendChild(textarea);
|
||
textarea.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(textarea);
|
||
showToast('错误详情已复制', 'success');
|
||
});
|
||
}
|
||
|
||
// 统一处理 API 响应错误
|
||
function handleApiError(data, defaultMessage = '请求失败') {
|
||
if (!data.success) {
|
||
// 检查是否是统一错误格式
|
||
if (data.error && data.error.message) {
|
||
const error = data.error;
|
||
// 使用后端提供的 message 作为用户友好信息
|
||
const userMessage = error.message;
|
||
|
||
// 调用 showToast 携带完整的错误对象
|
||
showToast(userMessage, 'error', error);
|
||
} else {
|
||
// 兼容旧的或非标准错误格式
|
||
const errorMessage = data.error || defaultMessage;
|
||
showToast(errorMessage, 'error');
|
||
}
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function escapeJs(str) {
|
||
if (!str) return '';
|
||
return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
||
}
|
||
|
||
// ==================== 工具函数 ====================
|
||
|
||
// HTML 转义
|
||
function escapeHtml(text) {
|
||
if (!text) return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// 格式化日期
|
||
function formatDate(dateStr) {
|
||
if (!dateStr) return '';
|
||
try {
|
||
const date = new Date(dateStr);
|
||
if (isNaN(date.getTime())) return dateStr;
|
||
|
||
const now = new Date();
|
||
const isToday = date.toDateString() === now.toDateString();
|
||
|
||
if (isToday) {
|
||
return '今天 ' + date.toLocaleTimeString('zh-CN', {
|
||
timeZone: 'Asia/Shanghai',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
} else {
|
||
return date.toLocaleDateString('zh-CN', {
|
||
timeZone: 'Asia/Shanghai',
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric'
|
||
}) + ' ' + date.toLocaleTimeString('zh-CN', {
|
||
timeZone: 'Asia/Shanghai',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
}
|
||
} catch (e) {
|
||
return dateStr;
|
||
}
|
||
}
|
||
|
||
// ==================== OAuth Refresh Token 相关 ====================
|
||
|
||
// 显示获取 Refresh Token 模态框
|
||
async function showGetRefreshTokenModal() {
|
||
document.getElementById('getRefreshTokenModal').classList.add('show');
|
||
|
||
// 重置表单
|
||
document.getElementById('redirectUrlInput').value = '';
|
||
document.getElementById('refreshTokenResult').style.display = 'none';
|
||
document.getElementById('refreshTokenOutput').value = '';
|
||
|
||
// 重置按钮状态
|
||
const btn = document.getElementById('exchangeTokenBtn');
|
||
btn.disabled = false;
|
||
btn.textContent = '换取 Token';
|
||
btn.style.display = '';
|
||
|
||
// 获取授权 URL
|
||
try {
|
||
const response = await fetch('/api/oauth/auth-url');
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
document.getElementById('authUrlInput').value = data.auth_url;
|
||
} else {
|
||
showToast('获取授权链接失败', 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast('获取授权链接失败', 'error');
|
||
}
|
||
}
|
||
|
||
// 隐藏获取 Refresh Token 模态框
|
||
function hideGetRefreshTokenModal() {
|
||
document.getElementById('getRefreshTokenModal').classList.remove('show');
|
||
}
|
||
|
||
// 复制授权 URL
|
||
function copyAuthUrl() {
|
||
const input = document.getElementById('authUrlInput');
|
||
input.select();
|
||
document.execCommand('copy');
|
||
showToast('授权链接已复制到剪贴板', 'success');
|
||
}
|
||
|
||
// 打开授权 URL
|
||
function openAuthUrl() {
|
||
const url = document.getElementById('authUrlInput').value;
|
||
if (url) {
|
||
window.open(url, '_blank');
|
||
showToast('已在新窗口打开授权页面', 'info');
|
||
}
|
||
}
|
||
|
||
// 换取 Token
|
||
async function exchangeToken() {
|
||
const redirectUrl = document.getElementById('redirectUrlInput').value.trim();
|
||
|
||
if (!redirectUrl) {
|
||
showToast('请先粘贴授权后的完整 URL', 'error');
|
||
return;
|
||
}
|
||
|
||
const btn = document.getElementById('exchangeTokenBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = '⏳ 换取中...';
|
||
|
||
try {
|
||
const response = await fetch('/api/oauth/exchange-token', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
redirected_url: redirectUrl
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
btn.disabled = false;
|
||
btn.textContent = '换取 Token';
|
||
|
||
// 关闭 Token 弹窗,打开添加账号弹窗并预填充
|
||
hideGetRefreshTokenModal();
|
||
showAddAccountModal();
|
||
|
||
const inputEl = document.getElementById('accountInput');
|
||
if (inputEl) {
|
||
inputEl.value = `your@outlook.com----yourpassword----${data.client_id}----${data.refresh_token}`;
|
||
inputEl.select();
|
||
}
|
||
|
||
showToast('✅ Token 获取成功!请将邮箱和密码替换后点导入', 'success');
|
||
} else {
|
||
handleApiError(data, '换取 Token 失败');
|
||
btn.disabled = false;
|
||
btn.textContent = '换取 Token';
|
||
}
|
||
} catch (error) {
|
||
showToast('换取 Token 失败: ' + error.message, 'error');
|
||
btn.disabled = false;
|
||
btn.textContent = '换取 Token';
|
||
}
|
||
}
|
||
|
||
// ==================== 设置相关 ====================
|
||
|
||
// 显示设置模态框
|
||
async function showSettingsModal() {
|
||
document.getElementById('settingsModal').classList.add('show');
|
||
await loadSettings();
|
||
}
|
||
|
||
// 隐藏设置模态框
|
||
function hideSettingsModal() {
|
||
document.getElementById('settingsModal').classList.remove('show');
|
||
// 清空密码输入框
|
||
document.getElementById('settingsPassword').value = '';
|
||
}
|
||
|
||
// 加载设置
|
||
async function loadSettings() {
|
||
try {
|
||
const response = await fetch('/api/settings');
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
// 密码不显示,只显示 API Key
|
||
document.getElementById('settingsApiKey').value = data.settings.gptmail_api_key || '';
|
||
// 密码框留空
|
||
document.getElementById('settingsPassword').value = '';
|
||
|
||
// 加载刷新配置
|
||
document.getElementById('refreshIntervalDays').value = data.settings.refresh_interval_days || '30';
|
||
document.getElementById('refreshDelaySeconds').value = data.settings.refresh_delay_seconds || '5';
|
||
document.getElementById('refreshCron').value = data.settings.refresh_cron || '0 2 * * *';
|
||
|
||
// 设置定时刷新开关
|
||
const enableScheduled = data.settings.enable_scheduled_refresh !== 'false';
|
||
document.getElementById('enableScheduledRefresh').checked = enableScheduled;
|
||
|
||
// 设置刷新策略单选框
|
||
const useCron = data.settings.use_cron_schedule === 'true';
|
||
document.querySelector('input[name="refreshStrategy"][value="' + (useCron ? 'cron' : 'days') + '"]').checked = true;
|
||
toggleRefreshStrategy();
|
||
|
||
// 加载轮询设置(后端返回 boolean,兼容处理)
|
||
const enablePolling = data.settings.enable_auto_polling === true || data.settings.enable_auto_polling === 'true';
|
||
document.getElementById('enableAutoPolling').checked = enablePolling;
|
||
document.getElementById('pollingInterval').value = data.settings.polling_interval || '10';
|
||
document.getElementById('pollingCount').value = data.settings.polling_count || '5';
|
||
}
|
||
} catch (error) {
|
||
showToast('加载设置失败', 'error');
|
||
}
|
||
}
|
||
|
||
// 切换刷新策略
|
||
function toggleRefreshStrategy() {
|
||
const strategy = document.querySelector('input[name="refreshStrategy"]:checked').value;
|
||
document.getElementById('daysStrategyContainer').style.display = strategy === 'days' ? 'block' : 'none';
|
||
document.getElementById('cronStrategyContainer').style.display = strategy === 'cron' ? 'block' : 'none';
|
||
}
|
||
|
||
// 选择 Cron 样例
|
||
async function selectCronExample(cronExpr) {
|
||
document.getElementById('refreshCron').value = cronExpr;
|
||
await validateCronExpression();
|
||
}
|
||
|
||
// 验证 Cron 表达式
|
||
async function validateCronExpression() {
|
||
const cronExpr = document.getElementById('refreshCron').value.trim();
|
||
const resultEl = document.getElementById('cronValidationResult');
|
||
|
||
if (!cronExpr) {
|
||
resultEl.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/settings/validate-cron', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ cron_expression: cronExpr })
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success && data.valid) {
|
||
const nextRun = new Date(data.next_run).toLocaleString('zh-CN');
|
||
resultEl.innerHTML = `
|
||
<div style="color: #28a745;">
|
||
✓ 表达式有效<br>
|
||
下次执行: ${nextRun}
|
||
</div>
|
||
`;
|
||
} else {
|
||
resultEl.innerHTML = `
|
||
<div style="color: #dc3545;">
|
||
✗ ${data.error && data.error.message ? data.error.message : (data.error || '表达式无效')}
|
||
</div>
|
||
`;
|
||
}
|
||
} catch (error) {
|
||
resultEl.innerHTML = `
|
||
<div style="color: #dc3545;">
|
||
✗ 验证失败: ${error.message}
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
// 保存设置
|
||
async function saveSettings() {
|
||
const password = document.getElementById('settingsPassword').value;
|
||
const apiKey = document.getElementById('settingsApiKey').value.trim();
|
||
const refreshDays = document.getElementById('refreshIntervalDays').value;
|
||
const refreshDelay = document.getElementById('refreshDelaySeconds').value;
|
||
const refreshCron = document.getElementById('refreshCron').value.trim();
|
||
const strategy = document.querySelector('input[name="refreshStrategy"]:checked').value;
|
||
const enableScheduled = document.getElementById('enableScheduledRefresh').checked;
|
||
|
||
// 轮询设置
|
||
const enablePolling = document.getElementById('enableAutoPolling').checked;
|
||
const pollingInterval = document.getElementById('pollingInterval').value;
|
||
const pollingCount = document.getElementById('pollingCount').value;
|
||
|
||
const settings = {};
|
||
|
||
// 只有输入了密码才更新密码
|
||
if (password) {
|
||
settings.login_password = password;
|
||
}
|
||
|
||
// API Key 可以为空(清除)
|
||
settings.gptmail_api_key = apiKey;
|
||
|
||
// 刷新配置
|
||
const days = parseInt(refreshDays);
|
||
const delay = parseInt(refreshDelay);
|
||
|
||
if (isNaN(days) || days < 1 || days > 90) {
|
||
showToast('刷新周期必须在 1-90 天之间', 'error');
|
||
return;
|
||
}
|
||
|
||
if (isNaN(delay) || delay < 0 || delay > 60) {
|
||
showToast('刷新间隔必须在 0-60 秒之间', 'error');
|
||
return;
|
||
}
|
||
|
||
settings.refresh_interval_days = days;
|
||
settings.refresh_delay_seconds = delay;
|
||
settings.use_cron_schedule = strategy === 'cron';
|
||
settings.enable_scheduled_refresh = enableScheduled;
|
||
|
||
if (strategy === 'cron') {
|
||
if (!refreshCron) {
|
||
showToast('请输入 Cron 表达式', 'error');
|
||
return;
|
||
}
|
||
settings.refresh_cron = refreshCron;
|
||
}
|
||
|
||
// 轮询配置验证
|
||
const pInterval = parseInt(pollingInterval);
|
||
const pCount = parseInt(pollingCount);
|
||
|
||
if (isNaN(pInterval) || pInterval < 5 || pInterval > 300) {
|
||
showToast('轮询间隔必须在 5-300 秒之间', 'error');
|
||
return;
|
||
}
|
||
|
||
// 0 表示持续轮询,1-100 表示有限次数
|
||
if (isNaN(pCount) || pCount < 0 || pCount > 100) {
|
||
showToast('轮询次数必须在 0-100 次之间(0 表示持续轮询)', 'error');
|
||
return;
|
||
}
|
||
|
||
settings.enable_auto_polling = enablePolling;
|
||
settings.polling_interval = pInterval;
|
||
settings.polling_count = pCount;
|
||
|
||
try {
|
||
const response = await fetch('/api/settings', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(settings)
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
showToast('设置已保存,重启应用后生效', 'success');
|
||
hideSettingsModal();
|
||
} else {
|
||
handleApiError(data, '保存设置失败');
|
||
}
|
||
} catch (error) {
|
||
showToast('保存设置失败', 'error');
|
||
}
|
||
}
|
||
|
||
// ==================== 自动轮询功能 ====================
|
||
|
||
// 初始化轮询设置
|
||
async function initPollingSettings() {
|
||
try {
|
||
const response = await fetch('/api/settings');
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
pollingInterval = parseInt(data.settings.polling_interval) || 10;
|
||
maxPollingCount = parseInt(data.settings.polling_count) || 5;
|
||
|
||
// 如果启用了自动轮询,则开始轮询(后端返回 boolean,兼容处理)
|
||
const enablePolling = data.settings.enable_auto_polling === true || data.settings.enable_auto_polling === 'true';
|
||
if (enablePolling && currentAccount) {
|
||
startPolling();
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('初始化轮询设置失败:', error);
|
||
}
|
||
}
|
||
|
||
// 开始轮询
|
||
function startPolling() {
|
||
if (isPolling || !currentAccount) {
|
||
console.log('[轮询] 无法启动: isPolling=', isPolling, ', currentAccount=', currentAccount);
|
||
return;
|
||
}
|
||
|
||
isPolling = true;
|
||
pollingCount = 0;
|
||
pollingErrorCount = 0; // 重置错误计数
|
||
|
||
// 初始化已知邮件ID集合
|
||
knownEmailIds = new Set(currentEmails.map(email => email.id));
|
||
|
||
console.log('[轮询] 已启动,间隔:', pollingInterval, '秒,最大次数:', maxPollingCount);
|
||
|
||
// 显示轮询状态指示器
|
||
showPollingStatusIndicator();
|
||
|
||
// 立即执行一次
|
||
pollForNewEmails();
|
||
|
||
// 设置定时器
|
||
pollingTimer = setInterval(pollForNewEmails, pollingInterval * 1000);
|
||
}
|
||
|
||
// 显示轮询状态指示器
|
||
function showPollingStatusIndicator() {
|
||
let indicator = document.getElementById('pollingStatusIndicator');
|
||
if (!indicator) {
|
||
indicator = document.createElement('div');
|
||
indicator.id = 'pollingStatusIndicator';
|
||
indicator.style.cssText = `
|
||
position: fixed;
|
||
bottom: 24px;
|
||
right: 24px;
|
||
background-color: #28a745;
|
||
color: white;
|
||
padding: 8px 16px;
|
||
border-radius: 20px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
box-shadow: 0 2px 8px rgba(40, 167, 69, 0.3);
|
||
z-index: 1000;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
cursor: pointer;
|
||
`;
|
||
indicator.innerHTML = `<span style="animation: pulse 1.5s infinite;">🔄</span> 轮询中`;
|
||
indicator.onclick = () => {
|
||
if (confirm('是否停止轮询?')) {
|
||
stopPolling(false);
|
||
}
|
||
};
|
||
document.body.appendChild(indicator);
|
||
}
|
||
indicator.style.display = 'flex';
|
||
}
|
||
|
||
// 隐藏轮询状态指示器
|
||
function hidePollingStatusIndicator() {
|
||
const indicator = document.getElementById('pollingStatusIndicator');
|
||
if (indicator) {
|
||
indicator.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// 停止轮询
|
||
function stopPolling(silent = false) {
|
||
console.log('[轮询] 停止轮询, silent=', silent);
|
||
if (pollingTimer) {
|
||
clearInterval(pollingTimer);
|
||
pollingTimer = null;
|
||
}
|
||
isPolling = false;
|
||
pollingCount = 0;
|
||
pollingErrorCount = 0;
|
||
hideNewEmailIndicator();
|
||
hideAccountNewEmailDot();
|
||
hidePollingStatusIndicator();
|
||
// 用户主动停止时显示提示
|
||
if (!silent) {
|
||
showToast('已停止轮询', 'info');
|
||
}
|
||
}
|
||
|
||
// 轮询检查新邮件
|
||
let pollingErrorCount = 0; // 连续错误计数
|
||
const MAX_POLLING_ERRORS = 3; // 最大连续错误次数
|
||
|
||
async function pollForNewEmails() {
|
||
if (!currentAccount) {
|
||
stopPolling(true);
|
||
return;
|
||
}
|
||
|
||
pollingCount++;
|
||
|
||
try {
|
||
const response = await fetch(`/api/emails/${encodeURIComponent(currentAccount)}?skip=0&limit=10&folder=${currentFolder}`);
|
||
const data = await response.json();
|
||
|
||
if (data.success && data.emails) {
|
||
pollingErrorCount = 0; // 重置错误计数
|
||
const newEmails = data.emails.filter(email => !knownEmailIds.has(email.id));
|
||
|
||
if (newEmails.length > 0) {
|
||
// 发现新邮件
|
||
showNewEmailNotification(newEmails);
|
||
|
||
// 更新已知邮件ID集合
|
||
newEmails.forEach(email => knownEmailIds.add(email.id));
|
||
|
||
// 更新邮件列表(在现有列表前面插入新邮件)
|
||
currentEmails = [...newEmails, ...currentEmails];
|
||
renderEmailList(currentEmails);
|
||
document.getElementById('emailCount').textContent = `(${currentEmails.length})`;
|
||
|
||
// 显示新邮件指示器(账号列表项红点)
|
||
showAccountNewEmailDot(newEmails.length);
|
||
}
|
||
} else {
|
||
pollingErrorCount++;
|
||
}
|
||
} catch (error) {
|
||
console.error('轮询新邮件失败:', error);
|
||
pollingErrorCount++;
|
||
}
|
||
|
||
// 连续错误超过阈值,停止轮询并提示
|
||
if (pollingErrorCount >= MAX_POLLING_ERRORS) {
|
||
stopPolling(true);
|
||
showToast('轮询连续失败,已自动停止', 'error');
|
||
return;
|
||
}
|
||
|
||
// 检查是否达到最大轮询次数(0 表示持续轮询,不检查次数)
|
||
if (maxPollingCount > 0 && pollingCount >= maxPollingCount) {
|
||
stopPolling(true);
|
||
}
|
||
}
|
||
|
||
// 显示新邮件通知(包含邮箱地址和邮件主题)
|
||
function showNewEmailNotification(newEmails) {
|
||
const count = newEmails.length;
|
||
const firstEmail = newEmails[0];
|
||
const subject = firstEmail?.subject || '无主题';
|
||
|
||
// 请求浏览器通知权限
|
||
if ('Notification' in window && Notification.permission === 'granted') {
|
||
const title = count === 1 ? `新邮件 - ${currentAccount}` : `新邮件 (${count}封) - ${currentAccount}`;
|
||
const body = count === 1 ? subject : `${subject} 等 ${count} 封新邮件`;
|
||
new Notification(title, {
|
||
body: body,
|
||
icon: '/static/favicon.ico'
|
||
});
|
||
} else if ('Notification' in window && Notification.permission !== 'denied') {
|
||
Notification.requestPermission();
|
||
}
|
||
|
||
// 显示页面内通知(包含邮箱和主题)
|
||
const message = count === 1
|
||
? `📬 ${currentAccount}: ${subject}`
|
||
: `📬 ${currentAccount}: ${subject} 等 ${count} 封新邮件`;
|
||
showToast(message, 'success');
|
||
}
|
||
|
||
// 显示账号列表项上的红点
|
||
function showAccountNewEmailDot(count) {
|
||
// 在当前选中的账号项上显示红点
|
||
const activeItem = document.querySelector('.account-card.active');
|
||
if (activeItem) {
|
||
let dot = activeItem.querySelector('.new-email-badge');
|
||
if (!dot) {
|
||
dot = document.createElement('span');
|
||
dot.className = 'new-email-badge';
|
||
dot.style.cssText = `
|
||
position: absolute;
|
||
top: 8px;
|
||
right: 8px;
|
||
background-color: #dc3545;
|
||
color: white;
|
||
padding: 2px 6px;
|
||
border-radius: 10px;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
min-width: 18px;
|
||
text-align: center;
|
||
`;
|
||
activeItem.style.position = 'relative';
|
||
activeItem.appendChild(dot);
|
||
}
|
||
dot.textContent = count;
|
||
dot.style.display = 'block';
|
||
}
|
||
|
||
// 同时保留右上角指示器(作为备用)
|
||
showNewEmailIndicator(count);
|
||
}
|
||
|
||
// 隐藏账号列表项上的红点
|
||
function hideAccountNewEmailDot() {
|
||
const dots = document.querySelectorAll('.new-email-badge');
|
||
dots.forEach(dot => dot.style.display = 'none');
|
||
}
|
||
|
||
// 显示新邮件指示器(红点)- 移到右下角
|
||
function showNewEmailIndicator(count) {
|
||
let indicator = document.getElementById('newEmailIndicator');
|
||
if (!indicator) {
|
||
indicator = document.createElement('div');
|
||
indicator.id = 'newEmailIndicator';
|
||
indicator.style.cssText = `
|
||
position: fixed;
|
||
bottom: 70px;
|
||
right: 24px;
|
||
background-color: #dc3545;
|
||
color: white;
|
||
padding: 8px 16px;
|
||
border-radius: 20px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
box-shadow: 0 2px 8px rgba(220, 53, 69, 0.3);
|
||
z-index: 1000;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
animation: slideIn 0.3s ease;
|
||
`;
|
||
indicator.innerHTML = `<span class="new-email-dot"></span> 新邮件 (${count})`;
|
||
indicator.onclick = () => {
|
||
hideNewEmailIndicator();
|
||
hideAccountNewEmailDot();
|
||
// 滚动到邮件列表顶部
|
||
document.getElementById('emailList').scrollTop = 0;
|
||
};
|
||
document.body.appendChild(indicator);
|
||
|
||
// 添加动画样式
|
||
if (!document.getElementById('newEmailIndicatorStyles')) {
|
||
const style = document.createElement('style');
|
||
style.id = 'newEmailIndicatorStyles';
|
||
style.textContent = `
|
||
@keyframes slideIn {
|
||
from { transform: translateX(100%); opacity: 0; }
|
||
to { transform: translateX(0); opacity: 1; }
|
||
}
|
||
.new-email-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
background-color: white;
|
||
border-radius: 50%;
|
||
animation: pulse 1.5s infinite;
|
||
}
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.5; }
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
}
|
||
} else {
|
||
indicator.innerHTML = `<span class="new-email-dot"></span> 新邮件 (${count})`;
|
||
indicator.style.display = 'flex';
|
||
}
|
||
}
|
||
|
||
// 隐藏新邮件指示器
|
||
function hideNewEmailIndicator() {
|
||
const indicator = document.getElementById('newEmailIndicator');
|
||
if (indicator) {
|
||
indicator.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// 切换轮询状态
|
||
function togglePolling() {
|
||
if (isPolling) {
|
||
stopPolling();
|
||
} else {
|
||
startPolling();
|
||
}
|
||
}
|
||
|
||
// ==================== 工具函数 ====================
|
||
|
||
// 相对时间格式化
|
||
function formatRelativeTime(timestamp) {
|
||
if (!timestamp) return '从未刷新';
|
||
|
||
const now = new Date();
|
||
// 如果时间戳不包含时区信息,假定为 UTC 时间并添加 Z
|
||
let dateStr = timestamp;
|
||
if (typeof dateStr === 'string' && !dateStr.includes('Z') && !dateStr.includes('+') && !dateStr.includes('-', 10)) {
|
||
dateStr = dateStr + 'Z';
|
||
}
|
||
const past = new Date(dateStr);
|
||
const diffMs = now - past;
|
||
const diffMins = Math.floor(diffMs / 60000);
|
||
const diffHours = Math.floor(diffMs / 3600000);
|
||
const diffDays = Math.floor(diffMs / 86400000);
|
||
|
||
if (diffMins < 1) return '刚刚';
|
||
if (diffMins < 60) return `${diffMins} 分钟前`;
|
||
if (diffHours < 24) return `${diffHours} 小时前`;
|
||
if (diffDays < 30) return `${diffDays} 天前`;
|
||
return `${Math.floor(diffDays / 30)} 月前`;
|
||
}
|
||
|
||
// ==================== Token 刷新管理 ====================
|
||
|
||
// 显示刷新模态框
|
||
async function showRefreshModal() {
|
||
document.getElementById('refreshModal').classList.add('show');
|
||
// 加载统计数据
|
||
await loadRefreshStats();
|
||
// 自动加载失败列表(如果有失败记录)
|
||
await autoLoadFailedListIfNeeded();
|
||
}
|
||
|
||
// 自动加载失败列表(如果有失败记录)
|
||
async function autoLoadFailedListIfNeeded() {
|
||
try {
|
||
const response = await fetch('/api/accounts/refresh-logs/failed');
|
||
const data = await response.json();
|
||
|
||
if (data.success && data.logs && data.logs.length > 0) {
|
||
// 有失败记录,自动显示失败列表
|
||
showFailedListFromData(data.logs.map(log => ({
|
||
id: log.account_id,
|
||
email: log.account_email,
|
||
error: log.error_message
|
||
})));
|
||
}
|
||
} catch (error) {
|
||
console.error('自动加载失败列表失败:', error);
|
||
}
|
||
}
|
||
|
||
// 隐藏刷新模态框
|
||
function hideRefreshModal() {
|
||
const modal = document.getElementById('refreshModal');
|
||
modal.classList.remove('show');
|
||
|
||
// 确保所有内容都被隐藏,防止残留
|
||
const progress = document.getElementById('refreshProgress');
|
||
if (progress) {
|
||
progress.style.display = 'none';
|
||
}
|
||
const failedList = document.getElementById('failedListContainer');
|
||
if (failedList) {
|
||
failedList.style.display = 'none';
|
||
}
|
||
const logsContainer = document.getElementById('refreshLogsContainer');
|
||
if (logsContainer) {
|
||
logsContainer.style.display = 'none';
|
||
}
|
||
|
||
// 重置按钮状态
|
||
const refreshAllBtn = document.getElementById('refreshAllBtn');
|
||
if (refreshAllBtn) {
|
||
refreshAllBtn.disabled = false;
|
||
refreshAllBtn.textContent = '🔄 全量刷新';
|
||
}
|
||
|
||
const retryFailedBtn = document.getElementById('retryFailedBtn');
|
||
if (retryFailedBtn) {
|
||
retryFailedBtn.disabled = false;
|
||
retryFailedBtn.textContent = '🔁 重试失败';
|
||
}
|
||
}
|
||
|
||
// 加载刷新统计
|
||
async function loadRefreshStats() {
|
||
try {
|
||
const response = await fetch('/api/accounts/refresh-stats');
|
||
const data = await response.json();
|
||
|
||
console.log('刷新统计数据:', data);
|
||
|
||
if (data.success) {
|
||
const stats = data.stats;
|
||
|
||
// 优先使用保存的本地刷新时间
|
||
if (lastRefreshTime && lastRefreshTime instanceof Date) {
|
||
document.getElementById('lastRefreshTime').textContent = formatDateTime(lastRefreshTime.toISOString());
|
||
} else if (stats.last_refresh_time) {
|
||
document.getElementById('lastRefreshTime').textContent = formatDateTime(stats.last_refresh_time);
|
||
} else {
|
||
document.getElementById('lastRefreshTime').textContent = '-';
|
||
}
|
||
|
||
document.getElementById('totalRefreshCount').textContent = stats.total;
|
||
document.getElementById('successRefreshCount').textContent = stats.success_count;
|
||
document.getElementById('failedRefreshCount').textContent = stats.failed_count;
|
||
|
||
console.log('统计数据已更新到页面');
|
||
}
|
||
} catch (error) {
|
||
console.error('加载刷新统计失败:', error);
|
||
}
|
||
}
|
||
|
||
// 全量刷新所有账号
|
||
async function refreshAllAccounts() {
|
||
const btn = document.getElementById('refreshAllBtn');
|
||
const progress = document.getElementById('refreshProgress');
|
||
const progressText = document.getElementById('refreshProgressText');
|
||
|
||
if (btn.disabled) return;
|
||
|
||
if (!confirm('确定要刷新所有账号的 Token 吗?')) {
|
||
return;
|
||
}
|
||
|
||
btn.disabled = true;
|
||
btn.textContent = '刷新中...';
|
||
progress.style.display = 'block';
|
||
progressText.innerHTML = '正在初始化...';
|
||
|
||
try {
|
||
const eventSource = new EventSource('/api/accounts/trigger-scheduled-refresh?force=true');
|
||
let totalCount = 0;
|
||
let successCount = 0;
|
||
let failedCount = 0;
|
||
|
||
eventSource.onmessage = function (event) {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
|
||
if (data.type === 'start') {
|
||
totalCount = data.total;
|
||
const delayInfo = data.delay_seconds > 0 ? `(间隔 ${data.delay_seconds} 秒)` : '';
|
||
progressText.innerHTML = `总共 <strong>${totalCount}</strong> 个账号${delayInfo},准备开始刷新...`;
|
||
// 初始化统计
|
||
document.getElementById('totalRefreshCount').textContent = totalCount;
|
||
document.getElementById('successRefreshCount').textContent = '0';
|
||
document.getElementById('failedRefreshCount').textContent = '0';
|
||
} else if (data.type === 'progress') {
|
||
successCount = data.success_count;
|
||
failedCount = data.failed_count;
|
||
// 实时更新统计
|
||
document.getElementById('successRefreshCount').textContent = successCount;
|
||
document.getElementById('failedRefreshCount').textContent = failedCount;
|
||
progressText.innerHTML = `
|
||
正在处理: <strong>${data.email}</strong><br>
|
||
进度: <strong>${data.current}/${data.total}</strong> |
|
||
成功: <strong style="color: #28a745;">${successCount}</strong> |
|
||
失败: <strong style="color: #dc3545;">${failedCount}</strong>
|
||
`;
|
||
} else if (data.type === 'delay') {
|
||
progressText.innerHTML += `<br><span style="color: #999;">等待 ${data.seconds} 秒后继续...</span>`;
|
||
} else if (data.type === 'complete') {
|
||
eventSource.close();
|
||
progress.style.display = 'none';
|
||
btn.disabled = false;
|
||
btn.textContent = '🔄 全量刷新';
|
||
|
||
// 直接更新统计数据,使用本地时间
|
||
const now = new Date();
|
||
lastRefreshTime = now; // 保存刷新时间
|
||
document.getElementById('lastRefreshTime').textContent = '刚刚';
|
||
document.getElementById('totalRefreshCount').textContent = data.total;
|
||
document.getElementById('successRefreshCount').textContent = data.success_count;
|
||
document.getElementById('failedRefreshCount').textContent = data.failed_count;
|
||
|
||
showToast(`刷新完成!成功: ${data.success_count}, 失败: ${data.failed_count}`,
|
||
data.failed_count > 0 ? 'warning' : 'success');
|
||
|
||
// 如果有失败的,显示失败列表
|
||
if (data.failed_count > 0) {
|
||
showFailedListFromData(data.failed_list);
|
||
}
|
||
|
||
// 刷新账号列表以更新刷新时间
|
||
if (currentGroupId) {
|
||
loadAccountsByGroup(currentGroupId, true);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('解析进度数据失败:', e);
|
||
}
|
||
};
|
||
|
||
eventSource.onerror = function (error) {
|
||
console.error('EventSource 错误:', error);
|
||
eventSource.close();
|
||
progress.style.display = 'none';
|
||
btn.disabled = false;
|
||
btn.textContent = '🔄 全量刷新';
|
||
showToast('刷新过程中出现错误', 'error');
|
||
};
|
||
|
||
} catch (error) {
|
||
progress.style.display = 'none';
|
||
btn.disabled = false;
|
||
btn.textContent = '🔄 全量刷新';
|
||
showToast('刷新请求失败', 'error');
|
||
}
|
||
}
|
||
|
||
// 重试失败的账号
|
||
async function retryFailedAccounts() {
|
||
const btn = document.getElementById('retryFailedBtn');
|
||
const progress = document.getElementById('refreshProgress');
|
||
const progressText = document.getElementById('refreshProgressText');
|
||
|
||
if (btn.disabled) return;
|
||
|
||
btn.disabled = true;
|
||
btn.textContent = '重试中...';
|
||
progress.style.display = 'block';
|
||
progressText.textContent = '正在重试失败的账号...';
|
||
|
||
try {
|
||
const response = await fetch('/api/accounts/refresh-failed', {
|
||
method: 'POST'
|
||
});
|
||
const data = await response.json();
|
||
|
||
progress.style.display = 'none';
|
||
btn.disabled = false;
|
||
btn.textContent = '🔁 重试失败';
|
||
|
||
if (data.success) {
|
||
if (data.total === 0) {
|
||
showToast('没有需要重试的失败账号', 'info');
|
||
} else {
|
||
showToast(`重试完成!成功: ${data.success_count}, 失败: ${data.failed_count}`,
|
||
data.failed_count > 0 ? 'warning' : 'success');
|
||
|
||
// 刷新统计
|
||
loadRefreshStats();
|
||
|
||
// 如果还有失败的,显示失败列表
|
||
if (data.failed_count > 0) {
|
||
showFailedListFromData(data.failed_list);
|
||
} else {
|
||
hideFailedList();
|
||
}
|
||
}
|
||
} else {
|
||
handleApiError(data, '重试失败');
|
||
}
|
||
} catch (error) {
|
||
progress.style.display = 'none';
|
||
btn.disabled = false;
|
||
btn.textContent = '🔁 重试失败';
|
||
showToast('重试请求失败', 'error');
|
||
}
|
||
}
|
||
|
||
// 单个账号重试
|
||
async function retrySingleAccount(accountId, accountEmail) {
|
||
try {
|
||
const response = await fetch(`/api/accounts/${accountId}/retry-refresh`, {
|
||
method: 'POST'
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
showToast(`${accountEmail} 刷新成功`, 'success');
|
||
loadRefreshStats();
|
||
|
||
// 刷新失败列表
|
||
loadFailedLogs();
|
||
} else {
|
||
handleApiError(data, `${accountEmail} 刷新失败`);
|
||
}
|
||
} catch (error) {
|
||
handleApiError({ success: false, error: { message: '刷新请求失败', details: error.message, code: 'NETWORK_ERROR', type: 'Frontend' } });
|
||
}
|
||
}
|
||
|
||
// 显示失败列表(从数据)
|
||
function showFailedListFromData(failedList) {
|
||
const container = document.getElementById('failedListContainer');
|
||
const listEl = document.getElementById('failedList');
|
||
|
||
// 隐藏其他列表
|
||
hideRefreshLogs();
|
||
|
||
if (!failedList || failedList.length === 0) {
|
||
container.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
failedList.forEach(item => {
|
||
html += `
|
||
<div style="padding: 12px; border-bottom: 1px solid #e5e5e5; display: flex; justify-content: space-between; align-items: start;">
|
||
<div style="flex: 1;">
|
||
<div style="font-weight: 600; margin-bottom: 4px;">${escapeHtml(item.email)}</div>
|
||
<div style="font-size: 12px; color: #dc3545;">${escapeHtml(item.error || '未知错误')}</div>
|
||
</div>
|
||
<button class="btn btn-sm btn-primary" onclick="retrySingleAccount(${item.id}, '${escapeHtml(item.email)}')">
|
||
重试
|
||
</button>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
listEl.innerHTML = html;
|
||
container.style.display = 'block';
|
||
}
|
||
|
||
// 隐藏失败列表
|
||
function hideFailedList() {
|
||
document.getElementById('failedListContainer').style.display = 'none';
|
||
}
|
||
|
||
// 加载失败日志
|
||
async function loadFailedLogs() {
|
||
const container = document.getElementById('failedListContainer');
|
||
const listEl = document.getElementById('failedList');
|
||
|
||
hideRefreshLogs();
|
||
|
||
try {
|
||
const response = await fetch('/api/accounts/refresh-logs/failed');
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
if (data.logs.length === 0) {
|
||
listEl.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">暂无失败状态的邮箱</div>';
|
||
} else {
|
||
let html = '';
|
||
data.logs.forEach(log => {
|
||
html += `
|
||
<div style="padding: 12px; border-bottom: 1px solid #e5e5e5; display: flex; justify-content: space-between; align-items: center;">
|
||
<div style="flex: 1;">
|
||
<div style="font-weight: 600; margin-bottom: 4px;">${escapeHtml(log.account_email)}</div>
|
||
<div style="font-size: 12px; color: #dc3545;">${escapeHtml(log.error_message || '未知错误')}</div>
|
||
<div style="font-size: 11px; color: #999; margin-top: 4px;">最后刷新: ${formatDateTime(log.created_at)}</div>
|
||
</div>
|
||
<button class="btn btn-sm btn-primary" onclick="retrySingleAccount(${log.account_id}, '${escapeJs(log.account_email)}')">
|
||
重试
|
||
</button>
|
||
</div>
|
||
`;
|
||
});
|
||
listEl.innerHTML = html;
|
||
}
|
||
container.style.display = 'block';
|
||
}
|
||
} catch (error) {
|
||
showToast('加载失败邮箱列表失败', 'error');
|
||
}
|
||
}
|
||
|
||
// 加载刷新历史
|
||
async function loadRefreshLogs() {
|
||
const container = document.getElementById('refreshLogsContainer');
|
||
const listEl = document.getElementById('refreshLogsList');
|
||
|
||
try {
|
||
const response = await fetch('/api/accounts/refresh-logs?limit=1000');
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
if (data.logs.length === 0) {
|
||
listEl.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">暂无全量刷新历史</div>';
|
||
} else {
|
||
listEl.innerHTML = `<div style="padding: 12px; background-color: #f8f9fa; border-bottom: 1px solid #e5e5e5; font-size: 13px; color: #666;">近半年刷新历史(共 ${data.logs.length} 条)</div>`;
|
||
let html = '';
|
||
data.logs.forEach(log => {
|
||
const statusColor = log.status === 'success' ? '#28a745' : '#dc3545';
|
||
const statusText = log.status === 'success' ? '成功' : '失败';
|
||
const typeText = log.refresh_type === 'manual' ? '手动' : '自动';
|
||
const typeColor = log.refresh_type === 'manual' ? '#007bff' : '#28a745';
|
||
const typeBgColor = log.refresh_type === 'manual' ? '#e7f3ff' : '#e8f5e9';
|
||
|
||
html += `
|
||
<div style="padding: 14px; border-bottom: 1px solid #e5e5e5; transition: background-color 0.2s;"
|
||
onmouseover="this.style.backgroundColor='#f8f9fa'"
|
||
onmouseout="this.style.backgroundColor='transparent'">
|
||
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 6px;">
|
||
<div style="font-weight: 600; font-size: 14px;">${escapeHtml(log.account_email)}</div>
|
||
<div style="display: flex; gap: 8px; align-items: center;">
|
||
<span style="font-size: 11px; padding: 3px 8px; background-color: ${typeBgColor}; color: ${typeColor}; border-radius: 4px; font-weight: 500;">${typeText}</span>
|
||
<span style="font-size: 13px; color: ${statusColor}; font-weight: 600;">${statusText}</span>
|
||
</div>
|
||
</div>
|
||
<div style="font-size: 12px; color: #888;">${formatDateTime(log.created_at)}</div>
|
||
${log.error_message ? `<div style="font-size: 12px; color: #dc3545; margin-top: 6px; padding: 6px; background-color: #fff5f5; border-radius: 4px;">${escapeHtml(log.error_message)}</div>` : ''}
|
||
</div>
|
||
`;
|
||
});
|
||
listEl.innerHTML += html;
|
||
}
|
||
container.style.display = 'block';
|
||
}
|
||
} catch (error) {
|
||
showToast('加载刷新历史失败', 'error');
|
||
}
|
||
}
|
||
|
||
// 隐藏刷新历史
|
||
function hideRefreshLogs() {
|
||
document.getElementById('refreshLogsContainer').style.display = 'none';
|
||
}
|
||
|
||
// ==================== 页面级:刷新日志 ====================
|
||
|
||
async function loadRefreshLogPage() {
|
||
const container = document.getElementById('refreshLogContainer');
|
||
if (!container) return;
|
||
container.innerHTML = '<div class="loading-overlay"><span class="spinner"></span> 加载中…</div>';
|
||
|
||
try {
|
||
const response = await fetch('/api/accounts/refresh-logs?limit=200');
|
||
const data = await response.json();
|
||
|
||
if (data.success && data.logs && data.logs.length > 0) {
|
||
container.innerHTML = `
|
||
<div style="padding:0.6rem 1rem;font-size:0.78rem;color:var(--text-muted);border-bottom:1px solid var(--border-light);">
|
||
共 ${data.logs.length} 条记录
|
||
</div>
|
||
<div class="dashboard-list-wrap">
|
||
${data.logs.map(log => {
|
||
const isSuccess = log.status === 'success';
|
||
const statusBadge = isSuccess
|
||
? '<span class="badge" style="background:var(--clr-jade);color:white;">成功</span>'
|
||
: '<span class="badge" style="background:var(--clr-danger);color:white;">失败</span>';
|
||
const typeText = log.refresh_type === 'manual' ? '手动' : (log.refresh_type === 'scheduled' ? '定时' : log.refresh_type || '-');
|
||
return `
|
||
<div style="padding:0.75rem 1rem;border-bottom:1px solid var(--border-light);display:flex;align-items:center;gap:0.8rem;">
|
||
<div style="flex:1;min-width:0;">
|
||
<div style="font-weight:600;font-size:0.85rem;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(log.account_email || '-')}</div>
|
||
<div style="font-size:0.72rem;color:var(--text-muted);margin-top:2px;">${formatDateTime(log.created_at)} · ${escapeHtml(typeText)}</div>
|
||
${log.error_message ? `<div style="font-size:0.72rem;color:var(--clr-danger);margin-top:4px;padding:4px 8px;background:rgba(185,28,28,0.06);border-radius:4px;">${escapeHtml(log.error_message)}</div>` : ''}
|
||
</div>
|
||
${statusBadge}
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
`;
|
||
} else {
|
||
container.innerHTML = '<div class="empty-state"><span class="empty-icon">📭</span><p>暂无刷新记录</p></div>';
|
||
}
|
||
} catch (error) {
|
||
container.innerHTML = '<div class="empty-state"><span class="empty-icon">⚠️</span><p>加载刷新日志失败</p></div>';
|
||
}
|
||
}
|
||
|
||
// ==================== 页面级:审计日志 ====================
|
||
|
||
async function loadAuditLogPage() {
|
||
const container = document.getElementById('auditLogContainer');
|
||
if (!container) return;
|
||
container.innerHTML = '<div class="loading-overlay"><span class="spinner"></span> 加载中…</div>';
|
||
|
||
try {
|
||
const response = await fetch('/api/audit-logs?limit=200');
|
||
const data = await response.json();
|
||
|
||
if (data.success && data.logs && data.logs.length > 0) {
|
||
container.innerHTML = `
|
||
<div style="padding:0.6rem 1rem;font-size:0.78rem;color:var(--text-muted);border-bottom:1px solid var(--border-light);">
|
||
共 ${data.total || data.logs.length} 条记录
|
||
</div>
|
||
<div class="dashboard-list-wrap">
|
||
${data.logs.map(log => {
|
||
const actionColor = log.action === 'delete' ? 'var(--clr-danger)' : (log.action === 'create' ? 'var(--clr-jade)' : 'var(--clr-primary)');
|
||
return `
|
||
<div style="padding:0.75rem 1rem;border-bottom:1px solid var(--border-light);">
|
||
<div style="display:flex;align-items:center;gap:0.6rem;margin-bottom:4px;">
|
||
<span class="badge" style="background:${actionColor};color:white;font-size:0.68rem;">${escapeHtml(log.action || '-')}</span>
|
||
<span style="font-size:0.78rem;color:var(--text-muted);">${escapeHtml(log.resource_type || '-')}</span>
|
||
<span style="font-size:0.72rem;color:var(--text-muted);margin-left:auto;">${formatDateTime(log.created_at)}</span>
|
||
</div>
|
||
<div style="font-size:0.82rem;color:var(--text);">${escapeHtml(log.resource_id || '-')}</div>
|
||
${log.details ? `<div style="font-size:0.72rem;color:var(--text-muted);margin-top:4px;word-break:break-all;">${escapeHtml(log.details).substring(0, 200)}</div>` : ''}
|
||
<div style="font-size:0.68rem;color:var(--text-muted);margin-top:2px;">IP: ${escapeHtml(log.user_ip || '-')} ${log.trace_id ? '· trace: ' + escapeHtml(log.trace_id) : ''}</div>
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
`;
|
||
} else {
|
||
container.innerHTML = '<div class="empty-state"><span class="empty-icon">📭</span><p>暂无审计记录</p></div>';
|
||
}
|
||
} catch (error) {
|
||
container.innerHTML = '<div class="empty-state"><span class="empty-icon">⚠️</span><p>加载审计日志失败</p></div>';
|
||
}
|
||
}
|
||
|
||
// 格式化日期时间
|
||
function formatDateTime(dateStr) {
|
||
if (!dateStr) return '-';
|
||
|
||
let date;
|
||
if (dateStr instanceof Date) {
|
||
date = dateStr;
|
||
} else {
|
||
// 如果字符串不包含时区信息,假定为 UTC 时间
|
||
if (!dateStr.includes('Z') && !dateStr.includes('+') && !dateStr.includes('-', 10)) {
|
||
dateStr = dateStr + 'Z';
|
||
}
|
||
date = new Date(dateStr);
|
||
}
|
||
|
||
const now = new Date();
|
||
const diff = now - date;
|
||
const minutes = Math.floor(diff / 60000);
|
||
const hours = Math.floor(diff / 3600000);
|
||
const days = Math.floor(diff / 86400000);
|
||
|
||
if (minutes < 1) return '刚刚';
|
||
if (minutes < 60) return `${minutes}分钟前`;
|
||
if (hours < 24) return `${hours}小时前`;
|
||
if (days < 7) return `${days}天前`;
|
||
|
||
return date.toLocaleString('zh-CN', {
|
||
timeZone: 'Asia/Shanghai',
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
}
|
||
|
||
// 统一关闭所有模态框的函数 (修复 bug:防止模态框意外残留)
|
||
function closeAllModals() {
|
||
hideAddGroupModal();
|
||
hideAddAccountModal();
|
||
hideEditAccountModal();
|
||
hideExportModal();
|
||
hideSettingsModal();
|
||
hideRefreshModal();
|
||
hideRefreshErrorModal();
|
||
hideErrorDetailModal();
|
||
hideGetRefreshTokenModal();
|
||
closeFullscreenEmail();
|
||
}
|
||
|
||
// HTML 转义
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// 键盘快捷键
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Escape') {
|
||
hideAddGroupModal();
|
||
hideAddAccountModal();
|
||
hideEditAccountModal();
|
||
hideExportModal();
|
||
hideSettingsModal();
|
||
hideRefreshModal();
|
||
hideRefreshErrorModal();
|
||
hideErrorDetailModal();
|
||
hideGetRefreshTokenModal();
|
||
closeFullscreenEmail();
|
||
}
|
||
});
|
||
// ==================== 标签管理 ====================
|
||
|
||
let allTags = [];
|
||
|
||
// 显示标签管理模态框
|
||
async function showTagManagementModal() {
|
||
document.getElementById('tagManagementModal').classList.add('show');
|
||
await loadTags();
|
||
}
|
||
|
||
// 隐藏标签管理模态框
|
||
function hideTagManagementModal() {
|
||
document.getElementById('tagManagementModal').classList.remove('show');
|
||
}
|
||
|
||
// 加载标签列表
|
||
async function loadTags() {
|
||
try {
|
||
const response = await fetch('/api/tags');
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
allTags = data.tags;
|
||
renderTagList();
|
||
updateTagFilter(); // Update Filter Dropdown
|
||
}
|
||
} catch (error) {
|
||
showToast('加载标签失败', 'error');
|
||
}
|
||
}
|
||
|
||
// 更新标签筛选下拉框
|
||
function updateTagFilter() {
|
||
const container = document.getElementById('tagFilterContainer');
|
||
if (!container) return;
|
||
|
||
if (allTags.length === 0) {
|
||
container.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
container.style.display = 'flex';
|
||
|
||
let html = '';
|
||
allTags.forEach(tag => {
|
||
html += `
|
||
<label style="display: inline-flex; align-items: center; gap: 4px; font-size: 11px; cursor: pointer; padding: 2px 6px; border: 1px solid #e5e5e5; border-radius: 12px; background: white; user-select: none;">
|
||
<input type="checkbox" class="tag-filter-checkbox" value="${tag.id}" onchange="handleTagFilterChange()" style="margin: 0;">
|
||
<span style="width: 8px; height: 8px; border-radius: 50%; background-color: ${tag.color}; display: inline-block;"></span>
|
||
${escapeHtml(tag.name)}
|
||
</label>
|
||
`;
|
||
});
|
||
container.innerHTML = html;
|
||
/* Old dropdown code removed */
|
||
|
||
|
||
}
|
||
|
||
// 渲染标签列表
|
||
function renderTagList() {
|
||
const listEl = document.getElementById('tagList');
|
||
if (!allTags.length) {
|
||
listEl.innerHTML = '<div style="text-align: center; color: #999; padding: 20px;">暂无标签</div>';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
allTags.forEach(tag => {
|
||
html += `
|
||
<div style="display: flex; align-items: center; justify-content: space-between; padding: 8px; border-bottom: 1px solid #f0f0f0;">
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<span class="tag-badge" style="background-color: ${tag.color};">${escapeHtml(tag.name)}</span>
|
||
</div>
|
||
<button class="btn btn-sm btn-danger" onclick="deleteTag(${tag.id})">删除</button>
|
||
</div>
|
||
`;
|
||
});
|
||
listEl.innerHTML = html;
|
||
}
|
||
|
||
// 创建标签
|
||
async function createTag() {
|
||
const nameInput = document.getElementById('newTagName');
|
||
const colorInput = document.getElementById('newTagColor');
|
||
const name = nameInput.value.trim();
|
||
const color = colorInput.value;
|
||
|
||
if (!name) {
|
||
showToast('请输入标签名称', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/tags', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, color })
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
nameInput.value = '';
|
||
showToast('标签创建成功', 'success');
|
||
await loadTags();
|
||
// 刷新账号列表以重新加载标签(如果是在查看列表时添加标签,可能不需要立即刷新列表,但为了保持一致性可以刷新)
|
||
// 但通常添加标签不影响当前列表显示,除非是给账号打标
|
||
} else {
|
||
showToast(data.error || '创建失败', 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast('创建标签失败', 'error');
|
||
}
|
||
}
|
||
|
||
// 删除标签
|
||
async function deleteTag(id) {
|
||
if (!confirm('确定要删除这个标签吗?')) return;
|
||
|
||
try {
|
||
const response = await fetch(`/api/tags/${id}`, { method: 'DELETE' });
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
showToast('标签已删除', 'success');
|
||
await loadTags();
|
||
// 刷新账号列表以更新标签显示
|
||
if (currentGroupId) {
|
||
loadAccountsByGroup(currentGroupId, true);
|
||
}
|
||
} else {
|
||
showToast(data.error || '删除失败', 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast('删除标签失败', 'error');
|
||
}
|
||
}
|
||
|
||
// ==================== 批量操作 ====================
|
||
|
||
// 全局选中的账号 ID 集合(跨分组保持)
|
||
let selectedAccountIds = new Set();
|
||
|
||
// 更新批量操作栏状态
|
||
function updateBatchActionBar() {
|
||
// 同步 DOM 复选框状态到全局 Set
|
||
const allCheckboxes = document.querySelectorAll('.account-select-checkbox');
|
||
allCheckboxes.forEach(cb => {
|
||
const id = parseInt(cb.value);
|
||
if (cb.checked) {
|
||
selectedAccountIds.add(id);
|
||
} else {
|
||
selectedAccountIds.delete(id);
|
||
}
|
||
});
|
||
|
||
const bar = document.getElementById('batchActionBar');
|
||
const countSpan = document.getElementById('selectedCount');
|
||
|
||
if (selectedAccountIds.size > 0) {
|
||
bar.style.display = 'flex';
|
||
countSpan.textContent = `已选 ${selectedAccountIds.size} 项`;
|
||
} else {
|
||
bar.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// 显示批量删除确认
|
||
function showBatchDeleteConfirm() {
|
||
if (selectedAccountIds.size === 0) {
|
||
showToast('请选择要删除的账号', 'error');
|
||
return;
|
||
}
|
||
|
||
if (!confirm(`确定要删除选中的 ${selectedAccountIds.size} 个账号吗?此操作不可恢复!`)) {
|
||
return;
|
||
}
|
||
|
||
batchDeleteAccounts();
|
||
}
|
||
|
||
// 批量删除账号
|
||
async function batchDeleteAccounts() {
|
||
const accountIds = Array.from(selectedAccountIds);
|
||
|
||
// 确保使用最新的 CSRF token
|
||
await initCSRFToken();
|
||
|
||
try {
|
||
const response = await fetch('/api/accounts/batch-delete', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ account_ids: accountIds })
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
showToast(data.message, 'success');
|
||
// 清空选中状态
|
||
selectedAccountIds.clear();
|
||
// 刷新分组和邮箱列表
|
||
loadGroups();
|
||
if (currentGroupId) {
|
||
delete accountsCache[currentGroupId];
|
||
loadAccountsByGroup(currentGroupId, true);
|
||
}
|
||
// 更新批量操作栏
|
||
updateBatchActionBar();
|
||
} else {
|
||
showToast(data.error || '删除失败', 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast('删除失败', 'error');
|
||
}
|
||
}
|
||
|
||
let batchActionType = ''; // 'add' or 'remove'
|
||
|
||
// 显示批量打标模态框
|
||
async function showBatchTagModal(type) {
|
||
batchActionType = type;
|
||
document.getElementById('batchTagTitle').textContent = type === 'add' ? '批量添加标签' : '批量移除标签';
|
||
document.getElementById('batchTagModal').classList.add('show');
|
||
|
||
// 加载标签选项
|
||
await loadTagsForSelect();
|
||
}
|
||
|
||
function hideBatchTagModal() {
|
||
document.getElementById('batchTagModal').classList.remove('show');
|
||
}
|
||
|
||
// 加载标签到下拉框
|
||
async function loadTagsForSelect() {
|
||
const select = document.getElementById('batchTagSelect');
|
||
select.innerHTML = '<option value="">加载中...</option>';
|
||
|
||
try {
|
||
const response = await fetch('/api/tags');
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
let html = '<option value="">请选择标签...</option>';
|
||
data.tags.forEach(tag => {
|
||
html += `<option value="${tag.id}">${escapeHtml(tag.name)}</option>`;
|
||
});
|
||
select.innerHTML = html;
|
||
}
|
||
} catch (error) {
|
||
select.innerHTML = '<option value="">加载失败</option>';
|
||
}
|
||
}
|
||
|
||
// 确认批量打标
|
||
async function confirmBatchTag() {
|
||
const tagId = document.getElementById('batchTagSelect').value;
|
||
if (!tagId) {
|
||
showToast('请选择标签', 'error');
|
||
return;
|
||
}
|
||
|
||
const accountIds = Array.from(selectedAccountIds);
|
||
|
||
if (accountIds.length === 0) return;
|
||
|
||
try {
|
||
const response = await fetch('/api/accounts/tags', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
account_ids: accountIds,
|
||
tag_id: parseInt(tagId),
|
||
action: batchActionType
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
showToast(data.message, 'success');
|
||
hideBatchTagModal();
|
||
// 清空选中状态
|
||
selectedAccountIds.clear();
|
||
// 刷新列表
|
||
loadGroups();
|
||
if (currentGroupId) {
|
||
delete accountsCache[currentGroupId];
|
||
loadAccountsByGroup(currentGroupId, true);
|
||
}
|
||
updateBatchActionBar();
|
||
} else {
|
||
showToast(data.error || '操作失败', 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast('请求失败', 'error');
|
||
}
|
||
}
|
||
|
||
// ==================== 批量移动分组 ====================
|
||
|
||
// 显示批量移动分组模态框
|
||
async function showBatchMoveGroupModal() {
|
||
document.getElementById('batchMoveGroupModal').classList.add('show');
|
||
await loadGroupsForBatchMove();
|
||
}
|
||
|
||
function hideBatchMoveGroupModal() {
|
||
document.getElementById('batchMoveGroupModal').classList.remove('show');
|
||
}
|
||
|
||
// 加载分组到下拉框
|
||
async function loadGroupsForBatchMove() {
|
||
const select = document.getElementById('batchMoveGroupSelect');
|
||
select.innerHTML = '<option value="">加载中...</option>';
|
||
|
||
try {
|
||
const response = await fetch('/api/groups');
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
let html = '<option value="">请选择分组...</option>';
|
||
data.groups.filter(g => !g.is_system).forEach(group => {
|
||
html += `<option value="${group.id}">${escapeHtml(group.name)}</option>`;
|
||
});
|
||
select.innerHTML = html;
|
||
}
|
||
} catch (error) {
|
||
select.innerHTML = '<option value="">加载失败</option>';
|
||
}
|
||
}
|
||
|
||
// 确认批量移动分组
|
||
async function confirmBatchMoveGroup() {
|
||
const groupId = document.getElementById('batchMoveGroupSelect').value;
|
||
if (!groupId) {
|
||
showToast('请选择目标分组', 'error');
|
||
return;
|
||
}
|
||
|
||
const accountIds = Array.from(selectedAccountIds);
|
||
|
||
if (accountIds.length === 0) return;
|
||
|
||
try {
|
||
const response = await fetch('/api/accounts/batch-update-group', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
account_ids: accountIds,
|
||
group_id: parseInt(groupId)
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
showToast(data.message, 'success');
|
||
hideBatchMoveGroupModal();
|
||
// 清空选中状态
|
||
selectedAccountIds.clear();
|
||
// 刷新分组列表
|
||
loadGroups();
|
||
// 刷新当前分组的邮箱列表
|
||
if (currentGroupId) {
|
||
delete accountsCache[currentGroupId];
|
||
loadAccountsByGroup(currentGroupId, true);
|
||
}
|
||
updateBatchActionBar();
|
||
} else {
|
||
showToast(data.error || '操作失败', 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast('请求失败', 'error');
|
||
}
|
||
}
|