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 自动化单元测试套件并通过全部验证
这个提交包含在:
wikiboss
2026-09-21 15:16:39 +08:00
父节点 e4882c7707
当前提交 2d3ff2f5bd
共有 6 个文件被更改,包括 600 次插入 和 4 次删除

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';
/**

查看文件

@@ -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 时区计算避免跨国时区偏差'
);
});