@csntgao/uni-base 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -37,7 +37,7 @@ examples/vanilla 原生 HTML / JavaScript 接入示例
37
37
 
38
38
  `<uniplat-user-login>` 必须严格复刻亲亲企服 40.1 注册登录原型,提供密码、验证码和扫码三个固定页签、逐字段校验错误态及注册成功过渡页。验证码登录只包含手机号和短信验证码,不得因为接口现状增加图形验证码或其他原型中不存在的字段;旧版 `createCaptcha()` 仅为源码兼容保留,组件不得调用。自定义 Host transport 的密码与扫码方法保持可选,未提供时对应页签保持显示但禁用。UniplatBase 专用 transport 已复用短信验证码及统一 `login-application` 接口提供三种登录能力;组件本身不得保存 JWT,登录结果必须先交给宿主 `setAccessToken()`,再派发不含凭据的成功事件。后端明确返回新用户时显示 2 秒过渡页,并通过 `onboarding-requested` 请求 Host 调用 `<uniplat-account-context>.startOrganizationOnboarding()`。
39
39
 
40
- 组织入驻不是公开 Web Component。40.1 原型 02、A/B 企业/团队创建分支、校验/成功状态及 4.1 无归属提示均由 `<uniplat-account-context>` 内部实现;Host 在其 `organizationOnboarding.transport` 中注入创建能力,不能使用或注册 `<uniplat-organization-onboarding>`。个人用户不允许以非员工身份留在系统里,因此无归属用户必须先入驻,没有任何跳过开关:账户投影为零归属时组件用原生 `dialog.showModal()` 自动打开强制态创建流程,不渲染跳过、无归属提示与关闭按钮,遮罩点击、`Escape` 与 `cancel` 都不关闭。强制态首屏只有三条出路:创建企业、创建团队,或“已有团队 / 企业?用邀请码加入”派发 `join-organization-requested`(公司已存在的员工必须能加入而不是新建重复组织);另有“退出登录”派发 `logout-requested`。企业/团队表单页提供“返回”回到创建方式选择,创建成功页的认证、升级或邀请动作才是关闭对话框的终点。`organizationOnboarding` 只接收 `transport`,不得再提供 `mandatory` 之类的退出开关。强制态必须使用 top layer 对话框,不得只依赖可能被 Host `transform/overflow` 限制的固定遮罩。点击账户菜单或个人态的“创建团队 / 企业”必须直接启动内部流程,不得向 Host 派发 `organization-create-requested` 或 `create-organization-requested`。真实 UniplatBase transport 复用 `createUniplatBaseOrganizationOnboardingTransport()` 与 `system.org/create_organization`。邀请码加入、企业认证、团队升级和邀请成员等未展开页面仍派发业务事件,由 Host 实现。
40
+ 组织入驻不是公开 Web Component。40.1 原型 02、A/B 企业/团队创建分支、校验/成功状态及 4.1 无归属提示均由 `<uniplat-account-context>` 内部实现;Host 在其 `organizationOnboarding.transport` 中注入创建能力,不能使用或注册 `<uniplat-organization-onboarding>`。个人用户不允许以非员工身份留在系统里,因此无归属用户必须先入驻,没有任何跳过开关:账户投影为零归属时组件用原生 `dialog.showModal()` 自动打开强制态创建流程,不渲染跳过、无归属提示与关闭按钮,遮罩点击、`Escape` 与 `cancel` 都不关闭。强制态首屏只有创建企业、创建团队和“退出登录”(派发 `logout-requested`)三个出口,不提供邀请码加入入口:加入已有组织通过扫码或粘贴邀请链接进入系统完成,Host 也不需要为强制态实现邀请码页面。组织邀请页豁免强制入驻:当前 URL 末段路径命中 `organizationOnboarding.invitePaths`(默认 `['org-invite']`)且带非空 `code` 时不弹对话框,避免盖住加入流程;路径与 hash 路由都参与匹配,组件只取一个布尔值,邀请码的值不得进入组件状态、DOM、公共事件或日志。离开邀请页(`popstate`/`hashchange`)后仍无员工身份时必须重新强制入驻,不得留下可浏览的无归属态。企业/团队表单页提供“返回”回到创建方式选择,创建成功页的认证、升级或邀请动作才是关闭对话框的终点。`organizationOnboarding` 只接收 `transport`,不得再提供 `mandatory` 之类的退出开关。强制态必须使用 top layer 对话框,不得只依赖可能被 Host `transform/overflow` 限制的固定遮罩。点击账户菜单或个人态的“创建团队 / 企业”必须直接启动内部流程,不得向 Host 派发 `organization-create-requested` 或 `create-organization-requested`。真实 UniplatBase transport 复用 `createUniplatBaseOrganizationOnboardingTransport()` 与 `system.org/create_organization`。邀请码加入、企业认证、团队升级和邀请成员等未展开页面仍派发业务事件,由 Host 实现。
41
41
 
42
42
  `<uniplat-organization-selector>` 覆盖 40.1 原型 S.1 多归属选择弹窗,显示 Host 提供的完整企业/团队列表、类型、认证状态、角色、成员数和最近进入信息,并自行负责脱敏账户摘要、名称与统一社会信用代码搜索、全部/企业/团队类型筛选、“最近进入 / 全部组织”分组、默认选中和带目标名称的主按钮。选中与记住偏好只在组件内临时维护,点击“进入”后交由 Host 完成登录态切换,“退出登录”只派发事件。`presentation: 'modal'` 时组件自渲染 S.1 强制态弹窗:固定遮罩、无关闭按钮,遮罩点击与 `Escape` 都不关闭,`Escape` 也不冒泡到 Host 的全局关闭逻辑;默认 `inline` 只输出卡片,遮罩归 Host。S.0 的“弹不弹”判定由纯函数 `resolveOrganizationEntryDecision()` 提供,只按 0 个归属、1 个归属、命中记住选择、2 个及以上分别返回 `personal | auto-enter | prompt`;它不读写宿主存储,记住偏好由 Host 自行持久化并回传,记住的组织已不在列表时退回 `prompt`。
43
43
 
@@ -45,7 +45,7 @@ examples/vanilla 原生 HTML / JavaScript 接入示例
45
45
 
46
46
  `<uniplat-organization-single-employee-selector>` 是语义独立的组织员工单选公共组件。它可与多选组件复用内部数据控制器和真实 transport,但公开标签、`open()` 参数和确认 detail 必须独立。单选界面不渲染多选的已选右侧栏和移动端已选页签,候选区占满内容宽度,选择标识必须为单选圆点而非复选框。单选组件只接收 `initialSelectedEmployeeId`,确认时只返回 `{ employeeId, employee }`;不得向任一组件暴露 `selectionMode`,也不得让单选组件接收 `maxSelection` 或多选 ID 数组。候选数据继续只包含当前 JWT 组织内在职且启用的员工,JWT 和身份字段安全边界与多选组件一致。
