Files
mp-pilates/packages/app/src/pages/admin/orders.vue
richarjiang 7a06b5e336 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
2026-04-02 15:25:57 +08:00

295 lines
8.1 KiB
Vue

<template>
<view class="page">
<!-- 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.value"
class="filter-chip"
:class="{ 'filter-chip--active': activeFilter === f.value }"
@tap="selectFilter(f.value)"
>
<text class="filter-chip-text">{{ f.label }}</text>
</view>
</view>
</scroll-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="!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">
<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>
</view>
<!-- Load more -->
<view v-if="hasMore" class="load-more" @tap="loadMore">
<text class="load-more-text">{{ loading ? '加载中...' : '加载更多' }}</text>
</view>
<!-- Bottom spacer -->
<view style="height: 40rpx;" />
</scroll-view>
</view>
</template>
<script setup lang="ts">
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 = [
{ label: '全部', value: '' },
{ label: '已支付', value: OrderStatus.PAID },
{ label: '待支付', value: OrderStatus.PENDING },
{ label: '已退款', value: OrderStatus.REFUNDED },
]
const activeFilter = ref('')
const orders = ref<OrderWithDetails[]>([])
const loading = ref(false)
const refreshing = ref(false)
const page = ref(1)
const hasMore = ref(false)
const LIMIT = 20
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 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' })
} finally {
loading.value = false
refreshing.value = false
}
}
function selectFilter(value: string) {
activeFilter.value = value
loadOrders(true)
}
async function onRefresh() {
refreshing.value = true
await loadOrders(true)
}
function loadMore() {
if (!hasMore.value || loading.value) return
page.value++
loadOrders(false)
}
onMounted(() => loadOrders(true))
</script>
<style lang="scss" scoped>
.page {
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f3f0;
}
/* ── Filter scroll ───────────────────────── */
.filter-scroll {
flex-shrink: 0;
background: #ffffff;
border-bottom: 1rpx solid #eee;
}
.filter-row {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
gap: 16rpx;
white-space: nowrap;
}
.filter-chip {
display: inline-flex;
align-items: center;
height: 60rpx;
padding: 0 28rpx;
border-radius: 30rpx;
background: #f0f0f0;
flex-shrink: 0;
}
.filter-chip--active {
background: #1a1a2e;
}
.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: 24rpx; }
.skeleton-item {
height: 180rpx;
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;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
/* ── Empty ───────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 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;
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;
padding: 20rpx 24rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.order-card-name { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
.order-status-badge {
border-radius: 20rpx;
padding: 6rpx 20rpx;
}
.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; }
.order-body { padding: 16rpx 24rpx; }
.order-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10rpx 0;
}
.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;
}
.load-more-text { font-size: 26rpx; color: #c9a87c; }
</style>