13. 表单、校验与弹窗
一句话定位
项目里表单只有一套写法:el-form + label-width="auto" + 24 栅格,规则用 formRule.ts 预设,弹窗/抽屉有固定的五件套属性。照抄即可。
结构约定
弹窗组件 component/editXxx.vue
├─ <el-dialog v-model="state.isShowDialog" draggable overflow destroy-on-close width="700px">
│ ├─ #header → 白字标题(<div style="color:#fff">图标 + {{ props.title }}</div>)
│ ├─ default → <el-form label-width="auto"> + <el-row :gutter="10"> + <el-col>
│ └─ #footer → 取消(ele-CircleCloseFilled)/ 确定(ele-CircleCheckFilled)
└─ script setup
├─ defineProps({ title })
├─ defineEmits(['updateData'])
├─ openDialog(row) → 深拷贝 + resetFields
├─ submit() → validate → add/update → closeDialog()
└─ defineExpose({ openDialog })一、el-form 布局约定
<el-form :model="state.ruleForm" ref="ruleFormRef" label-width="auto">
<el-row :gutter="10">
<!-- 整行 -->
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
<el-form-item label="配置名称" prop="name" :rules="[{ required: true, message: '配置名称不能为空', trigger: 'blur' }]">
<el-input v-model="state.ruleForm.name" placeholder="配置名称" clearable />
</el-form-item>
</el-col>
<!-- 半行 -->
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="排序">
<el-input-number v-model="state.ruleForm.orderNo" placeholder="排序" class="w100" />
</el-form-item>
</el-col>
</el-row>
</el-form>| 约定 | 说明 |
|---|---|
label-width="auto" | 全站统一,不写死 120px |
el-row :gutter="10" | 统一 10px 间距 |
| 单列 | :xs="24" :sm="24" :md="24" :lg="24" :xl="24" |
| 双列 | :xs="24" :sm="12" :md="12" :lg="12" :xl="12" |
| 下边距 | 表单区用 class="mb20",查询区用 class="mb5" |
| 满宽控件 | class="w100" |
| 输入类控件 | 一律带 clearable |
| 数字 | 用 el-input-number + class="w100",不要用 el-input + type="number" |
查询区(inline 风格)则是 el-form + :show-message="false" :inlineMessage="true" label-width="auto" style="flex: 1 1 0%",栅格 :xs="24" :sm="12" :md="8" :lg="6" :xl="6"。
二、formRule 预设
代码位置:src/utils/formRule.ts,导出 useFormRulePresets(),返回两类函数:
A. 规则对象(放进 :rules 数组)
| 函数 | 说明 | 示例 |
|---|---|---|
required(label?, trigger?) | 必填 | required('姓名') |
requiredIf(cond?, label?, trigger?) | 静态条件必填(不随数据变化重校验) | requiredIf(isVip, '手机号') |
validatorIf(shouldError, formRef?, label?) | 动态条件校验,watch 条件变化自动 validateField | validatorIf(() => type === 1, ruleFormRef, '原因') |
maxLen(len, label?, trigger?) / minLen(...) | 长度 | maxLen(50, '名称') |
pattern(re, msg, trigger?) | 正则 | pattern(/^\d+$/, '只能数字') |
validator(fn, trigger?) | 通用自定义:true 通过 / string 为错误文案,支持 Promise | validator(v => v > 0 ? true : '必须大于0') |
phone() | 手机号 / 固定电话 | phone() |
idCard(shouldValidate?) | 身份证(15/18 位,含 MOD 11-2 校验码) | idCard(() => state.ruleForm.cardType === 0) |
email() | 邮箱 | email() |
digits(label?, trigger?) | 纯数字 | digits('数量') |
intRange(min, max, label?) | 整数范围 | intRange(1, 999, '排序') |
confirmSame(getOther, label?) | 两次输入一致 | confirmSame(() => state.ruleForm.password, '两次密码') |
dateAfterToday(label?, trigger?) / dateBeforeToday(...) | 日期相对今天 | dateAfterToday('生效日期') |
numberPrecision(maxInt, maxFrac, label?) | 数值精度(整数位/小数位) | numberPrecision(8, 2, '金额') |
B. 直接弹错函数(非表单场景,返回 boolean)
validateRequired / validateNumber / validateIntRange / validateChinese / validateLength / validateEmail / validatePhone / validateIdCard / validatePattern / validateMultiple / validateField(value, type, fieldName, options) / validateDecimalPrecision。
// 按钮点击前手工校验
if (!validateRequired(state.keyword, '关键字')) return;
if (!validateIdCard(state.ruleForm.idCardNum, '证件号码')) return;真实用法(views/system/user/component/editUser.vue)
<el-form-item :label="'证件号码'" prop="idCardNum" :rules="[useFormRulePresets().idCard(() => state.ruleForm.cardType === 0)]">import { useFormRulePresets } from '/@/utils/formRule';⚠️ 上面在模板里每次渲染都新建一个 presets 对象。推荐在 setup 里解构一次再用到模板:
const { required, idCard, phone } = useFormRulePresets();然后
:rules="[required('姓名'), phone()]".
三、自定义校验
简单:用 validator 工厂
<el-form-item label="折扣" prop="discount" :rules="[validator((v) => (v > 0 && v <= 1 ? true : '折扣需在 0~1 之间'))]">复杂:直接写 validator 函数
<el-form-item label="编码" prop="code" :rules="[{ required: true, message: '编码不能为空', trigger: 'blur' }, { validator: validateCode, trigger: 'blur' }]">const validateCode = (_rule: any, value: any, callback: any) => {
if (!value) return callback();
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(value)) return callback(new Error('编码需字母开头,仅含字母数字下划线'));
// 异步唯一性校验
getAPI(SysDemoApi).apiSysDemoCodeExistGet(value).then((res) => {
res.data.result ? callback(new Error('编码已存在')) : callback();
});
};动态条件必填(字段联动)
const { validatorIf } = useFormRulePresets();
// 当「类型 = 其他」时,原因必填;条件变化会自动重新校验该字段
const reasonRule = validatorIf(() => state.ruleForm.type === 99, ruleFormRef, '原因');四、弹窗标准写法(五件套)
<el-dialog v-model="state.isShowDialog" draggable overflow destroy-on-close width="700px">| 属性 | 作用 |
|---|---|
draggable | 可拖拽 |
overflow | 内容超出时弹窗内滚动,而不是撑爆 |
destroy-on-close | 关闭即销毁,避免残留校验态与旧数据 |
width | 常用 700px;复杂表单可用 60% |
v-model | 由 state.isShowDialog 控制 |
白字标题 #header
<template #header>
<div style="color: #fff">
<el-icon size="16" style="margin-right: 3px; display: inline; vertical-align: middle"> <ele-Edit /> </el-icon>
<span> {{ props.title }} </span>
</div>
</template>页脚
<template #footer>
<span class="dialog-footer">
<el-button icon="ele-CircleCloseFilled" @click="cancel">取消</el-button>
<el-button type="primary" icon="ele-CircleCheckFilled" @click="submit">确定</el-button>
</span>
</template>(页脚的分隔线与内边距由 theme/page.scss 的 .el-dialog__footer 统一提供,不用自己写。)
完整 script
const props = defineProps({ title: String });
const emits = defineEmits(['updateData']);
const ruleFormRef = ref();
const state = reactive({ isShowDialog: false, ruleForm: {} as UpdateDemoInput });
const openDialog = (row: any) => {
state.ruleForm = JSON.parse(JSON.stringify(row)); // 深拷贝,避免改到表格行
state.isShowDialog = true;
ruleFormRef.value?.resetFields();
};
const closeDialog = () => { emits('updateData'); state.isShowDialog = false; };
const cancel = () => { state.isShowDialog = false; };
const submit = () => {
ruleFormRef.value.validate(async (valid: boolean) => {
if (!valid) return;
if (state.ruleForm.id != undefined && state.ruleForm.id > 0) {
await getAPI(SysDemoApi).apiSysDemoUpdatePost(state.ruleForm);
} else {
await getAPI(SysDemoApi).apiSysDemoAddPost(state.ruleForm);
}
closeDialog();
});
};
defineExpose({ openDialog });父组件:
<EditDemo ref="editDemoRef" :title="state.title" @updateData="updateData" />
<script lang="ts" setup>
const editDemoRef = ref<InstanceType<typeof EditDemo>>();
const handleAdd = () => { state.title = '添加示例'; editDemoRef.value?.openDialog({ status: 1, orderNo: 100 }); };
const updateData = async () => { await handleQuery(); };
</script>五、抽屉(el-drawer)
适配合适场景:数据授权、预览、批量配置等较宽的内容区。
<el-drawer v-model="state.isVisible" size="55%" destroy-on-close>
<template #header>
<div style="color: #fff"><span>{{ props.title }}</span></div>
</template>
<!-- 内容 -->
<template #footer>
<el-button icon="ele-CircleCloseFilled" @click="cancel">取消</el-button>
<el-button type="primary" icon="ele-CircleCheckFilled" @click="submit">确定</el-button>
</template>
</el-drawer>项目内常见尺寸:size="40%"(授权类)、size="55%"(菜单/数据表授权)、size="60%" / "65%"(预览/集群)、size="min(820px, 100%)"(添加应用)。
抽屉内容区默认 padding: 16px、页脚 12px 16px + 顶部分隔线,由 theme/page.scss 统一。
六、复杂表单
6.1 分步(el-steps)
真实用法:views/document/components/DocumentUploadDialog.vue
<el-steps :active="activeStep" finish-status="success" simple class="upload-steps">
<el-step title="选择文件" />
<el-step title="填写信息" />
<el-step title="完成" />
</el-steps>
<!-- 每一步只校验当前段的字段 -->
<el-form ref="ruleFormRef" :model="state.ruleForm" label-width="auto">
<template v-if="activeStep === 0"> ... </template>
<template v-else-if="activeStep === 1"> ... </template>
</el-form>const next = async () => {
const fields = activeStep.value === 0 ? ['name'] : ['type', 'remark'];
await ruleFormRef.value.validateField(fields).catch(() => {});
activeStep.value += 1;
};6.2 动态表单项(子表增删)
用 components/table/tableEditor.vue:
<TableEditor
v-model:value="state.ruleForm.items"
:columns="[{ field: 'name', title: '名称' }, { field: 'qty', title: '数量' }]"
@add="onAdd" @delete="onDelete"
/>或手写 v-for:
<el-row v-for="(item, index) in state.ruleForm.items" :key="index" :gutter="10">
<el-col :span="10">
<el-form-item :prop="`items.${index}.name`" :rules="[{ required: true, message: '名称不能为空', trigger: 'blur' }]">
<el-input v-model="item.name" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-button icon="ele-Delete" type="danger" text @click="state.ruleForm.items.splice(index, 1)" />
</el-col>
</el-row>⚠️ 动态项的 prop 必须是 items.${index}.name 这种路径写法,否则校验不生效。
6.3 带提示的 label
<el-form-item prop="value" :rules="[{ required: true, message: '值不能为空', trigger: 'blur' }]">
<template v-slot:label>
<div>
值
<el-tooltip raw-content content="公匙和私匙之间用英文分号';'分隔" placement="top">
<el-icon size="16" style="margin-right: 3px; display: inline; vertical-align: middle"><ele-QuestionFilled /></el-icon>
</el-tooltip>
</div>
</template>
<el-input v-model="state.ruleForm.value" type="textarea" :rows="5" />
</el-form-item>七、步骤清单
- 弹窗文件放
component/editXxx.vue,name取sysEditXxx。 el-form→label-width="auto"→el-row :gutter="10"→el-col栅格。- 规则:内置用
useFormRulePresets()(setup 里解构),特殊的写 validator。 openDialog(row)深拷贝 +resetFields();submit()里ruleFormRef.validate()。- 新增/编辑分支:
id != undefined && id > 0才是编辑。 defineExpose({ openDialog });父组件靠@updateData刷新列表。
⚠️ 注意事项 / 常见坑
- 一定要深拷贝:
JSON.parse(JSON.stringify(row)),否则改弹窗会实时改到表格行。 id判断别漏 0:if (state.ruleForm.id)会把id === 0当新增;用id != undefined && id > 0。resetFields()要在赋值之后调用,否则会把刚赋的值清掉 —— 项目里的顺序是「先赋值 → 再isShowDialog = true→ 再resetFields()」。destroy-on-close不能省,否则第二次打开残留上一次的校验红字。- 接口默认失败不抛异常:
await add()之后即使报错也会走closeDialog()。需要严格中断请加{ customCatch: true }。 - 动态项 prop 用路径
items.${index}.field。 - 不要在模板里反复调用
useFormRulePresets(),setup 里解构一次。 validatorIf需要传formRef才能在条件变化时自动重校验。
小结
- 表单 =
label-width="auto"+ 24 栅格 +w100/mb20工具类。 - 校验规则优先
src/utils/formRule.ts,不重复造轮子。 - 弹窗五件套:
draggable/overflow/destroy-on-close/ 白字#header/defineExpose({ openDialog })。 - 新增与编辑的分支判断用
id != undefined && id > 0。