feat: 来源组合显示——徽章/识别卡拼装落地页·国家·语言(搜索意图线索)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-16 17:43:37 +08:00
parent f9c8c354ae
commit 008d9277d8
5 changed files with 49 additions and 8 deletions
+2 -1
View File
@@ -30,6 +30,8 @@ export interface VisitorRow {
total_stay_ms: number
first_source: string
last_source: string
/** 首次访问落地页(空串=未采集,如直达首页;列表 tooltip / 详情识别卡用) */
first_landing_path: string
/** 首触来源归因(first_touch 分类;无分类行/未重算的历史访客为 '',前端回落 first_source 展示) */
channel: string
platform: string
@@ -87,7 +89,6 @@ export interface VisitorDetail extends VisitorRow {
/** GenericIPAddressField(null=True),未采集到时为 null */
last_ip: string | null
first_utm: Record<string, string>
first_landing_path: string
first_referrer_domain: string
/** 来源归因块(first_touch=null 时前端回落 first_source 展示) */
attribution: AttributionBlock
@@ -94,6 +94,22 @@
<span class="shrink-0 text-xs text-g-600">搜索词</span>
<span class="search-term">{{ firstTouch.search_term }}</span>
</div>
<!-- 入口页:落地页是搜索意图的直接线索(如搜"挖掘机"落地产品页) -->
<div class="search-term-row">
<span class="shrink-0 text-xs text-g-600">入口页</span>
<template v-if="detail.first_landing_path">
<ElTooltip :content="detail.first_landing_path" placement="top">
<span class="mono-path landing-path">{{ detail.first_landing_path }}</span>
</ElTooltip>
<span class="text-xs text-g-500 shrink-0">落地页是搜索意图的直接线索</span>
</template>
<span v-else>—</span>
</div>
<!-- 上下文:国家·语言·设备一句话组合(空段省略;只做来源故事线,不重复描述列表) -->
<div v-if="sourceContextText" class="search-term-row">
<span class="shrink-0 text-xs text-g-600">上下文</span>
<span class="text-xs">{{ sourceContextText }}</span>
</div>
<!-- 冲突时并列展示两路证据(文本插值渲染,禁 v-html) -->
<div
v-if="firstTouch.resolution === 'conflict' && conflictEvidences.length"
@@ -512,6 +528,13 @@
() => CHANNEL_LABELS[firstTouch.value?.channel ?? ''] ?? CHANNEL_LABELS.unknown
)
/** 识别卡上下文一句话:国家 · 语言 · 设备类型(空段省略,全空不渲染该行) */
const sourceContextText = computed(() => {
const d = detail.value
if (!d) return ''
return [d.country, d.language, d.device_type].filter(Boolean).join(' · ')
})
/** conflict 时并列的两路证据(referrer 观察 vs UTM 标签),文本插值渲染 */
const conflictEvidences = computed(() => {
const d = firstTouch.value?.detail
+17 -3
View File
@@ -252,14 +252,28 @@
const renderSource = (val: string) => h('span', { class: 'whitespace-nowrap' }, val || '—')
/** 首触来源:有归因走渠道徽章(icon+platform/variant+搜索词+诚实后缀),first_source 原文进 tooltip 兜底 */
/** 首触来源 tooltip:意图与上下文组合多行(渠道按 channelBadge 同款数据拼装,搜索词有才显示) */
const firstSourceTooltipLines = (row: VisitorRow): string[] => {
const meta = CHANNEL_LABELS[row.channel] ?? CHANNEL_LABELS.unknown
const platform = [row.platform, row.variant].filter(Boolean).join('·')
const lines = [`渠道:${meta.icon} ${[meta.label, platform].filter(Boolean).join(' · ')}`]
if (row.search_term) lines.push(`搜索词:${row.search_term}`)
lines.push(`落地页:${row.first_landing_path || '—'}`)
lines.push(`国家·语言:${[row.country, row.language].filter(Boolean).join(' · ') || '—'}`)
return lines
}
/** 首触来源:有归因走渠道徽章(icon+platform/variant+搜索词+诚实后缀),tooltip 展示来源组合信息;
* 无归因(历史数据)回落 first_source 原文 */
const renderFirstSource = (row: VisitorRow) => {
if (!row.channel) return renderSource(row.first_source)
return h(
ElTooltip,
{ content: row.first_source || '—', placement: 'top' },
{ placement: 'top' },
{
default: () => h('span', { class: 'whitespace-nowrap' }, channelBadge(row))
default: () => h('span', { class: 'whitespace-nowrap' }, channelBadge(row)),
content: () =>
firstSourceTooltipLines(row).map((line, i) => h('div', { key: i }, line))
}
)
}
+1
View File
@@ -53,6 +53,7 @@ def _row(visitor, last_session, online_cutoff, ft=None):
"first_source": _source(visitor.first_referrer_domain, visitor.first_utm),
"last_source": _source(last_session.referrer_domain, last_session.utm)
if last_session else "直接访问", # 无会话访客(仅采集异常场景)
"first_landing_path": visitor.first_landing_path, # 列表首触来源 tooltip 用(详情本就输出)
"channel": ft.channel if ft else "",
"platform": ft.platform if ft else "",
"variant": ft.variant if ft else "",
+6 -4
View File
@@ -57,8 +57,8 @@ def test_list_fields_and_default_bot_excluded(client, member_user):
site = Site.objects.create(name="s", domains=["a.com"])
v1 = _visitor(site, "v1", country="中国", province="上海", city="上海",
device_type="desktop", os="Windows", browser="Chrome", language="zh-CN",
first_referrer_domain="google.com", visit_count=2, active_days=1,
last_seen=timezone.now())
first_referrer_domain="google.com", first_landing_path="/from-google",
visit_count=2, active_days=1, last_seen=timezone.now())
v2 = _visitor(site, "v2", first_utm={"utm_source": "baidu"},
last_seen=timezone.now() - timedelta(hours=1))
bot = _visitor(site, "bot-v")
@@ -77,16 +77,18 @@ def test_list_fields_and_default_bot_excluded(client, member_user):
assert set(row) == {"visitor_id", "site_id", "site_name", "country", "province", "city",
"device_type", "os", "browser", "language", "first_seen_at",
"last_seen_at", "visit_count", "active_days", "session_count",
"total_stay_ms", "first_source", "last_source", "online",
"channel", "platform", "variant", "search_term", "resolution"}
"total_stay_ms", "first_source", "last_source", "first_landing_path",
"online", "channel", "platform", "variant", "search_term", "resolution"}
assert row["country"] == "中国" and row["os"] == "Windows" and row["browser"] == "Chrome"
assert row["session_count"] == 2
assert row["total_stay_ms"] == 150000 # 120000+30000,有效停留口径
assert row["first_source"] == "google.com"
assert row["last_source"] == "bing.com" # 最近会话 s2 的 referrer
assert row["first_landing_path"] == "/from-google"
assert row["online"] is True
row2 = d["list"][1]
assert row2["first_source"] == "baidu" # first_referrer 空 → first_utm 兜底
assert row2["first_landing_path"] == "" # 未采集 → 空串(前端显示 —)
assert row2["last_source"] == "直接访问" # 会话无来源 → 兜底
assert row2["total_stay_ms"] == 5000 and row2["online"] is False