47
47
 
48
- `<uniplat-account-context>` 是 40.1 原型导航右侧的统一账户工作台组件,不渲染品牌和全局菜单。它在一个 Host 元素内包含工作台入口、账户胶囊、应用入口浮层和 6.7 企业/团队切换浮层;两个浮层互斥并绝对定位,不改变导航布局。组件根据 Host 提供的 `applicationName` 和账户 transport 自行加载当前应用对应的组织投影与企业认证状态;用户资料复用 `list_available_organizations` 顶层的 `display_name`、`avatar_url` 与 `mobile_masked`,不得再请求 `profile_summary_v2`,Host 不再组装这些展示数据。它支持无归属个人态、企业未认证/审核中/未通过/已认证/认证过期和团队态;组织切换由 Host 响应事件,应用加载与 ticket 跳转复用独立的 Application Switcher transport。选中组织时,账户胶囊和菜单资料区必须使用服务端投影中该组织的员工姓名与头像;没有选中组织或该组织没有员工身份时,回退到用户资料。账户胶囊宽度固定,胶囊内姓名超过 6 个字、组织名称超过 8 个字必须截断为省略号,完整值只放在 `title` 上,不得让长名称撑宽胶囊挤压导航左侧内容;展开菜单内仍显示完整名称。组织列表严格使用原型中的企业建筑与团队成员图标,不得替换成员工头像或组织首字。
48
+ `<uniplat-account-context>` 是 40.1 原型导航右侧的统一账户工作台组件,不渲染品牌和全局菜单。它在一个 Host 元素内包含工作台入口、账户胶囊、应用入口浮层和 6.7 企业/团队切换浮层;两个浮层互斥并绝对定位,不改变导航布局。组件根据 Host 提供的 `applicationName` 和账户 transport 自行加载当前应用对应的组织投影与企业认证状态;用户资料复用 `list_available_organizations` 顶层的 `display_name`、`avatar_url` 与 `mobile_masked`,不得再请求 `profile_summary_v2`,Host 不再组装这些展示数据。它支持无归属个人态、企业未认证/审核中/未通过/已认证/认证过期和团队态;用户持有有效 JWT 但在任何企业都没有员工身份时,组件仍须渲染账户胶囊,但不得显示工作台入口,即使 Host 配置了 `workbench`;只有存在员工身份的组织时才显示工作台,Open Application 的 `oid=0` 未选中组织仍按既有规则显示。组织切换由 Host 响应事件,应用加载与 ticket 跳转复用独立的 Application Switcher transport。选中组织时,账户胶囊和菜单资料区必须使用服务端投影中该组织的员工姓名与头像;没有选中组织或该组织没有员工身份时,回退到用户资料。账户胶囊宽度固定,胶囊内姓名超过 6 个字、组织名称超过 8 个字必须截断为省略号,完整值只放在 `title` 上,不得让长名称撑宽胶囊挤压导航左侧内容;展开菜单内仍显示完整名称。组织列表严格使用原型中的企业建筑与团队成员图标,不得替换成员工头像或组织首字。
49
49
 
50
50
  `<uniplat-notification-center>` 是内嵌通知中心面板,复刻 AE-Enterprise-Admin 通知中心原型的两栏收件箱:面板顶栏、五类页签、列表、详情、关联资源、附件下载与“全部标已读”。**组件和 core 都不建立 WebSocket 连接**:Host 保留自己唯一的 `/api/user-realtime/ws` 连接并订阅 `notification` channel,把事件原始负载通过 `applyRealtimeEvent()` 喂给组件;`authenticate` 首帧、心跳、游标、重连和断连提示全部归 Host。组件只负责收件箱投影、增量更新和未读数,顶栏铃铛仍属 Host chrome。不得为通知新增第二条 socket 或平行实时通道。
51
51
 
@@ -435,11 +435,11 @@ npm 包名、导出类型名、Custom Element 标签、`configure()` 结构、
435
435
  - Account Context 内嵌工作台需测试不注册独立应用切换标签、JWT 只交给 transport、HTTP Authorization header、入口净化、当前组织级应用加 `application_scope=open` 应用的筛选、缺字段时 `oid=0` 兼容、`issue_ticket` 空请求体、签发响应来源进入新 Fragment、公共事件无 JWT/Ticket、Ticket 只进入局部导航 URL、目标页面先清 Fragment 再按初始化应用登录、响应应用绑定校验、加载错误、仅请求应用列表和应用选择交互。
436
436
  - User Login 需测试组件注册与导出、三个固定页签、不可用能力禁用、原型中不存在图形验证码且不调用兼容方法、逐字段失焦校验和协议错误态、稳定后端错误码映射、短信倒计时、密码登录、扫码待确认/已扫码/过期/成功状态、注册成功过渡页、2 秒自动入驻和立即开始、页签切换与断开后的定时器清理、登录回调,以及 JWT、密码和二维码 challenge 不进入 URL、DOM、公共事件、日志或安全错误。
437
437
  - User Menu 需测试组件注册与导出、40.1 6.7 原型关键内容、个人账号与当前组织员工信息入口、企业认证与团队样式、安全头像与文字回退、Host 受控选中状态、重新配置、空组织状态、完整手机号拒绝、创建与退出事件、普通文本插值,以及公共事件只包含必要的组织 ID,不包含显示名、手机号、组织名称、头像 URL、JWT 或内部配置。
438
- - 内置组织入驻需测试所有原型步骤、逐字段校验、稳定 transport 错误映射、企业/团队成功状态、跳过与未展开流程事件、普通文本插值,以及公共事件不包含企业信息、信用代码、JWT 或原始异常。强制入驻还需测试零归属投影自动打开 top layer 对话框、不渲染跳过与无归属提示、`Escape` 与外部点击都不关闭、“退出登录”与“用邀请码加入”只派发空 detail 且加入不关闭对话框、表单页“返回”回到创建方式选择、成功页动作才关闭对话框,以及传入历史 `mandatory: false` 也不能让无归属用户跳过。真实 UniplatBase transport 还必须测试每次请求重新取 JWT、JWT 只进入 Authorization header、企业/团队请求字段、成功响应净化和未知错误收敛。
438
+ - 内置组织入驻需测试所有原型步骤、逐字段校验、稳定 transport 错误映射、企业/团队成功状态、跳过与未展开流程事件、普通文本插值,以及公共事件不包含企业信息、信用代码、JWT 或原始异常。强制入驻还需测试零归属投影自动打开 top layer 对话框、不渲染跳过、无归属提示与邀请码入口、`Escape` 与外部点击都不关闭、“退出登录”只派发空 detail、表单页“返回”回到创建方式选择、成功页动作才关闭对话框,以及传入历史 `mandatory: false` 也不能让无归属用户跳过。真实 UniplatBase transport 还必须测试每次请求重新取 JWT、JWT 只进入 Authorization header、企业/团队请求字段、成功响应净化和未知错误收敛。
439
439
  - Organization Selector 需测试完整列表、企业认证与团队类型样式、脱敏账户摘要与完整手机号拒绝、按名称和统一社会信用代码搜索且代码不进入 DOM、类型筛选与常显计数、“最近进入 / 全部组织”分组与默认选中、带目标名称的主按钮、无匹配与空归属状态、记住偏好、创建/进入/退出登录事件、`modal` 强制态无关闭按钮且遮罩点击与 `Escape` 都不关闭并在移除后解除 `Escape` 拦截、非法 `presentation` 与重复 ID 拒绝和普通文本插值;事件只包含稳定组织 ID 与偏好,不含姓名、手机号、组织名称或信用代码。`resolveOrganizationEntryDecision()` 需测试 0 个归属、单一归属、命中与失效的记住选择、2 个及以上的默认选中项和非法输入拒绝。
