f9c8c354ae
- 类型: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>
863 lines
32 KiB
Vue
863 lines
32 KiB
Vue
<!-- 访客统计 · 访客详情(档案卡 + 跨天时间线 + 会话列表/抽屉 + 原始上报批次) -->
|
||
<!-- 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>
|