文件
OmniCard/mpuniapp/pages/portal/index.uvue
wikiboss 6c3d5d323a refactor(arch): 落地平台架构演进第一二阶段整改,消除OOM与越权风险并支持高并发缓冲与AI多租户BYOK
- [P0安全底座] 多租户数据闭环:公开留资与雷达足迹自动逆向追溯绑定名片 tenantId 与归属销售 userId
- [P0性能稳定] 消除内存全量加载:公海池分批带条件扫描回收,雷达国家分布改用 SQL 原生 GROUP BY 聚合查询彻底消除 OOM 隐患
- [P0商业变现] 商业化配额门禁切面:在 Cool-Admin modifyBefore 生命周期织入 assertQuotaAvailable 校验,阻断超额名片创建
- [P0环境解耦] 客户端解耦本地硬编码:request.uts 支持 api_base_url 本地覆盖,生产数据库名修正为 crmmpdata
- [P1高并发削峰] 雷达足迹批量缓冲引擎:引入 CrmRadarBufferService 环形缓冲队列与阈值/定时批量 Bulk Insert 落盘
- [P2中枢租户化] AI 智能中枢多租户 BYOK 与 Token 成本风控:支持企业自带 Key 隔离、Token 消耗账本、用量配额门禁与名片人设个性化联动
- [跨端规范] 对齐 Uni-app x 移动微官网原生首屏与底部原生 tabBar 规范
- [测试驱动] 全工程 116 个测试套件,1,175 个单元与集成测试用例全部 100% 绿色通过,管理后台 Vite 打包无错误
2026-09-21 11:14:10 +08:00

843 行
22 KiB
Plaintext

