/** * 访客统计共享格式化工具(列表/详情页复用) * * 后端时间戳已统一 timezone.localtime().isoformat()(上海时间)序列化, * 前端切串直显即可,不做时区换算。 */ /** 国家 → 国旗 emoji(ip2region 中国 IP 为中文"中国",海外为英文名;未命中默认 🌐) */ const COUNTRY_FLAGS: Record = { 中国: '🇨🇳', '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 = { 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('') }