feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口

补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
wikiboss
2026-09-23 18:01:47 +08:00
父节点 8ea9c25386
当前提交 62e0ae4435
共有 227 个文件被更改,包括 21855 次插入 和 5264 次删除

查看文件

@@ -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 栏');
});