Appearance
BizModal 业务弹层
业务弹层统一壳:dialog 居中弹框 / drawer 侧向滑窗双模式,基于 el-dialog、el-drawer 二次封装。同时提供声明式组件(模板内 v-model)与函数式 API(openBizDialog / openBizDrawer,命令式打开、关闭即彻底销毁、promise 交付结果)两种用法,内置提交契约(loading 编排)、未保存脏检查、提交锁、单例防重复、级联滑窗(副滑窗在主滑窗左侧并排滑出、双栏联动)、尺寸预设(small / medium / large)、长内容滚动与全屏切换。
基础用法
声明式:v-model 控制显隐,footer 按钮内置,confirm 事件中编排校验与提交,confirm-loading 控制确定按钮 loading(提交期间自动锁定 X / Esc / 遮罩 / 取消)。
width="small" 为尺寸预设(480px),也可传像素 / 百分比。<script setup lang="ts">
import { reactive, ref } from 'vue'
import { ElButton, ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { BizModal } from 'vue3-ep-biz-lib'
const visible = ref(false)
const loading = ref(false)
const formRef = ref<FormInstance>()
const form = reactive({ name: '', email: '' })
const rules: FormRules = {
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' },
],
}
/** 声明式用法:confirm 事件由使用方编排(校验 → loading → 提交 → 关框) */
const onConfirm = async () => {
const valid = await formRef.value?.validate().then(() => true).catch(() => false)
if (!valid) return
loading.value = true
await new Promise((resolve) => setTimeout(resolve, 1000)) // 模拟接口提交
loading.value = false
visible.value = false
ElMessage.success('保存成功')
}
</script>
<template>
<ElButton type="primary" @click="visible = true">新增用户</ElButton>
<!-- width="small" 尺寸预设(480px);提交期间 confirmLoading 锁定 X/Esc/遮罩/取消 -->
<BizModal
v-model="visible"
title="新增用户"
width="small"
:confirm-loading="loading"
@confirm="onConfirm"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="72px">
<ElFormItem label="姓名" prop="name">
<ElInput v-model="form.name" placeholder="请输入姓名" />
</ElFormItem>
<ElFormItem label="邮箱" prop="email">
<ElInput v-model="form.email" placeholder="请输入邮箱" />
</ElFormItem>
</ElForm>
</BizModal>
</template>
显示代码
滑窗模式
mode="drawer" 切换为侧向滑窗;纯展示场景可关闭内置按钮,用 #footer 插槽自定义。
订单详情
标题由壳统一自绘(EP 原生 drawer 标题样式与 dialog 不一致,壳统一为 dialog 标题视觉);EP 原生属性(
direction、with-header 等)自动透传。<script setup lang="ts">
import { ref } from 'vue'
import { ElButton, ElDescriptions, ElDescriptionsItem, ElTag } from 'element-plus'
import { BizModal } from 'vue3-ep-biz-lib'
interface OrderRow {
id: number
orderNo: string
channel: string
amount: number
status: string
createdAt: string
}
const visible = ref(false)
const detail = ref<OrderRow>({
id: 1,
orderNo: 'SO2026082800123',
channel: '线上商城',
amount: 12860.0,
status: '已支付',
createdAt: '2026-08-28 10:23:45',
})
</script>
<template>
<ElButton @click="visible = true">查看订单详情(滑窗)</ElButton>
<!-- mode="drawer" 侧向滑窗;纯展示场景:内置按钮全关,#footer 自定义单按钮 -->
<BizModal
v-model="visible"
mode="drawer"
title="订单详情"
size="medium"
:show-confirm="false"
:show-cancel="false"
>
<ElDescriptions :column="1" border>
<ElDescriptionsItem label="订单号">{{ detail.orderNo }}</ElDescriptionsItem>
<ElDescriptionsItem label="渠道">{{ detail.channel }}</ElDescriptionsItem>
<ElDescriptionsItem label="金额(元)">{{ detail.amount.toFixed(2) }}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag type="success" size="small">{{ detail.status }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="下单时间">{{ detail.createdAt }}</ElDescriptionsItem>
</ElDescriptions>
<template #footer>
<ElButton type="primary" @click="visible = false">知道了</ElButton>
</template>
</BizModal>
</template>
显示代码
增强能力:尺寸预设 / 长内容滚动 / 全屏切换
width / size 支持 'small' | 'medium' | 'large' 预设(映射主题 token --biz-modal-width-small/medium/large,宽度规范统一可覆盖);内容超高时独立滚动、footer 常驻;fullscreenable 开启标题栏全屏切换按钮。
滚动阈值由
--biz-modal-body-max-height(默认 60vh)控制;全屏状态支持 v-model:fullscreen 受控。<script setup lang="ts">
import { ref } from 'vue'
import { ElButton, ElRadioButton, ElRadioGroup } from 'element-plus'
import { BizModal } from 'vue3-ep-biz-lib'
import type { BizModalSizePreset } from 'vue3-ep-biz-lib'
const visible = ref(false)
const preset = ref<BizModalSizePreset>('medium')
/** 长内容:超出 --biz-modal-body-max-height(默认 60vh)后内容区独立滚动,footer 常驻 */
const paragraphs = Array.from({ length: 16 }, (_, i) => `第 ${i + 1} 段:滚动内容区 + footer 常驻,长表单/长文案不再把操作按钮顶出视口。`)
</script>
<template>
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap">
<ElRadioGroup v-model="preset">
<ElRadioButton value="small">small · 480px</ElRadioButton>
<ElRadioButton value="medium">medium · 560px</ElRadioButton>
<ElRadioButton value="large">large · 720px</ElRadioButton>
</ElRadioGroup>
<ElButton type="primary" @click="visible = true">打开长内容弹框</ElButton>
</div>
<!-- fullscreenable:标题栏全屏切换;:width="preset" 尺寸预设 -->
<BizModal v-model="visible" title="长内容滚动 + 全屏切换" :width="preset" fullscreenable>
<p v-for="(text, i) in paragraphs" :key="i" style="margin: 0 0 12px">{{ text }}</p>
</BizModal>
</template>
显示代码
函数式 API(推荐:表单/详情类弹框)
命令式打开:内容组件只写内容不写壳,useBizModalChild 注册 submit / isDirty 契约后,loading 编排、成功关框、失败保持、脏检查全部由引擎托管;关闭即彻底销毁(下次打开全新挂载,无需 reset),结果经 promise / onResult 交付。
包含:懒加载打开(loading 占位)、单例防重复(关旧开新)、弹框/滑窗换壳复用同一内容组件、纯展示型、
onResult 快捷回调与 closeAllBizModals 全局关闭。<script setup lang="ts">
import { defineComponent, h, ref } from 'vue'
import { ElButton, ElMessage, ElTable, ElTableColumn, ElTag } from 'element-plus'
import { closeAllBizModals, openBizDialog, openBizDrawer, useBizModal } from 'vue3-ep-biz-lib'
import UserForm from './user-form.vue'
interface AppRow {
id: number
name: string
owner: string
status: 'enabled' | 'disabled'
}
/** 模拟列表数据:confirm 后由 promise 结果回写(泛型让 data 类型化) */
const list = ref<AppRow[]>([
{ id: 1, name: '统一认证中心', owner: '张伟', status: 'enabled' },
{ id: 2, name: '消息推送平台', owner: '李娜', status: 'disabled' },
{ id: 3, name: '任务调度系统', owner: '王强', status: 'enabled' },
])
/** useBizModal:弹框随本组件 deactivate/unmount 自动关闭;任意回调上下文可用 */
const { openBizDialog: scopedOpenDialog } = useBizModal()
/** 编辑(弹框 + 懒加载 + 单例防重复打开) */
const onEdit = (row: AppRow) => {
scopedOpenDialog<{ id?: number; name: string }>({
component: () => import('./user-form.vue'), // 懒加载:首开构建 chunk 期间展示 loading 占位
props: { id: row.id, name: row.name },
title: '编辑应用',
width: 'small',
singleton: 'app-edit', // 连点行/快速切行:关旧开新,弹框始终展示本次行数据
}).promise.then(({ action, data }) => {
if (action === 'confirm' && data) {
const index = list.value.findIndex((r) => r.id === row.id)
if (index >= 0) list.value[index] = { ...list.value[index], name: data.name }
}
})
}
/** 同一内容组件换壳:滑窗编辑(dialog/drawer 共享 submit 契约) */
const onEditDrawer = (row: AppRow) => {
openBizDrawer<{ id?: number; name: string }>({
component: UserForm,
props: { id: row.id, name: row.name },
title: '编辑应用(滑窗)',
size: 'small',
onResult: ({ action, data }) => {
// onResult 快捷回调:与 promise.then 等价,简单场景更轻
if (action === 'confirm' && data) {
const index = list.value.findIndex((r) => r.id === row.id)
if (index >= 0) list.value[index] = { ...list.value[index], name: data.name }
}
},
})
}
/** 纯展示型内容组件工厂:render 凄件(真实业务推荐独立 SFC / 懒加载 SFC) */
const makeDetailPanel = (row: AppRow) =>
defineComponent({
name: 'AppDetailPanel',
setup: () => () =>
h('div', { style: 'line-height: 2' }, [
h('p', `应用编号:${row.id}`),
h('p', `应用名称:${row.name}`),
h('p', `负责人:${row.owner}`),
]),
})
/** 纯展示型:不注册 submit,"确 定"直接关闭 */
const onView = (row: AppRow) => {
openBizDialog({
component: makeDetailPanel(row),
title: `${row.name} · 详情`,
width: 'small',
showCancel: false,
confirmText: '知道了',
})
}
const onCloseAll = () => {
closeAllBizModals()
ElMessage.info('已关闭全部弹框')
}
</script>
<template>
<div>
<ElTable :data="list" size="small">
<ElTableColumn prop="id" label="ID" width="60" />
<ElTableColumn prop="name" label="应用名称" min-width="140" />
<ElTableColumn prop="owner" label="负责人" width="90" />
<ElTableColumn label="状态" width="90">
<template #default="{ row }">
<ElTag :type="row.status === 'enabled' ? 'success' : 'info'" size="small">
{{ row.status === 'enabled' ? '启用' : '停用' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="240" fixed="right">
<template #default="{ row }">
<ElButton link type="primary" size="small" @click="onView(row as AppRow)">详情</ElButton>
<ElButton link type="primary" size="small" @click="onEdit(row as AppRow)">编辑(弹框·懒加载)</ElButton>
<ElButton link type="primary" size="small" @click="onEditDrawer(row as AppRow)">编辑(滑窗)</ElButton>
</template>
</ElTableColumn>
</ElTable>
<div style="margin-top: 12px">
<ElButton size="small" @click="onCloseAll">closeAllBizModals() 全局关闭</ElButton>
</div>
</div>
</template>
显示代码
使用步骤
ts
// 1. main.ts 安装插件(一次性,捕获应用 context,弹框内可用全局组件/store)
import { bizModalPlugin } from 'vue3-ep-biz-lib'
app.use(bizModalPlugin)
// 2. 内容组件 FooForm.vue:只写表单,注册提交契约
const submit = async () => {
await formRef.value?.validate() // 校验失败 → reject → 弹框保持打开
await apiSave(form) // 接口报错 → throw → 同上
return { id: form.id } // 返回值 → BizModalResult.data
}
const { close } = useBizModalChild({ submit, isDirty: () => isDirty() })
// 3. 页面中打开并消费结果
const { openBizDialog } = useBizModal() // 弹框随本组件自动关闭
openBizDialog({
component: () => import('./FooForm.vue'), // 懒加载(推荐,自动分包)
props: { id: row.id }, // → 传给 FooForm 的 props
title: '编辑', width: 'small',
singleton: 'FooForm', // 防重复打开
}).promise.then(({ action, data }) => {
if (action === 'confirm') fetchList() // 只有提交成功才刷新
})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
方法
| 方法 | 参数 | 返回 | 说明 |
|---|---|---|---|
openBizDialog | options: BizModalOpenOptions | BizModalHandle<T> | 函数式打开弹框(el-dialog 壳) |
openBizDrawer | options: BizModalOpenOptions | BizModalHandle<T> | 函数式打开滑窗(el-drawer 壳) |
useBizModal | options?: { closeOnRouteChange?: boolean } | { openBizDialog, openBizDrawer, closeAll } | 组合式入口:返回的函数任意上下文可用,弹框随调用方组件 deactivated/unmounted 自动关闭;检测到路由时 path 变化自动关闭 |
useBizModalChild | api: { submit?, isDirty? } | { close } | 内容组件契约(壳托管模式):注册提交/脏检查,获得自关能力;不在壳内为无害空操作 |
closeAllBizModals | — | — | 关闭当前全部弹框(退出登录等全局场景) |
bizModalPlugin | app.use(...) | — | 插件:捕获应用 context(main.ts 安装一次) |
OpenOptions 参数
① 引擎行为参数
| 属性名 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| component | Component | (() => import(...)) | 必填 | 业务内容组件;传 () => import() 懒加载(加载期间自动展示 loading 占位,失败自动重试 1 次) |
| props | Record<string, unknown> | {} | 传给内容组件的 props;每次打开全新挂载,初始值即此处的值 |
| on | Record<string, fn> | {} | 透传给内容组件的事件监听器({ saved: fn } → emit('saved') 触发) |
| bare | boolean | false | 自含壳模式:组件自带 el-dialog/el-drawer,需遵守 modelValue / closed / confirm 约定 |
| cascade | boolean | false | 级联滑窗(仅 openBizDrawer 壳托管模式):紧贴已打开滑窗左缘并排滑出、无遮罩点击穿透,主/副可同时交互;多级依次左移(间距 16px);Esc 只关最顶层;无已开滑窗时退化为普通滑窗 |
| singleton | string | { key, reopen? } | — | 单例键:默认 replace(关旧开新,展示本次数据);{ key, reopen: 'ignore' } 为纯防双击复用 |
| bindCaller | boolean | true | 随调用方组件 deactivate/unmount 自动关闭(事件回调等异步上下文取不到实例,请用 useBizModal) |
| onResult | (result: BizModalResult) => void | — | 结果快捷回调(与 promise.then 等价) |
② 壳参数(同下方 Props 表的 title / width / size / fullscreenable / closeOnClickModal / closeOnPressEscape / beforeClose / confirmText / cancelText / showConfirm / showCancel / lockWhenLoading / lockedTip / scrollable,bare 模式下忽略)
③ EP 原生属性:未在上面列出的属性(append-to-body、align-center、top、direction、modal-class 等)直接写在 options 里,经 $attrs 落到底层 el-dialog / el-drawer。
BizModalHandle(返回句柄)
| 成员 | 类型 | 说明 |
|---|---|---|
| key | string | 本次弹框键(传了 singleton 即其值,否则内部唯一键) |
| close | (result?: BizModalResult) => void | 编程式关闭(优雅走关闭动画),可指定结果;已销毁句柄为安全空操作 |
| promise | Promise<BizModalResult<T>> | 销毁完成时 resolve 一次,永不 reject;action:'confirm' 提交成功(data 为 submit 返回值)/ 'cancel' 取消按钮 / 'close' X、Esc、遮罩、编程式、页面切走 |
函数式场景:删除确认
替代 ElMessageBox 的业务化确认框:内容组件为纯展示型(不注册 submit),点确定即确认关闭,调用方在 onResult 里按 action 分支决定是否执行。
东门枪机摄像机东门岗亭在线
地库半球摄像机B2 车道入口离线
周界振动探测主机南围墙段 3在线
纯展示型内容组件不注册 submit,"删 除"即确认关闭;取消 / X / Esc 都不会执行删除(onResult 按 action 分支)。
取消 / X / Esc 都不会执行删除(
action 为 'cancel' / 'close');只有点"删 除"才移除列表行。<script setup lang="ts">
/**
* 场景:函数式删除确认——替代 ElMessageBox 的业务化确认框。
* 内容组件为纯展示型(不注册 submit 契约):点"删 除"直接确认关闭;
* 调用方在 onResult 里按 action 分支,只有 'confirm' 才执行删除。
*/
import { defineComponent, h, reactive } from 'vue'
import { ElAlert, ElButton, ElMessage, ElTag } from 'element-plus'
import { useBizModal } from 'vue3-ep-biz-lib'
interface Camera {
id: number
name: string
location: string
online: boolean
}
const cameras = reactive<Camera[]>([
{ id: 217, name: '东门枪机摄像机', location: '东门岗亭', online: true },
{ id: 233, name: '地库半球摄像机', location: 'B2 车道入口', online: false },
{ id: 126, name: '周界振动探测主机', location: '南围墙段 3', online: true },
])
/** 删除确认面板(纯展示):说明删除影响,不注册 submit——"确定"即确认关闭 */
const ConfirmPanel = defineComponent({
name: 'DeleteConfirmPanel',
props: { camera: { type: Object as () => Camera, required: true } },
setup(props) {
return () =>
h('div', { style: 'display:flex;flex-direction:column;gap:14px' }, [
h(ElAlert, {
type: 'warning',
showIcon: true,
closable: false,
title: `确认删除「${props.camera.name}」?`,
description: '删除后该设备的抓拍与录像关联数据将保留 30 天,期间配置不可恢复。',
}),
h(
'div',
{ style: 'display:flex;gap:16px;font-size:13px;color:var(--el-text-color-secondary)' },
[
h('span', null, `设备编号:CAM-${String(props.camera.id).padStart(4, '0')}`),
h('span', null, `安装位置:${props.camera.location}`),
h(ElTag, { type: props.camera.online ? 'success' : 'danger', size: 'small' }, { default: () => (props.camera.online ? '在线' : '离线') }),
],
),
])
},
})
const { openBizDialog } = useBizModal()
const confirmRemove = (camera: Camera): void => {
openBizDialog({
component: ConfirmPanel,
props: { camera },
title: '删除确认',
width: 'small',
confirmText: '删 除',
onResult: ({ action }) => {
// 只有点"删 除"才执行;取消 / X / Esc('cancel' / 'close')均不动作
if (action === 'confirm') {
const idx = cameras.findIndex((c) => c.id === camera.id)
if (idx > -1) cameras.splice(idx, 1)
ElMessage.success(`「${camera.name}」已删除`)
}
},
})
}
</script>
<template>
<div>
<div style="display: flex; flex-direction: column; gap: 8px">
<div
v-for="c in cameras"
:key="c.id"
style="display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px"
>
<span style="font-weight: 600; font-size: 14px; width: 150px">{{ c.name }}</span>
<span style="flex: 1; font-size: 13px; color: var(--el-text-color-secondary)">{{ c.location }}</span>
<ElTag :type="c.online ? 'success' : 'danger'" size="small">{{ c.online ? '在线' : '离线' }}</ElTag>
<ElButton size="small" type="danger" link @click="confirmRemove(c)">删除</ElButton>
</div>
<p v-if="cameras.length === 0" style="margin: 8px 0 0; font-size: 13px; color: var(--el-text-color-secondary)">
设备已全部删除(刷新页面可还原演示数据)
</p>
</div>
<p style="margin: 12px 0 0; font-size: 13px; color: var(--el-text-color-secondary); line-height: 1.8">
纯展示型内容组件不注册 submit,"删 除"即确认关闭;取消 / X / Esc 都不会执行删除(onResult 按 action 分支)。
</p>
</div>
</template>
显示代码
函数式场景:新建 / 编辑表单
submit 提交契约 + isDirty 脏检查全部引擎托管:确定按钮 loading(期间 X / Esc / 取 消 锁定)、校验失败保持打开、保存成功自动关框;有修改时点 X / Esc / 取 消 会先弹"放弃修改?"确认。
一号硬盘录像机NVR-8632N · 负责人:王强
A 区消防主机JTF-GOM/60ST · 负责人:李娜
周界振动探测主机AS-8300 · 负责人:赵敏
试试:清空名称点"确 定"(校验拦截保持打开);修改后点 X 或 Esc(脏检查先弹"放弃修改?"); 保存期间按钮 loading 且 X / 取 消 被锁定;保存成功后列表即时更新。
试试:清空名称点"确 定"(校验拦截)、修改后点 X(脏检查确认)、保存后列表即时更新;
singleton 让编辑不同行时始终展示当前行数据。<script setup lang="ts">
/**
* 场景:函数式新建/编辑表单——submit 提交契约 + isDirty 脏检查全托管。
* 引擎托管:确定按钮 loading(保存期间锁定 X/Esc/取消)、校验失败保持打开、
* 保存成功自动关框;有修改时点 X / Esc / 取 消 会先弹"放弃修改?"确认。
* singleton:编辑不同行时关旧开新,弹框内始终展示当前行数据。
*/
import { defineComponent, h, reactive } from 'vue'
import { ElButton, ElInput, ElMessage, ElOption, ElSelect } from 'element-plus'
import { useBizModal, useBizModalChild } from 'vue3-ep-biz-lib'
interface Device {
id: number
name: string
model: string
owner: string
}
const owners = ['王强', '李娜', '赵敏']
const devices = reactive<Device[]>([
{ id: 1, name: '一号硬盘录像机', model: 'NVR-8632N', owner: '王强' },
{ id: 2, name: 'A 区消防主机', model: 'JTF-GOM/60ST', owner: '李娜' },
{ id: 3, name: '周界振动探测主机', model: 'AS-8300', owner: '赵敏' },
])
let nextId = 4
/** 编辑/新建共用表单:submit = 校验 + 保存(返回值 → result.data);isDirty = 与初始快照比对 */
const DeviceFormPanel = defineComponent({
name: 'DeviceFormPanel',
props: { device: { type: Object as () => Device, required: true } },
setup(props) {
const form = reactive({ name: props.device.name, model: props.device.model, owner: props.device.owner })
const snapshot = JSON.stringify(form) // 打开瞬间快照,脏检查基准
const submit = async (): Promise<Device> => {
if (!form.name.trim()) {
ElMessage.warning('请填写设备名称') // 校验失败:提示 + throw → 弹框保持打开
throw new Error('name required')
}
const payload: Device = { ...props.device, ...form } // 提交瞬间快照(保存期间继续编辑不影响本次提交)
await new Promise((resolve) => setTimeout(resolve, 600)) // 模拟保存接口(期间确定按钮 loading)
return payload // 返回值 → BizModalResult.data
}
useBizModalChild({
submit,
isDirty: () => JSON.stringify(form) !== snapshot,
})
const label = 'font-size:14px;margin-bottom:6px'
return () =>
h('div', { style: 'display:flex;flex-direction:column;gap:14px' }, [
h('div', null, [
h('div', { style: label }, '设备名称(必填)'),
h(ElInput, {
modelValue: form.name,
'onUpdate:modelValue': (v: string) => (form.name = v),
placeholder: '请输入设备名称',
maxlength: 30,
}),
]),
h('div', null, [
h('div', { style: label }, '设备型号'),
h(ElInput, {
modelValue: form.model,
'onUpdate:modelValue': (v: string) => (form.model = v),
placeholder: '请输入型号',
}),
]),
h('div', null, [
h('div', { style: label }, '负责人'),
h(
ElSelect,
{ modelValue: form.owner, 'onUpdate:modelValue': (v: string) => (form.owner = v) },
{ default: () => owners.map((o) => h(ElOption, { key: o, label: o, value: o })) },
),
]),
])
},
})
const { openBizDialog } = useBizModal()
const openForm = (device: Device, title: string): void => {
openBizDialog<Device>({
component: DeviceFormPanel,
props: { device },
title,
width: 'small',
singleton: 'device-form', // 编辑不同行 → 关旧开新,始终展示当前行
onResult: ({ action, data }) => {
if (action !== 'confirm' || !data) return
if (data.id === 0) {
devices.push({ ...data, id: nextId++ }) // 新建:追加
} else {
const idx = devices.findIndex((d) => d.id === data.id)
if (idx > -1) devices[idx] = data // 编辑:就地替换
}
ElMessage.success('设备信息已保存')
},
})
}
const edit = (device: Device): void => openForm(device, `编辑设备 · ${device.name}`)
const create = (): void => openForm({ id: 0, name: '', model: '', owner: owners[0] }, '新增设备')
</script>
<template>
<div>
<div style="display: flex; flex-direction: column; gap: 8px">
<div
v-for="d in devices"
:key="d.id"
style="display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px"
>
<span style="font-weight: 600; font-size: 14px; width: 150px">{{ d.name }}</span>
<span style="flex: 1; font-size: 13px; color: var(--el-text-color-secondary)">{{ d.model }} · 负责人:{{ d.owner }}</span>
<ElButton size="small" type="primary" link @click="edit(d)">编辑</ElButton>
</div>
</div>
<div style="margin-top: 12px">
<ElButton type="primary" @click="create">新增设备</ElButton>
</div>
<p style="margin: 12px 0 0; font-size: 13px; color: var(--el-text-color-secondary); line-height: 1.8">
试试:清空名称点"确 定"(校验拦截保持打开);修改后点 X 或 Esc(脏检查先弹"放弃修改?");
保存期间按钮 loading 且 X / 取 消 被锁定;保存成功后列表即时更新。
</p>
</div>
</template>
显示代码
函数式场景:多选回传
泛型 openBizDialog<User[]>:submit 返回值即 result.data,props 回显已选——选择器 / 指派类弹窗的标准范式。
当前值班:王强 · 值班经理
泛型 `openBizDialog<User[]>`:submit 返回值即 `result.data`,指派结果直接回写页面;已选人员自动回显勾选态。
"至少一人"校验在 submit 内(为空提示并保持打开);确认指派后页面值班名单即时更新。
<script setup lang="ts">
/**
* 场景:函数式多选回传——泛型 openBizDialog<User[]> 把选择结果交给 promise / onResult。
* props.selected 回显已选;submit 内做"至少一人"校验,返回值即 result.data。
* 选择器 / 指派类弹窗的标准范式。
*/
import { defineComponent, h, ref } from 'vue'
import { ElButton, ElCheckbox, ElCheckboxGroup, ElMessage, ElTag } from 'element-plus'
import { useBizModal, useBizModalChild } from 'vue3-ep-biz-lib'
interface User {
id: string
name: string
dept: string
role: string
}
const staff: User[] = [
{ id: 'u01', name: '张伟', dept: '安全管理部', role: '安全负责人' },
{ id: 'u02', name: '李娜', dept: '研发中心', role: '应用负责人' },
{ id: 'u03', name: '王强', dept: '运维中心', role: '值班经理' },
{ id: 'u04', name: '赵敏', dept: '运维中心', role: '值守工程师' },
{ id: 'u05', name: '刘洋', dept: '安全管理部', role: '安全工程师' },
]
/** 值班人多选面板:勾选 → submit 返回 User[];空选校验在 submit 内 */
const DutyPickerPanel = defineComponent({
name: 'DutyPickerPanel',
props: { selected: { type: Array as () => string[], required: true } },
setup(props) {
const picked = ref<string[]>([...props.selected])
const submit = async (): Promise<User[]> => {
if (picked.value.length === 0) {
ElMessage.warning('请至少选择一名值班人')
throw new Error('empty selection')
}
await new Promise((resolve) => setTimeout(resolve, 300)) // 模拟指派接口
return staff.filter((u) => picked.value.includes(u.id))
}
useBizModalChild({ submit })
return () =>
h('div', { style: 'display:flex;flex-direction:column;gap:12px' }, [
h('div', { style: 'font-size:14px' }, `已选 ${picked.value.length} 人`),
h(
ElCheckboxGroup,
{
modelValue: picked.value,
'onUpdate:modelValue': (v: Array<string | number | boolean | undefined>) => {
picked.value = v.filter((x): x is string => typeof x === 'string')
},
},
{
default: () =>
h(
'div',
{ style: 'display:flex;flex-direction:column;gap:4px' },
staff.map((u) =>
h(
'div',
{
key: u.id,
style: 'display:flex;align-items:center;gap:10px;padding:7px 10px;border:1px solid var(--el-border-color-lighter);border-radius:6px',
},
[
h(ElCheckbox, { value: u.id }, { default: () => u.name }),
h('span', { style: 'flex:1;font-size:13px;color:var(--el-text-color-secondary)' }, `${u.dept} · ${u.role}`),
],
),
),
),
},
),
])
},
})
const { openBizDialog } = useBizModal()
const dutyStaff = ref<User[]>([staff[2]])
const openPicker = (): void => {
openBizDialog<User[]>({
component: DutyPickerPanel,
props: { selected: dutyStaff.value.map((u) => u.id) }, // 回显已选
title: '指派值班人(多选)',
width: 'medium',
confirmText: '确认指派',
onResult: ({ action, data }) => {
if (action === 'confirm' && data) {
dutyStaff.value = data
ElMessage.success(`已指派 ${data.length} 名值班人`)
}
},
})
}
</script>
<template>
<div>
<div style="display: flex; align-items: center; gap: 10px">
<span style="font-size: 14px">当前值班:</span>
<ElTag v-for="u in dutyStaff" :key="u.id" size="small">{{ u.name }} · {{ u.role }}</ElTag>
</div>
<div style="margin-top: 12px">
<ElButton type="primary" @click="openPicker">指派值班人</ElButton>
</div>
<p style="margin: 12px 0 0; font-size: 13px; color: var(--el-text-color-secondary); line-height: 1.8">
泛型 `openBizDialog<User[]>`:submit 返回值即 `result.data`,指派结果直接回写页面;已选人员自动回显勾选态。
</p>
</div>
</template>
显示代码
函数式场景:分步向导
关闭内置 footer(showConfirm / showCancel 均为 false),"上一步 / 下一步 / 完成"由内容组件自管;完成时经 useBizModalChild 的 close({ action: 'confirm', data }) 自关并交付结果(自关不经提交锁 / 脏检查拦截)。
还没有告警规则,点下方按钮创建一条
drawer 滑窗 + ElSteps 两步向导;未填规则名称时"完成并启用"被拦下;完成后结果经 promise 回写页面列表。
drawer 滑窗 + ElSteps 两步向导;未填规则名称时"完成并启用"被拦下;完成后结果经 promise 回写页面。
<script setup lang="ts">
/**
* 场景:函数式分步向导——关闭内置 footer(showConfirm / showCancel 均为 false),
* "上一步 / 下一步 / 完成"流程按钮由内容组件自管;完成时经 useBizModalChild
* 的 close({ action: 'confirm', data }) 自关并交付结果(自关不经提交锁/脏检查拦截)。
*/
import { defineComponent, h, ref } from 'vue'
import {
ElButton,
ElInput,
ElInputNumber,
ElMessage,
ElRadioButton,
ElRadioGroup,
ElStep,
ElSteps,
ElTag,
} from 'element-plus'
import { useBizModal, useBizModalChild } from 'vue3-ep-biz-lib'
type RuleType = 'threshold' | 'trend'
interface AlarmRule {
type: RuleType
name: string
threshold: number
windowMinutes: number
}
/** 两步向导面板:① 选规则类型 ② 配参数;完成时自关并交付配置结果 */
const AlarmRuleWizard = defineComponent({
name: 'AlarmRuleWizard',
setup() {
const step = ref(0)
const ruleType = ref<RuleType>('threshold')
const name = ref('')
const threshold = ref(80)
const windowMinutes = ref(15)
const { close } = useBizModalChild({}) // 只取自关能力,无 footer 提交
const finish = (): void => {
if (!name.value.trim()) {
ElMessage.warning('请填写规则名称')
return
}
// 自关并携带结果:与 promise/onResult 的 confirm 分支对接
close({
action: 'confirm',
data: { type: ruleType.value, name: name.value, threshold: threshold.value, windowMinutes: windowMinutes.value },
})
}
const label = 'font-size:14px;margin-bottom:6px'
return () =>
h('div', { style: 'display:flex;flex-direction:column;gap:20px' }, [
h(
ElSteps,
{ active: step.value, simple: true },
{
default: () => [
h(ElStep, { key: 1, title: '选择规则类型' }),
h(ElStep, { key: 2, title: '配置参数' }),
],
},
),
step.value === 0
? h(
ElRadioGroup,
{
modelValue: ruleType.value,
'onUpdate:modelValue': (v: string | number | boolean | undefined) => {
if (v === 'threshold' || v === 'trend') ruleType.value = v
},
},
{
default: () => [
h(ElRadioButton, { value: 'threshold' }, { default: () => '阈值告警' }),
h(ElRadioButton, { value: 'trend' }, { default: () => '趋势告警' }),
],
},
)
: h('div', { style: 'display:flex;flex-direction:column;gap:14px' }, [
h('div', null, [
h('div', { style: label }, '规则名称(必填)'),
h(ElInput, {
modelValue: name.value,
'onUpdate:modelValue': (v: string) => (name.value = v),
placeholder: '如:CPU 使用率过高',
maxlength: 20,
}),
]),
h('div', null, [
h('div', { style: label }, `触发阈值(${ruleType.value === 'threshold' ? '指标越过该值触发' : '累计变动超过该百分比触发'})`),
h(ElInputNumber, {
modelValue: threshold.value,
'onUpdate:modelValue': (v: number | undefined) => (threshold.value = v ?? 0),
min: 1,
max: 100,
}),
]),
h('div', null, [
h('div', { style: label }, '统计窗口(分钟)'),
h(ElInputNumber, {
modelValue: windowMinutes.value,
'onUpdate:modelValue': (v: number | undefined) => (windowMinutes.value = v ?? 1),
min: 1,
max: 60,
}),
]),
]),
// 流程按钮:无 footer 型面板,由内容组件自管
h('div', { style: 'display:flex;justify-content:flex-end;gap:10px' }, [
step.value === 1
? h(ElButton, { onClick: () => (step.value = 0) }, { default: () => '上一步' })
: null,
step.value === 0
? h(ElButton, { type: 'primary', onClick: () => (step.value = 1) }, { default: () => '下一步' })
: h(ElButton, { type: 'primary', onClick: finish }, { default: () => '完成并启用' }),
]),
])
},
})
const { openBizDrawer } = useBizModal()
const createdRules = ref<AlarmRule[]>([])
const openWizard = (): void => {
openBizDrawer<AlarmRule>({
component: AlarmRuleWizard,
title: '新建告警规则',
size: 'medium',
showConfirm: false, // 关闭内置 footer:向导流程按钮在内容区
showCancel: false,
}).promise.then(({ action, data }) => {
if (action === 'confirm' && data) {
createdRules.value.push(data)
ElMessage.success(`规则「${data.name}」已启用`)
}
})
}
</script>
<template>
<div>
<div style="display: flex; flex-direction: column; gap: 8px">
<div
v-for="(r, i) in createdRules"
:key="`${r.name}-${i}`"
style="display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px"
>
<ElTag :type="r.type === 'threshold' ? 'warning' : 'success'" size="small">
{{ r.type === 'threshold' ? '阈值' : '趋势' }}
</ElTag>
<span style="font-weight: 600; font-size: 14px">{{ r.name }}</span>
<span style="flex: 1; font-size: 13px; color: var(--el-text-color-secondary)">
阈值 {{ r.threshold }} · 窗口 {{ r.windowMinutes }} 分钟
</span>
</div>
<p v-if="createdRules.length === 0" style="margin: 8px 0 0; font-size: 13px; color: var(--el-text-color-secondary)">
还没有告警规则,点下方按钮创建一条
</p>
</div>
<div style="margin-top: 12px">
<ElButton type="primary" @click="openWizard">新建告警规则(向导)</ElButton>
</div>
<p style="margin: 12px 0 0; font-size: 13px; color: var(--el-text-color-secondary); line-height: 1.8">
drawer 滑窗 + ElSteps 两步向导;未填规则名称时"完成并启用"被拦下;完成后结果经 promise 回写页面列表。
</p>
</div>
</template>
显示代码
函数式场景:自动关闭公告
句柄编程式关闭:超时 handle.close() 与用户确认(action: 'confirm')经 promise 区分来源;句柄关闭幂等,用户先关后超时触发无副作用。
两条关闭路径经 promise 区分:点"知道了"为 confirm;8 秒无操作由 handle.close() 自动关(action='close')。 句柄关闭幂等,用户先关后超时触发无副作用。
8 秒无操作自动关闭;点"知道了"为确认已读,两条路径分别提示。
<script setup lang="ts">
/**
* 场景:函数式公告弹窗——句柄编程式关闭 + promise 区分关闭来源。
* 8 秒无人操作由调用方 handle.close() 自动关(action='close');
* 用户点"知道了"则为确认(action='confirm'),两条路径分别处理。
*/
import { defineComponent, h, onUnmounted, ref } from 'vue'
import { ElButton, ElMessage, ElTag } from 'element-plus'
import { useBizModal } from 'vue3-ep-biz-lib'
const NOTICE_SECONDS = 8
/** 公告面板(纯展示):倒计时仅作提示,超时关闭由调用方句柄驱动 */
const NoticePanel = defineComponent({
name: 'NoticePanel',
props: { seconds: { type: Number, required: true } },
setup(props) {
const remain = ref(props.seconds)
const timer = window.setInterval(() => {
remain.value = Math.max(0, remain.value - 1)
}, 1000)
onUnmounted(() => window.clearInterval(timer)) // 弹框销毁(任一路径关闭)即回收
const items = [
'新增级联滑窗:副滑窗紧贴主滑窗左缘并排滑出,主/副同屏交互',
'尺寸预设统一为 small / medium / large 完整单词写法',
'函数式场景示例扩充:删除确认、多选回传、分步向导、bare 自含壳',
]
return () =>
h('div', { style: 'display:flex;flex-direction:column;gap:12px' }, [
h('p', { style: 'margin:0;font-weight:600;font-size:15px' }, 'v1.1.0 更新公告'),
h(
'ul',
{
style:
'margin:0;padding-left:18px;display:flex;flex-direction:column;gap:6px;font-size:13px;color:var(--el-text-color-regular);line-height:1.7',
},
items.map((t) => h('li', { key: t }, t)),
),
h(ElTag, { type: 'info', size: 'small' }, { default: () => `${remain.value}s 后自动关闭` }),
])
},
})
const { openBizDialog } = useBizModal()
let autoCloseTimer: number | undefined
const showNotice = (): void => {
window.clearTimeout(autoCloseTimer)
const handle = openBizDialog({
component: NoticePanel,
props: { seconds: NOTICE_SECONDS },
title: '系统公告',
width: 'small',
showCancel: false,
confirmText: '知道了',
})
// 编程式关闭:超时兜底(句柄 close 幂等,用户先关也无副作用)
autoCloseTimer = window.setTimeout(() => handle.close(), NOTICE_SECONDS * 1000)
handle.promise.then(({ action }) => {
window.clearTimeout(autoCloseTimer)
if (action === 'confirm') ElMessage.success('公告已读')
else if (action === 'close') ElMessage.info('公告超时自动关闭')
})
}
</script>
<template>
<div>
<ElButton type="primary" @click="showNotice">查看更新公告</ElButton>
<p style="margin: 12px 0 0; font-size: 13px; color: var(--el-text-color-secondary); line-height: 1.8">
两条关闭路径经 promise 区分:点"知道了"为 confirm;8 秒无操作由 handle.close() 自动关(action='close')。
句柄关闭幂等,用户先关后超时触发无副作用。
</p>
</div>
</template>
显示代码
函数式场景:bare 自含壳
复用自带 el-dialog 的存量组件:遵守 modelValue / update:modelValue / closed / confirm(数据) 约定即可纳入引擎(promise 交付、关闭即销毁、编程式关闭),无需改造组件。
当前设备名:东门枪机摄像机
内容组件自带 el-dialog 壳(左上方源码 legacy-device-dialog.vue):保存走组件自身 footer, emit('confirm', data) 的数据经 onResult 回写页面;关闭动画结束后引擎自动销毁,无需手动清理。
内容组件(legacy-device-dialog.vue)自带 el-dialog 壳,
bare: true 声明后引擎注入关闭约定;保存结果经 onResult 回写页面。<script setup lang="ts">
/**
* 场景:bare 自含壳模式——复用自带 el-dialog 的存量组件。
* 组件遵守 modelValue / update:modelValue / closed / confirm(数据) 约定即可
* 纳入函数式引擎:promise 结果交付、关闭即销毁、编程式关闭,无需改造组件。
*/
import { ref } from 'vue'
import { ElButton, ElMessage } from 'element-plus'
import { useBizModal } from 'vue3-ep-biz-lib'
import LegacyDeviceDialog from './legacy-device-dialog.vue'
const { openBizDialog } = useBizModal()
const deviceName = ref('东门枪机摄像机')
const rename = (): void => {
openBizDialog<string>({
component: LegacyDeviceDialog, // 自带 el-dialog 壳的存量组件
bare: true, // 声明自含壳:引擎注入关闭约定,不再包 BizModal 壳
props: { deviceName: deviceName.value },
onResult: ({ action, data }) => {
if (action === 'confirm' && typeof data === 'string') {
deviceName.value = data
ElMessage.success('设备名称已更新')
}
},
})
}
</script>
<template>
<div>
<p style="margin: 0 0 12px; font-size: 14px">
当前设备名:<span style="font-weight: 600">{{ deviceName }}</span>
</p>
<ElButton type="primary" @click="rename">重命名(bare 模式)</ElButton>
<p style="margin: 12px 0 0; font-size: 13px; color: var(--el-text-color-secondary); line-height: 1.8">
内容组件自带 el-dialog 壳(左上方源码 legacy-device-dialog.vue):保存走组件自身 footer,
emit('confirm', data) 的数据经 onResult 回写页面;关闭动画结束后引擎自动销毁,无需手动清理。
</p>
</div>
</template>
显示代码
工单详情弹窗(只读)
详情场景:show-confirm=false 仅留关闭按钮、#header 插槽挂状态标签、width=large 宽幅 + 长内容滚动。
<script setup lang="ts">
/** 场景:工单详情弹窗——只读内容、footer 仅关闭、长内容独立滚动 */
import { ref } from 'vue'
import { ElButton, ElTag } from 'element-plus'
import { BizDescriptions, BizModal } from 'vue3-ep-biz-lib'
import type { BizDescriptionsItem } from 'vue3-ep-biz-lib'
const visible = ref(false)
const items: BizDescriptionsItem[] = [
{ label: '工单号', prop: 'no' },
{ label: '类型', prop: 'type' },
{ label: '优先级', prop: 'level' },
{ label: '受理组', prop: 'group' },
{ label: '创建时间', prop: 'createdAt' },
{ label: '期望完成', prop: 'deadline' },
{ label: '故障描述', prop: 'desc', span: 3 },
{ label: '处理记录', prop: 'log', span: 3 },
]
const detail: Record<string, unknown> = {
no: 'WO-20260923-0088',
type: '硬件故障',
level: 'P2',
group: '华东运维二组',
createdAt: '2026-09-23 09:12:40',
deadline: '2026-09-23 18:00',
desc: '2 号线输送机安全光幕联锁停机,复位后 10 分钟内再次触发,现场已隔离。',
log: '09:15 张工接单并到场;09:40 确认光幕被物料遮挡;10:02 加装临时导料板并恢复运行,观察中;14:00 复测无复发,待关闭。',
}
</script>
<template>
<div>
<ElButton type="primary" @click="visible = true">查看工单详情</ElButton>
<BizModal v-model="visible" title="工单详情" width="large" :show-confirm="false" cancel-text="关 闭">
<template #header>
<span>工单详情</span>
<ElTag size="small" type="warning" style="margin-left: 8px">处理中</ElTag>
</template>
<BizDescriptions :data="detail" :items="items" :column="3" border label-width="80px" />
</BizModal>
</div>
</template>
显示代码
长表单滑窗
开票信息维护
drawer 场景:42% 宽滑窗 + 分块表单(抬头 / 银行信息),scrollable 使 footer 保存按钮常驻不随内容滚走。
<script setup lang="ts">
/** 场景:长表单滑窗——drawer 42% + 分块表单,footer 常驻不随内容滚走 */
import { ref } from 'vue'
import { ElButton, ElInput, ElMessage, ElOption, ElSelect } from 'element-plus'
import { BizDivideTitle, BizModal } from 'vue3-ep-biz-lib'
const visible = ref(false)
const saving = ref(false)
const onSave = (): void => {
saving.value = true
window.setTimeout(() => {
saving.value = false
visible.value = false
ElMessage.success('开票信息已保存')
}, 800)
}
</script>
<template>
<div>
<ElButton type="primary" @click="visible = true">维护开票信息</ElButton>
<BizModal v-model="visible" mode="drawer" title="开票信息维护" size="42%" :confirm-loading="saving" confirm-text="保存开票信息" @confirm="onSave">
<BizDivideTitle title="抬头信息" divider>
<div style="display: grid; grid-template-columns: 1fr; gap: 12px; padding: 12px 0">
<ElInput model-value="云联科技(南京)有限公司" placeholder="单位名称" />
<ElInput model-value="91320114MA1X9Y2K3L" placeholder="纳税人识别号" maxlength="18" />
<ElInput model-value="江苏省南京市雨花台区软件大道 100 号" placeholder="注册地址" />
</div>
</BizDivideTitle>
<BizDivideTitle title="银行与联系方式" divider>
<div style="display: grid; grid-template-columns: 1fr; gap: 12px; padding: 12px 0">
<ElInput model-value="工商银行南京软件大道支行 4300 0212 9900 0668" placeholder="开户银行及账号" />
<ElInput model-value="025-88886666" placeholder="注册电话" />
<ElSelect model-value="special" placeholder="发票类型">
<ElOption label="增值税专用发票" value="special" />
<ElOption label="增值税普通发票" value="normal" />
</ElSelect>
</div>
</BizDivideTitle>
</BizModal>
</div>
</template>
显示代码
弹窗中开弹窗
嵌套场景:外层声明式列表弹窗 → 行内「详情」按钮函数式打开子弹窗(appendToBody),onResult 回调感知关闭动作。
<script setup lang="ts">
/** 场景:弹窗中再开弹窗——列表弹窗 → 人员详情子弹窗(函数式嵌套) */
import { defineComponent, h, ref } from 'vue'
import { ElButton, ElMessage } from 'element-plus'
import { BizModal, openBizDialog } from 'vue3-ep-biz-lib'
interface Member {
id: number
name: string
dept: string
role: string
}
const list: Member[] = [
{ id: 1, name: '张伟', dept: '安全管理部', role: '安全负责人' },
{ id: 2, name: '李娜', dept: '研发中心', role: '应用负责人' },
{ id: 3, name: '王强', dept: '运维中心', role: '值班经理' },
]
const visible = ref(false)
/** 人员详情内容组件(render 函数示例,业务侧推荐独立 SFC) */
const DetailPanel = defineComponent({
name: 'MemberDetailPanel',
props: { member: { type: Object as () => Member, required: true } },
setup(props) {
return () =>
h('div', { style: 'display:flex;flex-direction:column;gap:10px;line-height:1.9' }, [
h('p', { style: 'margin:0;font-weight:600;font-size:15px' }, `${props.member.name} · ${props.member.role}`),
h('p', { style: 'margin:0;color:var(--el-text-color-regular)' }, `所属部门:${props.member.dept}`),
h('p', { style: 'margin:0;color:var(--el-text-color-secondary);font-size:13px' }, `内部工号:EMP-${String(props.member.id).padStart(4, '0')}`),
h('p', { style: 'margin:0;color:var(--el-text-color-secondary);font-size:13px' }, '近 30 天参与审批 12 次,平均响应 26 分钟'),
])
},
})
const showDetail = (member: Member): void => {
openBizDialog({
component: DetailPanel,
props: { member },
title: `人员详情 · ${member.name}`,
width: 'small',
appendToBody: true,
onResult: ({ action }) => {
if (action === 'close') ElMessage.info('已关闭子弹窗')
},
})
}
</script>
<template>
<div>
<ElButton type="primary" @click="visible = true">选择审批人</ElButton>
<BizModal v-model="visible" title="选择审批人" width="small">
<div style="display: flex; flex-direction: column; gap: 8px">
<div
v-for="member in list"
:key="member.id"
style="display: flex; align-items: center; gap: 10px; padding: 8px 10px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px"
>
<span style="font-weight: 600; width: 56px">{{ member.name }}</span>
<span style="flex: 1; font-size: 13px; color: var(--el-text-color-secondary)">{{ member.dept }} · {{ member.role }}</span>
<ElButton link type="primary" size="small" @click="showDetail(member)">详情</ElButton>
</div>
</div>
</BizModal>
</div>
</template>
显示代码
级联滑窗(二级滑窗)
函数式滑窗传 cascade: true:副滑窗不覆盖主滑窗,而是紧贴主滑窗左缘并排滑出,且无遮罩、点击穿透——主/副滑窗同时可见、同时可操作,适配「列表 + 详情」「表单 + 选择器」这类需要两栏对照联动的业务场景。
- 副滑窗从主滑窗左缘滑出(滑入动画与右滑一致,方向感自然),主滑窗不被遮盖、不被锁交互
- 多级级联:三级、四级滑窗依次继续向左排布(相邻间距 16px),同屏共存
- Esc / 关闭按钮只作用于最顶层滑窗(EP 焦点陷阱栈保证);主滑窗关闭时,其内容组件经
useBizModal打开的副滑窗随调用方作用域自动联动关闭 - 当前没有已打开的滑窗时,
cascade自动退化为普通右滑窗;仅openBizDrawer壳托管模式支持(dialog/bare传了会被忽略并 DEV 警告)
依次体验:行内「详情」→ 副滑窗紧贴主滑窗左缘滑出且互不遮盖;副滑窗内「物流跟踪」→ 三级滑窗继续左移; 副滑窗改备注后「确 定」→ 主滑窗列表实时更新;Esc 只关最顶层滑窗。
主滑窗工单列表 → 行内「详情」级联打开副滑窗(紧贴左缘、互不遮盖)→ 副滑窗内再开「物流跟踪」三级滑窗继续左移;副滑窗改备注「确 定」后经 promise 回传,主滑窗列表实时更新;singleton 让换行查看始终关旧开新。
<script setup lang="ts">
/**
* 场景:级联滑窗——工单列表(主滑窗)→ 行内「详情」在主滑窗左侧并排滑出副滑窗(cascade)
* → 副滑窗内再开「物流跟踪」三级滑窗继续左移。主/副滑窗无遮罩互不遮盖、可同时操作;
* 副滑窗保存备注经 submit 契约 → promise 回传,主滑窗列表实时更新。
*/
import { defineComponent, h, reactive, ref } from 'vue'
import {
ElButton,
ElInput,
ElMessage,
ElTag,
ElTimeline,
ElTimelineItem,
} from 'element-plus'
import { useBizModal, useBizModalChild } from 'vue3-ep-biz-lib'
interface Ticket {
id: string
title: string
status: '待接单' | '处理中' | '已完成'
handler: string
remark: string
}
const tickets = reactive<Ticket[]>([
{ id: 'WO-1024', title: '东区门禁读卡器离线', status: '处理中', handler: '王强', remark: '已派单现场排查' },
{ id: 'WO-1025', title: '3 号楼消防主机误报', status: '待接单', handler: '—', remark: '' },
{ id: 'WO-1026', title: '周界围栏振动告警', status: '已完成', handler: '李娜', remark: '加固处理后恢复' },
])
type TagType = 'info' | 'warning' | 'success'
const statusTagType = (status: Ticket['status']): TagType =>
status === '待接单' ? 'info' : status === '处理中' ? 'warning' : 'success'
/** 三级滑窗:物流跟踪时间线(纯展示,useBizModalChild 获得自关能力) */
const TracePanel = defineComponent({
name: 'TicketTracePanel',
props: { ticketId: { type: String, required: true } },
setup(props) {
const { close } = useBizModalChild({})
const steps = [
{ text: `${props.ticketId} 创建 · 系统`, time: '10-21 09:15' },
{ text: '派单至运维中心 · 王强', time: '10-21 09:22' },
{ text: '现场排查 · 初判电源故障', time: '10-21 10:40' },
{ text: '备件更换完成 · 待复核', time: '10-21 14:05' },
]
return () =>
h('div', { style: 'display:flex;flex-direction:column;gap:20px' }, [
h(
ElTimeline,
null,
{
default: () =>
steps.map((s, i) =>
h(
ElTimelineItem,
{ key: s.time, timestamp: s.time, type: i === steps.length - 1 ? 'primary' : undefined },
{ default: () => s.text },
),
),
},
),
h(ElButton, { onClick: () => close() }, { default: () => '知道了,关闭' }),
])
},
})
/** 副滑窗:工单详情(备注经 submit 契约回传;按钮再级联打开物流跟踪三级滑窗) */
const DetailPanel = defineComponent({
name: 'TicketDetailPanel',
props: { ticket: { type: Object as () => Ticket, required: true } },
setup(props) {
const remark = ref(props.ticket.remark)
const { openBizDrawer } = useBizModal()
const submit = async (): Promise<string> => {
await new Promise((resolve) => setTimeout(resolve, 400)) // 模拟保存接口
return remark.value
}
useBizModalChild({
submit,
isDirty: () => remark.value !== props.ticket.remark,
})
const openTrace = (): void => {
openBizDrawer({
component: TracePanel,
props: { ticketId: props.ticket.id },
title: `物流跟踪 · ${props.ticket.id}`,
size: 320,
cascade: true, // 三级滑窗:继续紧贴副滑窗左缘滑出
showConfirm: false,
showCancel: false,
})
}
const rows: Array<[string, string]> = [
['工单号', props.ticket.id],
['故障标题', props.ticket.title],
['当前处理人', props.ticket.handler],
]
return () =>
h('div', { style: 'display:flex;flex-direction:column;gap:14px' }, [
...rows.map(([label, value]) =>
h('div', { key: label, style: 'display:flex;gap:12px;font-size:14px;line-height:1.9' }, [
h('span', { style: 'width:76px;color:var(--el-text-color-secondary);flex-shrink:0' }, label),
h('span', null, value),
]),
),
h('div', { style: 'display:flex;gap:12px;font-size:14px;line-height:1.9' }, [
h('span', { style: 'width:76px;color:var(--el-text-color-secondary);flex-shrink:0' }, '状态'),
h(ElTag, { type: statusTagType(props.ticket.status), size: 'small' }, { default: () => props.ticket.status }),
]),
h('div', { style: 'display:flex;flex-direction:column;gap:8px' }, [
h('span', { style: 'font-size:14px' }, '处理备注(确定后同步主滑窗列表)'),
h(ElInput, {
modelValue: remark.value,
'onUpdate:modelValue': (v: string) => (remark.value = v),
type: 'textarea',
rows: 3,
placeholder: '填写处理备注',
}),
]),
h(ElButton, { link: true, type: 'primary', onClick: openTrace }, { default: () => '查看物流跟踪(三级滑窗)' }),
])
},
})
/** 主滑窗:工单列表(行内详情级联打开;副滑窗保存后列表实时更新) */
const ListPanel = defineComponent({
name: 'TicketListPanel',
setup() {
const { openBizDrawer } = useBizModal()
const showDetail = (ticket: Ticket): void => {
openBizDrawer({
component: DetailPanel,
props: { ticket },
title: `工单详情 · ${ticket.id}`,
size: 400,
cascade: true, // 副滑窗:紧贴主滑窗左缘并排滑出,不遮盖
singleton: 'ticket-detail', // 换行查看时关旧开新,始终展示当前行
}).promise.then(({ action, data }) => {
if (action === 'confirm' && typeof data === 'string' && data !== ticket.remark) {
ticket.remark = data // 主/副滑窗联动:副滑窗结果实时回写列表行
ElMessage.success(`${ticket.id} 备注已同步`)
}
})
}
return () =>
h(
'div',
{ style: 'display:flex;flex-direction:column;gap:10px' },
tickets.map((t) =>
h(
'div',
{
key: t.id,
style: 'display:flex;flex-direction:column;gap:6px;padding:10px 12px;border:1px solid var(--el-border-color-lighter);border-radius:6px',
},
[
h('div', { style: 'display:flex;align-items:center;justify-content:space-between;gap:10px' }, [
h('span', { style: 'font-weight:600;font-size:14px' }, `${t.id} · ${t.title}`),
h(ElTag, { type: statusTagType(t.status), size: 'small' }, { default: () => t.status }),
]),
h('span', { style: 'font-size:13px;color:var(--el-text-color-secondary)' }, `处理人:${t.handler} 备注:${t.remark || '—'}`),
h('div', null, [
h(
ElButton,
{ size: 'small', type: 'primary', link: true, onClick: () => showDetail(t) },
{ default: () => '详情(级联副滑窗)' },
),
]),
],
),
),
)
},
})
const { openBizDrawer } = useBizModal()
const openList = (): void => {
openBizDrawer({
component: ListPanel,
title: '工单列表(主滑窗)',
size: 520,
})
}
</script>
<template>
<div>
<ElButton type="primary" @click="openList">打开工单列表(主滑窗)</ElButton>
<p style="margin:12px 0 0;font-size:13px;color:var(--el-text-color-secondary);line-height:1.8">
依次体验:行内「详情」→ 副滑窗紧贴主滑窗左缘滑出且互不遮盖;副滑窗内「物流跟踪」→ 三级滑窗继续左移;
副滑窗改备注后「确 定」→ 主滑窗列表实时更新;Esc 只关最顶层滑窗。
</p>
</div>
</template>
显示代码
点「选择设备」→ 选择器在主滑窗左侧并排滑出;两栏同屏,主滑窗表单仍可操作; 点选设备后回填主表单继续编辑;未选设备直接提交会被校验拦下(弹框保持打开)。
表单 + 选择器双栏:主滑窗填写调拨单,点「选择设备」在左侧级联打开选择器,同屏边看边选;点选设备经
close({ action: 'confirm', data }) 自关并回填主表单,主滑窗草稿不丢、继续编辑;未选设备直接提交会被 submit 校验拦下(保持打开)。<script setup lang="ts">
/**
* 场景:表单 + 选择器(级联滑窗)——主滑窗填写调拨单,点「选择设备」在主滑窗左侧
* 级联打开选择器;双栏同屏边看边选,点选设备经 promise 回填主表单,主滑窗草稿不丢、继续编辑。
* 提交校验失败(未选设备)保持打开,是 submit 契约 + 级联滑窗的组合用法。
*/
import { defineComponent, h, ref } from 'vue'
import { ElButton, ElInput, ElInputNumber, ElMessage, ElTag } from 'element-plus'
import { useBizModal, useBizModalChild } from 'vue3-ep-biz-lib'
interface Device {
code: string
name: string
model: string
online: boolean
}
const devices: Device[] = [
{ code: 'CAM-0217', name: '东门枪机摄像机', model: 'DS-2CD3T46', online: true },
{ code: 'CAM-0233', name: '地库半球摄像机', model: 'DS-IPC6212', online: false },
{ code: 'DVR-0008', name: '一号硬盘录像机', model: 'NVR-8632N', online: true },
{ code: 'ALM-0126', name: '周界振动探测主机', model: 'AS-8300', online: true },
{ code: 'ALM-0141', name: '二号振动探测主机', model: 'AS-8300', online: false },
]
/** 副滑窗:设备选择器(搜索过滤;点选行即携带结果自关,promise 回填主表单) */
const DevicePicker = defineComponent({
name: 'DevicePickerPanel',
setup() {
const keyword = ref('')
const { close } = useBizModalChild({})
const matched = (): Device[] => {
const kw = keyword.value.trim().toLowerCase()
return kw ? devices.filter((d) => `${d.name}${d.code}`.toLowerCase().includes(kw)) : devices
}
const pick = (device: Device): void => {
close({ action: 'confirm', data: device })
}
return () =>
h('div', { style: 'display:flex;flex-direction:column;gap:10px' }, [
h(ElInput, {
modelValue: keyword.value,
'onUpdate:modelValue': (v: string) => (keyword.value = v),
placeholder: '搜索设备名称 / 编号',
clearable: true,
}),
matched().length === 0
? h('p', { style: 'margin:8px 0;font-size:13px;color:var(--el-text-color-secondary)' }, '无匹配设备')
: h(
'div',
{ style: 'display:flex;flex-direction:column;gap:8px' },
matched().map((d) =>
h(
'div',
{
key: d.code,
style: 'display:flex;align-items:center;gap:10px;padding:9px 12px;border:1px solid var(--el-border-color-lighter);border-radius:6px;cursor:pointer;transition:border-color .15s',
onClick: () => pick(d),
onMouseenter: (e: MouseEvent) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--el-color-primary)'),
onMouseleave: (e: MouseEvent) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--el-border-color-lighter)'),
},
[
h('div', { style: 'flex:1;display:flex;flex-direction:column;gap:2px' }, [
h('span', { style: 'font-weight:600;font-size:14px' }, d.name),
h('span', { style: 'font-size:12px;color:var(--el-text-color-secondary)' }, `${d.code} · ${d.model}`),
]),
h(ElTag, { type: d.online ? 'success' : 'danger', size: 'small' }, { default: () => (d.online ? '在线' : '离线') }),
],
),
),
),
h('p', { style: 'margin:4px 0 0;font-size:12px;color:var(--el-text-color-secondary)' }, '点击任一设备即回填主表单并关闭本滑窗;主滑窗草稿保持不变'),
])
},
})
/** 主滑窗:新建调拨单(设备经左侧选择器级联选取;未选设备提交校验失败保持打开) */
const AllocationForm = defineComponent({
name: 'AllocationFormPanel',
setup() {
const device = ref<Device | null>(null)
const count = ref(1)
const reason = ref('')
const { openBizDrawer } = useBizModal()
const pickDevice = (): void => {
openBizDrawer<Device>({
component: DevicePicker,
title: '选择设备',
size: 420,
cascade: true, // 副滑窗紧贴主滑窗左缘,双栏同屏边看边选
showConfirm: false,
showCancel: false,
}).promise.then(({ action, data }) => {
if (action === 'confirm' && data) device.value = data
})
}
const submit = async (): Promise<{ device: Device; count: number }> => {
if (!device.value) {
ElMessage.warning('请先在左侧选择器中选取调拨设备')
throw new Error('device required') // 校验失败 → 弹框保持打开
}
await new Promise((resolve) => setTimeout(resolve, 400)) // 模拟提交接口
return { device: device.value, count: count.value }
}
useBizModalChild({
submit,
isDirty: () => device.value !== null || reason.value !== '',
})
const labelStyle = 'font-size:14px;margin-bottom:6px'
return () =>
h('div', { style: 'display:flex;flex-direction:column;gap:14px' }, [
h('div', null, [
h('div', { style: labelStyle }, '调拨设备'),
h('div', { style: 'display:flex;gap:8px' }, [
h(ElInput, {
modelValue: device.value ? `${device.value.name}(${device.value.code})` : '',
placeholder: '点右侧按钮,在左侧滑窗中选择',
readonly: true,
}),
h(ElButton, { onClick: pickDevice }, { default: () => '选择设备' }),
]),
]),
h('div', null, [
h('div', { style: labelStyle }, '调拨数量'),
h(ElInputNumber, {
modelValue: count.value,
'onUpdate:modelValue': (v: number | undefined) => (count.value = v ?? 1),
min: 1,
max: 99,
}),
]),
h('div', null, [
h('div', { style: labelStyle }, '调拨原因'),
h(ElInput, {
modelValue: reason.value,
'onUpdate:modelValue': (v: string) => (reason.value = v),
type: 'textarea',
rows: 3,
placeholder: '选填',
}),
]),
])
},
})
const { openBizDrawer } = useBizModal()
interface AllocationPayload {
device: Device
count: number
}
const openForm = (): void => {
openBizDrawer<AllocationPayload>({
component: AllocationForm,
title: '新建调拨单',
size: 480,
confirmText: '提交调拨单',
}).promise.then(({ action, data }) => {
if (action === 'confirm' && data) {
ElMessage.success(`调拨单已提交:${data.device.name} × ${data.count}`)
}
})
}
</script>
<template>
<div>
<ElButton type="primary" @click="openForm">新建调拨单(主滑窗)</ElButton>
<p style="margin:12px 0 0;font-size:13px;color:var(--el-text-color-secondary);line-height:1.8">
点「选择设备」→ 选择器在主滑窗左侧并排滑出;两栏同屏,主滑窗表单仍可操作;
点选设备后回填主表单继续编辑;未选设备直接提交会被校验拦下(弹框保持打开)。
</p>
</div>
</template>
显示代码
Props
| 属性名 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| mode | 'dialog' | 'drawer' | 'dialog' | 弹层模式:居中弹框 / 侧向滑窗 |
modelValue / v-model | boolean | true | 弹层显隐 |
| title | string | '' | 标题 |
| width | string | number | 'small' | 'medium' | 'large' | 'medium' | 弹框宽度(dialog):尺寸预设映射 --biz-modal-width-small/medium/large(480/560/720px) |
| size | string | number | 'small' | 'medium' | 'large' | '40%' | 滑窗尺寸(drawer),预设同上 |
| fullscreen | boolean | false | 初始是否全屏(dialog);支持 v-model:fullscreen 受控 |
| fullscreenable | boolean | false | 标题栏显示"全屏切换"按钮(dialog) |
| closeOnClickModal | boolean | false | 点击遮罩关闭(默认关,防表单误触) |
| closeOnPressEscape | boolean | true | 按 Esc 关闭 |
| beforeClose | (done: () => void) => void | — | 关闭前拦截;需要关闭时必须调用 done() |
| confirmText | string | '确 定' | 确定按钮文案 |
| cancelText | string | '取 消' | 取消按钮文案 |
| showConfirm | boolean | true | 是否显示确定按钮 |
| showCancel | boolean | true | 是否显示取消按钮(两者都 false 时隐藏整个 footer,除非提供 #footer) |
| confirmLoading | boolean | false | 确定按钮 loading(声明式受控;函数式由引擎自动管理) |
| lockWhenLoading | boolean | true | loading 期间锁定用户关闭路径(X/Esc/遮罩/取消),防"请求已发出但用户关框" |
| lockedTip | string | '正在处理,请稍候…' | 锁定拦截时的提示文案(传空串关闭提示) |
| scrollable | boolean | true | 内容超长独立滚动(最大高度 --biz-modal-body-max-height,默认 60vh),footer 常驻 |
Events
| 事件名 | 参数 | 说明 |
|---|---|---|
| update:modelValue | (value: boolean) | 显隐更新(v-model) |
| update:fullscreen | (value: boolean) | 全屏状态更新(v-model:fullscreen) |
| confirm | — | 点击"确 定"(声明式用法在此编排校验/提交;函数式由引擎接管 submit 契约) |
| cancel | — | 点击"取 消"(放行后自动关闭) |
| opened | — | 打开动画结束(透传 el-dialog/el-drawer) |
| closed | — | 关闭动画结束(透传;函数式引擎在此销毁子树) |
Slots
| 插槽名 | 说明 |
|---|---|
| default | 内容区(函数式模式为业务内容组件) |
| header | 自定义标题区(透传 EP header 插槽作用域;启用后 title 由该插槽渲染,dialog 模式追加全屏按钮) |
| footer | 自定义底部区(覆盖内置按钮) |
Expose
| 属性/方法 | 说明 |
|---|---|
| close | 编程式关闭(引擎 handle.close() 内部使用;声明式可直接调) |
| setConfirmLoading | 设置确定按钮 loading(函数式引擎在 submit 前后调用) |
| getChildSubmit | 取内容组件经 useBizModalChild 注册的 submit(未注册返回 undefined) |
| toggleFullscreen | 切换全屏状态(dialog) |
与参考实现(ProModal)的增强对照
| 能力 | ProModal | BizModal |
|---|---|---|
| 长内容滚动 | 整体撑高,footer 随内容滚走 | body 独立滚动 + footer 常驻(scrollable) |
| 尺寸规范 | 仅字符串宽度 | small / medium / large 预设 token,全局统一覆盖 |
| 懒加载反馈 | body 空白 | loading 占位视图(120ms 防闪) |
| 提交锁反馈 | 静默拦截 | lockedTip warning 提示(可关) |
| 全屏 | 仅 EP 静态 prop | fullscreenable 标题栏切换 + v-model |
| 结果消费 | 仅 promise | + onResult 快捷回调 |
| 全局治理 | 无 | closeAllBizModals() |
| TS 类型 | any 泛型擦除 | 全量 unknown + 泛型 openBizDialog<TData>() |
| 库化依赖 | 静态依赖 vue-router / import.meta.env | 运行时探测 $route;env 安全访问 |