fix(diy-card): 修复两处 WXSS 不兼容 calc() + MP 兼容契约测试

微信小程序 WXSS 不支持 calc(), 两处布局在小程序上会失效:

- mini.vue corner 档: max-width: calc(100% - 170rpx) →
  .mini-head { padding-right: 180rpx }(固定值, 浮层宽 120+偏移 20+呼吸 20)
- list.vue 网格: itemStyle width calc(50% - gap/2) →
  布局盒 50% 宽 + 左右各 half-gap padding, 视觉移入内层 .item-card
  (border/圆角/padding 随之下移, 布局盒透明只算 gutter)
- 新增 tests/diy-card-mp-compat.test.js 20 项: 全组件禁 calc()/var(--)
  (先剥注释再断言) + template 标签配平(栈式匹配, 兼容插槽嵌套 template)
  + 反例自测

注: 渐变角度 linear-gradient(deg,...) 有生产先例(detail/enterprise/
forward 均在生产小程序运行), 不在禁项; 真机渲染需 HBuilderX 构建验证。
验证: 46/46(三个名片测试文件) + npm test 仅剩两个存量失败
This commit is contained in:
2026-10-21 11:58:35 +08:00
parent a841af8f5b
commit 8bdd9f1291
3 changed files with 137 additions and 22 deletions

View File

