Files
uni-fans-score/subPackages/user/login/phone.vue
T
2026-02-06 18:09:23 +08:00

395 lines
8.5 KiB
Vue

<template>
<view class="login-container">
<view class="header">
<view class="title">Hello,</view>
<view class="subtitle">{{ $t('app.welcome') }}</view>
</view>
<!-- 国家区号选择器 -->
<view class="form-group">
<view class="phone-input-wrapper">
<view class="country-code" @click="showCountryPicker">
<text>{{ countryCode }}</text>
<text class="arrow"></text>
</view>
<view class="divider"></view>
<input
class="phone-input"
v-model="phone"
type="number"
maxlength="11"
:placeholder="$t('auth.phonePlaceholder')"
/>
</view>
</view>
<!-- 验证码输入 -->
<view class="form-group">
<view class="code-input-wrapper">
<input
class="code-input"
v-model="verifyCode"
type="number"
maxlength="6"
:placeholder="$t('auth.codePlaceholder')"
/>
<view class="code-btn" @click="handleSendCode" :class="{ disabled: countdown > 0 }">
<text class="code-btn-text">{{ countdown > 0 ? `${countdown}s` : $t('auth.getCode') }}</text>
</view>
</view>
</view>
<!-- 区域提示 -->
<view class="region-notice">
<text>{{ $t('auth.regionNotSupported') }}</text>
</view>
<!-- 登录按钮 -->
<view class="login-btn" @click="handleLogin">
<text class="login-btn-text">{{ $t('auth.loginBtn') }}</text>
</view>
<!-- 协议勾选 -->
<view class="agreement-box">
<checkbox-group @change="onAgreementChange">
<label class="agreement-label">
<checkbox value="agreed" :checked="isAgreed" color="#07c160" class="agreement-checkbox" />
<text class="agreement-text">
{{ $t('auth.agreeToTerms') }}
<text class="link" @tap.stop="go('/pages/legal/agreement')">{{ $t('user.userAgreement') }}</text>
{{ $t('common.and') }}
<text class="link" @tap.stop="go('/pages/legal/privacy')">{{ $t('user.privacyPolicy') }}</text>
</text>
</label>
</checkbox-group>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { sendVerifyCode, loginWithCode } from '@/config/api/user.js'
import { fetchAndCacheCustomerPhone } from '@/util/index.js'
import { useI18n } from '@/utils/i18n.js'
const { t } = useI18n()
// 设置页面标题
onMounted(() => {
uni.setNavigationBarTitle({
title: t('auth.phoneLogin')
})
})
const redirect = ref('/pages/index/index')
const isAgreed = ref(false) // 是否同意协议
const phone = ref('') // 手机号
const verifyCode = ref('') // 验证码
const countryCode = ref('+86') // 国家区号
const countdown = ref(0) // 验证码倒计时
let timer = null // 计时器
// 勾选协议变化
const onAgreementChange = (e) => {
isAgreed.value = e.detail.value.includes('agreed')
}
// 显示国家区号选择器(暂时仅支持+86)
const showCountryPicker = () => {
uni.showToast({
title: t('auth.onlyMainlandSupported'),
icon: 'none'
})
}
// 验证手机号格式
const validatePhone = () => {
if (!phone.value) {
uni.showToast({ title: t('auth.phoneRequired'), icon: 'none' })
return false
}
const phoneReg = /^1[3-9]\d{9}$/
if (!phoneReg.test(phone.value)) {
uni.showToast({ title: t('auth.phoneInvalid'), icon: 'none' })
return false
}
return true
}
// 发送验证码
const handleSendCode = async () => {
if (countdown.value > 0) return
if (!validatePhone()) return
try {
uni.showLoading({ title: t('common.sending') })
await sendVerifyCode(phone.value)
uni.hideLoading()
uni.showToast({ title: t('auth.codeSent'), icon: 'success' })
// 启动60秒倒计时
countdown.value = 60
timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
clearInterval(timer)
timer = null
}
}, 1000)
} catch (error) {
uni.hideLoading()
uni.showToast({
title: error.message || t('auth.sendCodeFailed'),
icon: 'none'
})
}
}
// 登录
const handleLogin = async () => {
if (!validatePhone()) return
if (!verifyCode.value) {
uni.showToast({ title: t('auth.codeRequired'), icon: 'none' })
return
}
if (!isAgreed.value) {
uni.showToast({ title: t('auth.pleaseAgreeToTerms'), icon: 'none' })
return
}
try {
uni.showLoading({ title: t('common.loggingIn') })
const res = await loginWithCode(phone.value, verifyCode.value)
// 保存token和client_id
// 兼容多种返回格式:res.data.token, res.token, res.data.access_token
const token = res.token || (res.data && (res.data.token || res.data.access_token))
const clientId = res.client_id || (res.data && (res.data.client_id || res.data.clientId))
if (token) {
uni.setStorageSync('token', token)
if (clientId) {
uni.setStorageSync('client_id', clientId)
}
// 登录成功后获取并缓存客服电话
fetchAndCacheCustomerPhone().catch(err => {
console.error(t('auth.getServicePhoneFailed'), err)
})
} else {
throw new Error(t('auth.noAuthToken'))
}
uni.hideLoading()
uni.showToast({ title: t('auth.loginSuccess'), icon: 'success' })
// 跳转到首页
setTimeout(() => {
uni.reLaunch({ url: redirect.value })
}, 1500)
} catch (error) {
uni.hideLoading()
uni.showToast({
title: error.message || t('auth.loginFailed'),
icon: 'none'
})
}
}
// 页面加载
onLoad((opts) => {
if (opts && opts.redirect) {
try {
redirect.value = decodeURIComponent(opts.redirect)
} catch (_) {}
}
})
const go = (url) => {
uni.navigateTo({ url })
}
// 清理定时器
onUnmounted(() => {
if (timer) {
clearInterval(timer)
timer = null
}
})
</script>
<style lang="scss" scoped>
.login-container {
min-height: 100vh;
background: linear-gradient(180deg, #C8F4D9 0%, #FFFFFF 100%);
padding: 0 48rpx;
box-sizing: border-box;
position: relative;
.header {
padding-top: 120rpx;
margin-bottom: 80rpx;
.title {
font-size: 64rpx;
font-weight: 700;
color: #000000;
line-height: 1.2;
margin-bottom: 8rpx;
}
.subtitle {
font-size: 64rpx;
font-weight: 700;
color: #000000;
line-height: 1.2;
}
}
.form-group {
margin-bottom: 32rpx;
.phone-input-wrapper {
background: #FFFFFF;
border-radius: 48rpx;
height: 96rpx;
display: flex;
align-items: center;
padding: 0 32rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
.country-code {
display: flex;
align-items: center;
font-size: 32rpx;
color: #333333;
padding-right: 16rpx;
.arrow {
margin-left: 8rpx;
font-size: 20rpx;
color: #999999;
}
}
.divider {
width: 2rpx;
height: 40rpx;
background: #E5E5E5;
margin: 0 16rpx;
}
.phone-input {
flex: 1;
font-size: 32rpx;
color: #333333;
}
}
.code-input-wrapper {
background: #FFFFFF;
border-radius: 48rpx;
height: 96rpx;
display: flex;
align-items: center;
padding: 0 32rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
.code-input {
flex: 1;
font-size: 32rpx;
color: #333333;
}
.code-btn {
padding-left: 24rpx;
border-left: 2rpx solid #E5E5E5;
&.disabled {
opacity: 0.5;
}
.code-btn-text {
font-size: 28rpx;
color: #07c160;
font-weight: 500;
white-space: nowrap;
}
}
}
}
.region-notice {
margin-bottom: 48rpx;
padding: 0 8rpx;
text {
font-size: 24rpx;
color: #666666;
line-height: 1.6;
}
}
.login-btn {
background: #07c160;
border-radius: 60rpx;
height: 112rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(7, 193, 96, 0.3);
margin-bottom: 48rpx;
&:active {
opacity: 0.9;
}
.login-btn-text {
font-size: 36rpx;
color: #FFFFFF;
font-weight: 600;
}
}
.agreement-box {
position: absolute;
left: 48rpx;
right: 48rpx;
bottom: 60rpx;
display: flex;
justify-content: center;
align-items: center;
.agreement-label {
display: flex;
align-items: center;
width: 100%;
.agreement-checkbox {
flex-shrink: 0;
transform: scale(0.75);
margin-right: 4rpx;
}
.agreement-text {
flex: 1;
font-size: 24rpx;
color: #666;
line-height: 1.8;
word-break: break-all;
.link {
color: #07c160;
font-weight: 500;
text-decoration: none;
}
}
}
}
}
</style>