380 lines
12 KiB
Vue
380 lines
12 KiB
Vue
<template>
|
||
<!--
|
||
page-dialog-form — 增删改查弹框组件
|
||
===================================
|
||
这是一个「带表单验证的弹框」组件,配合 page-table 使用。
|
||
负责:弹框显隐控制 + 表单渲染 + 表单验证 + 确定/取消按钮。
|
||
|
||
不支持复杂表单联动——如有需要,通过默认插槽自定义内容。
|
||
|
||
依赖:element-ui 的 <el-dialog> <el-form> <el-input> <el-select>
|
||
i18n:组件内部通过 $t() 翻译传入的 i18n key,切换语言时自动响应
|
||
|
||
@author 前端团队
|
||
@since 2026-05
|
||
-->
|
||
|
||
<!--
|
||
el-dialog:
|
||
visible.sync → 通过 .sync 修饰符双向绑定父组件的 visible prop
|
||
destroy-on-close → 每次关闭销毁 DOM 重建,避免表单残留上次数据
|
||
close-on-click-modal → 禁止点击遮罩关闭,防止误操作丢失填写数据
|
||
-->
|
||
<el-dialog
|
||
:visible.sync="visibleProxy"
|
||
:title="$t(title)"
|
||
:width="width"
|
||
:close-on-click-modal="false"
|
||
:destroy-on-close="true"
|
||
@close="onClose"
|
||
>
|
||
<!-- ==================== 表单区 ==================== -->
|
||
<!--
|
||
el-form:
|
||
rules 由父组件传入,字段名与 formData 的 key 一一对应
|
||
label-width 默认 100px,可自定义
|
||
-->
|
||
<el-form
|
||
ref="form"
|
||
:model="formData"
|
||
:rules="translatedRules"
|
||
:label-width="labelWidth || '100px'"
|
||
>
|
||
<!--
|
||
遍历 formCols 中所有行 → 每行中的每个字段 → 渲染对应的表单项
|
||
当前支持两种字段类型:
|
||
- type='input' → <el-input>(支持 textarea、密码等)
|
||
- type='select' → <el-select> + <el-option>
|
||
|
||
由调用方负责翻译,传已翻译的文本即可
|
||
-->
|
||
<el-form-item
|
||
v-for="col in flatFormCols"
|
||
:key="col.prop"
|
||
:label="$t(col.label)"
|
||
:prop="col.prop"
|
||
>
|
||
<!-- ===== 输入框类型 ===== -->
|
||
<!--
|
||
input:
|
||
inputType='textarea' → 多行文本框
|
||
不传 inputType → 普通文本输入框
|
||
clearable → 默认 true,传 false 可关闭
|
||
-->
|
||
<el-input
|
||
v-if="col.type === 'input'"
|
||
v-model="formData[col.prop]"
|
||
:placeholder="$t(col.placeholder)"
|
||
:type="col.inputType || 'text'"
|
||
:show-password="!!col.showPassword"
|
||
:autosize="col.autosize"
|
||
:clearable="col.clearable !== false"
|
||
:disabled="!!col.disabled"
|
||
:style="col.style"
|
||
@focus="handleFieldEvent(col, 'focus', $event)"
|
||
@blur="handleFieldEvent(col, 'blur', $event)"
|
||
/>
|
||
<el-input-number
|
||
v-else-if="col.type === 'input-number'"
|
||
v-model="formData[col.prop]"
|
||
:min="col.min"
|
||
:max="col.max"
|
||
:step="col.step || 1"
|
||
:precision="col.precision"
|
||
:controls-position="col.controlsPosition"
|
||
:disabled="!!col.disabled"
|
||
:style="col.style"
|
||
@change="handleFieldEvent(col, 'change', $event)"
|
||
@focus="handleFieldEvent(col, 'focus', $event)"
|
||
@blur="handleFieldEvent(col, 'blur', $event)"
|
||
/>
|
||
<el-date-picker
|
||
v-else-if="col.type === 'date-picker'"
|
||
v-model="formData[col.prop]"
|
||
:type="col.dateType || 'datetime'"
|
||
:placeholder="$t(col.placeholder)"
|
||
:value-format="col.valueFormat || 'yyyy-MM-dd HH:mm:ss'"
|
||
:format="col.format"
|
||
:clearable="col.clearable !== false"
|
||
:disabled="!!col.disabled"
|
||
:style="col.style"
|
||
@change="handleFieldEvent(col, 'change', $event)"
|
||
@focus="handleFieldEvent(col, 'focus', $event)"
|
||
/>
|
||
<!-- ===== 下拉选择类型 ===== -->
|
||
<!--
|
||
select:
|
||
options → [{ label: '苹果', value: 1 }]
|
||
filterable → 默认 true,支持搜索过滤
|
||
-->
|
||
<el-select
|
||
v-else-if="col.type === 'select'"
|
||
v-model="formData[col.prop]"
|
||
:placeholder="$t(col.placeholder)"
|
||
:clearable="col.clearable !== false"
|
||
:style="col.style"
|
||
:filterable="col.filterable !== false"
|
||
:disabled="!!col.disabled"
|
||
@change="handleFieldEvent(col, 'change', $event)"
|
||
@focus="handleFieldEvent(col, 'focus', $event)"
|
||
>
|
||
<el-option
|
||
v-for="opt in col.options"
|
||
:key="opt.value"
|
||
:label="opt.label"
|
||
:value="opt.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<!-- ===== 自定义表单内容插槽 ===== -->
|
||
<!--
|
||
如果需要超出 input/select 的复杂表单控件
|
||
父组件可以用此插槽添加任意内容
|
||
-->
|
||
<slot />
|
||
</el-form>
|
||
|
||
<!-- ==================== 底部按钮 ==================== -->
|
||
<!--
|
||
确定:type='primary' + submitting loading 状态
|
||
取消:调用 onClose → 重置表单 + 关闭弹框
|
||
由调用方负责翻译,传已翻译的文本即可
|
||
-->
|
||
<template #footer>
|
||
<el-button @click="onClose">{{ $t(cancelText) }}</el-button>
|
||
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ $t(confirmText) }}</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</template>
|
||
|
||
<script>
|
||
/**
|
||
* PageDialogForm — 增删改查弹框组件
|
||
*
|
||
* 【核心职责】
|
||
* 1. 管理弹框显隐
|
||
* 2. 根据 formCols 声明式渲染表单
|
||
* 3. 表单验证 + 验证失败提示
|
||
* 4. 提交/取消事件通知父组件
|
||
*
|
||
* 【典型用法】
|
||
* <page-dialog-form
|
||
* ref="dialogForm"
|
||
* :visible.sync="dialogVisible"
|
||
* :title="dialogTitle"
|
||
* width="35%"
|
||
* :form-cols="formCols"
|
||
* :form-data="formData"
|
||
* :rules="rules"
|
||
* :submitting="submitting"
|
||
* @submit="onDialogSubmit"
|
||
* @close="onDialogClose"
|
||
* />
|
||
*
|
||
* 【父组件调用的方法(通过 ref)】
|
||
* this.$refs.dialogForm.reset() — 重置表单
|
||
* this.$refs.dialogForm.validate() — 手动验证,返回 Promise<boolean>
|
||
*
|
||
* 【formCols 数据结构说明】
|
||
* 二维数组:外层是行,里层是该行的字段。大多数场景每行一个字段即可。
|
||
*
|
||
* 例:
|
||
* formCols: [
|
||
* [ { type: 'input', prop: 'code', label: T+'.code', placeholder: T+'.enter_code' } ],
|
||
* [ { type: 'input', prop: 'remark', label: T+'.remark', inputType: 'textarea', autosize: { minRows: 2 } } ],
|
||
* [ { type: 'select', prop: 'area_id', label: T+'.area', options: [{ label: 'A区', value: 1 }] } ]
|
||
* ]
|
||
*
|
||
* 【表单字段支持的属性】
|
||
* 基础:type / prop / label / placeholder
|
||
* input:inputType('textarea' | 'text')/ autosize / clearable
|
||
* select:options / filterable
|
||
* 通用:style(如 { width: '90%' })
|
||
*
|
||
* 【表单验证(rules)】
|
||
* rules 与 el-form 的 rules 完全一致:
|
||
* rules: {
|
||
* code: [{ required: true, message: '请输入编码', trigger: 'blur' }]
|
||
* }
|
||
* 验证失败时,组件会用 $message.warning 显示第一条错误信息
|
||
* message 字段支持 i18n key,父组件用 this.$t() 传值即可
|
||
*/
|
||
export default {
|
||
name: 'PageDialogForm',
|
||
props: {
|
||
/**
|
||
* 弹框显隐状态,父组件用 .sync 绑定
|
||
* 例::visible.sync="dialogVisible"
|
||
*/
|
||
visible: { type: Boolean, default: false },
|
||
|
||
/**
|
||
* 弹框标题,由调用方负责翻译
|
||
*/
|
||
title: { type: String, default: '' },
|
||
|
||
/**
|
||
* 弹框宽度,可以是百分比或像素
|
||
* 例:'35%' / '600px'
|
||
*/
|
||
width: { type: String, default: '35%' },
|
||
|
||
/**
|
||
* 表单字段结构,二维数组
|
||
* 外层数组每一个元素代表表单的一行
|
||
* 内层数组每个元素代表该行中的一个字段
|
||
*
|
||
* 每个字段对象支持的属性见组件顶部的注释
|
||
*/
|
||
formCols: { type: Array, default: () => [] },
|
||
|
||
/**
|
||
* 表单数据对象,key 与 formCols 中的 prop 一一对应
|
||
* 编辑时父组件将 row 数据赋值给 formData
|
||
*/
|
||
formData: { type: Object, default: () => ({}) },
|
||
|
||
/**
|
||
* 表单验证规则,与 el-form 的 rules 完全一致
|
||
* 例:{ code: [{ required: true, message: '请输入编码', trigger: 'blur' }] }
|
||
*/
|
||
rules: { type: Object, default: () => ({}) },
|
||
|
||
/**
|
||
* 表单 label 宽度,默认 '100px'
|
||
*/
|
||
labelWidth: { type: String, default: '100px' },
|
||
|
||
/**
|
||
* 提交 loading 状态,提交期间显示转圈防止重复点击
|
||
*/
|
||
submitting: { type: Boolean, default: false },
|
||
|
||
/**
|
||
* 确定按钮文字,默认 '确定',由调用方负责 i18n 翻译
|
||
*/
|
||
confirmText: { type: String, default: '确定' },
|
||
|
||
/**
|
||
* 取消按钮文字,默认 '取消',由调用方负责 i18n 翻译
|
||
*/
|
||
cancelText: { type: String, default: '取消' }
|
||
},
|
||
computed: {
|
||
/**
|
||
* visible 代理:用于 .sync 双向绑定
|
||
* get → 返回父组件传入的 visible
|
||
* set → emit update:visible 通知父组件
|
||
*/
|
||
visibleProxy: {
|
||
get () { return this.visible },
|
||
set (val) { this.$emit('update:visible', val) }
|
||
},
|
||
|
||
/**
|
||
* 将二维 formCols 打平为一维数组,方便 v-for 遍历
|
||
* 二维数组 → 一维数组:[ [{a}], [{b},{c}] ] → [a, b, c]
|
||
*/
|
||
flatFormCols () {
|
||
return this.formCols.flat()
|
||
},
|
||
|
||
translatedRules () {
|
||
const rules = this.rules || {}
|
||
const translated = {}
|
||
for (const [field, validators] of Object.entries(rules)) {
|
||
translated[field] = validators.map(v => {
|
||
const rule = { ...v }
|
||
if (rule.message) {
|
||
rule.message = this.$t(rule.message)
|
||
}
|
||
return rule
|
||
})
|
||
}
|
||
return translated
|
||
}
|
||
},
|
||
methods: {
|
||
handleFieldEvent (col, eventName, value) {
|
||
if (!col) return
|
||
const handlerKey = 'on' + eventName.charAt(0).toUpperCase() + eventName.slice(1)
|
||
const handler = col[handlerKey]
|
||
if (typeof handler === 'function') {
|
||
handler(value, col, this.formData)
|
||
}
|
||
},
|
||
/**
|
||
* 点击确定按钮
|
||
* 1. 调用 el-form 的 validate 方法验证所有字段
|
||
* 2. 验证通过 → emit('submit') 通知父组件执行提交逻辑
|
||
* 3. 验证失败 → 用 $message.warning 显示第一条错误信息
|
||
*
|
||
* 注意:rules 中的 message 由父组件传入时已用 $t() 翻译
|
||
* 这里只负责显示,翻译在父组件或 i18n 语言包中完成
|
||
*/
|
||
onSubmit () {
|
||
this.$refs.form.validate((valid, invalidFields) => {
|
||
if (!valid) {
|
||
const firstKey = Object.keys(invalidFields)[0]
|
||
if (firstKey && invalidFields[firstKey].length) {
|
||
const msg = invalidFields[firstKey][0].message
|
||
this.$message.warning(msg)
|
||
}
|
||
return
|
||
}
|
||
this.$emit('submit')
|
||
})
|
||
},
|
||
|
||
/**
|
||
* 关闭弹框
|
||
* 1. 重置表单清除验证状态和输入内容
|
||
* 2. emit update:visible 关闭弹框(.sync 机制)
|
||
* 3. emit close 通知父组件执行清理逻辑
|
||
*/
|
||
onClose () {
|
||
this.$refs.form && this.$refs.form.resetFields()
|
||
this.$emit('update:visible', false)
|
||
this.$emit('close')
|
||
},
|
||
|
||
/**
|
||
* 手动验证表单,返回 Promise<boolean>
|
||
* 主要用于父组件需要自行控制验证时机的场景
|
||
*
|
||
* 用法:const ok = await this.$refs.dialogForm.validate()
|
||
*/
|
||
validate () {
|
||
return new Promise(resolve => {
|
||
this.$refs.form.validate(valid => resolve(valid))
|
||
})
|
||
},
|
||
|
||
/**
|
||
* 重置表单(清除验证状态和输入值)
|
||
* 通常在打开新增弹框时调用
|
||
*/
|
||
reset () {
|
||
this.$refs.form && this.$refs.form.resetFields()
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 表单项间距固定 22px,不受 Element UI 状态覆盖 */
|
||
/deep/ .el-form-item {
|
||
margin-bottom: 22px !important;
|
||
}
|
||
/* 错误文字下沉到 margin 间隙中,不挤占表单项自身高度 */
|
||
/deep/ .el-form-item__error {
|
||
position: absolute;
|
||
top: auto;
|
||
bottom: -18px;
|
||
}
|
||
/* textarea 对齐顶部 */
|
||
/deep/ .el-textarea {
|
||
vertical-align: top;
|
||
}
|
||
</style>
|