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 中有效
|
||||
// 供 JWT 中间件调用,实现有状态 JWT 验证,按 clientType 隔离校验
|
||||
func (s *AuthService) ValidateAccessToken(ctx context.Context, userID int64, clientType, token string) bool {
|
||||
return s.tokenStore.ValidateAccessToken(ctx, userID, clientType, token)
|
||||
// 供 JWT 中间件 / WS 握手调用,实现有状态 JWT 验证,按 clientType 隔离校验;
|
||||
// username 用于识别云律临时会议账号并放宽「单点登录挤号」校验
|
||||
func (s *AuthService) ValidateAccessToken(ctx context.Context, userID int64, clientType, token, username string) bool {
|
||||
return s.tokenStore.ValidateAccessToken(ctx, userID, clientType, token, username)
|
||||
}
|
||||
|
||||
// GetProfile 获取用户个人信息
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/echochat/backend/config"
|
||||
@@ -17,8 +18,18 @@ import (
|
||||
const (
|
||||
keyPrefixAccessToken = "echo:auth:token:" // echo:auth:token:{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 中的存取
|
||||
// 实现有状态 JWT:登录存入、验证时校验、登出时删除
|
||||
type TokenStore struct {
|
||||
@@ -70,12 +81,30 @@ func (s *TokenStore) SaveTokens(ctx context.Context, userID int64, clientType, a
|
||||
|
||||
// ValidateAccessToken 校验 Access Token 是否与 Redis 中存储的一致
|
||||
// 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"
|
||||
|
||||
cloudLaw := isCloudLawUsername(username)
|
||||
|
||||
key := fmt.Sprintf("%s%s:%d", keyPrefixAccessToken, clientType, userID)
|
||||
stored, err := s.redis.Get(ctx, key).Result()
|
||||
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 不存在(已登出或过期)",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.String("client_type", clientType),
|
||||
@@ -83,6 +112,15 @@ func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, clie
|
||||
return false
|
||||
}
|
||||
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 失败",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.String("client_type", clientType),
|
||||
@@ -91,7 +129,20 @@ func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, clie
|
||||
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 中存储的一致
|
||||
|
||||
@@ -675,8 +675,22 @@ func (s *MeetingService) JoinRoom(ctx context.Context, userID int64, code, passw
|
||||
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 用户主动离会
|
||||
// 若离开者为 host 且房间内还有其他活跃成员:事务内转让给最早加入者;若为空房:关闭房间(status=Ended, reason=empty_ttl)
|
||||
// 云律房间例外:发起人(host)离会即结束整场会议(见下方说明)
|
||||
func (s *MeetingService) LeaveRoom(ctx context.Context, userID int64, code string) (int, error) {
|
||||
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 {
|
||||
// 云律视频咨询:发起人(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]
|
||||
// P1-1:TransferHost 内部事务已同时更新 meeting_rooms.host_id,不再需要单独 UpdateHost
|
||||
if txErr := s.participantDAO.TransferHost(ctx, room.ID, userID, newHost.UserID); txErr != nil {
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
// TokenValidator 有状态 JWT 验证接口(检查 Token 是否在 Redis 中有效)
|
||||
// 由 auth.AuthService 实现,用于防止已登出用户建立 WebSocket 连接
|
||||
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 离线消息推送接口
|
||||
@@ -160,7 +160,7 @@ func (h *Handler) Upgrade(c *gin.Context) {
|
||||
if clientType == "" {
|
||||
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 校验)",
|
||||
zap.Int64("user_id", claims.UserID))
|
||||
utils.ResponseUnauthorized(c, "认证已失效,请重新登录")
|
||||
|
||||
@@ -21,7 +21,7 @@ const (
|
||||
// TokenValidator Token 有效性校验接口
|
||||
// 由 AuthService 实现,中间件通过此接口检查 Token 是否在 Redis 中有效
|
||||
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)
|
||||
@@ -70,7 +70,7 @@ func JWTAuth(jwtCfg *config.JWTConfig, validator TokenValidator) gin.HandlerFunc
|
||||
}
|
||||
|
||||
// 校验 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 已失效(已登出或被覆盖)",
|
||||
zap.Int64("user_id", claims.UserID),
|
||||
zap.String("client_type", clientType),
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
|
||||
</script>
|
||||
<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-->
|
||||
<!--app-context-->
|
||||
</head>
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="!replaceChatWithScreenShare"
|
||||
class="btn btn-screen"
|
||||
:class="{ active: screenSharing, loading: screenLoading }"
|
||||
:disabled="screenLoading || screenDisabled"
|
||||
@@ -127,7 +128,36 @@
|
||||
<text class="label">成员</text>
|
||||
</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">
|
||||
<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>
|
||||
@@ -173,7 +203,9 @@ const props = defineProps({
|
||||
/** Phase B 录制:当前是否在录制中 */
|
||||
recording: { type: Boolean, default: false },
|
||||
/** 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'])
|
||||
@@ -242,18 +274,24 @@ const emitIf = (name) => {
|
||||
|
||||
.icon {
|
||||
position: relative;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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:active .icon { transform: scale(0.92); }
|
||||
|
||||
.label {
|
||||
font-size: 22rpx;
|
||||
@@ -262,8 +300,12 @@ const emitIf = (name) => {
|
||||
}
|
||||
.label-mobile { display: none; }
|
||||
|
||||
.btn-leave .icon { background: #EF4444; }
|
||||
.btn-leave:hover .icon { background: #DC2626; }
|
||||
.btn-leave .icon {
|
||||
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 录制按钮:录制中红色脉动 */
|
||||
.btn-recording.is-recording .icon {
|
||||
@@ -283,16 +325,17 @@ const emitIf = (name) => {
|
||||
height: 32rpx;
|
||||
padding: 0 6rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #10B981;
|
||||
background: linear-gradient(180deg, #34D399, #10B981);
|
||||
color: #FFFFFF;
|
||||
font-size: 18rpx;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: 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) {
|
||||
.toolbar {
|
||||
@@ -301,52 +344,65 @@ const emitIf = (name) => {
|
||||
right: 14rpx;
|
||||
bottom: calc(12rpx + env(safe-area-inset-bottom));
|
||||
justify-content: space-between;
|
||||
gap: 6rpx;
|
||||
gap: 4rpx;
|
||||
box-sizing: border-box;
|
||||
padding: 14rpx 14rpx;
|
||||
background: rgba(7, 15, 30, 0.9);
|
||||
backdrop-filter: blur(18px);
|
||||
border: 1rpx solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 32rpx;
|
||||
box-shadow: 0 12rpx 34rpx rgba(0, 0, 0, 0.42);
|
||||
padding: 18rpx 12rpx;
|
||||
background: linear-gradient(180deg, rgba(17, 26, 46, 0.92), rgba(7, 15, 30, 0.94));
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1rpx solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 34rpx;
|
||||
box-shadow: 0 16rpx 40rpx rgba(0, 0, 0, 0.48), inset 0 1rpx 0 rgba(255, 255, 255, 0.06);
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.btn {
|
||||
min-width: 0;
|
||||
flex: 1 1 0;
|
||||
gap: 6rpx;
|
||||
gap: 10rpx;
|
||||
padding: 4rpx 0;
|
||||
border-radius: 18rpx;
|
||||
}
|
||||
.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 {
|
||||
display: none;
|
||||
}
|
||||
/* 律师端:共享屏幕占原「聊天」槽位,移动端仍需显示 */
|
||||
.btn-screen-share-slot {
|
||||
display: flex;
|
||||
}
|
||||
.icon {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
background: rgba(51, 65, 85, 0.92);
|
||||
box-shadow: inset 0 0 0 1rpx rgba(255, 255, 255, 0.08);
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
background: linear-gradient(180deg, rgba(71, 85, 105, 0.95), rgba(44, 56, 78, 0.96));
|
||||
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.12), 0 6rpx 16rpx rgba(0, 0, 0, 0.36);
|
||||
}
|
||||
.icon svg {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
width: 42rpx;
|
||||
height: 42rpx;
|
||||
}
|
||||
.btn.active .icon {
|
||||
background: linear-gradient(180deg, #4F8DFF, #2563EB);
|
||||
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.3), 0 6rpx 18rpx rgba(37, 99, 235, 0.55);
|
||||
}
|
||||
.btn.active .icon { background: #2563EB; }
|
||||
.btn[disabled] .icon {
|
||||
background: rgba(30, 41, 59, 0.72);
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
box-shadow: inset 0 0 0 1rpx rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.btn[disabled] .label { color: rgba(255, 255, 255, 0.36); }
|
||||
.btn-leave .icon { background: #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; }
|
||||
.label {
|
||||
max-width: 100%;
|
||||
font-size: 19rpx;
|
||||
font-size: 20rpx;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.74);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -370,15 +426,15 @@ const emitIf = (name) => {
|
||||
padding-right: 8rpx;
|
||||
}
|
||||
.icon {
|
||||
width: 50rpx;
|
||||
height: 50rpx;
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
}
|
||||
.icon svg {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
width: 38rpx;
|
||||
height: 38rpx;
|
||||
}
|
||||
.label {
|
||||
font-size: 17rpx;
|
||||
font-size: 18rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
<!-- 顶部品牌区域 -->
|
||||
<view class="brand-section">
|
||||
<view class="logo-box">
|
||||
<image class="brand-logo" src="/static/shangsong.png" mode="aspectFit" />
|
||||
<text class="logo-letter">E</text>
|
||||
</view>
|
||||
<text class="brand-name">赏讼视频会议系统</text>
|
||||
<text class="brand-name">EchoChat</text>
|
||||
<text class="brand-slogan">连接无限,沟通无界</text>
|
||||
</view>
|
||||
|
||||
@@ -81,11 +81,14 @@
|
||||
</view>
|
||||
<view class="oauth-buttons">
|
||||
<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>
|
||||
</button>
|
||||
<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>
|
||||
</button>
|
||||
</view>
|
||||
@@ -278,14 +281,17 @@ export default {
|
||||
.logo-box {
|
||||
width: 112rpx;
|
||||
height: 112rpx;
|
||||
border-radius: 24rpx;
|
||||
background-color: #2563EB;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 112rpx;
|
||||
height: 112rpx;
|
||||
.logo-letter {
|
||||
font-size: 52rpx;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
@@ -455,8 +461,46 @@ export default {
|
||||
}
|
||||
|
||||
.oauth-icon {
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(255, 255, 255, 0.94);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.oauth-icon-wechat {
|
||||
position: relative;
|
||||
color: #07C160;
|
||||
}
|
||||
|
||||
.oauth-icon-qq {
|
||||
color: #12B7F5;
|
||||
}
|
||||
|
||||
.wechat-bubble {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background-color: #07C160;
|
||||
}
|
||||
|
||||
.wechat-bubble-large {
|
||||
width: 18rpx;
|
||||
height: 14rpx;
|
||||
left: 8rpx;
|
||||
top: 10rpx;
|
||||
}
|
||||
|
||||
.wechat-bubble-small {
|
||||
width: 14rpx;
|
||||
height: 11rpx;
|
||||
right: 7rpx;
|
||||
bottom: 9rpx;
|
||||
border: 2rpx solid #FFFFFF;
|
||||
}
|
||||
|
||||
/* ---- 底部链接 ---- */
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<text class="entry-title">{{ entryTitle }}</text>
|
||||
<text class="entry-desc">{{ joinError || stateText }}</text>
|
||||
<button v-if="joinError" class="retry-btn" @click="joinMeeting">重新进入</button>
|
||||
<button v-else-if="ended && buildMiniProgramReturnUrl()" class="retry-btn" @click="goToConsultDetail">查看咨询详情</button>
|
||||
</view>
|
||||
|
||||
<view v-else class="call-shell" :class="{ 'voice-mode': isVoiceMode }">
|
||||
@@ -59,13 +60,14 @@
|
||||
</view>
|
||||
|
||||
<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>
|
||||
<text class="top-duration">{{ durationText }}</text>
|
||||
<text class="top-plus">+</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="voice-topbar">
|
||||
<text class="voice-back" @click="hangup">‹</text>
|
||||
<text class="voice-back" @click="onTopBack">‹</text>
|
||||
</view>
|
||||
|
||||
<view v-if="isVoiceMode" class="voice-duration">{{ durationText }}</view>
|
||||
@@ -95,7 +97,17 @@
|
||||
</view>
|
||||
|
||||
<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">
|
||||
<image v-if="!captureIconError" class="control-image" :src="captureIcon" mode="aspectFit" @error="captureIconError = true"></image>
|
||||
<text v-else class="text-icon">截</text>
|
||||
@@ -150,6 +162,13 @@ import {
|
||||
MEETING_ENDED_REASON_LABEL
|
||||
} from '@/constants/meeting'
|
||||
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 hangupIcon from '@/static/cacel.png'
|
||||
import voiceOnIcon from '@/static/vocie_calling_1.png'
|
||||
@@ -168,11 +187,15 @@ const consultId = ref('')
|
||||
const miniProgramReturnUrl = ref('')
|
||||
const cloudLawApiBase = ref('')
|
||||
const cloudLawToken = ref('')
|
||||
// 调用方角色:'law' = 律师端(接单端),其余/空 = 用户端(applet)。
|
||||
// 律师为参会者(非主持人),结束会议走 /api/law/voiceCall/{callId}/end;用户端默认 /api/applet/voiceConnection。
|
||||
const cloudLawRole = ref('')
|
||||
const callMode = ref('video')
|
||||
const joining = ref(false)
|
||||
const joinError = ref('')
|
||||
const audioLoading = ref(false)
|
||||
const videoLoading = ref(false)
|
||||
const screenLoading = ref(false)
|
||||
const leaving = ref(false)
|
||||
const startedAt = ref(0)
|
||||
const nowTs = ref(Date.now())
|
||||
@@ -191,6 +214,10 @@ const captureIconError = ref(false)
|
||||
const flipCameraIconError = ref(false)
|
||||
const snapshotPreviewUrl = ref('')
|
||||
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 sameUser = (a, b) => String(a || '') === String(b || '')
|
||||
@@ -208,6 +235,9 @@ const audioEnabled = computed(() => !!meetingStore.localAudioEnabled)
|
||||
const videoEnabled = computed(() => !!meetingStore.localVideoEnabled)
|
||||
const speakingMap = computed(() => meetingStore.speakingMap || {})
|
||||
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 micIcon = computed(() => audioEnabled.value ? voiceOnIcon : voiceOffIcon)
|
||||
const speakerIcon = computed(() => speakerMuted.value ? speakerOffIcon : speakerOnIcon)
|
||||
@@ -237,7 +267,56 @@ const participants = computed(() => {
|
||||
|
||||
const localTrackVersion = computed(() => {
|
||||
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(() => {
|
||||
@@ -245,7 +324,7 @@ const remoteTrackVersion = computed(() => {
|
||||
const map = meetingStore.remoteConsumers || {}
|
||||
Object.keys(map).forEach(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('|')
|
||||
})
|
||||
@@ -308,6 +387,7 @@ const tiles = computed(() => {
|
||||
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 primaryTile = computed(() => {
|
||||
if (screenTile.value) return screenTile.value
|
||||
if (preferSelfMain.value && selfTile.value) return selfTile.value
|
||||
return remoteTile.value || selfTile.value
|
||||
})
|
||||
@@ -399,51 +479,174 @@ const requestCloudLawCommands = (url) => {
|
||||
}
|
||||
|
||||
const notifyCloudLawEnd = () => {
|
||||
if (!callId.value || !cloudLawApiBase.value) return Promise.resolve()
|
||||
return new Promise(resolve => {
|
||||
uni.request({
|
||||
url: resolveCloudLawApiUrl(`/api/applet/voiceConnection/${encodeURIComponent(callId.value)}/end`),
|
||||
method: 'POST',
|
||||
data: {},
|
||||
header: cloudLawToken.value ? { 'Content-Type': 'application/json', token: `Bearer ${cloudLawToken.value}` } : { 'Content-Type': 'application/json' },
|
||||
timeout: 5000,
|
||||
complete: resolve
|
||||
})
|
||||
const role = isLawRole.value ? 'law' : 'caller'
|
||||
const roomCode = meetingStore.currentRoom?.room_code || formattedCode.value || ''
|
||||
console.log('[CloudLawVideoCall] notifyCloudLawEnd called', {
|
||||
callId: callId.value,
|
||||
role,
|
||||
roomCode,
|
||||
cloudLawApiBase: cloudLawApiBase.value,
|
||||
hasToken: !!cloudLawToken.value
|
||||
})
|
||||
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 configured = String(miniProgramReturnUrl.value || '').trim()
|
||||
if (configured) {
|
||||
const decodeRepeated = (value) => {
|
||||
let text = String(value || '').trim()
|
||||
if (!text) return ''
|
||||
for (let i = 0; i < 3; i++) {
|
||||
try {
|
||||
return decodeURIComponent(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`
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
if (isLawRole.value) return
|
||||
const returnUrl = buildMiniProgramReturnUrl()
|
||||
const payload = {
|
||||
type: 'cloudLawCallEnded',
|
||||
action: 'cloudLawCallEnded',
|
||||
callId: callId.value,
|
||||
consultId: consultId.value,
|
||||
returnUrl: buildMiniProgramReturnUrl()
|
||||
returnUrl
|
||||
}
|
||||
const miniProgram = getMiniProgram()
|
||||
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') {
|
||||
miniProgram.postMessage({ data: payload })
|
||||
}
|
||||
if (miniProgram && typeof miniProgram.redirectTo === 'function' && payload.returnUrl) {
|
||||
miniProgram.redirectTo({ url: payload.returnUrl })
|
||||
}
|
||||
} 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 () => {
|
||||
@@ -466,6 +669,8 @@ const pollCommandsOnce = async () => {
|
||||
}
|
||||
|
||||
const startCommandPolling = () => {
|
||||
// 律师端(接单端)无 publicCommands 远程指令通道,跳过轮询避免无谓 404
|
||||
if (isLawRole.value) return
|
||||
if (!callId.value || commandPollHandle) return
|
||||
pollCommandsOnce()
|
||||
commandPollHandle = setInterval(pollCommandsOnce, 1200)
|
||||
@@ -609,6 +814,28 @@ const flipCamera = () => {
|
||||
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 command = String(cmd.command || cmd.type || '').toLowerCase()
|
||||
const value = cmd.value
|
||||
@@ -802,14 +1029,59 @@ const onCamToggle = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const onTopBack = () => {
|
||||
if (isLawRole.value) {
|
||||
minimizeToMiniProgram()
|
||||
} else {
|
||||
hangup()
|
||||
}
|
||||
}
|
||||
|
||||
/** 律师端返回小程序上一页:保留进行中通话,便于「返回会议」浮窗再次进入 */
|
||||
const minimizeToMiniProgram = () => {
|
||||
if (leaving.value || ended.value || minimizingToMiniProgram.value) return
|
||||
minimizingToMiniProgram.value = true
|
||||
stopCommandPolling()
|
||||
meetingStore.leave().catch(() => {})
|
||||
if (minimizeCloudLawMeeting(callId.value)) {
|
||||
return
|
||||
}
|
||||
minimizingToMiniProgram.value = false
|
||||
uni.showToast({ title: '请使用左上角返回', icon: 'none' })
|
||||
}
|
||||
|
||||
const hangup = async () => {
|
||||
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
|
||||
stopCommandPolling()
|
||||
try {
|
||||
console.log('[CloudLawVideoCall] hangup -> 调 notifyCloudLawEnd 前')
|
||||
// 先通知云律后端结束本次通话:
|
||||
// - 用户端:/api/applet/voiceConnection/{callId}/end
|
||||
// - 律师端:/api/law/voiceCall/{callId}/end → 后端 CloudLawEndMeeting 结束会议(双方都收到 room.ended)
|
||||
await notifyCloudLawEnd()
|
||||
console.log('[CloudLawVideoCall] hangup -> notifyCloudLawEnd 已返回')
|
||||
if (isLawRole.value) {
|
||||
// 律师为参会者(非主持人),EndRoom 是 host-only。会议已由上面的 /end 在后端结束,
|
||||
// 这里仅本地离会即可(直接 endMeeting 会因非主持人 403,徒增一次失败请求)。
|
||||
console.log('[CloudLawVideoCall] hangup(law) -> backend end + local leave', {
|
||||
roomCode: meetingStore.currentRoom?.room_code,
|
||||
callId: callId.value
|
||||
})
|
||||
await meetingStore.leave().catch(() => {})
|
||||
} else {
|
||||
// 云律视频咨询:发起人(小程序端用户)在 CreateRoomForInternal 时被加成 host,
|
||||
// 用户挂机 = 咨询结束,无条件 endMeeting 让律师 iframe 收到 room.ended 自动退出。
|
||||
// 用户挂机 = 咨询结束,无条件 endMeeting 让律师端收到 room.ended 自动退出。
|
||||
// 不依赖 meetingStore.isHost(host_id/userId 偶发类型不一致导致误判 false 只走 leave,
|
||||
// 表现就是"用户端挂机后 PC 还停在会议里")。
|
||||
console.log('[CloudLawVideoCall] hangup -> endMeeting', {
|
||||
@@ -819,24 +1091,71 @@ const hangup = async () => {
|
||||
isHostComputed: meetingStore.isHost
|
||||
})
|
||||
await meetingStore.endMeeting()
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[CloudLawVideoCall] endMeeting failed, fallback leave', err)
|
||||
// endMeeting 失败时兜底 leave,至少自己先退出,避免卡死
|
||||
console.warn('[CloudLawVideoCall] end/hangup failed, fallback leave', err)
|
||||
// 失败时兜底 leave,至少自己先退出,避免卡死
|
||||
try { await meetingStore.leave() } catch (leaveErr) { console.warn('[CloudLawVideoCall] fallback leave failed', leaveErr) }
|
||||
} finally {
|
||||
ended.value = true
|
||||
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 => {
|
||||
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 || ''
|
||||
callId.value = query?.callId || query?.call_id || ''
|
||||
consultId.value = query?.consultId || query?.consult_id || ''
|
||||
miniProgramReturnUrl.value = query?.miniProgramReturnUrl || query?.mini_program_return_url || ''
|
||||
consultId.value = decodeQueryValue(query?.consultId || query?.consult_id || '')
|
||||
miniProgramReturnUrl.value = decodeQueryValue(query?.miniProgramReturnUrl || query?.mini_program_return_url || '')
|
||||
cloudLawApiBase.value = query?.cloudLawApiBase || query?.cloud_law_api_base || ''
|
||||
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()
|
||||
callMode.value = mode === 'voice' || mode === 'audio' ? 'voice' : 'video'
|
||||
joinMeeting()
|
||||
@@ -851,15 +1170,49 @@ onMounted(() => {
|
||||
}
|
||||
if (s === MEETING_LOCAL_STATE_ENDED) {
|
||||
stopCommandPolling()
|
||||
// 律师端仅「返回上一页」最小化:不结束云律通话、不 postMessage 通知小程序通话结束
|
||||
if (minimizingToMiniProgram.value) {
|
||||
minimizingToMiniProgram.value = false
|
||||
return
|
||||
}
|
||||
const reason = meetingStore.lastEndedReason
|
||||
const label = MEETING_ENDED_REASON_LABEL?.[reason] || '视频咨询已结束'
|
||||
uni.showToast({ title: label, icon: 'none' })
|
||||
ended.value = true
|
||||
// 对方挂断或会议结束时,通知云律后端 + 父页面(PC 律师浮窗)+ 小程序
|
||||
notifyCloudLawEnd().finally(() => {
|
||||
if (isLawRole.value) {
|
||||
notifyCloudLawParentCallEnded(callId.value)
|
||||
if (isCloudLawPcIframe()) return
|
||||
}
|
||||
notifyMiniProgramCallEndedOnce()
|
||||
scheduleEndedRedirect()
|
||||
})
|
||||
}
|
||||
}, { immediate: true })
|
||||
})
|
||||
|
||||
watch(ended, (value) => {
|
||||
if (!value || isLawRole.value) return
|
||||
setTimeout(() => {
|
||||
if (ended.value && !miniProgramRedirected) {
|
||||
notifyMiniProgramCallEndedOnce()
|
||||
if (!miniProgramRedirected) {
|
||||
goToConsultDetail()
|
||||
}
|
||||
}
|
||||
}, 800)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (endedRedirectTimer) {
|
||||
clearTimeout(endedRedirectTimer)
|
||||
endedRedirectTimer = null
|
||||
}
|
||||
if (miniProgramRedirectRetryTimer) {
|
||||
clearInterval(miniProgramRedirectRetryTimer)
|
||||
miniProgramRedirectRetryTimer = null
|
||||
}
|
||||
if (timerHandle) {
|
||||
clearInterval(timerHandle)
|
||||
timerHandle = null
|
||||
@@ -877,6 +1230,13 @@ onBeforeUnmount(() => {
|
||||
|
||||
onUnload(() => {
|
||||
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) {
|
||||
notifyCloudLawEnd().catch(() => {})
|
||||
}
|
||||
@@ -1031,6 +1391,13 @@ onUnload(() => {
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
.video-back {
|
||||
font-size: 56rpx;
|
||||
line-height: 1;
|
||||
color: #ffffff;
|
||||
padding: 0 12rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.video-topbar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@@ -1041,6 +1408,7 @@ onUnload(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.55) 0%, rgba(0, 0, 0, 0) 100%);
|
||||
box-sizing: border-box;
|
||||
z-index: 6;
|
||||
@@ -1168,6 +1536,10 @@ onUnload(() => {
|
||||
.control-btn.disabled {
|
||||
opacity: 0.94;
|
||||
}
|
||||
.control-btn.active {
|
||||
background: linear-gradient(180deg, #4f8dff, #2563eb);
|
||||
border-color: rgba(79, 141, 255, 0.55);
|
||||
}
|
||||
.hangup-btn {
|
||||
width: 150rpx;
|
||||
height: 150rpx;
|
||||
|
||||
@@ -16,11 +16,19 @@
|
||||
-->
|
||||
<template>
|
||||
<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 v-else class="page">
|
||||
<view class="container">
|
||||
<text class="title">设备预览</text>
|
||||
<text class="title">{{ pageTitle }}</text>
|
||||
<text class="subtitle">{{ modeLabel }},确认设备和显示名称后加入会议</text>
|
||||
|
||||
<view class="content">
|
||||
@@ -107,6 +115,7 @@ import { ref, reactive, computed, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { useMeetingStore } from '@/store/meeting'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { isCloudLawEmbed, isCloudLawLawRole, minimizeCloudLawMeeting } from '@/utils/cloudLawMiniProgram'
|
||||
|
||||
const meetingStore = useMeetingStore()
|
||||
const userStore = useUserStore()
|
||||
@@ -115,8 +124,16 @@ const mode = ref('create')
|
||||
const joinCode = ref('')
|
||||
const joinPassword = ref('')
|
||||
const autoJoin = ref(false)
|
||||
const cloudLawRole = ref('')
|
||||
const cloudLawCallId = ref('')
|
||||
const cloudLawApiBase = ref('')
|
||||
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 videoBox = ref(null)
|
||||
@@ -360,9 +377,43 @@ const onToggleVideoDefault = (e) => { mediaPrefs.startVideo = e.detail.value }
|
||||
const onToggleAudioDefault = (e) => { mediaPrefs.startAudio = e.detail.value }
|
||||
|
||||
const onCancel = () => {
|
||||
if (isCloudLawEmbed() && isCloudLawLawRole(cloudLawRole.value)) {
|
||||
minimizeCloudLawMeeting(cloudLawCallId.value)
|
||||
return
|
||||
}
|
||||
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 = {}) => {
|
||||
if (joining.value || !canJoin.value) return
|
||||
joining.value = true
|
||||
@@ -420,15 +471,23 @@ const onJoin = async (options = {}) => {
|
||||
await meetingStore.joinAndEnter(joinCode.value, joinPassword.value, prefs)
|
||||
}
|
||||
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) {
|
||||
const msg = err?.message || JSON.stringify(err)
|
||||
// autoJoin 模式下把错误显式呈现在页面(toast 一闪而过,真机很难看清)
|
||||
if (autoJoin.value) {
|
||||
autoJoinError.value = `加入失败:${msg}`
|
||||
}
|
||||
uni.showToast({ title: `加入失败:${msg}`, icon: 'none', duration: 3500 })
|
||||
console.error('[Preview] 加入会议失败', err)
|
||||
// 入会失败:track 尚未转给 mediasoup 或刚拿到就失败,显式 stop 避免摄像头泄漏
|
||||
try { audioTrack && audioTrack.stop && audioTrack.stop() } catch {}
|
||||
try { videoTrack && videoTrack.stop && videoTrack.stop() } catch {}
|
||||
} finally {
|
||||
clearAutoJoinWatchdog()
|
||||
joining.value = false
|
||||
}
|
||||
}
|
||||
@@ -439,7 +498,22 @@ onLoad(async (query) => {
|
||||
try {
|
||||
mode.value = query?.mode === 'join' ? 'join' : 'create'
|
||||
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
|
||||
// #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:', {
|
||||
query,
|
||||
mode: mode.value,
|
||||
@@ -466,6 +540,7 @@ onLoad(async (query) => {
|
||||
hasPermission: hasPermission.value,
|
||||
permissionError: permissionError.value
|
||||
})
|
||||
startAutoJoinWatchdog()
|
||||
onJoin({ skipMedia: true })
|
||||
return
|
||||
}
|
||||
@@ -489,6 +564,7 @@ onBeforeUnmount(() => {
|
||||
clearTimeout(changeDebounceTimer)
|
||||
changeDebounceTimer = null
|
||||
}
|
||||
clearAutoJoinWatchdog()
|
||||
stopPreviewStream()
|
||||
stopAudioMeter()
|
||||
})
|
||||
@@ -499,13 +575,50 @@ onBeforeUnmount(() => {
|
||||
min-height: 100vh;
|
||||
background: #F8FAFC;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 48rpx;
|
||||
}
|
||||
.auto-join-title {
|
||||
font-size: 30rpx;
|
||||
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; }
|
||||
.container { max-width: 960rpx; margin: 0 auto; }
|
||||
.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-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>
|
||||
<view class="code-pill" @click="openInvite">
|
||||
<text class="code-text">{{ formattedCode }}</text>
|
||||
@@ -24,6 +28,7 @@
|
||||
</svg>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="top-right">
|
||||
<view class="state-pill" :class="stateClass">
|
||||
<view class="state-dot"></view>
|
||||
@@ -145,6 +150,7 @@
|
||||
:show-recording="isHost"
|
||||
:recording="isRecording"
|
||||
:recording-loading="recordingLoading"
|
||||
:replace-chat-with-screen-share="replaceChatWithScreenShare"
|
||||
@mic-toggle="onMicToggle"
|
||||
@cam-toggle="onCamToggle"
|
||||
@screen-toggle="onScreenToggle"
|
||||
@@ -226,10 +232,32 @@ import InviteDialog from '@/components/meeting/InviteDialog.vue'
|
||||
import NetworkBadge from '@/components/meeting/NetworkBadge.vue'
|
||||
import ChatPanel from '@/components/meeting/ChatPanel.vue'
|
||||
import SelfVideoFloat from '@/components/meeting/SelfVideoFloat.vue'
|
||||
import {
|
||||
isCloudLawEmbed,
|
||||
isCloudLawLawRole,
|
||||
minimizeCloudLawMeeting,
|
||||
notifyCloudLawCallEnded,
|
||||
getMiniProgram
|
||||
} from '@/utils/cloudLawMiniProgram'
|
||||
import {
|
||||
isCloudLawLawRoleInMeeting,
|
||||
isCloudLawPcIframe,
|
||||
notifyCloudLawBackendEnd,
|
||||
notifyCloudLawParentCallEnded,
|
||||
queryCloudLawCallStatus
|
||||
} from '@/utils/cloudLawVoiceCall'
|
||||
|
||||
const meetingStore = useMeetingStore()
|
||||
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 inviteVisible = 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 () => {
|
||||
leaveDialogVisible.value = false
|
||||
let failed = false
|
||||
try {
|
||||
await meetingStore.leave()
|
||||
} catch (err) {
|
||||
failed = true
|
||||
uni.showToast({ title: err?.message || '操作失败', icon: 'none' })
|
||||
} finally {
|
||||
redirectHome()
|
||||
leaveAndExit(failed ? '' : '已离开会议')
|
||||
}
|
||||
}
|
||||
|
||||
const confirmEndOrLeave = async () => {
|
||||
leaveDialogVisible.value = false
|
||||
const tip = isHost.value ? '会议已结束' : '已离开会议'
|
||||
let failed = false
|
||||
try {
|
||||
if (isHost.value) {
|
||||
await meetingStore.endMeeting()
|
||||
@@ -757,13 +804,35 @@ const confirmEndOrLeave = async () => {
|
||||
await meetingStore.leave()
|
||||
}
|
||||
} catch (err) {
|
||||
failed = true
|
||||
uni.showToast({ title: err?.message || '操作失败', icon: 'none' })
|
||||
} 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' })
|
||||
}
|
||||
|
||||
@@ -776,15 +845,106 @@ const syncViewportHeight = () => {
|
||||
// #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 popstateHandler = null
|
||||
|
||||
// P2-3 修复:redirectTo 后必须 return,避免 onMounted 继续执行闪默认 UI
|
||||
// 使用 module-scoped 标记,onMounted 读取后判断是否提前退出
|
||||
let redirectingToJoin = false
|
||||
|
||||
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 避免白屏
|
||||
if (!meetingStore.isInMeeting) {
|
||||
const paramCode = (query?.code || '').replace(/\D/g, '')
|
||||
@@ -821,12 +981,62 @@ onMounted(() => {
|
||||
// 会议被主持人/后端结束(ENDED 状态)时,弹提示并回首页
|
||||
stateStopWatch = watch(() => meetingStore.localState, (s) => {
|
||||
if (s === MEETING_LOCAL_STATE_ENDED) {
|
||||
if (minimizingToMiniProgram.value) {
|
||||
minimizingToMiniProgram.value = false
|
||||
redirectHome(false)
|
||||
return
|
||||
}
|
||||
const reason = meetingStore.lastEndedReason
|
||||
const label = MEETING_ENDED_REASON_LABEL?.[reason] || '会议已结束'
|
||||
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(() => {
|
||||
@@ -836,6 +1046,21 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
if (viewportCleanup) { viewportCleanup(); viewportCleanup = 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(() => {
|
||||
@@ -882,6 +1107,23 @@ onUnload(() => {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -895,6 +1137,14 @@ onUnload(() => {
|
||||
}
|
||||
|
||||
.top-left {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.top-left-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 - 事件类型
|
||||
|
||||
@@ -975,6 +975,16 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
}
|
||||
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 uid = userStore.userInfo?.id
|
||||
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