Files
QSY-lvjuxcx/pkgAccount/login/login.vue
liangjian 1111ac148b feat: 重构核心租住流程页面与交互体验
统一注册、看房、申请与入住等核心页面的视觉和流程衔接,补齐支付、签约与状态流转闭环,提升移动端操作连贯性。
2026-07-29 09:05:38 +08:00

605 lines
17 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="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>