284 lines
7.0 KiB
Vue
284 lines
7.0 KiB
Vue
<template>
|
||
<div class="bind-phone-page">
|
||
<div class="bind-card">
|
||
<div class="bind-header">
|
||
<h1>绑定手机号</h1>
|
||
<p>首次通过公众号登录,请先完成手机号绑定</p>
|
||
</div>
|
||
|
||
<div v-if="pageState === 'loading'" class="bind-state">
|
||
<a-spin size="large" />
|
||
<span>正在校验登录状态...</span>
|
||
</div>
|
||
|
||
<div v-else-if="pageState === 'error'" class="bind-state error">
|
||
<CloseCircleOutlined class="state-icon" />
|
||
<p>{{ pageMessage }}</p>
|
||
<a-button type="primary" @click="goToLogin">返回登录</a-button>
|
||
</div>
|
||
|
||
<div v-else class="bind-form-wrap">
|
||
<a-alert
|
||
:message="pageMessage || '绑定成功后将自动完成当前扫码登录'"
|
||
class="bind-alert"
|
||
show-icon
|
||
type="warning"
|
||
/>
|
||
|
||
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
|
||
<a-form-item label="手机号" name="phone">
|
||
<a-input v-model:value="form.phone" placeholder="请输入手机号" size="large" />
|
||
</a-form-item>
|
||
|
||
<a-form-item label="短信验证码" name="smsCode">
|
||
<div class="sms-row">
|
||
<a-input v-model:value="form.smsCode" placeholder="请输入短信验证码" size="large" />
|
||
<a-button :disabled="countdown > 0" :loading="sendingSms" size="large" @click="sendSmsCode">
|
||
{{ countdown > 0 ? `${countdown}s 后重试` : '发送验证码' }}
|
||
</a-button>
|
||
</div>
|
||
</a-form-item>
|
||
|
||
<a-button :loading="submitting" block size="large" type="primary" @click="submit">
|
||
绑定手机号并登录
|
||
</a-button>
|
||
</a-form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||
import { message, type FormInstance } from 'ant-design-vue'
|
||
import { CloseCircleOutlined } from '@ant-design/icons-vue'
|
||
import { checkQrCodeStatus, bindQrLoginPhone, type QrCodeStatusResponse } from '@/api/passport/qrLogin'
|
||
import { sendSmsCaptcha } from '@/api/passport/login'
|
||
import { setToken } from '@/utils/token-util'
|
||
|
||
definePageMeta({ layout: 'blank' })
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
|
||
const token = computed(() => String(route.query.token || ''))
|
||
const formRef = ref<FormInstance>()
|
||
const submitting = ref(false)
|
||
const sendingSms = ref(false)
|
||
const countdown = ref(0)
|
||
const pageState = ref<'loading' | 'ready' | 'error'>('loading')
|
||
const pageMessage = ref('')
|
||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||
|
||
const form = reactive({
|
||
phone: '',
|
||
smsCode: ''
|
||
})
|
||
|
||
const phoneReg = /^1[3-9]\d{9}$/
|
||
const rules = reactive({
|
||
phone: [
|
||
{ required: true, message: '请输入手机号', type: 'string' },
|
||
{ pattern: phoneReg, message: '手机号格式不正确', trigger: 'blur' }
|
||
],
|
||
smsCode: [{ required: true, message: '请输入短信验证码', type: 'string' }]
|
||
})
|
||
|
||
function stopCountdown() {
|
||
if (countdownTimer) {
|
||
clearInterval(countdownTimer)
|
||
countdownTimer = null
|
||
}
|
||
countdown.value = 0
|
||
}
|
||
|
||
function persistUserInfo(result: QrCodeStatusResponse) {
|
||
const accessToken = result.accessToken || result.access_token
|
||
if (accessToken) {
|
||
setToken(String(accessToken), true)
|
||
}
|
||
if (import.meta.client) {
|
||
if (result.tenantId) {
|
||
localStorage.setItem('TenantId', String(result.tenantId))
|
||
}
|
||
const userId = result.userInfo?.userId
|
||
if (userId) {
|
||
localStorage.setItem('UserId', String(userId))
|
||
}
|
||
}
|
||
}
|
||
|
||
async function applyLoginResult(result: QrCodeStatusResponse, successText = '登录成功') {
|
||
persistUserInfo(result)
|
||
message.success(successText)
|
||
await router.replace('/')
|
||
}
|
||
|
||
async function loadStatus() {
|
||
if (!token.value) {
|
||
pageState.value = 'error'
|
||
pageMessage.value = '缺少二维码参数,请重新扫码'
|
||
return
|
||
}
|
||
|
||
try {
|
||
const result = await checkQrCodeStatus(token.value)
|
||
if (result.status === 'confirmed') {
|
||
await applyLoginResult(result)
|
||
return
|
||
}
|
||
if (result.status === 'bind_phone') {
|
||
pageState.value = 'ready'
|
||
pageMessage.value = result.message || '请输入手机号和短信验证码,完成首次登录'
|
||
return
|
||
}
|
||
if (result.status === 'expired') {
|
||
pageState.value = 'error'
|
||
pageMessage.value = '二维码已过期,请返回登录页重新扫码'
|
||
return
|
||
}
|
||
pageState.value = 'error'
|
||
pageMessage.value = '当前二维码尚未进入绑定流程,请先完成扫码关注'
|
||
} catch (error: unknown) {
|
||
pageState.value = 'error'
|
||
pageMessage.value = error instanceof Error ? error.message : '校验扫码状态失败'
|
||
}
|
||
}
|
||
|
||
async function sendSmsCode() {
|
||
if (!phoneReg.test(form.phone)) {
|
||
return message.warning('请先输入正确的手机号')
|
||
}
|
||
sendingSms.value = true
|
||
try {
|
||
await sendSmsCaptcha({ phone: form.phone })
|
||
message.success('验证码已发送')
|
||
stopCountdown()
|
||
countdown.value = 60
|
||
countdownTimer = setInterval(() => {
|
||
countdown.value -= 1
|
||
if (countdown.value <= 0) {
|
||
stopCountdown()
|
||
}
|
||
}, 1000)
|
||
} catch (error: unknown) {
|
||
message.error(error instanceof Error ? error.message : '发送验证码失败')
|
||
} finally {
|
||
sendingSms.value = false
|
||
}
|
||
}
|
||
|
||
async function submit() {
|
||
if (!formRef.value || !token.value) return
|
||
submitting.value = true
|
||
try {
|
||
await formRef.value.validate()
|
||
const result = await bindQrLoginPhone({
|
||
token: token.value,
|
||
phone: form.phone,
|
||
code: form.smsCode
|
||
})
|
||
await applyLoginResult(result, '手机号绑定成功,已完成登录')
|
||
} catch (error: unknown) {
|
||
message.error(error instanceof Error ? error.message : '绑定手机号失败')
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
function goToLogin() {
|
||
router.replace('/login')
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await loadStatus()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
stopCountdown()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.bind-phone-page {
|
||
min-height: 100vh;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 24px 16px;
|
||
background: linear-gradient(135deg, #eff6ff 0%, #f5f3ff 100%);
|
||
}
|
||
|
||
.bind-card {
|
||
width: 460px;
|
||
max-width: 100%;
|
||
padding: 32px;
|
||
border-radius: 20px;
|
||
background: #fff;
|
||
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.08);
|
||
}
|
||
|
||
.bind-header {
|
||
text-align: center;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.bind-header h1 {
|
||
margin: 0 0 8px;
|
||
font-size: 28px;
|
||
font-weight: 600;
|
||
color: #111827;
|
||
}
|
||
|
||
.bind-header p {
|
||
margin: 0;
|
||
color: #6b7280;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.bind-state {
|
||
min-height: 240px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 16px;
|
||
text-align: center;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.bind-state.error {
|
||
color: #ef4444;
|
||
}
|
||
|
||
.state-icon {
|
||
font-size: 52px;
|
||
}
|
||
|
||
.bind-form-wrap {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
}
|
||
|
||
.bind-alert {
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.sms-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 132px;
|
||
gap: 12px;
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.bind-card {
|
||
padding: 24px 20px;
|
||
}
|
||
|
||
.sms-row {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|