Files
medical-mall/pages/mall/analytics/product-insights.uvue
2026-01-30 16:11:23 +08:00

807 lines
21 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="page" @click="closeMoreMenu">
<!-- 固定顶部导航栏 -->
<AnalyticsTopBar
:title="'商品洞察'"
:lastUpdateTime="lastUpdateTime"
:sidebarVisible="showSidebarMenu"
@menu-click="handleMenu"
@refresh="refreshData"
@search="handleSearch"
@notification="handleNotification"
@fullscreen="handleFullscreen"
@mobile="handleMobile"
@dropdown="handleDropdown"
@settings="handleSettings"
/>
<view class="page-layout">
<!-- 侧边栏菜单组件 -->
<AnalyticsSidebarMenu
:visible="showSidebarMenu"
:currentPath="currentPath"
@visible-change="handleSidebarUpdate"
/>
<!-- 主内容区域 -->
<view class="main-content">
<view class="container">
<!-- 时间维度筛选 -->
<view class="tabs">
<view
v-for="p in timePeriods"
:key="p.value"
class="tab"
:class="{ active: selectedPeriod === p.value }"
@click="selectPeriod(p.value)"
>
{{ p.label }}
</view>
</view>
<!-- KPI 指标卡片 -->
<view class="kpi-grid">
<view class="kpi-card">
<text class="kpi-label">商品总数</text>
<text class="kpi-value">{{ formatInt(productData.total_products) }}</text>
<text class="kpi-meta">较上期:{{ formatPct(productData.product_growth) }}</text>
</view>
<view class="kpi-card">
<text class="kpi-label">热销商品</text>
<text class="kpi-value">{{ formatInt(productData.hot_products) }}</text>
<text class="kpi-meta">销量 > 100</text>
</view>
<view class="kpi-card">
<text class="kpi-label">库存周转率</text>
<text class="kpi-value">{{ formatPct(productData.turnover_rate) }}</text>
<text class="kpi-meta">较上期:{{ formatPct(productData.turnover_growth) }}</text>
</view>
<view class="kpi-card">
<text class="kpi-label">平均库存</text>
<text class="kpi-value">{{ formatInt(productData.avg_stock) }}</text>
<text class="kpi-meta">较上期:{{ formatPct(productData.stock_growth) }}</text>
</view>
</view>
<!-- 商品销售分析 -->
<view class="card card-full">
<view class="card-head">
<text class="card-title">商品销售分析</text>
<view class="card-head-right">
<select class="select" v-model="selectedProductId" @change="handleProductChange">
<option v-for="p in realTopProducts" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</view>
</view>
<view v-if="loading || !salesChartOption || !salesChartOption.series || salesChartOption.series.length === 0" class="chart-loading">
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
</view>
<EChartsView v-else class="chart-box" :option="salesChartOption" />
</view>
<!-- 第二行:分类 & 排行 -->
<view class="grid-row">
<!-- 商品分类分析 -->
<view class="card grid-col-item">
<view class="card-head">
<text class="card-title">商品分类分析</text>
<text class="card-desc">按分类统计销售额</text>
</view>
<view v-if="loading || !categoryChartOption || !categoryChartOption.series || categoryChartOption.series.length === 0" class="chart-loading chart-loading-sm">
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
</view>
<EChartsView v-else class="chart-box chart-box-sm" :option="categoryChartOption" />
</view>
<!-- 热销商品排行 -->
<view class="card grid-col-item">
<view class="card-head">
<text class="card-title">热销商品排行 TOP 10</text>
<text class="card-desc">按销量排序</text>
</view>
<view v-if="loading || topProducts.length === 0" class="chart-loading chart-loading-sm">
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
</view>
<view v-else class="rank-list-scroll">
<view class="rank-list">
<view v-for="p in topProducts" :key="p.id" class="rank-item">
<text class="rank-no">{{ p.rank }}</text>
<text class="rank-name">{{ p.name }}</text>
<view class="rank-right">
<text class="rank-val">{{ p.sales }} 件</text>
<text class="chip" :class="p.growth >= 0 ? 'pos' : 'neg'">
{{ p.growth >= 0 ? '+' : '' }}{{ p.growth }}%
</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 第三行:库存 & 价格 -->
<view class="grid-row">
<!-- 商品库存分析 -->
<view class="card grid-col-item">
<view class="card-head">
<text class="card-title">商品库存分析</text>
<text class="card-desc">库存分布情况</text>
</view>
<view v-if="loading || !stockChartOption || !stockChartOption.series || stockChartOption.series.length === 0" class="chart-loading chart-loading-sm">
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
</view>
<EChartsView v-else class="chart-box chart-box-sm" :option="stockChartOption" />
</view>
<!-- 商品价格趋势 -->
<view class="card grid-col-item">
<view class="card-head">
<text class="card-title">商品价格趋势</text>
<text class="card-desc">平均价格变化</text>
</view>
<view v-if="loading || !priceChartOption || !priceChartOption.series || priceChartOption.series.length === 0" class="chart-loading chart-loading-sm">
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
</view>
<EChartsView v-else class="chart-box chart-box-sm" :option="priceChartOption" />
</view>
</view>
<!-- 第四行:评价 -->
<view class="card card-full">
<view class="card-head">
<text class="card-title">商品评价分析</text>
<text class="card-desc">评分分布</text>
</view>
<view v-if="loading || !reviewChartOption || !reviewChartOption.series || reviewChartOption.series.length === 0" class="chart-loading">
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
</view>
<EChartsView v-else class="chart-box" :option="reviewChartOption" />
</view>
<!-- 留白 -->
<view style="height: 24px;"></view>
</view>
</view>
</view>
</view>
</template>
<script lang="uts">
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import { fetchProductOverview, fetchTopProducts, fetchProductTrend, fetchCategorySales, fetchStockInsights, fetchPriceTrend, fetchReviewInsights } from '@/services/analytics/productInsightsService.uts'
import { computeDateRange, toDateOnly } from '@/services/analytics/dateRange.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
type TimePeriod = { value: string; label: string }
type ProductData = {
total_products: number
product_growth: number
hot_products: number
turnover_rate: number
turnover_growth: number
avg_stock: number
stock_growth: number
}
type ProductRank = { id: string; rank: number; name: string; sales: number; growth: number }
type ProductTrendRow = { date: string; gmv: number; qty: number; orders: number }
export default {
components: {
AnalyticsSidebarMenu,
AnalyticsTopBar,
EChartsView
},
data() {
return {
lastUpdateTime: '',
selectedPeriod: '7d',
showMoreMenu: false,
showSidebarMenu: false,
currentPath: '/pages/mall/analytics/product-insights',
timePeriods: [
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
] as Array<TimePeriod>,
productData: {
total_products: 0,
product_growth: 0,
hot_products: 0,
turnover_rate: 0,
turnover_growth: 0,
avg_stock: 0,
stock_growth: 0
} as ProductData,
topProducts: [] as Array<ProductRank>,
salesChartOption: {} as any,
categoryChartOption: {} as any,
stockChartOption: {} as any,
priceChartOption: {} as any,
reviewChartOption: {} as any,
selectedProductId: '' as string,
loading: false
}
},
computed: {
selectedPeriodText(): string {
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
return p ? p.label : '7天'
},
realTopProducts(): Array<ProductRank> {
return this.topProducts.filter((p) => !String(p.id).startsWith('fake-product-'))
}
},
onLoad() {
this.updateTime()
this.loadProductData()
},
methods: {
async loadSelectedProductTrend() {
try {
if (this.selectedProductId == null || this.selectedProductId === '') {
this.salesChartOption = {}
return
}
const trend = await fetchProductTrend(this.selectedPeriod, this.selectedProductId)
const rows: Array<any> = trend as any
const x: Array<string> = []
const gmv: Array<number> = []
const qty: Array<number> = []
const orders: Array<number> = []
for (let i = 0; i < rows.length; i++) {
const d = `${rows[i].date}`
x.push(d.slice(5))
gmv.push(Number(rows[i].gmv) || 0)
qty.push(Number(rows[i].qty) || 0)
orders.push(Number(rows[i].orders) || 0)
}
// 组合图GMV左轴 + 件数/订单(线,右轴)
this.salesChartOption = {
grid: { left: 50, right: 50, top: 20, bottom: 46 },
tooltip: { trigger: 'axis' },
legend: { data: ['GMV', '件数', '订单数'], bottom: 0 },
xAxis: { type: 'category', data: x, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
yAxis: [
{ type: 'value', name: 'GMV', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
{ type: 'value', name: '件/单', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { show: false } }
],
series: [
{ name: 'GMV', type: 'bar', data: gmv, barWidth: 14, itemStyle: { borderRadius: 6 } },
{ name: '件数', type: 'line', yAxisIndex: 1, data: qty, smooth: true, symbolSize: 6 },
{ name: '订单数', type: 'line', yAxisIndex: 1, data: orders, smooth: true, symbolSize: 6 }
]
}
// 价格趋势:计算均价
const avgPrice: Array<number> = []
for (let i = 0; i < rows.length; i++) {
const g = Number(rows[i].gmv) || 0
const q = Number(rows[i].qty) || 0
avgPrice.push(q > 0 ? g / q : 0)
}
this.priceChartOption = {
grid: { left: 40, right: 18, top: 20, bottom: 40 },
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: x, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
yAxis: { type: 'value', name: '均价', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
series: [{ name: '均价', type: 'line', data: avgPrice, smooth: true, symbolSize: 6, color: '#f97316' }]
}
} catch (e) {
console.error('loadSelectedProductTrend failed', e)
this.salesChartOption = {}
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '加载商品趋势失败' }), icon: 'none' })
}
},
handleProductChange() {
this.loadSelectedProductTrend()
},
async loadProductData() {
this.loading = true
try {
this.updateTime()
const [overview, topList, catRows, stockRows, priceRows, reviewRows] = await Promise.all([
fetchProductOverview(this.selectedPeriod),
fetchTopProducts(this.selectedPeriod, 10),
fetchCategorySales(this.selectedPeriod),
fetchStockInsights(this.selectedPeriod),
fetchPriceTrend(this.selectedPeriod),
fetchReviewInsights()
])
this.productData = overview
// 不足 10 条时补齐虚拟数据(真实数据优先,虚拟数据追加)
const top = topList.slice()
if (top.length < 10) {
const need = 10 - top.length
for (let i = 0; i < need; i++) {
const n = top.length + 1
top.push({
id: `fake-product-${n}`,
rank: n,
name: `示例商品${n}`,
sales: Math.max(1, Math.floor(Math.random() * 50000) + 500),
growth: Math.round((Math.random() * 20 - 10) * 10) / 10
})
}
} else {
top.splice(10)
}
for (let i = 0; i < top.length; i++) top[i].rank = i + 1
this.topProducts = top
if ((this.selectedProductId == null || this.selectedProductId === '') && top.length > 0) {
const real = top.find((it) => !String(it.id).startsWith('fake-product-'))
this.selectedProductId = real ? real.id : ''
}
if (this.selectedProductId == null || this.selectedProductId === '') {
this.salesChartOption = {}
} else {
await this.loadSelectedProductTrend()
}
this.buildCategoryChart(catRows)
this.buildStockChart(stockRows)
// priceChartOption 在 loadSelectedProductTrend 里会生成均价趋势;这里仍保留整体价格趋势图(如果你有对应图表函数可以接入)
this.buildReviewChart(reviewRows)
this.updateTime()
} catch (e) {
console.error('loadProductData failed', e)
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '商品洞察数据加载失败' }), icon: 'none', duration: 2000 })
} finally {
this.loading = false
this.updateTime()
}
},
selectPeriod(p: string) {
this.selectedPeriod = p
this.loadProductData()
},
refreshData() {
this.loadProductData()
uni.showToast({ title: '已刷新', icon: 'success' })
},
exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
})
},
updateTime() {
const now = new Date()
const hh = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
this.lastUpdateTime = `${hh}:${mm}`
},
formatInt(n: number): string {
const v = isFinite(n) ? Math.round(n) : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toString()
},
formatPct(n: number): string {
const v = isFinite(n) ? n : 0
const sign = v > 0 ? '+' : ''
return `${sign}${v.toFixed(1)}%`
},
buildChartOptions() {
// TODO: 构建图表配置
this.salesChartOption = {}
this.categoryChartOption = {}
this.stockChartOption = {}
this.priceChartOption = {}
this.reviewChartOption = {}
},
handleMenu() {
this.showSidebarMenu = true
},
handleSidebarUpdate(visible: boolean) {
this.showSidebarMenu = visible
},
toggleMoreMenu() {
this.showMoreMenu = !this.showMoreMenu
},
closeMoreMenu() {
this.showMoreMenu = false
}
}
}
</script>
<style>
.page {
min-height: 100vh;
background: #f6f7fb;
}
/* 页面布局:宽屏时侧边栏+内容,窄屏时全屏内容 */
.page-layout {
display: flex;
flex-direction: row !important;
min-height: 100vh;
}
.main-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
padding-top: 64px; /* 为固定顶部导航栏留出空间 */
}
.container {
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 16px 16px 28px;
box-sizing: border-box;
}
/* 顶部栏 */
.topbar {
display: flex;
flex-direction: row !important;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: #fff;
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.06);
width: 100%;
box-sizing: border-box;
overflow: hidden;
}
.topbar-left {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.menu-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.menu-icon:active {
background: #e5e7eb;
transform: scale(0.95);
}
.menu-icon .icon {
font-size: 18px;
color: #111;
line-height: 1;
}
.title-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
min-width: 0;
}
.title {
font-size: 18px;
font-weight: 700;
color: #111;
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitle {
font-size: 12px;
color: rgba(0,0,0,0.55);
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topbar-right {
display: flex;
flex-direction: row !important;
gap: 8px;
align-items: center;
flex-wrap: nowrap;
flex-shrink: 0;
position: relative;
white-space: nowrap;
}
.icon-btn-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.icon-btn-icon:active {
background: #e5e7eb;
transform: scale(0.95);
}
.icon-btn-icon .icon {
font-size: 16px;
line-height: 1;
}
.more-btn {
display: none;
width: 32px;
height: 32px;
border-radius: 8px;
background: #f3f4f6;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
transition: all 0.2s;
flex-shrink: 0;
}
.more-btn.active {
background: #e5e7eb;
}
.more-btn .icon {
font-size: 18px;
line-height: 1;
color: #111;
}
/* 时间维度 tabs */
.tabs {
margin-top: 12px;
display: flex;
flex-direction: row !important;
gap: 8px;
padding: 8px;
background: #fff;
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.06);
overflow-x: auto;
flex-wrap: wrap;
justify-content: center;
}
.tab {
padding: 8px 12px;
border-radius: 999px;
background: #f3f4f6;
color: #111;
font-size: 13px;
white-space: nowrap;
flex-shrink: 0;
}
.tab.active {
background: #111;
color: #fff;
}
/* KPI 网格 */
.kpi-grid {
margin-top: 12px;
display: flex;
flex-direction: row !important;
flex-wrap: wrap;
gap: 12px;
}
.kpi-card {
background: #fff;
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.06);
padding: 14px;
box-sizing: border-box;
flex: 1 1 calc(50% - 6px);
min-width: 260px;
}
.kpi-label {
font-size: 12px;
color: rgba(0,0,0,0.55);
}
.kpi-value {
margin-top: 8px;
font-size: 22px;
font-weight: 800;
color: #111;
}
.kpi-meta {
margin-top: 8px;
font-size: 12px;
color: rgba(0,0,0,0.55);
}
/* 卡片 */
.card {
margin-top: 12px;
background: #fff;
border-radius: 16px;
border: 1px solid rgba(0,0,0,0.06);
box-shadow: 0 2px 10px rgba(0,0,0,0.04);
padding: 14px;
box-sizing: border-box;
}
.card-full {
width: 100%;
}
.card-head {
display: flex;
flex-direction: row !important;
align-items: baseline;
justify-content: space-between;
margin-bottom: 16px;
}
.card-title {
font-size: 14px;
font-weight: 600;
color: #111;
}
.card-desc {
font-size: 12px;
color: rgba(0,0,0,0.55);
}
.chart-box {
width: 100%;
height: 360px;
}
/* 排行列表 */
.rank-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.rank-item {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid rgba(0,0,0,0.06);
}
.rank-item:last-child {
border-bottom: none;
}
.rank-no {
width: 28px;
height: 28px;
border-radius: 999px;
background: rgba(0,0,0,0.06);
text-align: center;
line-height: 28px;
font-size: 12px;
flex: 0 0 auto;
}
.rank-name {
flex: 1;
font-size: 13px;
color: #111;
}
.rank-val {
font-size: 13px;
color: rgba(0,0,0,0.65);
}
.rank-right {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 8px;
}
.chip {
padding: 3px 8px;
border-radius: 999px;
font-size: 12px;
}
.chip.pos {
background: rgba(34,197,94,0.12);
color: #16a34a;
}
.chip.neg {
background: rgba(239,68,68,0.12);
color: #dc2626;
}
/* 响应式 */
@media screen and (min-width: 960px) {
.kpi-card {
flex: 1 1 calc(25% - 9px);
min-width: 200px;
}
}
@media screen and (max-width: 960px) {
.title,
.subtitle {
max-width: 200px;
}
.topbar-right .btn-hidden {
display: none !important;
}
.more-btn {
display: flex !important;
}
}
/* 响应式:窄屏时全屏显示 */
@media screen and (max-width: 959px) {
.page-layout {
flex-direction: column !important;
}
.main-content {
width: 100%;
}
}
</style>