345 lines
10 KiB
Vue
345 lines
10 KiB
Vue
<script setup lang="ts">
|
||
import { onMounted, ref } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { Money, Refresh } from '@element-plus/icons-vue'
|
||
import {
|
||
cancelAdminMohongOrder,
|
||
completeAdminMohongOrder,
|
||
fetchAdminMohongOrders,
|
||
mohongOrderStatusLabel,
|
||
receiveAdminMohongOrder,
|
||
refundAdminMohongOrder,
|
||
type MohongOrder,
|
||
} from '@/features/mohong/api/mohong'
|
||
import { centToYuan, formatCent, yuanToCent } from '@/shared/utils/money'
|
||
import { formatDateTime } from '@/shared/utils/time'
|
||
import { readError } from '@/shared/utils/error'
|
||
import AdminTablePagination from '../components/AdminTablePagination.vue'
|
||
|
||
const loading = ref(false)
|
||
const items = ref<MohongOrder[]>([])
|
||
const total = ref(0)
|
||
const page = ref(1)
|
||
const pageSize = ref(20)
|
||
const keyword = ref('')
|
||
const status = ref('')
|
||
const detail = ref<MohongOrder | null>(null)
|
||
const refundingId = ref<number | null>(null)
|
||
|
||
onMounted(load)
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
try {
|
||
const result = await fetchAdminMohongOrders({
|
||
keyword: keyword.value || undefined,
|
||
status: status.value || undefined,
|
||
page: page.value,
|
||
page_size: pageSize.value,
|
||
})
|
||
items.value = result.items || []
|
||
total.value = result.total || 0
|
||
} catch (error) {
|
||
ElMessage.error(readError(error, '加载失败'))
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleReceive(row: MohongOrder) {
|
||
try {
|
||
await ElMessageBox.confirm(`确认接待订单 ${row.order_no}?标记后状态变为「接待中」。`, '提示', {
|
||
type: 'info',
|
||
})
|
||
await receiveAdminMohongOrder(row.id)
|
||
ElMessage.success('已标记为接待中')
|
||
await load()
|
||
} catch (error) {
|
||
if (error !== 'cancel') ElMessage.error(readError(error, '操作失败'))
|
||
}
|
||
}
|
||
|
||
async function handleComplete(row: MohongOrder) {
|
||
try {
|
||
await ElMessageBox.confirm(`确认将订单 ${row.order_no} 标记为已完成?`, '提示', {
|
||
type: 'warning',
|
||
})
|
||
await completeAdminMohongOrder(row.id)
|
||
ElMessage.success('已完成')
|
||
await load()
|
||
} catch (error) {
|
||
if (error !== 'cancel') ElMessage.error(readError(error, '操作失败'))
|
||
}
|
||
}
|
||
|
||
async function handleCancel(row: MohongOrder) {
|
||
try {
|
||
const { value } = await ElMessageBox.prompt('请输入取消原因', '取消订单', {
|
||
inputPlaceholder: '可选',
|
||
confirmButtonText: '确认取消',
|
||
cancelButtonText: '返回',
|
||
})
|
||
await cancelAdminMohongOrder(row.id, value || '后台取消')
|
||
ElMessage.success('已取消')
|
||
await load()
|
||
} catch (error) {
|
||
if (error !== 'cancel') ElMessage.error(readError(error, '操作失败'))
|
||
}
|
||
}
|
||
|
||
async function handleRefund(row: MohongOrder) {
|
||
const defaultAmount = centToYuan(row.amount_cent).toFixed(2)
|
||
try {
|
||
const { value } = await ElMessageBox.prompt(
|
||
`请输入退款金额(元),默认全额 ¥${defaultAmount}`,
|
||
'人工退款',
|
||
{
|
||
inputValue: defaultAmount,
|
||
inputPlaceholder: defaultAmount,
|
||
confirmButtonText: '确认退款',
|
||
cancelButtonText: '返回',
|
||
type: 'warning',
|
||
inputValidator(input: string) {
|
||
const amount = Number(input)
|
||
if (!Number.isFinite(amount) || amount <= 0) return '请输入大于 0 的退款金额'
|
||
if (yuanToCent(amount) > row.amount_cent) return '退款金额不能超过订单金额'
|
||
return true
|
||
},
|
||
}
|
||
)
|
||
const amountCent = yuanToCent(Number(value))
|
||
refundingId.value = row.id
|
||
const result = await refundAdminMohongOrder(row.id, amountCent, '后台撞车订单人工退款')
|
||
ElMessage.success(result?.status === 'refunded' ? '退款成功' : '退款已提交')
|
||
await load()
|
||
} catch (error) {
|
||
if (error !== 'cancel') ElMessage.error(readError(error, '退款失败'))
|
||
} finally {
|
||
refundingId.value = null
|
||
}
|
||
}
|
||
|
||
function canReceive(row: MohongOrder) {
|
||
return row.status === 'paid'
|
||
}
|
||
|
||
function canComplete(row: MohongOrder) {
|
||
return row.status === 'paid' || row.status === 'receiving'
|
||
}
|
||
|
||
function canCancel(row: MohongOrder) {
|
||
return row.status === 'pending_payment' || row.status === 'paid' || row.status === 'receiving'
|
||
}
|
||
|
||
function canRefund(row: MohongOrder) {
|
||
return row.status === 'cancelled' && Boolean(row.paid_at) && Number(row.amount_cent || 0) > 0
|
||
}
|
||
|
||
async function copyText(text: string) {
|
||
try {
|
||
await navigator.clipboard.writeText(text)
|
||
ElMessage.success('已复制')
|
||
} catch {
|
||
ElMessage.error('复制失败')
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="admin-page">
|
||
<div class="page-head">
|
||
<div>
|
||
<h2>撞车订单</h2>
|
||
<p>查看订单、接待买家、完成履约、复制用户订单信息</p>
|
||
</div>
|
||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||
</div>
|
||
|
||
<div class="filters">
|
||
<el-input
|
||
v-model="keyword"
|
||
clearable
|
||
placeholder="订单号"
|
||
style="width: 220px"
|
||
@keyup.enter="load"
|
||
/>
|
||
<el-select v-model="status" clearable placeholder="状态" style="width: 140px">
|
||
<el-option label="待支付" value="pending_payment" />
|
||
<el-option label="已支付" value="paid" />
|
||
<el-option label="接待中" value="receiving" />
|
||
<el-option label="已完成" value="completed" />
|
||
<el-option label="已取消" value="cancelled" />
|
||
<el-option label="已退款" value="refunded" />
|
||
</el-select>
|
||
<el-button type="primary" @click=";((page = 1), load())">查询</el-button>
|
||
</div>
|
||
|
||
<el-table v-loading="loading" :data="items" border stripe>
|
||
<el-table-column prop="order_no" label="订单号" min-width="170" />
|
||
<el-table-column prop="product_title" label="商品" min-width="140" />
|
||
<el-table-column label="数量" width="70" prop="quantity" />
|
||
<el-table-column label="金额" width="100">
|
||
<template #default="{ row }">¥{{ row.amount || formatCent(row.amount_cent) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="买家" min-width="140">
|
||
<template #default="{ row }">
|
||
{{ row.buyer_nickname || '-' }}
|
||
<div class="sub">{{ row.buyer_phone || '' }}</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="100">
|
||
<template #default="{ row }">{{ mohongOrderStatusLabel(row.status) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="下单时间" min-width="160">
|
||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="310" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button link type="primary" @click="detail = row">详情</el-button>
|
||
<el-button v-if="canReceive(row)" link type="warning" @click="handleReceive(row)">
|
||
接待
|
||
</el-button>
|
||
<el-button v-if="canComplete(row)" link type="success" @click="handleComplete(row)">
|
||
完成
|
||
</el-button>
|
||
<el-button v-if="canCancel(row)" link type="danger" @click="handleCancel(row)">
|
||
取消
|
||
</el-button>
|
||
<el-button
|
||
v-if="canRefund(row)"
|
||
link
|
||
type="warning"
|
||
:icon="Money"
|
||
:loading="refundingId === row.id"
|
||
@click="handleRefund(row)"
|
||
>
|
||
退款
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<AdminTablePagination
|
||
:current-page="page"
|
||
:page-size="pageSize"
|
||
:total="total"
|
||
@update:current-page="page = $event"
|
||
@update:page-size="pageSize = $event"
|
||
@page-change="load"
|
||
/>
|
||
|
||
<el-drawer
|
||
:model-value="Boolean(detail)"
|
||
size="420px"
|
||
title="订单详情"
|
||
destroy-on-close
|
||
@update:model-value="(v: boolean) => !v && (detail = null)"
|
||
>
|
||
<template v-if="detail">
|
||
<el-descriptions :column="1" border>
|
||
<el-descriptions-item label="订单号">{{ detail.order_no }}</el-descriptions-item>
|
||
<el-descriptions-item label="状态">
|
||
{{ mohongOrderStatusLabel(detail.status) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="商品">{{ detail.product_title }}</el-descriptions-item>
|
||
<el-descriptions-item label="数量">{{ detail.quantity }}</el-descriptions-item>
|
||
<el-descriptions-item label="金额">
|
||
¥{{ detail.amount || formatCent(detail.amount_cent) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="买家">
|
||
{{ detail.buyer_nickname }} {{ detail.buyer_phone }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
<div v-if="detail.items?.length" class="items-box">
|
||
<strong>商品明细</strong>
|
||
<div v-for="item in detail.items" :key="item.product_id" class="item-line">
|
||
<span>{{ item.title }} × {{ item.quantity }}</span>
|
||
<em>¥{{ item.amount || formatCent(item.amount_cent) }}</em>
|
||
</div>
|
||
</div>
|
||
<div v-if="detail.copy_text" class="copy-box">
|
||
<div class="copy-head">
|
||
<strong>复制文案</strong>
|
||
<el-button size="small" @click="copyText(detail.copy_text)">复制</el-button>
|
||
</div>
|
||
<pre>{{ detail.copy_text }}</pre>
|
||
</div>
|
||
<div v-if="detail.qrcode_url_snapshot" class="qr-box">
|
||
<strong>二维码快照</strong>
|
||
<el-image
|
||
:src="detail.qrcode_url_snapshot"
|
||
fit="contain"
|
||
style="width: 180px; height: 180px"
|
||
/>
|
||
</div>
|
||
</template>
|
||
</el-drawer>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.admin-page {
|
||
display: grid;
|
||
gap: 16px;
|
||
}
|
||
.page-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
}
|
||
.page-head h2 {
|
||
margin: 0 0 4px;
|
||
}
|
||
.page-head p,
|
||
.sub {
|
||
margin: 0;
|
||
color: #6b7a90;
|
||
font-size: 12px;
|
||
}
|
||
.filters {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.items-box {
|
||
margin-top: 14px;
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
.item-line {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
font-size: 13px;
|
||
color: #334155;
|
||
}
|
||
.item-line em {
|
||
font-style: normal;
|
||
color: #ff6a00;
|
||
font-weight: 600;
|
||
}
|
||
.copy-box,
|
||
.qr-box {
|
||
margin-top: 16px;
|
||
}
|
||
.copy-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
}
|
||
.copy-box pre {
|
||
margin: 0;
|
||
padding: 12px;
|
||
background: #f7f9fc;
|
||
border-radius: 8px;
|
||
white-space: pre-wrap;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
}
|
||
.qr-box {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
</style>
|