440
440
  - Organization Multi Employee Selector 需测试部门树、员工分页和搜索、初始选择补取、跨部门按员工 ID 去重、多选上限、受保护项、确认/取消事件、独立滚动、移动视图、错误重试、身份切换清理,以及 JWT、完整手机号、身份字段和后端错误不进入 DOM、事件、URL或日志。专用 transport 必须测试每次请求重新读取 JWT、只使用 Authorization header、组织级 directory scope 和无调用方身份参数。
441
441
  - Organization Single Employee Selector 需测试独立标签与导出、初始单个 ID 回显、新选择替换旧选择、未选时禁止确认、受保护项、标量确认 detail、拒绝多选打开参数,以及与多选组件相同的数据和认证安全边界。
442
- - Account Context 需测试必填应用名、组件通过 transport 自行加载用户资料与完整应用组织投影、无归属、企业五种认证状态和团队状态、当前组织员工身份优先及无组织用户身份回退、安全头像与加载失败回退、胶囊内姓名 6 字与组织名 8 字截断及完整 `title`、未超长时不加 `title`、工作台、账户菜单与内置入驻浮层互斥、浮层不改变导航布局、内嵌应用九宫格、创建入口直接启动内置入驻、Host 响应组织切换事件、退出和公共事件安全。专用 UniplatBase transport 还必须测试不请求 `profile_summary_v2`、从 `list_available_organizations` 顶层读取用户资料、按 `application_name` 加载投影、Open Application 的 `organization_oid` 切换、组织级应用目标 `xid` 选择、JWT 只进入 Authorization header 和 Host `setAccessToken()`、响应净化、稳定错误以及公共数据无 JWT;组件范围不得扩展为品牌或完整导航。
442
+ - Account Context 需测试必填应用名、组件通过 transport 自行加载用户资料与完整应用组织投影、无归属时仍渲染账户胶囊但不显示工作台入口、带 `code` 的邀请页豁免强制入驻且邀请码不进 DOM、缺 `code` 或其他路径仍强制、离开邀请页后重新强制、Host 自定义 `invitePaths` 生效、企业五种认证状态和团队状态、当前组织员工身份优先及无组织用户身份回退、安全头像与加载失败回退、胶囊内姓名 6 字与组织名 8 字截断及完整 `title`、未超长时不加 `title`、工作台、账户菜单与内置入驻浮层互斥、浮层不改变导航布局、内嵌应用九宫格、创建入口直接启动内置入驻、Host 响应组织切换事件、退出和公共事件安全。专用 UniplatBase transport 还必须测试不请求 `profile_summary_v2`、从 `list_available_organizations` 顶层读取用户资料、按 `application_name` 加载投影、Open Application 的 `organization_oid` 切换、组织级应用目标 `xid` 选择、JWT 只进入 Authorization header 和 Host `setAccessToken()`、响应净化、稳定错误以及公共数据无 JWT;组件范围不得扩展为品牌或完整导航。
443
443
  - 通知中心需测试组件注册与导出、两栏首版输出、数据驱动分类页签(`includeTypeFacets` 随收件箱请求、后端 `type_facets` 优先、无 facet 回退累积、筛选后页签不塌缩、展示名取 `notification_type_name` 且为空时回退原始 code、契约四个码不得残留内置中文名、实时新分类入目录)、未读角标与 `unread-count-change`、打开未读即标记已读、全部已读、加载更多、关联资源事件只含安全业务标识、附件经 transport 下载、Markdown 净化、`applyRealtimeEvent()` 不建立任何 WebSocket 且原地更新列表与未读数、事件无法映射时降级刷新、收件箱在途时延后 resync 不丢事件、`configure()` 之前与移除之后喂入事件无副作用、错误横幅为净化文案,以及 JWT 与后端原始正文不进入 DOM、公共事件或日志。小型通知浮层还需测试初始关闭、4 条未读投影、未读总数、严格原型结构与 SVG 图标、单条/全部已读、完整通知页意图事件只含稳定 ID、外部关闭、空态以及 Host 实时事件更新。
444
444
  - 使用 fake timer 的测试必须恢复 timer;构造 fake WebSocket 时不要把测试凭据写入 URL。
445
445
  - 修复竞态或生命周期问题时增加回归测试,不只验证最终 DOM。
package/CLAUDE.md CHANGED
@@ -37,7 +37,7 @@ examples/vanilla 原生 HTML / JavaScript 接入示例
37
37
 
38
38
  `<uniplat-user-login>` 必须严格复刻亲亲企服 40.1 注册登录原型,提供密码、验证码和扫码三个固定页签、逐字段校验错误态及注册成功过渡页。验证码登录只包含手机号和短信验证码,不得因为接口现状增加图形验证码或其他原型中不存在的字段;旧版 `createCaptcha()` 仅为源码兼容保留,组件不得调用。自定义 Host transport 的密码与扫码方法保持可选,未提供时对应页签保持显示但禁用。UniplatBase 专用 transport 已复用短信验证码及统一 `login-application` 接口提供三种登录能力;组件本身不得保存 JWT,登录结果必须先交给宿主 `setAccessToken()`,再派发不含凭据的成功事件。后端明确返回新用户时显示 2 秒过渡页,并通过 `onboarding-requested` 请求 Host 调用 `<uniplat-account-context>.startOrganizationOnboarding()`。
39
39
 
