Modal 弹窗
弹窗 —— 内置 portal、focus trap、滚动锁、tone 变体、异步确认、可拖拽、可缩放、命令式服务、多模态栈管理。
基础用法
v-model:open / open + onOpenChange 双向绑定。组件内部自动处理:portal 到 body、focus trap、body 滚动锁、Esc 关闭、遮罩关闭、进出 fade + scale 动画。
<script setup lang="ts">
import { ref } from 'vue';
import { CfButton, CfModal } from '@chufix-design/vue';
const open = ref(false);
</script>
<template>
<CfButton @click="open = true">打开 Modal</CfButton>
<CfModal v-model:open="open" title="确认操作">
<p style="margin: 0;">这是一个最简弹窗。点击遮罩 / Esc / × 都能关闭。</p>
<template #footer>
<CfButton variant="ghost" @click="open = false">取消</CfButton>
<CfButton @click="open = false">确定</CfButton>
</template>
</CfModal>
</template>
尺寸
size 控制弹窗最大宽度。full 几乎铺满视口;width / min-height 可自定义具体像素。
<script setup lang="ts">
import { ref } from 'vue';
import { CfButton, CfModal } from '@chufix-design/vue';
const sm = ref(false);
const md = ref(false);
const lg = ref(false);
const xl = ref(false);
const full = ref(false);
</script>
<template>
<div class="demo-row">
<CfButton variant="tertiary" @click="sm = true">sm · 360px</CfButton>
<CfButton variant="tertiary" @click="md = true">md · 480px</CfButton>
<CfButton variant="tertiary" @click="lg = true">lg · 640px</CfButton>
<CfButton variant="tertiary" @click="xl = true">xl · 800px</CfButton>
<CfButton variant="tertiary" @click="full = true">full · 几乎铺满</CfButton>
</div>
<CfModal v-model:open="sm" size="sm" title="size = sm">
<p style="margin: 0;">最大宽度 360px,适合精简的确认弹窗。</p>
</CfModal>
<CfModal v-model:open="md" size="md" title="size = md(默认)">
<p style="margin: 0;">最大宽度 480px,是大多数表单的合理尺寸。</p>
</CfModal>
<CfModal v-model:open="lg" size="lg" title="size = lg">
<p style="margin: 0;">最大宽度 640px,适合内容较多的弹窗。</p>
</CfModal>
<CfModal v-model:open="xl" size="xl" title="size = xl">
<p style="margin: 0;">最大宽度 800px,适合复杂表单或预览面板。</p>
</CfModal>
<CfModal v-model:open="full" size="full" title="size = full">
<p style="margin: 0;">几乎铺满视口(保留 1.5rem 外边距),适合设置面板或全屏预览。</p>
</CfModal>
</template>
关闭行为
默认任何操作都能关闭:遮罩点击、Esc、右上角 ×。可以通过 closeOnOverlay / closeOnEsc / showClose 单独禁用。异步态(onBeforeOk 进行中)会自动屏蔽所有关闭路径。
<script setup lang="ts">
import { ref } from 'vue';
import { CfButton, CfModal } from '@chufix-design/vue';
const a = ref(false);
const b = ref(false);
</script>
<template>
<div class="demo-row">
<CfButton variant="tertiary" @click="a = true">禁用遮罩关闭</CfButton>
<CfButton variant="tertiary" @click="b = true">隐藏 × 按钮</CfButton>
</div>
<CfModal v-model:open="a" title="只能从底部关闭" :close-on-overlay="false">
<p style="margin: 0;">点遮罩不会关闭,只能用 Esc 或下面的按钮。</p>
<template #footer>
<CfButton @click="a = false">我知道了</CfButton>
</template>
</CfModal>
<CfModal v-model:open="b" title="无右上角 ×" :show-close="false">
<p style="margin: 0;">这个 Modal 隐藏了右上角的关闭按钮,必须靠 footer 操作或 Esc 关闭。</p>
<template #footer>
<CfButton @click="b = false">关闭</CfButton>
</template>
</CfModal>
</template>
Tone 变体
tone="info | success | warning | error" 给 header 加一个圆形 tone 图标 + 强调色。tone="error" 时默认 OK 按钮自动切到 danger 红。
<script setup lang="ts">
import { ref } from 'vue';
import { CfButton, CfModal } from '@chufix-design/vue';
const open = ref<'info' | 'success' | 'warning' | 'error' | null>(null);
function set(t: typeof open.value) { open.value = t; }
</script>
<template>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<CfButton variant="tertiary" @click="set('info')">Info</CfButton>
<CfButton variant="tertiary" @click="set('success')">Success</CfButton>
<CfButton variant="tertiary" @click="set('warning')">Warning</CfButton>
<CfButton variant="danger" @click="set('error')">Error</CfButton>
</div>
<CfModal
:open="open === 'info'"
@update:open="(v) => set(v ? 'info' : null)"
tone="info"
title="新功能上线"
description="表格组件现已支持双向虚拟化与 ResizeObserver 自动测高。"
ok-text="知道了"
/>
<CfModal
:open="open === 'success'"
@update:open="(v) => set(v ? 'success' : null)"
tone="success"
title="部署成功"
description="v0.1.5 已发布到生产环境。"
ok-text="完成"
/>
<CfModal
:open="open === 'warning'"
@update:open="(v) => set(v ? 'warning' : null)"
tone="warning"
title="离开当前页面?"
description="未保存的更改会丢失。"
ok-text="离开"
cancel-text="留下"
/>
<CfModal
:open="open === 'error'"
@update:open="(v) => set(v ? 'error' : null)"
tone="error"
title="删除工作区"
description="工作区内的所有数据将被立即清除,且无法恢复。"
ok-text="确认删除"
cancel-text="取消"
/>
</template>
内置 OK / Cancel + 异步确认
直接传 okText / cancelText,组件渲染默认按钮组。onBeforeOk 接异步函数:返回 false / 抛错 → 阻止关闭并恢复 loading 态;正常 resolve → 关闭并发 @ok 事件。
<script setup lang="ts">
import { ref } from 'vue';
import { CfButton, CfModal, CfInput } from '@chufix-design/vue';
const open = ref(false);
const phrase = ref('');
async function onBeforeOk() {
// 模拟服务端校验:必须输入 'delete'
await new Promise((r) => setTimeout(r, 800));
if (phrase.value !== 'delete') {
// 阻止关闭
return false;
}
// 真删
await new Promise((r) => setTimeout(r, 600));
}
</script>
<template>
<CfButton variant="danger" @click="open = true">删除项目(异步)</CfButton>
<CfModal
v-model:open="open"
tone="error"
title="确认删除"
description="这是不可撤销的操作。请输入 'delete' 确认。"
:on-before-ok="onBeforeOk"
ok-text="确认删除"
cancel-text="取消"
>
<CfInput v-model="phrase" placeholder="输入 delete 启用确认按钮" />
</CfModal>
</template>
拖拽 + 缩放
draggable 让标题栏变成拖手柄;resizable 在右下角加一个对角线缩放把柄。两个能力都用 PointerEvent 实现,0 第三方依赖。
<script setup lang="ts">
import { ref } from 'vue';
import { CfButton, CfModal } from '@chufix-design/vue';
const open = ref(false);
</script>
<template>
<CfButton @click="open = true">打开可拖拽 + 可缩放</CfButton>
<CfModal
v-model:open="open"
title="可移动 / 可缩放"
description="拖标题栏移动;右下角拖把柄缩放最小 280×160。"
draggable
resizable
:centered="false"
ok-text="完成"
cancel-text="取消"
>
<p style="line-height: 1.6;">
在桌面应用风格的工具型 modal 里很常用:用户希望并排看背景内容时移动 modal,或拉大到屏幕大小看长内容。
</p>
</CfModal>
</template>
命令式服务
modal.confirm / danger / alert / info / success / warning / error 直接弹一个对话框并返回 Promise<boolean> —— 没有任何状态绑定、没有模板嵌套。最适合那种”先确认再操作”的辅助流程。
import { modal } from '@chufix-design/vue'; // React 端:从 @chufix-design/react 导入
const ok = await modal.confirm({
title: '提交订单?',
description: '提交后无法撤销',
onOk: async () => {
const r = await api.submit(); // 抛错或返回 false 都阻止关闭
if (!r.ok) return false;
},
});
if (ok) modal.success({ title: '已提交' });
<script setup lang="ts">
import { CfButton, modal } from '@chufix-design/vue';
async function onConfirm() {
const ok = await modal.confirm({
title: '提交订单?',
description: '提交后无法撤销。',
onOk: async () => {
await new Promise((r) => setTimeout(r, 800));
// 返回 false 阻止关闭
},
});
if (ok) modal.success({ title: '订单已提交', description: '我们已经发邮件给你确认。' });
}
async function onDanger() {
const ok = await modal.danger({
title: '清空回收站?',
description: '所有 18 个文件将永久删除。',
});
if (ok) modal.info({ title: '已清空' });
}
function onAlert() {
modal.warning({
title: '余额不足',
description: '当前 ¥12.00,本次消费 ¥38.00。请先充值。',
});
}
</script>
<template>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<CfButton variant="primary" @click="onConfirm">异步 confirm</CfButton>
<CfButton variant="danger" @click="onDanger">danger</CfButton>
<CfButton variant="tertiary" @click="onAlert">warning alert</CfButton>
</div>
</template>
服务方法签名:
| 方法 | tone | 默认按钮 |
|---|---|---|
modal.open(opts) | 跟 opts 一致 | 跟 opts |
modal.confirm(opts) | warning | OK + Cancel |
modal.danger(opts) | error | 红色 OK + Cancel |
modal.alert(opts) | info | OK |
modal.info / success / warning / error(opts) | 同名 tone | OK |
多模态栈
每打开一个 Modal,组件内部把它的 close 函数压进栈,z-index 自动 +10;Esc 只关最顶层;关闭后从栈里弹出。所以你可以在 modal 里再触发 modal,不用关心层级。
<script setup lang="ts">
import { ref } from 'vue';
import { CfButton, CfModal } from '@chufix-design/vue';
const layer1 = ref(false);
const layer2 = ref(false);
</script>
<template>
<CfButton @click="layer1 = true">打开第一层</CfButton>
<CfModal
v-model:open="layer1"
title="第一层"
description="可以在这一层再打开嵌套的对话框。"
ok-text="确定"
cancel-text="取消"
>
<p style="line-height: 1.6; margin-bottom: 12px;">两层 modal 同时存在时,组件维护内部 z-index 栈,每层自动 +10。Esc 只关闭最顶层。</p>
<CfButton variant="tertiary" @click="layer2 = true">打开第二层</CfButton>
<CfModal
v-model:open="layer2"
tone="warning"
title="第二层"
description="这一层是从第一层里弹出来的;遮罩仍然位于第一层之上。"
ok-text="知道了"
/>
</CfModal>
</template>
API
| Prop | 类型 | 默认 | 说明 |
|---|---|---|---|
open | boolean | false | 受控开关 |
title | string | — | header 标题文字 |
description | string | — | 标题下的描述(一行更弱的副标题) |
size | 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'md' | 最大宽度档位 |
tone | 'default' | 'info' | 'success' | 'warning' | 'error' | 'default' | 视觉 tone + 自动图标 |
centered | boolean | true | 垂直居中或顶部 80px 起 |
width | number | string | — | 自定义宽度(覆盖 size) |
minHeight | number | string | — | 自定义最小高度 |
closeOnOverlay | boolean | true | 点遮罩是否关闭 |
closeOnEsc | boolean | true | Esc 是否关闭 |
showClose | boolean | true | 右上角 × |
footerAlign | 'start' | 'center' | 'end' | 'space-between' | 'end' | footer 对齐 |
draggable | boolean | false | 拖标题移动 |
resizable | boolean | false | 右下角拖拽缩放 |
okText / cancelText | string | — | 默认按钮文案;不传则不渲染默认按钮 |
okVariant | 'primary' | 'danger' | 'secondary' | 'primary' | 确认按钮样式 |
onBeforeOk | () => boolean | void | Promise<...> | — | 异步钩子;false / throw 阻止关闭 |
to | string | Element | 'body' | Teleport / Portal 目标容器 |
zIndex | number | 自动栈管理 | 自定义 z-index 起点 |
插槽 / 事件
- Vue:
header/ 默认 /footer三个具名插槽。footer插槽接收{ ok, cancel, loading }用于自渲染按钮。 - React:
header、footer(可传 ReactNode 或({ ok, cancel, loading }) => ReactNode)+children。 - 事件:
update:open/close/ok/cancel。
反馈与讨论
Modal 弹窗 的讨论