feat(admin): 访客详情页按客迹视觉V1重设计(头部卡/统计卡/时间线/来源证据三口径/访问环境)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-16 18:19:31 +08:00
parent 008d9277d8
commit 50faa73cf9
6 changed files with 1152 additions and 457 deletions
+16 -1
View File
@@ -76,9 +76,22 @@ export interface AttributionInfo {
session_id?: string | null 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 { export interface AttributionBlock {
/** 首触分类(旧数据未重算时为 null) */
first_touch: AttributionInfo | 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[] session_entries: AttributionInfo[]
} }
@@ -90,6 +103,8 @@ export interface VisitorDetail extends VisitorRow {
last_ip: string | null last_ip: string | null
first_utm: Record<string, string> first_utm: Record<string, string>
first_referrer_domain: string first_referrer_domain: string
/** page_view 事件数(page_stay 是停留信号不并计) */
pages_viewed: number
/** 来源归因块(first_touch=null 时前端回落 first_source 展示) */ /** 来源归因块(first_touch=null 时前端回落 first_source 展示) */
attribution: AttributionBlock attribution: AttributionBlock
sessions: SessionSummary[] sessions: SessionSummary[]
+2 -2
View File
@@ -47,7 +47,7 @@ export const CHANNEL_LABELS: Record<string, { label: string; icon: string; color
social: { label: '社交媒体', icon: '💬', color: '#E6A23C' }, social: { label: '社交媒体', icon: '💬', color: '#E6A23C' },
in_app: { label: '应用内', icon: '📱', color: '#67C23A' }, in_app: { label: '应用内', icon: '📱', color: '#67C23A' },
referral: { label: '外部链接', icon: '🔗', color: '#909399' }, referral: { label: '外部链接', icon: '🔗', color: '#909399' },
direct: { label: '直接访问', icon: '⏱', color: '#C0C4CC' }, direct: { label: '直接或未知', icon: '⏱', color: '#C0C4CC' },
unknown: { label: '未知', icon: '❓', color: '#C0C4CC' } unknown: { label: '未知', icon: '❓', color: '#C0C4CC' }
} }
@@ -63,7 +63,7 @@ export interface ChannelBadgeRow {
/** /**
* 渠道徽章显示串:icon + platform(·variant),无 platform 时退渠道名; * 渠道徽章显示串:icon + platform(·variant),无 platform 时退渠道名;
* 有搜索词附加「词」;label_only / conflict 后缀诚实标注;channel 空(未归因)返回 —。 * 有搜索词附加「词」;label_only / conflict 后缀诚实标注;channel 空(未归因)返回 —。
* 例:"🔍 Google·ru"、"🤖 ChatGPT"、"📱 微信"、"🔍 百度「挖掘机」"、"⏱ 直接访问" * 例:"🔍 Google·ru"、"🤖 ChatGPT"、"📱 微信"、"🔍 百度「挖掘机」"、"⏱ 直接或未知"
*/ */
export function channelBadge(row: ChannelBadgeRow): string { export function channelBadge(row: ChannelBadgeRow): string {
if (!row.channel) return '—' 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> <template>
<ElTimeline> <div class="evt-timeline">
<ElTimelineItem <div v-for="(e, i) in events" :key="i" class="evt-row">
v-for="(e, i) in events" <span class="evt-time">{{ formatTime(e.time) }}</span>
:key="i" <div class="evt-icon">
:icon="eventMeta(e.event_name).icon" <ElIcon :size="16">
:type="eventMeta(e.event_name).type" <component :is="eventMeta(e.event_name).icon" />
:timestamp="formatTime(e.time)" </ElIcon>
> </div>
<!-- page_view:浏览 + 停留副行(active_ms>0 才显示)+ title 小字 --> <div class="evt-content">
<template v-if="e.event_name === 'page_view'"> <!-- 标题行:事件名粗体(page_stay 独立条目带时长) -->
<div> <div class="evt-title">
浏览
<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 }} {{ 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> </div>
</template> <div v-if="e.path" class="evt-path">{{ e.path }}</div>
</ElTimelineItem> <div v-if="e.title" class="evt-page-title">{{ e.title }}</div>
</ElTimeline> <!-- 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> </template>
<script setup lang="ts"> <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 { Cpu, InfoFilled, Timer, View } from '@element-plus/icons-vue'
import type { TimelineEvent } from '@/api/tracker/visitors' import type { TimelineEvent } from '@/api/tracker/visitors'
import { formatStay } from '@/utils/tracker' import { formatStay } from '@/utils/tracker'
@@ -56,34 +47,113 @@
/** ISO(后端上海时间)→ HH:mm */ /** ISO(后端上海时间)→ HH:mm */
const formatTime = (iso?: string | null): string => (iso ? iso.slice(11, 16) : '—') const formatTime = (iso?: string | null): string => (iso ? iso.slice(11, 16) : '—')
/** 事件行图标/节点色:page_view=浏览、page_stay=计时、sdk_loaded=加载、其余兜底 */ /** 事件行图标/标题:page_view=浏览页面、page_stay=页面停留、sdk_loaded=SDK 加载、其余兜底 */
const EVENT_META: Record< const EVENT_META: Record<string, { icon: Component; label: string }> = {
string, page_view: { icon: markRaw(View), label: '浏览页面' },
{ icon: Component; type: 'primary' | 'warning' | 'info'; label: string } page_stay: { icon: markRaw(Timer), label: '页面停留' },
> = { sdk_loaded: { icon: markRaw(Cpu), label: 'SDK 加载' }
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) => const eventMeta = (name: string): { icon: Component; label: string } =>
EVENT_META[name] ?? { icon: InfoFilled, type: 'info', label: name } EVENT_META[name] ?? { icon: markRaw(InfoFilled), label: name }
</script> </script>
<style lang="scss" scoped> <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-family: var(--el-font-family-mono, monospace);
font-size: 12px; font-size: 12px;
color: #86909c;
text-overflow: ellipsis;
white-space: nowrap;
} }
.stay-line { .evt-page-title {
margin-top: 2px; margin-top: 2px;
font-size: 12px; font-size: 12px;
color: var(--el-color-warning); color: #86909c;
} }
.title-line { // 停留时长:「停留」灰字 + 时长深色粗体
.evt-stay {
display: block;
margin-top: 2px; margin-top: 2px;
font-size: 12px; 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> </style>
File diff suppressed because it is too large Load Diff
+37 -4
View File
@@ -208,6 +208,31 @@ class VisitorListView(EnvelopeMixin, APIView):
return ok({"list": rows, "total": total, "page": page}) return ok({"list": rows, "total": total, "page": page})
def _source_evidence_triplet(visitor, ft, entries):
"""来源证据三口径(详情页「来源证据」卡的数据面)。
first_observable 首次可观测来源:first_touch 分类原样(含 direct/unknown——诚实展示
"首访只观察到直接访问");无分类行(历史数据未重算)→ unknown 占位。
first_identifiable 首次可识别来源:first_touch 本身可识别(resolved/label_only/conflict,
即除"没认出来"外的全部状态)直接用;否则回溯最早一条可识别的会话入口
——首访 direct 不算"可识别",后续某天才带上可识别来源是常见轨迹。
都没有 → None(前端显示 —,不虚构)。
latest_session 本次会话来源:最新一条会话入口分类;无 → None。
"""
first_observable = _classification(ft) or {"channel": "unknown", "resolution": "unknown"}
if ft and ft.resolution in ("resolved", "label_only", "conflict"):
first_identifiable = _classification(ft)
else:
earliest = (SourceClassification.objects
.filter(visitor=visitor, scope="session_entry",
resolution__in=("resolved", "label_only"))
.select_related("session")
.order_by("session__started_at", "pk").first())
first_identifiable = _classification(earliest, with_session=True)
latest_session = _classification(entries[0], with_session=True) if entries else None
return first_observable, first_identifiable, latest_session
class VisitorDetailView(EnvelopeMixin, APIView): class VisitorDetailView(EnvelopeMixin, APIView):
def get(self, request, visitor_id): def get(self, request, visitor_id):
visitor = _annotate(Visitor.objects.filter(visitor_id=visitor_id) visitor = _annotate(Visitor.objects.filter(visitor_id=visitor_id)
@@ -222,16 +247,24 @@ class VisitorDetailView(EnvelopeMixin, APIView):
cutoff = timezone.now() - ONLINE_WINDOW cutoff = timezone.now() - ONLINE_WINDOW
ft = (SourceClassification.objects.filter(visitor=visitor, scope="first_touch") ft = (SourceClassification.objects.filter(visitor=visitor, scope="first_touch")
.first()) .first())
entries = (SourceClassification.objects entries = list(SourceClassification.objects
.filter(visitor=visitor, scope="session_entry") .filter(visitor=visitor, scope="session_entry")
.select_related("session") # session_id 随行带出,免逐行查询 .select_related("session") # session_id 随行带出,免逐行查询
.order_by("-session__started_at", "-pk")[:ATTR_SESSION_ENTRIES]) .order_by("-session__started_at", "-pk")[:ATTR_SESSION_ENTRIES])
first_observable, first_identifiable, latest_session = \
_source_evidence_triplet(visitor, ft, entries)
return ok({**_row(visitor, last_session, cutoff, ft), return ok({**_row(visitor, last_session, cutoff, ft),
# 浏览页面数:page_view 事件计数(page_stay 不并计——它是停留信号不是浏览)
"pages_viewed": Event.objects.filter(
visitor=visitor, event_name="page_view").count(),
"screen": visitor.screen, "timezone_name": visitor.timezone_name, "screen": visitor.screen, "timezone_name": visitor.timezone_name,
"last_ip": visitor.last_ip, "first_utm": visitor.first_utm or {}, "last_ip": visitor.last_ip, "first_utm": visitor.first_utm or {},
"first_landing_path": visitor.first_landing_path, "first_landing_path": visitor.first_landing_path,
"first_referrer_domain": visitor.first_referrer_domain, "first_referrer_domain": visitor.first_referrer_domain,
"attribution": {"first_touch": _classification(ft), "attribution": {"first_touch": _classification(ft),
"first_observable": first_observable,
"first_identifiable": first_identifiable,
"latest_session": latest_session,
"session_entries": [_classification(e, with_session=True) "session_entries": [_classification(e, with_session=True)
for e in entries]}, for e in entries]},
"sessions": [_session_summary(s) for s in sessions]}) "sessions": [_session_summary(s) for s in sessions]})
+51 -4
View File
@@ -176,15 +176,21 @@ def test_detail_shape(client, member_user):
v = _visitor(site, "v1", screen="1920x1080", timezone_name="Asia/Shanghai", v = _visitor(site, "v1", screen="1920x1080", timezone_name="Asia/Shanghai",
last_ip="1.2.3.4", first_utm={"utm_source": "google"}, last_ip="1.2.3.4", first_utm={"utm_source": "google"},
first_referrer_domain="google.com", first_landing_path="/landing") first_referrer_domain="google.com", first_landing_path="/landing")
_session(site, v, "s-old", at=NOW - timedelta(hours=2), s_old = _session(site, v, "s-old", at=NOW - timedelta(hours=2),
referrer_domain="google.com", stay_ms=60000, duration_ms=300000, event_count=3) referrer_domain="google.com", stay_ms=60000, duration_ms=300000,
_session(site, v, "s-new", at=NOW, utm={"utm_source": "wechat"}, event_count=3)
landing_path="/new", stay_ms=30000, duration_ms=120000, event_count=2) s_new = _session(site, v, "s-new", at=NOW, utm={"utm_source": "wechat"},
landing_path="/new", stay_ms=30000, duration_ms=120000, event_count=2)
_event(site, v, s_old, "e1", "page_view", NOW - timedelta(hours=2), path="/a")
_event(site, v, s_old, "e2", "page_stay", NOW - timedelta(minutes=119),
path="/a", active_ms=1000)
_event(site, v, s_new, "e3", "page_view", NOW, path="/b")
r = client.get("/api/admin/visitors/v1") r = client.get("/api/admin/visitors/v1")
assert r.status_code == 200 assert r.status_code == 200
d = r.json()["data"] d = r.json()["data"]
assert d["site_id"] == site.site_id and d["site_name"] == "s" # 详情同样输出站点归属 assert d["site_id"] == site.site_id and d["site_name"] == "s" # 详情同样输出站点归属
assert d["pages_viewed"] == 2 # 只计 page_view;page_stay 是停留信号不并计
assert d["screen"] == "1920x1080" and d["timezone_name"] == "Asia/Shanghai" assert d["screen"] == "1920x1080" and d["timezone_name"] == "Asia/Shanghai"
assert d["last_ip"] == "1.2.3.4" and d["first_utm"] == {"utm_source": "google"} assert d["last_ip"] == "1.2.3.4" and d["first_utm"] == {"utm_source": "google"}
assert d["first_landing_path"] == "/landing" and d["first_referrer_domain"] == "google.com" assert d["first_landing_path"] == "/landing" and d["first_referrer_domain"] == "google.com"
@@ -382,3 +388,44 @@ def test_detail_attribution_block(client, member_user):
assert ft["computed_at"] and ft["detail"] == {} assert ft["computed_at"] and ft["detail"] == {}
assert [e["session_id"] for e in attr["session_entries"]] == ["s2", "s1"] # 最近在前 assert [e["session_id"] for e in attr["session_entries"]] == ["s2", "s1"] # 最近在前
assert attr["session_entries"][0]["channel"] == "ai_referral" assert attr["session_entries"][0]["channel"] == "ai_referral"
def test_detail_source_evidence_triplet(client, member_user):
"""三口径:首日 direct(不可识别)+ 次日 Google——first_observable=direct/unknown,
first_identifiable 回溯到次日的可识别会话入口,latest_session=最新会话入口。"""
_auth(client, member_user)
site = Site.objects.create(name="s", domains=["a.com"])
v = _visitor(site, "v1")
s1 = _session(site, v, "s1", at=NOW - timedelta(days=1)) # 首日直接访问
s2 = _session(site, v, "s2", at=NOW, referrer_domain="google.com") # 次日 Google
_classify(site, v, "first_touch", "direct", resolution="unknown")
_classify(site, v, "session_entry", "direct", session=s1, resolution="unknown")
_classify(site, v, "session_entry", "natural_search", "Google", session=s2,
evidence_kind="observed_referrer", resolution="resolved")
r = client.get("/api/admin/visitors/v1")
assert r.status_code == 200
attr = r.json()["data"]["attribution"]
fo = attr["first_observable"]
assert fo["channel"] == "direct" and fo["resolution"] == "unknown" # 首访可观测=直接
fi = attr["first_identifiable"]
assert fi["channel"] == "natural_search" and fi["platform"] == "Google"
assert fi["resolution"] == "resolved" and fi["session_id"] == "s2" # 回溯到次日入口
ls = attr["latest_session"]
assert ls["channel"] == "natural_search" and ls["session_id"] == "s2"
def test_detail_source_evidence_no_classification(client, member_user):
"""历史数据无任何分类行:first_observable 给 unknown 占位,其余两口径为 null。"""
_auth(client, member_user)
site = Site.objects.create(name="s", domains=["a.com"])
v = _visitor(site, "v1")
_session(site, v, "s1", referrer_domain="google.com")
r = client.get("/api/admin/visitors/v1")
assert r.status_code == 200
attr = r.json()["data"]["attribution"]
assert attr["first_touch"] is None
assert attr["first_observable"] == {"channel": "unknown", "resolution": "unknown"}
assert attr["first_identifiable"] is None
assert attr["latest_session"] is None