跳转正文

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 ​

属性类型默认值说明
titlestring必填弹窗标题
descriptionstring无标题说明
modalModalRef无程序式任务实例;存在时拥有打开状态与结果
guardModalGuard无读取业务 dirty/busy;与组件提交保护合并
size'sm' | 'md' | 'lg'lg显式属性 → modal.options.size → lg
expandablebooleantrue显式属性 → modal.options.expandable → true
draggablebooleantrue显式属性 → modal.options.draggable → true;桌面范围见 YModal
stablebooleantrue使用稳定任务高度
submit(intent: 'save' | 'save-and-add') => void | Promise<void>必填接收提交意图,业务处理成功和失败
busybooleanfalse业务操作中;禁用普通关闭和底部操作
loadingbooleanfalse显示初始加载状态,隐藏默认内容并阻止提交
primaryDisabledbooleanfalse禁用主按钮和提交;不禁用取消
allowAddbooleanfalse显示继续添加复选框;业务只在新增任务开启
primaryLabelstring根据 labels.save自定义普通主按钮文字;勾选继续添加时由 labels.saveAndAdd 覆盖
labelsPartial<Record<LabelKey, string>>应用本地化或英文回退覆盖操作与反馈文案

Models 与 Events ​

名称类型说明
open / update:openboolean声明式 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 类型。

键英文回退用途
cancelCancel取消按钮
saveSave普通主按钮
saveAndAddSave and add another勾选后主按钮
continueAddingAdd another after saving复选框
loadingLoading…初始加载
failedUnable 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 是可采用的场景,不代表已经接入。

YoyoBoot · 从真实项目走向可复用能力