|
|
@@ -0,0 +1,1249 @@
|
|
|
+<script setup lang="ts">
|
|
|
+import { computed, onMounted, ref, watch } from 'vue'
|
|
|
+import {
|
|
|
+ fetchVideoDiscoveryCandidates,
|
|
|
+ fetchVideoDiscoveryRun,
|
|
|
+ fetchVideoDiscoveryRuns,
|
|
|
+ fetchVideoDiscoverySearches,
|
|
|
+} from '../api/videoDiscoveryRecords'
|
|
|
+import type {
|
|
|
+ JsonValue,
|
|
|
+ VideoDiscoveryCandidateRecord,
|
|
|
+ VideoDiscoveryRunRecord,
|
|
|
+ VideoDiscoverySearchRecord,
|
|
|
+} from '../types/videoDiscoveryRecords'
|
|
|
+
|
|
|
+type DetailTab = 'searches' | 'candidates'
|
|
|
+
|
|
|
+const PAGE_SIZE = 20
|
|
|
+const runs = ref<VideoDiscoveryRunRecord[]>([])
|
|
|
+const selected = ref<VideoDiscoveryRunRecord | null>(null)
|
|
|
+const searches = ref<VideoDiscoverySearchRecord[]>([])
|
|
|
+const candidates = ref<VideoDiscoveryCandidateRecord[]>([])
|
|
|
+const runTotal = ref(0)
|
|
|
+const searchTotal = ref(0)
|
|
|
+const candidateTotal = ref(0)
|
|
|
+const runPage = ref(1)
|
|
|
+const searchPage = ref(1)
|
|
|
+const candidatePage = ref(1)
|
|
|
+const tab = ref<DetailTab>('searches')
|
|
|
+const bizDt = ref('')
|
|
|
+const status = ref('')
|
|
|
+const runKeyword = ref('')
|
|
|
+const detailKeyword = ref('')
|
|
|
+const candidateBucket = ref('')
|
|
|
+const loadingRuns = ref(true)
|
|
|
+const loadingDetail = ref(false)
|
|
|
+const error = ref('')
|
|
|
+const expandedSearchId = ref<number | null>(null)
|
|
|
+const expandedCandidateId = ref<number | null>(null)
|
|
|
+let runRequestId = 0
|
|
|
+let detailRequestId = 0
|
|
|
+
|
|
|
+const runPages = computed(() => Math.max(1, Math.ceil(runTotal.value / PAGE_SIZE)))
|
|
|
+const detailTotal = computed(() => tab.value === 'searches' ? searchTotal.value : candidateTotal.value)
|
|
|
+const detailPage = computed(() => tab.value === 'searches' ? searchPage.value : candidatePage.value)
|
|
|
+const detailPages = computed(() => Math.max(1, Math.ceil(detailTotal.value / PAGE_SIZE)))
|
|
|
+
|
|
|
+onMounted(() => loadRuns())
|
|
|
+
|
|
|
+watch(tab, () => {
|
|
|
+ detailKeyword.value = ''
|
|
|
+ expandedSearchId.value = null
|
|
|
+ expandedCandidateId.value = null
|
|
|
+ if (!selected.value) return
|
|
|
+ if (tab.value === 'searches' && !searches.value.length) void loadSearches()
|
|
|
+ if (tab.value === 'candidates' && !candidates.value.length) void loadCandidates()
|
|
|
+})
|
|
|
+
|
|
|
+watch(candidateBucket, () => {
|
|
|
+ candidatePage.value = 1
|
|
|
+ if (selected.value && tab.value === 'candidates') void loadCandidates()
|
|
|
+})
|
|
|
+
|
|
|
+async function loadRuns(preferredRunId?: string) {
|
|
|
+ const requestId = ++runRequestId
|
|
|
+ loadingRuns.value = true
|
|
|
+ error.value = ''
|
|
|
+ try {
|
|
|
+ const response = await fetchVideoDiscoveryRuns({
|
|
|
+ bizDt: bizDt.value.trim(),
|
|
|
+ status: status.value,
|
|
|
+ keyword: runKeyword.value.trim(),
|
|
|
+ limit: PAGE_SIZE,
|
|
|
+ offset: (runPage.value - 1) * PAGE_SIZE,
|
|
|
+ })
|
|
|
+ if (requestId !== runRequestId) return
|
|
|
+ runs.value = response.items
|
|
|
+ runTotal.value = response.total
|
|
|
+ const targetId = preferredRunId || selected.value?.run_id
|
|
|
+ const target = response.items.find((item) => item.run_id === targetId) || response.items[0]
|
|
|
+ if (target) await selectRun(target)
|
|
|
+ else clearSelection()
|
|
|
+ } catch (cause) {
|
|
|
+ if (requestId === runRequestId) {
|
|
|
+ error.value = cause instanceof Error ? cause.message : String(cause)
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ if (requestId === runRequestId) loadingRuns.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function selectRun(run: VideoDiscoveryRunRecord) {
|
|
|
+ selected.value = run
|
|
|
+ searchPage.value = 1
|
|
|
+ candidatePage.value = 1
|
|
|
+ detailKeyword.value = ''
|
|
|
+ candidateBucket.value = ''
|
|
|
+ searches.value = []
|
|
|
+ candidates.value = []
|
|
|
+ expandedSearchId.value = null
|
|
|
+ expandedCandidateId.value = null
|
|
|
+ await loadActiveDetail()
|
|
|
+}
|
|
|
+
|
|
|
+function clearSelection() {
|
|
|
+ selected.value = null
|
|
|
+ searches.value = []
|
|
|
+ candidates.value = []
|
|
|
+ searchTotal.value = 0
|
|
|
+ candidateTotal.value = 0
|
|
|
+}
|
|
|
+
|
|
|
+async function loadActiveDetail() {
|
|
|
+ if (tab.value === 'searches') await loadSearches()
|
|
|
+ else await loadCandidates()
|
|
|
+}
|
|
|
+
|
|
|
+async function loadSearches() {
|
|
|
+ if (!selected.value) return
|
|
|
+ const requestId = ++detailRequestId
|
|
|
+ loadingDetail.value = true
|
|
|
+ error.value = ''
|
|
|
+ try {
|
|
|
+ const response = await fetchVideoDiscoverySearches(selected.value.run_id, {
|
|
|
+ keyword: detailKeyword.value.trim(),
|
|
|
+ limit: PAGE_SIZE,
|
|
|
+ offset: (searchPage.value - 1) * PAGE_SIZE,
|
|
|
+ })
|
|
|
+ if (requestId !== detailRequestId) return
|
|
|
+ searches.value = response.items
|
|
|
+ searchTotal.value = response.total
|
|
|
+ } catch (cause) {
|
|
|
+ if (requestId === detailRequestId) {
|
|
|
+ error.value = cause instanceof Error ? cause.message : String(cause)
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ if (requestId === detailRequestId) loadingDetail.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function loadCandidates() {
|
|
|
+ if (!selected.value) return
|
|
|
+ const requestId = ++detailRequestId
|
|
|
+ loadingDetail.value = true
|
|
|
+ error.value = ''
|
|
|
+ try {
|
|
|
+ const response = await fetchVideoDiscoveryCandidates(selected.value.run_id, {
|
|
|
+ bucket: candidateBucket.value,
|
|
|
+ keyword: detailKeyword.value.trim(),
|
|
|
+ limit: PAGE_SIZE,
|
|
|
+ offset: (candidatePage.value - 1) * PAGE_SIZE,
|
|
|
+ })
|
|
|
+ if (requestId !== detailRequestId) return
|
|
|
+ candidates.value = response.items
|
|
|
+ candidateTotal.value = response.total
|
|
|
+ } catch (cause) {
|
|
|
+ if (requestId === detailRequestId) {
|
|
|
+ error.value = cause instanceof Error ? cause.message : String(cause)
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ if (requestId === detailRequestId) loadingDetail.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function applyRunFilters() {
|
|
|
+ runPage.value = 1
|
|
|
+ void loadRuns()
|
|
|
+}
|
|
|
+
|
|
|
+function resetRunFilters() {
|
|
|
+ bizDt.value = ''
|
|
|
+ status.value = ''
|
|
|
+ runKeyword.value = ''
|
|
|
+ runPage.value = 1
|
|
|
+ void loadRuns()
|
|
|
+}
|
|
|
+
|
|
|
+function applyDetailFilter() {
|
|
|
+ if (tab.value === 'searches') searchPage.value = 1
|
|
|
+ else candidatePage.value = 1
|
|
|
+ void loadActiveDetail()
|
|
|
+}
|
|
|
+
|
|
|
+function changeRunPage(delta: number) {
|
|
|
+ const next = runPage.value + delta
|
|
|
+ if (next < 1 || next > runPages.value) return
|
|
|
+ runPage.value = next
|
|
|
+ void loadRuns()
|
|
|
+}
|
|
|
+
|
|
|
+function changeDetailPage(delta: number) {
|
|
|
+ if (tab.value === 'searches') searchPage.value += delta
|
|
|
+ else candidatePage.value += delta
|
|
|
+ void loadActiveDetail()
|
|
|
+}
|
|
|
+
|
|
|
+async function refresh() {
|
|
|
+ const runId = selected.value?.run_id
|
|
|
+ if (runId) {
|
|
|
+ try {
|
|
|
+ selected.value = await fetchVideoDiscoveryRun(runId)
|
|
|
+ } catch {
|
|
|
+ // The list refresh below owns the visible error state.
|
|
|
+ }
|
|
|
+ }
|
|
|
+ await loadRuns(runId)
|
|
|
+}
|
|
|
+
|
|
|
+function statusLabel(value: string): string {
|
|
|
+ return { running: '运行中', finished: '已完成', failed: '失败' }[value] || value
|
|
|
+}
|
|
|
+
|
|
|
+function bucketLabel(value: string): string {
|
|
|
+ return {
|
|
|
+ primary: '主推荐',
|
|
|
+ rejected: '已淘汰',
|
|
|
+ pending_evaluation: '待评估',
|
|
|
+ }[value] || value
|
|
|
+}
|
|
|
+
|
|
|
+function sourceLabel(value: string): string {
|
|
|
+ return {
|
|
|
+ demand: '需求词',
|
|
|
+ seed: '种子视频',
|
|
|
+ point: '内容点',
|
|
|
+ tag: '标签',
|
|
|
+ author: '作者',
|
|
|
+ pagination: '翻页',
|
|
|
+ mixed: '混合',
|
|
|
+ }[value] || value
|
|
|
+}
|
|
|
+
|
|
|
+function formatTime(value: string | null): string {
|
|
|
+ if (!value) return '—'
|
|
|
+ const date = new Date(value)
|
|
|
+ if (Number.isNaN(date.getTime())) return value
|
|
|
+ return new Intl.DateTimeFormat('zh-CN', {
|
|
|
+ month: '2-digit',
|
|
|
+ day: '2-digit',
|
|
|
+ hour: '2-digit',
|
|
|
+ minute: '2-digit',
|
|
|
+ second: '2-digit',
|
|
|
+ hour12: false,
|
|
|
+ }).format(date)
|
|
|
+}
|
|
|
+
|
|
|
+function formatDate(value: string | null): string {
|
|
|
+ if (!value) return '未标记'
|
|
|
+ if (/^\d{8}$/.test(value)) return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6)}`
|
|
|
+ return value
|
|
|
+}
|
|
|
+
|
|
|
+function formatCount(value: number | null): string {
|
|
|
+ if (value === null) return '—'
|
|
|
+ if (value >= 100_000_000) return `${(value / 100_000_000).toFixed(1)}亿`
|
|
|
+ if (value >= 10_000) return `${(value / 10_000).toFixed(1)}万`
|
|
|
+ return new Intl.NumberFormat('zh-CN').format(value)
|
|
|
+}
|
|
|
+
|
|
|
+function formatScore(value: number | null): string {
|
|
|
+ if (value === null) return '—'
|
|
|
+ return value.toFixed(value > 1 ? 2 : 3)
|
|
|
+}
|
|
|
+
|
|
|
+function formatJson(value: JsonValue): string {
|
|
|
+ if (value === null || value === undefined || value === '') return '暂无数据'
|
|
|
+ if (typeof value === 'string') return value
|
|
|
+ return JSON.stringify(value, null, 2)
|
|
|
+}
|
|
|
+
|
|
|
+function openCandidate(candidate: VideoDiscoveryCandidateRecord) {
|
|
|
+ const url = candidate.content_link?.trim()
|
|
|
+ if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <div class="records-page">
|
|
|
+ <header class="page-header">
|
|
|
+ <div>
|
|
|
+ <div class="eyebrow"><span /> FIND AGENT · DATA TRACE</div>
|
|
|
+ <h1>找视频记录</h1>
|
|
|
+ <p>沿运行、搜索轨迹和候选视频还原 Find Agent 的完整决策过程。</p>
|
|
|
+ </div>
|
|
|
+ <button class="refresh-button" type="button" :disabled="loadingRuns" @click="refresh">
|
|
|
+ <span :class="{ spinning: loadingRuns }">↻</span>
|
|
|
+ 刷新数据
|
|
|
+ </button>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ <form class="filters" @submit.prevent="applyRunFilters">
|
|
|
+ <label>
|
|
|
+ <span>业务日</span>
|
|
|
+ <input v-model="bizDt" inputmode="numeric" maxlength="8" placeholder="YYYYMMDD" />
|
|
|
+ </label>
|
|
|
+ <label>
|
|
|
+ <span>运行状态</span>
|
|
|
+ <select v-model="status">
|
|
|
+ <option value="">全部状态</option>
|
|
|
+ <option value="running">运行中</option>
|
|
|
+ <option value="finished">已完成</option>
|
|
|
+ <option value="failed">失败</option>
|
|
|
+ </select>
|
|
|
+ </label>
|
|
|
+ <label class="keyword-filter">
|
|
|
+ <span>需求 / Run ID</span>
|
|
|
+ <input v-model="runKeyword" type="search" placeholder="搜索需求词、种子标题或 Run ID" />
|
|
|
+ </label>
|
|
|
+ <button class="primary-button" type="submit">查询</button>
|
|
|
+ <button class="ghost-button" type="button" @click="resetRunFilters">重置</button>
|
|
|
+ </form>
|
|
|
+
|
|
|
+ <p v-if="error" class="error-banner">
|
|
|
+ <strong>数据加载失败</strong>
|
|
|
+ {{ error }}
|
|
|
+ </p>
|
|
|
+
|
|
|
+ <section class="workspace">
|
|
|
+ <aside class="run-panel">
|
|
|
+ <div class="panel-heading">
|
|
|
+ <div>
|
|
|
+ <span>video_discovery_run</span>
|
|
|
+ <strong>运行记录</strong>
|
|
|
+ </div>
|
|
|
+ <b>{{ runTotal }}</b>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="loadingRuns && !runs.length" class="state"><i class="loader" />加载运行记录…</div>
|
|
|
+ <div v-else-if="!runs.length" class="state empty">
|
|
|
+ <strong>暂无运行记录</strong>
|
|
|
+ <span>尝试调整业务日或状态筛选</span>
|
|
|
+ </div>
|
|
|
+ <div v-else class="run-list">
|
|
|
+ <button
|
|
|
+ v-for="run in runs"
|
|
|
+ :key="run.run_id"
|
|
|
+ type="button"
|
|
|
+ class="run-card"
|
|
|
+ :class="{ active: selected?.run_id === run.run_id }"
|
|
|
+ @click="selectRun(run)"
|
|
|
+ >
|
|
|
+ <span class="run-card-top">
|
|
|
+ <span class="status-pill" :class="`status-${run.status}`">
|
|
|
+ <i />{{ statusLabel(run.status) }}
|
|
|
+ </span>
|
|
|
+ <time>{{ formatTime(run.create_time) }}</time>
|
|
|
+ </span>
|
|
|
+ <strong>{{ run.demand_word }}</strong>
|
|
|
+ <small :title="run.run_id">{{ run.run_id }}</small>
|
|
|
+ <span class="run-card-meta">
|
|
|
+ <span>{{ formatDate(run.biz_dt) }}</span>
|
|
|
+ <span>{{ run.search_count }} 次搜索</span>
|
|
|
+ <span>{{ run.candidate_count }} 个候选</span>
|
|
|
+ </span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="pagination compact">
|
|
|
+ <button type="button" :disabled="runPage <= 1" @click="changeRunPage(-1)">‹</button>
|
|
|
+ <span>{{ runPage }} / {{ runPages }}</span>
|
|
|
+ <button type="button" :disabled="runPage >= runPages" @click="changeRunPage(1)">›</button>
|
|
|
+ </div>
|
|
|
+ </aside>
|
|
|
+
|
|
|
+ <main v-if="selected" class="detail-panel">
|
|
|
+ <section class="run-summary">
|
|
|
+ <div class="summary-main">
|
|
|
+ <div class="summary-title">
|
|
|
+ <div>
|
|
|
+ <span>当前运行 · {{ formatDate(selected.biz_dt) }}</span>
|
|
|
+ <h2>{{ selected.demand_word }}</h2>
|
|
|
+ </div>
|
|
|
+ <span class="status-pill large" :class="`status-${selected.status}`">
|
|
|
+ <i />{{ statusLabel(selected.status) }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <p v-if="selected.intent_summary" class="intent">{{ selected.intent_summary }}</p>
|
|
|
+ <p v-else class="intent muted">Agent 尚未写入意图总结。</p>
|
|
|
+ <div class="identity-row">
|
|
|
+ <code>{{ selected.run_id }}</code>
|
|
|
+ <span v-if="selected.demand_grade_id">需求分级 #{{ selected.demand_grade_id }}</span>
|
|
|
+ <span v-if="selected.seed_video_id">种子视频 {{ selected.seed_video_id }}</span>
|
|
|
+ </div>
|
|
|
+ <details v-if="selected.relevant_points || selected.stop_reason" class="run-context">
|
|
|
+ <summary>查看输入点位与停止原因</summary>
|
|
|
+ <div v-if="selected.stop_reason">
|
|
|
+ <strong>停止原因</strong>
|
|
|
+ <p>{{ selected.stop_reason }}</p>
|
|
|
+ </div>
|
|
|
+ <div v-if="selected.relevant_points">
|
|
|
+ <strong>相关点位</strong>
|
|
|
+ <pre>{{ formatJson(selected.relevant_points) }}</pre>
|
|
|
+ </div>
|
|
|
+ </details>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="metric-grid">
|
|
|
+ <article><span>搜索页</span><strong>{{ selected.search_count }}</strong></article>
|
|
|
+ <article><span>全部候选</span><strong>{{ selected.candidate_count }}</strong></article>
|
|
|
+ <article class="metric-primary"><span>主推荐</span><strong>{{ selected.primary_count }}</strong></article>
|
|
|
+ <article class="metric-rejected"><span>淘汰 / 待评估</span><strong>{{ selected.rejected_count }}<small> / {{ selected.pending_count }}</small></strong></article>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="records-card">
|
|
|
+ <div class="tabs">
|
|
|
+ <button type="button" :class="{ active: tab === 'searches' }" @click="tab = 'searches'">
|
|
|
+ 搜索轨迹 <b>{{ selected.search_count }}</b>
|
|
|
+ </button>
|
|
|
+ <button type="button" :class="{ active: tab === 'candidates' }" @click="tab = 'candidates'">
|
|
|
+ 候选视频 <b>{{ selected.candidate_count }}</b>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <form class="detail-filters" @submit.prevent="applyDetailFilter">
|
|
|
+ <label class="detail-search">
|
|
|
+ <span class="search-icon" />
|
|
|
+ <input
|
|
|
+ v-model="detailKeyword"
|
|
|
+ type="search"
|
|
|
+ :placeholder="tab === 'searches' ? '搜索关键词、原因或来源' : '搜索视频 ID、标题或作者'"
|
|
|
+ />
|
|
|
+ </label>
|
|
|
+ <select v-if="tab === 'candidates'" v-model="candidateBucket" aria-label="候选分池">
|
|
|
+ <option value="">全部分池</option>
|
|
|
+ <option value="primary">主推荐</option>
|
|
|
+ <option value="rejected">已淘汰</option>
|
|
|
+ <option value="pending_evaluation">待评估</option>
|
|
|
+ </select>
|
|
|
+ <button type="submit">筛选</button>
|
|
|
+ <span class="result-count">共 {{ detailTotal }} 条</span>
|
|
|
+ </form>
|
|
|
+
|
|
|
+ <div v-if="loadingDetail" class="state"><i class="loader" />加载记录…</div>
|
|
|
+ <div
|
|
|
+ v-else-if="tab === 'searches' && !searches.length"
|
|
|
+ class="state empty"
|
|
|
+ >
|
|
|
+ <strong>暂无搜索轨迹</strong>
|
|
|
+ <span>Agent 保存搜索页后会显示在这里</span>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ v-else-if="tab === 'candidates' && !candidates.length"
|
|
|
+ class="state empty"
|
|
|
+ >
|
|
|
+ <strong>暂无候选视频</strong>
|
|
|
+ <span>当前运行或筛选条件下没有候选</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-else-if="tab === 'searches'" class="table-wrap">
|
|
|
+ <table class="search-table">
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th>ID / 状态</th>
|
|
|
+ <th>搜索词与原因</th>
|
|
|
+ <th>来源</th>
|
|
|
+ <th>供应方 / 分页</th>
|
|
|
+ <th>结果</th>
|
|
|
+ <th>创建时间</th>
|
|
|
+ <th />
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ <template v-for="item in searches" :key="item.id">
|
|
|
+ <tr>
|
|
|
+ <td>
|
|
|
+ <span class="record-id">#{{ item.id }}</span>
|
|
|
+ <span class="mini-status" :class="item.status === 'success' ? 'ok' : 'bad'">
|
|
|
+ {{ item.status === 'success' ? '成功' : '失败' }}
|
|
|
+ </span>
|
|
|
+ </td>
|
|
|
+ <td class="wide-cell">
|
|
|
+ <strong>{{ item.keyword }}</strong>
|
|
|
+ <small>{{ item.query_reason }}</small>
|
|
|
+ </td>
|
|
|
+ <td>
|
|
|
+ <span class="source-pill">{{ sourceLabel(item.source_type) }}</span>
|
|
|
+ <small>{{ item.source_value || '—' }}</small>
|
|
|
+ </td>
|
|
|
+ <td>
|
|
|
+ <strong>{{ item.provider }}</strong>
|
|
|
+ <small>第 {{ item.page_no }} 页 · 游标 {{ item.cursor }}</small>
|
|
|
+ </td>
|
|
|
+ <td>
|
|
|
+ <strong>{{ item.results_count }}</strong>
|
|
|
+ <small>新增 {{ item.new_candidate_count }} · {{ item.has_more ? '有下一页' : '已到底' }}</small>
|
|
|
+ </td>
|
|
|
+ <td class="nowrap">{{ formatTime(item.create_time) }}</td>
|
|
|
+ <td>
|
|
|
+ <button
|
|
|
+ class="expand-button"
|
|
|
+ type="button"
|
|
|
+ :aria-expanded="expandedSearchId === item.id"
|
|
|
+ @click="expandedSearchId = expandedSearchId === item.id ? null : item.id"
|
|
|
+ >
|
|
|
+ {{ expandedSearchId === item.id ? '收起' : '详情' }}
|
|
|
+ </button>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ <tr v-if="expandedSearchId === item.id" class="expanded-row">
|
|
|
+ <td colspan="7">
|
|
|
+ <div class="expanded-grid">
|
|
|
+ <div>
|
|
|
+ <strong>检索参数</strong>
|
|
|
+ <dl>
|
|
|
+ <dt>搜索键</dt><dd>{{ item.search_key }}</dd>
|
|
|
+ <dt>内容 / 排序</dt><dd>{{ item.content_type }} · {{ item.sort_type }}</dd>
|
|
|
+ <dt>发布时间</dt><dd>{{ item.publish_time }}</dd>
|
|
|
+ <dt>父搜索</dt><dd>{{ item.parent_search_id ? `#${item.parent_search_id}` : '根搜索' }}</dd>
|
|
|
+ <dt>下一游标</dt><dd>{{ item.next_cursor || '—' }}</dd>
|
|
|
+ </dl>
|
|
|
+ <p v-if="item.error_message" class="error-text">{{ item.error_message }}</p>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <strong>供应方状态</strong>
|
|
|
+ <pre>{{ formatJson(item.provider_state) }}</pre>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <strong>结果视频 ID</strong>
|
|
|
+ <pre>{{ formatJson(item.result_ids) }}</pre>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ </template>
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-else class="table-wrap">
|
|
|
+ <table class="candidate-table">
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th>ID / 分池</th>
|
|
|
+ <th>视频与作者</th>
|
|
|
+ <th>互动数据</th>
|
|
|
+ <th>R / E / S / V</th>
|
|
|
+ <th>AIGC 计划</th>
|
|
|
+ <th>创建时间</th>
|
|
|
+ <th />
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ <template v-for="item in candidates" :key="item.id">
|
|
|
+ <tr>
|
|
|
+ <td>
|
|
|
+ <span class="record-id">#{{ item.id }}</span>
|
|
|
+ <span class="bucket-pill" :class="`bucket-${item.decision_bucket}`">
|
|
|
+ {{ bucketLabel(item.decision_bucket) }}
|
|
|
+ </span>
|
|
|
+ </td>
|
|
|
+ <td class="wide-cell video-cell">
|
|
|
+ <strong>{{ item.title || '未命名视频' }}</strong>
|
|
|
+ <small>{{ item.author_name || '未知作者' }} · {{ item.aweme_id }}</small>
|
|
|
+ </td>
|
|
|
+ <td>
|
|
|
+ <strong>{{ formatCount(item.play_count) }} 播放</strong>
|
|
|
+ <small>
|
|
|
+ 赞 {{ formatCount(item.like_count) }} · 评 {{ formatCount(item.comment_count) }}
|
|
|
+ · 藏 {{ formatCount(item.collect_count) }} · 转 {{ formatCount(item.share_count) }}
|
|
|
+ </small>
|
|
|
+ </td>
|
|
|
+ <td>
|
|
|
+ <span class="score-line">
|
|
|
+ <b>R {{ formatScore(item.relevance_score) }}</b>
|
|
|
+ <b>E {{ formatScore(item.elder_score) }}</b>
|
|
|
+ <b>S {{ formatScore(item.share_score) }}</b>
|
|
|
+ <b class="value-score">V {{ formatScore(item.value_score) }}</b>
|
|
|
+ </span>
|
|
|
+ </td>
|
|
|
+ <td>
|
|
|
+ <strong>{{ item.aigc_plan_label || '未分发' }}</strong>
|
|
|
+ <small>{{ item.aigc_crawler_plan_id || '—' }}</small>
|
|
|
+ </td>
|
|
|
+ <td class="nowrap">{{ formatTime(item.create_time) }}</td>
|
|
|
+ <td>
|
|
|
+ <button
|
|
|
+ class="expand-button"
|
|
|
+ type="button"
|
|
|
+ :aria-expanded="expandedCandidateId === item.id"
|
|
|
+ @click="expandedCandidateId = expandedCandidateId === item.id ? null : item.id"
|
|
|
+ >
|
|
|
+ {{ expandedCandidateId === item.id ? '收起' : '详情' }}
|
|
|
+ </button>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ <tr v-if="expandedCandidateId === item.id" class="expanded-row">
|
|
|
+ <td colspan="7">
|
|
|
+ <div class="candidate-detail">
|
|
|
+ <div class="decision-block">
|
|
|
+ <strong>决策依据</strong>
|
|
|
+ <p>{{ item.decision_reason || '暂无决策说明' }}</p>
|
|
|
+ <div class="tag-row">
|
|
|
+ <span v-for="tag in Array.isArray(item.tags) ? item.tags : []" :key="String(tag)">
|
|
|
+ {{ tag }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ v-if="item.content_link"
|
|
|
+ class="link-button"
|
|
|
+ type="button"
|
|
|
+ @click="openCandidate(item)"
|
|
|
+ >
|
|
|
+ 打开原视频 ↗
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <div class="evidence-grid">
|
|
|
+ <details>
|
|
|
+ <summary>内容受众年龄证据</summary>
|
|
|
+ <pre>{{ formatJson(item.content_age_evidence) }}</pre>
|
|
|
+ </details>
|
|
|
+ <details>
|
|
|
+ <summary>账号粉丝年龄证据</summary>
|
|
|
+ <pre>{{ formatJson(item.account_age_evidence) }}</pre>
|
|
|
+ </details>
|
|
|
+ <details>
|
|
|
+ <summary>年龄归一化结果</summary>
|
|
|
+ <pre>{{ formatJson(item.age_normalization) }}</pre>
|
|
|
+ </details>
|
|
|
+ <details>
|
|
|
+ <summary>来源与 AIGC 计划</summary>
|
|
|
+ <dl>
|
|
|
+ <dt>Search ID</dt><dd>{{ item.search_id || '历史记录未关联' }}</dd>
|
|
|
+ <dt>来源关键词</dt><dd>{{ formatJson(item.source_keywords) }}</dd>
|
|
|
+ <dt>来源搜索</dt><dd>{{ formatJson(item.source_search_ids) }}</dd>
|
|
|
+ <dt>爬取计划</dt><dd>{{ item.aigc_crawler_plan_id || '—' }}</dd>
|
|
|
+ <dt>生成计划</dt><dd>{{ item.aigc_produce_plan_id || '—' }}</dd>
|
|
|
+ <dt>发布计划</dt><dd>{{ item.aigc_publish_plan_id || '—' }}</dd>
|
|
|
+ </dl>
|
|
|
+ </details>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ </template>
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="detailTotal" class="pagination">
|
|
|
+ <span>第 {{ detailPage }} / {{ detailPages }} 页</span>
|
|
|
+ <div>
|
|
|
+ <button type="button" :disabled="detailPage <= 1" @click="changeDetailPage(-1)">上一页</button>
|
|
|
+ <button type="button" :disabled="detailPage >= detailPages" @click="changeDetailPage(1)">下一页</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ </main>
|
|
|
+
|
|
|
+ <main v-else class="detail-panel no-selection">
|
|
|
+ <span>⌕</span>
|
|
|
+ <strong>选择一次找视频运行</strong>
|
|
|
+ <p>运行的搜索轨迹和候选评估将在这里展开。</p>
|
|
|
+ </main>
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.records-page {
|
|
|
+ display: flex;
|
|
|
+ min-width: 0;
|
|
|
+ max-width: 1680px;
|
|
|
+ min-height: 100%;
|
|
|
+ margin: 0 auto;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 16px;
|
|
|
+ color: #242b3a;
|
|
|
+}
|
|
|
+
|
|
|
+.page-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-end;
|
|
|
+ justify-content: space-between;
|
|
|
+ padding: 8px 4px 2px;
|
|
|
+}
|
|
|
+
|
|
|
+.eyebrow {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ color: #737b8c;
|
|
|
+ font-size: 9px;
|
|
|
+ font-weight: 800;
|
|
|
+ letter-spacing: .17em;
|
|
|
+}
|
|
|
+
|
|
|
+.eyebrow span {
|
|
|
+ width: 7px;
|
|
|
+ height: 7px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: #6666d4;
|
|
|
+ box-shadow: 0 0 0 4px rgba(102, 102, 212, .11);
|
|
|
+}
|
|
|
+
|
|
|
+.page-header h1 {
|
|
|
+ margin: 8px 0 3px;
|
|
|
+ font-size: 27px;
|
|
|
+ letter-spacing: -.04em;
|
|
|
+}
|
|
|
+
|
|
|
+.page-header p {
|
|
|
+ margin: 0;
|
|
|
+ color: #8a91a0;
|
|
|
+ font-size: 11px;
|
|
|
+}
|
|
|
+
|
|
|
+button, input, select { font: inherit; }
|
|
|
+
|
|
|
+.refresh-button,
|
|
|
+.primary-button,
|
|
|
+.ghost-button {
|
|
|
+ height: 38px;
|
|
|
+ border-radius: 9px;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+
|
|
|
+.refresh-button {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 7px;
|
|
|
+ padding: 0 13px;
|
|
|
+ border: 1px solid #dfe2e8;
|
|
|
+ background: #fff;
|
|
|
+ color: #5d6575;
|
|
|
+ font-size: 10px;
|
|
|
+ font-weight: 700;
|
|
|
+}
|
|
|
+
|
|
|
+.spinning { display: inline-block; animation: spin .7s linear infinite; }
|
|
|
+@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
+
|
|
|
+.filters {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 150px 160px minmax(260px, 1fr) auto auto;
|
|
|
+ align-items: end;
|
|
|
+ gap: 10px;
|
|
|
+ padding: 13px 15px;
|
|
|
+ border: 1px solid #e3e6eb;
|
|
|
+ border-radius: 14px;
|
|
|
+ background: rgba(255, 255, 255, .88);
|
|
|
+}
|
|
|
+
|
|
|
+.filters label {
|
|
|
+ display: grid;
|
|
|
+ gap: 5px;
|
|
|
+}
|
|
|
+
|
|
|
+.filters label > span {
|
|
|
+ color: #858c9b;
|
|
|
+ font-size: 9px;
|
|
|
+ font-weight: 700;
|
|
|
+}
|
|
|
+
|
|
|
+.filters input,
|
|
|
+.filters select,
|
|
|
+.detail-filters input,
|
|
|
+.detail-filters select {
|
|
|
+ height: 38px;
|
|
|
+ border: 1px solid #dfe2e8;
|
|
|
+ border-radius: 8px;
|
|
|
+ outline: none;
|
|
|
+ background: #fff;
|
|
|
+ color: #424a5b;
|
|
|
+ font-size: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.filters input { width: 100%; padding: 0 11px; }
|
|
|
+.filters select, .detail-filters select { padding: 0 30px 0 10px; }
|
|
|
+.filters input:focus, .filters select:focus, .detail-filters input:focus, .detail-filters select:focus {
|
|
|
+ border-color: #7474d8;
|
|
|
+ box-shadow: 0 0 0 3px rgba(102, 102, 212, .08);
|
|
|
+}
|
|
|
+
|
|
|
+.primary-button {
|
|
|
+ padding: 0 18px;
|
|
|
+ border: 1px solid #5d5dcd;
|
|
|
+ background: #6262d2;
|
|
|
+ color: #fff;
|
|
|
+ font-size: 10px;
|
|
|
+ font-weight: 700;
|
|
|
+}
|
|
|
+
|
|
|
+.ghost-button {
|
|
|
+ padding: 0 13px;
|
|
|
+ border: 1px solid #dfe2e8;
|
|
|
+ background: #fff;
|
|
|
+ color: #7a8190;
|
|
|
+ font-size: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.error-banner {
|
|
|
+ margin: 0;
|
|
|
+ padding: 10px 14px;
|
|
|
+ border: 1px solid #f0cdd2;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: #fff5f6;
|
|
|
+ color: #a43d4b;
|
|
|
+ font-size: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.error-banner strong { margin-right: 8px; }
|
|
|
+
|
|
|
+.workspace {
|
|
|
+ display: grid;
|
|
|
+ min-height: 650px;
|
|
|
+ flex: 1;
|
|
|
+ grid-template-columns: 260px minmax(0, 1fr);
|
|
|
+ gap: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.run-panel,
|
|
|
+.detail-panel {
|
|
|
+ min-width: 0;
|
|
|
+ border: 1px solid #e2e5ea;
|
|
|
+ border-radius: 15px;
|
|
|
+ background: rgba(255, 255, 255, .91);
|
|
|
+ box-shadow: 0 8px 26px rgba(34, 42, 61, .035);
|
|
|
+}
|
|
|
+
|
|
|
+.run-panel {
|
|
|
+ display: flex;
|
|
|
+ overflow: hidden;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+
|
|
|
+.panel-heading {
|
|
|
+ display: flex;
|
|
|
+ min-height: 62px;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ padding: 0 15px;
|
|
|
+ border-bottom: 1px solid #ebedf1;
|
|
|
+}
|
|
|
+
|
|
|
+.panel-heading div > span,
|
|
|
+.panel-heading div > strong { display: block; }
|
|
|
+.panel-heading div > span { color: #a0a6b1; font-size: 8px; font-weight: 700; }
|
|
|
+.panel-heading div > strong { margin-top: 3px; font-size: 12px; }
|
|
|
+.panel-heading b {
|
|
|
+ display: grid;
|
|
|
+ min-width: 27px;
|
|
|
+ height: 22px;
|
|
|
+ place-items: center;
|
|
|
+ padding: 0 7px;
|
|
|
+ border-radius: 7px;
|
|
|
+ background: #f0f0ff;
|
|
|
+ color: #5f5fcc;
|
|
|
+ font-size: 9px;
|
|
|
+}
|
|
|
+
|
|
|
+.run-list {
|
|
|
+ min-height: 0;
|
|
|
+ padding: 7px;
|
|
|
+ flex: 1;
|
|
|
+ overflow: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.run-card {
|
|
|
+ display: flex;
|
|
|
+ width: 100%;
|
|
|
+ padding: 12px 11px;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 6px;
|
|
|
+ border: 1px solid transparent;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: transparent;
|
|
|
+ color: inherit;
|
|
|
+ text-align: left;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+
|
|
|
+.run-card:hover { background: #f7f7fa; }
|
|
|
+.run-card.active {
|
|
|
+ border-color: #dcdcff;
|
|
|
+ background: #f1f1ff;
|
|
|
+}
|
|
|
+
|
|
|
+.run-card-top,
|
|
|
+.run-card-meta {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 6px;
|
|
|
+}
|
|
|
+
|
|
|
+.run-card-top time { color: #9aa0ac; font-size: 8px; }
|
|
|
+.run-card > strong {
|
|
|
+ overflow: hidden;
|
|
|
+ font-size: 12px;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+.run-card > small {
|
|
|
+ overflow: hidden;
|
|
|
+ color: #9ca2ae;
|
|
|
+ font-family: ui-monospace, monospace;
|
|
|
+ font-size: 8px;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+.run-card-meta {
|
|
|
+ justify-content: flex-start;
|
|
|
+ color: #7e8594;
|
|
|
+ font-size: 8px;
|
|
|
+}
|
|
|
+.run-card-meta span + span::before { margin-right: 6px; color: #c3c7cf; content: '·'; }
|
|
|
+
|
|
|
+.status-pill,
|
|
|
+.bucket-pill {
|
|
|
+ display: inline-flex;
|
|
|
+ width: fit-content;
|
|
|
+ align-items: center;
|
|
|
+ gap: 5px;
|
|
|
+ padding: 3px 7px;
|
|
|
+ border-radius: 999px;
|
|
|
+ font-size: 8px;
|
|
|
+ font-weight: 800;
|
|
|
+}
|
|
|
+.status-pill i { width: 5px; height: 5px; border-radius: 50%; }
|
|
|
+.status-running { background: #fff7df; color: #9a6b08; }
|
|
|
+.status-running i { background: #e8a91d; }
|
|
|
+.status-finished { background: #eaf8f2; color: #278365; }
|
|
|
+.status-finished i { background: #31aa80; }
|
|
|
+.status-failed { background: #fff0f1; color: #af4652; }
|
|
|
+.status-failed i { background: #d25563; }
|
|
|
+.status-pill.large { padding: 5px 9px; font-size: 9px; }
|
|
|
+
|
|
|
+.detail-panel { overflow: hidden; }
|
|
|
+.run-summary {
|
|
|
+ display: grid;
|
|
|
+ padding: 19px;
|
|
|
+ grid-template-columns: minmax(0, 1fr) 390px;
|
|
|
+ gap: 18px;
|
|
|
+ border-bottom: 1px solid #e8eaee;
|
|
|
+ background: linear-gradient(145deg, #fff 20%, #f8f8ff);
|
|
|
+}
|
|
|
+.summary-title {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-start;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 15px;
|
|
|
+}
|
|
|
+.summary-title > div > span { color: #9298a5; font-size: 9px; font-weight: 700; }
|
|
|
+.summary-title h2 { margin: 4px 0 0; font-size: 20px; letter-spacing: -.03em; }
|
|
|
+.intent { max-width: 800px; margin: 11px 0 10px; color: #586071; font-size: 10px; line-height: 1.7; }
|
|
|
+.intent.muted { color: #9ca2ad; }
|
|
|
+.identity-row { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
|
+.identity-row code, .identity-row span {
|
|
|
+ padding: 4px 7px;
|
|
|
+ border-radius: 6px;
|
|
|
+ background: #f0f2f5;
|
|
|
+ color: #707787;
|
|
|
+ font-size: 8px;
|
|
|
+}
|
|
|
+.run-context { margin-top: 10px; font-size: 9px; }
|
|
|
+.run-context summary { color: #6262cc; cursor: pointer; }
|
|
|
+.run-context strong { display: block; margin-top: 8px; color: #606878; }
|
|
|
+.run-context p { margin: 4px 0; color: #737b89; }
|
|
|
+pre {
|
|
|
+ max-height: 210px;
|
|
|
+ margin: 6px 0 0;
|
|
|
+ padding: 10px;
|
|
|
+ overflow: auto;
|
|
|
+ border: 1px solid #e5e7ec;
|
|
|
+ border-radius: 8px;
|
|
|
+ background: #f8f9fb;
|
|
|
+ color: #596173;
|
|
|
+ font: 8px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
|
+ white-space: pre-wrap;
|
|
|
+ word-break: break-word;
|
|
|
+}
|
|
|
+.metric-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; align-self: start; }
|
|
|
+.metric-grid article {
|
|
|
+ min-height: 68px;
|
|
|
+ padding: 11px 12px;
|
|
|
+ border: 1px solid #e5e7ed;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: rgba(255,255,255,.8);
|
|
|
+}
|
|
|
+.metric-grid span { display: block; color: #9298a5; font-size: 8px; font-weight: 700; }
|
|
|
+.metric-grid strong { display: block; margin-top: 5px; font-size: 19px; letter-spacing: -.04em; }
|
|
|
+.metric-grid strong small { color: #999fac; font-size: 11px; }
|
|
|
+.metric-grid .metric-primary { border-color: #cceadf; background: #f4fbf8; }
|
|
|
+.metric-primary strong { color: #248062; }
|
|
|
+.metric-grid .metric-rejected { border-color: #f0dadd; background: #fff9f9; }
|
|
|
+.metric-rejected strong { color: #a94c58; }
|
|
|
+
|
|
|
+.records-card { min-width: 0; }
|
|
|
+.tabs {
|
|
|
+ display: flex;
|
|
|
+ height: 49px;
|
|
|
+ align-items: flex-end;
|
|
|
+ gap: 21px;
|
|
|
+ padding: 0 18px;
|
|
|
+ border-bottom: 1px solid #e8eaee;
|
|
|
+}
|
|
|
+.tabs button {
|
|
|
+ height: 49px;
|
|
|
+ padding: 0 2px;
|
|
|
+ border: 0;
|
|
|
+ border-bottom: 2px solid transparent;
|
|
|
+ background: transparent;
|
|
|
+ color: #858c9a;
|
|
|
+ font-size: 10px;
|
|
|
+ font-weight: 700;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+.tabs button.active { border-bottom-color: #6262d2; color: #4f50bd; }
|
|
|
+.tabs b { margin-left: 4px; color: #a1a6b1; font-size: 8px; }
|
|
|
+
|
|
|
+.detail-filters {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ padding: 10px 13px;
|
|
|
+ border-bottom: 1px solid #eceef2;
|
|
|
+ background: #fafbfc;
|
|
|
+}
|
|
|
+.detail-search { position: relative; width: min(360px, 45%); }
|
|
|
+.detail-search input { width: 100%; padding: 0 11px 0 34px; }
|
|
|
+.search-icon {
|
|
|
+ position: absolute;
|
|
|
+ z-index: 1;
|
|
|
+ top: 12px;
|
|
|
+ left: 12px;
|
|
|
+ width: 10px;
|
|
|
+ height: 10px;
|
|
|
+ border: 1.5px solid #969daa;
|
|
|
+ border-radius: 50%;
|
|
|
+}
|
|
|
+.search-icon::after {
|
|
|
+ position: absolute;
|
|
|
+ right: -4px;
|
|
|
+ bottom: -3px;
|
|
|
+ width: 5px;
|
|
|
+ height: 1.5px;
|
|
|
+ background: #969daa;
|
|
|
+ content: '';
|
|
|
+ transform: rotate(45deg);
|
|
|
+}
|
|
|
+.detail-filters > button {
|
|
|
+ height: 34px;
|
|
|
+ padding: 0 13px;
|
|
|
+ border: 1px solid #d7dae1;
|
|
|
+ border-radius: 8px;
|
|
|
+ background: #fff;
|
|
|
+ color: #646c7b;
|
|
|
+ font-size: 9px;
|
|
|
+ font-weight: 700;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+.result-count { margin-left: auto; color: #939aa7; font-size: 9px; }
|
|
|
+
|
|
|
+.table-wrap { width: 100%; overflow-x: auto; }
|
|
|
+table { width: 100%; min-width: 920px; border-collapse: collapse; table-layout: fixed; }
|
|
|
+th {
|
|
|
+ height: 36px;
|
|
|
+ padding: 0 10px;
|
|
|
+ border-bottom: 1px solid #e8eaee;
|
|
|
+ background: #fff;
|
|
|
+ color: #9298a5;
|
|
|
+ font-size: 8px;
|
|
|
+ font-weight: 700;
|
|
|
+ text-align: left;
|
|
|
+}
|
|
|
+td {
|
|
|
+ height: 64px;
|
|
|
+ padding: 9px 10px;
|
|
|
+ border-bottom: 1px solid #eff0f3;
|
|
|
+ color: #5b6373;
|
|
|
+ font-size: 9px;
|
|
|
+ vertical-align: middle;
|
|
|
+}
|
|
|
+tbody > tr:not(.expanded-row):hover { background: #fafafe; }
|
|
|
+td strong, td small { display: block; }
|
|
|
+td strong {
|
|
|
+ overflow: hidden;
|
|
|
+ color: #41495a;
|
|
|
+ font-size: 9px;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+td small {
|
|
|
+ max-width: 100%;
|
|
|
+ margin-top: 4px;
|
|
|
+ overflow: hidden;
|
|
|
+ color: #9096a3;
|
|
|
+ font-size: 8px;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+.search-table th:nth-child(1), .search-table td:nth-child(1) { width: 86px; }
|
|
|
+.search-table th:nth-child(2), .search-table td:nth-child(2) { width: 25%; }
|
|
|
+.search-table th:nth-child(3), .search-table td:nth-child(3) { width: 15%; }
|
|
|
+.search-table th:nth-child(4), .search-table td:nth-child(4) { width: 16%; }
|
|
|
+.search-table th:nth-child(5), .search-table td:nth-child(5) { width: 14%; }
|
|
|
+.search-table th:nth-child(6), .search-table td:nth-child(6) { width: 112px; }
|
|
|
+.search-table th:nth-child(7), .search-table td:nth-child(7) { width: 53px; }
|
|
|
+.candidate-table th:nth-child(1), .candidate-table td:nth-child(1) { width: 89px; }
|
|
|
+.candidate-table th:nth-child(2), .candidate-table td:nth-child(2) { width: 24%; }
|
|
|
+.candidate-table th:nth-child(3), .candidate-table td:nth-child(3) { width: 19%; }
|
|
|
+.candidate-table th:nth-child(4), .candidate-table td:nth-child(4) { width: 20%; }
|
|
|
+.candidate-table th:nth-child(5), .candidate-table td:nth-child(5) { width: 13%; }
|
|
|
+.candidate-table th:nth-child(6), .candidate-table td:nth-child(6) { width: 112px; }
|
|
|
+.candidate-table th:nth-child(7), .candidate-table td:nth-child(7) { width: 53px; }
|
|
|
+.wide-cell strong { font-size: 10px; }
|
|
|
+.record-id {
|
|
|
+ display: block;
|
|
|
+ margin-bottom: 5px;
|
|
|
+ color: #858c9a;
|
|
|
+ font-family: ui-monospace, monospace;
|
|
|
+ font-size: 8px;
|
|
|
+}
|
|
|
+.mini-status, .source-pill {
|
|
|
+ display: inline-block;
|
|
|
+ padding: 2px 6px;
|
|
|
+ border-radius: 5px;
|
|
|
+ font-size: 8px;
|
|
|
+ font-weight: 700;
|
|
|
+}
|
|
|
+.mini-status.ok { background: #eaf8f2; color: #278365; }
|
|
|
+.mini-status.bad { background: #fff0f1; color: #ad4350; }
|
|
|
+.source-pill { background: #eef0ff; color: #5f61bc; }
|
|
|
+.bucket-primary { background: #e8f8f1; color: #247e61; }
|
|
|
+.bucket-rejected { background: #fff0f1; color: #aa4551; }
|
|
|
+.bucket-pending_evaluation { background: #fff7df; color: #946708; }
|
|
|
+.nowrap { white-space: nowrap; }
|
|
|
+.score-line { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
|
+.score-line b {
|
|
|
+ padding: 3px 5px;
|
|
|
+ border-radius: 5px;
|
|
|
+ background: #f1f2f5;
|
|
|
+ color: #687080;
|
|
|
+ font-size: 7px;
|
|
|
+}
|
|
|
+.score-line .value-score { background: #ececff; color: #5758c2; }
|
|
|
+.expand-button {
|
|
|
+ padding: 4px 0;
|
|
|
+ border: 0;
|
|
|
+ background: transparent;
|
|
|
+ color: #5e60c8;
|
|
|
+ font-size: 8px;
|
|
|
+ font-weight: 700;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+.expanded-row > td { height: auto; padding: 13px; background: #f8f9fc; }
|
|
|
+.expanded-grid { display: grid; grid-template-columns: .8fr 1fr 1fr; gap: 12px; }
|
|
|
+.expanded-grid > div, .decision-block, .evidence-grid details {
|
|
|
+ min-width: 0;
|
|
|
+ padding: 11px;
|
|
|
+ border: 1px solid #e3e6ec;
|
|
|
+ border-radius: 9px;
|
|
|
+ background: #fff;
|
|
|
+}
|
|
|
+.expanded-grid > div > strong, .decision-block > strong {
|
|
|
+ color: #4b5363;
|
|
|
+ font-size: 9px;
|
|
|
+}
|
|
|
+dl {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 78px minmax(0, 1fr);
|
|
|
+ gap: 6px;
|
|
|
+ margin: 8px 0 0;
|
|
|
+ font-size: 8px;
|
|
|
+}
|
|
|
+dt { color: #9ba1ad; }
|
|
|
+dd { margin: 0; overflow-wrap: anywhere; color: #606878; }
|
|
|
+.error-text { color: #ad4350; }
|
|
|
+.candidate-detail { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 12px; }
|
|
|
+.decision-block p { color: #697181; font-size: 9px; line-height: 1.7; }
|
|
|
+.tag-row { display: flex; flex-wrap: wrap; gap: 5px; }
|
|
|
+.tag-row span { padding: 3px 6px; border-radius: 5px; background: #f0f1f5; color: #717887; font-size: 8px; }
|
|
|
+.link-button {
|
|
|
+ margin-top: 10px;
|
|
|
+ padding: 5px 8px;
|
|
|
+ border: 1px solid #d9daf4;
|
|
|
+ border-radius: 6px;
|
|
|
+ background: #f5f5ff;
|
|
|
+ color: #5a5cc5;
|
|
|
+ font-size: 8px;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+.evidence-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
|
|
+.evidence-grid details { padding: 9px; }
|
|
|
+.evidence-grid summary { color: #5e6676; font-size: 8px; font-weight: 700; cursor: pointer; }
|
|
|
+
|
|
|
+.state {
|
|
|
+ display: flex;
|
|
|
+ min-height: 190px;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 8px;
|
|
|
+ color: #8d94a2;
|
|
|
+ font-size: 10px;
|
|
|
+}
|
|
|
+.state.empty { flex-direction: column; }
|
|
|
+.state.empty strong { color: #666e7d; font-size: 11px; }
|
|
|
+.state.empty span { color: #9ba1ad; font-size: 9px; }
|
|
|
+.loader {
|
|
|
+ width: 13px;
|
|
|
+ height: 13px;
|
|
|
+ border: 2px solid #dddff1;
|
|
|
+ border-top-color: #6262d2;
|
|
|
+ border-radius: 50%;
|
|
|
+ animation: spin .7s linear infinite;
|
|
|
+}
|
|
|
+.pagination {
|
|
|
+ display: flex;
|
|
|
+ min-height: 51px;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ padding: 8px 13px;
|
|
|
+ border-top: 1px solid #eaecf0;
|
|
|
+ color: #8b92a0;
|
|
|
+ font-size: 9px;
|
|
|
+}
|
|
|
+.pagination div { display: flex; gap: 6px; }
|
|
|
+.pagination button {
|
|
|
+ min-width: 54px;
|
|
|
+ height: 29px;
|
|
|
+ border: 1px solid #dfe2e7;
|
|
|
+ border-radius: 7px;
|
|
|
+ background: #fff;
|
|
|
+ color: #656d7d;
|
|
|
+ font-size: 8px;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+.pagination button:disabled { opacity: .4; cursor: default; }
|
|
|
+.pagination.compact { justify-content: center; gap: 10px; margin-top: auto; }
|
|
|
+.pagination.compact button { min-width: 29px; }
|
|
|
+.no-selection {
|
|
|
+ display: flex;
|
|
|
+ min-height: 650px;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ flex-direction: column;
|
|
|
+ color: #9299a6;
|
|
|
+}
|
|
|
+.no-selection > span { font-size: 32px; color: #7778d4; }
|
|
|
+.no-selection strong { margin-top: 9px; color: #596171; font-size: 13px; }
|
|
|
+.no-selection p { margin: 5px 0; font-size: 9px; }
|
|
|
+
|
|
|
+@media (max-width: 1180px) {
|
|
|
+ .run-summary { grid-template-columns: 1fr; }
|
|
|
+ .metric-grid { grid-template-columns: repeat(4, 1fr); }
|
|
|
+ .metric-grid article { min-height: 58px; }
|
|
|
+}
|
|
|
+</style>
|