feat(web): streamline room entry UX [skip ci]
Local typecheck, 37 focused tests, client build, hygiene, responsive Chromium check, and independent P0/P1 review passed.
This commit is contained in:
1 parent
671f7ef731
commit
f6a8d533d7
12 files changed
+194
-105
No files matched your search
+1
-1
@@ -17,7 +17,7 @@ Flagship; parallel; min run/smoke/rollback; benchmark later. Active UI/config/lo
|
||||
|
||||
- Capture precedes room creation; source/quality changes and AV pause preserve peers. Recommended profiles and advanced defaults use `balanced`; clarity/fluid remain explicit. The browser chooses degradation; readback/stats report actual behavior.
|
||||
- Production access is `screener-v2`: a valid private fragment grant enters directly; every code-only Viewer must first hold site access, after which public-watch accepts the code and private rooms still require their room password. `SITE_ACCESS_PASSWORD`, `/api/site-access`, and the site-access cookie are deployed atomically; raw credentials are never stored.
|
||||
- Web Host names are session-local. Presence now exposes exact active upstream for P2P/SFU details and an on-demand Host topology; production still has the prior roster UI.
|
||||
- Source UI: adjacent nickname edit, distinct site/room prompts, shared inline/`/join` room-code form; production has prior UI. Presence exposes exact upstream/topology.
|
||||
- Native v2 is source-only (memory rooms, 300s reclaim); it follows authenticated direct-child assignments up to two and fails unsupported SFU/selected ingress boundedly.
|
||||
- PR #44's STUN-only/SFU-UDP router and token-free fallback prewarm are process-enabled for all normal rooms: roots <=2, browser relay <=1, bounded failure. Room `1` is historical smoke; mobile/iPad viewers are leaves, healthy edges sticky, and HTTPS/WSS stays TLS/TCP.
|
||||
- After an answer, a generation-bound 15s initial-connect deadline enters ICE restart; success/replacement/disposal cancels it. It is deployed but not mobile-verified.
|
||||
|
||||
+7
-5
@@ -308,13 +308,13 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md)
|
||||
|
||||
首屏就是工具,不做营销页。视觉采用中性深灰工作台,绿色表示 direct、琥珀色表示 relay/警告、红色表示失败,避免单一蓝紫色主题;浏览器 favicon 复用页头 `MonitorUp` 的深绿底、绿色显示器和上行箭头语言。
|
||||
|
||||
- 访问入口:未鉴权根路径、`/join` 和 code-only `/r/{code}` 只显示中性的站点访问验证,未知路径只显示“无法访问”。有效 `/r/{code}#v={grant}` 直接进入;其余 Viewer 通过 site access 后,public-watch 进入房间,private room 再显示中性房间密码输入。无效、过期、错误密码或满员只显示通用拒绝,不泄露房间存在性或 policy。
|
||||
- 分享者根页面:顶部显示“{分享者显示名} 的屏幕”和房间状态,并在标题旁显示可单独复制的紧凑房间号;Viewer 同一位置显示权威 Host 名称,不再在标题下重复。主区域保持稳定的 16:9 舞台。未发布、已停止或可恢复错误状态时,舞台内以同等尺寸和权重显示“开始分享”和“加入房间”,中间只用“或”与分隔线表达发送/接收;前者直接开始现有分享流程,后者直接进入 `/join`,不增加首页、向导或拓扑选择。直播时舞台显示本地预览及独立全屏图标,底部保留停止、同步暂停音视频、质量档位和复制完整邀请链接;页面失焦仅暂停本地 video element,媒体暂停则禁用 capture stream 轨道,两者都保留现有连接。
|
||||
- `/join`:通过 site access 后只输入正整数房间码并进入 `/r/{code}`;public-watch 直接通过,private room 缺失 grant 时显示中性房间密码表单。
|
||||
- 访问入口:未鉴权根路径、`/join` 和 code-only `/r/{code}` 显示“站点访问”与“站点口令”,未知路径只显示“无法访问”。有效 `/r/{code}#v={grant}` 直接进入;其余 Viewer 通过 site access 后,public-watch 进入房间,private room 显示“加入房间”与“房间密码”。已有有效 site-access cookie 不重复显示整站验证;站点口令和房间密码不共享输入值或授权状态。无效、过期、错误密码或满员只显示通用拒绝,不泄露房间存在性或 policy。
|
||||
- 分享者根页面:顶部显示“{分享者显示名} 的屏幕”和房间状态,并在标题旁显示可单独复制的紧凑房间号;Viewer 同一位置显示权威 Host 名称,不再在标题下重复。主区域保持稳定的 16:9 舞台。未发布、已停止或可恢复错误状态时,舞台内以同等尺寸和权重显示“开始分享”和“加入房间”,中间只用“或”与分隔线表达发送/接收;前者直接开始现有分享流程,后者作为 disclosure 在当前舞台内、紧邻触发按钮就地展开共享的房间码表单,提交后才导航到 `/r/{code}`。按钮同步 `aria-expanded`/`aria-controls`,展开后输入获得焦点,DOM 与焦点顺序保持触发器后接表单;窄屏允许表单换到动作行下方,不产生横向滚动。不增加首页、向导或拓扑选择。直播时舞台显示本地预览及独立全屏图标,底部保留停止、同步暂停音视频、质量档位和复制完整邀请链接;页面失焦仅暂停本地 video element,媒体暂停则禁用 capture stream 轨道,两者都保留现有连接。
|
||||
- `/join`:保留为可直接访问的回退入口,并复用同一个房间码表单;通过 site access 后只输入正整数房间码并进入 `/r/{code}`。public-watch 直接通过,private room 缺失 grant 时显示当前房间的密码表单。
|
||||
- `/r/{code}` 观看者:private room 首次解析合法 fragment grant 后立即清理地址栏并直达;仅有 code 时先通过 site access,再按 public/private policy 进入。鉴权后主区域最大化远端视频,标题旁显示可复制的紧凑房间号,顶部保留连接状态,并提供本地音量、静音、全屏和重试控制。
|
||||
- 移动端:视频保持稳定宽高比,控制与状态移到视频下方,任何文字和按钮不得溢出或遮挡。
|
||||
|
||||
两端后续默认显示紧凑在线 roster;Host 与 Viewer 的本机显示名默认只读,点击铅笔按钮才展开输入、保存和取消,展示仍为名称加 room-scoped `peerId` 短后缀。一个不持久化的“显示连接详情”复选框按页展开信令、ICE/TURN、浏览器实际暴露的本地/远端候选地址、codec、RTT、RTP 丢包率、编解码耗时及逐边统计;真实连接失败、音频缺失、持续画质受限和需要用户动作的告警始终可见。用户名/roster 不进入 access credential 或 SQLite。
|
||||
两端后续默认显示紧凑在线 roster;Host 与 Viewer 的本机显示名在界面中统一标为“昵称”,默认只读,铅笔按钮紧贴昵称并只切换当前控件的输入、保存和取消,展示仍为名称加 room-scoped `peerId` 短后缀。一个不持久化的“显示连接详情”复选框按页展开信令、ICE/TURN、浏览器实际暴露的本地/远端候选地址、codec、RTT、RTP 丢包率、编解码耗时及逐边统计;真实连接失败、音频缺失、持续画质受限和需要用户动作的告警始终可见。显示名/roster 不进入 access credential 或 SQLite。
|
||||
|
||||
用户可见的非直播状态只使用“已停止分享”(分享端已结束当前发布)、“等待开始分享”(观看端等待发布)、“正在连接”与“正在恢复连接”。`room-closed` 显示“房间已关闭”或“房间已过期”,不得误写成“分享已结束”。房间寿命、媒体代次、换父和恢复等内部状态不得作为额外产品文案暴露。
|
||||
|
||||
@@ -406,7 +406,7 @@ spike 复用现有 screen stream 和浏览器 codec 协商,覆盖当前桌面
|
||||
|
||||
## 官方与参考资料
|
||||
|
||||
访问日期为 2026-08-18 至 2026-08-19:
|
||||
访问日期为 2026-08-18 至 2026-08-21:
|
||||
|
||||
- [Vite Getting Started](https://vite.dev/guide/)
|
||||
- [React: Build a React app from Scratch](https://react.dev/learn/build-a-react-app-from-scratch)
|
||||
@@ -428,6 +428,8 @@ spike 复用现有 screen stream 和浏览器 codec 协商,覆盖当前桌面
|
||||
- [Cookies: HTTP State Management Mechanism draft (RFC6265bis)](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/)
|
||||
- [Node.js `crypto.createHmac()` and `crypto.timingSafeEqual()`](https://nodejs.org/api/crypto.html)
|
||||
- [W3C WAI: Labeling Controls](https://www.w3.org/WAI/tutorials/forms/labels/)
|
||||
- [W3C WAI-ARIA APG: Disclosure Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/)
|
||||
- [W3C WCAG 2.2: Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html)
|
||||
- [W3C WCAG 2.2: Target Size (Enhanced)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced)
|
||||
|
||||
MiroTalk BRO(AGPL-3.0)和 Screego(GPL-3.0)仅用于结构研究。Tailchat Meeting(Apache-2.0)用于捕获生命周期对照,但它采用 mediasoup/SFU,不作为本项目拓扑或依赖底座。项目许可证确定前不复制 GPL/AGPL 代码。
|
||||
|
||||
+5
-5
@@ -16,13 +16,13 @@
|
||||
|
||||
### P0 - 必须实现
|
||||
|
||||
- [ ] 首屏双入口:分享入口通过站点访问后,主工作区必须同等突出“开始分享”和“加入房间”两个主要动作,并用清楚的视觉分隔表达发送与接收;持合法房间 grant 的观看者不需要知道站点访问密码。点击后直接进入现有分享或房间加入流程,不增加营销首页、模式向导或拓扑选择。浏览器标签页使用与现有 `MonitorUp` 品牌语言一致的 favicon。
|
||||
- [ ] 首屏双入口:分享入口通过站点访问后,主工作区必须同等突出“开始分享”和“加入房间”两个主要动作,并用清楚的视觉分隔表达发送与接收;持合法房间 grant 的观看者不需要知道站点访问密码。“加入房间”在当前舞台内就地展开房间码输入并提交,不先跳转到独立页面;直接访问 `/join` 仍提供同一输入能力。不增加营销首页、模式向导或拓扑选择。浏览器标签页使用与现有 `MonitorUp` 品牌语言一致的 favicon。
|
||||
- [ ] 简单房间:分享者获得纯数字房间码,但房间码只负责定位。现有 `/r/{code}#v={grant}` 合法未过期邀请继续直接进入;仅有 `/r/{code}` 或 `/join` 房间码的 Viewer 必须先通过站点访问验证,再按房间 policy 进入:public-watch 接受房间码,private room 还需正确观看密码。所有入口只授予该房间 Viewer,不能建房、发布、冒充 Host 或跨房。Host 与 Viewer 标题附近都紧凑、显眼地显示房间号并提供只复制房间号的动作,Host 保留复制完整邀请链接;持久房间码从 `1` 开始自增,其他模式继续使用随机临时房间码。
|
||||
- [ ] 免安装观看:桌面和手机观看者可直接通过浏览器链接进入、授权并播放,不依赖与分享端相同的桌面客户端。
|
||||
- [ ] 屏幕采集:分享者可主动选择显示器或应用窗口,开始、停止、临时同步暂停音视频和切换分享来源;暂停保留现有连接并禁用当前 capture stream 的全部媒体轨道。Host 本地预览必须直接使用 capture stream,支持独立全屏;页面失焦或隐藏时只暂停本地 video element,恢复焦点后继续预览,不得停止 capture、编码或上行。预览不得建立 viewer/媒体边或消耗 TURN、SFU、应用服务器 media egress;预览仍可能产生本地合成、缩放和 GPU 显示成本,不能把隐藏预览称为停止采集或编码。
|
||||
- [ ] 游戏声音:Windows Chrome/Edge 目标环境可请求系统音频;没有返回音轨时必须明确提示,而不是静默无声。
|
||||
- [ ] 观看端本地音量:播放控制栏提供 `0%` 至 `100%` 音量条和静音按钮,只改变当前浏览器的媒体元素,不修改 Host 音轨、编码、房间状态或其他观看者。切流、换父和 ICE 恢复保留当前页面内的值;刷新后不持久化。静音保留上次非零音量,拖动到非零值自动取消静音。
|
||||
- [ ] 本地显示名与房间成员:用户可设置一个只保存在当前浏览器同源 `localStorage` 的显示名;默认以只读文本显示,点击铅笔按钮才进入编辑。Viewer 顶部标题直接显示“{Host 名称} 的屏幕”,不得在下方重复一行 Host 名称。信令只在当前在线 socket 会话内保存和广播 NFC 规范化、最多 24 个 Unicode code points、拒绝控制/换行/双向覆盖符与孤立 surrogate 的纯文本标签,不写 SQLite 或日志。空值使用通用角色 fallback。Host 必须看到全部当前在线 Viewer 的权威总数与 roster,不能拿最多两条 Host 媒体直连数冒充在线人数;presence 更新也不得创建媒体连接。展示身份使用“名称 + 当前房间 `peerId` 的至少六字符短后缀”,房内碰撞时有界延长。显示名、后缀和 `clientId` 都不是账号、授权身份或全局 UUID,重复名称允许且不得参与授权、路由或质量决策。Web Host 与 Viewer 通过同一 opt-in participant roster 看到 Host 名称;Native 不发送 capability,wire 保持不变。
|
||||
- [ ] 本地昵称与房间成员:用户可设置一个只保存在当前浏览器同源 `localStorage` 的显示名,界面统一称为“昵称”;默认以只读文本显示,铅笔编辑按钮紧贴昵称,点击后才进入编辑。Viewer 顶部标题直接显示“{Host 名称} 的屏幕”,不得在下方重复一行 Host 名称。信令只在当前在线 socket 会话内保存和广播 NFC 规范化、最多 24 个 Unicode code points、拒绝控制/换行/双向覆盖符与孤立 surrogate 的纯文本标签,不写 SQLite 或日志。空值使用通用角色 fallback。Host 必须看到全部当前在线 Viewer 的权威总数与 roster,不能拿最多两条 Host 媒体直连数冒充在线人数;presence 更新也不得创建媒体连接。展示身份使用“名称 + 当前房间 `peerId` 的至少六字符短后缀”,房内碰撞时有界延长。显示名、后缀和 `clientId` 都不是账号、授权身份或全局 UUID,重复名称允许且不得参与授权、路由或质量决策。Web Host 与 Viewer 通过同一 opt-in participant roster 看到 Host 名称;Native 不发送 capability,wire 保持不变。
|
||||
- [ ] 一对多观看:当前安全默认上限为 8、可配置范围为 1 至 16;自动分布式拓扑验收后,同一 release 将默认值和上限改为 20。当前 PoC 的逐观看者连接是测量基线,不再是超过两名观看者时的目标拓扑;未通过 20 人门禁时继续明确拒绝超额加入。
|
||||
- [ ] 端点 fanout 硬上限:任一非服务器端点任何时刻最多维护两条 active downstream media edges,包括加入、断线、换父和恢复过程;上游接收不计入这项上传预算。当前 browser relay 因逐跳重编码仍保守限制为一个 child,只有资源矩阵通过后才可升到二。第三名及后续观看者由系统自动分配给通过验收的 peer-assisted 拓扑,不能隐藏地回退为 Host 第三条连接。没有可行 peer path 时,SFU 作为主要中央兜底 virtual parent 正常向一至两个必要 roots 送流;媒体到达 root 后其余可行观看者仍优先沿受限 peer descendants 扩散。多个无法挂到健康 root 的极端 viewer 可成为额外 server-fed 例外,但必须受显式中央 egress/admission 上限约束,不能无界整房 fanout。TURN 只作为可选的极端网络 transport 兼容层,不定义房间拓扑。
|
||||
- [ ] P2P 优先:每条逻辑媒体边的 ICE 必须先尝试直接 UDP 路径,服务器不得默认中转可直连的媒体。一个或两个观看者优先直接连接分享端;更多观看者的 peer-assisted 行为须先通过 ADR-0004 的实验门槛。
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
### P1 - 重要功能
|
||||
|
||||
- [ ] 私密房间双入口:private room 接受现有 `/r/{code}#v={grant}` 合法邀请直接进入,或“站点访问 + 房间号 + 该房间观看密码”。没有 grant 的 `/join` 或 `/r/{code}` 先显示中性的站点访问验证,通过后才显示中性的房间密码输入。Host 可设置、轮换或移除房间观看密码。密码接受 1 至 64 个可见 ASCII 字符;异步 scrypt 全局最多执行 2 个、等待 16 个派生。站点口令和房间密码都不得进入 URL/query/storage/log/SQLite;不存在、错误密码、队满和正确密码后的满房都返回通用结果。
|
||||
- [ ] 私密房间双入口:private room 接受现有 `/r/{code}#v={grant}` 合法邀请直接进入,或“站点访问 + 房间号 + 该房间观看密码”。没有 grant 的 `/join` 或 `/r/{code}` 先显示明确标为“站点访问”的整站验证,通过后才显示明确标为“加入房间”的当前房间密码输入;有效 site-access cookie 直接跳过第一步,但两类密码不得复用、代填或互相替代。Host 可设置、轮换或移除房间观看密码。密码接受 1 至 64 个可见 ASCII 字符;异步 scrypt 全局最多执行 2 个、等待 16 个派生。站点口令和房间密码都不得进入 URL/query/storage/log/SQLite;不存在、错误密码、队满和正确密码后的满房都返回通用结果。
|
||||
|
||||
- [ ] 统一自动路由:旗舰部署开启 `PEER_ASSISTED_MEDIA` 后,所有正常房间都进入同一个 direct/peer -> SFU -> selected-edge controller;每个房间独立保存 assignment/session/revision/connection generation 和容量状态。`PEER_ASSISTED_ROOM_IDS` 已退役,room `1` 只作为历史 smoke fixture,不能作为功能开关。未启用总开关的开发/轻量部署仍可保留 ordinary P2P;普通 peer 始终 STUN-only。selected-edge TURN 只能复用当前 controller 选中的异常 edge,不能向普通 peer edge 预发 candidates。该能力不增加 UI、用户选择、百分比发布或第二套路由器。
|
||||
- [ ] 质量控制扩展:推荐档位仍是默认入口;折叠高级区只允许 720p/1080p/1440p 分辨率 ceiling、15 至 60 的整数 FPS ceiling、2 至 12 Mbps 视频码率 ceiling,以及清晰/平衡/流畅三种偏好。直播中显式应用后才生效,避免滑杆产生连续控制流量。Web 不提供音频声道、采样率、Opus 码率、stereo、DTX 或 FEC 控件;每个实际浏览器音频 sender 固定请求并读回 128 kbps ceiling,SFU 音频发布使用同值 preset 且关闭 DTX。该值只是标准 API/SDK 支持的发送上限,实际码率仍以 stats 为准。display capture 未把声道和采样率定义为可移植控制,其他项依赖 codec/fmtp 协商而没有可移植 sender setter。不得为这些控件新增应用层 SDP munging 或用默认值制造伪控制。实际质量持续低于所选上限时必须显示可解释的限制原因;在真实拥塞、编码器和接收端证据完成前,不把任一组合称为“完美平衡”。
|
||||
@@ -101,7 +101,7 @@
|
||||
- Given Viewer 只有 site access cookie、另一个房间的 grant、缺失/过期 grant 且没有正确房间密码,或自行把 role 改为 Host,When 首条信令鉴权到达,Then 私密房间返回同一通用无效结果且不泄露房间存在性;不能建房、发布、跨房或绕过 Host token。
|
||||
- Given Host rotate/revoke 私密邀请或把 public-watch 改为私密,When 新摘要持久化成功,Then 服务端先提交新授权代次,再清除 Viewer grace/路由,命令 Viewer 关闭接收 PC/媒体并让每个 Host/relay/SFU upstream owner 关闭对应旧代次媒体边,最后关闭旧 Viewer socket;rotate 和 public-to-private 各返回一次新 grant,revoke 不返回 grant 且锁定 grant 路径,已配置的房间密码仍是独立的以后进入方式。Given 持久化失败,Then 旧摘要、连接和媒体保持不变。
|
||||
- Given 房间处于 public-watch,When 已通过站点访问的用户输入房间码,Then 可在现有 Viewer/连接/egress 上限内观看;没有站点访问时返回中性拒绝。Host 切回私密时签发新 grant 并撤销现有 code-only Viewer。
|
||||
- Given private room 配置了观看密码,When Viewer 仅以房间号访问,Then 先显示中性站点访问验证,通过后才显示中性房间密码表单;正确密码只鉴权当前 Viewer 会话,错误密码与不存在房间返回同一通用结果。Given Host 改密或移除,Then 旧密码的以后鉴权失败,现有 fragment grant 仍可直接进入;public-watch 仍要求站点访问。
|
||||
- Given private room 配置了观看密码,When Viewer 仅以房间号访问,Then 先显示“站点访问 / 站点口令”,通过后才显示“加入房间 / 房间密码”;已有有效 site-access cookie 不重复提示站点口令,正确房间密码只鉴权当前 Viewer 会话,错误密码与不存在房间返回同一通用结果。Given Host 改密或移除,Then 旧密码的以后鉴权失败,现有 fragment grant 仍可直接进入;public-watch 仍要求站点访问。
|
||||
- Given schema v1 或 v2 SQLite 在备份后首次由新 release 打开,When 单事务升级到 v3,Then v1 先补 checked `viewer_grant_digest` 并 fail-closed 锁定旧行,v1/v2 都新增 `viewer_password_material BLOB NULL CHECK (viewer_password_material IS NULL OR length(viewer_password_material) = 48)`;旧 Host token 仍可回收房间,旧 Viewer 链接不能 fail-open。畸形非空值必须拒绝写入或读取时 fail closed;回滚旧 binary 必须恢复迁移前备份,不运行双 schema 代码。
|
||||
- Given 部署切换了不兼容的当前协议,When 仍打开的旧页面连接新服务端,Then 单一 release/protocol mismatch 边界终止该 session、停止自动重连并明确提示刷新;不得通过旧 parser、双写或双协议继续假连接。
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
- Given 用户在普通手机浏览器尝试成为 Host,When `navigator.mediaDevices.getDisplayMedia` 缺失,Then 页面在创建发布路径前明确显示该浏览器不支持移动端屏幕分享,并保留加入房间入口。Given API 存在但真实调用失败,When 失败原因是拒绝/取消或其他 capture error,Then 分别显示对应状态而不是一律重标为不支持;判断使用运行时能力/结果而不是 UA、平台或设备型号,也不能把移动 Viewer 支持宣传成移动 Web Host 支持。
|
||||
- Given 观看者调节音量或静音,When 媒体切流、换父或恢复连接,Then 当前页面的本地播放音量保持不变,其他观看者和发送端不受影响;刷新页面后恢复默认音量,不产生信令、持久化或媒体重协商。
|
||||
- Given Viewer 已通过浏览器要求的用户手势开始可听播放,When 页面切到后台且浏览器仍保留该页面,Then 同一个媒体元素、信令和 WebRTC 连接继续承载音频,返回前台后沿当前直播恢复画面而无需重新加入;后台视频合成或逐帧回调增长不作为连续播放承诺。Given iOS 锁屏、系统回收页面或后台期间产生新的媒体连接,Then 以当前真机行为为准,恢复时继续使用已有重连和显式播放入口。
|
||||
- Given 用户在本机设置显示名,When 以 Host 或 Viewer 加入房间,Then 所有在线参与者看到同一份服务端权威 roster、对应房间标题和“名称 + room-scoped peer 后缀”;刷新后本机名称仍在,清除站点数据后恢复未命名默认值,数据库中不存在显示名或成员记录,展示身份不参与授权或路由。
|
||||
- Given 用户查看本机身份,When 尚未进入编辑状态,Then 界面显示“昵称”、当前昵称和紧贴其后的铅笔按钮,不显示默认可编辑输入框。Given 用户修改昵称后以 Host 或 Viewer 加入房间,Then 所有在线参与者看到同一份服务端权威 roster、对应房间标题和“名称 + room-scoped peer 后缀”;刷新后本机名称仍在,清除站点数据后恢复未命名默认值,数据库中不存在显示名或成员记录,展示身份不参与授权或路由。
|
||||
- Given 手机从 Wi-Fi 切换到蜂窝网络或页面从后台恢复,When 原候选对失效,Then 客户端执行 ICE restart 或重建连接,并显示恢复进度而不是永久黑屏。
|
||||
- Given Viewer 尝试把直播输出到电视,When 浏览器/系统提供已验证的 Remote Playback、AirPlay 或屏幕镜像能力,Then 该动作只改变本机输出设备,现有 upstream PeerConnection、TURN/SFU/peer route 和其他参与者保持不变。Given live `srcObject` 未经目标设备证明或 API 缺失,Then 产品不显示伪可用 Cast/AirPlay 按钮,也不创建自有 receiver 或转码服务器。
|
||||
|
||||
|
||||
+10
-10
@@ -30,7 +30,7 @@ function stateFromStatus(status: SiteAccessStatus): AccessState {
|
||||
function readableError(error: unknown): string {
|
||||
return error instanceof Error && error.message
|
||||
? error.message
|
||||
: "无法连接验证服务,请重试";
|
||||
: "无法连接站点访问服务,请重试";
|
||||
}
|
||||
|
||||
export function App() {
|
||||
@@ -86,7 +86,7 @@ function SiteAccessGate({
|
||||
const submittedPassword = password;
|
||||
setPassword("");
|
||||
if (!submittedPassword.trim()) {
|
||||
setAccess({ kind: "required", error: "请输入访问密码" });
|
||||
setAccess({ kind: "required", error: "请输入站点口令" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ function SiteAccessGate({
|
||||
kind: "required",
|
||||
error:
|
||||
error instanceof ApiError && error.status === 401
|
||||
? "访问密码不正确,请重试"
|
||||
? "站点口令不正确,请重试"
|
||||
: readableError(error),
|
||||
});
|
||||
} finally {
|
||||
@@ -118,7 +118,7 @@ function SiteAccessGate({
|
||||
return (
|
||||
<HostPage
|
||||
onAuthorizationRequired={() =>
|
||||
setAccess({ kind: "required", error: "验证已失效,请重新登录" })
|
||||
setAccess({ kind: "required", error: "站点访问已失效,请重新验证" })
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -130,11 +130,11 @@ function SiteAccessGate({
|
||||
{access.kind === "checking" ? (
|
||||
<div className="access-loading" role="status">
|
||||
<LoaderCircle size={20} className="spin" aria-hidden="true" />
|
||||
正在验证
|
||||
正在验证站点访问
|
||||
</div>
|
||||
) : access.kind === "unavailable" ? (
|
||||
<section className="access-panel" aria-labelledby="access-heading">
|
||||
<h1 id="access-heading">暂时无法验证</h1>
|
||||
<h1 id="access-heading">暂时无法验证站点访问</h1>
|
||||
<p className="access-error" role="alert">
|
||||
{access.message}
|
||||
</p>
|
||||
@@ -149,11 +149,11 @@ function SiteAccessGate({
|
||||
) : (
|
||||
<form className="access-panel" onSubmit={(event) => void submit(event)}>
|
||||
<div>
|
||||
<h1>访问验证</h1>
|
||||
<p className="section-meta">请输入访问口令</p>
|
||||
<h1>站点访问</h1>
|
||||
<p className="section-meta">请输入站点口令</p>
|
||||
</div>
|
||||
<label className="token-field">
|
||||
<span>访问口令</span>
|
||||
<span>站点口令</span>
|
||||
<span className="input-with-icon">
|
||||
<KeyRound size={16} aria-hidden="true" />
|
||||
<input
|
||||
@@ -176,7 +176,7 @@ function SiteAccessGate({
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "正在验证" : "进入"}
|
||||
{submitting ? "正在验证" : "进入站点"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Hash, LogIn } from "lucide-react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { ROOM_CODE_LENGTH } from "../../shared/protocol";
|
||||
import { roomRouteFromInput } from "../lib/session";
|
||||
|
||||
interface RoomCodeEntryProps {
|
||||
id: string;
|
||||
autoFocus?: boolean;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
export function RoomCodeEntry({ id, autoFocus = false, inline = false }: RoomCodeEntryProps) {
|
||||
const [roomId, setRoomId] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputId = `${id}-input`;
|
||||
|
||||
function join(event: FormEvent<HTMLFormElement>): void {
|
||||
event.preventDefault();
|
||||
const route = roomRouteFromInput(roomId);
|
||||
if (!route) {
|
||||
setError("房间码格式不正确");
|
||||
return;
|
||||
}
|
||||
window.location.assign(route);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
id={id}
|
||||
className={inline ? "room-code-entry is-inline" : "access-panel"}
|
||||
onSubmit={join}
|
||||
>
|
||||
{!inline && (
|
||||
<div>
|
||||
<h1>加入房间</h1>
|
||||
<p className="section-meta">输入分享者提供的房间码</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="token-field">
|
||||
<label className={inline ? "visually-hidden" : "field-label"} htmlFor={inputId}>
|
||||
房间码
|
||||
</label>
|
||||
<span className="input-with-icon">
|
||||
<Hash size={16} aria-hidden="true" />
|
||||
<input
|
||||
id={inputId}
|
||||
value={roomId}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={ROOM_CODE_LENGTH}
|
||||
autoComplete="off"
|
||||
autoFocus={autoFocus}
|
||||
placeholder={inline ? "房间码" : undefined}
|
||||
aria-invalid={error ? "true" : undefined}
|
||||
onChange={(event) => {
|
||||
setRoomId(event.target.value.replace(/\D/g, ""));
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="access-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
className={inline ? "icon-button room-code-submit" : "button button-primary"}
|
||||
type="submit"
|
||||
title={inline ? "加入房间" : undefined}
|
||||
aria-label={inline ? "加入房间" : undefined}
|
||||
>
|
||||
{inline ? <LogIn size={18} aria-hidden="true" /> : "加入"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export async function getSiteAccess(): Promise<SiteAccessStatus> {
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
response.status === 401
|
||||
? "验证已失效,请重新登录"
|
||||
? "站点访问已失效,请重新验证"
|
||||
: (errorMessage(body) ?? `验证失败 (${response.status})`),
|
||||
response.status,
|
||||
);
|
||||
@@ -93,7 +93,7 @@ export async function authenticateSiteAccess(
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
response.status === 401
|
||||
? "访问密码不正确,请重试"
|
||||
? "站点口令不正确,请重试"
|
||||
: (errorMessage(body) ?? `验证失败 (${response.status})`),
|
||||
response.status,
|
||||
);
|
||||
@@ -118,7 +118,7 @@ export async function createRoom(
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
response.status === 401
|
||||
? "验证已失效,请重新登录"
|
||||
? "站点访问已失效,请重新验证"
|
||||
: (errorMessage(body) ?? `建房失败 (${response.status})`),
|
||||
response.status,
|
||||
);
|
||||
|
||||
@@ -35,6 +35,11 @@ export function isValidRoomId(value: string): boolean {
|
||||
return roomCodeSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
export function roomRouteFromInput(value: string): string | null {
|
||||
const roomId = value.trim();
|
||||
return isValidRoomId(roomId) ? `/r/${roomId}` : null;
|
||||
}
|
||||
|
||||
export function isHostRoomExpired(
|
||||
room: HostRoomIdentity,
|
||||
now = Date.now(),
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import { AppHeader } from "../components/AppHeader";
|
||||
import { ConnectionDetailsToggle } from "../components/ConnectionDetailsToggle";
|
||||
import { RoomCode } from "../components/RoomCode";
|
||||
import { RoomCodeEntry } from "../components/RoomCodeEntry";
|
||||
import { qualityLimitationSummary } from "../components/connection-details";
|
||||
import {
|
||||
MediaRouteBadge,
|
||||
@@ -228,6 +229,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
const [sharingPaused, setSharingPaused] = useState(false);
|
||||
const [showConnectionDetails, setShowConnectionDetails] = useState(false);
|
||||
const [showTopology, setShowTopology] = useState(false);
|
||||
const [joiningRoom, setJoiningRoom] = useState(false);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
@@ -992,7 +994,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
setViewerPasswordDraft("");
|
||||
if (action) {
|
||||
setNotice(
|
||||
action === "remove" ? "访问密码已移除" : "访问密码已更新",
|
||||
action === "remove" ? "房间密码已移除" : "房间密码已更新",
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -1212,7 +1214,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
isCurrentGeneration(generation) &&
|
||||
signalRef.current === signal
|
||||
) {
|
||||
endSharing("验证已失效,请重新登录", false);
|
||||
endSharing("站点访问已失效,请重新验证", false);
|
||||
onAuthorizationRequired?.();
|
||||
}
|
||||
},
|
||||
@@ -1449,7 +1451,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
!viewerPasswordSchema.safeParse(password).success
|
||||
) {
|
||||
setNotice(
|
||||
`访问密码只需 1-${MAX_VIEWER_PASSWORD_LENGTH} 个可见字符`,
|
||||
`房间密码只需 1-${MAX_VIEWER_PASSWORD_LENGTH} 个可见字符`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -1458,7 +1460,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
phase !== "live" ||
|
||||
!signalRef.current?.send({ type: "set-viewer-password", password })
|
||||
) {
|
||||
setNotice("开始分享并连接后才能修改访问密码");
|
||||
setNotice("开始分享并连接后才能修改房间密码");
|
||||
return;
|
||||
}
|
||||
viewerPasswordActionRef.current = password === null ? "remove" : "set";
|
||||
@@ -1482,7 +1484,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
setDisplayNameError(null);
|
||||
setEditingDisplayName(false);
|
||||
if (!signalRef.current?.setDisplayName(saved)) {
|
||||
setNotice("开始分享并连接后才能修改显示名");
|
||||
setNotice("开始分享并连接后才能修改昵称");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1585,7 +1587,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
<label
|
||||
htmlFor={editingDisplayName ? "host-display-name" : undefined}
|
||||
>
|
||||
显示名
|
||||
昵称
|
||||
</label>
|
||||
{editingDisplayName ? (
|
||||
<>
|
||||
@@ -1605,8 +1607,8 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
<button
|
||||
type="submit"
|
||||
className="icon-button"
|
||||
title="保存显示名"
|
||||
aria-label="保存显示名"
|
||||
title="保存昵称"
|
||||
aria-label="保存昵称"
|
||||
disabled={displayNameDraft === displayName}
|
||||
>
|
||||
<Save size={17} />
|
||||
@@ -1615,7 +1617,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
type="button"
|
||||
className="icon-button"
|
||||
title="取消编辑"
|
||||
aria-label="取消编辑显示名"
|
||||
aria-label="取消编辑昵称"
|
||||
onClick={() => {
|
||||
setDisplayNameDraft(displayName);
|
||||
setDisplayNameError(null);
|
||||
@@ -1631,8 +1633,8 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
title="编辑显示名"
|
||||
aria-label="编辑显示名"
|
||||
title="编辑昵称"
|
||||
aria-label="编辑昵称"
|
||||
onClick={() => {
|
||||
setDisplayNameDraft(displayName);
|
||||
setDisplayNameError(null);
|
||||
@@ -1680,7 +1682,10 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
className="entry-action"
|
||||
type="button"
|
||||
title="开始分享屏幕"
|
||||
onClick={() => void startSharing()}
|
||||
onClick={() => {
|
||||
setJoiningRoom(false);
|
||||
void startSharing();
|
||||
}}
|
||||
>
|
||||
<MonitorUp size={18} aria-hidden="true" />
|
||||
开始分享
|
||||
@@ -1688,14 +1693,24 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
<span className="entry-divider" aria-hidden="true">
|
||||
或
|
||||
</span>
|
||||
<a
|
||||
<button
|
||||
className="entry-action"
|
||||
href="/join"
|
||||
type="button"
|
||||
title="输入房间码加入观看"
|
||||
aria-expanded={joiningRoom}
|
||||
aria-controls="host-room-code-entry"
|
||||
onClick={() => setJoiningRoom((current) => !current)}
|
||||
>
|
||||
<Hash size={18} aria-hidden="true" />
|
||||
加入房间
|
||||
</a>
|
||||
</button>
|
||||
{joiningRoom && (
|
||||
<RoomCodeEntry
|
||||
id="host-room-code-entry"
|
||||
autoFocus
|
||||
inline
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1979,7 +1994,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
changeViewerPassword(viewerPasswordDraft);
|
||||
}}
|
||||
>
|
||||
<label htmlFor="viewer-password">访问密码</label>
|
||||
<label htmlFor="viewer-password">房间密码</label>
|
||||
<span className="input-with-icon">
|
||||
<KeyRound size={16} aria-hidden="true" />
|
||||
<input
|
||||
@@ -2000,9 +2015,9 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
<button
|
||||
className="icon-button"
|
||||
type="submit"
|
||||
title={viewerPasswordEnabled ? "更改访问密码" : "设置访问密码"}
|
||||
title={viewerPasswordEnabled ? "更改房间密码" : "设置房间密码"}
|
||||
aria-label={
|
||||
viewerPasswordEnabled ? "更改访问密码" : "设置访问密码"
|
||||
viewerPasswordEnabled ? "更改房间密码" : "设置房间密码"
|
||||
}
|
||||
disabled={
|
||||
viewerPasswordUpdating ||
|
||||
@@ -2016,8 +2031,8 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
title="移除访问密码"
|
||||
aria-label="移除访问密码"
|
||||
title="移除房间密码"
|
||||
aria-label="移除房间密码"
|
||||
disabled={viewerPasswordUpdating || phase !== "live"}
|
||||
onClick={() => changeViewerPassword(null)}
|
||||
>
|
||||
|
||||
@@ -1,58 +1,12 @@
|
||||
import { Hash } from "lucide-react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { ROOM_CODE_LENGTH } from "../../shared/protocol";
|
||||
import { AppHeader } from "../components/AppHeader";
|
||||
import { isValidRoomId } from "../lib/session";
|
||||
import { RoomCodeEntry } from "../components/RoomCodeEntry";
|
||||
|
||||
export function JoinPage() {
|
||||
const [roomId, setRoomId] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function join(event: FormEvent<HTMLFormElement>): void {
|
||||
event.preventDefault();
|
||||
const normalizedRoomId = roomId.trim();
|
||||
if (!isValidRoomId(normalizedRoomId)) {
|
||||
setError("房间码格式不正确");
|
||||
return;
|
||||
}
|
||||
window.location.assign(`/r/${normalizedRoomId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<AppHeader status={null} />
|
||||
<main className="access-workspace">
|
||||
<form className="access-panel" onSubmit={join}>
|
||||
<div>
|
||||
<h1>加入分享</h1>
|
||||
<p className="section-meta">输入分享者提供的房间码</p>
|
||||
</div>
|
||||
<label className="token-field">
|
||||
<span>房间码</span>
|
||||
<span className="input-with-icon">
|
||||
<Hash size={16} aria-hidden="true" />
|
||||
<input
|
||||
value={roomId}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={ROOM_CODE_LENGTH}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
onChange={(event) =>
|
||||
setRoomId(event.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
{error && (
|
||||
<p className="access-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button className="button button-primary" type="submit">
|
||||
加入
|
||||
</button>
|
||||
</form>
|
||||
<RoomCodeEntry id="join-room-code" autoFocus />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -877,7 +877,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
if (message.code === "INVALID_TOKEN") {
|
||||
clearViewerGrant(roomId);
|
||||
if (!viewerGrant && viewerPasswordAttempt) {
|
||||
setViewerPasswordError("访问验证失败,请重试");
|
||||
setViewerPasswordError("无法加入房间,请重试");
|
||||
}
|
||||
setStatusText("邀请无效或已失效");
|
||||
return;
|
||||
@@ -1033,7 +1033,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
{accessState === "checking" ? (
|
||||
<div className="access-loading" role="status">
|
||||
<LoaderCircle size={20} className="spin" aria-hidden="true" />
|
||||
正在验证
|
||||
正在加入房间
|
||||
</div>
|
||||
) : viewerGrant ? (
|
||||
<section className="access-panel">
|
||||
@@ -1042,11 +1042,11 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
) : (
|
||||
<form className="access-panel" onSubmit={submitViewerPassword}>
|
||||
<div>
|
||||
<h1>访问验证</h1>
|
||||
<p className="section-meta">请输入访问密码</p>
|
||||
<h1>加入房间</h1>
|
||||
<p className="section-meta">请输入当前房间的密码</p>
|
||||
</div>
|
||||
<label className="token-field">
|
||||
<span>访问密码</span>
|
||||
<span>房间密码</span>
|
||||
<span className="input-with-icon">
|
||||
<KeyRound size={16} aria-hidden="true" />
|
||||
<input
|
||||
@@ -1068,7 +1068,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
</p>
|
||||
)}
|
||||
<button className="button button-primary" type="submit">
|
||||
进入
|
||||
加入
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
@@ -1126,7 +1126,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
<label
|
||||
htmlFor={editingDisplayName ? "viewer-display-name" : undefined}
|
||||
>
|
||||
显示名
|
||||
昵称
|
||||
</label>
|
||||
{editingDisplayName ? (
|
||||
<>
|
||||
@@ -1146,8 +1146,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
<button
|
||||
type="submit"
|
||||
className="icon-button"
|
||||
title="保存显示名"
|
||||
aria-label="保存显示名"
|
||||
title="保存昵称"
|
||||
aria-label="保存昵称"
|
||||
disabled={displayNameDraft === displayName}
|
||||
>
|
||||
<Save size={17} />
|
||||
@@ -1156,7 +1156,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
type="button"
|
||||
className="icon-button"
|
||||
title="取消编辑"
|
||||
aria-label="取消编辑显示名"
|
||||
aria-label="取消编辑昵称"
|
||||
onClick={() => {
|
||||
setDisplayNameDraft(displayName);
|
||||
setDisplayNameError(null);
|
||||
@@ -1172,8 +1172,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
title="编辑显示名"
|
||||
aria-label="编辑显示名"
|
||||
title="编辑昵称"
|
||||
aria-label="编辑昵称"
|
||||
onClick={() => {
|
||||
setDisplayNameDraft(displayName);
|
||||
setDisplayNameError(null);
|
||||
|
||||
@@ -403,6 +403,25 @@ h3 {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.room-code-entry.is-inline {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-column: 3;
|
||||
grid-template-columns: minmax(0, 1fr) 44px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.room-code-entry.is-inline .access-error {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.room-code-submit {
|
||||
width: 44px;
|
||||
min-height: 44px;
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.capture-strip {
|
||||
display: flex;
|
||||
min-height: 34px;
|
||||
@@ -1020,6 +1039,16 @@ h3 {
|
||||
grid-template-columns: auto minmax(0, 1fr) 40px 40px;
|
||||
}
|
||||
|
||||
.viewer-name-control:not(.is-editing) {
|
||||
display: inline-flex;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.viewer-name-control:not(.is-editing) .viewer-name-value {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.viewer-name-control label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
@@ -1198,6 +1227,10 @@ h3 {
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.room-code-entry.is-inline {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.viewer-title-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
readViewerGrant,
|
||||
readViewerRoute,
|
||||
replaceViewerInvite,
|
||||
roomRouteFromInput,
|
||||
writeHostRoom,
|
||||
} from "../src/client/lib/session.ts";
|
||||
import {
|
||||
@@ -414,7 +415,7 @@ describe("site access API", () => {
|
||||
|
||||
await expect(authenticateSiteAccess("wrong-password")).rejects.toMatchObject({
|
||||
status: 401,
|
||||
message: "访问密码不正确,请重试",
|
||||
message: "站点口令不正确,请重试",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -468,6 +469,8 @@ describe("room codes", () => {
|
||||
expect(isValidRoomId("012345")).toBe(false);
|
||||
expect(isValidRoomId("1234567890123")).toBe(false);
|
||||
expect(isValidRoomId("12345678901a")).toBe(false);
|
||||
expect(roomRouteFromInput(" 42 ")).toBe("/r/42");
|
||||
expect(roomRouteFromInput("042")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user