Dify 工作区成员邀请弹窗:从 README 到源码的收件人状态机与表单校验全解析
本文以 Dify 前端 invite-modal 模块的 README 为核心,结合其真实源码与测试,完整还原「工作区成员邀请」功能的状态归属设计:收件人如何从一段自由文本被解析、去重、校验为结构化对象,席位额度如何从服务端特性查询中推导,后端错误码又如何精准落位到对应表单字段。读完后,你将掌握该模块 InviteForm / EmailRecipientsField / RoleSelector 三组件的职责边界,以及一套可复用的「草稿 + 已确认收件人」双状态管理模式。
模块定位与职责边界
模块的 README(README.md)开宗明义地声明了这个模块的所有权范围:
本模块拥有工作区成员邀请表单:收件人组合、角色选择、字段与表单错误、请求状态,以及邀请成功的结果。
这句话把职责切成了两个清晰的阵营。React 本地状态负责「还没离开浏览器的一切」——收件人草稿、解析后的收件人列表、当前提交错误;服务端数据则一律经由 TanStack Query 管理——特性查询(决定席位上限)、角色查询(决定可分配角色)、邀请变更(mutation)以及提交成功后的缓存失效。
这种切分在 index.tsx 的顶部导入中就能得到印证:
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { consoleQuery } from '@/service/client'
import { commonQueryKeys } from '@/service/use-common'
import { mergeEmailRecipients } from './email-recipients'
而 README 最容易被忽略、却最关键的一句是:
席位可用性来自
consoleQuery.features.get;特性模块并不在本地或 Provider 状态中镜像它。
也就是说,「还能再邀请几个人」这个数不存任何本地状态,而是每次从 features 查询的返回值里现算。这一设计避免了「本地席位缓存与服务端漂移」的经典 Bug,代价只是每次打开表单多读一次 query cache。
对外契约:InviteModal 的 Props 设计
InviteModal 对外暴露的 props 定义了整个组件的边界(index.tsx):
type InviteModalProps = {
open: boolean
trigger: ReactElement
isEmailSetup: boolean
onOpenChange: (open: boolean) => void
onSend: (invitationResults: MemberInviteResponse['invitation_results']) => void
}
两个设计要点值得注意:
- 对话框可见性由调用方拥有(README 最后一句 "dialog visibility remains caller-owned")。
open是受控属性,onOpenChange是唯一的出口。测试用例routes close actions through the controlled state owner专门验证了点击关闭按钮只会回调onOpenChange(false),组件内部绝不私改可见性。 isEmailSetup把邮件能力探测上移。组件只消费一个布尔值,不关心它是怎么探测的。当isEmailSetup为false时,表单顶部渲染一条警告横幅(members.emailNotSetup),提示用户当前环境未配置邮件服务——邀请邮件发不出去,但表单本身不被禁用。
InviteForm 内部只保留三个状态,对应 README 说的 "email draft, parsed recipients, and current submission error"(index.tsx):
const [recipients, setRecipients] = useState<EmailRecipient[]>([]) // 已确认的收件人
const [draft, setDraft] = useState('') // 输入框中的草稿
const [submissionError, setSubmissionError] = useState<SubmissionError>(null)
错误被建模成一个可辨识联合(discriminated union),这是整个错误处理体系的骨架:
type SubmissionError =
| { kind: 'fields'; errors: Partial<Record<InviteFieldName, string>> } // 字段级错误
| { kind: 'form'; message: string } // 整表单错误
| null
fields 类错误按字段名(emails 或 role)落位,交给 Base UI Form 渲染并聚焦对应控件;form 类错误(比如网络失败)则作为一条持久的 role="alert" 提示挂在表单底部。README 中 "Base UI Form owns registration, validation, external field errors, and invalid-field focus" 描述的就是这套机制——字段注册、必填校验、外部错误注入、无效字段聚焦全部由 @langgenius/dify-ui/form 承担,业务代码只需把 fieldErrors 传进去。
收件人解析:分隔符、去重与浏览器原生校验
收件人的核心逻辑全部沉淀在纯函数文件 email-recipients.ts,没有任何 React 依赖,因此单测(email-recipients.spec.ts)可以完全脱离渲染环境运行。
分隔符采用一个宽松的正则,一次覆盖逗号、分号、换行与制表符:
const EMAIL_DELIMITER_PATTERN = /[,;\r\n\t]+/
这让「从 Excel 粘一列邮箱」或「从邮件头粘贴分隔名单」都能被正确拆成多个收件人。测试用例用参数化断言逐一验证了 ,、;、\n、\r\n、\t 五种分隔符均被识别,而纯空白连接的地址不会被误拆。
单条地址的规范化在 createEmailRecipient 中完成:先 trim().toLowerCase(),再用一个临时 DOM 输入框借道浏览器的 ValidityState 做格式判断:
function isEmailValid(value: string) {
const input = document.createElement('input')
input.type = 'email'
input.value = value
return input.validity.valid
}
这是一个有意的取舍:有效性语义与用户所用浏览器保持一致,而不是引入一套可能与浏览器行为漂移的自研正则。测试用例 accepts an address allowed by the browser without requiring a dotted domain 专门锁定这一行为——person@example 这类没有点号域名的地址,浏览器认为合法,前端就接受,最终由后端邮件服务兜底。
合并与去重由 mergeEmailRecipients 完成,它接收「已确认收件人 + 草稿文本」两个输入,输出统一列表:
export function mergeEmailRecipients(recipients: EmailRecipient[], input: string) {
const nextRecipients = [...recipients]
const existingValues = new Set(recipients.map(({ value }) => value))
input
.split(EMAIL_DELIMITER_PATTERN)
.map(createEmailRecipient)
.filter(({ value }) => Boolean(value))
.forEach((recipient) => {
const { value } = recipient
if (existingValues.has(value)) return // 与已有收件人去重
existingValues.add(value)
nextRecipients.push(recipient)
})
return nextRecipients
}
三个关键语义都由测试固化:
- 大小写不敏感去重:
FIRST@example.com与已有的first@example.com视为同一人; - 无效地址不丢弃:
mergeEmailRecipients([], 'valid@example.com, not-an-email')会保留not-an-email(isValid: false),让用户能看见并修正,而不是静默吞掉; - 顺序保持:二十人名单按粘贴顺序稳定输出。
EmailRecipientsField:草稿提交、粘贴拦截与键盘可达性
展示层组件 email-recipients-field.tsx 承接「chip 输入框」的交互细节,即 README 所说 "Colocated presentation components may own their transient interaction state"——draftTouched 这种瞬态交互状态就留在本组件内。
它的核心交互策略是「Enter 提交草稿、失焦提交草稿、直接点发送也提交草稿」,三种路径最终都收敛到同一语义。commitDraft 的逻辑:
const commitDraft = () => {
if (!draft.trim()) return
if (draftRecipients.some(({ isValid }) => !isValid)) {
setDraftTouched(true) // 非法草稿:不生成 chip,标记 touched 触发错误提示
return
}
updateRecipients(mergeEmailRecipients(recipients, draft))
updateDraft('')
setDraftTouched(false)
}
而表单提交时的 validateRecipients 会把「已确认收件人 + 未提交的草稿」合并后再校验,这意味着用户输入完邮箱直接点发送、不按 Enter 也能成功提交——测试用例 submits a valid draft without requiring Enter or blur to create a chip 专门验证了这一点,它对应了真实的用户操作习惯:很多人根本不理解「chip 化」这个中间态。
几个值得学习的细节:
- 粘贴拦截(
onPaste):只有当剪贴板内容包含分隔符时才preventDefault,把整段文本插到光标位置后走mergeEmailRecipients批量生成 chip,避免用户手动删逗号;单条粘贴则走原生行为。 - 中文输入法保护:
onKeyDown中检查event.nativeEvent.isComposing,组合输入期间的 Enter 只preventDefault、不触发commitDraft,避免拼音选词时误提交。 - RTL 方向感知:
isRightToLeft同时检查元素计算样式与document.documentElement.dir,阿拉伯语等 RTL 界面下左右方向键的 chip 导航自动镜像。 - 无障碍:无效 chip 用
aria-describedby指向屏幕阅读器专用的隐藏错误文本(sr-only);删除 chip 后焦点回落到相邻 chip 或输入框,测试Backspace空草稿时删除最后一个收件人等路径均有覆盖。 - 字段级错误来源有两路:本地检测(chip 或草稿中存在非法地址)产生
members.emailInvalid;必填性则由<Field name="emails" validate={validateRecipients}>交给 Base UI Form 处理,空提交时显示members.emailRequired并自动聚焦输入框——这正是 README 把 "invalid-field focus" 划给 Base UI Form 的具体体现。
RoleSelector:分页角色下拉与遗留角色兼容
角色选择器 role-selector.tsx 用 TanStack Query 的无限分页查询 useWorkspaceRoleList({ page: 1, limit: 20, language }) 拉取当前租户的角色列表,配合 IntersectionObserver 实现下拉滚动加载:
observer = new IntersectionObserver(
(entries) => {
if (
entries[0]!.isIntersecting &&
!rolesLoading &&
!isFetchingNextPage &&
!rolesError &&
hasMore
)
fetchNextPage()
},
{
root: listRef.current,
rootMargin: `${dynamicMargin}px`, // 100~200px 的动态预加载边距
},
)
rootMargin 不是固定值,而是 Math.max(100, Math.min(listHeight * 0.2, 200))——按列表实际高度动态计算提前量,保证滚到底之前下一页已经发出请求。列表尾部那个 <div ref={setAnchorElement} className="h-0" /> 就是被观察的哨兵。
另一段容易被忽视的兼容逻辑是 LEGACY_ROLE_DESCRIPTION_KEY_MAP:
const LEGACY_ROLE_DESCRIPTION_KEY_MAP = {
admin: 'members.adminTip',
editor: 'members.editorTip',
normal: 'members.normalTip',
dataset_operator: 'members.datasetOperatorTip',
} as const
角色描述优先取后端的 role.description;当后端没给描述时,组件按角色的 name 或 id(归一化为小写)匹配这四个内置键,回退到静态 i18n 文案;再匹配不上则显示 role.noDescription 占位。这让新版本的自定义 RBAC 角色与早期四个硬编码角色(Admin/Editor/Normal/Dataset Operator)在 UI 上呈现一致。选中值统一使用 role.id 提交(itemToStringValue={(role) => role.id}),测试也断言请求体中 role: 'admin' 传的是 id 而非名称。
席位推导:纯服务端特性、零本地镜像
README 那句 "Seat availability comes from consoleQuery.features.get" 在 index.tsx 中展开为一段带优先级的推导链:
const { data: features } = useQuery(consoleQuery.features.get.queryOptions())
const memberLimit = features?.workspace_members.enabled
? features.workspace_members
: features?.billing.enabled && features.members.limit > 0
? features.members
: undefined
const remainingSeats =
memberLimit && memberLimit.limit > 0 ? Math.max(memberLimit.limit - memberLimit.size, 0) : null
const effectiveRecipients = mergeEmailRecipients(recipients, draft)
const validRecipientCount = effectiveRecipients.filter(({ isValid }) => isValid).length
const exceedsRemainingSeats = remainingSeats !== null && validRecipientCount > remainingSeats
解读这条链:
- 限额来源优先级:
workspace_members特性启用时用它;否则若billing启用且members.limit > 0则用members;两者都不满足时memberLimit为undefined,意味着无上限,不显示任何席位提示。 remainingSeats为null与0语义不同:null代表「没有席位概念」(自托管未购套餐等场景),0才是「真的没有剩余席位」。Math.max(..., 0)防止负数出现。- 超额判断只针对合法收件人:
validRecipientCount过滤掉isValid: false的地址,避免「还没修正的坏地址」也计入席位占用。 - 只警告、不拦截。
exceedsRemainingSeats为真时渲染一条role="status"的警告("剩余 N 席 · 收件人数超出"),但提交按钮保持可用——测试warns but lets the backend decide whether recipients consume remaining seats明确断言了按钮 enabled 且请求照常发出。最终裁决权在服务端:超限会以limit_exceeded错误码返回,再由前端映射为字段错误。这是一种「前端给确定性提示、后端做权威校验」的分工。
错误码落位:从后端 body 到字段级 aria-invalid
后端邀请接口的错误契约定义在 OpenAPI 生成的类型里(@dify/contracts/api/console/workspaces/types.gen 的 MemberInviteErrorResponse),前端用一个白名单函数做防御式解析(invite-error.ts):
const INVITE_ERROR_CODES = new Set<InviteErrorCode>([
'invalid_param',
'invalid_role',
'limit_exceeded',
])
export function getInviteErrorCode(error: unknown): InviteErrorCode | null {
const errorRecord = getRecord(error)
const dataRecord = getRecord(errorRecord?.data)
const bodyRecord = getRecord(dataRecord?.body)
const code = bodyRecord?.code ?? errorRecord?.code
return typeof code === 'string' && INVITE_ERROR_CODES.has(code as InviteErrorCode)
? (code as InviteErrorCode)
: null
}
它做了两件保守的事:只认契约中列出的三个码(invalid_param / invalid_role / limit_exceeded),只从 data.body.code 或顶层 code 两处读取,其余一律返回 null 走兜底。测试 invite-error.spec.ts 验证了 code: 'BAD_REQUEST'(HTTP 层错误)和 invalid-role(下划线变体,非契约码)都会返回 null——不猜、不放大。
拿到码之后,handleSubmit 的 onError 完成落位(index.tsx):
switch (getInviteErrorCode(error)) {
case 'limit_exceeded': // → emails 字段:邀请超出席位上限
case 'invalid_role': // → role 字段:角色已失效或非法
default: // → 整表单:members.inviteFailed 持久 alert
}
配套的两个状态清理规则同样有测试锚定:
clearEmailSubmissionError:用户在邮件输入框任何变更(继续输入或删除 chip)时,立即清除emails字段的服务端错误——「改了就别再骂我」;- role 的服务端错误则更黏性:测试
keeps a role server error visible when the user only opens the selector表明,仅仅点开角色下拉不会清除invalid_role错误,只有真正换一个角色才清除。这个差异是刻意的:打开下拉不代表用户已经修正了选择。
请求生命周期:防重入、缓存失效与成功回调
handleSubmit 的完整流程(index.tsx)体现了该模块对「请求状态」的全部处理:
const handleSubmit = ({ role }: InviteFormValues) => {
if (isPending) return // 1. 防重入
setRecipients(effectiveRecipients) // 2. 提交前把草稿固化成 chip
setDraft('')
setSubmissionError(null) // 3. 清空旧错误
mutate(
{
body: {
emails: effectiveRecipients.map(({ value }) => value),
role,
language: locale, // 4. 邀请邮件按当前界面语言发送
},
},
{
onSuccess: (response) => {
void queryClient.invalidateQueries({ queryKey: consoleQuery.features.get.queryKey() })
void queryClient.invalidateQueries({ queryKey: commonQueryKeys.members })
onOpenChange(false) // 5. 关弹窗
onSend(response.invitation_results) // 6. 把结果交给调用方展示
},
onError: (error) => { /* 见上一节 */ },
},
)
}
几个可验证的决策点:
- 防重入:
isPending时直接 return;mutation 挂上了context: { silent: true },请求进行中不弹全局 loading。测试freezes all editable controls while invitations are being sent断言了发送期间输入框、角色选择器、每个 chip 的删除按钮、提交按钮全部禁用/aria-disabled。 - 缓存失效范围精准:只失效
features(席位数变了)和commonQueryKeys.members(成员列表变了)两个 key,不做大面积invalidateAll。测试用vi.spyOn(queryClient, 'invalidateQueries')精确断言了失效对象。 - 成功后的职责移交:组件自己不做成功庆祝动画,而是通过
onSend把invitation_results抛给调用方。上层 members-page/index.tsx 拿到结果后打开同级的invited-modal(invited-modal/index.tsx)来渲染发送成功的结果与邀请链接。这与 README 的 "successful invitation result" 属于本模块产出、但展示留在调用侧的边界一致。 - 提交按钮文案是动态的:有 N 个合法收件人时显示
members.sendInviteCount(含 count 插值),否则显示members.sendInvite——测试counts a manually typed recipient list before it is committed甚至验证了「还没回车、邮箱还在草稿里」时按钮文案已经按 2 计数。 - 语言字段:
language: locale取自useLocale(),决定后端发出的邀请邮件语言,测试断言其为'en-US'。
受控关闭与表单重置
README 末尾的 "dialog visibility remains caller-owned" 还有一个隐含推论:关闭即重置。因为 InviteModal 是受控组件,调用方把 open 置回 false 后,DialogContent 卸载,InviteForm 连同它的三个 state 一起销毁;下次打开是全新实例。测试 resets the form after a controlled close 覆盖了这条路径:关闭后再打开,之前输入的收件人 chip 不存在、角色选择器回到 selectRole 占位态。反过来,does not render dialog content while controlled closed 确认了 open: false 时连 dialog 节点都不渲染。这也解释了组件里没有任何「关闭时手动清状态」代码——用挂载/卸载代替手动重置,是受控弹窗最省心的状态卫生方案。
测试覆盖与架构自检
该模块的测试规模本身就说明这套设计被当作可验证契约来维护,共五个规格文件(位于 tests 目录):
| 文件 | 覆盖的 README 声明 |
|---|---|
| index.spec.tsx | 受控弹窗、初始聚焦、草稿直接提交、chip 固化、席位警告不拦截、错误码落位与清除、发送期冻结、关闭即重置 |
| email-recipients.spec.ts | 五种分隔符、规范化去重、无效地址保留、20 人批量顺序 |
| email-recipients-field.spec.tsx | 字段交互态(草稿、chip 编辑/删除、键盘导航) |
| invite-error.spec.ts | 错误码白名单与防御式解析 |
| role-selector.spec.tsx | 角色下拉的分页加载与遗留角色描述回退 |
值得注意的是 index.spec.tsx 里对 @/service/client 的整体 mock:测试用 vi.mock 把 consoleQuery.features.get 和 workspaces.current.members.inviteEmail.post 替换为可控的 fetchFeatures / inviteMember 函数,从而在不发真实请求的前提下断言请求体形状(emails 数组、role id、language)与缓存失效调用——这也反向印证了 README 所说「TanStack Query owns feature and role queries, the invitation mutation, and cache invalidation」:所有数据行为都收敛在这两个 query 定义上,没有旁路 fetch。
小结:一份可迁移的前端表单架构清单
把 README 的三句话翻译成工程实践,可以得到这份清单:
- 状态归属三分法——瞬态交互状态(
draftTouched)留在展示组件;业务草稿(recipients/draft/submissionError)集中在InviteForm;服务端事实(features/roles)只存在于 Query cache,禁止本地镜像。 - 纯函数先行——分隔符、规范化、去重抽成无 React 依赖的
email-recipients.ts,单测可在 Node 环境直接跑,UI 组件只做编排。 - 错误建模用可辨识联合——
fields与form两类错误、配合错误码白名单,保证「后端说哪错、UI 就红哪个框」,且网络类故障不会被误标到具体字段。 - 软警告 + 权威校验——席位超额只提示不拦截,最终以服务端
limit_exceeded为准;前端提示永远允许与后端存在时间差而不产生死锁。 - 受控弹窗 + 卸载即重置——不维护任何手动 reset 逻辑,用 React 的挂载语义天然保证表单卫生。
这套模式对任何「多收件人表单 + 远程下拉 + 限额控制」的管理后台场景都可直接借鉴:先把 README 级别的职责声明写清楚,再让测试逐条锚定声明,模块边界就不会在迭代中悄悄腐化。
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 StartedRust0624
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