- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换 - 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片 - 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转 - 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出 - 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志 - 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票 - 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏 - 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
421 行
9.7 KiB
Plaintext
421 行
9.7 KiB
Plaintext
/**
|
||
* OmniCard AI (CRMmp) - 微官网 Bento 首页与 Hero 身份区业务驱动层
|
||
* 严格遵循 uts 强类型规范与 AGENTS.md 约束
|
||
*/
|
||
|
||
import {
|
||
CardPortalDetail,
|
||
BentoBlockPublic,
|
||
RadarTrack,
|
||
} from '../../common/types.uts';
|
||
import {
|
||
fetchPortalCard,
|
||
recordRadarTrack,
|
||
} from '../../common/api.uts';
|
||
import { OmniLocale, getCurrentLocale, getOmniText } from '../../common/locale.uts';
|
||
import {
|
||
getCachedPortal,
|
||
setCachedPortal,
|
||
isCacheValid,
|
||
CachedPortalData,
|
||
} from './useCache.uts';
|
||
|
||
export const PORTAL_LOCALE_STORAGE_KEY = 'omni_locale';
|
||
|
||
type PortalCardWithSuggested = CardPortalDetail & {
|
||
suggestedLocale?: string;
|
||
};
|
||
|
||
/**
|
||
* 门户合法语言:zh-CN / en-US / ar-SA / es-ES / de-DE / fr-FR / ja-JP
|
||
*/
|
||
export function parsePortalLocale(raw: string): OmniLocale | null {
|
||
if (raw == null) {
|
||
return null;
|
||
}
|
||
const code = raw.trim();
|
||
if (
|
||
code === 'zh-CN' ||
|
||
code === 'en-US' ||
|
||
code === 'ar-SA' ||
|
||
code === 'es-ES' ||
|
||
code === 'de-DE' ||
|
||
code === 'fr-FR' ||
|
||
code === 'ja-JP'
|
||
) {
|
||
return code;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 进入页优先级:query.lang > storage > suggestedLocale > fallback(detectLocale)
|
||
*/
|
||
export function resolvePortalLocale(
|
||
queryLang: string,
|
||
stored: string,
|
||
suggested: string,
|
||
fallback: OmniLocale
|
||
): OmniLocale {
|
||
const fromQuery = parsePortalLocale(queryLang);
|
||
if (fromQuery != null) {
|
||
return fromQuery;
|
||
}
|
||
const fromStored = parsePortalLocale(stored);
|
||
if (fromStored != null) {
|
||
return fromStored;
|
||
}
|
||
const fromSuggested = parsePortalLocale(suggested);
|
||
if (fromSuggested != null) {
|
||
return fromSuggested;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
export function readStoredPortalLocale(): string {
|
||
try {
|
||
const stored = uni.getStorageSync(PORTAL_LOCALE_STORAGE_KEY) as string;
|
||
if (stored == null) {
|
||
return '';
|
||
}
|
||
return stored;
|
||
} catch (_e) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
export function persistPortalLocale(code: OmniLocale): void {
|
||
try {
|
||
uni.setStorageSync(PORTAL_LOCALE_STORAGE_KEY, code);
|
||
} catch (_e) {}
|
||
}
|
||
|
||
export function readCardSuggestedLocale(card: CardPortalDetail | null): string {
|
||
if (card == null) {
|
||
return '';
|
||
}
|
||
const extra = card as PortalCardWithSuggested;
|
||
if (extra.suggestedLocale == null) {
|
||
return '';
|
||
}
|
||
return extra.suggestedLocale;
|
||
}
|
||
|
||
export type PortalState = {
|
||
card: CardPortalDetail | null;
|
||
loading: boolean;
|
||
errorMessage: string;
|
||
visitorId: string;
|
||
enterTime: number;
|
||
touchType: string;
|
||
isPlayingAudio: boolean;
|
||
audioContext: any | null;
|
||
};
|
||
|
||
const VISITOR_STORAGE_KEY = 'omni_visitor_id';
|
||
|
||
/**
|
||
* 获取或初始化访客唯一识别码 (持久化在本地 Storage)
|
||
*/
|
||
export function getOrCreateVisitorId(): string {
|
||
let visitorId = '';
|
||
try {
|
||
visitorId = uni.getStorageSync(VISITOR_STORAGE_KEY) as string;
|
||
} catch (e) {
|
||
visitorId = '';
|
||
}
|
||
|
||
if (!visitorId || visitorId.trim() === '') {
|
||
const randomPart = Math.random().toString(36).substring(2, 10);
|
||
visitorId = `vis_${Date.now()}_${randomPart}`;
|
||
try {
|
||
uni.setStorageSync(VISITOR_STORAGE_KEY, visitorId);
|
||
} catch (e) {}
|
||
}
|
||
return visitorId;
|
||
}
|
||
|
||
/**
|
||
* 创建微官网响应式状态初始对象
|
||
*/
|
||
export function createPortalState(): PortalState {
|
||
return {
|
||
card: null,
|
||
loading: false,
|
||
errorMessage: '',
|
||
visitorId: getOrCreateVisitorId(),
|
||
enterTime: Date.now(),
|
||
touchType: 'link',
|
||
isPlayingAudio: false,
|
||
audioContext: null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 加载微官网名片聚合数据并上报首次触碰 (支持离线 LocalStorage 极速预加载与静默更新)
|
||
*/
|
||
export async function loadCardPortal(
|
||
state: PortalState,
|
||
cardIdOrSlug: string,
|
||
source: string = 'link'
|
||
): Promise<CardPortalDetail | null> {
|
||
state.errorMessage = '';
|
||
state.touchType = source;
|
||
state.enterTime = Date.now();
|
||
|
||
// 1. 优先尝试秒级读取本地缓存快照,实现首屏 0ms 呈现
|
||
const cached: CachedPortalData | null = getCachedPortal(cardIdOrSlug);
|
||
let hasValidCache = false;
|
||
if (cached != null && isCacheValid(cached)) {
|
||
state.card = cached.data;
|
||
state.loading = false;
|
||
hasValidCache = true;
|
||
} else {
|
||
state.loading = true;
|
||
}
|
||
|
||
try {
|
||
const networkCardData = await fetchPortalCard(cardIdOrSlug);
|
||
if (networkCardData != null) {
|
||
state.card = networkCardData;
|
||
// 静默将最新名片快照持久化至本地
|
||
setCachedPortal(cardIdOrSlug, networkCardData);
|
||
if (networkCardData.slug != null && networkCardData.slug !== cardIdOrSlug) {
|
||
setCachedPortal(networkCardData.slug!, networkCardData);
|
||
}
|
||
}
|
||
state.loading = false;
|
||
|
||
// 载入成功后上报雷达初次触碰轨迹
|
||
if (state.card != null && state.card!.id != null) {
|
||
recordRadarTrack({
|
||
cardId: state.card!.id,
|
||
visitorId: state.visitorId,
|
||
touchType: state.touchType,
|
||
stayDurationMs: 0,
|
||
scrollDepth: 0,
|
||
}).catch((_e: unknown) => {
|
||
// 轨迹上报容错,不阻断主流程
|
||
});
|
||
}
|
||
|
||
return state.card;
|
||
} catch (err: unknown) {
|
||
state.loading = false;
|
||
if (hasValidCache) {
|
||
// 命中有效缓存时弱网/异常平滑降级,维持缓存内容展示,避免阻断性白屏
|
||
return state.card;
|
||
}
|
||
state.card = null;
|
||
const msg =
|
||
err != null && typeof err === 'object' && 'message' in err
|
||
? String((err as { message: unknown }).message)
|
||
: '获取名片微官网详情失败';
|
||
state.errorMessage = msg;
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 上报访客停留时长与阅读深度
|
||
*/
|
||
export async function reportPortalStay(
|
||
state: PortalState,
|
||
scrollDepth: number = 0
|
||
): Promise<RadarTrack | null> {
|
||
if (!state.card || !state.card.id) {
|
||
return null;
|
||
}
|
||
|
||
const stayDurationMs = Math.max(0, Date.now() - state.enterTime);
|
||
try {
|
||
const track = await recordRadarTrack({
|
||
cardId: state.card.id,
|
||
visitorId: state.visitorId,
|
||
touchType: state.touchType,
|
||
stayDurationMs,
|
||
scrollDepth,
|
||
});
|
||
return track;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 名字语音发音试听控制
|
||
*/
|
||
export function toggleAudioPronunciation(state: PortalState, audioUrl?: string): void {
|
||
if (!audioUrl || audioUrl.trim() === '') {
|
||
return;
|
||
}
|
||
|
||
if (state.isPlayingAudio && state.audioContext) {
|
||
try {
|
||
state.audioContext.stop();
|
||
} catch (e) {}
|
||
state.isPlayingAudio = false;
|
||
return;
|
||
}
|
||
|
||
if (!state.audioContext) {
|
||
state.audioContext = uni.createInnerAudioContext();
|
||
state.audioContext.onPlay(() => {
|
||
state.isPlayingAudio = true;
|
||
});
|
||
state.audioContext.onEnded(() => {
|
||
state.isPlayingAudio = false;
|
||
});
|
||
state.audioContext.onError(() => {
|
||
state.isPlayingAudio = false;
|
||
});
|
||
}
|
||
|
||
try {
|
||
state.audioContext.src = audioUrl;
|
||
state.audioContext.play();
|
||
state.isPlayingAudio = true;
|
||
} catch (e) {
|
||
state.isPlayingAudio = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 快捷动作条呼出处理
|
||
*/
|
||
export type PortalQuickAction =
|
||
| 'whatsapp'
|
||
| 'linkedin'
|
||
| 'email'
|
||
| 'phone'
|
||
| 'telegram'
|
||
| 'twitter'
|
||
| 'wechat';
|
||
|
||
export function triggerQuickAction(
|
||
type: PortalQuickAction,
|
||
value?: string
|
||
): boolean {
|
||
if (!value || value.trim() === '') {
|
||
uni.showToast({
|
||
title: getOmniText(getCurrentLocale(), 'portal.contact_unset'),
|
||
icon: 'none',
|
||
});
|
||
return false;
|
||
}
|
||
|
||
if (type === 'phone') {
|
||
uni.makePhoneCall({
|
||
phoneNumber: value,
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (type === 'whatsapp') {
|
||
const cleanNumber = value.replace(/[^0-9+]/g, '');
|
||
const waUrl = `https://wa.me/${cleanNumber.replace('+', '')}`;
|
||
uni.setClipboardData({
|
||
data: waUrl,
|
||
success: () => {
|
||
uni.showToast({
|
||
title: getOmniText(getCurrentLocale(), 'portal.copied_whatsapp'),
|
||
icon: 'none',
|
||
});
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (type === 'linkedin') {
|
||
uni.setClipboardData({
|
||
data: value,
|
||
success: () => {
|
||
uni.showToast({
|
||
title: getOmniText(getCurrentLocale(), 'portal.copied_linkedin'),
|
||
icon: 'none',
|
||
});
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (type === 'email') {
|
||
uni.setClipboardData({
|
||
data: value,
|
||
success: () => {
|
||
uni.showToast({
|
||
title: getOmniText(getCurrentLocale(), 'portal.copied_email'),
|
||
icon: 'none',
|
||
});
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (type === 'telegram') {
|
||
const raw = value.trim();
|
||
const handle = raw.replace(/^@+/, '');
|
||
const link = /https?:\/\//i.test(raw) ? raw : `https://t.me/${handle}`;
|
||
uni.setClipboardData({
|
||
data: link,
|
||
success: () => {
|
||
uni.showToast({
|
||
title: getOmniText(getCurrentLocale(), 'portal.copied_telegram'),
|
||
icon: 'none',
|
||
});
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (type === 'twitter') {
|
||
uni.setClipboardData({
|
||
data: value,
|
||
success: () => {
|
||
uni.showToast({
|
||
title: getOmniText(getCurrentLocale(), 'portal.copied_twitter'),
|
||
icon: 'none',
|
||
});
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (type === 'wechat') {
|
||
uni.setClipboardData({
|
||
data: value,
|
||
success: () => {
|
||
uni.showToast({
|
||
title: getOmniText(getCurrentLocale(), 'portal.copied_wechat'),
|
||
icon: 'none',
|
||
});
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 根据 colSpan 计算 Bento Grid 列宽样式类
|
||
*/
|
||
export function getBentoColSpanClass(colSpan: number): string {
|
||
if (colSpan === 2) {
|
||
return 'bento-col-2';
|
||
}
|
||
return 'bento-col-1';
|
||
}
|
||
|
||
/**
|
||
* 过滤非 Hero 的 Bento 卡片并按 sortOrder 升序排列
|
||
*/
|
||
export function filterVisibleBentoBlocks(blocks?: BentoBlockPublic[]): BentoBlockPublic[] {
|
||
if (!blocks || !Array.isArray(blocks)) {
|
||
return [];
|
||
}
|
||
return blocks
|
||
.filter((b) => b.type !== 'hero')
|
||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||
}
|