feat(card): add leads and share pages with platform-specific handling

- Implemented leads.vue for managing lead statuses and displaying lead information.
- Added share.vue for sharing card links with platform-specific web-view handling for WeChat Mini Programs, HarmonyOS, and H5.
- Introduced tests for cross-platform compatibility, ensuring proper handling of conditional compilation and API usage across WeChat, H5, and HarmonyOS.
- Ensured that sharing functionality correctly encodes and decodes URLs for different platforms.
This commit is contained in:
2026-10-12 14:32:20 +08:00
parent a957679957
commit 9e6dc8665f
8 changed files with 1822 additions and 4 deletions

View File

@@ -272,6 +272,59 @@ export default {
}
});
},
/**
* JSON body 请求通道
*
* 为什么需要: sendRequest 走表单编码, 嵌套数组(如 fields.mobiles)的序列化
* 格式在各端不一致(uni 序列化器的平台差异, 小程序/H5 产出不同),
* 后端 input('fields/a') 依赖 PHP 方括号语法, 踩坑风险高。
* 本方法统一 JSON.stringify + application/json —— TP6 Request
* 原生解析 JSON body(getInputData), 且字符串 body 各端原样发送, 零歧义。
* token/uniacid/app_type 与 sendRequest 一样自动补齐。
*/
sendJson(params) {
if (!Config.baseUrl) {
uni.showToast({
title: getLang().lang('http.baseUrlNotConfigured'),
'icon': 'none',
'duration': 10000
});
return;
}
const url = (Config.baseUrl + params.url).replace(/(?<!:)\/+/g, '/');
const data = {
app_type: appType,
app_type_name: appTypeName,
token: store.state.token || '',
uniacid: uni.getStorageSync('uniacid') || Config.uniacid
};
if (params.data != undefined) Object.assign(data, params.data);
const store_info = store.state.globalStoreInfo;
if (store_info) data.store_id = store_info.store_id;
console.log('[HTTP][REQUEST] sendJson', url);
uni.request({
url: url,
method: params.method || 'POST',
header: Object.assign({ 'content-type': 'application/json' }, params.header || {}),
data: JSON.stringify(data),
dataType: params.dataType || 'json',
responseType: params.responseType || 'text',
success: (res) => {
console.log('[HTTP][RESPONSE] sendJson', url, 'statusCode=', res.statusCode);
let r = res.data;
if (typeof r === 'string') {
try { r = JSON.parse(r); } catch (e) { /* 保持原样 */ }
}
typeof params.success == 'function' && params.success(r);
},
fail: (res) => {
console.error('[HTTP][FAIL] sendJson', url, JSON.stringify(res));
typeof params.fail == 'function' && params.fail(res);
}
});
},
upload(params) {
console.log('[HTTP][REQUEST] upload', Config.baseUrl + params.url, 'name=', params.name || 'file');
uni.uploadFile({

View File

@@ -200,6 +200,10 @@ export const PAGE_KEY_MAP = {
// === 电子名片 ===
PAGE_CONTACT_CARD: '/pages/contact/contact',
// === 名片云(card 插件) ===
PAGE_CARD_LIST: '/pages_tool/card/list',
PAGE_CARD_LEADS: '/pages_tool/card/leads',
// === VR ===
PAGE_VR_INDEX: '/pages_tool/vr/index',

View File

@@ -1480,6 +1480,26 @@
"navigationBarTitleText": "名片详情"
}
},
{
"path": "card/edit",
"style": {
"navigationBarTitleText": "编辑名片"
}
},
{
"path": "card/leads",
"style": {
"navigationBarTitleText": "我的线索",
"enablePullDownRefresh": true
}
},
{
"path": "card/share",
"style": {
"navigationBarTitleText": "名片分享",
"navigationStyle": "custom"
}
},
{
"path": "member/card",
"style": {

View File

@@ -3,7 +3,8 @@
<!-- 名片头部 -->
<view class="detail-head" v-if="relation">
<view class="head-avatar">
<text>{{ avatarText(relation.name) }}</text>
<image v-if="relation.avatar" :src="$util.img(relation.avatar)" class="head-img" mode="aspectFill" />
<text v-else>{{ avatarText(relation.name) }}</text>
</view>
<view class="head-info">
<view class="head-name">{{ relation.name || '未命名名片' }}</view>
@@ -11,13 +12,17 @@
{{ [relation.job_title, relation.company].filter(Boolean).join(' · ') }}
</view>
</view>
<view class="head-actions">
<view class="head-btn" @tap="openShare">分享</view>
<view class="head-btn" @tap="goEdit">编辑</view>
</view>
</view>
<!-- 字段列表 -->
<view class="detail-fields" v-if="fieldRows.length > 0">
<view v-for="(row, index) in fieldRows" :key="index" class="field-row">
<view v-for="(row, index) in fieldRows" :key="index" class="field-row" :class="{ 'field-row-multi': row.multiline }">
<view class="field-label">{{ row.label }}</view>
<view class="field-value">{{ row.value }}</view>
<view class="field-value" :class="{ 'field-value-multi': row.multiline }">{{ row.value }}</view>
<view class="field-action" v-if="row.action"
@tap="row.action.type === 'call' ? callPhone(row.value) : copyText2(row.value)">
{{ row.action.text }}
@@ -25,6 +30,38 @@
</view>
</view>
<!-- 分享面板(微信式底部抽屉) -->
<view class="share-mask" v-if="showShare" @tap="closeShare"></view>
<view class="share-sheet" :class="{ 'share-sheet-show': showShare }">
<view class="ss-bar"></view>
<view class="ss-title">分享名片</view>
<view class="ss-sub">客户打开链接即可查看名片并留言留资</view>
<view class="ss-qr-box">
<image v-if="qrUrl" :src="qrUrl" class="ss-qr" mode="widthFix" show-menu-by-longpress="true" />
</view>
<view class="ss-link">{{ shareUrl }}</view>
<view class="ss-btns">
<view class="ss-btn ss-btn-main" @tap="copyLink">复制链接</view>
<!-- #ifdef MP-WEIXIN -->
<view class="ss-btn" @tap="saveQr">保存二维码</view>
<view class="ss-btn" @tap="shareToFriend">发给好友</view>
<!-- #endif -->
<!-- #ifdef H5 -->
<view class="ss-btn" @tap="h5Share">浏览器分享</view>
<!-- #endif -->
<!-- #ifdef MP-HARMONY -->
<!-- 鸿蒙元服务:uni.saveImageToPhotosAlbum/downloadFile 均支持(先下载后保存的模式不变);
无小程序右上角转发菜单,系统级分享用「复制链接」代替;原生 image 无长按菜单 -->
<view class="ss-btn" @tap="saveQr">保存二维码</view>
<!-- #endif -->
</view>
<view class="ss-tip">
<!-- #ifdef MP-WEIXIN -->长按二维码也可保存到相册<!-- #endif -->
<!-- #ifdef H5 -->长按二维码可保存到相册<!-- #endif -->
<!-- #ifdef MP-HARMONY -->点击「保存二维码」存入相册<!-- #endif -->
</view>
</view>
<!-- 加载中 -->
<view class="detail-loading" v-if="isLoading">加载中…</view>
@@ -43,7 +80,10 @@ export default {
source: null,
shareList: [],
isLoading: true,
errorMsg: ''
errorMsg: '',
showShare: false,
shareUrl: '',
qrUrl: ''
};
},
computed: {
@@ -68,6 +108,27 @@ export default {
push('Telegram', r.telegram);
push('Messenger', r.messenger);
// 附加手机号(mobiles_list, 后端 CardRichFields 解码) → 可拨打
(r.mobiles_list || []).forEach((m, i) => {
push('手机' + (i + 2), m, { type: 'call', text: '拨打' });
});
// 社交账号(socials_list: {type,label,value}) → label 作行名, 值可复制
// (完整主页链接与纯账号统一复制 value, 链接可粘贴到浏览器打开)
(r.socials_list || []).forEach(s => {
push(s.label || s.type, s.value, { type: 'copy', text: '复制' });
});
// 自定义字段(custom_list: {label,value})
(r.custom_list || []).forEach(c => {
push(c.label, c.value);
});
// 简介(多行, 放最后)
if (r.bio && String(r.bio).trim() !== '') {
rows.push({ label: '简介', value: String(r.bio), action: null, multiline: true });
}
return rows;
}
},
@@ -75,7 +136,30 @@ export default {
this.cardId = decodeURIComponent(options.card_id || '');
this.loadDetail();
},
onShow() {
// 首次进入由 onLoad 负责加载; 从编辑页返回时(onShow 再次触发)刷新
if (this._loadedOnce) {
this.loadDetail();
}
this._loadedOnce = true;
},
// 小程序端转发: 分享 web-view 中转页(其内部加载 wap H5 名片页),
// 因为 onShareAppMessage 的 path 只能指向小程序页面, 不能直接分享 H5 链接
onShareAppMessage() {
const name = (this.relation && this.relation.name) || '我的名片';
return {
title: name + '的名片',
path: '/pages_tool/card/share?url=' + encodeURIComponent(this.shareUrl || ''),
imageUrl: (this.relation && this.relation.avatar) ? this.$util.img(this.relation.avatar) : ''
};
},
methods: {
goEdit() {
if (!this.cardId) return;
uni.navigateTo({
url: '/pages_tool/card/edit?card_id=' + encodeURIComponent(this.cardId)
});
},
avatarText(name) {
return (name || '?').trim().charAt(0).toUpperCase();
},
@@ -120,6 +204,68 @@ export default {
uni.showToast({ title: '已复制', icon: 'success' });
}
});
},
/* ---------- 分享 ---------- */
siteId() {
return uni.getStorageSync('uniacid') || this.$config.uniacid || 0;
},
openShare() {
if (!this.relation) return;
const sid = this.siteId();
const rid = this.relation.id;
const base = this.$config.baseUrl || '';
// wap 公开名片页(客户视角, 无需登录)
this.shareUrl = base + '/card/wap/index/detail?site_id=' + sid + '&id=' + rid;
// 二维码由后端同源生成(项目内置 extend\QRcode), 与链接一一对应
this.qrUrl = base + '/card/wap/index/qr?site_id=' + sid + '&id=' + rid;
this.showShare = true;
},
closeShare() {
this.showShare = false;
},
copyLink() {
if (!this.shareUrl) return;
uni.setClipboardData({
data: this.shareUrl,
success: () => {
uni.showToast({ title: '链接已复制', icon: 'success' });
}
});
},
saveQr() {
if (!this.qrUrl) return;
uni.downloadFile({
url: this.qrUrl,
success: res => {
if (res.statusCode !== 200 || !res.tempFilePath) {
uni.showToast({ title: '二维码下载失败', icon: 'none' });
return;
}
uni.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => uni.showToast({ title: '已保存到相册', icon: 'success' }),
// 文案不提「长按保存」: 鸿蒙元服务的原生 image 无长按菜单, 只微信/H5 有
fail: () => uni.showToast({ title: '保存失败,可复制链接分享', icon: 'none' })
});
},
fail: () => uni.showToast({ title: '二维码下载失败', icon: 'none' })
});
},
shareToFriend() {
// 小程序右上角菜单转发(onshareappmessage 已配置)或提示用户点击右上角
uni.showToast({ title: '请点击右上角「···」转发给好友', icon: 'none' });
},
h5Share() {
// H5: 优先 navigator.share(移动浏览器), 不支持则复制链接兜底
if (navigator && typeof navigator.share === 'function') {
navigator.share({
title: ((this.relation && this.relation.name) || '我的名片') + '的名片',
url: this.shareUrl
}).catch(() => {});
return;
}
this.copyLink();
}
}
};
@@ -152,9 +298,16 @@ export default {
justify-content: center;
flex-shrink: 0;
margin-right: 28rpx;
overflow: hidden;
.head-img {
width: 100%;
height: 100%;
}
}
.head-info {
flex: 1;
min-width: 0;
.head-name {
@@ -168,6 +321,139 @@ export default {
margin-top: 12rpx;
}
}
.head-actions {
flex-shrink: 0;
margin-left: 20rpx;
display: flex;
align-items: center;
}
.head-btn {
font-size: 24rpx;
color: #ffffff;
border: 1rpx solid rgba(255, 255, 255, 0.8);
border-radius: 28rpx;
padding: 8rpx 24rpx;
& + .head-btn {
margin-left: 12rpx;
}
}
}
/* 分享面板(微信式底部抽屉, 与 wap 留资抽屉同风格) */
.share-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 100;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s;
}
.share-mask {
opacity: 1;
pointer-events: auto;
}
.share-sheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: #ffffff;
border-radius: 24rpx 24rpx 0 0;
padding: 20rpx 32rpx 40rpx;
z-index: 101;
transform: translateY(100%);
transition: transform 0.25s;
&.share-sheet-show {
transform: translateY(0);
}
.ss-bar {
width: 36rpx;
height: 4rpx;
border-radius: 2rpx;
background-color: #e3e7ee;
margin: 0 auto 20rpx;
}
.ss-title {
font-size: 32rpx;
font-weight: 600;
color: #1a1f36;
text-align: center;
}
.ss-sub {
font-size: 24rpx;
color: #9aa4b8;
text-align: center;
margin: 8rpx 0 24rpx;
}
.ss-qr-box {
display: flex;
justify-content: center;
margin-bottom: 20rpx;
.ss-qr {
width: 320rpx;
height: 320rpx;
}
}
.ss-link {
font-size: 22rpx;
color: #9aa4b8;
background-color: #f6f7f9;
border-radius: 8rpx;
padding: 14rpx 20rpx;
margin-bottom: 24rpx;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.ss-btns {
display: flex;
justify-content: space-between;
.ss-btn {
flex: 1;
height: 80rpx;
line-height: 80rpx;
text-align: center;
font-size: 28rpx;
color: #333;
border: 1rpx solid #dcdfe6;
border-radius: 12rpx;
& + .ss-btn {
margin-left: 20rpx;
}
}
.ss-btn-main {
background-color: var(--main-color, #0054a5);
border-color: var(--main-color, #0054a5);
color: #ffffff;
}
}
.ss-tip {
text-align: center;
font-size: 22rpx;
color: #c0c4cc;
margin-top: 20rpx;
}
}
.detail-fields {
@@ -182,6 +468,10 @@ export default {
padding: 28rpx 24rpx;
border-bottom: 1rpx solid #f0f0f0;
&.field-row-multi {
align-items: flex-start;
}
&:last-child {
border-bottom: none;
}
@@ -200,6 +490,11 @@ export default {
word-break: break-all;
}
.field-value-multi {
line-height: 1.6;
white-space: pre-wrap;
}
.field-action {
margin-left: 20rpx;
flex-shrink: 0;

570
pages_tool/card/edit.vue Normal file
View File

@@ -0,0 +1,570 @@
<template>
<view class="card-edit">
<!-- 基本信息 -->
<view class="form-group">
<view class="group-title">基本信息</view>
<view class="form-row">
<view class="row-label">头像</view>
<view class="avatar-box" @tap="chooseAvatar">
<image v-if="form.avatar" :src="$util.img(form.avatar)" class="avatar-img" mode="aspectFill" />
<text v-else class="avatar-plus">+</text>
</view>
<view class="avatar-tip">点击从相册选择</view>
</view>
<view class="form-row">
<view class="row-label">姓名</view>
<input class="row-input" :value="form.name" placeholder="请输入姓名"
@input="e => form.name = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">公司</view>
<input class="row-input" :value="form.company" placeholder="请输入公司名称"
@input="e => form.company = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">职位</view>
<input class="row-input" :value="form.job_title" placeholder="请输入职位"
@input="e => form.job_title = e.detail.value" />
</view>
</view>
<!-- 联系方式 -->
<view class="form-group">
<view class="group-title">联系方式</view>
<view class="form-row">
<view class="row-label">手机</view>
<input class="row-input" type="number" :value="form.mobile" placeholder="请输入手机号"
@input="e => form.mobile = e.detail.value" />
</view>
<!-- 附加手机号(动态行, 与主手机号并列提交 mobiles) -->
<view v-for="(m, i) in mobiles" :key="'mob' + i" class="form-row dyn-row">
<view class="row-label">手机{{ i + 2 }}</view>
<input class="row-input" type="number" :value="m" placeholder="其他手机号"
@input="e => onMobileInput(i, e)" />
<text class="dyn-del" @tap="delMobile(i)">×</text>
</view>
<view class="form-row" v-if="mobiles.length < MAX_MOBILES">
<text class="dyn-add" @tap="addMobile">+ 添加手机号(最多{{ MAX_MOBILES }}个)</text>
</view>
<view class="form-row">
<view class="row-label">电话</view>
<input class="row-input" :value="form.phone" placeholder="固定电话(选填)"
@input="e => form.phone = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">邮箱</view>
<input class="row-input" :value="form.email" placeholder="请输入邮箱"
@input="e => form.email = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">网站</view>
<input class="row-input" :value="form.website" placeholder="个人主页 / 官网"
@input="e => form.website = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">微信号</view>
<input class="row-input" :value="form.wechat" placeholder="微信号"
@input="e => form.wechat = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">Telegram</view>
<input class="row-input" :value="form.telegram" placeholder="Telegram 账号(选填)"
@input="e => form.telegram = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">Messenger</view>
<input class="row-input" :value="form.messenger" placeholder="Messenger 账号(选填)"
@input="e => form.messenger = e.detail.value" />
</view>
</view>
<!-- 社交媒体账号 -->
<view class="form-group">
<view class="group-title">社交媒体账号</view>
<view v-for="(s, i) in socials" :key="'soc' + i" class="form-row dyn-row">
<picker class="dyn-picker" :range="socialTypeLabels" :value="socialTypeIndex(s.type)"
@change="e => onSocialTypeChange(i, e)">
<view class="picker-text">{{ socialTypeLabel(s.type) }} ▾</view>
</picker>
<input class="row-input" :value="s.value" placeholder="账号 / 链接"
@input="e => onSocialValueInput(i, e)" />
<text class="dyn-del" @tap="delSocial(i)">×</text>
</view>
<view class="form-row" v-if="socials.length < MAX_SOCIALS">
<text class="dyn-add" @tap="addSocial">+ 添加社交媒体(最多{{ MAX_SOCIALS }}个)</text>
</view>
<view class="group-tip" v-if="socials.length === 0">还没有社交媒体账号,添加后可展示在名片上</view>
</view>
<!-- 自定义字段 -->
<view class="form-group">
<view class="group-title">自定义字段</view>
<view v-for="(c, i) in customFields" :key="'cst' + i" class="form-row dyn-row">
<input class="cf-label" :value="c.label" placeholder="名称"
@input="e => onCustomLabelInput(i, e)" />
<input class="row-input" :value="c.value" placeholder="内容"
@input="e => onCustomValueInput(i, e)" />
<text class="dyn-del" @tap="delCustom(i)">×</text>
</view>
<view class="form-row" v-if="customFields.length < MAX_CUSTOM">
<text class="dyn-add" @tap="addCustom">+ 添加自定义字段(最多{{ MAX_CUSTOM }}个)</text>
</view>
<view class="group-tip" v-if="customFields.length === 0">如「展厅号 E3-108」「收款方式」等个性信息</view>
</view>
<!-- 地址 -->
<view class="form-group">
<view class="group-title">地址</view>
<view class="form-row">
<view class="row-label">国家</view>
<input class="row-input" :value="form.country" placeholder="选填"
@input="e => form.country = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">省份</view>
<input class="row-input" :value="form.province" placeholder="选填"
@input="e => form.province = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">城市</view>
<input class="row-input" :value="form.city" placeholder="选填"
@input="e => form.city = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">详细地址</view>
<input class="row-input" :value="form.address" placeholder="街道门牌等"
@input="e => form.address = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">邮编</view>
<input class="row-input" :value="form.postal_code" placeholder="选填"
@input="e => form.postal_code = e.detail.value" />
</view>
</view>
<!-- 个人简介 -->
<view class="form-group">
<view class="group-title">个人简介</view>
<view class="form-row bio-row">
<textarea class="bio-input" :value="form.bio" placeholder="介绍一下自己…(保留换行)"
@input="e => form.bio = e.detail.value" />
</view>
</view>
<!-- 保存 -->
<view class="save-bar">
<button class="save-btn" :disabled="saving" @tap="save">{{ saving ? '保存中…' : '保存' }}</button>
</view>
</view>
</template>
<script>
// 与后端 BusinessCardFieldNormalizer 输出对齐的标量字段(含 2026-10 补的 bio)。
// 富化字段(mobiles/socials/custom_fields)由页面动态行单独收集,
// 后端 Card::update 的 richFieldSaves 仅对显式携带的键做 CardRichFields 清洗。
const FIELDS = [
'name', 'company', 'job_title', 'avatar',
'mobile', 'phone', 'email', 'website', 'wechat', 'telegram', 'messenger',
'country', 'province', 'city', 'address', 'postal_code',
'bio'
];
// 社交媒体类型: 必须与后端 CardRichFields::SOCIAL_TYPES 白名单保持一致(增减需两边同步)
const SOCIAL_TYPES = [
{ key: 'alipay', label: '支付宝' },
{ key: 'douyin', label: '抖音' },
{ key: 'wxvideo', label: '视频号' },
{ key: 'xiaohongshu', label: '小红书' },
{ key: 'weibo', label: '微博' },
{ key: 'kuaishou', label: '快手' },
{ key: 'bilibili', label: '哔哩哔哩' },
{ key: 'qq', label: 'QQ' },
{ key: 'linkedin', label: 'LinkedIn' },
{ key: 'facebook', label: 'Facebook' },
{ key: 'instagram', label: 'Instagram' },
{ key: 'x', label: 'X' },
{ key: 'other', label: '其他' }
];
// 上限与后端 CardRichFields::MAX_* 一致
const MAX_MOBILES = 5;
const MAX_SOCIALS = 10;
const MAX_CUSTOM = 10;
export default {
data() {
return {
cardId: '',
form: {
name: '', company: '', job_title: '', avatar: '',
mobile: '', phone: '', email: '', website: '', wechat: '', telegram: '', messenger: '',
country: '', province: '', city: '', address: '', postal_code: '',
bio: ''
},
mobiles: [],
socials: [],
customFields: [],
socialTypeLabels: SOCIAL_TYPES.map(t => t.label),
MAX_MOBILES,
MAX_SOCIALS,
MAX_CUSTOM,
isLoading: false,
saving: false
};
},
onLoad(options) {
this.cardId = decodeURIComponent(options.card_id || '');
this.loadDetail();
},
methods: {
loadDetail() {
if (!this.cardId) {
uni.showToast({ title: '缺少名片编号', icon: 'none' });
return;
}
this.isLoading = true;
this.$api.sendRequest({
url: '/card/api/card/detail',
data: { card_id: this.cardId },
success: res => {
this.isLoading = false;
if (res.code == 0) {
const r = (res.data && res.data.relation) || {};
FIELDS.forEach(k => {
if (r[k] !== undefined && r[k] !== null) {
this.form[k] = String(r[k]);
}
});
// 富化字段: detail 由后端 CardRichFields 解码后挂在 relation 上
// (注意是 res.data.relation.mobiles_list, 不是 res.data.mobiles_list)
this.mobiles = (r.mobiles_list || []).map(v => String(v || ''));
this.socials = (r.socials_list || []).map(s => ({
type: s.type || 'other',
value: String(s.value || '')
}));
this.customFields = (r.custom_list || []).map(c => ({
label: String(c.label || ''),
value: String(c.value || '')
}));
} else {
uni.showModal({
title: '提示',
content: res.message || '卡片不存在',
showCancel: false,
success: () => uni.navigateBack()
});
}
},
fail: () => {
this.isLoading = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
},
/* ---------- 头像: 相册选择 → 系统上传 → 回填路径 ---------- */
chooseAvatar() {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
success: res => {
const filePath = res.tempFilePaths && res.tempFilePaths[0];
if (!filePath) return;
uni.showLoading({ title: '上传中…', mask: true });
this.$api.upload({
url: '/api/upload/headimg',
filePath: filePath,
fileType: 'image',
success: r => {
uni.hideLoading();
if (r && r.code == 0 && r.data && r.data.pic_path) {
this.form.avatar = r.data.pic_path;
} else {
uni.showToast({ title: (r && r.message) || '上传失败', icon: 'none' });
}
},
fail: () => {
uni.hideLoading();
uni.showToast({ title: '上传失败', icon: 'none' });
}
});
}
});
},
/* ---------- 附加手机号 ---------- */
onMobileInput(i, e) {
this.$set(this.mobiles, i, e.detail.value);
},
addMobile() {
if (this.mobiles.length >= MAX_MOBILES) {
uni.showToast({ title: '最多 ' + MAX_MOBILES + ' 个手机号', icon: 'none' });
return;
}
this.mobiles.push('');
},
delMobile(i) {
this.mobiles.splice(i, 1);
},
/* ---------- 社交媒体 ---------- */
socialTypeLabel(key) {
const t = SOCIAL_TYPES.find(x => x.key === key);
return t ? t.label : '其他';
},
socialTypeIndex(key) {
const i = SOCIAL_TYPES.findIndex(x => x.key === key);
return i < 0 ? SOCIAL_TYPES.length - 1 : i;
},
onSocialTypeChange(i, e) {
const idx = Number(e.detail.value);
const t = SOCIAL_TYPES[idx];
if (t) this.$set(this.socials, i, { type: t.key, value: this.socials[i].value });
},
onSocialValueInput(i, e) {
const s = this.socials[i];
this.$set(this.socials, i, { type: s.type, value: e.detail.value });
},
addSocial() {
if (this.socials.length >= MAX_SOCIALS) {
uni.showToast({ title: '最多 ' + MAX_SOCIALS + ' 个社交媒体', icon: 'none' });
return;
}
this.socials.push({ type: 'other', value: '' });
},
delSocial(i) {
this.socials.splice(i, 1);
},
/* ---------- 自定义字段 ---------- */
onCustomLabelInput(i, e) {
const c = this.customFields[i];
this.$set(this.customFields, i, { label: e.detail.value, value: c.value });
},
onCustomValueInput(i, e) {
const c = this.customFields[i];
this.$set(this.customFields, i, { label: c.label, value: e.detail.value });
},
addCustom() {
if (this.customFields.length >= MAX_CUSTOM) {
uni.showToast({ title: '最多 ' + MAX_CUSTOM + ' 个自定义字段', icon: 'none' });
return;
}
this.customFields.push({ label: '', value: '' });
},
delCustom(i) {
this.customFields.splice(i, 1);
},
/* ---------- 保存 ---------- */
save() {
if (this.saving) return;
if (!this.form.name.trim()) {
uni.showToast({ title: '请填写姓名', icon: 'none' });
return;
}
this.saving = true;
// 标量全量提交(空值=清空, 与后端 normalizer 语义一致);
// 富化字段始终显式携带(空数组=清空), 后端 richFieldSaves 只对携带的键清洗落库。
// 走 sendJson(JSON body): 嵌套数组序列化跨端零歧义, TP6 原生解析
const fields = {};
FIELDS.forEach(k => { fields[k] = this.form[k]; });
fields.mobiles = this.mobiles.map(v => String(v || '').trim());
fields.socials = this.socials.map(s => ({ type: s.type, value: String(s.value || '').trim() }));
fields.custom_fields = this.customFields.map(c => ({
label: String(c.label || '').trim(),
value: String(c.value || '').trim()
}));
this.$api.sendJson({
url: '/card/api/card/update',
data: {
card_id: this.cardId,
fields: fields
},
success: res => {
this.saving = false;
if (res.code == 0) {
uni.showToast({ title: '保存成功', icon: 'success' });
// 返回详情页; detail.vue 的 onShow 会自动刷新
setTimeout(() => uni.navigateBack(), 600);
} else {
uni.showToast({ title: res.message || '保存失败', icon: 'none' });
}
},
fail: () => {
this.saving = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
}
}
};
</script>
<style lang="scss" scoped>
.card-edit {
min-height: 100vh;
background-color: #f6f7f9;
padding: 24rpx 24rpx 60rpx;
box-sizing: border-box;
}
.form-group {
background-color: #ffffff;
border-radius: 16rpx;
margin-bottom: 24rpx;
overflow: hidden;
.group-title {
font-size: 28rpx;
font-weight: 600;
color: #333;
padding: 24rpx 24rpx 8rpx;
}
.group-tip {
font-size: 22rpx;
color: #999;
padding: 0 24rpx 20rpx;
}
.form-row {
display: flex;
align-items: center;
padding: 24rpx;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.row-label {
width: 160rpx;
font-size: 28rpx;
color: #666;
flex-shrink: 0;
}
.row-input {
flex: 1;
font-size: 28rpx;
color: #333;
}
}
}
/* 头像行 */
.avatar-box {
width: 108rpx;
height: 108rpx;
border-radius: 12rpx;
background-color: #f2f3f5;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
.avatar-img {
width: 100%;
height: 100%;
}
.avatar-plus {
font-size: 44rpx;
color: #c0c4cc;
}
}
.avatar-tip {
margin-left: 20rpx;
font-size: 24rpx;
color: #999;
}
/* 动态行(附加手机号/社交媒体/自定义字段) */
.dyn-row {
.row-label {
width: 160rpx;
}
}
.dyn-del {
width: 44rpx;
text-align: center;
font-size: 34rpx;
color: #c0c4cc;
flex-shrink: 0;
padding: 6rpx 0 6rpx 12rpx;
}
.dyn-add {
flex: 1;
font-size: 26rpx;
color: var(--main-color, #0054a5);
}
.dyn-picker {
flex-shrink: 0;
width: 170rpx;
height: 44rpx;
line-height: 44rpx;
border: 1rpx solid #dcdfe6;
border-radius: 8rpx;
margin-right: 16rpx;
.picker-text {
font-size: 24rpx;
color: #333;
text-align: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.cf-label {
width: 170rpx;
flex-shrink: 0;
font-size: 26rpx;
color: #333;
background-color: #f6f7f9;
border-radius: 8rpx;
padding: 10rpx 16rpx;
margin-right: 16rpx;
}
/* 简介 */
.bio-row {
align-items: flex-start;
.bio-input {
width: 100%;
min-height: 160rpx;
font-size: 28rpx;
color: #333;
line-height: 1.6;
}
}
.save-bar {
padding: 20rpx 0 0;
.save-btn {
height: 88rpx;
line-height: 88rpx;
background-color: var(--main-color, #0054a5);
color: #ffffff;
font-size: 30rpx;
border-radius: 44rpx;
&[disabled] {
opacity: 0.6;
color: #ffffff;
background-color: var(--main-color, #0054a5);
}
}
}
</style>

314
pages_tool/card/leads.vue Normal file
View File

@@ -0,0 +1,314 @@
<template>
<view class="lead-page">
<!-- 状态筛选 -->
<scroll-view scroll-x class="tab-bar">
<view class="tab-inner">
<view v-for="tab in tabs" :key="tab.value" class="tab-item"
:class="{ active: status === tab.value }" @tap="switchTab(tab.value)">
{{ tab.label }}
</view>
</view>
</scroll-view>
<!-- 线索列表 -->
<view class="lead-list">
<view v-for="(item, index) in list" :key="item.id || index" class="lead-item">
<view class="lead-top" @tap="callPhone(item.mobile)">
<view class="lead-name">{{ item.name || '未留姓名' }}</view>
<view class="lead-status" :class="'st-' + item.status">{{ item.status_text || '新线索' }}</view>
</view>
<view class="lead-mobile" v-if="item.mobile" @tap="callPhone(item.mobile)">📱 {{ item.mobile }}</view>
<view class="lead-note" v-if="item.note">「{{ item.note }}」</view>
<view class="lead-meta">
<text class="meta-card">来自名片:{{ item.card_name }}</text>
<text class="meta-time">{{ item.create_time_txt }}</text>
</view>
<view class="lead-actions">
<view class="action-btn copy" v-if="item.wechat" @tap="copyText(item.wechat)">复制微信</view>
<view class="action-btn primary" @tap="changeStatus(item, index)">更新状态</view>
</view>
</view>
<view class="load-more" v-if="list.length > 0">
<text v-if="isLoading">加载中…</text>
<text v-else-if="page < pageCount">上拉加载更多</text>
<text v-else class="load-end">— 没有更多了 —</text>
</view>
<ns-empty v-if="!isLoading && list.length === 0" isIndex="false"
:text="'暂无线索,访客在你的名片页留资后会出现在这里'"></ns-empty>
</view>
</view>
</template>
<script>
// 与后端 Lead::STATUSES 一致: 0=新线索 1=已联系 2=已成交 3=无效
const STATUS_MAP = [
{ value: 0, label: '新线索' },
{ value: 1, label: '已联系' },
{ value: 2, label: '已成交' },
{ value: 3, label: '无效' }
];
export default {
data() {
return {
tabs: [{ value: '', label: '全部' }].concat(STATUS_MAP),
status: '',
list: [],
page: 1,
pageSize: 20,
pageCount: 1,
isLoading: false
};
},
onLoad() {
this.refresh();
},
onPullDownRefresh() {
this.refresh(() => uni.stopPullDownRefresh());
},
onReachBottom() {
if (this.isLoading) return;
if (this.page >= this.pageCount) return;
this.loadPage(this.page + 1);
},
methods: {
switchTab(value) {
if (this.status === value) return;
this.status = value;
this.refresh();
},
refresh(done) {
this.page = 1;
this.loadPage(1, done);
},
loadPage(pageNo, done) {
this.isLoading = true;
const data = { page: pageNo, page_size: this.pageSize };
// 空字符串 = 全部, 后端对 '' 不加 status 条件
if (this.status !== '') {
data.status = this.status;
}
this.$api.sendRequest({
url: '/card/api/lead/lists',
data: data,
success: res => {
if (res.code == 0) {
const d = res.data || {};
const rows = d.list || [];
this.pageCount = d.page_count || 1;
this.page = pageNo;
this.list = pageNo === 1 ? rows : this.list.concat(rows);
} else {
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
}
this.isLoading = false;
typeof done === 'function' && done();
},
fail: () => {
this.isLoading = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
typeof done === 'function' && done();
}
});
},
changeStatus(item, index) {
uni.showActionSheet({
itemList: STATUS_MAP.map(s => s.label),
success: res => {
const target = STATUS_MAP[res.tapIndex];
if (!target || target.value === item.status) return;
this.doSetStatus(item, index, target.value);
}
});
},
doSetStatus(item, index, status) {
this.$api.sendRequest({
url: '/card/api/lead/setStatus',
data: { id: item.id, status: status },
success: res => {
if (res.code == 0) {
// 就地更新, 不整页刷新
this.list[index].status = status;
this.list[index].status_text = STATUS_MAP.find(s => s.value === status).label;
uni.showToast({ title: '已更新', icon: 'success' });
} else {
uni.showToast({ title: res.message || '更新失败', icon: 'none' });
}
},
fail: () => {
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
},
callPhone(mobile) {
if (!mobile) return;
uni.makePhoneCall({ phoneNumber: mobile });
},
copyText(text) {
uni.setClipboardData({
data: text,
success: () => uni.showToast({ title: '已复制', icon: 'success' })
});
}
}
};
</script>
<style lang="scss" scoped>
.lead-page {
min-height: 100vh;
background-color: #f6f7f9;
padding-bottom: 40rpx;
box-sizing: border-box;
}
.tab-bar {
background-color: #ffffff;
white-space: nowrap;
.tab-inner {
display: inline-flex;
padding: 0 12rpx;
}
.tab-item {
display: inline-block;
padding: 24rpx 28rpx;
font-size: 28rpx;
color: #666;
position: relative;
&.active {
color: var(--main-color, #0054a5);
font-weight: 600;
&::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 10rpx;
width: 40rpx;
height: 6rpx;
border-radius: 3rpx;
background-color: var(--main-color, #0054a5);
}
}
}
}
.lead-list {
padding: 20rpx 24rpx 0;
}
.lead-item {
background-color: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
.lead-top {
display: flex;
align-items: center;
.lead-name {
flex: 1;
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.lead-status {
font-size: 22rpx;
padding: 6rpx 18rpx;
border-radius: 22rpx;
&.st-0 {
color: var(--main-color, #0054a5);
background-color: var(--main-color-shallow, #e8f1fb);
}
&.st-1 {
color: #e6a23c;
background-color: #fdf3e6;
}
&.st-2 {
color: #1daa39;
background-color: #e8f8ea;
}
&.st-3 {
color: #999;
background-color: #f2f2f2;
}
}
}
.lead-mobile {
font-size: 28rpx;
color: #333;
margin-top: 14rpx;
}
.lead-note {
font-size: 26rpx;
color: #666;
margin-top: 12rpx;
line-height: 1.5;
}
.lead-meta {
display: flex;
justify-content: space-between;
font-size: 22rpx;
color: #999;
margin-top: 14rpx;
.meta-card {
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.lead-actions {
display: flex;
justify-content: flex-end;
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f0f0f0;
.action-btn {
font-size: 24rpx;
border-radius: 30rpx;
padding: 10rpx 30rpx;
margin-left: 20rpx;
&.copy {
color: #666;
border: 1rpx solid #ddd;
}
&.primary {
color: #ffffff;
background-color: var(--main-color, #0054a5);
}
}
}
}
.load-more {
text-align: center;
color: #999;
font-size: 24rpx;
padding: 24rpx 0;
.load-end {
color: #ccc;
}
}
</style>

217
pages_tool/card/share.vue Normal file
View File

@@ -0,0 +1,217 @@
<template>
<view class="card-share">
<!-- #ifdef MP-WEIXIN || MP-HARMONY -->
<!-- 微信小程序/鸿蒙元服务:web-view 是原生组件,必须用 cover-view 盖在上面(cover-view 不支持 iconfont,用文本) -->
<cover-view class="mp-header">
<cover-view class="mp-status-bar" :style="{ height: statusBarHeight + 'px' }"></cover-view>
<cover-view class="mp-header-inner">
<cover-view class="nav-btn" @click="goBack">
<cover-view class="nav-back-text">‹</cover-view>
</cover-view>
<cover-view class="header-title">名片分享</cover-view>
<cover-view class="nav-btn nav-btn-placeholder"></cover-view>
</cover-view>
</cover-view>
<!-- web-view 是全屏原生组件,父容器 padding 对它无效,用等高占位 view 顶下去 -->
<view class="mp-spacer" :style="{ height: headerTotalHeightPx + 'px' }"></view>
<!-- #endif -->
<!-- 小程序/鸿蒙:web-view 加载 wap H5 名片页(客户视角,含留资表单) -->
<!-- #ifdef MP-WEIXIN || MP-HARMONY -->
<web-view v-if="cardUrl" :src="cardUrl"></web-view>
<view v-else class="empty-tip">链接无效</view>
<!-- #endif -->
<!-- #ifdef H5 -->
<!-- H5 直接就是浏览器,onLoad 时已 location 跳转,这里仅兜底展示 -->
<view class="h5-fallback">
<view class="h5-fallback-tip">正在打开名片…</view>
<button class="h5-fallback-btn" @tap="openUrl">如未自动跳转,点此打开</button>
</view>
<!-- #endif -->
<!-- #ifdef APP-PLUS -->
<web-view v-if="cardUrl" :src="cardUrl"></web-view>
<view v-else class="empty-tip">链接无效</view>
<!-- #endif -->
</view>
</template>
<script>
/**
* 名片分享中转页
*
* 背景: 小程序 onShareAppMessage 的 path 只能指向小程序页面, 不能直接分享 H5 链接,
* 而名片客户页(wap detail)是 H5。因此员工转发名片给微信好友时, 分享的是本页,
* 本页再用 web-view 把客户带到 wap 名片页(含留资表单)。
* H5 端分享就是普通 URL, 不需要中转, onLoad 直接跳转。
* 鸿蒙元服务(MP-HARMONY): web-view 同为原生全屏组件(参考 pages_tool/webview/webview.vue
* 的 cover-view 返回键方案), 与微信小程序共用同一套头部+占位+web-view 结构;
* 无 onShareAppMessage 生命周期, 鸿蒙端分享入口是名片详情页内「分享」按钮。
* 注意: web-view 加载的外链域名需在 manifest → HarmonyOS 网络配置里放行(元服务要求)。
*/
export default {
data() {
return {
cardUrl: '',
statusBarHeight: 20,
windowWidth: 375
};
},
computed: {
// #ifdef MP-WEIXIN || MP-HARMONY
headerTotalHeightPx() {
const rpxToPx = this.windowWidth / 750;
return this.statusBarHeight + Math.round(88 * rpxToPx);
}
// #endif
},
onLoad(options) {
this.cardUrl = options.url ? decodeURIComponent(options.url) : '';
// #ifdef MP-WEIXIN || MP-HARMONY
try {
const sys = uni.getSystemInfoSync();
this.statusBarHeight = sys.statusBarHeight || 20;
this.windowWidth = sys.windowWidth || 375;
} catch (e) {
// 鸿蒙个别版本 getSystemInfoSync 可能抛错, 用默认值兜底(头部高度略差不错位)
console.warn('[card/share] getSystemInfoSync fail, use fallback', e);
}
// #endif
// #ifdef H5
// H5 端无 web-view 中转必要,直接跳转到名片页
if (this.cardUrl) {
window.location.href = this.cardUrl;
}
// #endif
},
methods: {
goBack() {
const pages = getCurrentPages();
if (pages && pages.length > 1) {
uni.navigateBack();
} else {
// 栈底回首页必须走 $util.redirectTo: 鸿蒙元服务上 uni.switchTab 会静默失败
// (success/fail 都不触发, .catch 兜底失效), util 内部有
// switchTab → 500ms 强制 reLaunch 的兜底(见 util.js#redirectTo tabBar 分支)
this.$util.redirectTo('/pages/index/index');
}
},
// #ifdef H5
openUrl() {
if (this.cardUrl) window.location.href = this.cardUrl;
}
// #endif
}
};
</script>
<style lang="scss" scoped>
.card-share {
min-height: 100vh;
background-color: #f5f5f5;
}
.empty-tip {
display: flex;
align-items: center;
justify-content: center;
height: 60vh;
color: #999;
font-size: 28rpx;
}
/* #ifdef MP-WEIXIN || MP-HARMONY */
.mp-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
background-color: #ffffff;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
.mp-status-bar {
width: 100%;
}
.mp-header-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 20rpx;
width: 100%;
box-sizing: border-box;
}
.nav-btn {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
.nav-back-text {
font-size: 48rpx;
color: #333;
line-height: 1;
}
}
.nav-btn-placeholder {
visibility: hidden;
}
.header-title {
flex: 1;
min-width: 0;
font-size: 32rpx;
font-weight: 600;
color: #333;
text-align: center;
margin: 0 16rpx;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.mp-spacer {
width: 100%;
}
/* #endif */
/* #ifdef H5 */
.h5-fallback {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 80vh;
.h5-fallback-tip {
font-size: 28rpx;
color: #999;
margin-bottom: 24rpx;
}
.h5-fallback-btn {
font-size: 28rpx;
color: #333;
border: 1rpx solid #dcdfe6;
border-radius: 36rpx;
padding: 0 40rpx;
height: 72rpx;
line-height: 72rpx;
background: #fff;
margin: 0;
&::after {
border: none;
}
}
}
/* #endif */
</style>

345
tests/card-platform.test.js Normal file
View File

@@ -0,0 +1,345 @@
/**
* card 电子名片模块跨平台适配测试: 微信小程序 / H5 / 鸿蒙元服务
*
* 背景:
* uni-app 产物覆盖 MP-WEIXIN / H5 / MP-HARMONY(鸿蒙元服务) 三端,
* card 模块(名片夹/详情/编辑/线索/分享)任何平台分支遗漏都会在对应端
* 表现为「按钮消失」或「整页白屏」。本测试把适配契约固化为断言。
*
* 覆盖:
* 1. 条件编译配对平衡(防止 #ifdef 漏 #endif 导致编译失败)
* 2. 平台 token 覆盖(detail/share 必须同时照顾三端)
* 3. 保存二维码按钮在 微信+鸿蒙 双端可见(saveImageToPhotosAlbum 鸿蒙可用)
* 4. share.vue 无白页: web-view 分支覆盖 微信+鸿蒙+App, H5 分支有跳转
* 5. goBack 不用裸 uni.switchTab(鸿蒙 switchTab 静默失败, catch 兜底失效,
* 必须走 $util.redirectTo —— 见 common/js/util.js#redirectTo tabBar 分支)
* 6. 分享 URL 编解码闭环(三端语义不同: 微信/鸿蒙不 decode 值, H5 预 decode)
* 7. 平台敏感 API 只在受保护的分支内调用
* 8. card_id 参数编码安全(纯 ASCII, encodeURIComponent 幂等)
*
* 跑法:
* node --test tests/card-platform.test.js
*/
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const CARD_DIR = path.resolve(__dirname, '../pages_tool/card');
const read = f => fs.readFileSync(path.join(CARD_DIR, f), 'utf8');
// =============================================================
// 工具: 条件编译块解析(行注释 // / 块注释 /* */ / HTML <!-- --> 三种写法)
// =============================================================
/**
* 把文件解析成 [{ cond, start, end }] 顶层块列表。
* 兼容三种写法: 行注释 `// #ifdef X`、块注释 `/* #ifdef X *\/`、HTML `<!-- #ifdef X -->`。
* 关键细节:
* - 条件名含连字符(MP-WEIXIN/APP-PLUS), 捕获到注释终止符为止
* - 一行可内联 "#ifdef X -->text<!-- #endif -->", 开/关都按出现次数计
*/
function parseIfdefBlocks(src) {
const lines = src.split('\n');
const stack = []; // [{ cond, startLine }]
const blocks = []; // 顶层块
// 开标记: 捕获条件串(惰性到注释终止符/行尾); 关标记: 单独识别
const TOKEN_RE = /(?:\/\/\s*|\/\*\s*|<!--\s*)(#ifdef|#ifndef|#endif)\b(?:\s+(.+?))?\s*(?:-->|\*\/|$)/g;
lines.forEach((line, i) => {
TOKEN_RE.lastIndex = 0;
let m;
const tokens = [];
while ((m = TOKEN_RE.exec(line)) !== null) {
if (m[0].trim() === '') break; // 防空匹配死循环
tokens.push({ kind: m[1], cond: m[2] ? m[2].trim() : null });
}
for (const tk of tokens) {
if (tk.kind === '#ifdef' || tk.kind === '#ifndef') {
if (stack.length === 0) blocks.push({ cond: tk.cond, start: i, end: lines.length - 1 });
stack.push({ cond: tk.cond, startLine: i });
} else {
const top = stack.pop();
if (top && stack.length === 0) {
const b = blocks[blocks.length - 1];
if (b && b.start === top.startLine) b.end = i;
}
}
}
});
return blocks;
}
/** 判断 cond 串(如 "MP-WEIXIN || MP-HARMONY")在指定平台下是否命中 */
function condHits(cond, platform) {
return cond
.split('||')
.map(s => s.trim().replace(/^!/, ''))
.includes(platform);
}
/** 找出源码中某 API 出现的所有位置及其所属顶层 cond(无保护返回 null) */
function apiOccurrences(src, apiRegex) {
const lines = src.split('\n');
const blocks = parseIfdefBlocks(src);
const out = [];
lines.forEach((line, i) => {
if (!apiRegex.test(line)) return;
// 找包含该行的顶层块
const b = blocks.find(b => i >= b.start && i <= b.end);
out.push({ line: i + 1, text: line.trim(), cond: b ? b.cond : null });
});
return out;
}
// =============================================================
// 1. 条件编译配对平衡
// =============================================================
for (const f of ['list.vue', 'detail.vue', 'edit.vue', 'leads.vue', 'share.vue']) {
test(`ifdef 配对平衡: ${f}`, () => {
const src = read(f);
const open = (src.match(/#ifdef|#ifndef/g) || []).length;
const close = (src.match(/#endif/g) || []).length;
assert.equal(open, close, `${f}: #ifdef/#ifndef=${open} 但 #endif=${close}(漏配对编译必失败)`);
});
}
// =============================================================
// 2. 平台 token 覆盖
// =============================================================
test('平台覆盖: detail.vue 同时含 MP-WEIXIN / H5 / MP-HARMONY 分支', () => {
const src = read('detail.vue');
for (const p of ['MP-WEIXIN', 'H5', 'MP-HARMONY']) {
assert.ok(src.includes(p), `detail.vue 缺少 ${p} 分支(该端分享面板会缺按钮)`);
}
});
test('平台覆盖: share.vue 的 web-view 分支含 MP-HARMONY(防鸿蒙白屏)', () => {
const src = read('share.vue');
// web-view 必须出现在 MP-WEIXIN || MP-HARMONY 保护块内
const wvOcc = apiOccurrences(src, /<web-view/);
assert.ok(wvOcc.length >= 2, 'share.vue 至少应有 2 处 web-view(小程序+App)');
const mpWv = wvOcc.find(o => o.cond && /MP-WEIXIN/.test(o.cond));
assert.ok(mpWv, 'web-view 必须位于 MP-WEIXIN 分支内');
assert.ok(/MP-HARMONY/.test(mpWv.cond),
`web-view 分支必须同时覆盖 MP-HARMONY, 实际 cond="${mpWv.cond}"(鸿蒙会白页)`);
});
test('平台覆盖: share.vue H5 分支有 location.href 跳转(防 H5 停留中转页)', () => {
const src = read('share.vue');
const occ = apiOccurrences(src, /window\.location\.href/);
const h5Occ = occ.find(o => o.cond === 'H5');
assert.ok(h5Occ, 'location.href 必须位于 H5 分支内');
});
// =============================================================
// 3. 保存二维码按钮双端可见
// =============================================================
test('保存二维码: 按钮在 MP-WEIXIN 与 MP-HARMONY 分支均可见', () => {
const src = read('detail.vue');
// saveQr 的按钮绑定点(template 内 @tap="saveQr")
const btnOcc = apiOccurrences(src, /@tap="saveQr"/);
const conds = btnOcc.map(o => o.cond).filter(Boolean);
assert.ok(conds.some(c => /MP-WEIXIN/.test(c)), '保存二维码按钮应有 MP-WEIXIN 分支');
assert.ok(conds.some(c => /MP-HARMONY/.test(c)),
`保存二维码按钮应有 MP-HARMONY 分支(鸿蒙 uni.saveImageToPhotosAlbum 可用), 实际: ${JSON.stringify(conds)}`);
});
test('保存二维码: saveImageToPhotosAlbum/downloadFile 调用不受平台分支保护误删', () => {
// 这两个 API 在鸿蒙受支持, 但不能出现在 H5 分支( H5 无此 API )
const src = read('detail.vue');
for (const api of [/uni\.saveImageToPhotosAlbum/, /uni\.downloadFile/]) {
for (const occ of apiOccurrences(src, api)) {
assert.notEqual(occ.cond, 'H5', `${api} 不应在 H5 分支内(第 ${occ.line} 行)`);
}
}
});
// =============================================================
// 4. 分享中转页不白屏(各端都有可渲染内容)
// =============================================================
test('share.vue 三端均有渲染分支: MP-WEIXIN||MP-HARMONY / H5 / APP-PLUS', () => {
const src = read('share.vue');
const conds = parseIfdefBlocks(src).map(b => b.cond);
assert.ok(conds.some(c => c && /MP-WEIXIN/.test(c) && /MP-HARMONY/.test(c)),
`应有合并的 MP-WEIXIN||MP-HARMONY 分支, 实际: ${JSON.stringify(conds)}`);
assert.ok(conds.includes('H5'), '应有 H5 分支');
assert.ok(conds.includes('APP-PLUS'), '应有 APP-PLUS 分支');
});
// =============================================================
// 5. goBack 不用裸 switchTab(鸿蒙静默失败回归)
// =============================================================
test('goBack: share.vue 不用裸 uni.switchTab(鸿蒙静默失败, catch 不触发)', () => {
const src = read('share.vue');
// 提取 goBack 方法体
const m = src.match(/goBack\(\)\s*\{([\s\S]*?)\n\t\t\},/);
assert.ok(m, '应能提取 goBack 方法体');
const body = m[1];
// 只断言真实调用(带括号), 注释里提到 uni.switchTab 是说明文字不算
assert.ok(!/uni\.switchTab\s*\(/.test(body),
'goBack 不得直接调 uni.switchTab: 鸿蒙上 success/fail 都不触发, .catch 兜底失效');
assert.ok(/\$util\.redirectTo/.test(body),
'goBack 栈底回首页必须走 $util.redirectTo(内部有 switchTab→500ms→reLaunch 兜底)');
});
// =============================================================
// 6. 分享 URL 编解码闭环(三端语义)
// =============================================================
/**
* 模拟三端 onShareAppMessage path → 目标页 onLoad options.url 的传递:
* - 微信: path 里的 query 按 & = 切分但不 decode value(项目实测结论,
* 见 common/js/util-navigate.js#_safeEncodeForPlatform 注释)
* - 鸿蒙: 同微信语义(uni-app harmony 沿用小程序路由)
* - H5: 浏览器预 decode, onLoad 拿到已解码值
*/
function simulateShareOpen(pathStr, platform) {
const qi = pathStr.indexOf('?');
const query = qi === -1 ? '' : pathStr.slice(qi + 1);
const params = {};
for (const kv of query.split('&')) {
if (!kv) continue;
const eq = kv.indexOf('=');
const k = kv.slice(0, eq);
const raw = kv.slice(eq + 1);
// 微信/鸿蒙: value 原样(不 decode); H5: 浏览器已 decode
params[k] = platform === 'h5' ? safeDecode(raw) : raw;
}
return params;
}
function safeDecode(s) {
try { return decodeURIComponent(s); } catch (e) { return s; }
}
test('分享闭环: 微信/鸿蒙 onLoad 拿到编码值, decode 后还原完整 URL', () => {
const shareUrl = 'https://shop.example.com/card/wap/index/detail?site_id=1&id=237';
const pathStr = '/pages_tool/card/share?url=' + encodeURIComponent(shareUrl);
for (const p of ['mp-weixin', 'mp-harmony']) {
const opts = simulateShareOpen(pathStr, p);
assert.ok(opts.url, `${p}: options.url 应存在`);
// 编码值不应含裸 & (否则 query 被切碎, url 被截断)
assert.ok(!opts.url.includes('&'), `${p}: url 参数不应含裸 &`);
// share.vue onLoad 的 decodeURIComponent 必须还原出完整 URL
assert.equal(safeDecode(opts.url), shareUrl, `${p}: decode 后应还原完整分享 URL`);
}
});
test('分享闭环: H5 onLoad 拿到已解码值, 再 decode 是幂等的(无双解码)', () => {
const shareUrl = 'https://shop.example.com/card/wap/index/detail?site_id=1&id=237';
const pathStr = '/pages_tool/card/share?url=' + encodeURIComponent(shareUrl);
const opts = simulateShareOpen(pathStr, 'h5');
assert.equal(safeDecode(opts.url), shareUrl, 'H5: 浏览器已解码, 页面内再 decode 应仍得完整 URL');
});
test('分享闭环: onShareAppMessage 的 path 确实预编码了 url(与 webview.vue 惯例一致)', () => {
const src = read('detail.vue');
assert.ok(/path:\s*'\/pages_tool\/card\/share\?url='\s*\+\s*encodeURIComponent/.test(src),
'onShareAppMessage path 必须 encodeURIComponent(url), 否则 & 会切碎 query');
});
// =============================================================
// 7. card_id 参数编码安全
// =============================================================
test('card_id 编码幂等: 纯 ASCII 编号 encodeURIComponent 不变(双编码无害)', () => {
// 服务端生成格式 c_YYYYMMDD_hex, 只含字母数字下划线
const cardId = 'c_20261012_f1018de954e6';
assert.equal(encodeURIComponent(cardId), cardId, 'card_id 编码后应不变');
assert.equal(decodeURIComponent(encodeURIComponent(cardId)), cardId, '双解码也应无害');
});
// =============================================================
// 8. 名片夹入口导航走 $util.redirectTo(鸿蒙安全)
// =============================================================
test('名片夹入口: member/index.vue 的 tourl 走 $util.redirectTo', () => {
const memberSrc = fs.readFileSync(
path.resolve(__dirname, '../pages_tool/member/index.vue'), 'utf8');
const m = memberSrc.match(/tourl\(url\)\s*\{([\s\S]*?)\n\t\t\}/);
assert.ok(m, 'member/index.vue 应定义 tourl');
assert.ok(/\$util\.redirectTo/.test(m[1]),
'tourl 必须走 $util.redirectTo(鸿蒙 switchTab 静默失败兜底在内)');
});
// =============================================================
// 9. 端到端: 三端分享面板按钮矩阵
// =============================================================
test('端到端: 三端分享面板按钮矩阵(静态推导)', () => {
const src = read('detail.vue');
// 复制链接: 无条件(三端都有)
assert.ok(/@tap="copyLink"/.test(src), '复制链接应三端可用');
// 保存二维码: MP-WEIXIN + MP-HARMONY
// 浏览器分享: H5
// 发给好友: MP-WEIXIN(鸿蒙无转发菜单, 用复制链接代替)
const h5Btns = [];
const wxBtns = [];
const harmonyBtns = [];
// 简化推导: 逐行扫描按钮行并记录当前 cond
const blocks = parseIfdefBlocks(src);
src.split('\n').forEach((line, i) => {
const b = blocks.find(b => i >= b.start && i <= b.end);
const cond = b ? b.cond : null;
const tm = line.match(/@tap="(\w+)"/);
if (!tm || !/ss-btn/.test(line)) return;
if (!cond) { h5Btns.push(tm[1]); wxBtns.push(tm[1]); harmonyBtns.push(tm[1]); return; }
if (condHits(cond, 'MP-WEIXIN')) wxBtns.push(tm[1]);
if (condHits(cond, 'MP-HARMONY')) harmonyBtns.push(tm[1]);
if (condHits(cond, 'H5')) h5Btns.push(tm[1]);
});
assert.ok(wxBtns.includes('copyLink') && wxBtns.includes('saveQr') && wxBtns.includes('shareToFriend'),
`微信按钮矩阵: ${JSON.stringify(wxBtns)}`);
assert.ok(harmonyBtns.includes('copyLink') && harmonyBtns.includes('saveQr'),
`鸿蒙按钮矩阵(无发给好友): ${JSON.stringify(harmonyBtns)}`);
assert.ok(!harmonyBtns.includes('shareToFriend'), '鸿蒙不应出现「发给好友」(无转发菜单)');
assert.ok(h5Btns.includes('copyLink') && h5Btns.includes('h5Share'),
`H5 按钮矩阵: ${JSON.stringify(h5Btns)}`);
assert.ok(!h5Btns.includes('saveQr'), 'H5 不应出现「保存二维码」(无 saveImageToPhotosAlbum)');
});
// ============================================================
// 1.8.0 后台「选择链接」注册: PAGE_KEY_MAP ↔ pages.json 对齐
//
// 后端 lucky_link 注册 CARD_LIST/CARD_LEADS(page_key=PAGE_CARD_LIST/
// PAGE_CARD_LEADS), 前端导航 resolveLinkUrl 优先用 page_key 经
// PAGE_KEY_MAP 解析; 两边必须与 pages.json 注册路径一致, 否则链接点了 404。
// ============================================================
test('选择链接: PAGE_KEY_MAP 含 card 两个页面常量', () => {
const utilSrc = fs.readFileSync(path.resolve(__dirname, '../common/js/util.js'), 'utf8');
const m = utilSrc.match(/PAGE_CARD_LIST:/s*'([^']+)'/);
const m2 = utilSrc.match(/PAGE_CARD_LEADS:/s*'([^']+)'/);
assert.ok(m, 'PAGE_KEY_MAP 应有 PAGE_CARD_LIST');
assert.ok(m2, 'PAGE_KEY_MAP 应有 PAGE_CARD_LEADS');
assert.equal(m[1], '/pages_tool/card/list');
assert.equal(m2[1], '/pages_tool/card/leads');
});
test('选择链接: card 页面已在 pages.json 注册', () => {
const pagesSrc = fs.readFileSync(path.resolve(__dirname, '../pages.json'), 'utf8');
// pages.json 含 uni-app 合法 // 注释, 剥后解析
const stripped = pagesSrc.replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1');
const pages = JSON.parse(stripped);
const sub = (pages.subPackages || []).find(sp => sp.root === 'pages_tool');
assert.ok(sub, 'pages_tool 分包应存在');
const paths = (sub.pages || []).map(p => p.path);
assert.ok(paths.includes('card/list'), `card/list 未注册: ${paths.join(',')}`);
assert.ok(paths.includes('card/leads'), `card/leads 未注册: ${paths.join(',')}`);
});
test('选择链接: card 页面不在 TabBar(NO_TAB_PAGES 语义)不影响导航兜底', () => {
// card 页不是 tabBar 根页, redirectTo 走 navigateTo 分支;
// 此处仅防回归: 确认 util.js 的 TAB_BAR_KEYS 未被误加 card 路径
const utilSrc = fs.readFileSync(path.resolve(__dirname, '../common/js/util.js'), 'utf8');
const tabBlock = utilSrc.match(/TAB_BAR_KEYS\s*=\s*\{[\s\S]*?\}/);
assert.ok(tabBlock, 'TAB_BAR_KEYS 应存在');
assert.ok(!/card/.test(tabBlock[0]), 'TAB_BAR_KEYS 不应含 card 路径(card 非 tabBar 页)');
});