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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user