feat(mpuniapp): 完善微官网访客在线提交商务预约与需求功能
这个提交包含在:
@@ -77,7 +77,22 @@ export type OmniMessageKey =
|
||||
| 'portal.google_cal'
|
||||
| 'portal.outlook_cal'
|
||||
| 'portal.ics_download'
|
||||
| 'portal.copied_meeting_link';
|
||||
| 'portal.copied_meeting_link'
|
||||
| 'portal.appointment_form_title'
|
||||
| 'portal.appointment_form_desc'
|
||||
| 'portal.appointment_name'
|
||||
| 'portal.appointment_contact'
|
||||
| 'portal.appointment_company'
|
||||
| 'portal.appointment_time'
|
||||
| 'portal.appointment_time_video'
|
||||
| 'portal.appointment_time_offline'
|
||||
| 'portal.appointment_time_call'
|
||||
| 'portal.appointment_notes'
|
||||
| 'portal.appointment_notes_placeholder'
|
||||
| 'portal.appointment_submit'
|
||||
| 'portal.appointment_submitting'
|
||||
| 'portal.appointment_success_tip'
|
||||
| 'portal.appointment_fill_required';
|
||||
|
||||
export interface OmniLocaleConfig {
|
||||
code: OmniLocale;
|
||||
@@ -145,11 +160,26 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.collected': '已收入大厅',
|
||||
'portal.appointment': '商务预约',
|
||||
'portal.appointment_desc': '预约 30 分钟跨国视频会谈或展会现场洽谈',
|
||||
'portal.book_now': '立即排期',
|
||||
'portal.book_now': '立即预约',
|
||||
'portal.google_cal': 'Google 日历',
|
||||
'portal.outlook_cal': 'Outlook 日历',
|
||||
'portal.ics_download': '下载日历文件 (ICS)',
|
||||
'portal.copied_meeting_link': '已复制预约日历链接',
|
||||
'portal.appointment_form_title': '预约商务洽谈',
|
||||
'portal.appointment_form_desc': '提交您的会谈意向与需求,将即时通知名片主',
|
||||
'portal.appointment_name': '您的姓名',
|
||||
'portal.appointment_contact': '手机 / WhatsApp / 邮箱',
|
||||
'portal.appointment_company': '所属公司 (选填)',
|
||||
'portal.appointment_time': '期望会谈形式 / 时间',
|
||||
'portal.appointment_time_video': '线上视频 (30分钟)',
|
||||
'portal.appointment_time_offline': '展会现场洽谈',
|
||||
'portal.appointment_time_call': '电话快速沟通',
|
||||
'portal.appointment_notes': '合作需求 / 洽谈议题',
|
||||
'portal.appointment_notes_placeholder': '请简要说明您的业务需求或采购意向...',
|
||||
'portal.appointment_submit': '提交预约需求',
|
||||
'portal.appointment_submitting': '提交中...',
|
||||
'portal.appointment_success_tip': '预约已提交!名片主已收到您的需求。',
|
||||
'portal.appointment_fill_required': '请填写姓名和有效联系方式',
|
||||
},
|
||||
},
|
||||
'en-US': {
|
||||
@@ -215,6 +245,21 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.outlook_cal': 'Outlook Calendar',
|
||||
'portal.ics_download': 'Download ICS File',
|
||||
'portal.copied_meeting_link': 'Calendar link copied',
|
||||
'portal.appointment_form_title': 'Schedule Business Meeting',
|
||||
'portal.appointment_form_desc': 'Submit your meeting intent & requirements to the card owner',
|
||||
'portal.appointment_name': 'Your Name',
|
||||
'portal.appointment_contact': 'Phone / WhatsApp / Email',
|
||||
'portal.appointment_company': 'Company (Optional)',
|
||||
'portal.appointment_time': 'Preferred Format / Time',
|
||||
'portal.appointment_time_video': 'Online Video (30 min)',
|
||||
'portal.appointment_time_offline': 'In-Person Meeting',
|
||||
'portal.appointment_time_call': 'Quick Phone Call',
|
||||
'portal.appointment_notes': 'Topics & Requirements',
|
||||
'portal.appointment_notes_placeholder': 'Briefly describe your project or partnership goals...',
|
||||
'portal.appointment_submit': 'Submit Appointment',
|
||||
'portal.appointment_submitting': 'Submitting...',
|
||||
'portal.appointment_success_tip': 'Appointment submitted! The card owner will contact you shortly.',
|
||||
'portal.appointment_fill_required': 'Please provide your name and contact info',
|
||||
},
|
||||
},
|
||||
'ar-SA': {
|
||||
@@ -275,11 +320,26 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.collected': 'تم الحفظ في القاعة',
|
||||
'portal.appointment': 'حجز موعد',
|
||||
'portal.appointment_desc': 'احجز اجتماع فيديو عالمي لمدة 30 دقيقة أو محادثة شخصية',
|
||||
'portal.book_now': 'احجز الآن',
|
||||
'portal.book_now': 'احجز موعداً',
|
||||
'portal.google_cal': 'تقويم Google',
|
||||
'portal.outlook_cal': 'تقويم Outlook',
|
||||
'portal.ics_download': 'تنزيل ملف التقويم (ICS)',
|
||||
'portal.copied_meeting_link': 'تم نسخ رابط التقويم',
|
||||
'portal.appointment_form_title': 'حجز موعد عمل',
|
||||
'portal.appointment_form_desc': 'أرسل رغبتك ومتطلباتك إلى صاحب البطاقة مباشرة',
|
||||
'portal.appointment_name': 'اسمك الكريم',
|
||||
'portal.appointment_contact': 'رقم الهاتف / واتساب / البريد',
|
||||
'portal.appointment_company': 'اسم الشركة (اختياري)',
|
||||
'portal.appointment_time': 'طريقة / وقت الاجتماع المفضل',
|
||||
'portal.appointment_time_video': 'فيديو عبر الإنترنت (30 دقيقة)',
|
||||
'portal.appointment_time_offline': 'لقاء شخصي في المعرض',
|
||||
'portal.appointment_time_call': 'مكالمة هاتفية سريعة',
|
||||
'portal.appointment_notes': 'المتطلبات وموضوع النقاش',
|
||||
'portal.appointment_notes_placeholder': 'صف بإيجاز احتياجاتك أو أهداف التعاون...',
|
||||
'portal.appointment_submit': 'إرسال طلب الموعد',
|
||||
'portal.appointment_submitting': 'جاري الإرسال...',
|
||||
'portal.appointment_success_tip': 'تم إرسال طلبك بنجاح! سيتواصل معك صاحب البطاقة قريباً.',
|
||||
'portal.appointment_fill_required': 'يرجى إدخال الاسم ومعلومات التواصل',
|
||||
},
|
||||
},
|
||||
'es-ES': {
|
||||
@@ -340,11 +400,26 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'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.book_now': 'Agendar Cita',
|
||||
'portal.google_cal': 'Google Calendar',
|
||||
'portal.outlook_cal': 'Outlook Calendar',
|
||||
'portal.ics_download': 'Descargar Archivo ICS',
|
||||
'portal.copied_meeting_link': 'Enlace de calendario copiado',
|
||||
'portal.appointment_form_title': 'Agendar Reunión de Negocios',
|
||||
'portal.appointment_form_desc': 'Envía tus requisitos y propuesta directamente al titular de la tarjeta',
|
||||
'portal.appointment_name': 'Tu Nombre',
|
||||
'portal.appointment_contact': 'Teléfono / WhatsApp / Correo',
|
||||
'portal.appointment_company': 'Empresa (Opcional)',
|
||||
'portal.appointment_time': 'Formato / Horario Preferido',
|
||||
'portal.appointment_time_video': 'Videollamada (30 min)',
|
||||
'portal.appointment_time_offline': 'Reunión Presencial',
|
||||
'portal.appointment_time_call': 'Llamada Rápida',
|
||||
'portal.appointment_notes': 'Requisitos y Temas',
|
||||
'portal.appointment_notes_placeholder': 'Describe brevemente tus necesidades o propuesta...',
|
||||
'portal.appointment_submit': 'Enviar Solicitud',
|
||||
'portal.appointment_submitting': 'Enviando...',
|
||||
'portal.appointment_success_tip': '¡Solicitud enviada! El titular se pondrá en contacto pronto.',
|
||||
'portal.appointment_fill_required': 'Por favor indica tu nombre y contacto',
|
||||
},
|
||||
},
|
||||
'de-DE': {
|
||||
@@ -405,11 +480,26 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'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.book_now': 'Termin anfragen',
|
||||
'portal.google_cal': 'Google Kalender',
|
||||
'portal.outlook_cal': 'Outlook Kalender',
|
||||
'portal.ics_download': 'ICS-Datei herunterladen',
|
||||
'portal.copied_meeting_link': 'Kalenderlink kopiert',
|
||||
'portal.appointment_form_title': 'Geschäftstermin anfragen',
|
||||
'portal.appointment_form_desc': 'Senden Sie Ihre Anforderungen direkt an den Karteninhaber',
|
||||
'portal.appointment_name': 'Ihr Name',
|
||||
'portal.appointment_contact': 'Telefon / WhatsApp / E-Mail',
|
||||
'portal.appointment_company': 'Unternehmen (optional)',
|
||||
'portal.appointment_time': 'Bevorzugtes Format / Zeit',
|
||||
'portal.appointment_time_video': 'Online-Meeting (30 Min.)',
|
||||
'portal.appointment_time_offline': 'Persönliches Treffen',
|
||||
'portal.appointment_time_call': 'Kurzer Telefonanruf',
|
||||
'portal.appointment_notes': 'Themen & Anforderungen',
|
||||
'portal.appointment_notes_placeholder': 'Beschreiben Sie kurz Ihr Vorhaben oder Ihre Fragen...',
|
||||
'portal.appointment_submit': 'Termin anfragen',
|
||||
'portal.appointment_submitting': 'Wird gesendet...',
|
||||
'portal.appointment_success_tip': 'Anfrage gesendet! Der Karteninhaber meldet sich in Kürze.',
|
||||
'portal.appointment_fill_required': 'Bitte Namen und Kontaktdaten angeben',
|
||||
},
|
||||
},
|
||||
'fr-FR': {
|
||||
@@ -470,11 +560,26 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'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.book_now': 'Prendre RDV',
|
||||
'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é',
|
||||
'portal.appointment_form_title': 'Demande de Rendez-vous',
|
||||
'portal.appointment_form_desc': 'Transmettez vos besoins directement au titulaire de la carte',
|
||||
'portal.appointment_name': 'Votre Nom',
|
||||
'portal.appointment_contact': 'Téléphone / WhatsApp / E-mail',
|
||||
'portal.appointment_company': 'Société (Facultatif)',
|
||||
'portal.appointment_time': 'Format / Créneau Souhaité',
|
||||
'portal.appointment_time_video': 'Visio en ligne (30 min)',
|
||||
'portal.appointment_time_offline': 'Rencontre en personne',
|
||||
'portal.appointment_time_call': 'Appel rapide',
|
||||
'portal.appointment_notes': 'Besoins & Sujets',
|
||||
'portal.appointment_notes_placeholder': 'Décrivez brièvement votre projet ou vos attentes...',
|
||||
'portal.appointment_submit': 'Envoyer la demande',
|
||||
'portal.appointment_submitting': 'Envoi en cours...',
|
||||
'portal.appointment_success_tip': 'Demande envoyée ! Le titulaire vous contactera sous peu.',
|
||||
'portal.appointment_fill_required': 'Veuillez renseigner votre nom et vos coordonnées',
|
||||
},
|
||||
},
|
||||
'ja-JP': {
|
||||
@@ -534,12 +639,27 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.collect': 'ホールに保存',
|
||||
'portal.collected': 'ホールに保存しました',
|
||||
'portal.appointment': '日程を予約する',
|
||||
'portal.appointment_desc': '30分間のオンライン面談または商談を予約',
|
||||
'portal.book_now': '今すぐ予約',
|
||||
'portal.appointment_desc': '30分間のオンライン面談または商谈を予約',
|
||||
'portal.book_now': '予約・相談する',
|
||||
'portal.google_cal': 'Google カレンダー',
|
||||
'portal.outlook_cal': 'Outlook カレンダー',
|
||||
'portal.ics_download': 'ICSファイルをダウンロード',
|
||||
'portal.copied_meeting_link': 'カレンダーのリンクをコピーしました',
|
||||
'portal.appointment_form_title': '商談・面談のご予約',
|
||||
'portal.appointment_form_desc': 'ご相談内容やご要望を名刺所有者へ直接送信します',
|
||||
'portal.appointment_name': 'お名前',
|
||||
'portal.appointment_contact': '電話番号 / WhatsApp / メール',
|
||||
'portal.appointment_company': '会社名・組織名(任意)',
|
||||
'portal.appointment_time': 'ご希望の面談形式・時間',
|
||||
'portal.appointment_time_video': 'オンライン面談(30分)',
|
||||
'portal.appointment_time_offline': '対面・展示会での商談',
|
||||
'portal.appointment_time_call': 'お電話でのクイック相談',
|
||||
'portal.appointment_notes': 'ご相談内容・ご要望',
|
||||
'portal.appointment_notes_placeholder': '事業内容やご興味のある点をご記入ください...',
|
||||
'portal.appointment_submit': '予約・相談を送信',
|
||||
'portal.appointment_submitting': '送信中...',
|
||||
'portal.appointment_success_tip': 'ご予約を送信しました。担当者より追ってご連絡いたします。',
|
||||
'portal.appointment_fill_required': 'お名前とご連絡先を入力してください',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -330,7 +330,6 @@
|
||||
import {
|
||||
BentoBlockPublic,
|
||||
CardProfile,
|
||||
MeetingInviteOptions,
|
||||
} from '../../common/types.uts';
|
||||
import {
|
||||
createPortalState,
|
||||
@@ -351,13 +350,8 @@ import {
|
||||
trackAppointmentAction,
|
||||
PortalQuickAction,
|
||||
} from './usePortal.uts';
|
||||
import {
|
||||
generateGoogleCalendarUrl,
|
||||
generateOutlookCalendarUrl,
|
||||
getMeetingIcsDownloadUrl,
|
||||
} from '../../common/calendar.uts';
|
||||
import { saveToContacts } from '../../common/vcard-export.uts';
|
||||
import { collectHallContact } from '../../common/api.uts';
|
||||
import { collectHallContact, submitLead } from '../../common/api.uts';
|
||||
import { getAuthToken } from '../../common/request.uts';
|
||||
import { setPendingCollectCard } from '../owner/hall/useHall.uts';
|
||||
import {
|
||||
@@ -641,82 +635,100 @@ function openAiChat(): void {
|
||||
});
|
||||
}
|
||||
|
||||
// 商务日程预约相关逻辑
|
||||
// 商务日程预约与需求提报相关逻辑
|
||||
type AppointmentFormState = {
|
||||
name: string;
|
||||
contact: string;
|
||||
company: string;
|
||||
preferredTime: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
const isAppointmentSheetOpen = ref(false);
|
||||
const appointmentFormSubmitting = ref(false);
|
||||
const appointmentSubmitted = ref(false);
|
||||
|
||||
const appointmentForm = reactive<AppointmentFormState>({
|
||||
name: '',
|
||||
contact: '',
|
||||
company: '',
|
||||
preferredTime: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
const appointmentTimeOptions = computed((): Array<{ key: string; label: string }> => [
|
||||
{ key: 'video', label: textOf('portal.appointment_time_video') },
|
||||
{ key: 'offline', label: textOf('portal.appointment_time_offline') },
|
||||
{ key: 'call', label: textOf('portal.appointment_time_call') },
|
||||
]);
|
||||
|
||||
function selectPreferredTime(val: string): void {
|
||||
appointmentForm.preferredTime = val;
|
||||
}
|
||||
|
||||
function openAppointmentSheet(): void {
|
||||
if (!state.card) return;
|
||||
if (!appointmentForm.preferredTime && appointmentTimeOptions.value.length > 0) {
|
||||
appointmentForm.preferredTime = appointmentTimeOptions.value[0].label;
|
||||
}
|
||||
isAppointmentSheetOpen.value = true;
|
||||
trackAppointmentAction(state);
|
||||
}
|
||||
|
||||
function closeAppointmentSheet(): void {
|
||||
isAppointmentSheetOpen.value = false;
|
||||
if (appointmentSubmitted.value) {
|
||||
appointmentForm.name = '';
|
||||
appointmentForm.contact = '';
|
||||
appointmentForm.company = '';
|
||||
appointmentForm.notes = '';
|
||||
appointmentSubmitted.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildCurrentMeetingOptions(): MeetingInviteOptions {
|
||||
const card = state.card!;
|
||||
const now = new Date();
|
||||
const startTime = new Date(now.getTime() + 24 * 3600 * 1000);
|
||||
const endTime = new Date(startTime.getTime() + 30 * 60 * 1000);
|
||||
async function submitAppointmentForm(): Promise<void> {
|
||||
if (appointmentFormSubmitting.value) return;
|
||||
if (!state.card || state.card == null) return;
|
||||
|
||||
return {
|
||||
summary: `${card.name} · ${textOf('portal.appointment')}`,
|
||||
title: `${card.name} · ${textOf('portal.appointment')}`,
|
||||
description: `与 ${card.name} (${card.company || ''} ${card.title || ''}) 的商务会谈。\n名片: ${card.slug || ''}`,
|
||||
location: card.company || '在线商务会议 (Google Meet / Zoom)',
|
||||
startTime,
|
||||
endTime,
|
||||
organizerName: card.name,
|
||||
organizerEmail: card.email || 'meeting@omnicard.ai',
|
||||
};
|
||||
}
|
||||
|
||||
function handleCalendarSync(channel: 'google' | 'outlook' | 'ics'): void {
|
||||
if (!state.card) return;
|
||||
const options = buildCurrentMeetingOptions();
|
||||
trackAppointmentAction(state);
|
||||
|
||||
if (channel === 'google') {
|
||||
const url = generateGoogleCalendarUrl(options);
|
||||
uni.setClipboardData({
|
||||
data: url,
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: textOf('portal.copied_meeting_link'),
|
||||
icon: 'none',
|
||||
});
|
||||
},
|
||||
const name = appointmentForm.name.trim();
|
||||
const contact = appointmentForm.contact.trim();
|
||||
if (!name || !contact) {
|
||||
uni.showToast({
|
||||
title: textOf('portal.appointment_fill_required'),
|
||||
icon: 'none',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (channel === 'outlook') {
|
||||
const url = generateOutlookCalendarUrl(options);
|
||||
uni.setClipboardData({
|
||||
data: url,
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: textOf('portal.copied_meeting_link'),
|
||||
icon: 'none',
|
||||
});
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
appointmentFormSubmitting.value = true;
|
||||
try {
|
||||
const isEmail = contact.includes('@');
|
||||
const fullNotes = `[商务预约] 期望形式: ${appointmentForm.preferredTime || '未指定'}${appointmentForm.notes.trim() ? `\n需求与议题: ${appointmentForm.notes.trim()}` : ''}`;
|
||||
|
||||
if (channel === 'ics') {
|
||||
const url = getMeetingIcsDownloadUrl(options);
|
||||
uni.setClipboardData({
|
||||
data: url,
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: textOf('portal.copied_meeting_link'),
|
||||
icon: 'none',
|
||||
});
|
||||
},
|
||||
await submitLead({
|
||||
cardId: state.card!.id,
|
||||
visitorId: state.visitorId,
|
||||
name: name,
|
||||
company: appointmentForm.company.trim(),
|
||||
phone: isEmail ? '' : contact,
|
||||
email: isEmail ? contact : '',
|
||||
source: 'appointment',
|
||||
notes: fullNotes,
|
||||
submittedLead: true,
|
||||
});
|
||||
return;
|
||||
|
||||
await trackAppointmentAction(state, fullNotes);
|
||||
appointmentSubmitted.value = true;
|
||||
uni.showToast({
|
||||
title: textOf('portal.appointment_success_tip'),
|
||||
icon: 'none',
|
||||
});
|
||||
} catch (_e) {
|
||||
uni.showToast({
|
||||
title: textOf('common.error'),
|
||||
icon: 'none',
|
||||
});
|
||||
} finally {
|
||||
appointmentFormSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1481,108 +1493,127 @@ onUnload(() => {
|
||||
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 {
|
||||
/* 商务预约与需求提报表单样式 */
|
||||
.appointment-form-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sheet-detail-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #98A2B3;
|
||||
letter-spacing: 0.5px;
|
||||
.sheet-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-block-end: 12px;
|
||||
}
|
||||
.sheet-detail-val {
|
||||
.sheet-field-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--oc-text, #101828);
|
||||
margin-top: 2px;
|
||||
margin-block-end: 6px;
|
||||
}
|
||||
.calendar-channels-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.calendar-channel-item {
|
||||
.sheet-time-chips {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
min-height: 44px;
|
||||
background-color: #FFFFFF;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sheet-time-chip {
|
||||
padding: 8px 14px;
|
||||
min-height: 36px;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--oc-bg, #F5F8FC);
|
||||
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;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.channel-icon-badge {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
.sheet-time-chip-active {
|
||||
background-color: var(--oc-primary-soft, #EBF5FF);
|
||||
border-color: var(--oc-primary, #0088FF);
|
||||
}
|
||||
.sheet-time-chip-text {
|
||||
font-size: 13px;
|
||||
color: var(--oc-text-2, #64748B);
|
||||
font-weight: 500;
|
||||
}
|
||||
.sheet-time-chip-text-active {
|
||||
color: var(--oc-primary, #0088FF);
|
||||
font-weight: 600;
|
||||
}
|
||||
.sheet-field-input {
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--oc-border, #EBF1F7);
|
||||
border-radius: 12px;
|
||||
padding: 0 14px;
|
||||
font-size: 16px;
|
||||
color: var(--oc-text, #101828);
|
||||
background-color: #FFFFFF;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.sheet-field-textarea {
|
||||
min-height: 72px;
|
||||
height: 72px;
|
||||
border: 1px solid var(--oc-border, #EBF1F7);
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
font-size: 16px;
|
||||
color: var(--oc-text, #101828);
|
||||
background-color: #FFFFFF;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.appointment-submit-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
margin-top: 6px;
|
||||
border-radius: 9999px;
|
||||
background: var(--oc-primary-gradient, linear-gradient(135deg, #00A3FF 0%, #0B63E5 100%));
|
||||
box-shadow: var(--oc-shadow-cta, 0 8px 20px rgba(0, 136, 255, 0.22));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.google-badge {
|
||||
background-color: #FEF3F2;
|
||||
.appointment-submit-btn-disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
.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 {
|
||||
|
||||
/* 预约成功反馈卡片 */
|
||||
.sheet-success-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px 0 12px;
|
||||
}
|
||||
.channel-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
.sheet-success-ring {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 28px;
|
||||
background-color: #ECFDF3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.sheet-success-check {
|
||||
width: 14px;
|
||||
height: 24px;
|
||||
border-bottom: 3px solid #12B76A;
|
||||
border-right: 3px solid #12B76A;
|
||||
transform: rotate(45deg);
|
||||
margin-top: -6px;
|
||||
}
|
||||
.sheet-success-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: var(--oc-text, #101828);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.channel-sub {
|
||||
font-size: 11px;
|
||||
.sheet-success-sub {
|
||||
font-size: 13px;
|
||||
color: var(--oc-text-2, #64748B);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.channel-action-arrow {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #98A2B3;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -4,41 +4,31 @@ 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 种国际主流语言的商务预约词条', () => {
|
||||
test('1. 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',
|
||||
'portal.appointment_form_title',
|
||||
'portal.appointment_form_desc',
|
||||
'portal.appointment_name',
|
||||
'portal.appointment_contact',
|
||||
'portal.appointment_company',
|
||||
'portal.appointment_time',
|
||||
'portal.appointment_notes',
|
||||
'portal.appointment_notes_placeholder',
|
||||
'portal.appointment_submit',
|
||||
'portal.appointment_submitting',
|
||||
'portal.appointment_success_tip',
|
||||
'portal.appointment_fill_required',
|
||||
];
|
||||
|
||||
for (const key of requiredKeys) {
|
||||
@@ -48,84 +38,51 @@ test('3. common/locale.uts 必须覆盖 7 种国际主流语言的商务预约
|
||||
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`);
|
||||
assert.ok(content.includes("'portal.appointment_form_title':"), `${loc} 必须包含 portal.appointment_form_title`);
|
||||
assert.ok(content.includes("'portal.appointment_submit':"), `${loc} 必须包含 portal.appointment_submit`);
|
||||
}
|
||||
});
|
||||
|
||||
test('4. pages/portal/index.uvue 中集成商务日程预约卡片与底部抽屉', () => {
|
||||
test('2. pages/portal/index.uvue 包含 Bento 预约卡片并唤起需求提报抽屉', () => {
|
||||
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('appointment-card'), '必须包含 Bento 预约卡片');
|
||||
assert.ok(content.includes('openAppointmentSheet'), '必须包含打开预约抽屉的方法 openAppointmentSheet');
|
||||
assert.ok(content.includes('appointment-sheet') || content.includes('appointment-drawer'), '必须包含预约底部抽屉');
|
||||
assert.ok(content.includes('appointment-sheet'), '必须包含预约需求抽屉');
|
||||
});
|
||||
|
||||
// 抽屉内必须提供三种日历同步渠道
|
||||
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 日历文件');
|
||||
test('3. 抽屉内必须包含完整的姓名、联系方式、公司与业务需求提报字段', () => {
|
||||
const content = fs.readFileSync(portalIndexUvuePath, 'utf-8');
|
||||
|
||||
// 表单字段输入项
|
||||
assert.ok(content.includes('appointmentForm.name'), '必须绑定客户姓名输入项');
|
||||
assert.ok(content.includes('appointmentForm.contact'), '必须绑定联系方式输入项');
|
||||
assert.ok(content.includes('appointmentForm.company'), '必须绑定公司输入项');
|
||||
assert.ok(content.includes('appointmentForm.notes'), '必须绑定需求与议题说明 textarea');
|
||||
assert.ok(content.includes('submitAppointmentForm'), '必须包含提交预约需求方法 submitAppointmentForm');
|
||||
assert.ok(content.includes('appointmentFormSubmitting'), '必须包含防重复提交状态控制');
|
||||
});
|
||||
|
||||
test('4. 提交预约信息必须直接调用 submitLead 接口将需求提交给名片主', () => {
|
||||
const content = fs.readFileSync(portalIndexUvuePath, 'utf-8');
|
||||
|
||||
assert.ok(content.includes('submitLead(') || content.includes('submitLead'), '必须调用 submitLead 接口提交预约线索');
|
||||
assert.ok(content.includes("touchType: 'appointment'") || content.includes('trackAppointmentAction'), '提交后必须上报 appointment 雷达轨迹');
|
||||
assert.ok(content.includes('notes:'), '提交数据必须封装预约时间与需求 notes');
|
||||
});
|
||||
|
||||
test('5. 移动端 UI 规范审查:零非业务 Emoji、输入框防缩放与按钮触控区', () => {
|
||||
const content = fs.readFileSync(portalIndexUvuePath, 'utf-8');
|
||||
|
||||
// 零 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 时区计算避免跨国时区偏差'
|
||||
content.includes('appointment-submit-btn'),
|
||||
'预约提交按钮必须具备独立规范样式类'
|
||||
);
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户