Skip to content

未保存表单与统一确认 ​

表单页在 setup 中调用一次 useUnsavedChanges(getValues, options?)。Hook 自动追踪响应式字段、复制基准并比较,业务无需在每次输入时调用函数或手动设置 dirty。普通列表页不调用,就不会登记未保存表单。

生成模板已经安装确认控制器、独立路由保护和 ConfirmationDialog.vue。退出和未保存提示统一使用 fin-ui 的 FDialog(fin-dialog),展示组件位于项目 src/components/ConfirmationDialog.vue,由 AppProviders.vue 挂载,可在项目中调整排版。

什么时候提示 ​

用户操作行为
表单内容与基准相同,包括修改后又改回原值直接放行
切换宿主中仍被缓存的页签保留草稿,不提示
关闭、刷新、原位替换有修改的页签,或 sdk.back() 关闭详情基座统一提示,列出该页标题及表单名称
批量关闭两个有修改的页签一个弹框列出两个页面,一次确认后整体关闭;取消全部保留
独立运行时切换路由或修改路由参数当前表单有修改时提示;取消保留路由和输入
点击退出单独询问是否退出管理系统,并说明未保存内容会丢失;确认后撤销身份、卸载工作区,不逐页重复询问
认证过期立即撤销身份并清理工作区,不等待表单确认

取消按钮、关闭图标和 Esc 都表示继续编辑,不再追加“退出已取消,请先处理未保存页面”之类提示。点击遮罩不关闭弹框,默认焦点放在取消按钮。

最小接入 ​

vue
<script setup lang="ts">
import { reactive } from 'vue'
import { useUnsavedChanges } from '@axewo/fin-micro/vue'

const form = reactive({ name: '', remark: '' })
const { dirty, setBaseline } = useUnsavedChanges(() => form)

// 新增页以初始空数据为基准。编辑页查询成功并赋值后调用 setBaseline()。
// 保存请求成功后再调用 setBaseline(本次提交的数据)。
</script>

<template>
  <el-input v-model="form.name" placeholder="名称" />
  <el-input v-model="form.remark" type="textarea" placeholder="备注" />
  <span>{{ dirty ? '存在未保存修改' : '无未保存修改' }}</span>
</template>

传入的是 读取最新响应式数据的函数,例如 () => form 或 () => ({ name: name.value })。不要传入 form 本身,也不要传入查询时的一份普通对象快照。getValues 只选实际提交的业务字段,排除加载状态、校验错误和临时 UI 字段;它应为同步、无副作用的 getter,不在其中请求接口。

dirty 在子应用 Hook 内自动计算,基座只接收“哪个表单有修改”和展示文案,不接收、比较或保存表单内容。比较支持嵌套对象、数组和日期,基准是独立深拷贝。每次字段变化都会读取并比较选定数据,超大表单应缩小比较范围。

查询详情、保存成功与 setBaseline ​

setBaseline() 把当前数据复制为新的基准;setBaseline(value) 把指定数据复制为基准。它们都不会修改表单,也不会发请求。查询成功后设基准,表示接口加载的内容已经保存;保存失败时不设基准,继续保留脏状态。

下面完整页面沿用订单教程提供的 apis/orders.ts 与模板请求实例,可以替换教程中的详情组件。新增页可直接以空数据为基准,详情页如下:

vue
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMicroPage, useMicroSession, useMicroTab, usePermission, useUnsavedChanges } from '@axewo/fin-micro/vue'
import { getOrder, saveOrder } from '../../../../apis/orders'
import { toSafeError } from '../../../../utils/errorUtils'

const { bridge } = useMicroSession()
const sdk = bridge ? useMicroPage(bridge, useMicroTab()) : undefined
const route = useRoute()
const router = useRouter()
const canWrite = usePermission().can('order:write')
const id = ref('')
const note = ref('')
const loaded = ref(false)
const { dirty, setBaseline } = useUnsavedChanges(() => ({ note: note.value }), {
  enabled: loaded,
  label: () => `订单 ${id.value} 的备注`,
  message: { content: '备注尚未保存,离开后将丢失。是否放弃修改?' },
})
const busy = ref(false)
const message = ref('')
const lifetime = new AbortController()

// 生成模板已安装统一确认:嵌入时按固定 tab.key 管理,独立时由路由守卫管理。
onBeforeUnmount(() => lifetime.abort())

