优化了popup
This commit is contained in:
@@ -1,273 +1,197 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount } from 'vue';
|
import { onBeforeUnmount} from 'vue';
|
||||||
import { platformConfigs } from '@/config/platforms';
|
import {platformConfigs} from '@/config/platforms';
|
||||||
import { formatSeconds } from '@/shared/time_format';
|
import {formatSeconds} from '@/shared/time_format';
|
||||||
import { useLogin } from './hook/use-login';
|
import {useLogin} from './hook/use-login';
|
||||||
import { useScan } from './hook/use-scan';
|
import {useScan} from './hook/use-scan';
|
||||||
|
|
||||||
const { isLoggedIn, handleLogin, handleLogout } = useLogin();
|
const {isLoggedIn, handleLogin, handleLogout} = useLogin();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
selectedPlatformId,
|
selectedPlatformId,
|
||||||
isScanning,
|
isScanning,
|
||||||
crawlState,
|
crawlState,
|
||||||
elapsedSeconds,
|
elapsedSeconds,
|
||||||
handleScan,
|
taskStatus,
|
||||||
handleCancelCrawl,
|
handleScan,
|
||||||
handleResumeCrawl,
|
handleCancelCrawl,
|
||||||
handleDismissCrawl,
|
handleResumeCrawl,
|
||||||
} = useScan();
|
} = useScan();
|
||||||
|
|
||||||
|
/** 从扩展 manifest 读取版本号(兜底 `0.0.0`)。 */
|
||||||
const manifestVersion = (() => {
|
const manifestVersion = (() => {
|
||||||
try {
|
try {
|
||||||
return chrome.runtime.getManifest().version;
|
return chrome.runtime.getManifest().version;
|
||||||
} catch {
|
} catch {
|
||||||
return '0.0.0';
|
return '0.0.0';
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
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';
|
|
||||||
});
|
|
||||||
|
|
||||||
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';
|
|
||||||
});
|
|
||||||
|
|
||||||
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';
|
|
||||||
});
|
|
||||||
|
|
||||||
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';
|
|
||||||
});
|
|
||||||
|
|
||||||
function dotFor(status: string): string {
|
|
||||||
if (status === 'success') return '✓';
|
|
||||||
if (status === 'failed') return '×';
|
|
||||||
if (status === 'running') return '•';
|
|
||||||
return '·';
|
|
||||||
}
|
|
||||||
|
|
||||||
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> {
|
async function focusCrawlWindow(): Promise<void> {
|
||||||
if (!crawlState.value?.windowId) return;
|
if (!crawlState.value?.windowId) return;
|
||||||
try {
|
try {
|
||||||
await chrome.windows.update(crawlState.value.windowId, { focused: true, drawAttention: true });
|
await chrome.windows.update(crawlState.value.windowId, {focused: true, drawAttention: true});
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelConfirmTimer: number | null = null;
|
let cancelConfirmTimer: number | null = null;
|
||||||
|
|
||||||
|
/** “Cancel” 二次确认:第一次点击变成 `Cancel?`,再次点击才真正取消。 */
|
||||||
function requestCancel(): void {
|
function requestCancel(): void {
|
||||||
const btn = document.getElementById('popup-cancel-btn') as HTMLButtonElement | null;
|
const btn = document.getElementById('popup-cancel-btn') as HTMLButtonElement | null;
|
||||||
if (!btn) {
|
if (!btn) {
|
||||||
void handleCancelCrawl();
|
void handleCancelCrawl();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (btn.dataset.confirming === '1') {
|
if (btn.dataset.confirming === '1') {
|
||||||
btn.dataset.confirming = '0';
|
btn.dataset.confirming = '0';
|
||||||
btn.textContent = 'Cancel';
|
btn.textContent = 'Cancel';
|
||||||
if (cancelConfirmTimer) window.clearTimeout(cancelConfirmTimer);
|
if (cancelConfirmTimer) window.clearTimeout(cancelConfirmTimer);
|
||||||
cancelConfirmTimer = null;
|
cancelConfirmTimer = null;
|
||||||
void handleCancelCrawl();
|
void handleCancelCrawl();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
btn.dataset.confirming = '1';
|
btn.dataset.confirming = '1';
|
||||||
btn.textContent = 'Cancel?';
|
btn.textContent = 'Cancel?';
|
||||||
cancelConfirmTimer = window.setTimeout(() => {
|
cancelConfirmTimer = window.setTimeout(() => {
|
||||||
btn.dataset.confirming = '0';
|
btn.dataset.confirming = '0';
|
||||||
btn.textContent = 'Cancel';
|
btn.textContent = 'Cancel';
|
||||||
cancelConfirmTimer = null;
|
cancelConfirmTimer = null;
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (cancelConfirmTimer) window.clearTimeout(cancelConfirmTimer);
|
/** 组件销毁前清理定时器,避免异步回调触发在已卸载的视图上。 */
|
||||||
cancelConfirmTimer = null;
|
if (cancelConfirmTimer) window.clearTimeout(cancelConfirmTimer);
|
||||||
|
cancelConfirmTimer = null;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<span class="logo-mark">SA</span>
|
<span class="logo-mark">SA</span>
|
||||||
<span>StoreAI</span>
|
<span>StoreAI</span>
|
||||||
</div>
|
</div>
|
||||||
<span :class="badgeClass">{{ badgeText }}</span>
|
</header>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="status">{{ statusLine() }}</div>
|
<!-- 未登录-->
|
||||||
|
<template v-if="!isLoggedIn">
|
||||||
|
<div class="status">请先登录后再开始爬取</div>
|
||||||
|
<button style="margin-top: 20px" type="button" @click="handleLogin">
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div v-if="isLoggedIn" class="account">
|
<template v-else>
|
||||||
平台:{{ platformConfigs.find((p) => p.id === selectedPlatformId)?.name ?? selectedPlatformId }}
|
<!-- 未开始-->
|
||||||
|
<template v-if="crawlState == null">
|
||||||
|
<label class="platform-select">
|
||||||
|
<span class="account">平台选择</span>
|
||||||
|
<select v-model="selectedPlatformId"
|
||||||
|
class="platform-select__control">
|
||||||
|
<option v-for="platform in platformConfigs"
|
||||||
|
:key="platform.id"
|
||||||
|
:value="platform.id">
|
||||||
|
{{ platform.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="button" :disabled="isScanning" @click="handleScan">
|
||||||
|
{{ isScanning ? 'Opening…' : 'Scan now' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 进行中-->
|
||||||
|
<template v-else>
|
||||||
|
<div :class="['radar-card', taskStatus]">
|
||||||
|
<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">
|
||||||
|
<template v-if="taskStatus == 'paused'">Paused</template>
|
||||||
|
<template v-else-if="taskStatus == 'completed'">Done</template>
|
||||||
|
<template v-else-if="taskStatus == 'failed'">Failed</template>
|
||||||
|
<template v-else>Scanning</template>
|
||||||
|
· {{ formatSeconds(elapsedSeconds) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="steps">
|
||||||
|
<div v-for="(step, index) in crawlState.steps" :key="step.uniqueKey" class="step">
|
||||||
|
<div class="step-left">
|
||||||
|
<div class="step-dot">
|
||||||
|
<span v-if="step.status =='success'">✓</span>
|
||||||
|
<span v-else-if="step.status =='failed'">×</span>
|
||||||
|
<span v-else-if="step.status =='running'">•</span>
|
||||||
|
</div>
|
||||||
|
<div class="step-label">{{ index + 1 }}. {{ step.name }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="step-status">
|
||||||
|
<span v-if="step.status == 'success'">已完成</span>
|
||||||
|
<span v-else-if="step.status == 'failed'">失败</span>
|
||||||
|
<span v-else-if="step.status == 'running'">进行中</span>
|
||||||
|
<span v-else-if="step.status == 'pending'">等待中</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<!-- 运行中-->
|
||||||
|
<template v-if="taskStatus == 'running'">
|
||||||
|
<button v-if="taskStatus == 'running'"
|
||||||
|
type="button"
|
||||||
|
class="secondary"
|
||||||
|
@click="focusCrawlWindow">
|
||||||
|
Show tab
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 暂停中-->
|
||||||
|
<template v-else-if="taskStatus == 'paused'">
|
||||||
|
<button type="button" @click="handleResumeCrawl">
|
||||||
|
Continue now
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button id="popup-cancel-btn"
|
||||||
|
type="button"
|
||||||
|
class="secondary"
|
||||||
|
@click="requestCancel">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<button
|
||||||
|
v-if="isLoggedIn"
|
||||||
|
type="button"
|
||||||
|
class="secondary footer-btn"
|
||||||
|
@click="handleLogout">
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
<span class="version">v{{ manifestVersion }}</span>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="card === 'not_authed'">
|
|
||||||
<button type="button" @click="handleLogin">Sign in</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<button type="button" :disabled="isScanning" @click="handleScan">
|
|
||||||
{{ isScanning ? 'Opening…' : 'Scan now' }}
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
select:focus {
|
@import "tailwindcss";
|
||||||
outline: none;
|
</style>
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,35 +1,42 @@
|
|||||||
import {computed, onMounted, ref} from "vue";
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import {getToken, logout, setToken} from "@/shared/auth";
|
import { getToken, logout, setToken } from '@/shared/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Popup 的登录状态与操作。
|
||||||
|
*/
|
||||||
export const useLogin = () => {
|
export const useLogin = () => {
|
||||||
const token = ref<string | null>(null);
|
const token = ref<string | null>(null);
|
||||||
|
|
||||||
|
/** 当前是否已登录。 */
|
||||||
const isLoggedIn = computed(() => token.value !== null);
|
const isLoggedIn = computed(() => token.value !== null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录
|
* 登录并保存 token。
|
||||||
*/
|
*/
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
let value = "xxx"
|
const value = 'xxx';
|
||||||
await setToken(value)
|
await setToken(value);
|
||||||
token.value = value
|
token.value = value;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 退出登录
|
* 退出登录并清理本地状态。
|
||||||
*/
|
*/
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout()
|
await logout();
|
||||||
token.value = null
|
token.value = null;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组件挂载时,从存储恢复 token。
|
||||||
|
*/
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
token.value = await getToken()
|
token.value = await getToken();
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
handleLogin,
|
handleLogin,
|
||||||
handleLogout,
|
handleLogout,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,19 +1,35 @@
|
|||||||
import { onMounted, onUnmounted, ref } from 'vue';
|
import {computed, onMounted, onUnmounted, ref} from 'vue';
|
||||||
import { platformConfigs } from '@/config/platforms';
|
import {platformConfigs} from '@/config/platforms';
|
||||||
import type { CrawlTaskState } from '@/types';
|
import type {CrawlTaskState} from '@/types';
|
||||||
import { sendBackgroundMessage } from '@/shared/message';
|
import {sendBackgroundMessage} from '@/shared/message';
|
||||||
|
|
||||||
|
/** 用于同步爬取任务状态的 `chrome.storage.local` key。 */
|
||||||
const CRAWL_TASK_STORAGE_KEY = 'crawlTaskState';
|
const CRAWL_TASK_STORAGE_KEY = 'crawlTaskState';
|
||||||
|
/** 会持续刷新计时器的任务状态集合。 */
|
||||||
const ACTIVE_STATUSES = new Set(['running', 'paused']);
|
const ACTIVE_STATUSES = new Set(['running', 'paused']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Popup 内的爬取状态与操作集合。
|
||||||
|
*/
|
||||||
export const useScan = () => {
|
export const useScan = () => {
|
||||||
|
/** 当前选中的平台 id。 */
|
||||||
const selectedPlatformId = ref(platformConfigs[0]?.id ?? '');
|
const selectedPlatformId = ref(platformConfigs[0]?.id ?? '');
|
||||||
|
/** 防止重复点击“Scan now”(打开扫描窗口期间置为 true)。 */
|
||||||
const isScanning = ref<boolean>(false);
|
const isScanning = ref<boolean>(false);
|
||||||
|
/**
|
||||||
|
* 当前爬取任务状态(从 background 同步)。
|
||||||
|
*/
|
||||||
const crawlState = ref<CrawlTaskState | null>(null);
|
const crawlState = ref<CrawlTaskState | null>(null);
|
||||||
|
|
||||||
|
const taskStatus = computed(() => crawlState.value?.status);
|
||||||
|
/**
|
||||||
|
* 从任务开始到现在的秒数。
|
||||||
|
*/
|
||||||
const elapsedSeconds = ref<number>(0);
|
const elapsedSeconds = ref<number>(0);
|
||||||
|
|
||||||
let timer: number | undefined;
|
let timer: number | undefined;
|
||||||
|
|
||||||
|
/** 启动新的爬取任务(使用当前选择的平台)。 */
|
||||||
const handleScan = async () => {
|
const handleScan = async () => {
|
||||||
if (isScanning.value) {
|
if (isScanning.value) {
|
||||||
return;
|
return;
|
||||||
@@ -26,7 +42,7 @@ export const useScan = () => {
|
|||||||
|
|
||||||
const response = await sendBackgroundMessage<CrawlTaskState>({
|
const response = await sendBackgroundMessage<CrawlTaskState>({
|
||||||
action: 'START_CRAWL',
|
action: 'START_CRAWL',
|
||||||
payload: { platformId: selectedPlatformId.value },
|
payload: {platformId: selectedPlatformId.value},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -39,8 +55,9 @@ export const useScan = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 通知 background 取消当前任务。 */
|
||||||
const handleCancelCrawl = async () => {
|
const handleCancelCrawl = async () => {
|
||||||
const response = await sendBackgroundMessage<CrawlTaskState | null>({ action: 'CANCEL_CRAWL' });
|
const response = await sendBackgroundMessage<CrawlTaskState | null>({action: 'CANCEL_CRAWL'});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
syncCrawlState(response.data ?? null);
|
syncCrawlState(response.data ?? null);
|
||||||
@@ -51,8 +68,9 @@ export const useScan = () => {
|
|||||||
await refreshCrawlState();
|
await refreshCrawlState();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 通知 background 恢复被暂停的任务。 */
|
||||||
const handleResumeCrawl = async () => {
|
const handleResumeCrawl = async () => {
|
||||||
const response = await sendBackgroundMessage<CrawlTaskState | null>({ action: 'RESUME_CRAWL' });
|
const response = await sendBackgroundMessage<CrawlTaskState | null>({action: 'RESUME_CRAWL'});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
syncCrawlState(response.data ?? null);
|
syncCrawlState(response.data ?? null);
|
||||||
@@ -63,8 +81,9 @@ export const useScan = () => {
|
|||||||
await refreshCrawlState();
|
await refreshCrawlState();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 关闭任务卡片并通知 background 清理任务状态。 */
|
||||||
const handleDismissCrawl = async () => {
|
const handleDismissCrawl = async () => {
|
||||||
const response = await sendBackgroundMessage<CrawlTaskState | null>({ action: 'DISMISS_CRAWL' });
|
const response = await sendBackgroundMessage<CrawlTaskState | null>({action: 'DISMISS_CRAWL'});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
syncCrawlState(response.data ?? null);
|
syncCrawlState(response.data ?? null);
|
||||||
@@ -75,6 +94,7 @@ export const useScan = () => {
|
|||||||
await refreshCrawlState();
|
await refreshCrawlState();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 应用任务状态:刷新 elapsed,并根据状态管理计时器的开启/关闭。 */
|
||||||
function syncCrawlState(state: CrawlTaskState | null) {
|
function syncCrawlState(state: CrawlTaskState | null) {
|
||||||
crawlState.value = state;
|
crawlState.value = state;
|
||||||
updateSeconds();
|
updateSeconds();
|
||||||
@@ -87,6 +107,7 @@ export const useScan = () => {
|
|||||||
clearElapsedTimer();
|
clearElapsedTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 确保 1 秒一次的计时器正在运行。 */
|
||||||
function ensureElapsedTimer() {
|
function ensureElapsedTimer() {
|
||||||
if (timer !== undefined) {
|
if (timer !== undefined) {
|
||||||
return;
|
return;
|
||||||
@@ -97,6 +118,7 @@ export const useScan = () => {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 停止计时器(如果存在)。 */
|
||||||
function clearElapsedTimer() {
|
function clearElapsedTimer() {
|
||||||
if (timer === undefined) {
|
if (timer === undefined) {
|
||||||
return;
|
return;
|
||||||
@@ -106,6 +128,7 @@ export const useScan = () => {
|
|||||||
timer = undefined;
|
timer = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 根据任务 `startedAt` 更新时间(秒)。 */
|
||||||
function updateSeconds() {
|
function updateSeconds() {
|
||||||
if (!crawlState.value) {
|
if (!crawlState.value) {
|
||||||
elapsedSeconds.value = 0;
|
elapsedSeconds.value = 0;
|
||||||
@@ -115,14 +138,16 @@ export const useScan = () => {
|
|||||||
elapsedSeconds.value = Math.max(0, Math.floor((Date.now() - crawlState.value.startedAt) / 1000));
|
elapsedSeconds.value = Math.max(0, Math.floor((Date.now() - crawlState.value.startedAt) / 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 从 background 拉取最新任务状态。 */
|
||||||
async function refreshCrawlState() {
|
async function refreshCrawlState() {
|
||||||
const response = await sendBackgroundMessage<CrawlTaskState | null>({ action: 'GET_CRAWL_STATE' });
|
const response = await sendBackgroundMessage<CrawlTaskState | null>({action: 'GET_CRAWL_STATE'});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
syncCrawlState(response.data ?? null);
|
syncCrawlState(response.data ?? null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 监听 `chrome.storage` 的变化,用于跨上下文同步任务状态。 */
|
||||||
function handleStorageChanged(changes: Record<string, chrome.storage.StorageChange>, areaName: string) {
|
function handleStorageChanged(changes: Record<string, chrome.storage.StorageChange>, areaName: string) {
|
||||||
if (areaName !== 'local') {
|
if (areaName !== 'local') {
|
||||||
return;
|
return;
|
||||||
@@ -138,6 +163,7 @@ export const useScan = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
/** 首次加载 + 订阅 storage 事件。 */
|
||||||
await refreshCrawlState();
|
await refreshCrawlState();
|
||||||
|
|
||||||
if (typeof chrome !== 'undefined' && chrome.storage?.onChanged) {
|
if (typeof chrome !== 'undefined' && chrome.storage?.onChanged) {
|
||||||
@@ -146,6 +172,7 @@ export const useScan = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
/** 清理计时器 + 取消订阅 storage 事件。 */
|
||||||
clearElapsedTimer();
|
clearElapsedTimer();
|
||||||
|
|
||||||
if (typeof chrome !== 'undefined' && chrome.storage?.onChanged) {
|
if (typeof chrome !== 'undefined' && chrome.storage?.onChanged) {
|
||||||
@@ -157,6 +184,7 @@ export const useScan = () => {
|
|||||||
selectedPlatformId,
|
selectedPlatformId,
|
||||||
isScanning,
|
isScanning,
|
||||||
crawlState,
|
crawlState,
|
||||||
|
taskStatus,
|
||||||
handleScan,
|
handleScan,
|
||||||
handleCancelCrawl,
|
handleCancelCrawl,
|
||||||
handleResumeCrawl,
|
handleResumeCrawl,
|
||||||
@@ -165,6 +193,7 @@ export const useScan = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** storage 数据的运行时类型保护(防御不可信数据)。 */
|
||||||
function isCrawlTaskState(value: unknown): value is CrawlTaskState {
|
function isCrawlTaskState(value: unknown): value is CrawlTaskState {
|
||||||
return typeof value === 'object' && value !== null && 'id' in value && 'steps' in value;
|
return typeof value === 'object' && value !== null && 'id' in value && 'steps' in value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createApp } from 'vue';
|
import { createApp } from 'vue';
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
import './popup.css';
|
import './popup.scss';
|
||||||
|
|
||||||
createApp(App).mount('#app');
|
createApp(App).mount('#app');
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|
||||||
410
src/popup/popup.scss
Normal file
410
src/popup/popup.scss
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user