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
+40
View File
@@ -36,6 +36,46 @@ const COUNTRY_FLAGS: Record<string, string> = {
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<string, { label: string; icon: string; color: string }> = {
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) : '—'