Files
lucky_shop/components-diy/diy-form.vue
ZF sun 5831189e5e fix(diy-form): 修复鸿蒙元服务下value未定义导致的报错
针对鸿蒙元服务运行时首次渲染时value可能为undefined的情况,为所有模板表达式和计算属性添加空值守护,避免触发Cannot read properties of undefined错误
2026-07-18 14:51:42 +08:00

385 lines
15 KiB
Vue
Raw Permalink 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 || 'horizontal'"
: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="submitForm" class="form-submit" :style="submitStyle">{{ submitText }}</view>
</view>
</view>
</template>
<script>
// 自定义表单
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
}
},
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 || '#ff4544',
'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 || '提交';
},
// 后端把表单样式配置统一存放在 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'),
requiredColor: pick('requiredColor', 'required_color'),
checkedColor: pick('checkedColor', 'checked_color'),
// 占位符:遗漏会被 ns-newform SCSS 默认值回退覆盖
placeholderColor: pick('placeholderColor', 'placeholder_color'),
placeholderFontSize: pick('placeholderFontSize', 'placeholder_font_size'),
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];
}
});
// 平台差异化配置(terminal 块在 formConfig 子对象内)
// 优先级:v.formConfig.terminal > v.form_config_terminal > v.terminal > v.form_config_platform
const result = Object.assign({}, base);
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;
},
// 提交表单
submitForm() {
var self = this;
// 校验(同时返回 formData 数组;校验失败时 ns-newform 内部 toast 提示)
if (!this.verify()) {
return;
}
// 若父级监听了 @submit,则把提交控制权交给父级(用于父级有自定义提交字段/反馈逻辑的页面,如 formdata.vue 传 uniacid)
if (this.$listeners && this.$listeners.submit) {
this.$emit('submit', this.getFormData());
return;
}
// 获得保单数据
const submitFormData = this.getFormData();
// 提交到后端
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: '提交成功',
icon: 'success'
});
// 重置表单
if (self.$refs.nsNewform && self.$refs.nsNewform.setFormData) {
self.$refs.nsNewform.setFormData();
}
} else {
uni.showToast({
title: res.message || '提交失败',
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>