将组件级 i18n 对象重构为统一由全局 locales/messages.js 管理,确保中英文语言切换时正确显示对应文本。 - 新增 utils/i18n.js 国际化工具函数 - 更新 store/$t.mixin.js 翻译混入逻辑 - 迁移 pkgService、pkgBooking、pkgAccount、pkgExplore、pages 等共 58 个组件的中英文文本至全局消息表
550 lines
15 KiB
Vue
550 lines
15 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, resolveRegistrationApplicationCode } from '../../utils/flow'
|
|
import {
|
|
extractLoginSession,
|
|
matchRegisteredUser,
|
|
pickLatestRegistration,
|
|
resolveLoginPreparation
|
|
} from '../../utils/login-helper'
|
|
import { resolveBookingRedirectAfterLogin } from '../../utils/booking-entry'
|
|
import { syncLoginStayState } from '../../utils/session-sync'
|
|
import { registrationMyorder, 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())
|
|
},
|
|
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)
|
|
let nextRedirectUrl = this.redirectUrl
|
|
|
|
try {
|
|
const stayState = await syncLoginStayState({
|
|
language: this.$getLanguage(),
|
|
force: true
|
|
})
|
|
nextRedirectUrl = resolveBookingRedirectAfterLogin(this.redirectUrl, stayState)
|
|
} catch (error) {
|
|
console.error('sync stay state 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>
|