feat: 支持会员卡设置
This commit is contained in:
@@ -88,10 +88,11 @@
|
||||
</view>
|
||||
|
||||
<!-- Detail/Edit modal -->
|
||||
<view v-if="showDetail && detailMember" class="modal-mask" @tap.self="closeModal">
|
||||
<view class="modal">
|
||||
<!-- modal-header -->
|
||||
<view class="modal-header">
|
||||
<view v-if="showDetail && detailMember" class="modal-mask" @tap="closeModal">
|
||||
<view class="modal" @tap.stop>
|
||||
<!-- Drag indicator + close -->
|
||||
<view class="modal-handle">
|
||||
<view class="modal-handle-bar" />
|
||||
<view class="modal-close-btn" @tap="closeModal">
|
||||
<text class="modal-close-icon">×</text>
|
||||
</view>
|
||||
@@ -104,91 +105,115 @@
|
||||
:class="{ 'tab-item--active': activeTab === '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
|
||||
class="tab-item"
|
||||
:class="{ 'tab-item--active': activeTab === '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>
|
||||
|
||||
<!-- Detail tab content -->
|
||||
<!-- Scrollable content -->
|
||||
<scroll-view scroll-y class="modal-scroll">
|
||||
|
||||
<!-- ═══ Detail Tab ═══ -->
|
||||
<view v-if="activeTab === 'detail'" class="tab-content">
|
||||
<!-- User info -->
|
||||
<view class="detail-header">
|
||||
<view class="detail-avatar">
|
||||
<!-- User info — horizontal -->
|
||||
<view class="user-card">
|
||||
<view class="user-avatar">
|
||||
<image v-if="detailMember.avatarUrl" class="avatar-img" :src="detailMember.avatarUrl" mode="aspectFill" />
|
||||
<view v-else class="avatar-placeholder avatar-placeholder--lg">
|
||||
<text class="avatar-text avatar-text--lg">{{ (detailMember.nickname || '?').slice(0, 1) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="detail-name">{{ detailMember.nickname || '未知用户' }}</text>
|
||||
<text class="detail-openid" @tap="copyOpenid(detailMember.openid)">
|
||||
<view class="user-info">
|
||||
<text class="user-name">{{ detailMember.nickname || '未知用户' }}</text>
|
||||
<text class="user-openid" @tap="copyOpenid(detailMember.openid)">
|
||||
{{ detailMember.openid }}
|
||||
</text>
|
||||
<text class="detail-phone">{{ detailMember.phone || '未绑定手机' }}</text>
|
||||
<text class="user-phone">{{ detailMember.phone || '未绑定手机' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Stats -->
|
||||
<view class="detail-stats">
|
||||
<view class="detail-stat">
|
||||
<text class="detail-stat-value">{{ detailMember.totalBookings }}</text>
|
||||
<text class="detail-stat-label">总预约</text>
|
||||
<!-- Stats grid -->
|
||||
<view class="stats-grid">
|
||||
<view class="stats-cell">
|
||||
<text class="stats-cell-value">{{ detailMember.totalBookings }}</text>
|
||||
<text class="stats-cell-label">总预约</text>
|
||||
</view>
|
||||
<view class="detail-stat">
|
||||
<text class="detail-stat-value">{{ detailMember.completedBookings }}</text>
|
||||
<text class="detail-stat-label">已完成</text>
|
||||
<view class="stats-cell">
|
||||
<text class="stats-cell-value">{{ detailMember.completedBookings }}</text>
|
||||
<text class="stats-cell-label">已完成</text>
|
||||
</view>
|
||||
<view class="detail-stat">
|
||||
<text class="detail-stat-value">{{ detailMember.cancelledBookings }}</text>
|
||||
<text class="detail-stat-label">已取消</text>
|
||||
<view class="stats-cell">
|
||||
<text class="stats-cell-value">{{ detailMember.cancelledBookings }}</text>
|
||||
<text class="stats-cell-label">已取消</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Membership card -->
|
||||
<view v-if="detailMembership" class="membership-card">
|
||||
<view class="membership-card-header">
|
||||
<text class="membership-card-name">{{ detailMembership.cardTypeName }}</text>
|
||||
<text class="membership-card-badge" :class="'badge--' + detailMembership.status.toLowerCase()">
|
||||
{{ statusLabel(detailMembership.status) }}
|
||||
<view v-if="detailMembership" class="mship-card">
|
||||
<view class="mship-strip" :class="getCardGradientClass(detailMembership.cardType?.type)" />
|
||||
<view class="mship-header" :class="getCardGradientClass(detailMembership.cardType?.type)">
|
||||
<view class="mship-header-left">
|
||||
<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>
|
||||
</view>
|
||||
<view class="membership-card-info">
|
||||
<view class="membership-info-item">
|
||||
<text class="membership-info-label">剩余次数</text>
|
||||
<text class="membership-info-value">{{ detailMembership.remainingTimes ?? '不限' }}</text>
|
||||
<!-- Date info -->
|
||||
<view class="mship-info-row">
|
||||
<text class="mship-info-label">开始日期</text>
|
||||
<text class="mship-info-value">{{ formatDate(detailMembership.startDate) }}</text>
|
||||
</view>
|
||||
<view class="membership-info-item">
|
||||
<text class="membership-info-label">开始日期</text>
|
||||
<text class="membership-info-value">{{ formatDate(detailMembership.startDate) }}</text>
|
||||
<view class="mship-info-row">
|
||||
<text class="mship-info-label">到期日期</text>
|
||||
<text class="mship-info-value">{{ formatDate(detailMembership.expireDate) }}</text>
|
||||
</view>
|
||||
<view class="membership-info-item">
|
||||
<text class="membership-info-label">到期日期</text>
|
||||
<text class="membership-info-value">{{ formatDate(detailMembership.expireDate) }}</text>
|
||||
</view>
|
||||
<!-- Danger action -->
|
||||
<view class="mship-footer">
|
||||
<text class="mship-remove-link" @tap="onClearMembership">解除会员卡</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- No membership -->
|
||||
<view v-else class="no-membership">
|
||||
<text class="no-membership-text">暂无会员卡</text>
|
||||
<view class="no-membership-btn" @tap="goEdit">
|
||||
<text class="no-membership-btn-text">去开卡</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Danger zone -->
|
||||
<view v-if="detailMembership" class="danger-zone">
|
||||
<view class="danger-btn" @tap="onClearMembership">
|
||||
<text class="danger-btn-text">解除会员卡</text>
|
||||
<view v-else class="no-mship">
|
||||
<text class="no-mship-icon">💳</text>
|
||||
<text class="no-mship-title">暂无会员卡</text>
|
||||
<text class="no-mship-sub">为该用户开通会员卡</text>
|
||||
<view class="no-mship-btn" @tap="goEdit">
|
||||
<text class="no-mship-btn-text">去开卡</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Edit tab content -->
|
||||
<!-- ═══ Edit Tab ═══ -->
|
||||
<view v-if="activeTab === 'edit'" class="tab-content">
|
||||
<!-- Loading -->
|
||||
<view v-if="editLoading" class="edit-loading">
|
||||
@@ -197,6 +222,12 @@
|
||||
|
||||
<!-- 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 -->
|
||||
<view class="form-item">
|
||||
<text class="form-label">卡类型</text>
|
||||
@@ -215,7 +246,7 @@
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<!-- Remaining times (for TIMES/TRIAL) -->
|
||||
<!-- Remaining times -->
|
||||
<view v-if="isTimeBasedCard" class="form-item">
|
||||
<text class="form-label">剩余次数</text>
|
||||
<input
|
||||
@@ -257,7 +288,6 @@
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Submit button -->
|
||||
<view class="edit-submit">
|
||||
@@ -271,6 +301,9 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -280,8 +313,11 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { onReachBottom } from '@dcloudio/uni-app'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { getCardTypeLabel, getCardGradientClass, getMembershipProgressWidth, getMembershipUsedTimes } from '../../utils/format'
|
||||
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()
|
||||
|
||||
@@ -300,10 +336,12 @@ const showDetail = ref(false)
|
||||
const detailMember = ref<MemberSummary | null>(null)
|
||||
|
||||
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 editSubmitting = ref(false)
|
||||
const editCardTypes = ref<any[]>([])
|
||||
const editCardTypes = ref<CardType[]>([])
|
||||
const formReady = ref(false)
|
||||
|
||||
const editForm = ref({
|
||||
@@ -533,9 +571,12 @@ async function onSaveMembership() {
|
||||
if (!userId) return
|
||||
editSubmitting.value = true
|
||||
try {
|
||||
const remainingTimes = isTimeBasedCard.value
|
||||
? Number(editForm.value.remainingTimes) || 0
|
||||
: null
|
||||
await adminStore.updateUserMembership(userId, {
|
||||
cardTypeId: editForm.value.cardTypeId,
|
||||
remainingTimes: editForm.value.remainingTimes,
|
||||
remainingTimes,
|
||||
startDate: editForm.value.startDate,
|
||||
expireDate: editForm.value.expireDate,
|
||||
})
|
||||
@@ -820,7 +861,7 @@ onMounted(() => loadMembers(true))
|
||||
|
||||
.list-footer-text { font-size: 24rpx; color: $text-hint; }
|
||||
|
||||
/* ── Detail modal ────────────────────────── */
|
||||
/* ── Modal shell ─────────────────────────── */
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -832,73 +873,33 @@ onMounted(() => loadMembers(true))
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
background: $bg-card;
|
||||
border-radius: $radius-lg $radius-lg 0 0;
|
||||
padding: 48rpx 32rpx 60rpx;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.detail-avatar {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.detail-name { font-size: 32rpx; font-weight: 700; color: $brand-color; }
|
||||
|
||||
.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;
|
||||
.modal-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20rpx 32rpx 8rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal-close-text { font-size: 28rpx; color: $text-secondary; }
|
||||
|
||||
/* ── Modal header ─────────────────────────── */
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 24rpx;
|
||||
.modal-handle-bar {
|
||||
width: 64rpx;
|
||||
height: 6rpx;
|
||||
border-radius: 3rpx;
|
||||
background: $border-color;
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
position: absolute;
|
||||
right: 24rpx;
|
||||
top: 14rpx;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
display: flex;
|
||||
@@ -909,48 +910,343 @@ onMounted(() => loadMembers(true))
|
||||
}
|
||||
|
||||
.modal-close-icon {
|
||||
font-size: 36rpx;
|
||||
font-size: 32rpx;
|
||||
color: $text-secondary;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Tab bar ──────────────────────────────── */
|
||||
/* ── Tab bar ─────────────────────────────── */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 32rpx;
|
||||
margin: 16rpx 32rpx 0;
|
||||
background: $bg-page;
|
||||
border-radius: $radius-md;
|
||||
padding: 6rpx;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
height: 68rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $bg-page;
|
||||
border-radius: $radius-md;
|
||||
border-radius: 12rpx;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.tab-item--active {
|
||||
background: $brand-color;
|
||||
background: $bg-card;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: 28rpx;
|
||||
color: $text-secondary;
|
||||
.tab-label {
|
||||
font-size: 26rpx;
|
||||
color: $text-hint;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tab-text--active {
|
||||
color: $accent-color;
|
||||
.tab-label--active {
|
||||
color: $brand-color;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Tab content ──────────────────────────── */
|
||||
.tab-content {
|
||||
min-height: 400rpx;
|
||||
/* ── Scrollable content ──────────────────── */
|
||||
.modal-scroll {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -963,121 +1259,28 @@ onMounted(() => loadMembers(true))
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
/* ── Membership card ──────────────────────── */
|
||||
.membership-card {
|
||||
background: $bg-page;
|
||||
border-radius: $radius-md;
|
||||
padding: 28rpx;
|
||||
/* ── Section header ──────────────────────── */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.membership-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20rpx;
|
||||
.section-dot {
|
||||
width: 6rpx;
|
||||
height: 28rpx;
|
||||
border-radius: 3rpx;
|
||||
background: $accent-color;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.membership-card-name {
|
||||
font-size: 32rpx;
|
||||
.section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -1132,19 +1335,24 @@ onMounted(() => loadMembers(true))
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
/* ── Edit submit ──────────────────────────── */
|
||||
/* ── Submit button ───────────────────────── */
|
||||
.edit-submit {
|
||||
margin-top: 32rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
background: $brand-color;
|
||||
border-radius: 44rpx;
|
||||
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4rpx 16rpx rgba(26, 26, 46, 0.3);
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-btn--disabled {
|
||||
@@ -1154,6 +1362,7 @@ onMounted(() => loadMembers(true))
|
||||
.submit-btn-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: $accent-color;
|
||||
color: $primary-dark;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<view class="card-header-left">
|
||||
<text class="card-name">{{ m.cardType.name }}</text>
|
||||
<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 class="status-badge status-badge--active">
|
||||
@@ -70,11 +70,11 @@
|
||||
<view class="progress-bar">
|
||||
<view
|
||||
class="progress-fill"
|
||||
:style="{ width: progressWidth(m) }"
|
||||
:style="{ width: getMembershipProgressWidth(m) }"
|
||||
/>
|
||||
</view>
|
||||
<text class="progress-label">
|
||||
已使用 {{ usedTimes(m) }} / {{ m.cardType.totalTimes }} 次
|
||||
已使用 {{ getMembershipUsedTimes(m) }} / {{ m.cardType.totalTimes }} 次
|
||||
</text>
|
||||
</view>
|
||||
</template>
|
||||
@@ -110,7 +110,7 @@
|
||||
<view class="card-header-left">
|
||||
<text class="card-name card-name--dim">{{ m.cardType.name }}</text>
|
||||
<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 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 { useUserStore } from '../../stores/user'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes } from '../../utils/format'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
@@ -170,14 +171,6 @@ const inactiveMemberships = computed(() =>
|
||||
)
|
||||
|
||||
// ─── 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 {
|
||||
const map: Record<MembershipStatus, string> = {
|
||||
@@ -206,17 +199,6 @@ function headerClass(type: CardTypeCategory): string {
|
||||
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 ─────────────────────────────────────────
|
||||
async function loadMemberships() {
|
||||
loading.value = true
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { CardType } 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 {
|
||||
return (cents / 100).toFixed(2)
|
||||
@@ -49,13 +54,13 @@ export function getDateRange(days: number): ReadonlyArray<{ readonly date: strin
|
||||
}
|
||||
|
||||
/** 会员卡类型标签 */
|
||||
export function getCardTypeLabel(type: CardTypeCategory): string {
|
||||
const map: Record<CardTypeCategory, string> = {
|
||||
export function getCardTypeLabel(type: CardTypeCategory | string): string {
|
||||
const map: Record<string, string> = {
|
||||
[CardTypeCategory.TIMES]: '次卡',
|
||||
[CardTypeCategory.DURATION]: '月卡',
|
||||
[CardTypeCategory.TRIAL]: '体验',
|
||||
[CardTypeCategory.TRIAL]: '体验卡',
|
||||
}
|
||||
return map[type] ?? '会员'
|
||||
return map[type] ?? '会员卡'
|
||||
}
|
||||
|
||||
/** 会员卡封面 CSS 类名 */
|
||||
@@ -70,3 +75,23 @@ export function isSlotPast(date: string, startTime: string): boolean {
|
||||
const slotDateTime = new Date(`${date}T${startTime}:00`)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@nestjs/common'
|
||||
import type { Request, Response } from 'express'
|
||||
import type { ApiResponse } from '@mp-pilates/shared'
|
||||
import { formatRequestExtras } from '../utils/request-log'
|
||||
|
||||
@Catch()
|
||||
export class ApiExceptionFilter implements ExceptionFilter {
|
||||
@@ -28,15 +29,16 @@ export class ApiExceptionFilter implements ExceptionFilter {
|
||||
? this.extractMessage(exception)
|
||||
: '服务器内部错误'
|
||||
|
||||
// Log all server errors (5xx) with full stack; log 4xx at warn level
|
||||
const extras = formatRequestExtras(request)
|
||||
|
||||
if (status >= 500) {
|
||||
this.logger.error(
|
||||
`${request.method} ${request.originalUrl} → ${String(status)} ${message}`,
|
||||
`${request.method} ${request.originalUrl} → ${String(status)} ${message}${extras}`,
|
||||
exception instanceof Error ? exception.stack : undefined,
|
||||
)
|
||||
} else if (status >= 400) {
|
||||
this.logger.warn(
|
||||
`${request.method} ${request.originalUrl} → ${String(status)} ${message}`,
|
||||
`${request.method} ${request.originalUrl} → ${String(status)} ${message}${extras}`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,28 +7,7 @@ import {
|
||||
} from '@nestjs/common'
|
||||
import { Observable, tap } from 'rxjs'
|
||||
import type { Request, Response } 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',
|
||||
])
|
||||
|
||||
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],
|
||||
),
|
||||
)
|
||||
}
|
||||
import { formatRequestExtras } from '../utils/request-log'
|
||||
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements NestInterceptor {
|
||||
@@ -44,9 +23,9 @@ export class LoggingInterceptor implements NestInterceptor {
|
||||
next: () => {
|
||||
const res = context.switchToHttp().getResponse<Response>()
|
||||
const duration = Date.now() - start
|
||||
const bodyLog = this.formatBody(method, req.body as Record<string, unknown>)
|
||||
const extras = formatRequestExtras(req)
|
||||
this.logger.log(
|
||||
`${method} ${originalUrl} → ${String(res.statusCode)} (${String(duration)}ms)${bodyLog}`,
|
||||
`${method} ${originalUrl} → ${String(res.statusCode)} (${String(duration)}ms)${extras}`,
|
||||
)
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
@@ -55,22 +34,12 @@ export class LoggingInterceptor implements NestInterceptor {
|
||||
err instanceof Object && 'getStatus' in err
|
||||
? String((err as { getStatus: () => number }).getStatus())
|
||||
: '500'
|
||||
const bodyLog = this.formatBody(method, req.body as Record<string, unknown>)
|
||||
const extras = formatRequestExtras(req)
|
||||
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)}`
|
||||
}
|
||||
}
|
||||
|
||||
60
packages/server/src/common/utils/request-log.ts
Normal file
60
packages/server/src/common/utils/request-log.ts
Normal 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(' ')}` : ''
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { IsDateString, IsInt, IsOptional, IsUUID, Min } from 'class-validator'
|
||||
import { Type } from 'class-transformer'
|
||||
|
||||
export class UpdateUserMembershipDto {
|
||||
@IsUUID()
|
||||
cardTypeId!: string
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
remainingTimes?: number | null
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { MembershipStatus, BookingStatus, UserRole, CardTypeCategory } from '@mp-pilates/shared'
|
||||
import type { PaginatedData, UserProfileResponse, UserStatsResponse } from '@mp-pilates/shared'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { Membership, CardType, Prisma } from '@prisma/client'
|
||||
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
|
||||
|
||||
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
|
||||
@@ -236,7 +235,7 @@ export class UserService {
|
||||
where: { userId },
|
||||
include: { cardType: true },
|
||||
})
|
||||
return membership ? { ...membership, cardType: { ...membership.cardType } } : null
|
||||
return { membership }
|
||||
}
|
||||
|
||||
async updateUserMembership(userId: string, dto: UpdateUserMembershipDto) {
|
||||
@@ -252,38 +251,28 @@ export class UserService {
|
||||
status = MembershipStatus.USED_UP
|
||||
}
|
||||
|
||||
const existing = await this.prisma.membership.findFirst({ where: { userId } })
|
||||
|
||||
let membership: Membership & { cardType: CardType }
|
||||
if (existing) {
|
||||
const updated = await this.prisma.membership.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
const 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> {
|
||||
|
||||
Reference in New Issue
Block a user