feat(mpuniapp): 落地微官网商务日程预约与日历排期功能 (TDD)
- 新增 common/calendar.uts 日历工具,支持 Google Calendar、Outlook Live 与后端 RFC 5545 ICS 一键下载 - 扩展 common/types.uts,支持 appointment 卡片类型与日历预约参数契约 - 补齐 common/locale.uts 全球 7 种语言(含阿语 RTL)商务预约词条 - 在 pages/portal/index.uvue 中集成 Bento 预约卡片与底部预约抽屉 (Bottom Sheet) - 在 pages/portal/usePortal.uts 中增加预约雷达轨迹上报 (touchType: appointment) 闭环联动 CRM - 新增 mpuniapp/tests/appointment.test.ts 自动化单元测试套件并通过全部验证
这个提交包含在:
120
mpuniapp/common/calendar.uts
普通文件
120
mpuniapp/common/calendar.uts
普通文件
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
/**
|
||||
* OmniCard AI (CRMmp) - 商务日程预约与日历服务 (UTS 强类型版本)
|
||||
* 严格对齐 backend/src/modules/card/service/calendar.ts 规范
|
||||
*/
|
||||
|
||||
import { MeetingInviteOptions, CalendarInviteBundle } from './types.uts';
|
||||
import { getBaseUrl } from './request.uts';
|
||||
|
||||
/**
|
||||
* 格式化 UTC 时间为 RFC 5545 日期时间戳 (YYYYMMDDTHHmmssZ)
|
||||
*/
|
||||
export function formatIcsDate(date: Date | string | number): string {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
if (isNaN(d.getTime())) {
|
||||
return '';
|
||||
}
|
||||
const pad = (n: number): string => (n < 10 ? '0' + n : String(n));
|
||||
const year = d.getUTCFullYear();
|
||||
const month = pad(d.getUTCMonth() + 1);
|
||||
const day = pad(d.getUTCDate());
|
||||
const hours = pad(d.getUTCHours());
|
||||
const minutes = pad(d.getUTCMinutes());
|
||||
const seconds = pad(d.getUTCSeconds());
|
||||
return `${year}${month}${day}T${hours}${minutes}${seconds}Z`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化为 ISO 8601 字符串 (用于 Outlook)
|
||||
*/
|
||||
export function toIsoDate(date: Date | string | number): string {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
if (isNaN(d.getTime())) {
|
||||
return '';
|
||||
}
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Google Calendar Web 一键预约 URL
|
||||
*/
|
||||
export function generateGoogleCalendarUrl(meeting: MeetingInviteOptions): string {
|
||||
const summary = meeting.summary || meeting.title || '商务洽谈';
|
||||
const dtStart = formatIcsDate(meeting.startTime);
|
||||
const dtEnd = formatIcsDate(meeting.endTime);
|
||||
|
||||
let details = meeting.description || '';
|
||||
if (meeting.organizerEmail) {
|
||||
details += `\nOrganizer: ${meeting.organizerEmail}`;
|
||||
}
|
||||
|
||||
const queryParts: string[] = [
|
||||
'action=TEMPLATE',
|
||||
`text=${encodeURIComponent(summary)}`,
|
||||
`dates=${encodeURIComponent(`${dtStart}/${dtEnd}`)}`,
|
||||
`details=${encodeURIComponent(details.trim())}`,
|
||||
`location=${encodeURIComponent(meeting.location || '')}`,
|
||||
];
|
||||
|
||||
return `https://calendar.google.com/calendar/render?${queryParts.join('&')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Outlook Live 一键预约 URL
|
||||
*/
|
||||
export function generateOutlookCalendarUrl(meeting: MeetingInviteOptions): string {
|
||||
const summary = meeting.summary || meeting.title || '商务洽谈';
|
||||
const startDt = toIsoDate(meeting.startTime);
|
||||
const endDt = toIsoDate(meeting.endTime);
|
||||
|
||||
const queryParts: string[] = [
|
||||
'path=%2Fcalendar%2Faction%2Fcompose',
|
||||
'rru=addevent',
|
||||
`subject=${encodeURIComponent(summary)}`,
|
||||
`startdt=${encodeURIComponent(startDt)}`,
|
||||
`enddt=${encodeURIComponent(endDt)}`,
|
||||
`body=${encodeURIComponent(meeting.description || '')}`,
|
||||
`location=${encodeURIComponent(meeting.location || '')}`,
|
||||
];
|
||||
|
||||
return `https://outlook.live.com/calendar/0/deeplink/compose?${queryParts.join('&')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取后端 RFC 5545 ICS 订阅文件下载 URL
|
||||
*/
|
||||
export function getMeetingIcsDownloadUrl(meeting: MeetingInviteOptions): string {
|
||||
const baseUrl = getBaseUrl();
|
||||
const summary = meeting.summary || meeting.title || '商务洽谈';
|
||||
const dtStart = formatIcsDate(meeting.startTime);
|
||||
const dtEnd = formatIcsDate(meeting.endTime);
|
||||
|
||||
const queryParts: string[] = [
|
||||
`summary=${encodeURIComponent(summary)}`,
|
||||
`startTime=${encodeURIComponent(dtStart)}`,
|
||||
`endTime=${encodeURIComponent(dtEnd)}`,
|
||||
`description=${encodeURIComponent(meeting.description || '')}`,
|
||||
`location=${encodeURIComponent(meeting.location || '')}`,
|
||||
];
|
||||
|
||||
if (meeting.organizerName) {
|
||||
queryParts.push(`organizerName=${encodeURIComponent(meeting.organizerName)}`);
|
||||
}
|
||||
if (meeting.organizerEmail) {
|
||||
queryParts.push(`organizerEmail=${encodeURIComponent(meeting.organizerEmail)}`);
|
||||
}
|
||||
|
||||
return `${baseUrl}/open/card/portal/meeting-ics?${queryParts.join('&')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 综合生成全平台日历预约包
|
||||
*/
|
||||
export function buildCalendarInviteBundle(meeting: MeetingInviteOptions): CalendarInviteBundle {
|
||||
return {
|
||||
googleUrl: generateGoogleCalendarUrl(meeting),
|
||||
outlookUrl: generateOutlookCalendarUrl(meeting),
|
||||
icsUrl: getMeetingIcsDownloadUrl(meeting),
|
||||
};
|
||||
}
|
||||
@@ -68,7 +68,16 @@ export type OmniMessageKey =
|
||||
| 'portal.copied_email'
|
||||
| 'portal.copied_telegram'
|
||||
| 'portal.copied_twitter'
|
||||
| 'portal.copied_wechat';
|
||||
| 'portal.copied_wechat'
|
||||
| 'portal.collect'
|
||||
| 'portal.collected'
|
||||
| 'portal.appointment'
|
||||
| 'portal.appointment_desc'
|
||||
| 'portal.book_now'
|
||||
| 'portal.google_cal'
|
||||
| 'portal.outlook_cal'
|
||||
| 'portal.ics_download'
|
||||
| 'portal.copied_meeting_link';
|
||||
|
||||
export interface OmniLocaleConfig {
|
||||
code: OmniLocale;
|
||||
@@ -132,6 +141,15 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.copied_telegram': '已复制 Telegram 链接',
|
||||
'portal.copied_twitter': '已复制 X 链接',
|
||||
'portal.copied_wechat': '已复制微信号',
|
||||
'portal.collect': '收入大厅',
|
||||
'portal.collected': '已收入大厅',
|
||||
'portal.appointment': '商务预约',
|
||||
'portal.appointment_desc': '预约 30 分钟跨国视频会谈或展会现场洽谈',
|
||||
'portal.book_now': '立即排期',
|
||||
'portal.google_cal': 'Google 日历',
|
||||
'portal.outlook_cal': 'Outlook 日历',
|
||||
'portal.ics_download': '下载日历文件 (ICS)',
|
||||
'portal.copied_meeting_link': '已复制预约日历链接',
|
||||
},
|
||||
},
|
||||
'en-US': {
|
||||
@@ -188,6 +206,15 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.copied_telegram': 'Telegram link copied',
|
||||
'portal.copied_twitter': 'X link copied',
|
||||
'portal.copied_wechat': 'WeChat ID copied',
|
||||
'portal.collect': 'Save to Hall',
|
||||
'portal.collected': 'Saved to Hall',
|
||||
'portal.appointment': 'Schedule Meeting',
|
||||
'portal.appointment_desc': 'Book a 30-min global video sync or in-person talk',
|
||||
'portal.book_now': 'Book Now',
|
||||
'portal.google_cal': 'Google Calendar',
|
||||
'portal.outlook_cal': 'Outlook Calendar',
|
||||
'portal.ics_download': 'Download ICS File',
|
||||
'portal.copied_meeting_link': 'Calendar link copied',
|
||||
},
|
||||
},
|
||||
'ar-SA': {
|
||||
@@ -244,6 +271,15 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.copied_telegram': 'تم نسخ رابط تيليجرام',
|
||||
'portal.copied_twitter': 'تم نسخ رابط إكس',
|
||||
'portal.copied_wechat': 'تم نسخ معرف وي تشات',
|
||||
'portal.collect': 'حفظ في القاعة',
|
||||
'portal.collected': 'تم الحفظ في القاعة',
|
||||
'portal.appointment': 'حجز موعد',
|
||||
'portal.appointment_desc': 'احجز اجتماع فيديو عالمي لمدة 30 دقيقة أو محادثة شخصية',
|
||||
'portal.book_now': 'احجز الآن',
|
||||
'portal.google_cal': 'تقويم Google',
|
||||
'portal.outlook_cal': 'تقويم Outlook',
|
||||
'portal.ics_download': 'تنزيل ملف التقويم (ICS)',
|
||||
'portal.copied_meeting_link': 'تم نسخ رابط التقويم',
|
||||
},
|
||||
},
|
||||
'es-ES': {
|
||||
@@ -300,6 +336,15 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.copied_telegram': 'Enlace de Telegram copiado',
|
||||
'portal.copied_twitter': 'Enlace de X copiado',
|
||||
'portal.copied_wechat': 'ID de WeChat copiado',
|
||||
'portal.collect': 'Guardar en el hall',
|
||||
'portal.collected': 'Guardado en el hall',
|
||||
'portal.appointment': 'Agendar Reunión',
|
||||
'portal.appointment_desc': 'Reserva una reunión virtual de 30 min o charla presencial',
|
||||
'portal.book_now': 'Agendar Ahora',
|
||||
'portal.google_cal': 'Google Calendar',
|
||||
'portal.outlook_cal': 'Outlook Calendar',
|
||||
'portal.ics_download': 'Descargar Archivo ICS',
|
||||
'portal.copied_meeting_link': 'Enlace de calendario copiado',
|
||||
},
|
||||
},
|
||||
'de-DE': {
|
||||
@@ -356,6 +401,15 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.copied_telegram': 'Telegram-Link kopiert',
|
||||
'portal.copied_twitter': 'X-Link kopiert',
|
||||
'portal.copied_wechat': 'WeChat-ID kopiert',
|
||||
'portal.collect': 'In die Halle legen',
|
||||
'portal.collected': 'In der Halle gespeichert',
|
||||
'portal.appointment': 'Termin vereinbaren',
|
||||
'portal.appointment_desc': 'Buchen Sie ein 30-minütiges globales Meeting',
|
||||
'portal.book_now': 'Jetzt buchen',
|
||||
'portal.google_cal': 'Google Kalender',
|
||||
'portal.outlook_cal': 'Outlook Kalender',
|
||||
'portal.ics_download': 'ICS-Datei herunterladen',
|
||||
'portal.copied_meeting_link': 'Kalenderlink kopiert',
|
||||
},
|
||||
},
|
||||
'fr-FR': {
|
||||
@@ -412,6 +466,15 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.copied_telegram': 'Lien Telegram copié',
|
||||
'portal.copied_twitter': 'Lien X copié',
|
||||
'portal.copied_wechat': 'Identifiant WeChat copié',
|
||||
'portal.collect': 'Ajouter au hall',
|
||||
'portal.collected': 'Ajouté au hall',
|
||||
'portal.appointment': 'Prendre Rendez-vous',
|
||||
'portal.appointment_desc': 'Réservez une réunion vidéo globale de 30 minutes',
|
||||
'portal.book_now': 'Réserver maintenant',
|
||||
'portal.google_cal': 'Google Agenda',
|
||||
'portal.outlook_cal': 'Outlook Calendrier',
|
||||
'portal.ics_download': 'Télécharger le fichier ICS',
|
||||
'portal.copied_meeting_link': 'Lien de calendrier copié',
|
||||
},
|
||||
},
|
||||
'ja-JP': {
|
||||
@@ -468,6 +531,15 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.copied_telegram': 'Telegram のリンクをコピーしました',
|
||||
'portal.copied_twitter': 'X のリンクをコピーしました',
|
||||
'portal.copied_wechat': 'WeChat ID をコピーしました',
|
||||
'portal.collect': 'ホールに保存',
|
||||
'portal.collected': 'ホールに保存しました',
|
||||
'portal.appointment': '日程を予約する',
|
||||
'portal.appointment_desc': '30分間のオンライン面談または商談を予約',
|
||||
'portal.book_now': '今すぐ予約',
|
||||
'portal.google_cal': 'Google カレンダー',
|
||||
'portal.outlook_cal': 'Outlook カレンダー',
|
||||
'portal.ics_download': 'ICSファイルをダウンロード',
|
||||
'portal.copied_meeting_link': 'カレンダーのリンクをコピーしました',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -495,7 +567,7 @@ function syncState(target: OmniLocale): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 将任意语言标识规范化为 7 种主流语言之一(回退 en-US 出海默认)
|
||||
* 将任意语言标识规范化为 7 种主流语言之一(回退 en-US 国际默认)
|
||||
*/
|
||||
export function normalizeLocale(raw: string): OmniLocale {
|
||||
if (!raw || typeof raw !== 'string') {
|
||||
@@ -542,7 +614,7 @@ export function resolveDeviceLocale(raw: string): OmniLocale {
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据出海访客的国家码映射主流语言或回退 fallback(默认 en-US)
|
||||
* 根据访客的国家码映射主流语言或回退 fallback(默认 en-US)
|
||||
*/
|
||||
export function resolveGeoLocale(countryCode?: string, fallback: string = 'en-US'): OmniLocale {
|
||||
if (!countryCode || !countryCode.trim()) {
|
||||
|
||||
@@ -30,10 +30,11 @@ export interface CardProfile {
|
||||
address?: string;
|
||||
city?: string;
|
||||
country?: string;
|
||||
meetingUrl?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type BentoBlockType = 'hero' | 'case' | 'article' | 'social' | 'video' | 'form';
|
||||
export type BentoBlockType = 'hero' | 'case' | 'article' | 'social' | 'video' | 'form' | 'appointment';
|
||||
|
||||
export type BentoColSpan = 1 | 2;
|
||||
|
||||
@@ -58,6 +59,23 @@ export interface BrandKit {
|
||||
primaryColor: string;
|
||||
}
|
||||
|
||||
export interface MeetingInviteOptions {
|
||||
summary?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
startTime: Date | string | number;
|
||||
endTime: Date | string | number;
|
||||
organizerName?: string;
|
||||
organizerEmail?: string;
|
||||
}
|
||||
|
||||
export interface CalendarInviteBundle {
|
||||
googleUrl: string;
|
||||
outlookUrl: string;
|
||||
icsUrl: string;
|
||||
}
|
||||
|
||||
export interface CardPortalDetail {
|
||||
id: number | string;
|
||||
slug?: string;
|
||||
@@ -80,6 +98,7 @@ export interface CardPortalDetail {
|
||||
status: CardStatus;
|
||||
bentoBlocks: BentoBlockPublic[];
|
||||
brandKit?: BrandKit;
|
||||
meetingUrl?: string;
|
||||
cachedAt?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -1325,6 +1325,235 @@ onUnload(() => {
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
|
||||
/* 6. 商务预约排期卡片与抽屉样式 (Bento Grid Appointment & Bottom Sheet) */
|
||||
.appointment-card {
|
||||
background: linear-gradient(135deg, #FFFFFF 0%, #F5F9FF 100%);
|
||||
border: 1px solid rgba(0, 136, 255, 0.15);
|
||||
}
|
||||
.appointment-tag {
|
||||
background-color: var(--oc-primary-soft, #EBF5FF);
|
||||
}
|
||||
.appointment-card-body {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.appointment-info-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.appointment-card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--oc-text, #101828);
|
||||
margin-block-end: 4px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.appointment-card-desc {
|
||||
font-size: 13px;
|
||||
color: var(--oc-text-2, #64748B);
|
||||
line-height: 1.45;
|
||||
margin-block-end: 8px;
|
||||
}
|
||||
.appointment-tz-badge {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 8px;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid var(--oc-border, #EBF1F7);
|
||||
border-radius: 9999px;
|
||||
width: fit-content;
|
||||
}
|
||||
.appointment-tz-text {
|
||||
font-size: 11px;
|
||||
color: var(--oc-text-2, #64748B);
|
||||
font-weight: 500;
|
||||
}
|
||||
.appointment-action-pill {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
min-width: 90px;
|
||||
padding: 0 16px;
|
||||
background: var(--oc-primary-gradient, linear-gradient(135deg, #00A3FF 0%, #0B63E5 100%));
|
||||
border-radius: var(--oc-radius-pill, 9999px);
|
||||
border-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 136, 255, 0.2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.appointment-action-pill:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.appointment-action-text {
|
||||
color: #FFFFFF;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 预约底部抽屉 */
|
||||
.appointment-sheet-mask {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(16, 24, 40, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.appointment-sheet {
|
||||
background-color: #FFFFFF;
|
||||
border-top-left-radius: 24px;
|
||||
border-top-right-radius: 24px;
|
||||
padding: 12px 20px calc(24px + env(safe-area-inset-bottom, 0px));
|
||||
box-shadow: var(--oc-shadow-float, 0 12px 36px rgba(16, 24, 40, 0.08));
|
||||
}
|
||||
.sheet-handle {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
background-color: #D0D5DD;
|
||||
border-radius: 2px;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
.sheet-header {
|
||||
margin-block-end: 16px;
|
||||
}
|
||||
.sheet-title-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-block-end: 4px;
|
||||
}
|
||||
.sheet-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--oc-text, #101828);
|
||||
}
|
||||
.sheet-close {
|
||||
font-size: 18px;
|
||||
color: #98A2B3;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sheet-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--oc-text-2, #64748B);
|
||||
}
|
||||
.sheet-meeting-details {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background-color: var(--oc-bg, #F5F8FC);
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
margin-block-end: 16px;
|
||||
}
|
||||
.sheet-detail-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sheet-detail-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #98A2B3;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.sheet-detail-val {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--oc-text, #101828);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.calendar-channels-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.calendar-channel-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
min-height: 44px;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid var(--oc-border, #EBF1F7);
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
.calendar-channel-item:active {
|
||||
background-color: #F8FAFC;
|
||||
border-color: #B2D7FF;
|
||||
}
|
||||
.channel-info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.channel-icon-badge {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.google-badge {
|
||||
background-color: #FEF3F2;
|
||||
}
|
||||
.outlook-badge {
|
||||
background-color: #EFF8FF;
|
||||
}
|
||||
.ics-badge {
|
||||
background-color: #ECFDF3;
|
||||
}
|
||||
.channel-icon-text {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.google-badge .channel-icon-text {
|
||||
color: #D92D20;
|
||||
}
|
||||
.outlook-badge .channel-icon-text {
|
||||
color: #026AA2;
|
||||
}
|
||||
.ics-badge .channel-icon-text {
|
||||
color: #027A48;
|
||||
}
|
||||
.channel-texts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.channel-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--oc-text, #101828);
|
||||
}
|
||||
.channel-sub {
|
||||
font-size: 11px;
|
||||
color: var(--oc-text-2, #64748B);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.channel-action-arrow {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #98A2B3;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation: none !important;
|
||||
|
||||
@@ -435,6 +435,31 @@ export function resolvePortalCollectText(
|
||||
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';
|
||||
|
||||
/**
|
||||
|
||||
131
mpuniapp/tests/appointment.test.ts
普通文件
131
mpuniapp/tests/appointment.test.ts
普通文件
@@ -0,0 +1,131 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const MP_ROOT = path.resolve('m:/2026项目/CRMmp/mpuniapp');
|
||||
const calendarUtsPath = path.join(MP_ROOT, 'common/calendar.uts');
|
||||
const typesUtsPath = path.join(MP_ROOT, 'common/types.uts');
|
||||
const localeUtsPath = path.join(MP_ROOT, 'common/locale.uts');
|
||||
const portalIndexUvuePath = path.join(MP_ROOT, 'pages/portal/index.uvue');
|
||||
const usePortalUtsPath = path.join(MP_ROOT, 'pages/portal/usePortal.uts');
|
||||
|
||||
test('1. common/calendar.uts 文件与核心导出函数契约检查', () => {
|
||||
assert.ok(fs.existsSync(calendarUtsPath), '必须存在 common/calendar.uts');
|
||||
const content = fs.readFileSync(calendarUtsPath, 'utf-8');
|
||||
assert.ok(content.includes('export function generateGoogleCalendarUrl'), '必须导出 generateGoogleCalendarUrl');
|
||||
assert.ok(content.includes('export function generateOutlookCalendarUrl'), '必须导出 generateOutlookCalendarUrl');
|
||||
assert.ok(content.includes('export function getMeetingIcsDownloadUrl'), '必须导出 getMeetingIcsDownloadUrl');
|
||||
assert.ok(content.includes('export function buildCalendarInviteBundle'), '必须导出 buildCalendarInviteBundle');
|
||||
});
|
||||
|
||||
test('2. common/types.uts 中 BentoBlockType 与预约配置契约对齐', () => {
|
||||
assert.ok(fs.existsSync(typesUtsPath), '必须存在 common/types.uts');
|
||||
const content = fs.readFileSync(typesUtsPath, 'utf-8');
|
||||
assert.ok(content.includes("'appointment'"), "BentoBlockType 必须包含 'appointment'");
|
||||
assert.ok(content.includes('meetingUrl?: string'), 'CardPortalDetail / CardProfile 必须包含 meetingUrl 属性');
|
||||
assert.ok(content.includes('MeetingInviteOptions'), '必须导出 MeetingInviteOptions 接口');
|
||||
});
|
||||
|
||||
test('3. common/locale.uts 必须覆盖 7 种国际主流语言的商务预约词条', () => {
|
||||
assert.ok(fs.existsSync(localeUtsPath), '必须存在 common/locale.uts');
|
||||
const content = fs.readFileSync(localeUtsPath, 'utf-8');
|
||||
|
||||
const requiredKeys = [
|
||||
'portal.appointment',
|
||||
'portal.appointment_desc',
|
||||
'portal.book_now',
|
||||
'portal.google_cal',
|
||||
'portal.outlook_cal',
|
||||
'portal.ics_download',
|
||||
'portal.copied_meeting_link',
|
||||
];
|
||||
|
||||
for (const key of requiredKeys) {
|
||||
assert.ok(content.includes(`'${key}'`), `必须在 OmniMessageKey 中定义 ${key}`);
|
||||
}
|
||||
|
||||
const locales = ['zh-CN', 'en-US', 'ar-SA', 'es-ES', 'de-DE', 'fr-FR', 'ja-JP'];
|
||||
for (const loc of locales) {
|
||||
assert.ok(content.includes(`'${loc}':`), `必须配置语言 ${loc}`);
|
||||
assert.ok(content.includes("'portal.appointment':"), `${loc} 必须包含 portal.appointment`);
|
||||
assert.ok(content.includes("'portal.book_now':"), `${loc} 必须包含 portal.book_now`);
|
||||
}
|
||||
});
|
||||
|
||||
test('4. pages/portal/index.uvue 中集成商务日程预约卡片与底部抽屉', () => {
|
||||
assert.ok(fs.existsSync(portalIndexUvuePath), '必须存在 index.uvue');
|
||||
const content = fs.readFileSync(portalIndexUvuePath, 'utf-8');
|
||||
|
||||
// 必须包含预约卡片与唤起方法
|
||||
assert.ok(content.includes('appointment-card') || content.includes('appointment-box'), '必须包含预约卡片组件/元素');
|
||||
assert.ok(content.includes('openAppointmentSheet'), '必须包含打开预约抽屉的方法 openAppointmentSheet');
|
||||
assert.ok(content.includes('appointment-sheet') || content.includes('appointment-drawer'), '必须包含预约底部抽屉');
|
||||
|
||||
// 抽屉内必须提供三种日历同步渠道
|
||||
assert.ok(content.includes('handleCalendarSync'), '必须包含日历同步处理 handleCalendarSync');
|
||||
assert.ok(content.includes("'google'"), '必须支持 Google Calendar');
|
||||
assert.ok(content.includes("'outlook'"), '必须支持 Outlook Calendar');
|
||||
assert.ok(content.includes("'ics'"), '必须支持 ICS 日历文件');
|
||||
|
||||
// 零 Emoji 审查(严禁在代码中写死非业务 emoji 图标)
|
||||
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
|
||||
assert.equal(emojiRegex.test(content), false, 'index.uvue 严禁含有装饰性 Emoji');
|
||||
});
|
||||
|
||||
test('5. pages/portal/usePortal.uts 中必须上报 appointment 雷达轨迹', () => {
|
||||
assert.ok(fs.existsSync(usePortalUtsPath), '必须存在 usePortal.uts');
|
||||
const content = fs.readFileSync(usePortalUtsPath, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes("touchType: 'appointment'") || content.includes('trackAppointmentAction'),
|
||||
'预约动作必须向雷达上报 touchType appointment 推进商机'
|
||||
);
|
||||
});
|
||||
|
||||
test('6. 日历链接生成与时间戳算法规范断言', () => {
|
||||
const content = fs.readFileSync(calendarUtsPath, 'utf-8');
|
||||
|
||||
// Google Calendar 规范检查
|
||||
assert.ok(
|
||||
content.includes('calendar.google.com/calendar/render'),
|
||||
'Google Calendar 必须指向官方 render 终端'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('action=TEMPLATE'),
|
||||
'Google Calendar 必须使用 TEMPLATE 动作'
|
||||
);
|
||||
|
||||
// Outlook 规范检查
|
||||
assert.ok(
|
||||
content.includes('outlook.live.com/calendar/0/deeplink/compose'),
|
||||
'Outlook Calendar 必须使用官方 compose 深度链接'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('rru=addevent'),
|
||||
'Outlook Calendar 必须指定 rru=addevent'
|
||||
);
|
||||
|
||||
// 后端 RFC 5545 ICS 接口规范检查
|
||||
assert.ok(
|
||||
content.includes('/open/card/portal/meeting-ics'),
|
||||
'ICS 下载必须复用后端 /open/card/portal/meeting-ics 接口'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('summary='),
|
||||
'ICS 请求参数必须包含 summary'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('startTime='),
|
||||
'ICS 请求参数必须包含 startTime'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('endTime='),
|
||||
'ICS 请求参数必须包含 endTime'
|
||||
);
|
||||
|
||||
// UTC RFC 5545 时间戳对齐
|
||||
assert.ok(
|
||||
content.includes('getUTCFullYear') && content.includes('getUTCMonth'),
|
||||
'时间戳格式化必须采用 UTC 时区计算避免跨国时区偏差'
|
||||
);
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户