feat(admin): 来源归因展示——渠道徽章/筛选/来源识别卡/渠道构成图

- 类型: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 <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-16 17:19:31 +08:00
parent 64d5daf921
commit f9c8c354ae
6 changed files with 319 additions and 34 deletions
+143 -3
View File
@@ -57,8 +57,56 @@
}}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="首触来源">
<div class="flex-c flex-wrap gap-1.5">
<ElDescriptionsItem label="首触来源" :span="2">
<!-- 来源识别卡:归因引擎结论(渠道徽章/平台/国家站/搜索词/可信度标注) -->
<div v-if="firstTouch" class="source-card">
<div class="flex-c flex-wrap gap-x-2 gap-y-1.5">
<span
class="channel-tag"
:style="{
color: firstTouchMeta.color,
background: firstTouchMeta.color + '1A',
borderColor: firstTouchMeta.color + '59'
}"
>
{{ firstTouchMeta.icon }} {{ firstTouchMeta.label }}
</span>
<span v-if="firstTouch.platform" class="font-medium whitespace-nowrap">
{{ firstTouch.platform }}
</span>
<span v-if="firstTouch.variant" class="text-xs text-g-500 whitespace-nowrap">
{{ firstTouch.variant }}
</span>
<span
v-if="firstTouch.resolution === 'label_only'"
class="text-xs text-g-500 whitespace-nowrap"
>
仅UTM标签
</span>
<span
v-else-if="firstTouch.resolution === 'conflict'"
class="text-xs source-conflict whitespace-nowrap"
>
证据冲突
</span>
</div>
<div v-if="firstTouch.search_term" class="search-term-row">
<span class="shrink-0 text-xs text-g-600">搜索词</span>
<span class="search-term">{{ firstTouch.search_term }}</span>
</div>
<!-- 冲突时并列展示两路证据(文本插值渲染,禁 v-html) -->
<div
v-if="firstTouch.resolution === 'conflict' && conflictEvidences.length"
class="conflict-box"
>
<div v-for="(ev, i) in conflictEvidences" :key="i" class="conflict-line">
<span class="shrink-0 text-xs text-g-600">{{ ev.label }}</span>
<span class="text-xs">{{ ev.text }}</span>
</div>
</div>
</div>
<!-- 降级:无归因(旧数据未重算)→ 原 first_source 口径 + UTM 标签 -->
<div v-else class="flex-c flex-wrap gap-1.5">
<span class="whitespace-nowrap">{{ firstSourceText }}</span>
<ElTag
v-for="(val, key) in detail.first_utm"
@@ -368,6 +416,7 @@
getSession,
getTimeline,
getVisitor,
type AttributionInfo,
type RawBatchRow,
type SessionSummary,
type TimelineEvent,
@@ -375,7 +424,7 @@
type TimelineSession,
type VisitorDetail
} from '@/api/tracker/visitors'
import { countryFlag, formatDateTime, formatStay } from '@/utils/tracker'
import { CHANNEL_LABELS, countryFlag, formatDateTime, formatStay } from '@/utils/tracker'
import EventTimeline from './components/EventTimeline.vue'
defineOptions({ name: 'TrackerVisitorDetail' })
@@ -435,6 +484,48 @@
return d.first_referrer_domain || d.first_utm?.utm_source || '直接访问'
})
// ---------- 来源识别卡(归因引擎结论;旧数据未重算时降级上面的 first_source 口径) ----------
/** detail 字段类型收窄:归因 detail 是后端拼的 dict,只接受纯对象 */
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v)
/** 证据记录 → 一行可读文本:渠道 · 平台(· 变体)+ referrer 域名括注 */
const formatEvidence = (e: Record<string, unknown>, 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<AttributionInfo | null>(
() => 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;