Compare commits
58 Commits
dev/1.0
...
feat/camca
| Author | SHA1 | Date | |
|---|---|---|---|
| 1de3146c3a | |||
| e98d3ba0e5 | |||
| 8617bb0395 | |||
| 4b99bcdd7e | |||
| bd691d2d30 | |||
| ba7988765a | |||
| b36e1d0b5c | |||
| 8d8c90d9b5 | |||
| f64b00c5f5 | |||
| a08d0c2026 | |||
| f4998d1582 | |||
| 7d1349f336 | |||
| 549a98204a | |||
| 5845474be3 | |||
| 1b6114ef58 | |||
| 6977b914d3 | |||
| 0acd9e0e7b | |||
| a26b9d6601 | |||
| ff530cd6a2 | |||
| 0b3a49f0d1 | |||
| c9d77e901d | |||
| 0c0a6a1cb8 | |||
| a595a4ae1b | |||
| ecc81b9b31 | |||
| 8348cbb179 | |||
| cb454db326 | |||
| 887c1848a4 | |||
| f9277e8644 | |||
| 41d07843e7 | |||
| 5f4a0ecea1 | |||
| f26d11f38a | |||
| efa7c736f9 | |||
| 3fb5b0b92e | |||
| e11aa27279 | |||
| 31f67c109b | |||
| df9db2e2ed | |||
| 9b33fb7719 | |||
| 6c2619a33c | |||
| d64e989c25 | |||
| 3bcf0c8c87 | |||
| 3d144a7492 | |||
| b294fc3640 | |||
| 6f9ac29aa6 | |||
| a1d8ee8d7a | |||
| d605f4c9ea | |||
| 724e7ac134 | |||
| 37cd3c2f30 | |||
| d188737a6c | |||
| 57a67bd61b | |||
| f1bd606e0b | |||
| 805bbb9598 | |||
| 8bdd9f1291 | |||
| a841af8f5b | |||
| 35137ae814 | |||
| 48a17a59f7 | |||
| cc96e2ca13 | |||
| d0fe005c4a | |||
| 699787528c |
@@ -460,4 +460,17 @@
|
|||||||
}
|
}
|
||||||
.icon-shuqing:before {
|
.icon-shuqing:before {
|
||||||
content: "\e09b";
|
content: "\e09b";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 邮箱信封图标(2026-10-22, 名片资料行方案1落地):
|
||||||
|
字体包中暂无信封字形, 以内联 SVG 背景替代(content 置空避免渲染豆腐块)。
|
||||||
|
颜色取资料行文字标签默认色 #999999(与电话/地址图标观感一致);
|
||||||
|
后续若 iconfont 字库补充了同名码点字形, 删除本条覆盖即可无缝切换 */
|
||||||
|
.icon-youxiang:before {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
width: 30rpx;
|
||||||
|
height: 30rpx;
|
||||||
|
vertical-align: middle;
|
||||||
|
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23999999' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='4' width='20' height='16' rx='2'/%3E%3Cpath d='m2 7 10 6 10-6'/%3E%3C/svg%3E") center/contain no-repeat;
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,12 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//媒体
|
//媒体
|
||||||
if (this.goodsSkuDetail.video_url) this.switchMedia = "video";
|
// 媒体显示顺序以后台商品「高级设置 → 媒体显示顺序」media_order 为准
|
||||||
|
// (1=主图在前 2=视频在前); 真正渲染媒体区的是 goods-detail-view 组件,
|
||||||
|
// 组件内部按同一规则切换, 这里保持一致避免两处规则打架
|
||||||
|
if (this.goodsSkuDetail.video_url) {
|
||||||
|
this.switchMedia = (Number(this.goodsSkuDetail.media_order) === 2) ? "video" : "img";
|
||||||
|
}
|
||||||
|
|
||||||
if (!Array.isArray(this.goodsSkuDetail.sku_images)) {
|
if (!Array.isArray(this.goodsSkuDetail.sku_images)) {
|
||||||
if (this.goodsSkuDetail.sku_images) this.goodsSkuDetail.sku_images = this.goodsSkuDetail.sku_images.split(",");
|
if (this.goodsSkuDetail.sku_images) this.goodsSkuDetail.sku_images = this.goodsSkuDetail.sku_images.split(",");
|
||||||
|
|||||||
@@ -199,7 +199,25 @@ export default {
|
|||||||
* 全局默认 onShareAppMessage:
|
* 全局默认 onShareAppMessage:
|
||||||
* uni-app 规范:组件自身的钩子优先级 > mixin,因此已显式声明的页面不受影响。
|
* uni-app 规范:组件自身的钩子优先级 > mixin,因此已显式声明的页面不受影响。
|
||||||
*/
|
*/
|
||||||
onShareAppMessage() {
|
onShareAppMessage(options) {
|
||||||
|
// 名片「发送名片」按钮(open-type="share")触发: dataset 携带名片信息 → 精准转发该名片
|
||||||
|
// (覆盖默认 cfg 逻辑; 微信小程序无法在组件内直接定义转发, 只能由页面 onShareAppMessage 提供)
|
||||||
|
if (
|
||||||
|
options &&
|
||||||
|
options.from === 'button' &&
|
||||||
|
options.target &&
|
||||||
|
options.target.dataset &&
|
||||||
|
options.target.dataset.cardId
|
||||||
|
) {
|
||||||
|
const d = options.target.dataset;
|
||||||
|
const name = d.cardName || '我的名片';
|
||||||
|
const img = (this.$util && this.$util.img ? this.$util.img(d.cardQr) : d.cardQr) || '';
|
||||||
|
return {
|
||||||
|
title: name + ' 的电子名片',
|
||||||
|
path: '/pages_tool/card/detail?card_id=' + d.cardId,
|
||||||
|
imageUrl: img
|
||||||
|
};
|
||||||
|
}
|
||||||
// 命中禁用列表的页面:即使 wx.hideShareMenu 因某些原因没生效,
|
// 命中禁用列表的页面:即使 wx.hideShareMenu 因某些原因没生效,
|
||||||
// 这里直接 return undefined / 也不主动拼 cfg,让微信回退到默认行为
|
// 这里直接 return undefined / 也不主动拼 cfg,让微信回退到默认行为
|
||||||
// (主防线是 wx.hideShareMenu,这里只是兜底)
|
// (主防线是 wx.hideShareMenu,这里只是兜底)
|
||||||
|
|||||||
@@ -203,7 +203,8 @@ export const PAGE_KEY_MAP = {
|
|||||||
// === 名片云(card 插件) ===
|
// === 名片云(card 插件) ===
|
||||||
PAGE_CARD_LIST: '/pages_tool/card/list',
|
PAGE_CARD_LIST: '/pages_tool/card/list',
|
||||||
PAGE_CARD_LEADS: '/pages_tool/card/leads',
|
PAGE_CARD_LEADS: '/pages_tool/card/leads',
|
||||||
PAGE_CARD_CONTACTS: '/pages_tool/card/contacts',
|
PAGE_CARD_CONTACTS: '/pages_tool/card/contacts',
|
||||||
|
PAGE_CARD_ENTERPRISE: '/pages_tool/card/enterprise',
|
||||||
|
|
||||||
// === 名片云 · 商家端(后台接口就绪一部分, 先上有接口的页面) ===
|
// === 名片云 · 商家端(后台接口就绪一部分, 先上有接口的页面) ===
|
||||||
PAGE_CARD_ADMIN_MANAGE: '/pages_tool/card/admin/manage',
|
PAGE_CARD_ADMIN_MANAGE: '/pages_tool/card/admin/manage',
|
||||||
|
|||||||
512
components-diy/diy-card-common.js
Normal file
512
components-diy/diy-card-common.js
Normal file
File diff suppressed because one or more lines are too long
290
components-diy/diy-card-entry.vue
Normal file
290
components-diy/diy-card-entry.vue
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
<template>
|
||||||
|
<view data-component-name="diy-card-entry" class="diy-card-entry" :class="'entry-' + stylePreset" :style="wrapStyle" hover-class="entry-press" @tap="goLink">
|
||||||
|
<!-- 图标装饰(可选) -->
|
||||||
|
<view v-if="showIcon" class="entry-icon" :style="iconWrapStyle">
|
||||||
|
<image v-if="iconType === 'image' && iconImage" class="icon-img" :src="$util.img(iconImage)" mode="aspectFit" />
|
||||||
|
<text v-else class="icon-glyph">{{ iconGlyph || '💼' }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="entry-body">
|
||||||
|
<text class="entry-title" :style="titleStyle">{{ value.title || '扫名片 · 谈合作' }}</text>
|
||||||
|
<text v-if="value.subtitle" class="entry-sub" :style="subStyle">{{ value.subtitle }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 按钮(可选; link 模式为文字箭头) -->
|
||||||
|
<view v-if="buttonStyle !== 'none' && value.btnText" :class="['entry-btn', 'btn-' + buttonStyle]" :style="btnStyle" hover-class="btn-press" hover-stay-time="60">
|
||||||
|
<text class="entry-btn-text" :style="btnTextStyle">{{ value.btnText }}</text>
|
||||||
|
<text v-if="buttonStyle === 'link'" class="btn-arrow" :style="btnArrowStyle">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 数字名片 · 名片入口横幅(装修组件)
|
||||||
|
// 预设: gradient(渐变) / solid(纯色+主题饰条) / minimal(透明轻量)
|
||||||
|
// 布局: left / center; 按钮: solid / ghost / link / none
|
||||||
|
import { CardTokens, px2rpx, flagOn, num } from './diy-card-common.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'diy-card-entry',
|
||||||
|
props: {
|
||||||
|
value: { type: Object }
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
v() {
|
||||||
|
return this.value || {};
|
||||||
|
},
|
||||||
|
stylePreset() {
|
||||||
|
const p = String(this.v.stylePreset || 'gradient');
|
||||||
|
return ['gradient', 'solid', 'minimal'].includes(p) ? p : 'gradient';
|
||||||
|
},
|
||||||
|
isCenter() {
|
||||||
|
return String(this.v.layout || 'left') === 'center';
|
||||||
|
},
|
||||||
|
showIcon() {
|
||||||
|
return flagOn(this.v.showIcon, false);
|
||||||
|
},
|
||||||
|
iconType() {
|
||||||
|
return String(this.v.iconType || 'icon') === 'image' ? 'image' : 'icon';
|
||||||
|
},
|
||||||
|
iconImage() {
|
||||||
|
return this.v.iconImage || '';
|
||||||
|
},
|
||||||
|
iconGlyph() {
|
||||||
|
return this.v.iconGlyph || '名';
|
||||||
|
},
|
||||||
|
buttonStyle() {
|
||||||
|
const b = String(this.v.buttonStyle || 'solid');
|
||||||
|
return ['solid', 'ghost', 'link', 'none'].includes(b) ? b : 'solid';
|
||||||
|
},
|
||||||
|
isGradient() {
|
||||||
|
return this.stylePreset === 'gradient';
|
||||||
|
},
|
||||||
|
isMinimal() {
|
||||||
|
return this.stylePreset === 'minimal';
|
||||||
|
},
|
||||||
|
themeColor() {
|
||||||
|
return this.v.bgColorStart || CardTokens.color.primary;
|
||||||
|
},
|
||||||
|
wrapStyle() {
|
||||||
|
const v = this.v;
|
||||||
|
const radius = px2rpx(num(v.borderRadius, CardTokens.radius.md)) + 'rpx';
|
||||||
|
const pad = px2rpx(num(v.padding, CardTokens.space.lg)) + 'rpx';
|
||||||
|
if (this.isMinimal) {
|
||||||
|
return this.$util.objToStyle({
|
||||||
|
background: 'transparent',
|
||||||
|
borderRadius: radius,
|
||||||
|
padding: pad,
|
||||||
|
boxShadow: 'none',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this.isGradient) {
|
||||||
|
return this.$util.objToStyle({
|
||||||
|
background: 'linear-gradient(135deg, ' + (v.bgColorStart || CardTokens.color.primary) + ' 0%, ' + (v.bgColorEnd || '#1E40AF') + ' 100%)',
|
||||||
|
borderRadius: radius,
|
||||||
|
padding: pad,
|
||||||
|
minHeight: '120rpx',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// solid: 主题色底 + 左侧亮饰条
|
||||||
|
return this.$util.objToStyle({
|
||||||
|
background: v.bgColorStart || CardTokens.color.primary,
|
||||||
|
borderRadius: radius,
|
||||||
|
padding: pad,
|
||||||
|
borderLeft: '8rpx solid ' + (v.bgColorEnd || 'rgba(255,255,255,0.35)'),
|
||||||
|
minHeight: '120rpx',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
iconWrapStyle() {
|
||||||
|
return this.$util.objToStyle({
|
||||||
|
background: this.isGradient || !this.isMinimal ? 'rgba(255,255,255,0.18)' : 'rgba(47,107,255,0.10)',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
titleStyle() {
|
||||||
|
return this.$util.objToStyle({
|
||||||
|
color: this.v.textColor || (this.isMinimal ? CardTokens.color.text : '#FFFFFF'),
|
||||||
|
fontSize: px2rpx(num(this.v.titleSize, 16)) + 'rpx',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
subStyle() {
|
||||||
|
return this.$util.objToStyle({
|
||||||
|
color: this.v.textColor || (this.isMinimal ? CardTokens.color.textSub : '#FFFFFF'),
|
||||||
|
fontSize: px2rpx(num(this.v.subSize, 12)) + 'rpx',
|
||||||
|
opacity: this.isMinimal ? 1 : 0.85,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
btnStyle() {
|
||||||
|
const v = this.v;
|
||||||
|
const radius = px2rpx(CardTokens.radius.pill) + 'rpx';
|
||||||
|
const pad = '0 ' + px2rpx(CardTokens.space.xl) + 'rpx';
|
||||||
|
if (this.buttonStyle === 'ghost') {
|
||||||
|
return this.$util.objToStyle({
|
||||||
|
border: '2rpx solid ' + (v.accentColor || '#FFEB3B'),
|
||||||
|
borderRadius: radius,
|
||||||
|
padding: pad,
|
||||||
|
height: px2rpx(CardTokens.touch.chipHeight) + 'rpx',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this.buttonStyle === 'link') {
|
||||||
|
return this.$util.objToStyle({ padding: '0 0 0 ' + px2rpx(CardTokens.space.sm) + 'rpx' });
|
||||||
|
}
|
||||||
|
return this.$util.objToStyle({
|
||||||
|
background: v.accentColor || '#FFEB3B',
|
||||||
|
borderRadius: radius,
|
||||||
|
padding: pad,
|
||||||
|
height: px2rpx(CardTokens.touch.chipHeight) + 'rpx',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
btnTextStyle() {
|
||||||
|
const v = this.v;
|
||||||
|
const fontSize = px2rpx(num(this.v.btnSize, 12)) + 'rpx';
|
||||||
|
if (this.buttonStyle === 'ghost') {
|
||||||
|
return this.$util.objToStyle({ color: v.accentColor || '#FFEB3B', fontSize: fontSize });
|
||||||
|
}
|
||||||
|
if (this.buttonStyle === 'link') {
|
||||||
|
return this.$util.objToStyle({ color: v.textColor || CardTokens.color.primary, fontSize: fontSize });
|
||||||
|
}
|
||||||
|
return this.$util.objToStyle({ color: '#333333', fontSize: fontSize });
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 文字箭头按钮的箭头: 仅 link 档渲染, 颜色与该档按钮文字一致;
|
||||||
|
* 字号比按钮文字略大一档(默认 14px = 28rpx, 与历史渲染一致)
|
||||||
|
*/
|
||||||
|
btnArrowStyle() {
|
||||||
|
return this.$util.objToStyle({
|
||||||
|
color: this.v.textColor || CardTokens.color.primary,
|
||||||
|
fontSize: px2rpx(num(this.v.btnSize, 12) + 2) + 'rpx',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
goLink() {
|
||||||
|
const v = this.v;
|
||||||
|
let url = '';
|
||||||
|
if (v.linkMode === 'contacts') {
|
||||||
|
url = '/pages_tool/card/contacts';
|
||||||
|
} else if (v.linkMode === 'custom') {
|
||||||
|
url = String(v.customUrl || '').trim();
|
||||||
|
if (url !== '' && url.indexOf('/') !== 0) {
|
||||||
|
uni.showToast({ title: '自定义路径需以 / 开头', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (v.linkMode === 'link') {
|
||||||
|
// 平台统一「选择链接」: link 是个对象({name,title,wap_url,page_key,page_params}),
|
||||||
|
// 走 $util.diyRedirectTo 由平台统一处理(内部页/外链/小程序/拨号 等)
|
||||||
|
const link = v.link && typeof v.link === 'object' ? v.link : null;
|
||||||
|
if (!link || !link.name) {
|
||||||
|
uni.showToast({ title: '请先选择链接', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.$util.diyRedirectTo(link);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
url = '/pages_tool/card/list';
|
||||||
|
}
|
||||||
|
if (url === '') {
|
||||||
|
uni.showToast({ title: '未配置跳转路径', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.$util.redirectTo(url);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.diy-card-entry {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.entry-center {
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.entry-body {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 96rpx;
|
||||||
|
height: 96rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.icon-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-glyph {
|
||||||
|
font-size: 48rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.entry-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-sub {
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 20rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
.entry-btn-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-arrow {
|
||||||
|
font-size: 28rpx;
|
||||||
|
margin-left: 2rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-press {
|
||||||
|
transform: scale(0.985);
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-press {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
568
components-diy/diy-card-list.vue
Normal file
568
components-diy/diy-card-list.vue
Normal file
@@ -0,0 +1,568 @@
|
|||||||
|
<template>
|
||||||
|
<view data-component-name="diy-card-list" class="diy-card-list">
|
||||||
|
<view class="card-wall" :style="wallStyle">
|
||||||
|
<!-- 标题行: 标题 + 数量 + 查看全部 -->
|
||||||
|
<view v-if="showTitle || showCount || showMore" class="wall-head">
|
||||||
|
<text v-if="showTitle" class="wall-title" :style="titleStyle">{{ value.title || '团队名片' }}</text>
|
||||||
|
<text v-if="showCount && list.length" class="wall-count" :style="countStyle">{{ list.length }} 人</text>
|
||||||
|
<view class="head-spacer"></view>
|
||||||
|
<text v-if="showMore" class="wall-more" :style="moreStyle" hover-class="btn-press" @tap="goMore">{{ moreText || '查看全部' }} ›</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 骨架屏 -->
|
||||||
|
<view v-if="loading && !list.length" class="wall-skeleton">
|
||||||
|
<view v-for="n in skeletonCount" :key="'sk' + n" class="sk-item">
|
||||||
|
<view class="sk sk-avatar"></view>
|
||||||
|
<view class="sk-col">
|
||||||
|
<view class="sk sk-line" style="width:50%"></view>
|
||||||
|
<view class="sk sk-line" style="width:70%;margin-top:14rpx;"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 横滑布局 -->
|
||||||
|
<scroll-view v-else-if="layout === 'scroll' && list.length" class="wall-scroll" scroll-x show-scrollbar="false">
|
||||||
|
<view v-for="(item, index) in list" :key="item.card_id || index" class="scroll-item fade-up" :style="scrollItemStyle" 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>
|
||||||
|
<text class="scroll-name" :style="scrollNameStyle">{{ item.name || '未命名名片' }}</text>
|
||||||
|
<text v-if="infoLine(item)" class="scroll-meta" :style="scrollMetaStyle">{{ infoLine(item) }}</text>
|
||||||
|
<text v-if="isEnterprise(item)" class="item-badge" :style="badgeStyle(item)">企业</text>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 网格/列表布局 -->
|
||||||
|
<view v-else-if="list.length" :class="['wall-grid', layout === 'list' ? 'is-list' : 'is-grid']">
|
||||||
|
<view v-for="(item, index) in list" :key="item.card_id || index" class="wall-item fade-up"
|
||||||
|
:style="itemFinalStyle(index)" hover-class="btn-press" @tap="goDetail(item)">
|
||||||
|
<!-- 内层卡片: 布局盒(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>
|
||||||
|
<view class="item-info">
|
||||||
|
<view class="name-row">
|
||||||
|
<text class="item-name" :style="gridNameStyle">{{ 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="metaStyle">{{ infoLine(item) }}</text>
|
||||||
|
</view>
|
||||||
|
<text v-if="showArrow" class="item-arrow" :style="infoStyle">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空态 -->
|
||||||
|
<view v-else-if="!loading" class="wall-empty" :style="infoStyle">
|
||||||
|
<text class="empty-icon">🗂️</text>
|
||||||
|
<text class="empty-tip">{{ cardIdsConfigured ? '暂无可展示的名片' : '商家还未挑选名片' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 数字名片 · 团队名片墙(装修组件)
|
||||||
|
// 布局: grid(1/2 列) / list(整行) / scroll(横滑); 骨架屏 + 逐项入场 + 按压反馈
|
||||||
|
import { CardTokens, px2rpx, flagOn, num, initialOf, avatarBg } from './diy-card-common.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'diy-card-list',
|
||||||
|
props: {
|
||||||
|
value: { type: Object }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
list: [],
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
v() {
|
||||||
|
return this.value || {};
|
||||||
|
},
|
||||||
|
cardIds() {
|
||||||
|
return String(this.v.cardIds || '')
|
||||||
|
.split(',')
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 20)
|
||||||
|
.join(',');
|
||||||
|
},
|
||||||
|
cardIdsConfigured() {
|
||||||
|
return String(this.v.cardIds || '').trim() !== '';
|
||||||
|
},
|
||||||
|
layout() {
|
||||||
|
const l = String(this.v.layout || 'grid');
|
||||||
|
return ['grid', 'list', 'scroll'].includes(l) ? l : 'grid';
|
||||||
|
},
|
||||||
|
columns() {
|
||||||
|
return Number(this.v.columns) === 1 ? 1 : 2;
|
||||||
|
},
|
||||||
|
showTitle() {
|
||||||
|
return String(this.v.title || '').trim() !== '';
|
||||||
|
},
|
||||||
|
showCount() {
|
||||||
|
return flagOn(this.v.showCount, false);
|
||||||
|
},
|
||||||
|
showMore() {
|
||||||
|
return flagOn(this.v.showMore, false);
|
||||||
|
},
|
||||||
|
moreText() {
|
||||||
|
return this.v.moreText || '';
|
||||||
|
},
|
||||||
|
showArrow() {
|
||||||
|
return flagOn(this.v.showArrow, true);
|
||||||
|
},
|
||||||
|
skeletonCount() {
|
||||||
|
return this.layout === 'list' ? 3 : this.columns * 2;
|
||||||
|
},
|
||||||
|
wallStyle() {
|
||||||
|
return this.$util.objToStyle({
|
||||||
|
background: this.v.bgColor || CardTokens.color.bg,
|
||||||
|
borderRadius: px2rpx(num(this.v.borderRadius, CardTokens.radius.md)) + 'rpx',
|
||||||
|
padding: px2rpx(num(this.v.padding, CardTokens.space.md)) + 'rpx',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
titleStyle() {
|
||||||
|
return this.$util.objToStyle({
|
||||||
|
color: this.v.titleColor || CardTokens.color.text,
|
||||||
|
fontSize: px2rpx(num(this.v.titleSize, 16)) + 'rpx',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** 网格/列表卡片姓名: 字号可配(默认 14px = 28rpx) */
|
||||||
|
gridNameStyle() {
|
||||||
|
return this.$util.objToStyle({
|
||||||
|
color: this.v.nameColor || CardTokens.color.text,
|
||||||
|
fontSize: px2rpx(num(this.v.nameSize, 14)) + 'rpx',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** 横滑竖卡姓名: 比网格小一档(默认 13px = 26rpx, 与历史渲染一致) */
|
||||||
|
scrollNameStyle() {
|
||||||
|
return this.$util.objToStyle({
|
||||||
|
color: this.v.nameColor || CardTokens.color.text,
|
||||||
|
fontSize: px2rpx(num(this.v.nameSize, 14) - 1) + 'rpx',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** 网格/列表卡片信息行: 字号可配(默认 11px = 22rpx) */
|
||||||
|
metaStyle() {
|
||||||
|
return this.$util.objToStyle({
|
||||||
|
color: this.v.infoColor || CardTokens.color.textSub,
|
||||||
|
fontSize: px2rpx(num(this.v.infoSize, 11)) + 'rpx',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** 横滑竖卡信息行: 比网格小一档(默认 10px = 20rpx, 与历史渲染一致) */
|
||||||
|
scrollMetaStyle() {
|
||||||
|
return this.$util.objToStyle({
|
||||||
|
color: this.v.infoColor || CardTokens.color.textSub,
|
||||||
|
fontSize: px2rpx(num(this.v.infoSize, 11) - 1) + 'rpx',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** infoColor 纯色(箭头/空态用, 不带字号) */
|
||||||
|
infoStyle() {
|
||||||
|
return this.$util.objToStyle({ color: this.v.infoColor || CardTokens.color.textSub });
|
||||||
|
},
|
||||||
|
countStyle() {
|
||||||
|
return this.$util.objToStyle({
|
||||||
|
color: CardTokens.color.primary,
|
||||||
|
background: 'rgba(47,107,255,0.08)',
|
||||||
|
// 人数徽标比信息行小一档(默认 10px = 20rpx, 与历史渲染一致)
|
||||||
|
fontSize: px2rpx(num(this.v.infoSize, 11) - 1) + 'rpx',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
moreStyle() {
|
||||||
|
return this.$util.objToStyle({
|
||||||
|
color: CardTokens.color.textLabel,
|
||||||
|
fontSize: px2rpx(num(this.v.infoSize, 11)) + 'rpx',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
itemStyle() {
|
||||||
|
const gap = num(this.v.gap, 10);
|
||||||
|
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({
|
||||||
|
width: '260rpx',
|
||||||
|
marginRight: px2rpx(num(this.v.gap, 10)) + 'rpx',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'value.cardIds'() {
|
||||||
|
this.fetchData();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.fetchData();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* 列表项最终样式: itemStyle + 逐项入场动画延迟 合并输出单对象。
|
||||||
|
* uni-app 运行时会静默丢弃 :style="[A, B]" 数组绑定(2026-10-24 mini.vue
|
||||||
|
* 操作区同根因修复的先例), 数组形态禁止回归
|
||||||
|
*/
|
||||||
|
itemFinalStyle(index) {
|
||||||
|
return Object.assign({}, this.itemStyle, { animationDelay: (index * 60) + 'ms' });
|
||||||
|
},
|
||||||
|
initialOf,
|
||||||
|
avatarStyle(item) {
|
||||||
|
return this.$util.objToStyle({ background: avatarBg(item) });
|
||||||
|
},
|
||||||
|
badgeStyle(item) {
|
||||||
|
return this.$util.objToStyle({ background: avatarBg(item) });
|
||||||
|
},
|
||||||
|
isEnterprise(item) {
|
||||||
|
return item && item.card_type === 'enterprise';
|
||||||
|
},
|
||||||
|
infoLine(item) {
|
||||||
|
const showJob = flagOn(this.v.showJob, true);
|
||||||
|
const showCompany = flagOn(this.v.showCompany, true);
|
||||||
|
const parts = [];
|
||||||
|
if (showJob && item.job_title) parts.push(item.job_title);
|
||||||
|
if (showCompany && item.company) parts.push(item.company);
|
||||||
|
return parts.join(' · ');
|
||||||
|
},
|
||||||
|
fetchData() {
|
||||||
|
if (this.cardIds === '') {
|
||||||
|
this.list = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.$api.sendRequest({
|
||||||
|
url: '/card/api/diy/cards',
|
||||||
|
data: { card_ids: this.cardIds },
|
||||||
|
success: res => {
|
||||||
|
this.loading = false;
|
||||||
|
if (res.code == 0 && res.data) {
|
||||||
|
this.list = res.data.list || [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
goDetail(item) {
|
||||||
|
if (!item || !item.card_id) return;
|
||||||
|
this.$util.redirectTo('/pages_tool/card/detail', { card_id: item.card_id });
|
||||||
|
},
|
||||||
|
goMore() {
|
||||||
|
const mode = String(this.v.moreLinkMode || 'list');
|
||||||
|
let url = '/pages_tool/card/list';
|
||||||
|
if (mode === 'contacts') {
|
||||||
|
url = '/pages_tool/card/contacts';
|
||||||
|
} else if (mode === 'custom') {
|
||||||
|
url = String(this.v.moreLinkUrl || '').trim();
|
||||||
|
if (url === '' || url.indexOf('/') !== 0) {
|
||||||
|
uni.showToast({ title: '自定义路径需以 / 开头', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (mode === 'link') {
|
||||||
|
// 平台统一「选择链接」: moreLink 是个对象({name,title,wap_url,page_key,page_params}),
|
||||||
|
// 走 $util.diyRedirectTo 由平台统一处理(内部页/外链/小程序/拨号 等)
|
||||||
|
const link = this.v.moreLink && typeof this.v.moreLink === 'object' ? this.v.moreLink : null;
|
||||||
|
if (!link || !link.name) {
|
||||||
|
uni.showToast({ title: '请先选择链接', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.$util.diyRedirectTo(link);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.$util.redirectTo(url);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.diy-card-list {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.card-wall {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
.wall-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
|
||||||
|
.wall-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wall-count {
|
||||||
|
font-size: 20rpx;
|
||||||
|
margin-left: 12rpx;
|
||||||
|
padding: 2rpx 12rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wall-more {
|
||||||
|
font-size: 22rpx;
|
||||||
|
padding: 8rpx 0 8rpx 16rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 网格/列表 */
|
||||||
|
.wall-grid {
|
||||||
|
&.is-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wall-item {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
/* 视觉卡片层: 布局盒只算宽度与 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;
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.avatar-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-initial {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: 16rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 10rpx;
|
||||||
|
font-size: 18rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 2rpx 10rpx;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta {
|
||||||
|
font-size: 22rpx;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 8rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 横滑 */
|
||||||
|
.wall-scroll {
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.scroll-item {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 2rpx solid #f0f0f0;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 24rpx 16rpx;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.item-avatar {
|
||||||
|
width: 104rpx;
|
||||||
|
height: 104rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.avatar-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-initial {
|
||||||
|
font-size: 42rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-name {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-meta {
|
||||||
|
font-size: 20rpx;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 12rpx;
|
||||||
|
right: 12rpx;
|
||||||
|
font-size: 18rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 2rpx 10rpx;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空态 */
|
||||||
|
.wall-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 40rpx 0;
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 56rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
font-size: 24rpx;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 骨架屏 */
|
||||||
|
.wall-skeleton {
|
||||||
|
.sk-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
|
||||||
|
.sk-avatar {
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-col {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 16rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk {
|
||||||
|
background: linear-gradient(90deg, #f2f3f5 25%, #e8eaee 37%, #f2f3f5 63%);
|
||||||
|
background-size: 400rpx 100%;
|
||||||
|
animation: sk-shimmer 1.2s linear infinite;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
|
||||||
|
&.sk-line {
|
||||||
|
height: 24rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-up {
|
||||||
|
animation: card-fade-up 240ms ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-press {
|
||||||
|
opacity: 0.72;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sk-shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -200rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: 200rpx 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes card-fade-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1839
components-diy/diy-card-mini.vue
Normal file
1839
components-diy/diy-card-mini.vue
Normal file
File diff suppressed because it is too large
Load Diff
392
components-diy/diy-card-self.vue
Normal file
392
components-diy/diy-card-self.vue
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
<template>
|
||||||
|
<view data-component-name="diy-card-self" class="diy-card-self">
|
||||||
|
<!-- 未登录 + 登录引导兜底 -->
|
||||||
|
<view v-if="!hasToken && fallbackMode === 'none'" class="self-login" :style="boxStyle" @tap="goLogin" hover-class="btn-press">
|
||||||
|
<text class="login-tip" :style="nameStyle">{{ value.loginTip || '登录后这里展示我的名片' }}</text>
|
||||||
|
<view class="login-btn" :style="loginBtnStyle">
|
||||||
|
<text class="login-btn-text">{{ value.loginBtnText || '去登录' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 名片卡(已登录=本人名片 / 未登录=兜底名片); loading 时基件出骨架屏;
|
||||||
|
「我的名片」角标已按 2026-10-23 参考图定稿移除 -->
|
||||||
|
<diy-card-mini
|
||||||
|
v-else-if="loading || card"
|
||||||
|
:card="card"
|
||||||
|
:config="miniConfig"
|
||||||
|
:loading="loading"
|
||||||
|
@act="doAct"
|
||||||
|
@row-act="doAct"
|
||||||
|
@detail="goDetail"
|
||||||
|
>
|
||||||
|
</diy-card-mini>
|
||||||
|
|
||||||
|
<!-- 未登录提示条已按 2026-10-23 用户要求移除(不再显示「登录后即可展示我的名片」) -->
|
||||||
|
|
||||||
|
<!-- 没有任何名片可显示(非登录引导模式; 登录引导已由第一个分支承接) -->
|
||||||
|
<view v-else-if="!loading && !card" class="self-empty" :style="boxStyle">
|
||||||
|
<text class="empty-icon">🪪</text>
|
||||||
|
<text class="empty-tip" :style="subTextStyle">{{ value.noCardTip || '登录后即可展示我的名片' }}</text>
|
||||||
|
<view class="empty-btn" :style="loginBtnStyle" hover-class="btn-press" @tap="goCardList">
|
||||||
|
<text class="empty-btn-text">{{ value.noCardBtnText || '去看看名片' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 数字名片 · 我的名片(装修组件)
|
||||||
|
// 运行时判定: 已登录 → 本人名片(optionalAuth); 未登录 → 配置的兜底名片
|
||||||
|
// fallbackMode: enterprise=店铺企业名片 / card=指定 fallbackCardId / none=登录引导
|
||||||
|
// 视觉基件 diy-card-mini; 本文件管登录态/兜底/空态 CTA
|
||||||
|
export default {
|
||||||
|
name: 'diy-card-self',
|
||||||
|
props: {
|
||||||
|
value: { type: Object }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
card: null,
|
||||||
|
isSelf: false,
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
hasToken() {
|
||||||
|
try {
|
||||||
|
return !!(this.$store && this.$store.state && this.$store.state.token);
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fallbackMode() {
|
||||||
|
const m = String((this.value || {}).fallbackMode || 'enterprise');
|
||||||
|
return ['enterprise', 'card', 'none'].includes(m) ? m : 'enterprise';
|
||||||
|
},
|
||||||
|
miniConfig() {
|
||||||
|
const v = this.value || {};
|
||||||
|
return {
|
||||||
|
bgColor: v.bgColor,
|
||||||
|
nameColor: v.nameColor,
|
||||||
|
infoColor: v.infoColor,
|
||||||
|
labelColor: v.labelColor,
|
||||||
|
borderRadius: v.borderRadius,
|
||||||
|
padding: v.padding,
|
||||||
|
// 字号定制(px 口径, 缺省时基件按历史默认值渲染)
|
||||||
|
nameSize: v.nameSize,
|
||||||
|
cardTitleSize: v.cardTitleSize,
|
||||||
|
subSize: v.subSize,
|
||||||
|
labelSize: v.labelSize,
|
||||||
|
valueSize: v.valueSize,
|
||||||
|
actSize: v.actSize,
|
||||||
|
btnSize: v.btnSize,
|
||||||
|
showQuick: v.showQuick,
|
||||||
|
showDetailBtn: v.showDetailBtn,
|
||||||
|
detailBtnText: v.detailBtnText,
|
||||||
|
// 2.0 新增视觉开关
|
||||||
|
useCover: v.useCover,
|
||||||
|
showGradient: v.showGradient,
|
||||||
|
showLogo: v.showLogo,
|
||||||
|
showShadow: v.showShadow,
|
||||||
|
showRows: v.showRows,
|
||||||
|
maxRows: v.maxRows,
|
||||||
|
showQr: v.showQr,
|
||||||
|
qrTip: v.qrTip,
|
||||||
|
// 3.0 A+B 视觉升级
|
||||||
|
showRowIcons: v.showRowIcons,
|
||||||
|
rowTap: v.rowTap,
|
||||||
|
qrPosition: v.qrPosition,
|
||||||
|
// 3.1 头像开关 + 二维码样式/微调
|
||||||
|
showAvatar: v.showAvatar,
|
||||||
|
qrStyle: v.qrStyle,
|
||||||
|
qrSize: v.qrSize,
|
||||||
|
qrOffsetX: v.qrOffsetX,
|
||||||
|
qrOffsetY: v.qrOffsetY,
|
||||||
|
qrTipShow: v.qrTipShow,
|
||||||
|
// 2.17 双形态: 渐变头部 / 宫格快捷操作 / 资料区标题 / 复制按钮
|
||||||
|
headStyle: v.headStyle,
|
||||||
|
headNameColor: v.headNameColor,
|
||||||
|
headSubColor: v.headSubColor,
|
||||||
|
actsLayout: v.actsLayout,
|
||||||
|
actGridColorMode: v.actGridColorMode,
|
||||||
|
showRowsTitle: v.showRowsTitle,
|
||||||
|
rowsTitle: v.rowsTitle,
|
||||||
|
showCopyBtn: v.showCopyBtn,
|
||||||
|
// 2.18: 操作区位置/独立块样式/每按钮图标/自定义二维码图
|
||||||
|
actsPosition: v.actsPosition,
|
||||||
|
actsBlockBg: v.actsBlockBg,
|
||||||
|
actsBlockRadius: v.actsBlockRadius,
|
||||||
|
actsBlockPad: v.actsBlockPad,
|
||||||
|
actIcons: v.actIcons,
|
||||||
|
qrImage: v.qrImage,
|
||||||
|
// 2.19: 默认动作显隐/文案 + 自定义动作(基件 acts computed 合成)
|
||||||
|
actVis: v.actVis,
|
||||||
|
actDeleted: v.actDeleted,
|
||||||
|
actLabels: v.actLabels,
|
||||||
|
actOrder: v.actOrder,
|
||||||
|
customActs: v.customActs,
|
||||||
|
// 2.19.10 detail 版式对齐(三列行/复制范围外观/qrTip 富文本/URL 行/hero 徽章/分卡)
|
||||||
|
showRowLabels: v.showRowLabels,
|
||||||
|
copyBtnScope: v.copyBtnScope,
|
||||||
|
copyBtnLook: v.copyBtnLook,
|
||||||
|
qrTipEm: v.qrTipEm,
|
||||||
|
qrTipSub: v.qrTipSub,
|
||||||
|
qrUrlShow: v.qrUrlShow,
|
||||||
|
badgeShow: v.badgeShow,
|
||||||
|
badgeText: v.badgeText,
|
||||||
|
rowsBlockBg: v.rowsBlockBg,
|
||||||
|
rowsBlockRadius: v.rowsBlockRadius,
|
||||||
|
rowsBlockPad: v.rowsBlockPad,
|
||||||
|
qrBlockBg: v.qrBlockBg,
|
||||||
|
qrBlockRadius: v.qrBlockRadius,
|
||||||
|
qrBlockPad: v.qrBlockPad,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
boxStyle() {
|
||||||
|
const v = this.value || {};
|
||||||
|
return this.$util.objToStyle({
|
||||||
|
background: v.bgColor || '#FFFFFF',
|
||||||
|
borderRadius: this.px2rpx(v.borderRadius || 16) + 'rpx',
|
||||||
|
padding: this.px2rpx(v.padding || 16) + 'rpx',
|
||||||
|
boxShadow: '0 4rpx 16rpx rgba(15,23,42,0.06)',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
nameStyle() {
|
||||||
|
return this.$util.objToStyle({ color: (this.value || {}).nameColor || '#1F2329' });
|
||||||
|
},
|
||||||
|
subTextStyle() {
|
||||||
|
return this.$util.objToStyle({ color: (this.value || {}).infoColor || '#8A8F99' });
|
||||||
|
},
|
||||||
|
loginBtnStyle() {
|
||||||
|
return this.$util.objToStyle({
|
||||||
|
background: (this.card || {}).theme_color || '#2F6BFF',
|
||||||
|
height: '80rpx',
|
||||||
|
borderRadius: '999rpx',
|
||||||
|
padding: '0 48rpx',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
hasToken() {
|
||||||
|
this.fetchData();
|
||||||
|
},
|
||||||
|
'value.fallbackMode'() {
|
||||||
|
this.fetchData();
|
||||||
|
},
|
||||||
|
'value.fallbackCardId'() {
|
||||||
|
this.fetchData();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.fetchData();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
px2rpx(v) {
|
||||||
|
const n = Number(v);
|
||||||
|
return isNaN(n) ? 0 : Math.round(n * 2);
|
||||||
|
},
|
||||||
|
fetchData() {
|
||||||
|
if (!this.hasToken && this.fallbackMode === 'none') {
|
||||||
|
this.card = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.$api.sendRequest({
|
||||||
|
url: '/card/api/diy/mycard',
|
||||||
|
data: {
|
||||||
|
fallback_mode: this.fallbackMode,
|
||||||
|
fallback_card_id: String((this.value || {}).fallbackCardId || '').trim(),
|
||||||
|
},
|
||||||
|
success: res => {
|
||||||
|
this.loading = false;
|
||||||
|
if (res.code == 0 && res.data) {
|
||||||
|
this.card = res.data.card || null;
|
||||||
|
this.isSelf = res.data.is_self == 1;
|
||||||
|
} else {
|
||||||
|
this.card = null;
|
||||||
|
this.isSelf = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.loading = false;
|
||||||
|
this.card = null;
|
||||||
|
this.isSelf = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
goLogin() {
|
||||||
|
this.$util.redirectTo('/pages_tool/login/login');
|
||||||
|
},
|
||||||
|
goCardList() {
|
||||||
|
this.$util.redirectTo('/pages_tool/card/list');
|
||||||
|
},
|
||||||
|
doAct(act) {
|
||||||
|
if (!act) return;
|
||||||
|
// save(存通讯录): value 为空, 字段由本组件从 card 组装(白名单口径)
|
||||||
|
if (act.type === 'save') {
|
||||||
|
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: () => {} });
|
||||||
|
} else if (act.type === 'wx') {
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: act.value,
|
||||||
|
success: () => uni.showToast({ title: '微信号已复制', icon: 'none' }),
|
||||||
|
});
|
||||||
|
} else if (act.type === 'mail') {
|
||||||
|
// H5/公众号: 直接调起系统邮件客户端(满足「点击邮箱发邮件」)
|
||||||
|
// #ifdef H5
|
||||||
|
window.location.href = 'mailto:' + act.value;
|
||||||
|
// #endif
|
||||||
|
// 小程序/App(非 H5): 微信沙箱不支持 mailto 拉起, 复制地址并引导去邮件 App 粘贴
|
||||||
|
// #ifndef H5
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: act.value,
|
||||||
|
success: () => uni.showToast({ title: '邮箱已复制,请前往邮件App粘贴发送', icon: 'none' }),
|
||||||
|
});
|
||||||
|
// #endif
|
||||||
|
} else if (act.type === 'web') {
|
||||||
|
// 网站行点击 → 跳转到该网址
|
||||||
|
// H5/公众号: 直接在本页打开(微信内置浏览器同)
|
||||||
|
// #ifdef H5
|
||||||
|
window.location.href = act.value;
|
||||||
|
// #endif
|
||||||
|
// 小程序/App(非 H5): 经 web-view 承载页在微信内打开(需微信后台配置业务域名)
|
||||||
|
// #ifndef H5
|
||||||
|
const title = (this.card && this.card.name) || '网站';
|
||||||
|
uni.navigateTo({
|
||||||
|
url: '/pages_tool/webview/webview?src=' + encodeURIComponent(act.value) +
|
||||||
|
'&title=' + encodeURIComponent(title) + '&enable_set_title=1',
|
||||||
|
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,
|
||||||
|
success: () => uni.showToast({ title: '已复制', icon: 'none' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
goDetail(card) {
|
||||||
|
if (!card || !card.card_id) return;
|
||||||
|
this.$util.redirectTo('/pages_tool/card/detail', { card_id: card.card_id });
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 存通讯录(2.17.1): 与 diy-card-show 同构 —— 字段全部从 card 组装
|
||||||
|
* (rows 已过 shouldShow 白名单), 键名与 save-contact.vue onLoad 的 keys 一一对应。
|
||||||
|
*/
|
||||||
|
goSaveContact() {
|
||||||
|
const c = this.card || {};
|
||||||
|
const map = {};
|
||||||
|
(Array.isArray(c.rows) ? c.rows : []).forEach(r => {
|
||||||
|
if (r && r.field) map[r.field] = String(r.value || '');
|
||||||
|
});
|
||||||
|
// #ifdef H5
|
||||||
|
// H5 无通讯录 API: 复制资料文本兜底(小程序端走 save-contact 原生页)
|
||||||
|
const text = [c.name, map.mobile, map.email, c.company, c.job_title].filter(Boolean).join(' ');
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: text,
|
||||||
|
success: () => uni.showToast({ title: '名片资料已复制', icon: 'none' }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
// #endif
|
||||||
|
this.$util.redirectTo('/pages_tool/card/save-contact', {
|
||||||
|
name: c.name || '',
|
||||||
|
mobile: map.mobile || '',
|
||||||
|
company: c.company || '',
|
||||||
|
job_title: c.job_title || '',
|
||||||
|
email: map.email || '',
|
||||||
|
website: map.website || '',
|
||||||
|
address: map.address || '',
|
||||||
|
remark: map.remark || '',
|
||||||
|
wechat: map.wechat || '',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.diy-card-self {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.self-login,
|
||||||
|
.self-empty {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48rpx 24rpx;
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 64rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-tip,
|
||||||
|
.empty-tip {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn,
|
||||||
|
.empty-btn {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.login-btn-text,
|
||||||
|
.empty-btn-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-press {
|
||||||
|
opacity: 0.72;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
251
components-diy/diy-card-show.vue
Normal file
251
components-diy/diy-card-show.vue
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
<template>
|
||||||
|
<view data-component-name="diy-card-show" class="diy-card-show">
|
||||||
|
<diy-card-mini
|
||||||
|
:card="card"
|
||||||
|
:config="miniConfig"
|
||||||
|
:loading="loading"
|
||||||
|
empty-tip="商家还未选择名片"
|
||||||
|
@act="doAct"
|
||||||
|
@row-act="doAct"
|
||||||
|
@detail="goDetail"
|
||||||
|
></diy-card-mini>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 数字名片 · 精选名片(装修组件)
|
||||||
|
// 视觉基件 diy-card-mini(封面/渐变/Logo/主题色/折叠/芯片/二维码), 本文件只管数据
|
||||||
|
export default {
|
||||||
|
name: 'diy-card-show',
|
||||||
|
props: {
|
||||||
|
value: { type: Object }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
card: null,
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
miniConfig() {
|
||||||
|
const v = this.value || {};
|
||||||
|
return {
|
||||||
|
bgColor: v.bgColor,
|
||||||
|
nameColor: v.nameColor,
|
||||||
|
infoColor: v.infoColor,
|
||||||
|
labelColor: v.labelColor,
|
||||||
|
borderRadius: v.borderRadius,
|
||||||
|
padding: v.padding,
|
||||||
|
// 字号定制(px 口径, 缺省时基件按历史默认值渲染) —— 2.17 补齐:
|
||||||
|
// 此前 show 漏透传 7 个字号字段(面板配置不生效), 与 self 对齐
|
||||||
|
nameSize: v.nameSize,
|
||||||
|
cardTitleSize: v.cardTitleSize,
|
||||||
|
subSize: v.subSize,
|
||||||
|
labelSize: v.labelSize,
|
||||||
|
valueSize: v.valueSize,
|
||||||
|
actSize: v.actSize,
|
||||||
|
btnSize: v.btnSize,
|
||||||
|
showQuick: v.showQuick,
|
||||||
|
showDetailBtn: v.showDetailBtn,
|
||||||
|
detailBtnText: v.detailBtnText,
|
||||||
|
// 2.0 新增视觉开关
|
||||||
|
useCover: v.useCover,
|
||||||
|
showGradient: v.showGradient,
|
||||||
|
showLogo: v.showLogo,
|
||||||
|
showShadow: v.showShadow,
|
||||||
|
showRows: v.showRows,
|
||||||
|
maxRows: v.maxRows,
|
||||||
|
showQr: v.showQr,
|
||||||
|
qrTip: v.qrTip,
|
||||||
|
// 3.0 A+B 视觉升级
|
||||||
|
showRowIcons: v.showRowIcons,
|
||||||
|
rowTap: v.rowTap,
|
||||||
|
qrPosition: v.qrPosition,
|
||||||
|
// 3.1 头像开关 + 二维码样式/微调
|
||||||
|
showAvatar: v.showAvatar,
|
||||||
|
qrStyle: v.qrStyle,
|
||||||
|
qrSize: v.qrSize,
|
||||||
|
qrOffsetX: v.qrOffsetX,
|
||||||
|
qrOffsetY: v.qrOffsetY,
|
||||||
|
qrTipShow: v.qrTipShow,
|
||||||
|
// 2.17 双形态: 渐变头部 / 宫格快捷操作 / 资料区标题 / 复制按钮
|
||||||
|
headStyle: v.headStyle,
|
||||||
|
headNameColor: v.headNameColor,
|
||||||
|
headSubColor: v.headSubColor,
|
||||||
|
actsLayout: v.actsLayout,
|
||||||
|
actGridColorMode: v.actGridColorMode,
|
||||||
|
showRowsTitle: v.showRowsTitle,
|
||||||
|
rowsTitle: v.rowsTitle,
|
||||||
|
showCopyBtn: v.showCopyBtn,
|
||||||
|
// 2.18: 操作区位置/独立块样式/每按钮图标/自定义二维码图
|
||||||
|
actsPosition: v.actsPosition,
|
||||||
|
actsBlockBg: v.actsBlockBg,
|
||||||
|
actsBlockRadius: v.actsBlockRadius,
|
||||||
|
actsBlockPad: v.actsBlockPad,
|
||||||
|
actIcons: v.actIcons,
|
||||||
|
qrImage: v.qrImage,
|
||||||
|
// 2.19: 默认动作显隐/文案 + 自定义动作(基件 acts computed 合成)
|
||||||
|
actVis: v.actVis,
|
||||||
|
actDeleted: v.actDeleted,
|
||||||
|
actLabels: v.actLabels,
|
||||||
|
actOrder: v.actOrder,
|
||||||
|
customActs: v.customActs,
|
||||||
|
// 2.19.10 detail 版式对齐(三列行/复制范围外观/qrTip 富文本/URL 行/hero 徽章/分卡)
|
||||||
|
showRowLabels: v.showRowLabels,
|
||||||
|
copyBtnScope: v.copyBtnScope,
|
||||||
|
copyBtnLook: v.copyBtnLook,
|
||||||
|
qrTipEm: v.qrTipEm,
|
||||||
|
qrTipSub: v.qrTipSub,
|
||||||
|
qrUrlShow: v.qrUrlShow,
|
||||||
|
badgeShow: v.badgeShow,
|
||||||
|
badgeText: v.badgeText,
|
||||||
|
rowsBlockBg: v.rowsBlockBg,
|
||||||
|
rowsBlockRadius: v.rowsBlockRadius,
|
||||||
|
rowsBlockPad: v.rowsBlockPad,
|
||||||
|
qrBlockBg: v.qrBlockBg,
|
||||||
|
qrBlockRadius: v.qrBlockRadius,
|
||||||
|
qrBlockPad: v.qrBlockPad,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'value.cardId'() {
|
||||||
|
this.fetchData();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.fetchData();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fetchData() {
|
||||||
|
const cardId = String((this.value || {}).cardId || '').trim();
|
||||||
|
if (cardId === '') {
|
||||||
|
this.card = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.$api.sendRequest({
|
||||||
|
url: '/card/api/diy/card',
|
||||||
|
data: { card_id: cardId },
|
||||||
|
success: res => {
|
||||||
|
this.loading = false;
|
||||||
|
this.card = (res.code == 0 && res.data && res.data.card) ? res.data.card : null;
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.loading = false;
|
||||||
|
this.card = null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
doAct(act) {
|
||||||
|
if (!act) return;
|
||||||
|
// save(存通讯录): value 为空, 字段由本组件从 card 组装(白名单口径)
|
||||||
|
if (act.type === 'save') {
|
||||||
|
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: () => {} });
|
||||||
|
} else if (act.type === 'wx') {
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: act.value,
|
||||||
|
success: () => uni.showToast({ title: '微信号已复制', icon: 'none' }),
|
||||||
|
});
|
||||||
|
} else if (act.type === 'mail') {
|
||||||
|
// #ifdef H5
|
||||||
|
window.location.href = 'mailto:' + act.value;
|
||||||
|
// #endif
|
||||||
|
// #ifndef H5
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: act.value,
|
||||||
|
success: () => 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,
|
||||||
|
success: () => uni.showToast({ title: '已复制', icon: 'none' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
goDetail(card) {
|
||||||
|
if (!card || !card.card_id) return;
|
||||||
|
this.$util.redirectTo('/pages_tool/card/detail', { card_id: card.card_id });
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 存通讯录(2.17.1): 字段全部从 card 组装 —— rows 已过 shouldShow
|
||||||
|
* 白名单(商家隐藏的字段不下发也不落通讯录, 隐私口径与后端一致),
|
||||||
|
* name/company/job_title 为公开头部字段直接取。
|
||||||
|
* 键名与 save-contact.vue onLoad 的 keys 一一对应。
|
||||||
|
*/
|
||||||
|
goSaveContact() {
|
||||||
|
const c = this.card || {};
|
||||||
|
const map = {};
|
||||||
|
(Array.isArray(c.rows) ? c.rows : []).forEach(r => {
|
||||||
|
if (r && r.field) map[r.field] = String(r.value || '');
|
||||||
|
});
|
||||||
|
// #ifdef H5
|
||||||
|
// H5 无通讯录 API: 复制资料文本兜底(小程序端走 save-contact 原生页)
|
||||||
|
const text = [c.name, map.mobile, map.email, c.company, c.job_title].filter(Boolean).join(' ');
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: text,
|
||||||
|
success: () => uni.showToast({ title: '名片资料已复制', icon: 'none' }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
// #endif
|
||||||
|
this.$util.redirectTo('/pages_tool/card/save-contact', {
|
||||||
|
name: c.name || '',
|
||||||
|
mobile: map.mobile || '',
|
||||||
|
company: c.company || '',
|
||||||
|
job_title: c.job_title || '',
|
||||||
|
email: map.email || '',
|
||||||
|
website: map.website || '',
|
||||||
|
address: map.address || '',
|
||||||
|
remark: map.remark || '',
|
||||||
|
wechat: map.wechat || '',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.diy-card-show {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -170,6 +170,22 @@
|
|||||||
<!-- 自定义推荐码组件 -->
|
<!-- 自定义推荐码组件 -->
|
||||||
<diy-recommend-code :value="item"></diy-recommend-code>
|
<diy-recommend-code :value="item"></diy-recommend-code>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="item.componentName == 'DiyCardEntry'">
|
||||||
|
<!-- 数字名片 · 名片入口横幅 -->
|
||||||
|
<diy-card-entry :value="item"></diy-card-entry>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.componentName == 'DiyCardList'">
|
||||||
|
<!-- 数字名片 · 团队名片墙 -->
|
||||||
|
<diy-card-list :value="item"></diy-card-list>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.componentName == 'DiyCardShow'">
|
||||||
|
<!-- 数字名片 · 精选名片 -->
|
||||||
|
<diy-card-show :value="item"></diy-card-show>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.componentName == 'DiyCardSelf'">
|
||||||
|
<!-- 数字名片 · 我的名片(登录态自适应) -->
|
||||||
|
<diy-card-self :value="item"></diy-card-self>
|
||||||
|
</template>
|
||||||
<template v-if="item.componentName == 'GoodsBrand'">
|
<template v-if="item.componentName == 'GoodsBrand'">
|
||||||
<!-- 商品品牌 -->
|
<!-- 商品品牌 -->
|
||||||
<diy-goods-brand :value="item"></diy-goods-brand>
|
<diy-goods-brand :value="item"></diy-goods-brand>
|
||||||
|
|||||||
@@ -105,6 +105,24 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
/**
|
||||||
|
* 应用后台「高级设置 → 媒体显示顺序」(media_order)
|
||||||
|
* 1 = 主图在前(默认) 2 = 视频在前
|
||||||
|
* 商品没有视频时一律主图在前
|
||||||
|
*/
|
||||||
|
applyMediaOrder() {
|
||||||
|
const detail = this.goodsSkuDetail || {};
|
||||||
|
if (!detail.video_url) {
|
||||||
|
this.switchMedia = 'img';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.switchMedia = Number(detail.media_order) === 2 ? 'video' : 'img';
|
||||||
|
},
|
||||||
|
// 手动切换图片/视频
|
||||||
|
switchMediaTab(key) {
|
||||||
|
this.switchMedia = key;
|
||||||
|
if (key === 'img' && this.videoContext) this.videoContext.pause();
|
||||||
|
},
|
||||||
init(params) {
|
init(params) {
|
||||||
this.skuId = params.sku_id;
|
this.skuId = params.sku_id;
|
||||||
this.goodsId = params.goods_id;
|
this.goodsId = params.goods_id;
|
||||||
@@ -136,6 +154,9 @@ export default {
|
|||||||
|
|
||||||
this.videoContext = uni.createVideoContext('goodsVideo');
|
this.videoContext = uni.createVideoContext('goodsVideo');
|
||||||
|
|
||||||
|
// 媒体显示顺序: 以后台商品「高级设置 → 媒体显示顺序」为准
|
||||||
|
this.applyMediaOrder();
|
||||||
|
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
this.goodsSyncToGoodsCircle();
|
this.goodsSyncToGoodsCircle();
|
||||||
// #endif
|
// #endif
|
||||||
|
|||||||
@@ -65,10 +65,11 @@
|
|||||||
|
|
||||||
<!-- 切换视频、图片 -->
|
<!-- 切换视频、图片 -->
|
||||||
<view class="media-mode" v-if="goodsSkuDetail.video_url != ''">
|
<view class="media-mode" v-if="goodsSkuDetail.video_url != ''">
|
||||||
<text :class="{ 'color-base-bg': switchMedia == 'video' }" @click="switchMedia = 'video'">{{
|
<text v-for="tab in mediaTabs" :key="tab.key"
|
||||||
$lang('goods.detail.video') }}</text>
|
:class="{ 'color-base-bg': switchMedia == tab.key }"
|
||||||
<text :class="{ 'color-base-bg': switchMedia == 'img' }"
|
@click="switchMediaTab(tab.key)">{{
|
||||||
@click="(switchMedia = 'img'), videoContext.pause()">{{ $lang('goods.detail.image') }}</text>
|
tab.key == 'video' ? $lang('goods.detail.video') : $lang('goods.detail.image')
|
||||||
|
}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -684,6 +685,24 @@ export default {
|
|||||||
// 用于「未拿到任何数据时不显示真实内容」, 避免加载失败/异常时切到空详情页
|
// 用于「未拿到任何数据时不显示真实内容」, 避免加载失败/异常时切到空详情页
|
||||||
hasRealData() {
|
hasRealData() {
|
||||||
return this.goodsSkuDetail && Number(this.goodsSkuDetail.goods_id) > 0;
|
return this.goodsSkuDetail && Number(this.goodsSkuDetail.goods_id) > 0;
|
||||||
|
},
|
||||||
|
// 媒体顺序的变更标识: 只在「媒体显示顺序 / 视频地址」变化时重新应用
|
||||||
|
// 用它而不是直接 watch goodsSkuDetail(深度监听), 是为了避免用户手动切到
|
||||||
|
// 「视频」后被详情数据的任意一次深层变更打回「主图」
|
||||||
|
mediaOrderKey() {
|
||||||
|
const detail = this.goodsSkuDetail || {};
|
||||||
|
return (detail.media_order || 1) + '|' + (detail.video_url || '');
|
||||||
|
},
|
||||||
|
// 底部「图片 / 视频」切换按钮的排列, 与后台设置的媒体显示顺序保持一致
|
||||||
|
mediaTabs() {
|
||||||
|
const detail = this.goodsSkuDetail || {};
|
||||||
|
const tabs = [{ key: 'img' }, { key: 'video' }];
|
||||||
|
return Number(detail.media_order) === 2 ? tabs.reverse() : tabs;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
mediaOrderKey() {
|
||||||
|
this.applyMediaOrder();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mixins: [scroll, detail]
|
mixins: [scroll, detail]
|
||||||
|
|||||||
@@ -18,6 +18,13 @@
|
|||||||
:class="ds.layout === 'compact' ? 'dc-cover-compact' : 'dc-cover-hero'">
|
:class="ds.layout === 'compact' ? 'dc-cover-compact' : 'dc-cover-hero'">
|
||||||
<image class="dc-cover-img" :src="$util.img(ds.cover)" mode="aspectFill"></image>
|
<image class="dc-cover-img" :src="$util.img(ds.cover)" mode="aspectFill"></image>
|
||||||
</view>
|
</view>
|
||||||
|
<!-- 名片二维码入口: 名片卡右上角(2026-10-21 从底部宫格移入; 点击打开名片二维码弹层) -->
|
||||||
|
<view class="dc-qr" @tap="openShare">
|
||||||
|
<text class="iconfont icon-erweima" :style="{ color: '#000000' }"></text>
|
||||||
|
</view>
|
||||||
|
<!-- 预览名片: 二维码块下方, 纯文字四个字(2026-10-21 去背景去药丸, 只留「预览名片」;
|
||||||
|
点击打开本卡公开页, 后端 wap 模板完整渲染画布, 永远跟后台一致) -->
|
||||||
|
<view class="dc-preview" @tap="previewCard" v-if="shareUrl">预览名片</view>
|
||||||
<!-- 文字色/字号内联: 小程序端 var(--card-text / --card-font-scale) 失效 -->
|
<!-- 文字色/字号内联: 小程序端 var(--card-text / --card-font-scale) 失效 -->
|
||||||
<view class="dc-name" :style="{ color: ds.textColor }">{{ relation.name || '未命名名片' }}</view>
|
<view class="dc-name" :style="{ color: ds.textColor }">{{ relation.name || '未命名名片' }}</view>
|
||||||
<view class="dc-job" v-if="relation.job_title" :style="{ color: ds.textSoft, fontSize: (28 * ds.fontScaleNum) + 'rpx' }">{{ relation.job_title }}</view>
|
<view class="dc-job" v-if="relation.job_title" :style="{ color: ds.textSoft, fontSize: (28 * ds.fontScaleNum) + 'rpx' }">{{ relation.job_title }}</view>
|
||||||
@@ -35,8 +42,10 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 卡下操作条(参考图: 三项等分, 项与项之间用浅灰竖线分隔) -->
|
<!-- 卡下操作条(参考图: 三项等分, 项与项之间用浅灰竖线分隔)
|
||||||
<view class="card-actions" v-if="relation">
|
2026-10-21: 编辑资料/名片样式/切换名片 暂隐藏(showLegacyActions=false), 代码保留可随时恢复;
|
||||||
|
预览名片已移入名片卡右侧(.dc-preview), 此处不再重复放置 -->
|
||||||
|
<view class="card-actions" v-if="relation && showLegacyActions">
|
||||||
<view class="ca-item" @tap="goEdit">
|
<view class="ca-item" @tap="goEdit">
|
||||||
<text class="ca-icon iconfont icon-bianji"></text>
|
<text class="ca-icon iconfont icon-bianji"></text>
|
||||||
<text>编辑资料</text>
|
<text>编辑资料</text>
|
||||||
@@ -51,11 +60,6 @@
|
|||||||
<text class="ca-icon iconfont icon-shuaxin"></text>
|
<text class="ca-icon iconfont icon-shuaxin"></text>
|
||||||
<text>切换名片</text>
|
<text>切换名片</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="ca-divider"></view>
|
|
||||||
<!-- 预览名片: 打开本卡公开页(后端 wap 模板完整渲染画布, 永远跟后台一致) -->
|
|
||||||
<view class="ca-item" @tap="previewCard" v-if="shareUrl">
|
|
||||||
<text>预览名片</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 发名片: 微信小程序端必须用原生 <button open-type="share"> ——
|
<!-- 发名片: 微信小程序端必须用原生 <button open-type="share"> ——
|
||||||
@@ -72,24 +76,23 @@
|
|||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 功能入口宫格(参考图: 发名片下方四等分图标入口) -->
|
<!-- 功能入口宫格(参考图: 发名片下方四等分图标入口)
|
||||||
<view class="entry-grid" v-if="relation">
|
2026-10-21: 潜在客户/我的人脉 暂隐藏(showLegacyActions=false), 代码保留可随时恢复;
|
||||||
|
名片二维码入口已移至名片卡右上角(.dc-qr), 宫格不再重复放置 -->
|
||||||
|
<view class="entry-grid" v-if="relation && showLegacyActions">
|
||||||
<view class="eg-item" @tap="goList">
|
<view class="eg-item" @tap="goList">
|
||||||
<text class="eg-icon iconfont icon-list" :style="{ color: ds.themeColor }"></text>
|
<text class="eg-icon iconfont icon-list" :style="{ color: ds.themeColor }"></text>
|
||||||
<text class="eg-text">名片夹</text>
|
<text class="eg-text">名片夹</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="eg-item" @tap="goLeads">
|
<view class="eg-item" @tap="goLeads" v-if="showLegacyActions">
|
||||||
<text class="eg-icon iconfont icon-yonghu3" :style="{ color: ds.themeColor }"></text>
|
<text class="eg-icon iconfont icon-yonghu3" :style="{ color: ds.themeColor }"></text>
|
||||||
<text class="eg-text">潜在客户</text>
|
<text class="eg-text">潜在客户</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="eg-item" @tap="goContacts">
|
<view class="eg-item" @tap="goContacts" v-if="showLegacyActions">
|
||||||
<text class="eg-icon iconfont icon-pengyouquan" :style="{ color: ds.themeColor }"></text>
|
<text class="eg-icon iconfont icon-pengyouquan" :style="{ color: ds.themeColor }"></text>
|
||||||
<text class="eg-text">我的人脉</text>
|
<text class="eg-text">我的人脉</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="eg-item" @tap="openShare">
|
<!-- 名片二维码入口已移至名片卡右上角(.dc-qr), 宫格不再重复放置 -->
|
||||||
<text class="eg-icon iconfont icon-erweima" :style="{ color: ds.themeColor }"></text>
|
|
||||||
<text class="eg-text">名片二维码</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 交换名片提醒条(参考图: 宫格下方出现对方要交换的名片) -->
|
<!-- 交换名片提醒条(参考图: 宫格下方出现对方要交换的名片) -->
|
||||||
@@ -229,7 +232,10 @@ export default {
|
|||||||
exchangeList: [],
|
exchangeList: [],
|
||||||
exchangeCount: 0,
|
exchangeCount: 0,
|
||||||
// 商家是否开启「员工端样式自选」(GET design/presets 的 enabled); 默认 true(失败开放)
|
// 商家是否开启「员工端样式自选」(GET design/presets 的 enabled); 默认 true(失败开放)
|
||||||
designEnabled: true
|
designEnabled: true,
|
||||||
|
// 旧入口显隐开关(2026-10-21 需求: 隐藏「编辑资料/名片样式/切换名片」「潜在客户/我的人脉」,
|
||||||
|
// 隐藏非删除——改回 true 即全部恢复; 对应方法 goEdit/onStyleTap/goLeads/goContacts 均保留)
|
||||||
|
showLegacyActions: false
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -847,6 +853,32 @@ export default {
|
|||||||
padding: 40rpx 36rpx 32rpx;
|
padding: 40rpx 36rpx 32rpx;
|
||||||
box-shadow: 0 6rpx 20rpx rgba(0, 30, 80, 0.06);
|
box-shadow: 0 6rpx 20rpx rgba(0, 30, 80, 0.06);
|
||||||
|
|
||||||
|
// 名片二维码入口: 卡片右上角放大块(2026-10-21 从底部宫格移入后按需求放大到右侧整块区域)
|
||||||
|
.dc-qr {
|
||||||
|
position: absolute;
|
||||||
|
top: 24rpx;
|
||||||
|
right: 24rpx;
|
||||||
|
width: 170rpx;
|
||||||
|
height: 170rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 110rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预览名片: 二维码块下方, 纯文字四个字(2026-10-21 去背景去药丸, 无底色无边框无 padding)
|
||||||
|
.dc-preview {
|
||||||
|
position: absolute;
|
||||||
|
top: 206rpx;
|
||||||
|
right: 24rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
// 封面(hero/compact): 顶部全幅出血, 身份信息压在其下沿; classic 不展示
|
// 封面(hero/compact): 顶部全幅出血, 身份信息压在其下沿; classic 不展示
|
||||||
.dc-cover {
|
.dc-cover {
|
||||||
margin: -40rpx -36rpx 24rpx;
|
margin: -40rpx -36rpx 24rpx;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
* 入参(query, 均需 encodeURIComponent):
|
* 入参(query, 均需 encodeURIComponent):
|
||||||
* name/mobile/company/job_title/email/website/address/remark/wechat 名片字段
|
* name/mobile/company/job_title/email/website/address/remark/wechat 名片字段
|
||||||
* action=call 时进入页面直接拨号(wap 页「打电话」按钮也可走本页)
|
* action=call 时进入页面直接拨号(wap 页「打电话」按钮也可走本页)
|
||||||
|
* action=mail 时复制 email 到剪贴板并返回(web-view 内 mailto: 不生效的兜底)
|
||||||
*/
|
*/
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
@@ -70,6 +71,10 @@ export default {
|
|||||||
if (options && options.action === 'call' && this.form.mobile) {
|
if (options && options.action === 'call' && this.form.mobile) {
|
||||||
this.callNow();
|
this.callNow();
|
||||||
}
|
}
|
||||||
|
// 发邮件动作: web-view 内 mailto: 不生效, 复制邮箱后返回名片页
|
||||||
|
if (options && options.action === 'mail' && this.form.email) {
|
||||||
|
this.copyEmail();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onReady() {
|
onReady() {
|
||||||
// 微信真机才生效(模拟器 bug 见 docs/weixin-bug.md), onShow 兜底见下
|
// 微信真机才生效(模拟器 bug 见 docs/weixin-bug.md), onShow 兜底见下
|
||||||
@@ -91,6 +96,22 @@ export default {
|
|||||||
fail: () => {}
|
fail: () => {}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
// 发邮件兜底: 小程序无唤起邮件 App 的 API, 复制邮箱 + 提示后返回原名片页
|
||||||
|
copyEmail() {
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: this.form.email,
|
||||||
|
showToast: false,
|
||||||
|
success: () => {
|
||||||
|
uni.showToast({ title: '邮箱已复制,去邮件 App 粘贴发送', icon: 'none', duration: 2000 });
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.navigateBack({ fail: () => {} });
|
||||||
|
}, 1400);
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
uni.showToast({ title: '复制失败,邮箱:' + this.form.email, icon: 'none', duration: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
saveToContacts() {
|
saveToContacts() {
|
||||||
if (this.saving) return;
|
if (this.saving) return;
|
||||||
if (!this.form.name && !this.form.mobile) {
|
if (!this.form.name && !this.form.mobile) {
|
||||||
|
|||||||
205
tests/diy-card-ab-visual.test.js
Normal file
205
tests/diy-card-ab-visual.test.js
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* 名片 DIY 组件 · A+B 视觉升级契约测试
|
||||||
|
*
|
||||||
|
* 背景: 路径A(组件卡跳详情页) + 路径B(组件内复刻参考图视觉) 组合方案:
|
||||||
|
* - 资料行图标(电话/微信/地址; 无字形字段退化文字标签, 与详情页同口径)
|
||||||
|
* - 资料行可点(href 派生: 拨号/邮箱/复制)
|
||||||
|
* - 二维码四档任意位(rows 行下 / head 头部右侧 / corner 右上浮层 / bottom 主按钮上)
|
||||||
|
* - corner 档 Logo 自动避让 + 资料行让位(防遮挡)
|
||||||
|
*
|
||||||
|
* 覆盖:
|
||||||
|
* 1. common.js 纯函数(动态 import 真值断言, 非正则)
|
||||||
|
* 2. mini.vue 模板/计算/方法契约(先剥注释再断言)
|
||||||
|
* 3. show/self 透传 + @row-act 接线
|
||||||
|
* 4. 反例: 虚构字段/不存在的档位必须查不到
|
||||||
|
*
|
||||||
|
* 跑法: node --test tests/diy-card-ab-visual.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 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');
|
||||||
|
|
||||||
|
const showSrc = strip(read('diy-card-show.vue'));
|
||||||
|
const selfSrc = strip(read('diy-card-self.vue'));
|
||||||
|
const miniSrc = strip(read('diy-card-mini.vue'));
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 1. common.js 纯函数(真值断言)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('common: rowIconFor 字段→图标映射(无字形字段退化为空)', async () => {
|
||||||
|
const m = await import('../components-diy/diy-card-common.js');
|
||||||
|
assert.equal(m.rowIconFor('mobile'), 'icon-dianhua');
|
||||||
|
assert.equal(m.rowIconFor('phone'), 'icon-dianhua');
|
||||||
|
assert.equal(m.rowIconFor('wechat'), 'icon-weixin');
|
||||||
|
assert.equal(m.rowIconFor('address'), 'icon-dizhi');
|
||||||
|
// 邮箱: 方案1(2026-10-22)预留 icon-youxiang, 需字库补充信封字形后方能显示, 多键名兜底
|
||||||
|
assert.equal(m.rowIconFor('email'), 'icon-youxiang');
|
||||||
|
assert.equal(m.rowIconFor('mail'), 'icon-youxiang');
|
||||||
|
// 网站(2026-10-22): 复用链条字形 icon-fuzhilianjie; 自定义/未知 → 空串(退化为文字标签)
|
||||||
|
assert.equal(m.rowIconFor('website'), 'icon-fuzhilianjie');
|
||||||
|
assert.equal(m.rowIconFor('custom'), '');
|
||||||
|
assert.equal(m.rowIconFor(''), '');
|
||||||
|
assert.equal(m.rowIconFor(undefined), '');
|
||||||
|
// 2.17.1: rowCssIconFor —— 邮箱/网站走 CSS 描边图标(信封/线框地球), 口径互补
|
||||||
|
assert.equal(m.rowCssIconFor('email'), 'mail');
|
||||||
|
assert.equal(m.rowCssIconFor('website'), 'web');
|
||||||
|
assert.equal(m.rowCssIconFor('mobile'), '', '有 iconfont 字形的字段不走 CSS 档');
|
||||||
|
assert.equal(m.rowCssIconFor(''), '');
|
||||||
|
assert.equal(m.rowCssIconFor(undefined), '');
|
||||||
|
// 2.18.1: 宫格图标 = WAP_LINE_ICONS(与 wap detail 快捷操作同源 SVG 线稿), wx/save 固定色
|
||||||
|
assert.equal(m.WAP_LINE_ICONS.tel.mode, 'stroke');
|
||||||
|
assert.equal(m.WAP_LINE_ICONS.wx.mode, 'fill');
|
||||||
|
assert.equal(m.WAP_LINE_ICONS.wx.color, '#07C160', 'wx 微信绿固定色');
|
||||||
|
assert.equal(m.WAP_LINE_ICONS.save.color, '#FF9500', 'save 通讯录橙固定色');
|
||||||
|
assert.equal(m.WAP_LINE_ICONS.mail.mode, 'stroke');
|
||||||
|
assert.equal(m.WAP_LINE_ICONS.web.mode, 'stroke');
|
||||||
|
assert.ok(m.svgLineIconBg('unknown', '#000') === '', '未知类型空串');
|
||||||
|
assert.ok(m.svgLineIconBg('tel', '#0F6E56').indexOf('base64,') > 0, 'data URI base64 形态');
|
||||||
|
// 宫格 preset 补 save 紫色档
|
||||||
|
assert.deepEqual(m.ACT_GRID_PRESETS.save, { bg: '#F0E9FB', fg: '#5B3DA8' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('common: rowActOf 由 href 派生点击动作', async () => {
|
||||||
|
const m = await import('../components-diy/diy-card-common.js');
|
||||||
|
assert.deepEqual(m.rowActOf({ field: 'phone', value: '22644212509', href: 'tel:22644212509' }), { type: 'tel', value: '22644212509' });
|
||||||
|
assert.deepEqual(m.rowActOf({ field: 'email', value: 'a@b.com', href: 'mailto:a@b.com' }), { type: 'mail', value: 'a@b.com' });
|
||||||
|
// 网站行: http(s) 派生 web(点击经 web-view 承载页跳转, 2026-10-23 用户需求)
|
||||||
|
assert.deepEqual(m.rowActOf({ field: 'website', value: 'https://x.com', href: 'https://x.com' }), { type: 'web', value: 'https://x.com' });
|
||||||
|
// 空 href(地址/自定义)或空值 → 不可点
|
||||||
|
assert.equal(m.rowActOf({ field: 'address', value: '上海', href: '' }), null);
|
||||||
|
assert.equal(m.rowActOf({ field: 'phone', value: '', href: 'tel:' }), null);
|
||||||
|
assert.equal(m.rowActOf(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('common: 二维码四档归一化 + 尺寸表', async () => {
|
||||||
|
const m = await import('../components-diy/diy-card-common.js');
|
||||||
|
for (const p of ['rows', 'head', 'corner', 'bottom']) {
|
||||||
|
assert.equal(m.normalizeQrPosition(p), p);
|
||||||
|
}
|
||||||
|
// 未配置/非法值 → rows(保守默认, 不炸)
|
||||||
|
assert.equal(m.normalizeQrPosition(''), 'rows');
|
||||||
|
assert.equal(m.normalizeQrPosition('sideways'), 'rows');
|
||||||
|
assert.equal(m.normalizeQrPosition(undefined), 'rows');
|
||||||
|
assert.deepEqual(Object.keys(m.QR_SIZES).sort(), ['bottom', 'corner', 'head', 'rows']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 2. mini.vue 基件契约
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('mini: 资料行图标列 + 文字标签退化', () => {
|
||||||
|
// 2.17.1: 行首新增 CSS 描边图标档(邮箱/网站), iconfont 档退为 v-else-if
|
||||||
|
assert.match(miniSrc, /<view v-if="rowCssIcon\(row\)" class="row-css-wrap">/);
|
||||||
|
assert.match(miniSrc, /<text v-else-if="rowIcon\(row\)" class="row-icon iconfont" :class="rowIcon\(row\)"/);
|
||||||
|
assert.match(miniSrc, /<text v-if="showRowLabels \|\| \(!rowCssIcon\(row\) && !rowIcon\(row\)\)" class="row-label"/);
|
||||||
|
// rowIcon 受 showRowIcons 开关控制(默认开); CSS 档同受此开关
|
||||||
|
assert.match(miniSrc, /rowIcon\(row\)\s*\{\s*return flagOn\(this\.c\.showRowIcons,\s*true\)/);
|
||||||
|
assert.match(miniSrc, /rowCssIcon\(row\)\s*\{\s*return flagOn\(this\.c\.showRowIcons,\s*true\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mini: 资料行可点(开关 + href 派生 + emit row-act)', () => {
|
||||||
|
assert.match(miniSrc, /rowTapable\(row\)\s*\{\s*return flagOn\(this\.c\.rowTap,\s*true\)\s*&&\s*!!rowActOf\(row\)/);
|
||||||
|
assert.match(miniSrc, /:class="\{ 'row-tappable': rowTapable\(row\), 'row-3col': showRowLabels \}"/);
|
||||||
|
assert.match(miniSrc, /@tap="onRowTap\(row\)"/);
|
||||||
|
assert.match(miniSrc, /onRowTap\(row\)\s*\{[\s\S]*?this\.\$emit\('row-act',\s*act\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mini: 二维码四档渲染分支齐全', () => {
|
||||||
|
assert.match(miniSrc, /showQrRows\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'rows'/);
|
||||||
|
assert.match(miniSrc, /showQrBottom\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'bottom'/);
|
||||||
|
assert.match(miniSrc, /showQrHead\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'head'/);
|
||||||
|
assert.match(miniSrc, /showQrCorner\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'corner'/);
|
||||||
|
// 模板四个挂点
|
||||||
|
assert.match(miniSrc, /v-if="showQrCorner" class="mini-qr mini-qr-corner"/);
|
||||||
|
assert.match(miniSrc, /v-if="showQrHead" class="mini-qr mini-qr-head"/);
|
||||||
|
// rows/bottom 档流内块级, 带 mini-qr-center 水平居中(2026-10-22: 修复二维码偏左)
|
||||||
|
assert.match(miniSrc, /v-if="showQrRows" class="mini-qr mini-qr-center"/);
|
||||||
|
assert.match(miniSrc, /v-if="showQrBottom" class="mini-qr mini-qr-center"/);
|
||||||
|
// 居中只加左右 auto 边距, 不碰 corner/head 档定位
|
||||||
|
assert.match(miniSrc, /&\.mini-qr-center \{\s*margin-left: auto;\s*margin-right: auto;\s*\}/);
|
||||||
|
// 总条件: 开关 + 有码源(qrSrc: 自定义图优先, 回落名片码)
|
||||||
|
assert.match(miniSrc, /showQrBase\(\)\s*\{\s*return flagOn\(this\.c\.showQr,\s*false\) && !!this\.qrSrc/);
|
||||||
|
// 统一码源 qrSrc: 自定义图优先, 回落名片码(2.18)
|
||||||
|
assert.match(miniSrc, /qrSrc\(\)\s*\{[\s\S]*?String\(this\.c\.qrImage \|\| ''\)\.trim\(\)[\s\S]*?card && this\.card\.qr_url/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mini: corner 档 Logo 避让 + 资料行/操作区动态让位', () => {
|
||||||
|
assert.match(miniSrc, /showLogo\(\)\s*\{[\s\S]*?this\.qrPos !== 'corner'/);
|
||||||
|
assert.match(miniSrc, /:class="\{ 'qr-at-corner': qrPos === 'corner' \}"/);
|
||||||
|
// 头部水平让位 = cornerHeadStyle 动态反算(审计修复: 静态 180rpx 在自定义大尺寸下不足)
|
||||||
|
assert.match(miniSrc, /<view class="mini-head" :style="cornerHeadStyle">/);
|
||||||
|
// 资料行/操作区让位 = cornerClearStyle 动态反算(自定义大尺寸/关 tip 自动跟随, 不用 calc)
|
||||||
|
// 2.18: 让位经 rowsClearStyle 分流(操作区在上方时资料行不重复位移); 操作区让位并入
|
||||||
|
// actsBlockFinalStyle 单绑定(2026-10-24: uni-app 静默丢弃数组绑定, 操作区独立样式曾整体失效)
|
||||||
|
assert.match(miniSrc, /rowsClearStyle\(\)\s*\{[\s\S]*?this\.actsAbove \? \{\} : this\.cornerClearStyle/);
|
||||||
|
assert.match(miniSrc, /:style="actsBlockFinalStyle"/);
|
||||||
|
assert.doesNotMatch(miniSrc, /:style="\[actsBlockStyle/);
|
||||||
|
// 静态 CSS 让位必须已移除(动态值才是单一事实来源)
|
||||||
|
assert.ok(!/padding-right: 180rpx;/.test(miniSrc), '静态 180rpx 让位应废弃, 改由 cornerHeadStyle 动态给出');
|
||||||
|
// 浮层 CSS 只定位于层次; 视觉衬底(白卡等)由 qrBlockStyle 内联决定
|
||||||
|
assert.match(miniSrc, /&\.mini-qr-corner \{\s*position: absolute;\s*top: 20rpx;\s*right: 20rpx;\s*z-index: 3;\s*margin-top: 0;\s*box-shadow:/);
|
||||||
|
assert.ok(!/&\.mini-qr-corner \{[\s\S]*?background: #ffffff;/.test(miniSrc),
|
||||||
|
'白底衬底应移入 qrBlockStyle(多彩外观时不再是白底), CSS 不写死');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 3. self miniConfig 透传 + 接线 / show 自绘资料行
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('self: miniConfig 透传 A+B 三字段', () => {
|
||||||
|
assert.match(selfSrc, /showRowIcons:\s*v\.showRowIcons/);
|
||||||
|
assert.match(selfSrc, /rowTap:\s*v\.rowTap/);
|
||||||
|
assert.match(selfSrc, /qrPosition:\s*v\.qrPosition/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('self: miniConfig 透传 2.19.10 detail 对齐 9 字段', () => {
|
||||||
|
for (const f of ['showRowLabels', 'copyBtnScope', 'copyBtnLook', 'qrTipEm', 'qrTipSub', 'qrUrlShow', 'badgeShow', 'badgeText', 'rowsBlockBg', 'rowsBlockRadius', 'rowsBlockPad', 'qrBlockBg', 'qrBlockRadius', 'qrBlockPad']) {
|
||||||
|
assert.match(selfSrc, new RegExp(f + ':\\s*v\\.' + f + ','), 'self 透传 ' + f);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('show: miniConfig 透传 2.19.10 detail 对齐 9 字段', () => {
|
||||||
|
for (const f of ['showRowLabels', 'copyBtnScope', 'copyBtnLook', 'qrTipEm', 'qrTipSub', 'qrUrlShow', 'badgeShow', 'badgeText', 'rowsBlockBg', 'rowsBlockRadius', 'rowsBlockPad', 'qrBlockBg', 'qrBlockRadius', 'qrBlockPad']) {
|
||||||
|
assert.match(showSrc, new RegExp(f + ':\\s*v\\.' + f + ','), 'show 透传 ' + f);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('self: 模板挂 @row-act="doAct"(行点击有接收方)', () => {
|
||||||
|
assert.match(selfSrc, /@row-act="doAct"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('show: 资料行/行图标/行点击走基件透传(方案A: 渲染由 mini 侧行测试覆盖)', () => {
|
||||||
|
assert.match(showSrc, /showRows:\s*v\.showRows,/);
|
||||||
|
assert.match(showSrc, /showRowIcons:\s*v\.showRowIcons,/);
|
||||||
|
assert.match(showSrc, /rowTap:\s*v\.rowTap,/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 反例(防恒真)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('反例: 不存在第五档 qrPosition 分支', () => {
|
||||||
|
assert.ok(!miniSrc.includes("qrPos === 'sideways'"));
|
||||||
|
assert.ok(!miniSrc.includes('showQrSideways'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('反例: 行图标不映射不存在的字段', async () => {
|
||||||
|
const m = await import('../components-diy/diy-card-common.js');
|
||||||
|
assert.equal(m.rowIconFor('nosuchfield'), '');
|
||||||
|
// 未知 scheme 的 href(后端实际只会下发 tel:/mailto:/安全 URL/空) → 复制兜底
|
||||||
|
assert.deepEqual(m.rowActOf({ field: 'nosuchfield', value: 'x', href: 'nosuch:x' }), { type: 'copy', value: 'x' });
|
||||||
|
// 真空 href 才不可点
|
||||||
|
assert.equal(m.rowActOf({ field: 'nosuchfield', value: 'x', href: '' }), null);
|
||||||
|
});
|
||||||
148
tests/diy-card-detail-jump.test.js
Normal file
148
tests/diy-card-detail-jump.test.js
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* 名片 DIY 组件 · 路径A(组件卡 → 跳完整名片页)链路测试
|
||||||
|
*
|
||||||
|
* 背景:
|
||||||
|
* 商家端装修页嵌入名片组件后, C 端(微页面/小程序)渲染的是组件内迷你卡。
|
||||||
|
* 参考图形态(名片卡 + 二维码 + 全宽「发名片」)的完整功能不在组件里, 而在
|
||||||
|
* 现成的会员端详情页 /pages_tool/card/detail —— 组件靠「查看完整名片」
|
||||||
|
* 全宽主按钮把用户送过去(路径A)。游客未登录时 detail.vue 自动
|
||||||
|
* fallbackToPublicPage 落地 wap 公开页(保存通讯录/分享/二维码), 不是死路。
|
||||||
|
*
|
||||||
|
* 本测试把这条链路的源码契约固化为断言, 防回归:
|
||||||
|
* 1. self 组件 miniConfig 透传 showDetailBtn + detailBtnText; show 组件自绘
|
||||||
|
* 二维码块直挂 goDetail(2026-10-21 换思路: show 不再复用 mini 基件)
|
||||||
|
* 2. 两个组件模板都挂 @detail="goDetail"(基件 emit('detail') 有接收方)
|
||||||
|
* 3. goDetail 跳 /pages_tool/card/detail 且带 card_id 参数
|
||||||
|
* 4. mini 基件: showDetailBtn flagOn 默认开(开箱即用) + 全宽按钮 emit detail
|
||||||
|
* 5. detail.vue 游客兜底: 错误信封带 wap_url → fallbackToPublicPage(死路防护)
|
||||||
|
*
|
||||||
|
* 口径: 断言前先剔除注释(HTML/块/行注释), 防止「注释里写了某写法」被算实现
|
||||||
|
* (本项目踩过: 注释里写「绝不能写 X」把自己判违规)。
|
||||||
|
* 反例: 虚构选择器/不存在的字段必须查不到, 证明断言不是恒真。
|
||||||
|
*
|
||||||
|
* 跑法: node --test tests/diy-card-detail-jump.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 CARD_DIR = path.resolve(__dirname, '../pages_tool/card');
|
||||||
|
const read = f => fs.readFileSync(path.join(DIY_DIR, f), 'utf8');
|
||||||
|
const readCard = f => fs.readFileSync(path.join(CARD_DIR, f), 'utf8');
|
||||||
|
|
||||||
|
/** 剔除三类注释: HTML <!-- --> / 块 /* *\/ / 行 // (保护 https:// 里的 //) */
|
||||||
|
function stripComments(src) {
|
||||||
|
return src
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, '')
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||||
|
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
||||||
|
}
|
||||||
|
|
||||||
|
const showSrc = stripComments(read('diy-card-show.vue'));
|
||||||
|
const selfSrc = stripComments(read('diy-card-self.vue'));
|
||||||
|
const miniSrc = stripComments(read('diy-card-mini.vue'));
|
||||||
|
const detailSrc = stripComments(readCard('detail.vue'));
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 1. 详情页入口(self 走 mini 基件透传; show 自绘二维码直点进详情)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('self: miniConfig 透传 showDetailBtn 与 detailBtnText', () => {
|
||||||
|
assert.match(selfSrc, /showDetailBtn:\s*v\.showDetailBtn/);
|
||||||
|
assert.match(selfSrc, /detailBtnText:\s*v\.detailBtnText/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('show: 二维码/发名片点击走基件 emit detail 链路(方案A)', () => {
|
||||||
|
// 基件 onQrTap(qrTapPreview 开) / onSend $emit('detail'), show 挂 @detail="goDetail" 接收后跳详情
|
||||||
|
assert.match(showSrc, /@detail="goDetail"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 2. 模板挂 @detail(基件 emit('detail') 的接收方, 断链则按钮变哑巴)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('self: 模板挂 @detail="goDetail"', () => {
|
||||||
|
assert.match(selfSrc, /@detail="goDetail"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 3. goDetail 跳会员端详情页且带 card_id(路径A落点)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('show: goDetail 跳 /pages_tool/card/detail 并带 card_id', () => {
|
||||||
|
assert.match(showSrc, /redirectTo\('\/pages_tool\/card\/detail'/);
|
||||||
|
assert.match(showSrc, /card_id:\s*card\.card_id/);
|
||||||
|
// 守卫: 无 card_id 不跳(防脏数据跳空页)
|
||||||
|
assert.match(showSrc, /if\s*\(!card\s*\|\|\s*!card\.card_id\)\s*return;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('self: goDetail 跳 /pages_tool/card/detail 并带 card_id', () => {
|
||||||
|
assert.match(selfSrc, /redirectTo\('\/pages_tool\/card\/detail'/);
|
||||||
|
assert.match(selfSrc, /card_id:\s*card\.card_id/);
|
||||||
|
assert.match(selfSrc, /if\s*\(!card\s*\|\|\s*!card\.card_id\)\s*return;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 4. mini 基件: 默认开 + 全宽按钮 + emit(路径A的渲染与出口)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('mini: showDetailBtn 默认开(历史配置无此字段时也显示, 开箱即用)', () => {
|
||||||
|
assert.match(miniSrc, /flagOn\(this\.c\.showDetailBtn,\s*true\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mini: 发送名片按钮(小程序原生转发 + H5 弹抽屉, 双分支)', () => {
|
||||||
|
assert.match(miniSrc, /class="mini-detail"/);
|
||||||
|
// 小程序端: <button open-type="share"> 直接拉起「发送给朋友」面板(与微信分享一致)
|
||||||
|
assert.match(miniSrc, /open-type="share"/);
|
||||||
|
assert.match(miniSrc, /:data-card-id="card && card\.card_id"/);
|
||||||
|
assert.match(miniSrc, /:data-card-name="card && card\.name"/);
|
||||||
|
assert.match(miniSrc, /:data-card-qr="card && card\.qr_url"/);
|
||||||
|
// H5 端: 仍弹「分享名片」抽屉(原生分享在 H5 不可用)
|
||||||
|
assert.match(miniSrc, /@tap\.stop="openCardShare"/);
|
||||||
|
assert.match(miniSrc, /openCardShare\(\)\s*\{\s*this\.showShare = true;/);
|
||||||
|
// 抽屉版式与 show 同款: 标题/副文案/二维码/复制链接
|
||||||
|
assert.match(miniSrc, /<view class="ss-title">分享名片<\/view>/);
|
||||||
|
assert.match(miniSrc, /客户打开链接即可查看名片并留言留资/);
|
||||||
|
assert.match(miniSrc, /class="ss-btn ss-btn-main" :style="shareBtnStyle" @tap="copyCardLink"/);
|
||||||
|
// 遮罩关闭 + H5 分享/小程序存码方法齐备
|
||||||
|
assert.match(miniSrc, /class="cs-mask" v-if="showShare" @tap="closeShare"/);
|
||||||
|
assert.match(miniSrc, /h5CardShare\(\)/);
|
||||||
|
assert.match(miniSrc, /saveCardQr\(\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mini: 主按钮文案可配(detailBtnText, 面板改字即生效)', () => {
|
||||||
|
assert.match(miniSrc, /detailBtnText\(\)\s*\{[\s\S]*?this\.c\.detailBtnText/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 5. detail.vue 游客兜底(路径A对游客成立的关键: 不死路)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('detail: 错误信封带 wap_url 时 fallbackToPublicPage 落地公开页', () => {
|
||||||
|
assert.match(detailSrc, /if\s*\(data\.wap_url\)\s*\{\s*this\.fallbackToPublicPage\(data\.wap_url\);/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detail: fallbackToPublicPage 双端分支(H5 直跳/非 H5 走 share 中转 web-view)', () => {
|
||||||
|
assert.match(detailSrc, /fallbackToPublicPage\(wapUrl\)\s*\{/);
|
||||||
|
assert.match(detailSrc, /window\.location\.href\s*=\s*wapUrl/);
|
||||||
|
assert.match(detailSrc, /'\/pages_tool\/card\/share\?url='\s*\+\s*encodeURIComponent\(wapUrl\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 反例自测(防恒真): 虚构字段/错误落点必须不存在
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('反例: 两个组件都不存在虚构字段 nc.__no_such_field__', () => {
|
||||||
|
assert.ok(!showSrc.includes('__no_such_field__'));
|
||||||
|
assert.ok(!selfSrc.includes('__no_such_field__'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('反例: goDetail 不跳不存在的页面', () => {
|
||||||
|
assert.ok(!showSrc.includes('/pages_tool/card/nosuchpage'));
|
||||||
|
assert.ok(!selfSrc.includes('/pages_tool/card/nosuchpage'));
|
||||||
|
});
|
||||||
215
tests/diy-card-font-size.test.js
Normal file
215
tests/diy-card-font-size.test.js
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* 名片 DIY 组件 · 字号定制化三层口径契约测试
|
||||||
|
*
|
||||||
|
* 锁死: diy_view.php 默认值 ↔ design.html 面板(slide+预览消费) ↔ C 端 vue 消费
|
||||||
|
* 口径: 字号字段一律 px 配置, C 端渲染 ×2 转 rpx(px2rpx); 默认值 = 历史硬编码值
|
||||||
|
*
|
||||||
|
* 派生关系(保证默认渲染与历史一致):
|
||||||
|
* - list 横滑竖卡: 姓名/信息比网格小一档(-1px)
|
||||||
|
* - list 人数徽标: 比信息行小一档(-1px); 「查看全部」= 信息字号
|
||||||
|
* - entry 文字箭头的箭头: 比按钮文字大一档(+2px)
|
||||||
|
*/
|
||||||
|
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');
|
||||||
|
|
||||||
|
/** 三层口径定义: 字段 → [默认值(px), 消费侧期望字面量] */
|
||||||
|
const CONTRACT = {
|
||||||
|
entry: {
|
||||||
|
defaults: { titleSize: 16, subSize: 12, btnSize: 12 },
|
||||||
|
colors: { bgColorStart: '#2F6BFF', bgColorEnd: '#1E40AF', textColor: '#FFFFFF', accentColor: '#FFEB3B' },
|
||||||
|
layout: { borderRadius: 12, padding: 16 },
|
||||||
|
vueRead: 'this.v.',
|
||||||
|
vueFile: 'diy-card-entry.vue',
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
defaults: { titleSize: 16, nameSize: 14, infoSize: 11 },
|
||||||
|
colors: { bgColor: '#FFFFFF', titleColor: '#333333', nameColor: '#1F2329', infoColor: '#888888' },
|
||||||
|
layout: { borderRadius: 12, padding: 12, gap: 10 },
|
||||||
|
vueRead: 'this.v.',
|
||||||
|
vueFile: 'diy-card-list.vue',
|
||||||
|
},
|
||||||
|
show: {
|
||||||
|
defaults: { nameSize: 17, cardTitleSize: 13, subSize: 12, labelSize: 12, valueSize: 12, actSize: 12, btnSize: 13 },
|
||||||
|
colors: { bgColor: '#FFFFFF', nameColor: '#1F2329', infoColor: '#888888', labelColor: '#999999' },
|
||||||
|
layout: { borderRadius: 16, padding: 16 },
|
||||||
|
vueRead: 'this.c.',
|
||||||
|
vueFile: 'diy-card-mini.vue',
|
||||||
|
},
|
||||||
|
self: {
|
||||||
|
defaults: { nameSize: 17, cardTitleSize: 13, subSize: 12, labelSize: 12, valueSize: 12, actSize: 12, btnSize: 13 },
|
||||||
|
colors: { bgColor: '#FFFFFF', nameColor: '#1F2329', infoColor: '#888888', labelColor: '#999999' },
|
||||||
|
layout: { borderRadius: 16, padding: 16 },
|
||||||
|
vueRead: 'this.c.',
|
||||||
|
vueFile: 'diy-card-mini.vue',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 从 diy_view.php 提取组件 value JSON(容忍单引号 PHP 串内的转义) */
|
||||||
|
const PHP_NAMES = { entry: 'DiyCardEntry', list: 'DiyCardList', 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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('diy_view.php 四组件默认值包含全部字号字段且等于历史渲染值', () => {
|
||||||
|
for (const [comp, cfg] of Object.entries(CONTRACT)) {
|
||||||
|
const value = readValueJson(comp);
|
||||||
|
for (const [field, dft] of Object.entries(cfg.defaults)) {
|
||||||
|
assert.equal(Number(value[field]), dft, comp + '.' + field + ' 默认值应为 ' + dft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diy_view.php 配色/圆角间距默认值与契约一致(重置按钮的最终事实来源)', () => {
|
||||||
|
for (const [comp, cfg] of Object.entries(CONTRACT)) {
|
||||||
|
const value = readValueJson(comp);
|
||||||
|
for (const [field, dft] of Object.entries(cfg.colors)) {
|
||||||
|
assert.equal(String(value[field]).toUpperCase(), dft.toUpperCase(), comp + '.' + field + ' 颜色默认值应为 ' + dft);
|
||||||
|
}
|
||||||
|
for (const [field, dft] of Object.entries(cfg.layout)) {
|
||||||
|
assert.equal(Number(value[field]), dft, comp + '.' + field + ' 默认值应为 ' + dft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('design.html 样式面板提供一键恢复默认, 重置字段与默认值逐一对应', () => {
|
||||||
|
for (const [comp, cfg] of Object.entries(CONTRACT)) {
|
||||||
|
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
|
||||||
|
assert.ok(html.includes('diy-card-reset'), comp + ' 样式面板缺少恢复默认按钮');
|
||||||
|
const btn = html.match(/<button type="button" class="diy-card-reset" @click="([^"]+)"/);
|
||||||
|
assert.ok(btn, comp + ' 重置按钮缺少内联 @click 赋值');
|
||||||
|
const click = btn[1];
|
||||||
|
// 字号(px 数值)
|
||||||
|
for (const [field, dft] of Object.entries(cfg.defaults)) {
|
||||||
|
assert.ok(click.includes('nc.' + field + '=' + dft), comp + ' 重置缺少 ' + field + '=' + dft);
|
||||||
|
}
|
||||||
|
// 颜色(带引号)
|
||||||
|
for (const [field, dft] of Object.entries(cfg.colors)) {
|
||||||
|
assert.ok(click.includes("nc." + field + "='" + dft + "'"), comp + ' 重置缺少 ' + field + "='" + dft + "'");
|
||||||
|
}
|
||||||
|
// 圆角/间距(px 数值)
|
||||||
|
for (const [field, dft] of Object.entries(cfg.layout)) {
|
||||||
|
assert.ok(click.includes('nc.' + field + '=' + dft), comp + ' 重置缺少 ' + field + '=' + dft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('四个 design.html 面板: 每个字号字段都有 slide 控件', () => {
|
||||||
|
for (const comp of ['diy-card-entry', 'diy-card-list', 'diy-card-show', 'diy-card-self']) {
|
||||||
|
const html = readFileSync(resolve(BE, 'component/view/' + comp + '/design.html'), 'utf8');
|
||||||
|
const fields = Object.keys(CONTRACT[comp.replace('diy-card-', '')].defaults);
|
||||||
|
for (const field of fields) {
|
||||||
|
assert.match(html, new RegExp("<slide :data=\"\\{ field: '" + field + "'"), comp + ' 面板缺少 ' + field + ' 的 slide');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('design.html 预览必须消费字号字段(防「面板可配预览不动」漂移)', () => {
|
||||||
|
const expectPreview = {
|
||||||
|
'diy-card-entry': ['nc.titleSize', 'nc.subSize', 'nc.btnSize'],
|
||||||
|
'diy-card-list': ['nc.titleSize', 'nc.nameSize', 'nc.infoSize'],
|
||||||
|
'diy-card-show': ['nc.nameSize', 'nc.subSize', 'nc.labelSize', 'nc.valueSize', 'nc.actSize', 'nc.btnSize'],
|
||||||
|
'diy-card-self': ['nc.nameSize', 'nc.subSize', 'nc.labelSize', 'nc.valueSize', 'nc.actSize', 'nc.btnSize'],
|
||||||
|
};
|
||||||
|
for (const [comp, tokens] of Object.entries(expectPreview)) {
|
||||||
|
const html = readFileSync(resolve(BE, 'component/view/' + comp + '/design.html'), 'utf8');
|
||||||
|
for (const token of tokens) {
|
||||||
|
assert.ok(html.includes(token), comp + ' 预览未消费 ' + token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C 端 vue 按默认值消费字号字段(px2rpx 渲染)', () => {
|
||||||
|
for (const [comp, cfg] of Object.entries(CONTRACT)) {
|
||||||
|
const src = readFileSync(resolve(FE, cfg.vueFile), 'utf8');
|
||||||
|
for (const [field, dft] of Object.entries(cfg.defaults)) {
|
||||||
|
const literal = 'num(' + cfg.vueRead + field + ', ' + dft + ')';
|
||||||
|
assert.ok(src.includes(literal), cfg.vueFile + ' 缺少消费 ' + literal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('self.vue miniConfig 透传全部字号字段到基件', () => {
|
||||||
|
const src = readFileSync(resolve(FE, 'diy-card-self.vue'), 'utf8');
|
||||||
|
for (const field of Object.keys(CONTRACT.self.defaults)) {
|
||||||
|
assert.ok(src.includes(field + ': v.' + field), 'miniConfig 缺少透传 ' + field);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('派生关系: 横滑/徽标 -1 档, 箭头 +1 档(默认渲染与历史一致)', () => {
|
||||||
|
const list = readFileSync(resolve(FE, 'diy-card-list.vue'), 'utf8');
|
||||||
|
assert.ok(list.includes("num(this.v.nameSize, 14) - 1"), 'list 横滑姓名应比网格小 1px');
|
||||||
|
assert.ok(list.includes("num(this.v.infoSize, 11) - 1"), 'list 横滑信息/人数徽标应比网格小 1px');
|
||||||
|
const entry = readFileSync(resolve(FE, 'diy-card-entry.vue'), 'utf8');
|
||||||
|
assert.ok(entry.includes("num(this.v.btnSize, 12) + 2"), 'entry 箭头应比按钮文字大 2px(28rpx)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('design.html 每个 slide 控件都带 default 且与 diy_view.php 注册值一致(逐项重置口径)', () => {
|
||||||
|
for (const [comp, cfg] of Object.entries(CONTRACT)) {
|
||||||
|
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
|
||||||
|
const value = readValueJson(comp);
|
||||||
|
// 所有 <slide> 都必须携带 default(含内容页的 maxRows/qrSize)
|
||||||
|
const slides = html.match(/<slide[^>]*>/g) || [];
|
||||||
|
assert.ok(slides.length > 0, comp + ' 应存在 slide 控件');
|
||||||
|
for (const tag of slides) {
|
||||||
|
const fm = tag.match(/field:\s*'([A-Za-z0-9]+)'/);
|
||||||
|
const dm = tag.match(/default:\s*(-?\d+)/);
|
||||||
|
assert.ok(fm && dm, comp + ' slide 缺 field 或 default: ' + tag);
|
||||||
|
const field = fm[1];
|
||||||
|
const dft = Number(dm[1]);
|
||||||
|
assert.equal(
|
||||||
|
Number(value[field]), dft,
|
||||||
|
comp + '.' + field + ' slide default=' + dft + ' 应与 diy_view.php 值 ' + value[field] + ' 一致'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 契约中全部字号字段的 slide 必须存在(防漏配)
|
||||||
|
const allFields = slides.map(t => (t.match(/field:\s*'([A-Za-z0-9]+)'/) || [])[1]);
|
||||||
|
for (const field of Object.keys(cfg.defaults)) {
|
||||||
|
assert.ok(allFields.includes(field), comp + ' 缺少 ' + field + ' 的 slide(default 参数)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('design.html 颜色控件带 value 兜底(= defaultColor, 防存量行缺字段回落 #303133)', () => {
|
||||||
|
for (const [comp, cfg] of Object.entries(CONTRACT)) {
|
||||||
|
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
|
||||||
|
const tags = html.match(/<color :data="\{[^}]+\}"><\/color>/g) || [];
|
||||||
|
assert.ok(tags.length >= Object.keys(cfg.colors).length, comp + ' color 控件数量不足');
|
||||||
|
for (const [field, dft] of Object.entries(cfg.colors)) {
|
||||||
|
const tag = tags.find(t => t.includes("field: '" + field + "'"));
|
||||||
|
assert.ok(tag, comp + ' 缺少 ' + field + ' 的 color 控件');
|
||||||
|
assert.ok(tag.includes("defaultColor: '" + dft + "'"), comp + '.' + field + ' defaultColor 应为 ' + dft);
|
||||||
|
assert.ok(tag.includes("value: '" + dft + "'"), comp + '.' + field + ' 缺 value 兜底(应为 ' + dft + ')');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('核心 slide 组件支持 opt-in 逐项重置(不传 default 的调用方零影响)', () => {
|
||||||
|
const js = readFileSync(resolve(BE, '../../public/static/ext/diyview/js/components.js'), 'utf8');
|
||||||
|
assert.ok(js.includes('v-if="canReset"'), 'slide 模板缺少 canReset 条件渲染');
|
||||||
|
assert.ok(js.includes('resetDefault'), 'slide 缺少 resetDefault 方法');
|
||||||
|
// canReset 只能由 default 参数打开
|
||||||
|
assert.ok(js.match(/compData\.default !== undefined[\s\S]{0,60}canReset = true/), 'canReset 开关未绑定 default 参数');
|
||||||
|
// 颜色控件的平台自带重置仍在(逐项重置的颜色口径)
|
||||||
|
assert.ok(js.includes('class="color-selector-reset text-color" @click="reset()"'), 'color 组件内置重置丢失');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('反例自测: 契约定义缺字段时测试必须失败', () => {
|
||||||
|
// 校验 readValueJson 真的在解析 diy_view.php 而非恒真
|
||||||
|
const php = readFileSync(resolve(BE, 'config/diy_view.php'), 'utf8');
|
||||||
|
assert.ok(!php.includes('"titleSizeNonExist"'), '探针字段不应存在(存在则本测试失效)');
|
||||||
|
for (const comp of Object.keys(CONTRACT)) {
|
||||||
|
const value = readValueJson(comp);
|
||||||
|
assert.equal(value.titleSizeNonExist, undefined, comp + ' 不应有探针字段');
|
||||||
|
}
|
||||||
|
});
|
||||||
139
tests/diy-card-link-target.test.js
Normal file
139
tests/diy-card-link-target.test.js
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* 名片 DIY 组件 · 跳转目标(含平台统一「系统链接」)链路测试
|
||||||
|
*
|
||||||
|
* 背景:
|
||||||
|
* 商家在设计面板给「名片入口横幅 / 团队名片墙」配跳转目标, 有四档:
|
||||||
|
* list → 名片夹列表(内置)
|
||||||
|
* contacts→ 我的联系人(内置)
|
||||||
|
* custom → 自定义站内路径
|
||||||
|
* link → 平台统一「系统链接」(商品/分类/微页面/小程序/拨号/自定义外链…)
|
||||||
|
* 第四档由后台的 nc-link 组件写入一个 link 对象
|
||||||
|
* {name,title,wap_url,page_key,page_params}, C 端必须走 $util.diyRedirectTo
|
||||||
|
* 交给平台统一处理(内部页/外链/小程序/拨号), 不能自己拼 URL。
|
||||||
|
*
|
||||||
|
* 覆盖:
|
||||||
|
* 1. entry/list 的 link 模式走 diyRedirectTo(而不是 redirectTo)
|
||||||
|
* 2. 未选链接时有守卫(不跳空)
|
||||||
|
* 3. 内置三档仍走 redirectTo(没被新分支吃掉)
|
||||||
|
* 4. 反例: 不存在虚构模式/虚构字段
|
||||||
|
*
|
||||||
|
* 跑法: node --test tests/diy-card-link-target.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 read = f => fs.readFileSync(path.join(DIY_DIR, f), 'utf8');
|
||||||
|
|
||||||
|
/** 剔除三类注释: HTML <!-- --> / 块 /* *\/ / 行 // (保护 https:// 里的 //) */
|
||||||
|
function stripComments(src) {
|
||||||
|
return src
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, '')
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||||
|
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entrySrc = stripComments(read('diy-card-entry.vue'));
|
||||||
|
const listSrc = stripComments(read('diy-card-list.vue'));
|
||||||
|
|
||||||
|
/** 取某个方法体(从方法名到下一个同级方法定义) */
|
||||||
|
function methodBody(src, name) {
|
||||||
|
const start = src.indexOf(name + '(');
|
||||||
|
if (start === -1) return '';
|
||||||
|
// 从 '{' 开始做括号配平
|
||||||
|
let i = src.indexOf('{', start);
|
||||||
|
if (i === -1) return '';
|
||||||
|
let depth = 0;
|
||||||
|
for (let j = i; j < src.length; j++) {
|
||||||
|
if (src[j] === '{') depth++;
|
||||||
|
else if (src[j] === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return src.slice(start, j + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return src.slice(start);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryGoLink = methodBody(entrySrc, 'goLink');
|
||||||
|
const listGoMore = methodBody(listSrc, 'goMore');
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 1. link 模式走平台统一跳转
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('entry: goLink 的 link 模式走 $util.diyRedirectTo(平台统一处理链接对象)', () => {
|
||||||
|
assert.match(entryGoLink, /linkMode\s*===\s*'link'/);
|
||||||
|
assert.match(entryGoLink, /this\.\$util\.diyRedirectTo\(link\)/);
|
||||||
|
// link 模式必须**提前 return**, 不能落到下面的 redirectTo(url)
|
||||||
|
const linkBranch = entryGoLink.slice(entryGoLink.indexOf("linkMode === 'link'"));
|
||||||
|
assert.match(linkBranch.slice(0, 400), /return;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list: goMore 的 link 模式走 $util.diyRedirectTo', () => {
|
||||||
|
// list 的写法是先归一 mode = String(v.moreLinkMode || 'list'), 再按 mode 分支
|
||||||
|
assert.match(listGoMore, /const\s+mode\s*=\s*String\(this\.v\.moreLinkMode\s*\|\|\s*'list'\)/);
|
||||||
|
assert.match(listGoMore, /mode\s*===\s*'link'/);
|
||||||
|
assert.match(listGoMore, /this\.\$util\.diyRedirectTo\(link\)/);
|
||||||
|
const linkBranch = listGoMore.slice(listGoMore.indexOf("mode === 'link'"));
|
||||||
|
assert.match(linkBranch.slice(0, 400), /return;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('entry: link 取的是配置里的 link 对象(不是 wap_url 字符串)', () => {
|
||||||
|
assert.match(entryGoLink, /v\.link\s*&&\s*typeof\s+v\.link\s*===\s*'object'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list: link 取的是配置里的 moreLink 对象', () => {
|
||||||
|
assert.match(listGoMore, /this\.v\.moreLink\s*&&\s*typeof\s+this\.v\.moreLink\s*===\s*'object'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 2. 未选链接的守卫(不跳空)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('entry: 未选链接时提示而不是跳空', () => {
|
||||||
|
assert.match(entryGoLink, /if\s*\(!link\s*\|\|\s*!link\.name\)/);
|
||||||
|
assert.match(entryGoLink, /请先选择链接/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list: 未选链接时提示而不是跳空', () => {
|
||||||
|
assert.match(listGoMore, /if\s*\(!link\s*\|\|\s*!link\.name\)/);
|
||||||
|
assert.match(listGoMore, /请先选择链接/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 3. 内置三档仍走 redirectTo(没被新分支吃掉)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('entry: 内置三档仍是 redirectTo(名片夹/联系人/自定义路径)', () => {
|
||||||
|
assert.match(entryGoLink, /linkMode\s*===\s*'contacts'/);
|
||||||
|
assert.match(entryGoLink, /\/pages_tool\/card\/contacts/);
|
||||||
|
assert.match(entryGoLink, /linkMode\s*===\s*'custom'/);
|
||||||
|
assert.match(entryGoLink, /\/pages_tool\/card\/list/);
|
||||||
|
assert.match(entryGoLink, /this\.\$util\.redirectTo\(url\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list: 内置三档仍是 redirectTo', () => {
|
||||||
|
assert.match(listGoMore, /mode\s*===\s*'contacts'/);
|
||||||
|
assert.match(listGoMore, /\/pages_tool\/card\/contacts/);
|
||||||
|
assert.match(listGoMore, /mode\s*===\s*'custom'/);
|
||||||
|
assert.match(listGoMore, /this\.\$util\.redirectTo\(url\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 反例自测(防恒真): 虚构模式/虚构字段必须不存在
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('反例: 两个组件都不存在虚构跳转模式', () => {
|
||||||
|
assert.ok(!entrySrc.includes("linkMode === 'nosuchmode'"));
|
||||||
|
assert.ok(!listSrc.includes("moreLinkMode === 'nosuchmode'"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('反例: 两个组件都不存在虚构链接字段', () => {
|
||||||
|
assert.ok(!entrySrc.includes('v.linkMagic'));
|
||||||
|
assert.ok(!listSrc.includes('v.moreLinkMagic'));
|
||||||
|
});
|
||||||
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);
|
||||||
|
});
|
||||||
366
tests/diy-card-qr-style.test.js
Normal file
366
tests/diy-card-qr-style.test.js
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
/**
|
||||||
|
* 名片 DIY 组件 · 4.0 头像开关 + 二维码多彩/微调 契约测试
|
||||||
|
*
|
||||||
|
* 背景: 参考图复刻的第二轮增强:
|
||||||
|
* - 头像可隐藏(关闭后头部只留姓名/职位, 信息密度可调)
|
||||||
|
* - 二维码四档外观: plain 原图 / card 白卡 / theme 主题色 / gradient 渐变
|
||||||
|
* (多彩只装饰外围衬底, 码本身黑白保可扫 —— 不改共享 qr 生成端点)
|
||||||
|
* - 自定义尺寸(0=按档位自动) + transform 微调位移(WXSS 不支持 calc, 用 translate)
|
||||||
|
* - 扫码提示文案可关
|
||||||
|
*
|
||||||
|
* 覆盖:
|
||||||
|
* 1. common.js 纯函数(动态 import 真值断言, 非正则)
|
||||||
|
* 2. mini.vue 基件契约(先剥注释再断言)
|
||||||
|
* 3. show/self 6 字段透传
|
||||||
|
* 4. 两个 design.html 面板: 控件绑定 + 预览 mock 消费(属性变更预览同步)
|
||||||
|
* 5. 反例: 虚构外观/虚构字段必须查不到(防恒真)
|
||||||
|
*
|
||||||
|
* 跑法: node --test tests/diy-card-qr-style.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 PANEL_DIR = path.resolve(__dirname, '../../backend/src/addon/card/component/view');
|
||||||
|
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');
|
||||||
|
|
||||||
|
const showSrc = strip(read('diy-card-show.vue'));
|
||||||
|
const selfSrc = strip(read('diy-card-self.vue'));
|
||||||
|
const miniSrc = strip(read('diy-card-mini.vue'));
|
||||||
|
// 面板目录属 backend 仓库, 可能不在本工作区: 缺失时跳过面板断言(组件契约照常跑)
|
||||||
|
const hasPanels = fs.existsSync(path.join(PANEL_DIR, 'diy-card-self/design.html'));
|
||||||
|
const showPanel = hasPanels ? strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-show/design.html'), 'utf8')) : '';
|
||||||
|
const selfPanel = hasPanels ? strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-self/design.html'), 'utf8')) : '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 面板控件的字段绑定有三种形态(2026-10-21 面板改版: 下拉框 → 可视化选项组,
|
||||||
|
* 数字框 → 滑块):
|
||||||
|
* ① v-model="nc.X" 输入框 / 开关
|
||||||
|
* ② nc.X = 'v' 可视化选项组(芯片)的赋值绑定
|
||||||
|
* ③ field: 'X' 滑块控件 :data="{field:'X'}"
|
||||||
|
* 断言的是「这个字段确实被某个控件绑定了」, 不限定控件形态 —— 契约是字段名, 不是控件类型。
|
||||||
|
*/
|
||||||
|
const bound = (src, field) => new RegExp(
|
||||||
|
'v-model(\\.[a-z]+)?="nc\\.' + field + '"'
|
||||||
|
+ '|\\bnc\\.' + field + "\\s*=\\s*['\"]"
|
||||||
|
+ '|field\\s*:\\s*[\'"]' + field + '[\'"]'
|
||||||
|
).test(src);
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 1. common.js 纯函数(真值断言)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('common: QR_STYLES 四档外观枚举', async () => {
|
||||||
|
const m = await import('../components-diy/diy-card-common.js');
|
||||||
|
assert.deepEqual(m.QR_STYLES, ['plain', 'card', 'theme', 'gradient']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('common: normalizeQrStyle 合法直通, 未知/空回落 card(默认白卡)', async () => {
|
||||||
|
const m = await import('../components-diy/diy-card-common.js');
|
||||||
|
for (const s of ['plain', 'card', 'theme', 'gradient']) {
|
||||||
|
assert.equal(m.normalizeQrStyle(s), s);
|
||||||
|
}
|
||||||
|
// 未配置(存量装修页无此字段) → card, 与 diy_view.php 默认值同口径
|
||||||
|
assert.equal(m.normalizeQrStyle(''), 'card');
|
||||||
|
assert.equal(m.normalizeQrStyle(undefined), 'card');
|
||||||
|
assert.equal(m.normalizeQrStyle(null), 'card');
|
||||||
|
assert.equal(m.normalizeQrStyle('rainbow'), 'card');
|
||||||
|
// 面板只下发小写档位; 大写属未知值, 回落 card(保守, 不炸)
|
||||||
|
assert.equal(m.normalizeQrStyle('CARD'), 'card');
|
||||||
|
// 中文档位名别名(面板下拉若直接存中文档名, 归一化兜住, 否则 C 端永远回落 card)
|
||||||
|
assert.equal(m.normalizeQrStyle('原图'), 'plain');
|
||||||
|
assert.equal(m.normalizeQrStyle('白卡描边'), 'card');
|
||||||
|
assert.equal(m.normalizeQrStyle('主题色衬底'), 'theme');
|
||||||
|
assert.equal(m.normalizeQrStyle('渐变圆角'), 'gradient');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('common: normalizeQrPosition 位置档中文别名兼容', async () => {
|
||||||
|
const m = await import('../components-diy/diy-card-common.js');
|
||||||
|
assert.equal(m.normalizeQrPosition('资料行下方'), 'rows');
|
||||||
|
assert.equal(m.normalizeQrPosition('头部右侧'), 'head');
|
||||||
|
assert.equal(m.normalizeQrPosition('右上角浮层'), 'corner');
|
||||||
|
assert.equal(m.normalizeQrPosition('主按钮上方'), 'bottom');
|
||||||
|
// 英文码直通 / 未知回落 rows
|
||||||
|
assert.equal(m.normalizeQrPosition('bottom'), 'bottom');
|
||||||
|
assert.equal(m.normalizeQrPosition('nope'), 'rows');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('common: darkenColor 压暗生成可见渐变终止色', async () => {
|
||||||
|
const m = await import('../components-diy/diy-card-common.js');
|
||||||
|
// 纯色压暗 0.18: #119dff → #0e81d1
|
||||||
|
assert.equal(m.darkenColor('#119dff', 0.18), '#0e81d1');
|
||||||
|
// 比例越大约暗
|
||||||
|
assert.notEqual(m.darkenColor('#119dff', 0.4), m.darkenColor('#119dff', 0.18));
|
||||||
|
// 非法输入原样返回(不影响既有渲染)
|
||||||
|
assert.equal(m.darkenColor('#12', 0.18), '#12');
|
||||||
|
assert.equal(m.darkenColor('rgb(1,2,3)', 0.18), 'rgb(1,2,3)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('common: rowIconFor 资料行图标字段映射(含方案1邮箱预留 icon-youxiang)', async () => {
|
||||||
|
const m = await import('../components-diy/diy-card-common.js');
|
||||||
|
// 有字形的字段返回 iconfont 类
|
||||||
|
assert.equal(m.rowIconFor('mobile'), 'icon-dianhua');
|
||||||
|
assert.equal(m.rowIconFor('phone'), 'icon-dianhua');
|
||||||
|
assert.equal(m.rowIconFor('wechat'), 'icon-weixin');
|
||||||
|
assert.equal(m.rowIconFor('address'), 'icon-dizhi');
|
||||||
|
// 邮箱: 方案1 预留 icon-youxiang(需字库补充信封字形后方能显示), 多键名兜底
|
||||||
|
assert.equal(m.rowIconFor('email'), 'icon-youxiang');
|
||||||
|
assert.equal(m.rowIconFor('mail'), 'icon-youxiang');
|
||||||
|
assert.equal(m.rowIconFor('邮箱'), 'icon-youxiang');
|
||||||
|
// 网站(2026-10-22): 复用字库现成链条字形 icon-fuzhilianjie, 多键名兜底
|
||||||
|
assert.equal(m.rowIconFor('website'), 'icon-fuzhilianjie');
|
||||||
|
assert.equal(m.rowIconFor('link'), 'icon-fuzhilianjie');
|
||||||
|
assert.equal(m.rowIconFor('网站'), 'icon-fuzhilianjie');
|
||||||
|
assert.equal(m.rowIconFor('custom'), '');
|
||||||
|
assert.equal(m.rowIconFor(''), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('common: iconfont.css 邮箱信封 SVG 覆盖(字库无字形期的降级实现)', () => {
|
||||||
|
const iconSrc = fs.readFileSync(path.resolve(__dirname, '../common/css/iconfont.css'), 'utf8');
|
||||||
|
// 覆盖规则存在且 content 置空(避免渲染豆腐块), 尺寸/对齐与 row-icon 槽位匹配
|
||||||
|
assert.match(iconSrc, /\.icon-youxiang:before \{[\s\S]*?content: "";[\s\S]*?width: 30rpx;[\s\S]*?height: 30rpx;/);
|
||||||
|
// 信封 SVG data-uri(描边风格, #999999 与资料行标签默认色一致)
|
||||||
|
assert.match(iconSrc, /\.icon-youxiang:before \{[\s\S]*?data:image\/svg\+xml,[\s\S]*?stroke='%23999999'[\s\S]*?%3E%3Crect x='2' y='4' width='20' height='16'[\s\S]*?center\/contain no-repeat/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('common: resolveQrSize 自定义>档位自动, 单位 px→rpx(×2)', async () => {
|
||||||
|
const m = await import('../components-diy/diy-card-common.js');
|
||||||
|
// 自定义尺寸(面板 px) × 2 = rpx
|
||||||
|
assert.equal(m.resolveQrSize('rows', 120), 240);
|
||||||
|
assert.equal(m.resolveQrSize('corner', 60), 120);
|
||||||
|
// 0 / 未配置 / 非法 → 按档位自动
|
||||||
|
assert.equal(m.resolveQrSize('rows', 0), m.QR_SIZES.rows);
|
||||||
|
assert.equal(m.resolveQrSize('head', 0), m.QR_SIZES.head);
|
||||||
|
assert.equal(m.resolveQrSize('corner', 0), m.QR_SIZES.corner);
|
||||||
|
assert.equal(m.resolveQrSize('bottom', 0), m.QR_SIZES.bottom);
|
||||||
|
assert.equal(m.resolveQrSize('rows', undefined), m.QR_SIZES.rows);
|
||||||
|
assert.equal(m.resolveQrSize('rows', -30), m.QR_SIZES.rows);
|
||||||
|
assert.equal(m.resolveQrSize('rows', 'abc'), m.QR_SIZES.rows);
|
||||||
|
// 未知档位 → rows 兜底
|
||||||
|
assert.equal(m.resolveQrSize('sideways', 0), m.QR_SIZES.rows);
|
||||||
|
// 字符串数字也要认(面板可能给字符串)
|
||||||
|
assert.equal(m.resolveQrSize('rows', '120'), 240);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('common: qrTransform 位移(任一轴非零才输出; WXSS 支持 translate)', async () => {
|
||||||
|
const m = await import('../components-diy/diy-card-common.js');
|
||||||
|
assert.equal(m.qrTransform(0, 0), '');
|
||||||
|
assert.equal(m.qrTransform(undefined, undefined), '');
|
||||||
|
assert.equal(m.qrTransform(null, null), '');
|
||||||
|
assert.equal(m.qrTransform('', ''), '');
|
||||||
|
// px × 2 = rpx
|
||||||
|
assert.equal(m.qrTransform(10, -20), 'translate(20rpx, -40rpx)');
|
||||||
|
assert.equal(m.qrTransform(5, 0), 'translate(10rpx, 0rpx)');
|
||||||
|
assert.equal(m.qrTransform(0, 5), 'translate(0rpx, 10rpx)');
|
||||||
|
assert.equal(m.qrTransform(-50, 50), 'translate(-100rpx, 100rpx)');
|
||||||
|
// 字符串数字(面板 number 输入可能给字符串)
|
||||||
|
assert.equal(m.qrTransform('10', '20'), 'translate(20rpx, 40rpx)');
|
||||||
|
// 非数字 → 0
|
||||||
|
assert.equal(m.qrTransform('abc', 'xyz'), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 2. mini.vue 基件契约
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('mini: 蓝色顶带版式(2026-10-23 与精选名片 show 同款: 自适应白卡高度 + 弧线距底 88rpx)', () => {
|
||||||
|
// 模板: 卡片包进 wrap, 顶带在最前
|
||||||
|
assert.match(miniSrc, /class="mini-card-wrap"/);
|
||||||
|
assert.match(miniSrc, /class="mini-band" :style="bandStyle"/);
|
||||||
|
assert.match(miniSrc, /class="mini-card fade-up" :style="wrapStyle"/);
|
||||||
|
// 计算属性: 顶带底色走主题渐变(与 show bandStyle 同口径)
|
||||||
|
assert.match(miniSrc, /bandStyle\(\)\s*\{\s*return this\.\$util\.objToStyle\(\{\s*background: themeGradient\(this\.card\)\s*\}\);/);
|
||||||
|
// CSS: wrap 撑露沿 + band 拉伸(top:-40rpx / bottom:88rpx) + 圆角(顶直底弧)
|
||||||
|
assert.match(miniSrc, /\.mini-card-wrap \{\s*position: relative;\s*margin-top: 40rpx;/);
|
||||||
|
assert.match(miniSrc, /\.mini-band \{\s*position: absolute;[\s\S]*?top: -40rpx;[\s\S]*?bottom: 88rpx;[\s\S]*?border-radius: 0 0 28rpx 28rpx;/);
|
||||||
|
// mini-block 方案不得回归
|
||||||
|
assert.ok(!miniSrc.includes('mini-block'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mini: 企业徽标固定红底(参考图口径, 与头像底色解耦)', () => {
|
||||||
|
assert.match(miniSrc, /badgeStyle\(\)\s*\{\s*return this\.\$util\.objToStyle\(\{\s*background: '#FA5151'\s*\}\);/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mini: 头像开关(默认显示, 关掉只留姓名/职位)', () => {
|
||||||
|
assert.match(miniSrc, /showAvatarBlock\(\)\s*\{\s*return flagOn\(this\.c\.showAvatar,\s*true\)/);
|
||||||
|
// 模板头像块受开关控制(无 v-else 占位 —— 关掉即消失, 头部信息自然左移)
|
||||||
|
assert.match(miniSrc, /<view v-if="showAvatarBlock" class="mini-avatar" :style="avatarStyle">/);
|
||||||
|
// 反例形态: 不允许出现常显头像(绕过开关)
|
||||||
|
assert.ok(!miniSrc.includes('<view class="mini-avatar"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mini: qrStyle 计算属性归一化', () => {
|
||||||
|
assert.match(miniSrc, /qrStyle\(\)\s*\{\s*return normalizeQrStyle\(this\.c\.qrStyle\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mini: qrBlockStyle 四档外观分支(多彩只装饰外围, 码黑白保扫)', () => {
|
||||||
|
// card 白卡描边(分支内逐行收紧, 防跨分支 lazy 匹配造成假过)
|
||||||
|
assert.match(miniSrc, /if \(this\.qrStyle === 'card'\) \{\s+style\.background = '#ffffff';\s+style\.border = '2rpx solid #EEF0F3';\s+style\.borderRadius = '12rpx';\s+style\.padding = '10rpx';/);
|
||||||
|
// theme 主题色衬底
|
||||||
|
assert.match(miniSrc, /else if \(this\.qrStyle === 'theme'\) \{\s+style\.background = theme;\s+style\.borderRadius = '12rpx';\s+style\.padding = '10rpx';/);
|
||||||
|
// gradient 渐变 + 6rpx 紧凑内边距; 单主题色用 darkenColor 兜底终止色(可见明暗)
|
||||||
|
assert.match(miniSrc, /else if \(this\.qrStyle === 'gradient'\) \{\s+const end = \(this\.card && this\.card\.gradient_end\) \|\| darkenColor\(theme, 0\.18\);\s+style\.background = 'linear-gradient\(135deg, ' \+ theme \+ ' 0%, ' \+ end \+ ' 100%\)';\s+style\.borderRadius = '16rpx';\s+style\.padding = '6rpx';/);
|
||||||
|
// plain 不进任何分支(无衬底, 即原图) —— 三个 if 之外不再设 background
|
||||||
|
assert.ok(!miniSrc.includes("qrStyle === 'plain' ? style.background"));
|
||||||
|
// 2026-10-23 用户定稿: 外观衬底只包二维码图(qrImgStyle), 「扫码查看完整名片」tip 在衬底外无蓝底
|
||||||
|
assert.match(miniSrc, /class="qr-img" :style="qrImgStyle"/);
|
||||||
|
const tipBinds = miniSrc.match(/class="qr-tip" :style="labelStyle"/g) || [];
|
||||||
|
assert.equal(tipBinds.length, 5, '五档 tip 仍独立(不进衬底; 含 theme 头部块), 实际 ' + tipBinds.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mini: 二维码尺寸取 resolveQrSize(自定义>0 优先, 否则档位自动)', () => {
|
||||||
|
// qrBlockStyle(容器) + qrImgStyle(图, 外观衬底) + cornerClearStyle(垂直让位) + cornerHeadStyle(水平让位)
|
||||||
|
// (自定义大尺寸时浮层占高与占宽都要动态跟随)
|
||||||
|
const hits = miniSrc.match(/resolveQrSize\(this\.qrPos, this\.c\.qrSize\)/g) || [];
|
||||||
|
assert.equal(hits.length, 4, 'qrBlockStyle + qrImgStyle + cornerClearStyle + cornerHeadStyle 四处, 实际 ' + hits.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mini: 微调位移 transform(不用 calc, WXSS 兼容)', () => {
|
||||||
|
assert.match(miniSrc, /const transform = qrTransform\(this\.c\.qrOffsetX, this\.c\.qrOffsetY\);\s*if \(transform\) style\.transform = transform;/);
|
||||||
|
// 位移零值不输出 transform(不污染默认布局)
|
||||||
|
assert.match(miniSrc, /if \(transform\) style\.transform = transform;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mini: 扫码提示开关 + 四个挂点统一消费', () => {
|
||||||
|
assert.match(miniSrc, /showQrTip\(\)\s*\{\s*return flagOn\(this\.c\.qrTipShow,\s*true\)/);
|
||||||
|
// 二维码 tip 挂点: corner/rows/bottom 各 1 + head 档 2(head 双形态分支互斥渲染, 2.17)
|
||||||
|
const tips = miniSrc.match(/<text v-if="showQrTip" class="qr-tip"/g) || [];
|
||||||
|
assert.equal(tips.length, 5, 'tip 挂点(4 档 + head 双分支各一), 实际 ' + tips.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mini: cornerClearStyle 按实际尺寸反算让位(防遮挡, 纯数值不用 calc)', () => {
|
||||||
|
assert.match(miniSrc, /cornerClearStyle\(\)\s*\{[\s\S]*?if \(this\.qrPos !== 'corner' \|\| !this\.showQrBase\) return \{\};/);
|
||||||
|
// 让位量 = 浮层 top 20 + 块高[尺寸+衬底+tip] - 头部 104 + 间隙 10; 下限 24
|
||||||
|
assert.match(miniSrc, /Math\.max\(24, total - headHeight \+ 10\)/);
|
||||||
|
// 衬底与 tip 参与计算(关 tip 时让位自动收窄)
|
||||||
|
assert.match(miniSrc, /const tip = this\.showQrTip \? 42 : 0;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mini: cornerHeadStyle 头部水平让位按浮层实际宽度动态反算', () => {
|
||||||
|
// 审计修复: 静态 180rpx 在自定义 qrSize>120 时不足 → 头部让位动态化
|
||||||
|
assert.match(miniSrc, /cornerHeadStyle\(\)\s*\{[\s\S]*?if \(this\.qrPos !== 'corner' \|\| !this\.showQrBase\) return \{\};/);
|
||||||
|
// 浮层宽[尺寸+衬底] + 右偏移 20 + 呼吸 20
|
||||||
|
assert.match(miniSrc, /paddingRight: \(size \+ pad \+ 40\) \+ 'rpx'/);
|
||||||
|
// 模板头部绑定了动态让位(浮层不存在时输出空对象不占位)
|
||||||
|
assert.match(miniSrc, /<view class="mini-head" :style="cornerHeadStyle">/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mini: 二维码块样式绑定——corner/head 走 qrBlockStyle, rows/bottom 走 qrBlockFinalStyle(2.19.10 分卡合并)', () => {
|
||||||
|
const direct = miniSrc.match(/:style="qrBlockStyle"/g) || [];
|
||||||
|
assert.equal(direct.length, 3, 'qrBlockStyle 直绑(corner 1 + head 双分支 2), 实际 ' + direct.length);
|
||||||
|
const final = miniSrc.match(/:style="qrBlockFinalStyle"/g) || [];
|
||||||
|
assert.equal(final.length, 2, 'qrBlockFinalStyle(rows/bottom 各 1), 实际 ' + final.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 3. self miniConfig 透传 / show 自绘版式二维码契约
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('self: miniConfig 透传头像+二维码新六字段', () => {
|
||||||
|
assert.match(selfSrc, /showAvatar:\s*v\.showAvatar/);
|
||||||
|
assert.match(selfSrc, /qrStyle:\s*v\.qrStyle/);
|
||||||
|
assert.match(selfSrc, /qrSize:\s*v\.qrSize/);
|
||||||
|
assert.match(selfSrc, /qrOffsetX:\s*v\.qrOffsetX/);
|
||||||
|
assert.match(selfSrc, /qrOffsetY:\s*v\.qrOffsetY/);
|
||||||
|
assert.match(selfSrc, /qrTipShow:\s*v\.qrTipShow/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('self: 「我的名片」角标与未登录提示条均已移除(2026-10-23 用户定稿)', () => {
|
||||||
|
// 角标按 2026-10-23 参考图删除, 反例防回归
|
||||||
|
assert.ok(!selfSrc.includes('self-badge') && !selfSrc.includes('slot="badge"'));
|
||||||
|
// 未登录提示条(「登录后即可展示我的名片·去看看名片」)按用户要求整条移除, 反例防回归
|
||||||
|
assert.ok(!selfSrc.includes('self-login-bar') && !selfSrc.includes('loginBarTip'));
|
||||||
|
// 空态分支接管提示条删除后的条件(非引导模式 + 无卡 + 非加载)
|
||||||
|
assert.match(selfSrc, /v-else-if="!loading && !card" class="self-empty"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('show: miniConfig 全字段透传(方案A定稿: C端=diy-card-mini 基件=面板预览逐字段一致)', () => {
|
||||||
|
// 2026-10-24 用户定稿: Show 弃自绘版式恢复基件 —— 面板 53 字段全部经 miniConfig 生效(与 self 同构);
|
||||||
|
// 行为由 mini 侧同名测试覆盖, 此处锁透传链防 merge 冲突解决再丢(10-24 两次 merge 曾丢失整段透传)
|
||||||
|
const fields = [
|
||||||
|
// 视觉开关/设计资产
|
||||||
|
'showQuick', 'showDetailBtn', 'detailBtnText', 'useCover', 'showGradient', 'showLogo', 'showShadow',
|
||||||
|
'showRows', 'maxRows', 'showRowIcons', 'rowTap',
|
||||||
|
// 字号 7 字段
|
||||||
|
'nameSize', 'cardTitleSize', 'subSize', 'labelSize', 'valueSize', 'actSize', 'btnSize',
|
||||||
|
// 二维码六字段 + 自定义图
|
||||||
|
'showQr', 'qrTip', 'qrPosition', 'showAvatar', 'qrStyle', 'qrSize', 'qrOffsetX', 'qrOffsetY', 'qrTipShow', 'qrImage',
|
||||||
|
// 2.17 双形态 + 2.18 位置/成块 + 2.19 图层管理全套
|
||||||
|
'headStyle', 'headNameColor', 'headSubColor', 'actsLayout', 'actGridColorMode',
|
||||||
|
'showRowsTitle', 'rowsTitle', 'showCopyBtn', 'actsPosition', 'actsBlockBg', 'actsBlockRadius', 'actsBlockPad',
|
||||||
|
'actIcons', 'actVis', 'actDeleted', 'actLabels', 'actOrder', 'customActs',
|
||||||
|
];
|
||||||
|
for (const f of fields) {
|
||||||
|
assert.match(showSrc, new RegExp(f + ':\\s*v\\.' + f + ','), 'miniConfig 缺透传 ' + f);
|
||||||
|
}
|
||||||
|
// 二维码/发名片点击链路: 基件 onQrTap/onSend $emit('detail'/'send'), show 接收方挂 @detail
|
||||||
|
assert.match(showSrc, /@detail="goDetail"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 4. design.html 面板: 控件绑定 + 预览 mock 消费
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
for (const [name, panel] of hasPanels ? [['show', showPanel], ['self', selfPanel]] : []) {
|
||||||
|
test(name + ' 面板: 头像开关 + 二维码新六字段有 v-model 绑定', () => {
|
||||||
|
assert.match(panel, /v-model="nc\.showAvatar"/);
|
||||||
|
// qrStyle/qrSize 为档位 chips/slide 绑定形态(2026-10-24 merge 后口径), 其余字段 v-model
|
||||||
|
assert.match(panel, /(?:v-model|@click)="[^"]*nc\.qrStyle/);
|
||||||
|
assert.match(panel, /field:\s*'qrSize'/);
|
||||||
|
assert.match(panel, /v-model\.number="nc\.qrOffsetX"/);
|
||||||
|
assert.match(panel, /v-model\.number="nc\.qrOffsetY"/);
|
||||||
|
assert.match(panel, /v-model="nc\.qrTipShow"/);
|
||||||
|
// 二维码外观四选项(plain/card/theme/gradient)
|
||||||
|
|
||||||
|
for (const v of ['plain', 'card', 'theme', 'gradient']) {
|
||||||
|
assert.ok(
|
||||||
|
panel.includes("nc.qrStyle = '" + v + "'") || panel.includes('<option value="' + v + '">'),
|
||||||
|
'缺选项 ' + v
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(name + ' 面板: 预览 mock 消费新属性(属性变更预览同步)', () => {
|
||||||
|
// 头像开关
|
||||||
|
assert.ok(panel.includes('nc.showAvatar !== false'), '预览不消费 showAvatar');
|
||||||
|
// 二维码外观四档
|
||||||
|
assert.ok(panel.includes("nc.qrStyle === 'plain'"), '预览不消费 qrStyle');
|
||||||
|
// 自定义尺寸: 只锁「自定义值优先于档位自动」这层语义, 不锁死具体像素值
|
||||||
|
// (档位默认值是可调的设计参数, 锁死会让改一次尺寸就得改一次测试)
|
||||||
|
assert.ok(panel.includes('nc.qrSize > 0 ? nc.qrSize'), '预览不消费 qrSize');
|
||||||
|
// 微调位移
|
||||||
|
assert.ok(panel.includes('nc.qrOffsetX || 0'), '预览不消费 qrOffsetX');
|
||||||
|
assert.ok(panel.includes('nc.qrOffsetY || 0'), '预览不消费 qrOffsetY');
|
||||||
|
// 提示开关
|
||||||
|
assert.ok(panel.includes('nc.qrTipShow !== false'), '预览不消费 qrTipShow');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 反例(防恒真)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
test('反例: 不存在第五档二维码外观', () => {
|
||||||
|
assert.ok(!miniSrc.includes("qrStyle === 'rainbow'"));
|
||||||
|
assert.ok(!miniSrc.includes("qrStyle === 'neon'"));
|
||||||
|
// 面板同理
|
||||||
|
assert.ok(!showPanel.includes("nc.qrStyle == 'rainbow'"));
|
||||||
|
assert.ok(!selfPanel.includes("nc.qrStyle == 'rainbow'"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('反例: 虚构字段绝不出现在面板/基件', () => {
|
||||||
|
assert.ok(!showPanel.includes('nc.qrMagic'));
|
||||||
|
assert.ok(!selfPanel.includes('nc.qrMagic'));
|
||||||
|
assert.ok(!miniSrc.includes('c.qrMagic'));
|
||||||
|
assert.ok(!showSrc.includes('v.qrMagic'));
|
||||||
|
assert.ok(!selfSrc.includes('v.qrMagic'));
|
||||||
|
});
|
||||||
779
tests/diy-card-style-v2.test.js
Normal file
779
tests/diy-card-style-v2.test.js
Normal file
@@ -0,0 +1,779 @@
|
|||||||
|
/**
|
||||||
|
* 名片 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(颜色编码在图内改色无效)');
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user