ui+功能一样
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
392
src/popup/popup.css
Normal 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user