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 @@
+
+
+
+
+ 名片管理
+ 人脉管理
+ 潜在客户
+
+
+
+
+ keyword = e.detail.value" @confirm="doSearch" />
+ 搜索
+ + 添加
+
+
+
+
+
+
+ {{ tab.label }}
+
+
+
+
+
+
+
+
+ {{ item.name }}
+ {{ item.source_text }}
+
+
+ {{ item.company }} · {{ item.job_title }}
+
+ {{ item.mobile }}
+
+
+ {{ item.create_time_txt }}
+ 已归档
+
+
+ 编辑
+ 转为潜在客户
+ 删除
+
+
+
+
+ 加载中…
+ 上拉加载更多
+ — 没有更多了 —
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+ 名片管理
+ 人脉管理
+ 潜在客户
+
+
+
+
+ keyword = e.detail.value" @confirm="doSearch" />
+ 搜索
+
+
+
+
+
+
+ {{ tab.label }}
+
+
+
+
+
+
+
+
+ {{ item.name || '未留姓名' }}
+ {{ item.status_text || '新客户' }}
+
+ 📱 {{ item.mobile }}
+ 「{{ item.note }}」
+
+ 来自名片:{{ item.card_name }}
+ {{ item.create_time_txt }}
+
+
+ 复制微信
+ 更新状态
+
+
+
+
+ 加载中…
+ 上拉加载更多
+ — 没有更多了 —
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+ 名片管理
+ 人脉管理
+ 潜在客户
+
+
+
+
+ i
+ 查重合并、批量制作、导出 CSV/vCard 等功能即将上线
+
+
+
+
+
+
+ ✕
+
+
+
+
+
+
+
+
+
+ {{ avatarText(item.name) }}
+
+
+ {{ item.name || '未命名名片' }}
+
+ {{ [item.job_title, item.company].filter(Boolean).join(' · ') }}
+
+ 📱 {{ item.mobile }}
+
+
+ {{ sourceLabel(item) }}
+
+ 编辑
+ 详情
+
+
+
+
+
+
+ 加载中…
+ 上拉加载更多
+ — 没有更多了 —
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ {{ tab.label }}
+
+
+
+
+
+
+
+
+
+ {{ item.name.charAt(0) }}
+ 示例交换
+
+
+
+ {{ item.name }}
+
+
+
+ {{ item.job_title }}
+
+ 示例交换
+ {{ item.company }}
+
+
+ 同意
+ 已同意
+
+
+ 对方发起了名片交换请求
+
+ {{ item.create_time_txt }} 向您发起名片交换
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+ 名片模板
+ 分享封面
+
+
+
+
+
+
+ {{ card.name }}
+ {{ card.job_title }}
+ {{ card.company }}
+
+
+
+
+
+ {{ card.mobile }}
+
+
+ @
+ {{ card.email }}
+
+
+
+ {{ card.address }}
+
+
+
+
+
+
+ 解锁更多新模板、个性化logo,自由排版
+
+
+
+
+
+ 选择模板
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 选择背景
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ card.name }}
+ {{ card.job_title }}
+ {{ card.company }}
+
+
+
+
+ 选择封面
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 丢弃`);
+ }
+ }
+});