文件
OmniCard/mpuniapp/pages/portal/index.uvue
wikiboss 62e0ae4435 feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:01:47 +08:00

1951 行
54 KiB
Plaintext

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<template>
<view class="portal-page" :class="pageRtlClass" :style="pageTheme">
<!-- 顶部受控局部极光微光晕 (面积 < 8%) -->
<view class="ambient-aurora-glow"></view>
<!-- 顶栏:返回/预览在左;语言切换常驻右上,不进二级菜单 -->
<view class="locale-bar portal-top-bar">
<view class="top-bar-left">
<view
class="portal-back-btn"
role="button"
:aria-label="backLabel"
@click="handleGoBack"
>
<text class="portal-back-arrow">‹</text>
</view>
<view v-if="isOwner" class="preview-badge">
<view class="preview-dot"></view>
<text class="preview-badge-text">{{ previewModeLabel }}</text>
</view>
</view>
<view class="top-bar-left">
<view class="locale-exchange-wrap" @click="handleHeaderAction">
<text class="locale-exchange-link">{{ headerLinkText }}</text>
<text class="locale-exchange-arrow">↗</text>
</view>
<view class="locale-selector-container">
<i18n-selector :model-value="currentLang" @change="switchLang" />
</view>
</view>
</view>
<scroll-view class="portal-scroll" :scroll-y="true" @scroll="handlePortalScroll">
<!-- 骨架加载态 -->
<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 action-icon-whatsapp">
<image class="action-icon-img" src="/static/icons/whatsapp.svg" mode="aspectFit" />
</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 action-icon-linkedin">
<image class="action-icon-img" src="/static/icons/linkedin.svg" mode="aspectFit" />
</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 action-icon-email">
<image class="action-icon-img" src="/static/icons/email.svg" mode="aspectFit" />
</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 action-icon-phone">
<image class="action-icon-img" src="/static/icons/phone.svg" mode="aspectFit" />
</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 action-icon-telegram">
<image class="action-icon-img" src="/static/icons/telegram.svg" mode="aspectFit" />
</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 action-icon-twitter">
<image class="action-icon-img" src="/static/icons/twitter.svg" mode="aspectFit" />
</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 action-icon-wechat">
<image class="action-icon-img" src="/static/icons/wechat.svg" mode="aspectFit" />
</view>
<text class="action-item-label">{{ textOf('portal.wechat') }}</text>
</view>
<view v-if="hasQuickAction('line')" class="action-item" @click="handleAction('line')">
<view class="action-icon-box action-icon-line">
<image class="action-icon-img" src="/static/icons/line.svg" mode="aspectFit" />
</view>
<text class="action-item-label">{{ textOf('portal.line') }}</text>
</view>
<view v-if="hasQuickAction('kakao')" class="action-item" @click="handleAction('kakao')">
<view class="action-icon-box action-icon-kakao">
<image class="action-icon-img" src="/static/icons/kakao.svg" mode="aspectFit" />
</view>
<text class="action-item-label">{{ textOf('portal.kakao') }}</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. 在线顾问悬浮入口 (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>
<!-- Cookie 同意条:无本地同意记录时仅显示一条 -->
<view v-if="showCookieBanner" class="cookie-consent-bar" :class="pageRtlClass">
<text class="cookie-consent-text">{{ textOf('portal.cookie_notice') }}</text>
<view class="cookie-consent-actions">
<view class="cookie-consent-reject" role="button" @click="rejectCookieConsent">
<text class="cookie-consent-reject-text">{{ textOf('portal.cookie_reject') }}</text>
</view>
<view class="cookie-consent-accept" role="button" @click="acceptCookieConsent">
<text class="cookie-consent-accept-text">{{ textOf('portal.cookie_accept') }}</text>
</view>
</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 v-if="appointmentSubmitted" class="sheet-success-box">
<view class="sheet-success-ring">
<view class="sheet-success-check"></view>
</view>
<text class="sheet-success-title">{{ textOf('portal.appointment_success_tip') }}</text>
<text class="sheet-success-sub">{{ state.card!.name }} ({{ state.card!.company }})</text>
<button class="primary-cta-btn appointment-submit-btn" @click="closeAppointmentSheet">
<text class="primary-cta-btn-text">{{ textOf('common.confirm') }}</text>
</button>
</view>
<!-- 预约与需求提报表单 -->
<scroll-view v-else scroll-y="true" class="appointment-sheet-scroll">
<view class="appointment-form-wrap">
<view class="sheet-header">
<view class="sheet-title-row">
<text class="sheet-title">{{ textOf('portal.appointment_form_title') }}</text>
<text class="sheet-close" @click="closeAppointmentSheet">×</text>
</view>
<text class="sheet-subtitle">{{ textOf('portal.appointment_form_desc') }}</text>
</view>
<!-- 期望会谈形式快捷胶囊 -->
<view class="sheet-field">
<text class="sheet-field-label">{{ textOf('portal.appointment_time') }}</text>
<view class="sheet-time-chips">
<view
v-for="opt in appointmentTimeOptions"
:key="opt.key"
class="sheet-time-chip"
:class="{ 'sheet-time-chip-active': appointmentForm.preferredTime === opt.label }"
@click="selectPreferredTime(opt.label)"
>
<text
class="sheet-time-chip-text"
:class="{ 'sheet-time-chip-text-active': appointmentForm.preferredTime === opt.label }"
>{{ opt.label }}</text>
</view>
</view>
</view>
<!-- 姓名输入 (必填) -->
<view class="sheet-field">
<text class="sheet-field-label">{{ textOf('portal.appointment_name') }} *</text>
<input
class="sheet-field-input"
v-model="appointmentForm.name"
:placeholder="textOf('portal.appointment_name')"
:disabled="appointmentFormSubmitting"
/>
</view>
<!-- 联系方式输入 (必填) -->
<view class="sheet-field">
<text class="sheet-field-label">{{ textOf('portal.appointment_contact') }} *</text>
<input
class="sheet-field-input"
v-model="appointmentForm.contact"
:placeholder="textOf('portal.appointment_contact')"
:disabled="appointmentFormSubmitting"
/>
</view>
<!-- 公司名称 (选填) -->
<view class="sheet-field">
<text class="sheet-field-label">{{ textOf('portal.appointment_company') }}</text>
<input
class="sheet-field-input"
v-model="appointmentForm.company"
:placeholder="textOf('portal.appointment_company')"
:disabled="appointmentFormSubmitting"
/>
</view>
<!-- 需求与议题说明 (多行文本框) -->
<view class="sheet-field">
<text class="sheet-field-label">{{ textOf('portal.appointment_notes') }}</text>
<textarea
class="sheet-field-textarea"
v-model="appointmentForm.notes"
:placeholder="textOf('portal.appointment_notes_placeholder')"
:disabled="appointmentFormSubmitting"
:maxlength="500"
/>
</view>
<!-- 名片或需求照片 (选填附件) -->
<view class="sheet-field">
<view class="sheet-field-header-row">
<text class="sheet-field-label">{{ textOf('portal.appointment_photos') }}</text>
<text class="sheet-field-tip">{{ textOf('portal.appointment_photos_tip') }}</text>
</view>
<view class="sheet-uploader-wrap">
<ImageUploader
v-model="appointmentForm.photos"
:multiple="true"
:maxCount="3"
:disabled="appointmentFormSubmitting"
/>
</view>
</view>
<!-- 提交按钮 -->
<button
class="primary-cta-btn appointment-submit-btn"
:class="{ 'appointment-submit-btn-disabled': appointmentFormSubmitting }"
:disabled="appointmentFormSubmitting"
@click="submitAppointmentForm"
>
<text class="primary-cta-btn-text">{{ appointmentFormSubmitting ? textOf('portal.appointment_submitting') : textOf('portal.appointment_submit') }}</text>
</button>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import {
BentoBlockPublic,
CardPortalDetail,
CardProfile,
} from '../../common/types.uts';
import {
createPortalState,
loadCardPortal,
reportPortalStay,
updatePortalScrollDepth,
toggleAudioPronunciation,
triggerQuickAction,
getBentoColSpanClass,
filterVisibleBentoBlocks,
persistPortalLocale,
readStoredPortalLocale,
readCardSuggestedLocale,
resolvePortalLocale,
resolvePortalCollectText,
checkIsCardOwner,
resolvePortalCtaText,
resolvePortalHeaderLinkText,
executePortalGoBack,
trackAppointmentAction,
PortalQuickAction,
isRenderableHttpUrl,
} from './usePortal.uts';
import { saveToContacts } from '../../common/vcard-export.uts';
import { collectHallContact, submitLead } 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 ImageUploader from '../../components/image-uploader/image-uploader.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';
const COOKIE_CONSENT_KEY = 'omni_cookie_consent';
const COOKIE_CONSENT_ACCEPTED = 'accepted';
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 showCookieBanner = ref(false);
function readCookieConsentRaw(): string {
try {
const raw = uni.getStorageSync(COOKIE_CONSENT_KEY);
if (raw == null) {
return '';
}
return String(raw);
} catch (_e) {
return '';
}
}
function hasCookieConsentRecord(): boolean {
return readCookieConsentRaw() === COOKIE_CONSENT_ACCEPTED;
}
function initCookieBanner(): void {
// 无同意记录时显示;有 accepted 记录才视为已同意,绝不假装已同意
showCookieBanner.value = !hasCookieConsentRecord();
}
function acceptCookieConsent(): void {
try {
uni.setStorageSync(COOKIE_CONSENT_KEY, COOKIE_CONSENT_ACCEPTED);
} catch (_e) {}
showCookieBanner.value = false;
}
function rejectCookieConsent(): void {
// 拒绝:不写入同意记录,不把拒绝当成已同意;仅收起当前会话条
showCookieBanner.value = false;
}
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 backLabel = computed((): string => {
return textOf('common.back');
});
const previewModeLabel = computed((): string => {
return textOf('portal.preview_mode');
});
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');
}
type PortalCardSocialExtra = CardPortalDetail & {
line?: string;
kakao?: string;
};
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 || '';
}
if (type === 'line') {
const extra = state.card! as PortalCardSocialExtra;
return extra.line != null ? extra.line : '';
}
if (type === 'kakao') {
const extra = state.card! as PortalCardSocialExtra;
return extra.kakao != null ? extra.kakao : '';
}
return '';
}
function hasQuickAction(type: PortalQuickAction): boolean {
const raw = readQuickActionValue(type).trim();
if (raw === '') {
return false;
}
// Line / KakaoTalk:空、缺失或非 http(s) 均不渲染(P35 空社交不渲染)
if (type === 'line' || type === 'kakao') {
return isRenderableHttpUrl(raw);
}
return true;
}
const hasAnyQuickAction = computed((): boolean => {
return (
hasQuickAction('whatsapp') ||
hasQuickAction('linkedin') ||
hasQuickAction('email') ||
hasQuickAction('phone') ||
hasQuickAction('telegram') ||
hasQuickAction('twitter') ||
hasQuickAction('wechat') ||
hasQuickAction('line') ||
hasQuickAction('kakao')
);
});
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 handleGoBack(): void {
executePortalGoBack(isOwner.value);
}
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}`,
});
}
// 商务日程预约与需求提报相关逻辑
type AppointmentFormState = {
name: string;
contact: string;
company: string;
preferredTime: string;
notes: string;
photos: string[];
};
const isAppointmentSheetOpen = ref(false);
const appointmentFormSubmitting = ref(false);
const appointmentSubmitted = ref(false);
const appointmentForm = reactive<AppointmentFormState>({
name: '',
contact: '',
company: '',
preferredTime: '',
notes: '',
photos: [] as string[],
});
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;
}
function closeAppointmentSheet(): void {
isAppointmentSheetOpen.value = false;
if (appointmentSubmitted.value) {
appointmentForm.name = '';
appointmentForm.contact = '';
appointmentForm.company = '';
appointmentForm.notes = '';
appointmentForm.photos = [] as string[];
appointmentSubmitted.value = false;
}
}
async function submitAppointmentForm(): Promise<void> {
if (appointmentFormSubmitting.value) return;
if (!state.card || state.card == null) return;
const name = appointmentForm.name.trim();
const contact = appointmentForm.contact.trim();
if (!name || !contact) {
uni.showToast({
title: textOf('portal.appointment_fill_required'),
icon: 'none',
});
return;
}
appointmentFormSubmitting.value = true;
try {
const isEmail = contact.includes('@');
const photoNotes = appointmentForm.photos.length > 0
? `\n[附件照片 ${appointmentForm.photos.join(' ')}]`
: '';
const fullNotes = `[商务预约] 期望形式: ${appointmentForm.preferredTime || '未指定'}${appointmentForm.notes.trim() ? `\n需求与议题: ${appointmentForm.notes.trim()}` : ''}${photoNotes}`;
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,
});
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;
}
}
// Bento 卡片点击
function handleBlockClick(block: BentoBlockPublic): void {
const blockId = block.id != null ? String(block.id) : '';
const cardQuery = currentCardId.value;
if (block.type === 'case' || block.type === 'video') {
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;
initCookieBanner();
const stored = readStoredPortalLocale();
applyResolvedLocale(resolvePortalLocale(queryLang, stored, '', detectLocale()));
loadCardPortal(state, queryId, querySource).then((cardData) => {
const suggested = readCardSuggestedLocale(cardData);
applyResolvedLocale(resolvePortalLocale(queryLang, stored, suggested, detectLocale()));
});
});
type PortalScrollDetail = {
scrollTop: number;
scrollHeight: number;
};
type PortalScrollEvent = {
detail: PortalScrollDetail | null;
};
function handlePortalScroll(e: PortalScrollEvent): void {
if (e != null && e.detail != null) {
updatePortalScrollDepth(state, e.detail.scrollTop, e.detail.scrollHeight, 667);
}
}
onUnload(() => {
reportPortalStay(state, state.scrollDepth);
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,
.portal-rtl .portal-back-arrow {
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: 12px 16px;
background-color: transparent;
box-sizing: border-box;
z-index: 10;
}
.top-bar-left {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.portal-back-btn {
width: 36px;
height: 36px;
min-width: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
background-color: rgba(255, 255, 255, 0.85);
border: 1px solid var(--oc-border, #EBF1F7);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
cursor: pointer;
transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.portal-back-btn:active {
opacity: 0.75;
}
.portal-back-arrow {
font-size: 24px;
font-weight: 400;
color: var(--oc-text, #101828);
line-height: 1;
margin-top: -2px;
}
.preview-badge {
display: flex;
flex-direction: row;
align-items: center;
padding-inline: 8px;
height: 24px;
background-color: var(--oc-primary-soft, #EBF5FF);
border: 1px solid rgba(0, 136, 255, 0.2);
border-radius: 9999px;
}
.preview-dot {
width: 6px;
height: 6px;
border-radius: 3px;
background-color: var(--oc-primary, #0088FF);
margin-inline-end: 4px;
}
.preview-badge-text {
font-size: 11px;
font-weight: 600;
color: var(--oc-primary, #0088FF);
}
.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: 32px;
height: 32px;
border-radius: 9px;
background-color: var(--oc-primary-soft, #EBF5FF);
display: flex;
align-items: center;
justify-content: center;
margin-block-end: 4px;
}
.action-icon-img {
width: 18px;
height: 18px;
}
.action-icon-whatsapp {
background-color: #E8F9EE;
}
.action-icon-linkedin {
background-color: #E6F0FA;
}
.action-icon-email {
background-color: #EBF5FF;
}
.action-icon-phone {
background-color: #E6F8EF;
}
.action-icon-telegram {
background-color: #E6F5FC;
}
.action-icon-twitter {
background-color: #F2F4F7;
}
.action-icon-wechat {
background-color: #E6F8EE;
}
.action-icon-line {
background-color: #E6F8EE;
}
.action-icon-kakao {
background-color: #FFFBE6;
}
.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-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;
background: transparent;
box-shadow: none;
border: none;
}
.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: none;
border: none;
border-width: 0;
outline: none;
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::after {
border: none;
display: none;
}
.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;
}
/* Cookie 同意条 */
.cookie-consent-bar {
position: fixed;
left: 16px;
right: 16px;
bottom: calc(72px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px));
z-index: 120;
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px 16px;
background-color: rgba(255, 255, 255, 0.98);
border: 1px solid var(--oc-border, #EBF1F7);
border-radius: var(--oc-radius-card, 20px);
box-shadow: var(--oc-shadow-float, 0 12px 36px rgba(16, 24, 40, 0.08));
}
.cookie-consent-text {
font-size: 13px;
line-height: 1.5;
color: var(--oc-text-2, #64748B);
}
.cookie-consent-actions {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
.cookie-consent-reject {
min-height: 40px;
padding: 0 14px;
border-radius: var(--oc-radius-pill, 9999px);
border: 1px solid var(--oc-border, #EBF1F7);
background-color: var(--oc-bg, #F5F8FC);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.cookie-consent-reject-text {
font-size: 13px;
font-weight: 600;
color: var(--oc-text-2, #64748B);
}
.cookie-consent-accept {
min-height: 40px;
padding: 0 16px;
border-radius: var(--oc-radius-pill, 9999px);
background: var(--oc-primary-gradient, linear-gradient(135deg, #00A3FF 0%, #0B63E5 100%));
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: var(--oc-shadow-cta, 0 8px 20px rgba(0, 136, 255, 0.22));
}
.cookie-consent-accept-text {
font-size: 13px;
font-weight: 600;
color: #FFFFFF;
}
/* 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);
}
/* 商务预约与需求提报表单样式 */
.appointment-sheet-scroll {
max-height: 75vh;
overflow-y: auto;
}
.appointment-form-wrap {
display: flex;
flex-direction: column;
}
.sheet-field {
display: flex;
flex-direction: column;
margin-block-end: 12px;
}
.sheet-field-header-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-block-end: 6px;
}
.sheet-field-label {
font-size: 13px;
font-weight: 600;
color: var(--oc-text, #101828);
margin-block-end: 6px;
}
.sheet-field-tip {
font-size: 11px;
color: var(--oc-text-2, #64748B);
}
.sheet-uploader-wrap {
margin-top: 4px;
margin-bottom: 6px;
}
.sheet-time-chips {
display: flex;
flex-direction: row;
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);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-sizing: border-box;
}
.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: 96px;
height: 96px;
border: 1px solid var(--oc-border, #EBF1F7);
border-radius: 12px;
padding: 10px 14px;
font-size: 16px;
line-height: 1.5;
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;
}
.appointment-submit-btn-disabled {
opacity: 0.6;
pointer-events: none;
}
/* 预约成功反馈卡片 */
.sheet-success-box {
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 0 12px;
}
.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;
}
.sheet-success-sub {
font-size: 13px;
color: var(--oc-text-2, #64748B);
margin-bottom: 24px;
}
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}
</style>