feat: 来源组合显示——徽章/识别卡拼装落地页·国家·语言(搜索意图线索)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,8 @@ export interface VisitorRow {
|
|||||||
total_stay_ms: number
|
total_stay_ms: number
|
||||||
first_source: string
|
first_source: string
|
||||||
last_source: string
|
last_source: string
|
||||||
|
/** 首次访问落地页(空串=未采集,如直达首页;列表 tooltip / 详情识别卡用) */
|
||||||
|
first_landing_path: string
|
||||||
/** 首触来源归因(first_touch 分类;无分类行/未重算的历史访客为 '',前端回落 first_source 展示) */
|
/** 首触来源归因(first_touch 分类;无分类行/未重算的历史访客为 '',前端回落 first_source 展示) */
|
||||||
channel: string
|
channel: string
|
||||||
platform: string
|
platform: string
|
||||||
@@ -87,7 +89,6 @@ export interface VisitorDetail extends VisitorRow {
|
|||||||
/** GenericIPAddressField(null=True),未采集到时为 null */
|
/** GenericIPAddressField(null=True),未采集到时为 null */
|
||||||
last_ip: string | null
|
last_ip: string | null
|
||||||
first_utm: Record<string, string>
|
first_utm: Record<string, string>
|
||||||
first_landing_path: string
|
|
||||||
first_referrer_domain: string
|
first_referrer_domain: string
|
||||||
/** 来源归因块(first_touch=null 时前端回落 first_source 展示) */
|
/** 来源归因块(first_touch=null 时前端回落 first_source 展示) */
|
||||||
attribution: AttributionBlock
|
attribution: AttributionBlock
|
||||||
|
|||||||
@@ -94,6 +94,22 @@
|
|||||||
<span class="shrink-0 text-xs text-g-600">搜索词</span>
|
<span class="shrink-0 text-xs text-g-600">搜索词</span>
|
||||||
<span class="search-term">{{ firstTouch.search_term }}</span>
|
<span class="search-term">{{ firstTouch.search_term }}</span>
|
||||||
</div>
|
</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) -->
|
<!-- 冲突时并列展示两路证据(文本插值渲染,禁 v-html) -->
|
||||||
<div
|
<div
|
||||||
v-if="firstTouch.resolution === 'conflict' && conflictEvidences.length"
|
v-if="firstTouch.resolution === 'conflict' && conflictEvidences.length"
|
||||||
@@ -512,6 +528,13 @@
|
|||||||
() => CHANNEL_LABELS[firstTouch.value?.channel ?? ''] ?? CHANNEL_LABELS.unknown
|
() => 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 标签),文本插值渲染 */
|
/** conflict 时并列的两路证据(referrer 观察 vs UTM 标签),文本插值渲染 */
|
||||||
const conflictEvidences = computed(() => {
|
const conflictEvidences = computed(() => {
|
||||||
const d = firstTouch.value?.detail
|
const d = firstTouch.value?.detail
|
||||||
|
|||||||
@@ -252,14 +252,28 @@
|
|||||||
|
|
||||||
const renderSource = (val: string) => h('span', { class: 'whitespace-nowrap' }, val || '—')
|
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) => {
|
const renderFirstSource = (row: VisitorRow) => {
|
||||||
if (!row.channel) return renderSource(row.first_source)
|
if (!row.channel) return renderSource(row.first_source)
|
||||||
return h(
|
return h(
|
||||||
ElTooltip,
|
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))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ def _row(visitor, last_session, online_cutoff, ft=None):
|
|||||||
"first_source": _source(visitor.first_referrer_domain, visitor.first_utm),
|
"first_source": _source(visitor.first_referrer_domain, visitor.first_utm),
|
||||||
"last_source": _source(last_session.referrer_domain, last_session.utm)
|
"last_source": _source(last_session.referrer_domain, last_session.utm)
|
||||||
if last_session else "直接访问", # 无会话访客(仅采集异常场景)
|
if last_session else "直接访问", # 无会话访客(仅采集异常场景)
|
||||||
|
"first_landing_path": visitor.first_landing_path, # 列表首触来源 tooltip 用(详情本就输出)
|
||||||
"channel": ft.channel if ft else "",
|
"channel": ft.channel if ft else "",
|
||||||
"platform": ft.platform if ft else "",
|
"platform": ft.platform if ft else "",
|
||||||
"variant": ft.variant if ft else "",
|
"variant": ft.variant if ft else "",
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ def test_list_fields_and_default_bot_excluded(client, member_user):
|
|||||||
site = Site.objects.create(name="s", domains=["a.com"])
|
site = Site.objects.create(name="s", domains=["a.com"])
|
||||||
v1 = _visitor(site, "v1", country="中国", province="上海", city="上海",
|
v1 = _visitor(site, "v1", country="中国", province="上海", city="上海",
|
||||||
device_type="desktop", os="Windows", browser="Chrome", language="zh-CN",
|
device_type="desktop", os="Windows", browser="Chrome", language="zh-CN",
|
||||||
first_referrer_domain="google.com", visit_count=2, active_days=1,
|
first_referrer_domain="google.com", first_landing_path="/from-google",
|
||||||
last_seen=timezone.now())
|
visit_count=2, active_days=1, last_seen=timezone.now())
|
||||||
v2 = _visitor(site, "v2", first_utm={"utm_source": "baidu"},
|
v2 = _visitor(site, "v2", first_utm={"utm_source": "baidu"},
|
||||||
last_seen=timezone.now() - timedelta(hours=1))
|
last_seen=timezone.now() - timedelta(hours=1))
|
||||||
bot = _visitor(site, "bot-v")
|
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",
|
assert set(row) == {"visitor_id", "site_id", "site_name", "country", "province", "city",
|
||||||
"device_type", "os", "browser", "language", "first_seen_at",
|
"device_type", "os", "browser", "language", "first_seen_at",
|
||||||
"last_seen_at", "visit_count", "active_days", "session_count",
|
"last_seen_at", "visit_count", "active_days", "session_count",
|
||||||
"total_stay_ms", "first_source", "last_source", "online",
|
"total_stay_ms", "first_source", "last_source", "first_landing_path",
|
||||||
"channel", "platform", "variant", "search_term", "resolution"}
|
"online", "channel", "platform", "variant", "search_term", "resolution"}
|
||||||
assert row["country"] == "中国" and row["os"] == "Windows" and row["browser"] == "Chrome"
|
assert row["country"] == "中国" and row["os"] == "Windows" and row["browser"] == "Chrome"
|
||||||
assert row["session_count"] == 2
|
assert row["session_count"] == 2
|
||||||
assert row["total_stay_ms"] == 150000 # 120000+30000,有效停留口径
|
assert row["total_stay_ms"] == 150000 # 120000+30000,有效停留口径
|
||||||
assert row["first_source"] == "google.com"
|
assert row["first_source"] == "google.com"
|
||||||
assert row["last_source"] == "bing.com" # 最近会话 s2 的 referrer
|
assert row["last_source"] == "bing.com" # 最近会话 s2 的 referrer
|
||||||
|
assert row["first_landing_path"] == "/from-google"
|
||||||
assert row["online"] is True
|
assert row["online"] is True
|
||||||
row2 = d["list"][1]
|
row2 = d["list"][1]
|
||||||
assert row2["first_source"] == "baidu" # first_referrer 空 → first_utm 兜底
|
assert row2["first_source"] == "baidu" # first_referrer 空 → first_utm 兜底
|
||||||
|
assert row2["first_landing_path"] == "" # 未采集 → 空串(前端显示 —)
|
||||||
assert row2["last_source"] == "直接访问" # 会话无来源 → 兜底
|
assert row2["last_source"] == "直接访问" # 会话无来源 → 兜底
|
||||||
assert row2["total_stay_ms"] == 5000 and row2["online"] is False
|
assert row2["total_stay_ms"] == 5000 and row2["online"] is False
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user