From f9c8c354ae9c815ca06ed85bfe05be479497dec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Wed, 16 Sep 2026 17:19:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=9D=A5=E6=BA=90=E5=BD=92?= =?UTF-8?q?=E5=9B=A0=E5=B1=95=E7=A4=BA=E2=80=94=E2=80=94=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E5=BE=BD=E7=AB=A0/=E7=AD=9B=E9=80=89/=E6=9D=A5=E6=BA=90?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E5=8D=A1/=E6=B8=A0=E9=81=93=E6=9E=84?= =?UTF-8?q?=E6=88=90=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 类型:VisitorRow 补 channel/platform/variant/search_term/resolution,新增 AttributionInfo/AttributionBlock,详情响应带 attribution 块;dashboard 响应补 channels(ChannelItem) - 工具:CHANNEL_LABELS 八渠道元数据 + channelBadge 徽章串(搜索词/标签/ 冲突后缀,未归因回 —) - 访客列表:首触来源列升级渠道徽章(first_source 进 tooltip 兜底), 筛选栏加渠道下拉(channel= 枚举) - 访客详情:首触来源升级来源识别卡(渠道徽章/平台/国家站/搜索词显著 展示,label_only/conflict 诚实标注,冲突并列两路证据文本渲染), 无归因旧数据降级原 first_source 口径 - 总览:来源饼图改渠道构成(标签/颜色按渠道绑定 CHANNEL_LABELS), 下方保留平台明细小表(原 sources 域名口径) Co-Authored-By: Claude Code --- admin/src/api/tracker/dashboard.ts | 9 ++ admin/src/api/tracker/visitors.ts | 37 +++++ admin/src/utils/tracker/index.ts | 40 ++++++ admin/src/views/tracker/dashboard/index.vue | 55 +++++--- admin/src/views/tracker/visitor/detail.vue | 146 +++++++++++++++++++- admin/src/views/tracker/visitor/index.vue | 66 +++++++-- 6 files changed, 319 insertions(+), 34 deletions(-) diff --git a/admin/src/api/tracker/dashboard.ts b/admin/src/api/tracker/dashboard.ts index 5c0da12..9e22907 100644 --- a/admin/src/api/tracker/dashboard.ts +++ b/admin/src/api/tracker/dashboard.ts @@ -31,6 +31,13 @@ export interface SourceItem { sessions: number } +/** 今日渠道构成 TOP8(session_entry 分类聚合,sessions 去重计数; + * channel 为 CHANNELS 枚举,无分类行不计入——未重算的历史会话不在此口径内) */ +export interface ChannelItem { + channel: string + sessions: number +} + /** 今日热门页面 TOP8(page_view 按 path 聚合) */ export interface PageItem { path: string @@ -42,6 +49,8 @@ export interface OverviewResp { today: TodayCounts trend: TrendPoint[] sources: SourceItem[] + /** 来源渠道聚合(sources 的归因升级版,原始域名口径保留在 sources) */ + channels: ChannelItem[] top_pages: PageItem[] } diff --git a/admin/src/api/tracker/visitors.ts b/admin/src/api/tracker/visitors.ts index 0c3427b..c39cd3c 100644 --- a/admin/src/api/tracker/visitors.ts +++ b/admin/src/api/tracker/visitors.ts @@ -30,6 +30,12 @@ export interface VisitorRow { total_stay_ms: number first_source: string last_source: string + /** 首触来源归因(first_touch 分类;无分类行/未重算的历史访客为 '',前端回落 first_source 展示) */ + channel: string + platform: string + variant: string + search_term: string + resolution: string online: boolean } @@ -45,6 +51,35 @@ export interface SessionSummary { stay_ms: number } +/** 单条来源识别(visitors.py `_classification`:classify() 结果 + 规则版本/计算时间) */ +export interface AttributionInfo { + /** CHANNELS 枚举:natural_search/paid_search/ai_referral/social/in_app/referral/direct/unknown */ + channel: string + /** 平台显示名(Google/ChatGPT/微信…;referral 渠道为来源域名) */ + platform: string + /** 国家站变体(如 google.ru 的 "ru") */ + variant: string + /** 搜索词(仅自然搜索可能有值) */ + search_term: string + /** observed_referrer / utm_label / combined / observed_ua */ + evidence_kind: string + /** resolved / label_only / conflict / unknown */ + resolution: string + /** 规则侧附加证据(conflict 时含 referrer_evidence/utm_evidence 并列)——只做文本渲染,禁 v-html */ + detail: Record + /** 生成该分类的规则版本(attribution.py RULE_VERSION) */ + rule_version: string + computed_at: string + /** 仅 session_entries 携带(first_touch 无会话归属,不带该键) */ + session_id?: string | null +} + +/** 详情归因块:first_touch 主口径 + 近几次会话入口识别(旧数据未重算时 first_touch 为 null) */ +export interface AttributionBlock { + first_touch: AttributionInfo | null + session_entries: AttributionInfo[] +} + /** 访客详情(`_row` + 详情扩展字段 + 全量会话摘要,M1 不分页) */ export interface VisitorDetail extends VisitorRow { screen: string @@ -54,6 +89,8 @@ export interface VisitorDetail extends VisitorRow { first_utm: Record first_landing_path: string first_referrer_domain: string + /** 来源归因块(first_touch=null 时前端回落 first_source 展示) */ + attribution: AttributionBlock sessions: SessionSummary[] } diff --git a/admin/src/utils/tracker/index.ts b/admin/src/utils/tracker/index.ts index d9d1800..f29283b 100644 --- a/admin/src/utils/tracker/index.ts +++ b/admin/src/utils/tracker/index.ts @@ -36,6 +36,46 @@ const COUNTRY_FLAGS: Record = { export const countryFlag = (country?: string | null): string => (country && COUNTRY_FLAGS[country]) || '🌐' +/** + * 来源渠道展示元数据(key 与后端 CHANNELS 枚举一一对应,见 server/tracker/constants.py)。 + * icon 走 emoji;color 用于渠道徽章底色/图表取色(列表/详情/总览三处共用一套口径)。 + */ +export const CHANNEL_LABELS: Record = { + natural_search: { label: '自然搜索', icon: '🔍', color: '#409EFF' }, + paid_search: { label: '付费广告', icon: '💰', color: '#F56C6C' }, + ai_referral: { label: 'AI 引用', icon: '🤖', color: '#9B59B6' }, + social: { label: '社交媒体', icon: '💬', color: '#E6A23C' }, + in_app: { label: '应用内', icon: '📱', color: '#67C23A' }, + referral: { label: '外部链接', icon: '🔗', color: '#909399' }, + direct: { label: '直接访问', icon: '⏱', color: '#C0C4CC' }, + unknown: { label: '未知', icon: '❓', color: '#C0C4CC' } +} + +/** channelBadge 入参字段(VisitorRow / AttributionInfo 的共同子集) */ +export interface ChannelBadgeRow { + channel: string + platform: string + variant: string + search_term: string + resolution: string +} + +/** + * 渠道徽章显示串:icon + platform(·variant),无 platform 时退渠道名; + * 有搜索词附加「词」;label_only / conflict 后缀诚实标注;channel 空(未归因)返回 —。 + * 例:"🔍 Google·ru"、"🤖 ChatGPT"、"📱 微信"、"🔍 百度「挖掘机」"、"⏱ 直接访问" + */ +export function channelBadge(row: ChannelBadgeRow): string { + if (!row.channel) return '—' + const meta = CHANNEL_LABELS[row.channel] ?? CHANNEL_LABELS.unknown + const platform = [row.platform, row.variant].filter(Boolean).join('·') + let text = `${meta.icon} ${platform || meta.label}` + if (row.search_term) text += `「${row.search_term}」` + if (row.resolution === 'label_only') text += '(标签)' + else if (row.resolution === 'conflict') text += '(冲突)' + return text +} + /** ISO 时间 → YYYY-MM-DD HH:mm(后端已是上海时间;空值显示 —) */ export const formatDateTime = (iso?: string | null): string => iso ? iso.replace('T', ' ').slice(0, 16) : '—' diff --git a/admin/src/views/tracker/dashboard/index.vue b/admin/src/views/tracker/dashboard/index.vue index 625e4a5..67bc481 100644 --- a/admin/src/views/tracker/dashboard/index.vue +++ b/admin/src/views/tracker/dashboard/index.vue @@ -8,9 +8,10 @@ 今日指标为上海自然日口径,已排除爬虫
站点 + @@ -67,17 +68,32 @@
-

来源构成(今日 TOP8)

- 按会话数统计,无referrer 归为"直接访问" +

渠道构成(今日 TOP8)

+ 按归因规则聚合会话数,未重算的历史会话不计入
+ +
+
+

平台明细(今日 TOP8)

+ 无 referrer 归为"直接访问" +
+ + + + + + +
@@ -104,22 +120,13 @@ import { useChartOps } from '@/hooks/core/useChart' import { getSites, type SiteRow } from '@/api/tracker/sites' import { getOverview, type OverviewResp } from '@/api/tracker/dashboard' + import { CHANNEL_LABELS } from '@/utils/tracker' defineOptions({ name: 'TrackerDashboard' }) /** 图表色板:模板主题色为主,剔除过浅色与重复色(useChartOps 默认第 3 位 #EDF2FF 在浅色底不可见) */ const themeColor = useChartOps().themeColor const lineColors = [themeColor, '#4ABEFF', '#14DEBA', '#FFAF20'] - const pieColors = [ - themeColor, - '#4ABEFF', - '#14DEBA', - '#FFAF20', - '#FA8A6C', - '#67C23A', - '#E6A23C', - '#9AA5CE' - ] // 站点筛选(哨兵值 ALL_SITES = 全部站点;空串会被 ElSelect 当空值显示 placeholder) const ALL_SITES = 'ALL_SITES' @@ -195,10 +202,20 @@ ] }) - // 来源饼图("直接访问"由后端天然归并) - const sourceData = computed(() => - (overview.value?.sources ?? []).map((s) => ({ name: s.source, value: s.sessions })) + // 渠道构成饼图(归因口径):标签/颜色都按渠道绑定取自 CHANNEL_LABELS—— + // 颜色随 data 同序映射,渠道缺席时也不会错位到别的渠道色 + const channelData = computed(() => + (overview.value?.channels ?? []).map((c) => ({ + name: CHANNEL_LABELS[c.channel]?.label ?? c.channel, + value: c.sessions + })) ) + const channelColors = computed(() => + (overview.value?.channels ?? []).map((c) => CHANNEL_LABELS[c.channel]?.color ?? '#C0C4CC') + ) + + // 平台明细(原始 referrer 域名口径,与渠道归因互补) + const platformSources = computed(() => overview.value?.sources ?? []) // 热门页面 const topPages = computed(() => overview.value?.top_pages ?? []) diff --git a/admin/src/views/tracker/visitor/detail.vue b/admin/src/views/tracker/visitor/detail.vue index 3efa985..3a99f1a 100644 --- a/admin/src/views/tracker/visitor/detail.vue +++ b/admin/src/views/tracker/visitor/detail.vue @@ -57,8 +57,56 @@ }} - -
+ + +
+
+ + {{ firstTouchMeta.icon }} {{ firstTouchMeta.label }} + + + {{ firstTouch.platform }} + + + {{ firstTouch.variant }} + + + 仅UTM标签 + + + 证据冲突 + +
+
+ 搜索词 + {{ firstTouch.search_term }} +
+ +
+
+ {{ ev.label }} + {{ ev.text }} +
+
+
+ +
{{ firstSourceText }} => + typeof v === 'object' && v !== null && !Array.isArray(v) + + /** 证据记录 → 一行可读文本:渠道 · 平台(· 变体)+ referrer 域名括注 */ + const formatEvidence = (e: Record, withHost: boolean): string => { + const segs = [ + CHANNEL_LABELS[String(e.channel ?? '')]?.label || String(e.channel ?? ''), + String(e.platform ?? ''), + String(e.variant ?? '') + ] + .filter(Boolean) + .join(' · ') + const host = withHost && e.referrer_host ? `(${String(e.referrer_host)})` : '' + return `${segs}${host}` + } + + /** 首触归因(attribution.first_touch;无分类行时后端给 null → 模板走降级分支) */ + const firstTouch = computed( + () => detail.value?.attribution?.first_touch ?? null + ) + + /** 渠道徽章元数据(未知渠道值兜底 unknown 项) */ + const firstTouchMeta = computed( + () => CHANNEL_LABELS[firstTouch.value?.channel ?? ''] ?? CHANNEL_LABELS.unknown + ) + + /** conflict 时并列的两路证据(referrer 观察 vs UTM 标签),文本插值渲染 */ + const conflictEvidences = computed(() => { + const d = firstTouch.value?.detail + if (!isRecord(d)) return [] + const out: { label: string; text: string }[] = [] + if (isRecord(d.referrer_evidence)) { + out.push({ label: '观察证据', text: formatEvidence(d.referrer_evidence, true) }) + } + if (isRecord(d.utm_evidence)) { + out.push({ label: '标签证据', text: formatEvidence(d.utm_evidence, false) }) + } + return out + }) + /** 统计数字 4 项(?? 只兜 nullish,0 正常显示) */ const statTiles = computed(() => [ { label: '访问次数', value: detail.value ? String(detail.value.visit_count) : '—' }, @@ -649,6 +740,55 @@ font-family: var(--el-font-family-mono, monospace); } + // 来源识别卡(首触归因结论) + .source-card { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + } + + .channel-tag { + padding: 2px 8px; + font-size: 12px; + line-height: 18px; + border: 1px solid transparent; + border-radius: 4px; + white-space: nowrap; + } + + .search-term-row { + display: flex; + gap: 6px; + align-items: baseline; + } + + .search-term { + font-size: 14px; + font-weight: 600; + color: var(--el-color-primary); + word-break: break-all; + } + + .source-conflict { + color: var(--el-color-danger); + } + + .conflict-box { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 10px; + border-radius: 6px; + background: var(--el-fill-color-lighter); + + .conflict-line { + display: flex; + gap: 8px; + align-items: baseline; + } + } + .stat-tile { display: flex; flex-direction: column; diff --git a/admin/src/views/tracker/visitor/index.vue b/admin/src/views/tracker/visitor/index.vue index 52906c3..44bd424 100644 --- a/admin/src/views/tracker/visitor/index.vue +++ b/admin/src/views/tracker/visitor/index.vue @@ -1,12 +1,13 @@ +