LobeHub 命令式弹窗实战:createModal、confirmModal 与全局 ModalHost 的完整接入指南
在 LobeHub 中,弹窗(Modal / Dialog / Confirm)是贯穿设置页、Agent 管理、任务验收、技能商店等几十个功能模块的交互基础。本文基于仓库内的 Modal 规范文档(SKILL.md),系统讲解 LobeHub 推荐的 base-ui 命令式弹窗栈:从 createModal / confirmModal / ModalHost / useModalContext 的用法,到"为什么用命令式而非声明式"、"哪个关闭回调真正可靠",并对照仓库中真实的功能弹窗源码(如 CreateGoalModal、ForwardModal),帮助你在本仓库中新建或迁移弹窗时一次性做对。读完本文,你将能够独立编写符合 LobeHub 约定的 base-ui 弹窗,并正确处理关闭回调、i18n 与全局宿主挂载。
为什么选择命令式弹窗
规范文档给出了两种模式取舍的明确结论:
| 模式 | 特征 | 是否推荐 |
|---|---|---|
| 声明式 | 组件内维护 open 状态 + <Modal /> |
不推荐 |
| 命令式 | 直接调用 createModal(),无本地状态 |
推荐 |
命令式的核心价值在于消除"调用方状态"与"弹窗状态"之间的耦合:调用处不需要 useState、不需要渲染 <Modal /> 标签、不需要传递 open / onCancel props,一次函数调用即可弹出。这在 LobeHub 中尤为关键——弹窗内容渲染在全局 ModalHost 的独立宿主树中,远离触发它的组件树,因此把弹窗状态收敛到一个 createXxxModal() 工厂函数里,是最自然的组织方式。
技术选型:@lobehub/ui/base-ui
新代码应使用 base-ui 弹窗栈(headless 原语,而不是 antd 的 Modal):
createModal、confirmModal、ModalHost均来自@lobehub/ui/base-ui;- 弹窗内容组件内部使用
@lobehub/ui/base-ui的useModalContext。
从源码结构看,本仓库根 package.json 声明的 @lobehub/ui 版本为 ^5.38.0,base-ui 子路径导出正是该组件库的推荐入口;仓库内大量功能模块(如 GoalDetailActions 从 @lobehub/ui/base-ui 导入 confirmModal、DropdownMenu、toast)已统一迁移到该栈。
Body 插槽说明:传 content(也可以传 children,运行时按 content ?? children 取用)。
全局 ModalHost:必需,且来自 base-ui 子路径
base-ui 的 createModal 通过一个独立于根包的宿主进行渲染。应用必须在接近根组件的位置挂载一次 @lobehub/ui/base-ui 导出的 ModalHost(例如和其他全局宿主放在一起)。否则 createModal() 的调用将不可见——这是最容易踩的坑。
如果项目中只挂载了 @lobehub/ui 的 ModalHost,需要额外补挂一个 base-ui 的 ModalHost,直到所有命令式弹窗完成迁移。
LobeHub 主应用的做法可以直接参照 SPAGlobalProvider:
import { ContextMenuHost, ModalHost, TooltipGroup } from '@lobehub/ui';
import { ModalHost as BaseModalHost, ToastHost } from '@lobehub/ui/base-ui';
// …
<ModalHost /> // 遗留栈宿主
<BaseModalHost /> // base-ui 栈宿主(base-ui createModal 依赖它)
<ToastHost />
<ContextMenuHost />
各子应用(share / workbench)同样在各自 Shell 中挂载了 base-ui 的 ModalHost,例如 ShareAppShell 与 WorkbenchShell。
标准文件结构
仓库约定的组织方式是把每个弹窗拆成"内容组件 + 工厂函数"两个文件:
features/
└── MyFeatureModal/
├── index.tsx # export createXxxModal
└── MyFeatureContent.tsx # modal body
1. 内容组件(MyFeatureContent.tsx)
'use client';
import { useModalContext } from '@lobehub/ui/base-ui';
import { useTranslation } from 'react-i18next';
export const MyFeatureContent = () => {
const { t } = useTranslation('namespace');
const { close } = useModalContext();
return <div>{/* ... */}</div>;
};
内容组件通过 useModalContext() 拿到 close 等 API,无需任何 props 传递即可自己关闭弹窗。
2. 工厂函数(index.tsx)
'use client';
import { createModal } from '@lobehub/ui/base-ui';
import { t } from 'i18next';
import { MyFeatureContent } from './MyFeatureContent';
export const createMyFeatureModal = () =>
createModal({
content: <MyFeatureContent />,
footer: null,
maskClosable: true,
styles: {
content: { overflow: 'hidden', padding: 0 },
},
title: t('myFeature.title', { ns: 'setting' }),
width: 'min(80%, 800px)',
});
仓库中的真实示例 createGoalModal 与此结构完全一致,并返回 ModalInstance 供调用方后续 close() / update():
export const createGoalModal = (props?: CreateGoalContentProps): ModalInstance =>
createModal({
content: <CreateGoalContent {...props} />,
footer: null,
maskClosable: false,
styles: { content: { overflow: 'hidden', padding: 0 } },
title: null,
width: 'min(88vw, 720px)',
});
可以看到 width 支持 min(88vw, 720px) 这类 CSS 表达式写法,适合做"大屏限宽、小屏自适应"的响应式弹窗。
3. 调用方式
import { createMyFeatureModal } from '@/features/MyFeatureModal';
const handleOpen = useCallback(() => {
createMyFeatureModal();
}, []);
return <Button onClick={handleOpen}>Open</Button>;
调用方没有 open 状态、没有条件渲染,交互逻辑完全解耦。
跨 Provider 边界的注意事项:把 store 递进弹窗树
base-ui 弹窗渲染在全局 ModalHost 的独立 React 树中,不会自动继承调用方组件的 Context。仓库中有两类实战处理,值得掌握:
-
显式回传 store api。ForwardModal 的注释写得很直白:"The conversation store is context-scoped, and the modal renders in the global
ModalHosttree — hand the live store api back"。它的做法是把createConversationStore作为参数传入createModal,在content里包一层 Provider:export const openForwardModal = ({ createConversationStore, onClosed }: OpenForwardModalOptions) => createModal({ content: ( <Provider createStore={createConversationStore}> <ForwardModalContent /> </Provider> ), footer: null, onOpenChangeComplete: (open) => { if (!open) onClosed?.(); }, title: translate('messageForward.modal.title', { ns: 'chat' }), width: 760, });设置页的凭证弹窗(CreateCredModal/Content 等)也以同样的方式注释说明了"createModal() 渲染在全局 ModalHost,位于 CredsApiProvider 之外"的问题。
-
用 Portal 把 children 接回原组件树。遗留封装组件 ImperativeModal 记录了一个更隐蔽的坑:直接把
children作为content传入时,这些子节点处于另一个宿主树,对调用方状态的变化总是晚一个 commit 才感知——React 会在每次事件结束时把受控输入还原为过期值,直接打断 IME 组合,导致中文等 CJK 输入不可用。该组件的解法是:modal 树里只渲染一个稳定的空占位 div,真正的 children 通过createPortal(children, contentHost)从原组件树 portal 进去,使状态与输入在同一棵树中同步提交。如果你要在弹窗里放 CJK 输入框,且出现输入法异常,可以优先从这个方向排查。
i18n:两处不同的翻译入口
- 内容组件内:使用 React hook
useTranslation(react-i18next); createModal选项里:因为工厂函数不是组件、不能调用 hook,使用import { t } from 'i18next'。
这也是规范文档给出的明确分工。
useModalContext
const { close, setCanDismissByClickOutside } = useModalContext();
在弹窗内容中可用它获取关闭能力,或动态控制"点击遮罩是否允许关闭"(例如表单有未保存内容时可调用 setCanDismissByClickOutside(false) 阻止误触关闭)。
关闭回调辨析:onOpenChange vs onOpenChangeComplete
这是本规范中最重要、也最反直觉的一节。close()——无论来自内容内的 useModalContext(),还是来自返回的 ModalInstance——都只是把栈中的条目翻转为 open: false,它不走 base-ui 的 dismissal 路径,因此两类回调的触发行为不同:
| 回调 | 用户主动关闭(Esc / 遮罩 / 头部 ✕) | 内容或实例调用 close() |
|---|---|---|
onOpenChange |
触发 | 不触发 |
onOpenChangeComplete |
以 false 触发 |
以 false 触发 |
**结论:调用方侧的清理逻辑(清除"正在编辑"标志位、重置 Provider 的 open 状态等)应挂在 onOpenChangeComplete 上。**把它挂在 onOpenChange 上看起来没问题,直到某个 footer 按钮通过 close() 关闭弹窗——此时调用方永远收不到通知,标志位保持置位,弹窗将无法再次打开。仓库中的 ForwardModal 留有注释佐证:"NOT onOpenChange: that skips instance.close(), which is how the in-content Cancel and Forward buttons close this modal"。
另外注意:createModal 只会以 false 完成(命令式渲染器自行提供参数,不会把 prop 转发给 base-ui),但仍建议保留 guard——DropdownMenu 等其他 base-ui 原语会双向上报,保留判断可以让调用点不依赖这一实现差异:
onOpenChangeComplete: (open) => {
if (!open) onClosed?.();
},
常用配置项(base-ui)
ImperativeModalProps 继承自 BaseModalProps,支持 title、width、maskClosable、open、onOpenChange、footer、styles / classNames(语义键:backdrop、popup、header、title、close、content 等)。
| 属性 | 说明 |
|---|---|
content |
主体内容(优先于 children 的命名方式) |
maskClosable |
点击遮罩外部关闭 |
styles.* |
语义区域样式,注意不是 antd 的 styles.body |
这里与 antd 的关键差异是样式/类名按语义区域组织(content 而非 body、popup 而非 wrapper)。仓库的遗留封装 ImperativeModal 中有一段现成的映射代码,说明了二者的转换关系:classNames.content ← classNames.body、classNames.popup ← className + classNames.wrapper、styles.content ← styles.body、styles.popup ← styles.wrapper。
confirmModal:确认型弹窗
删除、取消等破坏性操作使用 confirmModal,仓库中的典型用法见 GoalDetailActions(删除目标的二次确认):
import { confirmModal } from '@lobehub/ui/base-ui';
confirmModal({
title: '…',
content: '…',
okText: '…',
cancelText: '…',
onOk: async () => {},
});
配合 okButtonProps: { danger: true } 可将确认按钮渲染为危险样式(如删除操作)。onOk 支持 async 函数,内部执行 store 动作或路由跳转后确认弹窗自动关闭。
遗留栈:@lobehub/ui 根包
旧调用点使用 @lobehub/ui 导出的 createModal,其类型是 antd Modal props(children、allowFullscreen、getContainer、destroyOnHidden、styles.body 等)。新工作应优先迁移到 @lobehub/ui/base-ui。
- 遗留示例:src/features/SkillStore/index.tsx、src/features/LibraryModal/CreateNew/index.tsx;
- 主应用通过 ImperativeModal 这一遗留封装组件做了 antd 语义 props → base-ui 语义 props 的归一化(
normalizeClassNames/normalizeStyles),并在内部复用 base-ui 的createModal、ModalFooter与Button,同时用createPortal解决前述 CJK IME 问题——这也是"迁移过渡期"的一个可参考实现; - 迁移完成的判断标准:调用点全部改为
@lobehub/ui/base-ui导入、styles/classNames改用content/popup等语义键、关闭清理逻辑落在onOpenChangeComplete上。
小结:新弹窗落地的检查清单
- 确认目标应用中已挂载 base-ui 的
ModalHost(主应用参照 SPAGlobalProvider 的双宿主写法); - 按
MyFeatureModal/index.tsx+MyFeatureContent.tsx结构新建,工厂函数返回ModalInstance; content传主体、footer: null(或提供自定义 footer)、styles.content设置overflow: 'hidden'、padding: 0;- 内容内用
useModalContext()的close,工厂内用i18next的t; - 需要调用方感知关闭时,只写
onOpenChangeComplete: (open) => { if (!open) ... }; - 破坏性操作用
confirmModal,并考虑okButtonProps: { danger: true }; - 若调用方有 Context 依赖(store、主题、权限等),显式把依赖以 props 或内层 Provider 的形式递进
content。
遵循以上约定,你的弹窗将和 LobeHub 仓库内 Agent 目标、任务验收、消息转发等模块的既有实现保持一致,后续统一维护和继续向 base-ui 迁移也不会出现宿主缺失或回调漏报的隐性 bug。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00