From 640cfbf467ba9808cf0e65021d7042470d589fd5 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Sun, 5 Apr 2026 10:21:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E7=94=A8=E6=88=B7=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E6=98=B5=E7=A7=B0=E5=8F=8A=E4=B8=AA=E4=BA=BA=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E5=8A=A0=E8=BD=BD=E7=8A=B6=E6=80=81=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新用户注册时随机生成普拉提主题默认昵称(16 个可选) - 修复 App 启动后个人中心首次进入不展示用户信息的 bug - loggedIn 改为仅依赖 token 是否存在 - 新增 hasProfile 判断用户数据是否已加载 - 未加载时显示骨架屏而非空白 - 抽离随机函数为可注入依赖,消除 Math.random 测试耦合 --- packages/app/src/components/UserCard.vue | 51 +++++++++++++++++-- packages/app/src/pages/profile/index.vue | 3 +- packages/app/src/stores/user.ts | 6 ++- .../src/auth/__tests__/auth.service.spec.ts | 10 ++-- packages/server/src/auth/auth.service.ts | 30 ++++++++++- 5 files changed, 89 insertions(+), 11 deletions(-) diff --git a/packages/app/src/components/UserCard.vue b/packages/app/src/components/UserCard.vue index 7e13156..ba64ad4 100644 --- a/packages/app/src/components/UserCard.vue +++ b/packages/app/src/components/UserCard.vue @@ -16,8 +16,8 @@ - - + + {{ maskedPhone }} + + + + + + + + + + + + + - - + + {{ stats?.totalBookings ?? 0 }} 总训练(次) @@ -65,6 +78,7 @@ import { MembershipStatus } from '@mp-pilates/shared' const props = defineProps<{ loggedIn: boolean + hasProfile: boolean user: UserProfileResponse | null stats: UserStatsResponse | null memberships?: readonly MembershipWithCardType[] @@ -235,6 +249,35 @@ function handleLogin() { 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 { display: flex; diff --git a/packages/app/src/pages/profile/index.vue b/packages/app/src/pages/profile/index.vue index 65eb085..d24d16d 100644 --- a/packages/app/src/pages/profile/index.vue +++ b/packages/app/src/pages/profile/index.vue @@ -3,6 +3,7 @@ { const token = ref(uni.getStorageSync('token') as string || '') // 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 activeMemberships = computed(() => memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE), @@ -91,6 +94,7 @@ export const useUserStore = defineStore('user', () => { memberships, token, loggedIn, + hasProfile, isAdmin, activeMemberships, hasValidMembership, diff --git a/packages/server/src/auth/__tests__/auth.service.spec.ts b/packages/server/src/auth/__tests__/auth.service.spec.ts index 1e7f06c..d06bed0 100644 --- a/packages/server/src/auth/__tests__/auth.service.spec.ts +++ b/packages/server/src/auth/__tests__/auth.service.spec.ts @@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing' import { JwtService } from '@nestjs/jwt' import { UnauthorizedException } from '@nestjs/common' 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 { PrismaService } from '../../prisma/prisma.service' @@ -12,13 +12,14 @@ const OPENID = 'test_openid_123' const SESSION_KEY = 'test_session_key' const USER_ID = 'user-uuid-001' const JWT_TOKEN = 'signed.jwt.token' +const TEST_NICKNAME = '优雅普拉提' const mockUser = { id: USER_ID, openid: OPENID, unionid: null, phone: null, - nickname: '', + nickname: TEST_NICKNAME, avatarUrl: null, role: UserRole.MEMBER, createdAt: new Date(), @@ -57,6 +58,7 @@ describe('AuthService', () => { { provide: PrismaService, useValue: mockPrismaService }, { provide: WechatService, useValue: mockWechatService }, { provide: JwtService, useValue: mockJwtService }, + { provide: RANDOM_FN_TOKEN, useValue: () => 0 }, // deterministic nickname ], }).compile() @@ -89,7 +91,7 @@ describe('AuthService', () => { where: { openid: OPENID }, }) expect(mockPrismaService.user.create).toHaveBeenCalledWith({ - data: { openid: OPENID }, + data: { openid: OPENID, nickname: TEST_NICKNAME }, }) expect(result.user).toEqual(mockUser) }) @@ -107,7 +109,7 @@ describe('AuthService', () => { await authService.login(loginCode) expect(mockPrismaService.user.create).toHaveBeenCalledWith({ - data: { openid: OPENID, unionid }, + data: { openid: OPENID, unionid, nickname: TEST_NICKNAME }, }) }) diff --git a/packages/server/src/auth/auth.service.ts b/packages/server/src/auth/auth.service.ts index a04463b..8416dd5 100644 --- a/packages/server/src/auth/auth.service.ts +++ b/packages/server/src/auth/auth.service.ts @@ -1,4 +1,4 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common' +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' import { JwtService } from '@nestjs/jwt' import { User } from '@prisma/client' import { UserRole } from '@mp-pilates/shared' @@ -22,12 +22,39 @@ export interface JwtPayload { */ const sessionKeyStore = new Map() +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() export class AuthService { constructor( private readonly prisma: PrismaService, private readonly jwtService: JwtService, private readonly wechatService: WechatService, + @Inject(RANDOM_FN_TOKEN) private readonly randomFn: () => number = Math.random, ) {} async login(code: string): Promise { @@ -44,6 +71,7 @@ export class AuthService { data: { openid, ...(unionid !== undefined && { unionid }), + nickname: generateDefaultNickname(this.randomFn), }, }))