786 lines
22 KiB
Vue
786 lines
22 KiB
Vue
<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>
|