feat(app): implement all sub-pages and admin management pages
Sub-pages: card purchase with WeChat Pay flow, my memberships with progress bars, my bookings with tabs, personal info editor Admin: management center grid, week template CRUD, slot adjustment, member management with search, order list with filters, card type CRUD with form modal, studio settings editor Admin Pinia store for all admin API calls
This commit is contained in:
@@ -1,177 +1,168 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- Search / filter bar -->
|
||||
<!-- Search bar -->
|
||||
<view class="filter-bar">
|
||||
<input
|
||||
class="search-input"
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索昵称或手机号"
|
||||
placeholder-style="color:#bbb"
|
||||
@input="onSearch"
|
||||
@confirm="onSearch"
|
||||
confirm-type="search"
|
||||
/>
|
||||
<view class="search-btn" @tap="onSearch">
|
||||
<text class="search-btn-text">搜索</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Stats summary -->
|
||||
<!-- Stats row -->
|
||||
<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 class="stat-item">
|
||||
<text class="stat-value">{{ total }}</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 v-if="loading && !members.length" class="skeleton-list">
|
||||
<view v-for="i in 5" :key="i" class="skeleton-item" />
|
||||
</view>
|
||||
|
||||
<!-- Empty -->
|
||||
<view v-else-if="!filteredMembers.length" class="empty-state">
|
||||
<view v-else-if="!loading && !members.length" class="empty-state">
|
||||
<text class="empty-icon">👥</text>
|
||||
<text class="empty-text">{{ searchQuery ? '未找到匹配会员' : '暂无预约记录' }}</text>
|
||||
<text class="empty-text">暂无会员数据</text>
|
||||
</view>
|
||||
|
||||
<!-- Member list -->
|
||||
<view v-else class="member-list">
|
||||
<view
|
||||
v-for="member in filteredMembers"
|
||||
:key="member.userId"
|
||||
class="member-card"
|
||||
v-for="m in members"
|
||||
:key="m.userId"
|
||||
class="member-row"
|
||||
@tap="openDetail(m)"
|
||||
>
|
||||
<view class="member-avatar">
|
||||
<text class="member-avatar-text">{{ member.nickname.slice(0, 1).toUpperCase() }}</text>
|
||||
<image v-if="m.avatarUrl" class="avatar-img" :src="m.avatarUrl" mode="aspectFill" />
|
||||
<view v-else class="avatar-placeholder">
|
||||
<text class="avatar-text">{{ (m.nickname || '?').slice(0, 1) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="member-info">
|
||||
<text class="member-name">{{ member.nickname }}</text>
|
||||
<text v-if="member.phone" class="member-phone">{{ maskPhone(member.phone) }}</text>
|
||||
<text class="member-name">{{ m.nickname || '未知用户' }}</text>
|
||||
<text class="member-phone">{{ m.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>
|
||||
<text class="member-stat-value">{{ m.totalBookings }}</text>
|
||||
<text class="member-stat-label">次预约</text>
|
||||
</view>
|
||||
<text class="member-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Load more -->
|
||||
<view v-if="hasMore && !loading" class="load-more" @tap="loadMore">
|
||||
<text class="load-more-text">加载更多</text>
|
||||
<view v-if="hasMore" class="load-more" @tap="loadMore">
|
||||
<text class="load-more-text">{{ loading ? '加载中...' : '加载更多' }}</text>
|
||||
</view>
|
||||
<view v-if="loadingMore" class="load-more">
|
||||
<text class="load-more-text">加载中...</text>
|
||||
|
||||
<!-- Detail modal -->
|
||||
<view v-if="showDetail && detailMember" class="modal-mask" @tap.self="showDetail = false">
|
||||
<view class="modal">
|
||||
<view class="detail-header">
|
||||
<view class="detail-avatar">
|
||||
<image v-if="detailMember.avatarUrl" class="avatar-img" :src="detailMember.avatarUrl" mode="aspectFill" />
|
||||
<view v-else class="avatar-placeholder avatar-placeholder--lg">
|
||||
<text class="avatar-text avatar-text--lg">{{ (detailMember.nickname || '?').slice(0, 1) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="detail-name">{{ detailMember.nickname || '未知用户' }}</text>
|
||||
<text class="detail-phone">{{ detailMember.phone || '未绑定手机' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="detail-stats">
|
||||
<view class="detail-stat">
|
||||
<text class="detail-stat-value">{{ detailMember.totalBookings }}</text>
|
||||
<text class="detail-stat-label">总预约</text>
|
||||
</view>
|
||||
<view class="detail-stat">
|
||||
<text class="detail-stat-value">{{ detailMember.completedBookings }}</text>
|
||||
<text class="detail-stat-label">已完成</text>
|
||||
</view>
|
||||
<view class="detail-stat">
|
||||
<text class="detail-stat-value">{{ detailMember.cancelledBookings }}</text>
|
||||
<text class="detail-stat-label">已取消</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="modal-close" @tap="showDetail = false">
|
||||
<text class="modal-close-text">关闭</text>
|
||||
</view>
|
||||
</view>
|
||||
</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'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAdminStore } from '../../stores/admin'
|
||||
import type { MemberSummary } from '../../stores/admin'
|
||||
|
||||
interface MemberSummary {
|
||||
userId: string
|
||||
nickname: string
|
||||
phone?: string
|
||||
totalBookings: number
|
||||
confirmedBookings: number
|
||||
}
|
||||
const adminStore = useAdminStore()
|
||||
|
||||
const allBookings = ref<BookingWithDetails[]>([])
|
||||
const page = ref(1)
|
||||
const limit = 50
|
||||
const hasMore = ref(true)
|
||||
const members = ref<MemberSummary[]>([])
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const hasMore = ref(false)
|
||||
const showDetail = ref(false)
|
||||
const detailMember = ref<MemberSummary | null>(null)
|
||||
|
||||
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 LIMIT = 20
|
||||
|
||||
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 = []
|
||||
async function loadMembers(reset = false) {
|
||||
if (loading.value) return
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
hasMore.value = true
|
||||
members.value = []
|
||||
}
|
||||
|
||||
loading.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++
|
||||
const result = await adminStore.fetchMembers({
|
||||
page: page.value,
|
||||
limit: LIMIT,
|
||||
search: searchQuery.value.trim() || undefined,
|
||||
})
|
||||
if (reset) {
|
||||
members.value = [...result.items]
|
||||
} else {
|
||||
members.value.push(...result.items)
|
||||
}
|
||||
total.value = result.total
|
||||
hasMore.value = members.value.length < result.total
|
||||
} 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
|
||||
loadMembers(true)
|
||||
}
|
||||
|
||||
function maskPhone(phone: string): string {
|
||||
return phone.slice(0, 3) + '****' + phone.slice(-4)
|
||||
function loadMore() {
|
||||
if (!hasMore.value || loading.value) return
|
||||
page.value++
|
||||
loadMembers(false)
|
||||
}
|
||||
|
||||
onMounted(() => fetchBookings())
|
||||
function openDetail(m: MemberSummary) {
|
||||
detailMember.value = m
|
||||
showDetail.value = true
|
||||
}
|
||||
|
||||
onMounted(() => loadMembers(true))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -183,62 +174,49 @@ onMounted(() => fetchBookings())
|
||||
|
||||
/* ── Filter bar ──────────────────────────── */
|
||||
.filter-bar {
|
||||
padding: 20rpx 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 24rpx;
|
||||
background: #ffffff;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
border-bottom: 1rpx solid #eee;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
background: #f5f3f0;
|
||||
border-radius: 32rpx;
|
||||
padding: 16rpx 28rpx;
|
||||
border-radius: 36rpx;
|
||||
padding: 0 28rpx;
|
||||
font-size: 26rpx;
|
||||
color: #222;
|
||||
width: 100%;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
background: #1a1a2e;
|
||||
border-radius: 36rpx;
|
||||
padding: 16rpx 32rpx;
|
||||
}
|
||||
|
||||
.search-btn-text { font-size: 26rpx; font-weight: 600; color: #c9a87c; }
|
||||
|
||||
/* ── Stats row ───────────────────────────── */
|
||||
.stats-row {
|
||||
display: flex;
|
||||
background: #ffffff;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
padding: 24rpx 28rpx 16rpx;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.stat-item { display: flex; align-items: baseline; gap: 8rpx; }
|
||||
.stat-value { font-size: 36rpx; font-weight: 800; color: #c9a87c; }
|
||||
.stat-label { font-size: 24rpx; color: #999; }
|
||||
|
||||
/* ── Skeleton ────────────────────────────── */
|
||||
.skeleton-list {
|
||||
padding: 16rpx 24rpx 0;
|
||||
}
|
||||
.skeleton-list { padding: 0 24rpx; }
|
||||
|
||||
.skeleton-item {
|
||||
height: 120rpx;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 12rpx;
|
||||
height: 100rpx;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 16rpx;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
@@ -262,93 +240,127 @@ onMounted(() => fetchBookings())
|
||||
.empty-text { font-size: 28rpx; color: #bbb; }
|
||||
|
||||
/* ── Member list ─────────────────────────── */
|
||||
.member-list {
|
||||
padding: 16rpx 24rpx 0;
|
||||
}
|
||||
.member-list { padding: 0 24rpx; padding-top: 8rpx; }
|
||||
|
||||
.member-card {
|
||||
.member-row {
|
||||
background: #ffffff;
|
||||
border-radius: 12rpx;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 12rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.06);
|
||||
margin-bottom: 16rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #1a1a2e, #2d2d5e);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-avatar-text {
|
||||
.avatar-img { width: 100%; height: 100%; }
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background: #1a1a2e;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-placeholder--lg {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #c9a87c;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
.avatar-text--lg { font-size: 48rpx; }
|
||||
|
||||
.member-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
.member-info { flex: 1; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.member-name { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
|
||||
.member-phone { font-size: 22rpx; color: #999; }
|
||||
|
||||
.member-phone {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
.member-stats { display: flex; flex-direction: column; align-items: flex-end; gap: 4rpx; }
|
||||
.member-stat-value { font-size: 32rpx; font-weight: 700; color: #c9a87c; }
|
||||
.member-stat-label { font-size: 20rpx; color: #bbb; }
|
||||
|
||||
.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;
|
||||
}
|
||||
.member-arrow { font-size: 36rpx; color: #ccc; }
|
||||
|
||||
/* ── Load more ───────────────────────────── */
|
||||
.load-more {
|
||||
text-align: center;
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.load-more-text { font-size: 26rpx; color: #c9a87c; }
|
||||
|
||||
/* ── Detail modal ────────────────────────── */
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
background: #ffffff;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
padding: 48rpx 32rpx 60rpx;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.detail-avatar {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-name { font-size: 32rpx; font-weight: 700; color: #1a1a2e; }
|
||||
.detail-phone { font-size: 26rpx; color: #888; }
|
||||
|
||||
.detail-stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
background: #f5f3f0;
|
||||
border-radius: 16rpx;
|
||||
padding: 28rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.detail-stat { display: flex; flex-direction: column; align-items: center; gap: 8rpx; }
|
||||
.detail-stat-value { font-size: 40rpx; font-weight: 800; color: #c9a87c; }
|
||||
.detail-stat-label { font-size: 22rpx; color: #999; }
|
||||
|
||||
.modal-close {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
background: #f0f0f0;
|
||||
border-radius: 44rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 28rpx 0;
|
||||
}
|
||||
|
||||
.load-more-text {
|
||||
font-size: 26rpx;
|
||||
color: #c9a87c;
|
||||
}
|
||||
.modal-close-text { font-size: 28rpx; color: #555; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user