Files
mp-pilates/packages/app/src/pages/admin/orders.vue
2026-04-12 18:16:18 +08:00

671 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page" :style="{ '--status-bar': statusBarHeight + 'px' }">
<CustomNavBar title="订单管理" show-back />
<!-- Summary stats bar -->
<view class="stats-bar">
<view class="stat-item">
<text class="stat-num">{{ totalCount || '--' }}</text>
<text class="stat-label">全部订单</text>
</view>
<view class="stat-divider" />
<view class="stat-item">
<text class="stat-num paid">{{ paidCount || '--' }}</text>
<text class="stat-label">已支付</text>
</view>
<view class="stat-divider" />
<view class="stat-item">
<text class="stat-num pending">{{ pendingCount || '--' }}</text>
<text class="stat-label">待支付</text>
</view>
</view>
<!-- Status filter tabs -->
<view class="filter-wrap">
<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-pill"
:class="{ active: activeFilter === f.value }"
@tap="selectFilter(f.value)"
>
<text class="filter-pill-text">{{ f.label }}</text>
<view v-if="f.count != null" class="filter-pill-dot" />
</view>
</view>
</scroll-view>
</view>
<!-- Pull-to-refresh -->
<scroll-view
scroll-y
class="list-scroll"
:refresher-enabled="true"
:refresher-triggered="refreshing"
:lower-threshold="120"
@refresherrefresh="onRefresh"
@scrolltolower="loadMore"
>
<!-- Loading skeleton -->
<view v-if="loading && !orders.length" class="order-list">
<view v-for="i in 5" :key="i" class="skeleton-card" />
</view>
<!-- Empty -->
<view v-else-if="!loading && !orders.length" class="empty-state">
<view class="empty-illustration">
<text class="empty-icon">📭</text>
</view>
<text class="empty-title">暂无订单</text>
<text class="empty-sub">当前筛选条件下没有找到订单</text>
</view>
<!-- Order cards -->
<view v-else class="order-list">
<view
v-for="(order, idx) in orders"
:key="order.id"
class="order-card"
:class="{ 'order-card--paid': order.status === OrderStatus.PAID, 'order-card--pending': order.status === OrderStatus.PENDING }"
:style="{ animationDelay: `${idx * 40}ms` }"
>
<!-- Card accent bar -->
<view class="card-accent" :class="statusAccentClass(order.status)" />
<!-- Card header -->
<view class="card-header">
<view class="card-title-row">
<text class="card-plan">{{ order.cardType?.name ?? '未知套餐' }}</text>
<view class="badge" :class="statusBadgeClass(order.status)">
<text class="badge-text">{{ statusLabel(order.status) }}</text>
</view>
</view>
<text class="card-order-no">#{{ order.orderNo }}</text>
</view>
<!-- Card divider -->
<view class="card-divider" />
<!-- Card body -->
<view class="card-body">
<view class="info-row">
<view class="info-left">
<text class="info-label">用户</text>
<text class="info-value">{{ order.user?.nickname ?? '未知用户' }}</text>
</view>
<view class="info-right">
<text class="info-label">手机</text>
<text class="info-value mono">{{ order.user?.phone ?? '未绑定' }}</text>
</view>
</view>
<view class="info-row">
<view class="info-left">
<text class="info-label">金额</text>
<text class="info-value price">¥{{ formatPrice(order.amount) }}</text>
</view>
<view class="info-right">
<text class="info-label">下单时间</text>
<text class="info-value">{{ formatDateTime(order.createdAt) }}</text>
</view>
</view>
<!-- Paid time if available -->
<view v-if="order.paidAt && order.status === OrderStatus.PAID" class="info-row">
<text class="info-label">支付时间</text>
<text class="info-value">{{ formatDateTime(order.paidAt) }}</text>
</view>
</view>
</view>
</view>
<!-- Load more / no more -->
<view v-if="hasMore" class="load-more">
<text class="load-more-text">{{ loading ? '加载中...' : '加载更多' }}</text>
</view>
<view v-else-if="orders.length > 0" class="no-more">
<text class="no-more-text"> 已加载全部 {{ orders.length }} 条订单 </text>
</view>
<view style="height: 60rpx" />
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { useAdminStore } from '../../stores/admin'
import { formatPrice, formatDateTime } from '../../utils/format'
import { OrderStatus } from '@mp-pilates/shared'
import type { OrderWithDetails } from '@mp-pilates/shared'
const adminStore = useAdminStore()
// 动态计算顶部模块高度
const statusBarHeight = ref(0)
onMounted(() => {
const windowInfo = uni.getWindowInfo()
statusBarHeight.value = windowInfo.statusBarHeight ?? 20
})
const filters = [
{ label: '全部', value: '', count: null },
{ label: '已支付', value: OrderStatus.PAID, count: null },
{ label: '待支付', value: OrderStatus.PENDING, count: null },
{ label: '已退款', value: OrderStatus.REFUNDED, count: null },
]
const activeFilter = ref('')
const orders = ref<OrderWithDetails[]>([])
const loading = ref(false)
const refreshing = ref(false)
const page = ref(1)
const hasMore = ref(false)
const totalCount = ref<number | null>(null)
const paidCount = ref<number | null>(null)
const pendingCount = ref<number | null>(null)
// 每个 tab 单独缓存数据
const orderCache: Record<string, { items: OrderWithDetails[]; total: number; page: number; hasMore: boolean }> = {}
function getCacheKey(filter: string): string {
return filter || 'all'
}
function getCachedData(filter: string) {
return orderCache[getCacheKey(filter)]
}
function setCachedData(filter: string, data: { items: OrderWithDetails[]; total: number; page: number; hasMore: boolean }) {
orderCache[getCacheKey(filter)] = data
}
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'
}
function statusAccentClass(s: string) {
if (s === OrderStatus.PAID) return 'accent--paid'
if (s === OrderStatus.PENDING) return 'accent--pending'
if (s === OrderStatus.REFUNDED) return 'accent--refunded'
return ''
}
async function loadOrders(reset = false) {
const filter = activeFilter.value
// 如果有缓存且是重置切换tab直接用缓存数据
if (reset) {
const cached = getCachedData(filter)
if (cached) {
orders.value = [...cached.items]
hasMore.value = cached.hasMore
page.value = cached.page
return
}
}
// 初始加载或下拉刷新,需要请求接口
if (loading.value) return
if (reset) page.value = 1
loading.value = true
try {
const params: { page: number; limit: number; status?: string } = {
page: page.value,
limit: LIMIT,
}
if (filter) params.status = filter
const result = await adminStore.fetchAdminOrders(params)
const newItems = reset ? [...result.items] : [...orders.value, ...result.items]
const newHasMore = newItems.length < result.total
// 缓存数据
setCachedData(filter, {
items: newItems,
total: result.total,
page: page.value,
hasMore: newHasMore,
})
orders.value = newItems
hasMore.value = newHasMore
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
refreshing.value = false
}
}
// 初始加载所有分类的数据
async function loadAllFiltersData() {
loading.value = true
try {
// 并行请求所有分类(第一页数据)
const [allResult, paidResult, pendingResult, refundedResult] = await Promise.all([
adminStore.fetchAdminOrders({ page: 1, limit: LIMIT }),
adminStore.fetchAdminOrders({ page: 1, limit: LIMIT, status: OrderStatus.PAID }),
adminStore.fetchAdminOrders({ page: 1, limit: LIMIT, status: OrderStatus.PENDING }),
adminStore.fetchAdminOrders({ page: 1, limit: LIMIT, status: OrderStatus.REFUNDED }),
])
// 缓存全部
setCachedData('', {
items: [...allResult.items],
total: allResult.total,
page: 1,
hasMore: allResult.items.length < allResult.total,
})
totalCount.value = allResult.total
// 缓存已支付
setCachedData(OrderStatus.PAID, {
items: [...paidResult.items],
total: paidResult.total,
page: 1,
hasMore: paidResult.items.length < paidResult.total,
})
paidCount.value = paidResult.total
// 缓存待支付
setCachedData(OrderStatus.PENDING, {
items: [...pendingResult.items],
total: pendingResult.total,
page: 1,
hasMore: pendingResult.items.length < pendingResult.total,
})
pendingCount.value = pendingResult.total
// 缓存已退款
setCachedData(OrderStatus.REFUNDED, {
items: [...refundedResult.items],
total: refundedResult.total,
page: 1,
hasMore: refundedResult.items.length < refundedResult.total,
})
// 设置当前 tab 的数据
orders.value = [...allResult.items]
hasMore.value = allResult.items.length < allResult.total
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
refreshing.value = false
}
}
function selectFilter(value: string) {
activeFilter.value = value
// 切换 tab 直接从缓存读取
const cached = getCachedData(value)
if (cached) {
orders.value = [...cached.items]
hasMore.value = cached.hasMore
page.value = cached.page
}
}
async function onRefresh() {
refreshing.value = true
// 下拉刷新重新请求所有分类的数据
await loadAllFiltersData()
}
function loadMore() {
if (!hasMore.value || loading.value) return
page.value++
loadOrders(false)
}
onMounted(() => {
loadAllFiltersData()
})
</script>
<style lang="scss" scoped>
/* ── Page shell ──────────────────────────────── */
.page {
height: 100vh;
display: flex;
flex-direction: column;
background: #FAF8F5;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
}
/* ── Stats bar ──────────────────────────────── */
.stats-bar {
position: fixed;
top: calc(var(--status-bar) + 44px);
left: 0;
right: 0;
display: flex;
align-items: center;
height: 96rpx;
background: #FFFFFF;
padding: 0;
border-bottom: 1rpx solid rgba(180, 160, 130, 0.2);
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.08);
z-index: 100;
}
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.stat-num {
font-size: 42rpx;
font-weight: 700;
color: #4A4035;
letter-spacing: -1rpx;
line-height: 1;
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
}
.stat-num.paid { color: #7A9E7E; }
.stat-num.pending { color: $warning-color; }
.stat-label {
font-size: 22rpx;
color: #A09080;
letter-spacing: 1rpx;
}
.stat-divider {
width: 1rpx;
height: 48rpx;
background: rgba(180, 160, 130, 0.25);
}
/* ── Filter pills ───────────────────────────── */
.filter-wrap {
position: fixed;
top: calc(var(--status-bar) + 92px);
left: 0;
right: 0;
background: #FAF8F5;
border-bottom: 1rpx solid rgba(180, 160, 130, 0.15);
z-index: 99;
}
.filter-scroll { overflow: hidden; }
.filter-row {
display: flex;
align-items: center;
padding: 20rpx 28rpx;
gap: 16rpx;
white-space: nowrap;
}
.filter-pill {
position: relative;
display: inline-flex;
align-items: center;
gap: 8rpx;
height: 64rpx;
padding: 0 32rpx;
border-radius: 32rpx;
background: rgba(180, 160, 130, 0.1);
border: 1.5rpx solid rgba(180, 160, 130, 0.2);
flex-shrink: 0;
transition: all 0.22s ease;
}
.filter-pill.active {
background: #4A4035;
border-color: #4A4035;
}
.filter-pill-text {
font-size: 26rpx;
color: #7A6A5A;
font-weight: 500;
transition: color 0.22s ease;
}
.filter-pill.active .filter-pill-text {
color: #E8D8C0;
font-weight: 600;
}
.filter-pill-dot {
width: 6rpx;
height: 6rpx;
border-radius: 50%;
background: $accent-color;
}
/* ── List ───────────────────────────────────── */
.list-scroll {
position: fixed;
top: calc(var(--status-bar) + 144px);
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
}
.order-list {
padding: 20rpx 24rpx 0;
display: flex;
flex-direction: column;
gap: 20rpx;
}
/* ── Skeleton ───────────────────────────────── */
.skeleton-card {
height: 220rpx;
border-radius: 20rpx;
background: linear-gradient(90deg, #F0EBE3 25%, #E8E0D5 50%, #F0EBE3 75%);
background-size: 400% 100%;
animation: shimmer 1.6s ease infinite;
}
/* ── Empty ──────────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 48rpx;
gap: 12rpx;
}
.empty-illustration {
width: 120rpx;
height: 120rpx;
border-radius: 60rpx;
background: rgba(180, 160, 130, 0.15);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12rpx;
}
.empty-icon { font-size: 56rpx; }
.empty-title {
font-size: 32rpx;
font-weight: 600;
color: #4A4035;
letter-spacing: 0.5rpx;
}
.empty-sub {
font-size: 26rpx;
color: rgba(74, 64, 53, 0.4);
text-align: center;
}
/* ── Order card ─────────────────────────────── */
.order-card {
position: relative;
background: #FFFFFF;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 4rpx 20rpx rgba(180, 160, 130, 0.12);
animation: cardIn 0.4s ease both;
}
@keyframes cardIn {
from { opacity: 0; transform: translateY(12rpx); }
to { opacity: 1; transform: translateY(0); }
}
.card-accent {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
}
.accent--paid { background: #8FCB9B; }
.accent--pending { background: #F2C94C; }
.accent--refunded { background: rgba(43, 43, 43, 0.2); }
.card-header {
padding: 24rpx 24rpx 20rpx 30rpx;
}
.card-title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-plan {
font-size: 30rpx;
font-weight: 700;
color: #4A4035;
letter-spacing: 0.5rpx;
}
.card-order-no {
font-size: 22rpx;
color: rgba(74, 64, 53, 0.35);
margin-top: 6rpx;
display: block;
font-family: 'SF Mono', 'Menlo', monospace;
}
.card-divider {
height: 1rpx;
background: rgba(180, 160, 130, 0.15);
margin: 0 24rpx;
}
.card-body {
padding: 20rpx 24rpx 20rpx 30rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.info-row {
display: flex;
align-items: center;
}
.info-left,
.info-right {
flex: 1;
display: flex;
align-items: center;
gap: 12rpx;
}
.info-right {
justify-content: flex-end;
}
.info-label {
font-size: 24rpx;
color: rgba(74, 64, 53, 0.4);
flex-shrink: 0;
min-width: 80rpx;
}
.info-value {
font-size: 26rpx;
color: #4A4035;
font-weight: 500;
}
.info-value.mono {
font-family: 'SF Mono', 'Menlo', monospace;
font-size: 24rpx;
}
.info-value.price {
font-size: 30rpx;
font-weight: 700;
color: $accent-color;
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
}
/* ── Status badges ─────────────────────────── */
.badge {
border-radius: 8rpx;
padding: 4rpx 14rpx;
}
.badge--paid { background: rgba(122, 158, 126, 0.15); }
.badge--paid .badge-text { font-size: 22rpx; color: #5A7E5E; font-weight: 600; }
.badge--pending { background: rgba(196, 149, 106, 0.2); }
.badge--pending .badge-text { font-size: 22rpx; color: #A07540; font-weight: 600; }
.badge--refunded { background: rgba(180, 160, 130, 0.15); }
.badge--refunded .badge-text { font-size: 22rpx; color: #8A7A6A; }
.badge--default .badge-text { font-size: 22rpx; color: #888; }
/* ── Load more ─────────────────────────────── */
.load-more {
text-align: center;
padding: 40rpx 0 20rpx;
}
.load-more-text {
font-size: 26rpx;
color: $accent-color;
font-weight: 500;
}
.no-more {
text-align: center;
padding: 32rpx 0 20rpx;
}
.no-more-text {
font-size: 24rpx;
color: rgba(74, 64, 53, 0.3);
letter-spacing: 0.5rpx;
}
</style>