CrawlerStatusDeck.vue 7.26 KB
<script setup lang="ts">
import { computed } from 'vue'

import type {
  CrawlerFlowStep,
  CrawlerLogEntry,
  CrawlerPlatformState,
  CrawlerPrimaryAction,
  CrawlerStateResponse,
  CrawlerSummaryItem,
} from '@/types'
import { formatTimeLabel, statusTone } from '@/utils/format'

const props = defineProps<{
  selectedPlatformState: CrawlerPlatformState | null
  capabilityHint: string
  snapshot: CrawlerStateResponse
  validationIssues: string[]
  workflowSteps: CrawlerFlowStep[]
  primaryAction: CrawlerPrimaryAction
  missionSummary: CrawlerSummaryItem[]
  selectedQrCode: string
  loading: boolean
  visibleLogs: CrawlerLogEntry[]
}>()

const activeStep = computed(() => (
  props.workflowSteps.find((step) => step.status === 'active' || step.status === 'warning')
  ?? props.workflowSteps[props.workflowSteps.length - 1]
))

const recentLogs = computed(() => props.visibleLogs.slice(-12).reverse())
const recentHistory = computed(() => props.snapshot.storage.history.slice(0, 8))
</script>

<template>
  <div class="status-deck">
    <div class="command-grid">
      <article class="hero-card">
        <div class="hero-card__head">
          <div>
            <p class="eyebrow">Now Focus</p>
            <h3>{{ activeStep?.label || '采集驾驶舱' }}</h3>
          </div>
          <span class="status-chip" :data-tone="statusTone(snapshot.crawler.status)">
            {{ snapshot.crawler.message || primaryAction.label }}
          </span>
        </div>

        <p class="hero-copy">
          {{ activeStep?.detail || primaryAction.description }}
        </p>

        <div class="meta-list">
          <span>平台状态:{{ selectedPlatformState?.message || '等待状态刷新' }}</span>
          <span>最近更新时间:{{ formatTimeLabel(selectedPlatformState?.updated_at) }}</span>
          <span>状态同步:{{ loading ? '刷新中' : '已同步' }}</span>
        </div>

        <div v-if="validationIssues.length > 0" class="issue-list">
          <div v-for="issue in validationIssues" :key="issue" class="issue-pill">
            {{ issue }}
          </div>
        </div>
      </article>

      <article class="qr-card">
        <div class="hero-card__head">
          <div>
            <p class="eyebrow">Login Surface</p>
            <h3>{{ selectedQrCode ? '扫码登录' : (selectedPlatformState?.logged_in ? '登录已就绪' : '等待登录动作') }}</h3>
          </div>
        </div>

        <div v-if="selectedQrCode" class="qr-box">
          <img :src="selectedQrCode" alt="登录二维码" />
          <div class="qr-copy">
            <strong>二维码已生成</strong>
            <span>请使用对应平台 App 扫码,完成后页面会自动刷新登录状态。</span>
          </div>
        </div>

        <div v-else class="qr-placeholder">
          <strong>{{ selectedPlatformState?.logged_in ? '当前平台已登录' : primaryAction.label }}</strong>
          <span>{{ selectedPlatformState?.logged_in ? '可以直接开始采集,或检查历史配置后再发起任务。' : capabilityHint }}</span>
        </div>
      </article>
    </div>

    <div class="summary-grid">
      <article v-for="item in missionSummary" :key="item.label" class="summary-card">
        <p>{{ item.label }}</p>
        <strong>{{ item.value }}</strong>
      </article>
    </div>

    <div class="log-card">
      <div class="log-head">
        <h3>实时输出</h3>
        <span>{{ visibleLogs.length }} 条</span>
      </div>

      <div v-if="recentLogs.length > 0" class="log-stream">
        <article v-for="entry in recentLogs" :key="`${entry.timestamp}-${entry.message}`" class="log-line">
          <span>{{ entry.timestamp }}</span>
          <p>{{ entry.message }}</p>
        </article>
      </div>

      <div v-else class="empty-surface">
        <strong>还没有新的日志输出</strong>
        <span>开始登录或采集后,这里会优先展示最新的结构化日志。</span>
      </div>
    </div>

    <div class="history-card">
      <div class="log-head">
        <h3>最近记录</h3>
        <span>{{ recentHistory.length }} 条</span>
      </div>

      <div v-if="recentHistory.length > 0" class="history-list">
        <div v-for="item in recentHistory" :key="item.id" class="history-item">
          <div class="history-item__head">
            <strong>{{ item.platform_label }} · {{ item.kind }}</strong>
            <span class="status-chip" :data-tone="statusTone(item.status)">
              {{ item.status }}
            </span>
          </div>
          <p>{{ item.message }}</p>
          <em>{{ formatTimeLabel(item.updated_at) }}</em>
        </div>
      </div>

      <div v-else class="empty-surface">
        <strong>暂无历史记录</strong>
        <span>一旦登录、采集或中断发生,这里会自动沉淀最近的执行轨迹。</span>
      </div>
    </div>
  </div>
