c9967b6d70
Co-Authored-By: Claude Code <noreply@anthropic.com>
122 lines
5.1 KiB
TypeScript
122 lines
5.1 KiB
TypeScript
/**
|
||
* 访客统计共享格式化工具(列表/详情页复用)
|
||
*
|
||
* 后端时间戳已统一 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' },
|
||
paid_social: { label: '付费社交', icon: '📣', color: '#F759AB' }, // 归因 v2:fbclid / fb 家族+付费 medium
|
||
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} ${meta.label}${platform ? `·${platform}` : ''}`
|
||
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) : '—'
|
||
|
||
/** 上海自然日今天(YYYY-MM-DD;后端 ISO 串同为上海时间,日期部分可直接比对) */
|
||
const shanghaiDate = (): string =>
|
||
new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(new Date())
|
||
|
||
/** YYYY-MM-DD 平移 n 天(按 UTC 毫秒运算,不受本地时区夏令时干扰) */
|
||
const shiftDate = (date: string, days: number): string => {
|
||
const d = new Date(`${date}T00:00:00Z`)
|
||
d.setUTCDate(d.getUTCDate() + days)
|
||
return d.toISOString().slice(0, 10)
|
||
}
|
||
|
||
/** ISO 时间 → 智能短时间:今天 HH:mm / 昨天 HH:mm / 更早 MM-DD HH:mm(空值 —)
|
||
* 后端已是上海时间,日期部分与 shanghaiDate 同口径比对,不做时区换算 */
|
||
export const formatSmartTime = (iso?: string | null): string => {
|
||
if (!iso) return '—'
|
||
const date = iso.slice(0, 10)
|
||
const time = iso.slice(11, 16)
|
||
const today = shanghaiDate()
|
||
if (date === today) return `今天 ${time}`
|
||
if (date === shiftDate(today, -1)) return `昨天 ${time}`
|
||
return `${date.slice(5)} ${time}`
|
||
}
|
||
|
||
/** 毫秒 → 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('')
|
||
}
|