首页
/ Dify 工作区成员邀请弹窗:从 README 到源码的收件人状态机与表单校验全解析

Dify 工作区成员邀请弹窗:从 README 到源码的收件人状态机与表单校验全解析

2026-09-06 21:18:05作者:傅爽业Veleda

本文以 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 把邮件能力探测上移。组件只消费一个布尔值,不关心它是怎么探测的。当 isEmailSetupfalse 时,表单顶部渲染一条警告横幅(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 类错误按字段名(emailsrole)落位,交给 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
}

三个关键语义都由测试固化:

  1. 大小写不敏感去重FIRST@example.com 与已有的 first@example.com 视为同一人;
  2. 无效地址不丢弃mergeEmailRecipients([], 'valid@example.com, not-an-email') 会保留 not-an-emailisValid: false),让用户能看见并修正,而不是静默吞掉;
  3. 顺序保持:二十人名单按粘贴顺序稳定输出。

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;当后端没给描述时,组件按角色的 nameid(归一化为小写)匹配这四个内置键,回退到静态 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

解读这条链:

  1. 限额来源优先级workspace_members 特性启用时用它;否则若 billing 启用且 members.limit > 0 则用 members;两者都不满足时 memberLimitundefined,意味着无上限,不显示任何席位提示。
  2. remainingSeatsnull0 语义不同null 代表「没有席位概念」(自托管未购套餐等场景),0 才是「真的没有剩余席位」。Math.max(..., 0) 防止负数出现。
  3. 超额判断只针对合法收件人validRecipientCount 过滤掉 isValid: false 的地址,避免「还没修正的坏地址」也计入席位占用。
  4. 只警告、不拦截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.genMemberInviteErrorResponse),前端用一个白名单函数做防御式解析(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——不猜、不放大。

拿到码之后,handleSubmitonError 完成落位(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') 精确断言了失效对象。
  • 成功后的职责移交:组件自己不做成功庆祝动画,而是通过 onSendinvitation_results 抛给调用方。上层 members-page/index.tsx 拿到结果后打开同级的 invited-modalinvited-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.mockconsoleQuery.features.getworkspaces.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 的三句话翻译成工程实践,可以得到这份清单:

  1. 状态归属三分法——瞬态交互状态(draftTouched)留在展示组件;业务草稿(recipients/draft/submissionError)集中在 InviteForm;服务端事实(features/roles)只存在于 Query cache,禁止本地镜像。
  2. 纯函数先行——分隔符、规范化、去重抽成无 React 依赖的 email-recipients.ts,单测可在 Node 环境直接跑,UI 组件只做编排。
  3. 错误建模用可辨识联合——fieldsform 两类错误、配合错误码白名单,保证「后端说哪错、UI 就红哪个框」,且网络类故障不会被误标到具体字段。
  4. 软警告 + 权威校验——席位超额只提示不拦截,最终以服务端 limit_exceeded 为准;前端提示永远允许与后端存在时间差而不产生死锁。
  5. 受控弹窗 + 卸载即重置——不维护任何手动 reset 逻辑,用 React 的挂载语义天然保证表单卫生。

这套模式对任何「多收件人表单 + 远程下拉 + 限额控制」的管理后台场景都可直接借鉴:先把 README 级别的职责声明写清楚,再让测试逐条锚定声明,模块边界就不会在迭代中悄悄腐化。

登录后查看全文
热门项目推荐
相关项目推荐