Compare commits
130 Commits
temp221
...
feat/camca
| Author | SHA1 | Date | |
|---|---|---|---|
| 1de3146c3a | |||
| e98d3ba0e5 | |||
| 8617bb0395 | |||
| 4b99bcdd7e | |||
| bd691d2d30 | |||
| ba7988765a | |||
| b36e1d0b5c | |||
| 8d8c90d9b5 | |||
| f64b00c5f5 | |||
| a08d0c2026 | |||
| f4998d1582 | |||
| 7d1349f336 | |||
| 549a98204a | |||
| 5845474be3 | |||
| 1b6114ef58 | |||
| 6977b914d3 | |||
| 0acd9e0e7b | |||
| a26b9d6601 | |||
| ff530cd6a2 | |||
| 0b3a49f0d1 | |||
| c9d77e901d | |||
| 0c0a6a1cb8 | |||
| a595a4ae1b | |||
| ecc81b9b31 | |||
| 8348cbb179 | |||
| cb454db326 | |||
| 887c1848a4 | |||
| f9277e8644 | |||
| 41d07843e7 | |||
| 5f4a0ecea1 | |||
| f26d11f38a | |||
| efa7c736f9 | |||
| 3fb5b0b92e | |||
| e11aa27279 | |||
| 31f67c109b | |||
| df9db2e2ed | |||
| 9b33fb7719 | |||
| 6c2619a33c | |||
| d64e989c25 | |||
| 3bcf0c8c87 | |||
| 3d144a7492 | |||
| b294fc3640 | |||
| 6f9ac29aa6 | |||
| a1d8ee8d7a | |||
| d605f4c9ea | |||
| 724e7ac134 | |||
| 37cd3c2f30 | |||
| d188737a6c | |||
| 57a67bd61b | |||
| f1bd606e0b | |||
| 805bbb9598 | |||
| 8bdd9f1291 | |||
| a841af8f5b | |||
| 35137ae814 | |||
| 48a17a59f7 | |||
| cc96e2ca13 | |||
| d0fe005c4a | |||
| c9a4673203 | |||
| bdd0935fab | |||
| 9d3cd2b65e | |||
| 22b33c45ad | |||
| 699787528c | |||
| a2bf8d6c25 | |||
| 8635f9795f | |||
| 166450f6ce | |||
| b4509e3092 | |||
| 8fa16d9b14 | |||
| 9c9d1a412f | |||
| 1f87b23e7b | |||
| 421dad516c | |||
| ab8dff4b93 | |||
| 8999dd3f25 | |||
| 91ce0b582b | |||
| 7daa3afdc2 | |||
| 21b3247547 | |||
| 7909cf2b22 | |||
| 33cd21cf78 | |||
| 336b4d21da | |||
| 77b1a3372c | |||
| 373db1fb73 | |||
| d4457e58d4 | |||
| 550ec72bb7 | |||
| 1276a44b6b | |||
| 9086f1665c | |||
| a9f3c7acc2 | |||
| 53b5b725a5 | |||
| fe3f059f58 | |||
| add384916c | |||
| f2997f5401 | |||
| 4439df7c9b | |||
| 5290271017 | |||
| d358bb55ba | |||
| 2f750dbb99 | |||
| 591ce742a9 | |||
| f41057a9ad | |||
| 5a009130e6 | |||
| e8fc1aca06 | |||
| e60b3e2e52 | |||
| 022a8bb5bb | |||
| 6309a5bb4b | |||
| 67af165c14 | |||
| 36dfbfed67 | |||
| e56b91970c | |||
| 4650d9ea30 | |||
| 52f50eb91e | |||
| b3f6ff7517 | |||
| d0d17fd916 | |||
| 9b540ecfad | |||
| 389d87b82c | |||
| c9a58a5ddf | |||
| 53810114ed | |||
| f2619ae3b6 | |||
| 15b0d2a18a | |||
| 1f44f11248 | |||
| 6e3818dff6 | |||
| f22892968f | |||
| 8c5ed50f7a | |||
| 8f82d97c95 | |||
| 10ed9792a4 | |||
| b92f474ac8 | |||
| 0859765efb | |||
| 8cf70b617d | |||
| 053cb00826 | |||
| 9e6dc8665f | |||
| a957679957 | |||
| b49fa378e7 | |||
| a5a94e31cc | |||
| 236e56f4e9 | |||
| d170e53082 | |||
| c36d8dbce6 |
@@ -120,6 +120,10 @@ const localDevConfig = ({
|
||||
uniacid: 237,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-248': { //中瑞 工业称重系统垃圾吊电子秤,首页,图片链接部分的PDF文档打不开
|
||||
uniacid: 248,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'2847': { // 家居装饰品
|
||||
uniacid: 2847,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
@@ -152,10 +156,6 @@ const localDevConfig = ({
|
||||
uniacid: 12,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'248': { // 工业称重系统垃圾吊电子秤,支持自定义字体大小、颜色
|
||||
uniacid: 248,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'228': { // 反渗透阻垢剂RO装置 商品分类,没有立即咨询文字
|
||||
uniacid: 228,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
@@ -214,27 +214,19 @@ const localDevConfig = ({
|
||||
uniacid: 3007,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'2843': { // 蔬菜种子网
|
||||
uniacid: 2843,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-317': { // 电磁兼容,模板显示不全商品
|
||||
uniacid: 317,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
|
||||
'5g-312': { // 天然植物功效活性物
|
||||
uniacid: 312,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-248': { // 工业称重
|
||||
uniacid: 248,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-325': { // 检验检测机构
|
||||
uniacid: 325,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'192.168.8.113-1': { // 测试平台
|
||||
uniacid: 1,
|
||||
domain: 'http://192.168.8.113:8050/',
|
||||
// domain: 'http://localhost:8050/',
|
||||
},
|
||||
// })['5g-237']; // 商品分类,没有立即咨询文字
|
||||
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
|
||||
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
|
||||
@@ -257,7 +249,9 @@ const localDevConfig = ({
|
||||
// })['2811']; // 本地
|
||||
// })['5g-317']; // 电磁兼容
|
||||
// })['5g-351']; //
|
||||
})['5g-97']; // 本地
|
||||
})['2811']; // 本地
|
||||
// })['5g-248']; // 中瑞 工业称重系统垃圾吊电子秤,首页,图片链接部分的PDF文档打不开
|
||||
// })['2881']; // 本地
|
||||
// })['local-2']; // 本地
|
||||
// })['local-11']; // 本地
|
||||
// })['1-test']; // test
|
||||
|
||||
@@ -460,4 +460,17 @@
|
||||
}
|
||||
.icon-shuqing:before {
|
||||
content: "\e09b";
|
||||
}
|
||||
|
||||
/* 邮箱信封图标(2026-10-22, 名片资料行方案1落地):
|
||||
字体包中暂无信封字形, 以内联 SVG 背景替代(content 置空避免渲染豆腐块)。
|
||||
颜色取资料行文字标签默认色 #999999(与电话/地址图标观感一致);
|
||||
后续若 iconfont 字库补充了同名码点字形, 删除本条覆盖即可无缝切换 */
|
||||
.icon-youxiang:before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
vertical-align: middle;
|
||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23999999' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='4' width='20' height='16' rx='2'/%3E%3Cpath d='m2 7 10 6 10-6'/%3E%3C/svg%3E") center/contain no-repeat;
|
||||
}
|
||||
567
common/js/card-api.js
Normal file
567
common/js/card-api.js
Normal file
@@ -0,0 +1,567 @@
|
||||
/**
|
||||
* 名片云(card addon)API 收口层
|
||||
*
|
||||
* 单一事实来源:后端《名片云(card addon)API 对接文档》CARD_API_REFERENCE.md(2026-10-13)
|
||||
* 文档与源码如有出入,以源码为准;URL 路径一个字符都不能变,只做收口不做改名。
|
||||
*
|
||||
* 收口要求(来自后端 checklist):
|
||||
* 1. 所有 /card/api/* URL 必须集中在本层,页面不得再直连字面量;
|
||||
* 2. 统一走项目请求工具(自动带 token / uniacid),任何接口不传 site_id;
|
||||
* 3. 失败分支统一 toast(message);「不存在」类错误按"内容不存在或已删除"提示;
|
||||
* 4. card/update 只传编辑过的键(键存在才写、空串=清空、键缺席=不擦除);
|
||||
* 5. 详情/列表渲染富化字段用 *_list 解码键,不解析原始 JSON 串;
|
||||
* 6. 编辑页对 locked_fields 命中的字段渲染只读;
|
||||
* 7. recognize 成功后检查 deduped,为 true 时提示"已存在同手机号名片";
|
||||
* 8. 分页参数 page_size 不超过 100;
|
||||
* 9. 名片路由参数用 card_id(字符串),人脉/线索用 id(数字),不要混用。
|
||||
*
|
||||
* 用法(页面内 this.$cardApi):
|
||||
* this.$cardApi.cardLists({ data: { page: 1 }, success: res => {}, fail: () => {} })
|
||||
*
|
||||
* 注意:本层只做"URL 收口 + 通道选择",不做业务加工;
|
||||
* 业务字典(状态/来源/字段白名单)随端口导出,供各页面复用。
|
||||
*/
|
||||
import Http from './http.js';
|
||||
|
||||
/** 所有端点路径(与后端文档逐一比对,改动需两边同步) */
|
||||
export const CARD_API_URL = {
|
||||
// === 名片夹 /card/api/card/* ===
|
||||
CARD_LISTS: '/card/api/card/lists',
|
||||
CARD_DETAIL: '/card/api/card/detail',
|
||||
CARD_UPDATE: '/card/api/card/update',
|
||||
CARD_FAVORITE: '/card/api/card/toggleFavorite', // 收藏(双向 toggle, 后端 2.9.12)
|
||||
CARD_CREATE: '/card/api/card/create', // 新建数字名片(后端源码已有, 文档未列; 以源码为准)
|
||||
CARD_CLAIM: '/card/api/card/claim',
|
||||
CARD_RECOGNIZE: '/card/api/card/recognize',
|
||||
CARD_RETRY: '/card/api/card/retry',
|
||||
CARD_RECOGNIZE_BATCH: '/card/api/card/recognizeBatch',
|
||||
CARD_DELETE: '/card/api/card/delete', // 删除自己的名片(软删, 2.11.91)
|
||||
|
||||
// === 跟进完成态 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',
|
||||
|
||||
// === 员工建卡申请 2.11.15 /card/api/apply/* ===
|
||||
APPLY_SUBMIT: '/card/api/apply/submit',
|
||||
APPLY_STATUS: '/card/api/apply/status',
|
||||
|
||||
// === 人脉库 /card/api/contact/* ===
|
||||
CONTACT_LISTS: '/card/api/contact/lists',
|
||||
CONTACT_ADD: '/card/api/contact/add',
|
||||
CONTACT_INFO: '/card/api/contact/info',
|
||||
CONTACT_UPDATE: '/card/api/contact/update',
|
||||
CONTACT_DELETE: '/card/api/contact/delete',
|
||||
CONTACT_TO_LEAD: '/card/api/contact/toLead',
|
||||
|
||||
// === 潜在客户 /card/api/lead/* ===
|
||||
LEAD_LISTS: '/card/api/lead/lists',
|
||||
LEAD_SET_STATUS: '/card/api/lead/setStatus',
|
||||
|
||||
// === 交换名片 /card/api/exchange/* ===
|
||||
EXCHANGE_LISTS: '/card/api/exchange/lists',
|
||||
EXCHANGE_AGREE: '/card/api/exchange/agree',
|
||||
EXCHANGE_IGNORE: '/card/api/exchange/ignore',
|
||||
|
||||
// === 名片设计(员工端可选样式, 后端 v2) /card/api/design/* ===
|
||||
// 预设列表必须与商家后台 CardTemplatePresets 同源(避免前端自建漂移)
|
||||
// ⚠ v2 路径无 /card/ 段: 初版 /card/api/card/design/* 在本框架 action 单段路由下 404
|
||||
CARD_DESIGN_PRESETS: '/card/api/design/presets', // 预设列表(同源后台 CardTemplatePresets)
|
||||
CARD_DESIGN_SAVE: '/card/api/design/save', // 应用预设/保存本卡样式
|
||||
|
||||
// === 企业名片 /card/api/enterprise/* (2.13.6) ===
|
||||
// 业务模型: 企业卡片 owner_id=0(店铺级), 同一时刻仅 1 张 is_active=1 生效;
|
||||
// 名片区三模式 header_mode: official(指定/默认官方企业卡) / self(员工自选个人卡) / staff(员工轮播)
|
||||
ENTERPRISE_DETAIL: '/card/api/enterprise/detail', // GET ?card_id= 不传=当前生效卡
|
||||
ENTERPRISE_SAVE: '/card/api/enterprise/save', // POST {card_id?,fields?,design?,header_mode?,official_card_id?}
|
||||
ENTERPRISE_MINE: '/card/api/enterprise/mine', // GET 自己名下企业卡列表
|
||||
ENTERPRISE_ACTIVATE: '/card/api/enterprise/activate', // POST {card_id} 切换生效(互斥)
|
||||
ENTERPRISE_CHOOSE_SELF: '/card/api/enterprise/chooseSelf', // POST {card_id} 员工自选展示个人卡
|
||||
ENTERPRISE_DELETE: '/card/api/enterprise/delete' // POST {card_id} 删候选卡(生效卡禁删)
|
||||
};
|
||||
|
||||
/**
|
||||
* 社交媒体类型白名单:必须与后端 CardRichFields::SOCIAL_TYPES 保持一致(增减需两边同步)。
|
||||
* type 非法或 value 为空的条目会被后端静默丢弃。
|
||||
* ⚠️ 2026-10-13:telegram / messenger 由独立标量字段软下线,统一并入本数组;
|
||||
* 旧数据后端读取时自动合并进 socials_list(见 CARD_API_REFERENCE.md 3.3)。
|
||||
*/
|
||||
export const SOCIAL_TYPES = [
|
||||
{ key: 'alipay', label: '支付宝' },
|
||||
{ key: 'douyin', label: '抖音' },
|
||||
{ key: 'wxvideo', label: '视频号' },
|
||||
{ key: 'xiaohongshu', label: '小红书' },
|
||||
{ key: 'weibo', label: '微博' },
|
||||
{ key: 'kuaishou', label: '快手' },
|
||||
{ key: 'bilibili', label: '哔哩哔哩' },
|
||||
{ key: 'qq', label: 'QQ' },
|
||||
{ key: 'linkedin', label: 'LinkedIn' },
|
||||
{ key: 'facebook', label: 'Facebook' },
|
||||
{ key: 'instagram', label: 'Instagram' },
|
||||
{ key: 'x', label: 'X' },
|
||||
{ key: 'telegram', label: 'Telegram' },
|
||||
{ key: 'messenger', label: 'Messenger' },
|
||||
{ key: 'other', label: '其他' }
|
||||
];
|
||||
|
||||
/** 富化字段上限:与后端 CardRichFields::MAX_* 一致 */
|
||||
export const RICH_FIELD_LIMITS = {
|
||||
MAX_MOBILES: 5,
|
||||
MAX_SOCIALS: 10,
|
||||
MAX_CUSTOM: 10
|
||||
};
|
||||
|
||||
/** 线索跟进状态:与后端 Lead::STATUSES 一致(0-3,其它值报「参数不合法」) */
|
||||
export const LEAD_STATUS = [
|
||||
{ value: 0, label: '新客户' },
|
||||
{ value: 1, label: '已联系' },
|
||||
{ value: 2, label: '已成交' },
|
||||
{ value: 3, label: '无效' }
|
||||
];
|
||||
|
||||
/** 人脉来源:与后端 Contact::SOURCES 一致(scan/exchange 暂无产生入口) */
|
||||
export const CONTACT_SOURCES = [
|
||||
{ value: 'manual', label: '手工录入' },
|
||||
{ value: 'lead_in', label: '潜在客户转入' },
|
||||
{ value: 'scan', label: '扫描' },
|
||||
{ value: 'exchange', label: '交换' }
|
||||
];
|
||||
|
||||
/** 名片来源:与后端 relation.source_type 一致 */
|
||||
export const CARD_SOURCE_TYPES = [
|
||||
{ value: 'manual', label: '手动创建' },
|
||||
{ value: 'manual_batch', label: '批量制作' },
|
||||
{ value: 'scan', label: '扫描识别' },
|
||||
{ value: 'import_csv', label: 'CSV 导入' },
|
||||
{ value: 'import_xlsx', label: 'Excel 导入' },
|
||||
{ value: 'clone', label: '克隆' },
|
||||
{ value: 'api', label: '接口创建' }
|
||||
];
|
||||
|
||||
/**
|
||||
* 名片编辑标量字段:与后端 BusinessCardFieldNormalizer 输出对齐。
|
||||
* ⚠️ 2026-10-13:telegram / messenger 已软下线为独立标量(传了也被忽略),
|
||||
* 统一走 fields.socials 数组按类型添加;读取时旧值自动合并进 socials_list。
|
||||
*/
|
||||
export const CARD_FIELDS = [
|
||||
'name', 'company', 'job_title', 'avatar',
|
||||
'mobile', 'phone', 'email', 'website', 'wechat',
|
||||
'country', 'province', 'city', 'address', 'postal_code',
|
||||
'bio'
|
||||
];
|
||||
|
||||
/** 人脉可更新白名单:与后端 Contact::EDITABLE_FIELDS 一致 */
|
||||
export const CONTACT_FIELDS = [
|
||||
'name', 'mobile', 'company', 'job_title', 'wechat', 'email', 'website', 'address', 'remark', 'status'
|
||||
];
|
||||
|
||||
const CardApi = {
|
||||
url: CARD_API_URL,
|
||||
|
||||
/* ================= 名片夹 ================= */
|
||||
|
||||
/**
|
||||
* 我的名片列表
|
||||
* @param {Object} p { page, page_size(<=100), keyword, success, fail }
|
||||
* 返回 data: { list, count, page_count }
|
||||
*/
|
||||
cardLists(p) {
|
||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_LISTS }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 卡片详情
|
||||
* @param {Object} p { card_id, success, fail }
|
||||
* 返回 data: { relation, source, share_list }
|
||||
*/
|
||||
cardDetail(p) {
|
||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_DETAIL }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新卡片(normalize 语义:键存在才写;空串=清空;键缺席=不擦除)
|
||||
* 局部更新时只传用户实际编辑过的键。走 JSON body(fields 为嵌套对象)。
|
||||
* @param {Object} p { card_id, fields, success, fail }
|
||||
*/
|
||||
cardUpdate(p) {
|
||||
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_UPDATE }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 新建数字名片(商家端批量制作/单张新建入口)
|
||||
* 入参:`fields`(与 card/update 同结构,`name` 必填)+ 可选 `owner_id`(指定归属员工,0=未归属待认领)
|
||||
* 返回 `data`:`{ card_id, ...relation }`(前端用 data.card_id 跳详情)
|
||||
* ⚠️ 该端点后端源码已实现但旧文档未列出,按「文档与源码出入以源码为准」收口。
|
||||
*/
|
||||
cardCreate(p) {
|
||||
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_CREATE }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 认领名片(商家后台建的卡未归属 owner_id=0,员工认领到自己名下)
|
||||
* 入参(二选一, claim_code 优先): { data: { claim_code } } 或 { data: { card_id } }
|
||||
* 成功 data.claimed: true=新认领, false=早已是自己的(幂等)
|
||||
* 文档: card_claim_api.md(POST /card/api/card/claim,token 自动带)
|
||||
* @param {Object} p { data: { claim_code | card_id }, success, fail }
|
||||
*/
|
||||
cardClaim(p) {
|
||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_CLAIM }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 切换收藏(双向 toggle,幂等可逆:未收藏→置1,已收藏→置0)
|
||||
* 直接用返回值 data.is_favorite 回填 UI,前端不自行判断状态。
|
||||
* @param {Object} p { data: { card_id }, success, fail }
|
||||
* 返回 data: { card_id, is_favorite: 0|1 }
|
||||
* 拒绝(message 直接 toast): 卡不存在 / 非自己名下卡(统一「名片不存在」) / 参数不合法 / 未登录
|
||||
*/
|
||||
cardToggleFavorite(p) {
|
||||
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_FAVORITE }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除自己的名片(软删, 2.11.91)
|
||||
* 文档: CARD_DELETE_API.md(POST /card/api/card/delete,token 自动带,不传 site_id)
|
||||
* 通道: 与 cardClaim/cardFollowDone 同款 sendRequest(表单编码);只需 card_id 一个字段
|
||||
* 交互: 调用前页面必须已做二次确认;成功后 toast「已删除」并刷新列表(接口不再返回该卡)
|
||||
* 口径: 「卡片不存在」是故意模糊(他人/已删/不存在同一文案),前端统一按 message toast,不特殊处理
|
||||
* @param {Object} p { data: { card_id }, success, fail }
|
||||
*/
|
||||
cardDelete(p) {
|
||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_DELETE }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 识别建卡
|
||||
* @param {Object} p { media_path, media_type?, source_type?, success, fail }
|
||||
* 返回 data: { source_id, relation_id, card_id, deduped, fields }
|
||||
* deduped=true 表示站点内已有同手机号名片,未新建,前端提示"已存在同名片"
|
||||
*/
|
||||
cardRecognize(p) {
|
||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_RECOGNIZE }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 识别失败重试(仅本站 + status=failed 的来源可重试,有次数上限)
|
||||
* @param {Object} p { source_id, success, fail }
|
||||
*/
|
||||
cardRetry(p) {
|
||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_RETRY }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 批量识别(1~10 条,逐项独立成败)
|
||||
* @param {Object} p { items: [{ source_type?, media_type?, media_path, media_key? }], success, fail }
|
||||
* 返回 data: { items: [{ ok, message, data }], ok, failed }
|
||||
*/
|
||||
cardRecognizeBatch(p) {
|
||||
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_RECOGNIZE_BATCH }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 标记跟进完成(2.11.3 跟进闭环)
|
||||
* 行级归属: 只能标记自己名下的名片(与收藏/认领同一套口径)
|
||||
* 成功 data: { card_id, follow_up_done: 1 }
|
||||
* 失败 message 直接 toast: 卡片不存在 / 只能标记自己名下的名片
|
||||
* @param {Object} p { data: { card_id }, success, fail }
|
||||
*/
|
||||
cardFollowDone(p) {
|
||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_FOLLOW_DONE }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 访客轨迹(2.11.4 谁看了我)
|
||||
* 入参(全部可选): { data: { card_id?, page?, page_size?(<=50, 建议20) } }
|
||||
* 不传 card_id = 当前员工名下全部名片; 成功 data: { list, total, page, page_size,
|
||||
* summary: { today_pv, today_uv, total_pv, total_uv } }
|
||||
* @param {Object} p { data: {...}, success, fail }
|
||||
*/
|
||||
cardViews(p) {
|
||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_VIEWS }, p || {}));
|
||||
},
|
||||
|
||||
/* ================= 人脉库 ================= */
|
||||
|
||||
/**
|
||||
* 人脉列表
|
||||
* @param {Object} p { page, page_size(<=100), keyword, source?, status?, success, fail }
|
||||
* 返回 data: { list, count, page_count }
|
||||
*/
|
||||
contactLists(p) {
|
||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_LISTS }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 新增人脉
|
||||
* @param {Object} p { fields: { name(必填), company?, ... }, success, fail }
|
||||
*/
|
||||
contactAdd(p) {
|
||||
Http.sendJson(Object.assign({ url: CARD_API_URL.CONTACT_ADD }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 人脉详情(编辑页回填;id 为数字自增主键,注意与名片 card_id 字符串不同)
|
||||
* @param {Object} p { id, success, fail }
|
||||
*/
|
||||
contactInfo(p) {
|
||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_INFO }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新人脉(兼容 fields 对象或顶层平铺字段)
|
||||
* @param {Object} p { id, fields?, ...平铺字段, success, fail }
|
||||
*/
|
||||
contactUpdate(p) {
|
||||
Http.sendJson(Object.assign({ url: CARD_API_URL.CONTACT_UPDATE }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除人脉(真删不可恢复,前端需二次确认弹窗)
|
||||
* @param {Object} p { id, success, fail }
|
||||
*/
|
||||
contactDelete(p) {
|
||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_DELETE }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 人脉 → 潜在客户(不删人脉行;同员工同手机号已转过则报错)
|
||||
* @param {Object} p { id, success, fail }
|
||||
*/
|
||||
contactToLead(p) {
|
||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_TO_LEAD }, p || {}));
|
||||
},
|
||||
|
||||
/* ================= 潜在客户 ================= */
|
||||
|
||||
/**
|
||||
* 线索列表(自己名下名片收到的线索 + 自己从人脉转入的线索)
|
||||
* @param {Object} p { page, page_size(<=100), status?(0-3), keyword, success, fail }
|
||||
* 返回 data: { list, count, page_count }
|
||||
*/
|
||||
leadLists(p) {
|
||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.LEAD_LISTS }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新跟进状态(status 只能是 0/1/2/3)
|
||||
* @param {Object} p { id, status, success, fail }
|
||||
* 返回 data: { id, status }
|
||||
*/
|
||||
leadSetStatus(p) {
|
||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.LEAD_SET_STATUS }, p || {}));
|
||||
},
|
||||
|
||||
/* ================= 交换名片 ================= */
|
||||
|
||||
/**
|
||||
* 待处理交换请求列表(返回 status=0;已同意/已忽略员工端不可见)
|
||||
* @param {Object} p { page, page_size(<=100), success, fail }
|
||||
* 返回 data: { list:[{ id, from_card_id, name, avatar, job_title, company,
|
||||
* create_time, create_time_txt, status }], count, page_count }
|
||||
* ⚠️ 详情页红点计数技巧: lists?page=1&page_size=1 取 count 即可
|
||||
* ⚠️ 后端契约要求 lists 走 GET(分页在 query); 而 Http.sendRequest 是
|
||||
* 「带 data 就发 POST」的语义, 所以这里把分页拼进 url、不传 data,
|
||||
* 否则会被发成 POST 导致接口不匹配、列表恒为空。
|
||||
*/
|
||||
exchangeLists(p) {
|
||||
const opt = Object.assign({}, p || {});
|
||||
const src = opt.data || opt;
|
||||
const page = src.page || 1;
|
||||
const pageSize = src.page_size || 20;
|
||||
delete opt.data;
|
||||
delete opt.page;
|
||||
delete opt.page_size;
|
||||
opt.url = CARD_API_URL.EXCHANGE_LISTS + '?page=' + page + '&page_size=' + pageSize;
|
||||
Http.sendRequest(opt);
|
||||
},
|
||||
|
||||
/**
|
||||
* 同意交换(后端自动双方建人脉,前端无需再调 contact/add)
|
||||
* 幂等: 同侧已存在同手机号人脉时 receiver_created=0,仍是成功,不当失败
|
||||
* @param {Object} p { data: { id }, success, fail }
|
||||
* 返回 data: { id, status:1, receiver_created, initiator_created }
|
||||
*/
|
||||
exchangeAgree(p) {
|
||||
Http.sendJson(Object.assign({ url: CARD_API_URL.EXCHANGE_AGREE }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 忽略交换(仅本方隐藏 status=2,对方不发通知、可再次发起)
|
||||
* @param {Object} p { data: { id }, success, fail }
|
||||
* 返回 data: { id, status:2 }
|
||||
*/
|
||||
exchangeIgnore(p) {
|
||||
Http.sendJson(Object.assign({ url: CARD_API_URL.EXCHANGE_IGNORE }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 同意交换后「校验 + 兜底」建人脉(页面点「同意」后必调;幂等,可安全重复调用)。
|
||||
* 背景: 后端声明 CardExchange::agree 会双向互存人脉(我方人脉收对方名片 / 对方人脉收我方名片),
|
||||
* 但实测出现「同意成功、对方名片却没进我的人脉」, 故前端不再只信 receiver_created,
|
||||
* 统一在同意后按「对方名片编号」校验一次: 我的人脉里没有同手机号记录才补建。
|
||||
* 流程: 取对方名片详情 → 按手机号查我的人脉 → 不存在则以 source=exchange 新建人脉。
|
||||
* 幂等: 同手机号人脉已存在(含后端已建)时 created=0, 与后台已建不会重复。
|
||||
* @param {Object} p { fromCardId, success, fail }
|
||||
* 回调 done(data): { code, created, existed } created=1 新建 / 0 已存在或无需建;
|
||||
* existed = 命中的同手机号人脉行(未命中为 null), 供页面提示「人脉中已有该联系人」。
|
||||
*/
|
||||
ensureExchangeContact(p) {
|
||||
const self = this;
|
||||
const done = p.success, fail = p.fail;
|
||||
const finish = (d) => { typeof done === 'function' && done(d); };
|
||||
const fromCardId = p.fromCardId;
|
||||
if (!fromCardId) { finish({ code: 0, created: 0 }); return; }
|
||||
this.cardDetail({
|
||||
data: { card_id: fromCardId },
|
||||
success: res => {
|
||||
if (res.code != 0) { typeof fail === 'function' && fail(res); return; }
|
||||
const r = (res.data && (res.data.relation || res.data)) || {};
|
||||
const mobile = r.mobile || '';
|
||||
const fields = {
|
||||
name: r.name || '',
|
||||
company: r.company || '',
|
||||
job_title: r.job_title || '',
|
||||
mobile: mobile,
|
||||
source: 'exchange'
|
||||
};
|
||||
// 富化字段仅在对方有名片值时带上(空串=清空语义, 新建人脉时不传空键)
|
||||
if (r.wechat) fields.wechat = r.wechat;
|
||||
if (r.email) fields.email = r.email;
|
||||
if (r.website) fields.website = r.website;
|
||||
if (r.address) fields.address = r.address;
|
||||
const add = () => self.contactAdd({
|
||||
data: { source: 'exchange', fields: fields },
|
||||
success: res2 => {
|
||||
// code!=0(如手机号已存在被拒): 交换已同意成功, 兜底不打扰用户, 按无需新建收口
|
||||
const ok = res2 && res2.code == 0;
|
||||
finish({ code: ok ? 0 : ((res2 && res2.code) || 0), created: ok ? 1 : 0, raw: res2 });
|
||||
},
|
||||
fail: err => { typeof fail === 'function' && fail(err); }
|
||||
});
|
||||
// 按手机号去重: 已存在同手机号人脉则不重复建(兼容后台已建 / 重复同意)
|
||||
if (mobile) {
|
||||
self.contactLists({
|
||||
data: { page: 1, page_size: 20, keyword: mobile },
|
||||
success: res3 => {
|
||||
const d3 = res3.data || {};
|
||||
const list = Array.isArray(d3) ? d3 : (d3.list || []);
|
||||
const hit = list.find(c => (c.mobile || '') === mobile);
|
||||
// 命中: 对方已在我的人脉里(可能显示为旧姓名), 不覆盖用户维护过的数据, 只回传命中行
|
||||
if (hit) { finish({ code: 0, created: 0, existed: hit }); return; }
|
||||
add();
|
||||
},
|
||||
fail: () => add()
|
||||
});
|
||||
} else {
|
||||
add();
|
||||
}
|
||||
},
|
||||
fail: err => { typeof fail === 'function' && fail(err); }
|
||||
});
|
||||
},
|
||||
|
||||
/* ================= 员工建卡申请 2.11.15 ================= */
|
||||
|
||||
/**
|
||||
* 提交建卡申请(空态申请 → 商家审批 → 自动建卡归属)
|
||||
* 约束: 同一会员同时只允许一条待审申请(重复提交后端会拒)
|
||||
* 入参: { data: { name, mobile } }
|
||||
* @param {Object} p { data: { name, mobile }, success, fail }
|
||||
*/
|
||||
applySubmit(p) {
|
||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.APPLY_SUBMIT }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 查询我的建卡申请状态(进入名片夹/空态时调用或轮询)
|
||||
* 成功 data: { status: 0 待审/1 已同意/2 已拒绝, status_text, note, has_card }
|
||||
* @param {Object} p { success, fail }
|
||||
*/
|
||||
applyStatus(p) {
|
||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.APPLY_STATUS }, p || {}));
|
||||
},
|
||||
|
||||
/* ================= 名片设计(员工端可选样式) ================= */
|
||||
|
||||
/**
|
||||
* 拉取名片预设列表(与商家后台 CardTemplatePresets 同一份, 同源)
|
||||
* 成功 data: { list:[{ key, name, desc, thumb,
|
||||
* design:{ theme_color, text_color, bg_color, layout, cover, font_family,
|
||||
* font_scale, card_radius, header_gradient_end, header_gradient_angle, quick_style } }] }
|
||||
* @param {Object} p { success, fail }
|
||||
*/
|
||||
cardDesignPresets(p) {
|
||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_DESIGN_PRESETS }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 应用预设 / 保存本卡样式(员工端选择后台预设后落库)
|
||||
* 入参(包在 data 里): { card_id, preset_key } —— 后端按 preset_key 取 CardTemplatePresets
|
||||
* 的 design 合并进本卡 lucky_card_design(非空覆盖), 返回 { code:0 }
|
||||
* @param {Object} p { data: { card_id, preset_key }, success, fail }
|
||||
*/
|
||||
cardDesignSave(p) {
|
||||
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_DESIGN_SAVE }, p || {}));
|
||||
},
|
||||
|
||||
/* ================= 企业名片 2.13.6 ================= */
|
||||
|
||||
/**
|
||||
* 企业名片详情(核心)
|
||||
* GET ?card_id= 不传返回当前生效企业名片;传候选卡 id 可预览(is_active=0)
|
||||
* 成功 data: { card_id, is_active, is_owner, fields:{name,company,job_title,mobile,email,
|
||||
* website,address,wechat,avatar,bio,name_en,company_en,job_title_en,bio_en},
|
||||
* design:{theme_color,bg_color,text_color,template_id,canvas,header_mode,official_card_id},
|
||||
* header:{mode,official_card_id,official,self,staff} }
|
||||
* 渲染: 名片区按 header.mode 取 official/self/staff 数据, 内容区用 fields+design.canvas
|
||||
* 失败「企业名片不存在」→ 前端空态引导去管理端创建
|
||||
* @param {Object} p { data: { card_id? }, success, fail }
|
||||
*/
|
||||
enterpriseDetail(p) {
|
||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.ENTERPRISE_DETAIL }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 保存企业名片(后台/会员;owner_id=0 的卡会员不可改)
|
||||
* @param {Object} p { data: { card_id?, fields?, design?, header_mode?, official_card_id? }, success, fail }
|
||||
*/
|
||||
enterpriseSave(p) {
|
||||
Http.sendJson(Object.assign({ url: CARD_API_URL.ENTERPRISE_SAVE }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 我的企业名片列表(自己名下)
|
||||
* 成功 data: { list: [{ card_id, name, is_active }] }
|
||||
* @param {Object} p { success, fail }
|
||||
*/
|
||||
enterpriseMine(p) {
|
||||
Http.sendRequest(Object.assign({ url: CARD_API_URL.ENTERPRISE_MINE }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 切换生效企业名片(互斥:全店仅 1 张 is_active=1,仅能切自己名下的卡)
|
||||
* @param {Object} p { data: { card_id }, success, fail }
|
||||
*/
|
||||
enterpriseActivate(p) {
|
||||
Http.sendJson(Object.assign({ url: CARD_API_URL.ENTERPRISE_ACTIVATE }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 员工自选展示名片(self 模式):card_id 必须是自己名下的 contact 卡
|
||||
* 选择按会员存储;detail 的 header.self.chosen=1 表示已自选
|
||||
* @param {Object} p { data: { card_id }, success, fail }
|
||||
*/
|
||||
enterpriseChooseSelf(p) {
|
||||
Http.sendJson(Object.assign({ url: CARD_API_URL.ENTERPRISE_CHOOSE_SELF }, p || {}));
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除企业名片(会员端;生效卡不可删,删除候选卡会清理官方名片引用)
|
||||
* @param {Object} p { data: { card_id }, success, fail }
|
||||
*/
|
||||
enterpriseDelete(p) {
|
||||
Http.sendJson(Object.assign({ url: CARD_API_URL.ENTERPRISE_DELETE }, p || {}));
|
||||
}
|
||||
};
|
||||
|
||||
export default CardApi;
|
||||
@@ -115,7 +115,12 @@ export default {
|
||||
}
|
||||
|
||||
//媒体
|
||||
if (this.goodsSkuDetail.video_url) this.switchMedia = "video";
|
||||
// 媒体显示顺序以后台商品「高级设置 → 媒体显示顺序」media_order 为准
|
||||
// (1=主图在前 2=视频在前); 真正渲染媒体区的是 goods-detail-view 组件,
|
||||
// 组件内部按同一规则切换, 这里保持一致避免两处规则打架
|
||||
if (this.goodsSkuDetail.video_url) {
|
||||
this.switchMedia = (Number(this.goodsSkuDetail.media_order) === 2) ? "video" : "img";
|
||||
}
|
||||
|
||||
if (!Array.isArray(this.goodsSkuDetail.sku_images)) {
|
||||
if (this.goodsSkuDetail.sku_images) this.goodsSkuDetail.sku_images = this.goodsSkuDetail.sku_images.split(",");
|
||||
|
||||
@@ -272,6 +272,59 @@ export default {
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* JSON body 请求通道
|
||||
*
|
||||
* 为什么需要: sendRequest 走表单编码, 嵌套数组(如 fields.mobiles)的序列化
|
||||
* 格式在各端不一致(uni 序列化器的平台差异, 小程序/H5 产出不同),
|
||||
* 后端 input('fields/a') 依赖 PHP 方括号语法, 踩坑风险高。
|
||||
* 本方法统一 JSON.stringify + application/json —— TP6 Request
|
||||
* 原生解析 JSON body(getInputData), 且字符串 body 各端原样发送, 零歧义。
|
||||
* token/uniacid/app_type 与 sendRequest 一样自动补齐。
|
||||
*/
|
||||
sendJson(params) {
|
||||
if (!Config.baseUrl) {
|
||||
uni.showToast({
|
||||
title: getLang().lang('http.baseUrlNotConfigured'),
|
||||
'icon': 'none',
|
||||
'duration': 10000
|
||||
});
|
||||
return;
|
||||
}
|
||||
const url = (Config.baseUrl + params.url).replace(/(?<!:)\/+/g, '/');
|
||||
const data = {
|
||||
app_type: appType,
|
||||
app_type_name: appTypeName,
|
||||
token: store.state.token || '',
|
||||
uniacid: uni.getStorageSync('uniacid') || Config.uniacid
|
||||
};
|
||||
if (params.data != undefined) Object.assign(data, params.data);
|
||||
|
||||
const store_info = store.state.globalStoreInfo;
|
||||
if (store_info) data.store_id = store_info.store_id;
|
||||
|
||||
console.log('[HTTP][REQUEST] sendJson', url);
|
||||
uni.request({
|
||||
url: url,
|
||||
method: params.method || 'POST',
|
||||
header: Object.assign({ 'content-type': 'application/json' }, params.header || {}),
|
||||
data: JSON.stringify(data),
|
||||
dataType: params.dataType || 'json',
|
||||
responseType: params.responseType || 'text',
|
||||
success: (res) => {
|
||||
console.log('[HTTP][RESPONSE] sendJson', url, 'statusCode=', res.statusCode);
|
||||
let r = res.data;
|
||||
if (typeof r === 'string') {
|
||||
try { r = JSON.parse(r); } catch (e) { /* 保持原样 */ }
|
||||
}
|
||||
typeof params.success == 'function' && params.success(r);
|
||||
},
|
||||
fail: (res) => {
|
||||
console.error('[HTTP][FAIL] sendJson', url, JSON.stringify(res));
|
||||
typeof params.fail == 'function' && params.fail(res);
|
||||
}
|
||||
});
|
||||
},
|
||||
upload(params) {
|
||||
console.log('[HTTP][REQUEST] upload', Config.baseUrl + params.url, 'name=', params.name || 'file');
|
||||
uni.uploadFile({
|
||||
|
||||
@@ -199,7 +199,25 @@ export default {
|
||||
* 全局默认 onShareAppMessage:
|
||||
* uni-app 规范:组件自身的钩子优先级 > mixin,因此已显式声明的页面不受影响。
|
||||
*/
|
||||
onShareAppMessage() {
|
||||
onShareAppMessage(options) {
|
||||
// 名片「发送名片」按钮(open-type="share")触发: dataset 携带名片信息 → 精准转发该名片
|
||||
// (覆盖默认 cfg 逻辑; 微信小程序无法在组件内直接定义转发, 只能由页面 onShareAppMessage 提供)
|
||||
if (
|
||||
options &&
|
||||
options.from === 'button' &&
|
||||
options.target &&
|
||||
options.target.dataset &&
|
||||
options.target.dataset.cardId
|
||||
) {
|
||||
const d = options.target.dataset;
|
||||
const name = d.cardName || '我的名片';
|
||||
const img = (this.$util && this.$util.img ? this.$util.img(d.cardQr) : d.cardQr) || '';
|
||||
return {
|
||||
title: name + ' 的电子名片',
|
||||
path: '/pages_tool/card/detail?card_id=' + d.cardId,
|
||||
imageUrl: img
|
||||
};
|
||||
}
|
||||
// 命中禁用列表的页面:即使 wx.hideShareMenu 因某些原因没生效,
|
||||
// 这里直接 return undefined / 也不主动拼 cfg,让微信回退到默认行为
|
||||
// (主防线是 wx.hideShareMenu,这里只是兜底)
|
||||
|
||||
@@ -200,6 +200,17 @@ export const PAGE_KEY_MAP = {
|
||||
// === 电子名片 ===
|
||||
PAGE_CONTACT_CARD: '/pages/contact/contact',
|
||||
|
||||
// === 名片云(card 插件) ===
|
||||
PAGE_CARD_LIST: '/pages_tool/card/list',
|
||||
PAGE_CARD_LEADS: '/pages_tool/card/leads',
|
||||
PAGE_CARD_CONTACTS: '/pages_tool/card/contacts',
|
||||
PAGE_CARD_ENTERPRISE: '/pages_tool/card/enterprise',
|
||||
|
||||
// === 名片云 · 商家端(后台接口就绪一部分, 先上有接口的页面) ===
|
||||
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',
|
||||
|
||||
// === VR ===
|
||||
PAGE_VR_INDEX: '/pages_tool/vr/index',
|
||||
|
||||
|
||||
512
components-diy/diy-card-common.js
Normal file
512
components-diy/diy-card-common.js
Normal file
File diff suppressed because one or more lines are too long
290
components-diy/diy-card-entry.vue
Normal file
290
components-diy/diy-card-entry.vue
Normal file
@@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<view data-component-name="diy-card-entry" class="diy-card-entry" :class="'entry-' + stylePreset" :style="wrapStyle" hover-class="entry-press" @tap="goLink">
|
||||
<!-- 图标装饰(可选) -->
|
||||
<view v-if="showIcon" class="entry-icon" :style="iconWrapStyle">
|
||||
<image v-if="iconType === 'image' && iconImage" class="icon-img" :src="$util.img(iconImage)" mode="aspectFit" />
|
||||
<text v-else class="icon-glyph">{{ iconGlyph || '💼' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="entry-body">
|
||||
<text class="entry-title" :style="titleStyle">{{ value.title || '扫名片 · 谈合作' }}</text>
|
||||
<text v-if="value.subtitle" class="entry-sub" :style="subStyle">{{ value.subtitle }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 按钮(可选; link 模式为文字箭头) -->
|
||||
<view v-if="buttonStyle !== 'none' && value.btnText" :class="['entry-btn', 'btn-' + buttonStyle]" :style="btnStyle" hover-class="btn-press" hover-stay-time="60">
|
||||
<text class="entry-btn-text" :style="btnTextStyle">{{ value.btnText }}</text>
|
||||
<text v-if="buttonStyle === 'link'" class="btn-arrow" :style="btnArrowStyle">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 数字名片 · 名片入口横幅(装修组件)
|
||||
// 预设: gradient(渐变) / solid(纯色+主题饰条) / minimal(透明轻量)
|
||||
// 布局: left / center; 按钮: solid / ghost / link / none
|
||||
import { CardTokens, px2rpx, flagOn, num } from './diy-card-common.js';
|
||||
|
||||
export default {
|
||||
name: 'diy-card-entry',
|
||||
props: {
|
||||
value: { type: Object }
|
||||
},
|
||||
computed: {
|
||||
v() {
|
||||
return this.value || {};
|
||||
},
|
||||
stylePreset() {
|
||||
const p = String(this.v.stylePreset || 'gradient');
|
||||
return ['gradient', 'solid', 'minimal'].includes(p) ? p : 'gradient';
|
||||
},
|
||||
isCenter() {
|
||||
return String(this.v.layout || 'left') === 'center';
|
||||
},
|
||||
showIcon() {
|
||||
return flagOn(this.v.showIcon, false);
|
||||
},
|
||||
iconType() {
|
||||
return String(this.v.iconType || 'icon') === 'image' ? 'image' : 'icon';
|
||||
},
|
||||
iconImage() {
|
||||
return this.v.iconImage || '';
|
||||
},
|
||||
iconGlyph() {
|
||||
return this.v.iconGlyph || '名';
|
||||
},
|
||||
buttonStyle() {
|
||||
const b = String(this.v.buttonStyle || 'solid');
|
||||
return ['solid', 'ghost', 'link', 'none'].includes(b) ? b : 'solid';
|
||||
},
|
||||
isGradient() {
|
||||
return this.stylePreset === 'gradient';
|
||||
},
|
||||
isMinimal() {
|
||||
return this.stylePreset === 'minimal';
|
||||
},
|
||||
themeColor() {
|
||||
return this.v.bgColorStart || CardTokens.color.primary;
|
||||
},
|
||||
wrapStyle() {
|
||||
const v = this.v;
|
||||
const radius = px2rpx(num(v.borderRadius, CardTokens.radius.md)) + 'rpx';
|
||||
const pad = px2rpx(num(v.padding, CardTokens.space.lg)) + 'rpx';
|
||||
if (this.isMinimal) {
|
||||
return this.$util.objToStyle({
|
||||
background: 'transparent',
|
||||
borderRadius: radius,
|
||||
padding: pad,
|
||||
boxShadow: 'none',
|
||||
});
|
||||
}
|
||||
if (this.isGradient) {
|
||||
return this.$util.objToStyle({
|
||||
background: 'linear-gradient(135deg, ' + (v.bgColorStart || CardTokens.color.primary) + ' 0%, ' + (v.bgColorEnd || '#1E40AF') + ' 100%)',
|
||||
borderRadius: radius,
|
||||
padding: pad,
|
||||
minHeight: '120rpx',
|
||||
});
|
||||
}
|
||||
// solid: 主题色底 + 左侧亮饰条
|
||||
return this.$util.objToStyle({
|
||||
background: v.bgColorStart || CardTokens.color.primary,
|
||||
borderRadius: radius,
|
||||
padding: pad,
|
||||
borderLeft: '8rpx solid ' + (v.bgColorEnd || 'rgba(255,255,255,0.35)'),
|
||||
minHeight: '120rpx',
|
||||
});
|
||||
},
|
||||
iconWrapStyle() {
|
||||
return this.$util.objToStyle({
|
||||
background: this.isGradient || !this.isMinimal ? 'rgba(255,255,255,0.18)' : 'rgba(47,107,255,0.10)',
|
||||
});
|
||||
},
|
||||
titleStyle() {
|
||||
return this.$util.objToStyle({
|
||||
color: this.v.textColor || (this.isMinimal ? CardTokens.color.text : '#FFFFFF'),
|
||||
fontSize: px2rpx(num(this.v.titleSize, 16)) + 'rpx',
|
||||
});
|
||||
},
|
||||
subStyle() {
|
||||
return this.$util.objToStyle({
|
||||
color: this.v.textColor || (this.isMinimal ? CardTokens.color.textSub : '#FFFFFF'),
|
||||
fontSize: px2rpx(num(this.v.subSize, 12)) + 'rpx',
|
||||
opacity: this.isMinimal ? 1 : 0.85,
|
||||
});
|
||||
},
|
||||
btnStyle() {
|
||||
const v = this.v;
|
||||
const radius = px2rpx(CardTokens.radius.pill) + 'rpx';
|
||||
const pad = '0 ' + px2rpx(CardTokens.space.xl) + 'rpx';
|
||||
if (this.buttonStyle === 'ghost') {
|
||||
return this.$util.objToStyle({
|
||||
border: '2rpx solid ' + (v.accentColor || '#FFEB3B'),
|
||||
borderRadius: radius,
|
||||
padding: pad,
|
||||
height: px2rpx(CardTokens.touch.chipHeight) + 'rpx',
|
||||
});
|
||||
}
|
||||
if (this.buttonStyle === 'link') {
|
||||
return this.$util.objToStyle({ padding: '0 0 0 ' + px2rpx(CardTokens.space.sm) + 'rpx' });
|
||||
}
|
||||
return this.$util.objToStyle({
|
||||
background: v.accentColor || '#FFEB3B',
|
||||
borderRadius: radius,
|
||||
padding: pad,
|
||||
height: px2rpx(CardTokens.touch.chipHeight) + 'rpx',
|
||||
});
|
||||
},
|
||||
btnTextStyle() {
|
||||
const v = this.v;
|
||||
const fontSize = px2rpx(num(this.v.btnSize, 12)) + 'rpx';
|
||||
if (this.buttonStyle === 'ghost') {
|
||||
return this.$util.objToStyle({ color: v.accentColor || '#FFEB3B', fontSize: fontSize });
|
||||
}
|
||||
if (this.buttonStyle === 'link') {
|
||||
return this.$util.objToStyle({ color: v.textColor || CardTokens.color.primary, fontSize: fontSize });
|
||||
}
|
||||
return this.$util.objToStyle({ color: '#333333', fontSize: fontSize });
|
||||
},
|
||||
/**
|
||||
* 文字箭头按钮的箭头: 仅 link 档渲染, 颜色与该档按钮文字一致;
|
||||
* 字号比按钮文字略大一档(默认 14px = 28rpx, 与历史渲染一致)
|
||||
*/
|
||||
btnArrowStyle() {
|
||||
return this.$util.objToStyle({
|
||||
color: this.v.textColor || CardTokens.color.primary,
|
||||
fontSize: px2rpx(num(this.v.btnSize, 12) + 2) + 'rpx',
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
goLink() {
|
||||
const v = this.v;
|
||||
let url = '';
|
||||
if (v.linkMode === 'contacts') {
|
||||
url = '/pages_tool/card/contacts';
|
||||
} else if (v.linkMode === 'custom') {
|
||||
url = String(v.customUrl || '').trim();
|
||||
if (url !== '' && url.indexOf('/') !== 0) {
|
||||
uni.showToast({ title: '自定义路径需以 / 开头', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
} else if (v.linkMode === 'link') {
|
||||
// 平台统一「选择链接」: link 是个对象({name,title,wap_url,page_key,page_params}),
|
||||
// 走 $util.diyRedirectTo 由平台统一处理(内部页/外链/小程序/拨号 等)
|
||||
const link = v.link && typeof v.link === 'object' ? v.link : null;
|
||||
if (!link || !link.name) {
|
||||
uni.showToast({ title: '请先选择链接', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
this.$util.diyRedirectTo(link);
|
||||
return;
|
||||
} else {
|
||||
url = '/pages_tool/card/list';
|
||||
}
|
||||
if (url === '') {
|
||||
uni.showToast({ title: '未配置跳转路径', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
this.$util.redirectTo(url);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.diy-card-entry {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
&.entry-center {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
|
||||
.entry-body {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.entry-icon {
|
||||
flex-shrink: 0;
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
.icon-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.icon-glyph {
|
||||
font-size: 48rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.entry-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.entry-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.entry-sub {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.5;
|
||||
margin-top: 8rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.entry-btn {
|
||||
flex-shrink: 0;
|
||||
margin-left: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
|
||||
.entry-btn-text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-arrow {
|
||||
font-size: 28rpx;
|
||||
margin-left: 2rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.entry-press {
|
||||
transform: scale(0.985);
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.btn-press {
|
||||
opacity: 0.72;
|
||||
}
|
||||
</style>
|
||||
568
components-diy/diy-card-list.vue
Normal file
568
components-diy/diy-card-list.vue
Normal file
@@ -0,0 +1,568 @@
|
||||
<template>
|
||||
<view data-component-name="diy-card-list" class="diy-card-list">
|
||||
<view class="card-wall" :style="wallStyle">
|
||||
<!-- 标题行: 标题 + 数量 + 查看全部 -->
|
||||
<view v-if="showTitle || showCount || showMore" class="wall-head">
|
||||
<text v-if="showTitle" class="wall-title" :style="titleStyle">{{ value.title || '团队名片' }}</text>
|
||||
<text v-if="showCount && list.length" class="wall-count" :style="countStyle">{{ list.length }} 人</text>
|
||||
<view class="head-spacer"></view>
|
||||
<text v-if="showMore" class="wall-more" :style="moreStyle" hover-class="btn-press" @tap="goMore">{{ moreText || '查看全部' }} ›</text>
|
||||
</view>
|
||||
|
||||
<!-- 骨架屏 -->
|
||||
<view v-if="loading && !list.length" class="wall-skeleton">
|
||||
<view v-for="n in skeletonCount" :key="'sk' + n" class="sk-item">
|
||||
<view class="sk sk-avatar"></view>
|
||||
<view class="sk-col">
|
||||
<view class="sk sk-line" style="width:50%"></view>
|
||||
<view class="sk sk-line" style="width:70%;margin-top:14rpx;"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 横滑布局 -->
|
||||
<scroll-view v-else-if="layout === 'scroll' && list.length" class="wall-scroll" scroll-x show-scrollbar="false">
|
||||
<view v-for="(item, index) in list" :key="item.card_id || index" class="scroll-item fade-up" :style="scrollItemStyle" hover-class="btn-press" @tap="goDetail(item)">
|
||||
<view class="item-avatar" :style="avatarStyle(item)">
|
||||
<image v-if="item.avatar" class="avatar-img" :src="$util.img(item.avatar)" mode="aspectFill" />
|
||||
<text v-else class="avatar-initial">{{ initialOf(item.name) }}</text>
|
||||
</view>
|
||||
<text class="scroll-name" :style="scrollNameStyle">{{ item.name || '未命名名片' }}</text>
|
||||
<text v-if="infoLine(item)" class="scroll-meta" :style="scrollMetaStyle">{{ infoLine(item) }}</text>
|
||||
<text v-if="isEnterprise(item)" class="item-badge" :style="badgeStyle(item)">企业</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 网格/列表布局 -->
|
||||
<view v-else-if="list.length" :class="['wall-grid', layout === 'list' ? 'is-list' : 'is-grid']">
|
||||
<view v-for="(item, index) in list" :key="item.card_id || index" class="wall-item fade-up"
|
||||
:style="itemFinalStyle(index)" hover-class="btn-press" @tap="goDetail(item)">
|
||||
<!-- 内层卡片: 布局盒(wall-item)只负责 50% 宽+左右 gutter, 视觉在此层
|
||||
(WXSS 不支持 calc(), 不能用 calc(50% - gap/2)) -->
|
||||
<view class="item-card">
|
||||
<view class="item-avatar" :style="avatarStyle(item)">
|
||||
<image v-if="item.avatar" class="avatar-img" :src="$util.img(item.avatar)" mode="aspectFill" />
|
||||
<text v-else class="avatar-initial">{{ initialOf(item.name) }}</text>
|
||||
</view>
|
||||
<view class="item-info">
|
||||
<view class="name-row">
|
||||
<text class="item-name" :style="gridNameStyle">{{ item.name || '未命名名片' }}</text>
|
||||
<text v-if="isEnterprise(item)" class="item-badge" :style="badgeStyle(item)">企业</text>
|
||||
</view>
|
||||
<text v-if="infoLine(item)" class="item-meta" :style="metaStyle">{{ infoLine(item) }}</text>
|
||||
</view>
|
||||
<text v-if="showArrow" class="item-arrow" :style="infoStyle">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空态 -->
|
||||
<view v-else-if="!loading" class="wall-empty" :style="infoStyle">
|
||||
<text class="empty-icon">🗂️</text>
|
||||
<text class="empty-tip">{{ cardIdsConfigured ? '暂无可展示的名片' : '商家还未挑选名片' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 数字名片 · 团队名片墙(装修组件)
|
||||
// 布局: grid(1/2 列) / list(整行) / scroll(横滑); 骨架屏 + 逐项入场 + 按压反馈
|
||||
import { CardTokens, px2rpx, flagOn, num, initialOf, avatarBg } from './diy-card-common.js';
|
||||
|
||||
export default {
|
||||
name: 'diy-card-list',
|
||||
props: {
|
||||
value: { type: Object }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
list: [],
|
||||
loading: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
v() {
|
||||
return this.value || {};
|
||||
},
|
||||
cardIds() {
|
||||
return String(this.v.cardIds || '')
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 20)
|
||||
.join(',');
|
||||
},
|
||||
cardIdsConfigured() {
|
||||
return String(this.v.cardIds || '').trim() !== '';
|
||||
},
|
||||
layout() {
|
||||
const l = String(this.v.layout || 'grid');
|
||||
return ['grid', 'list', 'scroll'].includes(l) ? l : 'grid';
|
||||
},
|
||||
columns() {
|
||||
return Number(this.v.columns) === 1 ? 1 : 2;
|
||||
},
|
||||
showTitle() {
|
||||
return String(this.v.title || '').trim() !== '';
|
||||
},
|
||||
showCount() {
|
||||
return flagOn(this.v.showCount, false);
|
||||
},
|
||||
showMore() {
|
||||
return flagOn(this.v.showMore, false);
|
||||
},
|
||||
moreText() {
|
||||
return this.v.moreText || '';
|
||||
},
|
||||
showArrow() {
|
||||
return flagOn(this.v.showArrow, true);
|
||||
},
|
||||
skeletonCount() {
|
||||
return this.layout === 'list' ? 3 : this.columns * 2;
|
||||
},
|
||||
wallStyle() {
|
||||
return this.$util.objToStyle({
|
||||
background: this.v.bgColor || CardTokens.color.bg,
|
||||
borderRadius: px2rpx(num(this.v.borderRadius, CardTokens.radius.md)) + 'rpx',
|
||||
padding: px2rpx(num(this.v.padding, CardTokens.space.md)) + 'rpx',
|
||||
});
|
||||
},
|
||||
titleStyle() {
|
||||
return this.$util.objToStyle({
|
||||
color: this.v.titleColor || CardTokens.color.text,
|
||||
fontSize: px2rpx(num(this.v.titleSize, 16)) + 'rpx',
|
||||
});
|
||||
},
|
||||
/** 网格/列表卡片姓名: 字号可配(默认 14px = 28rpx) */
|
||||
gridNameStyle() {
|
||||
return this.$util.objToStyle({
|
||||
color: this.v.nameColor || CardTokens.color.text,
|
||||
fontSize: px2rpx(num(this.v.nameSize, 14)) + 'rpx',
|
||||
});
|
||||
},
|
||||
/** 横滑竖卡姓名: 比网格小一档(默认 13px = 26rpx, 与历史渲染一致) */
|
||||
scrollNameStyle() {
|
||||
return this.$util.objToStyle({
|
||||
color: this.v.nameColor || CardTokens.color.text,
|
||||
fontSize: px2rpx(num(this.v.nameSize, 14) - 1) + 'rpx',
|
||||
});
|
||||
},
|
||||
/** 网格/列表卡片信息行: 字号可配(默认 11px = 22rpx) */
|
||||
metaStyle() {
|
||||
return this.$util.objToStyle({
|
||||
color: this.v.infoColor || CardTokens.color.textSub,
|
||||
fontSize: px2rpx(num(this.v.infoSize, 11)) + 'rpx',
|
||||
});
|
||||
},
|
||||
/** 横滑竖卡信息行: 比网格小一档(默认 10px = 20rpx, 与历史渲染一致) */
|
||||
scrollMetaStyle() {
|
||||
return this.$util.objToStyle({
|
||||
color: this.v.infoColor || CardTokens.color.textSub,
|
||||
fontSize: px2rpx(num(this.v.infoSize, 11) - 1) + 'rpx',
|
||||
});
|
||||
},
|
||||
/** infoColor 纯色(箭头/空态用, 不带字号) */
|
||||
infoStyle() {
|
||||
return this.$util.objToStyle({ color: this.v.infoColor || CardTokens.color.textSub });
|
||||
},
|
||||
countStyle() {
|
||||
return this.$util.objToStyle({
|
||||
color: CardTokens.color.primary,
|
||||
background: 'rgba(47,107,255,0.08)',
|
||||
// 人数徽标比信息行小一档(默认 10px = 20rpx, 与历史渲染一致)
|
||||
fontSize: px2rpx(num(this.v.infoSize, 11) - 1) + 'rpx',
|
||||
});
|
||||
},
|
||||
moreStyle() {
|
||||
return this.$util.objToStyle({
|
||||
color: CardTokens.color.textLabel,
|
||||
fontSize: px2rpx(num(this.v.infoSize, 11)) + 'rpx',
|
||||
});
|
||||
},
|
||||
itemStyle() {
|
||||
const gap = num(this.v.gap, 10);
|
||||
const isGrid = this.layout !== 'list' && this.columns !== 1;
|
||||
const style = { marginBottom: px2rpx(gap) + 'rpx' };
|
||||
if (isGrid) {
|
||||
// WXSS 不支持 calc(): 50% 宽 + 左右各 half-gap 作 gutter(视觉在内层 item-card)
|
||||
style.width = '50%';
|
||||
style.paddingLeft = px2rpx(gap / 2) + 'rpx';
|
||||
style.paddingRight = px2rpx(gap / 2) + 'rpx';
|
||||
} else {
|
||||
style.width = '100%';
|
||||
}
|
||||
return this.$util.objToStyle(style);
|
||||
},
|
||||
scrollItemStyle() {
|
||||
return this.$util.objToStyle({
|
||||
width: '260rpx',
|
||||
marginRight: px2rpx(num(this.v.gap, 10)) + 'rpx',
|
||||
});
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'value.cardIds'() {
|
||||
this.fetchData();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.fetchData();
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 列表项最终样式: itemStyle + 逐项入场动画延迟 合并输出单对象。
|
||||
* uni-app 运行时会静默丢弃 :style="[A, B]" 数组绑定(2026-10-24 mini.vue
|
||||
* 操作区同根因修复的先例), 数组形态禁止回归
|
||||
*/
|
||||
itemFinalStyle(index) {
|
||||
return Object.assign({}, this.itemStyle, { animationDelay: (index * 60) + 'ms' });
|
||||
},
|
||||
initialOf,
|
||||
avatarStyle(item) {
|
||||
return this.$util.objToStyle({ background: avatarBg(item) });
|
||||
},
|
||||
badgeStyle(item) {
|
||||
return this.$util.objToStyle({ background: avatarBg(item) });
|
||||
},
|
||||
isEnterprise(item) {
|
||||
return item && item.card_type === 'enterprise';
|
||||
},
|
||||
infoLine(item) {
|
||||
const showJob = flagOn(this.v.showJob, true);
|
||||
const showCompany = flagOn(this.v.showCompany, true);
|
||||
const parts = [];
|
||||
if (showJob && item.job_title) parts.push(item.job_title);
|
||||
if (showCompany && item.company) parts.push(item.company);
|
||||
return parts.join(' · ');
|
||||
},
|
||||
fetchData() {
|
||||
if (this.cardIds === '') {
|
||||
this.list = [];
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
this.$api.sendRequest({
|
||||
url: '/card/api/diy/cards',
|
||||
data: { card_ids: this.cardIds },
|
||||
success: res => {
|
||||
this.loading = false;
|
||||
if (res.code == 0 && res.data) {
|
||||
this.list = res.data.list || [];
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
this.loading = false;
|
||||
},
|
||||
});
|
||||
},
|
||||
goDetail(item) {
|
||||
if (!item || !item.card_id) return;
|
||||
this.$util.redirectTo('/pages_tool/card/detail', { card_id: item.card_id });
|
||||
},
|
||||
goMore() {
|
||||
const mode = String(this.v.moreLinkMode || 'list');
|
||||
let url = '/pages_tool/card/list';
|
||||
if (mode === 'contacts') {
|
||||
url = '/pages_tool/card/contacts';
|
||||
} else if (mode === 'custom') {
|
||||
url = String(this.v.moreLinkUrl || '').trim();
|
||||
if (url === '' || url.indexOf('/') !== 0) {
|
||||
uni.showToast({ title: '自定义路径需以 / 开头', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
} else if (mode === 'link') {
|
||||
// 平台统一「选择链接」: moreLink 是个对象({name,title,wap_url,page_key,page_params}),
|
||||
// 走 $util.diyRedirectTo 由平台统一处理(内部页/外链/小程序/拨号 等)
|
||||
const link = this.v.moreLink && typeof this.v.moreLink === 'object' ? this.v.moreLink : null;
|
||||
if (!link || !link.name) {
|
||||
uni.showToast({ title: '请先选择链接', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
this.$util.diyRedirectTo(link);
|
||||
return;
|
||||
}
|
||||
this.$util.redirectTo(url);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.diy-card-list {
|
||||
width: 100%;
|
||||
|
||||
.card-wall {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.wall-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
.wall-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wall-count {
|
||||
font-size: 20rpx;
|
||||
margin-left: 12rpx;
|
||||
padding: 2rpx 12rpx;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
|
||||
.head-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.wall-more {
|
||||
font-size: 22rpx;
|
||||
padding: 8rpx 0 8rpx 16rpx;
|
||||
}
|
||||
}
|
||||
|
||||
/* 网格/列表 */
|
||||
.wall-grid {
|
||||
&.is-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.wall-item {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
|
||||
/* 视觉卡片层: 布局盒只算宽度与 gutter, 不画外观 */
|
||||
.item-card {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 2rpx solid #f0f0f0;
|
||||
border-radius: 16rpx;
|
||||
padding: 20rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item-avatar {
|
||||
flex-shrink: 0;
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.avatar-initial {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.item-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-badge {
|
||||
flex-shrink: 0;
|
||||
margin-left: 10rpx;
|
||||
font-size: 18rpx;
|
||||
color: #ffffff;
|
||||
padding: 2rpx 10rpx;
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
font-size: 22rpx;
|
||||
margin-top: 4rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.item-arrow {
|
||||
flex-shrink: 0;
|
||||
margin-left: 8rpx;
|
||||
font-size: 32rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 横滑 */
|
||||
.wall-scroll {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
|
||||
.scroll-item {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
border: 2rpx solid #f0f0f0;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx 16rpx;
|
||||
position: relative;
|
||||
|
||||
.item-avatar {
|
||||
width: 104rpx;
|
||||
height: 104rpx;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.avatar-initial {
|
||||
font-size: 42rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-name {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
margin-top: 12rpx;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scroll-meta {
|
||||
font-size: 20rpx;
|
||||
margin-top: 4rpx;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-badge {
|
||||
position: absolute;
|
||||
top: 12rpx;
|
||||
right: 12rpx;
|
||||
font-size: 18rpx;
|
||||
color: #ffffff;
|
||||
padding: 2rpx 10rpx;
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 空态 */
|
||||
.wall-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 40rpx 0;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 56rpx;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
font-size: 24rpx;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
}
|
||||
|
||||
/* 骨架屏 */
|
||||
.wall-skeleton {
|
||||
.sk-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
.sk-avatar {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sk-col {
|
||||
flex: 1;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sk {
|
||||
background: linear-gradient(90deg, #f2f3f5 25%, #e8eaee 37%, #f2f3f5 63%);
|
||||
background-size: 400rpx 100%;
|
||||
animation: sk-shimmer 1.2s linear infinite;
|
||||
border-radius: 8rpx;
|
||||
|
||||
&.sk-line {
|
||||
height: 24rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fade-up {
|
||||
animation: card-fade-up 240ms ease both;
|
||||
}
|
||||
|
||||
.btn-press {
|
||||
opacity: 0.72;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
@keyframes sk-shimmer {
|
||||
0% {
|
||||
background-position: -200rpx 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 200rpx 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes card-fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12rpx);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1839
components-diy/diy-card-mini.vue
Normal file
1839
components-diy/diy-card-mini.vue
Normal file
File diff suppressed because it is too large
Load Diff
392
components-diy/diy-card-self.vue
Normal file
392
components-diy/diy-card-self.vue
Normal file
@@ -0,0 +1,392 @@
|
||||
<template>
|
||||
<view data-component-name="diy-card-self" class="diy-card-self">
|
||||
<!-- 未登录 + 登录引导兜底 -->
|
||||
<view v-if="!hasToken && fallbackMode === 'none'" class="self-login" :style="boxStyle" @tap="goLogin" hover-class="btn-press">
|
||||
<text class="login-tip" :style="nameStyle">{{ value.loginTip || '登录后这里展示我的名片' }}</text>
|
||||
<view class="login-btn" :style="loginBtnStyle">
|
||||
<text class="login-btn-text">{{ value.loginBtnText || '去登录' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 名片卡(已登录=本人名片 / 未登录=兜底名片); loading 时基件出骨架屏;
|
||||
「我的名片」角标已按 2026-10-23 参考图定稿移除 -->
|
||||
<diy-card-mini
|
||||
v-else-if="loading || card"
|
||||
:card="card"
|
||||
:config="miniConfig"
|
||||
:loading="loading"
|
||||
@act="doAct"
|
||||
@row-act="doAct"
|
||||
@detail="goDetail"
|
||||
>
|
||||
</diy-card-mini>
|
||||
|
||||
<!-- 未登录提示条已按 2026-10-23 用户要求移除(不再显示「登录后即可展示我的名片」) -->
|
||||
|
||||
<!-- 没有任何名片可显示(非登录引导模式; 登录引导已由第一个分支承接) -->
|
||||
<view v-else-if="!loading && !card" class="self-empty" :style="boxStyle">
|
||||
<text class="empty-icon">🪪</text>
|
||||
<text class="empty-tip" :style="subTextStyle">{{ value.noCardTip || '登录后即可展示我的名片' }}</text>
|
||||
<view class="empty-btn" :style="loginBtnStyle" hover-class="btn-press" @tap="goCardList">
|
||||
<text class="empty-btn-text">{{ value.noCardBtnText || '去看看名片' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 数字名片 · 我的名片(装修组件)
|
||||
// 运行时判定: 已登录 → 本人名片(optionalAuth); 未登录 → 配置的兜底名片
|
||||
// fallbackMode: enterprise=店铺企业名片 / card=指定 fallbackCardId / none=登录引导
|
||||
// 视觉基件 diy-card-mini; 本文件管登录态/兜底/空态 CTA
|
||||
export default {
|
||||
name: 'diy-card-self',
|
||||
props: {
|
||||
value: { type: Object }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
card: null,
|
||||
isSelf: false,
|
||||
loading: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasToken() {
|
||||
try {
|
||||
return !!(this.$store && this.$store.state && this.$store.state.token);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
fallbackMode() {
|
||||
const m = String((this.value || {}).fallbackMode || 'enterprise');
|
||||
return ['enterprise', 'card', 'none'].includes(m) ? m : 'enterprise';
|
||||
},
|
||||
miniConfig() {
|
||||
const v = this.value || {};
|
||||
return {
|
||||
bgColor: v.bgColor,
|
||||
nameColor: v.nameColor,
|
||||
infoColor: v.infoColor,
|
||||
labelColor: v.labelColor,
|
||||
borderRadius: v.borderRadius,
|
||||
padding: v.padding,
|
||||
// 字号定制(px 口径, 缺省时基件按历史默认值渲染)
|
||||
nameSize: v.nameSize,
|
||||
cardTitleSize: v.cardTitleSize,
|
||||
subSize: v.subSize,
|
||||
labelSize: v.labelSize,
|
||||
valueSize: v.valueSize,
|
||||
actSize: v.actSize,
|
||||
btnSize: v.btnSize,
|
||||
showQuick: v.showQuick,
|
||||
showDetailBtn: v.showDetailBtn,
|
||||
detailBtnText: v.detailBtnText,
|
||||
// 2.0 新增视觉开关
|
||||
useCover: v.useCover,
|
||||
showGradient: v.showGradient,
|
||||
showLogo: v.showLogo,
|
||||
showShadow: v.showShadow,
|
||||
showRows: v.showRows,
|
||||
maxRows: v.maxRows,
|
||||
showQr: v.showQr,
|
||||
qrTip: v.qrTip,
|
||||
// 3.0 A+B 视觉升级
|
||||
showRowIcons: v.showRowIcons,
|
||||
rowTap: v.rowTap,
|
||||
qrPosition: v.qrPosition,
|
||||
// 3.1 头像开关 + 二维码样式/微调
|
||||
showAvatar: v.showAvatar,
|
||||
qrStyle: v.qrStyle,
|
||||
qrSize: v.qrSize,
|
||||
qrOffsetX: v.qrOffsetX,
|
||||
qrOffsetY: v.qrOffsetY,
|
||||
qrTipShow: v.qrTipShow,
|
||||
// 2.17 双形态: 渐变头部 / 宫格快捷操作 / 资料区标题 / 复制按钮
|
||||
headStyle: v.headStyle,
|
||||
headNameColor: v.headNameColor,
|
||||
headSubColor: v.headSubColor,
|
||||
actsLayout: v.actsLayout,
|
||||
actGridColorMode: v.actGridColorMode,
|
||||
showRowsTitle: v.showRowsTitle,
|
||||
rowsTitle: v.rowsTitle,
|
||||
showCopyBtn: v.showCopyBtn,
|
||||
// 2.18: 操作区位置/独立块样式/每按钮图标/自定义二维码图
|
||||
actsPosition: v.actsPosition,
|
||||
actsBlockBg: v.actsBlockBg,
|
||||
actsBlockRadius: v.actsBlockRadius,
|
||||
actsBlockPad: v.actsBlockPad,
|
||||
actIcons: v.actIcons,
|
||||
qrImage: v.qrImage,
|
||||
// 2.19: 默认动作显隐/文案 + 自定义动作(基件 acts computed 合成)
|
||||
actVis: v.actVis,
|
||||
actDeleted: v.actDeleted,
|
||||
actLabels: v.actLabels,
|
||||
actOrder: v.actOrder,
|
||||
customActs: v.customActs,
|
||||
// 2.19.10 detail 版式对齐(三列行/复制范围外观/qrTip 富文本/URL 行/hero 徽章/分卡)
|
||||
showRowLabels: v.showRowLabels,
|
||||
copyBtnScope: v.copyBtnScope,
|
||||
copyBtnLook: v.copyBtnLook,
|
||||
qrTipEm: v.qrTipEm,
|
||||
qrTipSub: v.qrTipSub,
|
||||
qrUrlShow: v.qrUrlShow,
|
||||
badgeShow: v.badgeShow,
|
||||
badgeText: v.badgeText,
|
||||
rowsBlockBg: v.rowsBlockBg,
|
||||
rowsBlockRadius: v.rowsBlockRadius,
|
||||
rowsBlockPad: v.rowsBlockPad,
|
||||
qrBlockBg: v.qrBlockBg,
|
||||
qrBlockRadius: v.qrBlockRadius,
|
||||
qrBlockPad: v.qrBlockPad,
|
||||
};
|
||||
},
|
||||
boxStyle() {
|
||||
const v = this.value || {};
|
||||
return this.$util.objToStyle({
|
||||
background: v.bgColor || '#FFFFFF',
|
||||
borderRadius: this.px2rpx(v.borderRadius || 16) + 'rpx',
|
||||
padding: this.px2rpx(v.padding || 16) + 'rpx',
|
||||
boxShadow: '0 4rpx 16rpx rgba(15,23,42,0.06)',
|
||||
});
|
||||
},
|
||||
nameStyle() {
|
||||
return this.$util.objToStyle({ color: (this.value || {}).nameColor || '#1F2329' });
|
||||
},
|
||||
subTextStyle() {
|
||||
return this.$util.objToStyle({ color: (this.value || {}).infoColor || '#8A8F99' });
|
||||
},
|
||||
loginBtnStyle() {
|
||||
return this.$util.objToStyle({
|
||||
background: (this.card || {}).theme_color || '#2F6BFF',
|
||||
height: '80rpx',
|
||||
borderRadius: '999rpx',
|
||||
padding: '0 48rpx',
|
||||
});
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
hasToken() {
|
||||
this.fetchData();
|
||||
},
|
||||
'value.fallbackMode'() {
|
||||
this.fetchData();
|
||||
},
|
||||
'value.fallbackCardId'() {
|
||||
this.fetchData();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.fetchData();
|
||||
},
|
||||
methods: {
|
||||
px2rpx(v) {
|
||||
const n = Number(v);
|
||||
return isNaN(n) ? 0 : Math.round(n * 2);
|
||||
},
|
||||
fetchData() {
|
||||
if (!this.hasToken && this.fallbackMode === 'none') {
|
||||
this.card = null;
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
this.$api.sendRequest({
|
||||
url: '/card/api/diy/mycard',
|
||||
data: {
|
||||
fallback_mode: this.fallbackMode,
|
||||
fallback_card_id: String((this.value || {}).fallbackCardId || '').trim(),
|
||||
},
|
||||
success: res => {
|
||||
this.loading = false;
|
||||
if (res.code == 0 && res.data) {
|
||||
this.card = res.data.card || null;
|
||||
this.isSelf = res.data.is_self == 1;
|
||||
} else {
|
||||
this.card = null;
|
||||
this.isSelf = false;
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
this.loading = false;
|
||||
this.card = null;
|
||||
this.isSelf = false;
|
||||
},
|
||||
});
|
||||
},
|
||||
goLogin() {
|
||||
this.$util.redirectTo('/pages_tool/login/login');
|
||||
},
|
||||
goCardList() {
|
||||
this.$util.redirectTo('/pages_tool/card/list');
|
||||
},
|
||||
doAct(act) {
|
||||
if (!act) return;
|
||||
// save(存通讯录): value 为空, 字段由本组件从 card 组装(白名单口径)
|
||||
if (act.type === 'save') {
|
||||
this.goSaveContact();
|
||||
return;
|
||||
}
|
||||
// map(导航): value 留空回落名片地址(2.19.3)
|
||||
if (act.type === 'map' && !act.value && this.card && this.card.address) {
|
||||
act = Object.assign({}, act, { value: this.card.address });
|
||||
}
|
||||
if (!act.value) return;
|
||||
if (act.type === 'tel') {
|
||||
uni.makePhoneCall({ phoneNumber: act.value, fail: () => {} });
|
||||
} else if (act.type === 'wx') {
|
||||
uni.setClipboardData({
|
||||
data: act.value,
|
||||
success: () => uni.showToast({ title: '微信号已复制', icon: 'none' }),
|
||||
});
|
||||
} else if (act.type === 'mail') {
|
||||
// H5/公众号: 直接调起系统邮件客户端(满足「点击邮箱发邮件」)
|
||||
// #ifdef H5
|
||||
window.location.href = 'mailto:' + act.value;
|
||||
// #endif
|
||||
// 小程序/App(非 H5): 微信沙箱不支持 mailto 拉起, 复制地址并引导去邮件 App 粘贴
|
||||
// #ifndef H5
|
||||
uni.setClipboardData({
|
||||
data: act.value,
|
||||
success: () => uni.showToast({ title: '邮箱已复制,请前往邮件App粘贴发送', icon: 'none' }),
|
||||
});
|
||||
// #endif
|
||||
} else if (act.type === 'web') {
|
||||
// 网站行点击 → 跳转到该网址
|
||||
// H5/公众号: 直接在本页打开(微信内置浏览器同)
|
||||
// #ifdef H5
|
||||
window.location.href = act.value;
|
||||
// #endif
|
||||
// 小程序/App(非 H5): 经 web-view 承载页在微信内打开(需微信后台配置业务域名)
|
||||
// #ifndef H5
|
||||
const title = (this.card && this.card.name) || '网站';
|
||||
uni.navigateTo({
|
||||
url: '/pages_tool/webview/webview?src=' + encodeURIComponent(act.value) +
|
||||
'&title=' + encodeURIComponent(title) + '&enable_set_title=1',
|
||||
fail: () => uni.showToast({ title: '打开网站失败', icon: 'none' }),
|
||||
});
|
||||
// #endif
|
||||
} else if (act.type === 'sms') {
|
||||
// #ifdef H5
|
||||
window.location.href = 'sms:' + act.value;
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
uni.setClipboardData({
|
||||
data: act.value,
|
||||
success: () => uni.showToast({ title: '号码已复制,请到短信粘贴', icon: 'none' }),
|
||||
});
|
||||
// #endif
|
||||
} else if (act.type === 'map') {
|
||||
uni.setClipboardData({
|
||||
data: act.value,
|
||||
success: () => uni.showToast({ title: '地址已复制,可粘贴到地图导航', icon: 'none' }),
|
||||
});
|
||||
} else if (act.type === 'link') {
|
||||
var isHttp = act.value.indexOf('http://') === 0 || act.value.indexOf('https://') === 0;
|
||||
if (isHttp) {
|
||||
// #ifdef H5
|
||||
window.location.href = act.value;
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
uni.setClipboardData({
|
||||
data: act.value,
|
||||
success: () => uni.showToast({ title: '链接已复制,请在浏览器打开', icon: 'none' }),
|
||||
});
|
||||
// #endif
|
||||
} else {
|
||||
this.$util.redirectTo(act.value, {});
|
||||
}
|
||||
} else {
|
||||
uni.setClipboardData({
|
||||
data: act.value,
|
||||
success: () => uni.showToast({ title: '已复制', icon: 'none' }),
|
||||
});
|
||||
}
|
||||
},
|
||||
goDetail(card) {
|
||||
if (!card || !card.card_id) return;
|
||||
this.$util.redirectTo('/pages_tool/card/detail', { card_id: card.card_id });
|
||||
},
|
||||
/**
|
||||
* 存通讯录(2.17.1): 与 diy-card-show 同构 —— 字段全部从 card 组装
|
||||
* (rows 已过 shouldShow 白名单), 键名与 save-contact.vue onLoad 的 keys 一一对应。
|
||||
*/
|
||||
goSaveContact() {
|
||||
const c = this.card || {};
|
||||
const map = {};
|
||||
(Array.isArray(c.rows) ? c.rows : []).forEach(r => {
|
||||
if (r && r.field) map[r.field] = String(r.value || '');
|
||||
});
|
||||
// #ifdef H5
|
||||
// H5 无通讯录 API: 复制资料文本兜底(小程序端走 save-contact 原生页)
|
||||
const text = [c.name, map.mobile, map.email, c.company, c.job_title].filter(Boolean).join(' ');
|
||||
uni.setClipboardData({
|
||||
data: text,
|
||||
success: () => uni.showToast({ title: '名片资料已复制', icon: 'none' }),
|
||||
});
|
||||
return;
|
||||
// #endif
|
||||
this.$util.redirectTo('/pages_tool/card/save-contact', {
|
||||
name: c.name || '',
|
||||
mobile: map.mobile || '',
|
||||
company: c.company || '',
|
||||
job_title: c.job_title || '',
|
||||
email: map.email || '',
|
||||
website: map.website || '',
|
||||
address: map.address || '',
|
||||
remark: map.remark || '',
|
||||
wechat: map.wechat || '',
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.diy-card-self {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
.self-login,
|
||||
.self-empty {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48rpx 24rpx;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64rpx;
|
||||
}
|
||||
|
||||
.login-tip,
|
||||
.empty-tip {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-btn,
|
||||
.empty-btn {
|
||||
margin-top: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.login-btn-text,
|
||||
.empty-btn-text {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-press {
|
||||
opacity: 0.72;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
</style>
|
||||
251
components-diy/diy-card-show.vue
Normal file
251
components-diy/diy-card-show.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<view data-component-name="diy-card-show" class="diy-card-show">
|
||||
<diy-card-mini
|
||||
:card="card"
|
||||
:config="miniConfig"
|
||||
:loading="loading"
|
||||
empty-tip="商家还未选择名片"
|
||||
@act="doAct"
|
||||
@row-act="doAct"
|
||||
@detail="goDetail"
|
||||
></diy-card-mini>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 数字名片 · 精选名片(装修组件)
|
||||
// 视觉基件 diy-card-mini(封面/渐变/Logo/主题色/折叠/芯片/二维码), 本文件只管数据
|
||||
export default {
|
||||
name: 'diy-card-show',
|
||||
props: {
|
||||
value: { type: Object }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
card: null,
|
||||
loading: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
miniConfig() {
|
||||
const v = this.value || {};
|
||||
return {
|
||||
bgColor: v.bgColor,
|
||||
nameColor: v.nameColor,
|
||||
infoColor: v.infoColor,
|
||||
labelColor: v.labelColor,
|
||||
borderRadius: v.borderRadius,
|
||||
padding: v.padding,
|
||||
// 字号定制(px 口径, 缺省时基件按历史默认值渲染) —— 2.17 补齐:
|
||||
// 此前 show 漏透传 7 个字号字段(面板配置不生效), 与 self 对齐
|
||||
nameSize: v.nameSize,
|
||||
cardTitleSize: v.cardTitleSize,
|
||||
subSize: v.subSize,
|
||||
labelSize: v.labelSize,
|
||||
valueSize: v.valueSize,
|
||||
actSize: v.actSize,
|
||||
btnSize: v.btnSize,
|
||||
showQuick: v.showQuick,
|
||||
showDetailBtn: v.showDetailBtn,
|
||||
detailBtnText: v.detailBtnText,
|
||||
// 2.0 新增视觉开关
|
||||
useCover: v.useCover,
|
||||
showGradient: v.showGradient,
|
||||
showLogo: v.showLogo,
|
||||
showShadow: v.showShadow,
|
||||
showRows: v.showRows,
|
||||
maxRows: v.maxRows,
|
||||
showQr: v.showQr,
|
||||
qrTip: v.qrTip,
|
||||
// 3.0 A+B 视觉升级
|
||||
showRowIcons: v.showRowIcons,
|
||||
rowTap: v.rowTap,
|
||||
qrPosition: v.qrPosition,
|
||||
// 3.1 头像开关 + 二维码样式/微调
|
||||
showAvatar: v.showAvatar,
|
||||
qrStyle: v.qrStyle,
|
||||
qrSize: v.qrSize,
|
||||
qrOffsetX: v.qrOffsetX,
|
||||
qrOffsetY: v.qrOffsetY,
|
||||
qrTipShow: v.qrTipShow,
|
||||
// 2.17 双形态: 渐变头部 / 宫格快捷操作 / 资料区标题 / 复制按钮
|
||||
headStyle: v.headStyle,
|
||||
headNameColor: v.headNameColor,
|
||||
headSubColor: v.headSubColor,
|
||||
actsLayout: v.actsLayout,
|
||||
actGridColorMode: v.actGridColorMode,
|
||||
showRowsTitle: v.showRowsTitle,
|
||||
rowsTitle: v.rowsTitle,
|
||||
showCopyBtn: v.showCopyBtn,
|
||||
// 2.18: 操作区位置/独立块样式/每按钮图标/自定义二维码图
|
||||
actsPosition: v.actsPosition,
|
||||
actsBlockBg: v.actsBlockBg,
|
||||
actsBlockRadius: v.actsBlockRadius,
|
||||
actsBlockPad: v.actsBlockPad,
|
||||
actIcons: v.actIcons,
|
||||
qrImage: v.qrImage,
|
||||
// 2.19: 默认动作显隐/文案 + 自定义动作(基件 acts computed 合成)
|
||||
actVis: v.actVis,
|
||||
actDeleted: v.actDeleted,
|
||||
actLabels: v.actLabels,
|
||||
actOrder: v.actOrder,
|
||||
customActs: v.customActs,
|
||||
// 2.19.10 detail 版式对齐(三列行/复制范围外观/qrTip 富文本/URL 行/hero 徽章/分卡)
|
||||
showRowLabels: v.showRowLabels,
|
||||
copyBtnScope: v.copyBtnScope,
|
||||
copyBtnLook: v.copyBtnLook,
|
||||
qrTipEm: v.qrTipEm,
|
||||
qrTipSub: v.qrTipSub,
|
||||
qrUrlShow: v.qrUrlShow,
|
||||
badgeShow: v.badgeShow,
|
||||
badgeText: v.badgeText,
|
||||
rowsBlockBg: v.rowsBlockBg,
|
||||
rowsBlockRadius: v.rowsBlockRadius,
|
||||
rowsBlockPad: v.rowsBlockPad,
|
||||
qrBlockBg: v.qrBlockBg,
|
||||
qrBlockRadius: v.qrBlockRadius,
|
||||
qrBlockPad: v.qrBlockPad,
|
||||
};
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'value.cardId'() {
|
||||
this.fetchData();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.fetchData();
|
||||
},
|
||||
methods: {
|
||||
fetchData() {
|
||||
const cardId = String((this.value || {}).cardId || '').trim();
|
||||
if (cardId === '') {
|
||||
this.card = null;
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
this.$api.sendRequest({
|
||||
url: '/card/api/diy/card',
|
||||
data: { card_id: cardId },
|
||||
success: res => {
|
||||
this.loading = false;
|
||||
this.card = (res.code == 0 && res.data && res.data.card) ? res.data.card : null;
|
||||
},
|
||||
fail: () => {
|
||||
this.loading = false;
|
||||
this.card = null;
|
||||
},
|
||||
});
|
||||
},
|
||||
doAct(act) {
|
||||
if (!act) return;
|
||||
// save(存通讯录): value 为空, 字段由本组件从 card 组装(白名单口径)
|
||||
if (act.type === 'save') {
|
||||
this.goSaveContact();
|
||||
return;
|
||||
}
|
||||
// map(导航): value 留空回落名片地址(2.19.3)
|
||||
if (act.type === 'map' && !act.value && this.card && this.card.address) {
|
||||
act = Object.assign({}, act, { value: this.card.address });
|
||||
}
|
||||
if (!act.value) return;
|
||||
if (act.type === 'tel') {
|
||||
uni.makePhoneCall({ phoneNumber: act.value, fail: () => {} });
|
||||
} else if (act.type === 'wx') {
|
||||
uni.setClipboardData({
|
||||
data: act.value,
|
||||
success: () => uni.showToast({ title: '微信号已复制', icon: 'none' }),
|
||||
});
|
||||
} else if (act.type === 'mail') {
|
||||
// #ifdef H5
|
||||
window.location.href = 'mailto:' + act.value;
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
uni.setClipboardData({
|
||||
data: act.value,
|
||||
success: () => uni.showToast({ title: '邮箱已复制', icon: 'none' }),
|
||||
});
|
||||
// #endif
|
||||
} else if (act.type === 'sms') {
|
||||
// #ifdef H5
|
||||
window.location.href = 'sms:' + act.value;
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
uni.setClipboardData({
|
||||
data: act.value,
|
||||
success: () => uni.showToast({ title: '号码已复制,请到短信粘贴', icon: 'none' }),
|
||||
});
|
||||
// #endif
|
||||
} else if (act.type === 'map') {
|
||||
uni.setClipboardData({
|
||||
data: act.value,
|
||||
success: () => uni.showToast({ title: '地址已复制,可粘贴到地图导航', icon: 'none' }),
|
||||
});
|
||||
} else if (act.type === 'link') {
|
||||
var isHttp = act.value.indexOf('http://') === 0 || act.value.indexOf('https://') === 0;
|
||||
if (isHttp) {
|
||||
// #ifdef H5
|
||||
window.location.href = act.value;
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
uni.setClipboardData({
|
||||
data: act.value,
|
||||
success: () => uni.showToast({ title: '链接已复制,请在浏览器打开', icon: 'none' }),
|
||||
});
|
||||
// #endif
|
||||
} else {
|
||||
this.$util.redirectTo(act.value, {});
|
||||
}
|
||||
} else {
|
||||
uni.setClipboardData({
|
||||
data: act.value,
|
||||
success: () => uni.showToast({ title: '已复制', icon: 'none' }),
|
||||
});
|
||||
}
|
||||
},
|
||||
goDetail(card) {
|
||||
if (!card || !card.card_id) return;
|
||||
this.$util.redirectTo('/pages_tool/card/detail', { card_id: card.card_id });
|
||||
},
|
||||
/**
|
||||
* 存通讯录(2.17.1): 字段全部从 card 组装 —— rows 已过 shouldShow
|
||||
* 白名单(商家隐藏的字段不下发也不落通讯录, 隐私口径与后端一致),
|
||||
* name/company/job_title 为公开头部字段直接取。
|
||||
* 键名与 save-contact.vue onLoad 的 keys 一一对应。
|
||||
*/
|
||||
goSaveContact() {
|
||||
const c = this.card || {};
|
||||
const map = {};
|
||||
(Array.isArray(c.rows) ? c.rows : []).forEach(r => {
|
||||
if (r && r.field) map[r.field] = String(r.value || '');
|
||||
});
|
||||
// #ifdef H5
|
||||
// H5 无通讯录 API: 复制资料文本兜底(小程序端走 save-contact 原生页)
|
||||
const text = [c.name, map.mobile, map.email, c.company, c.job_title].filter(Boolean).join(' ');
|
||||
uni.setClipboardData({
|
||||
data: text,
|
||||
success: () => uni.showToast({ title: '名片资料已复制', icon: 'none' }),
|
||||
});
|
||||
return;
|
||||
// #endif
|
||||
this.$util.redirectTo('/pages_tool/card/save-contact', {
|
||||
name: c.name || '',
|
||||
mobile: map.mobile || '',
|
||||
company: c.company || '',
|
||||
job_title: c.job_title || '',
|
||||
email: map.email || '',
|
||||
website: map.website || '',
|
||||
address: map.address || '',
|
||||
remark: map.remark || '',
|
||||
wechat: map.wechat || '',
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.diy-card-show {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -170,6 +170,22 @@
|
||||
<!-- 自定义推荐码组件 -->
|
||||
<diy-recommend-code :value="item"></diy-recommend-code>
|
||||
</template>
|
||||
<template v-else-if="item.componentName == 'DiyCardEntry'">
|
||||
<!-- 数字名片 · 名片入口横幅 -->
|
||||
<diy-card-entry :value="item"></diy-card-entry>
|
||||
</template>
|
||||
<template v-else-if="item.componentName == 'DiyCardList'">
|
||||
<!-- 数字名片 · 团队名片墙 -->
|
||||
<diy-card-list :value="item"></diy-card-list>
|
||||
</template>
|
||||
<template v-else-if="item.componentName == 'DiyCardShow'">
|
||||
<!-- 数字名片 · 精选名片 -->
|
||||
<diy-card-show :value="item"></diy-card-show>
|
||||
</template>
|
||||
<template v-else-if="item.componentName == 'DiyCardSelf'">
|
||||
<!-- 数字名片 · 我的名片(登录态自适应) -->
|
||||
<diy-card-self :value="item"></diy-card-self>
|
||||
</template>
|
||||
<template v-if="item.componentName == 'GoodsBrand'">
|
||||
<!-- 商品品牌 -->
|
||||
<diy-goods-brand :value="item"></diy-goods-brand>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# AI Agent 接入使用说明 (Uniapp 前端)
|
||||
|
||||
> 适用版本: backend PR-WB-MP-INLINE-MEDIA (2026-09-16) 及之后; PR-OSS-REGISTRY (2026-09-15) 之后 12 个云存储 provider
|
||||
> 适用前端: `D:/projects/shop-projects/frontend` (uniapp 多端: 微信小程序 MP / H5 / App / Harmony)
|
||||
> 适用前端: 本仓库 (uniapp 多端: 微信小程序 MP / H5 / App / Harmony)
|
||||
> 配套阅读:
|
||||
> - `common/js/ai-service.js`、`pages_tool/ai-chat/`
|
||||
> - 后端 `docs/mp-media-upload-integration.md` (媒体上传后端单点真源)
|
||||
> - 后端 `docs/ai-agent-protocol-spec.md` (本文档对应的完整后端协议规范, 仓内后端目录)
|
||||
> - `D:/projects/refs/codebuddy-agent-sdk/docs/wb-cloud-sdk-coverage.md` (WB Cloud SDK 协议约束矩阵 — J7/J9/J10/J1/Q6)
|
||||
> - WB Cloud SDK 协议约束矩阵 (`docs/wb-cloud-sdk-coverage.md`, WB Cloud SDK 参考仓内) (J7/J9/J10/J1/Q6)
|
||||
|
||||
本文档面向前端工程师, 解决把 WorkBuddy Agent / AI 智能客服集成进 uniapp 时
|
||||
最常踩的 4 个坑:
|
||||
@@ -2177,7 +2177,7 @@ uniapp 内联 `:style` 绑定的 rpx 在 H5 端被浏览器丢弃, CSS 文件里
|
||||
| 媒体上传后端单点真源 | `backend/docs/mp-media-upload-integration.md` | 后端 media_policy / HINTS / STS 错误翻译 |
|
||||
| WB Agent 平台协议 | `backend/src/addon/aiagent/docs/workbuddy_api.md` | ACP / AgentOS REST+ACP 双轨 |
|
||||
| **后端协议完整规范** | `backend/docs/ai-agent-protocol-spec.md` | SSE/WS 帧结构 + REST 端点 + A/B 路径差异 |
|
||||
| **WB Cloud SDK 协议约束矩阵** | `D:/projects/refs/codebuddy-agent-sdk/docs/wb-cloud-sdk-coverage.md` | J7 (inline base64 ✅) / J9 (URL fetch, 服务端策略) / J10 (无 `/uploads/*` API) / J1 (KB 挂载) / Q6 (source_app 白名单) |
|
||||
| **WB Cloud SDK 协议约束矩阵** | `docs/wb-cloud-sdk-coverage.md` (WB Cloud SDK 参考仓) | J7 (inline base64 ✅) / J9 (URL fetch, 服务端策略) / J10 (无 `/uploads/*` API) / J1 (KB 挂载) / Q6 (source_app 白名单) |
|
||||
| **后端 PR-WB-MP-INLINE-MEDIA ANALYSIS** | `backend/tests/wb_media_backend/ANALYSIS.md` §8 | attachments.data 字段 PR 详情 + 4 处文件改动面 + 协议层 A/B 对称修复 + 26 项 sanity 测试结果 |
|
||||
| ai-chat 前端入口 | `frontend/pages_tool/ai-chat/index.vue` | 路由 + agent_id 接收 |
|
||||
| ai-chat 消息渲染 | `frontend/pages_tool/ai-chat/ai-chat-message.vue` | 流式消息 / 历史 / 预览 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 国际化(i18n)全量审计报告
|
||||
|
||||
> 审计范围:`z:\D\projects\shop-projects\frontend` 下所有 `.vue` / `.js` 文件(排除 `node_modules`、`uni_modules`、`unpackage`、`docs`、`scripts` 等)
|
||||
> 审计范围:项目根目录下所有 `.vue` / `.js` 文件(排除 `node_modules`、`uni_modules`、`unpackage`、`docs`、`scripts` 等)
|
||||
> 审计时间:2026-06-29
|
||||
> 当前静态校验:`node scripts/i18n-check.js` 通过(zh-cn / en-us key 完全一致)
|
||||
> 修复状态:P0 / P1 / P2(兜底 + 验证)已修复并验证 ✅
|
||||
|
||||
@@ -106,8 +106,9 @@ background: linear-gradient(<角度>, var(--token-start, #原始色) 0%, var(--t
|
||||
## 四、强制兼容性测试项(上 HBuilder 前必做)
|
||||
|
||||
```bash
|
||||
# 以下命令均在项目根目录(含 package.json)下执行
|
||||
|
||||
# 1. 静态语法检查
|
||||
cd D:/projects/shop-projects/frontend
|
||||
node --check common/js/theme-override.js
|
||||
node --check store/index.js
|
||||
# App.vue 走 vue 编译器,不在 node 范畴
|
||||
|
||||
2
main.js
2
main.js
@@ -6,6 +6,7 @@ import App from './App'
|
||||
import store from './store'
|
||||
import Util from './common/js/util.js'
|
||||
import Http from './common/js/http.js'
|
||||
import CardApi from './common/js/card-api.js'
|
||||
import Lang from './common/js/lang.js'
|
||||
import Config from './common/js/config.js'
|
||||
import EventBus from './common/js/event-bus.js'
|
||||
@@ -25,6 +26,7 @@ Vue.config.productionTip = false
|
||||
|
||||
Vue.prototype.$util = Util;
|
||||
Vue.prototype.$api = Http;
|
||||
Vue.prototype.$cardApi = CardApi; // 名片云(card addon) API 收口层
|
||||
|
||||
Vue.prototype.$langConfig = Lang; //语言包对象
|
||||
Vue.prototype.$lang = Lang.lang; //解析语言包
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"scripts": {
|
||||
"mp-weixin": "node scripts/mp-weixin.patch.js",
|
||||
"pack:harmony": "node scripts/pack-harmony-project.js",
|
||||
"i18n:check": "node scripts/i18n-check.js",
|
||||
"test": "node scripts/test-gate.js",
|
||||
"test:util": "node --test tests/util.test.js",
|
||||
|
||||
112
pages.json
112
pages.json
@@ -1467,6 +1467,118 @@
|
||||
"navigationBarTitleText": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/list",
|
||||
"style": {
|
||||
"navigationBarTitleText": "名片",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "名片",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/exchange",
|
||||
"style": {
|
||||
"navigationBarTitleText": "名片"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/style",
|
||||
"style": {
|
||||
"navigationBarTitleText": "名片"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/edit",
|
||||
"style": {
|
||||
"navigationBarTitleText": "名片"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/apply",
|
||||
"style": {
|
||||
"navigationBarTitleText": "名片"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/leads",
|
||||
"style": {
|
||||
"navigationBarTitleText": "名片",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/contacts",
|
||||
"style": {
|
||||
"navigationBarTitleText": "名片",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/views",
|
||||
"style": {
|
||||
"navigationBarTitleText": "名片",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/contact-edit",
|
||||
"style": {
|
||||
"navigationBarTitleText": "名片"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/share",
|
||||
"style": {
|
||||
// #ifdef MP-HARMONY
|
||||
// 鸿蒙元服务的 web-view 是原生全屏组件, 只能自绘头部(见 share.vue)
|
||||
"navigationStyle": "custom",
|
||||
// #endif
|
||||
// 微信小程序必须用原生导航栏: web-view 会自动排在导航栏下方,
|
||||
// 若用 custom + cover-view 自绘头部, 头部会浮在 web-view 上遮住名片顶部
|
||||
"navigationBarTitleText": "名片分享"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/save-contact",
|
||||
"style": {
|
||||
"navigationBarTitleText": "保存到通讯录"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/enterprise",
|
||||
"style": {
|
||||
"navigationBarTitleText": "企业名片",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/admin/manage",
|
||||
"style": {
|
||||
"navigationBarTitleText": "名片",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/admin/contacts",
|
||||
"style": {
|
||||
"navigationBarTitleText": "名片",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/admin/leads",
|
||||
"style": {
|
||||
"navigationBarTitleText": "名片",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "member/card",
|
||||
"style": {
|
||||
|
||||
@@ -105,6 +105,24 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 应用后台「高级设置 → 媒体显示顺序」(media_order)
|
||||
* 1 = 主图在前(默认) 2 = 视频在前
|
||||
* 商品没有视频时一律主图在前
|
||||
*/
|
||||
applyMediaOrder() {
|
||||
const detail = this.goodsSkuDetail || {};
|
||||
if (!detail.video_url) {
|
||||
this.switchMedia = 'img';
|
||||
return;
|
||||
}
|
||||
this.switchMedia = Number(detail.media_order) === 2 ? 'video' : 'img';
|
||||
},
|
||||
// 手动切换图片/视频
|
||||
switchMediaTab(key) {
|
||||
this.switchMedia = key;
|
||||
if (key === 'img' && this.videoContext) this.videoContext.pause();
|
||||
},
|
||||
init(params) {
|
||||
this.skuId = params.sku_id;
|
||||
this.goodsId = params.goods_id;
|
||||
@@ -136,6 +154,9 @@ export default {
|
||||
|
||||
this.videoContext = uni.createVideoContext('goodsVideo');
|
||||
|
||||
// 媒体显示顺序: 以后台商品「高级设置 → 媒体显示顺序」为准
|
||||
this.applyMediaOrder();
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
this.goodsSyncToGoodsCircle();
|
||||
// #endif
|
||||
|
||||
@@ -65,10 +65,11 @@
|
||||
|
||||
<!-- 切换视频、图片 -->
|
||||
<view class="media-mode" v-if="goodsSkuDetail.video_url != ''">
|
||||
<text :class="{ 'color-base-bg': switchMedia == 'video' }" @click="switchMedia = 'video'">{{
|
||||
$lang('goods.detail.video') }}</text>
|
||||
<text :class="{ 'color-base-bg': switchMedia == 'img' }"
|
||||
@click="(switchMedia = 'img'), videoContext.pause()">{{ $lang('goods.detail.image') }}</text>
|
||||
<text v-for="tab in mediaTabs" :key="tab.key"
|
||||
:class="{ 'color-base-bg': switchMedia == tab.key }"
|
||||
@click="switchMediaTab(tab.key)">{{
|
||||
tab.key == 'video' ? $lang('goods.detail.video') : $lang('goods.detail.image')
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -684,6 +685,24 @@ export default {
|
||||
// 用于「未拿到任何数据时不显示真实内容」, 避免加载失败/异常时切到空详情页
|
||||
hasRealData() {
|
||||
return this.goodsSkuDetail && Number(this.goodsSkuDetail.goods_id) > 0;
|
||||
},
|
||||
// 媒体顺序的变更标识: 只在「媒体显示顺序 / 视频地址」变化时重新应用
|
||||
// 用它而不是直接 watch goodsSkuDetail(深度监听), 是为了避免用户手动切到
|
||||
// 「视频」后被详情数据的任意一次深层变更打回「主图」
|
||||
mediaOrderKey() {
|
||||
const detail = this.goodsSkuDetail || {};
|
||||
return (detail.media_order || 1) + '|' + (detail.video_url || '');
|
||||
},
|
||||
// 底部「图片 / 视频」切换按钮的排列, 与后台设置的媒体显示顺序保持一致
|
||||
mediaTabs() {
|
||||
const detail = this.goodsSkuDetail || {};
|
||||
const tabs = [{ key: 'img' }, { key: 'video' }];
|
||||
return Number(detail.media_order) === 2 ? tabs.reverse() : tabs;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
mediaOrderKey() {
|
||||
this.applyMediaOrder();
|
||||
}
|
||||
},
|
||||
mixins: [scroll, detail]
|
||||
|
||||
481
pages_tool/card/admin/contacts.vue
Normal file
481
pages_tool/card/admin/contacts.vue
Normal file
@@ -0,0 +1,481 @@
|
||||
<template>
|
||||
<view class="admin-contacts">
|
||||
<!-- 商家端三大入口平级切换(鸿蒙安全: $util.redirectTo) -->
|
||||
<view class="page-switch">
|
||||
<view class="switch-item" @tap="goManage">名片管理</view>
|
||||
<view class="switch-item active" @tap="goSelf">人脉管理</view>
|
||||
<view class="switch-item" @tap="goLeads">潜在客户</view>
|
||||
</view>
|
||||
|
||||
<!-- 搜索栏 + 添加入口 -->
|
||||
<view class="search-bar">
|
||||
<input class="search-input" :value="keyword" placeholder="搜索姓名 / 手机 / 公司"
|
||||
confirm-type="search" @input="e => keyword = e.detail.value" @confirm="doSearch" />
|
||||
<view class="search-btn" @tap="doSearch">搜索</view>
|
||||
<view class="add-btn" @tap="goEdit()">+ 添加</view>
|
||||
</view>
|
||||
|
||||
<!-- 来源筛选(与后端 Contact::SOURCES 对齐) -->
|
||||
<scroll-view scroll-x class="tab-bar">
|
||||
<view class="tab-inner">
|
||||
<view v-for="tab in tabs" :key="tab.value" class="tab-item"
|
||||
:class="{ active: source === tab.value }" @tap="switchTab(tab.value)">
|
||||
{{ tab.label }}
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 人脉列表 -->
|
||||
<view class="contact-list">
|
||||
<view v-for="(item, index) in list" :key="item.id" class="contact-item">
|
||||
<view class="item-top" @tap="callPhone(item.mobile)">
|
||||
<view class="item-name">{{ item.name }}</view>
|
||||
<view class="item-source" :class="'src-' + item.source">{{ item.source_text }}</view>
|
||||
</view>
|
||||
<view class="item-company" v-if="item.company || item.job_title">
|
||||
{{ item.company }}<text v-if="item.company && item.job_title"> · </text>{{ item.job_title }}
|
||||
</view>
|
||||
<view class="item-mobile" v-if="item.mobile" @tap="callPhone(item.mobile)">{{ item.mobile }}</view>
|
||||
<view class="item-remark" v-if="item.remark">「{{ item.remark }}」</view>
|
||||
<view class="item-meta">
|
||||
<text class="meta-time">{{ item.create_time_txt }}</text>
|
||||
<text class="meta-status" v-if="item.status == 1">已归档</text>
|
||||
</view>
|
||||
<view class="item-actions">
|
||||
<view class="action-btn plain" :data-index="index" @tap="goEdit">编辑</view>
|
||||
<view class="action-btn primary" :data-index="index" @tap="convertToLead">转为潜在客户</view>
|
||||
<view class="action-btn danger" :data-index="index" @tap="delContact">删除</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="load-more" v-if="list.length > 0">
|
||||
<text v-if="isLoading">加载中…</text>
|
||||
<text v-else-if="page < pageCount">上拉加载更多</text>
|
||||
<text v-else class="load-end">— 没有更多了 —</text>
|
||||
</view>
|
||||
|
||||
<ns-empty v-if="!isLoading && list.length === 0" :isIndex="false"
|
||||
:text="'暂无人脉,点右上角添加'"></ns-empty>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 商家端 · 人脉管理
|
||||
*
|
||||
* 接口全部来自后端已就绪的 /card/api/contact/*(收口在 common/js/card-api.js):
|
||||
* contact/lists 列表 / contact/add+update 增改(复用 contact-edit 页) /
|
||||
* contact/toLead 转线索 / contact/delete 真删。
|
||||
* 与员工端「我的人脉」的区别: 本页是商家后台视角, 带来源筛选与三大入口互链;
|
||||
* 编辑页共用 contact-edit.vue(from=admin 时保存后回本页)。
|
||||
*/
|
||||
import { CONTACT_SOURCES } from '@/common/js/card-api.js';
|
||||
|
||||
// 来源筛选: scan/exchange 暂无产生入口, 只放出 manual / lead_in 两个 tab
|
||||
const SOURCE_TABS = [{ value: '', label: '全部' }].concat(
|
||||
CONTACT_SOURCES.filter(s => s.value === 'manual' || s.value === 'lead_in')
|
||||
);
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
tabs: SOURCE_TABS,
|
||||
source: '',
|
||||
keyword: '',
|
||||
list: [],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
pageCount: 1,
|
||||
isLoading: false
|
||||
};
|
||||
},
|
||||
onLoad() {
|
||||
this.refresh();
|
||||
},
|
||||
onReady() {
|
||||
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||
uni.setNavigationBarTitle({ title: '名片' });
|
||||
// #ifdef H5
|
||||
document.title = '名片';
|
||||
// #endif
|
||||
},
|
||||
onShow() {
|
||||
// 从编辑页保存返回(redirectTo 回本页)时刷新
|
||||
if (this._loadedOnce) this.refresh();
|
||||
this._loadedOnce = true;
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
this.refresh(() => uni.stopPullDownRefresh());
|
||||
},
|
||||
onReachBottom() {
|
||||
if (this.isLoading) return;
|
||||
if (this.page >= this.pageCount) return;
|
||||
this.loadPage(this.page + 1);
|
||||
},
|
||||
methods: {
|
||||
// 平级入口: 鸿蒙禁裸 uni.switchTab, 走 $util.redirectTo
|
||||
goManage() {
|
||||
this.$util.redirectTo('/pages_tool/card/admin/manage');
|
||||
},
|
||||
goLeads() {
|
||||
this.$util.redirectTo('/pages_tool/card/admin/leads');
|
||||
},
|
||||
goSelf() {},
|
||||
// 编辑页共用 contact-edit; from=admin 时保存后回本页(见 contact-edit.vue#backToList)
|
||||
/**
|
||||
* 取事件对应的行数据。小程序端 v-for 内联传对象会丢参, 统一 :data-index + 不传参。
|
||||
* 兼容: 无参(新建) / 事件对象(data-index) / 直接 id。
|
||||
*/
|
||||
resolveItem(payload) {
|
||||
if (payload == null) return null;
|
||||
if (typeof payload === 'number' || typeof payload === 'string') return null;
|
||||
const ds = payload.currentTarget && payload.currentTarget.dataset;
|
||||
if (ds) {
|
||||
const i = Number(ds.index);
|
||||
if (!isNaN(i) && this.list[i]) return this.list[i];
|
||||
}
|
||||
return payload.id ? payload : null;
|
||||
},
|
||||
goEdit(payload) {
|
||||
let id = payload;
|
||||
if (payload && payload.currentTarget) {
|
||||
const i = Number(payload.currentTarget.dataset.index);
|
||||
const item = this.list[i];
|
||||
id = item ? item.id : '';
|
||||
}
|
||||
const url = id
|
||||
? '/pages_tool/card/contact-edit?from=admin&id=' + id
|
||||
: '/pages_tool/card/contact-edit?from=admin';
|
||||
this.$util.redirectTo(url);
|
||||
},
|
||||
switchTab(value) {
|
||||
if (this.source === value) return;
|
||||
this.source = value;
|
||||
this.refresh();
|
||||
},
|
||||
doSearch() {
|
||||
this.refresh();
|
||||
},
|
||||
refresh(done) {
|
||||
this.page = 1;
|
||||
this.loadPage(1, done);
|
||||
},
|
||||
loadPage(pageNo, done) {
|
||||
this.isLoading = true;
|
||||
const data = { page: pageNo, page_size: this.pageSize };
|
||||
// 空字符串 = 全部, 后端对 '' 不加 source 条件
|
||||
if (this.source !== '') data.source = this.source;
|
||||
if (this.keyword.trim() !== '') data.keyword = this.keyword.trim();
|
||||
|
||||
this.$cardApi.contactLists({
|
||||
data: data,
|
||||
success: res => {
|
||||
this.isLoading = false;
|
||||
if (res.code == 0) {
|
||||
const d = res.data || {};
|
||||
const rows = d.list || [];
|
||||
this.pageCount = d.page_count || 1;
|
||||
this.page = pageNo;
|
||||
this.list = pageNo === 1 ? rows : this.list.concat(rows);
|
||||
} else {
|
||||
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
|
||||
}
|
||||
typeof done === 'function' && done();
|
||||
},
|
||||
fail: () => {
|
||||
this.isLoading = false;
|
||||
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||
typeof done === 'function' && done();
|
||||
}
|
||||
});
|
||||
},
|
||||
convertToLead(payload) {
|
||||
const item = this.resolveItem(payload);
|
||||
if (!item) return;
|
||||
uni.showModal({
|
||||
title: '转为潜在客户',
|
||||
content: '将「' + (item.name || '') + '」转入潜在客户跟进?人脉记录会保留。',
|
||||
success: res => {
|
||||
if (!res.confirm) return;
|
||||
this.doConvert(item);
|
||||
}
|
||||
});
|
||||
},
|
||||
doConvert(item) {
|
||||
uni.showLoading({ title: '转换中…', mask: true });
|
||||
// 后端: 写一条线索且不删人脉; 同员工同手机号已转过则报错(message 可直接展示)
|
||||
this.$cardApi.contactToLead({
|
||||
data: { id: item.id },
|
||||
success: res => {
|
||||
uni.hideLoading();
|
||||
if (res.code == 0) {
|
||||
uni.showToast({ title: '已转入潜在客户', icon: 'success' });
|
||||
} else {
|
||||
uni.showToast({ title: res.message || '转换失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||
}
|
||||
});
|
||||
},
|
||||
delContact(payload) {
|
||||
const item = this.resolveItem(payload);
|
||||
if (!item) return;
|
||||
// 真删不可恢复, 二次确认
|
||||
uni.showModal({
|
||||
title: '删除人脉',
|
||||
content: '删除后不可恢复,确定删除「' + (item.name || '') + '」?',
|
||||
confirmColor: '#f5222d',
|
||||
success: res => {
|
||||
if (!res.confirm) return;
|
||||
this.doDelete(item);
|
||||
}
|
||||
});
|
||||
},
|
||||
doDelete(item) {
|
||||
this.$cardApi.contactDelete({
|
||||
data: { id: item.id },
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
this.list = this.list.filter(c => c.id !== item.id);
|
||||
uni.showToast({ title: '已删除', icon: 'success' });
|
||||
} else {
|
||||
// 后端对"不存在"与"非本人"同一口径, 按"不存在或已删除"提示
|
||||
uni.showToast({ title: res.message || '删除失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||
}
|
||||
});
|
||||
},
|
||||
callPhone(mobile) {
|
||||
if (!mobile) return;
|
||||
uni.makePhoneCall({ phoneNumber: mobile });
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.admin-contacts {
|
||||
min-height: 100vh;
|
||||
background-color: #f6f7f9;
|
||||
padding-bottom: 40rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-switch {
|
||||
display: flex;
|
||||
background-color: #ffffff;
|
||||
padding: 0 12rpx;
|
||||
|
||||
.switch-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24rpx 0 20rpx;
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
position: relative;
|
||||
|
||||
&.active {
|
||||
color: var(--main-color, #0054a5);
|
||||
font-weight: 600;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: 8rpx;
|
||||
width: 48rpx;
|
||||
height: 6rpx;
|
||||
border-radius: 3rpx;
|
||||
background-color: var(--main-color, #0054a5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16rpx 24rpx;
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 64rpx;
|
||||
line-height: 64rpx;
|
||||
background-color: #ffffff;
|
||||
border-radius: 32rpx;
|
||||
padding: 0 28rpx;
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
font-size: 26rpx;
|
||||
color: #ffffff;
|
||||
background-color: var(--main-color, #0054a5);
|
||||
border-radius: 32rpx;
|
||||
padding: 12rpx 28rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
background-color: #ffffff;
|
||||
white-space: nowrap;
|
||||
|
||||
.tab-inner {
|
||||
display: inline-flex;
|
||||
padding: 0 12rpx;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: inline-block;
|
||||
padding: 24rpx 28rpx;
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
position: relative;
|
||||
|
||||
&.active {
|
||||
color: var(--main-color, #0054a5);
|
||||
font-weight: 600;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: 10rpx;
|
||||
width: 40rpx;
|
||||
height: 6rpx;
|
||||
border-radius: 3rpx;
|
||||
background-color: var(--main-color, #0054a5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contact-list {
|
||||
padding: 20rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.contact-item {
|
||||
background-color: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
.item-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.item-name {
|
||||
flex: 1;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.item-source {
|
||||
font-size: 22rpx;
|
||||
padding: 6rpx 18rpx;
|
||||
border-radius: 22rpx;
|
||||
|
||||
&.src-manual {
|
||||
color: var(--main-color, #0054a5);
|
||||
background-color: var(--main-color-shallow, #e8f1fb);
|
||||
}
|
||||
|
||||
&.src-lead_in {
|
||||
color: #1daa39;
|
||||
background-color: #e8f8ea;
|
||||
}
|
||||
|
||||
&.src-scan,
|
||||
&.src-exchange {
|
||||
color: #999;
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item-company {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
.item-mobile {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
|
||||
.item-remark {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
margin-top: 12rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20rpx;
|
||||
padding-top: 20rpx;
|
||||
border-top: 1rpx solid #f0f0f0;
|
||||
|
||||
.action-btn {
|
||||
font-size: 24rpx;
|
||||
border-radius: 30rpx;
|
||||
padding: 10rpx 26rpx;
|
||||
margin-left: 16rpx;
|
||||
|
||||
&.plain {
|
||||
color: #666;
|
||||
border: 1rpx solid #ddd;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
color: #ffffff;
|
||||
background-color: var(--main-color, #0054a5);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #f5222d;
|
||||
border: 1rpx solid #f5222d;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.load-more {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 24rpx;
|
||||
padding: 24rpx 0;
|
||||
|
||||
.load-end {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
431
pages_tool/card/admin/leads.vue
Normal file
431
pages_tool/card/admin/leads.vue
Normal file
@@ -0,0 +1,431 @@
|
||||
<template>
|
||||
<view class="admin-leads">
|
||||
<!-- 商家端三大入口平级切换(鸿蒙安全: $util.redirectTo) -->
|
||||
<view class="page-switch">
|
||||
<view class="switch-item" @tap="goManage">名片管理</view>
|
||||
<view class="switch-item" @tap="goContacts">人脉管理</view>
|
||||
<view class="switch-item active" @tap="goSelf">潜在客户</view>
|
||||
</view>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<view class="search-bar">
|
||||
<input class="search-input" :value="keyword" placeholder="搜索姓名 / 手机"
|
||||
confirm-type="search" @input="e => keyword = e.detail.value" @confirm="doSearch" />
|
||||
<view class="search-btn" @tap="doSearch">搜索</view>
|
||||
</view>
|
||||
|
||||
<!-- 跟进状态筛选(与后端 Lead::STATUSES 对齐, setStatus 仅接受 0-3) -->
|
||||
<scroll-view scroll-x class="tab-bar">
|
||||
<view class="tab-inner">
|
||||
<view v-for="tab in tabs" :key="tab.value" class="tab-item"
|
||||
:class="{ active: status === tab.value }" @tap="switchTab(tab.value)">
|
||||
{{ tab.label }}
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 线索列表 -->
|
||||
<view class="lead-list">
|
||||
<view v-for="(item, index) in list" :key="item.id || index" class="lead-item">
|
||||
<view class="lead-top" @tap="callPhone(item.mobile)">
|
||||
<view class="lead-name">{{ item.name || '未留姓名' }}</view>
|
||||
<view class="lead-status" :class="'st-' + item.status">{{ item.status_text || '新客户' }}</view>
|
||||
</view>
|
||||
<view class="lead-mobile" v-if="item.mobile" @tap="callPhone(item.mobile)">📱 {{ item.mobile }}</view>
|
||||
<view class="lead-note" v-if="item.note">「{{ item.note }}」</view>
|
||||
<view class="lead-meta">
|
||||
<text class="meta-card">来自名片:{{ item.card_name }}</text>
|
||||
<text class="meta-time">{{ item.create_time_txt }}</text>
|
||||
</view>
|
||||
<view class="lead-actions">
|
||||
<view class="action-btn copy" v-if="item.wechat" @tap="copyText(item.wechat)">复制微信</view>
|
||||
<view class="action-btn primary" :data-index="index" @tap="changeStatus">更新状态</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="load-more" v-if="list.length > 0">
|
||||
<text v-if="isLoading">加载中…</text>
|
||||
<text v-else-if="page < pageCount">上拉加载更多</text>
|
||||
<text v-else class="load-end">— 没有更多了 —</text>
|
||||
</view>
|
||||
|
||||
<ns-empty v-if="!isLoading && list.length === 0" :isIndex="false"
|
||||
:text="emptyText"></ns-empty>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 商家端 · 潜在客户
|
||||
*
|
||||
* 接口全部来自后端已就绪的 /card/api/lead/*(收口在 common/js/card-api.js):
|
||||
* lead/lists 列表(可见范围=自己名下名片收到的线索 + 自己从人脉转入的线索;
|
||||
* 没有名片时列表为空属正常现象) / lead/setStatus 更新跟进状态(0-3)。
|
||||
*/
|
||||
import { LEAD_STATUS } from '@/common/js/card-api.js';
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
tabs: [{ value: '', label: '全部' }].concat(LEAD_STATUS),
|
||||
status: '',
|
||||
keyword: '',
|
||||
list: [],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
pageCount: 1,
|
||||
isLoading: false,
|
||||
emptyText: '暂无意向客户,访客在你的名片页留资后会出现在这里'
|
||||
};
|
||||
},
|
||||
onLoad() {
|
||||
this.refresh();
|
||||
},
|
||||
onReady() {
|
||||
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||
uni.setNavigationBarTitle({ title: '名片' });
|
||||
// #ifdef H5
|
||||
document.title = '名片';
|
||||
// #endif
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
this.refresh(() => uni.stopPullDownRefresh());
|
||||
},
|
||||
onReachBottom() {
|
||||
if (this.isLoading) return;
|
||||
if (this.page >= this.pageCount) return;
|
||||
this.loadPage(this.page + 1);
|
||||
},
|
||||
methods: {
|
||||
// 平级入口: 鸿蒙禁裸 uni.switchTab, 走 $util.redirectTo
|
||||
goManage() {
|
||||
this.$util.redirectTo('/pages_tool/card/admin/manage');
|
||||
},
|
||||
goContacts() {
|
||||
this.$util.redirectTo('/pages_tool/card/admin/contacts');
|
||||
},
|
||||
goSelf() {},
|
||||
switchTab(value) {
|
||||
if (this.status === value) return;
|
||||
this.status = value;
|
||||
this.refresh();
|
||||
},
|
||||
doSearch() {
|
||||
this.refresh();
|
||||
},
|
||||
refresh(done) {
|
||||
this.page = 1;
|
||||
this.loadPage(1, done);
|
||||
},
|
||||
loadPage(pageNo, done) {
|
||||
this.isLoading = true;
|
||||
const data = { page: pageNo, page_size: this.pageSize };
|
||||
// 空字符串 = 全部, 后端对 '' 不加 status 条件; status 只传 0-3
|
||||
if (this.status !== '') data.status = this.status;
|
||||
if (this.keyword.trim() !== '') data.keyword = this.keyword.trim();
|
||||
|
||||
this.$cardApi.leadLists({
|
||||
data: data,
|
||||
success: res => {
|
||||
this.isLoading = false;
|
||||
if (res.code == 0) {
|
||||
const d = res.data || {};
|
||||
const rows = d.list || [];
|
||||
this.pageCount = d.page_count || 1;
|
||||
this.page = pageNo;
|
||||
this.list = pageNo === 1 ? rows : this.list.concat(rows);
|
||||
this.emptyText = this.keyword.trim() || this.status !== ''
|
||||
? '未找到匹配的客户'
|
||||
: '暂无意向客户,访客在你的名片页留资后会出现在这里';
|
||||
} else {
|
||||
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
|
||||
}
|
||||
typeof done === 'function' && done();
|
||||
},
|
||||
fail: () => {
|
||||
this.isLoading = false;
|
||||
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||
typeof done === 'function' && done();
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 取事件对应的行数据。小程序端 v-for 内联传对象会丢参, 统一 :data-index + 不传参。
|
||||
* 兼容: 事件对象(data-index) / 数字索引 / 行对象本身。
|
||||
*/
|
||||
resolveItem(payload) {
|
||||
if (payload == null) return null;
|
||||
if (typeof payload === 'number') return this.list[payload] || null;
|
||||
const ds = payload.currentTarget && payload.currentTarget.dataset;
|
||||
if (ds) {
|
||||
const i = Number(ds.index);
|
||||
if (!isNaN(i) && this.list[i]) return this.list[i];
|
||||
}
|
||||
return payload.id ? payload : null;
|
||||
},
|
||||
changeStatus(payload) {
|
||||
let item, index;
|
||||
if (payload && payload.currentTarget) {
|
||||
index = Number(payload.currentTarget.dataset.index);
|
||||
item = this.list[index];
|
||||
} else if (typeof payload === 'number') {
|
||||
index = payload;
|
||||
item = this.list[index];
|
||||
} else {
|
||||
item = payload;
|
||||
index = this.list.indexOf(item);
|
||||
}
|
||||
if (!item) return;
|
||||
uni.showActionSheet({
|
||||
itemList: LEAD_STATUS.map(s => s.label),
|
||||
success: res => {
|
||||
const target = LEAD_STATUS[res.tapIndex];
|
||||
if (!target || target.value === item.status) return;
|
||||
this.doSetStatus(item, index, target.value);
|
||||
}
|
||||
});
|
||||
},
|
||||
doSetStatus(item, index, status) {
|
||||
this.$cardApi.leadSetStatus({
|
||||
data: { id: item.id, status: status },
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
// 就地更新, 不整页刷新
|
||||
this.list[index].status = status;
|
||||
this.list[index].status_text = LEAD_STATUS.find(s => s.value === status).label;
|
||||
uni.showToast({ title: '已更新', icon: 'success' });
|
||||
} else {
|
||||
uni.showToast({ title: res.message || '更新失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||
}
|
||||
});
|
||||
},
|
||||
callPhone(mobile) {
|
||||
if (!mobile) return;
|
||||
uni.makePhoneCall({ phoneNumber: mobile });
|
||||
},
|
||||
copyText(text) {
|
||||
uni.setClipboardData({
|
||||
data: text,
|
||||
success: () => uni.showToast({ title: '已复制', icon: 'success' })
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.admin-leads {
|
||||
min-height: 100vh;
|
||||
background-color: #f6f7f9;
|
||||
padding-bottom: 40rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-switch {
|
||||
display: flex;
|
||||
background-color: #ffffff;
|
||||
padding: 0 12rpx;
|
||||
|
||||
.switch-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24rpx 0 20rpx;
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
position: relative;
|
||||
|
||||
&.active {
|
||||
color: var(--main-color, #0054a5);
|
||||
font-weight: 600;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: 8rpx;
|
||||
width: 48rpx;
|
||||
height: 6rpx;
|
||||
border-radius: 3rpx;
|
||||
background-color: var(--main-color, #0054a5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16rpx 24rpx;
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 64rpx;
|
||||
line-height: 64rpx;
|
||||
background-color: #ffffff;
|
||||
border-radius: 32rpx;
|
||||
padding: 0 28rpx;
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
background-color: #ffffff;
|
||||
white-space: nowrap;
|
||||
|
||||
.tab-inner {
|
||||
display: inline-flex;
|
||||
padding: 0 12rpx;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: inline-block;
|
||||
padding: 24rpx 28rpx;
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
position: relative;
|
||||
|
||||
&.active {
|
||||
color: var(--main-color, #0054a5);
|
||||
font-weight: 600;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: 10rpx;
|
||||
width: 40rpx;
|
||||
height: 6rpx;
|
||||
border-radius: 3rpx;
|
||||
background-color: var(--main-color, #0054a5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lead-list {
|
||||
padding: 20rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.lead-item {
|
||||
background-color: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
.lead-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.lead-name {
|
||||
flex: 1;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.lead-status {
|
||||
font-size: 22rpx;
|
||||
padding: 6rpx 18rpx;
|
||||
border-radius: 22rpx;
|
||||
|
||||
&.st-0 {
|
||||
color: var(--main-color, #0054a5);
|
||||
background-color: var(--main-color-shallow, #e8f1fb);
|
||||
}
|
||||
|
||||
&.st-1 {
|
||||
color: #e6a23c;
|
||||
background-color: #fdf3e6;
|
||||
}
|
||||
|
||||
&.st-2 {
|
||||
color: #1daa39;
|
||||
background-color: #e8f8ea;
|
||||
}
|
||||
|
||||
&.st-3 {
|
||||
color: #999;
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lead-mobile {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
|
||||
.lead-note {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
margin-top: 12rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.lead-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
margin-top: 14rpx;
|
||||
|
||||
.meta-card {
|
||||
max-width: 60%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.lead-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20rpx;
|
||||
padding-top: 20rpx;
|
||||
border-top: 1rpx solid #f0f0f0;
|
||||
|
||||
.action-btn {
|
||||
font-size: 24rpx;
|
||||
border-radius: 30rpx;
|
||||
padding: 10rpx 30rpx;
|
||||
margin-left: 20rpx;
|
||||
|
||||
&.copy {
|
||||
color: #666;
|
||||
border: 1rpx solid #ddd;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
color: #ffffff;
|
||||
background-color: var(--main-color, #0054a5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.load-more {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 24rpx;
|
||||
padding: 24rpx 0;
|
||||
|
||||
.load-end {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
432
pages_tool/card/admin/manage.vue
Normal file
432
pages_tool/card/admin/manage.vue
Normal file
@@ -0,0 +1,432 @@
|
||||
<template>
|
||||
<view class="admin-manage">
|
||||
<!-- 商家端三大入口平级切换(鸿蒙安全: $util.redirectTo, 禁裸 uni.switchTab) -->
|
||||
<view class="page-switch">
|
||||
<view class="switch-item active" @tap="goSelf">名片管理</view>
|
||||
<view class="switch-item" @tap="goContacts">人脉管理</view>
|
||||
<view class="switch-item" @tap="goLeads">潜在客户</view>
|
||||
</view>
|
||||
|
||||
<!-- 功能预告: 查重合并/批量制作/导出等依赖后台接口, 就绪后开放 -->
|
||||
<view class="notice-bar">
|
||||
<text class="notice-icon">i</text>
|
||||
<text class="notice-text">查重合并、批量制作、导出 CSV/vCard 等功能即将上线</text>
|
||||
</view>
|
||||
|
||||
<!-- 搜索区 -->
|
||||
<view class="search-bar">
|
||||
<view class="search-input-wrap">
|
||||
<input class="search-input" confirm-type="search" :value="keyword"
|
||||
placeholder="搜索姓名 / 手机 / 公司" @confirm="onSearch" @input="onKeywordInput" />
|
||||
<text v-if="keyword" class="search-clear" @tap="clearKeyword">✕</text>
|
||||
<text class="iconfont icon-sousuo3" @tap="onSearch"></text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 名片列表 -->
|
||||
<view class="card-list">
|
||||
<view v-for="(item, index) in list" :key="item.card_id || index" :data-index="index" class="card-item"
|
||||
@tap="goDetail">
|
||||
<view class="card-avatar">
|
||||
<image v-if="item.avatar" :src="$util.img(item.avatar)" class="avatar-img" mode="aspectFill" />
|
||||
<text v-else>{{ avatarText(item.name) }}</text>
|
||||
</view>
|
||||
<view class="card-info">
|
||||
<view class="card-name line1">{{ item.name || '未命名名片' }}</view>
|
||||
<view class="card-sub line1" v-if="item.job_title || item.company">
|
||||
{{ [item.job_title, item.company].filter(Boolean).join(' · ') }}
|
||||
</view>
|
||||
<view class="card-mobile" v-if="item.mobile">📱 {{ item.mobile }}</view>
|
||||
</view>
|
||||
<view class="card-side">
|
||||
<text v-if="sourceLabel(item)" class="source-tag">{{ sourceLabel(item) }}</text>
|
||||
<view class="card-actions" @tap.stop="">
|
||||
<text class="act-btn" :data-index="index" @tap="goEdit">编辑</text>
|
||||
<text class="act-btn primary" :data-index="index" @tap="goDetail">详情</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<view class="load-more" v-if="list.length > 0">
|
||||
<text v-if="isLoading">加载中…</text>
|
||||
<text v-else-if="page < pageCount">上拉加载更多</text>
|
||||
<text v-else class="load-end">— 没有更多了 —</text>
|
||||
</view>
|
||||
|
||||
<!-- 空态 -->
|
||||
<ns-empty v-if="!isLoading && list.length === 0" :isIndex="false" :text="emptyText"></ns-empty>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 商家端 · 名片管理(基础版)
|
||||
*
|
||||
* 范围: 本轮仅开放现有接口可支撑的能力——搜索 / 详情 / 编辑。
|
||||
* 列表/详情/编辑复用 card/lists + 既有 /pages_tool/card/detail、edit 页
|
||||
* (路由参数 card_id, 字符串主键)。
|
||||
* 查重合并 / 批量制作 / 新建数字名片 / 导出 CSV/vCard / 标签·状态·类型·跟进·收藏
|
||||
* 筛选依赖后台未就绪接口, 后台文档(CARD_API_REFERENCE.md)到位后在本页扩展,
|
||||
* 端点届时收口进 common/js/card-api.js, 页面只改 this.$cardApi 调用。
|
||||
*/
|
||||
import { CARD_SOURCE_TYPES } from '@/common/js/card-api.js';
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
keyword: '',
|
||||
list: [],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
pageCount: 1,
|
||||
isLoading: false,
|
||||
emptyText: '暂无名片刻'
|
||||
};
|
||||
},
|
||||
onLoad() {
|
||||
this.refresh();
|
||||
},
|
||||
onReady() {
|
||||
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||
uni.setNavigationBarTitle({ title: '名片' });
|
||||
// #ifdef H5
|
||||
document.title = '名片';
|
||||
// #endif
|
||||
},
|
||||
onShow() {
|
||||
// 从编辑页返回时刷新(编辑成功后数据已变)
|
||||
if (this._loadedOnce) this.refresh();
|
||||
this._loadedOnce = true;
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
this.refresh(() => uni.stopPullDownRefresh());
|
||||
},
|
||||
onReachBottom() {
|
||||
if (this.isLoading) return;
|
||||
if (this.page >= this.pageCount) return;
|
||||
this.loadPage(this.page + 1);
|
||||
},
|
||||
methods: {
|
||||
avatarText(name) {
|
||||
return (name || '?').trim().charAt(0).toUpperCase();
|
||||
},
|
||||
// 来源标签: 优先后端译文(source_type_text), 缺失时按字典兜底(与后端 source_type 对齐)
|
||||
sourceLabel(item) {
|
||||
if (!item) return '';
|
||||
if (item.source_type_text) return item.source_type_text;
|
||||
const hit = CARD_SOURCE_TYPES.find(s => s.value === item.source_type);
|
||||
return hit ? hit.label : '';
|
||||
},
|
||||
onKeywordInput(e) {
|
||||
this.keyword = e.detail.value;
|
||||
},
|
||||
onSearch() {
|
||||
this.refresh();
|
||||
},
|
||||
clearKeyword() {
|
||||
this.keyword = '';
|
||||
this.refresh();
|
||||
},
|
||||
refresh(done) {
|
||||
this.page = 1;
|
||||
this.loadPage(1, done);
|
||||
},
|
||||
loadPage(pageNo, done) {
|
||||
this.isLoading = true;
|
||||
// page_size 上限 100(后端硬限制), 这里 20 足够
|
||||
this.$cardApi.cardLists({
|
||||
data: {
|
||||
page: pageNo,
|
||||
page_size: this.pageSize,
|
||||
keyword: this.keyword.trim()
|
||||
},
|
||||
success: res => {
|
||||
this.isLoading = false;
|
||||
if (res.code == 0) {
|
||||
const d = res.data || {};
|
||||
const rows = d.list || [];
|
||||
this.pageCount = d.page_count || 1;
|
||||
this.page = pageNo;
|
||||
this.list = pageNo === 1 ? rows : this.list.concat(rows);
|
||||
this.emptyText = this.keyword.trim() ? '未找到匹配的名片' : '暂无名片刻';
|
||||
} else {
|
||||
// 后端 code!=0 的 message 可直接 toast
|
||||
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
|
||||
}
|
||||
typeof done === 'function' && done();
|
||||
},
|
||||
fail: () => {
|
||||
this.isLoading = false;
|
||||
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||
typeof done === 'function' && done();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/* ---------- 导航 ---------- */
|
||||
/**
|
||||
* 取事件对应的行数据。小程序端 v-for 内联传对象(如 goDetail(item))会丢参,
|
||||
* 统一改为 :data-index + 不传参, 这里从 dataset 取索引回表。
|
||||
*/
|
||||
resolveItem(payload) {
|
||||
if (payload == null) return null;
|
||||
if (typeof payload === 'number') return this.list[payload] || null;
|
||||
const ds = payload.currentTarget && payload.currentTarget.dataset;
|
||||
if (ds) {
|
||||
const i = Number(ds.index);
|
||||
if (!isNaN(i) && this.list[i]) return this.list[i];
|
||||
}
|
||||
return payload.card_id ? payload : null;
|
||||
},
|
||||
// 名片路由参数一律 card_id(字符串), encodeURIComponent 幂等
|
||||
goDetail(payload) {
|
||||
const item = this.resolveItem(payload);
|
||||
if (!item || !item.card_id) return;
|
||||
uni.navigateTo({
|
||||
url: '/pages_tool/card/detail?card_id=' + encodeURIComponent(item.card_id)
|
||||
});
|
||||
},
|
||||
goEdit(payload) {
|
||||
const item = this.resolveItem(payload);
|
||||
if (!item || !item.card_id) return;
|
||||
uni.navigateTo({
|
||||
url: '/pages_tool/card/edit?card_id=' + encodeURIComponent(item.card_id)
|
||||
});
|
||||
},
|
||||
// 平级入口: 鸿蒙元服务上 uni.switchTab 静默失败, 必须走 $util.redirectTo
|
||||
goContacts() {
|
||||
this.$util.redirectTo('/pages_tool/card/admin/contacts');
|
||||
},
|
||||
goLeads() {
|
||||
this.$util.redirectTo('/pages_tool/card/admin/leads');
|
||||
},
|
||||
goSelf() {
|
||||
// 当前页, 无需跳转
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.admin-manage {
|
||||
min-height: 100vh;
|
||||
background-color: #f6f7f9;
|
||||
padding-bottom: 40rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 顶部平级切换(三页互链) */
|
||||
.page-switch {
|
||||
display: flex;
|
||||
background-color: #ffffff;
|
||||
padding: 0 12rpx;
|
||||
|
||||
.switch-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24rpx 0 20rpx;
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
position: relative;
|
||||
|
||||
&.active {
|
||||
color: var(--main-color, #0054a5);
|
||||
font-weight: 600;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: 8rpx;
|
||||
width: 48rpx;
|
||||
height: 6rpx;
|
||||
border-radius: 3rpx;
|
||||
background-color: var(--main-color, #0054a5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 功能预告条 */
|
||||
.notice-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 20rpx 24rpx 0;
|
||||
padding: 16rpx 20rpx;
|
||||
background-color: var(--main-color-shallow, #e8f1fb);
|
||||
border-radius: 12rpx;
|
||||
|
||||
.notice-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
line-height: 32rpx;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background-color: var(--main-color, #0054a5);
|
||||
color: #ffffff;
|
||||
font-size: 22rpx;
|
||||
flex-shrink: 0;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
|
||||
.notice-text {
|
||||
font-size: 24rpx;
|
||||
color: var(--main-color, #0054a5);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx 24rpx 0;
|
||||
|
||||
.search-input-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #ffffff;
|
||||
border-radius: 36rpx;
|
||||
padding: 0 24rpx;
|
||||
height: 72rpx;
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
padding: 0 12rpx;
|
||||
color: #bbb;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
color: #999;
|
||||
font-size: 32rpx;
|
||||
padding-left: 12rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-list {
|
||||
padding: 20rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.card-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
padding: 28rpx 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
&:active {
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
.card-avatar {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 50%;
|
||||
background-color: var(--main-color-shallow, #e8f1fb);
|
||||
color: var(--main-color, #0054a5);
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-right: 24rpx;
|
||||
overflow: hidden;
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.card-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.card-sub {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.card-mobile {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.card-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
flex-shrink: 0;
|
||||
margin-left: 16rpx;
|
||||
|
||||
.source-tag {
|
||||
font-size: 20rpx;
|
||||
color: #999;
|
||||
background-color: #f2f2f2;
|
||||
border-radius: 20rpx;
|
||||
padding: 4rpx 14rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
|
||||
.act-btn {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
border: 1rpx solid #ddd;
|
||||
border-radius: 28rpx;
|
||||
padding: 8rpx 22rpx;
|
||||
|
||||
& + .act-btn {
|
||||
margin-left: 12rpx;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
color: var(--main-color, #0054a5);
|
||||
border-color: var(--main-color, #0054a5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.load-more {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 24rpx;
|
||||
padding: 24rpx 0;
|
||||
|
||||
.load-end {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.line1 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
213
pages_tool/card/apply.vue
Normal file
213
pages_tool/card/apply.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<view class="card-apply">
|
||||
<!-- 申请状态条: 提交过申请或已有名片时替代表单 -->
|
||||
<view class="tips" v-if="statusLoaded && statusTip">
|
||||
<text class="tips-title">{{ statusTip }}</text>
|
||||
<text class="tips-sub" v-if="statusSub">{{ statusSub }}</text>
|
||||
</view>
|
||||
|
||||
<template v-if="formEditable">
|
||||
<view class="tips">
|
||||
<text class="tips-title">向商家申请一张数字名片</text>
|
||||
<text class="tips-sub">提交后商家会收到通知,同意后名片自动出现在你的名片夹</text>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<view class="form-row">
|
||||
<view class="row-label"><text class="req-star">*</text>姓名</view>
|
||||
<input class="row-input" :value="form.name" placeholder="你希望名片上显示的姓名"
|
||||
@input="e => form.name = e.detail.value" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label"><text class="req-star">*</text>手机号</view>
|
||||
<input class="row-input" type="number" :value="form.mobile" placeholder="你注册本店使用的手机号"
|
||||
@input="e => form.mobile = e.detail.value" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view class="save-bar" v-if="formEditable">
|
||||
<button class="save-btn" :disabled="submitting" @tap="submit">{{ submitting ? '提交中…' : '提交申请' }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
form: { name: '', mobile: '' },
|
||||
submitting: false,
|
||||
// 状态回查: null=未加载, 否则 { apply, has_card }
|
||||
applyStatus: null,
|
||||
statusLoaded: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// 已有名片 / 申请待审 → 隐藏表单只显示状态; 被拒/无记录 → 允许(重新)提交
|
||||
formEditable() {
|
||||
const s = this.applyStatus;
|
||||
if (!s) return true;
|
||||
if (s.has_card) return false;
|
||||
if (s.apply && s.apply.status === 0) return false; // 待审核
|
||||
return true;
|
||||
},
|
||||
statusTip() {
|
||||
const s = this.applyStatus;
|
||||
if (!s) return '';
|
||||
if (s.has_card) return '您已拥有名片,去名片夹查看吧';
|
||||
if (s.apply && s.apply.status === 0) return '申请审核中,商家同意后名片会自动出现';
|
||||
if (s.apply && s.apply.status === 2) return '上次申请未通过,可修改后重新提交';
|
||||
return '';
|
||||
},
|
||||
statusSub() {
|
||||
const s = this.applyStatus;
|
||||
if (!s || !s.apply) return '';
|
||||
if (s.apply.status === 2) return '提交于 ' + (s.apply.create_time_txt || '');
|
||||
return s.apply.create_time_txt ? ('提交于 ' + s.apply.create_time_txt) : '';
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
uni.setNavigationBarTitle({ title: '名片' });
|
||||
this.loadStatus();
|
||||
},
|
||||
onReady() {
|
||||
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||
uni.setNavigationBarTitle({ title: '名片' });
|
||||
// #ifdef H5
|
||||
document.title = '名片';
|
||||
// #endif
|
||||
},
|
||||
methods: {
|
||||
loadStatus() {
|
||||
this.$api.sendRequest({
|
||||
url: '/card/api/apply/status',
|
||||
success: res => {
|
||||
if (res.code == 0 && res.data) {
|
||||
this.applyStatus = res.data;
|
||||
}
|
||||
this.statusLoaded = true;
|
||||
},
|
||||
fail: () => {
|
||||
// 回查失败不阻塞表单
|
||||
this.statusLoaded = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
submit() {
|
||||
if (this.submitting) return;
|
||||
const name = String(this.form.name || '').trim();
|
||||
const mobile = String(this.form.mobile || '').trim();
|
||||
if (!name) {
|
||||
uni.showToast({ title: '请填写姓名', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (!/^1\d{10}$/.test(mobile)) {
|
||||
uni.showToast({ title: '请填写正确的 11 位手机号', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
this.$api.sendJson({
|
||||
url: '/card/api/apply/submit',
|
||||
data: { name: name, mobile: mobile },
|
||||
success: res => {
|
||||
this.submitting = false;
|
||||
if (res.code == 0) {
|
||||
uni.showToast({ title: '申请已提交', icon: 'success' });
|
||||
setTimeout(() => uni.navigateBack(), 600);
|
||||
} else {
|
||||
uni.showToast({ title: res.message || '提交失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
this.submitting = false;
|
||||
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card-apply {
|
||||
min-height: 100vh;
|
||||
background-color: #f6f7f9;
|
||||
padding: 24rpx 24rpx 60rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tips {
|
||||
padding: 20rpx 8rpx 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.tips-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.tips-sub {
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
background-color: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28rpx 24rpx;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.row-label {
|
||||
width: 160rpx;
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
|
||||
.req-star {
|
||||
color: #f5222d;
|
||||
margin-right: 4rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.row-input {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.save-bar {
|
||||
padding: 40rpx 0 0;
|
||||
|
||||
.save-btn {
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
background-color: var(--main-color, #0054a5);
|
||||
color: #ffffff;
|
||||
font-size: 30rpx;
|
||||
border-radius: 44rpx;
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.6;
|
||||
color: #ffffff;
|
||||
background-color: var(--main-color, #0054a5);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
255
pages_tool/card/contact-edit.vue
Normal file
255
pages_tool/card/contact-edit.vue
Normal file
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<view class="contact-edit">
|
||||
<view class="form-group">
|
||||
<view class="group-title">{{ isEdit ? '编辑人脉' : '添加人脉' }}</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label"><text class="req-star">*</text>姓名</view>
|
||||
<input class="row-input" :value="form.name" placeholder="请输入姓名"
|
||||
@input="e => form.name = e.detail.value" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">手机</view>
|
||||
<input class="row-input" type="number" :value="form.mobile" placeholder="手机号(选填)"
|
||||
@input="e => form.mobile = e.detail.value" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">公司</view>
|
||||
<input class="row-input" :value="form.company" placeholder="公司名称(选填)"
|
||||
@input="e => form.company = e.detail.value" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">职位</view>
|
||||
<input class="row-input" :value="form.job_title" placeholder="职位(选填)"
|
||||
@input="e => form.job_title = e.detail.value" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">微信号</view>
|
||||
<input class="row-input" :value="form.wechat" placeholder="微信号(选填)"
|
||||
@input="e => form.wechat = e.detail.value" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">邮箱</view>
|
||||
<input class="row-input" :value="form.email" placeholder="邮箱(选填)"
|
||||
@input="e => form.email = e.detail.value" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">网站</view>
|
||||
<input class="row-input" :value="form.website" placeholder="个人主页 / 官网(选填)"
|
||||
@input="e => form.website = e.detail.value" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">地址</view>
|
||||
<input class="row-input" :value="form.address" placeholder="地址(选填)"
|
||||
@input="e => form.address = e.detail.value" />
|
||||
</view>
|
||||
<view class="form-row remark-row">
|
||||
<view class="row-label">备注</view>
|
||||
<textarea class="remark-input" :value="form.remark" placeholder="备注(选填)"
|
||||
@input="e => form.remark = e.detail.value" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 保存 -->
|
||||
<view class="save-bar">
|
||||
<button class="save-btn" :disabled="saving" @tap="save">{{ saving ? '保存中…' : '保存' }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 与后端 Contact::EDITABLE_FIELDS 对齐(status 由列表归档操作管理, 编辑页不放)
|
||||
const FIELDS = ['name', 'mobile', 'company', 'job_title', 'wechat', 'email', 'website', 'address', 'remark'];
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
id: 0,
|
||||
isEdit: false,
|
||||
form: {
|
||||
name: '', mobile: '', company: '', job_title: '',
|
||||
wechat: '', email: '', website: '', address: '', remark: ''
|
||||
},
|
||||
saving: false,
|
||||
fromAdmin: false
|
||||
};
|
||||
},
|
||||
onLoad(options) {
|
||||
this.id = parseInt(options.id || 0, 10) || 0;
|
||||
this.isEdit = this.id > 0;
|
||||
// from=admin: 商家端人脉管理页复用本编辑页, 保存后回商家端(缺省回员工端)
|
||||
this.fromAdmin = options.from === 'admin';
|
||||
if (this.isEdit) {
|
||||
this.loadDetail();
|
||||
}
|
||||
},
|
||||
onReady() {
|
||||
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||
uni.setNavigationBarTitle({ title: '名片' });
|
||||
// #ifdef H5
|
||||
document.title = '名片';
|
||||
// #endif
|
||||
},
|
||||
methods: {
|
||||
loadDetail() {
|
||||
this.$cardApi.contactInfo({
|
||||
data: { id: this.id },
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
const r = res.data || {};
|
||||
FIELDS.forEach(k => {
|
||||
if (r[k] !== undefined && r[k] !== null) {
|
||||
this.form[k] = String(r[k]);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: res.message || '人脉不存在',
|
||||
showCancel: false,
|
||||
success: () => this.backToList()
|
||||
});
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||
}
|
||||
});
|
||||
},
|
||||
save() {
|
||||
if (this.saving) return;
|
||||
// 防御式取值: 任何非字符串/undefined 都不会让校验误判或抛错
|
||||
const name = String(this.form.name || '').trim();
|
||||
if (!name) {
|
||||
uni.showToast({ title: '请填写姓名', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
this.form.name = name;
|
||||
this.saving = true;
|
||||
|
||||
const data = { id: this.id, fields: {} };
|
||||
FIELDS.forEach(k => { data.fields[k] = this.form[k]; });
|
||||
|
||||
const onOk = res => {
|
||||
this.saving = false;
|
||||
if (res.code == 0) {
|
||||
uni.showToast({ title: '保存成功', icon: 'success' });
|
||||
setTimeout(() => this.backToList(), 600);
|
||||
} else {
|
||||
// 后端错误文案有时放在 message, 有时放在 data(字符串), 两者都兜住
|
||||
const msg = res.message || (typeof res.data === 'string' ? res.data : '') || '保存失败';
|
||||
uni.showToast({ title: msg, icon: 'none' });
|
||||
}
|
||||
};
|
||||
const onFail = () => {
|
||||
this.saving = false;
|
||||
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||
};
|
||||
|
||||
// add/update 都走 JSON body,跨端序列化零歧义。
|
||||
// ⚠️ 契约差异(见 docs/CARD_API_REFERENCE.md §1.4.2 / §1.4.4):
|
||||
// contact/add → 入参是「顶层平铺字段」, name 必须在 body 顶层;
|
||||
// contact/update→ 额外兼容 fields 对象。
|
||||
// 历史 bug: 新增时也包成 { fields: {...} }, 后端在顶层读不到 name,
|
||||
// 返回「请填写姓名」→ 表现为"明明填了姓名还提示填写姓名"。
|
||||
// 这里新增按契约平铺, 同时保留一份 fields 兼容旧后端(多余键会被白名单忽略)。
|
||||
if (this.isEdit) {
|
||||
this.$cardApi.contactUpdate({ data: data, success: onOk, fail: onFail });
|
||||
} else {
|
||||
const addPayload = Object.assign({}, data.fields, { fields: data.fields });
|
||||
this.$cardApi.contactAdd({ data: addPayload, success: onOk, fail: onFail });
|
||||
}
|
||||
},
|
||||
// 鸿蒙安全回列表: $util.redirectTo(禁裸 uni.switchTab/uni.navigateBack 兜底)
|
||||
// 商家端(from=admin)回商家端人脉管理, 员工端回「我的人脉」
|
||||
backToList() {
|
||||
const target = this.fromAdmin ? '/pages_tool/card/admin/contacts' : '/pages_tool/card/contacts';
|
||||
this.$util.redirectTo(target);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.contact-edit {
|
||||
min-height: 100vh;
|
||||
background-color: #f6f7f9;
|
||||
padding: 24rpx 24rpx 60rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
background-color: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
overflow: hidden;
|
||||
|
||||
.group-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
padding: 24rpx 24rpx 8rpx;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.row-label {
|
||||
width: 160rpx;
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 必填红星(与 card/edit.vue 同款) */
|
||||
.req-star {
|
||||
color: #f5222d;
|
||||
margin-right: 4rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.row-input {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.remark-row {
|
||||
align-items: flex-start;
|
||||
|
||||
.remark-input {
|
||||
flex: 1;
|
||||
min-height: 120rpx;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.save-bar {
|
||||
padding: 20rpx 0 0;
|
||||
|
||||
.save-btn {
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
background-color: var(--main-color, #0054a5);
|
||||
color: #ffffff;
|
||||
font-size: 30rpx;
|
||||
border-radius: 44rpx;
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.6;
|
||||
color: #ffffff;
|
||||
background-color: var(--main-color, #0054a5);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
452
pages_tool/card/contacts.vue
Normal file
452
pages_tool/card/contacts.vue
Normal file
@@ -0,0 +1,452 @@
|
||||
<template>
|
||||
<view class="contact-page">
|
||||
<!-- 平级入口: 与潜在客户页互链(导航合并第一步) -->
|
||||
<view class="page-switch" @tap="goLeads">
|
||||
<text class="switch-label">潜在客户</text>
|
||||
<text class="switch-arrow">›</text>
|
||||
</view>
|
||||
|
||||
<!-- 搜索栏 + 添加入口 -->
|
||||
<view class="search-bar">
|
||||
<input class="search-input" :value="keyword" placeholder="搜索姓名 / 手机 / 公司"
|
||||
confirm-type="search" @input="e => keyword = e.detail.value" @confirm="doSearch" />
|
||||
<view class="search-btn" @tap="doSearch">搜索</view>
|
||||
<view class="add-btn" @tap="goEdit()">+ 添加</view>
|
||||
</view>
|
||||
|
||||
<!-- 来源筛选 -->
|
||||
<scroll-view scroll-x class="tab-bar">
|
||||
<view class="tab-inner">
|
||||
<view v-for="tab in tabs" :key="tab.value" class="tab-item"
|
||||
:class="{ active: source === tab.value }" @tap="switchTab(tab.value)">
|
||||
{{ tab.label }}
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 人脉列表 -->
|
||||
<view class="contact-list">
|
||||
<view v-for="item in list" :key="item.id" class="contact-item">
|
||||
<view class="item-top" @tap="callPhone(item.mobile)">
|
||||
<view class="item-name">{{ item.name }}</view>
|
||||
<view class="item-source" :class="'src-' + item.source">{{ item.source_text }}</view>
|
||||
</view>
|
||||
<view class="item-company" v-if="item.company || item.job_title">
|
||||
{{ item.company }}<text v-if="item.company && item.job_title"> · </text>{{ item.job_title }}
|
||||
</view>
|
||||
<view class="item-mobile" v-if="item.mobile" @tap="callPhone(item.mobile)">{{ item.mobile }}</view>
|
||||
<view class="item-remark" v-if="item.remark">「{{ item.remark }}」</view>
|
||||
<view class="item-meta">
|
||||
<text class="meta-time">{{ item.create_time_txt }}</text>
|
||||
<text class="meta-status" v-if="item.status == 1">已归档</text>
|
||||
</view>
|
||||
<view class="item-actions">
|
||||
<view class="action-btn plain" :data-index="index" @tap="goEdit">编辑</view>
|
||||
<view class="action-btn primary" :data-index="index" @tap="convertToLead">转为潜在客户</view>
|
||||
<view class="action-btn danger" :data-index="index" @tap="delContact">删除</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="load-more" v-if="list.length > 0">
|
||||
<text v-if="isLoading">加载中…</text>
|
||||
<text v-else-if="page < pageCount">上拉加载更多</text>
|
||||
<text v-else class="load-end">— 没有更多了 —</text>
|
||||
</view>
|
||||
|
||||
<ns-empty v-if="!isLoading && list.length === 0" :isIndex="false"
|
||||
:text="'暂无人脉,点右上角添加'"></ns-empty>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 来源筛选: 与后端 Contact::SOURCES 一致; scan/exchange 暂无产生入口, 本轮只放出 3 个 tab
|
||||
const SOURCE_TABS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'manual', label: '手工录入' },
|
||||
{ value: 'lead_in', label: '潜在客户转入' }
|
||||
];
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
tabs: SOURCE_TABS,
|
||||
source: '',
|
||||
keyword: '',
|
||||
list: [],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
pageCount: 1,
|
||||
isLoading: false
|
||||
};
|
||||
},
|
||||
onLoad() {
|
||||
this.refresh();
|
||||
},
|
||||
onReady() {
|
||||
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||
uni.setNavigationBarTitle({ title: '名片' });
|
||||
// #ifdef H5
|
||||
document.title = '名片';
|
||||
// #endif
|
||||
},
|
||||
onShow() {
|
||||
// 从编辑页保存后 redirectTo 回来会重走 onLoad; 从其它页返回时兜底刷新
|
||||
if (this.list.length > 0) {
|
||||
this.refresh();
|
||||
}
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
this.refresh(() => uni.stopPullDownRefresh());
|
||||
},
|
||||
onReachBottom() {
|
||||
if (this.isLoading) return;
|
||||
if (this.page >= this.pageCount) return;
|
||||
this.loadPage(this.page + 1);
|
||||
},
|
||||
methods: {
|
||||
// 顶部平级入口: $util.redirectTo(鸿蒙禁裸 uni.switchTab, 静默失败)
|
||||
goLeads() {
|
||||
this.$util.redirectTo('/pages_tool/card/leads');
|
||||
},
|
||||
/**
|
||||
* 取事件对应的行数据。小程序端 v-for 内联传对象(如 convertToLead(item))会丢参,
|
||||
* 统一改为 :data-index + 不传参, 这里从 dataset 取索引回表。
|
||||
* 兼容: 无参(新建) / 事件对象(data-index) / 直接 id。
|
||||
*/
|
||||
resolveItem(payload) {
|
||||
if (payload == null) return null;
|
||||
if (typeof payload === 'number') return this.list[payload] || null;
|
||||
const ds = payload.currentTarget && payload.currentTarget.dataset;
|
||||
if (ds) {
|
||||
const i = Number(ds.index);
|
||||
if (!isNaN(i) && this.list[i]) return this.list[i];
|
||||
}
|
||||
return payload.id ? payload : null;
|
||||
},
|
||||
goEdit(payload) {
|
||||
let id = payload;
|
||||
if (payload && payload.currentTarget) {
|
||||
const i = Number(payload.currentTarget.dataset.index);
|
||||
const item = this.list[i];
|
||||
id = item ? item.id : '';
|
||||
}
|
||||
const url = id ? '/pages_tool/card/contact-edit?id=' + id : '/pages_tool/card/contact-edit';
|
||||
this.$util.redirectTo(url);
|
||||
},
|
||||
switchTab(value) {
|
||||
if (this.source === value) return;
|
||||
this.source = value;
|
||||
this.refresh();
|
||||
},
|
||||
doSearch() {
|
||||
this.refresh();
|
||||
},
|
||||
refresh(done) {
|
||||
this.page = 1;
|
||||
this.loadPage(1, done);
|
||||
},
|
||||
loadPage(pageNo, done) {
|
||||
this.isLoading = true;
|
||||
const data = { page: pageNo, page_size: this.pageSize };
|
||||
// 空字符串 = 全部, 后端对 '' 不加 source 条件
|
||||
if (this.source !== '') {
|
||||
data.source = this.source;
|
||||
}
|
||||
if (this.keyword.trim() !== '') {
|
||||
data.keyword = this.keyword.trim();
|
||||
}
|
||||
this.$cardApi.contactLists({
|
||||
data: data,
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
const d = res.data || {};
|
||||
const rows = d.list || [];
|
||||
this.pageCount = d.page_count || 1;
|
||||
this.page = pageNo;
|
||||
this.list = pageNo === 1 ? rows : this.list.concat(rows);
|
||||
} else {
|
||||
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
|
||||
}
|
||||
this.isLoading = false;
|
||||
typeof done === 'function' && done();
|
||||
},
|
||||
fail: () => {
|
||||
this.isLoading = false;
|
||||
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||
typeof done === 'function' && done();
|
||||
}
|
||||
});
|
||||
},
|
||||
convertToLead(payload) {
|
||||
const item = this.resolveItem(payload);
|
||||
if (!item) return;
|
||||
uni.showModal({
|
||||
title: '转为潜在客户',
|
||||
content: '将「' + (item.name || '') + '」转入潜在客户跟进?人脉记录会保留。',
|
||||
success: res => {
|
||||
if (!res.confirm) return;
|
||||
this.doConvert(item);
|
||||
}
|
||||
});
|
||||
},
|
||||
doConvert(item) {
|
||||
uni.showLoading({ title: '转换中…', mask: true });
|
||||
this.$cardApi.contactToLead({
|
||||
data: { id: item.id },
|
||||
success: res => {
|
||||
uni.hideLoading();
|
||||
if (res.code == 0) {
|
||||
uni.showToast({ title: '已转入潜在客户', icon: 'success' });
|
||||
} else {
|
||||
uni.showToast({ title: res.message || '转换失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||
}
|
||||
});
|
||||
},
|
||||
delContact(payload) {
|
||||
const item = this.resolveItem(payload);
|
||||
if (!item) return;
|
||||
uni.showModal({
|
||||
title: '删除人脉',
|
||||
content: '删除后不可恢复,确定删除「' + (item.name || '') + '」?',
|
||||
confirmColor: '#f5222d',
|
||||
success: res => {
|
||||
if (!res.confirm) return;
|
||||
this.doDelete(item);
|
||||
}
|
||||
});
|
||||
},
|
||||
doDelete(item) {
|
||||
this.$cardApi.contactDelete({
|
||||
data: { id: item.id },
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
this.list = this.list.filter(c => c.id !== item.id);
|
||||
uni.showToast({ title: '已删除', icon: 'success' });
|
||||
} else {
|
||||
uni.showToast({ title: res.message || '删除失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||
}
|
||||
});
|
||||
},
|
||||
callPhone(mobile) {
|
||||
if (!mobile) return;
|
||||
uni.makePhoneCall({ phoneNumber: mobile });
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.contact-page {
|
||||
min-height: 100vh;
|
||||
background-color: #f6f7f9;
|
||||
padding-bottom: 40rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 16rpx 24rpx 0;
|
||||
|
||||
.switch-label {
|
||||
font-size: 26rpx;
|
||||
color: var(--main-color, #0054a5);
|
||||
}
|
||||
|
||||
.switch-arrow {
|
||||
font-size: 26rpx;
|
||||
color: var(--main-color, #0054a5);
|
||||
margin-left: 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16rpx 24rpx;
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 64rpx;
|
||||
line-height: 64rpx;
|
||||
background-color: #ffffff;
|
||||
border-radius: 32rpx;
|
||||
padding: 0 28rpx;
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
font-size: 26rpx;
|
||||
color: #ffffff;
|
||||
background-color: var(--main-color, #0054a5);
|
||||
border-radius: 32rpx;
|
||||
padding: 12rpx 28rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
background-color: #ffffff;
|
||||
white-space: nowrap;
|
||||
|
||||
.tab-inner {
|
||||
display: inline-flex;
|
||||
padding: 0 12rpx;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: inline-block;
|
||||
padding: 24rpx 28rpx;
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
position: relative;
|
||||
|
||||
&.active {
|
||||
color: var(--main-color, #0054a5);
|
||||
font-weight: 600;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: 10rpx;
|
||||
width: 40rpx;
|
||||
height: 6rpx;
|
||||
border-radius: 3rpx;
|
||||
background-color: var(--main-color, #0054a5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contact-list {
|
||||
padding: 20rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.contact-item {
|
||||
background-color: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
.item-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.item-name {
|
||||
flex: 1;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.item-source {
|
||||
font-size: 22rpx;
|
||||
padding: 6rpx 18rpx;
|
||||
border-radius: 22rpx;
|
||||
|
||||
&.src-manual {
|
||||
color: var(--main-color, #0054a5);
|
||||
background-color: var(--main-color-shallow, #e8f1fb);
|
||||
}
|
||||
|
||||
&.src-lead_in {
|
||||
color: #1daa39;
|
||||
background-color: #e8f8ea;
|
||||
}
|
||||
|
||||
&.src-scan,
|
||||
&.src-exchange {
|
||||
color: #999;
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item-company {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
.item-mobile {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
|
||||
.item-remark {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
margin-top: 12rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20rpx;
|
||||
padding-top: 20rpx;
|
||||
border-top: 1rpx solid #f0f0f0;
|
||||
|
||||
.action-btn {
|
||||
font-size: 24rpx;
|
||||
border-radius: 30rpx;
|
||||
padding: 10rpx 26rpx;
|
||||
margin-left: 16rpx;
|
||||
|
||||
&.plain {
|
||||
color: #666;
|
||||
border: 1rpx solid #ddd;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
color: #ffffff;
|
||||
background-color: var(--main-color, #0054a5);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #f5222d;
|
||||
border: 1rpx solid #f5222d;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.load-more {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 24rpx;
|
||||
padding: 24rpx 0;
|
||||
|
||||
.load-end {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1426
pages_tool/card/detail.vue
Normal file
1426
pages_tool/card/detail.vue
Normal file
File diff suppressed because it is too large
Load Diff
585
pages_tool/card/edit.vue
Normal file
585
pages_tool/card/edit.vue
Normal file
@@ -0,0 +1,585 @@
|
||||
<template>
|
||||
<view class="card-edit">
|
||||
<!-- 基本信息 -->
|
||||
<view class="form-group">
|
||||
<view class="group-title">基本信息</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">头像</view>
|
||||
<view class="avatar-box" @tap="chooseAvatar">
|
||||
<image v-if="form.avatar" :src="$util.img(form.avatar)" class="avatar-img" mode="aspectFill" />
|
||||
<text v-else class="avatar-plus">+</text>
|
||||
</view>
|
||||
<view class="avatar-tip">点击从相册选择</view>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label"><text class="req-star">*</text>姓名</view>
|
||||
<input class="row-input" :value="form.name" placeholder="请输入姓名"
|
||||
@input="e => form.name = e.detail.value" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">公司</view>
|
||||
<input class="row-input" :value="form.company" placeholder="请输入公司名称"
|
||||
@input="e => form.company = e.detail.value" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">职位</view>
|
||||
<input class="row-input" :value="form.job_title" placeholder="请输入职位"
|
||||
@input="e => form.job_title = e.detail.value" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 联系方式 -->
|
||||
<view class="form-group">
|
||||
<view class="group-title">联系方式</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">手机</view>
|
||||
<input class="row-input" type="number" :value="form.mobile" placeholder="请输入手机号"
|
||||
@input="e => form.mobile = e.detail.value" />
|
||||
</view>
|
||||
<!-- 附加手机号(动态行, 与主手机号并列提交 mobiles) -->
|
||||
<view v-for="(m, i) in mobiles" :key="'mob' + i" class="form-row dyn-row">
|
||||
<view class="row-label">手机{{ i + 2 }}</view>
|
||||
<input class="row-input" type="number" :value="m" placeholder="其他手机号"
|
||||
@input="e => onMobileInput(i, e)" />
|
||||
<text class="dyn-del" @tap="delMobile(i)">×</text>
|
||||
</view>
|
||||
<view class="form-row" v-if="mobiles.length < MAX_MOBILES">
|
||||
<text class="dyn-add" @tap="addMobile">+ 添加手机号(最多{{ MAX_MOBILES }}个)</text>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">电话</view>
|
||||
<input class="row-input" :value="form.phone" placeholder="固定电话(选填)"
|
||||
@input="e => form.phone = e.detail.value" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">邮箱</view>
|
||||
<input class="row-input" :value="form.email" placeholder="邮箱(选填)"
|
||||
@input="e => form.email = e.detail.value" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">网站</view>
|
||||
<input class="row-input" :value="form.website" placeholder="个人主页 / 官网(选填)"
|
||||
@input="e => form.website = e.detail.value" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">微信号</view>
|
||||
<input class="row-input" :value="form.wechat" placeholder="微信号(选填)"
|
||||
@input="e => form.wechat = e.detail.value" />
|
||||
</view>
|
||||
<!-- Telegram / Messenger 已于 2026-10-13 软下线为独立字段, 统一在下方「社交媒体账号」添加 -->
|
||||
</view>
|
||||
|
||||
<!-- 社交媒体账号 -->
|
||||
<view class="form-group">
|
||||
<view class="group-title">社交媒体账号</view>
|
||||
<view v-for="(s, i) in socials" :key="'soc' + i" class="form-row dyn-row">
|
||||
<picker class="dyn-picker" :range="socialTypeLabels" :value="socialTypeIndex(s.type)"
|
||||
@change="e => onSocialTypeChange(i, e)">
|
||||
<view class="picker-text">{{ socialTypeLabel(s.type) }} ▾</view>
|
||||
</picker>
|
||||
<input class="row-input" :value="s.value" placeholder="账号 / 链接"
|
||||
@input="e => onSocialValueInput(i, e)" />
|
||||
<text class="dyn-del" @tap="delSocial(i)">×</text>
|
||||
</view>
|
||||
<view class="form-row" v-if="socials.length < MAX_SOCIALS">
|
||||
<text class="dyn-add" @tap="addSocial">+ 添加社交媒体(最多{{ MAX_SOCIALS }}个)</text>
|
||||
</view>
|
||||
<view class="group-tip" v-if="socials.length === 0">还没有社交媒体账号;Telegram / Messenger 也已并入此处添加</view>
|
||||
</view>
|
||||
|
||||
<!-- 自定义字段 -->
|
||||
<view class="form-group">
|
||||
<view class="group-title">自定义字段</view>
|
||||
<view v-for="(c, i) in customFields" :key="'cst' + i" class="form-row dyn-row">
|
||||
<input class="cf-label" :value="c.label" placeholder="名称"
|
||||
@input="e => onCustomLabelInput(i, e)" />
|
||||
<input class="row-input" :value="c.value" placeholder="内容"
|
||||
@input="e => onCustomValueInput(i, e)" />
|
||||
<text class="dyn-del" @tap="delCustom(i)">×</text>
|
||||
</view>
|
||||
<view class="form-row" v-if="customFields.length < MAX_CUSTOM">
|
||||
<text class="dyn-add" @tap="addCustom">+ 添加自定义字段(最多{{ MAX_CUSTOM }}个)</text>
|
||||
</view>
|
||||
<view class="group-tip" v-if="customFields.length === 0">如「展厅号 E3-108」「收款方式」等个性信息</view>
|
||||
</view>
|
||||
|
||||
<!-- 地址 -->
|
||||
<view class="form-group">
|
||||
<view class="group-title">地址</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">国家</view>
|
||||
<input class="row-input" :value="form.country" placeholder="选填"
|
||||
@input="e => form.country = e.detail.value" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">省份</view>
|
||||
<input class="row-input" :value="form.province" placeholder="选填"
|
||||
@input="e => form.province = e.detail.value" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">城市</view>
|
||||
<input class="row-input" :value="form.city" placeholder="选填"
|
||||
@input="e => form.city = e.detail.value" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">详细地址</view>
|
||||
<input class="row-input" :value="form.address" placeholder="街道门牌等"
|
||||
@input="e => form.address = e.detail.value" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label">邮编</view>
|
||||
<input class="row-input" :value="form.postal_code" placeholder="选填"
|
||||
@input="e => form.postal_code = e.detail.value" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 个人简介 -->
|
||||
<view class="form-group">
|
||||
<view class="group-title">个人简介</view>
|
||||
<view class="form-row bio-row">
|
||||
<textarea class="bio-input" :value="form.bio" placeholder="介绍一下自己…(保留换行)"
|
||||
@input="e => form.bio = e.detail.value" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 保存 -->
|
||||
<view class="save-bar">
|
||||
<button class="save-btn" :disabled="saving" @tap="save">{{ saving ? '保存中…' : '保存' }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 与后端 BusinessCardFieldNormalizer 输出对齐的标量字段(含 2026-10 补的 bio)。
|
||||
* ⚠️ 2026-10-13: telegram / messenger 软下线为独立标量(传了也被后端忽略),
|
||||
* 统一走下方社交媒体动态行; 旧数据读取时已由后端合并进 socials_list。
|
||||
* 富化字段(mobiles/socials/custom_fields)由页面动态行单独收集,
|
||||
* 后端 Card::update 的 richFieldSaves 仅对显式携带的键做 CardRichFields 清洗。
|
||||
*
|
||||
* SOCIAL_TYPES / CARD_FIELDS / RICH_FIELD_LIMITS 统一从收口层导入,
|
||||
* 与后端白名单单点同步(见 common/js/card-api.js)。
|
||||
*/
|
||||
import { SOCIAL_TYPES, CARD_FIELDS, RICH_FIELD_LIMITS } from '@/common/js/card-api.js';
|
||||
|
||||
// 注:telegram/messenger 已软下线(2026-10-13), 不再作为独立标量字段,
|
||||
// 统一在「社交媒体账号」动态行里以 SOCIAL_TYPES 中的类型方式添加。
|
||||
|
||||
// 上限与后端 CardRichFields::MAX_* 一致
|
||||
const MAX_MOBILES = RICH_FIELD_LIMITS.MAX_MOBILES;
|
||||
const MAX_SOCIALS = RICH_FIELD_LIMITS.MAX_SOCIALS;
|
||||
const MAX_CUSTOM = RICH_FIELD_LIMITS.MAX_CUSTOM;
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
cardId: '',
|
||||
// 新建模式: 无 card_id 进入, 提交 /card/api/card/create
|
||||
isCreate: false,
|
||||
form: {
|
||||
name: '', company: '', job_title: '', avatar: '',
|
||||
mobile: '', phone: '', email: '', website: '', wechat: '',
|
||||
country: '', province: '', city: '', address: '', postal_code: '',
|
||||
bio: ''
|
||||
},
|
||||
mobiles: [],
|
||||
socials: [],
|
||||
customFields: [],
|
||||
socialTypeLabels: SOCIAL_TYPES.map(t => t.label),
|
||||
MAX_MOBILES,
|
||||
MAX_SOCIALS,
|
||||
MAX_CUSTOM,
|
||||
isLoading: false,
|
||||
saving: false
|
||||
};
|
||||
},
|
||||
onLoad(options) {
|
||||
this.cardId = decodeURIComponent(options.card_id || '');
|
||||
this.isCreate = this.cardId === '';
|
||||
if (this.isCreate) {
|
||||
uni.setNavigationBarTitle({ title: '名片' });
|
||||
} else {
|
||||
this.loadDetail();
|
||||
}
|
||||
},
|
||||
onReady() {
|
||||
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||
uni.setNavigationBarTitle({ title: '名片' });
|
||||
// #ifdef H5
|
||||
document.title = '名片';
|
||||
// #endif
|
||||
},
|
||||
methods: {
|
||||
loadDetail() {
|
||||
if (!this.cardId) {
|
||||
uni.showToast({ title: '缺少名片编号', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
this.isLoading = true;
|
||||
this.$cardApi.cardDetail({
|
||||
data: { card_id: this.cardId },
|
||||
success: res => {
|
||||
this.isLoading = false;
|
||||
if (res.code == 0) {
|
||||
const r = (res.data && res.data.relation) || {};
|
||||
CARD_FIELDS.forEach(k => {
|
||||
if (r[k] !== undefined && r[k] !== null) {
|
||||
this.form[k] = String(r[k]);
|
||||
}
|
||||
});
|
||||
// 富化字段: detail 由后端 CardRichFields 解码后挂在 relation 上
|
||||
// (注意是 res.data.relation.mobiles_list, 不是 res.data.mobiles_list)
|
||||
this.mobiles = (r.mobiles_list || []).map(v => String(v || ''));
|
||||
this.socials = (r.socials_list || []).map(s => ({
|
||||
type: s.type || 'other',
|
||||
value: String(s.value || '')
|
||||
}));
|
||||
this.customFields = (r.custom_list || []).map(c => ({
|
||||
label: String(c.label || ''),
|
||||
value: String(c.value || '')
|
||||
}));
|
||||
} else {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: res.message || '卡片不存在',
|
||||
showCancel: false,
|
||||
success: () => uni.navigateBack()
|
||||
});
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
this.isLoading = false;
|
||||
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/* ---------- 头像: 相册选择 → 系统上传 → 回填路径 ---------- */
|
||||
chooseAvatar() {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
success: res => {
|
||||
const filePath = res.tempFilePaths && res.tempFilePaths[0];
|
||||
if (!filePath) return;
|
||||
uni.showLoading({ title: '上传中…', mask: true });
|
||||
this.$api.upload({
|
||||
url: '/api/upload/headimg',
|
||||
filePath: filePath,
|
||||
fileType: 'image',
|
||||
success: r => {
|
||||
uni.hideLoading();
|
||||
if (r && r.code == 0 && r.data && r.data.pic_path) {
|
||||
this.form.avatar = r.data.pic_path;
|
||||
} else {
|
||||
uni.showToast({ title: (r && r.message) || '上传失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: '上传失败', icon: 'none' });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/* ---------- 附加手机号 ---------- */
|
||||
onMobileInput(i, e) {
|
||||
this.$set(this.mobiles, i, e.detail.value);
|
||||
},
|
||||
addMobile() {
|
||||
if (this.mobiles.length >= MAX_MOBILES) {
|
||||
uni.showToast({ title: '最多 ' + MAX_MOBILES + ' 个手机号', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
this.mobiles.push('');
|
||||
},
|
||||
delMobile(i) {
|
||||
this.mobiles.splice(i, 1);
|
||||
},
|
||||
|
||||
/* ---------- 社交媒体 ---------- */
|
||||
socialTypeLabel(key) {
|
||||
const t = SOCIAL_TYPES.find(x => x.key === key);
|
||||
return t ? t.label : '其他';
|
||||
},
|
||||
socialTypeIndex(key) {
|
||||
const i = SOCIAL_TYPES.findIndex(x => x.key === key);
|
||||
return i < 0 ? SOCIAL_TYPES.length - 1 : i;
|
||||
},
|
||||
onSocialTypeChange(i, e) {
|
||||
const idx = Number(e.detail.value);
|
||||
const t = SOCIAL_TYPES[idx];
|
||||
if (t) this.$set(this.socials, i, { type: t.key, value: this.socials[i].value });
|
||||
},
|
||||
onSocialValueInput(i, e) {
|
||||
const s = this.socials[i];
|
||||
this.$set(this.socials, i, { type: s.type, value: e.detail.value });
|
||||
},
|
||||
addSocial() {
|
||||
if (this.socials.length >= MAX_SOCIALS) {
|
||||
uni.showToast({ title: '最多 ' + MAX_SOCIALS + ' 个社交媒体', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
this.socials.push({ type: 'other', value: '' });
|
||||
},
|
||||
delSocial(i) {
|
||||
this.socials.splice(i, 1);
|
||||
},
|
||||
|
||||
/* ---------- 自定义字段 ---------- */
|
||||
onCustomLabelInput(i, e) {
|
||||
const c = this.customFields[i];
|
||||
this.$set(this.customFields, i, { label: e.detail.value, value: c.value });
|
||||
},
|
||||
onCustomValueInput(i, e) {
|
||||
const c = this.customFields[i];
|
||||
this.$set(this.customFields, i, { label: c.label, value: e.detail.value });
|
||||
},
|
||||
addCustom() {
|
||||
if (this.customFields.length >= MAX_CUSTOM) {
|
||||
uni.showToast({ title: '最多 ' + MAX_CUSTOM + ' 个自定义字段', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
this.customFields.push({ label: '', value: '' });
|
||||
},
|
||||
delCustom(i) {
|
||||
this.customFields.splice(i, 1);
|
||||
},
|
||||
|
||||
/* ---------- 保存 ---------- */
|
||||
save() {
|
||||
if (this.saving) return;
|
||||
if (!this.form.name.trim()) {
|
||||
uni.showToast({ title: '请填写姓名', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
|
||||
// 标量全量提交(空值=清空, 与后端 normalizer 语义一致);
|
||||
// 富化字段始终显式携带(空数组=清空), 后端 richFieldSaves 只对携带的键清洗落库。
|
||||
// 走 sendJson(JSON body): 嵌套数组序列化跨端零歧义, TP6 原生解析
|
||||
const fields = {};
|
||||
CARD_FIELDS.forEach(k => { fields[k] = this.form[k]; });
|
||||
fields.mobiles = this.mobiles.map(v => String(v || '').trim());
|
||||
fields.socials = this.socials.map(s => ({ type: s.type, value: String(s.value || '').trim() }));
|
||||
fields.custom_fields = this.customFields.map(c => ({
|
||||
label: String(c.label || '').trim(),
|
||||
value: String(c.value || '').trim()
|
||||
}));
|
||||
|
||||
// 新建模式走 card/create, 编辑模式走 card/update(均经 $cardApi 收口层)
|
||||
// ⚠️ 必须包在 data 里: sendJson 只合并 params.data(见 common/js/http.js),
|
||||
// 顶层直接挂 fields 会被丢弃 → 后端收不到 name, 报「请填写姓名」
|
||||
const payload = this.isCreate
|
||||
? { fields: fields }
|
||||
: { card_id: this.cardId, fields: fields };
|
||||
(this.isCreate ? this.$cardApi.cardCreate : this.$cardApi.cardUpdate)({
|
||||
data: payload,
|
||||
success: res => {
|
||||
this.saving = false;
|
||||
if (res.code == 0) {
|
||||
uni.showToast({ title: this.isCreate ? '创建成功' : '保存成功', icon: 'success' });
|
||||
if (this.isCreate) {
|
||||
// 新建成功 → 跳详情页(redirectTo 替换当前编辑页, 返回栈回到列表)
|
||||
const newCardId = (res.data && res.data.card_id) || '';
|
||||
setTimeout(() => {
|
||||
if (newCardId) {
|
||||
uni.redirectTo({
|
||||
url: '/pages_tool/card/detail?card_id=' + encodeURIComponent(newCardId)
|
||||
});
|
||||
} else {
|
||||
uni.navigateBack();
|
||||
}
|
||||
}, 600);
|
||||
} else {
|
||||
// 返回详情页; detail.vue 的 onShow 会自动刷新
|
||||
setTimeout(() => uni.navigateBack(), 600);
|
||||
}
|
||||
} else {
|
||||
uni.showToast({ title: res.message || '保存失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
this.saving = false;
|
||||
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card-edit {
|
||||
min-height: 100vh;
|
||||
background-color: #f6f7f9;
|
||||
padding: 24rpx 24rpx 60rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
background-color: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
overflow: hidden;
|
||||
|
||||
.group-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
padding: 24rpx 24rpx 8rpx;
|
||||
}
|
||||
|
||||
.group-tip {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
padding: 0 24rpx 20rpx;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.row-label {
|
||||
width: 160rpx;
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 必填红星 */
|
||||
.req-star {
|
||||
color: #f5222d;
|
||||
margin-right: 4rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.row-input {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 头像行 */
|
||||
.avatar-box {
|
||||
width: 108rpx;
|
||||
height: 108rpx;
|
||||
border-radius: 12rpx;
|
||||
background-color: #f2f3f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.avatar-plus {
|
||||
font-size: 44rpx;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-tip {
|
||||
margin-left: 20rpx;
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 动态行(附加手机号/社交媒体/自定义字段) */
|
||||
.dyn-row {
|
||||
.row-label {
|
||||
width: 160rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.dyn-del {
|
||||
width: 44rpx;
|
||||
text-align: center;
|
||||
font-size: 34rpx;
|
||||
color: #c0c4cc;
|
||||
flex-shrink: 0;
|
||||
padding: 6rpx 0 6rpx 12rpx;
|
||||
}
|
||||
|
||||
.dyn-add {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: var(--main-color, #0054a5);
|
||||
}
|
||||
|
||||
.dyn-picker {
|
||||
flex-shrink: 0;
|
||||
width: 170rpx;
|
||||
height: 44rpx;
|
||||
line-height: 44rpx;
|
||||
border: 1rpx solid #dcdfe6;
|
||||
border-radius: 8rpx;
|
||||
margin-right: 16rpx;
|
||||
|
||||
.picker-text {
|
||||
font-size: 24rpx;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.cf-label {
|
||||
width: 170rpx;
|
||||
flex-shrink: 0;
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
background-color: #f6f7f9;
|
||||
border-radius: 8rpx;
|
||||
padding: 10rpx 16rpx;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
/* 简介 */
|
||||
.bio-row {
|
||||
align-items: flex-start;
|
||||
|
||||
.bio-input {
|
||||
width: 100%;
|
||||
min-height: 160rpx;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.save-bar {
|
||||
padding: 20rpx 0 0;
|
||||
|
||||
.save-btn {
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
background-color: var(--main-color, #0054a5);
|
||||
color: #ffffff;
|
||||
font-size: 30rpx;
|
||||
border-radius: 44rpx;
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.6;
|
||||
color: #ffffff;
|
||||
background-color: var(--main-color, #0054a5);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
830
pages_tool/card/enterprise.vue
Normal file
830
pages_tool/card/enterprise.vue
Normal file
@@ -0,0 +1,830 @@
|
||||
<template>
|
||||
<view class="ent-card" :style="[designVars, { fontFamily: ds.fontFamily }]">
|
||||
<view class="page-layer">
|
||||
<!-- 顶部英雄区: 蓝色渐变圆角卡(对齐后端 wap 参考图) -->
|
||||
<view class="hero" v-if="headerSrc || staffList.length">
|
||||
<view class="hero-pill">
|
||||
<view class="hero-pill-dot"></view>
|
||||
<text>数字名片</text>
|
||||
</view>
|
||||
|
||||
<!-- staff 模式: 员工名片轮播 -->
|
||||
<swiper v-if="headerMode === 'staff' && staffList.length" class="hero-swiper"
|
||||
:indicator-dots="staffList.length > 1" indicator-active-color="rgba(255,255,255,0.9)"
|
||||
indicator-color="rgba(255,255,255,0.4)" :autoplay="staffList.length > 1" circular
|
||||
@change="onStaffChange">
|
||||
<swiper-item v-for="(s, i) in staffList" :key="i">
|
||||
<view class="hero-id">
|
||||
<image v-if="s.avatar" class="hi-avatar" :src="$util.img(s.avatar)" mode="aspectFill" />
|
||||
<view v-else class="hi-avatar hi-avatar-ph">
|
||||
<text class="hi-avatar-char">企</text>
|
||||
</view>
|
||||
<view class="hi-info">
|
||||
<view class="hi-name">{{ s.name }}</view>
|
||||
<view class="hi-job" v-if="s.job_title">{{ s.job_title }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
|
||||
<!-- official / self: 单人信息 -->
|
||||
<view v-else class="hero-id">
|
||||
<image v-if="headerSrc && headerSrc.avatar" class="hi-avatar" :src="$util.img(headerSrc.avatar)" mode="aspectFill" />
|
||||
<view v-else class="hi-avatar hi-avatar-ph">
|
||||
<text class="hi-avatar-char">企</text>
|
||||
</view>
|
||||
<view class="hi-info">
|
||||
<view class="hi-name">{{ headerSrc.name || '企业名片' }}</view>
|
||||
<view class="hi-job" v-if="headerSrc.job_title">{{ headerSrc.job_title }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 快捷操作条: 压在英雄卡下沿(参考图: 打电话/发邮件/官方网站/存通讯录) -->
|
||||
<view class="quick" v-if="headerSrc">
|
||||
<view class="q-item" @tap="onQuickCall">
|
||||
<view class="q-icon q-icon-blue">
|
||||
<text class="iconfont icon-dianhua"></text>
|
||||
</view>
|
||||
<text class="q-label">打电话</text>
|
||||
</view>
|
||||
<view class="q-item" @tap="onQuickEmail">
|
||||
<view class="q-icon q-icon-cyan">
|
||||
<text class="q-char">@</text>
|
||||
</view>
|
||||
<text class="q-label">发邮件</text>
|
||||
</view>
|
||||
<view class="q-item" @tap="onQuickWebsite">
|
||||
<view class="q-icon q-icon-blue">
|
||||
<text class="q-char">网</text>
|
||||
</view>
|
||||
<text class="q-label">官方网站</text>
|
||||
</view>
|
||||
<view class="q-item" @tap="onQuickSave">
|
||||
<view class="q-icon q-icon-orange">
|
||||
<text class="iconfont icon-yonghu"></text>
|
||||
</view>
|
||||
<text class="q-label">存通讯录</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 企业简介 -->
|
||||
<view class="bio" v-if="fields.bio" :style="{ backgroundColor: ds.bgColor, borderRadius: ds.cardRadius + 'px' }">
|
||||
<text class="bio-text" :style="{ color: ds.textColor }">{{ fields.bio }}</text>
|
||||
<text class="bio-en" v-if="fields.bio_en" :style="{ color: ds.textSoft }">{{ fields.bio_en }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 联系资料(企业字段) -->
|
||||
<view class="contact" v-if="contentRows.length" :style="{ backgroundColor: ds.bgColor, borderRadius: ds.cardRadius + 'px' }">
|
||||
<view class="ct-title" :style="{ borderLeftColor: ds.themeColor }">联系资料</view>
|
||||
<view v-for="(row, idx) in contentRows" :key="idx" class="ct-row">
|
||||
<view class="ct-icon" :style="{ backgroundColor: ds.themeSoft }">
|
||||
<text v-if="row.icon" class="iconfont" :class="row.icon" :style="{ color: ds.themeColor }"></text>
|
||||
<text v-else class="ct-char" :style="{ color: ds.themeColor }">{{ row.label.slice(0, 1) }}</text>
|
||||
</view>
|
||||
<text class="ct-label" :style="{ color: ds.textSoft }">{{ row.label }}</text>
|
||||
<text class="ct-value" :class="{ 'ct-multi': row.multiline }" :style="{ color: ds.textColor }">{{ row.value }}</text>
|
||||
<text v-if="row.copyable" class="ct-copy" @tap="copyText(row.value)">复制</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- self 模式未自选提示 -->
|
||||
<view class="self-hint" v-if="headerMode === 'self' && headerSelf && headerSelf.chosen === 0" @tap="openChooseSelf">
|
||||
<text class="sh-icon iconfont icon-gonggao" :style="{ color: ds.themeColor }"></text>
|
||||
<text class="sh-text">你还没有选定展示名片,当前自动取最新一张</text>
|
||||
<text class="sh-btn" :style="{ color: ds.themeColor }">选择</text>
|
||||
</view>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<view class="ent-loading" v-if="isLoading">加载中…</view>
|
||||
|
||||
<!-- 不存在 / 无权查看 -->
|
||||
<ns-empty v-if="!isLoading && !headerSrc && !staffList.length" :isIndex="false"
|
||||
:text="errorMsg || '企业名片不存在或未生效,请在管理端完善资料并设为生效'"></ns-empty>
|
||||
</view>
|
||||
|
||||
<!-- 底部操作条(参考图: 保存到通讯录 + 分享; 收藏/交换名片为企业卡无对应后端能力, 暂不提供) -->
|
||||
<view class="bottom-bar" v-if="headerSrc">
|
||||
<view class="bb-btn bb-main" @tap="onQuickSave">保存到通讯录</view>
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<button class="bb-btn bb-share" open-type="share">分享</button>
|
||||
<!-- #endif -->
|
||||
<!-- #ifdef H5 || APP-PLUS || MP-HARMONY -->
|
||||
<view class="bb-btn bb-share" @tap="openShare">分享</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
|
||||
<!-- 员工自选展示名片弹层 -->
|
||||
<view class="choose-mask" v-if="showChoose" @tap="closeChoose"></view>
|
||||
<view class="choose-sheet" :class="{ 'choose-show': showChoose }">
|
||||
<view class="cs-bar"></view>
|
||||
<view class="cs-title">选择展示名片</view>
|
||||
<view class="cs-list">
|
||||
<view v-for="(c, idx) in myCards" :key="idx" class="cs-item" @tap="chooseSelf(c)">
|
||||
<view class="cs-avatar" :style="{ backgroundColor: ds.themeColor }">{{ (c.name || '?').slice(0, 1) }}</view>
|
||||
<view class="cs-info">
|
||||
<view class="cs-name">{{ c.name }}</view>
|
||||
<view class="cs-sub" v-if="c.job_title || c.company">{{ [c.job_title, c.company].filter(Boolean).join(' · ') }}</view>
|
||||
</view>
|
||||
<text v-if="headerSelf && headerSelf.card_id === c.card_id" class="cs-chosen iconfont icon-dui" :style="{ color: ds.themeColor }"></text>
|
||||
</view>
|
||||
<view class="cs-empty" v-if="!myCards.length">你还没有个人名片</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
function mixHex(a, b, t) {
|
||||
const toRgb = h => {
|
||||
h = (h || '').replace('#', '');
|
||||
if (h.length === 3) h = h.split('').map(c => c + c).join('');
|
||||
const n = parseInt(h, 16);
|
||||
if (isNaN(n)) return [0, 0, 0];
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
};
|
||||
const [r1, g1, b1] = toRgb(a);
|
||||
const [r2, g2, b2] = toRgb(b);
|
||||
const r = Math.round(r1 + (r2 - r1) * t);
|
||||
const g = Math.round(g1 + (g2 - g1) * t);
|
||||
const bl = Math.round(b1 + (b2 - b1) * t);
|
||||
return '#' + [r, g, bl].map(x => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
cardId: '',
|
||||
isLoading: true,
|
||||
errorMsg: '',
|
||||
data: null, // 企业名片详情(后端 ENTERPRISE_UNIAPP_GUIDE §三)
|
||||
header: null, // {mode,official_card_id,official,self,staff}
|
||||
fields: {}, // 企业信息(内容区)
|
||||
design: null, // {theme_color,bg_color,text_color,template_id,canvas,header_mode,official_card_id}
|
||||
staffIndex: 0, // staff 轮播当前项(存通讯录按当前展示的员工取字段)
|
||||
showChoose: false,
|
||||
myCards: []
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
headerMode() {
|
||||
return (this.header && this.header.mode) || 'official';
|
||||
},
|
||||
headerSrc() {
|
||||
const h = this.header || {};
|
||||
let src;
|
||||
if (this.headerMode === 'official') src = h.official || this.fields;
|
||||
else if (this.headerMode === 'self') src = h.self || this.fields;
|
||||
else return null; // staff 走轮播
|
||||
// 注意: 空对象 {} 在 JS 里是真值, 必须显式判空,
|
||||
// 否则后端返回空数据时会渲染出"有壳没内容"的空页面且空态提示也不出现
|
||||
return src && Object.keys(src).length ? src : null;
|
||||
},
|
||||
staffList() {
|
||||
return (this.header && this.header.staff) || [];
|
||||
},
|
||||
staffCurrent() {
|
||||
return this.staffList[this.staffIndex] || null;
|
||||
},
|
||||
headerSelf() {
|
||||
return (this.header && this.header.self) || null;
|
||||
},
|
||||
// 名片区当前展示的那个人(official/self 单人 或 staff 当前轮播项)
|
||||
displayPerson() {
|
||||
if (this.headerMode === 'staff') return this.staffCurrent || {};
|
||||
return this.headerSrc || {};
|
||||
},
|
||||
contentRows() {
|
||||
const f = this.fields || {};
|
||||
const rows = [];
|
||||
const push = (label, value, opts) => {
|
||||
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
||||
rows.push(Object.assign({ label, value: String(value) }, opts || {}));
|
||||
}
|
||||
};
|
||||
push('手机', f.mobile, { icon: 'icon-shouji1', copyable: true });
|
||||
push('邮箱', f.email, { copyable: true });
|
||||
push('网站', f.website, { copyable: true });
|
||||
push('地址', f.address, { icon: 'icon-dizhi' });
|
||||
push('企业名称', f.name);
|
||||
return rows;
|
||||
},
|
||||
/* ============ design 归一化(与 detail.vue 同源, 仅做有限主题化) ============ */
|
||||
ds() {
|
||||
const d = this.design || {};
|
||||
const FONTS = {
|
||||
system: '-apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",Arial,sans-serif',
|
||||
sans: '"Source Han Sans SC","Noto Sans SC","PingFang SC",Helvetica,Arial,sans-serif',
|
||||
serif: '"Source Han Serif SC","Songti SC",SimSun,Georgia,serif',
|
||||
rounded: '"Yuanti SC","YouYuan","Quicksand",-apple-system,sans-serif'
|
||||
};
|
||||
const isHex = v => /^#[0-9a-fA-F]{3,8}$/.test(v || '');
|
||||
const theme = isHex(d.theme_color) ? d.theme_color : '#119dff';
|
||||
const text = isHex(d.text_color) ? d.text_color : '#1f2329';
|
||||
const bg = isHex(d.bg_color) ? d.bg_color : '#ffffff';
|
||||
const fontKey = FONTS[d.font_family] ? d.font_family : 'system';
|
||||
const fontScale = Math.min(160, Math.max(60, Number(d.font_scale) || 100));
|
||||
return {
|
||||
themeColor: theme,
|
||||
textColor: text,
|
||||
bgColor: bg,
|
||||
fontFamily: FONTS[fontKey],
|
||||
cardRadius: Number(d.card_radius) || 16,
|
||||
fontScaleNum: fontScale / 100,
|
||||
textSoft: mixHex(text, bg, 0.55),
|
||||
themeSoft: mixHex(theme, '#ffffff', 0.85),
|
||||
themeBorder: mixHex(theme, '#ffffff', 0.55),
|
||||
headerGradientEnd: typeof d.header_gradient_end === 'string' ? d.header_gradient_end : ''
|
||||
};
|
||||
},
|
||||
designVars() {
|
||||
const s = this.ds;
|
||||
return {
|
||||
'--card-theme': s.themeColor,
|
||||
'--card-theme-end': s.headerGradientEnd || s.themeColor,
|
||||
'--card-theme-soft': s.themeSoft,
|
||||
'--card-theme-border': s.themeBorder,
|
||||
'--card-text': s.textColor,
|
||||
'--card-text-soft': s.textSoft,
|
||||
'--card-bg': s.bgColor,
|
||||
'--card-radius': s.cardRadius + 'px',
|
||||
'--card-font': s.fontFamily,
|
||||
'--card-font-scale': s.fontScaleNum
|
||||
};
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
this.cardId = (options && options.card_id) ? decodeURIComponent(options.card_id) : '';
|
||||
this.loadData();
|
||||
},
|
||||
onReady() {
|
||||
uni.setNavigationBarTitle({ title: '企业名片' });
|
||||
},
|
||||
onShow() {
|
||||
// 每次显示都兜底标题(微信返回本页时可能恢复成旧值); 从自选弹层返回也刷新
|
||||
uni.setNavigationBarTitle({ title: '企业名片' });
|
||||
// #ifdef H5
|
||||
document.title = '企业名片';
|
||||
// #endif
|
||||
if (this._loadedOnce) this.loadData();
|
||||
this._loadedOnce = true;
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
this.loadData();
|
||||
setTimeout(() => uni.stopPullDownRefresh(), 300);
|
||||
},
|
||||
onShareAppMessage() {
|
||||
const name = (this.fields && this.fields.name) || '企业名片';
|
||||
return {
|
||||
title: name + ' - 企业名片',
|
||||
path: '/pages_tool/card/enterprise' + (this.cardId ? '?card_id=' + encodeURIComponent(this.cardId) : ''),
|
||||
imageUrl: this.displayPerson && this.displayPerson.avatar ? this.$util.img(this.displayPerson.avatar) : ''
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
loadData(retry) {
|
||||
this.isLoading = true;
|
||||
this.$cardApi.enterpriseDetail({
|
||||
data: this.cardId ? { card_id: this.cardId } : {},
|
||||
success: res => {
|
||||
// 输出原始响应, 便于真机排查"刷新后数据变空"类问题(控制台看后端到底回了什么)
|
||||
console.log('[enterprise] detail resp', this.cardId || '(无card_id)', res);
|
||||
if (res.code == 0) {
|
||||
const d = res.data || {};
|
||||
const emptyCard = !d.header && (!d.fields || !Object.keys(d.fields).length);
|
||||
if (emptyCard && (this.header || Object.keys(this.fields).length)) {
|
||||
// 已有数据时刷新却拿到空卡(多为后端"当前生效卡"与所查卡不一致):
|
||||
// 保留原数据, 避免"刷新一下内容就没了"
|
||||
console.warn('[enterprise] 刷新返回空卡, 保留原数据', d);
|
||||
} else {
|
||||
this.data = d;
|
||||
this.header = d.header || null;
|
||||
this.fields = d.fields || {};
|
||||
this.design = d.design || null;
|
||||
}
|
||||
this.errorMsg = '';
|
||||
this.isLoading = false;
|
||||
} else if (res.code == -1 && !retry) {
|
||||
// -1 = 尚未登录: 冷启动/刚返回本页时 store 的 token 可能还没恢复完成,
|
||||
// 属瞬时态, 静默重试一次再决定是否展示错误(与 detail.vue 同策略)
|
||||
setTimeout(() => this.loadData(true), 800);
|
||||
} else {
|
||||
// 登录类错误(-1/-10009 等)不展示"去登录"死路:
|
||||
// 本页是客户视角分享页, 按后端对接文档 §五 游客也应可看(待后端放开),
|
||||
// 这里给中性可重试文案; 其余错误原样带出
|
||||
const msg = res.message || '';
|
||||
const isLoginErr = res.code == -1 || /登录/.test(msg);
|
||||
this.errorMsg = isLoginErr ? '名片加载失败,请下拉重试' : (msg || '加载失败');
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
// 失败时保留原数据(不清空), 仅在首次无数据时展示错误
|
||||
if (!this.header && !Object.keys(this.fields).length) {
|
||||
this.errorMsg = '网络异常,请下拉重试';
|
||||
}
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
onStaffChange(e) {
|
||||
this.staffIndex = (e.detail && e.detail.current) || 0;
|
||||
},
|
||||
copyText(v) {
|
||||
uni.setClipboardData({
|
||||
data: String(v || ''),
|
||||
success: () => uni.showToast({ title: '已复制', icon: 'success' })
|
||||
});
|
||||
},
|
||||
/* ===== 快捷操作(参考图四项) ===== */
|
||||
onQuickCall() {
|
||||
const m = this.displayPerson.mobile || this.fields.mobile;
|
||||
if (!m) return uni.showToast({ title: '未填写手机号', icon: 'none' });
|
||||
uni.makePhoneCall({ phoneNumber: String(m) });
|
||||
},
|
||||
onQuickEmail() {
|
||||
const e = this.displayPerson.email || this.fields.email;
|
||||
if (!e) return uni.showToast({ title: '未填写邮箱', icon: 'none' });
|
||||
this.copyText(e);
|
||||
},
|
||||
onQuickWebsite() {
|
||||
const w = this.displayPerson.website || this.fields.website;
|
||||
if (!w) return uni.showToast({ title: '未填写官网', icon: 'none' });
|
||||
// 小程序内无法直接打开外部网页(web-view 中转体验差), 复制链接到浏览器打开
|
||||
this.copyText(w);
|
||||
uni.showToast({ title: '链接已复制,请在浏览器打开', icon: 'none' });
|
||||
},
|
||||
onQuickSave() {
|
||||
// 跳原生页直接调 addPhoneContact(web-view 内 H5 才受限, 原生页可直存)
|
||||
const p = this.displayPerson || {};
|
||||
const q = [
|
||||
'name=' + encodeURIComponent(p.name || this.fields.name || ''),
|
||||
'mobile=' + encodeURIComponent(p.mobile || this.fields.mobile || ''),
|
||||
'company=' + encodeURIComponent(this.fields.name || ''),
|
||||
'job_title=' + encodeURIComponent(p.job_title || ''),
|
||||
'email=' + encodeURIComponent(p.email || this.fields.email || ''),
|
||||
'website=' + encodeURIComponent(p.website || this.fields.website || ''),
|
||||
'address=' + encodeURIComponent(p.address || this.fields.address || ''),
|
||||
'wechat=' + encodeURIComponent(p.wechat || '')
|
||||
].join('&');
|
||||
uni.navigateTo({ url: '/pages_tool/card/save-contact?' + q });
|
||||
},
|
||||
openChooseSelf() {
|
||||
this.$cardApi.cardLists({
|
||||
data: { page: 1, page_size: 100 },
|
||||
success: res => {
|
||||
const d = res.data || {};
|
||||
this.myCards = d.list || [];
|
||||
this.showChoose = true;
|
||||
},
|
||||
fail: () => uni.showToast({ title: '加载名片失败', icon: 'none' })
|
||||
});
|
||||
},
|
||||
closeChoose() {
|
||||
this.showChoose = false;
|
||||
},
|
||||
chooseSelf(c) {
|
||||
if (!c || !c.card_id) return;
|
||||
this.$cardApi.enterpriseChooseSelf({
|
||||
data: { card_id: c.card_id },
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
uni.showToast({ title: '已选择展示名片', icon: 'success' });
|
||||
this.showChoose = false;
|
||||
this.loadData();
|
||||
} else {
|
||||
uni.showToast({ title: res.message || '选择失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: () => uni.showToast({ title: '网络异常', icon: 'none' })
|
||||
});
|
||||
},
|
||||
openShare() {
|
||||
uni.showToast({ title: '请点击右上角或浏览器菜单分享', icon: 'none' });
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ent-card {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f6fa;
|
||||
padding-bottom: 140rpx;
|
||||
}
|
||||
|
||||
.page-layer {
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
/* ===== 英雄区 ===== */
|
||||
.hero {
|
||||
position: relative;
|
||||
border-radius: 24rpx;
|
||||
padding: 32rpx 32rpx 140rpx;
|
||||
background:
|
||||
radial-gradient(circle at 88% 18%, rgba(255, 255, 255, 0.14) 0, rgba(255, 255, 255, 0.14) 120rpx, transparent 121rpx),
|
||||
linear-gradient(150deg, var(--card-theme, #1e6fd9) 0%, var(--card-theme-end, #123f8f) 100%);
|
||||
overflow: visible;
|
||||
margin-bottom: 90rpx;
|
||||
}
|
||||
|
||||
.hero-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6rpx 20rpx;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 26rpx;
|
||||
color: #ffffff;
|
||||
font-size: 22rpx;
|
||||
|
||||
.hero-pill-dot {
|
||||
width: 10rpx;
|
||||
height: 10rpx;
|
||||
border-radius: 50%;
|
||||
background: #6cf9c0;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-swiper {
|
||||
height: 180rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.hero-id {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 28rpx;
|
||||
|
||||
.hi-avatar {
|
||||
width: 128rpx;
|
||||
height: 128rpx;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
border: 6rpx solid rgba(255, 255, 255, 0.85);
|
||||
flex-shrink: 0;
|
||||
|
||||
&.hi-avatar-ph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.hi-avatar-char {
|
||||
font-size: 56rpx;
|
||||
font-weight: 600;
|
||||
color: var(--card-theme, #1e6fd9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hi-info {
|
||||
margin-left: 28rpx;
|
||||
min-width: 0;
|
||||
|
||||
.hi-name {
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.hi-job {
|
||||
margin-top: 10rpx;
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 快捷操作条: 上移压住英雄卡下沿 ===== */
|
||||
.quick {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin: -130rpx 24rpx 24rpx;
|
||||
background: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
box-shadow: 0 8rpx 30rpx rgba(30, 111, 217, 0.10);
|
||||
display: flex;
|
||||
padding: 28rpx 0;
|
||||
|
||||
.q-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.q-icon {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.q-icon-blue {
|
||||
background: #e3f0ff;
|
||||
|
||||
.iconfont,
|
||||
.q-char {
|
||||
color: #3d8bff;
|
||||
font-size: 40rpx;
|
||||
}
|
||||
}
|
||||
|
||||
&.q-icon-cyan {
|
||||
background: #e2f7f4;
|
||||
|
||||
.q-char {
|
||||
color: #2bb99e;
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&.q-icon-orange {
|
||||
background: #fff3e0;
|
||||
|
||||
.iconfont {
|
||||
color: #f5a623;
|
||||
font-size: 40rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.q-label {
|
||||
margin-top: 14rpx;
|
||||
font-size: 24rpx;
|
||||
color: #444;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 企业简介 ===== */
|
||||
.bio {
|
||||
margin: 0 0 24rpx;
|
||||
padding: 28rpx 28rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
|
||||
|
||||
.bio-text {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.bio-en {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 联系资料 ===== */
|
||||
.contact {
|
||||
padding: 28rpx 28rpx 12rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
.ct-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
padding-left: 16rpx;
|
||||
border-left-width: 6rpx;
|
||||
border-left-style: solid;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.ct-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 18rpx 0;
|
||||
border-bottom: 1rpx solid #f2f3f5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ct-icon {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 12rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 20rpx;
|
||||
flex-shrink: 0;
|
||||
|
||||
.iconfont {
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.ct-char {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.ct-label {
|
||||
width: 88rpx;
|
||||
flex-shrink: 0;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.ct-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 28rpx;
|
||||
word-break: break-all;
|
||||
|
||||
&.ct-multi {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.ct-copy {
|
||||
margin-left: 16rpx;
|
||||
flex-shrink: 0;
|
||||
padding: 6rpx 20rpx;
|
||||
border-radius: 22rpx;
|
||||
background: #f2f3f5;
|
||||
font-size: 22rpx;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== self 未自选提示 ===== */
|
||||
.self-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx 24rpx;
|
||||
background-color: var(--card-theme-soft, #e8f1fb);
|
||||
border: 1rpx solid var(--card-theme-border, #cfe3fb);
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
.sh-icon {
|
||||
margin-right: 12rpx;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.sh-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.sh-btn {
|
||||
font-size: 26rpx;
|
||||
margin-left: 12rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.ent-loading {
|
||||
text-align: center;
|
||||
padding: 40rpx 0;
|
||||
color: #999;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
/* ===== 底部操作条 ===== */
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding: 20rpx 24rpx calc(20rpx + env(safe-area-inset-bottom));
|
||||
background: #ffffff;
|
||||
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
|
||||
z-index: 10;
|
||||
|
||||
.bb-btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
text-align: center;
|
||||
border-radius: 44rpx;
|
||||
font-size: 28rpx;
|
||||
|
||||
&.bb-main {
|
||||
background: linear-gradient(150deg, #3d8bff, #1e6fd9);
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.bb-share {
|
||||
background: #f2f3f5;
|
||||
color: #333;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 员工自选弹层 ===== */
|
||||
.choose-mask {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.choose-sheet {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #fff;
|
||||
border-radius: 28rpx 28rpx 0 0;
|
||||
z-index: 100;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.25s ease;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.choose-show {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.cs-bar {
|
||||
width: 64rpx;
|
||||
height: 8rpx;
|
||||
background: #ddd;
|
||||
border-radius: 4rpx;
|
||||
margin: 16rpx auto 8rpx;
|
||||
}
|
||||
|
||||
.cs-title {
|
||||
text-align: center;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
padding: 12rpx 0 20rpx;
|
||||
}
|
||||
|
||||
.cs-list {
|
||||
overflow-y: auto;
|
||||
padding: 0 24rpx 32rpx;
|
||||
}
|
||||
|
||||
.cs-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1rpx solid #f2f2f2;
|
||||
}
|
||||
|
||||
.cs-avatar {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.cs-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.cs-name {
|
||||
font-size: 30rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.cs-sub {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.cs-chosen {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
|
||||
.cs-empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 26rpx;
|
||||
padding: 40rpx 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
403
pages_tool/card/exchange.vue
Normal file
403
pages_tool/card/exchange.vue
Normal file
@@ -0,0 +1,403 @@
|
||||
<template>
|
||||
<view class="ex-page">
|
||||
<!-- 状态筛选: 全部 / 待处理(N) / 已同意 -->
|
||||
<view class="ex-tabs">
|
||||
<view v-for="tab in tabs" :key="tab.value" class="ex-tab"
|
||||
:class="{ active: current === tab.value }" @tap="switchTab(tab.value)">
|
||||
{{ tab.label }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 交换请求列表 -->
|
||||
<view class="ex-list">
|
||||
<view v-for="(item, index) in shownList" :key="item.id" class="ex-item">
|
||||
<view class="ex-top">
|
||||
<view class="ex-avatar-wrap" :data-index="index" @tap.stop="viewCard">
|
||||
<image v-if="item.avatar" class="ex-avatar" :src="item.avatar" mode="aspectFill" />
|
||||
<view v-else class="ex-avatar ex-avatar-ph">{{ item.name.charAt(0) }}</view>
|
||||
</view>
|
||||
<view class="ex-info">
|
||||
<view class="ex-name-row">
|
||||
<text class="ex-name">{{ item.name }}</text>
|
||||
<text class="ex-badge iconfont icon-dui"></text>
|
||||
</view>
|
||||
<text class="ex-job">{{ item.job_title }}</text>
|
||||
<view class="ex-company-row">
|
||||
<text class="ex-company">{{ item.company }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="item.status === 'pending'" class="ex-agree" :data-index="index" @tap.stop="onAgree">同意</view>
|
||||
<text v-else class="ex-agreed">已同意</text>
|
||||
</view>
|
||||
|
||||
<view class="ex-reason">对方发起了名片交换请求</view>
|
||||
|
||||
<view class="ex-time">{{ item.create_time_txt }} 向您发起名片交换</view>
|
||||
</view>
|
||||
|
||||
<ns-empty v-if="shownList.length === 0" :isIndex="false" :text="emptyText"></ns-empty>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 名片交换请求列表
|
||||
// ⚠️ 目前为静态占位数据: 后端 /card/api/exchange/* 接口就绪后,
|
||||
// 改为在 onLoad 里用 this.$cardApi.exchangeLists({ data: { page: 1 }, ... }) 拉取,
|
||||
// 「同意」改成 cardApi.exchangeAgree({ data: { id: item.id } })。
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
current: 'pending',
|
||||
cardId: '',
|
||||
list: [],
|
||||
page: 1,
|
||||
pageCount: 1,
|
||||
loading: false
|
||||
};
|
||||
},
|
||||
onLoad(options) {
|
||||
this.cardId = decodeURIComponent(options.card_id || '');
|
||||
this.loadList();
|
||||
},
|
||||
onReady() {
|
||||
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||
uni.setNavigationBarTitle({ title: '名片' });
|
||||
// #ifdef H5
|
||||
document.title = '名片';
|
||||
// #endif
|
||||
},
|
||||
computed: {
|
||||
tabs() {
|
||||
const pending = this.list.filter(i => i.status === 'pending').length;
|
||||
return [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'pending', label: '待处理(' + pending + ')' },
|
||||
{ value: 'agreed', label: '已同意' }
|
||||
];
|
||||
},
|
||||
shownList() {
|
||||
if (this.current === 'all') return this.list;
|
||||
return this.list.filter(item => item.status === this.current);
|
||||
},
|
||||
emptyText() {
|
||||
return this.current === 'agreed' ? '暂无已同意的交换' : '暂无名片交换请求';
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
switchTab(value) {
|
||||
if (this.current === value) return;
|
||||
this.current = value;
|
||||
},
|
||||
// 待处理交换请求(后端只返回 status=0); 归一化 status 供模板区分
|
||||
loadList() {
|
||||
this.loading = true;
|
||||
this.$cardApi.exchangeLists({
|
||||
data: { page: this.page, page_size: 20 },
|
||||
success: res => {
|
||||
this.loading = false;
|
||||
if (res.code == 0 && res.data) {
|
||||
this.list = (res.data.list || []).map(it => ({
|
||||
id: it.id,
|
||||
from_card_id: it.from_card_id,
|
||||
name: it.name,
|
||||
avatar: it.avatar,
|
||||
job_title: it.job_title,
|
||||
company: it.company,
|
||||
create_time_txt: it.create_time_txt,
|
||||
status: it.status === 0 ? 'pending' : 'agreed'
|
||||
}));
|
||||
this.pageCount = res.data.page_count || 1;
|
||||
} else if (res.message) {
|
||||
uni.showToast({ title: res.message, icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 取事件对应的行数据。小程序端 v-for 内联传对象(viewCard(item))会丢参,
|
||||
* 统一改为 :data-index + 不传参, 这里从 dataset 取索引回表。
|
||||
*/
|
||||
resolveItem(payload) {
|
||||
if (payload == null) return null;
|
||||
if (typeof payload === 'number') return this.shownList[payload] || null;
|
||||
const ds = payload.currentTarget && payload.currentTarget.dataset;
|
||||
if (ds) {
|
||||
const i = Number(ds.index);
|
||||
if (!isNaN(i) && this.shownList[i]) return this.shownList[i];
|
||||
}
|
||||
return payload.id ? payload : null;
|
||||
},
|
||||
// 点对方名片 → 跳转对方名片详情(用 from_card_id 字符串)
|
||||
viewCard(payload) {
|
||||
const item = this.resolveItem(payload);
|
||||
if (!item || !item.from_card_id) return;
|
||||
uni.navigateTo({
|
||||
url: '/pages_tool/card/detail?card_id=' + encodeURIComponent(item.from_card_id)
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 契约 §4(2026-10-15): 同意后跳对方公开名片页。
|
||||
* wap_url 原样使用, 仅追加 &back_url=encodeURIComponent(我方名片页完整地址);
|
||||
* wap_url 为空则不跳转(对方刚删卡等, 人脉已互建)。
|
||||
*/
|
||||
jumpToOtherPublic(wapUrl) {
|
||||
if (!wapUrl) return;
|
||||
const url = this.withBackUrl(wapUrl);
|
||||
// #ifdef H5
|
||||
window.location.href = url;
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
uni.navigateTo({
|
||||
url: '/pages_tool/card/share?url=' + encodeURIComponent(url)
|
||||
});
|
||||
// #endif
|
||||
},
|
||||
/**
|
||||
* 给公开页地址追加 back_url=本页完整地址(契约 §4 前端唯一允许的改动)。
|
||||
* 对方名片页(PHP wap 模板)左上角返回键据此跳回本页; 已带 back_url 的原样返回, 不重复追加。
|
||||
*/
|
||||
withBackUrl(wapUrl) {
|
||||
if (!wapUrl) return '';
|
||||
if (wapUrl.indexOf('back_url=') >= 0) return wapUrl;
|
||||
const back = this.myPageFullUrl();
|
||||
if (!back) return wapUrl;
|
||||
return wapUrl + (wapUrl.indexOf('?') >= 0 ? '&' : '?') + 'back_url=' + encodeURIComponent(back);
|
||||
},
|
||||
// H5: 当前页面完整地址(禁止写死域名); 非 H5 无等价 H5 地址返回 '' 由 share.vue 中转页原生返回
|
||||
myPageFullUrl() {
|
||||
// #ifdef H5
|
||||
return window.location.href;
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
return '';
|
||||
// #endif
|
||||
},
|
||||
onAgree(payload) {
|
||||
const item = this.resolveItem(payload);
|
||||
if (!item) return;
|
||||
this.$cardApi.exchangeAgree({
|
||||
data: { id: item.id },
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
// receiver_created=0 也是正常成功(同人脉已存在被跳过), 不当失败
|
||||
const d = res.data || {};
|
||||
const txt = d.receiver_created === 0 ? '已交换(人脉已存在)' : '已交换,人脉已互加';
|
||||
uni.showToast({ title: txt, icon: 'none' });
|
||||
// 需求: 点「同意」后对方名片必须出现在我的人脉中。
|
||||
// 后端虽声明 agree 双向建人脉, 实测出现「同意成功、人脉里却没有对方」,
|
||||
// 故不再用 receiver_created 判断, 一律在同意后校验/补建(幂等, 不会重复);
|
||||
// 实现见 card/detail 页同名方法(取对方名片详情 → 按手机号查我的人脉 → 没有才新建)。
|
||||
this.ensureExchangeContact(item.from_card_id, ensureRes => {
|
||||
// 命中同手机号人脉但姓名不同(多为对方改过名片名): 提示实际名称,
|
||||
// 避免「交换后对方没出现」的误判(该联系人其实已在人脉里, 只是显示旧名)
|
||||
if (ensureRes && ensureRes.existed && ensureRes.existed.name
|
||||
&& item.name && ensureRes.existed.name !== item.name) {
|
||||
uni.showToast({
|
||||
title: '人脉中已有同手机号联系人「' + ensureRes.existed.name + '」',
|
||||
icon: 'none',
|
||||
duration: 2500
|
||||
});
|
||||
}
|
||||
// 契约 §4(2026-10-15): 同意后用响应里的 wap_url 跳对方公开名片页,
|
||||
// 仅追加 &back_url=; wap_url 为空则退回刷新列表(对方刚删卡等)
|
||||
if (d.wap_url) {
|
||||
this.jumpToOtherPublic(d.wap_url);
|
||||
} else {
|
||||
this.loadList();
|
||||
}
|
||||
});
|
||||
} else if (res.message) {
|
||||
uni.showToast({ title: res.message, icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: () => {}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 同意交换后校验并兜底建人脉: 保证对方名片出现在我的人脉中。
|
||||
* 走 card-api 的 ensureExchangeContact(取对方名片详情 → 按手机号去重 → 没有才 source=exchange 新建), 幂等。
|
||||
* 无论成败都执行 done(res) —— 兜底失败不阻塞同意后的跳转(交换本身已成功), 人脉页仍可手动补录。
|
||||
*/
|
||||
ensureExchangeContact(fromCardId, done) {
|
||||
if (!fromCardId) { typeof done === 'function' && done(null); return; }
|
||||
this.$cardApi.ensureExchangeContact({
|
||||
fromCardId: fromCardId,
|
||||
success: res => {
|
||||
if (res && res.created) {
|
||||
console.log('[card/exchange] 兜底新建人脉成功:', fromCardId);
|
||||
}
|
||||
typeof done === 'function' && done(res);
|
||||
},
|
||||
fail: err => {
|
||||
console.warn('[card/exchange] 兜底建人脉失败(不影响交换结果):', err);
|
||||
typeof done === 'function' && done(null);
|
||||
}
|
||||
});
|
||||
},
|
||||
onIgnore(item) {
|
||||
this.$cardApi.exchangeIgnore({
|
||||
data: { id: item.id },
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
uni.showToast({ title: '已忽略', icon: 'none' });
|
||||
this.loadList();
|
||||
} else if (res.message) {
|
||||
uni.showToast({ title: res.message, icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: () => {}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ex-page {
|
||||
min-height: 100vh;
|
||||
background-color: #f6f7f9;
|
||||
padding-bottom: 40rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// 筛选胶囊: 选中为浅蓝底蓝字, 未选中白底灰字
|
||||
.ex-tabs {
|
||||
display: flex;
|
||||
padding: 20rpx 24rpx;
|
||||
|
||||
.ex-tab {
|
||||
padding: 12rpx 28rpx;
|
||||
margin-right: 16rpx;
|
||||
border-radius: 32rpx;
|
||||
background-color: #ffffff;
|
||||
font-size: 26rpx;
|
||||
color: #666666;
|
||||
|
||||
&.active {
|
||||
background-color: #e8f3ff;
|
||||
color: #2aa5ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ex-list {
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
|
||||
.ex-item {
|
||||
background-color: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.ex-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ex-avatar-wrap {
|
||||
position: relative;
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ex-avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 16rpx;
|
||||
background-color: #2aa5ff;
|
||||
}
|
||||
|
||||
.ex-avatar-ph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ex-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0 16rpx 0 20rpx;
|
||||
}
|
||||
|
||||
.ex-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ex-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.ex-badge {
|
||||
margin-left: 6rpx;
|
||||
font-size: 26rpx;
|
||||
color: #2aa5ff;
|
||||
}
|
||||
|
||||
.ex-job {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: #7b8794;
|
||||
}
|
||||
|
||||
.ex-company-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
.ex-company {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 26rpx;
|
||||
color: #333333;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ex-agree {
|
||||
flex-shrink: 0;
|
||||
padding: 12rpx 34rpx;
|
||||
border-radius: 34rpx;
|
||||
background-color: #2aa5ff;
|
||||
color: #ffffff;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.ex-agreed {
|
||||
flex-shrink: 0;
|
||||
font-size: 24rpx;
|
||||
color: #9aa4b0;
|
||||
}
|
||||
|
||||
.ex-reason {
|
||||
margin-top: 20rpx;
|
||||
padding: 20rpx;
|
||||
border-radius: 10rpx;
|
||||
background-color: #f5f6f8;
|
||||
font-size: 24rpx;
|
||||
color: #666666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ex-time {
|
||||
margin-top: 14rpx;
|
||||
font-size: 22rpx;
|
||||
color: #9aa4b0;
|
||||
}
|
||||
</style>
|
||||
150
pages_tool/card/forward.vue
Normal file
150
pages_tool/card/forward.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<view class="forward-page">
|
||||
<!-- 名片预览 -->
|
||||
<view class="fp-card">
|
||||
<image v-if="avatar" class="fp-avatar" :src="avatar" mode="aspectFill" />
|
||||
<view v-else class="fp-avatar fp-avatar-ph">{{ (title || '?').slice(0, 1) }}</view>
|
||||
<view class="fp-name">{{ title || '数字名片' }}</view>
|
||||
</view>
|
||||
|
||||
<view class="fp-tip">点击下方按钮,把这张名片直接发给微信好友</view>
|
||||
|
||||
<!-- 转发: 微信小程序必须用原生 button open-type=share 才能拉起转发面板,
|
||||
纯 @tap 无法唤起; 内容取本页 onShareAppMessage -->
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<button class="fp-btn" open-type="share">发送给朋友</button>
|
||||
<!-- #endif -->
|
||||
<!-- #ifdef H5 || APP-PLUS || MP-HARMONY -->
|
||||
<view class="fp-btn" @tap="fallbackShare">发送给朋友</view>
|
||||
<!-- #endif -->
|
||||
|
||||
<view class="fp-sub" v-if="cardUrl">对方点开即可查看名片并保存联系方式</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 名片转发中转页(原生)
|
||||
*
|
||||
* 背景: 分享出去的 wap 名片页在 web-view 内, 其「分享」按钮无法拉起微信转发面板
|
||||
* (web-view 里的 H5 无此能力, 只能复制链接)。由 wap 页在小程序环境下把「分享」
|
||||
* 跳到本页, 本页用原生 button(open-type=share) 直接唤起「发送给朋友」。
|
||||
*
|
||||
* 入参(query, 均需 encodeURIComponent):
|
||||
* url wap 名片页地址(转发出去仍落地 card/share 的 web-view 页)
|
||||
* title 名片姓名(转发卡片标题)
|
||||
* avatar 头像(转发卡片封面, 可空)
|
||||
*/
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
cardUrl: '',
|
||||
title: '',
|
||||
avatar: ''
|
||||
};
|
||||
},
|
||||
onLoad(options) {
|
||||
this.cardUrl = options && options.url ? decodeURIComponent(options.url) : '';
|
||||
this.title = options && options.title ? decodeURIComponent(options.title) : '';
|
||||
this.avatar = options && options.avatar ? decodeURIComponent(options.avatar) : '';
|
||||
uni.setNavigationBarTitle({ title: '分享名片' });
|
||||
},
|
||||
onReady() {
|
||||
uni.setNavigationBarTitle({ title: '分享名片' });
|
||||
},
|
||||
// 右上角菜单转发与按钮转发同参
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
title: this.title ? this.title + ' - 数字名片' : '名片分享',
|
||||
path: '/pages_tool/card/share' + (this.cardUrl ? '?url=' + encodeURIComponent(this.cardUrl) : ''),
|
||||
imageUrl: this.avatar || ''
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
fallbackShare() {
|
||||
// 非微信小程序端无转发面板, 退化为复制链接
|
||||
if (this.cardUrl) {
|
||||
uni.setClipboardData({
|
||||
data: this.cardUrl,
|
||||
success: () => uni.showToast({ title: '链接已复制', icon: 'none' })
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.forward-page {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 48rpx 40rpx;
|
||||
background: linear-gradient(160deg, #29a3ff 0%, #1f8fef 34%, #f5f7fa 34.2%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fp-card {
|
||||
width: 100%;
|
||||
background: #ffffff;
|
||||
border-radius: 24rpx;
|
||||
padding: 56rpx 32rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-shadow: 0 8rpx 30rpx rgba(31, 143, 239, 0.12);
|
||||
|
||||
.fp-avatar {
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
border-radius: 50%;
|
||||
background: #eef4fb;
|
||||
|
||||
&.fp-avatar-ph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #1f8fef;
|
||||
font-size: 60rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.fp-name {
|
||||
margin-top: 24rpx;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
}
|
||||
|
||||
.fp-tip {
|
||||
margin-top: 56rpx;
|
||||
font-size: 28rpx;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.fp-btn {
|
||||
margin-top: 40rpx;
|
||||
width: 100%;
|
||||
height: 92rpx;
|
||||
line-height: 92rpx;
|
||||
border-radius: 46rpx;
|
||||
background: linear-gradient(150deg, #29a3ff, #1f8fef);
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fp-sub {
|
||||
margin-top: 24rpx;
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
418
pages_tool/card/leads.vue
Normal file
418
pages_tool/card/leads.vue
Normal file
@@ -0,0 +1,418 @@
|
||||
<template>
|
||||
<view class="lead-page">
|
||||
<!-- 平级入口: 与我的人脉页互链(导航合并第一步) -->
|
||||
<view class="page-switch" @tap="goContacts">
|
||||
<text class="switch-label">我的人脉</text>
|
||||
<text class="switch-arrow">›</text>
|
||||
</view>
|
||||
|
||||
<!-- 状态筛选 -->
|
||||
<scroll-view scroll-x class="tab-bar">
|
||||
<view class="tab-inner">
|
||||
<view v-for="tab in tabs" :key="tab.value" class="tab-item"
|
||||
:class="{ active: status === tab.value }" @tap="switchTab(tab.value)">
|
||||
{{ tab.label }}
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 客户列表 -->
|
||||
<view class="lead-list">
|
||||
<!-- 点「别人的名片」= 打开对方名片; 拨号/按钮一律 .stop, 避免误触成打电话 -->
|
||||
<view v-for="(item, index) in list" :key="item.id || index" :data-index="index" class="lead-item" @tap="viewCard">
|
||||
<view class="lead-top">
|
||||
<view class="lead-name">{{ item.name || '未留姓名' }}</view>
|
||||
<view class="lead-status" :class="'st-' + item.status">{{ item.status_text || '新客户' }}</view>
|
||||
</view>
|
||||
<!-- 手机号是唯一拨号入口(H5 下调 tel: 浏览器会报 ERR_UNKNOWN_URL_SCHEME, 属正常) -->
|
||||
<view class="lead-mobile" v-if="item.mobile" @tap.stop="callPhone(item.mobile)">📱 {{ item.mobile }}</view>
|
||||
<view class="lead-note" v-if="item.note">「{{ item.note }}」</view>
|
||||
<view class="lead-meta">
|
||||
<view class="meta-card" :class="{ tappable: !!cardIdOf(item) }">
|
||||
<text class="meta-card-name">来自名片:{{ item.card_name || '—' }}</text>
|
||||
<text class="meta-card-arrow" v-if="cardIdOf(item)">查看名片 ›</text>
|
||||
</view>
|
||||
<text class="meta-time">{{ item.create_time_txt }}</text>
|
||||
</view>
|
||||
<view class="lead-actions">
|
||||
<view class="action-btn copy" v-if="item.wechat" @tap.stop="copyText(item.wechat)">复制微信</view>
|
||||
<view class="action-btn primary" :data-index="index" @tap.stop="changeStatus">更新状态</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="load-more" v-if="list.length > 0">
|
||||
<text v-if="isLoading">加载中…</text>
|
||||
<text v-else-if="page < pageCount">上拉加载更多</text>
|
||||
<text v-else class="load-end">— 没有更多了 —</text>
|
||||
</view>
|
||||
|
||||
<ns-empty v-if="!isLoading && list.length === 0" :isIndex="false"
|
||||
:text="'暂无意向客户,访客在你的名片页留资后会出现在这里'"></ns-empty>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 与后端 Lead::STATUSES 一致: 0=新客户 1=已联系 2=已成交 3=无效
|
||||
const STATUS_MAP = [
|
||||
{ value: 0, label: '新客户' },
|
||||
{ value: 1, label: '已联系' },
|
||||
{ value: 2, label: '已成交' },
|
||||
{ value: 3, label: '无效' }
|
||||
];
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
tabs: [{ value: '', label: '全部' }].concat(STATUS_MAP),
|
||||
status: '',
|
||||
list: [],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
pageCount: 1,
|
||||
isLoading: false
|
||||
};
|
||||
},
|
||||
onLoad() {
|
||||
this.refresh();
|
||||
},
|
||||
onReady() {
|
||||
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||
uni.setNavigationBarTitle({ title: '名片' });
|
||||
// #ifdef H5
|
||||
document.title = '名片';
|
||||
// #endif
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
this.refresh(() => uni.stopPullDownRefresh());
|
||||
},
|
||||
onReachBottom() {
|
||||
if (this.isLoading) return;
|
||||
if (this.page >= this.pageCount) return;
|
||||
this.loadPage(this.page + 1);
|
||||
},
|
||||
methods: {
|
||||
// 顶部平级入口: $util.redirectTo(鸿蒙禁裸 uni.switchTab, 静默失败)
|
||||
goContacts() {
|
||||
this.$util.redirectTo('/pages_tool/card/contacts');
|
||||
},
|
||||
switchTab(value) {
|
||||
if (this.status === value) return;
|
||||
this.status = value;
|
||||
this.refresh();
|
||||
},
|
||||
refresh(done) {
|
||||
this.page = 1;
|
||||
this.loadPage(1, done);
|
||||
},
|
||||
loadPage(pageNo, done) {
|
||||
this.isLoading = true;
|
||||
const data = { page: pageNo, page_size: this.pageSize };
|
||||
// 空字符串 = 全部, 后端对 '' 不加 status 条件
|
||||
if (this.status !== '') {
|
||||
data.status = this.status;
|
||||
}
|
||||
this.$cardApi.leadLists({
|
||||
data: data,
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
const d = res.data || {};
|
||||
const rows = d.list || [];
|
||||
this.pageCount = d.page_count || 1;
|
||||
this.page = pageNo;
|
||||
this.list = pageNo === 1 ? rows : this.list.concat(rows);
|
||||
} else {
|
||||
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
|
||||
}
|
||||
this.isLoading = false;
|
||||
typeof done === 'function' && done();
|
||||
},
|
||||
fail: () => {
|
||||
this.isLoading = false;
|
||||
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||
typeof done === 'function' && done();
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 取事件对应的行数据。小程序端 v-for 内联传对象会丢参, 统一 :data-index + 不传参。
|
||||
* 兼容: 事件对象(data-index) / 数字索引 / 行对象本身。
|
||||
*/
|
||||
resolveItem(payload) {
|
||||
if (payload == null) return null;
|
||||
if (typeof payload === 'number') return this.list[payload] || null;
|
||||
const ds = payload.currentTarget && payload.currentTarget.dataset;
|
||||
if (ds) {
|
||||
const i = Number(ds.index);
|
||||
if (!isNaN(i) && this.list[i]) return this.list[i];
|
||||
}
|
||||
return payload.id ? payload : null;
|
||||
},
|
||||
changeStatus(payload) {
|
||||
let item, index;
|
||||
if (payload && payload.currentTarget) {
|
||||
index = Number(payload.currentTarget.dataset.index);
|
||||
item = this.list[index];
|
||||
} else if (typeof payload === 'number') {
|
||||
index = payload;
|
||||
item = this.list[index];
|
||||
} else {
|
||||
item = payload;
|
||||
index = this.list.indexOf(item);
|
||||
}
|
||||
if (!item) return;
|
||||
uni.showActionSheet({
|
||||
itemList: STATUS_MAP.map(s => s.label),
|
||||
success: res => {
|
||||
const target = STATUS_MAP[res.tapIndex];
|
||||
if (!target || target.value === item.status) return;
|
||||
this.doSetStatus(item, index, target.value);
|
||||
}
|
||||
});
|
||||
},
|
||||
doSetStatus(item, index, status) {
|
||||
this.$cardApi.leadSetStatus({
|
||||
data: { id: item.id, status: status },
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
// 就地更新, 不整页刷新
|
||||
this.list[index].status = status;
|
||||
this.list[index].status_text = STATUS_MAP.find(s => s.value === status).label;
|
||||
uni.showToast({ title: '已更新', icon: 'success' });
|
||||
} else {
|
||||
uni.showToast({ title: res.message || '更新失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||
}
|
||||
});
|
||||
},
|
||||
callPhone(mobile) {
|
||||
if (!mobile) return;
|
||||
uni.makePhoneCall({ phoneNumber: mobile });
|
||||
},
|
||||
copyText(text) {
|
||||
uni.setClipboardData({
|
||||
data: text,
|
||||
success: () => uni.showToast({ title: '已复制', icon: 'success' })
|
||||
});
|
||||
},
|
||||
// 该线索关联的名片编号(后端 lead/lists 行下发; 兼容常见字段名)
|
||||
cardIdOf(item) {
|
||||
if (!item) return '';
|
||||
return item.card_id || item.from_card_id || item.source_card_id || '';
|
||||
},
|
||||
// 点整条客户卡片 → 打开对方名片
|
||||
// 与交换名片(exchange.vue)同款写法: detail 接收 card_id(卡片编号字符串);
|
||||
// 本人名下的卡 → 正常详情页; 非本人名下 → detail 兜底跳公开名片页(后端返回的 wap_url)
|
||||
viewCard(payload) {
|
||||
const item = this.resolveItem(payload);
|
||||
const cardId = this.cardIdOf(item);
|
||||
if (!cardId) {
|
||||
// 后端 lead/lists 目前只下发 card_name(展示名), 没有名片编号 → 无法跳转。
|
||||
// 这里把该行真实字段打到控制台, 便于确认后端是否用了别的字段名(如 relation_id)
|
||||
console.warn('[card/leads] 该行缺少名片编号(card_id/from_card_id/source_card_id), 无法打开对方名片; 可用字段:',
|
||||
item ? Object.keys(item) : 'event', item);
|
||||
uni.showToast({ title: '该客户未关联名片,无法查看', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: '/pages_tool/card/detail?card_id=' + encodeURIComponent(cardId)
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.lead-page {
|
||||
min-height: 100vh;
|
||||
background-color: #f6f7f9;
|
||||
padding-bottom: 40rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 16rpx 24rpx 0;
|
||||
|
||||
.switch-label {
|
||||
font-size: 26rpx;
|
||||
color: var(--main-color, #0054a5);
|
||||
}
|
||||
|
||||
.switch-arrow {
|
||||
font-size: 26rpx;
|
||||
color: var(--main-color, #0054a5);
|
||||
margin-left: 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
background-color: #ffffff;
|
||||
white-space: nowrap;
|
||||
|
||||
.tab-inner {
|
||||
display: inline-flex;
|
||||
padding: 0 12rpx;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: inline-block;
|
||||
padding: 24rpx 28rpx;
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
position: relative;
|
||||
|
||||
&.active {
|
||||
color: var(--main-color, #0054a5);
|
||||
font-weight: 600;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: 10rpx;
|
||||
width: 40rpx;
|
||||
height: 6rpx;
|
||||
border-radius: 3rpx;
|
||||
background-color: var(--main-color, #0054a5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lead-list {
|
||||
padding: 20rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.lead-item {
|
||||
background-color: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
.lead-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.lead-name {
|
||||
flex: 1;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.lead-status {
|
||||
font-size: 22rpx;
|
||||
padding: 6rpx 18rpx;
|
||||
border-radius: 22rpx;
|
||||
|
||||
&.st-0 {
|
||||
color: var(--main-color, #0054a5);
|
||||
background-color: var(--main-color-shallow, #e8f1fb);
|
||||
}
|
||||
|
||||
&.st-1 {
|
||||
color: #e6a23c;
|
||||
background-color: #fdf3e6;
|
||||
}
|
||||
|
||||
&.st-2 {
|
||||
color: #1daa39;
|
||||
background-color: #e8f8ea;
|
||||
}
|
||||
|
||||
&.st-3 {
|
||||
color: #999;
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lead-mobile {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
|
||||
.lead-note {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
margin-top: 12rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.lead-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
margin-top: 14rpx;
|
||||
|
||||
.meta-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 64%;
|
||||
|
||||
// 有名片编号时才高亮/可点, 否则退化为普通文字
|
||||
&.tappable .meta-card-name {
|
||||
color: var(--main-color, #0054a5);
|
||||
}
|
||||
|
||||
.meta-card-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta-card-arrow {
|
||||
flex-shrink: 0;
|
||||
margin-left: 8rpx;
|
||||
color: var(--main-color, #0054a5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lead-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20rpx;
|
||||
padding-top: 20rpx;
|
||||
border-top: 1rpx solid #f0f0f0;
|
||||
|
||||
.action-btn {
|
||||
font-size: 24rpx;
|
||||
border-radius: 30rpx;
|
||||
padding: 10rpx 30rpx;
|
||||
margin-left: 20rpx;
|
||||
|
||||
&.copy {
|
||||
color: #666;
|
||||
border: 1rpx solid #ddd;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
color: #ffffff;
|
||||
background-color: var(--main-color, #0054a5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.load-more {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 24rpx;
|
||||
padding: 24rpx 0;
|
||||
|
||||
.load-end {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1068
pages_tool/card/list.vue
Normal file
1068
pages_tool/card/list.vue
Normal file
File diff suppressed because it is too large
Load Diff
251
pages_tool/card/save-contact.vue
Normal file
251
pages_tool/card/save-contact.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<view class="save-contact">
|
||||
<!-- 名片摘要卡 -->
|
||||
<view class="sc-card">
|
||||
<view class="sc-avatar">{{ initial }}</view>
|
||||
<view class="sc-name">{{ form.name || '未命名名片' }}</view>
|
||||
<view class="sc-job" v-if="form.job_title">{{ form.job_title }}</view>
|
||||
<view class="sc-company" v-if="form.company">{{ form.company }}</view>
|
||||
<view class="sc-mobile" v-if="form.mobile" @tap="callNow">
|
||||
<text class="sc-mobile-icon">📞</text>
|
||||
<text>{{ form.mobile }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 保存按钮: 调系统 API 直接写入手机通讯录(系统会弹出确认框) -->
|
||||
<button class="sc-btn" :disabled="saving" @tap="saveToContacts">
|
||||
{{ saving ? '保存中…' : '保存到通讯录' }}
|
||||
</button>
|
||||
<view class="sc-tip">保存后可在手机通讯录中直接拨号</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 保存名片到手机通讯录(原生页)
|
||||
*
|
||||
* 背景: 分享出去的名片是后端 wap H5 页, 经 web-view 打开。H5 原来的
|
||||
* 「保存到通讯录」是下载 .vcf 文件, 而微信小程序 web-view 禁止下载文件,
|
||||
* 会提示「可在浏览器打开此网页来下载文件」。
|
||||
*
|
||||
* 方案: wap H5 检测到运行在小程序 web-view 内(/miniProgram/i.test(UA))时,
|
||||
* 不再触发 .vcf 下载, 改为调 wx.miniProgram.navigateTo 跳到本页,
|
||||
* 由本页(小程序原生)调 uni.addPhoneContact 直接写入系统通讯录。
|
||||
*
|
||||
* 入参(query, 均需 encodeURIComponent):
|
||||
* name/mobile/company/job_title/email/website/address/remark/wechat 名片字段
|
||||
* action=call 时进入页面直接拨号(wap 页「打电话」按钮也可走本页)
|
||||
* action=mail 时复制 email 到剪贴板并返回(web-view 内 mailto: 不生效的兜底)
|
||||
*/
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
name: '',
|
||||
mobile: '',
|
||||
company: '',
|
||||
job_title: '',
|
||||
email: '',
|
||||
website: '',
|
||||
address: '',
|
||||
remark: '',
|
||||
wechat: ''
|
||||
},
|
||||
saving: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
initial() {
|
||||
const n = this.form.name || this.form.mobile || '?';
|
||||
return n.slice(0, 1);
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
const keys = ['name', 'mobile', 'company', 'job_title', 'email', 'website', 'address', 'remark', 'wechat'];
|
||||
keys.forEach(k => {
|
||||
if (options && options[k] != null) {
|
||||
this.form[k] = decodeURIComponent(options[k]);
|
||||
}
|
||||
});
|
||||
// 打电话动作: 直接唤起拨号盘并退出本页
|
||||
if (options && options.action === 'call' && this.form.mobile) {
|
||||
this.callNow();
|
||||
}
|
||||
// 发邮件动作: web-view 内 mailto: 不生效, 复制邮箱后返回名片页
|
||||
if (options && options.action === 'mail' && this.form.email) {
|
||||
this.copyEmail();
|
||||
}
|
||||
},
|
||||
onReady() {
|
||||
// 微信真机才生效(模拟器 bug 见 docs/weixin-bug.md), onShow 兜底见下
|
||||
uni.setNavigationBarTitle({ title: '保存到通讯录' });
|
||||
// 进入页面即自动调起系统"添加联系人"确认框:
|
||||
// web-view 内 H5 无法直接调 addPhoneContact, 必须经本原生页中转,
|
||||
// 若再让用户手动点一次等于两步操作, 所以这里自动触发, 用户零点击直接看到系统弹窗
|
||||
this.saveToContacts();
|
||||
},
|
||||
onShow() {
|
||||
uni.setNavigationBarTitle({ title: '保存到通讯录' });
|
||||
},
|
||||
methods: {
|
||||
callNow() {
|
||||
if (!this.form.mobile) return;
|
||||
uni.makePhoneCall({
|
||||
phoneNumber: this.form.mobile,
|
||||
// 用户取消拨号会走 fail, 不提示
|
||||
fail: () => {}
|
||||
});
|
||||
},
|
||||
// 发邮件兜底: 小程序无唤起邮件 App 的 API, 复制邮箱 + 提示后返回原名片页
|
||||
copyEmail() {
|
||||
uni.setClipboardData({
|
||||
data: this.form.email,
|
||||
showToast: false,
|
||||
success: () => {
|
||||
uni.showToast({ title: '邮箱已复制,去邮件 App 粘贴发送', icon: 'none', duration: 2000 });
|
||||
setTimeout(() => {
|
||||
uni.navigateBack({ fail: () => {} });
|
||||
}, 1400);
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({ title: '复制失败,邮箱:' + this.form.email, icon: 'none', duration: 3000 });
|
||||
}
|
||||
});
|
||||
},
|
||||
saveToContacts() {
|
||||
if (this.saving) return;
|
||||
if (!this.form.name && !this.form.mobile) {
|
||||
uni.showToast({ title: '名片资料缺失,无法保存', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
uni.addPhoneContact({
|
||||
firstName: this.form.name || this.form.mobile,
|
||||
mobilePhoneNumber: this.form.mobile || '',
|
||||
organization: this.form.company || '',
|
||||
title: this.form.job_title || '',
|
||||
email: this.form.email || '',
|
||||
url: this.form.website || '',
|
||||
weChatNumber: this.form.wechat || '',
|
||||
remark: [this.form.address, this.form.remark].filter(Boolean).join(' '),
|
||||
success: () => {
|
||||
uni.showToast({ title: '已保存到通讯录', icon: 'success' });
|
||||
setTimeout(() => {
|
||||
uni.navigateBack({ fail: () => {} });
|
||||
}, 1200);
|
||||
},
|
||||
fail: err => {
|
||||
// 细分失败原因, 便于真机上快速定位(系统确认框点「取消」不算错误)
|
||||
const msg = (err && err.errMsg) || '';
|
||||
if (msg.indexOf('cancel') > -1) return;
|
||||
if (msg.indexOf('privacy') > -1 || msg.indexOf('declared') > -1) {
|
||||
// 小程序后台「用户隐私保护指引」未声明 addPhoneContact(见 docs/CARD_SAVE_CONTACT_BACKEND.md)
|
||||
uni.showToast({ title: '小程序隐私协议未声明该能力,请管理员配置', icon: 'none', duration: 3000 });
|
||||
} else if (msg.indexOf('auth') > -1 || msg.indexOf('deny') > -1) {
|
||||
uni.showToast({ title: '未获通讯录权限,请在系统设置中允许微信访问通讯录', icon: 'none', duration: 3000 });
|
||||
} else {
|
||||
// 未知错误把 errMsg 原样带出, 截图即可定位
|
||||
uni.showToast({ title: '保存失败: ' + (msg || '未知'), icon: 'none', duration: 3000 });
|
||||
}
|
||||
},
|
||||
complete: () => {
|
||||
this.saving = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.save-contact {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 40rpx 32rpx;
|
||||
background: linear-gradient(160deg, #29a3ff 0%, #1f8fef 40%, #f5f7fa 40.2%);
|
||||
}
|
||||
|
||||
.sc-card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 24rpx;
|
||||
padding: 48rpx 32rpx 40rpx;
|
||||
box-shadow: 0 8rpx 30rpx rgba(31, 143, 239, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.sc-avatar {
|
||||
width: 128rpx;
|
||||
height: 128rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(150deg, #29a3ff, #1f8fef);
|
||||
color: #ffffff;
|
||||
font-size: 56rpx;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sc-name {
|
||||
margin-top: 24rpx;
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.sc-job {
|
||||
margin-top: 8rpx;
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.sc-company {
|
||||
margin-top: 4rpx;
|
||||
font-size: 26rpx;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.sc-mobile {
|
||||
margin-top: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 32rpx;
|
||||
color: #1f8fef;
|
||||
font-weight: 500;
|
||||
|
||||
.sc-mobile-icon {
|
||||
margin-right: 8rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sc-btn {
|
||||
margin-top: 64rpx;
|
||||
height: 92rpx;
|
||||
line-height: 92rpx;
|
||||
border-radius: 46rpx;
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(150deg, #29a3ff, #1f8fef);
|
||||
border: none;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.6;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.sc-tip {
|
||||
margin-top: 24rpx;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
217
pages_tool/card/share.vue
Normal file
217
pages_tool/card/share.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<view class="card-share">
|
||||
<!-- #ifdef MP-HARMONY -->
|
||||
<!-- 鸿蒙元服务:web-view 是原生全屏组件,必须用 cover-view 盖在上面(cover-view 不支持 iconfont,用文本) -->
|
||||
<cover-view class="mp-header">
|
||||
<cover-view class="mp-status-bar" :style="{ height: statusBarHeight + 'px' }"></cover-view>
|
||||
<cover-view class="mp-header-inner">
|
||||
<cover-view class="nav-btn" @click="goBack">
|
||||
<cover-view class="nav-back-text">‹</cover-view>
|
||||
</cover-view>
|
||||
<cover-view class="header-title">名片分享</cover-view>
|
||||
<cover-view class="nav-btn nav-btn-placeholder"></cover-view>
|
||||
</cover-view>
|
||||
</cover-view>
|
||||
<!-- web-view 是全屏原生组件,父容器 padding 对它无效,用等高占位 view 顶下去 -->
|
||||
<view class="mp-spacer" :style="{ height: headerTotalHeightPx + 'px' }"></view>
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- 小程序/鸿蒙:web-view 加载 wap H5 名片页(客户视角,含留资表单) -->
|
||||
<!-- #ifdef MP-WEIXIN || MP-HARMONY -->
|
||||
<web-view v-if="cardUrl" :src="cardUrl"></web-view>
|
||||
<view v-else class="empty-tip">链接无效</view>
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- #ifdef H5 -->
|
||||
<!-- H5 直接就是浏览器,onLoad 时已 location 跳转,这里仅兜底展示 -->
|
||||
<view class="h5-fallback">
|
||||
<view class="h5-fallback-tip">正在打开名片…</view>
|
||||
<button class="h5-fallback-btn" @tap="openUrl">如未自动跳转,点此打开</button>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- #ifdef APP-PLUS -->
|
||||
<web-view v-if="cardUrl" :src="cardUrl"></web-view>
|
||||
<view v-else class="empty-tip">链接无效</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 名片分享中转页
|
||||
*
|
||||
* 背景: 小程序 onShareAppMessage 的 path 只能指向小程序页面, 不能直接分享 H5 链接,
|
||||
* 而名片客户页(wap detail)是 H5。因此员工转发名片给微信好友时, 分享的是本页,
|
||||
* 本页再用 web-view 把客户带到 wap 名片页(含留资表单)。
|
||||
* H5 端分享就是普通 URL, 不需要中转, onLoad 直接跳转。
|
||||
* 鸿蒙元服务(MP-HARMONY): web-view 同为原生全屏组件(参考 pages_tool/webview/webview.vue
|
||||
* 的 cover-view 返回键方案), 与微信小程序共用同一套头部+占位+web-view 结构;
|
||||
* 无 onShareAppMessage 生命周期, 鸿蒙端分享入口是名片详情页内「分享」按钮。
|
||||
* 注意: web-view 加载的外链域名需在 manifest → HarmonyOS 网络配置里放行(元服务要求)。
|
||||
*/
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
cardUrl: '',
|
||||
statusBarHeight: 20,
|
||||
windowWidth: 375
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// #ifdef MP-HARMONY
|
||||
headerTotalHeightPx() {
|
||||
const rpxToPx = this.windowWidth / 750;
|
||||
return this.statusBarHeight + Math.round(88 * rpxToPx);
|
||||
}
|
||||
// #endif
|
||||
},
|
||||
onLoad(options) {
|
||||
this.cardUrl = options.url ? decodeURIComponent(options.url) : '';
|
||||
// #ifdef MP-WEIXIN || MP-HARMONY
|
||||
try {
|
||||
const sys = uni.getSystemInfoSync();
|
||||
this.statusBarHeight = sys.statusBarHeight || 20;
|
||||
this.windowWidth = sys.windowWidth || 375;
|
||||
} catch (e) {
|
||||
// 鸿蒙个别版本 getSystemInfoSync 可能抛错, 用默认值兜底(头部高度略差不错位)
|
||||
console.warn('[card/share] getSystemInfoSync fail, use fallback', e);
|
||||
}
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
// H5 端无 web-view 中转必要,直接跳转到名片页
|
||||
if (this.cardUrl) {
|
||||
window.location.href = this.cardUrl;
|
||||
}
|
||||
// #endif
|
||||
},
|
||||
methods: {
|
||||
goBack() {
|
||||
const pages = getCurrentPages();
|
||||
if (pages && pages.length > 1) {
|
||||
uni.navigateBack();
|
||||
} else {
|
||||
// 栈底回首页必须走 $util.redirectTo: 鸿蒙元服务上 uni.switchTab 会静默失败
|
||||
// (success/fail 都不触发, .catch 兜底失效), util 内部有
|
||||
// switchTab → 500ms 强制 reLaunch 的兜底(见 util.js#redirectTo tabBar 分支)
|
||||
this.$util.redirectTo('/pages/index/index');
|
||||
}
|
||||
},
|
||||
// #ifdef H5
|
||||
openUrl() {
|
||||
if (this.cardUrl) window.location.href = this.cardUrl;
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card-share {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60vh;
|
||||
color: #999;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
/* #ifdef MP-HARMONY */
|
||||
.mp-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
.mp-status-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mp-header-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 88rpx;
|
||||
padding: 0 20rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.nav-back-text {
|
||||
font-size: 48rpx;
|
||||
color: #333;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-btn-placeholder {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin: 0 16rpx;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.mp-spacer {
|
||||
width: 100%;
|
||||
}
|
||||
/* #endif */
|
||||
|
||||
/* #ifdef H5 */
|
||||
.h5-fallback {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 80vh;
|
||||
|
||||
.h5-fallback-tip {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.h5-fallback-btn {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
border: 1rpx solid #dcdfe6;
|
||||
border-radius: 36rpx;
|
||||
padding: 0 40rpx;
|
||||
height: 72rpx;
|
||||
line-height: 72rpx;
|
||||
background: #fff;
|
||||
margin: 0;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* #endif */
|
||||
</style>
|
||||
819
pages_tool/card/style.vue
Normal file
819
pages_tool/card/style.vue
Normal file
@@ -0,0 +1,819 @@
|
||||
<template>
|
||||
<view class="card-detail" :style="[previewVars, { fontFamily: previewDs.fontFamily }]" :class="{ 'qs-square': previewDs.quickStyle === 'square' }">
|
||||
<view class="page-layer">
|
||||
<view class="ps-disabled" v-if="relation && !enabled">商家未开启员工端样式自选,暂不可选样式</view>
|
||||
|
||||
<!-- 实时预览卡: 与详情页同款 design 渲染(展示当前所选预设的实际落库效果) -->
|
||||
<view class="card-wrap" v-if="relation">
|
||||
<view class="card-wrap-bg" :style="previewHeaderBg"></view>
|
||||
<view class="detail-card" :style="{ backgroundColor: previewDs.bgColor, borderRadius: previewDs.cardRadius + 'px' }"
|
||||
:class="['layout-' + previewDs.layout, previewDs.cover && previewDs.layout !== 'classic' ? 'has-cover' : '']">
|
||||
<view v-if="previewDs.cover && previewDs.layout !== 'classic'" class="dc-cover"
|
||||
:style="{ borderTopLeftRadius: previewDs.cardRadius + 'px', borderTopRightRadius: previewDs.cardRadius + 'px' }"
|
||||
:class="previewDs.layout === 'compact' ? 'dc-cover-compact' : 'dc-cover-hero'">
|
||||
<image class="dc-cover-img" :src="$util.img(previewDs.cover)" mode="aspectFill"></image>
|
||||
</view>
|
||||
<view class="dc-name" :style="{ color: previewDs.textColor }">{{ relation.name || '未命名名片' }}</view>
|
||||
<view class="dc-job" v-if="relation.job_title" :style="{ color: previewDs.textSoft, fontSize: (28 * previewDs.fontScaleNum) + 'rpx' }">{{ relation.job_title }}</view>
|
||||
<view class="dc-company" v-if="relation.company" :style="{ color: previewDs.textSoft, fontSize: (26 * previewDs.fontScaleNum) + 'rpx' }">{{ relation.company }}</view>
|
||||
|
||||
<view class="dc-rows" v-if="fieldRows.length > 0">
|
||||
<view v-for="(row, index) in fieldRows" :key="index" class="field-row"
|
||||
:class="{ 'field-row-multi': row.multiline }">
|
||||
<text v-if="row.icon" class="field-icon iconfont" :class="row.icon" :style="{ color: previewDs.textSoft }"></text>
|
||||
<text v-else class="field-tag" :style="{ color: previewDs.textSoft, fontSize: (26 * previewDs.fontScaleNum) + 'rpx' }">{{ row.label }}</text>
|
||||
<view class="field-value" :class="{ 'field-value-multi': row.multiline }" :style="{ color: previewDs.textColor, fontSize: (28 * previewDs.fontScaleNum) + 'rpx' }">{{ row.value }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="detail-loading" v-if="isLoading">加载中…</view>
|
||||
<ns-empty v-if="!isLoading && !relation" :isIndex="false"
|
||||
:text="errorMsg || '卡片不存在或已被删除'"></ns-empty>
|
||||
|
||||
<!-- 预设网格: 每个单元格 = 该预设的迷你预览(同源后台 CardTemplatePresets) -->
|
||||
<view class="preset-section" v-if="relation && enabled">
|
||||
<text class="ps-title">选择样式<text class="ps-count">(共 {{ presets.length }} 个)</text></text>
|
||||
<view class="ps-canvas-hint" v-if="isCanvasCard">
|
||||
当前名片为商家自定义画布设计,保存将不被允许
|
||||
</view>
|
||||
<view class="preset-grid">
|
||||
<view class="preset-cell" v-for="(p, idx) in presets" :key="p.key"
|
||||
:class="{ active: p.key === selectedKey }" :style="cellActiveStyle(p)"
|
||||
@tap="selectPreset(p.key)">
|
||||
<text class="preset-tag" v-if="p.source === 'canvas'">画布式</text>
|
||||
<!-- 缩略图: 彩色底 + 内嵌白卡。
|
||||
底色改由样式表 .pt-N(N=格序) 给出: 微信小程序会丢弃内联 background,
|
||||
只用内联会导致 14 个格子全部回落成同一个兜底蓝。 -->
|
||||
<view class="preset-thumb" :class="'pt-' + (idx % 14)" :style="cellVars(p)">
|
||||
<view class="pt-header"></view>
|
||||
<view class="pt-body" :style="cellBody(p)">
|
||||
<view class="pt-name" :style="cellText(p)"></view>
|
||||
<view class="pt-line" :style="cellSoft(p)"></view>
|
||||
<view class="pt-line pt-short" :style="cellSoft(p)"></view>
|
||||
</view>
|
||||
<view v-if="p.key === selectedKey" class="pt-check">
|
||||
<view class="pt-check-mark"></view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="preset-name">{{ p.name }}</text>
|
||||
<text class="preset-desc">{{ p.desc }}</text>
|
||||
<text v-if="p.key === currentKey" class="preset-current">当前</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="!isLoading && presets.length === 0" class="ps-empty">
|
||||
预设列表为空(后端 design/presets 未返回)
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部保存 -->
|
||||
<view class="ps-footer" v-if="relation && enabled">
|
||||
<view class="ps-save" :class="{ disabled: !selectedKey || saving || isCanvasCard }" @tap="save">
|
||||
{{ saving ? '保存中…' : (isCanvasCard ? '不支持修改' : '保存') }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 名片样式(可选样式页)· 后端 v2 对接
|
||||
* GET /card/api/design/presets → 与商家后台 CardTemplatePresets 同源的预设列表
|
||||
* POST /card/api/design/save → 按 preset_key 应用预设并保存到本卡
|
||||
* 关键点(v2):
|
||||
* - 预设列表由后端下发, 前端不自建; 数量不写死(当前 14 个, 直接渲染 list.length)
|
||||
* - save 只写入 10 个字段(theme_color/text_color/bg_color/font_family/font_scale/
|
||||
* card_radius/header_gradient_end/header_gradient_angle/quick_style/template_id),
|
||||
* 不覆盖 layout / cover; 预览据此合并(本卡 layout/cover + 预设 10 字段)
|
||||
* - source==='canvas' 的预设仅改配色; 本卡自身为 canvas 设计时 save 会被拒
|
||||
* - data.enabled 控制入口显隐(detail.vue 已据此隐藏按钮)
|
||||
*/
|
||||
function mixHex(a, b, t) {
|
||||
const toRgb = h => {
|
||||
h = (h || '').replace('#', '');
|
||||
if (h.length === 3) h = h.split('').map(c => c + c).join('');
|
||||
const n = parseInt(h, 16);
|
||||
if (isNaN(n)) return [0, 0, 0];
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
};
|
||||
const [r1, g1, b1] = toRgb(a);
|
||||
const [r2, g2, b2] = toRgb(b);
|
||||
const r = Math.round(r1 + (r2 - r1) * t);
|
||||
const g = Math.round(g1 + (g2 - g1) * t);
|
||||
const bl = Math.round(b1 + (b2 - b1) * t);
|
||||
return '#' + [r, g, bl].map(x => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// save 实际写入的字段(与后端 v2 一致); 预览只叠加这些, 其余沿用本卡既有值
|
||||
const SAVE_WRITTEN_FIELDS = [
|
||||
'theme_color', 'text_color', 'bg_color', 'font_family', 'font_scale',
|
||||
'card_radius', 'header_gradient_end', 'header_gradient_angle', 'quick_style', 'template_id'
|
||||
];
|
||||
|
||||
// 归一化 design(字段缺失/非法回落文档默认值); d 可为 null(旧后端)
|
||||
function normalizeDesign(d) {
|
||||
const dd = d || {};
|
||||
const FONTS = {
|
||||
system: '-apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",Arial,sans-serif',
|
||||
sans: '"Source Han Sans SC","Noto Sans SC","PingFang SC",Helvetica,Arial,sans-serif',
|
||||
serif: '"Source Han Serif SC","Songti SC",SimSun,Georgia,serif',
|
||||
rounded: '"Yuanti SC","YouYuan","Quicksand",-apple-system,sans-serif'
|
||||
};
|
||||
const isHex = v => /^#[0-9a-fA-F]{3,8}$/.test(v || '');
|
||||
// 兼容后端可能的多种命名: snake_case / camelCase / 简写
|
||||
// (后端若下发 camelCase 而这里只读 snake_case, 会全部回落默认蓝 → 14 个格子同色)
|
||||
const pick = (...keys) => {
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const v = dd[keys[i]];
|
||||
if (v !== undefined && v !== null && v !== '') return v;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const themeRaw = pick('theme_color', 'themeColor', 'theme', 'primary_color', 'primaryColor', 'primary', 'color');
|
||||
const textRaw = pick('text_color', 'textColor', 'text', 'text_primary', 'textPrimary');
|
||||
const bgRaw = pick('bg_color', 'bgColor', 'background_color', 'backgroundColor', 'background', 'bg');
|
||||
const radiusRaw = pick('card_radius', 'cardRadius', 'radius');
|
||||
const layoutRaw = pick('layout', 'card_layout', 'cardLayout');
|
||||
const fontRaw = pick('font_family', 'fontFamily', 'font');
|
||||
const scaleRaw = pick('font_scale', 'fontScale');
|
||||
const headerEndRaw = pick('header_gradient_end', 'headerGradientEnd', 'gradient_end', 'gradientEnd');
|
||||
const angleRaw = pick('header_gradient_angle', 'headerGradientAngle', 'gradient_angle', 'gradientAngle');
|
||||
|
||||
const theme = isHex(themeRaw) ? themeRaw : '#119dff';
|
||||
const text = isHex(textRaw) ? textRaw : '#1f2329';
|
||||
const bg = isHex(bgRaw) ? bgRaw : '#ffffff';
|
||||
const fontKey = FONTS[fontRaw] ? fontRaw : 'system';
|
||||
const fontScale = Math.min(160, Math.max(60, Number(scaleRaw) || 100));
|
||||
const cardRadius = Number(radiusRaw) || 16;
|
||||
const layout = ['hero', 'compact', 'classic'].includes(layoutRaw) ? layoutRaw : 'classic';
|
||||
const headerEnd = typeof headerEndRaw === 'string' ? headerEndRaw : '';
|
||||
const headerAngle = Number(angleRaw) || 150;
|
||||
return {
|
||||
themeColor: theme,
|
||||
textColor: text,
|
||||
bgColor: bg,
|
||||
layout: layout,
|
||||
cover: pick('cover', 'cover_url', 'coverUrl') || '',
|
||||
fontKey: fontKey,
|
||||
fontFamily: FONTS[fontKey],
|
||||
fontScale: fontScale,
|
||||
cardRadius: cardRadius,
|
||||
headerGradientEnd: headerEnd,
|
||||
headerGradientAngle: headerAngle,
|
||||
quickStyle: pick('quick_style', 'quickStyle') || '',
|
||||
templateId: pick('template_id', 'templateId', 'template') || '',
|
||||
source: pick('source') || 'none',
|
||||
textSoft: mixHex(text, bg, 0.55),
|
||||
themeSoft: mixHex(theme, '#ffffff', 0.85),
|
||||
fontScaleNum: fontScale / 100
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 预设配色兜底: 后端 design 未带合法 theme_color 时(字段名不一致 / 未下发),
|
||||
* 按预设 key/name/desc 关键词匹配一组与后台预设一致的色。
|
||||
* 仅用于缩略图观感, 不影响保存(save 仍按 preset_key 走后端)。
|
||||
*/
|
||||
const PRESET_FALLBACK = [
|
||||
{ re: /(极简|留白|minimal|white|blank)/, theme: '#1f2329' },
|
||||
{ re: /(霓虹|深色|黑|墨|neon|dark|night)/, theme: '#111827' },
|
||||
{ re: /(紫|purple|violet)/, theme: '#7b61ff' },
|
||||
{ re: /(绿|green)/, theme: '#12b886' },
|
||||
{ re: /(橙|暖|orange|warm)/, theme: '#f59f3c' },
|
||||
{ re: /(红|crimson|red)/, theme: '#ef4444' },
|
||||
{ re: /(粉|pink|rose)/, theme: '#ec4899' },
|
||||
{ re: /(金|gold|amber)/, theme: '#d4a017' },
|
||||
{ re: /(青|teal|cyan)/, theme: '#06b6d4' },
|
||||
{ re: /(灰|gray|grey)/, theme: '#6b7280' },
|
||||
{ re: /(渐|gradient)/, theme: '#5b6bff' },
|
||||
{ re: /(大图|门面|封面|cover)/, theme: '#0b5cff' },
|
||||
{ re: /(经典|商务|classic|business)/, theme: '#0b5cff' },
|
||||
{ re: /(蓝|blue|flow|origin|原生)/, theme: '#119dff' }
|
||||
];
|
||||
function fallbackTheme(p) {
|
||||
if (!p) return '';
|
||||
const hay = String(p.key || '') + ' ' + String(p.name || '') + ' '
|
||||
+ String(p.title || '') + ' ' + String(p.label || '') + ' ' + String(p.desc || '');
|
||||
const low = hay.toLowerCase();
|
||||
for (let i = 0; i < PRESET_FALLBACK.length; i++) {
|
||||
if (PRESET_FALLBACK[i].re.test(low)) return PRESET_FALLBACK[i].theme;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function designVarsFor(s) {
|
||||
return {
|
||||
'--card-theme': s.themeColor,
|
||||
'--card-theme-end': s.headerGradientEnd || s.themeColor,
|
||||
'--card-text': s.textColor,
|
||||
'--card-text-soft': s.textSoft,
|
||||
'--card-bg': s.bgColor,
|
||||
'--card-radius': s.cardRadius + 'px',
|
||||
'--card-font': s.fontFamily,
|
||||
'--card-font-scale': s.fontScaleNum
|
||||
};
|
||||
}
|
||||
|
||||
function headerBgFor(s) {
|
||||
if (s.headerGradientEnd) {
|
||||
return 'linear-gradient(' + s.headerGradientAngle + 'deg, ' + s.themeColor + ', ' + s.headerGradientEnd + ')';
|
||||
}
|
||||
return 'linear-gradient(150deg, ' + s.themeColor + ' 0%, ' + s.themeColor + ' 62%, rgba(0,0,0,0.28) 160%)';
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
cardId: '',
|
||||
relation: null,
|
||||
// 名片设计摘要(后端 /card/api/card/detail 顶层 design, 旧后端无此键时为 null)
|
||||
design: null,
|
||||
// 后端下发的预设列表(同源后台 CardTemplatePresets, 数量不写死)
|
||||
presets: [],
|
||||
// 商家是否开启「员工端样式自选」(presets 响应 enabled)
|
||||
enabled: true,
|
||||
// 当前选中的预设 key(初始 = 本卡当前 template_id)
|
||||
selectedKey: '',
|
||||
isLoading: true,
|
||||
errorMsg: '',
|
||||
saving: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
fieldRows() {
|
||||
const r = this.relation;
|
||||
if (!r) return [];
|
||||
const rows = [];
|
||||
const push = (label, value, action, icon) => {
|
||||
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
||||
rows.push({ label, value: String(value), action: action || null, icon: icon || '' });
|
||||
}
|
||||
};
|
||||
push('手机', r.mobile, { type: 'call', text: '拨打' }, 'icon-dianhua');
|
||||
push('电话', r.phone, { type: 'call', text: '拨打' }, 'icon-dianhua');
|
||||
push('邮箱', r.email, { type: 'copy', text: '复制' });
|
||||
push('微信', r.wechat, { type: 'copy', text: '复制' }, 'icon-weixin');
|
||||
push('网站', r.website);
|
||||
push('地址', [r.country, r.province, r.city, r.address].filter(Boolean).join(' '), null, 'icon-dizhi');
|
||||
push('邮编', r.postal_code);
|
||||
(r.mobiles_list || []).forEach((m, i) => {
|
||||
push('手机' + (i + 2), m, { type: 'call', text: '拨打' }, 'icon-dianhua');
|
||||
});
|
||||
(r.socials_list || []).forEach(s => {
|
||||
push(s.label || s.type, s.value, { type: 'copy', text: '复制' });
|
||||
});
|
||||
(r.custom_list || []).forEach(c => {
|
||||
push(c.label, c.value);
|
||||
});
|
||||
if (r.bio && String(r.bio).trim() !== '') {
|
||||
rows.push({ label: '简介', value: String(r.bio), action: null, multiline: true });
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
// 本卡当前 design 的 template_id(用于标记"当前")
|
||||
currentKey() {
|
||||
return (this.design && this.design.template_id) || '';
|
||||
},
|
||||
// 本卡自身为画布设计时, save 会被后端拒
|
||||
isCanvasCard() {
|
||||
return !!(this.design && this.design.source === 'canvas');
|
||||
},
|
||||
activePreset() {
|
||||
return this.presets.find(p => p.key === this.selectedKey) || null;
|
||||
},
|
||||
// 实时预览用的 design: 以本卡既有 design 为底, 仅叠加 save 实际写入的 10 个字段
|
||||
// (layout/cover 不被 save 覆盖, 预览须与落库一致, 避免误导)
|
||||
previewDesign() {
|
||||
const base = this.design || {};
|
||||
const p = this.activePreset ? this.activePreset.design : null;
|
||||
if (!p) return base;
|
||||
const merged = Object.assign({}, base);
|
||||
SAVE_WRITTEN_FIELDS.forEach(k => {
|
||||
if (p[k] !== undefined) merged[k] = p[k];
|
||||
});
|
||||
return merged;
|
||||
},
|
||||
previewDs() {
|
||||
return normalizeDesign(this.previewDesign);
|
||||
},
|
||||
previewVars() {
|
||||
return designVarsFor(this.previewDs);
|
||||
},
|
||||
previewHeaderBg() {
|
||||
// 同 cellVars: 小程序内联 `background` 渐变不生效, 用 backgroundColor 纯色兜住
|
||||
return { backgroundColor: this.previewDs.themeColor };
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 预设缩略图的配色。
|
||||
*
|
||||
* 为什么不用 CSS 变量: 微信小程序的渲染层不支持在内联 style 里定义自定义属性
|
||||
* (`style="--card-bg:#fff"`), 整条 style 会被丢弃 → var(--card-bg) 全部回落默认值,
|
||||
* 表现就是「小程序端样式缩略图全是白底黑条灰线, 没有颜色」(H5 正常)。
|
||||
* 这里改为直接输出具体属性 + 具体色值, 三端一致。
|
||||
*/
|
||||
cellDs(p) {
|
||||
let raw = p && p.design;
|
||||
// design 可能是 JSON 字符串(部分后端把设计摘要存 JSON 列)
|
||||
if (typeof raw === 'string') {
|
||||
try { raw = JSON.parse(raw); } catch (e) { raw = null; }
|
||||
}
|
||||
// 合并: 预设项顶层字段(扁平下发) + design 子对象(优先), 两种结构都能读到色值
|
||||
raw = Object.assign({}, p || {}, (raw && typeof raw === 'object') ? raw : {});
|
||||
const s = normalizeDesign(raw);
|
||||
// 缩略图颜色以「预设名称语义」为准(与后台/H5 的预设一致)。
|
||||
// 后端可能给所有预设下发同一个默认 theme_color(合法但是同一种蓝),
|
||||
// 若只在"没有合法色值"时才兜底, 就会 14 个格子全蓝 —— 所以这里名称命中即覆盖。
|
||||
const fb = fallbackTheme(p);
|
||||
if (fb) {
|
||||
s.themeColor = fb;
|
||||
s.headerGradientEnd = ''; // 纯色, 避免后端残留的渐变把兜底色盖掉
|
||||
s.themeSoft = mixHex(fb, '#ffffff', 0.85);
|
||||
}
|
||||
return s;
|
||||
},
|
||||
cellVars(p) {
|
||||
const s = this.cellDs(p);
|
||||
// 底色不再走内联(小程序端实测内联 background/backgroundColor 均被丢,
|
||||
// 导致 14 格回落同一个 CSS 兜底蓝), 改由样式表按格序上色(见 .preset-cell:nth-child)。
|
||||
// 这里只保留圆角这类无颜色语义的属性。
|
||||
const radius = Math.min(Number(s.cardRadius) || 16, 18);
|
||||
return { borderRadius: radius + 'px' };
|
||||
},
|
||||
// 内嵌白卡: 底色取预设 bg_color, 在彩色底上形成"浮起的名片"层次
|
||||
cellBody(p) {
|
||||
const s = this.cellDs(p);
|
||||
return { backgroundColor: s.bgColor };
|
||||
},
|
||||
cellText(p) {
|
||||
return { backgroundColor: this.cellDs(p).textColor };
|
||||
},
|
||||
cellSoft(p) {
|
||||
return { backgroundColor: this.cellDs(p).textSoft };
|
||||
},
|
||||
// 选中描边同样交给样式表(按格序), 避免内联失效
|
||||
cellActiveStyle() {
|
||||
return null;
|
||||
},
|
||||
onLoad(options) {
|
||||
this.cardId = decodeURIComponent(options.card_id || '');
|
||||
this.loadDetail();
|
||||
this.loadPresets();
|
||||
},
|
||||
onReady() {
|
||||
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||
uni.setNavigationBarTitle({ title: '名片' });
|
||||
// #ifdef H5
|
||||
document.title = '名片';
|
||||
// #endif
|
||||
},
|
||||
loadDetail() {
|
||||
if (!this.cardId) {
|
||||
this.isLoading = false;
|
||||
this.errorMsg = '缺少名片编号';
|
||||
return;
|
||||
}
|
||||
this.$cardApi.cardDetail({
|
||||
data: { card_id: this.cardId },
|
||||
success: res => {
|
||||
const data = res.data || {};
|
||||
if (res.code == 0) {
|
||||
this.relation = data.relation || null;
|
||||
this.design = data.design || null;
|
||||
this.initSelected();
|
||||
} else {
|
||||
this.errorMsg = res.message || '卡片不存在或已被删除';
|
||||
this.relation = null;
|
||||
}
|
||||
this.isLoading = false;
|
||||
},
|
||||
fail: () => {
|
||||
this.isLoading = false;
|
||||
this.errorMsg = '网络异常,请稍后重试';
|
||||
}
|
||||
});
|
||||
},
|
||||
loadPresets() {
|
||||
this.$cardApi.cardDesignPresets({
|
||||
success: res => {
|
||||
const data = res.data || {};
|
||||
// 商家未开启样式自选时前端隐藏入口(默认 true, 失败开放)
|
||||
this.enabled = data.enabled !== false;
|
||||
this.presets = Array.isArray(data.list) ? data.list : [];
|
||||
this.initSelected();
|
||||
},
|
||||
fail: () => {
|
||||
this.enabled = true;
|
||||
this.presets = [];
|
||||
}
|
||||
});
|
||||
},
|
||||
// 两路加载都到位后, 若本卡 template_id 命中某个预设, 默认选中它
|
||||
initSelected() {
|
||||
if (this.selectedKey || !this.currentKey || this.presets.length === 0) return;
|
||||
const hit = this.presets.find(p => p.key === this.currentKey);
|
||||
if (hit) this.selectedKey = hit.key;
|
||||
},
|
||||
selectPreset(key) {
|
||||
this.selectedKey = key;
|
||||
},
|
||||
save() {
|
||||
if (!this.selectedKey || this.saving) return;
|
||||
if (this.isCanvasCard) {
|
||||
uni.showToast({ title: '该名片使用商家自定义画布设计,暂不支持员工端修改', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.$cardApi.cardDesignSave({
|
||||
data: { card_id: this.cardId, preset_key: this.selectedKey },
|
||||
success: res => {
|
||||
this.saving = false;
|
||||
if (res.code == 0) {
|
||||
uni.showToast({ title: '已保存', icon: 'success' });
|
||||
// 返回详情页, 其 onShow 会重新拉取 design 并刷新生效
|
||||
setTimeout(() => uni.navigateBack(), 600);
|
||||
} else if (res.message) {
|
||||
uni.showToast({ title: res.message, icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
this.saving = false;
|
||||
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card-detail {
|
||||
min-height: 100vh;
|
||||
background-color: #ffffff;
|
||||
// 底部预留高度须 ≥ 固定底栏(按钮 88 + 上下 padding + 安全区), 否则滚到底最后一行会被遮住
|
||||
padding: 0 0 240rpx;
|
||||
box-sizing: border-box;
|
||||
font-family: var(--card-font, -apple-system, "PingFang SC", "Helvetica Neue", Arial, sans-serif);
|
||||
}
|
||||
|
||||
.page-layer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ps-disabled {
|
||||
margin: 24rpx;
|
||||
padding: 28rpx;
|
||||
text-align: center;
|
||||
font-size: 26rpx;
|
||||
color: #8a94a6;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
// 预览卡(与详情页同款 design 渲染)
|
||||
.card-wrap {
|
||||
position: relative;
|
||||
padding-top: 24rpx;
|
||||
}
|
||||
|
||||
.card-wrap-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 48rpx;
|
||||
z-index: 0;
|
||||
background-color: var(--card-theme, #2aa5ff);
|
||||
border-bottom-left-radius: 240rpx 44rpx;
|
||||
border-bottom-right-radius: 240rpx 44rpx;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 0 24rpx;
|
||||
background-color: var(--card-bg, #ffffff);
|
||||
border-radius: var(--card-radius, 20rpx);
|
||||
padding: 40rpx 36rpx 32rpx;
|
||||
box-shadow: 0 6rpx 20rpx rgba(0, 30, 80, 0.06);
|
||||
|
||||
.dc-cover {
|
||||
margin: -40rpx -36rpx 24rpx;
|
||||
border-top-left-radius: var(--card-radius, 20rpx);
|
||||
border-top-right-radius: var(--card-radius, 20rpx);
|
||||
overflow: hidden;
|
||||
|
||||
.dc-cover-img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.dc-cover-hero .dc-cover-img { height: 200rpx; }
|
||||
.dc-cover-compact .dc-cover-img { height: 90rpx; }
|
||||
|
||||
.dc-name {
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
color: var(--card-text, #1a1a1a);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.dc-job {
|
||||
margin-top: 6rpx;
|
||||
font-size: calc(28rpx * var(--card-font-scale, 1));
|
||||
line-height: 1.3;
|
||||
color: var(--card-text-soft, #4a4a4a);
|
||||
}
|
||||
|
||||
.dc-company {
|
||||
margin-top: 2rpx;
|
||||
font-size: calc(26rpx * var(--card-font-scale, 1));
|
||||
line-height: 1.3;
|
||||
color: var(--card-text-soft, #9aa4b2);
|
||||
}
|
||||
|
||||
.dc-rows {
|
||||
margin-top: 56rpx;
|
||||
}
|
||||
|
||||
&.layout-compact {
|
||||
padding-top: 28rpx;
|
||||
|
||||
.dc-rows { margin-top: 28rpx; }
|
||||
.field-row { padding: 8rpx 0; }
|
||||
.dc-name { font-size: 40rpx; }
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 1.2;
|
||||
padding: 12rpx 0;
|
||||
|
||||
&.field-row-multi {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.field-icon {
|
||||
width: 40rpx;
|
||||
margin-right: 18rpx;
|
||||
font-size: 32rpx;
|
||||
line-height: 1;
|
||||
color: var(--card-text-soft, #8a94a6);
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.field-tag {
|
||||
min-width: 40rpx;
|
||||
margin-right: 18rpx;
|
||||
font-size: calc(26rpx * var(--card-font-scale, 1));
|
||||
line-height: 1.2;
|
||||
color: var(--card-text-soft, #9aa4b2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: calc(28rpx * var(--card-font-scale, 1));
|
||||
line-height: 1.25;
|
||||
color: var(--card-text, #333333);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.field-value-multi {
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 预设网格(3 列自适应, 14 个不再过长) */
|
||||
.preset-section {
|
||||
margin: 32rpx 24rpx 0;
|
||||
|
||||
.ps-title {
|
||||
display: block;
|
||||
margin-bottom: 16rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.ps-count {
|
||||
font-size: 24rpx;
|
||||
font-weight: 400;
|
||||
color: #8a94a6;
|
||||
}
|
||||
|
||||
.ps-canvas-hint {
|
||||
margin-bottom: 16rpx;
|
||||
font-size: 24rpx;
|
||||
color: #f5a623;
|
||||
}
|
||||
|
||||
.preset-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preset-cell {
|
||||
position: relative;
|
||||
// 自建堆叠上下文: 把格子内的绝对定位元素(角标/选中钩)限制在本格内,
|
||||
// 避免其 z-index 逃逸到固定底栏之上
|
||||
z-index: 0;
|
||||
width: 32%;
|
||||
margin: 0 2% 16rpx 0;
|
||||
background-color: #ffffff;
|
||||
border: 2rpx solid #e8ebee;
|
||||
border-radius: 16rpx;
|
||||
padding: 14rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:nth-child(3n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--card-theme, #2aa5ff);
|
||||
box-shadow: 0 6rpx 18rpx rgba(42, 165, 255, 0.18);
|
||||
}
|
||||
|
||||
.preset-tag {
|
||||
position: absolute;
|
||||
top: 8rpx;
|
||||
left: 8rpx;
|
||||
z-index: 2;
|
||||
padding: 2rpx 10rpx;
|
||||
font-size: 20rpx;
|
||||
color: #ffffff;
|
||||
background-color: rgba(0, 0, 0, 0.45);
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.preset-thumb {
|
||||
position: relative;
|
||||
height: 160rpx;
|
||||
// 底色由 .pt-N 提供(见下方); 这里不能设兜底色, 否则会盖掉 .pt-N
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
|
||||
// 顶部彩色露出高度(底色由 cellVars 的渐变铺满整块)
|
||||
.pt-header {
|
||||
height: 24rpx;
|
||||
}
|
||||
|
||||
// 内嵌白卡: 浮在主题色底上, 左右下留边露出彩色(对齐 H5 缩略图层次)
|
||||
.pt-body {
|
||||
margin: 0 12rpx 18rpx;
|
||||
padding: 12rpx 14rpx;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pt-name {
|
||||
height: 14rpx;
|
||||
width: 56%;
|
||||
border-radius: 7rpx;
|
||||
background-color: var(--card-text, #1a1a1a);
|
||||
}
|
||||
|
||||
.pt-line {
|
||||
height: 7rpx;
|
||||
width: 80%;
|
||||
margin-top: 12rpx;
|
||||
border-radius: 4rpx;
|
||||
background-color: var(--card-text-soft, #9aa4b2);
|
||||
}
|
||||
|
||||
.pt-short {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.pt-check {
|
||||
position: absolute;
|
||||
right: 10rpx;
|
||||
bottom: 10rpx;
|
||||
width: 34rpx;
|
||||
height: 34rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.pt-check-mark {
|
||||
width: 13rpx;
|
||||
height: 6rpx;
|
||||
margin-top: -4rpx;
|
||||
border-left: 3rpx solid #2aa5ff;
|
||||
border-bottom: 3rpx solid #2aa5ff;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preset-name {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.preset-desc {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 20rpx;
|
||||
color: #8a94a6;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.preset-current {
|
||||
position: absolute;
|
||||
top: 8rpx;
|
||||
right: 8rpx;
|
||||
padding: 2rpx 10rpx;
|
||||
font-size: 20rpx;
|
||||
color: #ffffff;
|
||||
background-color: rgba(0, 0, 0, 0.35);
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 缩略图底色 =====
|
||||
* 按后台 14 个预设的固定顺序写死。
|
||||
* 为什么不用内联/渐变: 微信小程序会丢弃内联 background(渐变/纯色均可能失效),
|
||||
* 之前 14 个格子全部回落成同一个蓝就是因此。样式表 class 是唯一稳定的方式。
|
||||
* 后台预设顺序/配色调整时, 同步修改这里即可。
|
||||
*/
|
||||
.pt-0 { background-color: #119dff; } /* 原生样式 */
|
||||
.pt-1 { background-color: #12b886; } /* 商务绿 */
|
||||
.pt-2 { background-color: #f59f3c; } /* 暖橙 */
|
||||
.pt-3 { background-color: #7b61ff; } /* 紫罗兰 */
|
||||
.pt-4 { background-color: #0b5cff; } /* 经典商务 */
|
||||
.pt-5 { background-color: #1f2329; } /* 极简留白 */
|
||||
.pt-6 { background-color: #111827; } /* 深色霓虹 */
|
||||
.pt-7 { background-color: #f59f3c; } /* 暖调名片 */
|
||||
.pt-8 { background-color: #0b5cff; } /* 大图门面 */
|
||||
.pt-9 { background-color: #7aa7c7; }
|
||||
.pt-10 { background-color: #6cc24a; }
|
||||
.pt-11 { background-color: #111827; }
|
||||
.pt-12 { background-color: #d4a017; } /* 黑金质感: 金 */
|
||||
.pt-13 { background-color: #ef4444; } /* 朱砂活力: 红 */
|
||||
|
||||
.ps-empty {
|
||||
margin-top: 20rpx;
|
||||
font-size: 24rpx;
|
||||
color: #f5a623;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-loading {
|
||||
text-align: center;
|
||||
color: #999999;
|
||||
font-size: 26rpx;
|
||||
padding: 120rpx 0;
|
||||
}
|
||||
|
||||
/* 底部保存条 */
|
||||
.ps-footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
// 必须高于内容层: 否则预设格子里的绝对定位元素(如 .preset-tag z-index:2)
|
||||
// 会"浮"到固定底栏之上, 只露出角标而卡片本体被遮住
|
||||
z-index: 100;
|
||||
padding: 20rpx 24rpx;
|
||||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||
|
||||
.ps-save {
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
text-align: center;
|
||||
border-radius: 44rpx;
|
||||
background-color: #2aa5ff;
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
|
||||
&.disabled {
|
||||
background-color: #c8d6e5;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
244
pages_tool/card/views.vue
Normal file
244
pages_tool/card/views.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<view class="views-page">
|
||||
<!-- 汇总卡(2.11.4): 今日/累计 的 PV 与 UV -->
|
||||
<view class="summary">
|
||||
<view class="sum-card">
|
||||
<view class="sum-num">{{ summary.today_pv || 0 }}</view>
|
||||
<view class="sum-label">今日浏览 PV</view>
|
||||
</view>
|
||||
<view class="sum-card">
|
||||
<view class="sum-num">{{ summary.today_uv || 0 }}</view>
|
||||
<view class="sum-label">今日访客 UV</view>
|
||||
</view>
|
||||
<view class="sum-card">
|
||||
<view class="sum-num">{{ summary.total_pv || 0 }}</view>
|
||||
<view class="sum-label">累计浏览 PV</view>
|
||||
</view>
|
||||
<view class="sum-card">
|
||||
<view class="sum-num">{{ summary.total_uv || 0 }}</view>
|
||||
<view class="sum-label">累计访客 UV</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 列表: time / name / channel / device / ip -->
|
||||
<view class="view-list">
|
||||
<view v-for="(item, idx) in list" :key="item.id || idx" class="view-item">
|
||||
<view class="view-avatar">{{ avatarText(item) }}</view>
|
||||
<view class="view-info">
|
||||
<view class="view-name">{{ item.viewer_name || item.name || '匿名访客' }}</view>
|
||||
<view class="view-meta">
|
||||
<text class="view-channel">{{ item.channel || '未知渠道' }}</text>
|
||||
<text class="view-dot">·</text>
|
||||
<text class="view-device">{{ item.device || '' }}</text>
|
||||
<text class="view-dot" v-if="item.ip">·</text>
|
||||
<text class="view-ip">{{ item.ip || '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="view-time">{{ item.view_time || item.time || '' }}</view>
|
||||
</view>
|
||||
|
||||
<view class="load-more" v-if="list.length > 0">
|
||||
<text v-if="isLoading">加载中…</text>
|
||||
<text v-else-if="page < pageCount">上拉加载更多</text>
|
||||
<text v-else class="load-end">— 没有更多了 —</text>
|
||||
</view>
|
||||
<view v-if="!isLoading && list.length === 0" class="views-empty">暂无访客记录</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 访客轨迹 / 谁看了我(2.11.4,建议新页面)
|
||||
* 入口: 我的名片「访客」总入口(不带 card_id,查名下全部名片)
|
||||
* 或某张名片「浏览」入口(带 card_id,查该卡访客)
|
||||
* 接口: POST /card/api/card/views { card_id?, page?, page_size?(<=50, 默认20) }
|
||||
* 响应: { list, total, page, page_size, summary: { today_pv, today_uv, total_pv, total_uv } }
|
||||
*/
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
cardId: '', // 带 card_id=查该卡访客; 空=名下全部
|
||||
list: [],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
pageCount: 1,
|
||||
isLoading: false,
|
||||
summary: {}
|
||||
};
|
||||
},
|
||||
onLoad(options) {
|
||||
this.cardId = (options && options.card_id) ? String(options.card_id) : '';
|
||||
this.refresh();
|
||||
},
|
||||
onReady() {
|
||||
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||
uni.setNavigationBarTitle({ title: '名片' });
|
||||
// #ifdef H5
|
||||
document.title = '名片';
|
||||
// #endif
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
this.refresh(() => uni.stopPullDownRefresh());
|
||||
},
|
||||
onReachBottom() {
|
||||
if (this.isLoading) return;
|
||||
if (this.page >= this.pageCount) return;
|
||||
this.loadPage(this.page + 1);
|
||||
},
|
||||
methods: {
|
||||
avatarText(item) {
|
||||
const name = item.viewer_name || item.name || '?';
|
||||
return name.trim().charAt(0).toUpperCase();
|
||||
},
|
||||
refresh(done) {
|
||||
this.page = 1;
|
||||
this.loadPage(1, done);
|
||||
},
|
||||
loadPage(pageNo, done) {
|
||||
this.isLoading = true;
|
||||
const data = { page: pageNo, page_size: this.pageSize };
|
||||
if (this.cardId) data.card_id = this.cardId;
|
||||
this.$cardApi.cardViews({
|
||||
data: data,
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
const d = res.data || {};
|
||||
const rows = d.list || [];
|
||||
this.pageCount = d.page_count || 1;
|
||||
this.page = pageNo;
|
||||
this.summary = d.summary || {};
|
||||
this.list = pageNo === 1 ? rows : this.list.concat(rows);
|
||||
} else {
|
||||
this.handleApiError(res);
|
||||
}
|
||||
this.isLoading = false;
|
||||
typeof done === 'function' && done();
|
||||
},
|
||||
fail: () => {
|
||||
this.isLoading = false;
|
||||
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||
typeof done === 'function' && done();
|
||||
}
|
||||
});
|
||||
},
|
||||
handleApiError(res) {
|
||||
const msg = (res && res.message) || '加载失败';
|
||||
uni.showToast({ title: msg, icon: 'none' });
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.views-page {
|
||||
min-height: 100vh;
|
||||
background-color: #f6f7f9;
|
||||
padding: 20rpx 24rpx 40rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
.sum-card {
|
||||
flex: 1;
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx 0;
|
||||
text-align: center;
|
||||
margin: 0 8rpx;
|
||||
|
||||
&:first-child { margin-left: 0; }
|
||||
&:last-child { margin-right: 0; }
|
||||
|
||||
.sum-num {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: var(--main-color, #0054a5);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sum-label {
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.view-list {
|
||||
.view-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 16rpx;
|
||||
|
||||
.view-avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background-color: var(--main-color-shallow, #e8f1fb);
|
||||
color: var(--main-color, #0054a5);
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.view-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.view-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.view-meta {
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.view-dot {
|
||||
margin: 0 10rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.view-time {
|
||||
flex-shrink: 0;
|
||||
margin-left: 16rpx;
|
||||
font-size: 24rpx;
|
||||
color: #bbb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.load-more {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 24rpx;
|
||||
padding: 24rpx 0;
|
||||
|
||||
.load-end { color: #ccc; }
|
||||
}
|
||||
|
||||
.views-empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 28rpx;
|
||||
padding: 120rpx 0;
|
||||
}
|
||||
</style>
|
||||
@@ -230,4 +230,5 @@ export default {
|
||||
background-color:var(--main-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -25,7 +25,7 @@
|
||||
</view>
|
||||
</block>
|
||||
<view>
|
||||
<ns-empty v-if="list.length === 0" isIndex="false" :text="$lang('seal.medium.search.noData')"></ns-empty>
|
||||
<ns-empty v-if="list.length === 0" :isIndex="false" :text="$lang('seal.medium.search.noData')"></ns-empty>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
226
readme.md
226
readme.md
@@ -66,26 +66,6 @@
|
||||
- 用微信开发者工具导入解压后的代码目录
|
||||
- 编译验证无误后,上传发布
|
||||
|
||||
#### 4.1.2 鸿蒙小程序发布(mp-harmony)
|
||||
|
||||
##### ##### ##### ##### 【发布构建】
|
||||
1. 使用 HBuilderX 打开项目
|
||||
2. 选择菜单栏「发行」 → 「HarmonyOS」→ 「发行」,等待构建完成
|
||||
3. 构建完成后,**自动执行** `harmony.patch.js` 补丁脚本(配置拷贝 + ZIP 打包)
|
||||
4. 找到项目根目录 `/unpackage/dist/build` 下生成的 mp-harmony 压缩包
|
||||
|
||||
##### ##### ##### ##### 【交付与最终发布】🔍
|
||||
1. 将生成的 `.zip` 文件发送给运维人员
|
||||
2. **运维人员操作流程**:
|
||||
- 解压压缩包
|
||||
- 打开鸿蒙开发者工具,导入解压后的代码目录
|
||||
- 验证代码无报错后,执行上传发布操作
|
||||
|
||||
##### ##### ##### ##### 【本地设置】
|
||||

|
||||
#####
|
||||
---
|
||||
|
||||
### 4.2 快应用发布
|
||||
|
||||
#### 4.2.1 基本操作(通用版/定制化版)
|
||||
@@ -122,6 +102,212 @@ H5:打开自己的浏览器输入网址(https://5g.aigc-quickapp.com/hwappx/co
|
||||
|
||||
---
|
||||
|
||||
### 4.3 鸿蒙元服务发布(mp-harmony)
|
||||
|
||||
#### 4.3.1 【发布构建】
|
||||
1. 使用 HBuilderX 打开项目
|
||||
2. 选择菜单栏「发行」 → 「HarmonyOS」→ 「发行」,等待构建完成
|
||||
3. 构建完成后,**自动执行** `harmony.patch.js` 补丁脚本(配置拷贝 + ZIP 打包)
|
||||
4. 找到项目根目录 `/unpackage/dist/build` 下生成的 mp-harmony 压缩包
|
||||
|
||||
#### 4.3.2 【DevEco Studio 编译与产物】
|
||||
在 DevEco Studio 中导入 `mp-harmony` 工程后,点击「Build」→「Build App(s)/Project(s)」,实际执行的任务是 `assembleApp`:
|
||||
|
||||
```
|
||||
# 由 DevEco Studio 自动执行,无需手动敲
|
||||
hvigorw --mode project -p product=default assembleApp
|
||||
```
|
||||
|
||||
这个任务是**两步串联**的 —— **先逐个 module 编译出包,再把这些包打成 APP**:
|
||||
|
||||
| 产物 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| 分包(中间产物) | `{module}/build/default/outputs/default/{module}-default-signed.{hap\|hsp}` | 每个 module 一个,编译+签名的最小单位 |
|
||||
| APP(最终产物) | `build/outputs/default/mp-harmony-default-signed.app` | 把全部分包收拢成的分发单元 |
|
||||
|
||||
📌 **元服务上架 AppGallery Connect 上传的是 `.app`**,不是 `.hap`。`pack.info` 中 `"bundleType": "atomicService"` 即元服务包标记。
|
||||
|
||||
📌 **本工程共 1 个 HAP + 5 个 HSP**(不是 6 个 HAP)—— `module.json5` 里 `type: "entry"` 编成 **HAP**,`type: "shared"` 编成 **HSP**(共享包,动态下发)。详见 `build/outputs/default/pack.info`:
|
||||
|
||||
| 包名 | 扩展名 | moduleType | 说明 |
|
||||
|------|--------|------------|------|
|
||||
| `entry-default` | `.hap` | `entry` | 主包,EntryAbility + 服务卡片 + uniapp 主 bundle |
|
||||
| `components_diy-default` | `.hsp` | `shared` | ascf 动态化分包 |
|
||||
| `pages_goods-default` | `.hsp` | `shared` | ascf 动态化分包 |
|
||||
| `pages_order-default` | `.hsp` | `shared` | ascf 动态化分包 |
|
||||
| `pages_promotion-default` | `.hsp` | `shared` | ascf 动态化分包 |
|
||||
| `pages_tool-default` | `.hsp` | `shared` | ascf 动态化分包 |
|
||||
|
||||
⚠️ **主包体积**:元服务官方要求**单个包 ≤ 2MB**(按压缩后算,含其直接依赖的 HSP),同一设备类型下全部分包总和 ≤ 10MB。实测本工程主包 `entry-default.hap` 约 **2.37 MiB**,已超单包 2MB 限制;总包约 **6.39 MiB**,在 10MB 以内。
|
||||
|
||||
> 若只想单模块调试,可在 DevEco 中用「Build Hap(s)/Module(s)」(即 `assembleHap`),只产出分包、不打 APP。
|
||||
|
||||
#### 4.3.3 【工程包导出(换机编译)】
|
||||
|
||||
需要在一台**没有本仓库 / 没有 HBuilderX** 的电脑上编译鸿蒙工程时,用脚本把 `stub-app-projects/mp-harmony` 里编译所需的文件导成一个 zip:
|
||||
|
||||
```bash
|
||||
npm run pack:harmony
|
||||
```
|
||||
|
||||
输出到 `unpackage/dist/harmony-export/mp-harmony-project-v<日期>-<提交ID>.zip`,解压后即得 `mp-harmony/` 目录。
|
||||
|
||||
| 参数 | 用途 |
|
||||
|------|------|
|
||||
| (无参数) | 默认行为:含签名证书,不含 `node_modules` |
|
||||
| `--no-certs` | 不含 `cert-profile/`(见下方安全提示) |
|
||||
| `--with-node-modules` | 额外含 `hvigor/node_modules`(约 320 MB),供**离线**目标机 |
|
||||
| `--out <目录>` | 自定义输出目录 |
|
||||
|
||||
**脚本全程只读源目录**,产物先复制到暂存目录再压缩,不修改 `stub-app-projects` 下任何文件。
|
||||
|
||||
**导出内容**
|
||||
|
||||
| 类别 | 说明 |
|
||||
|------|------|
|
||||
| 工程源码 | `AppScope/`、`entry/src/`、`ascf/ascf_build/*/src/`、各类 `*.json5` / `hvigorfile.ts` |
|
||||
| `ascf/ascf_src/` | uniapp 编译产物(页面/组件代码),**带上它目标机就不需要本仓库和 HBuilderX** |
|
||||
| `cert-profile/` | 签名证书(`--no-certs` 可排除) |
|
||||
|
||||
**已排除**(可由目标机 DevEco 重新生成)
|
||||
|
||||
`node_modules/`(约 320 MB)、`oh_modules/`、`**/build/`、`.preview/`(约 22 MB)、`.idea/`、`.hvigor/`、`local.properties`、`.cxx/`、`.test/`
|
||||
|
||||
> ⚠️ **证书安全**:默认导出**包含签名私钥** `cert-profile/qq/qq.p12`,且 p12 密码在 `build-profile.json5` 中本就是明文。请仅在可信渠道传递,用完及时销毁或归档。对外发给不可信对象时务必加 `--no-certs`。
|
||||
>
|
||||
> 📌 缺证书 hvigor 无法签名,产不出可上传的 `.app`。用 `--no-certs` 时需自行把 `cert-profile/` 放到目标工程对应位置。
|
||||
|
||||
**目标机操作**
|
||||
|
||||
1. 安装 DevEco Studio 5.0.x(本工程 `compatibleSdkVersion` 5.0.0(12))
|
||||
2. 解压 zip,得到 `mp-harmony/`
|
||||
3. DevEco Studio「打开」`mp-harmony` 目录本身
|
||||
4. 首次打开自动执行依赖同步(`ohpm install` 拉取 `hvigor/node_modules`),**需联网**;离线机请用 `--with-node-modules` 导出
|
||||
5. 「Build」→「Build App(s)/Project(s)」,产物 `build/outputs/default/mp-harmony-default-signed.app`
|
||||
|
||||
> ⚠️ **`ascf/ascf_src/` 是导出时刻的快照**。若前端代码有更新,必须回源机器重新执行导出,否则编译出的包内容是旧的。
|
||||
|
||||
📌 zip 由脚本内纯 Node 实现(`zlib` + 手写 ZIP 头)生成,条目名统一正斜杠并置 UTF-8 标志位,可跨 Windows/macOS/Linux 使用。
|
||||
|
||||
**产出后自检(每次导出都会跑)**
|
||||
|
||||
脚本写完 zip 后会把文件**重新解析一遍**,任何一项不符直接判失败、不输出「完成」:
|
||||
|
||||
| 校验项 | 内容 |
|
||||
|---|---|
|
||||
| **零漏拷 / 零多拷** | 把本次实际复制的文件清单与 zip 条目**逐个双向比对**,有任何文件没打进去、或出现清单外的文件,当场失败 |
|
||||
| EOCD / 中央目录 | 签名、条目数、中央目录偏移与长度边界一致 |
|
||||
| `localOffset` | 每条都必须真的指向本地文件头,且文件名与中央目录一致 |
|
||||
| CRC | **逐条**解压后重算 CRC32 比对 |
|
||||
| 条目名 | 必须正斜杠、必须置 UTF-8 标志位、必须可逆解码为合法 UTF-8 |
|
||||
| 内容断言 | 构建必需文件齐全;`node_modules`/`build`/`.preview` 等排除项不得混入 |
|
||||
|
||||
```bash
|
||||
npm test # 跑全量测试, 含 pack-harmony-project.test.js 的 19 条用例
|
||||
```
|
||||
|
||||
正常输出示例:
|
||||
|
||||
```
|
||||
条目: 1382 个文件 + 327 个目录 (自检通过)
|
||||
自检: 零漏拷 ✅(1381/1381) / CRC 逐条 ✅ / 偏移一致 ✅ / 正斜杠 ✅ / UTF-8 ✅ / 骨架齐全 ✅
|
||||
```
|
||||
|
||||
> 📌 **不必再人工核对"某个目录有没有打进去"**——上面的「零漏拷」会逐文件双向比对并打印数量。怀疑漏拷时看这一行即可;若真的漏了,脚本会直接失败并列出文件名。
|
||||
>
|
||||
> 📌 `EntryCard/` 不在 `build-profile.json5` 的模块列表中,**不是编译模块**,其内容不参与构建;但导出脚本按「全量复制 − 排除清单」处理,同样会把它带进 zip。
|
||||
|
||||
> 📌 测试分**正向**(写出来能还原)与**反向**(故意篡改 zip,断言自检能抓到)两类。反向用例锁定了开发期真实踩过的三个 bug:中央目录偏移漏算文件数据长度、`localOffset` 记成数据区起点、`.test` 子串匹配误伤 `entry/src/ohosTest/ets/test/` 源码。**反向用例如果失效,自检就形同虚设**,改动脚本时务必保持它们通过。
|
||||
|
||||
#### 4.3.4 【常见错误】
|
||||
|
||||
**❌ `hvigor ERROR: 00303218 Configuration Error`**
|
||||
```
|
||||
Error Message: The reason and usedScene attributes are mandatory for user_grant permissions.
|
||||
at entry\src\main\module.json5
|
||||
```
|
||||
|
||||
**原因**:`user_grant` 类型权限必须**同时**声明 `reason` 和 `usedScene` 两个字段,只写 `usedScene` 会导致 `:entry:default@PreBuild` 失败。
|
||||
|
||||
**解决**:给对应权限补上 `reason` 字段,并在 `entry/src/main/resources/base/element/string.json` 中新增对应的理由文案。
|
||||
|
||||
```json5
|
||||
// entry/src/main/module.json5
|
||||
{
|
||||
"name": "ohos.permission.MICROPHONE",
|
||||
"reason": "$string:permission_microphone_reason",
|
||||
"usedScene": {
|
||||
"abilities": ["EntryAbility"],
|
||||
"when": "always"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// entry/src/main/resources/base/element/string.json
|
||||
{
|
||||
"name": "permission_microphone_reason",
|
||||
"value": "用于语音搜索与语音输入功能,录制语音以识别用户需求"
|
||||
}
|
||||
```
|
||||
|
||||
📌 **权限类型速查** —— 只有 `user_grant` 才需要 `reason`:
|
||||
|
||||
| 权限 | 类型 | 需要 reason |
|
||||
|------|------|-------------|
|
||||
| `ohos.permission.INTERNET` | system_grant | 否 |
|
||||
| `ohos.permission.GET_NETWORK_INFO` | system_grant | 否 |
|
||||
| `ohos.permission.VIBRATE` | system_grant | 否 |
|
||||
| `ohos.permission.MICROPHONE` | **user_grant** | **是** |
|
||||
|
||||
📌 `reason` 文案必须是**用户可读的中文说明**,不能为空或写 `xxx description` 之类的占位文本,否则上架审核会被驳回。
|
||||
|
||||
📌 **改完 `reason` 还不够** —— `user_grant` 权限编译通过后,仍需在运行时弹窗申请,否则录音等功能不可用。本项目已在 [EntryAbility.ets](stub-app-projects/mp-harmony/entry/src/main/ets/entryability/EntryAbility.ets) 的 `onCreate` 中接入:
|
||||
|
||||
```ts
|
||||
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
||||
// 必须先调父类: ascf runtime 的初始化在 AscfUIAbility.onCreate 里, 漏掉会导致元服务白屏
|
||||
super.onCreate(want, launchParam);
|
||||
this.requestMicrophonePermission();
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ **坑**:`AscfUIAbility` 父类**已定义** `onCreate`,重写时必须调 `super.onCreate(want, launchParam)`,否则 ascf runtime 初始化被跳过,元服务白屏。
|
||||
|
||||
> ⚠️ **坑**:`Permissions` 在 `@ohos.permissions` 里是**字符串联合类型**([permissions.d.ts](https://gitee.com/openharmony/docs/blob/master/zh-cn/application-dev/reference/apis-ability-kit/js-apis-permissions.md)),不是枚举,**只能用在类型位置**,当值引用会报:
|
||||
> ```
|
||||
> 'Permissions' only refers to a type, but is being used as a value here.
|
||||
> ```
|
||||
> 所以正确写法是标注 + 字面量,且权限名必须与 `module.json5` 中声明的一致:
|
||||
> ```ts
|
||||
> import { abilityAccessCtrl, AbilityConstant, Permissions, Want } from '@kit.AbilityKit';
|
||||
>
|
||||
> const PERMISSION_MICROPHONE: Permissions = 'ohos.permission.MICROPHONE';
|
||||
> // ❌ Permissions.MICROPHONE —— 编译不过
|
||||
> // ⚠️ abilityAccessCtrl.SwitchType.MICROPHONE(=1) 是"开关类型",不是权限名,别搞混
|
||||
> ```
|
||||
|
||||
📌 为什么在 `onCreate` 申请而不是"用户点录音时才申请":
|
||||
- 权限状态在 JS 侧(`uni.getRecorderManager`)读不到,项目里也没有现成的 JS ↔ ArkTS 通道,做不了按需触发
|
||||
- `requestPermissionsFromUser` 对已授权的应用直接 resolve 且**不弹窗**,所以实际只有首次启动打扰用户一次
|
||||
|
||||
📌 用户拒绝后的降级:不影响浏览/下单/卡片等其他功能,仅语音输入不可用;[ai-chat-voice-panel.vue](pages_tool/ai-chat/components/ai-chat-voice-panel.vue) 会 catch 到 recorder 错误并 `showToast` 提示用户。
|
||||
|
||||
#### 4.3.5 【待完善】⚠️
|
||||
- **release 签名仍指向 debug 证书**:`build-profile.json5` 中 `release` 签名的 `profile` 仍是 `./cert-profile/qq/qqDebug.p7b`,与 `default`(debug)共用同一份证书。本地调试无碍,但提交 AppGallery 前必须换成**发布证书 + release 类型 provisioning profile**,否则 AGC 校验会直接拒绝。
|
||||
|
||||
#### 4.3.6 【交付与最终发布】🔍
|
||||
1. 将生成的 `.zip` 文件发送给运维人员
|
||||
2. **运维人员操作流程**:
|
||||
- 解压压缩包
|
||||
- 打开鸿蒙开发者工具,导入解压后的代码目录
|
||||
- 验证代码无报错后,执行上传发布操作
|
||||
|
||||
#### 4.3.7 【本地设置】
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 5. 📝 构建与版本信息
|
||||
|
||||
### 5.1 自动注入机制
|
||||
|
||||
639
scripts/pack-harmony-project.js
Normal file
639
scripts/pack-harmony-project.js
Normal file
@@ -0,0 +1,639 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* pack-harmony-project.js
|
||||
*
|
||||
* 把 stub-app-projects/mp-harmony 中「编译所需」的文件导出为一个 zip,
|
||||
* 供其他电脑解压后直接用 DevEco Studio 编译并上传产物。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/pack-harmony-project.js # 默认: 含证书, 不含 node_modules
|
||||
* node scripts/pack-harmony-project.js --with-node-modules # 额外含 hvigor/node_modules (约 320MB), 供离线机器
|
||||
* node scripts/pack-harmony-project.js --no-certs # 不含签名证书
|
||||
* node scripts/pack-harmony-project.js --out <dir> # 自定义输出目录 (默认 unpackage/dist/harmony-export)
|
||||
*
|
||||
* 设计原则:
|
||||
* - **只读**源目录, 全程不修改 stub-app-projects 下任何文件 (产物先复制到暂存目录再压缩)
|
||||
* - 采用「全量复制 - 排除清单」策略, 而不是白名单, 避免漏掉新增的必需文件
|
||||
* - 输出 zip 内含一层 mp-harmony/ 目录, 解压后不会散落一地
|
||||
*
|
||||
* 为什么必须包含 cert-profile:
|
||||
* build-profile.json5 的 signingConfigs 引用了 ./cert-profile/qq/ 下的
|
||||
* qq.p12 / qqDebug.p7b / qq.cer。证书缺失时 hvigor 无法签名, 产不出可上传的 .app。
|
||||
* ⚠️ 默认包含意味着 zip 内含私钥, 仅在可信渠道传递。
|
||||
*
|
||||
* 为什么包含 ascf/ascf_src:
|
||||
* 它是 uniapp 构建产物 (由 vue.config.js 的 _copyMpHarmonyBuild 从
|
||||
* unpackage/dist/build/.mp-harmony 拷入), 装着真正的页面/组件代码。
|
||||
* 带上它, 目标机就不需要本仓库和 HBuilderX, 解压即可编译。
|
||||
* ⚠️ 它是导出时刻的快照; 若前端代码有更新, 需重新执行本脚本。
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const fsp = fs.promises;
|
||||
const path = require('path');
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const PROJECT_DIR_NAME = 'mp-harmony';
|
||||
const DEFAULT_OUT_DIR = path.join('unpackage', 'dist', 'harmony-export');
|
||||
|
||||
/**
|
||||
* 目录名黑名单 —— 出现在任意层级都跳过
|
||||
*
|
||||
* - node_modules / oh_modules: 依赖, 可由 DevEco 打开工程时自动拉取
|
||||
* (hvigor/node_modules 约 320MB, 不可能进包; 需要离线机时用 --with-node-modules)
|
||||
* - build: 各 module 的编译产物
|
||||
* - .preview: DevEco 预览缓存 (entry 下就占 22MB)
|
||||
* - .hvigor / .idea: IDE 本地状态
|
||||
* - .cxx / .test: 原生编译缓存与测试产物
|
||||
*/
|
||||
const EXCLUDE_DIRS = new Set([
|
||||
'node_modules', 'oh_modules', 'build', '.preview', '.hvigor', '.idea',
|
||||
'.cxx', '.test', '.git', '.appanalyzer', '.clangd', '.kotlin',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 文件名黑名单 —— 出现在任意层级都跳过
|
||||
*
|
||||
* - local.properties: DevEco 自动生成, 内含本机 SDK 路径
|
||||
* - _1.clang-format / _1.gitignore: macOS Finder 产生的重名副本 (Windows 上是 _1, macOS 是 _副本)
|
||||
*/
|
||||
const EXCLUDE_FILES = new Set([
|
||||
'local.properties', '.DS_Store', 'Thumbs.db', '_1.clang-format', '_1.gitignore',
|
||||
]);
|
||||
|
||||
/** 含私钥的目录, --no-certs 时跳过 */
|
||||
const CERT_DIR = 'cert-profile';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const opts = {
|
||||
withNodeModules: false,
|
||||
withCerts: true,
|
||||
outDir: null,
|
||||
};
|
||||
for (const arg of argv) {
|
||||
if (arg === '--with-node-modules') opts.withNodeModules = true;
|
||||
else if (arg === '--no-certs') opts.withCerts = false;
|
||||
else if (arg === '--out') { /* 由调用方给出下一项 */ }
|
||||
}
|
||||
const outIdx = argv.indexOf('--out');
|
||||
if (outIdx !== -1 && outIdx + 1 < argv.length) {
|
||||
opts.outDir = path.resolve(argv[outIdx + 1]);
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
/** 该目录是否需要跳过 */
|
||||
function shouldSkipDir(name, opts, isRootLevel) {
|
||||
if (EXCLUDE_DIRS.has(name)) {
|
||||
// node_modules 只有根级 hvigor/node_modules 需要可选保留;
|
||||
// oh_modules / entry/node_modules 等一律跳过 (体积小, DevEco 会重建)
|
||||
if (opts.withNodeModules && name === 'node_modules' && isRootLevel) return false;
|
||||
return true;
|
||||
}
|
||||
if (!opts.withCerts && name === CERT_DIR) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldSkipFile(name) {
|
||||
if (EXCLUDE_FILES.has(name)) return true;
|
||||
// 编译/日志类临时文件
|
||||
return /\.(log|tmp|swp)$/.test(name);
|
||||
}
|
||||
|
||||
const stats = { files: 0, bytes: 0, skipped: 0, copied: [] };
|
||||
|
||||
/**
|
||||
* 复制源目录到暂存目录 (应用排除规则), 同时记录每个被复制文件在 zip 中应有的条目名
|
||||
* @param {string} srcDir
|
||||
* @param {string} destDir
|
||||
* @param {object} opts
|
||||
* @param {string} relBase 该目录在 zip 中的条目名前缀 (首层传 PROJECT_DIR_NAME)
|
||||
* @param {number} depth
|
||||
*/
|
||||
async function copyDir(srcDir, destDir, opts, relBase = PROJECT_DIR_NAME, depth = 0) {
|
||||
await fsp.mkdir(destDir, { recursive: true });
|
||||
const entries = await fsp.readdir(srcDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(srcDir, entry.name);
|
||||
const destPath = path.join(destDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (shouldSkipDir(entry.name, opts, depth === 0)) {
|
||||
stats.skipped++;
|
||||
continue;
|
||||
}
|
||||
await copyDir(srcPath, destPath, opts, `${relBase}/${entry.name}`, depth + 1);
|
||||
} else if (entry.isFile()) {
|
||||
if (shouldSkipFile(entry.name)) {
|
||||
stats.skipped++;
|
||||
continue;
|
||||
}
|
||||
await fsp.copyFile(srcPath, destPath);
|
||||
const st = await fsp.stat(destPath);
|
||||
stats.files++;
|
||||
stats.bytes += st.size;
|
||||
stats.copied.push(`${relBase}/${entry.name}`);
|
||||
}
|
||||
// 符号链接等其它类型直接跳过
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
* ZIP 写入 (纯 Node, 零依赖)
|
||||
*
|
||||
* 为什么要自己写:
|
||||
* 本机两种系统方案都产出**不合规**的 zip, 且失败方式是静默的:
|
||||
* 1. PowerShell Compress-Archive / [ZipFile]::CreateFromDirectory
|
||||
* 写入的条目名用**反斜杠** (mp-harmony\AppScope\app.json5)。ZIP 规范要求
|
||||
* 正斜杠; Windows 解压侥幸能过, macOS/Linux 会把 `\` 当字面量, 解出畸形路径。
|
||||
* 2. 本机 tar 是 GNU tar 1.35, **不支持创建 zip** (`-a` 仅对 tar 归档生效),
|
||||
* 而且 exit 0, try/catch 拦不住, 会产出无法解压的 .zip。
|
||||
* 另外 .NET 方案还把中文文件名写成 ANSI 编码 (导出说明.txt -> 乱码), 因为没置
|
||||
* UTF-8 标志位 (general purpose bit 11)。
|
||||
*
|
||||
* 自己写则能保证: 条目名统一正斜杠 + UTF-8 标志位恒置 + 跨平台字节一致。
|
||||
* ========================================================================== */
|
||||
|
||||
const zlib = require('zlib');
|
||||
|
||||
/** CRC32 查表 (标准多项式 0xEDB88320) */
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Int32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(buf) {
|
||||
let c = -1;
|
||||
for (let i = 0; i < buf.length; i++) c = (c >>> 8) ^ CRC_TABLE[(c ^ buf[i]) & 0xff];
|
||||
return (c ^ -1) >>> 0;
|
||||
}
|
||||
|
||||
/** JS Date -> DOS 日期/时间 (ZIP 格式要求, 精度到 2 秒) */
|
||||
function toDosDateTime(date) {
|
||||
return {
|
||||
time: ((date.getHours() & 0x1f) << 11) | ((date.getMinutes() & 0x3f) << 5) | ((date.getSeconds() / 2) & 0x1f),
|
||||
date: (((date.getFullYear() - 1980) & 0x7f) << 9) | (((date.getMonth() + 1) & 0xf) << 5) | (date.getDate() & 0x1f),
|
||||
};
|
||||
}
|
||||
|
||||
/** 文件头 4 字节 */
|
||||
const SIG_LOCAL = 0x04034b50;
|
||||
const SIG_CENTRAL = 0x02014b50;
|
||||
const SIG_EOCD = 0x06054b50;
|
||||
/** 通用标志位: bit11 = 文件名/注释为 UTF-8 */
|
||||
const FLAG_UTF8 = 0x0800;
|
||||
/** 单文件超过 4GB 需要 ZIP64, 本工程产物远小于此, 直接拒绝而不是产出坏包 */
|
||||
const MAX_FILE_SIZE = 0xffffffff;
|
||||
|
||||
/**
|
||||
* 把 dirToZip 整个目录压成 zipPath, 顶层保留目录名
|
||||
* @returns {Promise<{files:number, bytes:number}>}
|
||||
*/
|
||||
async function zipDirectory(dirToZip, zipPath) {
|
||||
const rootName = path.basename(dirToZip);
|
||||
const rootAbs = path.resolve(dirToZip);
|
||||
|
||||
// 收集文件与目录, 路径统一转正斜杠
|
||||
const fileEntries = [];
|
||||
const dirEntries = [];
|
||||
|
||||
// ⚠️ 必须 await: 不 await 的话 zip 会只写入部分条目就收尾,
|
||||
// 且递归 walk 会在暂存目录被清理后继续跑并抛 ENOENT
|
||||
await (async function walk(absDir, relBase) {
|
||||
const items = await fsp.readdir(absDir, { withFileTypes: true });
|
||||
for (const item of items) {
|
||||
const abs = path.join(absDir, item.name);
|
||||
const rel = relBase ? `${relBase}/${item.name}` : item.name;
|
||||
if (item.isDirectory()) {
|
||||
dirEntries.push(rel);
|
||||
await walk(abs, rel);
|
||||
} else if (item.isFile()) {
|
||||
fileEntries.push({ abs, rel });
|
||||
}
|
||||
}
|
||||
})(rootAbs, rootName);
|
||||
|
||||
const { time, date } = toDosDateTime(new Date());
|
||||
const chunks = [];
|
||||
const central = [];
|
||||
let offset = 0;
|
||||
|
||||
const pushLocalHeader = (nameBuf, method, crc, compSize, uncompSize) => {
|
||||
// ⚠️ 必须在累加前取值: 中央目录记录的 localOffset 指向**本地文件头的起始**,
|
||||
// 不是紧随其后的数据区
|
||||
const localOffset = offset;
|
||||
const h = Buffer.alloc(30);
|
||||
h.writeUInt32LE(SIG_LOCAL, 0);
|
||||
h.writeUInt16LE(20, 4); // version needed
|
||||
h.writeUInt16LE(FLAG_UTF8, 6); // 标志位: UTF-8 文件名
|
||||
h.writeUInt16LE(method, 8);
|
||||
h.writeUInt16LE(time, 10);
|
||||
h.writeUInt16LE(date, 12);
|
||||
h.writeUInt32LE(crc, 14);
|
||||
h.writeUInt32LE(compSize, 18);
|
||||
h.writeUInt32LE(uncompSize, 22);
|
||||
h.writeUInt16LE(nameBuf.length, 26);
|
||||
h.writeUInt16LE(0, 28); // extra len
|
||||
chunks.push(h, nameBuf);
|
||||
offset += h.length + nameBuf.length;
|
||||
return localOffset;
|
||||
};
|
||||
|
||||
const pushCentralHeader = (nameBuf, method, crc, compSize, uncompSize, localOffset) => {
|
||||
const c = Buffer.alloc(46);
|
||||
c.writeUInt32LE(SIG_CENTRAL, 0);
|
||||
c.writeUInt16LE(20, 4); // version made by
|
||||
c.writeUInt16LE(20, 6); // version needed
|
||||
c.writeUInt16LE(FLAG_UTF8, 8);
|
||||
c.writeUInt16LE(method, 10);
|
||||
c.writeUInt16LE(time, 12);
|
||||
c.writeUInt16LE(date, 14);
|
||||
c.writeUInt32LE(crc, 16);
|
||||
c.writeUInt32LE(compSize, 20);
|
||||
c.writeUInt32LE(uncompSize, 24);
|
||||
c.writeUInt16LE(nameBuf.length, 28);
|
||||
c.writeUInt16LE(0, 30); // extra len
|
||||
c.writeUInt16LE(0, 32); // comment len
|
||||
c.writeUInt16LE(0, 34); // disk number
|
||||
c.writeUInt16LE(0, 36); // internal attrs
|
||||
c.writeUInt32LE(0, 38); // external attrs
|
||||
c.writeUInt32LE(localOffset, 42);
|
||||
central.push(c, nameBuf);
|
||||
};
|
||||
|
||||
// 目录条目 (以 / 结尾), 保证空目录也能保留
|
||||
for (const rel of dirEntries) {
|
||||
const nameBuf = Buffer.from(`${rel}/`, 'utf8');
|
||||
const localOffset = pushLocalHeader(nameBuf, 0, 0, 0, 0);
|
||||
pushCentralHeader(nameBuf, 0, 0, 0, 0, localOffset);
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
for (const f of fileEntries) {
|
||||
const data = await fsp.readFile(f.abs);
|
||||
if (data.length > MAX_FILE_SIZE) {
|
||||
throw new Error(`文件超过 4GB, 需要 ZIP64 支持: ${f.rel} (${data.length} 字节)`);
|
||||
}
|
||||
const crc = crc32(data);
|
||||
// 先试 deflate, 若压缩后反而更大则退回 stored
|
||||
const deflated = zlib.deflateRawSync(data, { level: 9 });
|
||||
const useDeflate = deflated.length < data.length;
|
||||
const payload = useDeflate ? deflated : data;
|
||||
const method = useDeflate ? 8 : 0;
|
||||
|
||||
const nameBuf = Buffer.from(f.rel, 'utf8');
|
||||
const localOffset = pushLocalHeader(nameBuf, method, crc, payload.length, data.length);
|
||||
chunks.push(payload);
|
||||
// ⚠️ 必须把文件数据长度计入 offset, 否则 EOCD 里的中央目录偏移会偏小,
|
||||
// 解压工具会报 "N extra bytes at beginning or within zipfile"
|
||||
offset += payload.length;
|
||||
pushCentralHeader(nameBuf, method, crc, payload.length, data.length, localOffset);
|
||||
totalBytes += data.length;
|
||||
}
|
||||
|
||||
// EOCD
|
||||
const centralBuf = Buffer.concat(central);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(SIG_EOCD, 0);
|
||||
eocd.writeUInt16LE(0, 4);
|
||||
eocd.writeUInt16LE(0, 6);
|
||||
eocd.writeUInt16LE(fileEntries.length + dirEntries.length, 8);
|
||||
eocd.writeUInt16LE(fileEntries.length + dirEntries.length, 10);
|
||||
eocd.writeUInt32LE(centralBuf.length, 12);
|
||||
eocd.writeUInt32LE(offset, 16);
|
||||
eocd.writeUInt16LE(0, 20);
|
||||
|
||||
await fsp.writeFile(zipPath, Buffer.concat([...chunks, centralBuf, eocd]));
|
||||
return { files: fileEntries.length, dirs: dirEntries.length, bytes: totalBytes };
|
||||
}
|
||||
|
||||
function humanSize(bytes) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MiB`;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
* 产出后自检
|
||||
*
|
||||
* 手写 ZIP 编码器最容易出的两类 bug 都是**静默**的:
|
||||
* - 偏移算错 -> 文件内容对但部分条目解不出来 (不校验就发现不了)
|
||||
* - 标志位/分隔符写错 -> Windows 能解开, macOS/Linux 解出畸形路径
|
||||
* 所以每次导出后都把 zip 重新解析一遍, 任何一项不符就判失败, 不输出「完成」。
|
||||
*
|
||||
* 刻意不复用写入端的逻辑: 这里用独立的解析实现, 避免自证其说。
|
||||
* ========================================================================== */
|
||||
|
||||
/**
|
||||
* @param {string} zipPath
|
||||
* @param {{withCerts?: boolean, skipRequired?: boolean, expectFiles?: string[], rootName?: string}} opts
|
||||
* skipRequired: 仅供单元测试用夹具目录时跳过"必需文件"断言
|
||||
* expectFiles: 实际复制到暂存目录的文件清单 (zip 内条目名), 用于零漏拷/零多拷比对
|
||||
* rootName: zip 内顶层目录名 (默认 mp-harmony)
|
||||
* @returns {Promise<{entries:number, files:number, dirs:number}>}
|
||||
* @throws {Error} 任一项校验不通过
|
||||
*/
|
||||
async function verifyZipFile(zipPath, opts = {}) {
|
||||
const rootName = opts.rootName || PROJECT_DIR_NAME;
|
||||
const buf = await fsp.readFile(zipPath);
|
||||
const fail = (msg) => { throw new Error(`zip 自检失败: ${msg}`); };
|
||||
|
||||
// --- 定位并校验 EOCD (从尾部往前扫, 兼容可能存在的注释) ---
|
||||
const EOCD_MIN = 22;
|
||||
if (buf.length < EOCD_MIN) fail('文件小于 EOCD 最小长度');
|
||||
let eocd = -1;
|
||||
for (let i = buf.length - EOCD_MIN; i >= 0; i--) {
|
||||
if (buf.readUInt32LE(i) === SIG_EOCD) { eocd = i; break; }
|
||||
}
|
||||
if (eocd === -1) fail('找不到 EOCD 结尾记录');
|
||||
|
||||
const totalEntries = buf.readUInt16LE(eocd + 10);
|
||||
const centralSize = buf.readUInt32LE(eocd + 12);
|
||||
const centralOffset = buf.readUInt32LE(eocd + 16);
|
||||
|
||||
if (centralOffset + centralSize > buf.length) {
|
||||
fail(`中央目录越界 (offset=${centralOffset} size=${centralSize} 文件长=${buf.length})`);
|
||||
}
|
||||
|
||||
// --- 逐条遍历中央目录 ---
|
||||
const names = [];
|
||||
let p = centralOffset;
|
||||
for (let idx = 0; idx < totalEntries; idx++) {
|
||||
if (p + 46 > buf.length) fail(`第 ${idx} 条中央目录记录越界`);
|
||||
if (buf.readUInt32LE(p) !== SIG_CENTRAL) fail(`第 ${idx} 条中央目录签名错误`);
|
||||
|
||||
const flags = buf.readUInt16LE(p + 8);
|
||||
const method = buf.readUInt16LE(p + 10);
|
||||
const crc = buf.readUInt32LE(p + 16);
|
||||
const compSize = buf.readUInt32LE(p + 20);
|
||||
const uncompSize = buf.readUInt32LE(p + 24);
|
||||
const nameLen = buf.readUInt16LE(p + 28);
|
||||
const extraLen = buf.readUInt16LE(p + 30);
|
||||
const commentLen = buf.readUInt16LE(p + 32);
|
||||
const localOffset = buf.readUInt32LE(p + 42);
|
||||
const nameBuf = buf.subarray(p + 46, p + 46 + nameLen);
|
||||
|
||||
// 名称合法性: 必须正斜杠 + 合法 UTF-8 + 与 UTF-8 标志位一致
|
||||
if (!(flags & FLAG_UTF8)) fail(`第 ${idx} 条缺少 UTF-8 标志位: ${nameBuf.toString('latin1')}`);
|
||||
const name = nameBuf.toString('utf8');
|
||||
if (name.includes('\\')) fail(`条目名含反斜杠 (非 ZIP 规范): ${name}`);
|
||||
if (!Buffer.from(name, 'utf8').equals(nameBuf)) fail(`条目名不是合法 UTF-8: ${nameBuf.toString('hex')}`);
|
||||
names.push(name);
|
||||
|
||||
// 本地文件头: 偏移必须真的指向一个本地头, 且文件名与中央目录一致
|
||||
if (localOffset + 30 > buf.length) fail(`第 ${idx} 条 localOffset 越界: ${localOffset}`);
|
||||
if (buf.readUInt32LE(localOffset) !== SIG_LOCAL) fail(`第 ${idx} 条 localOffset 未指向本地文件头: ${localOffset}`);
|
||||
const lNameLen = buf.readUInt16LE(localOffset + 26);
|
||||
const lExtraLen = buf.readUInt16LE(localOffset + 28);
|
||||
const localName = buf.subarray(localOffset + 30, localOffset + 30 + lNameLen);
|
||||
if (!localName.equals(nameBuf)) fail(`第 ${idx} 条本地头与中央目录文件名不一致`);
|
||||
|
||||
// 数据区: 解压后长度与 CRC 必须对得上 (目录条目除外)
|
||||
if (!name.endsWith('/')) {
|
||||
const dataStart = localOffset + 30 + lNameLen + lExtraLen;
|
||||
const raw = buf.subarray(dataStart, dataStart + compSize);
|
||||
if (raw.length !== compSize) fail(`第 ${idx} 条数据区越界`);
|
||||
let data;
|
||||
if (method === 0) data = raw;
|
||||
else if (method === 8) data = zlib.inflateRawSync(raw);
|
||||
else fail(`第 ${idx} 条压缩方式不支持: ${method}`);
|
||||
if (data.length !== uncompSize) fail(`第 ${idx} 条解压后长度不符 (${data.length} != ${uncompSize})`);
|
||||
if (crc32(data) !== crc) fail(`第 ${idx} 条 CRC 校验失败`);
|
||||
}
|
||||
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
|
||||
if (p - centralOffset !== centralSize) {
|
||||
fail(`中央目录实际长度 ${p - centralOffset} != EOCD 记录 ${centralSize}`);
|
||||
}
|
||||
|
||||
// --- 内容断言 ---
|
||||
if (!opts.skipRequired) {
|
||||
const required = [
|
||||
'mp-harmony/AppScope/app.json5',
|
||||
'mp-harmony/build-profile.json5',
|
||||
'mp-harmony/oh-package.json5',
|
||||
'mp-harmony/hvigor/hvigor-config.json5',
|
||||
'mp-harmony/entry/src/main/module.json5',
|
||||
'mp-harmony/entry/oh-package.json5',
|
||||
'mp-harmony/ascf/ascf_src/app.js',
|
||||
];
|
||||
if (opts.withCerts !== false) {
|
||||
required.push('mp-harmony/cert-profile/qq/qq.p12', 'mp-harmony/cert-profile/qq/qqDebug.p7b');
|
||||
}
|
||||
const missing = required.filter((r) => !names.includes(r));
|
||||
if (missing.length) fail(`缺少构建必需文件: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
// 按**路径分段**判断, 不用子串 —— 否则 .test 会误伤 entry/src/ohosTest/ets/test/
|
||||
// 这类合法源码文件 (.test 排除的是名为 .test 的目录, 不是任何含 .test 的路径)
|
||||
const dirSegmentsOf = (n) => n.split('/').slice(0, -1);
|
||||
const fileNameOf = (n) => n.split('/').pop();
|
||||
const leaked = names.filter((n) => dirSegmentsOf(n).some((s) => EXCLUDE_DIRS.has(s)));
|
||||
if (leaked.length) {
|
||||
fail(`混入了应排除的目录: ${leaked.slice(0, 3).join(', ')}${leaked.length > 3 ? ` ...共 ${leaked.length} 条` : ''}`);
|
||||
}
|
||||
const leakedFiles = names.filter((n) => !n.endsWith('/') && EXCLUDE_FILES.has(fileNameOf(n)));
|
||||
if (leakedFiles.length) {
|
||||
fail(`混入了应排除的文件: ${leakedFiles.slice(0, 3).join(', ')}`);
|
||||
}
|
||||
|
||||
if (opts.withCerts === false && names.some((n) => n.includes(CERT_DIR))) {
|
||||
fail('--no-certs 生效异常: zip 内仍存在 cert-profile');
|
||||
}
|
||||
|
||||
const fileCount = names.filter((n) => !n.endsWith('/')).length;
|
||||
|
||||
// --- 零漏拷 / 零多拷 比对 ---
|
||||
// expectFiles 是 copyDir 实际复制到暂存目录的文件清单 (已应用排除规则)。
|
||||
// 逐个核对 zip 内是否都有, 且 zip 内没有清单之外的文件 (README.txt 是后补的, 除外)。
|
||||
// 这样"某个目录没被打进去"这类问题会被脚本当场拦下, 不必人工逐目录核对。
|
||||
if (Array.isArray(opts.expectFiles)) {
|
||||
const nameSet = new Set(names);
|
||||
const missingFiles = opts.expectFiles.filter((f) => !nameSet.has(f));
|
||||
if (missingFiles.length) {
|
||||
fail(`漏拷: ${missingFiles.length} 个已复制的文件不在 zip 内, 例如 ${missingFiles.slice(0, 5).join(', ')}`);
|
||||
}
|
||||
const expected = new Set(opts.expectFiles);
|
||||
expected.add(`${rootName}/README.txt`);
|
||||
const unexpected = names.filter((n) => !n.endsWith('/') && !expected.has(n));
|
||||
if (unexpected.length) {
|
||||
fail(`多拷: ${unexpected.length} 个 zip 内文件不在复制清单中, 例如 ${unexpected.slice(0, 5).join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entries: totalEntries,
|
||||
files: fileCount,
|
||||
dirs: totalEntries - fileCount,
|
||||
};
|
||||
}
|
||||
|
||||
/** 校验暂存目录里构建必需的骨架文件都在 */
|
||||
async function verifySkeleton(dir) {
|
||||
const required = [
|
||||
'AppScope/app.json5',
|
||||
'build-profile.json5',
|
||||
'oh-package.json5',
|
||||
'hvigor/hvigor-config.json5',
|
||||
'entry/src/main/module.json5',
|
||||
'entry/oh-package.json5',
|
||||
'ascf/ascf_src/app.js',
|
||||
];
|
||||
const missing = [];
|
||||
for (const rel of required) {
|
||||
if (!fs.existsSync(path.join(dir, rel))) missing.push(rel);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
const repoRoot = path.join(__dirname, '..');
|
||||
const srcRoot = path.join(repoRoot, 'stub-app-projects', PROJECT_DIR_NAME);
|
||||
const outDir = opts.outDir || path.join(repoRoot, DEFAULT_OUT_DIR);
|
||||
|
||||
if (!fs.existsSync(srcRoot)) {
|
||||
console.error(`❌ 源目录不存在: ${srcRoot}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📦 打包鸿蒙工程 (仅读取源目录, 不做任何修改)');
|
||||
console.log(` 源目录: ${path.relative(repoRoot, srcRoot)}`);
|
||||
console.log(` 含证书: ${opts.withCerts ? '是 ⚠️ 包含私钥' : '否'}`);
|
||||
console.log(` 含 node_modules: ${opts.withNodeModules ? '是 (体积大)' : '否 (由 DevEco 自动拉取)'}`);
|
||||
|
||||
// 暂存目录: 与源目录物理隔离, 保证源目录零改动
|
||||
const stageParent = path.join(outDir, '.stage');
|
||||
const stageDir = path.join(stageParent, PROJECT_DIR_NAME);
|
||||
await fsp.rm(stageDir, { recursive: true, force: true });
|
||||
await fsp.mkdir(stageDir, { recursive: true });
|
||||
|
||||
const t0 = Date.now();
|
||||
// stats 是模块级累加器, 同一进程内重复调用 main 时需先清零, 否则体积/数量会翻倍
|
||||
stats.files = 0;
|
||||
stats.bytes = 0;
|
||||
stats.skipped = 0;
|
||||
stats.copied = [];
|
||||
await copyDir(srcRoot, stageDir, opts);
|
||||
console.log(`\n 复制完成: ${stats.files} 个文件 / ${humanSize(stats.bytes)} (跳过 ${stats.skipped} 项, ${Date.now() - t0}ms)`);
|
||||
|
||||
// 写入说明文件
|
||||
const readme = [
|
||||
'# 鸿蒙元服务工程包',
|
||||
'',
|
||||
`导出时间: ${new Date().toLocaleString('zh-CN')}`,
|
||||
`包含签名证书: ${opts.withCerts ? '是 (cert-profile/, 含私钥, 请妥善保管)' : '否'}`,
|
||||
'',
|
||||
'## 在目标电脑上编译',
|
||||
'',
|
||||
'1. 安装 DevEco Studio 5.0.x (本工程 compatibleSdkVersion 5.0.0(12) / targetSdkVersion 5.0.0(12))',
|
||||
'2. 解压本 zip, 得到 mp-harmony/ 目录',
|
||||
'3. 用 DevEco Studio 「打开」该 mp-harmony 目录 (不是其父目录)',
|
||||
'4. 首次打开会自动执行依赖同步 (ohpm install 拉取 hvigor/node_modules), 需联网',
|
||||
` ${opts.withNodeModules ? '' : ' 若目标机离线, 请在源机器用 --with-node-modules 重新导出\n'}`,
|
||||
'5. 菜单「Build」→「Build App(s)/Project(s)」, 等价于:',
|
||||
' hvigorw --mode project -p product=default assembleApp',
|
||||
'6. 产物: build/outputs/default/mp-harmony-default-signed.app',
|
||||
'7. 上传 .app 到 AppGallery Connect (元服务上传的是 .app, 不是 .hap)',
|
||||
'',
|
||||
'## 注意事项',
|
||||
'',
|
||||
'- **ascf/ascf_src 是导出时刻的快照**, 内含 uniapp 编译产物 (页面/组件代码)。',
|
||||
' 若前端代码有更新, 需回到源机器重新执行本脚本导出, 否则编译出的包内容是旧的。',
|
||||
opts.withCerts
|
||||
? '- **本包含签名私钥** (cert-profile/qq/qq.p12)。请仅在可信渠道传递, 用完及时销毁或归档。'
|
||||
: '- **本包不含签名证书**。缺证书 hvigor 无法签名, 产不出可上传的 .app。\n 请把源机器的 cert-profile/ 目录放到 mp-harmony/ 下再编译。',
|
||||
'- hvigor/node_modules 未包含时由 DevEco 自动拉取; 联网失败会导致依赖同步失败。',
|
||||
'',
|
||||
].join('\n');
|
||||
// 文件名用 ASCII: 虽然本脚本写入的条目名是标准 UTF-8 且已置 bit11 标志位,
|
||||
// 但个别解压工具 (如 Windows 上的 MSYS unzip) 不认该标志位, 中文名会落盘成乱码。
|
||||
// 说明文档正文保持中文, 文件名用 README.txt 兼容所有工具。
|
||||
await fsp.writeFile(path.join(stageDir, 'README.txt'), readme, 'utf8');
|
||||
|
||||
// 校验骨架完整性
|
||||
const missing = await verifySkeleton(stageDir);
|
||||
if (missing.length > 0) {
|
||||
console.error('\n❌ 导出包缺少构建必需文件:');
|
||||
missing.forEach((m) => console.error(` - ${m}`));
|
||||
console.error(' 请检查源目录是否完整 (是否执行过 git clean?)');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (!opts.withCerts && fs.existsSync(path.join(stageDir, CERT_DIR))) {
|
||||
console.error('❌ --no-certs 生效异常: 暂存目录仍存在 cert-profile');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const certNote = opts.withCerts ? ' / 证书' : '';
|
||||
console.log(` 骨架校验通过 (AppScope / build-profile / entry / ascf_src${certNote})`);
|
||||
|
||||
// 压缩
|
||||
await fsp.mkdir(outDir, { recursive: true });
|
||||
const now = new Date();
|
||||
const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`;
|
||||
let commitId = 'unknown';
|
||||
try {
|
||||
const { stdout } = await execAsync('git rev-parse --short HEAD', { cwd: repoRoot });
|
||||
commitId = stdout.trim();
|
||||
} catch (e) { /* 非 git 环境忽略 */ }
|
||||
|
||||
const zipPath = path.join(outDir, `${PROJECT_DIR_NAME}-project-${opts.withCerts ? '' : 'nocert-'}v${ts}-${commitId}.zip`);
|
||||
await zipDirectory(stageDir, zipPath);
|
||||
|
||||
// 自检: 解析回 zip 逐项校验, 不通过就抛错 (绝不打印「完成」)
|
||||
// expectFiles 传入实际复制清单, 让脚本自己证明"零漏拷", 无需人工逐目录核对
|
||||
const vres = await verifyZipFile(zipPath, {
|
||||
withCerts: opts.withCerts,
|
||||
expectFiles: stats.copied,
|
||||
rootName: PROJECT_DIR_NAME,
|
||||
});
|
||||
|
||||
const zipSize = (await fsp.stat(zipPath)).size;
|
||||
// 输出目录可能在仓库外 (如 --out /tmp/xxx), 这时相对路径会变成 ../.. 很难读
|
||||
const relZip = path.relative(repoRoot, zipPath);
|
||||
const shownZip = relZip.startsWith('..') ? zipPath : relZip;
|
||||
console.log(`\n✅ 完成`);
|
||||
console.log(` ZIP: ${shownZip}`);
|
||||
console.log(` 大小: ${humanSize(zipSize)} (原始 ${humanSize(stats.bytes)})`);
|
||||
console.log(` 条目: ${vres.files} 个文件 + ${vres.entries - vres.files} 个目录 (自检通过)`);
|
||||
console.log(` 自检: 零漏拷 ✅(${stats.copied.length}/${stats.copied.length}) / CRC 逐条 ✅ / 偏移一致 ✅ / 正斜杠 ✅ / UTF-8 ✅ / 骨架齐全 ✅`);
|
||||
|
||||
// 清暂存目录
|
||||
await fsp.rm(stageParent, { recursive: true, force: true });
|
||||
|
||||
if (opts.withCerts) {
|
||||
console.log('\n⚠️ 本包含签名私钥 (cert-profile/qq/qq.p12), 请仅在可信渠道传递。');
|
||||
}
|
||||
}
|
||||
|
||||
// 导出内部实现供单元测试使用; 作为命令行执行时才跑 main()
|
||||
module.exports = {
|
||||
crc32,
|
||||
toDosDateTime,
|
||||
zipDirectory,
|
||||
verifyZipFile,
|
||||
shouldSkipDir,
|
||||
shouldSkipFile,
|
||||
copyDir,
|
||||
EXCLUDE_DIRS,
|
||||
EXCLUDE_FILES,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((err) => {
|
||||
console.error('❌ 打包失败:', err.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,49 @@
|
||||
import { AscfUIAbility } from '@atomicservice/ascfapi';
|
||||
|
||||
export default class EntryAbility extends AscfUIAbility {
|
||||
}
|
||||
import { abilityAccessCtrl, AbilityConstant, Permissions, Want } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { AscfUIAbility } from '@atomicservice/ascfapi';
|
||||
|
||||
// ⚠️ Permissions 在 @ohos.permissions 里是**字符串联合类型**, 不是枚举,
|
||||
// 只能用在类型位置 (如这里的标注), 不能当值引用 (Permissions.MICROPHONE 会报
|
||||
// "'Permissions' only refers to a type, but is being used as a value here"),
|
||||
// 所以权限名必须写字面量, 且要与 module.json5 中声明的一致。
|
||||
// 另外别被 abilityAccessCtrl 里的 SwitchType 枚举误导, 它的 MICROPHONE=1 是
|
||||
// 开关类型, 不是权限名。
|
||||
const PERMISSION_MICROPHONE: Permissions = 'ohos.permission.MICROPHONE';
|
||||
|
||||
export default class EntryAbility extends AscfUIAbility {
|
||||
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
||||
// 必须先调父类: ascf runtime 的初始化在 AscfUIAbility.onCreate 里, 漏掉会导致元服务白屏
|
||||
super.onCreate(want, launchParam);
|
||||
this.requestMicrophonePermission();
|
||||
}
|
||||
|
||||
/**
|
||||
* 申请麦克风权限 (ohos.permission.MICROPHONE, user_grant 类型)。
|
||||
*
|
||||
* 为什么放在 onCreate 而不是"用户点录音时才申请":
|
||||
* 1. 权限状态在 JS 侧 (uni.getRecorderManager) 读不到, 项目里也没有现成的
|
||||
* JS <-> ArkTS 通道, 做不了按需触发的条件判断;
|
||||
* 2. requestPermissionsFromUser 对已授权的应用直接 resolve 且不弹窗,
|
||||
* 所以实际只有首次启动会打扰用户一次, 之后每次冷启动都是无感的。
|
||||
*
|
||||
* 未授权时的降级行为:
|
||||
* - 不影响其他功能 (浏览/下单/卡片), 只是语音输入不可用;
|
||||
* - 前端 ai-chat-voice-panel.vue 会 catch 到 recorder 错误并 showToast 提示用户。
|
||||
*/
|
||||
private async requestMicrophonePermission(): Promise<void> {
|
||||
try {
|
||||
const atManager = abilityAccessCtrl.createAtManager();
|
||||
const result = await atManager.requestPermissionsFromUser(this.context, [PERMISSION_MICROPHONE]);
|
||||
const authResult = result.authResults[0];
|
||||
if (authResult === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
|
||||
console.info('[EntryAbility] MICROPHONE 已授权, 语音输入可用');
|
||||
} else {
|
||||
console.warn('[EntryAbility] MICROPHONE 未授权, 语音输入不可用 (不影响其他功能)');
|
||||
}
|
||||
} catch (err) {
|
||||
// 用户取消授权弹窗 / context 异常, 同样降级为"语音不可用", 不阻断启动
|
||||
const e = err as BusinessError;
|
||||
console.warn(`[EntryAbility] 申请麦克风权限异常: code=${e.code}, message=${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,9 @@
|
||||
},
|
||||
{
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 元服务录音需要麦克风权限
|
||||
// user_grant 权限必须同时声明 reason 和 usedScene
|
||||
"name": "ohos.permission.MICROPHONE",
|
||||
"reason": "$string:permission_microphone_reason",
|
||||
"usedScene": {
|
||||
"abilities": [
|
||||
"EntryAbility"
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
"name": "module_desc",
|
||||
"value": "module description"
|
||||
},
|
||||
{
|
||||
"name": "permission_microphone_reason",
|
||||
"value": "用于语音搜索与语音输入功能,录制语音以识别用户需求"
|
||||
},
|
||||
{
|
||||
"name": "EntryAbility_desc",
|
||||
"value": "EntryAbility description"
|
||||
|
||||
186
tests/card-admin.test.js
Normal file
186
tests/card-admin.test.js
Normal file
@@ -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}`);
|
||||
}
|
||||
});
|
||||
326
tests/card-api.test.js
Normal file
326
tests/card-api.test.js
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* 名片云 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(!/<view class="row-label">Telegram<\/view>/.test(editSrc),
|
||||
'模板不得再有 Telegram 独立输入行');
|
||||
assert.ok(!/<view class="row-label">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.<helper>` 调用的实参文本(括号配对)。
|
||||
* 兼容两种写法: `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 丢弃`);
|
||||
}
|
||||
}
|
||||
});
|
||||
425
tests/card-platform.test.js
Normal file
425
tests/card-platform.test.js
Normal file
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* card 电子名片模块跨平台适配测试: 微信小程序 / H5 / 鸿蒙元服务
|
||||
*
|
||||
* 背景:
|
||||
* uni-app 产物覆盖 MP-WEIXIN / H5 / MP-HARMONY(鸿蒙元服务) 三端,
|
||||
* card 模块(名片夹/详情/编辑/线索/分享)任何平台分支遗漏都会在对应端
|
||||
* 表现为「按钮消失」或「整页白屏」。本测试把适配契约固化为断言。
|
||||
*
|
||||
* 覆盖:
|
||||
* 1. 条件编译配对平衡(防止 #ifdef 漏 #endif 导致编译失败)
|
||||
* 2. 平台 token 覆盖(detail/share 必须同时照顾三端)
|
||||
* 3. 保存二维码按钮在 微信+鸿蒙 双端可见(saveImageToPhotosAlbum 鸿蒙可用)
|
||||
* 4. share.vue 无白页: web-view 分支覆盖 微信+鸿蒙+App, H5 分支有跳转
|
||||
* 5. goBack 不用裸 uni.switchTab(鸿蒙 switchTab 静默失败, catch 兜底失效,
|
||||
* 必须走 $util.redirectTo —— 见 common/js/util.js#redirectTo tabBar 分支)
|
||||
* 6. 分享 URL 编解码闭环(三端语义不同: 微信/鸿蒙不 decode 值, H5 预 decode)
|
||||
* 7. 平台敏感 API 只在受保护的分支内调用
|
||||
* 8. card_id 参数编码安全(纯 ASCII, encodeURIComponent 幂等)
|
||||
*
|
||||
* 跑法:
|
||||
* node --test tests/card-platform.test.js
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const CARD_DIR = path.resolve(__dirname, '../pages_tool/card');
|
||||
const read = f => fs.readFileSync(path.join(CARD_DIR, f), 'utf8');
|
||||
|
||||
// =============================================================
|
||||
// 工具: 条件编译块解析(行注释 // / 块注释 /* */ / HTML <!-- --> 三种写法)
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 把文件解析成 [{ cond, start, end }] 顶层块列表。
|
||||
* 兼容三种写法: 行注释 `// #ifdef X`、块注释 `/* #ifdef X *\/`、HTML `<!-- #ifdef X -->`。
|
||||
* 关键细节:
|
||||
* - 条件名含连字符(MP-WEIXIN/APP-PLUS), 捕获到注释终止符为止
|
||||
* - 一行可内联 "#ifdef X -->text<!-- #endif -->", 开/关都按出现次数计
|
||||
*/
|
||||
function parseIfdefBlocks(src) {
|
||||
const lines = src.split('\n');
|
||||
const stack = []; // [{ cond, startLine }]
|
||||
const blocks = []; // 顶层块
|
||||
// 开标记: 捕获条件串(惰性到注释终止符/行尾); 关标记: 单独识别
|
||||
const TOKEN_RE = /(?:\/\/\s*|\/\*\s*|<!--\s*)(#ifdef|#ifndef|#endif)\b(?:\s+(.+?))?\s*(?:-->|\*\/|$)/g;
|
||||
lines.forEach((line, i) => {
|
||||
TOKEN_RE.lastIndex = 0;
|
||||
let m;
|
||||
const tokens = [];
|
||||
while ((m = TOKEN_RE.exec(line)) !== null) {
|
||||
if (m[0].trim() === '') break; // 防空匹配死循环
|
||||
tokens.push({ kind: m[1], cond: m[2] ? m[2].trim() : null });
|
||||
}
|
||||
for (const tk of tokens) {
|
||||
if (tk.kind === '#ifdef' || tk.kind === '#ifndef') {
|
||||
if (stack.length === 0) blocks.push({ cond: tk.cond, start: i, end: lines.length - 1 });
|
||||
stack.push({ cond: tk.cond, startLine: i });
|
||||
} else {
|
||||
const top = stack.pop();
|
||||
if (top && stack.length === 0) {
|
||||
const b = blocks[blocks.length - 1];
|
||||
if (b && b.start === top.startLine) b.end = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** 判断 cond 串(如 "MP-WEIXIN || MP-HARMONY")在指定平台下是否命中 */
|
||||
function condHits(cond, platform) {
|
||||
return cond
|
||||
.split('||')
|
||||
.map(s => s.trim().replace(/^!/, ''))
|
||||
.includes(platform);
|
||||
}
|
||||
|
||||
/** 找出源码中某 API 出现的所有位置及其所属顶层 cond(无保护返回 null) */
|
||||
function apiOccurrences(src, apiRegex) {
|
||||
const lines = src.split('\n');
|
||||
const blocks = parseIfdefBlocks(src);
|
||||
const out = [];
|
||||
lines.forEach((line, i) => {
|
||||
if (!apiRegex.test(line)) return;
|
||||
// 找包含该行的顶层块
|
||||
const b = blocks.find(b => i >= b.start && i <= b.end);
|
||||
out.push({ line: i + 1, text: line.trim(), cond: b ? b.cond : null });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// 1. 条件编译配对平衡
|
||||
// =============================================================
|
||||
|
||||
for (const f of ['list.vue', 'detail.vue', 'edit.vue', 'leads.vue', 'share.vue']) {
|
||||
test(`ifdef 配对平衡: ${f}`, () => {
|
||||
const src = read(f);
|
||||
const open = (src.match(/#ifdef|#ifndef/g) || []).length;
|
||||
const close = (src.match(/#endif/g) || []).length;
|
||||
assert.equal(open, close, `${f}: #ifdef/#ifndef=${open} 但 #endif=${close}(漏配对编译必失败)`);
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// 2. 平台 token 覆盖
|
||||
// =============================================================
|
||||
|
||||
test('平台覆盖: detail.vue 同时含 MP-WEIXIN / H5 / MP-HARMONY 分支', () => {
|
||||
const src = read('detail.vue');
|
||||
for (const p of ['MP-WEIXIN', 'H5', 'MP-HARMONY']) {
|
||||
assert.ok(src.includes(p), `detail.vue 缺少 ${p} 分支(该端分享面板会缺按钮)`);
|
||||
}
|
||||
});
|
||||
|
||||
test('平台覆盖: share.vue 的 web-view 分支含 MP-HARMONY(防鸿蒙白屏)', () => {
|
||||
const src = read('share.vue');
|
||||
// web-view 必须出现在 MP-WEIXIN || MP-HARMONY 保护块内
|
||||
const wvOcc = apiOccurrences(src, /<web-view/);
|
||||
assert.ok(wvOcc.length >= 2, 'share.vue 至少应有 2 处 web-view(小程序+App)');
|
||||
const mpWv = wvOcc.find(o => o.cond && /MP-WEIXIN/.test(o.cond));
|
||||
assert.ok(mpWv, 'web-view 必须位于 MP-WEIXIN 分支内');
|
||||
assert.ok(/MP-HARMONY/.test(mpWv.cond),
|
||||
`web-view 分支必须同时覆盖 MP-HARMONY, 实际 cond="${mpWv.cond}"(鸿蒙会白页)`);
|
||||
});
|
||||
|
||||
test('平台覆盖: share.vue H5 分支有 location.href 跳转(防 H5 停留中转页)', () => {
|
||||
const src = read('share.vue');
|
||||
const occ = apiOccurrences(src, /window\.location\.href/);
|
||||
const h5Occ = occ.find(o => o.cond === 'H5');
|
||||
assert.ok(h5Occ, 'location.href 必须位于 H5 分支内');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 3. 保存二维码按钮双端可见
|
||||
// =============================================================
|
||||
|
||||
test('保存二维码: 按钮在 MP-WEIXIN 与 MP-HARMONY 分支均可见', () => {
|
||||
const src = read('detail.vue');
|
||||
// saveQr 的按钮绑定点(template 内 @tap="saveQr")
|
||||
const btnOcc = apiOccurrences(src, /@tap="saveQr"/);
|
||||
const conds = btnOcc.map(o => o.cond).filter(Boolean);
|
||||
assert.ok(conds.some(c => /MP-WEIXIN/.test(c)), '保存二维码按钮应有 MP-WEIXIN 分支');
|
||||
assert.ok(conds.some(c => /MP-HARMONY/.test(c)),
|
||||
`保存二维码按钮应有 MP-HARMONY 分支(鸿蒙 uni.saveImageToPhotosAlbum 可用), 实际: ${JSON.stringify(conds)}`);
|
||||
});
|
||||
|
||||
test('保存二维码: saveImageToPhotosAlbum/downloadFile 调用不受平台分支保护误删', () => {
|
||||
// 这两个 API 在鸿蒙受支持, 但不能出现在 H5 分支( H5 无此 API )
|
||||
const src = read('detail.vue');
|
||||
for (const api of [/uni\.saveImageToPhotosAlbum/, /uni\.downloadFile/]) {
|
||||
for (const occ of apiOccurrences(src, api)) {
|
||||
assert.notEqual(occ.cond, 'H5', `${api} 不应在 H5 分支内(第 ${occ.line} 行)`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 4. 分享中转页不白屏(各端都有可渲染内容)
|
||||
// =============================================================
|
||||
|
||||
test('share.vue 三端均有渲染分支: MP-WEIXIN||MP-HARMONY / H5 / APP-PLUS', () => {
|
||||
const src = read('share.vue');
|
||||
const conds = parseIfdefBlocks(src).map(b => b.cond);
|
||||
assert.ok(conds.some(c => c && /MP-WEIXIN/.test(c) && /MP-HARMONY/.test(c)),
|
||||
`应有合并的 MP-WEIXIN||MP-HARMONY 分支, 实际: ${JSON.stringify(conds)}`);
|
||||
assert.ok(conds.includes('H5'), '应有 H5 分支');
|
||||
assert.ok(conds.includes('APP-PLUS'), '应有 APP-PLUS 分支');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 5. goBack 不用裸 switchTab(鸿蒙静默失败回归)
|
||||
// =============================================================
|
||||
|
||||
test('goBack: share.vue 不用裸 uni.switchTab(鸿蒙静默失败, catch 不触发)', () => {
|
||||
const src = read('share.vue');
|
||||
// 提取 goBack 方法体
|
||||
const m = src.match(/goBack\(\)\s*\{([\s\S]*?)\n\t\t\},/);
|
||||
assert.ok(m, '应能提取 goBack 方法体');
|
||||
const body = m[1];
|
||||
// 只断言真实调用(带括号), 注释里提到 uni.switchTab 是说明文字不算
|
||||
assert.ok(!/uni\.switchTab\s*\(/.test(body),
|
||||
'goBack 不得直接调 uni.switchTab: 鸿蒙上 success/fail 都不触发, .catch 兜底失效');
|
||||
assert.ok(/\$util\.redirectTo/.test(body),
|
||||
'goBack 栈底回首页必须走 $util.redirectTo(内部有 switchTab→500ms→reLaunch 兜底)');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 6. 分享 URL 编解码闭环(三端语义)
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 模拟三端 onShareAppMessage path → 目标页 onLoad options.url 的传递:
|
||||
* - 微信: path 里的 query 按 & = 切分但不 decode value(项目实测结论,
|
||||
* 见 common/js/util-navigate.js#_safeEncodeForPlatform 注释)
|
||||
* - 鸿蒙: 同微信语义(uni-app harmony 沿用小程序路由)
|
||||
* - H5: 浏览器预 decode, onLoad 拿到已解码值
|
||||
*/
|
||||
function simulateShareOpen(pathStr, platform) {
|
||||
const qi = pathStr.indexOf('?');
|
||||
const query = qi === -1 ? '' : pathStr.slice(qi + 1);
|
||||
const params = {};
|
||||
for (const kv of query.split('&')) {
|
||||
if (!kv) continue;
|
||||
const eq = kv.indexOf('=');
|
||||
const k = kv.slice(0, eq);
|
||||
const raw = kv.slice(eq + 1);
|
||||
// 微信/鸿蒙: value 原样(不 decode); H5: 浏览器已 decode
|
||||
params[k] = platform === 'h5' ? safeDecode(raw) : raw;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function safeDecode(s) {
|
||||
try { return decodeURIComponent(s); } catch (e) { return s; }
|
||||
}
|
||||
|
||||
test('分享闭环: 微信/鸿蒙 onLoad 拿到编码值, decode 后还原完整 URL', () => {
|
||||
const shareUrl = 'https://shop.example.com/card/wap/index/detail?site_id=1&id=237';
|
||||
const pathStr = '/pages_tool/card/share?url=' + encodeURIComponent(shareUrl);
|
||||
for (const p of ['mp-weixin', 'mp-harmony']) {
|
||||
const opts = simulateShareOpen(pathStr, p);
|
||||
assert.ok(opts.url, `${p}: options.url 应存在`);
|
||||
// 编码值不应含裸 & (否则 query 被切碎, url 被截断)
|
||||
assert.ok(!opts.url.includes('&'), `${p}: url 参数不应含裸 &`);
|
||||
// share.vue onLoad 的 decodeURIComponent 必须还原出完整 URL
|
||||
assert.equal(safeDecode(opts.url), shareUrl, `${p}: decode 后应还原完整分享 URL`);
|
||||
}
|
||||
});
|
||||
|
||||
test('分享闭环: H5 onLoad 拿到已解码值, 再 decode 是幂等的(无双解码)', () => {
|
||||
const shareUrl = 'https://shop.example.com/card/wap/index/detail?site_id=1&id=237';
|
||||
const pathStr = '/pages_tool/card/share?url=' + encodeURIComponent(shareUrl);
|
||||
const opts = simulateShareOpen(pathStr, 'h5');
|
||||
assert.equal(safeDecode(opts.url), shareUrl, 'H5: 浏览器已解码, 页面内再 decode 应仍得完整 URL');
|
||||
});
|
||||
|
||||
test('分享闭环: onShareAppMessage 的 path 确实预编码了 url(与 webview.vue 惯例一致)', () => {
|
||||
const src = read('detail.vue');
|
||||
assert.ok(/path:\s*'\/pages_tool\/card\/share\?url='\s*\+\s*encodeURIComponent/.test(src),
|
||||
'onShareAppMessage path 必须 encodeURIComponent(url), 否则 & 会切碎 query');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 7. card_id 参数编码安全
|
||||
// =============================================================
|
||||
|
||||
test('card_id 编码幂等: 纯 ASCII 编号 encodeURIComponent 不变(双编码无害)', () => {
|
||||
// 服务端生成格式 c_YYYYMMDD_hex, 只含字母数字下划线
|
||||
const cardId = 'c_20261012_f1018de954e6';
|
||||
assert.equal(encodeURIComponent(cardId), cardId, 'card_id 编码后应不变');
|
||||
assert.equal(decodeURIComponent(encodeURIComponent(cardId)), cardId, '双解码也应无害');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 8. member/index.vue 的 tourl 导航走 $util.redirectTo(鸿蒙安全)
|
||||
// =============================================================
|
||||
|
||||
test('member/index.vue 的 tourl 走 $util.redirectTo(鸿蒙安全)', () => {
|
||||
const memberSrc = fs.readFileSync(
|
||||
path.resolve(__dirname, '../pages_tool/member/index.vue'), 'utf8');
|
||||
const m = memberSrc.match(/tourl\(url\)\s*\{([\s\S]*?)\n\t\t\}/);
|
||||
assert.ok(m, 'member/index.vue 应定义 tourl');
|
||||
assert.ok(/\$util\.redirectTo/.test(m[1]),
|
||||
'tourl 必须走 $util.redirectTo(鸿蒙 switchTab 静默失败兜底在内)');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 9. 端到端: 三端分享面板按钮矩阵
|
||||
// =============================================================
|
||||
|
||||
test('端到端: 三端分享面板按钮矩阵(静态推导)', () => {
|
||||
const src = read('detail.vue');
|
||||
// 复制链接: 无条件(三端都有)
|
||||
assert.ok(/@tap="copyLink"/.test(src), '复制链接应三端可用');
|
||||
// 保存二维码: MP-WEIXIN + MP-HARMONY
|
||||
// 浏览器分享: H5
|
||||
// 发给好友: MP-WEIXIN, 且必须是原生 button(open-type=share) —— 普通 view 的 @tap
|
||||
// 调不起转发面板(微信未开放该 API), 只能弹「请点击右上角···」的死胡同提示
|
||||
const h5Btns = [];
|
||||
const wxBtns = [];
|
||||
const harmonyBtns = [];
|
||||
// 简化推导: 逐行扫描按钮行并记录当前 cond
|
||||
const blocks = parseIfdefBlocks(src);
|
||||
src.split('\n').forEach((line, i) => {
|
||||
const b = blocks.find(b => i >= b.start && i <= b.end);
|
||||
const cond = b ? b.cond : null;
|
||||
if (!/ss-btn/.test(line)) return;
|
||||
// 原生转发按钮没有 @tap, 统一记成 nativeShare 便于断言
|
||||
const tm = line.match(/@tap="(\w+)"/);
|
||||
const token = tm ? tm[1] : (/open-type="share"/.test(line) ? 'nativeShare' : null);
|
||||
if (!token) return;
|
||||
if (!cond) { h5Btns.push(token); wxBtns.push(token); harmonyBtns.push(token); return; }
|
||||
if (condHits(cond, 'MP-WEIXIN')) wxBtns.push(token);
|
||||
if (condHits(cond, 'MP-HARMONY')) harmonyBtns.push(token);
|
||||
if (condHits(cond, 'H5')) h5Btns.push(token);
|
||||
});
|
||||
assert.ok(wxBtns.includes('copyLink') && wxBtns.includes('saveQr') && wxBtns.includes('nativeShare'),
|
||||
`微信按钮矩阵(发给好友须为 nativeShare): ${JSON.stringify(wxBtns)}`);
|
||||
assert.ok(!wxBtns.includes('shareToFriend'),
|
||||
'「发给好友」不能退回 @tap 弹提示的死胡同, 必须原生 button 直接拉起转发面板');
|
||||
assert.ok(harmonyBtns.includes('copyLink') && harmonyBtns.includes('saveQr'),
|
||||
`鸿蒙按钮矩阵: ${JSON.stringify(harmonyBtns)}`);
|
||||
assert.ok(!harmonyBtns.includes('nativeShare'), '鸿蒙元服务无转发菜单, 不应出现原生转发按钮');
|
||||
assert.ok(h5Btns.includes('copyLink') && h5Btns.includes('h5Share'),
|
||||
`H5 按钮矩阵: ${JSON.stringify(h5Btns)}`);
|
||||
assert.ok(!h5Btns.includes('saveQr'), 'H5 不应出现「保存二维码」(无 saveImageToPhotosAlbum)');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 9.1 「发名片」主按钮: 微信端一键转发给好友
|
||||
// =============================================================
|
||||
|
||||
test('发名片: 微信端原生 button 一键转发 + 分享链接预先就绪', () => {
|
||||
const src = read('detail.vue');
|
||||
const btnLine = src.split('\n').find(l => /send-btn/.test(l) && /open-type="share"/.test(l));
|
||||
assert.ok(btnLine, 'detail.vue 应有「发名片」按钮(open-type="share")');
|
||||
assert.ok(/<button/.test(btnLine),
|
||||
'「发名片」必须是原生 <button> —— 普通 view 无法调起「发送给朋友」面板');
|
||||
// 分享链接必须在转发前就绪: 右上角「···」转发与原生按钮都不经过 openShare()
|
||||
assert.ok(/buildShareInfo\s*\(\)/.test(src), 'shareUrl/qrUrl 应由 buildShareInfo 统一生成');
|
||||
const osm = src.match(/onShareAppMessage\(\)\s*\{([\s\S]*?)\n\t\}/);
|
||||
assert.ok(osm, 'detail.vue 应声明 onShareAppMessage');
|
||||
assert.ok(/buildShareInfo/.test(osm[1]), 'onShareAppMessage 内需兜底生成分享链接(否则中转页拿到空 url)');
|
||||
assert.ok(!/请点击右上角/.test(src), '不应保留「请点击右上角···」的提示式分享');
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 1.8.0 后台「选择链接」注册: PAGE_KEY_MAP ↔ pages.json 对齐
|
||||
//
|
||||
// 后端 lucky_link 注册 CARD_LIST/CARD_LEADS(page_key=PAGE_CARD_LIST/
|
||||
// PAGE_CARD_LEADS), 前端导航 resolveLinkUrl 优先用 page_key 经
|
||||
// PAGE_KEY_MAP 解析; 两边必须与 pages.json 注册路径一致, 否则链接点了 404。
|
||||
// ============================================================
|
||||
test('选择链接: PAGE_KEY_MAP 含 card 两个页面常量', () => {
|
||||
const utilSrc = fs.readFileSync(path.resolve(__dirname, '../common/js/util.js'), 'utf8');
|
||||
const m = utilSrc.match(/PAGE_CARD_LIST:\s*'([^']+)'/);
|
||||
const m2 = utilSrc.match(/PAGE_CARD_LEADS:\s*'([^']+)'/);
|
||||
assert.ok(m, 'PAGE_KEY_MAP 应有 PAGE_CARD_LIST');
|
||||
assert.ok(m2, 'PAGE_KEY_MAP 应有 PAGE_CARD_LEADS');
|
||||
assert.equal(m[1], '/pages_tool/card/list');
|
||||
assert.equal(m2[1], '/pages_tool/card/leads');
|
||||
});
|
||||
|
||||
test('选择链接: card 页面已在 pages.json 注册', () => {
|
||||
const pagesSrc = fs.readFileSync(path.resolve(__dirname, '../pages.json'), 'utf8');
|
||||
// pages.json 含 uni-app 合法 // 注释, 剥后解析
|
||||
const stripped = pagesSrc.replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1');
|
||||
const pages = JSON.parse(stripped);
|
||||
const sub = (pages.subPackages || []).find(sp => sp.root === 'pages_tool');
|
||||
assert.ok(sub, 'pages_tool 分包应存在');
|
||||
const paths = (sub.pages || []).map(p => p.path);
|
||||
assert.ok(paths.includes('card/list'), `card/list 未注册: ${paths.join(',')}`);
|
||||
assert.ok(paths.includes('card/leads'), `card/leads 未注册: ${paths.join(',')}`);
|
||||
});
|
||||
|
||||
test('选择链接: card 页面不在 TabBar(NO_TAB_PAGES 语义)不影响导航兜底', () => {
|
||||
// card 页不是 tabBar 根页, redirectTo 走 navigateTo 分支;
|
||||
// 此处仅防回归: 确认 util.js 的 TAB_BAR_KEYS 未被误加 card 路径
|
||||
const utilSrc = fs.readFileSync(path.resolve(__dirname, '../common/js/util.js'), 'utf8');
|
||||
const tabBlock = utilSrc.match(/TAB_BAR_KEYS\s*=\s*\{[\s\S]*?\}/);
|
||||
assert.ok(tabBlock, 'TAB_BAR_KEYS 应存在');
|
||||
assert.ok(!/card/.test(tabBlock[0]), 'TAB_BAR_KEYS 不应含 card 路径(card 非 tabBar 页)');
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 2.0.0 人脉库: contacts / contact-edit 注册 + 互链 + 鸿蒙安全
|
||||
// ============================================================
|
||||
|
||||
test('人脉库: PAGE_KEY_MAP 含 PAGE_CARD_CONTACTS', () => {
|
||||
const utilSrc = fs.readFileSync(path.resolve(__dirname, '../common/js/util.js'), 'utf8');
|
||||
const m = utilSrc.match(/PAGE_CARD_CONTACTS:\s*'([^']+)'/);
|
||||
assert.ok(m, 'PAGE_KEY_MAP 应有 PAGE_CARD_CONTACTS');
|
||||
assert.equal(m[1], '/pages_tool/card/contacts');
|
||||
});
|
||||
|
||||
test('人脉库: contacts/contact-edit 已注册进 pages.json 且不在 TabBar', () => {
|
||||
const pagesSrc = fs.readFileSync(path.resolve(__dirname, '../pages.json'), 'utf8');
|
||||
const stripped = pagesSrc.replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1');
|
||||
const pages = JSON.parse(stripped);
|
||||
const sub = (pages.subPackages || []).find(sp => sp.root === 'pages_tool');
|
||||
assert.ok(sub, 'pages_tool 分包应存在');
|
||||
const paths = (sub.pages || []).map(p => p.path);
|
||||
assert.ok(paths.includes('card/contacts'), `card/contacts 未注册: ${paths.join(',')}`);
|
||||
assert.ok(paths.includes('card/contact-edit'), `card/contact-edit 未注册: ${paths.join(',')}`);
|
||||
// TabBar 根页不在 pages_tool 分包, 天然不在 TabBar; 防回归: tabBar 列表不得含 card
|
||||
const tabPages = (pages.tabBar && pages.tabBar.list ? pages.tabBar.list : []).map(p => p.pagePath);
|
||||
assert.ok(!tabPages.some(p => /card/.test(p)), 'TabBar 不应含 card 页面');
|
||||
});
|
||||
|
||||
test('人脉库: contacts/leads 顶部互链都走 $util.redirectTo(鸿蒙静默失败回归)', () => {
|
||||
const contactsSrc = read('contacts.vue');
|
||||
const leadsSrc = read('leads.vue');
|
||||
// contacts → leads
|
||||
assert.ok(/goLeads\(\)\s*\{[\s\S]*?\$util\.redirectTo\('\/pages_tool\/card\/leads'\)/.test(contactsSrc),
|
||||
'contacts.vue 顶部「潜在客户」链接必须走 $util.redirectTo');
|
||||
// leads → contacts
|
||||
assert.ok(/goContacts\(\)\s*\{[\s\S]*?\$util\.redirectTo\('\/pages_tool\/card\/contacts'\)/.test(leadsSrc),
|
||||
'leads.vue 顶部「我的人脉」链接必须走 $util.redirectTo');
|
||||
// 两文件不得出现裸 uni.switchTab 调用
|
||||
for (const [name, src] of [['contacts.vue', contactsSrc], ['leads.vue', leadsSrc]]) {
|
||||
assert.ok(!/uni\.switchTab\s*\(/.test(src), `${name} 不得直接调 uni.switchTab`);
|
||||
}
|
||||
});
|
||||
|
||||
test('人脉库: contact-edit 保存成功回 contacts 走 $util.redirectTo(鸿蒙安全)', () => {
|
||||
const src = read('contact-edit.vue');
|
||||
// backToList 按来源分支: 员工端缺省回 /pages_tool/card/contacts,
|
||||
// 商家端(from=admin)回 /pages_tool/card/admin/contacts(见 card-admin.test.js)
|
||||
const m = src.match(/backToList\(\)\s*\{([\s\S]*?)\n\t\t\}/);
|
||||
assert.ok(m, '应能提取 backToList 方法体');
|
||||
assert.ok(/\$util\.redirectTo/.test(m[1]),
|
||||
'保存成功回列表必须走 $util.redirectTo');
|
||||
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');
|
||||
// 姓名必填: 模板有 req-star + 保存前有空名校验
|
||||
assert.ok(/req-star/.test(src), '姓名必填应有 req-star 红星');
|
||||
assert.ok(/请填写姓名/.test(src), '保存前应校验姓名非空');
|
||||
});
|
||||
205
tests/diy-card-ab-visual.test.js
Normal file
205
tests/diy-card-ab-visual.test.js
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 名片 DIY 组件 · A+B 视觉升级契约测试
|
||||
*
|
||||
* 背景: 路径A(组件卡跳详情页) + 路径B(组件内复刻参考图视觉) 组合方案:
|
||||
* - 资料行图标(电话/微信/地址; 无字形字段退化文字标签, 与详情页同口径)
|
||||
* - 资料行可点(href 派生: 拨号/邮箱/复制)
|
||||
* - 二维码四档任意位(rows 行下 / head 头部右侧 / corner 右上浮层 / bottom 主按钮上)
|
||||
* - corner 档 Logo 自动避让 + 资料行让位(防遮挡)
|
||||
*
|
||||
* 覆盖:
|
||||
* 1. common.js 纯函数(动态 import 真值断言, 非正则)
|
||||
* 2. mini.vue 模板/计算/方法契约(先剥注释再断言)
|
||||
* 3. show/self 透传 + @row-act 接线
|
||||
* 4. 反例: 虚构字段/不存在的档位必须查不到
|
||||
*
|
||||
* 跑法: node --test tests/diy-card-ab-visual.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');
|
||||
const strip = s => s
|
||||
.replace(/<!--[\s\S]*?-->/g, '')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
||||
|
||||
const showSrc = strip(read('diy-card-show.vue'));
|
||||
const selfSrc = strip(read('diy-card-self.vue'));
|
||||
const miniSrc = strip(read('diy-card-mini.vue'));
|
||||
|
||||
// =============================================================
|
||||
// 1. common.js 纯函数(真值断言)
|
||||
// =============================================================
|
||||
|
||||
test('common: rowIconFor 字段→图标映射(无字形字段退化为空)', async () => {
|
||||
const m = await import('../components-diy/diy-card-common.js');
|
||||
assert.equal(m.rowIconFor('mobile'), 'icon-dianhua');
|
||||
assert.equal(m.rowIconFor('phone'), 'icon-dianhua');
|
||||
assert.equal(m.rowIconFor('wechat'), 'icon-weixin');
|
||||
assert.equal(m.rowIconFor('address'), 'icon-dizhi');
|
||||
// 邮箱: 方案1(2026-10-22)预留 icon-youxiang, 需字库补充信封字形后方能显示, 多键名兜底
|
||||
assert.equal(m.rowIconFor('email'), 'icon-youxiang');
|
||||
assert.equal(m.rowIconFor('mail'), 'icon-youxiang');
|
||||
// 网站(2026-10-22): 复用链条字形 icon-fuzhilianjie; 自定义/未知 → 空串(退化为文字标签)
|
||||
assert.equal(m.rowIconFor('website'), 'icon-fuzhilianjie');
|
||||
assert.equal(m.rowIconFor('custom'), '');
|
||||
assert.equal(m.rowIconFor(''), '');
|
||||
assert.equal(m.rowIconFor(undefined), '');
|
||||
// 2.17.1: rowCssIconFor —— 邮箱/网站走 CSS 描边图标(信封/线框地球), 口径互补
|
||||
assert.equal(m.rowCssIconFor('email'), 'mail');
|
||||
assert.equal(m.rowCssIconFor('website'), 'web');
|
||||
assert.equal(m.rowCssIconFor('mobile'), '', '有 iconfont 字形的字段不走 CSS 档');
|
||||
assert.equal(m.rowCssIconFor(''), '');
|
||||
assert.equal(m.rowCssIconFor(undefined), '');
|
||||
// 2.18.1: 宫格图标 = WAP_LINE_ICONS(与 wap detail 快捷操作同源 SVG 线稿), wx/save 固定色
|
||||
assert.equal(m.WAP_LINE_ICONS.tel.mode, 'stroke');
|
||||
assert.equal(m.WAP_LINE_ICONS.wx.mode, 'fill');
|
||||
assert.equal(m.WAP_LINE_ICONS.wx.color, '#07C160', 'wx 微信绿固定色');
|
||||
assert.equal(m.WAP_LINE_ICONS.save.color, '#FF9500', 'save 通讯录橙固定色');
|
||||
assert.equal(m.WAP_LINE_ICONS.mail.mode, 'stroke');
|
||||
assert.equal(m.WAP_LINE_ICONS.web.mode, 'stroke');
|
||||
assert.ok(m.svgLineIconBg('unknown', '#000') === '', '未知类型空串');
|
||||
assert.ok(m.svgLineIconBg('tel', '#0F6E56').indexOf('base64,') > 0, 'data URI base64 形态');
|
||||
// 宫格 preset 补 save 紫色档
|
||||
assert.deepEqual(m.ACT_GRID_PRESETS.save, { bg: '#F0E9FB', fg: '#5B3DA8' });
|
||||
});
|
||||
|
||||
test('common: rowActOf 由 href 派生点击动作', async () => {
|
||||
const m = await import('../components-diy/diy-card-common.js');
|
||||
assert.deepEqual(m.rowActOf({ field: 'phone', value: '22644212509', href: 'tel:22644212509' }), { type: 'tel', value: '22644212509' });
|
||||
assert.deepEqual(m.rowActOf({ field: 'email', value: 'a@b.com', href: 'mailto:a@b.com' }), { type: 'mail', value: 'a@b.com' });
|
||||
// 网站行: http(s) 派生 web(点击经 web-view 承载页跳转, 2026-10-23 用户需求)
|
||||
assert.deepEqual(m.rowActOf({ field: 'website', value: 'https://x.com', href: 'https://x.com' }), { type: 'web', value: 'https://x.com' });
|
||||
// 空 href(地址/自定义)或空值 → 不可点
|
||||
assert.equal(m.rowActOf({ field: 'address', value: '上海', href: '' }), null);
|
||||
assert.equal(m.rowActOf({ field: 'phone', value: '', href: 'tel:' }), null);
|
||||
assert.equal(m.rowActOf(null), null);
|
||||
});
|
||||
|
||||
test('common: 二维码四档归一化 + 尺寸表', async () => {
|
||||
const m = await import('../components-diy/diy-card-common.js');
|
||||
for (const p of ['rows', 'head', 'corner', 'bottom']) {
|
||||
assert.equal(m.normalizeQrPosition(p), p);
|
||||
}
|
||||
// 未配置/非法值 → rows(保守默认, 不炸)
|
||||
assert.equal(m.normalizeQrPosition(''), 'rows');
|
||||
assert.equal(m.normalizeQrPosition('sideways'), 'rows');
|
||||
assert.equal(m.normalizeQrPosition(undefined), 'rows');
|
||||
assert.deepEqual(Object.keys(m.QR_SIZES).sort(), ['bottom', 'corner', 'head', 'rows']);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 2. mini.vue 基件契约
|
||||
// =============================================================
|
||||
|
||||
test('mini: 资料行图标列 + 文字标签退化', () => {
|
||||
// 2.17.1: 行首新增 CSS 描边图标档(邮箱/网站), iconfont 档退为 v-else-if
|
||||
assert.match(miniSrc, /<view v-if="rowCssIcon\(row\)" class="row-css-wrap">/);
|
||||
assert.match(miniSrc, /<text v-else-if="rowIcon\(row\)" class="row-icon iconfont" :class="rowIcon\(row\)"/);
|
||||
assert.match(miniSrc, /<text v-if="showRowLabels \|\| \(!rowCssIcon\(row\) && !rowIcon\(row\)\)" class="row-label"/);
|
||||
// rowIcon 受 showRowIcons 开关控制(默认开); CSS 档同受此开关
|
||||
assert.match(miniSrc, /rowIcon\(row\)\s*\{\s*return flagOn\(this\.c\.showRowIcons,\s*true\)/);
|
||||
assert.match(miniSrc, /rowCssIcon\(row\)\s*\{\s*return flagOn\(this\.c\.showRowIcons,\s*true\)/);
|
||||
});
|
||||
|
||||
test('mini: 资料行可点(开关 + href 派生 + emit row-act)', () => {
|
||||
assert.match(miniSrc, /rowTapable\(row\)\s*\{\s*return flagOn\(this\.c\.rowTap,\s*true\)\s*&&\s*!!rowActOf\(row\)/);
|
||||
assert.match(miniSrc, /:class="\{ 'row-tappable': rowTapable\(row\), 'row-3col': showRowLabels \}"/);
|
||||
assert.match(miniSrc, /@tap="onRowTap\(row\)"/);
|
||||
assert.match(miniSrc, /onRowTap\(row\)\s*\{[\s\S]*?this\.\$emit\('row-act',\s*act\)/);
|
||||
});
|
||||
|
||||
test('mini: 二维码四档渲染分支齐全', () => {
|
||||
assert.match(miniSrc, /showQrRows\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'rows'/);
|
||||
assert.match(miniSrc, /showQrBottom\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'bottom'/);
|
||||
assert.match(miniSrc, /showQrHead\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'head'/);
|
||||
assert.match(miniSrc, /showQrCorner\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'corner'/);
|
||||
// 模板四个挂点
|
||||
assert.match(miniSrc, /v-if="showQrCorner" class="mini-qr mini-qr-corner"/);
|
||||
assert.match(miniSrc, /v-if="showQrHead" class="mini-qr mini-qr-head"/);
|
||||
// rows/bottom 档流内块级, 带 mini-qr-center 水平居中(2026-10-22: 修复二维码偏左)
|
||||
assert.match(miniSrc, /v-if="showQrRows" class="mini-qr mini-qr-center"/);
|
||||
assert.match(miniSrc, /v-if="showQrBottom" class="mini-qr mini-qr-center"/);
|
||||
// 居中只加左右 auto 边距, 不碰 corner/head 档定位
|
||||
assert.match(miniSrc, /&\.mini-qr-center \{\s*margin-left: auto;\s*margin-right: auto;\s*\}/);
|
||||
// 总条件: 开关 + 有码源(qrSrc: 自定义图优先, 回落名片码)
|
||||
assert.match(miniSrc, /showQrBase\(\)\s*\{\s*return flagOn\(this\.c\.showQr,\s*false\) && !!this\.qrSrc/);
|
||||
// 统一码源 qrSrc: 自定义图优先, 回落名片码(2.18)
|
||||
assert.match(miniSrc, /qrSrc\(\)\s*\{[\s\S]*?String\(this\.c\.qrImage \|\| ''\)\.trim\(\)[\s\S]*?card && this\.card\.qr_url/);
|
||||
});
|
||||
|
||||
test('mini: corner 档 Logo 避让 + 资料行/操作区动态让位', () => {
|
||||
assert.match(miniSrc, /showLogo\(\)\s*\{[\s\S]*?this\.qrPos !== 'corner'/);
|
||||
assert.match(miniSrc, /:class="\{ 'qr-at-corner': qrPos === 'corner' \}"/);
|
||||
// 头部水平让位 = cornerHeadStyle 动态反算(审计修复: 静态 180rpx 在自定义大尺寸下不足)
|
||||
assert.match(miniSrc, /<view class="mini-head" :style="cornerHeadStyle">/);
|
||||
// 资料行/操作区让位 = cornerClearStyle 动态反算(自定义大尺寸/关 tip 自动跟随, 不用 calc)
|
||||
// 2.18: 让位经 rowsClearStyle 分流(操作区在上方时资料行不重复位移); 操作区让位并入
|
||||
// actsBlockFinalStyle 单绑定(2026-10-24: uni-app 静默丢弃数组绑定, 操作区独立样式曾整体失效)
|
||||
assert.match(miniSrc, /rowsClearStyle\(\)\s*\{[\s\S]*?this\.actsAbove \? \{\} : this\.cornerClearStyle/);
|
||||
assert.match(miniSrc, /:style="actsBlockFinalStyle"/);
|
||||
assert.doesNotMatch(miniSrc, /:style="\[actsBlockStyle/);
|
||||
// 静态 CSS 让位必须已移除(动态值才是单一事实来源)
|
||||
assert.ok(!/padding-right: 180rpx;/.test(miniSrc), '静态 180rpx 让位应废弃, 改由 cornerHeadStyle 动态给出');
|
||||
// 浮层 CSS 只定位于层次; 视觉衬底(白卡等)由 qrBlockStyle 内联决定
|
||||
assert.match(miniSrc, /&\.mini-qr-corner \{\s*position: absolute;\s*top: 20rpx;\s*right: 20rpx;\s*z-index: 3;\s*margin-top: 0;\s*box-shadow:/);
|
||||
assert.ok(!/&\.mini-qr-corner \{[\s\S]*?background: #ffffff;/.test(miniSrc),
|
||||
'白底衬底应移入 qrBlockStyle(多彩外观时不再是白底), CSS 不写死');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 3. self miniConfig 透传 + 接线 / show 自绘资料行
|
||||
// =============================================================
|
||||
|
||||
test('self: miniConfig 透传 A+B 三字段', () => {
|
||||
assert.match(selfSrc, /showRowIcons:\s*v\.showRowIcons/);
|
||||
assert.match(selfSrc, /rowTap:\s*v\.rowTap/);
|
||||
assert.match(selfSrc, /qrPosition:\s*v\.qrPosition/);
|
||||
});
|
||||
|
||||
test('self: miniConfig 透传 2.19.10 detail 对齐 9 字段', () => {
|
||||
for (const f of ['showRowLabels', 'copyBtnScope', 'copyBtnLook', 'qrTipEm', 'qrTipSub', 'qrUrlShow', 'badgeShow', 'badgeText', 'rowsBlockBg', 'rowsBlockRadius', 'rowsBlockPad', 'qrBlockBg', 'qrBlockRadius', 'qrBlockPad']) {
|
||||
assert.match(selfSrc, new RegExp(f + ':\\s*v\\.' + f + ','), 'self 透传 ' + f);
|
||||
}
|
||||
});
|
||||
|
||||
test('show: miniConfig 透传 2.19.10 detail 对齐 9 字段', () => {
|
||||
for (const f of ['showRowLabels', 'copyBtnScope', 'copyBtnLook', 'qrTipEm', 'qrTipSub', 'qrUrlShow', 'badgeShow', 'badgeText', 'rowsBlockBg', 'rowsBlockRadius', 'rowsBlockPad', 'qrBlockBg', 'qrBlockRadius', 'qrBlockPad']) {
|
||||
assert.match(showSrc, new RegExp(f + ':\\s*v\\.' + f + ','), 'show 透传 ' + f);
|
||||
}
|
||||
});
|
||||
|
||||
test('self: 模板挂 @row-act="doAct"(行点击有接收方)', () => {
|
||||
assert.match(selfSrc, /@row-act="doAct"/);
|
||||
});
|
||||
|
||||
test('show: 资料行/行图标/行点击走基件透传(方案A: 渲染由 mini 侧行测试覆盖)', () => {
|
||||
assert.match(showSrc, /showRows:\s*v\.showRows,/);
|
||||
assert.match(showSrc, /showRowIcons:\s*v\.showRowIcons,/);
|
||||
assert.match(showSrc, /rowTap:\s*v\.rowTap,/);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 反例(防恒真)
|
||||
// =============================================================
|
||||
|
||||
test('反例: 不存在第五档 qrPosition 分支', () => {
|
||||
assert.ok(!miniSrc.includes("qrPos === 'sideways'"));
|
||||
assert.ok(!miniSrc.includes('showQrSideways'));
|
||||
});
|
||||
|
||||
test('反例: 行图标不映射不存在的字段', async () => {
|
||||
const m = await import('../components-diy/diy-card-common.js');
|
||||
assert.equal(m.rowIconFor('nosuchfield'), '');
|
||||
// 未知 scheme 的 href(后端实际只会下发 tel:/mailto:/安全 URL/空) → 复制兜底
|
||||
assert.deepEqual(m.rowActOf({ field: 'nosuchfield', value: 'x', href: 'nosuch:x' }), { type: 'copy', value: 'x' });
|
||||
// 真空 href 才不可点
|
||||
assert.equal(m.rowActOf({ field: 'nosuchfield', value: 'x', href: '' }), null);
|
||||
});
|
||||
148
tests/diy-card-detail-jump.test.js
Normal file
148
tests/diy-card-detail-jump.test.js
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 名片 DIY 组件 · 路径A(组件卡 → 跳完整名片页)链路测试
|
||||
*
|
||||
* 背景:
|
||||
* 商家端装修页嵌入名片组件后, C 端(微页面/小程序)渲染的是组件内迷你卡。
|
||||
* 参考图形态(名片卡 + 二维码 + 全宽「发名片」)的完整功能不在组件里, 而在
|
||||
* 现成的会员端详情页 /pages_tool/card/detail —— 组件靠「查看完整名片」
|
||||
* 全宽主按钮把用户送过去(路径A)。游客未登录时 detail.vue 自动
|
||||
* fallbackToPublicPage 落地 wap 公开页(保存通讯录/分享/二维码), 不是死路。
|
||||
*
|
||||
* 本测试把这条链路的源码契约固化为断言, 防回归:
|
||||
* 1. self 组件 miniConfig 透传 showDetailBtn + detailBtnText; show 组件自绘
|
||||
* 二维码块直挂 goDetail(2026-10-21 换思路: show 不再复用 mini 基件)
|
||||
* 2. 两个组件模板都挂 @detail="goDetail"(基件 emit('detail') 有接收方)
|
||||
* 3. goDetail 跳 /pages_tool/card/detail 且带 card_id 参数
|
||||
* 4. mini 基件: showDetailBtn flagOn 默认开(开箱即用) + 全宽按钮 emit detail
|
||||
* 5. detail.vue 游客兜底: 错误信封带 wap_url → fallbackToPublicPage(死路防护)
|
||||
*
|
||||
* 口径: 断言前先剔除注释(HTML/块/行注释), 防止「注释里写了某写法」被算实现
|
||||
* (本项目踩过: 注释里写「绝不能写 X」把自己判违规)。
|
||||
* 反例: 虚构选择器/不存在的字段必须查不到, 证明断言不是恒真。
|
||||
*
|
||||
* 跑法: node --test tests/diy-card-detail-jump.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 CARD_DIR = path.resolve(__dirname, '../pages_tool/card');
|
||||
const read = f => fs.readFileSync(path.join(DIY_DIR, f), 'utf8');
|
||||
const readCard = f => fs.readFileSync(path.join(CARD_DIR, f), 'utf8');
|
||||
|
||||
/** 剔除三类注释: HTML <!-- --> / 块 /* *\/ / 行 // (保护 https:// 里的 //) */
|
||||
function stripComments(src) {
|
||||
return src
|
||||
.replace(/<!--[\s\S]*?-->/g, '')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
||||
}
|
||||
|
||||
const showSrc = stripComments(read('diy-card-show.vue'));
|
||||
const selfSrc = stripComments(read('diy-card-self.vue'));
|
||||
const miniSrc = stripComments(read('diy-card-mini.vue'));
|
||||
const detailSrc = stripComments(readCard('detail.vue'));
|
||||
|
||||
// =============================================================
|
||||
// 1. 详情页入口(self 走 mini 基件透传; show 自绘二维码直点进详情)
|
||||
// =============================================================
|
||||
|
||||
test('self: miniConfig 透传 showDetailBtn 与 detailBtnText', () => {
|
||||
assert.match(selfSrc, /showDetailBtn:\s*v\.showDetailBtn/);
|
||||
assert.match(selfSrc, /detailBtnText:\s*v\.detailBtnText/);
|
||||
});
|
||||
|
||||
test('show: 二维码/发名片点击走基件 emit detail 链路(方案A)', () => {
|
||||
// 基件 onQrTap(qrTapPreview 开) / onSend $emit('detail'), show 挂 @detail="goDetail" 接收后跳详情
|
||||
assert.match(showSrc, /@detail="goDetail"/);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 2. 模板挂 @detail(基件 emit('detail') 的接收方, 断链则按钮变哑巴)
|
||||
// =============================================================
|
||||
|
||||
test('self: 模板挂 @detail="goDetail"', () => {
|
||||
assert.match(selfSrc, /@detail="goDetail"/);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 3. goDetail 跳会员端详情页且带 card_id(路径A落点)
|
||||
// =============================================================
|
||||
|
||||
test('show: goDetail 跳 /pages_tool/card/detail 并带 card_id', () => {
|
||||
assert.match(showSrc, /redirectTo\('\/pages_tool\/card\/detail'/);
|
||||
assert.match(showSrc, /card_id:\s*card\.card_id/);
|
||||
// 守卫: 无 card_id 不跳(防脏数据跳空页)
|
||||
assert.match(showSrc, /if\s*\(!card\s*\|\|\s*!card\.card_id\)\s*return;/);
|
||||
});
|
||||
|
||||
test('self: goDetail 跳 /pages_tool/card/detail 并带 card_id', () => {
|
||||
assert.match(selfSrc, /redirectTo\('\/pages_tool\/card\/detail'/);
|
||||
assert.match(selfSrc, /card_id:\s*card\.card_id/);
|
||||
assert.match(selfSrc, /if\s*\(!card\s*\|\|\s*!card\.card_id\)\s*return;/);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 4. mini 基件: 默认开 + 全宽按钮 + emit(路径A的渲染与出口)
|
||||
// =============================================================
|
||||
|
||||
test('mini: showDetailBtn 默认开(历史配置无此字段时也显示, 开箱即用)', () => {
|
||||
assert.match(miniSrc, /flagOn\(this\.c\.showDetailBtn,\s*true\)/);
|
||||
});
|
||||
|
||||
test('mini: 发送名片按钮(小程序原生转发 + H5 弹抽屉, 双分支)', () => {
|
||||
assert.match(miniSrc, /class="mini-detail"/);
|
||||
// 小程序端: <button open-type="share"> 直接拉起「发送给朋友」面板(与微信分享一致)
|
||||
assert.match(miniSrc, /open-type="share"/);
|
||||
assert.match(miniSrc, /:data-card-id="card && card\.card_id"/);
|
||||
assert.match(miniSrc, /:data-card-name="card && card\.name"/);
|
||||
assert.match(miniSrc, /:data-card-qr="card && card\.qr_url"/);
|
||||
// H5 端: 仍弹「分享名片」抽屉(原生分享在 H5 不可用)
|
||||
assert.match(miniSrc, /@tap\.stop="openCardShare"/);
|
||||
assert.match(miniSrc, /openCardShare\(\)\s*\{\s*this\.showShare = true;/);
|
||||
// 抽屉版式与 show 同款: 标题/副文案/二维码/复制链接
|
||||
assert.match(miniSrc, /<view class="ss-title">分享名片<\/view>/);
|
||||
assert.match(miniSrc, /客户打开链接即可查看名片并留言留资/);
|
||||
assert.match(miniSrc, /class="ss-btn ss-btn-main" :style="shareBtnStyle" @tap="copyCardLink"/);
|
||||
// 遮罩关闭 + H5 分享/小程序存码方法齐备
|
||||
assert.match(miniSrc, /class="cs-mask" v-if="showShare" @tap="closeShare"/);
|
||||
assert.match(miniSrc, /h5CardShare\(\)/);
|
||||
assert.match(miniSrc, /saveCardQr\(\)/);
|
||||
});
|
||||
|
||||
test('mini: 主按钮文案可配(detailBtnText, 面板改字即生效)', () => {
|
||||
assert.match(miniSrc, /detailBtnText\(\)\s*\{[\s\S]*?this\.c\.detailBtnText/);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 5. detail.vue 游客兜底(路径A对游客成立的关键: 不死路)
|
||||
// =============================================================
|
||||
|
||||
test('detail: 错误信封带 wap_url 时 fallbackToPublicPage 落地公开页', () => {
|
||||
assert.match(detailSrc, /if\s*\(data\.wap_url\)\s*\{\s*this\.fallbackToPublicPage\(data\.wap_url\);/);
|
||||
});
|
||||
|
||||
test('detail: fallbackToPublicPage 双端分支(H5 直跳/非 H5 走 share 中转 web-view)', () => {
|
||||
assert.match(detailSrc, /fallbackToPublicPage\(wapUrl\)\s*\{/);
|
||||
assert.match(detailSrc, /window\.location\.href\s*=\s*wapUrl/);
|
||||
assert.match(detailSrc, /'\/pages_tool\/card\/share\?url='\s*\+\s*encodeURIComponent\(wapUrl\)/);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 反例自测(防恒真): 虚构字段/错误落点必须不存在
|
||||
// =============================================================
|
||||
|
||||
test('反例: 两个组件都不存在虚构字段 nc.__no_such_field__', () => {
|
||||
assert.ok(!showSrc.includes('__no_such_field__'));
|
||||
assert.ok(!selfSrc.includes('__no_such_field__'));
|
||||
});
|
||||
|
||||
test('反例: goDetail 不跳不存在的页面', () => {
|
||||
assert.ok(!showSrc.includes('/pages_tool/card/nosuchpage'));
|
||||
assert.ok(!selfSrc.includes('/pages_tool/card/nosuchpage'));
|
||||
});
|
||||
215
tests/diy-card-font-size.test.js
Normal file
215
tests/diy-card-font-size.test.js
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 名片 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(/<button type="button" class="diy-card-reset" @click="([^"]+)"/);
|
||||
assert.ok(btn, comp + ' 重置按钮缺少内联 @click 赋值');
|
||||
const click = btn[1];
|
||||
// 字号(px 数值)
|
||||
for (const [field, dft] of Object.entries(cfg.defaults)) {
|
||||
assert.ok(click.includes('nc.' + field + '=' + dft), comp + ' 重置缺少 ' + field + '=' + dft);
|
||||
}
|
||||
// 颜色(带引号)
|
||||
for (const [field, dft] of Object.entries(cfg.colors)) {
|
||||
assert.ok(click.includes("nc." + field + "='" + dft + "'"), comp + ' 重置缺少 ' + field + "='" + dft + "'");
|
||||
}
|
||||
// 圆角/间距(px 数值)
|
||||
for (const [field, dft] of Object.entries(cfg.layout)) {
|
||||
assert.ok(click.includes('nc.' + field + '=' + dft), comp + ' 重置缺少 ' + field + '=' + dft);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('四个 design.html 面板: 每个字号字段都有 slide 控件', () => {
|
||||
for (const comp of ['diy-card-entry', 'diy-card-list', 'diy-card-show', 'diy-card-self']) {
|
||||
const html = readFileSync(resolve(BE, 'component/view/' + comp + '/design.html'), 'utf8');
|
||||
const fields = Object.keys(CONTRACT[comp.replace('diy-card-', '')].defaults);
|
||||
for (const field of fields) {
|
||||
assert.match(html, new RegExp("<slide :data=\"\\{ field: '" + field + "'"), comp + ' 面板缺少 ' + field + ' 的 slide');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('design.html 预览必须消费字号字段(防「面板可配预览不动」漂移)', () => {
|
||||
const expectPreview = {
|
||||
'diy-card-entry': ['nc.titleSize', 'nc.subSize', 'nc.btnSize'],
|
||||
'diy-card-list': ['nc.titleSize', 'nc.nameSize', 'nc.infoSize'],
|
||||
'diy-card-show': ['nc.nameSize', 'nc.subSize', 'nc.labelSize', 'nc.valueSize', 'nc.actSize', 'nc.btnSize'],
|
||||
'diy-card-self': ['nc.nameSize', 'nc.subSize', 'nc.labelSize', 'nc.valueSize', 'nc.actSize', 'nc.btnSize'],
|
||||
};
|
||||
for (const [comp, tokens] of Object.entries(expectPreview)) {
|
||||
const html = readFileSync(resolve(BE, 'component/view/' + comp + '/design.html'), 'utf8');
|
||||
for (const token of tokens) {
|
||||
assert.ok(html.includes(token), comp + ' 预览未消费 ' + token);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('C 端 vue 按默认值消费字号字段(px2rpx 渲染)', () => {
|
||||
for (const [comp, cfg] of Object.entries(CONTRACT)) {
|
||||
const src = readFileSync(resolve(FE, cfg.vueFile), 'utf8');
|
||||
for (const [field, dft] of Object.entries(cfg.defaults)) {
|
||||
const literal = 'num(' + cfg.vueRead + field + ', ' + dft + ')';
|
||||
assert.ok(src.includes(literal), cfg.vueFile + ' 缺少消费 ' + literal);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('self.vue miniConfig 透传全部字号字段到基件', () => {
|
||||
const src = readFileSync(resolve(FE, 'diy-card-self.vue'), 'utf8');
|
||||
for (const field of Object.keys(CONTRACT.self.defaults)) {
|
||||
assert.ok(src.includes(field + ': v.' + field), 'miniConfig 缺少透传 ' + field);
|
||||
}
|
||||
});
|
||||
|
||||
test('派生关系: 横滑/徽标 -1 档, 箭头 +1 档(默认渲染与历史一致)', () => {
|
||||
const list = readFileSync(resolve(FE, 'diy-card-list.vue'), 'utf8');
|
||||
assert.ok(list.includes("num(this.v.nameSize, 14) - 1"), 'list 横滑姓名应比网格小 1px');
|
||||
assert.ok(list.includes("num(this.v.infoSize, 11) - 1"), 'list 横滑信息/人数徽标应比网格小 1px');
|
||||
const entry = readFileSync(resolve(FE, 'diy-card-entry.vue'), 'utf8');
|
||||
assert.ok(entry.includes("num(this.v.btnSize, 12) + 2"), 'entry 箭头应比按钮文字大 2px(28rpx)');
|
||||
});
|
||||
|
||||
test('design.html 每个 slide 控件都带 default 且与 diy_view.php 注册值一致(逐项重置口径)', () => {
|
||||
for (const [comp, cfg] of Object.entries(CONTRACT)) {
|
||||
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
|
||||
const value = readValueJson(comp);
|
||||
// 所有 <slide> 都必须携带 default(含内容页的 maxRows/qrSize)
|
||||
const slides = html.match(/<slide[^>]*>/g) || [];
|
||||
assert.ok(slides.length > 0, comp + ' 应存在 slide 控件');
|
||||
for (const tag of slides) {
|
||||
const fm = tag.match(/field:\s*'([A-Za-z0-9]+)'/);
|
||||
const dm = tag.match(/default:\s*(-?\d+)/);
|
||||
assert.ok(fm && dm, comp + ' slide 缺 field 或 default: ' + tag);
|
||||
const field = fm[1];
|
||||
const dft = Number(dm[1]);
|
||||
assert.equal(
|
||||
Number(value[field]), dft,
|
||||
comp + '.' + field + ' slide default=' + dft + ' 应与 diy_view.php 值 ' + value[field] + ' 一致'
|
||||
);
|
||||
}
|
||||
// 契约中全部字号字段的 slide 必须存在(防漏配)
|
||||
const allFields = slides.map(t => (t.match(/field:\s*'([A-Za-z0-9]+)'/) || [])[1]);
|
||||
for (const field of Object.keys(cfg.defaults)) {
|
||||
assert.ok(allFields.includes(field), comp + ' 缺少 ' + field + ' 的 slide(default 参数)');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('design.html 颜色控件带 value 兜底(= defaultColor, 防存量行缺字段回落 #303133)', () => {
|
||||
for (const [comp, cfg] of Object.entries(CONTRACT)) {
|
||||
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
|
||||
const tags = html.match(/<color :data="\{[^}]+\}"><\/color>/g) || [];
|
||||
assert.ok(tags.length >= Object.keys(cfg.colors).length, comp + ' color 控件数量不足');
|
||||
for (const [field, dft] of Object.entries(cfg.colors)) {
|
||||
const tag = tags.find(t => t.includes("field: '" + field + "'"));
|
||||
assert.ok(tag, comp + ' 缺少 ' + field + ' 的 color 控件');
|
||||
assert.ok(tag.includes("defaultColor: '" + dft + "'"), comp + '.' + field + ' defaultColor 应为 ' + dft);
|
||||
assert.ok(tag.includes("value: '" + dft + "'"), comp + '.' + field + ' 缺 value 兜底(应为 ' + dft + ')');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('核心 slide 组件支持 opt-in 逐项重置(不传 default 的调用方零影响)', () => {
|
||||
const js = readFileSync(resolve(BE, '../../public/static/ext/diyview/js/components.js'), 'utf8');
|
||||
assert.ok(js.includes('v-if="canReset"'), 'slide 模板缺少 canReset 条件渲染');
|
||||
assert.ok(js.includes('resetDefault'), 'slide 缺少 resetDefault 方法');
|
||||
// canReset 只能由 default 参数打开
|
||||
assert.ok(js.match(/compData\.default !== undefined[\s\S]{0,60}canReset = true/), 'canReset 开关未绑定 default 参数');
|
||||
// 颜色控件的平台自带重置仍在(逐项重置的颜色口径)
|
||||
assert.ok(js.includes('class="color-selector-reset text-color" @click="reset()"'), 'color 组件内置重置丢失');
|
||||
});
|
||||
|
||||
test('反例自测: 契约定义缺字段时测试必须失败', () => {
|
||||
// 校验 readValueJson 真的在解析 diy_view.php 而非恒真
|
||||
const php = readFileSync(resolve(BE, 'config/diy_view.php'), 'utf8');
|
||||
assert.ok(!php.includes('"titleSizeNonExist"'), '探针字段不应存在(存在则本测试失效)');
|
||||
for (const comp of Object.keys(CONTRACT)) {
|
||||
const value = readValueJson(comp);
|
||||
assert.equal(value.titleSizeNonExist, undefined, comp + ' 不应有探针字段');
|
||||
}
|
||||
});
|
||||
139
tests/diy-card-link-target.test.js
Normal file
139
tests/diy-card-link-target.test.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 名片 DIY 组件 · 跳转目标(含平台统一「系统链接」)链路测试
|
||||
*
|
||||
* 背景:
|
||||
* 商家在设计面板给「名片入口横幅 / 团队名片墙」配跳转目标, 有四档:
|
||||
* list → 名片夹列表(内置)
|
||||
* contacts→ 我的联系人(内置)
|
||||
* custom → 自定义站内路径
|
||||
* link → 平台统一「系统链接」(商品/分类/微页面/小程序/拨号/自定义外链…)
|
||||
* 第四档由后台的 nc-link 组件写入一个 link 对象
|
||||
* {name,title,wap_url,page_key,page_params}, C 端必须走 $util.diyRedirectTo
|
||||
* 交给平台统一处理(内部页/外链/小程序/拨号), 不能自己拼 URL。
|
||||
*
|
||||
* 覆盖:
|
||||
* 1. entry/list 的 link 模式走 diyRedirectTo(而不是 redirectTo)
|
||||
* 2. 未选链接时有守卫(不跳空)
|
||||
* 3. 内置三档仍走 redirectTo(没被新分支吃掉)
|
||||
* 4. 反例: 不存在虚构模式/虚构字段
|
||||
*
|
||||
* 跑法: node --test tests/diy-card-link-target.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');
|
||||
|
||||
/** 剔除三类注释: HTML <!-- --> / 块 /* *\/ / 行 // (保护 https:// 里的 //) */
|
||||
function stripComments(src) {
|
||||
return src
|
||||
.replace(/<!--[\s\S]*?-->/g, '')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
||||
}
|
||||
|
||||
const entrySrc = stripComments(read('diy-card-entry.vue'));
|
||||
const listSrc = stripComments(read('diy-card-list.vue'));
|
||||
|
||||
/** 取某个方法体(从方法名到下一个同级方法定义) */
|
||||
function methodBody(src, name) {
|
||||
const start = src.indexOf(name + '(');
|
||||
if (start === -1) return '';
|
||||
// 从 '{' 开始做括号配平
|
||||
let i = src.indexOf('{', start);
|
||||
if (i === -1) return '';
|
||||
let depth = 0;
|
||||
for (let j = i; j < src.length; j++) {
|
||||
if (src[j] === '{') depth++;
|
||||
else if (src[j] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) return src.slice(start, j + 1);
|
||||
}
|
||||
}
|
||||
return src.slice(start);
|
||||
}
|
||||
|
||||
const entryGoLink = methodBody(entrySrc, 'goLink');
|
||||
const listGoMore = methodBody(listSrc, 'goMore');
|
||||
|
||||
// =============================================================
|
||||
// 1. link 模式走平台统一跳转
|
||||
// =============================================================
|
||||
|
||||
test('entry: goLink 的 link 模式走 $util.diyRedirectTo(平台统一处理链接对象)', () => {
|
||||
assert.match(entryGoLink, /linkMode\s*===\s*'link'/);
|
||||
assert.match(entryGoLink, /this\.\$util\.diyRedirectTo\(link\)/);
|
||||
// link 模式必须**提前 return**, 不能落到下面的 redirectTo(url)
|
||||
const linkBranch = entryGoLink.slice(entryGoLink.indexOf("linkMode === 'link'"));
|
||||
assert.match(linkBranch.slice(0, 400), /return;/);
|
||||
});
|
||||
|
||||
test('list: goMore 的 link 模式走 $util.diyRedirectTo', () => {
|
||||
// list 的写法是先归一 mode = String(v.moreLinkMode || 'list'), 再按 mode 分支
|
||||
assert.match(listGoMore, /const\s+mode\s*=\s*String\(this\.v\.moreLinkMode\s*\|\|\s*'list'\)/);
|
||||
assert.match(listGoMore, /mode\s*===\s*'link'/);
|
||||
assert.match(listGoMore, /this\.\$util\.diyRedirectTo\(link\)/);
|
||||
const linkBranch = listGoMore.slice(listGoMore.indexOf("mode === 'link'"));
|
||||
assert.match(linkBranch.slice(0, 400), /return;/);
|
||||
});
|
||||
|
||||
test('entry: link 取的是配置里的 link 对象(不是 wap_url 字符串)', () => {
|
||||
assert.match(entryGoLink, /v\.link\s*&&\s*typeof\s+v\.link\s*===\s*'object'/);
|
||||
});
|
||||
|
||||
test('list: link 取的是配置里的 moreLink 对象', () => {
|
||||
assert.match(listGoMore, /this\.v\.moreLink\s*&&\s*typeof\s+this\.v\.moreLink\s*===\s*'object'/);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 2. 未选链接的守卫(不跳空)
|
||||
// =============================================================
|
||||
|
||||
test('entry: 未选链接时提示而不是跳空', () => {
|
||||
assert.match(entryGoLink, /if\s*\(!link\s*\|\|\s*!link\.name\)/);
|
||||
assert.match(entryGoLink, /请先选择链接/);
|
||||
});
|
||||
|
||||
test('list: 未选链接时提示而不是跳空', () => {
|
||||
assert.match(listGoMore, /if\s*\(!link\s*\|\|\s*!link\.name\)/);
|
||||
assert.match(listGoMore, /请先选择链接/);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 3. 内置三档仍走 redirectTo(没被新分支吃掉)
|
||||
// =============================================================
|
||||
|
||||
test('entry: 内置三档仍是 redirectTo(名片夹/联系人/自定义路径)', () => {
|
||||
assert.match(entryGoLink, /linkMode\s*===\s*'contacts'/);
|
||||
assert.match(entryGoLink, /\/pages_tool\/card\/contacts/);
|
||||
assert.match(entryGoLink, /linkMode\s*===\s*'custom'/);
|
||||
assert.match(entryGoLink, /\/pages_tool\/card\/list/);
|
||||
assert.match(entryGoLink, /this\.\$util\.redirectTo\(url\)/);
|
||||
});
|
||||
|
||||
test('list: 内置三档仍是 redirectTo', () => {
|
||||
assert.match(listGoMore, /mode\s*===\s*'contacts'/);
|
||||
assert.match(listGoMore, /\/pages_tool\/card\/contacts/);
|
||||
assert.match(listGoMore, /mode\s*===\s*'custom'/);
|
||||
assert.match(listGoMore, /this\.\$util\.redirectTo\(url\)/);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 反例自测(防恒真): 虚构模式/虚构字段必须不存在
|
||||
// =============================================================
|
||||
|
||||
test('反例: 两个组件都不存在虚构跳转模式', () => {
|
||||
assert.ok(!entrySrc.includes("linkMode === 'nosuchmode'"));
|
||||
assert.ok(!listSrc.includes("moreLinkMode === 'nosuchmode'"));
|
||||
});
|
||||
|
||||
test('反例: 两个组件都不存在虚构链接字段', () => {
|
||||
assert.ok(!entrySrc.includes('v.linkMagic'));
|
||||
assert.ok(!listSrc.includes('v.moreLinkMagic'));
|
||||
});
|
||||
96
tests/diy-card-mp-compat.test.js
Normal file
96
tests/diy-card-mp-compat.test.js
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 名片 DIY 组件 · 微信小程序(WXSS/WXML)兼容契约测试
|
||||
*
|
||||
* 背景: 组件在商家端装修页配置, C 端跑在 uniapp 产物(含微信小程序)。
|
||||
* WXSS 有几个 Web 不支持、小程序也不支持的坑, 本测试把它们固化为禁项:
|
||||
* 1. calc() —— WXSS 不支持(曾致 corner 档浮层布局/List 网格列宽失效)
|
||||
* 2. CSS 自定义属性 var(--x) —— 小程序会丢弃内联 style 里的自定义属性
|
||||
* 3. 模板标签配平 —— 防 <template> 嵌套(插槽)误判/漏闭合导致编译失败
|
||||
*
|
||||
* 口径: 禁项断言先剥三类注释(防"注释里写不能用的写法"被算违规);
|
||||
* 反例: 故意含 calc 的串必须被抓住(证明禁项断言不是恒真)。
|
||||
*
|
||||
* 跑法: node --test tests/diy-card-mp-compat.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 FILES = ['diy-card-mini.vue', 'diy-card-show.vue', 'diy-card-self.vue', 'diy-card-list.vue', 'diy-card-entry.vue', 'diy-card-common.js'];
|
||||
|
||||
const read = f => fs.readFileSync(path.join(DIY_DIR, f), 'utf8');
|
||||
const strip = s => s
|
||||
.replace(/<!--[\s\S]*?-->/g, '')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
||||
|
||||
/** uni-app 编译产物里的 void 元素(无闭合标签) */
|
||||
const VOID_TAGS = new Set(['image', 'input', 'br', 'hr', 'img']);
|
||||
|
||||
/** 标签配平: 先剔除属性串(防 > 藏在引号里误判), 再栈式匹配 */
|
||||
function checkBalance(tpl) {
|
||||
const stripped = tpl
|
||||
.replace(/=\s*"(?:[^"\\]|\\.)*"/g, '=""')
|
||||
.replace(/=\s*'(?:[^'\\]|\\.)*'/g, "=''");
|
||||
const stack = [];
|
||||
const re = /<(\/?)([a-z][a-z0-9-]*)((?:\s[^>]*)?)(\/?)>/g;
|
||||
let m;
|
||||
while ((m = re.exec(stripped)) !== null) {
|
||||
const closing = m[1] === '/';
|
||||
const tag = m[2];
|
||||
const selfClose = m[4] === '/';
|
||||
if (VOID_TAGS.has(tag) || selfClose) continue;
|
||||
if (!closing) {
|
||||
stack.push(tag);
|
||||
} else {
|
||||
const top = stack.pop();
|
||||
if (top !== tag) return '期望 </' + top + '> 实为 </' + tag + '>';
|
||||
}
|
||||
}
|
||||
return stack.length ? '未闭合: ' + stack.join(',') : null;
|
||||
}
|
||||
|
||||
for (const f of FILES) {
|
||||
test('MP禁项: ' + f + ' 代码无 calc()(WXSS 不支持)', () => {
|
||||
const code = strip(read(f));
|
||||
assert.ok(!/calc\s*\(/.test(code), f + ' 含 calc(): ' + (code.match(/calc\s*\([^)]*\)/) || [''])[0]);
|
||||
});
|
||||
|
||||
test('MP禁项: ' + f + ' 代码无 var(-- 自定义属性(小程序丢弃)', () => {
|
||||
const code = strip(read(f));
|
||||
assert.ok(!/var\(--/.test(code));
|
||||
});
|
||||
}
|
||||
|
||||
for (const f of FILES.filter(x => x.endsWith('.vue'))) {
|
||||
test('MP编译: ' + f + ' template 标签配平', () => {
|
||||
const src = read(f);
|
||||
const start = src.indexOf('<template>');
|
||||
const end = src.indexOf('<script');
|
||||
assert.ok(start >= 0 && end > start, f + ' 缺少 <template>/<script> 结构');
|
||||
const err = checkBalance(src.slice(start, end));
|
||||
assert.equal(err, null, f + ' 模板标签问题: ' + err);
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// 反例(防恒真): 含 calc/未闭合的串必须被抓住
|
||||
// =============================================================
|
||||
|
||||
test('反例: 含 calc 的串被禁项断言抓住', () => {
|
||||
assert.ok(/calc\s*\(/.test(strip('width: calc(50% - 10rpx);')));
|
||||
});
|
||||
|
||||
test('反例: 未闭合标签被配平检查抓住', () => {
|
||||
assert.ok(checkBalance('<view><text>x</text>') !== null);
|
||||
assert.ok(checkBalance('<view><text>x</text></view>') === null);
|
||||
});
|
||||
|
||||
test('反例: 嵌套 template(插槽)不会被误判(配平通过)', () => {
|
||||
assert.equal(checkBalance('<view><template slot="badge"><text>我的名片</text></template></view>'), null);
|
||||
});
|
||||
366
tests/diy-card-qr-style.test.js
Normal file
366
tests/diy-card-qr-style.test.js
Normal file
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* 名片 DIY 组件 · 4.0 头像开关 + 二维码多彩/微调 契约测试
|
||||
*
|
||||
* 背景: 参考图复刻的第二轮增强:
|
||||
* - 头像可隐藏(关闭后头部只留姓名/职位, 信息密度可调)
|
||||
* - 二维码四档外观: plain 原图 / card 白卡 / theme 主题色 / gradient 渐变
|
||||
* (多彩只装饰外围衬底, 码本身黑白保可扫 —— 不改共享 qr 生成端点)
|
||||
* - 自定义尺寸(0=按档位自动) + transform 微调位移(WXSS 不支持 calc, 用 translate)
|
||||
* - 扫码提示文案可关
|
||||
*
|
||||
* 覆盖:
|
||||
* 1. common.js 纯函数(动态 import 真值断言, 非正则)
|
||||
* 2. mini.vue 基件契约(先剥注释再断言)
|
||||
* 3. show/self 6 字段透传
|
||||
* 4. 两个 design.html 面板: 控件绑定 + 预览 mock 消费(属性变更预览同步)
|
||||
* 5. 反例: 虚构外观/虚构字段必须查不到(防恒真)
|
||||
*
|
||||
* 跑法: node --test tests/diy-card-qr-style.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 PANEL_DIR = path.resolve(__dirname, '../../backend/src/addon/card/component/view');
|
||||
const read = f => fs.readFileSync(path.join(DIY_DIR, f), 'utf8');
|
||||
const strip = s => s
|
||||
.replace(/<!--[\s\S]*?-->/g, '')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
||||
|
||||
const showSrc = strip(read('diy-card-show.vue'));
|
||||
const selfSrc = strip(read('diy-card-self.vue'));
|
||||
const miniSrc = strip(read('diy-card-mini.vue'));
|
||||
// 面板目录属 backend 仓库, 可能不在本工作区: 缺失时跳过面板断言(组件契约照常跑)
|
||||
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')) : '';
|
||||
|
||||
/**
|
||||
* 面板控件的字段绑定有三种形态(2026-10-21 面板改版: 下拉框 → 可视化选项组,
|
||||
* 数字框 → 滑块):
|
||||
* ① v-model="nc.X" 输入框 / 开关
|
||||
* ② nc.X = 'v' 可视化选项组(芯片)的赋值绑定
|
||||
* ③ field: 'X' 滑块控件 :data="{field:'X'}"
|
||||
* 断言的是「这个字段确实被某个控件绑定了」, 不限定控件形态 —— 契约是字段名, 不是控件类型。
|
||||
*/
|
||||
const bound = (src, field) => new RegExp(
|
||||
'v-model(\\.[a-z]+)?="nc\\.' + field + '"'
|
||||
+ '|\\bnc\\.' + field + "\\s*=\\s*['\"]"
|
||||
+ '|field\\s*:\\s*[\'"]' + field + '[\'"]'
|
||||
).test(src);
|
||||
|
||||
// =============================================================
|
||||
// 1. common.js 纯函数(真值断言)
|
||||
// =============================================================
|
||||
|
||||
test('common: QR_STYLES 四档外观枚举', async () => {
|
||||
const m = await import('../components-diy/diy-card-common.js');
|
||||
assert.deepEqual(m.QR_STYLES, ['plain', 'card', 'theme', 'gradient']);
|
||||
});
|
||||
|
||||
test('common: normalizeQrStyle 合法直通, 未知/空回落 card(默认白卡)', async () => {
|
||||
const m = await import('../components-diy/diy-card-common.js');
|
||||
for (const s of ['plain', 'card', 'theme', 'gradient']) {
|
||||
assert.equal(m.normalizeQrStyle(s), s);
|
||||
}
|
||||
// 未配置(存量装修页无此字段) → card, 与 diy_view.php 默认值同口径
|
||||
assert.equal(m.normalizeQrStyle(''), 'card');
|
||||
assert.equal(m.normalizeQrStyle(undefined), 'card');
|
||||
assert.equal(m.normalizeQrStyle(null), 'card');
|
||||
assert.equal(m.normalizeQrStyle('rainbow'), 'card');
|
||||
// 面板只下发小写档位; 大写属未知值, 回落 card(保守, 不炸)
|
||||
assert.equal(m.normalizeQrStyle('CARD'), 'card');
|
||||
// 中文档位名别名(面板下拉若直接存中文档名, 归一化兜住, 否则 C 端永远回落 card)
|
||||
assert.equal(m.normalizeQrStyle('原图'), 'plain');
|
||||
assert.equal(m.normalizeQrStyle('白卡描边'), 'card');
|
||||
assert.equal(m.normalizeQrStyle('主题色衬底'), 'theme');
|
||||
assert.equal(m.normalizeQrStyle('渐变圆角'), 'gradient');
|
||||
});
|
||||
|
||||
test('common: normalizeQrPosition 位置档中文别名兼容', async () => {
|
||||
const m = await import('../components-diy/diy-card-common.js');
|
||||
assert.equal(m.normalizeQrPosition('资料行下方'), 'rows');
|
||||
assert.equal(m.normalizeQrPosition('头部右侧'), 'head');
|
||||
assert.equal(m.normalizeQrPosition('右上角浮层'), 'corner');
|
||||
assert.equal(m.normalizeQrPosition('主按钮上方'), 'bottom');
|
||||
// 英文码直通 / 未知回落 rows
|
||||
assert.equal(m.normalizeQrPosition('bottom'), 'bottom');
|
||||
assert.equal(m.normalizeQrPosition('nope'), 'rows');
|
||||
});
|
||||
|
||||
test('common: darkenColor 压暗生成可见渐变终止色', async () => {
|
||||
const m = await import('../components-diy/diy-card-common.js');
|
||||
// 纯色压暗 0.18: #119dff → #0e81d1
|
||||
assert.equal(m.darkenColor('#119dff', 0.18), '#0e81d1');
|
||||
// 比例越大约暗
|
||||
assert.notEqual(m.darkenColor('#119dff', 0.4), m.darkenColor('#119dff', 0.18));
|
||||
// 非法输入原样返回(不影响既有渲染)
|
||||
assert.equal(m.darkenColor('#12', 0.18), '#12');
|
||||
assert.equal(m.darkenColor('rgb(1,2,3)', 0.18), 'rgb(1,2,3)');
|
||||
});
|
||||
|
||||
test('common: rowIconFor 资料行图标字段映射(含方案1邮箱预留 icon-youxiang)', async () => {
|
||||
const m = await import('../components-diy/diy-card-common.js');
|
||||
// 有字形的字段返回 iconfont 类
|
||||
assert.equal(m.rowIconFor('mobile'), 'icon-dianhua');
|
||||
assert.equal(m.rowIconFor('phone'), 'icon-dianhua');
|
||||
assert.equal(m.rowIconFor('wechat'), 'icon-weixin');
|
||||
assert.equal(m.rowIconFor('address'), 'icon-dizhi');
|
||||
// 邮箱: 方案1 预留 icon-youxiang(需字库补充信封字形后方能显示), 多键名兜底
|
||||
assert.equal(m.rowIconFor('email'), 'icon-youxiang');
|
||||
assert.equal(m.rowIconFor('mail'), 'icon-youxiang');
|
||||
assert.equal(m.rowIconFor('邮箱'), 'icon-youxiang');
|
||||
// 网站(2026-10-22): 复用字库现成链条字形 icon-fuzhilianjie, 多键名兜底
|
||||
assert.equal(m.rowIconFor('website'), 'icon-fuzhilianjie');
|
||||
assert.equal(m.rowIconFor('link'), 'icon-fuzhilianjie');
|
||||
assert.equal(m.rowIconFor('网站'), 'icon-fuzhilianjie');
|
||||
assert.equal(m.rowIconFor('custom'), '');
|
||||
assert.equal(m.rowIconFor(''), '');
|
||||
});
|
||||
|
||||
test('common: iconfont.css 邮箱信封 SVG 覆盖(字库无字形期的降级实现)', () => {
|
||||
const iconSrc = fs.readFileSync(path.resolve(__dirname, '../common/css/iconfont.css'), 'utf8');
|
||||
// 覆盖规则存在且 content 置空(避免渲染豆腐块), 尺寸/对齐与 row-icon 槽位匹配
|
||||
assert.match(iconSrc, /\.icon-youxiang:before \{[\s\S]*?content: "";[\s\S]*?width: 30rpx;[\s\S]*?height: 30rpx;/);
|
||||
// 信封 SVG data-uri(描边风格, #999999 与资料行标签默认色一致)
|
||||
assert.match(iconSrc, /\.icon-youxiang:before \{[\s\S]*?data:image\/svg\+xml,[\s\S]*?stroke='%23999999'[\s\S]*?%3E%3Crect x='2' y='4' width='20' height='16'[\s\S]*?center\/contain no-repeat/);
|
||||
});
|
||||
|
||||
test('common: resolveQrSize 自定义>档位自动, 单位 px→rpx(×2)', async () => {
|
||||
const m = await import('../components-diy/diy-card-common.js');
|
||||
// 自定义尺寸(面板 px) × 2 = rpx
|
||||
assert.equal(m.resolveQrSize('rows', 120), 240);
|
||||
assert.equal(m.resolveQrSize('corner', 60), 120);
|
||||
// 0 / 未配置 / 非法 → 按档位自动
|
||||
assert.equal(m.resolveQrSize('rows', 0), m.QR_SIZES.rows);
|
||||
assert.equal(m.resolveQrSize('head', 0), m.QR_SIZES.head);
|
||||
assert.equal(m.resolveQrSize('corner', 0), m.QR_SIZES.corner);
|
||||
assert.equal(m.resolveQrSize('bottom', 0), m.QR_SIZES.bottom);
|
||||
assert.equal(m.resolveQrSize('rows', undefined), m.QR_SIZES.rows);
|
||||
assert.equal(m.resolveQrSize('rows', -30), m.QR_SIZES.rows);
|
||||
assert.equal(m.resolveQrSize('rows', 'abc'), m.QR_SIZES.rows);
|
||||
// 未知档位 → rows 兜底
|
||||
assert.equal(m.resolveQrSize('sideways', 0), m.QR_SIZES.rows);
|
||||
// 字符串数字也要认(面板可能给字符串)
|
||||
assert.equal(m.resolveQrSize('rows', '120'), 240);
|
||||
});
|
||||
|
||||
test('common: qrTransform 位移(任一轴非零才输出; WXSS 支持 translate)', async () => {
|
||||
const m = await import('../components-diy/diy-card-common.js');
|
||||
assert.equal(m.qrTransform(0, 0), '');
|
||||
assert.equal(m.qrTransform(undefined, undefined), '');
|
||||
assert.equal(m.qrTransform(null, null), '');
|
||||
assert.equal(m.qrTransform('', ''), '');
|
||||
// px × 2 = rpx
|
||||
assert.equal(m.qrTransform(10, -20), 'translate(20rpx, -40rpx)');
|
||||
assert.equal(m.qrTransform(5, 0), 'translate(10rpx, 0rpx)');
|
||||
assert.equal(m.qrTransform(0, 5), 'translate(0rpx, 10rpx)');
|
||||
assert.equal(m.qrTransform(-50, 50), 'translate(-100rpx, 100rpx)');
|
||||
// 字符串数字(面板 number 输入可能给字符串)
|
||||
assert.equal(m.qrTransform('10', '20'), 'translate(20rpx, 40rpx)');
|
||||
// 非数字 → 0
|
||||
assert.equal(m.qrTransform('abc', 'xyz'), '');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 2. mini.vue 基件契约
|
||||
// =============================================================
|
||||
|
||||
test('mini: 蓝色顶带版式(2026-10-23 与精选名片 show 同款: 自适应白卡高度 + 弧线距底 88rpx)', () => {
|
||||
// 模板: 卡片包进 wrap, 顶带在最前
|
||||
assert.match(miniSrc, /class="mini-card-wrap"/);
|
||||
assert.match(miniSrc, /class="mini-band" :style="bandStyle"/);
|
||||
assert.match(miniSrc, /class="mini-card fade-up" :style="wrapStyle"/);
|
||||
// 计算属性: 顶带底色走主题渐变(与 show bandStyle 同口径)
|
||||
assert.match(miniSrc, /bandStyle\(\)\s*\{\s*return this\.\$util\.objToStyle\(\{\s*background: themeGradient\(this\.card\)\s*\}\);/);
|
||||
// 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: 企业徽标固定红底(参考图口径, 与头像底色解耦)', () => {
|
||||
assert.match(miniSrc, /badgeStyle\(\)\s*\{\s*return this\.\$util\.objToStyle\(\{\s*background: '#FA5151'\s*\}\);/);
|
||||
});
|
||||
|
||||
test('mini: 头像开关(默认显示, 关掉只留姓名/职位)', () => {
|
||||
assert.match(miniSrc, /showAvatarBlock\(\)\s*\{\s*return flagOn\(this\.c\.showAvatar,\s*true\)/);
|
||||
// 模板头像块受开关控制(无 v-else 占位 —— 关掉即消失, 头部信息自然左移)
|
||||
assert.match(miniSrc, /<view v-if="showAvatarBlock" class="mini-avatar" :style="avatarStyle">/);
|
||||
// 反例形态: 不允许出现常显头像(绕过开关)
|
||||
assert.ok(!miniSrc.includes('<view class="mini-avatar"'));
|
||||
});
|
||||
|
||||
test('mini: qrStyle 计算属性归一化', () => {
|
||||
assert.match(miniSrc, /qrStyle\(\)\s*\{\s*return normalizeQrStyle\(this\.c\.qrStyle\)/);
|
||||
});
|
||||
|
||||
test('mini: qrBlockStyle 四档外观分支(多彩只装饰外围, 码黑白保扫)', () => {
|
||||
// card 白卡描边(分支内逐行收紧, 防跨分支 lazy 匹配造成假过)
|
||||
assert.match(miniSrc, /if \(this\.qrStyle === 'card'\) \{\s+style\.background = '#ffffff';\s+style\.border = '2rpx solid #EEF0F3';\s+style\.borderRadius = '12rpx';\s+style\.padding = '10rpx';/);
|
||||
// theme 主题色衬底
|
||||
assert.match(miniSrc, /else if \(this\.qrStyle === 'theme'\) \{\s+style\.background = theme;\s+style\.borderRadius = '12rpx';\s+style\.padding = '10rpx';/);
|
||||
// gradient 渐变 + 6rpx 紧凑内边距; 单主题色用 darkenColor 兜底终止色(可见明暗)
|
||||
assert.match(miniSrc, /else if \(this\.qrStyle === 'gradient'\) \{\s+const end = \(this\.card && this\.card\.gradient_end\) \|\| darkenColor\(theme, 0\.18\);\s+style\.background = 'linear-gradient\(135deg, ' \+ theme \+ ' 0%, ' \+ end \+ ' 100%\)';\s+style\.borderRadius = '16rpx';\s+style\.padding = '6rpx';/);
|
||||
// plain 不进任何分支(无衬底, 即原图) —— 三个 if 之外不再设 background
|
||||
assert.ok(!miniSrc.includes("qrStyle === 'plain' ? style.background"));
|
||||
// 2026-10-23 用户定稿: 外观衬底只包二维码图(qrImgStyle), 「扫码查看完整名片」tip 在衬底外无蓝底
|
||||
assert.match(miniSrc, /class="qr-img" :style="qrImgStyle"/);
|
||||
const tipBinds = miniSrc.match(/class="qr-tip" :style="labelStyle"/g) || [];
|
||||
assert.equal(tipBinds.length, 5, '五档 tip 仍独立(不进衬底; 含 theme 头部块), 实际 ' + tipBinds.length);
|
||||
});
|
||||
|
||||
test('mini: 二维码尺寸取 resolveQrSize(自定义>0 优先, 否则档位自动)', () => {
|
||||
// qrBlockStyle(容器) + qrImgStyle(图, 外观衬底) + cornerClearStyle(垂直让位) + cornerHeadStyle(水平让位)
|
||||
// (自定义大尺寸时浮层占高与占宽都要动态跟随)
|
||||
const hits = miniSrc.match(/resolveQrSize\(this\.qrPos, this\.c\.qrSize\)/g) || [];
|
||||
assert.equal(hits.length, 4, 'qrBlockStyle + qrImgStyle + cornerClearStyle + cornerHeadStyle 四处, 实际 ' + hits.length);
|
||||
});
|
||||
|
||||
test('mini: 微调位移 transform(不用 calc, WXSS 兼容)', () => {
|
||||
assert.match(miniSrc, /const transform = qrTransform\(this\.c\.qrOffsetX, this\.c\.qrOffsetY\);\s*if \(transform\) style\.transform = transform;/);
|
||||
// 位移零值不输出 transform(不污染默认布局)
|
||||
assert.match(miniSrc, /if \(transform\) style\.transform = transform;/);
|
||||
});
|
||||
|
||||
test('mini: 扫码提示开关 + 四个挂点统一消费', () => {
|
||||
assert.match(miniSrc, /showQrTip\(\)\s*\{\s*return flagOn\(this\.c\.qrTipShow,\s*true\)/);
|
||||
// 二维码 tip 挂点: corner/rows/bottom 各 1 + head 档 2(head 双形态分支互斥渲染, 2.17)
|
||||
const tips = miniSrc.match(/<text v-if="showQrTip" class="qr-tip"/g) || [];
|
||||
assert.equal(tips.length, 5, 'tip 挂点(4 档 + head 双分支各一), 实际 ' + tips.length);
|
||||
});
|
||||
|
||||
test('mini: cornerClearStyle 按实际尺寸反算让位(防遮挡, 纯数值不用 calc)', () => {
|
||||
assert.match(miniSrc, /cornerClearStyle\(\)\s*\{[\s\S]*?if \(this\.qrPos !== 'corner' \|\| !this\.showQrBase\) return \{\};/);
|
||||
// 让位量 = 浮层 top 20 + 块高[尺寸+衬底+tip] - 头部 104 + 间隙 10; 下限 24
|
||||
assert.match(miniSrc, /Math\.max\(24, total - headHeight \+ 10\)/);
|
||||
// 衬底与 tip 参与计算(关 tip 时让位自动收窄)
|
||||
assert.match(miniSrc, /const tip = this\.showQrTip \? 42 : 0;/);
|
||||
});
|
||||
|
||||
test('mini: cornerHeadStyle 头部水平让位按浮层实际宽度动态反算', () => {
|
||||
// 审计修复: 静态 180rpx 在自定义 qrSize>120 时不足 → 头部让位动态化
|
||||
assert.match(miniSrc, /cornerHeadStyle\(\)\s*\{[\s\S]*?if \(this\.qrPos !== 'corner' \|\| !this\.showQrBase\) return \{\};/);
|
||||
// 浮层宽[尺寸+衬底] + 右偏移 20 + 呼吸 20
|
||||
assert.match(miniSrc, /paddingRight: \(size \+ pad \+ 40\) \+ 'rpx'/);
|
||||
// 模板头部绑定了动态让位(浮层不存在时输出空对象不占位)
|
||||
assert.match(miniSrc, /<view class="mini-head" :style="cornerHeadStyle">/);
|
||||
});
|
||||
|
||||
test('mini: 二维码块样式绑定——corner/head 走 qrBlockStyle, rows/bottom 走 qrBlockFinalStyle(2.19.10 分卡合并)', () => {
|
||||
const direct = miniSrc.match(/:style="qrBlockStyle"/g) || [];
|
||||
assert.equal(direct.length, 3, 'qrBlockStyle 直绑(corner 1 + head 双分支 2), 实际 ' + direct.length);
|
||||
const final = miniSrc.match(/:style="qrBlockFinalStyle"/g) || [];
|
||||
assert.equal(final.length, 2, 'qrBlockFinalStyle(rows/bottom 各 1), 实际 ' + final.length);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 3. self miniConfig 透传 / show 自绘版式二维码契约
|
||||
// =============================================================
|
||||
|
||||
test('self: miniConfig 透传头像+二维码新六字段', () => {
|
||||
assert.match(selfSrc, /showAvatar:\s*v\.showAvatar/);
|
||||
assert.match(selfSrc, /qrStyle:\s*v\.qrStyle/);
|
||||
assert.match(selfSrc, /qrSize:\s*v\.qrSize/);
|
||||
assert.match(selfSrc, /qrOffsetX:\s*v\.qrOffsetX/);
|
||||
assert.match(selfSrc, /qrOffsetY:\s*v\.qrOffsetY/);
|
||||
assert.match(selfSrc, /qrTipShow:\s*v\.qrTipShow/);
|
||||
});
|
||||
|
||||
test('self: 「我的名片」角标与未登录提示条均已移除(2026-10-23 用户定稿)', () => {
|
||||
// 角标按 2026-10-23 参考图删除, 反例防回归
|
||||
assert.ok(!selfSrc.includes('self-badge') && !selfSrc.includes('slot="badge"'));
|
||||
// 未登录提示条(「登录后即可展示我的名片·去看看名片」)按用户要求整条移除, 反例防回归
|
||||
assert.ok(!selfSrc.includes('self-login-bar') && !selfSrc.includes('loginBarTip'));
|
||||
// 空态分支接管提示条删除后的条件(非引导模式 + 无卡 + 非加载)
|
||||
assert.match(selfSrc, /v-else-if="!loading && !card" class="self-empty"/);
|
||||
});
|
||||
|
||||
test('show: miniConfig 全字段透传(方案A定稿: C端=diy-card-mini 基件=面板预览逐字段一致)', () => {
|
||||
// 2026-10-24 用户定稿: Show 弃自绘版式恢复基件 —— 面板 53 字段全部经 miniConfig 生效(与 self 同构);
|
||||
// 行为由 mini 侧同名测试覆盖, 此处锁透传链防 merge 冲突解决再丢(10-24 两次 merge 曾丢失整段透传)
|
||||
const fields = [
|
||||
// 视觉开关/设计资产
|
||||
'showQuick', 'showDetailBtn', 'detailBtnText', 'useCover', 'showGradient', 'showLogo', 'showShadow',
|
||||
'showRows', 'maxRows', 'showRowIcons', 'rowTap',
|
||||
// 字号 7 字段
|
||||
'nameSize', 'cardTitleSize', 'subSize', 'labelSize', 'valueSize', 'actSize', 'btnSize',
|
||||
// 二维码六字段 + 自定义图
|
||||
'showQr', 'qrTip', 'qrPosition', 'showAvatar', 'qrStyle', 'qrSize', 'qrOffsetX', 'qrOffsetY', 'qrTipShow', 'qrImage',
|
||||
// 2.17 双形态 + 2.18 位置/成块 + 2.19 图层管理全套
|
||||
'headStyle', 'headNameColor', 'headSubColor', 'actsLayout', 'actGridColorMode',
|
||||
'showRowsTitle', 'rowsTitle', 'showCopyBtn', 'actsPosition', 'actsBlockBg', 'actsBlockRadius', 'actsBlockPad',
|
||||
'actIcons', 'actVis', 'actDeleted', 'actLabels', 'actOrder', 'customActs',
|
||||
];
|
||||
for (const f of fields) {
|
||||
assert.match(showSrc, new RegExp(f + ':\\s*v\\.' + f + ','), 'miniConfig 缺透传 ' + f);
|
||||
}
|
||||
// 二维码/发名片点击链路: 基件 onQrTap/onSend $emit('detail'/'send'), show 接收方挂 @detail
|
||||
assert.match(showSrc, /@detail="goDetail"/);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 4. design.html 面板: 控件绑定 + 预览 mock 消费
|
||||
// =============================================================
|
||||
|
||||
for (const [name, panel] of hasPanels ? [['show', showPanel], ['self', selfPanel]] : []) {
|
||||
test(name + ' 面板: 头像开关 + 二维码新六字段有 v-model 绑定', () => {
|
||||
assert.match(panel, /v-model="nc\.showAvatar"/);
|
||||
// qrStyle/qrSize 为档位 chips/slide 绑定形态(2026-10-24 merge 后口径), 其余字段 v-model
|
||||
assert.match(panel, /(?:v-model|@click)="[^"]*nc\.qrStyle/);
|
||||
assert.match(panel, /field:\s*'qrSize'/);
|
||||
assert.match(panel, /v-model\.number="nc\.qrOffsetX"/);
|
||||
assert.match(panel, /v-model\.number="nc\.qrOffsetY"/);
|
||||
assert.match(panel, /v-model="nc\.qrTipShow"/);
|
||||
// 二维码外观四选项(plain/card/theme/gradient)
|
||||
|
||||
for (const v of ['plain', 'card', 'theme', 'gradient']) {
|
||||
assert.ok(
|
||||
panel.includes("nc.qrStyle = '" + v + "'") || panel.includes('<option value="' + v + '">'),
|
||||
'缺选项 ' + v
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test(name + ' 面板: 预览 mock 消费新属性(属性变更预览同步)', () => {
|
||||
// 头像开关
|
||||
assert.ok(panel.includes('nc.showAvatar !== false'), '预览不消费 showAvatar');
|
||||
// 二维码外观四档
|
||||
assert.ok(panel.includes("nc.qrStyle === 'plain'"), '预览不消费 qrStyle');
|
||||
// 自定义尺寸: 只锁「自定义值优先于档位自动」这层语义, 不锁死具体像素值
|
||||
// (档位默认值是可调的设计参数, 锁死会让改一次尺寸就得改一次测试)
|
||||
assert.ok(panel.includes('nc.qrSize > 0 ? nc.qrSize'), '预览不消费 qrSize');
|
||||
// 微调位移
|
||||
assert.ok(panel.includes('nc.qrOffsetX || 0'), '预览不消费 qrOffsetX');
|
||||
assert.ok(panel.includes('nc.qrOffsetY || 0'), '预览不消费 qrOffsetY');
|
||||
// 提示开关
|
||||
assert.ok(panel.includes('nc.qrTipShow !== false'), '预览不消费 qrTipShow');
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// 反例(防恒真)
|
||||
// =============================================================
|
||||
|
||||
test('反例: 不存在第五档二维码外观', () => {
|
||||
assert.ok(!miniSrc.includes("qrStyle === 'rainbow'"));
|
||||
assert.ok(!miniSrc.includes("qrStyle === 'neon'"));
|
||||
// 面板同理
|
||||
assert.ok(!showPanel.includes("nc.qrStyle == 'rainbow'"));
|
||||
assert.ok(!selfPanel.includes("nc.qrStyle == 'rainbow'"));
|
||||
});
|
||||
|
||||
test('反例: 虚构字段绝不出现在面板/基件', () => {
|
||||
assert.ok(!showPanel.includes('nc.qrMagic'));
|
||||
assert.ok(!selfPanel.includes('nc.qrMagic'));
|
||||
assert.ok(!miniSrc.includes('c.qrMagic'));
|
||||
assert.ok(!showSrc.includes('v.qrMagic'));
|
||||
assert.ok(!selfSrc.includes('v.qrMagic'));
|
||||
});
|
||||
779
tests/diy-card-style-v2.test.js
Normal file
779
tests/diy-card-style-v2.test.js
Normal file
@@ -0,0 +1,779 @@
|
||||
/**
|
||||
* 名片 DIY 组件 · 2.17 双形态(渐变头部/宫格快捷操作/资料区标题/复制按钮)契约测试
|
||||
*
|
||||
* 锁死三层口径: diy_view.php 默认值 ↔ design.html 面板控件与预览 ↔ C 端 vue 消费
|
||||
* 兼容铁律(2026-10-24 起分两层):
|
||||
* ① 基件 flagOn/normalize 缺字段回落历史形态(plain/chips/关) —— 本测试 108-241 行
|
||||
* 断言保持不变, 保护任何缺字段的存量行渲染零变化;
|
||||
* ② diy_view.php config 默认值代际升级为参考图形态(theme/grid/开/above) ——
|
||||
* 配套迁移(test/card_diy_instance_style_migrate.php)把注册表行补缺 + 存量页面
|
||||
* 实例按整值指纹刷新, 存量行不再依赖①的回落, 故 config 默认值断言随代际更新。
|
||||
*
|
||||
* 同时锁死 2.17 修复: self.vue miniConfig 补齐 7 个字号字段透传
|
||||
* (diy-card-show.vue 自 2026-10-21 起改为自绘, 不再经 miniConfig 复用 diy-card-mini,
|
||||
* 配置由 computed 直读 this.c, 故仅对仍走 v 对象架构的 self.vue 锁死透传)
|
||||
*/
|
||||
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');
|
||||
|
||||
/** 2.17 新字段 → 默认值(2026-10-24 代际升级: 参考图形态, 见文件头注释) */
|
||||
const V2_FIELDS = {
|
||||
headStyle: 'theme',
|
||||
headNameColor: '#FFFFFF',
|
||||
headSubColor: '#FFFFFF',
|
||||
actsLayout: 'grid',
|
||||
actGridColorMode: 'preset',
|
||||
showRowsTitle: 1,
|
||||
rowsTitle: '联系资料',
|
||||
showCopyBtn: 1,
|
||||
};
|
||||
|
||||
/** 2.18 新字段 → 默认值(2026-10-24 代际升级: actsPosition above) */
|
||||
const V18_FIELDS = {
|
||||
actsPosition: 'above',
|
||||
actsBlockBg: '',
|
||||
actsBlockRadius: 0,
|
||||
actsBlockPad: 0,
|
||||
qrImage: '',
|
||||
};
|
||||
const V18_ACT_TYPES = ['tel', 'wx', 'mail', 'web', 'save'];
|
||||
const PHP_NAMES = { 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]);
|
||||
}
|
||||
|
||||
function readMiniVue() {
|
||||
return readFileSync(resolve(FE, 'diy-card-mini.vue'), 'utf8');
|
||||
}
|
||||
|
||||
function readCommonJs() {
|
||||
return readFileSync(resolve(FE, 'diy-card-common.js'), 'utf8');
|
||||
}
|
||||
|
||||
test('diy_view.php: Show/Self value 均含 8 个 2.17 字段且默认值=参考图形态(2026-10-24 代际升级)', () => {
|
||||
for (const comp of ['show', 'self']) {
|
||||
const v = readValueJson(comp);
|
||||
for (const [field, dft] of Object.entries(V2_FIELDS)) {
|
||||
assert.ok(v[field] !== undefined, comp + '.' + field + ' 已注册');
|
||||
assert.equal(v[field], dft, comp + '.' + field + ' 默认值=' + JSON.stringify(dft));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('diy_view.php: Show/Self value 均含 2.18 字段(actIcons 五类型完整结构, actsPosition=above 代际升级)', () => {
|
||||
for (const comp of ['show', 'self']) {
|
||||
const v = readValueJson(comp);
|
||||
for (const [field, dft] of Object.entries(V18_FIELDS)) {
|
||||
assert.ok(v[field] !== undefined, comp + '.' + field + ' 已注册');
|
||||
assert.deepEqual(v[field], dft, comp + '.' + field + ' 默认值=' + JSON.stringify(dft));
|
||||
}
|
||||
// actIcons: 必须是完整五类型结构(旧行 refreshComponent 深拷贝补全后, 面板
|
||||
// v-model="nc.actIcons[type].xxx" 才不会因缺键报错); 2.19 起含 iconKey(素材库)
|
||||
assert.ok(v.actIcons && typeof v.actIcons === 'object', comp + '.actIcons 已注册');
|
||||
for (const t of V18_ACT_TYPES) {
|
||||
assert.deepEqual(v.actIcons[t], { img: '', iconKey: '', svg: '', color: '', bg: '' }, comp + '.actIcons.' + t + ' 结构完整(含 svg 预览快照位)');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('common.js: normalizeActsPosition + actIconCfgOf(2.18)', () => {
|
||||
const js = readCommonJs();
|
||||
const consts = js.match(/export const ACTS_POSITIONS = [^;]+;/)[0].replace('export const', 'const');
|
||||
const pos = new Function('v', consts + '\n' + js.match(/export function normalizeActsPosition[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '; return normalizeActsPosition(v);');
|
||||
assert.equal(pos(undefined), 'below', '缺省回落 below(历史形态)');
|
||||
assert.equal(pos('nonsense'), 'below', '非法值回落 below');
|
||||
assert.equal(pos('above'), 'above', 'above 透传');
|
||||
assert.equal(pos('below'), 'below', 'below 透传');
|
||||
const cfgOf = new Function('actIcons', 'type', js.match(/export function actIconCfgOf[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '; return actIconCfgOf(actIcons, type);');
|
||||
assert.deepEqual(cfgOf(undefined, 'tel'), {}, 'actIcons 缺省 → 空对象');
|
||||
assert.deepEqual(cfgOf({}, 'tel'), {}, 'actIcons 空表 → 空对象');
|
||||
assert.deepEqual(cfgOf({ tel: { img: 'a.png' } }, 'tel'), { img: 'a.png' }, '按 type 取配置');
|
||||
assert.deepEqual(cfgOf({ tel: 'bad' }, 'tel'), {}, '非对象配置 → 空对象');
|
||||
});
|
||||
|
||||
test('common.js: normalize 非法/缺省值回落历史默认(兼容 1.0 铁律)', () => {
|
||||
const js = readCommonJs();
|
||||
// 函数体引用模块常量(HEAD_STYLES 等), 重构执行时一并提供作用域
|
||||
const consts = js.match(/export const (HEAD_STYLES|ACTS_LAYOUTS|QR_POSITIONS|QR_STYLES) = [^;]+;/g)
|
||||
.map(s => s.replace('export const ', 'const ')).join('\n');
|
||||
for (const [fn, arg, expect, desc] of [
|
||||
['normalizeHeadStyle', undefined, 'plain', '缺省回落 plain'],
|
||||
['normalizeHeadStyle', 'nonsense', 'plain', '非法值回落 plain'],
|
||||
['normalizeHeadStyle', 'theme', 'theme', 'theme 透传'],
|
||||
['normalizeActsLayout', undefined, 'chips', '缺省回落 chips'],
|
||||
['normalizeActsLayout', 'nonsense', 'chips', '非法值回落 chips'],
|
||||
['normalizeActsLayout', 'grid', 'grid', 'grid 透传'],
|
||||
['normalizeActGridColorMode', undefined, 'preset', '缺省回落 preset'],
|
||||
['normalizeActGridColorMode', 'theme', 'theme', 'theme 透传'],
|
||||
['normalizeActGridColorMode', 'nonsense', 'preset', '非法值回落 preset'],
|
||||
]) {
|
||||
const re = new RegExp('export function ' + fn + '\\(([^)]*)\\)\\s*\\{([\\s\\S]*?)\\n}');
|
||||
const m = js.match(re);
|
||||
assert.ok(m, 'common.js 存在 ' + fn);
|
||||
const body = m[0].replace(/^export function /, 'function ');
|
||||
const fnObj = new Function('v', consts + '\n' + body + '; return ' + fn + '(v);');
|
||||
assert.equal(fnObj(arg), expect, fn + ': ' + desc);
|
||||
}
|
||||
});
|
||||
|
||||
test('common.js: hexToRgba 换算与反例 + ACT_GRID_ICONS 字形映射', () => {
|
||||
const js = readCommonJs();
|
||||
const hexToRgba = new Function('hex', 'alpha', js.match(/export function hexToRgba[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '; return hexToRgba(hex, alpha);');
|
||||
assert.equal(hexToRgba('#2F6BFF', 0.12), 'rgba(47,107,255,0.12)');
|
||||
assert.equal(hexToRgba('2F6BFF', 0.5), 'rgba(47,107,255,0.5)');
|
||||
assert.equal(hexToRgba('#F8A', 0.2), 'rgba(255,136,170,0.2)', '3 位短写法展开(复审修复)');
|
||||
assert.equal(hexToRgba('#XYZ', 0.5), null, '非法 hex 返回 null');
|
||||
assert.equal(hexToRgba('', 0.5), null, '空串返回 null');
|
||||
const icons = js.match(/export const WAP_LINE_ICONS = \{[\s\S]*?\n\};/)[0];
|
||||
// 2.18.1: 宫格图标与 wap detail 快捷操作同源(五枚 SVG 线稿), wx/save 固定色
|
||||
assert.match(icons, /tel:\s*\{\s*mode:\s*'stroke'/);
|
||||
assert.match(icons, /wx:\s*\{\s*mode:\s*'fill',\s*color:\s*'#07C160'/, 'wx 微信绿固定色(detail 同源)');
|
||||
assert.match(icons, /mail:\s*\{\s*mode:\s*'stroke'/);
|
||||
assert.match(icons, /web:\s*\{\s*mode:\s*'stroke'/);
|
||||
assert.match(icons, /save:\s*\{\s*mode:\s*'stroke',\s*color:\s*'#FF9500'/, 'save 通讯录橙固定色(detail 同源)');
|
||||
// svgLineIconBg: data URI 生成(stroke 模式颜色内嵌 / fill 模式固定色)
|
||||
// 注入依赖: WAP_LINE_ICONS 常量 + 私有 b64ascii(含 B64T 字符表)
|
||||
const wapConsts = js.match(/export const WAP_LINE_ICONS = \{[\s\S]*?\n\};/)[0].replace('export const', 'const');
|
||||
const b64fn = js.match(/const B64T = [^;]+;[\s\S]*?function b64ascii\(s\) \{[\s\S]*?\n\}/)[0];
|
||||
const svgBg = new Function('type', 'color', wapConsts + '\n' + b64fn + '\n' + js.match(/export function svgLineIconBg[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '; return svgLineIconBg(type, color);');
|
||||
const telBg = svgBg('tel', '#0F6E56');
|
||||
assert.match(telBg, /^url\("data:image\/svg\+xml;base64,/, 'data URI base64 形态');
|
||||
assert.ok(atob(telBg.replace(/^url\("data:image\/svg\+xml;base64,/, '').replace(/"\)$/, '')).indexOf('stroke="#0F6E56"') >= 0, 'stroke 色内嵌');
|
||||
const wxBg = svgBg('wx', '#123456');
|
||||
assert.ok(atob(wxBg.replace(/^url\("data:image\/svg\+xml;base64,/, '').replace(/"\)$/, '')).indexOf('fill="#07C160"') >= 0, 'wx 固定绿不随入参色');
|
||||
assert.equal(svgBg('unknown', '#000'), '', '未知类型空串(首字兜底)');
|
||||
// save 入多彩预设(紫色档)
|
||||
assert.match(js, /save:\s*\{\s*bg:\s*'#F0E9FB',\s*fg:\s*'#5B3DA8'/, '宫格 preset 补 save 紫色档');
|
||||
});
|
||||
|
||||
test('common.js: rowCssIconFor 资料行 CSS 图标(2.17.1)', () => {
|
||||
const js = readCommonJs();
|
||||
const rowConsts = js.match(/const ROW_CSS_ICONS = \{[\s\S]*?\};/)[0];
|
||||
const fn = new Function('field', rowConsts + '\n' + js.match(/export function rowCssIconFor[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '; return rowCssIconFor(field);');
|
||||
assert.equal(fn('email'), 'mail', '邮箱行走 CSS 信封');
|
||||
assert.equal(fn('website'), 'web', '网站行走 CSS 线框地球');
|
||||
assert.equal(fn('mobile'), '', '手机行仍用 iconfont 字形(rowIconFor 口径)');
|
||||
assert.equal(fn(''), '');
|
||||
assert.equal(fn(undefined), '');
|
||||
});
|
||||
|
||||
test('mini.vue: theme 头部渲染分支 + 白字 fallback + 负 margin 通铺', () => {
|
||||
const src = readMiniVue();
|
||||
assert.match(src, /headTheme\(\)\s*\{[\s\S]*?normalizeHeadStyle\(this\.c\.headStyle\)\s*===\s*'theme'/, 'headTheme 消费 normalizeHeadStyle');
|
||||
assert.match(src, /v-if="headTheme"[\s\S]*?mini-headtheme/, 'theme 档渲染渐变头部块');
|
||||
assert.match(src, /v-if="!headTheme"[\s\S]*?class="mini-head"/, 'plain 档渲染白卡头部');
|
||||
// 白字 fallback: headNameColor 缺省 → #FFFFFF; 副行 → 白 85%
|
||||
assert.match(src, /this\.c\.headNameColor \|\| '#FFFFFF'/, '头部姓名色 fallback 白');
|
||||
assert.match(src, /this\.c\.headSubColor \|\| 'rgba\(255,255,255,0\.85\)'/, '头部副行色 fallback 白 85%');
|
||||
// 负 margin 通铺(不用 calc): margin: -{pad}rpx -{pad}rpx 0
|
||||
assert.match(src, /margin: '-' \+ pad \+ 'rpx -' \+ pad \+ 'rpx 0'/, '头部块负 margin 通铺卡边');
|
||||
// 空态提示不随 theme 变白(独立 emptyTipStyle)
|
||||
assert.match(src, /emptyTipStyle\(\)/, '空态提示独立样式');
|
||||
assert.doesNotMatch(src, /:style="subTextStyle">\{\{ emptyTip/, '空态不再复用 subTextStyle');
|
||||
});
|
||||
|
||||
test('mini.vue: 宫格快捷操作分支 + preset/theme 双配色 + 首字兜底', () => {
|
||||
const src = readMiniVue();
|
||||
assert.match(src, /actsGrid\(\)\s*\{[\s\S]*?normalizeActsLayout\(this\.c\.actsLayout\)\s*===\s*'grid'/, 'actsGrid 消费 normalizeActsLayout');
|
||||
// 2.18.1: 宫格图标 = SVG 线稿(与 wap detail 同源), img 覆盖优先, 未知类型首字兜底
|
||||
assert.match(src, /mini-acts-grid[\s\S]*?actSvgOf\(act\)/, '宫格渲染用 actSvgOf');
|
||||
assert.match(src, /v-else-if="actSvgOf\(act\)" class="act-grid-svg" :style="actSvgStyle\(act\)"/, 'SVG 线稿分支');
|
||||
assert.match(src, /actInitialOf\(act\)/, '未知类型首字兜底');
|
||||
assert.doesNotMatch(src, /act-grid-glyph/, 'iconfont 字形档已退出宫格(全 SVG)');
|
||||
assert.match(src, /gridColorMode\(\)\s*\{[\s\S]*?normalizeActGridColorMode\(this\.c\.actGridColorMode\)/, '宫格配色模式消费 normalizeActGridColorMode');
|
||||
// theme 档浅底 = detail 同款 145deg 12%→20% 主题渐变
|
||||
assert.match(src, /linear-gradient\(145deg, ' \+ \(hexToRgba\(theme, 0\.12\) \|\| '#F2F3F5'\) \+ ', ' \+ \(hexToRgba\(theme, 0\.2\) \|\| '#F2F3F5'\)/, 'theme 档浅主题渐变底(detail 同源)');
|
||||
assert.match(src, /ACT_GRID_PRESETS\[\(act && act\.type\) \|\| ''\] \|\| ACT_GRID_PRESET_FALLBACK/, 'preset 色板 + fallback');
|
||||
// 带参样式函数必须在 methods(computed 不支持参数)
|
||||
const methods = src.match(/methods:\s*\{[\s\S]*$/)[0];
|
||||
assert.match(methods, /actGridIconStyle\(act\)/, 'actGridIconStyle 在 methods');
|
||||
assert.match(methods, /actGridGlyphStyle\(act\)/, 'actGridGlyphStyle 在 methods');
|
||||
// 2.18.1: 图标色口径 = 每按钮 color > detail 固定色(wx绿/save橙) > theme > preset
|
||||
assert.match(methods, /actIconColor\(act\)[\s\S]*?cfgColor[\s\S]*?ico && ico\.color[\s\S]*?gridColorMode === 'theme'/, 'actIconColor 四级口径');
|
||||
assert.match(methods, /actSvgStyle\(act\)\s*\{[\s\S]*?svgLineIconBg\(act && act\.type,\s*this\.actIconColor\(act\)\)/, 'actSvgStyle 消费 svgLineIconBg + actIconColor');
|
||||
assert.match(src, /import \{[\s\S]*?WAP_LINE_ICONS, svgLineIconBg/, 'mini.vue 引入 SVG 图标源');
|
||||
// CSS 图标基础样式仍须顶层定义(资料行 email/website 行还在用 --row 版)
|
||||
assert.match(src, /\n\.css-ico-mail \{/, 'css-ico-mail 顶层定义(资料行消费)');
|
||||
assert.match(src, /\n\.css-ico-web \{/, 'css-ico-web 顶层定义(资料行消费)');
|
||||
// 5 动作一行: 列宽按 acts 数量动态(20% / 25%)
|
||||
assert.match(src, /gridItemWidth\(\)\s*\{[\s\S]*?acts\.length > 4 \? '20%' : '25%'/, '宫格列宽 5 动作 20%');
|
||||
assert.match(src, /:style="\{ width: gridItemWidth \}"/, '宫格列宽内联绑定');
|
||||
// WXSS 禁 calc(): CSS 图标规则体内不得出现 calc(限定规则体内, 避免注释「不支持 calc()」字样假阳性)
|
||||
assert.doesNotMatch(src, /css-ico[a-z-]*\s*\{[^{}]*calc\(/, 'CSS 描边图标无 calc');
|
||||
});
|
||||
|
||||
test('mini.vue + show/self: save(存通讯录)动作全链路(2.17.1)', () => {
|
||||
// 基件: 资料行 CSS 图标(邮箱/网站)与行图标开关
|
||||
const src = readMiniVue();
|
||||
assert.match(src, /rowCssIcon\(row\)\s*\{[\s\S]*?rowCssIconFor\(row && row\.field\)/, '资料行 CSS 图标消费 rowCssIconFor');
|
||||
assert.match(src, /v-if="rowCssIcon\(row\)"[\s\S]*?css-ico-mail--row/, '资料行渲染缩小版信封');
|
||||
assert.match(src, /row-css-wrap[\s\S]*?width: 96rpx/, 'CSS 行图标容器与标签列同宽对齐');
|
||||
// show/self: doAct save 分支 + goSaveContact 字段组装(白名单口径)
|
||||
for (const file of ['diy-card-show.vue', 'diy-card-self.vue']) {
|
||||
const vue = readFileSync(resolve(FE, file), 'utf8');
|
||||
assert.match(vue, /if \(act\.type === 'save'\)\s*\{\s*this\.goSaveContact\(\);\s*return;\s*\}/, file + ' doAct 加 save 分支');
|
||||
assert.match(vue, /goSaveContact\(\)\s*\{[\s\S]*?save-contact/, file + ' 跳 save-contact 页');
|
||||
// 字段组装自 rows(白名单)+ 公开头部字段, 键名与 save-contact.vue onLoad keys 一一对应
|
||||
for (const k of ['name', 'mobile', 'company', 'job_title', 'email', 'website', 'address', 'remark', 'wechat']) {
|
||||
assert.match(vue, new RegExp(k + ": (c\\." + k + " || '')|(" + k + ": map\\." + k + " || '')"), file + ' 组装字段 ' + k);
|
||||
}
|
||||
assert.match(vue, /Array\.isArray\(c\.rows\)[\s\S]*?map\[r\.field\]/, file + ' 字段从 rows(白名单)提取');
|
||||
assert.doesNotMatch(vue, /doAct\(act\)\s*\{\s*if \(!act \|\| !act\.value\) return;/, file + ' doAct 不再拦 value 为空的 save');
|
||||
// 2.19.3 扩展类型: sms/map/link 分支 + map 空值回落名片地址
|
||||
assert.match(vue, /act\.type === 'map' && !act\.value && this\.card && this\.card\.address/, file + ' map 空值回落名片地址');
|
||||
assert.match(vue, /act\.type === 'sms'\)\s*\{[\s\S]*?window\.location\.href = 'sms:' \+ act\.value;[\s\S]*?号码已复制/, file + ' sms 分支(H5 sms: 链接/非 H5 复制)');
|
||||
assert.match(vue, /act\.type === 'map'\)\s*\{[\s\S]*?地址已复制,可粘贴到地图导航/, file + ' map 分支(复制地址)');
|
||||
assert.match(vue, /act\.type === 'link'\)\s*\{[\s\S]*?indexOf\('http:\/\/'\) === 0 [\s\S]*?\$util\.redirectTo\(act\.value, \{\}\)/, file + ' link 分支(外链 H5 跳转/小程序复制, 站内 redirectTo)');
|
||||
}
|
||||
});
|
||||
|
||||
test('mini.vue: 资料区标题 + 复制按钮(默认关, emit row-act copy)', () => {
|
||||
const src = readMiniVue();
|
||||
assert.match(src, /rowsTitleShow\(\)\s*\{[\s\S]*?flagOn\(this\.c\.showRowsTitle,\s*false\)/, '资料标题默认关');
|
||||
assert.match(src, /showCopyBtn\(\)\s*\{[\s\S]*?flagOn\(this\.c\.showCopyBtn,\s*false\)/, '复制按钮默认关');
|
||||
assert.match(src, /v-if="rowsTitleShow"[\s\S]*?rowsTitleBarStyle/, '标题渲染含主题色竖条');
|
||||
assert.match(src, /onCopyRow\(row\)[\s\S]*?\$emit\('row-act',\s*\{\s*type:\s*'copy',\s*value:\s*row\.value\s*\}\)/, '复制复用 row-act copy 分支');
|
||||
assert.match(src, /@tap\.stop="onCopyRow\(row\)"/, '复制按钮阻断行点击冒泡');
|
||||
});
|
||||
|
||||
test('复审修复(2.17 review): corner 让位计 theme 头高 / 圆角仅底部 / text-shadow 不泄漏 plain', () => {
|
||||
const src = readMiniVue();
|
||||
// corner 浮层让位按头部实际高反算(theme 档 164 = 32+104+28)
|
||||
assert.match(src, /headHeight = this\.headTheme \? 164 : 104/, 'corner 让位区分 theme/plain 头部高度');
|
||||
// theme 头部块通铺卡边后圆角只给底部(避免与卡片圆角叠加双圆角)
|
||||
assert.match(src, /\.mini-headtheme \{[\s\S]*?border-radius: 0 0 16rpx 16rpx/, 'headtheme 圆角仅底部');
|
||||
// text-shadow 只作用于 theme 档头像(scoped 父选择器 .mini-headtheme &), plain 档零变化
|
||||
assert.match(src, /\.mini-headtheme & \{[\s\S]*?text-shadow/, 'text-shadow 限定 theme 档');
|
||||
});
|
||||
|
||||
test('self.vue: miniConfig 透传 8 个新字段 + 7 个字号(2.17 修复)', () => {
|
||||
// 注: diy-card-show.vue 自 2026-10-21 起改为自绘, 不再经 miniConfig 复用 diy-card-mini 通用布局,
|
||||
// 配置由 computed 直读 this.c 消费, 不持有 v 数据对象; 故仅对仍走 v 对象架构的 self.vue 锁死透传。
|
||||
const sizes = ['nameSize', 'cardTitleSize', 'subSize', 'labelSize', 'valueSize', 'actSize', 'btnSize'];
|
||||
const v2 = Object.keys(V2_FIELDS);
|
||||
const file = 'diy-card-self.vue';
|
||||
const src = readFileSync(resolve(FE, file), 'utf8');
|
||||
for (const f of v2) {
|
||||
assert.match(src, new RegExp(f + ':\\s*v\\.' + f + ','), file + ' 透传 ' + f);
|
||||
}
|
||||
// 2.18: 6 个新字段透传
|
||||
for (const f of Object.keys(V18_FIELDS).concat(['actIcons'])) {
|
||||
assert.match(src, new RegExp(f + ':\\s*v\\.' + f + ','), file + ' 透传 ' + f);
|
||||
}
|
||||
// 2.17: 7 个字号字段透传(可配置字号)
|
||||
for (const f of sizes) {
|
||||
assert.match(src, new RegExp(f + ':\\s*v\\.' + f + ','), file + ' 补齐字号透传 ' + f);
|
||||
}
|
||||
});
|
||||
|
||||
test('2.18: 操作区位置/独立样式/每按钮图标/自定义二维码 全链路', () => {
|
||||
// 基件消费
|
||||
const src = readMiniVue();
|
||||
assert.match(src, /actsAbove\(\)\s*\{[\s\S]*?normalizeActsPosition\(this\.c\.actsPosition\)\s*===\s*'above'/, 'actsAbove 消费 normalizeActsPosition');
|
||||
assert.match(src, /v-if="actsAbove && showActs && acts\.length && actsGrid"/, '上方档宫格分支渲染');
|
||||
assert.match(src, /v-if="!actsAbove && showActs && acts\.length && actsGrid"/, '下方档宫格分支互斥守卫');
|
||||
assert.match(src, /rowsClearStyle\(\)\s*\{[\s\S]*?return this\.actsAbove \? \{\} : this\.cornerClearStyle/, '上方档时资料行不再重复让位');
|
||||
// 2026-10-24: uni-app 运行时静默丢弃 :style="[字符串, 对象]" 数组绑定(2026-10-22 qrBlockStyle 线上问题同根因,
|
||||
// H5 实测 actsBlockBg/Radius/Pad 全失效、style 属性为 null) → 合并为 actsBlockFinalStyle 单绑定, 数组形态禁止回归
|
||||
assert.match(src, /:style="actsBlockFinalStyle"/, '操作区块走 actsBlockFinalStyle 单绑定');
|
||||
assert.doesNotMatch(src, /:style="\[actsBlockStyle/, '操作区块禁数组绑定(uni-app 静默丢弃)');
|
||||
assert.match(src, /actsBlockFinalStyle\(\)\s*\{[\s\S]*?cornerClearStyle/, 'actsBlockFinalStyle 合并 corner 让位');
|
||||
assert.match(src, /actsBlockStyle\(\)\s*\{[\s\S]*?actsBlockBg/, '独立块样式消费 actsBlockBg/Radius/Pad');
|
||||
// 每按钮图标: img 优先于字形/CSS 图标; 前景/底色覆盖
|
||||
assert.match(src, /actImgOf\(act\)\s*\{[\s\S]*?actIconCfgOf\(this\.c\.actIcons,\s*act && act\.type\)/, 'actImgOf 消费 actIconCfgOf');
|
||||
assert.match(src, /<image v-if="actImgOf\(act\)" class="act-grid-img"/, '自定义图标图片渲染分支');
|
||||
assert.match(src, /actGridIconStyle\(act\)\s*\{[\s\S]*?cfgBg[\s\S]*?if \(cfgBg\)/, '底色每按钮覆盖优先');
|
||||
assert.match(src, /actIconColor\(act\)[\s\S]*?if \(custom\) return custom/, '前景色每按钮覆盖优先');
|
||||
// 自定义二维码
|
||||
assert.match(src, /qrSrc\(\)\s*\{[\s\S]*?String\(this\.c\.qrImage \|\| ''\)\.trim\(\)/, 'qrSrc 自定义图优先');
|
||||
assert.match(src, /flagOn\(this\.c\.showQr,\s*false\) && !!this\.qrSrc/, 'showQrBase 消费 qrSrc');
|
||||
assert.equal((src.match(/\$util\.img\(qrSrc\)/g) || []).length, 5, '五个二维码挂点全部消费 qrSrc');
|
||||
// 展示挂点(qr-img 类)禁旧直连; 分享抽屉(ss-qr)保留名片原码 —— 分享出去必须能扫码打开名片(2026-10-24 口径)
|
||||
assert.doesNotMatch(src, /class="qr-img"[^>]*\$util\.img\(card\.qr_url\)/, '展示挂点无旧直连 card.qr_url');
|
||||
assert.match(src, /ss-qr[\s\S]{0,120}\$util\.img\(card\.qr_url\)/, '分享抽屉保留名片原码(扫码语义)');
|
||||
// SCSS: 自定义图标图片铺满图标块
|
||||
assert.match(src, /\.act-grid-img\s*\{[\s\S]*?width: 100%/, 'act-grid-img 铺满图标块');
|
||||
|
||||
// design.html ×2: 面板控件 + 预览消费
|
||||
for (const comp of ['show', 'self']) {
|
||||
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
|
||||
assert.match(html, /@click="nc\.actsPosition = 'above'"/, comp + ' 操作区位置 chips(above)');
|
||||
assert.match(html, /@click="nc\.actsPosition = 'below'"/, comp + ' 操作区位置 chips(below)');
|
||||
assert.match(html, /@click="nc\.actsBlockBg = '#F7F8FA'; nc\.actsBlockPad = 12; nc\.actsBlockRadius = 12"/, comp + ' 独立成块预设联动');
|
||||
assert.match(html, /field: 'actsBlockBg', label: '操作区底色'/, comp + ' 操作区底色 color');
|
||||
assert.match(html, /field: 'actsBlockRadius', label: '操作区圆角'/, comp + ' 操作区圆角 slide');
|
||||
assert.match(html, /field: 'actsBlockPad', label: '操作区内边距'/, comp + ' 操作区内边距 slide');
|
||||
// 每按钮图标自定义(2.19 起并入「快捷操作管理」图层面板; 媒体库走类名委托)
|
||||
assert.match(html, /快捷操作管理/, comp + ' 快捷操作管理图层面板');
|
||||
assert.doesNotMatch(html, /js-act-media/, comp + ' 媒体库选图入口已移除(2.19.1 图标唯一来源=素材图标库)');
|
||||
assert.match(html, /js-act-color" data-scope="default" :data-key="a\.k" data-field="color"/, comp + ' 每按钮前景色输入(2.19 起走 js-act-color 委托, $set 由委托 JS 兼容存量行)');
|
||||
assert.match(html, /js-act-color" data-scope="default" :data-key="a\.k" data-field="bg"/, comp + ' 每按钮底色输入(同上)');
|
||||
// 自定义二维码(媒体库)
|
||||
assert.match(html, /v-model="nc\.qrImage"/, comp + ' 二维码自定义图输入');
|
||||
assert.match(html, /js-qr-media" title="从媒体库选择二维码图片"/, comp + ' 二维码选图按钮走 js-qr-media 委托(2.19.3, 内联 openAlbum 不可靠)');
|
||||
assert.doesNotMatch(html, /@click="openAlbum\(/, comp + ' 零内联 openAlbum 调用');
|
||||
// 预览消费
|
||||
assert.match(html, /nc\.actsPosition === 'above'/, comp + ' 预览上方档分支');
|
||||
assert.match(html, /nc\.actsPosition !== 'above'/, comp + ' 预览下方档互斥守卫');
|
||||
assert.match(html, /diy-card-mini__act-img/, comp + ' 预览自定义图标分支');
|
||||
assert.match(html, /diy-card-qr__img--custom/, comp + ' 预览自定义二维码分支');
|
||||
}
|
||||
|
||||
// 平台: refreshComponent 对象默认值深拷贝(防同名组件共享嵌套对象串改)
|
||||
const ct = readFileSync(resolve(BE, '../../public/static/ext/diyview/js/custom_template.js'), 'utf8');
|
||||
assert.match(ct, /JSON\.parse\(JSON\.stringify\(defaults\[key\]\)\)/, 'refreshComponent 对象默认值深拷贝');
|
||||
// 反例: entry/list 不注册 2.18 字段
|
||||
const php = readFileSync(resolve(BE, 'config/diy_view.php'), 'utf8');
|
||||
const entryM = php.match(/'name'\s*=>\s*'DiyCardEntry'[\s\S]*?'value'\s*=>\s*'([^\']+)'/);
|
||||
const entry = JSON.parse(entryM[1]);
|
||||
assert.equal(entry.actsPosition, undefined, 'entry 不注册 actsPosition');
|
||||
assert.equal(entry.qrImage, undefined, 'entry 不注册 qrImage');
|
||||
});
|
||||
|
||||
test('design.html ×2: 面板控件与预览消费齐备 + 互证 diy_view 默认值', () => {
|
||||
for (const comp of ['show', 'self']) {
|
||||
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
|
||||
// 面板: 双形态 chips + 开关 + 文案输入
|
||||
assert.match(html, /@click="nc\.headStyle = 'plain'"/, comp + ' 头部形态 chips(plain)');
|
||||
assert.match(html, /@click="nc\.headStyle = 'theme'"/, comp + ' 头部形态 chips(theme)');
|
||||
assert.match(html, /@click="nc\.actsLayout = 'grid'"/, comp + ' 操作布局 chips(grid)');
|
||||
assert.match(html, /@click="nc\.actGridColorMode = 'theme'"/, comp + ' 宫格配色 chips(theme)');
|
||||
assert.match(html, /v-model="nc\.showRowsTitle"/, comp + ' 资料区标题开关');
|
||||
assert.match(html, /v-model="nc\.rowsTitle"/, comp + ' 标题文案输入');
|
||||
assert.match(html, /v-model="nc\.showCopyBtn"/, comp + ' 复制按钮开关');
|
||||
// 预览消费
|
||||
assert.match(html, /diy-card-mini__head--theme/, comp + ' 预览 theme 头部分支');
|
||||
assert.match(html, /diy-card-mini__act-grid/, comp + ' 预览宫格分支');
|
||||
assert.match(html, /diy-card-mini__rows-title/, comp + ' 预览资料标题');
|
||||
assert.match(html, /diy-card-mini__row-copy/, comp + ' 预览复制按钮');
|
||||
// 样式页: theme 档头部 color 控件 + 恢复默认按钮覆盖头部色
|
||||
assert.match(html, /field: 'headNameColor', label: '头部姓名色', defaultColor: '#FFFFFF'/, comp + ' 头部姓名色 color');
|
||||
assert.match(html, /field: 'headSubColor', label: '头部副行色', defaultColor: '#FFFFFF'/, comp + ' 头部副行色 color');
|
||||
assert.match(html, /nc\.headNameColor='#FFFFFF'; nc\.headSubColor='#FFFFFF'/, comp + ' 恢复默认覆盖头部色');
|
||||
}
|
||||
});
|
||||
|
||||
test('反例自测: 新形态标记不得出现在未改造组件中', () => {
|
||||
// entry/list 未接 2.17 双形态(它们没有 mini 基件), diy_view 默认值也不应出现
|
||||
const php = readFileSync(resolve(BE, 'config/diy_view.php'), 'utf8');
|
||||
const entryM = php.match(/'name'\s*=>\s*'DiyCardEntry'[\s\S]*?'value'\s*=>\s*'([^\']+)'/);
|
||||
const entry = JSON.parse(entryM[1]);
|
||||
assert.equal(entry.headStyle, undefined, 'entry 不注册 headStyle(基件专属)');
|
||||
assert.equal(entry.actsLayout, undefined, 'entry 不注册 actsLayout(基件专属)');
|
||||
// common.js 禁 calc 铁律: 新增代码不得引入 calc()
|
||||
const common = readCommonJs();
|
||||
assert.doesNotMatch(common, /calc\(/, 'common.js 无 calc(WXSS 不支持)');
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* 2.19 快捷操作: 显示控制 + 自定义操作 + 素材图标库 + 图层式管理
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const V19_FIELDS = { actVis: 'object', actDeleted: 'object', actLabels: 'object', customActs: 'array', actOrder: 'array' };
|
||||
const V19_ACT_LABELS = { tel: '打电话', wx: '微信号', mail: '发邮件', web: '网站', save: '存通讯录' };
|
||||
|
||||
/** 2.19.10 detail 版式对齐字段 → 默认值(零变化) */
|
||||
const V21910_FIELDS = {
|
||||
showRowLabels: 0,
|
||||
copyBtnScope: 'all',
|
||||
copyBtnLook: 'theme',
|
||||
qrTipEm: '',
|
||||
qrTipSub: '',
|
||||
qrUrlShow: 0,
|
||||
badgeShow: 0,
|
||||
badgeText: '数字名片',
|
||||
rowsBlockBg: '',
|
||||
rowsBlockRadius: 0,
|
||||
rowsBlockPad: 0,
|
||||
qrBlockBg: '',
|
||||
qrBlockRadius: 0,
|
||||
qrBlockPad: 0,
|
||||
};
|
||||
|
||||
test('diy_view.php: Show/Self value 均含 2.19 字段(默认值=全显/默认文案/空数组/历史顺序=零变化)', () => {
|
||||
for (const comp of ['show', 'self']) {
|
||||
const v = readValueJson(comp);
|
||||
// actVis: 五类型全 1(显示)
|
||||
assert.ok(v.actVis && typeof v.actVis === 'object', comp + '.actVis 已注册');
|
||||
assert.deepEqual(v.actVis, { tel: 1, wx: 1, mail: 1, web: 1, save: 1 }, comp + '.actVis 默认全显');
|
||||
// actLabels: 五类型默认文案
|
||||
assert.ok(v.actLabels && typeof v.actLabels === 'object', comp + '.actLabels 已注册');
|
||||
assert.deepEqual(v.actLabels, V19_ACT_LABELS, comp + '.actLabels 默认文案');
|
||||
// customActs: 空数组
|
||||
assert.deepEqual(v.customActs, [], comp + '.customActs 默认空数组');
|
||||
// actOrder(2.19.1): 历史渲染顺序
|
||||
assert.deepEqual(v.actOrder, ['tel', 'wx', 'mail', 'web', 'save'], comp + '.actOrder 默认=历史顺序');
|
||||
// actDeleted(2.19.2): 全 0=无删除(历史零变化)
|
||||
assert.deepEqual(v.actDeleted, { tel: 0, wx: 0, mail: 0, web: 0, save: 0 }, comp + '.actDeleted 默认全 0');
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* 2.19.10 detail 版式对齐: 三列行/复制范围外观/qrTip 富文本/URL 行/hero 徽章/分卡
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
test('diy_view.php: Show/Self value 均含 2.19.10 字段(默认值=历史形态零变化)', () => {
|
||||
for (const comp of ['show', 'self']) {
|
||||
const v = readValueJson(comp);
|
||||
for (const [field, dft] of Object.entries(V21910_FIELDS)) {
|
||||
assert.ok(field in v, comp + '.' + field + ' 已注册');
|
||||
assert.deepEqual(v[field], dft, comp + '.' + field + ' 默认=' + JSON.stringify(dft));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('show/self.vue: miniConfig 均透传 9 个 2.19.10 字段', () => {
|
||||
for (const file of ['diy-card-show.vue', 'diy-card-self.vue']) {
|
||||
const src = readFileSync(resolve(FE, file), 'utf8');
|
||||
for (const f of Object.keys(V21910_FIELDS)) {
|
||||
assert.match(src, new RegExp(f + ':\\s*v\\.' + f + ','), file + ' 透传 ' + f);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('mini.vue: 消费 2.19.10 字段(computed/模板/样式)', () => {
|
||||
const src = readFileSync(resolve(FE, 'diy-card-mini.vue'), 'utf8');
|
||||
// computed: 新字段 getter
|
||||
for (const f of ['showRowLabels', 'badgeShow', 'badgeText', 'qrTipEm', 'qrTipSub', 'qrUrlShow', 'qrUrlText', 'copyBtnScope', 'copyBtnLook', 'rowsBlockStyle', 'qrBlockCustomStyle']) {
|
||||
assert.match(src, new RegExp(f + '\\(\\)\\s*\\{'), 'mini.vue computed ' + f);
|
||||
}
|
||||
// 模板消费
|
||||
assert.match(src, /class="mini-hero-badge"/, 'hero 徽章渲染');
|
||||
assert.match(src, /row-3col.*showRowLabels|showRowLabels.*row-3col/, '三列行 class 绑定');
|
||||
assert.match(src, /copyBtnVisible\(row\)/, '复制按钮范围方法');
|
||||
assert.match(src, /qrTipEm/, 'qrTip 加粗段');
|
||||
assert.match(src, /qrTipSub/, 'qrTip 第二行');
|
||||
assert.match(src, /qrUrlShow/, 'URL 行开关');
|
||||
// 独立块样式消费(与操作区同款: Bg/Radius/Pad)
|
||||
assert.match(src, /rowsBlockBg/, 'rowsBlockStyle 消费 rowsBlockBg');
|
||||
assert.match(src, /qrBlockBg/, 'qrBlockCustomStyle 消费 qrBlockBg');
|
||||
assert.match(src, /rowsFinalStyle\(\)\s*\{[\s\S]*?rowsBlockStyle/, 'rowsFinalStyle 合并 rowsBlockStyle');
|
||||
assert.match(src, /qrBlockFinalStyle\(\)\s*\{[\s\S]*?qrBlockCustomStyle/, 'qrBlockFinalStyle 合并 qrBlockCustomStyle');
|
||||
// 样式
|
||||
assert.match(src, /\.mini-hero-badge/, 'hero 徽章 CSS');
|
||||
assert.match(src, /\.qr-tip-em/, 'qrTip 加粗 CSS');
|
||||
assert.match(src, /\.qr-tip-sub/, 'qrTip 第二行 CSS');
|
||||
assert.match(src, /\.qr-url/, 'URL 行 CSS');
|
||||
assert.match(src, /row-3col/, '三列行 CSS');
|
||||
// 反例: 禁数组 style 绑定(uni-app 静默丢弃)——先剥注释防误判
|
||||
const stripped = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
assert.doesNotMatch(stripped, /:style="\[/, '无数组 style 绑定');
|
||||
// 反例: layoutCards 已移除
|
||||
assert.doesNotMatch(stripped, /layoutCards/, 'layoutCards 已移除');
|
||||
// 徽章样式避让企业徽标 badgeStyle
|
||||
assert.match(src, /heroBadgeStyle\(\)/, 'heroBadgeStyle 避让 badgeStyle');
|
||||
});
|
||||
|
||||
test('素材图标库三源同步: PHP CardIconLibrary ↔ 面板 card-icon-lib.js ↔ C端 common.js', () => {
|
||||
// PHP 源: 解析 CardIconLibrary::ICONS(key => svg)
|
||||
const php = readFileSync(resolve(BE, 'service/CardIconLibrary.php'), 'utf8');
|
||||
const libRe = /'(\w+)'\s*=>\s*\[\s*'label'\s*=>\s*'[^']*',\s*'category'\s*=>\s*'[^']*',\s*'svg'\s*=>\s*'(<svg [^']*)',\s*\]/g;
|
||||
const phpLib = {};
|
||||
let m;
|
||||
while ((m = libRe.exec(php)) !== null) {
|
||||
phpLib[m[1]] = m[2];
|
||||
}
|
||||
assert.ok(Object.keys(phpLib).length >= 25, 'PHP 素材库图标数 >= 25, 实际 ' + Object.keys(phpLib).length);
|
||||
assert.ok(phpLib.phone && phpLib.phone.indexOf('currentColor') > 0, 'phone 图标带 currentColor');
|
||||
|
||||
const extractJson = (src, anchor) => {
|
||||
const i = src.indexOf(anchor);
|
||||
assert.ok(i >= 0, '找到锚点 ' + anchor);
|
||||
const j = src.indexOf('{', i);
|
||||
// 前向扫描配对花括号(JSON 内无裸花括号字面量, 安全)
|
||||
let depth = 0, k = j;
|
||||
for (; k < src.length; k++) {
|
||||
if (src[k] === '{') depth++;
|
||||
else if (src[k] === '}') { depth--; if (depth === 0) break; }
|
||||
}
|
||||
return JSON.parse(src.slice(j, k + 1));
|
||||
};
|
||||
|
||||
// 面板 resource JS
|
||||
const panelJs = readFileSync(resolve(BE, 'component/view/public/js/card-icon-lib.js'), 'utf8');
|
||||
const panelLib = extractJson(panelJs, 'window.CARD_ICON_LIB =');
|
||||
assert.deepEqual(panelLib, phpLib, '面板图标库与 PHP 同源');
|
||||
assert.match(panelJs, /function cardLibIconSvg\(key\)/, '面板提供 cardLibIconSvg');
|
||||
assert.match(panelJs, /function cardLibIconBg\(key, color\)/, '面板提供 cardLibIconBg');
|
||||
assert.match(panelJs, /split\('currentColor'\)\.join\(color/, '面板上色走 currentColor 替换');
|
||||
|
||||
// C端 common.js
|
||||
const common = readCommonJs();
|
||||
const feLib = extractJson(common, 'export const CARD_ICON_LIB =');
|
||||
assert.deepEqual(feLib, phpLib, 'C端图标库与 PHP 同源');
|
||||
assert.match(common, /export function libIconSvg\(key\)/, 'C端提供 libIconSvg');
|
||||
assert.match(common, /export function libIconBg\(key, color\)/, 'C端提供 libIconBg');
|
||||
});
|
||||
|
||||
test('common.js: normalizeActVis / normalizeActLabels / normalizeCustomActs / normalizeActOrder(2.19+2.19.1)', () => {
|
||||
const js = readCommonJs();
|
||||
const consts = [
|
||||
js.match(/export const ACT_TYPES = [^;]+;/)[0].replace('export const', 'const'),
|
||||
js.match(/export const ACT_ACTION_TYPES = [^;]+;/)[0].replace('export const', 'const'),
|
||||
js.match(/export const ACT_LABEL_DEFAULTS = \{[\s\S]*?\n\};/)[0].replace('export const', 'const'),
|
||||
js.match(/export const ACT_ORDER_DEFAULT = [^;]+;/)[0].replace('export const', 'const'),
|
||||
].join('\n');
|
||||
const grab = (name) => js.match(new RegExp('export function ' + name + '[\\s\\S]*?\\n}'))[0].replace('export function ', 'function ');
|
||||
const body = consts + '\n' + ['normalizeActVis', 'normalizeActDeleted', 'normalizeActLabels', 'normalizeCustomActs', 'normalizeActOrder'].map(grab).join('\n') + '\n'
|
||||
+ 'return [normalizeActVis, normalizeActDeleted, normalizeActLabels, normalizeCustomActs, normalizeActOrder];';
|
||||
const [visFn, delFn, labelFn, customFn, orderFn] = new Function(body)();
|
||||
|
||||
// actVis: 缺省全显(历史零变化); '0' → 0; 未知键忽略
|
||||
assert.deepEqual(visFn(undefined), { tel: 1, wx: 1, mail: 1, web: 1, save: 1 }, 'actVis 缺省全显');
|
||||
assert.deepEqual(visFn({ tel: '0', wx: 0 }), { tel: 0, wx: 0, mail: 1, web: 1, save: 1 }, 'actVis 0=隐藏/缺键=显');
|
||||
// actDeleted(2.19.2): 缺省全 0(历史零变化); '1' → 1
|
||||
assert.deepEqual(delFn(undefined), { tel: 0, wx: 0, mail: 0, web: 0, save: 0 }, 'actDeleted 缺省全 0');
|
||||
assert.deepEqual(delFn({ tel: '1', save: 1 }), { tel: 1, wx: 0, mail: 0, web: 0, save: 1 }, 'actDeleted 1=已删/缺键=0');
|
||||
// actLabels: 缺省=内置文案; 空串回落默认
|
||||
assert.deepEqual(labelFn(undefined), V19_ACT_LABELS, 'actLabels 缺省=内置默认');
|
||||
assert.deepEqual(labelFn({ tel: '', wx: '加微信' }).wx, '加微信', 'actLabels 改名生效');
|
||||
assert.equal(labelFn({ tel: '' }).tel, '打电话', 'actLabels 空串回落默认');
|
||||
// customActs: 白名单过滤 + 字段收敛 + custom 标记
|
||||
assert.deepEqual(customFn(undefined), [], 'customActs 缺省空');
|
||||
const list = customFn([
|
||||
{ label: '加群', type: 'url', value: 'https://x.com', vis: 1 },
|
||||
{ type: 'tel', value: '13900000000' },
|
||||
'junk',
|
||||
]);
|
||||
assert.equal(list.length, 2, '非对象项剔除');
|
||||
assert.equal(list[0].type, 'copy', '非法 type 回落 copy');
|
||||
assert.equal(list[0].custom, 1, '带 custom 标记');
|
||||
assert.equal(list[0].label, '加群', 'label 保留');
|
||||
assert.equal(list[1].label, '自定义', 'label 缺省');
|
||||
assert.equal(list[1].type, 'tel', '合法 type 保留');
|
||||
// 2.19.3 扩展类型白名单
|
||||
assert.deepEqual(customFn([{ type: 'sms', value: '139' }, { type: 'map', value: '某某大厦' }, { type: 'link', value: '/pages/index/index' }]).map(x => x.type), ['sms', 'map', 'link'], 'sms/map/link 新类型通过白名单');
|
||||
// actOrder(2.19.1): 缺省=历史顺序; 去重+白名单+缺失尾补
|
||||
assert.deepEqual(orderFn(undefined), ['tel', 'wx', 'mail', 'web', 'save'], 'actOrder 缺省=历史顺序');
|
||||
assert.deepEqual(orderFn(['save', 'tel', 'bogus', 'save', 'mail']), ['save', 'tel', 'mail', 'wx', 'web'], '重排去重+非法剔除+缺失尾补');
|
||||
assert.deepEqual(orderFn('junk'), ['tel', 'wx', 'mail', 'web', 'save'], '非数组=历史顺序');
|
||||
});
|
||||
|
||||
test('common.js: libIconBg 上色 + data URI 可解码回原图(2.19)', () => {
|
||||
const js = readCommonJs();
|
||||
const b64 = js.match(/const B64T = [^;]+;[\s\S]*?function b64ascii\(s\) \{[\s\S]*?\n\}/)[0];
|
||||
const libJson = js.slice(js.indexOf('{', js.indexOf('export const CARD_ICON_LIB =')));
|
||||
// 前向扫描取 JSON
|
||||
let depth = 0, end = 0;
|
||||
for (let i = libJson.indexOf('{'); i < libJson.length; i++) {
|
||||
if (libJson[i] === '{') depth++;
|
||||
else if (libJson[i] === '}') { depth--; if (depth === 0) { end = i + 1; break; } }
|
||||
}
|
||||
const lib = new Function('return ' + libJson.slice(libJson.indexOf('{'), end))();
|
||||
const fn = new Function('CARD_ICON_LIB', b64 + '\n' + js.match(/export function libIconSvg\(key\)[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '\n' + js.match(/export function libIconBg\(key, color\)[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '\nreturn libIconBg;')(lib);
|
||||
const bg = fn('phone', '#FF0000');
|
||||
assert.match(bg, /^url\("data:image\/svg\+xml;base64,/), 'libIconBg 输出 base64 data URI';
|
||||
// 解码回 SVG 验证: currentColor 已替换为指定色
|
||||
const b64str = bg.match(/base64,([^"]+)/)[1];
|
||||
const svg = Buffer.from(b64str, 'base64').toString('ascii');
|
||||
assert.ok(svg.indexOf('#FF0000') > 0, 'SVG 内嵌指定色');
|
||||
assert.equal(svg.indexOf('currentColor'), -1, 'currentColor 已全部替换');
|
||||
assert.equal(fn('not_exist', '#FFF'), '', '缺 key 返回空串');
|
||||
});
|
||||
|
||||
test('mini.vue: acts 合成(显隐过滤 + 次序 + 文案覆盖 + 自定义动作尾接) + 素材库图标分支(2.19+2.19.1)', () => {
|
||||
const src = readMiniVue();
|
||||
// acts computed: 过滤/改名/合并/四 cfg 字段
|
||||
assert.match(src, /acts\(\)\s*\{[\s\S]*?normalizeActVis\(this\.c\.actVis\)/, 'acts 消费 normalizeActVis');
|
||||
assert.match(src, /acts\(\)\s*\{[\s\S]*?normalizeActLabels\(this\.c\.actLabels\)/, 'acts 消费 normalizeActLabels');
|
||||
assert.match(src, /acts\(\)\s*\{[\s\S]*?normalizeCustomActs\(this\.c\.customActs\)/, 'acts 消费 normalizeCustomActs');
|
||||
// 2.19.1 次序: 按 normalizeActOrder 输出默认动作 + order 外扩展类型尾接兜底
|
||||
assert.match(src, /acts\(\)\s*\{[\s\S]*?normalizeActOrder\(this\.c\.actOrder\)/, 'acts 消费 normalizeActOrder');
|
||||
assert.match(src, /acts\(\)\s*\{[\s\S]*?const byType = \{\};[\s\S]*?order\.forEach\(t => \{/, 'acts 按 order 次序取默认动作');
|
||||
assert.match(src, /acts\(\)\s*\{[\s\S]*?if \(order\.indexOf\(t\) >= 0 \|\| vis\[t\] === 0 \|\| del\[t\] === 1\) return;/, 'order 外扩展类型尾接且隐藏/已删不渲染');
|
||||
// 2.19.2 删除: 按 order 取默认动作时 deleted=1 的不渲染
|
||||
assert.match(src, /acts\(\)\s*\{[\s\S]*?normalizeActDeleted\(this\.c\.actDeleted\)/, 'acts 消费 normalizeActDeleted');
|
||||
assert.match(src, /acts\(\)\s*\{[\s\S]*?if \(byType\[t\] && vis\[t\] !== 0 && del\[t\] !== 1\) out\.push\(merge\(byType\[t\]\)\);/, 'order 取默认动作时 vis=0/deleted=1 均过滤');
|
||||
assert.match(src, /labels\[t\] \|\| a\.label/, 'actLabels 覆盖文案(空回落服务端)');
|
||||
assert.match(src, /cfgImg: String\(cfg\.img \|\| ''\)\.trim\(\)/, '默认动作合并 cfgImg');
|
||||
assert.match(src, /cfgKey: String\(cfg\.iconKey \|\| ''\)\.trim\(\)/, '默认动作合并 cfgKey(iconKey)');
|
||||
assert.match(src, /normalizeCustomActs\(this\.c\.customActs\)\.forEach\(ca => \{[\s\S]*?cfgKey: ca\.iconKey/, '自定义动作尾接并合并 cfg 字段');
|
||||
// 图标渲染优先级: img > 素材库(iconKey) > SVG 线稿 > 首字
|
||||
assert.match(src, /actLibOf\(act\)\s*\{[\s\S]*?act\.cfgKey/, 'actLibOf 读合并 cfgKey');
|
||||
assert.match(src, /actLibStyle\(act\)\s*\{[\s\S]*?libIconBg\(this\.actLibOf\(act\), this\.actIconColor\(act\)\)/, 'actLibStyle 消费 libIconBg + 四级色口径');
|
||||
assert.equal((src.match(/v-else-if="actLibOf\(act\)" class="act-grid-svg" :style="actLibStyle\(act\)"/g) || []).length, 2, '上/下两份宫格副本均插入素材库分支');
|
||||
assert.match(src, /actSvgOf\(act\)\s*\{[\s\S]*?!this\.actImgOf\(act\) && !this\.actLibOf\(act\) && !!WAP_LINE_ICONS/, '线稿档被 img/素材库优先占位后不再进入');
|
||||
assert.match(src, /import \{[\s\S]*?normalizeActVis, normalizeActDeleted, normalizeActLabels, normalizeCustomActs, normalizeActOrder, libIconBg/, 'mini.vue 引入 2.19+2.19.1+2.19.2 依赖');
|
||||
// 透传
|
||||
// show.vue 自绘, 不持有 v 对象; actVis/actLabels/actOrder/customActs 透传仅校验 self.vue(走 v 架构)
|
||||
for (const file of ['diy-card-self.vue']) {
|
||||
const vue = readFileSync(resolve(FE, file), 'utf8');
|
||||
for (const f of ['actVis', 'actDeleted', 'actLabels', 'actOrder', 'customActs']) {
|
||||
assert.match(vue, new RegExp(f + ': v\\.' + f + ','), file + ' 透传 ' + f);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('design.html ×2: 图层式管理面板 + 素材图标库 + 预览显隐/文案/自定义动作消费(2.19)', () => {
|
||||
for (const comp of ['show', 'self']) {
|
||||
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
|
||||
// 图层管理器: 眼睛开关 / 图标缩略 / 行内改名 / 素材库+媒体库按钮 / 清除
|
||||
assert.match(html, /diy-card-layer__eye layui-icon" :class="nc\.actVis && nc\.actVis\[a\.k\] === 0 \? 'layui-icon-eye-invisible' : 'layui-icon-eye'/, comp + ' 默认动作眼睛开关');
|
||||
assert.match(html, /nc\.actVis\[a\.k\] = nc\.actVis\[a\.k\] === 0 \? 1 : 0;/, comp + ' 眼睛切换写回 actVis');
|
||||
// 存量组件防御(2026-10-24): defaults($diy_view_utils)缺新字段时 refreshComponent 补不了,
|
||||
// v-model="nc.actLabels[a.k]" 裸读双层直取会在 render 阶段炸 "reading 'tel'" ——
|
||||
// 管理器容器 v-if 守卫 + 改名输入 :value/@input 防御式写回 + @click 禁以 if 开头(keyword 编译警告)
|
||||
assert.match(html, /v-if="nc\.showQuick && nc\.actLabels && nc\.actIcons"/, comp + ' 管理器容器 v-if 守卫(未补全不渲染)');
|
||||
assert.doesNotMatch(html, /v-model="nc\.[a-zA-Z]+\[/, comp + ' 零 v-model 双层裸读(render 阶段防炸)');
|
||||
assert.match(html, /:value="nc\.actLabels \? \(nc\.actLabels\[a\.k\] \|\| ''\) : ''" @input="\$set\(nc, 'actLabels', nc\.actLabels \|\| \{\}\); \$set\(nc\.actLabels, a\.k, \$event\.target\.value\)"/, comp + ' 行内改名防御式写回($set 兼容存量行)');
|
||||
assert.doesNotMatch(html, /@click="if \(/, comp + ' 内联事件不以 if 开头(消 JS keyword 编译警告)');
|
||||
assert.match(html, /v-model="nc\.actLabels\[a\.k\]" maxlength="6"|maxlength="6" title="快捷操作文字"/, comp + ' 改名输入在位');
|
||||
assert.match(html, /js-act-lib" :data-key="a\.k" data-scope="default"/, comp + ' 素材图标按钮(默认动作)');
|
||||
assert.doesNotMatch(html, /js-act-media/, comp + ' 媒体库按钮已移除(2.19.1)');
|
||||
assert.match(html, /\$set\(nc\.actIcons\[a\.k\], 'iconKey', ''\)/, comp + ' 清除图标走 $set(iconKey 兼容存量行)');
|
||||
// 自定义动作行: 类型 chips / 取值 / 排序 / 删除 / 添加
|
||||
assert.match(html, /diy-card-layer--custom" v-for="\(c, ci\) in nc\.customActs"/, comp + ' 自定义动作图层行');
|
||||
assert.match(html, /:class="\{ active: c\.type === t\.v \}"/, comp + ' 自定义动作类型 chips');
|
||||
assert.match(html, /\{v:'copy',lb:'复制'\},\{v:'sms',lb:'短信'\},\{v:'map',lb:'导航'\},\{v:'link',lb:'页面'\}\]/, comp + ' 类型 chips 含 2.19.3 扩展(短信/导航/页面, web 文案改网址)');
|
||||
assert.match(html, /js-act-syslink" data-scope="custom" :data-idx="ci" v-if="c\.type === 'link'"/, comp + ' link 类型出现「系统链接」选择按钮');
|
||||
assert.match(html, /v-model="c\.value" maxlength="200"/, comp + ' 自定义动作取值输入');
|
||||
assert.match(html, /js-act-lib" data-scope="custom" :data-idx="ci"/, comp + ' 素材图标按钮(自定义动作)');
|
||||
assert.match(html, /nc\.customActs\.splice\(ci - 1, 0, nc\.customActs\.splice\(ci, 1\)\[0\]\)/, comp + ' 上移排序');
|
||||
assert.match(html, /nc\.customActs\.splice\(ci \+ 1, 0, nc\.customActs\.splice\(ci, 1\)\[0\]\)/, comp + ' 下移排序');
|
||||
assert.match(html, /nc\.customActs\.splice\(ci, 1\)/, comp + ' 删除自定义动作');
|
||||
// 2.19.1 默认动作次序: 面板行按 actOrder 渲染 + ↑↓ 委托调整
|
||||
assert.equal((html.match(/<template v-for="k in \(nc\.actOrder && nc\.actOrder\.length \? nc\.actOrder : \['tel','wx','mail','web','save'\]\)"><div class="diy-card-layer"/g) || []).length, 1, comp + ' 面板默认行按 actOrder 次序渲染(嵌套 v-for + v-if 匹配)');
|
||||
// 2.19.2 删除: 行过滤 + 删除按钮 + 恢复 chips + 预览过滤
|
||||
assert.match(html, /v-if="a\.k === k && !\(nc\.actDeleted && nc\.actDeleted\[a\.k\] === 1\)"/, comp + ' 面板行过滤已删除动作');
|
||||
assert.match(html, /diy-card-layer__delact" title="删除此默认操作\(可在下方恢复\)" @click="\$set\(nc, 'actDeleted', nc\.actDeleted \|\| \{ tel: 0, wx: 0, mail: 0, web: 0, save: 0 \}\); \$set\(nc\.actDeleted, a\.k, 1\);"/, comp + ' 删除按钮防御式写回 actDeleted');
|
||||
assert.match(html, /diy-card-layer__restore" v-if="nc\.actDeleted && \(nc\.actDeleted\.tel === 1/, comp + ' 恢复 chips 行(有删除项才显示)');
|
||||
assert.match(html, /v-if="nc\.actDeleted\[t\.k\] === 1" :key="t\.k" @click="\$set\(nc\.actDeleted, t\.k, 0\);"/, comp + ' 恢复 chip 点击写回 0');
|
||||
assert.equal((html.match(/v-if="a\.k === k && !\(nc\.actVis && nc\.actVis\[a\.k\] === 0\) && !\(nc\.actDeleted && nc\.actDeleted\[a\.k\] === 1\)"/g) || []).length, 2, comp + ' 预览显隐+删除双重过滤(上/下两副本)');
|
||||
assert.equal((html.match(/js-act-move" title="上移" :data-key="a\.k" data-dir="up"/g) || []).length, 1, comp + ' 默认行上移按钮(委托)');
|
||||
assert.equal((html.match(/js-act-move" title="下移" :data-key="a\.k" data-dir="down"/g) || []).length, 1, comp + ' 默认行下移按钮(委托)');
|
||||
assert.match(html, /nc\.customActs\.push\(\{ label: '自定义', type: 'copy'[\s\S]*?svg: ''[\s\S]*?vis: 1 \}\)/, comp + ' 添加自定义操作(全键补齐含 svg)');
|
||||
assert.match(html, /c\.vis = c\.vis === 0 \? 1 : 0;/, comp + ' 自定义动作眼睛开关');
|
||||
// 预览消费: 次序 + 显隐过滤 + 文案覆盖 + 自定义动作单元格
|
||||
assert.equal((html.match(/<template v-for="k in \(nc\.actOrder && nc\.actOrder\.length \? nc\.actOrder : \['tel','wx','mail','web','save'\]\)"><template v-for="a in \[\{k:'tel',ch:'打',lb:'打电话'/g) || []).length, 2, comp + ' 预览默认 cell 按 actOrder 次序渲染(上/下两副本)');
|
||||
assert.match(html, /\{\{ \(nc\.actLabels && nc\.actLabels\[a\.k\]\) \|\| a\.lb \}\}/, comp + ' 预览文案覆盖');
|
||||
// svg 快照口径: 预览只读纯数据字段(委托 JS 选图/改色时预计算 data URI 写入),
|
||||
// 模板内严禁调用全局函数(模板作用域函数解析不可靠, 实测 typeof 恒 false)
|
||||
assert.equal((html.match(/v-for="\(c, ci\) in nc\.customActs"/g) || []).length, 3, comp + ' 自定义动作 v-for = 面板行 + 预览×2 副本');
|
||||
assert.equal((html.match(/nc\.actIcons\[a\.k\]\.svg" class="diy-card-mini__act-svg"/g) || []).length, 2, comp + ' 预览默认动作素材库图标读 svg 快照(上/下两副本)');
|
||||
assert.equal((html.match(/v-else-if="c\.svg" class="diy-card-mini__act-svg"/g) || []).length, 2, comp + ' 预览自定义动作素材库图标读 svg 快照(上/下两副本)');
|
||||
assert.match(html, /nc\.actIcons\[a\.k\]\.svg" class="diy-card-layer__libico"/, comp + ' 面板行图标缩略读 svg 快照');
|
||||
assert.match(html, /c\.svg" class="diy-card-layer__libico"/, comp + ' 自定义行图标缩略读 svg 快照');
|
||||
assert.doesNotMatch(html, /cardLibIconBg/, comp + ' 模板内零函数调用(全部改读 svg 字段)');
|
||||
// 前景/底色走 class 委托(不再内联 @input 多语句)
|
||||
assert.equal((html.match(/class="diy-card-layer__color js-act-color"/g) || []).length, 4, comp + ' 4 个颜色输入走 js-act-color 委托');
|
||||
assert.match(html, /data-field="color"/, comp + ' 前景色 data-field');
|
||||
assert.match(html, /data-field="bg"/, comp + ' 底色 data-field');
|
||||
assert.match(html, /data-scope="custom" :data-idx="ci" data-field=/, comp + ' 自定义行颜色输入带作用域');
|
||||
// 2.19.7 面板颜色输入占位必须与预览同链(空值=跟随配色档, 显示诚实):
|
||||
// 默认行 color: wx#07C160/save#FF9500 固定 → theme#2F6BFF → a.fg; bg: theme#E6EDFF(rgba 叠白近似) → a.bg
|
||||
const previewColorChain = html.match(/color: \(nc\.actIcons && nc\.actIcons\[a\.k\] && nc\.actIcons\[a\.k\]\.color\) \|\| \(a\.k === 'wx' \? '#07C160'[^"]*\)/);
|
||||
assert.ok(previewColorChain, comp + ' 预览 color 回落链存在');
|
||||
// 2.19.9 起 theme 段可被所选名片设计字段覆盖(previewCard.theme_color 优先, 无则回落 #2F6BFF)
|
||||
assert.match(html, /nc\.actIcons\[a\.k\]\.color \? nc\.actIcons\[a\.k\]\.color : \(a\.k === 'wx' \? '#07C160' : a\.k === 'save' \? '#FF9500' : \(nc\.actGridColorMode === 'theme' \? \(\(nc\.tempData && nc\.tempData\.previewCard && nc\.tempData\.previewCard\.theme_color\) \|\| '#2F6BFF'\) : a\.fg\)\)"/, comp + ' 默认行前景输入占位与预览同链(wx/save 固定色 + theme 段 previewCard 覆盖/preset 档)');
|
||||
assert.match(html, /nc\.actIcons\[a\.k\]\.bg \? nc\.actIcons\[a\.k\]\.bg : \(nc\.actGridColorMode === 'theme' \? '#E6EDFF' : a\.bg\)"/, comp + ' 默认行底色输入占位与预览同链(theme 叠白近似/preset 浅底)');
|
||||
assert.doesNotMatch(html, /actIcons\[a\.k\]\.color \? nc\.actIcons\[a\.k\]\.color : '#2F6BFF'/, comp + ' 默认行前景占位不再恒 #2F6BFF(对多彩档是谎报)');
|
||||
assert.match(html, /:value="c\.color \|\| '#2F6BFF'"/, comp + ' 自定义行前景占位(与自定义预览回落一致)');
|
||||
assert.match(html, /:value="c\.bg \|\| '#E6EDFF'"/, comp + ' 自定义行底色占位(rgba 叠白近似)');
|
||||
// 结构铁律: 自定义动作 cell 必须在默认动作 template v-for 之外(否则每默认动作重复渲染一次);
|
||||
// 2.19.1 起默认 cell 为双层嵌套 template(外层 actOrder 次序), 收尾双 </template>
|
||||
assert.equal((html.match(/<\/template><\/template><div class="diy-card-mini__act-cell" v-for="\(c, ci\) in nc\.customActs"/g) || []).length, 2, comp + ' 自定义动作 cell 在双层 template v-for 之外(上/下两副本)');
|
||||
assert.doesNotMatch(html, /(?<!<\/template>)<\/template><div class="diy-card-mini__act-cell" v-for="\(c, ci\) in nc\.customActs"/, comp + ' 自定义动作 cell 不紧贴单层 template 收尾(嵌套层级正确)');
|
||||
assert.doesNotMatch(html, /<\/div><div class="diy-card-mini__act-cell" v-for="\(c, ci\) in nc\.customActs"/, comp + ' 自定义动作 cell 不再嵌套于 template v-for 内');
|
||||
// template 标签必须配平(未闭合 template 会把后续编辑器内容吞进惰性节点, vue 全局不初始化)
|
||||
assert.equal((html.match(/<template/g) || []).length, (html.match(/<\/template>/g) || []).length, comp + ' template 标签配平');
|
||||
// 资源引用(2026-10-24 起带 ?v={$version} 破生产浏览器/CDN 缓存 —— 同名 CSS/JS
|
||||
// 旧缓存曾致生产编辑器面板图层行与预览宫格样式全丢、竖排裸文字)
|
||||
for (const r of ['css/card-select\\.css', 'css/card-panel\\.css', 'css/card-preview\\.css', 'js/card-select\\.js', 'js/card-icon-lib\\.js', 'js/card-act-icon\\.js']) {
|
||||
assert.match(html, new RegExp('src="\\{\\$resource_path\\}/\\.\\./public/' + r + '\\?v=\\{\\$version\\}"'), comp + ' 资源引用带版本参数: ' + r);
|
||||
}
|
||||
assert.doesNotMatch(html, /public\/(css|js)\/card-[a-z-]+\.(css|js)"/, comp + ' 零无版本资源引用');
|
||||
}
|
||||
});
|
||||
|
||||
test('design.html ×2: 2.19.10 detail 对齐面板控件 + 预览消费齐备', () => {
|
||||
for (const comp of ['show', 'self']) {
|
||||
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
|
||||
// 面板控件: 9 字段 v-model / chips / toggle
|
||||
assert.match(html, /nc\.rowsBlockBg/, comp + ' rowsBlockBg chips');
|
||||
assert.match(html, /nc\.rowsBlockRadius/, comp + ' rowsBlockRadius slide');
|
||||
assert.match(html, /nc\.rowsBlockPad/, comp + ' rowsBlockPad slide');
|
||||
assert.match(html, /nc\.qrBlockBg/, comp + ' qrBlockBg chips');
|
||||
assert.match(html, /nc\.qrBlockRadius/, comp + ' qrBlockRadius slide');
|
||||
assert.match(html, /nc\.qrBlockPad/, comp + ' qrBlockPad slide');
|
||||
assert.doesNotMatch(html, /layoutCards/, comp + ' layoutCards 已移除');
|
||||
assert.match(html, /v-model="nc\.badgeShow"/, comp + ' badgeShow toggle');
|
||||
assert.match(html, /v-model="nc\.badgeText"/, comp + ' badgeText input');
|
||||
assert.match(html, /v-model="nc\.showRowLabels"/, comp + ' showRowLabels toggle');
|
||||
assert.match(html, /nc\.copyBtnScope/, comp + ' copyBtnScope chips');
|
||||
assert.match(html, /nc\.copyBtnLook/, comp + ' copyBtnLook chips');
|
||||
assert.match(html, /v-model="nc\.qrTipEm"/, comp + ' qrTipEm input');
|
||||
assert.match(html, /v-model="nc\.qrTipSub"/, comp + ' qrTipSub input');
|
||||
assert.match(html, /v-model="nc\.qrUrlShow"/, comp + ' qrUrlShow toggle');
|
||||
// 预览消费: 徽章 + 三列行 + 复制按钮 + qrTip 多段 + URL 行
|
||||
assert.match(html, /nc\.badgeShow.*badgeText|badgeText.*nc\.badgeShow/s, comp + ' 预览消费 badge');
|
||||
assert.match(html, /nc\.showRowLabels/, comp + ' 预览消费 showRowLabels');
|
||||
assert.match(html, /nc\.copyBtnLook/, comp + ' 预览消费 copyBtnLook');
|
||||
assert.match(html, /nc\.qrTipEm/, comp + ' 预览消费 qrTipEm');
|
||||
assert.match(html, /nc\.qrTipSub/, comp + ' 预览消费 qrTipSub');
|
||||
assert.match(html, /nc\.qrUrlShow/, comp + ' 预览消费 qrUrlShow');
|
||||
}
|
||||
});
|
||||
|
||||
test('card-act-icon.js: 类名委托修 2.18 选图标 Bug + $set 写回 + 素材库唯一图标来源 + 次序调整(2.19+2.19.1)', () => {
|
||||
const src = readFileSync(resolve(BE, 'component/view/public/js/card-act-icon.js'), 'utf8');
|
||||
assert.match(src, /on\('click', '\.js-act-lib'/, '素材图标库走类名委托');
|
||||
assert.doesNotMatch(src, /js-act-media/, '快捷操作媒体库委托已移除(2.19.1 图标唯一来源=素材图标库; QR 选图的 js-qr-media/openAlbum 为 2.19.3 合法新增)');
|
||||
// 2.19.1 默认动作次序调整
|
||||
assert.match(src, /on\('click', '\.js-act-move'/, '次序调整走类名委托');
|
||||
assert.match(src, /var ACT_ORDER_DEF = \['tel', 'wx', 'mail', 'web', 'save'\];/, 'actOrder 白名单默认序');
|
||||
assert.match(src, /function moveAct\(comp, key, dir\)/, 'moveAct 归一化+相邻交换');
|
||||
assert.match(src, /window\.vue\.\$set\(comp, 'actOrder', cur\)/, 'actOrder 写回走 $set(存量行缺键)');
|
||||
assert.match(src, /closest\('\.draggable-element'\)/, '组件实例定位 draggable-element');
|
||||
assert.match(src, /data\('scope'\) \|\| 'default'\) === 'custom'/, 'custom 作用域写 customActs[idx]');
|
||||
assert.match(src, /window\.vue\.\$set\(item, 'iconKey'/, 'iconKey 写回走 $set(存量行缺键)');
|
||||
assert.match(src, /window\.vue\.\$set\(item, 'img'/, 'img 写回走 $set');
|
||||
// svg 快照链路: 选图/改色时由委托 JS 预计算 data URI 写入 svg 字段(预览只读数据)
|
||||
assert.match(src, /function setIcon\(item, patch\)/, 'setIcon 统一写图标');
|
||||
assert.match(src, /window\.cardLibIconBg\(iconKey, currentColor\(item\)\)/, '选图时预计算 svg 快照');
|
||||
assert.match(src, /function actColorHandler\(\$el, field\)/, '改色处理器');
|
||||
assert.match(src, /on\('input', '\.js-act-color'/, '前景/底色走 input 类名委托');
|
||||
assert.match(src, /window\.cardLibIconBg\(target\.item\.iconKey, currentColor\(target\.item\)\)/, '改色时重算 svg 快照(颜色实时生效)');
|
||||
assert.match(src, /iconKey: '', svg: '', color: ''/, '缺行防御补齐含 svg 键');
|
||||
assert.match(src, /window\.CARD_ICON_LIB \|\| \{\}/, '弹层渲染消费同源图标库');
|
||||
assert.match(src, /cal-item" data-key=/, '弹层图标网格');
|
||||
// 2.19.3 系统链接对话框委托(link 类型)
|
||||
assert.match(src, /on\('click', '\.js-act-syslink'/, '系统链接走类名委托');
|
||||
assert.match(src, /ns\.select_link\(\{\}, function \(data\)/, '调全局 ns.select_link 打开系统链接对话框');
|
||||
assert.match(src, /data\.wap_url \|\| data\.url/, '回执取 wap_url 写入 value');
|
||||
assert.match(src, /window\.vue\.\$set\(target\.item, 'value', url\)/, '系统链接结果 $set 写回 value');
|
||||
// 2.19.3 自定义二维码选图委托(模板内联 openAlbum 不可靠, 2.18 同坑收尾)
|
||||
assert.match(src, /on\('click', '\.js-qr-media'/, '二维码选图走类名委托');
|
||||
assert.match(src, /window\.vue\.\$set\(comp, 'qrImage', obj\[0\]\.pic_path\)/, '媒体库回执 $set 写回 qrImage');
|
||||
// 反例: 面板不再内联调 openAlbum 选快捷操作图标(2.18 Bug 根因规避)
|
||||
for (const comp of ['show', 'self']) {
|
||||
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
|
||||
assert.doesNotMatch(html, /openAlbum\(function \(obj\) \{ if \(obj && obj\.length\) \{ if \(!nc\.actIcons\)/, comp + ' 面板不再内联调 openAlbum 写 actIcons');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('card-preview.css: 默认线稿图标 mask+currentColor —— 前景色实时跟随(2.19.6)', () => {
|
||||
const css = readFileSync(resolve(BE, 'component/view/public/css/card-preview.css'), 'utf8');
|
||||
// 5 个默认线稿类必须走 mask-image(颜色与图分离) + background-color: currentColor
|
||||
for (const k of ['tel', 'wx', 'mail', 'web', 'save']) {
|
||||
const m = css.match(new RegExp('\\.diy-card-mini__act-svg--' + k + '\\s*\\{[^}]*\\}'));
|
||||
assert.ok(m, '线稿类存在: ' + k);
|
||||
const block = m[0];
|
||||
assert.match(block, /mask-image:\s*url\("data:image\/svg\+xml/, k + ' 走 mask-image(非固定色 background-image)');
|
||||
assert.match(block, /-webkit-mask-image:/, k + ' 带 -webkit 前缀');
|
||||
assert.match(block, /background-color:\s*currentColor/, k + ' 背景消费 currentColor(跟随内联 color)');
|
||||
assert.doesNotMatch(block, /background-image:/, k + ' 不再用固定色 background-image(颜色编码在图内改色无效)');
|
||||
}
|
||||
});
|
||||
443
tests/pack-harmony-project.test.js
Normal file
443
tests/pack-harmony-project.test.js
Normal file
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* scripts/pack-harmony-project.js 单元测试
|
||||
*
|
||||
* 跑法:
|
||||
* node --test tests/pack-harmony-project.test.js
|
||||
*
|
||||
* 为什么这个脚本需要测试:
|
||||
* 它**手写了 ZIP 编码格式**(没有用系统命令 / 第三方库), 而这类代码的典型 bug
|
||||
* 都是静默的 —— 包能生成、看着正常, 但某些机器上解不开或解出畸形路径。
|
||||
* 历史上已经踩过两次:
|
||||
* 1. 漏算文件数据长度, 中央目录偏移偏小 -> "N extra bytes at beginning"
|
||||
* 2. localOffset 记成了数据区起点而非本地头起点 -> "bad zipfile offset"
|
||||
* 两者都不会抛异常, 只有真正解压/解析才会暴露。
|
||||
*
|
||||
* 所以测试分两类:
|
||||
* - 正向: 写出来的 zip 结构合法、内容可还原
|
||||
* - **反向**: 故意损坏 zip, 断言 verifyZipFile 真的能抓到
|
||||
* (否则自检可能形同虚设, 永远返回 true)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const fsp = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const zlib = require('node:zlib');
|
||||
|
||||
const {
|
||||
crc32,
|
||||
toDosDateTime,
|
||||
zipDirectory,
|
||||
verifyZipFile,
|
||||
shouldSkipDir,
|
||||
shouldSkipFile,
|
||||
EXCLUDE_DIRS,
|
||||
EXCLUDE_FILES,
|
||||
} = require('../scripts/pack-harmony-project');
|
||||
|
||||
/** 建临时目录, 测试结束自动清理 */
|
||||
function tmpDir(t) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-harmony-test-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** 建一个夹具目录, 内含各种体积/编码的文件 */
|
||||
function makeFixture(root) {
|
||||
fs.mkdirSync(path.join(root, 'AppScope'), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, 'a/b/c'), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, 'empty-dir'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'AppScope/app.json5'), '{"app":{"bundleName":"x"}}');
|
||||
fs.writeFileSync(path.join(root, 'a/b/c/deep.txt'), 'deep content');
|
||||
// 可压缩内容 (应走 deflate)
|
||||
fs.writeFileSync(path.join(root, 'a/repeatable.txt'), 'abc'.repeat(2000));
|
||||
// 不可压缩内容 (应退回 stored)
|
||||
fs.writeFileSync(path.join(root, 'a/random.bin'), require('node:crypto').randomBytes(4096));
|
||||
// 中文文件名
|
||||
fs.writeFileSync(path.join(root, 'a/中文名.txt'), '中文内容');
|
||||
// 零字节文件
|
||||
fs.writeFileSync(path.join(root, 'a/zero.txt'), '');
|
||||
return root;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* 独立 zip 读取器 (刻意不复用写入端实现, 避免自证其说)
|
||||
* 只实现测试需要的最小功能: 按条目名取回内容
|
||||
* ------------------------------------------------------------------------- */
|
||||
function readZipEntries(zipPath) {
|
||||
const buf = fs.readFileSync(zipPath);
|
||||
let eocd = -1;
|
||||
for (let i = buf.length - 22; i >= 0; i--) {
|
||||
if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||
}
|
||||
assert.ok(eocd !== -1, '应能找到 EOCD');
|
||||
|
||||
const count = buf.readUInt16LE(eocd + 10);
|
||||
let p = buf.readUInt32LE(eocd + 16);
|
||||
const out = new Map();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
assert.equal(buf.readUInt32LE(p), 0x02014b50, `第 ${i} 条中央目录签名应正确`);
|
||||
const method = buf.readUInt16LE(p + 10);
|
||||
const crc = buf.readUInt32LE(p + 16);
|
||||
const compSize = buf.readUInt32LE(p + 20);
|
||||
const uncompSize = buf.readUInt32LE(p + 24);
|
||||
const nameLen = buf.readUInt16LE(p + 28);
|
||||
const extraLen = buf.readUInt16LE(p + 30);
|
||||
const commentLen = buf.readUInt16LE(p + 32);
|
||||
const localOffset = buf.readUInt32LE(p + 42);
|
||||
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
|
||||
|
||||
if (!name.endsWith('/')) {
|
||||
const lNameLen = buf.readUInt16LE(localOffset + 26);
|
||||
const lExtraLen = buf.readUInt16LE(localOffset + 28);
|
||||
const start = localOffset + 30 + lNameLen + lExtraLen;
|
||||
const raw = buf.subarray(start, start + compSize);
|
||||
const data = method === 0 ? raw : zlib.inflateRawSync(raw);
|
||||
assert.equal(data.length, uncompSize, `${name} 解压长度应匹配`);
|
||||
assert.equal(crc32(data), crc, `${name} CRC 应匹配`);
|
||||
out.set(name, data);
|
||||
} else {
|
||||
out.set(name, null); // 目录条目
|
||||
}
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ============================ 纯函数 ============================ */
|
||||
|
||||
test('crc32: 标准校验向量', () => {
|
||||
// CRC-32/ISO-HDLC 的标准 check value
|
||||
assert.equal(crc32(Buffer.from('123456789')), 0xcbf43926);
|
||||
assert.equal(crc32(Buffer.from('')), 0);
|
||||
assert.equal(crc32(Buffer.from('a')), 0xe8b7be43);
|
||||
});
|
||||
|
||||
test('toDosDateTime: 字段不溢出且能反解', () => {
|
||||
const d = new Date(2026, 9, 17, 14, 30, 20);
|
||||
const { time, date } = toDosDateTime(d);
|
||||
assert.equal((date >> 9) & 0x7f, 2026 - 1980, '年份');
|
||||
assert.equal((date >> 5) & 0xf, 10, '月份');
|
||||
assert.equal(date & 0x1f, 17, '日');
|
||||
assert.equal((time >> 11) & 0x1f, 14, '时');
|
||||
assert.equal((time >> 5) & 0x3f, 30, '分');
|
||||
});
|
||||
|
||||
/* ======================= 排除规则 (回归锁定) ======================= */
|
||||
|
||||
test('shouldSkipDir: node_modules/oh_modules/build 等一律排除', () => {
|
||||
const opts = { withNodeModules: false, withCerts: true };
|
||||
for (const d of ['node_modules', 'oh_modules', 'build', '.preview', '.idea', '.hvigor', '.cxx', '.test', '.git']) {
|
||||
assert.equal(shouldSkipDir(d, opts, false), true, `${d} 应被排除`);
|
||||
}
|
||||
});
|
||||
|
||||
test('shouldSkipDir: --with-node-modules 只放开根级 node_modules', () => {
|
||||
const opts = { withNodeModules: true, withCerts: true };
|
||||
assert.equal(shouldSkipDir('node_modules', opts, true), false, '根级 node_modules 应保留');
|
||||
assert.equal(shouldSkipDir('node_modules', opts, false), true, '非根级 node_modules 仍应排除');
|
||||
assert.equal(shouldSkipDir('oh_modules', opts, true), true, 'oh_modules 始终排除');
|
||||
});
|
||||
|
||||
test('shouldSkipDir: cert-profile 受 --no-certs 控制', () => {
|
||||
assert.equal(shouldSkipDir('cert-profile', { withCerts: true }, false), false);
|
||||
assert.equal(shouldSkipDir('cert-profile', { withCerts: false }, false), true);
|
||||
});
|
||||
|
||||
test('shouldSkipFile: local.properties / 编辑器临时文件排除', () => {
|
||||
for (const f of ['local.properties', '.DS_Store', 'Thumbs.db', '_1.clang-format', '_1.gitignore', 'build.log']) {
|
||||
assert.equal(shouldSkipFile(f), true, `${f} 应被排除`);
|
||||
}
|
||||
// 回归锁定: 这些是**源码**, 不能误伤
|
||||
for (const f of ['module.json5', 'app.json', 'Ability.test.ets', 'List.test.js', 'oh-package.json5']) {
|
||||
assert.equal(shouldSkipFile(f), false, `${f} 不应被排除`);
|
||||
}
|
||||
});
|
||||
|
||||
/* ========================= zip 写入正确性 ========================= */
|
||||
|
||||
test('zipDirectory: 往返后内容与目录结构完全一致', async (t) => {
|
||||
const root = tmpDir(t);
|
||||
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||
const zipPath = path.join(root, 'out.zip');
|
||||
|
||||
const res = await zipDirectory(fixture, zipPath);
|
||||
assert.equal(res.files, 6, '夹具应有 6 个文件 (app.json5/deep.txt/repeatable.txt/random.bin/中文名.txt/zero.txt)');
|
||||
|
||||
const entries = readZipEntries(zipPath);
|
||||
const prefix = 'mp-harmony/';
|
||||
const get = (rel) => entries.get(prefix + rel);
|
||||
|
||||
assert.equal(get('AppScope/app.json5').toString(), '{"app":{"bundleName":"x"}}');
|
||||
assert.equal(get('a/b/c/deep.txt').toString(), 'deep content');
|
||||
assert.equal(get('a/repeatable.txt').toString(), 'abc'.repeat(2000));
|
||||
assert.equal(get('a/中文名.txt').toString(), '中文内容');
|
||||
assert.equal(get('a/zero.txt').length, 0, '零字节文件应存在且为空');
|
||||
assert.ok(get('a/random.bin').equals(fs.readFileSync(path.join(fixture, 'a/random.bin'))));
|
||||
|
||||
// 目录条目: 空目录也要保留
|
||||
assert.ok(entries.has(prefix + 'empty-dir/'), '空目录条目应保留');
|
||||
assert.ok(entries.has(prefix + 'a/b/c/'), '嵌套目录条目应保留');
|
||||
});
|
||||
|
||||
test('zipDirectory: 条目名全部用正斜杠且置 UTF-8 标志位', async (t) => {
|
||||
const root = tmpDir(t);
|
||||
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||
const zipPath = path.join(root, 'out.zip');
|
||||
await zipDirectory(fixture, zipPath);
|
||||
|
||||
const buf = fs.readFileSync(zipPath);
|
||||
let eocd = -1;
|
||||
for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||
const count = buf.readUInt16LE(eocd + 10);
|
||||
let p = buf.readUInt32LE(eocd + 16);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const flags = buf.readUInt16LE(p + 8);
|
||||
const nameLen = buf.readUInt16LE(p + 28);
|
||||
const extraLen = buf.readUInt16LE(p + 30);
|
||||
const commentLen = buf.readUInt16LE(p + 32);
|
||||
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
|
||||
|
||||
assert.ok(flags & 0x0800, `第 ${i} 条 ${name} 应置 UTF-8 标志位`);
|
||||
assert.ok(!name.includes('\\'), `第 ${i} 条 ${name} 不应含反斜杠`);
|
||||
assert.ok(!name.startsWith('/'), `第 ${i} 条 ${name} 不应是绝对路径`);
|
||||
assert.ok(!name.includes('//'), `第 ${i} 条 ${name} 不应有连续斜杠`);
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
});
|
||||
|
||||
test('verifyZipFile: 合法的包应通过', async (t) => {
|
||||
const root = tmpDir(t);
|
||||
const zipPath = path.join(root, 'out.zip');
|
||||
await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath);
|
||||
const res = await verifyZipFile(zipPath, { withCerts: false, skipRequired: true });
|
||||
assert.ok(res.files > 0);
|
||||
assert.equal(res.entries, res.files + res.dirs);
|
||||
});
|
||||
|
||||
/* ==================== 反向测试: 自检必须能抓到损坏 ==================== */
|
||||
|
||||
test('verifyZipFile: 数据区被篡改应报 CRC 错误', async (t) => {
|
||||
const root = tmpDir(t);
|
||||
const zipPath = path.join(root, 'out.zip');
|
||||
await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath);
|
||||
|
||||
const buf = fs.readFileSync(zipPath);
|
||||
// 找一个文件条目的数据区并改掉一个字节
|
||||
let eocd = -1;
|
||||
for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||
let p = buf.readUInt32LE(eocd + 16);
|
||||
const count = buf.readUInt16LE(eocd + 10);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const compSize = buf.readUInt32LE(p + 20);
|
||||
const nameLen = buf.readUInt16LE(p + 28);
|
||||
const extraLen = buf.readUInt16LE(p + 30);
|
||||
const commentLen = buf.readUInt16LE(p + 32);
|
||||
const localOffset = buf.readUInt32LE(p + 42);
|
||||
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
|
||||
if (!name.endsWith('/') && compSize > 10) {
|
||||
const start = localOffset + 30 + buf.readUInt16LE(localOffset + 26) + buf.readUInt16LE(localOffset + 28);
|
||||
buf[start + 2] ^= 0xff; // 翻转数据区中的一个字节
|
||||
break;
|
||||
}
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
fs.writeFileSync(zipPath, buf);
|
||||
|
||||
await assert.rejects(
|
||||
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
|
||||
/CRC|解压/,
|
||||
'数据被篡改时 verifyZipFile 必须报错',
|
||||
);
|
||||
});
|
||||
|
||||
test('verifyZipFile: central directory 偏移错误应被拒绝 (回归: 曾漏算数据长度)', async (t) => {
|
||||
const root = tmpDir(t);
|
||||
const zipPath = path.join(root, 'out.zip');
|
||||
await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath);
|
||||
|
||||
const buf = fs.readFileSync(zipPath);
|
||||
let eocd = -1;
|
||||
for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||
const realOffset = buf.readUInt32LE(eocd + 16);
|
||||
buf.writeUInt32LE(realOffset + 1000, eocd + 16); // 偏移挪 1000 字节
|
||||
fs.writeFileSync(zipPath, buf);
|
||||
|
||||
await assert.rejects(
|
||||
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
|
||||
/中央目录/,
|
||||
'中央目录偏移错误时 verifyZipFile 必须报错',
|
||||
);
|
||||
});
|
||||
|
||||
test('verifyZipFile: localOffset 指向数据区而非本地头应被拒绝 (回归: 曾记成数据起点)', async (t) => {
|
||||
const root = tmpDir(t);
|
||||
const zipPath = path.join(root, 'out.zip');
|
||||
await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath);
|
||||
|
||||
const buf = fs.readFileSync(zipPath);
|
||||
let eocd = -1;
|
||||
for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||
let p = buf.readUInt32LE(eocd + 16);
|
||||
// 把第一条**文件**记录的 localOffset 加 30 (= 跳到数据区)
|
||||
for (let i = 0; i < buf.readUInt16LE(eocd + 10); i++) {
|
||||
const nameLen = buf.readUInt16LE(p + 28);
|
||||
const extraLen = buf.readUInt16LE(p + 30);
|
||||
const commentLen = buf.readUInt16LE(p + 32);
|
||||
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
|
||||
if (!name.endsWith('/')) {
|
||||
buf.writeUInt32LE(buf.readUInt32LE(p + 42) + 30, p + 42);
|
||||
break;
|
||||
}
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
fs.writeFileSync(zipPath, buf);
|
||||
|
||||
await assert.rejects(
|
||||
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
|
||||
/localOffset|中央目录签名/,
|
||||
'localOffset 错误时 verifyZipFile 必须报错',
|
||||
);
|
||||
});
|
||||
|
||||
test('verifyZipFile: 混入应排除目录应被拒绝 (回归: 曾用子串匹配误伤 ohosTest/ets/test/)', async (t) => {
|
||||
const root = tmpDir(t);
|
||||
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||
// 手工塞一个不该存在的目录
|
||||
fs.mkdirSync(path.join(fixture, 'oh_modules/foo'), { recursive: true });
|
||||
fs.writeFileSync(path.join(fixture, 'oh_modules/foo/index.js'), 'x');
|
||||
const zipPath = path.join(root, 'out.zip');
|
||||
await zipDirectory(fixture, zipPath);
|
||||
|
||||
await assert.rejects(
|
||||
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
|
||||
/应排除/,
|
||||
'混入 oh_modules 时 verifyZipFile 必须报错',
|
||||
);
|
||||
});
|
||||
|
||||
test('verifyZipFile: 合法源码文件不应被误判为应排除 (回归锁定)', async (t) => {
|
||||
const root = tmpDir(t);
|
||||
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||
// 这些路径含 .test / test 字样, 但是**源码**, 必须放行
|
||||
fs.mkdirSync(path.join(fixture, 'entry/src/ohosTest/ets/test'), { recursive: true });
|
||||
fs.writeFileSync(path.join(fixture, 'entry/src/ohosTest/ets/test/Ability.test.ets'), 'x');
|
||||
fs.mkdirSync(path.join(fixture, 'entry/src/test'), { recursive: true });
|
||||
fs.writeFileSync(path.join(fixture, 'entry/src/test/List.test.ets'), 'x');
|
||||
const zipPath = path.join(root, 'out.zip');
|
||||
await zipDirectory(fixture, zipPath);
|
||||
|
||||
// 不应抛错 (夹具缺必需文件, 用 skipRequired 跳过该断言)
|
||||
const res = await verifyZipFile(zipPath, { withCerts: false, skipRequired: true });
|
||||
assert.ok(res.files > 0);
|
||||
});
|
||||
|
||||
test('verifyZipFile: --no-certs 时出现 cert-profile 应被拒绝', async (t) => {
|
||||
const root = tmpDir(t);
|
||||
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||
fs.mkdirSync(path.join(fixture, 'cert-profile/qq'), { recursive: true });
|
||||
fs.writeFileSync(path.join(fixture, 'cert-profile/qq/qq.p12'), 'FAKE');
|
||||
const zipPath = path.join(root, 'out.zip');
|
||||
await zipDirectory(fixture, zipPath);
|
||||
|
||||
await assert.rejects(
|
||||
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
|
||||
/nocerts|cert-profile/i,
|
||||
'--no-certs 下混入证书必须报错',
|
||||
);
|
||||
});
|
||||
|
||||
/* ============================ 导出骨架 ============================ */
|
||||
|
||||
test('verifyZipFile: 漏拷应被拒绝 (expectFiles 与 zip 内容不符)', async (t) => {
|
||||
const root = tmpDir(t);
|
||||
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||
const zipPath = path.join(root, 'out.zip');
|
||||
await zipDirectory(fixture, zipPath);
|
||||
|
||||
// 假装 EntryCard/entry/base/snapshot/widget-2x2.png 被复制了, 但 zip 里其实没有
|
||||
const expectFiles = [
|
||||
'mp-harmony/AppScope/app.json5',
|
||||
'mp-harmony/a/b/c/deep.txt',
|
||||
'mp-harmony/a/repeatable.txt',
|
||||
'mp-harmony/a/random.bin',
|
||||
'mp-harmony/a/中文名.txt',
|
||||
'mp-harmony/a/zero.txt',
|
||||
'mp-harmony/EntryCard/entry/base/snapshot/widget-2x2.png',
|
||||
];
|
||||
await assert.rejects(
|
||||
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true, expectFiles }),
|
||||
/漏拷/,
|
||||
'expectFiles 里多出的文件必须被报为漏拷',
|
||||
);
|
||||
});
|
||||
|
||||
test('verifyZipFile: 多拷应被拒绝 (zip 内出现复制清单外的文件)', async (t) => {
|
||||
const root = tmpDir(t);
|
||||
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||
const zipPath = path.join(root, 'out.zip');
|
||||
await zipDirectory(fixture, zipPath);
|
||||
|
||||
// 清单里故意少写一个, zip 里却存在 -> 应报多拷
|
||||
const expectFiles = [
|
||||
'mp-harmony/AppScope/app.json5',
|
||||
'mp-harmony/a/b/c/deep.txt',
|
||||
'mp-harmony/a/repeatable.txt',
|
||||
'mp-harmony/a/random.bin',
|
||||
'mp-harmony/a/中文名.txt',
|
||||
// 漏掉 a/zero.txt
|
||||
];
|
||||
await assert.rejects(
|
||||
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true, expectFiles }),
|
||||
/多拷/,
|
||||
'清单外的 zip 文件必须被报为多拷',
|
||||
);
|
||||
});
|
||||
|
||||
test('verifyZipFile: 清单完全吻合时应通过 (含 README.txt 豁免)', async (t) => {
|
||||
const root = tmpDir(t);
|
||||
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||
fs.writeFileSync(path.join(fixture, 'README.txt'), '# 说明');
|
||||
const zipPath = path.join(root, 'out.zip');
|
||||
await zipDirectory(fixture, zipPath);
|
||||
|
||||
const expectFiles = [
|
||||
'mp-harmony/AppScope/app.json5',
|
||||
'mp-harmony/a/b/c/deep.txt',
|
||||
'mp-harmony/a/repeatable.txt',
|
||||
'mp-harmony/a/random.bin',
|
||||
'mp-harmony/a/中文名.txt',
|
||||
'mp-harmony/a/zero.txt',
|
||||
'mp-harmony/README.txt',
|
||||
];
|
||||
const res = await verifyZipFile(zipPath, { withCerts: false, skipRequired: true, expectFiles });
|
||||
assert.equal(res.files, 7);
|
||||
});
|
||||
|
||||
test('copyDir: 排除规则在真实复制中生效', async (t) => {
|
||||
const root = tmpDir(t);
|
||||
const src = path.join(root, 'src');
|
||||
const dest = path.join(root, 'dest');
|
||||
|
||||
fs.mkdirSync(path.join(src, 'keep'), { recursive: true });
|
||||
fs.mkdirSync(path.join(src, 'node_modules/pkg'), { recursive: true });
|
||||
fs.mkdirSync(path.join(src, 'build'), { recursive: true });
|
||||
fs.writeFileSync(path.join(src, 'keep/a.txt'), 'a');
|
||||
fs.writeFileSync(path.join(src, 'node_modules/pkg/i.js'), 'i');
|
||||
fs.writeFileSync(path.join(src, 'build/out.txt'), 'o');
|
||||
fs.writeFileSync(path.join(src, 'local.properties'), 'sdk.dir=x');
|
||||
|
||||
const { copyDir } = require('../scripts/pack-harmony-project');
|
||||
await copyDir(src, dest, { withNodeModules: false, withCerts: true });
|
||||
|
||||
assert.ok(fs.existsSync(path.join(dest, 'keep/a.txt')), '正常文件应保留');
|
||||
assert.ok(!fs.existsSync(path.join(dest, 'node_modules')), 'node_modules 应排除');
|
||||
assert.ok(!fs.existsSync(path.join(dest, 'build')), 'build 应排除');
|
||||
assert.ok(!fs.existsSync(path.join(dest, 'local.properties')), 'local.properties 应排除');
|
||||
});
|
||||
Reference in New Issue
Block a user