Compare commits

...

40 Commits

Author SHA1 Message Date
duoaohui
8a5a3ab7ec 电话咨询 2026-06-08 17:14:44 +08:00
duoaohui
15b7cd61cd 电话咨询 2026-06-08 14:24:17 +08:00
duoaohui
3c305257f7 电话咨询 2026-06-08 14:17:17 +08:00
duoaohui
0d9d790d53 电话咨询 2026-06-08 13:49:13 +08:00
duoaohui
ce07f34c2b 视频通话 2026-06-07 11:44:35 +08:00
duoaohui
c033b21b02 视频通话 2026-06-07 11:37:19 +08:00
duoaohui
80a913c78e 运营数据 2026-06-06 15:06:40 +08:00
duoaohui
4477132081 运营数据 2026-06-06 14:32:40 +08:00
duoaohui
c6e460ad97 运营数据 2026-06-06 13:38:50 +08:00
duoaohui
e697354ce9 运营数据 2026-06-06 13:27:31 +08:00
duoaohui
9be151cc9f 运营数据 2026-06-06 13:15:08 +08:00
duoaohui
d936ebd17e 运营数据 2026-06-06 13:05:07 +08:00
duoaohui
98c481b25a 运营数据 2026-06-06 12:49:31 +08:00
duoaohui
f7526d98c6 运营数据 2026-06-06 12:25:11 +08:00
duoaohui
a7e91a2cde 运营数据 2026-06-06 12:16:27 +08:00
duoaohui
d6b8a0e1e1 运营数据 2026-06-06 11:56:16 +08:00
duoaohui
3b0f1f84ff 运营数据 2026-06-06 11:39:15 +08:00
duoaohui
9e9573a35d 运营数据 2026-06-06 11:22:47 +08:00
duoaohui
2410f5e2e9 运营数据 2026-06-06 11:18:31 +08:00
duoaohui
2b036f4aa5 电话咨询 2026-06-04 17:08:38 +08:00
duoaohui
cae3876d8b 电话咨询 2026-06-04 15:52:22 +08:00
duoaohui
495f0aae1b 电话咨询 2026-06-04 15:35:19 +08:00
duoaohui
e426357850 电话咨询 2026-06-04 15:19:31 +08:00
duoaohui
3cffd160f5 电话咨询 2026-06-04 15:10:40 +08:00
duoaohui
3ffdaa30d9 电话咨询 2026-06-04 14:58:03 +08:00
duoaohui
b89b930c41 电话咨询 2026-06-04 14:32:52 +08:00
duoaohui
edda31e8bf 电话咨询 2026-06-04 12:40:04 +08:00
duoaohui
46afcfe350 电话咨询 2026-06-04 12:26:22 +08:00
duoaohui
3b51aba8ec 电话咨询 2026-06-04 12:23:14 +08:00
duoaohui
6727259e8b 电话咨询 2026-06-04 11:51:24 +08:00
duoaohui
96fca438f4 视频连线 2026-05-28 10:15:07 +08:00
duoaohui
a3f19c6245 视频连线 2026-05-27 15:04:50 +08:00
duoaohui
db7ea0b44a 视频连线 2026-05-27 14:38:55 +08:00
duoaohui
3dc4fa6563 视频连线 2026-05-27 12:55:42 +08:00
duoaohui
533f0b1e36 视频连线 2026-05-27 12:52:11 +08:00
duoaohui
ad1cfa2f0b 视频连线 2026-05-27 12:51:18 +08:00
4969677be1 上传文件至 frontend/src/static 2026-05-27 11:51:03 +08:00
duoaohui
a70f6d1b93 视频连线 2026-05-27 11:49:57 +08:00
duoaohui
f13a2e51ec 视频连线 2026-05-26 22:54:55 +08:00
duoaohui
601366d2a8 视频连线 2026-05-26 22:50:11 +08:00
30 changed files with 1558 additions and 123 deletions

View File

@@ -298,9 +298,10 @@ func (s *AuthService) Logout(ctx context.Context, userID int64, clientType strin
} }
// ValidateAccessToken 校验 Access Token 是否在 Redis 中有效 // ValidateAccessToken 校验 Access Token 是否在 Redis 中有效
// 供 JWT 中间件调用,实现有状态 JWT 验证,按 clientType 隔离校验 // 供 JWT 中间件 / WS 握手调用,实现有状态 JWT 验证,按 clientType 隔离校验
func (s *AuthService) ValidateAccessToken(ctx context.Context, userID int64, clientType, token string) bool { // username 用于识别云律临时会议账号并放宽「单点登录挤号」校验
return s.tokenStore.ValidateAccessToken(ctx, userID, clientType, token) func (s *AuthService) ValidateAccessToken(ctx context.Context, userID int64, clientType, token, username string) bool {
return s.tokenStore.ValidateAccessToken(ctx, userID, clientType, token, username)
} }
// GetProfile 获取用户个人信息 // GetProfile 获取用户个人信息

View File

