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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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, "清理用户媒体资源",
|
||||
|
||||
68
design-system/echochat/pages/meeting-home.md
Normal file
68
design-system/echochat/pages/meeting-home.md
Normal file
@@ -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 拓展
|
||||
86
design-system/echochat/pages/meeting-invite.md
Normal file
86
design-system/echochat/pages/meeting-invite.md
Normal file
@@ -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 / 点击遮罩:** 关闭
|
||||
63
design-system/echochat/pages/meeting-preview.md
Normal file
63
design-system/echochat/pages/meeting-preview.md
Normal file
@@ -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`
|
||||
- **点击进入会议:** 禁用状态 = 设备权限未授予时灰显
|
||||
156
design-system/echochat/pages/meeting-room.md
Normal file
156
design-system/echochat/pages/meeting-room.md
Normal file
@@ -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: '<angle>';
|
||||
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 + `<transition-group>` 或者标志位 `isFirstLoad = false` 后才启用。
|
||||
|
||||
## 原创特色 6:NetworkBadge 3 条波浪
|
||||
|
||||
**规范:**
|
||||
|
||||
- 3 条 SVG 曲线 `<path d="M0 8 C 2 4, 4 12, 6 8 S 10 4, 12 8 S 16 12, 18 8" />`
|
||||
- 宽度 `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)`,禁用流光旋转与入会滑入,改为渐变态
|
||||
@@ -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 `<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 16:E2E 验证 + Playwright MCP 回归 + 代码审查 + 文档同步
|
||||
|
||||
|
||||
171
docs/plans/2026-04-24-phase2e-2-task15-ui-polish.plan.md
Normal file
171
docs/plans/2026-04-24-phase2e-2-task15-ui-polish.plan.md
Normal file
@@ -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 `<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 ↔ 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 | 初稿 |
|
||||
@@ -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 `<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 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` 双态部署指南。
|
||||
|
||||
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- 按钮带 loading 态,禁用期间点击不派发事件
|
||||
-->
|
||||
<template>
|
||||
<view class="toolbar">
|
||||
<view class="toolbar" :class="{ 'toolbar-all-muted': allMuted }">
|
||||
<button class="btn" :class="{ active: audioEnabled, loading: audioLoading }" :disabled="audioLoading" @click="emitIf('mic-toggle')">
|
||||
<view class="icon">
|
||||
<svg v-if="audioEnabled" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -100,7 +100,9 @@ const props = defineProps({
|
||||
videoLoading: { type: Boolean, default: false },
|
||||
memberCount: { type: Number, default: 0 },
|
||||
unreadChatCount: { type: Number, default: 0 },
|
||||
allowChat: { type: Boolean, default: true }
|
||||
allowChat: { type: Boolean, default: true },
|
||||
/** Task 15 原创特色 4:全员静音氛围色 */
|
||||
allMuted: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['mic-toggle', 'cam-toggle', 'invite', 'members', 'chat', 'leave'])
|
||||
@@ -130,6 +132,13 @@ const emitIf = (name) => {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 210;
|
||||
transition: background 0.4s ease, border-top-color 0.4s ease;
|
||||
}
|
||||
/* Task 15 原创特色 4:全员静音氛围色
|
||||
冷蓝渐变暗示"全房间安静",触发条件由父组件 computed (allMuted) 控制 */
|
||||
.toolbar.toolbar-all-muted {
|
||||
background: linear-gradient(to top, rgba(30, 58, 138, 0.88), rgba(30, 64, 175, 0.78));
|
||||
border-top-color: rgba(96, 165, 250, 0.18);
|
||||
}
|
||||
|
||||
.btn {
|
||||
|
||||
@@ -68,6 +68,20 @@
|
||||
<circle cx="12" cy="19" r="1"></circle>
|
||||
</svg>
|
||||
<view v-if="openMenuUid === p.user_id" class="menu" @click.stop>
|
||||
<view
|
||||
v-if="p.audio_enabled !== false"
|
||||
class="menu-item"
|
||||
@click="onMute(p, true)"
|
||||
>
|
||||
<text>请他静音</text>
|
||||
</view>
|
||||
<view
|
||||
v-else
|
||||
class="menu-item"
|
||||
@click="onMute(p, false)"
|
||||
>
|
||||
<text>请他开麦</text>
|
||||
</view>
|
||||
<view class="menu-item" @click="onTransfer(p)">
|
||||
<text>转让主持人</text>
|
||||
</view>
|
||||
@@ -98,7 +112,7 @@ const props = defineProps({
|
||||
displayNameMap: { type: Object, default: () => ({}) }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'kick', 'transfer-host'])
|
||||
const emit = defineEmits(['close', 'kick', 'transfer-host', 'mute-member'])
|
||||
|
||||
const openMenuUid = ref(0)
|
||||
const show = ref(false)
|
||||
@@ -163,6 +177,12 @@ const onKick = (p) => {
|
||||
emit('kick', p.user_id)
|
||||
}
|
||||
|
||||
/** Task 15:主持人权限四件套第 4 件 —— 请他静音 / 请他开麦 */
|
||||
const onMute = (p, mute) => {
|
||||
openMenuUid.value = 0
|
||||
emit('mute-member', { userId: p.user_id, mute })
|
||||
}
|
||||
|
||||
const close = () => emit('close')
|
||||
const onMaskClick = () => close()
|
||||
</script>
|
||||
|
||||
@@ -1,19 +1,40 @@
|
||||
<!--
|
||||
网络质量徽标(Task 11)
|
||||
网络质量徽标(Task 11 / Task 15 波浪动效升级)
|
||||
|
||||
职责:
|
||||
- 渲染 4 格信号柱(类似手机信号图标),根据 level (0-4) 点亮
|
||||
- 可选展示文字标签(默认隐藏,hover/长按再显示)
|
||||
- 本组件不产生网络数据,由父页面在 Task 15 接入 mediasoup getStats 后喂入
|
||||
- Task 15 原创特色 6:使用 3 条横向 SVG 波浪曲线代替传统格子信号条
|
||||
- 根据 level (0-4) 动态点亮可见条数 + 颜色
|
||||
- 3 条波浪以相位差 0 / -0.3s / -0.6s 循环流动,形成"呼吸感"
|
||||
- 可选文字标签(title tooltip 保留,默认不显示 inline label)
|
||||
|
||||
level 语义:
|
||||
- 4 excellent / 3 good / 2 fair / 1 poor / 0 disconnected
|
||||
-->
|
||||
<template>
|
||||
<view class="badge" :class="levelClass" :title="labelText">
|
||||
<view v-for="n in 4" :key="n" class="bar" :class="{ on: n <= level }"
|
||||
:style="{ height: `${n * 20}%` }"
|
||||
></view>
|
||||
<view v-if="level <= 0" class="dc-icon" aria-label="已断开">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="4" y1="12" x2="20" y2="12"></line>
|
||||
<circle cx="12" cy="17" r="1" fill="currentColor"></circle>
|
||||
</svg>
|
||||
</view>
|
||||
<template v-else>
|
||||
<svg
|
||||
v-for="n in visibleLines"
|
||||
:key="n"
|
||||
class="wave"
|
||||
:class="`wave-${n}`"
|
||||
viewBox="0 0 24 12"
|
||||
width="24"
|
||||
height="10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
>
|
||||
<path d="M 0 6 C 3 2, 6 10, 9 6 S 15 2, 18 6 S 22 10, 24 6" />
|
||||
</svg>
|
||||
</template>
|
||||
<text v-if="showLabel" class="label">{{ labelText }}</text>
|
||||
</view>
|
||||
</template>
|
||||
@@ -30,6 +51,14 @@ const LEVEL_LABEL = ['已断开', '很差', '一般', '良好', '优秀']
|
||||
|
||||
const labelText = computed(() => LEVEL_LABEL[Math.max(0, Math.min(4, props.level))])
|
||||
|
||||
/** level → 可见条数(4=3 条 / 3=2 条 / 2/1=1 条,0 由 dc-icon 承接) */
|
||||
const visibleLines = computed(() => {
|
||||
if (props.level >= 4) return 3
|
||||
if (props.level >= 3) return 2
|
||||
if (props.level >= 1) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
const levelClass = computed(() => {
|
||||
if (props.level >= 4) return 'lv-excellent'
|
||||
if (props.level >= 3) return 'lv-good'
|
||||
@@ -42,28 +71,42 @@ const levelClass = computed(() => {
|
||||
<style scoped>
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 3rpx;
|
||||
height: 28rpx;
|
||||
padding: 4rpx 8rpx;
|
||||
border-radius: 8rpx;
|
||||
align-items: center;
|
||||
gap: 2rpx;
|
||||
height: 36rpx;
|
||||
padding: 4rpx 10rpx;
|
||||
border-radius: 10rpx;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(4px);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 6rpx;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-radius: 2rpx;
|
||||
transition: background-color 0.15s ease;
|
||||
.wave {
|
||||
animation: wave-flow 1.2s linear infinite;
|
||||
flex-shrink: 0;
|
||||
/* 三条波浪相位差:通过 animation-delay 负值错开 */
|
||||
}
|
||||
.wave-1 { animation-delay: 0s; }
|
||||
.wave-2 { animation-delay: -0.3s; }
|
||||
.wave-3 { animation-delay: -0.6s; }
|
||||
|
||||
@keyframes wave-flow {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-6px); }
|
||||
}
|
||||
|
||||
.lv-excellent .bar.on { background: #10B981; }
|
||||
.lv-good .bar.on { background: #22C55E; }
|
||||
.lv-fair .bar.on { background: #F59E0B; }
|
||||
.lv-poor .bar.on { background: #EF4444; }
|
||||
.lv-off .bar { background: rgba(255, 255, 255, 0.2); }
|
||||
.lv-excellent { color: #10B981; }
|
||||
.lv-good { color: #22C55E; }
|
||||
.lv-fair { color: #F59E0B; }
|
||||
.lv-poor { color: #EF4444; }
|
||||
.lv-off { color: #EF4444; }
|
||||
|
||||
.dc-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24rpx;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-left: 8rpx;
|
||||
@@ -72,4 +115,9 @@ const levelClass = computed(() => {
|
||||
line-height: 1;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* 可访问性:减少动效偏好下停止流动 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wave { animation: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
325
frontend/src/components/meeting/SelfVideoFloat.vue
Normal file
325
frontend/src/components/meeting/SelfVideoFloat.vue
Normal file
@@ -0,0 +1,325 @@
|
||||
<!--
|
||||
自视频浮窗(Task 15 原创特色 3:四角吸附拖拽 + 图钉切回网格)
|
||||
|
||||
职责:
|
||||
- 桌面端恒为浮窗显示本地视频(默认右下角,可拖拽到任意位置,松手吸附到最近四角)
|
||||
- 提供图钉按钮切换 float ↔ grid 模式
|
||||
- 复用 VideoTile 的 track 挂载能力,只在外壳上做拖拽逻辑
|
||||
|
||||
设计要点:
|
||||
- 仅 H5 生效;非 H5 直接渲染为空
|
||||
- mousemove/touchmove 注册在 window 上,避免 iframe / 外部元素失焦
|
||||
- onBeforeUnmount 必须移除监听,防止泄漏
|
||||
- 吸附计算:记录容器边界矩形,释放时分别算到 4 角中心距离,取最小
|
||||
- z-index: 120(低于 Toolbar 210、低于 MemberPanel 200,高于 VideoGrid)
|
||||
-->
|
||||
<template>
|
||||
<view
|
||||
v-if="!hidden"
|
||||
ref="floatEl"
|
||||
class="float-shell"
|
||||
:class="{ dragging: isDragging }"
|
||||
:style="positionStyle"
|
||||
@mousedown="onDragStart"
|
||||
@touchstart="onDragStart"
|
||||
>
|
||||
<VideoTile
|
||||
:user-id="userId"
|
||||
:name="name"
|
||||
:is-local="true"
|
||||
:is-host="isHost"
|
||||
:audio-enabled="audioEnabled"
|
||||
:video-enabled="videoEnabled"
|
||||
:audio-track="audioTrack"
|
||||
:video-track="videoTrack"
|
||||
:is-speaking="isSpeaking"
|
||||
:compact="true"
|
||||
/>
|
||||
|
||||
<view class="pin-btn" title="收回网格" @click.stop="onPinClick" @mousedown.stop @touchstart.stop>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="17" x2="12" y2="22"></line>
|
||||
<path d="M5 17h14l-1.405-1.405A2 2 0 0 1 17 14.172V11a5 5 0 0 0-10 0v3.172a2 2 0 0 1-.595 1.423L5 17z"></path>
|
||||
</svg>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
import VideoTile from '@/components/meeting/VideoTile.vue'
|
||||
|
||||
const props = defineProps({
|
||||
userId: { type: [Number, String], required: true },
|
||||
name: { type: String, default: '你' },
|
||||
isHost: { type: Boolean, default: false },
|
||||
audioEnabled: { type: Boolean, default: true },
|
||||
videoEnabled: { type: Boolean, default: true },
|
||||
audioTrack: { type: Object, default: null },
|
||||
videoTrack: { type: Object, default: null },
|
||||
isSpeaking: { type: Boolean, default: false },
|
||||
/** 父容器引用,用于计算吸附边界;不传则以 window 为边界 */
|
||||
container: { type: Object, default: null },
|
||||
/** 是否隐藏浮窗(例如图钉切换后) */
|
||||
hidden: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['pin-click'])
|
||||
|
||||
const floatEl = ref(null)
|
||||
|
||||
// ============ 位置状态 ============
|
||||
|
||||
/** 浮窗当前位置(绝对像素 / 容器坐标系);默认右下角 */
|
||||
const position = ref({ x: 0, y: 0 })
|
||||
const isDragging = ref(false)
|
||||
|
||||
/** 拖拽起始记录 */
|
||||
const dragStart = ref({ pointerX: 0, pointerY: 0, startX: 0, startY: 0 })
|
||||
|
||||
/** 浮窗尺寸:桌面端(宽屏 >= 900px)280x180px,移动端 / 窄屏 160x100px */
|
||||
const getDefaultSize = () => {
|
||||
// #ifdef H5
|
||||
const isDesktop = typeof window !== 'undefined' && window.innerWidth >= 900
|
||||
return isDesktop ? { width: 280, height: 180 } : { width: 160, height: 100 }
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
return { width: 160, height: 100 }
|
||||
// #endif
|
||||
}
|
||||
const SIZE = getDefaultSize()
|
||||
const EDGE_MARGIN = 24
|
||||
|
||||
const positionStyle = computed(() => ({
|
||||
transform: `translate(${position.value.x}px, ${position.value.y}px)`,
|
||||
width: `${SIZE.width}px`,
|
||||
height: `${SIZE.height}px`,
|
||||
transition: isDragging.value ? 'none' : 'transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1)'
|
||||
}))
|
||||
|
||||
// ============ 计算父容器边界 ============
|
||||
|
||||
const getBounds = () => {
|
||||
// #ifdef H5
|
||||
const el = resolveDom(props.container) || document.body
|
||||
const rect = el.getBoundingClientRect()
|
||||
return {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: rect.width,
|
||||
bottom: rect.height
|
||||
}
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
return { left: 0, top: 0, right: 320, bottom: 240 }
|
||||
// #endif
|
||||
}
|
||||
|
||||
const resolveDom = (r) => {
|
||||
// #ifdef H5
|
||||
if (!r) return null
|
||||
if (r.$el) return r.$el
|
||||
if (r instanceof HTMLElement) return r
|
||||
// #endif
|
||||
return null
|
||||
}
|
||||
|
||||
/** 初始化位置:右下角 */
|
||||
const placeDefault = () => {
|
||||
const b = getBounds()
|
||||
position.value = {
|
||||
x: b.right - SIZE.width - EDGE_MARGIN,
|
||||
y: b.bottom - SIZE.height - EDGE_MARGIN
|
||||
}
|
||||
}
|
||||
|
||||
/** 松手后吸附到最近四角 */
|
||||
const snapToCorner = () => {
|
||||
const b = getBounds()
|
||||
const cx = position.value.x + SIZE.width / 2
|
||||
const cy = position.value.y + SIZE.height / 2
|
||||
const centers = [
|
||||
// 左上
|
||||
{ x: EDGE_MARGIN, y: EDGE_MARGIN },
|
||||
// 右上
|
||||
{ x: b.right - SIZE.width - EDGE_MARGIN, y: EDGE_MARGIN },
|
||||
// 左下
|
||||
{ x: EDGE_MARGIN, y: b.bottom - SIZE.height - EDGE_MARGIN },
|
||||
// 右下
|
||||
{ x: b.right - SIZE.width - EDGE_MARGIN, y: b.bottom - SIZE.height - EDGE_MARGIN }
|
||||
]
|
||||
let best = centers[3]
|
||||
let min = Infinity
|
||||
for (const c of centers) {
|
||||
const ccx = c.x + SIZE.width / 2
|
||||
const ccy = c.y + SIZE.height / 2
|
||||
const d = (cx - ccx) ** 2 + (cy - ccy) ** 2
|
||||
if (d < min) {
|
||||
min = d
|
||||
best = c
|
||||
}
|
||||
}
|
||||
position.value = best
|
||||
}
|
||||
|
||||
// ============ 拖拽交互 ============
|
||||
|
||||
const getPoint = (e) => {
|
||||
// #ifdef H5
|
||||
if (e.touches && e.touches.length > 0) {
|
||||
return { x: e.touches[0].clientX, y: e.touches[0].clientY }
|
||||
}
|
||||
return { x: e.clientX, y: e.clientY }
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
return { x: 0, y: 0 }
|
||||
// #endif
|
||||
}
|
||||
|
||||
const onDragStart = (e) => {
|
||||
// #ifdef H5
|
||||
if (e.target && e.target.closest && e.target.closest('.pin-btn')) return
|
||||
const pt = getPoint(e)
|
||||
dragStart.value = {
|
||||
pointerX: pt.x,
|
||||
pointerY: pt.y,
|
||||
startX: position.value.x,
|
||||
startY: position.value.y
|
||||
}
|
||||
isDragging.value = true
|
||||
window.addEventListener('mousemove', onDragMove, { passive: false })
|
||||
window.addEventListener('touchmove', onDragMove, { passive: false })
|
||||
window.addEventListener('mouseup', onDragEnd)
|
||||
window.addEventListener('touchend', onDragEnd)
|
||||
window.addEventListener('touchcancel', onDragEnd)
|
||||
// 禁用文字选中
|
||||
document.body.style.userSelect = 'none'
|
||||
e.preventDefault()
|
||||
// #endif
|
||||
}
|
||||
|
||||
const onDragMove = (e) => {
|
||||
// #ifdef H5
|
||||
if (!isDragging.value) return
|
||||
const pt = getPoint(e)
|
||||
const dx = pt.x - dragStart.value.pointerX
|
||||
const dy = pt.y - dragStart.value.pointerY
|
||||
const b = getBounds()
|
||||
// Clamp 在 bounds 内,防止浮窗被拖出视频区域
|
||||
const nx = Math.max(0, Math.min(dragStart.value.startX + dx, b.right - SIZE.width))
|
||||
const ny = Math.max(0, Math.min(dragStart.value.startY + dy, b.bottom - SIZE.height))
|
||||
position.value = { x: nx, y: ny }
|
||||
if (e.cancelable) e.preventDefault()
|
||||
// #endif
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
// #ifdef H5
|
||||
if (!isDragging.value) return
|
||||
isDragging.value = false
|
||||
window.removeEventListener('mousemove', onDragMove)
|
||||
window.removeEventListener('touchmove', onDragMove)
|
||||
window.removeEventListener('mouseup', onDragEnd)
|
||||
window.removeEventListener('touchend', onDragEnd)
|
||||
window.removeEventListener('touchcancel', onDragEnd)
|
||||
document.body.style.userSelect = ''
|
||||
snapToCorner()
|
||||
// #endif
|
||||
}
|
||||
|
||||
// ============ 容器尺寸变化 ============
|
||||
|
||||
let _resizeObserver = null
|
||||
|
||||
const onContainerResize = () => {
|
||||
// 重新 clamp + 吸附到当前象限对应的四角
|
||||
const b = getBounds()
|
||||
if (position.value.x > b.right - SIZE.width - EDGE_MARGIN ||
|
||||
position.value.y > b.bottom - SIZE.height - EDGE_MARGIN) {
|
||||
snapToCorner()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.container, () => {
|
||||
// container 引用变化时重新定位
|
||||
nextTick(placeDefault)
|
||||
})
|
||||
|
||||
const onPinClick = () => {
|
||||
emit('pin-click')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(placeDefault)
|
||||
// #ifdef H5
|
||||
window.addEventListener('resize', onContainerResize)
|
||||
if (typeof ResizeObserver !== 'undefined' && props.container) {
|
||||
const el = resolveDom(props.container)
|
||||
if (el) {
|
||||
_resizeObserver = new ResizeObserver(onContainerResize)
|
||||
_resizeObserver.observe(el)
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// #ifdef H5
|
||||
window.removeEventListener('resize', onContainerResize)
|
||||
if (_resizeObserver) {
|
||||
_resizeObserver.disconnect()
|
||||
_resizeObserver = null
|
||||
}
|
||||
// 兜底:组件卸载时仍在拖拽中则强制清理 window 监听
|
||||
if (isDragging.value) {
|
||||
onDragEnd()
|
||||
}
|
||||
// #endif
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.float-shell {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 120;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 12rpx 32rpx rgba(0, 0, 0, 0.45);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.1);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
/* transform 直接由 :style 驱动,不再设置 transition(由 isDragging 控制) */
|
||||
}
|
||||
.float-shell.dragging {
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 16rpx 40rpx rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.pin-btn {
|
||||
position: absolute;
|
||||
top: 8rpx;
|
||||
right: 8rpx;
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
.pin-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.float-shell { transition: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,11 @@
|
||||
<!--
|
||||
会议视频网格(Task 11)
|
||||
会议视频网格(Task 11 / Task 15 柔性网格升级)
|
||||
|
||||
职责:
|
||||
- 根据成员数量自适应网格布局(1/2/3-4/5-6/7-9/10+)
|
||||
- 手机端竖排为主,桌面端以 16:9 单元格填充
|
||||
- 根据成员数量自适应网格布局(1/2/3/4-9/10+)
|
||||
- Task 15 原创特色 2:2 人左右 65/35 非等分;3 人「大+两个小」三角布局;4+ 人维持等分
|
||||
- 本地 tile 由上层 SelfVideoFloat 承接浮窗,当传入 tiles 仅含远端时 layout-* 数字即可对应视觉人数
|
||||
- 入会滑入动效:新成员 tile 挂载时向上位移 + 渐显(Task 15 原创特色 5)
|
||||
- 不关心业务数据,仅负责排版;视频块由 slot #tile 自定义渲染,保持数据与组件解耦
|
||||
-->
|
||||
<template>
|
||||
@@ -13,6 +15,7 @@
|
||||
v-for="(tile, idx) in tiles"
|
||||
:key="tile.key || tile.userId || idx"
|
||||
class="grid-cell"
|
||||
:class="cellClassFor(idx)"
|
||||
>
|
||||
<slot name="tile" :tile="tile" :index="idx" />
|
||||
</view>
|
||||
@@ -30,28 +33,72 @@ const props = defineProps({
|
||||
tiles: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
/** 根据成员数量计算列数 */
|
||||
const columns = computed(() => {
|
||||
/**
|
||||
* 布局档位:按成员数量决定非等分策略
|
||||
* - 1: 单屏
|
||||
* - 2: 左右 65/35
|
||||
* - 3: 左大 + 右两叠
|
||||
* - 4-9: 3×3 等分兜底
|
||||
* - 10+: sqrt 自适应
|
||||
*/
|
||||
const layoutClass = computed(() => {
|
||||
const n = props.tiles.length
|
||||
if (n <= 1) return 1
|
||||
if (n <= 4) return 2
|
||||
if (n <= 9) return 3
|
||||
if (n <= 16) return 4
|
||||
return Math.ceil(Math.sqrt(n))
|
||||
if (n === 0) return 'layout-empty'
|
||||
if (n === 1) return 'layout-1'
|
||||
if (n === 2) return 'layout-2-flex'
|
||||
if (n === 3) return 'layout-3-tri'
|
||||
if (n <= 9) return 'layout-grid-3'
|
||||
return 'layout-grid-sqrt'
|
||||
})
|
||||
|
||||
const rows = computed(() => {
|
||||
/**
|
||||
* grid-template 计算:
|
||||
* - layout-2-flex 用 grid-template-columns: 65fr 35fr(单行)
|
||||
* - layout-3-tri 用 2 列,左列 1 行、右列 2 行(cell 手动控制 span)
|
||||
* - layout-grid-3 同现有逻辑(3 列)
|
||||
* - layout-grid-sqrt 同现有逻辑(sqrt 列)
|
||||
*/
|
||||
const gridStyle = computed(() => {
|
||||
const n = props.tiles.length
|
||||
if (!n) return 1
|
||||
return Math.ceil(n / columns.value)
|
||||
if (n === 2) {
|
||||
return {
|
||||
'grid-template-columns': '65fr 35fr',
|
||||
'grid-template-rows': '1fr'
|
||||
}
|
||||
}
|
||||
if (n === 3) {
|
||||
return {
|
||||
'grid-template-columns': '60fr 40fr',
|
||||
'grid-template-rows': '1fr 1fr'
|
||||
}
|
||||
}
|
||||
if (n === 1) {
|
||||
return {
|
||||
'grid-template-columns': '1fr',
|
||||
'grid-template-rows': '1fr'
|
||||
}
|
||||
}
|
||||
if (n <= 9) {
|
||||
const cols = n <= 4 ? 2 : 3
|
||||
const rows = Math.ceil(n / cols)
|
||||
return {
|
||||
'grid-template-columns': `repeat(${cols}, minmax(0, 1fr))`,
|
||||
'grid-template-rows': `repeat(${rows}, minmax(0, 1fr))`
|
||||
}
|
||||
}
|
||||
const cols = Math.ceil(Math.sqrt(n))
|
||||
const rows = Math.ceil(n / cols)
|
||||
return {
|
||||
'grid-template-columns': `repeat(${cols}, minmax(0, 1fr))`,
|
||||
'grid-template-rows': `repeat(${rows}, minmax(0, 1fr))`
|
||||
}
|
||||
})
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
'grid-template-columns': `repeat(${columns.value}, minmax(0, 1fr))`,
|
||||
'grid-template-rows': `repeat(${rows.value}, minmax(0, 1fr))`
|
||||
}))
|
||||
|
||||
const layoutClass = computed(() => `layout-${Math.min(props.tiles.length, 10)}`)
|
||||
/** 单元格特殊 class:3 人三角布局里,首个 tile 左列跨 2 行 */
|
||||
const cellClassFor = (idx) => {
|
||||
if (props.tiles.length === 3 && idx === 0) return 'cell-span-row-2'
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -76,6 +123,12 @@ const layoutClass = computed(() => `layout-${Math.min(props.tiles.length, 10)}`)
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
animation: tile-slide-in 0.28s cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
}
|
||||
|
||||
/* 3 人布局:首个 tile 左列占据整列高度(2 行) */
|
||||
.cell-span-row-2 {
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.empty {
|
||||
@@ -89,12 +142,32 @@ const layoutClass = computed(() => `layout-${Math.min(props.tiles.length, 10)}`)
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
/* 移动端单人时占满整屏 */
|
||||
@media (max-width: 640px) {
|
||||
.grid.layout-1 { grid-template-rows: 1fr !important; }
|
||||
.grid.layout-2 {
|
||||
/* Task 15 原创特色 5:入会滑入动效 */
|
||||
@keyframes tile-slide-in {
|
||||
from { transform: translateY(16rpx); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* 移动端(小屏):柔性布局回退为纵向等分,避免 65/35 在窄屏上过度挤压 */
|
||||
@media (max-width: 750px) {
|
||||
.grid.layout-2-flex {
|
||||
grid-template-columns: 1fr !important;
|
||||
grid-template-rows: 1fr 1fr !important;
|
||||
}
|
||||
.grid.layout-3-tri {
|
||||
grid-template-columns: 1fr !important;
|
||||
grid-template-rows: 1fr 1fr 1fr !important;
|
||||
}
|
||||
.cell-span-row-2 {
|
||||
grid-row: span 1;
|
||||
}
|
||||
.grid.layout-1 {
|
||||
grid-template-rows: 1fr !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 可访问性:系统偏好减少动效时禁用滑入 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.grid-cell { animation: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -165,10 +165,40 @@ onBeforeUnmount(() => {
|
||||
transition: box-shadow 0.18s ease, transform 0.18s ease;
|
||||
border: 2rpx solid transparent;
|
||||
}
|
||||
/* Fallback:不支持 @property 的浏览器(Safari 17-)降级为静态蓝色描边 */
|
||||
.tile.speaking {
|
||||
border-color: #3B82F6;
|
||||
box-shadow: 0 0 0 2rpx rgba(59, 130, 246, 0.18), 0 0 28rpx rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
|
||||
/* EchoChat 原创特色:说话者流光轮廓
|
||||
通过 @property 声明可动画化的自定义属性 --flow-angle,
|
||||
驱动 conic-gradient 环绕旋转;双层 background 方案:
|
||||
- padding-box:实色填充视频块内容
|
||||
- border-box:渐变仅生效于 border 区域,形成霓虹光圈效果 */
|
||||
@supports (background: conic-gradient(from 0deg, red, blue)) and (--flow-angle: 0deg) {
|
||||
@property --flow-angle {
|
||||
syntax: '<angle>';
|
||||
initial-value: 0deg;
|
||||
inherits: false;
|
||||
}
|
||||
.tile.speaking {
|
||||
border-color: transparent;
|
||||
background:
|
||||
linear-gradient(#0B1220, #0B1220) padding-box,
|
||||
conic-gradient(from var(--flow-angle), #10B981, #2563EB, #6366F1, #F59E0B, #10B981) border-box;
|
||||
box-shadow: 0 0 24rpx rgba(99, 102, 241, 0.45);
|
||||
animation: flow-rotate 3s linear infinite;
|
||||
}
|
||||
}
|
||||
@keyframes flow-rotate {
|
||||
to { --flow-angle: 360deg; }
|
||||
}
|
||||
|
||||
/* 可访问性:尊重系统减少动画偏好 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tile.speaking { animation: none; }
|
||||
}
|
||||
.tile.local::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
||||
@@ -188,7 +188,13 @@ const stopAudioMeter = () => {
|
||||
const startPreview = async () => {
|
||||
// #ifdef H5
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
permissionError.value = '当前浏览器不支持音视频预览(需 HTTPS + WebRTC)'
|
||||
// secure context 检查:大多数浏览器只在 HTTPS / localhost 下暴露 mediaDevices
|
||||
if (typeof window !== 'undefined' && window.isSecureContext === false) {
|
||||
const host = window.location?.host || ''
|
||||
permissionError.value = `当前为不安全连接,浏览器禁止访问摄像头/麦克风。请改用 HTTPS 访问:https://${host}(自签证书首次访问选择"仍然继续"即可)`
|
||||
} else {
|
||||
permissionError.value = '当前浏览器不支持音视频预览(需 HTTPS + WebRTC)'
|
||||
}
|
||||
return
|
||||
}
|
||||
stopPreviewStream()
|
||||
@@ -339,15 +345,33 @@ const onJoin = async () => {
|
||||
startVideo: mediaPrefs.startVideo
|
||||
}
|
||||
|
||||
// 释放预览流,避免与 mediasoup produce 争用摄像头(浏览器单轨互斥)
|
||||
stopPreviewStream()
|
||||
// iOS Safari/Chrome 上 preview 释放摄像头后 room 再次 getUserMedia 常触发 NotReadableError,
|
||||
// 改为把预览页已申请的 track 直接复用给 mediasoup produce。
|
||||
// 此处只停 AudioContext 分析器,不 stop previewStream,track 所有权随之转给 MediaEngine.produce。
|
||||
stopAudioMeter()
|
||||
|
||||
const rawAudioTrack = previewStream ? previewStream.getAudioTracks()[0] || null : null
|
||||
const rawVideoTrack = previewStream ? previewStream.getVideoTracks()[0] || null : null
|
||||
// 若用户关掉了"默认开麦/默认开摄像头",对应 track 不会被 mediasoup 接手,立即 stop 避免硬件泄漏
|
||||
if (rawAudioTrack && !mediaPrefs.startAudio) {
|
||||
try { rawAudioTrack.stop() } catch {}
|
||||
}
|
||||
if (rawVideoTrack && !mediaPrefs.startVideo) {
|
||||
try { rawVideoTrack.stop() } catch {}
|
||||
}
|
||||
const audioTrack = mediaPrefs.startAudio ? rawAudioTrack : null
|
||||
const videoTrack = mediaPrefs.startVideo ? rawVideoTrack : null
|
||||
// previewStream 对象本身不再需要持有,tracks 已取出交给 MediaEngine;
|
||||
// 避免在后续 onUnload 中误调 stopPreviewStream 把 track 给 stop 掉。
|
||||
previewStream = null
|
||||
|
||||
const prefs = {
|
||||
startAudio: mediaPrefs.startAudio,
|
||||
startVideo: mediaPrefs.startVideo,
|
||||
audioDeviceId: selectedAudioId.value,
|
||||
videoDeviceId: selectedVideoId.value
|
||||
videoDeviceId: selectedVideoId.value,
|
||||
audioTrack,
|
||||
videoTrack
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -367,6 +391,9 @@ const onJoin = async () => {
|
||||
const msg = err?.message || JSON.stringify(err)
|
||||
uni.showToast({ title: `加入失败:${msg}`, icon: 'none', duration: 3500 })
|
||||
console.error('[Preview] 加入会议失败', err)
|
||||
// 入会失败:track 尚未转给 mediasoup 或刚拿到就失败,显式 stop 避免摄像头泄漏
|
||||
try { audioTrack && audioTrack.stop && audioTrack.stop() } catch {}
|
||||
try { videoTrack && videoTrack.stop && videoTrack.stop() } catch {}
|
||||
} finally {
|
||||
joining.value = false
|
||||
}
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
|
||||
<!-- 主区:左视频 + 右侧聊天面板(仅 chatVisible 时侧栏展开) -->
|
||||
<view class="body">
|
||||
<view class="video-col">
|
||||
<VideoGrid :tiles="tiles">
|
||||
<view ref="videoCol" class="video-col">
|
||||
<VideoGrid :tiles="gridTiles">
|
||||
<template #tile="{ tile }">
|
||||
<VideoTile
|
||||
:user-id="tile.userId"
|
||||
@@ -48,9 +48,25 @@
|
||||
:video-enabled="tile.videoEnabled"
|
||||
:audio-track="tile.audioTrack"
|
||||
:video-track="tile.videoTrack"
|
||||
:is-speaking="!!speakingMap[tile.userId]"
|
||||
/>
|
||||
</template>
|
||||
</VideoGrid>
|
||||
|
||||
<!-- 自视频浮窗(Task 15):仅桌面端浮窗模式开启时显示;图钉切换后本地 tile 回到网格里 -->
|
||||
<SelfVideoFloat
|
||||
v-if="floatEnabled && selfTile"
|
||||
:user-id="selfTile.userId"
|
||||
:name="selfTile.name"
|
||||
:is-host="selfTile.isHost"
|
||||
:audio-enabled="selfTile.audioEnabled"
|
||||
:video-enabled="selfTile.videoEnabled"
|
||||
:audio-track="selfTile.audioTrack"
|
||||
:video-track="selfTile.videoTrack"
|
||||
:is-speaking="!!speakingMap[selfTile.userId]"
|
||||
:container="videoCol"
|
||||
@pin-click="togglePin"
|
||||
/>
|
||||
</view>
|
||||
<view class="chat-col" :class="{ open: chatVisible }">
|
||||
<ChatPanel
|
||||
@@ -77,6 +93,7 @@
|
||||
:member-count="tiles.length"
|
||||
:unread-chat-count="unreadChatCount"
|
||||
:allow-chat="allowChat"
|
||||
:all-muted="allMuted"
|
||||
@mic-toggle="onMicToggle"
|
||||
@cam-toggle="onCamToggle"
|
||||
@invite="openInvite"
|
||||
@@ -96,6 +113,7 @@
|
||||
@close="memberVisible = false"
|
||||
@kick="onKickMember"
|
||||
@transfer-host="onTransferHost"
|
||||
@mute-member="onMuteMember"
|
||||
/>
|
||||
|
||||
<!-- 邀请弹窗 -->
|
||||
@@ -149,6 +167,7 @@ import MemberPanel from '@/components/meeting/MemberPanel.vue'
|
||||
import InviteDialog from '@/components/meeting/InviteDialog.vue'
|
||||
import NetworkBadge from '@/components/meeting/NetworkBadge.vue'
|
||||
import ChatPanel from '@/components/meeting/ChatPanel.vue'
|
||||
import SelfVideoFloat from '@/components/meeting/SelfVideoFloat.vue'
|
||||
|
||||
const meetingStore = useMeetingStore()
|
||||
const userStore = useUserStore()
|
||||
@@ -160,11 +179,20 @@ const leaveDialogVisible = ref(false)
|
||||
const audioLoading = ref(false)
|
||||
const videoLoading = ref(false)
|
||||
const chatLoadingMore = ref(false)
|
||||
const networkLevel = ref(4) // Task 15 接入真实 getStats 后动态更新
|
||||
const networkLevel = ref(4) // 持续接入 getStats 后动态更新(Task 16 进一步收敛)
|
||||
const startedAt = ref(0)
|
||||
const nowTs = ref(Date.now())
|
||||
let timerHandle = null
|
||||
|
||||
/** 视频区容器引用:传给 SelfVideoFloat 计算吸附边界 */
|
||||
const videoCol = ref(null)
|
||||
|
||||
/** 说话者映射由 meeting store 探测出(Task 15),浮窗和网格都订阅它来驱动流光动效 */
|
||||
const speakingMap = computed(() => meetingStore.speakingMap)
|
||||
|
||||
/** 全员静音标记,用于 Task 15 工具栏冷色氛围(允许单人时恒为 false) */
|
||||
const allMuted = computed(() => meetingStore.isAllMuted)
|
||||
|
||||
// ============ 基础映射 ============
|
||||
|
||||
const myUserId = computed(() => userStore.userInfo?.id || 0)
|
||||
@@ -307,23 +335,28 @@ const tiles = computed(() => {
|
||||
participants.value.forEach(p => {
|
||||
if (!p.is_active && p.is_active !== undefined) return
|
||||
const isLocal = p.user_id === myUid
|
||||
let audioTrack = null
|
||||
let videoTrack = null
|
||||
if (isLocal) {
|
||||
audioTrack = meetingStore.getLocalTrack('audio')
|
||||
videoTrack = meetingStore.getLocalTrack('video')
|
||||
} else {
|
||||
audioTrack = meetingStore.getRemoteTrack(p.user_id, 'audio')
|
||||
videoTrack = meetingStore.getRemoteTrack(p.user_id, 'video')
|
||||
}
|
||||
// Task 15 回归修复:tile 是否渲染 <video>/<audio> 以 track 实际存在为准
|
||||
// 参会者的 audio_enabled/video_enabled 仅作为 MemberPanel 状态图标色彩依据,
|
||||
// 不再决定 VideoTile 媒体元素的挂载;否则一旦 state 字段滞后就会屏蔽实际画面
|
||||
const tile = {
|
||||
key: `u_${p.user_id}`,
|
||||
userId: p.user_id,
|
||||
name: isLocal ? '你' : nameOf(p.user_id),
|
||||
isLocal,
|
||||
isHost: p.user_id === hostId.value,
|
||||
audioEnabled: isLocal ? audioEnabled.value : (p.audio_enabled !== false),
|
||||
videoEnabled: isLocal ? videoEnabled.value : (p.video_enabled !== false),
|
||||
audioTrack: null,
|
||||
videoTrack: null
|
||||
}
|
||||
if (isLocal) {
|
||||
tile.audioTrack = meetingStore.getLocalTrack('audio')
|
||||
tile.videoTrack = meetingStore.getLocalTrack('video')
|
||||
} else {
|
||||
tile.audioTrack = meetingStore.getRemoteTrack(p.user_id, 'audio')
|
||||
tile.videoTrack = meetingStore.getRemoteTrack(p.user_id, 'video')
|
||||
audioEnabled: isLocal ? audioEnabled.value : !!audioTrack,
|
||||
videoEnabled: isLocal ? videoEnabled.value : !!videoTrack,
|
||||
audioTrack,
|
||||
videoTrack
|
||||
}
|
||||
list.push(tile)
|
||||
})
|
||||
@@ -344,6 +377,36 @@ const tiles = computed(() => {
|
||||
return list
|
||||
})
|
||||
|
||||
// ============ Task 15 浮窗模式 ============
|
||||
|
||||
/**
|
||||
* 浮窗开关:从 store.uiPrefs 读取,默认桌面端开启浮窗
|
||||
* 点击图钉按钮时 togglePin 切换,本地 tile 会在浮窗 <-> 网格间迁移
|
||||
*/
|
||||
const floatEnabled = computed(() => meetingStore.uiPrefs?.selfVideoFloat !== false)
|
||||
|
||||
/**
|
||||
* 自视频 tile:浮窗模式下从 tiles 中剥离出来单独挂载
|
||||
* 若 floatEnabled=false 则返回 null,自视频仍保留在 Grid 里
|
||||
*/
|
||||
const selfTile = computed(() => {
|
||||
if (!floatEnabled.value) return null
|
||||
return tiles.value.find(t => t.isLocal) || null
|
||||
})
|
||||
|
||||
/**
|
||||
* 网格 tiles:浮窗开启时剔除本地 tile(只渲染远端),否则保持原始
|
||||
* 这样 VideoGrid 的 layout-1/2/3/N 档位判断基于远端人数,符合用户认知
|
||||
*/
|
||||
const gridTiles = computed(() => {
|
||||
if (!floatEnabled.value) return tiles.value
|
||||
return tiles.value.filter(t => !t.isLocal)
|
||||
})
|
||||
|
||||
const togglePin = () => {
|
||||
meetingStore.uiPrefs.selfVideoFloat = !meetingStore.uiPrefs.selfVideoFloat
|
||||
}
|
||||
|
||||
// ============ 事件处理 ============
|
||||
|
||||
const onMicToggle = async () => {
|
||||
@@ -439,6 +502,15 @@ const onKickMember = async (uid) => {
|
||||
}
|
||||
}
|
||||
|
||||
const onMuteMember = async ({ userId: uid, mute }) => {
|
||||
try {
|
||||
await meetingStore.muteMember(uid, mute)
|
||||
uni.showToast({ title: mute ? '已请对方静音' : '已请对方开麦', icon: 'none' })
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err?.message || '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const onTransferHost = async (uid) => {
|
||||
const confirmed = await new Promise(resolve => {
|
||||
uni.showModal({
|
||||
|
||||
@@ -13,7 +13,21 @@
|
||||
import { BASE_URL } from '@/utils/request'
|
||||
import { getToken } from '@/utils/storage'
|
||||
|
||||
const WS_BASE = BASE_URL.replace(/^http/, 'ws')
|
||||
// WebSocket 基础地址推导:
|
||||
// - 若 BASE_URL 非空(生产 CDN 直连场景),将其协议从 http(s) 替换成 ws(s)
|
||||
// - 若 BASE_URL 为空(开发 vite proxy / 生产同源 Nginx),用当前页面 origin 推导同源 ws(s)
|
||||
// 这样 WebSocket 天然随页面协议走:HTTPS 页面用 wss,HTTP 页面用 ws,避免混合内容被浏览器阻断
|
||||
const resolveWsBase = () => {
|
||||
if (BASE_URL) return BASE_URL.replace(/^http/, 'ws')
|
||||
// #ifdef H5
|
||||
if (typeof window !== 'undefined' && window.location) {
|
||||
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${wsProto}//${window.location.host}`
|
||||
}
|
||||
// #endif
|
||||
return ''
|
||||
}
|
||||
const WS_BASE = resolveWsBase()
|
||||
const HEARTBEAT_INTERVAL = 30000
|
||||
const RECONNECT_BASE_DELAY = 1000
|
||||
const RECONNECT_MAX_DELAY = 30000
|
||||
|
||||
@@ -143,6 +143,31 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
/** MediaEngine 实例(H5 才有值),用 markRaw 包裹避免 Vue 深度代理 */
|
||||
let _engine = null
|
||||
|
||||
/**
|
||||
* 说话者探测:{ [userId]: boolean },由 UI 订阅驱动 VideoTile.speaking 边框
|
||||
* Task 15 原创特色 1——EchoChat 会议专属流光轮廓
|
||||
*/
|
||||
const speakingMap = reactive({})
|
||||
|
||||
/**
|
||||
* UI 偏好(内存态,不持久化到 storage)
|
||||
* - selfVideoFloat: 自视频是否走浮窗模式(桌面端默认 true)
|
||||
*/
|
||||
const uiPrefs = reactive({
|
||||
selfVideoFloat: true
|
||||
})
|
||||
|
||||
/**
|
||||
* 说话者探测内部状态:handle + 本地 WebAudio 实例
|
||||
* 所有字段均为 H5 浏览器 API,非 H5 平台永远保持 null
|
||||
*/
|
||||
let _speakingTimer = null
|
||||
let _localAudioCtx = null
|
||||
let _localAnalyser = null
|
||||
let _localSource = null
|
||||
/** { [userId]: { inCount: number, outCount: number } },用于防抖 1-in / 2-out */
|
||||
const _speakingDebounce = {}
|
||||
|
||||
// ==================== Getters ====================
|
||||
|
||||
const isInMeeting = computed(() => {
|
||||
@@ -162,6 +187,23 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
return participants.value.filter(p => p.is_active)
|
||||
})
|
||||
|
||||
/**
|
||||
* 全员静音标记:用于 Task 15 原创特色 4——静音氛围色
|
||||
* 规则:仅当参与者 ≥ 2 人且所有人 audio_enabled=false 时 true
|
||||
* 单人会议不触发(独自一人不算"全员静音氛围")
|
||||
*/
|
||||
const isAllMuted = computed(() => {
|
||||
const list = activeParticipants.value
|
||||
if (list.length < 2) return false
|
||||
const userStore = useUserStore()
|
||||
const myUid = userStore.userInfo?.id
|
||||
return list.every(p => {
|
||||
// 本地 audio 以 localAudioEnabled 为准(后端不会给自己广播 state.changed)
|
||||
if (myUid && p.user_id === myUid) return !localAudioEnabled.value
|
||||
return p.audio_enabled === false
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== 私有工具 ====================
|
||||
|
||||
const _log = (level, ...args) => {
|
||||
@@ -236,22 +278,63 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
lastEndedReason.value = data.reason || ''
|
||||
_log('info', '[Meeting] 会议已结束', data.reason, MEETING_ENDED_REASON_LABEL[data.reason])
|
||||
_cleanupMedia()
|
||||
// 清理 producer/consumer 索引与本地开关,防止下次 createAndEnter/joinAndEnter 时
|
||||
// startLocalAudio/Video 因 `if (localProducers.audio) return` 被残留 ID 拦下,
|
||||
// 表现为"重新发起会议后看不到自己的画面"
|
||||
localProducers.audio = null
|
||||
localProducers.video = null
|
||||
localAudioEnabled.value = false
|
||||
localVideoEnabled.value = false
|
||||
Object.keys(remoteConsumers).forEach(k => delete remoteConsumers[k])
|
||||
localState.value = MEETING_LOCAL_STATE_ENDED
|
||||
_unregisterListeners()
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并 REST getRoom 返回的 participants 与当前内存状态
|
||||
* - REST DTO 目前不包含 audio_enabled/video_enabled(后端 DTO 未暴露该字段)
|
||||
* - WS 广播驱动的内存状态(p.audio_enabled / p.video_enabled)可能已更新;REST 覆盖会回退
|
||||
* - 策略:以 REST 返回的静态属性为主,保留本地已知的音视频开关状态;首次未知时留 undefined
|
||||
* 交由 state.changed 广播权威填充,避免误将"未知"冲成 false 引起 UI 错判
|
||||
*/
|
||||
const _mergeParticipantsFromRest = (restList) => {
|
||||
const byUid = new Map()
|
||||
participants.value.forEach((p) => byUid.set(p.user_id, p))
|
||||
return restList.map((p) => {
|
||||
const prev = byUid.get(p.user_id)
|
||||
const merged = { ...p }
|
||||
if (typeof p.audio_enabled !== 'boolean' && prev && typeof prev.audio_enabled === 'boolean') {
|
||||
merged.audio_enabled = prev.audio_enabled
|
||||
}
|
||||
if (typeof p.video_enabled !== 'boolean' && prev && typeof prev.video_enabled === 'boolean') {
|
||||
merged.video_enabled = prev.video_enabled
|
||||
}
|
||||
return merged
|
||||
})
|
||||
}
|
||||
|
||||
const _onMemberJoined = (msg) => {
|
||||
const data = msg.data || {}
|
||||
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||
// 后端广播目前只带基础字段,完整 DTO 通过 GET /rooms/:code 二次拉取
|
||||
if (!participants.value.find(p => p.user_id === data.user_id)) {
|
||||
participants.value.push({
|
||||
user_id: data.user_id,
|
||||
joined_at: data.joined_at,
|
||||
is_active: true,
|
||||
role: 0
|
||||
})
|
||||
// 后端广播已带 user_name/user_avatar,直接补齐昵称头像字段即可
|
||||
const existed = participants.value.find(p => p.user_id === data.user_id)
|
||||
if (existed) {
|
||||
if (data.user_name) existed.user_name = data.user_name
|
||||
if (data.user_avatar) existed.user_avatar = data.user_avatar
|
||||
existed.is_active = true
|
||||
return
|
||||
}
|
||||
// Task 15:新入会者的 audio_enabled/video_enabled 保持 undefined,
|
||||
// 等 startLocalAudio/Video 成功后的 meeting.member.state.changed 广播权威填充
|
||||
// MemberPanel 对 undefined 按 !p.audio_enabled 解读为 off(灰),语义"尚未开启"与预期一致
|
||||
participants.value.push({
|
||||
user_id: data.user_id,
|
||||
user_name: data.user_name || '',
|
||||
user_avatar: data.user_avatar || '',
|
||||
joined_at: data.joined_at,
|
||||
is_active: true,
|
||||
role: 0
|
||||
})
|
||||
}
|
||||
|
||||
const _onMemberLeft = (msg) => {
|
||||
@@ -268,10 +351,39 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
const _onMemberStateChanged = (msg) => {
|
||||
const data = msg.data || {}
|
||||
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||
const p = participants.value.find(p => p.user_id === data.user_id)
|
||||
if (p) {
|
||||
if (typeof data.audio_enabled === 'boolean') p.audio_enabled = data.audio_enabled
|
||||
if (typeof data.video_enabled === 'boolean') p.video_enabled = data.video_enabled
|
||||
let p = participants.value.find(p => p.user_id === data.user_id)
|
||||
if (!p) {
|
||||
// 时序兜底:OnRoomJoin 触发的 existing state.changed 可能早于 REST getRoom 返回
|
||||
// 此时 participants 里还没有 data.user_id 条目。建个占位,等后续 REST / member.joined
|
||||
// 在 _mergeParticipantsFromRest 里补齐 user_name/avatar 即可,本处先把状态保留住
|
||||
p = {
|
||||
user_id: data.user_id,
|
||||
user_name: '',
|
||||
user_avatar: '',
|
||||
is_active: true,
|
||||
role: 0
|
||||
}
|
||||
participants.value.push(p)
|
||||
}
|
||||
if (typeof data.audio_enabled === 'boolean') p.audio_enabled = data.audio_enabled
|
||||
if (typeof data.video_enabled === 'boolean') p.video_enabled = data.video_enabled
|
||||
|
||||
// Task 15:主持人通过 meeting.member.state.changed 带 target_user_id 指令静音他人,
|
||||
// 后端广播字段名参见 meeting_signal_service.OnMemberStateChanged:
|
||||
// data.user_id = 被操作者(即当前收到广播的用户)
|
||||
// data.changed_by = 发起操作者(主持人)
|
||||
const userStore = useUserStore()
|
||||
const myUid = userStore.userInfo?.id
|
||||
const isTargetMe = myUid && data.user_id === myUid
|
||||
const isByOthers = data.changed_by && data.changed_by !== myUid
|
||||
if (isTargetMe && isByOthers && typeof data.audio_enabled === 'boolean' && !data.audio_enabled) {
|
||||
// 被主持人请求静音 → 立即关闭本地 audio producer
|
||||
if (localAudioEnabled.value) {
|
||||
stopLocalAudio().catch((err) => _log('warn', '[Meeting] 被动静音失败', err))
|
||||
}
|
||||
// #ifdef H5
|
||||
try { uni.showToast({ title: '主持人请你静音', icon: 'none', duration: 2000 }) } catch {}
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,7 +518,7 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
try {
|
||||
const detail = await meetingApi.getRoom(roomCode)
|
||||
if (detail && Array.isArray(detail.participants)) {
|
||||
participants.value = detail.participants
|
||||
participants.value = _mergeParticipantsFromRest(detail.participants)
|
||||
}
|
||||
} catch (e) {
|
||||
_log('warn', '[Meeting] 重绑定后拉取房间详情失败', e)
|
||||
@@ -421,20 +533,30 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
|
||||
// ==================== 远端媒体清理 ====================
|
||||
|
||||
/**
|
||||
* 精确关闭"某用户某个 producer 对应的 consumer"
|
||||
*
|
||||
* mediasoup-client 的 Consumer 对象自带 `producerId` 字段(transport.consume 时注入),
|
||||
* 利用它匹配 slot.audio / slot.video 中归属 producerId 的那一个;
|
||||
* 其他 kind 的 consumer 必须保留,否则会出现"关音频连带把视频画面也关掉"的连锁问题。
|
||||
*
|
||||
* 找不到匹配时(例如 producerId 异常),不再做 MVP 时期的"整槽关闭"兜底,
|
||||
* 而是只把 producerIds Set 条目移掉;真正该关的 consumer 由后续 transportclose / 离会清理接管
|
||||
*/
|
||||
const _cleanupRemoteProducer = (userId, producerId) => {
|
||||
const slot = remoteConsumers[userId]
|
||||
if (!slot) return
|
||||
slot.producerIds.delete(producerId)
|
||||
// 无法从 producerId 直接反查 consumer;简单策略:关 slot 内所有 consumer 然后重建(MVP)
|
||||
// 但更稳的:把 Consumer 索引按 producerId 存一遍
|
||||
;['audio', 'video'].forEach(kind => {
|
||||
const consumer = slot[kind]
|
||||
if (consumer && !consumer.closed) {
|
||||
if (!consumer) return
|
||||
if (consumer.producerId && consumer.producerId !== producerId) return
|
||||
if (!consumer.closed) {
|
||||
try { consumer.close() } catch {}
|
||||
slot[kind] = null
|
||||
}
|
||||
slot[kind] = null
|
||||
})
|
||||
if (slot.producerIds.size === 0) {
|
||||
if (!slot.audio && !slot.video && slot.producerIds.size === 0) {
|
||||
delete remoteConsumers[userId]
|
||||
}
|
||||
}
|
||||
@@ -451,12 +573,174 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
}
|
||||
|
||||
const _cleanupMedia = () => {
|
||||
_stopSpeakingDetection()
|
||||
if (_engine) {
|
||||
try { _engine.close() } catch (e) { _log('warn', '_cleanupMedia engine.close', e) }
|
||||
_engine = null
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 说话者探测(Task 15 原创特色 1) ====================
|
||||
|
||||
/**
|
||||
* 音量阈值(0-1 线性,RMS / audioLevel 均归一化到此区间)
|
||||
* 0.05 为静默阈值,比正常说话(0.15-0.4)低 3-8 倍,可过滤键盘敲击背景噪声
|
||||
*/
|
||||
const _SPEAKING_LEVEL_THRESHOLD = 0.05
|
||||
/** 探测轮询间隔(ms),500ms 已足够识别说话停顿 */
|
||||
const _SPEAKING_TICK_MS = 500
|
||||
|
||||
/** 读取远端 Consumer 的即时音量(0-1) */
|
||||
const _readRemoteAudioLevel = (consumer) => {
|
||||
// #ifdef H5
|
||||
if (!consumer || !consumer.rtpReceiver) return 0
|
||||
try {
|
||||
const sources = consumer.rtpReceiver.getSynchronizationSources?.() || []
|
||||
if (sources.length === 0) return 0
|
||||
// audioLevel 是 0-1 的浮点数(W3C Spec),Chrome/Edge 原生支持
|
||||
// Firefox 可能返回 undefined,此时回退为 0(不影响其他远端检测)
|
||||
const level = sources[0].audioLevel
|
||||
return typeof level === 'number' ? level : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
return 0
|
||||
// #endif
|
||||
}
|
||||
|
||||
/** 读取本地 AnalyserNode 的 RMS(0-1) */
|
||||
const _readLocalAudioLevel = () => {
|
||||
// #ifdef H5
|
||||
if (!_localAnalyser) return 0
|
||||
try {
|
||||
const buf = new Float32Array(_localAnalyser.fftSize)
|
||||
_localAnalyser.getFloatTimeDomainData(buf)
|
||||
let sum = 0
|
||||
for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i]
|
||||
const rms = Math.sqrt(sum / buf.length)
|
||||
// RMS 天然偏小,乘 3 放大到与 audioLevel 相似数量级
|
||||
return Math.min(1, rms * 3)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
return 0
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖更新:level >= 阈值 1 tick 即判定 speaking=true,
|
||||
* level < 阈值连续 2 tick 才 false,避免说话时短暂停顿 UI 闪烁
|
||||
*/
|
||||
const _updateSpeakingWithDebounce = (userId, level) => {
|
||||
if (!_speakingDebounce[userId]) {
|
||||
_speakingDebounce[userId] = { inCount: 0, outCount: 0 }
|
||||
}
|
||||
const state = _speakingDebounce[userId]
|
||||
const above = level >= _SPEAKING_LEVEL_THRESHOLD
|
||||
if (above) {
|
||||
state.inCount += 1
|
||||
state.outCount = 0
|
||||
if (state.inCount >= 1 && !speakingMap[userId]) {
|
||||
speakingMap[userId] = true
|
||||
}
|
||||
} else {
|
||||
state.inCount = 0
|
||||
state.outCount += 1
|
||||
if (state.outCount >= 2 && speakingMap[userId]) {
|
||||
speakingMap[userId] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化本地 WebAudio 管道(仅在有本地 audio track 时建) */
|
||||
const _ensureLocalAnalyser = () => {
|
||||
// #ifdef H5
|
||||
const track = getLocalTrack('audio')
|
||||
if (!track) {
|
||||
_teardownLocalAnalyser()
|
||||
return
|
||||
}
|
||||
// 若已有管道且 track 未变,直接复用;否则重建(例:麦克风切换设备)
|
||||
if (_localAnalyser && _localSource && _localSource.mediaStream?.getAudioTracks()[0]?.id === track.id) {
|
||||
return
|
||||
}
|
||||
_teardownLocalAnalyser()
|
||||
try {
|
||||
const AC = window.AudioContext || window.webkitAudioContext
|
||||
if (!AC) return
|
||||
_localAudioCtx = new AC()
|
||||
const stream = new MediaStream([track])
|
||||
_localSource = _localAudioCtx.createMediaStreamSource(stream)
|
||||
_localAnalyser = _localAudioCtx.createAnalyser()
|
||||
_localAnalyser.fftSize = 1024
|
||||
_localSource.connect(_localAnalyser)
|
||||
} catch (e) {
|
||||
_log('warn', '[Meeting] 本地 AnalyserNode 初始化失败', e)
|
||||
_teardownLocalAnalyser()
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
const _teardownLocalAnalyser = () => {
|
||||
// #ifdef H5
|
||||
try { _localSource?.disconnect?.() } catch {}
|
||||
try { _localAnalyser?.disconnect?.() } catch {}
|
||||
try { _localAudioCtx?.close?.() } catch {}
|
||||
_localSource = null
|
||||
_localAnalyser = null
|
||||
_localAudioCtx = null
|
||||
// #endif
|
||||
}
|
||||
|
||||
/** 500ms 轮询:计算所有远端 + 本地的音量并防抖更新 speakingMap */
|
||||
const _speakingTick = () => {
|
||||
const userStore = useUserStore()
|
||||
const myUid = userStore.userInfo?.id
|
||||
|
||||
// 远端:遍历所有有 audio consumer 的用户
|
||||
Object.keys(remoteConsumers).forEach(uidStr => {
|
||||
const uid = Number(uidStr)
|
||||
const slot = remoteConsumers[uid]
|
||||
const level = slot?.audio ? _readRemoteAudioLevel(slot.audio) : 0
|
||||
_updateSpeakingWithDebounce(uid, level)
|
||||
})
|
||||
|
||||
// 本地:仅在本地 audio 开启时探测
|
||||
if (myUid) {
|
||||
if (localAudioEnabled.value) {
|
||||
_ensureLocalAnalyser()
|
||||
const level = _readLocalAudioLevel()
|
||||
_updateSpeakingWithDebounce(myUid, level)
|
||||
} else {
|
||||
// 关麦后直接清零并销毁 AnalyserNode
|
||||
if (speakingMap[myUid]) speakingMap[myUid] = false
|
||||
if (_speakingDebounce[myUid]) _speakingDebounce[myUid] = { inCount: 0, outCount: 0 }
|
||||
_teardownLocalAnalyser()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const _startSpeakingDetection = () => {
|
||||
if (_speakingTimer) return
|
||||
// #ifdef H5
|
||||
_speakingTimer = setInterval(_speakingTick, _SPEAKING_TICK_MS)
|
||||
// #endif
|
||||
}
|
||||
|
||||
const _stopSpeakingDetection = () => {
|
||||
if (_speakingTimer) {
|
||||
clearInterval(_speakingTimer)
|
||||
_speakingTimer = null
|
||||
}
|
||||
_teardownLocalAnalyser()
|
||||
Object.keys(speakingMap).forEach(k => delete speakingMap[k])
|
||||
Object.keys(_speakingDebounce).forEach(k => delete _speakingDebounce[k])
|
||||
}
|
||||
|
||||
// ==================== 进入会议(公共私有) ====================
|
||||
|
||||
/**
|
||||
@@ -487,7 +771,19 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
|
||||
_registerListeners()
|
||||
|
||||
// 提前拉取参与者列表:让 OnRoomJoin 触发的 existing state.changed / producer.new
|
||||
// 到达时 participants 已就绪,避免后入者的 _onMemberStateChanged 找不到 participant 而丢状态
|
||||
try {
|
||||
const detail = await meetingApi.getRoom(currentRoom.value.room_code)
|
||||
if (detail && Array.isArray(detail.participants)) {
|
||||
participants.value = _mergeParticipantsFromRest(detail.participants)
|
||||
}
|
||||
} catch (e) {
|
||||
_log('warn', '[Meeting] 拉取参与者详情失败,保留广播缓存', e)
|
||||
}
|
||||
|
||||
// 告知后端 WS 我已绑定本房间(用于 WS 连接 ↔ roomCode 映射)
|
||||
// 后端在此事件内部异步 pushExistingRoomState,补推房间其他用户的 producer.new 与 state.changed
|
||||
await wsService.sendWithAck(MEETING_WS_ROOM_JOIN, {
|
||||
room_code: currentRoom.value.room_code
|
||||
})
|
||||
@@ -496,27 +792,33 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
await _engine.ensureSendTransport()
|
||||
await _engine.ensureRecvTransport()
|
||||
|
||||
// 拉取最新参与者列表(广播里只有 ID,这里补齐完整 DTO)
|
||||
try {
|
||||
const detail = await meetingApi.getRoom(currentRoom.value.room_code)
|
||||
if (detail && Array.isArray(detail.participants)) {
|
||||
participants.value = detail.participants
|
||||
}
|
||||
} catch (e) {
|
||||
_log('warn', '[Meeting] 拉取参与者详情失败,保留广播缓存', e)
|
||||
}
|
||||
|
||||
localState.value = MEETING_LOCAL_STATE_CONNECTED
|
||||
|
||||
// 启动说话者探测(Task 15):500ms 轮询,远端读 RTP audioLevel,本地用 WebAudio RMS
|
||||
_startSpeakingDetection()
|
||||
|
||||
// mediaPrefs 自动推流:失败仅告警,不阻断入会流程,允许用户在会议室内手动重试开麦/开摄像头
|
||||
// 预览页已申请过 track 时会通过 mediaPrefs.audioTrack/videoTrack 透传过来,避免 iOS 二次 getUserMedia
|
||||
if (mediaPrefs) {
|
||||
if (mediaPrefs.startAudio) {
|
||||
try { await startLocalAudio(mediaPrefs.audioDeviceId) }
|
||||
catch (e) { _log('warn', '[Meeting] 自动开麦失败', e) }
|
||||
try {
|
||||
await startLocalAudio(mediaPrefs.audioDeviceId, mediaPrefs.audioTrack)
|
||||
} catch (e) {
|
||||
_log('warn', '[Meeting] 自动开麦失败', e)
|
||||
// #ifdef H5
|
||||
try { uni.showToast({ title: `自动开麦失败:${e?.message || e?.name || e}`, icon: 'none', duration: 3500 }) } catch {}
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
if (mediaPrefs.startVideo) {
|
||||
try { await startLocalVideo(mediaPrefs.videoDeviceId) }
|
||||
catch (e) { _log('warn', '[Meeting] 自动开摄像头失败', e) }
|
||||
try {
|
||||
await startLocalVideo(mediaPrefs.videoDeviceId, mediaPrefs.videoTrack)
|
||||
} catch (e) {
|
||||
_log('warn', '[Meeting] 自动开摄像头失败', e)
|
||||
// #ifdef H5
|
||||
try { uni.showToast({ title: `自动开摄像头失败:${e?.message || e?.name || e}`, icon: 'none', duration: 3500 }) } catch {}
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -532,6 +834,9 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
if (isInMeeting.value) {
|
||||
throw new Error('你当前已在其他会议中')
|
||||
}
|
||||
// 上一次会议若以 ENDED 收尾(被主持人/后端结束),Store 中仍会残留 participants/remoteConsumers/
|
||||
// devicePreview 等旧状态;显式 _reset 一次避免污染新会议(典型症状:再次入会看不到自己)
|
||||
_reset()
|
||||
localState.value = MEETING_LOCAL_STATE_JOINING
|
||||
try {
|
||||
// 首次调用时抑制失败 toast:失败后若是 stale 残留会自动清理 + 重试,对用户无感
|
||||
@@ -572,6 +877,8 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
if (isInMeeting.value) {
|
||||
throw new Error('你当前已在其他会议中')
|
||||
}
|
||||
// 与 createAndEnter 同理:消除上一次 ENDED 会议残留的 Store 状态
|
||||
_reset()
|
||||
localState.value = MEETING_LOCAL_STATE_JOINING
|
||||
try {
|
||||
// 首次调用时抑制失败 toast:失败后若是 stale 残留会自动清理 + 重试,对用户无感
|
||||
@@ -630,36 +937,47 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
* 开启本地音频
|
||||
* @param {string} [deviceId] - 指定麦克风 deviceId(从 enumerateDevices 获取),为空则走系统默认
|
||||
*/
|
||||
const startLocalAudio = async (deviceId) => {
|
||||
const startLocalAudio = async (deviceId, existingTrack) => {
|
||||
if (!_engine) throw new Error('未连接')
|
||||
if (localProducers.audio) return
|
||||
const audioConstraints = deviceId ? { deviceId: { exact: deviceId } } : true
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints })
|
||||
const track = stream.getAudioTracks()[0]
|
||||
// 优先复用预览页已申请的 track,避开 iOS Safari/Chrome 连续两次 getUserMedia
|
||||
// 触发 NotReadableError(预览页 track.stop() 后相机硬件有释放延迟)
|
||||
let track = existingTrack && existingTrack.readyState === 'live' ? existingTrack : null
|
||||
if (!track) {
|
||||
const audioConstraints = deviceId ? { deviceId: { exact: deviceId } } : true
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints })
|
||||
track = stream.getAudioTracks()[0]
|
||||
}
|
||||
const producer = await _engine.produce({ kind: 'audio', track })
|
||||
localProducers.audio = producer.id
|
||||
localAudioEnabled.value = true
|
||||
_broadcastSelfState({ audio_enabled: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* 开启本地视频
|
||||
* @param {string} [deviceId] - 指定摄像头 deviceId,为空则走系统默认
|
||||
*/
|
||||
const startLocalVideo = async (deviceId) => {
|
||||
const startLocalVideo = async (deviceId, existingTrack) => {
|
||||
if (!_engine) throw new Error('未连接')
|
||||
if (localProducers.video) return
|
||||
// 与预览页保持一致的 HD 分辨率(ideal 1280x720,允许浏览器在设备不支持时降级)
|
||||
const videoConstraints = {
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
frameRate: { ideal: 24, max: 30 },
|
||||
...(deviceId ? { deviceId: { exact: deviceId } } : {})
|
||||
// 优先复用预览页已申请的 track,避开 iOS Safari/Chrome 连续两次 getUserMedia
|
||||
// 触发 NotReadableError(预览页 track.stop() 后相机硬件有释放延迟)
|
||||
let track = existingTrack && existingTrack.readyState === 'live' ? existingTrack : null
|
||||
if (!track) {
|
||||
const videoConstraints = {
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
frameRate: { ideal: 24, max: 30 },
|
||||
...(deviceId ? { deviceId: { exact: deviceId } } : {})
|
||||
}
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints })
|
||||
track = stream.getVideoTracks()[0]
|
||||
}
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints })
|
||||
const track = stream.getVideoTracks()[0]
|
||||
const producer = await _engine.produce({ kind: 'video', track })
|
||||
localProducers.video = producer.id
|
||||
localVideoEnabled.value = true
|
||||
_broadcastSelfState({ video_enabled: true })
|
||||
}
|
||||
|
||||
/** 关闭本地音频 */
|
||||
@@ -668,6 +986,7 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
await _engine.closeProducer(localProducers.audio)
|
||||
localProducers.audio = null
|
||||
localAudioEnabled.value = false
|
||||
_broadcastSelfState({ audio_enabled: false })
|
||||
}
|
||||
|
||||
/** 关闭本地视频 */
|
||||
@@ -676,6 +995,26 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
await _engine.closeProducer(localProducers.video)
|
||||
localProducers.video = null
|
||||
localVideoEnabled.value = false
|
||||
_broadcastSelfState({ video_enabled: false })
|
||||
}
|
||||
|
||||
/**
|
||||
* Task 15:向房间其他成员广播自身音视频状态
|
||||
* - 后端 meeting.member.state.changed 语义:操作自己无需 target_user_id
|
||||
* - 后端会广播给房间其他成员(不回显给本人)
|
||||
* - 容错:WS 未连接或 ACK 失败仅 warn 日志,不影响本地状态机
|
||||
*/
|
||||
const _broadcastSelfState = (patch) => {
|
||||
if (!currentRoom.value) return
|
||||
if (!patch || typeof patch !== 'object') return
|
||||
const payload = { room_code: currentRoom.value.room_code }
|
||||
if (typeof patch.audio_enabled === 'boolean') payload.audio_enabled = patch.audio_enabled
|
||||
if (typeof patch.video_enabled === 'boolean') payload.video_enabled = patch.video_enabled
|
||||
if (Object.keys(payload).length <= 1) return
|
||||
wsService.sendWithAck(MEETING_WS_MEMBER_STATE_CHANGED, payload, 3000)
|
||||
.catch((err) => {
|
||||
_log('warn', '[Meeting] 上报本地音视频状态失败', err)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -752,6 +1091,25 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
await meetingApi.kickMember(currentRoom.value.room_code, userId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 主持人静音他人 / 请他开麦(Task 15 主持人权限四件套第 4 件)
|
||||
*
|
||||
* 通过 WS meeting.member.state.changed 携带 target_user_id,
|
||||
* 后端已实现 host 校验与广播逻辑(meeting_signal_service.go),前端仅需发送。
|
||||
*
|
||||
* @param {number} targetUserId 被操作对象 user_id
|
||||
* @param {boolean} mute true=静音,false=请他开麦
|
||||
*/
|
||||
const muteMember = async (targetUserId, mute = true) => {
|
||||
if (!currentRoom.value) throw new Error('未入会')
|
||||
if (!isHost.value) throw new Error('仅主持人可操作')
|
||||
await wsService.sendWithAck(MEETING_WS_MEMBER_STATE_CHANGED, {
|
||||
room_code: currentRoom.value.room_code,
|
||||
target_user_id: targetUserId,
|
||||
audio_enabled: !mute
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const inviteUsers = async (inviteeIds) => {
|
||||
if (!currentRoom.value) throw new Error('未入会')
|
||||
return meetingApi.inviteUsers(currentRoom.value.room_code, inviteeIds)
|
||||
@@ -809,12 +1167,15 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
remoteConsumers,
|
||||
draftCreatePayload,
|
||||
devicePreview,
|
||||
speakingMap,
|
||||
uiPrefs,
|
||||
|
||||
// getters
|
||||
isInMeeting,
|
||||
isConnected,
|
||||
isHost,
|
||||
activeParticipants,
|
||||
isAllMuted,
|
||||
|
||||
// lifecycle
|
||||
createAndEnter,
|
||||
@@ -837,6 +1198,7 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
markChatsRead,
|
||||
transferHost,
|
||||
kickMember,
|
||||
muteMember,
|
||||
inviteUsers,
|
||||
cleanupStaleMeetings,
|
||||
|
||||
|
||||
@@ -184,12 +184,32 @@ export function createMediaEngine({ roomCode, userId, sendWithAck, logger = defa
|
||||
})
|
||||
}
|
||||
|
||||
/** 按需创建 sendTransport(已存在则复用) */
|
||||
/** in-flight Promise:sendTransport 创建并发锁;避免突发并发下重复创建 */
|
||||
let sendTransportPromise = null
|
||||
/** in-flight Promise:recvTransport 创建并发锁;Task 15 后入者 burst 订阅时尤其关键 */
|
||||
let recvTransportPromise = null
|
||||
|
||||
/** 按需创建 sendTransport(已存在则复用,并发调用共享同一个 in-flight Promise) */
|
||||
const ensureSendTransport = async () => {
|
||||
ensureDeviceLoaded()
|
||||
if (sendTransport && !sendTransport.closed) {
|
||||
return sendTransport
|
||||
}
|
||||
if (sendTransportPromise) {
|
||||
return sendTransportPromise
|
||||
}
|
||||
sendTransportPromise = (async () => {
|
||||
try {
|
||||
return await _createSendTransport()
|
||||
} finally {
|
||||
sendTransportPromise = null
|
||||
}
|
||||
})()
|
||||
return sendTransportPromise
|
||||
}
|
||||
|
||||
/** 首次创建 sendTransport 的底层流程,原本 inline 在 ensureSendTransport 中 */
|
||||
const _createSendTransport = async () => {
|
||||
const info = await requestTransportInfo('send')
|
||||
sendTransport = device.createSendTransport({
|
||||
id: info.id,
|
||||
@@ -203,12 +223,27 @@ export function createMediaEngine({ roomCode, userId, sendWithAck, logger = defa
|
||||
return sendTransport
|
||||
}
|
||||
|
||||
/** 按需创建 recvTransport(已存在则复用) */
|
||||
/** 按需创建 recvTransport(已存在则复用,并发调用共享同一个 in-flight Promise) */
|
||||
const ensureRecvTransport = async () => {
|
||||
ensureDeviceLoaded()
|
||||
if (recvTransport && !recvTransport.closed) {
|
||||
return recvTransport
|
||||
}
|
||||
if (recvTransportPromise) {
|
||||
return recvTransportPromise
|
||||
}
|
||||
recvTransportPromise = (async () => {
|
||||
try {
|
||||
return await _createRecvTransport()
|
||||
} finally {
|
||||
recvTransportPromise = null
|
||||
}
|
||||
})()
|
||||
return recvTransportPromise
|
||||
}
|
||||
|
||||
/** 首次创建 recvTransport 的底层流程 */
|
||||
const _createRecvTransport = async () => {
|
||||
const info = await requestTransportInfo('recv')
|
||||
recvTransport = device.createRecvTransport({
|
||||
id: info.id,
|
||||
|
||||
@@ -12,11 +12,14 @@
|
||||
|
||||
import { getToken, removeToken } from './storage'
|
||||
|
||||
// 基础 URL 配置,根据环境自动切换
|
||||
// 开发环境使用 window.location.hostname 动态获取主机名,支持局域网 IP 访问
|
||||
const BASE_URL = process.env.NODE_ENV === 'development'
|
||||
? `http://${window.location.hostname}:8085`
|
||||
: '' // 生产环境由 Nginx 反向代理,使用相对路径
|
||||
// 基础 URL 配置
|
||||
// 开发 / 生产环境都用相对路径(空串):
|
||||
// - 开发:vite 配置 server.proxy 把 /api /ws 转发到后端,同时开启 HTTPS;前端同源调用 /api/** 即可,
|
||||
// 同源意味着浏览器在 HTTPS 页面下不会因混合内容拒绝请求,且 WebRTC 所需的 secure context 自然满足。
|
||||
// - 生产:由 Nginx 反向代理到 Go 后端,同样同源。
|
||||
// 如果需要直连其他主机(极少数场景),可通过 VITE_API_BASE 在构建期覆盖。
|
||||
// eslint-disable-next-line no-undef
|
||||
const BASE_URL = (typeof __VITE_API_BASE__ !== 'undefined' && __VITE_API_BASE__) || ''
|
||||
|
||||
// 请求超时时间(毫秒)
|
||||
const TIMEOUT = 15000
|
||||
|
||||
@@ -1,11 +1,40 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
import basicSsl from '@vitejs/plugin-basic-ssl'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
//
|
||||
// HTTPS 说明(WebRTC 必需):
|
||||
// 浏览器仅在 secure context 下开放 navigator.mediaDevices(getUserMedia 等 API),
|
||||
// 手机通过局域网 IP http://192.168.x.x:5173 访问时 mediaDevices 会是 undefined。
|
||||
// 因此 dev 默认开启自签 HTTPS,同时把 /api 与 /ws 走同源代理转回后端 HTTP,
|
||||
// 这样浏览器只与 vite HTTPS 通信,前端代码无需关心后端是否 TLS。
|
||||
//
|
||||
// 代理目标由环境变量 VITE_DEV_BACKEND 控制,默认 http://localhost:8085,
|
||||
// 若后端跑在其他主机/端口(如 docker 主机)可自行覆盖。
|
||||
const BACKEND = process.env.VITE_DEV_BACKEND || 'http://localhost:8085'
|
||||
const BACKEND_WS = BACKEND.replace(/^http/, 'ws')
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
uni(),
|
||||
basicSsl(),
|
||||
],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
https: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: BACKEND,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/ws': {
|
||||
target: BACKEND_WS,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -105,6 +105,11 @@ async function bootstrap(): Promise<void> {
|
||||
host: config.http.host,
|
||||
port: config.http.port,
|
||||
env: config.nodeEnv,
|
||||
mediasoup: {
|
||||
listenIp: config.mediasoup.listenIp,
|
||||
announcedIp: config.mediasoup.announcedIp ?? '(none)',
|
||||
rtcPortRange: `${config.mediasoup.rtcMinPort}-${config.mediasoup.rtcMaxPort}`,
|
||||
},
|
||||
},
|
||||
'media-server listening',
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user