// 缓存内的 useRoute 已按页签隔离;独立路由复用同一组件时,也要重新加载新的 ID。
watch(() => route.query.id, (value, _previous, onCleanup) => {
  const request = new AbortController()
  onCleanup(() => request.abort())
  loaded.value = false
  id.value = typeof value === 'string' && /^[1-9]\d{0,9}$/.test(value) ? value : ''
  if (!id.value) { message.value = '订单号无效'; return }
  message.value = ''
  void getOrder(id.value, request.signal).then(order => {
    if (!request.signal.aborted) { note.value = order.note; setBaseline(); loaded.value = true }
  }).catch(error => { if (!request.signal.aborted) message.value = toSafeError(error).message })
}, { immediate: true })

/** 无参数返回来源;明确传列表目标时优先回列表。独立模式固定返回本地列表。 */
async function close(toList = false): Promise<void> {
  try {
    if (!sdk || !bridge) { await router.push('/list'); return }
    const destination = toList ? { appId: bridge.appId, tabId: `${bridge.appId}:list`, title: '业务列表', route: '/list' } : undefined
    const result = await sdk.back(destination)
    if (result.status === 'stale') message.value = '页面状态已变化,请重试'
  } catch (error) { message.value = toSafeError(error).message }
}

/** 只有后端保存成功才清除 dirty、关闭本页;失败保留输入,供用户处理后重试。 */
async function save(): Promise<void> {
  if (busy.value || !loaded.value || !canWrite.value) return
  busy.value = true
  message.value = ''
  const submitted = { note: note.value }
  try {
    await saveOrder(id.value, submitted.note, lifetime.signal)
    if (lifetime.signal.aborted) return
    setBaseline(submitted)
    busy.value = false
    await close()
  } catch (error) {
    if (!lifetime.signal.aborted) message.value = toSafeError(error).message
  } finally { busy.value = false }
}
</script>

<template>
  <section class="panel">
    <h1>订单 {{ id }}</h1>
    <p v-if="message" role="alert">{{ message }}</p>
    <el-input v-model="note" aria-label="订单备注" type="textarea" :maxlength="500" :disabled="!loaded || busy || !canWrite" />
    <p role="status">{{ dirty ? '存在未保存修改' : '无未保存修改' }}</p>
    <div class="actions">
      <el-button type="primary" :loading="busy" :disabled="!loaded || !canWrite" @click="save">保存并返回</el-button>
      <el-button :disabled="busy" @click="close()">取消并返回来源</el-button>
      <el-button :disabled="busy" @click="close(true)">关闭并回列表</el-button>
    </div>
  </section>
</template>

如果允许保存期间继续编辑,必须在请求前复制本次提交内容,成功后将该快照交给 setBaseline,避免误把后续输入标记为已保存:

ts
import { deepClone } from '@axewo/fin-core/utils'

async function save() {
  const submitted = deepClone(getValues())
  await saveApi(submitted) // 项目的业务保存接口
  setBaseline(submitted)
  // 保存期间又修改过的字段仍使 dirty 为 true。
}

若保存接口会标准化输入,需要业务决定如何合并服务端返回值;只有确实写回表单的已保存数据才能作为相应基准。setBaseline 本身不会覆盖用户输入。

第二个参数 ​

ts
const { dirty, setBaseline } = useUnsavedChanges(getValues, {
  enabled: loaded, // boolean、ref 或 getter;加载完成后再参与保护
  formId: 'order-main', // 可选;同一页面内唯一,省略时自动生成
  label: () => `订单 ${orderId.value} · 基础信息`,
  message: () => ({
    title: '放弃订单修改?',
    content: `订单 ${orderId.value} 尚未保存,离开后将丢失本次修改。`,
    confirmButtonText: '放弃修改并离开',
    cancelButtonText: '继续编辑',
  }),
})
选项类型与含义
enabledboolean / Ref / getter,默认 true。为 false 时暂停离开保护,dirty 仍反映真实差异,不会自动更新基准
formId可选字符串,1~120 字符;同页多表单需唯一。同一标识可在不同页签重复使用
label字符串、ref 或 getter,最多 240 字符,用于区分同页的基础信息、费用等表单
message配置对象、ref 或 getter;省略字段使用公共中英文默认值。单个表单确认使用此配置;批量多个表单使用公共文案并列出所有页面和表单,避免某一页覆盖整批提示

message 支持 title、content、html、confirmButtonText、cancelButtonText。标题和按钮最多 240 字符,正文最多 10000 字符。content 与 html 二选一,同时传入会报错。不需要 setMessage 或额外回调;动态文案通过 getter/ref 更新。

普通文本优先使用 content。需要简单排版时:

ts
useUnsavedChanges(getValues, {
  message: {
    title: '离开编辑页?',
    html: '<p>离开后将丢失<strong>尚未保存的修改</strong>。</p><p>可以选择继续编辑。</p>',
  },
})

