feat: 登录注册流程重构 - 登录页+完善信息页+401跳转

- 新增 profile/login.vue 登录页(UnauthLoginPanel)
- 新增 profile/setup.vue 完善信息页(头像/昵称/手机号)
- profile/index 未登录态显示头像占位+点击登录+菜单列表
- 登录后始终跳转setup页,completeLogin后才持久化token
- auth.js 登录后用pendingToken(内存),不检查profile_completed
- request.js 401时reLaunch到登录页,不显示toast,防重复跳转
- 约课页移除UnauthLoginPanel,未登录直接显示列表
- session.js 新增needsSetup状态和completeLogin方法
This commit is contained in:
gqt
2026-07-13 21:09:07 +08:00
parent b06cf69942
commit 5487ece142
9 changed files with 489 additions and 79 deletions
@@ -10,6 +10,7 @@ const toMember = (backendMember = {}) => ({
avatar_url: backendMember.avatar_url || '', avatar_url: backendMember.avatar_url || '',
balance: backendMember.balance || '0.00', balance: backendMember.balance || '0.00',
is_active: backendMember.is_active === true, is_active: backendMember.is_active === true,
profile_completed: backendMember.profile_completed === true,
created_at: backendMember.created_at || '' created_at: backendMember.created_at || ''
}) })
@@ -33,7 +34,7 @@ export const authApi = {
} }
}).then(toWechatLogin) }).then(toWechatLogin)
authStorage.setToken(loginData.access_token) authStorage.setPendingToken(loginData.access_token)
return loginData return loginData
}, },
logout() { logout() {
@@ -10,6 +10,7 @@ const toMember = (backendMember = {}) => ({
avatar_url: backendMember.avatar_url || '', avatar_url: backendMember.avatar_url || '',
balance: backendMember.balance || '0.00', balance: backendMember.balance || '0.00',
is_active: backendMember.is_active === true, is_active: backendMember.is_active === true,
profile_completed: backendMember.profile_completed === true,
created_at: backendMember.created_at || '' created_at: backendMember.created_at || ''
}) })
@@ -2,6 +2,8 @@ const KEY_TOKEN = 'COMPREHENSIVE_VENUE_TOKEN'
const API_BASE_URL = import.meta.env.VITE_COMPREHENSIVE_API_BASE_URL || 'http://127.0.0.1:8001' const API_BASE_URL = import.meta.env.VITE_COMPREHENSIVE_API_BASE_URL || 'http://127.0.0.1:8001'
let pendingToken = null
const normalizeError = (res, fallbackMessage = '请求服务失败') => { const normalizeError = (res, fallbackMessage = '请求服务失败') => {
const body = res?.data || res || {} const body = res?.data || res || {}
return { return {
@@ -19,16 +21,41 @@ export const authStorage = {
getToken() { getToken() {
return uni.getStorageSync(KEY_TOKEN) return uni.getStorageSync(KEY_TOKEN)
}, },
setPendingToken(token) {
pendingToken = token
},
getPendingToken() {
return pendingToken
},
clearPendingToken() {
pendingToken = null
},
clear() { clear() {
uni.removeStorageSync(KEY_TOKEN) uni.removeStorageSync(KEY_TOKEN)
pendingToken = null
}, },
isLoggedIn() { isLoggedIn() {
return !!uni.getStorageSync(KEY_TOKEN) return !!uni.getStorageSync(KEY_TOKEN)
} }
} }
let isRedirecting = false
function redirectToLogin() {
if (isRedirecting) return
const pages = getCurrentPages()
const current = pages[pages.length - 1]
const currentRoute = current?.route || ''
if (currentRoute === 'pages/comprehensive/profile/login' || currentRoute === 'pages/comprehensive/profile/setup') {
return
}
isRedirecting = true
authStorage.clear()
uni.reLaunch({ url: '/pages/comprehensive/profile/login', complete: () => { isRedirecting = false } })
}
export function request(options = {}) { export function request(options = {}) {
const token = authStorage.getToken() const token = authStorage.getToken() || authStorage.getPendingToken()
const header = { const header = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(options.header || {}) ...(options.header || {})
@@ -48,7 +75,9 @@ export function request(options = {}) {
const body = res.data const body = res.data
if (res.statusCode === 401 || body?.code === 40001) { if (res.statusCode === 401 || body?.code === 40001) {
authStorage.clear() redirectToLogin()
reject(normalizeError(res, ''))
return
} }
if (res.statusCode >= 200 && res.statusCode < 300 && body?.code === 0) { if (res.statusCode >= 200 && res.statusCode < 300 && body?.code === 0) {
@@ -76,7 +105,7 @@ export function request(options = {}) {
} }
export function uploadFile(options = {}) { export function uploadFile(options = {}) {
const token = authStorage.getToken() const token = authStorage.getToken() || authStorage.getPendingToken()
const header = { const header = {
...(options.header || {}) ...(options.header || {})
} }
@@ -101,7 +130,9 @@ export function uploadFile(options = {}) {
} }
if (res.statusCode === 401 || body?.code === 40001) { if (res.statusCode === 401 || body?.code === 40001) {
authStorage.clear() redirectToLogin()
reject(normalizeError({ ...res, data: body }, ''))
return
} }
if (res.statusCode >= 200 && res.statusCode < 300 && body?.code === 0) { if (res.statusCode >= 200 && res.statusCode < 300 && body?.code === 0) {
@@ -6,6 +6,7 @@ export const useComprehensiveSessionStore = defineStore('comprehensiveVenueSessi
state: () => ({ state: () => ({
isReady: false, isReady: false,
isLoggedIn: false, isLoggedIn: false,
needsSetup: false,
member: null member: null
}), }),
actions: { actions: {
@@ -28,12 +29,23 @@ export const useComprehensiveSessionStore = defineStore('comprehensiveVenueSessi
const loginResult = await loginByWechat() const loginResult = await loginByWechat()
const data = await authApi.wechatLogin({ code: loginResult.code }) const data = await authApi.wechatLogin({ code: loginResult.code })
this.member = data.member this.member = data.member
this.needsSetup = true
return data
},
completeLogin() {
const pendingToken = authStorage.getPendingToken()
if (pendingToken) {
authStorage.setToken(pendingToken)
authStorage.clearPendingToken()
this.isLoggedIn = true this.isLoggedIn = true
this.needsSetup = false
}
}, },
logout() { logout() {
authApi.logout() authApi.logout()
this.member = null this.member = null
this.isLoggedIn = false this.isLoggedIn = false
this.needsSetup = false
}, },
async refreshMember() { async refreshMember() {
if (!authStorage.isLoggedIn()) return if (!authStorage.isLoggedIn()) return
+12
View File
@@ -174,6 +174,18 @@
"navigationBarTitleText": "个人信息" "navigationBarTitleText": "个人信息"
} }
}, },
{
"path": "pages/comprehensive/profile/login",
"style": {
"navigationBarTitleText": "登录"
}
},
{
"path": "pages/comprehensive/profile/setup",
"style": {
"navigationBarTitleText": "完善信息"
}
},
{ {
"path": "pages/comprehensive/mall/index", "path": "pages/comprehensive/mall/index",
"style": { "style": {
@@ -1,11 +1,6 @@
<template> <template>
<view class="courses-page"> <view class="courses-page">
<UnauthLoginPanel <block>
v-if="activeTab === 'membership' && !session.isLoggedIn"
@login="login"
/>
<block v-else>
<view class="tab-bar"> <view class="tab-bar">
<view class="tab-item" :class="{ active: activeTab === 'membership' }" @tap="switchTab('membership')">会员卡</view> <view class="tab-item" :class="{ active: activeTab === 'membership' }" @tap="switchTab('membership')">会员卡</view>
<view class="tab-item" :class="{ active: activeTab === 'group' }" @tap="switchTab('group')">团课</view> <view class="tab-item" :class="{ active: activeTab === 'group' }" @tap="switchTab('group')">团课</view>
@@ -104,7 +99,6 @@ import { ticketApi } from '@/modules/comprehensive/api/ticket'
import { multiplyAmount } from '@/modules/comprehensive/utils/money' import { multiplyAmount } from '@/modules/comprehensive/utils/money'
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session' import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
import { formatTimeRange, todayString } from '@/modules/comprehensive/utils/date' import { formatTimeRange, todayString } from '@/modules/comprehensive/utils/date'
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
const session = useComprehensiveSessionStore() const session = useComprehensiveSessionStore()
const activeTab = ref('membership') const activeTab = ref('membership')
@@ -225,11 +219,6 @@ const goDetail = (item) => {
} }
} }
const login = async () => {
await session.login()
loadSessions(true)
}
const refresh = async () => { const refresh = async () => {
if (activeTab.value === 'tickets') { if (activeTab.value === 'tickets') {
ticketProduct.value = null ticketProduct.value = null
@@ -1,12 +1,20 @@
<template> <template>
<view class="profile-page container"> <view class="profile-page container">
<UnauthLoginPanel <view v-if="!session.isLoggedIn" class="user-profile-hero" @tap="login">
v-if="!session.isLoggedIn" <view class="hero-left">
@login="login" <view class="hero-avatar placeholder">
/> <text class="hero-placeholder-text">点击登录</text>
</view>
<view class="hero-meta">
<text class="hero-name">点击登录</text>
</view>
</view>
<view class="hero-right">
<view class="hero-arrow" />
</view>
</view>
<block v-else> <view v-else class="user-profile-hero" @tap="go('/pages/comprehensive/profile/detail')">
<view class="user-profile-hero" @tap="go('/pages/comprehensive/profile/detail')">
<view class="hero-left"> <view class="hero-left">
<image v-if="session.member?.avatar_url" :src="session.member.avatar_url" mode="aspectFill" class="hero-avatar" /> <image v-if="session.member?.avatar_url" :src="session.member.avatar_url" mode="aspectFill" class="hero-avatar" />
<view v-else class="hero-avatar placeholder"> <view v-else class="hero-avatar placeholder">
@@ -50,7 +58,7 @@
<view class="menu-arrow" /> <view class="menu-arrow" />
</view> </view>
</view> </view>
<view class="menu-item logout" @tap="logout"> <view v-if="session.isLoggedIn" class="menu-item logout" @tap="logout">
<view class="item-left"> <view class="item-left">
<view class="item-icon-container logout-theme"> <view class="item-icon-container logout-theme">
<view class="icon-logout"> <view class="icon-logout">
@@ -65,7 +73,6 @@
</view> </view>
</view> </view>
</view> </view>
</block>
<AgreementLinks v-if="session.isLoggedIn" class="profile-agreement-links" compact /> <AgreementLinks v-if="session.isLoggedIn" class="profile-agreement-links" compact />
</view> </view>
</template> </template>
@@ -76,7 +83,6 @@ import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/ses
import { memberApi } from '@/modules/comprehensive/api/member' import { memberApi } from '@/modules/comprehensive/api/member'
import { formatAmount } from '@/modules/comprehensive/utils/money' import { formatAmount } from '@/modules/comprehensive/utils/money'
import AgreementLinks from '@/components/AgreementLinks.vue' import AgreementLinks from '@/components/AgreementLinks.vue'
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
const session = useComprehensiveSessionStore() const session = useComprehensiveSessionStore()
const avatarInitial = computed(() => (session.member?.nickname || '用户').slice(0, 1)) const avatarInitial = computed(() => (session.member?.nickname || '用户').slice(0, 1))
@@ -104,8 +110,8 @@ onMounted(async () => {
} }
}) })
const login = async () => { const login = () => {
await session.login() uni.navigateTo({ url: '/pages/comprehensive/profile/login' })
} }
const logout = () => { const logout = () => {
@@ -121,6 +127,65 @@ defineExpose({ onPageShow: () => {} })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.login-entry {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 160rpx;
padding: $spacing-lg;
border: 1rpx solid rgba(241, 245, 249, 0.9);
border-radius: $border-radius-lg;
background-color: $bg-color-main;
box-shadow: 0 10rpx 40rpx rgba(15, 23, 42, 0.02);
}
.login-entry:active {
opacity: 0.96;
transform: scale(0.99);
}
.login-entry-left {
display: flex;
align-items: center;
gap: $spacing-md;
}
.login-entry-avatar {
width: 104rpx;
height: 104rpx;
flex-shrink: 0;
border-radius: 50%;
background: $bg-color-hover;
display: flex;
align-items: center;
justify-content: center;
}
.login-entry-avatar.placeholder {
background: $color-primary-light;
}
.login-entry-icon {
font-size: 48rpx;
}
.login-entry-meta {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.login-entry-title {
color: $text-color-main;
font-size: 34rpx;
font-weight: 700;
}
.login-entry-sub {
color: $text-color-muted;
font-size: 24rpx;
}
.user-profile-hero { .user-profile-hero {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -164,6 +229,12 @@ defineExpose({ onPageShow: () => {} })
background: $color-primary-light; background: $color-primary-light;
} }
.hero-placeholder-text {
font-size: 20rpx;
color: $text-color-muted;
font-weight: 700;
}
.hero-meta { .hero-meta {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -0,0 +1,28 @@
<template>
<view class="login-page">
<UnauthLoginPanel @login="handleLogin" />
</view>
</template>
<script setup>
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
const session = useComprehensiveSessionStore()
const handleLogin = async () => {
try {
await session.login()
uni.redirectTo({ url: '/pages/comprehensive/profile/setup' })
} catch {
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
.login-page {
min-height: 100vh;
background: $bg-color-soft;
}
</style>
@@ -0,0 +1,265 @@
<template>
<view class="setup-page">
<view class="setup-card">
<view class="avatar-row">
<button class="avatar-picker" open-type="chooseAvatar" @chooseavatar="onChooseAvatar">
<image v-if="avatarUrl" :src="avatarUrl" mode="aspectFill" class="avatar-img" />
<view v-else class="avatar-placeholder">
<text class="avatar-placeholder-text">选择头像</text>
</view>
</button>
</view>
<view class="field-row">
<text class="field-label">昵称</text>
<input
type="nickname"
class="field-input"
placeholder="请输入昵称"
:value="nickname"
@blur="onNicknameBlur"
/>
</view>
<view class="field-row">
<text class="field-label">手机号</text>
<view class="phone-area">
<text v-if="phone" class="phone-text">{{ phone }}</text>
<button class="phone-btn" open-type="getPhoneNumber" @getphonenumber="onGetPhone">
{{ phone ? '更换' : '获取' }}
</button>
</view>
</view>
<button
class="submit-button"
:class="{ disabled: submitting || !canSubmit }"
:disabled="submitting || !canSubmit"
@tap="onSubmit"
>
{{ submitting ? '提交中...' : '完成' }}
</button>
<view class="home-link" @tap="goHome">返回首页</view>
</view>
<AgreementLinks ref="agreementLinksRef" :show-entry="false" />
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { memberApi } from '@/modules/comprehensive/api/member'
import { authStorage } from '@/modules/comprehensive/api/auth'
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
import AgreementLinks from '@/components/AgreementLinks.vue'
const session = useComprehensiveSessionStore()
const avatarUrl = ref(session.member?.avatar_url || '')
const nickname = ref(session.member?.nickname || '')
const phone = ref(session.member?.phone || '')
const submitting = ref(false)
const canSubmit = computed(() => !!avatarUrl.value && !!nickname.value.trim())
const onChooseAvatar = async (e) => {
if (!e.detail.avatarUrl) return
try {
const data = await memberApi.uploadAvatar(e.detail.avatarUrl)
avatarUrl.value = data.avatar_url
} catch {
avatarUrl.value = e.detail.avatarUrl
}
}
const onNicknameBlur = (e) => {
nickname.value = e.detail.value || ''
}
const onGetPhone = async (e) => {
if (e.detail.errMsg !== 'getPhoneNumber:ok') return
try {
const member = await memberApi.bindMemberPhone(e.detail.code)
phone.value = member.phone || ''
} catch {
uni.showToast({ title: '获取手机号失败', icon: 'none' })
}
}
const onSubmit = async () => {
if (!canSubmit.value || submitting.value) return
submitting.value = true
try {
await memberApi.updateMemberProfile({
nickname: nickname.value.trim(),
avatar_url: avatarUrl.value
})
session.completeLogin()
uni.showToast({ title: '设置成功', icon: 'success' })
setTimeout(() => {
uni.switchTab({ url: '/pages/profile/index' })
}, 500)
} catch {
uni.showToast({ title: '提交失败,请重试', icon: 'none' })
} finally {
submitting.value = false
}
}
const goHome = () => {
authStorage.clear()
session.member = null
session.needsSetup = false
uni.switchTab({ url: '/pages/home/index' })
}
</script>
<style lang="scss" scoped>
.setup-page {
min-height: 100vh;
padding: 0 $spacing-lg 48rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
background: $bg-color-soft;
}
.setup-card {
width: 100%;
max-width: 560rpx;
display: flex;
flex-direction: column;
align-items: stretch;
gap: $spacing-md;
}
.avatar-row {
display: flex;
justify-content: center;
margin-bottom: $spacing-sm;
}
.avatar-picker {
width: 144rpx;
height: 144rpx;
padding: 0;
border-radius: 50%;
overflow: hidden;
background: $bg-color-hover;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.avatar-picker::after {
border: none;
}
.avatar-img {
width: 144rpx;
height: 144rpx;
border-radius: 50%;
}
.avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.avatar-placeholder-text {
font-size: 20rpx;
color: $text-color-light;
}
.field-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
height: 96rpx;
padding: 0 $spacing-md;
border-radius: $border-radius-base;
background: $bg-color-main;
}
.field-label {
flex-shrink: 0;
color: $text-color-muted;
font-size: 28rpx;
font-weight: 700;
width: 100rpx;
text-align: left;
}
.field-input {
flex: 1;
text-align: right;
font-size: 28rpx;
color: $text-color-main;
}
.phone-area {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
gap: $spacing-sm;
}
.phone-text {
color: $text-color-main;
font-size: 28rpx;
font-weight: 700;
}
.phone-btn {
font-size: 24rpx;
font-weight: 700;
color: $color-primary;
background: $color-primary-light;
padding: 8rpx 24rpx;
border-radius: $border-radius-sm;
line-height: 1.5;
}
.phone-btn::after {
border: none;
}
.submit-button {
width: 100%;
height: 88rpx;
border-radius: $border-radius-base;
color: #FFFFFF;
background-color: $text-color-main;
font-size: 28rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
margin-top: $spacing-sm;
}
.submit-button::after {
border: none;
}
.submit-button.disabled,
.submit-button[disabled] {
color: #FFFFFF;
background-color: $text-color-light;
}
.home-link {
text-align: center;
color: $text-color-muted;
font-size: 26rpx;
padding: $spacing-sm 0;
}
</style>