初始版本

This commit is contained in:
2026-04-23 16:30:57 +08:00
commit 0d0683a6e6
538 changed files with 113042 additions and 0 deletions

360
app/pages/wx-scan.vue Normal file
View File

@@ -0,0 +1,360 @@
<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>