refactor(portal): 优化主CTA视觉形态为居中轻量悬浮胶囊,告别厚重通栏横条
这个提交包含在:
@@ -144,7 +144,31 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 3. Bento Grid 网格展厅 -->
|
<!-- 3. Bento Grid 网格展厅 -->
|
||||||
<view v-if="visibleBlocks.length > 0" class="bento-grid">
|
<view class="bento-grid">
|
||||||
|
<!-- 3.1 国际商务日程预约卡片 (Bento Appointment Card) -->
|
||||||
|
<view class="bento-card bento-col-2 appointment-card" @click="openAppointmentSheet">
|
||||||
|
<view class="bento-header-row">
|
||||||
|
<view class="bento-tag appointment-tag">
|
||||||
|
<text class="bento-tag-text">{{ textOf('portal.appointment') }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="bento-arrow-icon">↗</text>
|
||||||
|
</view>
|
||||||
|
<view class="appointment-card-body">
|
||||||
|
<view class="appointment-info-col">
|
||||||
|
<text class="appointment-card-title">{{ state.card!.name }} · {{ textOf('portal.appointment') }}</text>
|
||||||
|
<text class="appointment-card-desc">{{ textOf('portal.appointment_desc') }}</text>
|
||||||
|
<view v-if="state.card!.timezone" class="appointment-tz-badge">
|
||||||
|
<view class="status-dot"></view>
|
||||||
|
<text class="appointment-tz-text">{{ state.card!.timezone }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<button class="appointment-action-pill" @click.stop="openAppointmentSheet">
|
||||||
|
<text class="appointment-action-text">{{ textOf('portal.book_now') }}</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 3.2 动态配置的 Bento 模块 -->
|
||||||
<view
|
<view
|
||||||
v-for="block in visibleBlocks"
|
v-for="block in visibleBlocks"
|
||||||
:key="block.id"
|
:key="block.id"
|
||||||
@@ -189,12 +213,83 @@
|
|||||||
<text class="ai-entry-text">{{ textOf('card.ai_chat') }}</text>
|
<text class="ai-entry-text">{{ textOf('card.ai_chat') }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 5. 单屏唯一定位主 CTA (主人态: 分享出示名片 / 访客态: 保存到通讯录) -->
|
<!-- 5. 单屏唯一定位主 CTA (悬浮动态胶囊:主人态分享名片 / 访客态保存到通讯录) -->
|
||||||
<view v-if="state.card != null" class="portal-cta-bar">
|
<view v-if="state.card != null" class="portal-cta-bar">
|
||||||
<button class="primary-cta-btn" @click="handlePrimaryCta">
|
<button class="primary-cta-btn" @click="handlePrimaryCta">
|
||||||
<text class="primary-cta-btn-text">{{ primaryCtaLabel }}</text>
|
<text class="primary-cta-btn-text">{{ primaryCtaLabel }}</text>
|
||||||
|
<text class="primary-cta-btn-arrow">↗</text>
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 6. 商务预约排期抽屉 (Bottom Sheet) -->
|
||||||
|
<view v-if="isAppointmentSheetOpen" class="appointment-sheet-mask" @click="closeAppointmentSheet">
|
||||||
|
<view class="appointment-sheet" :class="pageRtlClass" @click.stop>
|
||||||
|
<view class="sheet-handle"></view>
|
||||||
|
<view class="sheet-header">
|
||||||
|
<view class="sheet-title-row">
|
||||||
|
<text class="sheet-title">{{ textOf('portal.appointment') }}</text>
|
||||||
|
<text class="sheet-close" @click="closeAppointmentSheet">×</text>
|
||||||
|
</view>
|
||||||
|
<text class="sheet-subtitle">{{ state.card!.name }} · {{ state.card!.company }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="sheet-meeting-details">
|
||||||
|
<view class="sheet-detail-row">
|
||||||
|
<text class="sheet-detail-label">DURATION</text>
|
||||||
|
<text class="sheet-detail-val">30 Min</text>
|
||||||
|
</view>
|
||||||
|
<view class="sheet-detail-row">
|
||||||
|
<text class="sheet-detail-label">TIMEZONE</text>
|
||||||
|
<text class="sheet-detail-val">{{ state.card!.timezone || 'UTC+8' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 日历同步通道列表 -->
|
||||||
|
<view class="calendar-channels-list">
|
||||||
|
<!-- Google Calendar -->
|
||||||
|
<view class="calendar-channel-item" @click="handleCalendarSync('google')">
|
||||||
|
<view class="channel-info">
|
||||||
|
<view class="channel-icon-badge google-badge">
|
||||||
|
<text class="channel-icon-text">G</text>
|
||||||
|
</view>
|
||||||
|
<view class="channel-texts">
|
||||||
|
<text class="channel-name">{{ textOf('portal.google_cal') }}</text>
|
||||||
|
<text class="channel-sub">Google Calendar Web</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="channel-action-arrow">↗</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Outlook / Office 365 -->
|
||||||
|
<view class="calendar-channel-item" @click="handleCalendarSync('outlook')">
|
||||||
|
<view class="channel-info">
|
||||||
|
<view class="channel-icon-badge outlook-badge">
|
||||||
|
<text class="channel-icon-text">O</text>
|
||||||
|
</view>
|
||||||
|
<view class="channel-texts">
|
||||||
|
<text class="channel-name">{{ textOf('portal.outlook_cal') }}</text>
|
||||||
|
<text class="channel-sub">Outlook / Office 365</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="channel-action-arrow">↗</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- RFC 5545 ICS 日历文件 -->
|
||||||
|
<view class="calendar-channel-item" @click="handleCalendarSync('ics')">
|
||||||
|
<view class="channel-info">
|
||||||
|
<view class="channel-icon-badge ics-badge">
|
||||||
|
<text class="channel-icon-text">ICS</text>
|
||||||
|
</view>
|
||||||
|
<view class="channel-texts">
|
||||||
|
<text class="channel-name">{{ textOf('portal.ics_download') }}</text>
|
||||||
|
<text class="channel-sub">Apple / Mac / Windows / RFC 5545</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="channel-action-arrow">↓</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -202,6 +297,7 @@
|
|||||||
import {
|
import {
|
||||||
BentoBlockPublic,
|
BentoBlockPublic,
|
||||||
CardProfile,
|
CardProfile,
|
||||||
|
MeetingInviteOptions,
|
||||||
} from '../../common/types.uts';
|
} from '../../common/types.uts';
|
||||||
import {
|
import {
|
||||||
createPortalState,
|
createPortalState,
|
||||||
@@ -219,8 +315,14 @@ import {
|
|||||||
checkIsCardOwner,
|
checkIsCardOwner,
|
||||||
resolvePortalCtaText,
|
resolvePortalCtaText,
|
||||||
resolvePortalHeaderLinkText,
|
resolvePortalHeaderLinkText,
|
||||||
|
trackAppointmentAction,
|
||||||
PortalQuickAction,
|
PortalQuickAction,
|
||||||
} from './usePortal.uts';
|
} from './usePortal.uts';
|
||||||
|
import {
|
||||||
|
generateGoogleCalendarUrl,
|
||||||
|
generateOutlookCalendarUrl,
|
||||||
|
getMeetingIcsDownloadUrl,
|
||||||
|
} from '../../common/calendar.uts';
|
||||||
import { saveToContacts } from '../../common/vcard-export.uts';
|
import { saveToContacts } from '../../common/vcard-export.uts';
|
||||||
import { collectHallContact } from '../../common/api.uts';
|
import { collectHallContact } from '../../common/api.uts';
|
||||||
import { getAuthToken } from '../../common/request.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 卡片点击
|
// Bento 卡片点击
|
||||||
function handleBlockClick(block: BentoBlockPublic): void {
|
function handleBlockClick(block: BentoBlockPublic): void {
|
||||||
const blockId = block.id != null ? String(block.id) : '';
|
const blockId = block.id != null ? String(block.id) : '';
|
||||||
@@ -1053,22 +1234,24 @@ onUnload(() => {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 底部垫高 (防止被主 CTA 和 AI 入口遮挡) */
|
/* 底部垫高 (防止被居中悬浮主 CTA 和 AI 入口遮挡) */
|
||||||
.portal-bottom-spacer {
|
.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 {
|
.ai-entry-capsule {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset-inline-end: 16px;
|
inset-inline-end: 16px;
|
||||||
bottom: calc(80px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px));
|
bottom: calc(76px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px));
|
||||||
min-height: 44px;
|
height: 40px;
|
||||||
padding: 0 16px;
|
min-height: 40px;
|
||||||
background-color: var(--oc-surface, #FFFFFF);
|
padding: 0 14px;
|
||||||
border: 1px solid rgba(124, 92, 255, 0.25);
|
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);
|
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;
|
z-index: 99;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -1081,12 +1264,12 @@ onUnload(() => {
|
|||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
.ai-sparkle-dot {
|
.ai-sparkle-dot {
|
||||||
width: 8px;
|
width: 7px;
|
||||||
height: 8px;
|
height: 7px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background-color: var(--oc-ai, #7C5CFF);
|
background-color: var(--oc-ai, #7C5CFF);
|
||||||
margin-inline-end: 8px;
|
margin-inline-end: 6px;
|
||||||
box-shadow: 0 0 8px rgba(124, 92, 255, 0.6);
|
box-shadow: 0 0 6px rgba(124, 92, 255, 0.5);
|
||||||
}
|
}
|
||||||
.ai-entry-text {
|
.ai-entry-text {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -1094,30 +1277,35 @@ onUnload(() => {
|
|||||||
color: var(--oc-ai, #7C5CFF);
|
color: var(--oc-ai, #7C5CFF);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 5. 单屏唯一定位主 CTA (保存到通讯录) */
|
/* 5. 单屏唯一定位主 CTA (现代极简悬浮胶囊,透气留白,告别死板通栏横条) */
|
||||||
.portal-cta-bar {
|
.portal-cta-bar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: var(--window-bottom, 0px);
|
bottom: calc(16px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px));
|
||||||
padding: 12px 16px calc(16px + env(safe-area-inset-bottom, 0px));
|
display: flex;
|
||||||
background-color: rgba(255, 255, 255, 0.95);
|
flex-direction: row;
|
||||||
backdrop-filter: blur(12px);
|
justify-content: center;
|
||||||
border-top: 1px solid var(--oc-border, #EBF1F7);
|
align-items: center;
|
||||||
box-sizing: border-box;
|
pointer-events: none;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
.primary-cta-btn {
|
.primary-cta-btn {
|
||||||
height: 48px;
|
pointer-events: auto;
|
||||||
|
height: 44px;
|
||||||
min-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%));
|
background: var(--oc-primary-gradient, linear-gradient(135deg, #00A3FF 0%, #0B63E5 100%));
|
||||||
border-radius: var(--oc-radius-btn, 14px);
|
border-radius: var(--oc-radius-pill, 9999px);
|
||||||
box-shadow: var(--oc-shadow-cta, 0 8px 20px rgba(0, 136, 255, 0.22));
|
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;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
|
||||||
border-width: 0;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
}
|
}
|
||||||
@@ -1126,9 +1314,15 @@ onUnload(() => {
|
|||||||
}
|
}
|
||||||
.primary-cta-btn-text {
|
.primary-cta-btn-text {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -167,3 +167,25 @@ test('5. 多语言文案解析与主客态映射审查', () => {
|
|||||||
'访客态顶栏操作必须映射 card.exchange 词条'
|
'访客态顶栏操作必须映射 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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户