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;
|
||||
}
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户