5822 lines
245 KiB
JavaScript
5822 lines
245 KiB
JavaScript
// public/app.js - Alpine.js 应用逻辑
|
||
|
||
function geminiApp() {
|
||
return {
|
||
// ==================== 状态 ====================
|
||
activeTab: 'accounts',
|
||
accounts: [],
|
||
browsers: [],
|
||
accountPage: 1,
|
||
accountPageSize: 100,
|
||
_accountByEmail: new Map(),
|
||
_browserById: new Map(),
|
||
selectedEmails: new Set(),
|
||
isDeletingAccounts: false,
|
||
settings: {},
|
||
logs: [],
|
||
currentTaskId: null,
|
||
eventSource: null,
|
||
isRunning: false,
|
||
isPaused: false,
|
||
taskProgress: { current: 0, total: 0, startedAt: null },
|
||
taskClockNow: Date.now(),
|
||
bitStatus: 'checking',
|
||
|
||
// 操作参数
|
||
threadCount: 1,
|
||
monitorEnabled: false,
|
||
inviteEmails: '',
|
||
|
||
// TOTP
|
||
totpCodes: {},
|
||
totpRemaining: 30,
|
||
|
||
// 顶部仪表盘快照:每批账号更新后只重算一次,所有卡片共用结果。
|
||
dashboardStats: {
|
||
statusCounts: { '任务中': 0 },
|
||
tierCounts: {
|
||
level1: { '全部': 0, '未登录': 0, '已登录': 0, '登录失败': 0 },
|
||
level2: { '未违反规则': 0, '违反规则': 0, '有凭证': 0, 'GCP被限制': 0, 'OAuth等待7天': 0, '未检测凭证': 0 },
|
||
level3: { '未改密': 0, '未改FA': 0, '改密<7天': 0, '改密≥7天': 0, '可重新提取': 0, 'ChatGPT成功': 0, 'ChatGPT失败': 0 },
|
||
},
|
||
},
|
||
|
||
// Cliproxy
|
||
cliproxySid: '',
|
||
cliproxyFullStr: '',
|
||
nodemavenFullStr: '',
|
||
|
||
// 代理预设
|
||
proxyPresets: [],
|
||
presetName: '',
|
||
|
||
// 静态代理池
|
||
staticProxies: [],
|
||
staticProxyImportText: '',
|
||
staticProxyMaxUsage: 1,
|
||
|
||
// 半价Ultra
|
||
skipHalfPriceCheck: false,
|
||
|
||
// 模态框
|
||
showImportAccountModal: false,
|
||
showBatchEmailSearchModal: false,
|
||
showSettingsModal: false,
|
||
showCustomErrorsModal: false,
|
||
showEditAccountModal: false,
|
||
showExportModal: false,
|
||
showProblemExportModal: false,
|
||
showBatchStatusModal: false,
|
||
showBatchCustomerNameModal: false,
|
||
showRefundDeadlineModal: false,
|
||
showStagingModal: false,
|
||
showChatgptSessionModal: false,
|
||
showChatgptRunModal: false,
|
||
showSecurityRunModal: false,
|
||
showOpenAIPlanRunModal: false,
|
||
showOpenAIAuthModal: false,
|
||
showAgCredentialModal: false,
|
||
showCliAcCredentialModal: false,
|
||
showAistudioKeysModal: false,
|
||
aistudioKeyRecord: null,
|
||
aistudioKeyEmail: '',
|
||
showRechargeModal: false,
|
||
showRechargeCardModal: false,
|
||
showGoogleAccountAgModal: false,
|
||
chatgptAuthMode: 'keep',
|
||
securityRunMode: 'overwrite',
|
||
openAIPlanConnectionMode: 'browser_proxy',
|
||
chatgptSessionEmail: '',
|
||
chatgptSessionText: '',
|
||
chatgptSessionLoading: false,
|
||
chatgptSessionError: '',
|
||
openAIAuthEmail: '',
|
||
openAIAuthText: '',
|
||
openAIAuthLoading: false,
|
||
openAIAuthError: '',
|
||
agCredentialEmail: '',
|
||
agCredentialText: '',
|
||
agCredentialLoading: false,
|
||
agCredentialError: '',
|
||
cliAcCredentialEmail: '',
|
||
cliAcCredentialText: '',
|
||
cliAcCredentialLoading: false,
|
||
cliAcCredentialError: '',
|
||
rechargeCardEmail: '',
|
||
rechargeCardText: '',
|
||
rechargeCardCompletedAt: '',
|
||
rechargeCardLoading: false,
|
||
rechargeCardError: '',
|
||
rechargeAccounts: [],
|
||
rechargeCardKeys: '',
|
||
rechargeConcurrency: 2,
|
||
rechargeSubmitting: false,
|
||
rechargeCredentialMeta: { configured: false, keyIdMasked: '', updatedAt: null },
|
||
rechargeCredentialForm: { keyId: '', secret: '' },
|
||
rechargeCredentialSaving: false,
|
||
rechargeCredentialDeleting: false,
|
||
workspaceRefundCredentialMeta: {
|
||
configured: false,
|
||
ownerMasked: '',
|
||
proxyConfigured: false,
|
||
proxyCount: 0,
|
||
proxyMasked: '',
|
||
updatedAt: null,
|
||
},
|
||
workspaceRefundCredentialForm: { value: '', proxy: '' },
|
||
workspaceRefundCredentialSaving: false,
|
||
workspaceRefundCredentialDeleting: false,
|
||
googleAccountCredentialMeta: { configured: false, keyMasked: '', apiUrl: 'https://sv9.api999api.com/google/apiv2.php', timelive: 60, updatedAt: null },
|
||
googleAccountCredentialForm: { value: '', timelive: 60 },
|
||
googleAccountCredentialSaving: false,
|
||
googleAccountCredentialDeleting: false,
|
||
googleAccountAgQuantity: 1,
|
||
googleAccountAgConcurrency: 1,
|
||
feishuCredentialMeta: { configured: false, appIdMasked: '', updatedAt: null },
|
||
feishuCredentialForm: { appId: '', appSecret: '' },
|
||
feishuCredentialSaving: false,
|
||
feishuCredentialDeleting: false,
|
||
feishuSyncRunning: false,
|
||
feishuSyncTaskId: null,
|
||
feishuEventSource: null,
|
||
feishuSyncProgress: { current: 0, total: 0 },
|
||
feishuSyncResult: null,
|
||
feishuSyncStatusText: '',
|
||
feishuSyncStatusType: 'info',
|
||
quotaMonitorStatus: {
|
||
running: false,
|
||
activeTask: null,
|
||
nextRunAt: null,
|
||
lastRun: null,
|
||
deadlineMonitor: { enabled: false, running: false, intervalSeconds: 15, nextCheckAt: null, lastRun: null },
|
||
config: { enabled: false, intervalValue: 24, intervalUnit: 'hours', concurrency: 2, threshold: 100, autoRefundEnabled: false },
|
||
},
|
||
quotaMonitorEventSource: null,
|
||
quotaMonitorTaskId: null,
|
||
quotaMonitorProgress: {
|
||
phase: 'query', current: 0, total: 0, thresholdCount: 0, failureCount: 0,
|
||
refundCurrent: 0, refundTotal: 0, refundSuccessCount: 0, refundFailureCount: 0, refundSkippedCount: 0,
|
||
},
|
||
quotaMonitorStatusText: '',
|
||
quotaMonitorStatusType: 'info',
|
||
batchStatus: '未登录',
|
||
batchCustomerName: '',
|
||
batchCustomerNameSaving: false,
|
||
refundDeadlineEmails: [],
|
||
refundDeadlineValue: '',
|
||
refundDeadlineMode: 'absolute',
|
||
refundDeadlineOffsetValue: 1,
|
||
refundDeadlineOffsetUnit: 'days',
|
||
refundDeadlineSaving: false,
|
||
exportStagingNote: '',
|
||
deleteBrowserWindowsOnStage: false,
|
||
exportInProgress: false,
|
||
stagedBatches: [],
|
||
stagedBatchDetail: null,
|
||
inlineEditStatusEmail: null,
|
||
inlineEditRechargeStatusEmail: null,
|
||
inlineEditAccountRefundEmail: null,
|
||
inlineEditRefundProcessEmail: null,
|
||
exportFields: {
|
||
email: true, password: true, recovery_email: true, secret_key: true,
|
||
country: false, sheerid_link: false, status: false, message: false, customer_name: false,
|
||
account_refund_status: false, refund_deadline_at: false, sms_url: false, recharged_at: false, ag_refresh_token: false,
|
||
cli_ac_credential: false, chatgpt_session: false, openai_auth_json: false, aistudio_api_key: false
|
||
},
|
||
exportMode: { file: false, csv: false, sub2api_json: false, clipboard: true, staging: false },
|
||
exportFieldDefs: [
|
||
{ key: 'email', label: '邮箱' },
|
||
{ key: 'password', label: '密码' },
|
||
{ key: 'recovery_email', label: '辅助邮箱' },
|
||
{ key: 'secret_key', label: '2FA密钥' },
|
||
{ key: 'country', label: '国家' },
|
||
{ key: 'customer_name', label: '客户名' },
|
||
{ key: 'account_refund_status', label: '账号退款' },
|
||
{ key: 'refund_deadline_at', label: '退款截止时间' },
|
||
{ key: 'sheerid_link', label: 'SheerID链接' },
|
||
{ key: 'status', label: '状态' },
|
||
{ key: 'message', label: '消息' },
|
||
{ key: 'recharged_at', label: '充值时间' },
|
||
{ key: 'sms_url', label: '接码链接' },
|
||
{ key: 'ag_refresh_token', label: 'AG凭证' },
|
||
{ key: 'cli_ac_credential', label: 'Cli_AC凭证' },
|
||
{ key: 'chatgpt_session', label: 'ChatGPT Session' },
|
||
{ key: 'openai_auth_json', label: '授权JSON' },
|
||
{ key: 'aistudio_api_key', label: 'AI Studio Key' },
|
||
],
|
||
_exportList: [],
|
||
problemExportCategories: {
|
||
password: true,
|
||
twofa: true,
|
||
captcha: true,
|
||
},
|
||
importText: '',
|
||
importTemplates: [],
|
||
importSelectedIndex: 0,
|
||
importNewTemplateName: '',
|
||
importNewTemplatePattern: '',
|
||
showTemplateEditor: false,
|
||
importStatus: '未登录',
|
||
importSkipDuplicates: false,
|
||
|
||
// 拖拽选择状态
|
||
_dragState: null, // { type: 'account', mode: 'select'|'deselect' }
|
||
|
||
// 复制提示
|
||
copyToastVisible: false,
|
||
copyToastTimer: null,
|
||
customErrorRules: [],
|
||
customErrorNewTasks: ['login'],
|
||
customErrorNewText: '',
|
||
customErrorNewTip: '',
|
||
|
||
// 自定义执行
|
||
customMode: false,
|
||
customCheckedTasks: [],
|
||
customQueue: [],
|
||
customCurrentIndex: -1,
|
||
customRunning: false,
|
||
customStoppedIndex: null,
|
||
customJobList: [], // 自定义任务列表 [{email, tasks, currentTaskLabel, completedCount, status, message}]
|
||
customStatusSubFilter: '全部', // 自定义任务列表内的状态子筛选
|
||
|
||
// 自定义模板
|
||
savedTemplates: [],
|
||
templateName: '',
|
||
customStepParams: {}, // { bind_card: { halfPrice: true/false } }
|
||
selectedTemplateId: null,
|
||
showTemplateCreator: false,
|
||
|
||
// 截图弹窗
|
||
showScreenshotModal: false,
|
||
screenshotUrl: '',
|
||
screenshotEmail: '',
|
||
screenshotHover: null,
|
||
|
||
// 诊断日志页
|
||
diagAccounts: [],
|
||
diagEmail: '',
|
||
diagEmailSearch: '',
|
||
diagDates: [],
|
||
diagDate: '',
|
||
diagTotal: 0,
|
||
diagOffset: 0,
|
||
diagLimit: 200,
|
||
diagEvents: [],
|
||
diagLoading: false,
|
||
diagTypeFilter: '',
|
||
diagTaskFilter: '',
|
||
diagSearch: '',
|
||
diagQuickFilter: '',
|
||
diagSelected: null,
|
||
|
||
// SheerLink 结果弹窗
|
||
showSheerLinkResultModal: false,
|
||
sheerLinkResults: [],
|
||
|
||
// 任务追踪(供任务完成后使用)
|
||
_lastTaskType: null,
|
||
_lastTaskEmails: [],
|
||
_lastTaskStartedAt: null,
|
||
_lastTaskId: null,
|
||
// 当前活跃任务与“最近一次任务”分开,避免刷新/多标签页时互相覆盖。
|
||
_activeTaskType: null,
|
||
_activeTaskEmails: [],
|
||
_activeTaskEmailSet: new Set(),
|
||
_activeTaskStartedAt: null,
|
||
|
||
// 筛选 & 搜索
|
||
statusFilter: '全部',
|
||
tierFilter: { level1: '全部', level2: '全部', level3: '全部', errorCode: null },
|
||
accountFilterConditions: [],
|
||
_nextAccountFilterConditionId: 1,
|
||
accountFilterFields: [
|
||
{ value: 'email', label: '邮箱' },
|
||
{ value: 'status', label: '状态' },
|
||
{ value: 'message', label: '处理结论' },
|
||
{ value: 'customer_name', label: '客户名' },
|
||
{ value: 'account_refund_status', label: '账号退款' },
|
||
{ value: 'refund_process_status', label: '是否已发起退款流程' },
|
||
{ value: 'refund_deadline_at', label: '退款截止时间' },
|
||
{ value: 'proxy_ip', label: '静态IP' },
|
||
{ value: 'browser_seq', label: '浏览器编号' },
|
||
{ value: 'country', label: '国家' },
|
||
{ value: 'chatgpt_status', label: 'ChatGPT状态' },
|
||
{ value: 'recharge_status', label: '是否已充值' },
|
||
{ value: 'has_openai_auth', label: '授权JSON' },
|
||
{ value: 'has_ag_credential', label: 'AC凭证' },
|
||
{ value: 'has_cli_ac_credential', label: 'Cli_AC凭证' },
|
||
{ value: 'gmail_app_password', label: '16位应用密码' },
|
||
{ value: 'openai_plan_type', label: '账号套餐' },
|
||
{ value: 'is_pro', label: 'Pro' },
|
||
{ value: 'ag_status', label: 'AG状态' },
|
||
],
|
||
rechargeStatusOptions: ['未充值', '充值中', '等待已确认', '充值成功', '充值失败', '账号本身已有会员'],
|
||
accountRefundStatusOptions: [
|
||
'无状态', '已退款', '未退款', '退款失败', '已申请退款', 'google封号无法查询',
|
||
'gpt封号退款失败', 'gpt警告', '已被改密无法查询', '已被改2FA无法查询',
|
||
'需要手机号查询', '无订阅',
|
||
],
|
||
refundProcessStatusOptions: ['未发起', '已发起', '发起失败'],
|
||
searchText: '',
|
||
batchEmailSearchText: '',
|
||
batchEmailSearchEmails: [],
|
||
batchEmailSearchActive: false,
|
||
customerNameFilter: '',
|
||
importCustomerName: '',
|
||
|
||
// 编辑账号
|
||
editAccount: null,
|
||
inlineEditErrorEmail: null,
|
||
|
||
// UI
|
||
logDrawerOpen: false,
|
||
|
||
// 账号实时日志:{ email: { message, type } }
|
||
accountLogs: {},
|
||
_pendingLogEntries: [],
|
||
_logFlushTimer: null,
|
||
_logLimit: 500,
|
||
_pendingAccountUpdates: new Map(),
|
||
_accountUpdateTimer: null,
|
||
_pendingTaskProgress: null,
|
||
_taskProgressTimer: null,
|
||
_totpUpdateId: 0,
|
||
|
||
openUrlPresets: {
|
||
student: 'https://one.google.com/ai-student?g1_landing_page=75&utm_source=antigravity&utm_campaign=argon_limit_reached',
|
||
country: 'https://policies.google.com/terms?hl=zh_CN',
|
||
family: 'https://myaccount.google.com/family/details?continue=https%3A%2F%2Fmyaccount.google.com%2Fpeople-and-sharing%3Futm_source%3Dchrome-profile-chooser&utm_source=chrome-profile-chooser',
|
||
payment: 'https://payments.google.com/payments/home#paymentMethods',
|
||
},
|
||
|
||
// ==================== 初始化 ====================
|
||
async init() {
|
||
try {
|
||
const savedMonitor = localStorage.getItem('monitor_enabled');
|
||
if (savedMonitor !== null) this.monitorEnabled = savedMonitor === '1';
|
||
} catch (_) {}
|
||
try {
|
||
const savedJobs = localStorage.getItem('customJobList');
|
||
if (savedJobs) this.customJobList = JSON.parse(savedJobs);
|
||
} catch (_) {}
|
||
try {
|
||
const savedTasks = localStorage.getItem('customCheckedTasks');
|
||
if (savedTasks) this.customCheckedTasks = JSON.parse(savedTasks);
|
||
} catch (_) {}
|
||
this.cliproxySid = this._generateClipproxySid();
|
||
// 加载代理预设
|
||
try {
|
||
const savedPresets = localStorage.getItem('proxyPresets');
|
||
if (savedPresets) this.proxyPresets = JSON.parse(savedPresets);
|
||
} catch (_) {}
|
||
// 一次性迁移"自定义成功"状态并清除自定义消息
|
||
if (!localStorage.getItem('_customMigrated2')) {
|
||
try {
|
||
const mRes = await fetch('/api/accounts/migrate-custom', { method: 'POST' });
|
||
if (mRes.ok) {
|
||
localStorage.removeItem('customJobList');
|
||
this.customJobList = [];
|
||
localStorage.setItem('_customMigrated2', '1');
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
await this.loadAccounts();
|
||
await this.loadSettings();
|
||
await this.loadRechargeCredentialMeta();
|
||
await this.loadWorkspaceRefundCredentialMeta();
|
||
await this.loadGoogleAccountCredentialMeta();
|
||
await this.loadFeishuCredentialMeta();
|
||
await this.loadQuotaMonitorStatus();
|
||
this._restoreRecentTaskBatch();
|
||
await this.loadStaticProxies();
|
||
await this.loadImportTemplates();
|
||
await this.checkBitStatus();
|
||
await this.loadBrowsers();
|
||
await this.loadDiagAccounts();
|
||
await this.loadTemplates();
|
||
await this.checkRunningTasks(); // 刷新页面后恢复运行中任务
|
||
// 每30秒检查一次比特浏览器状态
|
||
setInterval(() => this.checkBitStatus(), 30000);
|
||
setInterval(() => this.loadQuotaMonitorStatus(true), 15000);
|
||
// 拖拽选择:松开鼠标时结束
|
||
document.addEventListener('mouseup', () => {
|
||
this._dragState = null;
|
||
document.body.classList.remove('drag-selecting');
|
||
});
|
||
// TOTP:每秒更新剩余时间,窗口切换时重新计算验证码
|
||
await this.updateTotpCodes();
|
||
let lastWindow = Math.floor(Date.now() / 1000 / 30);
|
||
setInterval(async () => {
|
||
this.taskClockNow = Date.now();
|
||
const remaining = 30 - (Math.floor(Date.now() / 1000) % 30);
|
||
this.totpRemaining = remaining;
|
||
const w = Math.floor(Date.now() / 1000 / 30);
|
||
if (w !== lastWindow) { lastWindow = w; await this.updateTotpCodes(); }
|
||
}, 1000);
|
||
},
|
||
|
||
// ==================== 账号管理 ====================
|
||
async loadAccounts() {
|
||
try {
|
||
const res = await fetch('/api/accounts');
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
this.accounts = Array.isArray(data.data) ? data.data : [];
|
||
this._accountByEmail = new Map(this.accounts.map(account => [account.email, account]));
|
||
this._rebuildDashboardStats();
|
||
this.accountPage = Math.min(this.accountCurrentPage, this.accountTotalPages);
|
||
await this.updateTotpCodes();
|
||
}
|
||
} catch (e) {
|
||
this.addLog(`加载账号失败: ${e.message}`, 'error');
|
||
}
|
||
},
|
||
|
||
get selectedAccounts() {
|
||
return this.accounts.filter(a => this.selectedEmails.has(a.email));
|
||
},
|
||
|
||
get accountTotalPages() {
|
||
const size = Math.max(1, Number(this.accountPageSize) || 100);
|
||
return Math.max(1, Math.ceil(this.filteredAccounts.length / size));
|
||
},
|
||
|
||
get accountCurrentPage() {
|
||
const page = Math.max(1, Number(this.accountPage) || 1);
|
||
return Math.min(page, this.accountTotalPages);
|
||
},
|
||
|
||
get pagedAccounts() {
|
||
const filtered = this.filteredAccounts;
|
||
const size = Math.max(1, Number(this.accountPageSize) || 100);
|
||
const totalPages = Math.max(1, Math.ceil(filtered.length / size));
|
||
const page = Math.min(Math.max(1, Number(this.accountPage) || 1), totalPages);
|
||
const start = (page - 1) * size;
|
||
return filtered.slice(start, start + size);
|
||
},
|
||
|
||
_scheduleVisibleTotpUpdate() {
|
||
const update = () => this.updateTotpCodes();
|
||
if (typeof this.$nextTick === 'function') this.$nextTick(update);
|
||
else if (typeof setTimeout === 'function') setTimeout(update, 0);
|
||
},
|
||
|
||
resetAccountPage() {
|
||
this.accountPage = 1;
|
||
this._scheduleVisibleTotpUpdate();
|
||
},
|
||
|
||
goToAccountPage(page) {
|
||
const next = Math.min(Math.max(1, Number(page) || 1), this.accountTotalPages);
|
||
if (next === this.accountCurrentPage) return;
|
||
this.accountPage = next;
|
||
this._scheduleVisibleTotpUpdate();
|
||
},
|
||
|
||
setAccountPageSize(size) {
|
||
const allowed = new Set([50, 100, 200]);
|
||
const next = Number(size);
|
||
this.accountPageSize = allowed.has(next) ? next : 100;
|
||
this.resetAccountPage();
|
||
},
|
||
|
||
_setActiveTaskEmails(emails) {
|
||
const normalized = [...new Set((Array.isArray(emails) ? emails : []).filter(Boolean))];
|
||
this._activeTaskEmails = normalized;
|
||
this._activeTaskEmailSet = new Set(normalized);
|
||
},
|
||
|
||
_parseRecentTaskBatch(stored) {
|
||
if (!stored) return null;
|
||
try {
|
||
const parsed = typeof stored === 'string' ? JSON.parse(stored) : stored;
|
||
if (!parsed || !Array.isArray(parsed.emails)) return null;
|
||
const emails = [...new Set(parsed.emails
|
||
.map(email => String(email || '').trim())
|
||
.filter(Boolean))];
|
||
return {
|
||
taskType: parsed.taskType || null,
|
||
emails,
|
||
startedAt: parsed.startedAt || null,
|
||
taskId: parsed.taskId || null,
|
||
};
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
},
|
||
|
||
_applyRecentTaskBatch(batch, persistLocal = true) {
|
||
if (!batch) return;
|
||
this._lastTaskType = batch.taskType || null;
|
||
this._lastTaskEmails = [...new Set((batch.emails || []).filter(Boolean))];
|
||
this._lastTaskStartedAt = batch.startedAt || null;
|
||
this._lastTaskId = batch.taskId || null;
|
||
if (persistLocal) {
|
||
try { localStorage.setItem('recentTaskBatch', JSON.stringify(batch)); } catch (_) {}
|
||
}
|
||
},
|
||
|
||
_restoreRecentTaskBatch() {
|
||
const serverBatch = this._parseRecentTaskBatch(this.settings && this.settings.recent_task_batch);
|
||
let localBatch = null;
|
||
try { localBatch = this._parseRecentTaskBatch(localStorage.getItem('recentTaskBatch')); } catch (_) {}
|
||
const candidates = [serverBatch, localBatch].filter(Boolean);
|
||
if (candidates.length === 0) return;
|
||
candidates.sort((a, b) => {
|
||
const aTime = Date.parse(a.startedAt || '') || 0;
|
||
const bTime = Date.parse(b.startedAt || '') || 0;
|
||
return bTime - aTime;
|
||
});
|
||
this._applyRecentTaskBatch(candidates[0], true);
|
||
},
|
||
|
||
_rememberTaskBatch(taskType, taskAccounts, taskId = null) {
|
||
const emails = [...new Set((taskAccounts || [])
|
||
.map(account => String((account && account.email) || '').trim())
|
||
.filter(Boolean))];
|
||
const batch = {
|
||
taskId: taskId || null,
|
||
taskType: taskType || null,
|
||
emails,
|
||
startedAt: new Date().toISOString(),
|
||
};
|
||
this._applyRecentTaskBatch(batch, true);
|
||
},
|
||
|
||
get recentTaskCount() {
|
||
if (!this._lastTaskEmails || this._lastTaskEmails.length === 0) return 0;
|
||
const emailSet = new Set(this._lastTaskEmails);
|
||
return this.customerFilteredAccounts.filter(account => emailSet.has(account.email)).length;
|
||
},
|
||
|
||
get recentTaskTooltip() {
|
||
if (!this._lastTaskEmails || this._lastTaskEmails.length === 0) return '暂无最近运行的账号';
|
||
const labels = {
|
||
custom_pipeline: '自定义任务',
|
||
login: '登录',
|
||
change_password: '改密',
|
||
change_2fa: '改2FA',
|
||
chatgpt_register: 'ChatGPT注册/登录',
|
||
openai_get_auth: '提取授权JSON',
|
||
openai_query_plan: '查询套餐',
|
||
refund_query: '查询退款',
|
||
card_pay_recharge: '充值',
|
||
workspace_refund: '退款',
|
||
security_chatgpt: '改密+2FA',
|
||
ag_revoke_authorization: '取消授权反重力',
|
||
ag_get_credential: '获取凭证',
|
||
gemini_cli_get_credential: '获取Cli凭证',
|
||
google_account_ag_credential: '链接提取+获取凭证',
|
||
};
|
||
const taskLabel = labels[this._lastTaskType] || this._lastTaskType || '任务';
|
||
let timeLabel = '';
|
||
if (this._lastTaskStartedAt) {
|
||
const d = new Date(this._lastTaskStartedAt);
|
||
if (!Number.isNaN(d.getTime())) timeLabel = `,启动于 ${d.toLocaleString()}`;
|
||
}
|
||
return `最近任务:${taskLabel},${this.recentTaskCount} 个账号${timeLabel};点击筛选并全选`;
|
||
},
|
||
|
||
_taskTerminalStatuses(taskType) {
|
||
switch (taskType) {
|
||
case 'login':
|
||
return new Set(['登录成功', '登录失败', '订阅成功']);
|
||
case 'change_password':
|
||
return new Set(['改密成功', '改密失败', '未验证已改密和2FA', '未注册成功已改密和2FA']);
|
||
case 'change_2fa':
|
||
return new Set(['2FA修改成功', '2FA修改失败', '未验证已改密和2FA', '未注册成功已改密和2FA']);
|
||
case 'chatgpt_register':
|
||
return new Set(['ChatGPT成功', 'ChatGPT失败', '已验证改密和2FA', '未注册成功已改密和2FA']);
|
||
case 'security_chatgpt':
|
||
return new Set(['改密失败', '2FA修改失败', '未验证已改密和2FA', '未注册成功已改密和2FA']);
|
||
case 'bind_card':
|
||
return new Set(['订阅成功', '绑卡失败', '无资格', '有资格']);
|
||
default:
|
||
return new Set();
|
||
}
|
||
},
|
||
|
||
_isTaskAccountInProgress(acc) {
|
||
if (!this.isRunning) return false;
|
||
const email = acc && acc.email ? acc.email : '';
|
||
if (!email) return false;
|
||
if (!this._activeTaskEmailSet || this._activeTaskEmailSet.size === 0) return false;
|
||
if (!this._activeTaskEmailSet.has(email)) return false;
|
||
|
||
const taskType = this._activeTaskType || null;
|
||
const st = acc.status || '未登录';
|
||
|
||
const terminals = this._taskTerminalStatuses(taskType);
|
||
if (terminals.size > 0 && terminals.has(st)) return false;
|
||
|
||
if (taskType === 'change_2fa') {
|
||
const msg = (acc.message || '').trim();
|
||
if (/^(✅|❌|⚠️)/.test(msg)) return false;
|
||
if (msg && msg !== '处理中...') return false;
|
||
}
|
||
if (taskType === 'card_pay_recharge'
|
||
&& ['充值成功', '充值失败', '账号本身已有会员'].includes(String(acc.recharge_status || ''))) {
|
||
return false;
|
||
}
|
||
if (taskType === 'workspace_refund'
|
||
&& /^(✅|退款失败|任务已停止|账号已经退款|退款已经提交)/.test(String(acc.message || '').trim())) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
},
|
||
|
||
_accountFilterFieldValue(acc, field) {
|
||
if (!acc) return '';
|
||
if (field === 'browser_seq') return this.getBrowserSeq(acc.browser_id, acc.browser_seq);
|
||
if (field === 'status') return String(acc.status || '未登录').trim() || '未登录';
|
||
if (field === 'recharge_status') return String(acc.recharge_status || '未充值').trim() || '未充值';
|
||
if (field === 'account_refund_status') return String(acc.account_refund_status || '无状态').trim() || '无状态';
|
||
if (field === 'refund_process_status') return String(acc.refund_process_status || '未发起').trim() || '未发起';
|
||
if (field === 'refund_deadline_at') {
|
||
if (!acc.refund_deadline_at) return '未设置';
|
||
const instant = new Date(acc.refund_deadline_at);
|
||
return Number.isFinite(instant.getTime()) && instant.getTime() <= Date.now() ? '已到期' : '未到期';
|
||
}
|
||
if (field === 'message') return this.getLiveMessage(acc) || acc.message || '';
|
||
// 账号列表只返回授权 JSON 是否存在,筛选过程不读取或暴露完整令牌内容。
|
||
if (field === 'has_openai_auth') return acc.has_openai_auth ? '有授权JSON' : '';
|
||
// AC 凭证同样只依据列表中的存在标记筛选,不在前端读取完整凭证。
|
||
if (field === 'has_ag_credential') return (acc.has_ag_credential || acc.ag_refresh_token) ? '有AC凭证' : '';
|
||
// Gemini CLI 凭证只依据列表中的存在标记筛选,不在前端读取完整凭证。
|
||
if (field === 'has_cli_ac_credential') return acc.has_cli_ac_credential ? '有Cli_AC凭证' : '';
|
||
// 是否 Google AI Pro:三态 —— 1=Pro / 0=非Pro / null=未检测。
|
||
if (field === 'is_pro') {
|
||
if (acc.is_pro == null) return '未检测';
|
||
return (acc.is_pro === true || Number(acc.is_pro) === 1) ? 'Pro' : '非Pro';
|
||
}
|
||
return acc[field] == null ? '' : acc[field];
|
||
},
|
||
|
||
accountFilterNeedsValue(operator) {
|
||
return operator !== 'empty' && operator !== 'filled';
|
||
},
|
||
|
||
_isActiveAccountFilterCondition(condition) {
|
||
if (!condition || !condition.field || !condition.operator) return false;
|
||
if (!this.accountFilterNeedsValue(condition.operator)) return true;
|
||
return String(condition.value == null ? '' : condition.value).trim().length > 0;
|
||
},
|
||
|
||
_matchesAccountFilterCondition(acc, condition) {
|
||
if (!this._isActiveAccountFilterCondition(condition)) return true;
|
||
|
||
const actual = String(this._accountFilterFieldValue(acc, condition.field) || '').trim();
|
||
if (condition.operator === 'empty') return actual.length === 0;
|
||
if (condition.operator === 'filled') return actual.length > 0;
|
||
|
||
const expected = String(condition.value == null ? '' : condition.value).trim();
|
||
const actualLower = actual.toLowerCase();
|
||
const expectedLower = expected.toLowerCase();
|
||
if (condition.operator === 'equals') return actualLower === expectedLower;
|
||
if (condition.operator === 'not_equals') return actualLower !== expectedLower;
|
||
if (condition.operator === 'not_contains') return !actualLower.includes(expectedLower);
|
||
return actualLower.includes(expectedLower);
|
||
},
|
||
|
||
_matchesAccountFieldFilters(acc) {
|
||
return (this.accountFilterConditions || [])
|
||
.every(condition => this._matchesAccountFilterCondition(acc, condition));
|
||
},
|
||
|
||
_applyAccountQuery(accounts, keyword) {
|
||
return accounts.filter(acc => (
|
||
this._matchesAccountFieldFilters(acc) && this._matchSearch(acc, keyword)
|
||
));
|
||
},
|
||
|
||
get accountStatusOptions() {
|
||
const preferred = [
|
||
'未登录', '登录成功', '登录失败', '改密成功', '改密失败', '2FA修改成功', '2FA修改失败', '未验证已改密和2FA', '已验证改密和2FA', '未注册成功已改密和2FA',
|
||
'ChatGPT成功', 'ChatGPT失败', '订阅成功', '订阅失败', '已充值提取',
|
||
];
|
||
const available = new Set(this.accounts.map(acc => (
|
||
String((acc && acc.status) || '未登录').trim() || '未登录'
|
||
)));
|
||
const ordered = preferred.filter(status => available.has(status));
|
||
const remaining = [...available]
|
||
.filter(status => !preferred.includes(status))
|
||
.sort((a, b) => a.localeCompare(b, 'zh-CN'));
|
||
return [...ordered, ...remaining];
|
||
},
|
||
|
||
get hasAccountFieldFilters() {
|
||
return (this.accountFilterConditions || [])
|
||
.some(condition => this._isActiveAccountFilterCondition(condition));
|
||
},
|
||
|
||
addAccountFilterCondition() {
|
||
this.accountFilterConditions.push({
|
||
id: this._nextAccountFilterConditionId++,
|
||
field: 'email',
|
||
operator: 'contains',
|
||
value: '',
|
||
});
|
||
},
|
||
|
||
removeAccountFilterCondition(id) {
|
||
this.accountFilterConditions = this.accountFilterConditions
|
||
.filter(condition => condition.id !== id);
|
||
this.selectedEmails = new Set();
|
||
this.resetAccountPage();
|
||
},
|
||
|
||
onAccountFilterConditionChange(condition) {
|
||
if (condition && !this.accountFilterNeedsValue(condition.operator)) condition.value = '';
|
||
this.selectedEmails = new Set();
|
||
this.resetAccountPage();
|
||
},
|
||
|
||
clearAccountFieldFilters() {
|
||
this.accountFilterConditions = [];
|
||
this.selectedEmails = new Set();
|
||
this.resetAccountPage();
|
||
},
|
||
|
||
_normalizeBatchEmailSearchKey(value) {
|
||
const email = String(value || '').trim().toLowerCase();
|
||
const atIndex = email.lastIndexOf('@');
|
||
if (atIndex <= 0) return email;
|
||
let localPart = email.slice(0, atIndex);
|
||
let domain = email.slice(atIndex + 1);
|
||
// Gmail 用户名不区分大小写和点号;googlemail.com 与 gmail.com 也按同一域处理。
|
||
if (domain === 'gmail.com' || domain === 'googlemail.com') {
|
||
localPart = localPart.replace(/\./g, '');
|
||
domain = 'gmail.com';
|
||
}
|
||
return `${localPart}@${domain}`;
|
||
},
|
||
|
||
_parseBatchEmailSearchText(value) {
|
||
const seen = new Set();
|
||
return String(value || '')
|
||
.split(/\r?\n/)
|
||
.map(line => line.replace(/^\uFEFF/, '').trim())
|
||
.filter((email) => {
|
||
if (!email) return false;
|
||
const key = this._normalizeBatchEmailSearchKey(email);
|
||
if (seen.has(key)) return false;
|
||
seen.add(key);
|
||
return true;
|
||
});
|
||
},
|
||
|
||
get batchEmailSearchAccounts() {
|
||
if (!this.batchEmailSearchActive) return [];
|
||
const accountsByKey = new Map();
|
||
this.accounts.forEach((account) => {
|
||
const key = this._normalizeBatchEmailSearchKey(account && account.email);
|
||
if (!key) return;
|
||
if (!accountsByKey.has(key)) accountsByKey.set(key, []);
|
||
accountsByKey.get(key).push(account);
|
||
});
|
||
return this.batchEmailSearchEmails.flatMap(email => (
|
||
accountsByKey.get(this._normalizeBatchEmailSearchKey(email)) || []
|
||
));
|
||
},
|
||
|
||
get batchEmailSearchMissingEmails() {
|
||
if (!this.batchEmailSearchActive) return [];
|
||
const existingEmails = new Set(this.accounts.map(account => (
|
||
this._normalizeBatchEmailSearchKey(account && account.email)
|
||
)));
|
||
return this.batchEmailSearchEmails.filter(email => (
|
||
!existingEmails.has(this._normalizeBatchEmailSearchKey(email))
|
||
));
|
||
},
|
||
|
||
openBatchEmailSearch() {
|
||
this.showBatchEmailSearchModal = true;
|
||
},
|
||
|
||
applyBatchEmailSearch() {
|
||
const emails = this._parseBatchEmailSearchText(this.batchEmailSearchText);
|
||
if (emails.length === 0) return this.addLog('⚠️ 请粘贴至少一个邮箱,每行一个', 'warn');
|
||
this.batchEmailSearchEmails = emails;
|
||
this.batchEmailSearchActive = true;
|
||
this.showBatchEmailSearchModal = false;
|
||
this.selectedEmails = new Set();
|
||
this.resetAccountPage();
|
||
this.addLog(
|
||
`批量邮箱查询:输入 ${emails.length} 个,找到 ${this.batchEmailSearchAccounts.length} 个,未找到 ${this.batchEmailSearchMissingEmails.length} 个`,
|
||
'success',
|
||
);
|
||
},
|
||
|
||
clearBatchEmailSearch() {
|
||
this.batchEmailSearchActive = false;
|
||
this.batchEmailSearchEmails = [];
|
||
this.batchEmailSearchText = '';
|
||
this.showBatchEmailSearchModal = false;
|
||
this.selectedEmails = new Set();
|
||
this.resetAccountPage();
|
||
},
|
||
|
||
async copyBatchEmailSearchMissing() {
|
||
const missing = this.batchEmailSearchMissingEmails;
|
||
if (missing.length === 0) return this.addLog('没有未找到的邮箱', 'warn');
|
||
await this.copyCell(missing.join('\n'));
|
||
this.addLog(`已复制 ${missing.length} 个未找到邮箱`, 'success');
|
||
},
|
||
|
||
get filteredAccounts() {
|
||
const kw = this.searchText.trim().toLowerCase();
|
||
const customJobEmails = this.customJobList.length > 0 ? new Set(this.customJobList.map(j => j.email)) : null;
|
||
const batchAccounts = this.batchEmailSearchActive ? this.batchEmailSearchAccounts : null;
|
||
// 批量邮箱只负责限定账号范围;其结果仍可叠加普通搜索、组合筛选和三级筛选。
|
||
// 使用对象集合做交集,并按批量邮箱的输入顺序返回结果。
|
||
const restrictToBatchEmails = (accounts) => {
|
||
if (!batchAccounts) return accounts;
|
||
const allowedAccounts = new Set(accounts);
|
||
return batchAccounts.filter(account => allowedAccounts.has(account));
|
||
};
|
||
|
||
// 特殊筛选路径:任务中 / 自定义任务(保留原逻辑)
|
||
if (this.statusFilter === '任务中') {
|
||
const pool = restrictToBatchEmails(
|
||
this.customerFilteredAccounts.filter(acc => this._isTaskAccountInProgress(acc)),
|
||
);
|
||
return this._applyAccountQuery(pool, kw);
|
||
}
|
||
if (this.statusFilter === '刚刚运行') {
|
||
const emailSet = new Set(this._lastTaskEmails || []);
|
||
const pool = restrictToBatchEmails(
|
||
this.customerFilteredAccounts.filter(acc => emailSet.has(acc.email)),
|
||
);
|
||
return this._applyAccountQuery(pool, kw);
|
||
}
|
||
if (this.statusFilter === '自定义任务') {
|
||
const pool = restrictToBatchEmails(this.customerFilteredAccounts.filter(acc => {
|
||
if (!customJobEmails || !customJobEmails.has(acc.email)) return false;
|
||
if (this.customStatusSubFilter && this.customStatusSubFilter !== '全部') {
|
||
if ((acc.status || '未登录') !== this.customStatusSubFilter) return false;
|
||
}
|
||
return true;
|
||
}));
|
||
return this._applyAccountQuery(pool, kw);
|
||
}
|
||
if (this.statusFilter === '家庭组母号') {
|
||
const pool = restrictToBatchEmails(
|
||
this.customerFilteredAccounts.filter(acc => acc.tag === '家庭组母号'),
|
||
);
|
||
return this._applyAccountQuery(pool, kw);
|
||
}
|
||
|
||
// 三级联动筛选
|
||
let pool = restrictToBatchEmails(this._tier2Accounts);
|
||
const l3 = this.tierFilter.level3;
|
||
if (l3 !== '全部') {
|
||
if (l3 === '未改密') pool = pool.filter(a => !this._hasPasswordChanged(a));
|
||
else if (l3 === '未改FA') pool = pool.filter(a => !a.fa_changed);
|
||
else if (l3 === '未关资料') pool = pool.filter(a => !a.payment_closed);
|
||
else if (l3 === '未查订阅') pool = pool.filter(a => !a.subscription_checked);
|
||
else if (l3 === '改密<7天') pool = pool.filter(a => { const d = this._getPasswordDaysAgo(a); return d >= 0 && d < 7; });
|
||
else if (l3 === '改密≥7天') pool = pool.filter(a => { const d = this._getPasswordDaysAgo(a); return d >= 7; });
|
||
else if (l3 === '可重新提取') pool = pool.filter(a => a.ag_status === '可重新授权');
|
||
else if (l3 === 'ChatGPT成功') pool = pool.filter(a => ['已登录', '已注册'].includes(a.chatgpt_status));
|
||
else if (l3 === 'ChatGPT失败') pool = pool.filter(a => a.chatgpt_status === '失败');
|
||
}
|
||
// 错误码筛选
|
||
const ec = this.tierFilter.errorCode;
|
||
if (ec === '__all__') {
|
||
pool = pool.filter(a => a.card_error_code);
|
||
} else if (ec === '__none__') {
|
||
pool = pool.filter(a => !a.card_error_code);
|
||
} else if (ec) {
|
||
pool = pool.filter(a => a.card_error_code === ec);
|
||
}
|
||
|
||
return this._applyAccountQuery(pool, kw);
|
||
},
|
||
|
||
/**
|
||
* 按机器原因码返回统一的账号处理结论,兼容旧版本中文原因值。
|
||
* @param {string} code - status_reason 或 SSE 推送的原因码
|
||
* @param {string} message - 历史消息,用于旧数据兜底识别
|
||
* @returns {string} 短处理结论
|
||
*/
|
||
_failureLabelForCode(code, message = '') {
|
||
const c = String(code || '').trim().toUpperCase();
|
||
const text = String(message || '').toLowerCase();
|
||
if (c.startsWith('GEMINI_CLI_')) return 'Cli凭证失败';
|
||
if (c === 'GCP_RESTRICTED' || text.includes('gcp被限制')) return 'GCP被限制';
|
||
if (c === 'GCP_NO_PERMISSION' || text.includes('无权限')) return '无权限';
|
||
if (c === 'GOOGLE_UNDER_18' || text.includes('未满18岁')) return '未满18岁';
|
||
if (c === 'CAPTCHA' || c === '人机验证' || c === '人机认证'
|
||
|| /人机(?:验证|认证)|recaptcha|(?:confirm|verify)(?:\s+that)?\s+you(?:['’]re|\s+are)\s+not\s+a\s+robot|\bnot\s+a\s+robot\b/i.test(text)) return '人机验证';
|
||
if (text.includes('密码错误')) return '密码错误';
|
||
if (c === 'PASSWORD_CHANGED_RECENTLY' || text.includes('密码已被修改')
|
||
|| /your\s+password\s+was\s+changed|密码(?:近期)?已(?:被)?(?:更改|修改)/i.test(text)) return '密码已被修改';
|
||
if (c === 'CHATGPT_ACCOUNT_DEACTIVATED'
|
||
|| /(?:账号|account).{0,40}(?:已删除|已停用|被删除|被停用|deleted|deactivated)/i.test(text)) return '账号已删除/停用';
|
||
if (c === 'TOTP_KEY_INCORRECT' || c === 'TOTP_CODE_WRONG' || text.includes('2fa密钥不正确')) return '2fa密钥不正确';
|
||
if (text.includes('2fa错误')) return '2fa错误';
|
||
if (text.includes('无法登录')) return '无法登录';
|
||
if (text.includes('切换ip重试')) return '切换ip重试';
|
||
if (c === 'PHONE_TABLET_VERIFY' || text.includes('需手机/平板验证')) return '要等7天';
|
||
if (text.includes('要等7天')) return '要等7天';
|
||
if (text.includes('功能失败')) return '功能失败';
|
||
if (['密码错误', '2FA错误', '无法登录', '切换IP重试', '要等7天', '功能失败'].includes(c)) {
|
||
return c === '切换IP重试' ? '切换ip重试' : c === '2FA错误' ? '2fa错误' : c;
|
||
}
|
||
if (c === '密码错误' || c === 'PASSWORD_WRONG') return '密码错误';
|
||
if (c === '2FA错误' || c === '2FA密钥无效' || c === '2FA验证码错误'
|
||
|| ['TOTP_KEY_INVALID', 'TOTP_OPTION_UNAVAILABLE'].includes(c)) return '2fa错误';
|
||
if (['NETWORK_PROXY', 'NETWORK_TIMEOUT', 'DNS', 'SIGNIN_REJECTED', 'NAVIGATION_FAILED',
|
||
'GOOGLE_TEMPORARY_ERROR', 'DOM_NOT_FOUND', 'CLICK_BLOCKED'].includes(c)) return '切换ip重试';
|
||
if (['OAUTH_RESTART_FAILED', 'OAUTH_FAILED', 'TOKEN_EXCHANGE_FAILED'].includes(c)
|
||
&& /net::err_|proxy|timeout|network|failed\s+to\s+fetch|超时|网络|代理.*(?:失败|异常|不可用)|测速.*失败|出口(?:ip)?获取失败|连接失败/i.test(text)) return '切换ip重试';
|
||
if (c.startsWith('OAUTH_') || ['OAUTH_FAILED', 'NO_REFRESH_TOKEN', 'TOKEN_EXCHANGE_FAILED'].includes(c)) return '要等7天';
|
||
if (['ENV_NOT_SECURE', 'PHONE_VERIFY', 'REAUTH_REQUIRED', 'VERIFY_ITS_YOU',
|
||
'ACCOUNT_LOCKED', 'PASSWORD_CHANGED_RECENTLY', 'RECOVERY_EMAIL_REQUIRED',
|
||
'BACKUP_CODE_REQUIRED', 'UNSUPPORTED_CHALLENGE', 'PASSWORD_REVERIFY_STUCK',
|
||
'FORCED_PASSWORD_CHANGE_FAILED', 'RISK', 'GCP_RESTRICTED', 'LOGIN_FAILED', 'NO_BROWSER'].includes(c)) {
|
||
return '无法登录';
|
||
}
|
||
if (/密码(?:错误|不正确|不对|有误)|wrong\s+password|incorrect\s+password/i.test(text)) return '密码错误';
|
||
if (/(?:2fa|totp|authenticator).*(?:错误|失败|无效|密钥|验证码)|验证码(?:错误|失败|不正确)|wrong\s+(?:2fa\s+|totp\s+)?code/i.test(text)) return '2fa错误';
|
||
if (/\boauth\b|授权页面|获取凭证|refresh[_ -]?token/i.test(text)) return '要等7天';
|
||
if (/net::err_|econnreset|etimedout|enotfound|eai_again|proxy|tunnel|timed\s*out|network|failed\s+to\s+fetch|网络|代理.*(?:失败|异常|不可用)|测速.*失败|出口(?:ip)?获取失败|连接(?:失败|超时)|页面跳转.*(?:失败|超时)|请求超时/i.test(text)) return '切换ip重试';
|
||
if (c === 'UNKNOWN' || c === 'CUSTOM_ERROR') {
|
||
return /登录|登陆|signin|challenge|account/i.test(text) ? '无法登录' : '功能失败';
|
||
}
|
||
return c ? '功能失败' : '';
|
||
},
|
||
|
||
/**
|
||
* 从账号记录和实时消息中取得最终处理结论,供表格和搜索共用。
|
||
* @param {object} account - 账号记录
|
||
* @returns {string} 短处理结论或空字符串
|
||
*/
|
||
_getAccountFailureLabel(account) {
|
||
if (!account) return '';
|
||
const direct = String(account.failure_label || '').trim();
|
||
if (['密码错误', '密码已被修改', '2fa错误', '2fa密钥不正确', '人机验证', '无法登录', '切换ip重试', '要等7天', '账号已删除/停用', '功能失败', 'GCP被限制', '无权限', '未满18岁', 'Cli凭证失败'].includes(direct)) return direct;
|
||
if (account.ag_status === 'OAuth等待7天') return '要等7天';
|
||
|
||
const live = this.accountLogs && this.accountLogs[account.email];
|
||
const message = [account.message, live && live.message].filter(Boolean).join(' ');
|
||
const reason = String(account.status_reason || '').trim();
|
||
if (reason) {
|
||
const fromCode = this._failureLabelForCode(reason, message);
|
||
if (fromCode) return fromCode;
|
||
}
|
||
|
||
// 仅对明确的失败消息做旧数据兼容识别,成功/处理中消息保持原样。
|
||
if (!/(失败|错误|异常|无法|拒绝|受阻|超时|不可用|已被修改|近期已更改|已删除|已停用|被删除|被停用|deleted|deactivated|password was changed|人机验证|人机认证|recaptcha|not a robot|测速|出口|wrong|incorrect|invalid|oauth|proxy|network)/i.test(message)) return '';
|
||
return this._failureLabelForCode('', message);
|
||
},
|
||
|
||
_matchSearch(acc, kw) {
|
||
if (!kw) return true;
|
||
const failureLabel = this._getAccountFailureLabel(acc);
|
||
const fields = [
|
||
acc.email,
|
||
acc.password,
|
||
acc.recovery_email,
|
||
acc.secret_key,
|
||
acc.browser_id,
|
||
this.getBrowserSeq(acc.browser_id, acc.browser_seq),
|
||
acc.country,
|
||
acc.status,
|
||
acc.message,
|
||
acc.sheerid_link,
|
||
acc.customer_name,
|
||
acc.card_error_code,
|
||
acc.ag_status,
|
||
acc.chatgpt_status,
|
||
acc.status_reason,
|
||
acc.failure_label,
|
||
failureLabel,
|
||
].filter(Boolean).map(v => String(v).toLowerCase());
|
||
return fields.some(v => v.includes(kw));
|
||
},
|
||
|
||
get statusCounts() {
|
||
const statuses = ['未登录','登录成功','登录失败','改密成功','改密失败','2FA修改成功','2FA修改失败','未验证已改密和2FA','已验证改密和2FA','未注册成功已改密和2FA','ChatGPT成功','ChatGPT失败','半价Ultra','订阅成功','订阅失败','已充值提取','自定义成功'];
|
||
const counts = Object.fromEntries(statuses.map(s => [s, 0]));
|
||
counts['未改2FA'] = 0;
|
||
counts['任务中'] = 0;
|
||
counts['其他'] = 0;
|
||
const customJobEmails = this.customJobList.length > 0 ? new Set(this.customJobList.map(j => j.email)) : null;
|
||
this.customerFilteredAccounts.forEach(acc => {
|
||
// 自定义任务中的账号不计入其他状态统计
|
||
if (customJobEmails && customJobEmails.has(acc.email)) return;
|
||
const st = acc.status || '未登录';
|
||
if (counts[st] !== undefined) counts[st]++;
|
||
else counts['其他']++;
|
||
|
||
// 统计未改2FA:有2FA且是小写的
|
||
if (st !== '订阅成功' && st !== '登录失败' && acc.secret_key && acc.secret_key === acc.secret_key.toLowerCase() && /[a-z]/.test(acc.secret_key)) {
|
||
counts['未改2FA']++;
|
||
}
|
||
|
||
if (this._isTaskAccountInProgress(acc)) {
|
||
counts['任务中']++;
|
||
}
|
||
});
|
||
return counts;
|
||
},
|
||
|
||
_rebuildDashboardStats() {
|
||
// tierCounts 是这里最贵的计算。先完成一次统计,再一次性替换快照,
|
||
// 避免 17 个 Alpine 卡片各自重扫全部账号。
|
||
const tierCounts = this.tierCounts;
|
||
const statusCounts = this.statusCounts;
|
||
this.dashboardStats = { tierCounts, statusCounts };
|
||
return this.dashboardStats;
|
||
},
|
||
|
||
get familyMainCount() {
|
||
return this.customerFilteredAccounts.filter(a => a.tag === '家庭组母号').length;
|
||
},
|
||
|
||
// ==================== 三级仪表盘 ====================
|
||
_LOGIN_FAIL_STATUSES: new Set(['登录失败', '失败', '未注册成功已改密和2FA']),
|
||
_UNLOGIN_STATUSES: new Set(['未登录']),
|
||
|
||
_getLevel1Group(status) {
|
||
const st = String(status || '未登录').trim();
|
||
if (this._UNLOGIN_STATUSES.has(st)) return '未登录';
|
||
// 任务会把主状态更新成“改密失败”“ChatGPT失败”等;这些不能兜底算作已登录。
|
||
if (this._LOGIN_FAIL_STATUSES.has(st) || st.endsWith('失败')) return '登录失败';
|
||
return '已登录';
|
||
},
|
||
|
||
_hasPasswordChanged(acc) {
|
||
if (!acc) return false;
|
||
if (acc.password_changed === true || Number(acc.password_changed) === 1) return true;
|
||
if (String(acc.status || '').trim() === '改密成功') return true;
|
||
return /密码(?:更改|修改)成功|改密成功/.test(String(acc.message || ''));
|
||
},
|
||
|
||
/** 从 security_dates 或本项目记录的改密时间计算天数,-1 表示未知 */
|
||
_getPasswordDaysAgo(acc) {
|
||
if (!acc) return -1;
|
||
if (acc.security_dates) {
|
||
try {
|
||
const obj = typeof acc.security_dates === 'string' ? JSON.parse(acc.security_dates) : acc.security_dates;
|
||
if (obj.passwordMonth && obj.passwordDay) {
|
||
return this._calcDaysAgo(obj.passwordMonth, obj.passwordDay, obj.checkedYear);
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
if (!acc.password_changed_at) return -1;
|
||
const raw = String(acc.password_changed_at).trim();
|
||
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(raw)
|
||
? `${raw.replace(' ', 'T')}Z`
|
||
: raw;
|
||
const changedAt = Date.parse(normalized);
|
||
if (!Number.isFinite(changedAt)) return -1;
|
||
return Math.max(0, Math.floor((Date.now() - changedAt) / (1000 * 60 * 60 * 24)));
|
||
},
|
||
|
||
_isOAuthWait7Account(acc) {
|
||
if (!acc) return false;
|
||
if (acc.ag_refresh_token) return false;
|
||
if (this._isGcpRestricted(acc) || acc.ag_status === '凭证已获取') return false;
|
||
if (String(acc.status_reason || '').toUpperCase() === 'OAUTH_WAIT_7_DAYS') return true;
|
||
if (String(acc.status_reason || '').toUpperCase().startsWith('OAUTH_')) return true;
|
||
if (acc.ag_status === 'OAuth等待7天') return true;
|
||
if (acc.ag_status === '可重新授权') return false;
|
||
if (this._getAccountFailureLabel(acc) === '要等7天') return true;
|
||
const message = String(acc.message || '');
|
||
return /验证方式(?:选择|选项)页(?:面)?无.*(?:2FA|TOTP|Authenticator)/i.test(message);
|
||
},
|
||
|
||
_isGcpRestricted(acc) {
|
||
if (!acc) return false;
|
||
return acc.ag_status === 'GCP被限制'
|
||
|| acc.restriction_status === 'restricted'
|
||
|| String(acc.status_reason || '').toUpperCase() === 'GCP_RESTRICTED';
|
||
},
|
||
|
||
// 第一行过滤后的账号列表
|
||
get _tier1Accounts() {
|
||
const pool = this.customerFilteredAccounts;
|
||
const base = pool;
|
||
const l1 = this.tierFilter.level1;
|
||
if (l1 === '全部') return base;
|
||
return base.filter(acc => this._getLevel1Group(acc.status) === l1);
|
||
},
|
||
|
||
// 第二行过滤后的账号列表
|
||
get _tier2Accounts() {
|
||
const pool = this._tier1Accounts;
|
||
const l2 = this.tierFilter.level2;
|
||
if (l2 === '全部') return pool;
|
||
if (l2 === '半价邮箱') return pool.filter(a => a.is_half_price === 1);
|
||
if (l2 === '全价邮箱') return pool.filter(a => a.is_half_price === 0);
|
||
if (l2 === '未检测') return pool.filter(a => a.is_half_price == null);
|
||
if (l2 === '订阅成功') return pool.filter(a => (a.status || '') === '订阅成功');
|
||
if (l2 === '订阅失败') return pool.filter(a => a.card_last4 && (a.status || '') !== '订阅成功');
|
||
if (l2 === '未违反规则') return pool.filter(a => a.restriction_status === 'ok');
|
||
if (l2 === '违反规则') return pool.filter(a => a.restriction_status === 'restricted');
|
||
if (l2 === '有凭证') return pool.filter(a => a.ag_refresh_token);
|
||
if (l2 === 'GCP被限制') return pool.filter(a => this._isGcpRestricted(a));
|
||
if (l2 === 'OAuth等待7天') return pool.filter(a => this._isOAuthWait7Account(a));
|
||
if (l2 === '未检测凭证') return pool.filter(a => !a.ag_refresh_token && !this._isGcpRestricted(a) && !this._isOAuthWait7Account(a));
|
||
return pool;
|
||
},
|
||
|
||
get tierCounts() {
|
||
const pool = this.customerFilteredAccounts;
|
||
const base = pool;
|
||
|
||
// Level 1 counts
|
||
const l1 = { '全部': base.length, '未登录': 0, '已登录': 0, '登录失败': 0 };
|
||
base.forEach(acc => {
|
||
const g = this._getLevel1Group(acc.status);
|
||
l1[g] = (l1[g] || 0) + 1;
|
||
});
|
||
|
||
// Level 2 counts (based on tier1 filtered)
|
||
const t1 = this._tier1Accounts;
|
||
const l2 = {
|
||
'全部': t1.length,
|
||
'半价邮箱': t1.filter(a => a.is_half_price === 1).length,
|
||
'全价邮箱': t1.filter(a => a.is_half_price === 0).length,
|
||
'未检测': t1.filter(a => a.is_half_price == null).length,
|
||
'订阅成功': t1.filter(a => (a.status || '') === '订阅成功').length,
|
||
'订阅失败': t1.filter(a => a.card_last4 && (a.status || '') !== '订阅成功').length,
|
||
'未违反规则': t1.filter(a => a.restriction_status === 'ok').length,
|
||
'违反规则': t1.filter(a => a.restriction_status === 'restricted').length,
|
||
'有凭证': t1.filter(a => a.ag_refresh_token).length,
|
||
'GCP被限制': t1.filter(a => this._isGcpRestricted(a)).length,
|
||
'OAuth等待7天': t1.filter(a => this._isOAuthWait7Account(a)).length,
|
||
'未检测凭证': t1.filter(a => !a.ag_refresh_token && !this._isGcpRestricted(a) && !this._isOAuthWait7Account(a)).length,
|
||
};
|
||
|
||
// Level 3 counts (based on tier2 filtered)
|
||
const t2 = this._tier2Accounts;
|
||
const l3 = {
|
||
'全部': t2.length,
|
||
'未改密': t2.filter(a => !this._hasPasswordChanged(a)).length,
|
||
'未改FA': t2.filter(a => !a.fa_changed).length,
|
||
'改密<7天': t2.filter(a => { const d = this._getPasswordDaysAgo(a); return d >= 0 && d < 7; }).length,
|
||
'改密≥7天': t2.filter(a => { const d = this._getPasswordDaysAgo(a); return d >= 7; }).length,
|
||
'可重新提取': t2.filter(a => a.ag_status === '可重新授权').length,
|
||
'ChatGPT成功': t2.filter(a => ['已登录', '已注册'].includes(a.chatgpt_status)).length,
|
||
'ChatGPT失败': t2.filter(a => a.chatgpt_status === '失败').length,
|
||
};
|
||
|
||
return { level1: l1, level2: l2, level3: l3 };
|
||
},
|
||
|
||
get errorCodeList() {
|
||
const codes = new Set();
|
||
this.customerFilteredAccounts.forEach(a => {
|
||
if (a.card_error_code && a.card_error_code !== '已订阅') codes.add(a.card_error_code);
|
||
});
|
||
return [...codes].sort();
|
||
},
|
||
|
||
get errorCodeTotalCount() {
|
||
return this._tier2Accounts.filter(a => a.card_error_code && a.card_error_code !== '已订阅').length;
|
||
},
|
||
|
||
get noErrorCodeCount() {
|
||
return this._tier2Accounts.filter(a => !a.card_error_code || a.card_error_code === '已订阅').length;
|
||
},
|
||
|
||
// 按客户名预筛选的账号列表
|
||
get customerFilteredAccounts() {
|
||
if (!this.customerNameFilter) return this.accounts;
|
||
return this.accounts.filter(acc => {
|
||
if (this.customerNameFilter === '__empty__') return !acc.customer_name;
|
||
return (acc.customer_name || '') === this.customerNameFilter;
|
||
});
|
||
},
|
||
|
||
get customerNames() {
|
||
const names = new Set();
|
||
this.accounts.forEach(acc => {
|
||
if (acc.customer_name) names.add(acc.customer_name);
|
||
});
|
||
return [...names].sort();
|
||
},
|
||
|
||
_customerColorMap: {},
|
||
_customerColorIdx: 0,
|
||
_customerColors: [
|
||
'bg-blue-100 text-blue-700',
|
||
'bg-green-100 text-green-700',
|
||
'bg-purple-100 text-purple-700',
|
||
'bg-orange-100 text-orange-700',
|
||
'bg-pink-100 text-pink-700',
|
||
'bg-teal-100 text-teal-700',
|
||
'bg-yellow-100 text-yellow-800',
|
||
'bg-red-100 text-red-700',
|
||
'bg-indigo-100 text-indigo-700',
|
||
'bg-cyan-100 text-cyan-700',
|
||
],
|
||
customerTagClass(name) {
|
||
if (!name) return '';
|
||
if (!this._customerColorMap[name]) {
|
||
this._customerColorMap[name] = this._customerColors[this._customerColorIdx % this._customerColors.length];
|
||
this._customerColorIdx++;
|
||
}
|
||
return this._customerColorMap[name];
|
||
},
|
||
|
||
toggleSelectAll() {
|
||
const visible = this.filteredAccounts;
|
||
const allSelected = visible.every(a => this.selectedEmails.has(a.email)) && visible.length > 0;
|
||
if (allSelected) {
|
||
const newSet = new Set(this.selectedEmails);
|
||
visible.forEach(a => newSet.delete(a.email));
|
||
this.selectedEmails = newSet;
|
||
} else {
|
||
this.selectedEmails = new Set([...this.selectedEmails, ...visible.map(a => a.email)]);
|
||
}
|
||
},
|
||
|
||
selectCurrentPage() {
|
||
const pageEmails = this.pagedAccounts.map(account => account.email).filter(Boolean);
|
||
if (pageEmails.length === 0) return;
|
||
this.selectedEmails = new Set([...this.selectedEmails, ...pageEmails]);
|
||
},
|
||
|
||
deselectCurrentPage() {
|
||
const pageEmails = new Set(this.pagedAccounts.map(account => account.email).filter(Boolean));
|
||
if (pageEmails.size === 0) return;
|
||
this.selectedEmails = new Set(
|
||
[...this.selectedEmails].filter(email => !pageEmails.has(email)),
|
||
);
|
||
},
|
||
|
||
clearSelection() {
|
||
this.selectedEmails = new Set();
|
||
},
|
||
|
||
toggleSelect(email) {
|
||
const newSet = new Set(this.selectedEmails);
|
||
if (newSet.has(email)) newSet.delete(email);
|
||
else newSet.add(email);
|
||
this.selectedEmails = newSet;
|
||
},
|
||
|
||
isSelected(email) {
|
||
return this.selectedEmails.has(email);
|
||
},
|
||
|
||
// ---- 拖拽选择(账号) ----
|
||
startAccountDrag(event, account) {
|
||
if (event.target.closest('input, button, .copy-cell')) return;
|
||
event.preventDefault();
|
||
const mode = this.isSelected(account.email) ? 'deselect' : 'select';
|
||
this._dragState = { type: 'account', mode };
|
||
document.body.classList.add('drag-selecting');
|
||
const newSet = new Set(this.selectedEmails);
|
||
if (mode === 'select') newSet.add(account.email);
|
||
else newSet.delete(account.email);
|
||
this.selectedEmails = newSet;
|
||
},
|
||
|
||
onAccountDragOver(event, account) {
|
||
if (!this._dragState || this._dragState.type !== 'account') return;
|
||
const newSet = new Set(this.selectedEmails);
|
||
if (this._dragState.mode === 'select') newSet.add(account.email);
|
||
else newSet.delete(account.email);
|
||
this.selectedEmails = newSet;
|
||
},
|
||
|
||
async importAccounts() {
|
||
if (!this.importText.trim()) return;
|
||
try {
|
||
const res = await fetch('/api/accounts/import', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
text: this.importText,
|
||
template: this.getSelectedTemplatePattern(),
|
||
defaultStatus: this.importStatus,
|
||
customerName: this.importCustomerName.trim() || null,
|
||
skipDuplicates: this.importSkipDuplicates,
|
||
}),
|
||
});
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
const skippedText = data.skippedCount > 0 ? `,跳过重复 ${data.skippedCount} 个` : '';
|
||
this.addLog(`导入成功 ${data.successCount} 个${skippedText},失败 ${data.errorCount} 个`, 'success');
|
||
if (data.errors && data.errors.length > 0) {
|
||
data.errors.slice(0, 5).forEach(e => this.addLog(` ⚠️ ${e}`, 'warn'));
|
||
}
|
||
this.showImportAccountModal = false;
|
||
const importedEmails = data.importedEmails || [];
|
||
this.importText = '';
|
||
this.importCustomerName = '';
|
||
this.accountPage = 1;
|
||
await this.loadAccounts();
|
||
|
||
if (importedEmails.length > 0) {
|
||
this.addLog('账号已入库;执行具体任务时会自动创建临时比特浏览器,任务结束后自动删除', 'info');
|
||
}
|
||
} else {
|
||
this.addLog(`导入失败: ${data.msg}`, 'error');
|
||
}
|
||
} catch (e) {
|
||
this.addLog(`导入失败: ${e.message}`, 'error');
|
||
}
|
||
},
|
||
|
||
async deleteSelectedAccounts() {
|
||
if (this.selectedEmails.size === 0) return;
|
||
if (this.isDeletingAccounts) {
|
||
this.addLog('账号删除任务仍在进行中,请等待当前批次完成', 'warn');
|
||
return;
|
||
}
|
||
if (!confirm(
|
||
`确认删除选中的 ${this.selectedEmails.size} 个账号?\n\n`
|
||
+ '账号的浏览器编号会一并删除,并尝试删除对应的比特浏览器窗口。\n'
|
||
+ '家庭组母号和正在使用静态代理的窗口会保留。',
|
||
)) return;
|
||
const emails = [...this.selectedEmails];
|
||
this.isDeletingAccounts = true;
|
||
// 中文注释:先给出明确进度提示,避免大批量删除时用户误以为没有触发。
|
||
this.addLog(`开始删除 ${emails.length} 个账号,请稍候...`, 'info');
|
||
try {
|
||
const res = await fetch('/api/accounts', {
|
||
method: 'DELETE',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ emails }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok || !data.success) throw new Error(data.msg || '删除请求失败');
|
||
const browserCount = Number(data.browsersDeleted) || 0;
|
||
const browserFound = Number(data.browsersFound) || 0;
|
||
const browserFailed = Number(data.browserDeleteFailed) || 0;
|
||
if (browserFailed > 0) {
|
||
this.addLog(
|
||
`已删除 ${data.count} 个账号;比特浏览器窗口成功 ${browserCount} 个、失败 ${browserFailed} 个`,
|
||
'warn',
|
||
);
|
||
} else if (browserFound > 0) {
|
||
this.addLog(`已删除 ${data.count} 个账号,并删除 ${browserCount} 个比特浏览器窗口`, 'success');
|
||
} else {
|
||
this.addLog(`已删除 ${data.count} 个账号(没有需要删除的比特浏览器窗口)`, 'success');
|
||
}
|
||
this.selectedEmails = new Set();
|
||
await this.loadAccounts();
|
||
} catch (e) {
|
||
this.addLog(`删除失败: ${e.message}`, 'error');
|
||
} finally {
|
||
this.isDeletingAccounts = false;
|
||
}
|
||
},
|
||
|
||
async stageExportAccounts(accounts, selectedFields) {
|
||
const emails = accounts.map(account => account.email).filter(Boolean);
|
||
if (emails.length === 0) return false;
|
||
try {
|
||
const res = await fetch('/api/accounts/staging', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
emails,
|
||
note: this.exportStagingNote.trim(),
|
||
selectedFields,
|
||
deleteBrowserWindows: this.deleteBrowserWindowsOnStage,
|
||
}),
|
||
});
|
||
const data = await res.json();
|
||
if (!data.success) {
|
||
this.addLog(`放入暂存箱失败: ${data.msg || '未知错误'}`, 'error');
|
||
return false;
|
||
}
|
||
if (data.count === 0) {
|
||
this.addLog('⚠️ 没有账号被放入暂存箱,列表可能已经刷新', 'warn');
|
||
return false;
|
||
}
|
||
const stagedEmailSet = new Set(emails);
|
||
this.selectedEmails = new Set([...this.selectedEmails].filter(email => !stagedEmailSet.has(email)));
|
||
this.addLog(`已将 ${data.count} 个账号放入暂存箱`, 'success');
|
||
if (data.browserDeleteRequested) {
|
||
if (data.browsersFound === 0) {
|
||
this.addLog('暂存账号没有绑定比特浏览器窗口', 'info');
|
||
} else if (data.browserDeleteFailed > 0) {
|
||
this.addLog(`比特窗口删除完成: 成功 ${data.browsersDeleted} 个,失败 ${data.browserDeleteFailed} 个`, 'warn');
|
||
} else {
|
||
this.addLog(`已同时删除 ${data.browsersDeleted} 个比特浏览器窗口`, 'success');
|
||
}
|
||
}
|
||
await this.loadAccounts();
|
||
return true;
|
||
} catch (e) {
|
||
this.addLog(`放入暂存箱失败: ${e.message}`, 'error');
|
||
return false;
|
||
}
|
||
},
|
||
|
||
async openStagingModal() {
|
||
this.showStagingModal = true;
|
||
this.stagedBatchDetail = null;
|
||
await this.loadStagingBatches();
|
||
},
|
||
|
||
async loadStagingBatches() {
|
||
try {
|
||
const res = await fetch('/api/accounts/staging');
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
this.stagedBatches = Array.isArray(data.data) ? data.data : [];
|
||
} else {
|
||
this.addLog(`加载暂存箱失败: ${data.msg || '未知错误'}`, 'error');
|
||
}
|
||
} catch (e) {
|
||
this.addLog(`加载暂存箱失败: ${e.message}`, 'error');
|
||
}
|
||
},
|
||
|
||
getStagingSelectedFields(batch) {
|
||
if (!batch) return [];
|
||
let fields = batch.selected_fields;
|
||
if (typeof fields === 'string') {
|
||
try { fields = JSON.parse(fields); } catch (_) { fields = []; }
|
||
}
|
||
return Array.isArray(fields) ? fields : [];
|
||
},
|
||
|
||
getStagingFieldLabels(batch) {
|
||
const selected = new Set(this.getStagingSelectedFields(batch));
|
||
return this.exportFieldDefs.filter(field => selected.has(field.key)).map(field => field.label).join(' · ');
|
||
},
|
||
|
||
async openStagedBatch(batch) {
|
||
try {
|
||
const res = await fetch(`/api/accounts/staging/${batch.id}`);
|
||
const data = await res.json();
|
||
if (!data.success) {
|
||
this.addLog(`打开暂存文件夹失败: ${data.msg || '未知错误'}`, 'error');
|
||
return;
|
||
}
|
||
this.stagedBatchDetail = data.data;
|
||
} catch (e) {
|
||
this.addLog(`打开暂存文件夹失败: ${e.message}`, 'error');
|
||
}
|
||
},
|
||
|
||
get stagedBatchText() {
|
||
if (!this.stagedBatchDetail) return '';
|
||
const fields = this.getStagingSelectedFields(this.stagedBatchDetail.batch);
|
||
return this.stagedBatchDetail.accounts
|
||
.map(account => fields.map(field => this.exportFieldValue(account, field)).join('----'))
|
||
.join('\n');
|
||
},
|
||
|
||
async copyStagedBatch() {
|
||
if (!this.stagedBatchText) return;
|
||
try {
|
||
await navigator.clipboard.writeText(this.stagedBatchText);
|
||
} catch (e) {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = this.stagedBatchText;
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
}
|
||
this.addLog(`已复制 ${this.stagedBatchDetail.accounts.length} 个暂存账号`, 'success');
|
||
},
|
||
|
||
downloadStagedBatch() {
|
||
if (!this.stagedBatchDetail || !this.stagedBatchText) return;
|
||
const blob = new Blob([this.stagedBatchText], { type: 'text/plain;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
const safeName = this.stagedBatchDetail.batch.name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').slice(0, 80) || '暂存账号';
|
||
link.href = url;
|
||
link.download = `${safeName}.txt`;
|
||
link.click();
|
||
URL.revokeObjectURL(url);
|
||
this.addLog(`已导出 ${this.stagedBatchDetail.accounts.length} 个暂存账号到 TXT`, 'success');
|
||
},
|
||
|
||
async restoreStagedBatch() {
|
||
if (!this.stagedBatchDetail) return;
|
||
if (this.isRunning) {
|
||
this.addLog('⚠️ 当前有任务正在运行,请等待完成后再恢复账号', 'warn');
|
||
return;
|
||
}
|
||
const batch = this.stagedBatchDetail.batch;
|
||
try {
|
||
const res = await fetch(`/api/accounts/staging/${batch.id}/restore`, { method: 'POST' });
|
||
const data = await res.json();
|
||
if (!data.success) {
|
||
this.addLog(`恢复失败: ${data.msg || '未知错误'}`, 'error');
|
||
return;
|
||
}
|
||
this.addLog(`已从“${batch.name}”恢复 ${data.count} 个账号`, 'success');
|
||
this.stagedBatchDetail = null;
|
||
await Promise.all([this.loadStagingBatches(), this.loadAccounts()]);
|
||
const restoredAccounts = Array.isArray(data.accounts) ? data.accounts : [];
|
||
if (restoredAccounts.length > 0) {
|
||
this.addLog('恢复完成;执行具体任务时会自动创建临时比特浏览器', 'info');
|
||
}
|
||
} catch (e) {
|
||
this.addLog(`恢复失败: ${e.message}`, 'error');
|
||
}
|
||
},
|
||
|
||
async permanentlyDeleteStagedBatch() {
|
||
if (!this.stagedBatchDetail) return;
|
||
const batch = this.stagedBatchDetail.batch;
|
||
if (!confirm(`确认彻底删除文件夹“${batch.name}”中的 ${batch.account_count} 个账号记录?此操作不会删除浏览器窗口。`)) return;
|
||
try {
|
||
const res = await fetch(`/api/accounts/staging/${batch.id}`, { method: 'DELETE' });
|
||
const data = await res.json();
|
||
if (!data.success) {
|
||
this.addLog(`彻底删除失败: ${data.msg || '未知错误'}`, 'error');
|
||
return;
|
||
}
|
||
this.addLog(`已彻底删除文件夹“${batch.name}”及 ${data.count} 个账号记录`, 'success');
|
||
this.stagedBatchDetail = null;
|
||
await this.loadStagingBatches();
|
||
} catch (e) {
|
||
this.addLog(`彻底删除失败: ${e.message}`, 'error');
|
||
}
|
||
},
|
||
|
||
|
||
async setAccountTag(account, tag) {
|
||
try {
|
||
await fetch(`/api/accounts/${encodeURIComponent(account.email)}/tag`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tag }),
|
||
});
|
||
account.tag = tag || null;
|
||
} catch (e) {
|
||
this.addLog(`设置标签失败: ${e.message}`, 'error');
|
||
}
|
||
},
|
||
|
||
async batchSetFamilyTag(setOrClear) {
|
||
const emails = [...this.selectedEmails];
|
||
if (emails.length === 0) { this.addLog('⚠️ 未选择账号', 'warn'); return; }
|
||
const tag = setOrClear ? '家庭组母号' : null;
|
||
for (const email of emails) {
|
||
try {
|
||
await fetch(`/api/accounts/${encodeURIComponent(email)}/tag`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tag }),
|
||
});
|
||
const acc = this._accountByEmail.get(email);
|
||
if (acc) acc.tag = tag;
|
||
} catch (_) {}
|
||
}
|
||
this.addLog(`${setOrClear ? '设置' : '清除'}家庭组母号标签: ${emails.length} 个账号`, 'info');
|
||
},
|
||
|
||
|
||
|
||
// ==================== 设置管理 ====================
|
||
async loadSettings() {
|
||
try {
|
||
const res = await fetch('/api/settings');
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
this.settings = data.data;
|
||
if (!this.settings.feishu_notify_mode) this.settings.feishu_notify_mode = 'none';
|
||
if (!this.settings.feishu_notify_receive_id_type) this.settings.feishu_notify_receive_id_type = 'email';
|
||
if (!this.settings.quota_monitor_enabled) this.settings.quota_monitor_enabled = '0';
|
||
if (!this.settings.quota_monitor_interval_value) this.settings.quota_monitor_interval_value = '24';
|
||
if (!this.settings.quota_monitor_interval_unit) this.settings.quota_monitor_interval_unit = 'hours';
|
||
if (!this.settings.quota_monitor_concurrency) this.settings.quota_monitor_concurrency = '2';
|
||
if (!this.settings.quota_monitor_threshold) this.settings.quota_monitor_threshold = '100';
|
||
if (!this.settings.quota_monitor_skip_401_enabled) this.settings.quota_monitor_skip_401_enabled = '0';
|
||
if (!this.settings.quota_monitor_auto_refund_enabled) this.settings.quota_monitor_auto_refund_enabled = '0';
|
||
if (!this.settings.refund_deadline_check_interval_seconds) this.settings.refund_deadline_check_interval_seconds = '15';
|
||
if (this.settings.default_thread_count) {
|
||
this.threadCount = parseInt(this.settings.default_thread_count) || 1;
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
},
|
||
|
||
async saveSetting(key, value) {
|
||
try {
|
||
await fetch(`/api/settings/${key}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ value }),
|
||
});
|
||
this.settings[key] = value;
|
||
this.addLog(`设置已保存: ${key}`, 'info');
|
||
} catch (e) {
|
||
this.addLog(`保存设置失败: ${e.message}`, 'error');
|
||
}
|
||
},
|
||
|
||
async loadRechargeCredentialMeta() {
|
||
try {
|
||
const res = await fetch('/api/recharge-credentials', { cache: 'no-store' });
|
||
const data = await res.json();
|
||
if (!res.ok || !data.success) throw new Error(data.msg || '读取失败');
|
||
this.rechargeCredentialMeta = {
|
||
configured: !!(data.data && data.data.configured),
|
||
keyIdMasked: String((data.data && data.data.keyIdMasked) || ''),
|
||
updatedAt: (data.data && data.data.updatedAt) || null,
|
||
};
|
||
} catch (_) {
|
||
this.rechargeCredentialMeta = { configured: false, keyIdMasked: '', updatedAt: null };
|
||
}
|
||
},
|
||
|
||
async saveRechargeCredentials() {
|
||
if (this.rechargeCredentialSaving) return;
|
||
const keyId = String(this.rechargeCredentialForm.keyId || '').trim();
|
||
const secret = String(this.rechargeCredentialForm.secret || '');
|
||
if (!keyId || !secret.trim()) {
|
||
this.addLog('⚠️ API Key ID 和 API Secret 都不能为空', 'warn');
|
||
return;
|
||
}
|
||
this.rechargeCredentialSaving = true;
|
||
try {
|
||
const res = await fetch('/api/recharge-credentials', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ keyId, secret }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok || !data.success) throw new Error(data.msg || '保存失败');
|
||
this.rechargeCredentialMeta = {
|
||
configured: !!(data.data && data.data.configured),
|
||
keyIdMasked: String((data.data && data.data.keyIdMasked) || ''),
|
||
updatedAt: (data.data && data.data.updatedAt) || null,
|
||
};
|
||
// Secret 从不保存在 Alpine 的全局 settings 中,保存后立即清空表单。
|
||
this.rechargeCredentialForm = { keyId: '', secret: '' };
|
||
this.addLog('✅ 充值 API 凭据已加密保存', 'success');
|
||
} catch (e) {
|
||
this.addLog(`❌ 保存充值 API 凭据失败: ${e.message}`, 'error');
|
||
} finally {
|
||
this.rechargeCredentialSaving = false;
|
||
}
|
||
},
|
||
|
||
async deleteRechargeCredentials() {
|
||
if (this.rechargeCredentialDeleting || !this.rechargeCredentialMeta.configured) return;
|
||
if (!confirm('确认清除已保存的充值 API Key ID 和 API Secret?')) return;
|
||
this.rechargeCredentialDeleting = true;
|
||
try {
|
||
const res = await fetch('/api/recharge-credentials', { method: 'DELETE' });
|
||
const data = await res.json();
|
||
if (!res.ok || !data.success) throw new Error(data.msg || '清除失败');
|
||
this.rechargeCredentialMeta = { configured: false, keyIdMasked: '', updatedAt: null };
|
||
this.rechargeCredentialForm = { keyId: '', secret: '' };
|
||
this.addLog('✅ 充值 API 凭据已清除', 'success');
|
||
} catch (e) {
|
||
this.addLog(`❌ 清除充值 API 凭据失败: ${e.message}`, 'error');
|
||
} finally {
|
||
this.rechargeCredentialDeleting = false;
|
||
}
|
||
},
|
||
|
||
async loadWorkspaceRefundCredentialMeta() {
|
||
try {
|
||
const res = await fetch('/api/workspace-refund-credentials', { cache: 'no-store' });
|
||
const data = await res.json();
|
||
if (!res.ok || !data.success) throw new Error(data.msg || '读取失败');
|
||
this.workspaceRefundCredentialMeta = {
|
||
configured: !!(data.data && data.data.configured),
|
||
ownerMasked: String((data.data && data.data.ownerMasked) || ''),
|
||
proxyConfigured: !!(data.data && data.data.proxyConfigured),
|
||
proxyCount: Number((data.data && data.data.proxyCount) || 0),
|
||
proxyMasked: String((data.data && data.data.proxyMasked) || ''),
|
||
updatedAt: (data.data && data.data.updatedAt) || null,
|
||
};
|
||
} catch (_) {
|
||
this.workspaceRefundCredentialMeta = {
|
||
configured: false,
|
||
ownerMasked: '',
|
||
proxyConfigured: false,
|
||
proxyCount: 0,
|
||
proxyMasked: '',
|
||
updatedAt: null,
|
||
};
|
||
}
|
||
},
|
||
|
||
async saveWorkspaceRefundCredentials() {
|
||
if (this.workspaceRefundCredentialSaving) return;
|
||
const value = String(this.workspaceRefundCredentialForm.value || '').trim();
|
||
const proxy = String(this.workspaceRefundCredentialForm.proxy || '').trim();
|
||
if (!value && !this.workspaceRefundCredentialMeta.configured) {
|
||
this.addLog('⚠️ 请填写母号 ChatGPT Session 或 Access Token', 'warn');
|
||
return;
|
||
}
|
||
if (!proxy && !this.workspaceRefundCredentialMeta.proxyConfigured) {
|
||
this.addLog('⚠️ 请填写退款专用代理', 'warn');
|
||
return;
|
||
}
|
||
if (!value && !proxy) {
|
||
this.addLog('⚠️ 请填写需要更新的母号登录内容或退款专用代理', 'warn');
|
||
return;
|
||
}
|
||
this.workspaceRefundCredentialSaving = true;
|
||
try {
|
||
const body = { value };
|
||
// 已保存代理时输入框留空代表保留原代理,不会意外清除。
|
||
if (proxy) body.proxy = proxy;
|
||
const res = await fetch('/api/workspace-refund-credentials', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok || !data.success) throw new Error(data.msg || '保存失败');
|
||
this.workspaceRefundCredentialMeta = {
|
||
configured: !!(data.data && data.data.configured),
|
||
ownerMasked: String((data.data && data.data.ownerMasked) || ''),
|
||
proxyConfigured: !!(data.data && data.data.proxyConfigured),
|
||
proxyCount: Number((data.data && data.data.proxyCount) || 0),
|
||
proxyMasked: String((data.data && data.data.proxyMasked) || ''),
|
||
updatedAt: (data.data && data.data.updatedAt) || null,
|
||
};
|
||
this.workspaceRefundCredentialForm = { value: '', proxy: '' };
|
||
this.addLog('✅ 退款母号和专用代理已加密保存', 'success');
|
||
} catch (e) {
|
||
this.addLog(`❌ 保存退款母号失败: ${e.message}`, 'error');
|
||
} finally {
|
||
this.workspaceRefundCredentialSaving = false;
|
||
}
|
||
},
|
||
|
||
async deleteWorkspaceRefundCredentials() {
|
||
if (this.workspaceRefundCredentialDeleting || !this.workspaceRefundCredentialMeta.configured) return;
|
||
if (!confirm('确认清除已保存的退款母号?')) return;
|
||
this.workspaceRefundCredentialDeleting = true;
|
||
try {
|
||
const res = await fetch('/api/workspace-refund-credentials', { method: 'DELETE' });
|
||
const data = await res.json();
|
||
if (!res.ok || !data.success) throw new Error(data.msg || '清除失败');
|
||
this.workspaceRefundCredentialMeta = {
|
||
configured: false,
|
||
ownerMasked: '',
|
||
proxyConfigured: false,
|
||
proxyCount: 0,
|
||
proxyMasked: '',
|
||
updatedAt: null,
|
||
};
|
||
this.workspaceRefundCredentialForm = { value: '', proxy: '' };
|
||
this.addLog('✅ 退款母号已清除', 'success');
|
||
} catch (e) {
|
||
this.addLog(`❌ 清除退款母号失败: ${e.message}`, 'error');
|
||
} finally {
|
||
this.workspaceRefundCredentialDeleting = false;
|
||
}
|
||
},
|
||
|
||
async loadGoogleAccountCredentialMeta() {
|
||
try {
|
||
const res = await fetch('/api/google-account-credentials', { cache: 'no-store' });
|
||
const data = await res.json();
|
||
if (!res.ok || !data.success) throw new Error(data.msg || '读取失败');
|
||
this.googleAccountCredentialMeta = {
|
||
configured: !!(data.data && data.data.configured),
|
||
keyMasked: String((data.data && data.data.keyMasked) || ''),
|
||
apiUrl: String((data.data && data.data.apiUrl) || 'https://sv9.api999api.com/google/apiv2.php'),
|
||
timelive: Number((data.data && data.data.timelive) || 60),
|
||
updatedAt: (data.data && data.data.updatedAt) || null,
|
||
};
|
||
this.googleAccountCredentialForm.timelive = this.googleAccountCredentialMeta.timelive;
|
||
} catch (_) {
|
||
this.googleAccountCredentialMeta = { configured: false, keyMasked: '', apiUrl: 'https://sv9.api999api.com/google/apiv2.php', timelive: 60, updatedAt: null };
|
||
this.googleAccountCredentialForm.timelive = 60;
|
||
}
|
||
},
|
||
|
||
async saveGoogleAccountCredentials() {
|
||
if (this.googleAccountCredentialSaving) return;
|
||
const value = String(this.googleAccountCredentialForm.value || '').trim();
|
||
const timelive = Math.max(1, Math.min(43200, Number.parseInt(this.googleAccountCredentialForm.timelive, 10) || 60));
|
||
if (!value && !this.googleAccountCredentialMeta.configured) {
|
||
this.addLog('⚠️ 请填写取号 API Key 或完整链接', 'warn');
|
||
return;
|
||
}
|
||
this.googleAccountCredentialSaving = true;
|
||
try {
|
||
const res = await fetch('/api/google-account-credentials', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ value, timelive }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok || !data.success) throw new Error(data.msg || '保存失败');
|
||
this.googleAccountCredentialMeta = {
|
||
configured: !!(data.data && data.data.configured),
|
||
keyMasked: String((data.data && data.data.keyMasked) || ''),
|
||
apiUrl: String((data.data && data.data.apiUrl) || 'https://sv9.api999api.com/google/apiv2.php'),
|
||
timelive: Number((data.data && data.data.timelive) || timelive),
|
||
updatedAt: (data.data && data.data.updatedAt) || null,
|
||
};
|
||
this.googleAccountCredentialForm = { value: '', timelive: this.googleAccountCredentialMeta.timelive };
|
||
this.addLog('✅ 取号 API 凭据已加密保存', 'success');
|
||
} catch (e) {
|
||
this.addLog(`❌ 保存取号 API 凭据失败: ${e.message}`, 'error');
|
||
} finally {
|
||
this.googleAccountCredentialSaving = false;
|
||
}
|
||
},
|
||
|
||
async deleteGoogleAccountCredentials() {
|
||
if (this.googleAccountCredentialDeleting || !this.googleAccountCredentialMeta.configured) return;
|
||
if (!confirm('确认清除已保存的取号 API 凭据?')) return;
|
||
this.googleAccountCredentialDeleting = true;
|
||
try {
|
||
const res = await fetch('/api/google-account-credentials', { method: 'DELETE' });
|
||
const data = await res.json();
|
||
if (!res.ok || !data.success) throw new Error(data.msg || '清除失败');
|
||
this.googleAccountCredentialMeta = { configured: false, keyMasked: '', apiUrl: 'https://sv9.api999api.com/google/apiv2.php', timelive: 60, updatedAt: null };
|
||
this.googleAccountCredentialForm = { value: '', timelive: 60 };
|
||
this.addLog('✅ 取号 API 凭据已清除', 'success');
|
||
} catch (e) {
|
||
this.addLog(`❌ 清除取号 API 凭据失败: ${e.message}`, 'error');
|
||
} finally {
|
||
this.googleAccountCredentialDeleting = false;
|
||
}
|
||
},
|
||
|
||
async loadFeishuCredentialMeta() {
|
||
try {
|
||
const res = await fetch('/api/feishu/credentials', { cache: 'no-store' });
|
||
const data = await res.json();
|
||
if (!res.ok || !data.success) throw new Error(data.msg || '读取失败');
|
||
this.feishuCredentialMeta = {
|
||
configured: !!(data.data && data.data.configured),
|
||
appIdMasked: String((data.data && data.data.appIdMasked) || ''),
|
||
updatedAt: (data.data && data.data.updatedAt) || null,
|
||
};
|
||
} catch (_) {
|
||
this.feishuCredentialMeta = { configured: false, appIdMasked: '', updatedAt: null };
|
||
}
|
||
},
|
||
|
||
async saveFeishuCredentials() {
|
||
if (this.feishuCredentialSaving) return;
|
||
const appId = String(this.feishuCredentialForm.appId || '').trim();
|
||
const appSecret = String(this.feishuCredentialForm.appSecret || '');
|
||
if (!appId || !appSecret.trim()) {
|
||
this.addLog('⚠️ 飞书 App ID 和 App Secret 都不能为空', 'warn');
|
||
return;
|
||
}
|
||
this.feishuCredentialSaving = true;
|
||
try {
|
||
const res = await fetch('/api/feishu/credentials', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ appId, appSecret }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok || !data.success) throw new Error(data.msg || '保存失败');
|
||
this.feishuCredentialMeta = {
|
||
configured: !!(data.data && data.data.configured),
|
||
appIdMasked: String((data.data && data.data.appIdMasked) || ''),
|
||
updatedAt: (data.data && data.data.updatedAt) || null,
|
||
};
|
||
this.feishuCredentialForm = { appId: '', appSecret: '' };
|
||
this.addLog('✅ 飞书应用凭据已在本机加密保存', 'success');
|
||
} catch (e) {
|
||
this.addLog(`❌ 保存飞书应用凭据失败:${e.message}`, 'error');
|
||
} finally {
|
||
this.feishuCredentialSaving = false;
|
||
}
|
||
},
|
||
|
||
async deleteFeishuCredentials() {
|
||
if (this.feishuCredentialDeleting || !this.feishuCredentialMeta.configured) return;
|
||
if (!confirm('确认清除已保存的飞书 App ID 和 App Secret?')) return;
|
||
this.feishuCredentialDeleting = true;
|
||
try {
|
||
const res = await fetch('/api/feishu/credentials', { method: 'DELETE' });
|
||
const data = await res.json();
|
||
if (!res.ok || !data.success) throw new Error(data.msg || '清除失败');
|
||
this.feishuCredentialMeta = { configured: false, appIdMasked: '', updatedAt: null };
|
||
this.feishuCredentialForm = { appId: '', appSecret: '' };
|
||
this.addLog('✅ 飞书应用凭据已清除', 'success');
|
||
} catch (e) {
|
||
this.addLog(`❌ 清除飞书应用凭据失败:${e.message}`, 'error');
|
||
} finally {
|
||
this.feishuCredentialDeleting = false;
|
||
}
|
||
},
|
||
|
||
async openCustomErrors() {
|
||
this.showCustomErrorsModal = true;
|
||
await this.loadCustomErrorRules();
|
||
},
|
||
|
||
async loadCustomErrorRules() {
|
||
try {
|
||
const res = await fetch('/api/settings/custom_error_rules');
|
||
const json = await res.json();
|
||
const raw = json && json.success ? (json.value || '[]') : '[]';
|
||
let arr = [];
|
||
try { arr = JSON.parse(raw || '[]'); } catch (e) { arr = []; }
|
||
if (!Array.isArray(arr)) arr = [];
|
||
this.customErrorRules = arr.map(r => {
|
||
if (!r) return r;
|
||
const taskTypes = Array.isArray(r.taskTypes) ? r.taskTypes : (r.taskType ? [r.taskType] : []);
|
||
return { ...r, taskTypes };
|
||
});
|
||
} catch (e) {
|
||
this.customErrorRules = [];
|
||
}
|
||
},
|
||
|
||
async saveCustomErrorRules() {
|
||
const v = JSON.stringify(this.customErrorRules || []);
|
||
await this.saveSetting('custom_error_rules', v);
|
||
},
|
||
|
||
async addCustomErrorRule() {
|
||
const taskTypes = (this.customErrorNewTasks || []).map(s => String(s || '').trim()).filter(Boolean);
|
||
const matchText = String(this.customErrorNewText || '').replace(/\s+/g, ' ').trim();
|
||
const tip = String(this.customErrorNewTip || '').replace(/\s+/g, ' ').trim();
|
||
if (taskTypes.length === 0 || !matchText || !tip) {
|
||
this.addLog('自定义错误添加失败:任务类型/匹配文本/提示不能为空', 'error');
|
||
return;
|
||
}
|
||
const id = `${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
|
||
this.customErrorRules = [...(this.customErrorRules || []), { id, taskTypes, matchText, tip }];
|
||
this.customErrorNewText = '';
|
||
this.customErrorNewTip = '';
|
||
await this.saveCustomErrorRules();
|
||
await this.loadCustomErrorRules();
|
||
},
|
||
|
||
async removeCustomErrorRule(id) {
|
||
const s = String(id || '');
|
||
this.customErrorRules = (this.customErrorRules || []).filter(r => String(r && r.id) !== s);
|
||
await this.saveCustomErrorRules();
|
||
await this.loadCustomErrorRules();
|
||
},
|
||
|
||
|
||
|
||
// ==================== 诊断日志页 ====================
|
||
|
||
async loadDiagAccounts() {
|
||
try {
|
||
const res = await fetch('/api/diagnostics/accounts');
|
||
const json = await res.json();
|
||
if (json.success) {
|
||
this.diagAccounts = json.data || [];
|
||
if (this.diagEmail && !this.diagAccounts.includes(this.diagEmail)) this.diagEmail = '';
|
||
}
|
||
} catch (e) {}
|
||
},
|
||
|
||
filteredDiagAccounts() {
|
||
const q = (this.diagEmailSearch || '').trim().toLowerCase();
|
||
if (!q) return this.diagAccounts;
|
||
return this.diagAccounts.filter(e => String(e || '').toLowerCase().includes(q));
|
||
},
|
||
|
||
async onDiagEmailChange() {
|
||
this.diagSelected = null;
|
||
this.diagOffset = 0;
|
||
await this.loadDiagDates();
|
||
await this.loadDiagEvents();
|
||
},
|
||
|
||
async loadDiagDates() {
|
||
if (!this.diagEmail) {
|
||
this.diagDates = [];
|
||
this.diagDate = '';
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch(`/api/diagnostics/dates?email=${encodeURIComponent(this.diagEmail)}`);
|
||
const json = await res.json();
|
||
if (json.success) {
|
||
this.diagDates = json.data || [];
|
||
if (!this.diagDate || !this.diagDates.includes(this.diagDate)) {
|
||
this.diagDate = this.diagDates[0] || '';
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
},
|
||
|
||
async loadDiagEvents() {
|
||
if (!this.diagEmail || !this.diagDate) return;
|
||
this.diagLoading = true;
|
||
this.diagSelected = null;
|
||
try {
|
||
const qs = new URLSearchParams({
|
||
email: this.diagEmail,
|
||
date: this.diagDate,
|
||
offset: String(this.diagOffset || 0),
|
||
limit: String(this.diagLimit || 200),
|
||
});
|
||
const res = await fetch(`/api/diagnostics/events?${qs.toString()}`);
|
||
const json = await res.json();
|
||
if (json.success) {
|
||
this.diagTotal = json.data.total || 0;
|
||
this.diagEvents = json.data.events || [];
|
||
} else {
|
||
this.addLog(`加载诊断日志失败: ${json.msg}`, 'error');
|
||
}
|
||
} catch (e) {
|
||
this.addLog(`加载诊断日志失败: ${e.message}`, 'error');
|
||
} finally {
|
||
this.diagLoading = false;
|
||
}
|
||
},
|
||
|
||
diagPrevPage() {
|
||
if (this.diagOffset <= 0) return;
|
||
this.diagOffset = Math.max(0, this.diagOffset - this.diagLimit);
|
||
this.loadDiagEvents();
|
||
},
|
||
|
||
diagNextPage() {
|
||
if ((this.diagOffset + this.diagLimit) >= this.diagTotal) return;
|
||
this.diagOffset = this.diagOffset + this.diagLimit;
|
||
this.loadDiagEvents();
|
||
},
|
||
|
||
filteredDiagEvents() {
|
||
let list = this.diagEvents || [];
|
||
const t = (this.diagTypeFilter || '').trim();
|
||
const task = (this.diagTaskFilter || '').trim();
|
||
const kw = (this.diagSearch || '').trim().toLowerCase();
|
||
const quick = (this.diagQuickFilter || '').trim();
|
||
|
||
if (quick === 'errors') {
|
||
const keep = new Set(['error', 'exception', 'requestfailed', 'response', 'console', 'pageerror']);
|
||
list = list.filter(e => keep.has(e.type));
|
||
} else if (quick === 'checkpoint') {
|
||
list = list.filter(e => e.type === 'checkpoint');
|
||
} else if (quick === 'network') {
|
||
const netRe = /ERR_PROXY|ERR_TUNNEL|ERR_TIMED_OUT|ERR_NAME_NOT_RESOLVED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|proxy|tunnel|timed/i;
|
||
list = list.filter(e => {
|
||
if (e.type === 'requestfailed' || e.type === 'response') return true;
|
||
const text = [e.message, e.url].filter(Boolean).join(' ');
|
||
return netRe.test(text);
|
||
});
|
||
}
|
||
if (t) list = list.filter(e => (e.type || '') === t);
|
||
if (task) list = list.filter(e => (e.taskType || '') === task);
|
||
if (kw) {
|
||
list = list.filter(e => {
|
||
const hay = [
|
||
e.message, e.url, e.step, e.phase, e.name, e.type, e.taskType,
|
||
].filter(Boolean).join(' ').toLowerCase();
|
||
return hay.includes(kw);
|
||
});
|
||
}
|
||
return list;
|
||
},
|
||
|
||
diagFinalReason() {
|
||
const list = this.filteredDiagEvents() || [];
|
||
for (let i = list.length - 1; i >= 0; i--) {
|
||
const t = list[i] && list[i].type;
|
||
if (t === 'exception' || t === 'error' || t === 'pageerror' || t === 'requestfailed' || t === 'response' || t === 'console') {
|
||
return list[i];
|
||
}
|
||
}
|
||
return null;
|
||
},
|
||
|
||
diagFinalHint() {
|
||
const ev = this.diagFinalReason();
|
||
if (!ev) return '';
|
||
const text = [ev.message, ev.url].filter(Boolean).join(' ');
|
||
const t = ev.type || '';
|
||
if (t === 'requestfailed' || /ERR_PROXY|ERR_TUNNEL|proxy/i.test(text)) return '优先检查VPN/上游代理端口与代理是否可用,必要时先换代理再重试。';
|
||
if (/recaptcha|人机验证|robot/i.test(text)) return '触发人机验证,建议手动完成验证后再重试。';
|
||
if (/手机号|phone|iap/i.test(text)) return '需要手机验证,自动流程无法完成,建议手动处理。';
|
||
if (/找不到|未找到|waiting for selector|timeout|超时/i.test(text)) return '页面结构变化或加载过慢,建议更换网络/代理后重试,并结合截图确认页面元素。';
|
||
if (/验证码|wrong code|invalid code|try again/i.test(text)) return '验证码校验失败,检查2FA密钥是否正确或验证码是否过期。';
|
||
if (/密码错误|wrong password|incorrect password/i.test(text)) return '密码可能不正确,请核对后重试。';
|
||
return '结合错误事件详情与截图进一步定位。';
|
||
},
|
||
|
||
diagTypeLabel(t) {
|
||
const map = {
|
||
error: '错误',
|
||
exception: '异常',
|
||
requestfailed: '请求失败',
|
||
response: '响应异常',
|
||
console: '控制台错误',
|
||
pageerror: '页面异常',
|
||
checkpoint: '关键节点',
|
||
navigate: '页面跳转',
|
||
};
|
||
return map[t] || (t || '-');
|
||
},
|
||
|
||
diagTypeClass(t) {
|
||
const map = {
|
||
error: 'bg-red-100 text-red-700',
|
||
exception: 'bg-red-100 text-red-700',
|
||
requestfailed: 'bg-orange-100 text-orange-700',
|
||
response: 'bg-yellow-100 text-yellow-700',
|
||
console: 'bg-rose-100 text-rose-700',
|
||
pageerror: 'bg-rose-100 text-rose-700',
|
||
checkpoint: 'bg-blue-100 text-blue-700',
|
||
navigate: 'bg-gray-100 text-gray-700',
|
||
};
|
||
return map[t] || 'bg-gray-100 text-gray-700';
|
||
},
|
||
|
||
diagTaskLabel(t) {
|
||
const map = {
|
||
login: '登录',
|
||
change_2fa: '更改2FA',
|
||
change_password: '更改密码',
|
||
chatgpt_register: 'ChatGPT注册/登录',
|
||
openai_get_auth: '提取授权JSON',
|
||
openai_query_plan: '查询套餐',
|
||
refund_query: '查询退款',
|
||
workspace_refund: '退款',
|
||
security_chatgpt: '改密+2FA',
|
||
change_country: '更改国家',
|
||
ag_get_credential: '获取凭证',
|
||
gemini_cli_get_credential: '获取Cli凭证',
|
||
google_account_ag_credential: '链接提取+获取凭证',
|
||
ag_revoke_authorization: '取消授权反重力',
|
||
};
|
||
return map[t] || (t || '-');
|
||
},
|
||
|
||
selectDiagEvent(ev) {
|
||
this.diagSelected = ev || null;
|
||
},
|
||
|
||
formatDiagTs(ts) {
|
||
try {
|
||
if (!ts) return '-';
|
||
const d = new Date(ts);
|
||
if (Number.isNaN(d.getTime())) return String(ts);
|
||
return d.toLocaleString();
|
||
} catch (_) {
|
||
return ts || '-';
|
||
}
|
||
},
|
||
|
||
diagSummary(ev) {
|
||
if (!ev) return '-';
|
||
if (ev.type === 'checkpoint') return ev.name || '关键节点';
|
||
if (ev.type === 'requestfailed') {
|
||
const m = ev.meta || {};
|
||
const left = [m.method, m.resourceType].filter(Boolean).join(' ');
|
||
const msg = ev.message || '请求失败';
|
||
return left ? `请求失败:${left} · ${msg}` : `请求失败:${msg}`;
|
||
}
|
||
if (ev.type === 'response') {
|
||
const m = ev.meta || {};
|
||
const s = m.status ? `HTTP ${m.status}` : (ev.message || '响应异常');
|
||
return `响应异常:${s}`;
|
||
}
|
||
if (ev.type === 'console') return `控制台错误:${ev.message || '-'}`;
|
||
if (ev.type === 'pageerror') return `页面异常:${ev.message || '-'}`;
|
||
if (ev.type === 'exception') return `异常:${ev.message || '-'}`;
|
||
if (ev.type === 'error') return `错误:${ev.message || '-'}`;
|
||
if (ev.type === 'navigate') return `跳转:${ev.url || '-'}`;
|
||
return ev.message || ev.url || '-';
|
||
},
|
||
|
||
viewScreenshotPath(screenshotPath, email) {
|
||
if (!screenshotPath) return;
|
||
this.screenshotUrl = '/' + screenshotPath + '?t=' + Date.now();
|
||
this.screenshotEmail = email || '';
|
||
this.showScreenshotModal = true;
|
||
},
|
||
|
||
|
||
// ==================== 导入模板管理 ====================
|
||
|
||
async loadImportTemplates() {
|
||
const fiveFieldTemplate = {
|
||
name: '默认5字段(----)',
|
||
pattern: '{{账号}}----{{密码}}----{{辅助邮箱}}----{{2fa}}----{{rt凭证}}',
|
||
};
|
||
const sixFieldTemplate = {
|
||
name: '默认6字段(----)',
|
||
pattern: '{{账号}}----{{密码}}----{{辅助邮箱}}----{{2fa}}----{{接码链接}}----{{rt凭证}}',
|
||
};
|
||
const raw = this.settings.import_templates;
|
||
if (raw) {
|
||
try {
|
||
this.importTemplates = JSON.parse(raw);
|
||
} catch (e) {
|
||
this.importTemplates = [];
|
||
}
|
||
}
|
||
if (!this.importTemplates || this.importTemplates.length === 0) {
|
||
this.importTemplates = [
|
||
fiveFieldTemplate,
|
||
sixFieldTemplate,
|
||
{ name: '默认4字段(----)', pattern: '{{账号}}----{{密码}}----{{辅助邮箱}}----{{2fa}}' },
|
||
{ name: '账号密码(----)', pattern: '{{账号}}----{{密码}}' },
|
||
];
|
||
} else {
|
||
// 已有用户配置也补入标准模板,确保升级后无需清理历史设置。
|
||
if (!this.importTemplates.some(tpl => tpl && tpl.pattern === sixFieldTemplate.pattern)) {
|
||
this.importTemplates.unshift(sixFieldTemplate);
|
||
}
|
||
if (!this.importTemplates.some(tpl => tpl && tpl.pattern === fiveFieldTemplate.pattern)) {
|
||
this.importTemplates.unshift(fiveFieldTemplate);
|
||
}
|
||
}
|
||
if (this.settings.import_templates !== JSON.stringify(this.importTemplates)) {
|
||
await this.saveImportTemplates();
|
||
}
|
||
this.importSelectedIndex = 0;
|
||
},
|
||
|
||
async saveImportTemplates() {
|
||
const json = JSON.stringify(this.importTemplates);
|
||
await this.saveSetting('import_templates', json);
|
||
},
|
||
|
||
async addImportTemplate() {
|
||
const name = this.importNewTemplateName.trim();
|
||
const pattern = this.importNewTemplatePattern.trim();
|
||
if (!name || !pattern) {
|
||
this.addLog('模板名称和格式不能为空', 'warn');
|
||
return;
|
||
}
|
||
if (!pattern.includes('{{账号}}')) {
|
||
this.addLog('模板必须包含 {{账号}} 字段', 'warn');
|
||
return;
|
||
}
|
||
this.importTemplates.push({ name, pattern });
|
||
await this.saveImportTemplates();
|
||
this.importSelectedIndex = this.importTemplates.length - 1;
|
||
this.importNewTemplateName = '';
|
||
this.importNewTemplatePattern = '';
|
||
this.showTemplateEditor = false;
|
||
this.addLog(`模板已保存: ${name}`, 'info');
|
||
},
|
||
|
||
async deleteImportTemplate(index) {
|
||
if (this.importTemplates.length <= 1) {
|
||
this.addLog('至少保留一个模板', 'warn');
|
||
return;
|
||
}
|
||
const removed = this.importTemplates.splice(index, 1);
|
||
await this.saveImportTemplates();
|
||
if (this.importSelectedIndex >= this.importTemplates.length) {
|
||
this.importSelectedIndex = this.importTemplates.length - 1;
|
||
}
|
||
this.addLog(`模板已删除: ${removed[0].name}`, 'info');
|
||
},
|
||
|
||
getSelectedTemplatePattern() {
|
||
// 新建模板编辑器展开时,优先用正在编辑的 pattern 做预览
|
||
if (this.showTemplateEditor && this.importNewTemplatePattern.trim()) {
|
||
return this.importNewTemplatePattern.trim();
|
||
}
|
||
if (this.importTemplates.length === 0) return null;
|
||
return this.importTemplates[this.importSelectedIndex]?.pattern || null;
|
||
},
|
||
|
||
getTemplateHint() {
|
||
const pattern = this.getSelectedTemplatePattern();
|
||
if (!pattern) return '';
|
||
return pattern
|
||
.replace(/\{\{账号\}\}/g, 'xxx@gmail.com')
|
||
.replace(/\{\{密码\}\}/g, 'password123')
|
||
.replace(/\{\{辅助邮箱\}\}/g, 'recovery@outlook.com')
|
||
.replace(/\{\{2fa\}\}/g, 'JBSWY3DPEHPK3PXP')
|
||
.replace(/\{\{接码(?:地址|链接)\}\}/g, 'https://sms.example/link')
|
||
.replace(/\{\{(?:rt|RT|AG)凭证\}\}/g, '1//refresh-token');
|
||
},
|
||
|
||
/**
|
||
* 用当前模板解析第1行输入,返回预览数据(跳过 {{无用}} 列)
|
||
* 返回: [ [{label, value, ok}, ...] ]
|
||
*/
|
||
getImportPreview() {
|
||
const pattern = this.getSelectedTemplatePattern();
|
||
if (!pattern || !this.importText.trim()) return [];
|
||
|
||
const fieldRegex = /\{\{(.+?)\}\}/g;
|
||
const fieldKeys = [];
|
||
let m;
|
||
while ((m = fieldRegex.exec(pattern)) !== null) fieldKeys.push(m[1]);
|
||
const separators = pattern.split(/\{\{.+?\}\}/);
|
||
|
||
const line = this.importText.trim().split('\n').find(l => l.trim() && !l.trim().startsWith('#'));
|
||
if (!line) return [];
|
||
|
||
let remaining = line.trim();
|
||
const allCols = [];
|
||
const findImportSeparator = (text, separator) => {
|
||
if (/^-{3,}$/.test(separator)) {
|
||
const match = /-{3,}/.exec(text);
|
||
return match ? { index: match.index, length: match[0].length } : null;
|
||
}
|
||
const index = text.indexOf(separator);
|
||
return index === -1 ? null : { index, length: separator.length };
|
||
};
|
||
|
||
if (separators[0] && remaining.startsWith(separators[0])) {
|
||
remaining = remaining.slice(separators[0].length);
|
||
}
|
||
|
||
for (let i = 0; i < fieldKeys.length; i++) {
|
||
let value = '';
|
||
if (i < fieldKeys.length - 1) {
|
||
const sep = separators[i + 1];
|
||
if (!sep) {
|
||
value = remaining.trim();
|
||
allCols.push({ label: fieldKeys[i], value, ok: !!value });
|
||
remaining = '';
|
||
break;
|
||
}
|
||
const separatorMatch = findImportSeparator(remaining, sep);
|
||
if (!separatorMatch) {
|
||
value = remaining.trim();
|
||
allCols.push({ label: fieldKeys[i], value, ok: !!value });
|
||
remaining = '';
|
||
break;
|
||
}
|
||
value = remaining.slice(0, separatorMatch.index).trim();
|
||
allCols.push({ label: fieldKeys[i], value, ok: !!value });
|
||
remaining = remaining.slice(separatorMatch.index + separatorMatch.length);
|
||
} else {
|
||
value = remaining.trim();
|
||
allCols.push({ label: fieldKeys[i], value, ok: !!value });
|
||
}
|
||
}
|
||
|
||
for (let i = allCols.length; i < fieldKeys.length; i++) {
|
||
allCols.push({ label: fieldKeys[i], value: '(缺失)', ok: false });
|
||
}
|
||
|
||
const credentialLabels = new Set(['辅助邮箱', '2fa', '接码地址', '接码链接', 'rt凭证', 'RT凭证', 'AG凭证']);
|
||
const credentialCols = allCols.filter(col => credentialLabels.has(col.label));
|
||
const credentialValues = credentialCols
|
||
.flatMap(col => String(col.value === '(缺失)' ? '' : col.value).split(/-{3,}/))
|
||
.map(value => value.trim())
|
||
.filter(Boolean);
|
||
const looksLikeEmail = value => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value);
|
||
const looksLikeTotp = value => {
|
||
if (/^otpauth:\/\/totp\//i.test(value)) return true;
|
||
const compact = value.replace(/[\s-]/g, '').replace(/=+$/g, '');
|
||
return compact.length >= 16 && /^[a-z2-7]+$/i.test(compact);
|
||
};
|
||
const looksLikeRt = value => /^1\/\/[a-z0-9_-]+$/i.test(value);
|
||
const looksLikeUrl = value => /^https?:\/\//i.test(value);
|
||
const detected = {
|
||
recovery: credentialValues.find(looksLikeEmail) || '',
|
||
twofa: credentialValues.find(looksLikeTotp) || '',
|
||
sms: credentialValues.find(looksLikeUrl) || '',
|
||
rt: credentialValues.find(looksLikeRt) || '',
|
||
};
|
||
const originalValue = label => credentialCols.find(col => col.label === label)?.value || '';
|
||
const recoveryFallback = originalValue('辅助邮箱');
|
||
const twofaFallback = originalValue('2fa');
|
||
const hasPackedValue = credentialCols.some(col => /-{3,}/.test(String(col.value)));
|
||
const hasSwappedValue = looksLikeTotp(recoveryFallback) || looksLikeEmail(twofaFallback);
|
||
|
||
let previewCols = allCols;
|
||
if (hasPackedValue || hasSwappedValue) {
|
||
previewCols = allCols.filter(col => !credentialLabels.has(col.label));
|
||
const fallbackRecovery = recoveryFallback && !looksLikeTotp(recoveryFallback) && !looksLikeRt(recoveryFallback)
|
||
? recoveryFallback
|
||
: '';
|
||
const fallbackTwofa = twofaFallback && !looksLikeEmail(twofaFallback) && !looksLikeRt(twofaFallback)
|
||
? twofaFallback
|
||
: '';
|
||
previewCols.push({ label: '辅助邮箱', value: detected.recovery || fallbackRecovery, ok: !!(detected.recovery || fallbackRecovery) });
|
||
previewCols.push({ label: '2fa', value: detected.twofa || fallbackTwofa, ok: !!(detected.twofa || fallbackTwofa) });
|
||
if (detected.sms) previewCols.push({ label: '接码链接', value: detected.sms, ok: true });
|
||
if (detected.rt) previewCols.push({ label: 'rt凭证', value: detected.rt, ok: true });
|
||
}
|
||
|
||
// 过滤掉 {{无用}} 列
|
||
const cols = previewCols.filter(c => c.label !== '无用');
|
||
return [cols];
|
||
},
|
||
|
||
async checkBitStatus() {
|
||
this.bitStatus = 'checking';
|
||
try {
|
||
const res = await fetch('/api/settings/bit_status');
|
||
const data = await res.json();
|
||
this.bitStatus = data.success ? 'online' : 'offline';
|
||
} catch (e) {
|
||
this.bitStatus = 'offline';
|
||
}
|
||
},
|
||
|
||
async loadBrowsers(force = false) {
|
||
try {
|
||
const res = await fetch(force ? '/api/settings/browsers?force=1' : '/api/settings/browsers');
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
this.browsers = data.data || [];
|
||
this._browserById = new Map(this.browsers
|
||
.filter(browser => browser && browser.id)
|
||
.map(browser => [String(browser.id), browser]));
|
||
// 接口已先把编号同步到数据库;同时更新当前页面对象,避免再等一次账号列表刷新。
|
||
for (const account of this.accounts) {
|
||
if (!account || !account.browser_id) continue;
|
||
const browser = this._browserById.get(String(account.browser_id));
|
||
if (browser && browser.seq !== undefined && browser.seq !== null) {
|
||
account.browser_seq = String(browser.seq);
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// 比特接口瞬时失败时保留上一次映射,并继续使用数据库保存的编号。
|
||
}
|
||
},
|
||
|
||
getBrowserName(browserId) {
|
||
if (!browserId) return '-';
|
||
const b = this._browserById.get(String(browserId));
|
||
return b ? b.name : '-';
|
||
},
|
||
|
||
getBrowserSeq(browserId, savedSeq = '') {
|
||
if (!browserId) return savedSeq == null ? '' : String(savedSeq);
|
||
const b = this._browserById.get(String(browserId));
|
||
return b && b.seq !== undefined && b.seq !== null
|
||
? String(b.seq)
|
||
: (savedSeq == null ? '' : String(savedSeq));
|
||
},
|
||
|
||
/** 计算某个月/日距离今天过了几天 */
|
||
_calcDaysAgo(month, day, year) {
|
||
if (!month || !day) return -1;
|
||
const now = new Date();
|
||
const target = new Date(year || now.getFullYear(), month - 1, day);
|
||
// 如果目标日期在未来(跨年情况),用去年
|
||
if (target > now) target.setFullYear(target.getFullYear() - 1);
|
||
const diff = Math.floor((now - target) / (1000 * 60 * 60 * 24));
|
||
return diff;
|
||
},
|
||
|
||
/** 解析安全日期 JSON 中的某个字段,附带天数 */
|
||
parseSecurityDate(jsonStr, field) {
|
||
if (!jsonStr) return '-';
|
||
try {
|
||
const obj = typeof jsonStr === 'string' ? JSON.parse(jsonStr) : jsonStr;
|
||
const label = obj[field] || '';
|
||
if (!label) return '-';
|
||
const month = obj[field + 'Month'];
|
||
const day = obj[field + 'Day'];
|
||
const year = obj.checkedYear;
|
||
const days = this._calcDaysAgo(month, day, year);
|
||
if (days === 0) return label.startsWith('今天') ? label : `${label}(今天)`;
|
||
if (days > 0) return `${label}(${days}天)`;
|
||
return label;
|
||
} catch (_) { return '-'; }
|
||
},
|
||
|
||
/** 格式化安全日期用于 title 悬停提示 */
|
||
formatSecurityDatesTitle(jsonStr) {
|
||
if (!jsonStr) return '未检查';
|
||
try {
|
||
const obj = typeof jsonStr === 'string' ? JSON.parse(jsonStr) : jsonStr;
|
||
const parts = [];
|
||
if (obj.twoStep) {
|
||
const d = this._calcDaysAgo(obj.twoStepMonth, obj.twoStepDay, obj.checkedYear);
|
||
parts.push(`两步验证: ${obj.twoStep}${d >= 0 ? ` (${d}天前)` : ''}`);
|
||
}
|
||
if (obj.password) {
|
||
const d = this._calcDaysAgo(obj.passwordMonth, obj.passwordDay, obj.checkedYear);
|
||
parts.push(`密码修改: ${obj.password}${d >= 0 ? ` (${d}天前)` : ''}`);
|
||
}
|
||
if (obj.authenticator) {
|
||
const d = this._calcDaysAgo(obj.authenticatorMonth, obj.authenticatorDay, obj.checkedYear);
|
||
parts.push(`验证器: ${obj.authenticator}${d >= 0 ? ` (${d}天前)` : ''}`);
|
||
}
|
||
return parts.join('\n') || '未检查';
|
||
} catch (_) { return '解析错误'; }
|
||
},
|
||
|
||
// AI Studio 一键组合流程日志:鼠标悬停显示完整信息
|
||
formatAistudioFlowTitle(account) {
|
||
if (!account || !account.aistudio_flow_status) return '';
|
||
const lines = [];
|
||
lines.push(account.aistudio_flow_status === 'success' ? '结果:成功(全部步骤通过)' : '结果:失败');
|
||
if (account.aistudio_flow_status !== 'success' && account.aistudio_flow_failed_step) {
|
||
lines.push(`失败步骤:${account.aistudio_flow_failed_step}`);
|
||
}
|
||
if (account.aistudio_flow_detail) lines.push(`详情:${account.aistudio_flow_detail}`);
|
||
if (account.aistudio_flow_at) lines.push(`时间:${this.formatDate(account.aistudio_flow_at)}`);
|
||
return lines.join('\n');
|
||
},
|
||
|
||
// ==================== 静态代理池 ====================
|
||
async loadStaticProxies() {
|
||
try {
|
||
const res = await fetch('/api/static-proxies');
|
||
const data = await res.json();
|
||
if (data.success) this.staticProxies = data.data || [];
|
||
} catch (_) {}
|
||
},
|
||
async importStaticProxies() {
|
||
if (!this.staticProxyImportText.trim()) return this.addLog('⚠️ 请粘贴代理列表', 'warn');
|
||
try {
|
||
const res = await fetch('/api/static-proxies/import', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text: this.staticProxyImportText, maxUsage: this.staticProxyMaxUsage }),
|
||
});
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
this.addLog(`✅ 导入静态代理: 新增 ${data.added}/${data.total}`, 'success');
|
||
this.staticProxyImportText = '';
|
||
await this.loadStaticProxies();
|
||
} else {
|
||
this.addLog(`导入失败: ${data.msg}`, 'error');
|
||
}
|
||
} catch (e) {
|
||
this.addLog(`导入失败: ${e.message}`, 'error');
|
||
}
|
||
},
|
||
async deleteStaticProxy(id) {
|
||
try {
|
||
await fetch(`/api/static-proxies/${id}`, { method: 'DELETE' });
|
||
await this.loadStaticProxies();
|
||
} catch (_) {}
|
||
},
|
||
async clearAllStaticProxies() {
|
||
if (!confirm('确定清空所有静态代理?')) return;
|
||
try {
|
||
await fetch('/api/static-proxies', { method: 'DELETE' });
|
||
await this.loadStaticProxies();
|
||
this.addLog('✅ 已清空静态代理池', 'success');
|
||
} catch (_) {}
|
||
},
|
||
async releaseStaticProxy(id) {
|
||
try {
|
||
await fetch(`/api/static-proxies/${id}/release`, { method: 'PUT' });
|
||
await this.loadStaticProxies();
|
||
await this.loadAccounts();
|
||
} catch (_) {}
|
||
},
|
||
async toggleStaticProxy(id) {
|
||
try {
|
||
await fetch(`/api/static-proxies/${id}/toggle`, { method: 'PUT' });
|
||
await this.loadStaticProxies();
|
||
} catch (_) {}
|
||
},
|
||
async editStaticProxyMaxUsage(p) {
|
||
const val = prompt('设置可用次数:', p.max_usage);
|
||
if (val === null) return;
|
||
const num = parseInt(val, 10);
|
||
if (!num || num < 1) return this.addLog('⚠️ 次数必须大于0', 'warn');
|
||
try {
|
||
await fetch(`/api/static-proxies/${p.id}/max-usage`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ maxUsage: num }),
|
||
});
|
||
await this.loadStaticProxies();
|
||
} catch (_) {}
|
||
},
|
||
async setMotherAccount() {
|
||
const emails = [...this.selectedEmails];
|
||
if (emails.length === 0) return this.addLog('⚠️ 请先选中需要设置的账号', 'warn');
|
||
let ok = 0;
|
||
for (const email of emails) {
|
||
try {
|
||
await fetch(`/api/accounts/${encodeURIComponent(email)}/tag`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tag: '家庭组母号' }),
|
||
});
|
||
const acc = this._accountByEmail.get(email);
|
||
if (acc) acc.tag = '家庭组母号';
|
||
ok++;
|
||
} catch (_) {}
|
||
}
|
||
this.addLog(`✅ 已设置母号: ${ok}/${emails.length}`, 'success');
|
||
},
|
||
|
||
// ==================== 模板代理(Cliproxy / Nexip) ====================
|
||
_generateClipproxySid() {
|
||
return Math.random().toString(36).substring(2, 10);
|
||
},
|
||
|
||
_generateSidFromSettings() {
|
||
const letters = 'abcdefghijklmnopqrstuvwxyz';
|
||
const numbers = '0123456789';
|
||
const useLetters = this.settings.sid_use_letters !== '0' && this.settings.sid_use_letters !== false;
|
||
const useNumbers = this.settings.sid_use_numbers !== '0' && this.settings.sid_use_numbers !== false;
|
||
let chars = '';
|
||
if (useLetters) chars += letters;
|
||
if (useNumbers) chars += numbers;
|
||
if (!chars) chars = letters + numbers;
|
||
const len = parseInt(this.settings.sid_length || '8', 10) || 8;
|
||
let result = '';
|
||
for (let i = 0; i < len; i++) {
|
||
result += chars[Math.floor(Math.random() * chars.length)];
|
||
}
|
||
return result;
|
||
},
|
||
|
||
get templateProxyPreview() {
|
||
const tpl = this.settings.proxy_template || '';
|
||
if (!tpl) return '';
|
||
const sid = this._generateSidFromSettings();
|
||
return tpl.replace(/\{sid\}/gi, sid);
|
||
},
|
||
|
||
refreshTemplatePreview() {
|
||
// 触发 Alpine 重新计算 getter
|
||
this.settings.proxy_template = this.settings.proxy_template;
|
||
},
|
||
|
||
get clipproxyPreview() {
|
||
const host = this.settings.cliproxy_host || '';
|
||
const username = this.settings.cliproxy_username || '';
|
||
const region = this.settings.cliproxy_region || '';
|
||
const duration = this.settings.cliproxy_duration || '120';
|
||
const password = this.settings.cliproxy_password || '';
|
||
if (!host || !username) return '';
|
||
const middle = region ? `${username}-${region}` : username;
|
||
return `${host}:${middle}-sid-${this.cliproxySid}-t-${duration}:${password}`;
|
||
},
|
||
|
||
get dataimpulsePreview() {
|
||
const username = this.settings.dataimpulse_username || '';
|
||
const password = this.settings.dataimpulse_password || '';
|
||
if (!username || !password) return '';
|
||
const port = 10000 + Math.floor(Math.random() * 10000);
|
||
return `gw.dataimpulse.com:${port}:${username}:${password}`;
|
||
},
|
||
|
||
get cliApiPreview() {
|
||
const url = this.settings.cliapi_url || '';
|
||
if (!url) return '';
|
||
const region = this.settings.cliapi_region || 'US';
|
||
const num = this.settings.cliapi_num || '1';
|
||
const time = this.settings.cliapi_time || '30';
|
||
return `${url}?region=${region}&num=${num}&time=${time}&format=n&type=txt`;
|
||
},
|
||
|
||
async parseClipproxyFull() {
|
||
const text = this.cliproxyFullStr.trim();
|
||
if (!text) return;
|
||
try {
|
||
// Format: host:port:username-region-sid-XXXX-t-duration:password
|
||
const c1 = text.indexOf(':');
|
||
const c2 = text.indexOf(':', c1 + 1);
|
||
if (c1 === -1 || c2 === -1) throw new Error('格式错误,需要至少3个冒号');
|
||
const hostPort = text.substring(0, c2);
|
||
const rest = text.substring(c2 + 1); // username-region-sid-XXXX-t-duration:password
|
||
|
||
const dash1 = rest.indexOf('-');
|
||
if (dash1 === -1) throw new Error('格式错误');
|
||
const username = rest.substring(0, dash1);
|
||
const rest2 = rest.substring(dash1 + 1); // region-sid-XXXX-t-duration:password
|
||
|
||
const sidIdx = rest2.indexOf('-sid-');
|
||
if (sidIdx === -1) throw new Error('格式错误,找不到 -sid-');
|
||
const region = rest2.substring(0, sidIdx);
|
||
const tail = rest2.substring(sidIdx + 5); // XXXX-t-duration:password
|
||
|
||
const colonInTail = tail.indexOf(':');
|
||
if (colonInTail === -1) throw new Error('格式错误,找不到密码分隔符');
|
||
const sidAndDuration = tail.substring(0, colonInTail); // XXXX-t-duration
|
||
const password = tail.substring(colonInTail + 1);
|
||
|
||
const tIdx = sidAndDuration.indexOf('-t-');
|
||
if (tIdx === -1) throw new Error('格式错误,找不到 -t-');
|
||
const sid = sidAndDuration.substring(0, tIdx);
|
||
const duration = sidAndDuration.substring(tIdx + 3);
|
||
|
||
// Update reactive state
|
||
this.settings.cliproxy_host = hostPort;
|
||
this.settings.cliproxy_username = username;
|
||
this.settings.cliproxy_region = region;
|
||
this.settings.cliproxy_duration = duration;
|
||
this.settings.cliproxy_password = password;
|
||
if (sid) this.cliproxySid = sid;
|
||
|
||
// Batch-save to backend
|
||
await fetch('/api/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
settings: {
|
||
cliproxy_host: hostPort,
|
||
cliproxy_username: username,
|
||
cliproxy_region: region,
|
||
cliproxy_duration: duration,
|
||
cliproxy_password: password,
|
||
},
|
||
}),
|
||
});
|
||
|
||
this.cliproxyFullStr = '';
|
||
this.addLog('Cliproxy 配置解析并保存成功', 'success');
|
||
} catch (e) {
|
||
alert('解析失败: ' + e.message);
|
||
}
|
||
},
|
||
|
||
async parseNodemavenFull() {
|
||
const text = (this.nodemavenFullStr || '').trim();
|
||
if (!text) return;
|
||
try {
|
||
const parts = text.split(':');
|
||
if (parts.length < 4) throw new Error('格式错误,需要至少3个冒号');
|
||
const host = (parts[0] || '').trim();
|
||
const port = (parts[1] || '').trim();
|
||
const username = (parts[2] || '').trim();
|
||
const password = parts.slice(3).join(':').trim();
|
||
if (!host || !port || !username || !password) throw new Error('格式错误:host/port/username/password 不能为空');
|
||
|
||
const proxy = `${host}:${port}:${username}:${password}`;
|
||
this.settings.proxy_provider = 'nodemaven';
|
||
this.settings.nodemaven_proxy = proxy;
|
||
|
||
await fetch('/api/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
settings: {
|
||
proxy_provider: 'nodemaven',
|
||
nodemaven_proxy: proxy,
|
||
},
|
||
}),
|
||
});
|
||
|
||
this.nodemavenFullStr = '';
|
||
this.addLog('nodemaven 代理解析并保存成功', 'success');
|
||
} catch (e) {
|
||
alert('解析失败: ' + e.message);
|
||
}
|
||
},
|
||
|
||
// ==================== 代理预设管理 ====================
|
||
_proxySettingKeys: [
|
||
'proxy_provider',
|
||
'proxy_template', 'sid_use_letters', 'sid_use_numbers', 'sid_length',
|
||
'nexip_sid_reuse_enabled', 'nexip_sid_reuse_count',
|
||
'cliproxy_host', 'cliproxy_username', 'cliproxy_region', 'cliproxy_duration', 'cliproxy_password',
|
||
'nodemaven_proxy',
|
||
'dataimpulse_username', 'dataimpulse_password',
|
||
'cliapi_url', 'cliapi_region', 'cliapi_num', 'cliapi_time',
|
||
],
|
||
|
||
async saveProxyPreset() {
|
||
const name = (this.presetName || '').trim();
|
||
if (!name) { alert('请输入预设名称'); return; }
|
||
// 收集当前代理配置
|
||
const config = {};
|
||
for (const key of this._proxySettingKeys) {
|
||
config[key] = this.settings[key] || '';
|
||
}
|
||
// 检查是否同名覆盖
|
||
const idx = this.proxyPresets.findIndex(p => p.name === name);
|
||
const preset = { name, config, updatedAt: new Date().toLocaleString() };
|
||
if (idx >= 0) {
|
||
this.proxyPresets[idx] = preset;
|
||
} else {
|
||
this.proxyPresets.push(preset);
|
||
}
|
||
localStorage.setItem('proxyPresets', JSON.stringify(this.proxyPresets));
|
||
this.presetName = '';
|
||
this.addLog(`✅ 代理预设「${name}」已保存`, 'success');
|
||
},
|
||
|
||
async loadProxyPreset(preset) {
|
||
if (!preset || !preset.config) return;
|
||
// 将预设配置写入 settings
|
||
for (const [key, value] of Object.entries(preset.config)) {
|
||
this.settings[key] = value;
|
||
}
|
||
// 批量保存到后端
|
||
try {
|
||
await fetch('/api/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ settings: preset.config }),
|
||
});
|
||
} catch (_) {}
|
||
this.cliproxySid = this._generateClipproxySid();
|
||
this.addLog(`✅ 已加载代理预设「${preset.name}」`, 'success');
|
||
},
|
||
|
||
deleteProxyPreset(idx) {
|
||
if (!confirm(`确定删除预设「${this.proxyPresets[idx].name}」?`)) return;
|
||
const name = this.proxyPresets[idx].name;
|
||
this.proxyPresets.splice(idx, 1);
|
||
localStorage.setItem('proxyPresets', JSON.stringify(this.proxyPresets));
|
||
this.addLog(`🗑️ 已删除代理预设「${name}」`, 'info');
|
||
},
|
||
|
||
_getPresetProviderLabel(config) {
|
||
const p = config.proxy_provider || 'cliproxy';
|
||
const labels = { cliproxy: 'Cliproxy', nexip: 'Nexip', cliapi: 'Cliapi', nodemaven: 'Nodemaven', dataimpulse: 'DataImpulse' };
|
||
return labels[p] || p;
|
||
},
|
||
|
||
_getPresetSummary(config) {
|
||
const p = config.proxy_provider || 'cliproxy';
|
||
if (p === 'cliproxy') {
|
||
if (config.proxy_template) {
|
||
const t = config.proxy_template;
|
||
return t.length > 35 ? t.substring(0, 35) + '...' : t;
|
||
}
|
||
return config.cliproxy_region || config.cliproxy_host || '';
|
||
}
|
||
if (p === 'nexip') return (config.proxy_template || '').substring(0, 35);
|
||
if (p === 'nodemaven') return (config.nodemaven_proxy || '').substring(0, 30);
|
||
if (p === 'dataimpulse') return config.dataimpulse_username || '';
|
||
if (p === 'cliapi') return config.cliapi_region || '';
|
||
return '';
|
||
},
|
||
|
||
// ==================== 任务执行 ====================
|
||
getTaskAccounts() {
|
||
const selected = this.selectedAccounts;
|
||
if (selected.length > 0) return selected;
|
||
// 最近批次为空时必须返回空,避免“未选中”被误解释为运行整个号池。
|
||
if (this.statusFilter === '刚刚运行') return this.filteredAccounts;
|
||
return this.accounts;
|
||
},
|
||
|
||
onMonitorToggle() {
|
||
try {
|
||
localStorage.setItem('monitor_enabled', this.monitorEnabled ? '1' : '0');
|
||
} catch (_) {}
|
||
this.addLog(`旁监听${this.monitorEnabled ? '已启用' : '已关闭'}(适用于业务任务)`, 'info');
|
||
},
|
||
|
||
buildTaskParams(taskType, extraParams = {}) {
|
||
const params = { threadCount: parseInt(this.threadCount) || 1, ...extraParams };
|
||
if (this.monitorEnabled) {
|
||
params.monitor = { enabled: true, mode: 'full' };
|
||
}
|
||
return params;
|
||
},
|
||
|
||
_taskPendingMessage(taskType) {
|
||
const messages = {
|
||
create_accounts: '等待创建浏览器...',
|
||
change_proxy: '等待切换 IP...',
|
||
login: '等待登录...',
|
||
change_password: '等待改密码...',
|
||
change_2fa: '等待更改 2FA...',
|
||
chatgpt_register: '等待 ChatGPT 注册/登录...',
|
||
openai_get_auth: '等待提取授权 JSON...',
|
||
openai_query_plan: '等待查询套餐...',
|
||
refund_query: '等待查询退款...',
|
||
card_pay_recharge: '等待充值...',
|
||
workspace_refund: '等待提交退款...',
|
||
security_chatgpt: '等待改密和2FA...',
|
||
change_language: '等待更改语言...',
|
||
check_restrictions: '等待检查账号限制...',
|
||
check_security_dates: '等待检查安全日期...',
|
||
ag_get_credential: '等待获取凭证...',
|
||
aistudio_combo: '等待处理 AI Studio...',
|
||
open_manual_aistudio: '浏览器已打开,等待人工创建 AI Studio Key...',
|
||
gemini_cli_get_credential: '等待获取 Cli 凭证...',
|
||
google_account_ag_credential: '等待链接取号并获取凭证...',
|
||
ag_revoke_authorization: '等待取消授权...',
|
||
open: '等待打开浏览器...',
|
||
open_url: '等待打开网页...',
|
||
delete: '等待删除浏览器...',
|
||
custom_pipeline: '等待组合任务...',
|
||
};
|
||
return messages[taskType] || '等待处理...';
|
||
},
|
||
|
||
_taskDisplayLabel(taskType) {
|
||
if (taskType === 'custom_pipeline') {
|
||
const steps = (this.customQueue || []).map(key => this.getCustomTaskLabel(key)).filter(Boolean);
|
||
return steps.length > 0 ? `组合任务:${steps.join(' → ')}` : '组合任务';
|
||
}
|
||
const labels = {
|
||
create_accounts: '创建浏览器', change_proxy: '换代理', login: '登录',
|
||
change_password: '改密码', change_2fa: '更改 2FA', change_language: '更改语言',
|
||
chatgpt_register: 'ChatGPT注册/登录',
|
||
openai_get_auth: '提取授权JSON',
|
||
openai_query_plan: '查询套餐',
|
||
refund_query: '查询退款',
|
||
card_pay_recharge: '充值',
|
||
workspace_refund: '退款',
|
||
security_chatgpt: '改密+2FA',
|
||
check_restrictions: '检查账号限制', check_security_dates: '检查安全日期',
|
||
ag_get_credential: '获取凭证', aistudio_combo: 'AI Studio 一键组合', aistudio_sync_key: '同步人工AI Key', gemini_cli_get_credential: '获取Cli凭证', ag_revoke_authorization: '取消授权',
|
||
google_account_ag_credential: '链接提取+获取凭证',
|
||
open: '打开浏览器', open_manual_aistudio: '打开浏览器(自动保存人工 Key)', open_url: '打开网页', delete: '删除浏览器',
|
||
};
|
||
return labels[taskType] || taskType || '任务';
|
||
},
|
||
|
||
get activeTaskDisplayLabel() {
|
||
return this._taskDisplayLabel(this._activeTaskType || this._lastTaskType);
|
||
},
|
||
|
||
get taskProgressPercent() {
|
||
const total = Number(this.taskProgress.total) || 0;
|
||
const current = Number(this.taskProgress.current) || 0;
|
||
if (total <= 0) return 0;
|
||
return Math.max(0, Math.min(100, Math.round((current / total) * 100)));
|
||
},
|
||
|
||
_formatEtaDuration(ms) {
|
||
const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
|
||
if (totalSeconds < 60) return `${totalSeconds} 秒`;
|
||
const minutes = Math.ceil(totalSeconds / 60);
|
||
if (minutes < 60) return `${minutes} 分钟`;
|
||
const hours = Math.floor(minutes / 60);
|
||
const restMinutes = minutes % 60;
|
||
return restMinutes > 0 ? `${hours} 小时 ${restMinutes} 分钟` : `${hours} 小时`;
|
||
},
|
||
|
||
get taskEtaText() {
|
||
const current = Number(this.taskProgress.current) || 0;
|
||
const total = Number(this.taskProgress.total) || 0;
|
||
const startedAt = Number(this.taskProgress.startedAt) || 0;
|
||
if (!startedAt || total <= 0 || current <= 0) return '预计结束时间:完成首个账号后计算';
|
||
if (current >= total) return '预计结束时间:即将完成';
|
||
const sampleTarget = Math.min(5, total);
|
||
if (current < sampleTarget) return `预计结束时间:采样中 (${current}/${sampleTarget})`;
|
||
const elapsed = Math.max(1000, this.taskClockNow - startedAt);
|
||
const remainingMs = (elapsed / current) * (total - current);
|
||
const eta = new Date(this.taskClockNow + remainingMs);
|
||
const time = eta.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||
return `预计结束 ${time} · 剩余约 ${this._formatEtaDuration(remainingMs)}`;
|
||
},
|
||
|
||
_setLiveAccountMessage(email, message) {
|
||
if (!email || message === undefined) return;
|
||
const text = String(message || '处理中...');
|
||
let type = 'info';
|
||
if (/⚠️|警告/.test(text)) type = 'warn';
|
||
else if (/❌|🚫|✗|失败|错误|异常/.test(text)) type = 'error';
|
||
else if (/✅|✓|成功|已登录/.test(text)) type = 'success';
|
||
this.accountLogs[email] = { message: text, type };
|
||
},
|
||
|
||
_showTaskStartFeedback(taskType, accounts) {
|
||
const parsedStart = Date.parse(this._activeTaskStartedAt || '');
|
||
this.taskProgress = {
|
||
current: 0,
|
||
total: accounts.length,
|
||
startedAt: Number.isFinite(parsedStart) ? parsedStart : Date.now(),
|
||
};
|
||
this.taskClockNow = Date.now();
|
||
this._rebuildDashboardStats();
|
||
},
|
||
|
||
_isTaskMessagePending(account) {
|
||
return !!(
|
||
this.isRunning
|
||
&& account
|
||
&& this._activeTaskEmailSet
|
||
&& this._activeTaskEmailSet.has(account.email)
|
||
&& !this.accountLogs[account.email]
|
||
);
|
||
},
|
||
|
||
_queueTaskProgress(progress) {
|
||
this._pendingTaskProgress = progress;
|
||
if (this._taskProgressTimer) return;
|
||
this._taskProgressTimer = setTimeout(() => this._flushTaskProgress(), 200);
|
||
},
|
||
|
||
_flushTaskProgress() {
|
||
if (this._taskProgressTimer) clearTimeout(this._taskProgressTimer);
|
||
this._taskProgressTimer = null;
|
||
const next = this._pendingTaskProgress;
|
||
this._pendingTaskProgress = null;
|
||
if (!next) return;
|
||
this.taskProgress = {
|
||
...next,
|
||
startedAt: this.taskProgress.startedAt || Date.now(),
|
||
};
|
||
},
|
||
|
||
async runTask(taskType, extraParams = {}, explicitAccounts = null) {
|
||
if (this.isRunning) {
|
||
this.addLog('⚠️ 有任务正在运行,请等待完成或停止', 'warn');
|
||
return false;
|
||
}
|
||
|
||
const taskAccounts = Array.isArray(explicitAccounts) ? explicitAccounts : this.getTaskAccounts();
|
||
const createsAccounts = taskType === 'google_account_ag_credential';
|
||
if (taskAccounts.length === 0 && !createsAccounts) {
|
||
this.addLog('⚠️ 没有可用的账号', 'warn');
|
||
return false;
|
||
}
|
||
|
||
const taskParams = this.buildTaskParams(taskType, extraParams);
|
||
const taskThreadCount = Math.max(1, parseInt(taskParams.threadCount, 10) || 1);
|
||
this.clearLogs();
|
||
const monitorText = this.monitorEnabled ? ', 旁监听: 开(full)' : '';
|
||
const requestedCount = createsAccounts
|
||
? Math.max(1, Number.parseInt(taskParams.quantity, 10) || 1)
|
||
: taskAccounts.length;
|
||
this.addLog(`🚀 启动任务: ${taskType}, 账号数: ${requestedCount}, 并发: ${taskThreadCount}${monitorText}`, 'info');
|
||
|
||
try {
|
||
const res = await fetch('/api/operations/run', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
taskType,
|
||
accounts: taskAccounts,
|
||
params: taskParams,
|
||
}),
|
||
});
|
||
const data = await res.json();
|
||
if (!data.success) {
|
||
this.addLog(`❌ 启动失败: ${data.msg}`, 'error');
|
||
return false;
|
||
}
|
||
|
||
this.currentTaskId = data.taskId;
|
||
this._rememberTaskBatch(taskType, taskAccounts, data.taskId);
|
||
this._activeTaskType = taskType;
|
||
this._setActiveTaskEmails(this._lastTaskEmails);
|
||
this._activeTaskStartedAt = this._lastTaskStartedAt;
|
||
this.isRunning = true;
|
||
if (taskType === 'card_pay_recharge') {
|
||
for (const taskAccount of taskAccounts) {
|
||
const liveAccount = this._accountByEmail.get(taskAccount.email);
|
||
if (liveAccount) {
|
||
liveAccount.recharge_status = '充值中';
|
||
liveAccount.recharge_message = '等待充值';
|
||
liveAccount.message = '等待充值';
|
||
liveAccount.has_recharge_card = true;
|
||
}
|
||
}
|
||
}
|
||
this._showTaskStartFeedback(taskType, taskAccounts);
|
||
if (createsAccounts) {
|
||
this.taskProgress.total = requestedCount * 2;
|
||
this.activeTab = 'accounts';
|
||
this.selectedEmails = new Set();
|
||
} else if (taskType !== 'custom_pipeline') {
|
||
this.activeTab = 'accounts';
|
||
this.statusFilter = '任务中';
|
||
this.selectedEmails = new Set(this._lastTaskEmails);
|
||
} else if (taskType === 'custom_pipeline') {
|
||
this.activeTab = 'accounts';
|
||
this.statusFilter = '自定义任务';
|
||
this.selectedEmails = new Set(this._lastTaskEmails);
|
||
}
|
||
this.resetAccountPage();
|
||
this.startSSEListener(data.taskId);
|
||
return true;
|
||
} catch (e) {
|
||
this.addLog(`❌ 网络错误: ${e.message}`, 'error');
|
||
return false;
|
||
}
|
||
},
|
||
// 页面刷新后恢复运行中的任务
|
||
async checkRunningTasks() {
|
||
try {
|
||
const res = await fetch('/api/operations/tasks');
|
||
const data = await res.json();
|
||
if (data.success && data.data && data.data.length > 0) {
|
||
const task = data.data[0]; // 取第一个运行中的任务
|
||
this._activeTaskType = task.taskType || null;
|
||
this._setActiveTaskEmails(task.emails);
|
||
this._activeTaskStartedAt = task.startedAt || null;
|
||
this.currentTaskId = task.taskId;
|
||
this.isRunning = true;
|
||
this.isPaused = !!task.paused;
|
||
if (task.taskType === 'custom_pipeline' && Array.isArray(task.taskSteps)) {
|
||
this.customQueue = task.taskSteps.slice();
|
||
}
|
||
this._showTaskStartFeedback(task.taskType, task.emails.map(email => ({ email })));
|
||
if (task.taskType === 'custom_pipeline') {
|
||
this.customRunning = true;
|
||
this.statusFilter = '自定义任务';
|
||
} else if (this._activeTaskEmails.length > 0) {
|
||
this.statusFilter = '任务中';
|
||
}
|
||
if (this._activeTaskEmails.length > 0) this.selectedEmails = new Set(this._activeTaskEmails);
|
||
this.resetAccountPage();
|
||
this.addLog('🔄 检测到运行中的任务,已自动恢复连接', 'info');
|
||
this.startSSEListener(task.taskId);
|
||
}
|
||
} catch (_) {}
|
||
},
|
||
|
||
_queueAccountUpdate(data) {
|
||
if (!data || !data.email) return;
|
||
const previous = this._pendingAccountUpdates.get(data.email) || {};
|
||
this._pendingAccountUpdates.set(data.email, { ...previous, ...data });
|
||
if (this._accountUpdateTimer) return;
|
||
this._accountUpdateTimer = setTimeout(() => this._flushAccountUpdates(), 200);
|
||
},
|
||
|
||
_flushAccountUpdates() {
|
||
if (this._accountUpdateTimer) {
|
||
clearTimeout(this._accountUpdateTimer);
|
||
this._accountUpdateTimer = null;
|
||
}
|
||
const pending = this._pendingAccountUpdates;
|
||
this._pendingAccountUpdates = new Map();
|
||
if (!pending || pending.size === 0) return;
|
||
|
||
const jobsByEmail = this.customJobList.length > 0
|
||
? new Map(this.customJobList.map(job => [job.email, job]))
|
||
: null;
|
||
let customJobsChanged = false;
|
||
for (const [email, data] of pending) {
|
||
const account = this._accountByEmail.get(email);
|
||
if (account) {
|
||
if (data.status !== undefined) account.status = data.status;
|
||
if (data.message !== undefined) account.message = data.message;
|
||
if (data.status_reason !== undefined) account.status_reason = data.status_reason;
|
||
if (data.failure_label !== undefined) account.failure_label = data.failure_label;
|
||
if (data.ag_status !== undefined) account.ag_status = data.ag_status;
|
||
if (data.has_cli_ac_credential !== undefined) account.has_cli_ac_credential = data.has_cli_ac_credential;
|
||
if (data.chatgpt_status !== undefined) account.chatgpt_status = data.chatgpt_status;
|
||
if (data.openai_auth_status !== undefined) account.openai_auth_status = data.openai_auth_status;
|
||
if (data.has_openai_auth !== undefined) account.has_openai_auth = data.has_openai_auth;
|
||
if (data.openai_plan_status !== undefined) account.openai_plan_status = data.openai_plan_status;
|
||
if (data.openai_plan_type !== undefined) account.openai_plan_type = data.openai_plan_type;
|
||
if (data.openai_usage_percent !== undefined) account.openai_usage_percent = data.openai_usage_percent;
|
||
if (data.openai_subscription_expires_at !== undefined) {
|
||
account.openai_subscription_expires_at = data.openai_subscription_expires_at;
|
||
}
|
||
if (data.openai_plan_checked_at !== undefined) account.openai_plan_checked_at = data.openai_plan_checked_at;
|
||
if (data.account_refund_status !== undefined) account.account_refund_status = data.account_refund_status;
|
||
if (data.refund_process_status !== undefined) account.refund_process_status = data.refund_process_status;
|
||
if (data.refund_deadline_at !== undefined) account.refund_deadline_at = data.refund_deadline_at;
|
||
if (data.recharge_status !== undefined) account.recharge_status = data.recharge_status;
|
||
if (data.recharge_task_id !== undefined) account.recharge_task_id = data.recharge_task_id;
|
||
if (data.recharge_message !== undefined) account.recharge_message = data.recharge_message;
|
||
if (data.recharged_at !== undefined) account.recharged_at = data.recharged_at;
|
||
if (data.has_recharge_card !== undefined) account.has_recharge_card = data.has_recharge_card;
|
||
if (data.password_changed !== undefined) account.password_changed = data.password_changed;
|
||
if (data.password_changed_at !== undefined) account.password_changed_at = data.password_changed_at;
|
||
if (data.security_dates !== undefined) account.security_dates = data.security_dates;
|
||
if (data.gmail_app_password !== undefined) account.gmail_app_password = data.gmail_app_password;
|
||
if (data.aistudio_api_key !== undefined) account.aistudio_api_key = data.aistudio_api_key;
|
||
if (data.aistudio_api_key_json !== undefined) account.aistudio_api_key_json = data.aistudio_api_key_json;
|
||
if (data.aistudio_flow_status !== undefined) account.aistudio_flow_status = data.aistudio_flow_status;
|
||
if (data.aistudio_flow_failed_step !== undefined) account.aistudio_flow_failed_step = data.aistudio_flow_failed_step;
|
||
if (data.aistudio_flow_detail !== undefined) account.aistudio_flow_detail = data.aistudio_flow_detail;
|
||
if (data.aistudio_flow_at !== undefined) account.aistudio_flow_at = data.aistudio_flow_at;
|
||
if (data.browser_id !== undefined) account.browser_id = data.browser_id;
|
||
if (data.browser_seq !== undefined) account.browser_seq = data.browser_seq;
|
||
if (data.screenshot_path !== undefined) account.screenshot_path = data.screenshot_path;
|
||
}
|
||
if (data.message !== undefined) this._setLiveAccountMessage(email, data.message);
|
||
const job = jobsByEmail && jobsByEmail.get(email);
|
||
if (job && data.message !== undefined) {
|
||
job.message = data.message;
|
||
customJobsChanged = true;
|
||
}
|
||
}
|
||
this._rebuildDashboardStats();
|
||
if (customJobsChanged) this._saveCustomJobList();
|
||
},
|
||
|
||
startSSEListener(taskId) {
|
||
if (this.eventSource) {
|
||
this.eventSource.close();
|
||
this.eventSource = null;
|
||
}
|
||
|
||
const es = new EventSource(`/api/operations/events?taskId=${taskId}`);
|
||
this.eventSource = es;
|
||
|
||
// 诊断日志:前端 SSE 生命周期
|
||
es.onopen = () => {
|
||
console.log('[SSE] open', { taskId });
|
||
};
|
||
|
||
es.onmessage = (event) => {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
if (data.type === 'log') {
|
||
this.addLog(data.message);
|
||
} else if (data.type === 'done') {
|
||
const doneResult = data.result && typeof data.result === 'object' ? data.result : {};
|
||
const doneFailed = doneResult.success === false || !!doneResult.error;
|
||
this._flushAccountUpdates();
|
||
this._flushTaskProgress();
|
||
if (this.taskProgress.total > 0) this.taskProgress.current = this.taskProgress.total;
|
||
this.isRunning = false;
|
||
this.isPaused = false;
|
||
this._rebuildDashboardStats();
|
||
es.close();
|
||
this.eventSource = null;
|
||
this.currentTaskId = null;
|
||
this.addLog(
|
||
doneFailed ? '⚠️ 任务已停止或异常结束' : '✅ 任务完成',
|
||
doneFailed ? 'warn' : 'success',
|
||
);
|
||
// 充值不修改比特浏览器,完成时只刷新账号数据,避免大批量
|
||
// 浏览器映射同步让页面产生不必要的卡顿。
|
||
const finishedTaskType = this._activeTaskType || this._lastTaskType;
|
||
const taskEmails = this._activeTaskEmails.length > 0
|
||
? this._activeTaskEmails.slice()
|
||
: this._lastTaskEmails.slice();
|
||
const wasSheerLink = finishedTaskType === 'sheerlink' || finishedTaskType === 'half_auto';
|
||
const refreshFinishedTask = ['card_pay_recharge', 'workspace_refund'].includes(finishedTaskType)
|
||
? this.loadAccounts()
|
||
: this.loadBrowsers(true).then(() => this.loadAccounts());
|
||
refreshFinishedTask
|
||
.then(() => {
|
||
if (wasSheerLink) this._showSheerLinkResults(taskEmails);
|
||
});
|
||
// 自定义流水线:全部完成
|
||
if (this.customRunning) {
|
||
this.customJobList.forEach(job => {
|
||
job.completedCount = job.tasks.length;
|
||
job.status = 'done';
|
||
job.currentTaskLabel = '全部完成';
|
||
});
|
||
this.customRunning = false;
|
||
this._saveCustomJobList();
|
||
this.addLog(`🏁 自定义流水线全部完成`, 'success');
|
||
}
|
||
this._activeTaskType = null;
|
||
this._setActiveTaskEmails([]);
|
||
this._activeTaskStartedAt = null;
|
||
} else if (data.type === 'account_update') {
|
||
this._queueAccountUpdate(data);
|
||
} else if (data.type === 'task_accounts') {
|
||
const emails = [...new Set((Array.isArray(data.emails) ? data.emails : []).filter(Boolean))];
|
||
this._setActiveTaskEmails(emails);
|
||
this.selectedEmails = new Set(emails);
|
||
this.statusFilter = '任务中';
|
||
this._rememberTaskBatch(this._activeTaskType || 'google_account_ag_credential', emails.map(email => ({ email })), taskId);
|
||
this.loadAccounts().then(() => this.resetAccountPage());
|
||
} else if (data.type === 'progress') {
|
||
this._queueTaskProgress({ current: Number(data.current) || 0, total: Number(data.total) || 0 });
|
||
} else if (data.type === 'task_state') {
|
||
this.isPaused = !!data.paused;
|
||
} else if (data.type === 'connected') {
|
||
// 每次连接/重连都检查任务是否还在运行
|
||
this._checkTaskStillRunning(taskId);
|
||
}
|
||
} catch (e) {}
|
||
};
|
||
|
||
es.onerror = (err) => {
|
||
console.warn('[SSE] error', err);
|
||
if (this.isRunning) {
|
||
this.addLog('[SSE] 连接中断,3秒后自动重连...', 'warn');
|
||
}
|
||
// 不调用 es.close(),浏览器会根据 retry 指令自动重连
|
||
};
|
||
},
|
||
|
||
// 重连后检查任务是否已结束(断线期间可能已完成)
|
||
async _checkTaskStillRunning(taskId) {
|
||
if (!this.isRunning) return;
|
||
try {
|
||
const res = await fetch('/api/operations/tasks');
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
const activeTask = data.data.find(t => t.taskId === taskId);
|
||
const isActive = !!activeTask;
|
||
if (activeTask) this.isPaused = !!activeTask.paused;
|
||
if (!isActive) {
|
||
// 任务在断线期间已结束
|
||
const finishedTaskType = this._activeTaskType || this._lastTaskType;
|
||
this.isRunning = false;
|
||
this.isPaused = false;
|
||
this._rebuildDashboardStats();
|
||
this.currentTaskId = null;
|
||
if (this.eventSource) {
|
||
this.eventSource.close();
|
||
this.eventSource = null;
|
||
}
|
||
this.addLog('✅ 任务已完成', 'success');
|
||
if (this.customRunning) {
|
||
this.customJobList.forEach(job => {
|
||
job.completedCount = job.tasks.length;
|
||
job.status = 'done';
|
||
job.currentTaskLabel = '全部完成';
|
||
});
|
||
this.customRunning = false;
|
||
this._saveCustomJobList();
|
||
}
|
||
this._activeTaskType = null;
|
||
this._setActiveTaskEmails([]);
|
||
this._activeTaskStartedAt = null;
|
||
if (['card_pay_recharge', 'workspace_refund'].includes(finishedTaskType)) {
|
||
this.loadAccounts();
|
||
} else {
|
||
this.loadBrowsers(true).then(() => this.loadAccounts());
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
},
|
||
|
||
async togglePauseTask() {
|
||
if (!this.currentTaskId || !this.isRunning) return;
|
||
const nextPaused = !this.isPaused;
|
||
try {
|
||
const action = nextPaused ? 'pause' : 'resume';
|
||
const res = await fetch(`/api/operations/${action}/${this.currentTaskId}`, { method: 'POST' });
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok || !data.success) throw new Error(data.msg || `${nextPaused ? '暂停' : '继续'}请求失败`);
|
||
this.isPaused = nextPaused;
|
||
this.addLog(nextPaused
|
||
? '⏸️ 已暂停领取新账号,当前正在处理的账号会安全收尾'
|
||
: '▶️ 任务已继续,开始处理剩余账号', nextPaused ? 'warn' : 'info');
|
||
} catch (e) {
|
||
this.addLog(`${nextPaused ? '暂停' : '继续'}失败: ${e.message}`, 'error');
|
||
}
|
||
},
|
||
|
||
async stopTask() {
|
||
if (!this.currentTaskId) return;
|
||
try {
|
||
const stoppingSensitiveTask = ['card_pay_recharge', 'workspace_refund'].includes(this._activeTaskType);
|
||
const res = await fetch(`/api/operations/stop/${this.currentTaskId}`, { method: 'POST' });
|
||
if (!res.ok) throw new Error('停止请求失败');
|
||
if (stoppingSensitiveTask) {
|
||
// 充值/退款只能软停止:请求可能已经被外部系统受理。保留 SSE 和
|
||
// 当前任务状态,等待 Worker 安全收尾并返回最终/待确认结果。
|
||
this.addLog(`⏳ 正在安全停止${this._activeTaskType === 'workspace_refund' ? '退款' : '充值'}任务,请等待当前请求完成...`, 'warn');
|
||
return;
|
||
}
|
||
this.isRunning = false;
|
||
this.isPaused = false;
|
||
this._rebuildDashboardStats();
|
||
this.currentTaskId = null;
|
||
this._activeTaskType = null;
|
||
this._setActiveTaskEmails([]);
|
||
this._activeTaskStartedAt = null;
|
||
this._pendingTaskProgress = null;
|
||
if (this._taskProgressTimer) clearTimeout(this._taskProgressTimer);
|
||
this._taskProgressTimer = null;
|
||
if (this.eventSource) {
|
||
this.eventSource.close();
|
||
this.eventSource = null;
|
||
}
|
||
this.addLog('⏹️ 任务已停止', 'warn');
|
||
} catch (e) {
|
||
this.addLog(`停止失败: ${e.message}`, 'error');
|
||
}
|
||
},
|
||
|
||
// 各操作快捷方法
|
||
runChangeProxy() { this.runTask('change_proxy'); },
|
||
runOpenBrowsers() {
|
||
this.runTask('open_manual_aistudio', { watchTimeoutMs: 30 * 60 * 1000 });
|
||
},
|
||
runDeleteBrowsers() {
|
||
if (this.selectedCount === 0) {
|
||
this.addLog('⚠️ 请先勾选要删除浏览器的账号', 'warn');
|
||
return;
|
||
}
|
||
const accounts = this.selectedAccounts.filter(account => account.browser_id);
|
||
if (accounts.length === 0) {
|
||
this.addLog('⚠️ 所选账号没有绑定浏览器编号', 'warn');
|
||
return;
|
||
}
|
||
const browserCount = new Set(accounts.map(account => account.browser_id)).size;
|
||
if (!confirm(
|
||
`确认删除所选账号中的 ${browserCount} 个比特浏览器窗口?\n\n`
|
||
+ '删除成功后会同时清空账号列表里的浏览器编号,账号本身会保留。',
|
||
)) return;
|
||
this.runTask('delete', {}, accounts);
|
||
},
|
||
runLogin() { this.runTask('login'); },
|
||
runChatgptRegister() {
|
||
if (this.isRunning) return;
|
||
this.chatgptAuthMode = 'keep';
|
||
this.showChatgptRunModal = true;
|
||
},
|
||
confirmChatgptRegister() {
|
||
const clearAuthState = this.chatgptAuthMode === 'clear';
|
||
this.showChatgptRunModal = false;
|
||
this.runTask('chatgpt_register', {
|
||
age: parseInt(this.settings.chatgpt_age || '23', 10) || 23,
|
||
clearAuthState,
|
||
});
|
||
},
|
||
runOpenAIGetAuth() {
|
||
this.runTask('openai_get_auth', {
|
||
age: parseInt(this.settings.chatgpt_age || '23', 10) || 23,
|
||
});
|
||
},
|
||
runOpenAIQueryPlan() {
|
||
if (this.isRunning) return;
|
||
this.openAIPlanConnectionMode = 'browser_proxy';
|
||
this.showOpenAIPlanRunModal = true;
|
||
},
|
||
confirmOpenAIQueryPlan() {
|
||
const connectionMode = this.openAIPlanConnectionMode === 'direct'
|
||
? 'direct'
|
||
: 'browser_proxy';
|
||
this.showOpenAIPlanRunModal = false;
|
||
this.runTask('openai_query_plan', { connectionMode });
|
||
},
|
||
runRefundQuery() {
|
||
this.runTask('refund_query');
|
||
},
|
||
async runWorkspaceRefund() {
|
||
if (this.isRunning) return;
|
||
if (this.selectedCount === 0) {
|
||
this.addLog('⚠️ 请先勾选要退款的账号', 'warn');
|
||
return;
|
||
}
|
||
await this.loadWorkspaceRefundCredentialMeta();
|
||
if (!this.workspaceRefundCredentialMeta.configured) {
|
||
this.activeTab = 'settings';
|
||
this.addLog('⚠️ 请先在设置中填写退款母号 ChatGPT Session 或 Access Token', 'warn');
|
||
return;
|
||
}
|
||
if (!this.workspaceRefundCredentialMeta.proxyConfigured) {
|
||
this.activeTab = 'settings';
|
||
this.addLog('⚠️ 请先在设置中填写退款专用代理', 'warn');
|
||
return;
|
||
}
|
||
const selected = this.selectedAccounts;
|
||
const pending = selected.filter(account => (
|
||
(String(account.refund_process_status || '').trim() || '未发起') !== '已发起'
|
||
));
|
||
if (pending.length === 0) {
|
||
this.addLog('⚠️ 所选账号均已发起退款流程', 'warn');
|
||
return;
|
||
}
|
||
const skipped = selected.length - pending.length;
|
||
const confirmed = confirm(
|
||
`确认提交 ${pending.length} 个账号的退款流程?\n\n`
|
||
+ '流程会清理母号团队中的全部非 Owner 成员,并依次执行邀请、接受、迁移个人空间、移出团队。\n'
|
||
+ '任务固定单路串行;成功后“是否已发起退款流程”改为“已发起”,现有“账号退款”结果保持不变,继续由查询退款功能根据邮件确认。'
|
||
+ (skipped > 0 ? `\n\n另有 ${skipped} 个退款流程状态为“已发起”的账号会跳过。` : ''),
|
||
);
|
||
if (!confirmed) return;
|
||
const taskAccounts = selected.map(account => ({ email: account.email }));
|
||
await this.runTask('workspace_refund', { threadCount: 1 }, taskAccounts);
|
||
},
|
||
_normalizedRechargeCardLines() {
|
||
const lines = String(this.rechargeCardKeys || '').replace(/\r/g, '').split('\n');
|
||
while (lines.length > 0 && !String(lines[lines.length - 1] || '').trim()) lines.pop();
|
||
return lines.map(line => String(line || '').trim());
|
||
},
|
||
get rechargeCardLineCount() {
|
||
return this._normalizedRechargeCardLines().filter(Boolean).length;
|
||
},
|
||
get normalizedRechargeConcurrency() {
|
||
const parsed = parseInt(this.rechargeConcurrency, 10);
|
||
return Math.min(5, Math.max(1, Number.isFinite(parsed) ? parsed : 2));
|
||
},
|
||
get rechargeInputError() {
|
||
const expected = Array.isArray(this.rechargeAccounts) ? this.rechargeAccounts.length : 0;
|
||
const lines = this._normalizedRechargeCardLines();
|
||
if (expected === 0) return '没有待充值账号';
|
||
if (lines.some(line => !line)) return '卡密中间不能有空行';
|
||
if (lines.length !== expected) return `需要 ${expected} 行卡密,当前 ${lines.length} 行`;
|
||
if (new Set(lines).size !== lines.length) return '卡密不能重复';
|
||
return '';
|
||
},
|
||
async openRechargeModal() {
|
||
if (this.isRunning) return;
|
||
if (this.selectedCount === 0) {
|
||
this.addLog('⚠️ 请先勾选要充值的账号', 'warn');
|
||
return;
|
||
}
|
||
await this.loadRechargeCredentialMeta();
|
||
if (!this.rechargeCredentialMeta.configured) {
|
||
this.activeTab = 'settings';
|
||
this.addLog('⚠️ 请先在设置中填写充值 API Key ID 和 API Secret', 'warn');
|
||
return;
|
||
}
|
||
this.rechargeAccounts = this.selectedAccounts.map(account => ({ email: account.email }));
|
||
this.rechargeCardKeys = '';
|
||
this.rechargeSubmitting = false;
|
||
this.showRechargeModal = true;
|
||
},
|
||
closeRechargeModal() {
|
||
if (this.rechargeSubmitting) return;
|
||
this.showRechargeModal = false;
|
||
this.rechargeAccounts = [];
|
||
this.rechargeCardKeys = '';
|
||
},
|
||
async confirmRecharge() {
|
||
if (this.rechargeSubmitting) return;
|
||
const validationError = this.rechargeInputError;
|
||
if (validationError) {
|
||
this.addLog(`⚠️ ${validationError}`, 'warn');
|
||
return;
|
||
}
|
||
const emails = this.rechargeAccounts.map(row => row.email);
|
||
const allAccountsStillExist = emails.every(email => this._accountByEmail.has(email));
|
||
if (!allAccountsStillExist) {
|
||
this.addLog('❌ 所选账号已变化,请关闭弹框后重新选择', 'error');
|
||
return;
|
||
}
|
||
// 充值 Worker 会从数据库读取最新 Session;前端只提交邮箱和卡密,
|
||
// 不重复发送密码、2FA、代理或浏览器编号等无关字段。
|
||
const taskAccounts = emails.map(email => ({ email }));
|
||
const lines = this._normalizedRechargeCardLines();
|
||
const assignments = emails.map((email, index) => ({ email, cardKey: lines[index] }));
|
||
const rechargeConcurrency = this.normalizedRechargeConcurrency;
|
||
this.rechargeConcurrency = rechargeConcurrency;
|
||
this.rechargeSubmitting = true;
|
||
try {
|
||
const started = await this.runTask(
|
||
'card_pay_recharge',
|
||
{ assignments, confirmDuplicate: false, threadCount: rechargeConcurrency },
|
||
taskAccounts,
|
||
);
|
||
if (started) {
|
||
this.showRechargeModal = false;
|
||
this.rechargeAccounts = [];
|
||
this.rechargeCardKeys = '';
|
||
}
|
||
} finally {
|
||
this.rechargeSubmitting = false;
|
||
}
|
||
},
|
||
runChange2fa() { this.runTask('change_2fa'); },
|
||
runChangePassword() { this.runTask('change_password'); },
|
||
runSecurityChatgpt() {
|
||
if (this.isRunning) return;
|
||
this.securityRunMode = 'overwrite';
|
||
this.showSecurityRunModal = true;
|
||
},
|
||
confirmSecurityTask() {
|
||
const skipCompletedSecuritySteps = this.securityRunMode === 'skip';
|
||
this.showSecurityRunModal = false;
|
||
this.runTask('security_chatgpt', { skipCompletedSecuritySteps });
|
||
},
|
||
runChangeCountry() { this.runTask('open_url', { url: 'https://policies.google.com/country-association-form?hl=en' }); },
|
||
runChangeLanguage() { this.runTask('change_language'); },
|
||
runAgGetCredential() { this.runTask('ag_get_credential'); },
|
||
runAistudioCombo() { this.runTask('aistudio_combo'); },
|
||
async openAistudioKeyModal(account) {
|
||
if (!account || !account.email) return;
|
||
this.aistudioKeyEmail = account.email;
|
||
this.aistudioKeyRecord = null;
|
||
try {
|
||
const url = `/api/operations/aistudio-api-keys?email=${encodeURIComponent(account.email)}`;
|
||
const r = await fetch(url).then(x => x.json());
|
||
this.aistudioKeyRecord = (r && r.success && r.data) || null;
|
||
if (!this.aistudioKeyRecord) this.addLog(`⚠️ ${account.email} 暂无已保存的 AI Studio API Key`, 'warn');
|
||
} catch (e) {
|
||
this.aistudioKeyRecord = null;
|
||
this.addLog(`⚠️ 读取 ${account.email} 的 AI Studio API Key 失败`, 'warn');
|
||
}
|
||
this.showAistudioKeysModal = true;
|
||
},
|
||
copyAistudioKeys() {
|
||
const text = JSON.stringify(this.aistudioKeyRecord || {}, null, 2);
|
||
const done = () => { this.copyToastVisible = true; setTimeout(() => { this.copyToastVisible = false; }, 1500); };
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(text).then(done).catch(() => {});
|
||
}
|
||
},
|
||
runGeminiCliGetCredential() { this.runTask('gemini_cli_get_credential'); },
|
||
async openGoogleAccountAgModal() {
|
||
if (this.isRunning) return;
|
||
await this.loadGoogleAccountCredentialMeta();
|
||
if (!this.googleAccountCredentialMeta.configured) {
|
||
this.addLog('⚠️ 请先在设置中保存取号 API Key 或完整链接', 'warn');
|
||
this.activeTab = 'settings';
|
||
return;
|
||
}
|
||
this.googleAccountAgQuantity = Math.max(1, Number.parseInt(this.googleAccountAgQuantity, 10) || 1);
|
||
this.googleAccountAgConcurrency = Math.max(1, Math.min(30, Number.parseInt(this.googleAccountAgConcurrency, 10) || 1));
|
||
this.showGoogleAccountAgModal = true;
|
||
},
|
||
async confirmGoogleAccountAgTask() {
|
||
const quantity = Number.parseInt(this.googleAccountAgQuantity, 10);
|
||
const concurrency = Number.parseInt(this.googleAccountAgConcurrency, 10);
|
||
if (!Number.isFinite(quantity) || quantity < 1 || quantity > 5000) {
|
||
this.addLog('⚠️ 取号数量必须为 1~5000', 'warn');
|
||
return;
|
||
}
|
||
if (!Number.isFinite(concurrency) || concurrency < 1 || concurrency > 30) {
|
||
this.addLog('⚠️ 并发必须为 1~30', 'warn');
|
||
return;
|
||
}
|
||
this.showGoogleAccountAgModal = false;
|
||
await this.runTask('google_account_ag_credential', { quantity, threadCount: concurrency }, []);
|
||
},
|
||
runAgRevokeAuthorization() {
|
||
if (this.selectedCount === 0) {
|
||
this.addLog('⚠️ 请先勾选要取消授权的账号', 'warn');
|
||
return;
|
||
}
|
||
if (!confirm(`确认取消已选 ${this.selectedCount} 个账号的 Google Antigravity 授权?`)) return;
|
||
this.runTask('ag_revoke_authorization');
|
||
},
|
||
runCheckRestrictions() { this.runTask('check_restrictions'); },
|
||
runCheckSecurityDates() { this.runTask('check_security_dates'); },
|
||
|
||
// ==================== 自定义执行 ====================
|
||
|
||
/** 所有可勾选的任务定义 */
|
||
_customTaskDefs: [
|
||
{ key: 'change_password', label: '改密码' },
|
||
{ key: 'change_2fa', label: '改2FA' },
|
||
{ key: 'chatgpt_register', label: 'ChatGPT注册/登录' },
|
||
{ key: 'openai_get_auth', label: '提取授权JSON' },
|
||
{ key: 'ag_revoke_authorization', label: '取消授权反重力' },
|
||
{ key: 'ag_get_credential', label: '获取凭证' },
|
||
{ key: 'change_language', label: '改语言' },
|
||
{ key: 'detect_country', label: '查看国家' },
|
||
{ key: 'check_restrictions', label: '检查账号限制' },
|
||
{ key: 'check_security_dates', label: '查安全日期' },
|
||
],
|
||
|
||
toggleCustomMode() {
|
||
this.customMode = !this.customMode;
|
||
if (!this.customMode) {
|
||
// 退出自定义模式时只清空运行状态,保留勾选记忆
|
||
this.customQueue = [];
|
||
this.customCurrentIndex = -1;
|
||
this.customRunning = false;
|
||
this.customStoppedIndex = null;
|
||
}
|
||
},
|
||
|
||
isCustomTaskChecked(key) {
|
||
return this.customCheckedTasks.includes(key);
|
||
},
|
||
|
||
toggleCustomTask(key) {
|
||
const idx = this.customCheckedTasks.indexOf(key);
|
||
if (idx >= 0) {
|
||
this.customCheckedTasks.splice(idx, 1);
|
||
} else {
|
||
this.customCheckedTasks.push(key);
|
||
}
|
||
try { localStorage.setItem('customCheckedTasks', JSON.stringify(this.customCheckedTasks)); } catch (_) {}
|
||
},
|
||
|
||
getCustomTaskLabel(key) {
|
||
const def = this._customTaskDefs.find(d => d.key === key);
|
||
if (def) return def.label;
|
||
// 旧key兼容映射
|
||
const legacy = {
|
||
login: '登录', open_payment: '删除支付资料', open_one_ai: '半价Ultra', check_eligibility: '查资格',
|
||
remove_recovery_email: '删除辅助邮箱', detect_country: '查看国家',
|
||
};
|
||
return legacy[key] || key;
|
||
},
|
||
|
||
async runCustomPipeline() {
|
||
if (this.customCheckedTasks.length === 0) {
|
||
this.addLog('⚠️ 请先勾选要执行的任务', 'warn');
|
||
return;
|
||
}
|
||
if (this.isRunning || this.customRunning) {
|
||
this.addLog('⚠️ 有任务正在运行', 'warn');
|
||
return;
|
||
}
|
||
// 构建队列(按勾选顺序)
|
||
this.customQueue = [...this.customCheckedTasks];
|
||
this.customRunning = true;
|
||
this.customStoppedIndex = null;
|
||
|
||
// 构建任务列表 UI
|
||
const taskAccounts = this.getTaskAccounts();
|
||
this.customJobList = taskAccounts.map(a => ({
|
||
email: a.email,
|
||
tasks: [...this.customCheckedTasks],
|
||
currentTaskLabel: '等待中',
|
||
completedCount: 0,
|
||
status: 'pending',
|
||
message: ''
|
||
}));
|
||
this._rebuildDashboardStats();
|
||
|
||
const taskLabels = this.customQueue.map(k => this.getCustomTaskLabel(k)).join(' → ');
|
||
this.addLog(`🔗 自定义流水线启动,共 ${this.customQueue.length} 个任务, ${taskAccounts.length} 个账号: ${taskLabels}`, 'info');
|
||
|
||
// 标记所有 job 为 running
|
||
this.customJobList.forEach(job => {
|
||
job.status = 'running';
|
||
job.currentTaskLabel = this.getCustomTaskLabel(this.customQueue[0]);
|
||
});
|
||
|
||
// 发送单个 custom_pipeline 任务到后端,后端一次打开浏览器执行全部
|
||
// 传递步骤对象(带参数)
|
||
const stepsWithParams = this.customCheckedTasks.map(key => {
|
||
const stepObj = { key };
|
||
const def = this._customTaskDefs.find(d => d.key === key);
|
||
if (def && def.params && this.customStepParams[key]) {
|
||
stepObj.params = { ...this.customStepParams[key] };
|
||
}
|
||
return stepObj;
|
||
});
|
||
this._saveCustomJobList();
|
||
// 若通过模板启动,带上模板名;否则尝试匹配当前选中的模板
|
||
let templateName = this._currentTemplateName || '';
|
||
if (!templateName && this.selectedTemplateId) {
|
||
const tpl = this.savedTemplates.find(t => t.id === this.selectedTemplateId);
|
||
if (tpl) templateName = tpl.name || '';
|
||
}
|
||
const age = parseInt(this.settings.chatgpt_age || '23', 10) || 23;
|
||
const started = await this.runTask('custom_pipeline', { tasks: stepsWithParams, templateName, age });
|
||
if (!started) {
|
||
this.customRunning = false;
|
||
this.customJobList.forEach(job => {
|
||
job.status = 'error';
|
||
job.currentTaskLabel = '启动失败';
|
||
job.message = '任务未能启动';
|
||
});
|
||
this._saveCustomJobList();
|
||
}
|
||
// 用完清掉,避免下次误带
|
||
this._currentTemplateName = '';
|
||
},
|
||
|
||
|
||
stopCustomPipeline() {
|
||
this.customStoppedIndex = this.customCurrentIndex;
|
||
this.customRunning = false;
|
||
this.customCurrentIndex = -1;
|
||
this.addLog('⏹️ 自定义流水线已中断', 'warn');
|
||
// 更新任务列表状态
|
||
this.customJobList.forEach(job => {
|
||
if (job.status === 'running' || job.status === 'pending') {
|
||
job.status = 'error';
|
||
job.message = '已中断';
|
||
}
|
||
});
|
||
this._saveCustomJobList();
|
||
this.stopTask();
|
||
},
|
||
|
||
clearCustomJobList() {
|
||
this.customJobList = [];
|
||
this._rebuildDashboardStats();
|
||
this._saveCustomJobList();
|
||
},
|
||
|
||
// ==================== 自定义模板管理 ====================
|
||
|
||
async loadTemplates() {
|
||
try {
|
||
const res = await fetch('/api/templates');
|
||
const data = await res.json();
|
||
if (data.success) this.savedTemplates = data.data;
|
||
} catch (_) {}
|
||
},
|
||
|
||
async saveAsTemplate() {
|
||
if (!this.templateName.trim()) {
|
||
this.addLog('⚠️ 请输入模板名称', 'warn');
|
||
return;
|
||
}
|
||
if (this.customCheckedTasks.length === 0) {
|
||
this.addLog('⚠️ 请先勾选要保存的步骤', 'warn');
|
||
return;
|
||
}
|
||
// 构建步骤列表(带参数)
|
||
const steps = this.customCheckedTasks.map(key => {
|
||
const stepObj = { key };
|
||
const def = this._customTaskDefs.find(d => d.key === key);
|
||
if (def && def.params && this.customStepParams[key]) {
|
||
stepObj.params = { ...this.customStepParams[key] };
|
||
}
|
||
return stepObj;
|
||
});
|
||
try {
|
||
const res = await fetch('/api/templates', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: this.templateName.trim(), steps }),
|
||
});
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
this.addLog(`✅ 模板「${this.templateName}」已保存`, 'success');
|
||
this.templateName = '';
|
||
await this.loadTemplates();
|
||
} else {
|
||
this.addLog(`❌ 保存失败: ${data.msg}`, 'error');
|
||
}
|
||
} catch (e) {
|
||
this.addLog(`❌ 保存失败: ${e.message}`, 'error');
|
||
}
|
||
},
|
||
|
||
loadTemplateSteps(template) {
|
||
// 用模板的步骤填充勾选
|
||
this.customCheckedTasks = template.steps.map(s => s.key || s);
|
||
// 填充参数
|
||
this.customStepParams = {};
|
||
for (const s of template.steps) {
|
||
if (s.params) this.customStepParams[s.key] = { ...s.params };
|
||
}
|
||
try { localStorage.setItem('customCheckedTasks', JSON.stringify(this.customCheckedTasks)); } catch (_) {}
|
||
this.addLog(`📋 已加载模板「${template.name}」: ${this.customCheckedTasks.map(k => this.getCustomTaskLabel(k)).join(' → ')}`, 'info');
|
||
},
|
||
|
||
async runTemplate(template) {
|
||
// 先加载步骤再运行
|
||
this.loadTemplateSteps(template);
|
||
// 记录当前模板名,流水线完成消息会带上
|
||
this._currentTemplateName = template && template.name ? template.name : '';
|
||
// 确保在自定义模式
|
||
if (!this.customMode) this.customMode = true;
|
||
await this.$nextTick();
|
||
this.runCustomPipeline();
|
||
},
|
||
|
||
async deleteTemplateById(id) {
|
||
if (!confirm('确认删除此模板?')) return;
|
||
try {
|
||
const res = await fetch(`/api/templates/${id}`, { method: 'DELETE' });
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
this.addLog('🗑️ 模板已删除', 'info');
|
||
await this.loadTemplates();
|
||
} else {
|
||
this.addLog(`❌ 删除失败: ${data.msg}`, 'error');
|
||
}
|
||
} catch (e) {
|
||
this.addLog(`❌ 删除失败: ${e.message}`, 'error');
|
||
}
|
||
},
|
||
|
||
getStepParamValue(stepKey, paramKey) {
|
||
return this.customStepParams[stepKey] && this.customStepParams[stepKey][paramKey];
|
||
},
|
||
|
||
setStepParamValue(stepKey, paramKey, value) {
|
||
if (!this.customStepParams[stepKey]) this.customStepParams[stepKey] = {};
|
||
this.customStepParams[stepKey][paramKey] = value;
|
||
},
|
||
|
||
selectTemplate(tpl) {
|
||
this.selectedTemplateId = tpl.id;
|
||
this.loadTemplateSteps(tpl);
|
||
},
|
||
|
||
runSelectedTemplate() {
|
||
const tpl = this.savedTemplates.find(t => t.id === this.selectedTemplateId);
|
||
if (!tpl) {
|
||
this.addLog('⚠️ 请先选择一个模板', 'warn');
|
||
return;
|
||
}
|
||
this.runTemplate(tpl);
|
||
},
|
||
|
||
_saveCustomJobList() {
|
||
try { localStorage.setItem('customJobList', JSON.stringify(this.customJobList)); } catch (_) {}
|
||
},
|
||
|
||
// ---- 自定义运行列表 辅助方法 ----
|
||
|
||
getCustomStepClass(ti) {
|
||
if (!this.customRunning && this.customStoppedIndex == null) return 'step-pending';
|
||
const cur = this.customRunning ? this.customCurrentIndex : (this.customStoppedIndex ?? 0);
|
||
const total = this.customCheckedTasks.length;
|
||
const allDone = !this.customRunning && cur > total;
|
||
if (ti < cur - 1 || allDone) return 'step-done';
|
||
if (ti === cur - 1) return this.customRunning ? 'step-running' : 'step-stopped';
|
||
return 'step-pending';
|
||
},
|
||
|
||
getCustomStepIcon(ti) {
|
||
if (!this.customRunning && this.customStoppedIndex == null) return '<span style="opacity:.4">⏳</span>';
|
||
const cur = this.customRunning ? this.customCurrentIndex : (this.customStoppedIndex ?? 0);
|
||
const total = this.customCheckedTasks.length;
|
||
const allDone = !this.customRunning && cur > total;
|
||
if (ti < cur - 1 || allDone) return '✅';
|
||
if (ti === cur - 1) return this.customRunning ? '<span class="custom-run-spinner"></span>' : '⏹️';
|
||
return '<span style="opacity:.4">⏳</span>';
|
||
},
|
||
|
||
getCustomStepStatusText(ti) {
|
||
if (!this.customRunning && this.customStoppedIndex == null) return '等待';
|
||
const cur = this.customRunning ? this.customCurrentIndex : (this.customStoppedIndex ?? 0);
|
||
const total = this.customCheckedTasks.length;
|
||
const allDone = !this.customRunning && cur > total;
|
||
if (ti < cur - 1 || allDone) return '已完成';
|
||
if (ti === cur - 1) return this.customRunning ? '执行中...' : '已中断';
|
||
return '等待';
|
||
},
|
||
|
||
/** 获取自定义流水线涉及的账号列表 */
|
||
getCustomRunAccounts() {
|
||
// 运行中优先使用活跃批次;运行后使用最近一次批次。
|
||
const trackedEmails = this.customRunning && this._activeTaskEmails.length > 0
|
||
? this._activeTaskEmails
|
||
: this._lastTaskEmails;
|
||
if ((this.customRunning || this.customStoppedIndex != null) && trackedEmails && trackedEmails.length > 0) {
|
||
const emailSet = new Set(trackedEmails);
|
||
return this.accounts.filter(a => emailSet.has(a.email));
|
||
}
|
||
// 运行前:用当前选中的账号,没选就用全部
|
||
const selected = this.selectedAccounts;
|
||
return selected.length > 0 ? selected : this.accounts;
|
||
},
|
||
|
||
getCustomRunAccountCount() {
|
||
return this.getCustomRunAccounts().length;
|
||
},
|
||
|
||
// ==================== SheerLink 结果 ====================
|
||
_showSheerLinkResults(taskEmails) {
|
||
const emailSet = new Set(taskEmails);
|
||
const results = this.accounts
|
||
.filter(a => emailSet.size === 0 || emailSet.has(a.email))
|
||
.filter(a => a.sheerid_link)
|
||
.map(a => ({ email: a.email, link: a.sheerid_link }));
|
||
if (results.length > 0) {
|
||
this.sheerLinkResults = results;
|
||
this.showSheerLinkResultModal = true;
|
||
}
|
||
},
|
||
|
||
copyOnlySheerLinks() {
|
||
const text = this.sheerLinkResults.map(r => r.link).join('\n');
|
||
navigator.clipboard.writeText(text).then(() => this.showCopyToast());
|
||
},
|
||
|
||
// ==================== 日志 ====================
|
||
addLog(message, type = 'default') {
|
||
const ts = new Date().toLocaleTimeString('zh-CN');
|
||
this._pendingLogEntries.push({ message: `[${ts}] ${message}`, type });
|
||
if (this._pendingLogEntries.length > this._logLimit * 2) {
|
||
this._pendingLogEntries.splice(0, this._pendingLogEntries.length - this._logLimit);
|
||
}
|
||
if (!this._logFlushTimer) {
|
||
this._logFlushTimer = setTimeout(() => this._flushLogs(), 80);
|
||
}
|
||
|
||
// 提取 [email] 格式的账号日志,更新对应账号的实时消息列
|
||
const bracketEmailRe = /^\[([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})\]\s*/;
|
||
const bem = message.match(bracketEmailRe);
|
||
if (bem) {
|
||
const email = bem[1];
|
||
const display = message.slice(bem[0].length).trim() || '处理中...';
|
||
this._setLiveAccountMessage(email, display);
|
||
}
|
||
},
|
||
|
||
_flushLogs() {
|
||
if (this._logFlushTimer) {
|
||
clearTimeout(this._logFlushTimer);
|
||
this._logFlushTimer = null;
|
||
}
|
||
if (this._pendingLogEntries.length === 0) return;
|
||
|
||
const pending = this._pendingLogEntries.slice(-this._logLimit);
|
||
this._pendingLogEntries = [];
|
||
const keepExisting = Math.max(0, this._logLimit - pending.length);
|
||
if (this.logs.length > keepExisting) {
|
||
this.logs.splice(0, this.logs.length - keepExisting);
|
||
}
|
||
this.logs.push(...pending);
|
||
|
||
if (!this.logDrawerOpen || typeof this.$nextTick !== 'function') return;
|
||
this.$nextTick(() => {
|
||
const container = document.getElementById('log-container');
|
||
if (container) container.scrollTop = container.scrollHeight;
|
||
});
|
||
},
|
||
|
||
clearLogs() {
|
||
if (this._logFlushTimer) clearTimeout(this._logFlushTimer);
|
||
this._logFlushTimer = null;
|
||
this._pendingLogEntries = [];
|
||
this.logs = [];
|
||
this.accountLogs = {};
|
||
},
|
||
|
||
toggleLogDrawer() {
|
||
this.logDrawerOpen = !this.logDrawerOpen;
|
||
if (this.logDrawerOpen) this._flushLogs();
|
||
console.log(`[UI] log drawer ${this.logDrawerOpen ? 'open' : 'close'}`);
|
||
},
|
||
|
||
logClass(type) {
|
||
const map = { success: 'log-success', error: 'log-error', warn: 'log-warn', info: 'log-info' };
|
||
return map[type] || '';
|
||
},
|
||
|
||
// ==================== 辅助 ====================
|
||
statusClass(status) {
|
||
let s = status == null ? '未登录' : String(status);
|
||
s = s.replace(/[\r\n\t]/g, ' ').replace(/\s+/g, ' ').trim();
|
||
if (!s) s = '未登录';
|
||
if (s === '已登录' || s.startsWith('已登录') || s.startsWith('登录成功')) return 'status-登录成功';
|
||
if (s.startsWith('登录失败')) return 'status-登录失败';
|
||
if (s.startsWith('订阅成功')) return 'status-订阅成功';
|
||
if (s.startsWith('改密成功')) return 'status-改密成功';
|
||
if (s.startsWith('改密失败')) return 'status-改密失败';
|
||
if (s.startsWith('2FA修改成功')) return 'status-2FA修改成功';
|
||
if (s.startsWith('2FA修改失败')) return 'status-2FA修改失败';
|
||
if (s.startsWith('未验证已改密和2FA')) return 'status-未验证已改密和2FA';
|
||
if (s.startsWith('已验证改密和2FA')) return 'status-已验证改密和2FA';
|
||
if (s.startsWith('未注册成功已改密和2FA')) return 'status-ChatGPT失败';
|
||
if (s.startsWith('ChatGPT成功') || s === '已注册') return 'status-ChatGPT成功';
|
||
if (s.startsWith('ChatGPT失败') || s === '失败') return 'status-ChatGPT失败';
|
||
if (s.startsWith('已提取')) return 'status-登录成功';
|
||
if (s.startsWith('提取失败')) return 'status-登录失败';
|
||
if (s === '查询成功' || s === '部分成功') return 'status-登录成功';
|
||
if (s === '查询失败'
|
||
|| s === '授权JSON无效'
|
||
|| s === '保存失败'
|
||
|| s.startsWith('Token失效')
|
||
|| s.startsWith('Token刷新失败')
|
||
|| s.startsWith('缺少Access Token')
|
||
|| s.startsWith('缺少Refresh Token')
|
||
|| s.startsWith('代理认证失败')
|
||
|| s.startsWith('代理连接失败')
|
||
|| s.startsWith('直连失败')
|
||
|| s.startsWith('浏览器代理格式错误')
|
||
|| s.startsWith('套餐查询异常')
|
||
|| s.startsWith('套餐任务异常')) return 'status-登录失败';
|
||
if (s === '查询中' || s === '无授权JSON' || s === '无浏览器') return 'status-未登录';
|
||
if (s.startsWith('充值成功')) return 'status-登录成功';
|
||
if (s.startsWith('充值失败')) return 'status-登录失败';
|
||
if (s.startsWith('充值中')) return 'status-未登录';
|
||
if (s.startsWith('等待已确认')) return 'status-等待已确认';
|
||
if (s.startsWith('账号本身已有会员')) return 'status-账号本身已有会员';
|
||
if (s.startsWith('认证成功')) return 'status-认证成功';
|
||
if (s.startsWith('认证失败')) return 'status-认证失败';
|
||
if (s.startsWith('绑卡成功')) return 'status-绑卡成功';
|
||
if (s.startsWith('绑卡失败')) return 'status-绑卡失败';
|
||
if (s.startsWith('有资格')) return 'status-有资格';
|
||
if (s.startsWith('无资格')) return 'status-无资格';
|
||
return `status-${s}`;
|
||
},
|
||
|
||
formatDate(ts) {
|
||
if (!ts) return '';
|
||
try {
|
||
const raw = String(ts).trim();
|
||
// SQLite CURRENT_TIMESTAMP 保存 UTC,但格式不带时区。先补成标准
|
||
// UTC ISO,再强制转换到北京时间,避免被浏览器误当成本地时间。
|
||
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(raw)
|
||
? `${raw.replace(' ', 'T')}Z`
|
||
: raw;
|
||
const date = new Date(normalized);
|
||
if (Number.isNaN(date.getTime())) return raw;
|
||
return date.toLocaleString('zh-CN', {
|
||
timeZone: 'Asia/Shanghai',
|
||
hour12: false,
|
||
});
|
||
} catch (e) {
|
||
return ts;
|
||
}
|
||
},
|
||
|
||
formatOpenAIPlanType(value) {
|
||
const raw = String(value || '').trim();
|
||
if (!raw) return '';
|
||
const labels = {
|
||
free: 'Free', plus: 'Plus', pro: 'Pro', team: 'Team',
|
||
business: 'Business', enterprise: 'Enterprise', edu: 'Edu',
|
||
};
|
||
return labels[raw.toLowerCase()] || raw;
|
||
},
|
||
|
||
formatOpenAIPlanCell(account) {
|
||
if (!account) return '-';
|
||
return this.formatOpenAIPlanType(account.openai_plan_type)
|
||
|| String(account.openai_plan_status || '').trim()
|
||
|| '-';
|
||
},
|
||
|
||
formatOpenAIUsagePercent(value) {
|
||
if (value === null || value === undefined || value === '') return '';
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric)) return '';
|
||
return `${Math.round(numeric * 100) / 100}%`;
|
||
},
|
||
|
||
formatOpenAIPlanTitle(account) {
|
||
if (!account) return '';
|
||
return [
|
||
account.openai_plan_status ? `状态:${account.openai_plan_status}` : '',
|
||
account.openai_plan_type ? `套餐:${this.formatOpenAIPlanType(account.openai_plan_type)}` : '',
|
||
this.formatOpenAIUsagePercent(account.openai_usage_percent)
|
||
? `主窗口用量:${this.formatOpenAIUsagePercent(account.openai_usage_percent)}`
|
||
: '',
|
||
account.openai_subscription_expires_at
|
||
? `订阅到期:${this.formatDate(account.openai_subscription_expires_at)}`
|
||
: '',
|
||
account.openai_plan_checked_at
|
||
? `查询时间:${this.formatDate(account.openai_plan_checked_at)}`
|
||
: '',
|
||
].filter(Boolean).join('\n');
|
||
},
|
||
|
||
formatCardNumber(num) {
|
||
if (!num) return '';
|
||
return num.slice(0, 4) + ' **** **** ' + num.slice(-4);
|
||
},
|
||
|
||
async copyCell(text) {
|
||
if (!text) return;
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
} catch (e) {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
}
|
||
// 显示 toast
|
||
this.copyToastVisible = true;
|
||
clearTimeout(this.copyToastTimer);
|
||
this.copyToastTimer = setTimeout(() => { this.copyToastVisible = false; }, 1500);
|
||
},
|
||
|
||
get allSelected() {
|
||
const visible = this.filteredAccounts;
|
||
return visible.length > 0 && visible.every(account => this.selectedEmails.has(account.email));
|
||
},
|
||
|
||
get selectedCount() {
|
||
return this.selectedEmails.size;
|
||
},
|
||
|
||
async copySelectedEmails() {
|
||
const list = this.selectedAccounts.map(a => a.email).filter(Boolean);
|
||
if (list.length === 0) return this.addLog('⚠️ 没有勾选任何账号', 'warn');
|
||
const text = list.join('\n');
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
} catch (e) {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
}
|
||
this.addLog(`✅ 已复制 ${list.length} 个邮箱到剪贴板`, 'success');
|
||
},
|
||
|
||
|
||
|
||
setStatusFilter(status) {
|
||
if (this.isRunning && status !== '任务中') {
|
||
const t = this._activeTaskType || null;
|
||
if (t === 'login' && (status === '登录成功' || status === '登录失败' || status === '订阅成功')) {
|
||
status = '任务中';
|
||
} else if (t === 'change_password' && (status === '改密成功' || status === '改密失败' || status === '未验证已改密和2FA' || status === '未注册成功已改密和2FA')) {
|
||
status = '任务中';
|
||
} else if (t === 'chatgpt_register' && (status === 'ChatGPT成功' || status === 'ChatGPT失败' || status === '已验证改密和2FA' || status === '未注册成功已改密和2FA')) {
|
||
status = '任务中';
|
||
} else if (t === 'change_2fa' && (status === '未改2FA' || status === '2FA修改成功' || status === '2FA修改失败' || status === '未验证已改密和2FA' || status === '未注册成功已改密和2FA')) {
|
||
status = '任务中';
|
||
} else if (t === 'security_chatgpt' && ['改密失败', '2FA修改失败', '未验证已改密和2FA', '未注册成功已改密和2FA'].includes(status)) {
|
||
status = '任务中';
|
||
} else if (t === 'bind_card' && (status === '订阅成功' || status === '绑卡失败' || status === '无资格' || status === '有资格')) {
|
||
status = '任务中';
|
||
}
|
||
}
|
||
this.resetAccountPage();
|
||
|
||
// 如果点击的是当前已选中的状态,则回到"全部"并清空选择
|
||
if (this.statusFilter === status) {
|
||
this.statusFilter = '全部';
|
||
this.selectedEmails = new Set();
|
||
return;
|
||
}
|
||
|
||
// 切换到新状态
|
||
this.statusFilter = status;
|
||
this.selectedEmails = new Set();
|
||
|
||
// 自动勾选符合筛选条件的账号(基于客户筛选后的列表)
|
||
const pool = this.customerFilteredAccounts;
|
||
let matchedAccounts = [];
|
||
if (status === '未改2FA') {
|
||
// 特殊处理:2FA是小写的账号
|
||
matchedAccounts = pool.filter(acc => {
|
||
const st = acc.status || '未登录';
|
||
if (st === '订阅成功' || st === '登录失败') return false;
|
||
return acc.secret_key && acc.secret_key === acc.secret_key.toLowerCase() && /[a-z]/.test(acc.secret_key);
|
||
});
|
||
} else if (status === '任务中') {
|
||
matchedAccounts = pool.filter(acc => this._isTaskAccountInProgress(acc));
|
||
} else if (status === '刚刚运行') {
|
||
const emailSet = new Set(this._lastTaskEmails || []);
|
||
matchedAccounts = pool.filter(acc => emailSet.has(acc.email));
|
||
} else if (status === '家庭组母号') {
|
||
matchedAccounts = pool.filter(acc => acc.tag === '家庭组母号');
|
||
} else if (status !== '全部') {
|
||
// 普通状态筛选
|
||
matchedAccounts = pool.filter(acc => (acc.status || '未登录') === status);
|
||
}
|
||
|
||
if (matchedAccounts.length > 0) {
|
||
this.selectedEmails = new Set(matchedAccounts.map(a => a.email));
|
||
}
|
||
},
|
||
|
||
setTierFilter(level, value) {
|
||
// 切换特殊筛选回正常模式
|
||
this.statusFilter = '全部';
|
||
|
||
if (level === 1) {
|
||
// 点击已选中的 → 回到全部
|
||
if (this.tierFilter.level1 === value && value !== '全部') {
|
||
this.tierFilter = { level1: '全部', level2: '全部', level3: '全部', errorCode: null };
|
||
} else {
|
||
this.tierFilter = { level1: value, level2: '全部', level3: '全部', errorCode: null };
|
||
}
|
||
} else if (level === 2) {
|
||
if (this.tierFilter.level2 === value && value !== '全部') {
|
||
this.tierFilter = { ...this.tierFilter, level2: '全部', level3: '全部', errorCode: null };
|
||
} else {
|
||
this.tierFilter = { ...this.tierFilter, level2: value, level3: '全部', errorCode: null };
|
||
}
|
||
} else if (level === 3) {
|
||
if (this.tierFilter.level3 === value && value !== '全部') {
|
||
this.tierFilter = { ...this.tierFilter, level3: '全部' };
|
||
} else {
|
||
this.tierFilter = { ...this.tierFilter, level3: value };
|
||
}
|
||
} else if (level === 'error') {
|
||
if (this.tierFilter.errorCode === value) {
|
||
this.tierFilter = { ...this.tierFilter, errorCode: null };
|
||
} else {
|
||
this.tierFilter = { ...this.tierFilter, errorCode: value };
|
||
}
|
||
}
|
||
// 自动勾选当前筛选结果
|
||
this.selectedEmails = new Set(this.filteredAccounts.map(a => a.email));
|
||
this._rebuildDashboardStats();
|
||
this.resetAccountPage();
|
||
},
|
||
|
||
onCustomerFilterChange() {
|
||
this.selectedEmails = new Set();
|
||
this.statusFilter = '全部';
|
||
this._rebuildDashboardStats();
|
||
this.resetAccountPage();
|
||
},
|
||
|
||
// ==================== TOTP ====================
|
||
_base32Decode(input) {
|
||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||
const s = input.toUpperCase().replace(/[\s=]+/g, '');
|
||
let bits = 0, value = 0;
|
||
const bytes = [];
|
||
for (const char of s) {
|
||
const idx = alphabet.indexOf(char);
|
||
if (idx === -1) continue;
|
||
value = (value << 5) | idx;
|
||
bits += 5;
|
||
if (bits >= 8) { bytes.push((value >>> (bits - 8)) & 0xff); bits -= 8; }
|
||
}
|
||
return new Uint8Array(bytes);
|
||
},
|
||
|
||
async _computeTotp(secret) {
|
||
try {
|
||
const keyBytes = this._base32Decode(secret);
|
||
const counter = Math.floor(Date.now() / 1000 / 30);
|
||
const buf = new Uint8Array(8);
|
||
let c = counter;
|
||
for (let i = 7; i >= 0; i--) { buf[i] = c & 0xff; c = Math.floor(c / 256); }
|
||
const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
|
||
const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, buf));
|
||
const offset = sig[19] & 0xf;
|
||
const code = ((sig[offset] & 0x7f) << 24 | sig[offset+1] << 16 | sig[offset+2] << 8 | sig[offset+3]) % 1000000;
|
||
return code.toString().padStart(6, '0');
|
||
} catch { return null; }
|
||
},
|
||
|
||
async updateTotpCodes() {
|
||
const updateId = ++this._totpUpdateId;
|
||
const visibleAccounts = this.pagedAccounts.filter(account => account.secret_key);
|
||
const entries = await Promise.all(visibleAccounts.map(async account => [
|
||
account.email,
|
||
await this._computeTotp(account.secret_key),
|
||
]));
|
||
// 翻页或筛选后,丢弃上一页尚未完成的异步计算。
|
||
if (updateId !== this._totpUpdateId) return;
|
||
this.totpCodes = Object.fromEntries(entries);
|
||
},
|
||
|
||
openEditAccount(account) {
|
||
this.editAccount = { ...account };
|
||
this.showEditAccountModal = true;
|
||
},
|
||
|
||
async saveEditAccount() {
|
||
if (!this.editAccount || !this.editAccount.email) return;
|
||
const payload = {
|
||
password: this.editAccount.password || '',
|
||
recovery_email: this.editAccount.recovery_email || '',
|
||
secret_key: this.editAccount.secret_key || '',
|
||
sheerid_link: this.editAccount.sheerid_link || '',
|
||
proxy_ip: this.editAccount.proxy_ip || '',
|
||
status: this.editAccount.status || '未登录',
|
||
message: this.editAccount.message || '',
|
||
country: this.editAccount.country || '',
|
||
browser_id: this.editAccount.browser_id || '',
|
||
customer_name: this.editAccount.customer_name || '',
|
||
};
|
||
try {
|
||
const res = await fetch(`/api/accounts/${encodeURIComponent(this.editAccount.email)}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
this.addLog(`账号已更新: ${this.editAccount.email}`, 'info');
|
||
this.showEditAccountModal = false;
|
||
await this.loadAccounts();
|
||
} else {
|
||
this.addLog(`更新失败: ${data.msg}`, 'error');
|
||
}
|
||
} catch (e) {
|
||
this.addLog(`更新失败: ${e.message}`, 'error');
|
||
}
|
||
},
|
||
|
||
startInlineEditAccountRefund(email) {
|
||
if (!email) return;
|
||
this.inlineEditStatusEmail = null;
|
||
this.inlineEditRechargeStatusEmail = null;
|
||
this.inlineEditRefundProcessEmail = null;
|
||
this.inlineEditAccountRefundEmail = email;
|
||
},
|
||
|
||
async quickSetAccountRefundStatus(email, newStatus) {
|
||
this.inlineEditAccountRefundEmail = null;
|
||
const rawStatus = String(newStatus || '').trim();
|
||
const status = !rawStatus || rawStatus === '未操作' ? '无状态' : rawStatus;
|
||
if (!this.accountRefundStatusOptions.includes(status)) {
|
||
this.addLog('账号退款状态无效', 'warn');
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch(`/api/accounts/${encodeURIComponent(email)}/account-refund-status`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ status }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok || !data.success) throw new Error(data.msg || '账号退款状态修改失败');
|
||
const account = this._accountByEmail.get(email);
|
||
if (account && data.data) Object.assign(account, data.data);
|
||
this.addLog(`已修改账号退款状态: ${email} → ${status}`, 'success');
|
||
} catch (e) {
|
||
this.addLog(`账号退款状态修改失败: ${e.message}`, 'error');
|
||
}
|
||
},
|
||
|
||
startInlineEditRefundProcess(email) {
|
||
if (!email) return;
|
||
this.inlineEditStatusEmail = null;
|
||
this.inlineEditRechargeStatusEmail = null;
|
||
this.inlineEditAccountRefundEmail = null;
|
||
this.inlineEditRefundProcessEmail = email;
|
||
},
|
||
|
||
async quickSetRefundProcessStatus(email, newStatus) {
|
||
this.inlineEditRefundProcessEmail = null;
|
||
const status = String(newStatus || '').trim() || '未发起';
|
||
if (!this.refundProcessStatusOptions.includes(status)) {
|
||
this.addLog('退款流程状态无效', 'warn');
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch(`/api/accounts/${encodeURIComponent(email)}/refund-process-status`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ status }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok || !data.success) throw new Error(data.msg || '退款流程状态修改失败');
|
||
const account = this._accountByEmail.get(email);
|
||
if (account && data.data) Object.assign(account, data.data);
|
||
this.addLog(`已修改退款流程状态: ${email} → ${status}`, 'success');
|
||
} catch (e) {
|
||
this.addLog(`退款流程状态修改失败: ${e.message}`, 'error');
|
||
}
|
||
},
|
||
|
||
startInlineEditRechargeStatus(email) {
|
||
if (!email) return;
|
||
this.inlineEditStatusEmail = null;
|
||
this.inlineEditAccountRefundEmail = null;
|
||
this.inlineEditRefundProcessEmail = null;
|
||
this.inlineEditRechargeStatusEmail = email;
|
||
},
|
||
|
||
// 保留旧入口,统一切换为与“状态”列相同的原地下拉编辑。
|
||
editRechargeStatus(account) {
|
||
if (!account || !account.email) return;
|
||
this.startInlineEditRechargeStatus(account.email);
|
||
},
|
||
|
||
async quickSetRechargeStatus(email, newStatus) {
|
||
this.inlineEditRechargeStatusEmail = null;
|
||
const status = String(newStatus || '').trim() || '未充值';
|
||
const allowed = new Set(['未充值', '充值中', '等待已确认', '充值成功', '充值失败', '账号本身已有会员']);
|
||
if (!allowed.has(status)) {
|
||
this.addLog('充值状态无效,请使用提示中的状态', 'warn');
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch(`/api/accounts/${encodeURIComponent(email)}/recharge-status`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ status }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok || !data.success) throw new Error(data.msg || '充值状态修改失败');
|
||
const account = this._accountByEmail.get(email);
|
||
if (account && data.data) Object.assign(account, data.data);
|
||
this._rebuildDashboardStats();
|
||
this.addLog(`已人工修改充值状态: ${email} → ${status}`, 'success');
|
||
} catch (e) {
|
||
this.addLog(`充值状态修改失败: ${e.message}`, 'error');
|
||
}
|
||
},
|
||
|
||
// ==================== 批量改写客户名 ====================
|
||
openBatchCustomerNameModal() {
|
||
if (this.selectedEmails.size === 0) {
|
||
this.addLog('⚠️ 请先选择要改写客户名的账号', 'warn');
|
||
return;
|
||
}
|
||
this.batchCustomerName = '';
|
||
this.showBatchCustomerNameModal = true;
|
||
},
|
||
|
||
async confirmBatchCustomerName() {
|
||
if (this.batchCustomerNameSaving) return;
|
||
const emails = [...this.selectedEmails];
|
||
if (emails.length === 0) return;
|
||
|
||
const customerName = String(this.batchCustomerName || '').trim();
|
||
this.batchCustomerNameSaving = true;
|
||
try {
|
||
const res = await fetch('/api/accounts/batch/customer-name', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ emails, customerName }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok || !data.success) throw new Error(data.msg || '批量改写失败');
|
||
|
||
const selectedSet = new Set(emails);
|
||
for (const account of this.accounts) {
|
||
if (selectedSet.has(account.email)) account.customer_name = customerName;
|
||
}
|
||
this._rebuildDashboardStats();
|
||
this.showBatchCustomerNameModal = false;
|
||
this.addLog(
|
||
customerName
|
||
? `已将 ${data.count} 个账号的客户名改写为“${customerName}”`
|
||
: `已清空 ${data.count} 个账号的客户名`,
|
||
'success',
|
||
);
|
||
} catch (e) {
|
||
this.addLog(`批量改写客户名失败: ${e.message}`, 'error');
|
||
} finally {
|
||
this.batchCustomerNameSaving = false;
|
||
}
|
||
},
|
||
|
||
// ==================== 退款截止时间 ====================
|
||
refundDeadlineInputValue(value) {
|
||
if (!value) return '';
|
||
const date = new Date(value);
|
||
if (!Number.isFinite(date.getTime())) return '';
|
||
const beijing = new Date(date.getTime() + 8 * 60 * 60 * 1000);
|
||
const pad = number => String(number).padStart(2, '0');
|
||
return `${beijing.getUTCFullYear()}-${pad(beijing.getUTCMonth() + 1)}-${pad(beijing.getUTCDate())}`
|
||
+ `T${pad(beijing.getUTCHours())}:${pad(beijing.getUTCMinutes())}:${pad(beijing.getUTCSeconds())}`;
|
||
},
|
||
|
||
openRefundDeadlineModal(account = null) {
|
||
const emails = account && account.email ? [account.email] : [...this.selectedEmails];
|
||
if (emails.length === 0) {
|
||
this.addLog('⚠️ 请先选择要设置退款截止时间的账号', 'warn');
|
||
return;
|
||
}
|
||
this.refundDeadlineEmails = emails;
|
||
this.refundDeadlineValue = account ? this.refundDeadlineInputValue(account.refund_deadline_at) : '';
|
||
this.refundDeadlineMode = 'absolute';
|
||
this.refundDeadlineOffsetValue = 1;
|
||
this.refundDeadlineOffsetUnit = 'days';
|
||
this.showRefundDeadlineModal = true;
|
||
},
|
||
|
||
setRefundDeadlineQuick(hours) {
|
||
const date = new Date(Date.now() + Number(hours || 0) * 60 * 60 * 1000);
|
||
this.refundDeadlineValue = this.refundDeadlineInputValue(date.toISOString());
|
||
this.refundDeadlineMode = 'absolute';
|
||
},
|
||
|
||
setRefundDeadlineRechargeOffset(value, unit) {
|
||
this.refundDeadlineOffsetValue = Number(value) || 1;
|
||
this.refundDeadlineOffsetUnit = ['minutes', 'hours', 'days'].includes(unit) ? unit : 'hours';
|
||
this.refundDeadlineMode = 'recharge_offset';
|
||
},
|
||
|
||
refundDeadlineOffsetLabel(value = this.refundDeadlineOffsetValue, unit = this.refundDeadlineOffsetUnit) {
|
||
const labels = { minutes: '分钟', hours: '小时', days: '天' };
|
||
return `${Number(value) || 0}${labels[unit] || '小时'}`;
|
||
},
|
||
|
||
async saveRefundDeadline(clear = false) {
|
||
if (this.refundDeadlineSaving) return;
|
||
const emails = [...this.refundDeadlineEmails];
|
||
if (emails.length === 0) return;
|
||
let deadlineAt = null;
|
||
const useRechargeOffset = !clear && this.refundDeadlineMode === 'recharge_offset';
|
||
let offsetValue = null;
|
||
let offsetUnit = null;
|
||
if (useRechargeOffset) {
|
||
offsetValue = Number(this.refundDeadlineOffsetValue);
|
||
offsetUnit = String(this.refundDeadlineOffsetUnit || '');
|
||
if (!Number.isInteger(offsetValue) || offsetValue <= 0
|
||
|| !['minutes', 'hours', 'days'].includes(offsetUnit)) {
|
||
this.addLog('⚠️ 请输入有效的充值时间偏移量', 'warn');
|
||
return;
|
||
}
|
||
} else if (!clear) {
|
||
const localText = String(this.refundDeadlineValue || '').trim();
|
||
if (!localText) {
|
||
this.addLog('⚠️ 请选择退款截止时间,或点击“清除时间”', 'warn');
|
||
return;
|
||
}
|
||
const parsed = new Date(`${localText}+08:00`);
|
||
if (!Number.isFinite(parsed.getTime())) {
|
||
this.addLog('⚠️ 退款截止时间格式无效', 'warn');
|
||
return;
|
||
}
|
||
deadlineAt = parsed.toISOString();
|
||
}
|
||
this.refundDeadlineSaving = true;
|
||
try {
|
||
const res = await fetch('/api/accounts/batch/refund-deadline', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(useRechargeOffset
|
||
? { emails, mode: 'recharge_offset', offsetValue, offsetUnit }
|
||
: { emails, mode: 'absolute', deadlineAt }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok || !data.success) throw new Error(data.msg || '退款截止时间保存失败');
|
||
if (useRechargeOffset) {
|
||
const deadlineByEmail = new Map((data.items || []).map(item => [item.email, item.deadlineAt]));
|
||
for (const account of this.accounts) {
|
||
if (deadlineByEmail.has(account.email)) account.refund_deadline_at = deadlineByEmail.get(account.email);
|
||
}
|
||
} else {
|
||
const emailSet = new Set(emails);
|
||
for (const account of this.accounts) {
|
||
if (emailSet.has(account.email)) account.refund_deadline_at = data.deadlineAt || null;
|
||
}
|
||
}
|
||
this.showRefundDeadlineModal = false;
|
||
if (useRechargeOffset) {
|
||
const skippedText = data.skippedCount > 0 ? `;另有 ${data.skippedCount} 个没有充值时间,已跳过` : '';
|
||
this.addLog(
|
||
`已按每个账号充值时间 + ${this.refundDeadlineOffsetLabel(offsetValue, offsetUnit)},设置 ${data.count} 个退款截止时间${skippedText}`,
|
||
data.skippedCount > 0 ? 'warn' : 'success',
|
||
);
|
||
} else {
|
||
this.addLog(
|
||
data.deadlineAt
|
||
? `已为 ${data.count} 个账号设置退款截止时间:${this.formatDate(data.deadlineAt)}`
|
||
: `已清除 ${data.count} 个账号的退款截止时间`,
|
||
'success',
|
||
);
|
||
}
|
||
} catch (e) {
|
||
this.addLog(`退款截止时间保存失败:${e.message}`, 'error');
|
||
} finally {
|
||
this.refundDeadlineSaving = false;
|
||
}
|
||
},
|
||
|
||
// ==================== 批量更改状态 ====================
|
||
openBatchStatusModal() {
|
||
if (this.selectedEmails.size === 0) {
|
||
this.addLog('⚠️ 请先选择要更改状态的账号', 'warn');
|
||
return;
|
||
}
|
||
this.batchStatus = '未登录';
|
||
this.showBatchStatusModal = true;
|
||
},
|
||
|
||
async confirmBatchStatus() {
|
||
const emails = [...this.selectedEmails];
|
||
if (emails.length === 0) return;
|
||
let successCount = 0;
|
||
let failCount = 0;
|
||
for (const email of emails) {
|
||
try {
|
||
const res = await fetch(`/api/accounts/${encodeURIComponent(email)}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ status: this.batchStatus }),
|
||
});
|
||
const data = await res.json();
|
||
if (data.success) successCount++;
|
||
else failCount++;
|
||
} catch (e) {
|
||
failCount++;
|
||
}
|
||
}
|
||
this.addLog(`批量更改状态完成: 成功 ${successCount} 个,失败 ${failCount} 个`, successCount > 0 ? 'success' : 'error');
|
||
this.showBatchStatusModal = false;
|
||
await this.loadAccounts();
|
||
|
||
},
|
||
|
||
// ==================== 快速设定状态(双击) ====================
|
||
startInlineEditStatus(email) {
|
||
this.inlineEditRechargeStatusEmail = null;
|
||
this.inlineEditAccountRefundEmail = null;
|
||
this.inlineEditRefundProcessEmail = null;
|
||
this.inlineEditStatusEmail = email;
|
||
},
|
||
async quickSetStatus(email, newStatus) {
|
||
this.inlineEditStatusEmail = null;
|
||
try {
|
||
const res = await fetch(`/api/accounts/${encodeURIComponent(email)}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ status: newStatus }),
|
||
});
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
const acc = this._accountByEmail.get(email);
|
||
if (acc) acc.status = newStatus;
|
||
this._rebuildDashboardStats();
|
||
} else {
|
||
this.addLog(`状态更新失败: ${data.msg}`, 'error');
|
||
}
|
||
} catch (e) {
|
||
this.addLog(`状态更新失败: ${e.message}`, 'error');
|
||
}
|
||
},
|
||
|
||
|
||
// ==================== 截图弹窗 ====================
|
||
viewScreenshot(account) {
|
||
if (!account.screenshot_path) return;
|
||
this.screenshotUrl = '/' + account.screenshot_path + '?t=' + Date.now();
|
||
this.screenshotEmail = account.email;
|
||
this.showScreenshotModal = true;
|
||
},
|
||
|
||
closeScreenshot() {
|
||
this.showScreenshotModal = false;
|
||
this.screenshotUrl = '';
|
||
this.screenshotEmail = '';
|
||
},
|
||
|
||
// ==================== ChatGPT Session 弹窗 ====================
|
||
async openChatgptSession(account) {
|
||
if (!account || !account.email) return;
|
||
this.chatgptSessionEmail = account.email;
|
||
this.chatgptSessionText = '';
|
||
this.chatgptSessionError = '';
|
||
this.chatgptSessionLoading = true;
|
||
this.showChatgptSessionModal = true;
|
||
|
||
try {
|
||
const res = await fetch(`/api/accounts/${encodeURIComponent(account.email)}/chatgpt-session`, {
|
||
cache: 'no-store',
|
||
});
|
||
const data = await res.json().catch(() => null);
|
||
if (!res.ok || !data || !data.success) {
|
||
throw new Error((data && data.msg) || `读取失败 (${res.status})`);
|
||
}
|
||
this.chatgptSessionText = JSON.stringify(data.data, null, 2);
|
||
} catch (e) {
|
||
this.chatgptSessionError = e.message || '读取 ChatGPT Session 失败';
|
||
} finally {
|
||
this.chatgptSessionLoading = false;
|
||
}
|
||
},
|
||
|
||
closeChatgptSession() {
|
||
this.showChatgptSessionModal = false;
|
||
this.chatgptSessionEmail = '';
|
||
this.chatgptSessionText = '';
|
||
this.chatgptSessionError = '';
|
||
this.chatgptSessionLoading = false;
|
||
},
|
||
|
||
async copyChatgptSession() {
|
||
if (!this.chatgptSessionText) return;
|
||
await this.copyCell(this.chatgptSessionText);
|
||
},
|
||
|
||
// ==================== 最近充值卡密弹窗 ====================
|
||
async openRechargeCard(account) {
|
||
if (!account || !account.email) return;
|
||
this.rechargeCardEmail = account.email;
|
||
this.rechargeCardText = '';
|
||
this.rechargeCardCompletedAt = '';
|
||
this.rechargeCardError = '';
|
||
this.rechargeCardLoading = true;
|
||
this.showRechargeCardModal = true;
|
||
try {
|
||
const res = await fetch(`/api/accounts/${encodeURIComponent(account.email)}/recharge-card`, {
|
||
cache: 'no-store',
|
||
});
|
||
const data = await res.json().catch(() => null);
|
||
if (!res.ok || !data || !data.success) {
|
||
throw new Error((data && data.msg) || `读取失败 (${res.status})`);
|
||
}
|
||
this.rechargeCardText = String((data.data && data.data.cardKey) || '');
|
||
this.rechargeCardCompletedAt = String((data.data && data.data.rechargedAt) || '');
|
||
if (!this.rechargeCardText) throw new Error('该账号尚未保存充值卡密');
|
||
} catch (e) {
|
||
this.rechargeCardError = e.message || '读取充值卡密失败';
|
||
} finally {
|
||
this.rechargeCardLoading = false;
|
||
}
|
||
},
|
||
|
||
closeRechargeCard() {
|
||
this.showRechargeCardModal = false;
|
||
this.rechargeCardEmail = '';
|
||
this.rechargeCardText = '';
|
||
this.rechargeCardCompletedAt = '';
|
||
this.rechargeCardError = '';
|
||
this.rechargeCardLoading = false;
|
||
},
|
||
|
||
async copyRechargeCard() {
|
||
if (!this.rechargeCardText) return;
|
||
await this.copyCell(this.rechargeCardText);
|
||
},
|
||
|
||
// ==================== 授权 JSON 弹窗 ====================
|
||
async openOpenAIAuth(account) {
|
||
if (!account || !account.email) return;
|
||
this.openAIAuthEmail = account.email;
|
||
this.openAIAuthText = '';
|
||
this.openAIAuthError = '';
|
||
this.openAIAuthLoading = true;
|
||
this.showOpenAIAuthModal = true;
|
||
try {
|
||
const res = await fetch(`/api/accounts/${encodeURIComponent(account.email)}/openai-auth`, {
|
||
cache: 'no-store',
|
||
});
|
||
const data = await res.json().catch(() => null);
|
||
if (!res.ok || !data || !data.success) {
|
||
throw new Error((data && data.msg) || `读取失败 (${res.status})`);
|
||
}
|
||
this.openAIAuthText = JSON.stringify(data.data, null, 2);
|
||
} catch (e) {
|
||
this.openAIAuthError = e.message || '读取授权 JSON 失败';
|
||
} finally {
|
||
this.openAIAuthLoading = false;
|
||
}
|
||
},
|
||
|
||
closeOpenAIAuth() {
|
||
this.showOpenAIAuthModal = false;
|
||
this.openAIAuthEmail = '';
|
||
this.openAIAuthText = '';
|
||
this.openAIAuthError = '';
|
||
this.openAIAuthLoading = false;
|
||
},
|
||
|
||
async copyOpenAIAuth() {
|
||
if (!this.openAIAuthText) return;
|
||
await this.copyCell(this.openAIAuthText);
|
||
},
|
||
|
||
// ==================== 反重力 AC 凭证弹窗 ====================
|
||
async openAgCredential(account) {
|
||
if (!account || !account.email) return;
|
||
this.agCredentialEmail = account.email;
|
||
this.agCredentialText = '';
|
||
this.agCredentialError = '';
|
||
this.agCredentialLoading = true;
|
||
this.showAgCredentialModal = true;
|
||
try {
|
||
const res = await fetch(`/api/accounts/${encodeURIComponent(account.email)}/ag-credential`, {
|
||
cache: 'no-store',
|
||
});
|
||
const data = await res.json().catch(() => null);
|
||
if (!res.ok || !data || !data.success) {
|
||
throw new Error((data && data.msg) || `读取失败 (${res.status})`);
|
||
}
|
||
this.agCredentialText = JSON.stringify(data.data, null, 2);
|
||
} catch (e) {
|
||
this.agCredentialError = e.message || '读取 AC 凭证失败';
|
||
} finally {
|
||
this.agCredentialLoading = false;
|
||
}
|
||
},
|
||
|
||
closeAgCredential() {
|
||
this.showAgCredentialModal = false;
|
||
this.agCredentialEmail = '';
|
||
this.agCredentialText = '';
|
||
this.agCredentialError = '';
|
||
this.agCredentialLoading = false;
|
||
},
|
||
|
||
async copyAgCredential() {
|
||
if (!this.agCredentialText) return;
|
||
await this.copyCell(this.agCredentialText);
|
||
},
|
||
|
||
// ==================== Gemini CLI AC 凭证弹窗 ====================
|
||
async openCliAcCredential(account) {
|
||
if (!account || !account.email) return;
|
||
this.cliAcCredentialEmail = account.email;
|
||
this.cliAcCredentialText = '';
|
||
this.cliAcCredentialError = '';
|
||
this.cliAcCredentialLoading = true;
|
||
this.showCliAcCredentialModal = true;
|
||
try {
|
||
const res = await fetch(`/api/accounts/${encodeURIComponent(account.email)}/cli-ac-credential`, {
|
||
cache: 'no-store',
|
||
});
|
||
const data = await res.json().catch(() => null);
|
||
if (!res.ok || !data || !data.success) {
|
||
throw new Error((data && data.msg) || `读取失败 (${res.status})`);
|
||
}
|
||
this.cliAcCredentialText = JSON.stringify(data.data, null, 2);
|
||
} catch (e) {
|
||
this.cliAcCredentialError = e.message || '读取 Cli_AC 凭证失败';
|
||
} finally {
|
||
this.cliAcCredentialLoading = false;
|
||
}
|
||
},
|
||
|
||
closeCliAcCredential() {
|
||
this.showCliAcCredentialModal = false;
|
||
this.cliAcCredentialEmail = '';
|
||
this.cliAcCredentialText = '';
|
||
this.cliAcCredentialError = '';
|
||
this.cliAcCredentialLoading = false;
|
||
},
|
||
|
||
async copyCliAcCredential() {
|
||
if (!this.cliAcCredentialText) return;
|
||
await this.copyCell(this.cliAcCredentialText);
|
||
},
|
||
|
||
async loadChatgptSessionsForExport(accounts) {
|
||
const source = Array.isArray(accounts) ? accounts : [];
|
||
const hydrated = source.map(account => ({ ...account, chatgpt_session: '' }));
|
||
let cursor = 0;
|
||
let missingCount = 0;
|
||
const workerCount = Math.min(8, hydrated.length);
|
||
|
||
await Promise.all(Array.from({ length: workerCount }, async () => {
|
||
while (true) {
|
||
const index = cursor++;
|
||
if (index >= hydrated.length) break;
|
||
const account = hydrated[index];
|
||
const res = await fetch(`/api/accounts/${encodeURIComponent(account.email)}/chatgpt-session`, {
|
||
cache: 'no-store',
|
||
});
|
||
const data = await res.json().catch(() => null);
|
||
if (res.ok && data && data.success) {
|
||
account.chatgpt_session = JSON.stringify(data.data) || '';
|
||
} else if (res.status === 404) {
|
||
missingCount++;
|
||
} else {
|
||
throw new Error((data && data.msg) || `读取 ChatGPT Session 失败 (${res.status})`);
|
||
}
|
||
}
|
||
}));
|
||
|
||
return { accounts: hydrated, missingCount };
|
||
},
|
||
|
||
async loadOpenAIAuthForExport(accounts) {
|
||
const source = Array.isArray(accounts) ? accounts : [];
|
||
const hydrated = source.map(account => ({ ...account, openai_auth_json: '' }));
|
||
let cursor = 0;
|
||
let missingCount = 0;
|
||
const workerCount = Math.min(8, hydrated.length);
|
||
|
||
await Promise.all(Array.from({ length: workerCount }, async () => {
|
||
while (true) {
|
||
const index = cursor++;
|
||
if (index >= hydrated.length) break;
|
||
const account = hydrated[index];
|
||
const res = await fetch(`/api/accounts/${encodeURIComponent(account.email)}/openai-auth`, {
|
||
cache: 'no-store',
|
||
});
|
||
const data = await res.json().catch(() => null);
|
||
if (res.ok && data && data.success) {
|
||
account.openai_auth_json = JSON.stringify(data.data) || '';
|
||
} else if (res.status === 404) {
|
||
missingCount++;
|
||
} else {
|
||
throw new Error((data && data.msg) || `读取授权 JSON 失败 (${res.status})`);
|
||
}
|
||
}
|
||
}));
|
||
|
||
return { accounts: hydrated, missingCount };
|
||
},
|
||
|
||
async loadCliAcCredentialsForExport(accounts) {
|
||
const source = Array.isArray(accounts) ? accounts : [];
|
||
const hydrated = source.map(account => ({ ...account, cli_ac_credential: '' }));
|
||
let cursor = 0;
|
||
let missingCount = 0;
|
||
const workerCount = Math.min(8, hydrated.length);
|
||
|
||
await Promise.all(Array.from({ length: workerCount }, async () => {
|
||
while (true) {
|
||
const index = cursor++;
|
||
if (index >= hydrated.length) break;
|
||
const account = hydrated[index];
|
||
const res = await fetch(`/api/accounts/${encodeURIComponent(account.email)}/cli-ac-credential`, {
|
||
cache: 'no-store',
|
||
});
|
||
const data = await res.json().catch(() => null);
|
||
if (res.ok && data && data.success) {
|
||
account.cli_ac_credential = JSON.stringify(data.data) || '';
|
||
} else if (res.status === 404) {
|
||
missingCount++;
|
||
} else {
|
||
throw new Error((data && data.msg) || `读取 Cli_AC 凭证失败 (${res.status})`);
|
||
}
|
||
}
|
||
}));
|
||
|
||
return { accounts: hydrated, missingCount };
|
||
},
|
||
|
||
// 消息列显示处理结论;详细步骤仍可在诊断日志中查看。
|
||
getLiveMessage(account) {
|
||
const live = this.accountLogs[account.email];
|
||
const label = this._getAccountFailureLabel(account);
|
||
const rechargeMessage = String(account.recharge_message || '').trim();
|
||
const persistedMessage = String(account.message || '').trim();
|
||
// 充值流程会把两个字段同步保存;任务完成后优先展示完整充值
|
||
// 结论,避免“卡密未找到”等具体原因被压缩成“功能失败”。
|
||
if (account.recharge_status && rechargeMessage && rechargeMessage === persistedMessage
|
||
&& (!this.isRunning || !live)) {
|
||
return rechargeMessage;
|
||
}
|
||
// 任务结束后以落库结论为准,避免最后一条详细日志重新占据列表。
|
||
if (label && (!this.isRunning || !live)) return label;
|
||
if (live && live.message) return live.message;
|
||
if (this._isTaskMessagePending(account)) return this._taskPendingMessage(this._activeTaskType);
|
||
return label || account.message || '-';
|
||
},
|
||
|
||
getLiveMessageClass(account) {
|
||
const live = this.accountLogs[account.email];
|
||
if (!live) {
|
||
if (this._isTaskMessagePending(account)) return 'text-blue-500';
|
||
const m = (account && account.message != null) ? String(account.message) : '';
|
||
const s = m.replace(/[\r\n\t]/g, ' ').replace(/\s+/g, ' ').trim();
|
||
const label = this._getAccountFailureLabel(account);
|
||
if (label === '切换ip重试' || label === '要等7天') return 'text-yellow-600';
|
||
if (label === '密码错误' || label === '密码已被修改' || label === '2fa错误' || label === '2fa密钥不正确' || label === '人机验证' || label === '无法登录' || label === '功能失败' || label === 'GCP被限制' || label === '无权限' || label === '未满18岁' || label === 'Cli凭证失败') return 'text-red-500';
|
||
if (/^⚠️/.test(s) || /警告/.test(s)) return 'text-yellow-600';
|
||
if (/^❌/.test(s) || /错误|异常/.test(s)) return 'text-red-500';
|
||
if (/^✅/.test(s) || /成功|已复制|已登录/.test(s)) return 'text-green-600';
|
||
if (!s || s === '-') return '';
|
||
return 'text-blue-500';
|
||
}
|
||
const liveLabel = this._failureLabelForCode('', live.message || '');
|
||
if (liveLabel === '切换ip重试' || liveLabel === '要等7天') return 'text-yellow-600';
|
||
if (liveLabel === '密码错误' || liveLabel === '密码已被修改' || liveLabel === '2fa错误' || liveLabel === '2fa密钥不正确' || liveLabel === '人机验证' || liveLabel === '无法登录' || liveLabel === '功能失败' || liveLabel === 'GCP被限制' || liveLabel === '无权限' || liveLabel === '未满18岁' || liveLabel === 'Cli凭证失败') return 'text-red-500';
|
||
const map = { error: 'text-red-500', success: 'text-green-600', warn: 'text-yellow-600', info: 'text-blue-500' };
|
||
return map[live.type] || 'text-blue-500';
|
||
},
|
||
|
||
|
||
exportSelectedAccounts() {
|
||
const list = this.selectedAccounts.length > 0 ? this.selectedAccounts : this.filteredAccounts;
|
||
if (list.length === 0) return this.addLog('⚠️ 没有可导出的账号', 'warn');
|
||
this._exportList = list;
|
||
this.exportMode = { file: false, csv: false, sub2api_json: false, clipboard: true, staging: false };
|
||
this.exportStagingNote = '';
|
||
this.deleteBrowserWindowsOnStage = false;
|
||
this.showExportModal = true;
|
||
},
|
||
|
||
escapeCsvCell(value) {
|
||
let text = '';
|
||
if (value !== null && value !== undefined) {
|
||
text = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||
}
|
||
return `"${text.replace(/"/g, '""')}"`;
|
||
},
|
||
|
||
exportFieldValue(account, key) {
|
||
if (!account) return '';
|
||
if (key === 'recharged_at') return this.formatDate(account.recharged_at);
|
||
if (key === 'refund_deadline_at') return this.formatDate(account.refund_deadline_at);
|
||
return account[key] ?? '';
|
||
},
|
||
|
||
buildExportCsv(accounts, selectedKeys) {
|
||
const source = Array.isArray(accounts) ? accounts : [];
|
||
const keys = Array.isArray(selectedKeys) ? selectedKeys : [];
|
||
const labels = keys.map((key) => {
|
||
const field = this.exportFieldDefs.find(item => item.key === key);
|
||
return field ? field.label : key;
|
||
});
|
||
const rows = [labels, ...source.map(account => keys.map(key => this.exportFieldValue(account, key)))];
|
||
return rows.map(row => row.map(value => this.escapeCsvCell(value)).join(',')).join('\r\n');
|
||
},
|
||
|
||
decodeJwtPayload(token) {
|
||
try {
|
||
const part = String(token || '').split('.')[1];
|
||
if (!part) return {};
|
||
const base64 = part.replace(/-/g, '+').replace(/_/g, '/')
|
||
+ '='.repeat((4 - (part.length % 4)) % 4);
|
||
const binary = atob(base64);
|
||
const bytes = Uint8Array.from(binary, char => char.charCodeAt(0));
|
||
let jsonText = '';
|
||
if (typeof TextDecoder === 'function') {
|
||
jsonText = new TextDecoder('utf-8').decode(bytes);
|
||
} else {
|
||
const encoded = Array.from(bytes, byte => `%${byte.toString(16).padStart(2, '0')}`).join('');
|
||
jsonText = decodeURIComponent(encoded);
|
||
}
|
||
const payload = JSON.parse(jsonText);
|
||
return payload && typeof payload === 'object' ? payload : {};
|
||
} catch (_) {
|
||
return {};
|
||
}
|
||
},
|
||
|
||
buildSub2ApiExport(accounts, exportedAt = new Date()) {
|
||
const source = Array.isArray(accounts) ? accounts : [];
|
||
const exportedDate = exportedAt instanceof Date && Number.isFinite(exportedAt.getTime())
|
||
? exportedAt
|
||
: new Date();
|
||
const result = {
|
||
exported_at: exportedDate.toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
||
proxies: [],
|
||
accounts: [],
|
||
};
|
||
let skippedCount = 0;
|
||
|
||
for (const account of source) {
|
||
let auth = account && account.openai_auth_json;
|
||
if (typeof auth === 'string') {
|
||
try { auth = JSON.parse(auth); } catch (_) { auth = null; }
|
||
}
|
||
if (!auth || typeof auth !== 'object') {
|
||
skippedCount++;
|
||
continue;
|
||
}
|
||
|
||
const tokenBag = auth.tokens && typeof auth.tokens === 'object' ? auth.tokens : {};
|
||
const accessToken = String(auth.access_token || tokenBag.access_token || '').trim();
|
||
const refreshToken = String(auth.refresh_token || tokenBag.refresh_token || '').trim();
|
||
const idToken = String(auth.id_token || tokenBag.id_token || '').trim();
|
||
if (!accessToken || !refreshToken) {
|
||
skippedCount++;
|
||
continue;
|
||
}
|
||
|
||
const accessClaims = this.decodeJwtPayload(accessToken);
|
||
const idClaims = this.decodeJwtPayload(idToken);
|
||
const accessAuth = accessClaims['https://api.openai.com/auth'] || {};
|
||
const idAuth = idClaims['https://api.openai.com/auth'] || {};
|
||
const profile = accessClaims['https://api.openai.com/profile'] || {};
|
||
const organizations = Array.isArray(idAuth.organizations) ? idAuth.organizations : [];
|
||
const email = String(idClaims.email || profile.email || auth.email || account.email || '').trim();
|
||
if (!email) {
|
||
skippedCount++;
|
||
continue;
|
||
}
|
||
|
||
const credentials = {
|
||
access_token: accessToken,
|
||
};
|
||
const chatgptAccountId = String(
|
||
accessAuth.chatgpt_account_id
|
||
|| idAuth.chatgpt_account_id
|
||
|| tokenBag.account_id
|
||
|| auth.account_id
|
||
|| '',
|
||
).trim();
|
||
const chatgptUserId = String(
|
||
accessAuth.chatgpt_user_id
|
||
|| idAuth.chatgpt_user_id
|
||
|| auth.chatgpt_user_id
|
||
|| auth.user_id
|
||
|| '',
|
||
).trim();
|
||
const organizationId = String(
|
||
accessAuth.poid
|
||
|| (organizations[0] && organizations[0].id)
|
||
|| auth.organization_id
|
||
|| '',
|
||
).trim();
|
||
const planType = String(accessAuth.chatgpt_plan_type || idAuth.chatgpt_plan_type || auth.plan_type || '').trim();
|
||
const clientId = String(accessClaims.client_id || auth.client_id || '').trim();
|
||
let expiresAt = Number(accessClaims.exp) || 0;
|
||
if (!expiresAt && auth.expires_at) {
|
||
const numericExpiry = Number(auth.expires_at);
|
||
if (Number.isFinite(numericExpiry) && numericExpiry > 0) {
|
||
expiresAt = numericExpiry > 1e12
|
||
? Math.floor(numericExpiry / 1000)
|
||
: Math.floor(numericExpiry);
|
||
} else {
|
||
const parsedExpiry = Date.parse(auth.expires_at);
|
||
if (Number.isFinite(parsedExpiry)) expiresAt = Math.floor(parsedExpiry / 1000);
|
||
}
|
||
}
|
||
|
||
if (chatgptAccountId) credentials.chatgpt_account_id = chatgptAccountId;
|
||
if (chatgptUserId) credentials.chatgpt_user_id = chatgptUserId;
|
||
if (clientId) credentials.client_id = clientId;
|
||
credentials.email = email;
|
||
if (expiresAt > 0) credentials.expires_at = Math.floor(expiresAt);
|
||
if (idToken) credentials.id_token = idToken;
|
||
if (organizationId) credentials.organization_id = organizationId;
|
||
if (planType) credentials.plan_type = planType;
|
||
credentials.refresh_token = refreshToken;
|
||
|
||
result.accounts.push({
|
||
name: email,
|
||
platform: 'openai',
|
||
type: 'oauth',
|
||
credentials,
|
||
extra: { email },
|
||
concurrency: 100,
|
||
priority: 1,
|
||
rate_multiplier: 1,
|
||
auto_pause_on_expired: true,
|
||
});
|
||
}
|
||
|
||
return { data: result, skippedCount };
|
||
},
|
||
|
||
formatSub2ApiExportTimestamp(value = new Date()) {
|
||
const date = value instanceof Date && Number.isFinite(value.getTime()) ? value : new Date();
|
||
const pad = number => String(number).padStart(2, '0');
|
||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`
|
||
+ `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
||
},
|
||
|
||
async confirmExport() {
|
||
if (this.exportInProgress) return;
|
||
const list = this._exportList;
|
||
if (!list || list.length === 0) return;
|
||
|
||
const selectedKeys = this.exportFieldDefs.filter(f => this.exportFields[f.key]).map(f => f.key);
|
||
const exportsText = this.exportMode.file || this.exportMode.clipboard;
|
||
const exportsCsv = Boolean(this.exportMode.csv);
|
||
const exportsSub2Api = Boolean(this.exportMode.sub2api_json);
|
||
const exportsTabular = exportsText || exportsCsv;
|
||
const exportsContent = exportsTabular || exportsSub2Api;
|
||
const needsSelectedFields = exportsTabular || Boolean(this.exportMode.staging);
|
||
if (needsSelectedFields && selectedKeys.length === 0) {
|
||
return this.addLog('⚠️ 请至少选择一个导出字段', 'warn');
|
||
}
|
||
if (!exportsContent && !this.exportMode.staging) return this.addLog('⚠️ 请至少选择一种处理方式', 'warn');
|
||
if (this.exportMode.staging && !this.exportStagingNote.trim()) {
|
||
return this.addLog('⚠️ 请填写暂存文件夹名称', 'warn');
|
||
}
|
||
|
||
this.exportInProgress = true;
|
||
try {
|
||
let exportList = list;
|
||
if (exportsTabular && selectedKeys.includes('chatgpt_session')) {
|
||
const loaded = await this.loadChatgptSessionsForExport(list);
|
||
exportList = loaded.accounts;
|
||
if (loaded.missingCount > 0) {
|
||
this.addLog(`${loaded.missingCount} 个账号没有可导出的 ChatGPT Session,已保留为空字段`, 'warn');
|
||
}
|
||
}
|
||
if (exportsTabular && selectedKeys.includes('cli_ac_credential')) {
|
||
const loaded = await this.loadCliAcCredentialsForExport(exportList);
|
||
exportList = loaded.accounts;
|
||
if (loaded.missingCount > 0) {
|
||
this.addLog(`${loaded.missingCount} 个账号没有可导出的 Cli_AC 凭证,已保留为空字段`, 'warn');
|
||
}
|
||
}
|
||
if (exportsSub2Api || (exportsTabular && selectedKeys.includes('openai_auth_json'))) {
|
||
const loaded = await this.loadOpenAIAuthForExport(exportList);
|
||
exportList = loaded.accounts;
|
||
if (loaded.missingCount > 0 && exportsTabular && selectedKeys.includes('openai_auth_json')) {
|
||
this.addLog(`${loaded.missingCount} 个账号没有可导出的授权 JSON,已保留为空字段`, 'warn');
|
||
}
|
||
}
|
||
|
||
const text = exportsText
|
||
? exportList.map(a => selectedKeys.map(k => this.exportFieldValue(a, k)).join('----')).join('\n')
|
||
: '';
|
||
const csv = exportsCsv ? this.buildExportCsv(exportList, selectedKeys) : '';
|
||
const sub2ApiExport = exportsSub2Api ? this.buildSub2ApiExport(exportList) : null;
|
||
if (sub2ApiExport && sub2ApiExport.skippedCount > 0) {
|
||
this.addLog(
|
||
`${sub2ApiExport.skippedCount} 个账号缺少有效授权 JSON,已从 sub2api 导出中跳过`,
|
||
'warn',
|
||
);
|
||
}
|
||
if (sub2ApiExport && sub2ApiExport.data.accounts.length === 0
|
||
&& !exportsTabular && !this.exportMode.staging) {
|
||
this.addLog('没有包含有效授权 JSON 的账号,未生成 sub2api 文件', 'error');
|
||
return;
|
||
}
|
||
|
||
if (this.exportMode.file) {
|
||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `accounts_${new Date().toISOString().slice(0, 10)}.txt`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
this.addLog(`已导出 ${list.length} 个账号到文件`, 'success');
|
||
}
|
||
if (exportsCsv) {
|
||
// UTF-8 BOM 让 Excel 直接打开时能正确识别中文;单元格内容由
|
||
// buildExportCsv 统一处理逗号、双引号和换行。
|
||
const blob = new Blob(['\uFEFF', csv], { type: 'text/csv;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `accounts_${new Date().toISOString().slice(0, 10)}.csv`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
this.addLog(`已导出 ${list.length} 个账号到 CSV`, 'success');
|
||
}
|
||
if (sub2ApiExport && sub2ApiExport.data.accounts.length > 0) {
|
||
const json = JSON.stringify(sub2ApiExport.data, null, 2);
|
||
const blob = new Blob([json], { type: 'application/json;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `sub2api-account-${this.formatSub2ApiExportTimestamp()}.json`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
this.addLog(`已导出 ${sub2ApiExport.data.accounts.length} 个账号到 sub2api JSON`, 'success');
|
||
}
|
||
if (this.exportMode.clipboard) {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
} catch (e) {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
}
|
||
this.addLog(`已复制 ${list.length} 个账号到剪贴板`, 'success');
|
||
}
|
||
if (this.exportMode.staging) {
|
||
const staged = await this.stageExportAccounts(list, selectedKeys);
|
||
if (!staged) return;
|
||
}
|
||
this.showExportModal = false;
|
||
} catch (e) {
|
||
this.addLog(`导出失败: ${e.message}`, 'error');
|
||
} finally {
|
||
this.exportInProgress = false;
|
||
}
|
||
},
|
||
|
||
// ==================== 一次性导出问题号 ====================
|
||
openProblemExportModal() {
|
||
// 每次打开都恢复为三类全选,避免上次的选择影响本次导出。
|
||
this.problemExportCategories = { password: true, twofa: true, captcha: true };
|
||
this.showProblemExportModal = true;
|
||
},
|
||
|
||
_problemCategoryForAccount(account) {
|
||
if (!account) return null;
|
||
|
||
// 新任务优先使用持久化的标准原因码;旧数据再从最后消息兼容识别。
|
||
const reason = String(account.status_reason || '').trim().toUpperCase();
|
||
if (reason === 'PASSWORD_WRONG' || reason === 'PASSWORD_CHANGED_RECENTLY'
|
||
|| reason === '密码错误' || reason === '密码已被修改') return 'password';
|
||
if (reason === 'TOTP_KEY_INVALID' || reason === 'TOTP_KEY_INCORRECT' || reason === 'TOTP_CODE_WRONG'
|
||
|| reason === '2FA密钥无效' || reason === '2FA验证码错误') return 'twofa';
|
||
if (reason === 'CAPTCHA' || reason === '人机验证' || reason === '人机认证') return 'captcha';
|
||
|
||
const live = this.accountLogs && this.accountLogs[account.email];
|
||
const text = [account.message, live && live.message].filter(Boolean).join(' ');
|
||
if (!text) return null;
|
||
|
||
const compact = this._getAccountFailureLabel(account);
|
||
if (compact === '密码错误' || compact === '密码已被修改') return 'password';
|
||
if (compact === '2fa错误' || compact === '2fa密钥不正确') return 'twofa';
|
||
|
||
if (/人机认证|人机验证|recaptcha|verify you are not a robot|not a robot/i.test(text)) {
|
||
return 'captcha';
|
||
}
|
||
if (/密码错误|密码不正确|密码不对|wrong password|incorrect password|password is incorrect/i.test(text)) {
|
||
return 'password';
|
||
}
|
||
|
||
// OAuth 验证方式页没有 Authenticator 属于“等待7天”,不是 2FA 密钥/验证码错误。
|
||
if (this._isOAuthWait7Account(account)) return null;
|
||
if (/2fa密钥(?:格式不正确|过短|解析失败|无效)|需要2fa验证但未提供密钥|2fa验证码错误|连续两次明确拒绝2fa验证码|验证码校验失败|密钥不匹配|(?:wrong|incorrect|invalid)\s+(?:2fa\s+|totp\s+|authenticator\s+)?code/i.test(text)) {
|
||
return 'twofa';
|
||
}
|
||
return null;
|
||
},
|
||
|
||
problemExportAccounts(category) {
|
||
return this.accounts.filter(account => this._problemCategoryForAccount(account) === category);
|
||
},
|
||
|
||
problemExportCount(category) {
|
||
return this.problemExportAccounts(category).length;
|
||
},
|
||
|
||
_problemExportAccountLine(account) {
|
||
// 新导入的账号保留整行原文,导出时不重新拼接、不转换大小写。
|
||
if (account && account.raw_account_line !== undefined && account.raw_account_line !== null
|
||
&& String(account.raw_account_line).length > 0) {
|
||
return String(account.raw_account_line);
|
||
}
|
||
|
||
// 升级前导入的旧账号没有原文快照,只能按现有默认四字段顺序兼容导出。
|
||
return [account && account.email, account && account.password,
|
||
account && account.recovery_email, account && account.secret_key]
|
||
.map(value => value === undefined || value === null ? '' : String(value))
|
||
.join('----');
|
||
},
|
||
|
||
buildProblemExportText() {
|
||
const definitions = [
|
||
{ key: 'password', heading: '密码错误:' },
|
||
{ key: 'twofa', heading: '2fa错误:' },
|
||
{ key: 'captcha', heading: '人机认证:' },
|
||
];
|
||
return definitions
|
||
.filter(def => this.problemExportCategories[def.key])
|
||
.map(def => [
|
||
def.heading,
|
||
...this.problemExportAccounts(def.key).map(account => this._problemExportAccountLine(account)),
|
||
].join('\n'))
|
||
.join('\n\n');
|
||
},
|
||
|
||
confirmProblemExport() {
|
||
const selectedKeys = Object.keys(this.problemExportCategories)
|
||
.filter(key => this.problemExportCategories[key]);
|
||
if (selectedKeys.length === 0) {
|
||
return this.addLog('⚠️ 请至少勾选一类问题号', 'warn');
|
||
}
|
||
|
||
const counts = selectedKeys.map(key => this.problemExportCount(key));
|
||
const total = counts.reduce((sum, count) => sum + count, 0);
|
||
if (total === 0) {
|
||
return this.addLog('⚠️ 所选类别中没有可导出的问题号', 'warn');
|
||
}
|
||
|
||
const text = this.buildProblemExportText();
|
||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
|
||
link.href = url;
|
||
link.download = `问题号_${stamp}.txt`;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||
|
||
this.showProblemExportModal = false;
|
||
const countLabels = {
|
||
password: '密码错误',
|
||
twofa: '2FA错误',
|
||
captcha: '人机认证',
|
||
};
|
||
const exportedSummary = selectedKeys
|
||
.map(key => `${countLabels[key]} ${this.problemExportCount(key)}`)
|
||
.join(' / ');
|
||
this.addLog(`已导出 ${total} 个问题号(${exportedSummary})`, 'success');
|
||
},
|
||
|
||
async runOpenUrl(urlKey) {
|
||
const target = this.openUrlPresets[urlKey];
|
||
if (!target) return;
|
||
if (this.isRunning) {
|
||
this.addLog('⚠️ 有任务正在运行,请等待完成或停止', 'warn');
|
||
return;
|
||
}
|
||
const taskAccounts = this.getTaskAccounts();
|
||
if (taskAccounts.length === 0) {
|
||
this.addLog('⚠️ 没有可用的账号', 'warn');
|
||
return;
|
||
}
|
||
this.clearLogs();
|
||
this.addLog(`🚀 打开窗口并跳转: ${target},账号数: ${taskAccounts.length}`, 'info');
|
||
try {
|
||
const res = await fetch('/api/operations/run', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
taskType: 'open_url',
|
||
accounts: taskAccounts,
|
||
params: this.buildTaskParams('open_url', { url: target }),
|
||
}),
|
||
});
|
||
const data = await res.json();
|
||
if (!data.success) {
|
||
this.addLog(`❌ 启动失败: ${data.msg}`, 'error');
|
||
return;
|
||
}
|
||
this.currentTaskId = data.taskId;
|
||
this._rememberTaskBatch('open_url', taskAccounts, data.taskId);
|
||
this._activeTaskType = 'open_url';
|
||
this._setActiveTaskEmails(this._lastTaskEmails);
|
||
this._activeTaskStartedAt = this._lastTaskStartedAt;
|
||
this.isRunning = true;
|
||
this._rebuildDashboardStats();
|
||
this.startSSEListener(data.taskId);
|
||
} catch (e) {
|
||
this.addLog(`❌ 网络错误: ${e.message}`, 'error');
|
||
}
|
||
},
|
||
|
||
async runOpenCustomUrl() {
|
||
const target = prompt('请输入要打开的网址(http/https)');
|
||
if (!target) return;
|
||
const url = String(target).trim();
|
||
if (!/^https?:\/\//i.test(url)) {
|
||
this.addLog('❌ 网址格式错误:必须以 http:// 或 https:// 开头', 'error');
|
||
return;
|
||
}
|
||
if (this.isRunning) {
|
||
this.addLog('⚠️ 有任务正在运行,请等待完成或停止', 'warn');
|
||
return;
|
||
}
|
||
const taskAccounts = this.getTaskAccounts();
|
||
if (taskAccounts.length === 0) {
|
||
this.addLog('⚠️ 没有可用的账号', 'warn');
|
||
return;
|
||
}
|
||
this.clearLogs();
|
||
this.addLog(`🚀 打开窗口并跳转: ${url},账号数: ${taskAccounts.length}`, 'info');
|
||
try {
|
||
const res = await fetch('/api/operations/run', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
taskType: 'open_url',
|
||
accounts: taskAccounts,
|
||
params: this.buildTaskParams('open_url', { url }),
|
||
}),
|
||
});
|
||
const data = await res.json();
|
||
if (!data.success) {
|
||
this.addLog(`❌ 启动失败: ${data.msg}`, 'error');
|
||
return;
|
||
}
|
||
this.currentTaskId = data.taskId;
|
||
this._rememberTaskBatch('open_url', taskAccounts, data.taskId);
|
||
this._activeTaskType = 'open_url';
|
||
this._setActiveTaskEmails(this._lastTaskEmails);
|
||
this._activeTaskStartedAt = this._lastTaskStartedAt;
|
||
this.isRunning = true;
|
||
this._rebuildDashboardStats();
|
||
this.startSSEListener(data.taskId);
|
||
} catch (e) {
|
||
this.addLog(`❌ 网络错误: ${e.message}`, 'error');
|
||
}
|
||
},
|
||
|
||
async saveQuotaMonitorSetting(key, value) {
|
||
const normalized = String(value);
|
||
this.settings[key] = normalized;
|
||
await this.saveSetting(key, normalized);
|
||
await this.loadQuotaMonitorStatus(true);
|
||
},
|
||
|
||
async loadQuotaMonitorStatus(silent = false) {
|
||
try {
|
||
const res = await fetch('/api/quota-monitor/status', { cache: 'no-store' });
|
||
const data = await res.json();
|
||
if (!res.ok || !data.success) throw new Error(data.msg || '读取额度监控状态失败');
|
||
const status = data.data && typeof data.data === 'object' ? data.data : {};
|
||
this.quotaMonitorStatus = {
|
||
running: !!status.running,
|
||
activeTask: status.activeTask || null,
|
||
nextRunAt: status.nextRunAt || null,
|
||
lastRun: status.lastRun || null,
|
||
deadlineMonitor: status.deadlineMonitor || this.quotaMonitorStatus.deadlineMonitor,
|
||
config: status.config || this.quotaMonitorStatus.config,
|
||
};
|
||
const config = this.quotaMonitorStatus.config || {};
|
||
this.settings.quota_monitor_enabled = config.enabled ? '1' : '0';
|
||
this.settings.quota_monitor_interval_value = String(config.intervalValue || 24);
|
||
this.settings.quota_monitor_interval_unit = String(config.intervalUnit || 'hours');
|
||
this.settings.quota_monitor_concurrency = String(config.concurrency || 2);
|
||
this.settings.quota_monitor_threshold = String(config.threshold ?? 100);
|
||
this.settings.quota_monitor_skip_401_enabled = config.skip401Enabled ? '1' : '0';
|
||
this.settings.quota_monitor_auto_refund_enabled = config.autoRefundEnabled ? '1' : '0';
|
||
if (this.quotaMonitorStatus.deadlineMonitor) {
|
||
this.settings.refund_deadline_check_interval_seconds = String(
|
||
this.quotaMonitorStatus.deadlineMonitor.intervalSeconds || 15,
|
||
);
|
||
}
|
||
const active = this.quotaMonitorStatus.activeTask;
|
||
if (active && active.progress) this.quotaMonitorProgress = { ...this.quotaMonitorProgress, ...active.progress };
|
||
if (status.running && active && active.taskId && this.quotaMonitorTaskId !== active.taskId) {
|
||
this.startQuotaMonitorListener(active.taskId, false);
|
||
}
|
||
if (!status.running && !this.quotaMonitorEventSource) this.quotaMonitorTaskId = null;
|
||
} catch (error) {
|
||
if (!silent) {
|
||
this.quotaMonitorStatusText = `状态读取失败:${error.message}`;
|
||
this.quotaMonitorStatusType = 'error';
|
||
}
|
||
}
|
||
},
|
||
|
||
async runQuotaMonitorNow() {
|
||
if (this.quotaMonitorStatus.running) {
|
||
const active = this.quotaMonitorStatus.activeTask;
|
||
const message = '额度监控任务正在执行,请等待本轮完成';
|
||
this.quotaMonitorStatusText = message;
|
||
this.quotaMonitorStatusType = 'warn';
|
||
if (active && active.taskId) this.startQuotaMonitorListener(active.taskId, false);
|
||
return;
|
||
}
|
||
if (!this.feishuCredentialMeta.configured) {
|
||
const message = '请先保存飞书 App ID 和 App Secret';
|
||
this.quotaMonitorStatusText = message;
|
||
this.quotaMonitorStatusType = 'warn';
|
||
alert(message);
|
||
return;
|
||
}
|
||
if (!['private', 'group'].includes(String(this.settings.feishu_notify_mode || 'none'))) {
|
||
const message = '请先在飞书设置中选择机器人私发或发送到群';
|
||
this.quotaMonitorStatusText = message;
|
||
this.quotaMonitorStatusType = 'warn';
|
||
alert(message);
|
||
return;
|
||
}
|
||
if (!String(this.settings.feishu_notify_receive_id || '').trim()) {
|
||
const message = '请先填写飞书私发目标或群 Chat ID';
|
||
this.quotaMonitorStatusText = message;
|
||
this.quotaMonitorStatusType = 'warn';
|
||
alert(message);
|
||
return;
|
||
}
|
||
const autoRefundEnabled = this.settings.quota_monitor_auto_refund_enabled === '1';
|
||
if (autoRefundEnabled) {
|
||
const threshold = this.settings.quota_monitor_threshold || '100';
|
||
const credentialWarning = (!this.workspaceRefundCredentialMeta.configured || !this.workspaceRefundCredentialMeta.proxyConfigured)
|
||
? '\n\n注意:退款母号或退款专用代理尚未完整配置,本轮将只查询额度并跳过自动退款。'
|
||
: '';
|
||
const confirmed = confirm(
|
||
`本轮将在额度查询完成后,对用量 ≥ ${threshold}% 且退款流程为“未发起”的账号自动发起退款。\n退款固定单路执行,账号退款字段不会被修改。${credentialWarning}\n\n确认继续吗?`,
|
||
);
|
||
if (!confirmed) return;
|
||
}
|
||
this.logDrawerOpen = true;
|
||
this.quotaMonitorProgress = {
|
||
phase: 'query', current: 0, total: 0, thresholdCount: 0, failureCount: 0,
|
||
refundCurrent: 0, refundTotal: 0, refundSuccessCount: 0, refundFailureCount: 0, refundSkippedCount: 0,
|
||
};
|
||
this.quotaMonitorStatusText = autoRefundEnabled ? '正在启动额度查询与自动退款…' : '正在启动额度查询…';
|
||
this.quotaMonitorStatusType = 'info';
|
||
this.addLog(autoRefundEnabled ? '🚀 手动启动额度监控与自动退款' : '🚀 手动启动额度监控', 'info');
|
||
try {
|
||
const res = await fetch('/api/quota-monitor/run', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: '{}',
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok || !data.success) {
|
||
if (data.taskId) this.startQuotaMonitorListener(data.taskId, false);
|
||
throw new Error(data.msg || '启动额度监控失败');
|
||
}
|
||
this.quotaMonitorStatus.running = true;
|
||
this.quotaMonitorTaskId = data.taskId;
|
||
this.quotaMonitorStatusText = autoRefundEnabled ? '额度查询进行中,达到阈值后将自动退款' : '额度查询进行中';
|
||
this.startQuotaMonitorListener(data.taskId, true);
|
||
} catch (error) {
|
||
this.quotaMonitorStatusText = `启动失败:${error.message}`;
|
||
this.quotaMonitorStatusType = 'error';
|
||
this.addLog(`❌ 额度监控启动失败:${error.message}`, 'error');
|
||
alert(`额度监控启动失败:${error.message}`);
|
||
}
|
||
},
|
||
|
||
startQuotaMonitorListener(taskId, showCompletionAlert = false) {
|
||
if (!taskId) return;
|
||
if (this.quotaMonitorEventSource && this.quotaMonitorTaskId === taskId) return;
|
||
if (this.quotaMonitorEventSource) this.quotaMonitorEventSource.close();
|
||
const es = new EventSource(`/api/operations/events?taskId=${encodeURIComponent(taskId)}`);
|
||
this.quotaMonitorEventSource = es;
|
||
this.quotaMonitorTaskId = taskId;
|
||
let disconnectLogged = false;
|
||
es.onmessage = async event => {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
if (data.type === 'log') {
|
||
this.addLog(String(data.message || ''));
|
||
} else if (data.type === 'progress') {
|
||
this.quotaMonitorProgress = { ...this.quotaMonitorProgress, ...data };
|
||
this.quotaMonitorProgress.current = Number(data.current) || 0;
|
||
this.quotaMonitorProgress.total = Number(data.total) || 0;
|
||
this.quotaMonitorProgress.refundCurrent = Number(data.refundCurrent) || 0;
|
||
this.quotaMonitorProgress.refundTotal = Number(data.refundTotal) || 0;
|
||
this.quotaMonitorProgress.refundSuccessCount = Number(data.refundSuccessCount) || 0;
|
||
this.quotaMonitorProgress.refundFailureCount = Number(data.refundFailureCount) || 0;
|
||
this.quotaMonitorProgress.refundSkippedCount = Number(data.refundSkippedCount) || 0;
|
||
this.quotaMonitorStatusText = data.phase === 'refund'
|
||
? `自动退款中:${this.quotaMonitorProgress.refundCurrent}/${this.quotaMonitorProgress.refundTotal},成功 ${this.quotaMonitorProgress.refundSuccessCount},失败 ${this.quotaMonitorProgress.refundFailureCount}`
|
||
: `额度查询中:${this.quotaMonitorProgress.current}/${this.quotaMonitorProgress.total}`;
|
||
this.quotaMonitorStatusType = 'info';
|
||
} else if (data.type === 'done') {
|
||
const result = data.result && typeof data.result === 'object' ? data.result : {};
|
||
es.close();
|
||
this.quotaMonitorEventSource = null;
|
||
this.quotaMonitorTaskId = null;
|
||
this.quotaMonitorStatus.running = false;
|
||
if (result.queryCompleted) {
|
||
const refundSuffix = result.autoRefundEnabled
|
||
? `,退款成功 ${result.refundSuccessCount || 0},失败 ${result.refundFailureCount || 0},跳过 ${result.refundSkippedCount || 0}`
|
||
: '';
|
||
this.quotaMonitorStatusText = result.deliveryStatus === 'sent'
|
||
? `完成:查询成功 ${result.successCount || 0},≥${result.threshold}% ${result.thresholdCount},查询失败 ${result.failureCount}${refundSuffix}`
|
||
: `查询完成,但飞书发送失败:${result.deliveryError || '未知错误'}`;
|
||
this.quotaMonitorStatusType = result.deliveryStatus === 'sent' ? 'success' : 'warn';
|
||
this.addLog(
|
||
`🏁 额度监控完成:检查 ${result.eligibleCount || 0},查询成功 ${result.successCount || 0},达到阈值 ${result.thresholdCount || 0},查询失败 ${result.failureCount || 0}${refundSuffix}`,
|
||
result.deliveryStatus === 'sent' ? 'success' : 'warn',
|
||
);
|
||
if (showCompletionAlert) {
|
||
alert(`额度监控完成\n检查:${result.eligibleCount || 0}\n查询成功:${result.successCount || 0}\n达到阈值:${result.thresholdCount || 0}\n查询失败:${result.failureCount || 0}${result.autoRefundEnabled ? `\n退款候选:${result.refundCandidateCount || 0}\n退款成功:${result.refundSuccessCount || 0}\n退款失败:${result.refundFailureCount || 0}\n退款跳过:${result.refundSkippedCount || 0}` : ''}\n飞书发送:${result.deliveryStatus === 'sent' ? '成功' : '失败'}`);
|
||
}
|
||
} else {
|
||
this.quotaMonitorStatusText = `执行失败:${result.error || '未知错误'}`;
|
||
this.quotaMonitorStatusType = 'error';
|
||
if (showCompletionAlert) alert(this.quotaMonitorStatusText);
|
||
}
|
||
await this.loadQuotaMonitorStatus(true);
|
||
await this.loadAccounts();
|
||
}
|
||
} catch (_) {}
|
||
};
|
||
es.onerror = () => {
|
||
if (!disconnectLogged) {
|
||
disconnectLogged = true;
|
||
this.quotaMonitorStatusText = '进度连接暂时中断,正在自动重连…';
|
||
this.quotaMonitorStatusType = 'warn';
|
||
}
|
||
};
|
||
},
|
||
|
||
async runFeishuSync() {
|
||
this.logDrawerOpen = true;
|
||
if (this.feishuSyncRunning) {
|
||
this.feishuSyncStatusText = '同步正在运行,请等待完成';
|
||
this.feishuSyncStatusType = 'warn';
|
||
this.addLog('⚠️ 飞书同步正在运行', 'warn');
|
||
return;
|
||
}
|
||
if (this.isRunning) {
|
||
const message = '当前有账号任务正在运行,请等待完成后再同步飞书';
|
||
this.feishuSyncStatusText = message;
|
||
this.feishuSyncStatusType = 'warn';
|
||
this.addLog(`⚠️ ${message}`, 'warn');
|
||
alert(message);
|
||
return;
|
||
}
|
||
const emails = [...this.selectedEmails];
|
||
if (emails.length === 0) {
|
||
const message = '请先勾选需要同步到飞书的账号';
|
||
this.feishuSyncStatusText = message;
|
||
this.feishuSyncStatusType = 'warn';
|
||
this.addLog(`⚠️ ${message}`, 'warn');
|
||
alert(message);
|
||
return;
|
||
}
|
||
if (!this.feishuCredentialMeta.configured) {
|
||
const message = '请先在设置中保存飞书 App ID 和 App Secret';
|
||
this.feishuSyncStatusText = message;
|
||
this.feishuSyncStatusType = 'warn';
|
||
this.addLog(`⚠️ ${message}`, 'warn');
|
||
this.showSettingsModal = true;
|
||
alert(message);
|
||
return;
|
||
}
|
||
if (!String(this.settings.feishu_document_url || '').trim()) {
|
||
const message = '请先在设置中填写飞书电子表格地址';
|
||
this.feishuSyncStatusText = message;
|
||
this.feishuSyncStatusType = 'warn';
|
||
this.addLog(`⚠️ ${message}`, 'warn');
|
||
this.showSettingsModal = true;
|
||
alert(message);
|
||
return;
|
||
}
|
||
|
||
this.clearLogs();
|
||
this.logDrawerOpen = true;
|
||
this.feishuSyncProgress = { current: 0, total: emails.length };
|
||
this.feishuSyncResult = null;
|
||
this.feishuSyncStatusText = `正在启动:0/${emails.length}`;
|
||
this.feishuSyncStatusType = 'info';
|
||
this.addLog(`🚀 开始同步飞书:${emails.length} 个已选账号`, 'info');
|
||
try {
|
||
// 浏览器只提交邮箱。密码、2FA 和 Session 始终由服务端直接读取,
|
||
// 不进入账号列表接口、前端状态或浏览器日志。
|
||
const res = await fetch('/api/feishu/sync', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ emails }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok || !data.success) throw new Error(data.msg || '启动同步失败');
|
||
this.feishuSyncRunning = true;
|
||
this.feishuSyncTaskId = data.taskId;
|
||
this.feishuSyncStatusText = `同步进行中:0/${emails.length}`;
|
||
this.startFeishuSyncListener(data.taskId);
|
||
} catch (e) {
|
||
this.feishuSyncRunning = false;
|
||
this.feishuSyncTaskId = null;
|
||
this.feishuSyncStatusText = `启动失败:${e.message}`;
|
||
this.feishuSyncStatusType = 'error';
|
||
this.addLog(`❌ 飞书同步启动失败:${e.message}`, 'error');
|
||
alert(`飞书同步启动失败:${e.message}`);
|
||
}
|
||
},
|
||
|
||
startFeishuSyncListener(taskId) {
|
||
if (this.feishuEventSource) this.feishuEventSource.close();
|
||
const es = new EventSource(`/api/operations/events?taskId=${encodeURIComponent(taskId)}`);
|
||
this.feishuEventSource = es;
|
||
let disconnectLogged = false;
|
||
es.onopen = () => {
|
||
disconnectLogged = false;
|
||
this.feishuSyncStatusType = 'info';
|
||
};
|
||
es.onmessage = event => {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
if (data.type === 'log') {
|
||
this.addLog(String(data.message || ''));
|
||
} else if (data.type === 'progress') {
|
||
this.feishuSyncProgress = {
|
||
current: Number(data.current) || 0,
|
||
total: Number(data.total) || 0,
|
||
};
|
||
this.feishuSyncStatusText = `同步进行中:${this.feishuSyncProgress.current}/${this.feishuSyncProgress.total}`;
|
||
} else if (data.type === 'done') {
|
||
const result = data.result && typeof data.result === 'object' ? data.result : {};
|
||
this.feishuSyncResult = result;
|
||
if (this.feishuSyncProgress.total > 0) {
|
||
this.feishuSyncProgress.current = this.feishuSyncProgress.total;
|
||
}
|
||
this.feishuSyncRunning = false;
|
||
this.feishuSyncTaskId = null;
|
||
es.close();
|
||
this.feishuEventSource = null;
|
||
const successCount = Number(result.successCount) || 0;
|
||
const failedCount = Number(result.failedCount) || 0;
|
||
if (result.error && successCount === 0) {
|
||
this.feishuSyncStatusText = `同步失败:${result.error}`;
|
||
this.feishuSyncStatusType = 'error';
|
||
this.addLog(`❌ 飞书同步失败:${result.error}`, 'error');
|
||
alert(`飞书同步失败:${result.error}`);
|
||
} else {
|
||
this.feishuSyncStatusText = `同步完成:成功 ${successCount},失败 ${failedCount}`;
|
||
this.feishuSyncStatusType = failedCount > 0 ? 'warn' : 'success';
|
||
this.addLog(
|
||
`🏁 飞书同步完成:成功 ${successCount} 个,失败 ${failedCount} 个`,
|
||
failedCount > 0 ? 'warn' : 'success',
|
||
);
|
||
alert(`飞书同步完成\n成功:${successCount} 个\n失败:${failedCount} 个`);
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
};
|
||
es.onerror = () => {
|
||
if (this.feishuSyncRunning && !disconnectLogged) {
|
||
disconnectLogged = true;
|
||
this.feishuSyncStatusText = '进度连接暂时中断,正在自动重连…';
|
||
this.feishuSyncStatusType = 'warn';
|
||
this.addLog('⚠️ 飞书同步进度连接暂时中断,正在自动重连…', 'warn');
|
||
}
|
||
};
|
||
},
|
||
};
|
||
}
|