feat: 新用户默认昵称及个人中心加载状态修复

- 新用户注册时随机生成普拉提主题默认昵称(16 个可选)
- 修复 App 启动后个人中心首次进入不展示用户信息的 bug
  - loggedIn 改为仅依赖 token 是否存在
  - 新增 hasProfile 判断用户数据是否已加载
  - 未加载时显示骨架屏而非空白
- 抽离随机函数为可注入依赖,消除 Math.random 测试耦合
This commit is contained in:
richarjiang
2026-04-05 10:21:58 +08:00
parent 982e569fa3
commit 640cfbf467
5 changed files with 89 additions and 11 deletions

View File

@@ -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;

View File

@@ -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)

View File

@@ -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,

View File

@@ -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 },
}) })
}) })

View File

@@ -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),
}, },
})) }))