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:
richarjiang
2026-04-02 15:25:57 +08:00
parent 3a29aca0db
commit 7a06b5e336
12 changed files with 1809 additions and 1680 deletions

View File

@@ -5,214 +5,215 @@
<view class="filter-row">
<view
v-for="f in filters"
:key="f.key"
:key="f.value"
class="filter-chip"
:class="{ 'filter-chip--active': statusFilter === f.key }"
@tap="selectFilter(f.key)"
:class="{ 'filter-chip--active': activeFilter === f.value }"
@tap="selectFilter(f.value)"
>
<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>
<!-- Pull-to-refresh wrapper -->
<scroll-view
scroll-y
class="list-scroll"
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
>
<!-- Loading skeleton -->
<view v-if="loading && !orders.length" 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>
<!-- Empty -->
<view v-else-if="!loading && !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>
<!-- Order list -->
<view v-else class="order-list">
<view v-for="order in orders" :key="order.id" class="order-card">
<view class="order-header">
<text class="order-card-name">{{ order.cardType?.name ?? '-' }}</text>
<view class="order-status-badge" :class="statusBadgeClass(order.status)">
<text class="order-status-text">{{ statusLabel(order.status) }}</text>
</view>
</view>
<view class="order-body">
<view class="order-row">
<text class="order-row-label">用户</text>
<text class="order-row-value">{{ order.user?.nickname ?? '-' }}</text>
</view>
<view class="order-row">
<text class="order-row-label">手机</text>
<text class="order-row-value">{{ order.user?.phone ?? '未绑定' }}</text>
</view>
<view class="order-row">
<text class="order-row-label">金额</text>
<text class="order-row-value order-price">¥{{ formatPrice(order.amount) }}</text>
</view>
<view class="order-row">
<text class="order-row-label">时间</text>
<text class="order-row-value">{{ formatDate(order.createdAt) }}</text>
</view>
</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>
<!-- Load more -->
<view v-if="hasMore" class="load-more" @tap="loadMore">
<text class="load-more-text">{{ loading ? '加载中...' : '加载更多' }}</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>
<!-- Bottom spacer -->
<view style="height: 40rpx;" />
</scroll-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'
import { ref, onMounted } from 'vue'
import { useAdminStore } from '../../stores/admin'
import { formatPrice, formatDate } from '../../utils/format'
import { OrderStatus } from '@mp-pilates/shared'
import type { OrderWithDetails } from '@mp-pilates/shared'
const adminStore = useAdminStore()
const filters = [
{ key: '', label: '全部' },
{ key: 'PAID', label: '已支付' },
{ key: 'PENDING', label: '待支付' },
{ key: 'REFUNDED', label: '已退款' },
{ key: 'CANCELLED', label: '已取消' },
{ label: '全部', value: '' },
{ label: '已支付', value: OrderStatus.PAID },
{ label: '待支付', value: OrderStatus.PENDING },
{ label: '已退款', value: OrderStatus.REFUNDED },
]
const statusFilter = ref('')
const activeFilter = ref('')
const orders = ref<OrderWithDetails[]>([])
const loading = ref(false)
const currentPage = ref(1)
const total = ref(0)
const limit = 10
const refreshing = ref(false)
const page = ref(1)
const hasMore = ref(false)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit)))
const LIMIT = 20
async function fetchOrders() {
function statusLabel(s: string) {
const map: Record<string, string> = {
[OrderStatus.PAID]: '已支付',
[OrderStatus.PENDING]: '待支付',
[OrderStatus.REFUNDED]: '已退款',
}
return map[s] ?? s
}
function statusBadgeClass(s: string) {
if (s === OrderStatus.PAID) return 'badge--paid'
if (s === OrderStatus.PENDING) return 'badge--pending'
if (s === OrderStatus.REFUNDED) return 'badge--refunded'
return 'badge--default'
}
async function loadOrders(reset = false) {
if (loading.value) return
if (reset) {
page.value = 1
orders.value = []
}
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
const result = await adminStore.fetchAdminOrders({
page: page.value,
limit: LIMIT,
status: activeFilter.value || undefined,
})
if (reset) {
orders.value = [...result.items]
} else {
orders.value.push(...result.items)
}
hasMore.value = orders.value.length < result.total
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
orders.value = []
} finally {
loading.value = false
refreshing.value = false
}
}
function selectFilter(key: string) {
statusFilter.value = key
currentPage.value = 1
fetchOrders()
function selectFilter(value: string) {
activeFilter.value = value
loadOrders(true)
}
function goPage(p: number) {
if (p < 1 || p > totalPages.value) return
currentPage.value = p
fetchOrders()
async function onRefresh() {
refreshing.value = true
await loadOrders(true)
}
function statusLabel(status: string): string {
const map: Record<string, string> = {
PAID: '已支付',
PENDING: '待支付',
REFUNDED: '已退款',
CANCELLED: '已取消',
}
return map[status] ?? status
function loadMore() {
if (!hasMore.value || loading.value) return
page.value++
loadOrders(false)
}
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)
onMounted(() => loadOrders(true))
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f3f0;
padding-bottom: 40rpx;
}
/* ── Filter scroll ───────────────────────── */
.filter-scroll {
flex-shrink: 0;
background: #ffffff;
border-bottom: 1rpx solid #f0f0f0;
border-bottom: 1rpx solid #eee;
}
.filter-row {
display: flex;
flex-direction: row;
gap: 12rpx;
align-items: center;
padding: 16rpx 24rpx;
width: max-content;
gap: 16rpx;
white-space: nowrap;
}
.filter-chip {
padding: 12rpx 28rpx;
border-radius: 32rpx;
display: inline-flex;
align-items: center;
height: 60rpx;
padding: 0 28rpx;
border-radius: 30rpx;
background: #f0f0f0;
&--active {
background: #1a1a2e;
}
flex-shrink: 0;
}
.filter-chip-text {
font-size: 26rpx;
color: #555;
.filter-chip--active {
background: #1a1a2e;
}
.filter-chip--active & {
color: #c9a87c;
font-weight: 600;
}
.filter-chip-text { font-size: 26rpx; color: #888; }
.filter-chip--active .filter-chip-text { color: #c9a87c; font-weight: 600; }
/* ── List scroll ─────────────────────────── */
.list-scroll {
flex: 1;
overflow: hidden;
}
/* ── Skeleton ────────────────────────────── */
.skeleton-list {
padding: 16rpx 24rpx 0;
}
.skeleton-list { padding: 24rpx; }
.skeleton-item {
height: 180rpx;
border-radius: 12rpx;
border-radius: 16rpx;
margin-bottom: 16rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 400% 100%;
@@ -229,7 +230,7 @@ onMounted(fetchOrders)
display: flex;
flex-direction: column;
align-items: center;
padding: 100rpx 0;
padding: 120rpx 0;
gap: 20rpx;
}
@@ -237,113 +238,57 @@ onMounted(fetchOrders)
.empty-text { font-size: 28rpx; color: #bbb; }
/* ── Order list ──────────────────────────── */
.order-list {
padding: 16rpx 24rpx 0;
}
.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);
overflow: hidden;
margin-bottom: 20rpx;
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;
padding: 20rpx 24rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.order-card-name {
font-size: 30rpx;
font-weight: 700;
color: #1a1a2e;
flex: 1;
}
.order-card-name { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
.status-badge {
.order-status-badge {
border-radius: 20rpx;
padding: 6rpx 16rpx;
padding: 6rpx 20rpx;
}
.status-badge-text {
font-size: 22rpx;
font-weight: 600;
}
.badge--paid { background: rgba(39,174,96,0.1); }
.badge--paid .order-status-text { font-size: 22rpx; color: #27ae60; }
.badge--pending { background: rgba(230,126,34,0.1); }
.badge--pending .order-status-text { font-size: 22rpx; color: #e67e22; }
.badge--refunded { background: rgba(0,0,0,0.06); }
.badge--refunded .order-status-text { font-size: 22rpx; color: #999; }
.badge--default .order-status-text { font-size: 22rpx; color: #888; }
.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-body { padding: 16rpx 24rpx; }
.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 {
.order-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
padding: 10rpx 0;
}
.order-amount {
font-size: 36rpx;
font-weight: 800;
color: #c9a87c;
.order-row-label { font-size: 24rpx; color: #999; }
.order-row-value { font-size: 26rpx; color: #333; }
.order-price { font-size: 28rpx; font-weight: 700; color: #c9a87c; }
/* ── Load more ───────────────────────────── */
.load-more {
text-align: center;
padding: 32rpx;
}
.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;
}
.load-more-text { font-size: 26rpx; color: #c9a87c; }
</style>