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