diff --git a/src/background/index.ts b/src/background/index.ts index 8134da6..7eef664 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -1,6 +1,13 @@ import {broadcastCrawlStorageChange, handleExternalConnect, handleExternalMessage} from './service/externalBridge'; import {MessageAction} from "@/shared/message"; -import {cancelCrawl, startCrawl} from "./task/crawlTask"; +import { + cancelCrawl, + dismissCrawl, + pauseCrawlOnTabRemoved, + pauseCrawlOnWindowRemoved, + resumeCrawl, + startCrawl +} from "./task/crawlTask"; import {getCrawlTaskState} from "./task/taskState"; chrome.runtime.onInstalled.addListener(() => { @@ -35,6 +42,14 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { case "CANCEL_CRAWL": await cancelCrawl() break; + + case "RESUME_CRAWL": + resultData = await resumeCrawl(); + break; + + case "DISMISS_CRAWL": + await dismissCrawl(); + break; default: throw new Error(`未知的后台指令: ${action}`); } @@ -55,6 +70,13 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { * 用户手动关掉爬虫窗口时,自动触发任务清理逻辑(取消任务、停掉后台循环)。 */ chrome.windows.onRemoved.addListener((windowId) => { + // 中文备注:用户手动关掉爬取窗口时,不要直接取消任务;要切换为暂停,等用户在 popup 点“继续”恢复。 + void pauseCrawlOnWindowRemoved(windowId); +}); + +chrome.tabs.onRemoved.addListener((tabId) => { + // 中文备注:兜底处理:有些情况下只会触发 tab 移除事件,这里同样按“窗口被关闭”暂停。 + void pauseCrawlOnTabRemoved(tabId); }); /** @@ -84,4 +106,4 @@ chrome.runtime.onConnectExternal.addListener(handleExternalConnect); */ chrome.storage.onChanged.addListener((changes, areaName) => { broadcastCrawlStorageChange(changes, areaName); -}); \ No newline at end of file +}); diff --git a/src/background/service/externalBridge.ts b/src/background/service/externalBridge.ts index f336ed0..6de3034 100644 --- a/src/background/service/externalBridge.ts +++ b/src/background/service/externalBridge.ts @@ -1,18 +1,22 @@ -import { platformConfigs } from '@/config/platforms'; -import type { CrawlTaskState } from '@/types'; +import {platformConfigs} from '@/config/platforms'; +import type {CrawlTaskState} from '@/types'; import {getCrawlTaskState} from "@/background/task/taskState"; import {cancelCrawl, startCrawl} from "@/background/task/crawlTask"; +/** 存储任务状态的 Key,需与存储层保持一致 */ const CRAWL_TASK_STORAGE_KEY = 'crawlTaskState'; +/** 外部通信的长连接端口名称 */ const EXTERNAL_PORT_NAME = 'DIANSHAN_CRAWL'; +/** 定义外部(网页侧)可以发起的动作类型 */ type ExternalAction = - | 'DIANSHAN_PING' - | 'DIANSHAN_START_CRAWL' - | 'DIANSHAN_GET_CRAWL_STATE' - | 'DIANSHAN_CANCEL_CRAWL' - | 'STORE_AI_PING'; + | 'DIANSHAN_PING' // 探测插件是否安装/活跃 + | 'DIANSHAN_START_CRAWL' // 从网页发起爬取 + | 'DIANSHAN_GET_CRAWL_STATE' // 获取当前进度 + | 'DIANSHAN_CANCEL_CRAWL' // 取消爬取 + | 'STORE_AI_PING'; // 兼容性探测 +/** 外部消息结构 */ interface ExternalMessage { type?: ExternalAction; action?: ExternalAction; @@ -21,6 +25,7 @@ interface ExternalMessage { }; } +/** 返回给网页的统一响应格式 */ interface ExternalResponse { ok: boolean; success?: boolean; @@ -29,17 +34,23 @@ interface ExternalResponse { error?: string; } +/** 网页侧接收到的复合载荷:包含任务状态和(完成后的)抓取结果 */ interface CrawlWebPayload { state: CrawlTaskState | null; result: Record | null; } +/** 维护当前所有已连接的网页端口(用于实时广播进度) */ const externalPorts = new Set(); +/** + * 处理外部网页发送的单次指令(一问一答模式) + */ export async function handleExternalMessage(message: ExternalMessage): Promise { const action = message.type ?? message.action; switch (action) { + // 插件存活探测:返回版本号及支持的平台列表 case 'STORE_AI_PING': case 'DIANSHAN_PING': return { @@ -53,25 +64,36 @@ export async function handleExternalMessage(message: ExternalMessage): Promise { postToExternalPort(port, { @@ -95,6 +118,7 @@ export function handleExternalConnect(port: chrome.runtime.Port): void { }); }); + // 监听网页通过长连接发送的消息 port.onMessage.addListener((message: ExternalMessage) => { void handleExternalMessage(message) .then((response) => { @@ -109,24 +133,26 @@ export function handleExternalConnect(port: chrome.runtime.Port): void { }); }); + // 端口断开(网页关闭)时,从集合中移除 port.onDisconnect.addListener(() => { externalPorts.delete(port); }); } +/** + * 监听 Storage 变化并广播给所有网页端口 + * 这是实现网页端进度条“丝滑跳动”的核心逻辑 + */ export function broadcastCrawlStorageChange(changes: Record, areaName: string): void { - if (areaName !== 'local') { - return; - } + if (areaName !== 'local') return; const change = changes[CRAWL_TASK_STORAGE_KEY]; - - if (!change) { - return; - } + if (!change) return; const nextState = isCrawlTaskState(change.newValue) ? change.newValue : null; const oldState = isCrawlTaskState(change.oldValue) ? change.oldValue : null; + + // 根据状态变化确定通知类型(如:已完成、已取消、普通更新) const type = getBroadcastType(nextState, oldState); broadcastToExternalPorts({ @@ -136,17 +162,29 @@ export function broadcastCrawlStorageChange(changes: Record> { - const response = await startCrawl(platformId ?? platformConfigs[0]?.id ?? ''); + // 调用核心爬取逻辑 + const response: any = await startCrawl(platformId ?? platformConfigs[0]?.id ?? ''); + + // 检查返回的是错误对象还是成功的任务状态 + const isError = response && typeof response === 'object' && 'error' in response && !('id' in response); + const state = !isError ? (response as CrawlTaskState) : null; return { - ok: response.ok, + ok: !isError, type: 'DIANSHAN_CRAWL_STARTED', - data: buildCrawlWebPayload(response.data ?? null), - error: response.error, + data: buildCrawlWebPayload(state), + error: isError ? String(response.error ?? 'start_failed') : undefined, }; } +/** + * 构建网页端专用的数据载荷 + * 如果任务已完成,则顺便把所有抓取到的结果打包带走 + */ function buildCrawlWebPayload(state: CrawlTaskState | null): CrawlWebPayload { return { state, @@ -154,6 +192,9 @@ function buildCrawlWebPayload(state: CrawlTaskState | null): CrawlWebPayload { }; } +/** + * 汇总任务中所有步骤的抓取结果 + */ function collectStepResults(state: CrawlTaskState): Record { return Object.fromEntries( state.steps.map((step) => [ @@ -168,40 +209,40 @@ function collectStepResults(state: CrawlTaskState): Record { ); } +/** + * 根据状态机的变化,转换成对应的外部事件名称 + */ function getBroadcastType(nextState: CrawlTaskState | null, oldState: CrawlTaskState | null): string { if (!nextState) { return oldState ? 'DIANSHAN_CRAWL_CLEARED' : 'DIANSHAN_CRAWL_STATE'; } - if (nextState.status === 'completed') { - return 'DIANSHAN_CRAWL_DONE'; + switch (nextState.status) { + case 'completed': return 'DIANSHAN_CRAWL_DONE'; + case 'failed': return 'DIANSHAN_CRAWL_FAILED'; + case 'canceled': return 'DIANSHAN_CRAWL_CANCELED'; + default: return 'DIANSHAN_CRAWL_STATE'; } - - if (nextState.status === 'failed') { - return 'DIANSHAN_CRAWL_FAILED'; - } - - if (nextState.status === 'canceled') { - return 'DIANSHAN_CRAWL_CANCELED'; - } - - return 'DIANSHAN_CRAWL_STATE'; } +/** 向所有已连接的网页广播消息 */ function broadcastToExternalPorts(message: ExternalResponse): void { for (const port of externalPorts) { postToExternalPort(port, message); } } +/** 向单个端口发送消息,并处理连接失效的情况 */ function postToExternalPort(port: chrome.runtime.Port, message: ExternalResponse): void { try { port.postMessage(message); } catch { + // 如果发送失败(通常因为网页已关闭),则强制清理 externalPorts.delete(port); } } +/** 类型守卫:判断对象是否为有效的任务状态 */ function isCrawlTaskState(value: unknown): value is CrawlTaskState { return typeof value === 'object' && value !== null && 'id' in value && 'steps' in value; -} +} \ No newline at end of file diff --git a/src/background/task/crawlTask.ts b/src/background/task/crawlTask.ts index 2e3c53e..97c4def 100644 --- a/src/background/task/crawlTask.ts +++ b/src/background/task/crawlTask.ts @@ -2,6 +2,7 @@ import {getPlatformById} from "@/config/platforms"; import {CrawlTaskState, PlatformStepConfig} from "@/types"; import {openSingleTabWindow, scrapeStepInContent, sleep, waitForTabLoaded} from "@/background/task/helper"; import {clearCrawlTaskState, getCrawlTaskState, setCrawlTaskState, updateCrawlTaskState} from "./taskState"; +import {sendTabMessage} from "@/shared/tab"; const activeCrawlControllers = new Map(); @@ -59,8 +60,9 @@ export async function startCrawl(platformId: string): Promise { /** * 执行器 */ -async function runCrawlSteps(taskId: string, tabId: number, steps: PlatformStepConfig[], signal: AbortSignal) { - for (let i = 0; i < steps.length; i += 1) { +async function runCrawlSteps(taskId: string, tabId: number, steps: PlatformStepConfig[], signal: AbortSignal, startIndex = 0) { + // 中文备注:startIndex 用于“继续/恢复”场景,从上次没爬完的步骤开始跑。 + for (let i = startIndex; i < steps.length; i += 1) { const step = steps[i]; let shouldRetryStep = true; @@ -116,6 +118,10 @@ async function runCrawlSteps(taskId: string, tabId: number, steps: PlatformStepC // 【修改 3】全部步骤完成,标记任务结束 await updateCrawlTaskState(taskId, s => ({...s, status: 'completed'})); + + // 中文备注:全部爬取完成后,需要把数据发送给网页,然后清空本次任务记录数据、关掉爬取窗口。 + // 这里由 background 统一做“完成后收尾”,避免 UI 侧各自处理导致状态不同步。 + await finalizeCompletedTask(taskId, signal); } @@ -145,3 +151,209 @@ export async function cancelCrawl() { } +/** + * 当爬取窗口被用户手动关闭时触发:把任务标记为暂停,并中止当前的执行器。 + * 中文备注:这里“暂停”不是取消,任务进度(steps/result/currentStepIndex)会保留,供后续“继续”恢复。 + */ +export async function pauseCrawlOnWindowRemoved(windowId: number): Promise { + const state = await getCrawlTaskState(); + if (!state) return; + if (state.status !== 'running') return; + if (state.windowId !== windowId) return; + + // 中文备注:窗口被关掉后继续跑会频繁报 tab 不存在;这里直接 abort 当前 controller,等待用户点击“继续”后重启。 + const controller = activeCrawlControllers.get(state.id); + if (controller) { + controller.abort(); + activeCrawlControllers.delete(state.id); + } + + await updateCrawlTaskState(state.id, (s) => ({ + ...s, + status: 'paused', + pause: { + reason: 'window_closed', + message: '检测到爬取窗口被关闭。点击“继续”后将重新打开窗口,并从上次进度继续爬取。', + }, + // 中文备注:窗口/tab 已经不存在,置空避免 UI 侧再尝试聚焦旧窗口。 + windowId: undefined, + tabId: undefined, + })); +} + +/** + * 当爬取 tab 被关闭时触发:同样按“窗口被关闭”处理。 + * 中文备注:有些情况下只会触发 tabs.onRemoved,这里单独兜底。 + */ +export async function pauseCrawlOnTabRemoved(tabId: number): Promise { + const state = await getCrawlTaskState(); + if (!state) return; + if (state.status !== 'running') return; + if (state.tabId !== tabId) return; + + // 直接复用 window 关闭的暂停逻辑(windowId 可能为空,但不影响暂停) + const controller = activeCrawlControllers.get(state.id); + if (controller) { + controller.abort(); + activeCrawlControllers.delete(state.id); + } + + await updateCrawlTaskState(state.id, (s) => ({ + ...s, + status: 'paused', + pause: { + reason: 'window_closed', + message: '检测到爬取页面被关闭。点击“继续”后将重新打开窗口,并从上次进度继续爬取。', + }, + windowId: undefined, + tabId: undefined, + })); +} + +/** + * 继续/恢复暂停的任务。 + * 中文备注: + * - 如果是登录/验证码导致的暂停:只需要把状态从 paused 切回 running,让原来的执行器继续跑(不重启)。 + * - 如果是窗口被关闭导致的暂停:需要重新打开窗口,并从上次没完成的步骤开始重新跑。 + */ +export async function resumeCrawl(): Promise { + const state = await getCrawlTaskState(); + if (!state) return null; + + if (state.status !== 'paused') { + return state; + } + + // 1) 登录/验证码等中断:窗口仍存在时,直接恢复即可 + if (state.pause?.reason !== 'window_closed' && state.windowId && state.tabId) { + await updateCrawlTaskState(state.id, (s) => ({...s, status: 'running', pause: undefined})); + return await getCrawlTaskState(); + } + + // 2) 窗口关闭导致的暂停:重新打开窗口,并从上次进度继续 + const platform = getPlatformById(state.platformId); + if (!platform) { + // 中文备注:平台配置找不到时只能保持暂停态 + return state; + } + + const resumeIndex = Math.max(0, Math.min(state.currentStepIndex ?? 0, platform.steps.length - 1)); + + // 中文备注:如果 currentStepIndex 对应 step 已经 success,说明暂停发生在步骤切换间隙,往后找第一个未完成的步骤。 + let startIndex = resumeIndex; + for (let i = resumeIndex; i < state.steps.length; i += 1) { + if (state.steps[i]?.status !== 'success') { + startIndex = i; + break; + } + } + + const openUrl = platform.steps[startIndex]?.url ?? platform.steps[resumeIndex]?.url ?? platform.steps[0].url; + const windowInfo = await openSingleTabWindow(openUrl); + + const nextState: CrawlTaskState = { + ...state, + windowId: windowInfo.windowId, + tabId: windowInfo.tabId, + status: 'running', + pause: undefined, + currentStepIndex: startIndex, + steps: state.steps.map((step, idx) => ({ + ...step, + // 中文备注:继续时把当前要执行的 step 标记为 running(success 不动,避免覆盖已完成步骤) + status: idx === startIndex && step.status !== 'success' ? 'running' : step.status, + })), + }; + + await setCrawlTaskState(nextState); + + // 中文备注:重启执行器,从 startIndex 开始继续跑 + const controller = new AbortController(); + activeCrawlControllers.set(nextState.id, controller); + void runCrawlSteps(nextState.id, nextState.tabId!, platform.steps, controller.signal, startIndex).finally(() => { + activeCrawlControllers.delete(nextState.id); + }); + + return nextState; +} + +/** + * 关闭/忽略当前任务的 UI 提示(只清空状态,不强制走取消逻辑)。 + * 中文备注:用于 UI 侧把卡片隐藏掉;如果窗口还存在也会顺手关闭,避免残留。 + */ +export async function dismissCrawl(): Promise { + const state = await getCrawlTaskState(); + if (!state) { + await clearCrawlTaskState(); + return; + } + + // 中文备注:如果仍有执行器在跑,dismiss 等同取消,避免后台继续执行。 + const controller = activeCrawlControllers.get(state.id); + if (controller) { + controller.abort(); + activeCrawlControllers.delete(state.id); + } + + await clearCrawlTaskState(); + + if (state.windowId) { + chrome.windows.remove(state.windowId).catch(() => { + }); + } +} + +/** + * 完成后的统一收尾:发送结果 -> 清空 storage -> 关闭爬取窗口 + * 中文备注: + * - “发送给网页”:外部网页(externally_connectable)会通过 storage 广播拿到 completed 状态和结果; + * - 同时也给爬取 tab 发一份 `CRAWL_COMPLETED`,方便页面内(content script)有需要时直接接收。 + */ +async function finalizeCompletedTask(taskId: string, signal: AbortSignal) { + const state = await getCrawlTaskState(); + if (!state || state.id !== taskId) return; + if (state.status !== 'completed') return; + + // 1) 发送给爬取 tab(如果 tab 还存在且页面内有监听方) + if (state.tabId) { + sendTabMessage(state.tabId, 'CRAWL_COMPLETED', { + taskId: state.id, + platformId: state.platformId, + platformName: state.platformName, + startedAt: state.startedAt, + result: collectStepResults(state), + }); + } + + // 2) 留一点时间给 storage.onChanged -> external ports 广播完成态(DIANSHAN_CRAWL_DONE) + // 中文备注:不宜太久,避免完成后窗口迟迟不关;这里 300ms 足够让消息出队。 + await sleep(300, signal); + + // 3) 清空任务记录(popup 会收到 storage 变化自动重置 UI) + await clearCrawlTaskState(); + + // 4) 关闭爬取窗口 + if (state.windowId) { + chrome.windows.remove(state.windowId).catch(() => { + }); + } +} + +/** + * 收集每个 step 的结果数据,统一输出为 { [uniqueKey]: { ... } } 结构。 + * 中文备注:该结构与 externalBridge.ts 里对外输出一致,方便网页侧消费。 + */ +function collectStepResults(state: CrawlTaskState): Record { + return Object.fromEntries( + state.steps.map((step) => [ + step.uniqueKey, + { + name: step.name, + status: step.status, + result: step.result ?? null, + message: step.message ?? null, + }, + ]), + ); +} + diff --git a/src/popup/App.vue b/src/popup/App.vue index 649929a..458a0b0 100644 --- a/src/popup/App.vue +++ b/src/popup/App.vue @@ -3,9 +3,13 @@ import {platformConfigs} from '@/config/platforms'; import {formatSeconds} from '@/shared/time_format'; import {useLogin} from './hook/use-login'; import {useScan} from './hook/use-scan'; +import {useI18n, type PopupUiLang} from './hook/use-i18n'; const {isLoggedIn, handleLogin, handleLogout} = useLogin(); +// 中文备注:Popup 内多语言(只影响文案显示) +const {uiLang, setUiLang, t, langOptions} = useI18n(); + const { selectedPlatformId, isScanning, @@ -44,8 +48,18 @@ async function focusCrawlWindow(): Promise { * 取消 */ function requestCancel(): void { - crawlState.value = null - handleCancelCrawl() + // 中文备注:不要在这里手动把 crawlState 置空。 + // 任务状态以 storage 同步为准;手动置空会让 use-scan 的计时器回调访问空对象,导致 popup 闪退(表现为“闪一下”)。 + void handleCancelCrawl(); +} + +/** + * 语言切换 + * 中文备注:用事件回调承接,避免在 template 里写复杂类型断言影响可读性。 + */ +function onLangChange(event: Event): void { + const value = (event.target as HTMLSelectElement).value as PopupUiLang; + void setUiLang(value); } @@ -62,9 +76,9 @@ function requestCancel(): void { @@ -73,7 +87,7 @@ function requestCancel(): void { @@ -100,10 +114,10 @@ function requestCancel(): void {
{{ crawlState.platformName }}
- - - - + + + + · {{ formatSeconds(elapsedSeconds) }}
@@ -120,10 +134,10 @@ function requestCancel(): void {
{{ index + 1 }}. {{ step.name }}
- 已完成 - 失败 - 进行中 - 等待中 + {{ t('step_done') }} + {{ t('step_failed') }} + {{ t('step_running') }} + {{ t('step_pending') }}
@@ -133,16 +147,16 @@ function requestCancel(): void { @@ -150,7 +164,7 @@ function requestCancel(): void { type="button" class="secondary" @click="requestCancel"> - Cancel + {{ t('cancel') }} @@ -164,13 +178,27 @@ function requestCancel(): void { type="button" class="secondary footer-btn" @click="handleLogout"> - Sign out + {{ t('sign_out') }} - v{{ manifestVersion }} +
+ + v{{ manifestVersion }} +
\ No newline at end of file + diff --git a/src/popup/hook/use-i18n.ts b/src/popup/hook/use-i18n.ts new file mode 100644 index 0000000..4b6603b --- /dev/null +++ b/src/popup/hook/use-i18n.ts @@ -0,0 +1,112 @@ +import {computed, onMounted, ref} from 'vue'; + +/** + * Popup 多语言(仅影响 Popup 文案,不改平台配置/爬取数据)。 + * 中文备注:用户要求把切换入口放在 Popup 底部版本号附近,因此这里提供一个轻量的本地 i18n。 + */ + +/** chrome.storage.local 中保存语言的 key */ +const POPUP_UI_LANG_KEY = 'popupUiLang'; + +/** 目前仅提供中英两种,后续可在这里继续扩展 */ +export type PopupUiLang = 'zh-CN' | 'en'; + +/** 文案字典:key 统一用英文标识,方便维护 */ +const DICT: Record> = { + 'zh-CN': { + please_login: '请先登录后再开始爬取', + sign_in: '登录', + sign_out: '退出登录', + platform_select: '平台选择', + scan_now: '立即扫描', + opening: '正在打开…', + scanning: '扫描中', + paused: '已暂停', + done: '已完成', + failed: '失败', + show_tab: '显示页面', + continue_now: '继续', + cancel: '取消', + step_done: '已完成', + step_failed: '失败', + step_running: '进行中', + step_pending: '等待中', + language: '语言', + lang_zh: '中文', + lang_en: 'English', + }, + en: { + please_login: 'Please sign in before scanning.', + sign_in: 'Sign in', + sign_out: 'Sign out', + platform_select: 'Platform', + scan_now: 'Scan now', + opening: 'Opening…', + scanning: 'Scanning', + paused: 'Paused', + done: 'Done', + failed: 'Failed', + show_tab: 'Show tab', + continue_now: 'Continue now', + cancel: 'Cancel', + step_done: 'Completed', + step_failed: 'Failed', + step_running: 'Running', + step_pending: 'Pending', + language: 'Language', + lang_zh: '中文', + lang_en: 'English', + }, +}; + +/** + * Popup 内使用的 i18n composable + */ +export function useI18n() { + const uiLang = ref('zh-CN'); + + // 中文备注:从本地存储恢复用户上次选择 + onMounted(async () => { + try { + if (typeof chrome === 'undefined' || !chrome.storage?.local) return; + const res = await chrome.storage.local.get(POPUP_UI_LANG_KEY); + const stored = res?.[POPUP_UI_LANG_KEY]; + if (stored === 'zh-CN' || stored === 'en') { + uiLang.value = stored; + } + } catch { + // ignore + } + }); + + const t = computed(() => { + return (key: string) => { + return DICT[uiLang.value]?.[key] ?? DICT.en[key] ?? key; + }; + }); + + /** + * 设置语言并持久化 + */ + async function setUiLang(lang: PopupUiLang) { + uiLang.value = lang; + try { + if (typeof chrome === 'undefined' || !chrome.storage?.local) return; + await chrome.storage.local.set({[POPUP_UI_LANG_KEY]: lang}); + } catch { + // ignore + } + } + + return { + uiLang, + t: t.value, + setUiLang, + // 中文备注:给 template 直接使用的语言选项 + langOptions: [ + {value: 'zh-CN' as const, labelKey: 'lang_zh'}, + {value: 'en' as const, labelKey: 'lang_en'}, + ], + }; +} + diff --git a/src/popup/hook/use-scan.ts b/src/popup/hook/use-scan.ts index 809cf21..bf4261e 100644 --- a/src/popup/hook/use-scan.ts +++ b/src/popup/hook/use-scan.ts @@ -27,6 +27,19 @@ export const useScan = () => { let timer: number | undefined; + /** + * 停止计时器,并把显示的耗时归零。 + * 中文备注:当 crawlState 被清空(例如取消/完成后清理)时,如果不清理定时器, + * 定时器回调会继续访问 crawlState!.startedAt,导致 popup 直接崩溃,看起来像“闪一下就没反应”。 + */ + function stopElapsedTimer() { + if (timer) { + window.clearInterval(timer); + timer = undefined; + } + elapsedSeconds.value = 0; + } + /** * 动新的爬取任务 */ @@ -97,7 +110,14 @@ export const useScan = () => { */ function syncCrawlState(state: CrawlTaskState | null) { crawlState.value = state; - startElapsedTimer() + + // 中文备注:任务被清空时,必须停止计时器,避免空引用导致 popup 闪退。 + if (state === null) { + stopElapsedTimer(); + return; + } + + startElapsedTimer(); } /** @@ -148,7 +168,8 @@ export const useScan = () => { onUnmounted(() => { /** 清理计时器 + 取消订阅 storage 事件。 */ - clearInterval(timer); + // 中文备注:统一走 stopElapsedTimer,避免 timer 残留。 + stopElapsedTimer(); if (typeof chrome !== 'undefined' && chrome.storage?.onChanged) { chrome.storage.onChanged.removeListener(handleStorageChanged); diff --git a/src/types/crawl.ts b/src/types/crawl.ts index 855289f..5e5eebf 100644 --- a/src/types/crawl.ts +++ b/src/types/crawl.ts @@ -21,7 +21,7 @@ export interface CrawlProgressStep { // 爬取暂停原因,通常由登录、验证码或页面不存在触发。 export interface CrawlPauseInfo { // 暂停原因编码。 - reason: 'reauth' | 'shield' | 'not_found' | 'page_not_ready'; + reason: 'reauth' | 'shield' | 'not_found' | 'page_not_ready' | 'window_closed'; // 展示给用户看的处理提示。 message: string; } diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index 23fe38d..b5f7567 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./manifest.config.ts","./message.js","./vite.config.ts","./src/background/domscraper.ts","./src/background/index.ts","./src/background/service.ts","./src/background/types.ts","./src/background/service/crawltask.ts","./src/background/service/externalbridge.ts","./src/background/service/lifecycle.ts","./src/background/service/taskstate.ts","./src/config/platforms.ts","./src/content/app.vue","./src/content/crawloverlay.ts","./src/content/main.ts","./src/content/pagerunner.ts","./src/options/app.vue","./src/options/main.ts","./src/popup/app.vue","./src/popup/main.ts","./src/popup/hook/use-login.ts","./src/popup/hook/use-scan.ts","./src/shared/auth.ts","./src/shared/message.ts","./src/shared/time_format.ts","./src/types/crawl.ts","./src/types/index.ts","./src/types/platform.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./manifest.config.ts","./message.js","./vite.config.ts","./src/background/domscraper.ts","./src/background/index.ts","./src/background/types.ts","./src/background/service/externalbridge.ts","./src/background/task/crawltask.ts","./src/background/task/helper.ts","./src/background/task/taskstate.ts","./src/config/platforms.ts","./src/content/app.vue","./src/content/crawloverlay.ts","./src/content/main.ts","./src/content/pagerunner.ts","./src/options/app.vue","./src/options/main.ts","./src/popup/app.vue","./src/popup/main.ts","./src/popup/hook/use-i18n.ts","./src/popup/hook/use-login.ts","./src/popup/hook/use-scan.ts","./src/shared/auth.ts","./src/shared/message.ts","./src/shared/tab.ts","./src/shared/time_format.ts","./src/types/crawl.ts","./src/types/index.ts","./src/types/platform.ts"],"version":"5.9.3"} \ No newline at end of file