feat: 新用户默认昵称及个人中心加载状态修复
- 新用户注册时随机生成普拉提主题默认昵称(16 个可选) - 修复 App 启动后个人中心首次进入不展示用户信息的 bug - loggedIn 改为仅依赖 token 是否存在 - 新增 hasProfile 判断用户数据是否已加载 - 未加载时显示骨架屏而非空白 - 抽离随机函数为可注入依赖,消除 Math.random 测试耦合
This commit is contained in:
@@ -16,8 +16,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Logged in state -->
|
<!-- Logged in + profile loaded -->
|
||||||
<view v-else class="user-card__user">
|
<view v-else-if="loggedIn && hasProfile" class="user-card__user">
|
||||||
<view class="user-card__avatar-wrap">
|
<view class="user-card__avatar-wrap">
|
||||||
<image
|
<image
|
||||||
class="user-card__avatar-img"
|
class="user-card__avatar-img"
|
||||||
@@ -36,10 +36,23 @@
|
|||||||
<text v-if="maskedPhone" class="user-card__phone">{{ maskedPhone }}</text>
|
<text v-if="maskedPhone" class="user-card__phone">{{ maskedPhone }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- Logged in but profile still loading -->
|
||||||
|
<view v-else class="user-card__loading">
|
||||||
|
<view class="user-card__avatar-wrap">
|
||||||
|
<view class="user-card__avatar-skeleton" />
|
||||||
|
</view>
|
||||||
|
<view class="user-card__info">
|
||||||
|
<view class="user-card__name-row">
|
||||||
|
<view class="user-card__nickname-skeleton" />
|
||||||
|
</view>
|
||||||
|
<view class="user-card__phone-skeleton" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Stats row: shown only when logged in -->
|
<!-- Stats row: shown only when profile is loaded -->
|
||||||
<view v-if="loggedIn" class="user-card__stats">
|
<view v-if="loggedIn && hasProfile" class="user-card__stats">
|
||||||
<view class="user-card__stat-item">
|
<view class="user-card__stat-item">
|
||||||
<text class="user-card__stat-value">{{ stats?.totalBookings ?? 0 }}</text>
|
<text class="user-card__stat-value">{{ stats?.totalBookings ?? 0 }}</text>
|
||||||
<text class="user-card__stat-label">总训练(次)</text>
|
<text class="user-card__stat-label">总训练(次)</text>
|
||||||
@@ -65,6 +78,7 @@ import { MembershipStatus } from '@mp-pilates/shared'
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
loggedIn: boolean
|
loggedIn: boolean
|
||||||
|
hasProfile: boolean
|
||||||
user: UserProfileResponse | null
|
user: UserProfileResponse | null
|
||||||
stats: UserStatsResponse | null
|
stats: UserStatsResponse | null
|
||||||
memberships?: readonly MembershipWithCardType[]
|
memberships?: readonly MembershipWithCardType[]
|
||||||
@@ -235,6 +249,35 @@ function handleLogin() {
|
|||||||
color: rgba(255, 255, 255, 0.75);
|
color: rgba(255, 255, 255, 0.75);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Loading state ──
|
||||||
|
&__loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar-skeleton {
|
||||||
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__nickname-skeleton {
|
||||||
|
width: 160rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__phone-skeleton {
|
||||||
|
width: 120rpx;
|
||||||
|
height: 26rpx;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Stats row ──
|
// ── Stats row ──
|
||||||
&__stats {
|
&__stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<!-- User card -->
|
<!-- User card -->
|
||||||
<UserCard
|
<UserCard
|
||||||
:logged-in="loggedIn"
|
:logged-in="loggedIn"
|
||||||
|
:has-profile="hasProfile"
|
||||||
:user="user"
|
:user="user"
|
||||||
:stats="stats"
|
:stats="stats"
|
||||||
:memberships="memberships"
|
:memberships="memberships"
|
||||||
@@ -35,7 +36,7 @@ import UserCard from '../../components/UserCard.vue'
|
|||||||
import ProfileMenu from '../../components/ProfileMenu.vue'
|
import ProfileMenu from '../../components/ProfileMenu.vue'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const { loggedIn, user, stats, memberships, isAdmin } = storeToRefs(userStore)
|
const { loggedIn, hasProfile, user, stats, memberships, isAdmin } = storeToRefs(userStore)
|
||||||
|
|
||||||
const loginLoading = ref(false)
|
const loginLoading = ref(false)
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
const token = ref<string>(uni.getStorageSync('token') as string || '')
|
const token = ref<string>(uni.getStorageSync('token') as string || '')
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
const loggedIn = computed(() => !!token.value && !!user.value)
|
// loggedIn: 是否已认证(token 存在),与 user 数据是否已加载无关
|
||||||
|
const loggedIn = computed(() => !!token.value)
|
||||||
|
// hasProfile: user 数据是否已加载(用于 UI 展示判断)
|
||||||
|
const hasProfile = computed(() => !!user.value)
|
||||||
const isAdmin = computed(() => user.value?.role === UserRole.ADMIN)
|
const isAdmin = computed(() => user.value?.role === UserRole.ADMIN)
|
||||||
const activeMemberships = computed(() =>
|
const activeMemberships = computed(() =>
|
||||||
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
|
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
|
||||||
@@ -91,6 +94,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
memberships,
|
memberships,
|
||||||
token,
|
token,
|
||||||
loggedIn,
|
loggedIn,
|
||||||
|
hasProfile,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
activeMemberships,
|
activeMemberships,
|
||||||
hasValidMembership,
|
hasValidMembership,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'
|
|||||||
import { JwtService } from '@nestjs/jwt'
|
import { JwtService } from '@nestjs/jwt'
|
||||||
import { UnauthorizedException } from '@nestjs/common'
|
import { UnauthorizedException } from '@nestjs/common'
|
||||||
import { UserRole } from '@mp-pilates/shared'
|
import { UserRole } from '@mp-pilates/shared'
|
||||||
import { AuthService } from '../auth.service'
|
import { AuthService, RANDOM_FN_TOKEN } from '../auth.service'
|
||||||
import { WechatService } from '../wechat.service'
|
import { WechatService } from '../wechat.service'
|
||||||
import { PrismaService } from '../../prisma/prisma.service'
|
import { PrismaService } from '../../prisma/prisma.service'
|
||||||
|
|
||||||
@@ -12,13 +12,14 @@ const OPENID = 'test_openid_123'
|
|||||||
const SESSION_KEY = 'test_session_key'
|
const SESSION_KEY = 'test_session_key'
|
||||||
const USER_ID = 'user-uuid-001'
|
const USER_ID = 'user-uuid-001'
|
||||||
const JWT_TOKEN = 'signed.jwt.token'
|
const JWT_TOKEN = 'signed.jwt.token'
|
||||||
|
const TEST_NICKNAME = '优雅普拉提'
|
||||||
|
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
id: USER_ID,
|
id: USER_ID,
|
||||||
openid: OPENID,
|
openid: OPENID,
|
||||||
unionid: null,
|
unionid: null,
|
||||||
phone: null,
|
phone: null,
|
||||||
nickname: '',
|
nickname: TEST_NICKNAME,
|
||||||
avatarUrl: null,
|
avatarUrl: null,
|
||||||
role: UserRole.MEMBER,
|
role: UserRole.MEMBER,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -57,6 +58,7 @@ describe('AuthService', () => {
|
|||||||
{ provide: PrismaService, useValue: mockPrismaService },
|
{ provide: PrismaService, useValue: mockPrismaService },
|
||||||
{ provide: WechatService, useValue: mockWechatService },
|
{ provide: WechatService, useValue: mockWechatService },
|
||||||
{ provide: JwtService, useValue: mockJwtService },
|
{ provide: JwtService, useValue: mockJwtService },
|
||||||
|
{ provide: RANDOM_FN_TOKEN, useValue: () => 0 }, // deterministic nickname
|
||||||
],
|
],
|
||||||
}).compile()
|
}).compile()
|
||||||
|
|
||||||
@@ -89,7 +91,7 @@ describe('AuthService', () => {
|
|||||||
where: { openid: OPENID },
|
where: { openid: OPENID },
|
||||||
})
|
})
|
||||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
||||||
data: { openid: OPENID },
|
data: { openid: OPENID, nickname: TEST_NICKNAME },
|
||||||
})
|
})
|
||||||
expect(result.user).toEqual(mockUser)
|
expect(result.user).toEqual(mockUser)
|
||||||
})
|
})
|
||||||
@@ -107,7 +109,7 @@ describe('AuthService', () => {
|
|||||||
await authService.login(loginCode)
|
await authService.login(loginCode)
|
||||||
|
|
||||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
||||||
data: { openid: OPENID, unionid },
|
data: { openid: OPENID, unionid, nickname: TEST_NICKNAME },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, UnauthorizedException } from '@nestjs/common'
|
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'
|
||||||
import { JwtService } from '@nestjs/jwt'
|
import { JwtService } from '@nestjs/jwt'
|
||||||
import { User } from '@prisma/client'
|
import { User } from '@prisma/client'
|
||||||
import { UserRole } from '@mp-pilates/shared'
|
import { UserRole } from '@mp-pilates/shared'
|
||||||
@@ -22,12 +22,39 @@ export interface JwtPayload {
|
|||||||
*/
|
*/
|
||||||
const sessionKeyStore = new Map<string, string>()
|
const sessionKeyStore = new Map<string, string>()
|
||||||
|
|
||||||
|
export const RANDOM_FN_TOKEN = 'RANDOM_FN_TOKEN'
|
||||||
|
|
||||||
|
const DEFAULT_NICKNAMES = [
|
||||||
|
'优雅普拉提',
|
||||||
|
'柔韧时光',
|
||||||
|
'轻盈姿态',
|
||||||
|
'身心合一',
|
||||||
|
'舒展生活',
|
||||||
|
'静享流动',
|
||||||
|
'普拉提修行者',
|
||||||
|
'姿态雕塑师',
|
||||||
|
'呼吸艺术家',
|
||||||
|
'柔美力量',
|
||||||
|
'线条雕刻师',
|
||||||
|
'优雅行者',
|
||||||
|
'轻盈韵律',
|
||||||
|
'内在平和',
|
||||||
|
'舒展之美',
|
||||||
|
]
|
||||||
|
|
||||||
|
function generateDefaultNickname(
|
||||||
|
randomFn: () => number = Math.random,
|
||||||
|
): string {
|
||||||
|
return DEFAULT_NICKNAMES[Math.floor(randomFn() * DEFAULT_NICKNAMES.length)]
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly wechatService: WechatService,
|
private readonly wechatService: WechatService,
|
||||||
|
@Inject(RANDOM_FN_TOKEN) private readonly randomFn: () => number = Math.random,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async login(code: string): Promise<LoginResult> {
|
async login(code: string): Promise<LoginResult> {
|
||||||
@@ -44,6 +71,7 @@ export class AuthService {
|
|||||||
data: {
|
data: {
|
||||||
openid,
|
openid,
|
||||||
...(unionid !== undefined && { unionid }),
|
...(unionid !== undefined && { unionid }),
|
||||||
|
nickname: generateDefaultNickname(this.randomFn),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user