@csntgao/uni-base 0.6.1 → 0.6.3
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 +3 -3
- package/CLAUDE.md +3 -3
- package/package.json +1 -1
- package/packages/core/package.json +1 -1
- package/packages/core/src/client.ts +21 -7
- package/packages/core/src/protocol.ts +5 -7
- package/packages/core/tests/client.test.ts +96 -0
- package/packages/web-components/package.json +1 -1
- package/packages/web-components/src/account-context.ts +18 -2
- package/packages/web-components/tests/account-context.test.ts +112 -4
- package//344/272/244/346/216/245/346/226/207/346/241/243.md +34 -17
package/AGENTS.md
CHANGED
|
@@ -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 不再组装这些展示数据。它支持无归属个人态、企业未认证/审核中/未通过/已认证/认证过期和团队态;用户持有有效 JWT 但在任何企业都没有员工身份时,组件仍须渲染账户胶囊,但不得显示工作台入口,即使 Host 配置了 `workbench`;只有存在员工身份的组织时才显示工作台,Open Application 的 `oid=0` 未选中组织仍按既有规则显示。组织切换由 Host 响应事件,应用加载与 ticket 跳转复用独立的 Application Switcher transport
|
|
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。组织投影更新后(创建组织、切换组织、Host 主动 `refresh()`),已挂载的账户菜单必须原地重新配置,不能因为元素实例没变就停留在旧的组织列表;入驻流程收尾时必须再取一次投影,保证新建的组织出现在切换列表里。选中组织时,账户胶囊和菜单资料区必须使用服务端投影中该组织的员工姓名与头像;没有选中组织或该组织没有员工身份时,回退到用户资料。账户胶囊宽度固定,胶囊内姓名超过 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
|
|
|
@@ -180,7 +180,7 @@ pnpm --filter @uniplat/ai-employee-web-components pack
|
|
|
180
180
|
5. 用户输入始终通过宿主 transport 的 HTTP 能力发送,不通过 WebSocket 发送。
|
|
181
181
|
6. `4401` 仅表示 JWT 无效或过期,需要重新调用 `getAccessToken()` 并退避重连;`4400` 和 `4403` 是不可自动恢复的协议或权限错误。`1013` 以及认证帧中的 `SERVICE_*`、`KANBAN_AUTH_FAILED`、`REALTIME_UNAVAILABLE` 表示服务暂不可用,最多退避重连 3 次,不能误报为凭证失效。
|
|
182
182
|
7. 重连前优先通过 HTTP 恢复历史;历史恢复失败时才请求 WebSocket replay 兜底。
|
|
183
|
-
8. 实时消息必须按稳定 ID 去重,并忽略其他 session
|
|
183
|
+
8. 实时消息必须按稳定 ID 去重,并忽略其他 session 的事件。服务端缺少消息 ID 时的合成 ID 只能取消息本身,不得把 JSON Patch 的 `path`/`op` 等投递信封算进指纹,否则同一条消息经 replay 与实时两条路径会得到不同 ID 而重复显示。本地已发送的用户消息在回声窗口内不消费匹配条目,replay 与实时的多次回声都要挡住;首条消息与后续消息都必须登记该窗口。
|
|
184
184
|
9. 主动断开和销毁后不得遗留 socket、心跳、重连任务或事件监听。
|
|
185
185
|
10. 未指定既有 `sessionId` 时,打开抽屉和点击“开启新对话”只能建立本地草稿,不得创建后端空 Session。首条消息必须通过 `createSessionWithFirstMessage()` 原子提交 `request_id + ae_code + agent_code + content + trusted_context + attachments`;失败重试同一操作必须复用原 `request_id`,成功后才绑定服务端返回的 Session 并订阅实时通道。已有 Session 的后续消息继续使用 `sendMessage(sessionId, content)`。
|
|
186
186
|
|
|
@@ -438,7 +438,7 @@ npm 包名、导出类型名、Custom Element 标签、`configure()` 结构、
|
|
|
438
438
|
- Organization Selector 需测试完整列表、企业认证与团队类型样式、脱敏账户摘要与完整手机号拒绝、按名称和统一社会信用代码搜索且代码不进入 DOM、类型筛选与常显计数、“最近进入 / 全部组织”分组与默认选中、带目标名称的主按钮、无匹配与空归属状态、记住偏好、创建/进入/退出登录事件、`modal` 强制态无关闭按钮且遮罩点击与 `Escape` 都不关闭并在移除后解除 `Escape` 拦截、非法 `presentation` 与重复 ID 拒绝和普通文本插值;事件只包含稳定组织 ID 与偏好,不含姓名、手机号、组织名称或信用代码。`resolveOrganizationEntryDecision()` 需测试 0 个归属、单一归属、命中与失效的记住选择、2 个及以上的默认选中项和非法输入拒绝。
|
|
439
439
|
- Organization Multi Employee Selector 需测试部门树、员工分页和搜索、初始选择补取、跨部门按员工 ID 去重、多选上限、受保护项、确认/取消事件、独立滚动、移动视图、错误重试、身份切换清理,以及 JWT、完整手机号、身份字段和后端错误不进入 DOM、事件、URL或日志。专用 transport 必须测试每次请求重新读取 JWT、只使用 Authorization header、组织级 directory scope 和无调用方身份参数。
|
|
440
440
|
- Organization Single Employee Selector 需测试独立标签与导出、初始单个 ID 回显、新选择替换旧选择、未选时禁止确认、受保护项、标量确认 detail、拒绝多选打开参数,以及与多选组件相同的数据和认证安全边界。
|
|
441
|
-
- Account Context
|
|
441
|
+
- 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;组件范围不得扩展为品牌或完整导航。
|
|
442
442
|
- 通知中心需测试组件注册与导出、两栏首版输出、数据驱动分类页签(`includeTypeFacets` 随收件箱请求、后端 `type_facets` 优先、无 facet 回退累积、筛选后页签不塌缩、展示名取 `notification_type_name` 且为空时回退原始 code、契约四个码不得残留内置中文名、实时新分类入目录)、未读角标与 `unread-count-change`、打开未读即标记已读、全部已读、加载更多、关联资源事件只含安全业务标识、附件经 transport 下载、Markdown 净化、`applyRealtimeEvent()` 不建立任何 WebSocket 且原地更新列表与未读数、事件无法映射时降级刷新、收件箱在途时延后 resync 不丢事件、`configure()` 之前与移除之后喂入事件无副作用、错误横幅为净化文案,以及 JWT 与后端原始正文不进入 DOM、公共事件或日志。小型通知浮层还需测试初始关闭、4 条未读投影、未读总数、严格原型结构与 SVG 图标、单条/全部已读、完整通知页意图事件只含稳定 ID、外部关闭、空态以及 Host 实时事件更新。
|
|
443
443
|
- 使用 fake timer 的测试必须恢复 timer;构造 fake WebSocket 时不要把测试凭据写入 URL。
|
|
444
444
|
- 修复竞态或生命周期问题时增加回归测试,不只验证最终 DOM。
|
package/CLAUDE.md
CHANGED
|
@@ -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 不再组装这些展示数据。它支持无归属个人态、企业未认证/审核中/未通过/已认证/认证过期和团队态;用户持有有效 JWT 但在任何企业都没有员工身份时,组件仍须渲染账户胶囊,但不得显示工作台入口,即使 Host 配置了 `workbench`;只有存在员工身份的组织时才显示工作台,Open Application 的 `oid=0` 未选中组织仍按既有规则显示。组织切换由 Host 响应事件,应用加载与 ticket 跳转复用独立的 Application Switcher transport
|
|
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。组织投影更新后(创建组织、切换组织、Host 主动 `refresh()`),已挂载的账户菜单必须原地重新配置,不能因为元素实例没变就停留在旧的组织列表;入驻流程收尾时必须再取一次投影,保证新建的组织出现在切换列表里。选中组织时,账户胶囊和菜单资料区必须使用服务端投影中该组织的员工姓名与头像;没有选中组织或该组织没有员工身份时,回退到用户资料。账户胶囊宽度固定,胶囊内姓名超过 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
|
|
|
@@ -180,7 +180,7 @@ pnpm --filter @uniplat/ai-employee-web-components pack
|
|
|
180
180
|
5. 用户输入始终通过宿主 transport 的 HTTP 能力发送,不通过 WebSocket 发送。
|
|
181
181
|
6. `4401` 仅表示 JWT 无效或过期,需要重新调用 `getAccessToken()` 并退避重连;`4400` 和 `4403` 是不可自动恢复的协议或权限错误。`1013` 以及认证帧中的 `SERVICE_*`、`KANBAN_AUTH_FAILED`、`REALTIME_UNAVAILABLE` 表示服务暂不可用,最多退避重连 3 次,不能误报为凭证失效。
|
|
182
182
|
7. 重连前优先通过 HTTP 恢复历史;历史恢复失败时才请求 WebSocket replay 兜底。
|
|
183
|
-
8. 实时消息必须按稳定 ID 去重,并忽略其他 session
|
|
183
|
+
8. 实时消息必须按稳定 ID 去重,并忽略其他 session 的事件。服务端缺少消息 ID 时的合成 ID 只能取消息本身,不得把 JSON Patch 的 `path`/`op` 等投递信封算进指纹,否则同一条消息经 replay 与实时两条路径会得到不同 ID 而重复显示。本地已发送的用户消息在回声窗口内不消费匹配条目,replay 与实时的多次回声都要挡住;首条消息与后续消息都必须登记该窗口。
|
|
184
184
|
9. 主动断开和销毁后不得遗留 socket、心跳、重连任务或事件监听。
|
|
185
185
|
10. 未指定既有 `sessionId` 时,打开抽屉和点击“开启新对话”只能建立本地草稿,不得创建后端空 Session。首条消息必须通过 `createSessionWithFirstMessage()` 原子提交 `request_id + ae_code + agent_code + content + trusted_context + attachments`;失败重试同一操作必须复用原 `request_id`,成功后才绑定服务端返回的 Session 并订阅实时通道。已有 Session 的后续消息继续使用 `sendMessage(sessionId, content)`。
|
|
186
186
|
|
|
@@ -438,7 +438,7 @@ npm 包名、导出类型名、Custom Element 标签、`configure()` 结构、
|
|
|
438
438
|
- Organization Selector 需测试完整列表、企业认证与团队类型样式、脱敏账户摘要与完整手机号拒绝、按名称和统一社会信用代码搜索且代码不进入 DOM、类型筛选与常显计数、“最近进入 / 全部组织”分组与默认选中、带目标名称的主按钮、无匹配与空归属状态、记住偏好、创建/进入/退出登录事件、`modal` 强制态无关闭按钮且遮罩点击与 `Escape` 都不关闭并在移除后解除 `Escape` 拦截、非法 `presentation` 与重复 ID 拒绝和普通文本插值;事件只包含稳定组织 ID 与偏好,不含姓名、手机号、组织名称或信用代码。`resolveOrganizationEntryDecision()` 需测试 0 个归属、单一归属、命中与失效的记住选择、2 个及以上的默认选中项和非法输入拒绝。
|
|
439
439
|
- Organization Multi Employee Selector 需测试部门树、员工分页和搜索、初始选择补取、跨部门按员工 ID 去重、多选上限、受保护项、确认/取消事件、独立滚动、移动视图、错误重试、身份切换清理,以及 JWT、完整手机号、身份字段和后端错误不进入 DOM、事件、URL或日志。专用 transport 必须测试每次请求重新读取 JWT、只使用 Authorization header、组织级 directory scope 和无调用方身份参数。
|
|
440
440
|
- Organization Single Employee Selector 需测试独立标签与导出、初始单个 ID 回显、新选择替换旧选择、未选时禁止确认、受保护项、标量确认 detail、拒绝多选打开参数,以及与多选组件相同的数据和认证安全边界。
|
|
441
|
-
- Account Context
|
|
441
|
+
- 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;组件范围不得扩展为品牌或完整导航。
|
|
442
442
|
- 通知中心需测试组件注册与导出、两栏首版输出、数据驱动分类页签(`includeTypeFacets` 随收件箱请求、后端 `type_facets` 优先、无 facet 回退累积、筛选后页签不塌缩、展示名取 `notification_type_name` 且为空时回退原始 code、契约四个码不得残留内置中文名、实时新分类入目录)、未读角标与 `unread-count-change`、打开未读即标记已读、全部已读、加载更多、关联资源事件只含安全业务标识、附件经 transport 下载、Markdown 净化、`applyRealtimeEvent()` 不建立任何 WebSocket 且原地更新列表与未读数、事件无法映射时降级刷新、收件箱在途时延后 resync 不丢事件、`configure()` 之前与移除之后喂入事件无副作用、错误横幅为净化文案,以及 JWT 与后端原始正文不进入 DOM、公共事件或日志。小型通知浮层还需测试初始关闭、4 条未读投影、未读总数、严格原型结构与 SVG 图标、单条/全部已读、完整通知页意图事件只含稳定 ID、外部关闭、空态以及 Host 实时事件更新。
|
|
443
443
|
- 使用 fake timer 的测试必须恢复 timer;构造 fake WebSocket 时不要把测试凭据写入 URL。
|
|
444
444
|
- 修复竞态或生命周期问题时增加回归测试,不只验证最终 DOM。
|
package/package.json
CHANGED
|
@@ -313,12 +313,7 @@ export class AiEmployeeClient {
|
|
|
313
313
|
content,
|
|
314
314
|
createdAt: now.toISOString(),
|
|
315
315
|
}
|
|
316
|
-
|
|
317
|
-
if (now.getTime() - sentAt > LOCAL_MESSAGE_MATCH_WINDOW_MS) {
|
|
318
|
-
this.pendingLocalMessages.delete(pendingContent)
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
this.pendingLocalMessages.set(content, now.getTime())
|
|
316
|
+
this.rememberPendingLocalMessage(content)
|
|
322
317
|
this.appendMessage(message, 'local')
|
|
323
318
|
this.setProcessing(true)
|
|
324
319
|
return message
|
|
@@ -374,6 +369,9 @@ export class AiEmployeeClient {
|
|
|
374
369
|
content,
|
|
375
370
|
createdAt: result.createdAt ?? result.startedAt,
|
|
376
371
|
}
|
|
372
|
+
// 首条消息同样要登记本地回声窗口:服务端 replay 与实时事件可能带着与
|
|
373
|
+
// createSessionWithFirstMessage 不同的消息 ID 回来。
|
|
374
|
+
this.rememberPendingLocalMessage(content)
|
|
377
375
|
this.appendMessage(message, 'local')
|
|
378
376
|
this.setProcessing(true)
|
|
379
377
|
this.replayHistoryOnNextSubscription = true
|
|
@@ -610,11 +608,27 @@ export class AiEmployeeClient {
|
|
|
610
608
|
this.emit('ready', { sessionId })
|
|
611
609
|
}
|
|
612
610
|
|
|
611
|
+
private rememberPendingLocalMessage(content: string): void {
|
|
612
|
+
const now = Date.now()
|
|
613
|
+
for (const [pendingContent, sentAt] of this.pendingLocalMessages) {
|
|
614
|
+
if (now - sentAt > LOCAL_MESSAGE_MATCH_WINDOW_MS) {
|
|
615
|
+
this.pendingLocalMessages.delete(pendingContent)
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
this.pendingLocalMessages.set(content, now)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* 本地已渲染的用户消息会被服务端以 replay 和实时事件多次回送,且不保证复用
|
|
623
|
+
* 同一个消息 ID。窗口期内不消费该条目,多次回声都要挡住;本地发送本身每次都
|
|
624
|
+
* 会追加气泡,所以重复发送同样内容不会因此丢消息。
|
|
625
|
+
*/
|
|
613
626
|
private matchesPendingLocalMessage(message: AiEmployeeMessage): boolean {
|
|
614
627
|
const sentAt = this.pendingLocalMessages.get(message.content)
|
|
615
628
|
if (sentAt === undefined) return false
|
|
629
|
+
if (Date.now() - sentAt <= LOCAL_MESSAGE_MATCH_WINDOW_MS) return true
|
|
616
630
|
this.pendingLocalMessages.delete(message.content)
|
|
617
|
-
return
|
|
631
|
+
return false
|
|
618
632
|
}
|
|
619
633
|
|
|
620
634
|
private appendMessage(message: AiEmployeeMessage, source: 'local' | 'realtime'): void {
|
|
@@ -96,7 +96,7 @@ function parseSessionPayload(payload: JsonRecord, sessionId: string): ParsedSess
|
|
|
96
96
|
for (const rawPatch of patches) {
|
|
97
97
|
const patch = asRecord(rawPatch)
|
|
98
98
|
const value = asRecord(patch.value)
|
|
99
|
-
const parsed = messageFromEntry(value, sessionId
|
|
99
|
+
const parsed = messageFromEntry(value, sessionId)
|
|
100
100
|
if (parsed) messages.push(parsed)
|
|
101
101
|
}
|
|
102
102
|
|
|
@@ -129,11 +129,7 @@ function parseSessionPayload(payload: JsonRecord, sessionId: string): ParsedSess
|
|
|
129
129
|
return { messages, fallbackResult, processing }
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
function messageFromEntry(
|
|
133
|
-
entry: JsonRecord,
|
|
134
|
-
sessionId: string,
|
|
135
|
-
patch: JsonRecord,
|
|
136
|
-
): AiEmployeeMessage | null {
|
|
132
|
+
function messageFromEntry(entry: JsonRecord, sessionId: string): AiEmployeeMessage | null {
|
|
137
133
|
const entryTypeValue = entry.entry_type
|
|
138
134
|
const entryType =
|
|
139
135
|
typeof entryTypeValue === 'string' ? entryTypeValue : stringValue(asRecord(entryTypeValue).type)
|
|
@@ -168,7 +164,9 @@ function messageFromEntry(
|
|
|
168
164
|
role === 'user'
|
|
169
165
|
? messageId || webSocketMessageId || stringValue(entry.id)
|
|
170
166
|
: webSocketMessageId || messageId || stringValue(entry.id)
|
|
171
|
-
|
|
167
|
+
// 同一条消息经 replay 与实时两条路径投递时 patch 的 path/op 不同,
|
|
168
|
+
// 合成 ID 只能取消息本身,否则同一条消息会得到不同 ID 而重复显示。
|
|
169
|
+
const fingerprint = stableString(entry)
|
|
172
170
|
const id = explicitId || `realtime:${sessionId}:${hashText(fingerprint)}`
|
|
173
171
|
return createMessage(sessionId, role, content, id, entry)
|
|
174
172
|
}
|
|
@@ -185,6 +185,102 @@ describe('AiEmployeeClient', () => {
|
|
|
185
185
|
expect(client.messages.filter((message) => message.id === 'ws-message-1')).toHaveLength(1)
|
|
186
186
|
})
|
|
187
187
|
|
|
188
|
+
it('deduplicates the same realtime message delivered through different patch paths', async () => {
|
|
189
|
+
const { client, sockets } = createHarness()
|
|
190
|
+
const received: string[] = []
|
|
191
|
+
client.on('message', ({ message }) => received.push(message.content))
|
|
192
|
+
await client.start()
|
|
193
|
+
await authenticateAndReady(sockets[0]!)
|
|
194
|
+
|
|
195
|
+
// 服务端没给消息 ID 时只能合成;replay 与实时投递的 patch 路径不同,
|
|
196
|
+
// 合成 ID 必须仍然相同,否则同一条消息会重复显示。
|
|
197
|
+
const entry = {
|
|
198
|
+
entry_type: { type: 'assistant_message' },
|
|
199
|
+
content: JSON.stringify({ text: '同一条回复' }),
|
|
200
|
+
timestamp: '2026-09-04T02:00:00.000Z',
|
|
201
|
+
}
|
|
202
|
+
const frameWithPath = (path: string) => ({
|
|
203
|
+
type: 'realtime_event',
|
|
204
|
+
channel: 'ai_employee',
|
|
205
|
+
event: {
|
|
206
|
+
type: 'session_message',
|
|
207
|
+
session_id: 'session-1',
|
|
208
|
+
payload: { JsonPatch: [{ path, value: entry }] },
|
|
209
|
+
},
|
|
210
|
+
})
|
|
211
|
+
sockets[0]?.receive(frameWithPath('/0'))
|
|
212
|
+
sockets[0]?.receive(frameWithPath('/3'))
|
|
213
|
+
|
|
214
|
+
expect(received).toEqual(['同一条回复'])
|
|
215
|
+
expect(client.messages.filter((message) => message.content === '同一条回复')).toHaveLength(1)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('keeps a locally sent message single across replay and live echoes', async () => {
|
|
219
|
+
const { client, sockets } = createHarness()
|
|
220
|
+
await client.start()
|
|
221
|
+
await authenticateAndReady(sockets[0]!)
|
|
222
|
+
await client.sendMessage('给我生成一个文本文件,内容是123456')
|
|
223
|
+
|
|
224
|
+
// 同一条用户消息被服务端以不同 ID 回送两次(replay + 实时)。
|
|
225
|
+
const echo = (messageId: string) => ({
|
|
226
|
+
type: 'realtime_event',
|
|
227
|
+
channel: 'ai_employee',
|
|
228
|
+
event: {
|
|
229
|
+
type: 'session_message',
|
|
230
|
+
session_id: 'session-1',
|
|
231
|
+
payload: {
|
|
232
|
+
JsonPatch: [
|
|
233
|
+
{
|
|
234
|
+
path: '/0',
|
|
235
|
+
value: {
|
|
236
|
+
message_id: messageId,
|
|
237
|
+
entry_type: { type: 'user_message' },
|
|
238
|
+
content: JSON.stringify({ text: '给我生成一个文本文件,内容是123456' }),
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
})
|
|
245
|
+
sockets[0]?.receive(echo('replay-message-1'))
|
|
246
|
+
sockets[0]?.receive(echo('live-message-1'))
|
|
247
|
+
|
|
248
|
+
expect(
|
|
249
|
+
client.messages.filter((message) => message.content === '给我生成一个文本文件,内容是123456'),
|
|
250
|
+
).toHaveLength(1)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('keeps the first message single when the server echoes another message id', async () => {
|
|
254
|
+
// 草稿会话:这条走的是 createSessionWithFirstMessage 原子建会话路径。
|
|
255
|
+
const { client, sockets } = createHarness(createTransport(), { draft: true })
|
|
256
|
+
await client.start()
|
|
257
|
+
await authenticateAndReady(sockets[0]!)
|
|
258
|
+
await client.sendMessage('首条消息内容')
|
|
259
|
+
|
|
260
|
+
sockets[0]?.receive({
|
|
261
|
+
type: 'realtime_event',
|
|
262
|
+
channel: 'ai_employee',
|
|
263
|
+
event: {
|
|
264
|
+
type: 'session_message',
|
|
265
|
+
session_id: 'atomic-session-default',
|
|
266
|
+
payload: {
|
|
267
|
+
JsonPatch: [
|
|
268
|
+
{
|
|
269
|
+
path: '/0',
|
|
270
|
+
value: {
|
|
271
|
+
message_id: 'server-side-other-id',
|
|
272
|
+
entry_type: { type: 'user_message' },
|
|
273
|
+
content: JSON.stringify({ text: '首条消息内容' }),
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
expect(client.messages.filter((message) => message.content === '首条消息内容')).toHaveLength(1)
|
|
282
|
+
})
|
|
283
|
+
|
|
188
284
|
it('uses a top-level result only when the current turn has no assistant message', async () => {
|
|
189
285
|
const { client, sockets } = createHarness()
|
|
190
286
|
const received: string[] = []
|
|
@@ -731,6 +731,7 @@ export class UniplatAccountContext extends LitElement {
|
|
|
731
731
|
private loadGeneration = 0
|
|
732
732
|
private configuredApplicationSwitcher: UniplatApplicationSwitcher | null = null
|
|
733
733
|
private configuredUserMenu: UniplatUserMenu | null = null
|
|
734
|
+
private configuredUserMenuViewModel: ResolvedAccountContext | null = null
|
|
734
735
|
private configuredOrganizationOnboarding: InternalOrganizationOnboarding | null = null
|
|
735
736
|
private accountSettingsNavigationInFlight = false
|
|
736
737
|
private employeeInfoGeneration = 0
|
|
@@ -757,6 +758,7 @@ export class UniplatAccountContext extends LitElement {
|
|
|
757
758
|
this.mandatoryOnboarding = false
|
|
758
759
|
this.configuredApplicationSwitcher = null
|
|
759
760
|
this.configuredUserMenu = null
|
|
761
|
+
this.configuredUserMenuViewModel = null
|
|
760
762
|
this.configuredOrganizationOnboarding = null
|
|
761
763
|
this.activePanel = null
|
|
762
764
|
this.employeeInfo = null
|
|
@@ -940,6 +942,7 @@ export class UniplatAccountContext extends LitElement {
|
|
|
940
942
|
this.employeeInfoGeneration += 1
|
|
941
943
|
this.configuredApplicationSwitcher = null
|
|
942
944
|
this.configuredUserMenu = null
|
|
945
|
+
this.configuredUserMenuViewModel = null
|
|
943
946
|
this.configuredOrganizationOnboarding = null
|
|
944
947
|
super.disconnectedCallback()
|
|
945
948
|
}
|
|
@@ -1055,11 +1058,21 @@ export class UniplatAccountContext extends LitElement {
|
|
|
1055
1058
|
if (!applicationSwitcher) this.configuredApplicationSwitcher = null
|
|
1056
1059
|
|
|
1057
1060
|
const userMenu = this.renderRoot.querySelector<UniplatUserMenu>('uniplat-user-menu')
|
|
1058
|
-
if (
|
|
1061
|
+
if (
|
|
1062
|
+
userMenu &&
|
|
1063
|
+
this.viewModel &&
|
|
1064
|
+
(userMenu !== this.configuredUserMenu || this.configuredUserMenuViewModel !== this.viewModel)
|
|
1065
|
+
) {
|
|
1066
|
+
// 菜单已挂载时投影也会变(创建组织、切换组织、Host 主动 refresh),
|
|
1067
|
+
// 只按元素实例判断会让组织列表停在旧数据上。
|
|
1059
1068
|
userMenu.configure(this.toUserMenuConfig(this.viewModel))
|
|
1060
1069
|
this.configuredUserMenu = userMenu
|
|
1070
|
+
this.configuredUserMenuViewModel = this.viewModel
|
|
1071
|
+
}
|
|
1072
|
+
if (!userMenu) {
|
|
1073
|
+
this.configuredUserMenu = null
|
|
1074
|
+
this.configuredUserMenuViewModel = null
|
|
1061
1075
|
}
|
|
1062
|
-
if (!userMenu) this.configuredUserMenu = null
|
|
1063
1076
|
|
|
1064
1077
|
const onboarding = this.renderRoot.querySelector<InternalOrganizationOnboarding>(
|
|
1065
1078
|
INTERNAL_ORGANIZATION_ONBOARDING_TAG,
|
|
@@ -1342,6 +1355,9 @@ export class UniplatAccountContext extends LitElement {
|
|
|
1342
1355
|
private readonly finishOnboarding = (): void => {
|
|
1343
1356
|
this.mandatoryOnboarding = false
|
|
1344
1357
|
this.activePanel = null
|
|
1358
|
+
// 创建成功后服务端投影可能晚于 organization-created 才包含新组织,
|
|
1359
|
+
// 流程收尾时再取一次,保证账户菜单里的组织列表是最新的。
|
|
1360
|
+
void this.refresh()
|
|
1345
1361
|
}
|
|
1346
1362
|
|
|
1347
1363
|
private readonly openEmployeeInfo = (): void => {
|
|
@@ -59,6 +59,30 @@ async function settle(context: UniplatAccountContext): Promise<void> {
|
|
|
59
59
|
await Promise.resolve()
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
function projectionWithCreatedOrganization(
|
|
63
|
+
id: string,
|
|
64
|
+
name: string,
|
|
65
|
+
type: 'enterprise' | 'team',
|
|
66
|
+
): AccountContextProjection {
|
|
67
|
+
const organization = {
|
|
68
|
+
id,
|
|
69
|
+
name,
|
|
70
|
+
type,
|
|
71
|
+
employeeDisplayName: '创建者高志刚',
|
|
72
|
+
employeeAvatarText: '高',
|
|
73
|
+
role: '所有者',
|
|
74
|
+
memberCount: 1,
|
|
75
|
+
...(type === 'enterprise' ? { verificationStatus: 'unverified' as const } : {}),
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
applicationName: APPLICATION_NAME,
|
|
79
|
+
user: { displayName: '131****2222', maskedMobile: '131****2222', avatarText: '1' },
|
|
80
|
+
currentOrganizationId: null,
|
|
81
|
+
currentOrganization: null,
|
|
82
|
+
organizations: [organization],
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
62
86
|
function emptyProjection(): AccountContextProjection {
|
|
63
87
|
return {
|
|
64
88
|
applicationName: APPLICATION_NAME,
|
|
@@ -69,7 +93,10 @@ function emptyProjection(): AccountContextProjection {
|
|
|
69
93
|
}
|
|
70
94
|
}
|
|
71
95
|
|
|
72
|
-
async function mountUnassigned(
|
|
96
|
+
async function mountUnassigned(
|
|
97
|
+
extraOnboardingConfig: Record<string, unknown> = {},
|
|
98
|
+
projectionAfterCreation?: AccountContextProjection,
|
|
99
|
+
): Promise<{
|
|
73
100
|
context: UniplatAccountContext
|
|
74
101
|
logout: ReturnType<typeof vi.fn>
|
|
75
102
|
join: ReturnType<typeof vi.fn>
|
|
@@ -77,11 +104,20 @@ async function mountUnassigned(extraOnboardingConfig: Record<string, unknown> =
|
|
|
77
104
|
const context = document.createElement(ACCOUNT_CONTEXT_TAG)
|
|
78
105
|
const logout = vi.fn()
|
|
79
106
|
const join = vi.fn()
|
|
107
|
+
let loadCount = 0
|
|
80
108
|
context.addEventListener('logout-requested', logout)
|
|
81
109
|
context.addEventListener('join-organization-requested', join)
|
|
82
110
|
context.configure({
|
|
83
111
|
applicationName: APPLICATION_NAME,
|
|
84
|
-
transport: {
|
|
112
|
+
transport: {
|
|
113
|
+
async load() {
|
|
114
|
+
loadCount += 1
|
|
115
|
+
// 创建成功后服务端投影应当包含新组织;未提供时保持零归属。
|
|
116
|
+
return loadCount > 1 && projectionAfterCreation
|
|
117
|
+
? projectionAfterCreation
|
|
118
|
+
: emptyProjection()
|
|
119
|
+
},
|
|
120
|
+
},
|
|
85
121
|
organizationOnboarding: {
|
|
86
122
|
transport: createMockOrganizationOnboardingTransport(),
|
|
87
123
|
...extraOnboardingConfig,
|
|
@@ -227,6 +263,72 @@ describe('<uniplat-account-context>', () => {
|
|
|
227
263
|
expect(menu?.shadowRoot?.textContent).toContain('新创建团队')
|
|
228
264
|
})
|
|
229
265
|
|
|
266
|
+
it('reloads the projection again when the onboarding flow finishes', async () => {
|
|
267
|
+
const created = projectionWithCreatedOrganization('team-created', '更更更小的团队', 'team')
|
|
268
|
+
const { context } = await mountUnassigned({}, created)
|
|
269
|
+
|
|
270
|
+
const onboarding = context.shadowRoot?.querySelector('uniplat-internal-organization-onboarding')
|
|
271
|
+
onboarding?.dispatchEvent(
|
|
272
|
+
new CustomEvent('onboarding-finished', {
|
|
273
|
+
detail: { organizationId: 'team-created' },
|
|
274
|
+
bubbles: true,
|
|
275
|
+
composed: true,
|
|
276
|
+
}),
|
|
277
|
+
)
|
|
278
|
+
await settle(context)
|
|
279
|
+
|
|
280
|
+
context.shadowRoot?.querySelector<HTMLButtonElement>('.account')?.click()
|
|
281
|
+
await settle(context)
|
|
282
|
+
const menu = context.shadowRoot?.querySelector<UniplatUserMenu>('uniplat-user-menu')
|
|
283
|
+
await vi.waitFor(async () => {
|
|
284
|
+
await menu?.updateComplete
|
|
285
|
+
expect(menu?.shadowRoot?.textContent).toContain('更更更小的团队')
|
|
286
|
+
})
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('updates an already open account menu when the projection changes', async () => {
|
|
290
|
+
const initialProjection = serverProjection()
|
|
291
|
+
const updatedProjection: AccountContextProjection = {
|
|
292
|
+
...initialProjection,
|
|
293
|
+
organizations: [
|
|
294
|
+
...initialProjection.organizations,
|
|
295
|
+
{
|
|
296
|
+
id: 'team-created',
|
|
297
|
+
name: '更更更小的团队',
|
|
298
|
+
type: 'team' as const,
|
|
299
|
+
employeeDisplayName: '团队成员小张',
|
|
300
|
+
role: '所有者',
|
|
301
|
+
memberCount: 1,
|
|
302
|
+
},
|
|
303
|
+
],
|
|
304
|
+
}
|
|
305
|
+
let loadCount = 0
|
|
306
|
+
const context = document.createElement(ACCOUNT_CONTEXT_TAG)
|
|
307
|
+
context.configure({
|
|
308
|
+
applicationName: APPLICATION_NAME,
|
|
309
|
+
transport: {
|
|
310
|
+
async load() {
|
|
311
|
+
loadCount += 1
|
|
312
|
+
return loadCount === 1 ? initialProjection : updatedProjection
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
})
|
|
316
|
+
document.body.append(context)
|
|
317
|
+
await vi.waitFor(() => expect(context.shadowRoot?.textContent).toContain('企业 A'))
|
|
318
|
+
|
|
319
|
+
context.shadowRoot?.querySelector<HTMLButtonElement>('.account')?.click()
|
|
320
|
+
await settle(context)
|
|
321
|
+
const menu = context.shadowRoot?.querySelector<UniplatUserMenu>('uniplat-user-menu')
|
|
322
|
+
await menu?.updateComplete
|
|
323
|
+
expect(menu?.shadowRoot?.textContent).not.toContain('更更更小的团队')
|
|
324
|
+
|
|
325
|
+
// 菜单保持打开时刷新投影,列表必须原地更新而不是停在旧数据上。
|
|
326
|
+
await context.refresh()
|
|
327
|
+
await settle(context)
|
|
328
|
+
await menu?.updateComplete
|
|
329
|
+
expect(menu?.shadowRoot?.textContent).toContain('更更更小的团队')
|
|
330
|
+
})
|
|
331
|
+
|
|
230
332
|
it('forces unassigned users into a mandatory onboarding modal with only create or logout', async () => {
|
|
231
333
|
const { context, logout } = await mountUnassigned()
|
|
232
334
|
|
|
@@ -284,7 +386,10 @@ describe('<uniplat-account-context>', () => {
|
|
|
284
386
|
})
|
|
285
387
|
|
|
286
388
|
it('closes the mandatory modal only after the created organization success action', async () => {
|
|
287
|
-
const { context } = await mountUnassigned(
|
|
389
|
+
const { context } = await mountUnassigned(
|
|
390
|
+
{},
|
|
391
|
+
projectionWithCreatedOrganization('enterprise-created', '星河科技', 'enterprise'),
|
|
392
|
+
)
|
|
288
393
|
const onboarding = context.shadowRoot?.querySelector('uniplat-internal-organization-onboarding')
|
|
289
394
|
const onboardingRoot = onboarding?.shadowRoot
|
|
290
395
|
|
|
@@ -309,7 +414,10 @@ describe('<uniplat-account-context>', () => {
|
|
|
309
414
|
})
|
|
310
415
|
|
|
311
416
|
it('closes the forced modal from the single confirm action on the team success page', async () => {
|
|
312
|
-
const { context } = await mountUnassigned(
|
|
417
|
+
const { context } = await mountUnassigned(
|
|
418
|
+
{},
|
|
419
|
+
projectionWithCreatedOrganization('team-created', '更更小的团队', 'team'),
|
|
420
|
+
)
|
|
313
421
|
const onboarding = context.shadowRoot?.querySelector('uniplat-internal-organization-onboarding')
|
|
314
422
|
const onboardingRoot = onboarding?.shadowRoot
|
|
315
423
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
>
|
|
7
7
|
> 分支:`main`
|
|
8
8
|
>
|
|
9
|
-
> 交接基线提交:`
|
|
9
|
+
> 交接基线提交:`495ef35 fix(web-components): 创建组织后刷新账户菜单的组织列表`
|
|
10
10
|
|
|
11
11
|
## 1. Claude 接手后的第一步
|
|
12
12
|
|
|
@@ -25,7 +25,7 @@ git log -5 --oneline --decorate
|
|
|
25
25
|
```text
|
|
26
26
|
main 与 origin/main 同步
|
|
27
27
|
工作树无代码改动
|
|
28
|
-
HEAD =
|
|
28
|
+
HEAD = 495ef35
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
不要清理、覆盖或提交根目录已有的 `.tgz`。它们是历史及当前本地发布产物,已由 `.gitignore` 忽略。
|
|
@@ -38,9 +38,9 @@ HEAD = 2aadb87
|
|
|
38
38
|
|
|
39
39
|
| 位置 | npm 名称 | 当前本地版本 | 说明 |
|
|
40
40
|
| ------------------------- | -------------------------------------- | -----------: | --------------------------------------- |
|
|
41
|
-
| 根目录 | `@csntgao/uni-base` | `0.
|
|
41
|
+
| 根目录 | `@csntgao/uni-base` | `0.6.2` | 当前公开发布入口;npmjs 已发布此版本 |
|
|
42
42
|
| `packages/core` | `@uniplat/ai-employee-client` | `0.14.0` | UI 无关的 HTTP/WebSocket/Core 状态机 |
|
|
43
|
-
| `packages/web-components` | `@uniplat/ai-employee-web-components` | `0.
|
|
43
|
+
| `packages/web-components` | `@uniplat/ai-employee-web-components` | `0.29.2` | Lit Web Components 与独立浏览器 runtime |
|
|
44
44
|
| `examples/vanilla` | `@uniplat/ai-employee-vanilla-example` | 私有示例 | 必须消费 workspace 包的构建产物 |
|
|
45
45
|
|
|
46
46
|
注意:根包改名为 `@csntgao/uni-base` 不代表两个 workspace 包、已有 Web Component 标签或公开类型可以随之改名。`@uniplat/*` 名称和 `uniplat-*` 标签仍是公开契约。
|
|
@@ -80,6 +80,11 @@ d19ac72 feat(web-components): 无归属用户强制走创建组织流程
|
|
|
80
80
|
acfeed4 feat(web-components)!: 强制入驻去掉跳过开关并恢复邀请码加入
|
|
81
81
|
efd56ef fix(web-components): 去掉强制入驻邀请码入口并隐藏无身份工作台
|
|
82
82
|
2aadb87 feat(web-components): 组织邀请页豁免强制入驻
|
|
83
|
+
e4738bb fix(web-components): 入驻流程改为居中对话框
|
|
84
|
+
f20ec7b feat(web-components): 入驻右上角改为按归属显示的取消
|
|
85
|
+
95bbc8c feat(web-components)!: 团队创建成功页改为单个确认按钮
|
|
86
|
+
ffab3f9 fix(web-components): 非强制入驻的确认按钮点击无反应
|
|
87
|
+
495ef35 fix(web-components): 创建组织后刷新账户菜单的组织列表
|
|
83
88
|
```
|
|
84
89
|
|
|
85
90
|
新运行机制:**个人用户不允许以非员工身份留在系统中**,跳过与“先逛逛”不再存在。当前实现:
|
|
@@ -87,8 +92,17 @@ efd56ef fix(web-components): 去掉强制入驻邀请码入口并隐藏无身份
|
|
|
87
92
|
1. 账户投影零归属时,`<uniplat-account-context>` 用原生 `dialog.showModal()`(top layer,不受
|
|
88
93
|
Host 导航 `transform/overflow` 影响)自动打开强制态创建流程:不渲染跳过、无归属提示、邀请码
|
|
89
94
|
入口与关闭按钮,遮罩点击、`Escape` 与 `cancel` 都不关闭。
|
|
90
|
-
2. 首屏出口只有创建企业、创建团队和“退出登录”(派发 `logout-requested
|
|
91
|
-
|
|
95
|
+
2. 首屏出口只有创建企业、创建团队和“退出登录”(派发 `logout-requested`)。企业/团队表单页在
|
|
96
|
+
强制态下是“返回”回到创建方式选择。
|
|
97
|
+
2.1 入驻流程一律用原生 `dialog.showModal()` 视口居中,不再用锚定胶囊的下拉浮层;普通态可由
|
|
98
|
+
遮罩点击、`Escape` 或右上角“取消”关闭,强制态一律不关闭。
|
|
99
|
+
2.2 右上角“取消”只在当前用户已归属某个组织时渲染(内部 `canCancel`,按投影归属数量传入),
|
|
100
|
+
点击后直接收起,不再经过“确定暂不创建”确认页——该确认页与其“仍可浏览官网”文案已删除。
|
|
101
|
+
2.3 创建成功页是终点:企业页保留“稍后认证 / 去认证”,团队页只有一个“确认”并派发
|
|
102
|
+
`onboarding-finished`;`team-upgrade-requested` 与 `team-invite-members-requested` 不再派发。
|
|
103
|
+
收尾处理器无论强制与否都收起对话框(早退曾导致非强制态“确认”点不动),并再取一次投影,
|
|
104
|
+
保证新建组织出现在账户菜单的切换列表里。账户菜单在投影变化时也会重新配置,不再因为元素
|
|
105
|
+
实例未变而停在旧列表。
|
|
92
106
|
3. **没有可跳过开关**:`organizationOnboarding` 只接收 `transport` 与可选 `invitePaths`,历史
|
|
93
107
|
`mandatory: false` 不再放行(有用例守着)。
|
|
94
108
|
4. 没有任何企业员工身份时,账户胶囊仍渲染,但**不显示工作台入口**,即使配置了 `workbench`;
|
|
@@ -301,13 +315,13 @@ Account Context 对外只派发净化后的 `LOAD_FAILED`,精确排错应看
|
|
|
301
315
|
|
|
302
316
|
## 7. 当前构建和验证状态
|
|
303
317
|
|
|
304
|
-
在 `
|
|
318
|
+
在 `495ef35` 上最近一次完整检查结果:
|
|
305
319
|
|
|
306
320
|
```text
|
|
307
321
|
pnpm format:check 通过
|
|
308
322
|
pnpm lint 通过
|
|
309
323
|
pnpm typecheck 通过
|
|
310
|
-
pnpm test 25 个测试文件通过;
|
|
324
|
+
pnpm test 25 个测试文件通过;292 passed,2 skipped
|
|
311
325
|
pnpm build core、web-components、runtime、vanilla 全部通过
|
|
312
326
|
```
|
|
313
327
|
|
|
@@ -315,7 +329,7 @@ pnpm build core、web-components、runtime、vanilla 全部通过
|
|
|
315
329
|
|
|
316
330
|
```text
|
|
317
331
|
/Users/gaostudio/uni-base/uniplat-ai-employee-client-0.14.0.tgz
|
|
318
|
-
/Users/gaostudio/uni-base/uniplat-ai-employee-web-components-0.
|
|
332
|
+
/Users/gaostudio/uni-base/uniplat-ai-employee-web-components-0.29.2.tgz
|
|
319
333
|
```
|
|
320
334
|
|
|
321
335
|
根包本地产物:
|
|
@@ -348,9 +362,9 @@ pnpm --filter @uniplat/ai-employee-web-components pack
|
|
|
348
362
|
|
|
349
363
|
```text
|
|
350
364
|
Manifest: https://10.10.10.131:8787/current.json
|
|
351
|
-
Version: 0.
|
|
352
|
-
Module: https://10.10.10.131:8787/releases/0.
|
|
353
|
-
SHA-256:
|
|
365
|
+
Version: 0.29.2
|
|
366
|
+
Module: https://10.10.10.131:8787/releases/0.29.2/index.js
|
|
367
|
+
SHA-256: 60fbeac569ff3cb5e27f7e4ef650fc5114343c44b42a3be3f86ace1da1bb1de9
|
|
354
368
|
```
|
|
355
369
|
|
|
356
370
|
最后验证结果:
|
|
@@ -358,7 +372,7 @@ SHA-256: a5dfb56d11b60b0e3d475b24b2590751f830806e0907ed7013a38fdf05fae1f5
|
|
|
358
372
|
- `current.json`:HTTP 200、`Cache-Control: no-store`;
|
|
359
373
|
- 版本模块:HTTP 200、CORS `*`、`immutable`;
|
|
360
374
|
- 远端模块 hash 与本地一致;
|
|
361
|
-
- `current.json` 指向 `./releases/0.
|
|
375
|
+
- `current.json` 指向 `./releases/0.29.2/index.js`。
|
|
362
376
|
|
|
363
377
|
静态服务未启动时可执行:
|
|
364
378
|
|
|
@@ -380,17 +394,20 @@ https://registry.npmjs.org/
|
|
|
380
394
|
|
|
381
395
|
```text
|
|
382
396
|
npm whoami csntgao(已登录)
|
|
383
|
-
@csntgao/uni-base 0.
|
|
397
|
+
@csntgao/uni-base 0.6.2 已发布并回读校验
|
|
384
398
|
@uniplat/ai-employee-client 未发布,PUT 返回 404(无 scope 创建权限)
|
|
385
399
|
@uniplat/ai-employee-web-components 未发布,同上
|
|
386
400
|
```
|
|
387
401
|
|
|
388
|
-
`@csntgao/uni-base@0.
|
|
402
|
+
`@csntgao/uni-base@0.6.2` 的远端 shasum 为 `9335c2d6e2e32a7bec7c282ef7cce65c90b8d70a`,
|
|
389
403
|
与本地打包一致。发布后远端 `npm view` 有几秒传播延迟,第一次回读到旧版本要重试再确认。注意该包没有 `main`/`exports`/`files`,发布内容是 91 个源码文件,
|
|
390
404
|
不是可直接 `import` 的库;调用方仍应使用 runtime 或 workspace 包。
|
|
391
405
|
|
|
392
|
-
发布需要浏览器 2FA
|
|
393
|
-
|
|
406
|
+
发布需要浏览器 2FA:该账号的第二因子是 passkey / WebAuthn,**没有 TOTP 六位码**,所以
|
|
407
|
+
`npm publish --otp=<code>` 这条路走不通。账号 2FA 当前仍是 `auth-and-writes`,改为 `auth-only`
|
|
408
|
+
的命令是 `npm profile enable-2fa auth-only`(需要账号密码 + 一次浏览器验证),本次未完成。
|
|
409
|
+
本机 shell 不是交互终端,直接 `npm publish` 会以 `EOTP` 退出;用伪终端包一层即可让 npm
|
|
410
|
+
停下来等浏览器授权:
|
|
394
411
|
|
|
395
412
|
```bash
|
|
396
413
|
script -q /dev/null npm publish <绝对路径或 tarball> --access public
|