Merge branch 'feat/camcard' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into feat/camcard

# Conflicts:
#	components-diy/diy-card-show.vue
#	tests/diy-card-style-v2.test.js
This commit is contained in:
2026-10-24 11:28:34 +08:00
5 changed files with 170 additions and 20 deletions

View File

@@ -397,8 +397,9 @@ export function hexToRgba(hex, alpha) {
/** 默认动作类型序(图层面板行序与 C 端合并序一致) */
export const ACT_TYPES = ['tel', 'wx', 'mail', 'web', 'save'];
/** 自定义动作类型白名单(doAct 天然消费: tel/wx/mail/copy 分支 + else 兜底) */
export const ACT_ACTION_TYPES = ['tel', 'wx', 'mail', 'web', 'copy'];
/** 自定义动作类型白名单(doAct 天然消费: tel/wx/mail/copy 分支 + else 兜底;
* 2.19.3 扩展: sms 短信/map 导航/link 页面链接(系统链接对话框)) */
export const ACT_ACTION_TYPES = ['tel', 'wx', 'mail', 'web', 'copy', 'sms', 'map', 'link'];
export const ACT_LABEL_DEFAULTS = {
tel: '打电话',
@@ -440,6 +441,20 @@ export function normalizeActVis(vis) {
return out;
}
/**
* 默认动作删除(2.19.2): actDeleted = { type: 0|1 }, 1=已删除(C端不渲染, 面板行隐藏,
* 经「恢复」chips 找回)。与 actVis(眼睛临时隐藏)独立 —— 删除与隐藏 C 端均不渲染。
* 缺键/非法 = 未删除(0), 历史配置无此字段 → 零变化
*/
export function normalizeActDeleted(deleted) {
const src = deleted && typeof deleted === 'object' ? deleted : {};
const out = {};
ACT_TYPES.forEach(t => {
out[t] = String(src[t]) === '1' ? 1 : 0;
});
return out;
}
/**
* 默认动作文案(2.19): actLabels = { type: string }, 空/缺 = 内置默认
* 历史配置无此字段 → 全默认, 渲染零变化

View File

@@ -238,7 +238,7 @@ import {
WAP_LINE_ICONS, svgLineIconBg,
ACT_GRID_PRESETS, ACT_GRID_PRESET_FALLBACK, hexToRgba,
// 2.19: 显隐/文案/自定义动作归一化 + 素材图标库(与后端 CardIconLibrary 同源)
normalizeActVis, normalizeActLabels, normalizeCustomActs, normalizeActOrder, libIconBg,
normalizeActVis, normalizeActDeleted, normalizeActLabels, normalizeCustomActs, normalizeActOrder, libIconBg,
} from './diy-card-common.js';
export default {
@@ -459,6 +459,7 @@ export default {
acts() {
const raw = this.card && Array.isArray(this.card.acts) ? this.card.acts : [];
const vis = normalizeActVis(this.c.actVis);
const del = normalizeActDeleted(this.c.actDeleted);
const labels = normalizeActLabels(this.c.actLabels);
const icons = this.c.actIcons && typeof this.c.actIcons === 'object' ? this.c.actIcons : {};
const order = normalizeActOrder(this.c.actOrder);
@@ -480,12 +481,12 @@ export default {
});
const out = [];
order.forEach(t => {
if (byType[t] && vis[t] !== 0) out.push(merge(byType[t]));
if (byType[t] && vis[t] !== 0 && del[t] !== 1) out.push(merge(byType[t]));
});
raw.forEach(a => {
if (!a || !a.type) return;
const t = String(a.type);
if (order.indexOf(t) >= 0 || vis[t] === 0) return;
if (order.indexOf(t) >= 0 || vis[t] === 0 || del[t] === 1) return;
out.push(merge(a));
});
normalizeCustomActs(this.c.customActs).forEach(ca => {

View File

@@ -121,6 +121,7 @@ export default {
qrImage: v.qrImage,
// 2.19: 默认动作显隐/文案 + 自定义动作(基件 acts computed 合成)
actVis: v.actVis,
actDeleted: v.actDeleted,
actLabels: v.actLabels,
actOrder: v.actOrder,
customActs: v.customActs,
@@ -211,6 +212,10 @@ export default {
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: () => {} });
@@ -246,6 +251,36 @@ export default {
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,

View File

@@ -401,6 +401,10 @@ export default {
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: () => {} });
@@ -436,6 +440,36 @@ export default {
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,

View File

@@ -227,6 +227,11 @@ test('mini.vue + show/self: save(存通讯录)动作全链路(2.17.1)', () => {
}
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)');
}
});
@@ -307,7 +312,8 @@ test('2.18: 操作区位置/独立样式/每按钮图标/自定义二维码 全
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, /openAlbum\(function \(obj\)[\s\S]*?nc\.qrImage = obj\[0\]\.pic_path/, comp + ' 二维码选图走 openAlbum 媒体库');
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 + ' 预览下方档互斥守卫');
@@ -365,7 +371,7 @@ test('反例自测: 新形态标记不得出现在未改造组件中', () => {
* 2.19 快捷操作: 显示控制 + 自定义操作 + 素材图标库 + 图层式管理
* ------------------------------------------------------------------ */
const V19_FIELDS = { actVis: 'object', actLabels: 'object', customActs: 'array', actOrder: 'array' };
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 字段(默认值=全显/默认文案/空数组/历史顺序=零变化)', () => {
@@ -381,6 +387,8 @@ test('diy_view.php: Show/Self value 均含 2.19 字段(默认值=全显/默认
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');
}
});
@@ -434,13 +442,16 @@ test('common.js: normalizeActVis / normalizeActLabels / normalizeCustomActs / no
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', 'normalizeActLabels', 'normalizeCustomActs', 'normalizeActOrder'].map(grab).join('\n') + '\n'
+ 'return [normalizeActVis, normalizeActLabels, normalizeCustomActs, normalizeActOrder];';
const [visFn, labelFn, customFn, orderFn] = new Function(body)();
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 改名生效');
@@ -458,6 +469,8 @@ test('common.js: normalizeActVis / normalizeActLabels / normalizeCustomActs / no
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'], '重排去重+非法剔除+缺失尾补');
@@ -494,8 +507,11 @@ test('mini.vue: acts 合成(显隐过滤 + 次序 + 文案覆盖 + 自定义动
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 => \{[\s\S]*?if \(byType\[t\] && vis\[t\] !== 0\) out\.push\(merge\(byType\[t\]\)\);/, '按 order 次序取默认动作(vis=0 过滤)');
assert.match(src, /acts\(\)\s*\{[\s\S]*?if \(order\.indexOf\(t\) >= 0 \|\| vis\[t\] === 0\) return;/, 'order 外扩展类型尾接且隐藏不渲染');
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)');
@@ -505,12 +521,12 @@ test('mini.vue: acts 合成(显隐过滤 + 次序 + 文案覆盖 + 自定义动
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, normalizeActLabels, normalizeCustomActs, normalizeActOrder, libIconBg/, 'mini.vue 引入 2.19+2.19.1 依赖');
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', 'actLabels', 'actOrder', 'customActs']) {
for (const f of ['actVis', 'actDeleted', 'actLabels', 'actOrder', 'customActs']) {
assert.match(vue, new RegExp(f + ': v\\.' + f + ','), file + ' 透传 ' + f);
}
}
@@ -522,13 +538,22 @@ test('design.html ×2: 图层式管理面板 + 素材图标库 + 预览显隐/
// 图层管理器: 眼睛开关 / 图标缩略 / 行内改名 / 素材库+媒体库按钮 / 清除
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');
assert.match(html, /v-model="nc\.actLabels\[a\.k\]" maxlength="6"/, comp + ' 行内改名 v-model actLabels');
// 存量组件防御(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 + ' 上移排序');
@@ -536,13 +561,18 @@ test('design.html ×2: 图层式管理面板 + 素材图标库 + 预览显隐/
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.equal((html.match(/v-if="a\.k === k && !\(nc\.actVis && nc\.actVis\[a\.k\] === 0\)"/g) || []).length, 2, comp + ' 预览显隐过滤(嵌套 key 匹配)');
assert.match(html, /\{\{ \(nc\.actLabels && nc\.actLabels\[a\.k\]\) \|\| a\.lb \}\}/, comp + ' 预览文案覆盖');
// svg 快照口径: 预览只读纯数据字段(委托 JS 选图/改色时预计算 data URI 写入),
// 模板内严禁调用全局函数(模板作用域函数解析不可靠, 实测 typeof 恒 false)
@@ -557,6 +587,15 @@ test('design.html ×2: 图层式管理面板 + 素材图标库 + 预览显隐/
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 回落链存在');
assert.match(html, /nc\.actIcons\[a\.k\]\.color \? nc\.actIcons\[a\.k\]\.color : \(a\.k === 'wx' \? '#07C160' : a\.k === 'save' \? '#FF9500' : \(nc\.actGridColorMode === 'theme' \? '#2F6BFF' : a\.fg\)\)"/, comp + ' 默认行前景输入占位与预览同链(wx/save 固定色 + theme/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 之外(上/下两副本)');
@@ -564,16 +603,19 @@ test('design.html ×2: 图层式管理面板 + 素材图标库 + 预览显隐/
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 标签配平');
// 资源引用
assert.match(html, /js src="\{\$resource_path\}\/\.\.\/public\/js\/card-icon-lib\.js"/, comp + ' 引入素材图标库 JS');
assert.match(html, /js src="\{\$resource_path\}\/\.\.\/public\/js\/card-act-icon\.js"/, comp + ' 引入图标选择委托 JS');
// 资源引用(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('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|openAlbum\(function/, '媒体库委托已整体移除(2.19.1 图标唯一来源=素材图标库)');
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 白名单默认序');
@@ -592,9 +634,32 @@ test('card-act-icon.js: 类名委托修 2.18 选图标 Bug + $set 写回 + 素
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(颜色编码在图内改色无效)');
}
});