feat(meeting): Task 15 UI 打磨 + 主持人四件套 + 媒体层稳定性回归修复

- Task 15 UI:6 项原创特色(说话者流光 / 柔性网格 / 自视频浮窗 / 静音氛围色 / 入会滑入 / NetworkBadge 3 条波浪)+ 说话者双源探测(RTP audioLevel + WebAudio RMS)+ 主持人四件套(静音/开麦/转让/踢出)
- 新增 SelfVideoFloat 浮窗组件 + 4 份 design-system 页面文档(home / preview / room / invite)
- 媒体回归补丁(手工联调触发):
  * 后端 signal_service 在 OnRoomJoin 追加 pushExistingRoomState → 向新加入者补发历史 producers + 历史成员 audio/video 状态
  * 新增 Redis Hash memberStateKey 持久化成员 audio/video enabled,OnMemberStateChanged 落盘、cleanupUserResources 清理
  * 前端 _broadcastSelfState 在本地音视频开关末尾同步 state.changed;_afterJoined 先 getRoom 再 room.join 修复后入者成员状态时序;_onMemberStateChanged 占位兜底
  * _cleanupRemoteProducer 改为按 producerId 精准清理(修复 "关音频误关视频")
  * _onRoomEnded + createAndEnter/joinAndEnter 强化 reset(修复重入会看不到自己画面)
  * mediasoup-client ensureSend/RecvTransport 引入 in-flight Promise 锁防并发重复建连
- 文档同步:CURRENT_STATUS.md / Task 15 plan / phase2e-2 implementation plan

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-04-23 16:04:46 +08:00
parent 46b37b8b4c
commit 790996feaf
26 changed files with 2043 additions and 151 deletions

View File

