diff --git a/packages/app/src/components/PracticeActivityCard.vue b/packages/app/src/components/PracticeActivityCard.vue
new file mode 100644
index 0000000..a48a9c7
--- /dev/null
+++ b/packages/app/src/components/PracticeActivityCard.vue
@@ -0,0 +1,129 @@
+
+
+
+
+ YOUR PRACTICE
+ 练习足迹最近 30 天
+
+ {{ activity ? total : '—' }}节课
+
+
+
+ 暂时未能加载练习记录
+
+
+
+
+ {{ activity ? formatDate(activity.days[0].date) : '正在读取练习记录' }}
+ {{ activity ? formatDate(activity.days[29].date) + ' · 今天' : '' }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/app/src/pages/admin/member-detail.vue b/packages/app/src/pages/admin/member-detail.vue
index 9543ab5..9ac6de9 100644
--- a/packages/app/src/pages/admin/member-detail.vue
+++ b/packages/app/src/pages/admin/member-detail.vue
@@ -10,7 +10,6 @@
-
@@ -19,12 +18,15 @@
- STUDIO LEDGER
{{ detail.user.nickname || '未知用户' }}
{{ detail.user.phone || '未绑定手机' }}
- {{ detail.user.openid }}
+
+ 微信标识
+ {{ detail.user.openid }}
+ 复制
+
注册时间
@@ -38,14 +40,14 @@
-
+
上课情况
{{ detail.stats.totalBookings }}
累计预约
-
+
{{ detail.stats.completedBookings }}
已完成
@@ -53,19 +55,24 @@
{{ detail.stats.cancelledBookings }}
已取消
+
+ {{ detail.stats.noShowBookings }}
+ 未到课
+
- 未到 {{ detail.stats.noShowBookings }} 次
- 会员卡
+
+ 会员卡
+ {{ detail.memberships.length }} 张
+
-
{{ card.cardType.name }}
@@ -77,7 +84,11 @@
{{ card.remainingTimes }}
- 次剩余
+ 次可用
+
+
+ 有效期内使用
+ 不限次数
@@ -103,11 +114,14 @@
- 即将上课
+
+ 即将上课
+ {{ detail.upcomingBookings.length }} 节待上
+
- {{ item.date.slice(5) }}
+ {{ item.date.slice(5, 10).replace('-', ' / ') }}
{{ item.startTime.slice(0, 5) }}–{{ item.endTime.slice(0, 5) }}
@@ -147,7 +161,6 @@ import { getSystemLayout } from '../../utils/system'
import {
formatDateTimeFull,
getCardTypeLabel,
- getCardGradientClass,
getMembershipProgressWidth,
getMembershipUsedTimes,
getMembershipTotalTimes,
@@ -238,443 +251,150 @@ onShow(() => {
diff --git a/packages/app/src/pages/profile/index.vue b/packages/app/src/pages/profile/index.vue
index 60c41ba..738293c 100644
--- a/packages/app/src/pages/profile/index.vue
+++ b/packages/app/src/pages/profile/index.vue
@@ -7,6 +7,8 @@
+
+
{
})
onShow(async () => {
+ activityRefreshKey.value += 1
if (loggedIn.value) {
await Promise.all([
userStore.fetchProfile(),
diff --git a/packages/server/src/booking/__tests__/booking.service.spec.ts b/packages/server/src/booking/__tests__/booking.service.spec.ts
index 2fc38c6..69f2b7d 100644
--- a/packages/server/src/booking/__tests__/booking.service.spec.ts
+++ b/packages/server/src/booking/__tests__/booking.service.spec.ts
@@ -1542,4 +1542,44 @@ describe('BookingService', () => {
expect(tx.booking.create).not.toHaveBeenCalled()
})
})
+ describe('getPracticeActivity', () => {
+ afterEach(() => jest.restoreAllMocks())
+
+ it('uses China today across UTC midnight and counts scheduled dates without pagination', async () => {
+ jest.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-09-07T16:01:00Z'))
+ ;(prisma.booking.findMany as jest.Mock).mockResolvedValue([
+ { timeSlot: { date: new Date('2026-08-10T00:00:00Z') } },
+ { timeSlot: { date: new Date('2026-09-08T00:00:00Z') } },
+ { timeSlot: { date: new Date('2026-09-08T00:00:00Z') } },
+ ])
+ const result = await service.getPracticeActivity(MOCK_USER_ID)
+ expect(result.days).toHaveLength(30)
+ expect(result.days[0]).toEqual({ date: '2026-08-10', count: 1 })
+ expect(result.days[29]).toEqual({ date: '2026-09-08', count: 2 })
+ expect(result.days[1]).toEqual({ date: '2026-08-11', count: 0 })
+ expect(prisma.booking.findMany).toHaveBeenCalledWith({
+ where: {
+ userId: MOCK_USER_ID,
+ status: BookingStatus.COMPLETED,
+ timeSlot: { date: {
+ gte: new Date('2026-08-10T00:00:00Z'),
+ lt: new Date('2026-09-09T00:00:00Z'),
+ } },
+ },
+ select: { timeSlot: { select: { date: true } } },
+ })
+ })
+
+ it('returns every day with zero counts across a leap-year boundary', async () => {
+ jest.spyOn(Date, 'now').mockReturnValue(Date.parse('2024-03-01T01:00:00Z'))
+ ;(prisma.booking.findMany as jest.Mock).mockResolvedValue([])
+ const result = await service.getPracticeActivity(MOCK_USER_ID)
+ expect(result.days).toHaveLength(30)
+ expect(result.days[0].date).toBe('2024-02-01')
+ expect(result.days[28].date).toBe('2024-02-29')
+ expect(result.days[29].date).toBe('2024-03-01')
+ expect(result.days.every(day => day.count === 0)).toBe(true)
+ })
+ })
+
})
diff --git a/packages/server/src/booking/booking.controller.ts b/packages/server/src/booking/booking.controller.ts
index 55e6c9c..ee13921 100644
--- a/packages/server/src/booking/booking.controller.ts
+++ b/packages/server/src/booking/booking.controller.ts
@@ -42,6 +42,12 @@ export class BookingController {
return this.bookingService.cancelBooking(userId, id)
}
+ @Get('booking/my/activity')
+ @UseGuards(JwtAuthGuard)
+ async getPracticeActivity(@CurrentUser('sub') userId: string) {
+ return this.bookingService.getPracticeActivity(userId)
+ }
+
@Get('booking/my/upcoming')
@UseGuards(JwtAuthGuard)
async getUpcomingBookings(@CurrentUser('sub') userId: string) {
diff --git a/packages/server/src/booking/booking.service.ts b/packages/server/src/booking/booking.service.ts
index fc1a589..574eb2e 100644
--- a/packages/server/src/booking/booking.service.ts
+++ b/packages/server/src/booking/booking.service.ts
@@ -12,6 +12,7 @@ import {
MembershipStatus,
TimeSlotStatus,
type TeachingScheduleSlot,
+ type PracticeActivity,
} from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { MembershipService } from '../membership/membership.service'
@@ -670,6 +671,34 @@ export class BookingService {
// ─── Get Upcoming Bookings ────────────────────────────────────────────────
+ async getPracticeActivity(userId: string): Promise {
+ // Slot dates are stored as UTC midnight date-only values. Determine today's
+ // calendar date in China independently of the server's timezone.
+ const dayMs = 86_400_000
+ const today = new Date(Date.now() + 8 * 3_600_000).toISOString().slice(0, 10)
+ const end = new Date(today + 'T00:00:00Z').getTime()
+ const start = end - 29 * dayMs
+ const bookings = await this.prisma.booking.findMany({
+ where: {
+ userId,
+ status: BookingStatus.COMPLETED,
+ timeSlot: { date: { gte: new Date(start), lt: new Date(end + dayMs) } },
+ },
+ select: { timeSlot: { select: { date: true } } },
+ })
+ const counts = new Map()
+ for (const booking of bookings) {
+ const date = booking.timeSlot.date.toISOString().slice(0, 10)
+ counts.set(date, (counts.get(date) ?? 0) + 1)
+ }
+ return {
+ days: Array.from({ length: 30 }, (_, index) => {
+ const date = new Date(start + index * dayMs).toISOString().slice(0, 10)
+ return { date, count: counts.get(date) ?? 0 }
+ }),
+ }
+ }
+
async getUpcomingBookings(userId: string): Promise {
const today = new Date()
today.setUTCHours(0, 0, 0, 0)
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 992861e..afd2c2f 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -78,6 +78,7 @@ export type {
PublishDaySlotsDto,
Booking,
BookingWithDetails,
+ PracticeActivity,
BookingWithUser,
TeachingScheduleStudent,
TeachingScheduleSlot,
diff --git a/packages/shared/src/types/booking.ts b/packages/shared/src/types/booking.ts
index 22bd3e1..9593abe 100644
--- a/packages/shared/src/types/booking.ts
+++ b/packages/shared/src/types/booking.ts
@@ -75,3 +75,8 @@ export interface AdminArrangeBookingDto {
readonly membershipId: string
readonly timeSlotId: string
}
+
+/** Completed classes by scheduled date, in Asia/Shanghai; includes today. */
+export interface PracticeActivity {
+ readonly days: readonly { readonly date: string; readonly count: number }[]
+}
diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts
index 8de15ea..73a150c 100644
--- a/packages/shared/src/types/index.ts
+++ b/packages/shared/src/types/index.ts
@@ -25,6 +25,7 @@ export type { TimeSlot, TimeSlotWithBookingStatus, CreateManualSlotDto, Schedule
export type {
Booking,
BookingWithDetails,
+ PracticeActivity,
BookingWithUser,
TeachingScheduleStudent,
TeachingScheduleSlot,