From 2d3ff2f5bd73b09171091e7c297175bf0babb3a8 Mon Sep 17 00:00:00 2001 From: wikiboss Date: Mon, 21 Sep 2026 15:16:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(mpuniapp):=20=E8=90=BD=E5=9C=B0=E5=BE=AE?= =?UTF-8?q?=E5=AE=98=E7=BD=91=E5=95=86=E5=8A=A1=E6=97=A5=E7=A8=8B=E9=A2=84?= =?UTF-8?q?=E7=BA=A6=E4=B8=8E=E6=97=A5=E5=8E=86=E6=8E=92=E6=9C=9F=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=20(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 自动化单元测试套件并通过全部验证 --- mpuniapp/common/calendar.uts | 120 +++++++++++++++ mpuniapp/common/locale.uts | 78 +++++++++- mpuniapp/common/types.uts | 21 ++- mpuniapp/pages/portal/index.uvue | 229 ++++++++++++++++++++++++++++ mpuniapp/pages/portal/usePortal.uts | 25 +++ mpuniapp/tests/appointment.test.ts | 131 ++++++++++++++++ 6 files changed, 600 insertions(+), 4 deletions(-) create mode 100644 mpuniapp/common/calendar.uts create mode 100644 mpuniapp/tests/appointment.test.ts diff --git a/mpuniapp/common/calendar.uts b/mpuniapp/common/calendar.uts new file mode 100644 index 0000000..54f62c5 --- /dev/null +++ b/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), + }; +} diff --git a/mpuniapp/common/locale.uts b/mpuniapp/common/locale.uts index dc12666..fa6a06c 100644 --- a/mpuniapp/common/locale.uts +++ b/mpuniapp/common/locale.uts @@ -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 = { '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 = { '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 = { '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 = { '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 = { '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 = { '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 = { '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()) { diff --git a/mpuniapp/common/types.uts b/mpuniapp/common/types.uts index e9db8e4..87a6e3f 100644 --- a/mpuniapp/common/types.uts +++ b/mpuniapp/common/types.uts @@ -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; } diff --git a/mpuniapp/pages/portal/index.uvue b/mpuniapp/pages/portal/index.uvue index 896b367..12e7583 100644 --- a/mpuniapp/pages/portal/index.uvue +++ b/mpuniapp/pages/portal/index.uvue @@ -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; diff --git a/mpuniapp/pages/portal/usePortal.uts b/mpuniapp/pages/portal/usePortal.uts index 03d6928..0bda534 100644 --- a/mpuniapp/pages/portal/usePortal.uts +++ b/mpuniapp/pages/portal/usePortal.uts @@ -435,6 +435,31 @@ export function resolvePortalCollectText( return getOmniText(loc, 'portal.collect'); } +/** + * 预约动作雷达轨迹上报,自动推进 CRM 商机流转 + */ +export async function trackAppointmentAction( + state: PortalState, + meetingSummary?: string +): Promise { + 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'; /** diff --git a/mpuniapp/tests/appointment.test.ts b/mpuniapp/tests/appointment.test.ts new file mode 100644 index 0000000..e9bf8ab --- /dev/null +++ b/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 时区计算避免跨国时区偏差' + ); +});