/** * 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; scrollDepth: 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(), scrollDepth: 0, touchType: 'link', isPlayingAudio: false, audioContext: null, }; } /** * 加载微官网名片聚合数据并上报首次触碰 (支持离线 LocalStorage 极速预加载与静默更新) */ export async function loadCardPortal( state: PortalState, cardIdOrSlug: string, source: string = 'link' ): Promise { state.errorMessage = ''; state.touchType = source; state.enterTime = Date.now(); state.scrollDepth = 0; // 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, viewedBlocks: ['hero'], }).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 function updatePortalScrollDepth( state: PortalState, scrollTop: number, scrollHeight: number, windowHeight: number ): number { if (scrollHeight <= 0) { return state.scrollDepth; } const maxScroll = Math.max(1, scrollHeight - windowHeight); const currentRatio = Math.min(1, Math.max(0, scrollTop / maxScroll)); const depth = Math.round(currentRatio * 100); if (depth > state.scrollDepth) { state.scrollDepth = depth; } return state.scrollDepth; } /** * 按真实滚动深度收录已进入视口的模块,未滚动只记名片主页 */ export function collectViewedBlockKeys( card: CardPortalDetail | null, scrollDepth: number ): string[] { const keys: string[] = ['hero']; if (card == null) { return keys; } const depth = Math.min(100, Math.max(0, Math.round(scrollDepth))); const blocks = filterVisibleBentoBlocks(card.bentoBlocks); const sequence: string[] = []; if (depth > 0) { sequence.push('appointment'); } const visibleCount = depth <= 0 ? 0 : Math.max(1, Math.ceil((depth / 100) * Math.max(blocks.length, 1))); const take = Math.min(blocks.length, visibleCount); for (let i = 0; i < take; i++) { const block = blocks[i]; const id = block.id != null ? String(block.id).trim() : ''; if (id !== '') { sequence.push(id); } else if (block.type != null && String(block.type).trim() !== '') { sequence.push(String(block.type).trim()); } } const seen: Record = { hero: true }; for (let i = 0; i < sequence.length; i++) { const key = sequence[i]; if (seen[key] === true) { continue; } seen[key] = true; keys.push(key); } return keys; } /** * 上报访客停留时长与阅读深度,并带上已查看模块 */ export async function reportPortalStay( state: PortalState, scrollDepth: number = 0 ): Promise { if (!state.card || !state.card.id) { return null; } const depth = Math.min(100, Math.max(0, Math.round(scrollDepth))); if (depth > state.scrollDepth) { state.scrollDepth = depth; } 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: state.scrollDepth, viewedBlocks: collectViewedBlockKeys(state.card, state.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' | 'line' | 'kakao'; /** * Line / KakaoTalk 等外链入口:仅合法 http(s) URL 可渲染与触发 */ export function isRenderableHttpUrl(value?: string | null): boolean { if (value == null) { return false; } const trimmed = value.trim(); if (trimmed === '') { return false; } return /^https?:\/\//i.test(trimmed); } 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 === 'line' || type === 'kakao') && !isRenderableHttpUrl(value)) { 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; } if (type === 'line') { uni.setClipboardData({ data: value.trim(), success: () => { uni.showToast({ title: getOmniText(getCurrentLocale(), 'portal.copied_line'), icon: 'none', }); }, }); return true; } if (type === 'kakao') { uni.setClipboardData({ data: value.trim(), success: () => { uni.showToast({ title: getOmniText(getCurrentLocale(), 'portal.copied_kakao'), 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); } export type PortalCollectCopyKey = 'label' | 'saved'; /** * 收藏大厅文案:只解析 locale,不改 load / save / 上报 */ export function resolvePortalCollectText( key: PortalCollectCopyKey, locale?: OmniLocale ): string { const loc = locale != null ? locale : getCurrentLocale(); if (key === 'saved') { return getOmniText(loc, 'portal.collected'); } return getOmniText(loc, 'portal.collect'); } /** * 预约动作雷达轨迹上报,自动推进 CRM 商机流转 */ export async function trackAppointmentAction( state: PortalState, meetingSummary?: string ): Promise { if (state.card == null || state.card!.id == null) { return false; } try { await recordRadarTrack({ cardId: state.card!.id, visitorId: state.visitorId, touchType: 'appointment', stayDurationMs: Math.max(0, Date.now() - state.enterTime), scrollDepth: 100, }); return true; } catch (_e) { return false; } } export const OWNER_CARD_IDS_STORAGE_KEY = 'omni_owner_card_ids'; /** * 读取本地名片主人 ID 集合缓存 */ export function getOwnerCardIds(): string[] { try { const raw = uni.getStorageSync(OWNER_CARD_IDS_STORAGE_KEY); if (raw == null) { return []; } if (Array.isArray(raw)) { const list: string[] = []; for (let i = 0; i < raw.length; i++) { list.push(String(raw[i])); } return list; } if (typeof raw === 'string') { const str = (raw as string).trim(); if (str.startsWith('[')) { const parsed = JSON.parse(str); if (Array.isArray(parsed)) { const list: string[] = []; for (let i = 0; i < parsed.length; i++) { list.push(String(parsed[i])); } return list; } } } } catch (_e) {} return []; } /** * 缓存名片主人名下的全部卡片 ID 与 Slug */ export function setOwnerCardIds(ids: string[]): void { try { uni.setStorageSync(OWNER_CARD_IDS_STORAGE_KEY, ids); } catch (_e) {} } export type CheckIsOwnerParams = { hasToken: boolean; queryId?: string; queryMode?: string; cardId?: string | number; cardSlug?: string; ownerCardIds?: string[]; }; /** * 精准判定当前浏览者是否为该名片主人 * 1. 显式参数带 mode=owner -> true * 2. 具备登录 Token 且: * - queryId 为空或 'default'(TabBar 默认进入) -> true * - 或 cardId / cardSlug 存在于名片主卡片 ID 缓存集合中 -> true * 3. 其余外部访客场景 -> false */ export function checkIsCardOwner(params: CheckIsOwnerParams): boolean { if (params.queryMode === 'owner') { return true; } if (!params.hasToken) { return false; } const qId = params.queryId != null ? params.queryId.trim().toLowerCase() : ''; if (qId === '' || qId === 'default') { return true; } const currentId = params.cardId != null ? String(params.cardId).trim() : ''; const currentSlug = params.cardSlug != null ? String(params.cardSlug).trim() : ''; const list = params.ownerCardIds != null ? params.ownerCardIds : getOwnerCardIds(); for (let i = 0; i < list.length; i++) { const item = String(list[i]).trim(); if (item !== '' && (item === currentId || item === currentSlug)) { return true; } } return false; } /** * 动态解析主 CTA 按钮文案 (主人态: 分享名片 / 访客态: 保存到通讯录) */ export function resolvePortalCtaText(isOwner: boolean, locale?: OmniLocale): string { const loc = locale != null ? locale : getCurrentLocale(); if (isOwner) { return getOmniText(loc, 'card.share'); } return getOmniText(loc, 'card.save_contact'); } /** * 动态解析微官网顶栏辅助操作文案 (主人态: 编辑 / 访客态: 交换名片) */ export function resolvePortalHeaderLinkText(isOwner: boolean, locale?: OmniLocale): string { const loc = locale != null ? locale : getCurrentLocale(); if (isOwner) { return getOmniText(loc, 'common.edit'); } return getOmniText(loc, 'card.exchange'); } export type PortalGoBackResult = { action: string; targetUrl: string; }; /** * 微官网/预览页顶栏返回上一页或主控台智能路由处理 */ export function executePortalGoBack(isOwner: boolean): PortalGoBackResult { try { if (typeof getCurrentPages === 'function') { const pages = getCurrentPages(); if (pages != null && pages.length > 1) { uni.navigateBack(); return { action: 'navigateBack', targetUrl: '' }; } } } catch (_e) {} const target = isOwner ? '/pages/owner/card/index' : '/pages/owner/hall/index'; try { uni.switchTab({ url: target, }); } catch (_e) { try { uni.reLaunch({ url: target, }); } catch (_err) {} } return { action: 'switchTab', targetUrl: target }; }