chore: initial import

This commit is contained in:
liangjian
2026-06-29 09:42:53 +08:00
commit 9fea127de1
241 changed files with 82767 additions and 0 deletions

View File

@@ -0,0 +1,629 @@
<template>
<view class="auth-result-page tn-safe-area-inset-bottom">
<tn-nav-bar :isBack="true" :bottomShadow="false" backgroundColor="transparent">
<view class="tn-text-bold tn-text-xl tn-color-black">{{ t('navTitle') }}</view>
</tn-nav-bar>
<view class="page-content tn-padding" :style="{paddingTop: vuex_custom_bar_height + 'px'}">
<view class="status-card fade-in">
<!-- 状态大图标 -->
<view class="status-icon-wrapper tn-flex tn-flex-row-center tn-flex-col-center" :class="authStatus">
<text v-if="authStatus === 'approved'" class="tn-icon-success-circle-fill status-icon icon-success"></text>
<text v-else-if="authStatus === 'rejected'" class="tn-icon-close-circle-fill status-icon icon-rejected"></text>
<text v-else-if="authStatus === 'pending'" class="tn-icon-time-fill status-icon icon-pending"></text>
<text v-else class="tn-icon-warning-fill status-icon icon-unsubmitted"></text>
</view>
<!-- 状态标题与描述 -->
<view class="tn-text-center tn-margin-top-lg tn-padding-left-lg tn-padding-right-lg">
<view class="card-title">{{ titleText }}</view>
<!-- <view class="card-desc tn-margin-top-sm">{{ descText }}</view> -->
</view>
<!-- <view v-if="applicationCode" class="code-chip tn-margin-top-lg">
申请编号:{{ applicationCode }}
</view> -->
<!-- 审批备注 -->
<view v-if="approvalRemark" class="reason-box tn-margin-top-xl tn-margin-left-lg tn-margin-right-lg" :class="authStatus">
<view class="reason-title tn-flex tn-flex-row-center tn-flex-col-center">
<text :class="remarkIconClass" class="tn-margin-right-xs"></text>{{ approvalRemarkTitle }}
</view>
<view class="reason-content tn-margin-top-sm tn-text-center">{{ approvalRemark }}</view>
</view>
<!-- 提交信息摘要 -->
<view v-if="profileSummary.length" class="summary-box tn-margin-top-xl tn-margin-bottom-lg tn-margin-left-lg tn-margin-right-lg">
<view class="summary-title tn-margin-bottom-sm">{{ t('submittedInfoTitle') }}</view>
<view v-for="item in profileSummary" :key="item.label" class="summary-row tn-flex tn-flex-row-between tn-padding-top-sm tn-padding-bottom-sm">
<text class="summary-label">{{ item.label }}</text>
<text class="summary-value tn-text-ellipsis">{{ item.value }}</text>
</view>
</view>
</view>
<!-- 操作按钮 -->
<view class="action-group tn-margin-top-xl fade-in-up">
<view v-if="authStatus === 'approved'" class="custom-btn btn-primary" @click="goToLogin">
<text class="tn-text-bold">{{ t('goLogin') }}</text>
</view>
<view v-else-if="authStatus === 'rejected'" class="custom-btn btn-primary" @click="resubmit">
<text class="tn-text-bold">{{ t('resubmitRegister') }}</text>
</view>
<view v-else class="custom-btn btn-dark" @click="refreshStatus">
<text class="tn-text-bold tn-flex tn-flex-row-center tn-flex-col-center">
<text v-if="loading" class="tn-icon-loading tn-margin-right-xs spin"></text>
{{ loading ? t('refreshing') : t('refreshStatus') }}
</text>
</view>
<view class="custom-btn btn-light tn-margin-top" @click="goToRegistrationRecords">
<text class="tn-text-bold">{{ t('viewRegistrationRecords') }}</text>
</view>
</view>
</view>
</view>
</template>
<script>
import { saveRegisteredUser } from '../../utils/app-state'
import { registrationApplicationMePage, registrationMyorder } from '@/api/index.js'
import { fetchEnums, getApprovalStatusLabel } from '../../utils/enum-store'
import { buildUserFromRegistrationApplication, mapRegistrationApprovalToAuthStatus, resolveRegistrationApplicationCode } from '../../utils/flow'
function formatPageResponse(response) {
const payload = response && response.data ? response.data : response
return payload && typeof payload === 'object' ? payload : {}
}
export default {
data() {
return {
loading: false,
applicationInfo: null,
targetCode: '',
sourceType: '',
i18n: {
zh: {
navTitle: '注册申请结果',
submittedInfoTitle: '已提交信息',
goLogin: '去登录',
resubmitRegister: '重新提交注册申请',
refreshing: '刷新中...',
refreshStatus: '刷新审批状态',
viewRegistrationRecords: '查看注册记录',
unsubmitted: '未提交',
titleUnsubmitted: '您还未提交注册申请',
titlePending: '注册申请已提交',
titleApproved: '注册审批已通过',
titleRejected: '注册审批未通过',
descUnsubmitted: '请先提交注册申请单,审批通过后才算注册成功。',
descPendingRecords: '当前展示的是该申请单的审批状态,可下拉或点击按钮刷新最新结果。',
descPendingDefault: '当前申请正在审批中,审批通过前无法登录。',
descApproved: '您的注册申请已审批通过,现在可以前往登录。',
descRejectedRecords: '当前展示的是该申请单的审批结果,请根据驳回原因修改信息后重新提交注册申请。',
descRejectedDefault: '请根据驳回原因修改信息后重新提交注册申请。',
remarkApproved: '通过备注',
remarkRejected: '驳回原因',
remarkPending: '审批备注',
roleForeign: '外籍人员',
roleDomestic: '中国籍',
labelIdentityType: '人员类型',
labelName: '姓名',
labelAccount: '注册账号',
labelCertificateNo: '证件号',
fallbackNone: '-',
latestDetailMissing: '未查到最新详情,已展示列表记录',
approvedPleaseLogin: '审批已通过,请登录',
fetchStatusFailed: '获取审批状态失败',
statusPending: '审核中',
statusApproved: '已通过',
statusRejected: '已驳回'
},
en: {
navTitle: 'Registration Result',
submittedInfoTitle: 'Submitted Information',
goLogin: 'Sign In',
resubmitRegister: 'Submit Registration Again',
refreshing: 'Refreshing...',
refreshStatus: 'Refresh Status',
viewRegistrationRecords: 'View Registration Records',
unsubmitted: 'Not Submitted',
titleUnsubmitted: 'No registration application submitted yet',
titlePending: 'Registration submitted',
titleApproved: 'Registration approved',
titleRejected: 'Registration was not approved',
descUnsubmitted: 'Please submit a registration application first. Registration is completed only after approval.',
descPendingRecords: 'This page shows the approval status of the selected application. Pull down or tap the button to refresh.',
descPendingDefault: 'Your application is under review and sign-in is unavailable before approval.',
descApproved: 'Your registration is approved and you can sign in now.',
descRejectedRecords: 'This page shows the approval result of the selected application. Please update the information and resubmit based on the rejection reason.',
descRejectedDefault: 'Please update the information and resubmit based on the rejection reason.',
remarkApproved: 'Approval Note',
remarkRejected: 'Rejection Reason',
remarkPending: 'Approval Note',
roleForeign: 'Foreign',
roleDomestic: 'Chinese',
labelIdentityType: 'Identity Type',
labelName: 'Name',
labelAccount: 'Account',
labelCertificateNo: 'Certificate No.',
fallbackNone: '-',
latestDetailMissing: 'Latest details are unavailable. Showing the list record instead.',
approvedPleaseLogin: 'Approved. Please sign in.',
fetchStatusFailed: 'Failed to fetch approval status',
statusPending: 'Under Review',
statusApproved: 'Approved',
statusRejected: 'Rejected'
}
}
}
},
computed: {
language() {
return this.$getLanguage()
},
authStatus() {
const status = this.applicationInfo ? this.applicationInfo.approvalStatus : undefined
return mapRegistrationApprovalToAuthStatus(status)
},
applicationCode() {
return this.targetCode || resolveRegistrationApplicationCode(this.applicationInfo) || ''
},
approvalStatusText() {
const status = this.applicationInfo ? this.applicationInfo.approvalStatus : undefined
if (typeof status === 'number') {
return this.getApprovalStatusLabelLocal(status)
}
return this.t('unsubmitted')
},
titleText() {
const map = {
unsubmitted: this.t('titleUnsubmitted'),
pending: this.t('titlePending'),
approved: this.t('titleApproved'),
rejected: this.t('titleRejected')
}
return map[this.authStatus] || map.unsubmitted
},
descText() {
const map = {
unsubmitted: this.t('descUnsubmitted'),
pending: this.sourceType === 'registration-records'
? this.t('descPendingRecords')
: this.t('descPendingDefault'),
approved: this.t('descApproved'),
rejected: this.sourceType === 'registration-records'
? this.t('descRejectedRecords')
: this.t('descRejectedDefault')
}
return map[this.authStatus] || map.unsubmitted
},
approvalRemarkTitle() {
const map = {
approved: this.t('remarkApproved'),
rejected: this.t('remarkRejected'),
pending: this.t('remarkPending'),
unsubmitted: this.t('remarkPending')
}
return map[this.authStatus] || map.pending
},
approvalRemark() {
return this.applicationInfo && this.applicationInfo.approveReason
? this.applicationInfo.approveReason
: ''
},
remarkIconClass() {
const map = {
approved: 'tn-icon-success',
rejected: 'tn-icon-warning',
pending: 'tn-icon-time',
unsubmitted: 'tn-icon-warning'
}
return map[this.authStatus] || map.pending
},
profileSummary() {
const profile = this.applicationInfo || {}
const identityType = profile.passportNumber ? 'foreign' : 'domestic'
const roleText = identityType === 'foreign' ? this.t('roleForeign') : identityType === 'domestic' ? this.t('roleDomestic') : this.t('fallbackNone')
const certificateNo = profile.passportNumber || profile.idCard || this.t('fallbackNone')
return [
{ label: this.t('labelIdentityType'), value: roleText },
{ label: this.t('labelName'), value: profile.name || this.t('fallbackNone') },
{ label: this.t('labelAccount'), value: profile.account || this.t('fallbackNone') },
{ label: this.t('labelCertificateNo'), value: certificateNo },
// { label: '国籍', value: profile.country || profile.nationality || '-' },
// { label: '工作单位', value: profile.workUnit || '-' },
// { label: '兴趣爱好', value: hobbyText }
]
}
},
watch: {
language() {
this.refreshStatus()
}
},
onLoad(options = {}) {
this.sourceType = options.from || ''
this.targetCode = decodeURIComponent(options.code || '').trim()
const payloadText = options.payload ? decodeURIComponent(options.payload) : ''
if (!payloadText) {
return
}
try {
const payload = JSON.parse(payloadText)
if (payload && typeof payload === 'object') {
this.applicationInfo = payload
if (!this.targetCode) {
this.targetCode = resolveRegistrationApplicationCode(payload)
}
}
} catch (error) {
console.error('parse auth result payload failed', error)
}
},
async onShow() {
await fetchEnums(false, this.language)
this.refreshStatus()
},
methods: {
t(key) {
return this.$tm('pkgAccount.auth_result.auth_result', key)
},
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 fetchApplicationByCode(providerValue, applicationCode) {
if (!providerValue || !applicationCode) {
return null
}
const response = await registrationApplicationMePage({
ProviderValue: providerValue,
Search: applicationCode,
PageIndex: 1,
PageSize: 20
}, this.language)
const pageData = formatPageResponse(response)
const list = Array.isArray(pageData.data) ? pageData.data : []
return list.find(item => resolveRegistrationApplicationCode(item) === applicationCode) || list[0] || null
},
async refreshStatus() {
this.loading = true
try {
const providerValue = await this.getWechatCode()
const applicationCode = this.applicationCode
let target = null
if (applicationCode) {
target = await this.fetchApplicationByCode(providerValue || '', applicationCode)
} else {
const response = await registrationMyorder({
providerValue: providerValue || ''
}, this.language)
const rawTarget = response && response.data && typeof response.data === 'object' ? response.data : response
target = rawTarget.data !== undefined ? rawTarget.data : rawTarget
}
if (!target || typeof target !== 'object' || !Object.keys(target).length) {
if (this.applicationInfo && this.applicationCode) {
uni.showToast({
title: this.t('latestDetailMissing'),
icon: 'none'
})
return
}
this.applicationInfo = {
approvalStatus: 1
}
uni.showToast({
title: this.t('approvedPleaseLogin'),
icon: 'success'
})
setTimeout(() => {
this.goToLogin()
}, 800)
return
}
this.applicationInfo = target
this.targetCode = resolveRegistrationApplicationCode(target) || this.targetCode
if (target.approvalStatus === 1) {
const approvedUser = buildUserFromRegistrationApplication(target, {
password: ''
})
saveRegisteredUser(approvedUser)
}
} catch (error) {
console.error('refresh registration status failed', error)
uni.showToast({
title: this.t('fetchStatusFailed'),
icon: 'none'
})
} finally {
this.loading = false
}
},
getApprovalStatusLabelLocal(status) {
return getApprovalStatusLabel(
status,
this.authStatus === 'pending'
? this.t('statusPending')
: this.authStatus === 'approved'
? this.t('statusApproved')
: this.authStatus === 'rejected'
? this.t('statusRejected')
: this.t('unsubmitted'),
this.language
)
},
goToLogin() {
uni.reLaunch({
url: '/pkgAccount/login/login?bypass=1'
})
},
resubmit() {
uni.redirectTo({
url: '/pkgAccount/register/register?bypass=1'
})
},
goToRegistrationRecords() {
uni.navigateTo({
url: '/pkgAccount/registration_records/registration_records'
})
},
}
}
</script>
<style lang="scss" scoped>
.auth-result-page {
/* 白蓝主题渐变背景 */
background: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%);
min-height: 100vh;
}
.status-card {
background: #ffffff;
border-radius: 24rpx;
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.04);
border: 1rpx solid rgba($brand-primary, 0.05);
position: relative;
z-index: 1;
margin-top: 120rpx;
padding-bottom: 40rpx;
}
.status-icon-wrapper {
width: 140rpx;
height: 140rpx;
margin: 0 auto;
border-radius: 50%;
margin-top: -70rpx;
background: #ffffff;
box-shadow: 0 8rpx 24rpx rgba($brand-primary, 0.08);
}
.status-icon-wrapper.approved {
background: rgba($brand-primary, 0.1);
}
.status-icon-wrapper.rejected {
background: rgba(239, 68, 68, 0.1);
}
.status-icon-wrapper.pending,
.status-icon-wrapper.unsubmitted {
background: rgba(245, 158, 11, 0.12);
}
.status-icon {
font-size: 80rpx;
}
.icon-success {
color: $brand-primary;
}
.icon-rejected {
color: #cf3c3c;
}
.icon-pending,
.icon-unsubmitted {
color: #c98512;
}
.card-title {
font-size: 44rpx;
font-weight: bold;
color: #1f1f1f;
}
.card-desc {
font-size: 28rpx;
color: #888888;
line-height: 1.6;
}
.code-chip {
width: fit-content;
max-width: calc(100% - 64rpx);
margin-left: auto;
margin-right: auto;
padding: 12rpx 24rpx;
border-radius: 999rpx;
background: rgba($brand-primary, 0.08);
color: $brand-primary;
font-size: 24rpx;
font-weight: 600;
}
.reason-box {
padding: 30rpx;
border-radius: 20rpx;
}
.reason-box.approved {
background: rgba($brand-primary, 0.05);
border: 1rpx solid rgba($brand-primary, 0.1);
}
.reason-box.approved .reason-title,
.reason-box.approved .reason-content {
color: $brand-primary;
}
.reason-box.rejected {
background: rgba(239, 68, 68, 0.05);
border: 1rpx solid rgba(239, 68, 68, 0.1);
}
.reason-box.rejected .reason-title,
.reason-box.rejected .reason-content {
color: #ef4444;
}
.reason-box.pending,
.reason-box.unsubmitted {
background: rgba(245, 158, 11, 0.05);
border: 1rpx solid rgba(245, 158, 11, 0.1);
}
.reason-box.pending .reason-title,
.reason-box.pending .reason-content,
.reason-box.unsubmitted .reason-title,
.reason-box.unsubmitted .reason-content {
color: #f59e0b;
}
.reason-title {
font-size: 28rpx;
font-weight: bold;
}
.reason-content {
font-size: 26rpx;
line-height: 1.6;
}
.summary-box {
padding: 30rpx;
background: rgba($brand-primary, 0.02);
border-radius: 20rpx;
border: 1rpx solid rgba($brand-primary, 0.1);
}
.summary-title {
font-size: 30rpx;
font-weight: bold;
color: #1A2233;
}
.summary-row {
border-bottom: 1rpx dashed rgba($brand-primary, 0.15);
}
.summary-row:last-child {
border-bottom: none;
}
.summary-label {
font-size: 28rpx;
color: #6A7A99;
}
.summary-value {
font-size: 28rpx;
color: #1A2233;
font-weight: 500;
max-width: 65%;
text-align: right;
}
.custom-btn {
width: 100%;
height: 96rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999rpx;
font-size: 32rpx;
transition: all 0.3s;
}
.custom-btn:active {
transform: scale(0.98);
}
.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-dark {
background: #ffffff;
color: $brand-primary;
border: 1rpx solid rgba($brand-primary, 0.3);
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
}
.btn-light {
background: #ffffff;
color: #1A2233;
border: 1rpx solid rgba($brand-primary, 0.15);
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.04);
}
/* 动画效果 */
.fade-in {
animation: fadeIn 0.6s ease-out forwards;
}
.fade-in-up {
animation: fadeInUp 0.6s ease-out forwards;
animation-delay: 0.2s;
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>

577
pkgAccount/login/login.vue Normal file
View 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>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,480 @@
<template>
<view class="registration-records-page tn-safe-area-inset-bottom">
<tn-nav-bar :isBack="true" :bottomShadow="false" backgroundColor="transparent">
<view class="tn-text-bold tn-text-xl tn-color-black">{{ t('navTitle') }}</view>
</tn-nav-bar>
<view class="page-main tn-padding" :style="{ paddingTop: vuex_custom_bar_height + 'px', paddingBottom: '48rpx' }">
<scroll-view class="filter-scroll" scroll-x :show-scrollbar="false">
<view class="filter-row tn-margin-bottom-lg">
<view
v-for="item in statusOptions"
:key="item.value"
class="filter-chip"
:class="{ 'filter-chip--active': approvalStatus === item.value }"
@click="changeStatus(item.value)"
>
{{ item.label }}
</view>
</view>
</scroll-view>
<view v-if="providerUnavailable" class="empty-panel tn-padding-lg">
<view class="empty-title">{{ t('providerUnavailableTitle') }}</view>
<view class="empty-desc tn-margin-top-sm">{{ t('providerUnavailableDesc') }}</view>
</view>
<view v-else-if="list.length">
<view
v-for="item in list"
:key="getRecordKey(item)"
class="record-card tn-margin-bottom-lg"
@click="goToRecordDetail(item)"
>
<view class="card-head tn-flex tn-flex-row-between tn-flex-col-center">
<view class="card-title-wrap">
<view class="card-title">{{ item.name || item.account || t('registrationFallbackTitle') }}</view>
<view class="card-code tn-margin-top-xs">{{ item.code || '--' }}</view>
</view>
<view :class="['status-badge', 'status-' + resolveStatusType(item.approvalStatus)]">
{{ getDisplayStatusText(item) }}
</view>
</view>
<view class="card-body tn-margin-top">
<view class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ t('accountLabel') }}</text>
<text class="info-value">{{ item.account || '--' }}</text>
</view>
<view class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ t('phoneLabel') }}</text>
<text class="info-value">{{ item.phone || '--' }}</text>
</view>
<view class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ t('idNumberLabel') }}</text>
<text class="info-value">{{ item.idCard || item.passportNumber || '--' }}</text>
</view>
<view class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ t('submittedAtLabel') }}</text>
<text class="info-value">{{ formatDateTime(item.createTime) }}</text>
</view>
<view v-if="item.approveTime" class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ t('approvedAtLabel') }}</text>
<text class="info-value">{{ formatDateTime(item.approveTime) }}</text>
</view>
<view v-if="item.approveReason" class="remark-panel tn-margin-top">
<view class="remark-label">{{ item.approvalStatus === -1 ? t('rejectReasonLabel') : t('approvalRemarkLabel') }}</view>
<view class="remark-text tn-margin-top-xs">{{ item.approveReason }}</view>
</view>
</view>
</view>
<view class="load-state">
<text v-if="loadingMore">{{ t('loadingMore') }}</text>
<text v-else-if="!hasMore">{{ t('noMore') }}</text>
<text v-else>{{ t('pullUpMore') }}</text>
</view>
</view>
<view v-else-if="!loading" class="empty-panel tn-padding-lg">
<view class="empty-title">{{ t('emptyTitle') }}</view>
<view class="empty-desc tn-margin-top-sm">{{ t('emptyDesc') }}</view>
<view class="action-btn tn-margin-top-lg" @click="goToRegister">
<text class="tn-text-bold">{{ t('goRegister') }}</text>
</view>
</view>
<view v-if="loading && !list.length && !providerUnavailable" class="empty-panel tn-padding-lg">
<view class="empty-title">{{ t('loadingTitle') }}</view>
<view class="empty-desc tn-margin-top-sm">{{ t('loadingDesc') }}</view>
</view>
</view>
</view>
</template>
<script>
import { registrationApplicationMePage } from '@/api/index.js'
function formatPageResponse(response) {
const payload = response && response.data ? response.data : response
return payload && typeof payload === 'object' ? payload : {}
}
function normalizeRecordList(list = []) {
if (!Array.isArray(list)) {
return []
}
return list.filter(item => item && typeof item === 'object')
}
export default {
data() {
return {
providerValue: '',
providerUnavailable: false,
approvalStatus: '',
pageIndex: 1,
pageSize: 10,
total: 0,
list: [],
loading: false,
loadingMore: false,
hasMore: true,
statusOptions: []
}
},
created() {
this.syncStatusOptions()
this.syncNavigationTitle()
},
onShow() {
this.syncNavigationTitle()
this.reloadList()
},
onPullDownRefresh() {
this.reloadList().finally(() => {
uni.stopPullDownRefresh()
})
},
onReachBottom() {
this.loadMore()
},
computed: {
language() {
return this.$getLanguage()
}
},
watch: {
language() {
this.syncNavigationTitle()
this.syncStatusOptions()
this.reloadList()
}
},
methods: {
t(key, params) {
return this.$tm('pkgAccount.registration_records', key, params)
},
syncStatusOptions() {
this.statusOptions = [
{ label: this.t('statusAll'), value: '' },
{ label: this.t('statusPending'), value: 0 },
{ label: this.t('statusApproved'), value: 1 },
{ label: this.t('statusRejected'), value: -1 }
]
},
syncNavigationTitle() {
this.$syncNavigationTitle()
},
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
})
},
getStatusText(status) {
const map = {
0: this.t('statusPending'),
1: this.t('statusApproved'),
'-1': this.t('statusRejected')
}
return map[status] || this.t('statusProcessing')
},
getDisplayStatusText(item = {}) {
if (this.language !== 'en' && item.approvalStatusDesc) {
return item.approvalStatusDesc
}
return this.getStatusText(item.approvalStatus)
},
resolveStatusType(status) {
if (Number(status) === 1) {
return 'approved'
}
if (Number(status) === -1) {
return 'rejected'
}
return 'pending'
},
formatDateTime(value) {
if (!value) {
return '--'
}
const text = String(value).replace('T', ' ')
return text.length > 16 ? text.slice(0, 16) : text
},
getRecordKey(item = {}) {
return item.code || `${item.account || 'registration'}-${item.createTime || ''}`
},
buildRecordRoutePayload(item = {}) {
return {
code: item.code || '',
account: item.account || '',
name: item.name || '',
gender: typeof item.gender === 'number' ? item.gender : null,
genderDesc: item.genderDesc || '',
email: item.email || '',
phone: item.phone || '',
country: item.country || '',
idCard: item.idCard || '',
workUnit: item.workUnit || '',
passportNumber: item.passportNumber || '',
school: item.school || '',
hobbyTags: item.hobbyTags || [],
approvalStatus: typeof item.approvalStatus === 'number' ? item.approvalStatus : undefined,
approvalStatusDesc: item.approvalStatusDesc || '',
approveReason: item.approveReason || '',
approveTime: item.approveTime || '',
createTime: item.createTime || ''
}
},
async ensureProviderValue() {
const providerValue = await this.getWechatCode()
this.providerValue = providerValue || ''
this.providerUnavailable = !this.providerValue
return this.providerValue
},
async reloadList() {
this.pageIndex = 1
this.hasMore = true
this.list = []
const providerValue = await this.ensureProviderValue()
if (!providerValue) {
this.total = 0
return
}
await this.fetchList(true)
},
async loadMore() {
if (!this.hasMore || this.loading || this.loadingMore || this.providerUnavailable) {
return
}
this.pageIndex += 1
await this.fetchList(false)
},
async fetchList(isReload = false) {
if (!this.providerValue) {
return
}
if (isReload) {
this.loading = true
} else {
this.loadingMore = true
}
const params = {
ProviderValue: this.providerValue,
PageIndex: this.pageIndex,
PageSize: this.pageSize
}
if (this.approvalStatus !== '' && this.approvalStatus !== null && typeof this.approvalStatus !== 'undefined') {
params.ApprovalStatus = this.approvalStatus
}
await registrationApplicationMePage(params, this.language)
.then((response) => {
const pageData = formatPageResponse(response)
const nextList = normalizeRecordList(pageData.data || [])
const total = Number(pageData.total || 0)
this.total = total
this.list = isReload ? nextList : this.list.concat(nextList)
this.hasMore = this.list.length < total && nextList.length > 0
})
.catch((error) => {
if (!isReload) {
this.pageIndex = Math.max(1, this.pageIndex - 1)
}
uni.showToast({
title: (error && (error.message || error.msg)) || this.t('loadError'),
icon: 'none'
})
})
.finally(() => {
this.loading = false
this.loadingMore = false
})
},
changeStatus(value) {
if (this.approvalStatus === value) {
return
}
this.approvalStatus = value
this.reloadList()
},
goToRecordDetail(item = {}) {
const code = item.code || ''
const payload = this.buildRecordRoutePayload(item)
uni.navigateTo({
url: `/pkgAccount/auth_result/auth_result?from=registration-records&code=${encodeURIComponent(code)}&payload=${encodeURIComponent(JSON.stringify(payload))}`
})
},
goToRegister() {
uni.navigateTo({
url: '/pkgAccount/register/register'
})
}
}
}
</script>
<style scoped lang="scss">
.registration-records-page {
min-height: 100vh;
background: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%);
}
.record-card,
.empty-panel {
background: #FFFFFF;
border-radius: 24rpx;
padding: 32rpx;
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.04);
border: 1px solid rgba($brand-primary, 0.05);
}
.record-card:active {
transform: scale(0.99);
}
.empty-title,
.card-title {
font-size: 32rpx;
font-weight: 700;
color: #1A2233;
}
.empty-desc,
.card-code,
.info-label,
.remark-label,
.load-state {
color: #6A7A99;
}
.filter-scroll {
width: 100%;
white-space: nowrap;
}
.filter-row {
display: inline-flex;
flex-wrap: nowrap;
gap: 16rpx;
padding: 4rpx;
}
.filter-chip {
flex-shrink: 0;
padding: 14rpx 28rpx;
border-radius: 999rpx;
background: #FFFFFF;
border: 1rpx solid rgba($brand-primary, 0.15);
color: #6A7A99;
font-size: 24rpx;
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
transition: all 0.2s;
}
.filter-chip--active {
background: #FFFFFF;
border-color: $brand-primary;
color: $brand-primary;
font-weight: bold;
box-shadow: 0 4rpx 16rpx rgba($brand-primary, 0.1);
}
.card-code {
font-size: 22rpx;
}
.status-badge {
padding: 8rpx 20rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 700;
}
.status-pending {
background: rgba(245, 158, 11, 0.12);
color: #B45309;
}
.status-approved {
background: rgba($brand-primary, 0.12);
color: $brand-primary;
}
.status-rejected {
background: rgba(239, 68, 68, 0.12);
color: #B91C1C;
}
.info-row {
padding: 16rpx 0;
border-bottom: 1px dashed rgba($brand-primary, 0.15);
}
.info-row:last-child {
border-bottom: none;
}
.info-value {
max-width: 62%;
text-align: right;
color: #1A2233;
font-weight: 500;
}
.remark-panel {
padding: 24rpx;
border-radius: 20rpx;
background: rgba($brand-primary, 0.02);
border: 1rpx solid rgba($brand-primary, 0.1);
}
.remark-text {
color: #1A2233;
line-height: 1.6;
}
.action-btn {
width: 100%;
height: 96rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999rpx;
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);
}
.load-state {
text-align: center;
font-size: 24rpx;
padding: 12rpx 0 24rpx;
}
::-webkit-scrollbar {
display: none;
width: 0 !important;
height: 0 !important;
-webkit-appearance: none;
background: transparent;
}
</style>