212 lines
6.7 KiB
Vue
212 lines
6.7 KiB
Vue
<!--
|
||
外部系统 SSO 桥接页(cloud_law -> EchoChat)
|
||
|
||
入参(query):
|
||
- payload: URI 编码的 JSON 字符串 { token, refresh_token, expires_in, user }
|
||
- redirect: URI 编码的目标 hash 路径,例如 /pages/meeting/join?code=XXX-XXX-XXX
|
||
|
||
逻辑:
|
||
1. 读取 payload,写入 storage(saveToken + saveUserInfo)
|
||
2. 同步 user store
|
||
3. 跳转 redirect(默认 /pages/meeting/index)
|
||
-->
|
||
<template>
|
||
<view class="sso">
|
||
<!-- 仅出错时显示文字;正常跳转过程肉眼无感(背景与会议页一致) -->
|
||
<text v-if="hasError" class="sso-title">{{ statusText }}</text>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref } from 'vue'
|
||
import { onLoad } from '@dcloudio/uni-app'
|
||
import { saveToken, saveUserInfo } from '@/utils/storage'
|
||
import { useUserStore } from '@/store/user'
|
||
import { useWebSocketStore } from '@/store/websocket'
|
||
import { useChatStore } from '@/store/chat'
|
||
import { useContactStore } from '@/store/contact'
|
||
import { useGroupStore } from '@/store/group'
|
||
import { useNotifyStore } from '@/store/notify'
|
||
|
||
const statusText = ref('正在登录…')
|
||
const hasError = ref(false)
|
||
|
||
function safeDecode(value) {
|
||
try {
|
||
return decodeURIComponent(value)
|
||
} catch (e) {
|
||
return value
|
||
}
|
||
}
|
||
|
||
function readRawQueryParams() {
|
||
const result = {}
|
||
try {
|
||
if (typeof window === 'undefined') return result
|
||
const search = String(window.location.search || '').replace(/^\?/, '')
|
||
const hash = String(window.location.hash || '')
|
||
const hashQuery = hash.indexOf('?') >= 0 ? hash.slice(hash.indexOf('?') + 1) : ''
|
||
;[search, hashQuery].forEach(raw => {
|
||
if (!raw) return
|
||
const params = new URLSearchParams(raw)
|
||
params.forEach((value, key) => {
|
||
if (result[key] === undefined) {
|
||
result[key] = value
|
||
}
|
||
})
|
||
})
|
||
} catch (e) {}
|
||
return result
|
||
}
|
||
|
||
function readSsoParam(query, key) {
|
||
const value = query && query[key] !== undefined ? query[key] : readRawQueryParams()[key]
|
||
return value === undefined || value === null ? '' : String(value)
|
||
}
|
||
|
||
function resolveSsoPayload(query) {
|
||
const payloadText = readSsoParam(query, 'payload')
|
||
if (payloadText) {
|
||
return JSON.parse(safeDecode(payloadText))
|
||
}
|
||
const token = readSsoParam(query, 'token')
|
||
const refreshToken = readSsoParam(query, 'refresh_token')
|
||
const expiresIn = Number(readSsoParam(query, 'expires_in') || 0)
|
||
const userText = readSsoParam(query, 'user')
|
||
const user = userText ? JSON.parse(safeDecode(userText)) : null
|
||
return { token, refresh_token: refreshToken, expires_in: expiresIn, user }
|
||
}
|
||
|
||
function decodeJwtPayload(token) {
|
||
try {
|
||
const parts = String(token || '').split('.')
|
||
if (parts.length < 2) return null
|
||
let base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
||
while (base64.length % 4) base64 += '='
|
||
const binary = atob(base64)
|
||
const json = decodeURIComponent(Array.prototype.map.call(binary, c => {
|
||
return `%${(`00${c.charCodeAt(0).toString(16)}`).slice(-2)}`
|
||
}).join(''))
|
||
return JSON.parse(json)
|
||
} catch (e) {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function resolveSsoUser(payload) {
|
||
if (payload && payload.user) return payload.user
|
||
const claims = decodeJwtPayload(payload && payload.token)
|
||
if (!claims || !claims.user_id) return null
|
||
const username = claims.username || String(claims.user_id)
|
||
return {
|
||
id: claims.user_id,
|
||
user_id: claims.user_id,
|
||
username,
|
||
nickname: username,
|
||
roles: Array.isArray(claims.roles) ? claims.roles : []
|
||
}
|
||
}
|
||
|
||
function resolveRedirect(query) {
|
||
const rawRedirect = readSsoParam(query || {}, 'redirect')
|
||
const decoded = rawRedirect ? safeDecode(rawRedirect) : '/pages/meeting/index'
|
||
const cleaned = String(decoded || '').trim().replace(/^["']+|["']+$/g, '')
|
||
return cleaned || '/pages/meeting/index'
|
||
}
|
||
|
||
function markCloudLawEmbed() {
|
||
try {
|
||
if (typeof window === 'undefined') return
|
||
window.sessionStorage.setItem('echo_cloud_law_meeting_embed', '1')
|
||
} catch (e) {}
|
||
}
|
||
|
||
function bootstrapAfterSso(token) {
|
||
// 模拟 App.onLaunch 的初始化路径:先重连 WS(顶掉旧 token 连接),再注册各 store 的 ws 监听并拉初始数据
|
||
try {
|
||
const wsStore = useWebSocketStore()
|
||
wsStore.disconnect()
|
||
wsStore.connect(token)
|
||
} catch (wsErr) {
|
||
console.warn('[SSO] WebSocket 重连失败', wsErr)
|
||
}
|
||
try {
|
||
const chatStore = useChatStore()
|
||
const contactStore = useContactStore()
|
||
const groupStore = useGroupStore()
|
||
const notifyStore = useNotifyStore()
|
||
chatStore.initWsListeners()
|
||
contactStore.initWsListeners()
|
||
groupStore.initWsListeners()
|
||
notifyStore.initWsListeners()
|
||
contactStore.fetchPendingRequests().catch(() => {})
|
||
notifyStore.fetchUnreadCount().catch(() => {})
|
||
} catch (storeErr) {
|
||
console.warn('[SSO] 全局 store 初始化异常', storeErr)
|
||
}
|
||
}
|
||
|
||
onLoad((query) => {
|
||
try {
|
||
try {
|
||
console.log('[SSO] location:', {
|
||
search: typeof window !== 'undefined' ? window.location.search : '',
|
||
hash: typeof window !== 'undefined' ? window.location.hash : '',
|
||
query
|
||
})
|
||
} catch (logErr) {}
|
||
const payload = resolveSsoPayload(query || {})
|
||
const ssoUser = resolveSsoUser(payload)
|
||
const redirect = resolveRedirect(query || {})
|
||
console.log('[SSO] resolved params:', {
|
||
hasToken: !!(payload && payload.token),
|
||
hasPayloadUser: !!(payload && payload.user),
|
||
hasSsoUser: !!ssoUser,
|
||
userId: ssoUser && (ssoUser.id || ssoUser.user_id),
|
||
redirect
|
||
})
|
||
if (!payload || !payload.token || !ssoUser) {
|
||
statusText.value = '缺少 payload 参数'
|
||
hasError.value = true
|
||
return
|
||
}
|
||
markCloudLawEmbed()
|
||
// 写入持久化存储
|
||
saveToken(payload.token, payload.refresh_token || '', payload.expires_in || 0)
|
||
saveUserInfo(ssoUser)
|
||
// 同步 pinia store
|
||
const userStore = useUserStore()
|
||
userStore.token = payload.token
|
||
userStore.userInfo = ssoUser
|
||
|
||
bootstrapAfterSso(payload.token)
|
||
|
||
statusText.value = '登录成功,跳转中…'
|
||
console.log('[SSO] redirectTo:', redirect)
|
||
// redirectTo 不保留历史,避免返回到 sso 页造成循环
|
||
// 此前曾延迟 200ms 跳转,导致拉起会议时中间出现白底"正在登录…"过渡页;
|
||
// saveToken/saveUserInfo 已是同步 storage 写入,无需额外等待。
|
||
uni.redirectTo({ url: redirect })
|
||
} catch (e) {
|
||
console.error('[SSO] 解析失败', e)
|
||
statusText.value = `SSO 失败:${e.message || e}`
|
||
hasError.value = true
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.sso {
|
||
min-height: 100vh;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
/* 与进入后的会议页背景一致,跳转闪烁不可见 */
|
||
background: #050b1d;
|
||
}
|
||
.sso-title {
|
||
font-size: 30rpx;
|
||
color: rgba(255,255,255,0.78);
|
||
}
|
||
</style>
|