feat: 支持会员卡设置

This commit is contained in:
richarjiang
2026-04-07 16:47:56 +08:00
parent 91abedcb86
commit 23bdd05811
8 changed files with 667 additions and 429 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -47,7 +47,7 @@
<view class="card-header-left"> <view class="card-header-left">
<text class="card-name">{{ m.cardType.name }}</text> <text class="card-name">{{ m.cardType.name }}</text>
<view class="card-type-badge"> <view class="card-type-badge">
<text class="card-type-badge-text">{{ typeLabel(m.cardType.type) }}</text> <text class="card-type-badge-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
</view> </view>
</view> </view>
<view class="status-badge status-badge--active"> <view class="status-badge status-badge--active">
@@ -70,11 +70,11 @@
<view class="progress-bar"> <view class="progress-bar">
<view <view
class="progress-fill" class="progress-fill"
:style="{ width: progressWidth(m) }" :style="{ width: getMembershipProgressWidth(m) }"
/> />
</view> </view>
<text class="progress-label"> <text class="progress-label">
已使用 {{ usedTimes(m) }} / {{ m.cardType.totalTimes }} 已使用 {{ getMembershipUsedTimes(m) }} / {{ m.cardType.totalTimes }}
</text> </text>
</view> </view>
</template> </template>
@@ -110,7 +110,7 @@
<view class="card-header-left"> <view class="card-header-left">
<text class="card-name card-name--dim">{{ m.cardType.name }}</text> <text class="card-name card-name--dim">{{ m.cardType.name }}</text>
<view class="card-type-badge card-type-badge--dim"> <view class="card-type-badge card-type-badge--dim">
<text class="card-type-badge-text">{{ typeLabel(m.cardType.type) }}</text> <text class="card-type-badge-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
</view> </view>
</view> </view>
<view class="status-badge" :class="statusBadgeClass(m.status)"> <view class="status-badge" :class="statusBadgeClass(m.status)">
@@ -148,6 +148,7 @@ import type { MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared' import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
import { useUserStore } from '../../stores/user' import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes } from '../../utils/format'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
const userStore = useUserStore() const userStore = useUserStore()
@@ -170,14 +171,6 @@ const inactiveMemberships = computed(() =>
) )
// ─── Helpers ────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────
function typeLabel(type: CardTypeCategory): string {
const map: Record<CardTypeCategory, string> = {
[CardTypeCategory.TIMES]: '次卡',
[CardTypeCategory.DURATION]: '月卡',
[CardTypeCategory.TRIAL]: '体验卡',
}
return map[type] ?? '会员卡'
}
function statusLabel(status: MembershipStatus): string { function statusLabel(status: MembershipStatus): string {
const map: Record<MembershipStatus, string> = { const map: Record<MembershipStatus, string> = {
@@ -206,17 +199,6 @@ function headerClass(type: CardTypeCategory): string {
return 'card-header--times' return 'card-header--times'
} }
function progressWidth(m: MembershipWithCardType): string {
if (m.remainingTimes === null || !m.cardType.totalTimes) return '0%'
const pct = (m.remainingTimes / m.cardType.totalTimes) * 100
return `${Math.max(0, Math.min(100, pct))}%`
}
function usedTimes(m: MembershipWithCardType): number {
if (m.remainingTimes === null || !m.cardType.totalTimes) return 0
return m.cardType.totalTimes - m.remainingTimes
}
// ─── Data loading ───────────────────────────────────────── // ─── Data loading ─────────────────────────────────────────
async function loadMemberships() { async function loadMemberships() {
loading.value = true loading.value = true

View File

@@ -1,6 +1,11 @@
import type { CardType } from '@mp-pilates/shared'
import { CardTypeCategory } from '@mp-pilates/shared' import { CardTypeCategory } from '@mp-pilates/shared'
/** Minimal membership shape needed by progress/usage helpers. */
interface MembershipLike {
readonly remainingTimes: number | null
readonly cardType: { readonly totalTimes: number | null }
}
/** 格式化金额:分 → 元 */ /** 格式化金额:分 → 元 */
export function formatPrice(cents: number): string { export function formatPrice(cents: number): string {
return (cents / 100).toFixed(2) return (cents / 100).toFixed(2)
@@ -49,13 +54,13 @@ export function getDateRange(days: number): ReadonlyArray<{ readonly date: strin
} }
/** 会员卡类型标签 */ /** 会员卡类型标签 */
export function getCardTypeLabel(type: CardTypeCategory): string { export function getCardTypeLabel(type: CardTypeCategory | string): string {
const map: Record<CardTypeCategory, string> = { const map: Record<string, string> = {
[CardTypeCategory.TIMES]: '次卡', [CardTypeCategory.TIMES]: '次卡',
[CardTypeCategory.DURATION]: '月卡', [CardTypeCategory.DURATION]: '月卡',
[CardTypeCategory.TRIAL]: '体验', [CardTypeCategory.TRIAL]: '体验',
} }
return map[type] ?? '会员' return map[type] ?? '会员'
} }
/** 会员卡封面 CSS 类名 */ /** 会员卡封面 CSS 类名 */
@@ -70,3 +75,23 @@ export function isSlotPast(date: string, startTime: string): boolean {
const slotDateTime = new Date(`${date}T${startTime}:00`) const slotDateTime = new Date(`${date}T${startTime}:00`)
return new Date() > slotDateTime return new Date() > slotDateTime
} }
/** 会员卡渐变 CSS 类名前缀 */
export function getCardGradientClass(type: CardTypeCategory | string): string {
if (type === CardTypeCategory.DURATION) return 'gradient--duration'
if (type === CardTypeCategory.TRIAL) return 'gradient--trial'
return 'gradient--times'
}
/** 会员卡进度百分比(剩余 / 总次数) */
export function getMembershipProgressWidth(membership: MembershipLike): string {
if (membership.remainingTimes === null || !membership.cardType.totalTimes) return '0%'
const pct = (membership.remainingTimes / membership.cardType.totalTimes) * 100
return `${Math.max(0, Math.min(100, pct))}%`
}
/** 已使用次数 */
export function getMembershipUsedTimes(membership: MembershipLike): number {
if (membership.remainingTimes === null || !membership.cardType.totalTimes) return 0
return membership.cardType.totalTimes - membership.remainingTimes
}

View File

@@ -8,6 +8,7 @@ import {
} from '@nestjs/common' } from '@nestjs/common'
import type { Request, Response } from 'express' import type { Request, Response } from 'express'
import type { ApiResponse } from '@mp-pilates/shared' import type { ApiResponse } from '@mp-pilates/shared'
import { formatRequestExtras } from '../utils/request-log'
@Catch() @Catch()
export class ApiExceptionFilter implements ExceptionFilter { export class ApiExceptionFilter implements ExceptionFilter {
@@ -28,15 +29,16 @@ export class ApiExceptionFilter implements ExceptionFilter {
? this.extractMessage(exception) ? this.extractMessage(exception)
: '服务器内部错误' : '服务器内部错误'
// Log all server errors (5xx) with full stack; log 4xx at warn level const extras = formatRequestExtras(request)
if (status >= 500) { if (status >= 500) {
this.logger.error( this.logger.error(
`${request.method} ${request.originalUrl}${String(status)} ${message}`, `${request.method} ${request.originalUrl}${String(status)} ${message}${extras}`,
exception instanceof Error ? exception.stack : undefined, exception instanceof Error ? exception.stack : undefined,
) )
} else if (status >= 400) { } else if (status >= 400) {
this.logger.warn( this.logger.warn(
`${request.method} ${request.originalUrl}${String(status)} ${message}`, `${request.method} ${request.originalUrl}${String(status)} ${message}${extras}`,
) )
} }

View File

@@ -7,28 +7,7 @@ import {
} from '@nestjs/common' } from '@nestjs/common'
import { Observable, tap } from 'rxjs' import { Observable, tap } from 'rxjs'
import type { Request, Response } from 'express' import type { Request, Response } from 'express'
import { formatRequestExtras } from '../utils/request-log'
/** Fields stripped from logged request bodies to avoid leaking secrets. */
const SENSITIVE_FIELDS: ReadonlySet<string> = new Set([
'password',
'token',
'secret',
'code',
'sessionKey',
'encryptedData',
'iv',
])
function sanitizeBody(
body: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!body || typeof body !== 'object') return undefined
return Object.fromEntries(
Object.entries(body).map(([key, value]) =>
SENSITIVE_FIELDS.has(key) ? [key, '***'] : [key, value],
),
)
}
@Injectable() @Injectable()
export class LoggingInterceptor implements NestInterceptor { export class LoggingInterceptor implements NestInterceptor {
@@ -44,9 +23,9 @@ export class LoggingInterceptor implements NestInterceptor {
next: () => { next: () => {
const res = context.switchToHttp().getResponse<Response>() const res = context.switchToHttp().getResponse<Response>()
const duration = Date.now() - start const duration = Date.now() - start
const bodyLog = this.formatBody(method, req.body as Record<string, unknown>) const extras = formatRequestExtras(req)
this.logger.log( this.logger.log(
`${method} ${originalUrl}${String(res.statusCode)} (${String(duration)}ms)${bodyLog}`, `${method} ${originalUrl}${String(res.statusCode)} (${String(duration)}ms)${extras}`,
) )
}, },
error: (err: unknown) => { error: (err: unknown) => {
@@ -55,22 +34,12 @@ export class LoggingInterceptor implements NestInterceptor {
err instanceof Object && 'getStatus' in err err instanceof Object && 'getStatus' in err
? String((err as { getStatus: () => number }).getStatus()) ? String((err as { getStatus: () => number }).getStatus())
: '500' : '500'
const bodyLog = this.formatBody(method, req.body as Record<string, unknown>) const extras = formatRequestExtras(req)
this.logger.error( this.logger.error(
`${method} ${originalUrl}${status} (${String(duration)}ms)${bodyLog}`, `${method} ${originalUrl}${status} (${String(duration)}ms)${extras}`,
) )
}, },
}), }),
) )
} }
private formatBody(
method: string,
body: Record<string, unknown> | undefined,
): string {
if (!['POST', 'PUT', 'PATCH'].includes(method)) return ''
const sanitized = sanitizeBody(body)
if (!sanitized || Object.keys(sanitized).length === 0) return ''
return ` body=${JSON.stringify(sanitized)}`
}
} }

