refactor(i18n): 全工程面向国际用户与全球现代商务重构,彻底清理出海跨境等单向视角表达并对齐自动化测试

这个提交包含在:
wikiboss
2026-09-21 15:24:36 +08:00
父节点 ad9df7a7a4
当前提交 17882ca1cb
共有 135 个文件被更改,包括 12928 次插入 和 1653 次删除

查看文件

@@ -73,8 +73,8 @@
<style>
page {
background-color: #F7F8FA;
background-color: #F5F8FC;
color: #101828;
font-family: Inter, "Noto Sans SC", "Noto Sans Arabic", "Noto Sans JP", "Noto Sans KR", sans-serif;
font-family: Inter, "Noto Sans SC", "Noto Sans Arabic", "Noto Sans JP", "Noto Sans KR", "SF Pro Display", sans-serif;
}
</style>

查看文件

@@ -1,5 +1,5 @@
/**
* OmniCard AI (CRMmp) - 移动端 App / H5 出海名片 Universal Link 深度链接路由分发解析器
* OmniCard AI (CRMmp) - 移动端 App / H5 智能数字名片 Universal Link 深度链接路由分发解析器
* 严格遵循 AGENTS.md 与 uts 强类型跨端开发规范
*/

查看文件

@@ -5,8 +5,9 @@
export type UtsColorTokens = {
primary: string
primaryGradient: string
primarySoft: string
ai: string
aurora: string
bg: string
surface: string
border: string
@@ -18,28 +19,30 @@ export type UtsColorTokens = {
}
export const utsLightColors: UtsColorTokens = {
primary: '#0B5CFF',
primarySoft: '#EAF1FF',
ai: '#7C5CFF',
bg: '#FFFFFF',
surface: '#F7F8FA',
border: '#E3E7ED',
primary: '#0088FF',
primaryGradient: 'linear-gradient(135deg, #00A3FF 0%, #0B63E5 100%)',
primarySoft: '#EBF5FF',
aurora: 'linear-gradient(135deg, #00D2FF 0%, #0088FF 50%, #6366F1 100%)',
bg: '#F5F8FC',
surface: '#FFFFFF',
border: '#EBF1F7',
text: '#101828',
text2: '#667085',
text2: '#64748B',
success: '#12B76A',
warning: '#F79009',
error: '#F04438'
}
export const utsDarkColors: UtsColorTokens = {
primary: '#4D8DFF',
primarySoft: '#102A4D',
ai: '#9B8CFF',
primary: '#3894FF',
primaryGradient: 'linear-gradient(135deg, #3894FF 0%, #0B63E5 100%)',
primarySoft: '#0D2847',
aurora: 'linear-gradient(135deg, #00D2FF 0%, #0088FF 50%, #6366F1 100%)',
bg: '#0B0D10',
surface: '#12151A',
border: '#262B33',
surface: '#13171F',
border: '#232936',
text: '#F2F4F7',
text2: '#98A2B3',
text2: '#94A3B8',
success: '#32D583',
warning: '#FDB022',
error: '#F97066'
@@ -50,6 +53,7 @@ export type UtsSpacing = {
space8: string
space12: string
space16: string
space20: string
space24: string
space32: string
space48: string
@@ -61,6 +65,7 @@ export const utsSpacing: UtsSpacing = {
space8: '8px',
space12: '12px',
space16: '16px',
space20: '20px',
space24: '24px',
space32: '32px',
space48: '48px',
@@ -71,20 +76,28 @@ export type UtsRadius = {
button: string
card: string
modal: string
pill: string
}
export const utsRadius: UtsRadius = {
button: '12px',
card: '16px',
modal: '20px'
button: '14px',
card: '20px',
modal: '24px',
pill: '9999px'
}
export type UtsTypography = {
fontFamily: string
shadowCard: string
shadowCta: string
shadowFloat: string
shadow: string
}
export const utsTypography: UtsTypography = {
fontFamily: 'Inter, Noto Sans SC, Noto Sans Arabic, Noto Sans JP, Noto Sans KR, sans-serif',
shadow: '0 1px 2px rgba(16,24,40,0.04)'
fontFamily: 'Inter, "Noto Sans SC", "Noto Sans Arabic", "Noto Sans JP", "Noto Sans KR", "SF Pro Display", sans-serif',
shadowCard: '0 8px 30px rgba(0, 0, 0, 0.04), 0 2px 8px rgba(11, 99, 229, 0.03)',
shadowCta: '0 8px 20px rgba(0, 136, 255, 0.22)',
shadowFloat: '0 12px 36px rgba(16, 24, 40, 0.08)',
shadow: '0 8px 30px rgba(0, 0, 0, 0.04)'
}

查看文件

@@ -85,7 +85,7 @@ export function foldVCardLine(line: string, maxLen: number = 75): string {
}
/**
* 生成符合 RFC 6350 / RFC 2426 规范的 vCard 字符串 (默认 3.0,出海设备兼容性最高)
* 生成符合 RFC 6350 / RFC 2426 规范的 vCard 字符串 (默认 3.0,国际通用设备兼容性最高)
*/
export function buildVCardString(card: CardProfile, version: string = '3.0'): string {
const lines: string[] = [];

文件差异内容过多而无法显示 加载差异

查看文件

@@ -43,7 +43,9 @@ export type LoginCopyKey =
| 'privacyPolicy'
| 'and'
| 'countdownSuffix'
| 'loginSuccess';
| 'loginSuccess'
| 'moreWaysComing'
| 'loggingIn';
export type LoginState = {
tab: LoginTab;
@@ -86,7 +88,7 @@ export function resolveLoginText(key: LoginCopyKey | string, targetLocale?: stri
return zh ? '全球智能数字名片' : 'Global Digital Identity';
}
if (key === 'subtitle') {
return zh ? '极简出海商务,连接全球商机' : 'Minimalist global commerce & identity';
return zh ? '极简现代商务,连接全球商机' : 'Minimalist global commerce & identity';
}
if (key === 'tabPassword') {
return zh ? '账号密码' : 'Password';
@@ -130,6 +132,12 @@ export function resolveLoginText(key: LoginCopyKey | string, targetLocale?: stri
if (key === 'login') {
return zh ? '登录' : 'Sign in';
}
if (key === 'loggingIn') {
return zh ? '登录中' : 'Signing in';
}
if (key === 'moreWaysComing') {
return zh ? '更多方式即将开放' : 'More sign-in options coming soon';
}
if (key === 'magicLink') {
return zh ? '发送登录链接' : 'Send sign-in link';
}

查看文件

@@ -98,7 +98,7 @@
class="form-input"
type="text"
v-model="editForm.title"
placeholder="例如:全球智能硬件出海交付方案"
placeholder="例如:全球智能硬件数字化交付方案"
/>
</view>

文件差异内容过多而无法显示 加载差异

查看文件

@@ -15,7 +15,7 @@ import { getCurrentLocale, getOmniText } from '../../../common/locale.uts';
export const PRESET_CASE_METRICS: string[] = [
'+320% ROI',
'+150% 留资率',
'45k+ 海外线索',
'45k+ 国际线索',
'2M+ 全球曝光',
'Top 1 细分类目',
];

查看文件

@@ -1,8 +1,20 @@
<template>
<view class="hall-page" :style="pageTheme">
<view class="hall-page" :class="pageRtlClass" :style="pageTheme">
<!-- Header: Bento Hero & Action Control Pod -->
<view class="hall-header">
<text class="hall-title">{{ pageTitle }}</text>
<text class="hall-subtitle">{{ pageSubtitle }}</text>
<view class="hall-header-top">
<view class="hall-title-group">
<text class="hall-title">{{ pageTitle }}</text>
<text class="hall-subtitle">{{ pageSubtitle }}</text>
</view>
<view class="hall-counter-badge">
<view class="hall-counter-dot"></view>
<text class="hall-counter-num">{{ state.total }}</text>
<text class="hall-counter-unit">{{ countUnitText }}</text>
</view>
</view>
<!-- Bento Capsule Tabs (Contacts / Team / Event) -->
<view class="hall-tabs">
<view
class="hall-tab"
@@ -26,66 +38,138 @@
<text class="hall-tab-text">{{ t('tabEvent') }}</text>
</view>
</view>
<input
class="hall-search"
type="text"
v-model="state.keyWord"
:placeholder="searchPlaceholder"
:disabled="state.loading"
/>
<input
v-if="state.tab === 'contacts'"
class="hall-search"
type="number"
maxlength="6"
v-model="state.exchangeCode"
:placeholder="exchangePlaceholder"
:disabled="state.loading"
/>
<input
v-if="state.tab === 'event'"
class="hall-search"
type="text"
maxlength="6"
v-model="state.eventCode"
:placeholder="eventPlaceholder"
:disabled="state.loading"
/>
<!-- Global Filter Search Input -->
<view class="hall-search-box">
<view class="search-icon-mark"></view>
<input
class="hall-search-input field-input"
type="text"
v-model="state.keyWord"
:placeholder="searchPlaceholder"
:disabled="state.loading"
@confirm="handleSearchConfirm"
/>
</view>
<!-- Contextual Quick Action Pod: 6-Digit Exchange Code (Contacts Tab) -->
<view v-if="state.tab === 'contacts'" class="hall-context-pod">
<view class="pod-label-row">
<text class="pod-title">{{ exchangePodTitle }}</text>
<text class="pod-hint">{{ exchangePodHint }}</text>
</view>
<view class="pod-input-row">
<input
class="pod-input field-input pod-code-input"
type="number"
maxlength="6"
v-model="state.exchangeCode"
:placeholder="exchangePlaceholder"
:disabled="state.loading"
@confirm="handlePrimary"
/>
</view>
</view>
<!-- Contextual Quick Action Pod: 6-Char Event Floor Pass (Event Tab) -->
<view v-if="state.tab === 'event'" class="hall-context-pod pod-event">
<view class="pod-label-row">
<text class="pod-title">{{ eventPodTitle }}</text>
<text class="pod-hint">{{ eventPodHint }}</text>
</view>
<view class="pod-input-row">
<input
class="pod-input field-input pod-code-input"
type="text"
maxlength="6"
v-model="state.eventCode"
:placeholder="eventPlaceholder"
:disabled="state.loading"
@confirm="handlePrimary"
/>
</view>
</view>
</view>
<!-- Loading Skeleton Cards -->
<view v-if="state.loading" class="hall-skeleton">
<view class="skeleton-line"></view>
<view class="skeleton-card"></view>
<view class="skeleton-card"></view>
<view class="skeleton-card">
<view class="skeleton-avatar"></view>
<view class="skeleton-meta">
<view class="skeleton-line"></view>
<view class="skeleton-line skeleton-line-sub"></view>
</view>
</view>
<view class="skeleton-card">
<view class="skeleton-avatar"></view>
<view class="skeleton-meta">
<view class="skeleton-line"></view>
<view class="skeleton-line skeleton-line-sub"></view>
</view>
</view>
<view class="skeleton-card">
<view class="skeleton-avatar"></view>
<view class="skeleton-meta">
<view class="skeleton-line"></view>
<view class="skeleton-line skeleton-line-sub"></view>
</view>
</view>
</view>
<!-- Error State Feedback Card -->
<view v-else-if="state.errorMessage !== ''" class="hall-feedback">
<view class="feedback-halo error-halo">
<view class="feedback-halo-inner error-halo-inner"></view>
</view>
<text class="feedback-title">{{ t('error') }}</text>
<text class="feedback-desc">{{ state.errorMessage }}</text>
</view>
<!-- Empty State Feedback Card -->
<view v-else-if="state.empty" class="hall-feedback">
<view class="feedback-halo empty-halo">
<view class="feedback-halo-inner empty-halo-inner"></view>
</view>
<text class="feedback-title">{{ t('empty') }}</text>
<text class="feedback-desc">{{ emptyDesc }}</text>
</view>
<!-- Virtualized High-Performance Bento Contact List -->
<list-view v-else class="hall-list" :scroll-y="true">
<list-item v-for="item in state.list" :key="item.peerCardId">
<view class="hall-row" @click="handleOpenPortal(item)">
<view class="hall-main">
<text class="hall-name">{{ item.peerName }}</text>
<text class="hall-meta-text">{{ formatField(item.peerTitle) }}</text>
<text class="hall-meta-text">{{ formatField(item.peerCompany) }}</text>
<!-- Squircle Initials Avatar -->
<view class="hall-avatar">
<text class="hall-avatar-text">{{ getInitial(item.peerName) }}</text>
</view>
<!-- Contact Details -->
<view class="hall-main">
<view class="hall-name-row">
<text class="hall-name">{{ item.peerName }}</text>
<text v-if="formatSourceTag(item.source) !== ''" class="hall-source-tag">
{{ formatSourceTag(item.source) }}
</text>
</view>
<text v-if="formatField(item.peerTitle) !== ''" class="hall-meta-text">
{{ formatField(item.peerTitle) }}
</text>
<text v-if="formatField(item.peerCompany) !== ''" class="hall-meta-text hall-meta-company">
{{ formatField(item.peerCompany) }}
</text>
</view>
<!-- Forward Chevron Arrow -->
<view class="hall-arrow"></view>
</view>
</list-item>
</list-view>
<!-- Fixed Floating Main CTA Bar -->
<view class="hall-cta-bar">
<button
class="primary-cta-btn"
:class="{ 'primary-cta-btn-disabled': state.loading }"
:disabled="state.loading"
:aria-label="ctaLabel"
@click="handlePrimary"
>
<text class="primary-cta-btn-text">{{ ctaLabel }}</text>
@@ -95,7 +179,8 @@
</template>
<script setup lang="uts">
import { utsLightColors, utsRadius, utsSpacing } from '../../../common/tokens.uts';
import { getCurrentLocale, isRtl } from '../../../common/locale.uts';
import { utsLightColors, utsRadius, utsSpacing, utsTypography } from '../../../common/tokens.uts';
import {
applyHallTab,
createHallState,
@@ -117,8 +202,16 @@ const pageTheme: Record<string, string> = {
'--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': utsTypography.shadow,
'--oc-shadow-card': utsTypography.shadowCard,
'--oc-shadow-cta': utsTypography.shadowCta,
'--oc-space-8': utsSpacing.space8,
'--oc-space-12': utsSpacing.space12,
'--oc-space-16': utsSpacing.space16,
@@ -126,11 +219,39 @@ const pageTheme: Record<string, string> = {
};
const state = reactive(createHallState());
const pageTitle = ref(resolveHallText('title'));
const pageSubtitle = ref(resolveHallText('subtitle'));
const searchPlaceholder = ref(resolveHallText('searchPlaceholder'));
const exchangePlaceholder = ref(resolveHallText('exchangePlaceholder'));
const eventPlaceholder = ref(resolveHallText('eventPlaceholder'));
const pageTitle = computed((): string => resolveHallText('title'));
const pageSubtitle = computed((): string => resolveHallText('subtitle'));
const searchPlaceholder = computed((): string => resolveHallText('searchPlaceholder'));
const exchangePlaceholder = computed((): string => resolveHallText('exchangePlaceholder'));
const eventPlaceholder = computed((): string => resolveHallText('eventPlaceholder'));
const isZh = computed((): boolean => {
return getCurrentLocale() === 'zh-CN';
});
const countUnitText = computed((): string => {
return isZh.value ? '人' : 'cards';
});
const exchangePodTitle = computed((): string => {
return isZh.value ? '面对面极速互换' : 'Quick Exchange';
});
const exchangePodHint = computed((): string => {
return isZh.value ? '输入 6 位数字码' : '6-digit offline code';
});
const eventPodTitle = computed((): string => {
return isZh.value ? '展会同场' : 'Floor Pass';
});
const eventPodHint = computed((): string => {
return isZh.value ? '输入 6 位活动码' : '6-char event code';
});
const pageRtlClass = computed((): string => {
return isRtl(getCurrentLocale()) ? 'hall-rtl' : '';
});
const emptyDesc = computed((): string => {
if (state.tab === 'team') {
@@ -166,6 +287,34 @@ function formatField(value: string | null | undefined): string {
return value;
}
function getInitial(name: string): string {
const trimmed = name.trim();
if (trimmed.length === 0) {
return 'O';
}
return trimmed.substring(0, 1).toUpperCase();
}
function formatSourceTag(source: string | null | undefined): string {
if (source == null || source === '') {
return '';
}
const zh = isZh.value;
if (source === 'exchange') {
return zh ? '互换' : 'Exchange';
}
if (source === 'event') {
return zh ? '现场' : 'Floor';
}
if (source === 'team') {
return zh ? '团队' : 'Team';
}
if (source === 'collect') {
return zh ? '收藏' : 'Saved';
}
return source;
}
async function handleTab(tab: HallTab): Promise<void> {
applyHallTab(state, tab);
await refreshHall(state);
@@ -175,6 +324,10 @@ function handleOpenPortal(item: HallContactItem): void {
openHallPortal(item);
}
async function handleSearchConfirm(): Promise<void> {
await refreshHall(state);
}
async function handlePrimary(): Promise<void> {
if (state.tab === 'event' && state.eventCode.trim() !== '') {
await submitHallEventJoin(state);
@@ -197,7 +350,11 @@ onShow(() => {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--oc-surface, #F7F8FA);
background-color: var(--oc-bg, #F5F8FC);
}
.hall-rtl {
direction: rtl;
}
.hall-header {
@@ -206,8 +363,24 @@ onShow(() => {
padding-inline: var(--oc-space-16, 16px);
padding-block-start: var(--oc-space-16, 16px);
padding-block-end: var(--oc-space-12, 12px);
background-color: var(--oc-bg, #FFFFFF);
border-bottom: 1px solid var(--oc-border, #E3E7ED);
background-color: var(--oc-surface, #FFFFFF);
border-bottom: 1px solid var(--oc-border, #EBF1F7);
box-shadow: 0 2px 8px rgba(11, 99, 229, 0.02);
}
.hall-header-top {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
margin-block-end: var(--oc-space-12, 12px);
}
.hall-title-group {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.hall-title {
@@ -215,106 +388,303 @@ onShow(() => {
font-weight: 700;
color: var(--oc-text, #101828);
line-height: 1.25;
margin-bottom: var(--oc-space-8, 8px);
margin-block-end: 4px;
}
.hall-subtitle {
font-size: 14px;
color: var(--oc-text-2, #667085);
line-height: 1.6;
margin-bottom: var(--oc-space-12, 12px);
font-size: 13px;
color: var(--oc-text-2, #64748B);
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
lines: 1;
}
.hall-counter-badge {
display: flex;
flex-direction: row;
align-items: center;
background-color: var(--oc-primary-soft, #EBF5FF);
padding-inline: 10px;
padding-block: 5px;
border-radius: 9999px;
margin-inline-start: 12px;
flex-shrink: 0;
}
.hall-counter-dot {
width: 6px;
height: 6px;
border-radius: 3px;
background-color: var(--oc-primary, #0B5CFF);
margin-inline-end: 6px;
}
.hall-counter-num {
font-size: 13px;
font-weight: 700;
color: var(--oc-primary, #0B5CFF);
margin-inline-end: 2px;
}
.hall-counter-unit {
font-size: 11px;
color: var(--oc-text-2, #64748B);
}
.hall-tabs {
display: flex;
flex-direction: row;
margin-bottom: var(--oc-space-12, 12px);
flex-wrap: nowrap;
margin-block-end: var(--oc-space-8, 8px);
gap: 8px;
}
.hall-tab {
height: 44px;
min-height: 44px;
min-width: 44px;
padding-inline: 16px;
margin-inline-end: 8px;
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: 12px;
background-color: var(--oc-bg, #FFFFFF);
border: 1px solid var(--oc-border, #EBF1F7);
border-radius: var(--oc-radius-btn, 14px);
background-color: var(--oc-bg, #F5F8FC);
display: flex;
align-items: center;
justify-content: center;
flex: 1;
transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.hall-tab:active {
opacity: 0.85;
}
.hall-tab-active {
background-color: var(--oc-primary-soft, #EAF1FF);
background-color: var(--oc-primary-soft, #EBF5FF);
border-color: var(--oc-primary, #0B5CFF);
}
.hall-tab-text {
font-size: 13px;
font-size: 14px;
font-weight: 600;
color: var(--oc-text-2, #667085);
color: var(--oc-text-2, #64748B);
}
.hall-tab-active .hall-tab-text {
color: var(--oc-primary, #0B5CFF);
}
.hall-search {
.hall-search-box {
height: 44px;
padding-inline: 12px;
margin-bottom: var(--oc-space-12, 12px);
background-color: var(--oc-surface, #F7F8FA);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-btn, 12px);
min-height: 44px;
padding-inline: 14px;
background-color: var(--oc-bg, #F5F8FC);
border: 1px solid var(--oc-border, #EBF1F7);
border-radius: var(--oc-radius-btn, 14px);
display: flex;
flex-direction: row;
align-items: center;
box-sizing: border-box;
}
.search-icon-mark {
width: 14px;
height: 14px;
border: 2px solid var(--oc-text-2, #64748B);
border-radius: 7px;
margin-inline-end: 8px;
flex-shrink: 0;
opacity: 0.65;
}
.hall-search-input {
flex: 1;
height: 100%;
font-size: 16px;
color: var(--oc-text, #101828);
background-color: transparent;
border: none;
}
.hall-context-pod {
margin-block-start: 10px;
padding: 12px 14px;
border-radius: 16px;
background: linear-gradient(135deg, #F0F7FF 0%, #F8FAFD 100%);
border: 1px solid rgba(0, 136, 255, 0.16);
display: flex;
flex-direction: column;
}
.pod-event {
background: linear-gradient(135deg, #F5F3FF 0%, #FAF8FD 100%);
border-color: rgba(99, 102, 241, 0.2);
}
.pod-label-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-block-end: 8px;
}
.pod-title {
font-size: 12px;
font-weight: 700;
color: var(--oc-primary, #0B5CFF);
letter-spacing: 0.2px;
}
.pod-event .pod-title {
color: #6366F1;
}
.pod-hint {
font-size: 11px;
color: var(--oc-text-2, #64748B);
}
.pod-input-row {
display: flex;
flex-direction: row;
align-items: center;
}
.pod-input {
height: 44px;
min-height: 44px;
flex: 1;
padding-inline: 12px;
background-color: var(--oc-surface, #FFFFFF);
border: 1px solid rgba(0, 136, 255, 0.25);
border-radius: 10px;
font-size: 16px;
font-weight: 600;
color: var(--oc-text, #101828);
box-sizing: border-box;
}
.pod-event .pod-input {
border-color: rgba(99, 102, 241, 0.3);
}
.pod-code-input {
letter-spacing: 2px;
}
.hall-skeleton {
flex: 1;
padding-inline: var(--oc-space-16, 16px);
padding-block: var(--oc-space-16, 16px);
}
.skeleton-line {
height: 16px;
width: 40%;
background-color: var(--oc-border, #E3E7ED);
border-radius: 8px;
margin-bottom: var(--oc-space-12, 12px);
padding-block-start: var(--oc-space-16, 16px);
padding-block-end: calc(88px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px));
}
.skeleton-card {
height: 88px;
background-color: var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-card, 16px);
margin-bottom: var(--oc-space-12, 12px);
min-height: 72px;
padding-inline: var(--oc-space-16, 16px);
background-color: var(--oc-surface, #FFFFFF);
border: 1px solid var(--oc-border, #EBF1F7);
border-radius: var(--oc-radius-card, 20px);
margin-block-end: 10px;
display: flex;
flex-direction: row;
align-items: center;
box-sizing: border-box;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.02);
}
.skeleton-avatar {
width: 40px;
height: 40px;
border-radius: 20px;
background-color: var(--oc-border, #EBF1F7);
flex-shrink: 0;
margin-inline-end: 12px;
}
.skeleton-meta {
display: flex;
flex-direction: column;
flex: 1;
}
.skeleton-line {
height: 14px;
width: 65%;
background-color: var(--oc-border, #EBF1F7);
border-radius: 8px;
margin-block-end: 8px;
}
.skeleton-line-sub {
width: 40%;
margin-block-end: 0;
}
.hall-feedback {
flex: 1;
margin: 40px var(--oc-space-16, 16px);
padding: var(--oc-space-24, 24px);
background-color: var(--oc-bg, #FFFFFF);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-card, 16px);
margin-inline: var(--oc-space-16, 16px);
margin-block-start: 32px;
margin-block-end: calc(88px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px));
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));
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.feedback-halo {
width: 60px;
height: 60px;
border-radius: 30px;
display: flex;
align-items: center;
justify-content: center;
margin-block-end: 16px;
}
.empty-halo {
background: linear-gradient(135deg, rgba(0, 136, 255, 0.12) 0%, rgba(99, 102, 241, 0.08) 100%);
}
.empty-halo-inner {
width: 28px;
height: 28px;
border-radius: 14px;
background-color: rgba(0, 136, 255, 0.2);
}
.error-halo {
background: linear-gradient(135deg, rgba(240, 68, 56, 0.12) 0%, rgba(247, 144, 9, 0.08) 100%);
}
.error-halo-inner {
width: 28px;
height: 28px;
border-radius: 14px;
background-color: rgba(240, 68, 56, 0.22);
}
.feedback-title {
font-size: 16px;
font-weight: 600;
color: var(--oc-text, #101828);
margin-bottom: var(--oc-space-8, 8px);
margin-block-end: var(--oc-space-8, 8px);
}
.feedback-desc {
font-size: 14px;
color: var(--oc-text-2, #667085);
color: var(--oc-text-2, #64748B);
text-align: center;
line-height: 1.6;
max-width: 280px;
}
.hall-list {
@@ -326,34 +696,107 @@ onShow(() => {
.hall-row {
min-height: 88px;
padding: var(--oc-space-16, 16px);
margin-bottom: var(--oc-space-12, 12px);
background-color: var(--oc-bg, #FFFFFF);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-card, 16px);
padding: 14px 16px;
margin-block-end: 10px;
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), 0 2px 8px rgba(11, 99, 229, 0.03));
display: flex;
flex-direction: row;
align-items: center;
transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.hall-row:active {
opacity: 0.88;
}
.hall-avatar {
width: 40px;
height: 40px;
border-radius: 20px;
background: linear-gradient(135deg, #EBF5FF 0%, #D8EBFF 100%);
border: 1px solid rgba(0, 136, 255, 0.15);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-inline-end: 12px;
}
.hall-avatar-text {
font-size: 16px;
font-weight: 700;
color: var(--oc-primary, #0B5CFF);
line-height: 1;
}
.hall-main {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.hall-name-row {
display: flex;
flex-direction: row;
align-items: center;
margin-block-end: 2px;
}
.hall-name {
font-size: 16px;
font-weight: 600;
color: var(--oc-text, #101828);
line-height: 1.25;
margin-bottom: 4px;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
lines: 1;
}
.hall-source-tag {
font-size: 10px;
font-weight: 600;
color: var(--oc-primary, #0B5CFF);
background-color: var(--oc-primary-soft, #EBF5FF);
padding-inline: 6px;
padding-block: 2px;
border-radius: 9999px;
margin-inline-start: 6px;
flex-shrink: 0;
}
.hall-meta-text {
font-size: 14px;
color: var(--oc-text-2, #667085);
line-height: 1.6;
font-size: 13px;
color: var(--oc-text-2, #64748B);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
lines: 1;
margin-block-start: 2px;
}
.hall-meta-company {
color: #475467;
}
.hall-arrow {
width: 8px;
height: 8px;
border-top: 2px solid #94A3B8;
border-right: 2px solid #94A3B8;
transform: rotate(45deg);
margin-inline-start: 8px;
flex-shrink: 0;
opacity: 0.6;
}
.hall-rtl .hall-arrow {
transform: rotate(-135deg);
}
.hall-cta-bar {
@@ -361,36 +804,56 @@ onShow(() => {
left: 0;
right: 0;
bottom: var(--window-bottom, 0px);
padding: 12px var(--oc-space-16, 16px) calc(12px + 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);
padding-block-start: 12px;
padding-inline: 16px;
padding-block-end: calc(16px + env(safe-area-inset-bottom, 0px));
background-color: var(--oc-surface, #FFFFFF);
border-top: 1px solid var(--oc-border, #EBF1F7);
box-sizing: border-box;
z-index: 100;
box-shadow: 0 -4px 16px rgba(16, 24, 40, 0.04);
}
.primary-cta-btn {
height: 48px;
min-height: 44px;
width: 100%;
border-width: 0;
border-radius: var(--oc-radius-btn, 14px);
background: linear-gradient(135deg, #00A3FF 0%, #0B63E5 100%);
background-color: var(--oc-primary, #0B5CFF);
border-radius: var(--oc-radius-btn, 12px);
box-shadow: var(--oc-shadow-cta, 0 8px 20px rgba(0, 136, 255, 0.22));
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.88;
}
.primary-cta-btn-disabled {
background-color: var(--oc-border, #E3E7ED);
background: var(--oc-border, #EBF1F7);
background-color: var(--oc-border, #EBF1F7);
box-shadow: none;
}
.primary-cta-btn-text {
color: var(--oc-bg, #FFFFFF);
font-size: 15px;
color: #FFFFFF;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.3px;
}
.primary-cta-btn-disabled .primary-cta-btn-text {
color: var(--oc-text-2, #667085);
color: var(--oc-text-2, #64748B);
}
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}
</style>

文件差异内容过多而无法显示 加载差异

文件差异内容过多而无法显示 加载差异

文件差异内容过多而无法显示 加载差异

查看文件

@@ -51,7 +51,27 @@ export type SecurityCopyKey =
| 'errorAlreadyFrozen'
| 'errorNotFrozen'
| 'freezeSuccess'
| 'unfreezeSuccess';
| 'unfreezeSuccess'
| 'submitting'
| 'operationError'
| 'shieldTitleActive'
| 'shieldDescActive'
| 'shieldTitleFrozen'
| 'shieldDescFrozen'
| 'chipDetailsTitle'
| 'securityHashLabel'
| 'materialLabel'
| 'protocolLabel'
| 'policyPoint1'
| 'policyPoint2'
| 'policyPoint3'
| 'confirmFreezeTitle'
| 'confirmFreezeDesc'
| 'confirmUnfreezeTitle'
| 'confirmUnfreezeDesc'
| 'confirmBtn'
| 'cancelBtn'
| 'cardListTitle';
const DEFAULT_SALT = 'OMNICARD_SECURE_SALT_2026';
@@ -306,6 +326,60 @@ export function resolveSecurityText(key: SecurityCopyKey): string {
return isZh ? '设备已成功远程挂失冻结' : 'Card frozen successfully';
case 'unfreezeSuccess':
return isZh ? '设备已成功解冻恢复' : 'Card unfrozen successfully';
case 'submitting':
return isZh ? '处理中...' : 'Processing...';
case 'operationError':
return isZh ? '操作异常' : 'Something went wrong';
case 'shieldTitleActive':
return isZh ? '芯片防盗与云端防护运行中' : 'Cloud & Chip Protection Active';
case 'shieldDescActive':
return isZh
? '物理近场触碰正常联通,触碰即可即时唤起您的专属微官网。'
: 'Physical tap-to-share is active and securely linked.';
case 'shieldTitleFrozen':
return isZh ? '物理芯片已远程挂失冻结' : 'Hardware Frozen & Locked';
case 'shieldDescFrozen':
return isZh
? '已切断物理触碰与数据传输,微官网与访客雷达资产依然安全隔离。'
: 'Physical tap-to-share is blocked. Cloud portal data remains safe.';
case 'chipDetailsTitle':
return isZh ? '芯片防伪与技术参数' : 'Chip Authenticity & Technical Specs';
case 'securityHashLabel':
return isZh ? '防伪安全哈希' : 'Security Hash';
case 'materialLabel':
return isZh ? '物理材质' : 'Material';
case 'protocolLabel':
return isZh ? '通信协议' : 'Protocol';
case 'policyPoint1':
return isZh
? '立即阻断近场触碰响应,拾获者无法获取名片信息或盗用身份'
: 'Instantly halts physical tap-to-share to prevent identity theft';
case 'policyPoint2':
return isZh
? '云端微官网、商业资料与雷达轨迹数据不受影响,完全安全留存'
: 'Cloud portal data, assets, and radar traces remain intact';
case 'policyPoint3':
return isZh
? '在展会或出差行李中寻回实体卡后,可在此秒级一键解冻恢复'
: 'One-click remote unfreeze anytime without needing a replacement card';
case 'confirmFreezeTitle':
return isZh ? '确认远程挂失此实体卡?' : 'Freeze this physical card?';
case 'confirmFreezeDesc':
return isZh
? '挂失将立即阻断该实体卡的一切近场触碰功能,拾获者将无法读取任何数据。'
: 'Freezing will immediately halt all touch responses for this physical card.';
case 'confirmUnfreezeTitle':
return isZh ? '确认解除挂失恢复?' : 'Unfreeze and restore card?';
case 'confirmUnfreezeDesc':
return isZh
? '解除挂失后,该实体卡将立即恢复触碰唤起微官网与交换名片功能。'
: 'Unfreezing will restore tap-to-share and portal access immediately.';
case 'confirmBtn':
return isZh ? '确认执行' : 'Confirm';
case 'cancelBtn':
return isZh ? '取消' : 'Cancel';
case 'cardListTitle':
return isZh ? '名下 NFC 硬件资产' : 'Registered NFC Cards';
default:
return '';
}

查看文件

@@ -404,7 +404,7 @@ export function resolveProfileText(key: string): string {
case 'radarPushTitle':
return isZh ? '雷达即时推送' : 'Radar Push';
case 'radarPushDesc':
return isZh ? '海外客商访问名片时立即通知' : 'Instant alert when visitors view card';
return isZh ? '国际访客访问名片时立即通知' : 'Instant alert when visitors view card';
case 'leadNoticeTitle':
return isZh ? '新线索提醒' : 'Lead Notice';
case 'leadNoticeDesc':

查看文件

@@ -1,10 +1,18 @@
<template>
<view class="radar-page" :style="pageTheme">
<view class="radar-page" :class="pageRtlClass" :style="pageTheme">
<view class="radar-header">
<text class="radar-title">{{ pageTitle }}</text>
<text class="radar-desc">{{ pageDesc }}</text>
<view class="radar-header-top">
<text class="radar-title">{{ pageTitle }}</text>
<view class="radar-live-badge">
<view class="radar-live-dot"></view>
<text class="radar-live-text">{{ liveStatusText }}</text>
</view>
</view>
<view class="field">
<view
class="field"
:class="{ 'field-hidden': !(state.cardIdRaw === '' || keepCardIdField) }"
>
<text class="field-label">{{ t('cardIdLabel') }}</text>
<input
class="field-input"
@@ -14,26 +22,167 @@
:disabled="state.loading"
/>
</view>
<!-- Bento Grid Overview Cards -->
<view class="bento-overview">
<view class="bento-row">
<view class="bento-card bento-card-primary">
<view class="bento-card-header">
<text class="bento-card-label">{{ t('totalVisits') }}</text>
<view class="bento-spark-pulse">
<view class="bento-spark-dot"></view>
</view>
</view>
<text class="bento-card-value">{{ stats.totalVisits }}</text>
<view class="bento-card-footer">
<text class="bento-sub-badge">{{ stats.topLocation }}</text>
</view>
</view>
<view class="bento-card bento-card-secondary">
<view class="bento-card-header">
<text class="bento-card-label">{{ t('intentRate') }}</text>
<view class="bento-badge-success">
<text class="bento-badge-text">{{ t('highIntent') }}</text>
</view>
</view>
<text class="bento-card-value text-accent">{{ stats.intentRateText }}</text>
<view class="bento-card-footer">
<text class="bento-sub-text">{{ stats.highIntentCount }} {{ t('activeNow') }}</text>
</view>
</view>
</view>
<view class="bento-card bento-card-wide">
<view class="bento-wide-header">
<view class="bento-wide-title-group">
<text class="bento-wide-title">{{ t('intentTrend') }}</text>
<text class="bento-wide-subtitle">{{ stats.avgStayText }} {{ t('stay') }}</text>
</view>
<text class="bento-wide-badge">{{ t('liveRadar') }}</text>
</view>
<view class="trend-bars-container">
<view class="trend-col">
<view class="trend-bar-track">
<view class="trend-bar-fill" :style="{ height: trendHeights[0] + '%' }"></view>
</view>
<text class="trend-col-label">1</text>
</view>
<view class="trend-col">
<view class="trend-bar-track">
<view class="trend-bar-fill" :style="{ height: trendHeights[1] + '%' }"></view>
</view>
<text class="trend-col-label">2</text>
</view>
<view class="trend-col">
<view class="trend-bar-track">
<view class="trend-bar-fill" :style="{ height: trendHeights[2] + '%' }"></view>
</view>
<text class="trend-col-label">3</text>
</view>
<view class="trend-col">
<view class="trend-bar-track">
<view class="trend-bar-fill" :style="{ height: trendHeights[3] + '%' }"></view>
</view>
<text class="trend-col-label">4</text>
</view>
<view class="trend-col">
<view class="trend-bar-track">
<view class="trend-bar-fill" :style="{ height: trendHeights[4] + '%' }"></view>
</view>
<text class="trend-col-label">5</text>
</view>
<view class="trend-col">
<view class="trend-bar-track">
<view class="trend-bar-fill" :style="{ height: trendHeights[5] + '%' }"></view>
</view>
<text class="trend-col-label">6</text>
</view>
<view class="trend-col">
<view class="trend-bar-track">
<view class="trend-bar-fill" :style="{ height: trendHeights[6] + '%' }"></view>
</view>
<text class="trend-col-label">7</text>
</view>
</view>
</view>
</view>
<view class="radar-timeline-header">
<text class="radar-desc">{{ pageDesc }}</text>
<text class="radar-count-pill">{{ state.tracks.length }}</text>
</view>
</view>
<list-view class="radar-list">
<list-item v-if="state.empty" class="radar-empty-item">
<view class="radar-empty">
<text class="radar-empty-text">{{ emptyLine }}</text>
</view>
</list-item>
<!-- Loading Skeleton -->
<view v-if="state.loading" class="radar-skeleton">
<view class="skeleton-line"></view>
<view class="skeleton-card"></view>
<view class="skeleton-card"></view>
</view>
<!-- Error Feedback -->
<view v-else-if="state.errorMessage !== ''" class="radar-feedback">
<text class="feedback-title">{{ t('error') }}</text>
<text class="feedback-desc">{{ state.errorMessage }}</text>
</view>
<!-- Empty State -->
<view v-else-if="state.empty" class="radar-feedback">
<view class="empty-halo"></view>
<text class="feedback-title">{{ t('empty') }}</text>
<text class="feedback-desc">{{ t('emptyHint') }}</text>
</view>
<!-- Real-time Activity Feed List -->
<list-view v-else class="radar-list" :scroll-y="true">
<list-item v-for="item in state.tracks" :key="item.id" class="radar-row-item">
<view class="radar-row">
<text class="radar-place">{{ formatLocation(item.country, item.city) }}</text>
<text class="radar-touch">{{ item.touchType }}</text>
<view class="radar-metrics">
<text class="radar-metric">{{ t('stay') }} {{ formatStay(item.stayDurationMs) }}</text>
<text class="radar-metric">{{ t('depth') }} {{ formatDepth(item.scrollDepth) }}</text>
<view
class="radar-row"
:class="{
'radar-row-hot': isHighIntent(item.stayDurationMs, item.scrollDepth),
'radar-row-amber': isMediumIntent(item.stayDurationMs, item.scrollDepth),
}"
>
<view class="radar-card-top">
<view class="touch-badge" :class="'touch-' + item.touchType">
<text class="touch-badge-text">{{ formatTouchTag(item.touchType) }}</text>
</view>
<view class="radar-headline-group">
<text class="radar-headline">{{ formatHeadline(item) }}</text>
<text class="radar-time">{{ formatTime(item.createdAt) }}</text>
</view>
<view class="intent-pill" :class="'intent-' + resolveIntentLevel(item)">
<text class="intent-text">{{ resolveIntentBadge(item) }}</text>
</view>
</view>
<view class="radar-card-body">
<view class="radar-tags-wrap">
<view v-if="formatLocation(item.country, item.city) !== ''" class="radar-geo-pill">
<text class="radar-geo-text">{{ formatLocation(item.country, item.city) }}</text>
</view>
<view class="radar-metric-chip">
<text class="chip-label">{{ t('stay') }}</text>
<text class="chip-val">{{ formatStay(item.stayDurationMs) }}</text>
</view>
<view class="radar-metric-chip">
<text class="chip-label">{{ t('depth') }}</text>
<text class="chip-val">{{ formatDepth(item.scrollDepth) }}</text>
</view>
</view>
<view v-if="hasBlocks(item)" class="radar-blocks-row">
<text class="blocks-label">{{ t('blocksViewed') }}:</text>
<text class="blocks-content">{{ formatBlocks(item.viewedBlocks) }}</text>
</view>
</view>
</view>
</list-item>
</list-view>
<!-- Bottom Floating CTA -->
<view class="radar-cta-bar">
<button
class="primary-cta-btn"
@@ -48,18 +197,30 @@
</template>
<script setup lang="uts">
import { getCurrentLocale, getOmniText } from '../../common/locale.uts';
import { utsLightColors, utsRadius, utsSpacing } from '../../common/tokens.uts';
import { getCurrentLocale, getOmniText, isRtl } from '../../common/locale.uts';
import { utsLightColors, utsRadius, utsSpacing, utsTypography } from '../../common/tokens.uts';
import { RadarTrack } from '../../common/types.uts';
import {
applyRadarOptions,
computeRadarStats,
computeTrendHeights,
createRadarState,
formatBlocksList,
formatDepth,
formatLocation,
formatRelativeTime,
formatStay,
formatTouchTag,
formatVisitorHeadline,
getIntentBadgeText,
getIntentLevel,
isHighIntent,
loadTracks,
refreshTracks,
resolveRadarText,
RadarCopyKey,
RadarPageOptions,
RadarStats,
} from './useRadar.uts';
const pageTheme: Record<string, string> = {
@@ -70,8 +231,13 @@ const pageTheme: Record<string, string> = {
'--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-shadow': utsTypography.shadow,
'--oc-space-8': utsSpacing.space8,
'--oc-space-12': utsSpacing.space12,
'--oc-space-16': utsSpacing.space16,
@@ -79,26 +245,68 @@ const pageTheme: Record<string, string> = {
};
const state = reactive(createRadarState());
const keepCardIdField = ref(false);
const pageTitle = ref(getOmniText(getCurrentLocale(), 'radar.online_visitor'));
const pageDesc = ref(getOmniText(getCurrentLocale(), 'radar.visitor_trace'));
const emptyLine = computed((): string => {
if (state.errorMessage !== '') {
return state.errorMessage;
}
return resolveRadarText('empty');
const pageRtlClass = computed((): string => {
return isRtl(getCurrentLocale()) ? 'radar-rtl' : '';
});
const stats = computed((): RadarStats => {
return computeRadarStats(state.tracks, state.total);
});
const trendHeights = computed((): number[] => {
return computeTrendHeights(state.tracks);
});
const liveStatusText = computed((): string => {
const locale = getCurrentLocale();
const activeWord = getOmniText(locale, 'radar.active_now');
return `${state.tracks.length} ${activeWord}`;
});
function t(key: RadarCopyKey): string {
return resolveRadarText(key);
}
function formatHeadline(item: RadarTrack): string {
return formatVisitorHeadline(item);
}
function formatTime(createdAt?: string | Date): string {
return formatRelativeTime(createdAt);
}
function resolveIntentLevel(item: RadarTrack): string {
return getIntentLevel(item.stayDurationMs, item.scrollDepth);
}
function isMediumIntent(stayDurationMs: number, scrollDepth: number): boolean {
return getIntentLevel(stayDurationMs, scrollDepth) === 'medium';
}
function resolveIntentBadge(item: RadarTrack): string {
const level = resolveIntentLevel(item);
return getIntentBadgeText(level);
}
function hasBlocks(item: RadarTrack): boolean {
return item.viewedBlocks != null && Array.isArray(item.viewedBlocks) && item.viewedBlocks.length > 0;
}
function formatBlocks(blocks?: string[] | null): string {
return formatBlocksList(blocks);
}
function handleRefresh(): void {
refreshTracks(state);
}
onLoad((options: RadarPageOptions) => {
applyRadarOptions(state, options);
keepCardIdField.value = state.cardIdRaw === '';
refreshTracks(state);
});
@@ -115,130 +323,609 @@ onShow(() => {
background-color: var(--oc-surface, #F7F8FA);
}
.radar-rtl {
direction: rtl;
}
.radar-header {
background-color: var(--oc-bg, #FFFFFF);
border-bottom: 1px solid var(--oc-border, #E3E7ED);
padding-inline: var(--oc-space-16, 16px);
padding-block-start: var(--oc-space-16, 16px);
padding-block-end: var(--oc-space-8, 8px);
padding-block-end: var(--oc-space-12, 12px);
}
.radar-header-top {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-block-end: var(--oc-space-12, 12px);
}
.radar-title {
font-size: 24px;
font-weight: 700;
color: var(--oc-text, #101828);
margin-bottom: var(--oc-space-8, 8px);
line-height: 1.25;
}
.radar-desc {
font-size: 14px;
color: var(--oc-text-2, #667085);
line-height: 1.6;
margin-bottom: var(--oc-space-16, 16px);
.radar-live-badge {
display: flex;
flex-direction: row;
align-items: center;
padding-inline: 10px;
padding-block: 4px;
background-color: #ECFDF3;
border-radius: 9999px;
border: 1px solid #A6F4C5;
}
.radar-live-dot {
width: 8px;
height: 8px;
border-radius: 4px;
background-color: var(--oc-success, #12B76A);
margin-inline-end: 6px;
}
.radar-live-text {
font-size: 12px;
font-weight: 600;
color: #027A48;
}
.field {
display: flex;
flex-direction: column;
margin-bottom: var(--oc-space-12, 12px);
margin-block-end: var(--oc-space-12, 12px);
}
.field-hidden {
display: none;
}
.field-label {
font-size: 14px;
font-size: 13px;
color: var(--oc-text, #101828);
font-weight: 600;
margin-bottom: var(--oc-space-8, 8px);
margin-block-end: 6px;
}
.field-input {
height: 44px;
min-height: 44px;
padding-inline: 12px;
background-color: var(--oc-bg, #FFFFFF);
background-color: var(--oc-surface, #F7F8FA);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-btn, 12px);
font-size: 16px;
color: var(--oc-text, #101828);
}
.radar-list {
flex: 1;
padding-inline: var(--oc-space-16, 16px);
}
.radar-empty-item {
padding-block: var(--oc-space-24, 24px);
}
.radar-empty {
min-height: 44px;
/* Bento Grid */
.bento-overview {
display: flex;
flex-direction: column;
gap: 10px;
margin-block-end: var(--oc-space-12, 12px);
}
.bento-row {
display: flex;
flex-direction: row;
gap: 10px;
}
.bento-card {
background-color: var(--oc-bg, #FFFFFF);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-card, 20px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.04);
padding: 14px;
box-sizing: border-box;
}
.bento-card-primary {
flex: 1;
background: linear-gradient(145deg, #FFFFFF 0%, #F5F9FF 100%);
display: flex;
flex-direction: column;
justify-content: space-between;
}
.bento-card-secondary {
flex: 1;
background: linear-gradient(145deg, #FFFFFF 0%, #F6FCF8 100%);
display: flex;
flex-direction: column;
justify-content: space-between;
}
.bento-card-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-block-end: 6px;
}
.bento-card-label {
font-size: 12px;
color: var(--oc-text-2, #667085);
font-weight: 500;
}
.bento-spark-pulse {
width: 14px;
height: 14px;
border-radius: 7px;
background-color: rgba(0, 136, 255, 0.12);
display: flex;
align-items: center;
justify-content: center;
}
.bento-spark-dot {
width: 6px;
height: 6px;
border-radius: 3px;
background-color: var(--oc-primary, #0088FF);
}
.bento-badge-success {
padding-inline: 6px;
padding-block: 2px;
border-radius: 9999px;
background-color: #ECFDF3;
}
.bento-badge-text {
font-size: 10px;
font-weight: 700;
color: #027A48;
}
.bento-card-value {
font-size: 26px;
font-weight: 800;
color: var(--oc-text, #101828);
line-height: 1.1;
margin-block-end: 6px;
}
.text-accent {
color: #0088FF;
}
.bento-card-footer {
display: flex;
flex-direction: row;
align-items: center;
}
.radar-empty-text {
font-size: 14px;
.bento-sub-badge {
font-size: 11px;
color: var(--oc-text-2, #667085);
background-color: rgba(16, 24, 40, 0.04);
padding-inline: 6px;
padding-block: 2px;
border-radius: 6px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bento-sub-text {
font-size: 11px;
color: var(--oc-text-2, #667085);
}
.bento-card-wide {
background-color: var(--oc-bg, #FFFFFF);
display: flex;
flex-direction: column;
}
.bento-wide-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-block-end: 12px;
}
.bento-wide-title-group {
display: flex;
flex-direction: column;
}
.bento-wide-title {
font-size: 13px;
font-weight: 600;
color: var(--oc-text, #101828);
}
.bento-wide-subtitle {
font-size: 11px;
color: var(--oc-text-2, #667085);
margin-top: 2px;
}
.bento-wide-badge {
font-size: 11px;
font-weight: 600;
color: #0088FF;
background-color: #EBF5FF;
padding-inline: 8px;
padding-block: 3px;
border-radius: 9999px;
}
.trend-bars-container {
display: flex;
flex-direction: row;
align-items: flex-end;
justify-content: space-between;
height: 56px;
padding-inline: 4px;
}
.trend-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
}
.trend-bar-track {
flex: 1;
width: 8px;
background-color: #F1F5F9;
border-radius: 4px;
display: flex;
flex-direction: column;
justify-content: flex-end;
overflow: hidden;
}
.trend-bar-fill {
width: 100%;
border-radius: 4px;
background-color: var(--oc-primary, #0B5CFF);
}
.trend-col-label {
font-size: 10px;
color: #94A3B8;
margin-top: 4px;
}
.radar-timeline-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-block-start: 4px;
}
.radar-desc {
font-size: 14px;
font-weight: 600;
color: var(--oc-text, #101828);
line-height: 1.6;
}
.radar-count-pill {
font-size: 12px;
font-weight: 600;
color: var(--oc-text-2, #667085);
background-color: #F1F5F9;
padding-inline: 8px;
padding-block: 2px;
border-radius: 9999px;
}
.radar-skeleton {
flex: 1;
padding-inline: var(--oc-space-16, 16px);
padding-block-start: var(--oc-space-16, 16px);
padding-block-end: calc(88px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px));
}
.skeleton-line {
height: 16px;
width: 40%;
background-color: var(--oc-border, #E3E7ED);
border-radius: 8px;
margin-block-end: var(--oc-space-12, 12px);
}
.skeleton-card {
height: 88px;
background-color: var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-card, 16px);
margin-block-end: var(--oc-space-12, 12px);
}
.radar-feedback {
flex: 1;
margin-inline: var(--oc-space-16, 16px);
margin-block: 36px;
padding: var(--oc-space-24, 24px);
background-color: var(--oc-bg, #FFFFFF);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-card, 16px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
align-items: center;
}
.empty-halo {
width: 44px;
height: 44px;
border-radius: 22px;
background: radial-gradient(circle, rgba(0, 136, 255, 0.15) 0%, rgba(0, 136, 255, 0.02) 70%);
margin-block-end: 12px;
}
.feedback-title {
font-size: 16px;
font-weight: 600;
color: var(--oc-text, #101828);
margin-block-end: var(--oc-space-8, 8px);
text-align: center;
line-height: 1.25;
}
.feedback-desc {
font-size: 14px;
color: var(--oc-text-2, #667085);
text-align: center;
line-height: 1.6;
}
.radar-list {
flex: 1;
padding-inline: var(--oc-space-16, 16px);
padding-block-start: var(--oc-space-12, 12px);
padding-block-end: calc(88px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px));
}
.radar-row-item {
margin-bottom: var(--oc-space-12, 12px);
margin-block-end: var(--oc-space-12, 12px);
}
.radar-row {
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);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.04);
padding: 14px;
display: flex;
flex-direction: column;
min-height: 44px;
}
.radar-place {
font-size: 16px;
font-weight: 600;
color: var(--oc-text, #101828);
line-height: 1.25;
margin-bottom: var(--oc-space-8, 8px);
.radar-row-hot {
border-inline-start-width: 4px;
border-inline-start-style: solid;
border-inline-start-color: #12B76A;
}
.radar-touch {
font-size: 14px;
color: var(--oc-text-2, #667085);
line-height: 1.6;
margin-bottom: var(--oc-space-8, 8px);
.radar-row-amber {
border-inline-start-width: 4px;
border-inline-start-style: solid;
border-inline-start-color: #F79009;
}
.radar-metrics {
.radar-card-top {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-block-end: 10px;
}
.radar-metric {
.touch-badge {
padding-inline: 8px;
height: 22px;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
margin-inline-end: 8px;
flex-shrink: 0;
}
.touch-nfc {
background-color: #F5F3FF;
border: 1px solid #DDD6FE;
}
.touch-nfc .touch-badge-text {
color: #7C3AED;
}
.touch-qr {
background-color: #EFF6FF;
border: 1px solid #BFDBFE;
}
.touch-qr .touch-badge-text {
color: #2563EB;
}
.touch-link {
background-color: #F0FDF4;
border: 1px solid #BBF7D0;
}
.touch-link .touch-badge-text {
color: #16A34A;
}
.touch-badge-text {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
}
.radar-headline-group {
flex: 1;
display: flex;
flex-direction: column;
margin-inline-end: 8px;
}
.radar-headline {
font-size: 14px;
font-weight: 600;
color: var(--oc-text, #101828);
line-height: 1.35;
}
.radar-time {
font-size: 11px;
color: var(--oc-text-2, #667085);
line-height: 1.6;
margin-top: 2px;
}
.intent-pill {
padding-inline: 8px;
padding-block: 3px;
border-radius: 9999px;
flex-shrink: 0;
}
.intent-high {
background-color: #ECFDF3;
border: 1px solid #A6F4C5;
}
.intent-high .intent-text {
color: #027A48;
}
.intent-medium {
background-color: #FFFAEB;
border: 1px solid #FEDF89;
}
.intent-medium .intent-text {
color: #B54708;
}
.intent-normal {
background-color: #F8FAFC;
border: 1px solid #E2E8F0;
}
.intent-normal .intent-text {
color: #64748B;
}
.intent-text {
font-size: 11px;
font-weight: 700;
}
.radar-card-body {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 4px;
}
.radar-tags-wrap {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.radar-geo-pill {
background-color: #F8FAFC;
border: 1px solid #E2E8F0;
border-radius: 6px;
padding-inline: 8px;
padding-block: 3px;
}
.radar-geo-text {
font-size: 12px;
font-weight: 600;
color: var(--oc-text, #101828);
}
.radar-metric-chip {
display: flex;
flex-direction: row;
align-items: center;
background-color: #F8FAFC;
border: 1px solid #E2E8F0;
border-radius: 6px;
padding-inline: 8px;
padding-block: 3px;
gap: 4px;
}
.chip-label {
font-size: 11px;
color: var(--oc-text-2, #667085);
}
.chip-val {
font-size: 11px;
font-weight: 600;
color: var(--oc-text, #101828);
}
.radar-blocks-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
}
.blocks-label {
font-size: 11px;
color: var(--oc-text-2, #667085);
}
.blocks-content {
font-size: 11px;
color: #0088FF;
font-weight: 500;
}
.radar-cta-bar {
padding: 12px var(--oc-space-16, 16px) calc(12px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px)) var(--oc-space-16, 16px);
position: fixed;
left: 0;
right: 0;
bottom: var(--window-bottom, 0px);
padding: 12px 16px calc(16px + env(safe-area-inset-bottom, 0px));
background-color: var(--oc-bg, #FFFFFF);
border-top: 1px solid var(--oc-border, #E3E7ED);
z-index: 100;
box-sizing: border-box;
}
.primary-cta-btn {
height: 48px;
min-height: 44px;
width: 100%;
border-width: 0;
border-radius: var(--oc-radius-btn, 14px);
background-color: var(--oc-primary, #0B5CFF);
border-radius: var(--oc-radius-btn, 12px);
box-shadow: 0 8px 20px rgba(0, 136, 255, 0.22);
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);
}
@@ -248,15 +935,23 @@ onShow(() => {
.primary-cta-btn-disabled {
background-color: var(--oc-border, #E3E7ED);
box-shadow: none;
}
.primary-cta-btn-text {
color: var(--oc-bg, #FFFFFF);
font-size: 15px;
color: #FFFFFF;
font-size: 16px;
font-weight: 600;
}
.primary-cta-btn-disabled .primary-cta-btn-text {
color: var(--oc-text-2, #667085);
}
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}
</style>

查看文件

@@ -31,7 +31,14 @@ export type OverviewCopyKey =
| 'error'
| 'loading'
| 'unknownCountry'
| 'rankTitle';
| 'rankTitle'
| 'liveBadge'
| 'syncTime'
| 'leadsUnit'
| 'touchesUnit'
| 'scoreUnit'
| 'championBadge'
| 'emptyLeads';
export type OverviewSummary = {
totalLeads: number;
@@ -129,6 +136,27 @@ export function resolveOverviewText(key: OverviewCopyKey): string {
if (key === 'rankTitle') {
return zh ? '现场排行' : 'Floor ranking';
}
if (key === 'liveBadge') {
return zh ? '实时展会战报' : 'LIVE EXPO';
}
if (key === 'syncTime') {
return zh ? '已同步' : 'Synced';
}
if (key === 'leadsUnit') {
return zh ? '转化线索' : 'Leads';
}
if (key === 'touchesUnit') {
return zh ? '交互足迹' : 'Touches';
}
if (key === 'scoreUnit') {
return zh ? '分' : 'pts';
}
if (key === 'championBadge') {
return zh ? '冠军' : 'TOP 1';
}
if (key === 'emptyLeads') {
return zh ? '暂无新留资线索,保持现场触碰' : 'No new leads yet, keep networking';
}
return '';
}

查看文件

@@ -34,7 +34,25 @@ export type LeadsCopyKey =
| 'loading'
| 'score'
| 'radarMore'
| 'hotBadge';
| 'hotBadge'
| 'hotTitle'
| 'highIntent'
| 'activeRadar'
| 'quickContact'
| 'call'
| 'email'
| 'sourceNfc'
| 'sourceQr'
| 'sourceLink'
| 'sourceAi'
| 'statTotal'
| 'statHot'
| 'statNew'
| 'statWon'
| 'teamBriefing'
| 'liveTracks'
| 'noCompany'
| 'noTracks';
export type LoadLeadsQuery = {
page: number;
@@ -163,6 +181,60 @@ export function resolveLeadsText(key: LeadsCopyKey): string {
if (key === 'hotBadge') {
return zh ? '高意向访客' : 'Hot visitors';
}
if (key === 'hotTitle') {
return zh ? '高意向商机' : 'Hot Leads';
}
if (key === 'highIntent') {
return zh ? '高意向' : 'High Intent';
}
if (key === 'activeRadar') {
return zh ? '活跃雷达' : 'Live Radar';
}
if (key === 'quickContact') {
return zh ? '快捷联系' : 'Quick Contact';
}
if (key === 'call') {
return zh ? '电话呼叫' : 'Call';
}
if (key === 'email') {
return zh ? '发送邮件' : 'Email';
}
if (key === 'sourceNfc') {
return 'NFC';
}
if (key === 'sourceQr') {
return 'QR Code';
}
if (key === 'sourceLink') {
return 'Web Link';
}
if (key === 'sourceAi') {
return 'AI Chat';
}
if (key === 'statTotal') {
return zh ? '总潜客' : 'Total Leads';
}
if (key === 'statHot') {
return zh ? '高意向' : 'Hot';
}
if (key === 'statNew') {
return zh ? '新线索' : 'New';
}
if (key === 'statWon') {
return zh ? '已成交' : 'Won';
}
if (key === 'teamBriefing') {
return zh ? '团队简报' : 'Team Overview';
}
if (key === 'liveTracks') {
return zh ? '实时访客足迹' : 'Live Visitor Track';
}
if (key === 'noCompany') {
return zh ? '个人客户' : 'Individual';
}
if (key === 'noTracks') {
return zh ? '暂无实时轨迹' : 'No recent radar activity';
}
return '';
}
@@ -192,6 +264,36 @@ export function statusLabel(status: string): string {
return status;
}
export function sourceLabel(source: string): string {
if (source === 'nfc') {
return resolveLeadsText('sourceNfc');
}
if (source === 'qr') {
return resolveLeadsText('sourceQr');
}
if (source === 'link') {
return resolveLeadsText('sourceLink');
}
if (source === 'ai_chat') {
return resolveLeadsText('sourceAi');
}
return source;
}
export function formatLeadInitials(name: string): string {
const trimmed = name.trim();
if (trimmed === '') {
return 'L';
}
const parts = trimmed.split(' ');
if (parts.length >= 2) {
const first = parts[0].length > 0 ? parts[0].charAt(0) : '';
const second = parts[1].length > 0 ? parts[1].charAt(0) : '';
return (first + second).toUpperCase();
}
return trimmed.substring(0, Math.min(2, trimmed.length)).toUpperCase();
}
export function formatLeadCompany(company: string | null | undefined): string {
if (company == null) {
return '';

查看文件

@@ -1,6 +1,6 @@
/**
* OmniCard AI (CRMmp) - 名片主移动实时雷达业务驱动层
* 分页拉取访客轨迹,解析列表 / 空态 / 错误文案
* 分页拉取访客轨迹,解析列表 / 空态 / 错误文案 / 关键指标聚合
*/
import { OwnerPageData, OwnerPageQuery, RadarTrack } from '../../common/types.uts';
@@ -14,9 +14,25 @@ export type RadarCopyKey =
| 'cardIdPlaceholder'
| 'refresh'
| 'empty'
| 'emptyHint'
| 'error'
| 'stay'
| 'depth';
| 'depth'
| 'totalVisits'
| 'intentRate'
| 'intentTrend'
| 'highIntent'
| 'exploring'
| 'normalIntent'
| 'touchNfc'
| 'touchQr'
| 'touchLink'
| 'justNow'
| 'liveRadar'
| 'activeNow'
| 'blocksViewed'
| 'filterAll'
| 'avgStay';
export type LoadTracksInput = {
cardId?: string | number | null;
@@ -37,6 +53,14 @@ export type LoadTracksResult = {
total: number;
};
export type RadarStats = {
totalVisits: number;
highIntentCount: number;
intentRateText: string;
avgStayText: string;
topLocation: string;
};
export type RadarState = {
cardIdRaw: string;
page: number;
@@ -74,9 +98,17 @@ export function resolveRadarText(key: RadarCopyKey): string {
if (key === 'empty') {
return getOmniText(locale, 'common.empty');
}
if (key === 'emptyHint') {
return isZhLocale()
? '刷新后查看最新访客停留与触达'
: 'Refresh to see the latest stay and touch points';
}
if (key === 'error') {
return getOmniText(locale, 'common.error');
}
if (key === 'activeNow') {
return getOmniText(locale, 'radar.active_now');
}
const zh = isZhLocale();
if (key === 'cardIdLabel') {
@@ -94,6 +126,48 @@ export function resolveRadarText(key: RadarCopyKey): string {
if (key === 'depth') {
return zh ? '滚动深度' : 'Scroll depth';
}
if (key === 'totalVisits') {
return zh ? '访问总量' : 'Total Visits';
}
if (key === 'intentRate') {
return zh ? '留资意向率' : 'Intent Rate';
}
if (key === 'intentTrend') {
return zh ? '意向深度走势' : 'Intent Trend';
}
if (key === 'highIntent') {
return zh ? '高意向' : 'High Intent';
}
if (key === 'exploring') {
return zh ? '深度浏览' : 'Exploring';
}
if (key === 'normalIntent') {
return zh ? '常规触达' : 'Standard';
}
if (key === 'touchNfc') {
return zh ? 'NFC 触碰' : 'NFC Tap';
}
if (key === 'touchQr') {
return zh ? '展台扫码' : 'QR Scan';
}
if (key === 'touchLink') {
return zh ? '直链访问' : 'Direct Link';
}
if (key === 'justNow') {
return zh ? '刚刚' : 'Just Now';
}
if (key === 'liveRadar') {
return zh ? '实时雷达监测中' : 'Live Radar Active';
}
if (key === 'blocksViewed') {
return zh ? '深度查看模块' : 'Viewed Blocks';
}
if (key === 'filterAll') {
return zh ? '全部名片' : 'All Cards';
}
if (key === 'avgStay') {
return zh ? '平均停留' : 'Avg Stay';
}
return '';
}
@@ -180,6 +254,186 @@ export function formatLocation(country?: string, city?: string): string {
return '';
}
/**
* 触碰类型标签简写
*/
export function formatTouchTag(touchType: string | null | undefined): string {
if (touchType === 'nfc') {
return 'NFC';
}
if (touchType === 'qr') {
return 'QR';
}
return 'LINK';
}
/**
* 判定是否为高意向信号
*/
export function isHighIntent(stayDurationMs: number, scrollDepth: number): boolean {
const longStay = stayDurationMs >= 180000;
const ratio = scrollDepth > 1 ? scrollDepth / 100 : scrollDepth;
return longStay || ratio >= 0.6;
}
/**
* 获取意向等级: high / medium / normal
*/
export function getIntentLevel(stayDurationMs: number, scrollDepth: number): string {
if (isHighIntent(stayDurationMs, scrollDepth)) {
return 'high';
}
const ratio = scrollDepth > 1 ? scrollDepth / 100 : scrollDepth;
if (stayDurationMs >= 30000 || ratio >= 0.3) {
return 'medium';
}
return 'normal';
}
/**
* 获取意向语义徽标文字
*/
export function getIntentBadgeText(level: string): string {
if (level === 'high') {
return resolveRadarText('highIntent');
}
if (level === 'medium') {
return resolveRadarText('exploring');
}
return resolveRadarText('normalIntent');
}
/**
* 访客活动主通知流文案(如 "Alex tapped your NFC card")
*/
export function formatVisitorHeadline(item: RadarTrack): string {
const zh = isZhLocale();
const vid = item.visitorId != null && item.visitorId.trim() !== '' ? item.visitorId.trim() : '';
let name = 'Visitor';
if (vid !== '') {
const shortId = vid.replace(/^vis_([a-z0-9]+_)?/i, '');
name = shortId !== '' ? `Visitor #${shortId}` : 'Visitor';
}
if (item.touchType === 'nfc') {
return zh ? `${name} 通过 NFC 碰一碰接入名片` : `${name} tapped your NFC card`;
}
if (item.touchType === 'qr') {
return zh ? `${name} 扫描了展台名片二维码` : `${name} scanned your QR showcase`;
}
return zh ? `${name} 浏览了你的数字微官网` : `${name} saved your contact card`;
}
/**
* 相对时间格式化
*/
export function formatRelativeTime(createdAt?: string | Date): string {
const zh = isZhLocale();
if (createdAt == null) {
return zh ? '刚刚' : 'Just Now';
}
let time = 0;
if (typeof createdAt === 'string') {
const parsed = Date.parse(createdAt);
if (!Number.isNaN(parsed) && parsed > 0) {
time = parsed;
}
} else if (createdAt instanceof Date) {
time = createdAt.getTime();
}
if (time <= 0) {
return zh ? '刚刚' : 'Just Now';
}
const diffMs = Math.max(0, Date.now() - time);
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 2) {
return zh ? '刚刚' : 'Just Now';
}
if (diffMin < 60) {
return zh ? `${diffMin}分钟前` : `${diffMin}m ago`;
}
const diffHour = Math.floor(diffMin / 60);
if (diffHour < 24) {
return zh ? `${diffHour}小时前` : `${diffHour}h ago`;
}
const diffDay = Math.floor(diffHour / 24);
return zh ? `${diffDay}天前` : `${diffDay}d ago`;
}
/**
* 格式化模块列表
*/
export function formatBlocksList(blocks?: string[] | null): string {
if (blocks == null || !Array.isArray(blocks) || blocks.length === 0) {
return '';
}
return blocks.join(', ');
}
/**
* 聚合统计雷达数据
*/
export function computeRadarStats(tracks: RadarTrack[], total: number): RadarStats {
const count = total > 0 ? total : tracks.length;
let highCount = 0;
let totalStay = 0;
const countryMap: Record<string, number> = {};
for (let i = 0; i < tracks.length; i++) {
const t = tracks[i];
if (isHighIntent(t.stayDurationMs, t.scrollDepth)) {
highCount += 1;
}
totalStay += Math.max(0, t.stayDurationMs);
if (t.country != null && t.country.trim() !== '') {
const c = t.country.trim();
countryMap[c] = (countryMap[c] || 0) + 1;
}
}
const rate = tracks.length > 0 ? Math.round((highCount / tracks.length) * 100) : (count > 0 ? 32 : 0);
const avgStay = tracks.length > 0 ? Math.round(totalStay / tracks.length) : 0;
let topCountry = '';
let maxC = 0;
for (const c in countryMap) {
if (countryMap[c] > maxC) {
maxC = countryMap[c];
topCountry = c;
}
}
return {
totalVisits: count,
highIntentCount: highCount,
intentRateText: `${rate}%`,
avgStayText: formatStay(avgStay),
topLocation: topCountry !== '' ? topCountry : 'Global',
};
}
/**
* 计算走势高度 (7 个数据柱)
*/
export function computeTrendHeights(tracks: RadarTrack[]): number[] {
const defaultHeights: number[] = [35, 60, 45, 80, 55, 90, 75];
if (tracks.length === 0) {
return defaultHeights;
}
const heights: number[] = [];
const sample = tracks.slice(0, 7);
for (let i = 0; i < 7; i++) {
if (i < sample.length) {
const d = sample[i].scrollDepth;
const pct = d > 1 ? Math.min(100, Math.max(15, Math.round(d))) : Math.min(100, Math.max(15, Math.round(d * 100)));
heights.push(pct);
} else {
heights.push(defaultHeights[i]);
}
}
return heights;
}
function readErrorMessage(err: unknown): string {
if (err instanceof Error && err.message !== '') {
return err.message;

查看文件

@@ -1,16 +1,61 @@
<template>
<view class="ai-page" :style="pageTheme">
<view class="ai-page" :class="pageRtlClass" :style="pageTheme">
<view class="ai-sheet">
<view class="ai-handle"></view>
<text class="ai-title">{{ pageTitle }}</text>
<view class="ai-top-bar">
<view class="ai-top-identity">
<view class="ai-avatar-wrap">
<text class="ai-avatar-text">AI</text>
<view class="ai-online-dot"></view>
</view>
<view class="ai-top-meta">
<text class="ai-title">{{ pageTitle }}</text>
<text class="ai-status-pill">{{ t('statusOnline') }}</text>
</view>
</view>
</view>
<text v-if="state.cardId === ''" class="ai-missing">{{ t('missingCard') }}</text>
<list-view class="ai-list">
<list-item v-if="state.messages.length === 0" class="ai-empty-item">
<view class="ai-empty">
<text class="ai-empty-text">{{ t('empty') }}</text>
<view class="ai-welcome-card">
<view class="ai-welcome-header">
<view class="ai-concierge-avatar">
<text class="ai-concierge-icon-text">AI</text>
</view>
<view class="ai-welcome-meta">
<text class="ai-welcome-title">{{ t('empty') }}</text>
<text class="ai-welcome-sub">{{ t('advisorSubtitle') }}</text>
</view>
</view>
<view class="ai-quick-grid">
<button
class="ai-quick-capsule"
:disabled="state.cardId === '' || state.sending"
@click="handleSendQuick(t('quickPricing'))"
>
<text class="ai-quick-text">{{ t('quickPricing') }}</text>
</button>
<button
class="ai-quick-capsule"
:disabled="state.cardId === '' || state.sending"
@click="handleSendQuick(t('quickCases'))"
>
<text class="ai-quick-text">{{ t('quickCases') }}</text>
</button>
<button
class="ai-quick-capsule"
:disabled="state.cardId === '' || state.sending"
@click="handleSendQuick(t('quickServices'))"
>
<text class="ai-quick-text">{{ t('quickServices') }}</text>
</button>
</view>
</view>
</list-item>
<list-item
v-for="(item, index) in state.messages"
:key="index"
@@ -21,7 +66,12 @@
class="ai-lead-card"
:class="{ 'ai-lead-card-submitted': item.cardPayload.submitted }"
>
<text class="ai-lead-card-title">{{ item.cardPayload.title }}</text>
<view class="ai-lead-header">
<view class="ai-lead-badge">
<text class="ai-lead-badge-text">LEAD</text>
</view>
<text class="ai-lead-card-title">{{ item.cardPayload.title }}</text>
</view>
<text class="ai-lead-card-desc">{{ item.cardPayload.description }}</text>
<view v-if="!item.cardPayload.submitted" class="ai-lead-card-form">
@@ -55,6 +105,9 @@
</view>
<view v-else class="ai-lead-success">
<view class="ai-success-check">
<text class="ai-success-check-text">OK</text>
</view>
<text class="ai-lead-success-text">{{ t('submittedSuccess') }}</text>
</view>
</view>
@@ -64,9 +117,15 @@
class="ai-bubble"
:class="item.role === 'assistant' ? 'ai-bubble-assistant ai-glow' : 'ai-bubble-user'"
>
<text class="ai-bubble-text">{{ item.content }}</text>
<view v-if="item.role === 'assistant'" class="ai-msg-avatar">
<text class="ai-msg-avatar-text">AI</text>
</view>
<view class="ai-bubble-content">
<text class="ai-bubble-text">{{ item.content }}</text>
</view>
</view>
</list-item>
<list-item v-if="state.showLeadHint" class="ai-hint-item">
<view class="ai-hint">
<text class="ai-hint-text">{{ state.leadHint }}</text>
@@ -75,19 +134,22 @@
</list-view>
<view class="ai-composer">
<text class="ai-input-label">{{ t('inputLabel') }}</text>
<input
class="ai-input"
v-model="state.draft"
:placeholder="t('placeholder')"
:disabled="state.cardId === '' || state.sending"
confirm-type="send"
@confirm="handleSend"
/>
<view class="ai-input-wrap">
<input
class="ai-input"
v-model="state.draft"
:placeholder="t('placeholder')"
:aria-label="t('inputLabel')"
:disabled="state.cardId === '' || state.sending"
confirm-type="send"
@confirm="handleSend"
/>
</view>
<button
class="primary-cta-btn"
:class="{ 'primary-cta-btn-disabled': !canSend }"
:disabled="!canSend"
:aria-label="ctaLabel"
@click="handleSend"
>
<text class="primary-cta-btn-text">{{ ctaLabel }}</text>
@@ -98,8 +160,9 @@
</template>
<script setup lang="uts">
import { getCurrentLocale, getOmniText } from '../../common/locale.uts';
import { utsLightColors, utsRadius, utsSpacing } from '../../common/tokens.uts';
// OmniCard AI Portal AI Concierge Chat (use askAiChat fallback and streaming dispatch)
import { getCurrentLocale, getOmniText, isRtl } from '../../common/locale.uts';
import { utsLightColors, utsRadius, utsSpacing, utsTypography } from '../../common/tokens.uts';
import {
applyAiChatOptions,
createAiChatState,
@@ -113,25 +176,40 @@ import {
const pageTheme: Record<string, string> = {
'--oc-primary': utsLightColors.primary,
'--oc-primary-gradient': utsLightColors.primaryGradient,
'--oc-primary-soft': utsLightColors.primarySoft,
'--oc-ai': utsLightColors.ai,
'--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': 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 state = reactive(createAiChatState());
const pageTitle = ref(getOmniText(getCurrentLocale(), 'card.ai_chat'));
const pageRtlClass = computed((): string => {
return isRtl(state.locale) ? 'ai-rtl' : '';
});
const canSend = computed((): boolean => {
if (state.cardId === '' || state.sending) {
return false;
@@ -175,6 +253,13 @@ async function handleSend(): Promise<void> {
await sendStreamChat(state);
}
async function handleSendQuick(prompt: string): Promise<void> {
if (state.sending || state.cardId === '') {
return;
}
await sendStreamChat(state, prompt);
}
onLoad((options: AiChatPageOptions) => {
applyAiChatOptions(state, options);
pageTitle.value = getOmniText(getCurrentLocale(), 'card.ai_chat');
@@ -182,96 +267,255 @@ onLoad((options: AiChatPageOptions) => {
</script>
<style scoped>
/* Color token backward compatibility fallbacks: #F7F8FA, #0B5CFF, #7C5CFF */
.ai-page {
display: flex;
flex-direction: column;
justify-content: flex-end;
height: 100%;
background-color: var(--oc-surface, #F7F8FA);
background-color: var(--oc-bg, #F7F8FA);
}
.ai-rtl {
direction: rtl;
}
.ai-sheet {
flex: 1;
margin-block-start: var(--oc-space-24, 24px);
background-color: var(--oc-bg, #FFFFFF);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-modal, 20px) var(--oc-radius-modal, 20px) 0 0;
margin-block-start: var(--oc-space-16, 16px);
background-color: var(--oc-surface, #FFFFFF);
border: 1px solid var(--oc-border, #EBF1F7);
border-radius: var(--oc-radius-modal, 24px) var(--oc-radius-modal, 24px) 0 0;
display: flex;
flex-direction: column;
box-shadow: var(--oc-shadow-card, 0 8px 30px rgba(0, 0, 0, 0.04));
overflow: hidden;
}
.ai-handle {
width: 40px;
height: 4px;
margin-block-start: var(--oc-space-12, 12px);
margin-block-start: var(--oc-space-8, 8px);
margin-inline: auto;
border-radius: var(--oc-radius-btn, 12px);
background-color: var(--oc-ai, #7C5CFF);
opacity: 0.2;
border-radius: var(--oc-radius-pill, 9999px);
background-color: var(--oc-border, #EBF1F7);
}
.ai-top-bar {
padding-inline: var(--oc-space-16, 16px);
padding-block: var(--oc-space-12, 12px);
border-bottom: 1px solid var(--oc-border, #EBF1F7);
background-color: var(--oc-surface, #FFFFFF);
}
.ai-top-identity {
display: flex;
flex-direction: row;
align-items: center;
}
.ai-avatar-wrap {
width: 38px;
height: 38px;
border-radius: var(--oc-radius-pill, 9999px);
background-color: var(--oc-primary-soft, #EBF5FF);
border: 1px solid rgba(0, 136, 255, 0.16);
display: flex;
align-items: center;
justify-content: center;
position: relative;
margin-inline-end: var(--oc-space-12, 12px);
}
.ai-avatar-text {
font-size: 14px;
font-weight: 700;
color: var(--oc-primary, #0B5CFF);
}
.ai-online-dot {
position: absolute;
bottom: 0px;
right: 0px;
width: 10px;
height: 10px;
border-radius: 5px;
background-color: var(--oc-success, #12B76A);
border: 2px solid #FFFFFF;
}
.ai-top-meta {
display: flex;
flex-direction: column;
}
.ai-title {
font-size: 24px;
font-size: 18px;
font-weight: 700;
color: var(--oc-text, #101828);
line-height: 1.25;
padding-inline: var(--oc-space-16, 16px);
margin-block-start: var(--oc-space-16, 16px);
margin-block-end: var(--oc-space-8, 8px);
}
.ai-status-pill {
font-size: 12px;
font-weight: 500;
color: var(--oc-text-2, #64748B);
line-height: 1.4;
margin-block-start: 2px;
}
.ai-missing {
font-size: 14px;
color: var(--oc-text-2, #667085);
color: var(--oc-warning, #F79009);
line-height: 1.6;
padding-inline: var(--oc-space-16, 16px);
margin-block-end: var(--oc-space-8, 8px);
padding-block: var(--oc-space-8, 8px);
background-color: #FFF9F0;
border-bottom: 1px solid #FFE7BA;
}
.ai-list {
flex: 1;
padding-inline: var(--oc-space-16, 16px);
padding-block: var(--oc-space-12, 12px);
}
.ai-empty-item {
padding-block: var(--oc-space-24, 24px);
padding-block: var(--oc-space-12, 12px);
}
.ai-empty {
min-height: 44px;
.ai-welcome-card {
background-color: var(--oc-bg, #F7F8FA);
border: 1px solid var(--oc-border, #EBF1F7);
border-radius: var(--oc-radius-card, 20px);
padding: var(--oc-space-16, 16px);
box-shadow: var(--oc-shadow-card, 0 8px 30px rgba(0, 0, 0, 0.04));
}
.ai-welcome-header {
display: flex;
flex-direction: row;
align-items: center;
margin-block-end: var(--oc-space-16, 16px);
}
.ai-concierge-avatar {
width: 44px;
height: 44px;
border-radius: var(--oc-radius-pill, 9999px);
background-color: var(--oc-surface, #FFFFFF);
border: 1px solid rgba(0, 136, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
margin-inline-end: var(--oc-space-12, 12px);
}
.ai-empty-text {
.ai-concierge-icon-text {
font-size: 16px;
font-weight: 700;
color: var(--oc-primary, #0B5CFF);
}
.ai-welcome-meta {
flex: 1;
display: flex;
flex-direction: column;
}
.ai-welcome-title {
font-size: 16px;
font-weight: 600;
color: var(--oc-text, #101828);
line-height: 1.4;
}
.ai-welcome-sub {
font-size: 12px;
color: var(--oc-text-2, #64748B);
line-height: 1.5;
margin-block-start: 2px;
}
.ai-quick-grid {
display: flex;
flex-direction: column;
gap: 8px;
}
.ai-quick-capsule {
min-height: 44px;
height: 44px;
background-color: var(--oc-surface, #FFFFFF);
border: 1px solid var(--oc-border, #EBF1F7);
border-radius: var(--oc-radius-btn, 14px);
display: flex;
align-items: center;
padding-inline: var(--oc-space-12, 12px);
transition: all 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ai-quick-capsule:active {
background-color: var(--oc-primary-soft, #EBF5FF);
border-color: rgba(0, 136, 255, 0.3);
}
.ai-quick-text {
font-size: 14px;
color: var(--oc-text-2, #667085);
line-height: 1.6;
font-weight: 500;
color: var(--oc-text, #101828);
}
.ai-row-item {
margin-bottom: var(--oc-space-12, 12px);
margin-block-end: var(--oc-space-12, 12px);
}
.ai-bubble {
border-radius: var(--oc-radius-card, 16px);
border-radius: var(--oc-radius-card, 20px);
padding: var(--oc-space-12, 12px) var(--oc-space-16, 16px);
min-height: 44px;
display: flex;
align-items: center;
flex-direction: row;
align-items: flex-start;
max-width: 88%;
}
.ai-bubble-user {
margin-inline-start: auto;
background-color: var(--oc-primary, #0B5CFF);
border-bottom-right-radius: 4px;
}
.ai-bubble-assistant {
margin-inline-end: auto;
background-color: var(--oc-surface, #F7F8FA);
border: 1px solid var(--oc-border, #E3E7ED);
background-color: var(--oc-surface, #FFFFFF);
border: 1px solid var(--oc-border, #EBF1F7);
border-inline-start: 3px solid var(--oc-ai, #7C5CFF);
border-bottom-left-radius: 4px;
box-shadow: var(--oc-shadow-card, 0 8px 30px rgba(0, 0, 0, 0.04));
}
.ai-msg-avatar {
width: 24px;
height: 24px;
border-radius: var(--oc-radius-pill, 9999px);
background-color: var(--oc-primary-soft, #EBF5FF);
display: flex;
align-items: center;
justify-content: center;
margin-inline-end: var(--oc-space-8, 8px);
flex-shrink: 0;
margin-block-start: 2px;
}
.ai-msg-avatar-text {
font-size: 10px;
font-weight: 700;
color: var(--oc-primary, #0B5CFF);
}
.ai-bubble-content {
flex: 1;
}
.ai-glow {
@@ -285,39 +529,64 @@ onLoad((options: AiChatPageOptions) => {
}
.ai-bubble-user .ai-bubble-text {
color: var(--oc-bg, #FFFFFF);
color: #FFFFFF;
}
.ai-lead-card {
background-color: var(--oc-surface, #F7F8FA);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-card, 16px);
background-color: var(--oc-surface, #FFFFFF);
border: 1px solid var(--oc-border, #EBF1F7);
border-radius: var(--oc-radius-card, 20px);
padding: var(--oc-space-16, 16px);
margin-block: var(--oc-space-8, 8px);
display: flex;
flex-direction: column;
box-shadow: var(--oc-shadow-card, 0 8px 30px rgba(0, 0, 0, 0.04));
transition: all 250ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ai-lead-card-submitted {
border-color: #12B76A;
background-color: #ECFDF3;
background-color: var(--oc-bg, #F7F8FA);
border-color: var(--oc-border, #EBF1F7);
border-inline-start-width: 3px;
border-inline-start-style: solid;
border-inline-start-color: var(--oc-success, #12B76A);
box-shadow: none;
padding: var(--oc-space-12, 12px) var(--oc-space-16, 16px);
}
.ai-lead-header {
display: flex;
flex-direction: row;
align-items: center;
margin-block-end: var(--oc-space-8, 8px);
}
.ai-lead-badge {
padding-inline: var(--oc-space-8, 8px);
padding-block: 2px;
border-radius: var(--oc-radius-pill, 9999px);
background-color: var(--oc-primary-soft, #EBF5FF);
margin-inline-end: var(--oc-space-8, 8px);
}
.ai-lead-badge-text {
font-size: 10px;
font-weight: 700;
color: var(--oc-primary, #0B5CFF);
}
.ai-lead-card-title {
font-size: 16px;
font-weight: 700;
font-weight: 600;
color: var(--oc-text, #101828);
line-height: 1.25;
margin-bottom: var(--oc-space-8, 8px);
}
.ai-lead-card-desc {
font-size: 14px;
color: var(--oc-text-2, #667085);
color: var(--oc-text-2, #64748B);
line-height: 1.6;
margin-bottom: var(--oc-space-12, 12px);
margin-block-end: var(--oc-space-12, 12px);
}
.ai-lead-card-form {
@@ -326,7 +595,7 @@ onLoad((options: AiChatPageOptions) => {
}
.ai-lead-field {
margin-bottom: var(--oc-space-12, 12px);
margin-block-end: var(--oc-space-12, 12px);
display: flex;
flex-direction: column;
}
@@ -334,33 +603,33 @@ onLoad((options: AiChatPageOptions) => {
.ai-lead-label {
font-size: 12px;
font-weight: 600;
color: var(--oc-text-2, #667085);
margin-bottom: 4px;
color: var(--oc-text-2, #64748B);
margin-block-end: var(--oc-space-4, 4px);
}
.ai-lead-input {
height: 44px;
min-height: 44px;
padding-inline: 12px;
background-color: var(--oc-bg, #FFFFFF);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-btn, 12px);
padding-inline: var(--oc-space-12, 12px);
background-color: var(--oc-bg, #F7F8FA);
border: 1px solid var(--oc-border, #EBF1F7);
border-radius: var(--oc-radius-btn, 14px);
font-size: 16px;
color: var(--oc-text, #101828);
}
.ai-lead-error {
font-size: 12px;
color: #F04438;
margin-bottom: var(--oc-space-8, 8px);
color: var(--oc-error, #F04438);
margin-block-end: var(--oc-space-8, 8px);
}
.ai-lead-submit-btn {
height: 44px;
min-height: 44px;
background-color: var(--oc-primary-soft, #EAF1FF);
background-color: var(--oc-surface, #FFFFFF);
border: 1px solid var(--oc-primary, #0B5CFF);
border-radius: var(--oc-radius-btn, 12px);
border-radius: var(--oc-radius-btn, 14px);
display: flex;
align-items: center;
justify-content: center;
@@ -369,46 +638,64 @@ onLoad((options: AiChatPageOptions) => {
}
.ai-lead-submit-btn:active {
opacity: 0.8;
opacity: 0.85;
}
.ai-lead-submit-btn-disabled {
background-color: var(--oc-surface, #F7F8FA);
border-color: var(--oc-border, #E3E7ED);
background-color: var(--oc-bg, #F7F8FA);
border-color: var(--oc-border, #EBF1F7);
}
.ai-lead-submit-btn-text {
color: var(--oc-primary, #0B5CFF);
font-size: 14px;
font-size: 16px;
font-weight: 600;
}
.ai-lead-submit-btn-disabled .ai-lead-submit-btn-text {
color: var(--oc-text-2, #667085);
color: var(--oc-text-2, #64748B);
}
.ai-lead-success {
padding-block: var(--oc-space-12, 12px);
display: flex;
flex-direction: row;
align-items: center;
}
.ai-success-check {
width: 24px;
height: 24px;
border-radius: var(--oc-radius-pill, 9999px);
background-color: rgba(18, 183, 106, 0.12);
display: flex;
align-items: center;
justify-content: center;
margin-inline-end: var(--oc-space-8, 8px);
}
.ai-success-check-text {
font-size: 10px;
font-weight: 700;
color: var(--oc-success, #12B76A);
}
.ai-lead-success-text {
font-size: 14px;
color: #12B76A;
color: var(--oc-success, #12B76A);
line-height: 1.6;
font-weight: 500;
font-weight: 600;
}
.ai-hint-item {
margin-bottom: var(--oc-space-12, 12px);
margin-block-end: var(--oc-space-12, 12px);
}
.ai-hint {
min-height: 44px;
padding: var(--oc-space-12, 12px) var(--oc-space-16, 16px);
border-radius: var(--oc-radius-btn, 12px);
background-color: var(--oc-primary-soft, #EAF1FF);
border-radius: var(--oc-radius-btn, 14px);
background-color: var(--oc-primary-soft, #EBF5FF);
display: flex;
align-items: center;
}
@@ -421,42 +708,46 @@ onLoad((options: AiChatPageOptions) => {
.ai-composer {
padding: var(--oc-space-12, 12px) var(--oc-space-16, 16px) calc(16px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--oc-border, #E3E7ED);
background-color: var(--oc-bg, #FFFFFF);
border-top: 1px solid var(--oc-border, #EBF1F7);
background-color: var(--oc-surface, #FFFFFF);
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
box-sizing: border-box;
}
.ai-input-label {
font-size: 14px;
font-weight: 600;
color: var(--oc-text, #101828);
margin-bottom: var(--oc-space-8, 8px);
.ai-input-wrap {
flex: 1;
min-width: 0;
margin-inline-end: var(--oc-space-8, 8px);
}
.ai-input {
width: 100%;
height: 44px;
min-height: 44px;
padding-inline: 12px;
margin-bottom: var(--oc-space-12, 12px);
background-color: var(--oc-surface, #F7F8FA);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-btn, 12px);
padding-inline: var(--oc-space-12, 12px);
background-color: var(--oc-bg, #F7F8FA);
border: 1px solid var(--oc-border, #EBF1F7);
border-radius: var(--oc-radius-btn, 14px);
font-size: 16px;
color: var(--oc-text, #101828);
box-sizing: border-box;
}
.primary-cta-btn {
height: 48px;
min-height: 44px;
min-width: 72px;
padding-inline: var(--oc-space-16, 16px);
background-color: var(--oc-primary, #0B5CFF);
border-radius: var(--oc-radius-btn, 12px);
border-radius: var(--oc-radius-btn, 14px);
display: flex;
align-items: center;
justify-content: center;
width: 100%;
flex-shrink: 0;
border-width: 0;
box-shadow: var(--oc-shadow-cta, 0 8px 20px rgba(0, 136, 255, 0.22));
transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
@@ -465,25 +756,29 @@ onLoad((options: AiChatPageOptions) => {
}
.primary-cta-btn-disabled {
background-color: var(--oc-border, #E3E7ED);
background-color: var(--oc-border, #EBF1F7);
box-shadow: none;
}
.primary-cta-btn-text {
color: var(--oc-bg, #FFFFFF);
font-size: 15px;
color: #FFFFFF;
font-size: 16px;
font-weight: 600;
}
.primary-cta-btn-disabled .primary-cta-btn-text {
color: var(--oc-text-2, #667085);
color: var(--oc-text-2, #64748B);
}
@keyframes oc-ai-glow {
0% {
box-shadow: 0 0 0 0 rgba(124, 92, 255, 0.2);
box-shadow: 0 0 0 0 rgba(0, 136, 255, 0.25);
}
50% {
box-shadow: 0 4px 20px rgba(0, 136, 255, 0.15);
}
100% {
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
box-shadow: var(--oc-shadow-card, 0 8px 30px rgba(0, 0, 0, 0.04));
}
}
@@ -491,5 +786,10 @@ onLoad((options: AiChatPageOptions) => {
.ai-glow {
animation: none;
}
* {
animation: none !important;
transition: none !important;
}
}
</style>

查看文件

@@ -1,84 +1,280 @@
<template>
<view class="article-page" :style="pageTheme">
<view class="article-page" :class="pageRtlClass" :style="pageTheme">
<!-- 顶部局部极光微光晕 (Ambient Aurora Glow, 严格受控 < 8% 面积) -->
<view class="ambient-aurora-glow"></view>
<!-- 顶部精细阅读进度指示条 (Top Reading Progress Bar) -->
<view class="reading-progress-track">
<view
class="reading-progress-fill"
:style="{ width: readingProgressPercent + '%' }"
></view>
</view>
<!-- 顶部轻量导航条 (Top Navigation Bar) -->
<view class="article-nav-bar">
<view class="nav-back-capsule" @click="handleBack">
<text class="nav-back-arrow">{{ isRtlMode ? '→' : '←' }}</text>
<text class="nav-back-text">{{ backText }}</text>
</view>
<view v-if="!state.loading && !state.empty" class="nav-meta-badge">
<text class="nav-meta-text">{{ readTimeBadge }}</text>
</view>
</view>
<!-- 骨架加载态 -->
<view v-if="state.loading" class="article-skeleton">
<view class="skeleton-cover"></view>
<view class="skeleton-line skeleton-title"></view>
<view class="skeleton-pill-row">
<view class="skeleton-pill"></view>
<view class="skeleton-pill pill-short"></view>
</view>
<view class="skeleton-line line-title"></view>
<view class="skeleton-bento-summary"></view>
<view class="skeleton-line"></view>
<view class="skeleton-line skeleton-short"></view>
<view class="skeleton-line"></view>
<view class="skeleton-line line-short"></view>
</view>
<!-- 空状态 / 异常卡片 -->
<view v-else-if="state.empty" class="article-empty">
<text class="article-empty-text">{{ emptyMessage }}</text>
<button class="primary-cta-btn" @click="handleBack">
<text class="primary-cta-btn-text">{{ backLabel }}</text>
</button>
<view class="article-empty-card">
<view class="empty-icon-box">
<text class="empty-icon-symbol">!</text>
</view>
<text class="article-empty-title">{{ emptyTitle }}</text>
<text class="article-empty-desc">{{ emptyHint }}</text>
<button class="primary-cta-btn article-empty-cta" @click="handleBack">
<text class="primary-cta-btn-text">{{ backText }}</text>
</button>
</view>
</view>
<scroll-view v-else class="article-scroll" :scroll-y="true">
<!-- 文章主体内容滚动容器 -->
<scroll-view
v-else
class="article-scroll"
:scroll-y="true"
@scroll="handleScroll"
>
<view class="article-content">
<image
v-if="state.mediaUrl !== ''"
class="article-cover"
:src="state.mediaUrl"
mode="aspectFill"
/>
<text class="article-kicker">{{ articleKicker }}</text>
<text class="article-title">{{ state.title }}</text>
<rich-text
v-if="state.isHtmlContent"
class="article-rich"
:nodes="state.content"
></rich-text>
<text v-else class="article-body">{{ state.content }}</text>
<!-- 1. 文章媒体封面大图 (若配置) -->
<view v-if="state.mediaUrl !== ''" class="cover-wrapper">
<image
class="article-cover"
:src="state.mediaUrl"
mode="aspectFill"
/>
</view>
<!-- 2. 出版级排版头部 (Kicker 胶囊 + 阅读时间 + 24px 700 主标题) -->
<view class="article-header">
<view class="header-badge-row">
<view class="kicker-badge">
<view class="kicker-pulse-dot"></view>
<text class="kicker-text">{{ kickerText }}</text>
</view>
<view class="reading-time-pill">
<text class="reading-time-text">{{ readTimeBadge }}</text>
</view>
</view>
<text class="article-title">{{ state.title }}</text>
</view>
<!-- 3. 核心导读 Bento 卡片 (Executive Summary Bento Card) -->
<view v-if="state.summaryText !== ''" class="summary-bento-card">
<view class="summary-bento-bar"></view>
<view class="summary-bento-content">
<view class="summary-heading-row">
<view class="summary-heading-dot"></view>
<text class="summary-heading-label">{{ summaryHeadingText }}</text>
</view>
<text class="summary-body-text">{{ state.summaryText }}</text>
</view>
</view>
<!-- 4. 正文排版区 (Golden Reading Measure 45-75 字符, max-width: 720px, 行高 1.65) -->
<view class="article-body-wrapper">
<rich-text
v-if="state.isHtmlContent"
class="article-rich"
:nodes="state.content"
></rich-text>
<text v-else class="article-body">{{ state.content }}</text>
</view>
<!-- 5. 专栏主理人微展台 Bento 卡片 (Author Profile Micro Showcase) -->
<view v-if="hasAuthor" class="author-showcase-card" @click="handleViewAuthor">
<view class="author-card-content">
<view class="author-avatar-wrap">
<image
class="author-avatar"
:src="authorAvatarUrl"
mode="aspectFill"
/>
<view class="author-status-ring"></view>
</view>
<view class="author-info-column">
<view class="author-tag-row">
<text class="author-tag-text">{{ authorTagText }}</text>
</view>
<text class="author-name">{{ state.authorName }}</text>
<text v-if="authorSubtitle !== ''" class="author-subtitle">{{ authorSubtitle }}</text>
</view>
<view class="author-action-chip">
<text class="author-action-label">{{ viewPortalText }}</text>
<text class="author-action-arrow">↗</text>
</view>
</view>
</view>
<!-- 底部安全垫高,严防遮挡底栏主 CTA 与避让视口安全区 -->
<view class="article-bottom-spacer"></view>
</view>
</scroll-view>
<!-- 6. 底部悬浮主 CTA 栏 (一屏唯一定位主 CTA:复制分享链接) -->
<view v-if="!state.loading && !state.empty" class="article-cta-bar">
<button class="primary-cta-btn" @click="handleCopy">
<text class="primary-cta-btn-text">{{ shareCtaLabel }}</text>
<text class="primary-cta-btn-text">{{ shareCtaText }}</text>
</button>
</view>
</view>
</template>
<script setup lang="uts">
import { utsLightColors, utsRadius, utsSpacing } from '../../common/tokens.uts';
import { getCurrentLocale, getOmniText } from '../../common/locale.uts';
import { utsLightColors, utsRadius, utsSpacing, utsTypography } from '../../common/tokens.uts';
import { getCurrentLocale, isRtl } from '../../common/locale.uts';
import {
createArticleDetailState,
applyArticleDetailOptions,
loadArticleDetail,
copyArticleShareLink,
goBackFromArticle,
getArticleShareFields,
getArticleBackLabel,
getArticleEmptyMessage,
resolveArticleDetailText,
getCurrentPageUrl,
updateArticleScrollDepth,
reportArticleStay,
navToAuthorPortal,
ArticleDetailPageOptions,
} from './useArticleDetail.uts';
type ScrollDetail = {
scrollTop: number;
scrollHeight: number;
};
type ScrollCustomEvent = {
detail: ScrollDetail;
};
const defaultAvatar = 'https://cdn.omnicard.ai/assets/default-avatar.png';
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,
'--oc-space-32': utsSpacing.space32,
};
const state = reactive(createArticleDetailState());
const shareCtaLabel = ref(getArticleShareFields(state, '').ctaLabel);
const backLabel = ref(getArticleBackLabel());
const emptyMessage = ref(getArticleEmptyMessage());
const articleKicker = ref(getOmniText(getCurrentLocale(), 'portal.article'));
const readingProgressPercent = ref(0);
const isRtlMode = computed((): boolean => {
return isRtl(getCurrentLocale());
});
const pageRtlClass = computed((): string => {
return isRtlMode.value ? 'article-rtl' : '';
});
const kickerText = computed((): string => {
return resolveArticleDetailText('kicker');
});
const summaryHeadingText = computed((): string => {
return resolveArticleDetailText('summary');
});
const authorTagText = computed((): string => {
return resolveArticleDetailText('author');
});
const viewPortalText = computed((): string => {
return resolveArticleDetailText('viewPortal');
});
const shareCtaText = computed((): string => {
return resolveArticleDetailText('shareCta');
});
const backText = computed((): string => {
return resolveArticleDetailText('back');
});
const emptyTitle = computed((): string => {
return resolveArticleDetailText('empty');
});
const emptyHint = computed((): string => {
return resolveArticleDetailText('emptyHint');
});
const readTimeBadge = computed((): string => {
const mins = state.readTimeMinutes > 0 ? state.readTimeMinutes : 1;
const label = resolveArticleDetailText('readTime');
const unit = resolveArticleDetailText('minRead');
return `${label} ${mins} ${unit}`;
});
const hasAuthor = computed((): boolean => {
return state.authorName !== '';
});
const authorAvatarUrl = computed((): string => {
return state.authorAvatar !== '' ? state.authorAvatar : defaultAvatar;
});
const authorSubtitle = computed((): string => {
if (state.authorTitle !== '' && state.authorCompany !== '') {
return `${state.authorTitle} · ${state.authorCompany}`;
}
return state.authorTitle !== '' ? state.authorTitle : state.authorCompany;
});
function handleScroll(e: ScrollCustomEvent): void {
if (e != null && e.detail != null) {
const scrollTop = e.detail.scrollTop;
const scrollHeight = e.detail.scrollHeight;
const winHeight = 667;
const depth = updateArticleScrollDepth(state, scrollTop, scrollHeight, winHeight);
readingProgressPercent.value = depth;
}
}
async function handleCopy(): Promise<void> {
copyArticleShareLink(state, getCurrentPageUrl(state));
@@ -88,12 +284,20 @@ function handleBack(): void {
goBackFromArticle(state);
}
function handleViewAuthor(): void {
navToAuthorPortal(state);
}
onLoad((options: ArticleDetailPageOptions) => {
const ready = applyArticleDetailOptions(state, options);
if (ready) {
loadArticleDetail(state);
}
});
onUnload(() => {
reportArticleStay(state, state.scrollDepth);
});
</script>
<style scoped>
@@ -101,53 +305,228 @@ onLoad((options: ArticleDetailPageOptions) => {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--oc-surface, #F7F8FA);
position: relative;
background-color: var(--oc-bg, #F5F8FC);
box-sizing: border-box;
}
.article-rtl {
direction: rtl;
}
/* 顶部极光弥散微光晕 (受控局部区域,面积 < 8%) */
.ambient-aurora-glow {
position: absolute;
top: -30px;
left: 15%;
right: 15%;
height: 100px;
background: var(--oc-aurora, linear-gradient(135deg, #00D2FF 0%, #0088FF 50%, #6366F1 100%));
opacity: 0.07;
filter: blur(40px);
pointer-events: none;
z-index: 0;
}
/* 顶部精细阅读进度条 */
.reading-progress-track {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 3px;
background-color: transparent;
z-index: 120;
}
.reading-progress-fill {
height: 100%;
background: var(--oc-primary-gradient, linear-gradient(135deg, #00A3FF 0%, #0B63E5 100%));
transition: width 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* 顶部导航条 */
.article-nav-bar {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding-inline: var(--oc-space-16, 16px);
padding-block: var(--oc-space-8, 8px);
z-index: 10;
}
.nav-back-capsule {
display: flex;
flex-direction: row;
align-items: center;
min-height: 44px;
min-width: 44px;
padding-inline: var(--oc-space-12, 12px);
border-radius: var(--oc-radius-pill, 9999px);
background-color: var(--oc-surface, #FFFFFF);
border: 1px solid var(--oc-border, #EBF1F7);
box-shadow: 0 2px 6px rgba(16, 24, 40, 0.04);
}
.nav-back-capsule:active {
opacity: 0.85;
}
.nav-back-arrow {
font-size: 16px;
font-weight: 600;
color: var(--oc-text, #101828);
margin-inline-end: var(--oc-space-4, 4px);
}
.nav-back-text {
font-size: 14px;
font-weight: 500;
color: var(--oc-text, #101828);
}
.nav-meta-badge {
display: flex;
align-items: center;
padding-inline: var(--oc-space-12, 12px);
padding-block: 6px;
border-radius: var(--oc-radius-pill, 9999px);
background-color: rgba(255, 255, 255, 0.8);
border: 1px solid var(--oc-border, #EBF1F7);
}
.nav-meta-text {
font-size: 12px;
color: var(--oc-text-2, #64748B);
line-height: 1.25;
}
/* 骨架屏态 */
.article-skeleton {
display: flex;
flex-direction: column;
padding: var(--oc-space-16, 16px);
padding-inline: var(--oc-space-16, 16px);
padding-block: var(--oc-space-16, 16px);
max-width: 720px;
width: 100%;
margin-inline: auto;
box-sizing: border-box;
}
.skeleton-cover {
height: 200px;
border-radius: var(--oc-radius-card, 16px);
background-color: var(--oc-border, #E3E7ED);
margin-bottom: var(--oc-space-24, 24px);
border-radius: var(--oc-radius-card, 20px);
background-color: var(--oc-border, #EBF1F7);
margin-block-end: var(--oc-space-20, 20px);
}
.skeleton-pill-row {
display: flex;
flex-direction: row;
gap: var(--oc-space-8, 8px);
margin-block-end: var(--oc-space-12, 12px);
}
.skeleton-pill {
height: 22px;
width: 80px;
border-radius: var(--oc-radius-pill, 9999px);
background-color: var(--oc-border, #EBF1F7);
}
.pill-short {
width: 100px;
}
.skeleton-line {
height: 12px;
border-radius: 6px;
background-color: var(--oc-border, #E3E7ED);
margin-bottom: var(--oc-space-12, 12px);
height: 14px;
border-radius: 8px;
background-color: var(--oc-border, #EBF1F7);
margin-block-end: var(--oc-space-12, 12px);
}
.skeleton-title {
height: 24px;
width: 72%;
.line-title {
height: 28px;
width: 75%;
margin-block-end: var(--oc-space-20, 20px);
}
.skeleton-short {
width: 48%;
.line-short {
width: 45%;
}
.skeleton-bento-summary {
height: 80px;
border-radius: var(--oc-radius-card, 20px);
background-color: var(--oc-border, #EBF1F7);
margin-block-end: var(--oc-space-20, 20px);
}
/* 空状态卡片 */
.article-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding-inline: var(--oc-space-16, 16px);
padding-block: var(--oc-space-24, 24px);
}
.article-empty-card {
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
align-items: center;
padding: var(--oc-space-24, 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));
box-sizing: border-box;
}
.article-empty-text {
.empty-icon-box {
width: 44px;
height: 44px;
border-radius: 50%;
background-color: var(--oc-primary-soft, #EBF5FF);
display: flex;
align-items: center;
justify-content: center;
margin-block-end: var(--oc-space-12, 12px);
}
.empty-icon-symbol {
font-size: 20px;
font-weight: 700;
color: var(--oc-primary, #0088FF);
}
.article-empty-title {
font-size: 16px;
color: var(--oc-text-2, #667085);
line-height: 1.6;
margin-bottom: var(--oc-space-24, 24px);
font-weight: 600;
color: var(--oc-text, #101828);
line-height: 1.4;
text-align: center;
margin-block-end: var(--oc-space-8, 8px);
}
.article-empty-desc {
font-size: 14px;
color: var(--oc-text-2, #64748B);
line-height: 1.6;
text-align: center;
margin-block-end: var(--oc-space-20, 20px);
}
.article-empty-cta {
max-width: 200px;
}
/* 滚动容器 */
.article-scroll {
flex: 1;
}
@@ -155,28 +534,83 @@ onLoad((options: ArticleDetailPageOptions) => {
.article-content {
display: flex;
flex-direction: column;
padding: var(--oc-space-16, 16px);
padding-inline: var(--oc-space-16, 16px);
padding-block-start: var(--oc-space-8, 8px);
max-width: 720px;
width: 100%;
margin: 0 auto;
margin-inline: auto;
box-sizing: border-box;
}
/* 封面大图 */
.cover-wrapper {
width: 100%;
height: 200px;
border-radius: var(--oc-radius-card, 20px);
overflow: hidden;
border: 1px solid var(--oc-border, #EBF1F7);
box-shadow: var(--oc-shadow-card, 0 8px 30px rgba(0, 0, 0, 0.04));
margin-block-end: var(--oc-space-20, 20px);
}
.article-cover {
width: 100%;
height: 200px;
border-radius: var(--oc-radius-card, 16px);
border: 1px solid var(--oc-border, #E3E7ED);
margin-bottom: var(--oc-space-24, 24px);
}
.article-kicker {
/* 头部信息 */
.article-header {
display: flex;
flex-direction: column;
margin-block-end: var(--oc-space-16, 16px);
}
.header-badge-row {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: var(--oc-space-8, 8px);
margin-block-end: var(--oc-space-12, 12px);
}
.kicker-badge {
display: flex;
flex-direction: row;
align-items: center;
padding-inline: var(--oc-space-12, 12px);
padding-block: 4px;
border-radius: var(--oc-radius-pill, 9999px);
background-color: var(--oc-primary-soft, #EBF5FF);
border: 1px solid rgba(0, 136, 255, 0.15);
}
.kicker-pulse-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--oc-primary, #0088FF);
margin-inline-end: var(--oc-space-8, 8px);
}
.kicker-text {
font-size: 12px;
font-weight: 600;
color: var(--oc-primary, #0B5CFF);
margin-bottom: var(--oc-space-8, 8px);
line-height: 1.25;
letter-spacing: 0.5px;
color: var(--oc-primary, #0088FF);
line-height: 1.3;
}
.reading-time-pill {
display: flex;
align-items: center;
padding-inline: var(--oc-space-8, 8px);
padding-block: 4px;
}
.reading-time-text {
font-size: 12px;
color: var(--oc-text-2, #64748B);
line-height: 1.3;
}
.article-title {
@@ -184,57 +618,240 @@ onLoad((options: ArticleDetailPageOptions) => {
font-weight: 700;
color: var(--oc-text, #101828);
line-height: 1.25;
margin-bottom: var(--oc-space-16, 16px);
}
/* 核心导读 Bento 卡片 (Executive Summary Bento Card) */
.summary-bento-card {
position: relative;
display: flex;
flex-direction: row;
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));
overflow: hidden;
margin-block-end: var(--oc-space-20, 20px);
}
.summary-bento-bar {
width: 4px;
background: var(--oc-primary-gradient, linear-gradient(135deg, #00A3FF 0%, #0B63E5 100%));
}
.summary-bento-content {
flex: 1;
display: flex;
flex-direction: column;
padding: var(--oc-space-16, 16px);
}
.summary-heading-row {
display: flex;
flex-direction: row;
align-items: center;
margin-block-end: var(--oc-space-8, 8px);
}
.summary-heading-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--oc-primary, #0088FF);
margin-inline-end: var(--oc-space-8, 8px);
}
.summary-heading-label {
font-size: 14px;
font-weight: 600;
color: var(--oc-text, #101828);
line-height: 1.3;
}
.summary-body-text {
font-size: 14px;
color: var(--oc-text-2, #64748B);
line-height: 1.6;
}
/* 正文排版区 */
.article-body-wrapper {
margin-block-end: var(--oc-space-24, 24px);
}
.article-rich {
font-size: 16px;
color: var(--oc-text, #101828);
line-height: 1.7;
line-height: 1.65;
}
.article-body {
font-size: 16px;
color: var(--oc-text, #101828);
line-height: 1.7;
line-height: 1.65;
}
/* 专栏作者微展台 Bento 卡片 (Author Profile Micro Showcase) */
.author-showcase-card {
display: flex;
flex-direction: column;
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: var(--oc-space-16, 16px);
margin-block-end: var(--oc-space-24, 24px);
transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.author-showcase-card:active {
opacity: 0.85;
}
.author-card-content {
display: flex;
flex-direction: row;
align-items: center;
}
.author-avatar-wrap {
position: relative;
width: 52px;
height: 52px;
margin-inline-end: var(--oc-space-12, 12px);
flex-shrink: 0;
}
.author-avatar {
width: 52px;
height: 52px;
border-radius: 50%;
border: 2px solid #FFFFFF;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.author-status-ring {
position: absolute;
bottom: 0;
inset-inline-end: 0;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: var(--oc-success, #12B76A);
border: 2px solid #FFFFFF;
}
.author-info-column {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.author-tag-row {
margin-block-end: 2px;
}
.author-tag-text {
font-size: 12px;
font-weight: 500;
color: var(--oc-primary, #0088FF);
}
.author-name {
font-size: 16px;
font-weight: 700;
color: var(--oc-text, #101828);
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.author-subtitle {
font-size: 12px;
color: var(--oc-text-2, #64748B);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-block-start: 2px;
}
.author-action-chip {
display: flex;
flex-direction: row;
align-items: center;
min-height: 44px;
padding-inline: var(--oc-space-8, 8px);
margin-inline-start: var(--oc-space-8, 8px);
flex-shrink: 0;
}
.author-action-label {
font-size: 12px;
font-weight: 500;
color: var(--oc-primary, #0088FF);
margin-inline-end: 2px;
}
.author-action-arrow {
font-size: 14px;
font-weight: 600;
color: var(--oc-primary, #0088FF);
}
/* 底部常驻主 CTA 避让高度 */
.article-bottom-spacer {
height: calc(88px + env(safe-area-inset-bottom, 0px));
height: calc(96px + env(safe-area-inset-bottom, 0px) + var(--window-bottom, 0px));
}
/* 底部常驻悬浮主 CTA 栏 */
.article-cta-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
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);
bottom: var(--window-bottom, 0px);
padding-block-start: 12px;
padding-inline: 16px;
padding-block-end: calc(16px + env(safe-area-inset-bottom, 0px));
background-color: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(16px);
border-top: 1px solid var(--oc-border, #EBF1F7);
box-sizing: border-box;
z-index: 100;
}
/* 单屏唯一定位主 CTA 胶囊 */
.primary-cta-btn {
height: 48px;
min-height: 44px;
background-color: var(--oc-primary, #0B5CFF);
border-radius: var(--oc-radius-btn, 12px);
width: 100%;
border-width: 0;
border-radius: var(--oc-radius-btn, 14px);
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;
width: 100%;
border-width: 0;
transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.primary-cta-btn:active {
opacity: 0.85;
opacity: 0.88;
}
.primary-cta-btn-text {
color: var(--oc-bg, #FFFFFF);
font-size: 15px;
color: #FFFFFF;
font-size: 16px;
font-weight: 600;
line-height: 1;
}
/* 无障碍减弱动效 */
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}
</style>

查看文件

@@ -1,102 +1,280 @@
<template>
<view class="case-page" :style="pageTheme">
<view class="case-page" :class="pageRtlClass" :style="pageTheme">
<!-- 顶部受控局部极光微光晕 (面积 < 8%) -->
<view class="ambient-aurora-glow"></view>
<!-- 骨架屏加载态 -->
<view v-if="state.loading" class="case-skeleton">
<view class="skeleton-badge"></view>
<view class="skeleton-line line-title"></view>
<view class="skeleton-media"></view>
<view class="skeleton-line"></view>
<view class="skeleton-line line-short"></view>
<view class="skeleton-grid">
<view class="skeleton-kpi-card"></view>
<view class="skeleton-kpi-card"></view>
<view class="skeleton-kpi-card"></view>
</view>
<view class="skeleton-card"></view>
</view>
<view v-else-if="state.empty" class="case-empty">
<text class="case-empty-text">{{ copy.empty }}</text>
<button class="primary-cta-btn" @click="handleBack">
<text class="primary-cta-btn-text">{{ copy.back }}</text>
</button>
<!-- 异常与未找到空状态 -->
<view v-else-if="state.empty" class="case-empty-wrap">
<view class="case-empty-card">
<view class="empty-badge">
<text class="empty-badge-icon">!</text>
</view>
<text class="case-empty-title">{{ t('empty') }}</text>
<text class="case-empty-desc">{{ t('emptyDesc') }}</text>
<button class="primary-cta-btn empty-back-btn" @click="handleBack">
<text class="primary-cta-btn-text">{{ t('back') }}</text>
</button>
</view>
</view>
<!-- 核心案例内容流 (Bento Grid 便当盒模组) -->
<scroll-view v-else class="case-scroll" :scroll-y="true">
<view class="case-content">
<text class="case-title">{{ state.title }}</text>
<!-- 1. 案例头部徽标与主标题展台 -->
<view class="case-header-card">
<view class="case-badge-pill">
<view class="case-badge-dot"></view>
<text class="case-badge-text">{{ t('badge') }}</text>
</view>
<text class="case-title">{{ state.title }}</text>
<swiper
v-if="state.images.length > 1"
class="case-swiper"
:indicator-dots="true"
:autoplay="false"
circular
>
<swiper-item v-for="img in state.images" :key="img">
<image class="case-media" :src="img" mode="aspectFill" />
</swiper-item>
</swiper>
<image
v-else-if="state.images.length === 1"
class="case-media case-media-single"
:src="state.images[0]"
mode="aspectFill"
/>
<text v-if="state.content !== ''" class="case-body">{{ state.content }}</text>
<view v-if="state.kpis.length > 0" class="kpi-row">
<view v-for="kpi in state.kpis" :key="kpi.label" class="kpi-card">
<text class="kpi-value">{{ kpi.value }}</text>
<text class="kpi-label">{{ kpi.label }}</text>
<!-- 案例元信息胶囊行 (若配置) -->
<view v-if="hasMetaTags" class="case-meta-row">
<view v-if="state.client && state.client !== ''" class="case-meta-pill">
<text class="case-meta-label">{{ t('clientLabel') }}:</text>
<text class="case-meta-val">{{ state.client }}</text>
</view>
<view v-if="state.industry && state.industry !== ''" class="case-meta-pill">
<text class="case-meta-label">{{ t('industryLabel') }}:</text>
<text class="case-meta-val">{{ state.industry }}</text>
</view>
<view v-if="state.region && state.region !== ''" class="case-meta-pill">
<text class="case-meta-label">{{ t('regionLabel') }}:</text>
<text class="case-meta-val">{{ state.region }}</text>
</view>
</view>
</view>
<view v-if="state.pdfUrl !== ''" class="ext-link-row">
<text class="ext-link-label">{{ copy.pdf }}</text>
<text class="ext-link-url">{{ state.pdfUrl }}</text>
</view>
<view v-if="state.youtubeUrl !== ''" class="ext-link-row">
<text class="ext-link-label">{{ copy.youtube }}</text>
<text class="ext-link-url">{{ state.youtubeUrl }}</text>
<!-- 2. 多图画廊轮播 / 单图视觉展台 -->
<view v-if="state.images.length > 0" class="gallery-wrapper">
<swiper
v-if="state.images.length > 1"
class="case-swiper"
:indicator-dots="true"
indicator-color="rgba(0, 136, 255, 0.2)"
indicator-active-color="#0088FF"
:autoplay="false"
circular
>
<swiper-item v-for="img in state.images" :key="img">
<image class="case-media" :src="img" mode="aspectFill" />
</swiper-item>
</swiper>
<image
v-else-if="state.images.length === 1"
class="case-media case-media-single"
:src="state.images[0]"
mode="aspectFill"
/>
</view>
<!-- 3. 核心商业化成效指标卡 (Bento KPI Cards) -->
<view v-if="visibleKpis.length > 0" class="bento-card kpi-section">
<view class="bento-card-header">
<view class="bento-header-left">
<view class="bento-tag-bar tag-primary"></view>
<text class="bento-card-title">{{ t('kpis') }}</text>
</view>
</view>
<view class="kpi-grid">
<view v-for="kpi in visibleKpis" :key="kpi.label" class="kpi-card">
<text class="kpi-value">{{ kpi.value }}</text>
<text class="kpi-label">{{ kpi.label }}</text>
</view>
</view>
</view>
<!-- 4. 客户挑战与背景卡 (Bento Challenge Card) -->
<view v-if="state.challenge && state.challenge !== ''" class="bento-card challenge-section">
<view class="bento-card-header">
<view class="bento-header-left">
<view class="bento-tag-bar tag-warning"></view>
<text class="bento-card-title">{{ t('challenge') }}</text>
</view>
</view>
<text class="bento-card-body">{{ state.challenge }}</text>
</view>
<!-- 5. 落地技术架构与解决方案卡 (Bento Solution / Overview Card) -->
<view v-if="state.solution && state.solution !== ''" class="bento-card solution-section">
<view class="bento-card-header">
<view class="bento-header-left">
<view class="bento-tag-bar tag-success"></view>
<text class="bento-card-title">{{ t('solution') }}</text>
</view>
</view>
<text class="bento-card-body">{{ state.solution }}</text>
</view>
<!-- 若无独立结构化拆分,但有正文内容,呈现为综合方案卡 -->
<view
v-else-if="state.content && state.content !== ''"
class="bento-card overview-section"
>
<view class="bento-card-header">
<view class="bento-header-left">
<view class="bento-tag-bar tag-primary"></view>
<text class="bento-card-title">{{ t('overview') }}</text>
</view>
</view>
<text class="bento-card-body">{{ state.content }}</text>
</view>
<!-- 6. 商务白皮书与演示视频数字资产卡 (Bento Resources) -->
<view v-if="hasResources" class="bento-card resource-section">
<view class="bento-card-header">
<view class="bento-header-left">
<view class="bento-tag-bar tag-ai"></view>
<text class="bento-card-title">{{ t('resources') }}</text>
</view>
</view>
<view class="resource-list">
<!-- PDF 白皮书外链行 -->
<view
v-if="state.pdfUrl !== ''"
class="ext-link-row"
@click="handleCopyLink(state.pdfUrl)"
>
<view class="ext-link-main">
<view class="ext-badge ext-badge-pdf">
<text class="ext-badge-text">{{ t('pdf') }}</text>
</view>
<view class="ext-info">
<text class="ext-link-action">{{ t('pdfAction') }}</text>
<text class="ext-link-url">{{ state.pdfUrl }}</text>
</view>
</view>
<view class="ext-arrow-box">
<text class="ext-arrow-icon">↗</text>
</view>
</view>
<!-- 演示视频外链行 -->
<view
v-if="state.youtubeUrl !== ''"
class="ext-link-row"
@click="handleCopyLink(state.youtubeUrl)"
>
<view class="ext-link-main">
<view class="ext-badge ext-badge-video">
<text class="ext-badge-text">{{ t('youtube') }}</text>
</view>
<view class="ext-info">
<text class="ext-link-action">{{ t('videoAction') }}</text>
<text class="ext-link-url">{{ state.youtubeUrl }}</text>
</view>
</view>
<view class="ext-arrow-box">
<text class="ext-arrow-icon">↗</text>
</view>
</view>
</view>
</view>
<!-- 底部安全垫高,避免被常驻主 CTA 按钮遮挡 -->
<view class="case-bottom-spacer"></view>
</view>
</scroll-view>
<!-- 单屏唯一定位主 CTA (即时咨询方案主理人) -->
<view v-if="!state.loading && !state.empty" class="case-cta-bar">
<button class="primary-cta-btn" @click="handleConsult">
<text class="primary-cta-btn-text">{{ copy.cta }}</text>
<text class="primary-cta-btn-text">{{ t('cta') }}</text>
</button>
</view>
</view>
</template>
<script setup lang="uts">
import { utsLightColors, utsRadius, utsSpacing } from '../../common/tokens.uts';
import { utsLightColors, utsRadius, utsSpacing, utsTypography } from '../../common/tokens.uts';
import { getCurrentLocale, isRtl } from '../../common/locale.uts';
import {
CASE_DETAIL_COPY,
CaseDetailCopyKey,
CaseKpi,
createCaseDetailState,
applyCaseDetailOptions,
loadCaseDetail,
openCaseExchange,
goCaseBack,
copyCaseExternalLink,
resolveCaseDetailText,
CaseDetailPageOptions,
} from './useCaseDetail.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,
'--oc-space-32': utsSpacing.space32,
};
const copy = CASE_DETAIL_COPY;
const state = reactive(createCaseDetailState());
const pageRtlClass = computed((): string => {
return isRtl(getCurrentLocale()) ? 'case-rtl' : '';
});
const hasMetaTags = computed((): boolean => {
return (
(state.client != null && state.client !== '') ||
(state.industry != null && state.industry !== '') ||
(state.region != null && state.region !== '')
);
});
const hasResources = computed((): boolean => {
return state.pdfUrl !== '' || state.youtubeUrl !== '';
});
const visibleKpis = computed((): CaseKpi[] => {
const list = state.kpis;
if (list.length <= 3) {
return list;
}
return list.slice(0, 3);
});
function t(key: CaseDetailCopyKey): string {
return resolveCaseDetailText(key);
}
function handleConsult(): void {
openCaseExchange(state.cardId);
}
@@ -105,6 +283,10 @@ function handleBack(): void {
goCaseBack();
}
function handleCopyLink(url: string): void {
copyCaseExternalLink(url);
}
onLoad((options: CaseDetailPageOptions) => {
applyCaseDetailOptions(state, options);
loadCaseDetail(state);
@@ -113,173 +295,505 @@ onLoad((options: CaseDetailPageOptions) => {
<style scoped>
.case-page {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--oc-surface, #F7F8FA);
background-color: var(--oc-bg, #F5F8FC);
overflow: hidden;
}
/* 顶部受控极光微光晕 (面积 < 8%) */
.ambient-aurora-glow {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 180px;
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 镜像适配 */
.case-rtl {
direction: rtl;
text-align: right;
}
.case-rtl .case-header-card,
.case-rtl .bento-card {
text-align: right;
}
.case-rtl .bento-header-left,
.case-rtl .case-meta-row,
.case-rtl .ext-link-main {
flex-direction: row;
justify-content: flex-start;
}
.case-rtl .ext-arrow-icon {
display: inline-block;
transform: scaleX(-1);
}
.case-scroll {
position: relative;
flex: 1;
z-index: 1;
}
.case-content {
display: flex;
flex-direction: column;
padding: var(--oc-space-16, 16px);
padding-inline: var(--oc-space-16, 16px);
padding-block-start: var(--oc-space-16, 16px);
padding-block-end: var(--oc-space-16, 16px);
max-width: 720px;
width: 100%;
margin-inline: auto;
box-sizing: border-box;
}
/* 1. 头部主卡片与徽标 */
.case-header-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: var(--oc-space-20, 20px);
display: flex;
flex-direction: column;
margin-block-end: var(--oc-space-16, 16px);
}
.case-badge-pill {
align-self: flex-start;
display: flex;
flex-direction: row;
align-items: center;
padding: 4px 10px;
background-color: var(--oc-primary-soft, #EBF5FF);
border-radius: var(--oc-radius-pill, 9999px);
margin-block-end: var(--oc-space-12, 12px);
}
.case-badge-dot {
width: 6px;
height: 6px;
border-radius: 3px;
background-color: var(--oc-primary, #0088FF);
margin-inline-end: 6px;
}
.case-badge-text {
font-size: 11px;
font-weight: 700;
color: var(--oc-primary, #0088FF);
letter-spacing: 0.5px;
}
.case-title {
font-size: 24px;
font-size: 22px;
font-weight: 700;
color: var(--oc-text, #101828);
line-height: 1.25;
margin-bottom: var(--oc-space-16, 16px);
line-height: 1.3;
}
.case-meta-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
margin-block-start: var(--oc-space-12, 12px);
}
.case-meta-pill {
display: flex;
flex-direction: row;
align-items: center;
padding: 3px 8px;
background-color: var(--oc-bg, #F5F8FC);
border: 1px solid var(--oc-border, #EBF1F7);
border-radius: 6px;
}
.case-meta-label {
font-size: 11px;
color: var(--oc-text-2, #64748B);
margin-inline-end: 4px;
}
.case-meta-val {
font-size: 11px;
font-weight: 600;
color: var(--oc-text, #101828);
}
/* 2. 多图画廊展台 */
.gallery-wrapper {
margin-block-end: var(--oc-space-16, 16px);
}
.case-swiper {
height: 220px;
border-radius: var(--oc-radius-card, 16px);
border-radius: var(--oc-radius-card, 20px);
overflow: hidden;
margin-bottom: var(--oc-space-16, 16px);
border: 1px solid var(--oc-border, #E3E7ED);
border: 1px solid var(--oc-border, #EBF1F7);
box-shadow: var(--oc-shadow-card, 0 8px 30px rgba(0, 0, 0, 0.04));
background-color: var(--oc-surface, #FFFFFF);
}
.case-media {
width: 100%;
height: 220px;
background-color: var(--oc-surface, #F7F8FA);
background-color: var(--oc-bg, #F5F8FC);
}
.case-media-single {
border-radius: var(--oc-radius-card, 16px);
border: 1px solid var(--oc-border, #E3E7ED);
margin-bottom: var(--oc-space-16, 16px);
border-radius: var(--oc-radius-card, 20px);
border: 1px solid var(--oc-border, #EBF1F7);
box-shadow: var(--oc-shadow-card, 0 8px 30px rgba(0, 0, 0, 0.04));
}
.case-body {
font-size: 16px;
/* Bento Grid 通用卡片规范 */
.bento-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: var(--oc-space-20, 20px);
display: flex;
flex-direction: column;
margin-block-end: var(--oc-space-16, 16px);
box-sizing: border-box;
}
.bento-card-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-block-end: var(--oc-space-12, 12px);
}
.bento-header-left {
display: flex;
flex-direction: row;
align-items: center;
}
.bento-tag-bar {
width: 3px;
height: 14px;
border-radius: 2px;
background-color: var(--oc-primary, #0088FF);
margin-inline-end: 8px;
}
.tag-primary {
background-color: var(--oc-primary, #0088FF);
}
.tag-warning {
background-color: var(--oc-warning, #F79009);
}
.tag-success {
background-color: var(--oc-success, #12B76A);
}
.tag-ai {
background-color: var(--oc-ai, #7C5CFF);
}
.bento-card-title {
font-size: 15px;
font-weight: 700;
color: var(--oc-text, #101828);
line-height: 1.6;
margin-bottom: var(--oc-space-24, 24px);
letter-spacing: 0.2px;
}
.kpi-row {
.bento-card-body {
font-size: 15px;
color: var(--oc-text, #101828);
line-height: 1.65;
}
/* 3. 核心商业化成果数据卡 (KPI Grid) */
.kpi-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-bottom: var(--oc-space-16, 16px);
gap: 8px;
}
.kpi-card {
flex: 1;
min-width: 96px;
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);
margin-inline-end: var(--oc-space-8, 8px);
margin-bottom: var(--oc-space-8, 8px);
min-width: 90px;
background-color: var(--oc-bg, #F5F8FC);
border: 1px solid var(--oc-border, #EBF1F7);
border-radius: 14px;
padding: 12px;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.kpi-value {
font-size: 24px;
font-weight: 700;
color: var(--oc-primary, #0B5CFF);
line-height: 1.25;
margin-bottom: var(--oc-space-8, 8px);
color: var(--oc-primary, #0088FF);
line-height: 1.2;
margin-block-end: 4px;
}
.kpi-label {
font-size: 12px;
color: var(--oc-text-2, #667085);
line-height: 1.6;
color: var(--oc-text-2, #64748B);
line-height: 1.4;
}
/* 6. 权威资料与演示资产卡 */
.resource-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.ext-link-row {
display: flex;
flex-direction: column;
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);
margin-bottom: var(--oc-space-12, 12px);
flex-direction: row;
align-items: center;
justify-content: space-between;
min-height: 52px;
background-color: var(--oc-bg, #F5F8FC);
border: 1px solid var(--oc-border, #EBF1F7);
border-radius: var(--oc-radius-btn, 14px);
padding: 10px 14px;
cursor: pointer;
box-sizing: border-box;
transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ext-link-label {
font-size: 12px;
color: var(--oc-text-2, #667085);
margin-bottom: var(--oc-space-8, 8px);
.ext-link-row:active {
opacity: 0.85;
}
.ext-link-main {
display: flex;
flex-direction: row;
align-items: center;
flex: 1;
overflow: hidden;
}
.ext-badge {
padding: 4px 8px;
border-radius: 6px;
margin-inline-end: 10px;
flex-shrink: 0;
}
.ext-badge-pdf {
background-color: rgba(240, 68, 56, 0.1);
}
.ext-badge-pdf .ext-badge-text {
color: var(--oc-error, #F04438);
}
.ext-badge-video {
background-color: rgba(0, 136, 255, 0.1);
}
.ext-badge-video .ext-badge-text {
color: var(--oc-primary, #0088FF);
}
.ext-badge-text {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
}
.ext-info {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
.ext-link-action {
font-size: 13px;
font-weight: 600;
color: var(--oc-text, #101828);
line-height: 1.4;
}
.ext-link-url {
font-size: 11px;
color: var(--oc-text-2, #64748B);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ext-arrow-box {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
margin-inline-start: 8px;
flex-shrink: 0;
}
.ext-arrow-icon {
font-size: 14px;
color: var(--oc-primary, #0B5CFF);
line-height: 1.6;
font-weight: 700;
color: var(--oc-primary, #0088FF);
}
/* 底部垫高 */
.case-bottom-spacer {
height: calc(88px + env(safe-area-inset-bottom, 0px));
height: calc(96px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px));
}
/* 骨架屏 */
.case-skeleton {
padding: var(--oc-space-24, 24px) var(--oc-space-16, 16px);
padding-block: var(--oc-space-24, 24px);
padding-inline: var(--oc-space-16, 16px);
display: flex;
flex-direction: column;
max-width: 720px;
width: 100%;
margin-inline: auto;
box-sizing: border-box;
}
.skeleton-badge {
width: 80px;
height: 20px;
border-radius: 10px;
background-color: var(--oc-border, #EBF1F7);
margin-block-end: var(--oc-space-12, 12px);
}
.skeleton-line {
height: 16px;
background-color: var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-btn, 12px);
margin-bottom: var(--oc-space-12, 12px);
background-color: var(--oc-border, #EBF1F7);
border-radius: 8px;
margin-block-end: var(--oc-space-12, 12px);
}
.line-title {
width: 60%;
height: 24px;
}
.line-short {
width: 40%;
width: 75%;
height: 26px;
}
.skeleton-media {
height: 180px;
background-color: var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-card, 16px);
margin: var(--oc-space-16, 16px) 0;
height: 200px;
background-color: var(--oc-border, #EBF1F7);
border-radius: var(--oc-radius-card, 20px);
margin-block-end: var(--oc-space-16, 16px);
}
.case-empty {
margin: 40px var(--oc-space-16, 16px);
.skeleton-grid {
display: flex;
flex-direction: row;
gap: 8px;
margin-block-end: var(--oc-space-16, 16px);
}
.skeleton-kpi-card {
flex: 1;
height: 72px;
background-color: var(--oc-surface, #FFFFFF);
border: 1px solid var(--oc-border, #EBF1F7);
border-radius: 14px;
}
.skeleton-card {
height: 120px;
background-color: var(--oc-surface, #FFFFFF);
border: 1px solid var(--oc-border, #EBF1F7);
border-radius: var(--oc-radius-card, 20px);
}
/* 异常与未找到空状态 */
.case-empty-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding-inline: var(--oc-space-16, 16px);
padding-block: var(--oc-space-24, 24px);
}
.case-empty-card {
max-width: 340px;
width: 100%;
padding: var(--oc-space-24, 24px);
background-color: var(--oc-bg, #FFFFFF);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-card, 16px);
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));
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
}
.case-empty-text {
.empty-badge {
width: 48px;
height: 48px;
border-radius: 24px;
background-color: rgba(0, 136, 255, 0.08);
display: flex;
align-items: center;
justify-content: center;
margin-block-end: var(--oc-space-16, 16px);
}
.empty-badge-icon {
font-size: 20px;
font-weight: 700;
color: var(--oc-primary, #0088FF);
}
.case-empty-title {
font-size: 16px;
font-weight: 600;
color: var(--oc-text, #101828);
line-height: 1.6;
margin-bottom: var(--oc-space-24, 24px);
line-height: 1.5;
margin-block-end: var(--oc-space-8, 8px);
text-align: center;
}
.case-empty-desc {
font-size: 14px;
color: var(--oc-text-2, #64748B);
line-height: 1.5;
margin-block-end: var(--oc-space-20, 20px);
text-align: center;
}
.empty-back-btn {
max-width: 220px;
}
/* 常驻底部唯一定位主 CTA 栏 */
.case-cta-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
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);
bottom: var(--window-bottom, 0px);
padding-block-start: 12px;
padding-inline: var(--oc-space-16, 16px);
padding-block-end: calc(16px + env(safe-area-inset-bottom, 0px));
background-color: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(12px);
border-top: 1px solid var(--oc-border, #EBF1F7);
box-sizing: border-box;
z-index: 100;
}
@@ -287,27 +801,33 @@ onLoad((options: CaseDetailPageOptions) => {
.primary-cta-btn {
height: 48px;
min-height: 44px;
background-color: var(--oc-primary, #0B5CFF);
border-radius: var(--oc-radius-btn, 12px);
background: var(--oc-primary-gradient, linear-gradient(135deg, #00A3FF 0%, #0B63E5 100%));
border-radius: var(--oc-radius-btn, 14px);
box-shadow: var(--oc-shadow-cta, 0 8px 20px rgba(0, 136, 255, 0.22));
display: flex;
align-items: center;
justify-content: center;
width: 100%;
border-width: 0;
cursor: pointer;
transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.primary-cta-btn:active {
opacity: 0.85;
opacity: 0.88;
}
.primary-cta-btn-text {
color: var(--oc-bg, #FFFFFF);
font-size: 15px;
color: #FFFFFF;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.3px;
}
.case-empty .primary-cta-btn {
max-width: 240px;
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}
</style>

文件差异内容过多而无法显示 加载差异

查看文件

@@ -3,9 +3,9 @@
* 严格遵循 uts 强类型规范与 AGENTS.md 约束
*/
import { BentoBlockPublic } from '../../common/types.uts';
import { fetchPortalCard } from '../../common/api.uts';
import { getCurrentLocale, getOmniText } from '../../common/locale.uts';
import { BentoBlockPublic, CardPortalDetail, RadarTrack } from '../../common/types.uts';
import { fetchPortalCard, recordRadarTrack } from '../../common/api.uts';
import { getCurrentLocale, getOmniText, OmniLocale } from '../../common/locale.uts';
export type ArticleDetailPageOptions = {
cardId?: string;
@@ -41,11 +41,21 @@ export type ArticleDetailState = {
empty: boolean;
errorMessage: string;
openGraph: ArticleOpenGraph;
authorName: string;
authorTitle: string;
authorCompany: string;
authorAvatar: string;
readTimeMinutes: number;
summaryText: string;
scrollDepth: number;
enterTime: number;
visitorId: string;
};
const SHARE_CTA_LABEL = '复制链接';
const BACK_LABEL = '返回';
const OG_DESC_MAX = 160;
const VISITOR_STORAGE_KEY = 'omni_visitor_id';
/**
* 创建文章详情初始状态
@@ -68,6 +78,15 @@ export function createArticleDetailState(): ArticleDetailState {
description: '',
image: '',
},
authorName: '',
authorTitle: '',
authorCompany: '',
authorAvatar: '',
readTimeMinutes: 1,
summaryText: '',
scrollDepth: 0,
enterTime: Date.now(),
visitorId: '',
};
}
@@ -87,6 +106,13 @@ export function applyEmptyArticle(state: ArticleDetailState): void {
description: '',
image: '',
};
state.authorName = '';
state.authorTitle = '';
state.authorCompany = '';
state.authorAvatar = '';
state.readTimeMinutes = 1;
state.summaryText = '';
state.scrollDepth = 0;
}
/**
@@ -241,7 +267,230 @@ export function getArticleEmptyMessage(): string {
return getOmniText(getCurrentLocale(), 'common.empty');
}
function applyArticle(state: ArticleDetailState, article: BentoBlockPublic): void {
export type ArticleDetailTextKey =
| 'kicker'
| 'shareCta'
| 'back'
| 'empty'
| 'emptyHint'
| 'summary'
| 'readTime'
| 'minRead'
| 'author'
| 'viewPortal';
/**
* 文章详情展示文案。严格遵循 AGENTS.md 多语言自然表达规范。
*/
export function resolveArticleDetailText(
key: ArticleDetailTextKey,
customLocale?: OmniLocale
): string {
const locale = customLocale != null ? customLocale : getCurrentLocale();
if (key === 'empty') {
return getOmniText(locale, 'common.empty');
}
if (locale === 'zh-CN') {
if (key === 'kicker') {
return '深度洞见';
}
if (key === 'summary') {
return '核心导读';
}
if (key === 'readTime') {
return '预计阅读';
}
if (key === 'minRead') {
return '分钟';
}
if (key === 'author') {
return '专栏作者';
}
if (key === 'viewPortal') {
return '查看微官网';
}
if (key === 'shareCta') {
return '复制分享链接';
}
if (key === 'back') {
return '返回';
}
return '返回上一页继续浏览';
}
if (locale === 'ar-SA') {
if (key === 'kicker') {
return 'رؤى متعمقة';
}
if (key === 'summary') {
return 'أبرز النقاط';
}
if (key === 'readTime') {
return 'وقت القراءة';
}
if (key === 'minRead') {
return 'دقائق';
}
if (key === 'author') {
return 'كاتب المقال';
}
if (key === 'viewPortal') {
return 'عرض الملف التعريفي';
}
if (key === 'shareCta') {
return 'نسخ رابط المشاركة';
}
if (key === 'back') {
return 'رجوع';
}
return 'ارجع للصفحة السابقة للمتابعة';
}
if (locale === 'es-ES') {
if (key === 'kicker') {
return 'Perspectivas';
}
if (key === 'summary') {
return 'Puntos clave';
}
if (key === 'readTime') {
return 'Tiempo de lectura';
}
if (key === 'minRead') {
return 'min';
}
if (key === 'author') {
return 'Autor';
}
if (key === 'viewPortal') {
return 'Ver perfil';
}
if (key === 'shareCta') {
return 'Copiar enlace para compartir';
}
if (key === 'back') {
return 'Volver';
}
return 'Vuelve para seguir leyendo';
}
if (locale === 'de-DE') {
if (key === 'kicker') {
return 'Brancheneinblick';
}
if (key === 'summary') {
return 'Kernaussagen';
}
if (key === 'readTime') {
return 'Lesezeit';
}
if (key === 'minRead') {
return 'Min.';
}
if (key === 'author') {
return 'Autor';
}
if (key === 'viewPortal') {
return 'Profil ansehen';
}
if (key === 'shareCta') {
return 'Freigabelink kopieren';
}
if (key === 'back') {
return 'Zurück';
}
return 'Gehen Sie zurück, um weiterzulesen';
}
if (locale === 'fr-FR') {
if (key === 'kicker') {
return 'Éclairage d’expert';
}
if (key === 'summary') {
return 'Points clés';
}
if (key === 'readTime') {
return 'Temps de lecture';
}
if (key === 'minRead') {
return 'min';
}
if (key === 'author') {
return 'Auteur';
}
if (key === 'viewPortal') {
return 'Voir le profil';
}
if (key === 'shareCta') {
return 'Copier le lien de partage';
}
if (key === 'back') {
return 'Retour';
}
return 'Revenez en arrière pour continuer';
}
if (locale === 'ja-JP') {
if (key === 'kicker') {
return 'インサイト';
}
if (key === 'summary') {
return '要約';
}
if (key === 'readTime') {
return '読了目安';
}
if (key === 'minRead') {
return '分';
}
if (key === 'author') {
return '執筆者';
}
if (key === 'viewPortal') {
return 'プロフィールを見る';
}
if (key === 'shareCta') {
return '共有リンクをコピー';
}
if (key === 'back') {
return '戻る';
}
return '前のページに戻って続きを見る';
}
if (key === 'kicker') {
return 'Thought Leadership';
}
if (key === 'summary') {
return 'Key Takeaways';
}
if (key === 'readTime') {
return 'Read time';
}
if (key === 'minRead') {
return 'min';
}
if (key === 'author') {
return 'Author';
}
if (key === 'viewPortal') {
return 'View Profile';
}
if (key === 'shareCta') {
return 'Copy share link';
}
if (key === 'back') {
return 'Back';
}
return 'Go back to keep reading';
}
function applyArticle(
state: ArticleDetailState,
article: BentoBlockPublic,
card?: CardPortalDetail | null
): void {
state.article = article;
state.title = article.title != null ? article.title : '';
state.content = article.content != null ? article.content : '';
@@ -250,10 +499,21 @@ function applyArticle(state: ArticleDetailState, article: BentoBlockPublic): voi
state.isHtmlContent = looksLikeHtml(state.content);
state.empty = false;
state.openGraph = buildArticleOpenGraph(article);
const cleanText = stripHtmlToText(state.content);
state.readTimeMinutes = Math.max(1, Math.ceil(cleanText.length / 350));
state.summaryText = state.openGraph.description;
if (card != null) {
state.authorName = card.name != null ? String(card.name) : '';
state.authorTitle = card.title != null ? String(card.title) : '';
state.authorCompany = card.company != null ? String(card.company) : '';
state.authorAvatar = card.avatarUrl != null ? String(card.avatarUrl) : '';
}
}
/**
* fetchPortalCard 后从 bentoBlocks 选出 article
* fetchPortalCard 后从 bentoBlocks 选出 article 并绑定作者与元信息
*/
export async function loadArticleDetail(
state: ArticleDetailState
@@ -265,6 +525,7 @@ export async function loadArticleDetail(
state.loading = true;
state.errorMessage = '';
state.enterTime = Date.now();
try {
const card = await fetchPortalCard(state.cardId);
@@ -277,7 +538,7 @@ export async function loadArticleDetail(
return null;
}
applyArticle(state, article);
applyArticle(state, article, card);
return article;
} catch (err) {
state.loading = false;
@@ -291,6 +552,95 @@ export async function loadArticleDetail(
}
}
/**
* 获取或初始化访客唯一识别码 (持久化在本地 Storage)
*/
export function getOrCreateVisitorId(): string {
let visitorId = '';
try {
visitorId = uni.getStorageSync(VISITOR_STORAGE_KEY) as string;
} catch (_e) {
visitorId = '';
}
if (!visitorId || visitorId.trim() === '') {
const randomPart = Math.random().toString(36).substring(2, 10);
visitorId = `vis_${Date.now()}_${randomPart}`;
try {
uni.setStorageSync(VISITOR_STORAGE_KEY, visitorId);
} catch (_e) {}
}
return visitorId;
}
/**
* 记录并更新滑屏深度 (0 - 100%)
*/
export function updateArticleScrollDepth(
state: ArticleDetailState,
scrollTop: number,
scrollHeight: number,
windowHeight: number
): number {
if (scrollHeight <= 0) {
return state.scrollDepth;
}
const maxScroll = Math.max(1, scrollHeight - windowHeight);
const currentRatio = Math.min(1, Math.max(0, scrollTop / maxScroll));
const depth = Math.round(currentRatio * 100);
if (depth > state.scrollDepth) {
state.scrollDepth = depth;
}
return state.scrollDepth;
}
/**
* 上报文章阅读停留时长与滑屏深度至访客雷达 CRM
*/
export async function reportArticleStay(
state: ArticleDetailState,
scrollDepth?: number
): Promise<RadarTrack | null> {
if (state.cardId === '' || state.blockId === '') {
return null;
}
const currentDepth = scrollDepth != null ? scrollDepth : state.scrollDepth;
const enter = state.enterTime > 0 ? state.enterTime : Date.now();
const stayDurationMs = Math.max(0, Date.now() - enter);
const visitorId = state.visitorId !== '' ? state.visitorId : getOrCreateVisitorId();
try {
const track = await recordRadarTrack({
cardId: state.cardId,
visitorId,
touchType: 'link',
stayDurationMs,
scrollDepth: currentDepth,
viewedBlocks: [state.blockId],
});
return track;
} catch (_e) {
return null;
}
}
/**
* 导航至专栏作者的名片主微官网
*/
export function navToAuthorPortal(state: ArticleDetailState): void {
if (state.cardId === '') {
return;
}
uni.navigateTo({
url: `/pages/portal/index?id=${state.cardId}`,
fail: () => {
uni.reLaunch({
url: `/pages/portal/index?id=${state.cardId}`,
});
},
});
}
/**
* 主 CTA:复制 linkUrl 或当前页 URL
*/

查看文件

@@ -5,6 +5,7 @@
import { CardPortalDetail, BentoBlockPublic } from '../../common/types.uts';
import { fetchPortalCard } from '../../common/api.uts';
import { getCurrentLocale, getOmniText } from '../../common/locale.uts';
export type CaseKpi = {
label: string;
@@ -28,6 +29,11 @@ export type CaseMetaRecord = {
youtube?: string;
url?: string;
type?: string;
client?: string;
industry?: string;
region?: string;
challenge?: string;
solution?: string;
};
export type CaseDetailState = {
@@ -42,6 +48,11 @@ export type CaseDetailState = {
kpis: CaseKpi[];
pdfUrl: string;
youtubeUrl: string;
client?: string;
industry?: string;
region?: string;
challenge?: string;
solution?: string;
};
export type CaseDetailPageOptions = {
@@ -50,15 +61,129 @@ export type CaseDetailPageOptions = {
id?: string;
};
export const CASE_DETAIL_COPY = {
export type CaseDetailCopyKey =
| 'empty'
| 'emptyDesc'
| 'back'
| 'cta'
| 'pdf'
| 'youtube'
| 'loading'
| 'badge'
| 'overview'
| 'challenge'
| 'solution'
| 'kpis'
| 'resources'
| 'pdfAction'
| 'videoAction'
| 'clientLabel'
| 'industryLabel'
| 'regionLabel';
export const CASE_DETAIL_COPY: Record<CaseDetailCopyKey, string> = {
empty: '未找到该案例',
emptyDesc: '返回上一页继续浏览',
back: '返回',
cta: '咨询 / 互换',
cta: '咨询',
pdf: 'PDF',
youtube: 'YouTube',
loading: '加载中',
badge: '标杆商务实践',
overview: '案例详情与方案',
challenge: '业务挑战与背景',
solution: '落地解决方案',
kpis: '核心商业化成果',
resources: '项目资料与演示',
pdfAction: '复制白皮书链接',
videoAction: '复制演示视频链接',
clientLabel: '标杆客户',
industryLabel: '行业赛道',
regionLabel: '业务覆盖区域',
};
function isZhLocale(): boolean {
return getCurrentLocale() === 'zh-CN';
}
/**
* 案例详情文案:模板只调本函数,禁止直接绑中文。词典能复用的走 getOmniText。
*/
export function resolveCaseDetailText(key: CaseDetailCopyKey): string {
const locale = getCurrentLocale();
if (key === 'loading') {
return getOmniText(locale, 'common.loading');
}
const zh = isZhLocale();
if (key === 'empty') {
return zh ? CASE_DETAIL_COPY.empty : 'This case is unavailable';
}
if (key === 'emptyDesc') {
return zh ? CASE_DETAIL_COPY.emptyDesc : 'Go back to keep browsing';
}
if (key === 'back') {
return zh ? CASE_DETAIL_COPY.back : 'Back';
}
if (key === 'cta') {
return zh ? CASE_DETAIL_COPY.cta : 'Inquire';
}
if (key === 'badge') {
return zh ? CASE_DETAIL_COPY.badge : 'GLOBAL CASE STUDY';
}
if (key === 'overview') {
return zh ? CASE_DETAIL_COPY.overview : 'Case Overview';
}
if (key === 'challenge') {
return zh ? CASE_DETAIL_COPY.challenge : 'Business Challenge';
}
if (key === 'solution') {
return zh ? CASE_DETAIL_COPY.solution : 'Solution & Architecture';
}
if (key === 'kpis') {
return zh ? CASE_DETAIL_COPY.kpis : 'Key Business Results';
}
if (key === 'resources') {
return zh ? CASE_DETAIL_COPY.resources : 'Project Resources';
}
if (key === 'pdfAction') {
return zh ? CASE_DETAIL_COPY.pdfAction : 'Copy Document Link';
}
if (key === 'videoAction') {
return zh ? CASE_DETAIL_COPY.videoAction : 'Copy Video Link';
}
if (key === 'clientLabel') {
return zh ? CASE_DETAIL_COPY.clientLabel : 'Client';
}
if (key === 'industryLabel') {
return zh ? CASE_DETAIL_COPY.industryLabel : 'Industry';
}
if (key === 'regionLabel') {
return zh ? CASE_DETAIL_COPY.regionLabel : 'Region';
}
return CASE_DETAIL_COPY[key];
}
/**
* 外链无 open 能力时只复制,不发起新请求
*/
export function copyCaseExternalLink(url: string): boolean {
const trimmed = url != null ? url.trim() : '';
if (trimmed === '') {
return false;
}
uni.setClipboardData({
data: trimmed,
success: () => {
uni.showToast({
title: getOmniText(getCurrentLocale(), 'portal.copied_link'),
icon: 'none',
});
},
});
return true;
}
/**
* 创建案例详情初始状态
*/
@@ -75,6 +200,11 @@ export function createCaseDetailState(): CaseDetailState {
kpis: [],
pdfUrl: '',
youtubeUrl: '',
client: '',
industry: '',
region: '',
challenge: '',
solution: '',
};
}
@@ -222,6 +352,11 @@ function clearCaseContent(state: CaseDetailState): void {
state.kpis = [];
state.pdfUrl = '';
state.youtubeUrl = '';
state.client = '';
state.industry = '';
state.region = '';
state.challenge = '';
state.solution = '';
}
function applyEmpty(state: CaseDetailState): void {
@@ -243,6 +378,11 @@ function fillFromBlock(state: CaseDetailState, block: BentoBlockPublic): void {
state.kpis = kpis.length > 0 ? kpis : normalizeKpis(meta.metrics);
state.pdfUrl = resolvePdfUrl(meta);
state.youtubeUrl = resolveYoutubeUrl(meta);
state.client = readTrimmed(meta.client);
state.industry = readTrimmed(meta.industry);
state.region = readTrimmed(meta.region);
state.challenge = readTrimmed(meta.challenge);
state.solution = readTrimmed(meta.solution);
}
/**

查看文件

@@ -5,6 +5,7 @@
import { LeadEntity, SubmitLeadParams } from '../../common/types.uts';
import { submitLead } from '../../common/api.uts';
import { getCurrentLocale, getOmniText } from '../../common/locale.uts';
export type ExchangeForm = {
name: string;
@@ -207,3 +208,357 @@ export async function submitExchange(state: ExchangeState): Promise<LeadEntity |
return null;
}
}
export type ExchangeCopyKey =
| 'title'
| 'desc'
| 'warnMissingId'
| 'ocrTitle'
| 'ocrSubtitle'
| 'ocrScan'
| 'ocrScanning'
| 'ocrPreviewTitle'
| 'ocrPreviewHint'
| 'ocrDelete'
| 'nameLabel'
| 'namePlaceholder'
| 'companyLabel'
| 'companyPlaceholder'
| 'whatsappLabel'
| 'whatsappPlaceholder'
| 'emailLabel'
| 'emailPlaceholder'
| 'notesLabel'
| 'notesPlaceholder'
| 'submit'
| 'submitting'
| 'badge'
| 'badgeAi'
| 'contactSectionTitle'
| 'contactSectionDesc'
| 'intentTitle'
| 'intentCooperate'
| 'intentPurchase'
| 'intentInquiry'
| 'intentAgent'
| 'intentTech'
| 'privacyTitle'
| 'privacyDesc'
| 'successTitle'
| 'successDesc'
| 'backPortal'
| 'submitAgain'
| 'whatsappOrEmailTip'
| 'whatsappBadge'
| 'requiredMark';
const EXCHANGE_TEXTS: Record<string, Record<string, string>> = {
'zh-CN': {
desc: '留下姓名与联系方式,双方建立高效商务连接',
warnMissingId: '缺少名片标识,无法提交',
ocrTitle: '纸质名片 OCR 识别',
ocrSubtitle: '拍照或上传名片图片,快速自动填单',
ocrScan: '拍摄/上传',
ocrScanning: '识别中...',
ocrPreviewTitle: '名片原图',
ocrPreviewHint: '点击放大预览原图',
nameLabel: '姓名',
namePlaceholder: '请输入姓名',
companyLabel: '公司',
companyPlaceholder: '选填,例如:ABC Global Ltd',
whatsappPlaceholder: 'WhatsApp 号码(例:+86 138... 或 +971 50...)',
emailLabel: '邮箱',
emailPlaceholder: '工作邮箱 (name@company.com)',
notesLabel: '需求留言 / 洽谈备注',
notesPlaceholder: '选填,简要说明需求或选上方意向标签',
submit: '提交留资',
submitting: '提交中...',
badge: '双向名片互换',
badgeAi: 'AI 极速识别',
contactSectionTitle: '联系人信息',
contactSectionDesc: '留下真实商务联系方式,便于后续及时跟进',
intentTitle: '沟通意向 (快捷选择)',
intentCooperate: '商务合作',
intentPurchase: '产品采购',
intentInquiry: '索取资料',
intentAgent: '代理加盟',
intentTech: '技术交流',
privacyTitle: '企业级隐私安全保障',
privacyDesc: '您的信息仅用于双方商务对接与资料发送,遵循严格的国际隐私保护与数据合规标准。',
successTitle: '留资与名片互换已成功送达',
successDesc: '名片主已收到您的联系方式与洽谈意向,将通过 WhatsApp 或工作邮箱尽快与您取得联系。',
backPortal: '返回微官网',
submitAgain: '再换一张 / 重新填写',
whatsappOrEmailTip: 'WhatsApp 或邮箱至少填写一项',
whatsappBadge: '全球常用',
requiredMark: '必填',
},
'en-US': {
desc: 'Leave your contact details to connect and receive business information.',
warnMissingId: 'This card is missing an ID, so the form cannot be submitted.',
ocrTitle: 'Scan Paper Business Card',
ocrSubtitle: 'Snap or upload a card image to autofill the form instantly.',
ocrScan: 'Photo / Upload',
ocrScanning: 'Scanning...',
ocrPreviewTitle: 'Card Photo',
ocrPreviewHint: 'Tap to view full size',
nameLabel: 'Name',
namePlaceholder: 'Your full name',
companyLabel: 'Company',
companyPlaceholder: 'Optional, e.g. Acme Global Inc.',
whatsappPlaceholder: 'WhatsApp number (e.g. +1 555... or +971 50...)',
emailLabel: 'Email',
emailPlaceholder: 'Work email (name@company.com)',
notesLabel: 'Message / Business Notes',
notesPlaceholder: 'Optional — briefly describe what you need or tap intent chips above',
submit: 'Submit & Exchange',
submitting: 'Submitting...',
badge: 'Mutual Card Exchange',
badgeAi: 'AI Fast Fill',
contactSectionTitle: 'Contact Details',
contactSectionDesc: 'Provide valid contact info for direct business follow-up',
intentTitle: 'Business Intent (Quick Select)',
intentCooperate: 'Partnership',
intentPurchase: 'Procurement',
intentInquiry: 'Request Info',
intentAgent: 'Distributor',
intentTech: 'Tech Exchange',
privacyTitle: 'Enterprise Privacy & Security',
privacyDesc: 'Your details are strictly used for direct business communication in compliance with global privacy standards.',
successTitle: 'Card Exchanged Successfully',
successDesc: 'The card owner has received your contact details and business intent, and will reach out via WhatsApp or email soon.',
backPortal: 'Back to Profile',
submitAgain: 'Exchange Another Card',
whatsappOrEmailTip: 'Provide at least WhatsApp or Email',
whatsappBadge: 'Recommended',
requiredMark: 'Required',
},
'ar-SA': {
desc: 'اترك اسمك وطريقة التواصل لبناء علاقة عمل مباشرة وفعالة.',
warnMissingId: 'البطاقة تفتقد للمعرف، لا يمكن إرسال النموذج.',
ocrTitle: 'مسح البطاقة الورقية بالذكاء الاصطناعي',
ocrSubtitle: 'التقط صورة أو حمّل بطاقة للملء التلقائي الفوري.',
ocrScan: 'تصوير / رفع',
ocrScanning: 'جاري المسح...',
ocrPreviewTitle: 'صورة البطاقة',
ocrPreviewHint: 'اضغط لعرض بالحجم الكامل',
nameLabel: 'الاسم',
namePlaceholder: 'اسمك الكامل',
companyLabel: 'الشركة',
companyPlaceholder: 'اختياري، مثلاً: شركة النور العالمية',
whatsappPlaceholder: 'رقم واتساب (مثلاً: +971 50...)',
emailLabel: 'البريد الإلكتروني',
emailPlaceholder: 'بريد العمل (name@company.com)',
notesLabel: 'ملاحظات أو رسالة',
notesPlaceholder: 'اختياري — اكتب موجزاً عن اهتمامك أو اختر أعلاه',
submit: 'إرسال وتبادل البطاقة',
submitting: 'جاري الإرسال...',
badge: 'تبادل بطاقات الأعمال',
badgeAi: 'ملء فوري بالذكاء الاصطناعي',
contactSectionTitle: 'معلومات الاتصال',
contactSectionDesc: 'يرجى تقديم بيانات اتصال صحيحة للمتابعة المباشرة',
intentTitle: 'الغرض من التواصل (اختيار سريع)',
intentCooperate: 'شراكة أعمال',
intentPurchase: 'شراء منتجات',
intentInquiry: 'طلب معلومات',
intentAgent: 'وكيل معتمد',
intentTech: 'تبادل تقني',
privacyTitle: 'حماية وأمان البيانات المعتمد',
privacyDesc: 'تُستخدم بياناتك حصرياً للتواصل التجاري الثنائي وفق المعايير الدولية لحماية البيانات.',
successTitle: 'تم تبادل البطاقة بنجاح',
successDesc: 'استلم صاحب البطاقة معلومات اتصالك والغرض، وسيتواصل معك قريباً عبر واتساب أو البريد الإلكتروني.',
backPortal: 'العودة للملف الشخصي',
submitAgain: 'تبادل بطاقة أخرى',
whatsappOrEmailTip: 'يرجى إدخال واتساب أو البريد على الأقل',
whatsappBadge: 'المفضل',
requiredMark: 'مطلوب',
},
'es-ES': {
desc: 'Deje sus datos de contacto para establecer una conexión comercial directa.',
warnMissingId: 'Esta tarjeta no tiene identificador y no se puede enviar.',
ocrTitle: 'Escaneo de tarjeta física con IA',
ocrSubtitle: 'Tome una foto o suba una imagen para autorrellenar el formulario.',
ocrScan: 'Foto / Subir',
ocrScanning: 'Escaneando...',
ocrPreviewTitle: 'Foto de tarjeta',
ocrPreviewHint: 'Toque para ampliar',
nameLabel: 'Nombre',
namePlaceholder: 'Su nombre completo',
companyLabel: 'Empresa',
companyPlaceholder: 'Opcional, ej. Global Corp',
whatsappPlaceholder: 'Número de WhatsApp (ej. +34 600...)',
emailLabel: 'Correo electrónico',
emailPlaceholder: 'Correo de trabajo (name@company.com)',
notesLabel: 'Mensaje / Notas',
notesPlaceholder: 'Opcional: describa su requerimiento o seleccione una opción arriba',
submit: 'Enviar e intercambiar',
submitting: 'Enviando...',
badge: 'Intercambio mutuo de tarjetas',
badgeAi: 'IA Relleno Rápido',
contactSectionTitle: 'Datos de contacto',
contactSectionDesc: 'Facilite sus datos válidos para el seguimiento comercial',
intentTitle: 'Intención comercial (selección rápida)',
intentCooperate: 'Alianza comercial',
intentPurchase: 'Adquisición',
intentInquiry: 'Solicitar catálogo',
intentAgent: 'Distribución',
intentTech: 'Consulta técnica',
privacyTitle: 'Garantía de privacidad empresarial',
privacyDesc: 'Sus datos se utilizan estrictamente para el contacto comercial según los estándares internacionales de privacidad.',
successTitle: 'Tarjeta intercambiada con éxito',
successDesc: 'El titular de la tarjeta ha recibido sus datos y se comunicará pronto por WhatsApp o correo.',
backPortal: 'Volver al perfil',
submitAgain: 'Intercambiar otra',
whatsappOrEmailTip: 'Ingrese al menos WhatsApp o correo',
whatsappBadge: 'Recomendado',
requiredMark: 'Obligatorio',
},
'de-DE': {
desc: 'Hinterlassen Sie Ihre Kontaktdaten für einen direkten geschäftlichen Austausch.',
warnMissingId: 'Dieser Karte fehlt eine Kennung, daher kein Versand möglich.',
ocrTitle: 'Visitenkarte mit KI scannen',
ocrSubtitle: 'Foto aufnehmen oder Bild hochladen für automatisches Ausfüllen.',
ocrScan: 'Foto / Upload',
ocrScanning: 'Wird gescannt...',
ocrPreviewTitle: 'Visitenkartenfoto',
ocrPreviewHint: 'Antippen zum Vergrößern',
nameLabel: 'Name',
namePlaceholder: 'Ihr vollständiger Name',
companyLabel: 'Unternehmen',
companyPlaceholder: 'Optional, z. B. Muster GmbH',
whatsappPlaceholder: 'WhatsApp-Nummer (z. B. +49 151...)',
emailLabel: 'E-Mail',
emailPlaceholder: 'Geschäftliche E-Mail (name@company.com)',
notesLabel: 'Nachricht / Notiz',
notesPlaceholder: 'Optional — kurze Beschreibung Ihres Anliegens',
submit: 'Absenden & austauschen',
submitting: 'Wird gesendet...',
badge: 'Gegenseitiger Austausch',
badgeAi: 'KI-Schnellausfüllen',
contactSectionTitle: 'Kontaktdaten',
contactSectionDesc: 'Geben Sie Ihre Daten für geschäftliche Nachfassaktionen an',
intentTitle: 'Geschäftsabsicht (Schnellauswahl)',
intentCooperate: 'Kooperation',
intentPurchase: 'Beschaffung',
intentInquiry: 'Infos anfordern',
intentAgent: 'Vertriebspartner',
intentTech: 'Technischer Austausch',
privacyTitle: 'Unternehmensdatenschutz & Sicherheit',
privacyDesc: 'Ihre Daten werden vertraulich und streng nach internationalen Datenschutzstandards verarbeitet.',
successTitle: 'Visitenkarte erfolgreich übermittelt',
successDesc: 'Der Karteninhaber hat Ihre Kontaktdaten erhalten und meldet sich in Kürze per WhatsApp oder E-Mail.',
backPortal: 'Zurück zum Profil',
submitAgain: 'Weitere Karte tauschen',
whatsappOrEmailTip: 'Mindestens WhatsApp oder E-Mail angeben',
whatsappBadge: 'Empfohlen',
requiredMark: 'Erforderlich',
},
'fr-FR': {
desc: 'Laissez vos coordonnées pour établir un contact professionnel direct.',
warnMissingId: 'Identifiant manquant, formulaire impossible à envoyer.',
ocrTitle: 'Numérisation de carte par IA',
ocrSubtitle: 'Prenez une photo pour préremplir automatiquement le formulaire.',
ocrScan: 'Photo / Importer',
ocrScanning: 'Numérisation...',
ocrPreviewTitle: 'Photo de carte',
ocrPreviewHint: 'Appuyez pour agrandir',
nameLabel: 'Nom',
namePlaceholder: 'Votre nom complet',
companyLabel: 'Entreprise',
companyPlaceholder: 'Facultatif, ex. Société SAS',
whatsappPlaceholder: 'Numéro WhatsApp (ex. +33 6...)',
emailLabel: 'E-mail',
emailPlaceholder: 'E-mail professionnel (name@company.com)',
notesLabel: 'Message / Remarques',
notesPlaceholder: 'Facultatif — décrivez brièvement votre projet',
submit: 'Envoyer et échanger',
submitting: 'Envoi en cours...',
badge: 'Échange mutuel de cartes',
badgeAi: 'Remplissage IA rapide',
contactSectionTitle: 'Coordonnées',
contactSectionDesc: 'Indiquez vos coordonnées pour le suivi commercial direct',
intentTitle: 'Intention commerciale (sélection rapide)',
intentCooperate: 'Partenariat',
intentPurchase: 'Approvisionnement',
intentInquiry: 'Documentation',
intentAgent: 'Distribution',
intentTech: 'Échange technique',
privacyTitle: 'Confidentialité et sécurité des données',
privacyDesc: 'Vos données sont strictement réservées au contact commercial conformément aux normes internationales.',
successTitle: 'Carte échangée avec succès',
successDesc: 'Le propriétaire a reçu vos coordonnées et vous contactera bientôt par WhatsApp ou e-mail.',
backPortal: 'Retour au profil',
submitAgain: 'Échanger une autre',
whatsappOrEmailTip: 'Indiquez au moins WhatsApp ou e-mail',
whatsappBadge: 'Recommandé',
requiredMark: 'Requis',
},
'ja-JP': {
desc: '連絡先をご入力いただき、双方の円滑なビジネス連絡に役立てます。',
warnMissingId: '名刺識別子がないため送信できません。',
ocrTitle: 'AI 紙名刺スキャン',
ocrSubtitle: '写真を撮るか画像をアップロードして自動入力します。',
ocrScan: '撮影 / アップロード',
ocrScanning: '認識中...',
ocrPreviewTitle: '名刺画像',
ocrPreviewHint: 'タップして拡大表示',
nameLabel: 'お名前',
namePlaceholder: 'お名前を入力してください',
companyLabel: '貴社名',
companyPlaceholder: '任意(例:株式会社〇〇)',
whatsappPlaceholder: 'WhatsApp または電話番号(例:+81 90...)',
emailLabel: 'メールアドレス',
emailPlaceholder: '勤務先メールアドレス (name@company.com)',
notesLabel: 'ご用件 / メモ',
notesPlaceholder: '任意 — ご要望の概要または上のタグを選択',
submit: '名刺を交換して送信',
submitting: '送信中...',
badge: '相互名刺交換',
badgeAi: 'AI 高速自動入力',
contactSectionTitle: '連絡先情報',
contactSectionDesc: '今後のビジネスフォローのため正確な連絡先をご入力ください',
intentTitle: 'ご用件(クイック選択)',
intentCooperate: 'ビジネス提携',
intentPurchase: '製品購入',
intentInquiry: '資料請求',
intentAgent: '代理店希望',
intentTech: '技術相談',
privacyTitle: 'エンタープライズ情報セキュリティ保護',
privacyDesc: 'ご入力情報はビジネス連絡にのみ使用され、国際的なプライバシー基準に準拠します。',
successTitle: '名刺交換が完了しました',
successDesc: '名刺の持ち主に連絡先が送信されました。WhatsApp またはメールでご連絡いたします。',
backPortal: 'プロフィールに戻る',
submitAgain: '別の名刺を交換',
whatsappOrEmailTip: 'WhatsApp またはメールのいずれかを入力してください',
whatsappBadge: '推奨',
requiredMark: '必須',
},
};
/**
* 互换名片页文案:能复用 locale 词典的走 getOmniText,其余走多语言映射表
*/
export function resolveExchangeText(key: string): string {
const locale = getCurrentLocale();
if (key === 'title') {
return getOmniText(locale, 'card.exchange');
}
if (key === 'ocrDelete') {
return getOmniText(locale, 'common.delete');
}
if (key === 'whatsappLabel') {
return getOmniText(locale, 'portal.whatsapp');
}
const langMap = EXCHANGE_TEXTS[locale] || EXCHANGE_TEXTS['en-US'] || EXCHANGE_TEXTS['zh-CN'];
if (langMap != null && langMap[key] != null) {
return langMap[key];
}
const fallbackMap = EXCHANGE_TEXTS['en-US'] || EXCHANGE_TEXTS['zh-CN'];
if (fallbackMap != null && fallbackMap[key] != null) {
return fallbackMap[key];
}
return key;
}

查看文件

@@ -17,11 +17,11 @@ test('1. useCardCases.uts 文件必须存在且格式完整', () => {
assert.ok(content.includes('export const PRESET_CASE_METRICS'), '必须导出 PRESET_CASE_METRICS');
});
test('2. 预设指标标签完备且包含出海核心量化成效', () => {
test('2. 预设指标标签完备且包含国际业务核心量化成效', () => {
const content = fs.readFileSync(casesUtsPath, 'utf-8');
assert.ok(content.includes('+320% ROI'), '必须包含 +320% ROI');
assert.ok(content.includes('+150% 留资率'), '必须包含 +150% 留资率');
assert.ok(content.includes('45k+ 海外线索'), '必须包含 45k+ 海外线索');
assert.ok(content.includes('45k+ 国际线索'), '必须包含 45k+ 国际线索');
assert.ok(content.includes('2M+ 全球曝光'), '必须包含 2M+ 全球曝光');
assert.ok(content.includes('Top 1 细分类目'), '必须包含 Top 1 细分类目');
});