Compare commits
104 Commits
166450f6ce
...
feat/camca
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f642585d1 | |||
| d789dd7142 | |||
| 60cac8780b | |||
| 5e6eec166a | |||
| 66aa66d2c1 | |||
| bbfd7ab466 | |||
| 91f47c8e9c | |||
| b8a1a916db | |||
| c6367822dc | |||
| debfb473cc | |||
| 4c5f070e7c | |||
| d356416518 | |||
| cb36136f40 | |||
| 648743ddaf | |||
| 523473250c | |||
| 0a5794d260 | |||
| 30f7ec8631 | |||
| 18875cda43 | |||
| 7c4d6cbb72 | |||
| 69e59d7902 | |||
| 4c7aac31b8 | |||
| 7d083df727 | |||
| c33831a02a | |||
| eb89806f63 | |||
| cd5f1efa86 | |||
| 1080c81f20 | |||
| 534302b842 | |||
| da7f5d9a6a | |||
| d291f5a6c1 | |||
| 526b513345 | |||
| 704c39bdae | |||
| 1de3146c3a | |||
| ad1524a9e7 | |||
| b365de4d10 | |||
| d446377683 | |||
| e19837b887 | |||
| 25e0e1cfad | |||
| fc4f90c2fc | |||
| f0b1665ea2 | |||
| 0265f388b6 | |||
| 45b53b7bf5 | |||
| 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 |
@@ -135,19 +135,31 @@
|
||||
bottom: 80rpx;
|
||||
//#endif
|
||||
text-align: center;
|
||||
line-height: 50rpx;
|
||||
line-height: calc(var(--hover-nav-media-tab-size, 24rpx) * 2.1);
|
||||
|
||||
// 视频/图片切换按钮(2026-10-24): 后台独立可调(与悬浮语言/客服/电话按钮互不干扰)。
|
||||
// hover_nav_media_tab_bg_color 未配置 → 原半透明灰 rgba(100,100,100,0.4)
|
||||
// hover_nav_media_tab_text_color 未配置 → #fff
|
||||
// hover_nav_media_tab_size 未配置 → 24rpx; 高度/圆角/内边距/间距以 em 随字号联动缩放
|
||||
// 选中态保留主题红(全局 .color-base-bg 提供 bg) + 白字。
|
||||
text {
|
||||
background: rgba(100, 100, 100, 0.4);
|
||||
color: #fff;
|
||||
font-size: $font-size-tag;
|
||||
line-height: 50rpx;
|
||||
border-radius: 20rpx;
|
||||
padding: 0 30rpx;
|
||||
background: var(--hover-nav-media-tab-bg-color, rgba(100, 100, 100, 0.4));
|
||||
color: var(--hover-nav-media-tab-text-color, #fff);
|
||||
font-size: var(--hover-nav-media-tab-size, 24rpx);
|
||||
height: 2.1em;
|
||||
line-height: 2.1em;
|
||||
border-radius: 0.85em;
|
||||
padding: 0 1.25em;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
|
||||
// 选中态: 主题红由全局 .color-base-bg 兜底, 这里只管白字保证对比度
|
||||
&.color-base-bg {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-left: 40rpx;
|
||||
margin-left: 1.6em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,3 +461,16 @@
|
||||
.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;
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export const CARD_API_URL = {
|
||||
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',
|
||||
@@ -66,7 +67,17 @@ export const CARD_API_URL = {
|
||||
// 预设列表必须与商家后台 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_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} 删候选卡(生效卡禁删)
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -136,7 +147,9 @@ export const CARD_FIELDS = [
|
||||
'name', 'company', 'job_title', 'avatar',
|
||||
'mobile', 'phone', 'email', 'website', 'wechat',
|
||||
'country', 'province', 'city', 'address', 'postal_code',
|
||||
'bio'
|
||||
'bio',
|
||||
// 2.20 名片级自定义二维码图片(空=用系统生成码)
|
||||
'qr_image'
|
||||
];
|
||||
|
||||
/** 人脉可更新白名单:与后端 Contact::EDITABLE_FIELDS 一致 */
|
||||
@@ -208,6 +221,18 @@ const CardApi = {
|
||||
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 }
|
||||
@@ -479,6 +504,65 @@ const CardApi = {
|
||||
*/
|
||||
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 || {}));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
177
common/js/card-share.js
Normal file
177
common/js/card-share.js
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* 名片分享 · 跨端共享工具(小程序 / H5 / 鸿蒙通用)
|
||||
*
|
||||
* 背景(2026-10-28):
|
||||
* 「我的名片」组件(components-diy/diy-card-self)的「发送名片」按钮在小程序端是
|
||||
* <button open-type="share">; 微信只允许**页面级** onShareAppMessage 响应,
|
||||
* 组件内无法自定义转发内容。而承载该组件的 DIY 页自带
|
||||
* onShareAppMessage(common/js/diy.js) —— Vue2 的 defaultStrat(子覆盖父)
|
||||
* 会让它顶掉全局兜底 mixin(common/js/mp-share-default.js)里那段
|
||||
* dataset.cardId 分支, 导致"分享给游客时名片 ID 丢失"。
|
||||
*
|
||||
* 本模块提供三条通道(任一命中即可):
|
||||
* ① dataset 通道: 页面钩子 onShareAppMessage(options) 里读
|
||||
* options.target.dataset.cardId(微信会把 data-card-id 转成 dataset.cardId)
|
||||
* —— 最准, 但只覆盖"发送给朋友"。
|
||||
* ② storage 通道: 按钮 @tap 时把 card_id 写进 storage, 钩子读取后立即清除
|
||||
* —— onShareTimeline 不接受事件参数, 只能走这条(朋友圈)。
|
||||
* ③ 路由 query 通道: 分享落地页从当前页 options 取 card_id,
|
||||
* 供「我的名片」组件回传给 /card/api/diy/mycard(访客看到分享者那张卡);
|
||||
* 同时作为 pickShareCardId 的第三级兜底(落地页上右上角「···」转发的续传)。
|
||||
*
|
||||
* 纯 JS(无 wxss), 小程序/H5/鸿蒙通用; 无 uni 运行时环境(如 node 单测)全部静默降级。
|
||||
*/
|
||||
|
||||
/** storage 槽: 记住"最近一次点分享按钮的卡片" */
|
||||
export const SHARE_CARD_STORAGE_KEY = '_cardShareCardId';
|
||||
|
||||
/** storage 记忆的最长有效期(毫秒) —— 超过则认为已过期, 不再用于分享 */
|
||||
export const SHARE_CARD_MEMORY_TTL = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 记住"正在分享的卡片"(按钮 @tap 时调用)
|
||||
* @param {string|number} cardId
|
||||
* @returns {string} 实际记住的 card_id(空串表示未记住)
|
||||
*/
|
||||
export function rememberShareCardId(cardId) {
|
||||
const v = String(cardId == null ? '' : cardId).trim();
|
||||
if (!v) return '';
|
||||
try {
|
||||
uni.setStorageSync(SHARE_CARD_STORAGE_KEY, { card_id: v, at: Date.now() });
|
||||
} catch (e) {
|
||||
/* 无 uni 运行时 / 存储失败: 静默, 退回 dataset 通道 */
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出并清除记住的 card_id(钩子里调用)
|
||||
* 读取后立即清除 —— 避免下一次"右上角···转发"复用了上一次按钮点击的卡片。
|
||||
* @param {number} [maxAgeMs] 有效期上限, 默认 5 分钟
|
||||
* @returns {string}
|
||||
*/
|
||||
export function takeRememberedShareCardId(maxAgeMs = SHARE_CARD_MEMORY_TTL) {
|
||||
let v = '';
|
||||
try {
|
||||
const raw = uni.getStorageSync(SHARE_CARD_STORAGE_KEY);
|
||||
if (raw && typeof raw === 'object' && raw.card_id) {
|
||||
const at = Number(raw.at || 0);
|
||||
if (!maxAgeMs || Date.now() - at <= maxAgeMs) {
|
||||
v = String(raw.card_id).trim();
|
||||
}
|
||||
} else if (typeof raw === 'string' && raw) {
|
||||
// 兼容早期纯字符串格式
|
||||
v = raw.trim();
|
||||
}
|
||||
} catch (e) {
|
||||
v = '';
|
||||
}
|
||||
try {
|
||||
uni.removeStorageSync(SHARE_CARD_STORAGE_KEY);
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从分享事件里挑出名片 ID(dataset 优先, storage 兜底, 路由 query 再兜底)
|
||||
*
|
||||
* 2026-10-28 新增第三级: 路由 query 续传。
|
||||
* 背景: 右上角「···」转发时微信不传事件对象, storage 通道又只在"点过分享按钮"
|
||||
* 后 5 分钟内有效 —— 访客经分享落地(query 带 card_id)后直接右上角转发,
|
||||
* 原先两路都取不到 → 分享出去不带 card_id → 源头卡断链变官方卡。
|
||||
* 落地页本身带着 card_id, 把它作为第三级兜底即可闭合"接收方续传"链:
|
||||
* 任何落地页上发生的转发(按钮/右上角/朋友圈)都自动续传当前落地的卡。
|
||||
*
|
||||
* @param {object} [options] onShareAppMessage 的事件对象
|
||||
* @returns {string}
|
||||
*/
|
||||
export function pickShareCardId(options) {
|
||||
try {
|
||||
const ds = options && options.target && options.target.dataset;
|
||||
if (ds && ds.cardId) {
|
||||
const v = String(ds.cardId).trim();
|
||||
if (v) return v;
|
||||
}
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
const remembered = takeRememberedShareCardId();
|
||||
if (remembered) return remembered;
|
||||
// 落地续传: 分享落地页 query 带 card_id → 右上角「···」转发/超时后的转发也绑源
|
||||
// (裸 Tab 路由无 card_id → 返回空串, 分享行为与旧行为一致, 官方卡兜底不受影响)
|
||||
return routeQueryValue('card_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前页面路由 query 的某个值(分享落地页带 card_id 时用)
|
||||
* 兼容三种取法: 原生 options / uni 封装的 $page.options / 字符串 query
|
||||
* @param {string} key
|
||||
* @returns {string}
|
||||
*/
|
||||
export function routeQueryValue(key) {
|
||||
try {
|
||||
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : [];
|
||||
if (!pages.length) return '';
|
||||
const cur = pages[pages.length - 1] || {};
|
||||
let opts = cur.options || (cur.$page && cur.$page.options) || null;
|
||||
if (opts && typeof opts === 'object') {
|
||||
const v = opts[key];
|
||||
return v == null ? '' : String(v).trim();
|
||||
}
|
||||
if (typeof cur.query === 'string' && cur.query) {
|
||||
const m = cur.query.match(new RegExp('(?:^|&)' + key + '=([^&]*)'));
|
||||
return m ? decodeURIComponent(m[1]) : '';
|
||||
}
|
||||
if (cur.query && typeof cur.query === 'object') {
|
||||
const v = cur.query[key];
|
||||
return v == null ? '' : String(v).trim();
|
||||
}
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 给页面 path 追加一个 query 参数
|
||||
* @param {string} path 形如 /pages_tool/index/diy?id=1
|
||||
* @param {string} key
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function appendPathParam(path, key, value) {
|
||||
const p = String(path == null ? '' : path).trim();
|
||||
const v = String(value == null ? '' : value).trim();
|
||||
if (!v) return p;
|
||||
if (!p) return '?' + encodeURIComponent(key) + '=' + encodeURIComponent(v);
|
||||
const sep = p.indexOf('?') >= 0 ? '&' : '?';
|
||||
return p + sep + encodeURIComponent(key) + '=' + encodeURIComponent(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* 给 query 串追加一个参数(朋友圈 onShareTimeline 只认 query)
|
||||
* @param {string} query 形如 id=1&name=xx
|
||||
* @param {string} key
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function appendQueryParam(query, key, value) {
|
||||
const q = String(query == null ? '' : query).trim().replace(/^[?&]+/, '');
|
||||
const v = String(value == null ? '' : value).trim();
|
||||
if (!v) return q;
|
||||
if (!q) return encodeURIComponent(key) + '=' + encodeURIComponent(v);
|
||||
return q + '&' + encodeURIComponent(key) + '=' + encodeURIComponent(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取 path 里的 query 段(不带 ? 前缀)
|
||||
* @param {string} path
|
||||
* @returns {string}
|
||||
*/
|
||||
export function pathQueryPart(path) {
|
||||
const p = String(path == null ? '' : path);
|
||||
const i = p.indexOf('?');
|
||||
return i >= 0 ? p.slice(i + 1) : '';
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import Config from '@/common/js/config.js';
|
||||
import util from '@/common/js/util.js';
|
||||
import { classifyHttpError, httpErrorKey } from '@/common/js/http-error.js';
|
||||
import { consumeNeedStartupAd, isColdLaunchPending } from '@/common/js/startup-ad.js';
|
||||
import { pickShareCardId, appendPathParam, appendQueryParam, pathQueryPart, routeQueryValue } from '@/common/js/card-share.js';
|
||||
|
||||
let systemInfo = util.getDeviceInfo();
|
||||
|
||||
@@ -797,6 +798,11 @@ export default {
|
||||
let shareUrl = this.$config.h5Domain + this.diyRoute;
|
||||
if (this.id) shareUrl += '?id=' + this.id;
|
||||
else if (this.name) shareUrl += '?name=' + this.name;
|
||||
// 2026-10-28: H5 分享链接补 card_id —— 与小程序 path 口径对齐。
|
||||
// 落地页 query 带 card_id(员工/访客从分享进入)时, 公众号转发链接续传源头卡;
|
||||
// 裸 Tab 无 card_id → 行为零变化(官方卡兜底)。
|
||||
const shareCardId = routeQueryValue('card_id');
|
||||
if (shareCardId) shareUrl = appendPathParam(shareUrl, 'card_id', shareCardId);
|
||||
this.$util.setPublicShare({
|
||||
title: this.$langConfig.getField(this.diyData.global, 'title'),
|
||||
desc: '',
|
||||
@@ -823,11 +829,50 @@ export default {
|
||||
}, 50);
|
||||
},
|
||||
// 分享给好友
|
||||
onShareAppMessage() {
|
||||
//
|
||||
// 2026-10-28 名片分享带卡 ID:
|
||||
// 名片组件(「我的名片」diy-card-self / 精选名片)的「发送名片」按钮在小程序端是
|
||||
// <button open-type="share">, 微信只认**页面级**钩子。本钩子来自本 mixin,
|
||||
// Vue2 defaultStrat(子覆盖父)会顶掉全局兜底 mixin(mp-share-default.js)里的
|
||||
// dataset.cardId 分支 —— 所以在这里把名片 ID 重新接回来:
|
||||
// 拿到 card_id → 分享 path 追加 card_id → 访客落地即可看到分享者那张卡。
|
||||
// 拿不到(右上角「···」转发等无按钮上下文的场景)→ 回落原有 mpShareData。
|
||||
onShareAppMessage(options) {
|
||||
const cardId = pickShareCardId(options);
|
||||
if (cardId) {
|
||||
const base = this.mpShareData?.appMessage || {};
|
||||
// 分享 path 优先用**当前页** path(带装修模板 id 等 query) ——
|
||||
// 后台配置的 base.path 未必是用户当前所在的那一页;
|
||||
// 拿不到当前页时(极少见)才回落 base.path。
|
||||
const target = this.$util.getCurrentRoute().path || base.path || '';
|
||||
return {
|
||||
title: base.title || '小程序分享',
|
||||
path: appendPathParam(target, 'card_id', cardId),
|
||||
imageUrl: base.imageUrl || ''
|
||||
};
|
||||
}
|
||||
return this.mpShareData?.appMessage;
|
||||
},
|
||||
// 分享到朋友圈
|
||||
//
|
||||
// 朋友圈只支持 query(且 onShareTimeline **不接受事件参数**) → 拿不到 dataset,
|
||||
// 只能走 storage 通道(按钮 @tap 时记住 card_id)。
|
||||
// 注意: util.getMpShare() 塞进 timeLine.query 的其实是**完整 path**(不是 query 段),
|
||||
// 这里统一用 pathQueryPart 取真正的 query 段再拼 card_id。
|
||||
onShareTimeline() {
|
||||
return this.mpShareData?.timeLine;
|
||||
const base = this.mpShareData?.timeLine;
|
||||
const cardId = pickShareCardId(null);
|
||||
if (!cardId) {
|
||||
return base;
|
||||
}
|
||||
let q = pathQueryPart((base && base.query) || '');
|
||||
if (!q) {
|
||||
q = pathQueryPart(this.$util.getCurrentRoute().path);
|
||||
}
|
||||
return {
|
||||
title: (base && base.title) || '小程序分享',
|
||||
query: appendQueryParam(q, 'card_id', cardId),
|
||||
imageUrl: (base && base.imageUrl) || ''
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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(",");
|
||||
|
||||
@@ -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,这里只是兜底)
|
||||
|
||||
@@ -210,6 +210,19 @@ async function loadAllOverrides() {
|
||||
new Promise((r) => setTimeout(() => r({ code: -1, message: 'timeout' }), REMOTE.requestTimeout || 4000)),
|
||||
]);
|
||||
|
||||
// [临时排查] 打印主题覆写接口实际返回,确认 hover_nav_media_tab_* 是否在 brand 桶
|
||||
// 排查完删除即可(全局搜索 THEME-OVERRIDES 定位)
|
||||
try {
|
||||
const _byTheme = (res && res.data && res.data.by_theme) || {};
|
||||
const _brand = (_byTheme.brand && typeof _byTheme.brand === 'object') ? _byTheme.brand : {};
|
||||
const _hoverKeys = Object.keys(_brand).filter(k => k.indexOf('hover_nav') === 0);
|
||||
console.log('[THEME-OVERRIDES] http code =', res && res.code,
|
||||
'| by_theme keys =', Object.keys(_byTheme),
|
||||
'| brand token 数 =', Object.keys(_brand).length,
|
||||
'| brand.hover_nav keys =', _hoverKeys,
|
||||
'| hover_nav_media_tab_size =', _brand['hover_nav_media_tab_size']);
|
||||
} catch (e) { /* 忽略 */ }
|
||||
|
||||
if (!res || res.code !== 0 || !res.data) {
|
||||
const reason = !res ? 'network' : (res.code === -1 ? 'timeout' : (res.message || 'bad-response'));
|
||||
setRemoteStatus('fail', { reason, theme: 'all' });
|
||||
|
||||
@@ -204,6 +204,7 @@ export const PAGE_KEY_MAP = {
|
||||
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',
|
||||
|
||||
513
components-diy/diy-card-common.js
Normal file
513
components-diy/diy-card-common.js
Normal file
File diff suppressed because one or more lines are too long
298
components-diy/diy-card-entry.vue
Normal file
298
components-diy/diy-card-entry.vue
Normal file
@@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<view data-component-name="diy-card-entry" class="diy-card-entry" :class="['entry-' + stylePreset, { 'entry-center': isCenter }]" :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" :style="iconGlyphStyle">{{ 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, 12)) + '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 || '#1E40AF'),
|
||||
minHeight: '120rpx',
|
||||
});
|
||||
},
|
||||
iconWrapStyle() {
|
||||
return this.$util.objToStyle({
|
||||
background: this.isMinimal ? 'rgba(47,107,255,0.10)' : 'rgba(255,255,255,0.18)',
|
||||
});
|
||||
},
|
||||
iconGlyphStyle() {
|
||||
return this.$util.objToStyle({
|
||||
color: this.v.textColor || (this.isMinimal ? CardTokens.color.text : '#FFFFFF'),
|
||||
});
|
||||
},
|
||||
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 ? 0.65 : 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: this.isMinimal ? (v.accentColor || '#FFEB3B') : (v.textColor || '#FFFFFF'),
|
||||
fontSize: fontSize,
|
||||
});
|
||||
}
|
||||
return this.$util.objToStyle({ color: '#333333', fontSize: fontSize });
|
||||
},
|
||||
/**
|
||||
* 文字箭头按钮的箭头: 仅 link 档渲染, 颜色与该档按钮文字一致;
|
||||
* 字号比按钮文字略大一档(默认 14px = 28rpx, 与历史渲染一致)
|
||||
*/
|
||||
btnArrowStyle() {
|
||||
return this.$util.objToStyle({
|
||||
color: this.isMinimal ? (this.v.accentColor || '#FFEB3B') : (this.v.textColor || '#FFFFFF'),
|
||||
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>
|
||||
567
components-diy/diy-card-list.vue
Normal file
567
components-diy/diy-card-list.vue
Normal file
@@ -0,0 +1,567 @@
|
||||
<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, 12)) + 'rpx',
|
||||
padding: px2rpx(num(this.v.padding, CardTokens.space.md)) + 'rpx',
|
||||
});
|
||||
},
|
||||
titleStyle() {
|
||||
return this.$util.objToStyle({
|
||||
color: this.v.titleColor || '#333333',
|
||||
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 || '#888888',
|
||||
fontSize: px2rpx(num(this.v.infoSize, 11)) + 'rpx',
|
||||
});
|
||||
},
|
||||
/** 横滑竖卡信息行: 比网格小一档(默认 10px = 20rpx, 与历史渲染一致) */
|
||||
scrollMetaStyle() {
|
||||
return this.$util.objToStyle({
|
||||
color: this.v.infoColor || '#888888',
|
||||
fontSize: px2rpx(num(this.v.infoSize, 11) - 1) + 'rpx',
|
||||
});
|
||||
},
|
||||
/** infoColor 纯色(箭头/空态用, 不带字号) */
|
||||
infoStyle() {
|
||||
return this.$util.objToStyle({ color: this.v.infoColor || '#888888' });
|
||||
},
|
||||
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 字符串 + 逐项入场动画延迟合并输出单字符串。
|
||||
* 不能用 Object.assign 展开样式字符串(会拆成字符索引),也不能绑定样式数组。
|
||||
*/
|
||||
itemFinalStyle(index) {
|
||||
return ((this.itemStyle || '') + ' animation-delay: ' + (index * 60) + 'ms;').trim();
|
||||
},
|
||||
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, lang: uni.getStorageSync('lang') || 'zh-cn' },
|
||||
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>
|
||||
2560
components-diy/diy-card-mini.vue
Normal file
2560
components-diy/diy-card-mini.vue
Normal file
File diff suppressed because it is too large
Load Diff
513
components-diy/diy-card-self.vue
Normal file
513
components-diy/diy-card-self.vue
Normal file
@@ -0,0 +1,513 @@
|
||||
<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
|
||||
import { routeQueryValue } from '@/common/js/card-share.js';
|
||||
|
||||
export default {
|
||||
name: 'diy-card-self',
|
||||
props: {
|
||||
value: { type: Object }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
card: null,
|
||||
isSelf: false,
|
||||
// 初始即 loading: 首帧渲染走骨架屏, 防止「登录后即可展示我的名片」空态
|
||||
// 在请求发出前抢先渲染一帧(2026-10-27 用户反馈切换名片 tab 时闪现)
|
||||
loading: true,
|
||||
};
|
||||
},
|
||||
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.20.1 二维码来源优先级(card/component/system; 缺省 card)
|
||||
qrPriority: v.qrPriority,
|
||||
// 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,
|
||||
// 顶部色带 / 卡片四向外边距 / 头部背景与盒模型
|
||||
bandShow: v.bandShow,
|
||||
bandBgMode: v.bandBgMode,
|
||||
bandBg: v.bandBg,
|
||||
wrapMarginTop: v.wrapMarginTop,
|
||||
wrapMarginRight: v.wrapMarginRight,
|
||||
wrapMarginBottom: v.wrapMarginBottom,
|
||||
wrapMarginLeft: v.wrapMarginLeft,
|
||||
headBgMode: v.headBgMode,
|
||||
headBg: v.headBg,
|
||||
headRadiusMode: v.headRadiusMode,
|
||||
headRadius: v.headRadius,
|
||||
headSpacingCustom: v.headSpacingCustom,
|
||||
headMarginTop: v.headMarginTop,
|
||||
headMarginRight: v.headMarginRight,
|
||||
headMarginBottom: v.headMarginBottom,
|
||||
headMarginLeft: v.headMarginLeft,
|
||||
headPaddingTop: v.headPaddingTop,
|
||||
headPaddingRight: v.headPaddingRight,
|
||||
headPaddingBottom: v.headPaddingBottom,
|
||||
headPaddingLeft: v.headPaddingLeft,
|
||||
// 2.20: 三块独立成块逐向间距(开关+逐向 margin/padding; 缺键=关=历史形态)
|
||||
rowsSpacingCustom: v.rowsSpacingCustom,
|
||||
rowsMarginTop: v.rowsMarginTop,
|
||||
rowsMarginRight: v.rowsMarginRight,
|
||||
rowsMarginBottom: v.rowsMarginBottom,
|
||||
rowsMarginLeft: v.rowsMarginLeft,
|
||||
rowsPaddingTop: v.rowsPaddingTop,
|
||||
rowsPaddingRight: v.rowsPaddingRight,
|
||||
rowsPaddingBottom: v.rowsPaddingBottom,
|
||||
rowsPaddingLeft: v.rowsPaddingLeft,
|
||||
actsSpacingCustom: v.actsSpacingCustom,
|
||||
actsMarginTop: v.actsMarginTop,
|
||||
actsMarginRight: v.actsMarginRight,
|
||||
actsMarginBottom: v.actsMarginBottom,
|
||||
actsMarginLeft: v.actsMarginLeft,
|
||||
actsPaddingTop: v.actsPaddingTop,
|
||||
actsPaddingRight: v.actsPaddingRight,
|
||||
actsPaddingBottom: v.actsPaddingBottom,
|
||||
actsPaddingLeft: v.actsPaddingLeft,
|
||||
qrSpacingCustom: v.qrSpacingCustom,
|
||||
qrMarginTop: v.qrMarginTop,
|
||||
qrMarginRight: v.qrMarginRight,
|
||||
qrMarginBottom: v.qrMarginBottom,
|
||||
qrMarginLeft: v.qrMarginLeft,
|
||||
qrPaddingTop: v.qrPaddingTop,
|
||||
qrPaddingRight: v.qrPaddingRight,
|
||||
qrPaddingBottom: v.qrPaddingBottom,
|
||||
qrPaddingLeft: v.qrPaddingLeft,
|
||||
// 2.20.4 逐向「已被手动改过」标记(C 端只透传, 渲染不消费; 保持与 config 同源)
|
||||
rowsSpacingTouched: v.rowsSpacingTouched,
|
||||
actsSpacingTouched: v.actsSpacingTouched,
|
||||
qrSpacingTouched: v.qrSpacingTouched,
|
||||
// 2.22 简介区块(开关/标题/行数/独立块/逐向)
|
||||
showBio: v.showBio,
|
||||
bioTitleShow: v.bioTitleShow,
|
||||
bioTitle: v.bioTitle,
|
||||
bioMaxLines: v.bioMaxLines,
|
||||
bioSize: v.bioSize,
|
||||
bioColor: v.bioColor,
|
||||
bioLineHeight: v.bioLineHeight,
|
||||
bioBlockBg: v.bioBlockBg,
|
||||
bioBlockRadius: v.bioBlockRadius,
|
||||
bioBlockPad: v.bioBlockPad,
|
||||
bioSpacingCustom: v.bioSpacingCustom,
|
||||
bioMarginTop: v.bioMarginTop,
|
||||
bioMarginRight: v.bioMarginRight,
|
||||
bioMarginBottom: v.bioMarginBottom,
|
||||
bioMarginLeft: v.bioMarginLeft,
|
||||
bioPaddingTop: v.bioPaddingTop,
|
||||
bioPaddingRight: v.bioPaddingRight,
|
||||
bioPaddingBottom: v.bioPaddingBottom,
|
||||
bioPaddingLeft: v.bioPaddingLeft,
|
||||
bioSpacingTouched: v.bioSpacingTouched,
|
||||
// 2.22 社交媒体区块(开关/独立块/逐向)
|
||||
showSocial: v.showSocial,
|
||||
socialBlockBg: v.socialBlockBg,
|
||||
socialBlockRadius: v.socialBlockRadius,
|
||||
socialBlockPad: v.socialBlockPad,
|
||||
socialSpacingCustom: v.socialSpacingCustom,
|
||||
socialMarginTop: v.socialMarginTop,
|
||||
socialMarginRight: v.socialMarginRight,
|
||||
socialMarginBottom: v.socialMarginBottom,
|
||||
socialMarginLeft: v.socialMarginLeft,
|
||||
socialPaddingTop: v.socialPaddingTop,
|
||||
socialPaddingRight: v.socialPaddingRight,
|
||||
socialPaddingBottom: v.socialPaddingBottom,
|
||||
socialPaddingLeft: v.socialPaddingLeft,
|
||||
socialSpacingTouched: v.socialSpacingTouched,
|
||||
// 2.22 名片标签 / 来源标注
|
||||
showTags: v.showTags,
|
||||
showSource: v.showSource,
|
||||
// 2.23 区块顺序(六个可独立成块模块任意排序)
|
||||
blockOrder: v.blockOrder,
|
||||
// 2.24 标签与来源(meta)区块独立成块 + 逐向
|
||||
metaBlockBg: v.metaBlockBg,
|
||||
metaBlockRadius: v.metaBlockRadius,
|
||||
metaBlockPad: v.metaBlockPad,
|
||||
metaSpacingCustom: v.metaSpacingCustom,
|
||||
metaMarginTop: v.metaMarginTop,
|
||||
metaMarginRight: v.metaMarginRight,
|
||||
metaMarginBottom: v.metaMarginBottom,
|
||||
metaMarginLeft: v.metaMarginLeft,
|
||||
metaPaddingTop: v.metaPaddingTop,
|
||||
metaPaddingRight: v.metaPaddingRight,
|
||||
metaPaddingBottom: v.metaPaddingBottom,
|
||||
metaPaddingLeft: v.metaPaddingLeft,
|
||||
metaSpacingTouched: v.metaSpacingTouched,
|
||||
};
|
||||
},
|
||||
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() {
|
||||
// 2026-10-28: 分享落地时小程序 path 上带 card_id(见 common/js/diy.js 的分享钩子)
|
||||
// → 访客(未登录)打开也能看到**分享者那张卡**, 而不是企业兜底卡。
|
||||
// 注意: 分享卡优先于登录引导空态 —— fallbackMode=none 只表示"不兜底",
|
||||
// 分享卡是显式指定的卡片, 不是兜底。
|
||||
const shareCardId = routeQueryValue('card_id');
|
||||
if (!this.hasToken && this.fallbackMode === 'none' && !shareCardId) {
|
||||
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(),
|
||||
card_id: shareCardId,
|
||||
lang: uni.getStorageSync('lang') || 'zh-cn',
|
||||
},
|
||||
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>
|
||||
363
components-diy/diy-card-show.vue
Normal file
363
components-diy/diy-card-show.vue
Normal file
@@ -0,0 +1,363 @@
|
||||
<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: true,
|
||||
};
|
||||
},
|
||||
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.20.1 二维码来源优先级(card/component/system; 缺省 card)
|
||||
qrPriority: v.qrPriority,
|
||||
// 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,
|
||||
// 顶部色带 / 卡片四向外边距 / 头部背景与盒模型
|
||||
bandShow: v.bandShow,
|
||||
bandBgMode: v.bandBgMode,
|
||||
bandBg: v.bandBg,
|
||||
wrapMarginTop: v.wrapMarginTop,
|
||||
wrapMarginRight: v.wrapMarginRight,
|
||||
wrapMarginBottom: v.wrapMarginBottom,
|
||||
wrapMarginLeft: v.wrapMarginLeft,
|
||||
headBgMode: v.headBgMode,
|
||||
headBg: v.headBg,
|
||||
headRadiusMode: v.headRadiusMode,
|
||||
headRadius: v.headRadius,
|
||||
headSpacingCustom: v.headSpacingCustom,
|
||||
headMarginTop: v.headMarginTop,
|
||||
headMarginRight: v.headMarginRight,
|
||||
headMarginBottom: v.headMarginBottom,
|
||||
headMarginLeft: v.headMarginLeft,
|
||||
headPaddingTop: v.headPaddingTop,
|
||||
headPaddingRight: v.headPaddingRight,
|
||||
headPaddingBottom: v.headPaddingBottom,
|
||||
headPaddingLeft: v.headPaddingLeft,
|
||||
// 2.20: 三块独立成块逐向间距(开关+逐向 margin/padding; 缺键=关=历史形态)
|
||||
rowsSpacingCustom: v.rowsSpacingCustom,
|
||||
rowsMarginTop: v.rowsMarginTop,
|
||||
rowsMarginRight: v.rowsMarginRight,
|
||||
rowsMarginBottom: v.rowsMarginBottom,
|
||||
rowsMarginLeft: v.rowsMarginLeft,
|
||||
rowsPaddingTop: v.rowsPaddingTop,
|
||||
rowsPaddingRight: v.rowsPaddingRight,
|
||||
rowsPaddingBottom: v.rowsPaddingBottom,
|
||||
rowsPaddingLeft: v.rowsPaddingLeft,
|
||||
actsSpacingCustom: v.actsSpacingCustom,
|
||||
actsMarginTop: v.actsMarginTop,
|
||||
actsMarginRight: v.actsMarginRight,
|
||||
actsMarginBottom: v.actsMarginBottom,
|
||||
actsMarginLeft: v.actsMarginLeft,
|
||||
actsPaddingTop: v.actsPaddingTop,
|
||||
actsPaddingRight: v.actsPaddingRight,
|
||||
actsPaddingBottom: v.actsPaddingBottom,
|
||||
actsPaddingLeft: v.actsPaddingLeft,
|
||||
qrSpacingCustom: v.qrSpacingCustom,
|
||||
qrMarginTop: v.qrMarginTop,
|
||||
qrMarginRight: v.qrMarginRight,
|
||||
qrMarginBottom: v.qrMarginBottom,
|
||||
qrMarginLeft: v.qrMarginLeft,
|
||||
qrPaddingTop: v.qrPaddingTop,
|
||||
qrPaddingRight: v.qrPaddingRight,
|
||||
qrPaddingBottom: v.qrPaddingBottom,
|
||||
qrPaddingLeft: v.qrPaddingLeft,
|
||||
// 2.20.4 逐向「已被手动改过」标记(C 端只透传, 渲染不消费; 保持与 config 同源)
|
||||
rowsSpacingTouched: v.rowsSpacingTouched,
|
||||
actsSpacingTouched: v.actsSpacingTouched,
|
||||
qrSpacingTouched: v.qrSpacingTouched,
|
||||
// 2.22 简介区块(开关/标题/行数/独立块/逐向)
|
||||
showBio: v.showBio,
|
||||
bioTitleShow: v.bioTitleShow,
|
||||
bioTitle: v.bioTitle,
|
||||
bioMaxLines: v.bioMaxLines,
|
||||
bioSize: v.bioSize,
|
||||
bioColor: v.bioColor,
|
||||
bioLineHeight: v.bioLineHeight,
|
||||
bioBlockBg: v.bioBlockBg,
|
||||
bioBlockRadius: v.bioBlockRadius,
|
||||
bioBlockPad: v.bioBlockPad,
|
||||
bioSpacingCustom: v.bioSpacingCustom,
|
||||
bioMarginTop: v.bioMarginTop,
|
||||
bioMarginRight: v.bioMarginRight,
|
||||
bioMarginBottom: v.bioMarginBottom,
|
||||
bioMarginLeft: v.bioMarginLeft,
|
||||
bioPaddingTop: v.bioPaddingTop,
|
||||
bioPaddingRight: v.bioPaddingRight,
|
||||
bioPaddingBottom: v.bioPaddingBottom,
|
||||
bioPaddingLeft: v.bioPaddingLeft,
|
||||
bioSpacingTouched: v.bioSpacingTouched,
|
||||
// 2.22 社交媒体区块(开关/独立块/逐向)
|
||||
showSocial: v.showSocial,
|
||||
socialBlockBg: v.socialBlockBg,
|
||||
socialBlockRadius: v.socialBlockRadius,
|
||||
socialBlockPad: v.socialBlockPad,
|
||||
socialSpacingCustom: v.socialSpacingCustom,
|
||||
socialMarginTop: v.socialMarginTop,
|
||||
socialMarginRight: v.socialMarginRight,
|
||||
socialMarginBottom: v.socialMarginBottom,
|
||||
socialMarginLeft: v.socialMarginLeft,
|
||||
socialPaddingTop: v.socialPaddingTop,
|
||||
socialPaddingRight: v.socialPaddingRight,
|
||||
socialPaddingBottom: v.socialPaddingBottom,
|
||||
socialPaddingLeft: v.socialPaddingLeft,
|
||||
socialSpacingTouched: v.socialSpacingTouched,
|
||||
// 2.22 名片标签 / 来源标注
|
||||
showTags: v.showTags,
|
||||
showSource: v.showSource,
|
||||
// 2.23 区块顺序(六个可独立成块模块任意排序)
|
||||
blockOrder: v.blockOrder,
|
||||
// 2.24 标签与来源(meta)区块独立成块 + 逐向
|
||||
metaBlockBg: v.metaBlockBg,
|
||||
metaBlockRadius: v.metaBlockRadius,
|
||||
metaBlockPad: v.metaBlockPad,
|
||||
metaSpacingCustom: v.metaSpacingCustom,
|
||||
metaMarginTop: v.metaMarginTop,
|
||||
metaMarginRight: v.metaMarginRight,
|
||||
metaMarginBottom: v.metaMarginBottom,
|
||||
metaMarginLeft: v.metaMarginLeft,
|
||||
metaPaddingTop: v.metaPaddingTop,
|
||||
metaPaddingRight: v.metaPaddingRight,
|
||||
metaPaddingBottom: v.metaPaddingBottom,
|
||||
metaPaddingLeft: v.metaPaddingLeft,
|
||||
metaSpacingTouched: v.metaSpacingTouched,
|
||||
};
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'value.cardId'() {
|
||||
this.fetchData();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.fetchData();
|
||||
},
|
||||
methods: {
|
||||
fetchData() {
|
||||
const cardId = String((this.value || {}).cardId || '').trim();
|
||||
if (cardId === '') {
|
||||
this.loading = false;
|
||||
this.card = null;
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
this.$api.sendRequest({
|
||||
url: '/card/api/diy/card',
|
||||
data: { card_id: cardId, lang: uni.getStorageSync('lang') || 'zh-cn' },
|
||||
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>
|
||||
|
||||
37
pages.json
37
pages.json
@@ -1470,7 +1470,7 @@
|
||||
{
|
||||
"path": "card/list",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的名片夹",
|
||||
"navigationBarTitleText": "名片",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
@@ -1485,52 +1485,52 @@
|
||||
{
|
||||
"path": "card/exchange",
|
||||
"style": {
|
||||
"navigationBarTitleText": "名片交换请求"
|
||||
"navigationBarTitleText": "名片"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/style",
|
||||
"style": {
|
||||
"navigationBarTitleText": "名片样式"
|
||||
"navigationBarTitleText": "名片"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/edit",
|
||||
"style": {
|
||||
"navigationBarTitleText": "编辑名片"
|
||||
"navigationBarTitleText": "名片"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/apply",
|
||||
"style": {
|
||||
"navigationBarTitleText": "申请名片"
|
||||
"navigationBarTitleText": "名片"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/leads",
|
||||
"style": {
|
||||
"navigationBarTitleText": "潜在客户",
|
||||
"navigationBarTitleText": "名片",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/contacts",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的人脉",
|
||||
"navigationBarTitleText": "名片",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/views",
|
||||
"style": {
|
||||
"navigationBarTitleText": "谁看了我",
|
||||
"navigationBarTitleText": "名片",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/contact-edit",
|
||||
"style": {
|
||||
"navigationBarTitleText": "编辑人脉"
|
||||
"navigationBarTitleText": "名片"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1546,23 +1546,36 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/save-contact",
|
||||
"style": {
|
||||
"navigationBarTitleText": "保存到通讯录"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/enterprise",
|
||||
"style": {
|
||||
"navigationBarTitleText": "企业名片",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/admin/manage",
|
||||
"style": {
|
||||
"navigationBarTitleText": "名片管理",
|
||||
"navigationBarTitleText": "名片",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/admin/contacts",
|
||||
"style": {
|
||||
"navigationBarTitleText": "人脉管理",
|
||||
"navigationBarTitleText": "名片",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "card/admin/leads",
|
||||
"style": {
|
||||
"navigationBarTitleText": "潜在客户",
|
||||
"navigationBarTitleText": "名片",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -93,6 +93,13 @@ export default {
|
||||
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();
|
||||
|
||||
@@ -82,6 +82,13 @@ export default {
|
||||
onLoad() {
|
||||
this.refresh();
|
||||
},
|
||||
onReady() {
|
||||
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||
uni.setNavigationBarTitle({ title: '名片' });
|
||||
// #ifdef H5
|
||||
document.title = '名片';
|
||||
// #endif
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
this.refresh(() => uni.stopPullDownRefresh());
|
||||
},
|
||||
|
||||
@@ -88,6 +88,13 @@ export default {
|
||||
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();
|
||||
|
||||
@@ -68,9 +68,16 @@ export default {
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
uni.setNavigationBarTitle({ title: '申请名片' });
|
||||
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({
|
||||
|
||||
@@ -82,6 +82,13 @@ export default {
|
||||
this.loadDetail();
|
||||
}
|
||||
},
|
||||
onReady() {
|
||||
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||
uni.setNavigationBarTitle({ title: '名片' });
|
||||
// #ifdef H5
|
||||
document.title = '名片';
|
||||
// #endif
|
||||
},
|
||||
methods: {
|
||||
loadDetail() {
|
||||
this.$cardApi.contactInfo({
|
||||
|
||||
@@ -83,6 +83,13 @@ export default {
|
||||
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) {
|
||||
|
||||
@@ -18,6 +18,13 @@
|
||||
:class="ds.layout === 'compact' ? 'dc-cover-compact' : 'dc-cover-hero'">
|
||||
<image class="dc-cover-img" :src="$util.img(ds.cover)" mode="aspectFill"></image>
|
||||
</view>
|
||||
<!-- 名片二维码入口: 名片卡右上角(2026-10-21 从底部宫格移入; 点击打开名片二维码弹层) -->
|
||||
<view class="dc-qr" @tap="openShare">
|
||||
<text class="iconfont icon-erweima" :style="{ color: '#000000' }"></text>
|
||||
</view>
|
||||
<!-- 预览名片: 二维码块下方, 纯文字四个字(2026-10-21 去背景去药丸, 只留「预览名片」;
|
||||
点击打开本卡公开页, 后端 wap 模板完整渲染画布, 永远跟后台一致) -->
|
||||
<view class="dc-preview" @tap="previewCard" v-if="shareUrl">预览名片</view>
|
||||
<!-- 文字色/字号内联: 小程序端 var(--card-text / --card-font-scale) 失效 -->
|
||||
<view class="dc-name" :style="{ color: ds.textColor }">{{ relation.name || '未命名名片' }}</view>
|
||||
<view class="dc-job" v-if="relation.job_title" :style="{ color: ds.textSoft, fontSize: (28 * ds.fontScaleNum) + 'rpx' }">{{ relation.job_title }}</view>
|
||||
@@ -35,8 +42,10 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 卡下操作条(参考图: 三项等分, 项与项之间用浅灰竖线分隔) -->
|
||||
<view class="card-actions" v-if="relation">
|
||||
<!-- 卡下操作条(参考图: 三项等分, 项与项之间用浅灰竖线分隔)
|
||||
2026-10-21: 编辑资料/名片样式/切换名片 暂隐藏(showLegacyActions=false), 代码保留可随时恢复;
|
||||
预览名片已移入名片卡右侧(.dc-preview), 此处不再重复放置 -->
|
||||
<view class="card-actions" v-if="relation && showLegacyActions">
|
||||
<view class="ca-item" @tap="goEdit">
|
||||
<text class="ca-icon iconfont icon-bianji"></text>
|
||||
<text>编辑资料</text>
|
||||
@@ -51,11 +60,6 @@
|
||||
<text class="ca-icon iconfont icon-shuaxin"></text>
|
||||
<text>切换名片</text>
|
||||
</view>
|
||||
<view class="ca-divider"></view>
|
||||
<!-- 预览名片: 打开本卡公开页(后端 wap 模板完整渲染画布, 永远跟后台一致) -->
|
||||
<view class="ca-item" @tap="previewCard" v-if="shareUrl">
|
||||
<text>预览名片</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 发名片: 微信小程序端必须用原生 <button open-type="share"> ——
|
||||
@@ -72,24 +76,23 @@
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
|
||||
<!-- 功能入口宫格(参考图: 发名片下方四等分图标入口) -->
|
||||
<view class="entry-grid" v-if="relation">
|
||||
<!-- 功能入口宫格(参考图: 发名片下方四等分图标入口)
|
||||
2026-10-21: 潜在客户/我的人脉 暂隐藏(showLegacyActions=false), 代码保留可随时恢复;
|
||||
名片二维码入口已移至名片卡右上角(.dc-qr), 宫格不再重复放置 -->
|
||||
<view class="entry-grid" v-if="relation && showLegacyActions">
|
||||
<view class="eg-item" @tap="goList">
|
||||
<text class="eg-icon iconfont icon-list" :style="{ color: ds.themeColor }"></text>
|
||||
<text class="eg-text">名片夹</text>
|
||||
</view>
|
||||
<view class="eg-item" @tap="goLeads">
|
||||
<view class="eg-item" @tap="goLeads" v-if="showLegacyActions">
|
||||
<text class="eg-icon iconfont icon-yonghu3" :style="{ color: ds.themeColor }"></text>
|
||||
<text class="eg-text">潜在客户</text>
|
||||
</view>
|
||||
<view class="eg-item" @tap="goContacts">
|
||||
<view class="eg-item" @tap="goContacts" v-if="showLegacyActions">
|
||||
<text class="eg-icon iconfont icon-pengyouquan" :style="{ color: ds.themeColor }"></text>
|
||||
<text class="eg-text">我的人脉</text>
|
||||
</view>
|
||||
<view class="eg-item" @tap="openShare">
|
||||
<text class="eg-icon iconfont icon-erweima" :style="{ color: ds.themeColor }"></text>
|
||||
<text class="eg-text">名片二维码</text>
|
||||
</view>
|
||||
<!-- 名片二维码入口已移至名片卡右上角(.dc-qr), 宫格不再重复放置 -->
|
||||
</view>
|
||||
|
||||
<!-- 交换名片提醒条(参考图: 宫格下方出现对方要交换的名片) -->
|
||||
@@ -229,7 +232,12 @@ export default {
|
||||
exchangeList: [],
|
||||
exchangeCount: 0,
|
||||
// 商家是否开启「员工端样式自选」(GET design/presets 的 enabled); 默认 true(失败开放)
|
||||
designEnabled: true
|
||||
designEnabled: true,
|
||||
// 旧入口显隐开关(2026-10-21 需求: 隐藏「编辑资料/名片样式/切换名片」「潜在客户/我的人脉」,
|
||||
// 隐藏非删除——改回 true 即全部恢复; 对应方法 goEdit/onStyleTap/goLeads/goContacts 均保留)
|
||||
showLegacyActions: false,
|
||||
|
||||
loadOptions: ''
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -363,11 +371,37 @@ export default {
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
this.cardId = decodeURIComponent(options.card_id || '');
|
||||
this.loadOptions =JSON.stringify(options);
|
||||
// ===== 调试转储(2026-10-27): 定位「缺少名片编号」——看本页到底收到了什么入参 =====
|
||||
// 小程序/扫码/转发进入时, 平台传给 onLoad 的所有参数都打出来(含 scene 解码后)
|
||||
try {
|
||||
console.log('[detail] onLoad 原始 options:', JSON.stringify(options || {}));
|
||||
Object.keys(options || {}).forEach(k => {
|
||||
let v = options[k];
|
||||
try { v = decodeURIComponent(v); } catch (e) {}
|
||||
console.log('[detail] options.' + k, '=', v);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[detail] options 转储失败:', e);
|
||||
}
|
||||
|
||||
// ===== 调试转储结束 =====
|
||||
this.applyPageTitle();
|
||||
// 2.21 小程序码入口: scene 里只有名片**主键 id**(微信 scene 仅 32 字符,
|
||||
// 装不下 30 字符的业务 card_id), 需先换成 card_id 才能查详情。
|
||||
const directId = this.pickDirectCardId(options);
|
||||
const sceneId = this.pickSceneRelId(options);
|
||||
if (directId) {
|
||||
this.cardId = directId;
|
||||
this.loadDetail();
|
||||
this.loadExchange();
|
||||
this.loadDesignEnabled();
|
||||
} else if (sceneId) {
|
||||
this.resolveCardIdByRelId(sceneId);
|
||||
} else {
|
||||
this.isLoading = false;
|
||||
this.errorMsg = `缺少名片编号 ${this.loadOptions}`;
|
||||
}
|
||||
},
|
||||
// H5 直接刷新/深链进入时, uni-page-head 可能在路由 meta 就绪前就挂载,
|
||||
// 此时标题会回落到 document.title(uni-app H5 默认值 "title");
|
||||
@@ -400,6 +434,78 @@ export default {
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
// 2.21 小程序码: 名片 ID 解析
|
||||
//
|
||||
// 微信 getwxacodeunlimit 的 scene 限制 32 可见字符, 装不下业务 card_id
|
||||
// (形如 c_1_shop_20261013111612_9588EA, 30 字符) → scene 里放的是
|
||||
// 名片**主键 id**(数字)。故两条入口:
|
||||
// ① 常规路由: query 带 card_id → 直接用
|
||||
// ② 小程序码: options.scene = "id=123" → 先按主键换业务 card_id
|
||||
//
|
||||
// 常规入口(①)
|
||||
pickDirectCardId(options) {
|
||||
const opt = options || {};
|
||||
if (opt.card_id) {
|
||||
try {
|
||||
return decodeURIComponent(opt.card_id);
|
||||
} catch (e) {
|
||||
return opt.card_id;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
},
|
||||
// 小程序码入口(②): 从 scene 里取名片主键 id
|
||||
// scene 形态: "id=123" (微信会先 encodeURIComponent, 需先解码)
|
||||
pickSceneRelId(options) {
|
||||
const opt = options || {};
|
||||
if (!opt.scene) {
|
||||
return 0;
|
||||
}
|
||||
let scene = '';
|
||||
try {
|
||||
scene = decodeURIComponent(opt.scene);
|
||||
} catch (e) {
|
||||
scene = opt.scene;
|
||||
}
|
||||
// 兼容 "id=123" 与裸 "123"
|
||||
let val = scene;
|
||||
const pairs = scene.split('&');
|
||||
for (let i = 0; i < pairs.length; i++) {
|
||||
const kv = pairs[i].split('=');
|
||||
if (kv[0] === 'id' && kv[1]) {
|
||||
val = kv[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
val = String(val).replace(/[^0-9]/g, '');
|
||||
return val ? parseInt(val, 10) : 0;
|
||||
},
|
||||
// 按主键 id 换业务 card_id(后端 /card/api/diy/card 支持 id 参数寻址)
|
||||
resolveCardIdByRelId(relId) {
|
||||
this.isLoading = true;
|
||||
this.$api.sendRequest({
|
||||
url: '/card/api/diy/card',
|
||||
data: { id: relId },
|
||||
success: res => {
|
||||
const card = (res.code == 0 && res.data && res.data.card) ? res.data.card : null;
|
||||
const bizId = (card && card.card_id) ? card.card_id : '';
|
||||
if (!bizId) {
|
||||
this.isLoading = false;
|
||||
this.errorMsg = '名片不存在或已被删除';
|
||||
this.relation = null;
|
||||
return;
|
||||
}
|
||||
this.cardId = bizId;
|
||||
this.loadDetail();
|
||||
this.loadExchange();
|
||||
this.loadDesignEnabled();
|
||||
},
|
||||
fail: () => {
|
||||
this.isLoading = false;
|
||||
this.errorMsg = '网络异常,请稍后重试';
|
||||
},
|
||||
});
|
||||
},
|
||||
// 导航栏标题统一出口: 小程序/鸿蒙/App 走原生导航栏;
|
||||
// H5 额外同步 document.title, 避免 uni-page-head 标题回落到默认值 "title"。
|
||||
applyPageTitle() {
|
||||
@@ -440,7 +546,7 @@ export default {
|
||||
loadDetail(retry) {
|
||||
if (!this.cardId) {
|
||||
this.isLoading = false;
|
||||
this.errorMsg = '缺少名片编号';
|
||||
this.errorMsg = `缺少名片编号 ${this.loadOptions}`;
|
||||
return;
|
||||
}
|
||||
this.isLoading = true;
|
||||
@@ -448,6 +554,13 @@ export default {
|
||||
data: { card_id: this.cardId },
|
||||
success: res => {
|
||||
const data = res.data || {};
|
||||
// ===== 调试转储(2026-10-27): 把 card/detail 后台数据打到控制台 =====
|
||||
// 完整响应可在控制台展开查看; 关键字段单独打出, 便于快速核对二维码/公开页地址
|
||||
console.log('[detail] card/detail 返回(code=' + res.code + '):', JSON.parse(JSON.stringify(res)));
|
||||
console.log('[detail] 关键字段 -> card_id:', this.cardId,
|
||||
'| wap_url:', data.wap_url || '(空)',
|
||||
'| qr_url:', data.qr_url || '(空)');
|
||||
// ===== 调试转储结束 =====
|
||||
if (res.code == 0) {
|
||||
this.isLoading = false;
|
||||
this.relation = data.relation || null;
|
||||
@@ -763,7 +876,11 @@ export default {
|
||||
});
|
||||
},
|
||||
saveQr() {
|
||||
if (!this.qrUrl) return;
|
||||
if (!this.qrUrl) {
|
||||
// 不再静默返回: qr_url 为空说明后端接口未返回二维码地址, 提示出来便于定位
|
||||
uni.showToast({ title: '名片二维码未生成(接口未返回 qr_url)', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
uni.downloadFile({
|
||||
url: this.qrUrl,
|
||||
success: res => {
|
||||
@@ -847,6 +964,32 @@ export default {
|
||||
padding: 40rpx 36rpx 32rpx;
|
||||
box-shadow: 0 6rpx 20rpx rgba(0, 30, 80, 0.06);
|
||||
|
||||
// 名片二维码入口: 卡片右上角放大块(2026-10-21 从底部宫格移入后按需求放大到右侧整块区域)
|
||||
.dc-qr {
|
||||
position: absolute;
|
||||
top: 24rpx;
|
||||
right: 24rpx;
|
||||
width: 170rpx;
|
||||
height: 170rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 16rpx;
|
||||
|
||||
.iconfont {
|
||||
font-size: 110rpx;
|
||||
}
|
||||
}
|
||||
|
||||
// 预览名片: 二维码块下方, 纯文字四个字(2026-10-21 去背景去药丸, 无底色无边框无 padding)
|
||||
.dc-preview {
|
||||
position: absolute;
|
||||
top: 206rpx;
|
||||
right: 24rpx;
|
||||
font-size: 24rpx;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
// 封面(hero/compact): 顶部全幅出血, 身份信息压在其下沿; classic 不展示
|
||||
.dc-cover {
|
||||
margin: -40rpx -36rpx 24rpx;
|
||||
|
||||
@@ -10,6 +10,19 @@
|
||||
<text v-else class="avatar-plus">+</text>
|
||||
</view>
|
||||
<view class="avatar-tip">点击从相册选择</view>
|
||||
</view>
|
||||
<!-- 2.20 自定义二维码图片: 空=用系统按名片链接自动生成的码 -->
|
||||
<view class="form-row">
|
||||
<view class="row-label">二维码</view>
|
||||
<view class="qr-box" @tap="chooseQrImage">
|
||||
<image v-if="form.qr_image" :src="$util.img(form.qr_image)" class="qr-img" mode="aspectFit" />
|
||||
<text v-else class="qr-plus">+</text>
|
||||
</view>
|
||||
<view class="qr-side">
|
||||
<view class="avatar-tip">点击上传自定义二维码</view>
|
||||
<view class="qr-tip">{{ form.qr_image ? '已自定义,将替换默认二维码' : '未设置时使用名片链接自动生成的码' }}</view>
|
||||
<text v-if="form.qr_image" class="qr-clear" @tap.stop="clearQrImage">移除自定义二维码</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="row-label"><text class="req-star">*</text>姓名</view>
|
||||
@@ -180,7 +193,9 @@ export default {
|
||||
name: '', company: '', job_title: '', avatar: '',
|
||||
mobile: '', phone: '', email: '', website: '', wechat: '',
|
||||
country: '', province: '', city: '', address: '', postal_code: '',
|
||||
bio: ''
|
||||
bio: '',
|
||||
// 2.20 自定义二维码图片(空=用系统自动生成的码)
|
||||
qr_image: ''
|
||||
},
|
||||
mobiles: [],
|
||||
socials: [],
|
||||
@@ -197,11 +212,18 @@ export default {
|
||||
this.cardId = decodeURIComponent(options.card_id || '');
|
||||
this.isCreate = this.cardId === '';
|
||||
if (this.isCreate) {
|
||||
uni.setNavigationBarTitle({ title: '新建名片' });
|
||||
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) {
|
||||
@@ -220,6 +242,12 @@ export default {
|
||||
this.form[k] = String(r[k]);
|
||||
}
|
||||
});
|
||||
// 2.20 自定义二维码: 优先取后端归一化后的 qr_image(顶层),
|
||||
// 回退 relation 原始值(归一化只影响站内相对路径补斜杠)
|
||||
const qrNorm = res.data && res.data.qr_image;
|
||||
this.form.qr_image = qrNorm !== undefined && qrNorm !== null
|
||||
? String(qrNorm)
|
||||
: String(r.qr_image || '');
|
||||
// 富化字段: detail 由后端 CardRichFields 解码后挂在 relation 上
|
||||
// (注意是 res.data.relation.mobiles_list, 不是 res.data.mobiles_list)
|
||||
this.mobiles = (r.mobiles_list || []).map(v => String(v || ''));
|
||||
@@ -277,6 +305,41 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
/* ---------- 自定义二维码图片(2.20): 相册选择 → card 专用上传 → 回填路径 ---------- */
|
||||
chooseQrImage() {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: res => {
|
||||
const filePath = res.tempFilePaths && res.tempFilePaths[0];
|
||||
if (!filePath) return;
|
||||
uni.showLoading({ title: '上传中…', mask: true });
|
||||
this.$api.upload({
|
||||
url: '/card/api/card/uploadQrImage',
|
||||
filePath: filePath,
|
||||
fileType: 'image',
|
||||
success: r => {
|
||||
uni.hideLoading();
|
||||
if (r && r.code == 0 && r.data && r.data.pic_path) {
|
||||
this.form.qr_image = r.data.pic_path;
|
||||
} else {
|
||||
uni.showToast({ title: (r && r.message) || '上传失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: '上传失败', icon: 'none' });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
/* 移除自定义二维码: 置空 = 提交时回落系统自动生成的码 */
|
||||
clearQrImage() {
|
||||
this.form.qr_image = '';
|
||||
},
|
||||
|
||||
/* ---------- 附加手机号 ---------- */
|
||||
onMobileInput(i, e) {
|
||||
this.$set(this.mobiles, i, e.detail.value);
|
||||
@@ -492,6 +555,49 @@ export default {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 自定义二维码行(2.20) */
|
||||
.qr-box {
|
||||
width: 108rpx;
|
||||
height: 108rpx;
|
||||
border-radius: 12rpx;
|
||||
background-color: #f2f3f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
.qr-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.qr-plus {
|
||||
font-size: 44rpx;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
}
|
||||
|
||||
.qr-side {
|
||||
flex: 1;
|
||||
margin-left: 20rpx;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.qr-tip {
|
||||
font-size: 22rpx;
|
||||
color: #b0b4bc;
|
||||
margin-top: 4rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.qr-clear {
|
||||
display: inline-block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 24rpx;
|
||||
color: var(--main-color, #0054a5);
|
||||
}
|
||||
|
||||
/* 动态行(附加手机号/社交媒体/自定义字段) */
|
||||
.dyn-row {
|
||||
.row-label {
|
||||
|
||||
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>
|
||||
@@ -60,6 +60,13 @@ export default {
|
||||
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;
|
||||
|
||||
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>
|
||||
@@ -76,6 +76,13 @@ export default {
|
||||
onLoad() {
|
||||
this.refresh();
|
||||
},
|
||||
onReady() {
|
||||
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||
uni.setNavigationBarTitle({ title: '名片' });
|
||||
// #ifdef H5
|
||||
document.title = '名片';
|
||||
// #endif
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
this.refresh(() => uni.stopPullDownRefresh());
|
||||
},
|
||||
|
||||
@@ -21,11 +21,13 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 筛选条: 跟进(全部 / 待跟进 / 已完成, follow_up 传 due/done) -->
|
||||
<!-- 筛选条: 跟进(全部 / 待跟进 / 已完成, follow_up 传 due/done) + 删除模式开关 -->
|
||||
<view class="filter-row">
|
||||
<view class="fav-filter" :class="{ active: followTab === 'all' }" @tap="switchFollow('all')">全部</view>
|
||||
<view class="fav-filter" :class="{ active: followTab === 'due' }" @tap="switchFollow('due')">待跟进</view>
|
||||
<view class="fav-filter" :class="{ active: followTab === 'done' }" @tap="switchFollow('done')">已完成</view>
|
||||
<!-- 删除名片入口(2.11.91): 点按进入删除模式, 行内才显示删除按钮, 避免平时误触 -->
|
||||
<view class="fav-filter del-filter" :class="{ active: delMode }" @tap="toggleDelMode">删除</view>
|
||||
</view>
|
||||
|
||||
<!-- 列表 -->
|
||||
@@ -53,6 +55,7 @@
|
||||
@tap.stop="toggleFavorite">{{ item.is_favorite == 1 ? '★' : '☆' }}</text>
|
||||
<view v-if="followTab === 'due' && item.follow_up_done != 1" class="card-done-btn"
|
||||
:data-index="index" @tap.stop="markDone">完成</view>
|
||||
<view v-if="delMode" class="card-del-btn" :data-index="index" @tap.stop="deleteCard">删除</view>
|
||||
<view class="card-arrow">›</view>
|
||||
</view>
|
||||
|
||||
@@ -153,6 +156,8 @@ export default {
|
||||
pendingClaimCode: '', // 扫码带参预填的码
|
||||
// 跟进筛选(2.11.3): all / due(待跟进) / done(已完成)
|
||||
followTab: 'all',
|
||||
// 删除模式(2.11.91): 由筛选条「删除」入口开关, 开启后行内才显示删除按钮
|
||||
delMode: false,
|
||||
// 建卡申请状态(2.11.15)
|
||||
apply: null, // { status, status_text, note, has_card }
|
||||
applyName: '',
|
||||
@@ -204,7 +209,20 @@ export default {
|
||||
}
|
||||
this.bootstrap();
|
||||
},
|
||||
onReady() {
|
||||
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
|
||||
uni.setNavigationBarTitle({ title: '名片' });
|
||||
// #ifdef H5
|
||||
document.title = '名片';
|
||||
// #endif
|
||||
},
|
||||
onShow() {
|
||||
// 从详情页返回时走 onShow(不会再触发 onReady), 微信可能把标题恢复成旧值,
|
||||
// 这里每次显示都重设一次, 保证导航栏始终是「名片」
|
||||
uni.setNavigationBarTitle({ title: '名片' });
|
||||
// #ifdef H5
|
||||
document.title = '名片';
|
||||
// #endif
|
||||
// 首次 onShow 紧跟 onLoad(由 bootstrap 负责首屏加载), 这里只处理"返回本页"的刷新
|
||||
if (this._entered) this.refresh();
|
||||
this._entered = true;
|
||||
@@ -280,6 +298,10 @@ export default {
|
||||
this.followTab = tab;
|
||||
this.refresh();
|
||||
},
|
||||
// 删除模式开关(2.11.91): 开启后各行显示删除按钮; 再次点按退出
|
||||
toggleDelMode() {
|
||||
this.delMode = !this.delMode;
|
||||
},
|
||||
// 标记跟进完成(2.11.3): followDone, 成功后该卡从待跟进消失
|
||||
markDone(payload) {
|
||||
const item = this.resolveItem(payload);
|
||||
@@ -303,6 +325,50 @@ export default {
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 删除名片(软删, 2.11.91, 与 CARD_DELETE_API.md 同步)
|
||||
* 行级归属: 只能删自己名下卡; 删他人/已删/不存在统一后端报「卡片不存在」。
|
||||
* 流程: 二次确认(danger 样式) → cardDelete → 成功 toast「已删除」并刷新列表
|
||||
* (列表接口立即不再返回该卡, 刷新即移除, 无需手动 splice)。
|
||||
* 失败口径同收口规范第 3 条: 统一 toast(message), 不刷新。
|
||||
*/
|
||||
deleteCard(payload) {
|
||||
const item = this.resolveItem(payload);
|
||||
if (!item) return;
|
||||
const cardId = this.cardIdOf(item);
|
||||
if (!cardId) {
|
||||
uni.showToast({ title: '该名片缺少编号,无法删除', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
const name = item.name || '未命名名片';
|
||||
uni.showModal({
|
||||
title: '删除名片',
|
||||
content: '删除后名片将立即失效,分享链接也会不可访问。确定删除「' + name + '」?',
|
||||
confirmText: '删除',
|
||||
confirmColor: '#e54d42',
|
||||
success: m => {
|
||||
if (!m.confirm) return;
|
||||
uni.showLoading({ title: '删除中…' });
|
||||
this.$cardApi.cardDelete({
|
||||
data: { card_id: cardId },
|
||||
success: res => {
|
||||
uni.hideLoading();
|
||||
if (res.code == 0) {
|
||||
uni.showToast({ title: '已删除', icon: 'success' });
|
||||
this.refresh();
|
||||
} else {
|
||||
// 「卡片不存在」模糊口径: 统一按 message 提示, 不特殊处理
|
||||
this.handleApiError(res);
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
// 空态: 查询我的建卡申请状态(2.11.15)
|
||||
loadApplyStatus() {
|
||||
this.$cardApi.applyStatus({
|
||||
@@ -679,6 +745,17 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
/* 删除模式入口(2.11.91): 与跟进 chips 同排, 激活态用 danger 红 */
|
||||
.fav-filter.del-filter {
|
||||
margin-left: 16rpx;
|
||||
|
||||
&.active {
|
||||
background-color: #fdeceb;
|
||||
border-color: #e54d42;
|
||||
color: #e54d42;
|
||||
}
|
||||
}
|
||||
|
||||
.card-list {
|
||||
.card-item {
|
||||
display: flex;
|
||||
@@ -794,6 +871,21 @@ export default {
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
|
||||
/* 删除名片按钮(2.11.91): danger 红, 与完成按钮同构的小药丸 */
|
||||
.card-del-btn {
|
||||
flex-shrink: 0;
|
||||
margin-left: 16rpx;
|
||||
padding: 8rpx 20rpx;
|
||||
background-color: #fdeceb;
|
||||
color: #e54d42;
|
||||
font-size: 24rpx;
|
||||
border-radius: 24rpx;
|
||||
|
||||
&:active {
|
||||
background-color: #f9d6d3;
|
||||
}
|
||||
}
|
||||
|
||||
/* 首屏加载占位(替代"先闪空态") */
|
||||
.first-loading {
|
||||
text-align: center;
|
||||
|
||||
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>
|
||||
@@ -363,6 +363,13 @@ export default {
|
||||
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;
|
||||
|
||||
@@ -71,6 +71,13 @@ export default {
|
||||
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());
|
||||
},
|
||||
|
||||
@@ -538,6 +538,17 @@ const store = new Vuex.Store({
|
||||
let color = util.colourBlend(theme.main_color, '#ffffff', (i / 10));
|
||||
themeColor += `--base-color-light-${i}:${color};`;
|
||||
}
|
||||
// [临时排查] 确认后台 hover_nav_media_tab_* 是否进入 themeStyle 并生成了 CSS 变量
|
||||
// 排查完删除即可(全局搜索 THEME-CHECK 定位)
|
||||
try {
|
||||
console.log('[THEME-CHECK]', {
|
||||
bg: theme['hover_nav_media_tab_bg_color'],
|
||||
text: theme['hover_nav_media_tab_text_color'],
|
||||
size: theme['hover_nav_media_tab_size'],
|
||||
hoverNavCount: Object.keys(theme).filter(k => k.indexOf('hover_nav') === 0).length,
|
||||
sizeVarGenerated: themeColor.indexOf('--hover-nav-media-tab-size') > -1
|
||||
});
|
||||
} catch (e) { /* 忽略 */ }
|
||||
_themeColorCache = themeColor;
|
||||
_themeColorCacheKey = cacheKey;
|
||||
this.commit('setThemeColor', themeColor);
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* 名片云 · 商家端(admin)页面测试
|
||||
*
|
||||
* 背景:
|
||||
* 商家端 7 个页面中, 后台接口先就绪的是 contact/* 与 lead/*(以及 card/lists),
|
||||
* 因此本轮先上三个页面(其余 5 页等后台接口, 见 CARD_API_REFERENCE.md 缺口):
|
||||
* - admin/manage 名片管理(基础版: 搜索/详情/编辑, 详情编辑复用员工端页)
|
||||
* - admin/contacts 人脉管理(列表/搜索/来源筛选/编辑/转线索/删除)
|
||||
* - admin/leads 潜在客户(状态筛选/列表/更新跟进状态)
|
||||
* 本测试固化商家端契约: 注册/收口/鸿蒙安全导航/复用回跳。
|
||||
*
|
||||
* 跑法:
|
||||
* node --test tests/card-admin.test.js
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const ADMIN_DIR = path.join(ROOT, 'pages_tool/card/admin');
|
||||
const readAdmin = f => fs.readFileSync(path.join(ADMIN_DIR, f), 'utf8');
|
||||
const readCard = f => fs.readFileSync(path.join(ROOT, 'pages_tool/card', f), 'utf8');
|
||||
const pagesSrc = fs.readFileSync(path.join(ROOT, 'pages.json'), 'utf8');
|
||||
const utilSrc = fs.readFileSync(path.join(ROOT, 'common/js/util.js'), 'utf8');
|
||||
|
||||
// pages.json 含 uni-app 合法 // 注释, 剥后解析
|
||||
const stripped = pagesSrc.replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1');
|
||||
const pagesJson = JSON.parse(stripped);
|
||||
|
||||
const ADMIN_PAGES = {
|
||||
'card/admin/manage': '名片管理',
|
||||
'card/admin/contacts': '人脉管理',
|
||||
'card/admin/leads': '潜在客户'
|
||||
};
|
||||
|
||||
/** 取 pages_tool 分包已注册路径 */
|
||||
function toolPaths() {
|
||||
const sub = (pagesJson.subPackages || []).find(sp => sp.root === 'pages_tool');
|
||||
assert.ok(sub, 'pages_tool 分包应存在');
|
||||
return (sub.pages || []).map(p => p.path);
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// 1. 页面注册
|
||||
// =============================================================
|
||||
|
||||
test('注册: 商家端 3 页已注册进 pages.json 的 pages_tool 分包', () => {
|
||||
const paths = toolPaths();
|
||||
for (const p of Object.keys(ADMIN_PAGES)) {
|
||||
assert.ok(paths.includes(p), `${p} 未注册: ${paths.join(',')}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('注册: 商家端页面标题正确且不在 TabBar', () => {
|
||||
const sub = (pagesJson.subPackages || []).find(sp => sp.root === 'pages_tool');
|
||||
for (const [p, title] of Object.entries(ADMIN_PAGES)) {
|
||||
const page = (sub.pages || []).find(x => x.path === p);
|
||||
assert.ok(page, `${p} 应存在`);
|
||||
assert.equal(page.style.navigationBarTitleText, title, `${p} 标题应为「${title}」`);
|
||||
// 名片云全部页面都不是 tabBar 根页
|
||||
assert.ok(page.style.enablePullDownRefresh === true, `${p} 应开启下拉刷新`);
|
||||
}
|
||||
const tabPages = (pagesJson.tabBar && pagesJson.tabBar.list ? pagesJson.tabBar.list : []).map(p => p.pagePath);
|
||||
for (const p of Object.keys(ADMIN_PAGES)) {
|
||||
assert.ok(!tabPages.some(tp => tp.includes(p)), `TabBar 不应含 ${p}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('注册: PAGE_KEY_MAP 含商家端 3 页且与 pages.json 一致', () => {
|
||||
const map = {
|
||||
PAGE_CARD_ADMIN_MANAGE: '/pages_tool/card/admin/manage',
|
||||
PAGE_CARD_ADMIN_CONTACTS: '/pages_tool/card/admin/contacts',
|
||||
PAGE_CARD_ADMIN_LEADS: '/pages_tool/card/admin/leads'
|
||||
};
|
||||
for (const [key, val] of Object.entries(map)) {
|
||||
const m = utilSrc.match(new RegExp(`${key}:\\s*'([^']+)'`));
|
||||
assert.ok(m, `PAGE_KEY_MAP 应有 ${key}`);
|
||||
assert.equal(m[1], val);
|
||||
assert.ok(toolPaths().includes(val.slice('/pages_tool/'.length)),
|
||||
`${val} 未在 pages.json 注册`);
|
||||
}
|
||||
// 防回归: TAB_BAR_KEYS 不得被误加 admin 路径
|
||||
const tabBlock = utilSrc.match(/TAB_BAR_KEYS\s*=\s*\{[\s\S]*?\}/);
|
||||
assert.ok(tabBlock, 'TAB_BAR_KEYS 应存在');
|
||||
assert.ok(!/admin/.test(tabBlock[0]), 'TAB_BAR_KEYS 不应含 admin 路径');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 2. API 收口(与 card-api 测试同一契约, 覆盖新页面)
|
||||
// =============================================================
|
||||
|
||||
test('收口: 商家端 3 页零 /card/api/ 字面量且走 this.$cardApi', () => {
|
||||
for (const f of ['manage.vue', 'contacts.vue', 'leads.vue']) {
|
||||
const src = readAdmin(f);
|
||||
assert.ok(!/['"]\/card\/api\//.test(src), `${f} 仍直连 /card/api/ 字面量`);
|
||||
assert.ok(/this\.\$cardApi\./.test(src), `${f} 未使用 this.$cardApi`);
|
||||
}
|
||||
});
|
||||
|
||||
test('收口: 商家端页面从 card-api.js 导入共享字典', () => {
|
||||
assert.ok(/import\s*\{\s*CONTACT_SOURCES\s*\}\s*from\s*'@\/common\/js\/card-api\.js'/.test(readAdmin('contacts.vue')),
|
||||
'contacts.vue 应导入 CONTACT_SOURCES');
|
||||
assert.ok(/import\s*\{\s*LEAD_STATUS\s*\}\s*from\s*'@\/common\/js\/card-api\.js'/.test(readAdmin('leads.vue')),
|
||||
'leads.vue 应导入 LEAD_STATUS');
|
||||
});
|
||||
|
||||
test('收口: leads.vue 的 setStatus 只接受 0-3(后端约束)', () => {
|
||||
const src = readAdmin('leads.vue');
|
||||
// ActionSheet 选项来自 LEAD_STATUS(0-3), 不产生越界值
|
||||
assert.ok(/itemList:\s*LEAD_STATUS\.map/.test(src), '状态选项必须来自 LEAD_STATUS 字典');
|
||||
assert.ok(!/status\s*=\s*[4-9]/.test(src), '不得出现 0-3 之外的 status 值');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 3. 鸿蒙安全导航(禁裸 uni.switchTab 静默失败)
|
||||
// =============================================================
|
||||
|
||||
test('鸿蒙: 商家端 3 页平级互链全部走 $util.redirectTo', () => {
|
||||
const cross = {
|
||||
'manage.vue': [['goContacts', '/pages_tool/card/admin/contacts'], ['goLeads', '/pages_tool/card/admin/leads']],
|
||||
'contacts.vue': [['goManage', '/pages_tool/card/admin/manage'], ['goLeads', '/pages_tool/card/admin/leads']],
|
||||
'leads.vue': [['goManage', '/pages_tool/card/admin/manage'], ['goContacts', '/pages_tool/card/admin/contacts']]
|
||||
};
|
||||
for (const [f, pairs] of Object.entries(cross)) {
|
||||
const src = readAdmin(f);
|
||||
for (const [method, target] of pairs) {
|
||||
const m = src.match(new RegExp(`${method}\\(\\)\\s*\\{([\\s\\S]*?)\\n\\t\\t\\}`));
|
||||
assert.ok(m, `${f} 应定义 ${method}`);
|
||||
assert.ok(/\$util\.redirectTo/.test(m[1]), `${f}#${method} 必须走 $util.redirectTo`);
|
||||
assert.ok(new RegExp(`redirectTo\\('${target.replace(/\//g, '\\/')}'\\)`).test(m[1]),
|
||||
`${f}#${method} 目标应为 ${target}`);
|
||||
}
|
||||
assert.ok(!/uni\.switchTab\s*\(/.test(src), `${f} 不得直接调 uni.switchTab`);
|
||||
}
|
||||
});
|
||||
|
||||
test('鸿蒙: manage.vue 跳详情/编辑用 navigateTo + encodeURIComponent(card_id)', () => {
|
||||
const src = readAdmin('manage.vue');
|
||||
// 名片路由参数一律 card_id(字符串), 双编码无害(纯 ASCII)
|
||||
assert.ok(/\/pages_tool\/card\/detail\?card_id='\s*\+\s*encodeURIComponent\(item\.card_id\)/.test(src),
|
||||
'goDetail 应 encodeURIComponent(card_id)');
|
||||
assert.ok(/\/pages_tool\/card\/edit\?card_id='\s*\+\s*encodeURIComponent\(item\.card_id\)/.test(src),
|
||||
'goEdit 应 encodeURIComponent(card_id)');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 4. 编辑页复用回跳(from=admin)
|
||||
// =============================================================
|
||||
|
||||
test('复用: contact-edit 支持 from=admin 且缺省行为不变', () => {
|
||||
const src = readCard('contact-edit.vue');
|
||||
assert.ok(/this\.fromAdmin\s*=\s*options\.from\s*===\s*'admin'/.test(src),
|
||||
'onLoad 应解析 from=admin');
|
||||
const m = src.match(/backToList\(\)\s*\{([\s\S]*?)\n\t\t\}/);
|
||||
assert.ok(m, '应能提取 backToList 方法体');
|
||||
assert.ok(/\$util\.redirectTo/.test(m[1]), 'backToList 必须走 $util.redirectTo');
|
||||
assert.ok(/this\.fromAdmin\s*\?/.test(m[1]), 'backToList 应按来源分支');
|
||||
assert.ok(m[1].includes("'/pages_tool/card/admin/contacts'"),
|
||||
'商家端应回 /pages_tool/card/admin/contacts');
|
||||
assert.ok(m[1].includes("'/pages_tool/card/contacts'"),
|
||||
'员工端缺省仍回 /pages_tool/card/contacts(不破坏既有行为)');
|
||||
assert.ok(!/uni\.switchTab\s*\(/.test(src), 'contact-edit.vue 不得直接调 uni.switchTab');
|
||||
});
|
||||
|
||||
test('复用: admin/contacts 的编辑入口带 from=admin', () => {
|
||||
const src = readAdmin('contacts.vue');
|
||||
assert.ok(/contact-edit\?from=admin&id='/.test(src), '编辑入口应带 from=admin&id=');
|
||||
assert.ok(/contact-edit\?from=admin'/.test(src), '添加入口应带 from=admin');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 5. 条件编译配对平衡(新文件同样纳入)
|
||||
// =============================================================
|
||||
|
||||
test('ifdef 配对平衡: 商家端 3 页', () => {
|
||||
for (const f of ['manage.vue', 'contacts.vue', 'leads.vue']) {
|
||||
const src = readAdmin(f);
|
||||
const open = (src.match(/#ifdef|#ifndef/g) || []).length;
|
||||
const close = (src.match(/#endif/g) || []).length;
|
||||
assert.equal(open, close, `${f}: #ifdef/#ifndef=${open} 但 #endif=${close}`);
|
||||
}
|
||||
});
|
||||
@@ -1,326 +0,0 @@
|
||||
/**
|
||||
* 名片云 API 收口层测试: common/js/card-api.js ↔ 后端 CARD_API_REFERENCE.md
|
||||
*
|
||||
* 背景:
|
||||
* 后端《名片云(card addon)API 对接文档》checklist 第 1 条要求:
|
||||
* "所有 /card/api/* URL 收口到 API 层, 与本文档路径逐一比对(现有 7 个页面
|
||||
* 12 处直连, 路径不可变)"。本测试把该契约固化为断言:
|
||||
*
|
||||
* 覆盖:
|
||||
* 1. card-api.js 的 18 个端点与后端文档逐一比对(一个字符都不能差)
|
||||
* 2. card-api.js 不得出现文档之外的 /card/api/ 路径
|
||||
* 3. 每个端点都有对应 helper 方法(收口不是只建常量表)
|
||||
* 4. 7 个业务页面零字面量直连(全部走 this.$cardApi)
|
||||
* 5. main.js 注册 $cardApi(Vue.prototype)
|
||||
* 6. 字典与后端白名单一致: SOCIAL_TYPES / LEAD_STATUS / CONTACT_SOURCES
|
||||
* 7. 嵌套对象入参走 sendJson 通道(card/update、contact/add、contact/update、
|
||||
* recognizeBatch), 其余走 sendRequest
|
||||
* 8. 走 sendJson 的 helper, 页面必须把 payload 包在 data 里
|
||||
* (sendJson 只合并 params.data, 顶层挂 fields 会被丢弃)
|
||||
*
|
||||
* 跑法:
|
||||
* node --test tests/card-api.test.js
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const CARD_DIR = path.join(ROOT, 'pages_tool/card');
|
||||
const read = f => fs.readFileSync(path.join(CARD_DIR, f), 'utf8');
|
||||
const API_SRC = fs.readFileSync(path.join(ROOT, 'common/js/card-api.js'), 'utf8');
|
||||
const MAIN_SRC = fs.readFileSync(path.join(ROOT, 'main.js'), 'utf8');
|
||||
|
||||
// 后端 CARD_API_REFERENCE.md 第 1/3/4/5 节列出的全部端点(15 个)
|
||||
const DOC_URLS = [
|
||||
// 名片夹 /card/api/card/*
|
||||
'/card/api/card/lists',
|
||||
'/card/api/card/detail',
|
||||
'/card/api/card/update',
|
||||
'/card/api/card/toggleFavorite', // 收藏(双向 toggle, 后端 2.9.12)
|
||||
'/card/api/card/create', // 后端源码已有(新建数字名片), 旧文档未列, 以源码为准
|
||||
'/card/api/card/claim',
|
||||
'/card/api/card/recognize',
|
||||
'/card/api/card/retry',
|
||||
'/card/api/card/recognizeBatch',
|
||||
'/card/api/card/followDone', // 跟进完成态 2.11.3
|
||||
'/card/api/card/views', // 访客轨迹 2.11.4
|
||||
// 人脉库 /card/api/contact/*
|
||||
'/card/api/contact/lists',
|
||||
'/card/api/contact/add',
|
||||
'/card/api/contact/info',
|
||||
'/card/api/contact/update',
|
||||
'/card/api/contact/delete',
|
||||
'/card/api/contact/toLead',
|
||||
// 潜在客户 /card/api/lead/*
|
||||
'/card/api/lead/lists',
|
||||
'/card/api/lead/setStatus',
|
||||
// 交换名片 /card/api/exchange/*
|
||||
'/card/api/exchange/lists',
|
||||
'/card/api/exchange/agree',
|
||||
'/card/api/exchange/ignore',
|
||||
'/card/api/apply/submit', // 员工建卡申请 2.11.15
|
||||
'/card/api/apply/status', // 员工建卡申请 2.11.15
|
||||
'/card/api/design/presets', // 名片预设列表(同源后台 CardTemplatePresets, 后端 v2 无 /card/ 段)
|
||||
'/card/api/design/save' // 应用预设/保存本卡样式
|
||||
];
|
||||
|
||||
// helper 方法名 ↔ URL 常量名(收口层必须两端齐全)
|
||||
const HELPER_URL_PAIRS = [
|
||||
['cardLists', 'CARD_LISTS'],
|
||||
['cardDetail', 'CARD_DETAIL'],
|
||||
['cardUpdate', 'CARD_UPDATE'],
|
||||
['cardToggleFavorite', 'CARD_FAVORITE'],
|
||||
['cardCreate', 'CARD_CREATE'],
|
||||
['cardClaim', 'CARD_CLAIM'],
|
||||
['cardRecognize', 'CARD_RECOGNIZE'],
|
||||
['cardRetry', 'CARD_RETRY'],
|
||||
['cardRecognizeBatch', 'CARD_RECOGNIZE_BATCH'],
|
||||
['contactLists', 'CONTACT_LISTS'],
|
||||
['contactAdd', 'CONTACT_ADD'],
|
||||
['contactInfo', 'CONTACT_INFO'],
|
||||
['contactUpdate', 'CONTACT_UPDATE'],
|
||||
['contactDelete', 'CONTACT_DELETE'],
|
||||
['contactToLead', 'CONTACT_TO_LEAD'],
|
||||
['leadLists', 'LEAD_LISTS'],
|
||||
['leadSetStatus', 'LEAD_SET_STATUS'],
|
||||
['exchangeLists', 'EXCHANGE_LISTS'],
|
||||
['exchangeAgree', 'EXCHANGE_AGREE'],
|
||||
['exchangeIgnore', 'EXCHANGE_IGNORE'],
|
||||
['cardFollowDone', 'CARD_FOLLOW_DONE'],
|
||||
['cardViews', 'CARD_VIEWS'],
|
||||
['applySubmit', 'APPLY_SUBMIT'],
|
||||
['applyStatus', 'APPLY_STATUS'],
|
||||
['cardDesignPresets', 'CARD_DESIGN_PRESETS'],
|
||||
['cardDesignSave', 'CARD_DESIGN_SAVE']
|
||||
];
|
||||
|
||||
// 文档 3.3/4.2/4.4/3.7: 嵌套对象/数组入参必须走 JSON body(sendJson)
|
||||
const JSON_BODY_HELPERS = ['cardUpdate', 'cardCreate', 'cardToggleFavorite', 'contactAdd', 'contactUpdate', 'cardRecognizeBatch', 'exchangeAgree', 'exchangeIgnore', 'cardDesignSave'];
|
||||
|
||||
// =============================================================
|
||||
// 1. 端点与后端文档逐一比对
|
||||
// =============================================================
|
||||
|
||||
test('收口: card-api.js 含文档全部 18 个端点', () => {
|
||||
for (const u of DOC_URLS) {
|
||||
assert.ok(API_SRC.includes(`'${u}'`), `card-api.js 缺少端点 ${u}(与后端文档不符)`);
|
||||
}
|
||||
});
|
||||
|
||||
test('收口: card-api.js 不得出现文档之外的 /card/api/ 路径', () => {
|
||||
const found = API_SRC.match(/'\/card\/api\/[^']+'/g) || [];
|
||||
const unknown = found.map(s => s.slice(1, -1)).filter(u => !DOC_URLS.includes(u));
|
||||
assert.deepEqual(unknown, [], `存在文档外路径: ${unknown.join(', ')}`);
|
||||
assert.equal(found.length, DOC_URLS.length,
|
||||
`端点数量应为 ${DOC_URLS.length}, 实际 ${found.length}(防止漏收/多收)`);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 2. 每个端点都有 helper(收口不是只建常量表)
|
||||
// =============================================================
|
||||
|
||||
test('收口: 15 个端点均有对应 helper 方法', () => {
|
||||
for (const [helper, urlConst] of HELPER_URL_PAIRS) {
|
||||
assert.ok(new RegExp(`\\b${helper}\\(p\\)\\s*\\{`).test(API_SRC),
|
||||
`缺少 helper ${helper}(p)`);
|
||||
assert.ok(new RegExp(`(?:url\\s*:|url\\s*=)\\s*CARD_API_URL\\.${urlConst}\\b`).test(API_SRC),
|
||||
`helper ${helper} 未绑定 ${urlConst}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('收口: 嵌套入参 helper 走 sendJson 通道', () => {
|
||||
for (const helper of JSON_BODY_HELPERS) {
|
||||
const m = API_SRC.match(new RegExp(`${helper}\\(p\\)\\s*\\{([\\s\\S]*?)\\n\\t\\}`));
|
||||
assert.ok(m, `应能提取 ${helper} 方法体`);
|
||||
assert.ok(/Http\.sendJson\(/.test(m[1]),
|
||||
`${helper} 必须走 Http.sendJson(JSON body), 否则嵌套 fields 跨端序列化有歧义`);
|
||||
}
|
||||
});
|
||||
|
||||
test('收口: 普通入参 helper 走 sendRequest 通道', () => {
|
||||
const formHelpers = HELPER_URL_PAIRS
|
||||
.map(p => p[0])
|
||||
.filter(h => !JSON_BODY_HELPERS.includes(h));
|
||||
for (const helper of formHelpers) {
|
||||
const m = API_SRC.match(new RegExp(`${helper}\\(p\\)\\s*\\{([\\s\\S]*?)\\n\\t\\}`));
|
||||
assert.ok(m, `应能提取 ${helper} 方法体`);
|
||||
assert.ok(/Http\.sendRequest\(/.test(m[1]),
|
||||
`${helper} 应走 Http.sendRequest`);
|
||||
assert.ok(!/Http\.sendJson\(/.test(m[1]),
|
||||
`${helper} 不应走 sendJson(无嵌套入参)`);
|
||||
}
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 3. 页面零字面量直连
|
||||
// =============================================================
|
||||
|
||||
test('收口: 7 个业务页面不得再直连 /card/api/ 字面量', () => {
|
||||
const files = ['list.vue', 'detail.vue', 'edit.vue', 'leads.vue', 'share.vue', 'contacts.vue', 'contact-edit.vue'];
|
||||
for (const f of files) {
|
||||
const src = read(f);
|
||||
assert.ok(!/['"]\/card\/api\//.test(src),
|
||||
`${f} 仍直连 /card/api/ 字面量, 必须走 this.$cardApi(后端 checklist 第 1 条)`);
|
||||
}
|
||||
});
|
||||
|
||||
test('收口: 有接口调用的 6 个页面全部走 this.$cardApi', () => {
|
||||
// share.vue 是 web-view 中转页, 无业务接口调用, 不在此列
|
||||
const files = ['list.vue', 'detail.vue', 'edit.vue', 'leads.vue', 'contacts.vue', 'contact-edit.vue'];
|
||||
for (const f of files) {
|
||||
const src = read(f);
|
||||
assert.ok(/this\.\$cardApi\./.test(src),
|
||||
`${f} 未使用 this.$cardApi`);
|
||||
}
|
||||
});
|
||||
|
||||
test('收口: main.js 注册 Vue.prototype.$cardApi', () => {
|
||||
assert.ok(/import\s+CardApi\s+from\s+'\.\/common\/js\/card-api\.js'/.test(MAIN_SRC),
|
||||
'main.js 应 import card-api.js');
|
||||
assert.ok(/Vue\.prototype\.\$cardApi\s*=\s*CardApi/.test(MAIN_SRC),
|
||||
'main.js 应注册 Vue.prototype.$cardApi');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 4. 字典与后端白名单一致
|
||||
// =============================================================
|
||||
|
||||
test('字典: SOCIAL_TYPES 与后端 CardRichFields::SOCIAL_TYPES 白名单一致', () => {
|
||||
const keys = [...API_SRC.matchAll(/\{\s*key:\s*'([a-z]+)',\s*label:/g)].map(m => m[1]);
|
||||
// 2026-10-13: telegram / messenger 由独立标量软下线, 并入 socials 白名单
|
||||
const expected = ['alipay', 'douyin', 'wxvideo', 'xiaohongshu', 'weibo', 'kuaishou',
|
||||
'bilibili', 'qq', 'linkedin', 'facebook', 'instagram', 'x', 'telegram', 'messenger', 'other'];
|
||||
assert.deepEqual(keys, expected, 'SOCIAL_TYPES 增减必须与后端同步');
|
||||
});
|
||||
|
||||
test('字典: CARD_FIELDS 不含 telegram/messenger(2026-10-13 软下线)', () => {
|
||||
const block = API_SRC.match(/export const CARD_FIELDS = \[([\s\S]*?)\];/);
|
||||
assert.ok(block, 'CARD_FIELDS 应存在');
|
||||
const values = [...block[1].matchAll(/'([a-z_]+)'/g)].map(m => m[1]);
|
||||
assert.ok(!values.includes('telegram'), 'telegram 已软下线为独立标量, 不得出现在 CARD_FIELDS');
|
||||
assert.ok(!values.includes('messenger'), 'messenger 已软下线为独立标量, 不得出现在 CARD_FIELDS');
|
||||
// 其余标量不受影响
|
||||
for (const f of ['name', 'company', 'job_title', 'avatar', 'mobile', 'phone', 'email',
|
||||
'website', 'wechat', 'country', 'province', 'city', 'address', 'postal_code', 'bio']) {
|
||||
assert.ok(values.includes(f), `CARD_FIELDS 应保留 ${f}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('软下线: edit.vue 不再提交 telegram/messenger 标量, 字典从收口层导入', () => {
|
||||
const editSrc = fs.readFileSync(path.join(CARD_DIR, 'edit.vue'), 'utf8');
|
||||
assert.ok(!/form\.telegram/.test(editSrc), 'edit.vue 不得再绑 form.telegram(软下线)');
|
||||
assert.ok(!/form\.messenger/.test(editSrc), 'edit.vue 不得再绑 form.messenger(软下线)');
|
||||
assert.ok(!/fields\.telegram/.test(editSrc) && !/fields\.messenger/.test(editSrc),
|
||||
'edit.vue 保存时不得再提交 telegram/messenger 标量');
|
||||
assert.ok(/import\s*\{\s*SOCIAL_TYPES,\s*CARD_FIELDS,\s*RICH_FIELD_LIMITS\s*\}\s*from\s*'@\/common\/js\/card-api\.js'/.test(editSrc),
|
||||
'edit.vue 应从 card-api.js 导入 SOCIAL_TYPES/CARD_FIELDS/RICH_FIELD_LIMITS');
|
||||
// 模板里不得再有 Telegram / Messenger 独立输入行
|
||||
assert.ok(!/<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 丢弃`);
|
||||
}
|
||||
}
|
||||
});
|
||||
128
tests/card-mini-code-scene.test.js
Normal file
128
tests/card-mini-code-scene.test.js
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 名片小程序码 · scene 解析契约测试(2.21)
|
||||
*
|
||||
* 背景:
|
||||
* 微信 getwxacodeunlimit 的 scene 只有 32 可见字符, 装不下业务 card_id
|
||||
* (形如 c_1_shop_20261013111612_9588EA, 30 字符; 加前缀更长),
|
||||
* 故 scene 里放的是名片**主键 id**(数字): "id=448"。
|
||||
*
|
||||
* 扫码进入小程序后, 微信把 scene 放在 options.scene(整体 encodeURIComponent
|
||||
* 过), 小程序端需:
|
||||
* ① decodeURIComponent(options.scene)
|
||||
* ② 解析出主键 id
|
||||
* ③ 用 id 向后端换业务 card_id(后端 /card/api/diy/card 支持 id 寻址)
|
||||
*
|
||||
* 覆盖:
|
||||
* 1. scene 解析: "id=448" / 裸 "448" / URL 编码形态
|
||||
* 2. 常规入口(query 带 card_id)不受影响
|
||||
* 3. 解析不出时走「缺少名片编号」提示(不得白屏)
|
||||
* 4. 换 card_id 走公开读接口且带 id 参数
|
||||
* 5. 反例: 不得把 scene 当 card_id 直接用(会查不到名片)
|
||||
*
|
||||
* 跑法: node --test tests/card-mini-code-scene.test.js
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PAGE = path.resolve(__dirname, '../pages_tool/card/detail.vue');
|
||||
const src = fs.readFileSync(PAGE, 'utf8');
|
||||
// 先剥注释再断言 —— 注释里写「绝不能写 X」会被自判违规
|
||||
const strip = s => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
|
||||
const body = strip(src);
|
||||
|
||||
// ================================================================
|
||||
// 1. scene 解析
|
||||
// ================================================================
|
||||
test('scene 解析: 从 options.scene 取名片主键 id', () => {
|
||||
assert.match(body, /pickSceneRelId\s*\(\s*options\s*\)/);
|
||||
// 必须先 decodeURIComponent(微信对 scene 整体做过编码)
|
||||
assert.match(body, /decodeURIComponent\(opt\.scene\)/);
|
||||
// 解析 "id=xxx" 形态
|
||||
assert.match(body, /kv\[0\]\s*===\s*'id'/);
|
||||
});
|
||||
|
||||
test('scene 解析: 兼容裸 id(无前缀)', () => {
|
||||
// 无 "=" 时整体当 id
|
||||
assert.match(body, /let val = scene;/);
|
||||
assert.match(body, /String\(val\)\.replace\(\/\[\^0-9\]\/g, ''\)/);
|
||||
});
|
||||
|
||||
test('scene 解析: 只取数字(防脏值/防注入)', () => {
|
||||
assert.match(body, /replace\(\/\[\^0-9\]\/g, ''\)/);
|
||||
assert.match(body, /parseInt\(val, 10\)/);
|
||||
});
|
||||
|
||||
test('scene 解析: 取不到时返回 0', () => {
|
||||
assert.match(body, /return val \? parseInt\(val, 10\) : 0;/);
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// 2. 常规入口不受影响
|
||||
// ================================================================
|
||||
test('常规入口: query 带 card_id 时直接使用', () => {
|
||||
assert.match(body, /pickDirectCardId\s*\(\s*options\s*\)/);
|
||||
assert.match(body, /if \(opt\.card_id\)/);
|
||||
assert.match(body, /decodeURIComponent\(opt\.card_id\)/);
|
||||
});
|
||||
|
||||
test('onLoad: 常规入口直连, scene 入口走换卡流程', () => {
|
||||
assert.match(body, /const directId = this\.pickDirectCardId\(options\);/);
|
||||
assert.match(body, /const sceneId = this\.pickSceneRelId\(options\);/);
|
||||
assert.match(body, /if \(directId\)\s*\{[\s\S]*?this\.cardId = directId;/);
|
||||
assert.match(body, /else if \(sceneId\)\s*\{\s*this\.resolveCardIdByRelId\(sceneId\);/);
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// 3. 换 card_id
|
||||
// ================================================================
|
||||
test('换 card_id: 走公开读接口且带 id 参数', () => {
|
||||
assert.match(body, /resolveCardIdByRelId\s*\(\s*relId\s*\)\s*\{/);
|
||||
assert.match(body, /url:\s*'\/card\/api\/diy\/card'/);
|
||||
assert.match(body, /data:\s*\{\s*id:\s*relId\s*\}/);
|
||||
});
|
||||
|
||||
test('换 card_id: 从返回里取业务 card_id', () => {
|
||||
assert.match(body, /res\.data && res\.data\.card/);
|
||||
assert.match(body, /card\.card_id/);
|
||||
assert.match(body, /this\.cardId = bizId;/);
|
||||
});
|
||||
|
||||
test('换 card_id: 失败时给明确提示(不白屏)', () => {
|
||||
assert.match(body, /errorMsg = '名片不存在或已被删除'/);
|
||||
assert.match(body, /errorMsg = '网络异常,请稍后重试'/);
|
||||
});
|
||||
|
||||
test('换 card_id: 成功后继续加载详情', () => {
|
||||
assert.match(body, /this\.cardId = bizId;[\s\S]*?this\.loadDetail\(\);/);
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// 4. 缺少名片编号的兜底
|
||||
// ================================================================
|
||||
test('既无 card_id 也无 scene: 提示「缺少名片编号」', () => {
|
||||
// 2026-10-27 调试增强: 文案带上实际收到的入参(loadOptions), 便于定位是哪种入口缺参
|
||||
assert.match(body, /this\.errorMsg = `缺少名片编号 \$\{this\.loadOptions\}`;/);
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// 5. 反例(防恒真 / 防走偏)
|
||||
// ================================================================
|
||||
test('反例: 不得把 scene 整体当 card_id 直接用', () => {
|
||||
// 若把 scene 当 card_id 传, 后端按业务 card_id 查不到 → 必然查不到名片
|
||||
assert.doesNotMatch(body, /this\.cardId = decodeURIComponent\(opt\.scene\)/);
|
||||
assert.doesNotMatch(body, /card_id:\s*decodeURIComponent\(opt\.scene\)/);
|
||||
});
|
||||
|
||||
test('反例: 不得用 card_id= 前缀解析 scene(scene 用 id= 前缀)', () => {
|
||||
assert.doesNotMatch(body, /kv\[0\]\s*===\s*'card_id'/);
|
||||
});
|
||||
|
||||
test('反例: 不得硬编码名片 id', () => {
|
||||
assert.doesNotMatch(body, /card_id:\s*['"]\d+['"]/);
|
||||
});
|
||||
430
tests/card-share-card-id.test.js
Normal file
430
tests/card-share-card-id.test.js
Normal file
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* 名片分享带卡 ID · 前端契约测试(2026-10-28)
|
||||
*
|
||||
* 背景:
|
||||
* 小程序端「我的名片」组件(diy-card-self)的「发送名片」按钮是 <button open-type="share">,
|
||||
* 微信只认**页面级** onShareAppMessage。承载该组件的 DIY 页自带 onShareAppMessage
|
||||
* (common/js/diy.js) → Vue2 defaultStrat(子覆盖父)会顶掉全局兜底 mixin
|
||||
* (common/js/mp-share-default.js)里那段 dataset.cardId 分支
|
||||
* → 分享 path 不带 card_id, 访客打开看到的是企业兜底卡而不是分享者那张卡。
|
||||
*
|
||||
* 修法(前端侧):
|
||||
* ① common/js/card-share.js(新增): 三条取 card_id 的通道
|
||||
* dataset(发送给朋友) / storage(朋友圈) / 路由 query(落地回传)
|
||||
* ② common/js/diy.js: 页面级分享钩子把 card_id 拼进分享 path
|
||||
* ③ components-diy/diy-card-mini.vue: 分享按钮 @tap 记住当前卡片(storage 通道)
|
||||
* ④ components-diy/diy-card-self.vue: 从路由 query 取 card_id 回传 /card/api/diy/mycard
|
||||
*
|
||||
* 跑法: node --test tests/card-share-card-id.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 read = f => fs.readFileSync(path.join(ROOT, f), 'utf8');
|
||||
const strip = s => s
|
||||
.replace(/<!--[\s\S]*?-->/g, '')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
||||
|
||||
/**
|
||||
* card-share.js 是纯 ESM(无 import) —— 去掉 export 关键字后用 new Function 求值,
|
||||
* 直接跑真实逻辑(不复制源码, 避免测试与实现漂移)。
|
||||
* uni / getCurrentPages 以参数注入, 天然完成 mock。
|
||||
*/
|
||||
function loadCardShare(uni, getCurrentPages) {
|
||||
const src = read('common/js/card-share.js').replace(/export\s+(const|function)\s/g, '$1 ');
|
||||
const factory = new Function(
|
||||
'uni', 'getCurrentPages',
|
||||
src + `
|
||||
return {
|
||||
SHARE_CARD_STORAGE_KEY, SHARE_CARD_MEMORY_TTL,
|
||||
rememberShareCardId, takeRememberedShareCardId, pickShareCardId,
|
||||
routeQueryValue, appendPathParam, appendQueryParam, pathQueryPart
|
||||
};`
|
||||
);
|
||||
return factory(uni, getCurrentPages);
|
||||
}
|
||||
|
||||
function makeUni() {
|
||||
const store = new Map();
|
||||
return {
|
||||
store,
|
||||
setStorageSync(k, v) { store.set(k, v); },
|
||||
getStorageSync(k) { return store.has(k) ? store.get(k) : ''; },
|
||||
removeStorageSync(k) { store.delete(k); },
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// A. card-share.js 真实行为
|
||||
// =============================================================
|
||||
|
||||
test('A1: 记住 → 取出 往返一致', () => {
|
||||
const uni = makeUni();
|
||||
const { rememberShareCardId, takeRememberedShareCardId } = loadCardShare(uni, () => []);
|
||||
assert.equal(rememberShareCardId('C_1_shop_20261013111612_9588EA'), 'C_1_shop_20261013111612_9588EA');
|
||||
assert.equal(takeRememberedShareCardId(), 'C_1_shop_20261013111612_9588EA');
|
||||
});
|
||||
|
||||
test('A2: 取出后即清除(下一次分享不串卡)', () => {
|
||||
const uni = makeUni();
|
||||
const { rememberShareCardId, takeRememberedShareCardId } = loadCardShare(uni, () => []);
|
||||
rememberShareCardId('C_A');
|
||||
assert.equal(takeRememberedShareCardId(), 'C_A');
|
||||
assert.equal(takeRememberedShareCardId(), '', '第二次取应为空');
|
||||
});
|
||||
|
||||
test('A3: 空 card_id 不入记忆槽', () => {
|
||||
const uni = makeUni();
|
||||
const { rememberShareCardId, takeRememberedShareCardId } = loadCardShare(uni, () => []);
|
||||
assert.equal(rememberShareCardId(''), '');
|
||||
assert.equal(rememberShareCardId(null), '');
|
||||
assert.equal(takeRememberedShareCardId(), '');
|
||||
});
|
||||
|
||||
test('A4: 记忆过期则不再用于分享(TTL)', () => {
|
||||
const uni = makeUni();
|
||||
const { rememberShareCardId, takeRememberedShareCardId } = loadCardShare(uni, () => []);
|
||||
rememberShareCardId('C_OLD');
|
||||
// 把时间戳改老(10 分钟前)
|
||||
uni.store.set('_cardShareCardId', { card_id: 'C_OLD', at: Date.now() - 10 * 60 * 1000 });
|
||||
assert.equal(takeRememberedShareCardId(), '', '过期记忆应被丢弃');
|
||||
});
|
||||
|
||||
test('A5: pickShareCardId 优先取 dataset(发送给朋友)', () => {
|
||||
const uni = makeUni();
|
||||
const { rememberShareCardId, pickShareCardId } = loadCardShare(uni, () => []);
|
||||
rememberShareCardId('C_FROM_STORAGE');
|
||||
const opts = { from: 'button', target: { dataset: { cardId: 'C_FROM_DATASET' } } };
|
||||
assert.equal(pickShareCardId(opts), 'C_FROM_DATASET');
|
||||
});
|
||||
|
||||
test('A6: pickShareCardId 无 dataset 时回落 storage(朋友圈通道)', () => {
|
||||
const uni = makeUni();
|
||||
const { rememberShareCardId, pickShareCardId } = loadCardShare(uni, () => []);
|
||||
rememberShareCardId('C_FROM_STORAGE');
|
||||
assert.equal(pickShareCardId(null), 'C_FROM_STORAGE');
|
||||
// storage 通道是"读一次即清"语义 → 第二次取前需重新记忆
|
||||
rememberShareCardId('C_FROM_STORAGE');
|
||||
assert.equal(pickShareCardId({ from: 'menu' }), 'C_FROM_STORAGE');
|
||||
});
|
||||
|
||||
test('A6b: 第三级兜底 — storage 空且落地 query 带 card_id → 续传(右上角转发 S5-2)', () => {
|
||||
// 场景: 访客经分享落地(query 带 card_id), 未点过任何分享按钮(storage 空),
|
||||
// 直接右上角「···」转发 → 应自动续传落地页的源头卡
|
||||
const uni = makeUni();
|
||||
const { pickShareCardId } = loadCardShare(uni, () => ([{
|
||||
route: 'pages_tool/index/diy',
|
||||
options: { id: '3', card_id: 'C_LANDING' },
|
||||
}]));
|
||||
assert.equal(pickShareCardId({ from: 'menu' }), 'C_LANDING');
|
||||
assert.equal(pickShareCardId(null), 'C_LANDING');
|
||||
});
|
||||
|
||||
test('A6c: 三级优先级 dataset > storage > 落地 query', () => {
|
||||
const uni = makeUni();
|
||||
const { rememberShareCardId, pickShareCardId } = loadCardShare(uni, () => ([{
|
||||
route: 'pages_tool/index/diy',
|
||||
options: { card_id: 'C_LANDING' },
|
||||
}]));
|
||||
// query 在, 但 dataset 最准 → dataset 胜
|
||||
assert.equal(pickShareCardId({ target: { dataset: { cardId: 'C_DATASET' } } }), 'C_DATASET');
|
||||
// storage 在(刚点过按钮), dataset 无 → storage 胜(按钮意图更近)
|
||||
rememberShareCardId('C_FROM_BTN');
|
||||
assert.equal(pickShareCardId(null), 'C_FROM_BTN');
|
||||
// 都没有 → 落地 query
|
||||
assert.equal(pickShareCardId({ from: 'menu' }), 'C_LANDING');
|
||||
});
|
||||
|
||||
test('A7: pickShareCardId 三路都没有 → 空', () => {
|
||||
const uni = makeUni();
|
||||
const { pickShareCardId } = loadCardShare(uni, () => []);
|
||||
assert.equal(pickShareCardId({ from: 'menu' }), '');
|
||||
assert.equal(pickShareCardId(null), '');
|
||||
});
|
||||
|
||||
test('A8: routeQueryValue 从当前页 options 取值(分享落地页)', () => {
|
||||
const { routeQueryValue } = loadCardShare(makeUni(), () => ([{
|
||||
route: 'pages_tool/index/diy',
|
||||
options: { id: '123', name: 'home', card_id: 'C_1_shop_x' },
|
||||
}]));
|
||||
assert.equal(routeQueryValue('card_id'), 'C_1_shop_x');
|
||||
assert.equal(routeQueryValue('id'), '123');
|
||||
assert.equal(routeQueryValue('not_exist'), '');
|
||||
});
|
||||
|
||||
test('A9: routeQueryValue 兼容字符串形态 query', () => {
|
||||
const { routeQueryValue } = loadCardShare(makeUni(), () => ([{
|
||||
route: 'pages_tool/index/diy',
|
||||
query: 'id=123&card_id=C_2',
|
||||
}]));
|
||||
assert.equal(routeQueryValue('card_id'), 'C_2');
|
||||
});
|
||||
|
||||
test('A10: routeQueryValue 无页面 / 无 uni 时安全返回空', () => {
|
||||
const { routeQueryValue } = loadCardShare(makeUni(), () => []);
|
||||
assert.equal(routeQueryValue('card_id'), '');
|
||||
});
|
||||
|
||||
test('A11: appendPathParam 拼 path(带/不带 query 两种形态)', () => {
|
||||
const { appendPathParam } = loadCardShare(makeUni(), () => []);
|
||||
assert.equal(
|
||||
appendPathParam('/pages_tool/index/diy?id=123', 'card_id', 'C_1'),
|
||||
'/pages_tool/index/diy?id=123&card_id=C_1'
|
||||
);
|
||||
assert.equal(
|
||||
appendPathParam('/pages_tool/index/diy', 'card_id', 'C_1'),
|
||||
'/pages_tool/index/diy?card_id=C_1'
|
||||
);
|
||||
assert.equal(appendPathParam('/pages_tool/index/diy?id=1', 'card_id', ''), '/pages_tool/index/diy?id=1');
|
||||
});
|
||||
|
||||
test('A12: appendQueryParam 拼 query(朋友圈只认 query)', () => {
|
||||
const { appendQueryParam } = loadCardShare(makeUni(), () => []);
|
||||
assert.equal(appendQueryParam('id=123', 'card_id', 'C_1'), 'id=123&card_id=C_1');
|
||||
assert.equal(appendQueryParam('', 'card_id', 'C_1'), 'card_id=C_1');
|
||||
assert.equal(appendQueryParam('?id=123', 'card_id', 'C_1'), 'id=123&card_id=C_1');
|
||||
});
|
||||
|
||||
test('A13: pathQueryPart 取 path 的 query 段', () => {
|
||||
const { pathQueryPart } = loadCardShare(makeUni(), () => []);
|
||||
assert.equal(pathQueryPart('/pages_tool/index/diy?id=1&name=x'), 'id=1&name=x');
|
||||
assert.equal(pathQueryPart('/pages_tool/index/diy'), '');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// B. DIY 页分享钩子(common/js/diy.js)
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 把 diy.js 的两个分享钩子抠出来**真跑**(diy.js 本体 import 了地图/配置等模块,
|
||||
* node 里无法直接 import; 这里只抠钩子源码, 用 card-share.js 的真实实现注入)。
|
||||
*/
|
||||
function loadDiyShareHooks() {
|
||||
const src = read('common/js/diy.js');
|
||||
const start = src.indexOf('onShareAppMessage(options) {');
|
||||
// 两个钩子是文件最后两个方法 → 一直取到导出对象的最后一个 '}' 之前
|
||||
const end = src.lastIndexOf('}');
|
||||
assert.ok(start > 0 && end > start, '未能定位 diy.js 的分享钩子');
|
||||
const hooksSrc = src.slice(start, end);
|
||||
const share = loadCardShare(makeUni(), () => []);
|
||||
const factory = new Function(
|
||||
'pickShareCardId', 'appendPathParam', 'appendQueryParam', 'pathQueryPart',
|
||||
'return {' + hooksSrc + '};'
|
||||
);
|
||||
const hooks = factory(share.pickShareCardId, share.appendPathParam, share.appendQueryParam, share.pathQueryPart);
|
||||
return { hooks, share };
|
||||
}
|
||||
|
||||
/** 模拟一个 DIY 页实例的上下文 */
|
||||
function makeDiyCtx() {
|
||||
return {
|
||||
mpShareData: {
|
||||
appMessage: { title: '店铺名', path: '/pages_tool/index/diy?id=3', imageUrl: 'cover.png' },
|
||||
timeLine: { title: '店铺名', query: '/pages_tool/index/diy?id=3', imageUrl: 'cover.png' },
|
||||
},
|
||||
$util: {
|
||||
getCurrentRoute: () => ({ path: '/pages_tool/index/diy?id=3&name=home', query: 'id=3&name=home' }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('B1: diy.js 引入名片分享工具', () => {
|
||||
const src = read('common/js/diy.js');
|
||||
assert.match(src, /import\s*\{[^}]*pickShareCardId[^}]*\}\s*from\s*['"]@\/common\/js\/card-share\.js['"]/,
|
||||
'未引入 card-share.js 的 pickShareCardId');
|
||||
});
|
||||
|
||||
test('B2: onShareAppMessage 接 options 并按 dataset/storage 取 card_id', () => {
|
||||
const src = strip(read('common/js/diy.js'));
|
||||
assert.match(src, /onShareAppMessage\(options\)\s*\{/,
|
||||
'onShareAppMessage 未接事件参数(拿不到 dataset)');
|
||||
assert.match(src, /pickShareCardId\(options\)/,
|
||||
'onShareAppMessage 未从事件里取 card_id');
|
||||
});
|
||||
|
||||
test('B3: onShareAppMessage 把 card_id 拼进分享 path', () => {
|
||||
const src = strip(read('common/js/diy.js'));
|
||||
assert.match(src, /appendPathParam\([^)]*'card_id',\s*cardId\)/,
|
||||
'分享 path 未追加 card_id');
|
||||
});
|
||||
|
||||
test('B4: onShareTimeline 走 storage 通道并把 card_id 拼进 query', () => {
|
||||
const src = strip(read('common/js/diy.js'));
|
||||
assert.match(src, /onShareTimeline\(\)\s*\{/);
|
||||
assert.match(src, /pickShareCardId\(null\)/,
|
||||
'onShareTimeline 未从 storage 取 card_id(它不接受事件参数)');
|
||||
assert.match(src, /appendQueryParam\([^)]*'card_id',\s*cardId\)/,
|
||||
'朋友圈 query 未追加 card_id');
|
||||
});
|
||||
|
||||
test('B5: 拿不到 card_id 时仍回落原分享数据(向后兼容)', () => {
|
||||
const src = strip(read('common/js/diy.js'));
|
||||
assert.match(src, /return this\.mpShareData\?\.appMessage;/,
|
||||
'未保留原有 appMessage 回落');
|
||||
assert.match(src, /return base;/,
|
||||
'未保留原有 timeLine 回落');
|
||||
});
|
||||
|
||||
test('B6: 行为级 — 钩子真跑: dataset 命中 → path 带 card_id', () => {
|
||||
const { hooks, share } = loadDiyShareHooks();
|
||||
const ctx = makeDiyCtx();
|
||||
const res = hooks.onShareAppMessage.call(ctx, {
|
||||
from: 'button',
|
||||
target: { dataset: { cardId: 'C_1_shop_x', cardName: '张三' } },
|
||||
});
|
||||
assert.equal(res.path, '/pages_tool/index/diy?id=3&name=home&card_id=C_1_shop_x',
|
||||
'分享 path 未带上当前页 query + card_id');
|
||||
assert.equal(res.title, '店铺名');
|
||||
});
|
||||
|
||||
test('B7: 行为级 — 无 card_id 时原样返回后台配置的分享数据', () => {
|
||||
const { hooks } = loadDiyShareHooks();
|
||||
const ctx = makeDiyCtx();
|
||||
assert.equal(hooks.onShareAppMessage.call(ctx, { from: 'menu' }), ctx.mpShareData.appMessage,
|
||||
'无 card_id 时应原样返回 mpShareData.appMessage');
|
||||
});
|
||||
|
||||
test('B8: 行为级 — 朋友圈钩子把 card_id 拼进 query', () => {
|
||||
const { hooks, share } = loadDiyShareHooks();
|
||||
const ctx = makeDiyCtx();
|
||||
share.rememberShareCardId('C_1_shop_x');
|
||||
const res = hooks.onShareTimeline.call(ctx);
|
||||
assert.equal(res.query, 'id=3&card_id=C_1_shop_x',
|
||||
'朋友圈 query 未带 card_id');
|
||||
assert.equal(res.title, '店铺名');
|
||||
});
|
||||
|
||||
test('B9: 行为级 — 朋友圈无 card_id 时原样回落', () => {
|
||||
const { hooks } = loadDiyShareHooks();
|
||||
const ctx = makeDiyCtx();
|
||||
assert.equal(hooks.onShareTimeline.call(ctx), ctx.mpShareData.timeLine);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// B+. H5 公众号分享链接补 card_id(setPublicShare, S10-1)
|
||||
// =============================================================
|
||||
|
||||
/** 把 diy.js 的 setPublicShare 抠出来真跑(同 loadDiyShareHooks 思路) */
|
||||
function loadDiySetPublicShare(share) {
|
||||
const src = read('common/js/diy.js');
|
||||
const start = src.indexOf('setPublicShare() {');
|
||||
const end = src.indexOf('onPageScroll(e) {');
|
||||
assert.ok(start > 0 && end > start, '未能定位 diy.js 的 setPublicShare');
|
||||
// 剥注释后, 丢掉切片尾部的外层 methods 闭括号(setPublicShare 自己的 '}' 是倒数第二个)
|
||||
let body = strip(src.slice(start, end));
|
||||
body = body.slice(0, body.lastIndexOf('}')).trim();
|
||||
const factory = new Function('routeQueryValue', 'appendPathParam', 'return {' + body + '};');
|
||||
// 工厂返回 { setPublicShare: fn } 对象, 这里直接交出方法本体
|
||||
return factory(share.routeQueryValue, share.appendPathParam).setPublicShare;
|
||||
}
|
||||
|
||||
test('B10: diy.js 引入 routeQueryValue 且 setPublicShare 补 card_id', () => {
|
||||
const src = read('common/js/diy.js');
|
||||
assert.match(src, /import\s*\{[^}]*routeQueryValue[^}]*\}\s*from\s*['"]@\/common\/js\/card-share\.js['"]/,
|
||||
'未引入 card-share.js 的 routeQueryValue');
|
||||
const stripped = strip(src);
|
||||
const start = stripped.indexOf('setPublicShare()');
|
||||
const end = stripped.indexOf('onPageScroll');
|
||||
assert.ok(start > 0 && end > start, '未定位到 setPublicShare');
|
||||
const seg = stripped.slice(start, end);
|
||||
assert.match(seg, /routeQueryValue\('card_id'\)/, 'setPublicShare 未从落地 query 取 card_id');
|
||||
assert.match(seg, /appendPathParam\(shareUrl, 'card_id', shareCardId\)/, '分享链接未追加 card_id');
|
||||
});
|
||||
|
||||
test('B11: 行为级 — H5 落地带 card_id 时公众号分享链接续传', () => {
|
||||
const uni = makeUni();
|
||||
const share = loadCardShare(uni, () => ([{
|
||||
route: 'pages_tool/index/diy',
|
||||
options: { id: '3', card_id: 'C_1_shop_x' },
|
||||
}]));
|
||||
const setPublicShare = loadDiySetPublicShare(share);
|
||||
let captured = null;
|
||||
const ctx = {
|
||||
$config: { h5Domain: 'https://h5.example.com' },
|
||||
diyRoute: '/pages_tool/index/diy',
|
||||
id: 3,
|
||||
name: '',
|
||||
$util: { setPublicShare: d => { captured = d; }, img: s => s || '' },
|
||||
$langConfig: { getField: () => '标题' },
|
||||
diyData: { global: {} },
|
||||
siteInfo: null,
|
||||
};
|
||||
setPublicShare.call(ctx);
|
||||
assert.equal(captured.link, 'https://h5.example.com/pages_tool/index/diy?id=3&card_id=C_1_shop_x',
|
||||
'H5 分享链接未续传落地 card_id');
|
||||
});
|
||||
|
||||
test('B12: 行为级 — 裸 Tab(无 card_id)时 H5 分享链接行为零变化', () => {
|
||||
const uni = makeUni();
|
||||
const share = loadCardShare(uni, () => ([]));
|
||||
const setPublicShare = loadDiySetPublicShare(share);
|
||||
let captured = null;
|
||||
const ctx = {
|
||||
$config: { h5Domain: 'https://h5.example.com' },
|
||||
diyRoute: '/pages_tool/index/diy',
|
||||
id: 3,
|
||||
name: '',
|
||||
$util: { setPublicShare: d => { captured = d; }, img: s => s || '' },
|
||||
$langConfig: { getField: () => '标题' },
|
||||
diyData: { global: {} },
|
||||
siteInfo: null,
|
||||
};
|
||||
setPublicShare.call(ctx);
|
||||
assert.equal(captured.link, 'https://h5.example.com/pages_tool/index/diy?id=3',
|
||||
'无 card_id 时链接应保持原样(不得追加空参数)');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// C. 名片基件的分享按钮(diy-card-mini.vue)
|
||||
// =============================================================
|
||||
|
||||
test('C1: 小程序端分享按钮带 data-card-id + @tap 记忆', () => {
|
||||
const src = strip(read('components-diy/diy-card-mini.vue'));
|
||||
assert.match(src, /open-type="share"/, '分享按钮丢了 open-type=share');
|
||||
assert.match(src, /:data-card-id="card && card\.card_id"/,
|
||||
'分享按钮未带 data-card-id(dataset 通道依赖它)');
|
||||
assert.match(src, /@tap="rememberShareCard"/,
|
||||
'分享按钮未在 tap 时记住卡片(storage 通道依赖它)');
|
||||
});
|
||||
|
||||
test('C2: diy-card-mini 引入 rememberShareCardId 并实现 rememberShareCard', () => {
|
||||
const src = read('components-diy/diy-card-mini.vue');
|
||||
assert.match(src, /import\s*\{[^}]*rememberShareCardId[^}]*\}\s*from\s*['"]@\/common\/js\/card-share\.js['"]/);
|
||||
assert.match(src, /rememberShareCard\(\)\s*\{[\s\S]*?rememberShareCardId\(this\.card && this\.card\.card_id\)/);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// D. 「我的名片」组件把分享带来的 card_id 回传后端(diy-card-self.vue)
|
||||
// =============================================================
|
||||
|
||||
test('D1: diy-card-self 从路由 query 取 card_id 并传给 mycard', () => {
|
||||
const src = strip(read('components-diy/diy-card-self.vue'));
|
||||
assert.match(src, /routeQueryValue\('card_id'\)/,
|
||||
'未从路由 query 取分享带来的 card_id');
|
||||
assert.match(src, /card_id:\s*shareCardId/,
|
||||
'请求参数未带 card_id');
|
||||
});
|
||||
|
||||
test('D2: 分享卡优先于登录引导空态(fallbackMode=none 也照常取分享卡)', () => {
|
||||
const src = strip(read('components-diy/diy-card-self.vue'));
|
||||
assert.match(src, /!this\.hasToken && this\.fallbackMode === 'none' && !shareCardId/,
|
||||
'登录引导早退条件未排除"有分享卡"的情况');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// E. 全局兜底 mixin 的 dataset 分支仍在(其它页面继续受益)
|
||||
// =============================================================
|
||||
|
||||
test('E1: mp-share-default 的 dataset.cardId 分支未被破坏', () => {
|
||||
const src = strip(read('common/js/mp-share-default.js'));
|
||||
assert.match(src, /options\.target\.dataset\.cardId/,
|
||||
'全局兜底 mixin 的 dataset 分支丢了');
|
||||
assert.match(src, /path:\s*'\/pages_tool\/card\/detail\?card_id=' \+ d\.cardId/);
|
||||
});
|
||||
210
tests/diy-card-ab-visual.test.js
Normal file
210
tests/diy-card-ab-visual.test.js
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* 名片 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: 二维码档位归一化 + 尺寸表(2.23: flow/head/corner 三档 + rows/bottom 存量别名)', async () => {
|
||||
const m = await import('../components-diy/diy-card-common.js');
|
||||
// 2.23 对外三档: flow(卡片流内) / head(头部右侧) / corner(右上角浮层)
|
||||
for (const p of ['flow', 'head', 'corner']) {
|
||||
assert.equal(m.normalizeQrPosition(p), p);
|
||||
}
|
||||
// 存量别名: rows/bottom 仍归一化到自身(存量实例不炸), 渲染上等价 flow
|
||||
for (const p of ['rows', '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', 'flow', '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: 二维码渲染分支齐全(2.23: flow 流内 + head/corner 锚点 + rows/bottom 存量别名)', () => {
|
||||
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'/);
|
||||
// 2.23 流内档: flow + 存量 rows/bottom 归并到同一个流内挂点(位置由区块顺序决定)
|
||||
assert.match(miniSrc, /showQrFlow\(\)\s*\{[\s\S]*?this\.qrPos === 'flow' \|\| this\.qrPos === 'rows' \|\| this\.qrPos === 'bottom'/);
|
||||
// 模板挂点: 流内(区块流) / 头部右侧 / 右上角浮层
|
||||
assert.match(miniSrc, /v-if="blk === 'qr' && showQrFlow" class="mini-qr mini-qr-center"/);
|
||||
assert.match(miniSrc, /v-if="showQrCorner" class="mini-qr mini-qr-corner"/);
|
||||
assert.match(miniSrc, /v-if="showQrHead" class="mini-qr mini-qr-head"/);
|
||||
// 居中只加左右 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);
|
||||
});
|
||||
139
tests/diy-card-bio-social.test.js
Normal file
139
tests/diy-card-bio-social.test.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 名片 DIY 组件 ·「简介 / 社交媒体 / 标签 / 来源」契约测试(2.22, 2026-10-28)
|
||||
*
|
||||
* 背景: 名片 DIY 组件相对「名片基础信息」有字段遗漏(简介 bio 后端已下发但组件
|
||||
* 不渲染、社交媒体 socials[] 整族缺失、标签/来源标注也没有)。
|
||||
* 本次补齐, 且「简介」像资料区一样可独立成块 + 逐向微调。
|
||||
*
|
||||
* 覆盖:
|
||||
* 1. 简介区块: 开关 + 独立成块判定 + 逐向间距(prefix=bio, 复用 blockCustomSpacing)
|
||||
* 2. 社交/标签/来源: 字段读取与渲染
|
||||
* 3. 地址细分: address 行用完整地址覆盖 + 邮编独立成行
|
||||
* 4. 通用块样式工厂: blockIndependent / blockFinalStyle 的语义
|
||||
* 5. 反例: 不得出现「简介写死 mock」「社交恒为空」这类假绿
|
||||
*
|
||||
* 跑法: node --test tests/diy-card-bio-social.test.js
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DIY_DIR = path.resolve(__dirname, '../components-diy');
|
||||
const read = f => fs.readFileSync(path.join(DIY_DIR, f), 'utf8');
|
||||
// 先剥注释再断言 —— 注释里写「绝不能写 X」会被自判违规
|
||||
const strip = s => s
|
||||
.replace(/<!--[\s\S]*?-->/g, '')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
||||
|
||||
const miniSrc = strip(read('diy-card-mini.vue'));
|
||||
|
||||
test('简介区块: 开关 + 独立成块 + 逐向间距(bio 前缀)', () => {
|
||||
// 开关
|
||||
assert.match(miniSrc, /showBioBlock\(\)\s*\{[\s\S]*?flagOn\(this\.c\.showBio, false\)/,
|
||||
'缺少「显示简介」开关(flagOn(this.c.showBio))');
|
||||
// 数据源: 名片简介
|
||||
assert.match(miniSrc, /bioText\(\)\s*\{[\s\S]*?this\.card && this\.card\.bio/,
|
||||
'简介文本未取 card.bio');
|
||||
// 独立成块判定 + 逐向间距
|
||||
assert.match(miniSrc, /bioFinalStyle\(\)\s*\{[\s\S]*?blockFinalStyle\(this\.c, 'bio'\)/,
|
||||
'简介未走通用独立成块样式(blockFinalStyle(this.c, "bio"))');
|
||||
assert.match(miniSrc, /function blockFinalStyle\(c, prefix\)/,
|
||||
'缺少通用 blockFinalStyle 工厂');
|
||||
assert.match(miniSrc, /function blockCustomSpacing\(c, prefix\)/,
|
||||
'缺少通用逐向间距工厂(应复用而非新写一套)');
|
||||
// 逐向字段走**通用 prefix 拼接**(C 端不写字面量, 与资料区/二维码同一套)
|
||||
assert.match(miniSrc, /side\('MarginTop'\)|c\[prefix \+ 'MarginTop'\]/,
|
||||
'通用逐向工厂未按 prefix 拼字段名(bio 应复用同一套)');
|
||||
// 标题与行数
|
||||
assert.match(miniSrc, /bioTitleShow\(\)/, '缺少简介标题开关');
|
||||
assert.match(miniSrc, /bioMaxLines\(\)/, '缺少简介行数控制');
|
||||
});
|
||||
|
||||
test('简介渲染: 模板消费 bioText, 不是写死文案', () => {
|
||||
assert.match(miniSrc, /class="mini-bio"[\s\S]*?\{\{ bioText \}\}/,
|
||||
'简介块未渲染 bioText');
|
||||
// 反例: 不得出现写死的简介正文
|
||||
assert.doesNotMatch(miniSrc, /这是一段示例简介|示例简介文案/,
|
||||
'简介正文疑似写死');
|
||||
});
|
||||
|
||||
test('社交媒体: 读取 card.socials 并渲染, 可独立成块', () => {
|
||||
assert.match(miniSrc, /socialList\(\)\s*\{[\s\S]*?this\.card && Array\.isArray\(this\.card\.socials\)/,
|
||||
'社交列表未取 card.socials');
|
||||
assert.match(miniSrc, /showSocialBlock\(\)/, '缺少社交显示开关');
|
||||
assert.match(miniSrc, /socialFinalStyle\(\)\s*\{[\s\S]*?blockFinalStyle\(this\.c, 'social'\)/,
|
||||
'社交块未走通用独立成块样式');
|
||||
assert.match(miniSrc, /class="mini-social"[\s\S]*?v-for="\(s, i\) in socialList"/,
|
||||
'社交块未渲染列表');
|
||||
// 反例: 不得把社交写死成空
|
||||
assert.doesNotMatch(miniSrc, /socialList\(\)\s*\{\s*return \[\];/, '社交列表被写死为空');
|
||||
});
|
||||
|
||||
test('名片标签 / 来源标注 / 过期提示', () => {
|
||||
assert.match(miniSrc, /cardTags\(\)\s*\{[\s\S]*?this\.card && Array\.isArray\(this\.card\.tags\)/,
|
||||
'标签未取 card.tags');
|
||||
assert.match(miniSrc, /showTagsRow\(\)/, '缺少标签显示开关');
|
||||
assert.match(miniSrc, /sourceName\(\)\s*\{[\s\S]*?this\.card && this\.card\.source_name/,
|
||||
'来源标注未取 card.source_name');
|
||||
assert.match(miniSrc, /showSourceRow\(\)/, '缺少来源显示开关');
|
||||
assert.match(miniSrc, /cardExpired\(\)\s*\{[\s\S]*?this\.card && this\.card\.expired/,
|
||||
'过期标记未取 card.expired');
|
||||
assert.match(miniSrc, /class="mini-expired"/, '缺少过期遮罩元素');
|
||||
});
|
||||
|
||||
test('地址细分: address 行用完整地址覆盖 + 邮编独立成行', () => {
|
||||
assert.match(miniSrc, /decorateRows\(rows\)/, '缺少资料行装饰');
|
||||
assert.match(miniSrc, /this\.card && this\.card\.address_full/, '未取完整地址');
|
||||
assert.match(miniSrc, /this\.card && this\.card\.postal_code/, '未取邮编');
|
||||
assert.match(miniSrc, /field === 'address'/, '未按 address 字段覆盖');
|
||||
assert.match(miniSrc, /label: '邮编'/, '邮编未独立成行');
|
||||
// rowsInfo 必须走 decorateRows(否则覆盖不生效)
|
||||
assert.match(miniSrc, /rowsInfo\(\)\s*\{[\s\S]*?visibleRows\(this\.decorateRows\(this\.card\.rows\)/,
|
||||
'rowsInfo 未走 decorateRows');
|
||||
});
|
||||
|
||||
test('多语言: 请求带 lang 参数(后端按 _en 优先下发)', () => {
|
||||
for (const f of ['diy-card-show.vue', 'diy-card-self.vue', 'diy-card-list.vue']) {
|
||||
const src = read(f);
|
||||
assert.match(src, /lang: uni\.getStorageSync\('lang'\) \|\| 'zh-cn'/,
|
||||
f + ' 请求未带 lang 参数');
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 2.24: 简介文字样式可配 + meta 区块独立成块 + 有效期展示 ----
|
||||
|
||||
test('2.24 简介样式: 消费 bioSize/bioColor/bioLineHeight(后端补 3 键后可配)', () => {
|
||||
assert.match(miniSrc, /bioTextStyle\(\)\s*\{[\s\S]*?num\(c\.bioSize, 13\)/,
|
||||
'简介字号未读 bioSize(回退 13)');
|
||||
assert.match(miniSrc, /bioTextStyle\(\)\s*\{[\s\S]*?c\.bioColor \|\| c\.infoColor/,
|
||||
'简介颜色未读 bioColor(应回退 infoColor)');
|
||||
assert.match(miniSrc, /bioTextStyle\(\)\s*\{[\s\S]*?num\(c\.bioLineHeight, 22\)/,
|
||||
'简介行高未读 bioLineHeight(回退 22)');
|
||||
});
|
||||
|
||||
test('2.24 meta 区块: 与 bio/social 同款, 可独立成块 + 逐向间距', () => {
|
||||
assert.match(miniSrc, /metaFinalStyle\(\)\s*\{[\s\S]*?blockFinalStyle\(this\.c, 'meta'\)/,
|
||||
'meta 块未走通用独立成块样式(blockFinalStyle(this.c, "meta"))');
|
||||
// 模板挂上 metaFinalStyle
|
||||
assert.match(miniSrc, /class="mini-meta"[\s\S]*?:style="metaFinalStyle"/,
|
||||
'meta 块模板未挂 metaFinalStyle');
|
||||
// 区块流 token 含 meta(可参与排序)
|
||||
assert.match(miniSrc, /BLOCK_TOKENS = \['bio', 'rows', 'acts', 'social', 'qr', 'meta'\]/,
|
||||
'BLOCK_TOKENS 应含 meta token');
|
||||
});
|
||||
|
||||
test('2.24 有效期: expire_at 自动展示「有效期至」(无独立开关)', () => {
|
||||
assert.match(miniSrc, /showExpireRow\(\)\s*\{[\s\S]*?this\.card && num\(this\.card\.expire_at, 0\) > 0/,
|
||||
'有效期行未按 card.expire_at>0 判定');
|
||||
assert.match(miniSrc, /expireDateText\(\)\s*\{[\s\S]*?this\.card && this\.card\.expire_at/,
|
||||
'缺少有效期日期格式化');
|
||||
// 模板渲染「有效期至」且计入 meta 块可见性
|
||||
assert.match(miniSrc, /有效期至 \{\{ expireDateText \}\}/, '模板未渲染「有效期至」');
|
||||
assert.match(miniSrc, /showTagsRow \|\| showSourceRow \|\| showExpireRow/,
|
||||
'meta 块可见性未纳入有效期行');
|
||||
});
|
||||
98
tests/diy-card-block-order.test.js
Normal file
98
tests/diy-card-block-order.test.js
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 名片 DIY 组件 ·「区块顺序」契约测试(2.23, 2026-10-28)
|
||||
*
|
||||
* 背景: 简介 / 资料区 / 快捷操作 / 社交媒体 / 二维码 / 标签来源 六个「可独立成块」的
|
||||
* 模块此前是固定顺序(且快捷操作有 above/below 开关、二维码有 rows/bottom 档位),
|
||||
* 2.23 统一为**区块流**: 顺序存 c.blockOrder, C 端按序渲染, 面板用 ↑↓ 调整。
|
||||
*
|
||||
* 覆盖(锁三个踩过的坑):
|
||||
* 1. 区块流顺序解析(blockSeq): 配置优先 + 缺失 token 补齐 + 缺省按历史位置推导
|
||||
* 2. 模板结构: <template v-for> 内必须用**独立 v-if**(Vue2 的 v-else-if 链跨迭代串联)
|
||||
* 且 **template 不能带 :key**(Vue2 报错 "cannot be keyed")
|
||||
* 3. 位置开关下线: 快捷操作上/下由顺序推导; 二维码 head/corner 是锚点档, 不进流内
|
||||
*
|
||||
* 跑法: node --test tests/diy-card-block-order.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 miniSrc = strip(read('diy-card-mini.vue'));
|
||||
|
||||
test('区块流: 常量与顺序解析', () => {
|
||||
assert.match(miniSrc, /const BLOCK_TOKENS = \['bio', 'rows', 'acts', 'social', 'qr', 'meta'\]/,
|
||||
'缺少 BLOCK_TOKENS(六个可独立成块的模块)');
|
||||
assert.match(miniSrc, /const DEFAULT_BLOCK_ORDER = 'bio,acts,rows,social,qr,meta'/,
|
||||
'缺少默认顺序(快捷操作在资料区之上, 兼容历史 actsPosition=above)');
|
||||
assert.match(miniSrc, /blockSeq\(\)\s*\{[\s\S]*?this\.c\.blockOrder/,
|
||||
'blockSeq 未读 c.blockOrder');
|
||||
// 缺省回落: 按历史 actsPosition 推导快捷操作在资料区上/下
|
||||
assert.match(miniSrc, /if \(!list\.length\) \{[\s\S]*?normalizeActsPosition\(this\.c\.actsPosition\) === 'above'/,
|
||||
'缺省顺序未按历史 actsPosition 推导');
|
||||
// 未知 token 忽略 + 缺失 token 补齐
|
||||
assert.match(miniSrc, /BLOCK_TOKENS\.indexOf\(token\) >= 0 && list\.indexOf\(token\) < 0/,
|
||||
'未过滤未知/重复 token');
|
||||
assert.match(miniSrc, /def\.forEach\(\(t\) => \{[\s\S]*?if \(at < 0\) list\.unshift\(t\); else list\.splice\(at, 0, t\);/,
|
||||
'缺失 token 未按默认顺序补齐');
|
||||
});
|
||||
|
||||
test('区块流: 位置开关由顺序推导(旧开关只作兜底)', () => {
|
||||
assert.match(miniSrc, /actsAbove\(\)\s*\{\s*const i = this\.blockSeq\.indexOf\('acts'\);\s*const r = this\.blockSeq\.indexOf\('rows'\);[\s\S]*?return i < r;/,
|
||||
'actsAbove 未由区块顺序推导');
|
||||
// 二维码: head/corner 是锚点档, 其余(rows/bottom/flow)进流内
|
||||
assert.match(miniSrc, /showQrFlow\(\)\s*\{[\s\S]*?'flow'[\s\S]*?'rows'[\s\S]*?'bottom'/,
|
||||
'showQrFlow 未覆盖 flow/rows/bottom 三档');
|
||||
// 右上角浮层让位: 仅当资料区是流内首块
|
||||
assert.match(miniSrc, /if \(this\.blockSeq\[0\] !== 'rows'\) return \{\};/,
|
||||
'corner 让位未按区块顺序判据收窄');
|
||||
});
|
||||
|
||||
test('区块流模板: template v-for 内用独立 v-if, 且 template 不带 :key', () => {
|
||||
// Vue2: <template v-for> 内 v-else-if 链会跨迭代串联 → 必须全部独立 v-if
|
||||
assert.doesNotMatch(miniSrc, /v-else-if="blk/, 'template v-for 内不得使用 v-else-if 链');
|
||||
// Vue2: <template> cannot be keyed → key 放在真实元素上
|
||||
const tmpl = miniSrc.match(/<template v-for="blk in blockSeq"[^>]*>/g) || [];
|
||||
assert.equal(tmpl.length, 1, '应只有一个区块流 template');
|
||||
assert.doesNotMatch(tmpl[0] || '', /:key=/, 'template v-for 不得带 :key(Vue2 报错)');
|
||||
// key 下移到真实元素(快捷操作有宫格/芯片两个元素 → 真实元素数 7)
|
||||
const keyed = miniSrc.match(/v-if="blk === '(bio|acts|rows|qr|social|meta)'[^>]*:key="blk"/g) || [];
|
||||
assert.ok(keyed.length >= 6, '六个块的真实元素都应带 :key="blk", 实际 ' + keyed.length);
|
||||
for (const blk of ['bio', 'acts', 'rows', 'qr', 'social', 'meta']) {
|
||||
assert.match(miniSrc, new RegExp('v-if="blk === \'' + blk + '\'[^>]*:key="blk"'),
|
||||
'块 ' + blk + ' 的真实元素未带 :key');
|
||||
}
|
||||
});
|
||||
|
||||
test('区块流: 六个块都被渲染且样式各自独立', () => {
|
||||
for (const [blk, cls] of [['bio', 'mini-bio'], ['acts', 'mini-acts'], ['rows', 'mini-rows'],
|
||||
['social', 'mini-social'], ['qr', 'mini-qr'], ['meta', 'mini-meta']]) {
|
||||
assert.match(miniSrc, new RegExp('v-if="blk === \'' + blk + '\''), '缺少块 ' + blk + ' 的渲染分支');
|
||||
}
|
||||
assert.match(miniSrc, /class="mini-bio" :style="bioFinalStyle"/, '简介块未绑定独立样式');
|
||||
assert.match(miniSrc, /class="mini-social" :style="socialFinalStyle"/, '社交块未绑定独立样式');
|
||||
});
|
||||
|
||||
test('区块流: 通用工厂复用于新块(不新写一套)', () => {
|
||||
assert.match(miniSrc, /function blockIndependent\(c, prefix\)/, '缺少 blockIndependent 工厂');
|
||||
assert.match(miniSrc, /function blockFinalStyle\(c, prefix\)/, '缺少 blockFinalStyle 工厂');
|
||||
assert.match(miniSrc, /bioFinalStyle\(\)\s*\{\s*return blockFinalStyle\(this\.c, 'bio'\);/, '简介未走通用工厂');
|
||||
assert.match(miniSrc, /socialFinalStyle\(\)\s*\{\s*return blockFinalStyle\(this\.c, 'social'\);/, '社交未走通用工厂');
|
||||
});
|
||||
|
||||
test('二维码档位: 新增 flow(卡片流内)', () => {
|
||||
const common = strip(read('diy-card-common.js'));
|
||||
assert.match(common, /export const QR_POSITIONS = \['flow', 'rows', 'head', 'corner', 'bottom'\]/,
|
||||
'QR_POSITIONS 缺 flow 档');
|
||||
assert.match(common, /export const QR_SIZES = \{ flow: 240,/, 'QR_SIZES 缺 flow 尺寸');
|
||||
});
|
||||
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'));
|
||||
});
|
||||
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);
|
||||
});
|
||||
324
tests/diy-card-qr-image-source.test.js
Normal file
324
tests/diy-card-qr-image-source.test.js
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* 名片 DIY 组件 · 名片级自定义二维码(card.qr_image)契约测试
|
||||
*
|
||||
* 背景(2.20):
|
||||
* 此前二维码只有两个来源 —— 组件级自定义图(c.qrImage)与系统生成码(card.qr_url)。
|
||||
* 2.20 起每张名片(员工名片/企业名片)可各自持有一张自定义二维码图片,
|
||||
* DIY 组件按三层优先级取用:
|
||||
* 1) 名片级 card.qr_image ← 每张名片各自一张(「我的名片」登录后即见本人那张)
|
||||
* 2) 组件级 c.qrImage ← 装修期统一指定(名片自己没设图时生效)
|
||||
* 3) 系统码 card.qr_url ← 兜底(原有行为)
|
||||
*
|
||||
* 覆盖:
|
||||
* 1. mini 基件 qrSrc 按上述优先级取值(源码契约 + 真实求值双证)
|
||||
* 2. 组件级 qrImage 仍完整保留(未被名片级顶掉)
|
||||
* 3. 反例: 虚构字段不得出现(防恒真)
|
||||
*
|
||||
* 跑法: node --test tests/diy-card-qr-image-source.test.js
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DIY_DIR = path.resolve(__dirname, '../components-diy');
|
||||
const read = f => fs.readFileSync(path.join(DIY_DIR, f), 'utf8');
|
||||
// 先剥注释再断言 —— 注释里写「绝不能写 X」会被自判违规
|
||||
const strip = s => s
|
||||
.replace(/<!--[\s\S]*?-->/g, '')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
||||
|
||||
const miniSrc = strip(read('diy-card-mini.vue'));
|
||||
|
||||
/**
|
||||
* 从 mini.vue 抽出 qrSrc 的真实函数体并求值 —— 不靠正则猜逻辑,
|
||||
* 避免「正则匹配到注释/字符串就假绿」。
|
||||
*
|
||||
* 函数体内部用的是 this.c / this.card, 故以 ctx 为 this 求值。
|
||||
*/
|
||||
function extractQrSrc(src) {
|
||||
const m = src.match(/qrSrc\(\)\s*\{([\s\S]*?)\n\t\t\}/);
|
||||
assert.ok(m, 'mini.vue 找不到 qrSrc 计算属性');
|
||||
return m[1];
|
||||
}
|
||||
|
||||
/** 用构造器求值 qrSrc 函数体(只依赖 card/c 两个入参) */
|
||||
function evalQrSrc(card, config) {
|
||||
const body = extractQrSrc(miniSrc);
|
||||
// eslint-disable-next-line no-new-func
|
||||
const fn = new Function('card', 'c', body);
|
||||
// ctx 需提供 qrPriority 计算属性(Vue 里 qrSrc 依赖 this.qrPriority)
|
||||
const ctx = {
|
||||
card: card,
|
||||
c: config,
|
||||
get qrPriority() { return evalQrPriority(config); },
|
||||
};
|
||||
return fn.call(ctx, card, config);
|
||||
}
|
||||
|
||||
/** 抽 qrPriority 计算属性体并求值(模式白名单化) */
|
||||
function evalQrPriority(config) {
|
||||
const m = miniSrc.match(/qrPriority\(\)\s*\{([\s\S]*?)\n\t\t\}/);
|
||||
assert.ok(m, 'mini.vue 找不到 qrPriority 计算属性');
|
||||
// eslint-disable-next-line no-new-func
|
||||
const fn = new Function('c', m[1]);
|
||||
const ctx = { c: config };
|
||||
return fn.call(ctx, config);
|
||||
}
|
||||
|
||||
test('mini: qrSrc 源码按「名片级 > 组件级 > 系统码」取用', () => {
|
||||
const body = extractQrSrc(miniSrc);
|
||||
// 名片级: card.qr_image
|
||||
assert.ok(/card\.qr_image/.test(body), 'qrSrc 未消费名片级 card.qr_image');
|
||||
// 组件级: c.qrImage
|
||||
assert.ok(/c\.qrImage/.test(body), 'qrSrc 未保留组件级 c.qrImage');
|
||||
// 系统码兜底: card.qr_url
|
||||
assert.ok(/card\.qr_url/.test(body), 'qrSrc 未保留系统码兜底 card.qr_url');
|
||||
});
|
||||
|
||||
test('mini: qrSrc 真实求值 —— 名片级优先于组件级', () => {
|
||||
const got = evalQrSrc(
|
||||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||||
{ qrImage: '/upload/component.png' }
|
||||
);
|
||||
assert.equal(got, '/upload/own.png');
|
||||
});
|
||||
|
||||
test('mini: qrSrc 真实求值 —— 名片级为空时回落组件级', () => {
|
||||
const got = evalQrSrc(
|
||||
{ qr_image: '', qr_url: 'http://x/q' },
|
||||
{ qrImage: '/upload/component.png' }
|
||||
);
|
||||
assert.equal(got, '/upload/component.png');
|
||||
});
|
||||
|
||||
test('mini: qrSrc 真实求值 —— 两级皆空回落系统生成码(原行为)', () => {
|
||||
const got = evalQrSrc(
|
||||
{ qr_image: '', qr_url: 'http://x/q' },
|
||||
{ qrImage: '' }
|
||||
);
|
||||
assert.equal(got, 'http://x/q');
|
||||
});
|
||||
|
||||
test('mini: qrSrc 真实求值 —— 名片级优先(组件级也配了图时)', () => {
|
||||
// 「我的名片」组件: 员工登录后应看到属于该员工自己名片的那张图,
|
||||
// 即使装修时组件级也配了图 —— 名片级更贴近实体, 优先。
|
||||
const got = evalQrSrc(
|
||||
{ qr_image: '/upload/employee-own.png', qr_url: 'http://x/q' },
|
||||
{ qrImage: '/upload/shop-level.png' }
|
||||
);
|
||||
assert.equal(got, '/upload/employee-own.png');
|
||||
});
|
||||
|
||||
test('mini: qrSrc 真实求值 —— card 为 null 不抛异常(未登录空态)', () => {
|
||||
const got = evalQrSrc(null, { qrImage: '/upload/component.png' });
|
||||
assert.equal(got, '/upload/component.png');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 2.20.1 优先级模式(用户在 DIY 组件面板显式选择)
|
||||
// =============================================================
|
||||
|
||||
test('mini: qrPriority 白名单化 —— 缺失/非法值回落 card', () => {
|
||||
assert.equal(evalQrPriority({}), 'card');
|
||||
assert.equal(evalQrPriority({ qrPriority: '' }), 'card');
|
||||
assert.equal(evalQrPriority({ qrPriority: 'evil' }), 'card');
|
||||
assert.equal(evalQrPriority({ qrPriority: 'component' }), 'component');
|
||||
assert.equal(evalQrPriority({ qrPriority: 'system' }), 'system');
|
||||
});
|
||||
|
||||
test('mini: mode=card 名片级优先(默认; 员工登录看到自己那张)', () => {
|
||||
const got = evalQrSrc(
|
||||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||||
{ qrImage: '/upload/component.png', qrPriority: 'card' }
|
||||
);
|
||||
assert.equal(got, '/upload/own.png');
|
||||
});
|
||||
|
||||
test('mini: mode=card 名片级为空则用组件级', () => {
|
||||
const got = evalQrSrc(
|
||||
{ qr_image: '', qr_url: 'http://x/q' },
|
||||
{ qrImage: '/upload/component.png', qrPriority: 'card' }
|
||||
);
|
||||
assert.equal(got, '/upload/component.png');
|
||||
});
|
||||
|
||||
test('mini: mode=component 组件级优先于名片级', () => {
|
||||
const got = evalQrSrc(
|
||||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||||
{ qrImage: '/upload/component.png', qrPriority: 'component' }
|
||||
);
|
||||
assert.equal(got, '/upload/component.png');
|
||||
});
|
||||
|
||||
test('mini: mode=component 组件级为空则回落名片级', () => {
|
||||
const got = evalQrSrc(
|
||||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||||
{ qrImage: '', qrPriority: 'component' }
|
||||
);
|
||||
assert.equal(got, '/upload/own.png');
|
||||
});
|
||||
|
||||
test('mini: mode=system 只用系统生成码(忽略全部自定义图)', () => {
|
||||
const got = evalQrSrc(
|
||||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||||
{ qrImage: '/upload/component.png', qrPriority: 'system' }
|
||||
);
|
||||
assert.equal(got, 'http://x/q');
|
||||
});
|
||||
|
||||
test('mini: mode=system 名片级有图也忽略', () => {
|
||||
const got = evalQrSrc(
|
||||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||||
{ qrImage: '', qrPriority: 'system' }
|
||||
);
|
||||
assert.equal(got, 'http://x/q');
|
||||
});
|
||||
|
||||
test('mini: 未设置 qrPriority(存量实例)等价于 card', () => {
|
||||
const withMode = evalQrSrc(
|
||||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||||
{ qrImage: '/upload/component.png', qrPriority: 'card' }
|
||||
);
|
||||
const withoutMode = evalQrSrc(
|
||||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||||
{ qrImage: '/upload/component.png' }
|
||||
);
|
||||
assert.equal(withoutMode, withMode);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 面板契约(两个组件的 design.html 都要有来源优先级控件)
|
||||
// =============================================================
|
||||
|
||||
const PANEL_DIR = path.resolve(__dirname, '../../backend/src/addon/card/component/view');
|
||||
const hasPanels = fs.existsSync(path.join(PANEL_DIR, 'diy-card-self/design.html'));
|
||||
|
||||
if (hasPanels) {
|
||||
const selfPanel = strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-self/design.html'), 'utf8'));
|
||||
const showPanel = strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-show/design.html'), 'utf8'));
|
||||
|
||||
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
|
||||
test('面板(' + name + '): 二维码来源优先级三档控件均存在', () => {
|
||||
for (const v of ['card', 'component', 'system']) {
|
||||
assert.ok(
|
||||
panel.includes("nc.qrPriority = '" + v + "'"),
|
||||
name + ' 面板缺优先级选项 ' + v
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('面板(' + name + '): 来源控件在「显示二维码」打开时才显示', () => {
|
||||
// 控件所在容器必须带 v-show="nc.showQr"
|
||||
const idx = panel.indexOf("nc.qrPriority = 'card'");
|
||||
assert.ok(idx > 0, name + ' 面板找不到来源控件');
|
||||
const head = panel.slice(Math.max(0, idx - 1200), idx);
|
||||
assert.ok(head.includes('v-show="nc.showQr"'), name + ' 面板来源控件未随显示二维码开关联动');
|
||||
});
|
||||
}
|
||||
|
||||
test('面板: 优先级默认值与 config 注册表一致(card)', () => {
|
||||
// config/diy_view.php 的 util 段默认值
|
||||
const cfgPath = path.resolve(__dirname, '../../backend/src/addon/card/config/diy_view.php');
|
||||
const cfg = fs.readFileSync(cfgPath, 'utf8');
|
||||
assert.ok(cfg.includes('"qrPriority":"card"'), 'config/diy_view.php 缺 qrPriority 默认值');
|
||||
});
|
||||
|
||||
test('面板: 预览真实消费 qrPriority(3 处二维码挂点走分层 v-if)', () => {
|
||||
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
|
||||
// 2.23: 挂点由 4(head/corner/rows/bottom)合并为 3(head/corner/卡片流内) 各一组分支:
|
||||
// system 真实系统码 + component 组件图 + component 名片图 + card 名片图 + card 组件图 + 兜底真实系统码
|
||||
// ⚠ 计数只数**渲染分支**(v-if/v-else-if 挂在 .diy-card-qr__img 上的),
|
||||
// 「当前实际生效」提示块里也有 nc.qrPriority === 'system' 等判定, 不能计入。
|
||||
const sys = (panel.match(/class="diy-card-qr__img" v-if="nc\.qrPriority === 'system'"/g) || []).length;
|
||||
const comp = (panel.match(/class="diy-card-qr__img diy-card-qr__img--custom" v-else-if="nc\.qrPriority === 'component'/g) || []).length;
|
||||
assert.equal(sys, 3, name + ' 面板 system 档分支数不对: ' + sys);
|
||||
// component 档每挂点 2 支(组件图 + 名片图回落) = 6
|
||||
assert.equal(comp, 6, name + ' 面板 component 档分支数不对: ' + comp);
|
||||
// card 档每挂点 2 支(名片图 + 组件图回落) = 6
|
||||
const card = (panel.match(/class="diy-card-qr__img diy-card-qr__img--custom" v-else-if="nc\.qrPriority === 'card' &&/g) || []).length;
|
||||
assert.equal(card, 6, name + ' 面板 card 档分支数不对: ' + card);
|
||||
// 名片级图分支(每挂点 2 支: component 回落 + card 优先;
|
||||
// 每支出现 2 次: v-else-if 条件 + style 内取值) = 12
|
||||
// + 「当前实际生效」提示块内 2 次 = 14
|
||||
const cardImg = (panel.match(/nc\.tempData\.previewCard && nc\.tempData\.previewCard\.qr_image/g) || []).length;
|
||||
assert.equal(cardImg, 14, name + ' 面板名片级图分支数不对: ' + cardImg);
|
||||
// system 档与兜底档必须渲染真实系统码 qr_url(否则看不出真实出码方式)
|
||||
// 2.23: 3 挂点 × (system 档 + 兜底档) = 6
|
||||
const qrUrl = (panel.match(/nc\.tempData\.previewCard && nc\.tempData\.previewCard\.qr_url/g) || []).length;
|
||||
assert.equal(qrUrl, 6, name + ' 面板系统码 URL 分支数不对: ' + qrUrl);
|
||||
}
|
||||
});
|
||||
|
||||
test('面板: 有「当前实际生效」提示(切三档时商家能看到结果差异)', () => {
|
||||
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
|
||||
assert.ok(panel.includes('当前实际生效'), name + ' 面板缺当前生效来源提示');
|
||||
assert.ok(panel.includes('(微信小程序码)'), name + ' 面板提示未区分小程序码');
|
||||
assert.ok(panel.includes('(普通二维码)'), name + ' 面板提示未区分普通码');
|
||||
// 三档各自独立判定
|
||||
for (const mode of ['system', 'component', 'card']) {
|
||||
assert.ok(
|
||||
panel.includes("nc.qrPriority === '" + mode + "'"),
|
||||
name + ' 面板提示缺 ' + mode + ' 档判定'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('面板: 预览不得调用 window 全局函数(Vue2 模板作用域不可见)', () => {
|
||||
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
|
||||
assert.ok(
|
||||
!/cardQrPreviewSrc\s*\(/.test(panel),
|
||||
name + ' 面板仍调用 window 全局函数 —— Vue2 模板会报「not defined on the instance」'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 2026-10-27: 「快捷操作管理」图层行的 color input 空值报错
|
||||
// 图层行 v-for 数组曾只有 {k,ch}(无 bg/fg), 而同行 :value 引用 a.fg/a.bg
|
||||
// → undefined → Vue 写 elm.value='' → Chrome CSS color 格式告警
|
||||
test('面板: 图层行 v-for 数组含 bg/fg(避免 color input 空值)', () => {
|
||||
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
|
||||
const m = panel.match(/v-for="a in (\[\{k:'tel'[\s\S]*?\}\])"/);
|
||||
assert.ok(m, name + ' 面板找不到图层行 v-for 数组');
|
||||
const arr = m[1];
|
||||
const items = arr.match(/\{k:'[a-z]+'/g) || [];
|
||||
assert.equal(items.length, 5, name + ' 面板图层行数组项数不对: ' + items.length);
|
||||
const bgCount = (arr.match(/bg:'#[0-9A-Fa-f]{6}'/g) || []).length;
|
||||
const fgCount = (arr.match(/fg:'#[0-9A-Fa-f]{6}'/g) || []).length;
|
||||
assert.equal(bgCount, 5, name + ' 面板图层行数组缺 bg: ' + bgCount);
|
||||
assert.equal(fgCount, 5, name + ' 面板图层行数组缺 fg: ' + fgCount);
|
||||
}
|
||||
});
|
||||
|
||||
test('面板: 图层行 color input 的 :value 不含未定义引用', () => {
|
||||
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
|
||||
const colorBind = panel.match(/data-field="color"[^>]*:value="([^"]*)"/);
|
||||
const bgBind = panel.match(/data-field="bg"[^>]*:value="([^"]*)"/);
|
||||
assert.ok(colorBind, name + ' 面板找不到图层行 color 绑定');
|
||||
assert.ok(bgBind, name + ' 面板找不到图层行 bg 绑定');
|
||||
for (const [label, expr] of [['color', colorBind[1]], ['bg', bgBind[1]]]) {
|
||||
const refs = (expr.match(/\ba\.([a-z]+)/g) || []).map(x => x.slice(2));
|
||||
for (const r of Array.from(new Set(refs))) {
|
||||
assert.ok(
|
||||
['k', 'ch', 'lb', 'bg', 'fg'].includes(r),
|
||||
name + ' 面板图层行 ' + label + ' 绑定引用了数组里不存在的 a.' + r
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// 反例(防恒真)
|
||||
// =============================================================
|
||||
|
||||
test('反例: 虚构字段绝不出现在基件', () => {
|
||||
assert.ok(!miniSrc.includes('c.qrMagic'));
|
||||
assert.ok(!miniSrc.includes('card.qrMagic'));
|
||||
});
|
||||
455
tests/diy-card-qr-style.test.js
Normal file
455
tests/diy-card-qr-style.test.js
Normal file
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* 名片 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')) : '';
|
||||
const BOX_STYLE_FIELDS = [
|
||||
'bandShow', 'bandBgMode', 'bandBg',
|
||||
'wrapMarginTop', 'wrapMarginRight', 'wrapMarginBottom', 'wrapMarginLeft',
|
||||
'headBgMode', 'headBg', 'headRadiusMode', 'headRadius', 'headSpacingCustom',
|
||||
'headMarginTop', 'headMarginRight', 'headMarginBottom', 'headMarginLeft',
|
||||
'headPaddingTop', 'headPaddingRight', 'headPaddingBottom', 'headPaddingLeft',
|
||||
];
|
||||
|
||||
/**
|
||||
* 面板控件的字段绑定有三种形态(2026-10-21 面板改版: 下拉框 → 可视化选项组,
|
||||
* 数字框 → 滑块):
|
||||
* ① 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: 顶部色带默认隐藏,背景支持同步名片设计或独立颜色', () => {
|
||||
assert.match(miniSrc, /class="mini-card-wrap"/);
|
||||
assert.match(miniSrc, /<view v-if="bandShow" class="mini-band" :style="bandStyle"><\/view>/);
|
||||
assert.match(miniSrc, /bandShow\(\)\s*\{\s*return flagOn\(this\.c\.bandShow, false\)/);
|
||||
assert.match(miniSrc, /bandStyle\(\)\s*\{[\s\S]*?this\.c\.bandBgMode === 'custom'[\s\S]*?themeGradient\(this\.card\)/);
|
||||
assert.match(miniSrc, /class="mini-card fade-up" :style="wrapStyle"/);
|
||||
// 保留原色带造型,显隐和背景只改内联消费,不改几何结构。
|
||||
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;/);
|
||||
assert.ok(!miniSrc.includes('mini-block'));
|
||||
});
|
||||
|
||||
test('mini: 卡片四向外边距与头部盒模型配置完整消费', () => {
|
||||
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
|
||||
assert.match(miniSrc, new RegExp('margin' + side + ': px2rpx\\(num\\(c\\.wrapMargin' + side + ', 0\\)\\)'));
|
||||
assert.match(miniSrc, new RegExp('c\\.headMargin' + side));
|
||||
assert.match(miniSrc, new RegExp('c\\.headPadding' + side));
|
||||
}
|
||||
assert.match(miniSrc, /headBoxStyle\(\)\s*\{[\s\S]*?c\.headBgMode === 'custom'[\s\S]*?this\.headTheme \? themeGradient\(this\.card\) : 'transparent'/);
|
||||
assert.match(miniSrc, /headThemeStyle\(\)\s*\{\s*return this\.headBoxStyle/);
|
||||
assert.match(miniSrc, /plainHeadFinalStyle\(\)\s*\{[\s\S]*?this\.cornerHeadStyle[\s\S]*?return \[this\.headBoxStyle, corner\]/);
|
||||
});
|
||||
|
||||
test('mini: C端盒模型计算值——历史默认兼容且自定义值逐项生效', async () => {
|
||||
const m = await import('../components-diy/diy-card-common.js');
|
||||
const share = await import('../common/js/card-share.js');
|
||||
// 2026-10-28: mini 组件新增了 '@/common/js/card-share.js' 的 import(分享带名片 ID),
|
||||
// 原来只剥离 './diy-card-common.js' 一条 → 每加一个 import 就崩一次;
|
||||
// 改为**通用剥离所有 import 语句**, 被剥离的标识符改为按需注入。
|
||||
const script = miniSrc.match(/<script>([\s\S]*?)<\/script>/)[1]
|
||||
.replace(/^\s*import\s+[^;]+;\s*$/gm, '')
|
||||
.replace('export default {', 'module.exports = {');
|
||||
const names = [...Object.keys(m), ...Object.keys(share)];
|
||||
const moduleBox = { exports: {} };
|
||||
new Function('module', ...names, script)(moduleBox, ...names.map(k => (m[k] !== undefined ? m[k] : share[k])));
|
||||
const computed = moduleBox.exports.computed;
|
||||
const util = { objToStyle: o => o };
|
||||
const card = { theme_color: '#119DFF', gradient_end: '#0E81D1', gradient_angle: 135 };
|
||||
|
||||
assert.equal(computed.bandShow.call({ c: {} }), false, '历史配置色带必须默认隐藏');
|
||||
assert.equal(computed.bandStyle.call({ c: {}, card, $util: util }).background.includes('linear-gradient'), true);
|
||||
assert.equal(computed.bandStyle.call({ c: { bandBgMode: 'custom', bandBg: '#123456' }, card, $util: util }).background, '#123456');
|
||||
|
||||
const wrap = computed.wrapStyle.call({
|
||||
c: { wrapMarginTop: 11, wrapMarginRight: 12, wrapMarginBottom: 13, wrapMarginLeft: 14, padding: 22 },
|
||||
card, $util: util,
|
||||
});
|
||||
assert.deepEqual([wrap.marginTop, wrap.marginRight, wrap.marginBottom, wrap.marginLeft], ['22rpx', '24rpx', '26rpx', '28rpx']);
|
||||
assert.equal(wrap.padding, '44rpx');
|
||||
|
||||
const autoHead = computed.headBoxStyle.call({ c: { padding: 16 }, headTheme: true, card, $util: util });
|
||||
assert.deepEqual(
|
||||
[autoHead.marginTop, autoHead.marginRight, autoHead.marginBottom, autoHead.marginLeft],
|
||||
['-32rpx', '-32rpx', '0rpx', '-32rpx']
|
||||
);
|
||||
assert.deepEqual(
|
||||
[autoHead.paddingTop, autoHead.paddingRight, autoHead.paddingBottom, autoHead.paddingLeft],
|
||||
['32rpx', '32rpx', '28rpx', '32rpx']
|
||||
);
|
||||
assert.equal(autoHead.borderRadius, '0 0 16rpx 16rpx');
|
||||
const customCfg = {
|
||||
headBgMode: 'custom', headBg: '#ABCDEF', headRadiusMode: 'custom', headRadius: 18, headSpacingCustom: 1,
|
||||
headMarginTop: 1, headMarginRight: 2, headMarginBottom: 3, headMarginLeft: 4,
|
||||
headPaddingTop: 5, headPaddingRight: 6, headPaddingBottom: 7, headPaddingLeft: 8,
|
||||
};
|
||||
for (const headTheme of [true, false]) {
|
||||
const style = computed.headBoxStyle.call({ c: customCfg, headTheme, card, $util: util });
|
||||
assert.equal(style.background, '#ABCDEF');
|
||||
assert.equal(style.borderRadius, '36rpx');
|
||||
assert.deepEqual([style.marginTop, style.marginRight, style.marginBottom, style.marginLeft], ['2rpx', '4rpx', '6rpx', '8rpx']);
|
||||
assert.deepEqual([style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft], ['10rpx', '12rpx', '14rpx', '16rpx']);
|
||||
}
|
||||
});
|
||||
|
||||
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"/);
|
||||
// 2.23: 挂点由 4(head/corner/rows/bottom)合并为 3(head 双分支 / corner / 卡片流内)
|
||||
const tipBinds = miniSrc.match(/class="qr-tip" :style="labelStyle"/g) || [];
|
||||
assert.equal(tipBinds.length, 4, '三挂点 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\)/);
|
||||
// 2.23: tip 挂点 = corner 1 + 卡片流内 1 + head 档 2(head 双形态分支互斥渲染)
|
||||
const tips = miniSrc.match(/<text v-if="showQrTip" class="qr-tip"/g) || [];
|
||||
assert.equal(tips.length, 4, 'tip 挂点(3 挂点 + 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 + 2.20 逐向微调右位移(0=关)
|
||||
assert.match(miniSrc, /const customRight = flagOn\(this\.c\.qrSpacingCustom, false\) \? px2rpx\(num\(this\.c\.qrMarginRight, 0\)\) : 0;/);
|
||||
assert.match(miniSrc, /paddingRight: \(size \+ pad \+ 40 \+ customRight\) \+ 'rpx'/);
|
||||
// 模板头部绑定了动态让位(浮层不存在时输出空对象不占位)
|
||||
assert.match(miniSrc, /<view class="mini-head" :style="cornerHeadStyle">/);
|
||||
});
|
||||
|
||||
test('mini: 二维码块样式绑定——corner/head 走 qrBlockStyle, 卡片流内走 qrBlockFinalStyle(2.23 合并 rows/bottom)', () => {
|
||||
const direct = miniSrc.match(/:style="qrBlockStyle"/g) || [];
|
||||
assert.equal(direct.length, 3, 'qrBlockStyle 直绑(corner 1 + head 双分支 2), 实际 ' + direct.length);
|
||||
// 2.23: rows/bottom 两处合并为「卡片流内」一处(位置由区块顺序决定)
|
||||
const final = miniSrc.match(/:style="qrBlockFinalStyle"/g) || [];
|
||||
assert.equal(final.length, 1, 'qrBlockFinalStyle(卡片流内 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: miniConfig 透传色带、卡片外边距与头部盒模型字段', () => {
|
||||
for (const f of BOX_STYLE_FIELDS) {
|
||||
assert.match(selfSrc, new RegExp(f + ':\\s*v\\.' + f + ','), 'miniConfig 缺透传 ' + f);
|
||||
}
|
||||
});
|
||||
|
||||
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',
|
||||
...BOX_STYLE_FIELDS,
|
||||
];
|
||||
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 + ' 面板: 色带/卡片/头部盒模型字段均有控件且预览真实消费', () => {
|
||||
const preview = panel.split('<template slot="edit-content">')[0];
|
||||
for (const f of BOX_STYLE_FIELDS) {
|
||||
assert.ok(bound(panel, f), '面板缺控件绑定 ' + f);
|
||||
assert.ok(preview.includes('nc.' + f), '预览缺消费 ' + f);
|
||||
}
|
||||
assert.match(panel, /nc\.bandBgMode = 'theme'/);
|
||||
assert.match(panel, /nc\.bandBgMode = 'custom'/);
|
||||
assert.match(panel, /nc\.headBgMode = 'auto'/);
|
||||
assert.match(panel, /nc\.headBgMode = 'custom'/);
|
||||
});
|
||||
|
||||
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'));
|
||||
});
|
||||
Reference in New Issue
Block a user