@@ -37,18 +37,22 @@
<view v-else-if="list.length" :class="['wall-grid', layout === 'list' ? 'is-list' : 'is-grid']">
<view v-for="(item, index) in list" :key="'g' + index" class="wall-item fade-up"
:style="[itemStyle, { animationDelay: (index * 60) + 'ms' }]" hover-class="btn-press" @tap="goDetail(item)">
<view class="item-avatar" :style="avatarStyle(item)">
<image v-if="item.avatar" class="avatar-img" :src="$util.img(item.avatar)" mode="aspectFill" />
<text v-else class="avatar-initial">{{ initialOf(item.name) }}</text>
</view>
<view class="item-info">
<view class="name-row">
<text class="item-name" :style="nameStyle">{{ item.name || '未命名名片' }}</text>
<text v-if="isEnterprise(item)" class="item-badge" :style="badgeStyle(item)">企业</text>
<!-- 内层卡片: 布局盒(wall-item)只负责 50% 宽+左右 gutter, 视觉在此层
(WXSS 不支持 calc(), 不能用 calc(50% - gap/2)) -->
<view class="item-card">
<view class="item-avatar" :style="avatarStyle(item)">
<image v-if="item.avatar" class="avatar-img" :src="$util.img(item.avatar)" mode="aspectFill" />
<text v-else class="avatar-initial">{{ initialOf(item.name) }}</text>
</view>
<text v-if="infoLine(item)" class="item-meta" :style="infoStyle">{{ infoLine(item) }}</text>
<view class="item-info">
<view class="name-row">
<text class="item-name" :style="nameStyle">{{ item.name || '未命名名片' }}</text>
<text v-if="isEnterprise(item)" class="item-badge" :style="badgeStyle(item)">企业</text>
</view>
<text v-if="infoLine(item)" class="item-meta" :style="infoStyle">{{ infoLine(item) }}</text>
</view>
<text v-if="showArrow" class="item-arrow" :style="infoStyle">›</text>
</view>
<text v-if="showArrow" class="item-arrow" :style="infoStyle">›</text>
</view>
</view>
@@ -144,11 +148,17 @@ export default {
},
itemStyle() {
const gap = num(this.v.gap, 10);
const width = this.layout === 'list' || this.columns === 1 ? '100%' : 'calc(50% - ' + px2rpx(gap / 2) + 'rpx)';
return this.$util.objToStyle({
width: width,
marginBottom: px2rpx(gap) + 'rpx',
});
const isGrid = this.layout !== 'list' && this.columns !== 1;
const style = { marginBottom: px2rpx(gap) + 'rpx' };
if (isGrid) {
// WXSS 不支持 calc(): 50% 宽 + 左右各 half-gap 作 gutter(视觉在内层 item-card)
style.width = '50%';
style.paddingLeft = px2rpx(gap / 2) + 'rpx';
style.paddingRight = px2rpx(gap / 2) + 'rpx';
} else {
style.width = '100%';
}
return this.$util.objToStyle(style);
},
scrollItemStyle() {
return this.$util.objToStyle({
@@ -272,11 +282,18 @@ export default {
.wall-item {
box-sizing: border-box;
display: flex;
align-items: center;
border: 2rpx solid #f0f0f0;
border-radius: 16rpx;
padding: 20rpx;
overflow: hidden;
/* 视觉卡片层: 布局盒只算宽度与 gutter, 不画外观 */
.item-card {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
border: 2rpx solid #f0f0f0;
border-radius: 16rpx;
padding: 20rpx;
overflow: hidden;
}
.item-avatar {
flex-shrink: 0;

View File

@@ -548,8 +548,10 @@ export default {
margin-top: 108rpx;
}
.mini-head-info {
max-width: calc(100% - 170rpx);
/* 头部右侧留出浮层宽度: WXSS 不支持 calc(), 用固定 padding-right
(浮层 120rpx + 右偏移 20rpx + 呼吸 20rpx ≈ 160, 取 180 富余) */
.mini-head {
padding-right: 180rpx;
}
}

View File

@@ -0,0 +1,96 @@
/**
* 名片 DIY 组件 · 微信小程序(WXSS/WXML)兼容契约测试
*
* 背景: 组件在商家端装修页配置, C 端跑在 uniapp 产物(含微信小程序)。
* WXSS 有几个 Web 不支持、小程序也不支持的坑, 本测试把它们固化为禁项:
* 1. calc() —— WXSS 不支持(曾致 corner 档浮层布局/List 网格列宽失效)
* 2. CSS 自定义属性 var(--x) —— 小程序会丢弃内联 style 里的自定义属性
* 3. 模板标签配平 —— 防 <template> 嵌套(插槽)误判/漏闭合导致编译失败
*
* 口径: 禁项断言先剥三类注释(防"注释里写不能用的写法"被算违规);
* 反例: 故意含 calc 的串必须被抓住(证明禁项断言不是恒真)。
*
* 跑法: node --test tests/diy-card-mp-compat.test.js
*/
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const DIY_DIR = path.resolve(__dirname, '../components-diy');
const FILES = ['diy-card-mini.vue', 'diy-card-show.vue', 'diy-card-self.vue', 'diy-card-list.vue', 'diy-card-entry.vue', 'diy-card-common.js'];
const read = f => fs.readFileSync(path.join(DIY_DIR, f), 'utf8');
const strip = s => s
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1');
/** uni-app 编译产物里的 void 元素(无闭合标签) */
const VOID_TAGS = new Set(['image', 'input', 'br', 'hr', 'img']);
/** 标签配平: 先剔除属性串(防 > 藏在引号里误判), 再栈式匹配 */
function checkBalance(tpl) {
const stripped = tpl
.replace(/=\s*"(?:[^"\\]|\\.)*"/g, '=""')
.replace(/=\s*'(?:[^'\\]|\\.)*'/g, "=''");
const stack = [];
const re = /<(\/?)([a-z][a-z0-9-]*)((?:\s[^>]*)?)(\/?)>/g;
let m;
while ((m = re.exec(stripped)) !== null) {
const closing = m[1] === '/';
const tag = m[2];
const selfClose = m[4] === '/';
if (VOID_TAGS.has(tag) || selfClose) continue;
if (!closing) {
stack.push(tag);
} else {
const top = stack.pop();
if (top !== tag) return '期望 </' + top + '> 实为 </' + tag + '>';
}
}
return stack.length ? '未闭合: ' + stack.join(',') : null;
}
for (const f of FILES) {
test('MP禁项: ' + f + ' 代码无 calc()(WXSS 不支持)', () => {
const code = strip(read(f));
assert.ok(!/calc\s*\(/.test(code), f + ' 含 calc(): ' + (code.match(/calc\s*\([^)]*\)/) || [''])[0]);
});
test('MP禁项: ' + f + ' 代码无 var(-- 自定义属性(小程序丢弃)', () => {
const code = strip(read(f));
assert.ok(!/var\(--/.test(code));
});
}
for (const f of FILES.filter(x => x.endsWith('.vue'))) {
test('MP编译: ' + f + ' template 标签配平', () => {
const src = read(f);
const start = src.indexOf('<template>');
const end = src.indexOf('<script');
assert.ok(start >= 0 && end > start, f + ' 缺少 <template>/<script> 结构');
const err = checkBalance(src.slice(start, end));
assert.equal(err, null, f + ' 模板标签问题: ' + err);
});
}
// =============================================================
// 反例(防恒真): 含 calc/未闭合的串必须被抓住
// =============================================================
test('反例: 含 calc 的串被禁项断言抓住', () => {
assert.ok(/calc\s*\(/.test(strip('width: calc(50% - 10rpx);')));
});
test('反例: 未闭合标签被配平检查抓住', () => {
assert.ok(checkBalance('<view><text>x</text>') !== null);
assert.ok(checkBalance('<view><text>x</text></view>') === null);
});
test('反例: 嵌套 template(插槽)不会被误判(配平通过)', () => {
assert.equal(checkBalance('<view><template slot="badge"><text>我的名片</text></template></view>'), null);
});