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

View File

@@ -88,10 +88,11 @@
</view> </view>
<!-- Detail/Edit modal --> <!-- Detail/Edit modal -->
<view v-if="showDetail && detailMember" class="modal-mask" @tap.self="closeModal"> <view v-if="showDetail && detailMember" class="modal-mask" @tap="closeModal">
<view class="modal"> <view class="modal" @tap.stop>
<!-- modal-header --> <!-- Drag indicator + close -->
<view class="modal-header"> <view class="modal-handle">
<view class="modal-handle-bar" />
<view class="modal-close-btn" @tap="closeModal"> <view class="modal-close-btn" @tap="closeModal">
<text class="modal-close-icon">×</text> <text class="modal-close-icon">×</text>
</view> </view>
@@ -104,91 +105,115 @@
:class="{ 'tab-item--active': activeTab === 'detail' }" :class="{ 'tab-item--active': activeTab === 'detail' }"
@tap="switchTab('detail')" @tap="switchTab('detail')"
> >
<text class="tab-text" :class="{ 'tab-text--active': activeTab === 'detail' }">详情</text> <text class="tab-label" :class="{ 'tab-label--active': activeTab === 'detail' }">详情</text>
</view> </view>
<view <view
class="tab-item" class="tab-item"
:class="{ 'tab-item--active': activeTab === 'edit' }" :class="{ 'tab-item--active': activeTab === 'edit' }"
@tap="switchTab('edit')" @tap="switchTab('edit')"
> >
<text class="tab-text" :class="{ 'tab-text--active': activeTab === 'edit' }">编辑</text> <text class="tab-label" :class="{ 'tab-label--active': activeTab === 'edit' }">编辑</text>
</view> </view>
</view> </view>
<!-- Detail tab content --> <!-- Scrollable content -->
<scroll-view scroll-y class="modal-scroll">
<!-- Detail Tab -->
<view v-if="activeTab === 'detail'" class="tab-content"> <view v-if="activeTab === 'detail'" class="tab-content">
<!-- User info --> <!-- User info horizontal -->
<view class="detail-header"> <view class="user-card">
<view class="detail-avatar"> <view class="user-avatar">
<image v-if="detailMember.avatarUrl" class="avatar-img" :src="detailMember.avatarUrl" mode="aspectFill" /> <image v-if="detailMember.avatarUrl" class="avatar-img" :src="detailMember.avatarUrl" mode="aspectFill" />
<view v-else class="avatar-placeholder avatar-placeholder--lg"> <view v-else class="avatar-placeholder avatar-placeholder--lg">
<text class="avatar-text avatar-text--lg">{{ (detailMember.nickname || '?').slice(0, 1) }}</text> <text class="avatar-text avatar-text--lg">{{ (detailMember.nickname || '?').slice(0, 1) }}</text>
</view> </view>
</view> </view>
<text class="detail-name">{{ detailMember.nickname || '未知用户' }}</text> <view class="user-info">
<text class="detail-openid" @tap="copyOpenid(detailMember.openid)"> <text class="user-name">{{ detailMember.nickname || '未知用户' }}</text>
<text class="user-openid" @tap="copyOpenid(detailMember.openid)">
{{ detailMember.openid }} {{ detailMember.openid }}
</text> </text>
<text class="detail-phone">{{ detailMember.phone || '未绑定手机' }}</text> <text class="user-phone">{{ detailMember.phone || '未绑定手机' }}</text>
</view>
</view> </view>
<!-- Stats --> <!-- Stats grid -->
<view class="detail-stats"> <view class="stats-grid">
<view class="detail-stat"> <view class="stats-cell">
<text class="detail-stat-value">{{ detailMember.totalBookings }}</text> <text class="stats-cell-value">{{ detailMember.totalBookings }}</text>
<text class="detail-stat-label">总预约</text> <text class="stats-cell-label">总预约</text>
</view> </view>
<view class="detail-stat"> <view class="stats-cell">
<text class="detail-stat-value">{{ detailMember.completedBookings }}</text> <text class="stats-cell-value">{{ detailMember.completedBookings }}</text>
<text class="detail-stat-label">已完成</text> <text class="stats-cell-label">已完成</text>
</view> </view>
<view class="detail-stat"> <view class="stats-cell">
<text class="detail-stat-value">{{ detailMember.cancelledBookings }}</text> <text class="stats-cell-value">{{ detailMember.cancelledBookings }}</text>
<text class="detail-stat-label">已取消</text> <text class="stats-cell-label">已取消</text>
</view> </view>
</view> </view>
<!-- Membership card --> <!-- Membership card -->
<view v-if="detailMembership" class="membership-card"> <view v-if="detailMembership" class="mship-card">
<view class="membership-card-header"> <view class="mship-strip" :class="getCardGradientClass(detailMembership.cardType?.type)" />
<text class="membership-card-name">{{ detailMembership.cardTypeName }}</text> <view class="mship-header" :class="getCardGradientClass(detailMembership.cardType?.type)">
<text class="membership-card-badge" :class="'badge--' + detailMembership.status.toLowerCase()"> <view class="mship-header-left">
{{ statusLabel(detailMembership.status) }} <text class="mship-name">{{ detailMembership.cardType?.name || '未知卡类型' }}</text>
<view class="mship-type-badge">
<text class="mship-type-badge-text">{{ getCardTypeLabel(detailMembership.cardType?.type) }}</text>
</view>
</view>
<view class="mship-status-badge" :class="'mship-status--' + detailMembership.status.toLowerCase()">
<text class="mship-status-text">{{ statusLabel(detailMembership.status) }}</text>
</view>
</view>
<view class="mship-body">
<!-- Times highlight -->
<view v-if="detailMembership.remainingTimes !== null" class="mship-highlight-row">
<text class="mship-highlight-label">剩余课时</text>
<view class="mship-highlight-value-wrap">
<text class="mship-highlight-number">{{ detailMembership.remainingTimes }}</text>
<text class="mship-highlight-unit"> </text>
</view>
</view>
<!-- Progress bar -->
<view v-if="detailMembership.remainingTimes !== null && detailMembership.cardType?.totalTimes" class="mship-progress-wrap">
<view class="mship-progress-bar">
<view class="mship-progress-fill" :style="{ width: getMembershipProgressWidth(detailMembership) }" />
</view>
<text class="mship-progress-label">
已使用 {{ getMembershipUsedTimes(detailMembership) }} / {{ detailMembership.cardType.totalTimes }}
</text> </text>
</view> </view>
<view class="membership-card-info"> <!-- Date info -->
<view class="membership-info-item"> <view class="mship-info-row">
<text class="membership-info-label">剩余次数</text> <text class="mship-info-label">开始日期</text>
<text class="membership-info-value">{{ detailMembership.remainingTimes ?? '不限' }}</text> <text class="mship-info-value">{{ formatDate(detailMembership.startDate) }}</text>
</view> </view>
<view class="membership-info-item"> <view class="mship-info-row">
<text class="membership-info-label">开始日期</text> <text class="mship-info-label">到期日期</text>
<text class="membership-info-value">{{ formatDate(detailMembership.startDate) }}</text> <text class="mship-info-value">{{ formatDate(detailMembership.expireDate) }}</text>
</view> </view>
<view class="membership-info-item">
<text class="membership-info-label">到期日期</text>
<text class="membership-info-value">{{ formatDate(detailMembership.expireDate) }}</text>
</view> </view>
<!-- Danger action -->
<view class="mship-footer">
<text class="mship-remove-link" @tap="onClearMembership">解除会员卡</text>
</view> </view>
</view> </view>
<!-- No membership --> <!-- No membership -->
<view v-else class="no-membership"> <view v-else class="no-mship">
<text class="no-membership-text">暂无会员卡</text> <text class="no-mship-icon">💳</text>
<view class="no-membership-btn" @tap="goEdit"> <text class="no-mship-title">暂无会员卡</text>
<text class="no-membership-btn-text">去开</text> <text class="no-mship-sub">为该用户开通会员</text>
</view> <view class="no-mship-btn" @tap="goEdit">
</view> <text class="no-mship-btn-text">去开卡</text>
<!-- Danger zone -->
<view v-if="detailMembership" class="danger-zone">
<view class="danger-btn" @tap="onClearMembership">
<text class="danger-btn-text">解除会员卡</text>
</view> </view>
</view> </view>
</view> </view>
<!-- Edit tab content --> <!-- Edit Tab -->
<view v-if="activeTab === 'edit'" class="tab-content"> <view v-if="activeTab === 'edit'" class="tab-content">
<!-- Loading --> <!-- Loading -->
<view v-if="editLoading" class="edit-loading"> <view v-if="editLoading" class="edit-loading">
@@ -197,6 +222,12 @@
<!-- Form --> <!-- Form -->
<view v-else-if="formReady" class="edit-form"> <view v-else-if="formReady" class="edit-form">
<!-- Section header -->
<view class="section-header">
<view class="section-dot" />
<text class="section-title">{{ detailMembership ? '编辑会员卡' : '开通会员卡' }}</text>
</view>
<!-- Card type --> <!-- Card type -->
<view class="form-item"> <view class="form-item">
<text class="form-label">卡类型</text> <text class="form-label">卡类型</text>
@@ -215,7 +246,7 @@
</picker> </picker>
</view> </view>
<!-- Remaining times (for TIMES/TRIAL) --> <!-- Remaining times -->
<view v-if="isTimeBasedCard" class="form-item"> <view v-if="isTimeBasedCard" class="form-item">
<text class="form-label">剩余次数</text> <text class="form-label">剩余次数</text>
<input <input
@@ -257,7 +288,6 @@
</view> </view>
</picker> </picker>
</view> </view>
</view>
<!-- Submit button --> <!-- Submit button -->
<view class="edit-submit"> <view class="edit-submit">
@@ -271,6 +301,9 @@
</view> </view>
</view> </view>
</view> </view>
</scroll-view>
</view>
</view> </view>
</view> </view>
</template> </template>
@@ -280,8 +313,11 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { onReachBottom } from '@dcloudio/uni-app' import { onReachBottom } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { getCardTypeLabel, getCardGradientClass, getMembershipProgressWidth, getMembershipUsedTimes } from '../../utils/format'
import { useAdminStore } from '../../stores/admin' import { useAdminStore } from '../../stores/admin'
import type { MemberSummary } from '../../stores/admin' import type { MemberSummary, UserMembership } from '../../stores/admin'
import type { CardType } from '@mp-pilates/shared'
import { CardTypeCategory } from '@mp-pilates/shared'
const adminStore = useAdminStore() const adminStore = useAdminStore()
@@ -300,10 +336,12 @@ const showDetail = ref(false)
const detailMember = ref<MemberSummary | null>(null) const detailMember = ref<MemberSummary | null>(null)
const activeTab = ref<'detail' | 'edit'>('detail') const activeTab = ref<'detail' | 'edit'>('detail')
const detailMembership = ref<any>(null) type DetailMembership = NonNullable<UserMembership['membership']>
const detailMembership = ref<DetailMembership | null>(null)
const editLoading = ref(false) const editLoading = ref(false)
const editSubmitting = ref(false) const editSubmitting = ref(false)
const editCardTypes = ref<any[]>([]) const editCardTypes = ref<CardType[]>([])
const formReady = ref(false) const formReady = ref(false)
const editForm = ref({ const editForm = ref({
@@ -533,9 +571,12 @@ async function onSaveMembership() {
if (!userId) return if (!userId) return
editSubmitting.value = true editSubmitting.value = true
try { try {
const remainingTimes = isTimeBasedCard.value
? Number(editForm.value.remainingTimes) || 0
: null
await adminStore.updateUserMembership(userId, { await adminStore.updateUserMembership(userId, {
cardTypeId: editForm.value.cardTypeId, cardTypeId: editForm.value.cardTypeId,
remainingTimes: editForm.value.remainingTimes, remainingTimes,
startDate: editForm.value.startDate, startDate: editForm.value.startDate,
expireDate: editForm.value.expireDate, expireDate: editForm.value.expireDate,
}) })
@@ -820,7 +861,7 @@ onMounted(() => loadMembers(true))
.list-footer-text { font-size: 24rpx; color: $text-hint; } .list-footer-text { font-size: 24rpx; color: $text-hint; }
/* ── Detail modal ────────────────────────── */ /* ── Modal shell ─────────────────────────── */
.modal-mask { .modal-mask {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -832,73 +873,33 @@ onMounted(() => loadMembers(true))
.modal { .modal {
width: 100%; width: 100%;
max-height: 80vh;
background: $bg-card; background: $bg-card;
border-radius: $radius-lg $radius-lg 0 0; border-radius: $radius-lg $radius-lg 0 0;
padding: 48rpx 32rpx 60rpx;
}
.detail-header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
gap: 12rpx;
margin-bottom: 40rpx;
}
.detail-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
overflow: hidden; overflow: hidden;
margin-bottom: 8rpx;
} }
.detail-name { font-size: 32rpx; font-weight: 700; color: $brand-color; } .modal-handle {
.detail-openid {
font-size: 22rpx;
color: $accent-color;
font-family: Menlo, Monaco, Consolas, monospace;
padding: 6rpx 16rpx;
background: rgba($accent-color, 0.08);
border-radius: 8rpx;
}
.detail-phone { font-size: 26rpx; color: $text-secondary; }
.detail-stats {
display: flex;
justify-content: space-around;
background: $bg-page;
border-radius: $radius-md;
padding: 28rpx;
margin-bottom: 32rpx;
}
.detail-stat { display: flex; flex-direction: column; align-items: center; gap: 8rpx; }
.detail-stat-value { font-size: 40rpx; font-weight: 800; color: $accent-color; }
.detail-stat-label { font-size: 22rpx; color: $text-hint; }
.modal-close {
width: 100%;
height: 88rpx;
background: $bg-page;
border-radius: 44rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 20rpx 32rpx 8rpx;
position: relative;
} }
.modal-close-text { font-size: 28rpx; color: $text-secondary; } .modal-handle-bar {
width: 64rpx;
/* ── Modal header ─────────────────────────── */ height: 6rpx;
.modal-header { border-radius: 3rpx;
display: flex; background: $border-color;
justify-content: flex-end;
margin-bottom: 24rpx;
} }
.modal-close-btn { .modal-close-btn {
position: absolute;
right: 24rpx;
top: 14rpx;
width: 48rpx; width: 48rpx;
height: 48rpx; height: 48rpx;
display: flex; display: flex;
@@ -909,48 +910,343 @@ onMounted(() => loadMembers(true))
} }
.modal-close-icon { .modal-close-icon {
font-size: 36rpx; font-size: 32rpx;
color: $text-secondary; color: $text-secondary;
line-height: 1; line-height: 1;
} }
/* ── Tab bar ─────────────────────────────── */ /* ── Tab bar ─────────────────────────────── */
.tab-bar { .tab-bar {
display: flex; display: flex;
gap: 16rpx; margin: 16rpx 32rpx 0;
margin-bottom: 32rpx; background: $bg-page;
border-radius: $radius-md;
padding: 6rpx;
} }
.tab-item { .tab-item {
flex: 1; flex: 1;
height: 80rpx; height: 68rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: $bg-page; border-radius: 12rpx;
border-radius: $radius-md; transition: background 0.2s;
} }
.tab-item--active { .tab-item--active {
background: $brand-color; background: $bg-card;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
} }
.tab-text { .tab-label {
font-size: 28rpx; font-size: 26rpx;
color: $text-secondary; color: $text-hint;
font-weight: 500; font-weight: 500;
} }
.tab-text--active { .tab-label--active {
color: $accent-color; color: $brand-color;
font-weight: 700; font-weight: 700;
} }
/* ── Tab content ──────────────────────────── */ /* ── Scrollable content ──────────────────── */
.tab-content { .modal-scroll {
min-height: 400rpx; flex: 1;
overflow: hidden;
} }
.tab-content {
padding: 24rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
}
/* ── User card (horizontal) ──────────────── */
.user-card {
display: flex;
align-items: center;
gap: 24rpx;
margin-bottom: 24rpx;
}
.user-avatar {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
}
.user-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
min-width: 0;
}
.user-name {
font-size: 32rpx;
font-weight: 700;
color: $brand-color;
}
.user-openid {
font-size: 20rpx;
color: $accent-color;
font-family: Menlo, Monaco, Consolas, monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-phone {
font-size: 24rpx;
color: $text-hint;
}
/* ── Stats grid ──────────────────────────── */
.stats-grid {
display: flex;
background: $bg-page;
border-radius: $radius-md;
padding: 24rpx 0;
margin-bottom: 24rpx;
}
.stats-cell {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
position: relative;
& + & {
border-left: 1rpx solid $border-color;
}
}
.stats-cell-value {
font-size: 40rpx;
font-weight: 800;
color: $accent-color;
line-height: 1.1;
}
.stats-cell-label {
font-size: 22rpx;
color: $text-hint;
}
/* ── Membership card (gradient) ──────────── */
.mship-card {
background: $bg-card;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.07);
margin-bottom: 24rpx;
}
.mship-strip {
height: 6rpx;
}
.mship-header {
padding: 22rpx 28rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.gradient--times {
&.mship-strip,
&.mship-header { background: linear-gradient(90deg, #1a1a2e, #2d2d5e); }
}
.gradient--duration {
&.mship-strip,
&.mship-header { background: linear-gradient(90deg, #6c3483, #9b59b6); }
}
.gradient--trial {
&.mship-strip,
&.mship-header { background: linear-gradient(90deg, #5a7a8a, $primary-dark); }
}
.mship-header-left {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.mship-name {
font-size: 30rpx;
font-weight: 700;
color: #fff;
}
.mship-type-badge {
align-self: flex-start;
padding: 4rpx 14rpx;
border-radius: 12rpx;
background: rgba(255, 255, 255, 0.15);
border: 1rpx solid rgba(255, 255, 255, 0.25);
}
.mship-type-badge-text {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.85);
font-weight: 500;
}
.mship-status-badge {
padding: 8rpx 20rpx;
border-radius: 20rpx;
border: 1rpx solid rgba(255, 255, 255, 0.35);
flex-shrink: 0;
}
.mship-status--active { background: rgba(76, 175, 80, 0.3); }
.mship-status--expired { background: rgba(0, 0, 0, 0.2); }
.mship-status--used_up { background: rgba(0, 0, 0, 0.2); }
.mship-status-text {
font-size: 22rpx;
color: #fff;
font-weight: 600;
}
.mship-body {
padding: 20rpx 28rpx 24rpx;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.mship-highlight-row {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 4rpx;
}
.mship-highlight-label {
font-size: 26rpx;
color: $text-hint;
}
.mship-highlight-value-wrap {
display: flex;
align-items: baseline;
}
.mship-highlight-number {
font-size: 44rpx;
font-weight: 800;
color: $accent-color;
line-height: 1;
}
.mship-highlight-unit {
font-size: 22rpx;
color: $accent-color;
font-weight: 500;
}
.mship-progress-wrap {
display: flex;
flex-direction: column;
gap: 8rpx;
margin-bottom: 6rpx;
}
.mship-progress-bar {
height: 8rpx;
background: #f0f0f0;
border-radius: 4rpx;
overflow: hidden;
}
.mship-progress-fill {
height: 100%;
background: linear-gradient(90deg, $accent-color, $primary-color);
border-radius: 4rpx;
transition: width 0.4s ease;
}
.mship-progress-label {
font-size: 22rpx;
color: $text-hint;
text-align: right;
}
.mship-info-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.mship-info-label {
font-size: 26rpx;
color: $text-hint;
}
.mship-info-value {
font-size: 26rpx;
color: $text-primary;
font-weight: 500;
}
.mship-footer {
padding: 16rpx 28rpx 20rpx;
display: flex;
justify-content: flex-end;
}
.mship-remove-link {
font-size: 24rpx;
color: $error-color;
}
/* ── No membership ───────────────────────── */
.no-mship {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
padding: 48rpx 0 24rpx;
}
.no-mship-icon {
font-size: 80rpx;
}
.no-mship-title {
font-size: 30rpx;
font-weight: 600;
color: $text-primary;
}
.no-mship-sub {
font-size: 26rpx;
color: $text-hint;
}
.no-mship-btn {
margin-top: 12rpx;
padding: 20rpx 56rpx;
border-radius: 44rpx;
background: $accent-color;
box-shadow: 0 4rpx 16rpx rgba(123, 165, 190, 0.35);
}
.no-mship-btn-text {
font-size: 28rpx;
font-weight: 600;
color: #fff;
}
/* ── Edit loading ────────────────────────── */
.edit-loading { .edit-loading {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -963,121 +1259,28 @@ onMounted(() => loadMembers(true))
color: $text-hint; color: $text-hint;
} }
/* ── Membership card ──────────────────────── */ /* ── Section header ──────────────────────── */
.membership-card { .section-header {
background: $bg-page; display: flex;
border-radius: $radius-md; align-items: center;
padding: 28rpx; gap: 12rpx;
margin-bottom: 24rpx; margin-bottom: 24rpx;
} }
.membership-card-header { .section-dot {
display: flex; width: 6rpx;
align-items: center; height: 28rpx;
justify-content: space-between; border-radius: 3rpx;
margin-bottom: 20rpx; background: $accent-color;
flex-shrink: 0;
} }
.membership-card-name { .section-title {
font-size: 32rpx; font-size: 30rpx;
font-weight: 700; font-weight: 700;
color: $brand-color; color: $brand-color;
} }
.membership-card-badge {
font-size: 22rpx;
font-weight: 600;
padding: 4rpx 16rpx;
border-radius: 20rpx;
}
.badge--active {
color: #52c41a;
background: rgba(#52c41a, 0.1);
}
.badge--expired {
color: #999;
background: rgba(#999, 0.1);
}
.badge--used_up {
color: #e64329;
background: rgba(#e64329, 0.1);
}
.membership-card-info {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.membership-info-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.membership-info-label {
font-size: 26rpx;
color: $text-hint;
}
.membership-info-value {
font-size: 26rpx;
color: $text-primary;
font-weight: 600;
}
/* ── No membership ────────────────────────── */
.no-membership {
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
padding: 48rpx 0;
}
.no-membership-text {
font-size: 28rpx;
color: $text-hint;
}
.no-membership-btn {
background: $brand-color;
border-radius: $radius-md;
padding: 20rpx 48rpx;
}
.no-membership-btn-text {
font-size: 28rpx;
font-weight: 600;
color: $accent-color;
}
/* ── Danger zone ─────────────────────────── */
.danger-zone {
margin-top: 32rpx;
padding-top: 32rpx;
border-top: 1rpx solid $border-color;
}
.danger-btn {
width: 100%;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(#e64329, 0.08);
border-radius: $radius-md;
}
.danger-btn-text {
font-size: 28rpx;
color: #e64329;
font-weight: 600;
}
/* ── Edit form ───────────────────────────── */ /* ── Edit form ───────────────────────────── */
.edit-form { .edit-form {
display: flex; display: flex;
@@ -1132,19 +1335,24 @@ onMounted(() => loadMembers(true))
color: $text-hint; color: $text-hint;
} }
/* ── Edit submit ──────────────────────────── */ /* ── Submit button ───────────────────────── */
.edit-submit { .edit-submit {
margin-top: 32rpx; margin-top: 16rpx;
} }
.submit-btn { .submit-btn {
width: 100%; width: 100%;
height: 88rpx; height: 88rpx;
background: $brand-color;
border-radius: 44rpx; border-radius: 44rpx;
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 4rpx 16rpx rgba(26, 26, 46, 0.3);
&:active {
opacity: 0.85;
}
} }
.submit-btn--disabled { .submit-btn--disabled {
@@ -1154,6 +1362,7 @@ onMounted(() => loadMembers(true))
.submit-btn-text { .submit-btn-text {
font-size: 28rpx; font-size: 28rpx;
font-weight: 600; font-weight: 600;
color: $accent-color; color: $primary-dark;
letter-spacing: 1rpx;
} }
</style> </style>

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 = {
let membership: Membership & { cardType: CardType }
if (existing) {
const updated = await this.prisma.membership.update({
where: { id: existing.id },
data: {
cardTypeId: dto.cardTypeId, cardTypeId: dto.cardTypeId,
remainingTimes: dto.remainingTimes ?? null, remainingTimes: dto.remainingTimes ?? null,
startDate: new Date(dto.startDate), startDate: new Date(dto.startDate),
expireDate: new Date(dto.expireDate), expireDate: new Date(dto.expireDate),
status, 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> {