文件
OmniCard/mpuniapp/pages/owner/useRadar.uts
wikiboss 62e0ae4435 feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:01:47 +08:00

1068 行
30 KiB
Plaintext

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

/**
* 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<OwnerPageData<RadarTrack>>;
};
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<string, boolean> = {};
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<string, RadarTrack> = {};
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<string, boolean> = {};
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<string, string> = {};
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<string, number> = {};
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<string, boolean> = {};
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<string, unknown>;
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<RadarTrack> | 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<LoadTracksResult> {
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<LoadTracksResult> {
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;
}