/** * 名片 DIY 组件 · 2.17 双形态(渐变头部/宫格快捷操作/资料区标题/复制按钮)契约测试 * * 锁死三层口径: diy_view.php 默认值 ↔ design.html 面板控件与预览 ↔ C 端 vue 消费 * 兼容铁律: 新字段默认值 = 历史形态(plain/chips/关), 存量组件行缺字段时基件 * flagOn/normalize 回落历史默认 —— 渲染零变化, 种子只补缺绝不 UPDATE * * 同时锁死 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 新字段 → 默认值(= 历史形态, 兼容存量行) */ const V2_FIELDS = { headStyle: 'plain', headNameColor: '#FFFFFF', headSubColor: '#FFFFFF', actsLayout: 'chips', actGridColorMode: 'preset', showRowsTitle: 0, rowsTitle: '联系资料', showCopyBtn: 0, }; /** 2.18 新字段 → 默认值(= 历史形态, 兼容存量行) */ const V18_FIELDS = { actsPosition: 'below', 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 字段且默认值=历史形态', () => { 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 五类型完整结构)', () => { 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/, '上方档时资料行不再重复让位'); assert.match(src, /:style="\[actsBlockStyle, cornerClearStyle\]"/, '操作区块样式 + 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, /]*\$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: '存通讯录' }; 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'); } }); 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*'(= 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(/