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

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

790 行
17 KiB
Plaintext

<template>
<view class="radar-page" :class="pageRtlClass" :style="pageTheme">
<view class="radar-top">
<view class="radar-bar">
<view class="radar-heading">
<text class="radar-title">{{ pageTitle }}</text>
<text class="radar-desc">{{ pageDesc }}</text>
</view>
<view class="radar-refresh" @click="handleRefresh">
<text class="radar-refresh-text">{{ t('refresh') }}</text>
</view>
</view>
<view class="radar-live">
<view class="radar-live-dot"></view>
<text class="radar-live-text">{{ liveStatusText }}</text>
</view>
<view v-if="showSummary" class="stat-row">
<view class="stat-cell">
<text class="stat-value">{{ stats.totalVisits }}</text>
<text class="stat-label">{{ t('totalVisits') }}</text>
</view>
<view class="stat-cell">
<text class="stat-value" :class="{ 'stat-value-hot': stats.highIntentCount > 0 }">{{ stats.highIntentCount }}</text>
<text class="stat-label">{{ t('highIntent') }}</text>
</view>
<view class="stat-cell">
<text class="stat-value">{{ stats.avgStayText }}</text>
<text class="stat-label">{{ t('avgStay') }}</text>
</view>
<view class="stat-cell">
<text class="stat-value" :class="{ 'stat-value-live': stats.onlineCount > 0 }">{{ stats.onlineCount }}</text>
<text class="stat-label">{{ t('onlineNow') }}</text>
</view>
</view>
<text v-if="showSummary" class="stat-place">{{ stats.topLocation }}</text>
</view>
<scroll-view class="radar-scroll" :scroll-y="true">
<view class="radar-body">
<view v-if="showSkeleton" class="radar-skeleton">
<view class="skeleton-card"></view>
<view class="skeleton-card"></view>
<view class="skeleton-card"></view>
</view>
<view v-else-if="state.errorMessage !== ''" class="radar-feedback">
<text class="feedback-title">{{ t('error') }}</text>
<text class="feedback-desc">{{ state.errorMessage }}</text>
<view class="feedback-retry" @click="handleRefresh">
<text class="feedback-retry-text">{{ t('refresh') }}</text>
</view>
</view>
<view v-else-if="state.empty" class="radar-feedback">
<text class="feedback-title">{{ t('empty') }}</text>
<text class="feedback-desc">{{ t('emptyHint') }}</text>
</view>
<view v-else>
<view v-if="showOnlineStrip" class="online-strip">
<text class="online-strip-label">{{ onlineStripLabel }}</text>
<list-view class="online-list" :style="onlineListStyle">
<list-item v-for="item in onlineVisitors" :key="item.id">
<view class="mini-avatar">
<image class="mini-avatar-img" :src="avatarSrc(item)" mode="aspectFill" />
<view class="mini-dot"></view>
</view>
</list-item>
</list-view>
</view>
<list-view class="radar-list" :style="listStyle">
<list-item v-for="item in rows" :key="item.id">
<view
class="radar-row"
:class="{
'radar-row-hot': isHighIntent(item.stayDurationMs, item.scrollDepth),
'radar-row-amber': isMediumIntent(item.stayDurationMs, item.scrollDepth),
}"
>
<view class="radar-row-main">
<view class="avatar-wrap">
<image class="avatar-img" :src="avatarSrc(item)" mode="aspectFill" />
<view v-if="showOnline(item)" class="avatar-online"></view>
</view>
<view class="radar-row-body">
<view class="radar-row-top">
<text class="radar-headline">{{ formatName(item) }}</text>
<text class="radar-time">{{ formatTime(item) }}</text>
</view>
<text class="radar-sub">{{ formatAction(item) }} · {{ formatLocation(item.country, item.city) }}</text>
<text v-if="hasExtra(item)" class="radar-sub">{{ extraLine(item) }}</text>
<view class="radar-row-meta">
<text class="radar-meta">{{ formatMeta(item) }}</text>
<view :class="'intent-' + resolveIntentLevel(item)">
<text class="intent-text">{{ resolveIntentBadge(item) }}</text>
</view>
</view>
<text v-if="hasBlocks(item)" class="radar-blocks">{{ formatBlocks(item) }}</text>
</view>
</view>
</view>
</list-item>
</list-view>
</view>
<view class="radar-spacer"></view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import { getCurrentLocale, getOmniText, isRtl } from '../../common/locale.uts';
import { utsLightColors, utsRadius, utsSpacing, utsTypography } from '../../common/tokens.uts';
import { RadarTrack } from '../../common/types.uts';
import {
applyRadarOptions,
computeRadarStats,
consolidateRadarTracks,
createRadarState,
formatDepth,
formatLocation,
formatMonitorTime,
formatNetworkLine,
formatStay,
formatViewedLine,
formatVisitorAction,
formatVisitorName,
getIntentBadgeText,
getIntentLevel,
isHighIntent,
isVisitorOnline,
refreshTracks,
resolveRadarText,
RadarCopyKey,
RadarPageOptions,
RadarStats,
} from './useRadar.uts';
const pageTheme: Record<string, string> = {
'--oc-primary': '#0B5CFF',
'--oc-primary-soft': utsLightColors.primarySoft,
'--oc-bg': '#F7F8FA',
'--oc-surface': utsLightColors.surface,
'--oc-border': '#E3E7ED',
'--oc-text': '#101828',
'--oc-text-2': '#667085',
'--oc-success': utsLightColors.success,
'--oc-warning': utsLightColors.warning,
'--oc-error': '#F04438',
'--oc-radius-btn': utsRadius.button,
'--oc-radius-card': utsRadius.card,
'--oc-radius-modal': utsRadius.modal,
'--oc-shadow': utsTypography.shadow,
'--oc-space-8': utsSpacing.space8,
'--oc-space-12': utsSpacing.space12,
'--oc-space-16': utsSpacing.space16,
'--oc-space-24': utsSpacing.space24,
};
const state = reactive(createRadarState());
const booted = ref(false);
const pageTitle = computed((): string => {
return getOmniText(getCurrentLocale(), 'radar.online_visitor');
});
const pageDesc = computed((): string => {
return getOmniText(getCurrentLocale(), 'radar.visitor_trace');
});
const pageRtlClass = computed((): string => {
return isRtl(getCurrentLocale()) ? 'radar-rtl' : '';
});
const rows = computed((): RadarTrack[] => {
return consolidateRadarTracks(state.tracks);
});
const onlineVisitors = computed((): RadarTrack[] => {
const list: RadarTrack[] = [];
for (let i = 0; i < rows.value.length; i++) {
const item = rows.value[i];
if (isVisitorOnline(item)) {
list.push(item);
}
}
return list;
});
const stats = computed((): RadarStats => {
return computeRadarStats(rows.value, state.total);
});
const liveStatusText = computed((): string => {
return resolveRadarText('liveRadar');
});
const showSummary = computed((): boolean => {
return booted.value && state.errorMessage === '' && !state.empty;
});
const showSkeleton = computed((): boolean => {
if (state.errorMessage !== '') {
return false;
}
if (!booted.value) {
return true;
}
return state.loading && state.tracks.length === 0;
});
const showOnlineStrip = computed((): boolean => {
return onlineVisitors.value.length > 0;
});
const onlineStripLabel = computed((): string => {
return t('onlineNow');
});
const onlineListStyle = computed((): string => {
return 'height:48px';
});
const listStyle = computed((): string => {
const count = rows.value.length > 0 ? rows.value.length : 1;
return 'height:' + String(count * 112) + 'px';
});
function t(key: RadarCopyKey): string {
return resolveRadarText(key);
}
function formatName(item: RadarTrack): string {
return formatVisitorName(item);
}
function formatAction(item: RadarTrack): string {
return formatVisitorAction(item);
}
function formatTime(item: RadarTrack): string {
return formatMonitorTime(item);
}
const GUEST_AVATAR = '/static/icons/avatar-guest.svg';
function avatarSrc(item: RadarTrack): string {
const url = item.avatarUrl != null ? String(item.avatarUrl).trim() : '';
return url !== '' ? url : GUEST_AVATAR;
}
function extraLine(item: RadarTrack): string {
const parts: string[] = [];
const card = cardLine(item);
const company = companyLine(item);
const network = networkLine(item);
if (card !== '') {
parts.push(card);
}
if (company !== '') {
parts.push(company);
}
if (network !== '') {
parts.push(network);
}
return parts.join(' · ');
}
function hasExtra(item: RadarTrack): boolean {
return extraLine(item) !== '';
}
function showOnline(item: RadarTrack): boolean {
return isVisitorOnline(item);
}
function hasCompany(item: RadarTrack): boolean {
return item.visitorCompany != null && String(item.visitorCompany).trim() !== '';
}
function companyLine(item: RadarTrack): string {
return item.visitorCompany != null ? String(item.visitorCompany).trim() : '';
}
function hasCardTitle(item: RadarTrack): boolean {
return item.cardTitle != null && String(item.cardTitle).trim() !== '';
}
function cardLine(item: RadarTrack): string {
return item.cardTitle != null ? String(item.cardTitle).trim() : '';
}
function hasNetwork(item: RadarTrack): boolean {
return formatNetworkLine(item.ip) !== '';
}
function networkLine(item: RadarTrack): string {
return formatNetworkLine(item.ip);
}
function formatMeta(item: RadarTrack): string {
return t('stay') + ' ' + formatStay(item.stayDurationMs) + ' · ' + t('depth') + ' ' + formatDepth(item.scrollDepth);
}
function resolveIntentLevel(item: RadarTrack): string {
return getIntentLevel(item.stayDurationMs, item.scrollDepth);
}
function isMediumIntent(stayDurationMs: number, scrollDepth: number): boolean {
return getIntentLevel(stayDurationMs, scrollDepth) === 'medium';
}
function resolveIntentBadge(item: RadarTrack): string {
const level = resolveIntentLevel(item);
return getIntentBadgeText(level);
}
function hasBlocks(item: RadarTrack): boolean {
return formatViewedLine(item) !== '';
}
function formatBlocks(item: RadarTrack): string {
return formatViewedLine(item);
}
async function handleRefresh(): Promise<void> {
const wasLoading = state.loading;
await refreshTracks(state);
if (wasLoading) {
return;
}
booted.value = true;
}
onLoad((options: RadarPageOptions) => {
applyRadarOptions(state, options);
handleRefresh();
});
onShow(() => {
handleRefresh();
});
</script>
<style scoped>
.radar-page {
display: flex;
flex-direction: column;
width: 100vw;
max-width: 480px;
height: 100vh;
max-height: 100vh;
margin-inline: auto;
background-color: var(--oc-bg, #F7F8FA);
overflow: hidden;
}
.radar-rtl {
direction: rtl;
}
.radar-top {
flex-shrink: 0;
padding-block-start: calc(8px + env(safe-area-inset-top, 0px));
padding-inline: 16px;
padding-block-end: 8px;
background-color: #F7F8FA;
}
.radar-bar {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
min-height: 44px;
}
.radar-heading {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
margin-inline-end: 8px;
}
.radar-title {
font-size: 20px;
font-weight: 700;
line-height: 1.25;
color: #101828;
}
.radar-desc {
margin-top: 2px;
font-size: 13px;
font-weight: 500;
line-height: 1.4;
color: #667085;
}
.radar-refresh {
min-width: 44px;
min-height: 44px;
padding-inline: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.radar-refresh:active {
opacity: 0.72;
}
.radar-refresh-text {
font-size: 14px;
font-weight: 600;
color: #0B5CFF;
}
.radar-live {
display: flex;
flex-direction: row;
align-items: center;
align-self: flex-start;
margin-block: 4px 12px;
padding: 4px 10px;
background-color: #ECFDF3;
border-radius: 9999px;
}
.radar-live-dot {
width: 8px;
height: 8px;
margin-inline-end: 6px;
border-radius: 4px;
background-color: #12B76A;
}
.radar-live-text {
font-size: 12px;
font-weight: 600;
line-height: 1.4;
color: #027A48;
}
.stat-row {
display: flex;
flex-direction: row;
background-color: #FFFFFF;
border: 1px solid #E3E7ED;
border-radius: 16px;
}
.stat-cell {
flex: 1;
min-width: 0;
min-height: 52px;
padding: 8px 4px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.stat-cell + .stat-cell {
border-inline-start: 1px solid #E3E7ED;
}
.stat-value {
font-size: 18px;
font-weight: 700;
line-height: 1.2;
color: #101828;
white-space: nowrap;
}
.stat-value-hot {
color: #F04438;
}
.stat-value-live {
color: #027A48;
}
.stat-label {
margin-top: 2px;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
color: #667085;
text-align: center;
}
.stat-place {
margin-block: 8px 4px;
font-size: 13px;
font-weight: 500;
line-height: 1.4;
color: #667085;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.radar-scroll {
flex: 1;
width: 100%;
height: 0;
min-height: 0;
}
.radar-body {
width: 100%;
box-sizing: border-box;
padding-inline: 16px;
}
.radar-skeleton {
padding-block-start: 4px;
}
.skeleton-card {
height: 96px;
margin-block-end: 8px;
background-color: #E3E7ED;
border-radius: 16px;
}
.radar-feedback {
margin-block-start: 8px;
padding: 28px 16px;
display: flex;
flex-direction: column;
align-items: center;
background-color: #FFFFFF;
border: 1px solid #E3E7ED;
border-radius: 16px;
}
.feedback-title {
font-size: 16px;
font-weight: 600;
line-height: 1.25;
color: #101828;
text-align: center;
}
.feedback-desc {
margin-top: 6px;
font-size: 14px;
line-height: 1.6;
color: #667085;
text-align: center;
}
.feedback-retry {
min-height: 44px;
margin-top: 16px;
padding-inline: 16px;
display: flex;
align-items: center;
justify-content: center;
background-color: #EBF5FF;
border-radius: 9999px;
}
.feedback-retry:active {
opacity: 0.72;
}
.feedback-retry-text {
font-size: 14px;
font-weight: 600;
color: #0B5CFF;
}
.online-strip {
margin-block-end: 8px;
padding: 10px 12px 8px;
background-color: #FFFFFF;
border: 1px solid #E3E7ED;
border-radius: 16px;
}
.online-strip-label {
font-size: 12px;
font-weight: 600;
line-height: 1.4;
color: #027A48;
}
.online-list {
width: 100%;
height: 48px;
margin-top: 6px;
display: flex;
flex-direction: row;
}
.mini-avatar {
position: relative;
width: 32px;
height: 32px;
margin-inline-end: 8px;
border-radius: 16px;
background-color: #EBF5FF;
overflow: visible;
}
.mini-avatar-img {
width: 32px;
height: 32px;
border-radius: 16px;
background-color: #E8EEF4;
}
.mini-dot {
position: absolute;
right: 0;
bottom: 0;
width: 8px;
height: 8px;
border-radius: 4px;
background-color: #12B76A;
border: 1px solid #FFFFFF;
}
.radar-list {
width: 100%;
}
.radar-row {
min-height: 0;
margin-block-end: 6px;
padding: 8px 10px;
display: flex;
flex-direction: column;
background-color: #FFFFFF;
border: 1px solid #E3E7ED;
border-radius: 12px;
}
.radar-row-main {
display: flex;
flex-direction: row;
align-items: flex-start;
}
.avatar-wrap {
position: relative;
width: 32px;
height: 32px;
margin-inline-end: 8px;
flex-shrink: 0;
}
.avatar-img {
width: 32px;
height: 32px;
border-radius: 16px;
background-color: #E8EEF4;
}
.avatar-online {
position: absolute;
right: 0;
bottom: 0;
width: 8px;
height: 8px;
border-radius: 4px;
background-color: #12B76A;
border: 1px solid #FFFFFF;
}
.radar-row-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.radar-row-hot {
border-color: #E3E7ED;
background-color: #FFFFFF;
}
.radar-row-amber {
border-color: #E3E7ED;
background-color: #FFFFFF;
}
.radar-row-top {
display: flex;
flex-direction: row;
align-items: flex-start;
}
.radar-headline {
flex: 1;
min-width: 0;
font-size: 14px;
font-weight: 600;
line-height: 1.3;
color: #101828;
}
.radar-time {
flex-shrink: 0;
margin-inline-start: 8px;
font-size: 12px;
font-weight: 500;
line-height: 1.3;
color: #667085;
}
.radar-sub {
margin-top: 2px;
font-size: 12px;
line-height: 1.35;
color: #667085;
}
.radar-row-meta {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-top: 2px;
}
.radar-meta {
flex: 1;
min-width: 0;
margin-inline-end: 8px;
font-size: 12px;
line-height: 1.35;
color: #667085;
}
.intent-high {
background-color: transparent;
padding: 0;
}
.intent-medium,
.intent-normal {
background-color: transparent;
padding: 0;
}
.intent-high .intent-text {
color: var(--oc-error, #F04438);
}
.intent-medium .intent-text {
color: #B54708;
}
.intent-normal .intent-text {
color: #667085;
}
.intent-text {
font-size: 12px;
font-weight: 700;
line-height: 1.4;
}
.radar-blocks {
margin-top: 2px;
font-size: 12px;
line-height: 1.35;
color: #667085;
}
.radar-spacer {
height: calc(16px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px));
}
@media (prefers-reduced-motion: reduce) {
.radar-refresh,
.feedback-retry {
transition: none;
}
}
</style>