fix(admin): 详情页时间线兜底/事件组件抽取/失败态区分/换访客清理
- timelineSessionSource 查不到会话摘要时改显示 —:无事实依据不做 "直接访问"断言(sessionSourceText 内的直接访问是后端同口径语义,保留) - 抽取 EventTimeline 子组件(components/EventTimeline.vue): 轨迹 Tab 与会话抽屉的 page_view/page_stay/其他三分支事件行模板 去重为单一实现,两处各一行引用;stay-line/title-line 样式随迁 - 时间线失败态三分支:有数据渲染分组 / 请求结束且无数据对象提示 "时间线加载失败" / 成功但窗口内无事件提示"暂无访问轨迹" - ensureLoad 换访客时同步清 detail,与 timeline/抽屉清理对齐, 防旧访客 ID 在 loading 遮罩下闪现 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
<!-- 访客统计 · 事件时间线(详情页轨迹 Tab 与会话抽屉共用) -->
|
||||||
|
<!-- 事件行三分支:page_view(浏览+停留副行+title)/ page_stay 独立条目 / sdk_loaded 及自定义事件 -->
|
||||||
|
<template>
|
||||||
|
<ElTimeline>
|
||||||
|
<ElTimelineItem
|
||||||
|
v-for="(e, i) in events"
|
||||||
|
:key="i"
|
||||||
|
:icon="eventMeta(e.event_name).icon"
|
||||||
|
:type="eventMeta(e.event_name).type"
|
||||||
|
:timestamp="formatTime(e.time)"
|
||||||
|
>
|
||||||
|
<!-- page_view:浏览 + 停留副行(active_ms>0 才显示)+ 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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
import { Cpu, InfoFilled, Timer, View } from '@element-plus/icons-vue'
|
||||||
|
import type { TimelineEvent } from '@/api/tracker/visitors'
|
||||||
|
import { formatStay } from '@/utils/tracker'
|
||||||
|
|
||||||
|
defineOptions({ name: 'EventTimeline' })
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
/** 已按服务端合并规则处理过的序列(page_stay 已并入 page_view 的 active_ms) */
|
||||||
|
events: TimelineEvent[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/** ISO(后端上海时间)→ HH:mm */
|
||||||
|
const formatTime = (iso?: string | null): string => (iso ? iso.slice(11, 16) : '—')
|
||||||
|
|
||||||
|
/** 事件行图标/节点色: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 }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.mono-path {
|
||||||
|
font-family: var(--el-font-family-mono, monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -145,46 +145,17 @@
|
|||||||
>· 停留 {{ formatStay(sessionStayMs(session)) }}</span
|
>· 停留 {{ formatStay(sessionStayMs(session)) }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<ElTimeline class="session-timeline">
|
<EventTimeline :events="session.events" 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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- 失败:请求已结束但无数据对象(首屏加载中不显示,避免闪现) -->
|
||||||
|
<ElEmpty
|
||||||
|
v-else-if="timeline === null && !timelineLoading"
|
||||||
|
description="时间线加载失败"
|
||||||
|
:image-size="80"
|
||||||
|
/>
|
||||||
|
<!-- 成功但窗口内无事件 -->
|
||||||
<ElEmpty
|
<ElEmpty
|
||||||
v-else-if="!timelineLoading"
|
v-else-if="!timelineLoading"
|
||||||
description="该时间范围内暂无访问轨迹"
|
description="该时间范围内暂无访问轨迹"
|
||||||
@@ -277,40 +248,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 页面路径视图:事件序列 -->
|
<!-- 页面路径视图:事件序列 -->
|
||||||
<ElTimeline class="mt-5">
|
<EventTimeline :events="sessionDetail.events" 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
|
<ElEmpty
|
||||||
v-if="!sessionDetail.events.length && !sessionLoading"
|
v-if="!sessionDetail.events.length && !sessionLoading"
|
||||||
description="本会话暂无事件"
|
description="本会话暂无事件"
|
||||||
@@ -328,8 +266,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Component } from 'vue'
|
import { Back as BackIcon } from '@element-plus/icons-vue'
|
||||||
import { Back as BackIcon, Cpu, InfoFilled, Timer, View } from '@element-plus/icons-vue'
|
|
||||||
import {
|
import {
|
||||||
getSession,
|
getSession,
|
||||||
getTimeline,
|
getTimeline,
|
||||||
@@ -341,6 +278,7 @@
|
|||||||
type VisitorDetail
|
type VisitorDetail
|
||||||
} from '@/api/tracker/visitors'
|
} from '@/api/tracker/visitors'
|
||||||
import { countryFlag, formatDateTime, formatStay } from '@/utils/tracker'
|
import { countryFlag, formatDateTime, formatStay } from '@/utils/tracker'
|
||||||
|
import EventTimeline from './components/EventTimeline.vue'
|
||||||
|
|
||||||
defineOptions({ name: 'TrackerVisitorDetail' })
|
defineOptions({ name: 'TrackerVisitorDetail' })
|
||||||
|
|
||||||
@@ -437,29 +375,18 @@
|
|||||||
const sessionStayMs = (session: TimelineSession): number =>
|
const sessionStayMs = (session: TimelineSession): number =>
|
||||||
session.events.reduce((sum, e) => sum + (e.active_ms ?? 0), 0)
|
session.events.reduce((sum, e) => sum + (e.active_ms ?? 0), 0)
|
||||||
|
|
||||||
/** 时间线会话组来源:session_id 回查详情会话摘要的 referrer/utm;孤立事件组无来源信息 */
|
/** 时间线会话组来源:session_id 回查详情会话摘要的 referrer/utm;孤立事件组/查不到摘要时
|
||||||
|
* 无事实依据,不做"直接访问"断言,显示 —(sessionSourceText 内的"直接访问"是后端同口径语义) */
|
||||||
const timelineSessionSource = (session: TimelineSession): string => {
|
const timelineSessionSource = (session: TimelineSession): string => {
|
||||||
if (!session.session_id) return '—'
|
if (!session.session_id) return '—'
|
||||||
const s = detail.value?.sessions.find((x) => x.session_id === session.session_id)
|
const s = detail.value?.sessions.find((x) => x.session_id === session.session_id)
|
||||||
return s ? sessionSourceText(s) : '直接访问'
|
return s ? sessionSourceText(s) : '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 会话来源:referrer_domain → utm_source → 直接访问 */
|
/** 会话来源:referrer_domain → utm_source → 直接访问 */
|
||||||
const sessionSourceText = (s: SessionSummary): string =>
|
const sessionSourceText = (s: SessionSummary): string =>
|
||||||
s.referrer_domain || s.utm?.utm_source || '直接访问'
|
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 drawerVisible = ref(false)
|
||||||
const sessionLoading = ref(false)
|
const sessionLoading = ref(false)
|
||||||
@@ -491,10 +418,11 @@
|
|||||||
const vid = routeVisitorId.value
|
const vid = routeVisitorId.value
|
||||||
if (!vid || vid === loadedVid) return
|
if (!vid || vid === loadedVid) return
|
||||||
loadedVid = vid
|
loadedVid = vid
|
||||||
// 换访客:清抽屉态,时间线 days 保持用户所选范围重新拉取
|
// 换访客:清档案/时间线/抽屉态,防旧访客 ID 在 loading 遮罩下闪现(days 保持所选范围重新拉取)
|
||||||
|
detail.value = null
|
||||||
|
timeline.value = null
|
||||||
drawerVisible.value = false
|
drawerVisible.value = false
|
||||||
sessionDetail.value = null
|
sessionDetail.value = null
|
||||||
timeline.value = null
|
|
||||||
loadDetail(vid)
|
loadDetail(vid)
|
||||||
loadTimeline()
|
loadTimeline()
|
||||||
}
|
}
|
||||||
@@ -585,18 +513,6 @@
|
|||||||
padding-left: 4px;
|
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) {
|
:deep(.session-row) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user