574 行
14 KiB
Plaintext
574 行
14 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';
|
||
|
||
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;
|
||
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';
|
||
}
|
||
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`;
|
||
}
|
||
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}%`;
|
||
}
|
||
|
||
/**
|
||
* 国家 / 城市一行展示
|
||
*/
|
||
export function formatLocation(country?: string, city?: string): string {
|
||
const countryText = country != null ? String(country).trim() : '';
|
||
const cityText = city != null ? String(city).trim() : '';
|
||
if (countryText !== '' && cityText !== '') {
|
||
return `${countryText} / ${cityText}`;
|
||
}
|
||
if (countryText !== '') {
|
||
return countryText;
|
||
}
|
||
if (cityText !== '') {
|
||
return cityText;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* 触碰类型标签简写
|
||
*/
|
||
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');
|
||
}
|
||
|
||
/**
|
||
* 访客活动主通知流文案(如 "Alex tapped your NFC card")
|
||
*/
|
||
export function formatVisitorHeadline(item: RadarTrack): string {
|
||
const zh = isZhLocale();
|
||
const vid = item.visitorId != null && item.visitorId.trim() !== '' ? item.visitorId.trim() : '';
|
||
let name = 'Visitor';
|
||
if (vid !== '') {
|
||
const shortId = vid.replace(/^vis_([a-z0-9]+_)?/i, '');
|
||
name = shortId !== '' ? `Visitor #${shortId}` : 'Visitor';
|
||
}
|
||
|
||
if (item.touchType === 'nfc') {
|
||
return zh ? `${name} 通过 NFC 碰一碰接入名片` : `${name} tapped your NFC card`;
|
||
}
|
||
if (item.touchType === 'qr') {
|
||
return zh ? `${name} 扫描了展台名片二维码` : `${name} scanned your QR showcase`;
|
||
}
|
||
return zh ? `${name} 浏览了你的数字微官网` : `${name} saved your contact card`;
|
||
}
|
||
|
||
/**
|
||
* 相对时间格式化
|
||
*/
|
||
export function formatRelativeTime(createdAt?: string | Date): string {
|
||
const zh = isZhLocale();
|
||
if (createdAt == null) {
|
||
return zh ? '刚刚' : 'Just Now';
|
||
}
|
||
let time = 0;
|
||
if (typeof createdAt === 'string') {
|
||
const parsed = Date.parse(createdAt);
|
||
if (!Number.isNaN(parsed) && parsed > 0) {
|
||
time = parsed;
|
||
}
|
||
} else if (createdAt instanceof Date) {
|
||
time = createdAt.getTime();
|
||
}
|
||
if (time <= 0) {
|
||
return zh ? '刚刚' : 'Just Now';
|
||
}
|
||
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) : (count > 0 ? 32 : 0);
|
||
const avgStay = tracks.length > 0 ? Math.round(totalStay / tracks.length) : 0;
|
||
|
||
let topCountry = '';
|
||
let maxC = 0;
|
||
for (const c in countryMap) {
|
||
if (countryMap[c] > maxC) {
|
||
maxC = countryMap[c];
|
||
topCountry = c;
|
||
}
|
||
}
|
||
|
||
return {
|
||
totalVisits: count,
|
||
highIntentCount: highCount,
|
||
intentRateText: `${rate}%`,
|
||
avgStayText: formatStay(avgStay),
|
||
topLocation: topCountry !== '' ? topCountry : 'Global',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 计算走势高度 (7 个数据柱)
|
||
*/
|
||
export function computeTrendHeights(tracks: RadarTrack[]): number[] {
|
||
const defaultHeights: number[] = [35, 60, 45, 80, 55, 90, 75];
|
||
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 parseTrackList(payload: OwnerPageData<RadarTrack> | null | undefined): RadarTrack[] {
|
||
if (payload == null || payload.list == null) {
|
||
return [];
|
||
}
|
||
if (!Array.isArray(payload.list)) {
|
||
return [];
|
||
}
|
||
return payload.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;
|
||
}
|