feat: 完善订单定价与支付收益管理
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -69,6 +69,10 @@ export const menus = [
|
||||
path: '/order/list',
|
||||
title: '订单列表'
|
||||
},
|
||||
{
|
||||
path: '/order/pay007-orders',
|
||||
title: '007pay订单'
|
||||
},
|
||||
{
|
||||
path: '/order/payment-config',
|
||||
title: '支付配置'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user