Files
lucky_shop/components/ns-newform/ns-newform.vue

2030 lines
79 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="form-wrap form-component" :style="formWrapStyle" >
<view v-for="(item, index) in formData" :key="index">
<!-- 文本输入框 -->
<view v-if="item.controller == 'Text'" class="order-wrap" :class="rowWrapClass" :style="item._fieldWrapStyle">
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]" :style="rowCellStyle">
<view class="name" :style="item._labelStyle">
<text class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
<text class="tit" :style="item._titStyle">{{ item.value.title }}</text><text v-if="requiredPosition === 'after'" class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'align-left']" :style="item._boxAlignStyle">
<input
type="text"
:placeholder="item.value.placeholder"
placeholder-class="placeholder color-tip"
:placeholder-style="item._placeholderStyle"
:style="item._inputStyle"
v-model="item.val"
/>
</view>
</view>
</view>
<!-- 多行文本输入框 -->
<view v-if="item.controller == 'Textarea'" class="order-wrap" :class="rowWrapClass" :style="item._fieldWrapStyle">
<view :class="['order-cell', 'flex-box', 'textarea', { 'vertical-layout': mode === 'vertical' }]" :style="rowCellStyle">
<view class="name" :style="item._labelStyle">
<text class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
<text class="tit" :style="item._titStyle">{{ item.value.title }}</text><text v-if="requiredPosition === 'after'" class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'align-left']" :style="item._boxAlignStyle">
<!-- 所有平台统一通过 formConfig.textareaRows / item.value.rows 控制显示行数:
- H5 / 微信:原生 textarea 识别 :rows 属性;
- 鸿蒙:渲染为 ArkUI 原生 TextArea,CSS height 不生效,必须靠 :rows 控制。
rows 全部由 formConfig 动态下发,与 ns-form 行为保持一致。 -->
<textarea
:rows="item.value.rows || textareaRowsValue"
:placeholder="item.value.placeholder"
placeholder-class="placeholder color-tip"
:placeholder-style="item._placeholderStyle"
v-model="item.val"
:style="item._textareaStyle"
></textarea>
</view>
</view>
</view>
<!-- 下拉选择器 -->
<view v-if="item.controller == 'Select'" class="order-wrap" :class="rowWrapClass" :style="item._fieldWrapStyle">
<picker
mode="selector"
:range="item.value.options"
@change="pickerChange($event, index)"
>
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]" :style="rowCellStyle">
<view class="name" :style="item._labelStyle">
<text class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
<text class="tit" :style="item._titStyle">{{ item.value.title }}</text><text v-if="requiredPosition === 'after'" class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'box-flex', 'align-right']" :style="item._controlBoxStyle">
<text v-if="item.val != ''" :style="item._valueTextStyle">{{ item.val }}</text>
<text v-else class="color-tip" :style="item._tipTextStyle">{{ item.value.placeholder || $lang('components.ns-newform.selectPlaceholder') }}</text>
<text class="iconfont icon-right"></text>
</view>
</view>
</picker>
</view>
<!-- 复选框 -->
<view v-if="item.controller == 'Checkbox'" class="order-wrap" :class="rowWrapClass" :style="item._fieldWrapStyle">
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]" :style="rowCellStyle">
<view class="name" :style="item._labelStyle">
<text class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
<text class="tit" :style="item._titStyle">{{ item.value.title }}</text><text v-if="requiredPosition === 'after'" class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'check-group-box', 'align-left', 'option-layout-' + (item.value.option_layout || 'vertical')]" :style="item._controlWidthStyle">
<checkbox-group @change="checkboxChange($event, index)" :style="item._optionLayoutStyle">
<label v-for="(v, k) in item.option_lists" :key="k" :style="item._optionLabelStyle">
<checkbox :value="v.value" :checked="v.checked"></checkbox>
<view class="checkbox">
<text
:class="[
'iconfont',
'check-icon',
!v.checked ? 'icon-fuxuankuang2' : '',
v.checked ? 'icon-fuxuankuang1' : ''
]"
:style="v.checked ? item._checkedColorStyle : ''"
></text>
{{ v.value }}
</view>
</label>
</checkbox-group>
</view>
</view>
</view>
<!-- 单选框 -->
<view v-if="item.controller == 'Radio'" class="order-wrap" :class="rowWrapClass" :style="item._fieldWrapStyle">
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]" :style="rowCellStyle">
<view class="name" :style="item._labelStyle">
<text class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
<text class="tit" :style="item._titStyle">{{ item.value.title }}</text><text v-if="requiredPosition === 'after'" class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'radio-group-box', 'align-left', 'option-layout-' + (item.value.option_layout || 'vertical')]" :style="item._controlWidthStyle">
<radio-group @change="radioChange($event, index)" :style="item._optionLayoutStyle">
<label v-for="(v, k) in item.option_lists" :key="k" :style="item._optionLabelStyle">
<radio :value="v.value" :checked="item.val == v.value"></radio>
<view class="radio-box">
<text
:class="[
'iconfont',
'radio-icon',
item.val != v.value ? 'icon-yuan_checkbox' : '',
item.val == v.value ? 'icon-yuan_checked' : ''
]"
:style="item.val == v.value ? item._checkedColorStyle : ''"
></text>
{{ v.value }}
</view>
</label>
</radio-group>
</view>
</view>
</view>
<!-- 图片上传 -->
<view v-if="item.controller == 'Img'" class="order-wrap" :class="rowWrapClass" :style="item._fieldWrapStyle">
<view :class="['order-cell', 'flex-box', { 'vertical-layout': mode === 'vertical' }]" :style="rowCellStyle">
<view class="name" :style="item._labelStyle">
<text class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
<text class="tit" :style="item._titStyle">{{ item.value.title }}</text><text v-if="requiredPosition === 'after'" class="required" :style="item._starStyle">{{ 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)">
<image :src="$util.img(v)" mode="aspectFill" show-menu-by-longpress></image>
<text class="iconfont icon-guanbi" @tap.stop="delImg(k, index)"></text>
</view>
<view class="img-box" @tap="addImg(index)">
<text class="iconfont icon-add1"></text>
</view>
</view>
</view>
</view>
<!-- 日期选择器 -->
<view v-if="item.controller == 'Date'" class="order-wrap" :class="rowWrapClass" :style="item._fieldWrapStyle">
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]" :style="rowCellStyle">
<view class="name" :style="item._labelStyle">
<text class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
<text class="tit" :style="item._titStyle">{{ item.value.title }}</text><text v-if="requiredPosition === 'after'" class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'box-flex', 'align-right']" :style="item._controlBoxStyle">
<picker
mode="date"
:value="item.val"
@change="bindDateChange($event, index)"
>
<view :class="['uni-input', !item.val ? 'color-tip' : '']" :style="item._uniInputStyle + (item.val ? item._valueTextStyle : item._tipTextStyle)">
{{ item.val ? item.val : (item.value.placeholder || $lang('components.ns-newform.datePlaceholder')) }}
</view>
</picker>
<text class="iconfont icon-right"></text>
</view>
</view>
</view>
<!-- 日期范围选择器 -->
<view v-if="item.controller == 'Datelimit'" class="order-wrap" :class="rowWrapClass" :style="item._fieldWrapStyle">
<view :class="['order-cell', 'flex-box', { 'vertical-layout': mode === 'vertical' }]" :style="rowCellStyle">
<view class="name" :style="item._labelStyle">
<text class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
<text class="tit" :style="item._titStyle">{{ item.value.title }}</text><text v-if="requiredPosition === 'after'" class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'date-boxs', 'align-center']" :style="item._boxAlignStyle">
<view class="date-box">
<picker
mode="date"
:value="item.start_date"
@change="bindStartDateChange($event, index)"
>
<view class="picker-box">
<view :class="['uni-input', !item.start_date ? 'color-tip' : '']" :style="item._uniInputStyle + (item.start_date ? item._valueTextStyle : item._tipTextStyle)">
{{ item.start_date ? item.start_date : (item.value.placeholder_start || $lang('components.ns-newform.startDate')) }}
</view>
</view>
</picker>
</view>
<view class="interval iconfont icon-jian"></view>
<view class="date-box">
<picker
mode="date"
:value="item.end_date"
@change="bindEndDateChange($event, index)"
>
<view class="picker-box">
<view :class="['uni-input', !item.end_date ? 'color-tip' : '']" :style="item._uniInputStyle + (item.end_date ? item._valueTextStyle : item._tipTextStyle)">
{{ item.end_date ? item.end_date : (item.value.placeholder_end || $lang('components.ns-newform.endDate')) }}
</view>
</view>
</picker>
</view>
</view>
</view>
</view>
<!-- 时间选择器 -->
<view v-if="item.controller == 'Time'" class="order-wrap" :class="rowWrapClass" :style="item._fieldWrapStyle">
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]" :style="rowCellStyle">
<view class="name" :style="item._labelStyle">
<text class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
<text class="tit" :style="item._titStyle">{{ item.value.title }}</text><text v-if="requiredPosition === 'after'" class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'box-flex', 'align-right']" :style="item._controlBoxStyle">
<picker
mode="time"
:value="item.val"
@change="bindTimeChange($event, index)"
>
<view :class="['uni-input', !item.val ? 'color-tip' : '']" :style="item._uniInputStyle + (item.val ? item._valueTextStyle : item._tipTextStyle)">
{{ item.val ? item.val : (item.value.placeholder || $lang('components.ns-newform.timePlaceholder')) }}
</view>
</picker>
<text class="iconfont icon-right"></text>
</view>
</view>
</view>
<!-- 时间范围选择器 -->
<view v-if="item.controller == 'Timelimit'" class="order-wrap" :class="rowWrapClass" :style="item._fieldWrapStyle">
<view :class="['order-cell', 'flex-box', { 'vertical-layout': mode === 'vertical' }]" :style="rowCellStyle">
<view class="name" :style="item._labelStyle">
<text class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
<text class="tit" :style="item._titStyle">{{ item.value.title }}</text><text v-if="requiredPosition === 'after'" class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'date-boxs', 'align-center']" :style="item._boxAlignStyle">
<view class="date-box">
<picker
mode="time"
:value="item.start_time"
@change="bindStartTimeChange($event, index)"
>
<view class="picker-box">
<view :class="['uni-input', !item.start_time ? 'color-tip' : '']" :style="item._uniInputStyle + (item.start_time ? item._valueTextStyle : item._tipTextStyle)">
{{ item.start_time ? item.start_time : (item.value.placeholder_start || $lang('components.ns-newform.startTime')) }}
</view>
</view>
</picker>
</view>
<view class="interval iconfont icon-jian"></view>
<view class="date-box">
<picker
mode="time"
:value="item.end_time"
@change="bindEndTimeChange($event, index)"
>
<view class="picker-box">
<view :class="['uni-input', !item.end_time ? 'color-tip' : '']" :style="item._uniInputStyle + (item.end_time ? item._valueTextStyle : item._tipTextStyle)">
{{ item.end_time ? item.end_time : (item.value.placeholder_end || $lang('components.ns-newform.endTime')) }}
</view>
</view>
</picker>
</view>
</view>
</view>
</view>
<!-- 城市选择器 -->
<view v-if="item.controller == 'City'" class="order-wrap" :class="rowWrapClass" :style="item._fieldWrapStyle">
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]" :style="rowCellStyle">
<view class="name" :style="item._labelStyle">
<text class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
<text class="tit" :style="item._titStyle">{{ item.value.title }}</text><text v-if="requiredPosition === 'after'" class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'box-flex', 'align-right']" :style="item._controlBoxStyle">
<pick-regions
:defaultRegions="item.default_regions"
:selectArr="item.select_arr"
@getRegions="handleGetRegions($event, index)"
>
<view :class="['select-address', !item.val ? 'empty' : '', !item.val ? 'color-tip' : '']" :style="item._uniInputStyle + (item.val ? item._valueTextStyle : item._tipTextStyle)">
{{ item.val ? item.val : (item.value.placeholder || (item.value.default_type == 1 ? $lang('components.ns-newform.selectProvinceCity') : $lang('components.ns-newform.selectProvinceCityDistrict'))) }}
</view>
</pick-regions>
<text class="iconfont icon-right"></text>
</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="'font-size:' + ((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>
<script>
import formPlatformMixin from '@/common/js/form-platform-mixin.js';
export default {
name: 'ns-newform',
mixins: [formPlatformMixin],
props: {
data: {
type: Array,
default: () => []
},
customAttr: {
type: Object,
default: () => ({})
},
mode: {
type: String,
default: 'vertical',
validator: (value) => !value || ['horizontal', 'vertical'].includes(value)
},
// 表单配置(按平台差异化)
// 格式:{
// form_padding_x: 14, // 全局默认值
// mp: { form_padding_x: 10 }, // 微信小程序平台覆盖
// h5: { row_padding_y: 8 }, // H5 平台覆盖
// harmony: { ... } // 鸿蒙元服务覆盖
// }
formConfig: {
type: Object,
default: () => ({})
}
},
data() {
return {
formData: [],
maxLabelWidth: 0
}
},
created() {
// 避免直接修改 prop,初始化本地数据
this.initFormData();
},
mounted() {
this.$nextTick(() => {
this.calculateMaxLabelWidth();
});
},
watch: {
data: {
handler() {
this.initFormData();
this.$nextTick(() => {
this.calculateMaxLabelWidth();
});
},
deep: true
},
// 配置变化(如字号 / 模式切换)时,重新计算 maxLabelWidth
formConfig: {
handler() {
this.$nextTick(() => {
this.calculateMaxLabelWidth();
});
},
deep: true
},
mode() {
this.$nextTick(() => {
this.calculateMaxLabelWidth();
});
}
},
computed: {
// 合并配置:内置默认 < 全局 < 当前平台
config() {
// 内置默认值(最低优先级)- 与后端 D:\projects\shop-projects\backend\src\app\component\view\form\js\design.js getDefaultFormData().formConfig 对齐
const defaults = {
formPaddingX: 28,
bannerPaddingX: 0,
tipPaddingX: 28,
rowPaddingY: 24,
rowMinHeight: 60,
labelGap: 20,
formBgColor: '#ffffff',
rowDividerColor: '#f0f0f0',
showRowDivider: false,
inputHeight: 84,
inputRadius: 16,
labelWidthMode: 'auto',
labelFixedWidth: 180,
labelFontSize: 32,
labelColor: '#1f2937',
labelFontWeight: 700,
labelAutoMaxWidth: 200, // auto 模式下 label 宽度上限(rpx),超过后 ellipsis 截断
requiredColor: '#ef4444',
checkedColor: '#3b82f6',
placeholderColor: '#9ca3af',
placeholderFontSize: 28,
// textarea 显示行数 / 行高(与 ns-form 保持一致,由 formConfig 覆盖)
// 改用 :rows + line-height 驱动高度,rows 给原生 <textarea>,line-height 在 textareaStyle 算 px 高度
textareaRows: 4,
textareaLineHeight: 48,
// 输入框视觉增强(可由后端 form_config 覆盖)
inputBorderColor: '#e5e7eb',
inputBgColor: '#ffffff',
inputFocusBorderColor: '#3b82f6',
inputFocusBgColor: '#ffffff',
labelFontWeight: 700,
// 提交按钮
submitColor: '#ffffff',
submitBgColor: '#3b82f6',
submitMarginTop: 0, // 提交按钮顶部外边距(rpx):调整表单与按钮之间的距离
// 提示语
tipFontSize: 32,
tipColor: '#000000',
tipBgColor: 'transparent'
};
// 全局配置(中间优先级)
const globalCfg = this.formConfig || {};
// 当前平台配置(最高优先级):从 formConfig.terminal 多个别名中按顺序合并
const formConfigRoot = this.formConfig || {};
// 兼容两种结构:
// 1) 后端格式:formConfig.terminal = { h5: {...}, weapp: {...}, harmony: {...} }
// 2) 旧式扁平:formConfig[platformKey] = { ... }
const terminalBlock = formConfigRoot.terminal || {};
const platformKeys = this.platformConfigKeys || [];
const platformCfg = Object.assign(
{},
...platformKeys.map(k => (terminalBlock[k] || formConfigRoot[k] || {}))
);
// H5 终端配置作为"后台样式基线":商家端编辑器以 H5 预览为准,很多样式只落在
// formConfig.terminal.h5(或全局 base)里。若当前平台(如 weapp)没有该键,
// 小程序端会一直吃内置默认 → 表现为"后台改了行内边距,H5 变、小程序不变"。
// 这里在内置默认之上、全局配置之下插入 h5 基线,保证后台改的样式小程序端同样跟随。
let baselineCfg = {};
if (this.platform !== 'h5') {
const h5Block = terminalBlock['h5'] || formConfigRoot['h5'];
if (h5Block && typeof h5Block === 'object') baselineCfg = h5Block;
}
// 合并:defaults <- h5基线 <- 全局 <- 当前平台
const merged = Object.assign({}, defaults, baselineCfg, globalCfg, platformCfg);
// 过滤 undefined / null / ''(保留 0 / false):Object.assign 会用 undefined 覆盖默认值,
// 导致后续 $util.rpx(undefined) = '' → 内联样式整条失效、静默回退到 SCSS 默认值
Object.keys(merged).forEach((k) => {
const v = merged[k];
if (v === undefined || v === null || v === '') delete merged[k];
});
// 行内上下边距键名兼容:后台 / 不同版本序列化路径可能下发
// row_padding_y / rowPaddingY / row_padding / rowPadding 等,取第一个有效值
const rowPadY = this.pickConfigNum(
['rowPaddingY', 'row_padding_y', 'rowPadding', 'row_padding', 'rowPaddingVertical', 'row_padding_vertical', 'rowPaddingTop', 'row_padding_top'],
terminalBlock, platformKeys, formConfigRoot, baselineCfg
);
if (rowPadY !== null) merged.rowPaddingY = rowPadY;
return merged;
},
nameWidthStyle() {
if (this.mode === 'vertical') {
return {};
}
let width;
if (this.config.labelWidthMode === 'fixed') {
width = this.$util.rpx(this.config.labelFixedWidth);
} else {
width = this.maxLabelWidth ? this.$util.rpx(this.maxLabelWidth) : 'auto';
}
return {
width: width
};
},
// 容器样式(与后端 form-preview-content 对齐:通过 CSS 变量驱动子元素样式)
formWrapStyle() {
const cfg = this.config;
const num = (v, fb) => (typeof v === 'number' ? v : parseInt(v) || fb);
return this.$util.objToStyle({
// 仅保留左右内边距 = form-padding-x;上下由每行 row-padding-y 承担
padding: `0 ${this.$util.rpx(cfg.formPaddingX)}`,
background: cfg.formBgColor,
// 通过 CSS 变量传递:与后端 design.css 的 :root 变量一一对应
'--form-padding-x': this.$util.rpx(cfg.formPaddingX),
'--banner-padding-x': this.$util.rpx(cfg.bannerPaddingX),
'--tip-padding-x': this.$util.rpx(cfg.tipPaddingX),
'--tip-font-size': this.$util.rpx(cfg.tipFontSize),
'--tip-color': cfg.tipColor,
'--tip-bg-color': cfg.tipBgColor,
'--row-padding-y': this.$util.rpx(cfg.rowPaddingY),
// 行最小高度:默认 0(不绑定),行高由 row-padding-y + input-height 撑开,后台改样式即时生效;
// 仅后台显式配置 row_min_height 时按其值生效,与 rowCellStyle 内联输出保持一致(两端同步)
'--row-min-height': (this.explicitConfigNum('rowMinHeight', 'row_min_height') || 0) > 0
? this.$util.rpx(cfg.rowMinHeight)
: '0rpx',
'--label-gap': this.$util.rpx(cfg.labelGap),
'--form-bg-color': cfg.formBgColor,
'--row-divider-color': cfg.rowDividerColor,
'--input-height': this.$util.rpx(cfg.inputHeight),
'--input-radius': this.$util.rpx(cfg.inputRadius),
'--label-fixed-width': this.$util.rpx(cfg.labelFixedWidth),
'--label-font-size': this.$util.rpx(num(cfg.labelFontSize, 28)),
'--label-color': cfg.labelColor,
'--required-color': cfg.requiredColor,
'--checked-color': cfg.checkedColor,
'--placeholder-color': cfg.placeholderColor,
'--placeholder-font-size': this.$util.rpx(num(cfg.placeholderFontSize, 28)),
// textarea 行数 / 行高(CSS 兜底 + JS 算高度都用得到)
'--textarea-rows': num(cfg.textareaRows, 4),
'--textarea-line-height': this.$util.rpx(num(cfg.textareaLineHeight, 44)),
// 输入框视觉增强(边框 / 背景 / 聚焦 / label 字重)
'--input-border-color': cfg.inputBorderColor,
'--input-bg-color': cfg.inputBgColor,
'--input-focus-border-color': cfg.inputFocusBorderColor,
'--input-focus-bg-color': cfg.inputFocusBgColor,
'--label-font-weight': cfg.labelFontWeight,
'--submit-color': cfg.submitColor,
'--submit-bg-color': cfg.submitBgColor,
'--submit-margin-top': this.$util.rpx(cfg.submitMarginTop || 0),
// 分割线显隐
'--show-row-divider': cfg.showRowDivider === false ? '0' : '1'
});
},
// 行容器 class:用于控制分割线显隐
rowWrapClass() {
return this.config.showRowDivider === false ? 'order-wrap no-divider' : 'order-wrap';
},
// 行最小高度:不再默认绑定。行高改由后台配置的行内边距(row_padding_y)+ 控件高度(input_height)
// 自然撑开,后台改样式立即生效;仅当后台**显式**配置了 row_min_height 时才按后台值输出。
rowCellStyle() {
const h = this.explicitConfigNum('rowMinHeight', 'row_min_height');
if (h === null || h <= 0) return '';
return this.$util.objToStyle({
'min-height': this.$util.rpx(h)
});
},
// 标签与输入框间距
nameStyle() {
if (this.mode === 'vertical') {
return {};
}
return {
'margin-right': this.$util.rpx(this.config.labelGap)
};
},
// 输入框样式(全局,未指定 item 时使用):不绑定最小高度,高度由后台 input_height 决定
inputStyle() {
return this.$util.objToStyle({
'border-radius': this.$util.rpx(this.config.inputRadius)
});
},
// textarea 显示行数(来自 formConfig.textareaRows,未配置时回退到默认 4)
textareaRowsValue() {
const n = parseInt(this.config.textareaRows, 10);
return Number.isFinite(n) && n > 0 ? n : 4;
},
// textarea 行高(来自 formConfig.textareaLineHeight,用于 :rows × line-height 算出可视高度)
textareaLineHeightValue() {
const n = parseInt(this.config.textareaLineHeight, 10);
return Number.isFinite(n) && n > 0 ? n : 44;
},
// textarea 基础 inline style 对象(供 textareaStyle 与字段级尺寸合并复用)
textareaBaseStyle() {
const lh = this.$util.rpx(this.textareaLineHeightValue);
const rows = this.textareaRowsValue;
const paddings = this.$util.rpx(24) + this.$util.rpx(2); // padding 12rpx 上下 + border 1rpx 上下
const h = (this.$util.rpx(this.textareaLineHeightValue * (rows - 1)) + lh) + paddings;
return {
'height': h,
'min-height': h,
'line-height': lh,
'border-radius': this.$util.rpx(this.config.inputRadius)
};
},
// textarea 专用 inline style(所有平台统一使用,见 template 中 textarea 节点)
// 高度 = rows × lineHeight + padding + border,由 formConfig 动态控制,与 ns-form 同公式
textareaStyle() {
return this.$util.objToStyle(this.textareaBaseStyle);
},
labelStyle() {
const style = {
fontSize: this.$util.rpx(this.config.labelFontSize),
color: this.config.labelColor
};
return this.$util.objToStyle(style);
},
// 必填星号位置(before=标签前(默认)/ after=标签后;与后端 form_config.required_position 对齐)
// 星号的颜色/字号/间距动态值由 recomputeItemStyles 预计算到 item._starStyle(inline,跨端安全)
requiredPosition() {
return this.config.requiredPosition === 'after' ? 'after' : 'before';
},
checkedColor() {
return this.config.checkedColor;
},
rowDividerStyle() {
const style = {};
if (this.config.showRowDivider === false) {
style.display = 'none';
} else {
style.background = this.config.rowDividerColor;
}
return this.$util.objToStyle(style);
}
},
methods: {
// 按别名列表取配置数值:优先级 = 当前平台块 > 全局 > h5基线
// 返回数字;都不存在返回 null(调用方决定是否回退内置默认值)
pickConfigNum(aliasList, terminalBlock, platformKeys, formConfigRoot, baselineCfg) {
const blocks = [];
(platformKeys || []).forEach((k) => {
if (terminalBlock && terminalBlock[k]) blocks.push(terminalBlock[k]);
if (formConfigRoot && formConfigRoot[k]) blocks.push(formConfigRoot[k]);
});
blocks.push(formConfigRoot || {});
blocks.push(baselineCfg || {});
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
if (!block || typeof block !== 'object') continue;
for (let j = 0; j < aliasList.length; j++) {
const v = block[aliasList[j]];
if (v !== undefined && v !== null && v !== '') {
const n = parseInt(v, 10);
if (!isNaN(n)) return n;
}
}
}
return null;
},
// 读取后台"显式配置"的数值(camel / snake 双键兼容,含平台覆盖块 terminal[platform])。
// 与 computed config 的区别:config 会混入组件内置默认值,无法区分"后台配了"和"没配";
// 这里只在后台确实下发该字段时才返回数字,未配置返回 null(用于"未配置就不绑定样式"的场景,
// 例如行最小高度:未配置时不输出 min-height,行高完全由后台的行内边距 + 控件高度决定)。
explicitConfigNum(camelKey, snakeKey) {
const root = this.formConfig || {};
const terminal = root.terminal || {};
const keys = this.platformConfigKeys || [];
let raw;
// 1) 平台覆盖优先(terminal.h5 / terminal.weapp ... 或扁平 formConfig.mp)
for (let i = 0; i < keys.length; i++) {
const block = terminal[keys[i]] || root[keys[i]];
if (block && typeof block === 'object') {
const v = block[camelKey] !== undefined ? block[camelKey] : block[snakeKey];
if (v !== undefined && v !== null && v !== '') {
raw = v;
break;
}
}
}
// 2) 全局配置
if (raw === undefined || raw === null || raw === '') {
raw = root[camelKey] !== undefined ? root[camelKey] : root[snakeKey];
}
const n = parseInt(raw, 10);
return isNaN(n) ? null : n;
},
// 预计算每项的样式(写到 item._labelStyle / item._boxAlignStyle),
// 避免在模板里以方法形式调用:微信小程序 WXML 不支持 :style="getXxx(item)" 语法。
// 触发时机:processFormData 末尾 / calculateMaxLabelWidth 末尾 / config 变化 / mode 变化。
recomputeItemStyles() {
if (!this.formData || this.formData.length === 0) return;
const widthStyle = this.nameWidthStyle || {};
const nameMarginStyle = this.nameStyle || {};
// 字段级间距(每个组件独立,互不影响):数值解析(''/null/undefined/非数字 → null = 未设置,跟随全局)
const spNum = (x) => {
if (x === undefined || x === null || x === '') return null;
const n = parseFloat(x);
return isNaN(n) ? null : n;
};
// 行容器基础上下内边距(原 rowWrapStyle 语义:全局行间距 rowPaddingY;左右恒 0)
// 兜底 24rpx:config.rowPaddingY 缺失时 $util.rpx 会返回 '',导致整条 padding 简写失效
const rowPadYNum = parseInt(this.config.rowPaddingY, 10);
const rowPadY = this.$util.rpx(isNaN(rowPadYNum) ? 24 : rowPadYNum);
// #ifdef MP-WEIXIN
// 诊断用(确认与后台同步后可删除):在微信开发者工具控制台查看后台"行内上下边距"是否送达小程序端
console.log('[ns-newform] rowPaddingY =', this.config.rowPaddingY, '→ padding =', rowPadY);
// #endif
const clampRpx = (n, min, max) => this.$util.rpx(Math.max(min, Math.min(max, n)));
// 字段级控件尺寸(宽度 / 高度 / 选项间距):'' / null / undefined = 未设置,沿用全局(宽度默认占满 100%)
// clamp 与后端 fieldSizeStyleMixin 一致(width 0..750 / height 0..400 / gap 0..200 rpx)
const sizeRpx = (x, min, max) => {
if (x === undefined || x === null || x === '') return null;
const n = parseFloat(x);
return isNaN(n) ? null : this.$util.rpx(Math.max(min, Math.min(max, n)));
};
// ---- 全局动态样式值(后台 formConfig 下发):直出 inline,跨端同步 ----
// 背景:小程序 WXSS 对 .form-wrap inline style 里的自定义属性(--*)支持不可靠,
// 子元素 var(--x, fallback) 会静默回退到 SCSS 默认值,导致「后台改样式 H5 生效 / 小程序不变」。
// 对策:与 _starStyle 同思路,把动态值直接算好写到每个消费元素的内联 style 上
//(小程序原生元素 inline style 可靠);H5 端 CSS 变量通道保留,两端结果一致。
const cfg = this.config;
const numOr = (v, fb) => {
const n = parseFloat(v);
return isNaN(n) ? fb : n;
};
const gInputHNum = numOr(cfg.inputHeight, 84);
const gInputH = this.$util.rpx(gInputHNum);
const gInputR = this.$util.rpx(numOr(cfg.inputRadius, 16));
const gInputBorder = '1rpx solid ' + (cfg.inputBorderColor || '#e5e7eb');
const gInputBg = cfg.inputBgColor || '#ffffff';
const gPhColor = cfg.placeholderColor || '#9ca3af';
const gPhFs = this.$util.rpx(numOr(cfg.placeholderFontSize, 28));
const gFormBg = cfg.formBgColor || '#ffffff';
const gDividerColor = cfg.rowDividerColor || '#f0f0f0';
const showDivider = cfg.showRowDivider !== false;
const labelGapRpx = this.$util.rpx(numOr(cfg.labelGap, 20));
const isVertical = this.mode === 'vertical';
// input/textarea 是 H5 下编译为 uni-input/uni-textarea 的自定义组件,
// 内联 style 只会落在组件根节点(会与内部真实输入框样式叠加),因此元素级内联仅小程序端输出
let mpElementInline = false;
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ || MP-KUAISHOU || MP-LARKDINGTALK || MP-JD || MP-XHS || MP-HARMONY
mpElementInline = true;
// #endif
this.formData.forEach((item) => {
const itemValue = (item && item.value) || {};
// ---- 字段级控件尺寸 ----
const cW = sizeRpx(itemValue.field_width, 0, 750);
const cH = sizeRpx(itemValue.field_height, 0, 400);
const oG = sizeRpx(itemValue.option_gap, 0, 200);
// 宽度:.box 基础规则是 flex:1,需 flex:none 解除伸缩,否则 inline width 不生效
const ctrlWidthStyle = cW !== null ? { width: cW, 'flex': 'none', 'max-width': '100%' } : {};
// 高度:单行控件(input / picker .uni-input)的 height 与 line-height 均由 --input-height 驱动,
// 在 .box 上覆盖该 CSS 变量即可同时命中 H5(::v-deep input)与小程序的原生元素规则
const ctrlHeightStyle = cH !== null ? { '--input-height': cH } : {};
const ctrlBoxStyle = Object.assign({}, ctrlWidthStyle, ctrlHeightStyle);
// ---- 字段级间距(每个组件独立):外边距直出(允许负值上移贴近相邻组件)----
// clamp 与后端 fieldSpacingStyle 一致(margin -100..400 / padding 0..400 rpx)
const mTop = spNum(itemValue.margin_top), mBottom = spNum(itemValue.margin_bottom),
mLeft = spNum(itemValue.margin_left), mRight = spNum(itemValue.margin_right);
const fieldMargin = {};
if (mTop !== null) fieldMargin['margin-top'] = clampRpx(mTop, -100, 400);
if (mBottom !== null) fieldMargin['margin-bottom'] = clampRpx(mBottom, -100, 400);
if (mLeft !== null) fieldMargin['margin-left'] = clampRpx(mLeft, -100, 400);
if (mRight !== null) fieldMargin['margin-right'] = clampRpx(mRight, -100, 400);
const pTop = spNum(itemValue.padding_top), pBottom = spNum(itemValue.padding_bottom),
pLeft = spNum(itemValue.padding_left), pRight = spNum(itemValue.padding_right);
// 字段级"上下内边距"兼容:部分后台序列化路径下发 padding_y / paddingY(同时作用于上下),
// 无 padding_top / padding_bottom 单边值时用它覆盖全局 rowPaddingY
const pY = spNum(itemValue.padding_y !== undefined ? itemValue.padding_y : itemValue.paddingY);
// 单边取值的 helper:字段级优先 → 全局(行内上下边距 rowPaddingY / 左右恒 0)→ 兜底 0rpx。
// 兜底必须给具体值:空字符串会让整条 padding 简写失效,小程序端就"改了没反应"
const padOf = (explicit, globalVal, min, max) => {
if (explicit !== null) return this.$util.rpx(Math.max(min, Math.min(max, explicit)));
return (globalVal === undefined || globalVal === null || globalVal === '') ? '0rpx' : globalVal;
};
const padT = padOf(pTop !== null ? pTop : pY, rowPadY, 0, 400);
const padB = padOf(pBottom !== null ? pBottom : pY, rowPadY, 0, 400);
const padL = padOf(pLeft, '0rpx', 0, 400);
const padR = padOf(pRight, '0rpx', 0, 400);
// 11 种输入控件行容器(.order-wrap,原 rowWrapStyle 承载层):
// margin 直出;padding 用**简写**一次性直出四边(小程序端对 padding 长写支持不稳,
// 简写可保证后台配置的上下行内边距一定生效)
if (item.controller != 'InfoCard') {
const fieldWrapStyle = Object.assign({}, fieldMargin);
fieldWrapStyle['padding'] = `${padT} ${padR} ${padB} ${padL}`;
// 表单底色 / 行分割线颜色直出 inline(替代 var(--form-bg-color) / var(--row-divider-color))
fieldWrapStyle['background'] = gFormBg;
if (showDivider) {
fieldWrapStyle['border-bottom-color'] = gDividerColor;
}
// ⚠️ 必须输出字符串:小程序端 :style 绑定对象会渲染成 "[object Object]"(样式整体失效),
// 这正是"后台改样式 H5 生效、小程序不生效"的根因;objToStyle 转成 kebab-case 字符串后两端通用
this.$set(item, '_fieldWrapStyle', this.$util.objToStyle(fieldWrapStyle));
}
const style = Object.assign({}, widthStyle);
// item 级别 label 字号 / 颜色覆盖(与原 getItemLabelStyle 逻辑一致)
if (itemValue.label_font_size !== undefined && itemValue.label_font_size !== null && itemValue.label_font_size !== '') {
style['--item-label-font-size'] = this.$util.rpx(itemValue.label_font_size);
}
if (itemValue.label_color) {
style['--item-label-color'] = itemValue.label_color;
}
// 合并全局的 nameStyle(margin-right)
Object.assign(style, nameMarginStyle);
// 不绑定 label 最小高度:行高由后台配置的行内边距 + 控件高度决定(后台可自定义)。
// 垂直布局下的标签/控件间距同样由后台 label_gap 驱动,直出 inline
if (isVertical) {
style['margin-bottom'] = labelGapRpx;
}
// $set 包裹以确保新属性是响应式的(Vue 2 对动态加的属性需要 $set);输出字符串(同上)
this.$set(item, '_labelStyle', this.$util.objToStyle(style));
// label 标题字号/颜色/字重:item 级覆盖 + 全局,直出 inline(小程序端 var 链不可靠)
const itemLabelFs = this.$util.rpx(numOr(itemValue.label_font_size, numOr(cfg.labelFontSize, 28)));
const itemLabelColor = itemValue.label_color || cfg.labelColor || '#1f2937';
this.$set(item, '_titStyle', this.$util.objToStyle({
'font-size': itemLabelFs,
'color': itemLabelColor,
'font-weight': cfg.labelFontWeight || 600
}));
// 输入框元素级内联(仅小程序端输出,H5 保持 CSS 变量通道,避免 uni-input 根节点样式叠加)
if (mpElementInline) {
const hNum = cH !== null ? parseFloat(cH) : gInputHNum;
this.$set(item, '_inputStyle', this.$util.objToStyle({
'font-size': itemLabelFs,
'color': itemLabelColor,
'height': this.$util.rpx(hNum),
'line-height': this.$util.rpx(Math.max(hNum - 2, 0)),
'border': gInputBorder,
'border-radius': gInputR,
'background': gInputBg
}));
} else {
this.$set(item, '_inputStyle', '');
}
// placeholder 颜色/字号:placeholder-style 直出(小程序 placeholder-class 被 scoped 编译后
// 无法命中原生 placeholder 节点,placeholder-style 是官方跨端属性)
this.$set(item, '_placeholderStyle', 'color: ' + gPhColor + '; font-size: ' + gPhFs + ';');
// picker 文本区(Select/Date/Time/City 等的 .uni-input / .select-address)与选项文字
// 不绑定 min-height:高度由 line-height(后台 inputHeight)+ 行内边距(后台 rowPaddingY)决定
this.$set(item, '_uniInputStyle', this.$util.objToStyle({
'font-size': itemLabelFs,
'line-height': gInputH,
'border-radius': gInputR
}));
this.$set(item, '_valueTextStyle', this.$util.objToStyle({ 'font-size': itemLabelFs, 'color': itemLabelColor }));
this.$set(item, '_tipTextStyle', this.$util.objToStyle({ 'font-size': itemLabelFs, 'color': gPhColor }));
this.$set(item, '_optionLabelStyle', this.$util.objToStyle({ 'font-size': itemLabelFs, 'color': itemLabelColor }));
// 选项选中态图标颜色(字符串,供模板三元拼接)
this.$set(item, '_checkedColorStyle', 'color:' + (cfg.checkedColor || '#3b82f6') + ';');
// item 级别 box 对齐方式(与原 getItemBoxAlignStyle 逻辑一致)
// 并入字段级宽度 / 高度(.box 是 flex:1,width 需配合 flex:none 才生效,见 ctrlWidthStyle)
const align = itemValue.input_align;
this.$set(item, '_boxAlignStyle', this.$util.objToStyle(Object.assign(align ? { 'text-align': align } : {}, ctrlBoxStyle)));
// 必填星号样式:颜色/字号/间距全部预计算 inline(微信小程序/鸿蒙元服务对
// 嵌套 var() fallback 与 .required-after 类 margin 翻转支持不全,动态值不走 CSS 变量链)。
// 字号优先 item 级 label_font_size,回退全局 labelFontSize(默认 28);
// after 位置 margin-left,before 位置 margin-right(跨端用 $util.rpx,H5 转 px)
const starFontSize = this.$util.rpx(itemValue.label_font_size || this.config.labelFontSize || 28);
const starStyle = {
color: this.config.requiredColor || '#ef4444',
'font-size': starFontSize,
'font-weight': 'bold'
};
if (this.requiredPosition === 'after') {
starStyle['margin-left'] = this.$util.rpx(6);
} else {
starStyle['margin-right'] = this.$util.rpx(6);
}
this.$set(item, '_starStyle', this.$util.objToStyle(starStyle));
// 选项排列样式:默认 vertical(每项一行),仅当后端显式配置 wrap 时横向换行
// 走 inline style(不依赖 dev 编译产物的 SCSS 类,跨端兼容更稳)
// 直接写 flex-direction 等 CSS 属性,inline style 优先级最高
const optLayout = itemValue.option_layout;
const optStyle = {};
if (optLayout === 'wrap') {
// wrap 模式:横向自动换行
optStyle['flex-direction'] = 'row';
optStyle['align-items'] = 'center';
optStyle['gap'] = '12rpx';
} else {
// vertical 模式(默认):每项独立一行
optStyle['flex-direction'] = 'column';
optStyle['align-items'] = 'flex-start';
optStyle['gap'] = '20rpx';
}
// 字段级选项间距(option_gap):覆盖默认 gap(wrap 12rpx / vertical 20rpx)
if (oG !== null) optStyle['gap'] = oG;
this.$set(item, '_optionLayoutStyle', this.$util.objToStyle(optStyle));
// 单行 / 多选行控件(Select、Date、Time、City 的 .box 无 _boxAlignStyle 绑定;
// Checkbox / Radio 只需要宽度,高度无意义)单独落 key,供模板直接绑定
this.$set(item, '_controlBoxStyle', this.$util.objToStyle(ctrlBoxStyle));
this.$set(item, '_controlWidthStyle', this.$util.objToStyle(ctrlWidthStyle));
// textarea:字段级高度直出(宽度走 .box 的 _boxAlignStyle;行高沿用 formConfig),
// 同步 min-height 防止被基础规则的 min-height 顶住不缩;
// 小程序端附加字号/颜色/边框/底色 inline(H5 仍走 CSS 变量,避免 uni-textarea 根节点叠加)
this.$set(item, '_textareaStyle', this.$util.objToStyle(Object.assign({},
this.textareaBaseStyle,
mpElementInline ? {
'font-size': itemLabelFs,
'color': itemLabelColor,
'border': gInputBorder,
'background': gInputBg
} : {},
cH !== null ? { 'height': cH, 'min-height': cH } : {})));
// 信息卡片:容器底色 + 空白判定 + 分割线样式(与后台 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';
}
// 字段级间距(InfoCard 行容器即卡片本身,无全局行内边距):margin 直出;
// padding 覆盖背景卡默认内边距的对应边(无 bg 时直接生效)
Object.keys(fieldMargin).forEach((k) => { wrapStyle[k] = fieldMargin[k]; });
if (pTop !== null || pBottom !== null || pLeft !== null || pRight !== null) {
wrapStyle['padding'] = `${padOf(pTop, '0rpx', 0, 400)} ${padOf(pRight, '0rpx', 0, 400)} ${padOf(pBottom, '0rpx', 0, 400)} ${padOf(pLeft, '0rpx', 0, 400)}`;
}
this.$set(item, '_infoCardWrapStyle', this.$util.objToStyle(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', this.$util.objToStyle(dStyle));
} else {
this.$set(item, '_infoCardDividerStyle', '');
}
}
});
},
// 容器样式(提供给外部 diy-form 使用)
getFormWrapStyle() {
return this.formWrapStyle;
},
calculateMaxLabelWidth() {
if (this.mode === 'vertical' || !this.formData || this.formData.length === 0) {
this.maxLabelWidth = 0;
// 垂直布局(默认)也要重算每项内联样式:formConfig 变化(后台改样式)后
// 小程序端依赖 recomputeItemStyles 把动态值直出 inline,提前 return 会漏刷
this.recomputeItemStyles();
return;
}
// 与后端 design.js calculateMaxLabelWidth 算法对齐:
// 取所有 title 中最长字符数(必填 +1),按 fontSize(rpx)近似换算宽度
// 后端在 H5 中按 1rpx = 0.5px 计算宽度,再加 12 边距
const fontSize = parseInt(this.config.labelFontSize) || 28;
let maxChars = 0;
this.formData.forEach((item) => {
if (!item.value || !item.value.title) return;
let len = (item.value.title || '').length;
if (item.value.required) len += 1;
if (len > maxChars) maxChars = len;
});
// 与后端公式一致:chars * (fontSize/2) * 1.1 + 12,单位 px → 转 rpx(×2)
let widthPx = Math.max(40, maxChars * (fontSize / 2) * 1.1 + 12);
let widthRpx = Math.round(widthPx * 2);
// auto 模式下加宽度上限:超长 title 撑爆布局时,按 labelAutoMaxWidth 截断(rpx)
// 上限内 ellipsis 截断,保证控件区有足够空间;上限外也不会让 label 占满整行
const maxWidthRpx = parseInt(this.config.labelAutoMaxWidth) || 200;
if (widthRpx > maxWidthRpx) widthRpx = maxWidthRpx;
this.maxLabelWidth = widthRpx;
// 宽度变了,需要把全局 nameWidthStyle 重新合到每项 _labelStyle 里
this.recomputeItemStyles();
},
initFormData() {
// 深拷贝 prop 数据到本地状态
this.formData = JSON.parse(JSON.stringify(this.data || []));
this.processFormData();
},
setFormData() {
// 重置为初始值
this.initFormData();
},
processFormData() {
this.formData.forEach((item) => {
// Text / Textarea:当 placeholder 为空字符串或没有值时,不设置默认值
const skipDefault = (item.controller == 'Text' || item.controller == 'Textarea') && !item.value.placeholder;
if (!item.val && !skipDefault) {
item.val = item.value.default ? item.value.default : '';
}
if (item.value.options) {
item.option_lists = [];
item.value.options.forEach((option, index) => {
let optionItem = {};
optionItem.value = option;
optionItem.checked = false;
if (item.controller == 'Radio') {
if ((!item.val && index == 0) || (item.val && item.val == option)) {
optionItem.checked = true;
item.val = option;
}
}
if (item.controller == 'Checkbox' && item.val) {
let valArray = item.val.split(',');
optionItem.checked = valArray.indexOf(option) != -1;
}
item.option_lists.push(optionItem);
});
}
if (item.controller == 'Img') {
item.img_lists = item.val ? item.val.split(',') : [];
}
if (item.controller == 'Date' && !item.val) {
if (item.value.is_show_default) {
if (item.value.is_current) {
item.val = this.getDate();
} else {
item.val = item.value.default;
}
} else {
item.val = '';
}
}
if (item.controller == 'Datelimit') {
if (item.val) {
let dateArray = item.val.split(' - ');
item.start_date = dateArray[0];
item.end_date = dateArray[1];
} else {
item.val = '';
if (item.value.is_show_default_start) {
if (item.value.is_current_start) {
item.start_date = this.getDate();
} else {
item.start_date = item.value.default_start;
}
} else {
item.start_date = '';
}
if (item.value.is_show_default_end) {
if (item.value.is_current_end) {
item.end_date = this.getDate();
} else {
item.end_date = item.value.default_end;
}
}
if (item.start_date && item.end_date) {
item.val = item.start_date + ' - ' + item.end_date;
}
}
}
if (item.controller == 'Time' && !item.val) {
if (item.value.is_show_default) {
if (item.value.is_current) {
item.val = this.getTime();
} else {
item.val = item.value.default;
}
} else {
item.val = '';
}
}
if (item.controller == 'Timelimit') {
if (item.val) {
let timeArray = item.val.split(' - ');
item.start_time = timeArray[0];
item.end_time = timeArray[1];
} else {
item.val = '';
if (item.value.is_show_default_start) {
if (item.value.is_current_start) {
item.start_time = this.getTime();
} else {
item.start_time = item.value.default_start;
}
} else {
item.start_time = '';
}
if (item.value.is_show_default_end) {
if (item.value.is_current_end) {
item.end_time = this.getTime();
} else {
item.end_time = item.value.default_end;
}
}
if (item.start_time && item.end_time) {
item.val = item.start_time + ' - ' + item.end_time;
}
}
}
if (item.controller == 'City') {
item.full_address = '';
item.select_arr = item.value.default_type == 1 ? '2' : '3';
if (item.val) {
item.default_regions = item.val.split('-');
} else {
item.default_regions = [];
}
}
});
// 数据处理完后再算每项的 _labelStyle / _boxAlignStyle(依赖 nameWidthStyle,而 maxLabelWidth 此时可能还没算)
// 这里先算一次;calculateMaxLabelWidth 完成后会再算一次(带正确的 maxLabelWidth)
this.recomputeItemStyles();
},
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 }) });
return false;
}
if (item.name == 'ID_CARD' && !/(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/.test(item.val)) {
if (!item.value.required) {
this.$util.showToast({ title: this.$lang('components.ns-newform.idCardInvalid') });
return false;
}
if (item.val != '') {
this.$util.showToast({ title: this.$lang('components.ns-newform.idCardInvalid') });
return false;
}
}
if (item.name == 'MOBILE' && !this.$util.verifyMobile(item.val)) {
if (!item.value.required) {
this.$util.showToast({ title: this.$lang('components.ns-newform.mobileInvalid') });
return false;
}
if (item.val != '') {
this.$util.showToast({ title: this.$lang('components.ns-newform.mobileInvalid') });
return false;
}
}
}
if (item.controller == 'Textarea' && item.value.required && !item.val) {
this.$util.showToast({ title: this.$lang('components.ns-newform.pleaseInput', { title: item.value.title }) });
return false;
}
if (item.controller == 'Select' && item.value.required && !item.val) {
this.$util.showToast({ title: this.$lang('components.ns-newform.pleaseSelect', { title: item.value.title }) });
return false;
}
if (item.controller == 'Checkbox' && item.value.required && !item.val) {
this.$util.showToast({ title: this.$lang('components.ns-newform.pleaseSelectAtLeastOne', { title: item.value.title }) });
return false;
}
if (item.controller == 'Img' && item.value.required && !item.val) {
this.$util.showToast({ title: this.$lang('components.ns-newform.pleaseUploadAtLeastOne', { title: item.value.title }) });
return false;
}
if (item.controller == 'Date' && item.value.required && !item.val) {
this.$util.showToast({ title: this.$lang('components.ns-newform.pleaseSelect', { title: item.value.title }) });
return false;
}
if (item.controller == 'Datelimit') {
if (item.value.required && !item.val) {
this.$util.showToast({ title: this.$lang('components.ns-newform.pleaseSelect', { title: item.value.title }) });
return false;
}
if (this.$util.timeTurnTimeStamp(item.start_date) > this.$util.timeTurnTimeStamp(item.end_date)) {
this.$util.showToast({ title: this.$lang('components.ns-newform.endDateBeforeStartDate') });
return false;
}
}
if (item.controller == 'Time' && item.value.required && !item.val) {
this.$util.showToast({ title: this.$lang('components.ns-newform.pleaseSelect', { title: item.value.title }) });
return false;
}
if (item.controller == 'Timelimit') {
if (item.value.required && !item.val) {
this.$util.showToast({ title: this.$lang('components.ns-newform.pleaseSelect', { title: item.value.title }) });
return false;
}
if (item.start_time >= item.end_time) {
this.$util.showToast({ title: this.$lang('components.ns-newform.endTimeBeforeStartTime') });
return false;
}
}
if (item.controller == 'City' && item.value.required && !item.val) {
this.$util.showToast({ title: this.$lang('components.ns-newform.pleaseSelect', { title: item.value.title }) });
return false;
}
}
return this.formData;
},
pickerChange(event, index) {
this.formData[index].val = this.data[index].value.options[event.detail.value];
this.$forceUpdate();
},
checkboxChange(event, index) {
this.formData[index].val = event.detail.value.toString();
this.formData[index].option_lists.forEach((option) => {
option.checked = event.detail.value.indexOf(option.value) != -1;
});
this.$forceUpdate();
},
radioChange(event, index) {
this.formData[index].val = event.detail.value;
this.$forceUpdate();
},
uploadImg(index) {
let self = this;
this.$util.upload(Number(this.formData[index].value.max_count), { path: 'evaluateimg' }, function(res) {
if (res.length > 0) {
res.forEach(function(img) {
if (self.formData[index].img_lists.length >= Number(self.formData[index].value.max_count)) {
self.$util.showToast({ title: self.$lang('components.ns-newform.maxUploadImages', { max: self.formData[index].value.max_count }) });
return false;
}
self.formData[index].img_lists.push(img);
});
self.formData[index].val = self.formData[index].img_lists.toString();
self.$forceUpdate();
}
});
},
addImg(index) {
let self = this;
if (this.formData[index].img_lists.length >= Number(this.formData[index].value.max_count)) {
this.$util.showToast({ title: this.$lang('components.ns-newform.maxUploadImages', { max: this.formData[index].value.max_count }) });
return false;
}
this.$util.upload(Number(this.formData[index].value.max_count), { path: 'evaluateimg' }, function(res) {
if (res.length > 0) {
res.forEach(function(img) {
self.formData[index].img_lists.push(img);
});
self.formData[index].val = self.formData[index].img_lists.toString();
self.$forceUpdate();
}
});
},
delImg(imgIndex, formIndex) {
this.formData[formIndex].img_lists.splice(imgIndex, 1);
this.formData[formIndex].val = this.formData[formIndex].img_lists.toString();
this.$forceUpdate();
},
getDate() {
let date = new Date();
let year = date.getFullYear();
let month = date.getMonth() + 1;
let day = date.getDate();
month = month > 9 ? month : '0' + month;
day = day > 9 ? day : '0' + day;
return `${year}-${month}-${day}`;
},
getTime() {
let date = new Date();
let hours = date.getHours();
let minutes = date.getMinutes();
hours = hours > 9 ? hours : '0' + hours;
minutes = minutes > 9 ? minutes : '0' + minutes;
return `${hours}:${minutes}`;
},
bindDateChange(event, index) {
this.formData[index].val = event.detail.value;
this.$forceUpdate();
},
bindStartDateChange(event, index) {
this.$set(this.formData[index], 'start_date', event.detail.value);
this.$set(this.formData[index], 'val', this.formData[index].start_date + ' - ' + this.formData[index].end_date);
this.$forceUpdate();
},
bindEndDateChange(event, index) {
this.$set(this.formData[index], 'end_date', event.detail.value);
this.$set(this.formData[index], 'val', this.formData[index].start_date + ' - ' + this.formData[index].end_date);
this.$forceUpdate();
},
bindTimeChange(event, index) {
this.formData[index].val = event.detail.value;
this.$forceUpdate();
},
bindStartTimeChange(event, index) {
this.formData[index].start_time = event.detail.value;
this.$forceUpdate();
},
bindEndTimeChange(event, index) {
this.formData[index].end_time = event.detail.value;
this.formData[index].val = this.formData[index].start_time + ' - ' + this.formData[index].end_time;
this.$forceUpdate();
},
handleGetRegions(regions, index) {
this.formData[index].val = '';
this.formData[index].val += regions[0] != undefined ? regions[0].label : '';
this.formData[index].val += regions[1] != undefined ? '-' + regions[1].label : '';
this.formData[index].val += regions[2] != undefined ? '-' + regions[2].label : '';
this.$forceUpdate();
}
}
}
</script>
<style lang="scss" scoped>
/* 与后端 D:\projects\shop-projects\backend\src\app\component\view\form\css\design.css 对齐:
在 .form-wrap 上通过内联 style 写入 --form-* / --label-* 等 CSS 变量,
子元素统一读取。
⚠️ 小程序环境修复(2026-09-04):scoped + CSS变量 + inline style 三者结合时,
小程序 WXSS 对 inline style 的自定义属性(--*)作用域识别与浏览器 CSSOM 不一致,
会出现「H5 正常 / 小程序端 CSS 变量值被吞」的两端表现不一致现象。
根因:小程序自定义组件 scoped 把 .form-wrap 编译为 .form-wrap[data-v-xxx],
而 inline style 由 Vue 渲染在 .form-wrap 上,无 data-v 标识,WXSS 把整段 inline CSS变量忽略。
对策:
1) 在这里同时给 :root (H5) / page (小程序) 定义同一组变量兜底,不依赖 inline 通道
2) 子元素 var() 加双重 fallback:先 var(--x, <rpx>),再补一个 page/root 兜底
3) formWrapStyle 同步输出 inline CSS变量(H5 路径仍生效,scoped 小程序也无副作用)
*/
:root {
/* H5 :root 兜底(scoped 下 :root 不会被 vue 编译进 data-v 选择器) */
--form-padding-x: 28rpx;
--banner-padding-x: 0rpx;
--tip-padding-x: 28rpx;
--tip-font-size: 32rpx;
--tip-color: #666666;
--tip-bg-color: #ffffff;
--row-padding-y: 24rpx;
--row-min-height: 60rpx;
--label-gap: 20rpx;
--form-bg-color: #ffffff;
--row-divider-color: #f0f0f0;
--input-height: 84rpx;
--input-radius: 16rpx;
--label-fixed-width: 180rpx;
--label-font-size: 32rpx;
--label-color: #1f2937;
--required-color: #ef4444;
--checked-color: #3b82f6;
--submit-color: #ffffff;
--submit-bg-color: #3b82f6;
--placeholder-color: #9ca3af;
--placeholder-font-size: 28rpx;
--textarea-rows: 4;
--textarea-line-height: 48rpx;
--input-border-color: #e5e7eb;
--input-bg-color: #ffffff;
--input-focus-border-color: #3b82f6;
--input-focus-bg-color: #ffffff;
--label-font-weight: 700;
--show-row-divider: 0;
}
/* #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ || MP-WEIXIN-X ||
MP-KUAISHOU || MP-LARKDINGTALK || MP-JD || MP-XHS || MP-HARMONY */
/* 小程序/鸿蒙:用 view 作为变量载体(与后端 design.css 的 view--form-* 等价) */
view {
--form-padding-x: 28rpx;
--banner-padding-x: 0rpx;
--tip-padding-x: 28rpx;
--tip-font-size: 32rpx;
--tip-color: #666666;
--tip-bg-color: #ffffff;
--row-padding-y: 24rpx;
--row-min-height: 60rpx;
--label-gap: 20rpx;
--form-bg-color: #ffffff;
--row-divider-color: #f0f0f0;
--input-height: 84rpx;
--input-radius: 16rpx;
--label-fixed-width: 180rpx;
--label-font-size: 32rpx;
--label-color: #1f2937;
--required-color: #ef4444;
--checked-color: #3b82f6;
--submit-color: #ffffff;
--submit-bg-color: #3b82f6;
--placeholder-color: #9ca3af;
--placeholder-font-size: 28rpx;
--textarea-rows: 4;
--textarea-line-height: 48rpx;
--input-border-color: #e5e7eb;
--input-bg-color: #ffffff;
--input-focus-border-color: #3b82f6;
--input-focus-bg-color: #ffffff;
--label-font-weight: 700;
--show-row-divider: 0;
}
/* #endif */
/* 原有 scoped .form-wrap 兜底保留(H5 走这里,小程序走上面的 view 选择器,两端互不干扰) */
.form-wrap {
background: #fff;
/* 默认值兜底:与 ns-newform.config 默认值一致 */
--form-padding-x: 28rpx;
--banner-padding-x: 0rpx;
--tip-padding-x: 28rpx;
--tip-font-size: 32rpx;
--tip-color: #666666;
--tip-bg-color: #ffffff;
--row-padding-y: 24rpx;
--row-min-height: 60rpx;
--label-gap: 20rpx;
--form-bg-color: #ffffff;
--row-divider-color: #f0f0f0;
--input-height: 84rpx;
--input-radius: 16rpx;
--label-fixed-width: 180rpx;
--label-font-size: 32rpx;
--label-color: #1f2937;
--required-color: #ef4444;
--checked-color: #3b82f6;
--submit-color: #ffffff;
--submit-bg-color: #3b82f6;
--placeholder-color: #9ca3af;
--placeholder-font-size: 28rpx;
// textarea 行数 / 行高(运行时被 formWrapStyle 注入的 inline 覆盖;这里是兜底)
--textarea-rows: 4;
--textarea-line-height: 48rpx;
// 输入框视觉增强(边框 / 背景 / 聚焦 / label 字重)
--input-border-color: #e5e7eb;
--input-bg-color: #ffffff;
--input-focus-border-color: #3b82f6;
--input-focus-bg-color: #ffffff;
--label-font-weight: 700;
--show-row-divider: 0;
// 与后端 form-preview-wrap.no-divider-all 对齐:整组隐藏所有行分割线
&.no-divider-all {
.order-wrap {
border-bottom: 0;
}
}
}
.order-wrap {
position: relative;
transition: all 0.3s ease;
padding: 0;
background: var(--form-bg-color);
box-sizing: border-box;
// 行分割线:与后端 design.css .form-preview-wrap .form-gorup 对齐
// 用 border-bottom 而非 ::after 伪元素(亚像素不可见 + 多端兼容性差)
// border-bottom: 2rpx solid var(--row-divider-color, #f0f0f0);
border-bottom-width: 2rpx;
border-bottom-style: solid;
border-bottom-color: var(--row-divider-color, #f0f0f0);
// 后端 show_row_divider = 0:隐藏所有行分割线
&.no-divider {
border-bottom: 0;
}
.order-cell {
display: flex;
align-items: center;
background: transparent;
position: relative;
padding: 0;
// 不绑定固定最小高度:行高由后台 row_padding_y + input_height 决定(后台可自定义);
// 后台显式配置 row_min_height 时由 rowCellStyle 内联输出
min-height: var(--row-min-height, 0);
flex-wrap: nowrap;
// 水平布局(默认)
&:not(.vertical-layout) {
flex-direction: row;
.name {
flex-shrink: 0;
margin-bottom: 0;
margin-right: var(--label-gap, 16rpx);
}
}
&.vertical-layout {
flex-direction: column;
align-items: flex-start;
.name {
width: 100%;
min-height: auto;
margin-bottom: var(--label-gap, 20rpx);
margin-right: 0;
}
.box {
width: 100%;
}
}
&.textarea {
align-items: flex-start;
.name {
padding-top: 8rpx;
height: auto;
line-height: 1.5;
}
}
&.clear-flex {
display: block;
.box {
margin-top: 12rpx;
text-align: left;
}
}
&:last-child {
margin-bottom: 0;
}
&.align-top {
align-items: flex-start;
}
text {
font-size: var(--label-font-size, 28rpx);
}
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
align-items: center;
// 不绑定最小高度:标签高度由字号(后台 label_font_size)+ 行内边距决定(两端一致)
min-height: 0;
line-height: 1.5;
.tit {
text-align: left;
font-size: var(--item-label-font-size, var(--label-font-size, 28rpx));
color: var(--item-label-color, var(--label-color, #1f2937));
font-weight: var(--item-label-font-weight, var(--label-font-weight, 600));
letter-spacing: 0.2rpx;
text {
font-size: var(--item-label-font-size, var(--label-font-size, 28rpx));
}
}
/* 必填星号:动态值(颜色/字号/间距)由 item._starStyle 内联提供;
类内只留静态兜底(recompute 前的首次渲染),不依赖 var() 嵌套 fallback(MP/鸿蒙支持不全) */
.required {
color: #ef4444;
font-size: 28rpx;
margin-right: 6rpx;
font-weight: bold;
display: inline-block;
vertical-align: middle;
}
}
.box {
flex: 1;
padding: 0;
line-height: inherit;
min-width: 0; // 防止 flex 子元素内容溢出
// 对齐方式
&.align-left {
text-align: left;
}
&.align-right {
text-align: right;
}
&.align-center {
text-align: center;
}
// 让 box 在 City / Select / Date / Time 等"picker + 右侧箭头"场景下成为
// 独立的 flex 行容器:picker 占满剩余宽度,箭头自然贴右
// (与 .order-cell.vertical-layout 不冲突 —— 在垂直布局下 .order-cell 是
// column,.box.box-flex 自身仍是 row,箭头与 picker 仍同行)
&.box-flex {
display: flex;
align-items: center;
justify-content: space-between;
picker,
.pick-regions {
min-width: 0;
width: auto; // 覆盖下方 .box picker,.pick-regions 的 width: 100%
}
.iconfont.icon-right {
flex-shrink: 0;
margin-left: 8rpx;
line-height: 1;
}
}
// 让原生 picker 与自定义 pick-regions 撑满父容器,
// 使 .box.align-right / align-center 的 text-align 能正确传递到内部
// .uni-input / .select-address(inline-block),与后端 .more 的右对齐效果一致
picker,
.pick-regions {
display: block;
width: 100%;
}
// H5 端 <input> 被 uni-app 编译为 <uni-input> 自定义组件,
// 真实 <input> 是其子节点,需要 ::v-deep 穿透 scoped 才能命中
::v-deep input {
font-size: var(--label-font-size, 28rpx);
text-align: left;
height: var(--input-height, 84rpx);
border: 1rpx solid var(--input-border-color, #e5e7eb);
border-radius: var(--input-radius, 16rpx);
line-height: calc(var(--input-height, 84rpx) - 2rpx);
padding: 0 24rpx;
background: var(--input-bg-color, #ffffff);
color: var(--label-color, #1f2937);
transition: all 0.3s ease;
width: 100%;
box-sizing: border-box;
&:focus {
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #3b82f6);
}
&::placeholder {
color: var(--placeholder-color, #9ca3af);
font-size: var(--placeholder-font-size, 28rpx);
}
}
// 非 H5 端(小程序 / 鸿蒙 / App)<input> 是直接原生元素,普通选择器即可
input {
font-size: var(--label-font-size, 28rpx);
text-align: left;
height: var(--input-height, 84rpx);
border: 1rpx solid var(--input-border-color, #e5e7eb);
border-radius: var(--input-radius, 16rpx);
line-height: calc(var(--input-height, 84rpx) - 2rpx);
padding: 0 24rpx;
background: var(--input-bg-color, #ffffff);
color: var(--label-color, #1f2937);
transition: all 0.3s ease;
width: 100%;
box-sizing: border-box;
&:focus {
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #3b82f6);
}
&::placeholder {
color: var(--placeholder-color, #9ca3af);
font-size: var(--placeholder-font-size, 28rpx);
}
}
// H5 端 <textarea> 被 uni-app 编译为 <uni-textarea> 自定义组件,
// 真实 <textarea> 是其子节点,需要 ::v-deep 穿透 scoped 才能命中
::v-deep textarea {
font-size: var(--label-font-size, 28rpx);
width: 100%;
// 行高 / 高度全部由 formConfig 动态决定(CSS 兜底公式与 JS textareaStyle 一致)
line-height: var(--textarea-line-height, 48rpx);
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
text-align: left;
border: 1rpx solid var(--input-border-color, #e5e7eb);
border-radius: var(--input-radius, 16rpx);
padding: 12rpx 24rpx;
background: var(--input-bg-color, #ffffff);
color: var(--label-color, #1f2937);
transition: all 0.3s ease;
box-sizing: border-box;
&:focus {
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #3b82f6);
}
&::placeholder {
color: var(--placeholder-color, #9ca3af);
font-size: var(--placeholder-font-size, 28rpx);
}
}
// 非 H5 端(小程序 / 鸿蒙 / App)<textarea> 是直接原生元素,普通选择器即可
textarea {
font-size: var(--label-font-size, 28rpx);
width: 100%;
// 行高 / 高度全部由 formConfig 动态决定(与 ::v-deep textarea 同步)
line-height: var(--textarea-line-height, 48rpx);
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
text-align: left;
border: 1rpx solid var(--input-border-color, #e5e7eb);
border-radius: var(--input-radius, 16rpx);
padding: 12rpx 24rpx;
background: var(--input-bg-color, #ffffff);
color: var(--label-color, #1f2937);
transition: all 0.3s ease;
box-sizing: border-box;
&:focus {
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #3b82f6);
}
&::placeholder {
color: var(--placeholder-color, #9ca3af);
font-size: var(--placeholder-font-size, 28rpx);
}
}
.uni-input, .select-address {
display: inline-block;
padding: 0;
border-radius: var(--input-radius, 16rpx);
background: transparent;
border: none;
color: var(--label-color, #1f2937);
font-size: var(--label-font-size, 28rpx);
transition: all 0.3s ease;
// 不绑定最小高度:高度由 line-height(后台 input_height,inline 直出)决定
min-height: 0;
box-sizing: border-box;
line-height: var(--input-height, 84rpx);
&.color-tip {
color: var(--placeholder-color, #9ca3af);
}
&.empty {
color: var(--placeholder-color, #9ca3af);
}
}
checkbox-group {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12rpx;
width: 100%;
// 隐藏 uniapp 默认的 checkbox 样式
checkbox {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
}
radio-group {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12rpx;
width: 100%;
// 隐藏 uniapp 默认的 radio 样式
radio {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
}
// 选项排列样式:vertical = 每个选项独立一行;wrap = 横向自动换行
// option-layout-* 是 .box 自身的类,必须用 & 嵌套,否则编译成 .box .option-layout-* 选不中实际 DOM
&.option-layout-vertical {
checkbox-group,
radio-group {
flex-direction: column;
align-items: flex-start;
width: 100%;
label {
width: 100%;
justify-content: flex-start;
padding: 12rpx 0;
}
}
}
label {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
margin: 0;
padding: 6rpx 0;
border: none;
background: transparent;
transition: all 0.3s ease;
flex-shrink: 0;
position: relative;
color: var(--item-label-color, var(--label-color, #1f2937));
font-size: var(--item-label-font-size, var(--label-font-size, 28rpx));
&:active {
opacity: 0.6;
}
}
// 自定义勾选图标
.checkbox,
.radio-box {
display: inline-flex;
align-items: center;
gap: 12rpx;
}
.check-icon,
.radio-icon {
font-size: 40rpx;
line-height: 1;
color: #d1d5db;
}
&.img-boxs {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16rpx;
// 与后端 design.css .form-preview-wrap .img-box 对齐:
// 100rpx (≈ 50px) + 1rpx solid #E6E6E6 + 4rpx radius
.img-box {
margin: 0;
display: flex;
justify-content: center;
align-items: center;
width: 100rpx;
height: 100rpx;
border: 1rpx solid #E6E6E6;
border-radius: 4rpx;
position: relative;
background: transparent;
box-sizing: border-box;
transition: all 0.3s ease;
overflow: hidden;
&:active {
border-color: var(--checked-color, #ff4544);
}
// 关闭按钮:与后端 .form-preview-wrap .img-box .close 对齐
// top/right = -14rpx (≈ -7px),size = 28rpx (≈ 14px),背景 #999
.icon-guanbi {
position: absolute;
top: -14rpx;
right: -14rpx;
display: flex;
align-items: center;
justify-content: center;
width: 28rpx;
height: 28rpx;
line-height: 1;
color: #fff;
background: #999;
border-radius: 50%;
font-size: 28rpx;
z-index: 10;
}
.icon-add1 {
font-size: 48rpx;
color: #9ca3af;
}
}
}
&.date-boxs {
display: flex;
align-items: center;
gap: 12rpx;
.date-box {
flex: 1;
.picker-box {
.uni-input {
width: 100%;
box-sizing: border-box;
}
}
}
.interval {
padding: 0 8rpx;
color: #9ca3af;
font-size: 28rpx;
flex-shrink: 0;
}
}
}
.icon-right {
color: #9ca3af;
font-size: 32rpx;
margin-left: 12rpx;
flex-shrink: 0;
}
}
}
// 信息卡片(与 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>
.placeholder {
color: #9ca3af;
font-size: 28rpx;
// #ifdef H5
height: 100% !important;
// #endif
}
.color-tip {
color: #9ca3af;
}
</style>