/** * OmniCard AI (CRMmp) - 名片主移动实时雷达业务驱动层 * 分页拉取访客轨迹,解析列表 / 空态 / 错误文案 / 关键指标聚合 */ import { OwnerPageData, OwnerPageQuery, RadarTrack } from '../../common/types.uts'; import { fetchOwnerRadarPage } from '../../common/api.uts'; import { getCurrentLocale, getOmniText } from '../../common/locale.uts'; export type RadarCopyKey = | 'title' | 'desc' | 'cardIdLabel' | 'cardIdPlaceholder' | 'refresh' | 'empty' | 'emptyHint' | 'error' | 'stay' | 'depth' | 'totalVisits' | 'intentRate' | 'intentTrend' | 'highIntent' | 'exploring' | 'normalIntent' | 'touchNfc' | 'touchQr' | 'touchLink' | 'justNow' | 'liveRadar' | 'activeNow' | 'blocksViewed' | 'filterAll' | 'avgStay' | 'unknown' | 'localNetwork' | 'networkAddress' | 'onlineNow' | 'openedNfc' | 'openedQr' | 'openedLink' | 'blockHero' | 'blockCase' | 'blockArticle' | 'blockSocial' | 'blockVideo' | 'blockForm' | 'blockAppointment' | 'blockContact'; export type LoadTracksInput = { cardId?: string | number | null; page?: number; size?: number; }; export type RadarFetchApi = { fetchOwnerRadarPage: (query: OwnerPageQuery) => Promise>; }; export type LoadTracksResult = { list: RadarTrack[]; empty: boolean; errorMessage: string; page: number; size: number; total: number; }; export type RadarStats = { totalVisits: number; highIntentCount: number; onlineCount: number; intentRateText: string; avgStayText: string; topLocation: string; }; export type RadarState = { cardIdRaw: string; page: number; size: number; total: number; loading: boolean; empty: boolean; errorMessage: string; tracks: RadarTrack[]; }; export type RadarPageOptions = { cardId?: string; id?: string; }; const DEFAULT_PAGE = 1; const DEFAULT_SIZE = 20; function isZhLocale(): boolean { return getCurrentLocale() === 'zh-CN'; } /** * 页面文案:标题与轨迹走词典,其余中英本地词典 */ export function resolveRadarText(key: RadarCopyKey): string { const locale = getCurrentLocale(); if (key === 'title') { return getOmniText(locale, 'radar.online_visitor'); } if (key === 'desc') { return getOmniText(locale, 'radar.visitor_trace'); } if (key === 'empty') { return getOmniText(locale, 'common.empty'); } if (key === 'emptyHint') { return isZhLocale() ? '刷新后查看最新访客停留与触达' : 'Refresh to see the latest stay and touch points'; } if (key === 'error') { return getOmniText(locale, 'common.error'); } if (key === 'activeNow') { return getOmniText(locale, 'radar.active_now'); } const zh = isZhLocale(); if (key === 'cardIdLabel') { return zh ? '名片编号' : 'Card ID'; } if (key === 'cardIdPlaceholder') { return zh ? '可选,输入名片编号' : 'Optional card ID'; } if (key === 'refresh') { return zh ? '刷新轨迹' : 'Refresh trail'; } if (key === 'stay') { return zh ? '停留' : 'Stay'; } if (key === 'depth') { return zh ? '滚动深度' : 'Scroll depth'; } if (key === 'totalVisits') { return zh ? '访问总量' : 'Total Visits'; } if (key === 'intentRate') { return zh ? '留资意向率' : 'Intent Rate'; } if (key === 'intentTrend') { return zh ? '意向深度走势' : 'Intent Trend'; } if (key === 'highIntent') { return zh ? '高意向' : 'High Intent'; } if (key === 'exploring') { return zh ? '深度浏览' : 'Exploring'; } if (key === 'normalIntent') { return zh ? '常规触达' : 'Standard'; } if (key === 'touchNfc') { return zh ? 'NFC 触碰' : 'NFC Tap'; } if (key === 'touchQr') { return zh ? '展台扫码' : 'QR Scan'; } if (key === 'touchLink') { return zh ? '直链访问' : 'Direct Link'; } if (key === 'justNow') { return zh ? '刚刚' : 'Just Now'; } if (key === 'liveRadar') { return zh ? '实时雷达监测中' : 'Live Radar Active'; } if (key === 'blocksViewed') { return zh ? '深度查看模块' : 'Viewed Blocks'; } if (key === 'filterAll') { return zh ? '全部名片' : 'All Cards'; } if (key === 'avgStay') { return zh ? '平均停留' : 'Avg Stay'; } if (key === 'unknown') { return zh ? '未知' : 'Unknown'; } if (key === 'localNetwork') { return zh ? '本地网络' : 'Local network'; } if (key === 'networkAddress') { return zh ? '网络地址' : 'Network'; } if (key === 'onlineNow') { return zh ? '在线' : 'Online'; } if (key === 'openedNfc') { return zh ? '通过 NFC 碰一碰打开名片' : 'Opened your card with an NFC tap'; } if (key === 'openedQr') { return zh ? '扫描展台二维码进入名片' : 'Opened your card from the QR showcase'; } if (key === 'openedLink') { return zh ? '通过链接打开了你的名片' : 'Opened your card from a direct link'; } if (key === 'blockHero') { return zh ? '名片主页' : 'Profile'; } if (key === 'blockCase') { return zh ? '案例' : 'Case'; } if (key === 'blockArticle') { return zh ? '文章' : 'Article'; } if (key === 'blockSocial') { return zh ? '社交' : 'Social'; } if (key === 'blockVideo') { return zh ? '视频' : 'Video'; } if (key === 'blockForm') { return zh ? '留资表单' : 'Lead form'; } if (key === 'blockAppointment') { return zh ? '预约会面' : 'Meeting'; } if (key === 'blockContact') { return zh ? '联系方式' : 'Contact'; } return ''; } /** * 解析为正整数。非正整数返回 null */ export function parseCardId(raw: string | number | null | undefined): number | null { if (raw == null) { return null; } if (typeof raw === 'number') { if (raw <= 0) { return null; } if (Math.floor(raw) !== raw) { return null; } return raw; } const text = String(raw).trim(); if (text === '') { return null; } if (!/^[1-9]\d*$/.test(text)) { return null; } return Number(text); } function normalizePage(raw: number | null | undefined): number { if (raw == null || !Number.isFinite(raw) || raw < 1) { return DEFAULT_PAGE; } return Math.floor(raw); } function normalizeSize(raw: number | null | undefined): number { if (raw == null || !Number.isFinite(raw) || raw < 1) { return DEFAULT_SIZE; } return Math.floor(raw); } /** * 停留时长:不足 1 秒用 ms,否则保留一位小数的秒 */ export function formatStay(ms: number | null | undefined): string { if (ms == null || !Number.isFinite(ms) || ms <= 0) { return '0ms'; } const safe = Math.round(ms); if (safe < 1000) { return `${safe}ms`; } if (safe >= 60000) { const minutes = Math.floor(safe / 60000); const seconds = Math.round((safe % 60000) / 1000); if (seconds <= 0) { return `${minutes}m`; } return `${minutes}m ${seconds}s`; } return `${(safe / 1000).toFixed(1)}s`; } /** * 滚动深度:纠偏到 0~100 后以百分比展示 */ export function formatDepth(depth: number | null | undefined): string { if (depth == null || !Number.isFinite(depth)) { return '0%'; } const clamped = Math.min(100, Math.max(0, Math.round(depth))); return `${clamped}%`; } function isBlankGeo(value: string): boolean { const text = value.trim().toLowerCase(); return text === '' || text === 'unknown' || text === 'null' || text === 'undefined'; } function isLocalGeo(value: string): boolean { const text = value.trim().toLowerCase(); return text === 'local' || text === 'localhost'; } /** * 国家 / 城市一行展示。空值与 Unknown 走「未知」,内网 Local 走「本地网络」,不回退示例城市 */ export function formatLocation(country?: string, city?: string): string { const countryText = country != null ? String(country).trim() : ''; const cityText = city != null ? String(city).trim() : ''; if (isLocalGeo(countryText) || isLocalGeo(cityText)) { return resolveRadarText('localNetwork'); } const displayCountry = !isBlankGeo(countryText) ? countryText : resolveRadarText('unknown'); const displayCity = !isBlankGeo(cityText) ? cityText : ''; if (displayCity !== '') { return `${displayCountry} / ${displayCity}`; } return displayCountry; } /** * 公网 IP 才展示。回环、内网与空地址不当作地理位置 */ export function formatPublicIp(ip?: string): string { const raw = ip != null ? String(ip).trim() : ''; if (raw === '') { return ''; } const clean = raw.replace(/^::ffff:/i, '').toLowerCase(); if ( clean === '' || clean === '::1' || clean === 'localhost' || clean === '0.0.0.0' || clean === '127.0.0.1' || clean.startsWith('127.') || clean.startsWith('10.') || clean.startsWith('192.168.') || clean.startsWith('169.254.') || /^172\.(1[6-9]|2\d|3[0-1])\./.test(clean) ) { return ''; } return raw.replace(/^::ffff:/i, ''); } export function formatNetworkLine(ip?: string): string { const pub = formatPublicIp(ip); if (pub === '') { return ''; } return `${resolveRadarText('networkAddress')} ${pub}`; } /** * 触碰类型标签简写 */ export function formatTouchTag(touchType: string | null | undefined): string { if (touchType === 'nfc') { return 'NFC'; } if (touchType === 'qr') { return 'QR'; } return 'LINK'; } /** * 判定是否为高意向信号 */ export function isHighIntent(stayDurationMs: number, scrollDepth: number): boolean { const longStay = stayDurationMs >= 180000; const ratio = scrollDepth > 1 ? scrollDepth / 100 : scrollDepth; return longStay || ratio >= 0.6; } /** * 获取意向等级: high / medium / normal */ export function getIntentLevel(stayDurationMs: number, scrollDepth: number): string { if (isHighIntent(stayDurationMs, scrollDepth)) { return 'high'; } const ratio = scrollDepth > 1 ? scrollDepth / 100 : scrollDepth; if (stayDurationMs >= 30000 || ratio >= 0.3) { return 'medium'; } return 'normal'; } /** * 获取意向语义徽标文字 */ export function getIntentBadgeText(level: string): string { if (level === 'high') { return resolveRadarText('highIntent'); } if (level === 'medium') { return resolveRadarText('exploring'); } return resolveRadarText('normalIntent'); } /** * 访客显示名:有线索或内部用户姓名就用姓名,否则用访客编号,不编造身份 */ export function formatVisitorName(item: RadarTrack): string { const named = item.visitorName != null ? String(item.visitorName).trim() : ''; if (named !== '') { return named; } const zh = isZhLocale(); const vid = item.visitorId != null && item.visitorId.trim() !== '' ? item.visitorId.trim() : ''; if (vid === '') { return zh ? '访客' : 'Visitor'; } const shortId = vid.replace(/^vis_/i, ''); const tail = shortId.length > 10 ? shortId.slice(-8) : shortId; return zh ? `访客 ${tail}` : `Visitor ${tail}`; } export function visitorAvatarLetter(item: RadarTrack): string { const name = formatVisitorName(item).trim(); if (name === '') { return 'V'; } return name.slice(0, 1).toUpperCase(); } /** * 触达动作,只描述真实渠道,不把浏览写成已留资 */ export function formatVisitorAction(item: RadarTrack): string { if (item.touchType === 'nfc') { return resolveRadarText('openedNfc'); } if (item.touchType === 'qr') { return resolveRadarText('openedQr'); } return resolveRadarText('openedLink'); } /** * 访客活动主通知流文案 */ export function formatVisitorHeadline(item: RadarTrack): string { return `${formatVisitorName(item)} ${formatVisitorAction(item)}`; } export function readTrackTime(item: { createdAt?: string | Date; createTime?: string | Date }): number { const raw = item.createdAt != null ? item.createdAt : item.createTime; return parseTimeValue(raw); } function parseTimeValue(raw: string | Date | null | undefined): number { if (raw == null) { return 0; } if (raw instanceof Date) { const time = raw.getTime(); return Number.isFinite(time) ? time : 0; } const text = String(raw).trim(); if (text === '') { return 0; } const direct = Date.parse(text); if (!Number.isNaN(direct) && direct > 0) { return direct; } const normalized = Date.parse(text.replace(' ', 'T')); if (!Number.isNaN(normalized) && normalized > 0) { return normalized; } return 0; } /** 5 分钟内有足迹视为仍在浏览 */ export const RADAR_ONLINE_WINDOW_MS = 5 * 60 * 1000; export function isVisitorOnline( item: { createdAt?: string | Date; createTime?: string | Date }, now = Date.now() ): boolean { const time = readTrackTime(item); if (time <= 0) { return false; } const diff = now - time; return diff <= RADAR_ONLINE_WINDOW_MS && diff >= -60000; } function padTime(value: number): string { const text = String(value); return text.length < 2 ? `0${text}` : text; } /** * 绝对时间,供名片主核对监控时刻 */ export function formatClockTime(createdAt?: string | Date): string { const time = readTrackTime({ createdAt }); if (time <= 0) { return ''; } const date = new Date(time); return `${padTime(date.getMonth() + 1)}-${padTime(date.getDate())} ${padTime(date.getHours())}:${padTime(date.getMinutes())}`; } export function formatMonitorTime(item: { createdAt?: string | Date; createTime?: string | Date }): string { const raw = item.createdAt != null ? item.createdAt : item.createTime; const clock = formatClockTime(raw); const relative = formatRelativeTime(raw); if (clock === '') { return relative; } return `${clock} · ${relative}`; } function blockTypeLabel(key: string): string { const id = key.trim().toLowerCase(); if (id === 'hero' || id === 'profile') { return resolveRadarText('blockHero'); } if (id === 'case' || id === 'cases') { return resolveRadarText('blockCase'); } if (id === 'article' || id === 'articles') { return resolveRadarText('blockArticle'); } if (id === 'social') { return resolveRadarText('blockSocial'); } if (id === 'video') { return resolveRadarText('blockVideo'); } if (id === 'form') { return resolveRadarText('blockForm'); } if (id === 'appointment') { return resolveRadarText('blockAppointment'); } if (id === 'contact' || id === 'contact_btn') { return resolveRadarText('blockContact'); } return key.trim(); } /** * 浏览内容:优先用模块标题,否则用类型名称,原始编号仅在没有标题时保留 */ export function formatViewedLabels(item: RadarTrack): string[] { const labels: string[] = []; const seen: Record = {}; const contents = item.viewedContents; if (contents != null && Array.isArray(contents) && contents.length > 0) { for (let i = 0; i < contents.length; i++) { const row = contents[i]; const title = row.title != null ? String(row.title).trim() : ''; const key = row.key != null ? String(row.key).trim() : ''; const label = title !== '' ? title : blockTypeLabel(key); if (label !== '' && seen[label] !== true) { seen[label] = true; labels.push(label); } } return labels; } const blocks = item.viewedBlocks; if (blocks == null || !Array.isArray(blocks)) { return labels; } for (let i = 0; i < blocks.length; i++) { const label = blockTypeLabel(String(blocks[i])); if (label !== '' && seen[label] !== true) { seen[label] = true; labels.push(label); } } return labels; } export function formatViewedLine(item: RadarTrack): string { const labels = formatViewedLabels(item); if (labels.length === 0) { return ''; } return `${resolveRadarText('blocksViewed')} ${labels.join(' · ')}`; } function sessionKey(item: RadarTrack): string { const visitorId = item.visitorId != null ? item.visitorId.trim() : ''; const cardId = item.cardId != null ? String(item.cardId) : ''; if (visitorId === '' || cardId === '') { return ''; } return `${visitorId}_${cardId}`; } function copyTrack(item: RadarTrack): RadarTrack { const blocks = item.viewedBlocks != null && Array.isArray(item.viewedBlocks) ? item.viewedBlocks.slice() : []; const contents = item.viewedContents != null && Array.isArray(item.viewedContents) ? item.viewedContents.slice() : undefined; return { id: item.id, tenantId: item.tenantId, cardId: item.cardId, visitorId: item.visitorId, ip: item.ip, country: item.country, city: item.city, touchType: item.touchType, stayDurationMs: item.stayDurationMs, scrollDepth: item.scrollDepth, viewedBlocks: blocks, viewedContents: contents, visitorName: item.visitorName, visitorCompany: item.visitorCompany, avatarUrl: item.avatarUrl, cardTitle: item.cardTitle, createdAt: item.createdAt != null ? item.createdAt : item.createTime, createTime: item.createTime != null ? item.createTime : item.createdAt, }; } /** * 同一访客对同一名片的进入与离开合并成一条监控会话 */ export function consolidateRadarTracks(tracks: RadarTrack[]): RadarTrack[] { const order: string[] = []; const map: Record = {}; const singles: RadarTrack[] = []; for (let i = 0; i < tracks.length; i++) { const item = tracks[i]; const key = sessionKey(item); if (key === '') { singles.push(copyTrack(item)); continue; } const prev = map[key]; if (prev == null) { map[key] = copyTrack(item); order.push(key); continue; } prev.stayDurationMs = Math.max(prev.stayDurationMs || 0, item.stayDurationMs || 0); prev.scrollDepth = Math.max(prev.scrollDepth || 0, item.scrollDepth || 0); const prevBlocks = prev.viewedBlocks != null ? prev.viewedBlocks : []; const nextBlocks = item.viewedBlocks != null && Array.isArray(item.viewedBlocks) ? item.viewedBlocks : []; const merged: string[] = []; const seenBlock: Record = {}; for (let b = 0; b < prevBlocks.length; b++) { const text = String(prevBlocks[b]); if (seenBlock[text] !== true) { seenBlock[text] = true; merged.push(text); } } for (let b = 0; b < nextBlocks.length; b++) { const text = String(nextBlocks[b]); if (seenBlock[text] !== true) { seenBlock[text] = true; merged.push(text); } } prev.viewedBlocks = merged; const name = item.visitorName != null ? String(item.visitorName).trim() : ''; if ((prev.visitorName == null || String(prev.visitorName).trim() === '') && name !== '') { prev.visitorName = name; } const company = item.visitorCompany != null ? String(item.visitorCompany).trim() : ''; if ((prev.visitorCompany == null || String(prev.visitorCompany).trim() === '') && company !== '') { prev.visitorCompany = company; } const avatar = item.avatarUrl != null ? String(item.avatarUrl).trim() : ''; if ((prev.avatarUrl == null || String(prev.avatarUrl).trim() === '') && avatar !== '') { prev.avatarUrl = avatar; } if (item.touchType === 'nfc') { prev.touchType = 'nfc'; } else if (item.touchType === 'qr' && prev.touchType !== 'nfc') { prev.touchType = 'qr'; } const prevContents = prev.viewedContents != null ? prev.viewedContents : []; const nextContents = item.viewedContents != null && Array.isArray(item.viewedContents) ? item.viewedContents : []; const contentMap: Record = {}; const contentOrder: string[] = []; for (let c = 0; c < prevContents.length; c++) { const row = prevContents[c]; const contentKey = row.key != null ? String(row.key) : ''; if (contentKey === '') { continue; } if (contentMap[contentKey] == null) { contentOrder.push(contentKey); } contentMap[contentKey] = row.title != null ? String(row.title) : ''; } for (let c = 0; c < nextContents.length; c++) { const row = nextContents[c]; const contentKey = row.key != null ? String(row.key) : ''; if (contentKey === '') { continue; } const title = row.title != null ? String(row.title).trim() : ''; if (contentMap[contentKey] == null) { contentOrder.push(contentKey); contentMap[contentKey] = title; } else if (contentMap[contentKey] === '' && title !== '') { contentMap[contentKey] = title; } } if (contentOrder.length > 0) { const nextViewed: RadarTrack['viewedContents'] = []; for (let c = 0; c < contentOrder.length; c++) { const contentKey = contentOrder[c]; nextViewed.push({ key: contentKey, title: contentMap[contentKey] }); } prev.viewedContents = nextViewed; } if (readTrackTime(item) >= readTrackTime(prev)) { prev.id = item.id; prev.createdAt = item.createdAt != null ? item.createdAt : item.createTime; prev.createTime = item.createTime != null ? item.createTime : item.createdAt; if (item.ip != null && String(item.ip).trim() !== '') { prev.ip = item.ip; } if (item.country != null && String(item.country).trim() !== '') { prev.country = item.country; } if (item.city != null && String(item.city).trim() !== '') { prev.city = item.city; } } } const merged: RadarTrack[] = []; for (let i = 0; i < order.length; i++) { merged.push(map[order[i]]); } for (let i = 0; i < singles.length; i++) { merged.push(singles[i]); } merged.sort((a, b) => readTrackTime(b) - readTrackTime(a)); return merged; } /** * 相对时间格式化 */ export function formatRelativeTime(createdAt?: string | Date): string { const zh = isZhLocale(); const time = parseTimeValue(createdAt); if (time <= 0) { return zh ? '时间未知' : 'Time unknown'; } const diffMs = Math.max(0, Date.now() - time); const diffMin = Math.floor(diffMs / 60000); if (diffMin < 2) { return zh ? '刚刚' : 'Just Now'; } if (diffMin < 60) { return zh ? `${diffMin}分钟前` : `${diffMin}m ago`; } const diffHour = Math.floor(diffMin / 60); if (diffHour < 24) { return zh ? `${diffHour}小时前` : `${diffHour}h ago`; } const diffDay = Math.floor(diffHour / 24); return zh ? `${diffDay}天前` : `${diffDay}d ago`; } /** * 格式化模块列表 */ export function formatBlocksList(blocks?: string[] | null): string { if (blocks == null || !Array.isArray(blocks) || blocks.length === 0) { return ''; } return blocks.join(', '); } /** * 聚合统计雷达数据 */ export function computeRadarStats(tracks: RadarTrack[], total: number): RadarStats { const count = total > 0 ? total : tracks.length; let highCount = 0; let totalStay = 0; const countryMap: Record = {}; for (let i = 0; i < tracks.length; i++) { const t = tracks[i]; if (isHighIntent(t.stayDurationMs, t.scrollDepth)) { highCount += 1; } totalStay += Math.max(0, t.stayDurationMs); if (t.country != null && t.country.trim() !== '') { const c = t.country.trim(); countryMap[c] = (countryMap[c] || 0) + 1; } } const rate = tracks.length > 0 ? Math.round((highCount / tracks.length) * 100) : 0; const avgStay = tracks.length > 0 ? Math.round(totalStay / tracks.length) : 0; const seenOnline: Record = {}; let onlineCount = 0; for (let i = 0; i < tracks.length; i++) { const current = tracks[i]; if (!isVisitorOnline(current)) { continue; } const visitorKey = current.visitorId != null && current.visitorId.trim() !== '' ? current.visitorId.trim() : String(current.id); if (seenOnline[visitorKey] === true) { continue; } seenOnline[visitorKey] = true; onlineCount += 1; } let topCountry = ''; let maxC = 0; for (const c in countryMap) { if (countryMap[c] > maxC) { maxC = countryMap[c]; topCountry = c; } } return { totalVisits: count, highIntentCount: highCount, onlineCount, intentRateText: `${rate}%`, avgStayText: formatStay(avgStay), topLocation: topCountry !== '' ? formatLocation(topCountry, '') : resolveRadarText('unknown'), }; } /** * 计算走势高度 (7 个数据柱) */ export function computeTrendHeights(tracks: RadarTrack[]): number[] { const defaultHeights: number[] = [0, 0, 0, 0, 0, 0, 0]; if (tracks.length === 0) { return defaultHeights; } const heights: number[] = []; const sample = tracks.slice(0, 7); for (let i = 0; i < 7; i++) { if (i < sample.length) { const d = sample[i].scrollDepth; const pct = d > 1 ? Math.min(100, Math.max(15, Math.round(d))) : Math.min(100, Math.max(15, Math.round(d * 100))); heights.push(pct); } else { heights.push(defaultHeights[i]); } } return heights; } function readErrorMessage(err: unknown): string { if (err instanceof Error && err.message !== '') { return err.message; } return resolveRadarText('error'); } function readRowField(row: RadarTrack, camel: string, snake: string): unknown { const record = row as unknown as Record; const primary = record[camel]; if (primary != null && primary !== '') { return primary; } return record[snake]; } function normalizeTrack(row: RadarTrack): RadarTrack { const blocksRaw = readRowField(row, 'viewedBlocks', 'viewed_blocks'); let viewedBlocks: string[] = []; if (Array.isArray(blocksRaw)) { viewedBlocks = blocksRaw.map((item) => String(item)); } else if (typeof blocksRaw === 'string' && blocksRaw.trim() !== '') { viewedBlocks = [blocksRaw]; } const createTime = readRowField(row, 'createTime', 'create_time'); const createdAt = readRowField(row, 'createdAt', 'created_at'); const time = createTime != null ? createTime : createdAt; const stayRaw = readRowField(row, 'stayDurationMs', 'stay_duration_ms'); const depthRaw = readRowField(row, 'scrollDepth', 'scroll_depth'); const stay = Number(stayRaw); const depth = Number(depthRaw); const touch = readRowField(row, 'touchType', 'touch_type'); const touchType = touch === 'nfc' || touch === 'qr' || touch === 'link' ? touch : 'link'; return { id: row.id, tenantId: readRowField(row, 'tenantId', 'tenant_id') as string | number | undefined, cardId: (readRowField(row, 'cardId', 'card_id') as string | number) || row.cardId, visitorId: String(readRowField(row, 'visitorId', 'visitor_id') || ''), ip: readRowField(row, 'ip', 'ip') as string | undefined, country: readRowField(row, 'country', 'country') as string | undefined, city: readRowField(row, 'city', 'city') as string | undefined, touchType, stayDurationMs: Number.isFinite(stay) ? stay : 0, scrollDepth: Number.isFinite(depth) ? depth : 0, viewedBlocks, viewedContents: row.viewedContents, visitorName: row.visitorName, visitorCompany: row.visitorCompany, avatarUrl: row.avatarUrl, cardTitle: row.cardTitle, createdAt: (createdAt != null ? createdAt : time) as string | Date | undefined, createTime: (createTime != null ? createTime : time) as string | Date | undefined, }; } function parseTrackList(payload: OwnerPageData | null | undefined): RadarTrack[] { if (payload == null || payload.list == null) { return []; } if (!Array.isArray(payload.list)) { return []; } const list: RadarTrack[] = []; for (let i = 0; i < payload.list.length; i++) { list.push(normalizeTrack(payload.list[i])); } return list; } /** * 拉取雷达分页。可注入 fetchOwnerRadarPage,缺省走 common/api */ export async function loadTracks( input: LoadTracksInput, api?: RadarFetchApi ): Promise { const page = normalizePage(input.page); const size = normalizeSize(input.size); const query: OwnerPageQuery = { page, size, }; const cardId = parseCardId(input.cardId); if (cardId != null) { query.cardId = cardId; } const fetchPage = api != null && api.fetchOwnerRadarPage != null ? api.fetchOwnerRadarPage : fetchOwnerRadarPage; try { const payload = await fetchPage(query); const list = parseTrackList(payload); const pagination = payload != null && payload.pagination != null ? payload.pagination : { page, size, total: list.length }; return { list, empty: list.length === 0, errorMessage: '', page: normalizePage(pagination.page), size: normalizeSize(pagination.size), total: pagination.total != null && Number.isFinite(pagination.total) ? Math.max(0, pagination.total) : list.length, }; } catch (err) { return { list: [], empty: true, errorMessage: readErrorMessage(err), page, size, total: 0, }; } } export function createRadarState(): RadarState { return { cardIdRaw: '', page: DEFAULT_PAGE, size: DEFAULT_SIZE, total: 0, loading: false, empty: true, errorMessage: '', tracks: [], }; } export function applyRadarOptions(state: RadarState, options: RadarPageOptions | null): void { if (options == null) { return; } const cardRaw = options.cardId != null && options.cardId !== '' ? options.cardId : options.id != null ? options.id : ''; if (cardRaw !== '') { state.cardIdRaw = String(cardRaw).trim(); } } /** * 页面主 CTA:刷新当前页轨迹 */ export async function refreshTracks( state: RadarState, api?: RadarFetchApi ): Promise { if (state.loading) { return { list: state.tracks, empty: state.empty, errorMessage: state.errorMessage, page: state.page, size: state.size, total: state.total, }; } state.loading = true; state.errorMessage = ''; const result = await loadTracks( { cardId: state.cardIdRaw, page: state.page, size: state.size, }, api ); state.loading = false; state.tracks = result.list; state.empty = result.empty; state.errorMessage = result.errorMessage; state.page = result.page; state.size = result.size; state.total = result.total; return result; }