Files
mp-pilates/packages/app/src/components/ShopOrders.vue
2026-09-16 22:49:47 +08:00

239 lines
7.2 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>
<input
v-if="admin"
v-model="search"
class="input search"
placeholder="搜索商品、订单号或手机号"
confirm-type="search"
@confirm="load(true)"
/>
<view class="tabs">
<text
v-for="tab in tabs"
:key="tab.value"
class="tab"
:class="{ active: status === tab.value }"
@tap="filter(tab.value)"
>
{{ tab.label }}
</text>
</view>
<view class="row">
<text class="muted">共 {{ total }} 笔订单</text>
<text class="badge" @tap="load(true)">刷新 ↻</text>
</view>
<view style="height: 24rpx" />
<view v-if="error" class="error" @tap="load(true)">{{ error }} · 点击重试</view>
<view v-if="!items.length && !error" class="state">
{{ loading ? '加载中…' : '这里还没有订单' }}
</view>
<view v-for="order in items" :key="order.id" class="panel">
<view class="row">
<text class="muted">{{ date(order.createdAt) }}</text>
<text class="badge">{{ shopStatus[order.status] }}</text>
</view>
<view class="divider" />
<view class="row">
<image class="thumb" :src="order.coverUrl" mode="aspectFill" />
<view style="flex: 1">
<text class="item-title">{{ order.title }}</text>
<text class="muted">¥{{ shopMoney(order.unitPrice) }} × {{ order.quantity }}</text>
<text class="price">¥{{ shopMoney(order.amount) }}</text>
</view>
</view>
<view class="divider" />
<text class="muted">{{ order.contactName }} · {{ order.contactPhone }}</text>
<text class="order-no" @tap="copy(order.orderNo)">订单号 {{ order.orderNo }} ⧉</text>
<template v-if="order.status === 'PAID'">
<view v-if="!admin" class="notice">
到馆出示提货码
<text class="code" @tap="copy(order.pickupCode)">{{ order.pickupCode }}</text>
请在领取商品时向工作人员出示。
</view>
<view v-else class="field">
<input
v-model="codes[order.id]"
class="input"
maxlength="12"
placeholder="输入顾客的 12 位提货码"
/>
</view>
</template>
<text v-if="order.status === 'PENDING'" class="muted">
付款截止 {{ date(order.expiresAt) }},超时后自动取消。
</text>
<view class="actions">
<template v-if="order.status === 'PENDING' && !admin">
<button class="secondary" :disabled="!!busy" @tap="cancel(order)">取消订单</button>
<button class="secondary" :disabled="!!busy" @tap="sync(order)">刷新支付状态</button>
<button class="primary" :disabled="!!busy || expired(order)" @tap="pay(order)">
继续付款
</button>
</template>
<button
v-if="admin && order.status === 'PAID'"
class="primary"
:disabled="!!busy"
@tap="complete(order)"
>
确认已交付商品
</button>
</view>
</view>
<button
v-if="items.length < total"
class="secondary load-more"
:disabled="loading"
@tap="load(false)"
>
加载更多
</button>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { ShopOrder, ShopPage } from '@mp-pilates/shared'
import { get, post } from '../utils/request'
import { shopMoney, shopStatus, shopError, payShopOrder } from '../utils/shop'
const props = defineProps<{ admin?: boolean }>(),
emit = defineEmits<{ (e: 'changed'): void }>()
const items = ref<ShopOrder[]>([]),
status = ref(''),
search = ref(''),
page = ref(0),
total = ref(0),
loading = ref(false),
error = ref(''),
busy = ref(''),
codes = ref<Record<string, string>>({})
const tabs = [
{ value: '', label: '全部' },
...Object.entries(shopStatus).map(([value, label]) => ({ value, label })),
]
const date = (value: string) => {
const date = new Date(value)
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
const expired = (o: ShopOrder) => new Date(o.expiresAt).getTime() <= Date.now()
const copy = (data: string) => uni.setClipboardData({ data })
async function load(reset = true) {
if (loading.value) return
loading.value = true
error.value = ''
try {
const data = await get<ShopPage<ShopOrder>>(
props.admin ? '/admin/shop/orders' : '/shop/orders',
{
page: reset ? 1 : page.value + 1,
...(status.value ? { status: status.value } : {}),
search: search.value,
},
)
items.value = reset ? data.items : [...items.value, ...data.items]
total.value = data.total
page.value = data.page
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
function filter(value: string) {
if (loading.value) return
status.value = value
load(true)
}
async function sync(order: ShopOrder) {
if (busy.value) return
busy.value = order.id
try {
const updated = await post<ShopOrder>(`/shop/orders/${order.id}/sync`)
items.value = items.value.map((o) => (o.id === updated.id ? updated : o))
uni.showToast({
title:
updated.status === 'PENDING' ? '仍在等待付款确认,请稍后刷新' : shopStatus[updated.status],
icon: 'none',
})
} catch (e) {
shopError(e)
} finally {
busy.value = ''
}
}
async function syncId(id: string) {
try {
await post(`/shop/orders/${id}/sync`)
} catch (e) {
shopError(e)
}
await load(true)
}
async function pay(order: ShopOrder) {
if (busy.value) return
busy.value = order.id
try {
await payShopOrder(order.id)
} catch (e) {
shopError(e)
} finally {
busy.value = ''
await sync(order)
}
}
async function cancel(order: ShopOrder) {
const answer = await uni.showModal({
title: '取消订单',
content: '确认取消这笔未付款订单?库存会在微信关单确认后释放。',
})
if (!answer.confirm || busy.value) return
busy.value = order.id
try {
await post(`/shop/orders/${order.id}/cancel`)
await load(true)
} catch (e) {
shopError(e)
} finally {
busy.value = ''
}
}
async function complete(order: ShopOrder) {
const code = (codes.value[order.id] || '').trim().toUpperCase()
if (!/^[A-F0-9]{12}$/.test(code)) {
shopError(new Error('请输入完整提货码'))
return
}
const answer = await uni.showModal({
title: '确认商品已交付',
content: `${order.title} × ${order.quantity},取货人 ${order.contactName}。核销后订单将完成。`,
})
if (!answer.confirm || busy.value) return
busy.value = order.id
try {
await post(`/admin/shop/orders/${order.id}/complete`, { pickupCode: code })
await load(true)
emit('changed')
uni.showToast({ title: '提货完成' })
} catch (e) {
shopError(e)
} finally {
busy.value = ''
}
}
defineExpose({ load, syncId })
</script>
<style scoped src="../pages/shop/shop.css"></style>
<style scoped lang="scss">
.price {
font-size: 31rpx;
}
.order-no {
display: block;
margin-top: 14rpx;
}
.actions:empty {
display: none;
}
</style>