diff --git a/common/js/card-api.js b/common/js/card-api.js
index 958e138..46918d3 100644
--- a/common/js/card-api.js
+++ b/common/js/card-api.js
@@ -36,12 +36,16 @@ export const CARD_API_URL = {
CARD_RETRY: '/card/api/card/retry',
CARD_RECOGNIZE_BATCH: '/card/api/card/recognizeBatch',
CARD_DELETE: '/card/api/card/delete', // 删除自己的名片(软删, 2.11.91)
+ CARD_UPLOAD_QR: '/card/api/card/uploadQrImage', // 自定义二维码图片上传(2.20, 走 Http.upload 通道)
// === 跟进完成态 2.11.3 /card/api/card/* ===
CARD_FOLLOW_DONE: '/card/api/card/followDone',
// === 访客轨迹 2.11.4 /card/api/card/* ===
CARD_VIEWS: '/card/api/card/views',
+ // === 名片主键寻址 /card/api/diy/card (小程序码 scene 仅 32 字符装不下业务 card_id, 需按主键 id 换) ===
+ CARD_BY_REL_ID: '/card/api/diy/card', // POST {id} → 返回该主键对应的业务 card(card_id)
+
// === 员工建卡申请 2.11.15 /card/api/apply/* ===
APPLY_SUBMIT: '/card/api/apply/submit',
APPLY_STATUS: '/card/api/apply/status',
@@ -147,7 +151,9 @@ export const CARD_FIELDS = [
'name', 'company', 'job_title', 'avatar',
'mobile', 'phone', 'email', 'website', 'wechat',
'country', 'province', 'city', 'address', 'postal_code',
- 'bio'
+ 'bio',
+ // 2.20 名片级自定义二维码图片(空=用系统生成码)
+ 'qr_image'
];
/** 人脉可更新白名单:与后端 Contact::EDITABLE_FIELDS 一致 */
@@ -280,6 +286,16 @@ const CardApi = {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_VIEWS }, p || {}));
},
+ /**
+ * 按名片主键 id 换业务 card_id(小程序码 scene 仅 32 字符装不下业务 card_id,故先查)
+ * 入参: { data: { id }, success, fail }
+ * 返回 data: { card: { card_id, ... } }
+ * @param {Object} p { data: { id }, success, fail }
+ */
+ cardByRelId(p) {
+ Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_BY_REL_ID }, p || {}));
+ },
+
/* ================= 人脉库 ================= */
/**
diff --git a/components-diy/diy-card-mini.vue b/components-diy/diy-card-mini.vue
index b246c26..b25184e 100644
--- a/components-diy/diy-card-mini.vue
+++ b/components-diy/diy-card-mini.vue
@@ -319,9 +319,38 @@ export default {
showQrBase() {
return flagOn(this.c.showQr, false) && !!this.qrSrc;
},
- /** 二维码码源(2.18): 用户自定义图片(媒体库)优先, 空=名片自动生成的码 */
+ /**
+ * 二维码来源优先级模式(2.20.1, 由用户在 DIY 组件面板显式选择):
+ * card 名片级 > 组件级 > 系统码(默认)
+ * component 组件级 > 名片级 > 系统码
+ * system 仅系统生成码(忽略全部自定义图)
+ * 非法/缺失一律回落 'card' —— 与后端 CardQrSource::normalizePriority 同口径。
+ */
+ qrPriority() {
+ const m = String(this.c.qrPriority || '').trim();
+ return ['card', 'component', 'system'].includes(m) ? m : 'card';
+ },
+ /**
+ * 二维码码源(2.20 三层来源 + 2.20.1 可选优先级, 与后端 CardQrSource::resolve 同口径):
+ * - 名片级 card.qr_image 每张名片各自一张(员工/企业都适用)
+ * - 组件级 c.qrImage 装修期统一指定
+ * - 系统码 card.qr_url 原有行为, 兜底
+ */
qrSrc() {
+ const mode = this.qrPriority;
+ if (mode === 'system') {
+ return (this.card && this.card.qr_url) || '';
+ }
+ const own = String((this.card && this.card.qr_image) || '').trim();
const custom = String(this.c.qrImage || '').trim();
+ if (mode === 'component') {
+ // 组件级优先: 组件图 → 名片图 → 系统码
+ if (custom) return custom;
+ if (own) return own;
+ return (this.card && this.card.qr_url) || '';
+ }
+ // card(默认): 名片级 → 组件级 → 系统码
+ if (own) return own;
if (custom) return custom;
return (this.card && this.card.qr_url) || '';
},
diff --git a/components-diy/diy-card-self.vue b/components-diy/diy-card-self.vue
index 204ec94..a664e84 100644
--- a/components-diy/diy-card-self.vue
+++ b/components-diy/diy-card-self.vue
@@ -48,7 +48,9 @@ export default {
return {
card: null,
isSelf: false,
- loading: false,
+ // 初始即 loading: 首帧渲染走骨架屏, 防止「登录后即可展示我的名片」空态
+ // 在请求发出前抢先渲染一帧(2026-10-27 用户反馈切换名片 tab 时闪现)
+ loading: true,
};
},
computed: {
@@ -119,6 +121,8 @@ export default {
actsBlockPad: v.actsBlockPad,
actIcons: v.actIcons,
qrImage: v.qrImage,
+ // 2.20.1 二维码来源优先级(card/component/system; 缺省 card)
+ qrPriority: v.qrPriority,
// 2.19: 默认动作显隐/文案 + 自定义动作(基件 acts computed 合成)
actVis: v.actVis,
actDeleted: v.actDeleted,
diff --git a/components-diy/diy-card-show.vue b/components-diy/diy-card-show.vue
index e130a15..0a3229d 100644
--- a/components-diy/diy-card-show.vue
+++ b/components-diy/diy-card-show.vue
@@ -85,6 +85,8 @@ export default {
actsBlockPad: v.actsBlockPad,
actIcons: v.actIcons,
qrImage: v.qrImage,
+ // 2.20.1 二维码来源优先级(card/component/system; 缺省 card)
+ qrPriority: v.qrPriority,
// 2.19: 默认动作显隐/文案 + 自定义动作(基件 acts computed 合成)
actVis: v.actVis,
actDeleted: v.actDeleted,
diff --git a/pages_tool/card/detail.vue b/pages_tool/card/detail.vue
index 0afc8b7..ecc2e8c 100644
--- a/pages_tool/card/detail.vue
+++ b/pages_tool/card/detail.vue
@@ -235,7 +235,9 @@ export default {
designEnabled: true,
// 旧入口显隐开关(2026-10-21 需求: 隐藏「编辑资料/名片样式/切换名片」「潜在客户/我的人脉」,
// 隐藏非删除——改回 true 即全部恢复; 对应方法 goEdit/onStyleTap/goLeads/goContacts 均保留)
- showLegacyActions: false
+ showLegacyActions: false,
+
+ loadOptions: ''
};
},
computed: {
@@ -369,11 +371,37 @@ export default {
}
},
onLoad(options) {
- this.cardId = decodeURIComponent(options.card_id || '');
+ this.loadOptions =JSON.stringify(options);
+ // ===== 调试转储(2026-10-27): 定位「缺少名片编号」——看本页到底收到了什么入参 =====
+ // 小程序/扫码/转发进入时, 平台传给 onLoad 的所有参数都打出来(含 scene 解码后)
+ try {
+ console.log('[detail] onLoad 原始 options:', JSON.stringify(options || {}));
+ Object.keys(options || {}).forEach(k => {
+ let v = options[k];
+ try { v = decodeURIComponent(v); } catch (e) {}
+ console.log('[detail] options.' + k, '=', v);
+ });
+ } catch (e) {
+ console.warn('[detail] options 转储失败:', e);
+ }
+
+ // ===== 调试转储结束 =====
this.applyPageTitle();
- this.loadDetail();
- this.loadExchange();
- this.loadDesignEnabled();
+ // 2.21 小程序码入口: scene 里只有名片**主键 id**(微信 scene 仅 32 字符,
+ // 装不下 30 字符的业务 card_id), 需先换成 card_id 才能查详情。
+ const directId = this.pickDirectCardId(options);
+ const sceneId = this.pickSceneRelId(options);
+ if (directId) {
+ this.cardId = directId;
+ this.loadDetail();
+ this.loadExchange();
+ this.loadDesignEnabled();
+ } else if (sceneId) {
+ this.resolveCardIdByRelId(sceneId);
+ } else {
+ this.isLoading = false;
+ this.errorMsg = `缺少名片编号 ${this.loadOptions}`;
+ }
},
// H5 直接刷新/深链进入时, uni-page-head 可能在路由 meta 就绪前就挂载,
// 此时标题会回落到 document.title(uni-app H5 默认值 "title");
@@ -406,6 +434,77 @@ export default {
};
},
methods: {
+ // 2.21 小程序码: 名片 ID 解析
+ //
+ // 微信 getwxacodeunlimit 的 scene 限制 32 可见字符, 装不下业务 card_id
+ // (形如 c_1_shop_20261013111612_9588EA, 30 字符) → scene 里放的是
+ // 名片**主键 id**(数字)。故两条入口:
+ // ① 常规路由: query 带 card_id → 直接用
+ // ② 小程序码: options.scene = "id=123" → 先按主键换业务 card_id
+ //
+ // 常规入口(①)
+ pickDirectCardId(options) {
+ const opt = options || {};
+ if (opt.card_id) {
+ try {
+ return decodeURIComponent(opt.card_id);
+ } catch (e) {
+ return opt.card_id;
+ }
+ }
+ return '';
+ },
+ // 小程序码入口(②): 从 scene 里取名片主键 id
+ // scene 形态: "id=123" (微信会先 encodeURIComponent, 需先解码)
+ pickSceneRelId(options) {
+ const opt = options || {};
+ if (!opt.scene) {
+ return 0;
+ }
+ let scene = '';
+ try {
+ scene = decodeURIComponent(opt.scene);
+ } catch (e) {
+ scene = opt.scene;
+ }
+ // 兼容 "id=123" 与裸 "123"
+ let val = scene;
+ const pairs = scene.split('&');
+ for (let i = 0; i < pairs.length; i++) {
+ const kv = pairs[i].split('=');
+ if (kv[0] === 'id' && kv[1]) {
+ val = kv[1];
+ break;
+ }
+ }
+ val = String(val).replace(/[^0-9]/g, '');
+ return val ? parseInt(val, 10) : 0;
+ },
+ // 按主键 id 换业务 card_id(后端 /card/api/diy/card 支持 id 参数寻址)
+ resolveCardIdByRelId(relId) {
+ this.isLoading = true;
+ this.$cardApi.cardByRelId({
+ data: { id: relId },
+ success: res => {
+ const card = (res.code == 0 && res.data && res.data.card) ? res.data.card : null;
+ const bizId = (card && card.card_id) ? card.card_id : '';
+ if (!bizId) {
+ this.isLoading = false;
+ this.errorMsg = '名片不存在或已被删除';
+ this.relation = null;
+ return;
+ }
+ this.cardId = bizId;
+ this.loadDetail();
+ this.loadExchange();
+ this.loadDesignEnabled();
+ },
+ fail: () => {
+ this.isLoading = false;
+ this.errorMsg = '网络异常,请稍后重试';
+ },
+ });
+ },
// 导航栏标题统一出口: 小程序/鸿蒙/App 走原生导航栏;
// H5 额外同步 document.title, 避免 uni-page-head 标题回落到默认值 "title"。
applyPageTitle() {
@@ -446,7 +545,7 @@ export default {
loadDetail(retry) {
if (!this.cardId) {
this.isLoading = false;
- this.errorMsg = '缺少名片编号';
+ this.errorMsg = `缺少名片编号 ${this.loadOptions}`;
return;
}
this.isLoading = true;
@@ -454,6 +553,13 @@ export default {
data: { card_id: this.cardId },
success: res => {
const data = res.data || {};
+ // ===== 调试转储(2026-10-27): 把 card/detail 后台数据打到控制台 =====
+ // 完整响应可在控制台展开查看; 关键字段单独打出, 便于快速核对二维码/公开页地址
+ console.log('[detail] card/detail 返回(code=' + res.code + '):', JSON.parse(JSON.stringify(res)));
+ console.log('[detail] 关键字段 -> card_id:', this.cardId,
+ '| wap_url:', data.wap_url || '(空)',
+ '| qr_url:', data.qr_url || '(空)');
+ // ===== 调试转储结束 =====
if (res.code == 0) {
this.isLoading = false;
this.relation = data.relation || null;
diff --git a/pages_tool/card/edit.vue b/pages_tool/card/edit.vue
index 56eb1e2..65219bc 100644
--- a/pages_tool/card/edit.vue
+++ b/pages_tool/card/edit.vue
@@ -9,8 +9,21 @@
+
- 点击从相册选择
+ 点击从相册选择
+
+
+
+ 二维码
+
+
+ +
+
+ 点击上传自定义二维码
+ {{ form.qr_image ? '已自定义,将替换默认二维码' : '未设置时使用名片链接自动生成的码' }}
+ 移除自定义二维码
+
+
*姓名
String(v || ''));
@@ -284,6 +305,41 @@ export default {
});
},
+ /* ---------- 自定义二维码图片(2.20): 相册选择 → card 专用上传 → 回填路径 ---------- */
+ chooseQrImage() {
+ uni.chooseImage({
+ count: 1,
+ sizeType: ['compressed'],
+ sourceType: ['album', 'camera'],
+ success: res => {
+ const filePath = res.tempFilePaths && res.tempFilePaths[0];
+ if (!filePath) return;
+ uni.showLoading({ title: '上传中…', mask: true });
+ this.$api.upload({
+ url: this.$cardApi.url.CARD_UPLOAD_QR,
+ filePath: filePath,
+ fileType: 'image',
+ success: r => {
+ uni.hideLoading();
+ if (r && r.code == 0 && r.data && r.data.pic_path) {
+ this.form.qr_image = r.data.pic_path;
+ } else {
+ uni.showToast({ title: (r && r.message) || '上传失败', icon: 'none' });
+ }
+ },
+ fail: () => {
+ uni.hideLoading();
+ uni.showToast({ title: '上传失败', icon: 'none' });
+ }
+ });
+ }
+ });
+ },
+ /* 移除自定义二维码: 置空 = 提交时回落系统自动生成的码 */
+ clearQrImage() {
+ this.form.qr_image = '';
+ },
+
/* ---------- 附加手机号 ---------- */
onMobileInput(i, e) {
this.$set(this.mobiles, i, e.detail.value);
@@ -499,6 +555,49 @@ export default {
color: #999;
}
+/* 自定义二维码行(2.20) */
+.qr-box {
+ width: 108rpx;
+ height: 108rpx;
+ border-radius: 12rpx;
+ background-color: #f2f3f5;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ flex-shrink: 0;
+
+ .qr-img {
+ width: 100%;
+ height: 100%;
+ }
+
+ .qr-plus {
+ font-size: 44rpx;
+ color: #c0c4cc;
+ }
+}
+
+.qr-side {
+ flex: 1;
+ margin-left: 20rpx;
+ min-width: 0;
+}
+
+.qr-tip {
+ font-size: 22rpx;
+ color: #b0b4bc;
+ margin-top: 4rpx;
+ line-height: 1.5;
+}
+
+.qr-clear {
+ display: inline-block;
+ margin-top: 6rpx;
+ font-size: 24rpx;
+ color: var(--main-color, #0054a5);
+}
+
/* 动态行(附加手机号/社交媒体/自定义字段) */
.dyn-row {
.row-label {
diff --git a/tests/card-admin.test.js b/tests/card-admin.test.js
deleted file mode 100644
index ac9a54a..0000000
--- a/tests/card-admin.test.js
+++ /dev/null
@@ -1,186 +0,0 @@
-/**
- * 名片云 · 商家端(admin)页面测试
- *
- * 背景:
- * 商家端 7 个页面中, 后台接口先就绪的是 contact/* 与 lead/*(以及 card/lists),
- * 因此本轮先上三个页面(其余 5 页等后台接口, 见 CARD_API_REFERENCE.md 缺口):
- * - admin/manage 名片管理(基础版: 搜索/详情/编辑, 详情编辑复用员工端页)
- * - admin/contacts 人脉管理(列表/搜索/来源筛选/编辑/转线索/删除)
- * - admin/leads 潜在客户(状态筛选/列表/更新跟进状态)
- * 本测试固化商家端契约: 注册/收口/鸿蒙安全导航/复用回跳。
- *
- * 跑法:
- * node --test tests/card-admin.test.js
- */
-
-'use strict';
-
-const test = require('node:test');
-const assert = require('node:assert/strict');
-const fs = require('fs');
-const path = require('path');
-
-const ROOT = path.resolve(__dirname, '..');
-const ADMIN_DIR = path.join(ROOT, 'pages_tool/card/admin');
-const readAdmin = f => fs.readFileSync(path.join(ADMIN_DIR, f), 'utf8');
-const readCard = f => fs.readFileSync(path.join(ROOT, 'pages_tool/card', f), 'utf8');
-const pagesSrc = fs.readFileSync(path.join(ROOT, 'pages.json'), 'utf8');
-const utilSrc = fs.readFileSync(path.join(ROOT, 'common/js/util.js'), 'utf8');
-
-// pages.json 含 uni-app 合法 // 注释, 剥后解析
-const stripped = pagesSrc.replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1');
-const pagesJson = JSON.parse(stripped);
-
-const ADMIN_PAGES = {
- 'card/admin/manage': '名片管理',
- 'card/admin/contacts': '人脉管理',
- 'card/admin/leads': '潜在客户'
-};
-
-/** 取 pages_tool 分包已注册路径 */
-function toolPaths() {
- const sub = (pagesJson.subPackages || []).find(sp => sp.root === 'pages_tool');
- assert.ok(sub, 'pages_tool 分包应存在');
- return (sub.pages || []).map(p => p.path);
-}
-
-// =============================================================
-// 1. 页面注册
-// =============================================================
-
-test('注册: 商家端 3 页已注册进 pages.json 的 pages_tool 分包', () => {
- const paths = toolPaths();
- for (const p of Object.keys(ADMIN_PAGES)) {
- assert.ok(paths.includes(p), `${p} 未注册: ${paths.join(',')}`);
- }
-});
-
-test('注册: 商家端页面标题正确且不在 TabBar', () => {
- const sub = (pagesJson.subPackages || []).find(sp => sp.root === 'pages_tool');
- for (const [p, title] of Object.entries(ADMIN_PAGES)) {
- const page = (sub.pages || []).find(x => x.path === p);
- assert.ok(page, `${p} 应存在`);
- assert.equal(page.style.navigationBarTitleText, title, `${p} 标题应为「${title}」`);
- // 名片云全部页面都不是 tabBar 根页
- assert.ok(page.style.enablePullDownRefresh === true, `${p} 应开启下拉刷新`);
- }
- const tabPages = (pagesJson.tabBar && pagesJson.tabBar.list ? pagesJson.tabBar.list : []).map(p => p.pagePath);
- for (const p of Object.keys(ADMIN_PAGES)) {
- assert.ok(!tabPages.some(tp => tp.includes(p)), `TabBar 不应含 ${p}`);
- }
-});
-
-test('注册: PAGE_KEY_MAP 含商家端 3 页且与 pages.json 一致', () => {
- const map = {
- PAGE_CARD_ADMIN_MANAGE: '/pages_tool/card/admin/manage',
- PAGE_CARD_ADMIN_CONTACTS: '/pages_tool/card/admin/contacts',
- PAGE_CARD_ADMIN_LEADS: '/pages_tool/card/admin/leads'
- };
- for (const [key, val] of Object.entries(map)) {
- const m = utilSrc.match(new RegExp(`${key}:\\s*'([^']+)'`));
- assert.ok(m, `PAGE_KEY_MAP 应有 ${key}`);
- assert.equal(m[1], val);
- assert.ok(toolPaths().includes(val.slice('/pages_tool/'.length)),
- `${val} 未在 pages.json 注册`);
- }
- // 防回归: TAB_BAR_KEYS 不得被误加 admin 路径
- const tabBlock = utilSrc.match(/TAB_BAR_KEYS\s*=\s*\{[\s\S]*?\}/);
- assert.ok(tabBlock, 'TAB_BAR_KEYS 应存在');
- assert.ok(!/admin/.test(tabBlock[0]), 'TAB_BAR_KEYS 不应含 admin 路径');
-});
-
-// =============================================================
-// 2. API 收口(与 card-api 测试同一契约, 覆盖新页面)
-// =============================================================
-
-test('收口: 商家端 3 页零 /card/api/ 字面量且走 this.$cardApi', () => {
- for (const f of ['manage.vue', 'contacts.vue', 'leads.vue']) {
- const src = readAdmin(f);
- assert.ok(!/['"]\/card\/api\//.test(src), `${f} 仍直连 /card/api/ 字面量`);
- assert.ok(/this\.\$cardApi\./.test(src), `${f} 未使用 this.$cardApi`);
- }
-});
-
-test('收口: 商家端页面从 card-api.js 导入共享字典', () => {
- assert.ok(/import\s*\{\s*CONTACT_SOURCES\s*\}\s*from\s*'@\/common\/js\/card-api\.js'/.test(readAdmin('contacts.vue')),
- 'contacts.vue 应导入 CONTACT_SOURCES');
- assert.ok(/import\s*\{\s*LEAD_STATUS\s*\}\s*from\s*'@\/common\/js\/card-api\.js'/.test(readAdmin('leads.vue')),
- 'leads.vue 应导入 LEAD_STATUS');
-});
-
-test('收口: leads.vue 的 setStatus 只接受 0-3(后端约束)', () => {
- const src = readAdmin('leads.vue');
- // ActionSheet 选项来自 LEAD_STATUS(0-3), 不产生越界值
- assert.ok(/itemList:\s*LEAD_STATUS\.map/.test(src), '状态选项必须来自 LEAD_STATUS 字典');
- assert.ok(!/status\s*=\s*[4-9]/.test(src), '不得出现 0-3 之外的 status 值');
-});
-
-// =============================================================
-// 3. 鸿蒙安全导航(禁裸 uni.switchTab 静默失败)
-// =============================================================
-
-test('鸿蒙: 商家端 3 页平级互链全部走 $util.redirectTo', () => {
- const cross = {
- 'manage.vue': [['goContacts', '/pages_tool/card/admin/contacts'], ['goLeads', '/pages_tool/card/admin/leads']],
- 'contacts.vue': [['goManage', '/pages_tool/card/admin/manage'], ['goLeads', '/pages_tool/card/admin/leads']],
- 'leads.vue': [['goManage', '/pages_tool/card/admin/manage'], ['goContacts', '/pages_tool/card/admin/contacts']]
- };
- for (const [f, pairs] of Object.entries(cross)) {
- const src = readAdmin(f);
- for (const [method, target] of pairs) {
- const m = src.match(new RegExp(`${method}\\(\\)\\s*\\{([\\s\\S]*?)\\n\\t\\t\\}`));
- assert.ok(m, `${f} 应定义 ${method}`);
- assert.ok(/\$util\.redirectTo/.test(m[1]), `${f}#${method} 必须走 $util.redirectTo`);
- assert.ok(new RegExp(`redirectTo\\('${target.replace(/\//g, '\\/')}'\\)`).test(m[1]),
- `${f}#${method} 目标应为 ${target}`);
- }
- assert.ok(!/uni\.switchTab\s*\(/.test(src), `${f} 不得直接调 uni.switchTab`);
- }
-});
-
-test('鸿蒙: manage.vue 跳详情/编辑用 navigateTo + encodeURIComponent(card_id)', () => {
- const src = readAdmin('manage.vue');
- // 名片路由参数一律 card_id(字符串), 双编码无害(纯 ASCII)
- assert.ok(/\/pages_tool\/card\/detail\?card_id='\s*\+\s*encodeURIComponent\(item\.card_id\)/.test(src),
- 'goDetail 应 encodeURIComponent(card_id)');
- assert.ok(/\/pages_tool\/card\/edit\?card_id='\s*\+\s*encodeURIComponent\(item\.card_id\)/.test(src),
- 'goEdit 应 encodeURIComponent(card_id)');
-});
-
-// =============================================================
-// 4. 编辑页复用回跳(from=admin)
-// =============================================================
-
-test('复用: contact-edit 支持 from=admin 且缺省行为不变', () => {
- const src = readCard('contact-edit.vue');
- assert.ok(/this\.fromAdmin\s*=\s*options\.from\s*===\s*'admin'/.test(src),
- 'onLoad 应解析 from=admin');
- const m = src.match(/backToList\(\)\s*\{([\s\S]*?)\n\t\t\}/);
- assert.ok(m, '应能提取 backToList 方法体');
- assert.ok(/\$util\.redirectTo/.test(m[1]), 'backToList 必须走 $util.redirectTo');
- assert.ok(/this\.fromAdmin\s*\?/.test(m[1]), 'backToList 应按来源分支');
- assert.ok(m[1].includes("'/pages_tool/card/admin/contacts'"),
- '商家端应回 /pages_tool/card/admin/contacts');
- assert.ok(m[1].includes("'/pages_tool/card/contacts'"),
- '员工端缺省仍回 /pages_tool/card/contacts(不破坏既有行为)');
- assert.ok(!/uni\.switchTab\s*\(/.test(src), 'contact-edit.vue 不得直接调 uni.switchTab');
-});
-
-test('复用: admin/contacts 的编辑入口带 from=admin', () => {
- const src = readAdmin('contacts.vue');
- assert.ok(/contact-edit\?from=admin&id='/.test(src), '编辑入口应带 from=admin&id=');
- assert.ok(/contact-edit\?from=admin'/.test(src), '添加入口应带 from=admin');
-});
-
-// =============================================================
-// 5. 条件编译配对平衡(新文件同样纳入)
-// =============================================================
-
-test('ifdef 配对平衡: 商家端 3 页', () => {
- for (const f of ['manage.vue', 'contacts.vue', 'leads.vue']) {
- const src = readAdmin(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}`);
- }
-});
diff --git a/tests/card-api.test.js b/tests/card-api.test.js
index be964a3..938fe00 100644
--- a/tests/card-api.test.js
+++ b/tests/card-api.test.js
@@ -47,9 +47,11 @@ const DOC_URLS = [
'/card/api/card/recognize',
'/card/api/card/retry',
'/card/api/card/delete', // 后端新增端点, 与源码同步
+ '/card/api/card/uploadQrImage', // 自定义二维码上传(2.20)
'/card/api/card/recognizeBatch',
'/card/api/card/followDone', // 跟进完成态 2.11.3
'/card/api/card/views', // 访客轨迹 2.11.4
+ '/card/api/diy/card', // 小程序码主键寻址换业务 card_id(2.21)
// 人脉库 /card/api/contact/*
'/card/api/contact/lists',
'/card/api/contact/add',
@@ -101,6 +103,7 @@ const HELPER_URL_PAIRS = [
['exchangeIgnore', 'EXCHANGE_IGNORE'],
['cardFollowDone', 'CARD_FOLLOW_DONE'],
['cardViews', 'CARD_VIEWS'],
+ ['cardByRelId', 'CARD_BY_REL_ID'],
['applySubmit', 'APPLY_SUBMIT'],
['applyStatus', 'APPLY_STATUS'],
['cardDesignPresets', 'CARD_DESIGN_PRESETS'],
diff --git a/tests/diy-card-qr-image-source.test.js b/tests/diy-card-qr-image-source.test.js
new file mode 100644
index 0000000..94141e4
--- /dev/null
+++ b/tests/diy-card-qr-image-source.test.js
@@ -0,0 +1,299 @@
+/**
+ * 名片 DIY 组件 · 名片级自定义二维码(card.qr_image)契约测试
+ *
+ * 背景(2.20):
+ * 此前二维码只有两个来源 —— 组件级自定义图(c.qrImage)与系统生成码(card.qr_url)。
+ * 2.20 起每张名片(员工名片/企业名片)可各自持有一张自定义二维码图片,
+ * DIY 组件按三层优先级取用:
+ * 1) 名片级 card.qr_image ← 每张名片各自一张(「我的名片」登录后即见本人那张)
+ * 2) 组件级 c.qrImage ← 装修期统一指定(名片自己没设图时生效)
+ * 3) 系统码 card.qr_url ← 兜底(原有行为)
+ *
+ * 覆盖:
+ * 1. mini 基件 qrSrc 按上述优先级取值(源码契约 + 真实求值双证)
+ * 2. 组件级 qrImage 仍完整保留(未被名片级顶掉)
+ * 3. 反例: 虚构字段不得出现(防恒真)
+ *
+ * 跑法: node --test tests/diy-card-qr-image-source.test.js
+ */
+
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('fs');
+const path = require('path');
+
+const DIY_DIR = path.resolve(__dirname, '../components-diy');
+const read = f => fs.readFileSync(path.join(DIY_DIR, f), 'utf8');
+// 先剥注释再断言 —— 注释里写「绝不能写 X」会被自判违规
+const strip = s => s
+ .replace(//g, '')
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .replace(/(^|[^:])\/\/.*$/gm, '$1');
+
+const miniSrc = strip(read('diy-card-mini.vue'));
+
+/**
+ * 从 mini.vue 抽出 qrSrc 的真实函数体并求值 —— 不靠正则猜逻辑,
+ * 避免「正则匹配到注释/字符串就假绿」。
+ *
+ * 函数体内部用的是 this.c / this.card, 故以 ctx 为 this 求值。
+ */
+function extractQrSrc(src) {
+ const m = src.match(/qrSrc\(\)\s*\{([\s\S]*?)\n\t\t\}/);
+ assert.ok(m, 'mini.vue 找不到 qrSrc 计算属性');
+ return m[1];
+}
+
+/** 用构造器求值 qrSrc 函数体(只依赖 card/c 两个入参) */
+function evalQrSrc(card, config) {
+ const body = extractQrSrc(miniSrc);
+ // eslint-disable-next-line no-new-func
+ const fn = new Function('card', 'c', body);
+ // ctx 需提供 qrPriority 计算属性(Vue 里 qrSrc 依赖 this.qrPriority)
+ const ctx = {
+ card: card,
+ c: config,
+ get qrPriority() { return evalQrPriority(config); },
+ };
+ return fn.call(ctx, card, config);
+}
+
+/** 抽 qrPriority 计算属性体并求值(模式白名单化) */
+function evalQrPriority(config) {
+ const m = miniSrc.match(/qrPriority\(\)\s*\{([\s\S]*?)\n\t\t\}/);
+ assert.ok(m, 'mini.vue 找不到 qrPriority 计算属性');
+ // eslint-disable-next-line no-new-func
+ const fn = new Function('c', m[1]);
+ const ctx = { c: config };
+ return fn.call(ctx, config);
+}
+
+test('mini: qrSrc 源码按「名片级 > 组件级 > 系统码」取用', () => {
+ const body = extractQrSrc(miniSrc);
+ // 名片级: card.qr_image
+ assert.ok(/card\.qr_image/.test(body), 'qrSrc 未消费名片级 card.qr_image');
+ // 组件级: c.qrImage
+ assert.ok(/c\.qrImage/.test(body), 'qrSrc 未保留组件级 c.qrImage');
+ // 系统码兜底: card.qr_url
+ assert.ok(/card\.qr_url/.test(body), 'qrSrc 未保留系统码兜底 card.qr_url');
+});
+
+test('mini: qrSrc 真实求值 —— 名片级优先于组件级', () => {
+ const got = evalQrSrc(
+ { qr_image: '/upload/own.png', qr_url: 'http://x/q' },
+ { qrImage: '/upload/component.png' }
+ );
+ assert.equal(got, '/upload/own.png');
+});
+
+test('mini: qrSrc 真实求值 —— 名片级为空时回落组件级', () => {
+ const got = evalQrSrc(
+ { qr_image: '', qr_url: 'http://x/q' },
+ { qrImage: '/upload/component.png' }
+ );
+ assert.equal(got, '/upload/component.png');
+});
+
+test('mini: qrSrc 真实求值 —— 两级皆空回落系统生成码(原行为)', () => {
+ const got = evalQrSrc(
+ { qr_image: '', qr_url: 'http://x/q' },
+ { qrImage: '' }
+ );
+ assert.equal(got, 'http://x/q');
+});
+
+test('mini: qrSrc 真实求值 —— 名片级优先(组件级也配了图时)', () => {
+ // 「我的名片」组件: 员工登录后应看到属于该员工自己名片的那张图,
+ // 即使装修时组件级也配了图 —— 名片级更贴近实体, 优先。
+ const got = evalQrSrc(
+ { qr_image: '/upload/employee-own.png', qr_url: 'http://x/q' },
+ { qrImage: '/upload/shop-level.png' }
+ );
+ assert.equal(got, '/upload/employee-own.png');
+});
+
+test('mini: qrSrc 真实求值 —— card 为 null 不抛异常(未登录空态)', () => {
+ const got = evalQrSrc(null, { qrImage: '/upload/component.png' });
+ assert.equal(got, '/upload/component.png');
+});
+
+// =============================================================
+// 2.20.1 优先级模式(用户在 DIY 组件面板显式选择)
+// =============================================================
+
+test('mini: qrPriority 白名单化 —— 缺失/非法值回落 card', () => {
+ assert.equal(evalQrPriority({}), 'card');
+ assert.equal(evalQrPriority({ qrPriority: '' }), 'card');
+ assert.equal(evalQrPriority({ qrPriority: 'evil' }), 'card');
+ assert.equal(evalQrPriority({ qrPriority: 'component' }), 'component');
+ assert.equal(evalQrPriority({ qrPriority: 'system' }), 'system');
+});
+
+test('mini: mode=card 名片级优先(默认; 员工登录看到自己那张)', () => {
+ const got = evalQrSrc(
+ { qr_image: '/upload/own.png', qr_url: 'http://x/q' },
+ { qrImage: '/upload/component.png', qrPriority: 'card' }
+ );
+ assert.equal(got, '/upload/own.png');
+});
+
+test('mini: mode=card 名片级为空则用组件级', () => {
+ const got = evalQrSrc(
+ { qr_image: '', qr_url: 'http://x/q' },
+ { qrImage: '/upload/component.png', qrPriority: 'card' }
+ );
+ assert.equal(got, '/upload/component.png');
+});
+
+test('mini: mode=component 组件级优先于名片级', () => {
+ const got = evalQrSrc(
+ { qr_image: '/upload/own.png', qr_url: 'http://x/q' },
+ { qrImage: '/upload/component.png', qrPriority: 'component' }
+ );
+ assert.equal(got, '/upload/component.png');
+});
+
+test('mini: mode=component 组件级为空则回落名片级', () => {
+ const got = evalQrSrc(
+ { qr_image: '/upload/own.png', qr_url: 'http://x/q' },
+ { qrImage: '', qrPriority: 'component' }
+ );
+ assert.equal(got, '/upload/own.png');
+});
+
+test('mini: mode=system 只用系统生成码(忽略全部自定义图)', () => {
+ const got = evalQrSrc(
+ { qr_image: '/upload/own.png', qr_url: 'http://x/q' },
+ { qrImage: '/upload/component.png', qrPriority: 'system' }
+ );
+ assert.equal(got, 'http://x/q');
+});
+
+test('mini: mode=system 名片级有图也忽略', () => {
+ const got = evalQrSrc(
+ { qr_image: '/upload/own.png', qr_url: 'http://x/q' },
+ { qrImage: '', qrPriority: 'system' }
+ );
+ assert.equal(got, 'http://x/q');
+});
+
+test('mini: 未设置 qrPriority(存量实例)等价于 card', () => {
+ const withMode = evalQrSrc(
+ { qr_image: '/upload/own.png', qr_url: 'http://x/q' },
+ { qrImage: '/upload/component.png', qrPriority: 'card' }
+ );
+ const withoutMode = evalQrSrc(
+ { qr_image: '/upload/own.png', qr_url: 'http://x/q' },
+ { qrImage: '/upload/component.png' }
+ );
+ assert.equal(withoutMode, withMode);
+});
+
+// =============================================================
+// 面板契约(两个组件的 design.html 都要有来源优先级控件)
+// =============================================================
+
+const PANEL_DIR = path.resolve(__dirname, '../../backend/src/addon/card/component/view');
+const hasPanels = fs.existsSync(path.join(PANEL_DIR, 'diy-card-self/design.html'));
+
+if (hasPanels) {
+ const selfPanel = strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-self/design.html'), 'utf8'));
+ const showPanel = strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-show/design.html'), 'utf8'));
+
+ for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
+ test('面板(' + name + '): 二维码来源优先级三档控件均存在', () => {
+ for (const v of ['card', 'component', 'system']) {
+ assert.ok(
+ panel.includes("nc.qrPriority = '" + v + "'"),
+ name + ' 面板缺优先级选项 ' + v
+ );
+ }
+ });
+
+ test('面板(' + name + '): 来源控件在「显示二维码」打开时才显示', () => {
+ // 控件所在容器必须带 v-show="nc.showQr"
+ const idx = panel.indexOf("nc.qrPriority = 'card'");
+ assert.ok(idx > 0, name + ' 面板找不到来源控件');
+ const head = panel.slice(Math.max(0, idx - 1200), idx);
+ assert.ok(head.includes('v-show="nc.showQr"'), name + ' 面板来源控件未随显示二维码开关联动');
+ });
+ }
+
+ test('面板: 优先级默认值与 config 注册表一致(card)', () => {
+ // config/diy_view.php 的 util 段默认值
+ const cfgPath = path.resolve(__dirname, '../../backend/src/addon/card/config/diy_view.php');
+ const cfg = fs.readFileSync(cfgPath, 'utf8');
+ assert.ok(cfg.includes('"qrPriority":"card"'), 'config/diy_view.php 缺 qrPriority 默认值');
+ });
+
+ test('面板: 预览真实消费 qrPriority(4 处二维码挂点走分层 v-if)', () => {
+ for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
+ // 4 个挂点(head/corner/rows/bottom) 各一组分支:
+ // system 占位 + component 组件图 + component 名片图 + card 名片图 + card 组件图 + 兜底占位
+ const sys = (panel.match(/v-if="nc\.qrPriority === 'system'"/g) || []).length;
+ const comp = (panel.match(/v-else-if="nc\.qrPriority === 'component'/g) || []).length;
+ assert.equal(sys, 4, name + ' 面板 system 档分支数不对: ' + sys);
+ // component 档每挂点 2 支(组件图 + 名片图回落) = 8
+ assert.equal(comp, 8, name + ' 面板 component 档分支数不对: ' + comp);
+ // 名片级图分支(每挂点 2 支: component 回落 + card 优先;
+ // 每支出现 2 次: v-else-if 条件 + style 内取值) = 16
+ const cardImg = (panel.match(/nc\.tempData\.previewCard && nc\.tempData\.previewCard\.qr_image/g) || []).length;
+ assert.equal(cardImg, 16, name + ' 面板名片级图分支数不对: ' + cardImg);
+ }
+ });
+
+ test('面板: 预览不得调用 window 全局函数(Vue2 模板作用域不可见)', () => {
+ for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
+ assert.ok(
+ !/cardQrPreviewSrc\s*\(/.test(panel),
+ name + ' 面板仍调用 window 全局函数 —— Vue2 模板会报「not defined on the instance」'
+ );
+ }
+ });
+
+ // 2026-10-27: 「快捷操作管理」图层行的 color input 空值报错
+ // 图层行 v-for 数组曾只有 {k,ch}(无 bg/fg), 而同行 :value 引用 a.fg/a.bg
+ // → undefined → Vue 写 elm.value='' → Chrome CSS color 格式告警
+ test('面板: 图层行 v-for 数组含 bg/fg(避免 color input 空值)', () => {
+ for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
+ const m = panel.match(/v-for="a in (\[\{k:'tel'[\s\S]*?\}\])"/);
+ assert.ok(m, name + ' 面板找不到图层行 v-for 数组');
+ const arr = m[1];
+ const items = arr.match(/\{k:'[a-z]+'/g) || [];
+ assert.equal(items.length, 5, name + ' 面板图层行数组项数不对: ' + items.length);
+ const bgCount = (arr.match(/bg:'#[0-9A-Fa-f]{6}'/g) || []).length;
+ const fgCount = (arr.match(/fg:'#[0-9A-Fa-f]{6}'/g) || []).length;
+ assert.equal(bgCount, 5, name + ' 面板图层行数组缺 bg: ' + bgCount);
+ assert.equal(fgCount, 5, name + ' 面板图层行数组缺 fg: ' + fgCount);
+ }
+ });
+
+ test('面板: 图层行 color input 的 :value 不含未定义引用', () => {
+ for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
+ const colorBind = panel.match(/data-field="color"[^>]*:value="([^"]*)"/);
+ const bgBind = panel.match(/data-field="bg"[^>]*:value="([^"]*)"/);
+ assert.ok(colorBind, name + ' 面板找不到图层行 color 绑定');
+ assert.ok(bgBind, name + ' 面板找不到图层行 bg 绑定');
+ for (const [label, expr] of [['color', colorBind[1]], ['bg', bgBind[1]]]) {
+ const refs = (expr.match(/\ba\.([a-z]+)/g) || []).map(x => x.slice(2));
+ for (const r of Array.from(new Set(refs))) {
+ assert.ok(
+ ['k', 'ch', 'lb', 'bg', 'fg'].includes(r),
+ name + ' 面板图层行 ' + label + ' 绑定引用了数组里不存在的 a.' + r
+ );
+ }
+ }
+ }
+ });
+}
+
+// =============================================================
+// 反例(防恒真)
+// =============================================================
+
+test('反例: 虚构字段绝不出现在基件', () => {
+ assert.ok(!miniSrc.includes('c.qrMagic'));
+ assert.ok(!miniSrc.includes('card.qrMagic'));
+});
diff --git a/tests/diy-card-qr-style.test.js b/tests/diy-card-qr-style.test.js
index 6bf2600..b9177c4 100644
--- a/tests/diy-card-qr-style.test.js
+++ b/tests/diy-card-qr-style.test.js
@@ -40,6 +40,13 @@ const miniSrc = strip(read('diy-card-mini.vue'));
const hasPanels = fs.existsSync(path.join(PANEL_DIR, 'diy-card-self/design.html'));
const showPanel = hasPanels ? strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-show/design.html'), 'utf8')) : '';
const selfPanel = hasPanels ? strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-self/design.html'), 'utf8')) : '';
+const BOX_STYLE_FIELDS = [
+ 'bandShow', 'bandBgMode', 'bandBg',
+ 'wrapMarginTop', 'wrapMarginRight', 'wrapMarginBottom', 'wrapMarginLeft',
+ 'headBgMode', 'headBg', 'headRadiusMode', 'headRadius', 'headSpacingCustom',
+ 'headMarginTop', 'headMarginRight', 'headMarginBottom', 'headMarginLeft',
+ 'headPaddingTop', 'headPaddingRight', 'headPaddingBottom', 'headPaddingLeft',
+];
/**
* 面板控件的字段绑定有三种形态(2026-10-21 面板改版: 下拉框 → 可视化选项组,
@@ -172,20 +179,76 @@ test('common: qrTransform 位移(任一轴非零才输出; WXSS 支持 translate
// 2. mini.vue 基件契约
// =============================================================
-test('mini: 蓝色顶带版式(2026-10-23 与精选名片 show 同款: 自适应白卡高度 + 弧线距底 88rpx)', () => {
- // 模板: 卡片包进 wrap, 顶带在最前
+test('mini: 顶部色带默认隐藏,背景支持同步名片设计或独立颜色', () => {
assert.match(miniSrc, /class="mini-card-wrap"/);
- assert.match(miniSrc, /class="mini-band" :style="bandStyle"/);
+ assert.match(miniSrc, /<\/view>/);
+ assert.match(miniSrc, /bandShow\(\)\s*\{\s*return flagOn\(this\.c\.bandShow, false\)/);
+ assert.match(miniSrc, /bandStyle\(\)\s*\{[\s\S]*?this\.c\.bandBgMode === 'custom'[\s\S]*?themeGradient\(this\.card\)/);
assert.match(miniSrc, /class="mini-card fade-up" :style="wrapStyle"/);
- // 计算属性: 顶带底色可自定义(bandBg)或回落主题渐变; 高度可配(bandHeight)
- assert.match(miniSrc, /bandStyle\(\)\s*\{[\s\S]*?bandBg[\s\S]*?themeGradient\(this\.card\)/);
- // CSS: wrap 撑露沿 + band 拉伸(top:-40rpx / bottom:88rpx) + 圆角(顶直底弧)
+ // 保留原色带造型,显隐和背景只改内联消费,不改几何结构。
assert.match(miniSrc, /\.mini-card-wrap \{\s*position: relative;\s*margin-top: 40rpx;/);
assert.match(miniSrc, /\.mini-band \{\s*position: absolute;[\s\S]*?top: -40rpx;[\s\S]*?bottom: 88rpx;[\s\S]*?border-radius: 0 0 28rpx 28rpx;/);
- // mini-block 方案不得回归
assert.ok(!miniSrc.includes('mini-block'));
});
+test('mini: 卡片四向外边距与头部盒模型配置完整消费', () => {
+ for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
+ assert.match(miniSrc, new RegExp('margin' + side + ': px2rpx\\(num\\(c\\.wrapMargin' + side + ', 0\\)\\)'));
+ assert.match(miniSrc, new RegExp('c\\.headMargin' + side));
+ assert.match(miniSrc, new RegExp('c\\.headPadding' + side));
+ }
+ assert.match(miniSrc, /headBoxStyle\(\)\s*\{[\s\S]*?c\.headBgMode === 'custom'[\s\S]*?this\.headTheme \? themeGradient\(this\.card\) : 'transparent'/);
+ assert.match(miniSrc, /headThemeStyle\(\)\s*\{\s*return this\.headBoxStyle/);
+ assert.match(miniSrc, /plainHeadFinalStyle\(\)\s*\{[\s\S]*?this\.cornerHeadStyle[\s\S]*?return \[this\.headBoxStyle, corner\]/);
+});
+
+test('mini: C端盒模型计算值——历史默认兼容且自定义值逐项生效', async () => {
+ const m = await import('../components-diy/diy-card-common.js');
+ const script = miniSrc.match(/