diff --git a/backend/go-service/app/meeting/controller/meeting_controller.go b/backend/go-service/app/meeting/controller/meeting_controller.go index 7abf18d..b2d4887 100644 --- a/backend/go-service/app/meeting/controller/meeting_controller.go +++ b/backend/go-service/app/meeting/controller/meeting_controller.go @@ -105,7 +105,8 @@ func roomToDTO(r *model.MeetingRoom, onlineCount int) *dto.MeetingRoomDTO { } // participantToDTO 将 model.MeetingParticipant 转 DTO -func participantToDTO(p *model.MeetingParticipant) *dto.MeetingParticipantDTO { +// userMap 可选:若提供则附带 user_name / user_avatar,便于前端 MemberPanel/VideoTile 直接渲染昵称头像 +func participantToDTO(p *model.MeetingParticipant, userMap map[int64]service.UserDisplayInfo) *dto.MeetingParticipantDTO { if p == nil { return nil } @@ -123,6 +124,10 @@ func participantToDTO(p *model.MeetingParticipant) *dto.MeetingParticipantDTO { if p.LeftAt != nil { out.LeftAt = p.LeftAt.Format("2006-01-02 15:04:05") } + if info, ok := userMap[p.UserID]; ok { + out.UserName = info.Name + out.UserAvatar = info.Avatar + } return out } @@ -190,9 +195,15 @@ func (ctl *MeetingController) GetRoom(c *gin.Context) { ctl.handleError(c, err, "获取会议详情失败") return } + userIDs := make([]int64, 0, len(participants)) + for i := range participants { + userIDs = append(userIDs, participants[i].UserID) + } + userMap := ctl.meetingService.ResolveUsersDisplay(c.Request.Context(), userIDs) + parts := make([]dto.MeetingParticipantDTO, 0, len(participants)) for i := range participants { - parts = append(parts, *participantToDTO(&participants[i])) + parts = append(parts, *participantToDTO(&participants[i], userMap)) } resp := dto.GetMeetingRoomResponse{ Room: *roomToDTO(room, int(onlineCount)), @@ -225,9 +236,10 @@ func (ctl *MeetingController) JoinRoom(c *gin.Context) { } // Task 9:JoinRoom 响应携带 rtpCapabilities,供前端 mediasoup-client Device.load 直接使用 _, rtpCaps, _ := ctl.meetingService.ResolveRouterInfo(code) + userMap := ctl.meetingService.ResolveUsersDisplay(c.Request.Context(), []int64{participant.UserID}) resp := dto.JoinMeetingRoomResponse{ Room: *roomToDTO(room, 0), - Participant: *participantToDTO(participant), + Participant: *participantToDTO(participant, userMap), RouterID: routerID, RtpCapabilities: rtpCaps, } diff --git a/backend/go-service/app/meeting/service/meeting_service.go b/backend/go-service/app/meeting/service/meeting_service.go index 4d85df0..e1f217e 100644 --- a/backend/go-service/app/meeting/service/meeting_service.go +++ b/backend/go-service/app/meeting/service/meeting_service.go @@ -385,10 +385,14 @@ func (s *MeetingService) JoinRoom(ctx context.Context, userID int64, code, passw zap.String("room_code", code)) } + // 广播 payload 附带 user_name / user_avatar,前端 _onMemberJoined 直接落库,无需二次拉取 + name, avatar := s.resolveUserDisplay(ctx, userID) go s.broadcastToActiveParticipants(context.Background(), room.ID, constants.MeetingWSEventMemberJoined, map[string]interface{}{ - "room_code": code, - "user_id": userID, - "joined_at": participant.JoinedAt.Format("2006-01-02 15:04:05"), + "room_code": code, + "user_id": userID, + "user_name": name, + "user_avatar": avatar, + "joined_at": participant.JoinedAt.Format("2006-01-02 15:04:05"), }, userID) logs.Info(ctx, funcName, "用户加入会议成功", zap.String("room_code", code), zap.Int64("user_id", userID)) diff --git a/backend/go-service/app/meeting/service/meeting_signal_service.go b/backend/go-service/app/meeting/service/meeting_signal_service.go index 3af43b3..285a4cf 100644 --- a/backend/go-service/app/meeting/service/meeting_signal_service.go +++ b/backend/go-service/app/meeting/service/meeting_signal_service.go @@ -60,6 +60,14 @@ func resourceTrackKey(roomCode string, userID int64) string { return fmt.Sprintf("echo:meeting:resource:%s:%d", roomCode, userID) } +// memberStateKey Redis Hash,保存用户当前的音视频开关状态 +// fields:audio_enabled / video_enabled,值为 "true" / "false" +// 用途:后入者加入房间时,后端从这里读取每个已有成员的最新状态定向补推 state.changed, +// 解决"后入者的成员列表里其他人图标一直灰色"的问题 +func memberStateKey(roomCode string, userID int64) string { + return fmt.Sprintf("echo:meeting:member_state:%s:%d", roomCode, userID) +} + // resourceTTL 单个用户资源追踪集合 TTL // 设计:会议期间维持可达即可;若用户长期不活跃由断线清理接管 const resourceTTL = time.Hour @@ -83,6 +91,54 @@ func (s *MeetingSignalService) untrackResource(ctx context.Context, roomCode str _ = s.redis.SRem(ctx, key, member).Err() } +// updateMemberState 将某用户的音视频开关状态持久化到 Redis Hash +// 非 nil 的字段才写入;audio/video 任意一个 nil 都不碰它,避免误覆盖另一维状态 +// 容错:任何一步失败仅 Warn 日志,不阻断业务流程(前端已发 state.changed 作为权威广播) +func (s *MeetingSignalService) updateMemberState(ctx context.Context, roomCode string, userID int64, audio, video *bool) { + if audio == nil && video == nil { + return + } + key := memberStateKey(roomCode, userID) + values := make([]interface{}, 0, 4) + if audio != nil { + values = append(values, "audio_enabled", boolToStr(*audio)) + } + if video != nil { + values = append(values, "video_enabled", boolToStr(*video)) + } + if err := s.redis.HSet(ctx, key, values...).Err(); err != nil { + logs.Warn(ctx, "service.meeting_signal_service.updateMemberState", "写入成员音视频状态失败", + zap.String("key", key), zap.Error(err)) + return + } + _ = s.redis.Expire(ctx, key, resourceTTL).Err() +} + +// loadMemberState 读取某用户的音视频开关状态;Hash 不存在时两个字段都返回 (nil, nil) +func (s *MeetingSignalService) loadMemberState(ctx context.Context, roomCode string, userID int64) (audio *bool, video *bool) { + key := memberStateKey(roomCode, userID) + fields, err := s.redis.HGetAll(ctx, key).Result() + if err != nil || len(fields) == 0 { + return nil, nil + } + if v, ok := fields["audio_enabled"]; ok { + b := v == "true" + audio = &b + } + if v, ok := fields["video_enabled"]; ok { + b := v == "true" + video = &b + } + return audio, video +} + +func boolToStr(b bool) string { + if b { + return "true" + } + return "false" +} + // loadRoomAndParticipant 通用前置校验:拉取房间 + 确认用户是活跃参会者 // 所有信令事件在进入业务前都要过这一关;返回的 *MeetingRoom 供后续广播使用 roomID func (s *MeetingSignalService) loadRoomAndParticipant(ctx context.Context, roomCode string, userID int64) (*model.MeetingRoom, error) { @@ -110,7 +166,8 @@ func (s *MeetingSignalService) loadRoomAndParticipant(ctx context.Context, roomC // OnRoomJoin 处理 meeting.room.join 事件 // 语义:客户端 REST 加入会议成功后,通过 WS 宣告在线;服务端记录 userID ↔ roomCode 映射 -// 仅做存在性校验 + 心跳意义上的资源 key 刷新,不产生副作用 +// 副作用(Task 15 增强):补推"房间内其他用户已有的 producer 列表"给刚 join 的用户, +// 解决 Mediasoup SFU "后入者错过历史 producer.new"的经典问题 func (s *MeetingSignalService) OnRoomJoin(ctx context.Context, userID int64, roomCode string) error { room, err := s.loadRoomAndParticipant(ctx, roomCode, userID) if err != nil { @@ -130,9 +187,144 @@ func (s *MeetingSignalService) OnRoomJoin(ctx context.Context, userID int64, roo zap.String("room_code", roomCode), zap.Int64("user_id", userID), zap.Int64("room_id", room.ID)) + + // 定向补推已有 producer + 成员状态:异步执行,不阻塞 ACK + go s.pushExistingRoomState(context.Background(), room.ID, room.RoomCode, userID) + return nil } +// pushExistingRoomState 向刚加入者定向推送房间的历史媒体状态,分两件事: +// 1. producer 列表:复用 meeting.member.producer.new 事件语义,前端自动发起 consume +// 2. 成员 audio/video 开关:复用 meeting.member.state.changed 事件语义,前端更新成员面板图标 +// +// 容错:任何单步失败仅 Warn 日志,不中断流程 +func (s *MeetingSignalService) pushExistingRoomState(ctx context.Context, roomID int64, roomCode string, userID int64) { + s.pushExistingProducers(ctx, roomID, roomCode, userID) + s.pushExistingMemberStates(ctx, roomID, roomCode, userID) +} + +// pushExistingProducers 向刚加入者定向推送房间里其他用户已产生的 producer 列表 +// 复用 meeting.member.producer.new 事件语义,前端 _onProducerNew handler 无需改动 +// 容错:任何单步失败仅 Warn 日志,不中断流程 +func (s *MeetingSignalService) pushExistingProducers(ctx context.Context, roomID int64, roomCode string, userID int64) { + funcName := "service.meeting_signal_service.pushExistingProducers" + + actives, err := s.participantDAO.ListActiveByRoom(ctx, roomID) + if err != nil { + logs.Warn(ctx, funcName, "列出房间活跃参会者失败", + zap.Int64("room_id", roomID), zap.Error(err)) + return + } + + pushCount := 0 + for i := range actives { + other := &actives[i] + if other.UserID == userID { + continue + } + otherKey := resourceTrackKey(roomCode, other.UserID) + members, err := s.redis.SMembers(ctx, otherKey).Result() + if err != nil { + logs.Warn(ctx, funcName, "读取他人资源追踪集合失败", + zap.String("key", otherKey), zap.Error(err)) + continue + } + for _, m := range members { + // 格式:"kind:id";仅关心 producer + idx := -1 + for j, c := range m { + if c == ':' { + idx = j + break + } + } + if idx < 0 { + continue + } + kind, id := m[:idx], m[idx+1:] + if kind != "producer" || id == "" { + continue + } + if err := s.broadcaster.PublishToUser(ctx, userID, constants.MeetingWSEventMemberProducerNew, map[string]interface{}{ + "room_code": roomCode, + "user_id": other.UserID, + "producer_id": id, + "existing": true, + }); err != nil { + logs.Warn(ctx, funcName, "定向推送 existing producer 失败", + zap.Int64("to_user", userID), + zap.Int64("owner_user", other.UserID), + zap.String("producer_id", id), + zap.Error(err)) + continue + } + pushCount++ + } + } + + if pushCount > 0 { + logs.Info(ctx, funcName, "补推已有 producer 完成", + zap.String("room_code", roomCode), + zap.Int64("to_user", userID), + zap.Int("push_count", pushCount)) + } +} + +// pushExistingMemberStates 向刚加入者定向推送房间里其他活跃成员当前的音视频开关状态 +// 数据源:updateMemberState 在每次 OnMemberStateChanged 成功后写入的 Redis Hash +// 前端 _onMemberStateChanged 处理逻辑已存在,收到本事件后会刷新 MemberPanel 图标色彩 +// 容错:读取 Hash 失败、该用户暂无状态记录都直接跳过 +func (s *MeetingSignalService) pushExistingMemberStates(ctx context.Context, roomID int64, roomCode string, userID int64) { + funcName := "service.meeting_signal_service.pushExistingMemberStates" + + actives, err := s.participantDAO.ListActiveByRoom(ctx, roomID) + if err != nil { + logs.Warn(ctx, funcName, "列出房间活跃参会者失败", + zap.Int64("room_id", roomID), zap.Error(err)) + return + } + + pushCount := 0 + for i := range actives { + other := &actives[i] + if other.UserID == userID { + continue + } + audio, video := s.loadMemberState(ctx, roomCode, other.UserID) + if audio == nil && video == nil { + continue + } + data := map[string]interface{}{ + "room_code": roomCode, + "user_id": other.UserID, + "changed_by": other.UserID, + "existing": true, + } + if audio != nil { + data["audio_enabled"] = *audio + } + if video != nil { + data["video_enabled"] = *video + } + if err := s.broadcaster.PublishToUser(ctx, userID, constants.MeetingWSEventMemberStateChange, data); err != nil { + logs.Warn(ctx, funcName, "定向推送 existing state.changed 失败", + zap.Int64("to_user", userID), + zap.Int64("owner_user", other.UserID), + zap.Error(err)) + continue + } + pushCount++ + } + + if pushCount > 0 { + logs.Info(ctx, funcName, "补推已有成员状态完成", + zap.String("room_code", roomCode), + zap.Int64("to_user", userID), + zap.Int("push_count", pushCount)) + } +} + // OnWSDisconnect WS 断线钩子,实现 ws.MeetingDisconnectHook 接口(Task 8) // 由 ws.handler 的 SetOnDisconnect 回调触发;场景:用户的最后一条 WS 连接被移除 // 职责: @@ -240,6 +432,10 @@ func (s *MeetingSignalService) OnMemberStateChanged(ctx context.Context, fromUse data["video_enabled"] = *payload.VideoEnabled } + // 持久化到 Redis Hash;作为后入者补推 state.changed 的数据源 + // 注意:写 Hash 之所以同步执行而非放进 goroutine,是因为同一用户并发开关操作需要顺序一致 + s.updateMemberState(ctx, payload.RoomCode, targetID, payload.AudioEnabled, payload.VideoEnabled) + go s.broadcaster.BroadcastToMeeting(context.Background(), room.ID, constants.MeetingWSEventMemberStateChange, data, fromUserID) return nil } @@ -463,6 +659,8 @@ func (s *MeetingSignalService) cleanupUserResources(ctx context.Context, roomCod } } _ = s.redis.Del(ctx, key).Err() + // 一并清理成员音视频状态,避免对方下次入会时读到旧 host 的僵尸 AV 状态 + _ = s.redis.Del(ctx, memberStateKey(roomCode, userID)).Err() if len(members) > 0 { logs.Info(ctx, funcName, "清理用户媒体资源", diff --git a/design-system/echochat/pages/meeting-home.md b/design-system/echochat/pages/meeting-home.md new file mode 100644 index 0000000..9f473a7 --- /dev/null +++ b/design-system/echochat/pages/meeting-home.md @@ -0,0 +1,68 @@ +# Meeting Home (会议 Hub 首页) Page Overrides + +> **PROJECT:** EchoChat +> **Generated:** 2026-04-24 (via Task 15 UI 打磨) +> **Page Type:** Tab Page — Meeting Entrance + Quick Actions +> **Route:** `/pages/meeting/home` / `/pages/meeting/join` + +> ⚠️ Rules in this file **override** `design-system/echochat/MASTER.md`. +> ⚠️ 会议相关页面整体采用**深色主题**(`#0F172A` 背景),与聊天模块浅色主题形成品牌视觉分层。 + +--- + +## Page Layout + +- **结构:** 顶部品牌区 + 主操作卡片(创建 / 加入二选一)+ 底部"最近会议"列表 +- **背景:** `#0F172A` 纯色 + 顶部柔和蓝紫径向光晕(参考系统主色 `#2563EB`) +- **状态栏:** 透明穿透,内容顶部留 `env(safe-area-inset-top)` 间距 + +### 主操作卡片(2 卡片横向) + +| 卡片 | 主图标 | 背景 | 文案 | +|------|-------|------|------| +| 创建会议 | `video` svg 28px white | `linear-gradient(135deg, #2563EB, #6366F1)` | 主标题 "发起会议" + 副标题 "创建一个即时会议" | +| 加入会议 | `log-in` svg 28px white | `rgba(255,255,255,0.06)` + `1rpx solid rgba(255,255,255,0.12)` | 主标题 "加入会议" + 副标题 "输入会议号或粘贴链接" | + +- **尺寸:** 卡片高 `240rpx`,圆角 `24rpx`,阴影 `0 8rpx 24rpx rgba(37, 99, 235, 0.25)`(仅创建卡) +- **悬停:** `transform: translateY(-4rpx)` + 阴影加深 +- **点击反馈:** `transform: scale(0.98)` `.12s` + +### 最近会议列表 + +- **背景:** `rgba(255, 255, 255, 0.04)` + `1rpx solid rgba(255,255,255,0.08)` 边框 +- **列表项:** 时间(左对齐 `#94A3B8 22rpx`)+ 标题(`#F3F4F6 28rpx`)+ 时长徽标(右侧 `#10B981` 小标签) +- **空状态:** 居中 icon `clock` + 灰字 "暂无最近会议" + +## Color Palette (页面专属) + +| 用途 | Hex | 说明 | +|------|-----|------| +| 主背景 | `#0F172A` | Slate 900 | +| 卡片面 | `rgba(255,255,255,0.06)` | Glass 效果 | +| 主 CTA 渐变 | `#2563EB → #6366F1` | 创建会议主按钮 | +| 辅 CTA | `rgba(255,255,255,0.12)` | 加入会议按钮边框 | +| 正文 | `#F3F4F6` | Gray 100 | +| 辅助文字 | `#94A3B8` | Slate 400 | +| 成功点缀 | `#10B981` | 在线 / 时长 | + +## Typography + +| 层级 | Size | Weight | Color | +|------|------|--------|-------| +| 品牌标题 | `44rpx` | 700 | `#F8FAFC` | +| 卡片主标题 | `32rpx` | 600 | `#FFFFFF` | +| 卡片副标题 | `24rpx` | 400 | `rgba(255,255,255,0.75)` | +| 列表时间戳 | `22rpx` | 400 | `#94A3B8` | + +## Motion + +- **入场:** 两张主卡片依次淡入 + 向上 `16rpx` 位移,`stagger: 80ms` +- **CTA 呼吸:** 创建卡在空闲 3s 后阴影脉动(`opacity 0.25 → 0.4 → 0.25`,`2.4s ease-in-out infinite`) +- **点击触达:** 任何 CTA 点击后转场用 `slide-up` `.28s cubic-bezier(0.2,0.8,0.2,1)` + +## Interactions + +- **创建会议:** `navigateTo('/pages/meeting/preview?mode=create')` +- **加入会议:** `navigateTo('/pages/meeting/join')` +- **最近会议项点击:** 直接 `navigateTo('/pages/meeting/preview?code=xxx&mode=join')` +- **长按列表项(暂不做):** Task 16 拓展 diff --git a/design-system/echochat/pages/meeting-invite.md b/design-system/echochat/pages/meeting-invite.md new file mode 100644 index 0000000..4754483 --- /dev/null +++ b/design-system/echochat/pages/meeting-invite.md @@ -0,0 +1,86 @@ +# Meeting Invite (邀请弹窗) Page Overrides + +> **PROJECT:** EchoChat +> **Generated:** 2026-04-24 (via Task 15 UI 打磨) +> **Page Type:** Bottom Sheet / Center Modal — Share Meeting +> **Component:** `frontend/src/components/meeting/InviteDialog.vue` + +> ⚠️ Rules in this file **override** `design-system/echochat/MASTER.md`. + +--- + +## Page Layout + +- **容器类型:** 居中 Modal(桌面端)/ 底部 BottomSheet(移动端 `<= 750px`) +- **遮罩:** `rgba(0, 0, 0, 0.55)` + backdrop blur `8px` +- **容器:** + - 桌面: `520rpx × auto`,`#FFFFFF`,圆角 `24rpx`,阴影 `0 24rpx 48rpx rgba(0,0,0,0.35)` + - 移动: `100vw`,顶部圆角 `24rpx 24rpx 0 0` +- **Z-index:** 200(同 MemberPanel 同级,因是互斥显示) + +## 内容分区 + +### 1. 头部 + +- 关闭按钮(右上角 `56rpx` 圆形按钮)+ 标题 "邀请成员加入" +- 副标题:会议标题 + 会议号格式化 `xxx-xxx-xxx` + +### 2. 分享方式 3 选项(并列) + +| 选项 | 图标 | 说明 | 操作 | +|------|-----|------|-----| +| 复制链接 | `link` 24px | 带 Token 链接,含密码(若有) | 复制到剪贴板 + toast | +| 复制会议号 | `hash` 24px | 纯会议号(需对方手动输入密码) | 复制 + toast | +| 邀请联系人 | `user-plus` 24px | 打开联系人选择器(复用 Phase 2a 组件) | 打开抽屉 | + +- **布局:** 水平排列,每项尺寸 `144rpx × 144rpx`;图标上方居中,文案下方居中 +- **Hover / Active:** 卡片背景 `#F3F4F6` → `#E5E7EB`;`scale(0.98)` on active + +### 3. 信息展示 + +- **邀请链接区:** 单行滚动 input(readonly)+ 右侧 "复制" 按钮 + - 背景 `#F8FAFC`,圆角 `12rpx`,高 `72rpx` +- **密码提示:** 若会议有密码,额外展示 `Password:` 标签 + 密码 pill(可点击复制) +- **过期时间:** 小字提示 "链接 24 小时内有效"(若后端返回过期时间) + +## Color Palette + +| 用途 | Hex | +|------|-----| +| 背景 | `#FFFFFF` | +| 选项卡片 | `#F8FAFC` | +| 选项悬停 | `#F3F4F6` | +| 主 CTA | `#2563EB` | +| 文本主 | `#1E293B` | +| 辅助文本 | `#64748B` | +| Toast 成功 | `#10B981` | + +## Typography + +| 层级 | Size | Weight | +|------|------|--------| +| 标题 | `32rpx` | 600 | +| 副标题 | `24rpx` | 400 | +| 选项名 | `26rpx` | 500 | +| 输入框 | `26rpx` | 400,`monospace` for code | + +## Motion + +- **打开:** + - 桌面: 遮罩 fade-in `180ms` + 容器 `scale(0.96) → 1 + opacity 0 → 1`,`220ms cubic-bezier(0.2,0.8,0.2,1)` + - 移动: 底部滑入 `translateY(100%) → 0`,`260ms` +- **关闭:** 反向播放,时长 `160ms` +- **复制成功 toast:** 顶部滑入 `translateY(-20px) → 0` 停留 `1.2s` 再滑出 + +## Interactions + +- **复制链接:** + - `clipboard.writeText(joinLink)` → 成功显示 toast "链接已复制";失败显示 `Modal` 文本兜底 + - 含密码的链接形如 `https://echochat.app/#/pages/meeting/join?code=123456789&token=xxx` +- **复制会议号:** + - 复制无密码纯 9 位数(不含分隔符) +- **联系人选择器:** + - 打开 `ContactPicker` 组件(复用 Phase 2a) + - 选中后 `POST /api/v1/meeting/rooms/:code/invite` 发送邀请 + - 成功后局部 toast,选择器不关闭方便批量邀请 +- **ESC / 点击遮罩:** 关闭 diff --git a/design-system/echochat/pages/meeting-preview.md b/design-system/echochat/pages/meeting-preview.md new file mode 100644 index 0000000..d154c31 --- /dev/null +++ b/design-system/echochat/pages/meeting-preview.md @@ -0,0 +1,63 @@ +# Meeting Preview (设备预览页) Page Overrides + +> **PROJECT:** EchoChat +> **Generated:** 2026-04-24 (via Task 15 UI 打磨) +> **Page Type:** Full Screen — Pre-join Device Check +> **Route:** `/pages/meeting/preview` + +> ⚠️ Rules in this file **override** `design-system/echochat/MASTER.md`. + +--- + +## Page Layout + +- **结构:** 左侧大预览区(≥ 60% 宽) + 右侧设备列表 + 底部入会操作条 +- **背景:** `#0F172A`(主区)+ `#111827`(右侧设备面板) +- **顶部导航:** 透明,仅左侧返回图标 `#F3F4F6` 24px + +### 左侧视频预览 + +- **比例:** 16:9,最大宽度 `960px`,圆角 `16rpx`,阴影 `0 12rpx 48rpx rgba(0,0,0,0.5)` +- **视频关闭态:** 深灰占位 `#1F2937` + 用户头像(`140rpx` 圆形) + 昵称下方 +- **音量条覆盖层(创新点):** + - **不是分段格子**,而是**渐变波浪条**横向贯穿预览底部 + - CSS: `height: 6rpx`,`linear-gradient(90deg, #10B981 0%, #F59E0B 100%)` + - 音量 0-1 映射为宽度 `0-100%`,`transition: width 0.05s linear` + - 实时刷新(约 16fps 节流) + +### 右侧设备面板(宽 `420rpx`) + +- **分组:** 摄像头 / 麦克风 / 扬声器 三段 +- **标题:** `24rpx` 600 `#94A3B8` uppercase +- **下拉框:** 高 `72rpx`,`#1F2937` 背景,`12rpx` 圆角,右侧箭头 `chevron-down` +- **入会设置(折叠):** "麦克风静音入会" + "摄像头关闭入会" 两个 toggle + +### 底部操作条 + +- **高度:** `120rpx` + safe area +- **左侧:** 麦克风 toggle + 摄像头 toggle(圆形 `80rpx`,开=蓝 `#2563EB` 关=灰 `#374151`) +- **右侧:** 主 CTA "进入会议" `160rpx × 72rpx`,`#2563EB → #6366F1` 渐变 + +## Color Palette (页面专属) + +| 用途 | Hex | +|------|-----| +| 预览区背景 | `#0F172A` | +| 设备面板背景 | `#111827` | +| 下拉框面 | `#1F2937` | +| 音量条渐变 | `#10B981 → #F59E0B` | +| 主 CTA | `#2563EB → #6366F1` | +| 禁用态 | `#374151` | + +## Motion + +- **入场:** 视频预览先于右侧面板 `80ms` 出现 +- **设备切换:** 下拉选中后预览区视频有 `200ms` 淡出 + `200ms` 淡入的 crossfade +- **音量波浪:** 超过 0.7(大声说话)时条形底部再叠一层 `0 0 16rpx #F59E0B` glow,脱离后 `300ms` 退场 + +## Interactions + +- **下拉切换设备:** 立即释放旧 track,重新 `getUserMedia`(带 Loading 50ms 超时后再渲染) +- **麦/摄 toggle:** 仅切换本地 preview track 启用态,不持久化设备选择 +- **入会设置 toggle:** 保存到 `meetingStore.devicePreview.enterMuted` / `enterCamOff` +- **点击进入会议:** 禁用状态 = 设备权限未授予时灰显 diff --git a/design-system/echochat/pages/meeting-room.md b/design-system/echochat/pages/meeting-room.md new file mode 100644 index 0000000..006ab7c --- /dev/null +++ b/design-system/echochat/pages/meeting-room.md @@ -0,0 +1,156 @@ +# Meeting Room (会议室主页面) Page Overrides + +> **PROJECT:** EchoChat +> **Generated:** 2026-04-24 (via Task 15 UI 打磨) +> **Page Type:** Full Screen — Meeting Main View (Fixed Fullscreen Layer) +> **Route:** `/pages/meeting/room` + +> ⚠️ Rules in this file **override** `design-system/echochat/MASTER.md`. +> ⚠️ 本页面是 EchoChat 会议模块原创视觉的集大成体现,包含 6 条原创特色: +> **流光轮廓 / 柔性网格 / 自视频浮窗吸附 / 静音氛围色 / 入会滑入 / 波浪网络徽标**。 + +--- + +## Page Layout + +- **结构:** 顶部信息条(Title + Code + State pill + NetworkBadge + Timer) + 主视频区 + 聊天侧栏(可隐藏)+ 底部工具栏 +- **Z-index 层级(严格遵守):** + - `room root`: 1000(高于 CustomTabBar) + - `top-bar`: 5 + - `video-grid`: auto + - `SelfVideoFloat`: 120 + - `MemberPanel / InviteDialog 遮罩`: 200 + - `MeetingToolbar`: 210 + - `Leave Confirm Modal`: 220 + +## 原创特色 1:说话者流光轮廓 + +**规范:** + +```css +.tile.speaking { + border: 2rpx solid transparent; + background: + linear-gradient(#0B1220, #0B1220) padding-box, + conic-gradient(from var(--flow-angle, 0deg), + #10B981, #2563EB, #6366F1, #10B981) border-box; + animation: flow 3s linear infinite; + box-shadow: 0 0 24rpx rgba(99, 102, 241, 0.45); +} + +@property --flow-angle { + syntax: ''; + initial-value: 0deg; + inherits: false; +} +@keyframes flow { to { --flow-angle: 360deg; } } +``` + +- **触发:** `audioLevel >= 0.05`,1 in / 2 out 防抖(500ms polling) +- **Safari 17- fallback:** 静态蓝环 `border-color: #3B82F6 + box-shadow`(既有实现) + +## 原创特色 2:柔性网格(2/3 人非等分) + +| 成员数(不含本地浮窗) | 布局 | 说明 | +|---|---|---| +| 0 | 居中文案 "等待其他人加入…" | 只有自己时本地浮窗承担唯一画面 | +| 1 | 单块铺满 | 远端唯一成员占满视频区 | +| 2 | 左右 65/35 | 首位(加入时间早)为主画面 | +| 3 | 大块左侧 60% + 右侧 2 块上下叠放各 50% | 三角视觉焦点 | +| 4-9 | `repeat(3, 1fr)` 等分 | 保守兜底 | +| 10+ | `repeat(ceil(sqrt(n)), 1fr)` | 同现有 | + +- **间隙:** `gap: 12rpx`(保持现有) +- **小屏(`max-width: 750px`):** 回退到纵向等分,不启用非等分 + +## 原创特色 3:自视频浮窗四角吸附 + +**规范:** + +- **默认位置:** 右下角,距视频区 `24rpx` +- **尺寸:** + - 桌面端: `280px × 180px` + - 移动端: `240rpx × 150rpx`(16:10) +- **视觉:** `border-radius: 16rpx`; 阴影 `0 12rpx 32rpx rgba(0,0,0,0.45)`; `1rpx solid rgba(255,255,255,0.1)` 微边 +- **图钉按钮:** 右上角 `36rpx × 36rpx` 半透明圆形按钮,点击切换 float ↔ grid +- **拖拽:** + - PC: `mousedown/mousemove/mouseup` + - 移动: `touchstart/touchmove/touchend` + - 拖拽中禁用 `user-select: none`,`cursor: grabbing` +- **吸附:** + - 释放时计算距离 4 角最近者 + - `transition: transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1)` +- **Z-index:** 120 + +## 原创特色 4:静音氛围色 + +**规范:** + +- 工具栏背景 `computed(allMuted)`: + - 正常(默认或有人开麦): `rgba(17, 24, 39, 0.88)` + - 全员静音且 ≥ 2 人:`linear-gradient(to top, rgba(30, 58, 138, 0.88), rgba(30, 64, 175, 0.78))`(冷蓝) +- **过渡:** `transition: background 0.4s ease` +- **触发条件:** `tiles.length >= 2 && tiles.every(t => !t.audioEnabled)` + +## 原创特色 5:入会滑入动效 + +- 新成员 `tile` 插入网格时使用 `@keyframes slideIn`: + +```css +@keyframes slideIn { + from { transform: translateY(16rpx); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} +.grid-cell { animation: slideIn 0.28s cubic-bezier(0.2,0.8,0.2,1) both; } +``` + +- **注意:** 避免所有 tile 在页面首次挂载时全部跑动画;用 `v-for` key + `` 或者标志位 `isFirstLoad = false` 后才启用。 + +## 原创特色 6:NetworkBadge 3 条波浪 + +**规范:** + +- 3 条 SVG 曲线 `` +- 宽度 `18px`,高 `16px`,`stroke-width: 2`,`fill: none` +- level → 可见条数 & 颜色: + - `4` 优秀: 3 条 `#10B981` + - `3` 良好: 2 条 `#22C55E` + - `2` 一般: 1 条 `#F59E0B` + - `1` 很差: 1 条短版 `#EF4444` + - `0` 已断: 静态水平红线 + `!` 小图标 +- **动效:** `@keyframes wave { from { transform: translateX(0); } to { transform: translateX(-6px); } }`,`1.2s linear infinite`,3 条相位差 `0s / -0.3s / -0.6s` + +## Color Palette (页面专属) + +| 用途 | Hex | +|------|-----| +| 房间背景 | `#0F172A` | +| 视频区背景 | `#0B1220` | +| 视频块背景 | `#0B1220` | +| 聊天侧栏 | `#1F2937` | +| Toolbar 默认 | `rgba(17,24,39,0.88)` | +| Toolbar 全员静音 | `linear-gradient(to top, rgba(30,58,138,0.88), rgba(30,64,175,0.78))` | +| 状态 pill 已连接 | `rgba(16,185,129,0.2)` + `#86EFAC` | +| 状态 pill 重连 | `rgba(245,158,11,0.25)` + `#FBBF24` | + +## Motion Timeline + +| 事件 | 动效 | 时长 | +|------|------|-----| +| 页面挂载 | Top-bar fade-in | 200ms | +| 新成员加入 | `tile slideIn` | 280ms | +| 开始说话 | 边框流光启动 | 持续 | +| 全员静音 | Toolbar 背景渐变 | 400ms | +| 浮窗释放吸附 | `transform` 缓动 | 220ms | + +## Interactions + +- **工具栏按钮:** 所有交互事件见 `MeetingToolbar.vue`;二次点击成员/聊天/邀请均 toggle 关闭 +- **浮窗图钉:** 点击切换 float/grid 模式,`meetingStore.uiPrefs.selfFloat` 记忆 +- **主持人:** + - 成员面板右侧三点菜单展开"请他静音 / 请他开麦 / 转让主持人 / 踢出会议" + - 四件套全部收敛在 MemberPanel 内,不在主界面暴露 + +## Accessibility + +- 所有动效尊重 `@media (prefers-reduced-motion: reduce)`,禁用流光旋转与入会滑入,改为渐变态 diff --git a/docs/plans/2026-04-21-phase2e-2-implementation.plan.md b/docs/plans/2026-04-21-phase2e-2-implementation.plan.md index ecb76f6..5267062 100644 --- a/docs/plans/2026-04-21-phase2e-2-implementation.plan.md +++ b/docs/plans/2026-04-21-phase2e-2-implementation.plan.md @@ -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-24(Task 14 ✅ docker-compose 双态扩展完成:新增 media-server + coturn(public profile)容器编排、三份 .env 模板、scripts/start|stop|status.sh 支持 full 子命令、scripts/deploy-public.sh 公网部署校验脚本、docs/deployment/meeting-mvp.md 双态部署指南。下一步 Task 15 UI 打磨) +> **最后更新:** 2026-04-23(Task 15 ✅ UI 打磨 + 主持人权限四件套完成: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 原创要求 - - 动效性能:不影响 60fps(Chrome 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 `` + `@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 16:E2E 验证 + Playwright MCP 回归 + 代码审查 + 文档同步 diff --git a/docs/plans/2026-04-24-phase2e-2-task15-ui-polish.plan.md b/docs/plans/2026-04-24-phase2e-2-task15-ui-polish.plan.md new file mode 100644 index 0000000..d56c8b5 --- /dev/null +++ b/docs/plans/2026-04-24-phase2e-2-task15-ui-polish.plan.md @@ -0,0 +1,171 @@ +# Phase 2e-2 Task 15:UI 打磨 + 主持人权限四件套补齐 + +> **状态:** 📋 设计阶段(等待用户 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` 属性上,不触发 layout;Chrome 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 API,Chrome/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 `` 波浪曲线 +- 动画:`@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 ↔ grid,grid 模式本地 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 | 初稿 | diff --git a/docs/progress/CURRENT_STATUS.md b/docs/progress/CURRENT_STATUS.md index 788907e..dedb01a 100644 --- a/docs/progress/CURRENT_STATUS.md +++ b/docs/progress/CURRENT_STATUS.md @@ -1,7 +1,7 @@ # EchoChat 项目开发进度 -> **最后更新**:2026-04-24(Phase 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-23(Phase 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 15:UI 打磨 + 主持人权限四件套完成 + +**交付**: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 `` 波浪线替代原 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` 注册一个可动画的 `` 自定义属性,配合 `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 路径用同一 `` 偏移 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 participant(9001/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 14:docker-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` 双态部署指南。 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 90a8706..d360750 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -37,6 +37,7 @@ "@dcloudio/uni-cli-shared": "3.0.0-4080720251210001", "@dcloudio/uni-stacktracey": "3.0.0-4080720251210001", "@dcloudio/vite-plugin-uni": "3.0.0-4080720251210001", + "@vitejs/plugin-basic-ssl": "^2.3.0", "@vue/runtime-core": "^3.4.21", "sass": "^1.97.3", "vite": "5.2.8" @@ -4443,6 +4444,19 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz", + "integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/@vitejs/plugin-legacy": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-legacy/-/plugin-legacy-5.3.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index ec6c9d2..364b295 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -65,6 +65,7 @@ "@dcloudio/uni-cli-shared": "3.0.0-4080720251210001", "@dcloudio/uni-stacktracey": "3.0.0-4080720251210001", "@dcloudio/vite-plugin-uni": "3.0.0-4080720251210001", + "@vitejs/plugin-basic-ssl": "^2.3.0", "@vue/runtime-core": "^3.4.21", "sass": "^1.97.3", "vite": "5.2.8" diff --git a/frontend/src/components/meeting/MeetingToolbar.vue b/frontend/src/components/meeting/MeetingToolbar.vue index 947501d..9228d0d 100644 --- a/frontend/src/components/meeting/MeetingToolbar.vue +++ b/frontend/src/components/meeting/MeetingToolbar.vue @@ -11,7 +11,7 @@ - 按钮带 loading 态,禁用期间点击不派发事件 -->