diff --git a/common/js/http.js b/common/js/http.js
index 84f37f3..70804e3 100644
--- a/common/js/http.js
+++ b/common/js/http.js
@@ -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(/(? {
+ 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({
diff --git a/common/js/util.js b/common/js/util.js
index 4ba077c..a5005f4 100644
--- a/common/js/util.js
+++ b/common/js/util.js
@@ -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',
diff --git a/pages.json b/pages.json
index d35d7be..cc3d79a 100644
--- a/pages.json
+++ b/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": {
diff --git a/pages_tool/card/detail.vue b/pages_tool/card/detail.vue
index 3884526..923b411 100644
--- a/pages_tool/card/detail.vue
+++ b/pages_tool/card/detail.vue
@@ -3,7 +3,8 @@
- {{ avatarText(relation.name) }}
+
+ {{ avatarText(relation.name) }}
{{ relation.name || '未命名名片' }}
@@ -11,13 +12,17 @@
{{ [relation.job_title, relation.company].filter(Boolean).join(' · ') }}
+
+ 分享
+ 编辑
+
-
+
{{ row.label }}
- {{ row.value }}
+ {{ row.value }}
{{ row.action.text }}
@@ -25,6 +30,38 @@
+
+
+
+
+ 分享名片
+ 客户打开链接即可查看名片并留言留资
+
+
+
+ {{ shareUrl }}
+
+ 复制链接
+
+ 保存二维码
+ 发给好友
+
+
+ 浏览器分享
+
+
+
+ 保存二维码
+
+
+
+ 长按二维码也可保存到相册
+ 长按二维码可保存到相册
+ 点击「保存二维码」存入相册
+
+
+
加载中…
@@ -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;
diff --git a/pages_tool/card/edit.vue b/pages_tool/card/edit.vue
new file mode 100644
index 0000000..30edf76
--- /dev/null
+++ b/pages_tool/card/edit.vue
@@ -0,0 +1,570 @@
+
+
+
+
+ 基本信息
+
+ 头像
+
+
+ +
+
+ 点击从相册选择
+
+
+ 姓名
+ form.name = e.detail.value" />
+
+
+ 公司
+ form.company = e.detail.value" />
+
+
+ 职位
+ form.job_title = e.detail.value" />
+
+
+
+
+
+ 联系方式
+
+ 手机
+ form.mobile = e.detail.value" />
+
+
+
+ 手机{{ i + 2 }}
+ onMobileInput(i, e)" />
+ ×
+
+
+ + 添加手机号(最多{{ MAX_MOBILES }}个)
+
+
+ 电话
+ form.phone = e.detail.value" />
+
+
+ 邮箱
+ form.email = e.detail.value" />
+
+
+ 网站
+ form.website = e.detail.value" />
+
+
+ 微信号
+ form.wechat = e.detail.value" />
+
+
+ Telegram
+ form.telegram = e.detail.value" />
+
+
+ Messenger
+ form.messenger = e.detail.value" />
+
+
+
+
+
+ 社交媒体账号
+
+ onSocialTypeChange(i, e)">
+ {{ socialTypeLabel(s.type) }} ▾
+
+ onSocialValueInput(i, e)" />
+ ×
+
+
+ + 添加社交媒体(最多{{ MAX_SOCIALS }}个)
+
+ 还没有社交媒体账号,添加后可展示在名片上
+
+
+
+
+ 自定义字段
+
+ onCustomLabelInput(i, e)" />
+ onCustomValueInput(i, e)" />
+ ×
+
+
+ + 添加自定义字段(最多{{ MAX_CUSTOM }}个)
+
+ 如「展厅号 E3-108」「收款方式」等个性信息
+
+
+
+
+ 地址
+
+ 国家
+ form.country = e.detail.value" />
+
+
+ 省份
+ form.province = e.detail.value" />
+
+
+ 城市
+ form.city = e.detail.value" />
+
+
+ 详细地址
+ form.address = e.detail.value" />
+
+
+ 邮编
+ form.postal_code = e.detail.value" />
+
+
+
+
+
+ 个人简介
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pages_tool/card/leads.vue b/pages_tool/card/leads.vue
new file mode 100644
index 0000000..3a47c98
--- /dev/null
+++ b/pages_tool/card/leads.vue
@@ -0,0 +1,314 @@
+
+
+
+
+
+
+ {{ tab.label }}
+
+
+
+
+
+
+
+
+ {{ item.name || '未留姓名' }}
+ {{ item.status_text || '新线索' }}
+
+ 📱 {{ item.mobile }}
+ 「{{ item.note }}」
+
+ 来自名片:{{ item.card_name }}
+ {{ item.create_time_txt }}
+
+
+ 复制微信
+ 更新状态
+
+
+
+
+ 加载中…
+ 上拉加载更多
+ — 没有更多了 —
+
+
+
+
+
+
+
+
+
+
diff --git a/pages_tool/card/share.vue b/pages_tool/card/share.vue
new file mode 100644
index 0000000..742640a
--- /dev/null
+++ b/pages_tool/card/share.vue
@@ -0,0 +1,217 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 链接无效
+
+
+
+
+
+ 正在打开名片…
+
+
+
+
+
+
+ 链接无效
+
+
+
+
+
+
+
diff --git a/tests/card-platform.test.js b/tests/card-platform.test.js
new file mode 100644
index 0000000..25fbf60
--- /dev/null
+++ b/tests/card-platform.test.js
@@ -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 ``。
+ * 关键细节:
+ * - 条件名含连字符(MP-WEIXIN/APP-PLUS), 捕获到注释终止符为止
+ * - 一行可内联 "#ifdef X -->text", 开/关都按出现次数计
+ */
+function parseIfdefBlocks(src) {
+ const lines = src.split('\n');
+ const stack = []; // [{ cond, startLine }]
+ const blocks = []; // 顶层块
+ // 开标记: 捕获条件串(惰性到注释终止符/行尾); 关标记: 单独识别
+ const TOKEN_RE = /(?:\/\/\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, /= 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 页)');
+});