feat(app): add membership edit modal with tab UI

- Replace detail-only modal with tab-based (detail/edit) modal
- Detail tab shows membership card info, stats, and action buttons
- Edit tab provides form to modify card type, remaining times, start/expire dates
- Add loadDetailMembership, switchTab, initEditForm, onSaveMembership, onClearMembership functions
- Add computed isTimeBasedCard for edit form conditional rendering
- Append new styles for modal-header, tab-bar, membership-card, edit-form, etc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
richarjiang
2026-04-07 09:51:10 +08:00
parent b281990808
commit f14da2c538

View File

@@ -87,40 +87,187 @@
</text>
</view>
<!-- Detail modal -->
<view v-if="showDetail && detailMember" class="modal-mask" @tap.self="showDetail = false">
<!-- Detail/Edit modal -->
<view v-if="showDetail && detailMember" class="modal-mask" @tap.self="closeModal">
<view class="modal">
<view class="detail-header">
<view class="detail-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>
<!-- modal-header -->
<view class="modal-header">
<view class="modal-close-btn" @tap="closeModal">
<text class="modal-close-icon">×</text>
</view>
</view>
<!-- Tab bar -->
<view class="tab-bar">
<view
class="tab-item"
:class="{ 'tab-item--active': activeTab === 'detail' }"
@tap="switchTab('detail')"
>
<text class="tab-text" :class="{ 'tab-text--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>
</view>
</view>
<!-- Loading overlay for edit tab -->
<view v-if="editLoading" class="edit-loading">
<text class="edit-loading-text">加载中...</text>
</view>
<!-- Detail tab content -->
<view v-if="activeTab === 'detail'" class="tab-content">
<!-- User info -->
<view class="detail-header">
<view class="detail-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)">
{{ detailMember.openid }}
</text>
<text class="detail-phone">{{ detailMember.phone || '未绑定手机' }}</text>
</view>
<!-- Stats -->
<view class="detail-stats">
<view class="detail-stat">
<text class="detail-stat-value">{{ detailMember.totalBookings }}</text>
<text class="detail-stat-label">总预约</text>
</view>
<view class="detail-stat">
<text class="detail-stat-value">{{ detailMember.completedBookings }}</text>
<text class="detail-stat-label">已完成</text>
</view>
<view class="detail-stat">
<text class="detail-stat-value">{{ detailMember.cancelledBookings }}</text>
<text class="detail-stat-label">已取消</text>
</view>
</view>
<text class="detail-name">{{ detailMember.nickname || '未知用户' }}</text>
<text class="detail-openid" @tap="copyOpenid(detailMember.openid)">
{{ detailMember.openid }}
</text>
<text class="detail-phone">{{ detailMember.phone || '未绑定手机' }}</text>
</view>
<view class="detail-stats">
<view class="detail-stat">
<text class="detail-stat-value">{{ detailMember.totalBookings }}</text>
<text class="detail-stat-label">总预约</text>
<!-- 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) }}
</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>
</view>
<view class="membership-info-item">
<text class="membership-info-label">开始日期</text>
<text class="membership-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.expireDate) }}</text>
</view>
</view>
</view>
<view class="detail-stat">
<text class="detail-stat-value">{{ detailMember.completedBookings }}</text>
<text class="detail-stat-label">已完成</text>
<!-- 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>
<view class="detail-stat">
<text class="detail-stat-value">{{ detailMember.cancelledBookings }}</text>
<text class="detail-stat-label">已取消</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 class="modal-close" @tap="showDetail = false">
<text class="modal-close-text">关闭</text>
<!-- Edit tab content -->
<view v-if="activeTab === 'edit'" class="tab-content">
<view class="edit-form">
<!-- Card type -->
<view class="form-item">
<text class="form-label">卡类型</text>
<picker
class="form-picker"
mode="selector"
:value="editForm.cardTypeIndex"
:range="editCardTypes"
range-key="name"
@change="onCardTypeChangeForEdit"
>
<view class="form-picker-inner">
<text class="form-picker-text">{{ editCardTypes[editForm.cardTypeIndex]?.name || '请选择' }}</text>
<text class="form-picker-arrow"></text>
</view>
</picker>
</view>
<!-- Remaining times (for TIMES/TRIAL) -->
<view v-if="isTimeBasedCard" class="form-item">
<text class="form-label">剩余次数</text>
<input
class="form-input"
type="number"
v-model="editForm.remainingTimes"
placeholder="请输入剩余次数"
/>
</view>
<!-- Start date -->
<view class="form-item">
<text class="form-label">开始日期</text>
<picker
class="form-picker"
mode="date"
:value="editForm.startDate"
@change="onStartDateChange"
>
<view class="form-picker-inner">
<text class="form-picker-text">{{ editForm.startDate || '请选择' }}</text>
<text class="form-picker-arrow"></text>
</view>
</picker>
</view>
<!-- Expire date -->
<view class="form-item">
<text class="form-label">到期日期</text>
<picker
class="form-picker"
mode="date"
:value="editForm.expireDate"
@change="onExpireDateChange"
>
<view class="form-picker-inner">
<text class="form-picker-text">{{ editForm.expireDate || '请选择' }}</text>
<text class="form-picker-arrow"></text>
</view>
</picker>
</view>
</view>
<!-- Submit button -->
<view class="edit-submit">
<view
class="submit-btn"
:class="{ 'submit-btn--disabled': editSubmitting }"
@tap="onSaveMembership"
>
<text class="submit-btn-text">{{ editSubmitting ? '保存中...' : '保存' }}</text>
</view>
</view>
</view>
</view>
</view>
@@ -128,7 +275,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { onReachBottom } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
@@ -151,6 +298,26 @@ const hasMore = ref(false)
const showDetail = ref(false)
const detailMember = ref<MemberSummary | null>(null)
const activeTab = ref<'detail' | 'edit'>('detail')
const detailMembership = ref<any>(null)
const editLoading = ref(false)
const editSubmitting = ref(false)
const editCardTypes = ref<any[]>([])
const editForm = ref({
cardTypeIndex: 0,
cardTypeId: '',
remainingTimes: null as number | null,
startDate: '',
expireDate: '',
manuallyEditedExpire: false,
})
const isTimeBasedCard = computed(() => {
const card = editCardTypes.value[editForm.value.cardTypeIndex]
return card && (card.type === 'TIMES' || card.type === 'TRIAL')
})
const LIMIT = 20
const cardTypeOptions = [
@@ -225,6 +392,9 @@ onReachBottom(() => {
function openDetail(m: MemberSummary) {
detailMember.value = m
showDetail.value = true
detailMembership.value = null
activeTab.value = 'detail'
loadDetailMembership(m.userId)
}
function copyOpenid(openid: string) {
@@ -234,6 +404,171 @@ function copyOpenid(openid: string) {
})
}
function formatDate(dateStr: string): string {
if (!dateStr) return '-'
return dateStr.slice(0, 10)
}
function statusLabel(status: string): string {
const map: Record<string, string> = {
ACTIVE: '有效',
EXPIRED: '已过期',
USED_UP: '已用完',
}
return map[status] || status
}
async function loadDetailMembership(userId: string) {
detailMembership.value = null
editLoading.value = true
try {
const result = await adminStore.getUserMembership(userId)
detailMembership.value = result.membership
} catch {
} finally {
editLoading.value = false
}
}
function switchTab(tab: 'detail' | 'edit') {
activeTab.value = tab
if (tab === 'edit') {
if (!editCardTypes.value.length) {
adminStore.fetchCardTypes().then((types) => {
editCardTypes.value = types
})
}
if (!detailMembership.value) {
loadDetailMembership(detailMember.value!.userId).then(() => {
initEditForm()
})
} else {
initEditForm()
}
}
}
function initEditForm() {
const m = detailMembership.value
const types = editCardTypes.value
if (m) {
const idx = types.findIndex((t: any) => t.id === m.cardTypeId)
editForm.value = {
cardTypeIndex: idx >= 0 ? idx : 0,
cardTypeId: m.cardTypeId,
remainingTimes: m.remainingTimes,
startDate: m.startDate.slice(0, 10),
expireDate: m.expireDate.slice(0, 10),
manuallyEditedExpire: false,
}
} else {
editForm.value = {
cardTypeIndex: 0,
cardTypeId: types[0]?.id || '',
remainingTimes: types[0]?.totalTimes ?? null,
startDate: formatDate2(new Date()),
expireDate: calculateExpireDate(formatDate2(new Date()), types[0]?.durationDays ?? 30),
manuallyEditedExpire: false,
}
}
}
function formatDate2(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
function calculateExpireDate(startDate: string, durationDays: number): string {
const d = new Date(startDate)
d.setDate(d.getDate() + durationDays)
return formatDate2(d)
}
function onCardTypeChangeForEdit(e: { detail: { value: number } }) {
const idx = e.detail.value
const cardType = editCardTypes.value[idx]
editForm.value.cardTypeIndex = idx
editForm.value.cardTypeId = cardType.id
if (cardType.totalTimes != null) {
editForm.value.remainingTimes = cardType.totalTimes
}
if (!editForm.value.manuallyEditedExpire) {
editForm.value.startDate = formatDate2(new Date())
editForm.value.expireDate = calculateExpireDate(formatDate2(new Date()), cardType.durationDays)
}
}
function onStartDateChange(e: { detail: { value: string } }) {
editForm.value.startDate = e.detail.value
if (!editForm.value.manuallyEditedExpire) {
const cardType = editCardTypes.value[editForm.value.cardTypeIndex]
if (cardType) {
editForm.value.expireDate = calculateExpireDate(e.detail.value, cardType.durationDays)
}
}
}
function onExpireDateChange(e: { detail: { value: string } }) {
editForm.value.expireDate = e.detail.value
editForm.value.manuallyEditedExpire = true
}
function goEdit() {
switchTab('edit')
}
async function onSaveMembership() {
if (editSubmitting.value) return
const userId = detailMember.value?.userId
if (!userId) return
editSubmitting.value = true
try {
await adminStore.updateUserMembership(userId, {
cardTypeId: editForm.value.cardTypeId,
remainingTimes: editForm.value.remainingTimes,
startDate: editForm.value.startDate,
expireDate: editForm.value.expireDate,
})
uni.showToast({ title: '保存成功', icon: 'success' })
activeTab.value = 'detail'
await loadDetailMembership(userId)
loadMembers(false)
} catch {
uni.showToast({ title: '保存失败', icon: 'none' })
} finally {
editSubmitting.value = false
}
}
async function onClearMembership() {
const userId = detailMember.value?.userId
if (!userId) return
uni.showModal({
title: '确认解除',
content: '确定要解除该用户的会员卡吗?',
confirmColor: '#e64329',
success: async (res) => {
if (!res.confirm) return
try {
await adminStore.deleteUserMembership(userId)
uni.showToast({ title: '已解除', icon: 'success' })
showDetail.value = false
loadMembers(false)
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
}
},
})
}
function closeModal() {
showDetail.value = false
activeTab.value = 'detail'
detailMembership.value = null
}
onMounted(() => loadMembers(true))
</script>
@@ -546,4 +881,270 @@ onMounted(() => loadMembers(true))
}
.modal-close-text { font-size: 28rpx; color: $text-secondary; }
/* ── Modal header ─────────────────────────── */
.modal-header {
display: flex;
justify-content: flex-end;
margin-bottom: 24rpx;
}
.modal-close-btn {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
background: $bg-page;
border-radius: 50%;
}
.modal-close-icon {
font-size: 36rpx;
color: $text-secondary;
line-height: 1;
}
/* ── Tab bar ──────────────────────────────── */
.tab-bar {
display: flex;
gap: 16rpx;
margin-bottom: 32rpx;
}
.tab-item {
flex: 1;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
background: $bg-page;
border-radius: $radius-md;
}
.tab-item--active {
background: $brand-color;
}
.tab-text {
font-size: 28rpx;
color: $text-secondary;
font-weight: 500;
}
.tab-text--active {
color: $accent-color;
font-weight: 700;
}
/* ── Tab content ──────────────────────────── */
.tab-content {
min-height: 400rpx;
}
.edit-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 400rpx;
}
.edit-loading-text {
font-size: 28rpx;
color: $text-hint;
}
/* ── Membership card ──────────────────────── */
.membership-card {
background: $bg-page;
border-radius: $radius-md;
padding: 28rpx;
margin-bottom: 24rpx;
}
.membership-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.membership-card-name {
font-size: 32rpx;
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;
flex-direction: column;
gap: 24rpx;
}
.form-item {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.form-label {
font-size: 26rpx;
color: $text-secondary;
font-weight: 500;
}
.form-input {
height: 80rpx;
background: $bg-page;
border-radius: $radius-md;
padding: 0 24rpx;
font-size: 28rpx;
color: $text-primary;
}
.form-picker {
height: 80rpx;
background: $bg-page;
border-radius: $radius-md;
display: flex;
align-items: center;
}
.form-picker-inner {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24rpx;
}
.form-picker-text {
font-size: 28rpx;
color: $text-primary;
}
.form-picker-arrow {
font-size: 20rpx;
color: $text-hint;
}
/* ── Edit submit ──────────────────────────── */
.edit-submit {
margin-top: 32rpx;
}
.submit-btn {
width: 100%;
height: 88rpx;
background: $brand-color;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
}
.submit-btn--disabled {
opacity: 0.5;
}
.submit-btn-text {
font-size: 28rpx;
font-weight: 600;
color: $accent-color;
}
</style>