529 lines
14 KiB
Plaintext
529 lines
14 KiB
Plaintext
<template>
|
|
<view class="product-statistic-page">
|
|
<!-- 商品概况头部 -->
|
|
<view class="page-header-row">
|
|
<view class="title-wrap">
|
|
<text class="page-title">商品概况</text>
|
|
<view class="info-icon">?</view>
|
|
</view>
|
|
<view class="header-right">
|
|
<view class="date-picker-wrap">
|
|
<text class="calendar-emoji">📅</text>
|
|
<text class="date-range">2026/01/04 - 2026/02/02</text>
|
|
</view>
|
|
<button class="btn-query">查询</button>
|
|
<button class="btn-export">导出</button>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- 统计指标网格 -->
|
|
<view class="stat-grid">
|
|
<view v-for="(item, index) in statItems" :key="index" class="stat-card">
|
|
<view class="stat-main">
|
|
<view class="icon-box" :style="{ backgroundColor: item.bgColor }">
|
|
<text class="stat-emoji">{{ item.emoji }}</text>
|
|
</view>
|
|
<view class="stat-content">
|
|
<text class="stat-label">{{ item.label }}</text>
|
|
<text class="stat-value">{{ item.value }}</text>
|
|
<view class="stat-compare">
|
|
<text class="compare-label">坏比增长:</text>
|
|
<text class="compare-val" :class="item.trendClass">
|
|
{{ item.compare }}
|
|
<text v-if="item.trend === 'up'" class="arrow">▲</text>
|
|
<text v-else-if="item.trend === 'down'" class="arrow">▼</text>
|
|
<text v-else>-</text>
|
|
</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- 图表卡片 -->
|
|
<view class="chart-card">
|
|
<view class="chart-header">
|
|
<view class="legend-wrap">
|
|
<view class="legend-item"><view class="dot purple"></view><text>商品浏览量</text></view>
|
|
<view class="legend-item"><view class="dot orange"></view><text>商品访客量</text></view>
|
|
<view class="legend-item"><view class="dot blue"></view><text>支付金额</text></view>
|
|
<view class="legend-item"><view class="dot green"></view><text>退款金额</text></view>
|
|
</view>
|
|
<view class="download-icon">
|
|
<text class="download-emoji">📥</text>
|
|
</view>
|
|
</view>
|
|
<view class="chart-main">
|
|
<EChartsView v-if="chartOption != null" :option="chartOption" class="main-chart" />
|
|
</view>
|
|
</view>
|
|
|
|
<!-- 商品排行 -->
|
|
<view class="ranking-card">
|
|
<view class="ranking-header">
|
|
<text class="ranking-title">商品排行</text>
|
|
<view class="ranking-filters">
|
|
<view class="mock-select-wrap">
|
|
<text class="select-val">浏览量</text>
|
|
<text class="select-arrow">▼</text>
|
|
</view>
|
|
<view class="date-picker-wrap">
|
|
<text class="calendar-emoji">📅</text>
|
|
<text class="date-range">2026/01/04 - 2026/02/02</text>
|
|
</view>
|
|
<button class="btn-query small">查询</button>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="ranking-table">
|
|
<view class="table-header">
|
|
<text class="th col-id">ID</text>
|
|
<text class="th col-img">商品图片</text>
|
|
<text class="th col-name">商品名称</text>
|
|
<text class="th col-num">浏览量</text>
|
|
<text class="th col-num">访客数</text>
|
|
<text class="th col-num">加购件数</text>
|
|
<text class="th col-num">下单件数</text>
|
|
<text class="th col-num">支付件数</text>
|
|
<text class="th col-num">支付金额</text>
|
|
<text class="th col-num">收藏数</text>
|
|
<text class="th col-num wide">访客-支付转化率(%)</text>
|
|
</view>
|
|
<view v-if="loading" class="table-loading" style="padding: 40px; text-align: center;">
|
|
<text>加载排行中...</text>
|
|
</view>
|
|
<view v-else-if="rankingList.length === 0" class="table-empty" style="padding: 40px; text-align: center;">
|
|
<text>暂无排行数据</text>
|
|
</view>
|
|
<view v-else v-for="(item, index) in rankingList" :key="index" class="table-row">
|
|
<text class="td col-id">{{ item.id }}</text>
|
|
<view class="td col-img">
|
|
<image class="product-img" :src="item.image" mode="aspectFill" />
|
|
</view>
|
|
<view class="td col-name">
|
|
<text class="product-name-txt">{{ item.name }}</text>
|
|
</view>
|
|
<text class="td col-num">{{ item.views }}</text>
|
|
<text class="td col-num">{{ item.visitors }}</text>
|
|
<text class="td col-num">{{ item.cart_count }}</text>
|
|
<text class="td col-num">{{ item.order_count }}</text>
|
|
<text class="td col-num">{{ item.pay_count }}</text>
|
|
<text class="td col-num">{{ item.pay_amount }}</text>
|
|
<text class="td col-num">{{ item.fav_count }}</text>
|
|
<text class="td col-num wide">{{ item.visitors > 0 ? (item.pay_count / item.visitors * 100).toFixed(2) : '0.00' }}%</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="uts">
|
|
import { ref, onMounted } from 'vue'
|
|
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
|
import { fetchAdminProductStats, fetchAdminProductTrend, fetchAdminProductRanking } from '@/services/admin/productService.uts'
|
|
|
|
const statItems = ref([
|
|
{ label: '商品浏览量', value: '0', compare: '0%', trend: 'none', trendClass: 'none-gray', bgColor: '#e6f7ff', emoji: '👁️', key: 'views' },
|
|
{ label: '商品访客量', value: '0', compare: '0%', trend: 'none', trendClass: 'none-gray', bgColor: '#f6ffed', emoji: '👤', key: 'visitors' },
|
|
{ label: '支付件数', value: '0', compare: '0%', trend: 'none', trendClass: 'none-gray', bgColor: '#fff7e6', emoji: '🛍️', key: 'pay_count' },
|
|
{ label: '支付金额', value: '0.00', compare: '0%', trend: 'none', trendClass: 'none-gray', bgColor: '#f9f0ff', emoji: '💰', key: 'pay_amount' },
|
|
{ label: '退款件数', value: '0', compare: '0%', trend: 'none', trendClass: 'none-gray', bgColor: '#e6f7ff', emoji: '🔄', key: 'refund_count' },
|
|
{ label: '退款金额', value: '0.00', compare: '0%', trend: 'none', trendClass: 'none-gray', bgColor: '#f6ffed', emoji: '💴', key: 'refund_amount' }
|
|
])
|
|
|
|
const rankingList = ref<Array<any>>([])
|
|
const chartOption = ref<any>({})
|
|
const loading = ref(false)
|
|
|
|
onMounted(() => {
|
|
loadAllData()
|
|
})
|
|
|
|
async function loadAllData() {
|
|
loading.value = true
|
|
const endTime = new Date().toISOString()
|
|
const startTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
|
|
|
|
try {
|
|
// 1. 加载核心指标
|
|
const stats = await fetchAdminProductStats(startTime, endTime)
|
|
if (stats != null) {
|
|
statItems.value.forEach(item => {
|
|
const val = stats[item.key as string]
|
|
item.value = typeof val === 'number' ? (item.key.includes('amount') ? val.toFixed(2) : String(val)) : String(val ?? '0')
|
|
})
|
|
}
|
|
|
|
// 2. 加载趋势图
|
|
const trendData = await fetchAdminProductTrend(startTime, endTime)
|
|
initChart(trendData)
|
|
|
|
// 3. 加载排行
|
|
const rankingData = await fetchAdminProductRanking(startTime, endTime, 'sales', 10)
|
|
rankingList.value = rankingData
|
|
} catch (e) {
|
|
uni.showToast({ title: '加载统计失败', icon: 'none' })
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function toPlainObject(obj: any): any {
|
|
if (obj == null) return null
|
|
if (typeof obj !== 'object') return obj
|
|
if (Array.isArray(obj)) {
|
|
return obj.map((item: any) : any => toPlainObject(item))
|
|
}
|
|
const plain: any = {}
|
|
const keys = Object.keys(obj)
|
|
for (let i = 0; i < keys.length; i++) {
|
|
const key = keys[i]
|
|
const value = obj[key]
|
|
if (typeof value === 'function' || key.startsWith('_') || key === 'toJSON') {
|
|
continue
|
|
}
|
|
if (value != null && typeof value === 'object') {
|
|
plain[key] = toPlainObject(value)
|
|
} else {
|
|
plain[key] = value
|
|
}
|
|
}
|
|
return plain
|
|
}
|
|
|
|
function initChart(data: any[]) {
|
|
const dates = data.map(item => item.date_group.substring(5))
|
|
const views = data.map(item => item.views)
|
|
const visitors = data.map(item => item.visitors)
|
|
const payAmounts = data.map(item => item.pay_amount)
|
|
const refundAmounts = data.map(item => item.refund_amount)
|
|
|
|
const option = {
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
backgroundColor: 'rgba(50, 50, 50, 0.7)',
|
|
padding: [10, 15],
|
|
textStyle: { color: '#fff' }
|
|
},
|
|
grid: {
|
|
left: '3%',
|
|
right: '4%',
|
|
bottom: '5%',
|
|
top: '10%',
|
|
containLabel: true
|
|
},
|
|
xAxis: {
|
|
type: 'category',
|
|
data: dates,
|
|
axisLine: { lineStyle: { color: '#f0f0f0' } },
|
|
axisLabel: { color: '#8c8c8c', fontSize: 11 },
|
|
axisTick: { show: false }
|
|
},
|
|
yAxis: [
|
|
{
|
|
type: 'value',
|
|
name: '金额',
|
|
splitLine: { lineStyle: { type: 'dashed', color: '#f0f0f0' } },
|
|
axisLabel: { color: '#8c8c8c' }
|
|
},
|
|
{
|
|
type: 'value',
|
|
name: '数量',
|
|
splitLine: { show: false },
|
|
axisLabel: { color: '#8c8c8c' }
|
|
}
|
|
],
|
|
series: [
|
|
{
|
|
name: '商品浏览量',
|
|
type: 'line',
|
|
yAxisIndex: 1,
|
|
smooth: true,
|
|
showSymbol: false,
|
|
itemStyle: { color: '#b37feb' },
|
|
data: views
|
|
},
|
|
{
|
|
name: '商品访客量',
|
|
type: 'line',
|
|
yAxisIndex: 1,
|
|
smooth: true,
|
|
showSymbol: false,
|
|
itemStyle: { color: '#ffbb96' },
|
|
data: visitors
|
|
},
|
|
{
|
|
name: '支付金额',
|
|
type: 'bar',
|
|
barWidth: '25%',
|
|
itemStyle: { color: '#1890ff' },
|
|
data: payAmounts
|
|
},
|
|
{
|
|
name: '退款金额',
|
|
type: 'bar',
|
|
barWidth: '25%',
|
|
itemStyle: { color: '#52c41a' },
|
|
data: refundAmounts
|
|
}
|
|
]
|
|
}
|
|
|
|
chartOption.value = toPlainObject(option)
|
|
}
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.product-statistic-page {
|
|
padding: 16px;
|
|
background-color: #f0f2f5;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.page-header-row {
|
|
display: flex;
|
|
flex-direction: row;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.title-wrap {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.page-title { font-size: 16px; font-weight: bold; color: #333; }
|
|
|
|
.info-icon {
|
|
width: 14px; height: 14px;
|
|
border-radius: 50%; border: 1px solid #999;
|
|
color: #999; font-size: 10px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
}
|
|
|
|
.header-right {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.date-picker-wrap {
|
|
background: #fff;
|
|
border: 1px solid #d9d9d9;
|
|
padding: 6px 12px;
|
|
border-radius: 4px;
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.calendar-emoji { font-size: 14px; }
|
|
.date-range { font-size: 14px; color: #595959; }
|
|
|
|
.btn-query { background: #1890ff; color: #fff; font-size: 14px; height: 32px; padding: 0 15px; border-radius: 4px; border: none; }
|
|
.btn-export { background: #1890ff; color: #fff; font-size: 14px; height: 32px; padding: 0 15px; border-radius: 4px; border: none; }
|
|
|
|
.stat-grid {
|
|
display: flex;
|
|
flex-direction: row;
|
|
flex-wrap: wrap;
|
|
gap: 16px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.stat-card {
|
|
width: calc(33.33% - 11px);
|
|
background: #fff;
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
}
|
|
|
|
.stat-main {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 16px;
|
|
}
|
|
|
|
.icon-box {
|
|
width: 48px; height: 48px;
|
|
border-radius: 50%;
|
|
display: flex; align-items: center; justify-content: center;
|
|
}
|
|
|
|
.stat-emoji { font-size: 24px; }
|
|
|
|
.stat-content {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.stat-label { font-size: 13px; color: #8c8c8c; margin-bottom: 4px; }
|
|
.stat-value { font-size: 24px; font-weight: bold; color: #262626; margin-bottom: 4px; }
|
|
|
|
.stat-compare {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
font-size: 12px;
|
|
}
|
|
.compare-label { color: #8c8c8c; }
|
|
.up-red { color: #ff4d4f; }
|
|
.down-green { color: #52c41a; }
|
|
.none-gray { color: #8c8c8c; }
|
|
.arrow { font-size: 10px; margin-left: 2px; }
|
|
|
|
.chart-card {
|
|
background: #fff;
|
|
border-radius: 8px;
|
|
padding: 24px;
|
|
}
|
|
|
|
.chart-header {
|
|
display: flex;
|
|
flex-direction: row;
|
|
justify-content: center;
|
|
align-items: center;
|
|
position: relative;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.legend-wrap {
|
|
display: flex;
|
|
flex-direction: row;
|
|
gap: 24px;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 8px;
|
|
text { font-size: 12px; color: #8c8c8c; }
|
|
}
|
|
|
|
.dot { width: 10px; height: 10px; border-radius: 2px; }
|
|
.purple { background-color: #b37feb; }
|
|
.orange { background-color: #ffbb96; }
|
|
.blue { background-color: #1890ff; }
|
|
.green { background-color: #52c41a; }
|
|
|
|
.download-icon {
|
|
position: absolute;
|
|
right: 0;
|
|
}
|
|
.download-emoji { font-size: 18px; opacity: 0.6; }
|
|
|
|
.chart-main {
|
|
height: 400px;
|
|
width: 100%;
|
|
}
|
|
.main-chart { width: 100%; height: 100%; }
|
|
|
|
/* 商品排行 */
|
|
.ranking-card {
|
|
background: #fff;
|
|
border-radius: 8px;
|
|
padding: 24px;
|
|
margin-top: 16px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.ranking-header {
|
|
display: flex;
|
|
flex-direction: row;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.ranking-title { font-size: 16px; font-weight: bold; color: #333; }
|
|
|
|
.ranking-filters {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.mock-select-wrap {
|
|
border: 1px solid #d9d9d9;
|
|
border-radius: 4px;
|
|
padding: 4px 12px;
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 8px;
|
|
min-width: 120px;
|
|
}
|
|
.select-val { font-size: 14px; color: #595959; flex: 1; }
|
|
.select-arrow { font-size: 10px; color: #bfbfbf; }
|
|
|
|
.btn-query.small { height: 32px; font-size: 13px; margin: 0; }
|
|
|
|
.ranking-table {
|
|
border: 1px solid #f0f0f0;
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.table-header {
|
|
display: flex;
|
|
flex-direction: row;
|
|
background-color: #e6f7ff;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
}
|
|
|
|
.th {
|
|
padding: 12px 8px;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: #595959;
|
|
text-align: center;
|
|
}
|
|
|
|
.table-row {
|
|
display: flex;
|
|
flex-direction: row;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
align-items: stretch;
|
|
}
|
|
.table-row:last-child { border-bottom: none; }
|
|
|
|
.td {
|
|
padding: 16px 8px;
|
|
font-size: 13px;
|
|
color: #262626;
|
|
text-align: center;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
/* 列宽度配置 */
|
|
.col-id { width: 60px; }
|
|
.col-img { width: 100px; }
|
|
.col-name { flex: 1; text-align: left; justify-content: flex-start; }
|
|
.col-num { width: 80px; }
|
|
.col-num.wide { width: 120px; }
|
|
|
|
.product-img {
|
|
width: 48px;
|
|
height: 48px;
|
|
background: #f5f5f5;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.product-name-txt {
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
color: #262626;
|
|
}
|
|
</style>
|
|
|