From 008d9277d8ccb623f1a672ceb191ae00a40d39a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Wed, 16 Sep 2026 17:43:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9D=A5=E6=BA=90=E7=BB=84=E5=90=88?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E2=80=94=E2=80=94=E5=BE=BD=E7=AB=A0/?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E5=8D=A1=E6=8B=BC=E8=A3=85=E8=90=BD=E5=9C=B0?= =?UTF-8?q?=E9=A1=B5=C2=B7=E5=9B=BD=E5=AE=B6=C2=B7=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=EF=BC=88=E6=90=9C=E7=B4=A2=E6=84=8F=E5=9B=BE=E7=BA=BF=E7=B4=A2?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- admin/src/api/tracker/visitors.ts | 3 ++- admin/src/views/tracker/visitor/detail.vue | 23 ++++++++++++++++++++++ admin/src/views/tracker/visitor/index.vue | 20 ++++++++++++++++--- server/tracker/api/visitors.py | 1 + server/tracker/tests/test_visitors.py | 10 ++++++---- 5 files changed, 49 insertions(+), 8 deletions(-) diff --git a/admin/src/api/tracker/visitors.ts b/admin/src/api/tracker/visitors.ts index c39cd3c..cde3b83 100644 --- a/admin/src/api/tracker/visitors.ts +++ b/admin/src/api/tracker/visitors.ts @@ -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 - first_landing_path: string first_referrer_domain: string /** 来源归因块(first_touch=null 时前端回落 first_source 展示) */ attribution: AttributionBlock diff --git a/admin/src/views/tracker/visitor/detail.vue b/admin/src/views/tracker/visitor/detail.vue index 3a99f1a..7dc88a9 100644 --- a/admin/src/views/tracker/visitor/detail.vue +++ b/admin/src/views/tracker/visitor/detail.vue @@ -94,6 +94,22 @@ 搜索词 {{ firstTouch.search_term }} + +
+ 入口页 + + — +
+ +
+ 上下文 + {{ sourceContextText }} +
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 diff --git a/admin/src/views/tracker/visitor/index.vue b/admin/src/views/tracker/visitor/index.vue index 44bd424..d10f82f 100644 --- a/admin/src/views/tracker/visitor/index.vue +++ b/admin/src/views/tracker/visitor/index.vue @@ -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)) } ) } diff --git a/server/tracker/api/visitors.py b/server/tracker/api/visitors.py index 57c8210..eb63605 100644 --- a/server/tracker/api/visitors.py +++ b/server/tracker/api/visitors.py @@ -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 "", diff --git a/server/tracker/tests/test_visitors.py b/server/tracker/tests/test_visitors.py index 1e2bc44..050767c 100644 --- a/server/tracker/tests/test_visitors.py +++ b/server/tracker/tests/test_visitors.py @@ -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