Files
dy/frontend/src/components/PaymentOrderListPanel.vue
T
2026-07-23 17:56:25 +08:00

647 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.
<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>