View File

@@ -0,0 +1,60 @@
import type { Request } from 'express'
/** Fields stripped from logged request bodies to avoid leaking secrets. */
const SENSITIVE_FIELDS: ReadonlySet<string> = new Set([
'password',
'token',
'secret',
'code',
'sessionKey',
'encryptedData',
'iv',
])
const BODY_METHODS: ReadonlySet<string> = new Set(['POST', 'PUT', 'PATCH'])
/** Max characters of JSON-serialised body/query included in a log line. */
const MAX_LOG_PAYLOAD = 2048
function truncate(value: string): string {
return value.length > MAX_LOG_PAYLOAD
? `${value.slice(0, MAX_LOG_PAYLOAD)}…(truncated)`
: value
}
export function sanitizeBody(
body: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!body || typeof body !== 'object') return undefined
const keys = Object.keys(body)
if (keys.length === 0) return undefined
const result: Record<string, unknown> = {}
for (const key of keys) {
result[key] = SENSITIVE_FIELDS.has(key) ? '***' : body[key]
}
return result
}
/**
* Build a human-readable suffix for a log line:
* ` | query={…} body={…}`
* Returns an empty string when there is nothing to append.
*/
export function formatRequestExtras(request: Request): string {
const parts: string[] = []
const query = request.query
if (query && Object.keys(query).length > 0) {
parts.push(`query=${truncate(JSON.stringify(query))}`)
}
if (BODY_METHODS.has(request.method)) {
const sanitized = sanitizeBody(request.body as Record<string, unknown>)
if (sanitized) {
parts.push(`body=${truncate(JSON.stringify(sanitized))}`)
}
}
return parts.length > 0 ? ` | ${parts.join(' ')}` : ''
}