@@ -1,11 +1,11 @@
# Phase 2e-2 实施计划:会议 MVP多人音视频
> **状态:** 🚧 代码开发中Task 0-14 ✅ / Task 15-16 待执行)
> **状态:** 🚧 代码开发中Task 0-15 ✅ / Task 16 待执行)
> **设计文档:** [Phase 2e-2 设计文档](./2026-04-21-phase2e-2-design.md)
> **上级路线图:** [Phase 2e 整体路线图](./2026-04-20-phase2e-design.md)
> **分支:** `feature/phase2e-2-meeting-mvp`
> **预估总工时:** **约 17 人日**17 个 Task含 PoC 与 UI 打磨)
> **最后更新:** 2026-04-24Task 14docker-compose 双态扩展完成:新增 media-server + coturnpublic profile容器编排、三份 .env 模板、scripts/start|stop|status.sh 支持 full 子命令、scripts/deploy-public.sh 公网部署校验脚本、docs/deployment/meeting-mvp.md 双态部署指南。下一步 Task 15 UI 打磨
> **最后更新:** 2026-04-23Task 15UI 打磨 + 主持人权限四件套完成6 项原创 UI 特色落地 / 说话者探测双源RTP stats + WebAudio / `SelfVideoFloat` 桌面恒浮窗 + 图钉切换 / `isAllMuted` 静音氛围色 / `NetworkBadge` 3 条波浪 / 主持人"请他静音/开麦/转让/踢出"四件套 / 4 屏 design-system 文档 / Playwright MCP 7 屏截图回归通过。下一步 Task 16 E2E + 文档同步
### 进度看板
@@ -26,7 +26,7 @@
| **Task 12** | **会议内聊天面板** | ✅ | `ChatPanel.vue` + WS `meeting.chat.new` + user_name/avatar 补齐 |
| **Task 13** | **meeting_invite 通知对接** | ✅ | extra 补齐 inviter_* / expired_at前端"立即加入/稍后"+ 过期态 + deep-link |
| **Task 14** | **docker-compose + 双态** | ✅ | `media-server` + `coturn(public)` 编排 + 3 份 .env 模板 + `deploy-public.sh` + `docs/deployment/meeting-mvp.md` |
| Task 15 | 观测性与告警 | | |
| **Task 15** | **`ui-ux-pro-max` UI 打磨 + 主持人权限四件套** | | 6 项原创 UI流光/柔性网格/浮窗/氛围色/滑入/波浪)+ 双源说话者探测 + 四件套 + 4 份设计文档 + Playwright MCP 7 屏回归 |
| Task 16 | E2E + 文档同步 | ⏳ | |
### 2026-04-22 UI 打磨专项Task 10-12 之后的体验收敛)
@@ -542,28 +542,35 @@ flowchart LR
- 真实 `compose build media-server` mediasoup arm64 编译耗时 > 15 min 未最终完成镜像落盘,不影响 Task 14 核心目标(配置 + 脚本 + 文档),公网服务器标准 x86 环境下属正常时间范围。
- **工作量****0.5 人日(实际约 0.4 人日)**
### Task 15`ui-ux-pro-max` 定制 UI 打磨
### Task 15`ui-ux-pro-max` 定制 UI 打磨 + 主持人权限四件套 ✅
- **目标**:调用技能包产出 4 屏 EchoChat 原创风格,落地到代码
- **目标**:调用技能包产出 4 屏 EchoChat 原创风格,落地 6 项原创 UI + 主持人四件套
- **依赖**T11
- **主要产出**
- 调用`npx openskills read ui-ux-pro-max` + 4 屏 brief设计 §9.1
- 设计落地
- `design-system/echochat/pages/meeting-home.md`
- `design-system/echochat/pages/meeting-preview.md`
- `design-system/echochat/pages/meeting-room.md`
- `design-system/echochat/pages/meeting-invite.md`
- 代码落地
- `VideoTile.vue` 说话者流光轮廓动效
- 柔性网格2/3 人布局的非等分样式
- 自视频浮窗四角吸附(`draggable` + 吸附计算)
- 静音氛围色(工具栏底色绑定 computed `allMuted`
- `NetworkBadge.vue` 3 档波浪动效
- **检查点**
- 视觉走查4 屏与设计产物一致,色彩 / 留白 / 动效符合飞书简洁 + EchoChat 原创要求
- 动效性能:不影响 60fpsChrome Performance 面板确认)
- 代码审查:`ui-ux-pro-max` 产物被真正使用,不留下"代码 vs 设计产物"脱节
- **工作量****2 人日**
- **实际产出**(相对设计预期"满配",无裁剪)
- 计划`docs/plans/2026-04-24-phase2e-2-task15-ui-polish.plan.md`(设计规范 + 10 子任务 + 验收点 + 风险
- 4 屏 design-system 文档
- `design-system/echochat/pages/meeting-home.md`
- `design-system/echochat/pages/meeting-preview.md`
- `design-system/echochat/pages/meeting-room.md` ✅(核心,含 Z-index / CSS / 动效 / 降级完整规格)
- `design-system/echochat/pages/meeting-invite.md`
- 6 项原创 UI 特色
- ✅ 说话者流光轮廓:`VideoTile.vue` + `@property --flow-angle` + `conic-gradient` + Safari 降级
- ✅ 说话者探测双源:`store/meeting.js` + `RTCRtpReceiver.audioLevel`(远端)+ WebAudio RMS本地+ 500ms 轮询 + in/out 防抖
- ✅ 柔性网格:`VideoGrid.vue` + `layout-2-flex`65/35 / `layout-3-tri`(左大右双小) / `layout-grid-3` / `sqrt`
- ✅ 自视频浮窗:`SelfVideoFloat.vue` 新增 + 桌面 280×180 恒浮窗 / 移动 160×100 + 拖拽 + 四角吸附 + 图钉切换
- ✅ 静音氛围色:`MeetingToolbar.vue` + `isAllMuted` computed≥2 人全员静音触发蓝紫渐变)
- ✅ 入会滑入:`VideoGrid.vue` `@keyframes tile-slide-in` + 新 tile 挂载时一次性 360ms
- ✅ NetworkBadge 3 条波浪:`NetworkBadge.vue` 重写 + 3 SVG `<path>` + `@keyframes wave-flow` 相位错开
- 主持人四件套:
- ✅ 请他静音 / 开麦(条件渲染):`MemberPanel.vue` + `store.muteMember` + WS `meeting.member.state.changed`
- ✅ 被静音侧体验:`_onMemberStateChanged` 检测 `changed_by !== myUid``stopLocalAudio` + `uni.showToast`
- ✅ 转让主持人 / 踢出会议(既有 Task 13 实现,菜单统一重构)
- **验收记录**
- `ReadLints`:零错误
- `npm run build:h5`:通过
- Playwright MCP 7 屏截图回归:`.playwright-mcp/task15/{01..07}-*.png` + `README.md`
- 01 首页 / 02 设备预览 / 03 空场浮窗+波浪 / 04 单人静音 / 05 **流光+柔性3-tri+氛围色**(核心)/ 06 图钉切回 4 人网格 / 07 主持人菜单条件渲染
- **工作量****2 人日(实际 ~1.5 人日)**
### Task 16E2E 验证 + Playwright MCP 回归 + 代码审查 + 文档同步

View File

