647 lines
17 KiB
Vue
647 lines
17 KiB
Vue
<script setup>
|
||
import { ref, onMounted, computed } from 'vue'
|
||
import { message, Modal } from 'ant-design-vue'
|
||
import { EditOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||
import api from '../api'
|
||
import { useIsMobile } from '../composables/useIsMobile'
|
||
|
||
const props = defineProps({
|
||
showUserColumn: { type: Boolean, default: false },
|
||
manageable: { type: Boolean, default: false },
|
||
defaultStatus: { type: String, default: '' }
|
||
})
|
||
|
||
const loading = ref(false)
|
||
const isMobile = useIsMobile()
|
||
const statusModalWidth = computed(() => (isMobile.value ? 'calc(100vw - 32px)' : 520))
|
||
const orders = ref([])
|
||
const total = ref(0)
|
||
const page = ref(1)
|
||
const pageSize = ref(20)
|
||
const status = ref(props.defaultStatus)
|
||
const channel = ref(undefined)
|
||
const stats = ref({ paid_count: 0, paid_amount_yuan: 0 })
|
||
|
||
const statusModalVisible = ref(false)
|
||
const statusSaving = ref(false)
|
||
const editingOrder = ref(null)
|
||
const newStatus = ref('paid')
|
||
|
||
const statusOptions = [
|
||
{ value: 'pending', label: '待支付' },
|
||
{ value: 'paid', label: '支付成功' },
|
||
{ value: 'refunded', label: '已退款' },
|
||
{ value: 'expired', label: '已过期' },
|
||
{ value: 'cancelled', label: '已取消' }
|
||
]
|
||
|
||
const statusLabel = (value) => ({
|
||
paid: '支付成功',
|
||
pending: '待支付',
|
||
expired: '已过期',
|
||
cancelled: '已取消',
|
||
refunded: '已退款'
|
||
}[value] || value)
|
||
|
||
const statusColor = (value) => ({
|
||
paid: 'success',
|
||
pending: 'processing',
|
||
expired: 'default',
|
||
cancelled: 'error',
|
||
refunded: 'warning'
|
||
}[value] || 'default')
|
||
|
||
const channelLabel = (value) => (value === 'alipay' ? '支付宝' : '微信')
|
||
|
||
const formatTime = (value) => {
|
||
if (!value) return '-'
|
||
const d = new Date(value)
|
||
const pad = (n) => String(n).padStart(2, '0')
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||
}
|
||
|
||
const tableScrollX = computed(() => {
|
||
let width = 920
|
||
if (props.showUserColumn) width += 120
|
||
if (props.manageable) width += 120
|
||
return width
|
||
})
|
||
|
||
const fetchOrders = async () => {
|
||
loading.value = true
|
||
try {
|
||
const params = { page: page.value, page_size: pageSize.value }
|
||
if (status.value) params.status = status.value
|
||
if (channel.value) params.channel = channel.value
|
||
const res = await api.get('/payments/orders', { params })
|
||
orders.value = res.data.items
|
||
total.value = res.data.total
|
||
stats.value = {
|
||
paid_count: res.data.paid_count,
|
||
paid_amount_yuan: res.data.paid_amount_yuan
|
||
}
|
||
} catch (error) {
|
||
message.error(error.response?.data?.detail || '加载订单失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const onFilterChange = () => {
|
||
page.value = 1
|
||
fetchOrders()
|
||
}
|
||
|
||
const onTableChange = (pagination) => {
|
||
page.value = pagination.current
|
||
pageSize.value = pagination.pageSize
|
||
fetchOrders()
|
||
}
|
||
|
||
const openStatusModal = (record) => {
|
||
editingOrder.value = record
|
||
newStatus.value = record.status
|
||
statusModalVisible.value = true
|
||
}
|
||
|
||
const handleStatusSave = async () => {
|
||
if (!editingOrder.value) return
|
||
statusSaving.value = true
|
||
try {
|
||
await api.put(`/payments/orders/${editingOrder.value.order_no}/status`, {
|
||
status: newStatus.value
|
||
})
|
||
message.success('订单状态已更新')
|
||
statusModalVisible.value = false
|
||
fetchOrders()
|
||
} catch (error) {
|
||
message.error(error.response?.data?.detail || '更新失败')
|
||
} finally {
|
||
statusSaving.value = false
|
||
}
|
||
}
|
||
|
||
const handleDelete = (record) => {
|
||
Modal.confirm({
|
||
title: `确定删除订单「${record.order_no}」吗?`,
|
||
content: record.status === 'paid'
|
||
? '该订单已支付成功,删除后将自动扣回对应账号额度。'
|
||
: '删除后不可恢复。',
|
||
okType: 'danger',
|
||
okText: '删除',
|
||
cancelText: '取消',
|
||
onOk: async () => {
|
||
try {
|
||
await api.delete(`/payments/orders/${record.order_no}`)
|
||
message.success('订单已删除')
|
||
fetchOrders()
|
||
} catch (error) {
|
||
message.error(error.response?.data?.detail || '删除失败')
|
||
return Promise.reject(error)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
onMounted(fetchOrders)
|
||
|
||
defineExpose({ refresh: fetchOrders })
|
||
</script>
|
||
|
||
<template>
|
||
<div class="order-list-panel">
|
||
<div class="order-stats">
|
||
<div class="stat-card">
|
||
<div class="stat-label">{{ showUserColumn ? '支付成功订单' : '我的成功订单' }}</div>
|
||
<div class="stat-value">{{ stats.paid_count }}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">{{ showUserColumn ? '成功支付总额' : '我的支付总额' }}</div>
|
||
<div class="stat-value accent">¥ {{ stats.paid_amount_yuan.toFixed(2) }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="order-filters">
|
||
<a-select v-model:value="status" class="filter-select" @change="onFilterChange">
|
||
<a-select-option value="">全部状态</a-select-option>
|
||
<a-select-option value="paid">支付成功</a-select-option>
|
||
<a-select-option value="pending">待支付</a-select-option>
|
||
<a-select-option value="refunded">已退款</a-select-option>
|
||
<a-select-option value="expired">已过期</a-select-option>
|
||
<a-select-option value="cancelled">已取消</a-select-option>
|
||
</a-select>
|
||
<a-select
|
||
v-model:value="channel"
|
||
allow-clear
|
||
placeholder="全部支付方式"
|
||
class="filter-select"
|
||
@change="onFilterChange"
|
||
>
|
||
<a-select-option value="wechat">微信支付</a-select-option>
|
||
<a-select-option value="alipay">支付宝</a-select-option>
|
||
</a-select>
|
||
</div>
|
||
|
||
<div v-if="!isMobile" class="table-shell">
|
||
<a-table
|
||
:data-source="orders"
|
||
:loading="loading"
|
||
row-key="id"
|
||
size="middle"
|
||
:scroll="{ x: tableScrollX }"
|
||
:pagination="{
|
||
current: page,
|
||
pageSize,
|
||
total,
|
||
showSizeChanger: true,
|
||
showTotal: (t) => `共 ${t} 条`
|
||
}"
|
||
@change="onTableChange"
|
||
>
|
||
<a-table-column title="订单号" key="order_no" :width="220">
|
||
<template #default="{ record }">
|
||
<div class="cell-order">
|
||
<span class="order-no">{{ record.order_no }}</span>
|
||
<span v-if="record.trade_no" class="trade-no">{{ record.trade_no }}</span>
|
||
</div>
|
||
</template>
|
||
</a-table-column>
|
||
|
||
<a-table-column v-if="showUserColumn" title="用户" key="user" :width="110">
|
||
<template #default="{ record }">
|
||
<span class="cell-user">{{ record.display_name || record.username || `#${record.user_id}` }}</span>
|
||
</template>
|
||
</a-table-column>
|
||
|
||
<a-table-column title="购买内容" key="purchase" :width="130">
|
||
<template #default="{ record }">
|
||
<div class="cell-purchase">
|
||
<span>{{ channelLabel(record.channel) }} · {{ record.slots }} 个</span>
|
||
<span class="amount">¥ {{ record.amount_yuan.toFixed(2) }}</span>
|
||
</div>
|
||
</template>
|
||
</a-table-column>
|
||
|
||
<a-table-column title="状态" key="status" :width="130">
|
||
<template #default="{ record }">
|
||
<div class="cell-status">
|
||
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
|
||
<a-tag v-if="record.is_demo" color="warning">演示</a-tag>
|
||
</div>
|
||
</template>
|
||
</a-table-column>
|
||
|
||
<a-table-column title="支付时间" key="paid_at" :width="160">
|
||
<template #default="{ record }">
|
||
<span class="cell-time">{{ formatTime(record.paid_at) }}</span>
|
||
</template>
|
||
</a-table-column>
|
||
|
||
<a-table-column title="创建时间" key="created_at" :width="160">
|
||
<template #default="{ record }">
|
||
<span class="cell-time">{{ formatTime(record.created_at) }}</span>
|
||
</template>
|
||
</a-table-column>
|
||
|
||
<a-table-column v-if="manageable" title="操作" key="action" :width="120">
|
||
<template #default="{ record }">
|
||
<div class="cell-actions">
|
||
<a-button type="link" size="small" class="action-edit" @click="openStatusModal(record)">
|
||
<EditOutlined /> 改状态
|
||
</a-button>
|
||
<a-button type="link" size="small" danger @click="handleDelete(record)">
|
||
<DeleteOutlined /> 删除
|
||
</a-button>
|
||
</div>
|
||
</template>
|
||
</a-table-column>
|
||
</a-table>
|
||
</div>
|
||
|
||
<div v-else class="order-mobile-list">
|
||
<a-spin :spinning="loading">
|
||
<div v-if="orders.length" class="order-card-list">
|
||
<div v-for="record in orders" :key="record.id" class="order-card">
|
||
<div class="order-card-head">
|
||
<span class="order-no">{{ record.order_no }}</span>
|
||
<div class="cell-status">
|
||
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
|
||
<a-tag v-if="record.is_demo" color="warning">演示</a-tag>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="record.trade_no" class="trade-no">{{ record.trade_no }}</div>
|
||
|
||
<div v-if="showUserColumn" class="order-card-row">
|
||
<span class="order-card-label">用户</span>
|
||
<span class="cell-user">{{ record.display_name || record.username || `#${record.user_id}` }}</span>
|
||
</div>
|
||
|
||
<div class="order-card-row">
|
||
<span class="order-card-label">购买</span>
|
||
<span>{{ channelLabel(record.channel) }} · {{ record.slots }} 个</span>
|
||
</div>
|
||
|
||
<div class="order-card-row">
|
||
<span class="order-card-label">金额</span>
|
||
<span class="amount">¥ {{ record.amount_yuan.toFixed(2) }}</span>
|
||
</div>
|
||
|
||
<div class="order-card-row">
|
||
<span class="order-card-label">支付时间</span>
|
||
<span class="cell-time">{{ formatTime(record.paid_at) }}</span>
|
||
</div>
|
||
|
||
<div class="order-card-row">
|
||
<span class="order-card-label">创建时间</span>
|
||
<span class="cell-time">{{ formatTime(record.created_at) }}</span>
|
||
</div>
|
||
|
||
<div v-if="manageable" class="order-card-actions">
|
||
<a-button type="link" size="small" class="action-edit" @click="openStatusModal(record)">
|
||
<EditOutlined /> 改状态
|
||
</a-button>
|
||
<a-button type="link" size="small" danger @click="handleDelete(record)">
|
||
<DeleteOutlined /> 删除
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="order-empty">暂无订单</div>
|
||
|
||
<div v-if="total > pageSize" class="order-mobile-pagination">
|
||
<a-pagination
|
||
v-model:current="page"
|
||
:total="total"
|
||
:page-size="pageSize"
|
||
size="small"
|
||
show-less-items
|
||
@change="(p) => { page = p; fetchOrders() }"
|
||
/>
|
||
</div>
|
||
</a-spin>
|
||
</div>
|
||
|
||
<a-modal
|
||
v-model:open="statusModalVisible"
|
||
title="修改订单状态"
|
||
ok-text="保存"
|
||
cancel-text="取消"
|
||
:confirm-loading="statusSaving"
|
||
:width="statusModalWidth"
|
||
@ok="handleStatusSave"
|
||
>
|
||
<div v-if="editingOrder" class="status-modal-body">
|
||
<p class="modal-order-no">订单号:{{ editingOrder.order_no }}</p>
|
||
<p class="modal-hint">
|
||
设为「支付成功」将自动增加 {{ editingOrder.slots }} 个账号额度;
|
||
设为「已退款」或从成功改为其他状态将自动扣回额度。
|
||
</p>
|
||
<a-form-item label="订单状态" style="margin-bottom: 0;">
|
||
<a-select v-model:value="newStatus" style="width: 100%;" :options="statusOptions" />
|
||
</a-form-item>
|
||
</div>
|
||
</a-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.order-stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 16px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.stat-card {
|
||
padding: 16px 20px;
|
||
border-radius: 12px;
|
||
background: rgba(255, 255, 255, 0.04);
|
||
border: 1px solid var(--border-light);
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 0.85rem;
|
||
color: var(--text-muted);
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 1.5rem;
|
||
font-weight: 700;
|
||
color: #fff;
|
||
}
|
||
|
||
.stat-value.accent {
|
||
color: #c084fc;
|
||
}
|
||
|
||
.order-filters {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.filter-select {
|
||
width: 150px;
|
||
}
|
||
|
||
.table-shell {
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
border: 1px solid var(--border-light);
|
||
background: rgba(0, 0, 0, 0.15);
|
||
}
|
||
|
||
.cell-order {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.order-no {
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
font-size: 0.82rem;
|
||
color: var(--text-primary);
|
||
word-break: break-all;
|
||
}
|
||
|
||
.trade-no {
|
||
font-size: 0.75rem;
|
||
color: var(--text-muted);
|
||
word-break: break-all;
|
||
}
|
||
|
||
.cell-user {
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.cell-purchase {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
font-size: 0.88rem;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.cell-purchase .amount {
|
||
color: #c084fc;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.cell-status {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.cell-time {
|
||
font-size: 0.85rem;
|
||
color: var(--text-secondary);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.cell-actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 2px;
|
||
}
|
||
|
||
.action-edit {
|
||
color: #c084fc !important;
|
||
padding: 0 !important;
|
||
height: auto !important;
|
||
}
|
||
|
||
.cell-actions :deep(.ant-btn-link) {
|
||
padding: 0;
|
||
height: auto;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.status-modal-body {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.modal-order-no {
|
||
color: var(--text-secondary);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.modal-hint {
|
||
font-size: 0.85rem;
|
||
color: var(--text-muted);
|
||
margin-bottom: 16px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
/* 表格暗色主题 */
|
||
.order-list-panel :deep(.ant-table) {
|
||
background: transparent;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.order-list-panel :deep(.ant-table-container) {
|
||
background: transparent;
|
||
}
|
||
|
||
.order-list-panel :deep(.ant-table-thead > tr > th) {
|
||
background: rgba(255, 255, 255, 0.04) !important;
|
||
color: var(--text-secondary) !important;
|
||
border-bottom: 1px solid var(--border-light) !important;
|
||
font-weight: 500;
|
||
padding: 12px 16px !important;
|
||
}
|
||
|
||
.order-list-panel :deep(.ant-table-tbody > tr > td) {
|
||
background: transparent !important;
|
||
border-bottom: 1px solid var(--border-light) !important;
|
||
padding: 14px 16px !important;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
.order-list-panel :deep(.ant-table-tbody > tr:hover > td) {
|
||
background: rgba(170, 59, 255, 0.06) !important;
|
||
}
|
||
|
||
.order-list-panel :deep(.ant-table-tbody > tr:last-child > td) {
|
||
border-bottom: none !important;
|
||
}
|
||
|
||
.order-list-panel :deep(.ant-table-cell-fix-left),
|
||
.order-list-panel :deep(.ant-table-cell-fix-right) {
|
||
background: hsl(230, 20%, 11%) !important;
|
||
}
|
||
|
||
.order-list-panel :deep(.ant-table-tbody > tr:hover > .ant-table-cell-fix-left),
|
||
.order-list-panel :deep(.ant-table-tbody > tr:hover > .ant-table-cell-fix-right) {
|
||
background: hsl(230, 20%, 13%) !important;
|
||
}
|
||
|
||
.order-list-panel :deep(.ant-pagination) {
|
||
margin: 16px !important;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.order-list-panel :deep(.ant-pagination-item) {
|
||
background: rgba(255, 255, 255, 0.05) !important;
|
||
border-color: var(--border-light) !important;
|
||
}
|
||
|
||
.order-list-panel :deep(.ant-pagination-item a) {
|
||
color: var(--text-secondary) !important;
|
||
}
|
||
|
||
.order-list-panel :deep(.ant-pagination-item-active) {
|
||
background: rgba(170, 59, 255, 0.2) !important;
|
||
border-color: rgba(170, 59, 255, 0.5) !important;
|
||
}
|
||
|
||
.order-list-panel :deep(.ant-pagination-item-active a) {
|
||
color: #c084fc !important;
|
||
}
|
||
|
||
.order-list-panel :deep(.ant-pagination-prev .ant-pagination-item-link),
|
||
.order-list-panel :deep(.ant-pagination-next .ant-pagination-item-link) {
|
||
background: rgba(255, 255, 255, 0.05) !important;
|
||
border-color: var(--border-light) !important;
|
||
color: var(--text-secondary) !important;
|
||
}
|
||
|
||
.order-list-panel :deep(.ant-select-selector) {
|
||
background: rgba(255, 255, 255, 0.05) !important;
|
||
border-color: var(--border-light) !important;
|
||
color: #fff !important;
|
||
}
|
||
|
||
.order-list-panel :deep(.ant-pagination-options .ant-select-selector) {
|
||
background: rgba(255, 255, 255, 0.05) !important;
|
||
}
|
||
|
||
.order-list-panel :deep(.ant-pagination-total-text) {
|
||
color: var(--text-muted) !important;
|
||
}
|
||
|
||
.order-list-panel :deep(.ant-empty-description) {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.order-mobile-list {
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.order-card-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.order-card {
|
||
padding: 14px 16px;
|
||
border-radius: 12px;
|
||
border: 1px solid var(--border-light);
|
||
background: rgba(0, 0, 0, 0.15);
|
||
}
|
||
|
||
.order-card-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.order-card-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 6px 0;
|
||
font-size: 0.88rem;
|
||
color: var(--text-secondary);
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||
}
|
||
|
||
.order-card-row:last-of-type {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.order-card-label {
|
||
flex-shrink: 0;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.order-card-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
padding-top: 10px;
|
||
border-top: 1px solid var(--border-light);
|
||
}
|
||
|
||
.order-mobile-pagination {
|
||
display: flex;
|
||
justify-content: center;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.order-empty {
|
||
text-align: center;
|
||
padding: 32px 16px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.order-stats {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.filter-select {
|
||
width: 100%;
|
||
}
|
||
}
|
||
</style>
|