diff --git a/background.js b/background.js index ce02cc8..33a887a 100644 --- a/background.js +++ b/background.js @@ -260,6 +260,7 @@ const DEFAULT_STATE = { email: null, // 运行时邮箱,由程序自动获取并写入,不能手动预填。 password: null, // 运行时实际密码,由 customPassword 或程序自动生成后写入。 accounts: [], // 已生成账号记录:{ email, password, createdAt }。 + accountRunHistory: [], // 账号运行历史快照,实际持久化在 chrome.storage.local。 manualAliasUsage: {}, preservedAliases: {}, lastEmailTimestamp: null, // 最近一次获取到邮箱数据的运行时时间戳。 @@ -859,12 +860,13 @@ async function getPersistedAliasState() { } async function getState() { - const [state, persistedSettings, persistedAliasState] = await Promise.all([ + const [state, persistedSettings, persistedAliasState, accountRunHistory] = await Promise.all([ chrome.storage.session.get(null), getPersistedSettings(), getPersistedAliasState(), + accountRunHistoryHelpers?.getPersistedAccountRunHistory?.() || [], ]); - return { ...DEFAULT_STATE, ...persistedSettings, ...persistedAliasState, ...state }; + return { ...DEFAULT_STATE, ...persistedSettings, ...persistedAliasState, accountRunHistory, ...state }; } async function initializeSessionStorageAccess() { @@ -4412,8 +4414,8 @@ async function completeStepFromBackground(step, payload = {}) { await setStepStatus(step, 'completed'); await addLog(`步骤 ${step} 已完成`, 'ok'); await handleStepData(step, payload); - if (step === 9 && accountRunHistoryHelpers?.appendAccountRunRecord) { - await accountRunHistoryHelpers.appendAccountRunRecord('success', completionState); + if (step === 9) { + await appendAndBroadcastAccountRunRecord('success', completionState); } notifyStepComplete(step, payload); } @@ -4428,7 +4430,7 @@ async function appendManualAccountRunRecordIfNeeded(status, stateOverride = null return null; } - return accountRunHistoryHelpers.appendAccountRunRecord(status, state, reason); + return appendAndBroadcastAccountRunRecord(status, state, reason); } async function finalizeDeferredStepExecutionError(step, error) { @@ -4761,9 +4763,34 @@ const accountRunHistoryHelpers = self.MultiPageBackgroundAccountRunHistory?.crea HOTMAIL_SERVICE_MODE_LOCAL, normalizeHotmailLocalBaseUrl, }); + +async function broadcastAccountRunHistoryUpdate() { + if (!accountRunHistoryHelpers?.getPersistedAccountRunHistory) { + return []; + } + + const history = await accountRunHistoryHelpers.getPersistedAccountRunHistory(); + broadcastDataUpdate({ accountRunHistory: history }); + return history; +} + +async function appendAndBroadcastAccountRunRecord(status, stateOverride = null, reason = '') { + if (!accountRunHistoryHelpers?.appendAccountRunRecord) { + return null; + } + + const record = await accountRunHistoryHelpers.appendAccountRunRecord(status, stateOverride, reason); + if (!record) { + return null; + } + + await broadcastAccountRunHistoryUpdate(); + return record; +} + const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoRunController({ addLog, - appendAccountRunRecord: (...args) => accountRunHistoryHelpers?.appendAccountRunRecord?.(...args), + appendAccountRunRecord: (...args) => appendAndBroadcastAccountRunRecord(...args), AUTO_RUN_MAX_RETRIES_PER_ROUND, AUTO_RUN_RETRY_DELAY_MS, AUTO_RUN_TIMER_KIND_BEFORE_RETRY, @@ -5302,7 +5329,7 @@ const stepExecutorsByKey = { }; const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter({ addLog, - appendAccountRunRecord: (...args) => accountRunHistoryHelpers?.appendAccountRunRecord?.(...args), + appendAccountRunRecord: (...args) => appendAndBroadcastAccountRunRecord(...args), batchUpdateLuckmailPurchases, buildLocalhostCleanupPrefix, buildLuckmailSessionSettingsPayload, diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index b4503c3..8d09ffd 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -1657,6 +1657,188 @@ header { margin-bottom: 6px; } +.account-run-history-strip { + margin-bottom: 8px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 8px; + background: + linear-gradient(180deg, + color-mix(in srgb, var(--bg-base) 72%, var(--blue-soft)), + color-mix(in srgb, var(--bg-surface) 92%, var(--bg-base)) + ); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); +} + +.account-run-history-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.account-run-history-copy { + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.account-run-history-title { + font-size: 12px; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--text-primary); +} + +.account-run-history-meta { + font-size: 11px; + color: var(--text-secondary); + line-height: 1.45; +} + +.account-run-history-stats { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + flex-wrap: wrap; + flex-shrink: 0; +} + +.account-run-history-stat { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--border-subtle); + background: color-mix(in srgb, var(--bg-base) 86%, transparent); + font-size: 11px; + font-weight: 700; + color: var(--text-secondary); +} + +.account-run-history-stat strong { + font-family: 'JetBrains Mono', 'Consolas', monospace; + font-size: 10px; +} + +.account-run-history-stat.is-success { + color: var(--green); + background: var(--green-soft); + border-color: color-mix(in srgb, var(--green) 16%, var(--border)); +} + +.account-run-history-stat.is-failed { + color: var(--red); + background: var(--red-soft); + border-color: color-mix(in srgb, var(--red) 16%, var(--border)); +} + +.account-run-history-stat.is-stopped { + color: var(--cyan); + background: color-mix(in srgb, var(--cyan) 12%, var(--bg-base)); + border-color: color-mix(in srgb, var(--cyan) 16%, var(--border)); +} + +.account-run-history-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.account-run-history-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 8px 9px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-base) 90%, transparent); +} + +.account-run-history-item-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.account-run-history-item-email { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-primary); + font-size: 12px; + font-weight: 600; +} + +.account-run-history-item-detail { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-secondary); + font-size: 11px; + line-height: 1.4; +} + +.account-run-history-item-side { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.account-run-history-item-status { + display: inline-flex; + align-items: center; + padding: 2px 7px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + background: var(--bg-elevated); + color: var(--text-secondary); +} + +.account-run-history-item-time { + color: var(--text-muted); + font-size: 11px; +} + +.account-run-history-item.is-success { + border-color: color-mix(in srgb, var(--green) 14%, var(--border-subtle)); +} + +.account-run-history-item.is-success .account-run-history-item-status { + background: var(--green-soft); + color: var(--green); +} + +.account-run-history-item.is-failed { + border-color: color-mix(in srgb, var(--red) 14%, var(--border-subtle)); +} + +.account-run-history-item.is-failed .account-run-history-item-status { + background: var(--red-soft); + color: var(--red); +} + +.account-run-history-item.is-stopped { + border-color: color-mix(in srgb, var(--cyan) 14%, var(--border-subtle)); +} + +.account-run-history-item.is-stopped .account-run-history-item-status { + background: color-mix(in srgb, var(--cyan) 12%, var(--bg-base)); + color: var(--cyan); +} + #log-area { background: var(--bg-surface); border: 1px solid var(--border); diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index f9bf025..db25bf0 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -534,6 +534,16 @@ 日志 +
diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 4672e1a..fe8595e 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -11,6 +11,10 @@ const STATUS_ICONS = { }; const logArea = document.getElementById('log-area'); +const accountRunHistoryStrip = document.getElementById('account-run-history-strip'); +const accountRunHistoryMeta = document.getElementById('account-run-history-meta'); +const accountRunHistoryStats = document.getElementById('account-run-history-stats'); +const accountRunHistoryList = document.getElementById('account-run-history-list'); const updateSection = document.getElementById('update-section'); const extensionUpdateStatus = document.getElementById('extension-update-status'); const extensionVersionMeta = document.getElementById('extension-version-meta'); @@ -720,6 +724,8 @@ function syncLatestState(nextState) { ...(nextState || {}), stepStatuses: mergedStepStatuses, }; + + renderAccountRunHistory(latestState); } function hasOwnStateValue(source, key) { @@ -2465,6 +2471,193 @@ function appendLog(entry) { logArea.scrollTop = logArea.scrollHeight; } +function getAccountRunHistory(state = latestState) { + return Array.isArray(state?.accountRunHistory) + ? state.accountRunHistory.filter((item) => item && typeof item === 'object') + : []; +} + +function parseAccountRunStatus(status = '') { + const normalized = String(status || '').trim().toLowerCase(); + const stepMatch = normalized.match(/^step(\d+)_(failed|stopped)$/); + if (stepMatch) { + return { + kind: stepMatch[2] === 'failed' ? 'failed' : 'stopped', + label: `步${stepMatch[1]}${stepMatch[2] === 'failed' ? '失败' : '停止'}`, + }; + } + + if (normalized === 'success') { + return { kind: 'success', label: '成功' }; + } + if (normalized === 'failed') { + return { kind: 'failed', label: '失败' }; + } + if (normalized === 'stopped') { + return { kind: 'stopped', label: '已停止' }; + } + + return { + kind: 'unknown', + label: normalized || '记录', + }; +} + +function summarizeAccountRunHistory(records = []) { + return records.reduce((summary, record) => { + summary.total += 1; + const { kind } = parseAccountRunStatus(record?.status); + if (kind === 'success') { + summary.success += 1; + } else if (kind === 'failed') { + summary.failed += 1; + } else if (kind === 'stopped') { + summary.stopped += 1; + } else { + summary.other += 1; + } + return summary; + }, { + total: 0, + success: 0, + failed: 0, + stopped: 0, + other: 0, + }); +} + +function formatAccountRunHistoryTime(recordedAt) { + const date = new Date(recordedAt); + 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: DISPLAY_TIMEZONE, + }); + } + + return date.toLocaleString('zh-CN', { + hour12: false, + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + ...(sameYear ? {} : { year: '2-digit' }), + timeZone: DISPLAY_TIMEZONE, + }).replace(/\//g, '-'); +} + +function buildAccountRunHistoryDetailText(record = {}) { + const reason = String(record.reason || '').trim(); + if (reason) { + return reason; + } + + const { kind } = parseAccountRunStatus(record.status); + if (kind === 'success') { + return '流程已完成并写入本地记录'; + } + if (kind === 'stopped') { + return '流程被手动停止,已保留当前账号快照'; + } + if (kind === 'failed') { + return '流程执行失败,已保留当前账号快照'; + } + + return '账号运行记录已保存'; +} + +function createAccountRunHistoryStat(label, value, className = '') { + const item = document.createElement('span'); + item.className = `account-run-history-stat${className ? ` ${className}` : ''}`; + item.innerHTML = `${escapeHtml(String(value))}${escapeHtml(label)}`; + return item; +} + +function renderAccountRunHistory(state = latestState) { + if (!accountRunHistoryStrip || !accountRunHistoryStats || !accountRunHistoryList || !accountRunHistoryMeta) { + return; + } + + const records = getAccountRunHistory(state); + if (!records.length) { + accountRunHistoryStrip.hidden = true; + accountRunHistoryStats.innerHTML = ''; + accountRunHistoryList.innerHTML = ''; + accountRunHistoryMeta.textContent = '最近运行记录'; + return; + } + + const summary = summarizeAccountRunHistory(records); + const recentRecords = records.slice(-2).reverse(); + const latestRecord = records[records.length - 1] || null; + + accountRunHistoryStrip.hidden = false; + accountRunHistoryMeta.textContent = latestRecord + ? `共 ${summary.total} 条,最近更新于 ${formatAccountRunHistoryTime(latestRecord.recordedAt)}` + : `共 ${summary.total} 条`; + + accountRunHistoryStats.innerHTML = ''; + accountRunHistoryStats.appendChild(createAccountRunHistoryStat('总', summary.total)); + if (summary.success > 0) { + accountRunHistoryStats.appendChild(createAccountRunHistoryStat('成', summary.success, 'is-success')); + } + if (summary.failed > 0) { + accountRunHistoryStats.appendChild(createAccountRunHistoryStat('失', summary.failed, 'is-failed')); + } + if (summary.stopped > 0) { + accountRunHistoryStats.appendChild(createAccountRunHistoryStat('停', summary.stopped, 'is-stopped')); + } + + accountRunHistoryList.innerHTML = ''; + recentRecords.forEach((record) => { + const statusMeta = parseAccountRunStatus(record.status); + const item = document.createElement('div'); + item.className = `account-run-history-item is-${statusMeta.kind}`; + item.title = [ + record.email || '', + statusMeta.label, + record.reason || '', + ].filter(Boolean).join('\n'); + + const main = document.createElement('div'); + main.className = 'account-run-history-item-main'; + + const email = document.createElement('div'); + email.className = 'account-run-history-item-email mono'; + email.textContent = String(record.email || '').trim() || '(空邮箱)'; + + const detail = document.createElement('div'); + detail.className = 'account-run-history-item-detail'; + detail.textContent = buildAccountRunHistoryDetailText(record); + + const side = document.createElement('div'); + side.className = 'account-run-history-item-side'; + + const status = document.createElement('span'); + status.className = 'account-run-history-item-status'; + status.textContent = statusMeta.label; + + const time = document.createElement('span'); + time.className = 'account-run-history-item-time mono'; + time.textContent = formatAccountRunHistoryTime(record.recordedAt); + + main.append(email, detail); + side.append(status, time); + item.append(main, side); + accountRunHistoryList.appendChild(item); + }); +} + function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; diff --git a/tests/sidepanel-account-run-history.test.js b/tests/sidepanel-account-run-history.test.js new file mode 100644 index 0000000..ed75d3a --- /dev/null +++ b/tests/sidepanel-account-run-history.test.js @@ -0,0 +1,107 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const sidepanelSource = fs.readFileSync('sidepanel/sidepanel.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => sidepanelSource.indexOf(marker)) + .find((index) => index >= 0); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let i = start; i < sidepanelSource.length; i += 1) { + const ch = sidepanelSource[i]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = i; + break; + } + } + + let depth = 0; + let end = braceStart; + for (; end < sidepanelSource.length; end += 1) { + const ch = sidepanelSource[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return sidepanelSource.slice(start, end); +} + +test('sidepanel html contains account run history strip under log header', () => { + const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8'); + + assert.match(html, /id="account-run-history-strip"/); + assert.match(html, /id="account-run-history-meta"/); + assert.match(html, /id="account-run-history-stats"/); + assert.match(html, /id="account-run-history-list"/); +}); + +test('sidepanel account run history helpers classify statuses and summarize counts', () => { + const bundle = [ + extractFunction('parseAccountRunStatus'), + extractFunction('summarizeAccountRunHistory'), + extractFunction('buildAccountRunHistoryDetailText'), + ].join('\n'); + + const api = new Function(`${bundle}; return { parseAccountRunStatus, summarizeAccountRunHistory, buildAccountRunHistoryDetailText };`)(); + + assert.deepStrictEqual(api.parseAccountRunStatus('step7_failed'), { + kind: 'failed', + label: '步7失败', + }); + assert.deepStrictEqual(api.parseAccountRunStatus('step4_stopped'), { + kind: 'stopped', + label: '步4停止', + }); + assert.deepStrictEqual(api.parseAccountRunStatus('success'), { + kind: 'success', + label: '成功', + }); + + assert.deepStrictEqual(api.summarizeAccountRunHistory([ + { status: 'success' }, + { status: 'step7_failed' }, + { status: 'stopped' }, + { status: 'step2_failed' }, + ]), { + total: 4, + success: 1, + failed: 2, + stopped: 1, + other: 0, + }); + + assert.equal( + api.buildAccountRunHistoryDetailText({ status: 'success', reason: '' }), + '流程已完成并写入本地记录' + ); + assert.equal( + api.buildAccountRunHistoryDetailText({ status: 'step7_failed', reason: '' }), + '流程执行失败,已保留当前账号快照' + ); + assert.equal( + api.buildAccountRunHistoryDetailText({ status: 'stopped', reason: '手动停止' }), + '手动停止' + ); +}); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 1c1d7c3..72484a0 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -37,6 +37,7 @@ - 向后台发送命令 - 接收后台广播并更新 UI - 动态渲染步骤列表 +- 在日志区标题下方汇总展示最近账号运行历史 ### 2.2 Background Service Worker diff --git a/项目文件结构说明.md b/项目文件结构说明.md index dab1fcc..6c6f1e0 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -110,8 +110,8 @@ - `sidepanel/icloud-manager.js`:侧边栏 iCloud 隐私邮箱管理器,负责列表、筛选、保留、删除和批量操作。 - `sidepanel/luckmail-manager.js`:侧边栏 LuckMail 管理器,负责邮箱列表、筛选、启停、保留与批量操作。 - `sidepanel/sidepanel.css`:侧边栏样式文件。 -- `sidepanel/sidepanel.html`:侧边栏页面结构,当前步骤列表已改为动态容器。 -- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互与广播接收。 +- `sidepanel/sidepanel.html`:侧边栏页面结构,当前步骤列表已改为动态容器,并在日志区标题下方预留账号运行历史摘要条带。 +- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、账号运行历史摘要渲染与广播接收。 - `sidepanel/update-service.js`:侧边栏更新检查服务,负责 GitHub Releases 查询与版本展示。 ## `tests/` @@ -146,6 +146,7 @@ - `tests/luckmail-utils.test.js`:测试 LuckMail 工具层的购买记录、邮件、游标和验证码匹配逻辑。 - `tests/microsoft-email.test.js`:测试 Microsoft 邮件拉取与验证码提取逻辑。 - `tests/sidepanel-hotmail-manager.test.js`:测试侧边栏 Hotmail 管理器模块接线与空态渲染。 +- `tests/sidepanel-account-run-history.test.js`:测试侧边栏日志区下方的账号运行历史条带结构与状态分类摘要逻辑。 - `tests/sidepanel-icloud-manager.test.js`:测试侧边栏 iCloud 管理器模块接线与空态渲染。 - `tests/sidepanel-icloud-provider.test.js`:测试侧边栏 iCloud 登录地址解析逻辑。 - `tests/sidepanel-luckmail-manager.test.js`:测试侧边栏 LuckMail 管理器模块接线与空态渲染。