View File

@@ -1,10 +1,12 @@
import { IsDateString, IsInt, IsOptional, IsUUID, Min } from 'class-validator' import { IsDateString, IsInt, IsOptional, IsUUID, Min } from 'class-validator'
import { Type } from 'class-transformer'
export class UpdateUserMembershipDto { export class UpdateUserMembershipDto {
@IsUUID() @IsUUID()
cardTypeId!: string cardTypeId!: string
@IsOptional() @IsOptional()
@Type(() => Number)
@IsInt() @IsInt()
@Min(0) @Min(0)
remainingTimes?: number | null remainingTimes?: number | null

View File

@@ -2,7 +2,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'
import { MembershipStatus, BookingStatus, UserRole, CardTypeCategory } from '@mp-pilates/shared' import { MembershipStatus, BookingStatus, UserRole, CardTypeCategory } from '@mp-pilates/shared'
import type { PaginatedData, UserProfileResponse, UserStatsResponse } from '@mp-pilates/shared' import type { PaginatedData, UserProfileResponse, UserStatsResponse } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service' import { PrismaService } from '../prisma/prisma.service'
import { Membership, CardType, Prisma } from '@prisma/client'
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto' import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory)) const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
@@ -236,7 +235,7 @@ export class UserService {
where: { userId }, where: { userId },
include: { cardType: true }, include: { cardType: true },
}) })
return membership ? { ...membership, cardType: { ...membership.cardType } } : null return { membership }
} }
async updateUserMembership(userId: string, dto: UpdateUserMembershipDto) { async updateUserMembership(userId: string, dto: UpdateUserMembershipDto) {
@@ -252,38 +251,28 @@ export class UserService {
status = MembershipStatus.USED_UP status = MembershipStatus.USED_UP
} }
const existing = await this.prisma.membership.findFirst({ where: { userId } }) const data = {
cardTypeId: dto.cardTypeId,
let membership: Membership & { cardType: CardType } remainingTimes: dto.remainingTimes ?? null,
if (existing) { startDate: new Date(dto.startDate),
const updated = await this.prisma.membership.update({ expireDate: new Date(dto.expireDate),
where: { id: existing.id }, status,
data: {
cardTypeId: dto.cardTypeId,
remainingTimes: dto.remainingTimes ?? null,
startDate: new Date(dto.startDate),
expireDate: new Date(dto.expireDate),
status,
},
include: { cardType: true },
})
membership = { ...updated, cardType: { ...updated.cardType } }
} else {
const created = await this.prisma.membership.create({
data: {
userId,
cardTypeId: dto.cardTypeId,
remainingTimes: dto.remainingTimes ?? null,
startDate: new Date(dto.startDate),
expireDate: new Date(dto.expireDate),
status,
},
include: { cardType: true },
})
membership = { ...created, cardType: { ...created.cardType } }
} }
return membership const existing = await this.prisma.membership.findFirst({ where: { userId } })
if (existing) {
return this.prisma.membership.update({
where: { id: existing.id },
data,
include: { cardType: true },
})
}
return this.prisma.membership.create({
data: { userId, ...data },
include: { cardType: true },
})
} }
async deleteUserMembership(userId: string): Promise<void> { async deleteUserMembership(userId: string): Promise<void> {