diff --git a/.gitignore b/.gitignore index 830d0c2..4d4d4ac 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ /_metadata/ .vscode -/data/account-run-history.txt \ No newline at end of file +/data/account-run-history.txt +/data/account-run-history.json diff --git a/README.md b/README.md index 45ddb2c..808e467 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ - 页面要求填写 `age` - 支持 `Auto` 多轮运行 - 支持中途 `Stop` +- 支持通过日志区的 `记录` 按钮查看邮箱记录面板,按邮箱展示最终状态、时间、失败标签和重试次数 +- 支持将邮箱记录完整快照同步到本地 helper,便于开发者直接查看 `data/account-run-history.json` - Step 8 会自动寻找 OAuth 同意页的“继续”按钮,并通过 Chrome debugger 输入事件发起点击,然后监听本地回调地址 @@ -229,7 +231,7 @@ python3 scripts/hotmail_helper.py Hotmail helper listening on http://127.0.0.1:17373 ``` -看到这行再回到扩展里点 `校验` 或 `复制最新验证码`。 +同时还会输出本地邮箱记录快照文件路径。看到这些输出后,再回到扩展里点 `校验`、`复制最新验证码`,或开启邮箱记录本地同步。 #### 最小排错说明 diff --git a/background.js b/background.js index ac2bfea..bfbbbb4 100644 --- a/background.js +++ b/background.js @@ -722,7 +722,7 @@ function normalizeAccountRunHistoryHelperBaseUrl(rawValue = '') { try { const parsed = new URL(value); - if (parsed.pathname === '/append-account-log') { + if (parsed.pathname === '/append-account-log' || parsed.pathname === '/sync-account-run-records') { parsed.pathname = ''; parsed.search = ''; parsed.hash = ''; @@ -5055,6 +5055,16 @@ async function appendAndBroadcastAccountRunRecord(status, stateOverride = null, return record; } +async function clearAndBroadcastAccountRunHistory(stateOverride = null) { + if (!accountRunHistoryHelpers?.clearAccountRunHistory) { + return { clearedCount: 0 }; + } + + const result = await accountRunHistoryHelpers.clearAccountRunHistory(stateOverride); + await broadcastAccountRunHistoryUpdate(); + return result; +} + const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoRunController({ addLog, appendAccountRunRecord: (...args) => appendAndBroadcastAccountRunRecord(...args), @@ -5757,6 +5767,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter broadcastDataUpdate, cancelScheduledAutoRun, checkIcloudSession, + clearAccountRunHistory: (...args) => clearAndBroadcastAccountRunHistory(...args), clearAutoRunTimerAlarm, clearLuckmailRuntimeState, clearStopRequest, diff --git a/background/account-run-history.js b/background/account-run-history.js index a20e143..98c6f4f 100644 --- a/background/account-run-history.js +++ b/background/account-run-history.js @@ -12,21 +12,176 @@ normalizeAccountRunHistoryHelperBaseUrl, } = deps; + function normalizeTimestamp(value) { + const timestamp = Date.parse(String(value || '')); + return Number.isFinite(timestamp) ? timestamp : 0; + } + + function normalizeRetryCount(value) { + const count = Math.floor(Number(value) || 0); + return count > 0 ? count : 0; + } + + function normalizeFinalStatus(status = '') { + const normalized = String(status || '').trim().toLowerCase(); + if (!normalized) { + return ''; + } + if (normalized === 'success') { + return 'success'; + } + if (normalized === 'failed' || /_failed$/.test(normalized)) { + return 'failed'; + } + if (normalized === 'stopped' || /_stopped$/.test(normalized)) { + return 'stopped'; + } + return ''; + } + + function extractFailedStep(status = '', detail = '') { + const normalizedStatus = String(status || '').trim().toLowerCase(); + const statusMatch = normalizedStatus.match(/^step(\d+)_failed$/); + if (statusMatch) { + const step = Number(statusMatch[1]); + return Number.isInteger(step) && step > 0 ? step : null; + } + + const text = String(detail || '').trim(); + const detailMatch = text.match(/(?:Step\s+(\d+)|步骤\s*(\d+))/i); + if (!detailMatch) { + return null; + } + + const step = Number(detailMatch[1] || detailMatch[2]); + return Number.isInteger(step) && step > 0 ? step : null; + } + + function isPhoneVerificationFailure(detail = '') { + const text = String(detail || '').trim(); + if (!text) { + return false; + } + + return /add[_\s-]?phone/i.test(text) + || /手机号(?:验证|页面|页)|手机(?:号)?页面|出现手机号验证/.test(text) + || /进入了手机号页面/.test(text); + } + + function buildFailureLabel(finalStatus, failedStep, failureDetail = '') { + if (finalStatus === 'success') { + return '流程完成'; + } + if (finalStatus !== 'failed') { + return ''; + } + if (isPhoneVerificationFailure(failureDetail)) { + return '出现手机号验证'; + } + if (Number.isInteger(failedStep) && failedStep > 0) { + return `步骤 ${failedStep} 失败`; + } + return '流程失败'; + } + + function buildRecordId(email = '') { + return String(email || '').trim().toLowerCase(); + } + + function normalizeSource(value = '') { + return String(value || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'; + } + + function normalizeAutoRunContext(context) { + if (!context || typeof context !== 'object') { + return null; + } + + const currentRun = Math.max(0, Math.floor(Number(context.currentRun) || 0)); + const totalRuns = Math.max(0, Math.floor(Number(context.totalRuns) || 0)); + const attemptRun = Math.max(0, Math.floor(Number(context.attemptRun) || 0)); + + if (!currentRun && !totalRuns && !attemptRun) { + return null; + } + + return { + currentRun, + totalRuns, + attemptRun, + }; + } + + function buildAutoRunContextFromState(state = {}) { + return normalizeAutoRunContext({ + currentRun: state.autoRunCurrentRun, + totalRuns: state.autoRunTotalRuns, + attemptRun: state.autoRunAttemptRun, + }); + } + + function getRetryCountFromState(state = {}) { + if (!Boolean(state.autoRunning)) { + return 0; + } + + const attemptRun = Math.max(0, Math.floor(Number(state.autoRunAttemptRun) || 0)); + return attemptRun > 1 ? attemptRun - 1 : 0; + } + + function normalizeAccountRunHistoryRecord(record) { + if (!record || typeof record !== 'object') { + return null; + } + + const email = String(record.email || '').trim(); + const password = String(record.password || '').trim(); + const finalStatus = normalizeFinalStatus(record.finalStatus || record.status || ''); + + if (!email || !password || !finalStatus || finalStatus === 'stopped') { + return null; + } + + const finishedAt = String(record.finishedAt || record.recordedAt || '').trim(); + const failureDetail = finalStatus === 'failed' + ? String(record.failureDetail || record.reason || '').trim() + : ''; + const failedStepCandidate = Number(record.failedStep); + const failedStep = Number.isInteger(failedStepCandidate) && failedStepCandidate > 0 + ? failedStepCandidate + : extractFailedStep(record.finalStatus || record.status || '', failureDetail); + const autoRunContext = normalizeAutoRunContext(record.autoRunContext); + const retryCount = normalizeRetryCount( + record.retryCount !== undefined + ? record.retryCount + : ((autoRunContext?.attemptRun || 0) > 1 ? autoRunContext.attemptRun - 1 : 0) + ); + const source = normalizeSource(record.source || (autoRunContext ? 'auto' : 'manual')); + + return { + recordId: String(record.recordId || '').trim() || buildRecordId(email), + email, + password, + finalStatus, + finishedAt, + retryCount, + failureLabel: String(record.failureLabel || '').trim() || buildFailureLabel(finalStatus, failedStep, failureDetail), + failureDetail, + failedStep: Number.isInteger(failedStep) && failedStep > 0 ? failedStep : null, + source, + autoRunContext: source === 'auto' ? autoRunContext : null, + }; + } + function normalizeAccountRunHistory(records) { if (!Array.isArray(records)) { return []; } return records - .filter((item) => item && typeof item === 'object') - .map((item) => ({ - email: String(item.email || '').trim(), - password: String(item.password || '').trim(), - status: String(item.status || '').trim().toLowerCase(), - recordedAt: String(item.recordedAt || '').trim(), - reason: String(item.reason || '').trim(), - })) - .filter((item) => item.email && item.password && item.status); + .map((item) => normalizeAccountRunHistoryRecord(item)) + .filter(Boolean) + .sort((left, right) => normalizeTimestamp(right.finishedAt) - normalizeTimestamp(left.finishedAt)); } async function getPersistedAccountRunHistory() { @@ -39,25 +194,63 @@ } } + async function setPersistedAccountRunHistory(records) { + const normalizedHistory = normalizeAccountRunHistory(records); + await chrome.storage.local.set({ + [ACCOUNT_RUN_HISTORY_STORAGE_KEY]: normalizedHistory, + }); + return normalizedHistory; + } + function buildAccountRunHistoryRecord(state = {}, status = '', reason = '') { const email = String(state.email || '').trim(); const password = String(state.password || state.customPassword || '').trim(); - const normalizedStatus = String(status || '').trim().toLowerCase(); - const normalizedReason = String(reason || '').trim(); + const finalStatus = normalizeFinalStatus(status); - if (!email || !password || !normalizedStatus) { + if (!email || !password || !finalStatus || finalStatus === 'stopped') { return null; } + const failureDetail = finalStatus === 'failed' ? String(reason || '').trim() : ''; + const failedStep = finalStatus === 'failed' ? extractFailedStep(status, failureDetail) : null; + const source = Boolean(state.autoRunning) ? 'auto' : 'manual'; + const autoRunContext = source === 'auto' ? buildAutoRunContextFromState(state) : null; + const retryCount = source === 'auto' ? getRetryCountFromState(state) : 0; + const finishedAt = new Date().toISOString(); + return { + recordId: buildRecordId(email), email, password, - status: normalizedStatus, - recordedAt: new Date().toISOString(), - reason: normalizedReason, + finalStatus, + finishedAt, + retryCount, + failureLabel: buildFailureLabel(finalStatus, failedStep, failureDetail), + failureDetail, + failedStep: Number.isInteger(failedStep) && failedStep > 0 ? failedStep : null, + source, + autoRunContext, }; } + function upsertAccountRunHistoryRecord(history, record) { + const normalizedHistory = normalizeAccountRunHistory(history); + if (!record) { + return normalizedHistory; + } + + const recordId = String(record.recordId || '').trim(); + const emailKey = String(record.email || '').trim().toLowerCase(); + const nextHistory = normalizedHistory.filter((item) => { + const itemRecordId = String(item.recordId || '').trim(); + const itemEmailKey = String(item.email || '').trim().toLowerCase(); + return itemRecordId !== recordId && itemEmailKey !== emailKey; + }); + + nextHistory.unshift(record); + return normalizeAccountRunHistory(nextHistory); + } + async function appendAccountRunHistoryRecord(status, stateOverride = null, reason = '') { const state = stateOverride || await getState(); const record = buildAccountRunHistoryRecord(state, status, reason); @@ -66,14 +259,39 @@ } const history = await getPersistedAccountRunHistory(); - history.push(record); - await chrome.storage.local.set({ - [ACCOUNT_RUN_HISTORY_STORAGE_KEY]: history, - }); + const nextHistory = upsertAccountRunHistoryRecord(history, record); + await setPersistedAccountRunHistory(nextHistory); return record; } - function shouldAppendAccountRunTextFile(state = {}) { + function summarizeAccountRunHistory(records = []) { + return normalizeAccountRunHistory(records).reduce((summary, record) => { + summary.total += 1; + if (record.finalStatus === 'success') { + summary.success += 1; + } else if (record.finalStatus === 'failed') { + summary.failed += 1; + } + summary.retryTotal += normalizeRetryCount(record.retryCount); + return summary; + }, { + total: 0, + success: 0, + failed: 0, + retryTotal: 0, + }); + } + + function buildAccountRunHistorySnapshotPayload(records = []) { + const normalizedHistory = normalizeAccountRunHistory(records); + return { + generatedAt: new Date().toISOString(), + summary: summarizeAccountRunHistory(normalizedHistory), + records: normalizedHistory, + }; + } + + function shouldSyncAccountRunHistorySnapshot(state = {}) { if (!Boolean(state.accountRunHistoryTextEnabled)) { return false; } @@ -82,49 +300,40 @@ return Boolean(helperBaseUrl); } - async function appendAccountRunHistoryTextFile(record, stateOverride = null) { - const normalizedRecord = record && typeof record === 'object' - ? record - : buildAccountRunHistoryRecord(stateOverride || await getState(), ''); - if (!normalizedRecord?.email || !normalizedRecord?.password || !normalizedRecord?.status) { - return null; - } + function shouldAppendAccountRunTextFile(state = {}) { + return shouldSyncAccountRunHistorySnapshot(state); + } + async function syncAccountRunHistorySnapshot(records, stateOverride = null) { const state = stateOverride || await getState(); - if (!shouldAppendAccountRunTextFile(state)) { - return null; + if (!shouldSyncAccountRunHistorySnapshot(state)) { + return ''; } const helperBaseUrl = normalizeAccountRunHistoryHelperBaseUrl(state.accountRunHistoryHelperBaseUrl); let response; try { - response = await fetch(buildLocalHelperEndpoint(helperBaseUrl, '/append-account-log'), { + response = await fetch(buildLocalHelperEndpoint(helperBaseUrl, '/sync-account-run-records'), { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, - body: JSON.stringify({ - email: normalizedRecord.email, - password: normalizedRecord.password, - status: normalizedRecord.status, - recordedAt: normalizedRecord.recordedAt, - reason: normalizedRecord.reason || '', - }), + body: JSON.stringify(buildAccountRunHistorySnapshotPayload(records)), }); } catch (err) { - throw new Error(`账号文本记录写入失败:无法连接本地 helper(${getErrorMessage(err)})`); + throw new Error(`账号记录快照同步失败:无法连接本地 helper(${getErrorMessage(err)})`); } let payload = null; try { payload = await response.json(); } catch (err) { - throw new Error(`账号文本记录写入失败:本地 helper 返回了无法解析的响应(${getErrorMessage(err)})`); + throw new Error(`账号记录快照同步失败:本地 helper 返回了无法解析的响应(${getErrorMessage(err)})`); } if (!response.ok || payload?.ok === false) { - throw new Error(`账号文本记录写入失败:${payload?.error || `HTTP ${response.status}`}`); + throw new Error(`账号记录快照同步失败:${payload?.error || `HTTP ${response.status}`}`); } return payload?.filePath || ''; @@ -138,9 +347,10 @@ } try { - const filePath = await appendAccountRunHistoryTextFile(record, state); + const history = await getPersistedAccountRunHistory(); + const filePath = await syncAccountRunHistorySnapshot(history, state); if (filePath) { - await addLog(`账号记录已追加到本地文本:${filePath}`, 'info'); + await addLog(`账号记录快照已同步到本地:${filePath}`, 'info'); } } catch (err) { await addLog(getErrorMessage(err), 'warn'); @@ -149,14 +359,40 @@ return record; } + async function clearAccountRunHistory(stateOverride = null) { + const state = stateOverride || await getState(); + const history = await getPersistedAccountRunHistory(); + await setPersistedAccountRunHistory([]); + + try { + const filePath = await syncAccountRunHistorySnapshot([], state); + if (filePath) { + await addLog(`账号记录快照已同步到本地:${filePath}`, 'info'); + } + } catch (err) { + await addLog(getErrorMessage(err), 'warn'); + } + + return { + clearedCount: history.length, + }; + } + return { appendAccountRunRecord, appendAccountRunHistoryRecord, - appendAccountRunHistoryTextFile, buildAccountRunHistoryRecord, + buildAccountRunHistorySnapshotPayload, + clearAccountRunHistory, getPersistedAccountRunHistory, normalizeAccountRunHistory, + normalizeAccountRunHistoryRecord, + normalizeFinalStatus, + setPersistedAccountRunHistory, shouldAppendAccountRunTextFile, + shouldSyncAccountRunHistorySnapshot, + summarizeAccountRunHistory, + syncAccountRunHistorySnapshot, }; } diff --git a/background/message-router.js b/background/message-router.js index 14a8e6f..ab29842 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -12,6 +12,7 @@ broadcastDataUpdate, cancelScheduledAutoRun, checkIcloudSession, + clearAccountRunHistory, clearAutoRunTimerAlarm, clearLuckmailRuntimeState, clearStopRequest, @@ -270,6 +271,18 @@ return { ok: true }; } + case 'CLEAR_ACCOUNT_RUN_HISTORY': { + const state = await getState(); + if (isAutoRunLockedState(state)) { + throw new Error('自动流程运行中,当前不能清理邮箱记录。'); + } + if (typeof clearAccountRunHistory !== 'function') { + return { ok: true, clearedCount: 0 }; + } + const result = await clearAccountRunHistory(state); + return { ok: true, ...result }; + } + case 'EXECUTE_STEP': { clearStopRequest(); if (message.source === 'sidepanel') { diff --git a/scripts/hotmail_helper.py b/scripts/hotmail_helper.py index 4804af0..4323d0d 100644 --- a/scripts/hotmail_helper.py +++ b/scripts/hotmail_helper.py @@ -64,7 +64,8 @@ REQUEST_TIMEOUT_SECONDS = 45 FETCH_LIMIT_DEFAULT = 5 BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) ACCOUNT_LOG_PATH = os.path.join(BASE_DIR, "data", "account-run-history.txt") -ACCOUNT_LOG_LOCK = threading.Lock() +ACCOUNT_RECORDS_SNAPSHOT_PATH = os.path.join(BASE_DIR, "data", "account-run-history.json") +ACCOUNT_RECORDS_LOCK = threading.Lock() def json_response(handler, status, payload): @@ -131,12 +132,104 @@ def append_account_log(email_addr, password, status, recorded_at="", reason=""): os.makedirs(os.path.dirname(ACCOUNT_LOG_PATH), exist_ok=True) line = f"{normalized_recorded_at}\t{normalized_email}\t{normalized_password}\t{normalized_status}\t{normalized_reason}\n" - with ACCOUNT_LOG_LOCK: + with ACCOUNT_RECORDS_LOCK: with open(ACCOUNT_LOG_PATH, "a", encoding="utf-8") as handle: handle.write(line) return ACCOUNT_LOG_PATH +def normalize_account_run_snapshot_record(record): + if not isinstance(record, dict): + return None + + email_addr = str(record.get("email") or "").strip() + password = str(record.get("password") or "").strip() + final_status = str(record.get("finalStatus") or "").strip().lower() + if not email_addr or not password or final_status not in {"success", "failed"}: + return None + + finished_at = str(record.get("finishedAt") or "").strip() or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + retry_count = max(0, int(record.get("retryCount") or 0)) + failed_step_raw = record.get("failedStep") + try: + failed_step = int(failed_step_raw) + except (TypeError, ValueError): + failed_step = None + if failed_step is not None and failed_step <= 0: + failed_step = None + + auto_run_context = record.get("autoRunContext") if isinstance(record.get("autoRunContext"), dict) else None + normalized_auto_run_context = None + if auto_run_context: + normalized_auto_run_context = { + "currentRun": max(0, int(auto_run_context.get("currentRun") or 0)), + "totalRuns": max(0, int(auto_run_context.get("totalRuns") or 0)), + "attemptRun": max(0, int(auto_run_context.get("attemptRun") or 0)), + } + if not any(normalized_auto_run_context.values()): + normalized_auto_run_context = None + + source = "auto" if str(record.get("source") or "").strip().lower() == "auto" else "manual" + + return { + "recordId": str(record.get("recordId") or email_addr).strip() or email_addr, + "email": email_addr, + "password": password, + "finalStatus": final_status, + "finishedAt": finished_at, + "retryCount": retry_count, + "failureLabel": str(record.get("failureLabel") or "").strip(), + "failureDetail": str(record.get("failureDetail") or "").strip(), + "failedStep": failed_step, + "source": source, + "autoRunContext": normalized_auto_run_context, + } + + +def summarize_account_run_snapshot(records): + summary = { + "total": 0, + "success": 0, + "failed": 0, + "retryTotal": 0, + } + for item in records: + summary["total"] += 1 + if item.get("finalStatus") == "success": + summary["success"] += 1 + elif item.get("finalStatus") == "failed": + summary["failed"] += 1 + summary["retryTotal"] += max(0, int(item.get("retryCount") or 0)) + return summary + + +def normalize_account_run_snapshot_payload(payload): + if not isinstance(payload, dict): + raise RuntimeError("Invalid account run snapshot payload") + + normalized_records = [] + for item in payload.get("records") if isinstance(payload.get("records"), list) else []: + normalized = normalize_account_run_snapshot_record(item) + if normalized: + normalized_records.append(normalized) + + return { + "generatedAt": str(payload.get("generatedAt") or "").strip() or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "summary": summarize_account_run_snapshot(normalized_records), + "records": normalized_records, + } + + +def sync_account_run_records(payload): + normalized_payload = normalize_account_run_snapshot_payload(payload) + os.makedirs(os.path.dirname(ACCOUNT_RECORDS_SNAPSHOT_PATH), exist_ok=True) + with ACCOUNT_RECORDS_LOCK: + with open(ACCOUNT_RECORDS_SNAPSHOT_PATH, "w", encoding="utf-8") as handle: + json.dump(normalized_payload, handle, ensure_ascii=False, indent=2) + handle.write("\n") + return ACCOUNT_RECORDS_SNAPSHOT_PATH + + def try_refresh_access_token(endpoint, client_id, refresh_token): request_data = { "client_id": client_id, @@ -625,6 +718,14 @@ class HotmailHelperHandler(BaseHTTPRequestHandler): try: payload = read_json_payload(self) + if self.path == "/sync-account-run-records": + file_path = sync_account_run_records(payload) + json_response(self, 200, { + "ok": True, + "filePath": file_path, + }) + return + if self.path == "/append-account-log": file_path = append_account_log( payload.get("email"), @@ -690,6 +791,7 @@ def main(): server = ThreadingHTTPServer((HOST, PORT), HotmailHelperHandler) print(f"Hotmail helper listening on http://{HOST}:{PORT}", flush=True) print(f"Account log file: {ACCOUNT_LOG_PATH}", flush=True) + print(f"Account snapshot file: {ACCOUNT_RECORDS_SNAPSHOT_PATH}", flush=True) try: server.serve_forever() except KeyboardInterrupt: diff --git a/sidepanel/account-records-manager.js b/sidepanel/account-records-manager.js new file mode 100644 index 0000000..9f67a0a --- /dev/null +++ b/sidepanel/account-records-manager.js @@ -0,0 +1,285 @@ +(function attachSidepanelAccountRecordsManager(globalScope) { + function createAccountRecordsManager(context = {}) { + const { + state, + dom, + helpers, + runtime, + constants = {}, + } = context; + + const displayTimeZone = constants.displayTimeZone || 'Asia/Shanghai'; + const pageSize = Math.max(1, Math.floor(Number(constants.pageSize) || 10)); + + let currentPage = 1; + + function escapeHtml(value) { + if (typeof helpers.escapeHtml === 'function') { + return helpers.escapeHtml(String(value || '')); + } + return String(value || ''); + } + + function normalizeTimestamp(value) { + const timestamp = Date.parse(String(value || '')); + return Number.isFinite(timestamp) ? timestamp : 0; + } + + function getAccountRunRecords(currentState = state.getLatestState()) { + return (Array.isArray(currentState?.accountRunHistory) ? currentState.accountRunHistory : []) + .filter((item) => item && typeof item === 'object') + .slice() + .sort((left, right) => normalizeTimestamp(right.finishedAt) - normalizeTimestamp(left.finishedAt)); + } + + function summarizeAccountRunHistory(records = []) { + return records.reduce((summary, record) => { + summary.total += 1; + if (record.finalStatus === 'success') { + summary.success += 1; + } else if (record.finalStatus === 'failed') { + summary.failed += 1; + } + summary.retryTotal += Math.max(0, Math.floor(Number(record.retryCount) || 0)); + return summary; + }, { + total: 0, + success: 0, + failed: 0, + retryTotal: 0, + }); + } + + function formatAccountRecordTime(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return '--:--'; + } + + const now = new Date(); + const sameYear = date.getFullYear() == now.getFullYear(); + const sameDay = date.toDateString() === now.toDateString(); + + if (sameDay) { + return date.toLocaleTimeString('zh-CN', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + timeZone: displayTimeZone, + }); + } + + return date.toLocaleString('zh-CN', { + hour12: false, + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + ...(sameYear ? {} : { year: '2-digit' }), + timeZone: displayTimeZone, + }).replace(/\//g, '-'); + } + + function getStatusMeta(record = {}) { + return record.finalStatus === 'success' + ? { kind: 'success', label: '成功' } + : { kind: 'failed', label: '失败' }; + } + + function getRecordSummaryText(record = {}) { + if (record.finalStatus === 'success') { + return '流程完成'; + } + + return String(record.failureLabel || '').trim() || '流程失败'; + } + + function createStatChip(label, value, className = '') { + return `${escapeHtml(String(value))}${escapeHtml(label)}`; + } + + function updateHeader(records) { + if (!dom.accountRecordsMeta || !dom.accountRecordsStats) { + return; + } + + if (!records.length) { + dom.accountRecordsMeta.textContent = '暂无邮箱记录'; + } else { + dom.accountRecordsMeta.textContent = `共 ${records.length} 条,最近更新于 ${formatAccountRecordTime(records[0]?.finishedAt)}`; + } + + const summary = summarizeAccountRunHistory(records); + dom.accountRecordsStats.innerHTML = [ + createStatChip('总', summary.total), + createStatChip('成', summary.success, 'is-success'), + createStatChip('失', summary.failed, 'is-failed'), + createStatChip('重试', summary.retryTotal, 'is-retry'), + ].join(''); + } + + function updatePagination(totalRecords) { + const totalPages = totalRecords > 0 ? Math.ceil(totalRecords / pageSize) : 0; + if (totalPages === 0) { + currentPage = 1; + } else if (currentPage > totalPages) { + currentPage = totalPages; + } else if (currentPage < 1) { + currentPage = 1; + } + + if (dom.accountRecordsPageLabel) { + dom.accountRecordsPageLabel.textContent = totalPages > 0 ? `${currentPage} / ${totalPages}` : '0 / 0'; + } + if (dom.btnAccountRecordsPrev) { + dom.btnAccountRecordsPrev.disabled = totalPages <= 1 || currentPage <= 1; + } + if (dom.btnAccountRecordsNext) { + dom.btnAccountRecordsNext.disabled = totalPages <= 1 || currentPage >= totalPages; + } + if (dom.btnClearAccountRecords) { + dom.btnClearAccountRecords.disabled = totalRecords === 0; + } + + return totalPages; + } + + function renderRecordList(records = []) { + if (!dom.accountRecordsList) { + return; + } + + const totalPages = updatePagination(records.length); + if (!records.length) { + dom.accountRecordsList.innerHTML = '