feat: 完整支付配套——CoinLedger流水模型+充值/消费/退款全链写流水+管理后台订单/余额/退款/调整/统计页+用户明细页+余额卡明细入口

This commit is contained in:
2026-09-14 12:26:41 +08:00
parent 968396bed5
commit f13309e6cd
11 changed files with 497 additions and 36 deletions
+181
View File
@@ -0,0 +1,181 @@
<template>
<div class="page">
<div class="stats-cards">
<div class="scard"><b>¥{{ stats.totalRevenueYuan?.toFixed(2) || '0.00' }}</b><span>累计收入</span></div>
<div class="scard"><b>¥{{ stats.todayRevenueYuan?.toFixed(2) || '0.00' }}</b><span>今日收入</span></div>
<div class="scard"><b>{{ stats.totalRechargedCoins || 0 }}</b><span>累计充值星尘</span></div>
<div class="scard"><b>{{ stats.totalConsumedCoins || 0 }}</b><span>累计消费星尘</span></div>
<div class="scard"><b>{{ stats.paidOrders || 0 }}</b><span>成功订单</span><i>退款 {{ stats.refundedOrders || 0 }}</i></div>
</div>
<el-tabs v-model="tab">
<el-tab-pane label="订单管理" name="orders" />
<el-tab-pane label="星尘流水" name="ledger" />
</el-tabs>
<!-- 订单 Tab -->
<template v-if="tab === 'orders'">
<div class="toolbar">
<el-select v-model="statusFilter" style="width:120px" @change="load">
<el-option label="全部" value="" /><el-option label="已支付" value="paid" /><el-option label="待支付" value="pending" /><el-option label="已退款" value="refunded" />
</el-select>
<el-input v-model="q" placeholder="搜订单号" style="width:200px" clearable @keyup.enter="load" />
<el-button type="primary" @click="load">搜索</el-button>
<el-button type="warning" @click="adjustDlg = true">调整余额</el-button>
</div>
<el-table :data="orders" stripe v-loading="loading" size="small">
<el-table-column prop="orderNo" label="订单号" width="220" show-overflow-tooltip />
<el-table-column label="用户" width="120">
<template #default="{ row }">{{ row.userId?.nickname || (row.openid||'').slice(0,8) }}</template>
</el-table-column>
<el-table-column prop="productName" label="商品" width="120" />
<el-table-column label="金额" width="70">
<template #default="{ row }">¥{{ row.amount }}</template>
</el-table-column>
<el-table-column label="星尘" width="60">
<template #default="{ row }">{{ row.metadata?.coins || '—' }}</template>
</el-table-column>
<el-table-column label="状态" width="70">
<template #default="{ row }">
<el-tag :type="row.status === 'paid' ? 'success' : row.status === 'refunded' ? 'danger' : 'warning'" size="small">
{{ row.status === 'paid' ? '已支付' : row.status === 'refunded' ? '已退款' : row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="时间" width="150">
<template #default="{ row }">{{ fmt(row.paidAt || row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="70">
<template #default="{ row }">
<el-button v-if="row.status === 'paid'" size="small" type="danger" @click="refund(row)">退款</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination background layout="total, prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page" @current-change="load" style="margin-top:12px" />
</template>
<!-- 流水 Tab -->
<template v-else>
<div class="toolbar">
<el-select v-model="ledgerType" style="width:120px" @change="loadLedger">
<el-option label="全部" value="" /><el-option label="充值" value="recharge" /><el-option label="消费" value="consume" /><el-option label="退款" value="refund" /><el-option label="调整" value="adjust" />
</el-select>
<el-input v-model="ledgerQ" placeholder="搜openid" style="width:200px" @keyup.enter="loadLedger" />
<el-button type="primary" @click="loadLedger">搜索</el-button>
</div>
<el-table :data="ledger" stripe v-loading="ledgerLoading" size="small">
<el-table-column label="时间" width="150"><template #default="{ row }">{{ fmt(row.createdAt) }}</template></el-table-column>
<el-table-column label="用户" width="120"><template #default="{ row }">{{ (row.openid||'').slice(0,10) }}</template></el-table-column>
<el-table-column label="类型" width="60">
<template #default="{ row }">
<el-tag :type="{ recharge: 'success', consume: 'info', refund: 'danger', adjust: 'warning' }[row.type]" size="small">
{{ { recharge: '充值', consume: '消费', refund: '退款', adjust: '调整', gift: '赠送' }[row.type] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="变动" width="70">
<template #default="{ row }">
<span :style="{ color: row.amount > 0 ? '#22c55e' : '#ef4444', fontWeight: 700 }">{{ row.amount > 0 ? '+' : '' }}{{ row.amount }}</span>
</template>
</el-table-column>
<el-table-column prop="balanceAfter" label="余额" width="60" />
<el-table-column prop="description" label="说明" min-width="140" show-overflow-tooltip />
<el-table-column prop="orderNo" label="关联订单" width="200" show-overflow-tooltip />
<el-table-column prop="adminName" label="操作人" width="70" />
</el-table>
<el-pagination background layout="total, prev, pager, next" :total="ledgerTotal" :page-size="pageSize" v-model:current-page="ledgerPage" @current-change="loadLedger" style="margin-top:12px" />
</template>
<!-- 退款确认 -->
<el-dialog v-model="refundDlg" title="确认退款" width="380px">
<p>订单:{{ refundTarget?.orderNo }}</p>
<p>金额:¥{{ refundTarget?.amount }}</p>
<p>将扣回星尘:{{ refundTarget?.metadata?.coins || 0 }}</p>
<el-alert type="warning" :closable="false" title="退款后星尘将从用户余额扣除,请确认用户余额充足" style="margin-top:10px" />
<template #footer>
<el-button @click="refundDlg = false">取消</el-button>
<el-button type="danger" @click="doRefund">确认退款</el-button>
</template>
</el-dialog>
<!-- 调整余额 -->
<el-dialog v-model="adjustDlg" title="调整用户余额" width="400px">
<el-form label-width="80px">
<el-form-item label="OpenID"><el-input v-model="adjustForm.openid" placeholder="用户openid" /></el-form-item>
<el-form-item label="变动值">
<el-input-number v-model="adjustForm.amount" :min="-100000" :max="100000" />
<span style="margin-left:8px;font-size:12px;color:#999">正=赠送 负=扣减</span>
</el-form-item>
<el-form-item label="原因"><el-input v-model="adjustForm.reason" placeholder="如:活动赠送/异常补偿" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="adjustDlg = false">取消</el-button>
<el-button type="primary" @click="doAdjust">确认</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { apiGet, apiPost } from '../api'
const tab = ref('orders')
const stats = ref({})
const orders = ref([]), total = ref(0), page = ref(1), pageSize = 20, q = ref(''), statusFilter = ref(''), loading = ref(false)
const ledger = ref([]), ledgerTotal = ref(0), ledgerPage = ref(1), ledgerType = ref(''), ledgerQ = ref(''), ledgerLoading = ref(false)
const refundDlg = ref(false), refundTarget = ref(null)
const adjustDlg = ref(false), adjustForm = ref({ openid: '', amount: 100, reason: '' })
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—' }
async function loadStats() {
try { stats.value = await apiGet('/api/admin/vpay/stats') } catch (e) {}
}
async function load() {
loading.value = true
try {
const r = await apiGet(`/api/admin/vpay/orders?page=${page.value}&limit=${pageSize}&status=${statusFilter.value}&keyword=${encodeURIComponent(q.value)}`)
orders.value = r.items || []
total.value = r.pagination?.total || 0
} finally { loading.value = false }
}
async function loadLedger() {
ledgerLoading.value = true
try {
const r = await apiGet(`/api/admin/vpay/ledger?page=${ledgerPage.value}&limit=${pageSize}&type=${ledgerType.value}&keyword=${encodeURIComponent(ledgerQ.value)}`)
ledger.value = r.items || []
ledgerTotal.value = r.pagination?.total || 0
} finally { ledgerLoading.value = false }
}
function refund(row) { refundTarget.value = row; refundDlg.value = true }
async function doRefund() {
try {
await ElMessageBox.confirm('退款后无法撤销,确认执行?', '最终确认', { type: 'warning' })
await apiPost(`/api/admin/vpay/orders/${refundTarget.value._id}/refund`)
ElMessage.success('退款成功')
refundDlg.value = false
load(); loadStats()
} catch (e) { if (e !== 'cancel') ElMessage.error(e.message || '退款失败') }
}
async function doAdjust() {
try {
await apiPost('/api/admin/vpay/adjust', adjustForm.value)
ElMessage.success('调整成功')
adjustDlg.value = false
loadLedger(); loadStats()
} catch (e) { ElMessage.error(e.message || '调整失败') }
}
onMounted(() => { loadStats(); load(); loadLedger() })
</script>
<style scoped>
.page { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.stats-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin-bottom: 16px; }
.scard { background: #f8f9fb; border-radius: 10px; padding: 12px; display: flex; flex-direction: column; gap: 2px; }
.scard b { font-size: 20px; color: #1f2333; }
.scard span { font-size: 12px; color: #6b7080; }
.scard i { font-size: 11px; color: #a6abbb; font-style: normal; }
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
</style>