Ver Fonte

重构更新账号日志功能,添加开发者模式

QLHazyCoder há 4 meses atrás
pai
commit
ccea024f9a

+ 2 - 1
.gitignore

@@ -4,4 +4,5 @@
 /_metadata/
 .vscode
 
-/data/account-run-history.txt
+/data/account-run-history.txt
+/data/account-run-history.json

+ 3 - 1
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
 ```
 
-看到这行再回到扩展里点 `校验` 或 `复制最新验证码`
+同时还会输出本地邮箱记录快照文件路径。看到这些输出后,再回到扩展里点 `校验`、`复制最新验证码`,或开启邮箱记录本地同步
 
 #### 最小排错说明
 

+ 12 - 1
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,

+ 279 - 43
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,
     };
   }
 

+ 13 - 0
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') {

+ 104 - 2
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:

+ 285 - 0
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 `<span class="account-records-stat${className ? ` ${className}` : ''}"><strong>${escapeHtml(String(value))}</strong>${escapeHtml(label)}</span>`;
+    }
+
+    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 = '<div class="account-records-empty">暂无邮箱记录。</div>';
+        return;
+      }
+
+      const startIndex = (currentPage - 1) * pageSize;
+      const visibleRecords = records.slice(startIndex, startIndex + pageSize);
+
+      dom.accountRecordsList.innerHTML = visibleRecords.map((record) => {
+        const statusMeta = getStatusMeta(record);
+        const summaryText = getRecordSummaryText(record);
+        const retryCount = Math.max(0, Math.floor(Number(record.retryCount) || 0));
+        return `
+          <div class="account-record-item is-${escapeHtml(statusMeta.kind)}" title="${escapeHtml(String(record.email || ''))}">
+            <div class="account-record-item-top">
+              <div class="account-record-item-email mono">${escapeHtml(String(record.email || '').trim() || '(空邮箱)')}</div>
+              <div class="account-record-item-side">
+                <span class="account-record-item-status">${escapeHtml(statusMeta.label)}</span>
+                <span class="account-record-item-time mono">${escapeHtml(formatAccountRecordTime(record.finishedAt))}</span>
+              </div>
+            </div>
+            <div class="account-record-item-bottom">
+              <div class="account-record-item-summary">${escapeHtml(summaryText)}</div>
+              <span class="account-record-item-retry mono">重试 ${escapeHtml(String(retryCount))}</span>
+            </div>
+          </div>
+        `;
+      }).join('');
+
+      if (totalPages <= 1 && dom.accountRecordsPageLabel) {
+        dom.accountRecordsPageLabel.textContent = '1 / 1';
+      }
+    }
+
+    function render(currentState = state.getLatestState()) {
+      const records = getAccountRunRecords(currentState);
+      updateHeader(records);
+      renderRecordList(records);
+    }
+
+    function openPanel() {
+      if (dom.accountRecordsOverlay) {
+        dom.accountRecordsOverlay.hidden = false;
+      }
+      render();
+    }
+
+    function closePanel() {
+      if (dom.accountRecordsOverlay) {
+        dom.accountRecordsOverlay.hidden = true;
+      }
+    }
+
+    async function clearRecords() {
+      const records = getAccountRunRecords();
+      if (!records.length) {
+        helpers.showToast?.('没有可清理的邮箱记录。', 'warn', 1800);
+        return;
+      }
+
+      const confirmed = await helpers.openConfirmModal({
+        title: '清理邮箱记录',
+        message: '确认清理当前全部邮箱记录吗?该操作会同时清空面板记录与本地同步快照。',
+        confirmLabel: '确认清理',
+        confirmVariant: 'btn-danger',
+      });
+      if (!confirmed) {
+        return;
+      }
+
+      const response = await runtime.sendMessage({
+        type: 'CLEAR_ACCOUNT_RUN_HISTORY',
+        source: 'sidepanel',
+      });
+      if (response?.error) {
+        throw new Error(response.error);
+      }
+
+      currentPage = 1;
+      state.syncLatestState({ accountRunHistory: [] });
+      helpers.showToast?.(`已清理 ${Math.max(0, Number(response?.clearedCount) || 0)} 条邮箱记录`, 'success', 2200);
+    }
+
+    function bindEvents() {
+      dom.btnOpenAccountRecords?.addEventListener('click', () => {
+        openPanel();
+      });
+      dom.btnCloseAccountRecords?.addEventListener('click', () => {
+        closePanel();
+      });
+      dom.accountRecordsOverlay?.addEventListener('click', (event) => {
+        if (event.target === dom.accountRecordsOverlay) {
+          closePanel();
+        }
+      });
+      dom.btnAccountRecordsPrev?.addEventListener('click', () => {
+        if (currentPage <= 1) {
+          return;
+        }
+        currentPage -= 1;
+        render();
+      });
+      dom.btnAccountRecordsNext?.addEventListener('click', () => {
+        currentPage += 1;
+        render();
+      });
+      dom.btnClearAccountRecords?.addEventListener('click', () => {
+        clearRecords().catch((error) => {
+          helpers.showToast?.(`清理邮箱记录失败:${error.message}`, 'error');
+        });
+      });
+    }
+
+    function reset() {
+      currentPage = 1;
+      closePanel();
+      render();
+    }
+
+    return {
+      bindEvents,
+      closePanel,
+      openPanel,
+      render,
+      reset,
+      summarizeAccountRunHistory,
+    };
+  }
+
+  globalScope.SidepanelAccountRecordsManager = {
+    createAccountRecordsManager,
+  };
+})(typeof window !== 'undefined' ? window : globalThis);

+ 128 - 56
sidepanel/sidepanel.css

@@ -1668,59 +1668,88 @@ header {
   margin-bottom: 6px;
 }
 
-.account-run-history-strip {
-  margin-bottom: 8px;
-  padding: 10px 12px;
+.log-header-actions {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.account-records-overlay {
+  position: fixed;
+  inset: 0;
+  z-index: 1210;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 12px;
+  background: rgba(15, 17, 23, 0.32);
+  backdrop-filter: blur(2px);
+}
+
+.account-records-overlay[hidden] {
+  display: none !important;
+}
+
+.account-records-panel {
+  width: min(100%, 420px);
+  max-height: calc(100vh - 24px);
   display: flex;
   flex-direction: column;
-  gap: 8px;
+  gap: 10px;
+  padding: 14px;
   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))
+      color-mix(in srgb, var(--bg-base) 82%, var(--blue-soft)),
+      color-mix(in srgb, var(--bg-surface) 96%, var(--bg-base))
     );
   border: 1px solid var(--border);
-  border-radius: var(--radius-md);
-  box-shadow: var(--shadow-sm);
+  border-radius: 14px;
+  box-shadow: var(--shadow-md), 0 18px 36px rgba(0, 0, 0, 0.18);
 }
 
-.account-run-history-head {
+.account-records-panel-header {
   display: flex;
   align-items: flex-start;
   justify-content: space-between;
   gap: 10px;
 }
 
-.account-run-history-copy {
+.account-records-panel-copy {
   min-width: 0;
   display: flex;
   flex-direction: column;
   gap: 3px;
 }
 
-.account-run-history-title {
-  font-size: 12px;
+.account-records-panel-title {
+  font-size: 13px;
   font-weight: 700;
   letter-spacing: -0.01em;
   color: var(--text-primary);
 }
 
-.account-run-history-meta {
+.account-records-panel-meta {
   font-size: 11px;
   color: var(--text-secondary);
   line-height: 1.45;
 }
 
-.account-run-history-stats {
+.account-records-panel-actions {
   display: flex;
   align-items: center;
   justify-content: flex-end;
   gap: 6px;
-  flex-wrap: wrap;
   flex-shrink: 0;
 }
 
-.account-run-history-stat {
+.account-records-stats {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex-wrap: wrap;
+}
+
+.account-records-stat {
   display: inline-flex;
   align-items: center;
   gap: 4px;
@@ -1733,55 +1762,80 @@ header {
   color: var(--text-secondary);
 }
 
-.account-run-history-stat strong {
+.account-records-stat strong {
   font-family: 'JetBrains Mono', 'Consolas', monospace;
   font-size: 10px;
 }
 
-.account-run-history-stat.is-success {
+.account-records-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 {
+.account-records-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-records-stat.is-retry {
+  color: var(--blue);
+  background: var(--blue-soft);
+  border-color: color-mix(in srgb, var(--blue) 16%, var(--border));
 }
 
-.account-run-history-list {
+.account-records-list {
   display: flex;
   flex-direction: column;
   gap: 6px;
+  min-height: 168px;
+  max-height: 360px;
+  overflow-y: auto;
+  padding-right: 2px;
 }
 
-.account-run-history-item {
-  display: grid;
-  grid-template-columns: minmax(0, 1fr) auto;
-  gap: 10px;
+.account-records-list::-webkit-scrollbar { width: 5px; }
+.account-records-list::-webkit-scrollbar-track { background: transparent; }
+.account-records-list::-webkit-scrollbar-thumb { background: var(--bg-elevated); border-radius: 4px; }
+.account-records-list::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
+
+.account-records-empty {
+  min-height: 168px;
+  display: flex;
   align-items: center;
-  padding: 8px 9px;
+  justify-content: center;
+  padding: 20px 12px;
+  border: 1px dashed var(--border);
+  border-radius: var(--radius-md);
+  background: color-mix(in srgb, var(--bg-base) 92%, transparent);
+  color: var(--text-secondary);
+  font-size: 12px;
+  text-align: center;
+}
+
+.account-record-item {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  padding: 10px 11px;
   border: 1px solid var(--border-subtle);
   border-radius: var(--radius-sm);
   background: color-mix(in srgb, var(--bg-base) 90%, transparent);
+  box-shadow: var(--shadow-sm);
 }
 
-.account-run-history-item-main {
-  min-width: 0;
+.account-record-item-top,
+.account-record-item-bottom {
   display: flex;
-  flex-direction: column;
-  gap: 3px;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px;
 }
 
-.account-run-history-item-email {
+.account-record-item-email {
   min-width: 0;
+  flex: 1;
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
@@ -1790,24 +1844,14 @@ header {
   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 {
+.account-record-item-side {
   display: flex;
   align-items: center;
   gap: 6px;
   flex-shrink: 0;
 }
 
-.account-run-history-item-status {
+.account-record-item-status {
   display: inline-flex;
   align-items: center;
   padding: 2px 7px;
@@ -1818,36 +1862,64 @@ header {
   color: var(--text-secondary);
 }
 
-.account-run-history-item-time {
+.account-record-item-time {
   color: var(--text-muted);
   font-size: 11px;
 }
 
-.account-run-history-item.is-success {
+.account-record-item-summary {
+  min-width: 0;
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  color: var(--text-secondary);
+  font-size: 11px;
+  line-height: 1.4;
+}
+
+.account-record-item-retry {
+  display: inline-flex;
+  align-items: center;
+  padding: 2px 7px;
+  border-radius: 999px;
+  background: color-mix(in srgb, var(--bg-base) 70%, var(--blue-soft));
+  color: var(--blue);
+  font-size: 11px;
+  font-weight: 700;
+  flex-shrink: 0;
+}
+
+.account-record-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 {
+.account-record-item.is-success .account-record-item-status {
   background: var(--green-soft);
   color: var(--green);
 }
 
-.account-run-history-item.is-failed {
+.account-record-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 {
+.account-record-item.is-failed .account-record-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-records-pagination {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px;
 }
 
-.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);
+.account-records-page-label {
+  min-width: 56px;
+  text-align: center;
+  color: var(--text-secondary);
+  font-size: 11px;
 }
 
 #log-area {

+ 28 - 12
sidepanel/sidepanel.html

@@ -289,7 +289,7 @@
         </div>
       </div>
       <div class="data-row">
-        <span class="data-label">本地留档</span>
+        <span class="data-label">本地同步</span>
         <div class="data-inline auto-delay-inline">
           <label class="toggle-switch" for="input-account-run-history-text-enabled">
             <input type="checkbox" id="input-account-run-history-text-enabled" />
@@ -308,7 +308,7 @@
         </div>
       </div>
       <div class="data-row" id="row-account-run-history-helper-base-url" style="display:none;">
-        <span class="data-label">留档服务</span>
+        <span class="data-label">同步服务</span>
         <input type="text" id="input-account-run-history-helper-base-url" class="data-input mono" placeholder="http://127.0.0.1:17373" />
       </div>
       <div class="data-row">
@@ -555,21 +555,36 @@
   <section id="log-section">
     <div class="log-header">
       <span class="section-label">日志</span>
-      <button id="btn-clear-log" class="btn btn-ghost btn-xs" title="清空日志">清空</button>
-    </div>
-    <div id="account-run-history-strip" class="account-run-history-strip" hidden>
-      <div class="account-run-history-head">
-        <div class="account-run-history-copy">
-          <span class="account-run-history-title">账号运行历史</span>
-          <span id="account-run-history-meta" class="account-run-history-meta">最近运行记录</span>
-        </div>
-        <div id="account-run-history-stats" class="account-run-history-stats"></div>
+      <div class="log-header-actions">
+        <button id="btn-open-account-records" class="btn btn-ghost btn-xs" title="查看邮箱记录">记录</button>
+        <button id="btn-clear-log" class="btn btn-ghost btn-xs" title="清空日志">清空</button>
       </div>
-      <div id="account-run-history-list" class="account-run-history-list"></div>
     </div>
     <div id="log-area"></div>
   </section>
 
+  <div id="account-records-overlay" class="account-records-overlay" hidden>
+    <div class="account-records-panel">
+      <div class="account-records-panel-header">
+        <div class="account-records-panel-copy">
+          <span class="account-records-panel-title">邮箱记录</span>
+          <span id="account-records-meta" class="account-records-panel-meta">暂无邮箱记录</span>
+        </div>
+        <div class="account-records-panel-actions">
+          <button id="btn-clear-account-records" class="btn btn-ghost btn-xs" type="button">清理记录</button>
+          <button id="btn-close-account-records" class="modal-close" type="button" aria-label="关闭">×</button>
+        </div>
+      </div>
+      <div id="account-records-stats" class="account-records-stats"></div>
+      <div id="account-records-list" class="account-records-list"></div>
+      <div class="account-records-pagination">
+        <button id="btn-account-records-prev" class="btn btn-outline btn-xs" type="button">上一页</button>
+        <span id="account-records-page-label" class="account-records-page-label mono">0 / 0</span>
+        <button id="btn-account-records-next" class="btn btn-outline btn-xs" type="button">下一页</button>
+      </div>
+    </div>
+  </div>
+
   <div id="auto-start-modal" class="modal-overlay" hidden>
     <div class="modal-card">
       <div class="modal-header">
@@ -603,6 +618,7 @@
   <script src="hotmail-manager.js"></script>
   <script src="icloud-manager.js"></script>
   <script src="luckmail-manager.js"></script>
+  <script src="account-records-manager.js"></script>
   <script src="sidepanel.js"></script>
 </body>
 

+ 48 - 193
sidepanel/sidepanel.js

@@ -11,10 +11,16 @@ 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 btnOpenAccountRecords = document.getElementById('btn-open-account-records');
+const accountRecordsOverlay = document.getElementById('account-records-overlay');
+const accountRecordsMeta = document.getElementById('account-records-meta');
+const accountRecordsStats = document.getElementById('account-records-stats');
+const accountRecordsList = document.getElementById('account-records-list');
+const accountRecordsPageLabel = document.getElementById('account-records-page-label');
+const btnAccountRecordsPrev = document.getElementById('btn-account-records-prev');
+const btnAccountRecordsNext = document.getElementById('btn-account-records-next');
+const btnCloseAccountRecords = document.getElementById('btn-close-account-records');
+const btnClearAccountRecords = document.getElementById('btn-clear-account-records');
 const updateSection = document.getElementById('update-section');
 const extensionUpdateStatus = document.getElementById('extension-update-status');
 const extensionVersionMeta = document.getElementById('extension-version-meta');
@@ -874,7 +880,7 @@ function syncLatestState(nextState) {
     stepStatuses: mergedStepStatuses,
   };
 
-  renderAccountRunHistory(latestState);
+  renderAccountRecords(latestState);
 }
 
 function hasOwnStateValue(source, key) {
@@ -1382,7 +1388,7 @@ function normalizeAccountRunHistoryHelperBaseUrlValue(value = '') {
       return DEFAULT_ACCOUNT_RUN_HISTORY_HELPER_BASE_URL;
     }
 
-    if (parsed.pathname === '/append-account-log') {
+    if (parsed.pathname === '/append-account-log' || parsed.pathname === '/sync-account-run-records') {
       parsed.pathname = '';
       parsed.search = '';
       parsed.hash = '';
@@ -2697,193 +2703,6 @@ 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 = `<strong>${escapeHtml(String(value))}</strong>${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;
@@ -3108,6 +2927,42 @@ const resetLuckmailManager = luckmailManager?.reset
 const bindLuckmailEvents = luckmailManager?.bindLuckmailEvents
   || (() => { });
 bindLuckmailEvents();
+
+const accountRecordsManager = window.SidepanelAccountRecordsManager?.createAccountRecordsManager({
+  state: {
+    getLatestState: () => latestState,
+    syncLatestState,
+  },
+  dom: {
+    accountRecordsList,
+    accountRecordsMeta,
+    accountRecordsOverlay,
+    accountRecordsPageLabel,
+    accountRecordsStats,
+    btnAccountRecordsNext,
+    btnAccountRecordsPrev,
+    btnClearAccountRecords,
+    btnCloseAccountRecords,
+    btnOpenAccountRecords,
+  },
+  helpers: {
+    escapeHtml,
+    openConfirmModal,
+    showToast,
+  },
+  runtime: {
+    sendMessage: (message) => chrome.runtime.sendMessage(message),
+  },
+  constants: {
+    displayTimeZone: DISPLAY_TIMEZONE,
+    pageSize: 10,
+  },
+});
+const renderAccountRecords = accountRecordsManager?.render
+  || (() => { });
+const bindAccountRecordEvents = accountRecordsManager?.bindEvents
+  || (() => { });
+bindAccountRecordEvents();
 renderStepsList();
 
 async function exportSettingsFile() {

+ 4 - 0
tests/background-account-history-settings.test.js

@@ -105,6 +105,10 @@ return {
     api.normalizePersistentSettingValue('accountRunHistoryHelperBaseUrl', 'http://127.0.0.1:17373/append-account-log'),
     'http://127.0.0.1:17373'
   );
+  assert.equal(
+    api.normalizePersistentSettingValue('accountRunHistoryHelperBaseUrl', 'http://127.0.0.1:17373/sync-account-run-records'),
+    'http://127.0.0.1:17373'
+  );
   assert.equal(
     api.normalizeAccountRunHistoryHelperBaseUrl(''),
     'http://127.0.0.1:17373'

+ 128 - 12
tests/background-account-run-history-module.test.js

@@ -16,12 +16,15 @@ test('account run history module exposes a factory', () => {
   assert.equal(typeof api?.createAccountRunHistoryHelpers, 'function');
 });
 
-test('account run history helper normalizes records and persists without helper upload when local helper is disabled', async () => {
+test('account run history helper upgrades old records, filters stopped items and stores normalized failed snapshot records', async () => {
   const source = fs.readFileSync('background/account-run-history.js', 'utf8');
   const globalScope = {};
   const api = new Function('self', `${source}; return self.MultiPageBackgroundAccountRunHistory;`)(globalScope);
 
-  let storedHistory = [{ email: 'old@example.com', password: 'old-pass', status: 'success', recordedAt: '2026-04-17T00:00:00.000Z' }];
+  let storedHistory = [
+    { email: 'old@example.com', password: 'old-pass', status: 'success', recordedAt: '2026-04-17T00:00:00.000Z' },
+    { email: 'stop@example.com', password: 'stop-pass', status: 'stopped', recordedAt: '2026-04-17T00:10:00.000Z' },
+  ];
   let fetchCalled = false;
   global.fetch = async () => {
     fetchCalled = true;
@@ -46,6 +49,10 @@ test('account run history helper normalizes records and persists without helper
     getState: async () => ({
       email: ' latest@example.com ',
       password: ' secret ',
+      autoRunning: true,
+      autoRunCurrentRun: 2,
+      autoRunTotalRuns: 10,
+      autoRunAttemptRun: 3,
       accountRunHistoryTextEnabled: false,
       accountRunHistoryHelperBaseUrl: '',
     }),
@@ -53,24 +60,133 @@ test('account run history helper normalizes records and persists without helper
   });
 
   const record = helpers.buildAccountRunHistoryRecord(
-    { email: ' latest@example.com ', password: ' secret ' },
-    ' FAILED ',
-    ' reason '
+    {
+      email: ' latest@example.com ',
+      password: ' secret ',
+      autoRunning: true,
+      autoRunCurrentRun: 2,
+      autoRunTotalRuns: 10,
+      autoRunAttemptRun: 3,
+    },
+    'step8_failed',
+    '步骤 8:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。'
   );
   assert.deepStrictEqual(record, {
+    recordId: 'latest@example.com',
     email: 'latest@example.com',
     password: 'secret',
-    status: 'failed',
-    recordedAt: record.recordedAt,
-    reason: 'reason',
+    finalStatus: 'failed',
+    finishedAt: record.finishedAt,
+    retryCount: 2,
+    failureLabel: '出现手机号验证',
+    failureDetail: '步骤 8:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。',
+    failedStep: 8,
+    source: 'auto',
+    autoRunContext: {
+      currentRun: 2,
+      totalRuns: 10,
+      attemptRun: 3,
+    },
   });
 
-  const appended = await helpers.appendAccountRunRecord('failed', null, 'boom');
+  const appended = await helpers.appendAccountRunRecord('step8_failed', null, '步骤 8:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。');
   assert.equal(appended.email, 'latest@example.com');
-  assert.equal(appended.status, 'failed');
-  assert.equal(storedHistory.length, 2);
-  assert.equal(storedHistory[1].reason, 'boom');
+  assert.equal(appended.finalStatus, 'failed');
+  assert.equal(appended.failureLabel, '出现手机号验证');
+  assert.equal(storedHistory.length, 2, '旧的 stopped 记录应在新结构中被过滤掉');
+  assert.equal(storedHistory.some((item) => item.email === 'stop@example.com'), false);
+  assert.equal(storedHistory.some((item) => item.email === 'latest@example.com' && item.retryCount === 2), true);
+  assert.equal(storedHistory.some((item) => item.email === 'old@example.com'), true);
   assert.equal(fetchCalled, false);
   assert.equal(helpers.shouldAppendAccountRunTextFile({ accountRunHistoryTextEnabled: false, accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373' }), false);
   assert.equal(helpers.shouldAppendAccountRunTextFile({ accountRunHistoryTextEnabled: true, accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373' }), true);
+  assert.equal(helpers.buildAccountRunHistoryRecord({ email: 'a@b.com', password: 'x' }, 'stopped', 'stop'), null);
+});
+
+test('account run history helper clears persisted records and syncs full snapshot payload to local helper', async () => {
+  const source = fs.readFileSync('background/account-run-history.js', 'utf8');
+  const globalScope = {};
+  const api = new Function('self', `${source}; return self.MultiPageBackgroundAccountRunHistory;`)(globalScope);
+
+  let storedHistory = [{
+    recordId: 'user@example.com',
+    email: 'user@example.com',
+    password: 'secret',
+    finalStatus: 'failed',
+    finishedAt: '2026-04-17T01:00:00.000Z',
+    retryCount: 1,
+    failureLabel: '步骤 6 失败',
+    failureDetail: '步骤 6:判断失败后已重试 2 次,仍未成功。',
+    failedStep: 6,
+    source: 'auto',
+    autoRunContext: {
+      currentRun: 1,
+      totalRuns: 5,
+      attemptRun: 2,
+    },
+  }];
+  const fetchCalls = [];
+  global.fetch = async (url, options = {}) => {
+    fetchCalls.push({
+      url,
+      options,
+    });
+    return {
+      ok: true,
+      json: async () => ({
+        ok: true,
+        filePath: 'C:/tmp/account-run-history.json',
+      }),
+    };
+  };
+
+  const logs = [];
+  const helpers = api.createAccountRunHistoryHelpers({
+    ACCOUNT_RUN_HISTORY_STORAGE_KEY: 'accountRunHistory',
+    addLog: async (message, level) => {
+      logs.push({ message, level });
+    },
+    buildLocalHelperEndpoint: (baseUrl, path) => `${baseUrl}${path}`,
+    chrome: {
+      storage: {
+        local: {
+          get: async () => ({ accountRunHistory: storedHistory }),
+          set: async (payload) => {
+            storedHistory = payload.accountRunHistory;
+          },
+        },
+      },
+    },
+    getErrorMessage: (error) => error?.message || String(error || ''),
+    getState: async () => ({
+      accountRunHistoryTextEnabled: true,
+      accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373',
+    }),
+    normalizeAccountRunHistoryHelperBaseUrl: (value) => String(value || '').trim(),
+  });
+
+  const payload = helpers.buildAccountRunHistorySnapshotPayload(storedHistory);
+  assert.deepStrictEqual(payload.summary, {
+    total: 1,
+    success: 0,
+    failed: 1,
+    retryTotal: 1,
+  });
+
+  const clearResult = await helpers.clearAccountRunHistory();
+  assert.deepStrictEqual(clearResult, { clearedCount: 1 });
+  assert.deepStrictEqual(storedHistory, []);
+  assert.equal(fetchCalls.length, 1);
+  assert.equal(fetchCalls[0].url, 'http://127.0.0.1:17373/sync-account-run-records');
+  assert.deepStrictEqual(JSON.parse(fetchCalls[0].options.body), {
+    generatedAt: JSON.parse(fetchCalls[0].options.body).generatedAt,
+    summary: {
+      total: 0,
+      success: 0,
+      failed: 0,
+      retryTotal: 0,
+    },
+    records: [],
+  });
+  assert.equal(logs[0].message, '账号记录快照已同步到本地:C:/tmp/account-run-history.json');
 });

+ 190 - 0
tests/sidepanel-account-records-manager.test.js

@@ -0,0 +1,190 @@
+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);
+}
+
+function createButton() {
+  return {
+    disabled: false,
+    textContent: '',
+    hidden: false,
+    listeners: {},
+    addEventListener(type, handler) {
+      this.listeners[type] = handler;
+    },
+  };
+}
+
+function createContainer() {
+  return {
+    innerHTML: '',
+    textContent: '',
+    hidden: true,
+    listeners: {},
+    addEventListener(type, handler) {
+      this.listeners[type] = handler;
+    },
+  };
+}
+
+test('sidepanel html contains account records overlay and manager script', () => {
+  const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
+  const managerIndex = html.indexOf('<script src="account-records-manager.js"></script>');
+  const sidepanelIndex = html.indexOf('<script src="sidepanel.js"></script>');
+
+  assert.match(html, /id="btn-open-account-records"/);
+  assert.match(html, /id="account-records-overlay"/);
+  assert.match(html, /id="account-records-list"/);
+  assert.match(html, /id="account-records-stats"/);
+  assert.match(html, /id="btn-clear-account-records"/);
+  assert.notEqual(managerIndex, -1);
+  assert.notEqual(sidepanelIndex, -1);
+  assert.ok(managerIndex < sidepanelIndex);
+});
+
+test('sidepanel account records helper normalizes snapshot helper base url', () => {
+  const bundle = [
+    extractFunction('normalizeAccountRunHistoryHelperBaseUrlValue'),
+  ].join('\n');
+
+  const api = new Function(`
+const DEFAULT_ACCOUNT_RUN_HISTORY_HELPER_BASE_URL = 'http://127.0.0.1:17373';
+${bundle}
+return { normalizeAccountRunHistoryHelperBaseUrlValue };
+`)();
+
+  assert.equal(
+    api.normalizeAccountRunHistoryHelperBaseUrlValue('http://127.0.0.1:17373/sync-account-run-records'),
+    'http://127.0.0.1:17373'
+  );
+});
+
+test('account records manager exposes a factory and renders summarized paginated records', () => {
+  const source = fs.readFileSync('sidepanel/account-records-manager.js', 'utf8');
+  const windowObject = {};
+
+  const api = new Function('window', `${source}; return window.SidepanelAccountRecordsManager;`)(windowObject);
+
+  assert.equal(typeof api?.createAccountRecordsManager, 'function');
+
+  const btnOpenAccountRecords = createButton();
+  const btnCloseAccountRecords = createButton();
+  const btnClearAccountRecords = createButton();
+  const btnAccountRecordsPrev = createButton();
+  const btnAccountRecordsNext = createButton();
+  const overlay = createContainer();
+  const list = createContainer();
+  const stats = createContainer();
+  const meta = createContainer();
+  const pageLabel = createContainer();
+
+  const manager = api.createAccountRecordsManager({
+    state: {
+      getLatestState: () => ({
+        accountRunHistory: [
+          {
+            email: 'success@example.com',
+            password: 'secret',
+            finalStatus: 'success',
+            finishedAt: '2026-04-17T04:31:00.000Z',
+            retryCount: 0,
+            failureLabel: '流程完成',
+          },
+          {
+            email: 'failed@example.com',
+            password: 'secret',
+            finalStatus: 'failed',
+            finishedAt: '2026-04-17T04:29:00.000Z',
+            retryCount: 2,
+            failureLabel: '出现手机号验证',
+          },
+        ],
+      }),
+      syncLatestState() {},
+    },
+    dom: {
+      accountRecordsList: list,
+      accountRecordsMeta: meta,
+      accountRecordsOverlay: overlay,
+      accountRecordsPageLabel: pageLabel,
+      accountRecordsStats: stats,
+      btnAccountRecordsNext,
+      btnAccountRecordsPrev,
+      btnClearAccountRecords,
+      btnCloseAccountRecords,
+      btnOpenAccountRecords,
+    },
+    helpers: {
+      escapeHtml: (value) => String(value || ''),
+      openConfirmModal: async () => true,
+      showToast() {},
+    },
+    runtime: {
+      sendMessage: async () => ({ clearedCount: 2 }),
+    },
+    constants: {
+      displayTimeZone: 'Asia/Shanghai',
+      pageSize: 10,
+    },
+  });
+
+  assert.equal(typeof manager.bindEvents, 'function');
+  assert.equal(typeof manager.render, 'function');
+  assert.equal(typeof manager.openPanel, 'function');
+
+  manager.bindEvents();
+  manager.render();
+
+  assert.match(meta.textContent, /共 2 条/);
+  assert.match(stats.innerHTML, /重试/);
+  assert.match(list.innerHTML, /success@example\.com/);
+  assert.match(list.innerHTML, /出现手机号验证/);
+  assert.match(list.innerHTML, /重试 2/);
+  assert.equal(pageLabel.textContent, '1 / 1');
+  assert.equal(btnClearAccountRecords.disabled, false);
+});

+ 0 - 118
tests/sidepanel-account-run-history.test.js

@@ -1,118 +0,0 @@
-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"/);
-  assert.match(html, /id="input-account-run-history-text-enabled"/);
-  assert.match(html, /id="input-account-run-history-helper-base-url"/);
-});
-
-test('sidepanel account run history helpers classify statuses and summarize counts', () => {
-  const bundle = [
-    extractFunction('normalizeAccountRunHistoryHelperBaseUrlValue'),
-    extractFunction('parseAccountRunStatus'),
-    extractFunction('summarizeAccountRunHistory'),
-    extractFunction('buildAccountRunHistoryDetailText'),
-  ].join('\n');
-
-  const api = new Function(`
-const DEFAULT_ACCOUNT_RUN_HISTORY_HELPER_BASE_URL = 'http://127.0.0.1:17373';
-${bundle}
-return { normalizeAccountRunHistoryHelperBaseUrlValue, 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: '手动停止' }),
-    '手动停止'
-  );
-  assert.equal(
-    api.normalizeAccountRunHistoryHelperBaseUrlValue('http://127.0.0.1:17373/append-account-log'),
-    'http://127.0.0.1:17373'
-  );
-});

+ 7 - 6
项目完整链路说明.md

@@ -37,7 +37,7 @@
 - 向后台发送命令
 - 接收后台广播并更新 UI
 - 动态渲染步骤列表
-- 在日志区标题下方汇总展示最近账号运行历史
+- 在日志区通过“记录”按钮打开独立的邮箱记录覆盖层,并展示成功/失败/重试统计与分页列表
 
 ### 2.2 Background Service Worker
 
@@ -137,9 +137,9 @@
 - LuckMail API 配置
 - 自动运行默认配置
 - 账号运行历史 `accountRunHistory`
-- 账号运行历史 txt 留档开关与 helper 地址
+- 账号运行历史本地同步开关与 helper 地址
 
-当启用了独立的账号运行历史 txt 留档配置时,账号运行历史会通过 [scripts/hotmail_helper.py](c:/Users/projectf/Downloads/codex注册扩展/scripts/hotmail_helper.py) 追加写入 `data/account-run-history.txt` 文本文件,便于离线留档
+当启用了独立的账号运行历史本地同步配置时,账号运行历史会通过 [scripts/hotmail_helper.py](c:/Users/projectf/Downloads/codex注册扩展/scripts/hotmail_helper.py) 整体同步写入 `data/account-run-history.json` 快照文件,便于开发者直接查看完整记录
 这条配置链路独立于 `mailProvider` 和 Hotmail 的接码模式。
 
 ### 4.3 状态广播
@@ -408,8 +408,8 @@
 
 补充:
 
-- 本地 helper 除了收信与验证码读取,还提供账号运行历史文本追加接口。
-- 账号运行历史 txt 留档由独立配置控制,不再绑定 Hotmail 的本地助手模式。
+- 本地 helper 除了收信与验证码读取,还提供邮箱记录 JSON 快照同步接口。
+- 账号运行历史本地同步由独立配置控制,不再绑定 Hotmail 的本地助手模式。
 
 ### 7.3 LuckMail
 
@@ -444,7 +444,8 @@
    - 立即停止
    - 当前轮重试
    - 下一轮继续
-6. 当前轮最终失败或被停止时,追加账号运行历史记录
+6. 当前轮最终失败时,写入邮箱记录,并在自动重试链路中累计重试次数
+   - 手动停止与自动停止不再进入新邮箱记录体系
 7. 如果配置了线程间隔,则挂计时计划
 8. 所有轮次结束后输出汇总
 

+ 9 - 8
项目文件结构说明.md

@@ -31,8 +31,8 @@
 - `microsoft-email.js`:Microsoft Graph / Outlook 邮件读取辅助模块,负责刷新令牌换 token、邮箱夹轮询和验证码提取。
 - `package.json`:仓库最小 Node 包配置,目前主要提供测试脚本定义。
 - `rules.json`:静态 DNR 规则,主要处理 iCloud 相关请求头。
-- `start-hotmail-helper.bat`:Windows 下启动本地 helper 的脚本;当前既服务于 Hotmail 本地收信,也服务于账号运行历史文本留档
-- `start-hotmail-helper.command`:macOS 下启动本地 helper 的脚本;当前既服务于 Hotmail 本地收信,也服务于账号运行历史文本留档
+- `start-hotmail-helper.bat`:Windows 下启动本地 helper 的脚本;当前既服务于 Hotmail 本地收信,也服务于邮箱记录快照同步
+- `start-hotmail-helper.command`:macOS 下启动本地 helper 的脚本;当前既服务于 Hotmail 本地收信,也服务于邮箱记录快照同步
 - `开发者AI开发与PR提交流程.md`:仓库现有的 AI 开发与 PR 提交流程说明。
 - `项目文件结构说明.md`:当前文件,维护整个仓库非忽略文件的结构与职责索引。
 - `项目完整链路说明.md`:面向 AI/开发者的完整功能链路说明,用于快速理解系统整体运行过程。
@@ -40,7 +40,7 @@
 
 ## `background/`
 
-- `background/account-run-history.js`:账号运行历史模块,负责将成功、失败、停止结果持久化到 `chrome.storage.local`,并在启用独立本地 txt 留档配置后追加写入文本日志
+- `background/account-run-history.js`:邮箱记录模块,负责把旧的 append-only 结果流水归一化为成功/失败摘要记录,落地到 `chrome.storage.local`,支持清理记录,并在启用独立本地同步配置后把完整快照同步到本地 helper
 - `background/auto-run-controller.js`:自动运行主控制器,封装多轮执行、重试、轮次摘要、线程间隔与倒计时恢复逻辑;fresh-attempt reset 时会额外保留 `gmailBaseEmail` 与 `mail2925BaseEmail`,避免自动流程重置后丢失别名基邮箱配置。
 - `background/generated-email-helpers.js`:生成邮箱辅助层,除了 Duck / Cloudflare / iCloud / Cloudflare Temp Email,也统一承接 Gmail / 2925 的别名邮箱生成入口。
 - `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层。
@@ -105,16 +105,17 @@
 
 ## `scripts/`
 
-- `scripts/hotmail_helper.py`:本地 helper 服务,负责通过本地接口协助 Hotmail 获取邮件和验证码,并提供账号运行历史文本追加接口
+- `scripts/hotmail_helper.py`:本地 helper 服务,负责通过本地接口协助 Hotmail 获取邮件和验证码,并提供邮箱记录 JSON 快照同步接口;旧的文本追加接口仍保留作兼容
 
 ## `sidepanel/`
 
 - `sidepanel/hotmail-manager.js`:侧边栏 Hotmail 账号池管理器,负责列表、导入、验证、测试收信和批量操作。
 - `sidepanel/icloud-manager.js`:侧边栏 iCloud 隐私邮箱管理器,负责列表、筛选、保留、删除和批量操作。
 - `sidepanel/luckmail-manager.js`:侧边栏 LuckMail 管理器,负责邮箱列表、筛选、启停、保留与批量操作。
+- `sidepanel/account-records-manager.js`:侧边栏邮箱记录面板管理器,负责“记录”按钮、覆盖层开关、分页列表、统计摘要和清理确认。
 - `sidepanel/sidepanel.css`:侧边栏样式文件。
-- `sidepanel/sidepanel.html`:侧边栏页面结构;当前步骤列表已改为动态容器,日志区标题下方预留账号运行历史摘要条带,并新增共享“验证码重发”次数输入,同时会加载 `managed-alias-utils.js`,把旧的“邮箱前缀”字段语义改为“别名基邮箱”。
-- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、账号运行历史摘要渲染、独立 txt 留档配置、共享验证码自动重发次数配置与广播接收;同时把 Gmail / 2925 的基邮箱输入、完整注册邮箱输入、自动生成按钮与兼容性校验统一接到共享别名逻辑上。
+- `sidepanel/sidepanel.html`:侧边栏页面结构;当前步骤列表已改为动态容器,日志区提供“记录”按钮并挂接邮箱记录覆盖层,同时新增共享“验证码重发”次数输入,并加载 `managed-alias-utils.js`,把旧的“邮箱前缀”字段语义改为“别名基邮箱”。
+- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、独立本地同步配置、共享验证码自动重发次数配置与广播接收,并装配 Hotmail / iCloud / LuckMail / 邮箱记录面板 manager;同时把 Gmail / 2925 的基邮箱输入、完整注册邮箱输入、自动生成按钮与兼容性校验统一接到共享别名逻辑上。
 - `sidepanel/update-service.js`:侧边栏更新检查服务,负责 GitHub Releases 查询与版本展示。
 
 ## `tests/`
@@ -125,7 +126,7 @@
 - `tests/auto-run-step6-restart.test.js`:测试自动运行在后半段授权链路遇错时会回到步骤 7 重开,并在命中 add-phone 时停止重开。
 - `tests/auto-step-random-delay.test.js`:测试自动运行步间延迟与旧配置键兼容解析。
 - `tests/background-auto-run-module.test.js`:测试自动运行控制器模块已接入且导出工厂。
-- `tests/background-account-run-history-module.test.js`:测试账号运行历史模块已接入、导出工厂,并能在不启用本地 helper 时正确持久化记录
+- `tests/background-account-run-history-module.test.js`:测试邮箱记录模块已接入、导出工厂,能够过滤停止记录、归一化失败标签、计算自动重试次数,并支持清理后同步完整快照
 - `tests/background-account-history-settings.test.js`:测试账号运行历史的独立配置项归一化,不再复用 Hotmail 模式作为启停条件。
 - `tests/background-generated-email-module.test.js`:测试生成邮箱辅助模块已接入且导出工厂。
 - `tests/background-icloud.test.js`:测试 iCloud 相关后台纯函数与别名收尾逻辑。
@@ -156,7 +157,7 @@
 - `tests/managed-alias-utils.test.js`:覆盖 Gmail / 2925 共享别名工具的解析、生成、兼容性判断。
 - `tests/microsoft-email.test.js`:测试 Microsoft 邮件拉取与验证码提取逻辑。
 - `tests/sidepanel-hotmail-manager.test.js`:测试侧边栏 Hotmail 管理器模块接线与空态渲染。
-- `tests/sidepanel-account-run-history.test.js`:测试侧边栏日志区下方的账号运行历史条带结构、独立留档配置控件与状态分类摘要逻辑。
+- `tests/sidepanel-account-records-manager.test.js`:测试侧边栏邮箱记录覆盖层的 HTML 接入、helper 地址归一化与 manager 渲染逻辑。
 - `tests/sidepanel-icloud-manager.test.js`:测试侧边栏 iCloud 管理器模块接线与空态渲染。
 - `tests/sidepanel-icloud-provider.test.js`:测试侧边栏 iCloud 登录地址解析逻辑。
 - `tests/sidepanel-luckmail-manager.test.js`:测试侧边栏 LuckMail 管理器模块接线与空态渲染。