@@ -3,6 +3,7 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"time" "time"
"github.com/echochat/backend/config" "github.com/echochat/backend/config"
@@ -17,8 +18,18 @@ import (
const ( const (
keyPrefixAccessToken = "echo:auth:token:" // echo:auth:token:{client_type}:{user_id} keyPrefixAccessToken = "echo:auth:token:" // echo:auth:token:{client_type}:{user_id}
keyPrefixRefreshToken = "echo:auth:refresh:" // echo:auth:refresh:{client_type}:{user_id} keyPrefixRefreshToken = "echo:auth:refresh:" // echo:auth:refresh:{client_type}:{user_id}
// cloudLawUsernamePrefix 云律临时会议账号用户名前缀(与 meeting.cloudLawUsername 保持一致)。
// 这类账号按手机号即时创建、仅用于视频/语音会议,会在「双方进会 / SSO 多跳 / 多端」时被重复签发,
// 单点登录语义会把先签发的 token 挤掉。对其放宽校验,避免 web-view 进会误报「认证已失效」。
cloudLawUsernamePrefix = "cloudlaw_"
) )
// isCloudLawUsername 判断是否为云律临时会议账号
func isCloudLawUsername(username string) bool {
return strings.HasPrefix(username, cloudLawUsernamePrefix)
}
// TokenStore 管理 Token 在 Redis 中的存取 // TokenStore 管理 Token 在 Redis 中的存取
// 实现有状态 JWT登录存入、验证时校验、登出时删除 // 实现有状态 JWT登录存入、验证时校验、登出时删除
type TokenStore struct { type TokenStore struct {
@@ -70,12 +81,30 @@ func (s *TokenStore) SaveTokens(ctx context.Context, userID int64, clientType, a
// ValidateAccessToken 校验 Access Token 是否与 Redis 中存储的一致 // ValidateAccessToken 校验 Access Token 是否与 Redis 中存储的一致
// clientType 用于定位正确的 Redis key前台 vs 管理端) // clientType 用于定位正确的 Redis key前台 vs 管理端)
func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, clientType, token string) bool { // username 用于识别云律临时会议账号,对其放宽有状态校验
//
// 云律临时会议账号(cloudlaw_*)说明:按手机号即时创建、默认口令、仅用于视频/语音会议、角色受限,
// 在「双方进会 / SSO 多跳 / 多端 / 重进会议」时会被重复签发,单点登录语义会把先签发的 token 挤掉;
// 叠加 Redis key 过期/内存淘汰/重启、登出清理、并发等情况Redis 侧可能出现「不一致 / 缺失 / 查询失败」。
// 由于调用方(中间件/WS 握手)在调用本方法前已用 ParseToken 校验过 JWT 的签名与有效期,
// 对这类临时账号只要 JWT 有效即放行(不强依赖 Redis 有状态记录),避免 web-view 进会偶发「认证已失效」。
// 普通账号(前台用户/管理端)仍维持严格的有状态校验。
func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, clientType, token, username string) bool {
funcName := "service.token_store.ValidateAccessToken" funcName := "service.token_store.ValidateAccessToken"
cloudLaw := isCloudLawUsername(username)
key := fmt.Sprintf("%s%s:%d", keyPrefixAccessToken, clientType, userID) key := fmt.Sprintf("%s%s:%d", keyPrefixAccessToken, clientType, userID)
stored, err := s.redis.Get(ctx, key).Result() stored, err := s.redis.Get(ctx, key).Result()
if err == redis.Nil { if err == redis.Nil {
// 云律临时账号Redis 无记录(挤号后过期 / 内存淘汰 / 重启 / 登出清理)也凭有效 JWT 放行
if cloudLaw {
logs.Debug(ctx, funcName, "云律临时账号 Redis 无 token 记录,按宽松策略放行(仅凭有效 JWT",
zap.Int64("user_id", userID),
zap.String("client_type", clientType),
)
return true
}
logs.Debug(ctx, funcName, "Token 不存在(已登出或过期)", logs.Debug(ctx, funcName, "Token 不存在(已登出或过期)",
zap.Int64("user_id", userID), zap.Int64("user_id", userID),
zap.String("client_type", clientType), zap.String("client_type", clientType),
@@ -83,6 +112,15 @@ func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, clie
return false return false
} }
if err != nil { if err != nil {
// 云律临时账号Redis 抖动/不可用时凭有效 JWT 放行,避免阻断进会
if cloudLaw {
logs.Warn(ctx, funcName, "Redis 查询 token 失败,云律临时账号按宽松策略放行(仅凭有效 JWT",
zap.Int64("user_id", userID),
zap.String("client_type", clientType),
zap.Error(err),
)
return true
}
logs.Error(ctx, funcName, "Redis 查询 Token 失败", logs.Error(ctx, funcName, "Redis 查询 Token 失败",
zap.Int64("user_id", userID), zap.Int64("user_id", userID),
zap.String("client_type", clientType), zap.String("client_type", clientType),
@@ -91,7 +129,20 @@ func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, clie
return false return false
} }
return stored == token if stored == token {
return true
}
// 云律临时会议账号token 已被新会话挤号stored != token也放行
if cloudLaw {
logs.Debug(ctx, funcName, "云律临时账号 token 已被新会话覆盖,按宽松策略放行",
zap.Int64("user_id", userID),
zap.String("client_type", clientType),
)
return true
}
return false
} }
// ValidateRefreshToken 校验 Refresh Token 是否与 Redis 中存储的一致 // ValidateRefreshToken 校验 Refresh Token 是否与 Redis 中存储的一致

View File

@@ -81,7 +81,7 @@ var MeetingLeftReasonMap = map[string]string{
// 会议默认配置(应用层强制约束) // 会议默认配置(应用层强制约束)
const ( const (
MeetingMVPMaxMembers = 8 // MVP 阶段单会议硬上限 MeetingMVPMaxMembers = 1000000 // MVP 阶段单会议硬上限
MeetingRoomCodeLength = 9 // 会议号去连字符后的长度XXX-XXX-XXX MeetingRoomCodeLength = 9 // 会议号去连字符后的长度XXX-XXX-XXX
MeetingRoomCodeRetryMax = 3 // 生成会议号冲突重试上限 MeetingRoomCodeRetryMax = 3 // 生成会议号冲突重试上限
MeetingPasswordMaxAttempts = 5 // 密码连续错误锁定阈值 MeetingPasswordMaxAttempts = 5 // 密码连续错误锁定阈值

View File

@@ -64,7 +64,7 @@ type MeetingChatDTO struct {
type CreateMeetingRoomRequest struct { type CreateMeetingRoomRequest struct {
Title string `json:"title" binding:"required,min=1,max=200"` // 会议标题 Title string `json:"title" binding:"required,min=1,max=200"` // 会议标题
Password string `json:"password" binding:"omitempty,min=4,max=20"` // 可选入会密码(纯文本入参,服务端 bcrypt 哈希存储) Password string `json:"password" binding:"omitempty,min=4,max=20"` // 可选入会密码(纯文本入参,服务端 bcrypt 哈希存储)
MaxMembers int `json:"max_members" binding:"omitempty,min=2,max=8"` // 可选上限MVP 硬上限 8留参数为 Phase 2e-3 扩展) MaxMembers int `json:"max_members" binding:"omitempty,min=2"` // 可选上限
} }
// CreateMeetingRoomResponse 创建会议响应 // CreateMeetingRoomResponse 创建会议响应

View File

@@ -250,7 +250,6 @@ func (ctl *MeetingController) CloudLawCreateMeeting(c *gin.Context) {
} }
room, _, _, err := ctl.meetingService.CreateRoomForInternal(c.Request.Context(), hostUser.ID, &dto.CreateMeetingRoomRequest{ room, _, _, err := ctl.meetingService.CreateRoomForInternal(c.Request.Context(), hostUser.ID, &dto.CreateMeetingRoomRequest{
Title: title, Title: title,
MaxMembers: 2,
}) })
if err != nil { if err != nil {
ctl.handleError(c, err, "创建云律会议失败") ctl.handleError(c, err, "创建云律会议失败")

View File

@@ -13,7 +13,7 @@ type MeetingRoom struct {
HostID int64 `json:"host_id" gorm:"not null;index:idx_meeting_rooms_host_status,priority:1"` // 主持人用户 ID HostID int64 `json:"host_id" gorm:"not null;index:idx_meeting_rooms_host_status,priority:1"` // 主持人用户 ID
Type int `json:"type" gorm:"not null;default:1"` // 会议类型1=即时2=预约 Type int `json:"type" gorm:"not null;default:1"` // 会议类型1=即时2=预约
PasswordHash *string `json:"-" gorm:"size:255"` // 入会密码 bcrypt 哈希NULL 表示无密码JSON 始终剥离 PasswordHash *string `json:"-" gorm:"size:255"` // 入会密码 bcrypt 哈希NULL 表示无密码JSON 始终剥离
MaxMembers int `json:"max_members" gorm:"not null;default:50"` // 房间最大成员数MVP 应用层强制 8 MaxMembers int `json:"max_members" gorm:"not null;default:50"` // 房间最大成员数
Status int `json:"status" gorm:"not null;default:0;index:idx_meeting_rooms_host_status,priority:2"` // 状态0=未开始1=进行中2=已结束 Status int `json:"status" gorm:"not null;default:0;index:idx_meeting_rooms_host_status,priority:2"` // 状态0=未开始1=进行中2=已结束
ScheduledAt *time.Time `json:"scheduled_at" gorm:"type:timestamp(0)"` // 预约开始时间,即时会议为 NULL ScheduledAt *time.Time `json:"scheduled_at" gorm:"type:timestamp(0)"` // 预约开始时间,即时会议为 NULL
StartedAt *time.Time `json:"started_at" gorm:"type:timestamp(0)"` // 实际开始时间 StartedAt *time.Time `json:"started_at" gorm:"type:timestamp(0)"` // 实际开始时间

View File

@@ -398,13 +398,6 @@ func (s *MeetingService) JoinRoomForInternal(ctx context.Context, userID int64,
routerID, _ := s.mediaOrchestrator.ResolveRouterID(code) routerID, _ := s.mediaOrchestrator.ResolveRouterID(code)
return room, existing, routerID, nil return room, existing, routerID, nil
} }
activeCount, err := s.participantDAO.CountActiveByRoom(ctx, room.ID)
if err != nil {
return nil, nil, "", err
}
if int(activeCount) >= room.MaxMembers {
return nil, nil, "", ErrMeetingFull
}
participant, err := s.participantDAO.JoinRoom(ctx, room.ID, userID, constants.MeetingRoleParticipant) participant, err := s.participantDAO.JoinRoom(ctx, room.ID, userID, constants.MeetingRoleParticipant)
if err != nil { if err != nil {
if errors.Is(err, dao.ErrAlreadyInMeeting) { if errors.Is(err, dao.ErrAlreadyInMeeting) {
@@ -635,14 +628,6 @@ func (s *MeetingService) JoinRoom(ctx context.Context, userID int64, code, passw
s.redis.Del(ctx, redisPasswordAttemptPrefix+code+":"+strconv.FormatInt(userID, 10)) s.redis.Del(ctx, redisPasswordAttemptPrefix+code+":"+strconv.FormatInt(userID, 10))
} }
activeCount, err := s.participantDAO.CountActiveByRoom(ctx, room.ID)
if err != nil {
return nil, nil, "", err
}
if int(activeCount) >= room.MaxMembers {
return nil, nil, "", ErrMeetingFull
}
participant, err := s.participantDAO.JoinRoom(ctx, room.ID, userID, constants.MeetingRoleParticipant) participant, err := s.participantDAO.JoinRoom(ctx, room.ID, userID, constants.MeetingRoleParticipant)
if err != nil { if err != nil {
return nil, nil, "", err return nil, nil, "", err
@@ -690,8 +675,22 @@ func (s *MeetingService) JoinRoom(ctx context.Context, userID int64, code, passw
return room, participant, routerID, nil return room, participant, routerID, nil
} }
// isCloudLawRoom 判断是否为云律视频咨询房间host 为云律自动创建的用户(邮箱后缀 @cloud-law.local
// 云律房间语义是「发起人(用户)↔接单人(律师)」一对一咨询,发起人挂断即咨询结束。
func (s *MeetingService) isCloudLawRoom(ctx context.Context, hostID int64) bool {
if s.userResolver == nil || hostID <= 0 {
return false
}
users, err := s.userResolver.GetUsersByIDs(ctx, []int64{hostID})
if err != nil || len(users) == 0 {
return false
}
return strings.HasSuffix(users[0].Email, "@cloud-law.local")
}
// LeaveRoom 用户主动离会 // LeaveRoom 用户主动离会
// 若离开者为 host 且房间内还有其他活跃成员事务内转让给最早加入者若为空房关闭房间status=Ended, reason=empty_ttl // 若离开者为 host 且房间内还有其他活跃成员事务内转让给最早加入者若为空房关闭房间status=Ended, reason=empty_ttl
// 云律房间例外:发起人(host)离会即结束整场会议(见下方说明)
func (s *MeetingService) LeaveRoom(ctx context.Context, userID int64, code string) (int, error) { func (s *MeetingService) LeaveRoom(ctx context.Context, userID int64, code string) (int, error) {
funcName := "service.meeting_service.LeaveRoom" funcName := "service.meeting_service.LeaveRoom"
@@ -727,6 +726,16 @@ func (s *MeetingService) LeaveRoom(ctx context.Context, userID int64, code strin
} }
if participant.Role == constants.MeetingRoleHost && len(actives) > 0 { if participant.Role == constants.MeetingRoleHost && len(actives) > 0 {
// 云律视频咨询:发起人(host)即咨询者,挂断/离会即代表咨询结束,
// 应直接结束整场会议,而不是把主持人转让给律师让会议继续
//(否则表现为「用户已挂断、律师端会议仍在、云律 status 一直 accepted」
if s.isCloudLawRoom(ctx, room.HostID) {
if endErr := s.EndRoomForInternal(ctx, code); endErr != nil {
logs.Warn(ctx, funcName, "云律发起人离会自动结束会议失败",
zap.String("room_code", code), zap.Int64("host_id", room.HostID), zap.Error(endErr))
}
return duration, nil
}
newHost := actives[0] newHost := actives[0]
// P1-1TransferHost 内部事务已同时更新 meeting_rooms.host_id不再需要单独 UpdateHost // P1-1TransferHost 内部事务已同时更新 meeting_rooms.host_id不再需要单独 UpdateHost
if txErr := s.participantDAO.TransferHost(ctx, room.ID, userID, newHost.UserID); txErr != nil { if txErr := s.participantDAO.TransferHost(ctx, room.ID, userID, newHost.UserID); txErr != nil {

View File

@@ -20,7 +20,7 @@ import (
// TokenValidator 有状态 JWT 验证接口(检查 Token 是否在 Redis 中有效) // TokenValidator 有状态 JWT 验证接口(检查 Token 是否在 Redis 中有效)
// 由 auth.AuthService 实现,用于防止已登出用户建立 WebSocket 连接 // 由 auth.AuthService 实现,用于防止已登出用户建立 WebSocket 连接
type TokenValidator interface { type TokenValidator interface {
ValidateAccessToken(ctx context.Context, userID int64, clientType, token string) bool ValidateAccessToken(ctx context.Context, userID int64, clientType, token, username string) bool
} }
// OfflineMessagePusher 离线消息推送接口 // OfflineMessagePusher 离线消息推送接口
@@ -160,7 +160,7 @@ func (h *Handler) Upgrade(c *gin.Context) {
if clientType == "" { if clientType == "" {
clientType = "frontend" clientType = "frontend"
} }
if h.tokenValidator != nil && !h.tokenValidator.ValidateAccessToken(c.Request.Context(), claims.UserID, clientType, token) { if h.tokenValidator != nil && !h.tokenValidator.ValidateAccessToken(c.Request.Context(), claims.UserID, clientType, token, claims.Username) {
logs.Warn(nil, funcName, "WebSocket Token 已失效Redis 校验)", logs.Warn(nil, funcName, "WebSocket Token 已失效Redis 校验)",
zap.Int64("user_id", claims.UserID)) zap.Int64("user_id", claims.UserID))
utils.ResponseUnauthorized(c, "认证已失效,请重新登录") utils.ResponseUnauthorized(c, "认证已失效,请重新登录")

View File

@@ -21,7 +21,7 @@ const (
// TokenValidator Token 有效性校验接口 // TokenValidator Token 有效性校验接口
// 由 AuthService 实现,中间件通过此接口检查 Token 是否在 Redis 中有效 // 由 AuthService 实现,中间件通过此接口检查 Token 是否在 Redis 中有效
type TokenValidator interface { type TokenValidator interface {
ValidateAccessToken(ctx context.Context, userID int64, clientType, token string) bool ValidateAccessToken(ctx context.Context, userID int64, clientType, token, username string) bool
} }
// JWTAuth JWT 认证中间件(有状态 JWT // JWTAuth JWT 认证中间件(有状态 JWT
@@ -70,7 +70,7 @@ func JWTAuth(jwtCfg *config.JWTConfig, validator TokenValidator) gin.HandlerFunc
} }
// 校验 Token 是否在 Redis 中有效(有状态 JWT 核心逻辑,按 clientType 隔离) // 校验 Token 是否在 Redis 中有效(有状态 JWT 核心逻辑,按 clientType 隔离)
if !validator.ValidateAccessToken(ctx, claims.UserID, clientType, tokenStr) { if !validator.ValidateAccessToken(ctx, claims.UserID, clientType, tokenStr, claims.Username) {
logs.Warn(ctx, funcName, "Token 已失效(已登出或被覆盖)", logs.Warn(ctx, funcName, "Token 已失效(已登出或被覆盖)",
zap.Int64("user_id", claims.UserID), zap.Int64("user_id", claims.UserID),
zap.String("client_type", clientType), zap.String("client_type", clientType),

View File

@@ -440,7 +440,7 @@ COMMENT ON COLUMN meeting_rooms.title IS '会议标题';
COMMENT ON COLUMN meeting_rooms.host_id IS '主持人用户 ID引用 auth_users.id'; COMMENT ON COLUMN meeting_rooms.host_id IS '主持人用户 ID引用 auth_users.id';
COMMENT ON COLUMN meeting_rooms.type IS '会议类型1=即时MVP 仅此2=预约Phase 2e-3'; COMMENT ON COLUMN meeting_rooms.type IS '会议类型1=即时MVP 仅此2=预约Phase 2e-3';
COMMENT ON COLUMN meeting_rooms.password_hash IS '入会密码 bcrypt 哈希NULL 表示无密码'; COMMENT ON COLUMN meeting_rooms.password_hash IS '入会密码 bcrypt 哈希NULL 表示无密码';
COMMENT ON COLUMN meeting_rooms.max_members IS '房间最大成员数MVP 应用层强制 8schema 默认 50 供后续扩展'; COMMENT ON COLUMN meeting_rooms.max_members IS '房间最大成员数schema 默认 50';
COMMENT ON COLUMN meeting_rooms.status IS '0=未开始仅预约1=进行中2=已结束'; COMMENT ON COLUMN meeting_rooms.status IS '0=未开始仅预约1=进行中2=已结束';
COMMENT ON COLUMN meeting_rooms.scheduled_at IS '预约开始时间,即时会议为 NULL'; COMMENT ON COLUMN meeting_rooms.scheduled_at IS '预约开始时间,即时会议为 NULL';
COMMENT ON COLUMN meeting_rooms.started_at IS '实际开始时间host 首次加入时回填'; COMMENT ON COLUMN meeting_rooms.started_at IS '实际开始时间host 首次加入时回填';

View File

@@ -29,7 +29,7 @@ COMMENT ON COLUMN meeting_rooms.title IS '会议标题';
COMMENT ON COLUMN meeting_rooms.host_id IS '主持人用户 ID引用 auth_users.id'; COMMENT ON COLUMN meeting_rooms.host_id IS '主持人用户 ID引用 auth_users.id';
COMMENT ON COLUMN meeting_rooms.type IS '会议类型1=即时MVP 仅此2=预约Phase 2e-3'; COMMENT ON COLUMN meeting_rooms.type IS '会议类型1=即时MVP 仅此2=预约Phase 2e-3';
COMMENT ON COLUMN meeting_rooms.password_hash IS '入会密码 bcrypt 哈希NULL 表示无密码'; COMMENT ON COLUMN meeting_rooms.password_hash IS '入会密码 bcrypt 哈希NULL 表示无密码';
COMMENT ON COLUMN meeting_rooms.max_members IS '房间最大成员数MVP 应用层强制 8schema 默认 50 供后续扩展'; COMMENT ON COLUMN meeting_rooms.max_members IS '房间最大成员数schema 默认 50';
COMMENT ON COLUMN meeting_rooms.status IS '0=未开始仅预约1=进行中2=已结束'; COMMENT ON COLUMN meeting_rooms.status IS '0=未开始仅预约1=进行中2=已结束';
COMMENT ON COLUMN meeting_rooms.scheduled_at IS '预约开始时间,即时会议为 NULL'; COMMENT ON COLUMN meeting_rooms.scheduled_at IS '预约开始时间,即时会议为 NULL';
COMMENT ON COLUMN meeting_rooms.started_at IS '实际开始时间host 首次加入时回填'; COMMENT ON COLUMN meeting_rooms.started_at IS '实际开始时间host 首次加入时回填';

View File

@@ -10,6 +10,9 @@
(coverSupport ? ', viewport-fit=cover' : '') + '" />') (coverSupport ? ', viewport-fit=cover' : '') + '" />')
</script> </script>
<title></title> <title></title>
<!-- 微信 JSSDKH5 在小程序 web-view 内需要它才能调用 wx.miniProgram.redirectTo/navigateBack 等,
否则视频结束后无法驱动外层小程序跳转PC/普通浏览器加载它无副作用) -->
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<!--preload-links--> <!--preload-links-->
<!--app-context--> <!--app-context-->
</head> </head>

View File

@@ -28,7 +28,7 @@
<line x1="8" y1="23" x2="16" y2="23"></line> <line x1="8" y1="23" x2="16" y2="23"></line>
</svg> </svg>
</view> </view>
<text class="label">{{ audioEnabled ? '静音' : '解除' }}</text> <text class="label">{{ audioEnabled ? '静音' : '开麦' }}</text>
</button> </button>
<button class="btn" :class="{ active: videoEnabled, loading: videoLoading }" :disabled="videoLoading" @click="emitIf('cam-toggle')"> <button class="btn" :class="{ active: videoEnabled, loading: videoLoading }" :disabled="videoLoading" @click="emitIf('cam-toggle')">
@@ -42,11 +42,15 @@
<path d="M16 16v2a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v4m0 0l6-4v10"></path> <path d="M16 16v2a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v4m0 0l6-4v10"></path>
</svg> </svg>
</view> </view>
<text class="label">{{ videoEnabled ? '停止视频' : '开启视频' }}</text> <text class="label">
<text class="label-desktop">{{ videoEnabled ? '停止视频' : '开启视频' }}</text>
<text class="label-mobile">视频</text>
</text>
</button> </button>
<button <button
class="btn" v-if="!replaceChatWithScreenShare"
class="btn btn-screen"
:class="{ active: screenSharing, loading: screenLoading }" :class="{ active: screenSharing, loading: screenLoading }"
:disabled="screenLoading || screenDisabled" :disabled="screenLoading || screenDisabled"
:title="screenDisabledReason || ''" :title="screenDisabledReason || ''"
@@ -70,7 +74,10 @@
<line x1="12" y1="7" x2="12" y2="14"></line> <line x1="12" y1="7" x2="12" y2="14"></line>
</svg> </svg>
</view> </view>
<text class="label">{{ screenSharing ? '停止共享' : '共享屏幕' }}</text> <text class="label">
<text class="label-desktop">{{ screenSharing ? '停止共享' : '共享屏幕' }}</text>
<text class="label-mobile">共享</text>
</text>
</button> </button>
<!-- 录制按钮Phase B仅主持人可见recording=true 时红色脉动上传中禁用 --> <!-- 录制按钮Phase B仅主持人可见recording=true 时红色脉动上传中禁用 -->
@@ -90,7 +97,10 @@
<circle cx="12" cy="12" r="3"></circle> <circle cx="12" cy="12" r="3"></circle>
</svg> </svg>
</view> </view>
<text class="label">{{ recording ? '停止录制' : '开始录制' }}</text> <text class="label">
<text class="label-desktop">{{ recording ? '停止录制' : '开始录制' }}</text>
<text class="label-mobile">录制</text>
</text>
</button> </button>
<button class="btn" @click="emitIf('invite')"> <button class="btn" @click="emitIf('invite')">
@@ -118,7 +128,36 @@
<text class="label">成员</text> <text class="label">成员</text>
</button> </button>
<button class="btn" :disabled="!allowChat" @click="emitIf('chat')"> <button
v-if="replaceChatWithScreenShare"
class="btn btn-screen-share-slot"
:class="{ active: screenSharing, loading: screenLoading }"
:disabled="screenLoading || screenDisabled"
:title="screenDisabledReason || ''"
@click="emitIf('screen-toggle')"
>
<view class="icon">
<svg v-if="screenSharing" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
<line x1="6" y1="7" x2="18" y2="13"></line>
<line x1="18" y1="7" x2="6" y2="13"></line>
</svg>
<svg v-else viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
<polyline points="9 10 12 7 15 10"></polyline>
<line x1="12" y1="7" x2="12" y2="14"></line>
</svg>
</view>
<text class="label">
<text class="label-desktop">{{ screenSharing ? '停止共享' : '共享屏幕' }}</text>
<text class="label-mobile">{{ screenSharing ? '停止' : '共享' }}</text>
</text>
</button>
<button v-else class="btn" :disabled="!allowChat" @click="emitIf('chat')">
<view class="icon"> <view class="icon">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path> <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
@@ -164,7 +203,9 @@ const props = defineProps({
/** Phase B 录制:当前是否在录制中 */ /** Phase B 录制:当前是否在录制中 */
recording: { type: Boolean, default: false }, recording: { type: Boolean, default: false },
/** Phase B 录制REST 调用 / 上传中 loading 态,禁用按钮 */ /** Phase B 录制REST 调用 / 上传中 loading 态,禁用按钮 */
recordingLoading: { type: Boolean, default: false } recordingLoading: { type: Boolean, default: false },
/** 云律律师端:用「共享屏幕」替换「聊天」按钮(移动端工具栏不隐藏屏幕共享) */
replaceChatWithScreenShare: { type: Boolean, default: false }
}) })
const emit = defineEmits(['mic-toggle', 'cam-toggle', 'screen-toggle', 'recording-toggle', 'invite', 'members', 'chat', 'leave']) const emit = defineEmits(['mic-toggle', 'cam-toggle', 'screen-toggle', 'recording-toggle', 'invite', 'members', 'chat', 'leave'])
@@ -233,27 +274,38 @@ const emitIf = (name) => {
.icon { .icon {
position: relative; position: relative;
width: 72rpx; width: 88rpx;
height: 72rpx; height: 88rpx;
border-radius: 50%; border-radius: 50%;
background: rgba(55, 65, 81, 0.9); background: linear-gradient(180deg, rgba(71, 85, 105, 0.95), rgba(51, 65, 81, 0.95));
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #FFFFFF; color: #FFFFFF;
transition: background-color 0.15s ease; box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.12), 0 4rpx 10rpx rgba(0, 0, 0, 0.28);
transition: background 0.18s ease, transform 0.12s ease, box-shadow 0.18s ease;
} }
.btn.active .icon { background: #3B82F6; } .btn.active .icon {
background: linear-gradient(180deg, #4F8DFF, #2563EB);
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.28), 0 4rpx 14rpx rgba(37, 99, 235, 0.5);
}
.btn.active .label { color: #BFDBFE; }
.btn.loading .icon { opacity: 0.6; } .btn.loading .icon { opacity: 0.6; }
.btn:active .icon { transform: scale(0.92); }
.label { .label {
font-size: 22rpx; font-size: 22rpx;
letter-spacing: 0.5rpx; letter-spacing: 0.5rpx;
color: inherit; color: inherit;
} }
.label-mobile { display: none; }
.btn-leave .icon { background: #EF4444; } .btn-leave .icon {
.btn-leave:hover .icon { background: #DC2626; } background: linear-gradient(180deg, #FB5B5B, #EF4444);
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.28), 0 4rpx 14rpx rgba(239, 68, 68, 0.5);
}
.btn-leave:hover .icon { background: linear-gradient(180deg, #F87171, #DC2626); }
.btn-leave .label { color: #FECACA; }
/* Phase B 录制按钮:录制中红色脉动 */ /* Phase B 录制按钮:录制中红色脉动 */
.btn-recording.is-recording .icon { .btn-recording.is-recording .icon {
@@ -273,20 +325,116 @@ const emitIf = (name) => {
height: 32rpx; height: 32rpx;
padding: 0 6rpx; padding: 0 6rpx;
border-radius: 999rpx; border-radius: 999rpx;
background: #10B981; background: linear-gradient(180deg, #34D399, #10B981);
color: #FFFFFF; color: #FFFFFF;
font-size: 18rpx; font-size: 18rpx;
font-weight: 600; font-weight: 700;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.4); border: 2rpx solid rgba(13, 21, 38, 0.95);
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.45);
} }
.badge.badge-red { background: #EF4444; } .badge.badge-red { background: linear-gradient(180deg, #FB7185, #EF4444); }
@media (max-width: 420px) { @media (max-width: 750px) {
.btn { min-width: 88rpx; } .toolbar {
.icon { width: 64rpx; height: 64rpx; } position: fixed;
.label { font-size: 20rpx; } left: 14rpx;
right: 14rpx;
bottom: calc(12rpx + env(safe-area-inset-bottom));
justify-content: space-between;
gap: 4rpx;
box-sizing: border-box;
padding: 18rpx 12rpx;
background: linear-gradient(180deg, rgba(17, 26, 46, 0.92), rgba(7, 15, 30, 0.94));
backdrop-filter: blur(20px);
border: 1rpx solid rgba(148, 163, 184, 0.2);
border-radius: 34rpx;
box-shadow: 0 16rpx 40rpx rgba(0, 0, 0, 0.48), inset 0 1rpx 0 rgba(255, 255, 255, 0.06);
overflow-x: hidden;
overflow-y: hidden;
}
.btn {
min-width: 0;
flex: 1 1 0;
gap: 10rpx;
padding: 4rpx 0;
border-radius: 18rpx;
}
.btn:hover { background: transparent; }
.btn:active { background: transparent; }
.btn:active .icon { transform: scale(0.9); }
.btn-screen {
display: none;
}
/* 律师端:共享屏幕占原「聊天」槽位,移动端仍需显示 */
.btn-screen-share-slot {
display: flex;
}
.icon {
width: 80rpx;
height: 80rpx;
background: linear-gradient(180deg, rgba(71, 85, 105, 0.95), rgba(44, 56, 78, 0.96));
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.12), 0 6rpx 16rpx rgba(0, 0, 0, 0.36);
}
.icon svg {
width: 42rpx;
height: 42rpx;
}
.btn.active .icon {
background: linear-gradient(180deg, #4F8DFF, #2563EB);
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.3), 0 6rpx 18rpx rgba(37, 99, 235, 0.55);
}
.btn[disabled] .icon {
background: rgba(30, 41, 59, 0.72);
color: rgba(255, 255, 255, 0.38);
box-shadow: inset 0 0 0 1rpx rgba(255, 255, 255, 0.05);
}
.btn[disabled] .label { color: rgba(255, 255, 255, 0.36); }
.btn-leave .icon {
background: linear-gradient(180deg, #FB5B5B, #EF4444);
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.3), 0 6rpx 18rpx rgba(239, 68, 68, 0.55);
}
.btn-leave .label { color: #FECACA; }
.label {
max-width: 100%;
font-size: 20rpx;
line-height: 1;
font-weight: 500;
color: rgba(255, 255, 255, 0.74);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.label-desktop { display: none; }
.label-mobile { display: inline; }
.badge {
top: -8rpx;
right: -8rpx;
min-width: 28rpx;
height: 28rpx;
font-size: 16rpx;
}
}
@media (max-width: 380px) {
.toolbar {
left: 10rpx;
right: 10rpx;
padding-left: 8rpx;
padding-right: 8rpx;
}
.icon {
width: 56rpx;
height: 56rpx;
}
.icon svg {
width: 38rpx;
height: 38rpx;
}
.label {
font-size: 18rpx;
}
} }
</style> </style>

View File

@@ -49,12 +49,14 @@ const props = defineProps({
audioTrack: { type: Object, default: null }, audioTrack: { type: Object, default: null },
videoTrack: { type: Object, default: null }, videoTrack: { type: Object, default: null },
isSpeaking: { type: Boolean, default: false }, isSpeaking: { type: Boolean, default: false },
compact: { type: Boolean, default: false } compact: { type: Boolean, default: false },
videoFit: { type: String, default: 'cover' }
}) })
const mediaBox = ref(null) const mediaBox = ref(null)
const hasVideo = computed(() => !!props.videoTrack && props.videoEnabled) const hasVideo = computed(() => !!props.videoTrack && props.videoEnabled)
const normalizedVideoFit = computed(() => props.videoFit === 'contain' ? 'contain' : 'cover')
const avatarInitial = computed(() => { const avatarInitial = computed(() => {
const s = (props.name || '').trim() const s = (props.name || '').trim()
@@ -82,7 +84,7 @@ const ensureMediaEl = (tagName) => {
if (tagName === 'video') { if (tagName === 'video') {
el.style.width = '100%' el.style.width = '100%'
el.style.height = '100%' el.style.height = '100%'
el.style.objectFit = 'cover' el.style.objectFit = normalizedVideoFit.value
el.style.background = '#0B1220' el.style.background = '#0B1220'
el.style.display = 'block' el.style.display = 'block'
} }
@@ -134,6 +136,7 @@ const applyVideo = (retry = 0) => {
} }
const el = ensureMediaEl('video') const el = ensureMediaEl('video')
if (!el) return if (!el) return
el.style.objectFit = normalizedVideoFit.value
// 关键:<video> 必须 muted 才能在 Chrome / 微信内核里自动播放。 // 关键:<video> 必须 muted 才能在 Chrome / 微信内核里自动播放。
// 音频在独立的 <audio> 元素上播放,所以此处 muted 不会让对方静音, // 音频在独立的 <audio> 元素上播放,所以此处 muted 不会让对方静音,
// 同时彻底解决"进会黑屏,必须点切换按钮才出图"的问题。 // 同时彻底解决"进会黑屏,必须点切换按钮才出图"的问题。
@@ -183,7 +186,7 @@ const applyAudio = (retry = 0) => {
// #endif // #endif
} }
watch(() => [props.videoTrack, props.videoEnabled], () => nextTick(() => applyVideo()), { immediate: true }) watch(() => [props.videoTrack, props.videoEnabled, props.videoFit], () => nextTick(() => applyVideo()), { immediate: true })
watch(() => [props.audioTrack, props.audioEnabled, props.isLocal], () => nextTick(() => applyAudio()), { immediate: true }) watch(() => [props.audioTrack, props.audioEnabled, props.isLocal], () => nextTick(() => applyAudio()), { immediate: true })
// 修复刚进入会议时画面不显示、必须点切换才出图的问题: // 修复刚进入会议时画面不显示、必须点切换才出图的问题:
@@ -350,4 +353,25 @@ onBeforeUnmount(() => {
.tile.compact .footer { padding: 8rpx 12rpx; } .tile.compact .footer { padding: 8rpx 12rpx; }
.tile.compact .name { font-size: 20rpx; } .tile.compact .name { font-size: 20rpx; }
.tile.compact .tag { font-size: 18rpx; padding: 2rpx 8rpx; } .tile.compact .tag { font-size: 18rpx; padding: 2rpx 8rpx; }
@media (max-width: 750px) {
.footer {
left: 12rpx;
right: 12rpx;
bottom: 12rpx;
padding: 8rpx 12rpx;
border-radius: 14rpx;
background: rgba(2, 6, 23, 0.62);
backdrop-filter: blur(10px);
}
.name {
font-size: 20rpx;
}
.tile.compact .footer {
left: 8rpx;
right: 8rpx;
bottom: 8rpx;
padding: 6rpx 10rpx;
}
}
</style> </style>

View File

@@ -90,7 +90,7 @@ export const MEETING_HOST_AUTO_REASON_LABEL = {
// ==================== 默认配置常量 ==================== // ==================== 默认配置常量 ====================
export const MEETING_MVP_MAX_MEMBERS = 8 // MVP 单会议硬上限 export const MEETING_MVP_MAX_MEMBERS = 1000000 // MVP 单会议硬上限
export const MEETING_HOST_GRACE_SECONDS = 120 // 主持人掉线宽限期Task 8 export const MEETING_HOST_GRACE_SECONDS = 120 // 主持人掉线宽限期Task 8
export const MEETING_EMPTY_ROOM_TTL_SECONDS = 300 // 空房销毁 TTLTask 8 export const MEETING_EMPTY_ROOM_TTL_SECONDS = 300 // 空房销毁 TTLTask 8
export const MEETING_WS_ACK_TIMEOUT_MS = 10000 // WS 事件 ACK 等待超时Task 9 export const MEETING_WS_ACK_TIMEOUT_MS = 10000 // WS 事件 ACK 等待超时Task 9

View File

@@ -9,7 +9,7 @@
{ {
"path": "pages/index/index", "path": "pages/index/index",
"style": { "style": {
"navigationBarTitleText": "EchoChat", "navigationBarTitleText": "赏讼视频会议系统",
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
@@ -227,7 +227,7 @@
}, },
"globalStyle": { "globalStyle": {
"navigationBarTextStyle": "black", "navigationBarTextStyle": "black",
"navigationBarTitleText": "EchoChat", "navigationBarTitleText": "赏讼视频会议系统",
"navigationBarBackgroundColor": "#FFFFFF", "navigationBarBackgroundColor": "#FFFFFF",
"backgroundColor": "#F8FAFC" "backgroundColor": "#F8FAFC"
} }

View File

@@ -80,8 +80,17 @@
<view class="oauth-line"></view> <view class="oauth-line"></view>
</view> </view>
<view class="oauth-buttons"> <view class="oauth-buttons">
<button class="oauth-btn oauth-wechat" :disabled="oauthLoading" @tap="oauthLogin('wechat')">微信登录</button> <button class="oauth-btn oauth-wechat" :disabled="oauthLoading" @tap="oauthLogin('wechat')">
<button class="oauth-btn oauth-qq" :disabled="oauthLoading" @tap="oauthLogin('qq')">QQ登录</button> <view class="oauth-icon oauth-icon-wechat">
<view class="wechat-bubble wechat-bubble-large"></view>
<view class="wechat-bubble wechat-bubble-small"></view>
</view>
<text>微信登录</text>
</button>
<button class="oauth-btn oauth-qq" :disabled="oauthLoading" @tap="oauthLogin('qq')">
<text class="oauth-icon oauth-icon-qq">Q</text>
<text>QQ登录</text>
</button>
</view> </view>
</view> </view>
@@ -433,6 +442,10 @@ export default {
font-size: 28rpx; font-size: 28rpx;
color: #FFFFFF; color: #FFFFFF;
border: none; border: none;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
} }
.oauth-btn::after { .oauth-btn::after {
@@ -447,6 +460,49 @@ export default {
background-color: #12B7F5; background-color: #12B7F5;
} }
.oauth-icon {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.94);
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
font-weight: 700;
line-height: 36rpx;
}
.oauth-icon-wechat {
position: relative;
color: #07C160;
}
.oauth-icon-qq {
color: #12B7F5;
}
.wechat-bubble {
position: absolute;
border-radius: 50%;
background-color: #07C160;
}
.wechat-bubble-large {
width: 18rpx;
height: 14rpx;
left: 8rpx;
top: 10rpx;
}
.wechat-bubble-small {
width: 14rpx;
height: 11rpx;
right: 7rpx;
bottom: 9rpx;
border: 2rpx solid #FFFFFF;
}
/* ---- 底部链接 ---- */ /* ---- 底部链接 ---- */
.link-row { .link-row {
display: flex; display: flex;

View File

@@ -20,10 +20,10 @@
<!-- 顶部品牌区域 --> <!-- 顶部品牌区域 -->
<view class="brand-section"> <view class="brand-section">
<view class="logo-box"> <view class="logo-box">
<text class="logo-letter">E</text> <image class="brand-logo" src="/static/shangsong.png" mode="aspectFit" />
</view> </view>
<text class="brand-name">创建账号</text> <text class="brand-name">创建账号</text>
<text class="brand-slogan">加入 EchoChat开启高效沟通</text> <text class="brand-slogan">加入赏讼视频会议系统开启高效沟通</text>
</view> </view>
<!-- 表单卡片 --> <!-- 表单卡片 -->
@@ -271,17 +271,14 @@ export default {
.logo-box { .logo-box {
width: 96rpx; width: 96rpx;
height: 96rpx; height: 96rpx;
border-radius: 20rpx;
background-color: #2563EB;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.logo-letter { .brand-logo {
font-size: 44rpx; width: 96rpx;
font-weight: 700; height: 96rpx;
color: #FFFFFF;
} }
.brand-name { .brand-name {

View File

@@ -7,6 +7,7 @@
<text class="entry-title">{{ entryTitle }}</text> <text class="entry-title">{{ entryTitle }}</text>
<text class="entry-desc">{{ joinError || stateText }}</text> <text class="entry-desc">{{ joinError || stateText }}</text>
<button v-if="joinError" class="retry-btn" @click="joinMeeting">重新进入</button> <button v-if="joinError" class="retry-btn" @click="joinMeeting">重新进入</button>
<button v-else-if="ended && buildMiniProgramReturnUrl()" class="retry-btn" @click="goToConsultDetail">查看咨询详情</button>
</view> </view>
<view v-else class="call-shell" :class="{ 'voice-mode': isVoiceMode }"> <view v-else class="call-shell" :class="{ 'voice-mode': isVoiceMode }">
@@ -59,13 +60,14 @@
</view> </view>
<view v-if="!isVoiceMode" class="video-topbar"> <view v-if="!isVoiceMode" class="video-topbar">
<text v-if="isLawRole" class="video-back" @click="minimizeToMiniProgram"></text>
<view class="top-window-icon" @click="swapPrimaryTile"></view> <view class="top-window-icon" @click="swapPrimaryTile"></view>
<text class="top-duration">{{ durationText }}</text> <text class="top-duration">{{ durationText }}</text>
<text class="top-plus"></text> <text class="top-plus"></text>
</view> </view>
<view v-else class="voice-topbar"> <view v-else class="voice-topbar">
<text class="voice-back" @click="hangup"></text> <text class="voice-back" @click="onTopBack"></text>
</view> </view>
<view v-if="isVoiceMode" class="voice-duration">{{ durationText }}</view> <view v-if="isVoiceMode" class="voice-duration">{{ durationText }}</view>
@@ -95,7 +97,17 @@
</view> </view>
<view class="controls-row controls-row-bottom"> <view class="controls-row controls-row-bottom">
<view class="control-item" @click="takeSnapshot" @tap="takeSnapshot"> <!-- 律师端底部左侧为共享屏幕替代通用会议页聊天能力 -->
<view v-if="isLawRole" class="control-item" @click="onScreenToggle" @tap="onScreenToggle">
<view
class="control-btn dark-btn text-icon-btn"
:class="{ active: screenSharingByMe, loading: screenLoading, disabled: screenDisabled && !screenSharingByMe }"
>
<text class="text-icon">{{ screenSharingByMe ? '停' : '屏' }}</text>
</view>
<text class="control-label">{{ screenSharingByMe ? '停止共享' : '共享屏幕' }}</text>
</view>
<view v-else class="control-item" @click="takeSnapshot" @tap="takeSnapshot">
<view class="control-btn dark-btn text-icon-btn"> <view class="control-btn dark-btn text-icon-btn">
<image v-if="!captureIconError" class="control-image" :src="captureIcon" mode="aspectFit" @error="captureIconError = true"></image> <image v-if="!captureIconError" class="control-image" :src="captureIcon" mode="aspectFit" @error="captureIconError = true"></image>
<text v-else class="text-icon"></text> <text v-else class="text-icon"></text>
@@ -150,6 +162,13 @@ import {
MEETING_ENDED_REASON_LABEL MEETING_ENDED_REASON_LABEL
} from '@/constants/meeting' } from '@/constants/meeting'
import VideoTile from '@/components/meeting/VideoTile.vue' import VideoTile from '@/components/meeting/VideoTile.vue'
import { minimizeCloudLawMeeting } from '@/utils/cloudLawMiniProgram'
import {
notifyCloudLawBackendEnd,
notifyCloudLawParentCallEnded,
isCloudLawPcIframe,
notifyNativeMiniProgramHangup
} from '@/utils/cloudLawVoiceCall'
import bgImage from '@/static/bg.png' import bgImage from '@/static/bg.png'
import hangupIcon from '@/static/cacel.png' import hangupIcon from '@/static/cacel.png'
import voiceOnIcon from '@/static/vocie_calling_1.png' import voiceOnIcon from '@/static/vocie_calling_1.png'
@@ -168,11 +187,15 @@ const consultId = ref('')
const miniProgramReturnUrl = ref('') const miniProgramReturnUrl = ref('')
const cloudLawApiBase = ref('') const cloudLawApiBase = ref('')
const cloudLawToken = ref('') const cloudLawToken = ref('')
// 调用方角色:'law' = 律师端(接单端),其余/空 = 用户端applet
// 律师为参会者(非主持人),结束会议走 /api/law/voiceCall/{callId}/end用户端默认 /api/applet/voiceConnection。
const cloudLawRole = ref('')
const callMode = ref('video') const callMode = ref('video')
const joining = ref(false) const joining = ref(false)
const joinError = ref('') const joinError = ref('')
const audioLoading = ref(false) const audioLoading = ref(false)
const videoLoading = ref(false) const videoLoading = ref(false)
const screenLoading = ref(false)
const leaving = ref(false) const leaving = ref(false)
const startedAt = ref(0) const startedAt = ref(0)
const nowTs = ref(Date.now()) const nowTs = ref(Date.now())
@@ -191,6 +214,10 @@ const captureIconError = ref(false)
const flipCameraIconError = ref(false) const flipCameraIconError = ref(false)
const snapshotPreviewUrl = ref('') const snapshotPreviewUrl = ref('')
const snapshotBusy = ref(false) const snapshotBusy = ref(false)
/** 用户点击挂断(区别于返回上一页最小化会议) */
const userInitiatedHangup = ref(false)
/** 律师端从小程序 web-view 返回:仅本地离会,不结束云律通话、不通知小程序通话已结束 */
const minimizingToMiniProgram = ref(false)
const digitsOf = (value) => String(value || '').replace(/\D/g, '').slice(0, 9) const digitsOf = (value) => String(value || '').replace(/\D/g, '').slice(0, 9)
const sameUser = (a, b) => String(a || '') === String(b || '') const sameUser = (a, b) => String(a || '') === String(b || '')
@@ -208,6 +235,9 @@ const audioEnabled = computed(() => !!meetingStore.localAudioEnabled)
const videoEnabled = computed(() => !!meetingStore.localVideoEnabled) const videoEnabled = computed(() => !!meetingStore.localVideoEnabled)
const speakingMap = computed(() => meetingStore.speakingMap || {}) const speakingMap = computed(() => meetingStore.speakingMap || {})
const isVoiceMode = computed(() => callMode.value === 'voice') const isVoiceMode = computed(() => callMode.value === 'voice')
const isLawRole = computed(() => String(cloudLawRole.value).toLowerCase() === 'law')
// 结束会议接口基址:律师端 /api/law/voiceCall用户端 /api/applet/voiceConnection
const voiceEndBase = computed(() => isLawRole.value ? '/api/law/voiceCall' : '/api/applet/voiceConnection')
const cameraIcon = computed(() => videoEnabled.value ? videoOnIcon : videoOffIcon) const cameraIcon = computed(() => videoEnabled.value ? videoOnIcon : videoOffIcon)
const micIcon = computed(() => audioEnabled.value ? voiceOnIcon : voiceOffIcon) const micIcon = computed(() => audioEnabled.value ? voiceOnIcon : voiceOffIcon)
const speakerIcon = computed(() => speakerMuted.value ? speakerOffIcon : speakerOnIcon) const speakerIcon = computed(() => speakerMuted.value ? speakerOffIcon : speakerOnIcon)
@@ -237,7 +267,56 @@ const participants = computed(() => {
const localTrackVersion = computed(() => { const localTrackVersion = computed(() => {
const producers = meetingStore.localProducers || {} const producers = meetingStore.localProducers || {}
return `${producers.audio || ''}-${producers.video || ''}` return `${producers.audio || ''}-${producers.video || ''}-${producers.screen || ''}`
})
const screenSharingActive = computed(() => !!meetingStore.screenShare?.ownerUserId)
const screenSharingByMe = computed(() => {
const owner = meetingStore.screenShare?.ownerUserId
return !!owner && sameUser(owner, myUserId.value)
})
const screenSupported = computed(() => {
// #ifdef H5
return typeof navigator !== 'undefined' &&
!!navigator.mediaDevices &&
typeof navigator.mediaDevices.getDisplayMedia === 'function'
// #endif
// #ifndef H5
return false
// #endif
})
const screenDisabled = computed(() => {
if (!screenSupported.value) return true
if (screenSharingActive.value && !screenSharingByMe.value) return true
return false
})
const screenDisabledReason = computed(() => {
if (!screenSupported.value) return '当前环境不支持屏幕共享'
if (screenSharingActive.value && !screenSharingByMe.value) return '已有成员正在共享屏幕'
return ''
})
const screenTile = computed(() => {
const owner = meetingStore.screenShare?.ownerUserId
if (!owner) return null
localTrackVersion.value
remoteTrackVersion.value
const isLocal = sameUser(owner, myUserId.value)
const track = isLocal
? meetingStore.getLocalTrack('screen')
: meetingStore.getRemoteTrack(owner, 'screen')
if (!track) return null
return {
key: `screen_${owner}`,
userId: owner,
name: isLocal ? '你' : nameOf(owner),
isLocal,
isHost: sameUser(owner, hostId.value),
audioEnabled: false,
videoEnabled: true,
audioTrack: null,
videoTrack: track
}
}) })
const remoteTrackVersion = computed(() => { const remoteTrackVersion = computed(() => {
@@ -245,7 +324,7 @@ const remoteTrackVersion = computed(() => {
const map = meetingStore.remoteConsumers || {} const map = meetingStore.remoteConsumers || {}
Object.keys(map).forEach(uid => { Object.keys(map).forEach(uid => {
const slot = map[uid] const slot = map[uid]
parts.push(`${uid}:${slot?.audio?.id || ''}:${slot?.video?.id || ''}`) parts.push(`${uid}:${slot?.audio?.id || ''}:${slot?.video?.id || ''}:${slot?.screen?.id || ''}`)
}) })
return parts.join('|') return parts.join('|')
}) })
@@ -308,6 +387,7 @@ const tiles = computed(() => {
const selfTile = computed(() => tiles.value.find(t => t.isLocal) || null) const selfTile = computed(() => tiles.value.find(t => t.isLocal) || null)
const remoteTile = computed(() => tiles.value.find(t => !t.isLocal && t.videoTrack) || tiles.value.find(t => !t.isLocal) || null) const remoteTile = computed(() => tiles.value.find(t => !t.isLocal && t.videoTrack) || tiles.value.find(t => !t.isLocal) || null)
const primaryTile = computed(() => { const primaryTile = computed(() => {
if (screenTile.value) return screenTile.value
if (preferSelfMain.value && selfTile.value) return selfTile.value if (preferSelfMain.value && selfTile.value) return selfTile.value
return remoteTile.value || selfTile.value return remoteTile.value || selfTile.value
}) })
@@ -399,51 +479,174 @@ const requestCloudLawCommands = (url) => {
} }
const notifyCloudLawEnd = () => { const notifyCloudLawEnd = () => {
if (!callId.value || !cloudLawApiBase.value) return Promise.resolve() const role = isLawRole.value ? 'law' : 'caller'
return new Promise(resolve => { const roomCode = meetingStore.currentRoom?.room_code || formattedCode.value || ''
uni.request({ console.log('[CloudLawVideoCall] notifyCloudLawEnd called', {
url: resolveCloudLawApiUrl(`/api/applet/voiceConnection/${encodeURIComponent(callId.value)}/end`), callId: callId.value,
method: 'POST', role,
data: {}, roomCode,
header: cloudLawToken.value ? { 'Content-Type': 'application/json', token: `Bearer ${cloudLawToken.value}` } : { 'Content-Type': 'application/json' }, cloudLawApiBase: cloudLawApiBase.value,
timeout: 5000, hasToken: !!cloudLawToken.value
complete: resolve
})
}) })
if (!callId.value) {
console.warn('[CloudLawVideoCall] notifyCloudLawEnd SKIP: callId 为空,不会发结束请求')
return Promise.resolve()
}
return notifyCloudLawBackendEnd(callId.value, role, cloudLawApiBase.value, cloudLawToken.value, roomCode)
} }
const buildMiniProgramReturnUrl = () => { const buildMiniProgramReturnUrl = () => {
const configured = String(miniProgramReturnUrl.value || '').trim() const decodeRepeated = (value) => {
if (configured) { let text = String(value || '').trim()
if (!text) return ''
for (let i = 0; i < 3; i++) {
try { try {
return decodeURIComponent(configured) const next = decodeURIComponent(text)
if (next === text) break
text = next
} catch (e) { } catch (e) {
break
}
}
return text
}
const configured = decodeRepeated(miniProgramReturnUrl.value)
if (configured) {
return configured return configured
} }
if (!consultId.value) {
// #ifdef H5
try {
const cachedCallId = window.sessionStorage.getItem('echo_cloud_law_call_id') || ''
if (callId.value && cachedCallId && cachedCallId !== callId.value) {
window.sessionStorage.removeItem('echo_cloud_law_consult_id')
window.sessionStorage.removeItem('echo_cloud_law_return_url')
return ''
}
const cachedConsultId = window.sessionStorage.getItem('echo_cloud_law_consult_id') || ''
if (cachedConsultId) {
return `/pages_work/ai_consult/ai_consult?id=${encodeURIComponent(cachedConsultId)}&mode=detail&from=videoCall`
}
const cached = window.sessionStorage.getItem('echo_cloud_law_return_url') || ''
if (cached) return decodeRepeated(cached)
} catch (e) {}
// #endif
return ''
} }
if (!consultId.value) return ''
return `/pages_work/ai_consult/ai_consult?id=${encodeURIComponent(consultId.value)}&mode=detail&from=videoCall` return `/pages_work/ai_consult/ai_consult?id=${encodeURIComponent(consultId.value)}&mode=detail&from=videoCall`
} }
let endedRedirectTimer = null
let miniProgramRedirected = false
let miniProgramEndNotified = false
let miniProgramRedirectRetryTimer = null
const getMiniProgram = () => {
return typeof window !== 'undefined' && window.wx && window.wx.miniProgram ? window.wx.miniProgram : null
}
const navigateMiniProgramToReturnUrl = (returnUrl) => {
const mp = getMiniProgram()
if (!mp) return false
const url = String(returnUrl || '').trim()
const tryBack = () => {
try {
if (typeof mp.navigateBack === 'function') {
mp.navigateBack({ delta: 1 })
}
} catch (e) {
console.warn('[CloudLawVideoCall] miniProgram navigateBack failed', e)
}
}
if (!url) {
tryBack()
return true
}
// 用 fail 回调级联reLaunch最可靠可跨分包会清栈→ redirectTo → navigateTo → navigateBack。
// 关键:上一版直接 mp.reLaunch({url}) 不带回调、调用即 return truejweixin 静默失败时
// 仍被判成功、阻断后续兜底,表现为「停在 EchoChat 已结束页、不跳咨询详情」。
const tryNavigate = () => {
if (typeof mp.navigateTo === 'function') {
mp.navigateTo({ url, fail: () => tryBack() })
} else {
tryBack()
}
}
const tryRedirect = () => {
if (typeof mp.redirectTo === 'function') {
mp.redirectTo({ url, fail: () => tryNavigate() })
} else {
tryNavigate()
}
}
if (typeof mp.reLaunch === 'function') {
mp.reLaunch({ url, fail: () => tryRedirect() })
} else {
tryRedirect()
}
return true
}
const notifyMiniProgramCallEnded = () => { const notifyMiniProgramCallEnded = () => {
if (isLawRole.value) return
const returnUrl = buildMiniProgramReturnUrl()
const payload = { const payload = {
type: 'cloudLawCallEnded', type: 'cloudLawCallEnded',
action: 'cloudLawCallEnded', action: 'cloudLawCallEnded',
callId: callId.value, callId: callId.value,
consultId: consultId.value, consultId: consultId.value,
returnUrl: buildMiniProgramReturnUrl() returnUrl
} }
const miniProgram = getMiniProgram()
try { try {
const miniProgram = typeof window !== 'undefined' && window.wx && window.wx.miniProgram ? window.wx.miniProgram : null // postMessage 只在 web-view 销毁/分享等时机投递;先发一份,作为小程序侧 onMessage 的兜底
if (miniProgram && typeof miniProgram.postMessage === 'function') { if (miniProgram && typeof miniProgram.postMessage === 'function') {
miniProgram.postMessage({ data: payload }) miniProgram.postMessage({ data: payload })
} }
if (miniProgram && typeof miniProgram.redirectTo === 'function' && payload.returnUrl) {
miniProgram.redirectTo({ url: payload.returnUrl })
}
} catch (e) { } catch (e) {
console.warn('[CloudLawVideoCall] notify mini program failed', e) console.warn('[CloudLawVideoCall] postMessage failed', e)
} }
if (miniProgramRedirected) return
// 首选:直接用 wx.miniProgram 导航到咨询详情 returnUrlreLaunch 会清栈、销毁承载页,
// 触发承载页 onUnload 调同域 /end 收尾)。这是 hash 路由 SPA 内唯一能直达详情页的路径
// ——onWebViewLoad/@message 在 SPA 内 hash 变化时不触发URL 桥接根本不会被原生页命中。
if (navigateMiniProgramToReturnUrl(returnUrl)) {
miniProgramRedirected = true
return
}
// 兜底URL 桥接(改地址加 cloudLawHangup=1触发原生页 @load 收尾+跳详情)。仅在无 wx.miniProgram 时才会走到。
if (notifyNativeMiniProgramHangup()) {
miniProgramRedirected = true
return
}
if (miniProgramRedirectRetryTimer) return
let retry = 0
miniProgramRedirectRetryTimer = setInterval(() => {
retry += 1
if (navigateMiniProgramToReturnUrl(returnUrl) || retry >= 10) {
clearInterval(miniProgramRedirectRetryTimer)
miniProgramRedirectRetryTimer = null
if (retry < 10) miniProgramRedirected = true
}
}, 300)
}
const notifyMiniProgramCallEndedOnce = () => {
if (miniProgramEndNotified) return
miniProgramEndNotified = true
notifyMiniProgramCallEnded()
}
const scheduleEndedRedirect = () => {
if (endedRedirectTimer || isLawRole.value) return
endedRedirectTimer = setTimeout(() => {
endedRedirectTimer = null
notifyCloudLawEnd().finally(() => notifyMiniProgramCallEndedOnce())
}, 1200)
}
const goToConsultDetail = () => {
notifyCloudLawEnd().finally(() => notifyMiniProgramCallEndedOnce())
} }
const pollCommandsOnce = async () => { const pollCommandsOnce = async () => {
@@ -466,6 +669,8 @@ const pollCommandsOnce = async () => {
} }
const startCommandPolling = () => { const startCommandPolling = () => {
// 律师端(接单端)无 publicCommands 远程指令通道,跳过轮询避免无谓 404
if (isLawRole.value) return
if (!callId.value || commandPollHandle) return if (!callId.value || commandPollHandle) return
pollCommandsOnce() pollCommandsOnce()
commandPollHandle = setInterval(pollCommandsOnce, 1200) commandPollHandle = setInterval(pollCommandsOnce, 1200)
@@ -609,6 +814,28 @@ const flipCamera = () => {
flipCameraByCommand() flipCameraByCommand()
} }
const onScreenToggle = async () => {
if (screenLoading.value) return
if (screenDisabled.value && !screenSharingByMe.value) {
uni.showToast({ title: screenDisabledReason.value || '无法共享屏幕', icon: 'none' })
return
}
screenLoading.value = true
try {
if (screenSharingByMe.value) {
await meetingStore.stopScreenShare()
} else {
await meetingStore.startScreenShare()
}
} catch (err) {
if (err?.name !== 'NotAllowedError') {
uni.showToast({ title: err?.message || '屏幕共享失败', icon: 'none' })
}
} finally {
screenLoading.value = false
}
}
const executeCloudLawCommand = async (cmd = {}) => { const executeCloudLawCommand = async (cmd = {}) => {
const command = String(cmd.command || cmd.type || '').toLowerCase() const command = String(cmd.command || cmd.type || '').toLowerCase()
const value = cmd.value const value = cmd.value
@@ -802,14 +1029,59 @@ const onCamToggle = async () => {
} }
} }
const onTopBack = () => {
if (isLawRole.value) {
minimizeToMiniProgram()
} else {
hangup()
}
}
/** 律师端返回小程序上一页:保留进行中通话,便于「返回会议」浮窗再次进入 */
const minimizeToMiniProgram = () => {
if (leaving.value || ended.value || minimizingToMiniProgram.value) return
minimizingToMiniProgram.value = true
stopCommandPolling()
meetingStore.leave().catch(() => {})
if (minimizeCloudLawMeeting(callId.value)) {
return
}
minimizingToMiniProgram.value = false
uni.showToast({ title: '请使用左上角返回', icon: 'none' })
}
const hangup = async () => { const hangup = async () => {
if (leaving.value) return console.log('[CloudLawVideoCall] hangup CLICKED', {
leaving: leaving.value,
isLawRole: isLawRole.value,
callId: callId.value,
roomCode: meetingStore.currentRoom?.room_code || formattedCode.value || ''
})
if (leaving.value) {
console.log('[CloudLawVideoCall] hangup SKIP: leaving 已为 true重复点击')
return
}
userInitiatedHangup.value = true
leaving.value = true leaving.value = true
stopCommandPolling() stopCommandPolling()
try { try {
console.log('[CloudLawVideoCall] hangup -> 调 notifyCloudLawEnd 前')
// 先通知云律后端结束本次通话:
// - 用户端:/api/applet/voiceConnection/{callId}/end
// - 律师端:/api/law/voiceCall/{callId}/end → 后端 CloudLawEndMeeting 结束会议(双方都收到 room.ended
await notifyCloudLawEnd() await notifyCloudLawEnd()
console.log('[CloudLawVideoCall] hangup -> notifyCloudLawEnd 已返回')
if (isLawRole.value) {
// 律师为参会者非主持人EndRoom 是 host-only。会议已由上面的 /end 在后端结束,
// 这里仅本地离会即可(直接 endMeeting 会因非主持人 403徒增一次失败请求
console.log('[CloudLawVideoCall] hangup(law) -> backend end + local leave', {
roomCode: meetingStore.currentRoom?.room_code,
callId: callId.value
})
await meetingStore.leave().catch(() => {})
} else {
// 云律视频咨询:发起人(小程序端用户)在 CreateRoomForInternal 时被加成 host // 云律视频咨询:发起人(小程序端用户)在 CreateRoomForInternal 时被加成 host
// 用户挂机 = 咨询结束,无条件 endMeeting 让律师 iframe 收到 room.ended 自动退出。 // 用户挂机 = 咨询结束,无条件 endMeeting 让律师收到 room.ended 自动退出。
// 不依赖 meetingStore.isHosthost_id/userId 偶发类型不一致导致误判 false 只走 leave // 不依赖 meetingStore.isHosthost_id/userId 偶发类型不一致导致误判 false 只走 leave
// 表现就是"用户端挂机后 PC 还停在会议里")。 // 表现就是"用户端挂机后 PC 还停在会议里")。
console.log('[CloudLawVideoCall] hangup -> endMeeting', { console.log('[CloudLawVideoCall] hangup -> endMeeting', {
@@ -819,24 +1091,71 @@ const hangup = async () => {
isHostComputed: meetingStore.isHost isHostComputed: meetingStore.isHost
}) })
await meetingStore.endMeeting() await meetingStore.endMeeting()
}
} catch (err) { } catch (err) {
console.warn('[CloudLawVideoCall] endMeeting failed, fallback leave', err) console.warn('[CloudLawVideoCall] end/hangup failed, fallback leave', err)
// endMeeting 失败时兜底 leave至少自己先退出避免卡死 // 失败时兜底 leave至少自己先退出避免卡死
try { await meetingStore.leave() } catch (leaveErr) { console.warn('[CloudLawVideoCall] fallback leave failed', leaveErr) } try { await meetingStore.leave() } catch (leaveErr) { console.warn('[CloudLawVideoCall] fallback leave failed', leaveErr) }
} finally { } finally {
ended.value = true ended.value = true
leaving.value = false leaving.value = false
notifyMiniProgramCallEnded() if (isLawRole.value) {
notifyMiniProgramCallEndedOnce()
} else {
// 用户端挂断:统一走 notifyMiniProgramCallEndedOnce()
// 它已改为「优先 wx.miniProgram 直达咨询详情 returnUrlreLaunch 清栈→承载页 onUnload 调 /end 收尾),
// URL 桥接仅作兜底」。避免之前 URL-桥接优先onWebViewLoad 在 hash SPA 内不触发)+ navigateBack
// 只返回上一页、跳不到咨询详情的问题。
notifyMiniProgramCallEndedOnce()
}
} }
} }
onLoad(query => { onLoad(query => {
const decodeQueryValue = (value) => {
let text = String(value || '').trim()
if (!text) return ''
for (let i = 0; i < 3; i++) {
try {
const next = decodeURIComponent(text)
if (next === text) break
text = next
} catch (e) {
break
}
}
return text
}
rawCode.value = query?.code || query?.roomCode || '' rawCode.value = query?.code || query?.roomCode || ''
callId.value = query?.callId || query?.call_id || '' callId.value = query?.callId || query?.call_id || ''
consultId.value = query?.consultId || query?.consult_id || '' consultId.value = decodeQueryValue(query?.consultId || query?.consult_id || '')
miniProgramReturnUrl.value = query?.miniProgramReturnUrl || query?.mini_program_return_url || '' miniProgramReturnUrl.value = decodeQueryValue(query?.miniProgramReturnUrl || query?.mini_program_return_url || '')
cloudLawApiBase.value = query?.cloudLawApiBase || query?.cloud_law_api_base || '' cloudLawApiBase.value = query?.cloudLawApiBase || query?.cloud_law_api_base || ''
cloudLawToken.value = query?.cloudLawToken || query?.cloud_law_token || '' cloudLawToken.value = query?.cloudLawToken || query?.cloud_law_token || ''
cloudLawRole.value = query?.cloudLawRole || query?.cloud_law_role || ''
// #ifdef H5
try {
const cachedCallId = window.sessionStorage.getItem('echo_cloud_law_call_id') || ''
if (callId.value && cachedCallId && cachedCallId !== callId.value) {
window.sessionStorage.removeItem('echo_cloud_law_consult_id')
window.sessionStorage.removeItem('echo_cloud_law_return_url')
}
if (callId.value) {
window.sessionStorage.setItem('echo_cloud_law_call_id', callId.value)
}
if (consultId.value) {
window.sessionStorage.setItem('echo_cloud_law_consult_id', consultId.value)
}
if (miniProgramReturnUrl.value) {
window.sessionStorage.setItem('echo_cloud_law_return_url', miniProgramReturnUrl.value)
}
} catch (e) {}
if (String(cloudLawRole.value).toLowerCase() === 'law') {
try {
window.sessionStorage.setItem('echo_cloud_law_meeting_role', 'law')
} catch (e) {}
}
// #endif
const mode = String(query?.mode || query?.callType || query?.type || '').toLowerCase() const mode = String(query?.mode || query?.callType || query?.type || '').toLowerCase()
callMode.value = mode === 'voice' || mode === 'audio' ? 'voice' : 'video' callMode.value = mode === 'voice' || mode === 'audio' ? 'voice' : 'video'
joinMeeting() joinMeeting()
@@ -851,15 +1170,49 @@ onMounted(() => {
} }
if (s === MEETING_LOCAL_STATE_ENDED) { if (s === MEETING_LOCAL_STATE_ENDED) {
stopCommandPolling() stopCommandPolling()
// 律师端仅「返回上一页」最小化:不结束云律通话、不 postMessage 通知小程序通话结束
if (minimizingToMiniProgram.value) {
minimizingToMiniProgram.value = false
return
}
const reason = meetingStore.lastEndedReason const reason = meetingStore.lastEndedReason
const label = MEETING_ENDED_REASON_LABEL?.[reason] || '视频咨询已结束' const label = MEETING_ENDED_REASON_LABEL?.[reason] || '视频咨询已结束'
uni.showToast({ title: label, icon: 'none' }) uni.showToast({ title: label, icon: 'none' })
ended.value = true ended.value = true
// 对方挂断或会议结束时,通知云律后端 + 父页面PC 律师浮窗)+ 小程序
notifyCloudLawEnd().finally(() => {
if (isLawRole.value) {
notifyCloudLawParentCallEnded(callId.value)
if (isCloudLawPcIframe()) return
}
notifyMiniProgramCallEndedOnce()
scheduleEndedRedirect()
})
} }
}, { immediate: true }) }, { immediate: true })
}) })
watch(ended, (value) => {
if (!value || isLawRole.value) return
setTimeout(() => {
if (ended.value && !miniProgramRedirected) {
notifyMiniProgramCallEndedOnce()
if (!miniProgramRedirected) {
goToConsultDetail()
}
}
}, 800)
})
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (endedRedirectTimer) {
clearTimeout(endedRedirectTimer)
endedRedirectTimer = null
}
if (miniProgramRedirectRetryTimer) {
clearInterval(miniProgramRedirectRetryTimer)
miniProgramRedirectRetryTimer = null
}
if (timerHandle) { if (timerHandle) {
clearInterval(timerHandle) clearInterval(timerHandle)
timerHandle = null timerHandle = null
@@ -877,6 +1230,13 @@ onBeforeUnmount(() => {
onUnload(() => { onUnload(() => {
stopCommandPolling() stopCommandPolling()
if (isLawRole.value && !ended.value && !userInitiatedHangup.value) {
minimizingToMiniProgram.value = true
if (meetingStore.isInMeeting && meetingStore.localState !== MEETING_LOCAL_STATE_LEAVING) {
meetingStore.leave().catch(() => {})
}
return
}
if (!ended.value && callId.value) { if (!ended.value && callId.value) {
notifyCloudLawEnd().catch(() => {}) notifyCloudLawEnd().catch(() => {})
} }
@@ -1031,6 +1391,13 @@ onUnload(() => {
height: 100%; height: 100%;
border-radius: 0; border-radius: 0;
} }
.video-back {
font-size: 56rpx;
line-height: 1;
color: #ffffff;
padding: 0 12rpx;
flex-shrink: 0;
}
.video-topbar { .video-topbar {
position: absolute; position: absolute;
left: 0; left: 0;
@@ -1041,6 +1408,7 @@ onUnload(() => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16rpx;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.55) 0%, rgba(0, 0, 0, 0) 100%); background: linear-gradient(180deg, rgba(0, 0, 0, 0.55) 0%, rgba(0, 0, 0, 0) 100%);
box-sizing: border-box; box-sizing: border-box;
z-index: 6; z-index: 6;
@@ -1168,6 +1536,10 @@ onUnload(() => {
.control-btn.disabled { .control-btn.disabled {
opacity: 0.94; opacity: 0.94;
} }
.control-btn.active {
background: linear-gradient(180deg, #4f8dff, #2563eb);
border-color: rgba(79, 141, 255, 0.55);
}
.hangup-btn { .hangup-btn {
width: 150rpx; width: 150rpx;
height: 150rpx; height: 150rpx;

View File

@@ -16,11 +16,19 @@
--> -->
<template> <template>
<view v-if="initializing || autoJoin" class="auto-join-page"> <view v-if="initializing || autoJoin" class="auto-join-page">
<text class="auto-join-title">正在进入会议</text> <template v-if="autoJoinError">
<text class="auto-join-error-title">进入会议失败</text>
<text class="auto-join-error-msg">{{ autoJoinError }}</text>
<view class="auto-join-actions">
<button class="aj-btn aj-btn-primary" @click="retryAutoJoin">重试</button>
<button class="aj-btn aj-btn-secondary" @click="exitAutoJoin">返回</button>
</view>
</template>
<text v-else class="auto-join-title">正在进入会议</text>
</view> </view>
<view v-else class="page"> <view v-else class="page">
<view class="container"> <view class="container">
<text class="title">设备预览</text> <text class="title">{{ pageTitle }}</text>
<text class="subtitle">{{ modeLabel }}确认设备和显示名称后加入会议</text> <text class="subtitle">{{ modeLabel }}确认设备和显示名称后加入会议</text>
<view class="content"> <view class="content">
@@ -107,6 +115,7 @@ import { ref, reactive, computed, onBeforeUnmount, nextTick } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { useMeetingStore } from '@/store/meeting' import { useMeetingStore } from '@/store/meeting'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
import { isCloudLawEmbed, isCloudLawLawRole, minimizeCloudLawMeeting } from '@/utils/cloudLawMiniProgram'
const meetingStore = useMeetingStore() const meetingStore = useMeetingStore()
const userStore = useUserStore() const userStore = useUserStore()
@@ -115,8 +124,16 @@ const mode = ref('create')
const joinCode = ref('') const joinCode = ref('')
const joinPassword = ref('') const joinPassword = ref('')
const autoJoin = ref(false) const autoJoin = ref(false)
const cloudLawRole = ref('')
const cloudLawCallId = ref('')
const cloudLawApiBase = ref('')
const initializing = ref(true) const initializing = ref(true)
// autoJoin 失败/超时的可见错误(替代无限"正在进入会议…"转圈)
const autoJoinError = ref('')
let autoJoinWatchdog = null
const isCloudLaw = computed(() => isCloudLawEmbed() && isCloudLawLawRole(cloudLawRole.value))
const pageTitle = computed(() => isCloudLaw.value ? '用户视频咨询' : '设备预览')
const modeLabel = computed(() => mode.value === 'create' ? '即将创建新会议' : `即将加入会议 ${joinCode.value}`) const modeLabel = computed(() => mode.value === 'create' ? '即将创建新会议' : `即将加入会议 ${joinCode.value}`)
const videoBox = ref(null) const videoBox = ref(null)
@@ -360,9 +377,43 @@ const onToggleVideoDefault = (e) => { mediaPrefs.startVideo = e.detail.value }
const onToggleAudioDefault = (e) => { mediaPrefs.startAudio = e.detail.value } const onToggleAudioDefault = (e) => { mediaPrefs.startAudio = e.detail.value }
const onCancel = () => { const onCancel = () => {
if (isCloudLawEmbed() && isCloudLawLawRole(cloudLawRole.value)) {
minimizeCloudLawMeeting(cloudLawCallId.value)
return
}
uni.navigateBack() uni.navigateBack()
} }
// autoJoin 看门狗:若 15s 内既没进房也没报错(多见于 WS 连不上 / mediasoup 传输 ICE 不通而无限等待),
// 主动把无限转圈切换成可见的超时错误,便于真机定位与重试。
const startAutoJoinWatchdog = () => {
clearAutoJoinWatchdog()
autoJoinWatchdog = setTimeout(() => {
if (joining.value && !autoJoinError.value) {
autoJoinError.value = '连接会议服务器超时15s。可能是网络受限或会议服务WebSocket/媒体通道)暂不可达,请重试或检查网络。'
}
}, 15000)
}
const clearAutoJoinWatchdog = () => {
if (autoJoinWatchdog) { clearTimeout(autoJoinWatchdog); autoJoinWatchdog = null }
}
const retryAutoJoin = () => {
autoJoinError.value = ''
joining.value = false
startAutoJoinWatchdog()
onJoin({ skipMedia: true })
}
const exitAutoJoin = () => {
clearAutoJoinWatchdog()
if (isCloudLawEmbed() && isCloudLawLawRole(cloudLawRole.value)) {
minimizeCloudLawMeeting(cloudLawCallId.value)
return
}
onCancel()
}
const onJoin = async (options = {}) => { const onJoin = async (options = {}) => {
if (joining.value || !canJoin.value) return if (joining.value || !canJoin.value) return
joining.value = true joining.value = true
@@ -420,15 +471,23 @@ const onJoin = async (options = {}) => {
await meetingStore.joinAndEnter(joinCode.value, joinPassword.value, prefs) await meetingStore.joinAndEnter(joinCode.value, joinPassword.value, prefs)
} }
const roomCode = meetingStore.currentRoom?.room_code const roomCode = meetingStore.currentRoom?.room_code
uni.redirectTo({ url: `/pages/meeting/room${roomCode ? `?code=${roomCode}` : ''}` }) const roleQ = cloudLawRole.value ? `&cloudLawRole=${encodeURIComponent(cloudLawRole.value)}` : ''
const callQ = cloudLawCallId.value ? `&callId=${encodeURIComponent(cloudLawCallId.value)}` : ''
const apiBaseQ = cloudLawApiBase.value ? `&cloudLawApiBase=${encodeURIComponent(cloudLawApiBase.value)}` : ''
uni.redirectTo({ url: `/pages/meeting/room${roomCode ? `?code=${roomCode}${roleQ}${callQ}${apiBaseQ}` : `${roleQ || callQ || apiBaseQ ? `?${(roleQ + callQ + apiBaseQ).replace(/^&/, '')}` : ''}`}` })
} catch (err) { } catch (err) {
const msg = err?.message || JSON.stringify(err) const msg = err?.message || JSON.stringify(err)
// autoJoin 模式下把错误显式呈现在页面toast 一闪而过,真机很难看清)
if (autoJoin.value) {
autoJoinError.value = `加入失败:${msg}`
}
uni.showToast({ title: `加入失败:${msg}`, icon: 'none', duration: 3500 }) uni.showToast({ title: `加入失败:${msg}`, icon: 'none', duration: 3500 })
console.error('[Preview] 加入会议失败', err) console.error('[Preview] 加入会议失败', err)
// 入会失败track 尚未转给 mediasoup 或刚拿到就失败,显式 stop 避免摄像头泄漏 // 入会失败track 尚未转给 mediasoup 或刚拿到就失败,显式 stop 避免摄像头泄漏
try { audioTrack && audioTrack.stop && audioTrack.stop() } catch {} try { audioTrack && audioTrack.stop && audioTrack.stop() } catch {}
try { videoTrack && videoTrack.stop && videoTrack.stop() } catch {} try { videoTrack && videoTrack.stop && videoTrack.stop() } catch {}
} finally { } finally {
clearAutoJoinWatchdog()
joining.value = false joining.value = false
} }
} }
@@ -439,7 +498,22 @@ onLoad(async (query) => {
try { try {
mode.value = query?.mode === 'join' ? 'join' : 'create' mode.value = query?.mode === 'join' ? 'join' : 'create'
joinCode.value = query?.code || '' joinCode.value = query?.code || ''
cloudLawRole.value = query?.cloudLawRole || query?.cloud_law_role || ''
cloudLawCallId.value = query?.callId || query?.call_id || ''
cloudLawApiBase.value = query?.cloudLawApiBase || query?.cloud_law_api_base || ''
if (isCloudLaw.value) {
try {
uni.setNavigationBarTitle({ title: '用户视频咨询' })
} catch (e) {}
}
autoJoin.value = query?.autoJoin === '1' || query?.autoJoin === 'true' || query?.autoJoin === true autoJoin.value = query?.autoJoin === '1' || query?.autoJoin === 'true' || query?.autoJoin === true
// #ifdef H5
if (String(cloudLawRole.value).toLowerCase() === 'law') {
try {
window.sessionStorage.setItem('echo_cloud_law_meeting_role', 'law')
} catch (e) {}
}
// #endif
console.log('[Preview] onLoad params:', { console.log('[Preview] onLoad params:', {
query, query,
mode: mode.value, mode: mode.value,
@@ -466,6 +540,7 @@ onLoad(async (query) => {
hasPermission: hasPermission.value, hasPermission: hasPermission.value,
permissionError: permissionError.value permissionError: permissionError.value
}) })
startAutoJoinWatchdog()
onJoin({ skipMedia: true }) onJoin({ skipMedia: true })
return return
} }
@@ -489,6 +564,7 @@ onBeforeUnmount(() => {
clearTimeout(changeDebounceTimer) clearTimeout(changeDebounceTimer)
changeDebounceTimer = null changeDebounceTimer = null
} }
clearAutoJoinWatchdog()
stopPreviewStream() stopPreviewStream()
stopAudioMeter() stopAudioMeter()
}) })
@@ -499,13 +575,50 @@ onBeforeUnmount(() => {
min-height: 100vh; min-height: 100vh;
background: #F8FAFC; background: #F8FAFC;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0 48rpx;
} }
.auto-join-title { .auto-join-title {
font-size: 30rpx; font-size: 30rpx;
color: #475569; color: #475569;
} }
.auto-join-error-title {
font-size: 34rpx;
font-weight: 600;
color: #DC2626;
margin-bottom: 20rpx;
}
.auto-join-error-msg {
font-size: 26rpx;
line-height: 40rpx;
color: #475569;
text-align: center;
margin-bottom: 40rpx;
word-break: break-all;
}
.auto-join-actions {
display: flex;
gap: 24rpx;
}
.aj-btn {
min-width: 200rpx;
height: 80rpx;
line-height: 80rpx;
border-radius: 16rpx;
font-size: 28rpx;
text-align: center;
border: none;
}
.aj-btn-primary {
background: #3B82F6;
color: #FFFFFF;
}
.aj-btn-secondary {
background: #E2E8F0;
color: #334155;
}
.page { min-height: 100vh; background: #F8FAFC; padding: 24rpx; } .page { min-height: 100vh; background: #F8FAFC; padding: 24rpx; }
.container { max-width: 960rpx; margin: 0 auto; } .container { max-width: 960rpx; margin: 0 auto; }
.title { font-size: 40rpx; font-weight: 600; color: #0F172A; display: block; margin: 24rpx 0 8rpx; } .title { font-size: 40rpx; font-weight: 600; color: #0F172A; display: block; margin: 24rpx 0 8rpx; }

View File

@@ -15,6 +15,10 @@
<!-- 顶部信息栏 --> <!-- 顶部信息栏 -->
<view class="top-bar"> <view class="top-bar">
<view class="top-left"> <view class="top-left">
<view v-if="showCloudLawBack" class="cloud-law-back" @click="onCloudLawBack">
<text class="cloud-law-back-icon"></text>
</view>
<view class="top-left-info">
<text class="meeting-title">{{ meetingTitle }}</text> <text class="meeting-title">{{ meetingTitle }}</text>
<view class="code-pill" @click="openInvite"> <view class="code-pill" @click="openInvite">
<text class="code-text">{{ formattedCode }}</text> <text class="code-text">{{ formattedCode }}</text>
@@ -24,6 +28,7 @@
</svg> </svg>
</view> </view>
</view> </view>
</view>
<view class="top-right"> <view class="top-right">
<view class="state-pill" :class="stateClass"> <view class="state-pill" :class="stateClass">
<view class="state-dot"></view> <view class="state-dot"></view>
@@ -59,6 +64,7 @@
:video-enabled="true" :video-enabled="true"
:audio-track="null" :audio-track="null"
:video-track="screenTile.videoTrack" :video-track="screenTile.videoTrack"
video-fit="contain"
:is-speaking="false" :is-speaking="false"
/> />
</view> </view>
@@ -144,6 +150,7 @@
:show-recording="isHost" :show-recording="isHost"
:recording="isRecording" :recording="isRecording"
:recording-loading="recordingLoading" :recording-loading="recordingLoading"
:replace-chat-with-screen-share="replaceChatWithScreenShare"
@mic-toggle="onMicToggle" @mic-toggle="onMicToggle"
@cam-toggle="onCamToggle" @cam-toggle="onCamToggle"
@screen-toggle="onScreenToggle" @screen-toggle="onScreenToggle"
@@ -225,10 +232,32 @@ import InviteDialog from '@/components/meeting/InviteDialog.vue'
import NetworkBadge from '@/components/meeting/NetworkBadge.vue' import NetworkBadge from '@/components/meeting/NetworkBadge.vue'
import ChatPanel from '@/components/meeting/ChatPanel.vue' import ChatPanel from '@/components/meeting/ChatPanel.vue'
import SelfVideoFloat from '@/components/meeting/SelfVideoFloat.vue' import SelfVideoFloat from '@/components/meeting/SelfVideoFloat.vue'
import {
isCloudLawEmbed,
isCloudLawLawRole,
minimizeCloudLawMeeting,
notifyCloudLawCallEnded,
getMiniProgram
} from '@/utils/cloudLawMiniProgram'
import {
isCloudLawLawRoleInMeeting,
isCloudLawPcIframe,
notifyCloudLawBackendEnd,
notifyCloudLawParentCallEnded,
queryCloudLawCallStatus
} from '@/utils/cloudLawVoiceCall'
const meetingStore = useMeetingStore() const meetingStore = useMeetingStore()
const userStore = useUserStore() const userStore = useUserStore()
/** 云律律师端:工具栏用共享屏幕替换聊天 */
const cloudLawRole = ref('')
const cloudLawCallId = ref('')
const cloudLawApiBase = ref('')
const minimizingToMiniProgram = ref(false)
const replaceChatWithScreenShare = computed(() => String(cloudLawRole.value).toLowerCase() === 'law')
const showCloudLawBack = computed(() => isCloudLawEmbed() && isCloudLawLawRole(cloudLawRole.value))
const memberVisible = ref(false) const memberVisible = ref(false)
const inviteVisible = ref(false) const inviteVisible = ref(false)
const chatVisible = ref(false) const chatVisible = ref(false)
@@ -241,6 +270,7 @@ const networkLevel = ref(4) // 持续接入 getStats 后动态更新Task 16
const startedAt = ref(0) const startedAt = ref(0)
const nowTs = ref(Date.now()) const nowTs = ref(Date.now())
let timerHandle = null let timerHandle = null
let viewportCleanup = null
/** 视频区容器引用:传给 SelfVideoFloat 计算吸附边界 */ /** 视频区容器引用:传给 SelfVideoFloat 计算吸附边界 */
const videoCol = ref(null) const videoCol = ref(null)
@@ -733,19 +763,38 @@ const onTransferHost = async (uid) => {
} }
} }
const onLeaveClick = () => { leaveDialogVisible.value = true } const onCloudLawBack = () => {
if (minimizingToMiniProgram.value) return
minimizingToMiniProgram.value = true
meetingStore.leave().catch(() => {})
minimizeCloudLawMeeting(cloudLawCallId.value)
}
const onLeaveClick = () => {
if (showCloudLawBack.value) {
onCloudLawBack()
return
}
leaveDialogVisible.value = true
}
const confirmLeaveSelf = async () => { const confirmLeaveSelf = async () => {
leaveDialogVisible.value = false leaveDialogVisible.value = false
let failed = false
try { try {
await meetingStore.leave() await meetingStore.leave()
} catch (err) {
failed = true
uni.showToast({ title: err?.message || '操作失败', icon: 'none' })
} finally { } finally {
redirectHome() leaveAndExit(failed ? '' : '已离开会议')
} }
} }
const confirmEndOrLeave = async () => { const confirmEndOrLeave = async () => {
leaveDialogVisible.value = false leaveDialogVisible.value = false
const tip = isHost.value ? '会议已结束' : '已离开会议'
let failed = false
try { try {
if (isHost.value) { if (isHost.value) {
await meetingStore.endMeeting() await meetingStore.endMeeting()
@@ -755,25 +804,147 @@ const confirmEndOrLeave = async () => {
await meetingStore.leave() await meetingStore.leave()
} }
} catch (err) { } catch (err) {
failed = true
uni.showToast({ title: err?.message || '操作失败', icon: 'none' }) uni.showToast({ title: err?.message || '操作失败', icon: 'none' })
} finally { } finally {
redirectHome() leaveAndExit(failed ? '' : tip)
} }
} }
const redirectHome = () => { // 退出会议视频界面:先给提示,再退出。
// 云律律师端「返回」→ 最小化并弹出「返回会议」;真正挂断/结束 → cloudLawCallEnded。
const leaveAndExit = (tip, ended = true) => {
if (tip) {
uni.showToast({ title: tip, icon: 'none', duration: 1200 })
}
setTimeout(() => redirectHome(ended), tip ? 600 : 800)
}
let meetingExited = false
const redirectHome = (ended = true) => {
if (meetingExited) return
if (isCloudLawEmbed() && isCloudLawLawRole(cloudLawRole.value)) {
meetingExited = true
const minimize = minimizingToMiniProgram.value || !ended
if (minimize) {
if (minimizeCloudLawMeeting(cloudLawCallId.value)) return
} else if (notifyCloudLawCallEnded(cloudLawCallId.value)) {
return
}
meetingExited = false
}
uni.reLaunch({ url: '/pages/index/index' }) uni.reLaunch({ url: '/pages/index/index' })
} }
const syncViewportHeight = () => {
// #ifdef H5
const height = window.visualViewport?.height || window.innerHeight
if (height > 0) {
document.documentElement.style.setProperty('--meeting-vh', `${height}px`)
}
// #endif
}
let cloudLawStatusPollTimer = null
let cloudLawParticipantStopWatch = null
let cloudLawSoloCheckTimer = null
let cloudLawConsultEnding = false
const hadRemoteParticipant = ref(false)
const getMyUserId = () => userStore.userInfo?.id || userStore.userInfo?.user_id
const countRemoteActiveParticipants = () => {
const myUid = getMyUserId()
return meetingStore.activeParticipants.filter(p => String(p.user_id) !== String(myUid)).length
}
const stopCloudLawStatusPoll = () => {
if (cloudLawStatusPollTimer) {
clearInterval(cloudLawStatusPollTimer)
cloudLawStatusPollTimer = null
}
}
const handleCloudLawConsultEnded = async (tip = '用户已退出咨询') => {
if (cloudLawConsultEnding || !isCloudLawLawRoleInMeeting(cloudLawRole.value)) return
cloudLawConsultEnding = true
stopCloudLawStatusPoll()
if (cloudLawSoloCheckTimer) {
clearTimeout(cloudLawSoloCheckTimer)
cloudLawSoloCheckTimer = null
}
try {
if (cloudLawCallId.value) {
await notifyCloudLawBackendEnd(cloudLawCallId.value, 'law', cloudLawApiBase.value)
if (isCloudLawPcIframe()) {
notifyCloudLawParentCallEnded(cloudLawCallId.value)
} else if (getMiniProgram()) {
notifyCloudLawCallEnded(cloudLawCallId.value)
}
}
if (meetingStore.isInMeeting) {
await meetingStore.leave().catch(() => {})
}
uni.showToast({ title: tip, icon: 'none' })
if (!isCloudLawPcIframe() && !getMiniProgram()) {
setTimeout(() => redirectHome(true), 800)
}
} finally {
cloudLawConsultEnding = false
}
}
const pollCloudLawCallStatusOnce = async () => {
if (!isCloudLawLawRoleInMeeting(cloudLawRole.value) || !cloudLawCallId.value || cloudLawConsultEnding) return
if (meetingStore.localState === MEETING_LOCAL_STATE_ENDED || meetingStore.localState === MEETING_LOCAL_STATE_LEAVING) return
const data = await queryCloudLawCallStatus(cloudLawCallId.value, 'law', cloudLawApiBase.value)
const status = String(data?.status || data?.callStatus || '').toLowerCase()
if (['ended', 'cancelled', 'failed'].includes(status)) {
await handleCloudLawConsultEnded('咨询已结束')
}
}
const startCloudLawStatusPoll = () => {
if (!isCloudLawLawRoleInMeeting(cloudLawRole.value) || !cloudLawCallId.value) return
stopCloudLawStatusPoll()
pollCloudLawCallStatusOnce()
cloudLawStatusPollTimer = setInterval(pollCloudLawCallStatusOnce, 1000)
}
const scheduleCloudLawSoloCheck = () => {
if (cloudLawSoloCheckTimer) {
clearTimeout(cloudLawSoloCheckTimer)
}
cloudLawSoloCheckTimer = setTimeout(async () => {
cloudLawSoloCheckTimer = null
if (!hadRemoteParticipant.value || cloudLawConsultEnding) return
if (meetingStore.localState !== MEETING_LOCAL_STATE_CONNECTED) return
if (countRemoteActiveParticipants() === 0) {
await handleCloudLawConsultEnded('用户已退出咨询')
}
}, 500)
}
// ============ 生命周期 ============ // ============ 生命周期 ============
let stateStopWatch = null let stateStopWatch = null
let popstateHandler = null
// P2-3 修复redirectTo 后必须 return避免 onMounted 继续执行闪默认 UI // P2-3 修复redirectTo 后必须 return避免 onMounted 继续执行闪默认 UI
// 使用 module-scoped 标记onMounted 读取后判断是否提前退出 // 使用 module-scoped 标记onMounted 读取后判断是否提前退出
let redirectingToJoin = false let redirectingToJoin = false
onLoad((query) => { onLoad((query) => {
cloudLawRole.value = query?.cloudLawRole || query?.cloud_law_role || ''
cloudLawCallId.value = query?.callId || query?.call_id || ''
cloudLawApiBase.value = query?.cloudLawApiBase || query?.cloud_law_api_base || ''
if (!cloudLawRole.value) {
// #ifdef H5
try {
cloudLawRole.value = window.sessionStorage.getItem('echo_cloud_law_meeting_role') || ''
} catch (e) {}
// #endif
}
// 刷新页面 / 直链访问 room 时 store 为空,回跳 join 避免白屏 // 刷新页面 / 直链访问 room 时 store 为空,回跳 join 避免白屏
if (!meetingStore.isInMeeting) { if (!meetingStore.isInMeeting) {
const paramCode = (query?.code || '').replace(/\D/g, '') const paramCode = (query?.code || '').replace(/\D/g, '')
@@ -787,6 +958,22 @@ onMounted(() => {
// P2-3 修复onLoad 已 redirectTo 跳转时,避免本页继续初始化定时器与 watcher // P2-3 修复onLoad 已 redirectTo 跳转时,避免本页继续初始化定时器与 watcher
if (redirectingToJoin) return if (redirectingToJoin) return
// #ifdef H5
syncViewportHeight()
const viewport = window.visualViewport
const onViewportChange = () => syncViewportHeight()
window.addEventListener('resize', onViewportChange)
window.addEventListener('orientationchange', onViewportChange)
viewport?.addEventListener?.('resize', onViewportChange)
viewport?.addEventListener?.('scroll', onViewportChange)
viewportCleanup = () => {
window.removeEventListener('resize', onViewportChange)
window.removeEventListener('orientationchange', onViewportChange)
viewport?.removeEventListener?.('resize', onViewportChange)
viewport?.removeEventListener?.('scroll', onViewportChange)
}
// #endif
const startIso = meetingStore.currentRoom?.started_at || meetingStore.currentRoom?.created_at const startIso = meetingStore.currentRoom?.started_at || meetingStore.currentRoom?.created_at
startedAt.value = startIso ? Date.parse(startIso) : Date.now() startedAt.value = startIso ? Date.parse(startIso) : Date.now()
timerHandle = setInterval(() => { nowTs.value = Date.now() }, 1000) timerHandle = setInterval(() => { nowTs.value = Date.now() }, 1000)
@@ -794,12 +981,62 @@ onMounted(() => {
// 会议被主持人/后端结束ENDED 状态)时,弹提示并回首页 // 会议被主持人/后端结束ENDED 状态)时,弹提示并回首页
stateStopWatch = watch(() => meetingStore.localState, (s) => { stateStopWatch = watch(() => meetingStore.localState, (s) => {
if (s === MEETING_LOCAL_STATE_ENDED) { if (s === MEETING_LOCAL_STATE_ENDED) {
if (minimizingToMiniProgram.value) {
minimizingToMiniProgram.value = false
redirectHome(false)
return
}
const reason = meetingStore.lastEndedReason const reason = meetingStore.lastEndedReason
const label = MEETING_ENDED_REASON_LABEL?.[reason] || '会议已结束' const label = MEETING_ENDED_REASON_LABEL?.[reason] || '会议已结束'
uni.showToast({ title: label, icon: 'none' }) uni.showToast({ title: label, icon: 'none' })
setTimeout(redirectHome, 1200) // 云律律师端(含 PC iframe用户挂断后同步云律后端 + 通知父页面释放浮窗
if (isCloudLawLawRoleInMeeting(cloudLawRole.value) && cloudLawCallId.value) {
notifyCloudLawBackendEnd(cloudLawCallId.value, 'law', cloudLawApiBase.value)
if (isCloudLawPcIframe()) {
notifyCloudLawParentCallEnded(cloudLawCallId.value)
return
}
if (getMiniProgram()) {
notifyCloudLawCallEnded(cloudLawCallId.value)
return
}
}
setTimeout(() => redirectHome(true), 1200)
} }
}) })
// 云律律师端:轮询通话状态 + 对方离会后自动收尾(用户挂断不一定能推到 room.ended
if (isCloudLawLawRoleInMeeting(cloudLawRole.value)) {
startCloudLawStatusPoll()
cloudLawParticipantStopWatch = watch(
() => meetingStore.activeParticipants,
() => {
const remoteCount = countRemoteActiveParticipants()
if (remoteCount > 0) {
hadRemoteParticipant.value = true
return
}
if (hadRemoteParticipant.value && remoteCount === 0) {
scheduleCloudLawSoloCheck()
}
},
{ deep: true }
)
}
// #ifdef H5
if (showCloudLawBack.value) {
try {
history.pushState({ cloudLawMeeting: 1 }, '')
popstateHandler = () => {
if (!minimizingToMiniProgram.value) {
onCloudLawBack()
}
}
window.addEventListener('popstate', popstateHandler)
} catch (e) {}
}
// #endif
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -807,7 +1044,23 @@ onBeforeUnmount(() => {
clearInterval(timerHandle) clearInterval(timerHandle)
timerHandle = null timerHandle = null
} }
if (viewportCleanup) { viewportCleanup(); viewportCleanup = null }
if (stateStopWatch) { stateStopWatch(); stateStopWatch = null } if (stateStopWatch) { stateStopWatch(); stateStopWatch = null }
stopCloudLawStatusPoll()
if (cloudLawParticipantStopWatch) {
cloudLawParticipantStopWatch()
cloudLawParticipantStopWatch = null
}
if (cloudLawSoloCheckTimer) {
clearTimeout(cloudLawSoloCheckTimer)
cloudLawSoloCheckTimer = null
}
// #ifdef H5
if (popstateHandler) {
window.removeEventListener('popstate', popstateHandler)
popstateHandler = null
}
// #endif
}) })
onUnload(() => { onUnload(() => {
@@ -832,8 +1085,18 @@ onUnload(() => {
inset: 0; inset: 0;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
height: 100svh;
height: 100dvh;
height: var(--meeting-vh, 100dvh);
min-width: 100vw; min-width: 100vw;
min-height: 100vh; min-height: 100vh;
min-height: 100svh;
min-height: 100dvh;
min-height: var(--meeting-vh, 100dvh);
max-height: 100vh;
max-height: 100svh;
max-height: 100dvh;
max-height: var(--meeting-vh, 100dvh);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #0F172A; background: #0F172A;
@@ -844,6 +1107,23 @@ onUnload(() => {
z-index: 1000; z-index: 1000;
} }
.cloud-law-back {
width: 64rpx;
height: 64rpx;
margin-right: 12rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.cloud-law-back-icon {
font-size: 44rpx;
line-height: 1;
color: #FFFFFF;
font-weight: 300;
}
.top-bar { .top-bar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -857,6 +1137,14 @@ onUnload(() => {
} }
.top-left { .top-left {
display: flex;
flex-direction: row;
align-items: center;
gap: 6rpx;
min-width: 0;
flex: 1;
}
.top-left-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6rpx; gap: 6rpx;
@@ -1038,6 +1326,10 @@ onUnload(() => {
.chat-col.open { width: 640rpx; } .chat-col.open { width: 640rpx; }
@media (max-width: 750px) { @media (max-width: 750px) {
.body {
padding-bottom: calc(142rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
/* 小屏幕:聊天面板改为全屏浮层,避免挤压视频 */ /* 小屏幕:聊天面板改为全屏浮层,避免挤压视频 */
.chat-col { position: fixed; top: 0; right: 0; bottom: 0; z-index: 205; } .chat-col { position: fixed; top: 0; right: 0; bottom: 0; z-index: 205; }
.chat-col.open { width: 100vw; } .chat-col.open { width: 100vw; }

View File

@@ -184,6 +184,65 @@ class WebSocketService {
}) })
} }
/**
* 当前连接是否处于可发送状态H5 下要求 readyState === OPEN
* 与 send() 的就绪判断保持一致
* @returns {boolean}
*/
isConnected() {
// #ifdef H5
return !!(this.ws && this.ws.readyState === WebSocket.OPEN)
// #endif
// #ifndef H5
return !!this.ws
// #endif
}
/**
* 等待 WebSocket 进入可用OPEN状态
*
* 场景SSO / web-view 登录后 connect() 为异步建链CONNECTING→OPEN
* 设备预览结束立即入会时,首个 sendWithAck 可能早于 socket OPEN
* 导致 send() 返回 -1 → 业务侧抛 "连接未就绪"。
* 本方法在未连接时主动发起一次连接connect 对 OPEN/CONNECTING 幂等),
* 并等待 _connected 事件;已连接则立即 resolve超时 reject。
*
* @param {number} [timeoutMS=10000] - 最长等待时间
* @returns {Promise<boolean>}
*/
waitForConnected(timeoutMS = 10000) {
if (this.isConnected()) return Promise.resolve(true)
// 未建链 / 已断开:主动发起一次连接(幂等,不会重复建链)
this.connect()
return new Promise((resolve, reject) => {
let settled = false
let timer = null
let poll = null
const cleanup = () => {
this.off('_connected', onConnected)
if (timer) clearTimeout(timer)
if (poll) clearInterval(poll)
}
const onConnected = () => {
if (settled) return
settled = true
cleanup()
resolve(true)
}
timer = setTimeout(() => {
if (settled) return
settled = true
cleanup()
reject({ code: -1, message: '连接会议服务器超时' })
}, timeoutMS)
// 轮询兜底:防止 _onOpen 在 listener 注册前已触发而错过事件
poll = setInterval(() => {
if (this.isConnected()) onConnected()
}, 100)
this.on('_connected', onConnected)
})
}
/** /**
* 注册事件监听 * 注册事件监听
* @param {string} event - 事件类型 * @param {string} event - 事件类型

BIN
frontend/src/static/qq.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@@ -975,6 +975,16 @@ export const useMeetingStore = defineStore('meeting', () => {
} }
localState.value = MEETING_LOCAL_STATE_CONNECTING localState.value = MEETING_LOCAL_STATE_CONNECTING
// 入会前确保 WS 已 OPENSSO/web-view 登录后 connect() 为异步建链,
// 设备预览后立即入会时,首个 sendWithAck 可能早于 socket OPEN
// 表现为 "加入失败:连接未就绪"。此处显式等待(必要时主动发起连接),最多 10s。
try {
await wsService.waitForConnected(10000)
} catch (e) {
_log('error', '[Meeting] 入会前等待 WS 连接超时', e)
throw new Error('连接会议服务器失败,请检查网络后重试')
}
const userStore = useUserStore() const userStore = useUserStore()
const uid = userStore.userInfo?.id const uid = userStore.userInfo?.id
if (!uid) throw new Error('当前用户未登录') if (!uid) throw new Error('当前用户未登录')
@@ -1294,7 +1304,14 @@ export const useMeetingStore = defineStore('meeting', () => {
const stream = await navigator.mediaDevices.getDisplayMedia({ const stream = await navigator.mediaDevices.getDisplayMedia({
// 屏幕共享对帧率要求低、清晰度要求高,控制带宽优先保证清晰度 // 屏幕共享对帧率要求低、清晰度要求高,控制带宽优先保证清晰度
video: { frameRate: { ideal: 15, max: 30 } }, video: {
width: { ideal: 1920, max: 1920 },
height: { ideal: 1080, max: 1080 },
frameRate: { ideal: 15, max: 15 },
cursor: 'always',
displaySurface: 'monitor',
logicalSurface: true
},
audio: false audio: false
}) })
const track = stream.getVideoTracks()[0] const track = stream.getVideoTracks()[0]
@@ -1313,6 +1330,17 @@ export const useMeetingStore = defineStore('meeting', () => {
producer = await _engine.produce({ producer = await _engine.produce({
kind: 'video', kind: 'video',
track, track,
encodings: [
{
maxBitrate: 2500000,
maxFramerate: 15
}
],
codecOptions: {
videoGoogleStartBitrate: 1500,
videoGoogleMinBitrate: 800,
videoGoogleMaxBitrate: 2500
},
appData: { screen: true } appData: { screen: true }
}) })
} catch (err) { } catch (err) {
@@ -1321,6 +1349,12 @@ export const useMeetingStore = defineStore('meeting', () => {
throw err throw err
} }
localProducers.screen = producer.id localProducers.screen = producer.id
if (typeof _engine.tuneProducerEncoding === 'function') {
_engine.tuneProducerEncoding(producer.id, {
maxBitrate: 2500000,
maxFramerate: 15
}).catch((err) => _log('warn', '[Meeting] 屏幕共享编码参数设置失败', err))
}
// 浏览器原生"停止共享"按钮:用户从系统 UI 取消时 track 进入 ended // 浏览器原生"停止共享"按钮:用户从系统 UI 取消时 track 进入 ended
// 此时本地 producer.on('trackended') 也会触发关闭,这里加一层保险 // 此时本地 producer.on('trackended') 也会触发关闭,这里加一层保险

View File

@@ -0,0 +1,80 @@
/** 云律小程序 web-view 内与原生壳通信(返回会议浮窗 / 挂断) */
export function getMiniProgram() {
return typeof window !== 'undefined' && window.wx && window.wx.miniProgram ? window.wx.miniProgram : null
}
export function isCloudLawEmbed() {
try {
return typeof window !== 'undefined'
&& !!window.sessionStorage
&& window.sessionStorage.getItem('echo_cloud_law_meeting_embed') === '1'
} catch (e) {
return false
}
}
export function isCloudLawLawRole(role) {
if (String(role || '').toLowerCase() === 'law') return true
try {
return window.sessionStorage.getItem('echo_cloud_law_meeting_role') === 'law'
} catch (e) {
return false
}
}
/** 律师端返回上一页:会议仍在进行,通知小程序弹出「返回会议」浮窗 */
export function minimizeCloudLawMeeting(callId = '') {
const mp = getMiniProgram()
if (!isCloudLawEmbed() || !mp) return false
try {
if (typeof mp.postMessage === 'function') {
mp.postMessage({
data: {
type: 'cloudLawMinimized',
action: 'cloudLawMinimized',
callId: String(callId || '')
}
})
}
} catch (e) {
console.warn('[cloudLawMiniProgram] minimize postMessage failed', e)
}
try {
if (typeof mp.navigateBack === 'function') {
mp.navigateBack({ delta: 1 })
return true
}
} catch (e) {
console.warn('[cloudLawMiniProgram] minimize navigateBack failed', e)
}
return false
}
/** 会议真正结束:通知小程序释放占用 */
export function notifyCloudLawCallEnded(callId = '') {
const mp = getMiniProgram()
if (!mp) return false
try {
if (typeof mp.postMessage === 'function') {
mp.postMessage({
data: {
type: 'cloudLawCallEnded',
action: 'cloudLawCallEnded',
callId: String(callId || '')
}
})
}
} catch (e) {
console.warn('[cloudLawMiniProgram] ended postMessage failed', e)
}
try {
if (typeof mp.navigateBack === 'function') {
mp.navigateBack({ delta: 1 })
return true
}
} catch (e) {
console.warn('[cloudLawMiniProgram] ended navigateBack failed', e)
}
return false
}

View File

@@ -0,0 +1,164 @@
/** 云律视频咨询iframe / web-view 内通知云律后端结束通话,并通知父页面(律师 PC 浮窗)释放状态 */
export function getCloudLawApiBase(explicitBase) {
const base = String(explicitBase || '').trim().replace(/\/+$/, '')
if (base) return base
if (typeof window !== 'undefined' && window.location && window.location.origin) {
return window.location.origin
}
return ''
}
export function resolveCloudLawApiUrl(path, apiBase) {
const base = getCloudLawApiBase(apiBase)
const normalizedPath = path.startsWith('/') ? path : `/${path}`
return `${base}${normalizedPath}`
}
/**
* 通知云律后端结束通话。
* - 律师端:/api/law/voiceCall/{callId}/endPC 同域,带登录态)
* - 用户端:/api/applet/voiceConnection/publicEnd/{callId}?roomCode=xxx
* web-view 跨域调带登录态的 /{callId}/end 会被浏览器 CORS 拦截、根本到不了后端,
* 导致「挂断后 status 一直 accepted、律师端不结束、用户端不跳转」。
* publicEnd 是 @CrossOrigin 公开端点,以 callId+roomCode 配对为凭证,可靠收尾。
*/
export function notifyCloudLawBackendEnd(callId, role = 'law', apiBase = '', token = '', roomCode = '') {
if (!callId) return Promise.resolve(false)
const isLaw = String(role).toLowerCase() === 'law'
if (isLaw) {
const url = resolveCloudLawApiUrl(`/api/law/voiceCall/${encodeURIComponent(callId)}/end`, apiBase)
const header = { 'Content-Type': 'application/json' }
if (token) {
header.token = token.startsWith('Bearer') ? token : `Bearer ${token}`
}
return new Promise(resolve => {
uni.request({ url, method: 'POST', data: {}, header, timeout: 8000, complete: () => resolve(true) })
})
}
// 用户端publicEnd@CrossOrigin 公开端点)。凭证是 URL 里的 callId+roomCode无需 token/body。
// 关键:不能带 application/json 或自定义头,否则触发 CORS 预检;预检失败时浏览器会直接
// 掐掉真正的 POST表现为「按钮触发了但没有请求发到后端」。
// 因此优先用 navigator.sendBeacon简单 POST、无预检、跨域也能到达服务器
const query = roomCode ? `?roomCode=${encodeURIComponent(roomCode)}` : ''
const url = resolveCloudLawApiUrl(`/api/applet/voiceConnection/publicEnd/${encodeURIComponent(callId)}${query}`, apiBase)
console.log('[cloudLawVoiceCall] publicEnd ->', url)
// #ifdef H5
try {
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
const ok = navigator.sendBeacon(url)
console.log('[cloudLawVoiceCall] sendBeacon publicEnd result:', ok)
if (ok) return Promise.resolve(true)
}
} catch (e) {
console.warn('[cloudLawVoiceCall] sendBeacon failed', e)
}
// #endif
// 兜底uni.request 不带任何自定义头 / body尽量保持「简单请求」避免预检。
return new Promise(resolve => {
uni.request({
url,
method: 'POST',
timeout: 8000,
complete: (res) => {
console.log('[cloudLawVoiceCall] publicEnd request done:', res && res.statusCode)
resolve(true)
}
})
})
}
/** PC 律师端 iframe 会议结束时通知父页面outbound 浮窗)清除「会议进行中」状态 */
export function notifyCloudLawParentCallEnded(callId = '') {
if (typeof window === 'undefined') return false
try {
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: 'cloudLawCallEnded',
action: 'cloudLawCallEnded',
callId: String(callId || '')
}, '*')
return true
}
} catch (e) {
console.warn('[cloudLawVoiceCall] parent postMessage failed', e)
}
return false
}
export function isCloudLawLawRoleInMeeting(role) {
if (String(role || '').toLowerCase() === 'law') return true
try {
return typeof window !== 'undefined'
&& window.sessionStorage
&& window.sessionStorage.getItem('echo_cloud_law_meeting_role') === 'law'
} catch (e) {
return false
}
}
export function isCloudLawPcIframe() {
return typeof window !== 'undefined' && !!(window.parent && window.parent !== window)
}
/** 查询云律通话状态(律师端 /api/law/voiceCall用户端 /api/applet/voiceConnection */
export function queryCloudLawCallStatus(callId, role = 'law', apiBase = '', token = '') {
if (!callId) return Promise.resolve(null)
const endBase = String(role).toLowerCase() === 'law' ? '/api/law/voiceCall' : '/api/applet/voiceConnection'
const url = resolveCloudLawApiUrl(`${endBase}/${encodeURIComponent(callId)}/status`, apiBase)
const header = { 'Content-Type': 'application/json' }
if (token) {
header.token = token.startsWith('Bearer') ? token : `Bearer ${token}`
}
return new Promise(resolve => {
uni.request({
url,
method: 'GET',
header,
timeout: 8000,
success: (res) => {
const body = res?.data || {}
const payload = body.data !== undefined ? body.data : body
resolve(payload && typeof payload === 'object' ? payload : null)
},
fail: () => resolve(null)
})
})
}
const CLOUD_LAW_HANGUP_QUERY = 'cloudLawHangup'
/** 小程序 web-view 内挂断:改 URL 触发原生页 @load立即调 /end不依赖 web-view 销毁) */
export function notifyNativeMiniProgramHangup() {
// #ifdef H5
if (typeof window === 'undefined') {
console.warn('[cloudLawVoiceCall] bridge skip: window undefined')
return false
}
try {
const mp = window.wx && window.wx.miniProgram
if (!mp) {
console.warn('[cloudLawVoiceCall] bridge skip: window.wx.miniProgram 不存在jweixin 未加载/不在小程序 web-view 内)')
return false
}
const href = String(window.location.href || '')
if (href.includes(`${CLOUD_LAW_HANGUP_QUERY}=1`)) {
console.log('[cloudLawVoiceCall] bridge: URL 已含 cloudLawHangup=1跳过重复重载')
return true
}
const url = new URL(href)
url.searchParams.set(CLOUD_LAW_HANGUP_QUERY, '1')
console.log('[cloudLawVoiceCall] bridge: location.replace ->', url.toString())
window.location.replace(url.toString())
return true
} catch (e) {
console.warn('[cloudLawVoiceCall] native hangup bridge failed', e)
}
// #endif
return false
}
export function isCloudLawHangupBridgeUrl(url) {
return String(url || '').includes(`${CLOUD_LAW_HANGUP_QUERY}=1`)
}

View File

@@ -314,6 +314,27 @@ export function createMediaEngine({ roomCode, userId, sendWithAck, logger = defa
return producer return producer
} }
const tuneProducerEncoding = async (producerId, encodingPatch = {}) => {
const producer = producers.get(producerId)
const sender = producer && producer.rtpSender
if (!sender || typeof sender.getParameters !== 'function' || typeof sender.setParameters !== 'function') {
return false
}
try {
const params = sender.getParameters() || {}
params.encodings = Array.isArray(params.encodings) && params.encodings.length
? params.encodings
: [{}]
params.encodings = params.encodings.map((encoding) => ({ ...encoding, ...encodingPatch }))
await sender.setParameters(params)
logger('info', '[MediaEngine] producer 编码参数已更新', { producerId, encodingPatch })
return true
} catch (e) {
logger('warn', '[MediaEngine] producer 编码参数更新失败', e)
return false
}
}
/** /**
* 订阅远端 Producer请求后端创建 Consumer → 本地 consume → 等 track 挂好后 resume * 订阅远端 Producer请求后端创建 Consumer → 本地 consume → 等 track 挂好后 resume
* *
@@ -480,6 +501,7 @@ export function createMediaEngine({ roomCode, userId, sendWithAck, logger = defa
closeProducer, closeProducer,
pauseProducer, pauseProducer,
resumeProducer, resumeProducer,
tuneProducerEncoding,
closeConsumer, closeConsumer,
close, close,
getDevice: () => device, getDevice: () => device,

View File

@@ -55,7 +55,7 @@ export async function createWebRtcTransport(params: {
enableUdp: true, enableUdp: true,
enableTcp: true, enableTcp: true,
preferUdp: true, preferUdp: true,
initialAvailableOutgoingBitrate: 1_000_000, initialAvailableOutgoingBitrate: 4_000_000,
appData: { appData: {
userId: params.userId, userId: params.userId,
direction: params.direction, direction: params.direction,
@@ -63,6 +63,17 @@ export async function createWebRtcTransport(params: {
}, },
}); });
if (params.direction === 'send') {
try {
await transport.setMaxIncomingBitrate(4_000_000);
} catch (err) {
log.warn(
{ transportId: transport.id, err: err instanceof Error ? err.message : String(err) },
'set max incoming bitrate failed',
);
}
}
transport.observer.once('close', () => { transport.observer.once('close', () => {
transportMap.delete(transport.id); transportMap.delete(transport.id);
log.info( log.info(