feat(app): implement home, booking, and profile pages
Home: brand banner, studio info swiper, smart quick entries based on membership status, upcoming bookings, card shop horizontal scroll Booking: 7-day date selector, time period filter, slot cards with status, booking confirm popup with membership picker Profile: user card with login, training stats, menu with admin entry 8 reusable components: BrandBanner, StudioInfo, QuickEntry, UpcomingBooking, CardShop, DateSelector, SlotCard, BookingConfirmPopup, TimePeriodFilter, UserCard, TrainingStats, ProfileMenu
This commit is contained in:
@@ -1,15 +1,349 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="placeholder">
|
||||
<text>订单管理 - 待实现</text>
|
||||
<!-- Status filter tabs -->
|
||||
<scroll-view scroll-x class="filter-scroll" :show-scrollbar="false">
|
||||
<view class="filter-row">
|
||||
<view
|
||||
v-for="f in filters"
|
||||
:key="f.key"
|
||||
class="filter-chip"
|
||||
:class="{ 'filter-chip--active': statusFilter === f.key }"
|
||||
@tap="selectFilter(f.key)"
|
||||
>
|
||||
<text class="filter-chip-text">{{ f.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- Loading skeleton -->
|
||||
<view v-if="loading" class="skeleton-list">
|
||||
<view v-for="i in 5" :key="i" class="skeleton-item" />
|
||||
</view>
|
||||
|
||||
<!-- Empty -->
|
||||
<view v-else-if="!orders.length" class="empty-state">
|
||||
<text class="empty-icon">📋</text>
|
||||
<text class="empty-text">暂无订单</text>
|
||||
</view>
|
||||
|
||||
<!-- Order list -->
|
||||
<view v-else class="order-list">
|
||||
<view
|
||||
v-for="order in orders"
|
||||
:key="order.id"
|
||||
class="order-card"
|
||||
>
|
||||
<!-- Header: card name + status badge -->
|
||||
<view class="order-header">
|
||||
<text class="order-card-name">{{ order.cardType?.name ?? '未知卡种' }}</text>
|
||||
<view class="status-badge" :class="statusBadgeClass(order.status)">
|
||||
<text class="status-badge-text">{{ statusLabel(order.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- User info -->
|
||||
<view v-if="order.user" class="order-user">
|
||||
<text class="order-user-icon">👤</text>
|
||||
<text class="order-user-text">
|
||||
{{ order.user.nickname }}
|
||||
<text v-if="order.user.phone"> · {{ maskPhone(order.user.phone) }}</text>
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- Amount + date row -->
|
||||
<view class="order-footer">
|
||||
<text class="order-amount">¥{{ formatPrice(order.amount) }}</text>
|
||||
<text class="order-date">{{ formatOrderDate(order.createdAt) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Order id -->
|
||||
<text class="order-id">订单号:{{ order.id.slice(0, 16) }}...</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Pagination -->
|
||||
<view v-if="totalPages > 1" class="pagination">
|
||||
<view
|
||||
class="page-btn"
|
||||
:class="{ 'page-btn--disabled': currentPage === 1 }"
|
||||
@tap="goPage(currentPage - 1)"
|
||||
>
|
||||
<text class="page-btn-text">‹ 上一页</text>
|
||||
</view>
|
||||
<text class="page-info">{{ currentPage }} / {{ totalPages }}</text>
|
||||
<view
|
||||
class="page-btn"
|
||||
:class="{ 'page-btn--disabled': currentPage === totalPages }"
|
||||
@tap="goPage(currentPage + 1)"
|
||||
>
|
||||
<text class="page-btn-text">下一页 ›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { get } from '../../utils/request'
|
||||
import { formatPrice } from '../../utils/format'
|
||||
import type { OrderWithDetails, PaginatedData } from '@mp-pilates/shared'
|
||||
|
||||
const filters = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'PAID', label: '已支付' },
|
||||
{ key: 'PENDING', label: '待支付' },
|
||||
{ key: 'REFUNDED', label: '已退款' },
|
||||
{ key: 'CANCELLED', label: '已取消' },
|
||||
]
|
||||
|
||||
const statusFilter = ref('')
|
||||
const orders = ref<OrderWithDetails[]>([])
|
||||
const loading = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const total = ref(0)
|
||||
const limit = 10
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit)))
|
||||
|
||||
async function fetchOrders() {
|
||||
loading.value = true
|
||||
try {
|
||||
const statusParam = statusFilter.value ? `&status=${statusFilter.value}` : ''
|
||||
const data = await get<PaginatedData<OrderWithDetails>>(
|
||||
`/admin/orders?page=${currentPage.value}&limit=${limit}${statusParam}`,
|
||||
)
|
||||
orders.value = [...(data.items ?? [])]
|
||||
total.value = data.total ?? 0
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
orders.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectFilter(key: string) {
|
||||
statusFilter.value = key
|
||||
currentPage.value = 1
|
||||
fetchOrders()
|
||||
}
|
||||
|
||||
function goPage(p: number) {
|
||||
if (p < 1 || p > totalPages.value) return
|
||||
currentPage.value = p
|
||||
fetchOrders()
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PAID: '已支付',
|
||||
PENDING: '待支付',
|
||||
REFUNDED: '已退款',
|
||||
CANCELLED: '已取消',
|
||||
}
|
||||
return map[status] ?? status
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: string): string {
|
||||
if (status === 'PAID') return 'badge--paid'
|
||||
if (status === 'PENDING') return 'badge--pending'
|
||||
if (status === 'REFUNDED') return 'badge--refunded'
|
||||
if (status === 'CANCELLED') return 'badge--cancelled'
|
||||
return ''
|
||||
}
|
||||
|
||||
function maskPhone(phone: string): string {
|
||||
return phone.slice(0, 3) + '****' + phone.slice(-4)
|
||||
}
|
||||
|
||||
function formatOrderDate(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日 ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
onMounted(fetchOrders)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page { min-height: 100vh; background: #f5f5f5; }
|
||||
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #f5f3f0;
|
||||
padding-bottom: 40rpx;
|
||||
}
|
||||
|
||||
/* ── Filter scroll ───────────────────────── */
|
||||
.filter-scroll {
|
||||
background: #ffffff;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12rpx;
|
||||
padding: 16rpx 24rpx;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
padding: 12rpx 28rpx;
|
||||
border-radius: 32rpx;
|
||||
background: #f0f0f0;
|
||||
|
||||
&--active {
|
||||
background: #1a1a2e;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-chip-text {
|
||||
font-size: 26rpx;
|
||||
color: #555;
|
||||
|
||||
.filter-chip--active & {
|
||||
color: #c9a87c;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Skeleton ────────────────────────────── */
|
||||
.skeleton-list {
|
||||
padding: 16rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
height: 180rpx;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 16rpx;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 100% 0; }
|
||||
100% { background-position: -100% 0; }
|
||||
}
|
||||
|
||||
/* ── Empty ───────────────────────────────── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 100rpx 0;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.empty-icon { font-size: 80rpx; }
|
||||
.empty-text { font-size: 28rpx; color: #bbb; }
|
||||
|
||||
/* ── Order list ──────────────────────────── */
|
||||
.order-list {
|
||||
padding: 16rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.order-card {
|
||||
background: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
padding: 28rpx;
|
||||
margin-bottom: 16rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.order-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.order-card-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
border-radius: 20rpx;
|
||||
padding: 6rpx 16rpx;
|
||||
}
|
||||
|
||||
.status-badge-text {
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge--paid { background: #d4edda; .status-badge-text { color: #155724; } }
|
||||
.badge--pending { background: #fff3cd; .status-badge-text { color: #856404; } }
|
||||
.badge--refunded { background: #cce5ff; .status-badge-text { color: #004085; } }
|
||||
.badge--cancelled { background: #f8d7da; .status-badge-text { color: #721c24; } }
|
||||
|
||||
.order-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.order-user-icon { font-size: 24rpx; }
|
||||
|
||||
.order-user-text {
|
||||
font-size: 24rpx;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.order-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.order-amount {
|
||||
font-size: 36rpx;
|
||||
font-weight: 800;
|
||||
color: #c9a87c;
|
||||
}
|
||||
|
||||
.order-date {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.order-id {
|
||||
font-size: 20rpx;
|
||||
color: #bbb;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Pagination ──────────────────────────── */
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 32rpx;
|
||||
padding: 32rpx 0;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 12rpx 32rpx;
|
||||
background: #ffffff;
|
||||
border-radius: 32rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.page-btn-text {
|
||||
font-size: 26rpx;
|
||||
color: #1a1a2e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 26rpx;
|
||||
color: #555;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user