Appearance
未保存表单与统一确认
表单页在 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: '继续编辑',
}),
})| 选项 | 类型与含义 |
|---|---|
enabled | boolean / 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。
建议验收:空表单直接返回;输入后改回原值直接返回;加载详情后无脏状态;保存失败保留输入;保存成功清除对应差异;同时编辑两个页签且切换不提示;关闭非活动页显示正确标题;批量关闭只弹一次;取消保留所有页签;退出取消留在系统,确认只退出一次;认证过期直接回登录页。