feat(admin): 访客详情页按客迹视觉V1重设计(头部卡/统计卡/时间线/来源证据三口径/访问环境)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -76,9 +76,22 @@ export interface AttributionInfo {
|
||||
session_id?: string | null
|
||||
}
|
||||
|
||||
/** 详情归因块:first_touch 主口径 + 近几次会话入口识别(旧数据未重算时 first_touch 为 null) */
|
||||
/** 首次可观测来源占位(无 first_touch 分类行的历史访客,后端仅给 channel/resolution 两字段) */
|
||||
export interface ObservablePlaceholder {
|
||||
channel: 'unknown'
|
||||
resolution: 'unknown'
|
||||
}
|
||||
|
||||
/** 详情归因块:first_touch 主口径 + 来源证据三口径 + 近几次会话入口识别 */
|
||||
export interface AttributionBlock {
|
||||
/** 首触分类(旧数据未重算时为 null) */
|
||||
first_touch: AttributionInfo | null
|
||||
/** 首次可观测来源:first_touch 分类原样(含 direct/unknown);无分类行 → unknown 占位 */
|
||||
first_observable: AttributionInfo | ObservablePlaceholder
|
||||
/** 首次可识别来源:first_touch 可识别时用之,否则最早一条可识别 session_entry;无则 null */
|
||||
first_identifiable: AttributionInfo | null
|
||||
/** 本次会话来源:最新一条 session_entry;无则 null */
|
||||
latest_session: AttributionInfo | null
|
||||
session_entries: AttributionInfo[]
|
||||
}
|
||||
|
||||
@@ -90,6 +103,8 @@ export interface VisitorDetail extends VisitorRow {
|
||||
last_ip: string | null
|
||||
first_utm: Record<string, string>
|
||||
first_referrer_domain: string
|
||||
/** page_view 事件数(page_stay 是停留信号不并计) */
|
||||
pages_viewed: number
|
||||
/** 来源归因块(first_touch=null 时前端回落 first_source 展示) */
|
||||
attribution: AttributionBlock
|
||||
sessions: SessionSummary[]
|
||||
|
||||
@@ -47,7 +47,7 @@ export const CHANNEL_LABELS: Record<string, { label: string; icon: string; color
|
||||
social: { label: '社交媒体', icon: '💬', color: '#E6A23C' },
|
||||
in_app: { label: '应用内', icon: '📱', color: '#67C23A' },
|
||||
referral: { label: '外部链接', icon: '🔗', color: '#909399' },
|
||||
direct: { label: '直接访问', icon: '⏱', color: '#C0C4CC' },
|
||||
direct: { label: '直接或未知', icon: '⏱', color: '#C0C4CC' },
|
||||
unknown: { label: '未知', icon: '❓', color: '#C0C4CC' }
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export interface ChannelBadgeRow {
|
||||
/**
|
||||
* 渠道徽章显示串:icon + platform(·variant),无 platform 时退渠道名;
|
||||
* 有搜索词附加「词」;label_only / conflict 后缀诚实标注;channel 空(未归因)返回 —。
|
||||
* 例:"🔍 Google·ru"、"🤖 ChatGPT"、"📱 微信"、"🔍 百度「挖掘机」"、"⏱ 直接访问"
|
||||
* 例:"🔍 Google·ru"、"🤖 ChatGPT"、"📱 微信"、"🔍 百度「挖掘机」"、"⏱ 直接或未知"
|
||||
*/
|
||||
export function channelBadge(row: ChannelBadgeRow): string {
|
||||
if (!row.channel) return '—'
|
||||
|
||||
@@ -1,47 +1,38 @@
|
||||
<!-- 访客统计 · 事件时间线(详情页轨迹 Tab 与会话抽屉共用) -->
|
||||
<!-- 事件行三分支:page_view(浏览+停留副行+title)/ page_stay 独立条目 / sdk_loaded 及自定义事件 -->
|
||||
<!-- 访客统计 · 事件时间线(详情页访问时间线与会话抽屉共用) -->
|
||||
<!-- 事件行三分支:page_view(浏览+停留)/ page_stay 独立条目 / sdk_loaded 及自定义事件 -->
|
||||
<!-- 视觉:客迹 V1——灰色时间 + 36px 浅蓝图标块 + 粗体标题 + 灰色 path + 停留时长,左侧 2px 连接线贯穿 -->
|
||||
<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>
|
||||
<div class="evt-timeline">
|
||||
<div v-for="(e, i) in events" :key="i" class="evt-row">
|
||||
<span class="evt-time">{{ formatTime(e.time) }}</span>
|
||||
<div class="evt-icon">
|
||||
<ElIcon :size="16">
|
||||
<component :is="eventMeta(e.event_name).icon" />
|
||||
</ElIcon>
|
||||
</div>
|
||||
<div class="evt-content">
|
||||
<!-- 标题行:事件名粗体(page_stay 独立条目带时长) -->
|
||||
<div class="evt-title">
|
||||
{{ eventMeta(e.event_name).label }}
|
||||
<span v-if="e.path" class="mono-path">{{ e.path }}</span>
|
||||
<span v-if="e.event_name === 'page_stay'" class="evt-stay">
|
||||
<span class="evt-stay-label">停留</span>
|
||||
{{ formatStay(e.active_ms ?? 0) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElTimelineItem>
|
||||
</ElTimeline>
|
||||
<div v-if="e.path" class="evt-path">{{ e.path }}</div>
|
||||
<div v-if="e.title" class="evt-page-title">{{ e.title }}</div>
|
||||
<!-- page_view 合并停留:下一行「停留」灰字 + 时长深色粗体 -->
|
||||
<div v-if="e.event_name === 'page_view' && (e.active_ms ?? 0) > 0" class="evt-stay">
|
||||
<span class="evt-stay-label">停留</span>
|
||||
{{ formatStay(e.active_ms) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { markRaw, 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'
|
||||
@@ -56,34 +47,113 @@
|
||||
/** 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 加载' }
|
||||
/** 事件行图标/标题:page_view=浏览页面、page_stay=页面停留、sdk_loaded=SDK 加载、其余兜底 */
|
||||
const EVENT_META: Record<string, { icon: Component; label: string }> = {
|
||||
page_view: { icon: markRaw(View), label: '浏览页面' },
|
||||
page_stay: { icon: markRaw(Timer), label: '页面停留' },
|
||||
sdk_loaded: { icon: markRaw(Cpu), label: 'SDK 加载' }
|
||||
}
|
||||
const eventMeta = (name: string) =>
|
||||
EVENT_META[name] ?? { icon: InfoFilled, type: 'info', label: name }
|
||||
const eventMeta = (name: string): { icon: Component; label: string } =>
|
||||
EVENT_META[name] ?? { icon: markRaw(InfoFilled), label: name }
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mono-path {
|
||||
$time-col: 44px;
|
||||
$icon-size: 36px;
|
||||
$row-gap: 12px;
|
||||
// 连接线 x = 时间列 + 间距 + 图标块中心(2px 宽居中于图标中线)
|
||||
$line-left: $time-col + $row-gap + $icon-size / 2 - 1px;
|
||||
|
||||
.evt-timeline {
|
||||
position: relative;
|
||||
|
||||
// 左侧 2px 连接线贯穿(图标块不透明底盖住穿越段,形成"节点"视觉)
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
left: $line-left;
|
||||
width: 2px;
|
||||
content: '';
|
||||
background: #dce3ec;
|
||||
}
|
||||
}
|
||||
|
||||
.evt-row {
|
||||
display: flex;
|
||||
gap: $row-gap;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.evt-time {
|
||||
width: $time-col;
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
line-height: $icon-size;
|
||||
color: #86909c;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.evt-icon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
width: $icon-size;
|
||||
height: $icon-size;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #2563eb;
|
||||
background: #e8f1fe;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.evt-content {
|
||||
min-width: 0;
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.evt-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.evt-path {
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
font-family: var(--el-font-family-mono, monospace);
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stay-line {
|
||||
.evt-page-title {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--el-color-warning);
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.title-line {
|
||||
// 停留时长:「停留」灰字 + 时长深色粗体
|
||||
.evt-stay {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-weight: 600;
|
||||
color: #4e5969;
|
||||
|
||||
.evt-stay-label {
|
||||
margin-right: 4px;
|
||||
font-weight: 400;
|
||||
color: #86909c;
|
||||
}
|
||||
}
|
||||
|
||||
// 标题行内的独立 page_stay 时长(不再单独成行)
|
||||
.evt-title .evt-stay {
|
||||
display: inline;
|
||||
margin-top: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user