refactor(i18n): 全工程面向国际用户与全球现代商务重构,彻底清理出海跨境等单向视角表达并对齐自动化测试
这个提交包含在:
@@ -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 细分类目');
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户