feat(mpuniapp): 实现移动微官网 Bento 首页骨架与 Hero 身份区 (Task 33)
这个提交包含在:
335
backend/test/mpuniapp/portal.test.ts
普通文件
335
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<string, string>;
|
||||||
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pages": [
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "pages/portal/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "数字微官网",
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"backgroundColorContent": "#F7F8FA"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/tabBar/component",
|
"path": "pages/tabBar/component",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
574
mpuniapp/pages/portal/index.uvue
普通文件
574
mpuniapp/pages/portal/index.uvue
普通文件
@@ -0,0 +1,574 @@
|
|||||||
|
<template>
|
||||||
|
<view class="portal-page">
|
||||||
|
<scroll-view class="portal-scroll" :scroll-y="true">
|
||||||
|
<!-- 骨架加载态 -->
|
||||||
|
<view v-if="state.loading" class="portal-skeleton">
|
||||||
|
<view class="skeleton-avatar"></view>
|
||||||
|
<view class="skeleton-line line-title"></view>
|
||||||
|
<view class="skeleton-line line-subtitle"></view>
|
||||||
|
<view class="skeleton-card"></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 异常提示态 -->
|
||||||
|
<view v-else-if="state.errorMessage" class="portal-error-card">
|
||||||
|
<text class="error-title">微官网暂不可用</text>
|
||||||
|
<text class="error-desc">{{ state.errorMessage }}</text>
|
||||||
|
<button class="retry-btn" @click="reloadPortal">
|
||||||
|
<text class="retry-btn-text">重新加载</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 核心微官网内容区 -->
|
||||||
|
<view v-else-if="state.card != null" class="portal-content">
|
||||||
|
<!-- 品牌标识 (若配置) -->
|
||||||
|
<view v-if="state.card!.brandKit && state.card!.brandKit!.logoUrl" class="brand-bar">
|
||||||
|
<image class="brand-logo" :src="state.card!.brandKit!.logoUrl" mode="heightFix" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 1. Hero 身份区 -->
|
||||||
|
<view class="hero-card">
|
||||||
|
<view class="hero-top-row">
|
||||||
|
<image
|
||||||
|
class="hero-avatar"
|
||||||
|
:src="state.card!.avatarUrl || defaultAvatar"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view v-if="state.card!.timezone" class="hero-timezone-pill">
|
||||||
|
<text class="hero-timezone-text">{{ state.card!.timezone }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 姓名与发音试听 -->
|
||||||
|
<view class="hero-name-row">
|
||||||
|
<text class="hero-name">{{ state.card!.name }}</text>
|
||||||
|
<view
|
||||||
|
v-if="state.card!.audioPronunciationUrl"
|
||||||
|
class="pronounce-pill"
|
||||||
|
:class="{ 'pronounce-pill-active': state.isPlayingAudio }"
|
||||||
|
@click="handleAudioToggle"
|
||||||
|
>
|
||||||
|
<text class="pronounce-icon">{{ state.isPlayingAudio ? '■' : '▶' }}</text>
|
||||||
|
<text class="pronounce-label">{{ state.isPlayingAudio ? '播放中' : '发音' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 头衔与所属公司 -->
|
||||||
|
<text class="hero-title">{{ state.card!.title }}</text>
|
||||||
|
<view class="hero-company-row" @click="handleCompanyClick">
|
||||||
|
<text class="hero-company-name">{{ state.card!.company }}</text>
|
||||||
|
<text class="hero-company-arrow">↗</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 个人签名 Slogan -->
|
||||||
|
<text v-if="state.card!.slogan" class="hero-slogan">{{ state.card!.slogan }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 2. 快捷动作条 (触控区 >= 44x44) -->
|
||||||
|
<view class="quick-action-bar">
|
||||||
|
<view class="action-item" @click="handleAction('whatsapp')">
|
||||||
|
<view class="action-icon-box">
|
||||||
|
<text class="action-icon-text">WA</text>
|
||||||
|
</view>
|
||||||
|
<text class="action-item-label">WhatsApp</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="action-item" @click="handleAction('linkedin')">
|
||||||
|
<view class="action-icon-box">
|
||||||
|
<text class="action-icon-text">IN</text>
|
||||||
|
</view>
|
||||||
|
<text class="action-item-label">LinkedIn</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="action-item" @click="handleAction('email')">
|
||||||
|
<view class="action-icon-box">
|
||||||
|
<text class="action-icon-text">@</text>
|
||||||
|
</view>
|
||||||
|
<text class="action-item-label">邮件</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="action-item" @click="handleAction('phone')">
|
||||||
|
<view class="action-icon-box">
|
||||||
|
<text class="action-icon-text">TEL</text>
|
||||||
|
</view>
|
||||||
|
<text class="action-item-label">电话呼出</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 3. Bento Grid 网格展厅 -->
|
||||||
|
<view class="bento-grid">
|
||||||
|
<view
|
||||||
|
v-for="block in visibleBlocks"
|
||||||
|
:key="block.id"
|
||||||
|
class="bento-card"
|
||||||
|
:class="getColClass(block.colSpan)"
|
||||||
|
@click="handleBlockClick(block)"
|
||||||
|
>
|
||||||
|
<!-- 卡片类型标签 -->
|
||||||
|
<view class="bento-tag">
|
||||||
|
<text class="bento-tag-text">{{ getBlockTypeLabel(block.type) }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 卡片多媒体封面 (若有) -->
|
||||||
|
<image
|
||||||
|
v-if="block.mediaUrl"
|
||||||
|
class="bento-cover"
|
||||||
|
:src="block.mediaUrl"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 卡片标题与内容 -->
|
||||||
|
<text v-if="block.title" class="bento-title">{{ block.title }}</text>
|
||||||
|
<text v-if="block.content" class="bento-content">{{ block.content }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 页面底部安全垫高,避免被常驻主 CTA 遮挡 -->
|
||||||
|
<view class="portal-bottom-spacer"></view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 4. 单屏唯一定位主 CTA (保存到通讯录) -->
|
||||||
|
<view v-if="state.card != null" class="portal-cta-bar">
|
||||||
|
<button class="primary-cta-btn" @click="handleSaveContact">
|
||||||
|
<text class="primary-cta-btn-text">保存到手机通讯录</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="uts">
|
||||||
|
import {
|
||||||
|
BentoBlockPublic,
|
||||||
|
} from '../../common/types.uts';
|
||||||
|
import {
|
||||||
|
createPortalState,
|
||||||
|
loadCardPortal,
|
||||||
|
reportPortalStay,
|
||||||
|
toggleAudioPronunciation,
|
||||||
|
triggerQuickAction,
|
||||||
|
getBentoColSpanClass,
|
||||||
|
filterVisibleBentoBlocks,
|
||||||
|
} from './usePortal.uts';
|
||||||
|
import { saveToContacts } from '../../common/vcard-export.uts';
|
||||||
|
|
||||||
|
const defaultAvatar = 'https://cdn.omnicard.ai/assets/default-avatar.png';
|
||||||
|
|
||||||
|
// 页面响应式状态
|
||||||
|
const state = reactive(createPortalState());
|
||||||
|
const currentCardId = ref('');
|
||||||
|
|
||||||
|
// 计算过滤后的 Bento 块
|
||||||
|
const visibleBlocks = computed((): BentoBlockPublic[] => {
|
||||||
|
if (!state.card || !state.card!.bentoBlocks) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return filterVisibleBentoBlocks(state.card!.bentoBlocks);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 样式类映射
|
||||||
|
function getColClass(colSpan: number): string {
|
||||||
|
return getBentoColSpanClass(colSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 块类型显示标签
|
||||||
|
function getBlockTypeLabel(type: string): string {
|
||||||
|
if (type === 'case') return '精选案例';
|
||||||
|
if (type === 'article') return '深度洞见';
|
||||||
|
if (type === 'social') return '社媒矩阵';
|
||||||
|
if (type === 'video') return '展会视频';
|
||||||
|
return '微官网内容';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放发音
|
||||||
|
function handleAudioToggle(): void {
|
||||||
|
if (state.card && state.card!.audioPronunciationUrl) {
|
||||||
|
toggleAudioPronunciation(state, state.card!.audioPronunciationUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 快捷动作
|
||||||
|
function handleAction(type: 'whatsapp' | 'linkedin' | 'email' | 'phone'): void {
|
||||||
|
if (!state.card) return;
|
||||||
|
let targetVal = '';
|
||||||
|
if (type === 'whatsapp') targetVal = state.card!.whatsapp || '';
|
||||||
|
if (type === 'linkedin') targetVal = state.card!.linkedin || '';
|
||||||
|
if (type === 'email') targetVal = state.card!.email || '';
|
||||||
|
if (type === 'phone') targetVal = state.card!.phone || '';
|
||||||
|
triggerQuickAction(type, targetVal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公司反向链接点击
|
||||||
|
function handleCompanyClick(): void {
|
||||||
|
if (!state.card) return;
|
||||||
|
uni.showToast({
|
||||||
|
title: state.card!.company,
|
||||||
|
icon: 'none',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bento 卡片点击
|
||||||
|
function handleBlockClick(block: BentoBlockPublic): void {
|
||||||
|
if (block.linkUrl && block.linkUrl!.trim() !== '') {
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: block.linkUrl!,
|
||||||
|
success: () => {
|
||||||
|
uni.showToast({
|
||||||
|
title: '已复制链接',
|
||||||
|
icon: 'none',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (block.title) {
|
||||||
|
uni.showToast({
|
||||||
|
title: block.title!,
|
||||||
|
icon: 'none',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存至通讯录
|
||||||
|
function handleSaveContact(): void {
|
||||||
|
if (!state.card) return;
|
||||||
|
saveToContacts(state.card as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新加载
|
||||||
|
function reloadPortal(): void {
|
||||||
|
if (currentCardId.value) {
|
||||||
|
loadCardPortal(state, currentCardId.value, state.touchType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad((options: any) => {
|
||||||
|
const queryId = (options && (options.id || options.slug)) ? String(options.id || options.slug) : 'default';
|
||||||
|
const querySource = (options && options.source) ? String(options.source) : 'link';
|
||||||
|
currentCardId.value = queryId;
|
||||||
|
loadCardPortal(state, queryId, querySource);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnload(() => {
|
||||||
|
reportPortalStay(state, 100);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.portal-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #F7F8FA;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-scroll {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 品牌栏 */
|
||||||
|
.brand-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.brand-logo {
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 骨架屏 */
|
||||||
|
.portal-skeleton {
|
||||||
|
padding: 24px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.skeleton-avatar {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 36px;
|
||||||
|
background-color: #E3E7ED;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.skeleton-line {
|
||||||
|
height: 16px;
|
||||||
|
background-color: #E3E7ED;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.line-title {
|
||||||
|
width: 60%;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
.line-subtitle {
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
.skeleton-card {
|
||||||
|
height: 180px;
|
||||||
|
background-color: #E3E7ED;
|
||||||
|
border-radius: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 异常提示 */
|
||||||
|
.portal-error-card {
|
||||||
|
margin: 40px 16px;
|
||||||
|
padding: 24px;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
border: 1px solid #E3E7ED;
|
||||||
|
border-radius: 16px;
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.error-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #101828;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.error-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #667085;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.retry-btn {
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 24px;
|
||||||
|
background-color: #0B5CFF;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.retry-btn-text {
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 1. Hero 身份卡片 */
|
||||||
|
.hero-card {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
border: 1px solid #E3E7ED;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.hero-top-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.hero-avatar {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 36px;
|
||||||
|
border: 1px solid #E3E7ED;
|
||||||
|
background-color: #F7F8FA;
|
||||||
|
}
|
||||||
|
.hero-timezone-pill {
|
||||||
|
padding: 4px 10px;
|
||||||
|
background-color: #F7F8FA;
|
||||||
|
border: 1px solid #E3E7ED;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.hero-timezone-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #667085;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-name-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.hero-name {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #101828;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
.pronounce-pill {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: #EAF1FF;
|
||||||
|
}
|
||||||
|
.pronounce-pill-active {
|
||||||
|
background-color: #0B5CFF;
|
||||||
|
}
|
||||||
|
.pronounce-icon {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #0B5CFF;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.pronounce-pill-active .pronounce-icon {
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
.pronounce-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #0B5CFF;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.pronounce-pill-active .pronounce-label {
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #667085;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.hero-company-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.hero-company-name {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #101828;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.hero-company-arrow {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #0B5CFF;
|
||||||
|
}
|
||||||
|
.hero-slogan {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #667085;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-top: 1px solid #F7F8FA;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. 快捷动作条 */
|
||||||
|
.quick-action-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.action-item {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 52px;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
border: 1px solid #E3E7ED;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin: 0 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 4px;
|
||||||
|
}
|
||||||
|
.action-icon-box {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.action-icon-text {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0B5CFF;
|
||||||
|
}
|
||||||
|
.action-item-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #101828;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. Bento Grid 网格 */
|
||||||
|
.bento-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.bento-card {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
border: 1px solid #E3E7ED;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.bento-col-1 {
|
||||||
|
width: 48.5%;
|
||||||
|
}
|
||||||
|
.bento-col-2 {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.bento-tag {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background-color: #F7F8FA;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.bento-tag-text {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #667085;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.bento-cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background-color: #F7F8FA;
|
||||||
|
}
|
||||||
|
.bento-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #101828;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.bento-content {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #667085;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部垫高与常驻主 CTA */
|
||||||
|
.portal-bottom-spacer {
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
.portal-cta-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
padding: 12px 16px 24px 16px;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
border-top: 1px solid #E3E7ED;
|
||||||
|
}
|
||||||
|
.primary-cta-btn {
|
||||||
|
height: 48px;
|
||||||
|
background-color: #0B5CFF;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.primary-cta-btn-text {
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
256
mpuniapp/pages/portal/usePortal.uts
普通文件
256
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<CardPortalDetail | null> {
|
||||||
|
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<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 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);
|
||||||
|
}
|
||||||
在新工单中引用
屏蔽一个用户