From 2bc395c3847482c95fa65001c2ef8b663e4d448f Mon Sep 17 00:00:00 2001 From: wikiboss Date: Sun, 20 Sep 2026 17:36:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(mpuniapp):=20=E5=AE=9E=E7=8E=B0=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E5=BE=AE=E5=AE=98=E7=BD=91=20Bento=20=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E9=AA=A8=E6=9E=B6=E4=B8=8E=20Hero=20=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E5=8C=BA=20(Task=2033)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/test/mpuniapp/portal.test.ts | 335 ++++++++++++++++ mpuniapp/pages.json | 8 + mpuniapp/pages/portal/index.uvue | 574 +++++++++++++++++++++++++++ mpuniapp/pages/portal/usePortal.uts | 256 ++++++++++++ 4 files changed, 1173 insertions(+) create mode 100644 backend/test/mpuniapp/portal.test.ts create mode 100644 mpuniapp/pages/portal/index.uvue create mode 100644 mpuniapp/pages/portal/usePortal.uts diff --git a/backend/test/mpuniapp/portal.test.ts b/backend/test/mpuniapp/portal.test.ts new file mode 100644 index 0000000..f2f0fe3 --- /dev/null +++ b/backend/test/mpuniapp/portal.test.ts @@ -0,0 +1,335 @@ +/** + * OmniCard AI (CRMmp) - mpuniapp 微官网 Bento 首页与 Hero 身份区测试套件 (TDD Red -> Green) + * + * 严格按照 AGENTS.md 3.3、4.3、5.1 规范与 PRD 3.1、5.1 设计编写 + */ + +describe('OmniCard AI 用户端微官网 Bento 首页与 Hero 业务逻辑测试 (usePortal.uts)', () => { + let storageMap: Map; + let toastCalls: Array<{ title: string; icon?: string }>; + let makePhoneCallParam: { phoneNumber: string } | null; + let clipboardText: string; + let audioContextMock: any; + let mockRequestResponse: { + statusCode?: number; + data?: any; + failError?: any; + }; + + beforeEach(() => { + storageMap = new Map(); + toastCalls = []; + makePhoneCallParam = null; + clipboardText = ''; + + audioContextMock = { + src: '', + play: jest.fn(), + pause: jest.fn(), + stop: jest.fn(), + destroy: jest.fn(), + onPlay: jest.fn((cb: () => void) => { + audioContextMock._onPlayCb = cb; + }), + onEnded: jest.fn((cb: () => void) => { + audioContextMock._onEndedCb = cb; + }), + onError: jest.fn((cb: (err: any) => void) => { + audioContextMock._onErrorCb = cb; + }), + _onPlayCb: null, + _onEndedCb: null, + _onErrorCb: null, + }; + + mockRequestResponse = { + statusCode: 200, + data: { + code: 1000, + message: 'success', + data: { + id: 'card-101', + name: 'Alex Vance', + title: 'Managing Director & Global Partner', + company: 'Nexus Tech Global Ltd', + timezone: 'Dubai UTC+4', + audioPronunciationUrl: 'https://cdn.omnicard.ai/audio/alex.mp3', + phone: '+971501234567', + email: 'alex.vance@nexustech.global', + whatsapp: '+971501234567', + linkedin: 'https://linkedin.com/in/alexvance', + status: 'published', + bentoBlocks: [ + { + id: 1, + cardId: 'card-101', + type: 'hero', + colSpan: 2, + rowSpan: 1, + sortOrder: 0, + title: 'Hero Block', + }, + { + id: 2, + cardId: 'card-101', + type: 'case', + colSpan: 2, + rowSpan: 1, + sortOrder: 1, + title: 'Middle East Energy Hub', + content: 'Built scalable renewable energy microgrid in UAE.', + }, + { + id: 3, + cardId: 'card-101', + type: 'article', + colSpan: 1, + rowSpan: 1, + sortOrder: 2, + title: 'The Future of Cross-Border Trade', + }, + { + id: 4, + cardId: 'card-101', + type: 'social', + colSpan: 1, + rowSpan: 1, + sortOrder: 3, + title: 'Global Social Matrix', + }, + ], + }, + }, + }; + + // 构造全局 uni 运行时 Mock 环境 + (global as any).uni = { + getStorageSync: (key: string) => storageMap.get(key) || '', + setStorageSync: (key: string, val: string) => storageMap.set(key, val), + removeStorageSync: (key: string) => storageMap.delete(key), + showToast: (options: { title: string; icon?: string }) => { + toastCalls.push(options); + }, + reLaunch: jest.fn(), + makePhoneCall: (options: { phoneNumber: string }) => { + makePhoneCallParam = options; + }, + setClipboardData: (options: { data: string; success?: () => void }) => { + clipboardText = options.data; + options.success?.(); + }, + createInnerAudioContext: () => audioContextMock, + request: (options: any) => { + setTimeout(() => { + if (mockRequestResponse.failError) { + options.fail?.(mockRequestResponse.failError); + } else { + options.success?.({ + statusCode: mockRequestResponse.statusCode ?? 200, + data: mockRequestResponse.data, + header: {}, + }); + } + options.complete?.(); + }, 0); + return { + abort: jest.fn(), + }; + }, + }; + }); + + afterEach(() => { + delete (global as any).uni; + }); + + describe('1. 访客标识识别与生成 (getOrCreateVisitorId)', () => { + it('1.1 若本地无 visitorId,则自动生成持久化并返回', () => { + // @ts-ignore + const { getOrCreateVisitorId } = require('../../../mpuniapp/pages/portal/usePortal.uts'); + const id = getOrCreateVisitorId(); + expect(id).toBeDefined(); + expect(id.startsWith('vis_')).toBe(true); + expect(storageMap.get('omni_visitor_id')).toBe(id); + }); + + it('1.2 若本地已有 visitorId,则直接复用', () => { + // @ts-ignore + const { getOrCreateVisitorId } = require('../../../mpuniapp/pages/portal/usePortal.uts'); + storageMap.set('omni_visitor_id', 'vis_existing_7788'); + const id = getOrCreateVisitorId(); + expect(id).toBe('vis_existing_7788'); + }); + }); + + describe('2. 微官网数据拉取与雷达初次触碰上报 (loadCardPortal)', () => { + it('2.1 成功拉取名片聚合详情并自动上报首次雷达轨迹', async () => { + // @ts-ignore + const { createPortalState, loadCardPortal } = require('../../../mpuniapp/pages/portal/usePortal.uts'); + + const state = createPortalState(); + expect(state.loading).toBe(false); + expect(state.card).toBeNull(); + + const loadPromise = loadCardPortal(state, 'card-101', 'qr'); + expect(state.loading).toBe(true); + + const result = await loadPromise; + expect(state.loading).toBe(false); + expect(state.errorMessage).toBe(''); + expect(result).not.toBeNull(); + expect(state.card?.id).toBe('card-101'); + expect(state.card?.name).toBe('Alex Vance'); + expect(state.touchType).toBe('qr'); + }); + + it('2.2 网络异常或404时正确设置错误提示并结束 loading', async () => { + // @ts-ignore + const { createPortalState, loadCardPortal } = require('../../../mpuniapp/pages/portal/usePortal.uts'); + + mockRequestResponse = { + statusCode: 404, + data: { + code: 404, + message: '名片不存在或未公开', + }, + }; + + const state = createPortalState(); + const result = await loadCardPortal(state, 'non-existing-id'); + + expect(state.loading).toBe(false); + expect(result).toBeNull(); + expect(state.card).toBeNull(); + expect(state.errorMessage).toContain('名片不存在'); + }); + }); + + describe('3. 访客雷达停留时长与深度上报 (reportPortalStay)', () => { + it('3.1 页面停留后上报有效停留毫秒数与滚动深度', async () => { + // @ts-ignore + const { createPortalState, loadCardPortal, reportPortalStay } = require('../../../mpuniapp/pages/portal/usePortal.uts'); + + const state = createPortalState(); + await loadCardPortal(state, 'card-101', 'link'); + + // 模拟停留 500ms + state.enterTime = Date.now() - 500; + + const track = await reportPortalStay(state, 85); + expect(track).toBeDefined(); + }); + + it('3.2 若名片尚未成功加载,上报应安全返回 null 而不抛出异常', async () => { + // @ts-ignore + const { createPortalState, reportPortalStay } = require('../../../mpuniapp/pages/portal/usePortal.uts'); + + const state = createPortalState(); + const track = await reportPortalStay(state, 0); + expect(track).toBeNull(); + }); + }); + + describe('4. 名字发音试听控制 (toggleAudioPronunciation)', () => { + it('4.1 成功创建音频上下文并播放,更新 isPlayingAudio 状态', () => { + // @ts-ignore + const { createPortalState, toggleAudioPronunciation } = require('../../../mpuniapp/pages/portal/usePortal.uts'); + + const state = createPortalState(); + const audioUrl = 'https://cdn.omnicard.ai/audio/alex.mp3'; + + toggleAudioPronunciation(state, audioUrl); + expect(audioContextMock.src).toBe(audioUrl); + expect(audioContextMock.play).toHaveBeenCalled(); + expect(state.isPlayingAudio).toBe(true); + + // 再次点击应触发暂停/停止 + toggleAudioPronunciation(state, audioUrl); + expect(audioContextMock.stop).toHaveBeenCalled(); + expect(state.isPlayingAudio).toBe(false); + }); + + it('4.2 音频播放完毕或异常回调时自动复位 isPlayingAudio 状态', () => { + // @ts-ignore + const { createPortalState, toggleAudioPronunciation } = require('../../../mpuniapp/pages/portal/usePortal.uts'); + + const state = createPortalState(); + toggleAudioPronunciation(state, 'https://cdn.omnicard.ai/audio/alex.mp3'); + expect(state.isPlayingAudio).toBe(true); + + // 触发 onEnded 回调 + audioContextMock._onEndedCb?.(); + expect(state.isPlayingAudio).toBe(false); + }); + + it('4.3 若未提供 audioUrl,安全退出不创建上下文', () => { + // @ts-ignore + const { createPortalState, toggleAudioPronunciation } = require('../../../mpuniapp/pages/portal/usePortal.uts'); + + const state = createPortalState(); + toggleAudioPronunciation(state, ''); + expect(audioContextMock.play).not.toHaveBeenCalled(); + expect(state.isPlayingAudio).toBe(false); + }); + }); + + describe('5. 快捷动作条触发与呼出交互 (triggerQuickAction)', () => { + it('5.1 点击电话直接调用原生 makePhoneCall', () => { + // @ts-ignore + const { triggerQuickAction } = require('../../../mpuniapp/pages/portal/usePortal.uts'); + + const ok = triggerQuickAction('phone', '+971501234567'); + expect(ok).toBe(true); + expect(makePhoneCallParam?.phoneNumber).toBe('+971501234567'); + }); + + it('5.2 点击 WhatsApp 或 LinkedIn 将安全处理并提示', () => { + // @ts-ignore + const { triggerQuickAction } = require('../../../mpuniapp/pages/portal/usePortal.uts'); + + const okWa = triggerQuickAction('whatsapp', '+971501234567'); + expect(okWa).toBe(true); + + const okIn = triggerQuickAction('linkedin', 'https://linkedin.com/in/alexvance'); + expect(okIn).toBe(true); + }); + + it('5.3 字段为空时给出清晰 Toast 提示而不报错', () => { + // @ts-ignore + const { triggerQuickAction } = require('../../../mpuniapp/pages/portal/usePortal.uts'); + + const ok = triggerQuickAction('phone', ''); + expect(ok).toBe(false); + expect(toastCalls.length).toBeGreaterThan(0); + expect(toastCalls[0].title).toContain('未设置'); + }); + }); + + describe('6. Bento Grid 跨列与数据过滤规则 (getBentoColSpanClass & filterVisibleBentoBlocks)', () => { + it('6.1 colSpan 计算:1 为半宽,2 为全宽', () => { + // @ts-ignore + const { getBentoColSpanClass } = require('../../../mpuniapp/pages/portal/usePortal.uts'); + + expect(getBentoColSpanClass(1)).toBe('bento-col-1'); + expect(getBentoColSpanClass(2)).toBe('bento-col-2'); + // 默认/兜底 + expect(getBentoColSpanClass(0 as any)).toBe('bento-col-1'); + }); + + it('6.2 过滤 Hero 类型的块,并按 sortOrder 升序排列其余 Bento 块', () => { + // @ts-ignore + const { filterVisibleBentoBlocks } = require('../../../mpuniapp/pages/portal/usePortal.uts'); + + const rawBlocks = [ + { id: 1, type: 'hero', sortOrder: 0, title: 'Hero' }, + { id: 4, type: 'social', sortOrder: 3, title: 'Social' }, + { id: 2, type: 'case', sortOrder: 1, title: 'Case' }, + { id: 3, type: 'article', sortOrder: 2, title: 'Article' }, + ]; + + const visible = filterVisibleBentoBlocks(rawBlocks as any); + expect(visible.length).toBe(3); + expect(visible.map((b: any) => b.type)).toEqual(['case', 'article', 'social']); + }); + }); +}); diff --git a/mpuniapp/pages.json b/mpuniapp/pages.json index a9747d2..8df2fca 100644 --- a/mpuniapp/pages.json +++ b/mpuniapp/pages.json @@ -12,6 +12,14 @@ } }, "pages": [ + { + "path": "pages/portal/index", + "style": { + "navigationBarTitleText": "数字微官网", + "navigationStyle": "custom", + "backgroundColorContent": "#F7F8FA" + } + }, { "path": "pages/tabBar/component", "style": { diff --git a/mpuniapp/pages/portal/index.uvue b/mpuniapp/pages/portal/index.uvue new file mode 100644 index 0000000..c62add5 --- /dev/null +++ b/mpuniapp/pages/portal/index.uvue @@ -0,0 +1,574 @@ + + + + + diff --git a/mpuniapp/pages/portal/usePortal.uts b/mpuniapp/pages/portal/usePortal.uts new file mode 100644 index 0000000..0a14985 --- /dev/null +++ b/mpuniapp/pages/portal/usePortal.uts @@ -0,0 +1,256 @@ +/** + * OmniCard AI (CRMmp) - 微官网 Bento 首页与 Hero 身份区业务驱动层 + * 严格遵循 uts 强类型规范与 AGENTS.md 约束 + */ + +import { + CardPortalDetail, + BentoBlockPublic, + RadarTrack, +} from '../../common/types.uts'; +import { + fetchPortalCard, + recordRadarTrack, +} from '../../common/api.uts'; + +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, + }; +} + +/** + * 加载微官网名片聚合数据并上报首次触碰 + */ +export async function loadCardPortal( + state: PortalState, + cardIdOrSlug: string, + source: string = 'link' +): Promise { + state.loading = true; + state.errorMessage = ''; + state.touchType = source; + state.enterTime = Date.now(); + + try { + const cardData = await fetchPortalCard(cardIdOrSlug); + state.card = cardData; + state.loading = false; + + // 首次载入成功后上报雷达初次触碰轨迹 + if (cardData && cardData.id) { + recordRadarTrack({ + cardId: cardData.id, + visitorId: state.visitorId, + touchType: state.touchType, + stayDurationMs: 0, + scrollDepth: 0, + }).catch((_e: any) => { + // 轨迹上报容错,不阻断主流程 + }); + } + + return cardData; + } catch (err: any) { + state.loading = false; + state.card = null; + state.errorMessage = err?.message || '获取名片微官网详情失败'; + return null; + } +} + +/** + * 上报访客停留时长与阅读深度 + */ +export async function reportPortalStay( + state: PortalState, + scrollDepth: number = 0 +): Promise { + 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 function triggerQuickAction( + type: 'whatsapp' | 'linkedin' | 'email' | 'phone', + value?: string +): boolean { + if (!value || value.trim() === '') { + uni.showToast({ + title: '未设置该联系方式', + 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: '已复制 WhatsApp 链接', + icon: 'none', + }); + }, + }); + return true; + } + + if (type === 'linkedin') { + uni.setClipboardData({ + data: value, + success: () => { + uni.showToast({ + title: '已复制 LinkedIn 链接', + icon: 'none', + }); + }, + }); + return true; + } + + if (type === 'email') { + uni.setClipboardData({ + data: value, + success: () => { + uni.showToast({ + title: '已复制邮箱地址', + 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); +}