40
- 组织入驻不是公开 Web Component。40.1 原型 02、A/B 企业/团队创建分支、校验/成功状态及 4.1 无归属提示均由 `<uniplat-account-context>` 内部实现;Host 在其 `organizationOnboarding.transport` 中注入创建能力,不能使用或注册 `<uniplat-organization-onboarding>`。个人用户不允许以非员工身份留在系统里,因此无归属用户必须先入驻,没有任何跳过开关:账户投影为零归属时组件用原生 `dialog.showModal()` 自动打开强制态创建流程,不渲染跳过、无归属提示与关闭按钮,遮罩点击、`Escape` 与 `cancel` 都不关闭。强制态首屏只有三条出路:创建企业、创建团队,或“已有团队 / 企业?用邀请码加入”派发 `join-organization-requested`(公司已存在的员工必须能加入而不是新建重复组织);另有“退出登录”派发 `logout-requested`。企业/团队表单页提供“返回”回到创建方式选择,创建成功页的认证、升级或邀请动作才是关闭对话框的终点。`organizationOnboarding` 只接收 `transport`,不得再提供 `mandatory` 之类的退出开关。强制态必须使用 top layer 对话框,不得只依赖可能被 Host `transform/overflow` 限制的固定遮罩。点击账户菜单或个人态的“创建团队 / 企业”必须直接启动内部流程,不得向 Host 派发 `organization-create-requested` 或 `create-organization-requested`。真实 UniplatBase transport 复用 `createUniplatBaseOrganizationOnboardingTransport()` 与 `system.org/create_organization`。邀请码加入、企业认证、团队升级和邀请成员等未展开页面仍派发业务事件,由 Host 实现。
40
+ 组织入驻不是公开 Web Component。40.1 原型 02、A/B 企业/团队创建分支、校验/成功状态及 4.1 无归属提示均由 `<uniplat-account-context>` 内部实现;Host 在其 `organizationOnboarding.transport` 中注入创建能力,不能使用或注册 `<uniplat-organization-onboarding>`。个人用户不允许以非员工身份留在系统里,因此无归属用户必须先入驻,没有任何跳过开关:账户投影为零归属时组件用原生 `dialog.showModal()` 自动打开强制态创建流程,不渲染跳过、无归属提示与关闭按钮,遮罩点击、`Escape` 与 `cancel` 都不关闭。强制态首屏只有创建企业、创建团队和“退出登录”(派发 `logout-requested`)三个出口,不提供邀请码加入入口:加入已有组织通过扫码或粘贴邀请链接进入系统完成,Host 也不需要为强制态实现邀请码页面。组织邀请页豁免强制入驻:当前 URL 末段路径命中 `organizationOnboarding.invitePaths`(默认 `['org-invite']`)且带非空 `code` 时不弹对话框,避免盖住加入流程;路径与 hash 路由都参与匹配,组件只取一个布尔值,邀请码的值不得进入组件状态、DOM、公共事件或日志。离开邀请页(`popstate`/`hashchange`)后仍无员工身份时必须重新强制入驻,不得留下可浏览的无归属态。企业/团队表单页提供“返回”回到创建方式选择,创建成功页的认证、升级或邀请动作才是关闭对话框的终点。`organizationOnboarding` 只接收 `transport`,不得再提供 `mandatory` 之类的退出开关。强制态必须使用 top layer 对话框,不得只依赖可能被 Host `transform/overflow` 限制的固定遮罩。点击账户菜单或个人态的“创建团队 / 企业”必须直接启动内部流程,不得向 Host 派发 `organization-create-requested` 或 `create-organization-requested`。真实 UniplatBase transport 复用 `createUniplatBaseOrganizationOnboardingTransport()` 与 `system.org/create_organization`。邀请码加入、企业认证、团队升级和邀请成员等未展开页面仍派发业务事件,由 Host 实现。
41
41
 
42
42
  `<uniplat-organization-selector>` 覆盖 40.1 原型 S.1 多归属选择弹窗,显示 Host 提供的完整企业/团队列表、类型、认证状态、角色、成员数和最近进入信息,并自行负责脱敏账户摘要、名称与统一社会信用代码搜索、全部/企业/团队类型筛选、“最近进入 / 全部组织”分组、默认选中和带目标名称的主按钮。选中与记住偏好只在组件内临时维护,点击“进入”后交由 Host 完成登录态切换,“退出登录”只派发事件。`presentation: 'modal'` 时组件自渲染 S.1 强制态弹窗:固定遮罩、无关闭按钮,遮罩点击与 `Escape` 都不关闭,`Escape` 也不冒泡到 Host 的全局关闭逻辑;默认 `inline` 只输出卡片,遮罩归 Host。S.0 的“弹不弹”判定由纯函数 `resolveOrganizationEntryDecision()` 提供,只按 0 个归属、1 个归属、命中记住选择、2 个及以上分别返回 `personal | auto-enter | prompt`;它不读写宿主存储,记住偏好由 Host 自行持久化并回传,记住的组织已不在列表时退回 `prompt`。
43
43
 
@@ -45,7 +45,7 @@ examples/vanilla 原生 HTML / JavaScript 接入示例
45
45
 
46
46
  `<uniplat-organization-single-employee-selector>` 是语义独立的组织员工单选公共组件。它可与多选组件复用内部数据控制器和真实 transport,但公开标签、`open()` 参数和确认 detail 必须独立。单选界面不渲染多选的已选右侧栏和移动端已选页签,候选区占满内容宽度,选择标识必须为单选圆点而非复选框。单选组件只接收 `initialSelectedEmployeeId`,确认时只返回 `{ employeeId, employee }`;不得向任一组件暴露 `selectionMode`,也不得让单选组件接收 `maxSelection` 或多选 ID 数组。候选数据继续只包含当前 JWT 组织内在职且启用的员工,JWT 和身份字段安全边界与多选组件一致。
47
47
 
48
- `<uniplat-account-context>` 是 40.1 原型导航右侧的统一账户工作台组件,不渲染品牌和全局菜单。它在一个 Host 元素内包含工作台入口、账户胶囊、应用入口浮层和 6.7 企业/团队切换浮层;两个浮层互斥并绝对定位,不改变导航布局。组件根据 Host 提供的 `applicationName` 和账户 transport 自行加载当前应用对应的组织投影与企业认证状态;用户资料复用 `list_available_organizations` 顶层的 `display_name`、`avatar_url` 与 `mobile_masked`,不得再请求 `profile_summary_v2`,Host 不再组装这些展示数据。它支持无归属个人态、企业未认证/审核中/未通过/已认证/认证过期和团队态;组织切换由 Host 响应事件,应用加载与 ticket 跳转复用独立的 Application Switcher transport。选中组织时,账户胶囊和菜单资料区必须使用服务端投影中该组织的员工姓名与头像;没有选中组织或该组织没有员工身份时,回退到用户资料。账户胶囊宽度固定,胶囊内姓名超过 6 个字、组织名称超过 8 个字必须截断为省略号,完整值只放在 `title` 上,不得让长名称撑宽胶囊挤压导航左侧内容;展开菜单内仍显示完整名称。组织列表严格使用原型中的企业建筑与团队成员图标,不得替换成员工头像或组织首字。
48
+ `<uniplat-account-context>` 是 40.1 原型导航右侧的统一账户工作台组件,不渲染品牌和全局菜单。它在一个 Host 元素内包含工作台入口、账户胶囊、应用入口浮层和 6.7 企业/团队切换浮层;两个浮层互斥并绝对定位,不改变导航布局。组件根据 Host 提供的 `applicationName` 和账户 transport 自行加载当前应用对应的组织投影与企业认证状态;用户资料复用 `list_available_organizations` 顶层的 `display_name`、`avatar_url` 与 `mobile_masked`,不得再请求 `profile_summary_v2`,Host 不再组装这些展示数据。它支持无归属个人态、企业未认证/审核中/未通过/已认证/认证过期和团队态;用户持有有效 JWT 但在任何企业都没有员工身份时,组件仍须渲染账户胶囊,但不得显示工作台入口,即使 Host 配置了 `workbench`;只有存在员工身份的组织时才显示工作台,Open Application 的 `oid=0` 未选中组织仍按既有规则显示。组织切换由 Host 响应事件,应用加载与 ticket 跳转复用独立的 Application Switcher transport。选中组织时,账户胶囊和菜单资料区必须使用服务端投影中该组织的员工姓名与头像;没有选中组织或该组织没有员工身份时,回退到用户资料。账户胶囊宽度固定,胶囊内姓名超过 6 个字、组织名称超过 8 个字必须截断为省略号,完整值只放在 `title` 上,不得让长名称撑宽胶囊挤压导航左侧内容;展开菜单内仍显示完整名称。组织列表严格使用原型中的企业建筑与团队成员图标,不得替换成员工头像或组织首字。
49
49
 
