Files
lucky_shop/tests/diy-card-style-v2.test.js
ZF sun 1de3146c3a feat(card-diy): 2.19.10 C 端消费 detail 对齐 + 区块独立样式字段
- mini.vue: 8 detail 字段消费(三列行/复制范围外观/QR 多行/URL 行/hero 徽章)
  + rowsBlockStyle/qrBlockCustomStyle computed(与操作区 actsBlockStyle 同款模式)
  + rowsFinalStyle/qrBlockFinalStyle 合并独立块样式
- show.vue/self.vue: miniConfig 透传 14 字段(8 detail + 6 独立样式)
- 测试 ×3: 2.19.10 契约锁(配置层/透传/面板/预览消费/反例: layoutCards 已移除)

前端 7 套 123 项全绿
2026-10-24 17:03:57 +08:00

780 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 名片 DIY 组件 · 2.17 双形态(渐变头部/宫格快捷操作/资料区标题/复制按钮)契约测试
*
* 锁死三层口径: diy_view.php 默认值 ↔ design.html 面板控件与预览 ↔ C 端 vue 消费
* 兼容铁律(2026-10-24 起分两层):
* ① 基件 flagOn/normalize 缺字段回落历史形态(plain/chips/关) —— 本测试 108-241 行
* 断言保持不变, 保护任何缺字段的存量行渲染零变化;
* ② diy_view.php config 默认值代际升级为参考图形态(theme/grid/开/above) ——
* 配套迁移(test/card_diy_instance_style_migrate.php)把注册表行补缺 + 存量页面
* 实例按整值指纹刷新, 存量行不再依赖①的回落, 故 config 默认值断言随代际更新。
*
* 同时锁死 2.17 修复: self.vue miniConfig 补齐 7 个字号字段透传
* (diy-card-show.vue 自 2026-10-21 起改为自绘, 不再经 miniConfig 复用 diy-card-mini,
* 配置由 computed 直读 this.c, 故仅对仍走 v 对象架构的 self.vue 锁死透传)
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BE = resolve(__dirname, '../../backend/src/addon/card');
const FE = resolve(__dirname, '../components-diy');
/** 2.17 新字段 → 默认值(2026-10-24 代际升级: 参考图形态, 见文件头注释) */
const V2_FIELDS = {
headStyle: 'theme',
headNameColor: '#FFFFFF',
headSubColor: '#FFFFFF',
actsLayout: 'grid',
actGridColorMode: 'preset',
showRowsTitle: 1,
rowsTitle: '联系资料',
showCopyBtn: 1,
};
/** 2.18 新字段 → 默认值(2026-10-24 代际升级: actsPosition above) */
const V18_FIELDS = {
actsPosition: 'above',
actsBlockBg: '',
actsBlockRadius: 0,
actsBlockPad: 0,
qrImage: '',
};
const V18_ACT_TYPES = ['tel', 'wx', 'mail', 'web', 'save'];
const PHP_NAMES = { show: 'DiyCardShow', self: 'DiyCardSelf' };
function readValueJson(component) {
const php = readFileSync(resolve(BE, 'config/diy_view.php'), 'utf8');
const re = new RegExp("'name'\\s*=>\\s*'" + PHP_NAMES[component] + "'[\\s\\S]*?'value'\\s*=>\\s*'([^\']+)'");
const m = php.match(re);
assert.ok(m, 'diy_view.php 中找到组件 ' + PHP_NAMES[component]);
return JSON.parse(m[1]);
}
function readMiniVue() {
return readFileSync(resolve(FE, 'diy-card-mini.vue'), 'utf8');
}
function readCommonJs() {
return readFileSync(resolve(FE, 'diy-card-common.js'), 'utf8');
}
test('diy_view.php: Show/Self value 均含 8 个 2.17 字段且默认值=参考图形态(2026-10-24 代际升级)', () => {
for (const comp of ['show', 'self']) {
const v = readValueJson(comp);
for (const [field, dft] of Object.entries(V2_FIELDS)) {
assert.ok(v[field] !== undefined, comp + '.' + field + ' 已注册');
assert.equal(v[field], dft, comp + '.' + field + ' 默认值=' + JSON.stringify(dft));
}
}
});
test('diy_view.php: Show/Self value 均含 2.18 字段(actIcons 五类型完整结构, actsPosition=above 代际升级)', () => {
for (const comp of ['show', 'self']) {
const v = readValueJson(comp);
for (const [field, dft] of Object.entries(V18_FIELDS)) {
assert.ok(v[field] !== undefined, comp + '.' + field + ' 已注册');
assert.deepEqual(v[field], dft, comp + '.' + field + ' 默认值=' + JSON.stringify(dft));
}
// actIcons: 必须是完整五类型结构(旧行 refreshComponent 深拷贝补全后, 面板
// v-model="nc.actIcons[type].xxx" 才不会因缺键报错); 2.19 起含 iconKey(素材库)
assert.ok(v.actIcons && typeof v.actIcons === 'object', comp + '.actIcons 已注册');
for (const t of V18_ACT_TYPES) {
assert.deepEqual(v.actIcons[t], { img: '', iconKey: '', svg: '', color: '', bg: '' }, comp + '.actIcons.' + t + ' 结构完整(含 svg 预览快照位)');
}
}
});
test('common.js: normalizeActsPosition + actIconCfgOf(2.18)', () => {
const js = readCommonJs();
const consts = js.match(/export const ACTS_POSITIONS = [^;]+;/)[0].replace('export const', 'const');
const pos = new Function('v', consts + '\n' + js.match(/export function normalizeActsPosition[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '; return normalizeActsPosition(v);');
assert.equal(pos(undefined), 'below', '缺省回落 below(历史形态)');
assert.equal(pos('nonsense'), 'below', '非法值回落 below');
assert.equal(pos('above'), 'above', 'above 透传');
assert.equal(pos('below'), 'below', 'below 透传');
const cfgOf = new Function('actIcons', 'type', js.match(/export function actIconCfgOf[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '; return actIconCfgOf(actIcons, type);');
assert.deepEqual(cfgOf(undefined, 'tel'), {}, 'actIcons 缺省 → 空对象');
assert.deepEqual(cfgOf({}, 'tel'), {}, 'actIcons 空表 → 空对象');
assert.deepEqual(cfgOf({ tel: { img: 'a.png' } }, 'tel'), { img: 'a.png' }, '按 type 取配置');
assert.deepEqual(cfgOf({ tel: 'bad' }, 'tel'), {}, '非对象配置 → 空对象');
});
test('common.js: normalize 非法/缺省值回落历史默认(兼容 1.0 铁律)', () => {
const js = readCommonJs();
// 函数体引用模块常量(HEAD_STYLES 等), 重构执行时一并提供作用域
const consts = js.match(/export const (HEAD_STYLES|ACTS_LAYOUTS|QR_POSITIONS|QR_STYLES) = [^;]+;/g)
.map(s => s.replace('export const ', 'const ')).join('\n');
for (const [fn, arg, expect, desc] of [
['normalizeHeadStyle', undefined, 'plain', '缺省回落 plain'],
['normalizeHeadStyle', 'nonsense', 'plain', '非法值回落 plain'],
['normalizeHeadStyle', 'theme', 'theme', 'theme 透传'],
['normalizeActsLayout', undefined, 'chips', '缺省回落 chips'],
['normalizeActsLayout', 'nonsense', 'chips', '非法值回落 chips'],
['normalizeActsLayout', 'grid', 'grid', 'grid 透传'],
['normalizeActGridColorMode', undefined, 'preset', '缺省回落 preset'],
['normalizeActGridColorMode', 'theme', 'theme', 'theme 透传'],
['normalizeActGridColorMode', 'nonsense', 'preset', '非法值回落 preset'],
]) {
const re = new RegExp('export function ' + fn + '\\(([^)]*)\\)\\s*\\{([\\s\\S]*?)\\n}');
const m = js.match(re);
assert.ok(m, 'common.js 存在 ' + fn);
const body = m[0].replace(/^export function /, 'function ');
const fnObj = new Function('v', consts + '\n' + body + '; return ' + fn + '(v);');
assert.equal(fnObj(arg), expect, fn + ': ' + desc);
}
});
test('common.js: hexToRgba 换算与反例 + ACT_GRID_ICONS 字形映射', () => {
const js = readCommonJs();
const hexToRgba = new Function('hex', 'alpha', js.match(/export function hexToRgba[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '; return hexToRgba(hex, alpha);');
assert.equal(hexToRgba('#2F6BFF', 0.12), 'rgba(47,107,255,0.12)');
assert.equal(hexToRgba('2F6BFF', 0.5), 'rgba(47,107,255,0.5)');
assert.equal(hexToRgba('#F8A', 0.2), 'rgba(255,136,170,0.2)', '3 位短写法展开(复审修复)');
assert.equal(hexToRgba('#XYZ', 0.5), null, '非法 hex 返回 null');
assert.equal(hexToRgba('', 0.5), null, '空串返回 null');
const icons = js.match(/export const WAP_LINE_ICONS = \{[\s\S]*?\n\};/)[0];
// 2.18.1: 宫格图标与 wap detail 快捷操作同源(五枚 SVG 线稿), wx/save 固定色
assert.match(icons, /tel:\s*\{\s*mode:\s*'stroke'/);
assert.match(icons, /wx:\s*\{\s*mode:\s*'fill',\s*color:\s*'#07C160'/, 'wx 微信绿固定色(detail 同源)');
assert.match(icons, /mail:\s*\{\s*mode:\s*'stroke'/);
assert.match(icons, /web:\s*\{\s*mode:\s*'stroke'/);
assert.match(icons, /save:\s*\{\s*mode:\s*'stroke',\s*color:\s*'#FF9500'/, 'save 通讯录橙固定色(detail 同源)');
// svgLineIconBg: data URI 生成(stroke 模式颜色内嵌 / fill 模式固定色)
// 注入依赖: WAP_LINE_ICONS 常量 + 私有 b64ascii(含 B64T 字符表)
const wapConsts = js.match(/export const WAP_LINE_ICONS = \{[\s\S]*?\n\};/)[0].replace('export const', 'const');
const b64fn = js.match(/const B64T = [^;]+;[\s\S]*?function b64ascii\(s\) \{[\s\S]*?\n\}/)[0];
const svgBg = new Function('type', 'color', wapConsts + '\n' + b64fn + '\n' + js.match(/export function svgLineIconBg[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '; return svgLineIconBg(type, color);');
const telBg = svgBg('tel', '#0F6E56');
assert.match(telBg, /^url\("data:image\/svg\+xml;base64,/, 'data URI base64 形态');
assert.ok(atob(telBg.replace(/^url\("data:image\/svg\+xml;base64,/, '').replace(/"\)$/, '')).indexOf('stroke="#0F6E56"') >= 0, 'stroke 色内嵌');
const wxBg = svgBg('wx', '#123456');
assert.ok(atob(wxBg.replace(/^url\("data:image\/svg\+xml;base64,/, '').replace(/"\)$/, '')).indexOf('fill="#07C160"') >= 0, 'wx 固定绿不随入参色');
assert.equal(svgBg('unknown', '#000'), '', '未知类型空串(首字兜底)');
// save 入多彩预设(紫色档)
assert.match(js, /save:\s*\{\s*bg:\s*'#F0E9FB',\s*fg:\s*'#5B3DA8'/, '宫格 preset 补 save 紫色档');
});
test('common.js: rowCssIconFor 资料行 CSS 图标(2.17.1)', () => {
const js = readCommonJs();
const rowConsts = js.match(/const ROW_CSS_ICONS = \{[\s\S]*?\};/)[0];
const fn = new Function('field', rowConsts + '\n' + js.match(/export function rowCssIconFor[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '; return rowCssIconFor(field);');
assert.equal(fn('email'), 'mail', '邮箱行走 CSS 信封');
assert.equal(fn('website'), 'web', '网站行走 CSS 线框地球');
assert.equal(fn('mobile'), '', '手机行仍用 iconfont 字形(rowIconFor 口径)');
assert.equal(fn(''), '');
assert.equal(fn(undefined), '');
});
test('mini.vue: theme 头部渲染分支 + 白字 fallback + 负 margin 通铺', () => {
const src = readMiniVue();
assert.match(src, /headTheme\(\)\s*\{[\s\S]*?normalizeHeadStyle\(this\.c\.headStyle\)\s*===\s*'theme'/, 'headTheme 消费 normalizeHeadStyle');
assert.match(src, /v-if="headTheme"[\s\S]*?mini-headtheme/, 'theme 档渲染渐变头部块');
assert.match(src, /v-if="!headTheme"[\s\S]*?class="mini-head"/, 'plain 档渲染白卡头部');
// 白字 fallback: headNameColor 缺省 → #FFFFFF; 副行 → 白 85%
assert.match(src, /this\.c\.headNameColor \|\| '#FFFFFF'/, '头部姓名色 fallback 白');
assert.match(src, /this\.c\.headSubColor \|\| 'rgba\(255,255,255,0\.85\)'/, '头部副行色 fallback 白 85%');
// 负 margin 通铺(不用 calc): margin: -{pad}rpx -{pad}rpx 0
assert.match(src, /margin: '-' \+ pad \+ 'rpx -' \+ pad \+ 'rpx 0'/, '头部块负 margin 通铺卡边');
// 空态提示不随 theme 变白(独立 emptyTipStyle)
assert.match(src, /emptyTipStyle\(\)/, '空态提示独立样式');
assert.doesNotMatch(src, /:style="subTextStyle">\{\{ emptyTip/, '空态不再复用 subTextStyle');
});
test('mini.vue: 宫格快捷操作分支 + preset/theme 双配色 + 首字兜底', () => {
const src = readMiniVue();
assert.match(src, /actsGrid\(\)\s*\{[\s\S]*?normalizeActsLayout\(this\.c\.actsLayout\)\s*===\s*'grid'/, 'actsGrid 消费 normalizeActsLayout');
// 2.18.1: 宫格图标 = SVG 线稿(与 wap detail 同源), img 覆盖优先, 未知类型首字兜底
assert.match(src, /mini-acts-grid[\s\S]*?actSvgOf\(act\)/, '宫格渲染用 actSvgOf');
assert.match(src, /v-else-if="actSvgOf\(act\)" class="act-grid-svg" :style="actSvgStyle\(act\)"/, 'SVG 线稿分支');
assert.match(src, /actInitialOf\(act\)/, '未知类型首字兜底');
assert.doesNotMatch(src, /act-grid-glyph/, 'iconfont 字形档已退出宫格(全 SVG)');
assert.match(src, /gridColorMode\(\)\s*\{[\s\S]*?normalizeActGridColorMode\(this\.c\.actGridColorMode\)/, '宫格配色模式消费 normalizeActGridColorMode');
// theme 档浅底 = detail 同款 145deg 12%→20% 主题渐变
assert.match(src, /linear-gradient\(145deg, ' \+ \(hexToRgba\(theme, 0\.12\) \|\| '#F2F3F5'\) \+ ', ' \+ \(hexToRgba\(theme, 0\.2\) \|\| '#F2F3F5'\)/, 'theme 档浅主题渐变底(detail 同源)');
assert.match(src, /ACT_GRID_PRESETS\[\(act && act\.type\) \|\| ''\] \|\| ACT_GRID_PRESET_FALLBACK/, 'preset 色板 + fallback');
// 带参样式函数必须在 methods(computed 不支持参数)
const methods = src.match(/methods:\s*\{[\s\S]*$/)[0];
assert.match(methods, /actGridIconStyle\(act\)/, 'actGridIconStyle 在 methods');
assert.match(methods, /actGridGlyphStyle\(act\)/, 'actGridGlyphStyle 在 methods');
// 2.18.1: 图标色口径 = 每按钮 color > detail 固定色(wx绿/save橙) > theme > preset
assert.match(methods, /actIconColor\(act\)[\s\S]*?cfgColor[\s\S]*?ico && ico\.color[\s\S]*?gridColorMode === 'theme'/, 'actIconColor 四级口径');
assert.match(methods, /actSvgStyle\(act\)\s*\{[\s\S]*?svgLineIconBg\(act && act\.type,\s*this\.actIconColor\(act\)\)/, 'actSvgStyle 消费 svgLineIconBg + actIconColor');
assert.match(src, /import \{[\s\S]*?WAP_LINE_ICONS, svgLineIconBg/, 'mini.vue 引入 SVG 图标源');
// CSS 图标基础样式仍须顶层定义(资料行 email/website 行还在用 --row 版)
assert.match(src, /\n\.css-ico-mail \{/, 'css-ico-mail 顶层定义(资料行消费)');
assert.match(src, /\n\.css-ico-web \{/, 'css-ico-web 顶层定义(资料行消费)');
// 5 动作一行: 列宽按 acts 数量动态(20% / 25%)
assert.match(src, /gridItemWidth\(\)\s*\{[\s\S]*?acts\.length > 4 \? '20%' : '25%'/, '宫格列宽 5 动作 20%');
assert.match(src, /:style="\{ width: gridItemWidth \}"/, '宫格列宽内联绑定');
// WXSS 禁 calc(): CSS 图标规则体内不得出现 calc(限定规则体内, 避免注释「不支持 calc()」字样假阳性)
assert.doesNotMatch(src, /css-ico[a-z-]*\s*\{[^{}]*calc\(/, 'CSS 描边图标无 calc');
});
test('mini.vue + show/self: save(存通讯录)动作全链路(2.17.1)', () => {
// 基件: 资料行 CSS 图标(邮箱/网站)与行图标开关
const src = readMiniVue();
assert.match(src, /rowCssIcon\(row\)\s*\{[\s\S]*?rowCssIconFor\(row && row\.field\)/, '资料行 CSS 图标消费 rowCssIconFor');
assert.match(src, /v-if="rowCssIcon\(row\)"[\s\S]*?css-ico-mail--row/, '资料行渲染缩小版信封');
assert.match(src, /row-css-wrap[\s\S]*?width: 96rpx/, 'CSS 行图标容器与标签列同宽对齐');
// show/self: doAct save 分支 + goSaveContact 字段组装(白名单口径)
for (const file of ['diy-card-show.vue', 'diy-card-self.vue']) {
const vue = readFileSync(resolve(FE, file), 'utf8');
assert.match(vue, /if \(act\.type === 'save'\)\s*\{\s*this\.goSaveContact\(\);\s*return;\s*\}/, file + ' doAct 加 save 分支');
assert.match(vue, /goSaveContact\(\)\s*\{[\s\S]*?save-contact/, file + ' 跳 save-contact 页');
// 字段组装自 rows(白名单)+ 公开头部字段, 键名与 save-contact.vue onLoad keys 一一对应
for (const k of ['name', 'mobile', 'company', 'job_title', 'email', 'website', 'address', 'remark', 'wechat']) {
assert.match(vue, new RegExp(k + ": (c\\." + k + " || '')|(" + k + ": map\\." + k + " || '')"), file + ' 组装字段 ' + k);
}
assert.match(vue, /Array\.isArray\(c\.rows\)[\s\S]*?map\[r\.field\]/, file + ' 字段从 rows(白名单)提取');
assert.doesNotMatch(vue, /doAct\(act\)\s*\{\s*if \(!act \|\| !act\.value\) return;/, file + ' doAct 不再拦 value 为空的 save');
// 2.19.3 扩展类型: sms/map/link 分支 + map 空值回落名片地址
assert.match(vue, /act\.type === 'map' && !act\.value && this\.card && this\.card\.address/, file + ' map 空值回落名片地址');
assert.match(vue, /act\.type === 'sms'\)\s*\{[\s\S]*?window\.location\.href = 'sms:' \+ act\.value;[\s\S]*?号码已复制/, file + ' sms 分支(H5 sms: 链接/非 H5 复制)');
assert.match(vue, /act\.type === 'map'\)\s*\{[\s\S]*?地址已复制,可粘贴到地图导航/, file + ' map 分支(复制地址)');
assert.match(vue, /act\.type === 'link'\)\s*\{[\s\S]*?indexOf\('http:\/\/'\) === 0 [\s\S]*?\$util\.redirectTo\(act\.value, \{\}\)/, file + ' link 分支(外链 H5 跳转/小程序复制, 站内 redirectTo)');
}
});
test('mini.vue: 资料区标题 + 复制按钮(默认关, emit row-act copy)', () => {
const src = readMiniVue();
assert.match(src, /rowsTitleShow\(\)\s*\{[\s\S]*?flagOn\(this\.c\.showRowsTitle,\s*false\)/, '资料标题默认关');
assert.match(src, /showCopyBtn\(\)\s*\{[\s\S]*?flagOn\(this\.c\.showCopyBtn,\s*false\)/, '复制按钮默认关');
assert.match(src, /v-if="rowsTitleShow"[\s\S]*?rowsTitleBarStyle/, '标题渲染含主题色竖条');
assert.match(src, /onCopyRow\(row\)[\s\S]*?\$emit\('row-act',\s*\{\s*type:\s*'copy',\s*value:\s*row\.value\s*\}\)/, '复制复用 row-act copy 分支');
assert.match(src, /@tap\.stop="onCopyRow\(row\)"/, '复制按钮阻断行点击冒泡');
});
test('复审修复(2.17 review): corner 让位计 theme 头高 / 圆角仅底部 / text-shadow 不泄漏 plain', () => {
const src = readMiniVue();
// corner 浮层让位按头部实际高反算(theme 档 164 = 32+104+28)
assert.match(src, /headHeight = this\.headTheme \? 164 : 104/, 'corner 让位区分 theme/plain 头部高度');
// theme 头部块通铺卡边后圆角只给底部(避免与卡片圆角叠加双圆角)
assert.match(src, /\.mini-headtheme \{[\s\S]*?border-radius: 0 0 16rpx 16rpx/, 'headtheme 圆角仅底部');
// text-shadow 只作用于 theme 档头像(scoped 父选择器 .mini-headtheme &), plain 档零变化
assert.match(src, /\.mini-headtheme & \{[\s\S]*?text-shadow/, 'text-shadow 限定 theme 档');
});
test('self.vue: miniConfig 透传 8 个新字段 + 7 个字号(2.17 修复)', () => {
// 注: diy-card-show.vue 自 2026-10-21 起改为自绘, 不再经 miniConfig 复用 diy-card-mini 通用布局,
// 配置由 computed 直读 this.c 消费, 不持有 v 数据对象; 故仅对仍走 v 对象架构的 self.vue 锁死透传。
const sizes = ['nameSize', 'cardTitleSize', 'subSize', 'labelSize', 'valueSize', 'actSize', 'btnSize'];
const v2 = Object.keys(V2_FIELDS);
const file = 'diy-card-self.vue';
const src = readFileSync(resolve(FE, file), 'utf8');
for (const f of v2) {
assert.match(src, new RegExp(f + ':\\s*v\\.' + f + ','), file + ' 透传 ' + f);
}
// 2.18: 6 个新字段透传
for (const f of Object.keys(V18_FIELDS).concat(['actIcons'])) {
assert.match(src, new RegExp(f + ':\\s*v\\.' + f + ','), file + ' 透传 ' + f);
}
// 2.17: 7 个字号字段透传(可配置字号)
for (const f of sizes) {
assert.match(src, new RegExp(f + ':\\s*v\\.' + f + ','), file + ' 补齐字号透传 ' + f);
}
});
test('2.18: 操作区位置/独立样式/每按钮图标/自定义二维码 全链路', () => {
// 基件消费
const src = readMiniVue();
assert.match(src, /actsAbove\(\)\s*\{[\s\S]*?normalizeActsPosition\(this\.c\.actsPosition\)\s*===\s*'above'/, 'actsAbove 消费 normalizeActsPosition');
assert.match(src, /v-if="actsAbove && showActs && acts\.length && actsGrid"/, '上方档宫格分支渲染');
assert.match(src, /v-if="!actsAbove && showActs && acts\.length && actsGrid"/, '下方档宫格分支互斥守卫');
assert.match(src, /rowsClearStyle\(\)\s*\{[\s\S]*?return this\.actsAbove \? \{\} : this\.cornerClearStyle/, '上方档时资料行不再重复让位');
// 2026-10-24: uni-app 运行时静默丢弃 :style="[字符串, 对象]" 数组绑定(2026-10-22 qrBlockStyle 线上问题同根因,
// H5 实测 actsBlockBg/Radius/Pad 全失效、style 属性为 null) → 合并为 actsBlockFinalStyle 单绑定, 数组形态禁止回归
assert.match(src, /:style="actsBlockFinalStyle"/, '操作区块走 actsBlockFinalStyle 单绑定');
assert.doesNotMatch(src, /:style="\[actsBlockStyle/, '操作区块禁数组绑定(uni-app 静默丢弃)');
assert.match(src, /actsBlockFinalStyle\(\)\s*\{[\s\S]*?cornerClearStyle/, 'actsBlockFinalStyle 合并 corner 让位');
assert.match(src, /actsBlockStyle\(\)\s*\{[\s\S]*?actsBlockBg/, '独立块样式消费 actsBlockBg/Radius/Pad');
// 每按钮图标: img 优先于字形/CSS 图标; 前景/底色覆盖
assert.match(src, /actImgOf\(act\)\s*\{[\s\S]*?actIconCfgOf\(this\.c\.actIcons,\s*act && act\.type\)/, 'actImgOf 消费 actIconCfgOf');
assert.match(src, /<image v-if="actImgOf\(act\)" class="act-grid-img"/, '自定义图标图片渲染分支');
assert.match(src, /actGridIconStyle\(act\)\s*\{[\s\S]*?cfgBg[\s\S]*?if \(cfgBg\)/, '底色每按钮覆盖优先');
assert.match(src, /actIconColor\(act\)[\s\S]*?if \(custom\) return custom/, '前景色每按钮覆盖优先');
// 自定义二维码
assert.match(src, /qrSrc\(\)\s*\{[\s\S]*?String\(this\.c\.qrImage \|\| ''\)\.trim\(\)/, 'qrSrc 自定义图优先');
assert.match(src, /flagOn\(this\.c\.showQr,\s*false\) && !!this\.qrSrc/, 'showQrBase 消费 qrSrc');
assert.equal((src.match(/\$util\.img\(qrSrc\)/g) || []).length, 5, '五个二维码挂点全部消费 qrSrc');
// 展示挂点(qr-img 类)禁旧直连; 分享抽屉(ss-qr)保留名片原码 —— 分享出去必须能扫码打开名片(2026-10-24 口径)
assert.doesNotMatch(src, /class="qr-img"[^>]*\$util\.img\(card\.qr_url\)/, '展示挂点无旧直连 card.qr_url');
assert.match(src, /ss-qr[\s\S]{0,120}\$util\.img\(card\.qr_url\)/, '分享抽屉保留名片原码(扫码语义)');
// SCSS: 自定义图标图片铺满图标块
assert.match(src, /\.act-grid-img\s*\{[\s\S]*?width: 100%/, 'act-grid-img 铺满图标块');
// design.html ×2: 面板控件 + 预览消费
for (const comp of ['show', 'self']) {
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
assert.match(html, /@click="nc\.actsPosition = 'above'"/, comp + ' 操作区位置 chips(above)');
assert.match(html, /@click="nc\.actsPosition = 'below'"/, comp + ' 操作区位置 chips(below)');
assert.match(html, /@click="nc\.actsBlockBg = '#F7F8FA'; nc\.actsBlockPad = 12; nc\.actsBlockRadius = 12"/, comp + ' 独立成块预设联动');
assert.match(html, /field: 'actsBlockBg', label: '操作区底色'/, comp + ' 操作区底色 color');
assert.match(html, /field: 'actsBlockRadius', label: '操作区圆角'/, comp + ' 操作区圆角 slide');
assert.match(html, /field: 'actsBlockPad', label: '操作区内边距'/, comp + ' 操作区内边距 slide');
// 每按钮图标自定义(2.19 起并入「快捷操作管理」图层面板; 媒体库走类名委托)
assert.match(html, /快捷操作管理/, comp + ' 快捷操作管理图层面板');
assert.doesNotMatch(html, /js-act-media/, comp + ' 媒体库选图入口已移除(2.19.1 图标唯一来源=素材图标库)');
assert.match(html, /js-act-color" data-scope="default" :data-key="a\.k" data-field="color"/, comp + ' 每按钮前景色输入(2.19 起走 js-act-color 委托, $set 由委托 JS 兼容存量行)');
assert.match(html, /js-act-color" data-scope="default" :data-key="a\.k" data-field="bg"/, comp + ' 每按钮底色输入(同上)');
// 自定义二维码(媒体库)
assert.match(html, /v-model="nc\.qrImage"/, comp + ' 二维码自定义图输入');
assert.match(html, /js-qr-media" title="从媒体库选择二维码图片"/, comp + ' 二维码选图按钮走 js-qr-media 委托(2.19.3, 内联 openAlbum 不可靠)');
assert.doesNotMatch(html, /@click="openAlbum\(/, comp + ' 零内联 openAlbum 调用');
// 预览消费
assert.match(html, /nc\.actsPosition === 'above'/, comp + ' 预览上方档分支');
assert.match(html, /nc\.actsPosition !== 'above'/, comp + ' 预览下方档互斥守卫');
assert.match(html, /diy-card-mini__act-img/, comp + ' 预览自定义图标分支');
assert.match(html, /diy-card-qr__img--custom/, comp + ' 预览自定义二维码分支');
}
// 平台: refreshComponent 对象默认值深拷贝(防同名组件共享嵌套对象串改)
const ct = readFileSync(resolve(BE, '../../public/static/ext/diyview/js/custom_template.js'), 'utf8');
assert.match(ct, /JSON\.parse\(JSON\.stringify\(defaults\[key\]\)\)/, 'refreshComponent 对象默认值深拷贝');
// 反例: entry/list 不注册 2.18 字段
const php = readFileSync(resolve(BE, 'config/diy_view.php'), 'utf8');
const entryM = php.match(/'name'\s*=>\s*'DiyCardEntry'[\s\S]*?'value'\s*=>\s*'([^\']+)'/);
const entry = JSON.parse(entryM[1]);
assert.equal(entry.actsPosition, undefined, 'entry 不注册 actsPosition');
assert.equal(entry.qrImage, undefined, 'entry 不注册 qrImage');
});
test('design.html ×2: 面板控件与预览消费齐备 + 互证 diy_view 默认值', () => {
for (const comp of ['show', 'self']) {
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
// 面板: 双形态 chips + 开关 + 文案输入
assert.match(html, /@click="nc\.headStyle = 'plain'"/, comp + ' 头部形态 chips(plain)');
assert.match(html, /@click="nc\.headStyle = 'theme'"/, comp + ' 头部形态 chips(theme)');
assert.match(html, /@click="nc\.actsLayout = 'grid'"/, comp + ' 操作布局 chips(grid)');
assert.match(html, /@click="nc\.actGridColorMode = 'theme'"/, comp + ' 宫格配色 chips(theme)');
assert.match(html, /v-model="nc\.showRowsTitle"/, comp + ' 资料区标题开关');
assert.match(html, /v-model="nc\.rowsTitle"/, comp + ' 标题文案输入');
assert.match(html, /v-model="nc\.showCopyBtn"/, comp + ' 复制按钮开关');
// 预览消费
assert.match(html, /diy-card-mini__head--theme/, comp + ' 预览 theme 头部分支');
assert.match(html, /diy-card-mini__act-grid/, comp + ' 预览宫格分支');
assert.match(html, /diy-card-mini__rows-title/, comp + ' 预览资料标题');
assert.match(html, /diy-card-mini__row-copy/, comp + ' 预览复制按钮');
// 样式页: theme 档头部 color 控件 + 恢复默认按钮覆盖头部色
assert.match(html, /field: 'headNameColor', label: '头部姓名色', defaultColor: '#FFFFFF'/, comp + ' 头部姓名色 color');
assert.match(html, /field: 'headSubColor', label: '头部副行色', defaultColor: '#FFFFFF'/, comp + ' 头部副行色 color');
assert.match(html, /nc\.headNameColor='#FFFFFF'; nc\.headSubColor='#FFFFFF'/, comp + ' 恢复默认覆盖头部色');
}
});
test('反例自测: 新形态标记不得出现在未改造组件中', () => {
// entry/list 未接 2.17 双形态(它们没有 mini 基件), diy_view 默认值也不应出现
const php = readFileSync(resolve(BE, 'config/diy_view.php'), 'utf8');
const entryM = php.match(/'name'\s*=>\s*'DiyCardEntry'[\s\S]*?'value'\s*=>\s*'([^\']+)'/);
const entry = JSON.parse(entryM[1]);
assert.equal(entry.headStyle, undefined, 'entry 不注册 headStyle(基件专属)');
assert.equal(entry.actsLayout, undefined, 'entry 不注册 actsLayout(基件专属)');
// common.js 禁 calc 铁律: 新增代码不得引入 calc()
const common = readCommonJs();
assert.doesNotMatch(common, /calc\(/, 'common.js 无 calc(WXSS 不支持)');
});
/* ------------------------------------------------------------------
* 2.19 快捷操作: 显示控制 + 自定义操作 + 素材图标库 + 图层式管理
* ------------------------------------------------------------------ */
const V19_FIELDS = { actVis: 'object', actDeleted: 'object', actLabels: 'object', customActs: 'array', actOrder: 'array' };
const V19_ACT_LABELS = { tel: '打电话', wx: '微信号', mail: '发邮件', web: '网站', save: '存通讯录' };
/** 2.19.10 detail 版式对齐字段 → 默认值(零变化) */
const V21910_FIELDS = {
showRowLabels: 0,
copyBtnScope: 'all',
copyBtnLook: 'theme',
qrTipEm: '',
qrTipSub: '',
qrUrlShow: 0,
badgeShow: 0,
badgeText: '数字名片',
rowsBlockBg: '',
rowsBlockRadius: 0,
rowsBlockPad: 0,
qrBlockBg: '',
qrBlockRadius: 0,
qrBlockPad: 0,
};
test('diy_view.php: Show/Self value 均含 2.19 字段(默认值=全显/默认文案/空数组/历史顺序=零变化)', () => {
for (const comp of ['show', 'self']) {
const v = readValueJson(comp);
// actVis: 五类型全 1(显示)
assert.ok(v.actVis && typeof v.actVis === 'object', comp + '.actVis 已注册');
assert.deepEqual(v.actVis, { tel: 1, wx: 1, mail: 1, web: 1, save: 1 }, comp + '.actVis 默认全显');
// actLabels: 五类型默认文案
assert.ok(v.actLabels && typeof v.actLabels === 'object', comp + '.actLabels 已注册');
assert.deepEqual(v.actLabels, V19_ACT_LABELS, comp + '.actLabels 默认文案');
// customActs: 空数组
assert.deepEqual(v.customActs, [], comp + '.customActs 默认空数组');
// actOrder(2.19.1): 历史渲染顺序
assert.deepEqual(v.actOrder, ['tel', 'wx', 'mail', 'web', 'save'], comp + '.actOrder 默认=历史顺序');
// actDeleted(2.19.2): 全 0=无删除(历史零变化)
assert.deepEqual(v.actDeleted, { tel: 0, wx: 0, mail: 0, web: 0, save: 0 }, comp + '.actDeleted 默认全 0');
}
});
/* ------------------------------------------------------------------
* 2.19.10 detail 版式对齐: 三列行/复制范围外观/qrTip 富文本/URL 行/hero 徽章/分卡
* ------------------------------------------------------------------ */
test('diy_view.php: Show/Self value 均含 2.19.10 字段(默认值=历史形态零变化)', () => {
for (const comp of ['show', 'self']) {
const v = readValueJson(comp);
for (const [field, dft] of Object.entries(V21910_FIELDS)) {
assert.ok(field in v, comp + '.' + field + ' 已注册');
assert.deepEqual(v[field], dft, comp + '.' + field + ' 默认=' + JSON.stringify(dft));
}
}
});
test('show/self.vue: miniConfig 均透传 9 个 2.19.10 字段', () => {
for (const file of ['diy-card-show.vue', 'diy-card-self.vue']) {
const src = readFileSync(resolve(FE, file), 'utf8');
for (const f of Object.keys(V21910_FIELDS)) {
assert.match(src, new RegExp(f + ':\\s*v\\.' + f + ','), file + ' 透传 ' + f);
}
}
});
test('mini.vue: 消费 2.19.10 字段(computed/模板/样式)', () => {
const src = readFileSync(resolve(FE, 'diy-card-mini.vue'), 'utf8');
// computed: 新字段 getter
for (const f of ['showRowLabels', 'badgeShow', 'badgeText', 'qrTipEm', 'qrTipSub', 'qrUrlShow', 'qrUrlText', 'copyBtnScope', 'copyBtnLook', 'rowsBlockStyle', 'qrBlockCustomStyle']) {
assert.match(src, new RegExp(f + '\\(\\)\\s*\\{'), 'mini.vue computed ' + f);
}
// 模板消费
assert.match(src, /class="mini-hero-badge"/, 'hero 徽章渲染');
assert.match(src, /row-3col.*showRowLabels|showRowLabels.*row-3col/, '三列行 class 绑定');
assert.match(src, /copyBtnVisible\(row\)/, '复制按钮范围方法');
assert.match(src, /qrTipEm/, 'qrTip 加粗段');
assert.match(src, /qrTipSub/, 'qrTip 第二行');
assert.match(src, /qrUrlShow/, 'URL 行开关');
// 独立块样式消费(与操作区同款: Bg/Radius/Pad)
assert.match(src, /rowsBlockBg/, 'rowsBlockStyle 消费 rowsBlockBg');
assert.match(src, /qrBlockBg/, 'qrBlockCustomStyle 消费 qrBlockBg');
assert.match(src, /rowsFinalStyle\(\)\s*\{[\s\S]*?rowsBlockStyle/, 'rowsFinalStyle 合并 rowsBlockStyle');
assert.match(src, /qrBlockFinalStyle\(\)\s*\{[\s\S]*?qrBlockCustomStyle/, 'qrBlockFinalStyle 合并 qrBlockCustomStyle');
// 样式
assert.match(src, /\.mini-hero-badge/, 'hero 徽章 CSS');
assert.match(src, /\.qr-tip-em/, 'qrTip 加粗 CSS');
assert.match(src, /\.qr-tip-sub/, 'qrTip 第二行 CSS');
assert.match(src, /\.qr-url/, 'URL 行 CSS');
assert.match(src, /row-3col/, '三列行 CSS');
// 反例: 禁数组 style 绑定(uni-app 静默丢弃)——先剥注释防误判
const stripped = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
assert.doesNotMatch(stripped, /:style="\[/, '无数组 style 绑定');
// 反例: layoutCards 已移除
assert.doesNotMatch(stripped, /layoutCards/, 'layoutCards 已移除');
// 徽章样式避让企业徽标 badgeStyle
assert.match(src, /heroBadgeStyle\(\)/, 'heroBadgeStyle 避让 badgeStyle');
});
test('素材图标库三源同步: PHP CardIconLibrary ↔ 面板 card-icon-lib.js ↔ C端 common.js', () => {
// PHP 源: 解析 CardIconLibrary::ICONS(key => svg)
const php = readFileSync(resolve(BE, 'service/CardIconLibrary.php'), 'utf8');
const libRe = /'(\w+)'\s*=>\s*\[\s*'label'\s*=>\s*'[^']*',\s*'category'\s*=>\s*'[^']*',\s*'svg'\s*=>\s*'(<svg [^']*)',\s*\]/g;
const phpLib = {};
let m;
while ((m = libRe.exec(php)) !== null) {
phpLib[m[1]] = m[2];
}
assert.ok(Object.keys(phpLib).length >= 25, 'PHP 素材库图标数 >= 25, 实际 ' + Object.keys(phpLib).length);
assert.ok(phpLib.phone && phpLib.phone.indexOf('currentColor') > 0, 'phone 图标带 currentColor');
const extractJson = (src, anchor) => {
const i = src.indexOf(anchor);
assert.ok(i >= 0, '找到锚点 ' + anchor);
const j = src.indexOf('{', i);
// 前向扫描配对花括号(JSON 内无裸花括号字面量, 安全)
let depth = 0, k = j;
for (; k < src.length; k++) {
if (src[k] === '{') depth++;
else if (src[k] === '}') { depth--; if (depth === 0) break; }
}
return JSON.parse(src.slice(j, k + 1));
};
// 面板 resource JS
const panelJs = readFileSync(resolve(BE, 'component/view/public/js/card-icon-lib.js'), 'utf8');
const panelLib = extractJson(panelJs, 'window.CARD_ICON_LIB =');
assert.deepEqual(panelLib, phpLib, '面板图标库与 PHP 同源');
assert.match(panelJs, /function cardLibIconSvg\(key\)/, '面板提供 cardLibIconSvg');
assert.match(panelJs, /function cardLibIconBg\(key, color\)/, '面板提供 cardLibIconBg');
assert.match(panelJs, /split\('currentColor'\)\.join\(color/, '面板上色走 currentColor 替换');
// C端 common.js
const common = readCommonJs();
const feLib = extractJson(common, 'export const CARD_ICON_LIB =');
assert.deepEqual(feLib, phpLib, 'C端图标库与 PHP 同源');
assert.match(common, /export function libIconSvg\(key\)/, 'C端提供 libIconSvg');
assert.match(common, /export function libIconBg\(key, color\)/, 'C端提供 libIconBg');
});
test('common.js: normalizeActVis / normalizeActLabels / normalizeCustomActs / normalizeActOrder(2.19+2.19.1)', () => {
const js = readCommonJs();
const consts = [
js.match(/export const ACT_TYPES = [^;]+;/)[0].replace('export const', 'const'),
js.match(/export const ACT_ACTION_TYPES = [^;]+;/)[0].replace('export const', 'const'),
js.match(/export const ACT_LABEL_DEFAULTS = \{[\s\S]*?\n\};/)[0].replace('export const', 'const'),
js.match(/export const ACT_ORDER_DEFAULT = [^;]+;/)[0].replace('export const', 'const'),
].join('\n');
const grab = (name) => js.match(new RegExp('export function ' + name + '[\\s\\S]*?\\n}'))[0].replace('export function ', 'function ');
const body = consts + '\n' + ['normalizeActVis', 'normalizeActDeleted', 'normalizeActLabels', 'normalizeCustomActs', 'normalizeActOrder'].map(grab).join('\n') + '\n'
+ 'return [normalizeActVis, normalizeActDeleted, normalizeActLabels, normalizeCustomActs, normalizeActOrder];';
const [visFn, delFn, labelFn, customFn, orderFn] = new Function(body)();
// actVis: 缺省全显(历史零变化); '0' → 0; 未知键忽略
assert.deepEqual(visFn(undefined), { tel: 1, wx: 1, mail: 1, web: 1, save: 1 }, 'actVis 缺省全显');
assert.deepEqual(visFn({ tel: '0', wx: 0 }), { tel: 0, wx: 0, mail: 1, web: 1, save: 1 }, 'actVis 0=隐藏/缺键=显');
// actDeleted(2.19.2): 缺省全 0(历史零变化); '1' → 1
assert.deepEqual(delFn(undefined), { tel: 0, wx: 0, mail: 0, web: 0, save: 0 }, 'actDeleted 缺省全 0');
assert.deepEqual(delFn({ tel: '1', save: 1 }), { tel: 1, wx: 0, mail: 0, web: 0, save: 1 }, 'actDeleted 1=已删/缺键=0');
// actLabels: 缺省=内置文案; 空串回落默认
assert.deepEqual(labelFn(undefined), V19_ACT_LABELS, 'actLabels 缺省=内置默认');
assert.deepEqual(labelFn({ tel: '', wx: '加微信' }).wx, '加微信', 'actLabels 改名生效');
assert.equal(labelFn({ tel: '' }).tel, '打电话', 'actLabels 空串回落默认');
// customActs: 白名单过滤 + 字段收敛 + custom 标记
assert.deepEqual(customFn(undefined), [], 'customActs 缺省空');
const list = customFn([
{ label: '加群', type: 'url', value: 'https://x.com', vis: 1 },
{ type: 'tel', value: '13900000000' },
'junk',
]);
assert.equal(list.length, 2, '非对象项剔除');
assert.equal(list[0].type, 'copy', '非法 type 回落 copy');
assert.equal(list[0].custom, 1, '带 custom 标记');
assert.equal(list[0].label, '加群', 'label 保留');
assert.equal(list[1].label, '自定义', 'label 缺省');
assert.equal(list[1].type, 'tel', '合法 type 保留');
// 2.19.3 扩展类型白名单
assert.deepEqual(customFn([{ type: 'sms', value: '139' }, { type: 'map', value: '某某大厦' }, { type: 'link', value: '/pages/index/index' }]).map(x => x.type), ['sms', 'map', 'link'], 'sms/map/link 新类型通过白名单');
// actOrder(2.19.1): 缺省=历史顺序; 去重+白名单+缺失尾补
assert.deepEqual(orderFn(undefined), ['tel', 'wx', 'mail', 'web', 'save'], 'actOrder 缺省=历史顺序');
assert.deepEqual(orderFn(['save', 'tel', 'bogus', 'save', 'mail']), ['save', 'tel', 'mail', 'wx', 'web'], '重排去重+非法剔除+缺失尾补');
assert.deepEqual(orderFn('junk'), ['tel', 'wx', 'mail', 'web', 'save'], '非数组=历史顺序');
});
test('common.js: libIconBg 上色 + data URI 可解码回原图(2.19)', () => {
const js = readCommonJs();
const b64 = js.match(/const B64T = [^;]+;[\s\S]*?function b64ascii\(s\) \{[\s\S]*?\n\}/)[0];
const libJson = js.slice(js.indexOf('{', js.indexOf('export const CARD_ICON_LIB =')));
// 前向扫描取 JSON
let depth = 0, end = 0;
for (let i = libJson.indexOf('{'); i < libJson.length; i++) {
if (libJson[i] === '{') depth++;
else if (libJson[i] === '}') { depth--; if (depth === 0) { end = i + 1; break; } }
}
const lib = new Function('return ' + libJson.slice(libJson.indexOf('{'), end))();
const fn = new Function('CARD_ICON_LIB', b64 + '\n' + js.match(/export function libIconSvg\(key\)[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '\n' + js.match(/export function libIconBg\(key, color\)[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '\nreturn libIconBg;')(lib);
const bg = fn('phone', '#FF0000');
assert.match(bg, /^url\("data:image\/svg\+xml;base64,/), 'libIconBg 输出 base64 data URI';
// 解码回 SVG 验证: currentColor 已替换为指定色
const b64str = bg.match(/base64,([^"]+)/)[1];
const svg = Buffer.from(b64str, 'base64').toString('ascii');
assert.ok(svg.indexOf('#FF0000') > 0, 'SVG 内嵌指定色');
assert.equal(svg.indexOf('currentColor'), -1, 'currentColor 已全部替换');
assert.equal(fn('not_exist', '#FFF'), '', '缺 key 返回空串');
});
test('mini.vue: acts 合成(显隐过滤 + 次序 + 文案覆盖 + 自定义动作尾接) + 素材库图标分支(2.19+2.19.1)', () => {
const src = readMiniVue();
// acts computed: 过滤/改名/合并/四 cfg 字段
assert.match(src, /acts\(\)\s*\{[\s\S]*?normalizeActVis\(this\.c\.actVis\)/, 'acts 消费 normalizeActVis');
assert.match(src, /acts\(\)\s*\{[\s\S]*?normalizeActLabels\(this\.c\.actLabels\)/, 'acts 消费 normalizeActLabels');
assert.match(src, /acts\(\)\s*\{[\s\S]*?normalizeCustomActs\(this\.c\.customActs\)/, 'acts 消费 normalizeCustomActs');
// 2.19.1 次序: 按 normalizeActOrder 输出默认动作 + order 外扩展类型尾接兜底
assert.match(src, /acts\(\)\s*\{[\s\S]*?normalizeActOrder\(this\.c\.actOrder\)/, 'acts 消费 normalizeActOrder');
assert.match(src, /acts\(\)\s*\{[\s\S]*?const byType = \{\};[\s\S]*?order\.forEach\(t => \{/, 'acts 按 order 次序取默认动作');
assert.match(src, /acts\(\)\s*\{[\s\S]*?if \(order\.indexOf\(t\) >= 0 \|\| vis\[t\] === 0 \|\| del\[t\] === 1\) return;/, 'order 外扩展类型尾接且隐藏/已删不渲染');
// 2.19.2 删除: 按 order 取默认动作时 deleted=1 的不渲染
assert.match(src, /acts\(\)\s*\{[\s\S]*?normalizeActDeleted\(this\.c\.actDeleted\)/, 'acts 消费 normalizeActDeleted');
assert.match(src, /acts\(\)\s*\{[\s\S]*?if \(byType\[t\] && vis\[t\] !== 0 && del\[t\] !== 1\) out\.push\(merge\(byType\[t\]\)\);/, 'order 取默认动作时 vis=0/deleted=1 均过滤');
assert.match(src, /labels\[t\] \|\| a\.label/, 'actLabels 覆盖文案(空回落服务端)');
assert.match(src, /cfgImg: String\(cfg\.img \|\| ''\)\.trim\(\)/, '默认动作合并 cfgImg');
assert.match(src, /cfgKey: String\(cfg\.iconKey \|\| ''\)\.trim\(\)/, '默认动作合并 cfgKey(iconKey)');
assert.match(src, /normalizeCustomActs\(this\.c\.customActs\)\.forEach\(ca => \{[\s\S]*?cfgKey: ca\.iconKey/, '自定义动作尾接并合并 cfg 字段');
// 图标渲染优先级: img > 素材库(iconKey) > SVG 线稿 > 首字
assert.match(src, /actLibOf\(act\)\s*\{[\s\S]*?act\.cfgKey/, 'actLibOf 读合并 cfgKey');
assert.match(src, /actLibStyle\(act\)\s*\{[\s\S]*?libIconBg\(this\.actLibOf\(act\), this\.actIconColor\(act\)\)/, 'actLibStyle 消费 libIconBg + 四级色口径');
assert.equal((src.match(/v-else-if="actLibOf\(act\)" class="act-grid-svg" :style="actLibStyle\(act\)"/g) || []).length, 2, '上/下两份宫格副本均插入素材库分支');
assert.match(src, /actSvgOf\(act\)\s*\{[\s\S]*?!this\.actImgOf\(act\) && !this\.actLibOf\(act\) && !!WAP_LINE_ICONS/, '线稿档被 img/素材库优先占位后不再进入');
assert.match(src, /import \{[\s\S]*?normalizeActVis, normalizeActDeleted, normalizeActLabels, normalizeCustomActs, normalizeActOrder, libIconBg/, 'mini.vue 引入 2.19+2.19.1+2.19.2 依赖');
// 透传
// show.vue 自绘, 不持有 v 对象; actVis/actLabels/actOrder/customActs 透传仅校验 self.vue(走 v 架构)
for (const file of ['diy-card-self.vue']) {
const vue = readFileSync(resolve(FE, file), 'utf8');
for (const f of ['actVis', 'actDeleted', 'actLabels', 'actOrder', 'customActs']) {
assert.match(vue, new RegExp(f + ': v\\.' + f + ','), file + ' 透传 ' + f);
}
}
});
test('design.html ×2: 图层式管理面板 + 素材图标库 + 预览显隐/文案/自定义动作消费(2.19)', () => {
for (const comp of ['show', 'self']) {
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
// 图层管理器: 眼睛开关 / 图标缩略 / 行内改名 / 素材库+媒体库按钮 / 清除
assert.match(html, /diy-card-layer__eye layui-icon" :class="nc\.actVis && nc\.actVis\[a\.k\] === 0 \? 'layui-icon-eye-invisible' : 'layui-icon-eye'/, comp + ' 默认动作眼睛开关');
assert.match(html, /nc\.actVis\[a\.k\] = nc\.actVis\[a\.k\] === 0 \? 1 : 0;/, comp + ' 眼睛切换写回 actVis');
// 存量组件防御(2026-10-24): defaults($diy_view_utils)缺新字段时 refreshComponent 补不了,
// v-model="nc.actLabels[a.k]" 裸读双层直取会在 render 阶段炸 "reading 'tel'" ——
// 管理器容器 v-if 守卫 + 改名输入 :value/@input 防御式写回 + @click 禁以 if 开头(keyword 编译警告)
assert.match(html, /v-if="nc\.showQuick && nc\.actLabels && nc\.actIcons"/, comp + ' 管理器容器 v-if 守卫(未补全不渲染)');
assert.doesNotMatch(html, /v-model="nc\.[a-zA-Z]+\[/, comp + ' 零 v-model 双层裸读(render 阶段防炸)');
assert.match(html, /:value="nc\.actLabels \? \(nc\.actLabels\[a\.k\] \|\| ''\) : ''" @input="\$set\(nc, 'actLabels', nc\.actLabels \|\| \{\}\); \$set\(nc\.actLabels, a\.k, \$event\.target\.value\)"/, comp + ' 行内改名防御式写回($set 兼容存量行)');
assert.doesNotMatch(html, /@click="if \(/, comp + ' 内联事件不以 if 开头(消 JS keyword 编译警告)');
assert.match(html, /v-model="nc\.actLabels\[a\.k\]" maxlength="6"|maxlength="6" title="快捷操作文字"/, comp + ' 改名输入在位');
assert.match(html, /js-act-lib" :data-key="a\.k" data-scope="default"/, comp + ' 素材图标按钮(默认动作)');
assert.doesNotMatch(html, /js-act-media/, comp + ' 媒体库按钮已移除(2.19.1)');
assert.match(html, /\$set\(nc\.actIcons\[a\.k\], 'iconKey', ''\)/, comp + ' 清除图标走 $set(iconKey 兼容存量行)');
// 自定义动作行: 类型 chips / 取值 / 排序 / 删除 / 添加
assert.match(html, /diy-card-layer--custom" v-for="\(c, ci\) in nc\.customActs"/, comp + ' 自定义动作图层行');
assert.match(html, /:class="\{ active: c\.type === t\.v \}"/, comp + ' 自定义动作类型 chips');
assert.match(html, /\{v:'copy',lb:'复制'\},\{v:'sms',lb:'短信'\},\{v:'map',lb:'导航'\},\{v:'link',lb:'页面'\}\]/, comp + ' 类型 chips 含 2.19.3 扩展(短信/导航/页面, web 文案改网址)');
assert.match(html, /js-act-syslink" data-scope="custom" :data-idx="ci" v-if="c\.type === 'link'"/, comp + ' link 类型出现「系统链接」选择按钮');
assert.match(html, /v-model="c\.value" maxlength="200"/, comp + ' 自定义动作取值输入');
assert.match(html, /js-act-lib" data-scope="custom" :data-idx="ci"/, comp + ' 素材图标按钮(自定义动作)');
assert.match(html, /nc\.customActs\.splice\(ci - 1, 0, nc\.customActs\.splice\(ci, 1\)\[0\]\)/, comp + ' 上移排序');
assert.match(html, /nc\.customActs\.splice\(ci \+ 1, 0, nc\.customActs\.splice\(ci, 1\)\[0\]\)/, comp + ' 下移排序');
assert.match(html, /nc\.customActs\.splice\(ci, 1\)/, comp + ' 删除自定义动作');
// 2.19.1 默认动作次序: 面板行按 actOrder 渲染 + ↑↓ 委托调整
assert.equal((html.match(/<template v-for="k in \(nc\.actOrder && nc\.actOrder\.length \? nc\.actOrder : \['tel','wx','mail','web','save'\]\)"><div class="diy-card-layer"/g) || []).length, 1, comp + ' 面板默认行按 actOrder 次序渲染(嵌套 v-for + v-if 匹配)');
// 2.19.2 删除: 行过滤 + 删除按钮 + 恢复 chips + 预览过滤
assert.match(html, /v-if="a\.k === k && !\(nc\.actDeleted && nc\.actDeleted\[a\.k\] === 1\)"/, comp + ' 面板行过滤已删除动作');
assert.match(html, /diy-card-layer__delact" title="删除此默认操作\(可在下方恢复\)" @click="\$set\(nc, 'actDeleted', nc\.actDeleted \|\| \{ tel: 0, wx: 0, mail: 0, web: 0, save: 0 \}\); \$set\(nc\.actDeleted, a\.k, 1\);"/, comp + ' 删除按钮防御式写回 actDeleted');
assert.match(html, /diy-card-layer__restore" v-if="nc\.actDeleted && \(nc\.actDeleted\.tel === 1/, comp + ' 恢复 chips 行(有删除项才显示)');
assert.match(html, /v-if="nc\.actDeleted\[t\.k\] === 1" :key="t\.k" @click="\$set\(nc\.actDeleted, t\.k, 0\);"/, comp + ' 恢复 chip 点击写回 0');
assert.equal((html.match(/v-if="a\.k === k && !\(nc\.actVis && nc\.actVis\[a\.k\] === 0\) && !\(nc\.actDeleted && nc\.actDeleted\[a\.k\] === 1\)"/g) || []).length, 2, comp + ' 预览显隐+删除双重过滤(上/下两副本)');
assert.equal((html.match(/js-act-move" title="上移" :data-key="a\.k" data-dir="up"/g) || []).length, 1, comp + ' 默认行上移按钮(委托)');
assert.equal((html.match(/js-act-move" title="下移" :data-key="a\.k" data-dir="down"/g) || []).length, 1, comp + ' 默认行下移按钮(委托)');
assert.match(html, /nc\.customActs\.push\(\{ label: '自定义', type: 'copy'[\s\S]*?svg: ''[\s\S]*?vis: 1 \}\)/, comp + ' 添加自定义操作(全键补齐含 svg)');
assert.match(html, /c\.vis = c\.vis === 0 \? 1 : 0;/, comp + ' 自定义动作眼睛开关');
// 预览消费: 次序 + 显隐过滤 + 文案覆盖 + 自定义动作单元格
assert.equal((html.match(/<template v-for="k in \(nc\.actOrder && nc\.actOrder\.length \? nc\.actOrder : \['tel','wx','mail','web','save'\]\)"><template v-for="a in \[\{k:'tel',ch:'打',lb:'打电话'/g) || []).length, 2, comp + ' 预览默认 cell 按 actOrder 次序渲染(上/下两副本)');
assert.match(html, /\{\{ \(nc\.actLabels && nc\.actLabels\[a\.k\]\) \|\| a\.lb \}\}/, comp + ' 预览文案覆盖');
// svg 快照口径: 预览只读纯数据字段(委托 JS 选图/改色时预计算 data URI 写入),
// 模板内严禁调用全局函数(模板作用域函数解析不可靠, 实测 typeof 恒 false)
assert.equal((html.match(/v-for="\(c, ci\) in nc\.customActs"/g) || []).length, 3, comp + ' 自定义动作 v-for = 面板行 + 预览×2 副本');
assert.equal((html.match(/nc\.actIcons\[a\.k\]\.svg" class="diy-card-mini__act-svg"/g) || []).length, 2, comp + ' 预览默认动作素材库图标读 svg 快照(上/下两副本)');
assert.equal((html.match(/v-else-if="c\.svg" class="diy-card-mini__act-svg"/g) || []).length, 2, comp + ' 预览自定义动作素材库图标读 svg 快照(上/下两副本)');
assert.match(html, /nc\.actIcons\[a\.k\]\.svg" class="diy-card-layer__libico"/, comp + ' 面板行图标缩略读 svg 快照');
assert.match(html, /c\.svg" class="diy-card-layer__libico"/, comp + ' 自定义行图标缩略读 svg 快照');
assert.doesNotMatch(html, /cardLibIconBg/, comp + ' 模板内零函数调用(全部改读 svg 字段)');
// 前景/底色走 class 委托(不再内联 @input 多语句)
assert.equal((html.match(/class="diy-card-layer__color js-act-color"/g) || []).length, 4, comp + ' 4 个颜色输入走 js-act-color 委托');
assert.match(html, /data-field="color"/, comp + ' 前景色 data-field');
assert.match(html, /data-field="bg"/, comp + ' 底色 data-field');
assert.match(html, /data-scope="custom" :data-idx="ci" data-field=/, comp + ' 自定义行颜色输入带作用域');
// 2.19.7 面板颜色输入占位必须与预览同链(空值=跟随配色档, 显示诚实):
// 默认行 color: wx#07C160/save#FF9500 固定 → theme#2F6BFF → a.fg; bg: theme#E6EDFF(rgba 叠白近似) → a.bg
const previewColorChain = html.match(/color: \(nc\.actIcons && nc\.actIcons\[a\.k\] && nc\.actIcons\[a\.k\]\.color\) \|\| \(a\.k === 'wx' \? '#07C160'[^"]*\)/);
assert.ok(previewColorChain, comp + ' 预览 color 回落链存在');
// 2.19.9 起 theme 段可被所选名片设计字段覆盖(previewCard.theme_color 优先, 无则回落 #2F6BFF)
assert.match(html, /nc\.actIcons\[a\.k\]\.color \? nc\.actIcons\[a\.k\]\.color : \(a\.k === 'wx' \? '#07C160' : a\.k === 'save' \? '#FF9500' : \(nc\.actGridColorMode === 'theme' \? \(\(nc\.tempData && nc\.tempData\.previewCard && nc\.tempData\.previewCard\.theme_color\) \|\| '#2F6BFF'\) : a\.fg\)\)"/, comp + ' 默认行前景输入占位与预览同链(wx/save 固定色 + theme 段 previewCard 覆盖/preset 档)');
assert.match(html, /nc\.actIcons\[a\.k\]\.bg \? nc\.actIcons\[a\.k\]\.bg : \(nc\.actGridColorMode === 'theme' \? '#E6EDFF' : a\.bg\)"/, comp + ' 默认行底色输入占位与预览同链(theme 叠白近似/preset 浅底)');
assert.doesNotMatch(html, /actIcons\[a\.k\]\.color \? nc\.actIcons\[a\.k\]\.color : '#2F6BFF'/, comp + ' 默认行前景占位不再恒 #2F6BFF(对多彩档是谎报)');
assert.match(html, /:value="c\.color \|\| '#2F6BFF'"/, comp + ' 自定义行前景占位(与自定义预览回落一致)');
assert.match(html, /:value="c\.bg \|\| '#E6EDFF'"/, comp + ' 自定义行底色占位(rgba 叠白近似)');
// 结构铁律: 自定义动作 cell 必须在默认动作 template v-for 之外(否则每默认动作重复渲染一次);
// 2.19.1 起默认 cell 为双层嵌套 template(外层 actOrder 次序), 收尾双 </template>
assert.equal((html.match(/<\/template><\/template><div class="diy-card-mini__act-cell" v-for="\(c, ci\) in nc\.customActs"/g) || []).length, 2, comp + ' 自定义动作 cell 在双层 template v-for 之外(上/下两副本)');
assert.doesNotMatch(html, /(?<!<\/template>)<\/template><div class="diy-card-mini__act-cell" v-for="\(c, ci\) in nc\.customActs"/, comp + ' 自定义动作 cell 不紧贴单层 template 收尾(嵌套层级正确)');
assert.doesNotMatch(html, /<\/div><div class="diy-card-mini__act-cell" v-for="\(c, ci\) in nc\.customActs"/, comp + ' 自定义动作 cell 不再嵌套于 template v-for 内');
// template 标签必须配平(未闭合 template 会把后续编辑器内容吞进惰性节点, vue 全局不初始化)
assert.equal((html.match(/<template/g) || []).length, (html.match(/<\/template>/g) || []).length, comp + ' template 标签配平');
// 资源引用(2026-10-24 起带 ?v={$version} 破生产浏览器/CDN 缓存 —— 同名 CSS/JS
// 旧缓存曾致生产编辑器面板图层行与预览宫格样式全丢、竖排裸文字)
for (const r of ['css/card-select\\.css', 'css/card-panel\\.css', 'css/card-preview\\.css', 'js/card-select\\.js', 'js/card-icon-lib\\.js', 'js/card-act-icon\\.js']) {
assert.match(html, new RegExp('src="\\{\\$resource_path\\}/\\.\\./public/' + r + '\\?v=\\{\\$version\\}"'), comp + ' 资源引用带版本参数: ' + r);
}
assert.doesNotMatch(html, /public\/(css|js)\/card-[a-z-]+\.(css|js)"/, comp + ' 零无版本资源引用');
}
});
test('design.html ×2: 2.19.10 detail 对齐面板控件 + 预览消费齐备', () => {
for (const comp of ['show', 'self']) {
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
// 面板控件: 9 字段 v-model / chips / toggle
assert.match(html, /nc\.rowsBlockBg/, comp + ' rowsBlockBg chips');
assert.match(html, /nc\.rowsBlockRadius/, comp + ' rowsBlockRadius slide');
assert.match(html, /nc\.rowsBlockPad/, comp + ' rowsBlockPad slide');
assert.match(html, /nc\.qrBlockBg/, comp + ' qrBlockBg chips');
assert.match(html, /nc\.qrBlockRadius/, comp + ' qrBlockRadius slide');
assert.match(html, /nc\.qrBlockPad/, comp + ' qrBlockPad slide');
assert.doesNotMatch(html, /layoutCards/, comp + ' layoutCards 已移除');
assert.match(html, /v-model="nc\.badgeShow"/, comp + ' badgeShow toggle');
assert.match(html, /v-model="nc\.badgeText"/, comp + ' badgeText input');
assert.match(html, /v-model="nc\.showRowLabels"/, comp + ' showRowLabels toggle');
assert.match(html, /nc\.copyBtnScope/, comp + ' copyBtnScope chips');
assert.match(html, /nc\.copyBtnLook/, comp + ' copyBtnLook chips');
assert.match(html, /v-model="nc\.qrTipEm"/, comp + ' qrTipEm input');
assert.match(html, /v-model="nc\.qrTipSub"/, comp + ' qrTipSub input');
assert.match(html, /v-model="nc\.qrUrlShow"/, comp + ' qrUrlShow toggle');
// 预览消费: 徽章 + 三列行 + 复制按钮 + qrTip 多段 + URL 行
assert.match(html, /nc\.badgeShow.*badgeText|badgeText.*nc\.badgeShow/s, comp + ' 预览消费 badge');
assert.match(html, /nc\.showRowLabels/, comp + ' 预览消费 showRowLabels');
assert.match(html, /nc\.copyBtnLook/, comp + ' 预览消费 copyBtnLook');
assert.match(html, /nc\.qrTipEm/, comp + ' 预览消费 qrTipEm');
assert.match(html, /nc\.qrTipSub/, comp + ' 预览消费 qrTipSub');
assert.match(html, /nc\.qrUrlShow/, comp + ' 预览消费 qrUrlShow');
}
});
test('card-act-icon.js: 类名委托修 2.18 选图标 Bug + $set 写回 + 素材库唯一图标来源 + 次序调整(2.19+2.19.1)', () => {
const src = readFileSync(resolve(BE, 'component/view/public/js/card-act-icon.js'), 'utf8');
assert.match(src, /on\('click', '\.js-act-lib'/, '素材图标库走类名委托');
assert.doesNotMatch(src, /js-act-media/, '快捷操作媒体库委托已移除(2.19.1 图标唯一来源=素材图标库; QR 选图的 js-qr-media/openAlbum 为 2.19.3 合法新增)');
// 2.19.1 默认动作次序调整
assert.match(src, /on\('click', '\.js-act-move'/, '次序调整走类名委托');
assert.match(src, /var ACT_ORDER_DEF = \['tel', 'wx', 'mail', 'web', 'save'\];/, 'actOrder 白名单默认序');
assert.match(src, /function moveAct\(comp, key, dir\)/, 'moveAct 归一化+相邻交换');
assert.match(src, /window\.vue\.\$set\(comp, 'actOrder', cur\)/, 'actOrder 写回走 $set(存量行缺键)');
assert.match(src, /closest\('\.draggable-element'\)/, '组件实例定位 draggable-element');
assert.match(src, /data\('scope'\) \|\| 'default'\) === 'custom'/, 'custom 作用域写 customActs[idx]');
assert.match(src, /window\.vue\.\$set\(item, 'iconKey'/, 'iconKey 写回走 $set(存量行缺键)');
assert.match(src, /window\.vue\.\$set\(item, 'img'/, 'img 写回走 $set');
// svg 快照链路: 选图/改色时由委托 JS 预计算 data URI 写入 svg 字段(预览只读数据)
assert.match(src, /function setIcon\(item, patch\)/, 'setIcon 统一写图标');
assert.match(src, /window\.cardLibIconBg\(iconKey, currentColor\(item\)\)/, '选图时预计算 svg 快照');
assert.match(src, /function actColorHandler\(\$el, field\)/, '改色处理器');
assert.match(src, /on\('input', '\.js-act-color'/, '前景/底色走 input 类名委托');
assert.match(src, /window\.cardLibIconBg\(target\.item\.iconKey, currentColor\(target\.item\)\)/, '改色时重算 svg 快照(颜色实时生效)');
assert.match(src, /iconKey: '', svg: '', color: ''/, '缺行防御补齐含 svg 键');
assert.match(src, /window\.CARD_ICON_LIB \|\| \{\}/, '弹层渲染消费同源图标库');
assert.match(src, /cal-item" data-key=/, '弹层图标网格');
// 2.19.3 系统链接对话框委托(link 类型)
assert.match(src, /on\('click', '\.js-act-syslink'/, '系统链接走类名委托');
assert.match(src, /ns\.select_link\(\{\}, function \(data\)/, '调全局 ns.select_link 打开系统链接对话框');
assert.match(src, /data\.wap_url \|\| data\.url/, '回执取 wap_url 写入 value');
assert.match(src, /window\.vue\.\$set\(target\.item, 'value', url\)/, '系统链接结果 $set 写回 value');
// 2.19.3 自定义二维码选图委托(模板内联 openAlbum 不可靠, 2.18 同坑收尾)
assert.match(src, /on\('click', '\.js-qr-media'/, '二维码选图走类名委托');
assert.match(src, /window\.vue\.\$set\(comp, 'qrImage', obj\[0\]\.pic_path\)/, '媒体库回执 $set 写回 qrImage');
// 反例: 面板不再内联调 openAlbum 选快捷操作图标(2.18 Bug 根因规避)
for (const comp of ['show', 'self']) {
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
assert.doesNotMatch(html, /openAlbum\(function \(obj\) \{ if \(obj && obj\.length\) \{ if \(!nc\.actIcons\)/, comp + ' 面板不再内联调 openAlbum 写 actIcons');
}
});
test('card-preview.css: 默认线稿图标 mask+currentColor —— 前景色实时跟随(2.19.6)', () => {
const css = readFileSync(resolve(BE, 'component/view/public/css/card-preview.css'), 'utf8');
// 5 个默认线稿类必须走 mask-image(颜色与图分离) + background-color: currentColor
for (const k of ['tel', 'wx', 'mail', 'web', 'save']) {
const m = css.match(new RegExp('\\.diy-card-mini__act-svg--' + k + '\\s*\\{[^}]*\\}'));
assert.ok(m, '线稿类存在: ' + k);
const block = m[0];
assert.match(block, /mask-image:\s*url\("data:image\/svg\+xml/, k + ' 走 mask-image(非固定色 background-image)');
assert.match(block, /-webkit-mask-image:/, k + ' 带 -webkit 前缀');
assert.match(block, /background-color:\s*currentColor/, k + ' 背景消费 currentColor(跟随内联 color)');
assert.doesNotMatch(block, /background-image:/, k + ' 不再用固定色 background-image(颜色编码在图内改色无效)');
}
});