diff --git a/src/popup/App.vue b/src/popup/App.vue index 9ed1f87..4f9b28f 100644 --- a/src/popup/App.vue +++ b/src/popup/App.vue @@ -1,273 +1,197 @@ +@import "tailwindcss"; + \ No newline at end of file diff --git a/src/popup/hook/use-login.ts b/src/popup/hook/use-login.ts index 72a1c9e..02cdb3d 100644 --- a/src/popup/hook/use-login.ts +++ b/src/popup/hook/use-login.ts @@ -1,35 +1,42 @@ -import {computed, onMounted, ref} from "vue"; -import {getToken, logout, setToken} from "@/shared/auth"; +import { computed, onMounted, ref } from 'vue'; +import { getToken, logout, setToken } from '@/shared/auth'; +/** + * Popup 的登录状态与操作。 + */ export const useLogin = () => { const token = ref(null); + /** 当前是否已登录。 */ const isLoggedIn = computed(() => token.value !== null); /** - * 登录 + * 登录并保存 token。 */ const handleLogin = async () => { - let value = "xxx" - await setToken(value) - token.value = value - } + const value = 'xxx'; + await setToken(value); + token.value = value; + }; /** - * 退出登录 + * 退出登录并清理本地状态。 */ const handleLogout = async () => { - await logout() - token.value = null - } + await logout(); + token.value = null; + }; + /** + * 组件挂载时,从存储恢复 token。 + */ onMounted(async () => { - token.value = await getToken() - }) + token.value = await getToken(); + }); return { isLoggedIn, handleLogin, handleLogout, - } -} \ No newline at end of file + }; +}; diff --git a/src/popup/hook/use-scan.ts b/src/popup/hook/use-scan.ts index 20b04e8..3f7a266 100644 --- a/src/popup/hook/use-scan.ts +++ b/src/popup/hook/use-scan.ts @@ -1,19 +1,35 @@ -import { onMounted, onUnmounted, ref } from 'vue'; -import { platformConfigs } from '@/config/platforms'; -import type { CrawlTaskState } from '@/types'; -import { sendBackgroundMessage } from '@/shared/message'; +import {computed, onMounted, onUnmounted, ref} from 'vue'; +import {platformConfigs} from '@/config/platforms'; +import type {CrawlTaskState} from '@/types'; +import {sendBackgroundMessage} from '@/shared/message'; +/** 用于同步爬取任务状态的 `chrome.storage.local` key。 */ const CRAWL_TASK_STORAGE_KEY = 'crawlTaskState'; +/** 会持续刷新计时器的任务状态集合。 */ const ACTIVE_STATUSES = new Set(['running', 'paused']); +/** + * Popup 内的爬取状态与操作集合。 + */ export const useScan = () => { + /** 当前选中的平台 id。 */ const selectedPlatformId = ref(platformConfigs[0]?.id ?? ''); + /** 防止重复点击“Scan now”(打开扫描窗口期间置为 true)。 */ const isScanning = ref(false); + /** + * 当前爬取任务状态(从 background 同步)。 + */ const crawlState = ref(null); + + const taskStatus = computed(() => crawlState.value?.status); + /** + * 从任务开始到现在的秒数。 + */ const elapsedSeconds = ref(0); let timer: number | undefined; + /** 启动新的爬取任务(使用当前选择的平台)。 */ const handleScan = async () => { if (isScanning.value) { return; @@ -26,7 +42,7 @@ export const useScan = () => { const response = await sendBackgroundMessage({ action: 'START_CRAWL', - payload: { platformId: selectedPlatformId.value }, + payload: {platformId: selectedPlatformId.value}, }); if (response.ok) { @@ -39,8 +55,9 @@ export const useScan = () => { } }; + /** 通知 background 取消当前任务。 */ const handleCancelCrawl = async () => { - const response = await sendBackgroundMessage({ action: 'CANCEL_CRAWL' }); + const response = await sendBackgroundMessage({action: 'CANCEL_CRAWL'}); if (response.ok) { syncCrawlState(response.data ?? null); @@ -51,8 +68,9 @@ export const useScan = () => { await refreshCrawlState(); }; + /** 通知 background 恢复被暂停的任务。 */ const handleResumeCrawl = async () => { - const response = await sendBackgroundMessage({ action: 'RESUME_CRAWL' }); + const response = await sendBackgroundMessage({action: 'RESUME_CRAWL'}); if (response.ok) { syncCrawlState(response.data ?? null); @@ -63,8 +81,9 @@ export const useScan = () => { await refreshCrawlState(); }; + /** 关闭任务卡片并通知 background 清理任务状态。 */ const handleDismissCrawl = async () => { - const response = await sendBackgroundMessage({ action: 'DISMISS_CRAWL' }); + const response = await sendBackgroundMessage({action: 'DISMISS_CRAWL'}); if (response.ok) { syncCrawlState(response.data ?? null); @@ -75,6 +94,7 @@ export const useScan = () => { await refreshCrawlState(); }; + /** 应用任务状态:刷新 elapsed,并根据状态管理计时器的开启/关闭。 */ function syncCrawlState(state: CrawlTaskState | null) { crawlState.value = state; updateSeconds(); @@ -87,6 +107,7 @@ export const useScan = () => { clearElapsedTimer(); } + /** 确保 1 秒一次的计时器正在运行。 */ function ensureElapsedTimer() { if (timer !== undefined) { return; @@ -97,6 +118,7 @@ export const useScan = () => { }, 1000); } + /** 停止计时器(如果存在)。 */ function clearElapsedTimer() { if (timer === undefined) { return; @@ -106,6 +128,7 @@ export const useScan = () => { timer = undefined; } + /** 根据任务 `startedAt` 更新时间(秒)。 */ function updateSeconds() { if (!crawlState.value) { elapsedSeconds.value = 0; @@ -115,14 +138,16 @@ export const useScan = () => { elapsedSeconds.value = Math.max(0, Math.floor((Date.now() - crawlState.value.startedAt) / 1000)); } + /** 从 background 拉取最新任务状态。 */ async function refreshCrawlState() { - const response = await sendBackgroundMessage({ action: 'GET_CRAWL_STATE' }); + const response = await sendBackgroundMessage({action: 'GET_CRAWL_STATE'}); if (response.ok) { syncCrawlState(response.data ?? null); } } + /** 监听 `chrome.storage` 的变化,用于跨上下文同步任务状态。 */ function handleStorageChanged(changes: Record, areaName: string) { if (areaName !== 'local') { return; @@ -138,6 +163,7 @@ export const useScan = () => { } onMounted(async () => { + /** 首次加载 + 订阅 storage 事件。 */ await refreshCrawlState(); if (typeof chrome !== 'undefined' && chrome.storage?.onChanged) { @@ -146,6 +172,7 @@ export const useScan = () => { }); onUnmounted(() => { + /** 清理计时器 + 取消订阅 storage 事件。 */ clearElapsedTimer(); if (typeof chrome !== 'undefined' && chrome.storage?.onChanged) { @@ -157,6 +184,7 @@ export const useScan = () => { selectedPlatformId, isScanning, crawlState, + taskStatus, handleScan, handleCancelCrawl, handleResumeCrawl, @@ -165,6 +193,7 @@ export const useScan = () => { }; }; +/** storage 数据的运行时类型保护(防御不可信数据)。 */ function isCrawlTaskState(value: unknown): value is CrawlTaskState { return typeof value === 'object' && value !== null && 'id' in value && 'steps' in value; } diff --git a/src/popup/main.ts b/src/popup/main.ts index ba9cdcf..7b2237e 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'; +import './popup.scss'; createApp(App).mount('#app'); diff --git a/src/popup/popup.css b/src/popup/popup.css deleted file mode 100644 index dfd8e01..0000000 --- a/src/popup/popup.css +++ /dev/null @@ -1,392 +0,0 @@ -: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/popup/popup.scss b/src/popup/popup.scss new file mode 100644 index 0000000..b614553 --- /dev/null +++ b/src/popup/popup.scss @@ -0,0 +1,410 @@ +: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; + + &-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; + } +} + + +.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; +} + +.platform-select { + display: flex; + flex-direction: column; + gap: 6px; + + &__control { + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: #fff; + font-size: 13px; + + &:focus { + outline: none; + } + } +} + +.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); + + &-green { + background: var(--success); + } + + &-yellow { + background: var(--warning); + } + + &-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; + + &:hover:not(:disabled) { + opacity: 0.9; + } + + &.secondary { + background: transparent; + color: var(--muted); + border: 1px solid var(--border); + } + + &: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-btn { + width: auto; + padding: 4px 10px; + } + + .version { + flex: 0 0 auto; + } +} + +.radar-card { + background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%); + color: #e2e8f0; + border-radius: 10px; + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; + + &.paused { + background: linear-gradient(180deg, #2b2008 0%, #3d2b0f 100%); + } + + &.done { + background: linear-gradient(180deg, #0a2e1a 0%, #134028 100%); + } + + &.failed { + background: linear-gradient(180deg, #2a0f0f 0%, #3b1718 100%); + } + + &.canceled { + background: linear-gradient(180deg, #1e293b 0%, #263345 100%); + } + + &.paused, + &.done, + &.canceled, + &.failed { + .sweep { + animation: none; + opacity: 0.3; + } + } + + &.paused { + .ping { + background: #eab308; + animation: none; + } + } + + &.done { + .ping { + background: #22c55e; + animation: none; + } + } + + &.failed { + .ping { + background: #ef4444; + animation: none; + } + } + + &.canceled { + .ping { + background: #94a3b8; + animation: none; + } + } +} + +.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); + + .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; + } + + .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-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); + + &-left { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1 1 auto; + } + + &-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; + } + + &-label { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &-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; + + p { + margin: 0; + color: #fef3c7; + } +} + +.actions { + display: flex; + gap: 8px; + + button { + width: auto; + flex: 1 1 0; + + &.secondary { + color: rgba(226, 232, 240, 0.85); + border-color: rgba(226, 232, 240, 0.2); + } + } +}