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:
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
20
pages.json
20
pages.json
@@ -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": {
|
||||
|
||||
@@ -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
570
pages_tool/card/edit.vue
Normal 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
314
pages_tool/card/leads.vue
Normal 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
217
pages_tool/card/share.vue
Normal 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
345
tests/card-platform.test.js
Normal 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 页)');
|
||||
});
|
||||
Reference in New Issue
Block a user