chore: initial import
This commit is contained in:
577
pkgAccount/login/login.vue
Normal file
577
pkgAccount/login/login.vue
Normal file
@@ -0,0 +1,577 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user