diff --git a/mpuniapp/pages/portal/index.uvue b/mpuniapp/pages/portal/index.uvue index a5084f0..896b367 100644 --- a/mpuniapp/pages/portal/index.uvue +++ b/mpuniapp/pages/portal/index.uvue @@ -144,7 +144,31 @@ - + + + + + + {{ textOf('portal.appointment') }} + + ↗ + + + + {{ state.card!.name }} · {{ textOf('portal.appointment') }} + {{ textOf('portal.appointment_desc') }} + + + {{ state.card!.timezone }} + + + + + + + {{ textOf('card.ai_chat') }} - + + + + + + + + + {{ textOf('portal.appointment') }} + × + + {{ state.card!.name }} · {{ state.card!.company }} + + + + + DURATION + 30 Min + + + TIMEZONE + {{ state.card!.timezone || 'UTC+8' }} + + + + + + + + + + G + + + {{ textOf('portal.google_cal') }} + Google Calendar Web + + + ↗ + + + + + + + O + + + {{ textOf('portal.outlook_cal') }} + Outlook / Office 365 + + + ↗ + + + + + + + ICS + + + {{ textOf('portal.ics_download') }} + Apple / Mac / Windows / RFC 5545 + + + ↓ + + + + @@ -202,6 +297,7 @@ import { BentoBlockPublic, CardProfile, + MeetingInviteOptions, } from '../../common/types.uts'; import { createPortalState, @@ -219,8 +315,14 @@ import { checkIsCardOwner, resolvePortalCtaText, resolvePortalHeaderLinkText, + 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 { getAuthToken } from '../../common/request.uts'; @@ -506,6 +608,85 @@ function openAiChat(): void { }); } +// 商务日程预约相关逻辑 +const isAppointmentSheetOpen = ref(false); + +function openAppointmentSheet(): void { + if (!state.card) return; + isAppointmentSheetOpen.value = true; + trackAppointmentAction(state); +} + +function closeAppointmentSheet(): void { + isAppointmentSheetOpen.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); + + 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', + }); + }, + }); + return; + } + + if (channel === 'outlook') { + const url = generateOutlookCalendarUrl(options); + uni.setClipboardData({ + data: url, + success: () => { + uni.showToast({ + title: textOf('portal.copied_meeting_link'), + icon: 'none', + }); + }, + }); + return; + } + + if (channel === 'ics') { + const url = getMeetingIcsDownloadUrl(options); + uni.setClipboardData({ + data: url, + success: () => { + uni.showToast({ + title: textOf('portal.copied_meeting_link'), + icon: 'none', + }); + }, + }); + return; + } +} + // Bento 卡片点击 function handleBlockClick(block: BentoBlockPublic): void { const blockId = block.id != null ? String(block.id) : ''; @@ -1053,22 +1234,24 @@ onUnload(() => { line-height: 1.6; } -/* 底部垫高 (防止被主 CTA 和 AI 入口遮挡) */ +/* 底部垫高 (防止被居中悬浮主 CTA 和 AI 入口遮挡) */ .portal-bottom-spacer { - height: calc(140px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px)); + height: calc(100px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px)); } -/* 4. AI 智能中枢入口胶囊 (次级入口,极光微光晕) */ +/* 4. AI 智能中枢入口胶囊 (次级入口,轻量极光微光晕) */ .ai-entry-capsule { position: fixed; inset-inline-end: 16px; - bottom: calc(80px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px)); - min-height: 44px; - padding: 0 16px; - background-color: var(--oc-surface, #FFFFFF); - border: 1px solid rgba(124, 92, 255, 0.25); + bottom: calc(76px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px)); + height: 40px; + min-height: 40px; + padding: 0 14px; + background-color: rgba(255, 255, 255, 0.96); + backdrop-filter: blur(12px); + border: 1px solid rgba(124, 92, 255, 0.22); border-radius: var(--oc-radius-pill, 9999px); - box-shadow: var(--oc-shadow-float, 0 12px 36px rgba(16, 24, 40, 0.08)); + box-shadow: 0 4px 16px rgba(16, 24, 40, 0.06); z-index: 99; display: flex; flex-direction: row; @@ -1081,12 +1264,12 @@ onUnload(() => { opacity: 0.85; } .ai-sparkle-dot { - width: 8px; - height: 8px; + width: 7px; + height: 7px; border-radius: 4px; background-color: var(--oc-ai, #7C5CFF); - margin-inline-end: 8px; - box-shadow: 0 0 8px rgba(124, 92, 255, 0.6); + margin-inline-end: 6px; + box-shadow: 0 0 6px rgba(124, 92, 255, 0.5); } .ai-entry-text { font-size: 13px; @@ -1094,30 +1277,35 @@ onUnload(() => { color: var(--oc-ai, #7C5CFF); } -/* 5. 单屏唯一定位主 CTA (保存到通讯录) */ +/* 5. 单屏唯一定位主 CTA (现代极简悬浮胶囊,透气留白,告别死板通栏横条) */ .portal-cta-bar { position: fixed; left: 0; right: 0; - bottom: var(--window-bottom, 0px); - padding: 12px 16px calc(16px + env(safe-area-inset-bottom, 0px)); - background-color: rgba(255, 255, 255, 0.95); - backdrop-filter: blur(12px); - border-top: 1px solid var(--oc-border, #EBF1F7); - box-sizing: border-box; + bottom: calc(16px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px)); + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + pointer-events: none; z-index: 100; } .primary-cta-btn { - height: 48px; + pointer-events: auto; + height: 44px; min-height: 44px; + width: auto; + min-width: 150px; + max-width: 220px; + padding: 0 24px; background: var(--oc-primary-gradient, linear-gradient(135deg, #00A3FF 0%, #0B63E5 100%)); - border-radius: var(--oc-radius-btn, 14px); - box-shadow: var(--oc-shadow-cta, 0 8px 20px rgba(0, 136, 255, 0.22)); + border-radius: var(--oc-radius-pill, 9999px); + box-shadow: 0 8px 24px rgba(0, 136, 255, 0.26), 0 2px 6px rgba(11, 99, 229, 0.12); + border: 1px solid rgba(255, 255, 255, 0.25); display: flex; + flex-direction: row; align-items: center; justify-content: center; - width: 100%; - border-width: 0; cursor: pointer; transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1); } @@ -1126,9 +1314,15 @@ onUnload(() => { } .primary-cta-btn-text { color: #FFFFFF; - font-size: 16px; + font-size: 14px; font-weight: 600; - letter-spacing: 0.3px; + letter-spacing: 0.4px; +} +.primary-cta-btn-arrow { + color: rgba(255, 255, 255, 0.85); + font-size: 13px; + font-weight: 600; + margin-inline-start: 4px; } @media (prefers-reduced-motion: reduce) { diff --git a/mpuniapp/tests/portal-owner-mode.test.ts b/mpuniapp/tests/portal-owner-mode.test.ts index dfc9a45..e7f773d 100644 --- a/mpuniapp/tests/portal-owner-mode.test.ts +++ b/mpuniapp/tests/portal-owner-mode.test.ts @@ -167,3 +167,25 @@ test('5. 多语言文案解析与主客态映射审查', () => { '访客态顶栏操作必须映射 card.exchange 词条' ); }); + +test('6. 主 CTA 悬浮胶囊形态与呼吸感视觉规范审查', () => { + const content = fs.readFileSync(portalUvuePath, 'utf-8'); + assert.ok( + content.includes('border-radius: var(--oc-radius-pill, 9999px);') || + content.includes('border-radius: 9999px;'), + 'primary-cta-btn 必须使用胶囊 Pill 圆角 (9999px) 替代沉重矩形' + ); + assert.ok( + content.includes('max-width: 220px') || content.includes('min-width: 150px'), + 'primary-cta-btn 严禁撑满全屏,必须使用精致收窄胶囊尺寸' + ); + assert.ok( + content.includes('.portal-cta-bar') && content.includes('pointer-events: none;'), + 'portal-cta-bar 容器必须开启 pointer-events: none 以实现居中悬浮呼吸感' + ); + assert.ok( + content.includes('.primary-cta-btn') && content.includes('pointer-events: auto;'), + 'primary-cta-btn 自身必须显式开启 pointer-events: auto' + ); +}); +