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