ui+功能一样

This commit is contained in:
zhu
2026-05-11 17:24:53 +08:00
parent 6677ec5eec
commit f8972f72ba
15 changed files with 1453 additions and 168 deletions

View File

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

View File

@@ -12,6 +12,8 @@ interface PageRunnerResponse {
}
const activeCrawlControllers = new Map<string, AbortController>();
const autoCloseTimers = new Map<string, number>();
const DEFAULT_AUTOCLOSE_DELAY_MS = 10_000;
/**
* 创建新的爬取任务,打开目标平台窗口,并把初始时间轴状态写入 storage。
@@ -53,7 +55,16 @@ export async function startCrawl(platformId: string): Promise<CrawlStateResponse
try {
const windowInfo = await createCrawlWindow(firstStep.url);
const stateWithWindow = { ...nextState, windowId: windowInfo.id };
let tabId: number | undefined;
try {
if (windowInfo.id) {
tabId = await getWindowActiveTabId(windowInfo.id);
}
} catch {
tabId = undefined;
}
const stateWithWindow = { ...nextState, windowId: windowInfo.id, tabId };
const controller = new AbortController();
await setCrawlTaskState(stateWithWindow);
@@ -88,14 +99,26 @@ export async function cancelCrawl(): Promise<CrawlStateResponse> {
}
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<vo
}
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,
),
@@ -156,10 +181,12 @@ export async function cancelStaleCrawlWhenWindowMissing(): Promise<void> {
}
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<CrawlStateResponse> {
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<CrawlStateResponse> {
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.Window> {
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<chrome.windows.Window> {
return;
}
void chrome.windows.update(windowInfo.id, { drawAttention: true }).catch(() => undefined);
resolve(windowInfo);
},
);

View File

@@ -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: '未知的后台指令' };
}

View File

@@ -10,6 +10,7 @@ export async function getCrawlTaskState(): Promise<CrawlTaskState | null> {
export async function setCrawlTaskState(state: CrawlTaskState): Promise<void> {
await chrome.storage.local.set({ [CRAWL_TASK_STORAGE_KEY]: state });
broadcastToCrawlTab(state);
}
export async function clearCrawlTaskState(): Promise<void> {
@@ -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;
}

View File

@@ -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<T = unknown> {

View File

@@ -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',

606
src/content/crawlOverlay.ts Normal file
View File

@@ -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<void> {
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()}
<div class="expanded">
<div class="header">
<div class="radar-wrap">
<div class="radar"><div class="sweep"></div><div class="ping"></div></div>
</div>
<div class="titles">
<div class="title"></div>
<div class="subtitle"></div>
</div>
<button class="minimise-btn" type="button" aria-label="最小化"></button>
</div>
<div class="body">
<div class="steps-list"></div>
<div class="current-detail"></div>
<div class="pause-banner" style="display:none">
<div class="pause-row">
<div class="pause-icon">!</div>
<div style="flex:1 1 auto">
<div class="pause-message"></div>
<div class="pause-auto-hint">处理完成后可点下方按钮继续</div>
<button class="resume-btn" type="button">我已完成 · 继续爬取</button>
</div>
</div>
</div>
</div>
<div class="autoclose-banner" style="display:none">
<div class="autoclose-text"></div>
<button class="stay-open-btn" type="button">保持打开</button>
</div>
<div class="footer">
<div class="coffee-hint">扫描期间你可以继续做别的事 · 完成后回到扩展查看结果</div>
<button class="cancel-btn" type="button">取消爬取</button>
</div>
</div>
<button class="capsule" type="button" aria-label="展开面板">
<div class="radar-mini"><div class="sweep-mini"></div></div>
<span class="capsule-text">爬取中</span>
</button>
`;
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 `<style>
:host, .container, .expanded, .capsule, button {
font-family: -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-size: 13px;
color: #e6edf3;
box-sizing: border-box;
}
.container {
width: 360px;
background: linear-gradient(180deg, #0d1117 0%, #161b22 100%);
border: 1px solid #30363d;
border-radius: 14px;
box-shadow: 0 10px 32px rgba(0,0,0,0.45), 0 2px 8px rgba(0,0,0,0.25);
overflow: hidden;
transition: all 220ms ease;
}
.container[data-collapsed="1"] { width: auto; background: transparent; border: none; box-shadow: none; }
.container[data-collapsed="1"] .expanded { display: none; }
.container[data-collapsed="0"] .capsule { display: none; }
.container[data-phase="paused"] { border-color: #d29922; box-shadow: 0 10px 32px rgba(210,153,34,0.3); }
.container[data-phase="done"] { border-color: #238636; }
.container[data-phase="failed"] { border-color: #da3633; }
.expanded { display: flex; flex-direction: column; }
.header { display: flex; align-items: center; gap: 12px; padding: 14px 16px 10px 16px; }
.radar-wrap { flex: 0 0 44px; height: 44px; }
.radar {
width: 44px; height: 44px; border-radius: 50%;
position: relative; overflow: hidden;
background: radial-gradient(circle at center, rgba(46,160,67,0.12), rgba(46,160,67,0.02) 70%, transparent 80%);
border: 1px solid rgba(46,160,67,0.35);
}
.sweep {
position: absolute; inset: 0;
background: conic-gradient(from 0deg, rgba(46,160,67,0) 0deg, rgba(46,160,67,0.7) 50deg, rgba(46,160,67,0) 60deg);
animation: sweep 2s linear infinite;
}
.ping {
position: absolute; left: 50%; top: 50%; width: 8px; height: 8px;
background: #2ea043; border-radius: 50%; transform: translate(-50%,-50%);
box-shadow: 0 0 0 0 rgba(46,160,67,0.7);
animation: ping 2s ease-out infinite;
}
@keyframes sweep { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes ping {
0% { box-shadow: 0 0 0 0 rgba(46,160,67,0.7); }
100% { box-shadow: 0 0 0 18px rgba(46,160,67,0); }
}
.container[data-phase="paused"] .sweep,
.container[data-phase="done"] .sweep,
.container[data-phase="failed"] .sweep,
.container[data-phase="cancelled"] .sweep { animation: none; opacity: 0.3; }
.container[data-phase="done"] .ping { background: #2ea043; animation: none; }
.container[data-phase="paused"] .ping { background: #d29922; animation: none; }
.container[data-phase="failed"] .ping { background: #da3633; animation: none; }
.titles { flex: 1 1 auto; min-width: 0; }
.title { font-size: 14px; font-weight: 600; color: #f0f6fc; line-height: 1.3; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.subtitle { font-size: 12px; color: #8b949e; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.minimise-btn {
flex: 0 0 auto;
background: transparent; color: #8b949e;
border: 1px solid #30363d; border-radius: 6px;
width: 28px; height: 28px; cursor: pointer;
font-size: 16px; line-height: 1;
transition: all 120ms ease;
}
.minimise-btn:hover { color: #e6edf3; border-color: #8b949e; }
.body { padding: 2px 16px 12px 16px; }
.steps-list { display: flex; flex-direction: column; gap: 6px; }
.step { display: flex; align-items: center; gap: 8px; font-size: 12px; line-height: 1.4; padding: 4px 0; color: #8b949e; }
.step[data-status="active"] { color: #58a6ff; }
.step[data-status="ok"] { color: #3fb950; }
.step[data-status="partial"] { color: #d29922; }
.step[data-status="failed"] { color: #f85149; }
.step .dot { flex: 0 0 auto; display: inline-flex; width: 16px; height: 16px; align-items: center; justify-content: center; font-size: 10px; font-weight: 700; }
.step .step-label { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.current-detail {
margin-top: 10px;
padding: 8px 10px;
background: rgba(110,118,129,0.08);
border: 1px dashed rgba(139,148,158,0.25);
border-radius: 6px;
color: #c9d1d9;
font-size: 11.5px; line-height: 1.4;
}
.pause-banner { margin-top: 12px; padding: 10px 12px; background: rgba(210,153,34,0.08); border: 1px solid rgba(210,153,34,0.35); border-radius: 8px; }
.pause-row { display: flex; align-items: flex-start; gap: 10px; }
.pause-icon { flex: 0 0 22px; width: 22px; height: 22px; background: #d29922; color: #0d1117; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: 700; font-size: 13px; }
.pause-message { flex: 1 1 auto; color: #e6edf3; font-size: 12px; line-height: 1.45; }
.pause-auto-hint { margin-top: 8px; font-size: 11px; color: #d29922; line-height: 1.45; opacity: 0.85; }
.resume-btn { margin-top: 10px; width: 100%; padding: 8px 10px; background: #d29922; color: #0d1117; border: none; border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer; transition: filter 120ms ease; }
.resume-btn:hover { filter: brightness(1.1); }
.autoclose-banner { margin: 0 16px 8px 16px; padding: 10px 12px; display: flex; align-items: center; justify-content: space-between; gap: 10px; background: rgba(88,166,255,0.08); border: 1px solid rgba(88,166,255,0.35); border-radius: 8px; }
.autoclose-text { flex: 1 1 auto; min-width: 0; font-size: 11.5px; color: #c9d1d9; line-height: 1.4; }
.stay-open-btn { flex: 0 0 auto; padding: 6px 12px; background: #58a6ff; color: #0d1117; border: none; border-radius: 5px; font-size: 11px; font-weight: 600; cursor: pointer; transition: filter 120ms ease; }
.stay-open-btn:hover { filter: brightness(1.1); }
.footer { padding: 10px 16px 14px 16px; border-top: 1px solid rgba(48,54,61,0.6); display: flex; flex-direction: column; gap: 10px; }
.coffee-hint { font-size: 11px; color: #8b949e; line-height: 1.5; }
.cancel-btn { align-self: flex-start; padding: 5px 10px; background: transparent; color: #8b949e; border: 1px solid #30363d; border-radius: 6px; font-size: 11px; cursor: pointer; transition: all 120ms ease; }
.cancel-btn:hover { color: #f85149; border-color: #f85149; }
.capsule { display: inline-flex; align-items: center; gap: 8px; padding: 7px 12px 7px 8px; background: #0d1117; color: #e6edf3; border: 1px solid #30363d; border-radius: 999px; box-shadow: 0 4px 14px rgba(0,0,0,0.4); cursor: pointer; font-size: 12px; transition: all 120ms ease; }
.capsule:hover { transform: translateY(-1px); }
.radar-mini { width: 18px; height: 18px; border-radius: 50%; position: relative; overflow: hidden; background: radial-gradient(circle at center, rgba(46,160,67,0.2), rgba(46,160,67,0.02) 70%, transparent 80%); border: 1px solid rgba(46,160,67,0.4); }
.sweep-mini { position: absolute; inset: 0; background: conic-gradient(from 0deg, rgba(46,160,67,0) 0deg, rgba(46,160,67,0.8) 50deg, rgba(46,160,67,0) 60deg); animation: sweep 2s linear infinite; }
.capsule-text { white-space: nowrap; }
</style>`;
}

View File

@@ -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') {

View File

@@ -1,170 +1,273 @@
<script setup lang="ts">
import {useLogin} from "./hook/use-login";
import {platformConfigs} from "@/config/platforms";
import {useScan} from "./hook/use-scan";
import {computed} from "vue";
import {formatSeconds} from "@/shared/time_format";
import { computed, onBeforeUnmount } from 'vue';
import { platformConfigs } from '@/config/platforms';
import { formatSeconds } from '@/shared/time_format';
import { useLogin } from './hook/use-login';
import { useScan } from './hook/use-scan';
/**
* 登录逻辑
*/
const {isLoggedIn, handleLogin, handleLogout} = useLogin()
const { isLoggedIn, handleLogin, handleLogout } = useLogin();
/**
* 爬取逻辑的数据
*/
const {
selectedPlatformId,
isScanning,
crawlState,
handleScan,
handleCancelCrawl,
elapsedSeconds
} = useScan()
selectedPlatformId,
isScanning,
crawlState,
elapsedSeconds,
handleScan,
handleCancelCrawl,
handleResumeCrawl,
handleDismissCrawl,
} = useScan();
const manifestVersion = (() => {
try {
return chrome.runtime.getManifest().version;
} catch {
return '0.0.0';
}
})();
/**
* 显示进度条
*/
const shouldShowCrawlProgress = computed<boolean>(() =>
crawlState.value != null
);
type PopupCard = 'not_authed' | 'idle' | 'running' | 'paused' | 'done' | 'failed' | 'cancelled';
const card = computed<PopupCard>(() => {
if (!isLoggedIn.value) return 'not_authed';
if (!crawlState.value) return 'idle';
if (crawlState.value.status === 'paused') return 'paused';
if (crawlState.value.status === 'completed') return 'done';
if (crawlState.value.status === 'failed') return 'failed';
if (crawlState.value.status === 'canceled') return 'cancelled';
return 'running';
});
/**
* 获取进度样式
*/
function getStepClass(status: string): string {
if (status === 'running') {
return 'border-emerald-500 bg-emerald-50 text-emerald-700';
}
const badgeClass = computed(() => {
const c = card.value;
if (c === 'running') return 'badge badge-scanning';
if (c === 'paused') return 'badge badge-paused';
if (c === 'done') return 'badge badge-done';
if (c === 'failed') return 'badge badge-failed';
if (c === 'cancelled') return 'badge badge-cancelled';
if (c === 'idle') return 'badge badge-ok';
return 'badge';
});
if (status === 'success') {
return 'border-green-500 bg-green-50 text-green-700';
}
const badgeText = computed(() => {
const c = card.value;
if (c === 'running') return 'SCANNING';
if (c === 'paused') return 'PAUSED';
if (c === 'done') return 'DONE';
if (c === 'failed') return 'FAILED';
if (c === 'cancelled') return 'CANCELLED';
if (c === 'idle') return 'READY';
return 'SIGN IN';
});
if (status === 'failed') {
return 'border-red-500 bg-red-50 text-red-700';
}
const radarCardClass = computed(() => {
const c = card.value;
if (c === 'paused') return 'radar-card paused';
if (c === 'done') return 'radar-card done';
if (c === 'failed') return 'radar-card failed';
if (c === 'cancelled') return 'radar-card cancelled';
return 'radar-card';
});
return 'border-slate-300 bg-white text-slate-500';
function dotFor(status: string): string {
if (status === 'success') return '✓';
if (status === 'failed') return '×';
if (status === 'running') return '•';
return '·';
}
function getStepText(status: string): string {
const textMap: Record<string, string> = {
pending: '等待中',
running: '爬取中',
success: '已完成',
failed: '爬取失败',
};
return textMap[status] ?? status;
function stepStatusText(status: string): string {
const map: Record<string, string> = {
pending: '等待中',
running: '爬取中',
success: '已完成',
failed: '爬取失败',
};
return map[status] ?? status;
}
function statusLine(): string {
const c = card.value;
if (c === 'not_authed') return '请先登录后再开始爬取';
if (c === 'idle') return '选择平台后开始爬取,会打开一个专用扫描窗口';
if (!crawlState.value) return '';
if (c === 'paused') return crawlState.value.pause?.message ?? '任务已暂停,请处理后继续';
if (c === 'done') return '爬取完成';
if (c === 'failed') return '爬取失败,可重试';
if (c === 'cancelled') return '任务已取消';
return `已运行 ${formatSeconds(elapsedSeconds.value)}`;
}
async function focusCrawlWindow(): Promise<void> {
if (!crawlState.value?.windowId) return;
try {
await chrome.windows.update(crawlState.value.windowId, { focused: true, drawAttention: true });
} catch {
// ignore
}
}
let cancelConfirmTimer: number | null = null;
function requestCancel(): void {
const btn = document.getElementById('popup-cancel-btn') as HTMLButtonElement | null;
if (!btn) {
void handleCancelCrawl();
return;
}
if (btn.dataset.confirming === '1') {
btn.dataset.confirming = '0';
btn.textContent = 'Cancel';
if (cancelConfirmTimer) window.clearTimeout(cancelConfirmTimer);
cancelConfirmTimer = null;
void handleCancelCrawl();
return;
}
btn.dataset.confirming = '1';
btn.textContent = 'Cancel?';
cancelConfirmTimer = window.setTimeout(() => {
btn.dataset.confirming = '0';
btn.textContent = 'Cancel';
cancelConfirmTimer = null;
}, 3000);
}
onBeforeUnmount(() => {
if (cancelConfirmTimer) window.clearTimeout(cancelConfirmTimer);
cancelConfirmTimer = null;
});
</script>
<template>
<main class="w-80 bg-slate-50 text-slate-900">
<section class="flex min-h-64 flex-col gap-5 p-5">
<header class="space-y-2">
<p class="text-lg font-semibold leading-6">店闪</p>
<p class="text-sm leading-5 text-slate-600">自动打开商家后台按平台配置顺序采集页面数据</p>
</header>
<div class="container">
<header>
<div class="logo">
<span class="logo-mark">SA</span>
<span>StoreAI</span>
</div>
<span :class="badgeClass">{{ badgeText }}</span>
</header>
<div class="status">{{ statusLine() }}</div>
<template v-if="!isLoggedIn">
<button type="button"
class="rounded-md bg-slate-900 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-slate-700"
@click="handleLogin">
请登录
</button>
</template>
<div v-if="isLoggedIn" class="account">
平台{{ platformConfigs.find((p) => p.id === selectedPlatformId)?.name ?? selectedPlatformId }}
</div>
<template v-else-if="shouldShowCrawlProgress && crawlState">
<section class="space-y-4">
<div class="flex items-center justify-between rounded-md bg-white px-3 py-2 shadow-sm">
<div>
<p class="text-sm font-medium text-slate-800">{{ crawlState.platformName }}</p>
<p class="text-xs text-slate-500">
{{
crawlState.status === 'paused' ? '已暂停' : '已运行 ' + formatSeconds(elapsedSeconds)
}}
</p>
</div>
<div class="flex items-center gap-2">
<!-- <button v-if="crawlState.status === 'paused'" type="button"-->
<!-- class="text-xs text-emerald-600 transition hover:text-emerald-700"-->
<!-- @click="handleResumeCrawl">-->
<!-- 继续-->
<!-- </button>-->
<button type="button" class="text-xs text-red-600 transition hover:text-red-700"
@click="handleCancelCrawl">
取消
</button>
</div>
</div>
<template v-if="card === 'not_authed'">
<button type="button" @click="handleLogin">Sign in</button>
</template>
<div v-if="crawlState.status === 'paused' && crawlState.pause"
class="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
{{ crawlState.pause.message }}
</div>
<template v-else-if="card === 'idle'">
<label style="display: flex; flex-direction: column; gap: 6px">
<span class="account">平台选择</span>
<select
v-model="selectedPlatformId"
style="
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: #fff;
font-size: 13px;
"
>
<option v-for="platform in platformConfigs" :key="platform.id" :value="platform.id">
{{ platform.name }}
</option>
</select>
</label>
<ol class="space-y-3">
<li v-for="(step, index) in crawlState.steps" :key="step.uniqueKey"
class="relative border-l-2 border-slate-200 pl-4">
<span
class="absolute -left-[7px] top-1 h-3 w-3 rounded-full border-2 border-white bg-slate-300"
:class="{ 'bg-emerald-500': step.status === 'running' || step.status === 'success', 'bg-red-500': step.status === 'failed' }"></span>
<div class="rounded-md border px-3 py-2 text-sm" :class="getStepClass(step.status)">
<div class="flex items-center justify-between gap-3">
<span class="font-medium">{{ index + 1 }}. {{ step.name }}</span>
<span class="text-xs">{{ getStepText(step.status) }}</span>
</div>
<p v-if="step.message" class="mt-1 text-xs">{{ step.message }}</p>
<pre v-if="step.result !== undefined"
class="mt-2 max-h-32 overflow-auto rounded bg-slate-950 p-2 text-[11px] leading-4 text-slate-100">{{
JSON.stringify(step.result, null, 2)
}}</pre>
</div>
</li>
</ol>
</section>
</template>
<button type="button" :disabled="isScanning" @click="handleScan">
{{ isScanning ? 'Opening' : 'Scan now' }}
</button>
</template>
<template v-else>
<label class="space-y-2">
<span class="text-sm font-medium text-slate-700">平台选择</span>
<select v-model="selectedPlatformId"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm outline-none transition focus:border-slate-800 focus:ring-2 focus:ring-slate-200">
<option v-for="platform in platformConfigs"
:key="platform.id"
:value="platform.id">
{{ platform.name }}
</option>
</select>
</label>
<template v-else-if="crawlState">
<div :class="radarCardClass">
<div class="radar-row">
<div class="radar">
<div class="sweep"></div>
<div class="ping"></div>
</div>
<div class="radar-titles">
<div class="radar-title">{{ crawlState.platformName }}</div>
<div class="radar-sub">
{{
card === 'paused'
? 'Paused'
: card === 'done'
? 'Done'
: card === 'failed'
? 'Failed'
: card === 'cancelled'
? 'Cancelled'
: 'Scanning'
}}
· {{ formatSeconds(elapsedSeconds) }}
</div>
</div>
</div>
<button type="button"
class="rounded-md bg-emerald-600 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:bg-slate-300"
:disabled="isScanning" @click="handleScan">
{{ isScanning ? '正在打开...' : '立即爬取' }}
</button>
</template>
<div class="steps">
<div v-for="(step, index) in crawlState.steps" :key="step.uniqueKey" class="step">
<div class="step-left">
<div class="step-dot">{{ dotFor(step.status) }}</div>
<div class="step-label">{{ index + 1 }}. {{ step.name }}</div>
</div>
<div class="step-status">{{ stepStatusText(step.status) }}</div>
</div>
</div>
<div v-if="card === 'paused' && crawlState.pause" class="pause-banner">
<p>{{ crawlState.pause.message }}</p>
</div>
<footer
class="mt-auto flex items-center justify-between border-t border-slate-200 pt-4 text-xs text-slate-500">
<button v-if="isLoggedIn" type="button" class="text-slate-600 transition hover:text-slate-900"
@click="handleLogout">
退出
</button>
<span v-else></span>
<span>v1.0.0</span>
</footer>
</section>
</main>
<div class="actions">
<button v-if="card === 'running'" type="button" class="secondary" @click="focusCrawlWindow">
Show tab
</button>
<button v-if="card === 'paused'" type="button" @click="handleResumeCrawl">Continue now</button>
<button
v-if="card === 'done' || card === 'failed' || card === 'cancelled'"
type="button"
@click="handleScan"
>
Scan again
</button>
<button
v-if="card === 'done' || card === 'failed' || card === 'cancelled'"
type="button"
class="secondary"
@click="handleDismissCrawl"
>
Close
</button>
<button v-if="card === 'running'" id="popup-cancel-btn" type="button" class="secondary" @click="requestCancel">
Cancel
</button>
</div>
</div>
</template>
<footer>
<button
v-if="isLoggedIn"
type="button"
class="secondary"
style="width: auto; padding: 4px 10px"
@click="handleLogout"
>
Sign out
</button>
<span v-else></span>
<span class="version">v{{ manifestVersion }}</span>
</footer>
</div>
</template>
<style>
@import "tailwindcss";
select:focus {
outline: none;
}
</style>

View File

@@ -51,6 +51,30 @@ export const useScan = () => {
await refreshCrawlState();
};
const handleResumeCrawl = async () => {
const response = await sendBackgroundMessage<CrawlTaskState | null>({ 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<CrawlTaskState | null>({ 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,
};
};

View File

@@ -1,5 +1,5 @@
import { createApp } from 'vue';
import App from './App.vue';
import './popup.css';
createApp(App).mount('#app');

392
src/popup/popup.css Normal file
View File

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

View File

@@ -2,7 +2,9 @@ export type MessageAction =
| 'GET_CRAWL_STATE'
| 'START_CRAWL'
| 'CANCEL_CRAWL'
| 'RESUME_CRAWL';
| 'RESUME_CRAWL'
| 'CANCEL_AUTOCLOSE'
| 'DISMISS_CRAWL';
interface BackgroundMessage<T = unknown> {
action: MessageAction;

View File

@@ -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;
// 当前任务状态。

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/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/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"}