50
50
  `<uniplat-notification-center>` 是内嵌通知中心面板,复刻 AE-Enterprise-Admin 通知中心原型的两栏收件箱:面板顶栏、五类页签、列表、详情、关联资源、附件下载与“全部标已读”。**组件和 core 都不建立 WebSocket 连接**:Host 保留自己唯一的 `/api/user-realtime/ws` 连接并订阅 `notification` channel,把事件原始负载通过 `applyRealtimeEvent()` 喂给组件;`authenticate` 首帧、心跳、游标、重连和断连提示全部归 Host。组件只负责收件箱投影、增量更新和未读数,顶栏铃铛仍属 Host chrome。不得为通知新增第二条 socket 或平行实时通道。
51
51
 
@@ -435,11 +435,11 @@ npm 包名、导出类型名、Custom Element 标签、`configure()` 结构、
435
435
  - Account Context 内嵌工作台需测试不注册独立应用切换标签、JWT 只交给 transport、HTTP Authorization header、入口净化、当前组织级应用加 `application_scope=open` 应用的筛选、缺字段时 `oid=0` 兼容、`issue_ticket` 空请求体、签发响应来源进入新 Fragment、公共事件无 JWT/Ticket、Ticket 只进入局部导航 URL、目标页面先清 Fragment 再按初始化应用登录、响应应用绑定校验、加载错误、仅请求应用列表和应用选择交互。
436
436
  - User Login 需测试组件注册与导出、三个固定页签、不可用能力禁用、原型中不存在图形验证码且不调用兼容方法、逐字段失焦校验和协议错误态、稳定后端错误码映射、短信倒计时、密码登录、扫码待确认/已扫码/过期/成功状态、注册成功过渡页、2 秒自动入驻和立即开始、页签切换与断开后的定时器清理、登录回调,以及 JWT、密码和二维码 challenge 不进入 URL、DOM、公共事件、日志或安全错误。
437
437
  - User Menu 需测试组件注册与导出、40.1 6.7 原型关键内容、个人账号与当前组织员工信息入口、企业认证与团队样式、安全头像与文字回退、Host 受控选中状态、重新配置、空组织状态、完整手机号拒绝、创建与退出事件、普通文本插值,以及公共事件只包含必要的组织 ID,不包含显示名、手机号、组织名称、头像 URL、JWT 或内部配置。
438
- - 内置组织入驻需测试所有原型步骤、逐字段校验、稳定 transport 错误映射、企业/团队成功状态、跳过与未展开流程事件、普通文本插值,以及公共事件不包含企业信息、信用代码、JWT 或原始异常。强制入驻还需测试零归属投影自动打开 top layer 对话框、不渲染跳过与无归属提示、`Escape` 与外部点击都不关闭、“退出登录”与“用邀请码加入”只派发空 detail 且加入不关闭对话框、表单页“返回”回到创建方式选择、成功页动作才关闭对话框,以及传入历史 `mandatory: false` 也不能让无归属用户跳过。真实 UniplatBase transport 还必须测试每次请求重新取 JWT、JWT 只进入 Authorization header、企业/团队请求字段、成功响应净化和未知错误收敛。
438
+ - 内置组织入驻需测试所有原型步骤、逐字段校验、稳定 transport 错误映射、企业/团队成功状态、跳过与未展开流程事件、普通文本插值,以及公共事件不包含企业信息、信用代码、JWT 或原始异常。强制入驻还需测试零归属投影自动打开 top layer 对话框、不渲染跳过、无归属提示与邀请码入口、`Escape` 与外部点击都不关闭、“退出登录”只派发空 detail、表单页“返回”回到创建方式选择、成功页动作才关闭对话框,以及传入历史 `mandatory: false` 也不能让无归属用户跳过。真实 UniplatBase transport 还必须测试每次请求重新取 JWT、JWT 只进入 Authorization header、企业/团队请求字段、成功响应净化和未知错误收敛。
439
439
  - Organization Selector 需测试完整列表、企业认证与团队类型样式、脱敏账户摘要与完整手机号拒绝、按名称和统一社会信用代码搜索且代码不进入 DOM、类型筛选与常显计数、“最近进入 / 全部组织”分组与默认选中、带目标名称的主按钮、无匹配与空归属状态、记住偏好、创建/进入/退出登录事件、`modal` 强制态无关闭按钮且遮罩点击与 `Escape` 都不关闭并在移除后解除 `Escape` 拦截、非法 `presentation` 与重复 ID 拒绝和普通文本插值;事件只包含稳定组织 ID 与偏好,不含姓名、手机号、组织名称或信用代码。`resolveOrganizationEntryDecision()` 需测试 0 个归属、单一归属、命中与失效的记住选择、2 个及以上的默认选中项和非法输入拒绝。
440
440
  - Organization Multi Employee Selector 需测试部门树、员工分页和搜索、初始选择补取、跨部门按员工 ID 去重、多选上限、受保护项、确认/取消事件、独立滚动、移动视图、错误重试、身份切换清理,以及 JWT、完整手机号、身份字段和后端错误不进入 DOM、事件、URL或日志。专用 transport 必须测试每次请求重新读取 JWT、只使用 Authorization header、组织级 directory scope 和无调用方身份参数。
441
441
  - Organization Single Employee Selector 需测试独立标签与导出、初始单个 ID 回显、新选择替换旧选择、未选时禁止确认、受保护项、标量确认 detail、拒绝多选打开参数,以及与多选组件相同的数据和认证安全边界。
