perf: 优化订阅刷新逻辑
This commit is contained in:
@@ -146,6 +146,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import type { BookingWithUser, BookingStatusHistory } from '@mp-pilates/shared'
|
||||
import { BookingStatus } from '@mp-pilates/shared'
|
||||
import { useBookingStore } from '../../stores/booking'
|
||||
@@ -165,6 +166,7 @@ const bookingStore = useBookingStore()
|
||||
const navBarHeight = ref('64px')
|
||||
const refreshing = ref(false)
|
||||
const loading = ref(false)
|
||||
const hasLoadedOnce = ref(false)
|
||||
|
||||
// ─── Filter state ─────────────────────────────────────────────────────────
|
||||
type FilterValue = string | null
|
||||
@@ -215,9 +217,9 @@ function formatTimelineText(h: BookingStatusHistory): string {
|
||||
}
|
||||
|
||||
// ─── Data loading ─────────────────────────────────────────────────────────
|
||||
async function loadBookings(append = false) {
|
||||
async function loadBookings(append = false, opts: { silent?: boolean } = {}) {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
if (!opts.silent) loading.value = true
|
||||
|
||||
try {
|
||||
const page = append ? currentPage.value + 1 : 1
|
||||
@@ -234,8 +236,9 @@ async function loadBookings(append = false) {
|
||||
totalCount.value = result.total
|
||||
hasMore.value = bookings.value.length < result.total
|
||||
|
||||
// Fetch history for each booking
|
||||
if (!append) {
|
||||
// Fetch history for each booking. Skip in silent mode — history is only
|
||||
// shown as a small inline preview, and the detail page has the full one.
|
||||
if (!append && !opts.silent) {
|
||||
await Promise.all(
|
||||
bookings.value.map((b) => fetchHistory(b.id)),
|
||||
)
|
||||
@@ -247,9 +250,9 @@ async function loadBookings(append = false) {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Load bookings failed:', err)
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
if (!opts.silent) uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (!opts.silent) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,6 +394,24 @@ onMounted(() => {
|
||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||
loadBookings(false)
|
||||
loadAllForStats()
|
||||
hasLoadedOnce.value = true
|
||||
})
|
||||
|
||||
// After returning from booking detail (where status may have changed),
|
||||
// re-sync the list with the server. Local row actions still call onRefresh
|
||||
// directly — this onShow is the safety net for the navigate-back path.
|
||||
// Uses silent mode so the list stays visible (no skeleton flash).
|
||||
onShow(() => {
|
||||
if (!hasLoadedOnce.value) return
|
||||
// Skip while a refresh is already in flight to avoid overlap.
|
||||
if (refreshing.value || loading.value) return
|
||||
Promise.all([
|
||||
loadBookings(false, { silent: true }),
|
||||
loadAllForStats(),
|
||||
]).catch(() => {
|
||||
// Errors are non-fatal here — list keeps showing stale data until
|
||||
// the next explicit refresh.
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
694
packages/app/src/pages/admin/member-arrange.vue
Normal file
694
packages/app/src/pages/admin/member-arrange.vue
Normal file
@@ -0,0 +1,694 @@
|
||||
<template>
|
||||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="安排课程" show-back />
|
||||
|
||||
<view v-if="detail" class="who-bar">
|
||||
<view class="who-avatar">
|
||||
<image v-if="detail.user.avatarUrl" class="avatar-img" :src="detail.user.avatarUrl" mode="aspectFill" />
|
||||
<view v-else class="avatar-fallback">
|
||||
<text class="avatar-letter">{{ (detail.user.nickname || '?').slice(0, 1) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="who-copy">
|
||||
<text class="who-kicker">为学员安排</text>
|
||||
<text class="who-name">{{ detail.user.nickname || '未知用户' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="usableMemberships.length" class="card-switch">
|
||||
<scroll-view scroll-x class="card-scroll" :show-scrollbar="false">
|
||||
<view class="card-track">
|
||||
<view
|
||||
v-for="card in usableMemberships"
|
||||
:key="card.id"
|
||||
class="card-pill"
|
||||
:class="{ 'card-pill--on': selectedMembershipId === card.id }"
|
||||
@tap="selectedMembershipId = card.id"
|
||||
>
|
||||
<text class="card-pill-name">{{ card.cardType.name }}</text>
|
||||
<text class="card-pill-meta">
|
||||
{{ card.remainingTimes == null ? '月卡不扣次' : `剩 ${card.remainingTimes} 次` }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view v-else class="ban-banner">
|
||||
<text class="ban-text">该会员没有可扣课的有效卡,请先开卡</text>
|
||||
</view>
|
||||
|
||||
<DateSelector v-model="selectedDate" variant="booking" @select="onDateSelect" />
|
||||
<TimePeriodFilter v-model="selectedPeriod" variant="booking" />
|
||||
|
||||
<view v-if="slotsLoading" class="slot-skeleton">
|
||||
<view v-for="i in 4" :key="i" class="slot-skel" />
|
||||
</view>
|
||||
|
||||
<view v-else-if="filteredSlots.length === 0" class="empty-slots">
|
||||
<text class="empty-title">这天还没有可安排的课表</text>
|
||||
<text class="empty-sub">可以去排课管理发布,或在下方加开一个时段</text>
|
||||
<view class="ghost-link" @tap="goSchedule">
|
||||
<text class="ghost-link-text">前往排课管理</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="slot-list">
|
||||
<view
|
||||
v-for="slot in filteredSlots"
|
||||
:key="slot.startTime + slot.endTime + (slot.id || 'draft')"
|
||||
class="slot-row"
|
||||
:class="{ 'slot-row--disabled': !canPickSlot(slot) }"
|
||||
@tap="onPickSlot(slot)"
|
||||
>
|
||||
<view class="slot-time-col">
|
||||
<text class="slot-time">{{ slot.startTime.slice(0, 5) }}</text>
|
||||
<text class="slot-end">{{ slot.endTime.slice(0, 5) }}</text>
|
||||
</view>
|
||||
<view class="slot-body">
|
||||
<text class="slot-title">{{ slotLabel(slot) }}</text>
|
||||
<text class="slot-cap">{{ slot.bookedCount }}/{{ slot.capacity }} 人</text>
|
||||
</view>
|
||||
<text class="slot-action">{{ slotActionLabel(slot) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="custom-block">
|
||||
<text class="custom-title">加开此时段</text>
|
||||
<view class="custom-row">
|
||||
<picker mode="time" :value="customStart" @change="onCustomStartChange">
|
||||
<view class="custom-picker">
|
||||
<text class="custom-picker-text">{{ customStart }} – {{ customEnd }}</text>
|
||||
<text class="custom-picker-arrow">选择开始时间</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view
|
||||
class="custom-btn"
|
||||
:class="{ 'custom-btn--disabled': !canArrange }"
|
||||
@tap="onCustomArrange"
|
||||
>
|
||||
<text class="custom-btn-text">加开并安排</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="confirmVisible && pendingSlot" class="mask" @tap="confirmVisible = false">
|
||||
<view class="sheet" @tap.stop>
|
||||
<text class="sheet-kicker">立即确认</text>
|
||||
<text class="sheet-title">安排给 {{ detail?.user.nickname || '该会员' }}</text>
|
||||
<view class="sheet-lines">
|
||||
<view class="sheet-line">
|
||||
<text class="sheet-label">时间</text>
|
||||
<text class="sheet-value">{{ pendingSlot.date }} {{ pendingSlot.startTime.slice(0, 5) }}–{{ pendingSlot.endTime.slice(0, 5) }}</text>
|
||||
</view>
|
||||
<view class="sheet-line">
|
||||
<text class="sheet-label">扣卡</text>
|
||||
<text class="sheet-value">{{ selectedMembership?.cardType.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="sheet-note">{{ deductHint }}</text>
|
||||
<view class="sheet-actions">
|
||||
<view class="sheet-cancel" @tap="confirmVisible = false">
|
||||
<text class="sheet-cancel-text">取消</text>
|
||||
</view>
|
||||
<view class="sheet-ok" :class="{ 'sheet-ok--disabled': arranging }" @tap="confirmArrange">
|
||||
<text class="sheet-ok-text">{{ arranging ? '安排中...' : '确认安排' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import type {
|
||||
AdminMemberDetail,
|
||||
ScheduleSlotPreview,
|
||||
} from '@mp-pilates/shared'
|
||||
import { MembershipStatus, TIME_PERIODS, TimeSlotStatus, TimeSlotSource } from '@mp-pilates/shared'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import DateSelector from '../../components/DateSelector.vue'
|
||||
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { formatDate, isSlotPast } from '../../utils/format'
|
||||
import { getErrorMessage } from '../../utils/auth'
|
||||
import { useAdminStore } from '../../stores/admin'
|
||||
|
||||
type PeriodKey = keyof typeof TIME_PERIODS | null
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
const navBarHeight = ref('64px')
|
||||
const userId = ref('')
|
||||
const detail = ref<AdminMemberDetail | null>(null)
|
||||
const selectedMembershipId = ref('')
|
||||
const selectedDate = ref(formatDate(new Date()))
|
||||
const selectedPeriod = ref<PeriodKey>(null)
|
||||
const slots = ref<ScheduleSlotPreview[]>([])
|
||||
const slotsLoading = ref(false)
|
||||
const customStart = ref('10:00')
|
||||
const confirmVisible = ref(false)
|
||||
const pendingSlot = ref<ScheduleSlotPreview | null>(null)
|
||||
const arranging = ref(false)
|
||||
|
||||
const usableMemberships = computed(() =>
|
||||
(detail.value?.memberships ?? []).filter((item) =>
|
||||
item.status === MembershipStatus.ACTIVE
|
||||
&& (item.remainingTimes === null || item.remainingTimes > 0)
|
||||
&& new Date(item.expireDate) > new Date(),
|
||||
),
|
||||
)
|
||||
|
||||
const selectedMembership = computed(
|
||||
() => usableMemberships.value.find((item) => item.id === selectedMembershipId.value) ?? null,
|
||||
)
|
||||
|
||||
const canArrange = computed(() => Boolean(selectedMembership.value))
|
||||
|
||||
const isDurationCard = computed(() => selectedMembership.value?.remainingTimes == null)
|
||||
|
||||
const deductHint = computed(() => {
|
||||
if (isDurationCard.value) {
|
||||
return '将立即确认该课,月卡不扣次,会员无需再确认。'
|
||||
}
|
||||
return '将立即确认该课并扣除 1 次,会员无需再确认。'
|
||||
})
|
||||
|
||||
const filteredSlots = computed(() => {
|
||||
if (!selectedPeriod.value) return slots.value
|
||||
const period = TIME_PERIODS[selectedPeriod.value]
|
||||
return slots.value.filter((slot) => slot.startTime >= period.start && slot.startTime < period.end)
|
||||
})
|
||||
|
||||
const customEnd = computed(() => addHour(customStart.value))
|
||||
|
||||
function addHour(time: string): string {
|
||||
const [hours, minutes] = time.split(':').map(Number)
|
||||
const total = hours * 60 + (minutes || 0) + 60
|
||||
const nextHours = Math.floor(total / 60) % 24
|
||||
const nextMinutes = total % 60
|
||||
return `${String(nextHours).padStart(2, '0')}:${String(nextMinutes).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function canPickSlot(slot: ScheduleSlotPreview): boolean {
|
||||
if (!canArrange.value) return false
|
||||
if (isSlotPast(slot.date, slot.startTime)) return false
|
||||
if (slot.status === TimeSlotStatus.CLOSED) return false
|
||||
if (slot.isPublished && (slot.status === TimeSlotStatus.FULL || slot.bookedCount >= slot.capacity)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function slotLabel(slot: ScheduleSlotPreview): string {
|
||||
if (!slot.isPublished || !slot.id) return '未发布时段'
|
||||
if (slot.status === TimeSlotStatus.CLOSED) return '已关闭'
|
||||
if (slot.status === TimeSlotStatus.FULL || slot.bookedCount >= slot.capacity) return '已满员'
|
||||
if (isSlotPast(slot.date, slot.startTime)) return '已过点'
|
||||
return '可安排'
|
||||
}
|
||||
|
||||
function slotActionLabel(slot: ScheduleSlotPreview): string {
|
||||
if (!slot.isPublished || !slot.id) return '加开'
|
||||
if (!canPickSlot(slot)) return '—'
|
||||
return '安排'
|
||||
}
|
||||
|
||||
async function loadSlots(date: string) {
|
||||
slotsLoading.value = true
|
||||
try {
|
||||
slots.value = await adminStore.previewScheduleByDate(date)
|
||||
} catch (err: unknown) {
|
||||
slots.value = []
|
||||
uni.showToast({ title: getErrorMessage(err, '课表加载失败'), icon: 'none' })
|
||||
} finally {
|
||||
slotsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
detail.value = await adminStore.fetchMemberDetail(userId.value)
|
||||
const first = usableMemberships.value[0]
|
||||
selectedMembershipId.value = first?.id ?? ''
|
||||
await loadSlots(selectedDate.value)
|
||||
}
|
||||
|
||||
function onDateSelect(date: string) {
|
||||
selectedDate.value = date
|
||||
loadSlots(date)
|
||||
}
|
||||
|
||||
function onCustomStartChange(e: { detail: { value: string } }) {
|
||||
customStart.value = e.detail.value
|
||||
}
|
||||
|
||||
function goSchedule() {
|
||||
uni.navigateTo({ url: '/pages/admin/schedule' })
|
||||
}
|
||||
|
||||
function onPickSlot(slot: ScheduleSlotPreview) {
|
||||
if (!canPickSlot(slot)) return
|
||||
pendingSlot.value = slot
|
||||
confirmVisible.value = true
|
||||
}
|
||||
|
||||
function onCustomArrange() {
|
||||
if (!canArrange.value) {
|
||||
uni.showToast({ title: '请先开通有效会员卡', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (customEnd.value <= customStart.value) {
|
||||
uni.showToast({ title: '结束时间必须晚于开始时间', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (isSlotPast(selectedDate.value, customStart.value)) {
|
||||
uni.showToast({ title: '不能安排已经过去的时间', icon: 'none' })
|
||||
return
|
||||
}
|
||||
pendingSlot.value = {
|
||||
id: null,
|
||||
date: selectedDate.value,
|
||||
startTime: customStart.value,
|
||||
endTime: customEnd.value,
|
||||
capacity: 1,
|
||||
bookedCount: 0,
|
||||
status: TimeSlotStatus.OPEN,
|
||||
source: TimeSlotSource.MANUAL,
|
||||
templateId: null,
|
||||
isPublished: false,
|
||||
}
|
||||
confirmVisible.value = true
|
||||
}
|
||||
|
||||
async function confirmArrange() {
|
||||
const slot = pendingSlot.value
|
||||
const membership = selectedMembership.value
|
||||
if (!slot || !membership || arranging.value) return
|
||||
arranging.value = true
|
||||
try {
|
||||
let timeSlotId = slot.id
|
||||
await adminStore.arrangeMemberBooking({
|
||||
userId: userId.value,
|
||||
membershipId: membership.id,
|
||||
...(timeSlotId ? { timeSlotId } : {}),
|
||||
date: slot.date,
|
||||
startTime: slot.startTime.slice(0, 5),
|
||||
endTime: slot.endTime.slice(0, 5),
|
||||
capacity: slot.capacity || 1,
|
||||
})
|
||||
confirmVisible.value = false
|
||||
uni.showToast({ title: '已安排并确认', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 500)
|
||||
} catch (err: unknown) {
|
||||
uni.showToast({ title: getErrorMessage(err, '安排失败'), icon: 'none' })
|
||||
} finally {
|
||||
arranging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
userId.value = String(query?.userId || '')
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||
try {
|
||||
await loadPage()
|
||||
} catch (err: unknown) {
|
||||
uni.showToast({ title: getErrorMessage(err, '加载失败'), icon: 'none' })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $bg-page;
|
||||
padding-bottom: 80rpx;
|
||||
}
|
||||
|
||||
.who-bar {
|
||||
margin: 16rpx 24rpx 0;
|
||||
padding: 20rpx;
|
||||
border-radius: 20rpx;
|
||||
background: linear-gradient(135deg, #3c3228, #5a4a3a);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.who-avatar {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 18rpx;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-img { width: 100%; height: 100%; }
|
||||
|
||||
.avatar-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: $accent-color;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-letter {
|
||||
color: #fff8f0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.who-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.who-kicker {
|
||||
font-size: 18rpx;
|
||||
letter-spacing: 3rpx;
|
||||
color: rgba(255, 248, 240, 0.55);
|
||||
}
|
||||
|
||||
.who-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #fff8f0;
|
||||
}
|
||||
|
||||
.card-switch {
|
||||
padding: 16rpx 0 4rpx;
|
||||
}
|
||||
|
||||
.card-scroll { white-space: nowrap; }
|
||||
|
||||
.card-track {
|
||||
display: inline-flex;
|
||||
gap: 12rpx;
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
|
||||
.card-pill {
|
||||
padding: 14rpx 22rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #fff;
|
||||
border: 2rpx solid rgba(180, 160, 130, 0.18);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.card-pill--on {
|
||||
border-color: $brand-color;
|
||||
background: #3c3228;
|
||||
}
|
||||
|
||||
.card-pill-name {
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.card-pill--on .card-pill-name,
|
||||
.card-pill--on .card-pill-meta {
|
||||
color: #fff8f0;
|
||||
}
|
||||
|
||||
.card-pill-meta {
|
||||
font-size: 20rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
.ban-banner {
|
||||
margin: 16rpx 24rpx;
|
||||
padding: 20rpx;
|
||||
border-radius: 16rpx;
|
||||
background: rgba($error-color, 0.1);
|
||||
}
|
||||
|
||||
.ban-text {
|
||||
font-size: 24rpx;
|
||||
color: $error-color;
|
||||
}
|
||||
|
||||
.slot-skeleton { padding: 16rpx 24rpx; }
|
||||
|
||||
.slot-skel {
|
||||
height: 112rpx;
|
||||
border-radius: 18rpx;
|
||||
margin-bottom: 12rpx;
|
||||
background: linear-gradient(90deg, #efe8df 25%, #f7f2ea 50%, #efe8df 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
.empty-slots {
|
||||
padding: 48rpx 32rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
font-size: 24rpx;
|
||||
color: $text-hint;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ghost-link {
|
||||
margin-top: 8rpx;
|
||||
padding: 10rpx 24rpx;
|
||||
border-radius: 999rpx;
|
||||
border: 1rpx solid $brand-color;
|
||||
}
|
||||
|
||||
.ghost-link-text {
|
||||
font-size: 22rpx;
|
||||
color: $brand-color;
|
||||
}
|
||||
|
||||
.slot-list {
|
||||
padding: 16rpx 24rpx 8rpx;
|
||||
}
|
||||
|
||||
.slot-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
background: #fff;
|
||||
border-radius: 18rpx;
|
||||
padding: 22rpx 20rpx;
|
||||
margin-bottom: 12rpx;
|
||||
border: 1rpx solid rgba(180, 160, 130, 0.12);
|
||||
}
|
||||
|
||||
.slot-row--disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.slot-time-col {
|
||||
width: 120rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.slot-time {
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
color: $text-primary;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
.slot-end {
|
||||
font-size: 22rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
.slot-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.slot-title {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.slot-cap {
|
||||
font-size: 22rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
.slot-action {
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
color: $accent-color;
|
||||
}
|
||||
|
||||
.custom-block {
|
||||
margin: 12rpx 24rpx 40rpx;
|
||||
padding: 24rpx;
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
border: 1rpx dashed rgba(180, 160, 130, 0.28);
|
||||
}
|
||||
|
||||
.custom-title {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
letter-spacing: 3rpx;
|
||||
color: $text-hint;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.custom-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.custom-picker {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.custom-picker-text {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: $text-primary;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
.custom-picker-arrow {
|
||||
font-size: 20rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
.custom-btn {
|
||||
padding: 16rpx 22rpx;
|
||||
border-radius: 14rpx;
|
||||
background: $brand-color;
|
||||
}
|
||||
|
||||
.custom-btn--disabled { opacity: 0.4; }
|
||||
|
||||
.custom-btn-text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
color: #fff8f0;
|
||||
}
|
||||
|
||||
.mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(44, 36, 28, 0.45);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
width: 100%;
|
||||
background: #fff8f0;
|
||||
border-radius: 28rpx 28rpx 0 0;
|
||||
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.sheet-kicker {
|
||||
font-size: 20rpx;
|
||||
letter-spacing: 4rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
.sheet-title {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: $text-primary;
|
||||
font-family: 'Songti SC', Georgia, serif;
|
||||
}
|
||||
|
||||
.sheet-lines {
|
||||
margin: 28rpx 0 16rpx;
|
||||
}
|
||||
|
||||
.sheet-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 14rpx 0;
|
||||
border-bottom: 1rpx solid rgba(180, 160, 130, 0.14);
|
||||
}
|
||||
|
||||
.sheet-label {
|
||||
font-size: 24rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
.sheet-value {
|
||||
font-size: 26rpx;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.sheet-note {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
color: $text-secondary;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sheet-actions {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.sheet-cancel,
|
||||
.sheet-ok {
|
||||
flex: 1;
|
||||
height: 84rpx;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sheet-cancel {
|
||||
background: #fff;
|
||||
border: 2rpx solid $brand-color;
|
||||
}
|
||||
|
||||
.sheet-ok {
|
||||
background: $brand-color;
|
||||
}
|
||||
|
||||
.sheet-ok--disabled { opacity: 0.5; }
|
||||
|
||||
.sheet-cancel-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: $brand-color;
|
||||
}
|
||||
|
||||
.sheet-ok-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #fff8f0;
|
||||
}
|
||||
</style>
|
||||
680
packages/app/src/pages/admin/member-detail.vue
Normal file
680
packages/app/src/pages/admin/member-detail.vue
Normal file
@@ -0,0 +1,680 @@
|
||||
<template>
|
||||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="会员档案" show-back />
|
||||
|
||||
<view v-if="loading && !detail" class="skeleton-wrap">
|
||||
<view class="skeleton-hero" />
|
||||
<view class="skeleton-block" />
|
||||
<view class="skeleton-block" />
|
||||
</view>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<view class="hero">
|
||||
<view class="hero-grain" />
|
||||
<view class="hero-inner">
|
||||
<view class="hero-avatar">
|
||||
<image v-if="detail.user.avatarUrl" class="avatar-img" :src="detail.user.avatarUrl" mode="aspectFill" />
|
||||
<view v-else class="avatar-fallback">
|
||||
<text class="avatar-letter">{{ (detail.user.nickname || '?').slice(0, 1) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="hero-copy">
|
||||
<text class="hero-kicker">STUDIO LEDGER</text>
|
||||
<text class="hero-name">{{ detail.user.nickname || '未知用户' }}</text>
|
||||
<text class="hero-phone">{{ detail.user.phone || '未绑定手机' }}</text>
|
||||
<text class="hero-openid" @tap="copyOpenid">{{ detail.user.openid }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="time-pair">
|
||||
<view class="time-cell">
|
||||
<text class="time-label">注册时间</text>
|
||||
<text class="time-value">{{ formatDateTimeFull(detail.user.createdAt) }}</text>
|
||||
</view>
|
||||
<view class="time-rule" />
|
||||
<view class="time-cell">
|
||||
<text class="time-label">最近登录</text>
|
||||
<text class="time-value">{{ detail.user.lastLoginAt ? formatDateTimeFull(detail.user.lastLoginAt) : '暂无登录记录' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<text class="section-label">上课情况</text>
|
||||
<view class="stats-strip">
|
||||
<view class="stat">
|
||||
<text class="stat-num">{{ detail.stats.totalBookings }}</text>
|
||||
<text class="stat-name">累计预约</text>
|
||||
</view>
|
||||
<view class="stat">
|
||||
<text class="stat-num">{{ detail.stats.completedBookings }}</text>
|
||||
<text class="stat-name">已完成</text>
|
||||
</view>
|
||||
<view class="stat">
|
||||
<text class="stat-num">{{ detail.stats.cancelledBookings }}</text>
|
||||
<text class="stat-name">已取消</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="noshow-hint">未到 {{ detail.stats.noShowBookings }} 次</text>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<text class="section-label">会员卡</text>
|
||||
<view v-if="detail.memberships.length" class="card-list">
|
||||
<view
|
||||
v-for="card in detail.memberships"
|
||||
:key="card.id"
|
||||
class="mship"
|
||||
>
|
||||
<view class="mship-strip" :class="getCardGradientClass(card.cardType.type)" />
|
||||
<view class="mship-head">
|
||||
<view class="mship-titles">
|
||||
<text class="mship-name">{{ card.cardType.name }}</text>
|
||||
<text class="mship-type">{{ getCardTypeLabel(card.cardType.type) }}</text>
|
||||
</view>
|
||||
<view class="mship-status" :class="'mship-status--' + card.status.toLowerCase()">
|
||||
<text class="mship-status-text">{{ membershipStatusLabel(card.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="card.remainingTimes !== null" class="mship-times">
|
||||
<text class="mship-times-num">{{ card.remainingTimes }}</text>
|
||||
<text class="mship-times-unit">次剩余</text>
|
||||
</view>
|
||||
<view v-if="card.remainingTimes !== null && getMembershipTotalTimes(card)" class="progress">
|
||||
<view class="progress-bar">
|
||||
<view class="progress-fill" :style="{ width: getMembershipProgressWidth(card) }" />
|
||||
</view>
|
||||
<text class="progress-text">
|
||||
已用 {{ getMembershipUsedTimes(card) }} / {{ getMembershipTotalTimes(card) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="mship-dates">
|
||||
<text>{{ formatDate(card.startDate) }} 起</text>
|
||||
<text>{{ formatDate(card.expireDate) }} 止</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-card">
|
||||
<text class="empty-card-title">尚未开卡</text>
|
||||
<text class="empty-card-sub">开通体验卡、次卡或月卡后即可安排课程</text>
|
||||
<view class="empty-card-btn" @tap="goEdit">
|
||||
<text class="empty-card-btn-text">去开卡</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section--last">
|
||||
<text class="section-label">即将上课</text>
|
||||
<view v-if="detail.upcomingBookings.length" class="upcoming-list">
|
||||
<view v-for="item in detail.upcomingBookings" :key="item.id" class="upcoming-row">
|
||||
<view class="upcoming-time">
|
||||
<text class="upcoming-date">{{ item.date.slice(5) }}</text>
|
||||
<text class="upcoming-hour">{{ item.startTime.slice(0, 5) }}–{{ item.endTime.slice(0, 5) }}</text>
|
||||
</view>
|
||||
<view class="upcoming-meta">
|
||||
<text class="upcoming-card">{{ item.cardName }}</text>
|
||||
<text class="upcoming-status">{{ bookingStatusLabel(item.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="upcoming-empty">
|
||||
<text class="upcoming-empty-text">近期没有待上的课</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view class="dock">
|
||||
<view class="dock-btn dock-btn--ghost" @tap="goEdit">
|
||||
<text class="dock-btn-text">编辑资料</text>
|
||||
</view>
|
||||
<view
|
||||
class="dock-btn dock-btn--solid"
|
||||
:class="{ 'dock-btn--disabled': !canArrange }"
|
||||
@tap="goArrange"
|
||||
>
|
||||
<text class="dock-btn-text dock-btn-text--solid">安排课程</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import type { AdminMemberDetail, MembershipWithCardType } from '@mp-pilates/shared'
|
||||
import { MembershipStatus, BookingStatus } from '@mp-pilates/shared'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import {
|
||||
formatDateTimeFull,
|
||||
getCardTypeLabel,
|
||||
getCardGradientClass,
|
||||
getMembershipProgressWidth,
|
||||
getMembershipUsedTimes,
|
||||
getMembershipTotalTimes,
|
||||
} from '../../utils/format'
|
||||
import { BOOKING_STATUS_LABELS } from '../../utils/booking-helpers'
|
||||
import { getErrorMessage } from '../../utils/auth'
|
||||
import { useAdminStore } from '../../stores/admin'
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
const navBarHeight = ref('64px')
|
||||
const userId = ref('')
|
||||
const loading = ref(false)
|
||||
const detail = ref<AdminMemberDetail | null>(null)
|
||||
|
||||
const canArrange = computed(() => (detail.value?.memberships ?? []).some(isArrangableMembership))
|
||||
|
||||
function isArrangableMembership(membership: MembershipWithCardType): boolean {
|
||||
if (membership.status !== MembershipStatus.ACTIVE) return false
|
||||
if (membership.remainingTimes !== null && membership.remainingTimes <= 0) return false
|
||||
return new Date(membership.expireDate) > new Date()
|
||||
}
|
||||
|
||||
function membershipStatusLabel(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
ACTIVE: '有效',
|
||||
EXPIRED: '已过期',
|
||||
USED_UP: '已用完',
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
function bookingStatusLabel(status: BookingStatus): string {
|
||||
return BOOKING_STATUS_LABELS[status] || status
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return dateStr.slice(0, 10)
|
||||
}
|
||||
|
||||
function copyOpenid() {
|
||||
const openid = detail.value?.user.openid
|
||||
if (!openid) return
|
||||
uni.setClipboardData({
|
||||
data: openid,
|
||||
success: () => uni.showToast({ title: '已复制 OpenID', icon: 'success' }),
|
||||
})
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
if (!userId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
detail.value = await adminStore.fetchMemberDetail(userId.value)
|
||||
} catch (err: unknown) {
|
||||
uni.showToast({ title: getErrorMessage(err, '加载失败'), icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goEdit() {
|
||||
if (!userId.value) return
|
||||
uni.navigateTo({ url: `/pages/admin/member-edit?userId=${userId.value}` })
|
||||
}
|
||||
|
||||
function goArrange() {
|
||||
if (!canArrange.value) {
|
||||
uni.showToast({ title: '请先开通有效会员卡', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.navigateTo({ url: `/pages/admin/member-arrange?userId=${userId.value}` })
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
userId.value = String(query?.userId || '')
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
if (userId.value) {
|
||||
loadDetail()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $bg-page;
|
||||
padding-bottom: 180rpx;
|
||||
}
|
||||
|
||||
.skeleton-wrap {
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.skeleton-hero,
|
||||
.skeleton-block {
|
||||
border-radius: 24rpx;
|
||||
background: linear-gradient(90deg, #efe8df 25%, #f7f2ea 50%, #efe8df 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
.skeleton-hero { height: 360rpx; margin-bottom: 24rpx; }
|
||||
.skeleton-block { height: 180rpx; margin-bottom: 20rpx; }
|
||||
|
||||
.hero {
|
||||
margin: 20rpx 24rpx 8rpx;
|
||||
border-radius: 28rpx;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(160deg, #2c241c 0%, #4a4035 58%, #6b5a48 100%);
|
||||
position: relative;
|
||||
box-shadow: 0 18rpx 40rpx rgba(44, 36, 28, 0.22);
|
||||
}
|
||||
|
||||
.hero-grain {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
radial-gradient(circle at 18% 20%, rgba(169, 191, 204, 0.18), transparent 36%),
|
||||
radial-gradient(circle at 90% 80%, rgba(232, 168, 124, 0.16), transparent 32%);
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
padding: 36rpx 32rpx 20rpx;
|
||||
}
|
||||
|
||||
.hero-avatar {
|
||||
width: 128rpx;
|
||||
height: 128rpx;
|
||||
border-radius: 28rpx;
|
||||
overflow: hidden;
|
||||
border: 3rpx solid rgba(255, 248, 240, 0.35);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-img { width: 100%; height: 100%; }
|
||||
|
||||
.avatar-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #7ba5be;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-letter {
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
color: #fff8f0;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.hero-kicker {
|
||||
font-size: 18rpx;
|
||||
letter-spacing: 4rpx;
|
||||
color: rgba(200, 216, 228, 0.72);
|
||||
}
|
||||
|
||||
.hero-name {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: #fff8f0;
|
||||
font-family: 'Songti SC', 'Noto Serif SC', Georgia, serif;
|
||||
}
|
||||
|
||||
.hero-phone,
|
||||
.hero-openid {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 248, 240, 0.68);
|
||||
}
|
||||
|
||||
.hero-openid {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.time-pair {
|
||||
position: relative;
|
||||
margin: 8rpx 20rpx 20rpx;
|
||||
background: rgba(255, 248, 240, 0.08);
|
||||
border: 1rpx solid rgba(255, 248, 240, 0.1);
|
||||
border-radius: 18rpx;
|
||||
display: flex;
|
||||
padding: 18rpx 8rpx;
|
||||
}
|
||||
|
||||
.time-cell {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.time-rule {
|
||||
width: 1rpx;
|
||||
background: rgba(255, 248, 240, 0.16);
|
||||
}
|
||||
|
||||
.time-label {
|
||||
font-size: 20rpx;
|
||||
color: rgba(255, 248, 240, 0.5);
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
|
||||
.time-value {
|
||||
font-size: 22rpx;
|
||||
color: #fff8f0;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 28rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.section--last {
|
||||
padding-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
letter-spacing: 4rpx;
|
||||
color: $text-hint;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.stats-strip {
|
||||
background: $bg-card;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
padding: 28rpx 12rpx;
|
||||
border: 1rpx solid rgba(180, 160, 130, 0.12);
|
||||
}
|
||||
|
||||
.stat {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.stat-num {
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
color: $text-primary;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
.stat-name {
|
||||
font-size: 22rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
.noshow-hint {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 22rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.mship {
|
||||
background: $bg-card;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
border: 1rpx solid rgba(180, 160, 130, 0.12);
|
||||
}
|
||||
|
||||
.mship-strip {
|
||||
height: 8rpx;
|
||||
}
|
||||
|
||||
.gradient--times { background: linear-gradient(90deg, #7ba5be, #a9bfcc); }
|
||||
.gradient--duration { background: linear-gradient(90deg, #7A9E7E, #b7cbb8); }
|
||||
.gradient--trial { background: linear-gradient(90deg, #C47A7A, #e8b4b4); }
|
||||
|
||||
.mship-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 24rpx 24rpx 8rpx;
|
||||
}
|
||||
|
||||
.mship-titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.mship-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.mship-type {
|
||||
font-size: 22rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
.mship-status {
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
background: rgba($success-color, 0.14);
|
||||
|
||||
&--expired,
|
||||
&--used_up { background: rgba($text-hint, 0.14); }
|
||||
}
|
||||
|
||||
.mship-status-text {
|
||||
font-size: 20rpx;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.mship-times {
|
||||
padding: 4rpx 24rpx 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.mship-times-num {
|
||||
font-size: 48rpx;
|
||||
font-weight: 800;
|
||||
color: $text-primary;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
.mship-times-unit {
|
||||
font-size: 22rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
.progress {
|
||||
padding: 12rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8rpx;
|
||||
border-radius: 8rpx;
|
||||
background: rgba(180, 160, 130, 0.16);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 8rpx;
|
||||
background: $accent-color;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 20rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
.mship-dates {
|
||||
padding: 16rpx 24rpx 24rpx;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 22rpx;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.empty-card {
|
||||
background: $bg-card;
|
||||
border-radius: 20rpx;
|
||||
padding: 48rpx 32rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
border: 1rpx dashed rgba(180, 160, 130, 0.28);
|
||||
}
|
||||
|
||||
.empty-card-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.empty-card-sub {
|
||||
font-size: 24rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
.empty-card-btn {
|
||||
margin-top: 12rpx;
|
||||
padding: 12rpx 32rpx;
|
||||
border-radius: 999rpx;
|
||||
background: $brand-color;
|
||||
}
|
||||
|
||||
.empty-card-btn-text {
|
||||
font-size: 24rpx;
|
||||
color: $primary-dark;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.upcoming-list {
|
||||
background: $bg-card;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
border: 1rpx solid rgba(180, 160, 130, 0.12);
|
||||
}
|
||||
|
||||
.upcoming-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx;
|
||||
border-bottom: 1rpx solid rgba(180, 160, 130, 0.1);
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.upcoming-time {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.upcoming-date {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.upcoming-hour {
|
||||
font-size: 24rpx;
|
||||
color: $accent-color;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
.upcoming-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.upcoming-card {
|
||||
font-size: 24rpx;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.upcoming-status {
|
||||
font-size: 20rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
.upcoming-empty {
|
||||
padding: 32rpx;
|
||||
background: $bg-card;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.upcoming-empty-text {
|
||||
font-size: 24rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
.dock {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
|
||||
background: rgba(250, 248, 245, 0.94);
|
||||
border-top: 1rpx solid rgba(180, 160, 130, 0.14);
|
||||
}
|
||||
|
||||
.dock-btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
border-radius: 18rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dock-btn--ghost {
|
||||
background: #fff;
|
||||
border: 2rpx solid $brand-color;
|
||||
}
|
||||
|
||||
.dock-btn--solid {
|
||||
background: $brand-color;
|
||||
}
|
||||
|
||||
.dock-btn--disabled {
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
.dock-btn-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: $brand-color;
|
||||
}
|
||||
|
||||
.dock-btn-text--solid {
|
||||
color: #fff8f0;
|
||||
}
|
||||
</style>
|
||||
400
packages/app/src/pages/admin/member-edit.vue
Normal file
400
packages/app/src/pages/admin/member-edit.vue
Normal file
@@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="编辑资料" show-back />
|
||||
|
||||
<view v-if="pageLoading" class="loading-wrap">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="form">
|
||||
<view class="block">
|
||||
<text class="block-title">基本信息</text>
|
||||
<view class="field">
|
||||
<text class="field-label">昵称</text>
|
||||
<input class="field-input" v-model="profileForm.nickname" maxlength="32" placeholder="会员昵称" />
|
||||
</view>
|
||||
<view class="field">
|
||||
<text class="field-label">手机号</text>
|
||||
<input class="field-input" v-model="profileForm.phone" maxlength="20" type="number" placeholder="未绑定可手动填写" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="block">
|
||||
<text class="block-title">{{ editingMembership ? '会员卡' : '开通会员卡' }}</text>
|
||||
|
||||
<view v-if="existingMemberships.length > 1" class="field">
|
||||
<text class="field-label">选择卡片</text>
|
||||
<picker
|
||||
class="field-picker"
|
||||
mode="selector"
|
||||
:value="membershipIndex"
|
||||
:range="membershipPickerLabels"
|
||||
@change="onMembershipPick"
|
||||
>
|
||||
<view class="picker-inner">
|
||||
<text class="picker-text">{{ membershipPickerLabels[membershipIndex] }}</text>
|
||||
<text class="picker-arrow">▾</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">卡类型</text>
|
||||
<picker
|
||||
class="field-picker"
|
||||
mode="selector"
|
||||
:value="editForm.cardTypeIndex"
|
||||
:range="cardTypes"
|
||||
range-key="name"
|
||||
@change="onCardTypeChange"
|
||||
>
|
||||
<view class="picker-inner">
|
||||
<text class="picker-text">{{ cardTypes[editForm.cardTypeIndex]?.name || '请选择' }}</text>
|
||||
<text class="picker-arrow">▾</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view v-if="isTimeBasedCard" class="field">
|
||||
<text class="field-label">剩余次数</text>
|
||||
<input class="field-input" type="number" v-model="editForm.remainingTimes" placeholder="请输入剩余次数" />
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">开始日期</text>
|
||||
<picker class="field-picker" mode="date" :value="editForm.startDate" @change="onStartDateChange">
|
||||
<view class="picker-inner">
|
||||
<text class="picker-text">{{ editForm.startDate || '请选择' }}</text>
|
||||
<text class="picker-arrow">▾</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">到期日期</text>
|
||||
<picker class="field-picker" mode="date" :value="editForm.expireDate" @change="onExpireDateChange">
|
||||
<view class="picker-inner">
|
||||
<text class="picker-text">{{ editForm.expireDate || '请选择' }}</text>
|
||||
<text class="picker-arrow">▾</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view v-if="editingMembership" class="danger-link" @tap="onClearMembership">
|
||||
<text class="danger-link-text">解除该会员卡</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="save-btn"
|
||||
:class="{ 'save-btn--disabled': submitting }"
|
||||
@tap="onSave"
|
||||
>
|
||||
<text class="save-btn-text">{{ submitting ? '保存中...' : '保存' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import type { AdminMemberDetail, CardType, MembershipWithCardType } from '@mp-pilates/shared'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { formatDateLocal } from '../../utils/format'
|
||||
import { getErrorMessage } from '../../utils/auth'
|
||||
import { useAdminStore } from '../../stores/admin'
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
const navBarHeight = ref('64px')
|
||||
const userId = ref('')
|
||||
const pageLoading = ref(true)
|
||||
const submitting = ref(false)
|
||||
const detail = ref<AdminMemberDetail | null>(null)
|
||||
const cardTypes = ref<CardType[]>([])
|
||||
const membershipIndex = ref(0)
|
||||
|
||||
const profileForm = ref({
|
||||
nickname: '',
|
||||
phone: '',
|
||||
})
|
||||
|
||||
const editForm = ref({
|
||||
membershipId: '' as string | '',
|
||||
cardTypeIndex: 0,
|
||||
cardTypeId: '',
|
||||
remainingTimes: null as number | null,
|
||||
startDate: '',
|
||||
expireDate: '',
|
||||
manuallyEditedExpire: false,
|
||||
})
|
||||
|
||||
const existingMemberships = computed(() => detail.value?.memberships ?? [])
|
||||
const editingMembership = computed(() => existingMemberships.value[membershipIndex.value] ?? null)
|
||||
|
||||
const membershipPickerLabels = computed(() =>
|
||||
existingMemberships.value.map((item) => `${item.cardType.name} · ${item.status}`),
|
||||
)
|
||||
|
||||
const isTimeBasedCard = computed(() => {
|
||||
const card = cardTypes.value[editForm.value.cardTypeIndex]
|
||||
return card && (card.type === 'TIMES' || card.type === 'TRIAL')
|
||||
})
|
||||
|
||||
function calculateExpireDate(startDate: string, durationDays: number): string {
|
||||
const d = new Date(startDate)
|
||||
d.setDate(d.getDate() + durationDays)
|
||||
return formatDateLocal(d)
|
||||
}
|
||||
|
||||
function applyMembership(membership: MembershipWithCardType | null) {
|
||||
const types = cardTypes.value
|
||||
if (membership) {
|
||||
const idx = types.findIndex((item) => item.id === membership.cardTypeId)
|
||||
editForm.value = {
|
||||
membershipId: membership.id,
|
||||
cardTypeIndex: idx >= 0 ? idx : 0,
|
||||
cardTypeId: membership.cardTypeId,
|
||||
remainingTimes: membership.remainingTimes,
|
||||
startDate: membership.startDate.slice(0, 10),
|
||||
expireDate: membership.expireDate.slice(0, 10),
|
||||
manuallyEditedExpire: false,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
editForm.value = {
|
||||
membershipId: '',
|
||||
cardTypeIndex: 0,
|
||||
cardTypeId: types[0]?.id || '',
|
||||
remainingTimes: types[0]?.totalTimes ?? null,
|
||||
startDate: formatDateLocal(new Date()),
|
||||
expireDate: calculateExpireDate(formatDateLocal(new Date()), types[0]?.durationDays ?? 30),
|
||||
manuallyEditedExpire: false,
|
||||
}
|
||||
}
|
||||
|
||||
function onMembershipPick(e: { detail: { value: number } }) {
|
||||
membershipIndex.value = Number(e.detail.value)
|
||||
applyMembership(existingMemberships.value[membershipIndex.value] ?? null)
|
||||
}
|
||||
|
||||
function onCardTypeChange(e: { detail: { value: number } }) {
|
||||
const idx = Number(e.detail.value)
|
||||
const cardType = cardTypes.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 = formatDateLocal(new Date())
|
||||
editForm.value.expireDate = calculateExpireDate(formatDateLocal(new Date()), cardType.durationDays)
|
||||
}
|
||||
}
|
||||
|
||||
function onStartDateChange(e: { detail: { value: string } }) {
|
||||
editForm.value.startDate = e.detail.value
|
||||
if (!editForm.value.manuallyEditedExpire) {
|
||||
const cardType = cardTypes.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
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const [member, types] = await Promise.all([
|
||||
adminStore.fetchMemberDetail(userId.value),
|
||||
cardTypes.value.length ? Promise.resolve(cardTypes.value) : adminStore.fetchCardTypes(),
|
||||
])
|
||||
detail.value = member
|
||||
cardTypes.value = [...types]
|
||||
profileForm.value = {
|
||||
nickname: member.user.nickname || '',
|
||||
phone: member.user.phone || '',
|
||||
}
|
||||
membershipIndex.value = 0
|
||||
applyMembership(member.memberships[0] ?? null)
|
||||
} catch (err: unknown) {
|
||||
uni.showToast({ title: getErrorMessage(err, '加载失败'), icon: 'none' })
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (submitting.value || !userId.value) return
|
||||
if (!editForm.value.cardTypeId) {
|
||||
uni.showToast({ title: '请选择卡类型', icon: 'none' })
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await adminStore.updateMemberProfile(userId.value, {
|
||||
nickname: profileForm.value.nickname.trim(),
|
||||
phone: profileForm.value.phone.trim(),
|
||||
})
|
||||
await adminStore.updateUserMembership(userId.value, {
|
||||
...(editForm.value.membershipId ? { membershipId: editForm.value.membershipId } : {}),
|
||||
cardTypeId: editForm.value.cardTypeId,
|
||||
remainingTimes: isTimeBasedCard.value ? Number(editForm.value.remainingTimes) || 0 : null,
|
||||
startDate: editForm.value.startDate,
|
||||
expireDate: editForm.value.expireDate,
|
||||
})
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
} catch (err: unknown) {
|
||||
uni.showToast({ title: getErrorMessage(err, '保存失败'), icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onClearMembership() {
|
||||
if (!userId.value) return
|
||||
uni.showModal({
|
||||
title: '确认解除',
|
||||
content: '确定要解除当前这张会员卡吗?其他卡不受影响。',
|
||||
confirmColor: '#C47A7A',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
const membershipId = editForm.value.membershipId
|
||||
if (!membershipId) {
|
||||
uni.showToast({ title: '没有可解除的会员卡', icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
await adminStore.deleteUserMembership(userId.value, membershipId)
|
||||
uni.showToast({ title: '已解除', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
} catch (err: unknown) {
|
||||
uni.showToast({ title: getErrorMessage(err, '操作失败'), icon: 'none' })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
userId.value = String(query?.userId || '')
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||
loadPage()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $bg-page;
|
||||
padding-bottom: 80rpx;
|
||||
}
|
||||
|
||||
.loading-wrap {
|
||||
padding: 80rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 26rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
.form {
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.block {
|
||||
background: $bg-card;
|
||||
border-radius: 20rpx;
|
||||
padding: 8rpx 24rpx 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
border: 1rpx solid rgba(180, 160, 130, 0.12);
|
||||
}
|
||||
|
||||
.block-title {
|
||||
display: block;
|
||||
padding: 20rpx 0 8rpx;
|
||||
font-size: 22rpx;
|
||||
letter-spacing: 3rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
.field {
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1rpx solid rgba(180, 160, 130, 0.1);
|
||||
|
||||
&:last-of-type { border-bottom: none; }
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
color: $text-hint;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
height: 64rpx;
|
||||
font-size: 30rpx;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.field-picker {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.picker-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 64rpx;
|
||||
}
|
||||
|
||||
.picker-text {
|
||||
font-size: 30rpx;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.picker-arrow {
|
||||
font-size: 24rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
.danger-link {
|
||||
padding: 20rpx 0 8rpx;
|
||||
}
|
||||
|
||||
.danger-link-text {
|
||||
font-size: 24rpx;
|
||||
color: $error-color;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
margin-top: 12rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 18rpx;
|
||||
background: $brand-color;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.save-btn--disabled { opacity: 0.5; }
|
||||
|
||||
.save-btn-text {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #fff8f0;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -163,6 +163,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import type { BookingWithDetails } from '@mp-pilates/shared'
|
||||
import { BookingStatus } from '@mp-pilates/shared'
|
||||
import { useBookingStore } from '../../stores/booking'
|
||||
@@ -295,6 +296,8 @@ function formatDateDisplay(dateStr: string): string {
|
||||
}
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────
|
||||
const hasLoadedOnce = ref(false)
|
||||
|
||||
function selectTab(key: TabKey) {
|
||||
activeTab.value = key
|
||||
}
|
||||
@@ -351,7 +354,19 @@ onMounted(() => {
|
||||
const windowInfo = uni.getWindowInfo()
|
||||
const statusBarH = windowInfo.statusBarHeight ?? 20
|
||||
navBarHeight.value = `${statusBarH + Math.round(88 * windowInfo.windowWidth / 750)}px`
|
||||
bookingStore.fetchMyBookings()
|
||||
bookingStore.fetchMyBookings().then(() => {
|
||||
hasLoadedOnce.value = true
|
||||
})
|
||||
})
|
||||
|
||||
// After returning from booking detail (where status may have changed),
|
||||
// silently re-sync without flipping loading state — keeps the list visible
|
||||
// and avoids the skeleton flash. Store action `replaceBooking` already
|
||||
// keeps `myBookings` in sync for the common case; this is the safety net
|
||||
// for any state we didn't locally patch (e.g. server-side cascading fields).
|
||||
onShow(() => {
|
||||
if (!hasLoadedOnce.value) return
|
||||
bookingStore.fetchMyBookings(undefined, { silent: true })
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user