补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
1068 行
30 KiB
Plaintext
1068 行
30 KiB
Plaintext
/**
|
||
* 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;
|
||
}
|