feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+371
View File
@@ -0,0 +1,371 @@
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
@click="handleClose"
>
<div class="absolute inset-0 bg-bg-primary-60 backdrop-blur-sm" />
<div
class="relative w-full max-w-sm overflow-hidden rounded-3xl border border-border-secondary bg-bg-secondary shadow-2xl"
@click.stop
>
<div class="flex items-center justify-between border-b border-border-secondary-50 p-8 pb-4">
<div>
<h3 class="flex items-center gap-3 text-xl font-black tracking-tight text-text-primary">
<div
class="flex h-10 w-10 items-center justify-center rounded-2xl bg-info-10 text-info"
>
<Icon name="music" :size="20" />
</div>
{{ locale.qqTitle }}
</h3>
<p class="ml-13 mt-1 text-xs text-text-tertiary">{{ locale.qqDesc }}</p>
</div>
<button
class="rounded-2xl bg-bg-tertiary-50 p-3 text-text-tertiary transition-all hover:bg-bg-tertiary hover:text-text-primary"
type="button"
@click="handleClose"
>
<Icon name="x" :size="20" />
</button>
</div>
<div class="flex flex-col items-center p-8 pt-4">
<!-- 登录渠道 Tab -->
<div
v-if="!status || status !== 'success'"
class="mb-5 grid w-full grid-cols-2 gap-1 rounded-2xl bg-bg-tertiary-50 p-1"
>
<button
:class="[
'rounded-xl px-3 py-2 text-[11px] font-black uppercase tracking-widest transition-all',
channel === 'qq'
? 'bg-bg-secondary text-text-primary shadow'
: 'text-text-tertiary hover:text-text-secondary'
]"
type="button"
@click="switchChannel('qq')"
>
{{ locale.channelQq }}
</button>
<button
:class="[
'rounded-xl px-3 py-2 text-[11px] font-black uppercase tracking-widest transition-all',
channel === 'wx'
? 'bg-bg-secondary text-text-primary shadow'
: 'text-text-tertiary hover:text-text-secondary'
]"
type="button"
@click="switchChannel('wx')"
>
{{ locale.channelWx }}
</button>
</div>
<div class="flex min-h-[250px] w-full flex-col items-center justify-center">
<div v-if="loading" class="flex flex-col items-center text-text-tertiary">
<AppSpinner :size="48" class="mb-4" />
<p class="text-[10px] font-bold uppercase tracking-widest">{{ locale.loadingQr }}</p>
</div>
<div v-else-if="qrImg" class="group relative">
<div
class="rounded-3xl bg-bg-secondary p-4 shadow-inner transition-transform duration-500 group-hover:scale-[1.02]"
>
<img
:src="qrImg"
alt="QQ Music Login QR Code"
class="h-44 w-44 rounded-2xl object-contain"
>
</div>
<div
v-if="isExpired"
class="absolute inset-0 flex cursor-pointer items-center justify-center rounded-3xl bg-bg-secondary-90 backdrop-blur-sm transition-all hover:bg-bg-secondary-80"
@click="initLogin"
>
<div class="flex flex-col items-center text-text-primary">
<Icon name="refresh" :size="40" class="mb-3 text-text-tertiary" />
<span class="text-xs font-black uppercase tracking-widest">{{ locale.qrExpired }}</span>
<span class="mt-1 text-[10px] font-bold text-text-tertiary">{{ locale.clickRefresh }}</span>
</div>
</div>
</div>
<div v-else-if="errorMessage" class="flex flex-col items-center text-center">
<Icon name="alert-circle" :size="44" class="mb-4 text-error" />
<p class="text-sm font-bold text-text-primary">{{ errorMessage }}</p>
<button
class="mt-5 rounded-2xl bg-info px-5 py-2 text-xs font-black text-text-primary transition hover:bg-info"
type="button"
@click="initLogin"
>
{{ locale.retry }}
</button>
</div>
<div class="mt-8 h-6 text-center">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2"
enter-to-class="opacity-100 translate-y-0"
>
<p
v-if="status === 'expired'"
class="text-xs font-black uppercase tracking-widest text-text-tertiary"
>
{{ locale.expiredRefresh }}
</p>
<p
v-else-if="status === 'waiting'"
class="text-xs font-black uppercase tracking-widest text-text-tertiary"
>
{{
scanned
? locale.wxScanned
: channel === 'wx'
? locale.wxWaiting
: locale.qqWaiting
}}
</p>
<p
v-else-if="status === 'success'"
class="text-xs font-black uppercase tracking-widest text-success"
>
{{ locale.qqSuccess }}
</p>
</Transition>
</div>
</div>
<div class="mt-8 w-full rounded-2xl border border-border-secondary-50 bg-bg-tertiary-30 p-4">
<p
class="text-center text-[10px] font-black uppercase leading-relaxed tracking-[0.15em] text-text-tertiary"
>
{{ channel === 'wx' ? locale.wxTip : locale.qqTip }}
</p>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { computed, onUnmounted, ref, watch } from 'vue'
import Icon from '~/components/UI/Icon.vue'
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
import { useLocale } from '~/utils/locale'
const { songs } = useLocale()
const locale = computed(() => songs.value?.musicLoginModal || {})
const props = defineProps({
show: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['close', 'login-success'])
const qrImg = ref('')
const loading = ref(false)
const status = ref('')
const isExpired = ref(false)
// 微信渠道:已扫码待确认(驱动状态行文案切换)
const scanned = ref(false)
const errorMessage = ref('')
const checking = ref(false)
// 登录渠道:qq、wx
const channel = ref('qq')
// 弹窗开关标记:防止关闭后在飞的长轮询响应继续触发下一轮
let isOpen = false
let timer = null
let qrPayload = null
const stopPolling = () => {
if (timer) {
clearTimeout(timer)
timer = null
}
}
const scheduleNextCheck = () => {
if (timer || !isOpen) return
// 微信为长轮询接口,收到响应后短间隔重入即可
const delay = channel.value === 'wx' ? 800 : 3000
timer = setTimeout(async () => {
timer = null
await checkStatus()
}, delay)
}
const handleClose = () => {
isOpen = false
stopPolling()
emit('close')
}
const getSessionCookie = (session) => {
if (typeof session?.cookie === 'string') return session.cookie
if (Array.isArray(session?.cookieList)) return session.cookieList.join('; ')
return ''
}
const buildUserInfo = (session, user) => {
if (user) return user
const uin = session?.uin || session?.loginUin || session?.cookieObject?.uin || ''
return {
userId: uin,
id: uin,
nickname: uin || locale.value.qqAccount,
userName: uin || locale.value.qqAccount,
raw: session
}
}
const switchChannel = (value) => {
if (channel.value === value) return
channel.value = value
initLogin()
}
const initLogin = async () => {
stopPolling()
isOpen = true
loading.value = true
status.value = ''
isExpired.value = false
scanned.value = false
errorMessage.value = ''
qrImg.value = ''
qrPayload = null
try {
if (channel.value === 'wx') {
const response = await $fetch('/api/native-api/qq/login-qr-wx')
const data = response?.data || {}
if (!response?.success || !data.img || !data.uuid) {
throw new Error(locale.value.qqQrIncomplete)
}
qrPayload = data
qrImg.value = data.img
status.value = 'waiting'
scheduleNextCheck()
} else {
const response = await $fetch('/api/native-api/qq/login-qr')
const data = response?.data || {}
if (!response?.success || !data.img || !data.ptqrtoken || !data.qrsig) {
throw new Error(locale.value.qqQrIncomplete)
}
qrPayload = data
qrImg.value = data.img
status.value = 'waiting'
scheduleNextCheck()
}
} catch (error) {
console.error('初始化扫码登录失败:', error)
errorMessage.value = error?.message || locale.value.qqQrFailed
} finally {
loading.value = false
}
}
const checkStatus = async () => {
if (!qrPayload || !isOpen || checking.value) return
checking.value = true
try {
let data
if (channel.value === 'wx') {
const response = await $fetch('/api/native-api/qq/check-wx-login', {
method: 'POST',
body: { uuid: qrPayload.uuid }
})
data = response?.data || {}
} else {
const response = await $fetch('/api/native-api/qq/check-login', {
method: 'POST',
body: {
ptqrtoken: qrPayload.ptqrtoken,
qrsig: qrPayload.qrsig
}
})
data = response?.data || {}
}
const loginStatus = data.status || (data.isOk ? 'success' : data.refresh ? 'expired' : 'waiting')
// 微信渠道:手机已扫码、等待确认
if (data.scanned) {
scanned.value = true
}
if (loginStatus === 'success' || data.isOk) {
const cookie = data.cookie || getSessionCookie(data.session)
if (!cookie) throw new Error(locale.value.qqMissingCookie)
status.value = 'success'
stopPolling()
emit('login-success', {
cookie,
user: buildUserInfo(data.session, data.user),
authDiagnostic: data.authDiagnostic
})
handleClose()
return
}
if (loginStatus === 'expired' || data.refresh) {
status.value = 'expired'
isExpired.value = true
stopPolling()
return
}
status.value = 'waiting'
} catch (error) {
console.error('检查扫码登录状态失败:', error)
errorMessage.value = error?.message || locale.value.qqCheckFailed
stopPolling()
} finally {
checking.value = false
}
// 仅在未出结果时继续轮询
if (!isExpired.value && status.value === 'waiting') {
scheduleNextCheck()
}
}
watch(
() => props.show,
(visible) => {
if (visible) {
initLogin()
} else {
isOpen = false
stopPolling()
}
}
)
onUnmounted(() => {
isOpen = false
stopPolling()
})
</script>