perf: 完善订单管理

This commit is contained in:
richarjiang
2026-04-05 21:03:18 +08:00
parent fdb13c32c2
commit 4633ceea8c
29 changed files with 1000 additions and 261 deletions

View File

@@ -1,22 +1,44 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="订单管理" show-back />
<!-- 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 -->
<!-- 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"
@@ -25,60 +47,95 @@
@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 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">
<text class="empty-icon">📋</text>
<text class="empty-text">暂无订单</text>
<view class="empty-illustration">
<text class="empty-icon">📭</text>
</view>
<text class="empty-title">暂无订单</text>
<text class="empty-sub">当前筛选条件下没有找到订单</text>
</view>
<!-- Order list -->
<!-- Order cards -->
<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
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>
<view class="order-body">
<view class="order-row">
<text class="order-row-label">用户</text>
<text class="order-row-value">{{ order.user?.nickname ?? '-' }}</text>
<!-- 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="order-row">
<text class="order-row-label">手机</text>
<text class="order-row-value">{{ order.user?.phone ?? '未绑定' }}</text>
<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">{{ formatDate(order.createdAt) }}</text>
</view>
</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>
<!-- 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">{{ formatDate(order.paidAt) }}</text>
</view>
</view>
</view>
</view>
<!-- Load more -->
<!-- Load more / no more -->
<view v-if="hasMore" class="load-more" @tap="loadMore">
<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>
<!-- Bottom spacer -->
<view style="height: 40rpx;" />
<view style="height: 60rpx" />
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import { formatPrice, formatDate } from '../../utils/format'
import { OrderStatus } from '@mp-pilates/shared'
@@ -88,15 +145,14 @@ const adminStore = useAdminStore()
const navBarHeight = ref('64px')
onMounted(() => {
const sys = uni.getSystemInfoSync()
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
})
const filters = [
{ label: '全部', value: '' },
{ label: '已支付', value: OrderStatus.PAID },
{ label: '待支付', value: OrderStatus.PENDING },
{ label: '已退款', value: OrderStatus.REFUNDED },
{ 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('')
@@ -105,6 +161,9 @@ 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)
const LIMIT = 20
@@ -124,25 +183,31 @@ function statusBadgeClass(s: string) {
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) {
if (loading.value) return
if (reset) {
page.value = 1
orders.value = []
}
if (reset) page.value = 1
loading.value = true
try {
const result = await adminStore.fetchAdminOrders({
const params: { page: number; limit: number; status?: string } = {
page: page.value,
limit: LIMIT,
status: activeFilter.value || undefined,
})
}
if (activeFilter.value) params.status = activeFilter.value
const result = await adminStore.fetchAdminOrders(params)
if (reset) {
orders.value = [...result.items]
orders.value = [...result.data]
} else {
orders.value.push(...result.items)
orders.value.push(...result.data)
}
hasMore.value = orders.value.length < result.total
totalCount.value = result.total
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
@@ -151,14 +216,30 @@ async function loadOrders(reset = false) {
}
}
async function loadSummaryCounts() {
try {
const [allResult, paidResult, pendingResult] = await Promise.all([
adminStore.fetchAdminOrders({ page: 1, limit: 1 }),
adminStore.fetchAdminOrders({ page: 1, limit: 1, status: OrderStatus.PAID }),
adminStore.fetchAdminOrders({ page: 1, limit: 1, status: OrderStatus.PENDING }),
])
totalCount.value = allResult.total
paidCount.value = paidResult.total
pendingCount.value = pendingResult.total
} catch {
// non-critical, ignore
}
}
function selectFilter(value: string) {
activeFilter.value = value
totalCount.value = null
loadOrders(true)
}
async function onRefresh() {
refreshing.value = true
await loadOrders(true)
await Promise.all([loadOrders(true), loadSummaryCounts()])
}
function loadMore() {
@@ -167,65 +248,140 @@ function loadMore() {
loadOrders(false)
}
onMounted(() => loadOrders(true))
onMounted(() => {
loadOrders(true)
loadSummaryCounts()
})
</script>
<style lang="scss" scoped>
/* ── Page shell ──────────────────────────────── */
.page {
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f3f0;
background: #FAF8F5;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
}
/* ── Filter scroll ───────────────────────── */
.filter-scroll {
flex-shrink: 0;
background: #ffffff;
border-bottom: 1rpx solid #eee;
/* ── Stats bar ──────────────────────────────── */
.stats-bar {
display: flex;
align-items: center;
background: #FFFFFF;
padding: 28rpx 0;
margin: 0;
border-bottom: 1rpx solid rgba(180, 160, 130, 0.2);
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.08);
}
.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: #C4956A; }
.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 {
background: #FAF8F5;
border-bottom: 1rpx solid rgba(180, 160, 130, 0.15);
flex-shrink: 0;
}
.filter-scroll { overflow: hidden; }
.filter-row {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
padding: 20rpx 28rpx;
gap: 16rpx;
white-space: nowrap;
}
.filter-chip {
.filter-pill {
position: relative;
display: inline-flex;
align-items: center;
height: 60rpx;
padding: 0 28rpx;
border-radius: 30rpx;
background: #f0f0f0;
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-chip--active {
background: #1a1a2e;
.filter-pill.active {
background: #4A4035;
border-color: #4A4035;
}
.filter-chip-text { font-size: 26rpx; color: #888; }
.filter-chip--active .filter-chip-text { color: #c9a87c; font-weight: 600; }
.filter-pill-text {
font-size: 26rpx;
color: #7A6A5A;
font-weight: 500;
transition: color 0.22s ease;
}
/* ── List scroll ─────────────────────────── */
.filter-pill.active .filter-pill-text {
color: #E8D8C0;
font-weight: 600;
}
.filter-pill-dot {
width: 6rpx;
height: 6rpx;
border-radius: 50%;
background: #B08050;
}
/* ── List ───────────────────────────────────── */
.list-scroll {
flex: 1;
overflow: hidden;
}
/* ── Skeleton ────────────────────────────── */
.skeleton-list { padding: 24rpx; }
.order-list {
padding: 20rpx 24rpx 0;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.skeleton-item {
height: 180rpx;
border-radius: 16rpx;
margin-bottom: 16rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
/* ── 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.4s infinite;
animation: shimmer 1.6s ease infinite;
}
@keyframes shimmer {
@@ -233,70 +389,185 @@ onMounted(() => loadOrders(true))
100% { background-position: -100% 0; }
}
/* ── Empty ───────────────────────────────── */
/* ── Empty ──────────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
gap: 20rpx;
padding: 120rpx 48rpx;
gap: 12rpx;
}
.empty-icon { font-size: 80rpx; }
.empty-text { font-size: 28rpx; color: #bbb; }
.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;
}
/* ── Order list ──────────────────────────── */
.order-list { padding: 16rpx 24rpx 0; }
.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 {
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 {
position: relative;
background: #FFFFFF;
border-radius: 20rpx;
padding: 6rpx 20rpx;
overflow: hidden;
box-shadow: 0 4rpx 20rpx rgba(180, 160, 130, 0.12);
animation: cardIn 0.4s ease both;
}
.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; }
@keyframes cardIn {
from { opacity: 0; transform: translateY(12rpx); }
to { opacity: 1; transform: translateY(0); }
}
.order-body { padding: 16rpx 24rpx; }
.card-accent {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
}
.order-row {
.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;
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; }
.card-plan {
font-size: 30rpx;
font-weight: 700;
color: #4A4035;
letter-spacing: 0.5rpx;
}
/* ── Load more ───────────────────────────── */
.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: #B08050;
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: 32rpx;
padding: 40rpx 0 20rpx;
}
.load-more-text { font-size: 26rpx; color: #c9a87c; }
.load-more-text {
font-size: 26rpx;
color: #B08050;
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>