</template>

<style scoped>
.status-deck,
.command-grid,
.summary-grid,
.issue-list,
.history-list {
  display: grid;
  gap: 18px;
}

.command-grid {
  grid-template-columns: minmax(0, 1.15fr) minmax(300px, 0.85fr);
}

.hero-card,
.qr-card,
.log-card,
.history-card,
.summary-card {
  border: 1px solid var(--border-soft);
  border-radius: 24px;
  background: rgba(255, 252, 246, 0.78);
}

.hero-card,
.qr-card,
.log-card,
.history-card {
  padding: 20px;
}

.hero-card {
  background:
    radial-gradient(circle at top right, rgba(40, 94, 77, 0.12), transparent 28%),
    rgba(255, 252, 246, 0.86);
}

.hero-card__head,
.log-head,
.history-item__head {
  display: flex;
  justify-content: space-between;
  gap: 14px;
  align-items: flex-start;
}

.eyebrow,
.summary-card p,
.meta-list span,
.log-head span,
.log-line span,
.history-item em {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.hero-card__head h3,
.log-head h3 {
  margin: 6px 0 0;
  font-size: 18px;
}

.hero-copy,
.qr-copy span,
.qr-placeholder span,
.history-item p,
.empty-surface span {
  margin: 0;
  color: var(--text-muted);
  line-height: 1.7;
}

.hero-copy {
  margin-top: 16px;
}

.meta-list {
  display: grid;
  gap: 8px;
  margin-top: 18px;
}

.issue-list {
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  margin-top: 18px;
}

.issue-pill {
  padding: 12px 14px;
  border-radius: 16px;
  background: rgba(140, 53, 45, 0.08);
  color: #8c352d;
}

.qr-box,
.qr-placeholder,
.empty-surface {
  display: grid;
  gap: 14px;
  min-height: 100%;
}

.qr-box {
  place-items: start;
}

.qr-box img {
  width: min(240px, 100%);
  border-radius: 18px;
  border: 1px solid var(--border-soft);
  background: white;
  padding: 10px;
}

.qr-copy,
.log-stream {
  display: grid;
  gap: 10px;
}

.qr-copy strong,
.qr-placeholder strong,
.empty-surface strong,
.summary-card strong {
  font-size: 16px;
}

.summary-grid {
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

.summary-card {
  display: grid;
  gap: 8px;
  padding: 16px;
}

.log-stream {
  margin-top: 14px;
}

.log-line,
.history-item {
  display: grid;
  gap: 8px;
  padding: 14px;
  border-radius: 18px;
  background: rgba(255, 255, 255, 0.68);
}

.log-line p {
  margin: 0;
  line-height: 1.7;
}

.history-list {
  margin-top: 14px;
}

.history-item p {
  margin: 0;
}

@media (max-width: 1320px) {
  .command-grid,
  .summary-grid {
    grid-template-columns: 1fr;
  }
}
</style>