235 lines
7.0 KiB
TypeScript
235 lines
7.0 KiB
TypeScript
import { ref, computed } from 'vue'
|
||
import { defineStore } from 'pinia'
|
||
import { getUsdcBalance, formatUsdcBalance, getUserInfo } from '@/api/user'
|
||
import { getUserWsUrl, BASE_URL } from '@/api/request'
|
||
import { jsonInBlacklist } from '@/api/jwt'
|
||
import { UserSdk, type BalanceData, type PositionData } from '../../sdk/userSocket'
|
||
|
||
export interface UserInfo {
|
||
/** 用户 ID(API 可能返回 id 或 ID) */
|
||
id?: number | string
|
||
ID?: number
|
||
headerImg?: string
|
||
nickName?: string
|
||
userName?: string
|
||
[key: string]: unknown
|
||
}
|
||
|
||
const STORAGE_KEY = 'poly-user'
|
||
|
||
function loadStored(): { token: string; user: UserInfo } | null {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEY)
|
||
if (!raw) return null
|
||
return JSON.parse(raw) as { token: string; user: UserInfo }
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function saveToStorage(token: string, user: UserInfo) {
|
||
try {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ token, user }))
|
||
} catch {
|
||
//
|
||
}
|
||
}
|
||
|
||
function clearStorage() {
|
||
try {
|
||
localStorage.removeItem(STORAGE_KEY)
|
||
} catch {
|
||
//
|
||
}
|
||
}
|
||
|
||
/** 从 API 返回的 user 对象解析 id/ID,兼容 number 与 string */
|
||
function parseUserId(raw: { id?: number | string; ID?: number } | null | undefined): {
|
||
id: number | string | undefined
|
||
numId: number | undefined
|
||
} {
|
||
const rawId = raw?.ID ?? raw?.id
|
||
const numId =
|
||
typeof rawId === 'number' ? rawId : rawId != null ? parseInt(String(rawId), 10) : undefined
|
||
return { id: rawId ?? numId, numId: Number.isFinite(numId) ? numId : undefined }
|
||
}
|
||
|
||
export const useUserStore = defineStore('user', () => {
|
||
const stored = loadStored()
|
||
const token = ref<string>(stored?.token ?? '')
|
||
const user = ref<UserInfo | null>(stored?.user ?? null)
|
||
|
||
const isLoggedIn = computed(() => !!token.value && !!user.value)
|
||
const avatarUrl = computed(() => {
|
||
const img = user.value?.headerImg
|
||
if (!img) return ''
|
||
if (img.startsWith('http') || img.startsWith('data:')) return img
|
||
// 处理相对路径,拼接 BASE_URL
|
||
const baseUrl = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL
|
||
const path = img.startsWith('/') ? img : `/${img}`
|
||
return `${baseUrl}${path}`
|
||
})
|
||
/** 钱包余额显示,如 "0.00",可从接口或 UserSocket 推送更新 */
|
||
const balance = ref<string>('0.00')
|
||
|
||
let userSdkRef: UserSdk | null = null
|
||
const positionUpdateCallbacks: ((data: PositionData & Record<string, unknown>) => void)[] = []
|
||
|
||
// 若从 storage 恢复登录态,自动连接 UserSocket
|
||
if (stored?.token && stored?.user) {
|
||
// 延迟到 nextTick 后连接,避免 store 未完全初始化
|
||
Promise.resolve().then(() => connectUserSocket())
|
||
}
|
||
|
||
function connectUserSocket() {
|
||
if (!token.value) return
|
||
disconnectUserSocket()
|
||
const sdk = new UserSdk({
|
||
url: getUserWsUrl(),
|
||
token: token.value,
|
||
autoReconnect: true,
|
||
reconnectInterval: 2000,
|
||
})
|
||
sdk.onBalanceUpdate((data: BalanceData) => {
|
||
if ((data.tokenType ?? '').toUpperCase() !== 'USDC') return
|
||
const avail = data.available ?? data.amount
|
||
if (avail != null) {
|
||
balance.value = formatUsdcBalance(String(avail))
|
||
}
|
||
})
|
||
sdk.onPositionUpdate((data) => {
|
||
positionUpdateCallbacks.forEach((cb) => cb(data as PositionData & Record<string, unknown>))
|
||
})
|
||
sdk.onConnect(() => {})
|
||
sdk.onDisconnect(() => {})
|
||
sdk.onError((e) => {
|
||
console.error('[UserStore] UserSocket 错误:', e)
|
||
})
|
||
userSdkRef = sdk
|
||
sdk.connect()
|
||
}
|
||
|
||
function disconnectUserSocket() {
|
||
if (userSdkRef) {
|
||
userSdkRef.disconnect()
|
||
userSdkRef = null
|
||
}
|
||
}
|
||
|
||
/** 订阅 position_update 推送,返回取消订阅函数 */
|
||
function onPositionUpdate(
|
||
cb: (data: PositionData & Record<string, unknown>) => void,
|
||
): () => void {
|
||
positionUpdateCallbacks.push(cb)
|
||
return () => {
|
||
const i = positionUpdateCallbacks.indexOf(cb)
|
||
if (i >= 0) positionUpdateCallbacks.splice(i, 1)
|
||
}
|
||
}
|
||
|
||
function setUser(loginData: { token?: string; user?: UserInfo }) {
|
||
const t = loginData.token ?? ''
|
||
const raw = loginData.user ?? null
|
||
token.value = t
|
||
if (raw) {
|
||
const { id, numId } = parseUserId(raw)
|
||
user.value = { ...raw, id, ID: numId ?? raw.ID }
|
||
} else {
|
||
user.value = null
|
||
}
|
||
if (t && user.value) {
|
||
saveToStorage(t, user.value)
|
||
connectUserSocket()
|
||
} else {
|
||
clearStorage()
|
||
disconnectUserSocket()
|
||
}
|
||
}
|
||
|
||
async function logout() {
|
||
const headers = getAuthHeaders()
|
||
if (headers) {
|
||
try {
|
||
await jsonInBlacklist({ headers })
|
||
} catch {
|
||
// 忽略失败,仍执行本地登出
|
||
}
|
||
}
|
||
disconnectUserSocket()
|
||
token.value = ''
|
||
user.value = null
|
||
clearStorage()
|
||
}
|
||
|
||
/** 鉴权请求头:x-token 与 x-user-id,未登录时返回 undefined */
|
||
function getAuthHeaders(): Record<string, string> | undefined {
|
||
if (!token.value) return undefined
|
||
const uid = user.value?.id ?? user.value?.ID
|
||
return {
|
||
'x-token': token.value,
|
||
...(uid != null && uid !== '' && { 'x-user-id': String(uid) }),
|
||
}
|
||
}
|
||
|
||
/** 请求 USDC 余额(需已登录),amount/available 除以 1000000 后更新余额显示 */
|
||
async function fetchUsdcBalance() {
|
||
const headers = getAuthHeaders()
|
||
if (!headers) return
|
||
try {
|
||
const res = await getUsdcBalance(headers)
|
||
if (res.code === 0 && res.data) {
|
||
balance.value = formatUsdcBalance(res.data.available)
|
||
}
|
||
} catch (e) {
|
||
console.error('[fetchUsdcBalance] 请求失败:', e)
|
||
}
|
||
}
|
||
|
||
/** 请求用户信息(需已登录),更新 store 中的 user 与 balance */
|
||
async function fetchUserInfo() {
|
||
const headers = getAuthHeaders()
|
||
if (!headers) return
|
||
try {
|
||
const res = await getUserInfo(headers)
|
||
const data = res.data as Record<string, unknown> | undefined
|
||
if (res.code !== 0 && res.code !== 200) return
|
||
// 更新余额:data.balance.available
|
||
const bal = data?.balance as { available?: string } | undefined
|
||
if (bal?.available != null) {
|
||
balance.value = formatUsdcBalance(String(bal.available))
|
||
}
|
||
// 更新用户信息:data.userInfo
|
||
const u = (data?.userInfo ?? data?.user ?? data) as Record<string, unknown> | undefined
|
||
if (!u) return
|
||
const { id, numId } = parseUserId(u as { id?: number | string; ID?: number })
|
||
user.value = {
|
||
...u,
|
||
userName: (u.userName ?? u.username) as string | undefined,
|
||
nickName: (u.nickName ?? u.nickname) as string | undefined,
|
||
headerImg: (u.headerImg ?? u.avatar ?? u.avatarUrl) as string | undefined,
|
||
id,
|
||
ID: numId,
|
||
} as UserInfo
|
||
if (token.value && user.value) saveToStorage(token.value, user.value)
|
||
} catch (e) {
|
||
console.error('[fetchUserInfo] 请求失败:', e)
|
||
}
|
||
}
|
||
|
||
return {
|
||
token,
|
||
user,
|
||
isLoggedIn,
|
||
avatarUrl,
|
||
balance,
|
||
setUser,
|
||
logout,
|
||
getAuthHeaders,
|
||
fetchUsdcBalance,
|
||
fetchUserInfo,
|
||
connectUserSocket,
|
||
disconnectUserSocket,
|
||
onPositionUpdate,
|
||
}
|
||
})
|