442
- - Account Context 需测试必填应用名、组件通过 transport 自行加载用户资料与完整应用组织投影、无归属、企业五种认证状态和团队状态、当前组织员工身份优先及无组织用户身份回退、安全头像与加载失败回退、胶囊内姓名 6 字与组织名 8 字截断及完整 `title`、未超长时不加 `title`、工作台、账户菜单与内置入驻浮层互斥、浮层不改变导航布局、内嵌应用九宫格、创建入口直接启动内置入驻、Host 响应组织切换事件、退出和公共事件安全。专用 UniplatBase transport 还必须测试不请求 `profile_summary_v2`、从 `list_available_organizations` 顶层读取用户资料、按 `application_name` 加载投影、Open Application 的 `organization_oid` 切换、组织级应用目标 `xid` 选择、JWT 只进入 Authorization header 和 Host `setAccessToken()`、响应净化、稳定错误以及公共数据无 JWT;组件范围不得扩展为品牌或完整导航。
442
+ - Account Context 需测试必填应用名、组件通过 transport 自行加载用户资料与完整应用组织投影、无归属时仍渲染账户胶囊但不显示工作台入口、带 `code` 的邀请页豁免强制入驻且邀请码不进 DOM、缺 `code` 或其他路径仍强制、离开邀请页后重新强制、Host 自定义 `invitePaths` 生效、企业五种认证状态和团队状态、当前组织员工身份优先及无组织用户身份回退、安全头像与加载失败回退、胶囊内姓名 6 字与组织名 8 字截断及完整 `title`、未超长时不加 `title`、工作台、账户菜单与内置入驻浮层互斥、浮层不改变导航布局、内嵌应用九宫格、创建入口直接启动内置入驻、Host 响应组织切换事件、退出和公共事件安全。专用 UniplatBase transport 还必须测试不请求 `profile_summary_v2`、从 `list_available_organizations` 顶层读取用户资料、按 `application_name` 加载投影、Open Application 的 `organization_oid` 切换、组织级应用目标 `xid` 选择、JWT 只进入 Authorization header 和 Host `setAccessToken()`、响应净化、稳定错误以及公共数据无 JWT;组件范围不得扩展为品牌或完整导航。
443
443
  - 通知中心需测试组件注册与导出、两栏首版输出、数据驱动分类页签(`includeTypeFacets` 随收件箱请求、后端 `type_facets` 优先、无 facet 回退累积、筛选后页签不塌缩、展示名取 `notification_type_name` 且为空时回退原始 code、契约四个码不得残留内置中文名、实时新分类入目录)、未读角标与 `unread-count-change`、打开未读即标记已读、全部已读、加载更多、关联资源事件只含安全业务标识、附件经 transport 下载、Markdown 净化、`applyRealtimeEvent()` 不建立任何 WebSocket 且原地更新列表与未读数、事件无法映射时降级刷新、收件箱在途时延后 resync 不丢事件、`configure()` 之前与移除之后喂入事件无副作用、错误横幅为净化文案,以及 JWT 与后端原始正文不进入 DOM、公共事件或日志。小型通知浮层还需测试初始关闭、4 条未读投影、未读总数、严格原型结构与 SVG 图标、单条/全部已读、完整通知页意图事件只含稳定 ID、外部关闭、空态以及 Host 实时事件更新。
444
444
  - 使用 fake timer 的测试必须恢复 timer;构造 fake WebSocket 时不要把测试凭据写入 URL。
445
445
  - 修复竞态或生命周期问题时增加回归测试,不只验证最终 DOM。
package/README.md CHANGED
@@ -310,7 +310,22 @@ accountContext.configure({
310
310
  })
