Files
QSY-lvjuxcx/pkgAccount/login/login.vue
2026-06-29 09:42:53 +08:00

578 lines
16 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">{{ t('pageTitle') }}</view>
<view class="tn-color-gray tn-text-sm tn-margin-top-sm subtitle-text">{{ t('pageDesc') }}</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">{{ t('accountLabel') }}</view>
<view class="input-wrapper" :class="{ 'is-focused': accountFocused }">
<text class="tn-icon-my input-icon"></text>
<input
class="form-input"
:placeholder="t('accountPlaceholder')"
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">{{ t('passwordLabel') }}</view>
<view class="input-wrapper" :class="{ 'is-focused': passwordFocused }">
<text class="tn-icon-lock input-icon"></text>
<input
class="form-input"
type="password"
:placeholder="t('passwordPlaceholder')"
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 ? t('submitting') : t('submitLogin') }}</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">{{ t('viewRegistrationRecords') }}</view>
<view class="secondary-entry-desc tn-margin-top-xs">{{ t('viewRegistrationRecordsDesc') }}</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">{{ t('noAccount') }}</view>
<view class="tn-color-black tn-text-bold tn-text-sm tn-margin-left-xs register-link" @click="goToRegister">{{ t('goRegister') }}</view>
</view>
</view>
</view>
</view>
</template>
<script>
import { getRegisteredUser, saveRegisteredUser, startAuthorizedLoginSession } from '../../utils/app-state'
import { mergeBackendUserInfo, normalizeUser, resolveRegistrationApplicationCode } from '../../utils/flow'
import {
extractLoginSession,
matchRegisteredUser,
pickLatestRegistration,
resolveLoginPreparation
} from '../../utils/login-helper'
import { registrationMyorder, userMe, usersLogin } from '@/api/index.js'
export default {
data() {
return {
isSubmitting: false,
redirectUrl: '',
accountFocused: false,
passwordFocused: false,
form: {
account: '',
password: ''
},
i18n: {
zh: {
pageTitle: '欢迎回来',
pageDesc: '登录绿居公寓,探索您的国际化青年社区',
accountLabel: '账号 / 邮箱',
accountPlaceholder: '请输入账号或邮箱',
passwordLabel: '密码',
passwordPlaceholder: '请输入密码',
submitting: '登录中...',
submitLogin: '立即登录',
viewRegistrationRecords: '查看注册记录',
viewRegistrationRecordsDesc: '未登录也可查看当前微信账号提交的注册申请记录',
noAccount: '还没有账号?',
goRegister: '去注册',
loading: '加载中...',
validateRequired: '请输入账号和密码',
loginSuccess: '登录成功',
loginFailed: '登录失败,请稍后重试'
},
en: {
pageTitle: 'Welcome Back',
pageDesc: 'Sign in to Luju Apartments and explore your international youth community.',
accountLabel: 'Account / Email',
accountPlaceholder: 'Enter account or email',
passwordLabel: 'Password',
passwordPlaceholder: 'Enter password',
submitting: 'Signing In...',
submitLogin: 'Sign In',
viewRegistrationRecords: 'View Registration Records',
viewRegistrationRecordsDesc: 'You can review registration records submitted by this Wechat account even without signing in.',
noAccount: 'No account yet?',
goRegister: 'Register',
loading: 'Loading...',
validateRequired: 'Please enter both account and password',
loginSuccess: 'Signed in successfully',
loginFailed: 'Sign-in failed, please try again later'
}
}
}
},
async onLoad(options) {
this.redirectUrl = this.normalizeRedirectUrl(options && options.redirect)
const bypass = options && options.bypass === '1'
await this.checkPendingRegistration(bypass)
},
methods: {
t(key) {
return this.$tm('pkgAccount.login.login', key)
},
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() {
const 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.t('loading') })
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.t('validateRequired'),
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())
},
fetchUserInfoAfterLogin() {
return userMe({}, this.$getLanguage())
.then(userInfoRes => {
return userInfoRes && userInfoRes.data ? userInfoRes.data : userInfoRes
})
.catch(error => {
console.error('fetch userinfo failed', error)
return null
})
},
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 } = await this.loginByApi()
const latestRegistration = await this.fetchLatestRegistration()
const loginPreparation = resolveLoginPreparation({
language: this.$getLanguage(),
matchedLocalUser,
latestRegistration,
account: this.form.account,
password: this.form.password
})
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)
const backendUserInfo = await this.fetchUserInfoAfterLogin()
const finalUser = mergeBackendUserInfo(sessionUser, backendUserInfo)
saveRegisteredUser(finalUser)
startAuthorizedLoginSession(finalUser, token, expires)
uni.hideLoading()
uni.showToast({
title: this.t('loginSuccess'),
icon: 'success'
})
setTimeout(() => {
this.isSubmitting = false
this.navigateAfterLogin()
}, 600)
},
handleLogin() {
if (this.isSubmitting || !this.validateForm()) {
return
}
this.isSubmitting = true
uni.showLoading({ title: this.t('submitting') })
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.t('loginFailed'),
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>