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

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

724 行
18 KiB
Plaintext

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

/**
* 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<CardPortalDetail | null> {
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<string, boolean> = { 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<RadarTrack | null> {
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<boolean> {
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 };
}