311
311
  ```
312
312
 
313
- 强制态下不渲染“跳过”“暂不创建,先逛逛”和关闭按钮,遮罩点击、`Escape` 与原生 `cancel` 都不关闭。首屏出路有三条:创建企业、创建团队,或“已有团队 / 企业?用邀请码加入”(派发 `join-organization-requested`,Host 打开邀请码页面;公司已存在的员工必须能加入而不是新建重复组织);另有“退出登录”派发 `logout-requested`,由 Host 清理登录态。企业/团队表单页提供“返回”回到创建方式选择,避免困在错误分支;创建成功页的“去认证 / 稍后认证 / 升级为企业 / 邀请成员”是关闭对话框的终点。对话框使用 top layer,不受 Host 导航 `transform` `overflow` 影响。
313
+ 强制态下不渲染“跳过”“暂不创建,先逛逛”和关闭按钮,遮罩点击、`Escape` 与原生 `cancel` 都不关闭。首屏出口只有创建企业、创建团队和“退出登录”(派发 `logout-requested`,由 Host 清理登录态):加入已有组织通过扫码或粘贴邀请链接进入系统完成,强制态不提供邀请码入口,Host 也无需为此实现页面。持有有效 JWT 但没有任何企业员工身份时,账户胶囊仍会渲染,但不显示工作台入口,即使配置了 `workbench`。
314
+
315
+ 组织邀请页豁免强制入驻。用户扫码或粘贴 `org-invite?code=...` 进入时已有有效 JWT、还没有员工身份,此时不弹对话框,否则会盖住加入流程:
316
+
317
+ ```js
318
+ accountContext.configure({
319
+ applicationName: 'OfficialWebsite',
320
+ transport: accountTransport,
321
+ organizationOnboarding: {
322
+ transport: organizationOnboardingTransport,
323
+ invitePaths: ['org-invite'], // 默认值,可按 Host 路由覆盖
324
+ },
325
+ })
326
+ ```
327
+
328
+ 匹配规则是「当前 URL 末段路径命中 `invitePaths` 且 `code` 非空」,`pathname` 与 hash 路由都参与匹配。组件只取一个布尔值,邀请码的值不进入组件状态、DOM、公共事件或日志。离开邀请页(`popstate` / `hashchange`)后如果仍没有员工身份,强制入驻会重新生效。企业/团队表单页提供“返回”回到创建方式选择,避免困在错误分支;创建成功页的“去认证 / 稍后认证 / 升级为企业 / 邀请成员”是关闭对话框的终点。对话框使用 top layer,不受 Host 导航 `transform` 或 `overflow` 影响。
314
329
 
315
330
  ### Organization Selector
316
331
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@csntgao/uni-base",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@11.3.0",
6
6
  "publishConfig": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniplat/ai-employee-web-components",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Framework-independent Web Components for Uniplat account, login, application switching and AI Employee chat",
5
5
  "type": "module",
6
6
  "sideEffects": true,
@@ -21,6 +21,13 @@ const INTERNAL_APPLICATION_SWITCHER_TAG = 'uniplat-internal-application-switcher
21
21
  const CAPSULE_DISPLAY_NAME_MAX_CHARS = 6
22
22
  const CAPSULE_ORGANIZATION_NAME_MAX_CHARS = 8
23
23
 
24
+ /**
25
+ * 组织邀请页:用户扫码或粘贴 `org-invite?code=...` 进入时已持有有效 JWT 但还没有
26
+ * 员工身份,此处不强制入驻,否则会盖住加入流程本身。只判断路径与 code 是否存在,
27
+ * 邀请码本身不读入组件状态、事件或日志。
28
+ */
29
+ const DEFAULT_ORGANIZATION_INVITE_PATHS = ['org-invite'] as const
30
+
24
31
  if (
25
32
  typeof customElements !== 'undefined' &&
26
33
  !customElements.get(INTERNAL_APPLICATION_SWITCHER_TAG)
@@ -117,6 +124,11 @@ export interface AccountContextConfig {
117
124
  */
118
125
  organizationOnboarding?: {
119
126
  transport: OrganizationOnboardingTransport
127
+ /**
128
+ * 命中这些路径且带非空 `code` 时不强制入驻,默认 `['org-invite']`。
129
+ * 只用于识别邀请页,组件不读取 code 的值。
130
+ */
131
+ invitePaths?: readonly string[]
120
132
  }
121
133
  }
122
134
 
@@ -714,6 +726,7 @@ export class UniplatAccountContext extends LitElement {
714
726
 
715
727
  private workbenchConfig: ApplicationSwitcherConfig | null = null
716
728
  private organizationOnboardingTransport: OrganizationOnboardingTransport | null = null
729
+ private organizationInvitePaths: readonly string[] = DEFAULT_ORGANIZATION_INVITE_PATHS
717
730
  private configuredOnboardingMandatory = false
718
731
  private applicationName = ''
719
732
  private accountTransport: AccountContextTransport | null = null
@@ -744,6 +757,7 @@ export class UniplatAccountContext extends LitElement {
744
757
  this.organizationOnboardingTransport = normalizeOrganizationOnboardingTransport(
745
758
  config.organizationOnboarding?.transport,
746
759
  )
760
+ this.organizationInvitePaths = normalizeInvitePaths(config.organizationOnboarding?.invitePaths)
747
761
  this.mandatoryOnboarding = false
748
762
  this.configuredApplicationSwitcher = null
749
763
  this.configuredUserMenu = null
@@ -884,13 +898,25 @@ export class UniplatAccountContext extends LitElement {
884
898
  this.organizationOnboardingTransport &&
885
899
  this.viewModel &&
886
900
  this.viewModel.organizations.length === 0 &&
887
- !this.viewModel.organization
901
+ !this.viewModel.organization &&
902
+ !this.isOnOrganizationInvitePage()
888
903
  ) {
889
904
  this.mandatoryOnboarding = true
890
905
  this.activePanel = 'onboarding'
891
906
  }
892
907
  }
893
908
 
909
+ private isOnOrganizationInvitePage(): boolean {
910
+ const location = this.ownerDocument?.defaultView?.location
911
+ if (!location) return false
912
+ return isOrganizationInviteLocation(location, this.organizationInvitePaths)
913
+ }
914
+
915
+ // 离开邀请页后仍然没有员工身份时重新强制入驻,不给出无归属的浏览态。
916
+ private readonly reevaluateMandatoryOnboarding = (): void => {
917
+ this.syncMandatoryOnboarding()
918
+ }
919
+
894
920
  private get onboardingLocked(): boolean {
895
921
  return this.mandatoryOnboarding && this.activePanel === 'onboarding'
896
922
  }
@@ -899,12 +925,19 @@ export class UniplatAccountContext extends LitElement {
899
925
  super.connectedCallback()
900
926
  document.addEventListener('pointerdown', this.handleDocumentPointerDown, true)
901
927
  document.addEventListener('keydown', this.handleDocumentKeyDown)
928
+ const view = this.ownerDocument?.defaultView
929
+ view?.addEventListener('popstate', this.reevaluateMandatoryOnboarding)
930
+ view?.addEventListener('hashchange', this.reevaluateMandatoryOnboarding)
931
+ this.syncMandatoryOnboarding()
902
932
  if (this.accountTransport && !this.viewModel) void this.refresh()
903
933
  }
904
934
 
905
935
  override disconnectedCallback(): void {
906
936
  document.removeEventListener('pointerdown', this.handleDocumentPointerDown, true)
907
937
  document.removeEventListener('keydown', this.handleDocumentKeyDown)
938
+ const view = this.ownerDocument?.defaultView
939
+ view?.removeEventListener('popstate', this.reevaluateMandatoryOnboarding)
940
+ view?.removeEventListener('hashchange', this.reevaluateMandatoryOnboarding)
908
941
  this.activePanel = null
909
942
  this.employeeInfo = null
910
943
  this.loadGeneration += 1
@@ -927,7 +960,10 @@ export class UniplatAccountContext extends LitElement {
927
960
  aria-label="账户、工作台与当前组织"
928
961
  >
929
962
  ${
930
- viewModel.showWorkbench && (organization || this.workbenchConfig)
963
+ // 没有任何企业员工身份时不显示工作台入口,只保留账户胶囊本身。
964
+ viewModel.showWorkbench &&
965
+ hasAvailableOrganizations &&
966
+ (organization || this.workbenchConfig)
931
967
  ? html`<button
932
968
  class="workbench"
933
969
  part="workbench-action"
@@ -1606,6 +1642,38 @@ function normalizeOptionalText(value: unknown, maxLength: number): string {
1606
1642
  return normalized
1607
1643
  }
1608
1644
 
