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:
richarjiang
2026-04-02 14:35:17 +08:00
parent 554fc30954
commit 3a29aca0db
26 changed files with 7766 additions and 74 deletions

View File

@@ -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>