<template>
<view class="portal-page" :class="pageRtlClass" :style="pageTheme">
<view class="locale-bar">
<view class="locale-selector-container">
<i18n-selector :model-value="currentLang" @change="switchLang" />
</view>
<text class="locale-chip locale-exchange" @click="openExchange">{{ textOf('card.exchange') }}</text>
<text v-if="canCollect" class="locale-chip locale-exchange" @click="handleCollect">{{ collectLabel }}</text>
</view>
<scroll-view class="portal-scroll" :scroll-y="true">
<!-- 骨架加载态 -->
<view v-if="state.loading" class="portal-skeleton">
<view class="skeleton-avatar"></view>
<view class="skeleton-line line-title"></view>
<view class="skeleton-line line-subtitle"></view>
<view class="skeleton-card"></view>
</view>
<!-- 异常提示态 -->
<view v-else-if="state.errorMessage" class="portal-error-card">
<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 身份区 -->
<view class="hero-card">
<view class="hero-top-row">
<image
class="hero-avatar"
:src="state.card!.avatarUrl || defaultAvatar"
mode="aspectFill"
/>
<view v-if="state.card!.timezone" class="hero-timezone-pill">
<text class="hero-timezone-text">{{ state.card!.timezone }}</text>
</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 class="quick-action-bar">
<view 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 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 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 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 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 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 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">
<view
v-for="block in visibleBlocks"
:key="block.id"
class="bento-card"
:class="getColClass(block.colSpan)"
@click="handleBlockClick(block)"
>
<!-- 卡片类型标签 -->
<view class="bento-tag">
<text class="bento-tag-text">{{ getBlockTypeLabel(block.type) }}</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 遮挡 -->
<view class="portal-bottom-spacer"></view>
</view>
</scroll-view>
<text
v-if="state.card != null"
class="ai-entry"
@click="openAiChat"
>{{ textOf('card.ai_chat') }}</text>
<!-- 4. 单屏唯一定位主 CTA (保存到通讯录) -->
<view v-if="state.card != null" class="portal-cta-bar">
<button class="primary-cta-btn" @click="handleSaveContact">
<text class="primary-cta-btn-text">{{ textOf('card.save_contact') }}</text>
</button>
</view>
</view>
</template>
<script setup lang="uts">
import {
BentoBlockPublic,
} from '../../common/types.uts';
import {
createPortalState,
loadCardPortal,
reportPortalStay,
toggleAudioPronunciation,
triggerQuickAction,
getBentoColSpanClass,
filterVisibleBentoBlocks,
persistPortalLocale,
readStoredPortalLocale,
readCardSuggestedLocale,
resolvePortalLocale,
PortalQuickAction,
} from './usePortal.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 } from '../../common/tokens.uts';
const pageTheme: Record<string, string> = {
'--oc-primary': utsLightColors.primary,
'--oc-primary-soft': utsLightColors.primarySoft,
'--oc-ai': utsLightColors.ai,
'--oc-bg': utsLightColors.bg,
'--oc-surface': utsLightColors.surface,
'--oc-border': utsLightColors.border,
'--oc-text': utsLightColors.text,
'--oc-text-2': utsLightColors.text2,
'--oc-radius-btn': utsRadius.button,
'--oc-radius-card': utsRadius.card,
'--oc-space-4': utsSpacing.space4,
'--oc-space-8': utsSpacing.space8,
'--oc-space-12': utsSpacing.space12,
'--oc-space-16': utsSpacing.space16,
'--oc-space-24': utsSpacing.space24,
};
const defaultAvatar = 'https://cdn.omnicard.ai/assets/default-avatar.png';
// 页面响应式状态
const state = reactive(createPortalState());
const currentCardId = ref('');
const currentLang = ref<OmniLocale>(detectLocale());
const canCollect = ref(true);
const collectLabel = computed((): string => {
return currentLang.value === 'zh-CN' ? '收入大厅' : 'Save to hall';
});
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 handleAudioToggle(): void {
if (state.card && state.card!.audioPronunciationUrl) {
toggleAudioPronunciation(state, state.card!.audioPronunciationUrl);
}
}
// 快捷动作
function handleAction(type: PortalQuickAction): void {
if (!state.card) return;
let targetVal = '';
if (type === 'whatsapp') targetVal = state.card!.whatsapp || '';
if (type === 'linkedin') targetVal = state.card!.linkedin || '';
if (type === 'email') targetVal = state.card!.email || '';
if (type === 'phone') targetVal = state.card!.phone || '';
if (type === 'telegram') targetVal = state.card!.telegram || '';
if (type === 'twitter') targetVal = state.card!.twitter || '';
if (type === 'wechat') targetVal = state.card!.wechat || '';
triggerQuickAction(type, targetVal);
}
// 公司反向链接点击
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: currentLang.value === 'zh-CN' ? '已收入大厅' : 'Saved to hall',
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 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}`,
});
}
// 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(state.card as any);
}
// 重新加载
function reloadPortal(): void {
if (currentCardId.value) {
loadCardPortal(state, currentCardId.value, state.touchType);
}
}
onLoad((options: any) => {
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) : '';
currentCardId.value = queryId;
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 {
display: flex;
flex-direction: column;
height: 100%;
background-color: #F7F8FA;
}
.portal-rtl {
direction: rtl;
text-align: right;
}
.locale-bar {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 8px 16px 0 16px;
}
.locale-selector-container {
display: flex;
align-items: center;
}
.locale-chip {
min-width: 44px;
min-height: 44px;
padding: 8px 12px;
font-size: 13px;
color: #0B5CFF;
display: flex;
align-items: center;
justify-content: center;
}
.locale-chip-active {
color: #0B5CFF;
font-weight: 600;
}
/* RTL 镜像适配 */
.portal-rtl .hero-card {
text-align: right;
}
.portal-rtl .hero-name-row {
flex-direction: row;
justify-content: flex-start;
}
.portal-rtl .hero-name {
margin-right: 0;
margin-left: 12px;
}
.portal-rtl .hero-company-row {
justify-content: flex-start;
}
.portal-rtl .hero-company-name {
margin-right: 0;
margin-left: 4px;
}
.portal-rtl .hero-company-arrow {
display: inline-block;
transform: scaleX(-1);
}
.portal-rtl .pronounce-icon {
margin-right: 0;
margin-left: 4px;
}
.portal-rtl .ai-entry {
right: auto;
left: 16px;
}
.portal-scroll {
flex: 1;
}
.portal-content {
display: flex;
flex-direction: column;
padding: 16px;
}
/* 品牌栏 */
.brand-bar {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.brand-logo {
height: 28px;
}
/* 骨架屏 */
.portal-skeleton {
padding: 24px 16px;
display: flex;
flex-direction: column;
}
.skeleton-avatar {
width: 72px;
height: 72px;
border-radius: 36px;
background-color: #E3E7ED;
margin-bottom: 16px;
}
.skeleton-line {
height: 16px;
background-color: #E3E7ED;
border-radius: 8px;
margin-bottom: 12px;
}
.line-title {
width: 60%;
height: 24px;
}
.line-subtitle {
width: 40%;
}
.skeleton-card {
height: 180px;
background-color: #E3E7ED;
border-radius: 16px;
margin-top: 16px;
}
/* 异常提示 */
.portal-error-card {
margin: 40px 16px;
padding: 24px;
background-color: #FFFFFF;
border: 1px solid #E3E7ED;
border-radius: 16px;
align-items: center;
display: flex;
flex-direction: column;
}
.error-title {
font-size: 16px;
font-weight: 600;
color: #101828;
margin-bottom: 8px;
}
.error-desc {
font-size: 14px;
color: #667085;
margin-bottom: 20px;
text-align: center;
}
.retry-btn {
height: 40px;
padding: 0 24px;
background-color: #0B5CFF;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.retry-btn-text {
color: #FFFFFF;
font-size: 14px;
font-weight: 500;
}
/* 1. Hero 身份卡片 */
.hero-card {
background-color: #FFFFFF;
border: 1px solid #E3E7ED;
border-radius: 16px;
padding: 24px 20px;
display: flex;
flex-direction: column;
margin-bottom: 16px;
}
.hero-top-row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.hero-avatar {
width: 72px;
height: 72px;
border-radius: 36px;
border: 1px solid #E3E7ED;
background-color: #F7F8FA;
}
.hero-timezone-pill {
padding: 4px 10px;
background-color: #F7F8FA;
border: 1px solid #E3E7ED;
border-radius: 12px;
}
.hero-timezone-text {
font-size: 12px;
color: #667085;
font-weight: 500;
}
.hero-name-row {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 6px;
}
.hero-name {
font-size: 24px;
font-weight: 700;
color: #101828;
margin-right: 12px;
}
.pronounce-pill {
display: flex;
flex-direction: row;
align-items: center;
padding: 4px 8px;
border-radius: 12px;
background-color: #EAF1FF;
}
.pronounce-pill-active {
background-color: #0B5CFF;
}
.pronounce-icon {
font-size: 10px;
color: #0B5CFF;
margin-right: 4px;
}
.pronounce-pill-active .pronounce-icon {
color: #FFFFFF;
}
.pronounce-label {
font-size: 12px;
color: #0B5CFF;
font-weight: 500;
}
.pronounce-pill-active .pronounce-label {
color: #FFFFFF;
}
.hero-title {
font-size: 15px;
color: #667085;
margin-bottom: 6px;
line-height: 1.4;
}
.hero-company-row {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 12px;
}
.hero-company-name {
font-size: 14px;
color: #101828;
font-weight: 600;
margin-right: 4px;
}
.hero-company-arrow {
font-size: 12px;
color: #0B5CFF;
}
.hero-slogan {
font-size: 13px;
color: #667085;
line-height: 1.5;
border-top: 1px solid #F7F8FA;
padding-top: 12px;
}
/* 2. 快捷动作条 (4列规整网格,触控区 >= 44x44) */
.quick-action-bar {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
margin-bottom: var(--oc-space-16, 16px);
}
.action-item {
width: calc(25% - 6px);
min-height: 56px;
background-color: var(--oc-bg, #FFFFFF);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-btn, 12px);
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);
}
.action-item:active {
opacity: 0.7;
}
.action-icon-box {
margin-bottom: 2px;
}
.action-icon-text {
font-size: 11px;
font-weight: 700;
color: var(--oc-primary, #0B5CFF);
letter-spacing: 0.5px;
}
.action-item-label {
font-size: 11px;
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-bg, #FFFFFF);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-card, 16px);
padding: var(--oc-space-16, 16px);
display: flex;
flex-direction: column;
box-sizing: border-box;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
transition: transform 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bento-card:active {
transform: scale(0.99);
}
.bento-col-1 {
width: calc(50% - 6px);
}
.bento-col-2 {
width: 100%;
}
.bento-tag {
align-self: flex-start;
padding: 2px 8px;
background-color: var(--oc-surface, #F7F8FA);
border-radius: 8px;
margin-bottom: 8px;
}
.bento-tag-text {
font-size: 11px;
color: var(--oc-text-2, #667085);
font-weight: 500;
}
.bento-cover {
width: 100%;
height: 120px;
border-radius: 8px;
margin-bottom: 10px;
background-color: var(--oc-surface, #F7F8FA);
}
.bento-title {
font-size: 15px;
font-weight: 600;
color: var(--oc-text, #101828);
margin-bottom: 6px;
line-height: 1.25;
}
.bento-content {
font-size: 13px;
color: var(--oc-text-2, #667085);
line-height: 1.6;
}
/* 底部垫高与常驻主 CTA (视口安全区 safe-area-inset-bottom 适配) */
.portal-bottom-spacer {
height: calc(140px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px));
}
.ai-entry {
position: fixed;
right: 16px;
bottom: calc(80px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px));
min-height: 44px;
min-width: 44px;
padding: 8px 14px;
color: var(--oc-ai, #7C5CFF);
background-color: var(--oc-bg, #FFFFFF);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: 22px;
box-shadow: 0 2px 8px rgba(124, 92, 255, 0.12);
font-size: 13px;
font-weight: 600;
text-align: center;
z-index: 99;
display: flex;
align-items: center;
justify-content: center;
}
.portal-cta-bar {
position: fixed;
left: 0;
right: 0;
bottom: var(--window-bottom, 0px);
padding: 12px var(--oc-space-16, 16px) calc(16px + env(safe-area-inset-bottom, 0px)) var(--oc-space-16, 16px);
background-color: var(--oc-bg, #FFFFFF);
border-top: 1px solid var(--oc-border, #E3E7ED);
box-sizing: border-box;
z-index: 100;
}
.primary-cta-btn {
height: 48px;
min-height: 44px;
background-color: var(--oc-primary, #0B5CFF);
border-radius: var(--oc-radius-btn, 12px);
display: flex;
align-items: center;
justify-content: center;
width: 100%;
border-width: 0;
transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.primary-cta-btn:active {
opacity: 0.85;
}
.primary-cta-btn-text {
color: var(--oc-bg, #FFFFFF);
font-size: 15px;
font-weight: 600;
}
</style>