2026-01-02 16:40:48 +08:00

967 lines
24 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>
<view class="page-login">
<!-- 背景装饰 -->
<view class="bg-decoration"></view>
<view class="content-wrap">
<!-- Logo区域 -->
<view class="logo-section">
<view class="logo-wrapper">
<image class="logo-img" src="/static/logo.png" mode="aspectFit"></image>
</view>
<text class="app-name">柯大鸭</text>
<text class="app-slogan">潮玩盲盒 · 惊喜无限</text>
</view>
<!-- 登录卡片 -->
<view class="login-card glass-card">
<!-- 切换Tab -->
<view class="tab-bar">
<!-- #ifdef MP-WEIXIN -->
<view
class="tab-item"
:class="{ active: loginMode === 'wechat' }"
@tap="switchMode('wechat')"
>
<text class="tab-text">微信快捷登录</text>
</view>
<!-- #endif -->
<!-- #ifdef MP-TOUTIAO -->
<view
class="tab-item"
:class="{ active: loginMode === 'toutiao' }"
@tap="switchMode('toutiao')"
>
<text class="tab-text">抖音快捷登录</text>
</view>
<!-- #endif -->
<view
class="tab-item"
:class="{ active: loginMode === 'sms' }"
@tap="switchMode('sms')"
>
<text class="tab-text">验证码登录</text>
</view>
<view class="tab-indicator" :class="{ right: loginMode === 'sms' }"></view>
</view>
<!-- 内容区域 -->
<view class="content-area">
<!-- 微信登录 -->
<!-- #ifdef MP-WEIXIN -->
<view v-if="loginMode === 'wechat'" class="login-panel wechat-panel">
<view class="panel-icon-wrap">
<view class="panel-icon wechat-icon">
<text class="icon-emoji">💬</text>
</view>
</view>
<text class="panel-title">一键获取手机号</text>
<text class="panel-desc">授权获取本机手机号安全快速登录</text>
<button
class="btn-primary btn-login"
open-type="getPhoneNumber"
:disabled="loading"
@getphonenumber="onGetPhoneNumber"
>
{{ loading ? '获取中...' : '一键获取手机号' }}
</button>
</view>
<!-- #endif -->
<!-- 抖音登录 -->
<!-- #ifdef MP-TOUTIAO -->
<view v-if="loginMode === 'toutiao'" class="login-panel toutiao-panel">
<view class="panel-icon-wrap">
<view class="panel-icon toutiao-icon">
<text class="icon-emoji">🎵</text>
</view>
</view>
<text class="panel-title">抖音快捷登录</text>
<text class="panel-desc">使用抖音账号快速登录</text>
<button
class="btn-primary btn-login"
:disabled="loading"
@tap="handleDouyinLogin"
>
{{ loading ? '登录中...' : '抖音登录' }}
</button>
</view>
<!-- #endif -->
<!-- 短信登录 -->
<view v-else class="login-panel sms-panel">
<text class="panel-title">手机号验证码登录</text>
<text class="panel-desc">输入手机号获取验证码完成登录</text>
<view class="form-group">
<!-- 手机号 -->
<view class="input-field">
<view class="field-prefix">
<text class="prefix-text">+86</text>
</view>
<input
type="number"
v-model="mobile"
placeholder="请输入手机号"
class="field-input"
maxlength="11"
/>
<text v-if="mobile" class="clear-btn" @tap="mobile = ''"></text>
</view>
<!-- 验证码 -->
<view class="input-field">
<view class="field-prefix">
<text class="prefix-icon">🔐</text>
</view>
<input
type="number"
v-model="smsCode"
placeholder="请输入验证码"
class="field-input"
maxlength="6"
/>
<view
class="send-code-btn"
:class="{ disabled: !canSendCode || sendingCode || countdown > 0 }"
@tap="handleSendCode"
>
<text>{{ sendingCode ? '发送中' : (countdown > 0 ? `${countdown}s` : '获取验证码') }}</text>
</view>
</view>
</view>
<button
class="btn-primary btn-login"
:class="{ disabled: !canLogin }"
:disabled="loading || !canLogin"
@tap="handleSmsLogin"
>
{{ loading ? '登录中...' : '登录 / 注册' }}
</button>
<text class="hint-text">未注册的手机号将自动创建账号</text>
</view>
</view>
<!-- 协议区 -->
<view class="agreement-section">
<view class="agreement-row" @tap="toggleAgreement">
<view class="checkbox" :class="{ checked: agreementChecked }">
<text v-if="agreementChecked" class="check-mark"></text>
</view>
<text class="agreement-text">
我已阅读并同意
<text class="link" @tap.stop="toUserAgreement">用户协议</text>
<text class="link" @tap.stop="toPurchaseAgreement">隐私政策</text>
</text>
</view>
</view>
</view>
<!-- 底部信息 -->
<view class="footer">
<text class="copyright">© 2025 柯大鸭潮玩科技</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { request } from '../../utils/request'
import { wechatLogin, douyinLogin, bindPhone, bindDouyinPhone, getUserStats, getPointsBalance, sendSmsCode, smsLogin, getUserInfo } from '../../api/appUser'
import { authRequest } from '../../utils/request'
import { vibrateShort } from '@/utils/vibrate.js'
const loading = ref(false)
const agreementChecked = ref(false)
// 登录模式:根据平台设置默认值
// #ifdef MP-WEIXIN
const loginMode = ref('wechat')
// #endif
// #ifdef MP-TOUTIAO
const loginMode = ref('toutiao')
// #endif
// #ifndef MP-WEIXIN || MP-TOUTIAO
const loginMode = ref('sms')
// #endif
// 静默获取 OpenID
async function ensureOpenID() {
const current = uni.getStorageSync('openid')
if (current) return
console.log('[DEBUG] 本地缺少 openid, 尝试静默获取...')
// #ifdef MP-WEIXIN
uni.login({
provider: 'weixin',
success: async (loginRes) => {
try {
const res = await request({
url: '/api/app/common/openid',
method: 'POST',
data: { code: loginRes.code }
})
if (res && res.openid) {
console.log('[DEBUG] 静默获取 openid 成功:', res.openid)
uni.setStorageSync('openid', res.openid)
}
} catch (err) {
console.error('[DEBUG] 静默获取 openid 失败:', err)
}
}
})
// #endif
// #ifdef MP-TOUTIAO
// 抖音小程序使用 tt.login
tt.login({
success: async (loginRes) => {
try {
console.log('[DEBUG] 抖音登录成功code:', loginRes.code)
// 保存 code 用于后续登录
uni.setStorageSync('douyin_login_code', loginRes.code)
// 尝试获取 openid
const res = await request({
url: '/api/app/common/openid',
method: 'POST',
data: { code: loginRes.code, platform: 'douyin' }
})
if (res && res.openid) {
console.log('[DEBUG] 静默获取 openid 成功:', res.openid)
uni.setStorageSync('openid', res.openid)
}
} catch (err) {
console.error('[DEBUG] 静默获取 openid 失败:', err)
}
},
fail: (err) => {
console.error('[DEBUG] 抖音登录失败:', err)
}
})
// #endif
}
onLoad(() => {
ensureOpenID()
})
const fetchExtraDataLoading = ref(false)
// 短信登录
const mobile = ref('')
const smsCode = ref('')
const countdown = ref(0)
const sendingCode = ref(false)
let countdownTimer = null
// 计算属性
const canSendCode = computed(() => {
return mobile.value.length === 11 && /^1[3-9]\d{9}$/.test(mobile.value) && agreementChecked.value
})
const canLogin = computed(() => {
return mobile.value.length === 11 && smsCode.value.length >= 4 && agreementChecked.value
})
onMounted(() => {
try {
const savedMobile = uni.getStorageSync('last_login_mobile')
if (savedMobile) mobile.value = savedMobile
} catch (e) {}
})
onUnmounted(() => {
if (countdownTimer) clearInterval(countdownTimer)
})
function switchMode(mode) {
loginMode.value = mode
}
function toggleAgreement() {
agreementChecked.value = !agreementChecked.value
}
function toUserAgreement() {
uni.navigateTo({ url: '/pages-user/agreement/user' })
}
function toPurchaseAgreement() {
uni.navigateTo({ url: '/pages-user/agreement/purchase' })
}
// 发送验证码
async function handleSendCode() {
if (!agreementChecked.value) {
uni.showToast({ title: '请先同意用户协议', icon: 'none' })
vibrateShort()
return
}
if (countdown.value > 0 || sendingCode.value) return
if (!mobile.value || mobile.value.length !== 11) {
uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
return
}
if (!/^1[3-9]\d{9}$/.test(mobile.value)) {
uni.showToast({ title: '手机号格式不正确', icon: 'none' })
return
}
sendingCode.value = true
try {
await sendSmsCode(mobile.value)
uni.showToast({ title: '验证码已发送', icon: 'success' })
countdown.value = 60
countdownTimer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
clearInterval(countdownTimer)
countdownTimer = null
}
}, 1000)
} catch (err) {
uni.showToast({ title: err.message || '发送失败', icon: 'none' })
} finally {
sendingCode.value = false
}
}
// 短信登录
async function handleSmsLogin() {
if (!agreementChecked.value) {
uni.showToast({ title: '请先同意用户协议', icon: 'none' })
vibrateShort()
return
}
if (!canLogin.value) return
loading.value = true
try {
const inviterCode = uni.getStorageSync('inviter_code')
const data = await smsLogin(mobile.value, smsCode.value, inviterCode)
console.log('[DEBUG] 短信登录响应原始数据:', data)
saveUserData(data)
const isNew = data && data.is_new_user
uni.showToast({
title: isNew ? '🎉 注册成功!' : '✨ 登录成功!',
icon: 'none',
duration: 1200
})
// 后台获取数据
if (data && data.user_id) {
fetchExtraData(data.user_id)
}
setTimeout(() => uni.reLaunch({ url: '/pages/mine/index' }), 600)
} catch (err) {
uni.showToast({ title: err.message || '登录失败', icon: 'none' })
} finally {
loading.value = false
}
}
// 微信登录
function onGetPhoneNumber(e) {
if (!agreementChecked.value) {
uni.showToast({ title: '请先同意用户协议', icon: 'none' })
vibrateShort()
return
}
const phoneCode = e.detail.code
if (!phoneCode) {
uni.showToast({ title: '需要授权手机号', icon: 'none' })
return
}
loading.value = true
uni.login({
provider: 'weixin',
success: async (res) => {
try {
const inviterCode = uni.getStorageSync('inviter_code')
const data = await wechatLogin(res.code, inviterCode)
// 检查后端返回的数据中是否已包含手机号
const isBound = data.phone || data.phone_number || data.mobile
console.log('[DEBUG] 微信登录返回数据手机号状态:', isBound ? '已绑定' : '未绑定', data)
// 如果未绑定手机号,则调用绑定接口
if (!isBound) {
try {
console.log('[DEBUG] 开始绑定手机号')
await bindPhone(data.user_id, phoneCode)
// 绑定成功后,强制从服务器获取最新的用户信息(不读取缓存)
try {
const updatedUserInfo = await authRequest({ url: '/api/app/users/info', method: 'GET' })
console.log('[DEBUG] 绑定后从服务器获取用户信息:', updatedUserInfo)
// 如果返回了用户信息,合并到 data 对象
if (updatedUserInfo) {
Object.assign(data, updatedUserInfo)
}
} catch (err) {
console.warn('[DEBUG] 绑定后获取用户信息失败:', err)
}
} catch (e) {
console.error('[DEBUG] 绑定手机号失败:', e)
}
} else {
console.log('[DEBUG] 微信登录已包含手机号,跳过绑定')
}
// 保存用户数据(此时应该包含手机号)
saveUserData(data)
// 后台获取数据
fetchExtraData(data.user_id)
uni.showToast({ title: '✨ 登录成功!', icon: 'none', duration: 1200 })
setTimeout(() => uni.reLaunch({ url: '/pages/mine/index' }), 600)
} catch (err) {
uni.showToast({ title: err.message || '登录失败', icon: 'none' })
} finally {
loading.value = false
}
},
fail: () => {
loading.value = false
}
})
}
// 抖音登录 - 不需要手机号授权
async function handleDouyinLogin() {
console.log('[DEBUG] 抖音登录按钮点击')
if (!agreementChecked.value) {
uni.showToast({ title: '请先同意用户协议', icon: 'none' })
vibrateShort()
return
}
loading.value = true
try {
// 使用保存的登录 code 或重新登录获取
const loginCode = uni.getStorageSync('douyin_login_code')
let loginData
if (!loginCode) {
// 如果没有保存的 code重新登录获取
console.log('[DEBUG] 未保存登录 code开始重新登录...')
const loginRes = await new Promise((resolve, reject) => {
tt.login({
success: resolve,
fail: reject
})
})
console.log('[DEBUG] 重新获取抖音登录 code:', loginRes.code)
loginData = await douyinLogin(loginRes.code, null, uni.getStorageSync('inviter_code'))
} else {
console.log('[DEBUG] 使用保存的登录 code:', loginCode)
loginData = await douyinLogin(loginCode, null, uni.getStorageSync('inviter_code'))
}
console.log('[DEBUG] 抖音登录成功:', loginData)
// 保存登录数据saveUserData 会自动检查手机号绑定状态)
saveUserData(loginData)
// 后台获取数据(仅在已绑定手机号时)
const hasPhone = loginData.mobile || loginData.phone || loginData.phone_number
if (hasPhone) {
fetchExtraData(loginData.user_id)
uni.showToast({ title: '✨ 登录成功!', icon: 'none', duration: 1200 })
setTimeout(() => uni.reLaunch({ url: '/pages/mine/index' }), 600)
}
// 如果未绑定手机号saveUserData 会自动切换到短信登录tab
} catch (err) {
console.error('[DEBUG] 抖音登录失败:', err)
uni.showToast({ title: err.message || '登录失败,请重试', icon: 'none', duration: 2000 })
} finally {
loading.value = false
}
}
function saveUserData(data) {
if (!data) return
console.log('[DEBUG] 准备执行 saveUserData, payload:', data)
uni.setStorageSync('user_info', data)
if (data.token) uni.setStorageSync('token', data.token)
if (data.user_id) uni.setStorageSync('user_id', data.user_id)
if (data.avatar) uni.setStorageSync('avatar', data.avatar)
if (data.nickname) uni.setStorageSync('nickname', data.nickname)
if (data.invite_code) uni.setStorageSync('invite_code', data.invite_code)
if (data.mobile) uni.setStorageSync('last_login_mobile', data.mobile)
// 检查是否已绑定手机号(检查所有可能的字段名)
const hasPhone = data.mobile || data.phone || data.phone_number
console.log('[DEBUG] 检查手机号绑定状态:', {
mobile: data.mobile,
phone: data.phone,
phone_number: data.phone_number,
hasPhone: hasPhone ? '已绑定' : '未绑定'
})
// 根据实际是否有手机号来设置 phone_bound 状态
if (hasPhone) {
uni.setStorageSync('phone_bound', true)
console.log('[DEBUG] 已设置 phone_bound = true')
} else {
uni.setStorageSync('phone_bound', false)
console.log('[DEBUG] 已设置 phone_bound = false')
}
// 如果返回了 openid则存储 (短信登录现在也会返回已关联的 openid)
const openid = data.openid || data.open_id
if (openid) {
console.log('[DEBUG] 存储从登录接口获取的 openid:', openid)
uni.setStorageSync('openid', openid)
} else {
console.warn('[DEBUG] 登录接口未返回 openid, 请检查后端或联系管理员')
}
if (!hasPhone) {
// 未绑定手机号切换到短信登录tab进行绑定
console.log('[DEBUG] 未检测到手机号切换到短信登录tab')
uni.showModal({
title: '绑定手机号',
content: '登录成功!为了账号安全,请绑定手机号',
showCancel: false,
confirmText: '去绑定',
success: () => {
// 切换到短信登录tab
loginMode.value = 'sms'
}
})
} else {
console.log('[DEBUG] 已检测到手机号,正常登录流程')
}
}
function fetchExtraData(userId) {
Promise.all([
getUserStats(userId).then(s => s && uni.setStorageSync('user_stats', s)).catch(() => {}),
getPointsBalance(userId).then(b => {
const val = b && b.balance !== undefined ? b.balance : b
uni.setStorageSync('points_balance', val)
}).catch(() => {})
])
}
</script>
<style lang="scss" scoped>
.page-login {
min-height: 100vh;
background: $bg-page;
position: relative;
}
.content-wrap {
position: relative;
z-index: 10;
padding: 80rpx 32rpx 40rpx;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Logo区域 */
.logo-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 48rpx;
}
.logo-wrapper {
width: 160rpx;
height: 160rpx;
background: $bg-card;
border-radius: 40rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-card;
margin-bottom: 24rpx;
border: 2rpx solid rgba($brand-primary, 0.08);
.logo-img {
width: 120rpx;
height: 120rpx;
}
}
.app-name {
font-size: 44rpx;
font-weight: 800;
color: $text-main;
margin-bottom: 8rpx;
}
.app-slogan {
font-size: 24rpx;
color: $text-sub;
letter-spacing: 2rpx;
}
/* 登录卡片 */
.login-card {
flex: 1;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
/* Tab栏 */
.tab-bar {
display: flex;
position: relative;
background: rgba($brand-primary, 0.05);
margin: 24rpx 24rpx 0;
border-radius: $radius-round;
padding: 6rpx;
}
.tab-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
height: 76rpx;
position: relative;
z-index: 2;
transition: all $transition-normal $ease-out;
.tab-text {
font-size: 28rpx;
color: $text-sub;
font-weight: 500;
transition: all $transition-normal $ease-out;
}
&.active .tab-text {
color: $brand-primary;
font-weight: 700;
}
}
.tab-indicator {
position: absolute;
top: 6rpx;
left: 6rpx;
width: calc(50% - 6rpx);
height: 76rpx;
background: $bg-card;
border-radius: $radius-round;
box-shadow: $shadow-sm;
transition: transform $transition-normal $ease-out;
z-index: 1;
&.right {
transform: translateX(100%);
}
}
/* 内容区域 */
.content-area {
flex: 1;
padding: 40rpx 32rpx;
}
/* 登录面板 */
.login-panel {
display: flex;
flex-direction: column;
}
/* 微信登录面板 */
.wechat-panel {
align-items: center;
padding-top: 32rpx;
.panel-icon-wrap {
margin-bottom: 32rpx;
}
.panel-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
&.wechat-icon {
background: linear-gradient(135deg, #07c160 0%, #06ad56 100%);
box-shadow: 0 12rpx 32rpx rgba(7, 193, 96, 0.3);
}
.icon-emoji {
font-size: 56rpx;
}
}
.panel-title {
font-size: 36rpx;
font-weight: 700;
color: $text-main;
margin-bottom: 12rpx;
}
.panel-desc {
font-size: 26rpx;
color: $text-sub;
margin-bottom: 48rpx;
}
}
/* 抖音登录面板 */
.toutiao-panel {
align-items: center;
justify-content: center;
padding-top: 48rpx;
.panel-icon-wrap {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 40rpx;
}
.panel-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
&.toutiao-icon {
background: linear-gradient(135deg, #000000 0%, #1a1a1a 100%);
box-shadow: 0 12rpx 32rpx rgba(0, 0, 0, 0.3);
}
.icon-emoji {
font-size: 56rpx;
}
}
.panel-title {
font-size: 36rpx;
font-weight: 700;
color: $text-main;
margin-bottom: 12rpx;
text-align: center;
}
.panel-desc {
font-size: 26rpx;
color: $text-sub;
margin-bottom: 48rpx;
text-align: center;
}
}
/* 短信登录面板 */
.sms-panel {
.panel-title {
font-size: 32rpx;
font-weight: 700;
color: $text-main;
margin-bottom: 8rpx;
}
.panel-desc {
font-size: 24rpx;
color: $text-sub;
margin-bottom: 36rpx;
}
}
.form-group {
display: flex;
flex-direction: column;
gap: 24rpx;
margin-bottom: 36rpx;
}
.input-field {
background: $bg-secondary;
border-radius: $radius-lg;
height: 104rpx;
padding: 0 24rpx;
display: flex;
align-items: center;
border: 2rpx solid transparent;
transition: all $transition-normal $ease-out;
&:focus-within {
background: $bg-card;
border-color: rgba($brand-primary, 0.3);
box-shadow: 0 0 0 6rpx rgba($brand-primary, 0.08);
}
.field-prefix {
margin-right: 16rpx;
padding-right: 16rpx;
border-right: 2rpx solid $border-color-light;
.prefix-text {
font-size: 28rpx;
color: $text-main;
font-weight: 600;
}
.prefix-icon {
font-size: 32rpx;
}
}
.field-input {
flex: 1;
font-size: 30rpx;
color: $text-main;
height: 100%;
}
.clear-btn {
width: 40rpx;
height: 40rpx;
background: $text-tertiary;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
color: $bg-card;
margin-left: 12rpx;
}
}
.send-code-btn {
height: 68rpx;
padding: 0 24rpx;
background: $gradient-brand;
border-radius: $radius-round;
display: flex;
align-items: center;
justify-content: center;
margin-left: 16rpx;
text {
font-size: 24rpx;
color: #fff;
font-weight: 600;
white-space: nowrap;
}
&.disabled {
background: $text-tertiary;
}
}
/* 登录按钮 */
.btn-login {
width: 100%;
height: 100rpx;
font-size: 32rpx;
border: none;
&::after { border: none; }
&.disabled, &[disabled] {
background: $text-tertiary !important;
box-shadow: none !important;
}
}
.hint-text {
display: block;
text-align: center;
font-size: 24rpx;
color: $text-tertiary;
margin-top: 20rpx;
}
/* 协议区 */
.agreement-section {
padding: 24rpx 32rpx 32rpx;
border-top: 2rpx solid $border-color-light;
}
.agreement-row {
display: flex;
align-items: flex-start;
justify-content: center;
}
.checkbox {
width: 36rpx;
height: 36rpx;
border: 2rpx solid $text-tertiary;
border-radius: 50%;
margin-right: 12rpx;
margin-top: 2rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all $transition-fast $ease-out;
&.checked {
background: $gradient-brand;
border-color: $brand-primary;
}
.check-mark {
color: #fff;
font-size: 22rpx;
font-weight: bold;
}
}
.agreement-text {
font-size: 24rpx;
color: $text-sub;
line-height: 1.6;
.link {
color: $brand-primary;
font-weight: 600;
}
}
/* 底部 */
.footer {
padding: 32rpx 0 20rpx;
text-align: center;
}
.copyright {
font-size: 22rpx;
color: $text-tertiary;
}
</style>