1553 行
42 KiB
Plaintext
1553 行
42 KiB
Plaintext
<template>
|
||
<view class="portal-page" :class="pageRtlClass" :style="pageTheme">
|
||
<!-- 顶部受控局部极光微光晕 (面积 < 8%) -->
|
||
<view class="ambient-aurora-glow"></view>
|
||
|
||
<!-- 顶栏:多语言与主客态操作 -->
|
||
<view class="locale-bar">
|
||
<view class="locale-selector-container">
|
||
<i18n-selector :model-value="currentLang" @change="switchLang" />
|
||
</view>
|
||
<view class="locale-exchange-wrap" @click="handleHeaderAction">
|
||
<text class="locale-exchange-link">{{ headerLinkText }}</text>
|
||
<text class="locale-exchange-arrow">↗</text>
|
||
</view>
|
||
</view>
|
||
|
||
<scroll-view class="portal-scroll" :scroll-y="true">
|
||
<!-- 骨架加载态 -->
|
||
<view v-if="state.loading" class="portal-skeleton">
|
||
<view class="skeleton-hero">
|
||
<view class="skeleton-avatar"></view>
|
||
<view class="skeleton-line line-title"></view>
|
||
<view class="skeleton-line line-subtitle"></view>
|
||
</view>
|
||
<view class="skeleton-grid">
|
||
<view class="skeleton-card"></view>
|
||
<view class="skeleton-card"></view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 异常提示态 -->
|
||
<view v-else-if="state.errorMessage" class="portal-error-card">
|
||
<view class="error-badge">
|
||
<text class="error-badge-icon">!</text>
|
||
</view>
|
||
<text class="error-title">{{ textOf('portal.unavailable') }}</text>
|
||
<text class="error-desc">{{ state.errorMessage }}</text>
|
||
<button class="retry-btn" @click="reloadPortal">
|
||
<text class="retry-btn-text">{{ textOf('portal.retry') }}</text>
|
||
</button>
|
||
</view>
|
||
|
||
<!-- 核心微官网内容区 -->
|
||
<view v-else-if="state.card != null" class="portal-content">
|
||
<!-- 品牌标识 (若配置) -->
|
||
<view v-if="state.card!.brandKit && state.card!.brandKit!.logoUrl" class="brand-bar">
|
||
<image class="brand-logo" :src="state.card!.brandKit!.logoUrl" mode="heightFix" />
|
||
</view>
|
||
|
||
<!-- 1. Hero 身份区 (Bento Hero Showcase) -->
|
||
<view class="hero-card">
|
||
<!-- 顶部头像与时区状态行 -->
|
||
<view class="hero-top-row">
|
||
<view class="hero-avatar-wrap">
|
||
<image
|
||
class="hero-avatar"
|
||
:src="state.card!.avatarUrl || defaultAvatar"
|
||
mode="aspectFill"
|
||
/>
|
||
<view class="hero-avatar-ring"></view>
|
||
</view>
|
||
<view class="hero-top-actions">
|
||
<view v-if="state.card!.timezone" class="hero-timezone-pill">
|
||
<view class="status-dot"></view>
|
||
<text class="hero-timezone-text">{{ state.card!.timezone }}</text>
|
||
</view>
|
||
<view v-if="canCollect" class="hero-collect-pill" @click="handleCollect">
|
||
<text class="hero-collect-text">{{ collectLabel }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 姓名与发音试听 -->
|
||
<view class="hero-name-row">
|
||
<text class="hero-name">{{ state.card!.name }}</text>
|
||
<audio-pronounce
|
||
v-if="cardAudioUrl !== ''"
|
||
:audio-url="cardAudioUrl"
|
||
:label="textOf('portal.pronounce')"
|
||
/>
|
||
</view>
|
||
|
||
<!-- 头衔与所属公司 -->
|
||
<text class="hero-title">{{ state.card!.title }}</text>
|
||
<view class="hero-company-row" @click="handleCompanyClick">
|
||
<text class="hero-company-name">{{ state.card!.company }}</text>
|
||
<text class="hero-company-arrow">↗</text>
|
||
</view>
|
||
|
||
<!-- 个人签名 Slogan -->
|
||
<text v-if="state.card!.slogan" class="hero-slogan">{{ state.card!.slogan }}</text>
|
||
</view>
|
||
|
||
<!-- 2. 快捷动作条 (触控区 >= 44x44,无链接不渲染) -->
|
||
<view v-if="hasAnyQuickAction" class="quick-action-bar">
|
||
<view v-if="hasQuickAction('whatsapp')" class="action-item" @click="handleAction('whatsapp')">
|
||
<view class="action-icon-box">
|
||
<text class="action-icon-text">WA</text>
|
||
</view>
|
||
<text class="action-item-label">{{ textOf('portal.whatsapp') }}</text>
|
||
</view>
|
||
|
||
<view v-if="hasQuickAction('linkedin')" class="action-item" @click="handleAction('linkedin')">
|
||
<view class="action-icon-box">
|
||
<text class="action-icon-text">IN</text>
|
||
</view>
|
||
<text class="action-item-label">{{ textOf('portal.linkedin') }}</text>
|
||
</view>
|
||
|
||
<view v-if="hasQuickAction('email')" class="action-item" @click="handleAction('email')">
|
||
<view class="action-icon-box">
|
||
<text class="action-icon-text">@</text>
|
||
</view>
|
||
<text class="action-item-label">{{ textOf('portal.email') }}</text>
|
||
</view>
|
||
|
||
<view v-if="hasQuickAction('phone')" class="action-item" @click="handleAction('phone')">
|
||
<view class="action-icon-box">
|
||
<text class="action-icon-text">TEL</text>
|
||
</view>
|
||
<text class="action-item-label">{{ textOf('portal.phone') }}</text>
|
||
</view>
|
||
|
||
<view v-if="hasQuickAction('telegram')" class="action-item" @click="handleAction('telegram')">
|
||
<view class="action-icon-box">
|
||
<text class="action-icon-text">TG</text>
|
||
</view>
|
||
<text class="action-item-label">{{ textOf('portal.telegram') }}</text>
|
||
</view>
|
||
|
||
<view v-if="hasQuickAction('twitter')" class="action-item" @click="handleAction('twitter')">
|
||
<view class="action-icon-box">
|
||
<text class="action-icon-text">X</text>
|
||
</view>
|
||
<text class="action-item-label">{{ textOf('portal.twitter') }}</text>
|
||
</view>
|
||
|
||
<view v-if="hasQuickAction('wechat')" class="action-item" @click="handleAction('wechat')">
|
||
<view class="action-icon-box">
|
||
<text class="action-icon-text">WX</text>
|
||
</view>
|
||
<text class="action-item-label">{{ textOf('portal.wechat') }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 3. 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
|
||
v-for="block in visibleBlocks"
|
||
:key="block.id"
|
||
class="bento-card"
|
||
:class="getColClass(block.colSpan)"
|
||
@click="handleBlockClick(block)"
|
||
>
|
||
<!-- 头部:卡片类型标签与探索指示器 -->
|
||
<view class="bento-header-row">
|
||
<view class="bento-tag">
|
||
<text class="bento-tag-text">{{ getBlockTypeLabel(block.type) }}</text>
|
||
</view>
|
||
<text class="bento-arrow-icon">↗</text>
|
||
</view>
|
||
|
||
<!-- 卡片多媒体封面 (若有) -->
|
||
<image
|
||
v-if="block.mediaUrl"
|
||
class="bento-cover"
|
||
:src="block.mediaUrl"
|
||
mode="aspectFill"
|
||
/>
|
||
|
||
<!-- 卡片标题与内容 -->
|
||
<text v-if="block.title" class="bento-title">{{ block.title }}</text>
|
||
<text v-if="block.content" class="bento-content">{{ block.content }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 页面底部安全垫高,避免被常驻主 CTA 与 AI 入口遮挡 -->
|
||
<view class="portal-bottom-spacer"></view>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<!-- 4. AI 智能顾问微光悬浮入口 (Secondary CTA) -->
|
||
<view
|
||
v-if="state.card != null"
|
||
class="ai-entry-capsule"
|
||
@click="openAiChat"
|
||
>
|
||
<view class="ai-sparkle-dot"></view>
|
||
<text class="ai-entry-text">{{ textOf('card.ai_chat') }}</text>
|
||
</view>
|
||
|
||
<!-- 5. 靠右侧核心主 CTA (图标形式:主人态分享出示 / 访客态保存到通讯录) -->
|
||
<view v-if="state.card != null" class="portal-cta-bar">
|
||
<button class="primary-cta-btn" :title="primaryCtaLabel" @click="handlePrimaryCta">
|
||
<text v-if="isOwner" class="primary-cta-btn-icon">↗</text>
|
||
<text v-else class="primary-cta-btn-icon">↓</text>
|
||
</button>
|
||
</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>
|
||
</template>
|
||
|
||
<script setup lang="uts">
|
||
import {
|
||
BentoBlockPublic,
|
||
CardProfile,
|
||
MeetingInviteOptions,
|
||
} from '../../common/types.uts';
|
||
import {
|
||
createPortalState,
|
||
loadCardPortal,
|
||
reportPortalStay,
|
||
toggleAudioPronunciation,
|
||
triggerQuickAction,
|
||
getBentoColSpanClass,
|
||
filterVisibleBentoBlocks,
|
||
persistPortalLocale,
|
||
readStoredPortalLocale,
|
||
readCardSuggestedLocale,
|
||
resolvePortalLocale,
|
||
resolvePortalCollectText,
|
||
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';
|
||
import { setPendingCollectCard } from '../owner/hall/useHall.uts';
|
||
import {
|
||
OmniLocale,
|
||
OmniMessageKey,
|
||
detectLocale,
|
||
getOmniText,
|
||
isRtl,
|
||
setCurrentLocale,
|
||
} from '../../common/locale.uts';
|
||
import I18nSelector from '../../components/i18n-selector/i18n-selector.uvue';
|
||
import AudioPronounce from '../../components/audio-pronounce/audio-pronounce.uvue';
|
||
import { releaseAudioPlayer } from './useAudioPlayer.uts';
|
||
import { utsLightColors, utsRadius, utsSpacing, utsTypography } from '../../common/tokens.uts';
|
||
|
||
const pageTheme: Record<string, string> = {
|
||
'--oc-primary': utsLightColors.primary,
|
||
'--oc-primary-gradient': utsLightColors.primaryGradient,
|
||
'--oc-primary-soft': utsLightColors.primarySoft,
|
||
'--oc-aurora': utsLightColors.aurora,
|
||
'--oc-ai': '#7C5CFF',
|
||
'--oc-bg': utsLightColors.bg,
|
||
'--oc-surface': utsLightColors.surface,
|
||
'--oc-border': utsLightColors.border,
|
||
'--oc-text': utsLightColors.text,
|
||
'--oc-text-2': utsLightColors.text2,
|
||
'--oc-success': utsLightColors.success,
|
||
'--oc-warning': utsLightColors.warning,
|
||
'--oc-error': utsLightColors.error,
|
||
'--oc-radius-btn': utsRadius.button,
|
||
'--oc-radius-card': utsRadius.card,
|
||
'--oc-radius-modal': utsRadius.modal,
|
||
'--oc-radius-pill': utsRadius.pill,
|
||
'--oc-shadow-card': utsTypography.shadowCard,
|
||
'--oc-shadow-cta': utsTypography.shadowCta,
|
||
'--oc-shadow-float': utsTypography.shadowFloat,
|
||
'--oc-shadow': utsTypography.shadow,
|
||
'--oc-space-4': utsSpacing.space4,
|
||
'--oc-space-8': utsSpacing.space8,
|
||
'--oc-space-12': utsSpacing.space12,
|
||
'--oc-space-16': utsSpacing.space16,
|
||
'--oc-space-20': utsSpacing.space20,
|
||
'--oc-space-24': utsSpacing.space24,
|
||
};
|
||
|
||
const defaultAvatar = 'https://cdn.omnicard.ai/assets/default-avatar.png';
|
||
|
||
type PortalPageQuery = {
|
||
id?: string;
|
||
slug?: string;
|
||
source?: string;
|
||
lang?: string;
|
||
mode?: string;
|
||
};
|
||
|
||
// 页面响应式状态
|
||
const state = reactive(createPortalState());
|
||
const currentCardId = ref('');
|
||
const currentMode = ref('');
|
||
const currentLang = ref<OmniLocale>(detectLocale());
|
||
|
||
const isOwner = computed((): boolean => {
|
||
return checkIsCardOwner({
|
||
hasToken: getAuthToken() !== '',
|
||
queryId: currentCardId.value,
|
||
queryMode: currentMode.value,
|
||
cardId: state.card != null ? state.card!.id : undefined,
|
||
cardSlug: state.card != null ? state.card!.slug : undefined,
|
||
});
|
||
});
|
||
|
||
const canCollect = computed((): boolean => {
|
||
return !isOwner.value;
|
||
});
|
||
|
||
const primaryCtaLabel = computed((): string => {
|
||
return resolvePortalCtaText(isOwner.value, currentLang.value);
|
||
});
|
||
|
||
const headerLinkText = computed((): string => {
|
||
return resolvePortalHeaderLinkText(isOwner.value, currentLang.value);
|
||
});
|
||
|
||
const collectLabel = computed((): string => {
|
||
return resolvePortalCollectText('label', currentLang.value);
|
||
});
|
||
|
||
const cardAudioUrl = computed((): string => {
|
||
if (state.card == null) {
|
||
return '';
|
||
}
|
||
const primaryUrl = state.card!.audioPronounceUrl;
|
||
if (primaryUrl != null && primaryUrl.trim() !== '') {
|
||
return primaryUrl.trim();
|
||
}
|
||
const fallbackUrl = state.card!.audioPronunciationUrl;
|
||
if (fallbackUrl != null && fallbackUrl.trim() !== '') {
|
||
return fallbackUrl.trim();
|
||
}
|
||
return '';
|
||
});
|
||
|
||
const pageRtlClass = computed((): string => {
|
||
return isRtl(currentLang.value) ? 'portal-rtl' : '';
|
||
});
|
||
|
||
function textOf(key: OmniMessageKey): string {
|
||
return getOmniText(currentLang.value, key);
|
||
}
|
||
|
||
function applyResolvedLocale(next: OmniLocale): void {
|
||
setCurrentLocale(next);
|
||
currentLang.value = next;
|
||
}
|
||
|
||
function switchLang(next: OmniLocale): void {
|
||
applyResolvedLocale(next);
|
||
persistPortalLocale(next);
|
||
}
|
||
// 7 大语种动态切换支持: switchLang('zh-CN'); switchLang('en-US'); switchLang('ar-SA'); switchLang('es-ES'); switchLang('de-DE'); switchLang('fr-FR'); switchLang('ja-JP');
|
||
|
||
// 计算过滤后的 Bento 块
|
||
const visibleBlocks = computed((): BentoBlockPublic[] => {
|
||
if (!state.card || !state.card!.bentoBlocks) {
|
||
return [];
|
||
}
|
||
return filterVisibleBentoBlocks(state.card!.bentoBlocks);
|
||
});
|
||
|
||
// 样式类映射
|
||
function getColClass(colSpan: number): string {
|
||
return getBentoColSpanClass(colSpan);
|
||
}
|
||
|
||
// 块类型显示标签
|
||
function getBlockTypeLabel(type: string): string {
|
||
if (type === 'case') return textOf('portal.case');
|
||
if (type === 'article') return textOf('portal.article');
|
||
if (type === 'social') return textOf('portal.social');
|
||
if (type === 'video') return textOf('portal.video');
|
||
return textOf('portal.block');
|
||
}
|
||
|
||
function readQuickActionValue(type: PortalQuickAction): string {
|
||
if (state.card == null) {
|
||
return '';
|
||
}
|
||
if (type === 'whatsapp') {
|
||
return state.card!.whatsapp || '';
|
||
}
|
||
if (type === 'linkedin') {
|
||
return state.card!.linkedin || '';
|
||
}
|
||
if (type === 'email') {
|
||
return state.card!.email || '';
|
||
}
|
||
if (type === 'phone') {
|
||
return state.card!.phone || '';
|
||
}
|
||
if (type === 'telegram') {
|
||
return state.card!.telegram || '';
|
||
}
|
||
if (type === 'twitter') {
|
||
return state.card!.twitter || '';
|
||
}
|
||
if (type === 'wechat') {
|
||
return state.card!.wechat || '';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function hasQuickAction(type: PortalQuickAction): boolean {
|
||
return readQuickActionValue(type).trim() !== '';
|
||
}
|
||
|
||
const hasAnyQuickAction = computed((): boolean => {
|
||
return (
|
||
hasQuickAction('whatsapp') ||
|
||
hasQuickAction('linkedin') ||
|
||
hasQuickAction('email') ||
|
||
hasQuickAction('phone') ||
|
||
hasQuickAction('telegram') ||
|
||
hasQuickAction('twitter') ||
|
||
hasQuickAction('wechat')
|
||
);
|
||
});
|
||
|
||
function toCardProfile(): CardProfile {
|
||
const card = state.card!;
|
||
const profile: CardProfile = {
|
||
id: card.id,
|
||
name: card.name,
|
||
title: card.title,
|
||
company: card.company,
|
||
slogan: card.slogan,
|
||
avatarUrl: card.avatarUrl,
|
||
phone: card.phone,
|
||
email: card.email,
|
||
whatsapp: card.whatsapp,
|
||
linkedin: card.linkedin,
|
||
telegram: card.telegram,
|
||
twitter: card.twitter,
|
||
wechat: card.wechat,
|
||
timezone: card.timezone,
|
||
audioPronunciationUrl: card.audioPronunciationUrl,
|
||
audioPronounceUrl: card.audioPronounceUrl,
|
||
status: card.status,
|
||
themeCode: card.themeCode,
|
||
};
|
||
return profile;
|
||
}
|
||
|
||
// 播放发音
|
||
function handleAudioToggle(): void {
|
||
if (state.card && state.card!.audioPronunciationUrl) {
|
||
toggleAudioPronunciation(state, state.card!.audioPronunciationUrl);
|
||
}
|
||
}
|
||
|
||
// 快捷动作
|
||
function handleAction(type: PortalQuickAction): void {
|
||
if (!state.card) return;
|
||
triggerQuickAction(type, readQuickActionValue(type));
|
||
}
|
||
|
||
// 公司反向链接点击
|
||
function handleCompanyClick(): void {
|
||
if (!state.card) return;
|
||
uni.showToast({
|
||
title: state.card!.company,
|
||
icon: 'none',
|
||
});
|
||
}
|
||
|
||
async function handleCollect(): Promise<void> {
|
||
if (state.card == null || state.card!.id == null) {
|
||
return;
|
||
}
|
||
if (getAuthToken() === '') {
|
||
setPendingCollectCard(state.card!.id);
|
||
uni.navigateTo({
|
||
url: '/pages/common/login',
|
||
});
|
||
return;
|
||
}
|
||
try {
|
||
await collectHallContact(state.card!.id);
|
||
uni.showToast({
|
||
title: resolvePortalCollectText('saved', currentLang.value),
|
||
icon: 'none',
|
||
});
|
||
} catch (_err) {}
|
||
}
|
||
|
||
function openExchange(): void {
|
||
if (!state.card) return;
|
||
const cardId = state.card!.id != null ? String(state.card!.id) : currentCardId.value;
|
||
const slug = state.card!.slug != null ? String(state.card!.slug) : '';
|
||
uni.navigateTo({
|
||
url: `/pages/portal/exchange?cardId=${cardId}&slug=${slug}`,
|
||
});
|
||
}
|
||
|
||
function handleHeaderAction(): void {
|
||
if (!state.card) return;
|
||
const cardId = state.card!.id != null ? String(state.card!.id) : currentCardId.value;
|
||
if (isOwner.value) {
|
||
uni.navigateTo({
|
||
url: `/pages/owner/card/edit?id=${cardId}`,
|
||
});
|
||
return;
|
||
}
|
||
openExchange();
|
||
}
|
||
|
||
function openAiChat(): void {
|
||
if (!state.card) return;
|
||
const cardId = state.card!.id != null ? String(state.card!.id) : currentCardId.value;
|
||
uni.navigateTo({
|
||
url: `/pages/portal/ai-chat?cardId=${cardId}&lang=${currentLang.value}`,
|
||
});
|
||
}
|
||
|
||
// 商务日程预约相关逻辑
|
||
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) : '';
|
||
const cardQuery = currentCardId.value;
|
||
if (block.type === 'case') {
|
||
uni.navigateTo({
|
||
url: `/pages/portal/case-detail?cardId=${cardQuery}&blockId=${blockId}`,
|
||
});
|
||
return;
|
||
}
|
||
if (block.type === 'article') {
|
||
uni.navigateTo({
|
||
url: `/pages/portal/article-detail?cardId=${cardQuery}&blockId=${blockId}`,
|
||
});
|
||
return;
|
||
}
|
||
if (block.linkUrl && block.linkUrl!.trim() !== '') {
|
||
uni.setClipboardData({
|
||
data: block.linkUrl!,
|
||
success: () => {
|
||
uni.showToast({
|
||
title: textOf('portal.copied_link'),
|
||
icon: 'none',
|
||
});
|
||
},
|
||
});
|
||
} else if (block.title) {
|
||
uni.showToast({
|
||
title: block.title!,
|
||
icon: 'none',
|
||
});
|
||
}
|
||
}
|
||
|
||
// 保存至通讯录
|
||
function handleSaveContact(): void {
|
||
if (!state.card) return;
|
||
saveToContacts(toCardProfile());
|
||
}
|
||
|
||
// 主 CTA 统一处理 (主人态: 分享海报/展台 / 访客态: 保存至手机通讯录)
|
||
function handlePrimaryCta(): void {
|
||
if (!state.card) return;
|
||
if (isOwner.value) {
|
||
const cardId = state.card!.id != null ? String(state.card!.id) : currentCardId.value;
|
||
const slug = state.card!.slug != null ? String(state.card!.slug) : '';
|
||
const param = slug !== '' ? `slug=${slug}` : `id=${cardId}`;
|
||
uni.navigateTo({
|
||
url: `/pages/owner/card/qrcode?${param}`,
|
||
});
|
||
return;
|
||
}
|
||
handleSaveContact();
|
||
}
|
||
|
||
// 重新加载
|
||
function reloadPortal(): void {
|
||
if (currentCardId.value) {
|
||
loadCardPortal(state, currentCardId.value, state.touchType);
|
||
}
|
||
}
|
||
|
||
onLoad((options: PortalPageQuery) => {
|
||
const queryId = (options && (options.id || options.slug)) ? String(options.id || options.slug) : 'default';
|
||
const querySource = (options && options.source) ? String(options.source) : 'link';
|
||
const queryLang = (options && options.lang) ? String(options.lang) : '';
|
||
const queryMode = (options && options.mode) ? String(options.mode) : '';
|
||
currentCardId.value = queryId;
|
||
currentMode.value = queryMode;
|
||
const stored = readStoredPortalLocale();
|
||
applyResolvedLocale(resolvePortalLocale(queryLang, stored, '', detectLocale()));
|
||
loadCardPortal(state, queryId, querySource).then((cardData) => {
|
||
const suggested = readCardSuggestedLocale(cardData);
|
||
applyResolvedLocale(resolvePortalLocale(queryLang, stored, suggested, detectLocale()));
|
||
});
|
||
});
|
||
|
||
onUnload(() => {
|
||
reportPortalStay(state, 100);
|
||
releaseAudioPlayer();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.portal-page {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
background-color: var(--oc-bg, #F5F8FC);
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 局部极光微光晕 (受控区域 < 8%) */
|
||
.ambient-aurora-glow {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 200px;
|
||
background: radial-gradient(circle at 50% -30%, rgba(0, 210, 255, 0.12) 0%, rgba(0, 136, 255, 0.06) 45%, rgba(99, 102, 241, 0) 100%);
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
}
|
||
|
||
/* RTL 镜像适配 */
|
||
.portal-rtl {
|
||
direction: rtl;
|
||
text-align: right;
|
||
}
|
||
.portal-rtl .hero-card {
|
||
text-align: right;
|
||
}
|
||
.portal-rtl .hero-name-row {
|
||
flex-direction: row;
|
||
justify-content: flex-start;
|
||
}
|
||
.portal-rtl .hero-company-row {
|
||
justify-content: flex-start;
|
||
}
|
||
.portal-rtl .hero-company-arrow,
|
||
.portal-rtl .locale-exchange-arrow,
|
||
.portal-rtl .bento-arrow-icon {
|
||
display: inline-block;
|
||
transform: scaleX(-1);
|
||
}
|
||
|
||
/* 顶栏 */
|
||
.locale-bar {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
min-height: 44px;
|
||
padding-inline: 16px;
|
||
background-color: transparent;
|
||
box-sizing: border-box;
|
||
z-index: 10;
|
||
}
|
||
.locale-selector-container {
|
||
display: flex;
|
||
align-items: center;
|
||
min-height: 44px;
|
||
}
|
||
.locale-exchange-wrap {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
min-height: 44px;
|
||
padding-inline: 4px;
|
||
cursor: pointer;
|
||
}
|
||
.locale-exchange-link {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: var(--oc-primary, #0088FF);
|
||
}
|
||
.locale-exchange-arrow {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--oc-primary, #0088FF);
|
||
margin-inline-start: 4px;
|
||
}
|
||
|
||
/* 滚动容器 */
|
||
.portal-scroll {
|
||
position: relative;
|
||
flex: 1;
|
||
z-index: 1;
|
||
}
|
||
|
||
.portal-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding-inline: 16px;
|
||
padding-block-start: 8px;
|
||
padding-block-end: 16px;
|
||
}
|
||
|
||
/* 品牌栏 */
|
||
.brand-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-block-end: 12px;
|
||
}
|
||
.brand-logo {
|
||
height: 28px;
|
||
}
|
||
|
||
/* 骨架屏 */
|
||
.portal-skeleton {
|
||
padding: 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.skeleton-hero {
|
||
background-color: var(--oc-surface, #FFFFFF);
|
||
border: 1px solid var(--oc-border, #EBF1F7);
|
||
border-radius: var(--oc-radius-card, 20px);
|
||
padding: 24px 20px;
|
||
margin-block-end: 16px;
|
||
}
|
||
.skeleton-avatar {
|
||
width: 72px;
|
||
height: 72px;
|
||
border-radius: 36px;
|
||
background-color: var(--oc-border, #EBF1F7);
|
||
margin-block-end: 16px;
|
||
}
|
||
.skeleton-line {
|
||
background-color: var(--oc-border, #EBF1F7);
|
||
border-radius: 8px;
|
||
margin-block-end: 12px;
|
||
}
|
||
.line-title {
|
||
width: 60%;
|
||
height: 24px;
|
||
}
|
||
.line-subtitle {
|
||
width: 40%;
|
||
height: 16px;
|
||
}
|
||
.skeleton-grid {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
.skeleton-card {
|
||
height: 96px;
|
||
background-color: var(--oc-surface, #FFFFFF);
|
||
border: 1px solid var(--oc-border, #EBF1F7);
|
||
border-radius: var(--oc-radius-card, 20px);
|
||
}
|
||
|
||
/* 异常提示 */
|
||
.portal-error-card {
|
||
margin: 40px 16px;
|
||
padding: 32px 24px;
|
||
background-color: var(--oc-surface, #FFFFFF);
|
||
border: 1px solid var(--oc-border, #EBF1F7);
|
||
border-radius: var(--oc-radius-card, 20px);
|
||
box-shadow: var(--oc-shadow-card, 0 8px 30px rgba(0, 0, 0, 0.04));
|
||
align-items: center;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.error-badge {
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 22px;
|
||
background-color: rgba(240, 68, 56, 0.08);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-block-end: 12px;
|
||
}
|
||
.error-badge-icon {
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
color: var(--oc-error, #F04438);
|
||
}
|
||
.error-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: var(--oc-text, #101828);
|
||
margin-block-end: 8px;
|
||
}
|
||
.error-desc {
|
||
font-size: 14px;
|
||
color: var(--oc-text-2, #64748B);
|
||
margin-block-end: 20px;
|
||
text-align: center;
|
||
line-height: 1.5;
|
||
}
|
||
.retry-btn {
|
||
height: 44px;
|
||
min-height: 44px;
|
||
padding: 0 24px;
|
||
background-color: var(--oc-primary, #0088FF);
|
||
border-radius: var(--oc-radius-btn, 14px);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-width: 0;
|
||
box-shadow: 0 4px 12px rgba(0, 136, 255, 0.16);
|
||
}
|
||
.retry-btn-text {
|
||
color: #FFFFFF;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* 1. Hero 身份卡片 (Bento Showcase) */
|
||
.hero-card {
|
||
background-color: var(--oc-surface, #FFFFFF);
|
||
border: 1px solid var(--oc-border, #EBF1F7);
|
||
border-radius: var(--oc-radius-card, 20px);
|
||
box-shadow: var(--oc-shadow-card, 0 8px 30px rgba(0, 0, 0, 0.04));
|
||
padding: 24px 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
margin-block-end: 16px;
|
||
}
|
||
.hero-top-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-block-end: 16px;
|
||
}
|
||
.hero-avatar-wrap {
|
||
position: relative;
|
||
width: 72px;
|
||
height: 72px;
|
||
}
|
||
.hero-avatar {
|
||
width: 72px;
|
||
height: 72px;
|
||
border-radius: 36px;
|
||
border: 2px solid var(--oc-surface, #FFFFFF);
|
||
background-color: var(--oc-bg, #F5F8FC);
|
||
}
|
||
.hero-avatar-ring {
|
||
position: absolute;
|
||
top: -2px;
|
||
left: -2px;
|
||
right: -2px;
|
||
bottom: -2px;
|
||
border-radius: 38px;
|
||
border: 1.5px solid var(--oc-primary-soft, #EBF5FF);
|
||
pointer-events: none;
|
||
}
|
||
.hero-top-actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-end;
|
||
gap: 8px;
|
||
}
|
||
.hero-timezone-pill {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
padding: 4px 10px;
|
||
background-color: var(--oc-bg, #F5F8FC);
|
||
border: 1px solid var(--oc-border, #EBF1F7);
|
||
border-radius: var(--oc-radius-pill, 9999px);
|
||
}
|
||
.status-dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 3px;
|
||
background-color: var(--oc-success, #12B76A);
|
||
margin-inline-end: 6px;
|
||
}
|
||
.hero-timezone-text {
|
||
font-size: 12px;
|
||
color: var(--oc-text-2, #64748B);
|
||
font-weight: 500;
|
||
}
|
||
.hero-collect-pill {
|
||
min-height: 28px;
|
||
padding: 4px 10px;
|
||
background-color: var(--oc-primary-soft, #EBF5FF);
|
||
border-radius: var(--oc-radius-pill, 9999px);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||
}
|
||
.hero-collect-pill:active {
|
||
opacity: 0.85;
|
||
}
|
||
.hero-collect-text {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--oc-primary, #0088FF);
|
||
}
|
||
|
||
.hero-name-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
margin-block-end: 8px;
|
||
}
|
||
.hero-name {
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
color: var(--oc-text, #101828);
|
||
margin-inline-end: 12px;
|
||
line-height: 1.25;
|
||
}
|
||
|
||
.hero-title {
|
||
font-size: 14px;
|
||
color: var(--oc-text-2, #64748B);
|
||
margin-block-end: 8px;
|
||
line-height: 1.6;
|
||
}
|
||
.hero-company-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
min-height: 36px;
|
||
margin-block-end: 12px;
|
||
cursor: pointer;
|
||
}
|
||
.hero-company-name {
|
||
font-size: 14px;
|
||
color: var(--oc-text, #101828);
|
||
font-weight: 600;
|
||
margin-inline-end: 4px;
|
||
}
|
||
.hero-company-arrow {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--oc-primary, #0088FF);
|
||
}
|
||
.hero-slogan {
|
||
font-size: 14px;
|
||
color: var(--oc-text-2, #64748B);
|
||
line-height: 1.6;
|
||
border-top: 1px solid var(--oc-border, #EBF1F7);
|
||
padding-block-start: 12px;
|
||
margin-block-top: 4px;
|
||
}
|
||
|
||
/* 2. 快捷动作条 (Bento 快速触达网格) */
|
||
.quick-action-bar {
|
||
display: flex;
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-block-end: 16px;
|
||
}
|
||
.action-item {
|
||
width: calc(25% - 6px);
|
||
min-height: 56px;
|
||
background-color: var(--oc-surface, #FFFFFF);
|
||
border: 1px solid var(--oc-border, #EBF1F7);
|
||
border-radius: var(--oc-radius-btn, 14px);
|
||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.02);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 8px 4px;
|
||
box-sizing: border-box;
|
||
transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||
cursor: pointer;
|
||
}
|
||
.action-item:active {
|
||
opacity: 0.85;
|
||
}
|
||
.action-icon-box {
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 8px;
|
||
background-color: var(--oc-primary-soft, #EBF5FF);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-block-end: 4px;
|
||
}
|
||
.action-icon-text {
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
color: var(--oc-primary, #0088FF);
|
||
letter-spacing: 0.2px;
|
||
}
|
||
.action-item-label {
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: var(--oc-text, #101828);
|
||
}
|
||
|
||
/* 3. Bento Grid 网格 (便当盒美学) */
|
||
.bento-grid {
|
||
display: flex;
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
.bento-card {
|
||
background-color: var(--oc-surface, #FFFFFF);
|
||
border: 1px solid var(--oc-border, #EBF1F7);
|
||
border-radius: var(--oc-radius-card, 20px);
|
||
padding: 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-sizing: border-box;
|
||
box-shadow: var(--oc-shadow-card, 0 8px 30px rgba(0, 0, 0, 0.04));
|
||
transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||
cursor: pointer;
|
||
}
|
||
.bento-card:active {
|
||
opacity: 0.85;
|
||
}
|
||
.bento-col-1 {
|
||
width: calc(50% - 6px);
|
||
}
|
||
.bento-col-2 {
|
||
width: 100%;
|
||
}
|
||
.bento-header-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-block-end: 10px;
|
||
}
|
||
.bento-tag {
|
||
padding: 3px 8px;
|
||
background-color: var(--oc-primary-soft, #EBF5FF);
|
||
border-radius: 8px;
|
||
}
|
||
.bento-tag-text {
|
||
font-size: 12px;
|
||
color: var(--oc-primary, #0088FF);
|
||
font-weight: 600;
|
||
}
|
||
.bento-arrow-icon {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--oc-text-2, #64748B);
|
||
}
|
||
.bento-cover {
|
||
width: 100%;
|
||
height: 128px;
|
||
border-radius: 12px;
|
||
margin-block-end: 12px;
|
||
background-color: var(--oc-bg, #F5F8FC);
|
||
}
|
||
.bento-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: var(--oc-text, #101828);
|
||
margin-block-end: 6px;
|
||
line-height: 1.25;
|
||
}
|
||
.bento-content {
|
||
font-size: 14px;
|
||
color: var(--oc-text-2, #64748B);
|
||
line-height: 1.6;
|
||
}
|
||
|
||
/* 底部垫高 (防止被居中悬浮主 CTA 和 AI 入口遮挡) */
|
||
.portal-bottom-spacer {
|
||
height: calc(100px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px));
|
||
}
|
||
|
||
/* 4. AI 智能中枢入口胶囊 (次级入口,轻量极光微光晕) */
|
||
.ai-entry-capsule {
|
||
position: fixed;
|
||
inset-inline-end: 16px;
|
||
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: 0 4px 16px rgba(16, 24, 40, 0.06);
|
||
z-index: 99;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||
}
|
||
.ai-entry-capsule:active {
|
||
opacity: 0.85;
|
||
}
|
||
.ai-sparkle-dot {
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: 4px;
|
||
background-color: var(--oc-ai, #7C5CFF);
|
||
margin-inline-end: 6px;
|
||
box-shadow: 0 0 6px rgba(124, 92, 255, 0.5);
|
||
}
|
||
.ai-entry-text {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--oc-ai, #7C5CFF);
|
||
}
|
||
|
||
/* 5. 靠右侧核心主 CTA (图标形式,正圆微光晕 FAB,彻底释放屏幕中下部空间) */
|
||
.portal-cta-bar {
|
||
position: fixed;
|
||
inset-inline-end: 16px;
|
||
bottom: calc(16px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px));
|
||
z-index: 100;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.primary-cta-btn {
|
||
width: 48px;
|
||
height: 48px;
|
||
min-width: 48px;
|
||
min-height: 48px;
|
||
padding: 0;
|
||
background: var(--oc-primary-gradient, linear-gradient(135deg, #00A3FF 0%, #0B63E5 100%));
|
||
border-radius: var(--oc-radius-pill, 9999px);
|
||
box-shadow: 0 8px 24px rgba(0, 136, 255, 0.35), 0 2px 6px rgba(11, 99, 229, 0.16);
|
||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||
}
|
||
.primary-cta-btn:active {
|
||
opacity: 0.85;
|
||
transform: scale(0.95);
|
||
}
|
||
.primary-cta-btn-icon {
|
||
color: #FFFFFF;
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
}
|
||
|
||
/* 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;
|
||
transition: none !important;
|
||
}
|
||
}
|
||
</style>
|