Files
fangke/admin/src/utils/tracker/index.ts
T
浅念 f9c8c354ae 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>
2026-09-16 17:19:31 +08:00

98 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 访客统计共享格式化工具(列表/详情页复用)
*
* 后端时间戳已统一 timezone.localtime().isoformat()(上海时间)序列化,
* 前端切串直显即可,不做时区换算。
*/
/** 国家 → 国旗 emoji(ip2region 中国 IP 为中文"中国",海外为英文名;未命中默认 🌐) */
const COUNTRY_FLAGS: Record<string, string> = {
中国: '🇨🇳',
'United States': '🇺🇸',
Japan: '🇯🇵',
'South Korea': '🇰🇷',
Singapore: '🇸🇬',
'United Kingdom': '🇬🇧',
Germany: '🇩🇪',
France: '🇫🇷',
Canada: '🇨🇦',
Australia: '🇦🇺',
Russia: '🇷🇺',
India: '🇮🇳',
Brazil: '🇧🇷',
Vietnam: '🇻🇳',
Thailand: '🇹🇭',
Malaysia: '🇲🇾',
Indonesia: '🇮🇩',
Philippines: '🇵🇭',
Netherlands: '🇳🇱',
Italy: '🇮🇹',
Spain: '🇪🇸',
Turkey: '🇹🇷',
Iran: '🇮🇷'
}
/** 国家名 → 国旗 emoji(空/未命中返回 🌐) */
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) : '—'
/** 毫秒 → x天x小时x分x秒(智能省略:为 0 的单位不显示;全 0 / 不足 1 秒显示 0秒) */
export const formatStay = (ms?: number | null): string => {
const totalSec = Math.max(0, Math.floor((ms ?? 0) / 1000))
const days = Math.floor(totalSec / 86400)
const hours = Math.floor((totalSec % 86400) / 3600)
const minutes = Math.floor((totalSec % 3600) / 60)
const seconds = totalSec % 60
const parts: string[] = []
if (days) parts.push(`${days}天`)
if (hours) parts.push(`${hours}小时`)
if (minutes) parts.push(`${minutes}分`)
// 仅当非零或无更高单位时显示秒(与下方条件表达式语义一致)
if (seconds || !parts.length) parts.push(`${seconds}秒`)
return parts.join('')
}