feat(admin): 访客详情跨天时间线

- 覆写 detail.vue 占位:档案卡(ID/在线/地理/IP/语言时区/设备/首触来源 utm 标签/
  落地页截断 tooltip)+ 统计 4 项 + 轨迹/会话 Tab
- 轨迹 Tab:按日倒序分组(日期头含会话数)、会话块(时间·来源·active_ms 求和停留)、
  el-timeline 事件行(page_view/page_stay 独立条目/sdk_loaded 图标区分)、
  7/30/90 天切换(reqSeq 守卫)、truncated 提示条、空态
- 会话 Tab:全量会话表,行点击开抽屉看单会话事件序列(getSession)
- keepAlive:onMounted/onActivated/query-watch 三入口统一 ensureLoad,
  loadedVid 去重(query 清空时重置令牌),切访客必刷新
- 顺车 Minor:formatStay 注释与代码对齐;useTable 背对背 if 改 if/else

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-15 21:05:52 +08:00
parent ba31670095
commit ea561c974a
3 changed files with 618 additions and 10 deletions
+4 -5
View File
@@ -404,14 +404,13 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
return { records: [], total: 0, current: 1, size: 10 }
}
if (!superseded) {
// 状态机:请求失败,进入 error 状态
loadingState.value = 'error'
data.value = []
}
if (superseded) {
// 旧请求的失败交给新请求兜底,不触发错误回调
throw err instanceof Error ? err : new Error(String(err))
} else {
// 状态机:请求失败,进入 error 状态
loadingState.value = 'error'
data.value = []
}
const tableError = handleError(err, '获取表格数据失败')
throw tableError
+1 -1
View File
@@ -51,7 +51,7 @@ export const formatStay = (ms?: number | null): string => {
if (days) parts.push(`${days}天`)
if (hours) parts.push(`${hours}小时`)
if (minutes) parts.push(`${minutes}分`)
// 秒恒显示(含 0),避免"0秒/不足 1 秒"整行变空
// 仅当非零或无更高单位时显示秒(与下方条件表达式语义一致)
if (seconds || !parts.length) parts.push(`${seconds}秒`)
return parts.join('')
}
+613 -4
View File
@@ -1,10 +1,619 @@
<!-- 访客统计 · 访客详情(档案卡 + 跨天时间线 + 会话列表/抽屉) -->
<!-- keepAlive=true 且路由 path 固定:访客切换复用同一实例,刷新依赖 onActivated + query watch -->
<template>
<div class="p-5">
<h2 class="mb-3 text-lg font-semibold">访客详情</h2>
<div>Task 20 填充</div>
<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="首触来源">
<div 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>
<ElTimeline class="session-timeline">
<ElTimelineItem
v-for="(e, i) in session.events"
:key="i"
:icon="eventMeta(e.event_name).icon"
:type="eventMeta(e.event_name).type"
:timestamp="formatTime(e.time)"
>
<!-- page_view:浏览 + 停留副行 + title 小字 -->
<template v-if="e.event_name === 'page_view'">
<div>
浏览
<span class="mono-path">{{ e.path || '—' }}</span>
</div>
<div v-if="(e.active_ms ?? 0) > 0" class="stay-line">
停留 {{ formatStay(e.active_ms) }}
</div>
<div v-if="e.title" class="title-line">{{ e.title }}</div>
</template>
<!-- page_stay 独立条目(无匹配 page_view 时) -->
<template v-else-if="e.event_name === 'page_stay'">
<div>
停留
<span class="mono-path">{{ e.path || '—' }}</span>
({{ formatStay(e.active_ms ?? 0) }})
</div>
<div v-if="e.title" class="title-line">{{ e.title }}</div>
</template>
<!-- sdk_loaded / 其他自定义事件 -->
<template v-else>
<div>
{{ eventMeta(e.event_name).label }}
<span v-if="e.path" class="mono-path">{{ e.path }}</span>
</div>
</template>
</ElTimelineItem>
</ElTimeline>
</div>
</div>
</template>
<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>
</ElTabs>
</template>
<!-- 会话详情抽屉 -->
<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>
<!-- 页面路径视图:事件序列 -->
<ElTimeline class="mt-5">
<ElTimelineItem
v-for="(e, i) in sessionDetail.events"
:key="i"
:icon="eventMeta(e.event_name).icon"
:type="eventMeta(e.event_name).type"
:timestamp="formatTime(e.time)"
>
<template v-if="e.event_name === 'page_view'">
<div>
浏览
<span class="mono-path">{{ e.path || '—' }}</span>
</div>
<div v-if="(e.active_ms ?? 0) > 0" class="stay-line">
停留 {{ formatStay(e.active_ms) }}
</div>
<div v-if="e.title" class="title-line">{{ e.title }}</div>
</template>
<template v-else-if="e.event_name === 'page_stay'">
<div>
停留
<span class="mono-path">{{ e.path || '—' }}</span>
({{ formatStay(e.active_ms ?? 0) }})
</div>
<div v-if="e.title" class="title-line">{{ e.title }}</div>
</template>
<template v-else>
<div>
{{ eventMeta(e.event_name).label }}
<span v-if="e.path" class="mono-path">{{ e.path }}</span>
</div>
</template>
</ElTimelineItem>
</ElTimeline>
<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">
// 占位页:Task 20 填充真实内容(档案 + 会话列表 + 跨天时间线;query 参数 visitor_id)
import type { Component } from 'vue'
import { Back as BackIcon, Cpu, InfoFilled, Timer, View } from '@element-plus/icons-vue'
import {
getSession,
getTimeline,
getVisitor,
type SessionSummary,
type TimelineEvent,
type TimelineResp,
type TimelineSession,
type VisitorDetail
} from '@/api/tracker/visitors'
import { countryFlag, formatDateTime, formatStay } from '@/utils/tracker'
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 || '直接访问'
})
/** 统计数字 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'>('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;孤立事件组无来源信息 */
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 || '直接访问'
/** 事件行图标/节点色:page_view=浏览、page_stay=计时、sdk_loaded=加载、其余兜底 */
const EVENT_META: Record<
string,
{ icon: Component; type: 'primary' | 'warning' | 'info'; label: string }
> = {
page_view: { icon: View, type: 'primary', label: '浏览' },
page_stay: { icon: Timer, type: 'warning', label: '停留' },
sdk_loaded: { icon: Cpu, type: 'info', label: 'SDK 加载' }
}
const eventMeta = (name: string) =>
EVENT_META[name] ?? { icon: InfoFilled, type: 'info', label: name }
// ---------- 会话详情抽屉 ----------
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
}
}
// ---------- 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
// 换访客:清抽屉态,时间线 days 保持用户所选范围重新拉取
drawerVisible.value = false
sessionDetail.value = null
timeline.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);
}
.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;
}
.stay-line {
margin-top: 2px;
font-size: 12px;
color: var(--el-color-warning);
}
.title-line {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
: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;
}
</style>