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,354 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="placeholder">
|
||||
<text>会员管理 - 待实现</text>
|
||||
<!-- Search / filter bar -->
|
||||
<view class="filter-bar">
|
||||
<input
|
||||
class="search-input"
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索昵称或手机号"
|
||||
placeholder-style="color:#bbb"
|
||||
@input="onSearch"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- Stats summary -->
|
||||
<view class="stats-row">
|
||||
<view class="stat-cell">
|
||||
<text class="stat-value">{{ totalMembers }}</text>
|
||||
<text class="stat-label">活跃会员</text>
|
||||
</view>
|
||||
<view class="stat-cell">
|
||||
<text class="stat-value">{{ totalBookings }}</text>
|
||||
<text class="stat-label">总预约次数</text>
|
||||
</view>
|
||||
<view class="stat-cell">
|
||||
<text class="stat-value">{{ confirmedBookings }}</text>
|
||||
<text class="stat-label">待上课</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Loading skeleton -->
|
||||
<view v-if="loading" class="skeleton-list">
|
||||
<view v-for="i in 6" :key="i" class="skeleton-item" />
|
||||
</view>
|
||||
|
||||
<!-- Empty -->
|
||||
<view v-else-if="!filteredMembers.length" class="empty-state">
|
||||
<text class="empty-icon">👥</text>
|
||||
<text class="empty-text">{{ searchQuery ? '未找到匹配会员' : '暂无预约记录' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Member list -->
|
||||
<view v-else class="member-list">
|
||||
<view
|
||||
v-for="member in filteredMembers"
|
||||
:key="member.userId"
|
||||
class="member-card"
|
||||
>
|
||||
<view class="member-avatar">
|
||||
<text class="member-avatar-text">{{ member.nickname.slice(0, 1).toUpperCase() }}</text>
|
||||
</view>
|
||||
<view class="member-info">
|
||||
<text class="member-name">{{ member.nickname }}</text>
|
||||
<text v-if="member.phone" class="member-phone">{{ maskPhone(member.phone) }}</text>
|
||||
</view>
|
||||
<view class="member-stats">
|
||||
<view class="member-stat">
|
||||
<text class="member-stat-value">{{ member.totalBookings }}</text>
|
||||
<text class="member-stat-label">次预约</text>
|
||||
</view>
|
||||
<view class="member-stat">
|
||||
<text class="member-stat-value confirmed-count">{{ member.confirmedBookings }}</text>
|
||||
<text class="member-stat-label">待上课</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Load more -->
|
||||
<view v-if="hasMore && !loading" class="load-more" @tap="loadMore">
|
||||
<text class="load-more-text">加载更多</text>
|
||||
</view>
|
||||
<view v-if="loadingMore" class="load-more">
|
||||
<text class="load-more-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { get } from '../../utils/request'
|
||||
import { BookingStatus } from '@mp-pilates/shared'
|
||||
import type { BookingWithDetails, PaginatedData } from '@mp-pilates/shared'
|
||||
|
||||
interface MemberSummary {
|
||||
userId: string
|
||||
nickname: string
|
||||
phone?: string
|
||||
totalBookings: number
|
||||
confirmedBookings: number
|
||||
}
|
||||
|
||||
const allBookings = ref<BookingWithDetails[]>([])
|
||||
const page = ref(1)
|
||||
const limit = 50
|
||||
const hasMore = ref(true)
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const searchQuery = ref('')
|
||||
|
||||
const members = computed<MemberSummary[]>(() => {
|
||||
const map = new Map<string, MemberSummary>()
|
||||
for (const b of allBookings.value) {
|
||||
const userId = b.userId
|
||||
if (!userId) continue
|
||||
if (!map.has(userId)) {
|
||||
map.set(userId, {
|
||||
userId,
|
||||
nickname: userId.slice(0, 8),
|
||||
totalBookings: 0,
|
||||
confirmedBookings: 0,
|
||||
})
|
||||
}
|
||||
const m = map.get(userId)!
|
||||
m.totalBookings++
|
||||
if (b.status === BookingStatus.CONFIRMED) {
|
||||
m.confirmedBookings++
|
||||
}
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => b.totalBookings - a.totalBookings)
|
||||
})
|
||||
|
||||
const filteredMembers = computed(() => {
|
||||
if (!searchQuery.value.trim()) return members.value
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
return members.value.filter(
|
||||
(m) =>
|
||||
m.nickname.toLowerCase().includes(q) ||
|
||||
(m.phone && m.phone.includes(q)),
|
||||
)
|
||||
})
|
||||
|
||||
const totalMembers = computed(() => members.value.length)
|
||||
const totalBookings = computed(() => members.value.reduce((s, m) => s + m.totalBookings, 0))
|
||||
const confirmedBookings = computed(() => members.value.reduce((s, m) => s + m.confirmedBookings, 0))
|
||||
|
||||
async function fetchBookings(isLoadMore = false) {
|
||||
if (isLoadMore) {
|
||||
loadingMore.value = true
|
||||
} else {
|
||||
loading.value = true
|
||||
allBookings.value = []
|
||||
page.value = 1
|
||||
hasMore.value = true
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await get<PaginatedData<BookingWithDetails>>(
|
||||
`/admin/bookings?page=${page.value}&limit=${limit}`,
|
||||
)
|
||||
allBookings.value = [...allBookings.value, ...(data.items ?? [])]
|
||||
hasMore.value = allBookings.value.length < data.total
|
||||
page.value++
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
await fetchBookings(true)
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
// Reactive filtering via computed — no action needed
|
||||
}
|
||||
|
||||
function maskPhone(phone: string): string {
|
||||
return phone.slice(0, 3) + '****' + phone.slice(-4)
|
||||
}
|
||||
|
||||
onMounted(() => fetchBookings())
|
||||
</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 bar ──────────────────────────── */
|
||||
.filter-bar {
|
||||
padding: 20rpx 24rpx;
|
||||
background: #ffffff;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background: #f5f3f0;
|
||||
border-radius: 32rpx;
|
||||
padding: 16rpx 28rpx;
|
||||
font-size: 26rpx;
|
||||
color: #222;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Stats row ───────────────────────────── */
|
||||
.stats-row {
|
||||
display: flex;
|
||||
background: #ffffff;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.stat-cell {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 28rpx 0;
|
||||
border-right: 1rpx solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
color: #1a1a2e;
|
||||
line-height: 1;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 20rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* ── Skeleton ────────────────────────────── */
|
||||
.skeleton-list {
|
||||
padding: 16rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
height: 120rpx;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 12rpx;
|
||||
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; }
|
||||
|
||||
/* ── Member list ─────────────────────────── */
|
||||
.member-list {
|
||||
padding: 16rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.member-card {
|
||||
background: #ffffff;
|
||||
border-radius: 12rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 12rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #1a1a2e, #2d2d5e);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-avatar-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #c9a87c;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.member-phone {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.member-stats {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.member-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.member-stat-value {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.confirmed-count {
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.member-stat-label {
|
||||
font-size: 20rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* ── Load more ───────────────────────────── */
|
||||
.load-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 28rpx 0;
|
||||
}
|
||||
|
||||
.load-more-text {
|
||||
font-size: 26rpx;
|
||||
color: #c9a87c;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user