605 lines
17 KiB
Vue
605 lines
17 KiB
Vue
<template>
|
||
<view class="login-page tn-safe-area-inset-bottom">
|
||
<tn-nav-bar :isBack="true" :bottomShadow="false" backgroundColor="transparent">
|
||
<!-- 可保留标题或留空保持简洁 -->
|
||
</tn-nav-bar>
|
||
|
||
<view class="login-content tn-padding-xl" :style="{paddingTop: vuex_custom_bar_height + 'px'}">
|
||
|
||
<!-- 页面头部大标题 -->
|
||
<view class="page-header tn-margin-bottom-xl tn-margin-top-xl fade-in">
|
||
<view class="tn-text-bold tn-color-black title-text">{{ $tr('欢迎回来') }}</view>
|
||
<view class="tn-color-gray tn-text-sm tn-margin-top-sm subtitle-text">{{ $tr('登录绿居公寓,探索您的国际化青年社区') }}</view>
|
||
</view>
|
||
|
||
<!-- 登录表单 -->
|
||
<view class="form-area tn-margin-top-xl fade-in-up">
|
||
<view class="form-item tn-margin-bottom-lg">
|
||
<view class="form-label tn-text-bold tn-margin-bottom-sm">{{ $tr('账号 / 邮箱') }}</view>
|
||
<view class="input-wrapper" :class="{ 'is-focused': accountFocused }">
|
||
<text class="tn-icon-my input-icon"></text>
|
||
<input
|
||
class="form-input"
|
||
:placeholder="$tr('请输入账号或邮箱')"
|
||
placeholder-class="input-placeholder"
|
||
v-model="form.account"
|
||
@focus="accountFocused = true"
|
||
@blur="accountFocused = false"
|
||
/>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="form-item tn-margin-bottom-xl">
|
||
<view class="form-label tn-text-bold tn-margin-bottom-sm">{{ $tr('密码') }}</view>
|
||
<view class="input-wrapper" :class="{ 'is-focused': passwordFocused }">
|
||
<text class="tn-icon-lock input-icon"></text>
|
||
<input
|
||
class="form-input"
|
||
type="password"
|
||
:placeholder="$tr('请输入密码')"
|
||
placeholder-class="input-placeholder"
|
||
v-model="form.password"
|
||
@focus="passwordFocused = true"
|
||
@blur="passwordFocused = false"
|
||
/>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 操作按钮 -->
|
||
<view class="action-area tn-margin-top-xxl fade-in-up" style="animation-delay: 0.2s;">
|
||
<view class="custom-btn btn-primary" @click="handleLogin" :class="{ 'btn-disabled': isSubmitting }">
|
||
<text v-if="isSubmitting" class="tn-icon-loading tn-margin-right-xs spin"></text>
|
||
<text class="tn-text-bold tn-text-lg">{{ isSubmitting ? $tr('登录中...') : $tr('立即登录') }}</text>
|
||
</view>
|
||
|
||
<view class="secondary-entry-card tn-margin-top-lg" @click="goToRegistrationRecords">
|
||
<view class="tn-flex tn-flex-row-between tn-flex-col-center">
|
||
<view>
|
||
<view class="secondary-entry-title">{{ $tr('查看注册记录') }}</view>
|
||
<view class="secondary-entry-desc tn-margin-top-xs">{{ $tr('未登录也可查看当前微信账号提交的注册申请记录') }}</view>
|
||
</view>
|
||
<text class="tn-icon-right secondary-entry-arrow"></text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="tn-flex tn-flex-row-center tn-margin-top-xl">
|
||
<view class="tn-color-gray tn-text-sm">{{ $tr('还没有账号?') }}</view>
|
||
<view class="tn-color-black tn-text-bold tn-text-sm tn-margin-left-xs register-link" @click="goToRegister">{{ $tr('去注册') }}</view>
|
||
</view>
|
||
</view>
|
||
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import { getRegisteredUser, saveRegisteredUser, startAuthorizedLoginSession } from '../../utils/app-state'
|
||
import { mergeBackendUserInfo, normalizeUser } from '../../utils/flow'
|
||
import {
|
||
extractLoginSession,
|
||
matchRegisteredUser,
|
||
pickLatestRegistration,
|
||
resolveLoginPreparation
|
||
} from '../../utils/login-helper'
|
||
import { resolveBookingRedirectAfterLogin } from '../../utils/booking-entry'
|
||
import { parseReservationFlowResponse, getReservationCurrentNode, hasEffectiveReservationFlow } from '../../utils/reservation-flow'
|
||
import { registrationMyorder, orderSearchedReservationFlow, userMe, usersLogin } from '@/api/index.js'
|
||
|
||
export default {
|
||
data() {
|
||
return {
|
||
isSubmitting: false,
|
||
redirectUrl: '',
|
||
accountFocused: false,
|
||
passwordFocused: false,
|
||
form: {
|
||
account: '',
|
||
password: ''
|
||
}
|
||
}
|
||
},
|
||
async onLoad(options) {
|
||
this.redirectUrl = this.normalizeRedirectUrl(options && options.redirect)
|
||
const bypass = options && options.bypass === '1'
|
||
await this.checkPendingRegistration(bypass)
|
||
},
|
||
methods: {
|
||
normalizeRedirectUrl(value = '') {
|
||
if (!value) {
|
||
return ''
|
||
}
|
||
if (String(value).startsWith('/')) {
|
||
return String(value)
|
||
}
|
||
try {
|
||
return decodeURIComponent(value)
|
||
} catch (error) {
|
||
console.error('decode login redirect failed', error)
|
||
return ''
|
||
}
|
||
},
|
||
isHomePageUrl(url = '') {
|
||
return /^\/pages\/index\/index(?:\?|$)/.test(String(url || ''))
|
||
},
|
||
navigateAfterLogin(targetUrl = this.redirectUrl) {
|
||
const pages = getCurrentPages()
|
||
if (targetUrl) {
|
||
if (this.isHomePageUrl(targetUrl)) {
|
||
uni.reLaunch({
|
||
url: targetUrl
|
||
})
|
||
return
|
||
}
|
||
if (Array.isArray(pages) && pages.length > 1) {
|
||
uni.redirectTo({
|
||
url: targetUrl
|
||
})
|
||
return
|
||
}
|
||
uni.reLaunch({
|
||
url: targetUrl
|
||
})
|
||
return
|
||
}
|
||
if (Array.isArray(pages) && pages.length > 1) {
|
||
uni.navigateBack()
|
||
return
|
||
}
|
||
uni.reLaunch({
|
||
url: '/pages/index/index'
|
||
})
|
||
},
|
||
async checkPendingRegistration(bypassRedirect = false) {
|
||
uni.showLoading({ title: this.$tr('加载中...') })
|
||
try {
|
||
const providerValue = await this.getWechatCode()
|
||
if (!providerValue) {
|
||
return false
|
||
}
|
||
const response = await registrationMyorder({
|
||
providerValue
|
||
}, this.$getLanguage())
|
||
|
||
const rawLatest = response && response.data && typeof response.data === 'object' ? response.data : response
|
||
const latest = rawLatest.data !== undefined ? rawLatest.data : rawLatest
|
||
|
||
if (latest && typeof latest === 'object' && Object.keys(latest).length > 0) {
|
||
|
||
if (!bypassRedirect) {
|
||
uni.redirectTo({ url: '/pkgAccount/auth_result/auth_result' })
|
||
return true
|
||
} else {
|
||
if (latest.approvalStatus === 1) {
|
||
this.form.account = latest.account || latest.email || ''
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('check pending registration failed', e)
|
||
} finally {
|
||
uni.hideLoading()
|
||
}
|
||
return false
|
||
},
|
||
validateForm() {
|
||
if (this.form.account && this.form.password) {
|
||
return true
|
||
}
|
||
|
||
uni.showToast({
|
||
title: this.$tr('请输入账号和密码'),
|
||
icon: 'none'
|
||
})
|
||
return false
|
||
},
|
||
getWechatCode() {
|
||
return new Promise((resolve) => {
|
||
// #ifdef MP-WEIXIN
|
||
uni.login({
|
||
provider: 'weixin',
|
||
success: (res) => {
|
||
resolve(res.code || '')
|
||
},
|
||
fail: (err) => {
|
||
console.error('getWechatCode failed', err)
|
||
resolve('')
|
||
}
|
||
})
|
||
// #endif
|
||
// #ifndef MP-WEIXIN
|
||
resolve('')
|
||
// #endif
|
||
})
|
||
},
|
||
async fetchLatestRegistration() {
|
||
const providerValue = await this.getWechatCode()
|
||
const response = await registrationMyorder({
|
||
Account: this.form.account,
|
||
providerValue: providerValue || ''
|
||
}, this.$getLanguage())
|
||
|
||
const rawLatest = response && response.data && typeof response.data === 'object' ? response.data : response
|
||
const latest = rawLatest.data !== undefined ? rawLatest.data : rawLatest
|
||
|
||
if (latest && typeof latest === 'object' && Object.keys(latest).length > 0) {
|
||
return latest
|
||
}
|
||
return null
|
||
},
|
||
async loginByApi() {
|
||
const response = await usersLogin({
|
||
account: this.form.account,
|
||
password: this.form.password
|
||
}, this.$getLanguage())
|
||
|
||
return extractLoginSession(response, this.$getLanguage())
|
||
},
|
||
fetchReservationFlowAfterLogin(userCode = '') {
|
||
const resolvedUserCode = String(userCode || '').trim()
|
||
if (!resolvedUserCode) {
|
||
return Promise.resolve({
|
||
flowList: [],
|
||
currentFlow: null,
|
||
currentNode: null
|
||
})
|
||
}
|
||
|
||
return orderSearchedReservationFlow({
|
||
userCode: resolvedUserCode
|
||
}, this.$getLanguage())
|
||
.then(response => {
|
||
const flowList = parseReservationFlowResponse(response)
|
||
const currentFlow = flowList.find(item => {
|
||
const node = getReservationCurrentNode(item)
|
||
return node && node.isCurrent
|
||
}) || flowList[0] || null
|
||
const currentNode = currentFlow ? getReservationCurrentNode(currentFlow) : null
|
||
const hasActiveReservationFlow = flowList.some(item => hasEffectiveReservationFlow(item))
|
||
return {
|
||
flowList,
|
||
currentFlow,
|
||
currentNode,
|
||
hasActiveReservationFlow
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('fetch reservation flow after login failed', error)
|
||
return {
|
||
flowList: [],
|
||
currentFlow: null,
|
||
currentNode: null,
|
||
hasActiveReservationFlow: false
|
||
}
|
||
})
|
||
},
|
||
rejectHandledLogin(options = {}) {
|
||
const {
|
||
title = '',
|
||
navigateToAuthResult = false
|
||
} = options
|
||
|
||
uni.hideLoading()
|
||
this.isSubmitting = false
|
||
|
||
if (title) {
|
||
uni.showToast({
|
||
title,
|
||
icon: 'none'
|
||
})
|
||
}
|
||
|
||
if (navigateToAuthResult) {
|
||
setTimeout(() => {
|
||
uni.navigateTo({
|
||
url: '/pkgAccount/auth_result/auth_result'
|
||
})
|
||
}, 600)
|
||
}
|
||
|
||
throw { __handled: true }
|
||
},
|
||
async resolveLoginState() {
|
||
const localRegisteredUser = normalizeUser(getRegisteredUser())
|
||
const matchedLocalUser = matchRegisteredUser(localRegisteredUser, this.form.account)
|
||
const { token, expires, userCode } = await this.loginByApi()
|
||
const latestRegistration = await this.fetchLatestRegistration()
|
||
const loginPreparation = resolveLoginPreparation({
|
||
language: this.$getLanguage(),
|
||
matchedLocalUser,
|
||
latestRegistration,
|
||
account: this.form.account,
|
||
password: this.form.password,
|
||
userCode
|
||
})
|
||
|
||
if (!loginPreparation.ok) {
|
||
this.rejectHandledLogin(loginPreparation.handledError)
|
||
}
|
||
return {
|
||
sessionUser: loginPreparation.sessionUser,
|
||
token,
|
||
expires
|
||
}
|
||
},
|
||
async finishLogin(loginState) {
|
||
const {
|
||
sessionUser,
|
||
token,
|
||
expires
|
||
} = loginState
|
||
|
||
startAuthorizedLoginSession(sessionUser, token, expires)
|
||
|
||
// 先获取用户信息(含 userCode),再获取流程
|
||
let finalSessionUser = sessionUser
|
||
try {
|
||
const userInfoRes = await userMe({}, this.$getLanguage())
|
||
finalSessionUser = mergeBackendUserInfo(sessionUser, userInfoRes)
|
||
startAuthorizedLoginSession(finalSessionUser, token, expires)
|
||
} catch (error) {
|
||
console.error('fetch user info after login failed', error)
|
||
}
|
||
|
||
const userCode = finalSessionUser.userCode || finalSessionUser.code || ''
|
||
const reservationFlow = await this.fetchReservationFlowAfterLogin(userCode)
|
||
const currentStep = reservationFlow.currentNode ? reservationFlow.currentNode.step : null
|
||
|
||
const finalUser = {
|
||
...finalSessionUser,
|
||
reservationFlowList: reservationFlow.flowList,
|
||
currentReservationStep: currentStep,
|
||
hasActiveReservationFlow: reservationFlow.hasActiveReservationFlow
|
||
}
|
||
|
||
saveRegisteredUser(finalUser)
|
||
startAuthorizedLoginSession(finalUser, token, expires)
|
||
let nextRedirectUrl = this.redirectUrl
|
||
|
||
try {
|
||
const stayState = {
|
||
hasCheckedIn: currentStep === 5,
|
||
currentOrder: null,
|
||
currentBooking: reservationFlow.hasActiveReservationFlow && reservationFlow.currentFlow
|
||
? {
|
||
orderNo: reservationFlow.currentFlow.orderNo || '',
|
||
refNo: reservationFlow.currentNode ? String(reservationFlow.currentNode.refNo || '').trim() : '',
|
||
step: currentStep
|
||
}
|
||
: null
|
||
}
|
||
nextRedirectUrl = resolveBookingRedirectAfterLogin(this.redirectUrl, stayState)
|
||
} catch (error) {
|
||
console.error('resolve booking redirect after login failed', error)
|
||
}
|
||
|
||
uni.hideLoading()
|
||
uni.showToast({
|
||
title: this.$tr('登录成功'),
|
||
icon: 'success'
|
||
})
|
||
setTimeout(() => {
|
||
this.isSubmitting = false
|
||
this.navigateAfterLogin(nextRedirectUrl)
|
||
}, 600)
|
||
},
|
||
handleLogin() {
|
||
if (this.isSubmitting || !this.validateForm()) {
|
||
return
|
||
}
|
||
|
||
this.isSubmitting = true
|
||
uni.showLoading({ title: this.$tr('登录中...') })
|
||
|
||
return this.resolveLoginState()
|
||
.then(loginState => {
|
||
return this.finishLogin(loginState)
|
||
})
|
||
.catch(error => {
|
||
if (error && error.__handled) {
|
||
return
|
||
}
|
||
|
||
console.error('handle login failed', error)
|
||
uni.hideLoading()
|
||
this.isSubmitting = false
|
||
uni.showToast({
|
||
title: this.$tr('登录失败,请稍后重试'),
|
||
icon: 'none'
|
||
})
|
||
})
|
||
},
|
||
goToRegister() {
|
||
uni.navigateTo({
|
||
url: '/pkgAccount/register/register'
|
||
})
|
||
},
|
||
goToRegistrationRecords() {
|
||
uni.navigateTo({
|
||
url: '/pkgAccount/registration_records/registration_records'
|
||
})
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.login-page {
|
||
background: #FFFFFF;
|
||
background-image: radial-gradient(circle at 100% 0%, rgba($brand-primary, 0.15) 0%, transparent 40%),
|
||
radial-gradient(circle at 0% 100%, rgba($brand-primary, 0.05) 0%, transparent 40%);
|
||
min-height: 100vh;
|
||
}
|
||
|
||
.page-header {
|
||
padding-top: 20rpx;
|
||
}
|
||
|
||
.title-text {
|
||
font-size: 64rpx;
|
||
letter-spacing: 2rpx;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.subtitle-text {
|
||
font-size: 28rpx;
|
||
opacity: 0.8;
|
||
}
|
||
|
||
/* 表单区域 */
|
||
.form-label {
|
||
font-size: 30rpx;
|
||
color: #1f1f1f;
|
||
}
|
||
|
||
.input-wrapper {
|
||
display: flex;
|
||
align-items: center;
|
||
background-color: #FFFFFF;
|
||
border-radius: 20rpx;
|
||
border: 1rpx solid rgba($brand-primary, 0.15);
|
||
box-shadow: 0 8rpx 20rpx rgba($brand-primary, 0.05);
|
||
transition: all 0.3s ease;
|
||
padding: 0 30rpx;
|
||
height: 108rpx;
|
||
}
|
||
|
||
.input-wrapper.is-focused {
|
||
border-color: $brand-primary;
|
||
background-color: #FFFFFF;
|
||
box-shadow: 0 4rpx 16rpx rgba($brand-primary, 0.15);
|
||
}
|
||
|
||
.input-icon {
|
||
font-size: 36rpx;
|
||
color: #cccccc;
|
||
margin-right: 20rpx;
|
||
transition: color 0.3s ease;
|
||
}
|
||
|
||
.input-wrapper.is-focused .input-icon {
|
||
color: $brand-primary;
|
||
}
|
||
|
||
.form-input {
|
||
flex: 1;
|
||
height: 100%;
|
||
font-size: 30rpx;
|
||
color: #333333;
|
||
}
|
||
|
||
.input-placeholder {
|
||
color: #bbbbbb;
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
/* 按钮与操作区 */
|
||
.custom-btn {
|
||
width: 100%;
|
||
height: 108rpx;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
border-radius: 999rpx;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.custom-btn:active {
|
||
transform: scale(0.96);
|
||
}
|
||
|
||
.btn-primary {
|
||
background: linear-gradient(135deg, rgba($brand-primary, 0.9) 0%, $brand-primary 100%);
|
||
color: #ffffff;
|
||
box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.25);
|
||
}
|
||
|
||
.btn-disabled {
|
||
opacity: 0.7;
|
||
pointer-events: none;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.secondary-entry-card {
|
||
background: #FFFFFF;
|
||
border-radius: 24rpx;
|
||
padding: 28rpx 32rpx;
|
||
border: 1rpx solid rgba($brand-primary, 0.12);
|
||
box-shadow: 0 8rpx 20rpx rgba($brand-primary, 0.05);
|
||
}
|
||
|
||
.secondary-entry-card:active {
|
||
transform: scale(0.98);
|
||
}
|
||
|
||
.secondary-entry-title {
|
||
font-size: 30rpx;
|
||
font-weight: 700;
|
||
color: #1A2233;
|
||
}
|
||
|
||
.secondary-entry-desc {
|
||
font-size: 24rpx;
|
||
color: #6A7A99;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.secondary-entry-arrow {
|
||
color: $brand-primary;
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.register-link {
|
||
position: relative;
|
||
display: inline-block;
|
||
padding: 10rpx 0;
|
||
}
|
||
|
||
.register-link::after {
|
||
content: '';
|
||
position: absolute;
|
||
bottom: 4rpx;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 4rpx;
|
||
background: $brand-primary;
|
||
border-radius: 2rpx;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
/* 动画 */
|
||
.fade-in {
|
||
animation: fadeIn 0.8s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
|
||
}
|
||
|
||
.fade-in-up {
|
||
animation: fadeInUp 0.8s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
|
||
opacity: 0;
|
||
}
|
||
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
|
||
@keyframes fadeInUp {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(40rpx);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
.spin {
|
||
display: inline-block;
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
100% { transform: rotate(360deg); }
|
||
}
|
||
</style>
|