Files
claude-outlonok/static/script.js
2026-03-06 02:01:37 +08:00

1352 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ====================================================================
// 登录验证
// ====================================================================
(function () {
const ACCESS_KEY = 'oadmin123';
const overlay = document.getElementById('loginOverlay');
if (!overlay) return;
// 已登录则直接隐藏
if (sessionStorage.getItem('authed') === '1') {
overlay.classList.add('hidden');
return;
}
const pwdInput = document.getElementById('loginPassword');
const loginBtn = document.getElementById('loginBtn');
const errEl = document.getElementById('loginError');
function doLogin() {
if (pwdInput.value === ACCESS_KEY) {
sessionStorage.setItem('authed', '1');
overlay.classList.add('hidden');
errEl.textContent = '';
} else {
errEl.textContent = '密码错误,请重试';
pwdInput.value = '';
pwdInput.focus();
}
}
loginBtn.addEventListener('click', doLogin);
pwdInput.addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
pwdInput.focus();
})();
// Outlook 邮件管理器 — 账号管理 + 双栏邮件查看器
class MailManager {
constructor() {
// 账号管理状态
this.accounts = [];
this.page = 1;
this.pageSize = 15;
this.total = 0;
this.query = '';
// 邮件查看状态
this.currentEmail = '';
this.currentFolder = '';
this.messages = [];
this.selectedId = null;
this.claudePaymentStatuses = {};
this.paymentFilter = '';
this.init();
}
init() {
this.bindAccountEvents();
this.bindEmailEvents();
this.loadAccounts();
}
// ====================================================================
// 事件绑定 — 账号视图
// ====================================================================
bindAccountEvents() {
// 搜索 — 防抖
const searchInput = document.getElementById('accountSearch');
let searchTimer = null;
searchInput.addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
this.query = searchInput.value.trim();
this.page = 1;
this.loadAccounts();
}, 300);
});
// 导入按钮
document.getElementById('importBtn').addEventListener('click', () => {
document.getElementById('importText').value = '';
document.getElementById('importModal').classList.add('show');
});
// 取消导入
document.getElementById('cancelImportBtn').addEventListener('click', () => {
document.getElementById('importModal').classList.remove('show');
});
// 点击模态框背景关闭
document.getElementById('importModal').addEventListener('click', (e) => {
if (e.target === e.currentTarget) {
e.currentTarget.classList.remove('show');
}
});
// 确认导入
document.getElementById('doImportBtn').addEventListener('click', () => this.importAccounts());
// 导出
document.getElementById('exportBtn').addEventListener('click', () => this.exportAccounts());
// Claude支付检测
document.getElementById('claudePaymentBtn').addEventListener('click', () => this.startClaudePaymentCheck());
// 支付状态筛选
document.getElementById('paymentFilter').addEventListener('change', (e) => {
this.paymentFilter = e.target.value;
this.page = 1;
this.loadAccounts();
});
// 刷新
document.getElementById('refreshAccountsBtn').addEventListener('click', () => this.loadAccounts());
// 复制按钮 — 事件委托
document.getElementById('accountTableBody').addEventListener('click', (e) => {
const btn = e.target.closest('button[data-copy-field]');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const field = btn.dataset.copyField;
const key = btn.dataset.copyKey;
if (field === 'email') {
this.copyToClipboard(key);
} else if (this._accountDataMap && this._accountDataMap[key]) {
const map = { pwd: 'pwd', cid: 'cid', token: 'token' };
this.copyToClipboard(this._accountDataMap[key][map[field]] || '');
}
});
// 凭证弹窗事件
document.getElementById('closeCredentialBtn').addEventListener('click', () => {
document.getElementById('credentialModal').classList.remove('show');
});
document.getElementById('credentialModal').addEventListener('click', (e) => {
if (e.target === e.currentTarget) e.currentTarget.classList.remove('show');
});
document.getElementById('copyCredClientIdBtn').addEventListener('click', () => {
this.copyToClipboard(document.getElementById('credClientId').textContent);
});
document.getElementById('copyCredTokenBtn').addEventListener('click', () => {
this.copyToClipboard(document.getElementById('credToken').textContent);
});
// 凭证查看按钮 — 事件委托
document.getElementById('accountTableBody').addEventListener('click', (e) => {
const btn = e.target.closest('.credential-btn');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
this.showCredentialModal(btn.dataset.credEmail);
});
// 退款到账按钮 — 事件委托
document.getElementById('accountTableBody').addEventListener('click', (e) => {
const btn = e.target.closest('.refund-received-btn');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
this.toggleRefundReceived(btn.dataset.email);
});
// 代理按钮 — 事件委托打开弹窗 + 复制代理
document.getElementById('accountTableBody').addEventListener('click', (e) => {
// 复制代理
const copyBtn = e.target.closest('[data-copy-proxy]');
if (copyBtn) {
e.preventDefault();
e.stopPropagation();
const em = copyBtn.dataset.copyProxy;
const info = this.claudePaymentStatuses[em];
if (info && info.proxy) this.copyToClipboard(info.proxy);
return;
}
// 打开弹窗
const btn = e.target.closest('.proxy-btn');
if (!btn) return;
this.openProxyModal(btn.dataset.email);
});
// 代理弹窗事件
document.getElementById('cancelProxyBtn').addEventListener('click', () => {
document.getElementById('proxyModal').classList.remove('show');
});
document.getElementById('proxyModal').addEventListener('click', (e) => {
if (e.target === e.currentTarget) e.currentTarget.classList.remove('show');
});
document.getElementById('saveProxyBtn').addEventListener('click', () => this.saveProxy());
document.getElementById('clearProxyBtn').addEventListener('click', () => this.clearProxy());
// 快速粘贴解析
document.getElementById('proxyRaw').addEventListener('input', (e) => this.parseProxyRaw(e.target.value));
// 字段变化时更新预览
for (const id of ['proxyHost', 'proxyPort', 'proxyUser', 'proxyPass']) {
document.getElementById(id).addEventListener('input', () => this.updateProxyPreview());
}
document.querySelectorAll('input[name="proxyProtocol"]').forEach(r => {
r.addEventListener('change', () => this.updateProxyPreview());
});
// 有效期选项切换
document.querySelectorAll('input[name="proxyExpire"]').forEach(r => {
r.addEventListener('change', () => {
const customInput = document.getElementById('proxyExpireCustom');
customInput.style.display = r.value === 'custom' && r.checked ? '' : 'none';
this.calcProxyExpireDate();
});
});
document.getElementById('proxyExpireCustom').addEventListener('input', () => this.calcProxyExpireDate());
document.getElementById('proxyPurchaseDate').addEventListener('change', () => this.calcProxyExpireDate());
// 备注/卡号 — 点击打开弹窗
document.getElementById('accountTableBody').addEventListener('click', (e) => {
const btn = e.target.closest('[data-note-email]');
if (!btn) return;
this.openNoteModal(btn.dataset.noteEmail);
});
// 备注弹窗
document.getElementById('cancelNoteBtn').addEventListener('click', () => {
document.getElementById('noteModal').classList.remove('show');
});
document.getElementById('noteModal').addEventListener('click', (e) => {
if (e.target === e.currentTarget) e.currentTarget.classList.remove('show');
});
document.getElementById('saveNoteBtn').addEventListener('click', () => this.saveNote());
// 分页
document.getElementById('prevPageBtn').addEventListener('click', () => {
if (this.page > 1) { this.page--; this.loadAccounts(); }
});
document.getElementById('nextPageBtn').addEventListener('click', () => {
const maxPage = Math.ceil(this.total / this.pageSize) || 1;
if (this.page < maxPage) { this.page++; this.loadAccounts(); }
});
}
// ====================================================================
// 事件绑定 — 邮件视图
// ====================================================================
bindEmailEvents() {
document.getElementById('backToAccounts').addEventListener('click', () => this.showAccountView());
document.getElementById('mobileMailToggle').addEventListener('click', () => this.toggleMobileMailList());
document.getElementById('mobileOverlay').addEventListener('click', () => this.closeMobileMailList());
document.getElementById('refreshEmailsBtn').addEventListener('click', () => this.refreshEmails());
}
// ====================================================================
// 视图切换
// ====================================================================
showAccountView() {
document.getElementById('accountView').style.display = '';
document.getElementById('emailView').style.display = 'none';
this.loadAccounts();
}
showEmailView(email, folder) {
document.getElementById('accountView').style.display = 'none';
document.getElementById('emailView').style.display = '';
document.getElementById('topbarEmail').textContent = email;
const folderLabel = folder === 'Junk' ? '垃圾箱' : '收件箱';
document.getElementById('topbarFolder').textContent = folderLabel;
document.getElementById('emailListTitle').textContent = folderLabel;
this.currentEmail = email;
this.currentFolder = folder;
this.messages = [];
this.selectedId = null;
this.showDetailEmpty();
this.loadEmails();
}
// ====================================================================
// 账号管理 — 数据加载
// ====================================================================
async loadAccounts() {
const tbody = document.getElementById('accountTableBody');
tbody.innerHTML = `<tr><td colspan="13"><div class="table-loading"><div class="spinner-border spinner-border-sm"></div> 加载中...</div></td></tr>`;
try {
// 有筛选时加载全部数据,否则分页加载
const params = new URLSearchParams(
this.paymentFilter
? { page: 1, page_size: 9999 }
: { page: this.page, page_size: this.pageSize }
);
if (this.query) params.set('q', this.query);
const resp = await fetch(`/api/accounts/detailed?${params}`);
const result = await resp.json();
if (result.success && result.data) {
this._allAccounts = result.data.items || [];
this._allTotal = result.data.total || 0;
await this.loadClaudePaymentStatuses();
this.applyFilterAndRender();
} else {
tbody.innerHTML = `<tr><td colspan="13"><div class="no-data"><i class="bi bi-exclamation-circle"></i><div>${this.escapeHtml(result.message || '加载失败')}</div></div></td></tr>`;
}
} catch (err) {
console.error('加载账号失败:', err);
tbody.innerHTML = `<tr><td colspan="13"><div class="no-data"><i class="bi bi-wifi-off"></i><div>网络错误</div></div></td></tr>`;
}
}
_matchPaymentFilter(email) {
const info = this.claudePaymentStatuses[email];
switch (this.paymentFilter) {
case 'paid': return info && !!info.payment_time;
case 'unpaid': return !info || !info.payment_time;
case 'refunded': return info && !!info.refund_time;
case 'refund_received': return info && info.refund_received === '1';
case 'refund_not_received': return info && !!info.refund_time && info.refund_received !== '1';
case 'suspended': return info && !!info.suspended_time;
case 'unchecked': return !info;
default: return true;
}
}
applyFilterAndRender() {
if (this.paymentFilter) {
const filtered = this._allAccounts.filter(acc => this._matchPaymentFilter(acc.email));
this.total = filtered.length;
const start = (this.page - 1) * this.pageSize;
this.accounts = filtered.slice(start, start + this.pageSize);
} else {
this.accounts = this._allAccounts;
this.total = this._allTotal;
}
this.renderAccounts();
this.renderPager();
}
renderAccounts() {
const tbody = document.getElementById('accountTableBody');
if (!this.accounts.length) {
tbody.innerHTML = `<tr><td colspan="13"><div class="no-data"><i class="bi bi-inbox"></i><div>暂无邮箱数据</div></div></td></tr>`;
return;
}
// 将账户数据存到map中复制时通过索引取值
this._accountDataMap = {};
const startIdx = (this.page - 1) * this.pageSize;
tbody.innerHTML = this.accounts.map((acc, i) => {
const num = startIdx + i + 1;
const email = acc.email || '';
const pwd = acc.password || '';
const cid = acc.client_id || '';
const token = acc.refresh_token || '';
this._accountDataMap[email] = { pwd, cid, token };
return `
<tr>
<td class="col-num">${num}</td>
<td>
<div class="value-container">
<span class="value-text" title="${this.escapeHtml(email)}">${this.escapeHtml(email)}</span>
<button class="copy-icon-btn" data-copy-field="email" data-copy-key="${this.escapeHtml(email)}" title="复制">
<i class="bi bi-clipboard"></i>
</button>
</div>
</td>
<td>
<div class="value-container">
<span class="value-text value-secret">${pwd ? '••••••' : '-'}</span>
${pwd ? `<button class="copy-icon-btn" data-copy-field="pwd" data-copy-key="${this.escapeHtml(email)}" title="复制"><i class="bi bi-clipboard"></i></button>` : ''}
</div>
</td>
<td>
<button class="note-cell-btn credential-btn" data-cred-email="${this.escapeHtml(email)}"><i class="bi bi-key"></i> 查看</button>
</td>
${this.renderClaudeColumns(email)}
<td>
<div class="actions">
<button class="view" onclick="app.openMailbox('${this.escapeHtml(email)}', 'INBOX')">收件箱</button>
<button class="view" onclick="app.openMailbox('${this.escapeHtml(email)}', 'Junk')">垃圾箱</button>
<button class="view claude-check-btn" onclick="app.checkSingleClaudePayment('${this.escapeHtml(email)}', this)">Claude</button>
${this.renderRefundBtn(email)}
<button class="delete" onclick="app.deleteAccount('${this.escapeHtml(email)}')">删除</button>
</div>
</td>
</tr>
`;
}).join('');
}
renderPager() {
const maxPage = Math.ceil(this.total / this.pageSize) || 1;
document.getElementById('pagerInfo').textContent = `${this.total}`;
document.getElementById('pagerCurrent').textContent = `${this.page}/${maxPage}`;
// 渲染页码按钮
const controlsContainer = document.getElementById('pagerControls');
let btns = '';
// 上一页
btns += `<button class="pagination-btn" ${this.page <= 1 ? 'disabled' : ''} onclick="app.goPage(${this.page - 1})" title="上一页"><i class="bi bi-chevron-left"></i></button>`;
// 页码
const range = this.getPageRange(this.page, maxPage, 5);
for (const p of range) {
if (p === '...') {
btns += `<span style="color:#94a3b8;padding:0 4px;">...</span>`;
} else {
btns += `<button class="pagination-btn ${p === this.page ? 'active' : ''}" onclick="app.goPage(${p})">${p}</button>`;
}
}
// 下一页
btns += `<button class="pagination-btn" ${this.page >= maxPage ? 'disabled' : ''} onclick="app.goPage(${this.page + 1})" title="下一页"><i class="bi bi-chevron-right"></i></button>`;
controlsContainer.innerHTML = btns;
}
getPageRange(current, total, maxButtons) {
if (total <= maxButtons) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const pages = [];
const half = Math.floor(maxButtons / 2);
let start = Math.max(1, current - half);
let end = Math.min(total, start + maxButtons - 1);
if (end - start < maxButtons - 1) {
start = Math.max(1, end - maxButtons + 1);
}
if (start > 1) { pages.push(1); if (start > 2) pages.push('...'); }
for (let i = start; i <= end; i++) pages.push(i);
if (end < total) { if (end < total - 1) pages.push('...'); pages.push(total); }
return pages;
}
goPage(p) {
const maxPage = Math.ceil(this.total / this.pageSize) || 1;
if (p < 1 || p > maxPage) return;
this.page = p;
this.loadAccounts();
}
// ====================================================================
// 账号管理 — 操作
// ====================================================================
async importAccounts() {
const text = document.getElementById('importText').value.trim();
if (!text) {
this.showToast('请粘贴账号数据', 'warning');
return;
}
const mergeMode = document.querySelector('input[name="mergeMode"]:checked').value;
const btn = document.getElementById('doImportBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 导入中...';
try {
const resp = await fetch('/api/accounts/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, merge_mode: mergeMode })
});
const result = await resp.json();
if (result.success) {
this.showToast(result.message || '导入成功', 'success');
document.getElementById('importModal').classList.remove('show');
this.page = 1;
this.loadAccounts();
} else {
this.showToast(result.message || '导入失败', 'danger');
}
} catch (err) {
this.showToast('网络错误', 'danger');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-check2"></i><span>确定导入</span>';
}
}
async exportAccounts() {
try {
const resp = await fetch('/api/export');
if (!resp.ok) throw new Error('导出失败');
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `outlook_accounts_${new Date().toISOString().slice(0, 10)}.txt`;
a.click();
URL.revokeObjectURL(url);
this.showToast('导出成功', 'success');
} catch (err) {
this.showToast('导出失败', 'danger');
}
}
async deleteAccount(email) {
this.showConfirm(`确定要删除账号 ${email} 吗?`, () => this._doDeleteAccount(email), '删除确认');
}
async _doDeleteAccount(email) {
try {
const resp = await fetch(`/api/account/${encodeURIComponent(email)}`, { method: 'DELETE' });
const result = await resp.json();
if (result.success) {
this.showToast(`已删除 ${email}`, 'success');
this.loadAccounts();
} else {
this.showToast(result.message || '删除失败', 'danger');
}
} catch (err) {
this.showToast('网络错误', 'danger');
}
}
openMailbox(email, folder) {
this.showEmailView(email, folder);
}
// ====================================================================
// 邮件查看 — 数据
// ====================================================================
async refreshEmails() {
const btn = document.getElementById('refreshEmailsBtn');
btn.classList.add('spinning');
btn.disabled = true;
try {
await this.loadEmails(true);
} finally {
btn.classList.remove('spinning');
btn.disabled = false;
}
}
async loadEmails(refresh = false) {
const container = document.getElementById('emailListContent');
container.innerHTML = '<div class="loading-state"><div class="spinner-border spinner-border-sm"></div><p>加载邮件中...</p></div>';
try {
const params = new URLSearchParams({
email: this.currentEmail,
folder: this.currentFolder,
top: 20
});
if (refresh) params.set('refresh', 'true');
const resp = await fetch(`/api/messages?${params}`);
const result = await resp.json();
if (result.success && result.data && result.data.length > 0) {
this.messages = result.data;
this.renderMailList();
this.updateEmailCount(this.messages.length);
this.selectEmail(this.messages[0].id);
} else {
this.messages = [];
this.updateEmailCount(0);
container.innerHTML = `<div class="empty-state"><i class="bi bi-inbox"></i><p>${this.escapeHtml(result.message || '暂无邮件')}</p></div>`;
}
} catch (err) {
console.error('加载邮件失败:', err);
container.innerHTML = '<div class="empty-state"><i class="bi bi-wifi-off"></i><p>网络错误</p></div>';
}
}
renderMailList() {
const container = document.getElementById('emailListContent');
container.innerHTML = this.messages.map(msg => {
const sender = msg.sender?.emailAddress || msg.from?.emailAddress || {};
const senderName = sender.name || sender.address || '未知';
const subject = msg.subject || '(无主题)';
const preview = (msg.bodyPreview || '').substring(0, 80);
const time = this.formatDate(msg.receivedDateTime);
return `
<div class="mail-item" data-id="${this.escapeHtml(msg.id)}" onclick="app.selectEmail('${this.escapeHtml(msg.id)}')">
<div class="mail-item-header">
<span class="mail-sender">${this.escapeHtml(senderName)}</span>
<span class="mail-time">${time}</span>
</div>
<div class="mail-subject">${this.escapeHtml(subject)}</div>
<div class="mail-preview">${this.escapeHtml(preview)}</div>
</div>
`;
}).join('');
}
selectEmail(id) {
this.selectedId = id;
document.querySelectorAll('.mail-item').forEach(el => {
el.classList.toggle('selected', el.dataset.id === id);
});
const msg = this.messages.find(m => m.id === id);
if (msg) this.renderDetail(msg);
this.closeMobileMailList();
}
updateEmailCount(count) {
const badge = document.getElementById('emailCountBadge');
badge.textContent = count;
badge.style.display = count > 0 ? 'inline-flex' : 'none';
}
// ====================================================================
// 邮件详情
// ====================================================================
renderDetail(msg) {
const container = document.getElementById('detailContent');
const sender = msg.sender?.emailAddress || msg.from?.emailAddress || {};
const senderName = sender.name || sender.address || '未知发件人';
const senderAddr = sender.address || '';
const toRecipients = msg.toRecipients || [];
const recipients = toRecipients
.map(r => r.emailAddress?.name || r.emailAddress?.address)
.filter(Boolean)
.join(', ') || '未知收件人';
const subject = msg.subject || '(无主题)';
const date = this.formatDateFull(msg.receivedDateTime);
const body = msg.body?.content || '(无内容)';
const contentType = msg.body?.contentType || 'text';
const isHtml = contentType === 'html' ||
body.includes('<html') ||
body.includes('<body') ||
body.includes('<div') ||
body.includes('<table');
let bodySection = '';
if (isHtml) {
bodySection = '<div class="email-body-container"><iframe class="email-iframe" id="emailIframe" sandbox="allow-same-origin" frameborder="0"></iframe></div>';
} else {
bodySection = `<div class="email-body-text">${this.escapeHtml(body)}</div>`;
}
container.innerHTML = `
<div class="email-detail-wrapper">
<div class="email-detail-header">
<div class="email-detail-subject">${this.escapeHtml(subject)}</div>
<div class="email-detail-meta">
<div class="meta-row">
<span class="meta-label">发件人</span>
<span class="meta-value">${this.escapeHtml(senderName)}</span>
${senderAddr ? `<span class="meta-badge"><i class="bi bi-envelope-fill me-1"></i>${this.escapeHtml(senderAddr)}</span>` : ''}
</div>
<div class="meta-row">
<span class="meta-label">收件人</span>
<span class="meta-value">${this.escapeHtml(recipients)}</span>
</div>
<div class="meta-row">
<span class="meta-label">时间</span>
<span class="meta-value">${date}</span>
</div>
</div>
</div>
${bodySection}
</div>
`;
if (isHtml) this.writeIframe(body);
}
writeIframe(htmlContent) {
const iframe = document.getElementById('emailIframe');
if (!iframe) return;
const sanitized = this.sanitizeHtml(htmlContent);
iframe.onload = () => {
try {
const doc = iframe.contentDocument || iframe.contentWindow.document;
const height = doc.documentElement.scrollHeight || doc.body.scrollHeight;
iframe.style.height = Math.max(300, Math.min(height + 30, 2000)) + 'px';
} catch (e) {
iframe.style.height = '500px';
}
};
try {
const doc = iframe.contentDocument || iframe.contentWindow.document;
doc.open();
doc.write(`<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;font-size:14px;line-height:1.6;color:#334155;margin:12px;word-wrap:break-word;overflow-wrap:break-word}img{max-width:100%;height:auto}table{max-width:100%}a{color:#667eea}pre{white-space:pre-wrap;word-wrap:break-word}</style></head><body>${sanitized}</body></html>`);
doc.close();
} catch (e) {
console.error('写入iframe失败:', e);
iframe.style.height = '500px';
}
}
showDetailEmpty() {
const container = document.getElementById('detailContent');
container.innerHTML = '<div class="empty-detail"><i class="bi bi-envelope-open"></i><h6>选择一封邮件查看详情</h6><p>从左侧列表中选择邮件</p></div>';
}
// ====================================================================
// 移动端邮件列表
// ====================================================================
toggleMobileMailList() {
const panel = document.getElementById('emailListPanel');
const overlay = document.getElementById('mobileOverlay');
panel.classList.toggle('mobile-open');
overlay.classList.toggle('show');
}
closeMobileMailList() {
const panel = document.getElementById('emailListPanel');
const overlay = document.getElementById('mobileOverlay');
panel.classList.remove('mobile-open');
overlay.classList.remove('show');
}
// ====================================================================
// Claude 支付检测
// ====================================================================
renderRefundBtn(email) {
const info = this.claudePaymentStatuses[email];
// 只有已退款状态才显示退款到账按钮
if (!info || !info.refund_time) return '';
const isReceived = info.refund_received === '1';
if (isReceived) {
return `<button class="view" style="background:rgba(16,185,129,0.1);color:#059669;border-color:rgba(16,185,129,0.15);" onclick="app.confirmRefundToggle('${this.escapeHtml(email)}')">已到账</button>`;
}
return `<button class="view" style="background:rgba(245,158,11,0.1);color:#d97706;border-color:rgba(245,158,11,0.15);" onclick="app.confirmRefundToggle('${this.escapeHtml(email)}')">未到账</button>`;
}
confirmRefundToggle(email) {
const info = this.claudePaymentStatuses[email];
const isReceived = info && info.refund_received === '1';
const msg = isReceived
? `确定将 ${email} 的退款状态改为【未到账】吗?`
: `确定将 ${email} 标记为【退款已到账】吗?`;
this.showConfirm(msg, () => this.toggleRefundReceived(email));
}
showConfirm(message, onOk, title = '确认操作') {
const modal = document.getElementById('confirmModal');
document.getElementById('confirmTitle').textContent = title;
document.getElementById('confirmMessage').textContent = message;
modal.classList.add('show');
const okBtn = document.getElementById('confirmOkBtn');
const cancelBtn = document.getElementById('confirmCancelBtn');
const close = () => modal.classList.remove('show');
const onConfirm = () => { close(); onOk(); };
okBtn.onclick = onConfirm;
cancelBtn.onclick = close;
modal.onclick = (e) => { if (e.target === modal) close(); };
}
showCredentialModal(email) {
const data = this._accountDataMap[email];
if (!data) return;
const cid = data.cid || '-';
const token = data.token || '-';
// 复用 paste-modal 样式
const modal = document.getElementById('credentialModal');
document.getElementById('credModalEmail').textContent = email;
document.getElementById('credClientId').textContent = cid;
document.getElementById('credToken').textContent = token;
modal.classList.add('show');
}
async toggleRefundReceived(email) {
try {
const resp = await fetch(`/api/tools/refund-received/${encodeURIComponent(email)}`, { method: 'POST' });
const result = await resp.json();
if (result.success) {
if (this.claudePaymentStatuses[email]) {
this.claudePaymentStatuses[email].refund_received = result.data.refund_received;
this.claudePaymentStatuses[email].refund_received_at = result.data.refund_received_at || '';
}
this.renderAccounts();
this.showToast(result.data.refund_received === '1' ? '已标记到账' : '已取消到账', 'success');
} else {
this.showToast(result.message || '操作失败', 'danger');
}
} catch (e) {
this.showToast('网络错误', 'danger');
}
}
async loadClaudePaymentStatuses() {
try {
const resp = await fetch('/api/tools/claude-payment-status');
if (resp.ok) {
const result = await resp.json();
if (result.success) this.claudePaymentStatuses = result.data || {};
}
} catch (e) { console.error('加载Claude支付状态失败:', e); }
}
renderClaudeColumns(email) {
const info = this.claudePaymentStatuses[email];
if (!info) {
return `<td><span class="claude-badge claude-unknown">未检测</span></td>
<td><span class="claude-badge claude-unknown">未检测</span></td>
<td class="claude-time">-</td>
<td class="claude-time">-</td>
<td class="claude-time">-</td>
<td class="claude-time">-</td>
<td><button class="note-cell-btn" data-note-email="${this.escapeHtml(email)}"><i class="bi bi-pencil"></i> 编辑</button></td>
<td><button class="proxy-btn" data-email="${this.escapeHtml(email)}"><i class="bi bi-globe"></i> 设置</button></td>`;
}
const hasPaid = !!info.payment_time;
const hasRefund = !!info.refund_time;
const hasSuspended = !!info.suspended_time;
const paidBadge = info.status === 'error'
? '<span class="claude-badge claude-error">错误</span>'
: hasPaid
? '<span class="claude-badge claude-paid">已支付</span>'
: '<span class="claude-badge claude-unknown">未支付</span>';
const refundBadge = info.status === 'error'
? '<span class="claude-badge claude-error">错误</span>'
: hasRefund
? '<span class="claude-badge claude-refunded">已退款</span>'
: '<span class="claude-badge claude-unknown">未退款</span>';
const fmtTime = (t) => {
if (!t) return '-';
try {
const d = new Date(t);
if (isNaN(d.getTime())) return t;
return d.toLocaleString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
} catch(e) { return t; }
};
const suspendedHtml = hasSuspended
? `<span class="claude-badge claude-suspended">${fmtTime(info.suspended_time)}</span>`
: '-';
const refundReceivedTime = info.refund_received === '1' && info.refund_received_at
? fmtTime(info.refund_received_at)
: '-';
return `<td>${paidBadge}</td>
<td>${refundBadge}</td>
<td class="claude-time">${fmtTime(info.payment_time)}</td>
<td class="claude-time">${fmtTime(info.refund_time)}</td>
<td>${suspendedHtml}</td>
<td class="claude-time">${refundReceivedTime}</td>
<td>${this.renderNoteCell(email, info)}</td>
<td class="proxy-cell">
<div class="proxy-cell-inner">
<button class="proxy-btn ${info.proxy ? 'has-proxy' : ''}" data-email="${this.escapeHtml(email)}">${info.proxy ? '<i class="bi bi-globe2"></i> ' + this.escapeHtml(this.truncate(info.proxy, 16)) : '<i class="bi bi-globe"></i> 设置'}</button>
${info.proxy ? `<button class="copy-icon-btn" data-copy-proxy="${this.escapeHtml(email)}" title="复制代理"><i class="bi bi-clipboard"></i></button>` : ''}
</div>
${info.proxy ? this.renderProxyExpireBadge(info) : ''}
</td>`;
}
openProxyModal(email) {
this._proxyEditEmail = email;
const info = this.claudePaymentStatuses[email];
document.getElementById('proxyModalEmail').textContent = email;
// 清空字段
document.getElementById('proxyHost').value = '';
document.getElementById('proxyPort').value = '';
document.getElementById('proxyUser').value = '';
document.getElementById('proxyPass').value = '';
document.getElementById('proxyRaw').value = '';
document.querySelector('input[name="proxyProtocol"][value="http"]').checked = true;
// 有效期/类型默认值
const expireDays = info ? (info.proxy_expire_days || 30) : 30;
const share = info ? (info.proxy_share || 'exclusive') : 'exclusive';
const purchaseDate = info ? (info.proxy_purchase_date || '') : '';
// 设置有效期
const customInput = document.getElementById('proxyExpireCustom');
if (expireDays === 10 || expireDays === 30) {
document.querySelector(`input[name="proxyExpire"][value="${expireDays}"]`).checked = true;
customInput.style.display = 'none';
} else {
document.querySelector('input[name="proxyExpire"][value="custom"]').checked = true;
customInput.style.display = '';
customInput.value = expireDays;
}
// 设置类型
const shareRadio = document.querySelector(`input[name="proxyShare"][value="${share}"]`);
if (shareRadio) shareRadio.checked = true;
// 设置购买日期
document.getElementById('proxyPurchaseDate').value = purchaseDate || new Date().toISOString().slice(0, 10);
this.calcProxyExpireDate();
// 如果已有代理,解析填充
const existing = info ? (info.proxy || '') : '';
if (existing) {
this.parseProxyRaw(existing);
document.getElementById('proxyRaw').value = existing;
}
this.updateProxyPreview();
document.getElementById('proxyModal').classList.add('show');
setTimeout(() => document.getElementById('proxyHost').focus(), 100);
}
parseProxyRaw(raw) {
raw = raw.trim();
if (!raw) return;
let protocol = 'http', host = '', port = '', user = '', pass = '';
// 格式1: protocol://user:pass@host:port
const urlMatch = raw.match(/^(https?|socks5):\/\/(?:([^:@]+):([^@]+)@)?([^:\/]+):(\d+)/i);
if (urlMatch) {
protocol = urlMatch[1].toLowerCase();
user = urlMatch[2] || '';
pass = urlMatch[3] || '';
host = urlMatch[4];
port = urlMatch[5];
} else {
// 格式2: ip:port:user:pass 或 ip:port
const parts = raw.split(':');
if (parts.length >= 2) {
host = parts[0];
port = parts[1];
if (parts.length >= 4) {
user = parts[2];
pass = parts[3];
} else if (parts.length === 3) {
user = parts[2];
}
}
}
document.getElementById('proxyHost').value = host;
document.getElementById('proxyPort').value = port;
document.getElementById('proxyUser').value = user;
document.getElementById('proxyPass').value = pass;
const radio = document.querySelector(`input[name="proxyProtocol"][value="${protocol}"]`);
if (radio) radio.checked = true;
this.updateProxyPreview();
}
buildProxyString() {
const host = document.getElementById('proxyHost').value.trim();
const port = document.getElementById('proxyPort').value.trim();
if (!host || !port) return '';
const protocol = document.querySelector('input[name="proxyProtocol"]:checked').value;
const user = document.getElementById('proxyUser').value.trim();
const pass = document.getElementById('proxyPass').value.trim();
if (user && pass) {
return `${protocol}://${user}:${pass}@${host}:${port}`;
} else if (user) {
return `${protocol}://${user}@${host}:${port}`;
}
return `${protocol}://${host}:${port}`;
}
updateProxyPreview() {
const str = this.buildProxyString();
const el = document.getElementById('proxyPreview');
if (str) {
el.innerHTML = `<span class="proxy-preview-label">完整代理:</span><code class="proxy-preview-value">${this.escapeHtml(str)}</code>`;
} else {
el.innerHTML = '';
}
}
renderNoteCell(email, info) {
const title = info.title || '';
const card = info.card_number || '';
const hasContent = title || card || info.remark;
if (!hasContent) {
return `<button class="note-cell-btn" data-note-email="${this.escapeHtml(email)}"><i class="bi bi-pencil"></i> 编辑</button>`;
}
const maskedCard = card ? '****' + card.slice(-4) : '';
return `<div class="note-cell-display" data-note-email="${this.escapeHtml(email)}">
${title ? `<div class="note-title">${this.escapeHtml(title)}</div>` : ''}
${maskedCard ? `<div class="note-card"><i class="bi bi-credit-card"></i> ${maskedCard}</div>` : ''}
</div>`;
}
renderProxyExpireBadge(info) {
if (!info.proxy_purchase_date) return '';
const days = info.proxy_expire_days || 30;
const d = new Date(info.proxy_purchase_date);
d.setDate(d.getDate() + days);
const now = new Date(); now.setHours(0, 0, 0, 0);
const remaining = Math.ceil((d - now) / 86400000);
const shareLabel = info.proxy_share === 'shared' ? '共享' : '独享';
let cls = 'proxy-expire-ok';
let label = `${remaining}`;
if (remaining <= 0) { cls = 'proxy-expire-dead'; label = '已过期'; }
else if (remaining <= 3) { cls = 'proxy-expire-warn'; }
return `<div class="proxy-expire-info"><span class="proxy-expire-badge ${cls}">${label}</span><span class="proxy-share-tag">${shareLabel}</span></div>`;
}
getProxyExpireDays() {
const checked = document.querySelector('input[name="proxyExpire"]:checked').value;
if (checked === 'custom') {
return parseInt(document.getElementById('proxyExpireCustom').value) || 30;
}
return parseInt(checked);
}
calcProxyExpireDate() {
const purchase = document.getElementById('proxyPurchaseDate').value;
const days = this.getProxyExpireDays();
const expireEl = document.getElementById('proxyExpireDate');
if (purchase && days > 0) {
const d = new Date(purchase);
d.setDate(d.getDate() + days);
const now = new Date();
now.setHours(0, 0, 0, 0);
const remaining = Math.ceil((d - now) / 86400000);
const dateStr = d.toISOString().slice(0, 10);
if (remaining <= 0) {
expireEl.value = `${dateStr} (已过期)`;
expireEl.style.color = '#e11d48';
} else if (remaining <= 3) {
expireEl.value = `${dateStr} (剩${remaining}天)`;
expireEl.style.color = '#d97706';
} else {
expireEl.value = `${dateStr} (剩${remaining}天)`;
expireEl.style.color = '#059669';
}
} else {
expireEl.value = '';
expireEl.style.color = '#64748b';
}
}
async saveProxy() {
const email = this._proxyEditEmail;
if (!email) return;
const value = this.buildProxyString();
const expireDays = this.getProxyExpireDays();
const share = document.querySelector('input[name="proxyShare"]:checked').value;
const purchaseDate = document.getElementById('proxyPurchaseDate').value;
try {
const resp = await fetch(`/api/tools/claude-payment-note/${encodeURIComponent(email)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
proxy: value,
proxy_expire_days: expireDays,
proxy_share: share,
proxy_purchase_date: purchaseDate
})
});
const result = await resp.json();
if (result.success) {
if (!this.claudePaymentStatuses[email]) {
this.claudePaymentStatuses[email] = { status: 'unknown' };
}
this.claudePaymentStatuses[email].proxy = value;
this.claudePaymentStatuses[email].proxy_expire_days = expireDays;
this.claudePaymentStatuses[email].proxy_share = share;
this.claudePaymentStatuses[email].proxy_purchase_date = purchaseDate;
this.updateClaudeBadgeInTable(email);
document.getElementById('proxyModal').classList.remove('show');
this.showToast('代理已保存', 'success');
} else {
this.showToast(result.message || '保存失败', 'danger');
}
} catch (e) {
this.showToast('保存失败', 'danger');
}
}
async clearProxy() {
const email = this._proxyEditEmail;
if (!email) return;
try {
const resp = await fetch(`/api/tools/claude-payment-note/${encodeURIComponent(email)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ proxy: '' })
});
const result = await resp.json();
if (result.success) {
if (this.claudePaymentStatuses[email]) {
this.claudePaymentStatuses[email].proxy = '';
}
this.updateClaudeBadgeInTable(email);
document.getElementById('proxyModal').classList.remove('show');
this.showToast('代理已清除', 'success');
}
} catch (e) {
this.showToast('清除失败', 'danger');
}
}
openNoteModal(email) {
this._noteEditEmail = email;
const info = this.claudePaymentStatuses[email] || {};
document.getElementById('noteModalEmail').textContent = email;
document.getElementById('noteTitle').value = info.title || '';
document.getElementById('noteCardNumber').value = info.card_number || '';
document.getElementById('noteRemark').value = info.remark || '';
document.getElementById('noteModal').classList.add('show');
setTimeout(() => document.getElementById('noteTitle').focus(), 100);
}
async saveNote() {
const email = this._noteEditEmail;
if (!email) return;
const title = document.getElementById('noteTitle').value.trim();
const card_number = document.getElementById('noteCardNumber').value.trim();
const remark = document.getElementById('noteRemark').value.trim();
try {
const resp = await fetch(`/api/tools/claude-payment-note/${encodeURIComponent(email)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, card_number, remark })
});
const result = await resp.json();
if (result.success) {
if (!this.claudePaymentStatuses[email]) {
this.claudePaymentStatuses[email] = { status: 'unknown' };
}
this.claudePaymentStatuses[email].title = title;
this.claudePaymentStatuses[email].card_number = card_number;
this.claudePaymentStatuses[email].remark = remark;
this.updateClaudeBadgeInTable(email);
document.getElementById('noteModal').classList.remove('show');
this.showToast('备注已保存', 'success');
} else {
this.showToast(result.message || '保存失败', 'danger');
}
} catch (e) {
this.showToast('保存失败', 'danger');
}
}
async startClaudePaymentCheck() {
const btn = document.getElementById('claudePaymentBtn');
if (!btn) return;
btn.disabled = true;
const origHtml = btn.innerHTML;
btn.innerHTML = '<i class="bi bi-hourglass-split"></i><span>检测中...</span>';
try {
const response = await fetch('/api/tools/check-claude-payment', { method: 'POST' });
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'progress') {
btn.innerHTML = `<i class="bi bi-hourglass-split"></i><span>${data.current}/${data.total}</span>`;
} else if (data.type === 'result') {
this.claudePaymentStatuses[data.email] = {
status: data.status,
payment_time: data.payment_time || null,
refund_time: data.refund_time || null,
suspended_time: data.suspended_time || null,
checked_at: new Date().toLocaleString('zh-CN')
};
this.updateClaudeBadgeInTable(data.email);
} else if (data.type === 'done') {
btn.innerHTML = '<i class="bi bi-check-circle"></i><span>完成</span>';
setTimeout(() => { btn.innerHTML = origHtml; btn.disabled = false; }, 2000);
}
} catch (e) {}
}
}
} catch (err) {
console.error('Claude支付检测失败:', err);
btn.innerHTML = '<i class="bi bi-exclamation-triangle"></i><span>失败</span>';
setTimeout(() => { btn.innerHTML = origHtml; btn.disabled = false; }, 2000);
}
}
async checkSingleClaudePayment(email, btnEl) {
if (!btnEl) return;
btnEl.disabled = true;
const origText = btnEl.textContent;
btnEl.textContent = '检测中...';
try {
const resp = await fetch(`/api/tools/check-claude-payment/${encodeURIComponent(email)}`, { method: 'POST' });
const result = await resp.json();
if (result.success && result.data) {
this.claudePaymentStatuses[email] = {
status: result.data.status,
payment_time: result.data.payment_time || null,
refund_time: result.data.refund_time || null,
suspended_time: result.data.suspended_time || null,
checked_at: new Date().toLocaleString('zh-CN')
};
this.updateClaudeBadgeInTable(email);
this.showToast(`${email} 检测完成: ${result.data.status}`, 'success');
} else {
this.showToast(result.message || '检测失败', 'danger');
}
} catch (err) {
console.error('单账户Claude检测失败:', err);
this.showToast('检测失败', 'danger');
} finally {
btnEl.disabled = false;
btnEl.textContent = origText;
}
}
updateClaudeBadgeInTable(email) {
const tbody = document.getElementById('accountTableBody');
if (!tbody) return;
for (const row of tbody.querySelectorAll('tr')) {
const firstTd = row.querySelector('td');
if (firstTd && firstTd.textContent.includes(email)) {
const cells = row.querySelectorAll('td');
if (cells.length >= 13) {
const tmp = document.createElement('tr');
tmp.innerHTML = this.renderClaudeColumns(email);
const newCells = tmp.querySelectorAll('td');
for (let j = 0; j < 7 && j < newCells.length; j++) {
cells[5 + j].innerHTML = newCells[j].innerHTML;
cells[5 + j].className = newCells[j].className;
}
}
break;
}
}
}
// ====================================================================
// 工具方法
// ====================================================================
copyToClipboard(text) {
if (!text) return;
const fallback = () => {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0.01';
document.body.appendChild(ta);
ta.focus();
ta.select();
try { document.execCommand('copy'); } catch (e) {}
document.body.removeChild(ta);
this.showToast('已复制到剪贴板', 'success');
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => {
this.showToast('已复制到剪贴板', 'success');
}).catch(fallback);
} else {
fallback();
}
}
showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const iconMap = {
success: 'bi-check-circle-fill',
danger: 'bi-exclamation-triangle-fill',
warning: 'bi-exclamation-circle-fill',
info: 'bi-info-circle-fill'
};
const toast = document.createElement('div');
toast.className = `app-toast toast-${type}`;
toast.innerHTML = `<i class="bi ${iconMap[type] || iconMap.info}"></i><span>${this.escapeHtml(message)}</span>`;
container.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('show'));
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 2500);
}
truncate(text, maxLen) {
if (!text) return '-';
return text.length > maxLen ? text.substring(0, maxLen) + '...' : text;
}
formatDate(dateString) {
if (!dateString) return '';
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) return dateString;
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const msgDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const diff = Math.floor((today - msgDay) / 86400000);
if (diff === 0) return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
if (diff === 1) return '昨天';
if (diff < 7) return `${diff}天前`;
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
} catch (e) {
return dateString;
}
}
formatDateFull(dateString) {
if (!dateString) return '未知时间';
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) return dateString;
return date.toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit'
});
} catch (e) {
return dateString;
}
}
escapeHtml(text) {
if (text == null) return '';
const str = String(text);
const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' };
return str.replace(/[&<>"']/g, m => map[m]);
}
escapeJs(text) {
if (text == null) return '';
return String(text).replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
}
sanitizeHtml(html) {
let cleaned = html;
cleaned = cleaned.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
cleaned = cleaned.replace(/\s*on\w+\s*=\s*["'][^"']*["']/gi, '');
cleaned = cleaned.replace(/javascript\s*:/gi, '');
return cleaned;
}
}
// 初始化
const app = new MailManager();