统一展示层只保留 p / br / strong / b / em / i / ul / ol / li / span / code,移除全部属性;脚本、样式、iframe、SVG 等节点会移除,其他标签仅保留文本内容。不支持事件、链接、图片、任意组件、VNode 或跨应用渲染函数。

两个页签、同页两个表单 ​

嵌入页面通过 MicroTabCache 注入的固定 tab.key 绑定归属,页面切换后不会改绑当前活动页。基座用自己的页签标题展示来源,不根据当前 URL 猜测表单属于哪里。订单 1001 与订单 1002 各自记录;关闭 1001 只检查 1001,关闭全部则合并两者。

同页有两个独立表单时可以分别调用:

ts
const basicState = useUnsavedChanges(() => basicForm, { formId: 'basic', label: '基础信息' })
const costState = useUnsavedChanges(() => costForm, { formId: 'cost', label: '费用明细' })
// 各自查询或保存成功后,只更新自己的基准。
basicState.setBaseline()
costState.setBaseline()

关闭、刷新和组件真正卸载会清理对应记录;缓存停用不会清理。刷新重建后使用新的生命周期 key,旧表单不会串到新实例。确认期间表单又发生变化时,旧确认不能继续丢弃新内容,本次操作会失效,需重新操作。

已有项目如何补齐接线 ​

新生成项目已包含以下接线,无需重复安装。旧项目需要同步升级配套 fin-micro 与 fin-core,复制生成模板的 components/ConfirmationDialog.vue,在 AppProviders.vue 内挂载一次;嵌入子应用的实际弹框由基座展示。

基座 bootstrap 在挂载 App 前安装控制器,HostLayout 通过注入使用同一个实例:

ts
// bootstrap.ts
import { installMicroConfirmation } from '@axewo/fin-micro/vue'
const confirmation = installMicroConfirmation(app)
// AppProviders 内渲染 <ConfirmationDialog />。
// 整个应用卸载时调用 confirmation.dispose(),结束正在等待的确认。
ts
// HostLayout.vue setup
import { useMicroConfirmation } from '@axewo/fin-micro/vue'
import { useMicroWorkspace } from '@axewo/fin-micro'

const confirmation = useMicroConfirmation()
const workspace = useMicroWorkspace(apps, {
  host: {
    confirmUnsavedChanges: ({ forms, signal }) =>
      confirmation.confirm({ kind: 'unsaved', forms }, signal),
    // 合并项目原有的权限、认证能力等配置。
  },
})

没有配置 confirmUnsavedChanges 时,core 遇到登记的脏表单会取消破坏性导航,避免静默丢弃。非 Vue 集成可使用 PageSDK.setUnsavedChanges(formId, state | null) 自行登记;该接口只接收元数据,调用方自行判断差异。

独立子应用在安装路由器之前连接一次路由保护,嵌入运行时不重复安装:

ts
import { installMicroConfirmation, connectUnsavedChangesRouter } from '@axewo/fin-micro/vue'

const confirmation = installMicroConfirmation(app, {
  pageTitle: () => String(router.currentRoute.value.meta.title ?? ''),
})
const off = connectUnsavedChangesRouter(router, confirmation, () => session.identity.value.authenticated)
app.use(router)
// 卸载时:off(); confirmation.dispose()

认证变为未登录时取消当前弹框;生成模板已在 bootstrap 订阅身份执行 confirmation.cancel()。自定义退出先等待 confirmation.confirm({ kind: 'logout' }),返回 false 就结束动作;返回 true 后再撤销身份、清理凭据并 dispose 工作区,不能再执行一轮逐页关闭守卫。

保护范围与验收 ​

这套机制保护应用内会销毁表单的导航。浏览器整页刷新、关闭浏览器、进程退出、LRU 缓存淘汰和强制卸载无法等待应用内 FDialog;关键草稿仍需业务持久化。浏览器 beforeunload 如需接入只能使用浏览器自己的提示,不能替换为自定义弹框;当前 Hook 不注册它。

不要为同一个表单再注册基于 dirty 的 beforeLeave 或 onBeforeRouteLeave 确认,否则会重复提示。底层 beforeLeave 仍适合保存前置条件等业务规则;它继续按原规则在活动页切换时执行。底层同步 host.closeTab/openTab 用于初始化或受控适配器,会绕过交互守卫,业务按钮应走 operate / 页面 SDK。

建议验收:空表单直接返回;输入后改回原值直接返回;加载详情后无脏状态;保存失败保留输入;保存成功清除对应差异;同时编辑两个页签且切换不提示;关闭非活动页显示正确标题;批量关闭只弹一次;取消保留所有页签;退出取消留在系统,确认只退出一次;认证过期直接回登录页。