| | |
| | | <view class="title">欢迎登录</view> |
| | | |
| | | <view class="tabs"> |
| | | <view class="tab" :class="loginType === 'code' ? 'active' : 'normal'" @tap="switchLoginType('code')">验证码登录</view> |
| | | <view class="tab" :class="loginType === 'password' ? 'active' : 'normal'" @tap="switchLoginType('password')">密码登录</view> |
| | | <view class="tab" :class="loginType === 'code' ? 'active' : 'normal'" @tap="switchLoginType('code')">验证码登录 |
| | | </view> |
| | | <view class="tab" :class="loginType === 'password' ? 'active' : 'normal'" |
| | | @tap="switchLoginType('password')">密码登录</view> |
| | | </view> |
| | | |
| | | <view class="form"> |
| | | <view class="input-group"> |
| | | <input |
| | | class="input" |
| | | type="number" |
| | | maxlength="11" |
| | | v-model="phone" |
| | | placeholder="输入手机号" |
| | | placeholder-class="placeholder-style" |
| | | /> |
| | | <view |
| | | class="get-code-btn" |
| | | :class="{ disabled: countdown > 0 }" |
| | | v-if="loginType === 'code'" |
| | | @tap="handleSendCode" |
| | | > |
| | | <input class="input" type="number" maxlength="11" v-model="phone" placeholder="输入手机号" |
| | | placeholder-class="placeholder-style" /> |
| | | <view class="get-code-btn" :class="{ disabled: countdown > 0 }" v-if="loginType === 'code'" |
| | | @tap="handleSendCode"> |
| | | {{ countdown > 0 ? `${countdown}s` : '获取验证码' }} |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="input-group"> |
| | | <input |
| | | class="input" |
| | | :type="loginType === 'code' ? 'number' : 'text'" |
| | | :password="loginType === 'password'" |
| | | :maxlength="loginType === 'code' ? 6 : 32" |
| | | v-model="credential" |
| | | :placeholder="loginType === 'code' ? '输入验证码' : '输入密码'" |
| | | placeholder-class="placeholder-style" |
| | | /> |
| | | <input class="input" :type="loginType === 'code' ? 'number' : 'text'" |
| | | :password="loginType === 'password'" :maxlength="loginType === 'code' ? 6 : 32" v-model="credential" |
| | | :placeholder="loginType === 'code' ? '输入验证码' : '输入密码'" placeholder-class="placeholder-style" /> |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="login-btn" @tap="handleLogin">{{ loading ? '登录中...' : '登录' }}</view> |
| | | |
| | | <view class="action-links" :class="{ 'center': loginType === 'code', 'space-between': loginType === 'password' }"> |
| | | <view class="action-links" |
| | | :class="{ 'center': loginType === 'code', 'space-between': loginType === 'password' }"> |
| | | <text class="register-link" @tap="goRegister">没有账号?点击注册</text> |
| | | <text class="forgot-password" v-if="loginType === 'password'" @tap="goForgotPassword">忘记密码?</text> |
| | | </view> |
| | |
| | | </template> |
| | | |
| | | <script setup lang="ts"> |
| | | import { onLoad } from '@dcloudio/uni-app' |
| | | import { onUnmounted, ref } from 'vue' |
| | | import { passwordLogin, sendSmsCode, smsLogin, wechatLogin } from './service.js' |
| | | import { onLoad } from '@dcloudio/uni-app' |
| | | import { onUnmounted, ref } from 'vue' |
| | | import { passwordLogin, sendSmsCode, smsLogin, wechatLogin } from './service.js' |
| | | |
| | | const loginType = ref<'code' | 'password'>('code') |
| | | const phone = ref('') |
| | | const credential = ref('') |
| | | const countdown = ref(0) |
| | | const loading = ref(false) |
| | | const inviteUserId = ref('') |
| | | const inviteType = ref('') |
| | | const loginType = ref<'code' | 'password'>('code') |
| | | const phone = ref('') |
| | | const credential = ref('') |
| | | const countdown = ref(0) |
| | | const loading = ref(false) |
| | | const inviteUserId = ref('') |
| | | const inviteType = ref('') |
| | | |
| | | let countdownTimer: ReturnType<typeof setInterval> | null = null |
| | | let countdownTimer : ReturnType<typeof setInterval> | null = null |
| | | |
| | | interface AppLoginUser { |
| | | id: number |
| | | nickname: string |
| | | phone: string |
| | | avatar?: string |
| | | type?: number |
| | | status?: number |
| | | clinicId?: number |
| | | memberExpireAt?: string |
| | | } |
| | | |
| | | interface AppLoginPayload { |
| | | token: string |
| | | tokenType: string |
| | | expiresIn: number |
| | | user: AppLoginUser |
| | | } |
| | | |
| | | interface ApiResponse<T> { |
| | | code: number |
| | | msg?: string |
| | | message?: string |
| | | data: T |
| | | } |
| | | |
| | | interface AppWechatLoginPayload { |
| | | code?: string |
| | | accessToken?: string |
| | | openid?: string |
| | | unionid?: string |
| | | } |
| | | |
| | | interface WechatAuthResult { |
| | | access_token?: string |
| | | accessToken?: string |
| | | openid?: string |
| | | openId?: string |
| | | unionid?: string |
| | | unionId?: string |
| | | } |
| | | |
| | | interface WechatLoginResult { |
| | | code?: string |
| | | authResult?: WechatAuthResult |
| | | } |
| | | |
| | | /** |
| | | * 页面加载时记录来源参数,便于后续扩展回跳逻辑。 |
| | | */ |
| | | onLoad((options) => { |
| | | inviteUserId.value = options?.inviteUserId || '' |
| | | inviteType.value = options?.inviteType || '' |
| | | }) |
| | | |
| | | /** |
| | | * 页面销毁时清理倒计时,避免重复定时器泄漏。 |
| | | */ |
| | | onUnmounted(() => { |
| | | clearCountdown() |
| | | }) |
| | | |
| | | /** |
| | | * 切换登录方式时重置当前输入态。 |
| | | */ |
| | | const switchLoginType = (type: 'code' | 'password') => { |
| | | loginType.value = type |
| | | credential.value = '' |
| | | } |
| | | |
| | | /** |
| | | * 关闭登录页,优先返回上一页,没有页面栈时回首页。 |
| | | */ |
| | | const handleClose = () => { |
| | | if (getCurrentPages().length > 1) { |
| | | uni.navigateBack() |
| | | return |
| | | interface AppLoginUser { |
| | | id : number |
| | | nickname : string |
| | | phone : string |
| | | avatar ?: string |
| | | type ?: number |
| | | status ?: number |
| | | clinicId ?: number |
| | | memberExpireAt ?: string |
| | | } |
| | | uni.switchTab({ |
| | | url: '/pages/home/home' |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 跳转到注册页。 |
| | | */ |
| | | const goRegister = () => { |
| | | uni.navigateTo({ |
| | | url: '/pages/register/register' |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 跳转到忘记密码页。 |
| | | */ |
| | | const goForgotPassword = () => { |
| | | uni.navigateTo({ |
| | | url: '/pages/forgotPassword/forgotPassword' |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 执行微信登录,优先将微信授权结果交给后端拉取昵称和头像。 |
| | | */ |
| | | const handleWechatLogin = async () => { |
| | | if (loading.value) { |
| | | return |
| | | interface AppLoginPayload { |
| | | token : string |
| | | tokenType : string |
| | | expiresIn : number |
| | | user : AppLoginUser |
| | | } |
| | | // #ifdef APP-PLUS |
| | | loading.value = true |
| | | try { |
| | | const loginRes = await requestWechatAuthorization() |
| | | const payload = buildWechatLoginPayload(loginRes) |
| | | if (!payload) { |
| | | |
| | | interface ApiResponse<T> { |
| | | code : number |
| | | msg ?: string |
| | | message ?: string |
| | | data : T |
| | | } |
| | | |
| | | interface AppWechatLoginPayload { |
| | | code ?: string |
| | | accessToken ?: string |
| | | openid ?: string |
| | | unionid ?: string |
| | | } |
| | | |
| | | interface WechatAuthResult { |
| | | access_token ?: string |
| | | accessToken ?: string |
| | | openid ?: string |
| | | openId ?: string |
| | | unionid ?: string |
| | | unionId ?: string |
| | | } |
| | | |
| | | interface WechatLoginResult { |
| | | code ?: string |
| | | authResult ?: WechatAuthResult |
| | | } |
| | | |
| | | /** |
| | | * 页面加载时记录来源参数,便于后续扩展回跳逻辑。 |
| | | */ |
| | | onLoad((options) => { |
| | | inviteUserId.value = options?.inviteUserId || '' |
| | | inviteType.value = options?.inviteType || '' |
| | | }) |
| | | |
| | | /** |
| | | * 页面销毁时清理倒计时,避免重复定时器泄漏。 |
| | | */ |
| | | onUnmounted(() => { |
| | | clearCountdown() |
| | | }) |
| | | |
| | | /** |
| | | * 切换登录方式时重置当前输入态。 |
| | | */ |
| | | const switchLoginType = (type : 'code' | 'password') => { |
| | | loginType.value = type |
| | | credential.value = '' |
| | | } |
| | | |
| | | /** |
| | | * 关闭登录页,优先返回上一页,没有页面栈时回首页。 |
| | | */ |
| | | const handleClose = () => { |
| | | if (getCurrentPages().length > 1) { |
| | | uni.navigateBack() |
| | | return |
| | | } |
| | | const res = await wechatLogin(payload) |
| | | if (!isSuccessResponse(res)) { |
| | | uni.switchTab({ |
| | | url: '/pages/home/home' |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 跳转到注册页。 |
| | | */ |
| | | const goRegister = () => { |
| | | uni.navigateTo({ |
| | | url: '/pages/register/register' |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 跳转到忘记密码页。 |
| | | */ |
| | | const goForgotPassword = () => { |
| | | uni.navigateTo({ |
| | | url: '/pages/forgotPassword/forgotPassword' |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 执行微信登录,优先将微信授权结果交给后端拉取昵称和头像。 |
| | | */ |
| | | const handleWechatLogin = async () => { |
| | | if (loading.value) { |
| | | return |
| | | } |
| | | saveLoginSession(res.data) |
| | | uni.showToast({ |
| | | title: '登录成功', |
| | | icon: 'success' |
| | | }) |
| | | setTimeout(() => { |
| | | uni.switchTab({ |
| | | url: '/pages/home/home' |
| | | }) |
| | | }, 300) |
| | | } catch (error) { |
| | | uni.showToast({ |
| | | title: '微信登录失败', |
| | | icon: 'none' |
| | | }) |
| | | } finally { |
| | | loading.value = false |
| | | } |
| | | // #endif |
| | | |
| | | // #ifndef APP-PLUS |
| | | uni.showToast({ |
| | | title: '请在App中使用微信登录', |
| | | icon: 'none' |
| | | }) |
| | | // #endif |
| | | } |
| | | |
| | | /** |
| | | * 拉起微信授权,获取后端换取用户资料所需的授权结果。 |
| | | */ |
| | | const requestWechatAuthorization = () => { |
| | | return new Promise<WechatLoginResult>((resolve, reject) => { |
| | | uni.login({ |
| | | provider: 'weixin', |
| | | success: (res) => { |
| | | resolve(res as WechatLoginResult) |
| | | }, |
| | | fail: (error) => { |
| | | reject(error) |
| | | // #ifdef APP-PLUS |
| | | loading.value = true |
| | | try { |
| | | const loginRes = await requestWechatAuthorization() |
| | | const payload = buildWechatLoginPayload(loginRes) |
| | | if (!payload) { |
| | | return |
| | | } |
| | | const res = await wechatLogin(payload) |
| | | if (!isSuccessResponse(res)) { |
| | | return |
| | | } |
| | | saveLoginSession(res.data) |
| | | uni.showToast({ |
| | | title: '登录成功', |
| | | icon: 'success' |
| | | }) |
| | | setTimeout(() => { |
| | | uni.switchTab({ |
| | | url: '/pages/home/home' |
| | | }) |
| | | }, 300) |
| | | } catch (error: any) { |
| | | const errorCode = error?.code |
| | | const errorMessage = typeof error?.errMsg === 'string' ? error.errMsg : '' |
| | | uni.showToast({ |
| | | title: errorCode === -7 |
| | | ? '请重新云打包,并检查 manifest 中微信登录配置' |
| | | : (errorMessage || '微信登录失败'), |
| | | icon: 'none' |
| | | }) |
| | | } finally { |
| | | loading.value = false |
| | | } |
| | | // #endif |
| | | |
| | | // #ifndef APP-PLUS |
| | | uni.showToast({ |
| | | title: '请在App中使用微信登录', |
| | | icon: 'none' |
| | | }) |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 从 uni.login 返回结果中提取微信登录接口需要的参数。 |
| | | */ |
| | | const buildWechatLoginPayload = (loginRes: WechatLoginResult) => { |
| | | const authResult = loginRes?.authResult || {} |
| | | const payload: AppWechatLoginPayload = { |
| | | code: normalizeOptionalText(loginRes?.code), |
| | | accessToken: normalizeOptionalText(authResult.access_token || authResult.accessToken), |
| | | openid: normalizeOptionalText(authResult.openid || authResult.openId), |
| | | unionid: normalizeOptionalText(authResult.unionid || authResult.unionId) |
| | | // #endif |
| | | } |
| | | |
| | | // 当前阶段优先兼容 APP 端直接返回的 accessToken/openid,后续补齐配置后也可走 code 模式。 |
| | | if ((payload.accessToken && payload.openid) || payload.code) { |
| | | return payload |
| | | /** |
| | | * 拉起微信授权,获取后端换取用户资料所需的授权结果。 |
| | | */ |
| | | const requestWechatAuthorization = () => { |
| | | return new Promise<WechatLoginResult>((resolve, reject) => { |
| | | // #ifdef APP-PLUS |
| | | plus.oauth.getServices((services) => { |
| | | const weixinService = services.find((service) => service.id === 'weixin') |
| | | if (!weixinService) { |
| | | reject({ |
| | | code: -3, |
| | | errMsg: '当前环境不支持微信登录' |
| | | }) |
| | | return |
| | | } |
| | | if (!weixinService.nativeClient) { |
| | | reject({ |
| | | code: -8, |
| | | errMsg: '请先安装微信客户端' |
| | | }) |
| | | return |
| | | } |
| | | weixinService.authorize((event : any) => { |
| | | const code = normalizeOptionalText(event?.code) |
| | | if (!code) { |
| | | reject({ |
| | | code: -99, |
| | | errMsg: '微信授权未返回有效code' |
| | | }) |
| | | return |
| | | } |
| | | resolve({ |
| | | code |
| | | }) |
| | | }, (error : any) => { |
| | | reject({ |
| | | code: error?.code, |
| | | errMsg: error?.message || '微信授权失败' |
| | | }) |
| | | }, { |
| | | scope: 'snsapi_userinfo', |
| | | state: 'login' |
| | | }) |
| | | }, (error : any) => { |
| | | reject({ |
| | | code: error?.code, |
| | | errMsg: error?.message || '获取微信登录服务失败' |
| | | }) |
| | | }) |
| | | return |
| | | // #endif |
| | | |
| | | uni.login({ |
| | | provider: 'weixin', |
| | | success: (res) => { |
| | | resolve(res as WechatLoginResult) |
| | | }, |
| | | fail: (error) => { |
| | | reject(error) |
| | | } |
| | | }) |
| | | }) |
| | | } |
| | | |
| | | uni.showToast({ |
| | | title: '微信授权信息缺失', |
| | | icon: 'none' |
| | | }) |
| | | return null |
| | | } |
| | | /** |
| | | * 从 uni.login 返回结果中提取微信登录接口需要的参数。 |
| | | */ |
| | | const buildWechatLoginPayload = (loginRes : WechatLoginResult) => { |
| | | const authResult = loginRes?.authResult || {} |
| | | const payload : AppWechatLoginPayload = { |
| | | code: normalizeOptionalText(loginRes?.code), |
| | | accessToken: normalizeOptionalText(authResult.access_token || authResult.accessToken), |
| | | openid: normalizeOptionalText(authResult.openid || authResult.openId), |
| | | unionid: normalizeOptionalText(authResult.unionid || authResult.unionId) |
| | | } |
| | | |
| | | /** |
| | | * 发送短信验证码并启动倒计时。 |
| | | */ |
| | | const handleSendCode = async () => { |
| | | if (countdown.value > 0 || loading.value) { |
| | | return |
| | | } |
| | | const normalizedPhone = normalizePhone() |
| | | if (!normalizedPhone) { |
| | | return |
| | | } |
| | | const res = await sendSmsCode({ phone: normalizedPhone }) |
| | | if (!isSuccessResponse(res)) { |
| | | return |
| | | } |
| | | startCountdown() |
| | | uni.showToast({ |
| | | title: '验证码已发送', |
| | | icon: 'success' |
| | | }) |
| | | } |
| | | // 当前阶段优先兼容 APP 端直接返回的 accessToken/openid,后续补齐配置后也可走 code 模式。 |
| | | if ((payload.accessToken && payload.openid) || payload.code) { |
| | | return payload |
| | | } |
| | | |
| | | /** |
| | | * 执行登录动作,根据当前 tab 走验证码登录或密码登录。 |
| | | */ |
| | | const handleLogin = async () => { |
| | | if (loading.value) { |
| | | return |
| | | uni.showToast({ |
| | | title: '微信授权信息缺失', |
| | | icon: 'none' |
| | | }) |
| | | return null |
| | | } |
| | | const normalizedPhone = normalizePhone() |
| | | if (!normalizedPhone) { |
| | | return |
| | | } |
| | | loading.value = true |
| | | try { |
| | | const res = loginType.value === 'code' |
| | | ? await loginWithCode(normalizedPhone) |
| | | : await loginWithPassword(normalizedPhone) |
| | | if (!res) { |
| | | |
| | | /** |
| | | * 发送短信验证码并启动倒计时。 |
| | | */ |
| | | const handleSendCode = async () => { |
| | | if (countdown.value > 0 || loading.value) { |
| | | return |
| | | } |
| | | const normalizedPhone = normalizePhone() |
| | | if (!normalizedPhone) { |
| | | return |
| | | } |
| | | const res = await sendSmsCode({ phone: normalizedPhone }) |
| | | if (!isSuccessResponse(res)) { |
| | | return |
| | | } |
| | | saveLoginSession(res.data) |
| | | startCountdown() |
| | | uni.showToast({ |
| | | title: '登录成功', |
| | | title: '验证码已发送', |
| | | icon: 'success' |
| | | }) |
| | | setTimeout(() => { |
| | | uni.switchTab({ |
| | | url: '/pages/home/home' |
| | | }) |
| | | }, 300) |
| | | } finally { |
| | | loading.value = false |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 使用短信验证码完成登录。 |
| | | */ |
| | | const loginWithCode = (normalizedPhone: string) => { |
| | | const normalizedCode = normalizeCode() |
| | | if (!normalizedCode) { |
| | | return null |
| | | } |
| | | return smsLogin({ |
| | | phone: normalizedPhone, |
| | | code: normalizedCode |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 使用手机号密码完成登录。 |
| | | */ |
| | | const loginWithPassword = (normalizedPhone: string) => { |
| | | const normalizedPassword = normalizePassword() |
| | | if (!normalizedPassword) { |
| | | return null |
| | | } |
| | | return passwordLogin({ |
| | | phone: normalizedPhone, |
| | | password: normalizedPassword |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 将登录响应写入本地缓存,供后续请求与页面读取。 |
| | | */ |
| | | const saveLoginSession = (loginData: AppLoginPayload) => { |
| | | if (!loginData) { |
| | | return |
| | | } |
| | | const authToken = `${loginData.tokenType || 'Bearer'} ${loginData.token || ''}`.trim() |
| | | uni.setStorageSync('token', authToken) |
| | | uni.setStorageSync('appUserInfo', loginData.user || {}) |
| | | uni.setStorageSync('showLogoutModal', 1) |
| | | // 先保留来源参数,后续如需登录后回跳可直接复用。 |
| | | uni.setStorageSync('loginRedirectMeta', { |
| | | inviteUserId: inviteUserId.value, |
| | | inviteType: inviteType.value |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 校验手机号输入是否合法。 |
| | | */ |
| | | const normalizePhone = () => { |
| | | const normalizedPhone = (phone.value || '').trim() |
| | | if (!/^1\d{10}$/.test(normalizedPhone)) { |
| | | uni.showToast({ |
| | | title: '请输入正确的手机号', |
| | | icon: 'none' |
| | | }) |
| | | return '' |
| | | } |
| | | return normalizedPhone |
| | | } |
| | | |
| | | /** |
| | | * 校验验证码输入是否合法。 |
| | | */ |
| | | const normalizeCode = () => { |
| | | const normalizedCode = (credential.value || '').trim() |
| | | if (!/^\d{6}$/.test(normalizedCode)) { |
| | | uni.showToast({ |
| | | title: '请输入6位验证码', |
| | | icon: 'none' |
| | | }) |
| | | return '' |
| | | } |
| | | return normalizedCode |
| | | } |
| | | |
| | | /** |
| | | * 校验密码输入是否合法。 |
| | | */ |
| | | const normalizePassword = () => { |
| | | const normalizedPassword = (credential.value || '').trim() |
| | | if (!normalizedPassword) { |
| | | uni.showToast({ |
| | | title: '请输入密码', |
| | | icon: 'none' |
| | | }) |
| | | return '' |
| | | } |
| | | return normalizedPassword |
| | | } |
| | | |
| | | /** |
| | | * 将可选字符串参数归一化为空串或有效文本。 |
| | | */ |
| | | const normalizeOptionalText = (value: unknown) => { |
| | | if (typeof value !== 'string') { |
| | | return '' |
| | | } |
| | | return value.trim() |
| | | } |
| | | |
| | | /** |
| | | * 判断接口响应是否为成功态。 |
| | | */ |
| | | const isSuccessResponse = (response: ApiResponse<unknown> | undefined) => { |
| | | return !!response && (response.code === 0 || response.code === 200) |
| | | } |
| | | |
| | | /** |
| | | * 启动 60 秒验证码发送倒计时。 |
| | | */ |
| | | const startCountdown = () => { |
| | | clearCountdown() |
| | | countdown.value = 60 |
| | | countdownTimer = setInterval(() => { |
| | | if (countdown.value <= 1) { |
| | | clearCountdown() |
| | | /** |
| | | * 执行登录动作,根据当前 tab 走验证码登录或密码登录。 |
| | | */ |
| | | const handleLogin = async () => { |
| | | if (loading.value) { |
| | | return |
| | | } |
| | | countdown.value -= 1 |
| | | }, 1000) |
| | | } |
| | | |
| | | /** |
| | | * 清理验证码倒计时。 |
| | | */ |
| | | const clearCountdown = () => { |
| | | if (countdownTimer) { |
| | | clearInterval(countdownTimer) |
| | | countdownTimer = null |
| | | const normalizedPhone = normalizePhone() |
| | | if (!normalizedPhone) { |
| | | return |
| | | } |
| | | loading.value = true |
| | | try { |
| | | const res = loginType.value === 'code' |
| | | ? await loginWithCode(normalizedPhone) |
| | | : await loginWithPassword(normalizedPhone) |
| | | if (!res) { |
| | | return |
| | | } |
| | | if (!isSuccessResponse(res)) { |
| | | return |
| | | } |
| | | saveLoginSession(res.data) |
| | | uni.showToast({ |
| | | title: '登录成功', |
| | | icon: 'success' |
| | | }) |
| | | setTimeout(() => { |
| | | uni.switchTab({ |
| | | url: '/pages/home/home' |
| | | }) |
| | | }, 300) |
| | | } finally { |
| | | loading.value = false |
| | | } |
| | | } |
| | | countdown.value = 0 |
| | | } |
| | | |
| | | /** |
| | | * 使用短信验证码完成登录。 |
| | | */ |
| | | const loginWithCode = (normalizedPhone : string) => { |
| | | const normalizedCode = normalizeCode() |
| | | if (!normalizedCode) { |
| | | return null |
| | | } |
| | | return smsLogin({ |
| | | phone: normalizedPhone, |
| | | code: normalizedCode |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 使用手机号密码完成登录。 |
| | | */ |
| | | const loginWithPassword = (normalizedPhone : string) => { |
| | | const normalizedPassword = normalizePassword() |
| | | if (!normalizedPassword) { |
| | | return null |
| | | } |
| | | return passwordLogin({ |
| | | phone: normalizedPhone, |
| | | password: normalizedPassword |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 将登录响应写入本地缓存,供后续请求与页面读取。 |
| | | */ |
| | | const saveLoginSession = (loginData : AppLoginPayload) => { |
| | | if (!loginData) { |
| | | return |
| | | } |
| | | const authToken = `${loginData.tokenType || 'Bearer'} ${loginData.token || ''}`.trim() |
| | | uni.setStorageSync('token', authToken) |
| | | uni.setStorageSync('appUserInfo', loginData.user || {}) |
| | | uni.setStorageSync('showLogoutModal', 1) |
| | | // 先保留来源参数,后续如需登录后回跳可直接复用。 |
| | | uni.setStorageSync('loginRedirectMeta', { |
| | | inviteUserId: inviteUserId.value, |
| | | inviteType: inviteType.value |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 校验手机号输入是否合法。 |
| | | */ |
| | | const normalizePhone = () => { |
| | | const normalizedPhone = (phone.value || '').trim() |
| | | if (!/^1\d{10}$/.test(normalizedPhone)) { |
| | | uni.showToast({ |
| | | title: '请输入正确的手机号', |
| | | icon: 'none' |
| | | }) |
| | | return '' |
| | | } |
| | | return normalizedPhone |
| | | } |
| | | |
| | | /** |
| | | * 校验验证码输入是否合法。 |
| | | */ |
| | | const normalizeCode = () => { |
| | | const normalizedCode = (credential.value || '').trim() |
| | | if (!/^\d{6}$/.test(normalizedCode)) { |
| | | uni.showToast({ |
| | | title: '请输入6位验证码', |
| | | icon: 'none' |
| | | }) |
| | | return '' |
| | | } |
| | | return normalizedCode |
| | | } |
| | | |
| | | /** |
| | | * 校验密码输入是否合法。 |
| | | */ |
| | | const normalizePassword = () => { |
| | | const normalizedPassword = (credential.value || '').trim() |
| | | if (!normalizedPassword) { |
| | | uni.showToast({ |
| | | title: '请输入密码', |
| | | icon: 'none' |
| | | }) |
| | | return '' |
| | | } |
| | | return normalizedPassword |
| | | } |
| | | |
| | | /** |
| | | * 将可选字符串参数归一化为空串或有效文本。 |
| | | */ |
| | | const normalizeOptionalText = (value : unknown) => { |
| | | if (typeof value !== 'string') { |
| | | return '' |
| | | } |
| | | return value.trim() |
| | | } |
| | | |
| | | /** |
| | | * 判断接口响应是否为成功态。 |
| | | */ |
| | | const isSuccessResponse = (response : ApiResponse<unknown> | undefined) => { |
| | | return !!response && (response.code === 0 || response.code === 200) |
| | | } |
| | | |
| | | /** |
| | | * 启动 60 秒验证码发送倒计时。 |
| | | */ |
| | | const startCountdown = () => { |
| | | clearCountdown() |
| | | countdown.value = 60 |
| | | countdownTimer = setInterval(() => { |
| | | if (countdown.value <= 1) { |
| | | clearCountdown() |
| | | return |
| | | } |
| | | countdown.value -= 1 |
| | | }, 1000) |
| | | } |
| | | |
| | | /** |
| | | * 清理验证码倒计时。 |
| | | */ |
| | | const clearCountdown = () => { |
| | | if (countdownTimer) { |
| | | clearInterval(countdownTimer) |
| | | countdownTimer = null |
| | | } |
| | | countdown.value = 0 |
| | | } |
| | | </script> |
| | | <style></style> |
| | | <style lang="scss" scoped> |
| | | .container { |
| | | padding: 0 40rpx; |
| | | padding-top: calc(var(--status-bar-height, 0px) + 151rpx); |
| | | box-sizing: border-box; |
| | | min-height: 100vh; |
| | | background-color: #fff; |
| | | background-image: url('https://mxcd.zhinenganguan.com/file/2026-03-26/6b53248a-b511-4ffd-84e7-e86f3ef0e774.png'); |
| | | background-size: 100% 100%; |
| | | .container { |
| | | padding: 0 40rpx; |
| | | padding-top: calc(var(--status-bar-height, 0px) + 151rpx); |
| | | box-sizing: border-box; |
| | | min-height: 100vh; |
| | | background-color: #fff; |
| | | background-image: url('https://mxcd.zhinenganguan.com/file/2026-03-26/6b53248a-b511-4ffd-84e7-e86f3ef0e774.png'); |
| | | background-size: 100% 100%; |
| | | |
| | | background-repeat: no-repeat; |
| | | position: relative; |
| | | } |
| | | |
| | | .close-icon { |
| | | position: absolute; |
| | | left: 24rpx; |
| | | top: calc(var(--status-bar-height, 0px) + 65rpx); |
| | | width: 40rpx; |
| | | height: 40rpx; |
| | | z-index: 10; |
| | | display: block; |
| | | } |
| | | |
| | | .title { |
| | | font-size: 68rpx; |
| | | line-height: 84rpx; |
| | | color: #010F07; |
| | | font-weight: bold; |
| | | } |
| | | |
| | | .tabs { |
| | | margin-top: 64rpx; |
| | | display: flex; |
| | | align-items: center; |
| | | |
| | | .tab { |
| | | flex: 1; |
| | | height: 108rpx; |
| | | line-height: 108rpx; |
| | | text-align: center; |
| | | border-radius: 16rpx; |
| | | font-size: 32rpx; |
| | | |
| | | &.active { |
| | | background-color: #C63520; |
| | | color: #fff; |
| | | margin-right: 30rpx; |
| | | } |
| | | |
| | | &.normal { |
| | | background-color: #ffffff; |
| | | color: #677294; |
| | | box-shadow: 0px 0px 44px 0px rgba(0, 0, 0, 0.06); |
| | | } |
| | | background-repeat: no-repeat; |
| | | position: relative; |
| | | } |
| | | } |
| | | |
| | | .form { |
| | | margin-top: 60rpx; |
| | | .close-icon { |
| | | position: absolute; |
| | | left: 24rpx; |
| | | top: calc(var(--status-bar-height, 0px) + 65rpx); |
| | | width: 40rpx; |
| | | height: 40rpx; |
| | | z-index: 10; |
| | | display: block; |
| | | } |
| | | |
| | | .input-group { |
| | | width: 670rpx; |
| | | height: 108rpx; |
| | | border-radius: 16rpx; |
| | | border: 1px solid #EEEEEE; |
| | | .title { |
| | | font-size: 68rpx; |
| | | line-height: 84rpx; |
| | | color: #010F07; |
| | | font-weight: bold; |
| | | } |
| | | |
| | | .tabs { |
| | | margin-top: 64rpx; |
| | | display: flex; |
| | | align-items: center; |
| | | margin-bottom: 30rpx; |
| | | box-sizing: border-box; |
| | | |
| | | .input { |
| | | .tab { |
| | | flex: 1; |
| | | height: 100%; |
| | | padding-left: 48rpx; |
| | | font-size: 32rpx; |
| | | color: #333; |
| | | } |
| | | |
| | | .get-code-btn { |
| | | width: 178rpx; |
| | | height: 56rpx; |
| | | line-height: 56rpx; |
| | | height: 108rpx; |
| | | line-height: 108rpx; |
| | | text-align: center; |
| | | background-color: #C63520; |
| | | color: #fff; |
| | | font-size: 26rpx; |
| | | border-radius: 8rpx; |
| | | margin-right: 24rpx; |
| | | border-radius: 16rpx; |
| | | font-size: 32rpx; |
| | | |
| | | &.disabled { |
| | | background-color: #D9D9D9; |
| | | &.active { |
| | | background-color: #C63520; |
| | | color: #fff; |
| | | margin-right: 30rpx; |
| | | } |
| | | |
| | | &.normal { |
| | | background-color: #ffffff; |
| | | color: #677294; |
| | | box-shadow: 0px 0px 44px 0px rgba(0, 0, 0, 0.06); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | .placeholder-style { |
| | | color: #868686; |
| | | font-size: 32rpx; |
| | | } |
| | | .form { |
| | | margin-top: 60rpx; |
| | | |
| | | .login-btn { |
| | | width: 670rpx; |
| | | height: 96rpx; |
| | | line-height: 96rpx; |
| | | text-align: center; |
| | | background-color: #C63520; |
| | | color: #fff; |
| | | font-size: 36rpx; |
| | | border-radius: 16rpx; |
| | | margin-top: 48rpx; |
| | | } |
| | | .input-group { |
| | | width: 670rpx; |
| | | height: 108rpx; |
| | | border-radius: 16rpx; |
| | | border: 1px solid #EEEEEE; |
| | | display: flex; |
| | | align-items: center; |
| | | margin-bottom: 30rpx; |
| | | box-sizing: border-box; |
| | | |
| | | .action-links { |
| | | margin-top: 88rpx; |
| | | display: flex; |
| | | width: 100%; |
| | | box-sizing: border-box; |
| | | padding: 0 20rpx; |
| | | .input { |
| | | flex: 1; |
| | | height: 100%; |
| | | padding-left: 48rpx; |
| | | font-size: 32rpx; |
| | | color: #333; |
| | | } |
| | | |
| | | &.center { |
| | | .get-code-btn { |
| | | width: 178rpx; |
| | | height: 56rpx; |
| | | line-height: 56rpx; |
| | | text-align: center; |
| | | background-color: #C63520; |
| | | color: #fff; |
| | | font-size: 26rpx; |
| | | border-radius: 8rpx; |
| | | margin-right: 24rpx; |
| | | |
| | | &.disabled { |
| | | background-color: #D9D9D9; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | .placeholder-style { |
| | | color: #868686; |
| | | font-size: 32rpx; |
| | | } |
| | | |
| | | .login-btn { |
| | | width: 670rpx; |
| | | height: 96rpx; |
| | | line-height: 96rpx; |
| | | text-align: center; |
| | | background-color: #C63520; |
| | | color: #fff; |
| | | font-size: 36rpx; |
| | | border-radius: 16rpx; |
| | | margin-top: 48rpx; |
| | | } |
| | | |
| | | .action-links { |
| | | margin-top: 88rpx; |
| | | display: flex; |
| | | width: 100%; |
| | | box-sizing: border-box; |
| | | padding: 0 20rpx; |
| | | |
| | | &.center { |
| | | justify-content: center; |
| | | } |
| | | |
| | | &.space-between { |
| | | justify-content: space-between; |
| | | } |
| | | |
| | | .register-link, |
| | | .forgot-password { |
| | | font-size: 28rpx; |
| | | color: #C63520; |
| | | } |
| | | } |
| | | |
| | | .wechat-btn { |
| | | width: 400rpx; |
| | | height: 88rpx; |
| | | margin: 100rpx auto 0; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | } |
| | | border: 2px solid #EEEEEE; |
| | | border-radius: 16rpx; |
| | | box-sizing: border-box; |
| | | |
| | | &.space-between { |
| | | justify-content: space-between; |
| | | } |
| | | .wechat-icon { |
| | | width: 56rpx; |
| | | height: 56rpx; |
| | | margin-right: 16rpx; |
| | | } |
| | | |
| | | .register-link, |
| | | .forgot-password { |
| | | font-size: 28rpx; |
| | | color: #C63520; |
| | | .wechat-text { |
| | | font-size: 30rpx; |
| | | color: #333333; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .wechat-btn { |
| | | width: 400rpx; |
| | | height: 88rpx; |
| | | margin: 100rpx auto 0; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | border: 2px solid #EEEEEE; |
| | | border-radius: 16rpx; |
| | | box-sizing: border-box; |
| | | |
| | | .wechat-icon { |
| | | width: 56rpx; |
| | | height: 56rpx; |
| | | margin-right: 16rpx; |
| | | } |
| | | |
| | | .wechat-text { |
| | | font-size: 30rpx; |
| | | color: #333333; |
| | | } |
| | | } |
| | | </style> |