Files
lucky_shop/components-diy/diy-form.vue
2026-08-28 11:58:23 +08:00

437 lines
18 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 data-component-name="diy-form" class="diy-from" :style="diyFormStyle">
<!-- Banner -->
<!-- 鸿蒙元服务运行时在首次 _render 时 value 可能还是 undefined,
下面所有 value.xxx 模板表达式都必须用 `value && value.xxx` 守护,
否则会触发 MP 编译器注入的 propsToData 观察者(对应编译产物里
r=function(){var o=this.value.show_banner...this.$mp.data...})
报 "Cannot read properties of undefined (reading 'show_banner')"。 -->
<view v-if="value && value.show_banner && value.form_banner" class="form-banner" :style="bannerStyle">
<image :src="$util.img(value.form_banner)" mode="widthFix" class="banner-img" />
</view>
<!-- 提示语 -->
<view v-if="value && value.show_tip && value.form_tip" class="form-tip" :style="tipStyle">{{ value && value.form_tip }}</view>
<!-- 表单组件 -->
<ns-newform
:data="formComponents"
:customAttr="value"
:mode="mergedFormConfig.formLayout || 'vertical'"
:formConfig="mergedFormConfig"
ref="nsNewform"
></ns-newform>
<!-- 提交按钮(外层 = form-bg + form-padding-x;按钮 = submit-bg + submit-color + input-height + input-radius)。
showSubmit=false 时由父级页面渲染自己的提交按钮并通过 ref 调用本组件的 verify()/getFormData()。 -->
<view v-if="showSubmit" class="form-submit-wrap" :style="submitWrapStyle">
<view @click.stop="handleSubmit" class="form-submit" :style="submitStyle">{{ submitText }}</view>
</view>
</view>
</template>
<script>
// 自定义表单
// 提交接管三档(不依赖 $listeners / 事件机制,跨 H5 / MP / 鸿蒙一致):
// 1) 默认 → showSubmit=true + 不传 onSubmit → 内置按钮 + 内置 defaultSubmit
// 2) 复用按钮、自定义逻辑 → showSubmit=true + :onSubmit="fn" → 内置按钮 + 父级 fn
// 3) 完全自定义 UI → showSubmit=false + :onSubmit="fn" 或 ref.verify()/getFormData() → 父级全控
// 注:type: Function prop 在 uni-app MP 端走 Vue runtime 注入,机制与 ns-navbar.customBack 一致
import DiyMinx from './minx.js'
import NsNewform from '@/components/ns-newform/ns-newform.vue'
export default {
name: 'diy-from',
components: { NsNewform },
props: {
value: {
type: Object
},
// 数据源模式:
// 'diy'(默认):value.formComponents 是 [{ type, value }],组件内部转换为 ns-newform 期望的 [{ controller, value, val }]
// 'raw' :value.jsonData / value.json_data 已经是 ns-newform 期望的格式,直接透传给 ns-newform
// 用途:让 formdata 等"已是 ns-newform 格式"的页面也能复用本组件的 banner/tip/样式系统
dataMode: {
type: String,
default: 'diy',
validator: (v) => ['diy', 'raw'].includes(v)
},
// 是否渲染内置提交按钮。false 时由父级调用 ref 上的 verify()/getFormData() 自行实现提交
showSubmit: {
type: Boolean,
default: true
},
// 父级可选地接管提交:传入函数 prop 时,verify 通过后由它处理 formData;
// 不传时走组件内置 defaultSubmit(兜底)。default 必须是 null,不能写成函数返回 null
onSubmit: {
type: Function,
default: null
}
},
mixins: [DiyMinx],
data() {
return {
formComponents: [] // 转换后的表单组件
};
},
created() {
this.initForm();
},
watch: {
'value': {
deep: true,
handler() {
// raw 模式:formData 由父级透传,跳过重跑 initForm(避免每次父组件 re-render 触发 ns-newform 的深拷贝 initFormData)
if (this.dataMode === 'raw') return;
this.initForm();
}
}
},
computed: {
style() {
// 鸿蒙元服务运行时首次 _render 时 this.value 可能还是 undefined,必须全程加护
var css = '';
var v = this.value || {};
css += 'background-color:' + (v.contentBgColor || '') + ';';
if (v.elementAngle === 'round') {
css += 'border-top-left-radius:' + (v.topElementAroundRadius || 0) * 2 + 'rpx;';
css += 'border-top-right-radius:' + (v.topElementAroundRadius || 0) * 2 + 'rpx;';
css += 'border-bottom-left-radius:' + (v.bottomElementAroundRadius || 0) * 2 + 'rpx;';
css += 'border-bottom-right-radius:' + (v.bottomElementAroundRadius || 0) * 2 + 'rpx;';
}
return css;
},
// 兼容旧 style 别名(部分页面用 :style="style" 引用)
diyFormStyle() {
return this.style;
},
// Banner 容器左右内边距(与后端 .form-preview-banner padding-x 对齐)
bannerStyle() {
return this.$util.objToStyle({
padding: `0 ${this._num(this.mergedFormConfig && this.mergedFormConfig.bannerPaddingX, 0)}rpx`
});
},
// 提示语样式:padding-x、font-size、color、bg-color、border-bottom(与后端 .form-preview-tip 对齐)
tipStyle() {
const cfg = this.mergedFormConfig || {};
const style = {
padding: `${this._num(cfg.rowPaddingY, 4)}rpx ${this._num(cfg.tipPaddingX, 14)}rpx`,
'font-size': `${this._num(cfg.tipFontSize, 32)}rpx`,
'color': cfg.tipColor || '#000000',
'background-color': cfg.tipBgColor || 'transparent'
};
// 仅在配置了 tipBorderColor 时添加底线,避免 transparent 占位仍增加 2rpx 高度
if (cfg.tipBorderColor) {
style['border-bottom'] = `2rpx solid ${cfg.tipBorderColor}`;
}
return this.$util.objToStyle(style);
},
// 提交按钮外层:与后端 .form-preview-submit 对齐(margin-top + padding: row-padding-y form-padding-x;background: form-bg-color)
submitWrapStyle() {
const cfg = this.mergedFormConfig || {};
return this.$util.objToStyle({
marginTop: `${this._num(cfg.submitMarginTop, 0)}rpx`,
padding: `${this._num(cfg.rowPaddingY, 4)}rpx ${this._num(cfg.formPaddingX, 14)}rpx`,
'background-color': cfg.formBgColor || '#ffffff'
});
},
// 提交按钮样式:颜色 / 背景色 / 宽度(百分比)/ 上下内边距(控制高度)/ 圆角 / 字号
// 与后端 design.css .form-preview-submit button 对齐:
// - 高度由 --submit-padding-y + 字号决定,避免硬编码 line-height
// - 宽度由 --submit-width-percent 控制(10-100,clamp)
submitStyle() {
const cfg = this.mergedFormConfig || {};
// 宽度百分比:clamp 到 10-100
const widthPct = Math.min(100, Math.max(10, this._num(cfg.submitWidthPercent, 100)));
// 上下内边距:与后端 design.js submitSizeMap 对齐(mini/small/medium/large/huge → 0/4/12/24/40 rpx)
const submitPaddingMap = { mini: 0, small: 4, medium: 12, large: 24, huge: 40 };
const submitPaddingY = submitPaddingMap[cfg.submitSize] != null
? submitPaddingMap[cfg.submitSize]
: submitPaddingMap.medium;
return this.$util.objToStyle({
width: widthPct + '%',
padding: `${submitPaddingY}rpx 0`,
color: cfg.submitColor || '#ffffff',
'background-color': cfg.submitBgColor || '#3b82f6',
'border-radius': `${this._num(cfg.inputRadius, 10)}rpx`,
'font-size': `${this._num(cfg.labelFontSize, 28)}rpx`
});
},
// 提交按钮文字:与后端 design.html <button>提交</button> 默认对齐,开放给 formConfig 覆盖
// 走 mergedFormConfig(兼容 camelCase / snake_case),让 formConfig.submitText 能正确覆盖
submitText() {
return this.mergedFormConfig.submitText || '提交';
},
// 父级是否接管提交(集中判断,便于以后替换机制;避免多个调用点各自 typeof 校验)
hasCustomSubmit() {
return typeof this.onSubmit === 'function';
},
// 后端把表单样式配置统一存放在 data.formConfig 子对象中(与后端 design.js getDefaultFormData().formConfig 字段名一致)。
// 这里做一次归一化:优先取 formConfig;如果历史数据是扁平结构(直接挂在 value 上),也做兼容。
formCfg() {
const v = this.value || {};
if (v.formConfig && typeof v.formConfig === 'object') {
return v.formConfig;
}
return v;
},
// 合并后的表单配置(支持平台差异化)
mergedFormConfig() {
const v = this.value || {};
const fc = this.formCfg;
// 后端实际返回 camelCase(如 formPaddingX / formBgColor / labelFontSize),
// 但旧版 design.js / 部分序列化路径可能用 snake_case(form_padding_x)。这里同时读两种写法,camelCase 优先
const self = this;
const pick = function(camel, snake) {
var v1 = fc[camel];
if (v1 !== undefined && v1 !== null && v1 !== '') return v1;
return fc[snake];
};
// 通用全局配置(与后端 design.js getDefaultFormData().formConfig 字段名一致)
const base = {
formPaddingX: pick('formPaddingX', 'form_padding_x'),
bannerPaddingX: pick('bannerPaddingX', 'banner_padding_x'),
tipPaddingX: pick('tipPaddingX', 'tip_padding_x'),
rowPaddingY: pick('rowPaddingY', 'row_padding_y'),
rowMinHeight: pick('rowMinHeight', 'row_min_height'),
labelGap: pick('labelGap', 'label_gap'),
formBgColor: pick('formBgColor', 'form_bg_color'),
rowDividerColor: pick('rowDividerColor', 'row_divider_color'),
showRowDivider: self._coerceBool(pick('showRowDivider', 'show_row_divider')),
inputHeight: pick('inputHeight', 'input_height'),
inputRadius: pick('inputRadius', 'input_radius'),
formLayout: pick('formLayout', 'form_layout'),
labelWidthMode: pick('labelWidthMode', 'label_width_mode'),
labelFixedWidth: pick('labelFixedWidth', 'label_fixed_width'),
labelFontSize: pick('labelFontSize', 'label_font_size'),
labelColor: pick('labelColor', 'label_color'),
labelFontWeight: pick('labelFontWeight', 'label_font_weight'),
labelAutoMaxWidth: pick('labelAutoMaxWidth', 'label_auto_max_width'),
requiredColor: pick('requiredColor', 'required_color'),
checkedColor: pick('checkedColor', 'checked_color'),
// 占位符:遗漏会被 ns-newform SCSS 默认值回退覆盖
placeholderColor: pick('placeholderColor', 'placeholder_color'),
placeholderFontSize: pick('placeholderFontSize', 'placeholder_font_size'),
// textarea 显示行数 / 行高(透传给 ns-newform,与 ns-form 保持一致)
// 不传时 ns-newform 走自己的默认 4 行 / 44rpx
textareaRows: pick('textareaRows', 'textarea_rows'),
textareaLineHeight: pick('textareaLineHeight', 'textarea_line_height'),
// 输入框视觉增强(与 ns-newform 对齐)
inputBorderColor: pick('inputBorderColor', 'input_border_color'),
inputBgColor: pick('inputBgColor', 'input_bg_color'),
inputFocusBorderColor: pick('inputFocusBorderColor', 'input_focus_border_color'),
inputFocusBgColor: pick('inputFocusBgColor', 'input_focus_bg_color'),
submitText: pick('submitText', 'submit_text'),
submitColor: pick('submitColor', 'submit_color'),
submitBgColor: pick('submitBgColor', 'submit_bg_color'),
// 提交按钮尺寸:宽度百分比 + 尺寸预设(与后端 design.js 对齐)
submitWidthPercent: pick('submitWidthPercent', 'submit_width_percent'),
submitSize: pick('submitSize', 'submit_size'),
submitMarginTop: pick('submitMarginTop', 'submit_margin_top'),
// 提示语
tipFontSize: pick('tipFontSize', 'tip_font_size'),
tipColor: pick('tipColor', 'tip_color'),
tipBgColor: pick('tipBgColor', 'tip_bg_color'),
tipBorderColor: pick('tipBorderColor', 'tip_border_color')
};
// 移除 undefined / 空字符串(保留 0/false)
Object.keys(base).forEach(function(k) {
if (base[k] === undefined || base[k] === null || base[k] === '') {
delete base[k];
}
});
// 前端视觉默认:强制覆盖后端 formConfig 中可能存在的旧默认(horizontal / 红按钮 / 灰背景等),
// 与 formdata.vue 的 styleDefaults 保持一致,使所有 diy-form(含 diy-group 内的表单)统一呈现新设计稿样式。
// 后端仍保留行为/文案类配置(behavior / submitText / banner / tip 等),本合并仅覆盖视觉字段。
const styleDefaults = {
formLayout: 'vertical',
formPaddingX: 28,
rowPaddingY: 24,
labelGap: 20,
labelFontSize: 32,
labelFontWeight: 700,
labelColor: '#1f2937',
inputHeight: 84,
inputRadius: 16,
inputBgColor: '#ffffff',
inputBorderColor: '#e5e7eb',
inputFocusBorderColor: '#3b82f6',
inputFocusBgColor: '#ffffff',
placeholderColor: '#9ca3af',
placeholderFontSize: 28,
checkedColor: '#3b82f6',
showRowDivider: false,
submitColor: '#ffffff',
submitBgColor: '#3b82f6'
};
// 平台差异化配置(terminal 块在 formConfig 子对象内)
// 优先级:v.formConfig.terminal > v.form_config_terminal > v.terminal > v.form_config_platform
const result = Object.assign({}, base, styleDefaults);
const termBlock = (v.formConfig && v.formConfig.terminal) || v.form_config_terminal || v.terminal || v.form_config_platform;
if (termBlock && typeof termBlock === 'object') {
Object.keys(termBlock).forEach(function(platform) {
var src = termBlock[platform];
if (!src) return;
// 嵌套值若为 snake_case(部分序列化路径),在此统一转 camelCase
var translated = {};
Object.keys(src).forEach(function(k) {
translated[self.$util.snakeToCamel(k)] = src[k];
});
result[platform] = translated;
});
}
return result;
}
},
methods: {
// 初始化表单 - 将 diy-form 的数据格式转换成 ns-newform 的格式
initForm() {
if (!this.value) {
this.formComponents = [];
return;
}
// raw 模式:value.jsonData / value.json_data 已经是 ns-newform 期望的 [{ controller, value, val }],直接透传
if (this.dataMode === 'raw') {
this.formComponents = this.value.jsonData || this.value.json_data || [];
return;
}
var components = this.value.formComponents || this.value.form_components || [];
// 转换格式:type -> controller
this.formComponents = (components || []).map(function(item) {
return {
controller: item.type,
value: item.value || {},
val: (item.value && item.value.default) ? item.value.default : '' // 添加初始值
};
});
},
// 暴露给父级:触发 ns-newform 校验。校验失败时 ns-newform 内部会 toast;返回 false 表示失败,返回 formData 数组表示成功
verify() {
if (this.$refs.nsNewform && this.$refs.nsNewform.verify) {
return this.$refs.nsNewform.verify();
}
return false;
},
// 暴露给父级:获取 ns-newform 当前的 formData 数组(含 val / option_lists / img_lists 等全部字段)
getFormData() {
return (this.$refs.nsNewform && this.$refs.nsNewform.formData) || [];
},
// 数字保底:undefined/空串/非数字时返回兜底值
_num(v, fb) {
if (v === '' || v === null || v === undefined) return fb;
var n = parseInt(v);
return isNaN(n) ? fb : n;
},
// 布尔保底:0/false/'0'/'false' → false;其它有值 → true
_coerceBool(v) {
if (v === false || v === 0 || v === '0' || v === 'false' || v === null || v === undefined) {
return false;
}
return true;
},
// 统一提交入口:内置按钮 / 父级 ref 调用都走这里
// 不依赖 $listeners / 事件机制(MP 端 $listeners 为 undefined),跨端一致
handleSubmit() {
// 校验(同时返回 formData 数组;校验失败时 ns-newform 内部 toast 提示)
if (!this.verify()) {
return;
}
const formData = this.getFormData();
// 父级显式接管:function prop 在 H5 / MP / 鸿蒙一致可用
if (this.hasCustomSubmit) {
return this.onSubmit(formData);
}
// 兜底:内置 sendRequest 流程
this.defaultSubmit(formData);
},
// 公共兜底实现:默认的 /form/api/form/create 流程;
// 父级若需"先自定义、失败回退",可经 ref.defaultSubmit(formData) 调用
defaultSubmit(submitFormData) {
var self = this;
this.$api.sendRequest({
url: '/form/api/form/create',
data: {
form_id: this.value.form_id || 0,
form_data: typeof submitFormData === 'string' ? submitFormData : JSON.stringify(submitFormData),
component_id: this.value.id || '',
form_name: this.value.form_name || '默认表单'
},
success: function(res) {
if (res.code === 0) {
uni.showToast({
title: self.$lang('submitSuccess'),
icon: 'success'
});
// 重置表单
if (self.$refs.nsNewform && self.$refs.nsNewform.setFormData) {
self.$refs.nsNewform.setFormData();
}
} else {
uni.showToast({
title: res.message || self.$lang('submitFail'),
icon: 'none'
});
}
}
});
}
}
};
</script>
<style lang="scss">
.diy-from {
background: #fff;
padding: 0;
border-radius: 10rpx;
.form-banner {
width: 100%;
overflow: hidden;
// 默认左右无边距(与后端 --banner-padding-x 对齐;由内联 bannerStyle 覆盖)
margin: 0;
.banner-img {
width: 100%;
display: block;
}
}
.form-tip {
// 提示语:上下内边距 / 左右内边距 / 字号 / 颜色 / 背景 全部由 tipStyle 内联写入
padding: 0;
text-align: center;
line-height: 1.5;
}
.form-submit-wrap {
// 外层:与后端 .form-preview-submit 对齐(margin-top + padding-y + form-padding-x + form-bg-color + flex 居中)
// margin-top/padding/background 由 submitWrapStyle 内联写入
// display: flex + justify-content: center 是让宽度小于 100% 的按钮水平居中的关键
box-sizing: border-box;
width: 100%;
display: flex;
justify-content: center;
}
.form-submit {
// 提交按钮:颜色 / 背景色 / 宽度(百分比)/ 上下内边距(控制高度)/ 圆角 / 字号
// 全部由 submitStyle 内联写入
// 宽度不再写死 100%,由内联 style 的 width: <pct>% 决定;max-width: 100% 防止溢出
display: flex;
align-items: center;
justify-content: center;
max-width: 100%;
font-weight: 500;
letter-spacing: 1rpx;
transition: opacity 0.2s ease;
box-sizing: border-box;
&:active {
opacity: 0.85;
}
}
}
</style>