电话咨询
This commit is contained in:
@@ -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,7 +81,8 @@ 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 用于识别云律临时会议账号,对其放宽「单点登录挤号」校验
|
||||||
|
func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, clientType, token, username string) bool {
|
||||||
funcName := "service.token_store.ValidateAccessToken"
|
funcName := "service.token_store.ValidateAccessToken"
|
||||||
|
|
||||||
key := fmt.Sprintf("%s%s:%d", keyPrefixAccessToken, clientType, userID)
|
key := fmt.Sprintf("%s%s:%d", keyPrefixAccessToken, clientType, userID)
|
||||||
@@ -91,7 +103,23 @@ func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, clie
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return stored == token
|
if stored == token {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 云律临时会议账号:同一手机号在「双方进会 / SSO 多跳 / 多端」场景会被重复签发会话,
|
||||||
|
// 单点登录会把先签发的 token 挤掉,导致 web-view 进会报「认证已失效」。
|
||||||
|
// 这里只要该账号在 Redis 中仍存在有效会话(stored 非空,未登出/未过期),
|
||||||
|
// 且 token 已通过 JWT 签名与有效期校验(调用方 ParseToken 已保证),即放行。
|
||||||
|
if stored != "" && isCloudLawUsername(username) {
|
||||||
|
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 中存储的一致
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -245,15 +245,21 @@ const emitIf = (name) => {
|
|||||||
width: 72rpx;
|
width: 72rpx;
|
||||||
height: 72rpx;
|
height: 72rpx;
|
||||||
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 +268,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 +293,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 +312,61 @@ 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;
|
||||||
}
|
}
|
||||||
.icon {
|
.icon {
|
||||||
width: 56rpx;
|
width: 64rpx;
|
||||||
height: 56rpx;
|
height: 64rpx;
|
||||||
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 +390,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>
|
||||||
|
|||||||
@@ -427,25 +427,43 @@ const buildMiniProgramReturnUrl = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let endedRedirectTimer = null
|
let endedRedirectTimer = null
|
||||||
|
let miniProgramRedirected = false
|
||||||
|
|
||||||
|
const getMiniProgram = () => {
|
||||||
|
return typeof window !== 'undefined' && window.wx && window.wx.miniProgram ? window.wx.miniProgram : null
|
||||||
|
}
|
||||||
|
|
||||||
const notifyMiniProgramCallEnded = () => {
|
const notifyMiniProgramCallEnded = () => {
|
||||||
|
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) {
|
} catch (e) {
|
||||||
miniProgram.redirectTo({ url: payload.returnUrl })
|
console.warn('[CloudLawVideoCall] postMessage failed', e)
|
||||||
|
}
|
||||||
|
if (miniProgramRedirected) return
|
||||||
|
try {
|
||||||
|
if (miniProgram && returnUrl && typeof miniProgram.redirectTo === 'function') {
|
||||||
|
miniProgramRedirected = true
|
||||||
|
miniProgram.redirectTo({ url: returnUrl })
|
||||||
|
} else if (miniProgram && typeof miniProgram.navigateBack === 'function') {
|
||||||
|
// 没有可跳转的咨询详情地址时,至少退回上一页(等待页),由小程序侧轮询兜底跳转
|
||||||
|
miniProgramRedirected = true
|
||||||
|
miniProgram.navigateBack({ delta: 1 })
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[CloudLawVideoCall] notify mini program failed', e)
|
miniProgramRedirected = false
|
||||||
|
console.warn('[CloudLawVideoCall] redirect mini program failed', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -739,15 +739,21 @@ const onLeaveClick = () => { 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 +763,61 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 是否运行在云律小程序 web-view 内(SSO 进入时打的标记)
|
||||||
|
const isCloudLawEmbed = () => {
|
||||||
|
try {
|
||||||
|
return typeof window !== 'undefined'
|
||||||
|
&& !!window.sessionStorage
|
||||||
|
&& window.sessionStorage.getItem('echo_cloud_law_meeting_embed') === '1'
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMiniProgram = () => {
|
||||||
|
return typeof window !== 'undefined' && window.wx && window.wx.miniProgram ? window.wx.miniProgram : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退出会议视频界面:先给提示,再退出。
|
||||||
|
// 在云律小程序 web-view 内 → 通知并 navigateBack 回到小程序(关闭会议 web-view 页);
|
||||||
|
// 普通浏览器/PC → 回 EchoChat 首页。
|
||||||
|
const leaveAndExit = (tip) => {
|
||||||
|
if (tip) {
|
||||||
|
uni.showToast({ title: tip, icon: 'none', duration: 1200 })
|
||||||
|
}
|
||||||
|
// 留一点时间让提示可见,再退出视频界面
|
||||||
|
setTimeout(redirectHome, tip ? 600 : 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
let meetingExited = false
|
||||||
const redirectHome = () => {
|
const redirectHome = () => {
|
||||||
|
if (meetingExited) return
|
||||||
|
const miniProgram = getMiniProgram()
|
||||||
|
if (isCloudLawEmbed() && miniProgram) {
|
||||||
|
meetingExited = true
|
||||||
|
try {
|
||||||
|
miniProgram.postMessage({ data: { type: 'cloudLawCallEnded', action: 'cloudLawCallEnded' } })
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
if (typeof miniProgram.navigateBack === 'function') {
|
||||||
|
miniProgram.navigateBack({ delta: 1 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof miniProgram.redirectTo === 'function') {
|
||||||
|
miniProgram.redirectTo({ url: '/pages/index/home_page/home_page' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
meetingExited = false
|
||||||
|
}
|
||||||
|
}
|
||||||
uni.reLaunch({ url: '/pages/index/index' })
|
uni.reLaunch({ url: '/pages/index/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user