931 lines
45 KiB
Vue
931 lines
45 KiB
Vue
<template>
|
||
<view data-component-name="diy-form" class="diy-from" :style="diyFormStyle">
|
||
<!-- Banner -->
|
||
<!-- 鸿蒙元服务运行时在首次 _render 时 value 可能还是 undefined,
|
||
下面所有 value.xxx 模板表达式都必须用 `value && value.xxx` 守护,
|
||
否则会触发 MP 编译器注入的 propsToData 观察者(对应编译产物里
|
||
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">
|
||
<!-- Banner 图片高度策略(与商家端预览 .preview-banner height:auto 对齐):
|
||
默认 mode="widthFix":宽度撑满、高度按图片原始比例自适应,图片完整显示不裁剪;
|
||
显式配置 bannerHeight/banner_height 或 bannerCropRatio → 切 aspectFill 居中裁剪;
|
||
显式配置 bannerCropBox → 切 widthFix + image 内联 transform translate/缩放(手动裁栏框);
|
||
裁剪位置(bannerCropPosition):uni-app image 不支持 object-position,
|
||
通过 .banner-img-crop 容器的 transform translateY 控制(top/居中/底部)。 -->
|
||
<view class="banner-img-wrap" :class="bannerImgWrapClass" :style="bannerImgWrapStyle">
|
||
<view class="banner-img-crop" :class="'crop-' + bannerCropPosition">
|
||
<image :src="$util.img(value.form_banner)" :mode="bannerImgMode" class="banner-img" :style="bannerImgStyle" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<!-- 卡片浮起模式: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>
|
||
</template>
|
||
<!-- 无 banner 时仍按旧布局直接渲染 tip / ns-newform / submit,避免影响其他使用方(diy-group / diy-article 等) -->
|
||
<template v-else>
|
||
<!-- 提示语 -->
|
||
<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>
|
||
<view v-if="showSubmit" class="form-submit-wrap" :style="submitWrapStyle">
|
||
<view @click.stop="handleSubmit" class="form-submit" :style="submitStyle">{{ submitText }}</view>
|
||
</view>
|
||
</template>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
// 自定义表单
|
||
// 提交接管三档(不依赖 $listeners / 事件机制,跨 H5 / MP / 鸿蒙一致):
|
||
// 1) 默认 → showSubmit=true + 不传 onSubmit → 内置按钮 + 内置 defaultSubmit
|
||
// 2) 复用按钮、自定义逻辑 → showSubmit=true + :onSubmit="fn" → 内置按钮 + 父级 fn
|
||
// 3) 完全自定义 UI → showSubmit=false + :onSubmit="fn" 或 ref.verify()/getFormData() → 父级全控
|
||
// 注:type: Function prop 在 uni-app MP 端走 Vue runtime 注入,机制与 ns-navbar.customBack 一致
|
||
import DiyMinx from './minx.js'
|
||
import NsNewform from '@/components/ns-newform/ns-newform.vue'
|
||
|
||
// 横幅裁剪比例白名单(与后端 form.js syncFormConfigToCSS 保持一致)
|
||
const BANNER_CROP_RATIOS = { '16:9': 16/9, '4:3': 4/3, '1:1': 1, '3:4': 3/4 }
|
||
|
||
export default {
|
||
name: 'diy-from',
|
||
components: { NsNewform },
|
||
props: {
|
||
value: {
|
||
type: Object
|
||
},
|
||
// 数据源模式:
|
||
// 'diy'(默认):value.formComponents 是 [{ type, value }],组件内部转换为 ns-newform 期望的 [{ controller, value, val }]
|
||
// 'raw' :value.jsonData / value.json_data 已经是 ns-newform 期望的格式,直接透传给 ns-newform
|
||
// 用途:让 formdata 等"已是 ns-newform 格式"的页面也能复用本组件的 banner/tip/样式系统
|
||
dataMode: {
|
||
type: String,
|
||
default: 'diy',
|
||
validator: (v) => ['diy', 'raw'].includes(v)
|
||
},
|
||
// 是否渲染内置提交按钮。false 时由父级调用 ref 上的 verify()/getFormData() 自行实现提交
|
||
showSubmit: {
|
||
type: Boolean,
|
||
default: true
|
||
},
|
||
// 父级容器左右内边距(rpx)。卡片模式下让 banner 图片突破这个内边距左右铺满(如 system-form-wrap 的 padding-x)。
|
||
// 仅在 hasBannerMode=true 时生效;默认 0(不铺满)。
|
||
// 引入原因:formdata.vue 的 .system-form-wrap 默认 padding: 0 50rpx,banner 图片被左右各 50rpx 收缩,
|
||
// 后台配 form_banner 后图片应跨越屏幕宽度,而不是被容器内缩。
|
||
containerInset: {
|
||
type: Number,
|
||
default: 0
|
||
},
|
||
// 父级可选地接管提交:传入函数 prop 时,verify 通过后由它处理 formData;
|
||
// 不传时走组件内置 defaultSubmit(兜底)。default 必须是 null,不能写成函数返回 null
|
||
onSubmit: {
|
||
type: Function,
|
||
default: null
|
||
}
|
||
},
|
||
mixins: [DiyMinx],
|
||
data() {
|
||
return {
|
||
formComponents: [] // 转换后的表单组件
|
||
};
|
||
},
|
||
created() {
|
||
this.initForm();
|
||
},
|
||
watch: {
|
||
'value': {
|
||
deep: true,
|
||
handler() {
|
||
// raw 模式:formData 由父级透传,跳过重跑 initForm(避免每次父组件 re-render 触发 ns-newform 的深拷贝 initFormData)
|
||
if (this.dataMode === 'raw') return;
|
||
this.initForm();
|
||
}
|
||
}
|
||
},
|
||
computed: {
|
||
style() {
|
||
// 鸿蒙元服务运行时首次 _render 时 this.value 可能还是 undefined,必须全程加护
|
||
var css = '';
|
||
var v = this.value || {};
|
||
css += 'background-color:' + (v.contentBgColor || '') + ';';
|
||
if (v.elementAngle === 'round') {
|
||
css += 'border-top-left-radius:' + (v.topElementAroundRadius || 0) * 2 + 'rpx;';
|
||
css += 'border-top-right-radius:' + (v.topElementAroundRadius || 0) * 2 + 'rpx;';
|
||
css += 'border-bottom-left-radius:' + (v.bottomElementAroundRadius || 0) * 2 + 'rpx;';
|
||
css += 'border-bottom-right-radius:' + (v.bottomElementAroundRadius || 0) * 2 + 'rpx;';
|
||
}
|
||
return css;
|
||
},
|
||
// 兼容旧 style 别名(部分页面用 :style="style" 引用)
|
||
diyFormStyle() {
|
||
return this.style;
|
||
},
|
||
// Banner 容器左右内边距(与后端 .form-preview-banner padding-x 对齐)
|
||
bannerStyle() {
|
||
return this.$util.objToStyle({
|
||
padding: `0 ${this._num(this.mergedFormConfig && this.mergedFormConfig.bannerPaddingX, 0)}rpx`
|
||
});
|
||
},
|
||
// Banner 图片高度模式(2026-09-03 对齐商家端预览):
|
||
// 商家端预览 .preview-banner 是 max-width:100%; height:auto —— 按图片原始比例完整显示。
|
||
// 此前 H5 固定 180rpx + aspectFill 居中裁剪,导致横幅高度不一/图片内容被裁缺失。
|
||
// 现在:无显式配置 → widthFix 自适应(与后端 height:auto 等价);
|
||
// 显式配置 bannerHeight / banner_height → 固定高度 + aspectFill 裁剪(用户主动选择);
|
||
// 显式配置 bannerCropRatio → 按比例锁高度,比例+高度都设时高度优先(保持数字 rpx 的语义);
|
||
// 显式配置 bannerCropBox → 手动裁栏框(widthFix + image transform,最高优先级,独享遮罩)。
|
||
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() {
|
||
// 手动裁栏框优先(widthFix + transform);
|
||
// 显式选择比例(16:9/4:3/...)时用 aspectFill 填满比例容器;
|
||
// 仅配置高度 + 原比例时与商家端预览一致:widthFix 自适应,容器限制最大高度,图片完整显示不裁剪。
|
||
if (this.hasManualCrop) return 'widthFix';
|
||
return this.bannerCropRatio ? 'aspectFill' : 'widthFix';
|
||
},
|
||
// Banner 图片包裹层高度:
|
||
// - 比例模式 / 手动框:输出固定 height,配合 aspectFill 裁剪;
|
||
// - 仅高度 + 原比例:输出 max-height,让图片按原始比例完整显示,高度不超过设定值。
|
||
bannerImgWrapStyle() {
|
||
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;
|
||
if (this.bannerCropRatio || this.hasManualCrop) {
|
||
return this.$util.objToStyle({ height: h + 'rpx' });
|
||
}
|
||
return this.$util.objToStyle({ 'max-height': h + 'rpx' });
|
||
},
|
||
// 裁剪比例:读双键(camel + snake);合法值 16:9/4:3/1:1/3:4;空 = 不启用
|
||
bannerCropRatio() {
|
||
const cfg = this.mergedFormConfig || {};
|
||
const raw = cfg.bannerCropRatio !== undefined ? cfg.bannerCropRatio : cfg.banner_crop_ratio;
|
||
return (raw && BANNER_CROP_RATIOS[raw]) ? raw : '';
|
||
},
|
||
// 裁剪位置:top/center/bottom(默认 center);通过 CSS object-position 控制(uni-app H5/MP 容器配合 transform 实现)
|
||
bannerCropPosition() {
|
||
const cfg = this.mergedFormConfig || {};
|
||
const raw = cfg.bannerCropPosition !== undefined ? cfg.bannerCropPosition : cfg.banner_crop_position;
|
||
return (raw === 'top' || raw === 'bottom') ? raw : 'center';
|
||
},
|
||
// 手动裁栏框:读双键;返回 {x,y,w,h} 0..1 比例或 null
|
||
// 三优先级:手动框 > 比例/位置 > 高度/位置(与后端 syncFormConfigToCSS 对齐)
|
||
bannerCropBox() {
|
||
const cfg = this.mergedFormConfig || {};
|
||
const raw = cfg.bannerCropBox !== undefined ? cfg.bannerCropBox : cfg.banner_crop_box;
|
||
if (!raw) return null;
|
||
try {
|
||
const obj = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||
if (obj && typeof obj.x === 'number' && typeof obj.y === 'number' && typeof obj.w === 'number' && typeof obj.h === 'number' && obj.w > 0 && obj.h > 0) {
|
||
return obj;
|
||
}
|
||
} catch (e) { return null; }
|
||
return null;
|
||
},
|
||
// 手动裁栏框激活时:渲染模式切 widthFix(保留全图)+ 内层 img 用 transform 移动/缩放显示裁栏区域
|
||
hasManualCrop() { return !!this.bannerCropBox; },
|
||
// 手动裁栏框下 image 内联样式:放大到原图尺寸,按 box 坐标 translate 显示
|
||
// 用 rpx 转换(MP 原生 rpx / H5 换算 px)
|
||
bannerImgStyle() {
|
||
if (!this.hasManualCrop) return '';
|
||
const b = this.bannerCropBox;
|
||
const scaleX = 1 / b.w;
|
||
const scaleY = 1 / b.h;
|
||
const tx = -b.x * 100;
|
||
const ty = -b.y * 100;
|
||
return this.$util.objToStyle({
|
||
position: 'absolute',
|
||
left: 0,
|
||
top: 0,
|
||
width: (scaleX * 100) + '%',
|
||
height: (scaleY * 100) + '%',
|
||
maxWidth: 'none',
|
||
transform: 'translate(' + tx + '%, ' + ty + '%)',
|
||
});
|
||
},
|
||
// 手动裁栏框下容器需要绝对定位 + 隐藏溢出
|
||
bannerImgWrapClass() {
|
||
const isAspectFill = this.bannerImgMode === 'aspectFill';
|
||
return {
|
||
'is-fixed-height': isAspectFill,
|
||
'has-crop': isAspectFill,
|
||
'has-manual-crop': this.hasManualCrop,
|
||
'has-height-limit': this.hasBannerHeight && !isAspectFill && !this.hasManualCrop
|
||
};
|
||
},
|
||
// 表单风格:优先读显式 form_style(classic / card),未设置时回退到隐式判定(看 show_banner && form_banner)
|
||
// - classic:经典风格,平铺无 banner 卡片
|
||
// - card :卡片浮起风格,banner + 卡片浮起 + 容器内缩
|
||
// 注意:必须读 this.formCfg(原始 formConfig 对象)而不是 mergedFormConfig(白名单组装对象,不含 form_style 键)
|
||
// 双键兼容:formdata 链路 camel(formStyle,api splitFormConfig 转换);DIY 链路 snake(form_style,design.js 直存)
|
||
// 老表单无 form_style 字段时 → undefined → 走隐式判定,与原行为完全一致,零迁移风险
|
||
hasBannerMode() {
|
||
const v = this.value || {};
|
||
const fc = this.formCfg || {};
|
||
const style = fc.formStyle !== undefined ? fc.formStyle : fc.form_style;
|
||
if (style === 'card') return true;
|
||
if (style === 'classic') return false;
|
||
return !!(v.show_banner && v.form_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 || {};
|
||
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;
|
||
// 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 更明显,突出浮起感)
|
||
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';
|
||
// 边框:宽度 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 元素左/右边界对齐到屏幕边缘。
|
||
// 父级未传 containerInset 或传 0 时返回空样式,banner 保持原尺寸(零外溢)。
|
||
bannerBleedStyle() {
|
||
const inset = this._num(this.containerInset, 0);
|
||
if (!this.hasBannerMode || inset <= 0) return '';
|
||
const expand = inset * 2;
|
||
return this.$util.objToStyle({
|
||
marginLeft: -inset + 'rpx',
|
||
marginRight: -inset + 'rpx',
|
||
width: 'calc(100% + ' + expand + 'rpx)'
|
||
});
|
||
},
|
||
// 提示语样式(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: `${padT}rpx ${padR}rpx ${padB}rpx ${padL}rpx`,
|
||
'font-size': `${this._num(cfg.tipFontSize, 32)}rpx`,
|
||
'color': cfg.tipColor || (card ? '#1f2937' : '#000000'),
|
||
'background-color': cfg.tipBgColor || 'transparent',
|
||
'text-align': cfg.tipAlign || (card ? 'left' : 'center')
|
||
};
|
||
// 边框:配置了宽度(>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`,
|
||
'background-color': cfg.formBgColor || '#ffffff'
|
||
});
|
||
},
|
||
// 提交按钮样式:颜色 / 背景色 / 宽度(百分比)/ 上下内边距(控制高度)/ 圆角 / 字号
|
||
// 与后端 design.css .form-preview-submit button 对齐:
|
||
// - 高度由 --submit-padding-y + 字号决定,避免硬编码 line-height
|
||
// - 宽度由 --submit-width-percent 控制(10-100,clamp)
|
||
submitStyle() {
|
||
const cfg = this.mergedFormConfig || {};
|
||
// 宽度百分比:clamp 到 10-100
|
||
const widthPct = Math.min(100, Math.max(10, this._num(cfg.submitWidthPercent, 100)));
|
||
// 上下内边距:与后端 design.js submitSizeMap 对齐(mini/small/medium/large/huge → 0/4/12/24/40 rpx)
|
||
const submitPaddingMap = { mini: 0, small: 4, medium: 12, large: 24, huge: 40 };
|
||
const submitPaddingY = submitPaddingMap[cfg.submitSize] != null
|
||
? submitPaddingMap[cfg.submitSize]
|
||
: submitPaddingMap.medium;
|
||
return this.$util.objToStyle({
|
||
width: widthPct + '%',
|
||
padding: `${submitPaddingY}rpx 0`,
|
||
color: cfg.submitColor || '#ffffff',
|
||
'background-color': cfg.submitBgColor || '#3b82f6',
|
||
'border-radius': `${this._num(cfg.inputRadius, 10)}rpx`,
|
||
'font-size': `${this._num(cfg.labelFontSize, 28)}rpx`
|
||
});
|
||
},
|
||
// 提交按钮文字:与后端 design.html <button>提交</button> 默认对齐,开放给 formConfig 覆盖
|
||
// 走 mergedFormConfig(兼容 camelCase / snake_case),让 formConfig.submitText 能正确覆盖
|
||
submitText() {
|
||
return this.mergedFormConfig.submitText || '提交';
|
||
},
|
||
// 父级是否接管提交(集中判断,便于以后替换机制;避免多个调用点各自 typeof 校验)
|
||
hasCustomSubmit() {
|
||
return typeof this.onSubmit === 'function';
|
||
},
|
||
// 后端把表单样式配置统一存放在 data.formConfig 子对象中(与后端 design.js getDefaultFormData().formConfig 字段名一致)。
|
||
// 这里做一次归一化:优先取 formConfig;如果历史数据是扁平结构(直接挂在 value 上),也做兼容。
|
||
formCfg() {
|
||
const v = this.value || {};
|
||
if (v.formConfig && typeof v.formConfig === 'object') {
|
||
return v.formConfig;
|
||
}
|
||
return v;
|
||
},
|
||
// 合并后的表单配置(支持平台差异化)
|
||
mergedFormConfig() {
|
||
const v = this.value || {};
|
||
const fc = this.formCfg;
|
||
// 后端实际返回 camelCase(如 formPaddingX / formBgColor / labelFontSize),
|
||
// 但旧版 design.js / 部分序列化路径可能用 snake_case(form_padding_x)。这里同时读两种写法,camelCase 优先
|
||
const self = this;
|
||
const pick = function(camel, snake) {
|
||
var v1 = fc[camel];
|
||
if (v1 !== undefined && v1 !== null && v1 !== '') return v1;
|
||
return fc[snake];
|
||
};
|
||
// 多别名取值:后台不同版本 / 不同序列化路径对同一配置项可能用不同键名,
|
||
// 这里按优先级取第一个有效值,避免白名单裁剪时把后台配置丢掉(丢了下层永远读不到)
|
||
const pickAny = function(keys) {
|
||
for (var i = 0; i < keys.length; i++) {
|
||
var kv = fc[keys[i]];
|
||
if (kv !== undefined && kv !== null && kv !== '') return kv;
|
||
}
|
||
return undefined;
|
||
};
|
||
// 通用全局配置(与后端 design.js getDefaultFormData().formConfig 字段名一致)
|
||
const base = {
|
||
formPaddingX: pick('formPaddingX', 'form_padding_x'),
|
||
// Banner 高度(rpx)。后台可配,默认 180rpx ≈ 页面 1/8 高度(适配主流手机屏高)
|
||
// 配合 mode="aspectFill" 居中裁剪,保证横幅只占页面八分之一左右
|
||
bannerHeight: pick('bannerHeight', 'banner_height'),
|
||
bannerPaddingX: pick('bannerPaddingX', 'banner_padding_x'),
|
||
// 横幅裁剪位置(top/center/bottom)+ 比例(16:9/4:3/1:1/3:4)+ 手动裁栏框 JSON(2026-09-04 方案 C 抽屉配套)
|
||
bannerCropPosition: pick('bannerCropPosition', 'banner_crop_position'),
|
||
bannerCropRatio: pick('bannerCropRatio', 'banner_crop_ratio'),
|
||
bannerCropBox: pick('bannerCropBox', 'banner_crop_box'),
|
||
tipPaddingX: pick('tipPaddingX', 'tip_padding_x'),
|
||
// 行内上下边距:兼容 row_padding_y / rowPaddingY / row_padding / rowPadding 等键名
|
||
rowPaddingY: (function() {
|
||
var v = pick('rowPaddingY', 'row_padding_y');
|
||
if (v !== undefined && v !== null && v !== '') return v;
|
||
return pickAny(['rowPadding', 'row_padding', 'rowPaddingVertical', 'row_padding_vertical', 'row_padding_top', 'rowPaddingTop']);
|
||
})(),
|
||
rowMinHeight: pick('rowMinHeight', 'row_min_height'),
|
||
labelGap: pick('labelGap', 'label_gap'),
|
||
formBgColor: pick('formBgColor', 'form_bg_color'),
|
||
rowDividerColor: pick('rowDividerColor', 'row_divider_color'),
|
||
showRowDivider: self._coerceBool(pick('showRowDivider', 'show_row_divider')),
|
||
inputHeight: pick('inputHeight', 'input_height'),
|
||
inputRadius: pick('inputRadius', 'input_radius'),
|
||
formLayout: pick('formLayout', 'form_layout'),
|
||
labelWidthMode: pick('labelWidthMode', 'label_width_mode'),
|
||
labelFixedWidth: pick('labelFixedWidth', 'label_fixed_width'),
|
||
labelFontSize: pick('labelFontSize', 'label_font_size'),
|
||
labelColor: pick('labelColor', 'label_color'),
|
||
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'),
|
||
placeholderFontSize: pick('placeholderFontSize', 'placeholder_font_size'),
|
||
// textarea 显示行数 / 行高(透传给 ns-newform,与 ns-form 保持一致)
|
||
// 不传时 ns-newform 走自己的默认 4 行 / 44rpx
|
||
textareaRows: pick('textareaRows', 'textarea_rows'),
|
||
textareaLineHeight: pick('textareaLineHeight', 'textarea_line_height'),
|
||
// 输入框视觉增强(与 ns-newform 对齐)
|
||
inputBorderColor: pick('inputBorderColor', 'input_border_color'),
|
||
inputBgColor: pick('inputBgColor', 'input_bg_color'),
|
||
inputFocusBorderColor: pick('inputFocusBorderColor', 'input_focus_border_color'),
|
||
inputFocusBgColor: pick('inputFocusBgColor', 'input_focus_bg_color'),
|
||
submitText: pick('submitText', 'submit_text'),
|
||
submitColor: pick('submitColor', 'submit_color'),
|
||
submitBgColor: pick('submitBgColor', 'submit_bg_color'),
|
||
// 提交按钮尺寸:宽度百分比 + 尺寸预设(与后端 design.js 对齐)
|
||
submitWidthPercent: pick('submitWidthPercent', 'submit_width_percent'),
|
||
submitSize: pick('submitSize', 'submit_size'),
|
||
submitMarginTop: pick('submitMarginTop', 'submit_margin_top'),
|
||
// 提示语(与后端 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];
|
||
}
|
||
});
|
||
// 前端视觉默认:仅作兜底(后端未配置的字段才生效)。
|
||
// 注意合并顺序:base 在后 —— 后台 formConfig 显式配置的值(如 submit_bg_color / input_radius)
|
||
// 必须覆盖前端默认,否则商家端样式配置完全不生效(曾因此导致提交按钮恒为蓝色)。
|
||
const styleDefaults = {
|
||
formLayout: 'vertical',
|
||
formPaddingX: 28,
|
||
rowPaddingY: 24,
|
||
labelGap: 20,
|
||
labelFontSize: 32,
|
||
labelFontWeight: 700,
|
||
labelColor: '#1f2937',
|
||
inputHeight: 84,
|
||
inputRadius: 16,
|
||
inputBgColor: '#ffffff',
|
||
inputBorderColor: '#e5e7eb',
|
||
inputFocusBorderColor: '#3b82f6',
|
||
inputFocusBgColor: '#ffffff',
|
||
placeholderColor: '#9ca3af',
|
||
placeholderFontSize: 28,
|
||
checkedColor: '#3b82f6',
|
||
showRowDivider: false,
|
||
submitColor: '#ffffff',
|
||
submitBgColor: '#3b82f6'
|
||
};
|
||
// 平台差异化配置(terminal 块在 formConfig 子对象内)
|
||
// 优先级:v.formConfig.terminal > v.form_config_terminal > v.terminal > v.form_config_platform
|
||
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) {
|
||
var src = termBlock[platform];
|
||
if (!src) return;
|
||
// 嵌套值若为 snake_case(部分序列化路径),在此统一转 camelCase
|
||
var translated = {};
|
||
Object.keys(src).forEach(function(k) {
|
||
translated[self.$util.snakeToCamel(k)] = src[k];
|
||
});
|
||
result[platform] = translated;
|
||
});
|
||
}
|
||
return result;
|
||
}
|
||
},
|
||
methods: {
|
||
// 初始化表单 - 将 diy-form 的数据格式转换成 ns-newform 的格式
|
||
initForm() {
|
||
if (!this.value) {
|
||
this.formComponents = [];
|
||
return;
|
||
}
|
||
// raw 模式:value.jsonData / value.json_data 已经是 ns-newform 期望的 [{ controller, value, val }],直接透传
|
||
if (this.dataMode === 'raw') {
|
||
this.formComponents = this.value.jsonData || this.value.json_data || [];
|
||
return;
|
||
}
|
||
var components = this.value.formComponents || this.value.form_components || [];
|
||
// 转换格式:type -> controller
|
||
this.formComponents = (components || []).map(function(item) {
|
||
return {
|
||
controller: item.type,
|
||
value: item.value || {},
|
||
val: (item.value && item.value.default) ? item.value.default : '' // 添加初始值
|
||
};
|
||
});
|
||
},
|
||
|
||
// 暴露给父级:触发 ns-newform 校验。校验失败时 ns-newform 内部会 toast;返回 false 表示失败,返回 formData 数组表示成功
|
||
verify() {
|
||
if (this.$refs.nsNewform && this.$refs.nsNewform.verify) {
|
||
return this.$refs.nsNewform.verify();
|
||
}
|
||
return false;
|
||
},
|
||
// 暴露给父级:获取 ns-newform 当前的 formData 数组(含 val / option_lists / img_lists 等全部字段)
|
||
getFormData() {
|
||
return (this.$refs.nsNewform && this.$refs.nsNewform.formData) || [];
|
||
},
|
||
|
||
// 数字保底:undefined/空串/非数字时返回兜底值
|
||
_num(v, fb) {
|
||
if (v === '' || v === null || v === undefined) return fb;
|
||
var n = parseInt(v);
|
||
return isNaN(n) ? fb : n;
|
||
},
|
||
// 布尔保底:0/false/'0'/'false' → false;其它有值 → true
|
||
_coerceBool(v) {
|
||
if (v === false || v === 0 || v === '0' || v === 'false' || v === null || v === undefined) {
|
||
return false;
|
||
}
|
||
return true;
|
||
},
|
||
|
||
// 统一提交入口:内置按钮 / 父级 ref 调用都走这里
|
||
// 不依赖 $listeners / 事件机制(MP 端 $listeners 为 undefined),跨端一致
|
||
handleSubmit() {
|
||
// 校验(同时返回 formData 数组;校验失败时 ns-newform 内部 toast 提示)
|
||
if (!this.verify()) {
|
||
return;
|
||
}
|
||
const formData = this.getFormData();
|
||
// 父级显式接管:function prop 在 H5 / MP / 鸿蒙一致可用
|
||
if (this.hasCustomSubmit) {
|
||
return this.onSubmit(formData);
|
||
}
|
||
// 兜底:内置 sendRequest 流程
|
||
this.defaultSubmit(formData);
|
||
},
|
||
|
||
// 公共兜底实现:默认的 /form/api/form/create 流程;
|
||
// 父级若需"先自定义、失败回退",可经 ref.defaultSubmit(formData) 调用
|
||
defaultSubmit(submitFormData) {
|
||
var self = this;
|
||
this.$api.sendRequest({
|
||
url: '/form/api/form/create',
|
||
data: {
|
||
form_id: this.value.form_id || 0,
|
||
form_data: typeof submitFormData === 'string' ? submitFormData : JSON.stringify(submitFormData),
|
||
component_id: this.value.id || '',
|
||
form_name: this.value.form_name || '默认表单'
|
||
},
|
||
success: function(res) {
|
||
if (res.code === 0) {
|
||
uni.showToast({
|
||
title: self.$lang('submitSuccess'),
|
||
icon: 'success'
|
||
});
|
||
// 重置表单
|
||
if (self.$refs.nsNewform && self.$refs.nsNewform.setFormData) {
|
||
self.$refs.nsNewform.setFormData();
|
||
}
|
||
} else {
|
||
uni.showToast({
|
||
title: res.message || self.$lang('submitFail'),
|
||
icon: 'none'
|
||
});
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<style lang="scss">
|
||
.diy-from {
|
||
background: #fff;
|
||
padding: 0;
|
||
border-radius: 10rpx;
|
||
|
||
.form-banner {
|
||
width: 100%;
|
||
overflow: hidden;
|
||
// 默认左右无边距(与后端 --banner-padding-x 对齐;由内联 bannerStyle 覆盖)
|
||
margin: 0;
|
||
|
||
// banner 图片作为卡片上方的"底图":建立层叠上下文,让 .form-card 通过 z-index 浮在其上。
|
||
// 仅在卡片浮起模式(.is-card-overlap)启用,不影响其它使用方(diy-group 等)的旧布局。
|
||
&.is-card-overlap {
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
// 横幅图片容器:默认高度由 image widthFix 按图片比例自适应(与商家端预览 height:auto 对齐);
|
||
// .is-fixed-height(显式配置 bannerHeight)时高度由 bannerImgWrapStyle 内联写入,
|
||
// 配合 mode="aspectFill" 居中裁剪填充。
|
||
.banner-img-wrap {
|
||
width: 100%;
|
||
overflow: hidden;
|
||
position: relative;
|
||
|
||
// 默认作为 flex 容器,支持 widthFix + max-height 模式下的顶部/居中/底部对齐
|
||
.banner-img-crop {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: flex-start;
|
||
overflow: hidden;
|
||
|
||
&.crop-center {
|
||
justify-content: center;
|
||
}
|
||
|
||
&.crop-bottom {
|
||
justify-content: flex-end;
|
||
}
|
||
}
|
||
|
||
&.is-fixed-height {
|
||
.banner-img {
|
||
height: 100%;
|
||
}
|
||
}
|
||
|
||
// 启用 aspectFill 裁剪时,包装一层 .banner-img-crop 承接裁剪位置。
|
||
// 重要:uni-app image 在 H5 端固定转译为 <div style="background-image:url; background-size:cover; background-position:center center;"></div>,
|
||
// 内层 div 的 background-position 是运行时生成的 inline 样式,普通 CSS 覆盖不了,必须 !important;
|
||
// (不能用 .banner-img 的 translateY 实现位置:image 高度被钉死为容器 100% 后 transform 一去一回相互抵消,是零位移)
|
||
// - crop-top → background-position: center top(显示图片顶部区域)
|
||
// - crop-center → 默认 center center(不覆盖)
|
||
// - crop-bottom → background-position: center bottom(显示图片底部区域)
|
||
&.has-crop {
|
||
.banner-img-crop {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
}
|
||
.banner-img {
|
||
// 撑满 wrap,让内层 background-image div 拿到确定高度(uni-image H5 端转译依赖)
|
||
height: 100%;
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
width: 100%;
|
||
}
|
||
// uni-app H5 编译器会给样式追加 [data-v-xxx](即使未写 scoped),
|
||
// 而 uni-image 运行时 JS 生成的内层 div 没有该属性 → 必须 ::v-deep 把属性选择器挪到 .banner-img 上
|
||
.banner-img-crop.crop-top .banner-img ::v-deep div {
|
||
background-position: center top !important;
|
||
}
|
||
.banner-img-crop.crop-bottom .banner-img ::v-deep div {
|
||
background-position: center bottom !important;
|
||
}
|
||
}
|
||
|
||
// 启用手动裁栏框(bannerCropBox 非空)时:image 用 transform 显示裁栏部分,原图按 box 缩放 + 平移
|
||
&.has-manual-crop {
|
||
.banner-img-crop {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
}
|
||
.banner-img {
|
||
// 手动框下由 bannerImgStyle 内联 width/height/transform 驱动,不固定 100%
|
||
height: auto;
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
.banner-img {
|
||
width: 100%;
|
||
display: block;
|
||
}
|
||
}
|
||
|
||
// 卡片容器:浮在 banner 图片上方(参考设计左图)。
|
||
// 仅在 hasBannerMode=true 时渲染(见 template v-if);
|
||
// 阴影 + margin-top(负偏移)+ 圆角 + 白底 全部由 cardWrapStyle 内联写入(读取后台 formConfig.card*)。
|
||
// 关键:卡片必须自带白底并 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);
|
||
// 关键:裁剪卡内子元素(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;
|
||
text-align: center;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.form-submit-wrap {
|
||
// 外层:与后端 .form-preview-submit 对齐(margin-top + padding-y + form-padding-x + form-bg-color + flex 居中)
|
||
// margin-top/padding/background 由 submitWrapStyle 内联写入
|
||
// display: flex + justify-content: center 是让宽度小于 100% 的按钮水平居中的关键
|
||
box-sizing: border-box;
|
||
width: 100%;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
.form-submit {
|
||
// 提交按钮:颜色 / 背景色 / 宽度(百分比)/ 上下内边距(控制高度)/ 圆角 / 字号
|
||
// 全部由 submitStyle 内联写入
|
||
// 宽度不再写死 100%,由内联 style 的 width: <pct>% 决定;max-width: 100% 防止溢出
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
max-width: 100%;
|
||
font-weight: 500;
|
||
letter-spacing: 1rpx;
|
||
transition: opacity 0.2s ease;
|
||
box-sizing: border-box;
|
||
|
||
&:active {
|
||
opacity: 0.85;
|
||
}
|
||
}
|
||
}
|
||
</style>
|