| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478 |
- <script setup lang="ts">
- import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
- import {
- createFindAgentV2Test, fetchFindAgentV2Candidates, fetchFindAgentV2Detail,
- fetchFindAgentV2Runs, fetchFindAgentV2SearchCandidates,
- } from '../api/findAgentV2'
- import type { FindAgentV2Detail, FindAgentV2Run } from '../types/findAgentV2'
- type Tab = 'journey' | 'candidates' | 'context'
- type Row = Record<string, any>
- const runs = ref<FindAgentV2Run[]>([])
- const totalRuns = ref(0)
- const currentPage = ref(1)
- const totalPages = ref(1)
- const hasPreviousPage = ref(false)
- const hasNextPage = ref(false)
- const pageSize = 30
- const detail = ref<FindAgentV2Detail | null>(null)
- const selectedId = ref('')
- const demandWord = ref('')
- const keyword = ref('')
- const statusFilter = ref('')
- const candidateFilter = ref('all')
- const candidates = ref<Row[]>([])
- const candidatesLoaded = ref(false)
- const candidatesLoading = ref(false)
- const expandedSearchIds = ref<number[]>([])
- const searchCandidates = ref<Record<number, Row[]>>({})
- const searchCandidatesLoading = ref<Record<number, boolean>>({})
- const tab = ref<Tab>('journey')
- const loading = ref(false)
- const submitting = ref(false)
- const error = ref('')
- let poll: number | undefined
- let detailRequestId = 0
- const run = computed(() => detail.value?.run || null)
- const obagentUrl = computed(() => run.value?.obagent_run_uid
- ? `http://ob.aiddit.com/#client_uid=${run.value.obagent_run_uid}` : '')
- const filteredCandidates = computed(() => {
- const items = candidates.value
- const filtered = candidateFilter.value === 'all'
- ? items
- : items.filter((item) => item.decision_bucket === candidateFilter.value)
- const bucketOrder: Record<string, number> = {
- primary: 0,
- pending_evaluation: 1,
- rejected: 2,
- }
- return filtered
- .map((item, index) => ({ item, index }))
- .sort((left, right) => (
- (bucketOrder[left.item.decision_bucket] ?? 1)
- - (bucketOrder[right.item.decision_bucket] ?? 1)
- || left.index - right.index
- ))
- .map(({ item }) => item)
- })
- const taskPayload = computed<Row>(() => {
- const stored = (detail.value?.run.input as Row | undefined)?.user_input
- if (typeof stored === 'string') {
- try { return JSON.parse(stored) as Row } catch { return { task: stored } }
- }
- return (detail.value?.run.input as Row) || {}
- })
- const demandContext = computed<Row>(() => taskPayload.value.demand || {})
- const referenceVideos = computed<Row[]>(() => Array.isArray(taskPayload.value.reference_videos)
- ? taskPayload.value.reference_videos : [])
- const ruleRows = computed(() => {
- const rules = (detail.value?.run.rule_config as Row) || {}
- return [
- { name: '最短视频时长', value: `${rules.min_duration_seconds ?? '—'} 秒`, note: '低于该时长的视频不能通过硬性门槛' },
- { name: '最低分享量', value: `${formatCount(rules.min_share_count)}`, note: '分享量需达到该数值,强评分候选可按补偿规则处理' },
- { name: '内容侧 50+ 占比', value: formatRatio(rules.min_content_50_plus_ratio), note: '视频内容受众中 50 岁以上人群的最低比例' },
- { name: '账号侧 50+ 占比', value: formatRatio(rules.min_account_50_plus_ratio), note: '作者账号受众中 50 岁以上人群的最低比例' },
- { name: '事件内容有效期', value: `${rules.event_max_age_days ?? '—'} 天`, note: '事件型内容超过该天数视为过期' },
- { name: '季节内容有效期', value: `${rules.seasonal_max_age_days ?? '—'} 天`, note: '季节型内容超过该天数视为过期' },
- { name: '判断时区', value: rules.timezone === 'Asia/Shanghai' ? '中国标准时间' : zh(rules.timezone), note: `规则版本:${rules.rule_version || '—'}` },
- { name: '规则生效时间', value: rules.current_datetime ? formatTime(rules.current_datetime) : '—', note: '本次任务固定使用该时间快照进行时效判断' },
- ]
- })
- const statusMap: Record<string, string> = {
- running: '运行中', finished: '已完成', failed: '失败', open: '进行中', done: '已完成',
- success: '成功', pending: '待处理', pending_evaluation: '待评估', primary: '通过推荐',
- rejected: '已淘汰', pass: '通过', partial: '部分命中', goal_met: '目标达成',
- no_match: '没有合格结果', planning: '规划中', searching: '搜索中', evidence: '补充证据',
- evaluating: '评估中', detail: '视频详情', portrait: '受众画像', web_admin: '管理员页面',
- cli: '命令行', internal_keyword: '内部关键词搜索', tikhub: 'TikHub 搜索',
- demand: '需求词', seed: '参考视频', point: '内容点位', mixed: '混合策略',
- inspiration: '灵感点', purpose: '目的点', key: '关键点',
- aligned: '内容与账号画像一致', conflict: '内容与账号画像不一致', account_only: '仅有账号画像',
- content_only: '仅有内容画像', missing: '暂无画像', strong: '高度匹配', moderate: '中度匹配', weak: '匹配偏弱',
- older: '50 岁以上', mature: '40—49 岁', younger: '40 岁以下', crawler: '视频详情服务', douhot: '画像服务',
- }
- const reasonMap: Record<string, string> = {
- TEMPORAL_UNKNOWN: '发布时间不明确', RELATIVE_DATE_EXPIRED: '相对日期内容已过期',
- EVENT_EXPIRED: '事件内容已过时', SEASONAL_EXPIRED: '季节性内容已过时',
- DURATION_UNKNOWN: '视频时长缺失', DURATION_TOO_SHORT: '视频时长不足',
- SHARE_COUNT_UNKNOWN: '分享量缺失', SHARE_COUNT_TOO_LOW: '分享量未达到要求',
- CONTENT_PORTRAIT_MISSING: '缺少受众年龄画像',
- PORTRAIT_50_PLUS_TOO_LOW: '50 岁以上受众占比未达到要求',
- }
- onMounted(async () => {
- await loadRuns()
- poll = window.setInterval(() => {
- if (run.value?.status === 'running') void refresh(false)
- }, 5000)
- })
- onBeforeUnmount(() => poll && window.clearInterval(poll))
- async function loadRuns(preferred = '', showLoading = true, preserveDetail = false) {
- if (showLoading) loading.value = true
- error.value = ''
- try {
- const response = await fetchFindAgentV2Runs({
- status: statusFilter.value, keyword: keyword.value,
- page: currentPage.value, pageSize,
- })
- runs.value = response.items
- totalRuns.value = response.total
- currentPage.value = response.page
- totalPages.value = response.total_pages
- hasPreviousPage.value = response.has_previous
- hasNextPage.value = response.has_next
- const selectedOnPage = response.items.some((item) => item.run_id === selectedId.value)
- const target = preferred || (selectedOnPage ? selectedId.value : '')
- || response.items[0]?.run_id || ''
- if (target) await selectRun(target, false, preserveDetail)
- else detail.value = null
- } catch (cause) {
- error.value = cause instanceof Error ? cause.message : String(cause)
- } finally { if (showLoading) loading.value = false }
- }
- async function selectRun(runId: string, showLoading = true, preserveDetail = false) {
- const requestId = ++detailRequestId
- if (showLoading) loading.value = true
- selectedId.value = runId
- if (!preserveDetail) {
- detail.value = null
- candidates.value = []
- candidatesLoaded.value = false
- candidatesLoading.value = false
- expandedSearchIds.value = []
- searchCandidates.value = {}
- searchCandidatesLoading.value = {}
- }
- error.value = ''
- try {
- const response = await fetchFindAgentV2Detail(runId)
- if (requestId === detailRequestId) {
- detail.value = response
- if (preserveDetail) {
- const availableIds = new Set(response.searches.map((item) => Number(item.id)))
- const openSearchIds = expandedSearchIds.value.filter((id) => availableIds.has(id))
- const retainedSearchCandidates = Object.fromEntries(
- openSearchIds
- .filter((id) => Object.prototype.hasOwnProperty.call(searchCandidates.value, id))
- .map((id) => [id, searchCandidates.value[id]]),
- )
- candidates.value = []
- candidatesLoaded.value = false
- candidatesLoading.value = false
- expandedSearchIds.value = openSearchIds
- searchCandidates.value = retainedSearchCandidates
- searchCandidatesLoading.value = {}
- if (tab.value === 'candidates') void changeTab('candidates')
- openSearchIds.forEach((id) => void loadSearchCandidates(id, false))
- }
- }
- } catch (cause) {
- if (requestId === detailRequestId) {
- error.value = cause instanceof Error ? cause.message : String(cause)
- }
- } finally {
- if (showLoading && requestId === detailRequestId) loading.value = false
- }
- }
- async function refresh(showLoading = true) {
- await loadRuns(selectedId.value, showLoading, !showLoading)
- }
- async function applyFilters() {
- currentPage.value = 1
- await loadRuns()
- }
- async function changePage(nextPage: number) {
- if (nextPage < 1 || nextPage > totalPages.value || nextPage === currentPage.value) return
- currentPage.value = nextPage
- await loadRuns()
- }
- async function changeTab(next: Tab) {
- tab.value = next
- if (next !== 'candidates' || candidatesLoaded.value || candidatesLoading.value) return
- candidatesLoading.value = true
- const runId = selectedId.value
- try {
- const response = await fetchFindAgentV2Candidates(runId)
- if (selectedId.value === runId) {
- candidates.value = response.items
- candidatesLoaded.value = true
- }
- } catch (cause) {
- error.value = cause instanceof Error ? cause.message : String(cause)
- } finally { candidatesLoading.value = false }
- }
- function isSearchExpanded(searchId: number) {
- return expandedSearchIds.value.includes(searchId)
- }
- async function toggleSearch(searchId: number) {
- if (isSearchExpanded(searchId)) {
- expandedSearchIds.value = expandedSearchIds.value.filter((id) => id !== searchId)
- return
- }
- expandedSearchIds.value = [...expandedSearchIds.value, searchId]
- if (Object.prototype.hasOwnProperty.call(searchCandidates.value, searchId)) return
- await loadSearchCandidates(searchId)
- }
- async function loadSearchCandidates(searchId: number, showLoading = true) {
- if (showLoading) {
- searchCandidatesLoading.value = { ...searchCandidatesLoading.value, [searchId]: true }
- }
- const runId = selectedId.value
- try {
- const response = await fetchFindAgentV2SearchCandidates(runId, searchId)
- if (selectedId.value === runId) {
- searchCandidates.value = { ...searchCandidates.value, [searchId]: response.items }
- }
- } catch (cause) {
- error.value = cause instanceof Error ? cause.message : String(cause)
- } finally {
- if (showLoading) {
- searchCandidatesLoading.value = { ...searchCandidatesLoading.value, [searchId]: false }
- }
- }
- }
- async function submitTest() {
- if (!demandWord.value.trim() || submitting.value) return
- submitting.value = true
- error.value = ''
- try {
- const response = await createFindAgentV2Test(demandWord.value.trim())
- demandWord.value = ''
- keyword.value = ''
- statusFilter.value = ''
- currentPage.value = 1
- await loadRuns(response.run_id)
- } catch (cause) {
- error.value = cause instanceof Error ? cause.message : String(cause)
- } finally { submitting.value = false }
- }
- function zh(value: unknown, fallback = '—') {
- if (value === null || value === undefined || value === '') return fallback
- return statusMap[String(value)] || String(value)
- }
- function zhReason(value: unknown) {
- const key = String(value || '')
- return reasonMap[key] || key || '暂无原因'
- }
- function formatTime(value: string | null) {
- return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
- }
- function formatRatio(value: unknown) {
- return typeof value === 'number' ? `${(value * 100).toFixed(1)}%` : '—'
- }
- function formatCount(value: unknown) {
- return typeof value === 'number' ? value.toLocaleString('zh-CN') : '—'
- }
- function formatScore(value: unknown) {
- return typeof value === 'number' ? value.toFixed(2) : '—'
- }
- function gateReasons(candidate: Row): string[] {
- const values = candidate.gate_result?.failed_reason_codes
- return Array.isArray(values) ? values.map(zhReason) : []
- }
- function planForRound(roundIndex: number) {
- return detail.value?.rounds.find((item) => item.round_index === roundIndex)?.plan || {}
- }
- function portraitNormalization(candidate: Row): Row {
- return candidate.portrait?.age_normalization || {}
- }
- function portraitBuckets(candidate: Row, side: 'content' | 'account'): Row[] {
- const buckets = portraitNormalization(candidate)?.[side]?.buckets
- return Array.isArray(buckets) ? buckets : []
- }
- function evidenceName(type: unknown) {
- return String(type) === 'detail' ? '视频详情证据' : String(type) === 'portrait' ? '受众画像证据' : zh(type, '其他证据')
- }
- </script>
- <template>
- <section class="page">
- <header class="slim-head">寻找 Agent V2 · 运行审计</header>
- <div v-if="error" class="alert">{{ error }}</div>
- <div class="shell" :class="{ 'is-loading': loading }">
- <div v-if="loading" class="loading-mask" role="status" aria-live="polite">
- <span class="spinner" aria-hidden="true" />
- <b>正在切换运行数据…</b>
- </div>
- <aside class="left-column">
- <form class="launch compact" @submit.prevent="submitTest">
- <span>测试任务</span>
- <input v-model.trim="demandWord" maxlength="256" placeholder="输入需求词" required />
- <button :disabled="submitting">{{ submitting ? '创建中…' : '开始执行' }}</button>
- <small>每次生成独立记录</small>
- </form>
- <section class="sidebar-card">
- <div class="list-title"><strong>执行记录</strong><span>共 {{ totalRuns }} 条</span></div>
- <div class="filters">
- <input v-model.trim="keyword" placeholder="需求词 / 任务号 / 执行人" @keyup.enter="applyFilters()" />
- <div><select v-model="statusFilter" @change="applyFilters()"><option value="">全部状态</option><option value="running">运行中</option><option value="finished">已完成</option><option value="failed">失败</option></select><button @click="applyFilters()">筛选</button></div>
- </div>
- <div class="run-list">
- <button v-for="item in runs" :key="item.run_id" class="run-item" :class="{ active: item.run_id === selectedId }" :disabled="loading" @click="selectRun(item.run_id)">
- <span class="run-title"><strong>{{ item.demand_word }}</strong><i :class="`tone-${item.status}`">{{ zh(item.status) }}</i></span>
- <small class="run-id">{{ item.run_id }}</small>
- <span class="mini-stats"><b>{{ item.search_count }}<small>次搜索</small></b><b>{{ item.candidate_count }}<small>个候选</small></b><b>{{ item.valid_primary_count }}<small>条通过</small></b></span>
- <span class="who">{{ item.triggered_by_username || '系统任务' }} · {{ formatTime(item.create_time) }}</span>
- </button>
- <div v-if="!runs.length && !loading" class="empty">暂无执行记录</div>
- </div>
- <nav class="list-pagination" aria-label="执行记录分页">
- <button :disabled="loading || !hasPreviousPage" @click="changePage(currentPage - 1)">上一页</button>
- <span>第 {{ currentPage }} / {{ totalPages }} 页</span>
- <button :disabled="loading || !hasNextPage" @click="changePage(currentPage + 1)">下一页</button>
- </nav>
- </section>
- </aside>
- <main v-if="detail" class="content">
- <section class="task-strip">
- <div><b>{{ run?.demand_word }}</b><span>{{ zh(run?.status) }} · {{ zh(run?.outcome_status, '等待结论') }} · 搜索 {{ run?.search_count }} 次 · 候选 {{ run?.candidate_count }} 条 · 通过 {{ run?.valid_primary_count }} 条</span></div>
- <small>{{ run?.triggered_by_username || '系统' }} · {{ formatTime(run?.create_time || null) }}</small>
- <div class="actions"><button @click="refresh()">刷新</button><a v-if="obagentUrl" :href="obagentUrl" target="_blank">Agent 轨迹 ↗</a></div>
- </section>
- <nav class="tabs">
- <button :class="{ active: tab === 'journey' }" @click="changeTab('journey')"><span>01</span>搜索链路</button>
- <button :class="{ active: tab === 'candidates' }" @click="changeTab('candidates')"><span>02</span>候选评估</button>
- <button :class="{ active: tab === 'context' }" @click="changeTab('context')"><span>03</span>任务输入与规则</button>
- </nav>
- <section v-if="tab === 'journey'" class="journey">
- <div class="section-head search-legend"><div class="legend"><span class="dot primary" />通过推荐 <span class="dot rejected" />已淘汰 <span class="dot pending" />待评估</div></div>
- <article v-for="(search, index) in detail.searches" :key="search.id" class="search-block">
- <header class="search-head">
- <div class="sequence">{{ String(index + 1).padStart(2, '0') }}</div>
- <div class="search-title"><span>第 {{ search.round_index }} 轮 · {{ zh(search.provider) }}</span><h4>“{{ search.keyword }}”</h4><p>{{ search.query_reason }}</p></div>
- <div class="search-result"><i :class="`tone-${search.status}`">{{ zh(search.status) }}</i></div>
- </header>
- <div class="strategy-line"><span>策略来源:{{ zh(search.source_type) }}</span><span>第 {{ search.page_no }} 页</span><span>接口返回 {{ search.result_count }} 条</span><span>{{ search.has_more ? '还有下一页' : '本页已结束' }}</span><span>{{ formatTime(search.create_time) }}</span></div>
- <div class="search-summary">
- <span><b>{{ search.video_count }}</b><small>搜索到的视频</small></span>
- <span class="summary-primary"><b>{{ search.primary_count }}</b><small>通过</small></span>
- <span class="summary-rejected"><b>{{ search.rejected_count }}</b><small>拒绝</small></span>
- <span class="summary-pending"><b>{{ search.pending_count }}</b><small>待评估</small></span>
- <button class="expand-button" :aria-expanded="isSearchExpanded(search.id)" @click="toggleSearch(search.id)">
- {{ isSearchExpanded(search.id) ? '收起视频' : '展开视频' }}
- <span aria-hidden="true">{{ isSearchExpanded(search.id) ? '⌃' : '⌄' }}</span>
- </button>
- </div>
- <div v-if="isSearchExpanded(search.id) && searchCandidatesLoading[search.id]" class="inline-loading" role="status"><span class="spinner" />正在获取本次搜索的视频…</div>
- <div v-else-if="isSearchExpanded(search.id) && searchCandidates[search.id]?.length" class="hit-table">
- <div class="table-head"><span>命中视频</span><span>数据证据</span><span>四维评估</span><span>最终判断</span></div>
- <article v-for="candidate in searchCandidates[search.id]" :key="candidate.id" class="hit-row">
- <div class="video-cell"><a v-if="candidate.content_link" :href="candidate.content_link" target="_blank">{{ candidate.title || candidate.aweme_id }} ↗</a><strong v-else>{{ candidate.title || candidate.aweme_id }}</strong><small>{{ candidate.author_name || '未知作者' }} · {{ candidate.aweme_id }}</small></div>
- <div class="evidence-cell"><span>分享 {{ formatCount(candidate.share_count) }}</span><span>时长 {{ candidate.duration_seconds ?? '—' }} 秒</span><span>内容 50+:{{ formatRatio(candidate.content_50_plus_ratio) }}</span><span>账号 50+:{{ formatRatio(candidate.account_50_plus_ratio) }}</span></div>
- <div class="compact-scores"><span title="相关性:内容与需求的直接相关程度"><i>R · 相关性</i><b>{{ formatScore(candidate.relevance_score) }}</b></span><span title="中老年适配:内容和受众对 50+ 人群的适配程度"><i>E · 中老年适配</i><b>{{ formatScore(candidate.elder_score) }}</b></span><span title="传播力:用户愿意分享和传播的可能性"><i>S · 传播力</i><b>{{ formatScore(candidate.share_score) }}</b></span><span title="综合价值:综合内容价值和推荐价值"><i>V · 综合价值</i><b>{{ formatScore(candidate.value_score) }}</b></span></div>
- <div class="decision-cell"><i :class="`bucket-${candidate.decision_bucket}`">{{ zh(candidate.decision_bucket) }}</i><p>{{ candidate.decision_reason || '等待 Agent 评估' }}</p><small v-if="candidate.reject_reason_code">{{ zhReason(candidate.reject_reason_code) }}</small></div>
- </article>
- </div>
- <div v-else-if="isSearchExpanded(search.id)" class="no-hits">本次搜索没有可展示的视频{{ search.error_message ? `:${search.error_message}` : '' }}</div>
- </article>
- <article v-if="!detail.searches.length" class="empty-state"><b>等待首次搜索</b><p>Supervisor 完成规划后,搜索策略与结果会按执行顺序显示在这里。</p></article>
- <section class="rounds">
- <h3>Agent 轮次</h3>
- <article v-for="round in detail.rounds" :key="round.id">
- <span>第 {{ round.round_index }} 轮</span><b>{{ zh(round.phase) }} · {{ zh(round.status) }}</b><p>{{ planForRound(round.round_index)?.intent_summary || '本轮策略已记录' }}</p>
- <i v-if="round.error_message">{{ round.error_message }}</i>
- </article>
- </section>
- </section>
- <section v-else-if="tab === 'candidates'" class="candidate-section">
- <div class="section-head">
- <div><h3>候选视频评估</h3><p>Agent 四维评分与程序硬性门槛共同决定最终结果。</p></div>
- <select v-model="candidateFilter"><option value="all">全部候选</option><option value="primary">通过推荐</option><option value="rejected">已淘汰</option><option value="pending_evaluation">待评估</option></select>
- </div>
- <div class="score-legend"><b>评分说明</b><span><i>R</i>相关性</span><span><i>E</i>中老年适配</span><span><i>S</i>传播力</span><span><i>V</i>综合价值</span></div>
- <div v-if="candidatesLoading" class="inline-loading" role="status"><span class="spinner" />正在获取候选视频…</div>
- <div v-else class="candidate-list">
- <article v-for="candidate in filteredCandidates" :key="candidate.id" class="candidate" :class="`candidate-${candidate.decision_bucket}`">
- <header><div><i :class="`bucket-${candidate.decision_bucket}`">{{ zh(candidate.decision_bucket) }}</i><span>门槛判断:{{ zh(candidate.gate_status, '尚未判断') }}</span></div><a v-if="candidate.content_link" :href="candidate.content_link" target="_blank">打开视频 ↗</a></header>
- <h4>{{ candidate.title || candidate.aweme_id }}</h4><p class="author">{{ candidate.author_name || '未知作者' }} · 视频号 {{ candidate.aweme_id }}</p>
- <div class="candidate-scores" aria-label="候选四维评分"><span><i>R</i>{{ formatScore(candidate.relevance_score) }}</span><span><i>E</i>{{ formatScore(candidate.elder_score) }}</span><span><i>S</i>{{ formatScore(candidate.share_score) }}</span><span><i>V</i>{{ formatScore(candidate.value_score) }}</span></div>
- <div class="evidence-strip"><span><small>分享量</small>{{ formatCount(candidate.share_count) }}</span><span><small>视频时长</small>{{ candidate.duration_seconds ?? '—' }} 秒</span><span><small>内容 50+ 占比</small>{{ formatRatio(candidate.content_50_plus_ratio) }}</span><span><small>账号 50+ 占比</small>{{ formatRatio(candidate.account_50_plus_ratio) }}</span><span><small>详情状态</small>{{ zh(candidate.detail_status) }}</span><span><small>画像状态</small>{{ zh(candidate.portrait_status) }}</span></div>
- <div class="decision-box"><div><b>Agent 评估意见</b><p>{{ candidate.decision_reason || '尚未生成评估意见' }}</p></div><div class="gate-box"><b>硬性门槛结论</b><p v-if="candidate.reject_reason_code">{{ zhReason(candidate.reject_reason_code) }}</p><div class="reason-tags"><span v-for="reason in gateReasons(candidate)" :key="reason" class="gate-failed">{{ reason }}</span><span v-if="!gateReasons(candidate).length" :class="candidate.gate_status === 'pass' ? 'gate-passed' : 'gate-pending'">{{ candidate.gate_status === 'pass' ? '所有硬性门槛均已通过' : '等待门槛判断' }}</span></div></div></div>
- <details class="evidence-details">
- <summary>查看详情、画像与完整证据({{ candidate.evidence.length }} 条)</summary>
- <div class="evidence-content">
- <section class="readable-panel">
- <h5>视频详情 <i :class="`tone-${candidate.detail_status}`">{{ zh(candidate.detail_status) }}</i></h5>
- <div class="detail-fields"><span><small>发布时间</small>{{ formatTime(candidate.publish_at) }}</span><span><small>视频时长</small>{{ candidate.duration_seconds ?? '—' }} 秒</span><span><small>播放量</small>{{ formatCount(candidate.play_count) }}</span><span><small>点赞量</small>{{ formatCount(candidate.like_count) }}</span><span><small>评论量</small>{{ formatCount(candidate.comment_count) }}</span><span><small>收藏量</small>{{ formatCount(candidate.collect_count) }}</span><span><small>分享量</small>{{ formatCount(candidate.share_count) }}</span><span><small>作者</small>{{ candidate.author_name || '—' }}</span></div>
- <div v-if="candidate.tags?.length" class="content-tags"><b>内容标签</b><span v-for="tag in candidate.tags" :key="String(tag)">{{ typeof tag === 'object' ? (tag.name || tag.title || '未命名标签') : tag }}</span></div>
- <p v-if="candidate.detail?.body_text" class="body-text"><b>视频文案</b>{{ candidate.detail.body_text }}</p>
- </section>
- <section class="readable-panel">
- <h5>受众画像 <i :class="`tone-${candidate.portrait_status}`">{{ zh(candidate.portrait_status) }}</i></h5>
- <div class="portrait-summary"><span><small>内容 50+ 占比</small><b>{{ formatRatio(candidate.content_50_plus_ratio) }}</b></span><span><small>账号 50+ 占比</small><b>{{ formatRatio(candidate.account_50_plus_ratio) }}</b></span><span><small>画像一致性</small><b>{{ zh(portraitNormalization(candidate).consistency) }}</b></span><span><small>评分上限</small><b>{{ formatScore(portraitNormalization(candidate).elder_score_cap) }}</b></span></div>
- <div class="portrait-sides">
- <div><b>内容受众年龄分布</b><p v-if="!portraitBuckets(candidate, 'content').length">暂无年龄分布</p><span v-for="bucket in portraitBuckets(candidate, 'content')" :key="bucket.label"><i>{{ bucket.label }}</i><b>{{ formatRatio(bucket.ratio) }}</b><small>{{ zh(bucket.kind) }}<template v-if="bucket.tgi != null"> · 偏好指数 {{ formatScore(bucket.tgi) }}</template></small></span></div>
- <div><b>账号受众年龄分布</b><p v-if="!portraitBuckets(candidate, 'account').length">暂无年龄分布</p><span v-for="bucket in portraitBuckets(candidate, 'account')" :key="bucket.label"><i>{{ bucket.label }}</i><b>{{ formatRatio(bucket.ratio) }}</b><small>{{ zh(bucket.kind) }}<template v-if="bucket.tgi != null"> · 偏好指数 {{ formatScore(bucket.tgi) }}</template></small></span></div>
- </div>
- </section>
- <section class="readable-panel evidence-records">
- <h5>证据记录</h5>
- <article v-for="item in candidate.evidence" :key="item.id"><div><b>{{ evidenceName(item.evidence_type) }}</b><span :class="`tone-${item.status}`">{{ zh(item.status) }}</span></div><p>来源:{{ zh(item.provider) }} · {{ formatTime(item.create_time) }}</p><p v-if="item.error_message" class="evidence-error">失败原因:{{ item.error_message }}</p><div v-if="item.evidence_type === 'portrait' && item.normalized" class="record-summary"><span>内容画像:{{ zh(item.normalized.content?.strength) }}</span><span>账号画像:{{ zh(item.normalized.account?.strength) }}</span><span>一致性:{{ zh(item.normalized.consistency) }}</span></div><div v-else-if="item.evidence_type === 'detail'" class="record-summary"><span>已获取视频基础信息与传播数据</span></div></article>
- <p v-if="!candidate.evidence.length" class="no-record">暂无证据记录</p>
- </section>
- </div>
- </details>
- </article>
- </div>
- <div v-if="candidatesLoaded && !filteredCandidates.length" class="empty-state"><b>没有符合当前筛选的候选</b></div>
- </section>
- <section v-else class="context-view">
- <article class="input-card">
- <h3>任务输入</h3>
- <div class="input-summary"><span><small>需求词</small><b>{{ demandContext.demand_word || run?.demand_word }}</b></span><span><small>需求等级</small><b>{{ demandContext.grade || '—' }}</b></span><span><small>业务日期</small><b>{{ demandContext.biz_dt || '—' }}</b></span><span><small>需求评分</small><b>{{ demandContext.score ?? '—' }}</b></span></div>
- <div class="task-purpose"><small>执行目标</small><p>{{ taskPayload.task || '根据需求语义和参考视频点位,寻找新的高价值抖音候选视频。' }}</p></div>
- <h4>参考视频与点位({{ referenceVideos.length }} 个视频)</h4>
- <div class="reference-list"><article v-for="video in referenceVideos" :key="video.video_id"><b>{{ video.title || '未命名参考视频' }}</b><small>{{ video.video_id }}</small><ul><li v-for="(point, index) in video.points || []" :key="index"><span>{{ zh(point.point_type, '内容点') }}</span><b>{{ point.point }}</b><p v-if="point.point_desc">{{ point.point_desc }}</p></li></ul></article></div>
- </article>
- <article class="rules-card">
- <h3>质量门槛规则</h3><p>候选申请“通过推荐”后,程序仍会按以下硬性规则复核。</p>
- <table><thead><tr><th>规则项</th><th>当前门槛</th><th>说明</th></tr></thead><tbody><tr v-for="row in ruleRows" :key="row.name"><td>{{ row.name }}</td><td><b>{{ row.value }}</b></td><td>{{ row.note }}</td></tr></tbody></table>
- </article>
- </section>
- </main>
- <main v-else class="content empty-state"><b>请选择一条执行记录</b><p>选择后可查看完整搜索、命中视频和评估链路。</p></main>
- </div>
- </section>
- </template>
- <style scoped>
- .page{--ink:#1d2735;--muted:#6e7b8d;--line:#e4e8ed;--paper:#fff;--green:#177f68;--navy:#203a5c;display:grid;gap:18px;color:var(--ink)}.page-head{display:grid;grid-template-columns:1fr minmax(420px,560px);gap:28px;align-items:center;padding:28px 30px;border:1px solid #dfe5eb;border-radius:18px;background:linear-gradient(135deg,#fff 0%,#f4f8f8 65%,#eef5f3 100%);box-shadow:0 10px 30px #1f34490a}.kicker{color:var(--green);font-size:12px;font-weight:800;letter-spacing:.12em}.page-head h1{margin:6px 0 5px;font-family:"Noto Serif SC","Songti SC",serif;font-size:30px}.page-head p{margin:0;color:var(--muted)}.launch{padding:17px 18px;border:1px solid #d5e3df;border-radius:13px;background:#ffffffd9}.launch>span{display:block;margin-bottom:9px;font-weight:800}.launch-row{display:flex;gap:9px}.launch input,.filters input,.filters select,.candidate-section select{min-width:0;border:1px solid #cfd7df;border-radius:8px;background:white;padding:10px 12px;color:var(--ink)}.launch input{flex:1}.launch button,.filters button,.actions button{border:0;border-radius:8px;background:var(--navy);padding:10px 16px;color:white;font-weight:700;cursor:pointer}.launch small{display:block;margin-top:8px;color:#8792a0}.alert{padding:11px 14px;border:1px solid #f1c7c7;border-radius:10px;background:#fff4f3;color:#a43b37}.shell{display:grid;grid-template-columns:310px minmax(0,1fr);gap:16px;min-height:720px}.sidebar-card,.content{border:1px solid var(--line);border-radius:16px;background:var(--paper);box-shadow:0 5px 18px #24384e08;overflow:hidden}.list-title{display:flex;justify-content:space-between;padding:17px 18px;border-bottom:1px solid var(--line)}.list-title span{color:var(--muted);font-size:12px}.filters{display:grid;gap:8px;padding:12px;background:#f7f9fa}.filters>div{display:flex;gap:8px}.filters select{flex:1}.run-list{max-height:calc(100vh - 310px);overflow:auto}.run-item{display:grid;width:100%;gap:8px;padding:15px 17px;border:0;border-bottom:1px solid #edf0f2;background:white;text-align:left;cursor:pointer;transition:.2s}.run-item:hover{background:#f7faf9}.run-item.active{background:#eff7f5;box-shadow:inset 4px 0 var(--green)}.run-title{display:flex;align-items:center;justify-content:space-between;gap:8px}.run-title strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.run-title i,.search-result i,.decision-cell>i,.candidate header i{border-radius:999px;padding:4px 8px;font-size:11px;font-style:normal;font-weight:700}.tone-running{background:#fff1c7;color:#856000}.tone-finished,.tone-success{background:#dff2eb;color:#106b55}.tone-failed{background:#fbe1df;color:#9c3732}.run-id{overflow:hidden;color:#97a0ad;text-overflow:ellipsis}.mini-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:6px}.mini-stats b{font-size:15px}.mini-stats small{display:block;color:#85909e;font-size:10px;font-weight:400}.who{color:#8994a2;font-size:11px}.content{padding:22px;overflow:auto}.overview{display:grid;grid-template-columns:1fr auto;gap:16px}.task-id{color:var(--green);font:11px ui-monospace,monospace}.overview h2{margin:5px 0;font-family:"Noto Serif SC","Songti SC",serif;font-size:26px}.overview-copy>p{margin:0;color:var(--muted)}.audit{display:flex;align-items:center;gap:8px;margin-top:13px;color:#7b8796;font-size:12px}.audit span{width:3px;height:3px;border-radius:50%;background:#a8b0ba}.actions{display:flex;gap:8px}.actions a,.actions button{height:max-content;border-radius:8px;padding:9px 12px;text-decoration:none}.actions a{background:#eaf4f1;color:#156e5c}.metric-row{grid-column:1/-1;display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-top:5px}.metric-row article{padding:13px 14px;border:1px solid #e6e9ed;border-radius:10px;background:#fafbfc}.metric-row small{display:block;color:#7d8997}.metric-row strong{font-size:18px}.metric-row em{color:#7d8997;font-size:11px;font-style:normal}.metric-row .positive{border-color:#cde4dd;background:#f1f8f6}.metric-row .positive strong{color:var(--green)}.tabs{display:flex;gap:28px;margin:23px 0 20px;border-bottom:1px solid var(--line)}.tabs button{position:relative;border:0;background:transparent;padding:11px 2px;color:#758192;cursor:pointer}.tabs button span{margin-right:6px;color:#a2abb7;font:11px ui-monospace,monospace}.tabs button.active{color:var(--ink);font-weight:800}.tabs button.active:after{position:absolute;right:0;bottom:-1px;left:0;height:3px;background:var(--green);content:""}.section-head{display:flex;align-items:end;justify-content:space-between;gap:20px;margin-bottom:14px}.section-head h3{margin:0 0 4px;font-size:18px}.section-head p{margin:0;color:var(--muted);font-size:13px}.legend{display:flex;align-items:center;gap:6px;color:#748090;font-size:11px}.dot{width:8px;height:8px;border-radius:50%}.dot.primary{background:#1c9a77}.dot.rejected{background:#d45c56}.dot.pending{background:#d9a532}.search-block{margin-bottom:14px;border:1px solid #dfe4e9;border-radius:13px;background:#fff;overflow:hidden}.search-head{display:grid;grid-template-columns:45px 1fr 110px;gap:14px;align-items:center;padding:16px 17px}.sequence{display:grid;width:42px;height:42px;place-items:center;border-radius:10px;background:#203a5c;color:#fff;font:700 13px ui-monospace,monospace}.search-title span{color:var(--green);font-size:11px;font-weight:700}.search-title h4{margin:3px 0;font-size:17px}.search-title p{margin:0;color:#687689}.search-result{text-align:right}.search-result strong{display:block;font-size:22px}.search-result small{display:block;color:#818d9c}.search-result i{display:inline-block;margin-top:5px}.strategy-line{display:flex;flex-wrap:wrap;gap:15px;padding:9px 17px;border-block:1px solid #edf0f2;background:#f8f9fa;color:#738091;font-size:11px}.hit-table{padding:0 17px 12px}.table-head,.hit-row{display:grid;grid-template-columns:1.5fr 1.1fr 1fr 1.25fr;gap:12px;align-items:center}.table-head{padding:11px 8px 8px;color:#8b95a2;font-size:11px}.hit-row{padding:12px 8px;border-top:1px solid #edf0f2}.video-cell{min-width:0}.video-cell a,.video-cell>strong{display:block;overflow:hidden;color:#203a5c;font-weight:700;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.video-cell small{color:#8993a0}.evidence-cell{display:grid;grid-template-columns:1fr 1fr;gap:4px;color:#627083;font-size:11px}.compact-scores{display:grid;grid-template-columns:1fr 1fr;gap:4px}.compact-scores span{display:flex;justify-content:space-between;border-radius:5px;background:#eff3f6;padding:4px 6px;font-size:11px}.compact-scores i{color:var(--green);font-style:normal;font-weight:800}.decision-cell>i{display:inline-block}.bucket-primary{background:#dcf2ea;color:#087058}.bucket-rejected{background:#f8e2df;color:#a03e37}.bucket-pending_evaluation{background:#fff0c8;color:#896300}.decision-cell p{margin:6px 0 2px;color:#536173;font-size:11px}.decision-cell small{color:#b34a43}.no-hits{padding:24px;text-align:center;color:#87919e}.technical{border-top:1px solid #edf0f2;padding:10px 17px;color:#718093;font-size:12px}.technical summary,.candidate details summary{cursor:pointer}.rounds{margin-top:22px}.rounds>article{display:grid;grid-template-columns:90px 160px 1fr;gap:12px;padding:11px 2px;border-bottom:1px solid #edf0f2;font-size:12px}.rounds p{margin:0;color:#697687}.rounds i{grid-column:2/-1;color:#a7433d}.score-guide{display:grid;grid-template-columns:repeat(4,1fr);gap:9px;margin-bottom:14px}.score-guide article{display:flex;gap:10px;padding:13px;border:1px solid #dfe5e9;border-radius:10px;background:#f8faf9}.score-guide i{display:grid;flex:0 0 32px;height:32px;place-items:center;border-radius:8px;background:var(--navy);color:white;font-style:normal;font-weight:800}.score-guide b{display:block;font-size:13px}.score-guide p{margin:3px 0 0;color:#7a8694;font-size:11px}.candidate-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.candidate{padding:16px;border:1px solid #dfe4e9;border-top:4px solid #d9a532;border-radius:12px;background:#fff}.candidate-primary{border-top-color:#1c9a77}.candidate-rejected{border-top-color:#d45c56}.candidate header{display:flex;justify-content:space-between}.candidate header>div{display:flex;align-items:center;gap:7px}.candidate header span{color:#7e8997;font-size:11px}.candidate header a{color:var(--green);font-size:12px;text-decoration:none}.candidate h4{margin:12px 0 3px;font-size:17px}.author{margin:0;color:#8792a0;font-size:12px}.score-cards{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:13px}.score-cards article{display:flex;justify-content:space-between;padding:8px 10px;border-radius:7px;background:#edf2f4}.score-cards span{font-size:11px}.evidence-strip{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:9px}.evidence-strip>span{padding:7px;border:1px solid #e9ecef;border-radius:7px;font-size:12px}.evidence-strip small{display:block;color:#8993a0;font-size:10px}.decision-box{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px}.decision-box>div{padding:10px;border-radius:8px;background:#f3f6f7}.decision-box .gate-box{background:#fff7e9}.decision-box b{font-size:12px}.decision-box p{margin:5px 0;color:#596779;font-size:12px;white-space:pre-wrap}.reason-tags{display:flex;flex-wrap:wrap;gap:4px}.reason-tags span{border-radius:5px;background:#f5dfdc;padding:4px 6px;color:#993b34;font-size:10px}.candidate details{margin-top:10px;color:#687689;font-size:12px}.context-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.context-grid article{position:relative;padding:16px;border:1px solid #e0e5e9;border-radius:12px}.context-grid>article>span{color:var(--green);font:11px ui-monospace,monospace}.context-grid h3{margin:4px 0 10px}.empty,.empty-state{padding:35px;text-align:center;color:#87919e}.empty-state b{display:block;color:#465467}.empty-state p{margin:7px 0}.page pre{max-height:420px;overflow:auto;padding:12px;border-radius:8px;background:#182332;color:#c7ddd8;font:11px/1.55 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere}@media(max-width:1200px){.shell{grid-template-columns:270px minmax(0,1fr)}.candidate-list{grid-template-columns:1fr}.table-head,.hit-row{grid-template-columns:1.3fr 1fr 1fr}.table-head span:nth-child(2),.evidence-cell{display:none}}@media(max-width:900px){.page-head{grid-template-columns:1fr}.shell{grid-template-columns:1fr}.run-list{max-height:380px}.metric-row{grid-template-columns:repeat(3,1fr)}.score-guide{grid-template-columns:1fr 1fr}.context-grid{grid-template-columns:1fr}}@media(max-width:620px){.content{padding:15px}.overview{grid-template-columns:1fr}.metric-row{grid-template-columns:1fr 1fr}.search-head{grid-template-columns:38px 1fr}.search-result{grid-column:2;text-align:left}.table-head{display:none}.hit-row{grid-template-columns:1fr}.compact-scores,.decision-cell{margin-top:5px}.score-guide{grid-template-columns:1fr}.decision-box{grid-template-columns:1fr}.evidence-strip{grid-template-columns:1fr 1fr}.tabs{gap:10px}}
- .slim-head{padding:2px 2px 12px;border-bottom:1px solid var(--line);font-size:20px;font-weight:800;letter-spacing:.01em}.left-column{display:flex;min-width:0;flex-direction:column;gap:12px}.launch.compact{display:grid;grid-template-columns:1fr auto;gap:8px;padding:13px;border-radius:14px;background:#fff;box-shadow:0 5px 18px #24384e08}.launch.compact>span,.launch.compact>small{grid-column:1/-1;margin:0}.launch.compact>span{font-size:14px}.launch.compact>small{font-size:10px}.launch.compact input{width:100%;box-sizing:border-box;padding:9px 10px}.launch.compact button{padding:9px 12px}.sidebar-card{flex:1}.task-strip{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:14px;align-items:center;padding:11px 13px;border:1px solid #e0e6ea;border-radius:10px;background:#f8faf9}.task-strip>div:first-child{min-width:0}.task-strip b{display:block;overflow:hidden;font-size:15px;text-overflow:ellipsis;white-space:nowrap}.task-strip span,.task-strip>small{color:var(--muted);font-size:11px}.task-strip .actions{white-space:nowrap}.tabs{margin:14px 0 18px}.score-legend{display:flex;align-items:center;justify-content:flex-end;gap:10px;margin:-4px 0 10px;color:#718092;font-size:10px}.score-legend b{font-size:10px}.score-legend span{display:flex;align-items:center;gap:3px}.score-legend i,.candidate-scores i{color:var(--green);font-style:normal;font-weight:900}.candidate-scores{display:flex;justify-content:flex-end;gap:5px;margin-top:8px}.candidate-scores span{display:flex;align-items:center;gap:4px;border:1px solid #e0e6e8;border-radius:6px;background:#f6f8f8;padding:3px 6px;color:#4f5e6f;font-size:10px;font-weight:700}.context-view{display:grid;gap:14px}.input-card,.rules-card{padding:18px;border:1px solid var(--line);border-radius:12px;background:#fff}.input-card h3,.rules-card h3{margin:0 0 12px;font-size:17px}.input-card h4{margin:18px 0 10px;font-size:14px}.input-summary{display:grid;grid-template-columns:2fr repeat(3,1fr);gap:8px}.input-summary span{padding:10px;border:1px solid #e7ebee;border-radius:8px;background:#f8faf9}.input-summary small,.task-purpose small{display:block;margin-bottom:3px;color:#83909e;font-size:10px}.input-summary b{font-size:13px}.task-purpose{margin-top:9px;padding:11px 12px;border-left:3px solid var(--green);background:#f3f8f6}.task-purpose p{margin:0;color:#506071;font-size:12px;white-space:pre-wrap}.reference-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.reference-list>article{min-width:0;padding:12px;border:1px solid #e4e9ec;border-radius:9px}.reference-list>article>b{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.reference-list>article>small{color:#909aa6}.reference-list ul{display:grid;gap:6px;margin:10px 0 0;padding:0;list-style:none}.reference-list li{display:grid;grid-template-columns:auto 1fr;gap:5px 8px;padding:8px;border-radius:7px;background:#f6f8f9}.reference-list li span{height:max-content;border-radius:4px;background:#e1eee9;padding:2px 5px;color:#176a58;font-size:9px}.reference-list li b{font-size:11px}.reference-list li p{grid-column:2;margin:0;color:#748191;font-size:10px}.formatted-json{margin-top:14px;color:#667587;font-size:12px}.formatted-json h4{margin-bottom:8px}.rules-card>p{margin:-7px 0 13px;color:var(--muted);font-size:12px}.rules-card table{width:100%;border-collapse:separate;border-spacing:0;overflow:hidden;border:1px solid #e1e6ea;border-radius:9px;font-size:12px}.rules-card th,.rules-card td{padding:10px 12px;border-bottom:1px solid #e7ebee;text-align:left}.rules-card th{background:#f2f6f5;color:#536273;font-size:11px}.rules-card tr:last-child td{border-bottom:0}.rules-card td:first-child{width:22%;font-weight:700}.rules-card td:nth-child(2){width:19%;color:var(--green)}.rules-card td:last-child{color:#6f7c8c}.page pre{box-sizing:border-box;width:100%}
- @media(max-width:900px){.left-column{display:grid;grid-template-columns:1fr}.task-strip{grid-template-columns:1fr auto}.task-strip>small{grid-column:1}.input-summary{grid-template-columns:1fr 1fr}.reference-list{grid-template-columns:1fr}}
- @media(max-width:620px){.task-strip{grid-template-columns:1fr}.task-strip>small{grid-column:auto}.score-legend{align-items:flex-start;justify-content:flex-start;flex-wrap:wrap}.input-summary{grid-template-columns:1fr}.rules-card{overflow-x:auto}.rules-card table{min-width:620px}}
- .evidence-details{margin-top:10px;border-top:1px solid #e8ecef;padding-top:10px}.evidence-details>summary{color:#526276;font-weight:700}.evidence-content{display:grid;gap:9px;margin-top:10px}.readable-panel{padding:12px;border:1px solid #e3e8eb;border-radius:9px;background:#fafbfb}.readable-panel h5{display:flex;align-items:center;gap:7px;margin:0 0 10px;font-size:13px}.readable-panel h5 i,.evidence-records article>div>span{border-radius:999px;padding:3px 7px;font-size:9px;font-style:normal}.detail-fields{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}.detail-fields>span{padding:7px;border-radius:6px;background:#fff;color:#4e5d6e;font-size:11px}.detail-fields small{display:block;color:#8a95a2;font-size:9px}.content-tags{display:flex;align-items:center;flex-wrap:wrap;gap:5px;margin-top:8px;font-size:10px}.content-tags span{border-radius:5px;background:#e9f2ef;padding:3px 6px;color:#176b59}.body-text{margin:8px 0 0;padding:8px;border-radius:6px;background:#fff;color:#596779;font-size:11px;white-space:pre-wrap}.body-text b{display:block;margin-bottom:3px;color:#344558}.portrait-summary{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}.portrait-summary span{padding:8px;border-radius:7px;background:#fff}.portrait-summary small{display:block;color:#8a95a2;font-size:9px}.portrait-summary b{font-size:11px}.portrait-sides{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px}.portrait-sides>div{padding:9px;border-radius:7px;background:#fff}.portrait-sides>div>b{font-size:11px}.portrait-sides>div>p{margin:7px 0 0;color:#8a95a2;font-size:10px}.portrait-sides>div>span{display:grid;grid-template-columns:1fr auto;gap:1px 7px;padding:5px 0;border-bottom:1px solid #edf0f2;font-size:10px}.portrait-sides>div>span:last-child{border:0}.portrait-sides i{font-style:normal}.portrait-sides small{grid-column:1/-1;color:#84909e;font-size:9px}.evidence-records article{padding:9px 0;border-bottom:1px solid #e5e9ec}.evidence-records article:last-of-type{border:0}.evidence-records article>div:first-child{display:flex;align-items:center;justify-content:space-between}.evidence-records article p{margin:4px 0 0;color:#7c8897;font-size:10px}.evidence-records .evidence-error{color:#a33f38}.record-summary{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px}.record-summary span{border-radius:5px;background:#eef3f4;padding:4px 6px;color:#506071;font-size:9px}.no-record{margin:0;color:#8a95a2;font-size:10px}
- @media(max-width:620px){.detail-fields,.portrait-summary{grid-template-columns:1fr 1fr}.portrait-sides{grid-template-columns:1fr}}
- .reason-tags .gate-failed{background:#f5dfdc;color:#993b34}.reason-tags .gate-passed{background:#dcf2ea;color:#087058}.reason-tags .gate-pending{background:#fff0c8;color:#896300}
- .search-title p{font-size:12px;line-height:1.5}
- .search-legend{justify-content:flex-end}
- .list-pagination{display:grid;grid-template-columns:auto 1fr auto;gap:8px;align-items:center;padding:10px 12px;border-top:1px solid var(--line);background:#f8faf9}.list-pagination span{text-align:center;color:#718092;font-size:11px}.list-pagination button{border:1px solid #d4dde2;border-radius:7px;background:#fff;padding:6px 9px;color:#405268;font-size:11px;cursor:pointer}.list-pagination button:disabled{cursor:not-allowed;opacity:.45}
- .shell{position:relative}.shell.is-loading{min-height:720px}.loading-mask{position:absolute;z-index:20;inset:0;display:flex;align-items:flex-start;justify-content:center;gap:10px;padding-top:180px;border-radius:16px;background:#f5f8f8d9;color:var(--navy);backdrop-filter:blur(2px)}.loading-mask b{font-size:13px}.spinner{display:inline-block;width:18px;height:18px;box-sizing:border-box;border:2px solid #bed4ce;border-top-color:var(--green);border-radius:50%;animation:spin .7s linear infinite}.inline-loading{display:flex;align-items:center;justify-content:center;gap:9px;min-height:86px;color:#718092;font-size:12px}.search-summary{display:grid;grid-template-columns:repeat(4,minmax(90px,130px)) 1fr;gap:8px;align-items:center;padding:13px 17px;background:#fff}.search-summary>span{display:flex;align-items:baseline;gap:6px;padding:8px 10px;border:1px solid #e7ebee;border-radius:8px;background:#f8faf9}.search-summary b{font-size:17px}.search-summary small{color:#788595}.search-summary .summary-primary b{color:#177f68}.search-summary .summary-rejected b{color:#b44a43}.search-summary .summary-pending b{color:#a3740d}.expand-button{justify-self:end;border:1px solid #cfddd9;border-radius:8px;background:#eef6f3;padding:8px 12px;color:#176d5a;font-weight:700;cursor:pointer}.expand-button span{margin-left:5px}.run-item:disabled{cursor:wait;opacity:.72}@keyframes spin{to{transform:rotate(360deg)}}
- @media(max-width:900px){.search-summary{grid-template-columns:repeat(2,1fr)}.expand-button{grid-column:1/-1;justify-self:stretch}}
- </style>
|