This commit is contained in:
zhu
2026-05-12 17:58:27 +08:00
parent c7cb977243
commit 302311b3af
8 changed files with 498 additions and 62 deletions

View File

@@ -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);
});
});

View File

@@ -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<T = unknown> {
ok: boolean;
success?: boolean;
@@ -29,17 +34,23 @@ interface ExternalResponse<T = unknown> {
error?: string;
}
/** 网页侧接收到的复合载荷:包含任务状态和(完成后的)抓取结果 */
interface CrawlWebPayload {
state: CrawlTaskState | null;
result: Record<string, unknown> | null;
}
/** 维护当前所有已连接的网页端口(用于实时广播进度) */
const externalPorts = new Set<chrome.runtime.Port>();
/**
* 处理外部网页发送的单次指令(一问一答模式)
*/
export async function handleExternalMessage(message: ExternalMessage): Promise<ExternalResponse> {
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<E
})),
},
};
// 网页发起爬取指令
case 'DIANSHAN_START_CRAWL':
return startCrawlForWebsite(message.payload?.platformId);
// 主动查询当前进度
case 'DIANSHAN_GET_CRAWL_STATE':
return {
ok: true,
data: buildCrawlWebPayload(await getCrawlTaskState()),
};
// 网页强制取消任务
case 'DIANSHAN_CANCEL_CRAWL':
await cancelCrawl();
return {
ok: true,
data: buildCrawlWebPayload(null),
};
default:
return { ok: false, error: 'unknown_external_action' };
return {ok: false, error: 'unknown_external_action'};
}
}
/**
* 处理外部网页的长连接请求(用于实时推送进度)
*/
export function handleExternalConnect(port: chrome.runtime.Port): void {
// 只接受指定名称的端口连接
if (port.name !== EXTERNAL_PORT_NAME) {
port.disconnect();
return;
@@ -79,6 +101,7 @@ export function handleExternalConnect(port: chrome.runtime.Port): void {
externalPorts.add(port);
// 连接建立时,立即推送一次当前状态
getCrawlTaskState()
.then((state) => {
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<string, chrome.storage.StorageChange>, 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<string, chrome.stora
});
}
/**
* 封装网页调起的爬取逻辑:做一层错误兼容处理
*/
async function startCrawlForWebsite(platformId?: string): Promise<ExternalResponse<CrawlWebPayload>> {
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<string, unknown> {
return Object.fromEntries(
state.steps.map((step) => [
@@ -168,40 +209,40 @@ function collectStepResults(state: CrawlTaskState): Record<string, unknown> {
);
}
/**
* 根据状态机的变化,转换成对应的外部事件名称
*/
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<CrawlWebPayload>): 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;
}
}

View File

@@ -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<string, AbortController>();
@@ -59,8 +60,9 @@ export async function startCrawl(platformId: string): Promise<any> {
/**
* 执行器
*/
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<void> {
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<void> {
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<CrawlTaskState | null> {
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 标记为 runningsuccess 不动,避免覆盖已完成步骤)
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<void> {
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<string, unknown> {
return Object.fromEntries(
state.steps.map((step) => [
step.uniqueKey,
{
name: step.name,
status: step.status,
result: step.result ?? null,
message: step.message ?? null,
},
]),
);
}

View File

@@ -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<void> {
* 取消
*/
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 {
<!-- 未登录-->
<template v-if="!isLoggedIn">
<div class="status">请先登录后再开始爬取</div>
<div class="status">{{ t('please_login') }}</div>
<button style="margin-top: 20px" type="button" @click="handleLogin">
Sign in
{{ t('sign_in') }}
</button>
</template>
@@ -73,7 +87,7 @@ function requestCancel(): void {
<template v-if="crawlState == null">
<label class="platform-select">
<span class="account">平台选择</span>
<span class="account">{{ t('platform_select') }}</span>
<select v-model="selectedPlatformId"
class="platform-select__control">
<option v-for="platform in platformConfigs"
@@ -85,7 +99,7 @@ function requestCancel(): void {
</label>
<button type="button" :disabled="isScanning" @click="handleScan">
{{ isScanning ? 'Opening' : 'Scan now' }}
{{ isScanning ? t('opening') : t('scan_now') }}
</button>
</template>
@@ -100,10 +114,10 @@ function requestCancel(): void {
<div class="radar-titles">
<div class="radar-title">{{ crawlState.platformName }}</div>
<div class="radar-sub">
<template v-if="taskStatus == 'paused'">Paused</template>
<template v-else-if="taskStatus == 'completed'">Done</template>
<template v-else-if="taskStatus == 'failed'">Failed</template>
<template v-else>Scanning</template>
<template v-if="taskStatus == 'paused'">{{ t('paused') }}</template>
<template v-else-if="taskStatus == 'completed'">{{ t('done') }}</template>
<template v-else-if="taskStatus == 'failed'">{{ t('failed') }}</template>
<template v-else>{{ t('scanning') }}</template>
· {{ formatSeconds(elapsedSeconds) }}
</div>
</div>
@@ -120,10 +134,10 @@ function requestCancel(): void {
<div class="step-label">{{ index + 1 }}. {{ step.name }}</div>
</div>
<div class="step-status">
<span v-if="step.status == 'success'">已完成</span>
<span v-else-if="step.status == 'failed'">失败</span>
<span v-else-if="step.status == 'running'">进行中</span>
<span v-else-if="step.status == 'pending'">等待中</span>
<span v-if="step.status == 'success'">{{ t('step_done') }}</span>
<span v-else-if="step.status == 'failed'">{{ t('step_failed') }}</span>
<span v-else-if="step.status == 'running'">{{ t('step_running') }}</span>
<span v-else-if="step.status == 'pending'">{{ t('step_pending') }}</span>
</div>
</div>
</div>
@@ -133,16 +147,16 @@ function requestCancel(): void {
<template v-if="taskStatus == 'running'">
<button v-if="taskStatus == 'running'"
type="button"
class="secondary"
@click="focusCrawlWindow">
Show tab
class="secondary"
@click="focusCrawlWindow">
{{ t('show_tab') }}
</button>
</template>
<!-- 暂停中-->
<template v-else-if="taskStatus == 'paused'">
<button type="button" @click="handleResumeCrawl">
Continue now
{{ t('continue_now') }}
</button>
</template>
@@ -150,7 +164,7 @@ function requestCancel(): void {
type="button"
class="secondary"
@click="requestCancel">
Cancel
{{ t('cancel') }}
</button>
</div>
</div>
@@ -164,13 +178,27 @@ function requestCancel(): void {
type="button"
class="secondary footer-btn"
@click="handleLogout">
Sign out
{{ t('sign_out') }}
</button>
<span class="version">v{{ manifestVersion }}</span>
<div style="display:flex; align-items:center; gap:8px;">
<label style="display:flex; align-items:center; gap:6px;">
<span style="font-size: 12px; opacity: 0.75;">{{ t('language') }}</span>
<select
:value="uiLang"
style="font-size: 12px; padding: 2px 6px; border-radius: 6px; border: 1px solid rgba(0,0,0,0.12);"
@change="onLangChange"
>
<option v-for="opt in langOptions" :key="opt.value" :value="opt.value">
{{ t(opt.labelKey) }}
</option>
</select>
</label>
<span class="version">v{{ manifestVersion }}</span>
</div>
</footer>
</div>
</template>
<style>
@import "tailwindcss";
</style>
</style>

112
src/popup/hook/use-i18n.ts Normal file
View File

@@ -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<PopupUiLang, Record<string, string>> = {
'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<PopupUiLang>('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'},
],
};
}

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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"}
{"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"}