码桶
发现社区成员的开源项目
user.ts16.8 KB
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { SpeedLogSigner } from '@/utils/speedLogSecurity'
import { useAppStore } from '@/stores/app'
type LoginState = 'idle' | 'loading' | 'waiting' | 'binding' | 'expired' | 'failed' | 'success'
interface LoginCache {
session?: string
img?: string
url?: string
channelId?: number
channelType?: string
channelName?: string
verifyCode?: string
wechatName?: string
expiresAt?: number
}
interface LoginChannel {
id: number
name: string
type: string
}
export interface SpeedLogMetadata {
sessionId?: string
direction?: 'DOWNLOAD' | 'UPLOAD' | 'BIDIRECTIONAL'
downloadBytes?: number
uploadBytes?: number
testMode?: 'SINGLE' | 'GROUP'
groupName?: string
nodeCount?: number
groupNodes?: Array<{
title: string
url: string
direction?: 'DOWNLOAD' | 'UPLOAD' | 'BIDIRECTIONAL'
method?: 'GET' | 'POST' | 'HEAD'
httpProtocol?: 'AUTO' | 'HTTP_1_1' | 'HTTP_2'
enhanced?: boolean
}>
downloadThreadNum?: number
uploadThreadNum?: number
clientType?: 'WEB' | 'APP'
clientVersion?: string
isFinal?: boolean
}
const WXMP_VERIFY_CODE_TTL_MS = 5 * 60 * 1000
export const useUserStore = defineStore('user', () => {
const appStore = useAppStore()
const isLoggedIn = ref(false)
const uin = ref('')
const username = ref('')
const avatar = ref('')
const token = ref('')
const dosage = ref(parseInt(localStorage.getItem('dosage') || '0'))
const rankData = ref<any>(null)
const isCheckingStatus = ref(false)
const loginState = ref<LoginState>('idle')
const loginSession = ref('')
const loginQrImage = ref('')
const loginUrl = ref('')
const loginChannels = ref<LoginChannel[]>([])
const loginChannelId = ref(0)
const loginChannelType = ref('')
const loginChannelName = ref('')
const loginVerifyCode = ref('')
const loginWechatName = ref('')
const loginMessage = ref('请使用 QQ/TIM 手机版扫描二维码')
const loginError = ref('')
const loginBusy = ref(false)
const loginDots = ref('.')
const API_URL = 'https://net.arsn.cn/api/'
let loginTimer: number | null = null
let dotsTimer: number | null = null
let loginRefreshTimer: number | null = null
let loginPollInFlight = false
const speedLogSigners = new Map<string, SpeedLogSigner>()
const speedLogQueues = new Map<string, Promise<any>>()
async function apiRequest(action: string, method = 'GET', args: Record<string, any> = {}) {
const options: RequestInit = {
method,
mode: 'cors',
redirect: 'follow',
referrerPolicy: 'no-referrer',
}
if (method === 'POST') {
options.headers = { 'Content-Type': 'application/json' }
options.body = JSON.stringify(args)
}
const response = await fetch(API_URL + action, options)
return await response.json()
}
async function checkStatus() {
const accessToken = getToken()
if (!accessToken) {
clearUserState()
return { status: -1 }
}
isCheckingStatus.value = true
try {
const resp = await apiRequest('speed/my-status', 'POST', { AccessToken: accessToken })
if (resp.status === 0) {
isLoggedIn.value = true
token.value = accessToken
rankData.value = resp.data
uin.value = resp.uin || ''
username.value = resp.nickname || ''
avatar.value = resp.avatar || ''
} else {
clearUserState()
}
return resp
} catch {
clearUserState()
return { status: -2, msg: '登录服务器异常' }
} finally {
isCheckingStatus.value = false
}
}
function getToken(): string {
return localStorage.getItem('AccessToken') || ''
}
function getCookie(name: string): string {
const cookies = document.cookie.split('; ')
for (const cookie of cookies) {
const [cookieName, ...parts] = cookie.split('=')
if (cookieName === name) {
try {
return decodeURIComponent(parts.join('=') || '')
} catch {
return parts.join('=') || ''
}
}
}
return ''
}
function setCookie(name: string, value: string, durationMs: number) {
const expires = new Date(Date.now() + durationMs).toUTCString()
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`
}
function deleteCookie(name: string) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`
}
function clearUserState() {
isLoggedIn.value = false
uin.value = ''
username.value = ''
avatar.value = ''
token.value = ''
rankData.value = null
}
function readLoginCache(): LoginCache {
try {
return JSON.parse(getCookie('loginData') || '{}')
} catch {
return {}
}
}
function saveLoginCache(cache: LoginCache) {
setCookie('loginData', JSON.stringify(cache), WXMP_VERIFY_CODE_TTL_MS)
}
function clearLoginCache() {
deleteCookie('loginData')
}
function resetLoginState() {
stopLoginPolling()
loginState.value = 'idle'
loginSession.value = ''
loginQrImage.value = ''
loginUrl.value = ''
loginVerifyCode.value = ''
loginWechatName.value = ''
loginMessage.value = loginChannelType.value === 'wxmp' ? '请向微信公众号发送验证码' : '请使用 QQ/TIM 手机版扫描二维码'
loginError.value = ''
loginBusy.value = false
loginDots.value = '.'
}
function applyLoginCache(cache: LoginCache) {
loginSession.value = cache.session || ''
loginQrImage.value = cache.img || ''
loginUrl.value = cache.url || ''
loginChannelId.value = cache.channelId || loginChannelId.value
loginChannelType.value = cache.channelType || loginChannelType.value
loginChannelName.value = cache.channelName || loginChannelName.value
loginVerifyCode.value = cache.verifyCode || ''
loginWechatName.value = cache.wechatName || ''
loginMessage.value = loginChannelType.value === 'wxmp'
? `请向「${loginWechatName.value || loginChannelName.value}」发送验证码`
: '请使用 QQ/TIM 手机版扫描二维码'
loginError.value = ''
loginState.value = 'waiting'
}
async function loadLoginChannels() {
const channels = await apiRequest('auth/channels')
loginChannels.value = Array.isArray(channels) ? channels : []
const selected = loginChannels.value.find((channel) => channel.id === loginChannelId.value)
|| loginChannels.value.find((channel) => channel.type === 'qq')
|| loginChannels.value[0]
if (selected) {
loginChannelId.value = selected.id
loginChannelType.value = selected.type
loginChannelName.value = selected.name
}
return loginChannels.value
}
async function requestLoginQr(force = false, channelId?: number) {
loginBusy.value = true
loginError.value = ''
loginState.value = 'loading'
try {
if (!loginChannels.value.length) await loadLoginChannels()
if (channelId) {
const selected = loginChannels.value.find((channel) => channel.id === channelId)
if (selected) {
loginChannelId.value = selected.id
loginChannelType.value = selected.type
loginChannelName.value = selected.name
}
}
if (!loginChannelId.value) throw new Error('暂无可用登录通道')
const cache = readLoginCache()
const cacheIsFresh = cache.channelType !== 'wxmp' || (cache.expiresAt || 0) > Date.now()
if (!force && cache.session && cache.img && cache.channelId === loginChannelId.value && cacheIsFresh) {
applyLoginCache(cache)
startLoginPolling()
scheduleWxmpLoginRefresh(cache.expiresAt)
return { status: 0, cached: true }
}
const resp = await apiRequest('auth/login', 'POST', { channelId: loginChannelId.value, AccessToken: getToken() || undefined })
if (resp.session && resp.qrcode) {
const nextCache = {
session: resp.session || '',
img: resp.qrcode || '',
url: resp.url || '',
channelId: resp.channelId || loginChannelId.value,
channelType: resp.type || loginChannelType.value,
channelName: resp.name || loginChannelName.value,
verifyCode: resp.verify_code || '',
wechatName: resp.wechat_name || '',
expiresAt: (resp.type || loginChannelType.value) === 'wxmp'
? Date.now() + WXMP_VERIFY_CODE_TTL_MS
: undefined,
}
saveLoginCache(nextCache)
applyLoginCache(nextCache)
startLoginPolling()
scheduleWxmpLoginRefresh(nextCache.expiresAt)
} else {
loginState.value = 'failed'
loginError.value = '获取登录二维码失败'
}
return { status: 0, ...resp }
} catch (error: any) {
loginState.value = 'failed'
loginError.value = error?.message || '登录服务器异常,请稍后重试'
return { status: -2, msg: loginError.value }
} finally {
loginBusy.value = false
}
}
async function selectLoginChannel(channelId: number) {
if (channelId === loginChannelId.value && loginState.value !== 'failed') return
stopLoginPolling()
clearLoginCache()
loginSession.value = ''
loginQrImage.value = ''
loginUrl.value = ''
loginVerifyCode.value = ''
loginWechatName.value = ''
await requestLoginQr(true, channelId)
}
function startLoginPolling() {
stopLoginPolling()
loginTimer = window.setInterval(() => {
pollQrLogin()
}, 3000)
dotsTimer = window.setInterval(() => {
loginDots.value = loginDots.value.length >= 3 ? '.' : `${loginDots.value}.`
}, 1000)
}
function scheduleWxmpLoginRefresh(expiresAt?: number) {
if (loginRefreshTimer !== null) {
window.clearTimeout(loginRefreshTimer)
loginRefreshTimer = null
}
if (loginChannelType.value !== 'wxmp' || !expiresAt) return
const delay = Math.max(0, expiresAt - Date.now())
loginRefreshTimer = window.setTimeout(() => {
loginRefreshTimer = null
if (loginState.value === 'waiting') {
stopLoginPolling()
requestLoginQr(true)
}
}, delay)
}
function stopLoginPolling() {
if (loginTimer !== null) {
window.clearInterval(loginTimer)
loginTimer = null
}
if (dotsTimer !== null) {
window.clearInterval(dotsTimer)
dotsTimer = null
}
if (loginRefreshTimer !== null) {
window.clearTimeout(loginRefreshTimer)
loginRefreshTimer = null
}
}
async function pollQrLogin() {
if (!loginSession.value || loginPollInFlight) return { status: -5 }
loginPollInFlight = true
try {
const resp = await apiRequest('auth/check', 'POST', { session: loginSession.value })
if (resp.code === -1) {
loginState.value = 'waiting'
loginMessage.value = loginChannelType.value === 'wxmp'
? `请向「${loginWechatName.value || loginChannelName.value}」发送验证码`
: '请使用 QQ/TIM 手机版扫描二维码'
} else if (resp.code === -2) {
loginState.value = 'binding'
loginMessage.value = '请输入刚刚用于授权的 QQ 号码'
stopLoginPolling()
} else if (resp.code === -3) {
loginState.value = 'failed'
loginError.value = resp.msg || '本次登录已被拒绝'
loginMessage.value = '本次登录已被拒绝'
clearLoginCache()
stopLoginPolling()
} else if (resp.code === -4) {
loginState.value = 'expired'
loginMessage.value = '登录超时,请刷新二维码重试'
clearLoginCache()
stopLoginPolling()
} else if (resp.code === 0) {
localStorage.setItem('AccessToken', resp.AccessToken)
token.value = resp.AccessToken
uin.value = resp.uin || ''
username.value = resp.nickname || ''
avatar.value = resp.avatar || (loginChannelType.value === 'qq' && resp.uin ? `https://q.qlogo.cn/headimg_dl?dst_uin=${encodeURIComponent(resp.uin)}&spec=640` : '')
isLoggedIn.value = true
loginState.value = 'success'
loginMessage.value = '登录成功'
clearLoginCache()
stopLoginPolling()
}
return { status: resp.code, ...resp }
} catch {
loginState.value = 'failed'
loginError.value = '登录状态确认失败,请稍后重试'
stopLoginPolling()
return { status: -2, msg: loginError.value }
} finally {
loginPollInFlight = false
}
}
async function bindQQ(uinValue: string) {
const bindUin = uinValue.trim()
if (!bindUin) {
loginError.value = '请输入 QQ 号码'
return { status: -1, msg: loginError.value }
}
if (!loginSession.value) {
loginError.value = '登录会话已失效,请刷新二维码'
loginState.value = 'expired'
return { status: -4, msg: loginError.value }
}
loginBusy.value = true
loginError.value = ''
try {
const resp = await apiRequest('auth/bind', 'POST', {
uin: bindUin,
session: loginSession.value,
})
if (resp.code === 0) {
loginMessage.value = '绑定成功,正在确认登录'
await pollQrLogin()
} else {
loginError.value = resp.msg || resp.message || '绑定失败,请检查 QQ 号码'
}
const status = resp.code ?? (typeof resp.statusCode === 'number' ? -resp.statusCode : -1)
return { ...resp, status }
} catch (e: any) {
loginError.value = e?.message || '绑定服务异常,请稍后重试'
return { status: -2, msg: loginError.value }
} finally {
loginBusy.value = false
}
}
async function loginWithToken(accessToken: string) {
const nextToken = accessToken.trim()
if (!nextToken) return { status: -1, msg: '请输入 AccessToken' }
localStorage.setItem('AccessToken', nextToken)
const resp = await checkStatus()
if (resp.status !== 0) {
localStorage.removeItem('AccessToken')
clearUserState()
}
return resp
}
function logout() {
const accessToken = getToken()
if (accessToken) {
apiRequest('auth/logout', 'POST', { AccessToken: accessToken }).catch(() => {})
}
localStorage.removeItem('AccessToken')
speedLogSigners.clear()
speedLogQueues.clear()
clearUserState()
}
async function uploadLog(
used: number,
time: number,
urlValue: string,
threadNum: number,
urlName?: string,
metadata: SpeedLogMetadata = {},
) {
if (used <= 0 || time <= 0) return { status: 0 }
const accessToken = token.value || getToken()
const sessionId = metadata.sessionId
if (!accessToken || !sessionId) return { status: -1, msg: '未登录' }
const payload: Record<string, unknown> = {
url: urlValue,
threadNum,
used,
time,
urlName,
...metadata,
clientType: 'WEB',
clientVersion: appStore.siteVersion,
}
const submit = async () => {
let signer = speedLogSigners.get(sessionId)
if (!signer) {
signer = await SpeedLogSigner.create(apiRequest, accessToken, sessionId, 'WEB')
speedLogSigners.set(sessionId, signer)
}
try {
const envelope = await signer.seal(payload)
const response = await apiRequest('speed/log', 'POST', { AccessToken: accessToken, ...envelope })
if (response?.status === 0) signer.commit(envelope.sequence)
else speedLogSigners.delete(sessionId)
if (metadata.isFinal) speedLogSigners.delete(sessionId)
return response
} catch (error) {
speedLogSigners.delete(sessionId)
throw error
}
}
const previous = speedLogQueues.get(sessionId) || Promise.resolve()
const queued = previous.catch(() => undefined).then(submit)
speedLogQueues.set(sessionId, queued)
const cleanup = () => {
if (speedLogQueues.get(sessionId) === queued) speedLogQueues.delete(sessionId)
}
queued.then(cleanup, cleanup)
return await queued
}
let dosageSaveTimer: ReturnType<typeof setTimeout> | null = null
function addDosage(bytes: number) {
dosage.value += bytes
if (!dosageSaveTimer) {
dosageSaveTimer = setTimeout(() => {
localStorage.setItem('dosage', String(dosage.value))
dosageSaveTimer = null
}, 1000)
}
}
async function kickOtherDevices() {
const resp = await apiRequest('auth/kick', 'POST', { AccessToken: getToken() })
return { ...resp, status: resp.status ?? resp.code }
}
return {
isLoggedIn,
uin,
username,
avatar,
token,
dosage,
rankData,
isCheckingStatus,
loginState,
loginSession,
loginQrImage,
loginUrl,
loginChannels,
loginChannelId,
loginChannelType,
loginChannelName,
loginVerifyCode,
loginWechatName,
loginMessage,
loginError,
loginBusy,
loginDots,
apiRequest,
checkStatus,
getToken,
requestLoginQr,
loadLoginChannels,
selectLoginChannel,
stopLoginPolling,
resetLoginState,
bindQQ,
loginWithToken,
logout,
uploadLog,
addDosage,
kickOtherDevices,
}
})