Files
fangke/admin/src/views/tracker/visitor/detail.vue
T
浅念 f9c8c354ae feat(admin): 来源归因展示——渠道徽章/筛选/来源识别卡/渠道构成图
- 类型:VisitorRow 补 channel/platform/variant/search_term/resolution,新增
  AttributionInfo/AttributionBlock,详情响应带 attribution 块;dashboard
  响应补 channels(ChannelItem)
- 工具:CHANNEL_LABELS 八渠道元数据 + channelBadge 徽章串(搜索词/标签/
  冲突后缀,未归因回 —)
- 访客列表:首触来源列升级渠道徽章(first_source 进 tooltip 兜底),
  筛选栏加渠道下拉(channel= 枚举)
- 访客详情:首触来源升级来源识别卡(渠道徽章/平台/国家站/搜索词显著
  展示,label_only/conflict 诚实标注,冲突并列两路证据文本渲染),
  无归因旧数据降级原 first_source 口径
- 总览:来源饼图改渠道构成(标签/颜色按渠道绑定 CHANNEL_LABELS),
  下方保留平台明细小表(原 sources 域名口径)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 17:19:31 +08:00

863 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 访客统计 · 访客详情(档案卡 + 跨天时间线 + 会话列表/抽屉 + 原始上报批次) -->
<!-- keepAlive=true 且路由 path 固定:访客切换复用同一实例,刷新依赖 onActivated + query watch -->
<template>
<div class="visitor-detail-page p-5">
<!-- 顶栏:返回 + 标题 -->
<div class="flex-c flex-wrap gap-3 mb-3">
<ElTooltip content="返回访客列表" placement="top">
<ElButton circle :icon="BackIcon" @click="goBack" />
</ElTooltip>
<h2 class="m-0 text-lg font-semibold">访客详情</h2>
<span v-if="detail" class="text-xs text-g-500">档案与轨迹时间均为上海时间</span>
</div>
<!-- 缺参兜底:直接输 URL 无 visitor_id -->
<div v-if="!routeVisitorId" class="art-card px-5 py-4">
<ElEmpty description="缺少访客参数,请从访客列表进入" />
</div>
<template v-else>
<!-- 档案卡 -->
<div v-loading="profileLoading" class="art-card mb-4 px-5 py-4">
<template v-if="detail">
<!-- 访客 ID + 在线状态 -->
<div class="flex-c flex-wrap gap-x-5 gap-y-2 mb-3">
<div class="flex-c gap-2 min-w-0">
<span class="text-sm text-g-600 shrink-0">访客 ID</span>
<code class="visitor-id-code">{{ detail.visitor_id }}</code>
<ElButton link type="primary" size="small" @click="copyVisitorId">复制</ElButton>
</div>
<div class="flex-c gap-1.5">
<span class="online-dot" :class="{ 'is-online': detail.online }" />
<span class="text-sm">{{ detail.online ? '在线' : '离线' }}</span>
</div>
</div>
<ElDescriptions :column="3" border size="small" class="profile-desc">
<ElDescriptionsItem label="地理位置">
<span class="whitespace-nowrap">
{{ countryFlag(detail.country) }}
{{
[detail.country, detail.province, detail.city].filter(Boolean).join(' · ') || '—'
}}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="IP">
<span class="font-mono text-xs">{{ detail.last_ip || '—' }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="语言 / 时区">
{{ [detail.language, detail.timezone_name].filter(Boolean).join(' · ') || '—' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="设备环境">
<span class="whitespace-nowrap">
{{
[detail.device_type, detail.os, detail.browser, detail.screen]
.filter(Boolean)
.join(' · ') || '—'
}}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="首触来源" :span="2">
<!-- 来源识别卡:归因引擎结论(渠道徽章/平台/国家站/搜索词/可信度标注) -->
<div v-if="firstTouch" class="source-card">
<div class="flex-c flex-wrap gap-x-2 gap-y-1.5">
<span
class="channel-tag"
:style="{
color: firstTouchMeta.color,
background: firstTouchMeta.color + '1A',
borderColor: firstTouchMeta.color + '59'
}"
>
{{ firstTouchMeta.icon }} {{ firstTouchMeta.label }}
</span>
<span v-if="firstTouch.platform" class="font-medium whitespace-nowrap">
{{ firstTouch.platform }}
</span>
<span v-if="firstTouch.variant" class="text-xs text-g-500 whitespace-nowrap">
{{ firstTouch.variant }}
</span>
<span
v-if="firstTouch.resolution === 'label_only'"
class="text-xs text-g-500 whitespace-nowrap"
>
仅UTM标签
</span>
<span
v-else-if="firstTouch.resolution === 'conflict'"
class="text-xs source-conflict whitespace-nowrap"
>
证据冲突
</span>
</div>
<div v-if="firstTouch.search_term" class="search-term-row">
<span class="shrink-0 text-xs text-g-600">搜索词</span>
<span class="search-term">{{ firstTouch.search_term }}</span>
</div>
<!-- 冲突时并列展示两路证据(文本插值渲染,禁 v-html) -->
<div
v-if="firstTouch.resolution === 'conflict' && conflictEvidences.length"
class="conflict-box"
>
<div v-for="(ev, i) in conflictEvidences" :key="i" class="conflict-line">
<span class="shrink-0 text-xs text-g-600">{{ ev.label }}</span>
<span class="text-xs">{{ ev.text }}</span>
</div>
</div>
</div>
<!-- 降级:无归因(旧数据未重算)→ 原 first_source 口径 + UTM 标签 -->
<div v-else class="flex-c flex-wrap gap-1.5">
<span class="whitespace-nowrap">{{ firstSourceText }}</span>
<ElTag
v-for="(val, key) in detail.first_utm"
:key="key"
size="small"
type="info"
class="utm-tag"
>
{{ key }}={{ val }}
</ElTag>
</div>
</ElDescriptionsItem>
<ElDescriptionsItem label="落地页">
<ElTooltip
v-if="detail.first_landing_path"
:content="detail.first_landing_path"
placement="top"
>
<span class="mono-path landing-path">{{ detail.first_landing_path }}</span>
</ElTooltip>
<span v-else>—</span>
</ElDescriptionsItem>
</ElDescriptions>
<!-- 统计数字 -->
<div class="grid grid-cols-2 gap-3 mt-4 md:grid-cols-4">
<div v-for="tile in statTiles" :key="tile.label" class="stat-tile">
<span class="text-xs text-g-600">{{ tile.label }}</span>
<span class="mt-1 text-xl font-medium">{{ tile.value }}</span>
</div>
</div>
</template>
<ElEmpty v-else-if="!profileLoading" description="访客数据加载失败或不存在" />
</div>
<!-- 轨迹 / 会话 Tab -->
<ElTabs v-model="activeTab" class="detail-tabs">
<!-- Tab 1:跨天时间线 -->
<ElTabPane label="轨迹" name="timeline">
<div class="art-card px-5 py-4">
<div class="flex-c flex-wrap gap-2 mb-3">
<h3 class="m-0 text-sm font-semibold">访问轨迹</h3>
<span class="text-xs text-g-500">按日倒序分组,日内时间正序</span>
<div class="ml-auto flex-c gap-2">
<span class="text-xs text-g-600">时间范围</span>
<ElRadioGroup v-model="days" size="small" @change="loadTimeline()">
<ElRadioButton :value="7">近 7 天</ElRadioButton>
<ElRadioButton :value="30">近 30 天</ElRadioButton>
<ElRadioButton :value="90">近 90 天</ElRadioButton>
</ElRadioGroup>
</div>
</div>
<ElAlert
v-if="timeline?.truncated"
type="warning"
show-icon
:closable="false"
class="mb-3"
title="事件较多,仅显示最近 1000 条"
/>
<div v-loading="timelineLoading" class="timeline-body">
<template v-if="timeline?.days.length">
<div v-for="day in timeline.days" :key="day.date" class="day-group">
<!-- 日期标题行 -->
<div class="day-header">
<span class="font-medium">{{ day.date }}</span>
<span class="text-g-500">· {{ day.sessions.length }} 次会话</span>
</div>
<!-- 会话块 -->
<div
v-for="(session, si) in day.sessions"
:key="session.session_id ?? `orphan-${si}`"
class="session-block"
>
<div class="session-header">
<span class="font-medium">
{{ session.started_at ? formatTime(session.started_at) : '未知会话' }}
</span>
<span class="text-g-500">· {{ timelineSessionSource(session) }}</span>
<span class="text-g-500"
>· 停留 {{ formatStay(sessionStayMs(session)) }}</span
>
</div>
<EventTimeline :events="session.events" class="session-timeline" />
</div>
</div>
</template>
<!-- 失败:请求已结束但无数据对象(首屏加载中不显示,避免闪现) -->
<ElEmpty
v-else-if="timeline === null && !timelineLoading"
description="时间线加载失败"
:image-size="80"
/>
<!-- 成功但窗口内无事件 -->
<ElEmpty
v-else-if="!timelineLoading"
description="该时间范围内暂无访问轨迹"
:image-size="80"
/>
</div>
</div>
</ElTabPane>
<!-- Tab 2:会话列表 -->
<ElTabPane :label="`会话(${detail?.sessions.length ?? 0})`" name="sessions">
<div class="art-card px-5 py-4">
<div class="flex-c gap-2 mb-3">
<h3 class="m-0 text-sm font-semibold">会话列表</h3>
<span class="text-xs text-g-500">点击行查看会话事件序列</span>
</div>
<ElTable
:data="detail?.sessions ?? []"
v-loading="profileLoading"
empty-text="暂无会话数据"
row-class-name="session-row"
@row-click="openSession"
>
<ElTableColumn label="会话 ID" min-width="130">
<template #default="{ row }">
<code class="session-id-code">{{ row.session_id.slice(0, 8) }}</code>
</template>
</ElTableColumn>
<ElTableColumn label="开始时间" width="150">
<template #default="{ row }">{{ formatDateTime(row.started_at) }}</template>
</ElTableColumn>
<ElTableColumn label="结束时间" width="150">
<template #default="{ row }">{{ formatDateTime(row.ended_at) }}</template>
</ElTableColumn>
<ElTableColumn label="来源" min-width="120">
<template #default="{ row }">
<span class="whitespace-nowrap">{{ sessionSourceText(row) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="落地页" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="mono-path">{{ row.landing_path || '—' }}</span>
</template>
</ElTableColumn>
<ElTableColumn prop="event_count" label="事件数" width="80" align="right" />
<ElTableColumn label="停留" width="130">
<template #default="{ row }">{{ formatStay(row.stay_ms) }}</template>
</ElTableColumn>
</ElTable>
</div>
</ElTabPane>
<!-- Tab 3:原始上报批次(raw_batch,排查"上报了为何没进报表") -->
<ElTabPane label="原始数据" name="raw">
<div class="art-card px-5 py-4">
<div class="flex-c flex-wrap gap-2 mb-3">
<h3 class="m-0 text-sm font-semibold">原始上报批次</h3>
<span class="text-xs text-g-500">
每次接收的原样存档(保留 30 天);敏感字段已脱敏为 [FILTERED],被拒批次超 4KB 截断
</span>
</div>
<ElTable
:data="rawBatches?.list ?? []"
v-loading="rawLoading"
empty-text="暂无原始上报数据"
row-class-name="session-row"
@row-click="openRawDrawer"
>
<ElTableColumn label="接收时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.received_at) }}</template>
</ElTableColumn>
<ElTableColumn label="批次 ID" min-width="150">
<template #default="{ row }">
<div v-if="row.batch_id" class="flex-c gap-1">
<code class="session-id-code">{{ row.batch_id.slice(0, 8) }}</code>
<ElButton
link
type="primary"
size="small"
@click.stop="copyBatchId(row.batch_id)"
>
复制
</ElButton>
</div>
<span v-else>—</span>
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="100">
<template #default="{ row }">
<ElTag :type="rawStatus(row.status).type" size="small">
{{ rawStatus(row.status).label }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="event_count" label="入库数" width="80" align="right" />
<ElTableColumn prop="rejected_count" label="拒绝数" width="80" align="right" />
</ElTable>
<div class="flex-c justify-end mt-3">
<ElPagination
:total="rawBatches?.total ?? 0"
:page-size="RAW_PAGE_SIZE"
:current-page="rawPage"
layout="total, prev, pager, next"
@current-change="onRawPageChange"
/>
</div>
</div>
</ElTabPane>
</ElTabs>
</template>
<!-- 原始批次详情抽屉:body 是服务端脱敏后的 JSON 文本,<pre> 文本插值渲染(禁 v-html) -->
<ElDrawer v-model="rawDrawerVisible" title="原始上报批次" size="46%">
<template v-if="rawCurrent">
<div class="drawer-summary">
<div class="flex-c flex-wrap gap-x-4 gap-y-1.5">
<span class="text-g-600 text-sm">批次 ID</span>
<template v-if="rawCurrent.batch_id">
<code class="session-id-code">{{ rawCurrent.batch_id }}</code>
<ElButton link type="primary" size="small" @click="copyBatchId(rawCurrent.batch_id)">
复制
</ElButton>
</template>
<span v-else class="text-sm">—</span>
</div>
<div class="summary-grid">
<div class="flex-c gap-2">
<span class="text-xs text-g-600">接收时间</span>
<span class="text-sm">{{ formatDateTime(rawCurrent.received_at) }}</span>
</div>
<div class="flex-c gap-2">
<span class="text-xs text-g-600">状态</span>
<ElTag :type="rawStatus(rawCurrent.status).type" size="small">
{{ rawStatus(rawCurrent.status).label }}
</ElTag>
</div>
<div class="flex-c gap-2">
<span class="text-xs text-g-600">入库数</span>
<span class="text-sm">{{ rawCurrent.event_count }}</span>
</div>
<div class="flex-c gap-2">
<span class="text-xs text-g-600">拒绝数</span>
<span class="text-sm">{{ rawCurrent.rejected_count }}</span>
</div>
</div>
</div>
<pre class="raw-json-body">{{ prettyBody(rawCurrent.body) }}</pre>
</template>
</ElDrawer>
<!-- 会话详情抽屉 -->
<ElDrawer v-model="drawerVisible" title="会话详情" size="34%">
<div v-loading="sessionLoading" class="drawer-body">
<template v-if="sessionDetail">
<div class="drawer-summary">
<div class="flex-c flex-wrap gap-x-4 gap-y-1.5">
<span class="text-g-600 text-sm">会话 ID</span>
<code class="session-id-code">{{ sessionDetail.session_id }}</code>
</div>
<div class="summary-grid">
<div class="flex-c gap-2">
<span class="text-xs text-g-600">开始</span>
<span class="text-sm">{{ formatDateTime(sessionDetail.started_at) }}</span>
</div>
<div class="flex-c gap-2">
<span class="text-xs text-g-600">结束</span>
<span class="text-sm">{{ formatDateTime(sessionDetail.ended_at) }}</span>
</div>
<div class="flex-c gap-2">
<span class="text-xs text-g-600">来源</span>
<span class="text-sm">{{ sessionSourceText(sessionDetail) }}</span>
</div>
<div class="flex-c gap-2">
<span class="text-xs text-g-600">事件数</span>
<span class="text-sm">{{ sessionDetail.event_count }}</span>
</div>
<div class="flex-c gap-2">
<span class="text-xs text-g-600">停留</span>
<span class="text-sm">{{ formatStay(sessionDetail.stay_ms) }}</span>
</div>
</div>
<div class="flex-c gap-2">
<span class="shrink-0 text-xs text-g-600">落地页</span>
<span class="mono-path text-xs">{{ sessionDetail.landing_path || '—' }}</span>
</div>
</div>
<!-- 页面路径视图:事件序列 -->
<EventTimeline :events="sessionDetail.events" class="mt-5" />
<ElEmpty
v-if="!sessionDetail.events.length && !sessionLoading"
description="本会话暂无事件"
:image-size="80"
/>
</template>
<ElEmpty
v-else-if="!sessionLoading"
description="会话数据加载失败或不存在"
:image-size="80"
/>
</div>
</ElDrawer>
</div>
</template>
<script setup lang="ts">
import { Back as BackIcon } from '@element-plus/icons-vue'
import {
getRawBatches,
getSession,
getTimeline,
getVisitor,
type AttributionInfo,
type RawBatchRow,
type SessionSummary,
type TimelineEvent,
type TimelineResp,
type TimelineSession,
type VisitorDetail
} from '@/api/tracker/visitors'
import { CHANNEL_LABELS, countryFlag, formatDateTime, formatStay } from '@/utils/tracker'
import EventTimeline from './components/EventTimeline.vue'
defineOptions({ name: 'TrackerVisitorDetail' })
const route = useRoute()
const router = useRouter()
/** 当前 query 中的访客 ID(keepAlive 下 setup 只跑一次,必须取响应式 route) */
const routeVisitorId = computed(() => {
const v = route.query.visitor_id
return typeof v === 'string' ? v : ''
})
/** 已加载的访客 ID:三入口(mounted/activated/query watch)触发去重的令牌 */
let loadedVid = ''
// 复制完整 visitor_id(clipboard API 不可用时走 execCommand 兜底,与列表页同款)
const { copy } = useClipboard({ legacy: true })
const copyVisitorId = async () => {
if (!detail.value) return
try {
await copy(detail.value.visitor_id)
ElMessage.success('访客 ID 已复制到剪贴板')
} catch {
ElMessage.error('复制失败,请手动选择复制')
}
}
const goBack = () => {
if (window.history.length > 1) router.back()
else router.push('/tracker/visitor')
}
// ---------- 档案 ----------
const detail = ref<VisitorDetail | null>(null)
const profileLoading = ref(false)
let profileSeq = 0
const loadDetail = async (vid: string) => {
const seq = ++profileSeq
profileLoading.value = true
try {
const resp = await getVisitor(vid)
if (seq === profileSeq) detail.value = resp
} catch {
// 失败已由请求层统一 ElMessage 提示;置空走空态
if (seq === profileSeq) detail.value = null
} finally {
if (seq === profileSeq) profileLoading.value = false
}
}
/** 首触来源:referrer_domain → utm_source → 直接访问(与后端 _source 同口径) */
const firstSourceText = computed(() => {
const d = detail.value
if (!d) return '—'
return d.first_referrer_domain || d.first_utm?.utm_source || '直接访问'
})
// ---------- 来源识别卡(归因引擎结论;旧数据未重算时降级上面的 first_source 口径) ----------
/** detail 字段类型收窄:归因 detail 是后端拼的 dict,只接受纯对象 */
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v)
/** 证据记录 → 一行可读文本:渠道 · 平台(· 变体)+ referrer 域名括注 */
const formatEvidence = (e: Record<string, unknown>, withHost: boolean): string => {
const segs = [
CHANNEL_LABELS[String(e.channel ?? '')]?.label || String(e.channel ?? ''),
String(e.platform ?? ''),
String(e.variant ?? '')
]
.filter(Boolean)
.join(' · ')
const host = withHost && e.referrer_host ? `(${String(e.referrer_host)})` : ''
return `${segs}${host}`
}
/** 首触归因(attribution.first_touch;无分类行时后端给 null → 模板走降级分支) */
const firstTouch = computed<AttributionInfo | null>(
() => detail.value?.attribution?.first_touch ?? null
)
/** 渠道徽章元数据(未知渠道值兜底 unknown 项) */
const firstTouchMeta = computed(
() => CHANNEL_LABELS[firstTouch.value?.channel ?? ''] ?? CHANNEL_LABELS.unknown
)
/** conflict 时并列的两路证据(referrer 观察 vs UTM 标签),文本插值渲染 */
const conflictEvidences = computed(() => {
const d = firstTouch.value?.detail
if (!isRecord(d)) return []
const out: { label: string; text: string }[] = []
if (isRecord(d.referrer_evidence)) {
out.push({ label: '观察证据', text: formatEvidence(d.referrer_evidence, true) })
}
if (isRecord(d.utm_evidence)) {
out.push({ label: '标签证据', text: formatEvidence(d.utm_evidence, false) })
}
return out
})
/** 统计数字 4 项(?? 只兜 nullish,0 正常显示) */
const statTiles = computed(() => [
{ label: '访问次数', value: detail.value ? String(detail.value.visit_count) : '—' },
{ label: '活跃天数', value: detail.value ? String(detail.value.active_days) : '—' },
{ label: '会话数', value: detail.value ? String(detail.value.session_count) : '—' },
{ label: '总停留', value: detail.value ? formatStay(detail.value.total_stay_ms) : '—' }
])
// ---------- 跨天时间线 ----------
const activeTab = ref<'timeline' | 'sessions' | 'raw'>('timeline')
const days = ref(30)
const timeline = ref<TimelineResp | null>(null)
const timelineLoading = ref(false)
let timelineSeq = 0
const loadTimeline = async () => {
const vid = loadedVid
if (!vid) return
// 快速切 7/30/90 时丢弃过期响应(reqSeq 守卫,防旧数据覆盖/旧 finally 提前清 loading)
const seq = ++timelineSeq
timelineLoading.value = true
try {
const resp = await getTimeline(vid, days.value)
if (seq === timelineSeq) timeline.value = resp
} catch {
if (seq === timelineSeq) timeline.value = null
} finally {
if (seq === timelineSeq) timelineLoading.value = false
}
}
/** ISO(后端上海时间)→ HH:mm,时间线/会话头用短时间 */
const formatTime = (iso?: string | null): string => (iso ? iso.slice(11, 16) : '—')
/** 时间线会话组停留:timeline 无 stay_ms,用组内 events 的 active_ms 求和(null 跳过) */
const sessionStayMs = (session: TimelineSession): number =>
session.events.reduce((sum, e) => sum + (e.active_ms ?? 0), 0)
/** 时间线会话组来源:session_id 回查详情会话摘要的 referrer/utm;孤立事件组/查不到摘要时
* 无事实依据,不做"直接访问"断言,显示 —(sessionSourceText 内的"直接访问"是后端同口径语义) */
const timelineSessionSource = (session: TimelineSession): string => {
if (!session.session_id) return '—'
const s = detail.value?.sessions.find((x) => x.session_id === session.session_id)
return s ? sessionSourceText(s) : '—'
}
/** 会话来源:referrer_domain → utm_source → 直接访问 */
const sessionSourceText = (s: SessionSummary): string =>
s.referrer_domain || s.utm?.utm_source || '直接访问'
// ---------- 会话详情抽屉 ----------
const drawerVisible = ref(false)
const sessionLoading = ref(false)
const sessionDetail = ref<(SessionSummary & { events: TimelineEvent[] }) | null>(null)
let sessionSeq = 0
const openSession = async (row: SessionSummary) => {
drawerVisible.value = true
sessionDetail.value = null
const seq = ++sessionSeq
sessionLoading.value = true
try {
const resp = await getSession(loadedVid, row.session_id)
if (seq === sessionSeq) sessionDetail.value = resp
} catch {
if (seq === sessionSeq) sessionDetail.value = null
} finally {
if (seq === sessionSeq) sessionLoading.value = false
}
}
// ---------- 原始上报批次(Tab 3) ----------
const RAW_PAGE_SIZE = 10 // 与后端 visitors.py RAW_PAGE_SIZE 同步
const rawBatches = ref<{ list: RawBatchRow[]; total: number; page: number } | null>(null)
const rawLoading = ref(false)
const rawPage = ref(1)
let rawSeq = 0
const loadRawBatches = async (page = rawPage.value) => {
const vid = loadedVid
if (!vid) return
const seq = ++rawSeq
rawPage.value = page
rawLoading.value = true
try {
const resp = await getRawBatches(vid, page)
if (seq === rawSeq) rawBatches.value = resp
} catch {
if (seq === rawSeq) rawBatches.value = null
} finally {
if (seq === rawSeq) rawLoading.value = false
}
}
const onRawPageChange = (page: number) => {
loadRawBatches(page)
}
// 首次切到原始 Tab 才拉取(懒加载);失败置 null,切走再切回可重试
watch(activeTab, (tab) => {
if (tab === 'raw' && rawBatches.value === null && loadedVid) loadRawBatches()
})
/** 状态三色映射(accepted/partial/rejected;未知值兜底 info) */
const rawStatusMap: Record<
string,
{ label: string; type: 'success' | 'warning' | 'danger' | 'info' }
> = {
accepted: { label: '已入库', type: 'success' },
partial: { label: '部分拒绝', type: 'warning' },
rejected: { label: '已拒绝', type: 'danger' }
}
const rawStatus = (status: string) => rawStatusMap[status] ?? { label: status, type: 'info' }
/** 被拒批次 body 被 4KB 截断后不是合法 JSON——parse 失败原文直出 */
const prettyBody = (body: string): string => {
try {
return JSON.stringify(JSON.parse(body), null, 2)
} catch {
return body
}
}
// 原始批次抽屉(body 随行带出,无需再请求)
const rawDrawerVisible = ref(false)
const rawCurrent = ref<RawBatchRow | null>(null)
const openRawDrawer = (row: RawBatchRow) => {
rawCurrent.value = row
rawDrawerVisible.value = true
}
const copyBatchId = async (batchId: string) => {
try {
await copy(batchId)
ElMessage.success('批次 ID 已复制到剪贴板')
} catch {
ElMessage.error('复制失败,请手动选择复制')
}
}
// ---------- keepAlive 访客切换 ----------
/**
* 路由 keepAlive 且 path 固定为 /tracker/visitor/detail:列表点不同访客进来复用同一实例。
* 三入口(mounted / activated / query watch)统一走 ensureLoad,靠 loadedVid 去重,
* 保证"切访客必刷新、重复触发不重复请求"。
*/
const ensureLoad = () => {
const vid = routeVisitorId.value
if (!vid || vid === loadedVid) return
loadedVid = vid
// 换访客:清档案/时间线/原始批次/抽屉态,防旧访客 ID 在 loading 遮罩下闪现
//(days 保持所选范围重新拉取;原始 Tab 懒加载——分页重置回第 1 页,切到 Tab 时再拉)
detail.value = null
timeline.value = null
rawBatches.value = null
rawPage.value = 1
rawDrawerVisible.value = false
rawCurrent.value = null
drawerVisible.value = false
sessionDetail.value = null
loadDetail(vid)
loadTimeline()
}
onMounted(ensureLoad)
onActivated(ensureLoad)
watch(routeVisitorId, (vid: string) => {
// query 清空(离开详情页/手动去参)时重置令牌:再次进入同一访客也重新拉取,
// 避免空参兜底页停留后点回同访客不刷新
if (!vid) loadedVid = ''
ensureLoad()
})
</script>
<style lang="scss" scoped>
.visitor-id-code {
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
word-break: break-all;
background: var(--el-fill-color-light);
color: var(--el-text-color-regular);
}
.online-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background: var(--el-text-color-disabled);
&.is-online {
background: var(--el-color-success);
}
}
.mono-path {
font-family: var(--el-font-family-mono, monospace);
font-size: 12px;
}
.landing-path {
display: inline-block;
max-width: 22rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
.utm-tag {
font-family: var(--el-font-family-mono, monospace);
}
// 来源识别卡(首触归因结论)
.source-card {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.channel-tag {
padding: 2px 8px;
font-size: 12px;
line-height: 18px;
border: 1px solid transparent;
border-radius: 4px;
white-space: nowrap;
}
.search-term-row {
display: flex;
gap: 6px;
align-items: baseline;
}
.search-term {
font-size: 14px;
font-weight: 600;
color: var(--el-color-primary);
word-break: break-all;
}
.source-conflict {
color: var(--el-color-danger);
}
.conflict-box {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
border-radius: 6px;
background: var(--el-fill-color-lighter);
.conflict-line {
display: flex;
gap: 8px;
align-items: baseline;
}
}
.stat-tile {
display: flex;
flex-direction: column;
padding: 12px 16px;
border-radius: 6px;
background: var(--el-fill-color-lighter);
}
// 时间线
.day-header {
padding: 8px 12px;
margin-bottom: 8px;
font-size: 13px;
border-left: 3px solid var(--el-color-primary);
background: var(--el-fill-color-lighter);
}
.session-block {
padding: 10px 12px 4px;
margin-bottom: 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
}
.session-header {
margin-bottom: 8px;
font-size: 13px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.session-timeline {
padding-left: 4px;
}
:deep(.session-row) {
cursor: pointer;
}
// 抽屉
.drawer-summary {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 14px;
border-radius: 6px;
background: var(--el-fill-color-lighter);
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
}
// 原始批次 JSON(脱敏后文本,pre 渲染)
.raw-json-body {
max-height: calc(100vh - 260px);
padding: 12px 14px;
margin: 12px 0 0;
overflow: auto;
font-family: var(--el-font-family-mono, monospace);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
border-radius: 6px;
background: var(--el-fill-color-lighter);
}
</style>