1645
+ function normalizeInvitePaths(value: unknown): readonly string[] {
1646
+ if (value === undefined) return DEFAULT_ORGANIZATION_INVITE_PATHS
1647
+ if (!Array.isArray(value)) {
1648
+ throw new TypeError('account-context organizationOnboarding.invitePaths must be an array')
1649
+ }
1650
+ return value.map((entry) => {
1651
+ const path = normalizeRequiredText(entry, 'Organization invite path', 64)
1652
+ return path.replace(/^\/+|\/+$/g, '').toLowerCase()
1653
+ })
1654
+ }
1655
+
1656
+ /**
1657
+ * 只根据"是否处在组织邀请页"返回布尔值:命中末段路径且存在非空 `code`。
1658
+ * 邀请码的值不返回、不保存,也不写入日志。
1659
+ */
1660
+ function isOrganizationInviteLocation(
1661
+ location: { pathname: string; search: string; hash: string },
1662
+ invitePaths: readonly string[],
1663
+ ): boolean {
1664
+ if (!invitePaths.length) return false
1665
+ const routes = [`${location.pathname}${location.search}`]
1666
+ const hash = location.hash.replace(/^#/, '')
1667
+ if (hash) routes.push(hash)
1668
+ return routes.some((route) => {
1669
+ const [rawPath = '', rawQuery = ''] = route.split('?')
1670
+ const segments = rawPath.split('/').filter(Boolean)
1671
+ const lastSegment = segments[segments.length - 1]?.toLowerCase() ?? ''
1672
+ if (!invitePaths.includes(lastSegment)) return false
1673
+ return Boolean(new URLSearchParams(rawQuery).get('code')?.trim())
1674
+ })
1675
+ }
1676
+
1609
1677
  function truncateCapsuleText(value: string, maxChars: number): string {
1610
1678
  const characters = [...value]
1611
1679
  return characters.length > maxChars ? `${characters.slice(0, maxChars).join('')}…` : value
@@ -273,11 +273,6 @@ export class InternalOrganizationOnboarding extends LitElement {
273
273
  background: #ffffff;
274
274
  }
275
275
 
276
- .join-action {
277
- width: 100%;
278
- margin-top: 10px;
279
- }
280
-
281
276
  .form {
282
277
  display: grid;
283
278
  gap: 15px;
@@ -640,19 +635,6 @@ export class InternalOrganizationOnboarding extends LitElement {
640
635
  >
641
636
  下一步
642
637
  </button>
643
- ${
644
- // 强制入驻时这里是唯一的首屏出口之一:公司已存在的员工应当加入而不是新建重复组织。
645
- this.mandatory
646
- ? html`<button
647
- class="secondary join-action"
648
- part="join-action"
649
- type="button"
650
- @click=${() => this.requestJoin()}
651
- >
652
- 已有团队 / 企业?用邀请码加入
653
- </button>`
654
- : nothing
655
- }
656
638
  </div>
657
639
  </section>
658
640
  `
@@ -307,6 +307,64 @@ describe('<uniplat-account-context>', () => {
307
307
  expect(context.shadowRoot?.querySelector('[part="onboarding-panel"]')).toBeNull()
308
308
  })
309
309
 
310
+ it('exempts the organization invite page so the join flow is not covered', async () => {
311
+ history.replaceState(null, '', '/org-invite?code=INVITE-CODE-1234')
312
+ try {
313
+ const { context } = await mountUnassigned()
314
+
315
+ expect(context.shadowRoot?.querySelector('dialog.onboarding-modal')).toBeNull()
316
+ expect(context.shadowRoot?.querySelector('.context')).not.toBeNull()
317
+ expect(context.shadowRoot?.querySelector('.workbench')).toBeNull()
318
+ // 邀请码只用于判断是否处在邀请页,不得进入 DOM。
319
+ expect(context.shadowRoot?.innerHTML).not.toContain('INVITE-CODE-1234')
320
+ } finally {
321
+ history.replaceState(null, '', '/')
322
+ }
323
+ })
324
+
325
+ it('still forces onboarding on the invite path without a code and on other pages', async () => {
326
+ history.replaceState(null, '', '/org-invite')
327
+ try {
328
+ const { context } = await mountUnassigned()
329
+ expect(context.shadowRoot?.querySelector('dialog.onboarding-modal')).not.toBeNull()
330
+ } finally {
331
+ history.replaceState(null, '', '/')
332
+ }
333
+
334
+ history.replaceState(null, '', '/other-page?code=INVITE-CODE-1234')
335
+ try {
336
+ const { context } = await mountUnassigned()
337
+ expect(context.shadowRoot?.querySelector('dialog.onboarding-modal')).not.toBeNull()
338
+ } finally {
339
+ history.replaceState(null, '', '/')
340
+ }
341
+ })
342
+
343
+ it('re-arms mandatory onboarding after leaving the invite page', async () => {
344
+ history.replaceState(null, '', '/org-invite?code=INVITE-CODE-1234')
345
+ try {
346
+ const { context } = await mountUnassigned()
347
+ expect(context.shadowRoot?.querySelector('dialog.onboarding-modal')).toBeNull()
348
+
349
+ history.replaceState(null, '', '/')
350
+ window.dispatchEvent(new Event('popstate'))
351
+ await settle(context)
352
+ expect(context.shadowRoot?.querySelector('dialog.onboarding-modal')).not.toBeNull()
353
+ } finally {
354
+ history.replaceState(null, '', '/')
355
+ }
356
+ })
357
+
358
+ it('accepts a Host-configured invite path', async () => {
359
+ history.replaceState(null, '', '/join/team-invite?code=INVITE-CODE-1234')
360
+ try {
361
+ const { context } = await mountUnassigned({ invitePaths: ['team-invite'] })
362
+ expect(context.shadowRoot?.querySelector('dialog.onboarding-modal')).toBeNull()
363
+ } finally {
364
+ history.replaceState(null, '', '/')
365
+ }
366
+ })
367
+
310
368
  it('has no opt-out: a legacy mandatory flag cannot let unassigned users skip onboarding', async () => {
311
369
  const { context } = await mountUnassigned({ mandatory: false })
312
370
 
@@ -317,17 +375,36 @@ describe('<uniplat-account-context>', () => {
317
375
  expect(onboardingRoot?.querySelector('[part="skip-action"]')).toBeNull()
318
376
  })
319
377
 
320
- it('keeps the invite-code path in the forced flow so existing employees do not create duplicates', async () => {
378
+ it('offers no invite-code entry in the forced flow because joining happens through a link', async () => {
321
379
  const { context, join } = await mountUnassigned()
322
380
  const onboardingRoot = context.shadowRoot?.querySelector(
323
381
  'uniplat-internal-organization-onboarding',
324
382
  )?.shadowRoot
325
383
 
326
- const joinAction = onboardingRoot?.querySelector<HTMLButtonElement>('[part="join-action"]')
327
- expect(joinAction?.textContent?.trim()).toBe('已有团队 / 企业?用邀请码加入')
328
- joinAction?.click()
329
- expect(join).toHaveBeenCalledTimes(1)
330
- expect(join.mock.calls[0]?.[0].detail).toEqual({})
384
+ expect(onboardingRoot?.querySelector('[part="join-action"]')).toBeNull()
385
+ expect(onboardingRoot?.textContent).not.toContain('邀请码')
386
+ expect(join).not.toHaveBeenCalled()
387
+ })
388
+
389
+ it('renders the account capsule without a workbench entry while the user has no employee identity', async () => {
390
+ const context = document.createElement(ACCOUNT_CONTEXT_TAG)
391
+ context.configure({
392
+ applicationName: APPLICATION_NAME,
393
+ transport: { load: async () => emptyProjection() },
394
+ workbench: { getAccessToken: async () => 'test.access.token' },
395
+ organizationOnboarding: { transport: createMockOrganizationOnboardingTransport() },
396
+ })
397
+ document.body.append(context)
398
+ await vi.waitFor(() =>
399
+ expect(context.shadowRoot?.querySelector('.display-name')).not.toBeNull(),
400
+ )
401
+ await settle(context)
402
+
403
+ expect(context.shadowRoot?.querySelector('.context')).not.toBeNull()
404
+ const capsuleName = context.shadowRoot?.querySelector('.display-name')
405
+ expect(capsuleName?.textContent?.trim()).toBe('131***…')
406
+ expect(capsuleName?.getAttribute('title')).toBe('131****2222')
407
+ expect(context.shadowRoot?.querySelector('.workbench')).toBeNull()
331
408
  expect(context.shadowRoot?.querySelector('dialog.onboarding-modal')).not.toBeNull()
332
409
  })
333
410