From f8972f72baca51d9b00727b4f26f9c568b82822c Mon Sep 17 00:00:00 2001 From: zhu <1812073942@qq.com> Date: Mon, 11 May 2026 17:24:53 +0800 Subject: [PATCH] =?UTF-8?q?ui+=E5=8A=9F=E8=83=BD=E4=B8=80=E6=A0=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/background/index.ts | 20 +- src/background/service/crawlTask.ts | 119 +++++- src/background/service/lifecycle.ts | 14 +- src/background/service/taskState.ts | 13 + src/background/types.ts | 20 +- src/config/platforms.ts | 2 +- src/content/crawlOverlay.ts | 606 ++++++++++++++++++++++++++++ src/content/main.ts | 16 +- src/popup/App.vue | 381 ++++++++++------- src/popup/hook/use-scan.ts | 26 ++ src/popup/main.ts | 2 +- src/popup/popup.css | 392 ++++++++++++++++++ src/shared/message.ts | 4 +- src/types/crawl.ts | 4 + tsconfig.tsbuildinfo | 2 +- 15 files changed, 1453 insertions(+), 168 deletions(-) create mode 100644 src/content/crawlOverlay.ts create mode 100644 src/popup/popup.css diff --git a/src/background/index.ts b/src/background/index.ts index 421fb44..abbb7c0 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -1,6 +1,8 @@ import { handleBackgroundCommand, handleInstalled, handleStartup, handleWindowRemoved } from './service'; import { broadcastCrawlStorageChange, handleExternalConnect, handleExternalMessage } from './service/externalBridge'; import type { BackgroundCommand } from './types'; +import { cancelStaleCrawlWhenWindowMissing } from './service/crawlTask'; +import { getCrawlTaskState } from './service/taskState'; chrome.runtime.onInstalled.addListener(() => { void handleInstalled(); @@ -10,8 +12,22 @@ chrome.runtime.onStartup.addListener(() => { void handleStartup(); }); -chrome.runtime.onMessage.addListener((message: BackgroundCommand, _sender, sendResponse) => { - void handleBackgroundMessage(message, sendResponse); +chrome.runtime.onMessage.addListener((message: BackgroundCommand | { action?: string }, sender, sendResponse) => { + if (message && typeof message === 'object' && message.action === 'GET_CRAWL_STATE_FOR_TAB') { + void (async () => { + await cancelStaleCrawlWhenWindowMissing(); + const state = await getCrawlTaskState(); + const tabId = sender.tab?.id; + if (state && typeof tabId === 'number' && state.tabId === tabId) { + sendResponse({ ok: true, data: state }); + return; + } + sendResponse({ ok: true, data: null }); + })(); + return true; + } + + void handleBackgroundMessage(message as BackgroundCommand, sendResponse); return true; }); diff --git a/src/background/service/crawlTask.ts b/src/background/service/crawlTask.ts index 88b183b..c53330b 100644 --- a/src/background/service/crawlTask.ts +++ b/src/background/service/crawlTask.ts @@ -12,6 +12,8 @@ interface PageRunnerResponse { } const activeCrawlControllers = new Map(); +const autoCloseTimers = new Map(); +const DEFAULT_AUTOCLOSE_DELAY_MS = 10_000; /** * 创建新的爬取任务,打开目标平台窗口,并把初始时间轴状态写入 storage。 @@ -53,7 +55,16 @@ export async function startCrawl(platformId: string): Promise { } abortActiveCrawl(state.id); + clearAutoCloseTimer(state.id); - await clearCrawlTaskState(); + const canceledState: CrawlTaskState = { + ...state, + status: 'canceled', + autocloseAt: state.windowId ? Date.now() + DEFAULT_AUTOCLOSE_DELAY_MS : null, + steps: state.steps.map((step, index) => + index === state.currentStepIndex && step.status === 'running' + ? { ...step, status: 'failed', message: '用户取消爬取任务' } + : step, + ), + }; - if (state.windowId) { - await chrome.windows.remove(state.windowId).catch(() => undefined); + await setCrawlTaskState(canceledState); + + if (canceledState.windowId) { + scheduleAutoCloseWindow(canceledState.id, canceledState.windowId, canceledState.autocloseAt); } - return { ok: true, data: null }; + return { ok: true, data: canceledState }; } /** @@ -132,10 +155,12 @@ export async function cancelCrawlWhenWindowRemoved(windowId: number): Promise index === state.currentStepIndex ? { ...step, status: 'failed', message: '爬取窗口已关闭' } : step, ), @@ -156,10 +181,12 @@ export async function cancelStaleCrawlWhenWindowMissing(): Promise { } abortActiveCrawl(state.id); + clearAutoCloseTimer(state.id); await setCrawlTaskState({ ...state, status: 'canceled', + autocloseAt: null, steps: state.steps.map((step, index) => index === state.currentStepIndex ? { ...step, status: 'failed', message: '爬取窗口已关闭,任务已取消' } : step, ), @@ -170,6 +197,68 @@ function abortActiveCrawl(taskId: string): void { activeCrawlControllers.get(taskId)?.abort(); } +/** + * 取消终态自动关窗(overlay“保持打开”)。 + */ +export async function cancelAutoclose(): Promise { + const state = await getCrawlTaskState(); + + if (!state) { + return { ok: true, data: null }; + } + + clearAutoCloseTimer(state.id); + + const nextState: CrawlTaskState = { + ...state, + autocloseAt: null, + }; + + await setCrawlTaskState(nextState); + return { ok: true, data: nextState }; +} + +/** + * 清理当前任务快照(popup 的 Close/Dismiss)。不强制关窗,只影响 UI。 + */ +export async function dismissCrawl(): Promise { + const state = await getCrawlTaskState(); + + if (!state) { + return { ok: true, data: null }; + } + + clearAutoCloseTimer(state.id); + await clearCrawlTaskState(); + return { ok: true, data: null }; +} + +function scheduleAutoCloseWindow(taskId: string, windowId: number, autocloseAt?: number | null): void { + if (!autocloseAt) { + return; + } + + clearAutoCloseTimer(taskId); + + const delayMs = Math.max(0, autocloseAt - Date.now()); + const timer = setTimeout(() => { + autoCloseTimers.delete(taskId); + chrome.windows.remove(windowId).catch(() => undefined); + }, delayMs) as unknown as number; + + autoCloseTimers.set(taskId, timer); +} + +function clearAutoCloseTimer(taskId: string): void { + const timer = autoCloseTimers.get(taskId); + if (timer === undefined) { + return; + } + + clearTimeout(timer); + autoCloseTimers.delete(taskId); +} + /** * 按平台 steps 顺序执行页面跳转、DOM 等待、字段抓取和进度更新。 */ @@ -264,23 +353,37 @@ async function runCrawlSteps(platform: PlatformConfig, initialState: CrawlTaskSt } } + const autocloseAt = initialState.windowId ? Date.now() + DEFAULT_AUTOCLOSE_DELAY_MS : null; + await updateCrawlTaskState(initialState.id, (state) => ({ ...state, status: 'completed', + autocloseAt, steps: state.steps.map((step) => (step.status === 'running' ? { ...step, status: 'success' } : step)), })); + + if (initialState.windowId) { + scheduleAutoCloseWindow(initialState.id, initialState.windowId, autocloseAt); + } } catch (error: unknown) { console.error('[crawl] 执行失败', error); + const autocloseAt = initialState.windowId ? Date.now() + DEFAULT_AUTOCLOSE_DELAY_MS : null; + await updateCrawlTaskState(initialState.id, (state) => ({ ...state, status: 'failed', + autocloseAt, steps: state.steps.map((step, index) => index === state.currentStepIndex && step.status === 'running' ? { ...step, status: 'failed', message: error instanceof Error ? error.message : '爬取执行失败' } : step, ), })); + + if (initialState.windowId) { + scheduleAutoCloseWindow(initialState.id, initialState.windowId, autocloseAt); + } } } @@ -417,8 +520,9 @@ function createCrawlWindow(url: string): Promise { chrome.windows.create( { url, - type: 'normal', - focused: true, + type: 'popup', + focused: false, + state: 'normal', width: 1280, height: 900, }, @@ -435,6 +539,7 @@ function createCrawlWindow(url: string): Promise { return; } + void chrome.windows.update(windowInfo.id, { drawAttention: true }).catch(() => undefined); resolve(windowInfo); }, ); diff --git a/src/background/service/lifecycle.ts b/src/background/service/lifecycle.ts index c65f013..32910dc 100644 --- a/src/background/service/lifecycle.ts +++ b/src/background/service/lifecycle.ts @@ -1,5 +1,13 @@ import type { BackgroundCommand, BackgroundResponse, CrawlStateResponse } from '../types'; -import { cancelCrawl, cancelCrawlWhenWindowRemoved, cancelStaleCrawlWhenWindowMissing, resumeCrawl, startCrawl } from './crawlTask'; +import { + cancelAutoclose, + cancelCrawl, + cancelCrawlWhenWindowRemoved, + cancelStaleCrawlWhenWindowMissing, + dismissCrawl, + resumeCrawl, + startCrawl, +} from './crawlTask'; import { getCrawlTaskState } from './taskState'; /** @@ -41,6 +49,10 @@ export async function handleBackgroundCommand( return cancelCrawl(); case 'RESUME_CRAWL': return resumeCrawl(); + case 'CANCEL_AUTOCLOSE': + return cancelAutoclose(); + case 'DISMISS_CRAWL': + return dismissCrawl(); default: return { ok: false, error: '未知的后台指令' }; } diff --git a/src/background/service/taskState.ts b/src/background/service/taskState.ts index 808d145..2bc8fb9 100644 --- a/src/background/service/taskState.ts +++ b/src/background/service/taskState.ts @@ -10,6 +10,7 @@ export async function getCrawlTaskState(): Promise { export async function setCrawlTaskState(state: CrawlTaskState): Promise { await chrome.storage.local.set({ [CRAWL_TASK_STORAGE_KEY]: state }); + broadcastToCrawlTab(state); } export async function clearCrawlTaskState(): Promise { @@ -29,6 +30,18 @@ export async function updateCrawlTaskState( await setCrawlTaskState(updater(state)); } +function broadcastToCrawlTab(state: CrawlTaskState): void { + if (!state.tabId) { + return; + } + + try { + void chrome.tabs.sendMessage(state.tabId, { type: 'crawl_state_update', state }).catch(() => undefined); + } catch { + // ignore + } +} + function isCrawlTaskState(value: unknown): value is CrawlTaskState { return typeof value === 'object' && value !== null && 'id' in value && 'steps' in value; } diff --git a/src/background/types.ts b/src/background/types.ts index 097308c..30c7d99 100644 --- a/src/background/types.ts +++ b/src/background/types.ts @@ -29,8 +29,26 @@ export interface ResumeCrawlCommand { action: 'RESUME_CRAWL'; } +// 取消终态自动关窗(保持窗口打开)的后台消息。 +export interface CancelAutocloseCommand { + // 消息动作类型:用户在 overlay 中点“保持打开”,阻止 background 自动关闭爬取窗口。 + action: 'CANCEL_AUTOCLOSE'; +} + +// 清理当前爬取任务快照(用于 popup 的 Dismiss/Close)。 +export interface DismissCrawlCommand { + // 消息动作类型:清空 crawlTaskState,让 popup 回到 idle。 + action: 'DISMISS_CRAWL'; +} + // popup/content script 能发送给 background 的全部消息类型。 -export type BackgroundCommand = StartCrawlCommand | GetCrawlStateCommand | CancelCrawlCommand | ResumeCrawlCommand; +export type BackgroundCommand = + | StartCrawlCommand + | GetCrawlStateCommand + | CancelCrawlCommand + | ResumeCrawlCommand + | CancelAutocloseCommand + | DismissCrawlCommand; // background 统一响应结构。 export interface BackgroundResponse { diff --git a/src/config/platforms.ts b/src/config/platforms.ts index a359e26..a89d827 100644 --- a/src/config/platforms.ts +++ b/src/config/platforms.ts @@ -256,7 +256,7 @@ export const platformConfigs: PlatformConfig[] = [ ] }, { - name: "账户健康状态", + name: "账户健康状态2", uniqueKey: "accounthealth", url: "https://seller.shopee.com.my/portal/accounthealth/home", checkSelector: '.page-container', diff --git a/src/content/crawlOverlay.ts b/src/content/crawlOverlay.ts new file mode 100644 index 0000000..2152ed2 --- /dev/null +++ b/src/content/crawlOverlay.ts @@ -0,0 +1,606 @@ +import type { CrawlTaskState, CrawlStepStatus, CrawlTaskStatus } from '@/types'; + +const OVERLAY_HOST_ID = 'dianshan-crawl-overlay-host'; +const MASK_HOST_ID = 'dianshan-crawl-mask-host'; + +type OverlayPhase = 'running' | 'paused' | 'done' | 'failed' | 'cancelled'; + +interface OverlayRefs { + host: HTMLDivElement; + root: ShadowRoot; + container: HTMLDivElement; + expanded: HTMLDivElement; + capsule: HTMLButtonElement; + stepsList: HTMLDivElement; + pauseBanner: HTMLDivElement; + pauseMessage: HTMLDivElement; + resumeBtn: HTMLButtonElement; + cancelBtn: HTMLButtonElement; + minimiseBtn: HTMLButtonElement; + titleEl: HTMLDivElement; + subtitleEl: HTMLDivElement; + currentDetail: HTMLDivElement; + autocloseBanner: HTMLDivElement; + autocloseText: HTMLDivElement; + stayOpenBtn: HTMLButtonElement; +} + +let refs: OverlayRefs | null = null; +let maskHost: HTMLDivElement | null = null; +let currentState: CrawlTaskState | null = null; +let clockTimer: number | null = null; +let autoCollapseTimer: number | null = null; +let hasExpandedOnceForThisTask = false; +let hasKeptOpen = false; + +export function mountCrawlOverlay(): void { + if (document.getElementById(OVERLAY_HOST_ID)) { + return; + } + + if (!document.body) { + document.addEventListener('DOMContentLoaded', mountCrawlOverlay, { once: true }); + return; + } + + refs = buildDom(); + refs.host.style.display = 'none'; + maskHost = buildMaskHost(); + + // State broadcasts are targeted to the crawl tab only (background knows tabId). + chrome.runtime.onMessage.addListener((raw) => { + const msg = raw as { type?: string; state?: unknown } | undefined; + if (msg?.type === 'crawl_state_update') { + applyState(isCrawlTaskState(msg.state) ? (msg.state as CrawlTaskState) : null); + } + return false; + }); + + // Initial state snapshot (tab-gated in background; other tabs get null). + void refreshForThisTab(); +} + +async function refreshForThisTab(): Promise { + if (!chrome.runtime?.sendMessage) return; + try { + const response = await chrome.runtime.sendMessage({ action: 'GET_CRAWL_STATE_FOR_TAB' }); + const next = (response && typeof response === 'object' ? (response as { data?: unknown }).data : null) ?? null; + applyState(isCrawlTaskState(next) ? (next as CrawlTaskState) : null); + } catch { + // ignore + } +} + +function isCrawlTaskState(value: unknown): value is CrawlTaskState { + return typeof value === 'object' && value !== null && 'id' in value && 'steps' in value; +} + +function phaseFor(state: CrawlTaskState): OverlayPhase { + if (state.status === 'paused') return 'paused'; + if (state.status === 'completed') return 'done'; + if (state.status === 'failed') return 'failed'; + if (state.status === 'canceled') return 'cancelled'; + return 'running'; +} + +function isActive(status: CrawlTaskStatus): boolean { + return status === 'running' || status === 'paused' || status === 'completed' || status === 'failed' || status === 'canceled'; +} + +function isTerminal(status: CrawlTaskStatus): boolean { + return status === 'completed' || status === 'failed' || status === 'canceled'; +} + +function applyState(next: CrawlTaskState | null): void { + if (!refs) return; + + const prevId = currentState?.id; + currentState = next; + + if (!next || !isActive(next.status)) { + refs.host.style.display = 'none'; + setMaskActive(false); + clearTimers(); + return; + } + + if (prevId !== next.id) { + hasExpandedOnceForThisTask = false; + hasKeptOpen = false; + } + + refs.host.style.display = 'block'; + + // Mask blocks page interaction while running. Paused lifts mask so user can solve captcha/login. + setMaskActive(next.status === 'running'); + + // Auto-collapse once per task, only while actively running. + if (!hasExpandedOnceForThisTask && next.status === 'running') { + hasExpandedOnceForThisTask = true; + setCollapsed(false); + if (autoCollapseTimer) window.clearTimeout(autoCollapseTimer); + autoCollapseTimer = window.setTimeout(() => { + if (currentState?.status === 'running') setCollapsed(true); + }, 3000); + } + + if (next.status === 'paused') { + setCollapsed(false); + } + + // Keep the elapsed subtitle ticking without waiting for storage writes. + if (!clockTimer) { + clockTimer = window.setInterval(() => { + if (currentState) render(currentState); + }, 1000); + } + + render(next); +} + +function clearTimers(): void { + if (clockTimer) { + window.clearInterval(clockTimer); + clockTimer = null; + } + if (autoCollapseTimer) { + window.clearTimeout(autoCollapseTimer); + autoCollapseTimer = null; + } +} + +function setCollapsed(collapsed: boolean): void { + if (!refs) return; + refs.container.dataset.collapsed = collapsed ? '1' : '0'; +} + +function setMaskActive(active: boolean): void { + if (!maskHost) return; + maskHost.style.display = active ? 'block' : 'none'; +} + +function render(state: CrawlTaskState): void { + if (!refs) return; + + const phase = phaseFor(state); + refs.container.dataset.phase = phase; + + const elapsedSeconds = Math.max(0, Math.floor((Date.now() - state.startedAt) / 1000)); + const time = formatElapsed(elapsedSeconds); + + refs.titleEl.textContent = state.platformName || '爬取任务'; + + const okCount = state.steps.filter((s) => s.status === 'success').length; + const totalCount = state.steps.length; + + let subtitle = ''; + if (phase === 'paused') subtitle = `已暂停 · ${time}`; + else if (phase === 'done') subtitle = `已完成 · ${okCount}/${totalCount}`; + else if (phase === 'failed') subtitle = `失败 · ${okCount}/${totalCount}`; + else if (phase === 'cancelled') subtitle = `已取消 · ${okCount}/${totalCount}`; + else subtitle = `运行中 · ${okCount}/${totalCount} · ${time}`; + + refs.subtitleEl.textContent = subtitle; + refs.currentDetail.textContent = buildCurrentDetail(state); + + // Steps + refs.stepsList.innerHTML = ''; + for (const step of state.steps) { + const row = document.createElement('div'); + row.className = 'step'; + row.dataset.status = mapStepStatus(step.status); + + const dot = document.createElement('div'); + dot.className = 'dot'; + dot.textContent = dotFor(step.status); + + const label = document.createElement('div'); + label.className = 'step-label'; + label.textContent = step.name; + + row.appendChild(dot); + row.appendChild(label); + refs.stepsList.appendChild(row); + } + + // Pause banner + if (state.status === 'paused' && state.pause) { + refs.pauseBanner.style.display = 'block'; + refs.pauseMessage.textContent = state.pause.message; + } else { + refs.pauseBanner.style.display = 'none'; + } + + // Cancel button only while running (mask active). + refs.cancelBtn.style.display = state.status === 'running' ? 'inline-flex' : 'none'; + + // Autoclose banner on terminal, when autocloseAt exists and user didn't keep open. + if (isTerminal(state.status)) { + refs.autocloseBanner.style.display = 'block'; + const autocloseAt = state.autocloseAt; + if (autocloseAt && !hasKeptOpen) { + const remaining = Math.max(0, Math.ceil((autocloseAt - Date.now()) / 1000)); + refs.autocloseText.textContent = `窗口将在 ${remaining}s 后自动关闭`; + refs.stayOpenBtn.style.display = 'inline-flex'; + } else { + refs.autocloseText.textContent = '窗口将保持打开'; + refs.stayOpenBtn.style.display = 'none'; + } + } else { + refs.autocloseBanner.style.display = 'none'; + } + + // Capsule text + const capsuleText = refs.capsule.querySelector('.capsule-text') as HTMLSpanElement | null; + if (capsuleText) { + capsuleText.textContent = + phase === 'paused' + ? '已暂停' + : isTerminal(state.status) + ? phase === 'done' + ? '已完成' + : phase === 'failed' + ? '失败' + : '已取消' + : `爬取中 ${time}`; + } +} + +function buildCurrentDetail(state: CrawlTaskState): string { + const step = state.steps[state.currentStepIndex]; + if (!step) return ''; + if (state.status === 'paused' && state.pause) return state.pause.message; + if (state.status === 'failed') return step.message ?? '爬取失败'; + if (state.status === 'canceled') return step.message ?? '已取消'; + if (state.status === 'completed') return '爬取完成'; + return step.message ?? `正在处理:${step.name}`; +} + +function formatElapsed(totalSeconds: number): string { + const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0'); + const seconds = (totalSeconds % 60).toString().padStart(2, '0'); + return `${minutes}:${seconds}`; +} + +function mapStepStatus(status: CrawlStepStatus): 'active' | 'ok' | 'partial' | 'failed' | 'pending' { + if (status === 'running') return 'active'; + if (status === 'success') return 'ok'; + if (status === 'failed') return 'failed'; + return 'pending'; +} + +function dotFor(status: CrawlStepStatus): string { + if (status === 'success') return '✓'; + if (status === 'failed') return '×'; + if (status === 'running') return '•'; + return '·'; +} + +function buildDom(): OverlayRefs { + const host = document.createElement('div'); + host.id = OVERLAY_HOST_ID; + host.style.all = 'initial'; + host.style.position = 'fixed'; + host.style.right = '24px'; + host.style.bottom = '24px'; + host.style.zIndex = '2147483647'; + + const root = host.attachShadow({ mode: 'closed' }); + + const container = document.createElement('div'); + container.className = 'container'; + container.dataset.collapsed = '0'; + container.dataset.phase = 'running'; + + container.innerHTML = ` + ${styleTag()} +
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+ + +
+ + + + +
+ + + `; + + root.appendChild(container); + document.body.appendChild(host); + + const titleEl = container.querySelector('.title') as HTMLDivElement; + const subtitleEl = container.querySelector('.subtitle') as HTMLDivElement; + const stepsList = container.querySelector('.steps-list') as HTMLDivElement; + const pauseBanner = container.querySelector('.pause-banner') as HTMLDivElement; + const pauseMessage = container.querySelector('.pause-message') as HTMLDivElement; + const resumeBtn = container.querySelector('.resume-btn') as HTMLButtonElement; + const cancelBtn = container.querySelector('.cancel-btn') as HTMLButtonElement; + const minimiseBtn = container.querySelector('.minimise-btn') as HTMLButtonElement; + const capsule = container.querySelector('.capsule') as HTMLButtonElement; + const currentDetail = container.querySelector('.current-detail') as HTMLDivElement; + const autocloseBanner = container.querySelector('.autoclose-banner') as HTMLDivElement; + const autocloseText = container.querySelector('.autoclose-text') as HTMLDivElement; + const stayOpenBtn = container.querySelector('.stay-open-btn') as HTMLButtonElement; + + minimiseBtn.addEventListener('click', () => setCollapsed(true)); + capsule.addEventListener('click', () => setCollapsed(false)); + + // Cancel with 2-step confirm + let cancelConfirmTimer: number | null = null; + cancelBtn.addEventListener('click', () => { + if (cancelBtn.dataset.confirming === '1') { + void chrome.runtime.sendMessage({ action: 'CANCEL_CRAWL' }); + cancelBtn.dataset.confirming = '0'; + cancelBtn.textContent = '取消爬取'; + if (cancelConfirmTimer) window.clearTimeout(cancelConfirmTimer); + cancelConfirmTimer = null; + return; + } + + cancelBtn.dataset.confirming = '1'; + cancelBtn.textContent = '确认取消?'; + cancelConfirmTimer = window.setTimeout(() => { + cancelBtn.dataset.confirming = '0'; + cancelBtn.textContent = '取消爬取'; + cancelConfirmTimer = null; + }, 3000); + }); + + resumeBtn.addEventListener('click', () => { + void chrome.runtime.sendMessage({ action: 'RESUME_CRAWL' }); + }); + + stayOpenBtn.addEventListener('click', () => { + hasKeptOpen = true; + void chrome.runtime.sendMessage({ action: 'CANCEL_AUTOCLOSE' }); + if (currentState) render(currentState); + }); + + return { + host, + root, + container, + expanded: container.querySelector('.expanded') as HTMLDivElement, + capsule, + stepsList, + pauseBanner, + pauseMessage, + resumeBtn, + cancelBtn, + minimiseBtn, + titleEl, + subtitleEl, + currentDetail, + autocloseBanner, + autocloseText, + stayOpenBtn, + }; +} + +function buildMaskHost(): HTMLDivElement { + const existing = document.getElementById(MASK_HOST_ID) as HTMLDivElement | null; + if (existing) return existing; + + const m = document.createElement('div'); + m.id = MASK_HOST_ID; + m.style.all = 'initial'; + m.style.position = 'fixed'; + m.style.top = '0'; + m.style.left = '0'; + m.style.right = '0'; + m.style.bottom = '0'; + m.style.zIndex = '2147483646'; + m.style.background = 'rgba(15, 23, 42, 0.04)'; + m.style.cursor = 'progress'; + m.style.pointerEvents = 'auto'; + m.style.display = 'none'; + m.setAttribute('aria-hidden', 'true'); + + const tip = document.createElement('div'); + tip.id = 'dianshan-mask-tip'; + tip.style.all = 'initial'; + tip.style.position = 'fixed'; + tip.style.maxWidth = '280px'; + tip.style.padding = '8px 12px'; + tip.style.background = '#0f172a'; + tip.style.color = '#f8fafc'; + tip.style.fontFamily = "-apple-system,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif"; + tip.style.fontSize = '12px'; + tip.style.lineHeight = '1.45'; + tip.style.borderRadius = '6px'; + tip.style.boxShadow = '0 4px 14px rgba(0,0,0,0.18), 0 1px 3px rgba(0,0,0,0.12)'; + tip.style.pointerEvents = 'none'; + tip.style.zIndex = '1'; + tip.style.opacity = '0'; + tip.style.transition = 'opacity 120ms ease'; + tip.textContent = '正在爬取,请稍候…(暂停时可操作页面)'; + m.appendChild(tip); + + const swallow = (e: Event) => { + e.stopPropagation(); + e.preventDefault(); + }; + + for (const evt of ['click', 'dblclick', 'mousedown', 'mouseup', 'pointerdown', 'pointerup', 'wheel', 'touchstart', 'touchend', 'contextmenu']) { + m.addEventListener(evt, swallow, { capture: true, passive: false }); + } + + m.addEventListener( + 'mousemove', + (e) => { + tip.style.opacity = '1'; + const margin = 8; + const offsetX = 16; + const offsetY = 22; + const w = window.innerWidth; + const h = window.innerHeight; + const rect = tip.getBoundingClientRect(); + let left = (e as MouseEvent).clientX + offsetX; + let top = (e as MouseEvent).clientY + offsetY; + if (left + rect.width > w - margin) left = (e as MouseEvent).clientX - rect.width - offsetX; + if (top + rect.height > h - margin) top = (e as MouseEvent).clientY - rect.height - offsetY; + tip.style.left = Math.max(margin, left) + 'px'; + tip.style.top = Math.max(margin, top) + 'px'; + }, + { passive: true }, + ); + m.addEventListener('mouseleave', () => { + tip.style.opacity = '0'; + }); + + document.body.appendChild(m); + return m; +} + +function styleTag(): string { + // Copied/adapted from StoreAI overlay style for a 1:1 UI feel. + return ``; +} diff --git a/src/content/main.ts b/src/content/main.ts index c955e1f..1168044 100644 --- a/src/content/main.ts +++ b/src/content/main.ts @@ -1,24 +1,12 @@ -import { createApp } from 'vue'; -import App from './App.vue'; import { setupPageRunner } from './pageRunner'; +import { mountCrawlOverlay } from './crawlOverlay'; /** * 将内容脚本应用挂载到页面中。 */ function mountApp() { - if (document.getElementById('dianshan-crx-root')) { - return; - } - - const container = document.createElement('div'); - container.id = 'dianshan-crx-root'; - const appRoot = document.createElement('div'); - - container.appendChild(appRoot); - document.body.appendChild(container); - - createApp(App).mount(appRoot); setupPageRunner(); + mountCrawlOverlay(); } if (document.readyState === 'loading') { diff --git a/src/popup/App.vue b/src/popup/App.vue index 0e2e2ce..9ed1f87 100644 --- a/src/popup/App.vue +++ b/src/popup/App.vue @@ -1,170 +1,273 @@ diff --git a/src/popup/hook/use-scan.ts b/src/popup/hook/use-scan.ts index 76b9525..20b04e8 100644 --- a/src/popup/hook/use-scan.ts +++ b/src/popup/hook/use-scan.ts @@ -51,6 +51,30 @@ export const useScan = () => { await refreshCrawlState(); }; + const handleResumeCrawl = async () => { + const response = await sendBackgroundMessage({ action: 'RESUME_CRAWL' }); + + if (response.ok) { + syncCrawlState(response.data ?? null); + return; + } + + console.error('[crawl] resume failed', response.error); + await refreshCrawlState(); + }; + + const handleDismissCrawl = async () => { + const response = await sendBackgroundMessage({ action: 'DISMISS_CRAWL' }); + + if (response.ok) { + syncCrawlState(response.data ?? null); + return; + } + + console.error('[crawl] dismiss failed', response.error); + await refreshCrawlState(); + }; + function syncCrawlState(state: CrawlTaskState | null) { crawlState.value = state; updateSeconds(); @@ -135,6 +159,8 @@ export const useScan = () => { crawlState, handleScan, handleCancelCrawl, + handleResumeCrawl, + handleDismissCrawl, elapsedSeconds, }; }; diff --git a/src/popup/main.ts b/src/popup/main.ts index 0e698cc..ba9cdcf 100644 --- a/src/popup/main.ts +++ b/src/popup/main.ts @@ -1,5 +1,5 @@ import { createApp } from 'vue'; import App from './App.vue'; +import './popup.css'; createApp(App).mount('#app'); - diff --git a/src/popup/popup.css b/src/popup/popup.css new file mode 100644 index 0000000..dfd8e01 --- /dev/null +++ b/src/popup/popup.css @@ -0,0 +1,392 @@ +:root { + --bg: #ffffff; + --fg: #0f172a; + --muted: #64748b; + --border: #e2e8f0; + --primary: #0f172a; + --primary-fg: #ffffff; + --accent: #f1f5f9; + --success: #22c55e; + --warning: #eab308; + --danger: #ef4444; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + width: 360px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + background: var(--bg); + color: var(--fg); +} + +.container { + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.logo { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 600; + letter-spacing: -0.01em; +} +.logo-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + background: var(--primary); + color: var(--primary-fg); + border-radius: 5px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0; +} + +.badge { + font-size: 10px; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; + background: var(--accent); + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.badge-ok { + background: rgba(34, 197, 94, 0.12); + color: var(--success); +} + +.status { + font-size: 13px; + color: var(--muted); + line-height: 1.5; +} + +.account { + font-size: 12px; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.last-scan { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + padding: 8px 12px; + background: var(--accent); + border-radius: 6px; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--muted); +} +.dot-green { + background: var(--success); +} +.dot-yellow { + background: var(--warning); +} +.dot-red { + background: var(--danger); +} + +.progress { + font-size: 12px; + color: var(--muted); + padding: 6px 8px; + background: var(--accent); + border-radius: 4px; + font-style: italic; +} + +button { + width: 100%; + padding: 9px 12px; + border: none; + border-radius: 6px; + background: var(--primary); + color: var(--primary-fg); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: opacity 150ms ease; +} +button:hover:not(:disabled) { + opacity: 0.9; +} + +button.secondary { + background: transparent; + color: var(--muted); + border: 1px solid var(--border); +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +footer { + border-top: 1px solid var(--border); + padding-top: 8px; + font-size: 11px; + color: var(--muted); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +footer .version { + flex: 0 0 auto; +} + +/* =============================================================== + Scanning state - radar card + step list + pause banner + =============================================================== */ + +.badge-scanning, +.badge-starting, +.badge-drilling, +.badge-competitors, +.badge-uploading { + background: rgba(14, 165, 233, 0.12); + color: #0ea5e9; +} +.badge-paused { + background: rgba(234, 179, 8, 0.15); + color: #ca8a04; +} +.badge-done { + background: rgba(34, 197, 94, 0.15); + color: #16a34a; +} +.badge-cancelled { + background: rgba(100, 116, 139, 0.15); + color: #64748b; +} +.badge-failed { + background: rgba(239, 68, 68, 0.15); + color: #dc2626; +} + +.radar-card { + background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%); + color: #e2e8f0; + border-radius: 10px; + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; +} +.radar-card.paused { + background: linear-gradient(180deg, #2b2008 0%, #3d2b0f 100%); +} +.radar-card.done { + background: linear-gradient(180deg, #0a2e1a 0%, #134028 100%); +} +.radar-card.failed { + background: linear-gradient(180deg, #2a0f0f 0%, #3b1718 100%); +} +.radar-card.cancelled { + background: linear-gradient(180deg, #1e293b 0%, #263345 100%); +} + +.radar-row { + display: flex; + align-items: center; + gap: 12px; +} +.radar { + flex: 0 0 40px; + width: 40px; + height: 40px; + position: relative; + overflow: hidden; + border-radius: 50%; + background: radial-gradient( + circle at center, + rgba(46, 160, 67, 0.14), + rgba(46, 160, 67, 0.02) 70%, + transparent 80% + ); + border: 1px solid rgba(46, 160, 67, 0.35); +} +.radar .sweep { + position: absolute; + inset: 0; + background: conic-gradient( + from 0deg, + rgba(46, 160, 67, 0) 0deg, + rgba(46, 160, 67, 0.75) 50deg, + rgba(46, 160, 67, 0) 60deg + ); + animation: pop-sweep 2s linear infinite; +} +.radar .ping { + position: absolute; + left: 50%; + top: 50%; + width: 7px; + height: 7px; + background: #2ea043; + border-radius: 50%; + transform: translate(-50%, -50%); + box-shadow: 0 0 0 0 rgba(46, 160, 67, 0.7); + animation: pop-ping 2s ease-out infinite; +} +@keyframes pop-sweep { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} +@keyframes pop-ping { + 0% { + box-shadow: 0 0 0 0 rgba(46, 160, 67, 0.7); + } + 100% { + box-shadow: 0 0 0 14px rgba(46, 160, 67, 0); + } +} +.radar-card.paused .sweep, +.radar-card.done .sweep, +.radar-card.cancelled .sweep, +.radar-card.failed .sweep { + animation: none; + opacity: 0.3; +} +.radar-card.paused .ping { + background: #eab308; + animation: none; +} +.radar-card.done .ping { + background: #22c55e; + animation: none; +} +.radar-card.failed .ping { + background: #ef4444; + animation: none; +} +.radar-card.cancelled .ping { + background: #94a3b8; + animation: none; +} + +.radar-titles { + flex: 1 1 auto; + min-width: 0; +} +.radar-title { + font-size: 13px; + font-weight: 600; + line-height: 1.2; + color: #f8fafc; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.radar-sub { + font-size: 11.5px; + color: rgba(226, 232, 240, 0.75); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.steps { + display: flex; + flex-direction: column; + gap: 6px; +} +.step { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font-size: 12px; + color: rgba(226, 232, 240, 0.8); +} +.step-left { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1 1 auto; +} +.step-dot { + width: 16px; + height: 16px; + border-radius: 50%; + background: rgba(226, 232, 240, 0.18); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; +} +.step-label { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.step-status { + flex: 0 0 auto; + font-size: 11px; + opacity: 0.85; +} + +.pause-banner { + border-radius: 8px; + border: 1px solid rgba(234, 179, 8, 0.35); + background: rgba(234, 179, 8, 0.08); + padding: 10px 12px; + font-size: 12px; + line-height: 1.45; +} +.pause-banner p { + margin: 0; + color: #fef3c7; +} + +.actions { + display: flex; + gap: 8px; +} +.actions button { + width: auto; + flex: 1 1 0; +} +.actions button.secondary { + color: rgba(226, 232, 240, 0.85); + border-color: rgba(226, 232, 240, 0.2); +} + diff --git a/src/shared/message.ts b/src/shared/message.ts index b4cf279..f9157fe 100644 --- a/src/shared/message.ts +++ b/src/shared/message.ts @@ -2,7 +2,9 @@ export type MessageAction = | 'GET_CRAWL_STATE' | 'START_CRAWL' | 'CANCEL_CRAWL' - | 'RESUME_CRAWL'; + | 'RESUME_CRAWL' + | 'CANCEL_AUTOCLOSE' + | 'DISMISS_CRAWL'; interface BackgroundMessage { action: MessageAction; diff --git a/src/types/crawl.ts b/src/types/crawl.ts index 95ea98b..855289f 100644 --- a/src/types/crawl.ts +++ b/src/types/crawl.ts @@ -38,6 +38,10 @@ export interface CrawlTaskState { platformName: string; // 爬取窗口 ID,由 background 创建窗口后写入。 windowId?: number; + // 爬取窗口内承载任务的 tab ID(用于只在扫描 tab 显示 overlay)。 + tabId?: number; + // 终态时自动关窗的截止时间戳(ms)。null 表示保持打开;undefined 表示未启用。 + autocloseAt?: number | null; // 任务开始时间戳。 startedAt: number; // 当前任务状态。 diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index ceb2b30..23fe38d 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/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/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