Files
medical-mall/pages/mall/admin/product/product-statistics/index.uvue
2026-03-20 15:24:59 +08:00

548 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="admin-page">
<view class="admin-sections">
<!-- 商品概况头部 -->
<view class="admin-card page-header-row">
<view class="title-wrap">
<text class="page-title">商品概况</text>
<view class="info-icon">?</view>
</view>
<view class="header-right">
<AnalyticsDateRangePicker
:initialStartDate="startDate"
:initialEndDate="endDate"
@apply="onApplyRange"
@clear="onClearRange"
/>
<button class="btn-query" @click="onQuery">查询</button>
<button class="btn-export">导出</button>
</view>
</view>
<!-- 统计指标网格 (使用统一响应式网格) -->
<view class="kpi-grid">
<view v-for="(item, index) in statItems" :key="index" class="admin-card 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="admin-card 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="admin-card 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>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
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)
const startDate = ref<string>('')
const endDate = ref<string>('')
onMounted(() => {
// 默认最近 30 天(本地日期字符串)
const end = new Date()
const start = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
startDate.value = start.toISOString().substring(0, 10)
endDate.value = end.toISOString().substring(0, 10)
loadAllData()
})
function onApplyRange(payload: any) {
startDate.value = payload?.start ?? ''
endDate.value = payload?.end ?? ''
loadAllData()
}
function onClearRange() {
startDate.value = ''
endDate.value = ''
loadAllData()
}
function onQuery() {
loadAllData()
}
async function loadAllData() {
loading.value = true
const startTime = startDate.value ? (startDate.value + ' 00:00:00') : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
const endTime = endDate.value ? (endDate.value + ' 23:59:59') : new Date().toISOString()
// 各图表独立 try-catch单个接口失败如 404不影响其他区块展示
// 1. 加载核心指标
try {
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')
})
}
} catch (e) {
console.error('[product-stats] 核心指标加载失败', e)
}
// 2. 加载趋势图rpc_admin_product_trend 404 时返回空数组,图表为空但不崩溃)
try {
const trendData = await fetchAdminProductTrend(startTime, endTime)
initChart(trendData)
} catch (e) {
console.error('[product-stats] 趋势图加载失败', e)
}
// 3. 加载排行rpc_admin_product_ranking 404 时返回空数组)
try {
const rankingData = await fetchAdminProductRanking(startTime, endTime, 'sales', 10)
rankingList.value = rankingData
} catch (e) {
console.error('[product-stats] 排行加载失败', e)
} 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">
.page-header-row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.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 已废弃,由全局 kpi-grid 接管 */
.stat-card {
min-width: 0;
}
.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 {
}
.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>