diff --git a/pages_tool/card/detail.vue b/pages_tool/card/detail.vue index cee353a..2694430 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,6 +371,21 @@ export default { } }, onLoad(options) { + 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(); // 2.21 小程序码入口: scene 里只有名片**主键 id**(微信 scene 仅 32 字符, // 装不下 30 字符的业务 card_id), 需先换成 card_id 才能查详情。 @@ -383,7 +400,7 @@ export default { this.resolveCardIdByRelId(sceneId); } else { this.isLoading = false; - this.errorMsg = '缺少名片编号'; + this.errorMsg = `缺少名片编号 ${this.loadOptions}`; } }, // H5 直接刷新/深链进入时, uni-page-head 可能在路由 meta 就绪前就挂载, @@ -529,7 +546,7 @@ export default { loadDetail(retry) { if (!this.cardId) { this.isLoading = false; - this.errorMsg = '缺少名片编号'; + this.errorMsg = `缺少名片编号 ${this.loadOptions}`; return; } this.isLoading = true; @@ -537,6 +554,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/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 deleted file mode 100644 index 8d52b89..0000000 --- a/tests/card-api.test.js +++ /dev/null @@ -1,326 +0,0 @@ -/** - * 名片云 API 收口层测试: common/js/card-api.js ↔ 后端 CARD_API_REFERENCE.md - * - * 背景: - * 后端《名片云(card addon)API 对接文档》checklist 第 1 条要求: - * "所有 /card/api/* URL 收口到 API 层, 与本文档路径逐一比对(现有 7 个页面 - * 12 处直连, 路径不可变)"。本测试把该契约固化为断言: - * - * 覆盖: - * 1. card-api.js 的 18 个端点与后端文档逐一比对(一个字符都不能差) - * 2. card-api.js 不得出现文档之外的 /card/api/ 路径 - * 3. 每个端点都有对应 helper 方法(收口不是只建常量表) - * 4. 7 个业务页面零字面量直连(全部走 this.$cardApi) - * 5. main.js 注册 $cardApi(Vue.prototype) - * 6. 字典与后端白名单一致: SOCIAL_TYPES / LEAD_STATUS / CONTACT_SOURCES - * 7. 嵌套对象入参走 sendJson 通道(card/update、contact/add、contact/update、 - * recognizeBatch), 其余走 sendRequest - * 8. 走 sendJson 的 helper, 页面必须把 payload 包在 data 里 - * (sendJson 只合并 params.data, 顶层挂 fields 会被丢弃) - * - * 跑法: - * node --test tests/card-api.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 CARD_DIR = path.join(ROOT, 'pages_tool/card'); -const read = f => fs.readFileSync(path.join(CARD_DIR, f), 'utf8'); -const API_SRC = fs.readFileSync(path.join(ROOT, 'common/js/card-api.js'), 'utf8'); -const MAIN_SRC = fs.readFileSync(path.join(ROOT, 'main.js'), 'utf8'); - -// 后端 CARD_API_REFERENCE.md 第 1/3/4/5 节列出的全部端点(15 个) -const DOC_URLS = [ - // 名片夹 /card/api/card/* - '/card/api/card/lists', - '/card/api/card/detail', - '/card/api/card/update', - '/card/api/card/toggleFavorite', // 收藏(双向 toggle, 后端 2.9.12) - '/card/api/card/create', // 后端源码已有(新建数字名片), 旧文档未列, 以源码为准 - '/card/api/card/claim', - '/card/api/card/recognize', - '/card/api/card/retry', - '/card/api/card/recognizeBatch', - '/card/api/card/followDone', // 跟进完成态 2.11.3 - '/card/api/card/views', // 访客轨迹 2.11.4 - // 人脉库 /card/api/contact/* - '/card/api/contact/lists', - '/card/api/contact/add', - '/card/api/contact/info', - '/card/api/contact/update', - '/card/api/contact/delete', - '/card/api/contact/toLead', - // 潜在客户 /card/api/lead/* - '/card/api/lead/lists', - '/card/api/lead/setStatus', - // 交换名片 /card/api/exchange/* - '/card/api/exchange/lists', - '/card/api/exchange/agree', - '/card/api/exchange/ignore', - '/card/api/apply/submit', // 员工建卡申请 2.11.15 - '/card/api/apply/status', // 员工建卡申请 2.11.15 - '/card/api/design/presets', // 名片预设列表(同源后台 CardTemplatePresets, 后端 v2 无 /card/ 段) - '/card/api/design/save' // 应用预设/保存本卡样式 -]; - -// helper 方法名 ↔ URL 常量名(收口层必须两端齐全) -const HELPER_URL_PAIRS = [ - ['cardLists', 'CARD_LISTS'], - ['cardDetail', 'CARD_DETAIL'], - ['cardUpdate', 'CARD_UPDATE'], - ['cardToggleFavorite', 'CARD_FAVORITE'], - ['cardCreate', 'CARD_CREATE'], - ['cardClaim', 'CARD_CLAIM'], - ['cardRecognize', 'CARD_RECOGNIZE'], - ['cardRetry', 'CARD_RETRY'], - ['cardRecognizeBatch', 'CARD_RECOGNIZE_BATCH'], - ['contactLists', 'CONTACT_LISTS'], - ['contactAdd', 'CONTACT_ADD'], - ['contactInfo', 'CONTACT_INFO'], - ['contactUpdate', 'CONTACT_UPDATE'], - ['contactDelete', 'CONTACT_DELETE'], - ['contactToLead', 'CONTACT_TO_LEAD'], - ['leadLists', 'LEAD_LISTS'], - ['leadSetStatus', 'LEAD_SET_STATUS'], - ['exchangeLists', 'EXCHANGE_LISTS'], - ['exchangeAgree', 'EXCHANGE_AGREE'], - ['exchangeIgnore', 'EXCHANGE_IGNORE'], - ['cardFollowDone', 'CARD_FOLLOW_DONE'], - ['cardViews', 'CARD_VIEWS'], - ['applySubmit', 'APPLY_SUBMIT'], - ['applyStatus', 'APPLY_STATUS'], - ['cardDesignPresets', 'CARD_DESIGN_PRESETS'], - ['cardDesignSave', 'CARD_DESIGN_SAVE'] -]; - -// 文档 3.3/4.2/4.4/3.7: 嵌套对象/数组入参必须走 JSON body(sendJson) -const JSON_BODY_HELPERS = ['cardUpdate', 'cardCreate', 'cardToggleFavorite', 'contactAdd', 'contactUpdate', 'cardRecognizeBatch', 'exchangeAgree', 'exchangeIgnore', 'cardDesignSave']; - -// ============================================================= -// 1. 端点与后端文档逐一比对 -// ============================================================= - -test('收口: card-api.js 含文档全部 18 个端点', () => { - for (const u of DOC_URLS) { - assert.ok(API_SRC.includes(`'${u}'`), `card-api.js 缺少端点 ${u}(与后端文档不符)`); - } -}); - -test('收口: card-api.js 不得出现文档之外的 /card/api/ 路径', () => { - const found = API_SRC.match(/'\/card\/api\/[^']+'/g) || []; - const unknown = found.map(s => s.slice(1, -1)).filter(u => !DOC_URLS.includes(u)); - assert.deepEqual(unknown, [], `存在文档外路径: ${unknown.join(', ')}`); - assert.equal(found.length, DOC_URLS.length, - `端点数量应为 ${DOC_URLS.length}, 实际 ${found.length}(防止漏收/多收)`); -}); - -// ============================================================= -// 2. 每个端点都有 helper(收口不是只建常量表) -// ============================================================= - -test('收口: 15 个端点均有对应 helper 方法', () => { - for (const [helper, urlConst] of HELPER_URL_PAIRS) { - assert.ok(new RegExp(`\\b${helper}\\(p\\)\\s*\\{`).test(API_SRC), - `缺少 helper ${helper}(p)`); - assert.ok(new RegExp(`(?:url\\s*:|url\\s*=)\\s*CARD_API_URL\\.${urlConst}\\b`).test(API_SRC), - `helper ${helper} 未绑定 ${urlConst}`); - } -}); - -test('收口: 嵌套入参 helper 走 sendJson 通道', () => { - for (const helper of JSON_BODY_HELPERS) { - const m = API_SRC.match(new RegExp(`${helper}\\(p\\)\\s*\\{([\\s\\S]*?)\\n\\t\\}`)); - assert.ok(m, `应能提取 ${helper} 方法体`); - assert.ok(/Http\.sendJson\(/.test(m[1]), - `${helper} 必须走 Http.sendJson(JSON body), 否则嵌套 fields 跨端序列化有歧义`); - } -}); - -test('收口: 普通入参 helper 走 sendRequest 通道', () => { - const formHelpers = HELPER_URL_PAIRS - .map(p => p[0]) - .filter(h => !JSON_BODY_HELPERS.includes(h)); - for (const helper of formHelpers) { - const m = API_SRC.match(new RegExp(`${helper}\\(p\\)\\s*\\{([\\s\\S]*?)\\n\\t\\}`)); - assert.ok(m, `应能提取 ${helper} 方法体`); - assert.ok(/Http\.sendRequest\(/.test(m[1]), - `${helper} 应走 Http.sendRequest`); - assert.ok(!/Http\.sendJson\(/.test(m[1]), - `${helper} 不应走 sendJson(无嵌套入参)`); - } -}); - -// ============================================================= -// 3. 页面零字面量直连 -// ============================================================= - -test('收口: 7 个业务页面不得再直连 /card/api/ 字面量', () => { - const files = ['list.vue', 'detail.vue', 'edit.vue', 'leads.vue', 'share.vue', 'contacts.vue', 'contact-edit.vue']; - for (const f of files) { - const src = read(f); - assert.ok(!/['"]\/card\/api\//.test(src), - `${f} 仍直连 /card/api/ 字面量, 必须走 this.$cardApi(后端 checklist 第 1 条)`); - } -}); - -test('收口: 有接口调用的 6 个页面全部走 this.$cardApi', () => { - // share.vue 是 web-view 中转页, 无业务接口调用, 不在此列 - const files = ['list.vue', 'detail.vue', 'edit.vue', 'leads.vue', 'contacts.vue', 'contact-edit.vue']; - for (const f of files) { - const src = read(f); - assert.ok(/this\.\$cardApi\./.test(src), - `${f} 未使用 this.$cardApi`); - } -}); - -test('收口: main.js 注册 Vue.prototype.$cardApi', () => { - assert.ok(/import\s+CardApi\s+from\s+'\.\/common\/js\/card-api\.js'/.test(MAIN_SRC), - 'main.js 应 import card-api.js'); - assert.ok(/Vue\.prototype\.\$cardApi\s*=\s*CardApi/.test(MAIN_SRC), - 'main.js 应注册 Vue.prototype.$cardApi'); -}); - -// ============================================================= -// 4. 字典与后端白名单一致 -// ============================================================= - -test('字典: SOCIAL_TYPES 与后端 CardRichFields::SOCIAL_TYPES 白名单一致', () => { - const keys = [...API_SRC.matchAll(/\{\s*key:\s*'([a-z]+)',\s*label:/g)].map(m => m[1]); - // 2026-10-13: telegram / messenger 由独立标量软下线, 并入 socials 白名单 - const expected = ['alipay', 'douyin', 'wxvideo', 'xiaohongshu', 'weibo', 'kuaishou', - 'bilibili', 'qq', 'linkedin', 'facebook', 'instagram', 'x', 'telegram', 'messenger', 'other']; - assert.deepEqual(keys, expected, 'SOCIAL_TYPES 增减必须与后端同步'); -}); - -test('字典: CARD_FIELDS 不含 telegram/messenger(2026-10-13 软下线)', () => { - const block = API_SRC.match(/export const CARD_FIELDS = \[([\s\S]*?)\];/); - assert.ok(block, 'CARD_FIELDS 应存在'); - const values = [...block[1].matchAll(/'([a-z_]+)'/g)].map(m => m[1]); - assert.ok(!values.includes('telegram'), 'telegram 已软下线为独立标量, 不得出现在 CARD_FIELDS'); - assert.ok(!values.includes('messenger'), 'messenger 已软下线为独立标量, 不得出现在 CARD_FIELDS'); - // 其余标量不受影响 - for (const f of ['name', 'company', 'job_title', 'avatar', 'mobile', 'phone', 'email', - 'website', 'wechat', 'country', 'province', 'city', 'address', 'postal_code', 'bio']) { - assert.ok(values.includes(f), `CARD_FIELDS 应保留 ${f}`); - } -}); - -test('软下线: edit.vue 不再提交 telegram/messenger 标量, 字典从收口层导入', () => { - const editSrc = fs.readFileSync(path.join(CARD_DIR, 'edit.vue'), 'utf8'); - assert.ok(!/form\.telegram/.test(editSrc), 'edit.vue 不得再绑 form.telegram(软下线)'); - assert.ok(!/form\.messenger/.test(editSrc), 'edit.vue 不得再绑 form.messenger(软下线)'); - assert.ok(!/fields\.telegram/.test(editSrc) && !/fields\.messenger/.test(editSrc), - 'edit.vue 保存时不得再提交 telegram/messenger 标量'); - assert.ok(/import\s*\{\s*SOCIAL_TYPES,\s*CARD_FIELDS,\s*RICH_FIELD_LIMITS\s*\}\s*from\s*'@\/common\/js\/card-api\.js'/.test(editSrc), - 'edit.vue 应从 card-api.js 导入 SOCIAL_TYPES/CARD_FIELDS/RICH_FIELD_LIMITS'); - // 模板里不得再有 Telegram / Messenger 独立输入行 - assert.ok(!/Telegram<\/view>/.test(editSrc), - '模板不得再有 Telegram 独立输入行'); - assert.ok(!/Messenger<\/view>/.test(editSrc), - '模板不得再有 Messenger 独立输入行'); -}); - -test('软下线: detail.vue 不再单独渲染 telegram/messenger 行(旧值已并入 socials_list)', () => { - const detailSrc = fs.readFileSync(path.join(CARD_DIR, 'detail.vue'), 'utf8'); - assert.ok(!/push\('Telegram'/.test(detailSrc), 'detail.vue 不得再单独渲染 Telegram 行'); - assert.ok(!/push\('Messenger'/.test(detailSrc), 'detail.vue 不得再单独渲染 Messenger 行'); - // 社交账号统一走 socials_list(后端已合并旧值) - assert.ok(/r\.socials_list/.test(detailSrc), 'detail.vue 应继续消费 socials_list'); -}); - -test('字典: LEAD_STATUS 与后端 Lead::STATUSES 一致(0-3)', () => { - const block = API_SRC.match(/export const LEAD_STATUS = \[([\s\S]*?)\];/); - assert.ok(block, 'LEAD_STATUS 应存在'); - const values = [...block[1].matchAll(/value:\s*(\d)/g)].map(m => Number(m[1])); - assert.deepEqual(values, [0, 1, 2, 3]); - // setStatus 只接受 0/1/2/3, 其它值后端报「参数不合法」 - assert.ok(/status\?\(0-3\)/.test(API_SRC), 'leadLists 文档注释应标注 status 0-3'); -}); - -test('字典: CONTACT_SOURCES 与后端 Contact::SOURCES 一致', () => { - const block = API_SRC.match(/export const CONTACT_SOURCES = \[([\s\S]*?)\];/); - assert.ok(block, 'CONTACT_SOURCES 应存在'); - const values = [...block[1].matchAll(/value:\s*'([a-z_]+)'/g)].map(m => m[1]); - assert.deepEqual(values, ['manual', 'lead_in', 'scan', 'exchange']); -}); - -test('字典: CONTACT_FIELDS 与后端 Contact::EDITABLE_FIELDS 一致', () => { - const block = API_SRC.match(/export const CONTACT_FIELDS = \[([\s\S]*?)\];/); - assert.ok(block, 'CONTACT_FIELDS 应存在'); - const values = [...block[1].matchAll(/'([a-z_]+)'/g)].map(m => m[1]); - assert.deepEqual(values, - ['name', 'mobile', 'company', 'job_title', 'wechat', 'email', 'website', 'address', 'remark', 'status']); -}); - -// ============================================================= -// 5. 文档关键语义在收口层有注释固化(防后人改错) -// ============================================================= - -test('语义: card/update 的 normalize 规则写在收口层注释里', () => { - // 键存在才写 / 空串=清空 / 键缺席=不擦除(历史事故定稿) - assert.ok(/键存在才写/.test(API_SRC) && /空串=清空/.test(API_SRC) && /键缺席=不擦除/.test(API_SRC), - 'cardUpdate 注释必须固化 normalize 三语义'); -}); - -test('语义: recognize 的 deduped 提示写在收口层注释里', () => { - assert.ok(/deduped=true 表示站点内已有同手机号名片/.test(API_SRC), - 'cardRecognize 注释必须提示 deduped 语义'); -}); - -test('语义: 名片用 card_id 字符串 / 人脉线索用 id 数字的区分写在注释里', () => { - assert.ok(/card_id(字符串),人脉\/线索用 id(数字)/.test(API_SRC), - '收口层注释必须固化主键类型区分(后端 checklist 第 9 条)'); -}); - -// ============================================================= -// 6. 走 sendJson 的 helper: 页面必须把 payload 包在 data 里 -// ============================================================= - -/** - * 提取 `$cardApi.` 调用的实参文本(括号配对)。 - * 兼容两种写法: `this.$cardApi.cardUpdate({...})` 与三元分支 - * `(this.isCreate ? this.$cardApi.cardCreate : this.$cardApi.cardUpdate)({...})` - * —— 后者 helper 名后紧跟的是 ')' 而不是 '(', 故先定位名字再找其后第一个 '('。 - */ -function callArgs(src, helper) { - const at = src.indexOf('$cardApi.' + helper); - if (at < 0) return null; - const start = src.indexOf('(', at); - if (start < 0) return null; - let depth = 0; - for (let i = start; i < src.length; i++) { - const c = src[i]; - if (c === '(') depth++; - else if (c === ')') { - depth--; - if (depth === 0) return src.slice(start, i + 1); - } - } - return null; -} - -test('入参: 走 sendJson 的 helper 必须把 payload 包在 data 里', () => { - // 历史事故(2026-10-13): edit.vue 保存时把 fields 挂在顶层传参, 而 - // Http.sendJson 只 Object.assign(data, params.data)(见 common/js/http.js), - // 顶层键被整体丢弃 → 请求体里没有 fields → 后端按空 name 校验, - // 前端弹「保存失败」、Network 里看到 {code:-1, data:"请填写姓名"}。 - const CALLERS = { - 'edit.vue': ['cardCreate', 'cardUpdate'], - 'contact-edit.vue': ['contactAdd', 'contactUpdate'] - }; - for (const [f, helpers] of Object.entries(CALLERS)) { - const src = read(f); - for (const h of helpers) { - const args = callArgs(src, h); - assert.ok(args, `${f} 应能定位 $cardApi.${h} 调用`); - assert.ok(/data\s*:/.test(args), - `${f} 的 $cardApi.${h} 必须传 data: {...}; 顶层挂 fields 会被 sendJson 丢弃`); - } - } -}); diff --git a/tests/diy-card-font-size.test.js b/tests/diy-card-font-size.test.js deleted file mode 100644 index b43cece..0000000 --- a/tests/diy-card-font-size.test.js +++ /dev/null @@ -1,215 +0,0 @@ -/** - * 名片 DIY 组件 · 字号定制化三层口径契约测试 - * - * 锁死: diy_view.php 默认值 ↔ design.html 面板(slide+预览消费) ↔ C 端 vue 消费 - * 口径: 字号字段一律 px 配置, C 端渲染 ×2 转 rpx(px2rpx); 默认值 = 历史硬编码值 - * - * 派生关系(保证默认渲染与历史一致): - * - list 横滑竖卡: 姓名/信息比网格小一档(-1px) - * - list 人数徽标: 比信息行小一档(-1px); 「查看全部」= 信息字号 - * - entry 文字箭头的箭头: 比按钮文字大一档(+2px) - */ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const BE = resolve(__dirname, '../../backend/src/addon/card'); -const FE = resolve(__dirname, '../components-diy'); - -/** 三层口径定义: 字段 → [默认值(px), 消费侧期望字面量] */ -const CONTRACT = { - entry: { - defaults: { titleSize: 16, subSize: 12, btnSize: 12 }, - colors: { bgColorStart: '#2F6BFF', bgColorEnd: '#1E40AF', textColor: '#FFFFFF', accentColor: '#FFEB3B' }, - layout: { borderRadius: 12, padding: 16 }, - vueRead: 'this.v.', - vueFile: 'diy-card-entry.vue', - }, - list: { - defaults: { titleSize: 16, nameSize: 14, infoSize: 11 }, - colors: { bgColor: '#FFFFFF', titleColor: '#333333', nameColor: '#1F2329', infoColor: '#888888' }, - layout: { borderRadius: 12, padding: 12, gap: 10 }, - vueRead: 'this.v.', - vueFile: 'diy-card-list.vue', - }, - show: { - defaults: { nameSize: 17, cardTitleSize: 13, subSize: 12, labelSize: 12, valueSize: 12, actSize: 12, btnSize: 13 }, - colors: { bgColor: '#FFFFFF', nameColor: '#1F2329', infoColor: '#888888', labelColor: '#999999' }, - layout: { borderRadius: 16, padding: 16 }, - vueRead: 'this.c.', - vueFile: 'diy-card-mini.vue', - }, - self: { - defaults: { nameSize: 17, cardTitleSize: 13, subSize: 12, labelSize: 12, valueSize: 12, actSize: 12, btnSize: 13 }, - colors: { bgColor: '#FFFFFF', nameColor: '#1F2329', infoColor: '#888888', labelColor: '#999999' }, - layout: { borderRadius: 16, padding: 16 }, - vueRead: 'this.c.', - vueFile: 'diy-card-mini.vue', - }, -}; - -/** 从 diy_view.php 提取组件 value JSON(容忍单引号 PHP 串内的转义) */ -const PHP_NAMES = { entry: 'DiyCardEntry', list: 'DiyCardList', show: 'DiyCardShow', self: 'DiyCardSelf' }; -function readValueJson(component) { - const php = readFileSync(resolve(BE, 'config/diy_view.php'), 'utf8'); - const re = new RegExp("'name'\\s*=>\\s*'" + PHP_NAMES[component] + "'[\\s\\S]*?'value'\\s*=>\\s*'([^\']+)'"); - const m = php.match(re); - assert.ok(m, 'diy_view.php 中找到组件 ' + PHP_NAMES[component]); - return JSON.parse(m[1]); -} - -test('diy_view.php 四组件默认值包含全部字号字段且等于历史渲染值', () => { - for (const [comp, cfg] of Object.entries(CONTRACT)) { - const value = readValueJson(comp); - for (const [field, dft] of Object.entries(cfg.defaults)) { - assert.equal(Number(value[field]), dft, comp + '.' + field + ' 默认值应为 ' + dft); - } - } -}); - -test('diy_view.php 配色/圆角间距默认值与契约一致(重置按钮的最终事实来源)', () => { - for (const [comp, cfg] of Object.entries(CONTRACT)) { - const value = readValueJson(comp); - for (const [field, dft] of Object.entries(cfg.colors)) { - assert.equal(String(value[field]).toUpperCase(), dft.toUpperCase(), comp + '.' + field + ' 颜色默认值应为 ' + dft); - } - for (const [field, dft] of Object.entries(cfg.layout)) { - assert.equal(Number(value[field]), dft, comp + '.' + field + ' 默认值应为 ' + dft); - } - } -}); - -test('design.html 样式面板提供一键恢复默认, 重置字段与默认值逐一对应', () => { - for (const [comp, cfg] of Object.entries(CONTRACT)) { - const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8'); - assert.ok(html.includes('diy-card-reset'), comp + ' 样式面板缺少恢复默认按钮'); - const btn = html.match(/