mrr.sj.front/components/dynamic-form/dynamic-form.vue

472 lines
17 KiB
Vue
Raw Normal View History

2026-06-09 17:49:15 +08:00
<template>
2026-06-10 10:15:10 +08:00
<view class="dynamic-form">
2026-06-10 16:23:47 +08:00
<view v-for="(group, gi) in fields" :key="gi" class="form-group">
<!-- single -->
<block v-if="group.type === 'single'">
<view v-if="group.field.type === 'text'" class="form-item">
2026-06-11 20:48:30 +08:00
<text :class="['form-label', group.field.is_required === true ? 'required' : '']">{{ group.field.label
}}</text>
2026-06-10 16:23:47 +08:00
<input type="text" :value="formData[group.field.key]" :placeholder="group.field.placeholder"
placeholder-class="placeholder" class="form-input" @input="onInput(group.field.key, $event)" />
2026-06-10 10:15:10 +08:00
</view>
2026-06-11 20:48:30 +08:00
<view v-else-if="group.field.type === 'number'" class="form-item"
:style="group.field.tip ? 'flex-wrap: wrap;' : ''">
<text :class="['form-label', group.field.is_required === true ? 'required' : '']">{{ group.field.label
}}</text>
2026-06-10 16:23:47 +08:00
<input type="digit" :value="formData[group.field.key]" :placeholder="group.field.placeholder"
placeholder-class="placeholder" class="form-input" @input="onInput(group.field.key, $event)" />
<text v-if="group.field.tips && !group.field.tip" class="form-tips">{{ group.field.tips }}</text>
<view v-if="group.field.tip" class="price-tip">
<text class="price-tip-card">{{ group.field.tip }}</text>
</view>
2026-06-10 10:15:10 +08:00
</view>
2026-06-23 09:55:51 +08:00
<view v-else-if="group.field.type === 'textarea'" class="detail-row">
<text :class="['form-label', group.field.is_required === true ? 'required' : '']">{{ group.field.label
}}</text>
<view class="detail-textarea-wrap">
<textarea class="detail-textarea" :value="formData[group.field.key]" :placeholder="group.field.placeholder"
placeholder-class="placeholder" maxlength="200" @input="onInput(group.field.key, $event)" />
<text class="detail-count">{{ (formData[group.field.key] || "").length }}/200</text>
</view>
</view>
<view v-else-if="group.field.type === 'select'" class="form-item">
<text :class="['form-label', group.field.is_required === true ? 'required' : '']">{{ group.field.label
}}</text>
<form-item-select :value="formData[group.field.key]" :options="group.field.options || []"
:placeholder="group.field.placeholder" @input="onInput(group.field.key, $event)" />
</view>
<view v-else-if="group.field.type === 'single_select'" class="form-item" style="flex-wrap: wrap;">
<text :class="['form-label', group.field.is_required === true ? 'required' : '']">{{ group.field.label
}}</text>
<view class="select-options">
<form-item-radio :value="formData[group.field.key]" :options="group.field.options || []"
@input="onInput(group.field.key, $event)" />
</view>
</view>
<view v-else-if="group.field.type === 'multi_select'" class="form-item" style="flex-wrap: wrap;">
<text :class="['form-label', group.field.is_required === true ? 'required' : '']">{{ group.field.label
}}</text>
<view class="select-options">
<form-item-checkbox :value="formData[group.field.key]" :options="group.field.options || []"
:maxCount="group.field.maxCount || 0" @input="onInput(group.field.key, $event)" />
</view>
</view>
2026-06-10 16:23:47 +08:00
<view v-else-if="group.field.type === 'image_multi'" class="form-item-up upload-item">
<view>
2026-06-11 20:48:30 +08:00
<text :class="['form-label', group.field.is_required === true ? 'required' : '']">{{ group.field.label
}}</text>
2026-06-10 16:23:47 +08:00
<text class="upload-tip">(仅可上传{{ group.field.maxCount || 9 }}个图片)</text>
</view>
2026-06-23 09:55:51 +08:00
<view class="upload-content">
<form-item-image :value="formData[group.field.key]" :maxCount="group.field.maxCount || 9"
:userId="uploadUserId" @input="onInput(group.field.key, $event)" />
</view>
2026-06-10 10:15:10 +08:00
</view>
2026-06-10 16:23:47 +08:00
<view v-else-if="group.field.type === 'video'" class="form-item-up upload-item">
<view>
2026-06-11 20:48:30 +08:00
<text :class="['form-label', group.field.is_required === true ? 'required' : '']">{{ group.field.label
}}</text>
<text class="upload-tip">(仅可上传{{ group.field.maxCount || 1 }}个视频)</text>
2026-06-10 16:23:47 +08:00
</view>
<view class="upload-content">
2026-06-11 20:48:30 +08:00
<form-item-video :value="formData[group.field.key]" :maxCount="group.field.maxCount || 1"
:maxSize="group.field.maxSize || group.field.maxFileSize || 100"
:userId="uploadUserId"
@input="onInput(group.field.key, $event)" />
2026-06-10 16:23:47 +08:00
</view>
2026-06-10 10:15:10 +08:00
</view>
2026-06-10 16:23:47 +08:00
<view v-else-if="group.field.type === 'richtext_editor'" class="form-item">
2026-06-11 20:48:30 +08:00
<text :class="['form-label', group.field.is_required === true ? 'required' : '']">{{ group.field.label
}}</text>
2026-06-10 16:23:47 +08:00
<form-item-richtext :value="formData[group.field.key]" @input="onInput(group.field.key, $event)" />
2026-06-10 10:15:10 +08:00
</view>
2026-06-11 20:48:30 +08:00
<view v-else-if="group.field.type === 'service_step'" class="form-item-up upload-item">
<form-item-service-step :value="formData[group.field.key]" :label="group.field.label"
:required="group.field.is_required === true" @input="onInput(group.field.key, $event)" />
</view>
2026-06-10 16:23:47 +08:00
</block>
<!-- section -->
<view v-if="group.type === 'section'" class="section-card">
<view v-if="group.sectionTitle" class="section-title-bar">
<text class="section-title">{{ group.sectionTitle }}</text>
2026-06-10 10:15:10 +08:00
</view>
2026-06-10 16:23:47 +08:00
<view v-for="(field, fi) in group.fields" :key="field.key || fi" class="section-field">
<view v-if="field.type === 'text'" class="form-item no-bg">
<text :class="['form-label', field.is_required === true ? 'required' : '']">{{ field.label }}</text>
<input type="text" :value="formData[field.key]" :placeholder="field.placeholder"
placeholder-class="placeholder" class="form-input" @input="onInput(field.key, $event)" />
</view>
<view v-else-if="field.type === 'textarea'" class="detail-row">
<text :class="['form-label', field.is_required === true ? 'required' : '']">{{ field.label }}</text>
<view class="detail-textarea-wrap">
<textarea class="detail-textarea" :value="formData[field.key]" :placeholder="field.placeholder"
placeholder-class="placeholder" maxlength="200" @input="onInput(field.key, $event)" />
<text class="detail-count">{{ (formData[field.key] || "").length }}/200</text>
</view>
</view>
2026-06-11 20:48:30 +08:00
<view v-else-if="field.type === 'number'" class="form-item no-bg"
:style="field.tip ? 'flex-wrap: wrap;' : ''">
2026-06-10 16:23:47 +08:00
<text :class="['form-label', field.is_required === true ? 'required' : '']">{{ field.label }}</text>
<input type="digit" :value="formData[field.key]" :placeholder="field.placeholder"
placeholder-class="placeholder" class="form-input" @input="onInput(field.key, $event)" />
<text v-if="field.tips && !field.tip" class="form-tips">{{ field.tips }}</text>
<view v-if="field.tip" class="price-tip">
<text class="price-tip-card">{{ field.tip }}</text>
</view>
</view>
<view v-else-if="field.type === 'select'" class="form-item no-bg">
<text :class="['form-label', field.is_required === true ? 'required' : '']">{{ field.label }}</text>
2026-06-11 20:48:30 +08:00
<form-item-select :value="formData[field.key]" :options="field.options || []"
:placeholder="field.placeholder" @input="onInput(field.key, $event)" />
2026-06-10 16:23:47 +08:00
</view>
<view v-else-if="field.type === 'single_select'" class="form-item no-bg" style="flex-wrap: wrap;">
<text :class="['form-label', field.is_required === true ? 'required' : '']">{{ field.label }}</text>
<view class="select-options">
<form-item-radio :value="formData[field.key]" :options="field.options || []"
@input="onInput(field.key, $event)" />
</view>
</view>
<view v-else-if="field.type === 'multi_select'" class="form-item no-bg" style="flex-wrap: wrap;">
<text :class="['form-label', field.is_required === true ? 'required' : '']">{{ field.label }}</text>
<view class="select-options">
<form-item-checkbox :value="formData[field.key]" :options="field.options || []"
:maxCount="field.maxCount || 0" @input="onInput(field.key, $event)" />
</view>
</view>
<view v-else-if="field.type === 'image_multi'" class="form-item-up upload-item no-bg">
<view>
<text :class="['form-label', field.is_required === true ? 'required' : '']">{{ field.label }}</text>
<text class="upload-tip">(仅可上传{{ field.maxCount || 9 }}个图片)</text>
</view>
2026-06-23 09:55:51 +08:00
<view class="upload-content">
<form-item-image :value="formData[field.key]" :maxCount="field.maxCount || 9"
:userId="uploadUserId" @input="onInput(field.key, $event)" />
</view>
2026-06-10 16:23:47 +08:00
</view>
<view v-else-if="field.type === 'video'" class="form-item-up upload-item no-bg">
<view>
<text :class="['form-label', field.is_required === true ? 'required' : '']">{{ field.label }}</text>
2026-06-11 20:48:30 +08:00
<text class="upload-tip">(仅可上传{{ field.maxCount || 1 }}个视频)</text>
2026-06-10 16:23:47 +08:00
</view>
<view class="upload-content">
2026-06-11 20:48:30 +08:00
<form-item-video :value="formData[field.key]" :maxCount="field.maxCount || 1"
:maxSize="field.maxSize || field.maxFileSize || 100" :userId="uploadUserId"
@input="onInput(field.key, $event)" />
2026-06-10 16:23:47 +08:00
</view>
</view>
<view v-else-if="field.type === 'richtext_editor'" class="form-item no-bg">
<text :class="['form-label', field.is_required === true ? 'required' : '']">{{ field.label }}</text>
<form-item-richtext :value="formData[field.key]" @input="onInput(field.key, $event)" />
</view>
2026-06-11 20:48:30 +08:00
<view v-else-if="field.type === 'service_step'" class="form-item-up upload-item no-bg">
<form-item-service-step :value="formData[field.key]" :label="field.label"
:required="field.is_required === true" @input="onInput(field.key, $event)" />
</view>
2026-06-10 10:15:10 +08:00
</view>
</view>
</view>
</view>
2026-06-09 17:49:15 +08:00
</template>
<script>
import FormItemText from './FormItemText.vue';
import FormItemTextarea from './FormItemTextarea.vue';
import FormItemNumber from './FormItemNumber.vue';
import FormItemImage from './FormItemImage.vue';
import FormItemVideo from './FormItemVideo.vue';
import FormItemSelect from './FormItemSelect.vue';
import FormItemRadio from './FormItemRadio.vue';
import FormItemCheckbox from './FormItemCheckbox.vue';
import FormItemRichtext from './FormItemRichtext.vue';
2026-06-11 20:48:30 +08:00
import FormItemServiceStep from './FormItemServiceStep.vue';
2026-06-09 17:49:15 +08:00
export default {
2026-06-10 10:15:10 +08:00
name: 'DynamicForm',
components: {
'form-item-text': FormItemText,
'form-item-textarea': FormItemTextarea,
'form-item-number': FormItemNumber,
'form-item-image': FormItemImage,
'form-item-video': FormItemVideo,
'form-item-select': FormItemSelect,
'form-item-radio': FormItemRadio,
'form-item-checkbox': FormItemCheckbox,
2026-06-11 20:48:30 +08:00
'form-item-richtext': FormItemRichtext,
'form-item-service-step': FormItemServiceStep
2026-06-10 10:15:10 +08:00
},
props: {
fields: {
type: Array,
default: function () { return []; }
},
value: {
type: Object,
default: function () { return {}; }
2026-06-11 20:48:30 +08:00
},
uploadUserId: {
type: [Number, String],
default: null
2026-06-10 10:15:10 +08:00
}
},
data: function () {
return {
formData: {}
};
},
watch: {
value: {
handler: function (val) {
this.formData = Object.assign({}, val);
},
immediate: true
},
fields: {
handler: function () {
var self = this;
var data = {};
2026-06-10 16:23:47 +08:00
this.fields.forEach(function (group) {
if (group.type === 'single' && group.field) {
self._initFieldData(data, group.field);
} else if (group.type === 'section' && group.fields) {
group.fields.forEach(function (field) {
self._initFieldData(data, field);
});
2026-06-10 10:15:10 +08:00
}
});
this.formData = Object.assign({}, this.formData, data);
},
immediate: true
}
},
methods: {
2026-06-10 16:23:47 +08:00
_initFieldData: function (data, field) {
if (this.formData[field.key] !== undefined) {
data[field.key] = this.formData[field.key];
} else if (field.default_value !== undefined) {
data[field.key] = field.default_value;
2026-06-11 20:48:30 +08:00
} else if (
field.type === 'multi_select' ||
field.type === 'image_multi' ||
field.type === 'service_step' ||
(field.type === 'video' && field.maxCount > 1)
) {
2026-06-10 16:23:47 +08:00
data[field.key] = [];
} else {
data[field.key] = '';
}
},
2026-06-10 10:15:10 +08:00
onInput: function (key, event) {
var value = event;
if (event && event.target && event.target.value !== undefined) {
value = event.target.value;
}
this.$set(this.formData, key, value);
this.$emit('input', this.formData);
},
getData: function () {
return Object.assign({}, this.formData);
},
validate: function () {
var self = this;
for (var i = 0; i < this.fields.length; i++) {
2026-06-10 16:23:47 +08:00
var group = this.fields[i];
var fieldsToCheck = [];
if (group.type === 'single' && group.field) {
fieldsToCheck.push(group.field);
} else if (group.type === 'section' && group.fields) {
fieldsToCheck = group.fields;
}
for (var j = 0; j < fieldsToCheck.length; j++) {
var field = fieldsToCheck[j];
if (field.is_required === true) {
var value = self.formData[field.key];
if (!value || (Array.isArray(value) && value.length === 0)) {
uni.showToast({
title: field.label + '不能为空',
icon: 'none'
});
return false;
}
2026-06-10 10:15:10 +08:00
}
}
}
return true;
}
}
2026-06-09 17:49:15 +08:00
};
</script>
<style scoped>
.dynamic-form {
2026-06-10 10:15:10 +08:00
width: 100%;
}
2026-06-10 16:23:47 +08:00
.form-group {
2026-06-10 10:15:10 +08:00
width: 100%;
2026-06-09 17:49:15 +08:00
}
.form-item {
2026-06-10 10:15:10 +08:00
background-color: #fff;
padding: 24rpx;
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
box-sizing: border-box;
border-radius: 10rpx;
margin-bottom: 24rpx;
}
.form-label {
font-size: 28rpx;
color: #333333;
}
.required::before {
content: '*';
color: #ff0000;
margin-right: 4rpx;
}
.form-tips {
font-size: 24rpx;
2026-06-10 16:23:47 +08:00
color: #333333;
margin-left: 0;
2026-06-10 10:15:10 +08:00
}
.price-tip {
width: 100%;
margin-top: 40rpx;
}
.price-tip-card {
padding: 8rpx 12rpx;
background: #FDF0F0;
border-radius: 8rpx;
font-weight: 400;
font-size: 24rpx;
color: #E8101E;
line-height: 32rpx;
2026-06-09 17:49:15 +08:00
}
.form-item-up {
2026-06-10 10:15:10 +08:00
background-color: #fff;
padding: 24rpx;
display: flex;
flex-flow: column nowrap;
justify-content: space-between;
align-items: flex-start;
box-sizing: border-box;
border-radius: 10rpx;
margin-bottom: 24rpx;
2026-06-09 17:49:15 +08:00
}
2026-06-10 10:15:10 +08:00
.upload-item {
padding-bottom: 30rpx;
2026-06-09 17:49:15 +08:00
}
2026-06-10 10:15:10 +08:00
.upload-tip {
font-size: 24rpx;
color: #999999;
margin-left: 10rpx;
2026-06-09 17:49:15 +08:00
}
2026-06-10 10:15:10 +08:00
.upload-content {
margin-top: 20rpx;
width: 100%;
2026-06-09 17:49:15 +08:00
}
2026-06-10 10:15:10 +08:00
.select-options {
width: 100%;
margin-top: 16rpx;
}
2026-06-10 16:23:47 +08:00
.section-card {
2026-06-10 10:15:10 +08:00
background: #ffffff;
border-radius: 20rpx;
2026-06-10 16:23:47 +08:00
padding: 0 24rpx 24rpx;
2026-06-10 10:15:10 +08:00
margin-bottom: 24rpx;
box-sizing: border-box;
overflow: hidden;
}
2026-06-10 16:23:47 +08:00
/* section内字段间距20rpx通过section-field包装器控制 */
2026-06-11 20:48:30 +08:00
.section-field+.section-field {
2026-06-10 16:23:47 +08:00
padding-top: 12rpx;
}
/* section内字段去掉独立白色背景和底部间距 */
.section-card .form-item,
.section-card .form-item-up {
background-color: transparent;
padding-left: 0;
padding-right: 0;
margin-bottom: 0;
border-radius: 0;
}
.section-card .detail-row {
padding-top: 0;
padding-bottom: 0;
}
.section-title-bar {
padding: 0;
margin: 0;
}
.section-title {
padding: 24rpx 0 20rpx;
font-size: 30rpx;
font-weight: 500;
color: #333;
line-height: 40rpx;
display: block;
}
2026-06-10 10:15:10 +08:00
.detail-row {
2026-06-10 16:23:47 +08:00
padding: 20rpx 0 0;
}
.detail-row:first-child {
padding-top: 0;
2026-06-10 10:15:10 +08:00
}
.detail-row .form-label {
display: block;
margin-bottom: 20rpx;
line-height: 40rpx;
}
.detail-textarea-wrap {
position: relative;
background: #f7f7f7;
border-radius: 16rpx;
padding: 24rpx 24rpx 52rpx;
box-sizing: border-box;
}
.detail-textarea {
width: 100%;
height: 108rpx;
min-height: 108rpx;
margin: 0;
padding: 0;
background: transparent;
border: 0;
font-size: 28rpx;
line-height: 40rpx;
color: #333333;
box-sizing: border-box;
2026-06-09 17:49:15 +08:00
}
2026-06-10 10:15:10 +08:00
.detail-count {
position: absolute;
right: 24rpx;
bottom: 16rpx;
font-size: 22rpx;
color: #999999;
line-height: 32rpx;
2026-06-09 17:49:15 +08:00
}
</style>