feat: 完整支付配套——CoinLedger流水模型+充值/消费/退款全链写流水+管理后台订单/余额/退款/调整/统计页+用户明细页+余额卡明细入口
This commit is contained in:
@@ -30,6 +30,7 @@ import Trends from './views/Trends.vue'
|
||||
import Quotas from './views/Quotas.vue'
|
||||
import Feedbacks from './views/Feedbacks.vue'
|
||||
import Logs from './views/Logs.vue'
|
||||
import VpayOrders from './views/VpayOrders.vue'
|
||||
|
||||
const token = ref(getToken())
|
||||
const view = ref('dashboard')
|
||||
@@ -42,6 +43,7 @@ const menus = [
|
||||
{ key: 'trends', label: '趋势管理', icon: '📈', comp: Trends },
|
||||
{ key: 'quotas', label: '问答额度', icon: '💬', comp: Quotas },
|
||||
{ key: 'feedbacks', label: '反馈管理', icon: '📝', comp: Feedbacks },
|
||||
{ key: 'vpay', label: '订单/余额', icon: '💰', comp: VpayOrders },
|
||||
{ key: 'logs', label: '访问日志', icon: '🗂️', comp: Logs },
|
||||
]
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user