@@ -0,0 +1,171 @@
# Phase 2e-2 Task 15UI 打磨 + 主持人权限四件套补齐
> **状态:** 📋 设计阶段(等待用户 Review
> **上级实施计划:** [docs/plans/2026-04-21-phase2e-2-implementation.plan.md §Task 15](./2026-04-21-phase2e-2-implementation.plan.md)
> **设计依据:** [Phase 2e-2 设计文档 §9.1 / §9.2 / §3.3 D08](./2026-04-21-phase2e-2-design.md)
> **分支:** `feature/phase2e-2-meeting-mvp`
> **预估工时:** 1.5-2 人日
> **范围锁定:** 满配6 条原创特色 + 静音他人 + 4 屏设计文档 + 桌面端恒浮窗 + RTP stats 双源说话者探测)
---
## 一、目标
把设计文档 §9.2 定义的 EchoChat 会议原创特色 **6 条** 全部落地到代码,补齐主持人权限四件套的最后一件(静音他人),同时把会议模块 4 个核心屏幕的「设计 SSOT」写进 `design-system/echochat/pages/meeting-*.md`,让设计产物与代码实现可持续对齐。
不在本期做:
- 不做分布式 SFU / simulcast 档位切换
- 不做 `meeting.room.update`(会议主题 / 入会静音开关在线修改)
- 不做加入时视频块从网格外滑入(候选 §9.2 第 5 条,已被确认归入本期但作为低优先级,若工时紧可削减)
---
## 二、改动清单
### 2.1 新建文件5
| 路径 | 作用 |
|---|---|
| `design-system/echochat/pages/meeting-home.md` | 会议 Hub 首页(即时会议入口 + 加入会议)视觉规范 |
| `design-system/echochat/pages/meeting-preview.md` | 设备预览页(大预览 + 右侧设备列表 + 音量波浪)视觉规范 |
| `design-system/echochat/pages/meeting-room.md` | 会议室主页面(浮窗 / 流光 / 静音氛围色 / 柔性网格 / 波浪网络徽标)视觉规范 |
| `design-system/echochat/pages/meeting-invite.md` | 邀请弹窗(复制链接 / 会议号 / 联系人选择器)视觉规范 |
| `frontend/src/components/meeting/SelfVideoFloat.vue` | 自视频浮窗(桌面端恒浮窗 + 四角吸附拖拽 + 图钉切换回网格)|
### 2.2 改动文件7
| 路径 | 改动点 |
|---|---|
| `frontend/src/components/meeting/VideoTile.vue` | 流光轮廓 CSS `conic-gradient` + `@keyframes` 动效;`speaking` class 由上层喂入 |
| `frontend/src/components/meeting/VideoGrid.vue` | 2 人左右 65/35 非等分3 人大/小/小三角布局4+ 人维持等分;剔除本地 tile由浮窗承接|
| `frontend/src/components/meeting/MeetingToolbar.vue` | 新增 `:all-muted` prop背景色绑定冷蓝渐变`all-muted=true` 时)|
| `frontend/src/components/meeting/MemberPanel.vue` | 操作菜单新增「请他静音 / 请他开麦」(仅 host + 非自己 + 当前音频开/关)|
| `frontend/src/components/meeting/NetworkBadge.vue` | 4 条竖柱 → 3 条 SVG 波浪线 + 相位差动画;`level` prop 语义保留 |
| `frontend/src/store/meeting.js` | 新增 `muteMember(uid, mute)` action + `speakingMap` state新增 `_startSpeakingDetection` / `_stopSpeakingDetection` 内部方法;`isAllMuted` getter |
| `frontend/src/pages/meeting/room.vue` | 组装 `SelfVideoFloat` + `all-muted` 绑定 + speaking 下发到 tile离会弹窗未改 |
**动效性能目标:** 所有 `@keyframes` 限制在 `transform` / `opacity` / `border-image` / `background` 属性上,不触发 layoutChrome Performance 面板滚动会议室 10 秒,保持 60fps、无 long task > 50ms。
---
## 三、关键技术点
### 3.1 说话者流光轮廓
- CSS 实现:`border: 2rpx solid transparent` + `background-image: conic-gradient(from ...)` 切换为 `linear-gradient(#1e293b,#1e293b) padding-box, conic-gradient(...) border-box``@keyframes flow` 旋转 `--flow-angle``@property --flow-angle` 声明,不触发 layout
- 触发:`VideoTile.vue` 接收 `isSpeaking` prop上层 `room.vue` 通过 `store.speakingMap[userId]` 投影。
- 兼容Safari 不支持 `@property` 时 fallback 为静态蓝色环形边(现有 `.speaking` 样式)。
### 3.2 说话者探测RTP stats + WebAudio 双源)
- **远端**`store._startSpeakingDetection()` 每 500ms 遍历 `remoteConsumers[uid].audio.rtpReceiver`,调用 `getSynchronizationSources()`(标准 W3C APIChrome/Firefox/Edge 支持),读 `audioLevel`0-1 线性);阈值 0.05 以上且持续 ≥ 1 个 tick 判定为 `speaking=true`< 阈值连续 2 tick `speaking=false`防抖)。
- **本地**`localProducers.audio` 拿不到 `rtpReceiver`因为它是 sender)。本地用 `AudioContext` + `MediaStreamAudioSourceNode` + `AnalyserNode.getFloatTimeDomainData` RMS音量门限归一化到同一 0-1 区间
- **资源释放**`leave()` / `stopLocalAudio()` / `_clearRemoteConsumer()` 必须调 `_stopSpeakingDetection()`轮询用 `setInterval` + Pinia 内持有 handle避免泄漏
- **H5 Only**整段逻辑包在 `#ifdef H5` H5 平台 `speakingMap` 空对象UI 兜底为 never speaking
### 3.3 柔性网格
- `VideoGrid.vue` `tiles.length` 选择 layout class
- `layout-1`全屏单块
- `layout-2`左右 65/35横向 flex说话者自动成为大块Task 16 本期先固定"首个 tile 为大块"
- `layout-3`左上大块 65%右侧两个小块各 50% 高度叠放
- `layout-4+`继续 `repeat(n, 1fr)` 等分
- 小屏`<= 750px`统一回退到纵向等分现有逻辑保留
- 本地 tile `SelfVideoFloat` 渲染`VideoGrid` 只展示 `tiles.filter(t => !t.isLocal)`N-1 个远端)。所以实际 layout 判断基于 `tiles.length - 1`
### 3.4 自视频浮窗SelfVideoFloat.vue
- 尺寸默认 `240rpx × 150rpx`16:10桌面端 `280×180px`圆角 `16rpx` + 阴影 `0 12rpx 32rpx rgba(0,0,0,0.45)`
- 位置默认右下角距视频区边缘 `24rpx`
- 拖拽`@mousedown` + `@touchstart` 注册 `mousemove/touchmove` 全局监听释放时计算 4 角距离`transform: translate(x,y)` `transition: 0.22s cubic-bezier(0.2,0.8,0.2,1)` 吸附
- 图钉按钮右上角 pin 图标点击切换 `floatMode` `gridMode`gridMode 时浮窗隐藏本地 tile 回到 Grid
- 持久化`floatMode` 存入 `meetingStore.uiPrefs`Pinia 内存不落盘下次进会议默认浮窗
- Z-index浮窗 120低于 toolbar(210) 低于 MemberPanel(200)高于 VideoGrid
### 3.5 静音氛围色
- `room.vue` 新增 `allMuted = computed(() => tiles.length >= 2 && tiles.every(t => !t.audioEnabled))`
- `MeetingToolbar` 接收 `:all-muted``style: background` 绑定
- 默认`rgba(17, 24, 39, 0.88)`保持现有
- 全员静音`linear-gradient(to top, rgba(30, 58, 138, 0.88), rgba(30, 64, 175, 0.78))`冷蓝
- 过渡`transition: background 0.4s ease`
- 单人情况只有自己不触发避免独自加入就冷色
### 3.6 NetworkBadge 3 条波浪
- 改为 3 SVG `<path d="M 0 8 C 2 4, 4 12, 6 8 S 10 4, 12 8 S 16 12, 18 8" />` 波浪曲线
- 动画`@keyframes wave { 0% { transform: translateX(0) } 100% { transform: translateX(-6px) } }`3 条相位差 0 / -0.3s / -0.6s
- level 可见条数4=3条 / 3=2条 / 2=1条 / 1=1条短 / 0=静态红色中划线
- 颜色映射保持`#10B981` / `#22C55E` / `#F59E0B` / `#EF4444`
### 3.7 静音他人(主持人权限第 4 件)
- 后端能力已就绪`meeting.member.state.changed` WS 事件带 `target_user_id` 参数host 身份校验在 `MeetingSignalService.OnMemberStateChanged` 已实现
- 前端 store
```js
async muteMember(uid, mute = true) {
if (!this.isHost) throw new Error('仅主持人可操作')
const payload = { target_user_id: uid, audio_enabled: !mute }
await wsService.sendWithAck('meeting.member.state.changed', payload, 3000)
}
```
- `MemberPanel` 操作菜单:
- host 视角下每行尾部三点菜单新增 2 项:
- 若 `p.audio_enabled === true`:「请他静音」(灰色,非 danger
- 若 `p.audio_enabled === false`:「请他开麦」(灰色)
- 现有「转让主持人 / 踢出会议」保留
- 被静音侧 UX后端 `meeting.member.state.changed` 广播回来时store `_onMemberStateChanged` 已识别 `target_user_id === 我自己`,直接调 `stopLocalAudio()` 停本地 producer本期补一个 toast 提示「主持人请你静音」
---
## 四、任务拆分
| # | 任务 | 工时 | 产出 |
|---|---|---|---|
| T15.1 | 4 屏 design-system 文档 | 0.3 人日 | `design-system/echochat/pages/meeting-{home,preview,room,invite}.md` |
| T15.2 | VideoTile 流光轮廓 + CSS @property fallback | 0.15 人日 | VideoTile.vue 改 |
| T15.3 | 说话者探测双源store + 资源释放 + 防抖) | 0.3 人日 | `store/meeting.js` + VideoTile 接线 |
| T15.4 | VideoGrid 柔性网格 2/3 人布局 | 0.15 人日 | VideoGrid.vue 改 |
| T15.5 | SelfVideoFloat.vue 浮窗 + 拖拽 + 吸附 + 图钉 | 0.3 人日 | 新文件 + room.vue 接入 |
| T15.6 | MeetingToolbar 静音氛围色 + allMuted 绑定 | 0.1 人日 | Toolbar + room.vue |
| T15.7 | NetworkBadge 3 条波浪 SVG + level 映射 | 0.15 人日 | NetworkBadge.vue 重写 |
| T15.8 | 静音他人store action + MemberPanel 菜单 + 被静音 toast | 0.2 人日 | store / MemberPanel / room.vue |
| T15.9 | 本机 Playwright 截图回归4 屏 + 流光 + 浮窗吸附 + 静音氛围) | 0.15 人日 | 截图归档 |
| T15.10 | 进度文档同步 + commit + push | 0.1 人日 | CURRENT_STATUS / implementation.plan / project-context |
| **合计** | | **1.9 人日** | |
---
## 五、检查点(对齐设计文档 §9 + 实施计划 §Task 15 验收)
- [ ] 4 屏 `design-system/echochat/pages/meeting-*.md` 全部落盘,包含 Layout / Color / Typography / Motion 四栏
- [ ] 说话者流光轮廓在双人会议中稳定触发,阈值 `audioLevel >= 0.05`,防抖 1 in / 2 out
- [ ] 2 人 65/35 / 3 人三角布局视觉与设计一致4+ 人保持等分
- [ ] 自视频浮窗拖拽松手后 0.22s 内吸附到最近四角;桌面端 `Cmd+Drag` 不触发系统 drag
- [ ] 图钉按钮可切换 float ↔ gridgrid 模式本地 tile 回到网格里
- [ ] 全员静音时 Toolbar 冷蓝渐变,恢复说话立即回暖;单人独处不触发
- [ ] NetworkBadge 3 条波浪流动level 4/3/2/1/0 对应可见条数 3/2/1/1(短)/0
- [ ] 主持人菜单显示「请他静音 / 请他开麦」,非主持人不可见;被请对方收到 toast 并本地静音
- [ ] Chrome Performance 面板会议室滚动 10 秒 60fps无 long task > 50ms
- [ ] `npm run build:h5` 无新增警告;`ReadLints` 零错误
- [ ] Playwright 截图 4 张归档到 `.playwright-mcp/task15/`home / preview / room / invite
---
## 六、风险
| 风险 | 严重度 | 缓解 |
|---|---|---|
| `@property --flow-angle` Safari 17- 不支持 | 低 | CSS `@supports` + fallback 静态蓝环 |
| `getSynchronizationSources()` 在 Firefox 返回空数组 | 中 | `if (sources.length === 0)` 回退到 WebAudio |
| 自视频浮窗 `mousemove` 全局监听内存泄漏 | 中 | `useEventListener` 模式 + `onBeforeUnmount` 显式 removeListener |
| 静音氛围色在"全员非 host 静音但 host 未静音"时也触发,可能误伤 | 低 | 条件用 `tiles.length >= 2 && tiles.every(t => !t.audioEnabled)`host 未静音时不成立 |
| 被主持人静音的用户以为"自己麦克风故障" | 中 | 收到静音指令后显示 `uni.showToast({ title: '主持人请你静音' })` 持续 2s |
---
## 七、变更记录
| 日期 | 变更 |
|---|---|
| 2026-04-24 | 初稿 |

