feat(avatar): support production H5 token SSO
This commit is contained in:
@@ -428,7 +428,7 @@ export const getUserProfile = () =>
|
||||
export interface SmsLoginResult {
|
||||
token: string
|
||||
user: UserProfile & { huihuiUserId: string; phone: string; createdAt?: string; lastLoginAt?: string }
|
||||
huihui: { userId: string; nickname: string; avatarUrl: string; token: string }
|
||||
huihui: { userId: string; nickname: string; avatarUrl: string }
|
||||
}
|
||||
|
||||
// 发送短信验证码(演示模式会额外返回 devCode / dev 标记)
|
||||
@@ -443,6 +443,10 @@ export const loginBySms = (phone: string, code: string) =>
|
||||
export const loginByPassword = (account: string, password: string) =>
|
||||
request.post<SmsLoginResult>('/huihui/pwd/login', { account, password })
|
||||
|
||||
// Validate a production Huihui access token and exchange it for an app session.
|
||||
export const loginByHuihuiToken = (token: string) =>
|
||||
request.post<SmsLoginResult>('/huihui/token/login', { token })
|
||||
|
||||
// 当前登录用户
|
||||
export const getCurrentUser = () =>
|
||||
request.get<UserProfile & { huihuiUserId: string; phone: string }>('/huihui/me')
|
||||
|
||||
@@ -3,16 +3,14 @@ import uniWebView from '@dcloudio/uni-webview-js'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import pinia from './store'
|
||||
import { getLaunchParams, onNativeMessage, UniEvents } from '@/utils/uniapp-bridge'
|
||||
import { getLaunchParams, onNativeMessage, stripLaunchToken, UniEvents } from '@/utils/uniapp-bridge'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { setAuthToken } from '@/api'
|
||||
|
||||
// Bundle the bridge locally so the H5 does not depend on an external CDN.
|
||||
;(window as any).uni = (window as any).uni || uniWebView
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
|
||||
// —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 ——
|
||||
@@ -20,10 +18,8 @@ const params = getLaunchParams()
|
||||
const avatarStore = useAvatarStore(pinia)
|
||||
const userStore = useUserStore(pinia)
|
||||
|
||||
// 恢复本地短信登录会话(会会 userId ↔ 本系统用户)
|
||||
userStore.loadFromStorage()
|
||||
if (userStore.isLogin && userStore.user) {
|
||||
setAuthToken(userStore.token)
|
||||
function syncAvatarProfile() {
|
||||
if (!userStore.isLogin || !userStore.user) return
|
||||
avatarStore.setNativeProfile({
|
||||
userId: (userStore.user as any).huihuiUserId || '',
|
||||
nickname: userStore.user.nickname || '',
|
||||
@@ -31,29 +27,53 @@ if (userStore.isLogin && userStore.user) {
|
||||
})
|
||||
}
|
||||
|
||||
if (params.token) {
|
||||
setAuthToken(params.token)
|
||||
}
|
||||
if (params.userId || params.nickname || params.avatar) {
|
||||
avatarStore.setNativeProfile({
|
||||
userId: params.userId || '',
|
||||
nickname: params.nickname || '',
|
||||
avatarUrl: params.avatar || ''
|
||||
})
|
||||
async function exchangeInjectedToken(token: string) {
|
||||
stripLaunchToken()
|
||||
userStore.clearSession()
|
||||
await userStore.loginByToken(token)
|
||||
syncAvatarProfile()
|
||||
}
|
||||
|
||||
// 原生 → H5:注册消息处理(壳通过 web-view.evalJS 调用)
|
||||
onNativeMessage((msg) => {
|
||||
if (!msg || !msg.type) return
|
||||
if (msg.type === 'tokenRefresh' && msg.token) {
|
||||
setAuthToken(msg.token)
|
||||
void exchangeInjectedToken(msg.token).catch(() => {
|
||||
sessionStorage.setItem('hh_sso_error', '会会登录凭证无效或已过期,请重新进入')
|
||||
void router.replace('/login/sms')
|
||||
})
|
||||
}
|
||||
if (msg.type === 'userUpdate' && msg.user) {
|
||||
avatarStore.setNativeProfile(msg.user)
|
||||
}
|
||||
})
|
||||
|
||||
app.mount('#app')
|
||||
async function bootstrap() {
|
||||
// A URL token represents the current production user and must override stale storage.
|
||||
if (params.token) {
|
||||
try {
|
||||
await exchangeInjectedToken(params.token)
|
||||
} catch {
|
||||
sessionStorage.setItem('hh_sso_error', '会会登录凭证无效或已过期,请重新进入')
|
||||
}
|
||||
} else {
|
||||
userStore.loadFromStorage()
|
||||
syncAvatarProfile()
|
||||
}
|
||||
|
||||
// 通知原生壳:H5 已就绪
|
||||
UniEvents.ready()
|
||||
if (params.userId || params.nickname || params.avatar) {
|
||||
avatarStore.setNativeProfile({
|
||||
userId: params.userId || '',
|
||||
nickname: params.nickname || '',
|
||||
avatarUrl: params.avatar || ''
|
||||
})
|
||||
}
|
||||
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
// 通知原生壳:H5 已就绪
|
||||
UniEvents.ready()
|
||||
}
|
||||
|
||||
void bootstrap()
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { setAuthToken, sendSmsCode, loginBySms, loginByPassword, logoutUser, type UserProfile } from '@/api'
|
||||
import {
|
||||
setAuthToken,
|
||||
sendSmsCode,
|
||||
loginBySms,
|
||||
loginByPassword,
|
||||
loginByHuihuiToken,
|
||||
logoutUser,
|
||||
type SmsLoginResult,
|
||||
type UserProfile
|
||||
} from '@/api'
|
||||
|
||||
const TOKEN_KEY = 'hh_app_token'
|
||||
const USER_KEY = 'hh_app_user'
|
||||
@@ -37,6 +46,23 @@ export const useUserStore = defineStore('smsuser', () => {
|
||||
localStorage.removeItem(USER_KEY)
|
||||
}
|
||||
|
||||
const clearSession = () => {
|
||||
token.value = ''
|
||||
user.value = null
|
||||
isLogin.value = false
|
||||
setAuthToken(null)
|
||||
clearLocal()
|
||||
}
|
||||
|
||||
const acceptLogin = (res: SmsLoginResult) => {
|
||||
token.value = res.token
|
||||
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
|
||||
isLogin.value = true
|
||||
setAuthToken(res.token)
|
||||
persist()
|
||||
return res
|
||||
}
|
||||
|
||||
// 发送验证码(返回结果,演示模式含 devCode)
|
||||
const sendCode = async (phone: string) => {
|
||||
return await sendSmsCode(phone)
|
||||
@@ -44,26 +70,17 @@ export const useUserStore = defineStore('smsuser', () => {
|
||||
|
||||
// 短信登录
|
||||
const login = async (phone: string, code: string) => {
|
||||
const res: any = await loginBySms(phone, code)
|
||||
token.value = res.token
|
||||
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
|
||||
isLogin.value = true
|
||||
setAuthToken(res.token)
|
||||
persist()
|
||||
return res
|
||||
return acceptLogin(await loginBySms(phone, code))
|
||||
}
|
||||
|
||||
// 账号密码登录
|
||||
const loginByPwd = async (account: string, password: string) => {
|
||||
const res: any = await loginByPassword(account, password)
|
||||
token.value = res.token
|
||||
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
|
||||
isLogin.value = true
|
||||
setAuthToken(res.token)
|
||||
persist()
|
||||
return res
|
||||
return acceptLogin(await loginByPassword(account, password))
|
||||
}
|
||||
|
||||
const loginByToken = async (huihuiToken: string) =>
|
||||
acceptLogin(await loginByHuihuiToken(huihuiToken))
|
||||
|
||||
// 退出
|
||||
const logout = async () => {
|
||||
try {
|
||||
@@ -71,12 +88,19 @@ export const useUserStore = defineStore('smsuser', () => {
|
||||
} catch {
|
||||
/* 忽略网络错误,本地清除即可 */
|
||||
}
|
||||
token.value = ''
|
||||
user.value = null
|
||||
isLogin.value = false
|
||||
setAuthToken(null)
|
||||
clearLocal()
|
||||
clearSession()
|
||||
}
|
||||
|
||||
return { token, user, isLogin, loadFromStorage, sendCode, login, loginByPwd, logout }
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isLogin,
|
||||
loadFromStorage,
|
||||
clearSession,
|
||||
sendCode,
|
||||
login,
|
||||
loginByPwd,
|
||||
loginByToken,
|
||||
logout
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// 会会数字分身 H5 ↔ uniapp 原生壳 桥接工具
|
||||
// 协议详见 uniapp-avatar/README.md
|
||||
//
|
||||
// 引入方式:在 index.html 中加载 uniapp web-view bridge:
|
||||
// <script src="https://unpkg.com/@dcloudio/uni-webview-js@0.0.10/index.js"></script>
|
||||
// 引入后全局会出现 window.uni.webView,H5 即可用 postMessage 与原生通信。
|
||||
// uni-webview bridge is bundled by main.ts; no external CDN is required.
|
||||
|
||||
const BRIDGE_HANDLER = '__uniBridgeHandle__'
|
||||
|
||||
@@ -15,6 +13,16 @@ export interface UniLaunchParams {
|
||||
ts?: string
|
||||
}
|
||||
|
||||
const PARAM_KEYS: (keyof UniLaunchParams)[] = ['token', 'userId', 'nickname', 'avatar', 'ts']
|
||||
|
||||
function readParams(search: string, target: UniLaunchParams): void {
|
||||
const sp = new URLSearchParams(search)
|
||||
for (const key of PARAM_KEYS) {
|
||||
const value = sp.get(key)
|
||||
if (value) target[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// 是否运行在 uniapp web-view 环境中
|
||||
export function isInUniWebView(): boolean {
|
||||
return !!(window as any).uni?.webView
|
||||
@@ -22,21 +30,33 @@ export function isInUniWebView(): boolean {
|
||||
|
||||
// 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户)
|
||||
export function getLaunchParams(): UniLaunchParams {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
const params: UniLaunchParams = {}
|
||||
const token = sp.get('token')
|
||||
const userId = sp.get('userId')
|
||||
const nickname = sp.get('nickname')
|
||||
const avatar = sp.get('avatar')
|
||||
const ts = sp.get('ts')
|
||||
if (token) params.token = token
|
||||
if (userId) params.userId = userId
|
||||
if (nickname) params.nickname = decodeURIComponent(nickname)
|
||||
if (avatar) params.avatar = decodeURIComponent(avatar)
|
||||
if (ts) params.ts = ts
|
||||
readParams(window.location.search, params)
|
||||
const hashQueryIndex = window.location.hash.indexOf('?')
|
||||
if (hashQueryIndex >= 0) {
|
||||
readParams(window.location.hash.slice(hashQueryIndex + 1), params)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// Remove the one-time login credential before any route is rendered or logged.
|
||||
export function stripLaunchToken(): void {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('token')
|
||||
|
||||
const hash = url.hash.slice(1)
|
||||
const queryIndex = hash.indexOf('?')
|
||||
if (queryIndex >= 0) {
|
||||
const path = hash.slice(0, queryIndex)
|
||||
const hashParams = new URLSearchParams(hash.slice(queryIndex + 1))
|
||||
hashParams.delete('token')
|
||||
const query = hashParams.toString()
|
||||
url.hash = `${path}${query ? `?${query}` : ''}`
|
||||
}
|
||||
|
||||
window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`)
|
||||
}
|
||||
|
||||
// H5 → 原生:发送事件(需引入 uniapp web-view bridge)
|
||||
export function postToNative(message: Record<string, any>): boolean {
|
||||
if (!isInUniWebView()) return false
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed, onUnmounted } from 'vue'
|
||||
import { reactive, ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
@@ -161,6 +161,14 @@ const password = ref('')
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
const ssoError = sessionStorage.getItem('hh_sso_error')
|
||||
if (ssoError) {
|
||||
errorMsg.value = ssoError
|
||||
sessionStorage.removeItem('hh_sso_error')
|
||||
}
|
||||
})
|
||||
|
||||
const counting = ref(false)
|
||||
const countdown = ref(60)
|
||||
let timer: any = null
|
||||
|
||||
Reference in New Issue
Block a user