feat: 完善课程订阅

This commit is contained in:
richarjiang
2026-04-06 08:38:05 +08:00
parent f71ff968ad
commit 3a9982209f
21 changed files with 2301 additions and 94 deletions

View File

@@ -4,8 +4,10 @@
<view v-if="!userStore.loggedIn" class="entry-card login-card" @tap="handleLogin">
<view class="entry-content">
<view class="entry-left">
<text class="entry-icon">👋</text>
<view>
<view class="entry-icon-wrap login-icon">
<view class="icon-user" />
</view>
<view class="entry-text">
<text class="entry-title">欢迎来到工作室</text>
<text class="entry-subtitle">登录后即可预约课程</text>
</view>
@@ -24,8 +26,10 @@
>
<view class="entry-content">
<view class="entry-left">
<text class="entry-icon"></text>
<view>
<view class="entry-icon-wrap trial-icon">
<view class="icon-star" />
</view>
<view class="entry-text">
<text class="entry-title">初次体验</text>
<text class="entry-subtitle">专属体验课了解普拉提</text>
</view>
@@ -42,8 +46,10 @@
<view class="entry-card active-card" @tap="handleBooking">
<view class="entry-content">
<view class="entry-left">
<text class="entry-icon">🧘</text>
<view>
<view class="entry-icon-wrap active-icon">
<view class="icon-clock" />
</view>
<view class="entry-text">
<text class="entry-title">一键约课</text>
<text class="entry-subtitle">{{ activeMembershipLabel }}</text>
</view>
@@ -60,7 +66,9 @@
<!-- Renew reminder if running low -->
<view v-if="isRunningLow" class="renew-tip" @tap="scrollToCardShop">
<text class="renew-tip-icon"></text>
<view class="renew-tip-icon">
<view class="icon-warning" />
</view>
<text class="renew-tip-text">课次即将用完点击续卡保持练习节奏</text>
<text class="renew-tip-action">续卡 </text>
</view>
@@ -74,8 +82,10 @@
>
<view class="entry-content">
<view class="entry-left">
<text class="entry-icon">💳</text>
<view>
<view class="entry-icon-wrap expired-icon">
<view class="icon-card" />
</view>
<view class="entry-text">
<text class="entry-title">续费会员卡</text>
<text class="entry-subtitle">您的卡已到期续卡继续练习</text>
</view>
@@ -174,24 +184,24 @@ const lowestRemainingTimes = computed(() => {
position: relative;
border-radius: 16rpx;
padding: 36rpx 32rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.10);
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.login-card {
background: linear-gradient(135deg, #1a1a2e, #2d2d5e);
background: linear-gradient(135deg, #1a1a2e 0%, #2d2d5e 100%);
}
.trial-card {
background: linear-gradient(135deg, #2d2d5e, #4a3f7a);
background: linear-gradient(135deg, #2d2d5e 0%, #4a3f7a 100%);
}
.active-card {
background: linear-gradient(135deg, #1a1a2e, #3a2a1a);
background: linear-gradient(135deg, #2a3a4a 0%, #1a2a3a 100%);
}
.expired-card {
background: linear-gradient(135deg, #4a4a4a, #2a2a2a);
background: linear-gradient(135deg, #4a4a4a 0%, #2a2a2a 100%);
}
.entry-content {
@@ -204,59 +214,196 @@ const lowestRemainingTimes = computed(() => {
.entry-left {
display: flex;
align-items: center;
gap: 24rpx;
gap: 28rpx;
flex: 1;
}
.entry-icon {
font-size: 56rpx;
.entry-icon-wrap {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
}
.login-icon {
background: rgba(255, 255, 255, 0.12);
}
.trial-icon {
background: rgba(255, 215, 0, 0.2);
}
.active-icon {
background: rgba(168, 196, 206, 0.25);
}
.expired-icon {
background: rgba(255, 255, 255, 0.12);
}
/* ── Icon shapes (pure CSS) ── */
/* User icon: head + shoulders */
.icon-user {
position: relative;
width: 36rpx;
height: 36rpx;
&::before {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: #fff;
}
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 28rpx;
height: 14rpx;
border-radius: 14rpx 14rpx 0 0;
background: #fff;
}
}
/* Star icon - diamond shape */
.icon-star {
position: relative;
width: 32rpx;
height: 32rpx;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(45deg);
width: 24rpx;
height: 24rpx;
background: #ffd700;
}
}
/* Clock icon - circle with dot */
.icon-clock {
position: relative;
width: 36rpx;
height: 36rpx;
border-radius: 50%;
border: 3rpx solid #fff;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: #fff;
}
}
/* Card icon */
.icon-card {
position: relative;
width: 36rpx;
height: 26rpx;
border-radius: 4rpx;
border: 3rpx solid #fff;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 12rpx;
height: 6rpx;
border-radius: 2rpx;
background: #fff;
}
}
/* Warning triangle */
.icon-warning {
position: relative;
width: 0;
height: 0;
border-left: 12rpx solid transparent;
border-right: 12rpx solid transparent;
border-bottom: 20rpx solid #e8a87c;
}
.entry-text {
flex: 1;
min-width: 0;
}
.entry-title {
display: block;
font-size: 34rpx;
font-weight: 700;
font-weight: 600;
color: #ffffff;
margin-bottom: 8rpx;
letter-spacing: 1rpx;
}
.entry-subtitle {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.65);
color: rgba(255, 255, 255, 0.6);
line-height: 1.4;
}
.entry-btn {
flex-shrink: 0;
padding: 16rpx 32rpx;
padding: 18rpx 36rpx;
border-radius: 40rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255,255,255,0.2) 0%, transparent 100%);
opacity: 0.5;
}
}
.entry-btn-text {
font-size: 28rpx;
font-weight: 600;
white-space: nowrap;
position: relative;
z-index: 1;
}
.login-btn {
background: $primary-dark;
}
.trial-btn {
background: $primary-dark;
}
.login-btn,
.trial-btn,
.book-btn {
background: $primary-dark;
background: $primary-color;
}
.renew-btn {
background: #888;
background: #666;
}
.login-btn .entry-btn-text,
@@ -278,7 +425,7 @@ const lowestRemainingTimes = computed(() => {
}
.trial-badge {
background: $primary-dark;
background: linear-gradient(135deg, #ffd700, #ffaa00);
color: #1a1a2e;
}
@@ -296,11 +443,15 @@ const lowestRemainingTimes = computed(() => {
padding: 20rpx 24rpx;
background: #fff8f0;
border-radius: 12rpx;
border: 1rpx solid #f0d9bc;
border: 1rpx solid rgba(240, 180, 100, 0.3);
}
.renew-tip-icon {
font-size: 28rpx;
width: 36rpx;
height: 36rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}

View File

@@ -1,4 +1,7 @@
{
"easycom": {
"autoscan": true
},
"pages": [
{
"path": "pages/home/index",
@@ -12,6 +15,12 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/booking/detail",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/profile/index",
"style": {
@@ -48,6 +57,12 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/bookings",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/schedule",
"style": {

View File

@@ -0,0 +1,785 @@
<template>
<view class="admin-bookings-page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="课程管理" show-back />
<!-- Stats row -->
<view class="stats-row">
<view class="stat-item" @tap="switchTab(null)">
<text class="stat-num">{{ stats.total }}</text>
<text class="stat-label">全部</text>
</view>
<view class="stat-item stat-item--pending" @tap="switchTab('PENDING_CONFIRMATION')">
<text class="stat-num">{{ stats.pending }}</text>
<text class="stat-label">待确认</text>
</view>
<view class="stat-item stat-item--confirmed" @tap="switchTab('CONFIRMED')">
<text class="stat-num">{{ stats.confirmed }}</text>
<text class="stat-label">已确认</text>
</view>
<view class="stat-item stat-item--completed" @tap="switchTab('COMPLETED')">
<text class="stat-num">{{ stats.completed }}</text>
<text class="stat-label">已完成</text>
</view>
</view>
<!-- Tab filter bar -->
<view class="filter-bar">
<view
v-for="tab in filterTabs"
:key="tab.value ?? 'all'"
class="filter-tab"
:class="{ active: activeFilter === tab.value }"
@tap="switchTab(tab.value)"
>
<text class="filter-tab-text">{{ tab.label }}</text>
</view>
</view>
<!-- Booking list -->
<scroll-view
class="scroll"
scroll-y
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
>
<!-- Loading -->
<view v-if="loading && !refreshing" class="loading-wrap">
<view v-for="i in 4" :key="i" class="skeleton-card">
<view class="skeleton-stripe" />
<view class="skeleton-body">
<view class="skeleton-line skeleton-line--long" />
<view class="skeleton-line skeleton-line--medium" />
<view class="skeleton-line skeleton-line--short" />
</view>
</view>
</view>
<!-- Empty -->
<view v-else-if="bookings.length === 0" class="empty-wrap">
<view class="empty-icon-circle">
<text class="empty-icon-text">📋</text>
</view>
<text class="empty-title">暂无预约</text>
<text class="empty-sub">当前筛选条件下没有预约记录</text>
</view>
<!-- Booking cards -->
<view v-else class="list">
<view
v-for="booking in bookings"
:key="booking.id"
class="booking-card"
@tap="goDetail(booking)"
>
<!-- Left stripe -->
<view class="booking-stripe" :class="bookingStatusStripeClass(booking.status)" />
<!-- Content -->
<view class="booking-content">
<!-- Header row -->
<view class="booking-header">
<view class="student-info">
<text class="student-name">{{ booking.user?.nickname || '匿名用户' }}</text>
<text v-if="booking.user?.phone" class="student-phone">{{ booking.user.phone }}</text>
</view>
<view class="status-badge" :class="bookingStatusBadgeClass(booking.status)">
<text class="status-text">{{ bookingStatusLabel(booking.status) }}</text>
</view>
</view>
<!-- Course info -->
<view class="course-info">
<text class="course-date">{{ formatDateDisplay(booking.timeSlot.date) }}</text>
<text class="course-time">{{ booking.timeSlot.startTime.slice(0, 5) }} - {{ booking.timeSlot.endTime.slice(0, 5) }}</text>
</view>
<!-- Card type -->
<view class="card-info">
<text class="card-label">使用卡种</text>
<text class="card-name">{{ booking.membership?.cardType?.name }}</text>
</view>
<!-- Action buttons -->
<view v-if="booking.status === 'PENDING_CONFIRMATION'" class="action-row">
<view class="action-btn action-btn--confirm" @tap.stop="handleConfirm(booking)">
<text class="action-btn-text">确认预约</text>
</view>
<view class="action-btn action-btn--cancel" @tap.stop="handleCancel(booking)">
<text class="action-btn-text">取消</text>
</view>
</view>
<view v-else-if="booking.status === 'CONFIRMED'" class="action-row">
<view class="action-btn action-btn--complete" @tap.stop="handleComplete(booking)">
<text class="action-btn-text">核销完成</text>
</view>
<view class="action-btn action-btn--noshow" @tap.stop="handleNoShow(booking)">
<text class="action-btn-text">标记未到</text>
</view>
</view>
<!-- Timeline preview -->
<view v-if="getHistory(booking.id).length > 0" class="timeline-preview">
<view
v-for="(h, idx) in getHistory(booking.id).slice(-2)"
:key="idx"
class="timeline-item"
>
<text class="timeline-dot" :class="bookingTimelineDotClass(h.toStatus)" />
<text class="timeline-text">{{ formatTimelineText(h) }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- Load more / pagination -->
<view v-if="bookings.length > 0 && hasMore" class="load-more" @tap="loadMore">
<text class="load-more-text">加载更多</text>
</view>
<view class="scroll-bottom-spacer" />
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { BookingWithUser, BookingStatusHistory } from '@mp-pilates/shared'
import { BookingStatus } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking'
import { getSystemLayout } from '../../utils/system'
import {
formatDateDisplay,
bookingStatusLabel,
bookingStatusBadgeClass,
bookingStatusStripeClass,
bookingTimelineDotClass,
} from '../../utils/booking-helpers'
import CustomNavBar from '../../components/CustomNavBar.vue'
// ─── Store & Nav ──────────────────────────────────────────────────────────
const bookingStore = useBookingStore()
const navBarHeight = ref('64px')
const refreshing = ref(false)
const loading = ref(false)
// ─── Filter state ─────────────────────────────────────────────────────────
type FilterValue = string | null
const filterTabs: { label: string; value: FilterValue }[] = [
{ label: '全部', value: null },
{ label: '待确认', value: 'PENDING_CONFIRMATION' },
{ label: '已确认', value: 'CONFIRMED' },
{ label: '已完成', value: 'COMPLETED' },
{ label: '已取消', value: 'CANCELLED' },
]
const activeFilter = ref<FilterValue>(null)
// ─── Pagination ───────────────────────────────────────────────────────────
const currentPage = ref(1)
const pageSize = 20
const hasMore = ref(false)
const totalCount = ref(0)
// ─── Data ────────────────────────────────────────────────────────────────
const bookings = ref<BookingWithUser[]>([])
const allBookingsCache = ref<BookingWithUser[]>([]) // cache for stats
const historyMap = ref<Record<string, BookingStatusHistory[]>>({})
// ─── Computed stats ──────────────────────────────────────────────────────
const stats = computed(() => {
const cache = allBookingsCache.value
return {
total: cache.length,
pending: cache.filter((b) => b.status === BookingStatus.PENDING_CONFIRMATION).length,
confirmed: cache.filter((b) => b.status === BookingStatus.CONFIRMED).length,
completed: cache.filter(
(b) => b.status === BookingStatus.COMPLETED || b.status === BookingStatus.NO_SHOW,
).length,
}
})
// ─── Timeline helpers ─────────────────────────────────────────────────────
function getHistory(bookingId: string): BookingStatusHistory[] {
return historyMap.value[bookingId] || []
}
function formatTimelineText(h: BookingStatusHistory): string {
const d = new Date(h.createdAt)
const time = `${d.getMonth() + 1}${d.getDate()}${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
return `${time} ${h.remark || bookingStatusLabel(h.toStatus)}`
}
// ─── Data loading ─────────────────────────────────────────────────────────
async function loadBookings(append = false) {
if (loading.value) return
loading.value = true
try {
const page = append ? currentPage.value + 1 : 1
const result = await bookingStore.fetchAllAdminBookings(page, pageSize, activeFilter.value ?? undefined)
if (append) {
bookings.value = [...bookings.value, ...(result.data as BookingWithUser[])]
currentPage.value = page
} else {
bookings.value = result.data as BookingWithUser[]
currentPage.value = 1
}
totalCount.value = result.total
hasMore.value = bookings.value.length < result.total
// Fetch history for each booking
if (!append) {
await Promise.all(
bookings.value.map((b) => fetchHistory(b.id)),
)
}
// Update cache for stats
if (!append && activeFilter.value === null) {
allBookingsCache.value = bookings.value
}
} catch (err) {
console.error('Load bookings failed:', err)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
async function fetchHistory(bookingId: string) {
try {
const history = await bookingStore.fetchBookingHistory(bookingId)
historyMap.value[bookingId] = history
} catch (err) {
console.error('Fetch history failed:', err)
}
}
async function loadAllForStats() {
try {
// Load all statuses for stats display
const result = await bookingStore.fetchAllAdminBookings(1, 200, undefined)
allBookingsCache.value = result.data as BookingWithUser[]
} catch (err) {
console.error('Load stats failed:', err)
}
}
async function onRefresh() {
refreshing.value = true
await Promise.all([loadBookings(false), loadAllForStats()])
refreshing.value = false
}
async function loadMore() {
if (!hasMore.value) return
await loadBookings(true)
}
// ─── Tab switching ───────────────────────────────────────────────────────
function switchTab(value: FilterValue) {
if (activeFilter.value === value) return
activeFilter.value = value
loadBookings(false)
}
// ─── Actions ──────────────────────────────────────────────────────────────
async function handleConfirm(booking: BookingWithUser) {
uni.showModal({
title: '确认预约',
content: `确认 ${booking.user?.nickname} 的预约?确认后将扣除会员次数。`,
confirmText: '确认',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '处理中...' })
try {
await bookingStore.confirmBooking(booking.id)
uni.hideLoading()
uni.showToast({ title: '已确认', icon: 'success' })
await onRefresh()
} catch (err: unknown) {
uni.hideLoading()
const msg = err instanceof Error ? err.message : '操作失败'
uni.showToast({ title: msg, icon: 'none' })
}
},
})
}
async function handleComplete(booking: BookingWithUser) {
uni.showModal({
title: '核销完成',
content: `标记 ${booking.user?.nickname} 的课程为已完成?`,
confirmText: '确认',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '处理中...' })
try {
await bookingStore.completeBooking(booking.id)
uni.hideLoading()
uni.showToast({ title: '已核销', icon: 'success' })
await onRefresh()
} catch (err: unknown) {
uni.hideLoading()
const msg = err instanceof Error ? err.message : '操作失败'
uni.showToast({ title: msg, icon: 'none' })
}
},
})
}
async function handleNoShow(booking: BookingWithUser) {
uni.showModal({
title: '标记未到',
content: `标记 ${booking.user?.nickname} 的课程为未出席?`,
confirmText: '确认',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '处理中...' })
try {
await bookingStore.markNoShow(booking.id)
uni.hideLoading()
uni.showToast({ title: '已标记', icon: 'success' })
await onRefresh()
} catch (err: unknown) {
uni.hideLoading()
const msg = err instanceof Error ? err.message : '操作失败'
uni.showToast({ title: msg, icon: 'none' })
}
},
})
}
async function handleCancel(booking: BookingWithUser) {
uni.showModal({
title: '取消预约',
content: `取消 ${booking.user?.nickname} 的预约?`,
confirmText: '确认取消',
confirmColor: '#ef4444',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '处理中...' })
try {
await bookingStore.cancelBooking(booking.id)
uni.hideLoading()
uni.showToast({ title: '已取消', icon: 'success' })
await onRefresh()
} catch (err: unknown) {
uni.hideLoading()
const msg = err instanceof Error ? err.message : '操作失败'
uni.showToast({ title: msg, icon: 'none' })
}
},
})
}
function goDetail(booking: BookingWithUser) {
uni.navigateTo({
url: `/pages/booking/detail?id=${booking.id}`,
})
}
// ─── Lifecycle ────────────────────────────────────────────────────────────
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
loadBookings(false)
loadAllForStats()
})
</script>
<style lang="scss" scoped>
.admin-bookings-page {
min-height: 100vh;
background: $primary-bg;
display: flex;
flex-direction: column;
}
/* ── Stats row ──────────────────────────────────────── */
.stats-row {
display: flex;
background: #fff;
padding: 24rpx 16rpx;
gap: 8rpx;
border-bottom: 1rpx solid $primary-border;
}
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
padding: 16rpx 8rpx;
border-radius: 12rpx;
transition: background 0.15s;
&:active {
background: rgba(0, 0, 0, 0.04);
}
}
.stat-num {
font-size: 36rpx;
font-weight: 700;
color: #4A4035;
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
}
.stat-label {
font-size: 22rpx;
color: #A09080;
}
.stat-item--pending .stat-num { color: #f59e0b; }
.stat-item--confirmed .stat-num { color: $primary-dark; }
.stat-item--completed .stat-num { color: #66bb6a; }
/* ── Filter bar ────────────────────────────────────── */
.filter-bar {
display: flex;
background: #fff;
padding: 0 16rpx 16rpx;
gap: 8rpx;
}
.filter-tab {
padding: 10rpx 20rpx;
border-radius: 20rpx;
background: rgba(0, 0, 0, 0.04);
transition: all 0.15s;
&.active {
background: $primary-dark;
.filter-tab-text {
color: #fff;
}
}
}
.filter-tab-text {
font-size: 24rpx;
color: #666;
font-weight: 500;
}
/* ── Scroll ──────────────────────────────────────────── */
.scroll {
flex: 1;
height: calc(100vh - 300rpx);
}
/* ── Loading skeleton ────────────────────────────────── */
.loading-wrap {
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skeleton-card {
border-radius: 16rpx;
background: #fff;
overflow: hidden;
display: flex;
flex-direction: row;
}
.skeleton-stripe {
width: 8rpx;
flex-shrink: 0;
background: #eee;
}
.skeleton-body {
flex: 1;
padding: 28rpx 24rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skeleton-line {
height: 28rpx;
border-radius: 8rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
&--long { width: 60%; }
&--medium { width: 40%; }
&--short { width: 30%; }
}
/* ── Empty ───────────────────────────────────────────── */
.empty-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 40rpx;
gap: 16rpx;
}
.empty-icon-circle {
width: 140rpx;
height: 140rpx;
border-radius: 50%;
background: $primary-border;
display: flex;
align-items: center;
justify-content: center;
}
.empty-icon-text {
font-size: 56rpx;
}
.empty-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.empty-sub {
font-size: 26rpx;
color: #999;
}
/* ── List ────────────────────────────────────────────── */
.list {
padding: 20rpx 24rpx 0;
display: flex;
flex-direction: column;
gap: 20rpx;
}
/* ── Booking card ────────────────────────────────────── */
.booking-card {
background: #fff;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: row;
}
.booking-stripe {
width: 8rpx;
flex-shrink: 0;
&.stripe--pending { background: #f59e0b; }
&.stripe--confirmed { background: $primary-dark; }
&.stripe--completed { background: #66bb6a; }
&.stripe--cancelled { background: #e0e0e0; }
&.stripe--noshow { background: #ef5350; }
}
.booking-content {
flex: 1;
padding: 24rpx 20rpx;
display: flex;
flex-direction: column;
gap: 14rpx;
}
.booking-header {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
}
.student-info {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.student-name {
font-size: 30rpx;
font-weight: 600;
color: #1a1a1a;
}
.student-phone {
font-size: 24rpx;
color: #888;
}
/* Status badge */
.status-badge {
padding: 8rpx 18rpx;
border-radius: 20rpx;
flex-shrink: 0;
&.badge--pending { background: rgba(245, 158, 11, 0.12); }
&.badge--confirmed { background: rgba(201, 168, 124, 0.12); }
&.badge--completed { background: rgba(102, 187, 106, 0.12); }
&.badge--cancelled { background: rgba(0, 0, 0, 0.04); }
&.badge--noshow { background: rgba(239, 83, 80, 0.1); }
}
.status-text {
font-size: 22rpx;
font-weight: 600;
.badge--pending & { color: #f59e0b; }
.badge--confirmed & { color: $primary-dark; }
.badge--completed & { color: #66bb6a; }
.badge--cancelled & { color: #bbb; }
.badge--noshow & { color: #ef5350; }
}
/* Course info */
.course-info {
display: flex;
flex-direction: row;
align-items: center;
gap: 16rpx;
}
.course-date {
font-size: 28rpx;
font-weight: 600;
color: #333;
}
.course-time {
font-size: 26rpx;
color: #666;
}
/* Card info */
.card-info {
display: flex;
flex-direction: row;
align-items: center;
gap: 8rpx;
}
.card-label {
font-size: 22rpx;
color: #bbb;
}
.card-name {
font-size: 24rpx;
color: #666;
font-weight: 500;
}
/* Action buttons */
.action-row {
display: flex;
flex-direction: row;
gap: 12rpx;
padding-top: 8rpx;
border-top: 1rpx solid #f5f5f5;
}
.action-btn {
flex: 1;
padding: 16rpx 0;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.15s;
&:active {
opacity: 0.8;
}
&--confirm {
background: linear-gradient(135deg, $primary-color, $primary-dark);
}
&--cancel {
background: rgba(0, 0, 0, 0.04);
}
&--complete {
background: linear-gradient(135deg, #66bb6a, #4caf50);
}
&--noshow {
background: rgba(239, 83, 80, 0.1);
}
}
.action-btn-text {
font-size: 26rpx;
font-weight: 600;
color: #fff;
.action-btn--cancel & {
color: #666;
}
.action-btn--noshow & {
color: #ef5350;
}
}
/* Timeline preview */
.timeline-preview {
display: flex;
flex-direction: column;
gap: 6rpx;
padding-top: 8rpx;
border-top: 1rpx solid #f5f5f5;
}
.timeline-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 8rpx;
}
.timeline-dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
flex-shrink: 0;
&.dot--pending { background: #f59e0b; }
&.dot--confirmed { background: $primary-dark; }
&.dot--completed { background: #66bb6a; }
&.dot--cancelled { background: #e0e0e0; }
&.dot--noshow { background: #ef5350; }
}
.timeline-text {
font-size: 20rpx;
color: #999;
}
/* Load more */
.load-more {
padding: 32rpx;
display: flex;
align-items: center;
justify-content: center;
}
.load-more-text {
font-size: 26rpx;
color: $primary-dark;
font-weight: 500;
}
/* Bottom spacer */
.scroll-bottom-spacer {
height: 48rpx;
}
</style>

View File

@@ -34,6 +34,21 @@
<!-- List: schedule -->
<view class="list">
<view class="list-item" @tap="navigate('/pages/admin/bookings')">
<view class="item-left">
<view class="item-icon-wrap icon--bookings">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">预约管理</text>
<text class="item-desc">查看/确认/核销学员预约</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
</view>
<view class="list-item" @tap="navigate('/pages/admin/schedule')">
<view class="item-left">
<view class="item-icon-wrap icon--schedule">
@@ -309,6 +324,7 @@ onMounted(() => {
}
/* Icon variants — warm muted tones */
.icon--bookings { background: linear-gradient(135deg, #C4A87E, #B49868); }
.icon--schedule { background: linear-gradient(135deg, #8B9E7E, #7A8E6E); }
.icon--template { background: linear-gradient(135deg, #A090C0, #9080B0); }
.icon--members { background: linear-gradient(135deg, $primary-color, $primary-dark); }

View File

@@ -0,0 +1,588 @@
<template>
<view class="booking-detail-page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="预约详情" show-back />
<!-- Loading state -->
<view v-if="loading" class="loading-wrap">
<view class="skeleton-card" />
</view>
<!-- Error state -->
<view v-else-if="!booking" class="empty-wrap">
<text class="empty-title">预约不存在</text>
</view>
<template v-else>
<!-- Booking info card -->
<view class="info-card">
<!-- Status banner -->
<view class="status-banner" :class="bookingStatusBannerClass(booking.status)">
<text class="status-banner-text">{{ bookingStatusLabel(booking.status) }}</text>
</view>
<!-- Course info -->
<view class="info-section">
<view class="info-row">
<text class="info-label">课程日期</text>
<text class="info-value">{{ formatDateDisplay(booking.timeSlot.date) }}</text>
</view>
<view class="info-row">
<text class="info-label">课程时间</text>
<text class="info-value">
{{ booking.timeSlot.startTime.slice(0, 5) }} - {{ booking.timeSlot.endTime.slice(0, 5) }}
</text>
</view>
<view class="info-row">
<text class="info-label">使用卡种</text>
<text class="info-value">{{ booking.membership?.cardType?.name }}</text>
</view>
</view>
<!-- User info (for admin view) -->
<view v-if="isAdmin && bookingUser" class="info-section">
<view class="section-title">学员信息</view>
<view class="info-row">
<text class="info-label">学员姓名</text>
<text class="info-value">{{ bookingUser.nickname || '匿名用户' }}</text>
</view>
<view v-if="bookingUser.phone" class="info-row">
<text class="info-label">联系电话</text>
<text class="info-value">{{ bookingUser.phone }}</text>
</view>
</view>
<!-- Timestamps -->
<view class="info-section">
<view class="section-title">时间记录</view>
<view class="info-row">
<text class="info-label">预约时间</text>
<text class="info-value">{{ formatDateTime(booking.createdAt) }}</text>
</view>
<view v-if="booking.confirmedAt" class="info-row">
<text class="info-label">确认时间</text>
<text class="info-value">{{ formatDateTime(booking.confirmedAt) }}</text>
</view>
<view v-if="booking.completedAt" class="info-row">
<text class="info-label">核销时间</text>
<text class="info-value">{{ formatDateTime(booking.completedAt) }}</text>
</view>
<view v-if="booking.cancelledAt" class="info-row">
<text class="info-label">取消时间</text>
<text class="info-value">{{ formatDateTime(booking.cancelledAt) }}</text>
</view>
</view>
</view>
<!-- Status timeline -->
<view class="timeline-card">
<view class="timeline-header">
<text class="timeline-title">状态流转记录</text>
</view>
<view v-if="history.length === 0" class="timeline-empty">
<text class="timeline-empty-text">暂无流转记录</text>
</view>
<view v-else class="timeline">
<view
v-for="(item, idx) in history"
:key="item.id"
class="timeline-item"
:class="{ 'timeline-item--last': idx === history.length - 1 }"
>
<!-- Dot -->
<view class="timeline-dot-wrap">
<view class="timeline-dot" :class="bookingTimelineDotClass(item.toStatus)" />
<view v-if="idx < history.length - 1" class="timeline-line" />
</view>
<!-- Content -->
<view class="timeline-content">
<view class="timeline-content-header">
<text class="timeline-status">{{ formatHistoryStatus(item.toStatus) }}</text>
<text class="timeline-time">{{ formatDateTime(item.createdAt) }}</text>
</view>
<text v-if="item.remark" class="timeline-remark">{{ item.remark }}</text>
</view>
</view>
</view>
</view>
<!-- Action buttons -->
<view v-if="showActions" class="action-bar">
<!-- Pending: confirm button for admin -->
<view
v-if="booking.status === 'PENDING_CONFIRMATION' && isAdmin"
class="action-btn action-btn--confirm"
@tap="handleConfirm"
>
<text class="action-btn-text">确认预约</text>
</view>
<!-- Confirmed: complete / noshow buttons for admin -->
<view v-if="booking.status === 'CONFIRMED' && isAdmin" class="action-row">
<view class="action-btn action-btn--complete" @tap="handleComplete">
<text class="action-btn-text">核销完成</text>
</view>
<view class="action-btn action-btn--noshow" @tap="handleNoShow">
<text class="action-btn-text">标记未到</text>
</view>
</view>
<!-- User can cancel if pending or confirmed -->
<view
v-if="booking.status === 'PENDING_CONFIRMATION' || booking.status === 'CONFIRMED'"
class="action-btn action-btn--cancel"
@tap="handleCancel"
>
<text class="action-btn-text">取消预约</text>
</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import type { BookingWithDetails, BookingWithUser, BookingStatusHistory } from '@mp-pilates/shared'
import { BookingStatus } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking'
import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system'
import {
formatDateDisplay,
bookingStatusLabel,
bookingStatusBannerClass,
bookingTimelineDotClass,
} from '../../utils/booking-helpers'
import CustomNavBar from '../../components/CustomNavBar.vue'
const bookingStore = useBookingStore()
const userStore = useUserStore()
const navBarHeight = ref('64px')
const loading = ref(false)
const bookingId = ref('')
const booking = ref<BookingWithDetails | BookingWithUser | null>(null)
const history = ref<BookingStatusHistory[]>([])
const isAdmin = computed(() => userStore.isAdmin)
const showActions = computed(() =>
booking.value?.status === BookingStatus.PENDING_CONFIRMATION ||
booking.value?.status === BookingStatus.CONFIRMED,
)
// Type guard to check if booking has user property
function hasUser(b: BookingWithDetails | BookingWithUser | null): b is BookingWithUser {
return b !== null && 'user' in b
}
const bookingUser = computed(() => hasUser(booking.value) ? booking.value.user : null)
// ─── Status helpers ───────────────────────────────────────────────────────
function formatHistoryStatus(status: string): string {
return bookingStatusLabel(status)
}
function formatDateTime(dateStr: string): string {
if (!dateStr) return '-'
const d = new Date(dateStr)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return `${y}-${m}-${day} ${hh}:${mm}`
}
// ─── Data loading ─────────────────────────────────────────────────────────
async function loadData() {
loading.value = true
try {
// Fetch booking details and history in parallel
const [bookingData, historyData] = await Promise.all([
bookingStore.fetchBookingById(bookingId.value),
bookingStore.fetchBookingHistory(bookingId.value),
])
booking.value = bookingData
history.value = historyData
} catch (err) {
console.error('Load booking detail failed:', err)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
// ─── Actions ──────────────────────────────────────────────────────────────
async function handleConfirm() {
uni.showModal({
title: '确认预约',
content: '确认该预约?确认后将扣除会员次数。',
confirmText: '确认',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '处理中...' })
try {
await bookingStore.confirmBooking(bookingId.value)
uni.hideLoading()
uni.showToast({ title: '已确认', icon: 'success' })
await loadData()
} catch (err: unknown) {
uni.hideLoading()
const msg = err instanceof Error ? err.message : '操作失败'
uni.showToast({ title: msg, icon: 'none' })
}
},
})
}
async function handleComplete() {
uni.showModal({
title: '核销完成',
content: '标记该课程为已完成?',
confirmText: '确认',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '处理中...' })
try {
await bookingStore.completeBooking(bookingId.value)
uni.hideLoading()
uni.showToast({ title: '已核销', icon: 'success' })
await loadData()
} catch (err: unknown) {
uni.hideLoading()
const msg = err instanceof Error ? err.message : '操作失败'
uni.showToast({ title: msg, icon: 'none' })
}
},
})
}
async function handleNoShow() {
uni.showModal({
title: '标记未到',
content: '标记该学员未出席?',
confirmText: '确认',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '处理中...' })
try {
await bookingStore.markNoShow(bookingId.value)
uni.hideLoading()
uni.showToast({ title: '已标记', icon: 'success' })
await loadData()
} catch (err: unknown) {
uni.hideLoading()
const msg = err instanceof Error ? err.message : '操作失败'
uni.showToast({ title: msg, icon: 'none' })
}
},
})
}
async function handleCancel() {
uni.showModal({
title: '取消预约',
content: '确定要取消该预约?',
confirmText: '确认取消',
confirmColor: '#ef4444',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '处理中...' })
try {
await bookingStore.cancelBooking(bookingId.value)
uni.hideLoading()
uni.showToast({ title: '已取消', icon: 'success' })
await loadData()
} catch (err: unknown) {
uni.hideLoading()
const msg = err instanceof Error ? err.message : '操作失败'
uni.showToast({ title: msg, icon: 'none' })
}
},
})
}
// ─── Lifecycle ────────────────────────────────────────────────────────────
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
})
onLoad((query) => {
bookingId.value = (query as Record<string, string>).id || ''
if (bookingId.value) {
loadData()
}
})
</script>
<style lang="scss" scoped>
.booking-detail-page {
min-height: 100vh;
background: $primary-bg;
padding-bottom: 40rpx;
}
/* ── Loading ─────────────────────────────────────────── */
.loading-wrap {
padding: 24rpx;
}
.skeleton-card {
height: 300rpx;
border-radius: 20rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
/* ── Empty ───────────────────────────────────────────── */
.empty-wrap {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 40rpx;
}
.empty-title {
font-size: 32rpx;
color: #666;
}
/* ── Info card ────────────────────────────────────────── */
.info-card {
margin: 24rpx;
background: #fff;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.05);
}
.status-banner {
padding: 24rpx;
display: flex;
align-items: center;
justify-content: center;
&--pending { background: rgba(245, 158, 11, 0.1); }
&--confirmed { background: rgba(201, 168, 124, 0.1); }
&--completed { background: rgba(102, 187, 106, 0.1); }
&--cancelled { background: rgba(0, 0, 0, 0.04); }
&--noshow { background: rgba(239, 83, 80, 0.1); }
}
.status-banner-text {
font-size: 32rpx;
font-weight: 600;
.status-banner--pending & { color: #f59e0b; }
.status-banner--confirmed & { color: $primary-dark; }
.status-banner--completed & { color: #66bb6a; }
.status-banner--cancelled & { color: #bbb; }
.status-banner--noshow & { color: #ef5350; }
}
.info-section {
padding: 24rpx;
border-bottom: 1rpx solid #f5f5f5;
&:last-child {
border-bottom: none;
}
}
.section-title {
font-size: 24rpx;
font-weight: 600;
color: #A09080;
margin-bottom: 16rpx;
letter-spacing: 1rpx;
}
.info-row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 12rpx 0;
}
.info-label {
font-size: 26rpx;
color: #999;
}
.info-value {
font-size: 28rpx;
color: #333;
font-weight: 500;
}
/* ── Timeline card ────────────────────────────────────── */
.timeline-card {
margin: 24rpx;
background: #fff;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.05);
}
.timeline-header {
padding: 24rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.timeline-title {
font-size: 28rpx;
font-weight: 600;
color: #333;
}
.timeline-empty {
padding: 48rpx;
display: flex;
align-items: center;
justify-content: center;
}
.timeline-empty-text {
font-size: 26rpx;
color: #bbb;
}
.timeline {
padding: 24rpx;
display: flex;
flex-direction: column;
}
.timeline-item {
display: flex;
flex-direction: row;
gap: 16rpx;
}
.timeline-dot-wrap {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
}
.timeline-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
flex-shrink: 0;
&.dot--pending { background: #f59e0b; }
&.dot--confirmed { background: $primary-dark; }
&.dot--completed { background: #66bb6a; }
&.dot--cancelled { background: #e0e0e0; }
&.dot--noshow { background: #ef5350; }
}
.timeline-line {
width: 2rpx;
flex: 1;
min-height: 40rpx;
background: #e8e8e8;
margin: 4rpx 0;
}
.timeline-item--last .timeline-line {
display: none;
}
.timeline-content {
flex: 1;
padding-bottom: 28rpx;
}
.timeline-content-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 6rpx;
}
.timeline-status {
font-size: 28rpx;
font-weight: 600;
color: #333;
}
.timeline-time {
font-size: 22rpx;
color: #bbb;
}
.timeline-remark {
font-size: 24rpx;
color: #888;
display: block;
margin-top: 4rpx;
}
/* ── Action bar ──────────────────────────────────────── */
.action-bar {
margin: 24rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.action-row {
display: flex;
flex-direction: row;
gap: 16rpx;
}
.action-btn {
flex: 1;
padding: 28rpx 0;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.15s;
&:active {
opacity: 0.85;
}
&--confirm {
background: linear-gradient(135deg, $primary-color, $primary-dark);
}
&--complete {
background: linear-gradient(135deg, #66bb6a, #4caf50);
}
&--noshow {
background: rgba(239, 83, 80, 0.1);
}
&--cancel {
background: rgba(0, 0, 0, 0.04);
}
}
.action-btn-text {
font-size: 32rpx;
font-weight: 600;
color: #fff;
.action-btn--cancel & {
color: #666;
}
.action-btn--noshow & {
color: #ef5350;
}
}
</style>

View File

@@ -12,7 +12,7 @@
</view>
<!-- Error state -->
<view v-else-if="!card" class="error-wrap">
<view v-else-if="!card && !showAll" class="error-wrap">
<text class="error-icon">😕</text>
<text class="error-text">会员卡信息加载失败</text>
<view class="retry-btn" @tap="loadCard">
@@ -20,7 +20,50 @@
</view>
</view>
<!-- Card content -->
<!-- All cards list mode -->
<template v-else-if="showAll">
<view v-if="loading" class="loading-wrap">
<view class="skeleton-header" />
<view class="skeleton-body">
<view class="skeleton-line w80" />
<view class="skeleton-line w60" />
<view class="skeleton-line w40" />
</view>
</view>
<view v-else-if="allCards.length" class="all-cards-list">
<view
v-for="c in allCards"
:key="c.id"
class="card-row"
@tap="goToDetail(c.id)"
>
<view class="card-thumb" :class="thumbClass(c)">
<view class="thumb-fallback">
<text class="thumb-name">{{ truncate(c.name, 8) }}</text>
<text class="thumb-price">¥{{ formatPrice(c.price) }}</text>
</view>
</view>
<view class="card-info">
<text class="card-name">{{ c.name }}</text>
<text class="card-validity">有效期:{{ c.durationDays }} </text>
<view class="price-row">
<text class="price-current">¥{{ formatPrice(c.price) }}</text>
<text
v-if="c.originalPrice && c.originalPrice > c.price"
class="price-original"
>
原价:¥{{ formatPrice(c.originalPrice) }}
</text>
</view>
</view>
</view>
</view>
<view v-else class="empty-state">
<text class="empty-text">暂无可购买的会员卡</text>
</view>
</template>
<!-- Card content (single card mode) -->
<template v-else>
<!-- Hero section -->
<view class="card-hero" :class="heroClass">
@@ -142,9 +185,11 @@ const navBarHeight = ref('64px')
// ─── Route params ──────────────────────────────────────────
const cardId = ref<string>('')
const isTrial = ref(false)
const showAll = ref(false)
// ─── State ────────────────────────────────────────────────
const card = ref<CardType | null>(null)
const allCards = ref<CardType[]>([])
const loading = ref(false)
const buying = ref(false)
@@ -181,7 +226,12 @@ async function loadCard() {
loading.value = true
try {
const types = await get<CardType[]>('/membership/card-types')
const activeTypes = types.filter((c) => c.isActive)
const activeTypes = types.filter((c) => c.isActive).sort((a, b) => a.sortOrder - b.sortOrder)
if (showAll.value) {
allCards.value = activeTypes
return
}
if (isTrial.value) {
// Auto-find the trial card type
@@ -193,12 +243,30 @@ async function loadCard() {
card.value = activeTypes.find((c) => c.id === cardId.value) ?? null
}
} catch {
card.value = null
if (!showAll.value) {
card.value = null
}
allCards.value = []
} finally {
loading.value = false
}
}
// ─── Helpers ───────────────────────────────────────────────
function goToDetail(id: string) {
uni.navigateTo({ url: `/pages/card/detail?id=${id}` })
}
function thumbClass(card: CardType): string {
if (card.type === CardTypeCategory.TRIAL) return 'thumb--trial'
if (card.type === CardTypeCategory.DURATION) return 'thumb--duration'
return 'thumb--times'
}
function truncate(str: string, maxLen: number): string {
return str.length > maxLen ? str.slice(0, maxLen) + '…' : str
}
// ─── Buy flow ─────────────────────────────────────────────
async function handleBuy() {
if (buying.value || !card.value) return
@@ -286,6 +354,7 @@ onMounted(() => {
const options = (current as { options?: Record<string, string> }).options ?? {}
cardId.value = options.id ?? ''
isTrial.value = options.trial === '1'
showAll.value = options.showAll === '1'
loadCard()
})
</script>
@@ -629,4 +698,123 @@ onMounted(() => {
color: $primary-dark;
letter-spacing: 2rpx;
}
/* ── All cards list ────────────────────────────────────── */
.all-cards-list {
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.card-row {
display: flex;
align-items: center;
gap: 24rpx;
padding: 24rpx;
background: #fff;
border-radius: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
.card-thumb {
width: 200rpx;
height: 140rpx;
border-radius: 12rpx;
overflow: hidden;
flex-shrink: 0;
position: relative;
}
.thumb-fallback {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 12rpx;
}
.thumb--times .thumb-fallback {
background: linear-gradient(135deg, #3a3a3a, #555);
}
.thumb--duration .thumb-fallback {
background: linear-gradient(135deg, #6c3483, #9b59b6);
}
.thumb--trial .thumb-fallback {
background: linear-gradient(135deg, #5a7a8a, $primary-dark);
}
.thumb-name {
font-size: 22rpx;
font-weight: 600;
color: #ffffff;
text-align: center;
line-height: 1.3;
word-break: break-all;
}
.thumb-price {
font-size: 24rpx;
font-weight: 700;
color: #ffffff;
}
.card-info {
flex: 1;
min-width: 0;
}
.card-name {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #222;
margin-bottom: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-validity {
display: block;
font-size: 24rpx;
color: #999;
margin-bottom: 12rpx;
}
.price-row {
display: flex;
align-items: baseline;
gap: 8rpx;
}
.price-current {
font-size: 40rpx;
font-weight: 800;
color: #e53935;
}
.price-original {
font-size: 22rpx;
color: #bbb;
text-decoration: line-through;
}
/* ── Empty state ─────────────────────────────────────── */
.empty-state {
padding: 160rpx 40rpx;
display: flex;
align-items: center;
justify-content: center;
}
.empty-text {
font-size: 28rpx;
color: #bbb;
}
</style>

View File

@@ -56,8 +56,9 @@
v-for="booking in upcomingBookings"
:key="booking.id"
class="booking-card"
@tap="goDetail(booking)"
>
<view class="booking-stripe stripe--confirmed" />
<view class="booking-stripe" :class="stripeClass(booking.status)" />
<view class="booking-content">
<view class="booking-header">
<view class="booking-datetime">
@@ -66,19 +67,26 @@
{{ booking.timeSlot.startTime.slice(0, 5) }} - {{ booking.timeSlot.endTime.slice(0, 5) }}
</text>
</view>
<view class="status-badge badge--confirmed">
<text class="status-text">已预约</text>
<view class="status-badge" :class="statusBadgeClass(booking.status)">
<text class="status-text">{{ statusLabel(booking.status) }}</text>
</view>
</view>
<view class="booking-footer">
<view v-if="booking.status !== 'PENDING_CONFIRMATION'" class="booking-footer">
<view class="booking-meta">
<text class="meta-label">使用卡种</text>
<text class="meta-value">{{ booking.membership.cardType.name }}</text>
</view>
<view class="cancel-btn" @tap="handleCancel(booking)">
<view class="cancel-btn" @tap.stop="handleCancel(booking)">
<text class="cancel-text">取消预约</text>
</view>
</view>
<view v-else class="booking-footer">
<view class="booking-meta">
<text class="meta-label">使用卡种</text>
<text class="meta-value">{{ booking.membership.cardType.name }}</text>
</view>
<text class="pending-hint">等待老师确认</text>
</view>
</view>
</view>
</view>
@@ -121,6 +129,7 @@
v-for="booking in historyBookings"
:key="booking.id"
class="booking-card"
@tap="goDetail(booking)"
>
<view class="booking-stripe" :class="stripeClass(booking.status)" />
<view class="booking-content">
@@ -190,7 +199,9 @@ const today = computed(() => formatDate(new Date()))
const upcomingBookings = computed<BookingWithDetails[]>(() => {
return safeBookings()
.filter(
(b) => b.status === BookingStatus.CONFIRMED && toDateStr(b.timeSlot.date) >= today.value,
(b) =>
(b.status === BookingStatus.PENDING_CONFIRMATION || b.status === BookingStatus.CONFIRMED) &&
toDateStr(b.timeSlot.date) >= today.value,
)
.sort((a, b) => {
const dateA = toDateStr(a.timeSlot.date)
@@ -222,36 +233,39 @@ const historyBookings = computed<BookingWithDetails[]>(() => {
const upcomingCount = computed(() => upcomingBookings.value.length)
// ─── Helpers ──────────────────────────────────────────────
const STATUS_LABELS: Record<BookingStatus, string> = {
const STATUS_LABELS: Record<string, string> = {
[BookingStatus.PENDING_CONFIRMATION]: '待确认',
[BookingStatus.CONFIRMED]: '已预约',
[BookingStatus.CANCELLED]: '已取消',
[BookingStatus.COMPLETED]: '已完成',
[BookingStatus.NO_SHOW]: '未出席',
}
const STATUS_BADGE_CLASSES: Record<BookingStatus, string> = {
const STATUS_BADGE_CLASSES: Record<string, string> = {
[BookingStatus.PENDING_CONFIRMATION]: 'badge--pending',
[BookingStatus.CONFIRMED]: 'badge--confirmed',
[BookingStatus.CANCELLED]: 'badge--cancelled',
[BookingStatus.COMPLETED]: 'badge--completed',
[BookingStatus.NO_SHOW]: 'badge--noshow',
}
const STATUS_STRIPE_CLASSES: Record<BookingStatus, string> = {
const STATUS_STRIPE_CLASSES: Record<string, string> = {
[BookingStatus.PENDING_CONFIRMATION]: 'stripe--pending',
[BookingStatus.CONFIRMED]: 'stripe--confirmed',
[BookingStatus.CANCELLED]: 'stripe--cancelled',
[BookingStatus.COMPLETED]: 'stripe--completed',
[BookingStatus.NO_SHOW]: 'stripe--noshow',
}
function statusLabel(status: BookingStatus): string {
function statusLabel(status: string): string {
return STATUS_LABELS[status] ?? status
}
function statusBadgeClass(status: BookingStatus): string {
function statusBadgeClass(status: string): string {
return STATUS_BADGE_CLASSES[status] ?? ''
}
function stripeClass(status: BookingStatus): string {
function stripeClass(status: string): string {
return STATUS_STRIPE_CLASSES[status] ?? ''
}
@@ -297,6 +311,10 @@ function goBooking() {
uni.switchTab({ url: '/pages/booking/index' })
}
function goDetail(booking: BookingWithDetails) {
uni.navigateTo({ url: `/pages/booking/detail?id=${booking.id}` })
}
async function handleCancel(booking: BookingWithDetails) {
const dateLabel = formatDateDisplay(booking.timeSlot.date)
const timeLabel = booking.timeSlot.startTime.slice(0, 5)
@@ -535,6 +553,7 @@ onMounted(() => {
width: 8rpx;
flex-shrink: 0;
&.stripe--pending { background: #f59e0b; }
&.stripe--confirmed { background: $primary-dark; }
&.stripe--completed { background: #66bb6a; }
&.stripe--cancelled { background: #e0e0e0; }
@@ -580,6 +599,7 @@ onMounted(() => {
border-radius: 20rpx;
flex-shrink: 0;
&.badge--pending { background: rgba(245, 158, 11, 0.12); }
&.badge--confirmed { background: rgba(201, 168, 124, 0.12); }
&.badge--completed { background: rgba(102, 187, 106, 0.12); }
&.badge--cancelled { background: rgba(0, 0, 0, 0.04); }
@@ -590,6 +610,7 @@ onMounted(() => {
font-size: 22rpx;
font-weight: 600;
.badge--pending & { color: #f59e0b; }
.badge--confirmed & { color: $primary-dark; }
.badge--completed & { color: #66bb6a; }
.badge--cancelled & { color: #bbb; }
@@ -650,6 +671,12 @@ onMounted(() => {
font-weight: 500;
}
.pending-hint {
font-size: 24rpx;
color: #f59e0b;
font-weight: 500;
}
/* ── Spacer ──────────────────────────────────────────── */
.scroll-bottom-spacer {
height: 48rpx;

View File

@@ -3,6 +3,8 @@ import { ref } from 'vue'
import type {
TimeSlotWithBookingStatus,
BookingWithDetails,
BookingWithUser,
BookingStatusHistory,
CreateBookingDto,
} from '@mp-pilates/shared'
import { get, post, put } from '../utils/request'
@@ -68,6 +70,51 @@ export const useBookingStore = defineStore('booking', () => {
}
}
// ─── Admin methods ──────────────────────────────────────────────────────
async function fetchAllAdminBookings(
page = 1,
limit = 20,
status?: string,
): Promise<ServerPaginatedResult<BookingWithUser>> {
const params: Record<string, unknown> = { page, limit }
if (status) params.status = status
const paginated = await get<ServerPaginatedResult<BookingWithUser>>('/admin/bookings', params)
return paginated
}
async function confirmBooking(bookingId: string, remark?: string) {
const result = await put<BookingWithDetails>(`/booking/${bookingId}/confirm`, {
remark,
})
return result
}
async function completeBooking(bookingId: string, remark?: string) {
const result = await put<BookingWithDetails>(`/booking/${bookingId}/complete`, {
remark,
})
return result
}
async function markNoShow(bookingId: string, remark?: string) {
const result = await put<BookingWithDetails>(`/booking/${bookingId}/noshow`, {
remark,
})
return result
}
async function fetchBookingHistory(bookingId: string): Promise<BookingStatusHistory[]> {
const result = await get<BookingStatusHistory[]>(`/booking/${bookingId}/history`)
return result
}
async function fetchBookingById(bookingId: string) {
const result = await get<BookingWithDetails | BookingWithUser>(`/booking/${bookingId}`)
return result
}
return {
slots,
myBookings,
@@ -79,5 +126,11 @@ export const useBookingStore = defineStore('booking', () => {
cancelBooking,
fetchMyBookings,
fetchUpcomingBookings,
fetchAllAdminBookings,
confirmBooking,
completeBooking,
markNoShow,
fetchBookingHistory,
fetchBookingById,
}
})

View File

@@ -0,0 +1,81 @@
import { BookingStatus } from '@mp-pilates/shared'
/** 格式化日期显示:今天/明天/M月D日 星期X */
export function formatDateDisplay(dateStr: string): string {
const normalized = dateStr.slice(0, 10)
const [y, m, d] = normalized.split('-').map(Number)
const localDate = new Date(y, m - 1, d)
const today = new Date()
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
const tomorrow = new Date(today.getTime() + 86400000)
const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`
if (normalized === todayStr) return `今天 ${m}${d}`
if (normalized === tomorrowStr) return `明天 ${m}${d}`
const weekdayLabels = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return `${m}${d}${weekdayLabels[localDate.getDay()]}`
}
// ─── Booking status helpers ───────────────────────────────────────────────────
export const BOOKING_STATUS_LABELS: Record<string, string> = {
[BookingStatus.PENDING_CONFIRMATION]: '待确认',
[BookingStatus.CONFIRMED]: '已确认',
[BookingStatus.CANCELLED]: '已取消',
[BookingStatus.COMPLETED]: '已完成',
[BookingStatus.NO_SHOW]: '未出席',
}
export const BOOKING_STATUS_BADGE_CLASSES: Record<string, string> = {
[BookingStatus.PENDING_CONFIRMATION]: 'badge--pending',
[BookingStatus.CONFIRMED]: 'badge--confirmed',
[BookingStatus.CANCELLED]: 'badge--cancelled',
[BookingStatus.COMPLETED]: 'badge--completed',
[BookingStatus.NO_SHOW]: 'badge--noshow',
}
export const BOOKING_STATUS_STRIPE_CLASSES: Record<string, string> = {
[BookingStatus.PENDING_CONFIRMATION]: 'stripe--pending',
[BookingStatus.CONFIRMED]: 'stripe--confirmed',
[BookingStatus.CANCELLED]: 'stripe--cancelled',
[BookingStatus.COMPLETED]: 'stripe--completed',
[BookingStatus.NO_SHOW]: 'stripe--noshow',
}
export const BOOKING_STATUS_BANNER_CLASSES: Record<string, string> = {
[BookingStatus.PENDING_CONFIRMATION]: 'banner--pending',
[BookingStatus.CONFIRMED]: 'banner--confirmed',
[BookingStatus.CANCELLED]: 'banner--cancelled',
[BookingStatus.COMPLETED]: 'banner--completed',
[BookingStatus.NO_SHOW]: 'banner--noshow',
}
export function bookingStatusLabel(status: string): string {
return BOOKING_STATUS_LABELS[status] ?? status
}
export function bookingStatusBadgeClass(status: string): string {
return BOOKING_STATUS_BADGE_CLASSES[status] ?? ''
}
export function bookingStatusStripeClass(status: string): string {
return BOOKING_STATUS_STRIPE_CLASSES[status] ?? ''
}
export function bookingStatusBannerClass(status: string): string {
return BOOKING_STATUS_BANNER_CLASSES[status] ?? ''
}
export function bookingTimelineDotClass(status: string): string {
switch (status) {
case BookingStatus.PENDING_CONFIRMATION: return 'dot--pending'
case BookingStatus.CONFIRMED: return 'dot--confirmed'
case BookingStatus.COMPLETED: return 'dot--completed'
case BookingStatus.CANCELLED: return 'dot--cancelled'
case BookingStatus.NO_SHOW: return 'dot--noshow'
default: return ''
}
}