View File

@@ -1,7 +1,7 @@
# EchoChat 项目开发进度
> **最后更新**2026-04-24Phase 2e-2 Task 14 docker-compose 双态扩展完成media-server + coturn 容器编排 + 三份 .env 模板 + deploy-public.sh + docs/deployment/meeting-mvp.md 双态部署指南
> **当前阶段**Phase 2e-2 会议 MVP **代码开发阶段** 🚧Task 0-14 ✅ / Task 15-16 待执行)
> **最后更新**2026-04-23Phase 2e-2 Task 15 UI 打磨 + 主持人权限四件套完成 + 媒体层稳定性补丁6 项原创 UI 特色 + 说话者双源探测 + 主持人四件套 + 4 份 design-system 页面文档 + Playwright MCP 7 屏回归全通过;加挂 5 项 Mediasoup / 信令层回归修复
> **当前阶段**Phase 2e-2 会议 MVP **代码开发阶段** 🚧Task 0-15 ✅ / Task 16 待执行)
> **当前分支**`feature/phase2e-2-meeting-mvp`(从 `feature/phase2c-group-read-receipt` 衍生)
> **Phase 2e 整体设计**`docs/plans/2026-04-20-phase2e-design.md`(三子阶段路线图 + 后续规划清单)
> **Phase 2e-1 专用设计**`docs/plans/2026-04-20-phase2e-1-design.md`(✅ 已完成)
@@ -12,6 +12,66 @@
---
## 🎨 2026-04-23 Phase 2e-2 Task 15UI 打磨 + 主持人权限四件套完成
**交付**EchoChat 会议室页面完成从 MVP 功能 UI 到"原创视觉语言"的升级。落地 6 项原创 UI 特色(说话者流光轮廓 / 柔性网格 / 自视频浮窗 / 静音氛围色 / 入会滑入 / NetworkBadge 3 条波浪),配套 4 屏 design-system 页面文档;说话者探测采用**双源方案**(远端 `RTCRtpReceiver.getSynchronizationSources().audioLevel` + 本地 WebAudio RMS+ 500ms 防抖;主持人权限"静音他人 / 开麦 / 转让 / 踢出"四件套前端菜单按条件渲染接入既有 WS 事件。Playwright MCP 执行 7 屏截图回归全部通过,归档至 `.playwright-mcp/task15/`
### 产出文件
| 文件 | 类型 | 作用 |
|---|---|---|
| `docs/plans/2026-04-24-phase2e-2-task15-ui-polish.plan.md`(新建) | 计划 | Task 15 设计规范:目标 / 改动清单 / 关键技术点 / 10 个子任务 / 验收点 / 风险 |
| `design-system/echochat/pages/meeting-home.md`(新建) | 设计文档 | 会议 Hub 首页:深色背景渐变 / 创建&加入双卡片 / 最近会议列表 / CTA 呼吸 / 滑入过渡 |
| `design-system/echochat/pages/meeting-preview.md`(新建) | 设计文档 | 设备预览页:大视频预览 / 设备列表 / "gradient wave bar" 麦克风音量 / 切换动效 |
| `design-system/echochat/pages/meeting-room.md`(新建) | 设计文档 | 会议室核心页最重要一份Z-index 分层 / 6 项原创特色完整规格CSS / 动效 / 交互 / 降级) |
| `design-system/echochat/pages/meeting-invite.md`(新建) | 设计文档 | 邀请模态/底部弹层:链接/会议号/联系人三入口 + toast 反馈 + 桌面/移动端差异 |
| `frontend/src/components/meeting/VideoTile.vue`(改) | 组件 | ① 新增 `isSpeaking` prop ② CSS `@property --flow-angle` + `conic-gradient` 流光轮廓动效 ③ Safari 17- `@supports` 降级为静态彩色边框 |
| `frontend/src/components/meeting/VideoGrid.vue`(改) | 组件 | ① 新增 `layout-2-flex`65/35 非等分)/ `layout-3-tri`(左大右双小)/ `layout-grid-3` / `layout-grid-sqrt``@keyframes tile-slide-in` 入会滑入 ③ `@media (max-width: 750px)` 回退等分 ④ `prefers-reduced-motion` 兼容 |
| `frontend/src/components/meeting/SelfVideoFloat.vue`(新建) | 组件 | 桌面恒为浮窗280×180移动 160×100鼠标/触摸拖拽;四角 `snapToCorner`;图钉按钮 emit `pin-click``ResizeObserver` 响应容器尺寸;`z-index: 120` |
| `frontend/src/components/meeting/MeetingToolbar.vue`(改) | 组件 | 新增 `allMuted` prop → 绑定 `toolbar-all-muted` 类(蓝紫渐变 + `transition: background 0.4s` |
| `frontend/src/components/meeting/NetworkBadge.vue`(重写) | 组件 | 3 条 SVG `<path>` 波浪线替代原 4 格信号;`@keyframes wave-flow` 相位错开 `0 / -0.3s / -0.6s`level≤0 静态图标 + 红点 |
| `frontend/src/components/meeting/MemberPanel.vue`(改) | 组件 | 主持人操作菜单新增"请他静音 / 请他开麦"条件切换(依据 `audio_enabled`emit `mute-member` 事件 |
| `frontend/src/store/meeting.js`(改) | Pinia | ① `speakingMap` reactive ② `uiPrefs.selfVideoFloat``isAllMuted` computed≥2 人 + 全员 audio 关) ④ `_speakingTick`500ms 轮询 + in=1/out=2 次防抖)⑤ `_readRemoteAudioLevel`RTP stats+ `_readLocalAudioLevel`WebAudio `AnalyserNode` + RMS`muteMember(target, mute)` action 调 WS `meeting.member.state.changed` + `target_user_id``_onMemberStateChanged` 检测 `changed_by !== myUid` 被动触发 `stopLocalAudio` + `uni.showToast` |
| `frontend/src/pages/meeting/room.vue`(改) | 页面 | 引入 `SelfVideoFloat``selfTile`/`gridTiles``uiPrefs.selfVideoFloat` 分流;`togglePin` 切换;`onMuteMember` 调 store 并 toast |
| `.playwright-mcp/task15/README.md`(新建) | 回归报告 | 7 张截图对应的 UI 特性矩阵 + 测试账号 / 测试方法说明 |
| `.playwright-mcp/task15/{01..07}-*.png`新建7 张) | 截图 | 01 首页 / 02 设备预览 / 03 空场浮窗+波浪 / 04 单人静音 / 05 流光+柔性3-tri+氛围色 / 06 图钉切回4人网格 / 07 主持人四件套菜单 |
### 关键技术点
1. **说话者流光轮廓的 `@property` 方案**CSS `@property --flow-angle` 注册一个可动画的 `<angle>` 自定义属性,配合 `conic-gradient(from var(--flow-angle), ...)` + `@keyframes flow-rotate` 让整圈颜色 360° 流动;`@supports not (background: paint(angle-conic))` 分支回退到纯色 3px 边框,保障 Safari 17 及以下浏览器不丢布局。
2. **说话者探测双源策略**:远端用 W3C 标准 `RTCRtpReceiver.getSynchronizationSources()[0].audioLevel`0~1零额外开销本地 track 用 WebAudio `AnalyserNode.getFloatTimeDomainData` 计算 RMS因为本地 `RTCRtpSender` 没有标准 `audioLevel` 接口。两源共用 500ms 轮询 + 防抖阈值(`>0.03` 连续 1 次置 true`<0.015` 连续 2 次置 false彻底抑制抖动。
3. **`isAllMuted` 计算逻辑——避免单人场景"自己静音也变蓝"误触发**`activeParticipants.length >= 2` 是前置条件;本人以 `localAudioEnabled` 为准(后端不会给自己广播 `state.changed`),其他人以 `p.audio_enabled === false` 判定。这保证"独自会议自己静音"不被认为是"全员静音氛围"。
4. **SelfVideoFloat 桌面恒浮窗 + 图钉切换的取舍**:用户明确"桌面端默认浮窗 + 允许切回网格",移动端维持 160×100 小浮窗即可。`togglePin` 仅翻 `uiPrefs.selfVideoFloat` 布尔,`room.vue``selfTile`/`gridTiles` 计算自动分流;无需额外挪 DOM。
5. **主持人 mute 的"静默执行 + toast"交互**:用户决策——主持人点"请他静音"后不弹二次确认,直接 WS 推 `meeting.member.state.changed`(带 `target_user_id` + `audio_enabled: false`);后端广播到对方后,对方 `_onMemberStateChanged` 识别 `changed_by !== myUid` → 立即 `stopLocalAudio()` + `uni.showToast('你已被主持人静音')`;主动侧不弹 toast成员面板菜单项自己会变成"请他开麦")。
6. **入会滑入动效只针对"新增 tile"**`@keyframes tile-slide-in` 绑定 `.video-tile`,但通过 `:nth-child(n+N)` 难以稳定匹配"新来者",所以直接让所有 tile 在挂载时各跑一次(`animation-duration: 360ms` 短时间一次性),搭配 Vue 的 `key=user_id` 保证只在真正新增时才触发。`prefers-reduced-motion` 下整体禁用。
7. **NetworkBadge 3 条波浪的动效节奏**3 条 SVG 路径用同一 `<path>` 偏移 Y 轴 `3px/0/-3px``animation-delay` 分别 `0/-0.3s/-0.6s` 造就持续流动感;`level≤0` 时渲染一个带红点的静态"断开"图标,避免继续动画误导用户"有网络"。
### Playwright MCP 回归记录
- 环境:`go-service :8085` + `media-server :3300` + `frontend :5173`(真实三件套)+ Chromium 1440×900 桌面视口。
- 流程:`task15_a` 真实登录 → 创建"Alice的会议"(房号 `468-996-302`)→ 加入 → `task15_b` 走 REST API 成为真实参会人 → `browser_evaluate` 注入 2 个 mock participant9001/9002+ `speakingMap[9001]=true` + 三位远端 `audio_enabled=false` → 拍摄 3-tri 网格 + 流光 + 氛围色;点击"图钉"切回 4 人 2×2 网格;打开成员面板 → 点击用户 56 菜单 → 验证"请他开麦 / 转让主持人 / 踢出会议"条件渲染。
- 7 张截图全部符合 design-system/echochat/pages/meeting-room.md 规格,归档于 `.playwright-mcp/task15/`
### 2026-04-23 补丁:媒体层稳定性回归修复(手工联调触发)
在 Task 15 完成后bojinyuan/duanlingyun 双端 HTTPS 手工联调暴露出 5 个媒体/信令层缺陷,全部修复并经由用户二次验证通过,现作为 Task 15 的收尾补丁并入本章节:
| # | 问题表现 | 根因 | 修复点 |
|---|---------|------|--------|
| 1 | 后入会方看不到先入会方的音视频画面(需对方手动切一下按钮才能看到) | SFU 侧对"新加入者"没有补发房间内历史 producers | `backend/go-service/app/meeting/service/meeting_signal_service.go``OnRoomJoin` 异步调用 `pushExistingRoomState` → 遍历活跃参会者 Redis 上的 producer_id向新人定向下发 `meeting.member.producer.new` |
| 2 | 成员面板里对方的音视频图标一直灰色,只有对方手动切换按钮图标才会变色 | 前端本地切换音视频后未向服务器同步 `meeting.member.state.changed`;后端也没把房间内历史成员的 audio/video 状态补给新加入者 | ① 前端 `store/meeting.js` 新增 `_broadcastSelfState``startLocalAudio/stopLocalAudio/startLocalVideo/stopLocalVideo` 末尾广播自身状态 ② 后端新增 `memberStateKey` Redis Hash`echo:meeting:member_state:{room_code}:{uid}`)持久化 audio/video enabled ③ `OnMemberStateChanged` 收到广播后调用 `updateMemberState` 落 Redis ④ `OnRoomJoin` 追加 `pushExistingMemberStates` 把房间内他人当前状态定向下发给新人 ⑤ `cleanupUserResources` 清理对应 Hash |
| 3 | 一方关闭音频,另一方连画面都看不到了 | `_cleanupRemoteProducer` 写得过激,任一 producer 关闭就把该用户所有 consumers 全部关掉 | `frontend/src/store/meeting.js`:改为基于 `consumer.producerId` 精准匹配只关闭对应那一路audio/video 独立清理) |
| 4 | 结束会议后再次发起会议,自己看不到自己的本地画面 | `_onRoomEnded` 未清 `localProducers` / `localAudio/VideoEnabled`,下一次 `createAndEnter` 遇到历史 producer id 命中 early-return跳过真正的 producer 创建 | 强化 `_onRoomEnded`:显式清 `localProducers.audio/video``localAudio/VideoEnabled = false``remoteConsumers`;并在 `createAndEnter` / `joinAndEnter` 入口各加 `_reset()` 做兜底清理 |
| 5 | 后入会方成员面板仍显示对方音视频图标为灰(比 #2 更隐蔽的时序问题) | `_afterJoined``wsService.sendWithAck(room.join)` 早于 `meetingApi.getRoom`,后端 `pushExistingMemberStates` 下发的 `state.changed` 到达时 `participants` 还为空,事件被默默丢弃 | ① `_afterJoined` 调整顺序:先 `meetingApi.getRoom` 填充参会者,再 `sendWithAck(room.join)` 触发后端补推 ② `_onMemberStateChanged` 找不到 participant 时创建占位记录,等 `member.joined` / `getRoom` 后续补齐用户名头像 |
另外为了避免并发 `producer.new` 事件下频繁创建冗余 transport`frontend/src/utils/mediasoup-client.js``ensureSendTransport` / `ensureRecvTransport` 引入 in-flight Promise 锁(首次调用走 `_createSendTransport/_createRecvTransport`,并发调用共享同一 Promise
### 下一步
- **Task 16 E2E 总回归 + 文档同步**1 人日):`code-reviewer` 审计全栈go-service meeting + media-server + frontend meeting + 最近补丁)→ 修 P0/P1 → Playwright MCP 脚本化 4 场景(创建入会 / 邀请加入 / 主持人四件套全链路 / 主持人结束 + 自动转让)+ 手动回归点说明 → 清理 console/TODO/FIXME 残留 → 同步 6 份文档 + 落盘 `test-report-phase2e-2-meeting.md` → Phase 2e-2 整体切 ✅。
---
## 🚀 2026-04-24 Phase 2e-2 Task 14docker-compose 扩展 + 环境变量双态开关完成
**交付**EchoChat 会议 MVP 正式具备**本机 Demo / 公网部署**双态一键化能力。`deploy/docker-compose.dev.yml` 新增 `media-server` + `coturn``profiles: ["public"]`两个服务全部基础设施postgres / redis / minio / go-service / media-server / coturn通过环境变量注入参数配三份分角色的 `.env.*.example` 模板;`scripts/start|stop|status.sh` 扩展 `full` 子命令用 docker compose 跑完整栈;新建 `scripts/deploy-public.sh` 做公网部署前的**环境变量 + 端口 + Docker 自检** → `--profile public up -d --build` → 健康检查闭环;新建 `docs/deployment/meeting-mvp.md` 双态部署指南。