切换主题
YModalForm 业务表单弹窗
YModalForm 用于新增和编辑记录,提供统一的正文、加载反馈、取消/保存操作,以及可选的“保存后继续添加”。弹窗呈现与关闭保护复用 YModal;字段、校验、API、权威回读和下一份草稿由业务提供。
ts
import { YModalForm } from '@yoyoboot/ui-patterns/modal'何时使用
新增用户、角色、版本或标准 CRUD 记录,需要统一的表单任务与保存操作时使用。图片裁剪、纯确认等专用任务按自己的任务组件处理;只需要弹窗容器时使用 YModal。
解决场景
text
YModal
技术弹窗底座
│
┌──────────────┼──────────────┐
│ │ │
YModalForm Image Crop Confirmation
业务表单任务 专用任务 确认任务
│
┌─────┼─────┬───────────┐
Users Roles Edition Generated CRUD能力概览
| 能力 | 提供方式 | 用法 |
|---|---|---|
| 基础新增/编辑 | 统一正文和 footer,业务传入表单 | 基础用法、编辑记录 |
| 保存后关闭 | 业务确认保存成功后结束任务 | 保存后关闭 |
| 保存并继续添加 | 内置复选框与提交意图,业务初始化新草稿 | 保存并继续添加 |
| 字段校验与错误定位 | 业务校验,组件提供焦点方法 | 校验与错误定位 |
| 初始加载 | 内置 loading 呈现,业务负责重试入口 | 初始加载与重试 |
| 提交防重与忙碌保护 | 等待 submit 完成;结合 busy 和 guard | 保存中与失败反馈 |
| 未保存关闭确认 | 复用 YModal dirty guard | 未保存关闭确认 |
| 多页签表单 | tabs 插槽与统一内容间距 | 多页签表单 |
| 辅助操作 | footer-start、secondary-actions 插槽 | 底部扩展 |
| 尺寸、拖动、全屏、正文滚动 | 继承 YModal,业务默认 lg、稳定高度 | 尺寸与长内容 |
| 分阶段失败恢复 | 业务保留已成功阶段,组件等待处理结果 | 复杂场景 |
代码演示
以下是可复制的代码示例,没有嵌入在线交互预览。基础示例使用本地数组模拟保存,后续小例子注明需要替换的部分。
基础用法
点击“新增记录”打开表单。保存成功后写入下方列表并关闭;校验失败保留输入,定位到名称字段。
vue
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { YModalForm } from '@yoyoboot/ui-patterns/modal'
const open = ref(false)
const form = ref<InstanceType<typeof YModalForm>>()
const draft = reactive({ name: '' })
const baseline = ref('')
const fieldError = ref('')
const rows = ref<Array<{ id: number; name: string }>>([])
let nextId = 1
const dirty = computed(() => draft.name !== baseline.value)
const guard = { dirty: () => dirty.value }
const labels = {
cancel: '取消', save: '保存', continueAdding: '保存后继续添加',
saveAndAdd: '保存并继续添加', loading: '正在加载…', failed: '操作未完成,请重试。',
}
async function initializeNewDraft() {
draft.name = ''
baseline.value = ''
fieldError.value = ''
await nextTick()
await form.value?.focusFirstField()
}
watch(open, value => { if (value) void initializeNewDraft() })
async function submit(intent: 'save' | 'save-and-add') {
const name = draft.name.trim()
if (!name) {
fieldError.value = '请输入名称。'
await form.value?.focusFirstError()
return
}
// 本地保存示例。真实业务在这里完成 API 写入和必要回读。
await Promise.resolve()
rows.value.push({ id: nextId++, name })
baseline.value = draft.name
fieldError.value = ''
if (intent === 'save-and-add') await initializeNewDraft()
else open.value = false
}
</script>
<template>
<UButton @click="open = true">新增记录</UButton>
<YModalForm ref="form" v-model:open="open" title="新增记录"
:guard="guard" :submit="submit" :labels="labels">
<form @submit.prevent="form?.submit()">
<UFormField label="名称" required :error="fieldError">
<UInput v-model="draft.name" :aria-invalid="!!fieldError" class="w-full" />
</UFormField>
</form>
</YModalForm>
<ul><li v-for="row in rows" :key="row.id">{{ row.name }}</li></ul>
</template>示例按 Starter 已有的 Nuxt UI 和公共组件安装运行。声明式任务通过 open=false 表达保存完成;普通取消由组件调用安全关闭,不直接更改 open。
编辑记录
编辑模式不设置 allowAdd,默认只有“取消/保存”。打开时读取目标记录,保存后更新记录并完成权威回读;成功结果仍由业务明确处理。
基于基础示例,将打开时的草稿初始化替换为目标记录读取,将 rows.push 替换为更新与回读。不要把旧记录的 ID 带入下一次新增。
vue
<YModalForm v-model:open="open" title="编辑记录"
:guard="guard" :submit="submit" :labels="labels" :primary-disabled="!canEdit">
<!-- 放入基础示例中的表单;canEdit 来自业务权限。 -->
</YModalForm>此片段只展示编辑入口的属性差异,不是独立的完整页面。
保存后关闭
基础示例中的 save 分支展示默认保存行为。程序式任务在本次任务完成后调用 modal.notifySaved(recordId, false);组件不会因为 submit 的 Promise 正常返回就自行关闭。
text
校验 → 创建或更新成功 → 必要后续操作完成 → 必要权威回读完成
→ 通知列表 → 清除当前任务未保存标记 → 关闭并恢复入口焦点头像、权限等后续操作仅在本次业务任务需要时执行。成功关闭不会再次询问“放弃未保存修改”。
保存并继续添加
在基础示例的 YModalForm 上增加 allow-add 即可显示复选框,其 submit 已包含下一份草稿初始化。
vue
<YModalForm ref="form" v-model:open="open" title="新增记录"
allow-add :guard="guard" :submit="submit" :labels="labels">
<!-- 放入基础示例中的表单。 -->
</YModalForm>未勾选时主按钮是“保存”;勾选后同一个按钮改为“保存并继续添加”,submit 收到 save-and-add。连续新增保留勾选状态,关闭后重新打开默认不勾选。
text
校验 → 创建成功 → 必要后续操作完成 → 必要权威回读完成 → 通知列表
→ 初始化下一份新建草稿 → 清除错误与旧记录 ID,恢复新建默认值
→ 聚焦首个可编辑字段新草稿要处理整个任务:清除记录 ID、密码、头像、校验错误、保存和重试状态;所有 Tab 一起恢复,角色与权限重新读取新建默认值。只有业务明确指定的组织、分类等上下文可以保留。新草稿建立后是干净状态,直接关闭不提示放弃上一条已保存记录。
程序式任务使用 modal.notifySaved(recordId, true) 保持弹窗打开,然后初始化下一份草稿。keepOpen=true 本身不执行重置。
校验与错误定位
基础示例通过 fieldError 显示错误并调用 focusFirstError()。实际表单也可以使用既有表单校验机制;YModalForm 不定义字段规则。
ts
// 将对应字段标为 aria-invalid 后,调用组件方法。
await form.value?.focusFirstError()自定义输入需要正确标记 aria-invalid 并提供可聚焦节点。普通 HTML form 的 Enter 提交应调用 formRef.submit(),这样与点击主按钮使用同一个继续添加意图;名称不要与模板中的 <form> ref 混淆。
初始加载与重试
loading=true 时隐藏默认内容,显示加载状态并禁用操作。加载失败由业务提供错误信息和重试动作;下面片段中的 load 与 loadError 来自业务读取任务。
vue
<YModalForm v-model:open="open" title="编辑记录"
:loading="loading" :submit="submit" :labels="labels" :primary-disabled="!detail">
<UAlert v-if="loadError" color="error" :title="loadError"
:actions="[{ label: '重新加载', onClick: load }]" />
<RecordFields v-else-if="detail" />
</YModalForm>此例是加载接线片段,RecordFields 表示业务已有表单,不是公共组件。
保存中与失败反馈
组件等待 submit 完成,期间阻止重复提交和普通关闭。独立头像、权限等动作仍通过 busy 或 guard.busy 提供忙碌状态。
vue
<YModalForm v-model:open="open" title="编辑记录" :submit="submit"
:busy="pictureSaving || permissionSaving" :guard="guard" :labels="labels">
<!-- 业务表单 -->
</YModalForm>未处理的 submit 异常会显示 labels.failed,保留表单。字段和业务错误应由业务自己的错误状态呈现;组件不从异常猜测写入是否成功。
未保存关闭确认
基础示例的 guard 已展示 dirty 接线。业务还可以把多个 Tab 的草稿和独立动作合并起来:
ts
const guard = {
dirty: () => profileDirty.value || permissionsDirty.value || avatarDirty.value,
busy: () => avatarSaving.value || permissionsSaving.value,
}关闭按钮、取消和 Escape 沿用 YModal 关闭策略。guard 是状态读取接口,不是第二份草稿。
多页签表单
tabs 插槽位于正文上方,切换时保留同一份业务草稿。表单可以用 v-show 保留字段;继续添加时由业务一次性恢复所有分区。
vue
<YModalForm v-model:open="open" title="新增记录" allow-add :submit="submit" :labels="labels">
<template #tabs><UTabs v-model="pane" :items="panes" :content="false" /></template>
<ProfileFields v-show="pane === 'profile'" />
<PermissionFields v-show="pane === 'permissions'" />
</YModalForm>pane、panes 和两个字段组件由业务定义;这个片段展示插槽组合方式。
底部扩展
保留统一的取消与保存按钮,把业务独立动作放入 secondary-actions,把说明放入 footer-start。secondary-actions 的 disabled 包含 loading、busy 与本次 submit 等待状态。
vue
<YModalForm v-model:open="open" title="编辑记录" :submit="submit" :labels="labels">
<template #footer-start><span class="text-sm text-muted">仅影响当前记录</span></template>
<template #secondary-actions="{ disabled }">
<UButton color="neutral" variant="outline" :disabled="disabled" @click="restoreDraft">
恢复当前分区
</UButton>
</template>
<!-- 业务表单;restoreDraft 是业务自己的恢复动作。 -->
</YModalForm>尺寸与长内容
默认 lg、可全屏、桌面可拖动、稳定高度。技术行为复用 YModal,表单内容在正文内部滚动。
vue
<YModalForm v-model:open="open" title="短表单" size="sm"
:expandable="false" :draggable="false" :stable="false"
:submit="submit" :labels="labels">
<!-- 业务表单 -->
</YModalForm>复杂场景
创建成功,后续操作失败
用户已创建但头像保存失败、角色已创建但权限应用失败时,保留已创建 ID 和未完成阶段。重试只执行失败的后续操作;全部完成后才关闭或继续添加。每条记录独立通知列表,关闭不再次通知同一结果。
写入成功,回读失败
保留待回读状态,重试只读取。列表刷新失败属于列表读取恢复,不回放表单写入。下一份新草稿初始化失败则重试初始化,不能把上一条显示为创建失败。
| 阶段 | 保留内容 | 恢复动作 |
|---|---|---|
| 校验失败 | 当前草稿与继续添加意图 | 修正字段;不执行写入 |
| 写入失败 | 当前表单、附件、权限等输入 | 重试当前写入 |
| 创建成功,后续写入失败 | 已创建 ID、失败阶段与有关草稿 | 只重试未完成操作 |
| 写入成功,回读失败 | 已写入 ID 与读取状态 | 只重新读取 |
| 下一份草稿初始化失败 | 上一条成功结果与新建加载错误 | 只重试新建初始化 |
上述恢复由业务 editor 实现。创建与更新、后续任务、回读和重置不在公共组件中另建状态机。
API
Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| title | string | 必填 | 弹窗标题 |
| description | string | 无 | 标题说明 |
| modal | ModalRef | 无 | 程序式任务实例;存在时拥有打开状态与结果 |
| guard | ModalGuard | 无 | 读取业务 dirty/busy;与组件提交保护合并 |
| size | 'sm' | 'md' | 'lg' | lg | 显式属性 → modal.options.size → lg |
| expandable | boolean | true | 显式属性 → modal.options.expandable → true |
| draggable | boolean | true | 显式属性 → modal.options.draggable → true;桌面范围见 YModal |
| stable | boolean | true | 使用稳定任务高度 |
| submit | (intent: 'save' | 'save-and-add') => void | Promise<void> | 必填 | 接收提交意图,业务处理成功和失败 |
| busy | boolean | false | 业务操作中;禁用普通关闭和底部操作 |
| loading | boolean | false | 显示初始加载状态,隐藏默认内容并阻止提交 |
| primaryDisabled | boolean | false | 禁用主按钮和提交;不禁用取消 |
| allowAdd | boolean | false | 显示继续添加复选框;业务只在新增任务开启 |
| primaryLabel | string | 根据 labels.save | 自定义普通主按钮文字;勾选继续添加时由 labels.saveAndAdd 覆盖 |
| labels | Partial<Record<LabelKey, string>> | 应用本地化或英文回退 | 覆盖操作与反馈文案 |
Models 与 Events
| 名称 | 类型 | 说明 |
|---|---|---|
| open / update:open | boolean | 声明式 v-model,默认 false;传 modal 时以 ModalRef 为准 |
| after:leave | () => void | 转发弹窗离场完成事件 |
组件没有独立的 saved 事件,程序式任务沿用 ModalRef 的结果和 notifySaved;声明式页面在业务提交函数中处理结果。
Slots
| 名称 | 参数 | 内容 |
|---|---|---|
| default | 无 | 表单正文;loading 时暂不渲染 |
| tabs | 无 | 可选页签,位于正文上方 |
| footer-start | 无 | 左侧辅助说明,跟随继续添加复选框 |
| secondary-actions | { disabled: boolean } | 取消与主按钮之间的业务独立操作 |
Ref 方法
| 方法 | 返回 | 用途 |
|---|---|---|
| submit() | Promise<void> | 按当前复选框状态提交,适合 form Enter 接线 |
| focusFirstField() | Promise<void> | undefined | 定位正文首个可编辑字段 |
| focusFirstError() | Promise<void> | undefined | 定位并显露首个 aria-invalid 字段 |
Labels
上表的 LabelKey 表示下面六个键的联合;当前组件没有导出单独的 Labels 类型。
| 键 | 英文回退 | 用途 |
|---|---|---|
| cancel | Cancel | 取消按钮 |
| save | Save | 普通主按钮 |
| saveAndAdd | Save and add another | 勾选后主按钮 |
| continueAdding | Add another after saving | 复选框 |
| loading | Loading… | 初始加载 |
| failed | Unable to finish. Please retry. | submit 未处理异常 |
文案按 labels → ApplicationUiContext 的 UiPatterns.ModalForm.* → 英文回退解析。应用负责资源翻译,不在组件中维护语言 Store。
样式与主题
继承 YModal 和已安装的公共管理端主题,支持既有皮肤、明暗模式与 reduced-motion。属性和 $attrs 传入底层 YModal;需要调整底层呈现时查看 YModal 主题说明。
常见问题
为什么 submit 返回后弹窗没有自动关闭?
组件只等待处理完成,不判断 API 是否写入成功。业务完成写入、必要回读和后续任务后,程序式调用 notifySaved;声明式设置 open=false。
为什么继续添加后还是旧记录?
notifySaved(id, true) 只保持打开。业务还要清除旧 ID 与全部草稿,加载新建默认值并恢复焦点。首批用户与角色 editor 提供自己的 resetForCreate,不能把它当成公共组件方法。
为什么错误提示没有自动出现?
业务捕获异常后正常返回,组件不会再显示通用异常提示。字段或接口错误由业务状态呈现;只有 submit 抛出的未处理异常才触发 labels.failed。
为什么继续添加时“重新读取”文案被覆盖?
当前勾选状态优先显示 labels.saveAndAdd,primaryLabel 不覆盖它。若业务在恢复阶段需要单独显示重读动作,可用 secondary-actions 提供明确操作,或在该阶段暂时关闭 allowAdd。不要仅靠修改按钮文案推断写入阶段。
源码与相关组件
首批真实消费者是用户与角色编辑弹窗;Edition 和 Generated CRUD 是可采用的场景,不代表已经接入。