refactor(formdata.vue): update default styles and config merging logic

- Adjusted the merging order of form configuration to prioritize frontend defaults when backend configurations are absent.
- Clarified comments regarding the purpose of new visual styles and their application.
- Modified the hideInnerBanner logic to ensure consistent behavior with the updated form styles.
This commit is contained in:
2026-09-03 17:50:11 +08:00
parent b4c47fcc26
commit b2211edeab
6 changed files with 1186 additions and 691 deletions

View File

@@ -202,7 +202,8 @@ const localDevConfig = ({
// })['2724']; // 生物菌肥
// })['2811']; // 选择要使用的环境配置
// })['local-11']; // 选择要使用的环境配置
})['2811']; // 本地
// })['2811']; // 本地
})['local-1']; // 本地
// })['local-2']; // 本地
// })['local-11']; // 本地
// })['1-test']; // test

View File

@@ -7,31 +7,41 @@
r=function(){var o=this.value.show_banner...this.$mp.data...})
报 "Cannot read properties of undefined (reading 'show_banner')"。 -->
<view v-if="value && value.show_banner && value.form_banner" class="form-banner is-card-overlap" :style="bannerStyle + bannerBleedStyle">
<!-- 固定高度容器:默认 180rpx ≈ 页面 1/8,由 formConfig.bannerHeight 控制。
图片用 mode="aspectFill" 居中裁剪填充,使横幅只占页面八分之一(与设计图一致)。 -->
<view class="banner-img-wrap" :style="bannerImgWrapStyle">
<image :src="$util.img(value.form_banner)" mode="aspectFill" class="banner-img" />
<!-- Banner 图片高度策略(与商家端预览 .preview-banner height:auto 对齐):
默认 mode="widthFix":宽度撑满、高度按图片原始比例自适应,图片完整显示不裁剪;
仅显式配置 formConfig.bannerHeight / banner_height 时才固定高度 + aspectFill 居中裁剪。 -->
<view class="banner-img-wrap" :class="{ 'is-fixed-height': hasBannerHeight }" :style="bannerImgWrapStyle">
<image :src="$util.img(value.form_banner)" :mode="bannerImgMode" class="banner-img" />
</view>
</view>
<!-- 卡片容器:仅在后台配置了 banner 图片时启用,让表单浮在 banner 之上(参考左图布局)。
无 banner 时不渲染 .form-card,保持原有平铺行为,零外溢。 -->
<view v-if="hasBannerMode" class="form-card" :style="cardWrapStyle">
<!-- 提示语 -->
<view v-if="value && value.show_tip && value.form_tip" class="form-tip" :style="tipStyle">{{ value && value.form_tip }}</view>
<!-- 表单组件 -->
<ns-newform
:data="formComponents"
:customAttr="value"
:mode="mergedFormConfig.formLayout || 'vertical'"
:formConfig="mergedFormConfig"
ref="nsNewform"
></ns-newform>
<!-- 提交按钮(外层 = form-bg + form-padding-x;按钮 = submit-bg + submit-color + input-height + input-radius)。
showSubmit=false 时由父级页面渲染自己的提交按钮并通过 ref 调用本组件的 verify()/getFormData()。 -->
<view v-if="showSubmit" class="form-submit-wrap" :style="submitWrapStyle">
<!-- 卡片浮起模式:tip / 提交按钮位置可配(仅 card 风格生效)
- tip_position: outside(默认)= 独立白卡负 margin 压住 banner 底部;inside = 内容卡内
- submit_position: inside(默认)= 提交按钮在内容卡内;outside = 卡片下方独立区域 -->
<template v-if="hasBannerMode">
<!-- tip 独立卡(outside,默认):负 margin 压 banner + 白底 + 圆角 + 阴影,视觉参照商家端预览 .preview-tip -->
<view v-if="value && value.show_tip && value.form_tip && tipPosition === 'outside'" class="form-tip-card" :style="tipCardStyle">{{ value.form_tip }}</view>
<!-- 内容卡:tipPosition !== 'outside'(tip 关闭或 tip 在卡内)时由本卡接管负 margin 浮起 -->
<view class="form-card" :style="cardWrapStyle">
<!-- tip 卡内(inside):无独立卡视觉,用户配置的 bg/border/shadow 仍生效 -->
<view v-if="value && value.show_tip && value.form_tip && tipPosition !== 'outside'" class="form-tip" :style="tipStyle">{{ value.form_tip }}</view>
<!-- 表单组件 -->
<ns-newform
:data="formComponents"
:customAttr="value"
:mode="mergedFormConfig.formLayout || 'vertical'"
:formConfig="mergedFormConfig"
ref="nsNewform"
></ns-newform>
<!-- 提交按钮(inside,默认):在内容卡内 -->
<view v-if="showSubmit && submitPosition !== 'outside'" class="form-submit-wrap" :style="submitWrapStyle">
<view @click.stop="handleSubmit" class="form-submit" :style="submitStyle">{{ submitText }}</view>
</view>
</view>
<!-- 提交按钮独立区(outside):卡片下方,无卡背景 -->
<view v-if="showSubmit && submitPosition === 'outside'" class="form-submit-wrap" :style="submitWrapStyle">
<view @click.stop="handleSubmit" class="form-submit" :style="submitStyle">{{ submitText }}</view>
</view>
</view>
</template>
<!-- 无 banner 时仍按旧布局直接渲染 tip / ns-newform / submit,避免影响其他使用方(diy-group / diy-article 等) -->
<template v-else>
<!-- 提示语 -->
@@ -140,13 +150,27 @@ export default {
padding: `0 ${this._num(this.mergedFormConfig && this.mergedFormConfig.bannerPaddingX, 0)}rpx`
});
},
// Banner 图片包裹层高度(rpx)。固定高度使图片用 mode="aspectFill" 居中裁剪填充,
// 保证横幅只占页面 1/8 左右(默认 180rpx ≈ 1334rpx 屏高的 13.5%,接近 1/8)。
// 后台可配 formConfig.bannerHeight(camelCase)/ banner_height(snake_case)。
// Banner 图片高度模式(2026-09-03 对齐商家端预览):
// 商家端预览 .preview-banner 是 max-width:100%; height:auto —— 按图片原始比例完整显示。
// 此前 H5 固定 180rpx + aspectFill 居中裁剪,导致横幅高度不一/图片内容被裁缺失。
// 现在:无显式配置 → widthFix 自适应(与后端 height:auto 等价);
// 显式配置 bannerHeight / banner_height → 固定高度 + aspectFill 裁剪(用户主动选择)。
hasBannerHeight() {
const cfg = this.mergedFormConfig || {};
const raw = cfg.bannerHeight !== undefined ? cfg.bannerHeight : cfg.banner_height;
const n = parseInt(raw);
return !isNaN(n) && n > 0;
},
bannerImgMode() {
return this.hasBannerHeight ? 'aspectFill' : 'widthFix';
},
// Banner 图片包裹层高度:仅显式配置 bannerHeight(rpx,clamp 40-1200)时输出;否则为空由图片自适应。
bannerImgWrapStyle() {
return this.$util.objToStyle({
height: `${this._num(this.mergedFormConfig && this.mergedFormConfig.bannerHeight, 180)}rpx`
});
if (!this.hasBannerHeight) return '';
let h = parseInt(this.mergedFormConfig.bannerHeight !== undefined ? this.mergedFormConfig.bannerHeight : this.mergedFormConfig.banner_height);
if (h < 40) h = 40;
if (h > 1200) h = 1200;
return this.$util.objToStyle({ height: h + 'rpx' });
},
// 表单风格:优先读显式 form_style(classic / card),未设置时回退到隐式判定(看 show_banner && form_banner)
// - classic:经典风格,平铺无 banner 卡片
@@ -162,33 +186,104 @@ export default {
if (style === 'classic') return false;
return !!(v.show_banner && v.form_banner);
},
// 卡片容器内联样式:maring-top(负偏移让卡片向上压住 banner 底部)+ border-radius + padding-x + box-shadow + background。
// 数值由 formConfig 走 camelCase/snake_case 双读,缺省走默认值,使后端零配置时也能看到卡片浮起效果。
// 关键:卡片必须自带白底 + z-index(见 SCSS .form-card)—— 只有卡片自己的背景才能"盖住"banner 图片底部,
// 父级容器(如 formdata.vue 的 .system-form-wrap)是 banner 的祖先,其背景会被 banner 覆盖,无法形成浮起效果。
// tip 提示语位置(仅 card 风格消费;classic 恒 'outside' 维持原结构)
// outside=独立白卡浮起压 banner(默认);inside=内容卡内(无独立卡视觉)
tipPosition() {
if (!this.hasBannerMode) return 'outside';
return this.mergedFormConfig.tipPosition === 'inside' ? 'inside' : 'outside';
},
// 提交按钮位置(仅 card 风格消费;classic 恒 'outside' 维持原结构)
// inside=内容卡内(默认);outside=卡片下方独立区域
submitPosition() {
if (!this.hasBannerMode) return 'outside';
return this.mergedFormConfig.submitPosition === 'outside' ? 'outside' : 'inside';
},
// 卡片容器内联样式:margin-top(负偏移+下移补偿)+ 左右外缩 + border-radius + box-shadow + background + border。
// clamp 与默认值对齐后端 form.js syncFormConfigToCSS:overlap 0-200(默认48) / radius 0-40(默认24) / sidePad 0-60(默认30);
// margin-top = -overlap + overlap_top(后端 --card-overlap 公式 (-overlap+overlapTop)/2 为 px,rpx 不除 2)
// tip 独立卡(outside + showTip)带队浮起时,本卡只留正常间距(后端预览 draggable margin 10px=20rpx)
// sidePad 语义(2026-09-03 与后端统一):卡片距屏幕左右距离(对齐后端 --card-side-pad,默认 30rpx=15px)。
// formdata 链路(containerInset>0)容器已有 padding,卡片负 margin 破壳后再缩 sidePad(与 banner bleed 同思路);
// DIY 链路(无 containerInset)直接 margin = sidePad。
// 卡内内容缩进由 ns-newform .form-wrap 的 formPaddingX 承担(与后端 .draggable-wrap padding: form-padding-x 同构)。
cardWrapStyle() {
const cfg = this.mergedFormConfig || {};
// 父级传了 containerInset(即 formdata.vue 场景),外层 system-form-wrap 已给左右 padding-x,这里默认 0 不重复
// 其他场景(diy-group / diy-article 等未传 containerInset),回退到 bannerPaddingX 或 28rpx 保证卡片模式视觉正常
const parentInset = this._num(this.containerInset, 0);
const defaultSidePad = parentInset > 0 ? 0 : this._num(cfg.bannerPaddingX, 28);
const sidePad = this._num(cfg.cardSidePad, defaultSidePad);
// overlap:负值,让卡片向上"压"住 banner 底部(与设计图一致)
const overlap = -Math.abs(this._num(cfg.cardOverlap, 48));
let sidePad = this._num(cfg.cardSidePad, 30);
if (sidePad < 0) sidePad = 0;
if (sidePad > 60) sidePad = 60;
const sideMargin = parentInset > 0 ? (sidePad - parentInset) : sidePad;
// overlap:负值向上压住 banner 底部;overlap_top 为下移补偿(可正可负,叠加生效)
let overlap = Math.abs(this._num(cfg.cardOverlap, 48));
if (overlap > 200) overlap = 200;
const overlapTop = this._num(cfg.cardOverlapTop, 0);
// banner 实际渲染时才用负 margin 压住它;banner 被隐藏(如 hideInnerBanner / 配置缺失)时
// 负 margin 会把卡片(含 tip)整体推出屏幕顶部,必须收回 0
const bannerVisible = !!(this.value && this.value.show_banner && this.value.form_banner);
// tip 独立卡存在时由 tip 卡带队压 banner,内容卡只留正常间距(20rpx ≈ 后端预览 10px)
const tipLeads = this.tipPosition === 'outside' && !!(this.value && this.value.show_tip && this.value.form_tip);
// 卡片圆角:缺省 24rpx(浮起卡片的圆角,比外层卡片的 16rpx 更明显,突出浮起感)
const radius = this._num(cfg.cardRadius, 24);
let radius = this._num(cfg.cardRadius, 24);
if (radius < 0) radius = 0;
if (radius > 40) radius = 40;
// 阴影:缺省中等阴影(与设计方案一致)—— 由本容器承担浮起感
const shadow = cfg.cardShadow || '0 8rpx 24rpx rgba(0,0,0,0.08)';
// 卡片底色:纯白,与设计方案一致;可由后台 formConfig.cardBgColor 覆盖
const bgColor = cfg.cardBgColor || '#ffffff';
return this.$util.objToStyle({
marginTop: overlap + 'rpx',
paddingLeft: sidePad + 'rpx',
paddingRight: sidePad + 'rpx',
// 边框:宽度 rpx(0-20),留空/0 = 无边框
let borderWidth = this._num(cfg.cardBorderWidth, 0);
if (borderWidth < 0) borderWidth = 0;
if (borderWidth > 20) borderWidth = 20;
const style = {
marginTop: (bannerVisible ? (tipLeads ? 20 : (-overlap + overlapTop)) : 0) + 'rpx',
marginLeft: sideMargin + 'rpx',
marginRight: sideMargin + 'rpx',
borderRadius: radius + 'rpx',
boxShadow: shadow,
backgroundColor: bgColor
});
};
if (borderWidth > 0) {
style['border'] = borderWidth + 'rpx ' + (cfg.cardBorderStyle || 'solid') + ' ' + (cfg.cardBorderColor || 'transparent');
}
return this.$util.objToStyle(style);
},
// tip 独立卡样式(card + outside):负 margin 压 banner + 左右外缩 + 白底 + 圆角 + 阴影 + 细边框。
// 视觉参照后端预览 .form-style-card .preview-tip(margin: var(--card-overlap) var(--card-side-pad) 0;白底 12px 圆角 柔和阴影 左对齐)
// 用户配置的 tip 四态属性(bg/border/radius/shadow/padding/align)在此全部生效,仅默认值不同(独立卡默认白底卡视觉)
// sidePad 语义与 cardWrapStyle 一致:卡片距屏幕左右距离(默认 30rpx;formdata 链路破壳 sidePad-parentInset)
tipCardStyle() {
const cfg = this.mergedFormConfig || {};
const bannerVisible = !!(this.value && this.value.show_banner && this.value.form_banner);
let overlap = Math.abs(this._num(cfg.cardOverlap, 48));
if (overlap > 200) overlap = 200;
const overlapTop = this._num(cfg.cardOverlapTop, 0);
const parentInset = this._num(this.containerInset, 0);
let sidePad = this._num(cfg.cardSidePad, 30);
if (sidePad < 0) sidePad = 0;
if (sidePad > 60) sidePad = 60;
const sideMargin = parentInset > 0 ? (sidePad - parentInset) : sidePad;
const padT = this._num(cfg.tipPaddingTop, 32);
const padR = this._num(cfg.tipPaddingRight, 32);
const padB = this._num(cfg.tipPaddingBottom, 32);
const padL = this._num(cfg.tipPaddingLeft, 32);
const style = {
marginTop: (bannerVisible ? (-overlap + overlapTop) : 0) + 'rpx',
marginLeft: sideMargin + 'rpx',
marginRight: sideMargin + 'rpx',
padding: `${padT}rpx ${padR}rpx ${padB}rpx ${padL}rpx`,
'font-size': `${this._num(cfg.tipFontSize, 32)}rpx`,
'color': cfg.tipColor || '#1f2937',
'background-color': cfg.tipBgColor || '#ffffff',
'border-radius': `${this._num(cfg.tipRadius, 24)}rpx`,
'box-shadow': cfg.tipShadow || '0 8rpx 24rpx rgba(0,0,0,0.08)',
'text-align': cfg.tipAlign || 'left'
};
// 边框:独立卡默认细边框(2rpx ≈ 后端预览 1px);配置 0 = 无边框
const bw = this._num(cfg.tipBorderWidth, 2);
if (bw > 0) {
style['border'] = `${bw}rpx ${cfg.tipBorderStyle || 'solid'} ${cfg.tipBorderColor || 'rgba(0,0,0,0.06)'}`;
}
return this.$util.objToStyle(style);
},
// Banner 容器破壳样式:仅在卡片模式下 + 父级传了 containerInset>0 时启用,让 banner 突破父容器 padding 跨越屏幕宽度。
// 计算逻辑:负 margin-left/right + 等量扩宽 width,使 banner 元素左/右边界对齐到屏幕边缘。
@@ -203,24 +298,76 @@ export default {
width: 'calc(100% + ' + expand + 'rpx)'
});
},
// 提示语样式:padding-x、font-size、color、bg-color、border-bottom(与后端 .form-preview-tip 对齐)
// 提示语样式(classic 平铺 / card+inside 卡内共用):与后端 edit.html 内联 .preview-tip 对齐
// - 四边内边距:未配置时经典态 上/下=rowPaddingY(4) 左右=tipPaddingX(14);卡片态统一 32rpx
// - 边框:classic 用 border-bottom(宽度默认 0;旧数据仅配 color → 2rpx 底边);
// card 用全边框(配置宽度才输出)
// - 圆角 / 阴影(rpx 字符串) / 对齐(card 态默认左对齐,经典态居中)
// - 背景色:''=未配置 → 透明(独立卡白底兜底已挪到 tipCardStyle,卡内 tip 无独立卡视觉)
tipStyle() {
const cfg = this.mergedFormConfig || {};
const card = this.hasBannerMode;
const padT = this._num(cfg.tipPaddingTop, card ? 32 : this._num(cfg.rowPaddingY, 4));
const padR = this._num(cfg.tipPaddingRight, card ? 32 : this._num(cfg.tipPaddingX, 14));
const padB = this._num(cfg.tipPaddingBottom, card ? 32 : this._num(cfg.rowPaddingY, 4));
const padL = this._num(cfg.tipPaddingLeft, card ? 32 : this._num(cfg.tipPaddingX, 14));
const style = {
padding: `${this._num(cfg.rowPaddingY, 4)}rpx ${this._num(cfg.tipPaddingX, 14)}rpx`,
padding: `${padT}rpx ${padR}rpx ${padB}rpx ${padL}rpx`,
'font-size': `${this._num(cfg.tipFontSize, 32)}rpx`,
'color': cfg.tipColor || '#000000',
'background-color': cfg.tipBgColor || 'transparent'
'color': cfg.tipColor || (card ? '#1f2937' : '#000000'),
'background-color': cfg.tipBgColor || 'transparent',
'text-align': cfg.tipAlign || (card ? 'left' : 'center')
};
// 仅在配置了 tipBorderColor 时添加底线,避免 transparent 占位仍增加 2rpx 高度
if (cfg.tipBorderColor) {
// 边框:配置了宽度(>0)输出;未配宽度但配了颜色 → 旧数据兼容,2rpx 底边(仅经典态)
const bw = this._num(cfg.tipBorderWidth, 0);
if (bw > 0) {
const bs = cfg.tipBorderStyle || 'solid';
const bc = cfg.tipBorderColor || 'rgba(0,0,0,0.06)';
if (card) {
style['border'] = `${bw}rpx ${bs} ${bc}`;
} else {
style['border-bottom'] = `${bw}rpx ${bs} ${bc}`;
}
} else if (!card && cfg.tipBorderColor) {
style['border-bottom'] = `2rpx solid ${cfg.tipBorderColor}`;
}
// 圆角(rpx)
if (cfg.tipRadius !== undefined && cfg.tipRadius !== null && cfg.tipRadius !== '') {
style['border-radius'] = `${this._num(cfg.tipRadius, 0)}rpx`;
}
// 阴影:后端存 rpx 单位字符串(如 '0 4rpx 12rpx rgba(...)'),uniapp H5/MP 运行时支持 rpx
if (cfg.tipShadow) {
style['box-shadow'] = cfg.tipShadow;
}
return this.$util.objToStyle(style);
},
// 提交按钮外层:与后端 .form-preview-submit 对齐(margin-top + padding: row-padding-y form-padding-x;background: form-bg-color)
// card + inside(默认)时按钮已在内容卡内:padding 收窄为卡内边距(≈后端预览 14px 12px 20px),不设独立背景(继承卡白底)
submitWrapStyle() {
const cfg = this.mergedFormConfig || {};
if (this.hasBannerMode && this.submitPosition !== 'outside') {
return this.$util.objToStyle({
marginTop: `${this._num(cfg.submitMarginTop, 0)}rpx`,
padding: `24rpx 24rpx 40rpx`
});
}
if (this.hasBannerMode) {
// card + outside:卡片下方独立区域 —— 与后端 .preview-submit-wrap(card 模式)同构:
// 左右破壳对齐卡片缩进(sidePad - parentInset),水平内缩 formPaddingX,
// 上下 padding 36rpx/48rpx(后端 18px/24px),背景白底(后端 .preview-submit-wrap 亦为白底)
const parentInset = this._num(this.containerInset, 0);
let sidePad = this._num(cfg.cardSidePad, 30);
if (sidePad < 0) sidePad = 0;
if (sidePad > 60) sidePad = 60;
const sideMargin = parentInset > 0 ? (sidePad - parentInset) : 0;
return this.$util.objToStyle({
marginTop: `${this._num(cfg.submitMarginTop, 0)}rpx`,
marginLeft: sideMargin + 'rpx',
marginRight: sideMargin + 'rpx',
padding: `36rpx ${this._num(cfg.formPaddingX, 14)}rpx 48rpx`,
'background-color': cfg.formBgColor || '#ffffff'
});
}
return this.$util.objToStyle({
marginTop: `${this._num(cfg.submitMarginTop, 0)}rpx`,
padding: `${this._num(cfg.rowPaddingY, 4)}rpx ${this._num(cfg.formPaddingX, 14)}rpx`,
@@ -303,6 +450,8 @@ export default {
labelFontWeight: pick('labelFontWeight', 'label_font_weight'),
labelAutoMaxWidth: pick('labelAutoMaxWidth', 'label_auto_max_width'),
requiredColor: pick('requiredColor', 'required_color'),
// 必填星号位置:before=标签前(默认)/ after=标签后(终端无关,存 base 层)
requiredPosition: pick('requiredPosition', 'required_position'),
checkedColor: pick('checkedColor', 'checked_color'),
// 占位符:遗漏会被 ns-newform SCSS 默认值回退覆盖
placeholderColor: pick('placeholderColor', 'placeholder_color'),
@@ -323,27 +472,47 @@ export default {
submitWidthPercent: pick('submitWidthPercent', 'submit_width_percent'),
submitSize: pick('submitSize', 'submit_size'),
submitMarginTop: pick('submitMarginTop', 'submit_margin_top'),
// 提示语
tipFontSize: pick('tipFontSize', 'tip_font_size'),
tipColor: pick('tipColor', 'tip_color'),
tipBgColor: pick('tipBgColor', 'tip_bg_color'),
tipBorderColor: pick('tipBorderColor', 'tip_border_color'),
// 卡片浮起(仅 show_banner && form_banner 启用)
cardOverlap: pick('cardOverlap', 'card_overlap'),
cardRadius: pick('cardRadius', 'card_radius'),
cardShadow: pick('cardShadow', 'card_shadow'),
cardSidePad: pick('cardSidePad', 'card_side_pad'),
cardBgColor: pick('cardBgColor', 'card_bg_color')
};
// 提示语(与后端 form.js syncFormConfigToCSS 的 --tip-* 变量一一对应)
tipFontSize: pick('tipFontSize', 'tip_font_size'),
tipColor: pick('tipColor', 'tip_color'),
tipBgColor: pick('tipBgColor', 'tip_bg_color'),
tipBorderColor: pick('tipBorderColor', 'tip_border_color'),
// 边框:宽度(rpx)/线型;未配置宽度时保留旧逻辑(仅 color 配置 → 底边 2rpx)
tipBorderWidth: pick('tipBorderWidth', 'tip_border_width'),
tipBorderStyle: pick('tipBorderStyle', 'tip_border_style'),
// 独立样式:圆角 / 四边内边距 / 阴影 / 对齐('' 与 null 均走 delete → 用各自态的兜底默认)
tipRadius: pick('tipRadius', 'tip_radius'),
tipPaddingTop: pick('tipPaddingTop', 'tip_padding_top'),
tipPaddingRight: pick('tipPaddingRight', 'tip_padding_right'),
tipPaddingBottom: pick('tipPaddingBottom', 'tip_padding_bottom'),
tipPaddingLeft: pick('tipPaddingLeft', 'tip_padding_left'),
tipShadow: pick('tipShadow', 'tip_shadow'),
tipAlign: pick('tipAlign', 'tip_align'),
// 卡片浮起(仅 show_banner && form_banner 启用)
cardOverlap: pick('cardOverlap', 'card_overlap'),
// 额外下移补偿(rpx,可正可负,与 overlap 叠加:margin-top = -overlap + overlap_top)
cardOverlapTop: pick('cardOverlapTop', 'card_overlap_top'),
cardRadius: pick('cardRadius', 'card_radius'),
cardShadow: pick('cardShadow', 'card_shadow'),
cardSidePad: pick('cardSidePad', 'card_side_pad'),
cardBgColor: pick('cardBgColor', 'card_bg_color'),
// 卡片边框(宽度 rpx / CSS 线型 / 颜色;留空 = 无边框)
cardBorderWidth: pick('cardBorderWidth', 'card_border_width'),
cardBorderStyle: pick('cardBorderStyle', 'card_border_style'),
cardBorderColor: pick('cardBorderColor', 'card_border_color'),
// 位置控制(仅 card 风格生效;outside/inside 见 tipPosition/submitPosition computed)
tipPosition: pick('tipPosition', 'tip_position'),
submitPosition: pick('submitPosition', 'submit_position')
};
// 移除 undefined / 空字符串(保留 0/false)
Object.keys(base).forEach(function(k) {
if (base[k] === undefined || base[k] === null || base[k] === '') {
delete base[k];
}
});
// 前端视觉默认:强制覆盖后端 formConfig 中可能存在的旧默认(horizontal / 红按钮 / 灰背景等),
// 与 formdata.vue 的 styleDefaults 保持一致,使所有 diy-form(含 diy-group 内的表单)统一呈现新设计稿样式。
// 后端仍保留行为/文案类配置(behavior / submitText / banner / tip 等),本合并仅覆盖视觉字段。
// 前端视觉默认:仅作兜底(后端未配置的字段才生效)。
// 注意合并顺序:base 在后 —— 后台 formConfig 显式配置的值(如 submit_bg_color / input_radius)
// 必须覆盖前端默认,否则商家端样式配置完全不生效(曾因此导致提交按钮恒为蓝色)。
const styleDefaults = {
formLayout: 'vertical',
formPaddingX: 28,
@@ -367,7 +536,7 @@ export default {
};
// 平台差异化配置(terminal 块在 formConfig 子对象内)
// 优先级:v.formConfig.terminal > v.form_config_terminal > v.terminal > v.form_config_platform
const result = Object.assign({}, base, styleDefaults);
const result = Object.assign({}, styleDefaults, base);
const termBlock = (v.formConfig && v.formConfig.terminal) || v.form_config_terminal || v.terminal || v.form_config_platform;
if (termBlock && typeof termBlock === 'object') {
Object.keys(termBlock).forEach(function(platform) {
@@ -503,19 +672,23 @@ export default {
z-index: 1;
}
// 横幅图片固定高度容器:默认 180rpx ≈ 页面 1/8,与 bannerImgWrapStyle 默认值一致。
// 后台可通过 formConfig.bannerHeight / banner_height 覆盖。
// 配合 mode="aspectFill" 使图片居中裁剪填充,跨端一致呈现"只占页面八分之一"的视觉效果。
// 横幅图片容器:默认高度由 image widthFix 按图片比例自适应(与商家端预览 height:auto 对齐);
// .is-fixed-height(显式配置 bannerHeight)时高度由 bannerImgWrapStyle 内联写入,
// 配合 mode="aspectFill" 居中裁剪填充。
.banner-img-wrap {
width: 100%;
height: 180rpx;
overflow: hidden;
position: relative;
&.is-fixed-height {
.banner-img {
height: 100%;
}
}
}
.banner-img {
width: 100%;
height: 100%;
display: block;
}
}
@@ -526,18 +699,34 @@ export default {
// 关键:卡片必须自带白底并 z-index 高于 banner,才能真正"盖住"banner 图片底部形成浮起;
// 父级容器(formdata.vue 的 .system-form-wrap)虽也是白底,但它是 banner 的祖先、背景会被 banner 覆盖,
// 因此本容器的白底不可省略(与 .system-form-wrap 同色,视觉上从卡片向下自然延续到页面底部)。
.form-card {
// 定位上下文 + 高于 banner 的层级:让卡片盖住 banner 底部(banner z-index:1)
position: relative;
z-index: 2;
// 兜底白底 / 圆角 / 阴影:与后端未配置时内联写入的默认值一致;后台配置时由内联 cardWrapStyle 覆盖
background-color: #ffffff;
border-radius: 24rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
.form-card {
// 定位上下文 + 高于 banner 的层级:让卡片盖住 banner 底部(banner z-index:1)
position: relative;
z-index: 2;
// 兜底白底 / 圆角 / 阴影:与后端未配置时内联写入的默认值一致;后台配置时由内联 cardWrapStyle 覆盖
background-color: #ffffff;
border-radius: 24rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
// 关键:裁剪卡内子元素(ns-newform 白底 / submit 区)到圆角内。
// tip 关闭时本卡带队负 margin 压 banner,若无 overflow:hidden,
// 卡内第一个子元素的方形背景会盖住卡片上圆角(banner 图片在圆角外的部分被遮)。
// 商家端预览 .draggable-wrap 同样有 overflow:hidden(form.css 两处同步)。
overflow: hidden;
// 兜底 padding-bottom:让卡片与后面内容(如推荐码、其它 diy 组件)有间距
padding-bottom: 24rpx;
}
// tip 独立卡(card + tip_position=outside):浮在 banner 上方的独立白卡。
// margin/padding/白底/圆角/阴影/边框 全部由 tipCardStyle 内联写入;
// 必须自带 z-index 高于 banner(z-index:1)才能真正"盖住"banner 底部形成浮起。
.form-tip-card {
position: relative;
z-index: 2;
box-sizing: border-box;
line-height: 1.5;
word-break: break-all;
}
.form-tip {
// 提示语:上下内边距 / 左右内边距 / 字号 / 颜色 / 背景 全部由 tipStyle 内联写入
padding: 0;

View File

@@ -6,7 +6,7 @@
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]" :style="rowCellStyle">
<view class="name" :style="item._labelStyle">
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
<text class="tit">{{ item.value.title }}</text><text v-if="requiredPosition === 'after'" class="required required-after" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'align-left']" :style="item._boxAlignStyle">
<input
@@ -24,7 +24,7 @@
<view :class="['order-cell', 'flex-box', 'textarea', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
<text class="tit">{{ item.value.title }}</text><text v-if="requiredPosition === 'after'" class="required required-after" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'align-left']" :style="item._boxAlignStyle">
<!-- 所有平台统一通过 formConfig.textareaRows / item.value.rows 控制显示行数:
@@ -52,7 +52,7 @@
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
<text class="tit">{{ item.value.title }}</text><text v-if="requiredPosition === 'after'" class="required required-after" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'box-flex', 'align-right']">
<text v-if="item.val != ''">{{ item.val }}</text>
@@ -68,7 +68,7 @@
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
<text class="tit">{{ item.value.title }}</text><text v-if="requiredPosition === 'after'" class="required required-after" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'check-group-box', 'align-left', 'option-layout-' + (item.value.option_layout || 'vertical')]">
<checkbox-group @change="checkboxChange($event, index)" :style="item._optionLayoutStyle">
@@ -97,7 +97,7 @@
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
<text class="tit">{{ item.value.title }}</text><text v-if="requiredPosition === 'after'" class="required required-after" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'radio-group-box', 'align-left', 'option-layout-' + (item.value.option_layout || 'vertical')]">
<radio-group @change="radioChange($event, index)" :style="item._optionLayoutStyle">
@@ -126,7 +126,7 @@
<view :class="['order-cell', 'flex-box', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
<text class="tit">{{ item.value.title }}</text><text v-if="requiredPosition === 'after'" class="required required-after" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'img-boxs', 'align-left']" :style="item._boxAlignStyle">
<view v-for="(v, k) in item.img_lists" :key="k" class="img-box" @tap="uploadImg(index)">
@@ -145,7 +145,7 @@
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
<text class="tit">{{ item.value.title }}</text><text v-if="requiredPosition === 'after'" class="required required-after" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'box-flex', 'align-right']">
<picker
@@ -167,7 +167,7 @@
<view :class="['order-cell', 'flex-box', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
<text class="tit">{{ item.value.title }}</text><text v-if="requiredPosition === 'after'" class="required required-after" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'date-boxs', 'align-center']" :style="item._boxAlignStyle">
<view class="date-box">
@@ -206,7 +206,7 @@
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
<text class="tit">{{ item.value.title }}</text><text v-if="requiredPosition === 'after'" class="required required-after" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'box-flex', 'align-right']">
<picker
@@ -228,7 +228,7 @@
<view :class="['order-cell', 'flex-box', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
<text class="tit">{{ item.value.title }}</text><text v-if="requiredPosition === 'after'" class="required required-after" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'date-boxs', 'align-center']" :style="item._boxAlignStyle">
<view class="date-box">
@@ -267,7 +267,7 @@
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
<text class="tit">{{ item.value.title }}</text><text v-if="requiredPosition === 'after'" class="required required-after" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'box-flex', 'align-right']">
<pick-regions
@@ -283,6 +283,29 @@
</view>
</view>
</view>
<!-- 信息卡片(展示型组件,与 addon/form 新版 INFO_CARD 对齐)
- 无标签/必填/校验/存储/导出,verify() 跳过
- 内容存 value.content(富文本 HTML),rich-text 跨端渲染
- 容器底色 value.bg_color;底部分割线 show_divider + divider_* 五参数
- 老数据兼容:content 为空但存在 title/items 时按旧结构渲染 -->
<view v-if="item.controller == 'InfoCard'" class="info-card-wrap" :style="item._infoCardWrapStyle">
<rich-text v-if="item._infoCardHasContent" class="info-card-content" :nodes="item.value.content"></rich-text>
<template v-else-if="item.value.title || (item.value.items && item.value.items.length)">
<view v-if="item.value.title" class="info-card-title" :style="{ fontSize: ((item.value.title_font_size || 36) / 2) + 'px', color: item.value.title_color || '#1f2937', fontWeight: 700 }">
{{ item.value.title }}
</view>
<view v-if="item.value.items && item.value.items.length" class="info-card-items">
<view
v-for="(it, iidx) in item.value.items"
:key="iidx"
class="info-card-item"
:style="{ fontSize: ((item.value.item_font_size || 26) / 2) + 'px', color: item.value.item_color || '#6b7280' }"
>{{ it }}</view>
</view>
</template>
<view v-if="item.value.show_divider == 1" class="info-card-divider" :style="item._infoCardDividerStyle"></view>
</view>
</view>
</view>
</template>
@@ -546,6 +569,10 @@ export default {
requiredColor() {
return this.config.requiredColor;
},
// 必填星号位置(before=标签前(默认)/ after=标签后;与后端 form_config.required_position 对齐)
requiredPosition() {
return this.config.requiredPosition === 'after' ? 'after' : 'before';
},
checkedColor() {
return this.config.checkedColor;
},
@@ -607,6 +634,50 @@ export default {
optStyle['gap'] = '20rpx';
}
this.$set(item, '_optionLayoutStyle', optStyle);
// 信息卡片:容器底色 + 空白判定 + 分割线样式(与后台 design.js 的
// getInfoCardContainerStyle / getInfoCardDividerStyle 对齐;数值单位为 px 直出,
// 与编辑器输入单位一致,375 宽屏上与后台 PC 预览视觉一致)
if (item.controller == 'InfoCard') {
// 容器:bg_color 配置且非 transparent 时输出底色 + 圆角 + 内边距
const wrapStyle = {};
const bg = itemValue.bg_color;
if (bg && bg !== 'transparent') {
wrapStyle['background-color'] = bg;
wrapStyle['border-radius'] = '6px';
wrapStyle['padding'] = '8px 10px';
}
this.$set(item, '_infoCardWrapStyle', wrapStyle);
// 空白判定:去标签 + &nbsp; + 去空白后为空 → 不渲染富文本(与后台一致,避免空白块)
const raw = itemValue.content == null ? '' : String(itemValue.content);
const textOnly = raw.replace(/<[^>]+>/g, '').replace(/&nbsp;/gi, ' ').replace(/\s/g, '');
this.$set(item, '_infoCardHasContent', !!textOnly);
// 分割线:border-top 渲染支持 solid/dashed/dotted/double;
// 宽度 calc(w% - 2*px),w<100 且 px=0 时 margin auto 居中(与后台同规则)
if (itemValue.show_divider == 1) {
const h = parseInt(itemValue.divider_height);
const mt = parseInt(itemValue.divider_margin_top);
let w = parseInt(itemValue.divider_width);
let px = parseInt(itemValue.divider_padding_x);
if (!Number.isFinite(w) || w <= 0 || w > 100) w = 100;
if (!Number.isFinite(px) || px < 0) px = 0;
const dStyle = {
height: '0',
'border-top': (Number.isFinite(h) && h > 0 ? h : 1) + 'px ' + (itemValue.divider_style || 'solid') + ' ' + (itemValue.divider_color || '#f0f0f0'),
'margin-top': (Number.isFinite(mt) && mt >= 0 ? mt : 8) + 'px',
'margin-left': px + 'px',
'margin-right': px + 'px',
width: 'calc(' + w + '% - ' + (px * 2) + 'px)'
};
if (w < 100 && px === 0) {
dStyle['margin-left'] = 'auto';
dStyle['margin-right'] = 'auto';
}
this.$set(item, '_infoCardDividerStyle', dStyle);
} else {
this.$set(item, '_infoCardDividerStyle', {});
}
}
});
},
// 容器样式(提供给外部 diy-form 使用)
@@ -793,7 +864,12 @@ export default {
verify() {
for (let i = 0; i < this.formData.length; i++) {
let item = this.formData[i];
// 信息卡片:不参与必填/校验(设计如此:仅展示活动/事件描述)
if (item.controller == 'InfoCard') {
continue;
}
if (item.controller == 'Text') {
if (item.value.required && !item.val) {
this.$util.showToast({ title: this.$lang('components.ns-newform.pleaseInput', { title: item.value.title }) });
@@ -1170,6 +1246,11 @@ export default {
display: inline-block;
vertical-align: middle;
}
/* 必填星号位置=标签后(required_position=after):margin 方向反转,与标签前视觉对称 */
.required.required-after {
margin-right: 0;
margin-left: 6rpx;
}
}
.box {
@@ -1526,6 +1607,55 @@ export default {
}
}
}
// 信息卡片(与 addon/form/shop/view/public/css/form.css 的 .form-info-card* 对齐)
// 展示型组件:富文本内容 + 可选底部分割线;不参与数据校验/存储
.info-card-wrap {
width: 100%;
box-sizing: border-box;
padding: 4rpx 0 2rpx;
// 富文本内容(编辑器产出的 p/strong/em/u/span/a/img/video/hr/iframe)
.info-card-content {
display: block;
font-size: 28rpx;
color: #303133;
line-height: 1.7;
word-break: break-all;
::v-deep p { margin: 0; }
::v-deep p + p { margin-top: 6rpx; }
::v-deep img { max-width: 100%; }
::v-deep video { max-width: 100%; display: block; }
::v-deep hr { border: none; border-top: 1px solid #e0e0e0; margin: 16rpx 0; }
::v-deep a { color: #1278FE; }
}
// 底部分割线:线样式/颜色/粗细/上边距/宽度/左右边距由内联 _infoCardDividerStyle 控制
.info-card-divider {
box-sizing: border-box;
}
// 老数据结构(title + items)兜底渲染
.info-card-title {
line-height: 1.4;
// 字号 / 颜色由模板内联 style 控制
}
.info-card-items {
margin-top: 16rpx;
}
.info-card-item {
line-height: 1.6;
word-break: break-all;
// 字号 / 颜色由模板内联 style 控制
& + .info-card-item {
margin-top: 4rpx;
}
}
}
</style>
<style lang="scss" scoped>

View File

@@ -2203,7 +2203,64 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
"diy-bargain": "view",
"diy-bottom-nav": "view",
"diy-category": "view",
"diy-category-item": "view",
"diy-channel-list": "view",
"diy-channel-video": "view",
"diy-comp-extend": "view",
"diy-coupon": "view",
"diy-digit": "view",
"diy-fenxiao-goods-list": "view",
"diy-float-btn": "view",
"diy-follow-official-account": "view",
"diy-form": "view",
"diy-goods-brand": "view",
"diy-goods-list": "view",
"diy-goods-recommend": "view",
"diy-graphic-nav": "view",
"diy-group": "view",
"diy-groupbuy": "view",
"diy-horz-blank": "view",
"diy-horz-line": "view",
"diy-hot-area": "view",
"diy-icon": "view",
"diy-image-nav": "view",
"diy-img-ads": "view",
"diy-index-page": "view",
"diy-kefu": "view",
"diy-listmenu": "view",
"diy-live": "view",
"diy-many-goods-list": "view",
"diy-map": "view",
"diy-member-info": "view",
"diy-member-my-order": "view",
"diy-merch-list": "view",
"diy-notes": "view",
"diy-notice": "view",
"diy-payment-qrcode": "view",
"diy-picture": "view",
"diy-pinfan": "view",
"diy-pintuan": "view",
"diy-presale": "view",
"diy-quick-nav": "view",
"diy-recommend-code": "view",
"diy-rich-text": "view",
"diy-rubik-cube": "view",
"diy-search": "view",
"diy-seckill": "view",
"diy-store": "view",
"diy-store-label": "view",
"diy-tab": "view",
"diy-text": "view",
"diy-video": "view",
"diy-video-list": "view"
}
}
},
{

File diff suppressed because it is too large Load Diff

View File

@@ -178,8 +178,9 @@ export default {
// 即使消费节点都在 v-if="detail" 内,detail 置空瞬间(提交成功/登录回跳等)也会求值,
// 必须做空值守卫,否则真机抛 Cannot read properties of null (reading 'json_data') 导致渲染中断
diyValue() {
// 新视觉默认样式:强制覆盖后端 form_config 中可能存在的旧默认(horizontal / 红按钮 / 灰背景等),
// 使表单在前端统一呈现为新设计稿样式;后台仍保留行为类配置(如 captcha / behavior / submitText)。
// 新视觉默认样式:仅作兜底(后端 form_config 未配置的字段才生效)。
// 注意合并顺序:detail.form_config 在后 —— 后台显式配置的值(submit_bg_color / input_radius / form_layout 等)
// 必须覆盖前端默认,否则商家端样式配置完全不生效(曾因此导致提交按钮恒为蓝色)。
const styleDefaults = {
formLayout: 'vertical',
formPaddingX: 28,
@@ -213,7 +214,7 @@ export default {
}
return {
jsonData: this.detail.json_data,
formConfig: Object.assign({}, this.detail.form_config || {}, styleDefaults),
formConfig: Object.assign({}, styleDefaults, this.detail.form_config || {}),
// 全屏底图模式下隐藏卡片内 banner,避免与底图重复(见 hideInnerBanner)
show_banner: this.hideInnerBanner ? 0 : this.detail.show_banner,
form_banner: this.detail.form_banner || 'public/uniapp/form/banner.png',
@@ -266,15 +267,10 @@ export default {
return 'padding-left:' + this.containerInset + 'rpx;padding-right:' + this.containerInset + 'rpx;';
},
// 表单风格(与 diy-form.vue hasBannerMode 对齐):优先读显式 form_style,未设置时回退到原 bgImage 隐式判定
// - card :顶部图条替代内联 banner → 隐藏内联 banner
// - card :卡片浮起需要 banner 作为被压对象 → 保留内联 banner(与后端预览一致);仅 bgImage 全屏底图时隐藏避免重复
// - classic:保留内联 banner(与原行为一致),仅当后台显式配 bgImage 才隐藏
// 老表单无 form_style 字段时 → undefined → 走原隐式判定,零迁移风险
// 如需保留卡片内 banner:把下面这行改成 `return false;` 即可
hideInnerBanner() {
const fc = (this.detail && this.detail.form_config) || {};
const style = fc.formStyle !== undefined ? fc.formStyle : fc.form_style;
if (style === 'card') return true;
if (style === 'classic') return !!this.bgImage;
return !!this.bgImage;
},
// 行为设置:提交验证码开启时 diy-form 隐藏内置提交按钮,本页渲染验证码 + 自有提交按钮