feat: 完善订单定价与支付收益管理
Build and Deploy Vue3 / build (push) Successful in 1m44s
Build and Deploy Vue3 / deploy (push) Successful in 47s

This commit is contained in:
shiran
2026-07-20 18:33:46 +08:00
parent afc00141f7
commit c5d4dbc0db
11 changed files with 1600 additions and 98 deletions
+8
View File
@@ -7,6 +7,14 @@ export const getOrderList = (params) => {
export const getOrderDetail = (params) => {
return http2.get('/api/v1/admin/order/detail', {params: params})
}
/**计算新购订单价格 */
export const getOrderPrice = (data) => {
return http2.post('/api/v1/admin/order/price', data, {
headers:{
'Content-Type':'multipart/form-data'
}
})
}
/**删除订单 (未提供删除接口,暂时保留) */
/**删除订单 */
export const deleteOrder = (data) => {
+10
View File
@@ -13,3 +13,13 @@ export const updatePay007Config = (data) => {
}
})
}
/** 查询 007pay 订单 */
export const getPay007Orders = (params) => {
return http2.get('/api/v1/admin/pay007/orders', { params })
}
/** 查询 007pay 收益统计 */
export const getPay007Stats = (params) => {
return http2.get('/api/v1/admin/pay007/stats', { params })
}
+4
View File
@@ -69,6 +69,10 @@ export const menus = [
path: '/order/list',
title: '订单列表'
},
{
path: '/order/pay007-orders',
title: '007pay订单'
},
{
path: '/order/payment-config',
title: '支付配置'
+8
View File
@@ -314,6 +314,14 @@ const routes = [
title: '订单列表'
}
},
{
path: 'pay007-orders',
name: 'Pay007Orders',
component: () => import('../views/order/Pay007Orders.vue'),
meta: {
title: '007pay订单'
}
},
{
path: 'payment-config',
name: 'PaymentConfig',
+715 -6
View File
@@ -28,6 +28,77 @@
</el-col>
</el-row>
<!-- 007pay 收益计算 -->
<el-row :gutter="24" class="revenue-row">
<el-col :xs="24" :sm="24" :md="24" :lg="16" :xl="16">
<el-card class="revenue-card" shadow="hover" v-loading="revenueLoading">
<div class="revenue-header">
<div>
<div class="revenue-eyebrow">007pay Revenue</div>
<h3>收益计算中心</h3>
<p>日维度与月维度的到账退款手续费净收益联动对比</p>
</div>
<el-button circle plain :loading="revenueLoading" @click="fetchRevenueStats">
<el-icon><Refresh /></el-icon>
</el-button>
</div>
<div class="revenue-kpis">
<div v-for="item in revenueMetricCards" :key="item.label" class="revenue-kpi" :class="item.accent">
<div class="kpi-label">{{ item.label }}</div>
<div class="kpi-value">{{ item.value }}</div>
<div class="kpi-footer">
<span>{{ item.compare }}</span>
<span :class="['kpi-change', item.changeClass]">{{ item.change }}</span>
</div>
<div class="kpi-detail">{{ item.detail }}</div>
</div>
</div>
<el-alert
v-if="revenueError"
type="warning"
:closable="false"
show-icon
class="revenue-alert"
:title="revenueError"
/>
<div ref="revenueChartRef" class="revenue-chart"></div>
</el-card>
</el-col>
<el-col :xs="24" :sm="24" :md="24" :lg="8" :xl="8">
<el-card class="revenue-side-card" shadow="hover" v-loading="revenueLoading">
<div class="side-header">
<div>
<h3>日月对比</h3>
<p>{{ revenueSnapshotText }}</p>
</div>
</div>
<div class="revenue-compare">
<div v-for="row in revenueCompareRows" :key="row.label" class="compare-row">
<div class="compare-main">
<span class="compare-label">{{ row.label }}</span>
<span class="compare-value">{{ row.value }}</span>
</div>
<div class="compare-sub">
<span>{{ row.subLabel }}</span>
<span>{{ row.subValue }}</span>
</div>
<div class="compare-track">
<div class="compare-bar" :style="{ width: row.percent + '%', background: row.color }"></div>
</div>
</div>
</div>
<div class="revenue-health">
<div v-for="item in revenueHealthItems" :key="item.label" class="health-item">
<div class="health-value">{{ item.value }}</div>
<div class="health-label">{{ item.label }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 图表部分 -->
<!-- <el-row :gutter="24" class="chart-row">
<el-col :xs="24" :sm="24" :md="24" :lg="16" :xl="16">
@@ -339,7 +410,7 @@
</template>
<script setup>
import { ref, onMounted, watch, computed } from 'vue'
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import {
User, ShoppingCart, Money, DataAnalysis,
@@ -352,6 +423,7 @@ import Qrcode from '@/components/Qrcode.vue'
import {useUserStore} from "@/store/userStore.js";
import { getUserList } from '@/api/admin/user'
import { getOrderList } from '@/api/admin/order'
import { getPay007Stats } from '@/api/admin/pay007'
import { getTicketCount, getTickerList } from '@/api/ticket'
const userStore = useUserStore()
@@ -368,6 +440,268 @@ const recentOrders = ref([])
const recentTickets = ref([])
const listLoading = ref(false)
const emptyPay007Stats = () => ({
order_count: 0,
total_amount: 0,
total_fee: 0,
net_amount: 0,
total_order_count: 0,
paid_order_count: 0,
unpaid_order_count: 0,
refunded_order_count: 0,
closed_order_count: 0,
manual_order_count: 0,
received_amount: 0,
refund_amount: 0,
refund_count: 0
})
const revenueLoading = ref(false)
const revenueError = ref('')
const revenueChartRef = ref(null)
let revenueChart = null
const revenueStats = ref({
today: emptyPay007Stats(),
yesterday: emptyPay007Stats(),
thisMonth: emptyPay007Stats(),
lastMonth: emptyPay007Stats(),
daily: [],
monthly: []
})
const normalizePay007Stats = (data = {}) => {
const stats = emptyPay007Stats()
Object.keys(stats).forEach(key => {
stats[key] = Number(data?.[key]) || 0
})
if (!stats.order_count && stats.paid_order_count) {
stats.order_count = stats.paid_order_count
}
return stats
}
const padNumber = (num) => String(num).padStart(2, '0')
const formatApiDateTime = (date) => {
return [
date.getFullYear(),
padNumber(date.getMonth() + 1),
padNumber(date.getDate())
].join('-') + ' ' + [
padNumber(date.getHours()),
padNumber(date.getMinutes()),
padNumber(date.getSeconds())
].join(':')
}
const startOfDay = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0)
const endOfDay = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59)
const startOfMonth = (date) => new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0)
const endOfMonth = (date) => new Date(date.getFullYear(), date.getMonth() + 1, 0, 23, 59, 59)
const addDays = (date, days) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + days)
const addMonths = (date, months) => new Date(date.getFullYear(), date.getMonth() + months, 1)
const formatDayLabel = (date) => `${date.getMonth() + 1}/${date.getDate()}`
const formatMonthLabel = (date) => `${date.getFullYear()}.${padNumber(date.getMonth() + 1)}`
const centToYuan = (value) => (Number(value) || 0) / 100
const formatCurrency = (value) => `¥${centToYuan(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
const formatPercent = (value) => `${((Number(value) || 0) * 100).toFixed(1)}%`
const ratio = (part, total) => total > 0 ? Math.min(100, Math.max(0, Math.round((part / total) * 100))) : 0
const calcChange = (current, previous) => {
const c = Number(current) || 0
const p = Number(previous) || 0
if (p === 0) return c === 0 ? 0 : null
return ((c - p) / Math.abs(p)) * 100
}
const getChangeText = (current, previous) => {
const change = calcChange(current, previous)
if (change === null) return '新增'
if (Math.abs(change) < 0.05) return '持平'
return `${change > 0 ? '+' : ''}${change.toFixed(1)}%`
}
const getChangeClass = (current, previous, inverse = false) => {
const change = calcChange(current, previous)
if (change === null) return current > 0 ? (inverse ? 'down' : 'up') : 'flat'
if (Math.abs(change) < 0.05) return 'flat'
const positive = change > 0
return positive === !inverse ? 'up' : 'down'
}
const safePercent = (part, total) => {
if (!total) return '0.0%'
return `${((part / total) * 100).toFixed(1)}%`
}
const revenueMetricCards = computed(() => {
const { today, yesterday, thisMonth, lastMonth } = revenueStats.value
return [
{
label: '今日净收益',
value: formatCurrency(today.net_amount),
compare: '较昨日',
change: getChangeText(today.net_amount, yesterday.net_amount),
changeClass: getChangeClass(today.net_amount, yesterday.net_amount),
detail: `到账 ${formatCurrency(today.received_amount)} · ${today.paid_order_count}`,
accent: 'emerald'
},
{
label: '本月净收益',
value: formatCurrency(thisMonth.net_amount),
compare: '较上月',
change: getChangeText(thisMonth.net_amount, lastMonth.net_amount),
changeClass: getChangeClass(thisMonth.net_amount, lastMonth.net_amount),
detail: `手续费 ${formatCurrency(thisMonth.total_fee)} · 退款 ${formatCurrency(thisMonth.refund_amount)}`,
accent: 'blue'
},
{
label: '本月到账',
value: formatCurrency(thisMonth.received_amount),
compare: '较上月',
change: getChangeText(thisMonth.received_amount, lastMonth.received_amount),
changeClass: getChangeClass(thisMonth.received_amount, lastMonth.received_amount),
detail: `总订单 ${thisMonth.total_order_count} · 已支付 ${thisMonth.paid_order_count}`,
accent: 'violet'
},
{
label: '本月退款',
value: formatCurrency(thisMonth.refund_amount),
compare: '较上月',
change: getChangeText(thisMonth.refund_amount, lastMonth.refund_amount),
changeClass: getChangeClass(thisMonth.refund_amount, lastMonth.refund_amount, true),
detail: `退款 ${thisMonth.refund_count} 笔 · 关闭 ${thisMonth.closed_order_count}`,
accent: 'amber'
}
]
})
const revenueCompareRows = computed(() => {
const { today, yesterday, thisMonth, lastMonth } = revenueStats.value
return [
{
label: '今日净收益',
value: formatCurrency(today.net_amount),
subLabel: '昨日',
subValue: formatCurrency(yesterday.net_amount),
percent: ratio(today.net_amount, Math.max(today.net_amount, yesterday.net_amount)),
color: 'linear-gradient(90deg, #10b981, #34d399)'
},
{
label: '本月净收益',
value: formatCurrency(thisMonth.net_amount),
subLabel: '上月',
subValue: formatCurrency(lastMonth.net_amount),
percent: ratio(thisMonth.net_amount, Math.max(thisMonth.net_amount, lastMonth.net_amount)),
color: 'linear-gradient(90deg, #2563eb, #60a5fa)'
},
{
label: '本月手续费',
value: formatCurrency(thisMonth.total_fee),
subLabel: '费率',
subValue: safePercent(thisMonth.total_fee, thisMonth.received_amount),
percent: ratio(thisMonth.total_fee, thisMonth.received_amount),
color: 'linear-gradient(90deg, #f59e0b, #fbbf24)'
},
{
label: '本月退款',
value: formatCurrency(thisMonth.refund_amount),
subLabel: '退款率',
subValue: safePercent(thisMonth.refund_amount, thisMonth.total_amount || thisMonth.received_amount),
percent: ratio(thisMonth.refund_amount, thisMonth.total_amount || thisMonth.received_amount),
color: 'linear-gradient(90deg, #ef4444, #fb7185)'
}
]
})
const revenueHealthItems = computed(() => {
const month = revenueStats.value.thisMonth
return [
{ label: '净收益率', value: formatPercent(month.received_amount ? month.net_amount / month.received_amount : 0) },
{ label: '支付完成率', value: formatPercent(month.total_order_count ? month.paid_order_count / month.total_order_count : 0) },
{ label: '手工入账', value: `${month.manual_order_count}` },
{ label: '待支付', value: `${month.unpaid_order_count}` }
]
})
const revenueSnapshotText = computed(() => {
const month = revenueStats.value.thisMonth
return `本月已支付 ${month.paid_order_count} 单,净收益 ${formatCurrency(month.net_amount)}`
})
const fetchPay007StatsRange = async (begin, end) => {
const res = await getPay007Stats({
begin: formatApiDateTime(begin),
end: formatApiDateTime(end)
})
if (res?.data?.code === 200) {
return normalizePay007Stats(res.data.data)
}
throw new Error(res?.data?.message || '007pay 收益统计获取失败')
}
const fetchRevenueStats = async () => {
revenueLoading.value = true
revenueError.value = ''
const todayBase = new Date()
const yesterdayBase = addDays(todayBase, -1)
const lastMonthBase = addMonths(todayBase, -1)
let hasError = false
const safeFetch = async (begin, end) => {
try {
return await fetchPay007StatsRange(begin, end)
} catch (error) {
if (!hasError) {
console.warn('获取 007pay 收益统计失败:', error)
}
hasError = true
return emptyPay007Stats()
}
}
try {
const dailyRanges = Array.from({ length: 7 }, (_, index) => {
const date = addDays(todayBase, index - 6)
return { label: formatDayLabel(date), begin: startOfDay(date), end: endOfDay(date) }
})
const monthlyRanges = Array.from({ length: 6 }, (_, index) => {
const date = addMonths(todayBase, index - 5)
return { label: formatMonthLabel(date), begin: startOfMonth(date), end: endOfMonth(date) }
})
const [
today,
yesterday,
thisMonth,
lastMonth,
dailyStats,
monthlyStats
] = await Promise.all([
safeFetch(startOfDay(todayBase), endOfDay(todayBase)),
safeFetch(startOfDay(yesterdayBase), endOfDay(yesterdayBase)),
safeFetch(startOfMonth(todayBase), endOfDay(todayBase)),
safeFetch(startOfMonth(lastMonthBase), endOfMonth(lastMonthBase)),
Promise.all(dailyRanges.map(async item => ({ ...item, stats: await safeFetch(item.begin, item.end) }))),
Promise.all(monthlyRanges.map(async item => ({ ...item, stats: await safeFetch(item.begin, item.end) })))
])
revenueStats.value = {
today,
yesterday,
thisMonth,
lastMonth,
daily: dailyStats,
monthly: monthlyStats
}
revenueError.value = hasError ? '007pay 收益统计暂不可用,请检查支付配置或稍后刷新。' : ''
} finally {
revenueLoading.value = false
updateRevenueChart()
}
}
//
const statisticsCards = computed(() => [
{
@@ -618,19 +952,159 @@ const getPriorityType = (priority) => {
}
}
const updateRevenueChart = async () => {
await nextTick()
if (!revenueChartRef.value) return
if (!revenueChart) {
revenueChart = echarts.init(revenueChartRef.value)
}
const daily = revenueStats.value.daily
const monthly = revenueStats.value.monthly
const dayLabels = daily.map(item => item.label)
const monthLabels = monthly.map(item => item.label)
const toYuanSeries = (items, key) => items.map(item => Number(centToYuan(item.stats?.[key]).toFixed(2)))
revenueChart.setOption({
color: ['#2563eb', '#10b981', '#f59e0b', '#7c3aed'],
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
backgroundColor: 'rgba(255,255,255,0.96)',
borderColor: '#e5e7eb',
borderWidth: 1,
textStyle: { color: '#111827' },
formatter(params) {
const title = params?.[0]?.axisValue || ''
const lines = params.map(item => {
return `${item.marker}${item.seriesName}:¥${Number(item.value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
})
return [title, ...lines].join('<br/>')
}
},
legend: {
top: 0,
right: 8,
itemWidth: 10,
itemHeight: 10,
textStyle: { color: '#6b7280' }
},
grid: [
{ left: 48, right: 24, top: 52, height: 142 },
{ left: 48, right: 24, top: 248, height: 132 }
],
xAxis: [
{
type: 'category',
gridIndex: 0,
data: dayLabels,
axisTick: { show: false },
axisLine: { lineStyle: { color: '#e5e7eb' } },
axisLabel: { color: '#6b7280' }
},
{
type: 'category',
gridIndex: 1,
data: monthLabels,
axisTick: { show: false },
axisLine: { lineStyle: { color: '#e5e7eb' } },
axisLabel: { color: '#6b7280' }
}
],
yAxis: [
{
type: 'value',
gridIndex: 0,
splitLine: { lineStyle: { color: '#eef2f7' } },
axisLabel: { color: '#6b7280', formatter: value => `¥${value}` }
},
{
type: 'value',
gridIndex: 1,
splitLine: { lineStyle: { color: '#eef2f7' } },
axisLabel: { color: '#6b7280', formatter: value => `¥${value}` }
}
],
graphic: [
{ type: 'text', left: 4, top: 24, style: { text: '最近 7 日', fill: '#111827', fontSize: 13, fontWeight: 600 } },
{ type: 'text', left: 4, top: 220, style: { text: '最近 6 月', fill: '#111827', fontSize: 13, fontWeight: 600 } }
],
series: [
{
name: '日到账',
type: 'bar',
xAxisIndex: 0,
yAxisIndex: 0,
barWidth: 14,
itemStyle: { borderRadius: [6, 6, 0, 0] },
data: toYuanSeries(daily, 'received_amount')
},
{
name: '日净收益',
type: 'line',
xAxisIndex: 0,
yAxisIndex: 0,
smooth: true,
symbolSize: 7,
lineStyle: { width: 3 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(16,185,129,0.22)' },
{ offset: 1, color: 'rgba(16,185,129,0.02)' }
])
},
data: toYuanSeries(daily, 'net_amount')
},
{
name: '月到账',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
barWidth: 18,
itemStyle: { borderRadius: [7, 7, 0, 0] },
data: toYuanSeries(monthly, 'received_amount')
},
{
name: '月净收益',
type: 'line',
xAxisIndex: 1,
yAxisIndex: 1,
smooth: true,
symbolSize: 7,
lineStyle: { width: 3 },
data: toYuanSeries(monthly, 'net_amount')
}
]
}, true)
}
const handleDashboardResize = () => {
salesChart?.resize()
customerChart?.resize()
revenueChart?.resize()
}
onMounted(() => {
//
fetchStatistics()
fetchRecentLists()
fetchRevenueStats()
initSalesChart()
initCustomerChart()
//
window.addEventListener('resize', () => {
salesChart?.resize()
customerChart?.resize()
})
window.addEventListener('resize', handleDashboardResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleDashboardResize)
salesChart?.dispose()
customerChart?.dispose()
revenueChart?.dispose()
salesChart = null
customerChart = null
revenueChart = null
})
//
@@ -924,6 +1398,220 @@ watch(salesRange, (newVal) => {
transition: width 0.8s ease;
}
.revenue-row {
margin-bottom: 24px;
}
.revenue-card,
.revenue-side-card {
height: 100%;
border: 1px solid #e5e7eb;
overflow: hidden;
}
.revenue-card :deep(.el-card__body),
.revenue-side-card :deep(.el-card__body) {
padding: 0;
}
.revenue-header,
.side-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 22px 24px 18px;
border-bottom: 1px solid #eef2f7;
background: #fbfcff;
}
.revenue-eyebrow {
font-size: 12px;
font-weight: 700;
color: #2563eb;
text-transform: uppercase;
margin-bottom: 6px;
}
.revenue-header h3,
.side-header h3 {
margin: 0 0 6px;
font-size: 20px;
color: #111827;
font-weight: 700;
}
.revenue-header p,
.side-header p {
margin: 0;
font-size: 13px;
color: #6b7280;
line-height: 1.5;
}
.revenue-kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
padding: 18px 20px 8px;
}
.revenue-kpi {
min-height: 132px;
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
position: relative;
overflow: hidden;
}
.revenue-kpi::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: #2563eb;
}
.revenue-kpi.emerald::before { background: #10b981; }
.revenue-kpi.blue::before { background: #2563eb; }
.revenue-kpi.violet::before { background: #7c3aed; }
.revenue-kpi.amber::before { background: #f59e0b; }
.kpi-label {
color: #6b7280;
font-size: 13px;
margin-bottom: 8px;
}
.kpi-value {
font-size: 24px;
color: #111827;
font-weight: 800;
line-height: 1.15;
}
.kpi-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 10px;
color: #8a95a3;
font-size: 12px;
}
.kpi-change {
font-weight: 700;
}
.kpi-change.up { color: #059669; }
.kpi-change.down { color: #dc2626; }
.kpi-change.flat { color: #64748b; }
.kpi-detail {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed #e5e7eb;
color: #6b7280;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.revenue-alert {
margin: 8px 20px 0;
}
.revenue-chart {
height: 420px;
padding: 12px 20px 22px;
}
.revenue-compare {
padding: 18px 20px 4px;
}
.compare-row {
padding: 14px 0;
border-bottom: 1px solid #f1f5f9;
}
.compare-row:last-child {
border-bottom: none;
}
.compare-main,
.compare-sub {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.compare-label {
font-size: 13px;
color: #6b7280;
}
.compare-value {
font-size: 17px;
color: #111827;
font-weight: 700;
}
.compare-sub {
margin-top: 6px;
color: #94a3b8;
font-size: 12px;
}
.compare-track {
height: 7px;
margin-top: 10px;
background: #eef2f7;
border-radius: 999px;
overflow: hidden;
}
.compare-bar {
height: 100%;
border-radius: 999px;
transition: width 0.3s ease;
}
.revenue-health {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
padding: 16px 20px 20px;
background: #fbfcff;
border-top: 1px solid #eef2f7;
}
.health-item {
padding: 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.health-value {
font-size: 18px;
color: #111827;
font-weight: 800;
line-height: 1.2;
}
.health-label {
margin-top: 6px;
color: #6b7280;
font-size: 12px;
}
/* 图表样式 */
.chart-row {
margin-bottom: 24px;
@@ -1305,10 +1993,31 @@ watch(salesRange, (newVal) => {
gap: 12px;
}
@media (max-width: 1200px) {
.revenue-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.dashboard-container {
padding: 12px;
}
.revenue-header,
.side-header {
flex-direction: column;
}
.revenue-kpis,
.revenue-health {
grid-template-columns: 1fr;
}
.revenue-chart {
height: 360px;
padding: 8px 12px 18px;
}
.chart-overview {
flex-wrap: wrap;
@@ -1349,4 +2058,4 @@ watch(salesRange, (newVal) => {
font-size: 13px;
}
}
</style>
</style>
+272 -8
View File
@@ -453,6 +453,25 @@
<el-form-item label="订单名称" prop="name">
<el-input v-model="orderForm.name" placeholder="选择商品后自动生成,可手动修改" maxlength="200" show-word-limit />
</el-form-item>
<el-form-item v-if="orderForm.type === 'create'" label="购买时长" prop="pay_num">
<el-select
v-model="orderForm.pay_num"
filterable
allow-create
default-first-option
placeholder="请选择购买月数"
style="width: 100%"
@change="handleDurationChange"
>
<el-option
v-for="month in durationOptions"
:key="month"
:label="`${month} 个月`"
:value="month"
/>
</el-select>
<div v-if="maxPurchaseDuration" class="form-tip">产品首购上限{{ maxPurchaseDuration }} 个月</div>
</el-form-item>
<!-- 套餐固定参数只读展示 -->
<div v-if="planFixedArgs.length" class="args-section plan-fixed-args">
@@ -521,9 +540,39 @@
<!-- Step 3: 价格与支付 -->
<el-form v-show="currentStep === 2" ref="step3FormRef" :model="orderForm" :rules="step3Rules" label-width="110px">
<div v-if="isAutoPriceMode" class="price-quote-panel" v-loading="priceLoading">
<div class="price-quote-header">
<div class="price-quote-title">
<span>实时价格</span>
<el-tag v-if="isPriceManuallyAdjusted" type="warning" size="small">已手动调整</el-tag>
</div>
<el-button link type="primary" :loading="priceLoading" @click="fetchOrderPrice()">
<el-icon><Refresh /></el-icon>重新计算
</el-button>
</div>
<div v-if="orderPriceQuote" class="price-quote-grid">
<div class="price-quote-item">
<span class="price-quote-label">月基础价</span>
<strong>¥{{ formatYuan(orderPriceQuote.base_price) }}</strong>
</div>
<div class="price-quote-item">
<span class="price-quote-label">优惠前总价</span>
<strong>¥{{ formatYuan(orderPriceQuote.all_price) }}</strong>
</div>
<div class="price-quote-item is-primary">
<span class="price-quote-label">实际应付</span>
<strong>¥{{ formatYuan(orderPriceQuote.price) }}</strong>
</div>
<div class="price-quote-item">
<span class="price-quote-label">续费月价</span>
<strong>¥{{ formatYuan(orderPriceQuote.renew_price) }}</strong>
</div>
</div>
<div v-else class="price-quote-empty">等待报价</div>
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="价格" prop="price">
<el-form-item label="本次价格" prop="price">
<div class="unit-input-row">
<el-input-number v-model="orderForm.price" :min="0" :precision="0" style="flex:1" />
<span class="unit-text"></span>
@@ -531,7 +580,7 @@
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="续费价">
<el-form-item label="续费价">
<div class="unit-input-row">
<el-input-number v-model="orderForm.renew_price" :min="0" :precision="0" style="flex:1" />
<span class="unit-text"></span>
@@ -540,12 +589,12 @@
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-col v-if="orderForm.type !== 'create'" :span="12">
<el-form-item label="购买数量" prop="pay_num">
<el-input-number v-model="orderForm.pay_num" :min="1" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-col :span="orderForm.type === 'create' ? 24 : 12">
<el-form-item label="过期时间">
<el-date-picker v-model="orderForm.expire_time" type="datetime" placeholder="请选择" format="YYYY-MM-DD HH:mm:ss" value-format="x" style="width: 100%" />
</el-form-item>
@@ -568,6 +617,16 @@
<el-option label="微信(旧)" value="wx" />
</el-select>
</el-form-item>
<el-form-item v-if="Number(orderForm.state) === 1" label="收益入账">
<div class="account-amount-field">
<el-switch
v-model="orderForm.account_amount"
active-text="计入 007pay"
inactive-text="不计入"
/>
<div class="form-tip">用于管理员手动创建或改为已支付的订单后端会同步生成 007pay 手工入账记录</div>
</div>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="优惠码">
@@ -721,11 +780,11 @@
</template>
<script setup>
import { ref, reactive, onMounted, onActivated, watch } from 'vue'
import { ref, reactive, computed, onMounted, onActivated, onUnmounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Delete, Search, Download, Refresh, User, ShoppingCart, Ticket, Money, Close, Setting, Loading, Lock, Document, ArrowRight, Goods, Box } from '@element-plus/icons-vue'
import { getOrderList, getOrderDetail, createOrder, updateOrder, deleteOrder, retryOrderHook, refundOrder } from '@/api/admin/order'
import { getOrderList, getOrderDetail, getOrderPrice, createOrder, updateOrder, deleteOrder, retryOrderHook, refundOrder } from '@/api/admin/order'
import { getProductPlanList, getProductParameterList, getProductParameterDetail } from '@/api/admin/product'
import UserListSelector from '@/components/admin/UserListSelector.vue'
import ProductSelector from '@/components/admin/ProductSelector.vue'
@@ -765,6 +824,7 @@ const orderForm = reactive({
state: 0,
pay_type: 'default',
payment_order_id: '',
account_amount: false,
args: '',
note: ''
})
@@ -776,7 +836,8 @@ const step1Rules = {
}
const step2Rules = {
commodity_id: [{ required: true, message: '请选择商品', trigger: 'change' }],
name: [{ required: true, message: '请输入订单名称', trigger: 'blur' }]
name: [{ required: true, message: '请输入订单名称', trigger: 'blur' }],
pay_num: [{ required: true, message: '请选择购买时长', trigger: 'change' }]
}
const step3Rules = {
price: [{ required: true, message: '请输入价格', trigger: 'blur' }],
@@ -789,6 +850,8 @@ const submitLoading = ref(false)
const step1FormRef = ref(null)
const step2FormRef = ref(null)
const step3FormRef = ref(null)
const priceLoading = ref(false)
const orderPriceQuote = ref(null)
//
const planList = ref([])
@@ -827,6 +890,22 @@ const selectedProductInfo = ref(null)
const selectedDiscountCodeInfo = ref(null)
const selectedVoucherInfo = ref(null)
const isAutoPriceMode = computed(() => dialogType.value === 'add' && orderForm.type === 'create')
const maxPurchaseDuration = computed(() => Number(
selectedProductInfo.value?.maxFirstPurchaseDuration || selectedProductInfo.value?.max_first_purchase_duration || 0
))
const durationOptions = computed(() => {
const max = maxPurchaseDuration.value
if (max > 0 && max <= 60) return Array.from({ length: max }, (_, index) => index + 1)
const options = [1, 2, 3, 6, 12, 18, 24, 36]
return max > 0 ? options.filter(month => month <= max) : options
})
const isPriceManuallyAdjusted = computed(() => {
if (!orderPriceQuote.value) return false
return Number(orderForm.price) !== Number(orderPriceQuote.value.price)
|| Number(orderForm.renew_price) !== Number(orderPriceQuote.value.renew_price)
})
// 退
const refundForm = reactive({
order_id: undefined,
@@ -1028,6 +1107,7 @@ const handleCurrentChange = (page) => {
//
const handleAdd = () => {
resetOrderPriceQuote()
dialogType.value = 'add'
currentStep.value = 0
clearAllSelections()
@@ -1053,6 +1133,7 @@ const handleAdd = () => {
state: 0,
pay_type: 'default',
payment_order_id: '',
account_amount: false,
args: '',
note: ''
})
@@ -1077,6 +1158,7 @@ const handleView = async (rowOrId) => {
//
const handleEdit = (row) => {
resetOrderPriceQuote()
dialogType.value = 'edit'
currentStep.value = 0
clearAllSelections()
@@ -1110,6 +1192,7 @@ const handleEdit = (row) => {
state: row.state,
pay_type: row.payType || 'default',
payment_order_id: row.paymentOrderId || '',
account_amount: false,
args: row.args || '',
note: row.note || ''
})
@@ -1307,11 +1390,21 @@ const handleBatchDelete = () => {
//
const handleTypeChange = (type) => {
resetOrderPriceQuote()
if (type === 'create') {
orderForm.table = 'good'
}
}
const handleDurationChange = (value) => {
let duration = Math.max(1, Math.floor(Number(value) || 1))
if (maxPurchaseDuration.value && duration > maxPurchaseDuration.value) {
duration = maxPurchaseDuration.value
ElMessage.warning(`该产品首购最多可选择 ${maxPurchaseDuration.value} 个月`)
}
orderForm.pay_num = duration
}
//
const handleNextStep = async () => {
if (currentStep.value === 0) {
@@ -1324,6 +1417,10 @@ const handleNextStep = async () => {
if (!formEl) return
const valid = await formEl.validate().catch(() => false)
if (!valid) return
if (isAutoPriceMode.value) {
const priced = await fetchOrderPrice()
if (!priced) return
}
}
currentStep.value++
}
@@ -1431,6 +1528,62 @@ const buildArgsJson = () => {
return args.length > 0 ? JSON.stringify(args) : ''
}
const getCurrentArgsJson = () => {
return productParams.value.length || planFixedArgs.value.length
? buildArgsJson()
: orderForm.args || ''
}
let priceRequestId = 0
const resetOrderPriceQuote = () => {
priceRequestId++
priceLoading.value = false
orderPriceQuote.value = null
}
const fetchOrderPrice = async () => {
if (!isAutoPriceMode.value || !orderForm.user_id || !orderForm.commodity_id) return false
const requestId = ++priceRequestId
priceLoading.value = true
orderPriceQuote.value = null
try {
const data = {
user_id: Number(orderForm.user_id),
commodity_id: Number(orderForm.commodity_id),
purchase_duration: Number(orderForm.pay_num),
plan_id: Number(orderForm.plan_id) || 0,
args: getCurrentArgsJson(),
user_coupon_id: Number(orderForm.user_coupon_id) || 0,
discount_code_id: Number(orderForm.discount_code_id) || 0
}
const res = await getOrderPrice(data)
if (requestId !== priceRequestId) return false
if (res.data.code !== 200) {
ElMessage.error(res.data.message || '订单价格计算失败')
return false
}
const quote = res.data.data || {}
orderPriceQuote.value = {
base_price: Number(quote.base_price) || 0,
all_price: Number(quote.all_price) || 0,
price: Number(quote.price) || 0,
renew_price: Number(quote.renew_price) || 0
}
orderForm.price = orderPriceQuote.value.price
orderForm.renew_price = orderPriceQuote.value.renew_price
return true
} catch (error) {
if (requestId === priceRequestId) {
ElMessage.error(error.response?.data?.message || '订单价格计算失败')
}
return false
} finally {
if (requestId === priceRequestId) priceLoading.value = false
}
}
const findMatchedAttr = (param, numValue) => {
if (!param.attrs || !param.attrs.length) return null
const sorted = [...param.attrs].sort((a, b) => (a.phase || 0) - (b.phase || 0))
@@ -1445,6 +1598,7 @@ const findMatchedAttr = (param, numValue) => {
//
const handlePlanChange = (planId) => {
resetOrderPriceQuote()
Object.keys(argValues).forEach(k => delete argValues[k])
if (!planId) {
@@ -1489,7 +1643,7 @@ const submitForm = async () => {
expireTimeStr = new Date(Number(orderForm.expire_time)).toISOString()
}
const argsStr = productParams.value.length ? buildArgsJson() : orderForm.args || ''
const argsStr = getCurrentArgsJson()
const submitData = {
name: orderForm.name,
@@ -1511,6 +1665,9 @@ const submitForm = async () => {
if (orderForm.discount_code_id) submitData.discount_code_id = Number(orderForm.discount_code_id)
if (orderForm.user_coupon_id) submitData.user_coupon_id = Number(orderForm.user_coupon_id)
if (orderForm.payment_order_id) submitData.payment_order_id = orderForm.payment_order_id
if (Number(orderForm.state) === 1 && orderForm.account_amount) {
submitData.account_amount = true
}
if (dialogType.value === 'edit') {
submitData.order_id = Number(orderForm.order_id)
@@ -1541,11 +1698,13 @@ const submitForm = async () => {
const handleUserSelect = (user) => {
orderForm.user_id = user.user_id
selectedUserInfo.value = user
resetOrderPriceQuote()
}
const clearUser = () => {
orderForm.user_id = undefined
selectedUserInfo.value = null
resetOrderPriceQuote()
}
//
@@ -1554,6 +1713,8 @@ const handleProductSelect = (product) => {
selectedProductInfo.value = product
if (orderForm.type !== 'create' && product.table) orderForm.table = product.table
orderForm.plan_id = null
orderForm.pay_num = 1
resetOrderPriceQuote()
planFixedArgs.value = []
// -
const ts = new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '').replace(/\s/g, '-').replace(/:/g, '')
@@ -1572,6 +1733,7 @@ const clearProduct = () => {
planFixedArgs.value = []
productParams.value = []
allProductParams.value = []
resetOrderPriceQuote()
Object.keys(argValues).forEach(k => delete argValues[k])
}
@@ -1579,22 +1741,26 @@ const clearProduct = () => {
const handleDiscountCodeSelect = (code) => {
orderForm.discount_code_id = code.id
selectedDiscountCodeInfo.value = code
if (currentStep.value === 2 && isAutoPriceMode.value) fetchOrderPrice()
}
const clearDiscountCode = () => {
orderForm.discount_code_id = 0
selectedDiscountCodeInfo.value = null
if (currentStep.value === 2 && isAutoPriceMode.value) fetchOrderPrice()
}
//
const handleVoucherSelect = (voucher) => {
orderForm.user_coupon_id = voucher.id
selectedVoucherInfo.value = voucher
if (currentStep.value === 2 && isAutoPriceMode.value) fetchOrderPrice()
}
const clearVoucher = () => {
orderForm.user_coupon_id = 0
selectedVoucherInfo.value = null
if (currentStep.value === 2 && isAutoPriceMode.value) fetchOrderPrice()
}
//
@@ -1627,6 +1793,21 @@ onActivated(() => { checkAndOpenOrderId() })
watch(() => route.query.order_id, (newId) => {
if (newId) checkAndOpenOrderId()
})
watch(() => orderForm.state, (state) => {
if (Number(state) !== 1) {
orderForm.account_amount = false
}
})
let durationPriceTimer = null
watch(() => orderForm.pay_num, (value, oldValue) => {
if (value === oldValue || currentStep.value !== 2 || !isAutoPriceMode.value) return
clearTimeout(durationPriceTimer)
durationPriceTimer = setTimeout(() => fetchOrderPrice(), 300)
})
onUnmounted(() => clearTimeout(durationPriceTimer))
</script>
<style scoped>
@@ -1732,6 +1913,89 @@ watch(() => route.query.order_id, (newId) => {
line-height: 1.4;
}
.account-amount-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.price-quote-panel {
margin-bottom: 18px;
padding: 14px 16px;
border: 1px solid #d9e2ec;
border-left: 3px solid #409eff;
background: #f8fafc;
}
.price-quote-header,
.price-quote-title {
display: flex;
align-items: center;
}
.price-quote-header {
justify-content: space-between;
margin-bottom: 12px;
}
.price-quote-title {
gap: 8px;
font-size: 14px;
font-weight: 600;
color: #303133;
}
.price-quote-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.price-quote-item {
min-width: 0;
padding-right: 12px;
border-right: 1px solid #e4e7ed;
}
.price-quote-item:last-child {
padding-right: 0;
border-right: 0;
}
.price-quote-item strong {
display: block;
margin-top: 4px;
color: #303133;
font-size: 16px;
}
.price-quote-item.is-primary strong {
color: #d93f0b;
}
.price-quote-label,
.price-quote-empty {
color: #909399;
font-size: 12px;
}
.price-quote-empty {
min-height: 35px;
display: flex;
align-items: center;
}
@media (max-width: 760px) {
.price-quote-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.price-quote-item:nth-child(2) {
padding-right: 0;
border-right: 0;
}
}
.args-section {
margin-top: 16px;
padding: 16px;
+479
View File
@@ -0,0 +1,479 @@
<template>
<div class="pay007-orders-page">
<div class="page-toolbar">
<div class="title-block">
<div class="title-icon">
<el-icon><Money /></el-icon>
</div>
<div>
<h2>007pay 订单</h2>
<p>查看 007pay 平台订单到账金额手续费与手工入账记录</p>
</div>
</div>
<div class="toolbar-actions">
<el-button :loading="loading" @click="fetchOrders">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
<div class="summary-grid">
<div v-for="item in summaryCards" :key="item.label" class="summary-item" :class="item.accent">
<div class="summary-label">{{ item.label }}</div>
<div class="summary-value">{{ item.value }}</div>
<div class="summary-sub">{{ item.sub }}</div>
</div>
</div>
<div class="table-panel">
<div class="filter-section">
<el-form :inline="true" :model="queryParams" class="filter-form">
<el-form-item label="订单号">
<el-input
v-model.trim="queryParams.order_no"
placeholder="平台订单号/商户订单号"
clearable
style="width: 240px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="全部状态" clearable style="width: 140px">
<el-option label="待支付" :value="0" />
<el-option label="已支付" :value="1" />
<el-option label="已关闭" :value="2" />
<el-option label="已退款" :value="3" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">
<el-icon><Search /></el-icon>搜索
</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</div>
<el-table
v-loading="loading"
:data="orderList"
row-key="trade_no"
style="width: 100%"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
>
<el-table-column label="平台订单号" min-width="190" show-overflow-tooltip>
<template #default="{ row }">
<span class="mono">{{ row.trade_no || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="商户订单号" min-width="190" show-overflow-tooltip>
<template #default="{ row }">
<el-link
v-if="row.out_trade_no"
type="primary"
:underline="false"
class="mono"
@click.stop="goToLocalOrder(row.out_trade_no)"
>
{{ row.out_trade_no }}
</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="subject" label="订单标题" min-width="180" show-overflow-tooltip />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)" effect="plain">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="金额" width="120" align="right">
<template #default="{ row }">
<span class="amount-text">{{ formatCurrency(row.amount) }}</span>
</template>
</el-table-column>
<el-table-column label="手续费" width="120" align="right">
<template #default="{ row }">
<span>{{ formatCurrency(row.fee) }}</span>
</template>
</el-table-column>
<el-table-column label="净到账" width="120" align="right">
<template #default="{ row }">
<span class="net-text">{{ formatCurrency(getNetAmount(row)) }}</span>
</template>
</el-table-column>
<el-table-column label="客户端" width="100">
<template #default="{ row }">
<el-tag size="small" :type="row.client_type === 'mobile' ? 'success' : 'info'">
{{ getClientText(row.client_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="入账类型" width="110">
<template #default="{ row }">
<el-tag v-if="row.is_manual" size="small" type="warning" effect="dark">手工入账</el-tag>
<el-tag v-else size="small" effect="plain">在线支付</el-tag>
</template>
</el-table-column>
<el-table-column prop="channel_id" label="通道ID" width="90" />
<el-table-column prop="channel_trade_no" label="通道订单号" min-width="170" show-overflow-tooltip />
<el-table-column label="创建时间" width="170">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="支付时间" width="170">
<template #default="{ row }">
{{ formatDate(row.paid_at) }}
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.page_size"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
background
class="pagination"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Money, Refresh, Search } from '@element-plus/icons-vue'
import { getPay007Orders } from '@/api/admin/pay007'
const router = useRouter()
const loading = ref(false)
const orderList = ref([])
const total = ref(0)
const queryParams = reactive({
order_no: '',
status: '',
page: 1,
page_size: 20
})
const centToYuan = (value) => (Number(value) || 0) / 100
const formatCurrency = (value) => `¥${centToYuan(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
const getNetAmount = (row) => (Number(row?.amount) || 0) - (Number(row?.fee) || 0)
const pageStats = computed(() => {
return orderList.value.reduce((stats, row) => {
const amount = Number(row.amount) || 0
const fee = Number(row.fee) || 0
stats.amount += amount
stats.fee += fee
stats.net += amount - fee
if (Number(row.status) === 1) stats.paid += 1
if (row.is_manual) stats.manual += 1
return stats
}, { amount: 0, fee: 0, net: 0, paid: 0, manual: 0 })
})
const summaryCards = computed(() => [
{
label: '平台订单',
value: `${total.value}`,
sub: `当前页 ${orderList.value.length}`,
accent: 'blue'
},
{
label: '当前页到账',
value: formatCurrency(pageStats.value.amount),
sub: `已支付 ${pageStats.value.paid}`,
accent: 'green'
},
{
label: '当前页手续费',
value: formatCurrency(pageStats.value.fee),
sub: `净到账 ${formatCurrency(pageStats.value.net)}`,
accent: 'amber'
},
{
label: '手工入账',
value: `${pageStats.value.manual}`,
sub: '由后台手动记账产生',
accent: 'violet'
}
])
const getStatusText = (status) => {
const map = {
0: '待支付',
1: '已支付',
2: '已关闭',
3: '已退款'
}
return map[Number(status)] || '未知'
}
const getStatusType = (status) => {
const map = {
0: 'warning',
1: 'success',
2: 'info',
3: 'danger'
}
return map[Number(status)] || 'info'
}
const getClientText = (clientType) => {
const map = {
pc: 'PC',
mobile: '移动端'
}
return map[clientType] || clientType || '-'
}
const formatDate = (value) => {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString('zh-CN', { hour12: false })
}
const buildParams = () => {
const params = {
page: queryParams.page,
page_size: queryParams.page_size
}
if (queryParams.order_no) params.order_no = queryParams.order_no
if (queryParams.status !== '' && queryParams.status !== null && queryParams.status !== undefined) {
params.status = Number(queryParams.status)
}
return params
}
const fetchOrders = async () => {
loading.value = true
try {
const res = await getPay007Orders(buildParams())
if (res.data.code === 200) {
const data = res.data.data || {}
orderList.value = Array.isArray(data.list) ? data.list : []
total.value = Number(data.total) || 0
} else {
ElMessage.error(res.data.message || '获取 007pay 订单失败')
}
} catch (error) {
console.error('获取 007pay 订单失败:', error)
ElMessage.error(error.response?.data?.message || '获取 007pay 订单失败')
} finally {
loading.value = false
}
}
const handleQuery = () => {
queryParams.page = 1
fetchOrders()
}
const resetQuery = () => {
Object.assign(queryParams, {
order_no: '',
status: '',
page: 1,
page_size: 20
})
fetchOrders()
}
const handleSizeChange = (size) => {
queryParams.page_size = size
queryParams.page = 1
fetchOrders()
}
const handleCurrentChange = (page) => {
queryParams.page = page
fetchOrders()
}
const goToLocalOrder = (orderNo) => {
router.push({ path: '/order/list', query: { key: orderNo } })
}
onMounted(fetchOrders)
</script>
<style scoped>
.pay007-orders-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 18px 20px;
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 8px;
}
.title-block {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.title-icon {
width: 42px;
height: 42px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: #ecf5ff;
color: #409eff;
font-size: 22px;
flex-shrink: 0;
}
.title-block h2 {
margin: 0;
color: #1f2d3d;
font-size: 18px;
font-weight: 600;
}
.title-block p {
margin: 4px 0 0;
color: #909399;
font-size: 13px;
}
.toolbar-actions {
display: flex;
gap: 10px;
flex-shrink: 0;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.summary-item {
position: relative;
padding: 16px;
min-height: 112px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
overflow: hidden;
}
.summary-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: #409eff;
}
.summary-item.green::before { background: #67c23a; }
.summary-item.amber::before { background: #e6a23c; }
.summary-item.violet::before { background: #8b5cf6; }
.summary-label {
color: #909399;
font-size: 12px;
margin-bottom: 10px;
}
.summary-value {
color: #1f2d3d;
font-size: 24px;
font-weight: 700;
line-height: 1.2;
word-break: break-all;
}
.summary-sub {
margin-top: 10px;
color: #909399;
font-size: 12px;
}
.table-panel {
padding: 16px;
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 8px;
}
.filter-section {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.filter-form {
width: 100%;
}
.mono {
font-family: Consolas, Monaco, monospace;
}
.amount-text {
color: #409eff;
font-weight: 600;
}
.net-text {
color: #67c23a;
font-weight: 700;
}
.pagination {
margin-top: 18px;
justify-content: flex-end;
}
@media (max-width: 1200px) {
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.page-toolbar {
align-items: flex-start;
flex-direction: column;
}
.toolbar-actions {
width: 100%;
justify-content: flex-end;
}
.summary-grid {
grid-template-columns: 1fr;
}
.table-panel {
padding: 12px;
}
}
</style>
+26 -2
View File
@@ -258,6 +258,16 @@
<el-form-item v-if="renewForm.complete_pay" label="扣除用户余额">
<el-switch v-model="renewForm.deduct_balance" />
</el-form-item>
<el-form-item v-if="renewForm.complete_pay" label="收益入账">
<div class="account-amount-field">
<el-switch
v-model="renewForm.account_amount"
active-text="计入 007pay"
inactive-text="不计入"
/>
<div class="form-tip">仅记录 007pay 手工入账不等同于扣除用户余额</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="renewVisible = false">取消</el-button>
@@ -346,8 +356,10 @@ const formatExpireTime = (t) => {
}
const expireLabel = computed(() => formatExpireTime(detail.value?.expireTime || detail.value?.expire_time))
const isPending = computed(() => String(detail.value?.status || detail.value?.Status || '').toLowerCase() === 'pending')
const expireStatusText = computed(() => {
if (isPending.value) return '待开通'
const t = detail.value?.expireTime || detail.value?.expire_time
if (!t) return '未知'
const d = dayjs(t)
@@ -359,6 +371,7 @@ const expireStatusText = computed(() => {
})
const expireStatusClass = computed(() => {
if (isPending.value) return 'status-pending'
const t = detail.value?.expireTime || detail.value?.expire_time
if (!t) return ''
const d = dayjs(t)
@@ -452,14 +465,14 @@ const goToOrder = () => {
// ---- ----
const renewVisible = ref(false)
const renewLoading = ref(false)
const renewForm = reactive({ purchase_duration: 1, renew_price: 0, complete_pay: false, deduct_balance: false })
const renewForm = reactive({ purchase_duration: 1, renew_price: 0, complete_pay: false, deduct_balance: false, account_amount: false })
const renewTotalPrice = computed(() => {
const price = renewForm.renew_price || (detail.value?.renewPrice || detail.value?.renew_price || 0) / 100
return (price * renewForm.purchase_duration).toFixed(2)
})
const openRenew = () => {
const rawRenew = detail.value?.renewPrice || detail.value?.renew_price || 0
Object.assign(renewForm, { purchase_duration: 1, renew_price: rawRenew / 100, complete_pay: false, deduct_balance: false })
Object.assign(renewForm, { purchase_duration: 1, renew_price: rawRenew / 100, complete_pay: false, deduct_balance: false, account_amount: false })
renewVisible.value = true
}
const handleRenew = async () => {
@@ -469,6 +482,7 @@ const handleRenew = async () => {
if (renewForm.renew_price) data.renew_price = Math.round(renewForm.renew_price * 100)
if (renewForm.complete_pay) data.complete_pay = true
if (renewForm.complete_pay && renewForm.deduct_balance) data.deduct_balance = true
if (renewForm.complete_pay && renewForm.account_amount) data.account_amount = true
const res = await renewUserGoods(data)
if (res?.data?.code === 200) {
ElMessage.success('续费成功')
@@ -547,6 +561,13 @@ onActivated(() => {
watch(goodsId, (newId, oldId) => {
if (newId && newId !== oldId) { detail.value = null; loadDetail() }
})
watch(() => renewForm.complete_pay, (completePay) => {
if (!completePay) {
renewForm.deduct_balance = false
renewForm.account_amount = false
}
})
</script>
<style scoped>
@@ -587,6 +608,7 @@ watch(goodsId, (newId, oldId) => {
}
.meta-chip.id-chip { font-family: 'SF Mono', 'Consolas', monospace; letter-spacing: 0.3px; }
.meta-chip.status-ok { color: #52c41a; border-color: #b7eb8f; background: #f6ffed; }
.meta-chip.status-pending { color: #606266; border-color: #dcdfe6; background: #f4f4f5; }
.meta-chip.status-warning { color: #fa8c16; border-color: #ffd591; background: #fff7e6; }
.meta-chip.status-expired { color: #f5222d; border-color: #ffa39e; background: #fff1f0; }
.meta-chip.status-forever { color: #722ed1; border-color: #d3adf7; background: #f9f0ff; }
@@ -654,6 +676,8 @@ watch(goodsId, (newId, oldId) => {
.selector-row { display: flex; align-items: center; width: 100%; }
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
.account-amount-field { display: flex; flex-direction: column; gap: 4px; }
.form-tip { font-size: 12px; color: #909399; margin-top: 4px; line-height: 1.4; }
:deep(.el-descriptions__label) { font-weight: 500; color: #606266; }
</style>
+2 -1
View File
@@ -140,8 +140,8 @@
<el-tooltip v-if="isDeleted(row)" :content="`删除于 ${formatTime(row.deleteAt || row.DeleteAt || row.deleted_at)}`" placement="top">
<el-tag size="small" type="danger">已删除</el-tag>
</el-tooltip>
<el-tag v-else-if="isPending(row)" size="small" type="info">待开通</el-tag>
<el-tag v-else-if="isExpired(row)" size="small" type="warning">已到期</el-tag>
<el-tag v-else-if="row.status === 'pending'" size="small" type="info">待开通</el-tag>
<el-tag v-else size="small" type="success">正常</el-tag>
</template>
</el-table-column>
@@ -559,6 +559,7 @@ const formatExpireTime = (t) => {
// deleteAt
const isDeleted = (row) => !!(row?.deleteAt || row?.DeleteAt || row?.deleted_at)
const isPending = (row) => String(row?.status || row?.Status || '').toLowerCase() === 'pending'
//
const isExpired = (row) => {
+35 -80
View File
@@ -92,11 +92,6 @@
{{ formatDateTime(row.CreatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="warning" size="small" @click="handleRefund(row)">退款</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
@@ -131,6 +126,16 @@
<el-radio label="sub">减少</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="balanceForm.state === 'add'" label="收益入账">
<div class="account-amount-field">
<el-switch
v-model="balanceForm.account_amount"
active-text="计入 007pay"
inactive-text="不计入"
/>
<div class="form-tip">加款同时生成 007pay 手工入账记录用于收益统计</div>
</div>
</el-form-item>
<el-form-item label="变动金额" prop="price">
<el-input v-model.number="balanceForm.price" placeholder="请输入变动金额">
<template #prepend>¥</template>
@@ -180,28 +185,6 @@
</template>
</el-dialog>
<!-- 退款对话框 -->
<el-dialog v-model="refundDialogVisible" title="退款" width="500px">
<el-form ref="refundFormRef" :model="refundForm" :rules="refundRules" label-width="120px">
<el-form-item label="余额记录ID">
<el-input :value="refundForm.history_id" disabled />
</el-form-item>
<el-form-item label="订单ID" prop="order_id">
<el-input v-model="refundForm.order_id" :min="0" style="width: 100%" placeholder="请输入订单ID" />
</el-form-item>
<el-form-item label="退款方式" prop="bandwidth">
<el-radio-group v-model="refundForm.bandwidth">
<el-radio :label="false">退回余额</el-radio>
<el-radio :label="true">提现</el-radio>
</el-radio-group>
<div class="form-tip">退回余额将退款金额添加到对应余额提现将退款金额提现</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="refundDialogVisible = false">取消</el-button>
<el-button type="warning" @click="submitRefundForm" :loading="refundLoading">确认退款</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -210,7 +193,7 @@ import { ref, reactive, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Refresh, ArrowLeft } from '@element-plus/icons-vue'
import { getUserBalanceRecord, editUserBalance, addUserConsumption, getUserBalanceCount, getUserList, refundBalance } from '@/api/admin/user'
import { getUserBalanceRecord, editUserBalance, addUserConsumption, getUserBalanceCount, getUserList } from '@/api/admin/user'
const route = useRoute()
const router = useRouter()
@@ -246,11 +229,8 @@ const userList = ref([])
//
const balanceDialogVisible = ref(false)
const recordDialogVisible = ref(false)
const refundDialogVisible = ref(false)
const balanceFormRef = ref(null)
const recordFormRef = ref(null)
const refundFormRef = ref(null)
const refundLoading = ref(false)
//
const balanceForm = reactive({
@@ -258,7 +238,8 @@ const balanceForm = reactive({
balance_type: '',
price: 0,
state: 'add',
note: ''
note: '',
account_amount: false
})
const balanceRules = {
@@ -290,18 +271,6 @@ const recordRules = {
note: [{ required: true, message: '请输入备注', trigger: 'blur' }]
}
// 退
const refundForm = reactive({
history_id: 0,
order_id: 0,
bandwidth: false
})
const refundRules = {
order_id: [{ required: true, message: '请输入订单ID', trigger: 'blur' }],
bandwidth: [{ required: true, message: '请选择退款方式', trigger: 'change' }]
}
//
const currentBalanceDisplay = computed(() => {
const balance = balanceList.value.find(b => userBalance[b.type]?.type === balanceForm.balance_type)
@@ -445,7 +414,8 @@ const handleEditBalance = (balanceType) => {
balance_type: apiType,
price: 0,
state: 'add',
note: ''
note: '',
account_amount: false
})
balanceDialogVisible.value = true
}
@@ -471,7 +441,11 @@ const submitBalanceForm = () => {
balanceFormRef.value?.validate(async (valid) => {
if (valid) {
try {
const res = await editUserBalance(balanceForm)
const submitData = { ...balanceForm }
if (submitData.state !== 'add' || !submitData.account_amount) {
delete submitData.account_amount
}
const res = await editUserBalance(submitData)
if (res.data.code === 200) {
ElMessage.success('修改成功')
balanceDialogVisible.value = false
@@ -517,40 +491,6 @@ const handleRecordCurrentChange = () => {
fetchBalanceRecord()
}
// 退
const handleRefund = (row) => {
Object.assign(refundForm, {
history_id: row.Id,
order_id: String(row.OrderId) || 0,
bandwidth: false
})
refundDialogVisible.value = true
}
// 退
const submitRefundForm = () => {
refundFormRef.value?.validate(async (valid) => {
if (valid) {
refundLoading.value = true
try {
const res = await refundBalance(refundForm)
if (res.data.code === 200) {
ElMessage.success('退款成功')
refundDialogVisible.value = false
fetchUserBalance()
} else {
ElMessage.error(res.data.message || '退款失败')
}
} catch (error) {
console.error('退款错误:', error)
ElMessage.error('退款失败')
} finally {
refundLoading.value = false
}
}
})
}
//
onMounted(() => {
getUserListData()
@@ -573,6 +513,15 @@ watch(
},
{ immediate: true }
)
watch(
() => balanceForm.state,
(state) => {
if (state !== 'add') {
balanceForm.account_amount = false
}
}
)
</script>
<style scoped>
@@ -802,6 +751,12 @@ watch(
margin-top: 4px;
}
.account-amount-field {
display: flex;
flex-direction: column;
gap: 4px;
}
/* 响应式 */
@media (max-width: 1200px) {
.balance-cards {
+41 -1
View File
@@ -299,6 +299,7 @@
<el-table-column label="状态" min-width="90">
<template #default="{row}">
<el-tag v-if="isGoodsDeleted(row)" size="small" type="danger">已删除</el-tag>
<el-tag v-else-if="isGoodsPending(row)" size="small" type="info">待开通</el-tag>
<el-tag v-else-if="isGoodsExpired(row)" size="small" type="warning">已到期</el-tag>
<el-tag v-else size="small" type="success">正常</el-tag>
</template>
@@ -662,7 +663,7 @@
</template>
<script setup>
import { ref, reactive, onMounted, onActivated } from 'vue'
import { ref, reactive, onMounted, onActivated, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { baseURL } from '@/utils/request'
@@ -740,6 +741,7 @@ const ticketListPageSize = ref(10)
const userGoodsList = ref([])
// deleteAt
const isGoodsDeleted = (row) => !!(row?.deleteAt || row?.DeleteAt || row?.deleted_at)
const isGoodsPending = (row) => String(row?.status || row?.Status || '').toLowerCase() === 'pending'
//
const isGoodsExpired = (row) => {
const t = row?.expireTime || row?.expire_time
@@ -1814,6 +1816,38 @@ const loadUserData = async () => {
fetchActiveTabData();
}
const resetUserScopedData = () => {
userInfo.value = {}
currentAvatarUrl.value = ''
userBalanceList.value = []
Object.assign(userBalance, {
balance: 0,
frozen_balance: 0,
total_recharge: 0,
total_consume: 0
})
loginHistory.value = []
operationHistory.value = []
userOrderList.value = []
userTicketList.value = []
userGoodsList.value = []
userVoucherList.value = []
loginHistoryPage.value = 1
operationHistoryPage.value = 1
orderListPage.value = 1
ticketListPage.value = 1
goodsListPage.value = 1
voucherListPage.value = 1
loginHistoryTotal.value = 0
operationHistoryTotal.value = 0
orderListTotal.value = 0
ticketListTotal.value = 0
goodsListTotal.value = 0
voucherListTotal.value = 0
}
//
onMounted(() => {
if (route.query.user_id) {
@@ -1831,6 +1865,12 @@ onMounted(() => {
onActivated(() => {
loadUserData();
})
watch(() => route.query.user_id, (newUserId, oldUserId) => {
if (route.name !== 'UserDetail' || !newUserId || String(newUserId) === String(oldUserId || '')) return
resetUserScopedData()
loadUserData()
})
</script>
<style scoped>