diff --git a/pages_tool/card/admin/contacts.vue b/pages_tool/card/admin/contacts.vue new file mode 100644 index 0000000..12314cf --- /dev/null +++ b/pages_tool/card/admin/contacts.vue @@ -0,0 +1,450 @@ + + + + + diff --git a/pages_tool/card/admin/leads.vue b/pages_tool/card/admin/leads.vue new file mode 100644 index 0000000..0e9ed02 --- /dev/null +++ b/pages_tool/card/admin/leads.vue @@ -0,0 +1,398 @@ + + + + + diff --git a/pages_tool/card/admin/manage.vue b/pages_tool/card/admin/manage.vue new file mode 100644 index 0000000..9c0a66d --- /dev/null +++ b/pages_tool/card/admin/manage.vue @@ -0,0 +1,409 @@ + + + + + diff --git a/pages_tool/card/exchange.vue b/pages_tool/card/exchange.vue new file mode 100644 index 0000000..1923da1 --- /dev/null +++ b/pages_tool/card/exchange.vue @@ -0,0 +1,327 @@ + + + + + diff --git a/pages_tool/card/style.vue b/pages_tool/card/style.vue new file mode 100644 index 0000000..ac44361 --- /dev/null +++ b/pages_tool/card/style.vue @@ -0,0 +1,609 @@ + + + + + diff --git a/tests/card-admin.test.js b/tests/card-admin.test.js new file mode 100644 index 0000000..ac9a54a --- /dev/null +++ b/tests/card-admin.test.js @@ -0,0 +1,186 @@ +/** + * 名片云 · 商家端(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 new file mode 100644 index 0000000..3a74a26 --- /dev/null +++ b/tests/card-api.test.js @@ -0,0 +1,312 @@ +/** + * 名片云 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/create', // 后端源码已有(新建数字名片), 旧文档未列, 以源码为准 + '/card/api/card/claim', + '/card/api/card/recognize', + '/card/api/card/retry', + '/card/api/card/recognizeBatch', + // 人脉库 /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' +]; + +// helper 方法名 ↔ URL 常量名(收口层必须两端齐全) +const HELPER_URL_PAIRS = [ + ['cardLists', 'CARD_LISTS'], + ['cardDetail', 'CARD_DETAIL'], + ['cardUpdate', 'CARD_UPDATE'], + ['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'] +]; + +// 文档 3.3/4.2/4.4/3.7: 嵌套对象/数组入参必须走 JSON body(sendJson) +const JSON_BODY_HELPERS = ['cardUpdate', 'cardCreate', 'contactAdd', 'contactUpdate', 'cardRecognizeBatch', 'exchangeAgree', 'exchangeIgnore']; + +// ============================================================= +// 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*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 丢弃`); + } + } +});