Files
lucky_shop/components/ns-newform/ns-newform.vue
ZF sun bb0adb0542 style(ns-form, ns-newform): 新增占位符字体大小配置项
1. 为两个表单组件新增placeholderFontSize配置项与对应CSS变量
2. 将输入框占位符字体大小从复用label字号改为独立配置
3. 适配小程序/鸿蒙原生占位符样式规则
2026-07-17 16:14:25 +08:00

1371 lines
43 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="rowWrapStyle">
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]" :style="rowCellStyle">
<view class="name" :style="item._labelStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'align-left']" :style="item._boxAlignStyle">
<input
type="text"
:placeholder="item.value.placeholder || $lang('components.ns-newform.inputPlaceholder')"
placeholder-class="placeholder color-tip"
v-model="item.val"
/>
</view>
</view>
</view>
<!-- 多行文本输入框 -->
<view v-if="item.controller == 'Textarea'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
<view :class="['order-cell', 'flex-box', 'textarea', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'align-left']" :style="item._boxAlignStyle">
<textarea
:placeholder="item.value.placeholder || $lang('components.ns-newform.inputPlaceholder')"
placeholder-class="placeholder color-tip"
v-model="item.val"
></textarea>
</view>
</view>
</view>
<!-- 下拉选择器 -->
<view v-if="item.controller == 'Select'" class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
<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="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ 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="rowWrapStyle">
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'check-group-box', 'align-left']">
<checkbox-group @change="checkboxChange($event, index)">
<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',
!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="rowWrapStyle">
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'radio-group-box', 'align-left']">
<radio-group @change="radioChange($event, index)">
<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',
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="rowWrapStyle">
<view :class="['order-cell', 'flex-box', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'img-boxs', 'align-left']" :style="item._boxAlignStyle">
<view v-for="(v, k) in item.img_lists" :key="k" class="img-box" @tap="uploadImg(index)">
<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="rowWrapStyle">
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ 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="rowWrapStyle">
<view :class="['order-cell', 'flex-box', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ 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="rowWrapStyle">
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ 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="rowWrapStyle">
<view :class="['order-cell', 'flex-box', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ 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="rowWrapStyle">
<view :class="['order-cell', { 'vertical-layout': mode === 'vertical' }]">
<view class="name" :style="item._labelStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ 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>
</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: 'horizontal',
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: 14,
bannerPaddingX: 0,
tipPaddingX: 14,
rowPaddingY: 4,
rowMinHeight: 60,
labelGap: 16,
formBgColor: '#ffffff',
rowDividerColor: '#f0f0f0',
showRowDivider: true,
inputHeight: 60,
inputRadius: 10,
labelWidthMode: 'auto',
labelFixedWidth: 180,
labelFontSize: 28,
labelColor: '#1f2937',
requiredColor: '#ef4444',
checkedColor: '#ff4544',
placeholderColor: '#ddd',
placeholderFontSize: 28,
// 提交按钮
submitColor: '#ffffff',
submitBgColor: '#ff4544',
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)),
'--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'
});
},
// 行间距样式:仅上下内边距,水平由外层 form-wrap 控制(与后端 form-gorup 对齐)
rowWrapStyle() {
return this.$util.objToStyle({
padding: `${this.$util.rpx(this.config.rowPaddingY)} 0`
});
},
// 行容器 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)
});
},
labelStyle() {
const style = {
fontSize: this.$util.rpx(this.config.labelFontSize),
color: this.config.labelColor
};
return this.$util.objToStyle(style);
},
requiredColor() {
return this.config.requiredColor;
},
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 || {};
this.formData.forEach((item) => {
const itemValue = (item && item.value) || {};
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 } : {});
});
},
// 容器样式(提供给外部 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)
const widthPx = Math.max(40, maxChars * (fontSize / 2) * 1.1 + 12);
this.maxLabelWidth = Math.round(widthPx * 2);
// 宽度变了,需要把全局 nameWidthStyle 重新合到每项 _labelStyle 里
this.recomputeItemStyles();
},
initFormData() {
// 深拷贝 prop 数据到本地状态
this.formData = JSON.parse(JSON.stringify(this.data || []));
this.processFormData();
},
setFormData() {
// 重置为初始值
this.initFormData();
},
processFormData() {
this.formData.forEach((item) => {
if (!item.val) {
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 == '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: 14rpx;
--banner-padding-x: 0rpx;
--tip-padding-x: 14rpx;
--tip-font-size: 24rpx;
--tip-color: #666666;
--tip-bg-color: #ffffff;
--row-padding-y: 4rpx;
--row-min-height: 60rpx;
--label-gap: 16rpx;
--form-bg-color: #ffffff;
--row-divider-color: #f0f0f0;
--input-height: 60rpx;
--input-radius: 10rpx;
--label-fixed-width: 180rpx;
--label-font-size: 28rpx;
--label-color: #1f2937;
--required-color: #ef4444;
--checked-color: #ff4544;
--submit-color: #ffffff;
--submit-bg-color: #ff4544;
--placeholder-color: #ddd;
--placeholder-font-size: 28rpx;
--show-row-divider: 1;
// 与后端 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%;
margin-bottom: 8rpx;
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: 500;
letter-spacing: 0.2rpx;
text {
font-size: var(--item-label-font-size, var(--label-font-size, 28rpx));
}
}
.required {
color: var(--required-color, #ef4444);
font-size: var(--item-label-font-size, var(--label-font-size, 28rpx));
margin-left: 4rpx;
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%;
}
input {
font-size: var(--label-font-size, 28rpx);
text-align: left;
height: var(--input-height, 60rpx);
border: none;
border-radius: var(--input-radius, 10rpx);
line-height: var(--input-height, 60rpx);
padding: 0;
background: transparent;
color: var(--label-color, #1f2937);
transition: all 0.3s ease;
width: 100%;
box-sizing: border-box;
&:focus {
background: transparent;
}
&::placeholder {
color: var(--placeholder-color, #ddd);
font-size: var(--placeholder-font-size, 28rpx);
}
}
textarea {
font-size: var(--label-font-size, 28rpx);
width: 100%;
min-height: 120rpx;
line-height: 1.5;
text-align: left;
border: none;
border-radius: var(--input-radius, 10rpx);
padding: 0;
background: transparent;
color: var(--label-color, #1f2937);
transition: all 0.3s ease;
box-sizing: border-box;
height: auto;
&:focus {
background: transparent;
}
&::placeholder {
color: var(--placeholder-color, #ddd);
font-size: var(--placeholder-font-size, 28rpx);
}
}
.uni-input, .select-address {
display: inline-block;
padding: 0;
border-radius: var(--input-radius, 10rpx);
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, 60rpx);
box-sizing: border-box;
line-height: var(--input-height, 60rpx);
&.color-tip {
color: var(--placeholder-color, #ddd);
}
&.empty {
color: var(--placeholder-color, #ddd);
}
}
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;
}
}
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: 8rpx;
}
&.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;
}
}
}
</style>
<style lang="scss" scoped>
.placeholder {
color: #909399;
font-size: 28rpx;
// #ifdef H5
height: 100% !important;
// #endif
}
.color-tip {
color: #909399;
}
</style>