Files
jczxw-pc/app/pages/wx-scan.vue
2026-04-23 16:30:57 +08:00

361 lines
9.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="wx-scan-page">
<div class="scan-container">
<!-- 加载状态 -->
<div v-if="status === 'loading'" class="status-box">
<a-spin size="large" />
<p class="status-text">{{ loadingText }}</p>
</div>
<!-- 扫码成功 -->
<div v-else-if="status === 'success'" class="status-box success">
<CheckCircleOutlined class="status-icon success-icon" />
<h2 class="status-title">登录成功</h2>
<p class="status-text">正在跳转...</p>
<div class="user-info" v-if="userInfo">
<a-avatar :size="64" :src="userInfo.avatar">
<template v-if="!userInfo.avatar"><UserOutlined /></template>
</a-avatar>
<p class="username">{{ userInfo.nickname || userInfo.username }}</p>
</div>
</div>
<!-- 需要绑定 -->
<div v-else-if="status === 'bind_required'" class="status-box warning">
<ExclamationCircleOutlined class="status-icon warning-icon" />
<h2 class="status-title">需要绑定手机号</h2>
<p class="status-text">{{ message_text }}</p>
<a-button type="primary" size="large" @click="goToBindPhone">去绑定手机号</a-button>
</div>
<!-- 扫码失败 -->
<div v-else-if="status === 'error'" class="status-box error">
<CloseCircleOutlined class="status-icon error-icon" />
<h2 class="status-title">扫码失败</h2>
<p class="status-text">{{ message_text }}</p>
<a-button type="primary" size="large" @click="retry">重新扫码</a-button>
</div>
<!-- 未找到用户 -->
<div v-else-if="status === 'not_bound'" class="status-box warning">
<ExclamationCircleOutlined class="status-icon warning-icon" />
<h2 class="status-title">账号未绑定</h2>
<p class="status-text">{{ message_text }}</p>
<div class="action-buttons">
<a-button type="primary" size="large" @click="goToRegister">前往注册</a-button>
<a-button size="large" @click="retry">重新扫码</a-button>
</div>
</div>
<!-- 底部安全提示 -->
<div class="security-tip">
<SafetyCertificateOutlined />
<span>请确认是您本人操作如非本人操作请忽略</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import {
CheckCircleOutlined,
CloseCircleOutlined,
ExclamationCircleOutlined,
SafetyCertificateOutlined,
UserOutlined
} from '@ant-design/icons-vue'
import {
checkQrCodeStatus,
getWechatOAuthUrl,
wechatScanConfirm,
type WechatScanResponse
} from '@/api/passport/qrLogin'
import { setToken } from '@/utils/token-util'
definePageMeta({ layout: 'blank' })
const route = useRoute()
const router = useRouter()
const status = ref<'loading' | 'success' | 'error' | 'bind_required' | 'not_bound' | 'not_wechat'>('loading')
const message_text = ref('')
const loadingText = ref('正在等待扫码...')
const userInfo = ref<any>(null)
const token = computed(() => String(route.query.token || ''))
const code = computed(() => String(route.query.code || ''))
let pollTimer: ReturnType<typeof setInterval> | null = null
const isWechatBrowser = computed(() => {
if (!import.meta.client) return false
return /MicroMessenger/i.test(window.navigator.userAgent)
})
async function redirectToWechatOAuth() {
if (!import.meta.client || !token.value) return
try {
loadingText.value = '正在拉起微信授权...'
const oauthUrl = await getWechatOAuthUrl(token.value)
if (!oauthUrl) {
throw new Error('未获取到微信授权地址')
}
window.location.replace(oauthUrl)
} catch (error: unknown) {
console.error('获取微信授权地址失败:', error)
status.value = 'error'
message_text.value = error instanceof Error ? error.message : '获取微信授权地址失败,请重试'
}
}
/**
* 轮询检查登录状态(扫码关注登录模式)
*/
async function redirectToBindPhone(tip?: string) {
status.value = 'bind_required'
message_text.value = tip || '请先绑定手机号完成登录'
message.info(message_text.value)
await router.replace(`/bind-phone?token=${token.value}`)
}
async function pollLoginStatus() {
if (!token.value) return
try {
const response = await checkQrCodeStatus(token.value)
if (response.status === 'confirmed') {
// 登录成功
stopPolling()
handleLoginSuccess({
status: 'success',
accessToken: response.accessToken || response.access_token,
userInfo: response.userInfo,
tenantId: response.tenantId ? Number(response.tenantId) : undefined
})
} else if (response.status === 'bind_phone') {
stopPolling()
await redirectToBindPhone(response.message)
} else if (response.status === 'expired') {
// 二维码过期
stopPolling()
status.value = 'error'
message_text.value = '二维码已过期,请刷新后重新扫码'
} else if (response.status === 'scanned') {
// 已扫码,等待公众号事件回写
loadingText.value = '已识别扫码,请关注公众号后自动登录'
} else {
// pending 状态,继续等待
loadingText.value = '等待扫码...'
}
} catch (error) {
console.warn('检查登录状态失败:', error)
// 轮询错误不影响,继续轮询
}
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
onUnmounted(() => {
stopPolling()
})
onMounted(async () => {
// 如果没有 token显示错误
if (!token.value) {
status.value = 'error'
message_text.value = '二维码参数错误'
return
}
// 如果有 code微信授权回调直接用 code 登录
if (code.value) {
await handleLoginWithCode()
return
}
// 微信内打开扫码页时,先拉起 OAuth 获取身份,再由回调继续登录
if (isWechatBrowser.value) {
await redirectToWechatOAuth()
return
}
// 非微信环境下保留轮询兜底,兼容服务端推送扫码状态的场景
status.value = 'loading'
loadingText.value = '等待扫码,请在微信中扫描二维码...'
// 立即检查一次
await pollLoginStatus()
// 每2秒轮询一次
pollTimer = setInterval(pollLoginStatus, 2000)
})
async function handleLoginWithCode() {
status.value = 'loading'
loadingText.value = '正在验证身份...'
try {
const response: WechatScanResponse = await wechatScanConfirm({
token: token.value,
code: code.value
})
if ((response.status === 'success' || response.status === 'confirmed') && (response.accessToken || response.access_token)) {
handleLoginSuccess({
...response,
accessToken: response.accessToken || response.access_token
})
} else if (response.status === 'bind_required' || response.status === 'bind_phone') {
await redirectToBindPhone(response.message)
} else {
status.value = 'not_bound'
message_text.value = response.message || '该微信未绑定平台账号'
}
} catch (error: unknown) {
console.error('微信扫码登录失败:', error)
status.value = 'error'
message_text.value = error instanceof Error ? error.message : '登录失败,请重试'
}
}
function handleLoginSuccess(response: WechatScanResponse) {
status.value = 'success'
userInfo.value = response.userInfo
// 保存 token
if (response.accessToken) {
setToken(response.accessToken, true)
if (response.tenantId) {
localStorage.setItem('TenantId', String(response.tenantId))
}
if (userInfo.value?.userId) {
localStorage.setItem('UserId', String(userInfo.value.userId))
}
}
message.success('登录成功')
// 2秒后跳转到首页
setTimeout(() => {
router.push('/')
}, 2000)
}
function goToBindPhone() {
router.push(`/bind-phone?token=${token.value}`)
}
function goToRegister() {
router.push('/register')
}
function retry() {
router.replace('/login')
}
</script>
<style scoped>
.wx-scan-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.scan-container {
width: 420px;
max-width: 100%;
background: #fff;
border-radius: 16px;
padding: 40px 32px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
text-align: center;
}
.status-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
min-height: 280px;
justify-content: center;
}
.status-icon {
font-size: 72px;
}
.success-icon {
color: #52c41a;
}
.warning-icon {
color: #faad14;
}
.error-icon {
color: #ff4d4f;
}
.status-title {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #262626;
}
.status-text {
margin: 0;
font-size: 14px;
color: #8c8c8c;
line-height: 1.6;
}
.user-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.username {
margin: 0;
font-size: 16px;
font-weight: 500;
color: #262626;
}
.action-buttons {
display: flex;
gap: 12px;
margin-top: 8px;
}
.security-tip {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 32px;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
color: #8c8c8c;
font-size: 12px;
}
.security-tip :deep(.anticon) {
color: #52c41a;
}
</style>