Compare commits
30 Commits
96fca438f4
...
feature/ph
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a5a3ab7ec | ||
|
|
15b7cd61cd | ||
|
|
3c305257f7 | ||
|
|
0d9d790d53 | ||
|
|
ce07f34c2b | ||
|
|
c033b21b02 | ||
|
|
80a913c78e | ||
|
|
4477132081 | ||
|
|
c6e460ad97 | ||
|
|
e697354ce9 | ||
|
|
9be151cc9f | ||
|
|
d936ebd17e | ||
|
|
98c481b25a | ||
|
|
f7526d98c6 | ||
|
|
a7e91a2cde | ||
|
|
d6b8a0e1e1 | ||
|
|
3b0f1f84ff | ||
|
|
9e9573a35d | ||
|
|
2410f5e2e9 | ||
|
|
2b036f4aa5 | ||
|
|
cae3876d8b | ||
|
|
495f0aae1b | ||
|
|
e426357850 | ||
|
|
3cffd160f5 | ||
|
|
3ffdaa30d9 | ||
|
|
b89b930c41 | ||
|
|
edda31e8bf | ||
|
|
46afcfe350 | ||
|
|
3b51aba8ec | ||
|
|
6727259e8b |
@@ -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 获取用户个人信息
|
||||||
|
|||||||
@@ -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 中存储的一致
|
||||||
|
|||||||
@@ -675,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"
|
||||||
|
|
||||||
@@ -712,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-1:TransferHost 内部事务已同时更新 meeting_rooms.host_id,不再需要单独 UpdateHost
|
// P1-1:TransferHost 内部事务已同时更新 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 {
|
||||||
|
|||||||
@@ -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, "认证已失效,请重新登录")
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
|
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
|
||||||
</script>
|
</script>
|
||||||
<title></title>
|
<title></title>
|
||||||
|
<!-- 微信 JSSDK:H5 在小程序 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>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
v-if="!replaceChatWithScreenShare"
|
||||||
class="btn btn-screen"
|
class="btn btn-screen"
|
||||||
:class="{ active: screenSharing, loading: screenLoading }"
|
:class="{ active: screenSharing, loading: screenLoading }"
|
||||||
:disabled="screenLoading || screenDisabled"
|
:disabled="screenLoading || screenDisabled"
|
||||||
@@ -127,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>
|
||||||
@@ -173,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'])
|
||||||
@@ -242,18 +274,24 @@ 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;
|
||||||
@@ -262,8 +300,12 @@ const emitIf = (name) => {
|
|||||||
}
|
}
|
||||||
.label-mobile { display: none; }
|
.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 {
|
||||||
@@ -283,16 +325,17 @@ 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: 750px) {
|
@media (max-width: 750px) {
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -301,52 +344,65 @@ const emitIf = (name) => {
|
|||||||
right: 14rpx;
|
right: 14rpx;
|
||||||
bottom: calc(12rpx + env(safe-area-inset-bottom));
|
bottom: calc(12rpx + env(safe-area-inset-bottom));
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 6rpx;
|
gap: 4rpx;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 14rpx 14rpx;
|
padding: 18rpx 12rpx;
|
||||||
background: rgba(7, 15, 30, 0.9);
|
background: linear-gradient(180deg, rgba(17, 26, 46, 0.92), rgba(7, 15, 30, 0.94));
|
||||||
backdrop-filter: blur(18px);
|
backdrop-filter: blur(20px);
|
||||||
border: 1rpx solid rgba(148, 163, 184, 0.18);
|
border: 1rpx solid rgba(148, 163, 184, 0.2);
|
||||||
border-radius: 32rpx;
|
border-radius: 34rpx;
|
||||||
box-shadow: 0 12rpx 34rpx rgba(0, 0, 0, 0.42);
|
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-x: hidden;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
.btn {
|
.btn {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
gap: 6rpx;
|
gap: 10rpx;
|
||||||
padding: 4rpx 0;
|
padding: 4rpx 0;
|
||||||
border-radius: 18rpx;
|
border-radius: 18rpx;
|
||||||
}
|
}
|
||||||
.btn:hover { background: transparent; }
|
.btn:hover { background: transparent; }
|
||||||
.btn:active { background: rgba(255, 255, 255, 0.08); }
|
.btn:active { background: transparent; }
|
||||||
|
.btn:active .icon { transform: scale(0.9); }
|
||||||
.btn-screen {
|
.btn-screen {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
/* 律师端:共享屏幕占原「聊天」槽位,移动端仍需显示 */
|
||||||
|
.btn-screen-share-slot {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
.icon {
|
.icon {
|
||||||
width: 56rpx;
|
width: 80rpx;
|
||||||
height: 56rpx;
|
height: 80rpx;
|
||||||
background: rgba(51, 65, 85, 0.92);
|
background: linear-gradient(180deg, rgba(71, 85, 105, 0.95), rgba(44, 56, 78, 0.96));
|
||||||
box-shadow: inset 0 0 0 1rpx rgba(255, 255, 255, 0.08);
|
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.12), 0 6rpx 16rpx rgba(0, 0, 0, 0.36);
|
||||||
}
|
}
|
||||||
.icon svg {
|
.icon svg {
|
||||||
width: 40rpx;
|
width: 42rpx;
|
||||||
height: 40rpx;
|
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.active .icon { background: #2563EB; }
|
|
||||||
.btn[disabled] .icon {
|
.btn[disabled] .icon {
|
||||||
background: rgba(30, 41, 59, 0.72);
|
background: rgba(30, 41, 59, 0.72);
|
||||||
color: rgba(255, 255, 255, 0.38);
|
color: rgba(255, 255, 255, 0.38);
|
||||||
box-shadow: inset 0 0 0 1rpx rgba(255, 255, 255, 0.05);
|
box-shadow: inset 0 0 0 1rpx rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
.btn[disabled] .label { color: rgba(255, 255, 255, 0.36); }
|
.btn[disabled] .label { color: rgba(255, 255, 255, 0.36); }
|
||||||
.btn-leave .icon { background: #EF4444; }
|
.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; }
|
.btn-leave .label { color: #FECACA; }
|
||||||
.label {
|
.label {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
font-size: 19rpx;
|
font-size: 20rpx;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.74);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -370,15 +426,15 @@ const emitIf = (name) => {
|
|||||||
padding-right: 8rpx;
|
padding-right: 8rpx;
|
||||||
}
|
}
|
||||||
.icon {
|
.icon {
|
||||||
width: 50rpx;
|
width: 56rpx;
|
||||||
height: 50rpx;
|
height: 56rpx;
|
||||||
}
|
}
|
||||||
.icon svg {
|
.icon svg {
|
||||||
width: 36rpx;
|
width: 38rpx;
|
||||||
height: 36rpx;
|
height: 38rpx;
|
||||||
}
|
}
|
||||||
.label {
|
.label {
|
||||||
font-size: 17rpx;
|
font-size: 18rpx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -20,9 +20,9 @@
|
|||||||
<!-- 顶部品牌区域 -->
|
<!-- 顶部品牌区域 -->
|
||||||
<view class="brand-section">
|
<view class="brand-section">
|
||||||
<view class="logo-box">
|
<view class="logo-box">
|
||||||
<image class="brand-logo" src="/static/shangsong.png" mode="aspectFit" />
|
<text class="logo-letter">E</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="brand-name">赏讼视频会议系统</text>
|
<text class="brand-name">EchoChat</text>
|
||||||
<text class="brand-slogan">连接无限,沟通无界</text>
|
<text class="brand-slogan">连接无限,沟通无界</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -81,11 +81,14 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="oauth-buttons">
|
<view class="oauth-buttons">
|
||||||
<button class="oauth-btn oauth-wechat" :disabled="oauthLoading" @tap="oauthLogin('wechat')">
|
<button class="oauth-btn oauth-wechat" :disabled="oauthLoading" @tap="oauthLogin('wechat')">
|
||||||
<image class="oauth-icon" src="/static/weixin.png" mode="aspectFit" />
|
<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>
|
<text>微信登录</text>
|
||||||
</button>
|
</button>
|
||||||
<button class="oauth-btn oauth-qq" :disabled="oauthLoading" @tap="oauthLogin('qq')">
|
<button class="oauth-btn oauth-qq" :disabled="oauthLoading" @tap="oauthLogin('qq')">
|
||||||
<image class="oauth-icon" src="/static/qq.png" mode="aspectFit" />
|
<text class="oauth-icon oauth-icon-qq">Q</text>
|
||||||
<text>QQ登录</text>
|
<text>QQ登录</text>
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
@@ -278,14 +281,17 @@ export default {
|
|||||||
.logo-box {
|
.logo-box {
|
||||||
width: 112rpx;
|
width: 112rpx;
|
||||||
height: 112rpx;
|
height: 112rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
background-color: #2563EB;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-logo {
|
.logo-letter {
|
||||||
width: 112rpx;
|
font-size: 52rpx;
|
||||||
height: 112rpx;
|
font-weight: 700;
|
||||||
|
color: #FFFFFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-name {
|
.brand-name {
|
||||||
@@ -455,8 +461,46 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.oauth-icon {
|
.oauth-icon {
|
||||||
width: 52rpx;
|
width: 36rpx;
|
||||||
height: 52rpx;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 底部链接 ---- */
|
/* ---- 底部链接 ---- */
|
||||||
|
|||||||
@@ -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()
|
||||||
try {
|
if (!text) return ''
|
||||||
return decodeURIComponent(configured)
|
for (let i = 0; i < 3; i++) {
|
||||||
} catch (e) {
|
try {
|
||||||
return configured
|
const next = decodeURIComponent(text)
|
||||||
|
if (next === text) break
|
||||||
|
text = next
|
||||||
|
} catch (e) {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
const configured = decodeRepeated(miniProgramReturnUrl.value)
|
||||||
|
if (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 true,jweixin 静默失败时
|
||||||
|
// 仍被判成功、阻断后续兜底,表现为「停在 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 导航到咨询详情 returnUrl(reLaunch 会清栈、销毁承载页,
|
||||||
|
// 触发承载页 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,41 +1029,133 @@ 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()
|
||||||
// 云律视频咨询:发起人(小程序端用户)在 CreateRoomForInternal 时被加成 host,
|
console.log('[CloudLawVideoCall] hangup -> notifyCloudLawEnd 已返回')
|
||||||
// 用户挂机 = 咨询结束,无条件 endMeeting 让律师 iframe 收到 room.ended 自动退出。
|
if (isLawRole.value) {
|
||||||
// 不依赖 meetingStore.isHost(host_id/userId 偶发类型不一致导致误判 false 只走 leave,
|
// 律师为参会者(非主持人),EndRoom 是 host-only。会议已由上面的 /end 在后端结束,
|
||||||
// 表现就是"用户端挂机后 PC 还停在会议里")。
|
// 这里仅本地离会即可(直接 endMeeting 会因非主持人 403,徒增一次失败请求)。
|
||||||
console.log('[CloudLawVideoCall] hangup -> endMeeting', {
|
console.log('[CloudLawVideoCall] hangup(law) -> backend end + local leave', {
|
||||||
roomCode: meetingStore.currentRoom?.room_code,
|
roomCode: meetingStore.currentRoom?.room_code,
|
||||||
hostId: meetingStore.currentRoom?.host_id,
|
callId: callId.value
|
||||||
myUserId: userStore.userInfo?.id || userStore.userInfo?.user_id,
|
})
|
||||||
isHostComputed: meetingStore.isHost
|
await meetingStore.leave().catch(() => {})
|
||||||
})
|
} else {
|
||||||
await meetingStore.endMeeting()
|
// 云律视频咨询:发起人(小程序端用户)在 CreateRoomForInternal 时被加成 host,
|
||||||
|
// 用户挂机 = 咨询结束,无条件 endMeeting 让律师端收到 room.ended 自动退出。
|
||||||
|
// 不依赖 meetingStore.isHost(host_id/userId 偶发类型不一致导致误判 false 只走 leave,
|
||||||
|
// 表现就是"用户端挂机后 PC 还停在会议里")。
|
||||||
|
console.log('[CloudLawVideoCall] hangup -> endMeeting', {
|
||||||
|
roomCode: meetingStore.currentRoom?.room_code,
|
||||||
|
hostId: meetingStore.currentRoom?.host_id,
|
||||||
|
myUserId: userStore.userInfo?.id || userStore.userInfo?.user_id,
|
||||||
|
isHostComputed: meetingStore.isHost
|
||||||
|
})
|
||||||
|
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 直达咨询详情 returnUrl(reLaunch 清栈→承载页 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;
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -23,6 +27,7 @@
|
|||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||||
</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">
|
||||||
@@ -145,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"
|
||||||
@@ -226,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)
|
||||||
@@ -735,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()
|
||||||
@@ -757,13 +804,35 @@ 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' })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -776,15 +845,106 @@ const syncViewportHeight = () => {
|
|||||||
// #endif
|
// #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, '')
|
||||||
@@ -821,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(() => {
|
||||||
@@ -836,6 +1046,21 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
if (viewportCleanup) { viewportCleanup(); viewportCleanup = 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(() => {
|
||||||
@@ -882,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;
|
||||||
@@ -895,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;
|
||||||
|
|||||||
@@ -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 - 事件类型
|
||||||
|
|||||||
@@ -975,6 +975,16 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
}
|
}
|
||||||
localState.value = MEETING_LOCAL_STATE_CONNECTING
|
localState.value = MEETING_LOCAL_STATE_CONNECTING
|
||||||
|
|
||||||
|
// 入会前确保 WS 已 OPEN:SSO/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('当前用户未登录')
|
||||||
|
|||||||
80
frontend/src/utils/cloudLawMiniProgram.js
Normal file
80
frontend/src/utils/cloudLawMiniProgram.js
Normal 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
|
||||||
|
}
|
||||||
164
frontend/src/utils/cloudLawVoiceCall.js
Normal file
164
frontend/src/utils/cloudLawVoiceCall.js
Normal 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}/end(PC 同域,带登录态)
|
||||||
|
* - 用户端:/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`)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user