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

1719 lines
61 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">{{ 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"
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' }]">
<view class="name" :style="item._labelStyle">
<text class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ 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"
v-model="item.val"
:style="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' }]">
<view class="name" :style="item._labelStyle">
<text class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ 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']">
<text v-if="item.val != ''">{{ item.val }}</text>
<text v-else class="color-tip">{{ 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' }]">
<view class="name" :style="item._labelStyle">
<text class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ 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')]">
<checkbox-group @change="checkboxChange($event, index)" :style="item._optionLayoutStyle">
<label v-for="(v, k) in item.option_lists" :key="k">
<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="{ color: v.checked ? checkedColor : '' }"
></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' }]">
<view class="name" :style="item._labelStyle">
<text class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ 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')]">
<radio-group @change="radioChange($event, index)" :style="item._optionLayoutStyle">
<label v-for="(v, k) in item.option_lists" :key="k">
<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="{ color: item.val == v.value ? checkedColor : '' }"
></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' }]">
<view class="name" :style="item._labelStyle">
<text class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ 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' }]">
<view class="name" :style="item._labelStyle">
<text class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ 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']">
<picker
mode="date"
:value="item.val"
@change="bindDateChange($event, index)"
>
<view :class="['uni-input', !item.val ? 'color-tip' : '']">
{{ 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' }]">
<view class="name" :style="item._labelStyle">
<text class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ 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' : '']">
{{ 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' : '']">
{{ 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' }]">
<view class="name" :style="item._labelStyle">
<text class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ 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']">
<picker
mode="time"
:value="item.val"
@change="bindTimeChange($event, index)"
>
<view :class="['uni-input', !item.val ? 'color-tip' : '']">
{{ 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' }]">
<view class="name" :style="item._labelStyle">
<text class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ 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' : '']">
{{ 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' : '']">
{{ 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' }]">
<view class="name" :style="item._labelStyle">
<text class="required" :style="item._starStyle">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ 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']">
<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' : '']">
{{ 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="{ fontSize: ((item.value.item_font_size || 26) / 2) + 'px', color: item.value.item_color || '#6b7280' }"
>{{ it }}</view>
</view>
</template>
<view v-if="item.value.show_divider == 1" class="info-card-divider" :style="item._infoCardDividerStyle"></view>
</view>
</view>
</view>
</template>
<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] || {}))
);
// 合并:defaults <- globalCfg <- platformCfg
return Object.assign({}, defaults, globalCfg, platformCfg);
},
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),
'--row-min-height': this.$util.rpx(cfg.rowMinHeight),
'--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';
},
// 行最小高度
rowCellStyle() {
return this.$util.objToStyle({
'min-height': this.$util.rpx(this.config.rowMinHeight)
});
},
// 标签与输入框间距
nameStyle() {
if (this.mode === 'vertical') {
return {};
}
return {
'margin-right': this.$util.rpx(this.config.labelGap)
};
},
// 输入框样式(全局,未指定 item 时使用)
inputStyle() {
return this.$util.objToStyle({
'min-height': this.$util.rpx(this.config.inputHeight),
'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(所有平台统一使用,见 template 中 textarea 节点)
// 高度 = rows × lineHeight + padding + border,由 formConfig 动态控制,与 ns-form 同公式
textareaStyle() {
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 this.$util.objToStyle({
'height': h,
'min-height': h,
'line-height': lh,
'border-radius': this.$util.rpx(this.config.inputRadius)
});
},
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: {
// 预计算每项的样式(写到 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)
const rowPadY = this.$util.rpx(this.config.rowPaddingY);
const clampRpx = (n, min, max) => this.$util.rpx(Math.max(min, Math.min(max, n)));
this.formData.forEach((item) => {
const itemValue = (item && item.value) || {};
// ---- 字段级间距(每个组件独立):外边距直出(允许负值上移贴近相邻组件)----
// 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);
// 11 种输入控件行容器(.order-wrap,原 rowWrapStyle 承载层):
// margin 直出;padding 覆盖对应边(未设置的边跟随全局:上下 rowPaddingY / 左右 0)
if (item.controller != 'InfoCard') {
const fieldWrapStyle = Object.assign({}, fieldMargin);
fieldWrapStyle['padding-top'] = pTop !== null ? clampRpx(pTop, 0, 400) : rowPadY;
fieldWrapStyle['padding-bottom'] = pBottom !== null ? clampRpx(pBottom, 0, 400) : rowPadY;
fieldWrapStyle['padding-left'] = pLeft !== null ? clampRpx(pLeft, 0, 400) : '0';
fieldWrapStyle['padding-right'] = pRight !== null ? clampRpx(pRight, 0, 400) : '0';
this.$set(item, '_fieldWrapStyle', 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);
// $set 包裹以确保新属性是响应式的(Vue 2 对动态加的属性需要 $set)
this.$set(item, '_labelStyle', style);
// item 级别 box 对齐方式(与原 getItemBoxAlignStyle 逻辑一致)
const align = itemValue.input_align;
this.$set(item, '_boxAlignStyle', align ? { 'text-align': align } : {});
// 必填星号样式:颜色/字号/间距全部预计算 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', 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';
}
this.$set(item, '_optionLayoutStyle', optStyle);
// 信息卡片:容器底色 + 空白判定 + 分割线样式(与后台 design.js 的
// getInfoCardContainerStyle / getInfoCardDividerStyle 对齐;数值单位为 px 直出,
// 与编辑器输入单位一致,375 宽屏上与后台 PC 预览视觉一致)
if (item.controller == 'InfoCard') {
// 容器:bg_color 配置且非 transparent 时输出底色 + 圆角 + 内边距
const wrapStyle = {};
const bg = itemValue.bg_color;
if (bg && bg !== 'transparent') {
wrapStyle['background-color'] = bg;
wrapStyle['border-radius'] = '6px';
wrapStyle['padding'] = '8px 10px';
}
// 字段级间距(InfoCard 行容器即卡片本身,无全局行内边距):margin 直出;
// padding 覆盖背景卡默认内边距的对应边(无 bg 时直接生效)
Object.keys(fieldMargin).forEach((k) => { wrapStyle[k] = fieldMargin[k]; });
if (pTop !== null) wrapStyle['padding-top'] = clampRpx(pTop, 0, 400);
if (pBottom !== null) wrapStyle['padding-bottom'] = clampRpx(pBottom, 0, 400);
if (pLeft !== null) wrapStyle['padding-left'] = clampRpx(pLeft, 0, 400);
if (pRight !== null) wrapStyle['padding-right'] = clampRpx(pRight, 0, 400);
this.$set(item, '_infoCardWrapStyle', wrapStyle);
// 空白判定:去标签 + &nbsp; + 去空白后为空 → 不渲染富文本(与后台一致,避免空白块)
const raw = itemValue.content == null ? '' : String(itemValue.content);
const textOnly = raw.replace(/<[^>]+>/g, '').replace(/&nbsp;/gi, ' ').replace(/\s/g, '');
this.$set(item, '_infoCardHasContent', !!textOnly);
// 分割线:border-top 渲染支持 solid/dashed/dotted/double;
// 宽度 calc(w% - 2*px),w<100 且 px=0 时 margin auto 居中(与后台同规则)
if (itemValue.show_divider == 1) {
const h = parseInt(itemValue.divider_height);
const mt = parseInt(itemValue.divider_margin_top);
let w = parseInt(itemValue.divider_width);
let px = parseInt(itemValue.divider_padding_x);
if (!Number.isFinite(w) || w <= 0 || w > 100) w = 100;
if (!Number.isFinite(px) || px < 0) px = 0;
const dStyle = {
height: '0',
'border-top': (Number.isFinite(h) && h > 0 ? h : 1) + 'px ' + (itemValue.divider_style || 'solid') + ' ' + (itemValue.divider_color || '#f0f0f0'),
'margin-top': (Number.isFinite(mt) && mt >= 0 ? mt : 8) + 'px',
'margin-left': px + 'px',
'margin-right': px + 'px',
width: 'calc(' + w + '% - ' + (px * 2) + 'px)'
};
if (w < 100 && px === 0) {
dStyle['margin-left'] = 'auto';
dStyle['margin-right'] = 'auto';
}
this.$set(item, '_infoCardDividerStyle', dStyle);
} else {
this.$set(item, '_infoCardDividerStyle', {});
}
}
});
},
// 容器样式(提供给外部 diy-form 使用)
getFormWrapStyle() {
return this.formWrapStyle;
},
calculateMaxLabelWidth() {
if (this.mode === 'vertical' || !this.formData || this.formData.length === 0) {
this.maxLabelWidth = 0;
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 变量,
子元素统一读取。 */
.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;
min-height: var(--row-min-height, 60rpx);
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;
min-height: var(--input-height, 60rpx);
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;
min-height: var(--input-height, 84rpx);
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>