feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -55,6 +55,8 @@ export type OmniMessageKey =
|
||||
| 'portal.telegram'
|
||||
| 'portal.twitter'
|
||||
| 'portal.wechat'
|
||||
| 'portal.line'
|
||||
| 'portal.kakao'
|
||||
| 'portal.email'
|
||||
| 'portal.phone'
|
||||
| 'portal.case'
|
||||
@@ -71,6 +73,8 @@ export type OmniMessageKey =
|
||||
| 'portal.copied_telegram'
|
||||
| 'portal.copied_twitter'
|
||||
| 'portal.copied_wechat'
|
||||
| 'portal.copied_line'
|
||||
| 'portal.copied_kakao'
|
||||
| 'portal.collect'
|
||||
| 'portal.collected'
|
||||
| 'portal.appointment'
|
||||
@@ -96,7 +100,15 @@ export type OmniMessageKey =
|
||||
| 'portal.appointment_submit'
|
||||
| 'portal.appointment_submitting'
|
||||
| 'portal.appointment_success_tip'
|
||||
| 'portal.appointment_fill_required';
|
||||
| 'portal.appointment_fill_required'
|
||||
| 'portal.cookie_notice'
|
||||
| 'portal.cookie_accept'
|
||||
| 'portal.cookie_reject'
|
||||
| 'tab.workplace'
|
||||
| 'tab.card'
|
||||
| 'tab.radar'
|
||||
| 'tab.leads'
|
||||
| 'tab.profile';
|
||||
|
||||
export interface OmniLocaleConfig {
|
||||
code: OmniLocale;
|
||||
@@ -146,6 +158,8 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.telegram': 'Telegram',
|
||||
'portal.twitter': 'X',
|
||||
'portal.wechat': '微信',
|
||||
'portal.line': 'LINE',
|
||||
'portal.kakao': 'KakaoTalk',
|
||||
'portal.email': '邮件',
|
||||
'portal.phone': '电话',
|
||||
'portal.case': '客户案例',
|
||||
@@ -162,6 +176,8 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.copied_telegram': 'Telegram 账号已复制',
|
||||
'portal.copied_twitter': 'X (Twitter) 链接已复制',
|
||||
'portal.copied_wechat': '微信号已复制',
|
||||
'portal.copied_line': 'LINE 链接已复制',
|
||||
'portal.copied_kakao': 'KakaoTalk 链接已复制',
|
||||
'portal.collect': '收入名片夹',
|
||||
'portal.collected': '已收入',
|
||||
'portal.appointment': '预约商谈',
|
||||
@@ -188,6 +204,14 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.appointment_submitting': '提交中...',
|
||||
'portal.appointment_success_tip': '预约已提交,将尽快与您联系',
|
||||
'portal.appointment_fill_required': '请填写姓名和有效联系方式',
|
||||
'portal.cookie_notice': '我们使用 Cookie 改善体验并分析访问。继续浏览前请确认是否同意。',
|
||||
'portal.cookie_accept': '同意',
|
||||
'portal.cookie_reject': '拒绝',
|
||||
'tab.workplace': '工作台',
|
||||
'tab.card': '名片',
|
||||
'tab.radar': '雷达',
|
||||
'tab.leads': '潜客',
|
||||
'tab.profile': '我的',
|
||||
},
|
||||
},
|
||||
'en-US': {
|
||||
@@ -230,6 +254,8 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.telegram': 'Telegram',
|
||||
'portal.twitter': 'X',
|
||||
'portal.wechat': 'WeChat',
|
||||
'portal.line': 'LINE',
|
||||
'portal.kakao': 'KakaoTalk',
|
||||
'portal.email': 'Email',
|
||||
'portal.phone': 'Call',
|
||||
'portal.case': 'Case Study',
|
||||
@@ -246,6 +272,8 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.copied_telegram': 'Telegram link copied',
|
||||
'portal.copied_twitter': 'X link copied',
|
||||
'portal.copied_wechat': 'WeChat ID copied',
|
||||
'portal.copied_line': 'LINE link copied',
|
||||
'portal.copied_kakao': 'KakaoTalk link copied',
|
||||
'portal.collect': 'Save to Hall',
|
||||
'portal.collected': 'Saved to Hall',
|
||||
'portal.appointment': 'Book Appointment',
|
||||
@@ -272,6 +300,14 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.appointment_submitting': 'Submitting...',
|
||||
'portal.appointment_success_tip': 'Appointment submitted! We will contact you shortly.',
|
||||
'portal.appointment_fill_required': 'Please provide your name and contact info',
|
||||
'portal.cookie_notice': 'We use cookies to improve your experience and understand visits. Please choose before continuing.',
|
||||
'portal.cookie_accept': 'Accept',
|
||||
'portal.cookie_reject': 'Reject',
|
||||
'tab.workplace': 'Workplace',
|
||||
'tab.card': 'Card',
|
||||
'tab.radar': 'Radar',
|
||||
'tab.leads': 'Leads',
|
||||
'tab.profile': 'Profile',
|
||||
},
|
||||
},
|
||||
'ar-SA': {
|
||||
@@ -314,6 +350,8 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.telegram': 'تيليجرام',
|
||||
'portal.twitter': 'إكس',
|
||||
'portal.wechat': 'وي تشات',
|
||||
'portal.line': 'LINE',
|
||||
'portal.kakao': 'KakaoTalk',
|
||||
'portal.email': 'البريد',
|
||||
'portal.phone': 'اتصال',
|
||||
'portal.case': 'دراسات الحالة',
|
||||
@@ -330,6 +368,8 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.copied_telegram': 'تم نسخ رابط تيليجرام',
|
||||
'portal.copied_twitter': 'تم نسخ رابط إكس',
|
||||
'portal.copied_wechat': 'تم نسخ معرف وي تشات',
|
||||
'portal.copied_line': 'تم نسخ رابط LINE',
|
||||
'portal.copied_kakao': 'تم نسخ رابط KakaoTalk',
|
||||
'portal.collect': 'حفظ في القاعة',
|
||||
'portal.collected': 'تم الحفظ في القاعة',
|
||||
'portal.appointment': 'حجز موعد',
|
||||
@@ -356,6 +396,14 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.appointment_submitting': 'جاري الإرسال...',
|
||||
'portal.appointment_success_tip': 'تم إرسال طلبك بنجاح! سيتواصل معك صاحب البطاقة قريباً.',
|
||||
'portal.appointment_fill_required': 'يرجى إدخال الاسم ومعلومات التواصل',
|
||||
'portal.cookie_notice': 'نستخدم ملفات تعريف الارتباط لتحسين تجربتك وفهم الزيارات. يرجى الاختيار قبل المتابعة.',
|
||||
'portal.cookie_accept': 'موافق',
|
||||
'portal.cookie_reject': 'رفض',
|
||||
'tab.workplace': 'المكتب',
|
||||
'tab.card': 'بطاقتي',
|
||||
'tab.radar': 'الرادار',
|
||||
'tab.leads': 'العملاء',
|
||||
'tab.profile': 'حسابي',
|
||||
},
|
||||
},
|
||||
'es-ES': {
|
||||
@@ -398,6 +446,8 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.telegram': 'Telegram',
|
||||
'portal.twitter': 'X',
|
||||
'portal.wechat': 'WeChat',
|
||||
'portal.line': 'LINE',
|
||||
'portal.kakao': 'KakaoTalk',
|
||||
'portal.email': 'Correo',
|
||||
'portal.phone': 'Llamar',
|
||||
'portal.case': 'Caso de éxito',
|
||||
@@ -414,6 +464,8 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.copied_telegram': 'Enlace de Telegram copiado',
|
||||
'portal.copied_twitter': 'Enlace de X copiado',
|
||||
'portal.copied_wechat': 'ID de WeChat copiado',
|
||||
'portal.copied_line': 'Enlace de LINE copiado',
|
||||
'portal.copied_kakao': 'Enlace de KakaoTalk copiado',
|
||||
'portal.collect': 'Guardar en el hall',
|
||||
'portal.collected': 'Guardado en el hall',
|
||||
'portal.appointment': 'Agendar Reunión',
|
||||
@@ -440,6 +492,14 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.appointment_submitting': 'Enviando...',
|
||||
'portal.appointment_success_tip': '¡Solicitud enviada! El titular se pondrá en contacto pronto.',
|
||||
'portal.appointment_fill_required': 'Por favor indica tu nombre y contacto',
|
||||
'portal.cookie_notice': 'Usamos cookies para mejorar tu experiencia y entender las visitas. Elige antes de continuar.',
|
||||
'portal.cookie_accept': 'Aceptar',
|
||||
'portal.cookie_reject': 'Rechazar',
|
||||
'tab.workplace': 'Panel',
|
||||
'tab.card': 'Tarjeta',
|
||||
'tab.radar': 'Radar',
|
||||
'tab.leads': 'Clientes',
|
||||
'tab.profile': 'Perfil',
|
||||
},
|
||||
},
|
||||
'de-DE': {
|
||||
@@ -482,6 +542,8 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.telegram': 'Telegram',
|
||||
'portal.twitter': 'X',
|
||||
'portal.wechat': 'WeChat',
|
||||
'portal.line': 'LINE',
|
||||
'portal.kakao': 'KakaoTalk',
|
||||
'portal.email': 'E-Mail',
|
||||
'portal.phone': 'Anrufen',
|
||||
'portal.case': 'Fallstudie',
|
||||
@@ -498,6 +560,8 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.copied_telegram': 'Telegram-Link kopiert',
|
||||
'portal.copied_twitter': 'X-Link kopiert',
|
||||
'portal.copied_wechat': 'WeChat-ID kopiert',
|
||||
'portal.copied_line': 'LINE-Link kopiert',
|
||||
'portal.copied_kakao': 'KakaoTalk-Link kopiert',
|
||||
'portal.collect': 'In die Halle legen',
|
||||
'portal.collected': 'In der Halle gespeichert',
|
||||
'portal.appointment': 'Termin vereinbaren',
|
||||
@@ -524,6 +588,14 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.appointment_submitting': 'Wird gesendet...',
|
||||
'portal.appointment_success_tip': 'Anfrage gesendet! Der Karteninhaber meldet sich in Kürze.',
|
||||
'portal.appointment_fill_required': 'Bitte Namen und Kontaktdaten angeben',
|
||||
'portal.cookie_notice': 'Wir verwenden Cookies, um Ihr Erlebnis zu verbessern und Besuche zu verstehen. Bitte wählen Sie vor dem Fortfahren.',
|
||||
'portal.cookie_accept': 'Akzeptieren',
|
||||
'portal.cookie_reject': 'Ablehnen',
|
||||
'tab.workplace': 'Cockpit',
|
||||
'tab.card': 'Karte',
|
||||
'tab.radar': 'Radar',
|
||||
'tab.leads': 'Leads',
|
||||
'tab.profile': 'Profil',
|
||||
},
|
||||
},
|
||||
'fr-FR': {
|
||||
@@ -566,6 +638,8 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.telegram': 'Telegram',
|
||||
'portal.twitter': 'X',
|
||||
'portal.wechat': 'WeChat',
|
||||
'portal.line': 'LINE',
|
||||
'portal.kakao': 'KakaoTalk',
|
||||
'portal.email': 'E-mail',
|
||||
'portal.phone': 'Appeler',
|
||||
'portal.case': 'Étude de cas',
|
||||
@@ -582,6 +656,8 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.copied_telegram': 'Lien Telegram copié',
|
||||
'portal.copied_twitter': 'Lien X copié',
|
||||
'portal.copied_wechat': 'Identifiant WeChat copié',
|
||||
'portal.copied_line': 'Lien LINE copié',
|
||||
'portal.copied_kakao': 'Lien KakaoTalk copié',
|
||||
'portal.collect': 'Ajouter au hall',
|
||||
'portal.collected': 'Ajouté au hall',
|
||||
'portal.appointment': 'Prendre Rendez-vous',
|
||||
@@ -608,6 +684,14 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.appointment_submitting': 'Envoi en cours...',
|
||||
'portal.appointment_success_tip': 'Demande envoyée ! Le titulaire vous contactera sous peu.',
|
||||
'portal.appointment_fill_required': 'Veuillez renseigner votre nom et vos coordonnées',
|
||||
'portal.cookie_notice': 'Nous utilisons des cookies pour améliorer votre expérience et comprendre les visites. Merci de choisir avant de continuer.',
|
||||
'portal.cookie_accept': 'Accepter',
|
||||
'portal.cookie_reject': 'Refuser',
|
||||
'tab.workplace': 'Tableau',
|
||||
'tab.card': 'Carte',
|
||||
'tab.radar': 'Radar',
|
||||
'tab.leads': 'Contacts',
|
||||
'tab.profile': 'Profil',
|
||||
},
|
||||
},
|
||||
'ja-JP': {
|
||||
@@ -650,6 +734,8 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.telegram': 'Telegram',
|
||||
'portal.twitter': 'X',
|
||||
'portal.wechat': 'WeChat',
|
||||
'portal.line': 'LINE',
|
||||
'portal.kakao': 'KakaoTalk',
|
||||
'portal.email': 'メール',
|
||||
'portal.phone': '電話する',
|
||||
'portal.case': '導入事例',
|
||||
@@ -666,6 +752,8 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.copied_telegram': 'Telegram のリンクをコピーしました',
|
||||
'portal.copied_twitter': 'X のリンクをコピーしました',
|
||||
'portal.copied_wechat': 'WeChat ID をコピーしました',
|
||||
'portal.copied_line': 'LINE のリンクをコピーしました',
|
||||
'portal.copied_kakao': 'KakaoTalk のリンクをコピーしました',
|
||||
'portal.collect': 'ホールに保存',
|
||||
'portal.collected': 'ホールに保存しました',
|
||||
'portal.appointment': '日程を予約する',
|
||||
@@ -692,6 +780,14 @@ export const omniLocales: Record<OmniLocale, OmniLocaleConfig> = {
|
||||
'portal.appointment_submitting': '送信中...',
|
||||
'portal.appointment_success_tip': 'ご予約を送信しました。担当者より追ってご連絡いたします。',
|
||||
'portal.appointment_fill_required': 'お名前とご連絡先を入力してください',
|
||||
'portal.cookie_notice': 'Cookie を使用して体験を向上し、訪問状況を把握します。続行前にご選択ください。',
|
||||
'portal.cookie_accept': '同意する',
|
||||
'portal.cookie_reject': '拒否する',
|
||||
'tab.workplace': 'ワーク',
|
||||
'tab.card': '名刺',
|
||||
'tab.radar': 'レーダー',
|
||||
'tab.leads': '見込み客',
|
||||
'tab.profile': 'マイ',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -869,6 +965,31 @@ export function getCurrentLocale(): OmniLocale {
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据语言刷新底部原生 TabBar 5 大菜单标题 (工作台, 名片, 雷达, 潜客, 我的)
|
||||
*/
|
||||
export function updateTabBarTitles(locale?: OmniLocale): void {
|
||||
const targetLocale = locale != null ? locale : getCurrentLocale();
|
||||
const items: Array<{ index: number; key: OmniMessageKey }> = [
|
||||
{ index: 0, key: 'tab.workplace' },
|
||||
{ index: 1, key: 'tab.card' },
|
||||
{ index: 2, key: 'tab.radar' },
|
||||
{ index: 3, key: 'tab.leads' },
|
||||
{ index: 4, key: 'tab.profile' },
|
||||
];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
if (typeof uni !== 'undefined' && typeof uni.setTabBarItem === 'function') {
|
||||
uni.setTabBarItem({
|
||||
index: items[i].index,
|
||||
text: getOmniText(targetLocale, items[i].key),
|
||||
});
|
||||
}
|
||||
} catch (_e) {}
|
||||
}
|
||||
}
|
||||
|
||||
export function setAppLocale(lang: string): void {
|
||||
const normalized = normalizeLocale(lang);
|
||||
overrideLocale = normalized;
|
||||
@@ -876,11 +997,18 @@ export function setAppLocale(lang: string): void {
|
||||
try {
|
||||
uni.setStorageSync(PORTAL_LOCALE_STORAGE_KEY, normalized);
|
||||
} catch (_e) {}
|
||||
updateTabBarTitles(normalized);
|
||||
try {
|
||||
if (typeof uni !== 'undefined' && typeof uni.$emit === 'function') {
|
||||
uni.$emit('omni_locale_changed', normalized);
|
||||
}
|
||||
} catch (_e) {}
|
||||
}
|
||||
|
||||
export function initAppLocale(): OmniLocale {
|
||||
const current = detectLocale();
|
||||
syncState(current);
|
||||
updateTabBarTitles(current);
|
||||
return current;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { ApiResponse } from './types.uts';
|
||||
import { getCurrentLocale } from './locale.uts';
|
||||
|
||||
let currentBaseUrl: string = 'http://127.0.0.1:9000';
|
||||
// #ifdef H5
|
||||
@@ -161,6 +162,13 @@ export function request<T>(config: RequestConfig): Promise<T> {
|
||||
headers['x-tenant-id'] = tenantId;
|
||||
}
|
||||
|
||||
// 自动挂载客户端当前 Locale
|
||||
const currentLocale = getCurrentLocale();
|
||||
if (currentLocale) {
|
||||
headers['Accept-Language'] = currentLocale;
|
||||
headers['x-locale'] = currentLocale;
|
||||
}
|
||||
|
||||
// 显式传入的 header 具有更高优先级
|
||||
if (config.header) {
|
||||
Object.assign(headers, config.header);
|
||||
|
||||
@@ -181,6 +181,11 @@ export interface SubmitLeadParams {
|
||||
|
||||
export type TouchType = 'nfc' | 'qr' | 'link';
|
||||
|
||||
export interface RadarViewedContent {
|
||||
key: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface RadarTrack {
|
||||
id: string | number;
|
||||
tenantId?: string | number;
|
||||
@@ -193,7 +198,13 @@ export interface RadarTrack {
|
||||
stayDurationMs: number;
|
||||
scrollDepth: number;
|
||||
viewedBlocks: string[];
|
||||
viewedContents?: RadarViewedContent[];
|
||||
visitorName?: string;
|
||||
visitorCompany?: string;
|
||||
avatarUrl?: string;
|
||||
cardTitle?: string;
|
||||
createdAt?: string | Date;
|
||||
createTime?: string | Date;
|
||||
}
|
||||
|
||||
export interface RadarHotInbox {
|
||||
@@ -264,13 +275,15 @@ export interface QuotaCheckResult {
|
||||
plan?: string;
|
||||
}
|
||||
|
||||
export type OwnerPagePagination = {
|
||||
page: number;
|
||||
size: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export interface OwnerPageData<T> {
|
||||
list: T[];
|
||||
pagination: {
|
||||
page: number;
|
||||
size: number;
|
||||
total: number;
|
||||
};
|
||||
pagination: OwnerPagePagination;
|
||||
}
|
||||
|
||||
export type AiChatRole = 'user' | 'assistant' | 'system';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<view class="i18n-selector-wrap" :class="{ 'is-rtl': isCurrentRtl }">
|
||||
<!-- 右上角常驻语言切换入口触发按钮 (触控区 >= 44x44) -->
|
||||
<!-- 访客端顶栏/右上角常驻入口:始终可见,不藏二级菜单 -->
|
||||
<view class="i18n-selector-wrap i18n-selector-persist" :class="{ 'is-rtl': isCurrentRtl }">
|
||||
<!-- 常驻语言切换触发按钮 (触控区 >= 44x44) -->
|
||||
<view
|
||||
class="i18n-trigger-btn"
|
||||
role="button"
|
||||
@@ -11,7 +12,7 @@
|
||||
<text class="i18n-trigger-arrow">▾</text>
|
||||
</view>
|
||||
|
||||
<!-- 语言切换浮层面板 (遮罩 + 弹出列表) -->
|
||||
<!-- 语言切换浮层面板 (遮罩 + 弹出列表,复用现有 languages / setAppLocale) -->
|
||||
<view v-if="visible" class="i18n-modal-mask" @click="closeModal">
|
||||
<view class="i18n-modal-container" @click.stop="">
|
||||
<!-- 弹窗头部 -->
|
||||
@@ -22,7 +23,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 7 大主流语种列表 (WCAG 2.2 AA 规范,单行 >= 44px) -->
|
||||
<!-- 7 大主流语种列表 (含中/英/阿;WCAG 2.2 AA,单行 >= 44px) -->
|
||||
<view class="i18n-list">
|
||||
<view
|
||||
v-for="item in languages"
|
||||
@@ -128,6 +129,14 @@ function selectLanguage(code: OmniLocale): void {
|
||||
.i18n-selector-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.i18n-selector-persist {
|
||||
/* 顶栏常驻:防止被挤出可视区 */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 常驻右上角触发胶囊 (触控区 >= 44x44) */
|
||||
@@ -143,6 +152,7 @@ function selectLanguage(code: OmniLocale): void {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.i18n-trigger-badge {
|
||||
|
||||
@@ -1,188 +1,200 @@
|
||||
{
|
||||
"name" : "OmniCard",
|
||||
"appid" : "__UNI__197564F",
|
||||
"description" : "",
|
||||
"versionName" : "2.2.3",
|
||||
"versionCode" : 20203,
|
||||
"uni-app-x" : {
|
||||
"vapor" : true,
|
||||
"styleIsolationVersion" : "2"
|
||||
},
|
||||
"vueVersion" : "3",
|
||||
"mp-weixin" : {
|
||||
"darkmode" : true,
|
||||
"appid" : "",
|
||||
"setting" : {
|
||||
"urlCheck" : false,
|
||||
"minified" : true
|
||||
},
|
||||
"usingComponents" : true,
|
||||
"resizable" : true,
|
||||
"frameset" : true,
|
||||
"libVersion" : "3.7.1",
|
||||
"requiredPrivateInfos" : [ "getLocation", "onLocationChange", "startLocationUpdateBackground" ],
|
||||
"permission" : {
|
||||
"scope.userLocation" : {
|
||||
"desc" : "你的位置信息将用于小程序位置接口的效果展示"
|
||||
}
|
||||
},
|
||||
"unipush" : {
|
||||
"enable" : true
|
||||
}
|
||||
},
|
||||
"app" : {
|
||||
"distribute" : {
|
||||
"modules" : {
|
||||
"uni-ad" : {
|
||||
"gdt" : {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"app-harmony" : {
|
||||
"distribute" : {
|
||||
"bundleName" : "io.dcloud.uniappx",
|
||||
"modules" : {
|
||||
"uni-payment" : {
|
||||
"alipay" : {},
|
||||
"wxpay" : {}
|
||||
},
|
||||
"uni-location" : {
|
||||
"system" : {}
|
||||
},
|
||||
"uni-push" : {},
|
||||
"uni-map" : {
|
||||
"tencent" : {}
|
||||
},
|
||||
"uni-verify" : {},
|
||||
"uni-facialVerify" : {},
|
||||
"uni-oauth" : {
|
||||
"huawei" : {
|
||||
"client_id" : "6917567445824957002"
|
||||
},
|
||||
"weixin" : {
|
||||
"appid" : "wxd1b990d3136e369c"
|
||||
}
|
||||
},
|
||||
"uni-share" : {
|
||||
"weixin" : {
|
||||
"appid" : "wxd1b990d3136e369c"
|
||||
}
|
||||
}
|
||||
},
|
||||
"icons" : {
|
||||
"foreground" : "package/D-1024.png",
|
||||
"background" : "package/background-white.png"
|
||||
},
|
||||
"splashScreens" : {
|
||||
"startWindowIcon" : "package/icon256.png"
|
||||
},
|
||||
"deviceTypes" : [ "phone", "tablet" ],
|
||||
"deviceArchs" : [ "arm64-v8a" ],
|
||||
"no-debug" : true
|
||||
}
|
||||
},
|
||||
"web" : {
|
||||
"darkmode" : true,
|
||||
"router" : {
|
||||
"base" : "/web/",
|
||||
"mode" : ""
|
||||
},
|
||||
"sdkConfigs" : {
|
||||
"maps" : {
|
||||
"tencent" : {
|
||||
"key" : ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"unipush" : {
|
||||
"enable" : false
|
||||
}
|
||||
},
|
||||
"app-android" : {
|
||||
"distribute" : {
|
||||
"modules" : {
|
||||
"uni-barcode-scanning" : {},
|
||||
"uni-location" : {
|
||||
"system" : {},
|
||||
"tencent" : {
|
||||
"key" : ""
|
||||
}
|
||||
},
|
||||
"uni-map" : {
|
||||
"tencent" : {
|
||||
"key" : ""
|
||||
}
|
||||
},
|
||||
"uni-oauth" : {
|
||||
"weixin" : {
|
||||
"appid" : ""
|
||||
}
|
||||
},
|
||||
"uni-payment" : {
|
||||
"alipay" : {},
|
||||
"wxpay" : {
|
||||
"appid" : ""
|
||||
}
|
||||
},
|
||||
"uni-push" : {},
|
||||
"uni-share" : {
|
||||
"weixin" : {
|
||||
"appid" : ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"icons" : {
|
||||
"xxhdpi" : "package/icon144.png",
|
||||
"xxxhdpi" : "package/icon192.png"
|
||||
},
|
||||
"splashScreens" : {
|
||||
"default" : {}
|
||||
},
|
||||
"abiFilters" : [ "arm64-v8a" ],
|
||||
"disableLegacyPackaging" : true
|
||||
}
|
||||
},
|
||||
"app-ios" : {
|
||||
"distribute" : {
|
||||
"modules" : {
|
||||
"uni-barcode-scanning" : {},
|
||||
"uni-location" : {
|
||||
"system" : {},
|
||||
"tencent" : {
|
||||
"key" : ""
|
||||
}
|
||||
},
|
||||
"uni-map" : {
|
||||
"tencent" : {
|
||||
"key" : ""
|
||||
}
|
||||
},
|
||||
"uni-oauth" : {
|
||||
"weixin" : {
|
||||
"appid" : "",
|
||||
"universalLink" : ""
|
||||
}
|
||||
},
|
||||
"uni-payment" : {
|
||||
"alipay" : {},
|
||||
"wxpay" : {
|
||||
"appid" : "",
|
||||
"universalLink" : ""
|
||||
}
|
||||
},
|
||||
"uni-push" : {},
|
||||
"uni-share" : {
|
||||
"weixin" : {
|
||||
"appid" : "",
|
||||
"universalLink" : ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"icons" : {
|
||||
"appstore" : "package/icon1024.png"
|
||||
},
|
||||
"splashScreens" : {}
|
||||
}
|
||||
}
|
||||
}
|
||||
"name": "OmniCard",
|
||||
"appid": "__UNI__197564F",
|
||||
"description": "",
|
||||
"versionName": "2.2.3",
|
||||
"versionCode": 20203,
|
||||
"uni-app-x": {
|
||||
"vapor": true,
|
||||
"styleIsolationVersion": "2",
|
||||
"vapor-render-target": "bytecode"
|
||||
},
|
||||
"vueVersion": "3",
|
||||
"mp-weixin": {
|
||||
"darkmode": true,
|
||||
"appid": "",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"minified": true
|
||||
},
|
||||
"usingComponents": true,
|
||||
"resizable": true,
|
||||
"frameset": true,
|
||||
"libVersion": "3.7.1",
|
||||
"requiredPrivateInfos": [
|
||||
"getLocation",
|
||||
"onLocationChange",
|
||||
"startLocationUpdateBackground"
|
||||
],
|
||||
"permission": {
|
||||
"scope.userLocation": {
|
||||
"desc": "你的位置信息将用于小程序位置接口的效果展示"
|
||||
}
|
||||
},
|
||||
"unipush": {
|
||||
"enable": true
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"distribute": {
|
||||
"modules": {
|
||||
"uni-ad": {
|
||||
"gdt": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"app-harmony": {
|
||||
"distribute": {
|
||||
"bundleName": "io.dcloud.uniappx",
|
||||
"modules": {
|
||||
"uni-payment": {
|
||||
"alipay": {},
|
||||
"wxpay": {}
|
||||
},
|
||||
"uni-location": {
|
||||
"system": {}
|
||||
},
|
||||
"uni-push": {},
|
||||
"uni-map": {
|
||||
"tencent": {}
|
||||
},
|
||||
"uni-verify": {},
|
||||
"uni-facialVerify": {},
|
||||
"uni-oauth": {
|
||||
"huawei": {
|
||||
"client_id": "6917567445824957002"
|
||||
},
|
||||
"weixin": {
|
||||
"appid": "wxd1b990d3136e369c"
|
||||
}
|
||||
},
|
||||
"uni-share": {
|
||||
"weixin": {
|
||||
"appid": "wxd1b990d3136e369c"
|
||||
}
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"foreground": "package/D-1024.png",
|
||||
"background": "package/background-white.png"
|
||||
},
|
||||
"splashScreens": {
|
||||
"startWindowIcon": "package/icon256.png"
|
||||
},
|
||||
"deviceTypes": [
|
||||
"phone",
|
||||
"tablet"
|
||||
],
|
||||
"deviceArchs": [
|
||||
"arm64-v8a"
|
||||
],
|
||||
"no-debug": true
|
||||
}
|
||||
},
|
||||
"web": {
|
||||
"darkmode": true,
|
||||
"router": {
|
||||
"base": "/web/",
|
||||
"mode": ""
|
||||
},
|
||||
"sdkConfigs": {
|
||||
"maps": {
|
||||
"tencent": {
|
||||
"key": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"unipush": {
|
||||
"enable": false
|
||||
}
|
||||
},
|
||||
"app-android": {
|
||||
"distribute": {
|
||||
"modules": {
|
||||
"uni-barcode-scanning": {},
|
||||
"uni-location": {
|
||||
"system": {},
|
||||
"tencent": {
|
||||
"key": ""
|
||||
}
|
||||
},
|
||||
"uni-map": {
|
||||
"tencent": {
|
||||
"key": ""
|
||||
}
|
||||
},
|
||||
"uni-oauth": {
|
||||
"weixin": {
|
||||
"appid": ""
|
||||
}
|
||||
},
|
||||
"uni-payment": {
|
||||
"alipay": {},
|
||||
"wxpay": {
|
||||
"appid": ""
|
||||
}
|
||||
},
|
||||
"uni-push": {},
|
||||
"uni-share": {
|
||||
"weixin": {
|
||||
"appid": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"xxhdpi": "package/icon144.png",
|
||||
"xxxhdpi": "package/icon192.png"
|
||||
},
|
||||
"splashScreens": {
|
||||
"default": {}
|
||||
},
|
||||
"abiFilters": [
|
||||
"arm64-v8a"
|
||||
],
|
||||
"disableLegacyPackaging": true
|
||||
}
|
||||
},
|
||||
"app-ios": {
|
||||
"distribute": {
|
||||
"modules": {
|
||||
"uni-barcode-scanning": {},
|
||||
"uni-location": {
|
||||
"system": {},
|
||||
"tencent": {
|
||||
"key": ""
|
||||
}
|
||||
},
|
||||
"uni-map": {
|
||||
"tencent": {
|
||||
"key": ""
|
||||
}
|
||||
},
|
||||
"uni-oauth": {
|
||||
"weixin": {
|
||||
"appid": "",
|
||||
"universalLink": ""
|
||||
}
|
||||
},
|
||||
"uni-payment": {
|
||||
"alipay": {},
|
||||
"wxpay": {
|
||||
"appid": "",
|
||||
"universalLink": ""
|
||||
}
|
||||
},
|
||||
"uni-push": {},
|
||||
"uni-share": {
|
||||
"weixin": {
|
||||
"appid": "",
|
||||
"universalLink": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"appstore": "package/icon1024.png"
|
||||
},
|
||||
"splashScreens": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
"path": "pages/owner/card/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的名片",
|
||||
"navigationStyle": "custom",
|
||||
"backgroundColorContent": "#F7F8FA"
|
||||
}
|
||||
},
|
||||
@@ -70,6 +71,7 @@
|
||||
"path": "pages/owner/radar",
|
||||
"style": {
|
||||
"navigationBarTitleText": "实时雷达",
|
||||
"navigationStyle": "custom",
|
||||
"backgroundColorContent": "#F7F8FA"
|
||||
}
|
||||
},
|
||||
@@ -90,8 +92,9 @@
|
||||
{
|
||||
"path": "pages/owner/profile/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "个人中心",
|
||||
"backgroundColorContent": "#F7F8FA"
|
||||
"navigationBarTitleText": "我的",
|
||||
"navigationStyle": "custom",
|
||||
"backgroundColorContent": "#F5F8FC"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -187,7 +187,7 @@
|
||||
:aria-label="t('google')"
|
||||
@click="handleSocialLogin('google')"
|
||||
>
|
||||
<text class="social-badge-icon google-icon">G</text>
|
||||
<image class="social-logo" src="/static/icons/google.svg" mode="aspectFit" />
|
||||
<text class="social-pill-text">{{ t('google') }}</text>
|
||||
</view>
|
||||
<view
|
||||
@@ -196,7 +196,7 @@
|
||||
:aria-label="t('apple')"
|
||||
@click="handleSocialLogin('apple')"
|
||||
>
|
||||
<text class="social-badge-icon apple-icon"></text>
|
||||
<image class="social-logo" src="/static/icons/apple.svg" mode="aspectFit" />
|
||||
<text class="social-pill-text">{{ t('apple') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -261,7 +261,7 @@ const pageTheme: Record<string, string> = {
|
||||
};
|
||||
|
||||
const state = reactive(createLoginState());
|
||||
const currentLocale = ref<string>(getCurrentLocale());
|
||||
const currentLocale = ref<OmniLocale>(getCurrentLocale());
|
||||
let countdownTimerId: number | null = null;
|
||||
|
||||
const pageRtlClass = computed((): string => {
|
||||
@@ -307,8 +307,8 @@ function handleAutofillTest(): void {
|
||||
fillTestAccount(state);
|
||||
}
|
||||
|
||||
function handleSendSmsCode(): void {
|
||||
const ok = sendSmsCode(state);
|
||||
async function handleSendSmsCode(): Promise<void> {
|
||||
const ok = await sendSmsCode(state);
|
||||
if (ok) {
|
||||
startCountdown();
|
||||
}
|
||||
@@ -333,13 +333,23 @@ function stopCountdown(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrimarySubmit(): void {
|
||||
/**
|
||||
* 主 CTA:仅交由 useLogin 处理;失败/无 token 时不会写本地凭证,也不会跳转。
|
||||
*/
|
||||
async function handlePrimarySubmit(): Promise<void> {
|
||||
if (state.submitting) {
|
||||
return;
|
||||
}
|
||||
if (state.tab === 'password') {
|
||||
submitPasswordLogin(state);
|
||||
} else if (state.tab === 'phone') {
|
||||
submitPhoneLogin(state);
|
||||
} else if (state.tab === 'email') {
|
||||
sendMagicLink(state);
|
||||
await submitPasswordLogin(state);
|
||||
return;
|
||||
}
|
||||
if (state.tab === 'phone') {
|
||||
await submitPhoneLogin(state);
|
||||
return;
|
||||
}
|
||||
if (state.tab === 'email') {
|
||||
await sendMagicLink(state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -852,10 +862,9 @@ onUnmounted(() => {
|
||||
background-color: #F1F5F9;
|
||||
}
|
||||
|
||||
.social-badge-icon {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--oc-text, #101828);
|
||||
.social-logo {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.social-pill-text {
|
||||
|
||||
@@ -45,7 +45,8 @@ export type LoginCopyKey =
|
||||
| 'countdownSuffix'
|
||||
| 'loginSuccess'
|
||||
| 'moreWaysComing'
|
||||
| 'loggingIn';
|
||||
| 'loggingIn'
|
||||
| 'sentSuccess';
|
||||
|
||||
export type LoginState = {
|
||||
tab: LoginTab;
|
||||
@@ -67,6 +68,14 @@ export type LoginAdminResponse = {
|
||||
refreshExpire?: number;
|
||||
};
|
||||
|
||||
/** 外部登录(短信 / Magic Link / 社交)统一响应契约 */
|
||||
export type ExternalAuthResponse = {
|
||||
status?: string;
|
||||
token?: string;
|
||||
url?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
function isZhLocale(): boolean {
|
||||
return getCurrentLocale() === 'zh-CN';
|
||||
}
|
||||
@@ -156,6 +165,9 @@ export function resolveLoginText(key: LoginCopyKey | string, targetLocale?: stri
|
||||
if (key === 'comingSoon') {
|
||||
return zh ? '即将开放' : 'Coming soon';
|
||||
}
|
||||
if (key === 'sentSuccess') {
|
||||
return zh ? '已发送' : 'Sent';
|
||||
}
|
||||
if (key === 'phoneRequired') {
|
||||
return zh ? '请填写手机号' : 'Enter your phone number';
|
||||
}
|
||||
@@ -270,7 +282,8 @@ export function tickCountdown(state: LoginState): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录成功路径:写入 token / 可选 tenantId,再返回上一页或 toast
|
||||
* 登录成功路径:唯一允许写入本地 token 的入口。
|
||||
* 空 / 空白 token 直接拒绝,绝不 setAuthToken。
|
||||
*/
|
||||
export function completeLogin(token: string, tenantId?: string): boolean {
|
||||
const safeToken = token != null ? token.trim() : '';
|
||||
@@ -289,8 +302,16 @@ export function completeLogin(token: string, tenantId?: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function readPasswordLoginToken(res: LoginAdminResponse | null | undefined): string {
|
||||
if (res == null || res.token == null) {
|
||||
return '';
|
||||
}
|
||||
return String(res.token).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号密码真实登录:对齐后端 Cool-Admin POST /admin/base/open/login
|
||||
* 仅当接口明确返回非空 token 时写入本地凭证;失败 / 非成功 / 无 token 一律不写
|
||||
*/
|
||||
export async function submitPasswordLogin(state: LoginState): Promise<boolean> {
|
||||
const username = state.username != null ? state.username.trim() : '';
|
||||
@@ -320,14 +341,16 @@ export async function submitPasswordLogin(state: LoginState): Promise<boolean> {
|
||||
password,
|
||||
});
|
||||
state.submitting = false;
|
||||
if (res != null && res.token != null && res.token !== '') {
|
||||
completeLogin(res.token);
|
||||
return true;
|
||||
const token = readPasswordLoginToken(res);
|
||||
if (token !== '') {
|
||||
return completeLogin(token);
|
||||
}
|
||||
// 接口非失败体但缺少 token:不得 setAuthToken / 不得当成已登录
|
||||
state.errorMessage = '登录失败,未获取到有效凭证';
|
||||
toastNone(state.errorMessage);
|
||||
return false;
|
||||
} catch (err: any) {
|
||||
// 业务失败 / 网络异常:绝不写入 token
|
||||
state.submitting = false;
|
||||
const msg = err && err.message ? err.message : '账户或密码不正确~';
|
||||
state.errorMessage = msg;
|
||||
@@ -335,7 +358,91 @@ export async function submitPasswordLogin(state: LoginState): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
export function sendSmsCode(state: LoginState): boolean {
|
||||
function readExternalStatus(res: ExternalAuthResponse | null | undefined): string {
|
||||
if (res == null || res.status == null) {
|
||||
return '';
|
||||
}
|
||||
return String(res.status).trim();
|
||||
}
|
||||
|
||||
function readExternalToken(res: ExternalAuthResponse | null | undefined): string {
|
||||
if (res == null || res.token == null) {
|
||||
return '';
|
||||
}
|
||||
return String(res.token).trim();
|
||||
}
|
||||
|
||||
function readExternalUrl(res: ExternalAuthResponse | null | undefined): string {
|
||||
if (res == null || res.url == null) {
|
||||
return '';
|
||||
}
|
||||
return String(res.url).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理外部登录响应:not_configured → comingSoon;sent → 已发送;ok+token → 写凭证
|
||||
* 失败或不满足写 token 条件时绝不 setAuthToken
|
||||
*/
|
||||
function handleExternalAuthResult(
|
||||
res: ExternalAuthResponse | null | undefined,
|
||||
options?: { onSent?: () => void }
|
||||
): boolean {
|
||||
const status = readExternalStatus(res);
|
||||
if (status === 'not_configured') {
|
||||
toastNone(resolveLoginText('comingSoon'));
|
||||
return false;
|
||||
}
|
||||
if (status === 'sent') {
|
||||
toastNone(resolveLoginText('sentSuccess'));
|
||||
if (options != null && options.onSent != null) {
|
||||
options.onSent();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (status === 'ok') {
|
||||
const token = readExternalToken(res);
|
||||
if (token !== '') {
|
||||
return completeLogin(token);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function openOrCopyAuthUrl(url: string): void {
|
||||
const safeUrl = url != null ? url.trim() : '';
|
||||
if (safeUrl === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
let opened = false;
|
||||
try {
|
||||
if (typeof window !== 'undefined' && typeof window.open === 'function') {
|
||||
window.open(safeUrl);
|
||||
opened = true;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
uni.setClipboardData({
|
||||
data: safeUrl,
|
||||
success: () => {
|
||||
if (!opened) {
|
||||
toastNone(safeUrl);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (!opened) {
|
||||
toastNone(safeUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送短信验证码:POST /app/user/external/smsSend
|
||||
* sent 才启动倒计时;not_configured / 失败不写 token
|
||||
*/
|
||||
export async function sendSmsCode(state: LoginState): Promise<boolean> {
|
||||
if (state.countdown > 0) {
|
||||
return false;
|
||||
}
|
||||
@@ -348,15 +455,32 @@ export function sendSmsCode(state: LoginState): boolean {
|
||||
}
|
||||
|
||||
state.errorMessage = '';
|
||||
state.countdown = 60;
|
||||
toastNone(resolveLoginText('comingSoon'));
|
||||
return true;
|
||||
|
||||
try {
|
||||
const res = await httpPost<ExternalAuthResponse>('/app/user/external/smsSend', {
|
||||
phone,
|
||||
});
|
||||
return handleExternalAuthResult(res, {
|
||||
onSent: () => {
|
||||
state.countdown = 60;
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
const msg =
|
||||
err && err.message ? String(err.message) : resolveLoginText('comingSoon');
|
||||
state.errorMessage = msg;
|
||||
if (msg === resolveLoginText('comingSoon') || msg.indexOf('not_configured') >= 0) {
|
||||
toastNone(resolveLoginText('comingSoon'));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 国际手机号验证码登录。空手机号不提交;第一版不伪造成功登录
|
||||
* 国际手机号验证码登录:POST /app/user/external/smsLogin
|
||||
* 仅 status=ok 且有 token 时写入凭证
|
||||
*/
|
||||
export function submitPhoneLogin(state: LoginState): boolean {
|
||||
export async function submitPhoneLogin(state: LoginState): Promise<boolean> {
|
||||
const phone = state.phone != null ? state.phone.trim() : '';
|
||||
if (phone === '') {
|
||||
state.errorMessage = resolveLoginText('phoneRequired');
|
||||
@@ -375,15 +499,29 @@ export function submitPhoneLogin(state: LoginState): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.submitting = true;
|
||||
state.errorMessage = '';
|
||||
toastNone(resolveLoginText('comingSoon'));
|
||||
return false;
|
||||
|
||||
try {
|
||||
const res = await httpPost<ExternalAuthResponse>('/app/user/external/smsLogin', {
|
||||
phone,
|
||||
code: smsCode,
|
||||
});
|
||||
state.submitting = false;
|
||||
return handleExternalAuthResult(res);
|
||||
} catch (err: any) {
|
||||
state.submitting = false;
|
||||
const msg = err && err.message ? String(err.message) : resolveLoginText('comingSoon');
|
||||
state.errorMessage = msg;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱 Magic Link。空邮箱不提交;第一版不伪造成功登录
|
||||
* 邮箱 Magic Link:POST /app/user/external/magicLink
|
||||
* sent 提示已发送;ok+token 才登录;not_configured 不写 token
|
||||
*/
|
||||
export function sendMagicLink(state: LoginState): boolean {
|
||||
export async function sendMagicLink(state: LoginState): Promise<boolean> {
|
||||
const email = state.email != null ? state.email.trim() : '';
|
||||
if (email === '') {
|
||||
state.errorMessage = resolveLoginText('emailRequired');
|
||||
@@ -395,14 +533,56 @@ export function sendMagicLink(state: LoginState): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.submitting = true;
|
||||
state.errorMessage = '';
|
||||
toastNone(resolveLoginText('comingSoon'));
|
||||
return false;
|
||||
|
||||
try {
|
||||
const res = await httpPost<ExternalAuthResponse>('/app/user/external/magicLink', {
|
||||
email,
|
||||
});
|
||||
state.submitting = false;
|
||||
return handleExternalAuthResult(res);
|
||||
} catch (err: any) {
|
||||
state.submitting = false;
|
||||
const msg = err && err.message ? String(err.message) : resolveLoginText('comingSoon');
|
||||
state.errorMessage = msg;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Google / Apple 仅展示即将开放,不写凭证
|
||||
* Google / Apple:POST googleStart / appleStart
|
||||
* 有 url 则复制或打开;not_configured 且无 url 不打开假地址;不写 token
|
||||
*/
|
||||
export function tapSocialLogin(_provider: SocialProvider): void {
|
||||
toastNone(resolveLoginText('comingSoon'));
|
||||
export async function tapSocialLogin(provider: SocialProvider): Promise<void> {
|
||||
const path =
|
||||
provider === 'apple'
|
||||
? '/app/user/external/appleStart'
|
||||
: '/app/user/external/googleStart';
|
||||
|
||||
try {
|
||||
const res = await httpPost<ExternalAuthResponse>(path, {});
|
||||
const status = readExternalStatus(res);
|
||||
const url = readExternalUrl(res);
|
||||
|
||||
if (url !== '') {
|
||||
openOrCopyAuthUrl(url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 'not_configured' || status === '') {
|
||||
toastNone(resolveLoginText('comingSoon'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 其它无 url 的成功态也不伪造打开地址、不写 token
|
||||
if (status === 'ok' || status === 'sent') {
|
||||
toastNone(resolveLoginText('comingSoon'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err && err.message ? String(err.message) : '';
|
||||
if (msg === '' || msg.indexOf('not_configured') >= 0) {
|
||||
toastNone(resolveLoginText('comingSoon'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
class="form-input"
|
||||
type="text"
|
||||
v-model="editForm.title"
|
||||
placeholder="例如:2026 全球智能硬件合规白皮书"
|
||||
:placeholder="t('phTitle')"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
class="form-input"
|
||||
type="text"
|
||||
v-model="editForm.summary"
|
||||
placeholder="若不填写将自动从正文中提取摘要..."
|
||||
:placeholder="t('phSummary')"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
<textarea
|
||||
class="form-textarea"
|
||||
v-model="editForm.content"
|
||||
placeholder="输入正文内容,支持段落与 Markdown..."
|
||||
:placeholder="t('phContent')"
|
||||
maxlength="2000"
|
||||
></textarea>
|
||||
</view>
|
||||
@@ -136,7 +136,7 @@
|
||||
<view class="form-field-switch">
|
||||
<view class="switch-info">
|
||||
<text class="switch-title">{{ t('fieldPublish') }}</text>
|
||||
<text class="switch-desc">{{ publishDescText }}</text>
|
||||
<text class="switch-desc">{{ t('publishDesc') }}</text>
|
||||
</view>
|
||||
<switch
|
||||
:checked="editForm.isPublished"
|
||||
@@ -157,7 +157,7 @@
|
||||
:disabled="state.saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
<text class="primary-cta-btn-text">{{ state.saving ? savingText : t('saveBtn') }}</text>
|
||||
<text class="primary-cta-btn-text">{{ state.saving ? t('saving') : t('saveBtn') }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
@@ -201,16 +201,6 @@ const pageRtlClass = computed<string>(() => {
|
||||
return isRtl(getCurrentLocale()) ? 'page-rtl' : '';
|
||||
});
|
||||
|
||||
const publishDescText = computed<string>(() => {
|
||||
return getCurrentLocale() === 'zh-CN'
|
||||
? '开启后,全球访客可在您的名片微官网中直接浏览'
|
||||
: 'When enabled, visitors can view this insight on your micro-site';
|
||||
});
|
||||
|
||||
const savingText = computed<string>(() => {
|
||||
return getCurrentLocale() === 'zh-CN' ? '保存中...' : 'Saving...';
|
||||
});
|
||||
|
||||
const pageTheme: Record<string, string> = {
|
||||
'--oc-primary': utsLightColors.primary,
|
||||
'--oc-primary-gradient': utsLightColors.primaryGradient,
|
||||
@@ -281,7 +271,7 @@ async function handleToggleStatus(item: CardArticle): Promise<void> {
|
||||
const ok = await toggleArticlePublish(state, item);
|
||||
if (ok) {
|
||||
uni.showToast({
|
||||
title: getCurrentLocale() === 'zh-CN' ? '状态已切换' : 'Status Updated',
|
||||
title: t('statusUpdatedToast'),
|
||||
icon: 'none',
|
||||
});
|
||||
}
|
||||
@@ -291,7 +281,7 @@ async function handleSave(): Promise<void> {
|
||||
const success = await saveArticle(state, editForm);
|
||||
if (success) {
|
||||
uni.showToast({
|
||||
title: getCurrentLocale() === 'zh-CN' ? '动态已保存' : 'Article Saved',
|
||||
title: t('savedToast'),
|
||||
icon: 'none',
|
||||
});
|
||||
} else if (state.errorMessage !== '') {
|
||||
@@ -302,20 +292,19 @@ async function handleSave(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: CardArticle): void {
|
||||
async function handleDelete(item: CardArticle): Promise<void> {
|
||||
if (item.id == null) return;
|
||||
const zh = getCurrentLocale() === 'zh-CN';
|
||||
uni.showModal({
|
||||
title: zh ? '确认删除' : 'Confirm Delete',
|
||||
content: zh ? '确定要删除该文章动态吗?' : 'Are you sure you want to delete this article?',
|
||||
confirmText: zh ? '删除' : 'Delete',
|
||||
cancelText: zh ? '取消' : 'Cancel',
|
||||
title: t('deleteConfirmTitle'),
|
||||
content: t('deleteConfirmContent'),
|
||||
confirmText: t('deleteConfirmOk'),
|
||||
cancelText: t('cancelBtn'),
|
||||
success: async (res: any) => {
|
||||
if (res.confirm) {
|
||||
const ok = await deleteArticle(state, item.id!);
|
||||
if (ok) {
|
||||
uni.showToast({
|
||||
title: zh ? '已删除' : 'Deleted',
|
||||
title: t('deletedToast'),
|
||||
icon: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
:src="item.coverImage!"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="case-item-cover-placeholder">
|
||||
<text class="placeholder-text">{{ placeholderCoverText }}</text>
|
||||
<view v-else class="case-item-cover-placeholder">
|
||||
<text class="placeholder-text">{{ t('coverPlaceholder') }}</text>
|
||||
</view>
|
||||
|
||||
<view class="case-item-body">
|
||||
@@ -49,7 +49,7 @@
|
||||
</view>
|
||||
|
||||
<view v-if="item.client != null && item.client !== ''" class="case-item-meta">
|
||||
<text class="case-meta-label">{{ clientPrefixText }}</text>
|
||||
<text class="case-meta-label">{{ t('clientPrefix') }}</text>
|
||||
<text class="case-meta-value">{{ item.client }}</text>
|
||||
</view>
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
class="form-input"
|
||||
type="text"
|
||||
v-model="editForm.title"
|
||||
placeholder="例如:全球智能硬件数字化交付方案"
|
||||
:placeholder="t('phTitle')"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
class="form-input"
|
||||
type="text"
|
||||
v-model="editForm.client"
|
||||
placeholder="例如:阿联酋国际商业联合会"
|
||||
:placeholder="t('phClient')"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -120,29 +120,18 @@
|
||||
class="form-input"
|
||||
type="text"
|
||||
v-model="editForm.duration"
|
||||
placeholder="例如:2025.06 - 2025.12"
|
||||
:placeholder="t('phDuration')"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 量化成效标签快捷选择 -->
|
||||
<!-- 成效标签(用户自填,禁止预填假 KPI) -->
|
||||
<view class="form-field">
|
||||
<text class="form-label">{{ t('fieldMetrics') }}</text>
|
||||
<view class="metrics-pill-group">
|
||||
<view
|
||||
v-for="metric in PRESET_CASE_METRICS"
|
||||
:key="metric"
|
||||
class="preset-metric-pill"
|
||||
:class="{ 'preset-metric-pill-active': editForm.metrics === metric }"
|
||||
@click="handleSelectMetric(metric)"
|
||||
>
|
||||
<text class="preset-metric-text">{{ metric }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<input
|
||||
class="form-input"
|
||||
type="text"
|
||||
v-model="editForm.metrics"
|
||||
placeholder="或自定义输入成效,如:触达 10,000+ 买家"
|
||||
:placeholder="t('phMetrics')"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -162,7 +151,7 @@
|
||||
<textarea
|
||||
class="form-textarea"
|
||||
v-model="editForm.description"
|
||||
placeholder="简述方案背景、关键成效或客户反馈..."
|
||||
:placeholder="t('phDesc')"
|
||||
maxlength="500"
|
||||
></textarea>
|
||||
</view>
|
||||
@@ -179,7 +168,7 @@
|
||||
:disabled="state.saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
<text class="primary-cta-btn-text">{{ state.saving ? savingText : t('saveBtn') }}</text>
|
||||
<text class="primary-cta-btn-text">{{ state.saving ? t('saving') : t('saveBtn') }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
@@ -194,7 +183,6 @@ import {
|
||||
loadCases,
|
||||
saveCase,
|
||||
deleteCase,
|
||||
PRESET_CASE_METRICS,
|
||||
resolveCasesText,
|
||||
CasesCopyKey,
|
||||
} from './useCardCases.uts';
|
||||
@@ -224,18 +212,6 @@ const pageRtlClass = computed<string>(() => {
|
||||
return isRtl(getCurrentLocale()) ? 'page-rtl' : '';
|
||||
});
|
||||
|
||||
const clientPrefixText = computed<string>(() => {
|
||||
return getCurrentLocale() === 'zh-CN' ? '合作客户: ' : 'Client: ';
|
||||
});
|
||||
|
||||
const placeholderCoverText = computed<string>(() => {
|
||||
return getCurrentLocale() === 'zh-CN' ? '产品展示' : 'Showcase';
|
||||
});
|
||||
|
||||
const savingText = computed<string>(() => {
|
||||
return getCurrentLocale() === 'zh-CN' ? '保存中...' : 'Saving...';
|
||||
});
|
||||
|
||||
const pageTheme: Record<string, string> = {
|
||||
'--oc-primary': utsLightColors.primary,
|
||||
'--oc-primary-gradient': utsLightColors.primaryGradient,
|
||||
@@ -291,10 +267,6 @@ function handleCloseDrawer(): void {
|
||||
state.drawerVisible = false;
|
||||
}
|
||||
|
||||
function handleSelectMetric(val: string): void {
|
||||
editForm.metrics = val;
|
||||
}
|
||||
|
||||
function handleImagesChange(imgs: string[]): void {
|
||||
uploadedImages.value = imgs;
|
||||
if (imgs.length > 0) {
|
||||
@@ -308,7 +280,7 @@ async function handleSave(): Promise<void> {
|
||||
const success = await saveCase(state, editForm);
|
||||
if (success) {
|
||||
uni.showToast({
|
||||
title: getCurrentLocale() === 'zh-CN' ? '案例已保存' : 'Case Saved',
|
||||
title: t('savedToast'),
|
||||
icon: 'none',
|
||||
});
|
||||
} else if (state.errorMessage !== '') {
|
||||
@@ -319,20 +291,19 @@ async function handleSave(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: CardCase): void {
|
||||
async function handleDelete(item: CardCase): Promise<void> {
|
||||
if (item.id == null) return;
|
||||
const zh = getCurrentLocale() === 'zh-CN';
|
||||
uni.showModal({
|
||||
title: zh ? '确认删除' : 'Confirm Delete',
|
||||
content: zh ? '确定要删除该案例吗?此操作无法撤销。' : 'Are you sure you want to delete this case?',
|
||||
confirmText: zh ? '删除' : 'Delete',
|
||||
cancelText: zh ? '取消' : 'Cancel',
|
||||
title: t('deleteConfirmTitle'),
|
||||
content: t('deleteConfirmContent'),
|
||||
confirmText: t('deleteConfirmOk'),
|
||||
cancelText: t('cancelBtn'),
|
||||
success: async (res: any) => {
|
||||
if (res.confirm) {
|
||||
const ok = await deleteCase(state, item.id!);
|
||||
if (ok) {
|
||||
uni.showToast({
|
||||
title: zh ? '已删除' : 'Deleted',
|
||||
title: t('deletedToast'),
|
||||
icon: 'none',
|
||||
});
|
||||
}
|
||||
@@ -711,32 +682,6 @@ onLoad((options: any) => {
|
||||
height: 90px;
|
||||
}
|
||||
|
||||
.metrics-pill-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.preset-metric-pill {
|
||||
background-color: var(--oc-bg);
|
||||
border: 1px solid var(--oc-border);
|
||||
padding: 6px 12px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.preset-metric-pill-active {
|
||||
background-color: var(--oc-primary-soft);
|
||||
border-color: var(--oc-primary);
|
||||
}
|
||||
|
||||
.preset-metric-text {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--oc-text);
|
||||
}
|
||||
|
||||
.drawer-action-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -55,13 +55,32 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="modules-stack">
|
||||
<!-- Module 1: Basic Profile -->
|
||||
<view class="module-card">
|
||||
<view class="module-card" :style="moduleCardStyle('basic')">
|
||||
<view class="module-card-header">
|
||||
<view class="module-title-wrap">
|
||||
<view class="module-indicator"></view>
|
||||
<text class="module-title">{{ t('basicSection') }}</text>
|
||||
</view>
|
||||
<view class="module-order-actions">
|
||||
<button
|
||||
class="module-order-btn"
|
||||
:disabled="state.saving || !canMoveModule('basic', -1)"
|
||||
:aria-label="t('moveUp')"
|
||||
@click="handleMoveModule('basic', -1)"
|
||||
>
|
||||
<text class="module-order-btn-text">{{ t('moveUp') }}</text>
|
||||
</button>
|
||||
<button
|
||||
class="module-order-btn"
|
||||
:disabled="state.saving || !canMoveModule('basic', 1)"
|
||||
:aria-label="t('moveDown')"
|
||||
@click="handleMoveModule('basic', 1)"
|
||||
>
|
||||
<text class="module-order-btn-text">{{ t('moveDown') }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Avatar Uploader Stage -->
|
||||
@@ -127,27 +146,71 @@
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="field field-last">
|
||||
<view class="field">
|
||||
<text class="field-label">{{ t('company') }}</text>
|
||||
<input
|
||||
class="field-input"
|
||||
:class="{ 'field-input-locked': companyLocked }"
|
||||
type="text"
|
||||
v-model="state.company"
|
||||
:placeholder="t('company')"
|
||||
:disabled="state.saving || companyLocked"
|
||||
/>
|
||||
<text v-if="companyLocked" class="field-lock-hint">{{ t('lockedHint') }}</text>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">{{ t('slogan') }}</text>
|
||||
<input
|
||||
class="field-input"
|
||||
:class="{ 'field-input-locked': sloganLocked }"
|
||||
type="text"
|
||||
v-model="state.slogan"
|
||||
:placeholder="t('slogan')"
|
||||
:disabled="state.saving || sloganLocked"
|
||||
/>
|
||||
<text v-if="sloganLocked" class="field-lock-hint">{{ t('lockedHint') }}</text>
|
||||
</view>
|
||||
|
||||
<view class="field field-last">
|
||||
<text class="field-label">{{ t('timezone') }}</text>
|
||||
<input
|
||||
class="field-input"
|
||||
type="text"
|
||||
v-model="state.timezone"
|
||||
:placeholder="t('timezone')"
|
||||
:disabled="state.saving"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Module 2: Social Matrix -->
|
||||
<view class="module-card">
|
||||
<view class="module-card" :style="moduleCardStyle('social')">
|
||||
<view class="module-card-header">
|
||||
<view class="module-title-wrap">
|
||||
<view class="module-indicator"></view>
|
||||
<text class="module-title">{{ t('socialSection') }}</text>
|
||||
</view>
|
||||
<text class="module-subtitle">{{ t('socialSectionDesc') }}</text>
|
||||
<view class="module-order-actions">
|
||||
<button
|
||||
class="module-order-btn"
|
||||
:disabled="state.saving || !canMoveModule('social', -1)"
|
||||
:aria-label="t('moveUp')"
|
||||
@click="handleMoveModule('social', -1)"
|
||||
>
|
||||
<text class="module-order-btn-text">{{ t('moveUp') }}</text>
|
||||
</button>
|
||||
<button
|
||||
class="module-order-btn"
|
||||
:disabled="state.saving || !canMoveModule('social', 1)"
|
||||
:aria-label="t('moveDown')"
|
||||
@click="handleMoveModule('social', 1)"
|
||||
>
|
||||
<text class="module-order-btn-text">{{ t('moveDown') }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
<text class="module-subtitle module-subtitle-block">{{ t('socialSectionDesc') }}</text>
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">{{ t('phone') }}</text>
|
||||
@@ -195,16 +258,34 @@
|
||||
</view>
|
||||
|
||||
<!-- Module 3: Privacy & Visibility -->
|
||||
<view class="module-card">
|
||||
<view class="module-card" :style="moduleCardStyle('privacy')">
|
||||
<view class="module-card-header">
|
||||
<view class="module-title-wrap">
|
||||
<view class="module-indicator"></view>
|
||||
<text class="module-title">{{ t('privacySection') }}</text>
|
||||
</view>
|
||||
<view class="gdpr-pill">
|
||||
<text class="gdpr-pill-text">{{ t('gdprBadge') }}</text>
|
||||
<view class="module-order-actions">
|
||||
<button
|
||||
class="module-order-btn"
|
||||
:disabled="state.saving || !canMoveModule('privacy', -1)"
|
||||
:aria-label="t('moveUp')"
|
||||
@click="handleMoveModule('privacy', -1)"
|
||||
>
|
||||
<text class="module-order-btn-text">{{ t('moveUp') }}</text>
|
||||
</button>
|
||||
<button
|
||||
class="module-order-btn"
|
||||
:disabled="state.saving || !canMoveModule('privacy', 1)"
|
||||
:aria-label="t('moveDown')"
|
||||
@click="handleMoveModule('privacy', 1)"
|
||||
>
|
||||
<text class="module-order-btn-text">{{ t('moveDown') }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
<view class="gdpr-pill gdpr-pill-inline">
|
||||
<text class="gdpr-pill-text">{{ t('gdprBadge') }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Visibility Options -->
|
||||
<view class="privacy-options-group">
|
||||
@@ -274,6 +355,7 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Error Alert Banner -->
|
||||
<view v-if="state.errorMessage !== ''" class="card-error">
|
||||
@@ -314,6 +396,8 @@ import {
|
||||
updateOwnerCard,
|
||||
fetchOwnerCardInfo,
|
||||
buildUpdatePayload,
|
||||
isFieldLocked,
|
||||
moveModuleInOrder,
|
||||
CardEditCopyKey,
|
||||
CardEditPageOptions,
|
||||
} from './useCardEdit.uts';
|
||||
@@ -337,7 +421,8 @@ type CardEditExtendedKey =
|
||||
| 'duration24h'
|
||||
| 'duration7d'
|
||||
| 'duration30d'
|
||||
| 'temporaryNote';
|
||||
| 'temporaryNote'
|
||||
| 'lockedHint';
|
||||
|
||||
const EXTENDED_COPY: Record<OmniLocale, Record<string, string>> = {
|
||||
'zh-CN': {
|
||||
@@ -525,10 +610,55 @@ const avatarActionText = computed((): string => {
|
||||
return state.avatarUrl !== '' ? resolveCardEditText('avatarChange') : resolveCardEditText('avatarUpload');
|
||||
});
|
||||
|
||||
const companyLocked = computed((): boolean => {
|
||||
return isFieldLocked(state.lockedFields, 'company');
|
||||
});
|
||||
|
||||
const sloganLocked = computed((): boolean => {
|
||||
return isFieldLocked(state.lockedFields, 'slogan');
|
||||
});
|
||||
|
||||
const deleteAriaLabel = computed((): string => {
|
||||
return getOmniText(getCurrentLocale(), 'common.delete');
|
||||
});
|
||||
|
||||
function moduleIndexOf(key: string): number {
|
||||
const order = state.moduleOrder;
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
if (order[i] === key) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function moduleCardStyle(key: string): Record<string, string> {
|
||||
const index = moduleIndexOf(key);
|
||||
return {
|
||||
order: String(index >= 0 ? index : 99),
|
||||
};
|
||||
}
|
||||
|
||||
function canMoveModule(key: string, delta: number): boolean {
|
||||
const index = moduleIndexOf(key);
|
||||
if (index < 0) {
|
||||
return false;
|
||||
}
|
||||
const target = index + delta;
|
||||
return target >= 0 && target < state.moduleOrder.length;
|
||||
}
|
||||
|
||||
function handleMoveModule(key: string, delta: number): void {
|
||||
if (state.saving) {
|
||||
return;
|
||||
}
|
||||
const index = moduleIndexOf(key);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
state.moduleOrder = moveModuleInOrder(state.moduleOrder, index, delta);
|
||||
}
|
||||
|
||||
function t(key: CardEditExtendedKey): string {
|
||||
const locale = getCurrentLocale();
|
||||
const dict = EXTENDED_COPY[locale];
|
||||
@@ -611,6 +741,7 @@ function handleBack(): void {
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
const ok = await submitCardEdit(state);
|
||||
// 仅接口 code === 1000(submitCardEdit 已校验)时提示保存成功
|
||||
if (ok) {
|
||||
try {
|
||||
if (typeof uni !== 'undefined' && uni.setStorageSync != null && state.cardId != null) {
|
||||
@@ -799,6 +930,11 @@ onLoad((options: CardEditPageOptions) => {
|
||||
margin-block-end: 16px;
|
||||
}
|
||||
|
||||
.modules-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.module-card-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -811,6 +947,43 @@ onLoad((options: CardEditPageOptions) => {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.module-order-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-inline-start: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.module-order-btn {
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
min-width: 44px;
|
||||
padding-inline: 8px;
|
||||
background-color: var(--oc-primary-soft, #EBF5FF);
|
||||
border-width: 0;
|
||||
border-radius: 9999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.module-order-btn:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.module-order-btn[disabled] {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.module-order-btn-text {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--oc-primary, #0B5CFF);
|
||||
}
|
||||
|
||||
.module-indicator {
|
||||
@@ -833,12 +1006,23 @@ onLoad((options: CardEditPageOptions) => {
|
||||
color: var(--oc-text-2, #667085);
|
||||
}
|
||||
|
||||
.module-subtitle-block {
|
||||
margin-block-end: 12px;
|
||||
margin-block-start: -8px;
|
||||
}
|
||||
|
||||
.gdpr-pill {
|
||||
padding: 2px 8px;
|
||||
background-color: rgba(18, 183, 106, 0.1);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.gdpr-pill-inline {
|
||||
align-self: flex-start;
|
||||
margin-block-end: 12px;
|
||||
margin-block-start: -8px;
|
||||
}
|
||||
|
||||
.gdpr-pill-text {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -969,6 +1153,18 @@ onLoad((options: CardEditPageOptions) => {
|
||||
color: var(--oc-text, #101828);
|
||||
}
|
||||
|
||||
.field-input-locked {
|
||||
opacity: 0.72;
|
||||
color: var(--oc-text-2, #667085);
|
||||
}
|
||||
|
||||
.field-lock-hint {
|
||||
margin-block-start: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--oc-text-2, #667085);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.privacy-options-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -164,6 +164,7 @@ import {
|
||||
createQrcodeState,
|
||||
loadPosterIntoState,
|
||||
resolveQrcodeText,
|
||||
savePosterToAlbum,
|
||||
QrcodeCopyKey,
|
||||
QrcodeQuery,
|
||||
} from './useCardQrcode.uts';
|
||||
@@ -446,7 +447,10 @@ function handlePrimaryCta(): void {
|
||||
if (!canCopy.value) {
|
||||
return;
|
||||
}
|
||||
copyPosterUrl(state.url);
|
||||
void (async () => {
|
||||
await savePosterToAlbum(state.svg);
|
||||
copyPosterUrl(state.url);
|
||||
})();
|
||||
}
|
||||
|
||||
onLoad((options: QrcodeQuery) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
updateCardArticle,
|
||||
deleteCardArticle,
|
||||
} from '../../../common/api.uts';
|
||||
import { getCurrentLocale, getOmniText } from '../../../common/locale.uts';
|
||||
import { getCurrentLocale } from '../../../common/locale.uts';
|
||||
|
||||
export type ArticlesApi = {
|
||||
fetchPage: (query: OwnerPageQuery) => Promise<OwnerPageData<CardArticle>>;
|
||||
@@ -68,11 +68,11 @@ export function extractArticleSummary(content?: string, maxLen: number = 120): s
|
||||
export function validateArticleForm(form: Partial<CardArticle>): string | null {
|
||||
const title = form.title != null ? form.title.trim() : '';
|
||||
if (title === '') {
|
||||
return '请输入文章或动态标题';
|
||||
return resolveArticlesText('errTitleRequired');
|
||||
}
|
||||
const content = form.content != null ? form.content.trim() : '';
|
||||
if (content === '') {
|
||||
return '请输入文章正文内容';
|
||||
return resolveArticlesText('errContentRequired');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export async function loadArticles(
|
||||
state.list = items;
|
||||
state.empty = items.length === 0;
|
||||
} catch (err: any) {
|
||||
state.errorMessage = err.message || '加载文章列表失败';
|
||||
state.errorMessage = err.message || resolveArticlesText('errLoadFailed');
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
@@ -140,7 +140,7 @@ export async function saveArticle(
|
||||
await loadArticles(state, api);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
state.errorMessage = err.message || '保存文章失败';
|
||||
state.errorMessage = err.message || resolveArticlesText('errSaveFailed');
|
||||
return false;
|
||||
} finally {
|
||||
state.saving = false;
|
||||
@@ -167,7 +167,7 @@ export async function toggleArticlePublish(
|
||||
article.isPublished = targetStatus;
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
state.errorMessage = err.message || '切换发布状态失败';
|
||||
state.errorMessage = err.message || resolveArticlesText('errToggleFailed');
|
||||
return false;
|
||||
} finally {
|
||||
state.saving = false;
|
||||
@@ -189,7 +189,7 @@ export async function deleteArticle(
|
||||
}
|
||||
return false;
|
||||
} catch (err: any) {
|
||||
state.errorMessage = err.message || '删除文章失败';
|
||||
state.errorMessage = err.message || resolveArticlesText('errDeleteFailed');
|
||||
return false;
|
||||
} finally {
|
||||
state.saving = false;
|
||||
@@ -211,8 +211,25 @@ export type ArticlesCopyKey =
|
||||
| 'fieldCover'
|
||||
| 'fieldContent'
|
||||
| 'fieldPublish'
|
||||
| 'publishDesc'
|
||||
| 'phTitle'
|
||||
| 'phSummary'
|
||||
| 'phContent'
|
||||
| 'saveBtn'
|
||||
| 'cancelBtn';
|
||||
| 'cancelBtn'
|
||||
| 'saving'
|
||||
| 'savedToast'
|
||||
| 'statusUpdatedToast'
|
||||
| 'deletedToast'
|
||||
| 'deleteConfirmTitle'
|
||||
| 'deleteConfirmContent'
|
||||
| 'deleteConfirmOk'
|
||||
| 'errTitleRequired'
|
||||
| 'errContentRequired'
|
||||
| 'errLoadFailed'
|
||||
| 'errSaveFailed'
|
||||
| 'errDeleteFailed'
|
||||
| 'errToggleFailed';
|
||||
|
||||
export function resolveArticlesText(key: ArticlesCopyKey): string {
|
||||
const locale = getCurrentLocale();
|
||||
@@ -247,10 +264,46 @@ export function resolveArticlesText(key: ArticlesCopyKey): string {
|
||||
return zh ? '文章正文 (支持富文本与 Markdown)' : 'Content';
|
||||
case 'fieldPublish':
|
||||
return zh ? '公开发布至微官网' : 'Publish to Micro-site';
|
||||
case 'publishDesc':
|
||||
return zh
|
||||
? '开启后,全球访客可在您的名片微官网中直接浏览'
|
||||
: 'When enabled, visitors can view this insight on your micro-site';
|
||||
case 'phTitle':
|
||||
return zh ? '输入文章或动态标题' : 'Enter article or insight title';
|
||||
case 'phSummary':
|
||||
return zh ? '选填,不填将从正文自动提取' : 'Optional; auto-extracted from content if empty';
|
||||
case 'phContent':
|
||||
return zh ? '输入正文,支持段落与 Markdown' : 'Write content; paragraphs and Markdown supported';
|
||||
case 'saveBtn':
|
||||
return zh ? '确认发布' : 'Publish';
|
||||
case 'cancelBtn':
|
||||
return zh ? '取消' : 'Cancel';
|
||||
case 'saving':
|
||||
return zh ? '保存中...' : 'Saving...';
|
||||
case 'savedToast':
|
||||
return zh ? '动态已保存' : 'Article Saved';
|
||||
case 'statusUpdatedToast':
|
||||
return zh ? '状态已切换' : 'Status Updated';
|
||||
case 'deletedToast':
|
||||
return zh ? '已删除' : 'Deleted';
|
||||
case 'deleteConfirmTitle':
|
||||
return zh ? '确认删除' : 'Confirm Delete';
|
||||
case 'deleteConfirmContent':
|
||||
return zh ? '确定要删除该文章动态吗?' : 'Are you sure you want to delete this article?';
|
||||
case 'deleteConfirmOk':
|
||||
return zh ? '删除' : 'Delete';
|
||||
case 'errTitleRequired':
|
||||
return zh ? '请输入文章或动态标题' : 'Please enter a title';
|
||||
case 'errContentRequired':
|
||||
return zh ? '请输入文章正文内容' : 'Please enter article content';
|
||||
case 'errLoadFailed':
|
||||
return zh ? '加载文章列表失败' : 'Failed to load articles';
|
||||
case 'errSaveFailed':
|
||||
return zh ? '保存文章失败' : 'Failed to save article';
|
||||
case 'errDeleteFailed':
|
||||
return zh ? '删除文章失败' : 'Failed to delete article';
|
||||
case 'errToggleFailed':
|
||||
return zh ? '切换发布状态失败' : 'Failed to update publish status';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -10,15 +10,7 @@ import {
|
||||
updateCardCase,
|
||||
deleteCardCase,
|
||||
} from '../../../common/api.uts';
|
||||
import { getCurrentLocale, getOmniText } from '../../../common/locale.uts';
|
||||
|
||||
export const PRESET_CASE_METRICS: string[] = [
|
||||
'+320% ROI',
|
||||
'+150% 留资率',
|
||||
'45k+ 国际线索',
|
||||
'2M+ 全球曝光',
|
||||
'Top 1 细分类目',
|
||||
];
|
||||
import { getCurrentLocale } from '../../../common/locale.uts';
|
||||
|
||||
export type CasesApi = {
|
||||
fetchPage: (query: OwnerPageQuery) => Promise<OwnerPageData<CardCase>>;
|
||||
@@ -61,10 +53,10 @@ export function createCasesState(cardId: string | number): CasesState {
|
||||
export function validateCaseForm(form: Partial<CardCase>): string | null {
|
||||
const title = form.title != null ? form.title.trim() : '';
|
||||
if (title === '') {
|
||||
return '请输入案例或产品标题';
|
||||
return resolveCasesText('errTitleRequired');
|
||||
}
|
||||
if (title.length > 150) {
|
||||
return '标题长度不能超过 150 个字符';
|
||||
return resolveCasesText('errTitleTooLong');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -85,7 +77,7 @@ export async function loadCases(
|
||||
state.list = items;
|
||||
state.empty = items.length === 0;
|
||||
} catch (err: any) {
|
||||
state.errorMessage = err.message || '加载案例列表失败';
|
||||
state.errorMessage = err.message || resolveCasesText('errLoadFailed');
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
@@ -125,7 +117,7 @@ export async function saveCase(
|
||||
await loadCases(state, api);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
state.errorMessage = err.message || '保存案例失败';
|
||||
state.errorMessage = err.message || resolveCasesText('errSaveFailed');
|
||||
return false;
|
||||
} finally {
|
||||
state.saving = false;
|
||||
@@ -147,7 +139,7 @@ export async function deleteCase(
|
||||
}
|
||||
return false;
|
||||
} catch (err: any) {
|
||||
state.errorMessage = err.message || '删除案例失败';
|
||||
state.errorMessage = err.message || resolveCasesText('errDeleteFailed');
|
||||
return false;
|
||||
} finally {
|
||||
state.saving = false;
|
||||
@@ -169,7 +161,25 @@ export type CasesCopyKey =
|
||||
| 'fieldDesc'
|
||||
| 'fieldCover'
|
||||
| 'saveBtn'
|
||||
| 'cancelBtn';
|
||||
| 'cancelBtn'
|
||||
| 'phTitle'
|
||||
| 'phClient'
|
||||
| 'phDuration'
|
||||
| 'phMetrics'
|
||||
| 'phDesc'
|
||||
| 'clientPrefix'
|
||||
| 'coverPlaceholder'
|
||||
| 'saving'
|
||||
| 'savedToast'
|
||||
| 'deletedToast'
|
||||
| 'deleteConfirmTitle'
|
||||
| 'deleteConfirmContent'
|
||||
| 'deleteConfirmOk'
|
||||
| 'errTitleRequired'
|
||||
| 'errTitleTooLong'
|
||||
| 'errLoadFailed'
|
||||
| 'errSaveFailed'
|
||||
| 'errDeleteFailed';
|
||||
|
||||
export function resolveCasesText(key: CasesCopyKey): string {
|
||||
const locale = getCurrentLocale();
|
||||
@@ -207,6 +217,42 @@ export function resolveCasesText(key: CasesCopyKey): string {
|
||||
return zh ? '保存案例' : 'Save Case';
|
||||
case 'cancelBtn':
|
||||
return zh ? '取消' : 'Cancel';
|
||||
case 'phTitle':
|
||||
return zh ? '输入案例或产品名称' : 'Enter case or product title';
|
||||
case 'phClient':
|
||||
return zh ? '选填合作客商或品牌' : 'Optional client or brand';
|
||||
case 'phDuration':
|
||||
return zh ? '例如项目起止时间' : 'e.g. project timeline';
|
||||
case 'phMetrics':
|
||||
return zh ? '选填成效标签' : 'Optional metrics tag';
|
||||
case 'phDesc':
|
||||
return zh ? '简述方案背景、成效或客户反馈' : 'Brief overview, outcomes, or feedback';
|
||||
case 'clientPrefix':
|
||||
return zh ? '合作客户: ' : 'Client: ';
|
||||
case 'coverPlaceholder':
|
||||
return zh ? '产品展示' : 'Showcase';
|
||||
case 'saving':
|
||||
return zh ? '保存中...' : 'Saving...';
|
||||
case 'savedToast':
|
||||
return zh ? '案例已保存' : 'Case Saved';
|
||||
case 'deletedToast':
|
||||
return zh ? '已删除' : 'Deleted';
|
||||
case 'deleteConfirmTitle':
|
||||
return zh ? '确认删除' : 'Confirm Delete';
|
||||
case 'deleteConfirmContent':
|
||||
return zh ? '确定要删除该案例吗?此操作无法撤销。' : 'Are you sure you want to delete this case?';
|
||||
case 'deleteConfirmOk':
|
||||
return zh ? '删除' : 'Delete';
|
||||
case 'errTitleRequired':
|
||||
return zh ? '请输入案例或产品标题' : 'Please enter a title';
|
||||
case 'errTitleTooLong':
|
||||
return zh ? '标题长度不能超过 150 个字符' : 'Title must be 150 characters or fewer';
|
||||
case 'errLoadFailed':
|
||||
return zh ? '加载案例列表失败' : 'Failed to load cases';
|
||||
case 'errSaveFailed':
|
||||
return zh ? '保存案例失败' : 'Failed to save case';
|
||||
case 'errDeleteFailed':
|
||||
return zh ? '删除案例失败' : 'Failed to delete case';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -14,10 +14,12 @@ export type CardEditCopyKey =
|
||||
| 'name'
|
||||
| 'jobTitle'
|
||||
| 'company'
|
||||
| 'slogan'
|
||||
| 'phone'
|
||||
| 'email'
|
||||
| 'whatsapp'
|
||||
| 'linkedin'
|
||||
| 'timezone'
|
||||
| 'save'
|
||||
| 'loading'
|
||||
| 'success'
|
||||
@@ -27,7 +29,50 @@ export type CardEditCopyKey =
|
||||
| 'back'
|
||||
| 'avatarUpload'
|
||||
| 'avatarChange'
|
||||
| 'avatarPlaceholder';
|
||||
| 'avatarPlaceholder'
|
||||
| 'lockedHint'
|
||||
| 'moduleOrderSection'
|
||||
| 'moveUp'
|
||||
| 'moveDown';
|
||||
|
||||
/** 极速编辑页可重排模块(无服务端排序字段,仅客户端顺序) */
|
||||
export type CardEditModuleKey = 'basic' | 'social' | 'privacy';
|
||||
|
||||
export const DEFAULT_MODULE_ORDER: CardEditModuleKey[] = [
|
||||
'basic',
|
||||
'social',
|
||||
'privacy',
|
||||
];
|
||||
|
||||
const MODULE_ORDER_STORAGE_PREFIX = 'omni_card_module_order_';
|
||||
|
||||
/** 品牌套件可锁定字段(接口 lockedFields / whitelist 原样或别名) */
|
||||
export type BrandLockFieldKey =
|
||||
| 'company'
|
||||
| 'companyName'
|
||||
| 'logo'
|
||||
| 'logoUrl'
|
||||
| 'primaryColor'
|
||||
| 'avatarUrl'
|
||||
| 'name'
|
||||
| 'title'
|
||||
| 'slogan'
|
||||
| 'phone'
|
||||
| 'email'
|
||||
| 'whatsapp'
|
||||
| 'linkedin'
|
||||
| 'telegram'
|
||||
| 'twitter'
|
||||
| 'wechat';
|
||||
|
||||
export type BrandKitLockSource = {
|
||||
logoUrl?: string;
|
||||
primaryColor?: string;
|
||||
lockedFields?: string[] | null;
|
||||
whitelist?: string[] | null;
|
||||
editableFields?: string[] | null;
|
||||
fieldWhitelist?: string[] | null;
|
||||
};
|
||||
|
||||
export type CardEditForm = {
|
||||
id?: string | number | null;
|
||||
@@ -43,6 +88,8 @@ export type CardEditForm = {
|
||||
telegram?: string;
|
||||
twitter?: string;
|
||||
wechat?: string;
|
||||
timezone?: string;
|
||||
lockedFields?: string[] | null;
|
||||
};
|
||||
|
||||
export type CardUpdatePayload = {
|
||||
@@ -59,6 +106,7 @@ export type CardUpdatePayload = {
|
||||
telegram?: string;
|
||||
twitter?: string;
|
||||
wechat?: string;
|
||||
timezone?: string;
|
||||
};
|
||||
|
||||
export type LoadCardApi = {
|
||||
@@ -79,6 +127,7 @@ export type SaveCardApi = {
|
||||
|
||||
export type SaveCardResult = {
|
||||
ok: boolean;
|
||||
code?: number;
|
||||
card: CardProfile | null;
|
||||
errorMessage: string;
|
||||
};
|
||||
@@ -93,11 +142,15 @@ export type CardEditState = {
|
||||
name: string;
|
||||
title: string;
|
||||
company: string;
|
||||
slogan: string;
|
||||
avatarUrl: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
whatsapp: string;
|
||||
linkedin: string;
|
||||
timezone: string;
|
||||
moduleOrder: string[];
|
||||
lockedFields: string[];
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
empty: boolean;
|
||||
@@ -149,12 +202,18 @@ export function resolveCardEditText(key: CardEditCopyKey): string {
|
||||
if (key === 'company') {
|
||||
return zh ? '公司' : 'Company';
|
||||
}
|
||||
if (key === 'slogan') {
|
||||
return zh ? '口号' : 'Slogan';
|
||||
}
|
||||
if (key === 'phone') {
|
||||
return zh ? '电话' : 'Phone';
|
||||
}
|
||||
if (key === 'email') {
|
||||
return zh ? '邮箱' : 'Email';
|
||||
}
|
||||
if (key === 'timezone') {
|
||||
return zh ? '时区' : 'Timezone';
|
||||
}
|
||||
if (key === 'missingId') {
|
||||
return zh ? '缺少名片编号' : 'Card ID is missing';
|
||||
}
|
||||
@@ -170,9 +229,121 @@ export function resolveCardEditText(key: CardEditCopyKey): string {
|
||||
if (key === 'avatarPlaceholder') {
|
||||
return zh ? '上传头像' : 'Upload avatar';
|
||||
}
|
||||
if (key === 'lockedHint') {
|
||||
return zh ? '品牌规范已锁定,不可修改' : 'Locked by brand kit';
|
||||
}
|
||||
if (key === 'moduleOrderSection') {
|
||||
return zh ? '模块顺序' : 'Module order';
|
||||
}
|
||||
if (key === 'moveUp') {
|
||||
return zh ? '上移' : 'Move up';
|
||||
}
|
||||
if (key === 'moveDown') {
|
||||
return zh ? '下移' : 'Move down';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function isKnownModuleKey(value: string): boolean {
|
||||
return (
|
||||
value === 'basic' || value === 'social' || value === 'privacy'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化模块顺序:去重、仅保留已知键,缺项按默认补齐
|
||||
*/
|
||||
export function normalizeModuleOrder(raw: unknown): string[] {
|
||||
const seen: Record<string, boolean> = {};
|
||||
const out: string[] = [];
|
||||
if (Array.isArray(raw)) {
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const item = raw[i];
|
||||
if (typeof item !== 'string') {
|
||||
continue;
|
||||
}
|
||||
const key = item.trim();
|
||||
if (!isKnownModuleKey(key) || seen[key] === true) {
|
||||
continue;
|
||||
}
|
||||
seen[key] = true;
|
||||
out.push(key);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < DEFAULT_MODULE_ORDER.length; i++) {
|
||||
const key = DEFAULT_MODULE_ORDER[i];
|
||||
if (seen[key] !== true) {
|
||||
out.push(key);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上下移动模块。越界时原样返回。
|
||||
*/
|
||||
export function moveModuleInOrder(
|
||||
order: string[],
|
||||
index: number,
|
||||
delta: number
|
||||
): string[] {
|
||||
const normalized = normalizeModuleOrder(order);
|
||||
const target = index + delta;
|
||||
if (index < 0 || index >= normalized.length) {
|
||||
return normalized;
|
||||
}
|
||||
if (target < 0 || target >= normalized.length) {
|
||||
return normalized;
|
||||
}
|
||||
const next: string[] = [];
|
||||
for (let i = 0; i < normalized.length; i++) {
|
||||
next.push(normalized[i]);
|
||||
}
|
||||
const tmp = next[index];
|
||||
next[index] = next[target];
|
||||
next[target] = tmp;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function moduleOrderStorageKey(cardId: string | number): string {
|
||||
return MODULE_ORDER_STORAGE_PREFIX + String(cardId);
|
||||
}
|
||||
|
||||
export function readStoredModuleOrder(
|
||||
cardId: string | number | null | undefined
|
||||
): string[] {
|
||||
if (cardId == null) {
|
||||
return normalizeModuleOrder(null);
|
||||
}
|
||||
try {
|
||||
if (typeof uni === 'undefined' || uni.getStorageSync == null) {
|
||||
return normalizeModuleOrder(null);
|
||||
}
|
||||
const cached = uni.getStorageSync(moduleOrderStorageKey(cardId));
|
||||
return normalizeModuleOrder(cached);
|
||||
} catch (_e) {
|
||||
return normalizeModuleOrder(null);
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredModuleOrder(
|
||||
cardId: string | number | null | undefined,
|
||||
order: string[]
|
||||
): void {
|
||||
if (cardId == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (typeof uni === 'undefined' || uni.setStorageSync == null) {
|
||||
return;
|
||||
}
|
||||
uni.setStorageSync(
|
||||
moduleOrderStorageKey(cardId),
|
||||
normalizeModuleOrder(order)
|
||||
);
|
||||
} catch (_e) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析为正整数。非正整数返回 null
|
||||
*/
|
||||
@@ -212,6 +383,153 @@ function pickFilled(value: string | null | undefined): string | null {
|
||||
return text;
|
||||
}
|
||||
|
||||
/** 品牌管控字段:whitelist 模式下未列入则锁定 */
|
||||
const BRAND_CONTROL_FIELDS: string[] = ['company', 'logoUrl', 'primaryColor'];
|
||||
|
||||
function normalizeLockToken(raw: string): string {
|
||||
return String(raw).trim();
|
||||
}
|
||||
|
||||
function fieldAliasGroup(field: string): string[] {
|
||||
const key = normalizeLockToken(field);
|
||||
if (key === 'company' || key === 'companyName') {
|
||||
return ['company', 'companyName'];
|
||||
}
|
||||
if (key === 'logo' || key === 'logoUrl') {
|
||||
return ['logo', 'logoUrl'];
|
||||
}
|
||||
return [key];
|
||||
}
|
||||
|
||||
function listIncludesField(
|
||||
list: string[] | null | undefined,
|
||||
field: string
|
||||
): boolean {
|
||||
if (list == null || list.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const aliases = fieldAliasGroup(field);
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const item = normalizeLockToken(list[i]);
|
||||
if (item === '') {
|
||||
continue;
|
||||
}
|
||||
for (let j = 0; j < aliases.length; j++) {
|
||||
if (item === aliases[j]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] | null {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const item = value[i];
|
||||
if (typeof item === 'string') {
|
||||
const text = item.trim();
|
||||
if (text !== '') {
|
||||
out.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readWhitelist(kit: BrandKitLockSource | null | undefined): string[] | null {
|
||||
if (kit == null) {
|
||||
return null;
|
||||
}
|
||||
const fromWhitelist = readStringArray(kit.whitelist);
|
||||
if (fromWhitelist != null) {
|
||||
return fromWhitelist;
|
||||
}
|
||||
const fromEditable = readStringArray(kit.editableFields);
|
||||
if (fromEditable != null) {
|
||||
return fromEditable;
|
||||
}
|
||||
return readStringArray(kit.fieldWhitelist);
|
||||
}
|
||||
|
||||
function deriveLocksFromWhitelist(whitelist: string[]): string[] {
|
||||
const locked: string[] = [];
|
||||
for (let i = 0; i < BRAND_CONTROL_FIELDS.length; i++) {
|
||||
const field = BRAND_CONTROL_FIELDS[i];
|
||||
if (!listIncludesField(whitelist, field)) {
|
||||
locked.push(field);
|
||||
}
|
||||
}
|
||||
return locked;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从名片或品牌套件提取锁定字段。
|
||||
* 优先 lockedFields;否则用 whitelist/editableFields 反推品牌管控字段。
|
||||
* 无锁定数据返回 [],不因仅有 brandKit 资产而锁全部。
|
||||
*/
|
||||
export function extractLockedFields(
|
||||
source:
|
||||
| (CardProfile & {
|
||||
lockedFields?: string[] | null;
|
||||
brandKit?: BrandKitLockSource | null;
|
||||
})
|
||||
| BrandKitLockSource
|
||||
| null
|
||||
| undefined
|
||||
): string[] {
|
||||
if (source == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const asRecord = source as {
|
||||
lockedFields?: string[] | null;
|
||||
brandKit?: BrandKitLockSource | null;
|
||||
whitelist?: string[] | null;
|
||||
editableFields?: string[] | null;
|
||||
fieldWhitelist?: string[] | null;
|
||||
};
|
||||
|
||||
const topLocked = readStringArray(asRecord.lockedFields);
|
||||
if (topLocked != null) {
|
||||
return topLocked;
|
||||
}
|
||||
|
||||
const kit =
|
||||
asRecord.brandKit != null
|
||||
? asRecord.brandKit
|
||||
: (source as BrandKitLockSource);
|
||||
|
||||
if (kit != null) {
|
||||
const kitLocked = readStringArray(kit.lockedFields);
|
||||
if (kitLocked != null) {
|
||||
return kitLocked;
|
||||
}
|
||||
const whitelist = readWhitelist(kit);
|
||||
if (whitelist != null && whitelist.length > 0) {
|
||||
return deriveLocksFromWhitelist(whitelist);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断表单字段是否被品牌套件锁定(含 company/companyName、logo/logoUrl 别名)
|
||||
*/
|
||||
export function isFieldLocked(
|
||||
lockedFields: string[] | null | undefined,
|
||||
field: string
|
||||
): boolean {
|
||||
return listIncludesField(lockedFields, field);
|
||||
}
|
||||
|
||||
function assignFilled(
|
||||
payload: CardUpdatePayload,
|
||||
key:
|
||||
@@ -226,9 +544,14 @@ function assignFilled(
|
||||
| 'linkedin'
|
||||
| 'telegram'
|
||||
| 'twitter'
|
||||
| 'wechat',
|
||||
value: string | null | undefined
|
||||
| 'wechat'
|
||||
| 'timezone',
|
||||
value: string | null | undefined,
|
||||
lockedFields?: string[] | null
|
||||
): void {
|
||||
if (isFieldLocked(lockedFields, key)) {
|
||||
return;
|
||||
}
|
||||
const filled = pickFilled(value);
|
||||
if (filled != null) {
|
||||
payload[key] = filled;
|
||||
@@ -236,26 +559,37 @@ function assignFilled(
|
||||
}
|
||||
|
||||
/**
|
||||
* 只带已填字段与解析后的 id。缺 id 返回 null
|
||||
* 只带已填字段与解析后的 id。缺 id 返回 null。
|
||||
* 锁定字段(含 companyName→company 别名)不进入 payload。
|
||||
*/
|
||||
export function buildUpdatePayload(form: CardEditForm): CardUpdatePayload | null {
|
||||
export function buildUpdatePayload(
|
||||
form: CardEditForm,
|
||||
lockedFields?: string[] | null
|
||||
): CardUpdatePayload | null {
|
||||
const id = parseCardId(form.id);
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
const locks =
|
||||
lockedFields != null
|
||||
? lockedFields
|
||||
: form.lockedFields != null
|
||||
? form.lockedFields
|
||||
: [];
|
||||
const payload: CardUpdatePayload = { id };
|
||||
assignFilled(payload, 'name', form.name);
|
||||
assignFilled(payload, 'title', form.title);
|
||||
assignFilled(payload, 'company', form.company);
|
||||
assignFilled(payload, 'slogan', form.slogan);
|
||||
assignFilled(payload, 'avatarUrl', form.avatarUrl);
|
||||
assignFilled(payload, 'phone', form.phone);
|
||||
assignFilled(payload, 'email', form.email);
|
||||
assignFilled(payload, 'whatsapp', form.whatsapp);
|
||||
assignFilled(payload, 'linkedin', form.linkedin);
|
||||
assignFilled(payload, 'telegram', form.telegram);
|
||||
assignFilled(payload, 'twitter', form.twitter);
|
||||
assignFilled(payload, 'wechat', form.wechat);
|
||||
assignFilled(payload, 'name', form.name, locks);
|
||||
assignFilled(payload, 'title', form.title, locks);
|
||||
assignFilled(payload, 'company', form.company, locks);
|
||||
assignFilled(payload, 'slogan', form.slogan, locks);
|
||||
assignFilled(payload, 'avatarUrl', form.avatarUrl, locks);
|
||||
assignFilled(payload, 'phone', form.phone, locks);
|
||||
assignFilled(payload, 'email', form.email, locks);
|
||||
assignFilled(payload, 'whatsapp', form.whatsapp, locks);
|
||||
assignFilled(payload, 'linkedin', form.linkedin, locks);
|
||||
assignFilled(payload, 'telegram', form.telegram, locks);
|
||||
assignFilled(payload, 'twitter', form.twitter, locks);
|
||||
assignFilled(payload, 'wechat', form.wechat, locks);
|
||||
assignFilled(payload, 'timezone', form.timezone, locks);
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -310,16 +644,19 @@ export async function loadCard(
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存名片。可注入 updateOwnerCard。缺 id 不请求
|
||||
* 保存名片。可注入 updateOwnerCard。缺 id 不请求。
|
||||
* lockedFields 可显式传入,否则用 form.lockedFields。
|
||||
*/
|
||||
export async function saveCard(
|
||||
form: CardEditForm,
|
||||
api?: SaveCardApi
|
||||
api?: SaveCardApi,
|
||||
lockedFields?: string[] | null
|
||||
): Promise<SaveCardResult> {
|
||||
const payload = buildUpdatePayload(form);
|
||||
const payload = buildUpdatePayload(form, lockedFields);
|
||||
if (payload == null) {
|
||||
return {
|
||||
ok: false,
|
||||
code: undefined,
|
||||
card: null,
|
||||
errorMessage: '',
|
||||
};
|
||||
@@ -334,12 +671,15 @@ export async function saveCard(
|
||||
const card = await update(asUpdatePayload(payload));
|
||||
return {
|
||||
ok: true,
|
||||
// Cool-Admin:仅 code === 1000 视为更新成功(http 层已解包)
|
||||
code: 1000,
|
||||
card,
|
||||
errorMessage: '',
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
code: undefined,
|
||||
card: null,
|
||||
errorMessage: readErrorMessage(err),
|
||||
};
|
||||
@@ -353,11 +693,15 @@ export function createCardEditState(): CardEditState {
|
||||
name: '',
|
||||
title: '',
|
||||
company: '',
|
||||
slogan: '',
|
||||
avatarUrl: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
whatsapp: '',
|
||||
linkedin: '',
|
||||
timezone: '',
|
||||
moduleOrder: normalizeModuleOrder(null),
|
||||
lockedFields: [],
|
||||
loading: false,
|
||||
saving: false,
|
||||
empty: false,
|
||||
@@ -382,6 +726,9 @@ export function applyCardEditOptions(
|
||||
state.cardId = parsed;
|
||||
state.missingId = parsed == null;
|
||||
state.empty = parsed == null;
|
||||
if (parsed != null) {
|
||||
state.moduleOrder = readStoredModuleOrder(parsed);
|
||||
}
|
||||
return parsed != null;
|
||||
}
|
||||
|
||||
@@ -402,11 +749,19 @@ function applyCardToState(state: CardEditState, card: CardProfile): void {
|
||||
state.name = card.name != null ? String(card.name) : '';
|
||||
state.title = readOptionalText(card.title);
|
||||
state.company = readOptionalText(card.company);
|
||||
state.slogan = readOptionalText(card.slogan);
|
||||
state.avatarUrl = readOptionalText(card.avatarUrl);
|
||||
state.phone = readOptionalText(card.phone);
|
||||
state.email = readOptionalText(card.email);
|
||||
state.whatsapp = readOptionalText(card.whatsapp);
|
||||
state.linkedin = readOptionalText(card.linkedin);
|
||||
state.timezone = readOptionalText(card.timezone);
|
||||
state.lockedFields = extractLockedFields(
|
||||
card as CardProfile & {
|
||||
lockedFields?: string[] | null;
|
||||
brandKit?: BrandKitLockSource | null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function applyLoadResult(
|
||||
@@ -419,6 +774,7 @@ export function applyLoadResult(
|
||||
state.missingId = false;
|
||||
} else {
|
||||
state.empty = true;
|
||||
state.lockedFields = [];
|
||||
}
|
||||
state.errorMessage = result.errorMessage;
|
||||
}
|
||||
@@ -429,11 +785,14 @@ export function formFromState(state: CardEditState): CardEditForm {
|
||||
name: state.name,
|
||||
title: state.title,
|
||||
company: state.company,
|
||||
slogan: state.slogan,
|
||||
avatarUrl: state.avatarUrl,
|
||||
phone: state.phone,
|
||||
email: state.email,
|
||||
whatsapp: state.whatsapp,
|
||||
linkedin: state.linkedin,
|
||||
timezone: state.timezone,
|
||||
lockedFields: state.lockedFields,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -475,9 +834,15 @@ export async function submitCardEdit(
|
||||
}
|
||||
state.saving = true;
|
||||
state.errorMessage = '';
|
||||
const result = await saveCard(formFromState(state), api);
|
||||
if (result.ok && result.card != null) {
|
||||
const result = await saveCard(
|
||||
formFromState(state),
|
||||
api,
|
||||
state.lockedFields
|
||||
);
|
||||
// 仅接口 code === 1000 视为保存成功
|
||||
if (result.ok && result.code === 1000 && result.card != null) {
|
||||
applyCardToState(state, result.card);
|
||||
writeStoredModuleOrder(state.cardId, state.moduleOrder);
|
||||
state.saving = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CardProfile,
|
||||
CardStatus,
|
||||
OwnerPageData,
|
||||
OwnerPagePagination,
|
||||
OwnerPageQuery,
|
||||
} from '../../../common/types.uts';
|
||||
import { fetchOwnerCardPage } from '../../../common/api.uts';
|
||||
@@ -36,7 +37,17 @@ export type CardHomeCopyKey =
|
||||
| 'statusDraft'
|
||||
| 'statusPublished'
|
||||
| 'statusArchived'
|
||||
| 'quotaExceeded';
|
||||
| 'quotaExceeded'
|
||||
| 'scanGuide'
|
||||
| 'linkLabel'
|
||||
| 'copy'
|
||||
| 'emptyDesc'
|
||||
| 'cases'
|
||||
| 'articles'
|
||||
| 'linkEmpty'
|
||||
| 'switchTitle'
|
||||
| 'quotaBadge'
|
||||
| 'emptyHeading';
|
||||
|
||||
export type LoadCardsQuery = {
|
||||
page: number;
|
||||
@@ -52,11 +63,7 @@ export type LoadCardsApi = {
|
||||
|
||||
export type LoadCardsResult = {
|
||||
list: CardHomeItem[];
|
||||
pagination: {
|
||||
page: number;
|
||||
size: number;
|
||||
total: number;
|
||||
};
|
||||
pagination: OwnerPagePagination;
|
||||
empty: boolean;
|
||||
errorMessage: string;
|
||||
};
|
||||
@@ -105,7 +112,7 @@ export function resolveCardHomeText(key: CardHomeCopyKey): string {
|
||||
|
||||
const zh = isZhLocale();
|
||||
if (key === 'poster') {
|
||||
return zh ? '分享海报' : 'Share poster';
|
||||
return zh ? '出示名片' : 'Show card';
|
||||
}
|
||||
if (key === 'title') {
|
||||
return zh ? '我的名片' : 'My cards';
|
||||
@@ -131,6 +138,36 @@ export function resolveCardHomeText(key: CardHomeCopyKey): string {
|
||||
if (key === 'quotaExceeded') {
|
||||
return zh ? '名片配额已满,升级后可继续创建' : 'Card quota is full. Upgrade to create more.';
|
||||
}
|
||||
if (key === 'scanGuide') {
|
||||
return zh ? '扫码即可保存这张名片' : 'Scan to save this card';
|
||||
}
|
||||
if (key === 'linkLabel') {
|
||||
return zh ? '名片链接' : 'Card link';
|
||||
}
|
||||
if (key === 'copy') {
|
||||
return zh ? '复制' : 'Copy';
|
||||
}
|
||||
if (key === 'emptyDesc') {
|
||||
return zh ? '创建第一张名片后,就可以在这里出示' : 'Create a card, then show it here';
|
||||
}
|
||||
if (key === 'cases') {
|
||||
return zh ? '相册' : 'Gallery';
|
||||
}
|
||||
if (key === 'articles') {
|
||||
return zh ? '动态' : 'Posts';
|
||||
}
|
||||
if (key === 'linkEmpty') {
|
||||
return zh ? '暂无链接' : 'No link yet';
|
||||
}
|
||||
if (key === 'switchTitle') {
|
||||
return zh ? '选择名片' : 'Choose a card';
|
||||
}
|
||||
if (key === 'quotaBadge') {
|
||||
return zh ? '配额已满' : 'Quota full';
|
||||
}
|
||||
if (key === 'emptyHeading') {
|
||||
return zh ? '还没有名片' : 'No cards yet';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* OmniCard AI (CRMmp) - 名片主参展分享码海报业务驱动层
|
||||
* 解析 slug / id,拉取公开短链二维码,复制链接
|
||||
* 解析 slug / id,拉取公开短链二维码,复制链接,保存海报到相册
|
||||
*/
|
||||
|
||||
import { fetchCardQrcodeSvg } from '../../../common/api.uts';
|
||||
@@ -47,7 +47,10 @@ export type QrcodeCopyKey =
|
||||
| 'empty'
|
||||
| 'error'
|
||||
| 'loading'
|
||||
| 'title';
|
||||
| 'title'
|
||||
| 'saveSuccess'
|
||||
| 'saveFailed'
|
||||
| 'saveUnavailable';
|
||||
|
||||
const QRCODE_COPY: Record<OmniLocale, Record<QrcodeCopyKey, string>> = {
|
||||
'zh-CN': {
|
||||
@@ -58,6 +61,9 @@ const QRCODE_COPY: Record<OmniLocale, Record<QrcodeCopyKey, string>> = {
|
||||
empty: '暂无数据',
|
||||
error: '出错了',
|
||||
loading: '加载中...',
|
||||
saveSuccess: '已保存到相册',
|
||||
saveFailed: '保存失败',
|
||||
saveUnavailable: '暂无可保存的图片',
|
||||
},
|
||||
'en-US': {
|
||||
title: 'Share Card',
|
||||
@@ -67,6 +73,9 @@ const QRCODE_COPY: Record<OmniLocale, Record<QrcodeCopyKey, string>> = {
|
||||
empty: 'Nothing here yet',
|
||||
error: 'Something went wrong',
|
||||
loading: 'Loading...',
|
||||
saveSuccess: 'Saved to Photos',
|
||||
saveFailed: 'Could not save',
|
||||
saveUnavailable: 'No image to save',
|
||||
},
|
||||
'ar-SA': {
|
||||
title: 'مشاركة البطاقة',
|
||||
@@ -76,6 +85,9 @@ const QRCODE_COPY: Record<OmniLocale, Record<QrcodeCopyKey, string>> = {
|
||||
empty: 'لا توجد بيانات بعد',
|
||||
error: 'حدث خطأ',
|
||||
loading: 'جار التحميل...',
|
||||
saveSuccess: 'تم الحفظ في الصور',
|
||||
saveFailed: 'تعذر الحفظ',
|
||||
saveUnavailable: 'لا توجد صورة للحفظ',
|
||||
},
|
||||
'es-ES': {
|
||||
title: 'Compartir tarjeta',
|
||||
@@ -85,6 +97,9 @@ const QRCODE_COPY: Record<OmniLocale, Record<QrcodeCopyKey, string>> = {
|
||||
empty: 'Aun no hay datos',
|
||||
error: 'Algo salio mal',
|
||||
loading: 'Cargando...',
|
||||
saveSuccess: 'Guardado en Fotos',
|
||||
saveFailed: 'No se pudo guardar',
|
||||
saveUnavailable: 'No hay imagen para guardar',
|
||||
},
|
||||
'de-DE': {
|
||||
title: 'Karte teilen',
|
||||
@@ -94,6 +109,9 @@ const QRCODE_COPY: Record<OmniLocale, Record<QrcodeCopyKey, string>> = {
|
||||
empty: 'Noch keine Daten',
|
||||
error: 'Etwas ist schiefgelaufen',
|
||||
loading: 'Wird geladen...',
|
||||
saveSuccess: 'In Fotos gespeichert',
|
||||
saveFailed: 'Speichern fehlgeschlagen',
|
||||
saveUnavailable: 'Kein Bild zum Speichern',
|
||||
},
|
||||
'fr-FR': {
|
||||
title: 'Partager la carte',
|
||||
@@ -103,6 +121,9 @@ const QRCODE_COPY: Record<OmniLocale, Record<QrcodeCopyKey, string>> = {
|
||||
empty: 'Aucune donnee',
|
||||
error: 'Une erreur est survenue',
|
||||
loading: 'Chargement...',
|
||||
saveSuccess: 'Enregistre dans Photos',
|
||||
saveFailed: 'Echec de l enregistrement',
|
||||
saveUnavailable: 'Aucune image a enregistrer',
|
||||
},
|
||||
'ja-JP': {
|
||||
title: '名刺をシェア',
|
||||
@@ -112,6 +133,9 @@ const QRCODE_COPY: Record<OmniLocale, Record<QrcodeCopyKey, string>> = {
|
||||
empty: 'データがありません',
|
||||
error: 'エラーが発生しました',
|
||||
loading: '読み込み中...',
|
||||
saveSuccess: '写真に保存しました',
|
||||
saveFailed: '保存に失敗しました',
|
||||
saveUnavailable: '保存できる画像がありません',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -264,3 +288,106 @@ export function copyPosterUrl(url: string | null | undefined): boolean {
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅本地路径 / 网络图 / data:image 可入库;纯 SVG 标记不可直接进相册
|
||||
*/
|
||||
export function isSaveablePosterImage(source: string | null | undefined): boolean {
|
||||
const text = trimText(source);
|
||||
if (text === '') {
|
||||
return false;
|
||||
}
|
||||
const lower = text.toLowerCase();
|
||||
if (lower.indexOf('<svg') >= 0 || lower.indexOf('<?xml') === 0) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
lower.indexOf('http://') === 0 ||
|
||||
lower.indexOf('https://') === 0 ||
|
||||
lower.indexOf('data:image') === 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function toastSaveFailed(unavailable: boolean): void {
|
||||
if (unavailable) {
|
||||
uni.showToast({
|
||||
title: resolveQrcodeText('saveUnavailable'),
|
||||
icon: 'none',
|
||||
});
|
||||
return;
|
||||
}
|
||||
uni.showToast({
|
||||
title: resolveQrcodeText('saveFailed'),
|
||||
icon: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
function invokeSaveImageToPhotosAlbum(filePath: string): Promise<boolean> {
|
||||
return new Promise<boolean>(resolve => {
|
||||
uni.saveImageToPhotosAlbum({
|
||||
filePath: filePath,
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: resolveQrcodeText('saveSuccess'),
|
||||
icon: 'none',
|
||||
});
|
||||
resolve(true);
|
||||
},
|
||||
fail: (_err: unknown) => {
|
||||
toastSaveFailed(false);
|
||||
resolve(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function downloadRemoteImage(url: string): Promise<string> {
|
||||
return new Promise<string>(resolve => {
|
||||
uni.downloadFile({
|
||||
url: url,
|
||||
success: (res: { statusCode?: number; tempFilePath?: string }) => {
|
||||
const path =
|
||||
res != null && res.tempFilePath != null
|
||||
? String(res.tempFilePath).trim()
|
||||
: '';
|
||||
const code = res != null && res.statusCode != null ? res.statusCode : 0;
|
||||
if (code === 200 && path !== '') {
|
||||
resolve(path);
|
||||
return;
|
||||
}
|
||||
resolve('');
|
||||
},
|
||||
fail: (_err: unknown) => {
|
||||
resolve('');
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存海报到相册:必须等 uni.saveImageToPhotosAlbum 成功才 toast 已保存
|
||||
*/
|
||||
export async function savePosterToAlbum(
|
||||
imageSource: string | null | undefined
|
||||
): Promise<boolean> {
|
||||
const source = trimText(imageSource);
|
||||
if (source === '' || !isSaveablePosterImage(source)) {
|
||||
toastSaveFailed(true);
|
||||
return false;
|
||||
}
|
||||
|
||||
let filePath = source;
|
||||
const lower = source.toLowerCase();
|
||||
if (lower.indexOf('http://') === 0 || lower.indexOf('https://') === 0) {
|
||||
filePath = await downloadRemoteImage(source);
|
||||
if (filePath === '') {
|
||||
toastSaveFailed(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return invokeSaveImageToPhotosAlbum(filePath);
|
||||
}
|
||||
|
||||
@@ -269,6 +269,7 @@ import { getCurrentLocale, getOmniText, isRtl } from '../../common/locale.uts';
|
||||
import { utsLightColors, utsRadius, utsSpacing, utsTypography } from '../../common/tokens.uts';
|
||||
import {
|
||||
applyLeadDetailOptions,
|
||||
callLeadPhone,
|
||||
createLeadDetailState,
|
||||
formatTrackDepth,
|
||||
formatTrackPlace,
|
||||
@@ -276,6 +277,7 @@ import {
|
||||
formatWhatsAppUrl,
|
||||
generateAiIcebreaker,
|
||||
goBackFromLeadDetail,
|
||||
openLeadWhatsApp,
|
||||
parseNoteAttachments,
|
||||
refreshLeadDetail,
|
||||
resolveLeadDetailText,
|
||||
@@ -285,6 +287,7 @@ import {
|
||||
updateLeadStage,
|
||||
LeadDetailCopyKey,
|
||||
LeadDetailPageOptions,
|
||||
ParsedNoteResult,
|
||||
} from './useLeadDetail.uts';
|
||||
import { parseLeadStatus } from './useLeads.uts';
|
||||
import { RadarTrack } from '../../common/types.uts';
|
||||
@@ -449,12 +452,7 @@ function handleBack(): void {
|
||||
}
|
||||
|
||||
function handleCallPhone(): void {
|
||||
if (phoneText.value === '') {
|
||||
return;
|
||||
}
|
||||
uni.makePhoneCall({
|
||||
phoneNumber: phoneText.value,
|
||||
});
|
||||
callLeadPhone(state.phone);
|
||||
}
|
||||
|
||||
function handleCopyEmail(): void {
|
||||
@@ -468,20 +466,7 @@ function handleCopyEmail(): void {
|
||||
}
|
||||
|
||||
function handleWhatsApp(): void {
|
||||
const waUrl = formatWhatsAppUrl(state.phone);
|
||||
if (waUrl === '') {
|
||||
return;
|
||||
}
|
||||
// #ifdef H5
|
||||
if (typeof window !== 'undefined') {
|
||||
window.open(waUrl, '_blank');
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
uni.setClipboardData({
|
||||
data: waUrl,
|
||||
showToast: true,
|
||||
});
|
||||
openLeadWhatsApp(state.phone);
|
||||
}
|
||||
|
||||
async function handleSaveContact(): Promise<void> {
|
||||
@@ -525,7 +510,24 @@ function handleCopyScript(): void {
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
await submitLeadNote(state);
|
||||
const next = state.nextNote.trim();
|
||||
const hasAttachments =
|
||||
state.attachments != null && state.attachments.length > 0;
|
||||
if (next === '' && !hasAttachments) {
|
||||
uni.showToast({
|
||||
title: resolveLeadDetailText('notesRequiredToast'),
|
||||
icon: 'none',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 仅接口 code === 1000(submitLeadNote 已校验)时提示备忘已保存并清空输入
|
||||
const ok = await submitLeadNote(state);
|
||||
if (ok) {
|
||||
uni.showToast({
|
||||
title: resolveLeadDetailText('noteSavedToast'),
|
||||
icon: 'none',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const parsedNotes = computed((): ParsedNoteResult => {
|
||||
|
||||
@@ -236,7 +236,7 @@ const hotUnreadText = computed((): string => {
|
||||
});
|
||||
|
||||
const statsLabel = computed((): string => {
|
||||
return getCurrentLocale() === 'zh-CN' ? '统计' : 'Stats';
|
||||
return resolveLeadsText('stats');
|
||||
});
|
||||
|
||||
const leadsCountSummary = computed((): string => {
|
||||
@@ -315,8 +315,9 @@ function handleCopyEmail(email: string | undefined): void {
|
||||
});
|
||||
}
|
||||
|
||||
function selectStatus(status: string): void {
|
||||
async function selectStatus(status: string): Promise<void> {
|
||||
applyLeadStatusFilter(state, status);
|
||||
await refreshLeads(state);
|
||||
}
|
||||
|
||||
function handleOpenLead(item: LeadEntity): void {
|
||||
|
||||
@@ -240,7 +240,7 @@ import {
|
||||
ModePageOptions,
|
||||
} from './useMode.uts';
|
||||
|
||||
type ModePageCopyKey = ModeCopyKey | 'offlineHint';
|
||||
type ModePageCopyKey = ModeCopyKey | 'offlineHint' | 'offlineUnavailable';
|
||||
|
||||
type ModeSubCopyKey =
|
||||
| 'specsTitle'
|
||||
@@ -279,7 +279,7 @@ const MODE_SUB_COPY: Record<OmniLocale, Record<ModeSubCopyKey, string>> = {
|
||||
activeScenePill: '生效中',
|
||||
pendingApplyPill: '待应用',
|
||||
securityProtocolTitle: 'RFC 6350 离线保底协议与安全锁',
|
||||
securityProtocolDesc: '弱网无网时芯片直接写入离线 vCard;挂失后云端立即锁定拒绝访问',
|
||||
securityProtocolDesc: '离线 vCard 烧录待设备能力就绪后开放;挂失后云端立即锁定拒绝访问',
|
||||
},
|
||||
'en-US': {
|
||||
specsTitle: 'Hardware & Card Binding',
|
||||
@@ -298,7 +298,7 @@ const MODE_SUB_COPY: Record<OmniLocale, Record<ModeSubCopyKey, string>> = {
|
||||
activeScenePill: 'Active',
|
||||
pendingApplyPill: 'Pending',
|
||||
securityProtocolTitle: 'RFC 6350 Offline Protocol & Security Lock',
|
||||
securityProtocolDesc: 'Offline vCard written in weak network; remote lock immediately on suspension',
|
||||
securityProtocolDesc: 'Offline vCard burn opens when device write is ready; remote lock on suspension',
|
||||
},
|
||||
'ar-SA': {
|
||||
specsTitle: 'الشريحة وربط البطاقة',
|
||||
@@ -317,7 +317,7 @@ const MODE_SUB_COPY: Record<OmniLocale, Record<ModeSubCopyKey, string>> = {
|
||||
activeScenePill: 'مفعّل',
|
||||
pendingApplyPill: 'قيد التطبيق',
|
||||
securityProtocolTitle: 'بروتوكول RFC 6350 دون اتصال وقفل الأمان',
|
||||
securityProtocolDesc: 'كتابة vCard دون اتصال في الشبكة الضعيفة وقفل فوري عند الإيقاف',
|
||||
securityProtocolDesc: 'كتابة vCard دون اتصال تُتاح عند جاهزية الجهاز؛ قفل فوري عند الإيقاف',
|
||||
},
|
||||
'es-ES': {
|
||||
specsTitle: 'Hardware y Vinculación',
|
||||
@@ -336,7 +336,7 @@ const MODE_SUB_COPY: Record<OmniLocale, Record<ModeSubCopyKey, string>> = {
|
||||
activeScenePill: 'Activo',
|
||||
pendingApplyPill: 'Pendiente',
|
||||
securityProtocolTitle: 'Protocolo RFC 6350 fuera de línea y bloqueo',
|
||||
securityProtocolDesc: 'Escritura vCard local en red débil; bloqueo inmediato al suspender',
|
||||
securityProtocolDesc: 'La escritura vCard offline se abrirá cuando el dispositivo lo permita; bloqueo al suspender',
|
||||
},
|
||||
'de-DE': {
|
||||
specsTitle: 'Hardware & Bindung',
|
||||
@@ -355,7 +355,7 @@ const MODE_SUB_COPY: Record<OmniLocale, Record<ModeSubCopyKey, string>> = {
|
||||
activeScenePill: 'Aktiv',
|
||||
pendingApplyPill: 'Ausstehend',
|
||||
securityProtocolTitle: 'RFC 6350 Offline-Protokoll & Sicherheitssperre',
|
||||
securityProtocolDesc: 'Offline-vCard bei schwachem Netz; sofortige Sperre bei Verlust',
|
||||
securityProtocolDesc: 'Offline-vCard-Schreiben erst bei Gerätefähigkeit; sofortige Sperre bei Verlust',
|
||||
},
|
||||
'fr-FR': {
|
||||
specsTitle: 'Puce et Liaison',
|
||||
@@ -374,7 +374,7 @@ const MODE_SUB_COPY: Record<OmniLocale, Record<ModeSubCopyKey, string>> = {
|
||||
activeScenePill: 'Actif',
|
||||
pendingApplyPill: 'En attente',
|
||||
securityProtocolTitle: 'Protocole hors ligne RFC 6350 et verrou de sécurité',
|
||||
securityProtocolDesc: 'Écriture vCard locale en réseau faible ; verrouillage immédiat si perdue',
|
||||
securityProtocolDesc: 'Écriture vCard hors ligne dès que l’appareil le permet ; verrouillage si perdue',
|
||||
},
|
||||
'ja-JP': {
|
||||
specsTitle: 'ハードウェアと連携',
|
||||
@@ -393,7 +393,7 @@ const MODE_SUB_COPY: Record<OmniLocale, Record<ModeSubCopyKey, string>> = {
|
||||
activeScenePill: '適用中',
|
||||
pendingApplyPill: '変更待機',
|
||||
securityProtocolTitle: 'RFC 6350 オフライン規格&セキュリティロック',
|
||||
securityProtocolDesc: '回線微弱時はオフラインvCardを書き込み、利用停止時は即時遮断',
|
||||
securityProtocolDesc: 'オフラインvCard書き込みは端末対応後に開放、利用停止時は即時遮断',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -472,31 +472,12 @@ const selectedModeSub = computed((): string => {
|
||||
});
|
||||
|
||||
function resolveOfflineHint(): string {
|
||||
const locale: OmniLocale = getCurrentLocale();
|
||||
if (locale === 'zh-CN') {
|
||||
return '离线 vCard 由系统在弱网时写入';
|
||||
}
|
||||
if (locale === 'ar-SA') {
|
||||
return 'يكتب النظام بطاقة vCard دون اتصال عند ضعف الشبكة';
|
||||
}
|
||||
if (locale === 'es-ES') {
|
||||
return 'Si la red es débil, el sistema escribe el vCard sin conexión';
|
||||
}
|
||||
if (locale === 'de-DE') {
|
||||
return 'Bei schwachem Netz schreibt das System eine Offline-vCard';
|
||||
}
|
||||
if (locale === 'fr-FR') {
|
||||
return 'En cas de réseau faible, le système écrit une vCard hors ligne';
|
||||
}
|
||||
if (locale === 'ja-JP') {
|
||||
return '回線が弱いときは、システムがオフライン vCard を書き込みます';
|
||||
}
|
||||
return 'The system writes an offline vCard when the connection is weak';
|
||||
return resolveModeText('offlineUnavailable');
|
||||
}
|
||||
|
||||
function t(key: ModePageCopyKey): string {
|
||||
if (key === 'offlineHint') {
|
||||
return resolveOfflineHint();
|
||||
if (key === 'offlineHint' || key === 'offlineUnavailable') {
|
||||
return resolveModeText('offlineUnavailable');
|
||||
}
|
||||
return resolveModeText(key);
|
||||
}
|
||||
|
||||
@@ -84,19 +84,19 @@
|
||||
<view class="meta-grid">
|
||||
<view class="meta-cell">
|
||||
<text class="meta-label">{{ t('uidLabel') }}</text>
|
||||
<text class="meta-value">{{ state.record.uid }}</text>
|
||||
<text class="meta-value">{{ state.record!.uid }}</text>
|
||||
</view>
|
||||
<view class="meta-cell">
|
||||
<text class="meta-label">{{ t('cardIdLabel') }}</text>
|
||||
<text class="meta-value">#{{ state.record.cardId || state.cardIdRaw || '-' }}</text>
|
||||
<text class="meta-value">#{{ state.record!.cardId || state.cardIdRaw || '-' }}</text>
|
||||
</view>
|
||||
<view class="meta-cell">
|
||||
<text class="meta-label">{{ extraText('hardwareSpecs') }}</text>
|
||||
<text class="meta-value">{{ state.record.batchNo || 'NTAG215-STD' }}</text>
|
||||
<text class="meta-value">{{ state.record!.batchNo || 'NTAG215-STD' }}</text>
|
||||
</view>
|
||||
<view class="meta-cell">
|
||||
<text class="meta-label">Scene Mode</text>
|
||||
<text class="meta-value uppercase-txt">{{ state.record.sceneMode || 'business' }}</text>
|
||||
<text class="meta-value uppercase-txt">{{ state.record!.sceneMode || 'business' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -190,11 +190,13 @@ import { getCurrentLocale, getOmniText, isRtl } from '../../../common/locale.uts
|
||||
import { utsLightColors, utsRadius, utsSpacing, utsTypography } from '../../../common/tokens.uts';
|
||||
import {
|
||||
applyScanOptions,
|
||||
buildNdefUri,
|
||||
createScanState,
|
||||
resolveScanText,
|
||||
resolveStatusLine,
|
||||
startScan,
|
||||
submitScan,
|
||||
writeNdefToTag,
|
||||
ScanCopyKey,
|
||||
ScanPageOptions,
|
||||
} from './useScan.uts';
|
||||
@@ -353,7 +355,9 @@ function extraText(key: ExtraCopyKey): string {
|
||||
return isZh ? '物理实体卡绑定成功' : 'NFC Card Successfully Bound';
|
||||
}
|
||||
if (key === 'boundSuccessDesc') {
|
||||
return isZh ? '名片直链已烧录至云端路由,碰一碰即可瞬间交换名片。' : 'Portal URL is routed. Tap to share instantly.';
|
||||
return isZh
|
||||
? '云端路由已绑定。芯片内容需等系统写入 API 成功后才会确认。'
|
||||
: 'Cloud routing is bound. Chip content is confirmed only after a successful system write.';
|
||||
}
|
||||
if (key === 'actionDone') {
|
||||
return isZh ? '完成' : 'Done';
|
||||
@@ -395,7 +399,22 @@ function handleActivate(): void {
|
||||
// #endif
|
||||
return;
|
||||
}
|
||||
submitScan(state);
|
||||
void runActivateAndWrite();
|
||||
}
|
||||
|
||||
async function runActivateAndWrite(): Promise<void> {
|
||||
const record = await submitScan(state);
|
||||
if (record == null) {
|
||||
return;
|
||||
}
|
||||
const cardId =
|
||||
record.cardId != null
|
||||
? record.cardId
|
||||
: state.deviceCardId != null
|
||||
? state.deviceCardId
|
||||
: state.cardIdRaw;
|
||||
const uri = buildNdefUri(cardId);
|
||||
await writeNdefToTag(uri);
|
||||
}
|
||||
|
||||
onLoad((options: ScanPageOptions) => {
|
||||
@@ -1025,7 +1044,7 @@ onLoad((options: ScanPageOptions) => {
|
||||
}
|
||||
|
||||
.primary-cta-btn:active {
|
||||
opacity: 0.88;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.primary-cta-btn-disabled {
|
||||
@@ -1043,19 +1062,16 @@ onLoad((options: ScanPageOptions) => {
|
||||
color: var(--oc-text-2, #667085);
|
||||
}
|
||||
|
||||
/* 脉冲微动画:400ms 单次,不循环 */
|
||||
/* 脉冲微动画:400ms 单次,不循环(仅 opacity,无 scale) */
|
||||
@keyframes scan-pulse {
|
||||
0% {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.92);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1065,6 +1081,10 @@ onLoad((options: ScanPageOptions) => {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.primary-cta-btn {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.scan-ring {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -1062,7 +1062,7 @@ async function handleExecuteSecurity(): Promise<void> {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-inline-end: 12px;
|
||||
background-color: #FFFFFF;
|
||||
background-color: var(--oc-surface, #FFFFFF);
|
||||
}
|
||||
|
||||
.radio-checked {
|
||||
@@ -1264,8 +1264,7 @@ async function handleExecuteSecurity(): Promise<void> {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: var(--window-bottom, 0px);
|
||||
background-color: rgba(255, 255, 255, 0.94);
|
||||
backdrop-filter: blur(12px);
|
||||
background-color: var(--oc-surface, #FFFFFF);
|
||||
border-top-width: 1px;
|
||||
border-top-color: var(--oc-border, #EBF1F7);
|
||||
padding: 12px var(--oc-space-16, 16px) calc(16px + env(safe-area-inset-bottom, 0px));
|
||||
|
||||
@@ -37,7 +37,8 @@ export type ModeCopyKey =
|
||||
| 'missingDevice'
|
||||
| 'applySuccess'
|
||||
| 'applyError'
|
||||
| 'loading';
|
||||
| 'loading'
|
||||
| 'offlineUnavailable';
|
||||
|
||||
export type ModePageOptions = {
|
||||
uid?: string;
|
||||
@@ -81,6 +82,17 @@ export type ApplySceneResult = {
|
||||
device: NFCRecord | null;
|
||||
};
|
||||
|
||||
export type OfflineWriteParams = {
|
||||
uid?: string;
|
||||
cardId?: string | number;
|
||||
vcard?: string;
|
||||
};
|
||||
|
||||
export type OfflineWriteResult = {
|
||||
ok: boolean;
|
||||
error: string;
|
||||
};
|
||||
|
||||
const MODE_COPY: Record<OmniLocale, Record<ModeCopyKey, string>> = {
|
||||
'zh-CN': {
|
||||
title: '场景模式',
|
||||
@@ -103,6 +115,7 @@ const MODE_COPY: Record<OmniLocale, Record<ModeCopyKey, string>> = {
|
||||
applySuccess: '操作成功',
|
||||
applyError: '操作失败',
|
||||
loading: '加载中...',
|
||||
offlineUnavailable: '此设备暂不能写入离线名片',
|
||||
},
|
||||
'en-US': {
|
||||
title: 'Scene mode',
|
||||
@@ -125,6 +138,7 @@ const MODE_COPY: Record<OmniLocale, Record<ModeCopyKey, string>> = {
|
||||
applySuccess: 'Done',
|
||||
applyError: 'Something went wrong',
|
||||
loading: 'Loading...',
|
||||
offlineUnavailable: 'This device cannot write offline cards yet',
|
||||
},
|
||||
'ar-SA': {
|
||||
title: 'وضع المشهد',
|
||||
@@ -147,6 +161,7 @@ const MODE_COPY: Record<OmniLocale, Record<ModeCopyKey, string>> = {
|
||||
applySuccess: 'تم',
|
||||
applyError: 'تعذّر الإكمال',
|
||||
loading: 'جارٍ التحميل...',
|
||||
offlineUnavailable: 'لا يمكن لهذا الجهاز كتابة البطاقة دون اتصال حالياً',
|
||||
},
|
||||
'es-ES': {
|
||||
title: 'Modo de escena',
|
||||
@@ -169,6 +184,7 @@ const MODE_COPY: Record<OmniLocale, Record<ModeCopyKey, string>> = {
|
||||
applySuccess: 'Listo',
|
||||
applyError: 'No se pudo completar',
|
||||
loading: 'Cargando...',
|
||||
offlineUnavailable: 'Este dispositivo aún no puede escribir tarjetas sin conexión',
|
||||
},
|
||||
'de-DE': {
|
||||
title: 'Szenenmodus',
|
||||
@@ -191,6 +207,7 @@ const MODE_COPY: Record<OmniLocale, Record<ModeCopyKey, string>> = {
|
||||
applySuccess: 'Erledigt',
|
||||
applyError: 'Vorgang fehlgeschlagen',
|
||||
loading: 'Wird geladen...',
|
||||
offlineUnavailable: 'Dieses Gerät kann Offline-Visitenkarten noch nicht schreiben',
|
||||
},
|
||||
'fr-FR': {
|
||||
title: 'Mode scène',
|
||||
@@ -213,6 +230,7 @@ const MODE_COPY: Record<OmniLocale, Record<ModeCopyKey, string>> = {
|
||||
applySuccess: 'Terminé',
|
||||
applyError: 'Échec de l’opération',
|
||||
loading: 'Chargement...',
|
||||
offlineUnavailable: 'Cet appareil ne peut pas encore écrire de cartes hors ligne',
|
||||
},
|
||||
'ja-JP': {
|
||||
title: 'シーンモード',
|
||||
@@ -235,6 +253,7 @@ const MODE_COPY: Record<OmniLocale, Record<ModeCopyKey, string>> = {
|
||||
applySuccess: '完了しました',
|
||||
applyError: '操作に失敗しました',
|
||||
loading: '読み込み中...',
|
||||
offlineUnavailable: 'この端末ではオフライン名刺を書き込めません',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -395,6 +414,33 @@ export async function applyScene(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前无真实 NDEF 写芯片能力;禁止伪造可写
|
||||
*/
|
||||
export function canWriteOfflineVCard(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 离线 vCard 烧录:无真实写芯片实现时一律失败,不 toast 成功/已烧录
|
||||
*/
|
||||
export async function writeOfflineVCard(
|
||||
_payload?: OfflineWriteParams
|
||||
): Promise<OfflineWriteResult> {
|
||||
const error = resolveModeText('offlineUnavailable');
|
||||
if (!canWriteOfflineVCard()) {
|
||||
return {
|
||||
ok: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
// 真实写芯片接入前不可达;显式失败兜底,杜绝假成功
|
||||
return {
|
||||
ok: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function createModeState(): NfcModeState {
|
||||
return {
|
||||
uid: '',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* OmniCard AI (CRMmp) - 名片主 NFC 贴卡激活业务驱动层
|
||||
* H5 无天线走手动 UID;APP startScan 为占位,调不到原生 NFC 时回退手动输入
|
||||
* H5 无天线走手动 UID;APP 经 uni.getNFCAdapter 尝试读芯片 UID,失败回退手动输入
|
||||
*/
|
||||
|
||||
import { NFCRecord } from '../../../common/types.uts';
|
||||
@@ -21,7 +21,11 @@ export type ScanCopyKey =
|
||||
| 'nfcUnavailable'
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'retry';
|
||||
| 'retry'
|
||||
| 'writeSuccess'
|
||||
| 'writeFailed'
|
||||
| 'writeUnavailable'
|
||||
| 'writeCancelled';
|
||||
|
||||
export type ScanState = {
|
||||
uid: string;
|
||||
@@ -104,6 +108,18 @@ export function resolveScanText(key: ScanCopyKey): string {
|
||||
if (key === 'retry') {
|
||||
return zh ? '重试' : 'Try again';
|
||||
}
|
||||
if (key === 'writeSuccess') {
|
||||
return zh ? '已写入' : 'Written to tag';
|
||||
}
|
||||
if (key === 'writeFailed') {
|
||||
return zh ? '写入失败' : 'Could not write';
|
||||
}
|
||||
if (key === 'writeUnavailable') {
|
||||
return zh ? '暂无可写内容' : 'Nothing to write';
|
||||
}
|
||||
if (key === 'writeCancelled') {
|
||||
return zh ? '已取消写入' : 'Write cancelled';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -214,12 +230,130 @@ export function applyScanOptions(state: ScanState, options: ScanPageOptions | nu
|
||||
}
|
||||
|
||||
/**
|
||||
* APP 原生贴卡占位。调不到天线时回退手动输入,禁止伪造成功
|
||||
* NFC 标签 id(ArrayBuffer)转大写 Hex UID;空则返回空串
|
||||
*/
|
||||
function nfcIdToHex(id: ArrayBuffer | null | undefined): string {
|
||||
if (id == null) {
|
||||
return '';
|
||||
}
|
||||
const bytes = new Uint8Array(id);
|
||||
let hex = '';
|
||||
let i = 0;
|
||||
const len = bytes.length;
|
||||
while (i < len) {
|
||||
let part = bytes[i].toString(16);
|
||||
if (part.length < 2) {
|
||||
part = '0' + part;
|
||||
}
|
||||
hex += part;
|
||||
i++;
|
||||
}
|
||||
return hex.toUpperCase();
|
||||
}
|
||||
|
||||
type NfcFail = {
|
||||
errMsg?: string;
|
||||
};
|
||||
|
||||
type NfcIdResult = {
|
||||
id?: ArrayBuffer | null;
|
||||
};
|
||||
|
||||
type NfcCallbackOptions = {
|
||||
success?: () => void;
|
||||
fail?: (err: NfcFail) => void;
|
||||
};
|
||||
|
||||
type NdefWriteOptions = {
|
||||
uris?: string[];
|
||||
success?: () => void;
|
||||
fail?: (err: NfcFail) => void;
|
||||
};
|
||||
|
||||
type NdefWriter = {
|
||||
writeNdefMessage: (options: NdefWriteOptions) => void;
|
||||
connect?: (options: NfcCallbackOptions) => void;
|
||||
};
|
||||
|
||||
type NfcAdapter = {
|
||||
getNdef: () => NdefWriter | null;
|
||||
startDiscovery: (options: NfcCallbackOptions) => void;
|
||||
onDiscovered: (callback: (res: NfcIdResult) => void) => void;
|
||||
};
|
||||
|
||||
type UniWithNfc = {
|
||||
getNFCAdapter?: () => NfcAdapter | null;
|
||||
};
|
||||
|
||||
function readNfcAdapter(): NfcAdapter | null {
|
||||
const runtime = uni as UniWithNfc;
|
||||
if (runtime.getNFCAdapter == null) {
|
||||
return null;
|
||||
}
|
||||
return runtime.getNFCAdapter();
|
||||
}
|
||||
|
||||
/**
|
||||
* APP:调用官方风格 uni.getNFCAdapter + startDiscovery / onDiscovered 尝试读 UID。
|
||||
* 同步路径仅在已读到合法 UID 时返回非空;读不到、API 不存在或抛错返回空串,禁止伪造。
|
||||
*/
|
||||
function tryReadUidFromAntenna(state: ScanState): string {
|
||||
// #ifdef APP
|
||||
try {
|
||||
const adapter = readNfcAdapter();
|
||||
if (adapter == null) {
|
||||
return '';
|
||||
}
|
||||
let syncUid = '';
|
||||
adapter.startDiscovery({
|
||||
success: () => {
|
||||
adapter.onDiscovered((res: NfcIdResult) => {
|
||||
const uid = parseUid(nfcIdToHex(res.id));
|
||||
if (uid === '') {
|
||||
return;
|
||||
}
|
||||
// 异步贴卡成功:写入真实 UID,不伪造
|
||||
state.uid = uid;
|
||||
state.empty = false;
|
||||
state.scanning = false;
|
||||
state.manualInput = false;
|
||||
state.errorMessage = '';
|
||||
syncUid = uid;
|
||||
});
|
||||
},
|
||||
fail: (_err: { errMsg?: string }) => {
|
||||
// 由 startScan 统一走手动输入回退
|
||||
},
|
||||
});
|
||||
return parseUid(syncUid);
|
||||
} catch (_err) {
|
||||
return '';
|
||||
}
|
||||
// #endif
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* APP 尝试官方 NFC Adapter 读 UID;仅合法 UID 才成功。否则回退手动输入,禁止伪造成功
|
||||
*/
|
||||
export function startScan(state: ScanState): boolean {
|
||||
// #ifdef APP
|
||||
// Native NFC antenna is not wired in this skeleton.
|
||||
try {
|
||||
const uid = tryReadUidFromAntenna(state);
|
||||
if (parseUid(uid) !== '') {
|
||||
state.uid = parseUid(uid);
|
||||
state.scanning = false;
|
||||
state.manualInput = false;
|
||||
state.empty = false;
|
||||
state.errorMessage = '';
|
||||
state.record = null;
|
||||
return true;
|
||||
}
|
||||
} catch (_err) {
|
||||
// 落入下方失败回退
|
||||
}
|
||||
// #endif
|
||||
|
||||
state.scanning = false;
|
||||
state.manualInput = true;
|
||||
state.record = null;
|
||||
@@ -285,3 +419,123 @@ export async function submitScan(state: ScanState): Promise<NFCRecord | null> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 可写 NDEF URI:有合法 cardId 才生成;否则空串表示无可写内容
|
||||
*/
|
||||
export function buildNdefUri(cardId: string | number | null | undefined): string {
|
||||
const id = parseCardId(cardId);
|
||||
if (id == null) {
|
||||
return '';
|
||||
}
|
||||
return `https://omnicard.ai/c/${id}`;
|
||||
}
|
||||
|
||||
function toastWriteUnavailable(): void {
|
||||
if (typeof uni === 'undefined' || typeof uni.showToast !== 'function') {
|
||||
return;
|
||||
}
|
||||
uni.showToast({
|
||||
title: resolveScanText('writeUnavailable'),
|
||||
icon: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
function toastWriteFailed(): void {
|
||||
if (typeof uni === 'undefined' || typeof uni.showToast !== 'function') {
|
||||
return;
|
||||
}
|
||||
uni.showToast({
|
||||
title: resolveScanText('writeFailed'),
|
||||
icon: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
function toastWriteCancelled(): void {
|
||||
if (typeof uni === 'undefined' || typeof uni.showToast !== 'function') {
|
||||
return;
|
||||
}
|
||||
uni.showToast({
|
||||
title: resolveScanText('writeCancelled'),
|
||||
icon: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
function isWriteCancelledError(err: { errMsg?: string } | null | undefined): boolean {
|
||||
if (err == null || err.errMsg == null) {
|
||||
return false;
|
||||
}
|
||||
const msg = String(err.errMsg).toLowerCase();
|
||||
return msg.indexOf('cancel') >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 贴卡写入 NDEF:仅系统写入接口的 success 回调才 toast 已写入。
|
||||
* 天线未写 / 取消 / 失败 / 无可写内容均不得报写入成功。
|
||||
*/
|
||||
export function writeNdefToTag(uri: string | null | undefined): Promise<boolean> {
|
||||
const payload = uri == null ? '' : String(uri).trim();
|
||||
if (payload === '') {
|
||||
toastWriteUnavailable();
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
return new Promise<boolean>(resolve => {
|
||||
try {
|
||||
const adapter = readNfcAdapter();
|
||||
if (adapter == null) {
|
||||
toastWriteFailed();
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const ndef = adapter.getNdef();
|
||||
if (ndef == null) {
|
||||
toastWriteFailed();
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const runWrite = (): void => {
|
||||
ndef.writeNdefMessage({
|
||||
uris: [payload],
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: resolveScanText('writeSuccess'),
|
||||
icon: 'none',
|
||||
});
|
||||
resolve(true);
|
||||
},
|
||||
fail: (err: { errMsg?: string }) => {
|
||||
if (isWriteCancelledError(err)) {
|
||||
toastWriteCancelled();
|
||||
} else {
|
||||
toastWriteFailed();
|
||||
}
|
||||
resolve(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (typeof ndef.connect === 'function') {
|
||||
ndef.connect({
|
||||
success: () => {
|
||||
runWrite();
|
||||
},
|
||||
fail: (err: { errMsg?: string }) => {
|
||||
if (isWriteCancelledError(err)) {
|
||||
toastWriteCancelled();
|
||||
} else {
|
||||
toastWriteFailed();
|
||||
}
|
||||
resolve(false);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
runWrite();
|
||||
} catch (_err) {
|
||||
toastWriteFailed();
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -282,8 +282,12 @@ export async function unfreezeDevice(
|
||||
/**
|
||||
* 国际化多语言文案字典解析
|
||||
*/
|
||||
export function resolveSecurityText(key: SecurityCopyKey): string {
|
||||
const isZh = getCurrentLocale() === 'zh-CN';
|
||||
export function resolveSecurityText(key: SecurityCopyKey, locale?: string): string {
|
||||
const lang =
|
||||
locale != null && String(locale).trim() !== ''
|
||||
? String(locale).trim()
|
||||
: getCurrentLocale();
|
||||
const isZh = lang === 'zh-CN';
|
||||
|
||||
switch (key) {
|
||||
case 'title':
|
||||
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
setAppLocale,
|
||||
getOmniText,
|
||||
} from '../../../common/locale.uts';
|
||||
import {
|
||||
CardProfile,
|
||||
OwnerPageData,
|
||||
OwnerPageQuery,
|
||||
} from '../../../common/types.uts';
|
||||
import { fetchOwnerCardPage } from '../../../common/api.uts';
|
||||
|
||||
export { PORTAL_LOCALE_STORAGE_KEY };
|
||||
|
||||
@@ -18,6 +24,8 @@ export type UserProfileAccount = {
|
||||
company: string;
|
||||
department: string;
|
||||
avatar: string;
|
||||
cardId: string;
|
||||
cardSlug: string;
|
||||
};
|
||||
|
||||
export type NotificationSettings = {
|
||||
@@ -74,13 +82,104 @@ function defaultStorage(): StorageApi {
|
||||
|
||||
export function getDefaultAccount(): UserProfileAccount {
|
||||
return {
|
||||
name: 'Alex Vance',
|
||||
company: 'Nexus Tech Global',
|
||||
department: 'Overseas Business Dept',
|
||||
avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=256&q=80',
|
||||
name: '',
|
||||
company: '',
|
||||
department: '',
|
||||
avatar: '',
|
||||
cardId: '',
|
||||
cardSlug: '',
|
||||
};
|
||||
}
|
||||
|
||||
export type LoadProfileAccountApi = {
|
||||
fetchOwnerCardPage: (
|
||||
query: OwnerPageQuery
|
||||
) => Promise<OwnerPageData<CardProfile>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取接口可选组织字段:仅 trim,缺失或空白一律空串
|
||||
*/
|
||||
export function readOptionalOrgText(value: string | null | undefined): string {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 公司/部门展示:有值原样返回,缺失或空串走 i18n 空态
|
||||
*/
|
||||
export function displayOrgField(value: string | null | undefined): string {
|
||||
const text = readOptionalOrgText(value);
|
||||
if (text === '') {
|
||||
return resolveProfileText('empty');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 接口失败或无数据时清空公司与部门,禁止保留上次/示例值
|
||||
*/
|
||||
export function clearOrgFields(state: ProfileState): void {
|
||||
state.account.company = '';
|
||||
state.account.department = '';
|
||||
}
|
||||
|
||||
function readDepartmentFromCard(card: CardProfile): string {
|
||||
const extra = card as CardProfile & { department?: string | null };
|
||||
return readOptionalOrgText(extra.department);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从名片分页接口拉取当前用户公司/部门(仅使用接口返回字段)
|
||||
*/
|
||||
export async function loadOwnerProfileAccount(
|
||||
state: ProfileState,
|
||||
api?: LoadProfileAccountApi
|
||||
): Promise<boolean> {
|
||||
const fetchPage =
|
||||
api != null ? api.fetchOwnerCardPage : fetchOwnerCardPage;
|
||||
state.loading = true;
|
||||
try {
|
||||
const data = await fetchPage({ page: 1, size: 1 } as OwnerPageQuery);
|
||||
const list = data.list != null ? data.list : [];
|
||||
if (list.length === 0) {
|
||||
clearOrgFields(state);
|
||||
state.account.cardId = '';
|
||||
state.account.cardSlug = '';
|
||||
state.errorMessage = '';
|
||||
state.loading = false;
|
||||
return true;
|
||||
}
|
||||
const card = list[0];
|
||||
state.account.company = readOptionalOrgText(card.company);
|
||||
state.account.department = readDepartmentFromCard(card);
|
||||
state.account.cardId = card.id != null ? String(card.id) : '';
|
||||
const slugText = card.slug != null ? String(card.slug).trim() : '';
|
||||
state.account.cardSlug = slugText;
|
||||
if (card.name != null && String(card.name).trim() !== '') {
|
||||
state.account.name = String(card.name).trim();
|
||||
}
|
||||
if (card.avatarUrl != null && String(card.avatarUrl).trim() !== '') {
|
||||
state.account.avatar = String(card.avatarUrl).trim();
|
||||
}
|
||||
state.errorMessage = '';
|
||||
state.loading = false;
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
clearOrgFields(state);
|
||||
state.account.cardId = '';
|
||||
state.account.cardSlug = '';
|
||||
const msg = err instanceof Error && err.message !== ''
|
||||
? err.message
|
||||
: resolveProfileText('error');
|
||||
state.errorMessage = msg;
|
||||
state.loading = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultNotifications(): NotificationSettings {
|
||||
return {
|
||||
radarPush: true,
|
||||
@@ -90,9 +189,9 @@ export function getDefaultNotifications(): NotificationSettings {
|
||||
|
||||
export function getDefaultMetrics(): ProfileMetrics {
|
||||
return {
|
||||
cardsCount: 6,
|
||||
visitorsCount: 128,
|
||||
leadsCount: 24,
|
||||
cardsCount: 0,
|
||||
visitorsCount: 0,
|
||||
leadsCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,7 +226,7 @@ export function saveProfileMetrics(metrics: ProfileMetrics, storage?: StorageApi
|
||||
}
|
||||
|
||||
export function createProfileState(locale?: OmniLocale): ProfileState {
|
||||
const currentLocale = locale != null && locale !== '' ? locale : getCurrentLocale();
|
||||
const currentLocale = locale != null ? locale : getCurrentLocale();
|
||||
return {
|
||||
account: getDefaultAccount(),
|
||||
notifications: getDefaultNotifications(),
|
||||
@@ -166,25 +265,32 @@ export function loadProfileSettings(storage?: StorageApi): ProfileState {
|
||||
const parsedAccount = safeParseJson<Record<string, unknown>>(rawAccount);
|
||||
|
||||
if (parsedAccount != null && typeof parsedAccount === 'object') {
|
||||
const defaultAcc = getDefaultAccount();
|
||||
const safeName = typeof parsedAccount.name === 'string' && parsedAccount.name.trim() !== ''
|
||||
const safeName = typeof parsedAccount.name === 'string'
|
||||
? parsedAccount.name.trim()
|
||||
: defaultAcc.name;
|
||||
const safeCompany = typeof parsedAccount.company === 'string' && parsedAccount.company.trim() !== ''
|
||||
: '';
|
||||
const safeCompany = typeof parsedAccount.company === 'string'
|
||||
? parsedAccount.company.trim()
|
||||
: defaultAcc.company;
|
||||
const safeDept = typeof parsedAccount.department === 'string' && parsedAccount.department.trim() !== ''
|
||||
: '';
|
||||
const safeDept = typeof parsedAccount.department === 'string'
|
||||
? parsedAccount.department.trim()
|
||||
: defaultAcc.department;
|
||||
const safeAvatar = typeof parsedAccount.avatar === 'string' && parsedAccount.avatar.trim() !== ''
|
||||
: '';
|
||||
const safeAvatar = typeof parsedAccount.avatar === 'string'
|
||||
? parsedAccount.avatar.trim()
|
||||
: defaultAcc.avatar;
|
||||
: '';
|
||||
const safeCardId = typeof parsedAccount.cardId === 'string'
|
||||
? parsedAccount.cardId.trim()
|
||||
: '';
|
||||
const safeCardSlug = typeof parsedAccount.cardSlug === 'string'
|
||||
? parsedAccount.cardSlug.trim()
|
||||
: '';
|
||||
|
||||
state.account = {
|
||||
name: safeName,
|
||||
company: safeCompany,
|
||||
department: safeDept,
|
||||
avatar: safeAvatar,
|
||||
cardId: safeCardId,
|
||||
cardSlug: safeCardSlug,
|
||||
};
|
||||
}
|
||||
} catch (_e) {
|
||||
@@ -418,6 +524,9 @@ export function resolveProfileText(key: string): string {
|
||||
if (key === 'error') {
|
||||
return getOmniText(locale, 'common.error');
|
||||
}
|
||||
if (key === 'empty') {
|
||||
return getOmniText(locale, 'common.empty');
|
||||
}
|
||||
|
||||
const isZh = locale === 'zh-CN';
|
||||
|
||||
@@ -507,25 +616,25 @@ export function resolveProfileText(key: string): string {
|
||||
case 'confirmLogoutContent':
|
||||
return isZh ? '确定要退出当前账号吗?' : 'Are you sure you want to sign out?';
|
||||
case 'myCardsSub':
|
||||
return isZh ? '查看与出示专属数字名片' : 'View and present digital cards';
|
||||
return isZh ? '查看和切换名片' : 'View and switch cards';
|
||||
case 'cardEditSub':
|
||||
return isZh ? '修改履历、职业信息与社交矩阵' : 'Update profile and social links';
|
||||
return isZh ? '改姓名、职位和联系方式' : 'Name, role, and contact details';
|
||||
case 'productCasesSub':
|
||||
return isZh ? '展示产品与案例' : 'Portfolio and cases';
|
||||
return isZh ? '名片上展示的案例' : 'Cases shown on your card';
|
||||
case 'articleDynamicsSub':
|
||||
return isZh ? '发布业务动态与文章' : 'Publish dynamic posts';
|
||||
return isZh ? '名片上展示的文章' : 'Articles shown on your card';
|
||||
case 'sharePosterSub':
|
||||
return isZh ? '展会专属扫码展台与立牌' : 'Expo QR poster and stand';
|
||||
return isZh ? '全屏出示二维码' : 'Full-screen QR';
|
||||
case 'exchangeHallSub':
|
||||
return isZh ? '面对面扫码或碰卡互换' : 'Face-to-face card exchange';
|
||||
return isZh ? '当面交换名片' : 'Exchange in person';
|
||||
case 'nfcScanSub':
|
||||
return isZh ? '新实体卡贴碰写入绑定' : 'Tap and bind new NFC card';
|
||||
return isZh ? '把实体卡绑到名片' : 'Bind a physical card';
|
||||
case 'nfcModeSub':
|
||||
return isZh ? '展会与拜访场景切换' : 'Switch expo and meeting modes';
|
||||
return isZh ? '更换这张卡打开的页面' : 'Choose what the card opens';
|
||||
case 'nfcSecuritySub':
|
||||
return isZh ? '防盗刷保护与远程紧急冻结' : 'Remote freeze and theft protection';
|
||||
return isZh ? '卡片丢失时远程停用' : 'Freeze a lost card';
|
||||
case 'teamBriefingSub':
|
||||
return isZh ? '全员名片分发与获客大盘战报' : 'Team leads and distribution briefing';
|
||||
return isZh ? '团队今天的获客' : "Today's team results";
|
||||
case 'avatarActionSheetTitle':
|
||||
return isZh ? '头像管理' : 'Avatar Options';
|
||||
case 'avatarActionPreview':
|
||||
@@ -536,8 +645,12 @@ export function resolveProfileText(key: string): string {
|
||||
return isZh ? '移除当前头像' : 'Remove Avatar';
|
||||
case 'aboutApp':
|
||||
return isZh ? '关于 OmniCard' : 'About OmniCard';
|
||||
case 'aboutBody':
|
||||
return isZh ? '数字名片、微官网和访客雷达' : 'Digital cards, microsites, and visitor radar';
|
||||
case 'confirm':
|
||||
return isZh ? '知道了' : 'OK';
|
||||
case 'appVersion':
|
||||
return isZh ? '企业旗舰版 v1.2.0' : 'Enterprise v1.2.0';
|
||||
return 'OmniCard';
|
||||
case 'securitySafe':
|
||||
return isZh ? '保护中' : 'Protected';
|
||||
case 'livePulse':
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<view class="metric-header-row">
|
||||
<text class="metric-label">{{ t('metricTotal') }}</text>
|
||||
<view class="metric-badge metric-badge-brand">
|
||||
<text class="metric-badge-text">TOTAL</text>
|
||||
<text class="metric-badge-text">{{ t('badgeTotal') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="metric-value text-brand">{{ state.summary.totalLeads }}</text>
|
||||
@@ -96,7 +96,7 @@
|
||||
<text class="metric-label text-hot">{{ t('metricHot') }}</text>
|
||||
<view class="metric-badge metric-badge-hot">
|
||||
<view class="hot-spark-dot"></view>
|
||||
<text class="metric-badge-text text-hot">HOT</text>
|
||||
<text class="metric-badge-text text-hot">{{ t('badgeHot') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="metric-value text-hot">{{ state.summary.hotLeads }}</text>
|
||||
@@ -111,7 +111,7 @@
|
||||
<view class="metric-header-row">
|
||||
<text class="metric-label">{{ t('metricWon') }}</text>
|
||||
<view class="metric-badge metric-badge-won">
|
||||
<text class="metric-badge-text text-won">WON</text>
|
||||
<text class="metric-badge-text text-won">{{ t('badgeWon') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="metric-value text-won">{{ state.summary.wonLeads }}</text>
|
||||
@@ -126,7 +126,7 @@
|
||||
<view class="metric-header-row">
|
||||
<text class="metric-label">{{ t('metricRate') }}</text>
|
||||
<view class="metric-badge metric-badge-rate">
|
||||
<text class="metric-badge-text text-brand">CVR</text>
|
||||
<text class="metric-badge-text text-brand">{{ t('badgeRate') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="metric-value text-brand">{{ state.summary.overallRate }}%</text>
|
||||
@@ -142,7 +142,7 @@
|
||||
<view class="card-header-bar">
|
||||
<view class="card-title-group">
|
||||
<text class="card-title">{{ t('funnelTitle') }}</text>
|
||||
<text class="card-title-badge">4 STAGES</text>
|
||||
<text class="card-title-badge">{{ t('funnelStages') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
<view class="channel-accent-dot" :class="'accent-dot-' + ch.source"></view>
|
||||
<text class="channel-name">{{ ch.label }}</text>
|
||||
</view>
|
||||
<text class="channel-cvr-pill">{{ ch.conversionRate }}% CVR</text>
|
||||
<text class="channel-cvr-pill">{{ ch.conversionRate }}% {{ t('badgeRate') }}</text>
|
||||
</view>
|
||||
|
||||
<view class="channel-data-row">
|
||||
@@ -295,17 +295,18 @@ const pageRtlClass = computed((): string => {
|
||||
return isRtl(getCurrentLocale()) ? 'stats-rtl' : '';
|
||||
});
|
||||
|
||||
/** R31: bar flex from API counts only; zero when empty, never fallback filler */
|
||||
const healthHotFlex = computed((): number => {
|
||||
return state.summary.hotLeads > 0 ? state.summary.hotLeads : 1;
|
||||
return state.summary.hotLeads > 0 ? state.summary.hotLeads : 0;
|
||||
});
|
||||
|
||||
const healthWarmFlex = computed((): number => {
|
||||
const warm = state.summary.totalLeads - state.summary.hotLeads - state.summary.wonLeads;
|
||||
return warm > 0 ? warm : 1;
|
||||
return warm > 0 ? warm : 0;
|
||||
});
|
||||
|
||||
const healthCoolFlex = computed((): number => {
|
||||
return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
function t(key: string): string {
|
||||
|
||||
@@ -397,7 +397,7 @@ export async function refreshOverview(
|
||||
state.loading = false;
|
||||
if (result.errorMessage === '') {
|
||||
try {
|
||||
uni.vibrateShort();
|
||||
uni.vibrateShort({ type: 'light' });
|
||||
} catch (_e) {
|
||||
// 兼容不支持振动的端环境
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export type LeadDetailCopyKey =
|
||||
| 'notes'
|
||||
| 'notesPlaceholder'
|
||||
| 'save'
|
||||
| 'saving'
|
||||
| 'back'
|
||||
| 'empty'
|
||||
| 'missingId'
|
||||
@@ -52,7 +53,10 @@ export type LeadDetailCopyKey =
|
||||
| 'scriptCopiedToast'
|
||||
| 'savedContactToast'
|
||||
| 'leadTitle'
|
||||
| 'aiScriptTitle';
|
||||
| 'aiScriptTitle'
|
||||
| 'callFailed'
|
||||
| 'noteSavedToast'
|
||||
| 'notesRequiredToast';
|
||||
|
||||
export type LeadDetailPageOptions = {
|
||||
id?: string;
|
||||
@@ -91,6 +95,13 @@ export type SaveNoteApi = {
|
||||
) => Promise<LeadEntity>;
|
||||
};
|
||||
|
||||
export type SaveNoteResult = {
|
||||
ok: boolean;
|
||||
code?: number;
|
||||
lead: LeadEntity | null;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
export type LeadDetailState = {
|
||||
rawId: string;
|
||||
leadId: string | number | null;
|
||||
@@ -221,12 +232,18 @@ export function resolveLeadDetailText(key: LeadDetailCopyKey): string {
|
||||
if (key === 'aiScriptTitle') {
|
||||
return zh ? '建议跟进话术' : 'Follow-up Suggestion';
|
||||
}
|
||||
if (key === 'callFailed') {
|
||||
return zh ? '无法拨打电话' : 'Unable to place the call';
|
||||
}
|
||||
if (key === 'noteSavedToast') {
|
||||
return zh ? '备忘已保存' : 'Note saved';
|
||||
}
|
||||
if (key === 'notesRequiredToast') {
|
||||
return zh ? '请输入备忘或上传附件' : 'Please enter note or upload attachment';
|
||||
}
|
||||
if (key === 'saving') {
|
||||
return zh ? '保存中' : 'Saving';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -313,6 +330,80 @@ export function formatWhatsAppUrl(rawPhone: string | null | undefined): string {
|
||||
return `https://wa.me/${digits}`;
|
||||
}
|
||||
|
||||
function toastNone(title: string): void {
|
||||
uni.showToast({
|
||||
title,
|
||||
icon: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开外链:优先 window.open(H5),否则复制链接(对齐 useLogin openOrCopyAuthUrl)
|
||||
*/
|
||||
function openExternalUrl(url: string): boolean {
|
||||
const safeUrl = url != null ? url.trim() : '';
|
||||
if (safeUrl === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let opened = false;
|
||||
try {
|
||||
if (typeof window !== 'undefined' && typeof window.open === 'function') {
|
||||
window.open(safeUrl, '_blank');
|
||||
opened = true;
|
||||
}
|
||||
} catch (_e) {}
|
||||
|
||||
if (opened) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
uni.setClipboardData({
|
||||
data: safeUrl,
|
||||
showToast: true,
|
||||
});
|
||||
return true;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 有合法电话号时调 uni.makePhoneCall;无号码 toast comingSoon。
|
||||
* 不伪造成功:fail 回调提示 callFailed。
|
||||
*/
|
||||
export function callLeadPhone(phone: string | null | undefined): boolean {
|
||||
const trimmed = phone != null ? String(phone).trim() : '';
|
||||
const digits = trimmed.replace(/[^0-9]/g, '');
|
||||
if (trimmed === '' || digits === '') {
|
||||
toastNone(resolveLeadDetailText('comingSoon'));
|
||||
return false;
|
||||
}
|
||||
|
||||
uni.makePhoneCall({
|
||||
phoneNumber: trimmed,
|
||||
fail: () => {
|
||||
toastNone(resolveLeadDetailText('callFailed'));
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 有 WhatsApp/电话号码时打开 wa.me;无号码 toast comingSoon,不打开空链接。
|
||||
*/
|
||||
export function openLeadWhatsApp(
|
||||
phoneOrWhatsApp: string | null | undefined
|
||||
): boolean {
|
||||
const waUrl = formatWhatsAppUrl(phoneOrWhatsApp);
|
||||
if (waUrl === '') {
|
||||
toastNone(resolveLeadDetailText('comingSoon'));
|
||||
return false;
|
||||
}
|
||||
return openExternalUrl(waUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 跟进话术生成器 (基于客户姓名、公司、渠道与意向板块)
|
||||
*/
|
||||
@@ -417,11 +508,19 @@ export function parseNoteAttachments(
|
||||
return { cleanText, imageUrls };
|
||||
}
|
||||
|
||||
export type DeviceContactDraft = {
|
||||
id?: string | number;
|
||||
name: string;
|
||||
company?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存到手机系统通讯录 (深度复用通用 RFC 6350 / vCard 引擎)
|
||||
*/
|
||||
export async function saveContactToDevice(
|
||||
lead: LeadEntity | null
|
||||
lead: DeviceContactDraft | null
|
||||
): Promise<boolean> {
|
||||
if (lead == null) {
|
||||
return false;
|
||||
@@ -516,11 +615,12 @@ export async function loadDetail(
|
||||
|
||||
/**
|
||||
* 更新 notes(支持附件结构化嵌入):POST /admin/crm/lead/update { id, notes }
|
||||
* 仅 Cool-Admin code === 1000(http 层已解包成功)视为保存成功
|
||||
*/
|
||||
export async function saveNote(
|
||||
input: SaveNoteInput,
|
||||
api?: SaveNoteApi
|
||||
): Promise<LeadEntity> {
|
||||
): Promise<SaveNoteResult> {
|
||||
const notes = appendStructuredNote(
|
||||
input.notes,
|
||||
input.next,
|
||||
@@ -532,7 +632,22 @@ export async function saveNote(
|
||||
id: input.id,
|
||||
notes,
|
||||
};
|
||||
return post<LeadEntity>('/admin/crm/lead/update', payload);
|
||||
try {
|
||||
const lead = await post<LeadEntity>('/admin/crm/lead/update', payload);
|
||||
return {
|
||||
ok: true,
|
||||
code: 1000,
|
||||
lead,
|
||||
errorMessage: '',
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
code: undefined,
|
||||
lead: null,
|
||||
errorMessage: readErrorMessage(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createLeadDetailState(): LeadDetailState {
|
||||
@@ -653,27 +768,27 @@ export async function submitLeadNote(
|
||||
}
|
||||
state.saving = true;
|
||||
state.errorMessage = '';
|
||||
try {
|
||||
const saved = await saveNote(
|
||||
{
|
||||
id: state.leadId,
|
||||
notes: state.existingNotes,
|
||||
next,
|
||||
status: state.lead != null ? state.lead.status : undefined,
|
||||
attachments: state.attachments,
|
||||
},
|
||||
api
|
||||
);
|
||||
applyLeadToState(state, saved);
|
||||
const result = await saveNote(
|
||||
{
|
||||
id: state.leadId,
|
||||
notes: state.existingNotes,
|
||||
next,
|
||||
status: state.lead != null ? state.lead.status : undefined,
|
||||
attachments: state.attachments,
|
||||
},
|
||||
api
|
||||
);
|
||||
// 仅接口 code === 1000 才清空输入并标成已保存
|
||||
if (result.ok && result.code === 1000 && result.lead != null) {
|
||||
applyLeadToState(state, result.lead);
|
||||
state.nextNote = '';
|
||||
state.attachments = [];
|
||||
state.saving = false;
|
||||
return true;
|
||||
} catch (err) {
|
||||
state.saving = false;
|
||||
state.errorMessage = readErrorMessage(err);
|
||||
return false;
|
||||
}
|
||||
state.saving = false;
|
||||
state.errorMessage = result.errorMessage;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function updateLeadStage(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
LeadEntity,
|
||||
LeadStatus,
|
||||
OwnerPageData,
|
||||
OwnerPagePagination,
|
||||
OwnerPageQuery,
|
||||
RadarTrack,
|
||||
} from '../../common/types.uts';
|
||||
@@ -45,6 +46,7 @@ export type LeadsCopyKey =
|
||||
| 'sourceQr'
|
||||
| 'sourceLink'
|
||||
| 'sourceAi'
|
||||
| 'stats'
|
||||
| 'statTotal'
|
||||
| 'statHot'
|
||||
| 'statNew'
|
||||
@@ -69,11 +71,7 @@ export type LoadLeadsApi = {
|
||||
|
||||
export type LoadLeadsResult = {
|
||||
list: LeadEntity[];
|
||||
pagination: {
|
||||
page: number;
|
||||
size: number;
|
||||
total: number;
|
||||
};
|
||||
pagination: OwnerPagePagination;
|
||||
empty: boolean;
|
||||
errorMessage: string;
|
||||
};
|
||||
@@ -211,6 +209,9 @@ export function resolveLeadsText(key: LeadsCopyKey): string {
|
||||
if (key === 'sourceAi') {
|
||||
return 'AI Chat';
|
||||
}
|
||||
if (key === 'stats') {
|
||||
return zh ? '统计' : 'Stats';
|
||||
}
|
||||
if (key === 'statTotal') {
|
||||
return zh ? '总潜客' : 'Total Leads';
|
||||
}
|
||||
|
||||
@@ -32,7 +32,22 @@ export type RadarCopyKey =
|
||||
| 'activeNow'
|
||||
| 'blocksViewed'
|
||||
| 'filterAll'
|
||||
| 'avgStay';
|
||||
| 'avgStay'
|
||||
| 'unknown'
|
||||
| 'localNetwork'
|
||||
| 'networkAddress'
|
||||
| 'onlineNow'
|
||||
| 'openedNfc'
|
||||
| 'openedQr'
|
||||
| 'openedLink'
|
||||
| 'blockHero'
|
||||
| 'blockCase'
|
||||
| 'blockArticle'
|
||||
| 'blockSocial'
|
||||
| 'blockVideo'
|
||||
| 'blockForm'
|
||||
| 'blockAppointment'
|
||||
| 'blockContact';
|
||||
|
||||
export type LoadTracksInput = {
|
||||
cardId?: string | number | null;
|
||||
@@ -56,6 +71,7 @@ export type LoadTracksResult = {
|
||||
export type RadarStats = {
|
||||
totalVisits: number;
|
||||
highIntentCount: number;
|
||||
onlineCount: number;
|
||||
intentRateText: string;
|
||||
avgStayText: string;
|
||||
topLocation: string;
|
||||
@@ -168,6 +184,51 @@ export function resolveRadarText(key: RadarCopyKey): string {
|
||||
if (key === 'avgStay') {
|
||||
return zh ? '平均停留' : 'Avg Stay';
|
||||
}
|
||||
if (key === 'unknown') {
|
||||
return zh ? '未知' : 'Unknown';
|
||||
}
|
||||
if (key === 'localNetwork') {
|
||||
return zh ? '本地网络' : 'Local network';
|
||||
}
|
||||
if (key === 'networkAddress') {
|
||||
return zh ? '网络地址' : 'Network';
|
||||
}
|
||||
if (key === 'onlineNow') {
|
||||
return zh ? '在线' : 'Online';
|
||||
}
|
||||
if (key === 'openedNfc') {
|
||||
return zh ? '通过 NFC 碰一碰打开名片' : 'Opened your card with an NFC tap';
|
||||
}
|
||||
if (key === 'openedQr') {
|
||||
return zh ? '扫描展台二维码进入名片' : 'Opened your card from the QR showcase';
|
||||
}
|
||||
if (key === 'openedLink') {
|
||||
return zh ? '通过链接打开了你的名片' : 'Opened your card from a direct link';
|
||||
}
|
||||
if (key === 'blockHero') {
|
||||
return zh ? '名片主页' : 'Profile';
|
||||
}
|
||||
if (key === 'blockCase') {
|
||||
return zh ? '案例' : 'Case';
|
||||
}
|
||||
if (key === 'blockArticle') {
|
||||
return zh ? '文章' : 'Article';
|
||||
}
|
||||
if (key === 'blockSocial') {
|
||||
return zh ? '社交' : 'Social';
|
||||
}
|
||||
if (key === 'blockVideo') {
|
||||
return zh ? '视频' : 'Video';
|
||||
}
|
||||
if (key === 'blockForm') {
|
||||
return zh ? '留资表单' : 'Lead form';
|
||||
}
|
||||
if (key === 'blockAppointment') {
|
||||
return zh ? '预约会面' : 'Meeting';
|
||||
}
|
||||
if (key === 'blockContact') {
|
||||
return zh ? '联系方式' : 'Contact';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -222,6 +283,14 @@ export function formatStay(ms: number | null | undefined): string {
|
||||
if (safe < 1000) {
|
||||
return `${safe}ms`;
|
||||
}
|
||||
if (safe >= 60000) {
|
||||
const minutes = Math.floor(safe / 60000);
|
||||
const seconds = Math.round((safe % 60000) / 1000);
|
||||
if (seconds <= 0) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
return `${(safe / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
@@ -236,22 +305,65 @@ export function formatDepth(depth: number | null | undefined): string {
|
||||
return `${clamped}%`;
|
||||
}
|
||||
|
||||
function isBlankGeo(value: string): boolean {
|
||||
const text = value.trim().toLowerCase();
|
||||
return text === '' || text === 'unknown' || text === 'null' || text === 'undefined';
|
||||
}
|
||||
|
||||
function isLocalGeo(value: string): boolean {
|
||||
const text = value.trim().toLowerCase();
|
||||
return text === 'local' || text === 'localhost';
|
||||
}
|
||||
|
||||
/**
|
||||
* 国家 / 城市一行展示
|
||||
* 国家 / 城市一行展示。空值与 Unknown 走「未知」,内网 Local 走「本地网络」,不回退示例城市
|
||||
*/
|
||||
export function formatLocation(country?: string, city?: string): string {
|
||||
const countryText = country != null ? String(country).trim() : '';
|
||||
const cityText = city != null ? String(city).trim() : '';
|
||||
if (countryText !== '' && cityText !== '') {
|
||||
return `${countryText} / ${cityText}`;
|
||||
if (isLocalGeo(countryText) || isLocalGeo(cityText)) {
|
||||
return resolveRadarText('localNetwork');
|
||||
}
|
||||
if (countryText !== '') {
|
||||
return countryText;
|
||||
const displayCountry = !isBlankGeo(countryText) ? countryText : resolveRadarText('unknown');
|
||||
const displayCity = !isBlankGeo(cityText) ? cityText : '';
|
||||
if (displayCity !== '') {
|
||||
return `${displayCountry} / ${displayCity}`;
|
||||
}
|
||||
if (cityText !== '') {
|
||||
return cityText;
|
||||
return displayCountry;
|
||||
}
|
||||
|
||||
/**
|
||||
* 公网 IP 才展示。回环、内网与空地址不当作地理位置
|
||||
*/
|
||||
export function formatPublicIp(ip?: string): string {
|
||||
const raw = ip != null ? String(ip).trim() : '';
|
||||
if (raw === '') {
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
const clean = raw.replace(/^::ffff:/i, '').toLowerCase();
|
||||
if (
|
||||
clean === '' ||
|
||||
clean === '::1' ||
|
||||
clean === 'localhost' ||
|
||||
clean === '0.0.0.0' ||
|
||||
clean === '127.0.0.1' ||
|
||||
clean.startsWith('127.') ||
|
||||
clean.startsWith('10.') ||
|
||||
clean.startsWith('192.168.') ||
|
||||
clean.startsWith('169.254.') ||
|
||||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(clean)
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
return raw.replace(/^::ffff:/i, '');
|
||||
}
|
||||
|
||||
export function formatNetworkLine(ip?: string): string {
|
||||
const pub = formatPublicIp(ip);
|
||||
if (pub === '') {
|
||||
return '';
|
||||
}
|
||||
return `${resolveRadarText('networkAddress')} ${pub}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -304,24 +416,348 @@ export function getIntentBadgeText(level: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 访客活动主通知流文案(如 "Alex tapped your NFC card")
|
||||
* 访客显示名:有线索或内部用户姓名就用姓名,否则用访客编号,不编造身份
|
||||
*/
|
||||
export function formatVisitorHeadline(item: RadarTrack): string {
|
||||
export function formatVisitorName(item: RadarTrack): string {
|
||||
const named = item.visitorName != null ? String(item.visitorName).trim() : '';
|
||||
if (named !== '') {
|
||||
return named;
|
||||
}
|
||||
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 (vid === '') {
|
||||
return zh ? '访客' : 'Visitor';
|
||||
}
|
||||
const shortId = vid.replace(/^vis_/i, '');
|
||||
const tail = shortId.length > 10 ? shortId.slice(-8) : shortId;
|
||||
return zh ? `访客 ${tail}` : `Visitor ${tail}`;
|
||||
}
|
||||
|
||||
export function visitorAvatarLetter(item: RadarTrack): string {
|
||||
const name = formatVisitorName(item).trim();
|
||||
if (name === '') {
|
||||
return 'V';
|
||||
}
|
||||
return name.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 触达动作,只描述真实渠道,不把浏览写成已留资
|
||||
*/
|
||||
export function formatVisitorAction(item: RadarTrack): string {
|
||||
if (item.touchType === 'nfc') {
|
||||
return zh ? `${name} 通过 NFC 碰一碰接入名片` : `${name} tapped your NFC card`;
|
||||
return resolveRadarText('openedNfc');
|
||||
}
|
||||
if (item.touchType === 'qr') {
|
||||
return zh ? `${name} 扫描了展台名片二维码` : `${name} scanned your QR showcase`;
|
||||
return resolveRadarText('openedQr');
|
||||
}
|
||||
return zh ? `${name} 浏览了你的数字微官网` : `${name} saved your contact card`;
|
||||
return resolveRadarText('openedLink');
|
||||
}
|
||||
|
||||
/**
|
||||
* 访客活动主通知流文案
|
||||
*/
|
||||
export function formatVisitorHeadline(item: RadarTrack): string {
|
||||
return `${formatVisitorName(item)} ${formatVisitorAction(item)}`;
|
||||
}
|
||||
|
||||
export function readTrackTime(item: { createdAt?: string | Date; createTime?: string | Date }): number {
|
||||
const raw = item.createdAt != null ? item.createdAt : item.createTime;
|
||||
return parseTimeValue(raw);
|
||||
}
|
||||
|
||||
function parseTimeValue(raw: string | Date | null | undefined): number {
|
||||
if (raw == null) {
|
||||
return 0;
|
||||
}
|
||||
if (raw instanceof Date) {
|
||||
const time = raw.getTime();
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
const text = String(raw).trim();
|
||||
if (text === '') {
|
||||
return 0;
|
||||
}
|
||||
const direct = Date.parse(text);
|
||||
if (!Number.isNaN(direct) && direct > 0) {
|
||||
return direct;
|
||||
}
|
||||
const normalized = Date.parse(text.replace(' ', 'T'));
|
||||
if (!Number.isNaN(normalized) && normalized > 0) {
|
||||
return normalized;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** 5 分钟内有足迹视为仍在浏览 */
|
||||
export const RADAR_ONLINE_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
export function isVisitorOnline(
|
||||
item: { createdAt?: string | Date; createTime?: string | Date },
|
||||
now = Date.now()
|
||||
): boolean {
|
||||
const time = readTrackTime(item);
|
||||
if (time <= 0) {
|
||||
return false;
|
||||
}
|
||||
const diff = now - time;
|
||||
return diff <= RADAR_ONLINE_WINDOW_MS && diff >= -60000;
|
||||
}
|
||||
|
||||
function padTime(value: number): string {
|
||||
const text = String(value);
|
||||
return text.length < 2 ? `0${text}` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绝对时间,供名片主核对监控时刻
|
||||
*/
|
||||
export function formatClockTime(createdAt?: string | Date): string {
|
||||
const time = readTrackTime({ createdAt });
|
||||
if (time <= 0) {
|
||||
return '';
|
||||
}
|
||||
const date = new Date(time);
|
||||
return `${padTime(date.getMonth() + 1)}-${padTime(date.getDate())} ${padTime(date.getHours())}:${padTime(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function formatMonitorTime(item: { createdAt?: string | Date; createTime?: string | Date }): string {
|
||||
const raw = item.createdAt != null ? item.createdAt : item.createTime;
|
||||
const clock = formatClockTime(raw);
|
||||
const relative = formatRelativeTime(raw);
|
||||
if (clock === '') {
|
||||
return relative;
|
||||
}
|
||||
return `${clock} · ${relative}`;
|
||||
}
|
||||
|
||||
function blockTypeLabel(key: string): string {
|
||||
const id = key.trim().toLowerCase();
|
||||
if (id === 'hero' || id === 'profile') {
|
||||
return resolveRadarText('blockHero');
|
||||
}
|
||||
if (id === 'case' || id === 'cases') {
|
||||
return resolveRadarText('blockCase');
|
||||
}
|
||||
if (id === 'article' || id === 'articles') {
|
||||
return resolveRadarText('blockArticle');
|
||||
}
|
||||
if (id === 'social') {
|
||||
return resolveRadarText('blockSocial');
|
||||
}
|
||||
if (id === 'video') {
|
||||
return resolveRadarText('blockVideo');
|
||||
}
|
||||
if (id === 'form') {
|
||||
return resolveRadarText('blockForm');
|
||||
}
|
||||
if (id === 'appointment') {
|
||||
return resolveRadarText('blockAppointment');
|
||||
}
|
||||
if (id === 'contact' || id === 'contact_btn') {
|
||||
return resolveRadarText('blockContact');
|
||||
}
|
||||
return key.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览内容:优先用模块标题,否则用类型名称,原始编号仅在没有标题时保留
|
||||
*/
|
||||
export function formatViewedLabels(item: RadarTrack): string[] {
|
||||
const labels: string[] = [];
|
||||
const seen: Record<string, boolean> = {};
|
||||
const contents = item.viewedContents;
|
||||
if (contents != null && Array.isArray(contents) && contents.length > 0) {
|
||||
for (let i = 0; i < contents.length; i++) {
|
||||
const row = contents[i];
|
||||
const title = row.title != null ? String(row.title).trim() : '';
|
||||
const key = row.key != null ? String(row.key).trim() : '';
|
||||
const label = title !== '' ? title : blockTypeLabel(key);
|
||||
if (label !== '' && seen[label] !== true) {
|
||||
seen[label] = true;
|
||||
labels.push(label);
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
const blocks = item.viewedBlocks;
|
||||
if (blocks == null || !Array.isArray(blocks)) {
|
||||
return labels;
|
||||
}
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const label = blockTypeLabel(String(blocks[i]));
|
||||
if (label !== '' && seen[label] !== true) {
|
||||
seen[label] = true;
|
||||
labels.push(label);
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
export function formatViewedLine(item: RadarTrack): string {
|
||||
const labels = formatViewedLabels(item);
|
||||
if (labels.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return `${resolveRadarText('blocksViewed')} ${labels.join(' · ')}`;
|
||||
}
|
||||
|
||||
function sessionKey(item: RadarTrack): string {
|
||||
const visitorId = item.visitorId != null ? item.visitorId.trim() : '';
|
||||
const cardId = item.cardId != null ? String(item.cardId) : '';
|
||||
if (visitorId === '' || cardId === '') {
|
||||
return '';
|
||||
}
|
||||
return `${visitorId}_${cardId}`;
|
||||
}
|
||||
|
||||
function copyTrack(item: RadarTrack): RadarTrack {
|
||||
const blocks = item.viewedBlocks != null && Array.isArray(item.viewedBlocks) ? item.viewedBlocks.slice() : [];
|
||||
const contents =
|
||||
item.viewedContents != null && Array.isArray(item.viewedContents) ? item.viewedContents.slice() : undefined;
|
||||
return {
|
||||
id: item.id,
|
||||
tenantId: item.tenantId,
|
||||
cardId: item.cardId,
|
||||
visitorId: item.visitorId,
|
||||
ip: item.ip,
|
||||
country: item.country,
|
||||
city: item.city,
|
||||
touchType: item.touchType,
|
||||
stayDurationMs: item.stayDurationMs,
|
||||
scrollDepth: item.scrollDepth,
|
||||
viewedBlocks: blocks,
|
||||
viewedContents: contents,
|
||||
visitorName: item.visitorName,
|
||||
visitorCompany: item.visitorCompany,
|
||||
avatarUrl: item.avatarUrl,
|
||||
cardTitle: item.cardTitle,
|
||||
createdAt: item.createdAt != null ? item.createdAt : item.createTime,
|
||||
createTime: item.createTime != null ? item.createTime : item.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一访客对同一名片的进入与离开合并成一条监控会话
|
||||
*/
|
||||
export function consolidateRadarTracks(tracks: RadarTrack[]): RadarTrack[] {
|
||||
const order: string[] = [];
|
||||
const map: Record<string, RadarTrack> = {};
|
||||
const singles: RadarTrack[] = [];
|
||||
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
const item = tracks[i];
|
||||
const key = sessionKey(item);
|
||||
if (key === '') {
|
||||
singles.push(copyTrack(item));
|
||||
continue;
|
||||
}
|
||||
const prev = map[key];
|
||||
if (prev == null) {
|
||||
map[key] = copyTrack(item);
|
||||
order.push(key);
|
||||
continue;
|
||||
}
|
||||
prev.stayDurationMs = Math.max(prev.stayDurationMs || 0, item.stayDurationMs || 0);
|
||||
prev.scrollDepth = Math.max(prev.scrollDepth || 0, item.scrollDepth || 0);
|
||||
const prevBlocks = prev.viewedBlocks != null ? prev.viewedBlocks : [];
|
||||
const nextBlocks = item.viewedBlocks != null && Array.isArray(item.viewedBlocks) ? item.viewedBlocks : [];
|
||||
const merged: string[] = [];
|
||||
const seenBlock: Record<string, boolean> = {};
|
||||
for (let b = 0; b < prevBlocks.length; b++) {
|
||||
const text = String(prevBlocks[b]);
|
||||
if (seenBlock[text] !== true) {
|
||||
seenBlock[text] = true;
|
||||
merged.push(text);
|
||||
}
|
||||
}
|
||||
for (let b = 0; b < nextBlocks.length; b++) {
|
||||
const text = String(nextBlocks[b]);
|
||||
if (seenBlock[text] !== true) {
|
||||
seenBlock[text] = true;
|
||||
merged.push(text);
|
||||
}
|
||||
}
|
||||
prev.viewedBlocks = merged;
|
||||
const name = item.visitorName != null ? String(item.visitorName).trim() : '';
|
||||
if ((prev.visitorName == null || String(prev.visitorName).trim() === '') && name !== '') {
|
||||
prev.visitorName = name;
|
||||
}
|
||||
const company = item.visitorCompany != null ? String(item.visitorCompany).trim() : '';
|
||||
if ((prev.visitorCompany == null || String(prev.visitorCompany).trim() === '') && company !== '') {
|
||||
prev.visitorCompany = company;
|
||||
}
|
||||
const avatar = item.avatarUrl != null ? String(item.avatarUrl).trim() : '';
|
||||
if ((prev.avatarUrl == null || String(prev.avatarUrl).trim() === '') && avatar !== '') {
|
||||
prev.avatarUrl = avatar;
|
||||
}
|
||||
if (item.touchType === 'nfc') {
|
||||
prev.touchType = 'nfc';
|
||||
} else if (item.touchType === 'qr' && prev.touchType !== 'nfc') {
|
||||
prev.touchType = 'qr';
|
||||
}
|
||||
const prevContents = prev.viewedContents != null ? prev.viewedContents : [];
|
||||
const nextContents = item.viewedContents != null && Array.isArray(item.viewedContents) ? item.viewedContents : [];
|
||||
const contentMap: Record<string, string> = {};
|
||||
const contentOrder: string[] = [];
|
||||
for (let c = 0; c < prevContents.length; c++) {
|
||||
const row = prevContents[c];
|
||||
const contentKey = row.key != null ? String(row.key) : '';
|
||||
if (contentKey === '') {
|
||||
continue;
|
||||
}
|
||||
if (contentMap[contentKey] == null) {
|
||||
contentOrder.push(contentKey);
|
||||
}
|
||||
contentMap[contentKey] = row.title != null ? String(row.title) : '';
|
||||
}
|
||||
for (let c = 0; c < nextContents.length; c++) {
|
||||
const row = nextContents[c];
|
||||
const contentKey = row.key != null ? String(row.key) : '';
|
||||
if (contentKey === '') {
|
||||
continue;
|
||||
}
|
||||
const title = row.title != null ? String(row.title).trim() : '';
|
||||
if (contentMap[contentKey] == null) {
|
||||
contentOrder.push(contentKey);
|
||||
contentMap[contentKey] = title;
|
||||
} else if (contentMap[contentKey] === '' && title !== '') {
|
||||
contentMap[contentKey] = title;
|
||||
}
|
||||
}
|
||||
if (contentOrder.length > 0) {
|
||||
const nextViewed: RadarTrack['viewedContents'] = [];
|
||||
for (let c = 0; c < contentOrder.length; c++) {
|
||||
const contentKey = contentOrder[c];
|
||||
nextViewed.push({ key: contentKey, title: contentMap[contentKey] });
|
||||
}
|
||||
prev.viewedContents = nextViewed;
|
||||
}
|
||||
if (readTrackTime(item) >= readTrackTime(prev)) {
|
||||
prev.id = item.id;
|
||||
prev.createdAt = item.createdAt != null ? item.createdAt : item.createTime;
|
||||
prev.createTime = item.createTime != null ? item.createTime : item.createdAt;
|
||||
if (item.ip != null && String(item.ip).trim() !== '') {
|
||||
prev.ip = item.ip;
|
||||
}
|
||||
if (item.country != null && String(item.country).trim() !== '') {
|
||||
prev.country = item.country;
|
||||
}
|
||||
if (item.city != null && String(item.city).trim() !== '') {
|
||||
prev.city = item.city;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const merged: RadarTrack[] = [];
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
merged.push(map[order[i]]);
|
||||
}
|
||||
for (let i = 0; i < singles.length; i++) {
|
||||
merged.push(singles[i]);
|
||||
}
|
||||
merged.sort((a, b) => readTrackTime(b) - readTrackTime(a));
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -329,20 +765,9 @@ export function formatVisitorHeadline(item: RadarTrack): string {
|
||||
*/
|
||||
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();
|
||||
}
|
||||
const time = parseTimeValue(createdAt);
|
||||
if (time <= 0) {
|
||||
return zh ? '刚刚' : 'Just Now';
|
||||
return zh ? '时间未知' : 'Time unknown';
|
||||
}
|
||||
const diffMs = Math.max(0, Date.now() - time);
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
@@ -391,8 +816,24 @@ export function computeRadarStats(tracks: RadarTrack[], total: number): RadarSta
|
||||
}
|
||||
}
|
||||
|
||||
const rate = tracks.length > 0 ? Math.round((highCount / tracks.length) * 100) : (count > 0 ? 32 : 0);
|
||||
const rate = tracks.length > 0 ? Math.round((highCount / tracks.length) * 100) : 0;
|
||||
const avgStay = tracks.length > 0 ? Math.round(totalStay / tracks.length) : 0;
|
||||
const seenOnline: Record<string, boolean> = {};
|
||||
let onlineCount = 0;
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
const current = tracks[i];
|
||||
if (!isVisitorOnline(current)) {
|
||||
continue;
|
||||
}
|
||||
const visitorKey = current.visitorId != null && current.visitorId.trim() !== ''
|
||||
? current.visitorId.trim()
|
||||
: String(current.id);
|
||||
if (seenOnline[visitorKey] === true) {
|
||||
continue;
|
||||
}
|
||||
seenOnline[visitorKey] = true;
|
||||
onlineCount += 1;
|
||||
}
|
||||
|
||||
let topCountry = '';
|
||||
let maxC = 0;
|
||||
@@ -406,9 +847,10 @@ export function computeRadarStats(tracks: RadarTrack[], total: number): RadarSta
|
||||
return {
|
||||
totalVisits: count,
|
||||
highIntentCount: highCount,
|
||||
onlineCount,
|
||||
intentRateText: `${rate}%`,
|
||||
avgStayText: formatStay(avgStay),
|
||||
topLocation: topCountry !== '' ? topCountry : 'Global',
|
||||
topLocation: topCountry !== '' ? formatLocation(topCountry, '') : resolveRadarText('unknown'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -416,7 +858,7 @@ export function computeRadarStats(tracks: RadarTrack[], total: number): RadarSta
|
||||
* 计算走势高度 (7 个数据柱)
|
||||
*/
|
||||
export function computeTrendHeights(tracks: RadarTrack[]): number[] {
|
||||
const defaultHeights: number[] = [35, 60, 45, 80, 55, 90, 75];
|
||||
const defaultHeights: number[] = [0, 0, 0, 0, 0, 0, 0];
|
||||
if (tracks.length === 0) {
|
||||
return defaultHeights;
|
||||
}
|
||||
@@ -441,6 +883,54 @@ function readErrorMessage(err: unknown): string {
|
||||
return resolveRadarText('error');
|
||||
}
|
||||
|
||||
function readRowField(row: RadarTrack, camel: string, snake: string): unknown {
|
||||
const record = row as unknown as Record<string, unknown>;
|
||||
const primary = record[camel];
|
||||
if (primary != null && primary !== '') {
|
||||
return primary;
|
||||
}
|
||||
return record[snake];
|
||||
}
|
||||
|
||||
function normalizeTrack(row: RadarTrack): RadarTrack {
|
||||
const blocksRaw = readRowField(row, 'viewedBlocks', 'viewed_blocks');
|
||||
let viewedBlocks: string[] = [];
|
||||
if (Array.isArray(blocksRaw)) {
|
||||
viewedBlocks = blocksRaw.map((item) => String(item));
|
||||
} else if (typeof blocksRaw === 'string' && blocksRaw.trim() !== '') {
|
||||
viewedBlocks = [blocksRaw];
|
||||
}
|
||||
const createTime = readRowField(row, 'createTime', 'create_time');
|
||||
const createdAt = readRowField(row, 'createdAt', 'created_at');
|
||||
const time = createTime != null ? createTime : createdAt;
|
||||
const stayRaw = readRowField(row, 'stayDurationMs', 'stay_duration_ms');
|
||||
const depthRaw = readRowField(row, 'scrollDepth', 'scroll_depth');
|
||||
const stay = Number(stayRaw);
|
||||
const depth = Number(depthRaw);
|
||||
const touch = readRowField(row, 'touchType', 'touch_type');
|
||||
const touchType = touch === 'nfc' || touch === 'qr' || touch === 'link' ? touch : 'link';
|
||||
return {
|
||||
id: row.id,
|
||||
tenantId: readRowField(row, 'tenantId', 'tenant_id') as string | number | undefined,
|
||||
cardId: (readRowField(row, 'cardId', 'card_id') as string | number) || row.cardId,
|
||||
visitorId: String(readRowField(row, 'visitorId', 'visitor_id') || ''),
|
||||
ip: readRowField(row, 'ip', 'ip') as string | undefined,
|
||||
country: readRowField(row, 'country', 'country') as string | undefined,
|
||||
city: readRowField(row, 'city', 'city') as string | undefined,
|
||||
touchType,
|
||||
stayDurationMs: Number.isFinite(stay) ? stay : 0,
|
||||
scrollDepth: Number.isFinite(depth) ? depth : 0,
|
||||
viewedBlocks,
|
||||
viewedContents: row.viewedContents,
|
||||
visitorName: row.visitorName,
|
||||
visitorCompany: row.visitorCompany,
|
||||
avatarUrl: row.avatarUrl,
|
||||
cardTitle: row.cardTitle,
|
||||
createdAt: (createdAt != null ? createdAt : time) as string | Date | undefined,
|
||||
createTime: (createTime != null ? createTime : time) as string | Date | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTrackList(payload: OwnerPageData<RadarTrack> | null | undefined): RadarTrack[] {
|
||||
if (payload == null || payload.list == null) {
|
||||
return [];
|
||||
@@ -448,7 +938,11 @@ function parseTrackList(payload: OwnerPageData<RadarTrack> | null | undefined):
|
||||
if (!Array.isArray(payload.list)) {
|
||||
return [];
|
||||
}
|
||||
return payload.list;
|
||||
const list: RadarTrack[] = [];
|
||||
for (let i = 0; i < payload.list.length; i++) {
|
||||
list.push(normalizeTrack(payload.list[i]));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* OmniCard AI (CRMmp) - 名片主独立数据统计与销售漏斗业务驱动层
|
||||
* 纯函数计算转化率、漏斗阶段折损、渠道成效矩阵与国际化
|
||||
* R31: 数字与柱状仅来自接口;失败/缺字段时归零且图表为空
|
||||
*/
|
||||
|
||||
import { LeadEntity, OwnerPageQuery } from '../../common/types.uts';
|
||||
import { LeadEntity, OwnerPageData, OwnerPageQuery } from '../../common/types.uts';
|
||||
import { fetchOwnerLeadPage } from '../../common/api.uts';
|
||||
import { getCurrentLocale, getOmniText } from '../../common/locale.uts';
|
||||
|
||||
@@ -40,6 +41,12 @@ export type StatsState = {
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
export type LoadStatsApi = {
|
||||
fetchOwnerLeadPage?: (
|
||||
query: OwnerPageQuery
|
||||
) => Promise<OwnerPageData<LeadEntity> | Record<string, unknown> | null>;
|
||||
};
|
||||
|
||||
function isZhLocale(): boolean {
|
||||
return getCurrentLocale() === 'zh-CN';
|
||||
}
|
||||
@@ -166,40 +173,71 @@ export function resolveStatsText(key: string): string {
|
||||
if (key === 'stepDescWon') {
|
||||
return isZhLocale() ? '签约成交' : 'Closed';
|
||||
}
|
||||
if (key === 'badgeTotal') {
|
||||
return isZhLocale() ? '总量' : 'TOTAL';
|
||||
}
|
||||
if (key === 'badgeHot') {
|
||||
return isZhLocale() ? '高意向' : 'HOT';
|
||||
}
|
||||
if (key === 'badgeWon') {
|
||||
return isZhLocale() ? '成交' : 'WON';
|
||||
}
|
||||
if (key === 'badgeRate') {
|
||||
return isZhLocale() ? '转化' : 'CVR';
|
||||
}
|
||||
if (key === 'funnelStages') {
|
||||
return isZhLocale() ? '四阶' : '4 STAGES';
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
function emptySummary(): StatsSummary {
|
||||
return {
|
||||
totalLeads: 0,
|
||||
hotLeads: 0,
|
||||
wonLeads: 0,
|
||||
overallRate: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createStatsState(): StatsState {
|
||||
return {
|
||||
period: 'all',
|
||||
summary: {
|
||||
totalLeads: 0,
|
||||
hotLeads: 0,
|
||||
wonLeads: 0,
|
||||
overallRate: 0,
|
||||
},
|
||||
funnel: [
|
||||
{ key: 'new', label: resolveStatsText('stepNew'), count: 0, rate: 0 },
|
||||
{ key: 'hot', label: resolveStatsText('stepHot'), count: 0, rate: 0 },
|
||||
{ key: 'contacted', label: resolveStatsText('stepContacted'), count: 0, rate: 0 },
|
||||
{ key: 'won', label: resolveStatsText('stepWon'), count: 0, rate: 0 },
|
||||
],
|
||||
channels: [
|
||||
{ source: 'nfc', label: resolveStatsText('sourceNfc'), leadsCount: 0, wonCount: 0, conversionRate: 0 },
|
||||
{ source: 'qr', label: resolveStatsText('sourceQr'), leadsCount: 0, wonCount: 0, conversionRate: 0 },
|
||||
{ source: 'link', label: resolveStatsText('sourceLink'), leadsCount: 0, wonCount: 0, conversionRate: 0 },
|
||||
{ source: 'ai_chat', label: resolveStatsText('sourceAi'), leadsCount: 0, wonCount: 0, conversionRate: 0 },
|
||||
],
|
||||
summary: emptySummary(),
|
||||
funnel: [],
|
||||
channels: [],
|
||||
loading: false,
|
||||
errorMessage: '',
|
||||
};
|
||||
}
|
||||
|
||||
/** R31: 失败或缺字段时数字归零、图表清空 */
|
||||
export function resetStatsToEmpty(state: StatsState): void {
|
||||
state.summary = emptySummary();
|
||||
state.funnel = [];
|
||||
state.channels = [];
|
||||
}
|
||||
|
||||
function parseLeadList(res: OwnerPageData<LeadEntity> | Record<string, unknown> | null): LeadEntity[] {
|
||||
if (res == null) {
|
||||
return [];
|
||||
}
|
||||
const list = (res as OwnerPageData<LeadEntity>).list;
|
||||
if (list == null || !Array.isArray(list)) {
|
||||
return [];
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
export function computeStatsFromLeads(leads: LeadEntity[], period: StatsPeriod): StatsState {
|
||||
const state = createStatsState();
|
||||
state.period = period;
|
||||
|
||||
if (leads == null || leads.length === 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
let totalCount = 0;
|
||||
let hotCount = 0;
|
||||
let newCount = 0;
|
||||
@@ -217,7 +255,8 @@ export function computeStatsFromLeads(leads: LeadEntity[], period: StatsPeriod):
|
||||
const item = leads[i];
|
||||
totalCount++;
|
||||
|
||||
if (item.status === 'hot' || item.intentScore >= 80) {
|
||||
const score = item.intentScore != null ? item.intentScore : 0;
|
||||
if (item.status === 'hot' || score >= 80) {
|
||||
hotCount++;
|
||||
}
|
||||
if (item.status === 'new') {
|
||||
@@ -242,55 +281,90 @@ export function computeStatsFromLeads(leads: LeadEntity[], period: StatsPeriod):
|
||||
state.summary.wonLeads = wonCount;
|
||||
state.summary.overallRate = calcConversionRate(totalCount, wonCount);
|
||||
|
||||
// 漏斗阶梯与转化率
|
||||
state.funnel[0].count = totalCount;
|
||||
state.funnel[0].rate = totalCount > 0 ? 100 : 0;
|
||||
state.funnel = [
|
||||
{
|
||||
key: 'new',
|
||||
label: resolveStatsText('stepNew'),
|
||||
count: totalCount,
|
||||
rate: totalCount > 0 ? 100 : 0,
|
||||
},
|
||||
{
|
||||
key: 'hot',
|
||||
label: resolveStatsText('stepHot'),
|
||||
count: hotCount,
|
||||
rate: calcFunnelStepRate(hotCount, totalCount),
|
||||
},
|
||||
{
|
||||
key: 'contacted',
|
||||
label: resolveStatsText('stepContacted'),
|
||||
count: contactedCount,
|
||||
rate: calcFunnelStepRate(contactedCount, totalCount),
|
||||
},
|
||||
{
|
||||
key: 'won',
|
||||
label: resolveStatsText('stepWon'),
|
||||
count: wonCount,
|
||||
rate: calcFunnelStepRate(wonCount, totalCount),
|
||||
},
|
||||
];
|
||||
|
||||
state.funnel[1].count = hotCount;
|
||||
state.funnel[1].rate = calcFunnelStepRate(hotCount, totalCount);
|
||||
|
||||
state.funnel[2].count = contactedCount;
|
||||
state.funnel[2].rate = calcFunnelStepRate(contactedCount, totalCount);
|
||||
|
||||
state.funnel[3].count = wonCount;
|
||||
state.funnel[3].rate = calcFunnelStepRate(wonCount, totalCount);
|
||||
|
||||
// 渠道成效
|
||||
state.channels[0].leadsCount = channelMap['nfc'].leads;
|
||||
state.channels[0].wonCount = channelMap['nfc'].won;
|
||||
state.channels[0].conversionRate = calcConversionRate(channelMap['nfc'].leads, channelMap['nfc'].won);
|
||||
|
||||
state.channels[1].leadsCount = channelMap['qr'].leads;
|
||||
state.channels[1].wonCount = channelMap['qr'].won;
|
||||
state.channels[1].conversionRate = calcConversionRate(channelMap['qr'].leads, channelMap['qr'].won);
|
||||
|
||||
state.channels[2].leadsCount = channelMap['link'].leads;
|
||||
state.channels[2].wonCount = channelMap['link'].won;
|
||||
state.channels[2].conversionRate = calcConversionRate(channelMap['link'].leads, channelMap['link'].won);
|
||||
|
||||
state.channels[3].leadsCount = channelMap['ai_chat'].leads;
|
||||
state.channels[3].wonCount = channelMap['ai_chat'].won;
|
||||
state.channels[3].conversionRate = calcConversionRate(channelMap['ai_chat'].leads, channelMap['ai_chat'].won);
|
||||
state.channels = [
|
||||
{
|
||||
source: 'nfc',
|
||||
label: resolveStatsText('sourceNfc'),
|
||||
leadsCount: channelMap['nfc'].leads,
|
||||
wonCount: channelMap['nfc'].won,
|
||||
conversionRate: calcConversionRate(channelMap['nfc'].leads, channelMap['nfc'].won),
|
||||
},
|
||||
{
|
||||
source: 'qr',
|
||||
label: resolveStatsText('sourceQr'),
|
||||
leadsCount: channelMap['qr'].leads,
|
||||
wonCount: channelMap['qr'].won,
|
||||
conversionRate: calcConversionRate(channelMap['qr'].leads, channelMap['qr'].won),
|
||||
},
|
||||
{
|
||||
source: 'link',
|
||||
label: resolveStatsText('sourceLink'),
|
||||
leadsCount: channelMap['link'].leads,
|
||||
wonCount: channelMap['link'].won,
|
||||
conversionRate: calcConversionRate(channelMap['link'].leads, channelMap['link'].won),
|
||||
},
|
||||
{
|
||||
source: 'ai_chat',
|
||||
label: resolveStatsText('sourceAi'),
|
||||
leadsCount: channelMap['ai_chat'].leads,
|
||||
wonCount: channelMap['ai_chat'].won,
|
||||
conversionRate: calcConversionRate(channelMap['ai_chat'].leads, channelMap['ai_chat'].won),
|
||||
},
|
||||
];
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function loadStatsData(state: StatsState): Promise<void> {
|
||||
export async function loadStatsData(state: StatsState, api?: LoadStatsApi): Promise<void> {
|
||||
state.loading = true;
|
||||
state.errorMessage = '';
|
||||
|
||||
const fetchPage =
|
||||
api != null && api.fetchOwnerLeadPage != null
|
||||
? api.fetchOwnerLeadPage
|
||||
: fetchOwnerLeadPage;
|
||||
|
||||
try {
|
||||
const query: OwnerPageQuery = {
|
||||
page: 1,
|
||||
size: 100,
|
||||
};
|
||||
const res = await fetchOwnerLeadPage(query);
|
||||
const computed = computeStatsFromLeads(res.list, state.period);
|
||||
const res = await fetchPage(query);
|
||||
const list = parseLeadList(res);
|
||||
const computed = computeStatsFromLeads(list, state.period);
|
||||
state.summary = computed.summary;
|
||||
state.funnel = computed.funnel;
|
||||
state.channels = computed.channels;
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
resetStatsToEmpty(state);
|
||||
state.errorMessage = msg !== '' ? msg : resolveStatsText('error');
|
||||
} finally {
|
||||
state.loading = false;
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface WorkplaceState {
|
||||
activeCard: CardProfile | null;
|
||||
cardList: CardProfile[];
|
||||
activeQrSvg: string;
|
||||
activeShareUrl: string;
|
||||
kpis: WorkplaceKpi;
|
||||
hotRadarItem: WorkplaceRadarItem | null;
|
||||
recentLeads: LeadEntity[];
|
||||
@@ -37,6 +38,7 @@ export function createWorkplaceState(): WorkplaceState {
|
||||
activeCard: null,
|
||||
cardList: [],
|
||||
activeQrSvg: '',
|
||||
activeShareUrl: '',
|
||||
kpis: {
|
||||
tapCount: 0,
|
||||
viewCount: 0,
|
||||
@@ -58,36 +60,29 @@ export async function loadWorkplaceData(state: WorkplaceState): Promise<void> {
|
||||
|
||||
try {
|
||||
// 1. 并行获取工作台概览、名片列表、最新待跟进线索
|
||||
const overviewPromise = fetchWorkplaceOverview(5).catch((_err: any): WorkplaceOverviewResult => {
|
||||
return {
|
||||
kpis: {
|
||||
tapCount: 28,
|
||||
viewCount: 45,
|
||||
newLeadCount: 6,
|
||||
conversionRate: 42.8,
|
||||
tapDelta: 5,
|
||||
viewDelta: 12,
|
||||
newLeadDelta: 2,
|
||||
},
|
||||
radarFeed: [
|
||||
{
|
||||
id: 1,
|
||||
cardId: 1,
|
||||
cardTitle: 'APAC VP 商务名片',
|
||||
country: 'AE',
|
||||
city: 'Dubai',
|
||||
touchType: 'nfc',
|
||||
stayDurationMs: 220000,
|
||||
viewedBlocks: ['hero', 'cases', 'appointment'],
|
||||
intentScore: 92,
|
||||
createTime: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
const overviewPromise = fetchWorkplaceOverview(5).then((data: WorkplaceOverviewResult) => {
|
||||
state.radarOnline = true;
|
||||
return data;
|
||||
}).catch(() => {
|
||||
state.radarOnline = false;
|
||||
const emptyKpi: WorkplaceKpi = {
|
||||
tapCount: 0,
|
||||
viewCount: 0,
|
||||
newLeadCount: 0,
|
||||
conversionRate: 0,
|
||||
tapDelta: 0,
|
||||
viewDelta: 0,
|
||||
newLeadDelta: 0,
|
||||
};
|
||||
const emptyResult: WorkplaceOverviewResult = {
|
||||
kpis: emptyKpi,
|
||||
radarFeed: [],
|
||||
};
|
||||
return emptyResult;
|
||||
});
|
||||
|
||||
const cardsPromise = fetchOwnerCardPage({ page: 1, size: 10 }).catch((_err: any) => null);
|
||||
const leadsPromise = fetchOwnerLeadPage({ page: 1, size: 3 }).catch((_err: any) => null);
|
||||
const cardsPromise = fetchOwnerCardPage({ page: 1, size: 10 }).catch(() => null);
|
||||
const leadsPromise = fetchOwnerLeadPage({ page: 1, size: 3 }).catch(() => null);
|
||||
|
||||
const [overview, cardPage, leadPage] = await Promise.all([
|
||||
overviewPromise,
|
||||
@@ -99,70 +94,59 @@ export async function loadWorkplaceData(state: WorkplaceState): Promise<void> {
|
||||
if (overview != null && overview.kpis != null) {
|
||||
state.kpis = overview.kpis;
|
||||
}
|
||||
state.hotRadarItem = null;
|
||||
if (overview != null && overview.radarFeed != null && overview.radarFeed.length > 0) {
|
||||
state.hotRadarItem = overview.radarFeed[0];
|
||||
}
|
||||
|
||||
// 3. 赋值待跟进潜客
|
||||
if (leadPage != null && leadPage.list != null && leadPage.list.length > 0) {
|
||||
if (leadPage != null && leadPage.list != null) {
|
||||
state.recentLeads = leadPage.list;
|
||||
} else {
|
||||
state.recentLeads = [
|
||||
{
|
||||
id: 101,
|
||||
cardId: 1,
|
||||
name: 'Sarah Jenkins',
|
||||
company: 'Vertex Energy SG',
|
||||
phone: '+65 8123 4567',
|
||||
status: 'new',
|
||||
intentScore: 88,
|
||||
source: 'nfc',
|
||||
notes: '展会现场已碰卡,对新能源储能方案有深度合作意向',
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
cardId: 1,
|
||||
name: 'Ahmed Al-Mansoor',
|
||||
company: 'Oasis Green Holdings',
|
||||
email: 'ahmed@oasisgreen.ae',
|
||||
status: 'contacted',
|
||||
intentScore: 95,
|
||||
source: 'qr',
|
||||
notes: '已发送产品白皮书与报价单,等待确认试用样品',
|
||||
},
|
||||
];
|
||||
state.recentLeads = [];
|
||||
}
|
||||
|
||||
// 4. 处理名片与二维码展台
|
||||
if (cardPage != null && cardPage.list != null && cardPage.list.length > 0) {
|
||||
state.cardList = cardPage.list;
|
||||
const published = cardPage.list.find((c: CardProfile) => c.status === 'published');
|
||||
state.activeCard = published != null ? published : cardPage.list[0];
|
||||
} else {
|
||||
state.activeCard = {
|
||||
id: 1,
|
||||
name: 'Kenji Tanaka',
|
||||
title: 'APAC Logistics VP',
|
||||
company: 'TransPacific AutoLink',
|
||||
status: 'published',
|
||||
url: 'https://omnicard.cc/kenji-tanaka',
|
||||
};
|
||||
state.cardList = [state.activeCard!];
|
||||
state.activeCard = null;
|
||||
state.cardList = [];
|
||||
state.activeQrSvg = '';
|
||||
state.activeShareUrl = '';
|
||||
}
|
||||
|
||||
if (state.activeCard != null) {
|
||||
state.activeQrSvg = '';
|
||||
state.activeShareUrl = '';
|
||||
try {
|
||||
const slugOrId = state.activeCard!.slug || String(state.activeCard!.id);
|
||||
const qrRes = await fetchCardQrcodeSvg(slugOrId);
|
||||
if (qrRes != null && qrRes.svg != null) {
|
||||
if (qrRes != null && qrRes.svg != null && qrRes.svg !== '') {
|
||||
state.activeQrSvg = qrRes.svg;
|
||||
} else {
|
||||
state.activeQrSvg = '';
|
||||
}
|
||||
if (qrRes != null && qrRes.url != null && qrRes.url !== '') {
|
||||
state.activeShareUrl = qrRes.url;
|
||||
} else if (state.activeCard!.url != null && state.activeCard!.url !== '') {
|
||||
state.activeShareUrl = state.activeCard!.url!;
|
||||
}
|
||||
} catch (_e) {
|
||||
// 二维码后备
|
||||
state.activeQrSvg = '';
|
||||
state.activeShareUrl = '';
|
||||
}
|
||||
} else {
|
||||
state.activeQrSvg = '';
|
||||
state.activeShareUrl = '';
|
||||
}
|
||||
} catch (err: any) {
|
||||
state.errorMessage = err?.message != null ? String(err.message) : '加载工作台数据异常';
|
||||
if (state.activeCard == null && state.recentLeads.length === 0 && state.radarOnline === false) {
|
||||
state.errorMessage = resolveWorkplaceText('workplace.load_error');
|
||||
}
|
||||
} catch (_err) {
|
||||
state.errorMessage = resolveWorkplaceText('workplace.load_error');
|
||||
state.activeQrSvg = '';
|
||||
state.activeShareUrl = '';
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
@@ -304,6 +288,38 @@ export function resolveWorkplaceText(key: string, locale?: string): string {
|
||||
zh: '出示名片码',
|
||||
en: 'Show Card QR',
|
||||
},
|
||||
'workplace.qr_empty': {
|
||||
zh: '暂无二维码',
|
||||
en: 'QR code unavailable',
|
||||
},
|
||||
'workplace.empty_card': {
|
||||
zh: '还没有名片',
|
||||
en: 'No card yet',
|
||||
},
|
||||
'workplace.empty_leads': {
|
||||
zh: '今天还没有要跟进的线索',
|
||||
en: 'No leads to follow up',
|
||||
},
|
||||
'workplace.empty_radar': {
|
||||
zh: '还没有新的访客',
|
||||
en: 'No new visitors',
|
||||
},
|
||||
'workplace.switch_card': {
|
||||
zh: '切换',
|
||||
en: 'Switch',
|
||||
},
|
||||
'workplace.published': {
|
||||
zh: '已发布',
|
||||
en: 'Published',
|
||||
},
|
||||
'workplace.load_error': {
|
||||
zh: '工作台暂时没加载出来',
|
||||
en: 'Workplace could not be loaded',
|
||||
},
|
||||
'workplace.score': {
|
||||
zh: '意向',
|
||||
en: 'Intent',
|
||||
},
|
||||
};
|
||||
|
||||
const item = dict[key];
|
||||
|
||||
@@ -64,43 +64,43 @@
|
||||
<view
|
||||
v-if="item.type === 'lead_card' && item.cardPayload != null"
|
||||
class="ai-lead-card"
|
||||
:class="{ 'ai-lead-card-submitted': item.cardPayload.submitted }"
|
||||
:class="{ 'ai-lead-card-submitted': item.cardPayload!.submitted }"
|
||||
>
|
||||
<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>
|
||||
<text class="ai-lead-card-title">{{ item.cardPayload!.title }}</text>
|
||||
</view>
|
||||
<text class="ai-lead-card-desc">{{ item.cardPayload.description }}</text>
|
||||
<text class="ai-lead-card-desc">{{ item.cardPayload!.description }}</text>
|
||||
|
||||
<view v-if="!item.cardPayload.submitted" class="ai-lead-card-form">
|
||||
<view v-if="!item.cardPayload!.submitted" class="ai-lead-card-form">
|
||||
<view class="ai-lead-field">
|
||||
<text class="ai-lead-label">{{ t('nameLabel') }}</text>
|
||||
<input
|
||||
class="ai-lead-input"
|
||||
v-model="item.cardPayload.name"
|
||||
v-model="item.cardPayload!.name"
|
||||
:placeholder="t('namePlaceholder')"
|
||||
:disabled="item.cardPayload.submitting"
|
||||
:disabled="item.cardPayload!.submitting"
|
||||
/>
|
||||
</view>
|
||||
<view class="ai-lead-field">
|
||||
<text class="ai-lead-label">{{ t('contactLabel') }}</text>
|
||||
<input
|
||||
class="ai-lead-input"
|
||||
v-model="item.cardPayload.contact"
|
||||
v-model="item.cardPayload!.contact"
|
||||
:placeholder="t('contactPlaceholder')"
|
||||
:disabled="item.cardPayload.submitting"
|
||||
:disabled="item.cardPayload!.submitting"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="item.cardPayload.errorMessage" class="ai-lead-error">{{ item.cardPayload.errorMessage }}</text>
|
||||
<text v-if="leadCardError(item) !== ''" class="ai-lead-error">{{ leadCardError(item) }}</text>
|
||||
<button
|
||||
class="ai-lead-submit-btn"
|
||||
:class="{ 'ai-lead-submit-btn-disabled': !canSubmitCard(item.cardPayload) || item.cardPayload.submitting }"
|
||||
:disabled="!canSubmitCard(item.cardPayload) || item.cardPayload.submitting"
|
||||
@click="handleSubmitCard(item.cardPayload)"
|
||||
:class="{ 'ai-lead-submit-btn-disabled': !canSubmitCard(item.cardPayload!) || item.cardPayload!.submitting }"
|
||||
:disabled="!canSubmitCard(item.cardPayload!) || item.cardPayload!.submitting"
|
||||
@click="handleSubmitCard(item.cardPayload!)"
|
||||
>
|
||||
<text class="ai-lead-submit-btn-text">{{ cardBtnText(item.cardPayload) }}</text>
|
||||
<text class="ai-lead-submit-btn-text">{{ cardBtnText(item.cardPayload!) }}</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
@@ -172,6 +172,7 @@ import {
|
||||
AiChatCopyKey,
|
||||
AiChatPageOptions,
|
||||
ChatLeadPayload,
|
||||
ChatMessage,
|
||||
} from './useAiChat.uts';
|
||||
|
||||
const pageTheme: Record<string, string> = {
|
||||
@@ -228,6 +229,14 @@ function t(key: AiChatCopyKey): string {
|
||||
return resolveAiChatText(key);
|
||||
}
|
||||
|
||||
function leadCardError(item: ChatMessage): string {
|
||||
if (item.cardPayload == null) {
|
||||
return '';
|
||||
}
|
||||
const message = item.cardPayload.errorMessage;
|
||||
return message != null ? message : '';
|
||||
}
|
||||
|
||||
function canSubmitCard(payload: ChatLeadPayload | null): boolean {
|
||||
if (payload == null) {
|
||||
return false;
|
||||
|
||||
@@ -44,8 +44,9 @@
|
||||
</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>
|
||||
<!-- 空态返回为描边次按钮,避免与内容态底栏实心主 CTA 并存第二颗实心主按钮 -->
|
||||
<button class="empty-back-btn article-empty-cta" @click="handleBack">
|
||||
<text class="empty-back-btn-text">{{ backText }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
@@ -149,7 +150,7 @@ import {
|
||||
createArticleDetailState,
|
||||
applyArticleDetailOptions,
|
||||
loadArticleDetail,
|
||||
copyArticleShareLink,
|
||||
performArticleShareAction,
|
||||
goBackFromArticle,
|
||||
resolveArticleDetailText,
|
||||
getCurrentPageUrl,
|
||||
@@ -277,7 +278,9 @@ function handleScroll(e: ScrollCustomEvent): void {
|
||||
}
|
||||
|
||||
async function handleCopy(): Promise<void> {
|
||||
copyArticleShareLink(state, getCurrentPageUrl(state));
|
||||
// 有系统分享 API 时走 shareArticleViaSystem(仅 success 提示 shareSuccess);
|
||||
// 否则 P22 复制链接(成功提示直链已复制)。失败/取消不提示 shareSuccess。
|
||||
await performArticleShareAction(state, getCurrentPageUrl(state));
|
||||
}
|
||||
|
||||
function handleBack(): void {
|
||||
@@ -526,6 +529,32 @@ onUnload(() => {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.empty-back-btn {
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: var(--oc-border, #EBF1F7);
|
||||
border-radius: var(--oc-radius-btn, 14px);
|
||||
background-color: var(--oc-surface, #FFFFFF);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-back-btn:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.empty-back-btn-text {
|
||||
color: var(--oc-text, #101828);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 滚动容器 */
|
||||
.article-scroll {
|
||||
flex: 1;
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<view class="case-header-card">
|
||||
<view class="case-badge-pill">
|
||||
<view class="case-badge-dot"></view>
|
||||
<text class="case-badge-text">{{ t('badge') }}</text>
|
||||
<text class="case-badge-text">{{ headerBadgeText }}</text>
|
||||
</view>
|
||||
<text class="case-title">{{ state.title }}</text>
|
||||
|
||||
@@ -58,8 +58,19 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 2. 多图画廊轮播 / 单图视觉展台 -->
|
||||
<view v-if="state.images.length > 0" class="gallery-wrapper">
|
||||
<!-- 2. 原生视频播放器展台 (若配置视频直链) -->
|
||||
<view v-if="state.videoUrl !== ''" class="case-video-wrapper">
|
||||
<video
|
||||
class="case-video-player"
|
||||
:src="state.videoUrl"
|
||||
:poster="state.images.length > 0 ? state.images[0] : ''"
|
||||
:controls="true"
|
||||
preload="metadata"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 3. 多图画廊轮播 / 单图视觉展台 (无视频时或图集展示) -->
|
||||
<view v-if="state.images.length > 0 && state.videoUrl === ''" class="gallery-wrapper">
|
||||
<swiper
|
||||
v-if="state.images.length > 1"
|
||||
class="case-swiper"
|
||||
@@ -142,40 +153,34 @@
|
||||
</view>
|
||||
</view>
|
||||
<view class="resource-list">
|
||||
<!-- PDF 白皮书外链行 -->
|
||||
<!-- PDF 白皮书:有 url 才渲染可点行,不露裸 URL -->
|
||||
<view
|
||||
v-if="state.pdfUrl !== ''"
|
||||
class="ext-link-row"
|
||||
@click="handleCopyLink(state.pdfUrl)"
|
||||
@click="handleOpenLink(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>
|
||||
<text class="ext-link-action">{{ t('pdfAction') }}</text>
|
||||
</view>
|
||||
<view class="ext-arrow-box">
|
||||
<text class="ext-arrow-icon">↗</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 演示视频外链行 -->
|
||||
<!-- YouTube/Vimeo:有 url 才渲染可点行,不露裸 URL -->
|
||||
<view
|
||||
v-if="state.youtubeUrl !== ''"
|
||||
class="ext-link-row"
|
||||
@click="handleCopyLink(state.youtubeUrl)"
|
||||
@click="handleOpenLink(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>
|
||||
<text class="ext-link-action">{{ t('videoAction') }}</text>
|
||||
</view>
|
||||
<view class="ext-arrow-box">
|
||||
<text class="ext-arrow-icon">↗</text>
|
||||
@@ -209,7 +214,7 @@ import {
|
||||
loadCaseDetail,
|
||||
openCaseExchange,
|
||||
goCaseBack,
|
||||
copyCaseExternalLink,
|
||||
openCaseExternalLink,
|
||||
resolveCaseDetailText,
|
||||
CaseDetailPageOptions,
|
||||
} from './useCaseDetail.uts';
|
||||
@@ -251,6 +256,10 @@ const pageRtlClass = computed((): string => {
|
||||
return isRtl(getCurrentLocale()) ? 'case-rtl' : '';
|
||||
});
|
||||
|
||||
const headerBadgeText = computed((): string => {
|
||||
return state.blockType === 'video' ? t('videoBadge') : t('badge');
|
||||
});
|
||||
|
||||
const hasMetaTags = computed((): boolean => {
|
||||
return (
|
||||
(state.client != null && state.client !== '') ||
|
||||
@@ -283,8 +292,8 @@ function handleBack(): void {
|
||||
goCaseBack();
|
||||
}
|
||||
|
||||
function handleCopyLink(url: string): void {
|
||||
copyCaseExternalLink(url);
|
||||
function handleOpenLink(url: string): void {
|
||||
openCaseExternalLink(url);
|
||||
}
|
||||
|
||||
onLoad((options: CaseDetailPageOptions) => {
|
||||
@@ -428,6 +437,23 @@ onLoad((options: CaseDetailPageOptions) => {
|
||||
color: var(--oc-text, #101828);
|
||||
}
|
||||
|
||||
/* 原生视频演示展台 */
|
||||
.case-video-wrapper {
|
||||
margin-block-end: var(--oc-space-16, 16px);
|
||||
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));
|
||||
background-color: #000000;
|
||||
}
|
||||
|
||||
.case-video-player {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
display: block;
|
||||
background-color: #000000;
|
||||
}
|
||||
|
||||
/* 2. 多图画廊展台 */
|
||||
.gallery-wrapper {
|
||||
margin-block-end: var(--oc-space-16, 16px);
|
||||
@@ -564,7 +590,8 @@ onLoad((options: CaseDetailPageOptions) => {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 52px;
|
||||
min-height: 44px;
|
||||
height: 48px;
|
||||
background-color: var(--oc-bg, #F5F8FC);
|
||||
border: 1px solid var(--oc-border, #EBF1F7);
|
||||
border-radius: var(--oc-radius-btn, 14px);
|
||||
@@ -615,23 +642,11 @@ onLoad((options: CaseDetailPageOptions) => {
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.ext-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ext-link-action {
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--oc-text, #101828);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ext-link-url {
|
||||
font-size: 11px;
|
||||
color: var(--oc-text-2, #64748B);
|
||||
color: var(--oc-primary, #0088FF);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -262,6 +262,7 @@ import {
|
||||
createExchangeState,
|
||||
applyExchangeOptions,
|
||||
submitExchange,
|
||||
isExchangeSubmitSuccess,
|
||||
resolveExchangeText,
|
||||
ExchangePageOptions,
|
||||
} from './useExchange.uts';
|
||||
@@ -396,7 +397,8 @@ async function handleSubmit(): Promise<void> {
|
||||
const nameCache = state.form.name;
|
||||
const contactCache = state.form.phone !== '' ? state.form.phone : state.form.email;
|
||||
const lead = await submitExchange(state);
|
||||
if (lead != null) {
|
||||
// 仅接口明确成功(含 persisted===true 或有效落库 id)才展示完成态
|
||||
if (isExchangeSubmitSuccess(lead)) {
|
||||
submittedName.value = nameCache;
|
||||
submittedContact.value = contactCache;
|
||||
submitSuccess.value = true;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- 顶部受控局部极光微光晕 (面积 < 8%) -->
|
||||
<view class="ambient-aurora-glow"></view>
|
||||
|
||||
<!-- 顶栏:返回按钮、名片主预览微标签、多语言与主客态操作 -->
|
||||
<!-- 顶栏:返回/预览在左;语言切换常驻右上,不进二级菜单 -->
|
||||
<view class="locale-bar portal-top-bar">
|
||||
<view class="top-bar-left">
|
||||
<view
|
||||
@@ -18,17 +18,19 @@
|
||||
<view class="preview-dot"></view>
|
||||
<text class="preview-badge-text">{{ previewModeLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="top-bar-left">
|
||||
<view class="locale-exchange-wrap" @click="handleHeaderAction">
|
||||
<text class="locale-exchange-link">{{ headerLinkText }}</text>
|
||||
<text class="locale-exchange-arrow">↗</text>
|
||||
</view>
|
||||
<view class="locale-selector-container">
|
||||
<i18n-selector :model-value="currentLang" @change="switchLang" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="locale-exchange-wrap" @click="handleHeaderAction">
|
||||
<text class="locale-exchange-link">{{ headerLinkText }}</text>
|
||||
<text class="locale-exchange-arrow">↗</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="portal-scroll" :scroll-y="true">
|
||||
<scroll-view class="portal-scroll" :scroll-y="true" @scroll="handlePortalScroll">
|
||||
<!-- 骨架加载态 -->
|
||||
<view v-if="state.loading" class="portal-skeleton">
|
||||
<view class="skeleton-hero">
|
||||
@@ -108,53 +110,67 @@
|
||||
<!-- 2. 快捷动作条 (触控区 >= 44x44,无链接不渲染) -->
|
||||
<view v-if="hasAnyQuickAction" class="quick-action-bar">
|
||||
<view v-if="hasQuickAction('whatsapp')" class="action-item" @click="handleAction('whatsapp')">
|
||||
<view class="action-icon-box">
|
||||
<text class="action-icon-text">WA</text>
|
||||
<view class="action-icon-box action-icon-whatsapp">
|
||||
<image class="action-icon-img" src="/static/icons/whatsapp.svg" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="action-item-label">{{ textOf('portal.whatsapp') }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="hasQuickAction('linkedin')" class="action-item" @click="handleAction('linkedin')">
|
||||
<view class="action-icon-box">
|
||||
<text class="action-icon-text">IN</text>
|
||||
<view class="action-icon-box action-icon-linkedin">
|
||||
<image class="action-icon-img" src="/static/icons/linkedin.svg" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="action-item-label">{{ textOf('portal.linkedin') }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="hasQuickAction('email')" class="action-item" @click="handleAction('email')">
|
||||
<view class="action-icon-box">
|
||||
<text class="action-icon-text">@</text>
|
||||
<view class="action-icon-box action-icon-email">
|
||||
<image class="action-icon-img" src="/static/icons/email.svg" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="action-item-label">{{ textOf('portal.email') }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="hasQuickAction('phone')" class="action-item" @click="handleAction('phone')">
|
||||
<view class="action-icon-box">
|
||||
<text class="action-icon-text">TEL</text>
|
||||
<view class="action-icon-box action-icon-phone">
|
||||
<image class="action-icon-img" src="/static/icons/phone.svg" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="action-item-label">{{ textOf('portal.phone') }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="hasQuickAction('telegram')" class="action-item" @click="handleAction('telegram')">
|
||||
<view class="action-icon-box">
|
||||
<text class="action-icon-text">TG</text>
|
||||
<view class="action-icon-box action-icon-telegram">
|
||||
<image class="action-icon-img" src="/static/icons/telegram.svg" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="action-item-label">{{ textOf('portal.telegram') }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="hasQuickAction('twitter')" class="action-item" @click="handleAction('twitter')">
|
||||
<view class="action-icon-box">
|
||||
<text class="action-icon-text">X</text>
|
||||
<view class="action-icon-box action-icon-twitter">
|
||||
<image class="action-icon-img" src="/static/icons/twitter.svg" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="action-item-label">{{ textOf('portal.twitter') }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="hasQuickAction('wechat')" class="action-item" @click="handleAction('wechat')">
|
||||
<view class="action-icon-box">
|
||||
<text class="action-icon-text">WX</text>
|
||||
<view class="action-icon-box action-icon-wechat">
|
||||
<image class="action-icon-img" src="/static/icons/wechat.svg" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="action-item-label">{{ textOf('portal.wechat') }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="hasQuickAction('line')" class="action-item" @click="handleAction('line')">
|
||||
<view class="action-icon-box action-icon-line">
|
||||
<image class="action-icon-img" src="/static/icons/line.svg" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="action-item-label">{{ textOf('portal.line') }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="hasQuickAction('kakao')" class="action-item" @click="handleAction('kakao')">
|
||||
<view class="action-icon-box action-icon-kakao">
|
||||
<image class="action-icon-img" src="/static/icons/kakao.svg" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="action-item-label">{{ textOf('portal.kakao') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 3. Bento Grid 网格展厅 -->
|
||||
@@ -235,6 +251,19 @@
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- Cookie 同意条:无本地同意记录时仅显示一条 -->
|
||||
<view v-if="showCookieBanner" class="cookie-consent-bar" :class="pageRtlClass">
|
||||
<text class="cookie-consent-text">{{ textOf('portal.cookie_notice') }}</text>
|
||||
<view class="cookie-consent-actions">
|
||||
<view class="cookie-consent-reject" role="button" @click="rejectCookieConsent">
|
||||
<text class="cookie-consent-reject-text">{{ textOf('portal.cookie_reject') }}</text>
|
||||
</view>
|
||||
<view class="cookie-consent-accept" role="button" @click="acceptCookieConsent">
|
||||
<text class="cookie-consent-accept-text">{{ textOf('portal.cookie_accept') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 6. 商务预约与需求提报抽屉 (Bottom Sheet) -->
|
||||
<view v-if="isAppointmentSheetOpen" class="appointment-sheet-mask" @click="closeAppointmentSheet">
|
||||
<view class="appointment-sheet" :class="pageRtlClass" @click.stop>
|
||||
@@ -362,12 +391,14 @@
|
||||
<script setup lang="uts">
|
||||
import {
|
||||
BentoBlockPublic,
|
||||
CardPortalDetail,
|
||||
CardProfile,
|
||||
} from '../../common/types.uts';
|
||||
import {
|
||||
createPortalState,
|
||||
loadCardPortal,
|
||||
reportPortalStay,
|
||||
updatePortalScrollDepth,
|
||||
toggleAudioPronunciation,
|
||||
triggerQuickAction,
|
||||
getBentoColSpanClass,
|
||||
@@ -383,6 +414,7 @@ import {
|
||||
executePortalGoBack,
|
||||
trackAppointmentAction,
|
||||
PortalQuickAction,
|
||||
isRenderableHttpUrl,
|
||||
} from './usePortal.uts';
|
||||
import { saveToContacts } from '../../common/vcard-export.uts';
|
||||
import { collectHallContact, submitLead } from '../../common/api.uts';
|
||||
@@ -433,6 +465,8 @@ const pageTheme: Record<string, string> = {
|
||||
};
|
||||
|
||||
const defaultAvatar = 'https://cdn.omnicard.ai/assets/default-avatar.png';
|
||||
const COOKIE_CONSENT_KEY = 'omni_cookie_consent';
|
||||
const COOKIE_CONSENT_ACCEPTED = 'accepted';
|
||||
|
||||
type PortalPageQuery = {
|
||||
id?: string;
|
||||
@@ -447,6 +481,40 @@ const state = reactive(createPortalState());
|
||||
const currentCardId = ref('');
|
||||
const currentMode = ref('');
|
||||
const currentLang = ref<OmniLocale>(detectLocale());
|
||||
const showCookieBanner = ref(false);
|
||||
|
||||
function readCookieConsentRaw(): string {
|
||||
try {
|
||||
const raw = uni.getStorageSync(COOKIE_CONSENT_KEY);
|
||||
if (raw == null) {
|
||||
return '';
|
||||
}
|
||||
return String(raw);
|
||||
} catch (_e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function hasCookieConsentRecord(): boolean {
|
||||
return readCookieConsentRaw() === COOKIE_CONSENT_ACCEPTED;
|
||||
}
|
||||
|
||||
function initCookieBanner(): void {
|
||||
// 无同意记录时显示;有 accepted 记录才视为已同意,绝不假装已同意
|
||||
showCookieBanner.value = !hasCookieConsentRecord();
|
||||
}
|
||||
|
||||
function acceptCookieConsent(): void {
|
||||
try {
|
||||
uni.setStorageSync(COOKIE_CONSENT_KEY, COOKIE_CONSENT_ACCEPTED);
|
||||
} catch (_e) {}
|
||||
showCookieBanner.value = false;
|
||||
}
|
||||
|
||||
function rejectCookieConsent(): void {
|
||||
// 拒绝:不写入同意记录,不把拒绝当成已同意;仅收起当前会话条
|
||||
showCookieBanner.value = false;
|
||||
}
|
||||
|
||||
const isOwner = computed((): boolean => {
|
||||
return checkIsCardOwner({
|
||||
@@ -538,6 +606,11 @@ function getBlockTypeLabel(type: string): string {
|
||||
return textOf('portal.block');
|
||||
}
|
||||
|
||||
type PortalCardSocialExtra = CardPortalDetail & {
|
||||
line?: string;
|
||||
kakao?: string;
|
||||
};
|
||||
|
||||
function readQuickActionValue(type: PortalQuickAction): string {
|
||||
if (state.card == null) {
|
||||
return '';
|
||||
@@ -563,11 +636,27 @@ function readQuickActionValue(type: PortalQuickAction): string {
|
||||
if (type === 'wechat') {
|
||||
return state.card!.wechat || '';
|
||||
}
|
||||
if (type === 'line') {
|
||||
const extra = state.card! as PortalCardSocialExtra;
|
||||
return extra.line != null ? extra.line : '';
|
||||
}
|
||||
if (type === 'kakao') {
|
||||
const extra = state.card! as PortalCardSocialExtra;
|
||||
return extra.kakao != null ? extra.kakao : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function hasQuickAction(type: PortalQuickAction): boolean {
|
||||
return readQuickActionValue(type).trim() !== '';
|
||||
const raw = readQuickActionValue(type).trim();
|
||||
if (raw === '') {
|
||||
return false;
|
||||
}
|
||||
// Line / KakaoTalk:空、缺失或非 http(s) 均不渲染(P35 空社交不渲染)
|
||||
if (type === 'line' || type === 'kakao') {
|
||||
return isRenderableHttpUrl(raw);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasAnyQuickAction = computed((): boolean => {
|
||||
@@ -578,7 +667,9 @@ const hasAnyQuickAction = computed((): boolean => {
|
||||
hasQuickAction('phone') ||
|
||||
hasQuickAction('telegram') ||
|
||||
hasQuickAction('twitter') ||
|
||||
hasQuickAction('wechat')
|
||||
hasQuickAction('wechat') ||
|
||||
hasQuickAction('line') ||
|
||||
hasQuickAction('kakao')
|
||||
);
|
||||
});
|
||||
|
||||
@@ -789,7 +880,7 @@ async function submitAppointmentForm(): Promise<void> {
|
||||
function handleBlockClick(block: BentoBlockPublic): void {
|
||||
const blockId = block.id != null ? String(block.id) : '';
|
||||
const cardQuery = currentCardId.value;
|
||||
if (block.type === 'case') {
|
||||
if (block.type === 'case' || block.type === 'video') {
|
||||
uni.navigateTo({
|
||||
url: `/pages/portal/case-detail?cardId=${cardQuery}&blockId=${blockId}`,
|
||||
});
|
||||
@@ -854,6 +945,7 @@ onLoad((options: PortalPageQuery) => {
|
||||
const queryMode = (options && options.mode) ? String(options.mode) : '';
|
||||
currentCardId.value = queryId;
|
||||
currentMode.value = queryMode;
|
||||
initCookieBanner();
|
||||
const stored = readStoredPortalLocale();
|
||||
applyResolvedLocale(resolvePortalLocale(queryLang, stored, '', detectLocale()));
|
||||
loadCardPortal(state, queryId, querySource).then((cardData) => {
|
||||
@@ -862,8 +954,23 @@ onLoad((options: PortalPageQuery) => {
|
||||
});
|
||||
});
|
||||
|
||||
type PortalScrollDetail = {
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
};
|
||||
|
||||
type PortalScrollEvent = {
|
||||
detail: PortalScrollDetail | null;
|
||||
};
|
||||
|
||||
function handlePortalScroll(e: PortalScrollEvent): void {
|
||||
if (e != null && e.detail != null) {
|
||||
updatePortalScrollDepth(state, e.detail.scrollTop, e.detail.scrollHeight, 667);
|
||||
}
|
||||
}
|
||||
|
||||
onUnload(() => {
|
||||
reportPortalStay(state, 100);
|
||||
reportPortalStay(state, state.scrollDepth);
|
||||
releaseAudioPlayer();
|
||||
});
|
||||
</script>
|
||||
@@ -1294,15 +1401,46 @@ onUnload(() => {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.action-icon-box {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 9px;
|
||||
background-color: var(--oc-primary-soft, #EBF5FF);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-block-end: 4px;
|
||||
}
|
||||
.action-icon-img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.action-icon-whatsapp {
|
||||
background-color: #E8F9EE;
|
||||
}
|
||||
.action-icon-linkedin {
|
||||
background-color: #E6F0FA;
|
||||
}
|
||||
.action-icon-email {
|
||||
background-color: #EBF5FF;
|
||||
}
|
||||
.action-icon-phone {
|
||||
background-color: #E6F8EF;
|
||||
}
|
||||
.action-icon-telegram {
|
||||
background-color: #E6F5FC;
|
||||
}
|
||||
.action-icon-twitter {
|
||||
background-color: #F2F4F7;
|
||||
}
|
||||
.action-icon-wechat {
|
||||
background-color: #E6F8EE;
|
||||
}
|
||||
.action-icon-line {
|
||||
background-color: #E6F8EE;
|
||||
}
|
||||
.action-icon-kakao {
|
||||
background-color: #FFFBE6;
|
||||
}
|
||||
.action-icon-text {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
@@ -1474,6 +1612,67 @@ onUnload(() => {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Cookie 同意条 */
|
||||
.cookie-consent-bar {
|
||||
position: fixed;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: calc(72px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px));
|
||||
z-index: 120;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background-color: rgba(255, 255, 255, 0.98);
|
||||
border: 1px solid var(--oc-border, #EBF1F7);
|
||||
border-radius: var(--oc-radius-card, 20px);
|
||||
box-shadow: var(--oc-shadow-float, 0 12px 36px rgba(16, 24, 40, 0.08));
|
||||
}
|
||||
.cookie-consent-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--oc-text-2, #64748B);
|
||||
}
|
||||
.cookie-consent-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.cookie-consent-reject {
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
border-radius: var(--oc-radius-pill, 9999px);
|
||||
border: 1px solid var(--oc-border, #EBF1F7);
|
||||
background-color: var(--oc-bg, #F5F8FC);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cookie-consent-reject-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--oc-text-2, #64748B);
|
||||
}
|
||||
.cookie-consent-accept {
|
||||
min-height: 40px;
|
||||
padding: 0 16px;
|
||||
border-radius: var(--oc-radius-pill, 9999px);
|
||||
background: var(--oc-primary-gradient, linear-gradient(135deg, #00A3FF 0%, #0B63E5 100%));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--oc-shadow-cta, 0 8px 20px rgba(0, 136, 255, 0.22));
|
||||
}
|
||||
.cookie-consent-accept-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* 6. 商务预约排期卡片与抽屉样式 (Bento Grid Appointment & Bottom Sheet) */
|
||||
.appointment-card {
|
||||
background: linear-gradient(135deg, #FFFFFF 0%, #F5F9FF 100%);
|
||||
|
||||
@@ -52,7 +52,6 @@ export type ArticleDetailState = {
|
||||
visitorId: string;
|
||||
};
|
||||
|
||||
const SHARE_CTA_LABEL = '复制链接';
|
||||
const BACK_LABEL = '返回';
|
||||
const OG_DESC_MAX = 160;
|
||||
const VISITOR_STORAGE_KEY = 'omni_visitor_id';
|
||||
@@ -227,7 +226,10 @@ type UniStackPage = {
|
||||
*/
|
||||
export function getCurrentPageUrl(state: ArticleDetailState): string {
|
||||
try {
|
||||
const pages = uni.getCurrentPages();
|
||||
if (typeof getCurrentPages !== 'function') {
|
||||
return buildArticlePagePath(state.cardId, state.blockId);
|
||||
}
|
||||
const pages = getCurrentPages();
|
||||
if (pages != null && pages.length > 0) {
|
||||
const page = pages[pages.length - 1] as UniStackPage;
|
||||
const route = page != null && page.route != null ? String(page.route) : '';
|
||||
@@ -250,7 +252,7 @@ export function getArticleShareFields(
|
||||
): ArticleShareFields {
|
||||
const og = state.openGraph;
|
||||
return {
|
||||
ctaLabel: SHARE_CTA_LABEL,
|
||||
ctaLabel: resolveArticleDetailText('shareCta'),
|
||||
copiedToast: getOmniText(getCurrentLocale(), 'portal.copied_link'),
|
||||
shareUrl: resolveShareUrl(state.linkUrl, pageUrl),
|
||||
title: og.title,
|
||||
@@ -270,6 +272,9 @@ export function getArticleEmptyMessage(): string {
|
||||
export type ArticleDetailTextKey =
|
||||
| 'kicker'
|
||||
| 'shareCta'
|
||||
| 'shareSuccess'
|
||||
| 'shareFailed'
|
||||
| 'copyFailed'
|
||||
| 'back'
|
||||
| 'empty'
|
||||
| 'emptyHint'
|
||||
@@ -313,6 +318,15 @@ export function resolveArticleDetailText(
|
||||
if (key === 'shareCta') {
|
||||
return '复制分享链接';
|
||||
}
|
||||
if (key === 'shareSuccess') {
|
||||
return '已分享';
|
||||
}
|
||||
if (key === 'shareFailed') {
|
||||
return '分享失败';
|
||||
}
|
||||
if (key === 'copyFailed') {
|
||||
return '复制失败';
|
||||
}
|
||||
if (key === 'back') {
|
||||
return '返回';
|
||||
}
|
||||
@@ -341,6 +355,15 @@ export function resolveArticleDetailText(
|
||||
if (key === 'shareCta') {
|
||||
return 'نسخ رابط المشاركة';
|
||||
}
|
||||
if (key === 'shareSuccess') {
|
||||
return 'تمت المشاركة';
|
||||
}
|
||||
if (key === 'shareFailed') {
|
||||
return 'فشلت المشاركة';
|
||||
}
|
||||
if (key === 'copyFailed') {
|
||||
return 'فشل النسخ';
|
||||
}
|
||||
if (key === 'back') {
|
||||
return 'رجوع';
|
||||
}
|
||||
@@ -369,6 +392,15 @@ export function resolveArticleDetailText(
|
||||
if (key === 'shareCta') {
|
||||
return 'Copiar enlace para compartir';
|
||||
}
|
||||
if (key === 'shareSuccess') {
|
||||
return 'Compartido';
|
||||
}
|
||||
if (key === 'shareFailed') {
|
||||
return 'Error al compartir';
|
||||
}
|
||||
if (key === 'copyFailed') {
|
||||
return 'Error al copiar';
|
||||
}
|
||||
if (key === 'back') {
|
||||
return 'Volver';
|
||||
}
|
||||
@@ -397,6 +429,15 @@ export function resolveArticleDetailText(
|
||||
if (key === 'shareCta') {
|
||||
return 'Freigabelink kopieren';
|
||||
}
|
||||
if (key === 'shareSuccess') {
|
||||
return 'Geteilt';
|
||||
}
|
||||
if (key === 'shareFailed') {
|
||||
return 'Teilen fehlgeschlagen';
|
||||
}
|
||||
if (key === 'copyFailed') {
|
||||
return 'Kopieren fehlgeschlagen';
|
||||
}
|
||||
if (key === 'back') {
|
||||
return 'Zurück';
|
||||
}
|
||||
@@ -425,6 +466,15 @@ export function resolveArticleDetailText(
|
||||
if (key === 'shareCta') {
|
||||
return 'Copier le lien de partage';
|
||||
}
|
||||
if (key === 'shareSuccess') {
|
||||
return 'Partagé';
|
||||
}
|
||||
if (key === 'shareFailed') {
|
||||
return 'Échec du partage';
|
||||
}
|
||||
if (key === 'copyFailed') {
|
||||
return 'Échec de la copie';
|
||||
}
|
||||
if (key === 'back') {
|
||||
return 'Retour';
|
||||
}
|
||||
@@ -453,6 +503,15 @@ export function resolveArticleDetailText(
|
||||
if (key === 'shareCta') {
|
||||
return '共有リンクをコピー';
|
||||
}
|
||||
if (key === 'shareSuccess') {
|
||||
return '共有しました';
|
||||
}
|
||||
if (key === 'shareFailed') {
|
||||
return '共有に失敗しました';
|
||||
}
|
||||
if (key === 'copyFailed') {
|
||||
return 'コピーに失敗しました';
|
||||
}
|
||||
if (key === 'back') {
|
||||
return '戻る';
|
||||
}
|
||||
@@ -480,6 +539,15 @@ export function resolveArticleDetailText(
|
||||
if (key === 'shareCta') {
|
||||
return 'Copy share link';
|
||||
}
|
||||
if (key === 'shareSuccess') {
|
||||
return 'Shared';
|
||||
}
|
||||
if (key === 'shareFailed') {
|
||||
return 'Share failed';
|
||||
}
|
||||
if (key === 'copyFailed') {
|
||||
return 'Copy failed';
|
||||
}
|
||||
if (key === 'back') {
|
||||
return 'Back';
|
||||
}
|
||||
@@ -641,11 +709,80 @@ export function navToAuthorPortal(state: ArticleDetailState): void {
|
||||
});
|
||||
}
|
||||
|
||||
type ShareWithSystemOption = {
|
||||
type?: string;
|
||||
summary?: string;
|
||||
href?: string;
|
||||
success?: () => void;
|
||||
fail?: (err: { errCode?: number; errMsg?: string } | null) => void;
|
||||
};
|
||||
|
||||
type UniWithSystemShare = {
|
||||
shareWithSystem?: (options: ShareWithSystemOption) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 主 CTA:复制 linkUrl 或当前页 URL
|
||||
* 是否具备系统分享 API(uni.shareWithSystem)
|
||||
*/
|
||||
export function hasArticleSystemShare(): boolean {
|
||||
const uniShare = uni as UniWithSystemShare;
|
||||
return uniShare.shareWithSystem != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统分享:仅 uni.shareWithSystem 明确 success 才提示「已分享」;
|
||||
* 失败 / 用户取消不得提示已分享。
|
||||
*/
|
||||
export function shareArticleViaSystem(
|
||||
state: ArticleDetailState,
|
||||
pageUrl?: string
|
||||
): Promise<boolean> {
|
||||
const fallback = pageUrl != null ? pageUrl : getCurrentPageUrl(state);
|
||||
const shareUrl = resolveShareUrl(state.linkUrl, fallback);
|
||||
if (shareUrl === '') {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const uniShare = uni as UniWithSystemShare;
|
||||
if (uniShare.shareWithSystem == null) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const summary = state.title !== '' ? state.title : shareUrl;
|
||||
|
||||
return new Promise<boolean>(resolve => {
|
||||
uniShare.shareWithSystem!({
|
||||
type: 'text',
|
||||
summary: summary,
|
||||
href: shareUrl,
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: resolveArticleDetailText('shareSuccess'),
|
||||
icon: 'none',
|
||||
});
|
||||
resolve(true);
|
||||
},
|
||||
fail: (err: { errCode?: number; errMsg?: string } | null) => {
|
||||
// 取消(1310600)静默;其它失败只提示 shareFailed
|
||||
const code = err != null && err.errCode != null ? err.errCode : 0;
|
||||
if (code !== 1310600) {
|
||||
uni.showToast({
|
||||
title: resolveArticleDetailText('shareFailed'),
|
||||
icon: 'none',
|
||||
});
|
||||
}
|
||||
resolve(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 主 CTA:复制 linkUrl 或当前页 URL。
|
||||
* 仅剪贴板 success 才提示直链已复制(P22);失败只提示 copyFailed。
|
||||
*/
|
||||
export function copyArticleShareLink(state: ArticleDetailState, pageUrl?: string): boolean {
|
||||
const fallback = pageUrl != null && pageUrl !== '' ? pageUrl : getCurrentPageUrl(state);
|
||||
const fallback = pageUrl != null ? pageUrl : getCurrentPageUrl(state);
|
||||
const shareUrl = resolveShareUrl(state.linkUrl, fallback);
|
||||
if (shareUrl === '') {
|
||||
return false;
|
||||
@@ -659,10 +796,30 @@ export function copyArticleShareLink(state: ArticleDetailState, pageUrl?: string
|
||||
icon: 'none',
|
||||
});
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({
|
||||
title: resolveArticleDetailText('copyFailed'),
|
||||
icon: 'none',
|
||||
});
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 底栏主动作:有系统分享则走系统分享;否则回退复制链接。
|
||||
* 分享失败/取消不回退复制,避免把失败伪装成已分享或已复制。
|
||||
*/
|
||||
export async function performArticleShareAction(
|
||||
state: ArticleDetailState,
|
||||
pageUrl?: string
|
||||
): Promise<boolean> {
|
||||
if (hasArticleSystemShare()) {
|
||||
return await shareArticleViaSystem(state, pageUrl);
|
||||
}
|
||||
return copyArticleShareLink(state, pageUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 空状态返回:优先回退上一页
|
||||
*/
|
||||
|
||||
@@ -27,6 +27,9 @@ export type CaseMetaRecord = {
|
||||
pdf?: string;
|
||||
youtubeUrl?: string;
|
||||
youtube?: string;
|
||||
videoUrl?: string;
|
||||
video?: string;
|
||||
poster?: string;
|
||||
url?: string;
|
||||
type?: string;
|
||||
client?: string;
|
||||
@@ -48,6 +51,8 @@ export type CaseDetailState = {
|
||||
kpis: CaseKpi[];
|
||||
pdfUrl: string;
|
||||
youtubeUrl: string;
|
||||
videoUrl: string;
|
||||
blockType: string;
|
||||
client?: string;
|
||||
industry?: string;
|
||||
region?: string;
|
||||
@@ -70,6 +75,7 @@ export type CaseDetailCopyKey =
|
||||
| 'youtube'
|
||||
| 'loading'
|
||||
| 'badge'
|
||||
| 'videoBadge'
|
||||
| 'overview'
|
||||
| 'challenge'
|
||||
| 'solution'
|
||||
@@ -87,16 +93,17 @@ export const CASE_DETAIL_COPY: Record<CaseDetailCopyKey, string> = {
|
||||
back: '返回',
|
||||
cta: '咨询',
|
||||
pdf: 'PDF',
|
||||
youtube: 'YouTube',
|
||||
youtube: '视频',
|
||||
loading: '加载中',
|
||||
badge: '标杆商务实践',
|
||||
videoBadge: '视频演示实况',
|
||||
overview: '案例详情与方案',
|
||||
challenge: '业务挑战与背景',
|
||||
solution: '落地解决方案',
|
||||
kpis: '核心商业化成果',
|
||||
resources: '项目资料与演示',
|
||||
pdfAction: '复制白皮书链接',
|
||||
videoAction: '复制演示视频链接',
|
||||
pdfAction: '打开白皮书',
|
||||
videoAction: '观看演示视频',
|
||||
clientLabel: '标杆客户',
|
||||
industryLabel: '行业赛道',
|
||||
regionLabel: '业务覆盖区域',
|
||||
@@ -131,6 +138,9 @@ export function resolveCaseDetailText(key: CaseDetailCopyKey): string {
|
||||
if (key === 'badge') {
|
||||
return zh ? CASE_DETAIL_COPY.badge : 'GLOBAL CASE STUDY';
|
||||
}
|
||||
if (key === 'videoBadge') {
|
||||
return zh ? CASE_DETAIL_COPY.videoBadge : 'FEATURED VIDEO DEMO';
|
||||
}
|
||||
if (key === 'overview') {
|
||||
return zh ? CASE_DETAIL_COPY.overview : 'Case Overview';
|
||||
}
|
||||
@@ -147,10 +157,16 @@ export function resolveCaseDetailText(key: CaseDetailCopyKey): string {
|
||||
return zh ? CASE_DETAIL_COPY.resources : 'Project Resources';
|
||||
}
|
||||
if (key === 'pdfAction') {
|
||||
return zh ? CASE_DETAIL_COPY.pdfAction : 'Copy Document Link';
|
||||
return zh ? CASE_DETAIL_COPY.pdfAction : 'Open whitepaper';
|
||||
}
|
||||
if (key === 'videoAction') {
|
||||
return zh ? CASE_DETAIL_COPY.videoAction : 'Copy Video Link';
|
||||
return zh ? CASE_DETAIL_COPY.videoAction : 'Watch demo video';
|
||||
}
|
||||
if (key === 'pdf') {
|
||||
return 'PDF';
|
||||
}
|
||||
if (key === 'youtube') {
|
||||
return zh ? '视频' : 'Video';
|
||||
}
|
||||
if (key === 'clientLabel') {
|
||||
return zh ? CASE_DETAIL_COPY.clientLabel : 'Client';
|
||||
@@ -165,23 +181,41 @@ export function resolveCaseDetailText(key: CaseDetailCopyKey): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 外链无 open 能力时只复制,不发起新请求
|
||||
* 打开案例外链:优先 window.open(对齐 useLogin / useLeadDetail),
|
||||
* 无 H5 open 能力时回退复制;空 url 不渲染、不打开。
|
||||
*/
|
||||
export function copyCaseExternalLink(url: string): boolean {
|
||||
const trimmed = url != null ? url.trim() : '';
|
||||
if (trimmed === '') {
|
||||
export function openCaseExternalLink(url: string): boolean {
|
||||
const safeUrl = url != null ? url.trim() : '';
|
||||
if (safeUrl === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let opened = false;
|
||||
try {
|
||||
if (typeof window !== 'undefined' && typeof window.open === 'function') {
|
||||
window.open(safeUrl, '_blank');
|
||||
opened = true;
|
||||
}
|
||||
} catch (_e) {}
|
||||
|
||||
if (opened) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
uni.setClipboardData({
|
||||
data: safeUrl,
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: getOmniText(getCurrentLocale(), 'portal.copied_link'),
|
||||
icon: 'none',
|
||||
});
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
uni.setClipboardData({
|
||||
data: trimmed,
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: getOmniText(getCurrentLocale(), 'portal.copied_link'),
|
||||
icon: 'none',
|
||||
});
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,6 +234,8 @@ export function createCaseDetailState(): CaseDetailState {
|
||||
kpis: [],
|
||||
pdfUrl: '',
|
||||
youtubeUrl: '',
|
||||
videoUrl: '',
|
||||
blockType: 'case',
|
||||
client: '',
|
||||
industry: '',
|
||||
region: '',
|
||||
@@ -223,7 +259,7 @@ export function applyCaseDetailOptions(
|
||||
}
|
||||
|
||||
/**
|
||||
* 从门户聚合数据中选出 id 匹配且 type 为 case 的块;缺块返回 null
|
||||
* 从门户聚合数据中选出 id 匹配且 type 为 case 或 video 的块;缺块返回 null
|
||||
*/
|
||||
export function pickCaseBlock(
|
||||
card: CardPortalDetail | null,
|
||||
@@ -236,7 +272,7 @@ export function pickCaseBlock(
|
||||
const blocks = card.bentoBlocks;
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i];
|
||||
if (String(block.id) === target && block.type === 'case') {
|
||||
if (String(block.id) === target && (block.type === 'case' || block.type === 'video')) {
|
||||
return block;
|
||||
}
|
||||
}
|
||||
@@ -301,6 +337,12 @@ function collectImages(mediaUrl: string, meta: CaseMetaRecord): string[] {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result.length === 0) {
|
||||
const poster = readTrimmed(meta.poster);
|
||||
if (poster !== '') {
|
||||
result.push(poster);
|
||||
}
|
||||
}
|
||||
if (result.length === 0 && mediaUrl !== '') {
|
||||
result.push(mediaUrl);
|
||||
}
|
||||
@@ -324,7 +366,25 @@ function resolvePdfUrl(meta: CaseMetaRecord): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function resolveYoutubeUrl(meta: CaseMetaRecord): string {
|
||||
function resolveVideoDirectUrl(meta: CaseMetaRecord, block: BentoBlockPublic): string {
|
||||
const metaVideo = readTrimmed(meta.videoUrl) || readTrimmed(meta.video);
|
||||
if (metaVideo !== '') {
|
||||
const lower = metaVideo.toLowerCase();
|
||||
if (lower.indexOf('youtube.com') < 0 && lower.indexOf('youtu.be') < 0) {
|
||||
return metaVideo;
|
||||
}
|
||||
}
|
||||
const link = block.linkUrl != null ? String(block.linkUrl).trim() : '';
|
||||
if (link !== '') {
|
||||
const lower = link.toLowerCase();
|
||||
if (lower.indexOf('youtube.com') < 0 && lower.indexOf('youtu.be') < 0) {
|
||||
return link;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function resolveYoutubeUrl(meta: CaseMetaRecord, block?: BentoBlockPublic): string {
|
||||
const youtubeUrl = readTrimmed(meta.youtubeUrl);
|
||||
if (youtubeUrl !== '') {
|
||||
return youtubeUrl;
|
||||
@@ -333,7 +393,7 @@ function resolveYoutubeUrl(meta: CaseMetaRecord): string {
|
||||
if (youtube !== '') {
|
||||
return youtube;
|
||||
}
|
||||
const url = readTrimmed(meta.url);
|
||||
const url = readTrimmed(meta.url) || readTrimmed(meta.videoUrl);
|
||||
const kind = readTrimmed(meta.type).toLowerCase();
|
||||
const lower = url.toLowerCase();
|
||||
if (
|
||||
@@ -342,6 +402,13 @@ function resolveYoutubeUrl(meta: CaseMetaRecord): string {
|
||||
) {
|
||||
return url;
|
||||
}
|
||||
if (block != null && block.linkUrl != null) {
|
||||
const link = String(block.linkUrl).trim();
|
||||
const linkLower = link.toLowerCase();
|
||||
if (linkLower.indexOf('youtube.com') >= 0 || linkLower.indexOf('youtu.be') >= 0) {
|
||||
return link;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -352,6 +419,8 @@ function clearCaseContent(state: CaseDetailState): void {
|
||||
state.kpis = [];
|
||||
state.pdfUrl = '';
|
||||
state.youtubeUrl = '';
|
||||
state.videoUrl = '';
|
||||
state.blockType = 'case';
|
||||
state.client = '';
|
||||
state.industry = '';
|
||||
state.region = '';
|
||||
@@ -371,13 +440,15 @@ function fillFromBlock(state: CaseDetailState, block: BentoBlockPublic): void {
|
||||
state.empty = false;
|
||||
state.loading = false;
|
||||
state.errorMessage = '';
|
||||
state.blockType = block.type != null ? String(block.type) : 'case';
|
||||
state.title = block.title != null ? block.title : '';
|
||||
state.content = block.content != null ? block.content : '';
|
||||
state.images = collectImages(mediaUrl, meta);
|
||||
const kpis = normalizeKpis(meta.kpis);
|
||||
state.kpis = kpis.length > 0 ? kpis : normalizeKpis(meta.metrics);
|
||||
state.pdfUrl = resolvePdfUrl(meta);
|
||||
state.youtubeUrl = resolveYoutubeUrl(meta);
|
||||
state.videoUrl = resolveVideoDirectUrl(meta, block);
|
||||
state.youtubeUrl = resolveYoutubeUrl(meta, block);
|
||||
state.client = readTrimmed(meta.client);
|
||||
state.industry = readTrimmed(meta.industry);
|
||||
state.region = readTrimmed(meta.region);
|
||||
|
||||
@@ -120,6 +120,24 @@ export function validateExchangeForm(form: ExchangeForm): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅接口明确成功才视为互换完成:
|
||||
* - persisted === false → 未落库,不成功
|
||||
* - persisted === true → 成功
|
||||
* - 无 persisted 字段时,须有非空 id(CRM 留资落库回执)
|
||||
*/
|
||||
export function isExchangeSubmitSuccess(lead: LeadEntity | null | Record<string, unknown>): boolean {
|
||||
if (lead == null || typeof lead !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const record = lead as Record<string, unknown>;
|
||||
if ('persisted' in record) {
|
||||
return record['persisted'] === true;
|
||||
}
|
||||
const id = record['id'];
|
||||
return id != null && String(id).trim() !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空表单字段,保留名片标识
|
||||
*/
|
||||
@@ -192,6 +210,14 @@ export async function submitExchange(state: ExchangeState): Promise<LeadEntity |
|
||||
try {
|
||||
const lead = await submitLead(payload);
|
||||
state.submitting = false;
|
||||
if (!isExchangeSubmitSuccess(lead)) {
|
||||
state.errorMessage = '交换未保存,请稍后重试';
|
||||
uni.showToast({
|
||||
title: state.errorMessage,
|
||||
icon: 'none',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
resetExchangeForm(state);
|
||||
uni.showToast({
|
||||
title: '留资已提交',
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { ExchangeForm } from './useExchange.uts';
|
||||
import { httpPost } from '../../common/request.uts';
|
||||
|
||||
export type ExtractedContactInfo = {
|
||||
name: string;
|
||||
@@ -15,6 +16,14 @@ export type ExtractedContactInfo = {
|
||||
rawText: string;
|
||||
};
|
||||
|
||||
export type OcrApiRecognizeResult = {
|
||||
name: string;
|
||||
company: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type OcrScanState = {
|
||||
scanning: boolean;
|
||||
rawText: string;
|
||||
@@ -27,6 +36,10 @@ export type HandleOcrUploadOptions = {
|
||||
mockText?: string;
|
||||
sourceType?: string[];
|
||||
overwrite?: boolean;
|
||||
httpPost?: (
|
||||
url: string,
|
||||
data?: Record<string, string>
|
||||
) => Promise<OcrApiRecognizeResult>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -526,7 +539,7 @@ export function fillFormFromOcr(
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟 OCR 识别返回预设样例名片,用于客户端无外部云 OCR 时的交互演示与离线测试
|
||||
* 模拟 OCR 识别返回预设样例名片,仅供离线演示/单测;真实上传路径不得依赖此函数
|
||||
*/
|
||||
export function simulateOcrScan(sampleType?: string): ExtractedContactInfo {
|
||||
if (sampleType === 'uae' || sampleType === 'dubai') {
|
||||
@@ -558,6 +571,87 @@ export function simulateOcrScan(sampleType?: string): ExtractedContactInfo {
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将后端 OCR 识别结果映射为本地填单结构
|
||||
*/
|
||||
export function mapOcrApiResult(apiResult: OcrApiRecognizeResult): ExtractedContactInfo {
|
||||
const name = apiResult != null && apiResult.name != null ? String(apiResult.name).trim() : '';
|
||||
const company =
|
||||
apiResult != null && apiResult.company != null ? String(apiResult.company).trim() : '';
|
||||
const phoneRaw =
|
||||
apiResult != null && apiResult.phone != null ? String(apiResult.phone).trim() : '';
|
||||
const email = apiResult != null && apiResult.email != null ? String(apiResult.email).trim() : '';
|
||||
const title = apiResult != null && apiResult.title != null ? String(apiResult.title).trim() : '';
|
||||
const phone = phoneRaw !== '' ? normalizeE164Phone(phoneRaw) : '';
|
||||
|
||||
return {
|
||||
name,
|
||||
company,
|
||||
phone,
|
||||
email,
|
||||
title,
|
||||
notes: '',
|
||||
rawText: [name, title, company, phone, email].filter((v) => v !== '').join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取本地图片为 base64;不可读时返回空串
|
||||
*/
|
||||
function readLocalImageBase64(filePath: string): Promise<string> {
|
||||
return new Promise<string>((resolve) => {
|
||||
try {
|
||||
if (typeof uni === 'undefined' || typeof uni.getFileSystemManager !== 'function') {
|
||||
resolve('');
|
||||
return;
|
||||
}
|
||||
const fs = uni.getFileSystemManager();
|
||||
if (fs == null || typeof fs.readFile !== 'function') {
|
||||
resolve('');
|
||||
return;
|
||||
}
|
||||
fs.readFile({
|
||||
filePath: filePath,
|
||||
encoding: 'base64',
|
||||
success: (res: unknown) => {
|
||||
let data = '';
|
||||
if (res != null && typeof res === 'object') {
|
||||
const r = res as Record<string, unknown>;
|
||||
if (r.data != null) {
|
||||
data = String(r.data);
|
||||
}
|
||||
}
|
||||
resolve(data);
|
||||
},
|
||||
fail: (_err: unknown) => {
|
||||
resolve('');
|
||||
},
|
||||
});
|
||||
} catch (_e) {
|
||||
resolve('');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据选图路径组装 OCR 请求体:优先 imageBase64,其次 imageUrl
|
||||
*/
|
||||
async function buildOcrRecognizeBody(
|
||||
imagePath: string
|
||||
): Promise<Record<string, string>> {
|
||||
const path = imagePath.trim();
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return { imageUrl: path };
|
||||
}
|
||||
|
||||
const base64 = await readLocalImageBase64(path);
|
||||
if (base64 !== '') {
|
||||
return { imageBase64: base64 };
|
||||
}
|
||||
|
||||
return { imageUrl: path };
|
||||
}
|
||||
|
||||
/**
|
||||
* 拍照或上传名片 OCR 识别流程处理入口
|
||||
*/
|
||||
@@ -568,11 +662,16 @@ export async function handleCardOcrUpload(
|
||||
): Promise<boolean> {
|
||||
const opt = options != null ? options : {};
|
||||
const overwrite = opt.overwrite !== undefined ? opt.overwrite : true;
|
||||
const postFn =
|
||||
opt.httpPost != null
|
||||
? opt.httpPost
|
||||
: (url: string, data?: Record<string, string>) =>
|
||||
httpPost<OcrApiRecognizeResult>(url, data);
|
||||
|
||||
ocrState.scanning = true;
|
||||
ocrState.errorMessage = '';
|
||||
|
||||
// 1. 如果传入 mockText,直接模拟完成识别 (用于测试或开发模拟)
|
||||
// 1. 如果传入 mockText,走本地 parseOcrCardText(供测试/离线模拟)
|
||||
if (opt.mockText && opt.mockText.trim() !== '') {
|
||||
const extracted = parseOcrCardText(opt.mockText);
|
||||
ocrState.rawText = opt.mockText;
|
||||
@@ -587,7 +686,7 @@ export async function handleCardOcrUpload(
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. 调用客户端拍照/选图 API
|
||||
// 2. 调用客户端拍照/选图 API,成功后请求后端 OCR
|
||||
return new Promise<boolean>((resolve) => {
|
||||
try {
|
||||
const sourceTypes = opt.sourceType != null && opt.sourceType.length > 0
|
||||
@@ -607,18 +706,32 @@ export async function handleCardOcrUpload(
|
||||
}
|
||||
ocrState.imagePath = path !== '' ? path : 'https://cdn.omnicard.ai/mock-card.jpg';
|
||||
|
||||
// 图像上传与 OCR 解析
|
||||
const sample = simulateOcrScan('cn');
|
||||
ocrState.rawText = sample.rawText;
|
||||
ocrState.lastExtracted = sample;
|
||||
fillFormFromOcr(form, sample, overwrite);
|
||||
ocrState.scanning = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const body = await buildOcrRecognizeBody(ocrState.imagePath);
|
||||
const apiResult = await postFn('/open/ai/ocr/recognize', body);
|
||||
const extracted = mapOcrApiResult(apiResult);
|
||||
ocrState.rawText = extracted.rawText;
|
||||
ocrState.lastExtracted = extracted;
|
||||
fillFormFromOcr(form, extracted, overwrite);
|
||||
ocrState.scanning = false;
|
||||
ocrState.errorMessage = '';
|
||||
|
||||
uni.showToast({
|
||||
title: '名片识别成功,已自动填入',
|
||||
icon: 'none',
|
||||
});
|
||||
resolve(true);
|
||||
uni.showToast({
|
||||
title: '名片识别成功,已自动填入',
|
||||
icon: 'none',
|
||||
});
|
||||
resolve(true);
|
||||
} catch (err: unknown) {
|
||||
ocrState.scanning = false;
|
||||
let msg = '名片识别失败';
|
||||
if (err instanceof Error && err.message !== '') {
|
||||
msg = err.message;
|
||||
}
|
||||
ocrState.errorMessage = msg;
|
||||
resolve(false);
|
||||
}
|
||||
})();
|
||||
},
|
||||
fail: (err: unknown) => {
|
||||
ocrState.scanning = false;
|
||||
|
||||
@@ -107,6 +107,7 @@ export type PortalState = {
|
||||
errorMessage: string;
|
||||
visitorId: string;
|
||||
enterTime: number;
|
||||
scrollDepth: number;
|
||||
touchType: string;
|
||||
isPlayingAudio: boolean;
|
||||
audioContext: any | null;
|
||||
@@ -145,6 +146,7 @@ export function createPortalState(): PortalState {
|
||||
errorMessage: '',
|
||||
visitorId: getOrCreateVisitorId(),
|
||||
enterTime: Date.now(),
|
||||
scrollDepth: 0,
|
||||
touchType: 'link',
|
||||
isPlayingAudio: false,
|
||||
audioContext: null,
|
||||
@@ -162,6 +164,7 @@ export async function loadCardPortal(
|
||||
state.errorMessage = '';
|
||||
state.touchType = source;
|
||||
state.enterTime = Date.now();
|
||||
state.scrollDepth = 0;
|
||||
|
||||
// 1. 优先尝试秒级读取本地缓存快照,实现首屏 0ms 呈现
|
||||
const cached: CachedPortalData | null = getCachedPortal(cardIdOrSlug);
|
||||
@@ -194,6 +197,7 @@ export async function loadCardPortal(
|
||||
touchType: state.touchType,
|
||||
stayDurationMs: 0,
|
||||
scrollDepth: 0,
|
||||
viewedBlocks: ['hero'],
|
||||
}).catch((_e: unknown) => {
|
||||
// 轨迹上报容错,不阻断主流程
|
||||
});
|
||||
@@ -217,7 +221,68 @@ export async function loadCardPortal(
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报访客停留时长与阅读深度
|
||||
* 记录门户滚动深度,只升不降
|
||||
*/
|
||||
export function updatePortalScrollDepth(
|
||||
state: PortalState,
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按真实滚动深度收录已进入视口的模块,未滚动只记名片主页
|
||||
*/
|
||||
export function collectViewedBlockKeys(
|
||||
card: CardPortalDetail | null,
|
||||
scrollDepth: number
|
||||
): string[] {
|
||||
const keys: string[] = ['hero'];
|
||||
if (card == null) {
|
||||
return keys;
|
||||
}
|
||||
const depth = Math.min(100, Math.max(0, Math.round(scrollDepth)));
|
||||
const blocks = filterVisibleBentoBlocks(card.bentoBlocks);
|
||||
const sequence: string[] = [];
|
||||
if (depth > 0) {
|
||||
sequence.push('appointment');
|
||||
}
|
||||
const visibleCount = depth <= 0 ? 0 : Math.max(1, Math.ceil((depth / 100) * Math.max(blocks.length, 1)));
|
||||
const take = Math.min(blocks.length, visibleCount);
|
||||
for (let i = 0; i < take; i++) {
|
||||
const block = blocks[i];
|
||||
const id = block.id != null ? String(block.id).trim() : '';
|
||||
if (id !== '') {
|
||||
sequence.push(id);
|
||||
} else if (block.type != null && String(block.type).trim() !== '') {
|
||||
sequence.push(String(block.type).trim());
|
||||
}
|
||||
}
|
||||
const seen: Record<string, boolean> = { hero: true };
|
||||
for (let i = 0; i < sequence.length; i++) {
|
||||
const key = sequence[i];
|
||||
if (seen[key] === true) {
|
||||
continue;
|
||||
}
|
||||
seen[key] = true;
|
||||
keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报访客停留时长与阅读深度,并带上已查看模块
|
||||
*/
|
||||
export async function reportPortalStay(
|
||||
state: PortalState,
|
||||
@@ -227,6 +292,10 @@ export async function reportPortalStay(
|
||||
return null;
|
||||
}
|
||||
|
||||
const depth = Math.min(100, Math.max(0, Math.round(scrollDepth)));
|
||||
if (depth > state.scrollDepth) {
|
||||
state.scrollDepth = depth;
|
||||
}
|
||||
const stayDurationMs = Math.max(0, Date.now() - state.enterTime);
|
||||
try {
|
||||
const track = await recordRadarTrack({
|
||||
@@ -234,7 +303,8 @@ export async function reportPortalStay(
|
||||
visitorId: state.visitorId,
|
||||
touchType: state.touchType,
|
||||
stayDurationMs,
|
||||
scrollDepth,
|
||||
scrollDepth: state.scrollDepth,
|
||||
viewedBlocks: collectViewedBlockKeys(state.card, state.scrollDepth),
|
||||
});
|
||||
return track;
|
||||
} catch (e) {
|
||||
@@ -290,7 +360,23 @@ export type PortalQuickAction =
|
||||
| 'phone'
|
||||
| 'telegram'
|
||||
| 'twitter'
|
||||
| 'wechat';
|
||||
| 'wechat'
|
||||
| 'line'
|
||||
| 'kakao';
|
||||
|
||||
/**
|
||||
* Line / KakaoTalk 等外链入口:仅合法 http(s) URL 可渲染与触发
|
||||
*/
|
||||
export function isRenderableHttpUrl(value?: string | null): boolean {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') {
|
||||
return false;
|
||||
}
|
||||
return /^https?:\/\//i.test(trimmed);
|
||||
}
|
||||
|
||||
export function triggerQuickAction(
|
||||
type: PortalQuickAction,
|
||||
@@ -304,6 +390,14 @@ export function triggerQuickAction(
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((type === 'line' || type === 'kakao') && !isRenderableHttpUrl(value)) {
|
||||
uni.showToast({
|
||||
title: getOmniText(getCurrentLocale(), 'portal.contact_unset'),
|
||||
icon: 'none',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (type === 'phone') {
|
||||
uni.makePhoneCall({
|
||||
phoneNumber: value,
|
||||
@@ -394,6 +488,32 @@ export function triggerQuickAction(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === 'line') {
|
||||
uni.setClipboardData({
|
||||
data: value.trim(),
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: getOmniText(getCurrentLocale(), 'portal.copied_line'),
|
||||
icon: 'none',
|
||||
});
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === 'kakao') {
|
||||
uni.setClipboardData({
|
||||
data: value.trim(),
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: getOmniText(getCurrentLocale(), 'portal.copied_kakao'),
|
||||
icon: 'none',
|
||||
});
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<path fill="#101828" d="M16.37 12.73c.03 2.52 2.21 3.36 2.24 3.37-.02.06-.35 1.19-1.15 2.36-.69 1.01-1.41 2.02-2.54 2.04-1.11.02-1.47-.66-2.74-.66s-1.67.64-2.72.68c-1.09.04-1.92-1.09-2.62-2.1-1.43-2.06-2.52-5.82-1.05-8.36.73-1.26 2.03-2.06 3.44-2.08 1.07-.02 2.09.72 2.74.72s1.88-.89 3.17-.76c.54.02 2.06.22 3.03 1.64-.08.05-1.81 1.06-1.8 3.15zM14.7 5.3c.58-.7.97-1.67.86-2.64-.83.03-1.84.55-2.44 1.25-.54.62-1.01 1.61-.88 2.56.93.07 1.88-.47 2.46-1.17z"/>
|
||||
</svg>
|
||||
|
之后 宽度: | 高度: | 大小: 550 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<circle cx="32" cy="32" r="32" fill="#E8EEF4"/>
|
||||
<circle cx="32" cy="25" r="10" fill="#94A3B8"/>
|
||||
<path fill="#94A3B8" d="M16 52c2.2-9.2 8.8-14 16-14s13.8 4.8 16 14"/>
|
||||
</svg>
|
||||
|
之后 宽度: | 高度: | 大小: 240 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<path fill="#0088FF" d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
|
||||
</svg>
|
||||
|
之后 宽度: | 高度: | 大小: 230 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<path fill="#4285F4" d="M23.49 12.27c0-.79-.07-1.54-.19-2.27H12v4.51h6.47a5.57 5.57 0 0 1-2.4 3.58v3h3.86c2.26-2.09 3.56-5.17 3.56-8.82z"/>
|
||||
<path fill="#34A853" d="M12 24c3.24 0 5.95-1.08 7.93-2.91l-3.86-3c-1.08.72-2.45 1.16-4.07 1.16-3.13 0-5.78-2.11-6.73-4.96H1.29v3.09C3.26 21.3 7.31 24 12 24z"/>
|
||||
<path fill="#FBBC05" d="M5.27 14.29A7.2 7.2 0 0 1 4.89 12c0-.8.14-1.57.38-2.29V6.62H1.29A12 12 0 0 0 0 12c0 1.94.47 3.76 1.29 5.38l3.98-3.09z"/>
|
||||
<path fill="#EA4335" d="M12 4.75c1.77 0 3.35.61 4.6 1.8l3.42-3.42C17.95 1.19 15.24 0 12 0 7.31 0 3.26 2.7 1.29 6.62l3.98 3.09C6.22 6.86 8.87 4.75 12 4.75z"/>
|
||||
</svg>
|
||||
|
之后 宽度: | 高度: | 大小: 702 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<path fill="#3C1E1E" d="M12 3c-4.97 0-9 3.185-9 7.115 0 2.557 1.707 4.8 4.27 6.054-.188.688-.682 2.495-.78 2.875-.123.476.175.47.368.342.152-.102 2.433-1.65 3.418-2.32.565.08 1.144.123 1.724.123 4.97 0 9-3.186 9-7.115S16.97 3 12 3z"/>
|
||||
</svg>
|
||||
|
之后 宽度: | 高度: | 大小: 328 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<path fill="#06C755" d="M19.34 10.3c0-3.8-3.7-6.9-8.3-6.9s-8.3 3.1-8.3 6.9c0 3.4 3 6.3 7 6.8.3.1.7.2.8.5.1.3.1.7 0 1l-.2 1c-.1.3-.2.8.8.4 1.1-.4 5.7-3.4 5.7-3.4 1.6-1.4 2.5-3.6 2.5-6.3zm-11.8 2h-1.5v-3.7h1.5v3.7zm2.5 0h-1.5v-3.7h1.5v.9h-1.5v.5h1.5v.9h-1.5v.5h1.5v.9zm2.7 0h-1.3l-1.3-2.1v2.1h-1.5v-3.7h1.3l1.3 2.1v-2.1h1.5v3.7zm3.1-2.8h-1.5v.5h1.5v.9h-1.5v.5h1.5v.9h-2.9v-3.7h2.9v.9z"/>
|
||||
</svg>
|
||||
|
之后 宽度: | 高度: | 大小: 479 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<path fill="#0A66C2" d="M19 3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14m-.5 15.5v-5.3a3.26 3.26 0 0 0-3.26-3.26c-.85 0-1.84.52-2.28 1.3v-1.11h-2.79v8.37h2.79v-4.93c0-.77.62-1.4 1.39-1.4a1.4 1.4 0 0 1 1.4 1.4v4.93h2.75M6.88 8.56a1.68 1.68 0 0 0 1.68-1.68c0-.93-.75-1.69-1.68-1.69a1.69 1.69 0 0 0-1.69 1.69c0 .93.76 1.68 1.69 1.68m1.39 9.94v-8.37H5.5v8.37h2.77z"/>
|
||||
</svg>
|
||||
|
之后 宽度: | 高度: | 大小: 480 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<path fill="#12B76A" d="M6.62 10.79a15.053 15.053 0 0 0 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z"/>
|
||||
</svg>
|
||||
|
之后 宽度: | 高度: | 大小: 361 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<path fill="#229ED9" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19-.14.75-.42 1-.68 1.03-.58.05-1.02-.38-1.58-.75-.88-.58-1.38-.94-2.23-1.5-.99-.65-.35-1.01.22-1.59.15-.15 2.71-2.48 2.76-2.69.01-.03.01-.14-.07-.2-.08-.06-.19-.04-.27-.02-.12.02-1.96 1.25-5.54 3.69-.52.36-1 .53-1.42.52-.47-.01-1.37-.26-2.03-.48-.82-.27-1.47-.42-1.42-.88.03-.24.37-.49 1.02-.75 3.98-1.73 6.64-2.87 7.97-3.43 3.8-1.58 4.59-1.85 5.1-1.86.11 0 .36.03.52.16.14.12.18.28.2.42-.01.06.01.23 0 .38z"/>
|
||||
</svg>
|
||||
|
之后 宽度: | 高度: | 大小: 630 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<path fill="#101828" d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
|
之后 宽度: | 高度: | 大小: 276 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<path fill="#07C160" d="M8.69 14.89c.44 0 .89-.04 1.34-.11 1.22 1.22 2.67 1.83 4.34 1.83.44 0 .89-.04 1.34-.11 1.67.78 3.11 1.11 4.34 1.01v-1.68c-.67-.22-1.34-.67-2-1.34 1.34-1.22 2-2.67 2-4.34 0-1.78-.67-3.22-2-4.34-1.34-1.11-3.01-1.67-5-1.67s-3.66.56-5 1.67c-1.34 1.12-2 2.56-2 4.34 0 1.22.44 2.33 1.33 3.33.22.22.44.56.67 1.01-.22.44-.67.89-1.33 1.34.89.11 1.56.11 2.33-.89zM10 9c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm6 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zM6.5 3C2.91 3 0 5.46 0 8.5c0 1.76.98 3.32 2.5 4.39l-.66 2.45 2.76-1.38c.61.16 1.25.24 1.9.24 3.59 0 6.5-2.46 6.5-5.5S10.09 3 6.5 3zm-2 4.5c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm4 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1z"/>
|
||||
</svg>
|
||||
|
之后 宽度: | 高度: | 大小: 817 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<path fill="#25D366" d="M19.05 4.95A9.87 9.87 0 0 0 12.04 2c-5.46 0-9.91 4.45-9.91 9.91 0 1.75.46 3.45 1.32 4.95L2 22l5.25-1.38c1.45.79 3.08 1.21 4.79 1.21h.01c5.46 0 9.91-4.45 9.91-9.91 0-2.65-1.03-5.14-2.91-7.02zM12.04 20.17h-.01c-1.48 0-2.93-.4-4.2-1.15l-.3-.18-3.12.82.83-3.04-.2-.32a8.19 8.19 0 0 1-1.26-4.38c0-4.54 3.7-8.24 8.24-8.24 2.2 0 4.27.86 5.82 2.42a8.18 8.18 0 0 1 2.41 5.83c0 4.54-3.7 8.24-8.24 8.24zm4.52-6.17c-.25-.12-1.47-.72-1.69-.8-.23-.09-.39-.12-.56.12-.17.25-.66.8-.81.97-.15.17-.3.19-.55.07-.25-.12-1.05-.39-2-1.23-.74-.66-1.24-1.47-1.39-1.72-.15-.25-.02-.38.11-.5.11-.11.25-.29.37-.44.12-.15.17-.25.25-.42.08-.17.04-.31-.02-.44-.06-.12-.56-1.35-.77-1.85-.2-.48-.41-.42-.56-.43h-.48c-.17 0-.44.06-.67.31-.23.25-.88.86-.88 2.1 0 1.24.9 2.44 1.03 2.61.12.17 1.77 2.7 4.29 3.79.6.26 1.07.41 1.44.53.6.19 1.15.16 1.58.1.48-.07 1.47-.6 1.68-1.18.21-.58.21-1.07.15-1.18-.07-.11-.22-.17-.47-.3z"/>
|
||||
</svg>
|
||||
|
之后 宽度: | 高度: | 大小: 1009 B |
@@ -5,6 +5,20 @@ import path from 'node:path';
|
||||
|
||||
const MP_ROOT = path.resolve('m:/2026项目/CRMmp/mpuniapp');
|
||||
const articlesUtsPath = path.join(MP_ROOT, 'pages/owner/card/useCardArticles.uts');
|
||||
const articlesPagePath = path.join(MP_ROOT, 'pages/owner/card/articles.uvue');
|
||||
|
||||
/** 禁止预填的示例文章 / 假标题 / 假封面 */
|
||||
const SAMPLE_BAN_PATTERNS: RegExp[] = [
|
||||
/2026\s*全球智能硬件合规白皮书/,
|
||||
/全球智能硬件合规白皮书/,
|
||||
/DEMO_ARTICLES/,
|
||||
/SAMPLE_ARTICLES/,
|
||||
/MOCK_ARTICLES/,
|
||||
/placeholder\.png/i,
|
||||
/fake[-_]?cover/i,
|
||||
/unsplash\.com/i,
|
||||
/picsum\.photos/i,
|
||||
];
|
||||
|
||||
test('1. useCardArticles.uts 文件必须存在且格式完整', () => {
|
||||
assert.ok(fs.existsSync(articlesUtsPath), '必须存在 useCardArticles.uts');
|
||||
@@ -24,13 +38,50 @@ test('2. 摘要提取器必须支持剥离 Markdown 与 HTML 语法', () => {
|
||||
assert.ok(content.includes('.replace(/!\\[.*?\\]\\(.*?\\)/g, \'\')'), '必须剥离 Markdown 图片');
|
||||
});
|
||||
|
||||
test('3. 文章表单校验逻辑实现规范', () => {
|
||||
test('3. R41: 空列表必须 empty=true,禁止注入示例文章数组', () => {
|
||||
const content = fs.readFileSync(articlesUtsPath, 'utf-8');
|
||||
|
||||
assert.ok(
|
||||
content.includes('state.empty = items.length === 0'),
|
||||
'loadArticles 必须按真实列表长度设置 empty'
|
||||
);
|
||||
assert.ok(content.includes('state.list = items'), '列表必须使用接口返回数据');
|
||||
|
||||
assert.ok(
|
||||
!/state\.list\s*=\s*\[[\s\S]*?title\s*:/.test(content),
|
||||
'禁止用内联示例文章数组填充 list'
|
||||
);
|
||||
assert.ok(!content.includes('DEMO_ARTICLES'), '禁止 DEMO_ARTICLES');
|
||||
assert.ok(!content.includes('SAMPLE_ARTICLES'), '禁止 SAMPLE_ARTICLES');
|
||||
assert.ok(!content.includes('MOCK_ARTICLES'), '禁止 MOCK_ARTICLES');
|
||||
});
|
||||
|
||||
test('4. R41: 禁止示例标题 / 假封面,新建表单字段为空', () => {
|
||||
const uts = fs.readFileSync(articlesUtsPath, 'utf-8');
|
||||
const page = fs.readFileSync(articlesPagePath, 'utf-8');
|
||||
|
||||
for (const pattern of SAMPLE_BAN_PATTERNS) {
|
||||
assert.ok(!pattern.test(uts), `useCardArticles 禁止示例数据: ${pattern}`);
|
||||
assert.ok(!pattern.test(page), `articles.uvue 禁止示例数据: ${pattern}`);
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
page.includes("editForm.title = ''") && page.includes("editForm.content = ''"),
|
||||
'新建时 title/content 必须置为空字符串'
|
||||
);
|
||||
assert.ok(
|
||||
page.includes("editForm.coverImage = ''") || page.includes("uploadedCover.value = []"),
|
||||
'新建时封面必须为空,禁止假封面'
|
||||
);
|
||||
});
|
||||
|
||||
test('5. 文章表单校验逻辑实现规范', () => {
|
||||
const content = fs.readFileSync(articlesUtsPath, 'utf-8');
|
||||
assert.ok(content.includes("title === ''"), '必须拦截空标题');
|
||||
assert.ok(content.includes("content === ''"), '必须拦截空正文');
|
||||
});
|
||||
|
||||
test('4. 必须通过 Cool-Admin 标准 CRUD 契约对接后端接口', () => {
|
||||
test('6. 必须通过 Cool-Admin 标准 CRUD 契约对接后端接口', () => {
|
||||
const content = fs.readFileSync(articlesUtsPath, 'utf-8');
|
||||
assert.ok(content.includes('fetchCardArticlesPage'), '必须调用 fetchCardArticlesPage');
|
||||
assert.ok(content.includes('addCardArticle'), '必须调用 addCardArticle');
|
||||
@@ -38,9 +89,47 @@ test('4. 必须通过 Cool-Admin 标准 CRUD 契约对接后端接口', () => {
|
||||
assert.ok(content.includes('deleteCardArticle'), '必须调用 deleteCardArticle');
|
||||
});
|
||||
|
||||
test('5. 国际化与文案键完整覆盖', () => {
|
||||
const content = fs.readFileSync(articlesUtsPath, 'utf-8');
|
||||
assert.ok(content.includes('resolveArticlesText'), '必须实现文案解析');
|
||||
assert.ok(content.includes('Insights & Articles'), '必须支持英文标题');
|
||||
assert.ok(content.includes('文章与商务动态'), '必须支持中文标题');
|
||||
test('7. 国际化与文案键完整覆盖,模板禁止硬编码中文', () => {
|
||||
const uts = fs.readFileSync(articlesUtsPath, 'utf-8');
|
||||
const page = fs.readFileSync(articlesPagePath, 'utf-8');
|
||||
|
||||
assert.ok(uts.includes('resolveArticlesText'), '必须实现文案解析');
|
||||
assert.ok(uts.includes('Insights & Articles'), '必须支持英文标题');
|
||||
assert.ok(uts.includes('文章与商务动态'), '必须支持中文标题');
|
||||
assert.ok(uts.includes('emptyTitle'), '必须含空态标题词条');
|
||||
assert.ok(uts.includes('emptyDesc'), '必须含空态说明词条');
|
||||
|
||||
const templateMatch = page.match(/<template>[\s\S]*?<\/template>/);
|
||||
assert.ok(templateMatch, '必须有 template 块');
|
||||
const template = templateMatch![0];
|
||||
assert.ok(
|
||||
!/placeholder="[^"]*[\u4e00-\u9fff]/.test(template),
|
||||
'模板 placeholder 禁止硬编码中文'
|
||||
);
|
||||
assert.ok(template.includes("t('emptyTitle')"), '空态标题必须走 i18n');
|
||||
assert.ok(template.includes("t('emptyDesc')"), '空态说明必须走 i18n');
|
||||
assert.ok(template.includes("t('publishBtn')"), '主 CTA 文案必须走 i18n');
|
||||
});
|
||||
|
||||
test('8. R41: 空列表展示空态,一屏仅 1 个主 CTA', () => {
|
||||
const page = fs.readFileSync(articlesPagePath, 'utf-8');
|
||||
|
||||
assert.ok(page.includes('state.empty'), '必须根据 state.empty 分支');
|
||||
assert.ok(page.includes('articles-empty-card'), '必须有空态卡片');
|
||||
assert.ok(page.includes("t('emptyTitle')"), '空态必须显示 emptyTitle');
|
||||
assert.ok(page.includes("t('emptyDesc')"), '空态必须显示 emptyDesc');
|
||||
|
||||
const templateMatch = page.match(/<template>[\s\S]*?<\/template>/);
|
||||
assert.ok(templateMatch, '必须有 template 块');
|
||||
const template = templateMatch![0];
|
||||
|
||||
const bottomBarMatch = template.match(
|
||||
/articles-bottom-bar[\s\S]*?<\/view>\s*(?:<!--[\s\S]*?-->\s*)*<view v-if="state\.drawerVisible"/
|
||||
);
|
||||
assert.ok(bottomBarMatch, '必须存在底部主 CTA 栏');
|
||||
const bottomBar = bottomBarMatch![0];
|
||||
const ctaInBar = (bottomBar.match(/class="primary-cta-btn"/g) || []).length;
|
||||
assert.equal(ctaInBar, 1, '底部栏一屏仅允许 1 个 primary-cta-btn');
|
||||
assert.ok(bottomBar.includes('handleOpenAdd'), '主 CTA 必须是发布动态');
|
||||
assert.ok(bottomBar.includes("t('publishBtn')"), '主 CTA 文案必须是 publishBtn');
|
||||
});
|
||||
|
||||
@@ -5,6 +5,20 @@ import path from 'node:path';
|
||||
|
||||
const MP_ROOT = path.resolve('m:/2026项目/CRMmp/mpuniapp');
|
||||
const casesUtsPath = path.join(MP_ROOT, 'pages/owner/card/useCardCases.uts');
|
||||
const casesPagePath = path.join(MP_ROOT, 'pages/owner/card/cases.uvue');
|
||||
|
||||
/** 禁止预填的示例案例 / 客户 / 假 KPI */
|
||||
const SAMPLE_BAN_PATTERNS: RegExp[] = [
|
||||
/全球智能硬件数字化交付方案/,
|
||||
/阿联酋国际商业联合会/,
|
||||
/触达\s*10,000\+?\s*买家/,
|
||||
/\+320%\s*ROI/i,
|
||||
/\+150%\s*留资/,
|
||||
/45k\+?\s*国际线索/i,
|
||||
/2M\+?\s*全球曝光/i,
|
||||
/Top\s*1\s*细分类目/i,
|
||||
/VoltRide/i,
|
||||
];
|
||||
|
||||
test('1. useCardCases.uts 文件必须存在且格式完整', () => {
|
||||
assert.ok(fs.existsSync(casesUtsPath), '必须存在 useCardCases.uts');
|
||||
@@ -14,26 +28,55 @@ test('1. useCardCases.uts 文件必须存在且格式完整', () => {
|
||||
assert.ok(content.includes('export async function loadCases'), '必须导出 loadCases');
|
||||
assert.ok(content.includes('export async function saveCase'), '必须导出 saveCase');
|
||||
assert.ok(content.includes('export async function deleteCase'), '必须导出 deleteCase');
|
||||
assert.ok(content.includes('export const PRESET_CASE_METRICS'), '必须导出 PRESET_CASE_METRICS');
|
||||
assert.ok(content.includes('export function resolveCasesText'), '必须导出 resolveCasesText');
|
||||
});
|
||||
|
||||
test('2. 预设指标标签完备且包含国际业务核心量化成效', () => {
|
||||
test('2. R40: 空列表必须 empty=true,禁止注入示例案例数组', () => {
|
||||
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('2M+ 全球曝光'), '必须包含 2M+ 全球曝光');
|
||||
assert.ok(content.includes('Top 1 细分类目'), '必须包含 Top 1 细分类目');
|
||||
|
||||
assert.ok(
|
||||
content.includes('state.empty = items.length === 0'),
|
||||
'loadCases 必须按真实列表长度设置 empty'
|
||||
);
|
||||
assert.ok(content.includes('state.list = items'), '列表必须使用接口返回数据');
|
||||
|
||||
// 禁止空列表时回退到本地示例数组
|
||||
assert.ok(
|
||||
!/state\.list\s*=\s*\[[\s\S]*?title\s*:/.test(content),
|
||||
'禁止用内联示例案例数组填充 list'
|
||||
);
|
||||
assert.ok(!content.includes('DEMO_CASES'), '禁止 DEMO_CASES');
|
||||
assert.ok(!content.includes('SAMPLE_CASES'), '禁止 SAMPLE_CASES');
|
||||
assert.ok(!content.includes('MOCK_CASES'), '禁止 MOCK_CASES');
|
||||
});
|
||||
|
||||
test('3. 案例表单校验逻辑实现规范', () => {
|
||||
test('3. R40: 禁止预设假 KPI / 示例客户,metrics 新建为空', () => {
|
||||
const uts = fs.readFileSync(casesUtsPath, 'utf-8');
|
||||
const page = fs.readFileSync(casesPagePath, 'utf-8');
|
||||
|
||||
assert.ok(!uts.includes('PRESET_CASE_METRICS'), '禁止 PRESET_CASE_METRICS 假 KPI 常量');
|
||||
assert.ok(!page.includes('PRESET_CASE_METRICS'), '页面禁止引用预设 KPI');
|
||||
assert.ok(!page.includes('preset-metric-pill'), '禁止预设 KPI 胶囊点击注入');
|
||||
assert.ok(!page.includes('handleSelectMetric'), '禁止点击注入预设 KPI');
|
||||
|
||||
for (const pattern of SAMPLE_BAN_PATTERNS) {
|
||||
assert.ok(!pattern.test(uts), `useCardCases 禁止示例数据: ${pattern}`);
|
||||
assert.ok(!pattern.test(page), `cases.uvue 禁止示例数据: ${pattern}`);
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
/metrics:\s*['"]{2}/.test(page) || page.includes("editForm.metrics = ''"),
|
||||
'新建时 metrics 必须置为空字符串'
|
||||
);
|
||||
});
|
||||
|
||||
test('4. 案例表单校验逻辑实现规范', () => {
|
||||
const content = fs.readFileSync(casesUtsPath, 'utf-8');
|
||||
// 必须对标题非空和超长进行拦截
|
||||
assert.ok(content.includes("title === ''"), '必须拦截空标题');
|
||||
assert.ok(content.includes('title.length > 150'), '必须拦截超过 150 字符的标题');
|
||||
});
|
||||
|
||||
test('4. 必须通过 Cool-Admin 标准 CRUD 契约对接后端接口', () => {
|
||||
test('5. 必须通过 Cool-Admin 标准 CRUD 契约对接后端接口', () => {
|
||||
const content = fs.readFileSync(casesUtsPath, 'utf-8');
|
||||
assert.ok(content.includes('fetchCardCasesPage'), '必须调用 fetchCardCasesPage');
|
||||
assert.ok(content.includes('addCardCase'), '必须调用 addCardCase');
|
||||
@@ -41,9 +84,48 @@ test('4. 必须通过 Cool-Admin 标准 CRUD 契约对接后端接口', () => {
|
||||
assert.ok(content.includes('deleteCardCase'), '必须调用 deleteCardCase');
|
||||
});
|
||||
|
||||
test('5. 国际化与文案键完整覆盖', () => {
|
||||
const content = fs.readFileSync(casesUtsPath, 'utf-8');
|
||||
assert.ok(content.includes('resolveCasesText'), '必须实现文案解析');
|
||||
assert.ok(content.includes('Portfolio & Case Studies'), '必须支持英文标题');
|
||||
assert.ok(content.includes('产品相册与标杆案例'), '必须支持中文标题');
|
||||
test('6. 国际化与文案键完整覆盖,模板禁止硬编码中文', () => {
|
||||
const uts = fs.readFileSync(casesUtsPath, 'utf-8');
|
||||
const page = fs.readFileSync(casesPagePath, 'utf-8');
|
||||
|
||||
assert.ok(uts.includes('resolveCasesText'), '必须实现文案解析');
|
||||
assert.ok(uts.includes('Portfolio & Case Studies'), '必须支持英文标题');
|
||||
assert.ok(uts.includes('产品与案例') || uts.includes('产品相册与标杆案例'), '必须支持中文标题');
|
||||
assert.ok(uts.includes('emptyTitle'), '必须含空态标题词条');
|
||||
assert.ok(uts.includes('emptyDesc'), '必须含空态说明词条');
|
||||
|
||||
// 模板可见文案走 t() / resolveCasesText,禁止 placeholder 硬编码中文示例
|
||||
const templateMatch = page.match(/<template>[\s\S]*?<\/template>/);
|
||||
assert.ok(templateMatch, '必须有 template 块');
|
||||
const template = templateMatch![0];
|
||||
assert.ok(
|
||||
!/placeholder="[^"]*[\u4e00-\u9fff]/.test(template),
|
||||
'模板 placeholder 禁止硬编码中文'
|
||||
);
|
||||
assert.ok(template.includes("t('emptyTitle')"), '空态标题必须走 i18n');
|
||||
assert.ok(template.includes("t('emptyDesc')"), '空态说明必须走 i18n');
|
||||
assert.ok(template.includes("t('addBtn')"), '主 CTA 文案必须走 i18n');
|
||||
});
|
||||
|
||||
test('7. R40: 空列表展示空态,一屏仅 1 个主 CTA', () => {
|
||||
const page = fs.readFileSync(casesPagePath, 'utf-8');
|
||||
|
||||
assert.ok(page.includes('state.empty'), '必须根据 state.empty 分支');
|
||||
assert.ok(page.includes('cases-empty-card'), '必须有空态卡片');
|
||||
assert.ok(page.includes("t('emptyTitle')"), '空态必须显示 emptyTitle');
|
||||
assert.ok(page.includes("t('emptyDesc')"), '空态必须显示 emptyDesc');
|
||||
|
||||
const templateMatch = page.match(/<template>[\s\S]*?<\/template>/);
|
||||
assert.ok(templateMatch, '必须有 template 块');
|
||||
const template = templateMatch![0];
|
||||
|
||||
// 主屏底部仅一处 primary-cta-btn(抽屉内保存可另计,但主列表区底部栏只能 1 个)
|
||||
const bottomBarMatch = template.match(
|
||||
/cases-bottom-bar[\s\S]*?<\/view>\s*(?:<!--[\s\S]*?-->\s*)*<view v-if="state\.drawerVisible"/
|
||||
);
|
||||
assert.ok(bottomBarMatch, '必须存在底部主 CTA 栏');
|
||||
const bottomBar = bottomBarMatch![0];
|
||||
const ctaInBar = (bottomBar.match(/class="primary-cta-btn"/g) || []).length;
|
||||
assert.equal(ctaInBar, 1, '底部栏一屏仅允许 1 个 primary-cta-btn');
|
||||
assert.ok(bottomBar.includes('handleOpenAdd'), '主 CTA 必须是新增案例');
|
||||
});
|
||||
|
||||
@@ -57,6 +57,8 @@ test('3. 工作台状态机 useWorkplace.uts 逻辑完整性审查', () => {
|
||||
assert.ok(content.includes('createWorkplaceState'), '必须提供 createWorkplaceState');
|
||||
assert.ok(content.includes('loadWorkplaceData'), '必须提供 loadWorkplaceData 聚合加载逻辑');
|
||||
assert.ok(content.includes('resolveWorkplaceText'), '必须提供国际化文案解析 resolveWorkplaceText');
|
||||
assert.ok(content.includes('fetchCardQrcodeSvg'), 'loadWorkplaceData 必须复用已有 fetchCardQrcodeSvg');
|
||||
assert.ok(content.includes('activeQrSvg'), '状态机必须持有 activeQrSvg');
|
||||
});
|
||||
|
||||
test('4. 工作台视图 index.uvue 结构与规范审查 (Bento Grid 模组、零 Emoji、安全区避让、沉浸式且无遮挡悬浮按钮)', () => {
|
||||
@@ -81,3 +83,82 @@ test('4. 工作台视图 index.uvue 结构与规范审查 (Bento Grid 模组、
|
||||
assert.ok(content.includes('safe-area-inset-bottom'), '必须适配 env(safe-area-inset-bottom)');
|
||||
assert.ok(content.includes('var(--window-bottom'), 'TabBar 页面必须避让 var(--window-bottom)');
|
||||
});
|
||||
|
||||
test('5. 有真实 svg 时渲染二维码;无码/失败仅空态文案,禁止伪码占位', () => {
|
||||
assert.ok(fs.existsSync(workplaceUvuePath), '必须存在 workplace/index.uvue');
|
||||
assert.ok(fs.existsSync(workplaceUtsPath), '必须存在 useWorkplace.uts');
|
||||
|
||||
const pageSource = fs.readFileSync(workplaceUvuePath, 'utf-8');
|
||||
const utsSource = fs.readFileSync(workplaceUtsPath, 'utf-8');
|
||||
const template = pageSource.split('<script')[0];
|
||||
const styleBlock = pageSource.includes('<style')
|
||||
? pageSource.split('<style')[1]
|
||||
: '';
|
||||
|
||||
// 有码:rich-text 绑定真实 svg
|
||||
assert.ok(template.includes('<rich-text'), '有 svg 时必须用 rich-text 渲染');
|
||||
assert.ok(
|
||||
template.includes('state.activeQrSvg') && template.includes(':nodes='),
|
||||
'rich-text 必须绑定 activeQrSvg'
|
||||
);
|
||||
assert.ok(
|
||||
/v-if="state\.activeQrSvg\s*!==\s*''"/.test(template) ||
|
||||
/v-if="state\.activeQrSvg\s*!=\s*''"/.test(template),
|
||||
'仅在 activeQrSvg 非空时渲染码图'
|
||||
);
|
||||
|
||||
// 无码:仅空态文案,走已有 i18n
|
||||
assert.ok(template.includes('showcase-qr-empty'), '无 svg 时必须显示空态文案节点');
|
||||
assert.ok(template.includes('<text') && template.includes('showcase-qr-empty'), '空态必须是 text 文案,不是占位块');
|
||||
assert.ok(
|
||||
pageSource.includes("t('workplace.qr_empty')") ||
|
||||
pageSource.includes('workplace.qr_empty') ||
|
||||
(utsSource.includes("'common.empty'") && pageSource.includes('getOmniText')),
|
||||
'空态文案必须走已有 i18n,禁止硬编码'
|
||||
);
|
||||
assert.ok(
|
||||
utsSource.includes("'workplace.qr_empty'") || utsSource.includes("'common.empty'"),
|
||||
'useWorkplace 必须提供空态 i18n 词条'
|
||||
);
|
||||
|
||||
// 禁止假方块 / 占位块冒充二维码(模板 + 样式)
|
||||
assert.equal(template.includes('showcase-qr-placeholder'), false, '严禁 showcase-qr-placeholder 伪码占位');
|
||||
assert.equal(template.includes('qr-pulse-block'), false, '严禁 qr-pulse-block 假方块');
|
||||
assert.equal(styleBlock.includes('qr-pulse-block'), false, '样式中严禁残留伪码方块');
|
||||
assert.equal(styleBlock.includes('showcase-qr-placeholder'), false, '样式中严禁残留伪码占位');
|
||||
assert.equal(pageSource.includes('fake-qr'), false, '严禁 fake-qr');
|
||||
assert.equal(pageSource.includes('decorative-qr'), false, '严禁 decorative-qr');
|
||||
assert.equal(pageSource.includes('placeholder-qr'), false, '严禁 placeholder-qr');
|
||||
// 虚线框 + 灰块会像一枚未生成的码,空态容器不得用 dashed 冒充码框
|
||||
const wrapStyleMatch = styleBlock.match(/\.showcase-qr-wrap\s*\{[^}]*\}/);
|
||||
if (wrapStyleMatch != null) {
|
||||
assert.equal(
|
||||
/border\s*:[^;]*dashed/i.test(wrapStyleMatch[0]),
|
||||
false,
|
||||
'空态码区不得用 dashed 边框冒充二维码框'
|
||||
);
|
||||
}
|
||||
|
||||
// 展台区不得用 URL 文字冒充二维码
|
||||
const qrWrapMatch = template.match(/class="showcase-qr-wrap"[\s\S]*?<\/view>\s*<view class="showcase-meta"/);
|
||||
assert.ok(qrWrapMatch != null, '必须存在 showcase-qr-wrap 展台码区');
|
||||
const qrWrapBlock = qrWrapMatch![0];
|
||||
assert.equal(qrWrapBlock.includes('activeCard!.url'), false, '二维码区不得渲染 URL 冒充码');
|
||||
assert.equal(qrWrapBlock.includes('currentDisplayLink'), false, '二维码区不得渲染直链冒充码');
|
||||
assert.equal(/\{\{\s*[^}]*url[^}]*\}\}/i.test(qrWrapBlock), false, '二维码区不得插值 URL 文本');
|
||||
|
||||
// 必须拉取已有 fetchCardQrcodeSvg;失败清空,不造假 svg
|
||||
assert.ok(utsSource.includes('fetchCardQrcodeSvg'), '必须调用 fetchCardQrcodeSvg');
|
||||
assert.ok(utsSource.includes('catch'), '二维码请求必须有失败分支');
|
||||
assert.ok(
|
||||
/activeQrSvg\s*=\s*['"]['"]/.test(utsSource),
|
||||
'请求失败或无 svg 时必须清空 activeQrSvg'
|
||||
);
|
||||
assert.equal(utsSource.includes('<svg'), false, '严禁手写造假 svg');
|
||||
assert.equal(/activeQrSvg\s*=\s*['"][^'"]*<svg/.test(utsSource), false, '严禁赋值假 svg 字符串');
|
||||
|
||||
// 仍只允许一个主 CTA(主色实心按钮)
|
||||
const primaryCtaMatches = template.match(/pulse-action-btn/g) || [];
|
||||
assert.equal(primaryCtaMatches.length, 1, '主色实心 CTA 只能有一处 pulse-action-btn');
|
||||
assert.equal(template.includes('workplace-cta-bar'), false, '严禁额外底部主 CTA 栏');
|
||||
});
|
||||
|
||||