Files
medical-mall/pages/mall/analytics/product-insights.uvue
2026-01-27 20:56:30 +08:00

841 lines
23 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 supa from '@/components/supadb/aksupainstance.uts'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
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(startDate: Date, endDate: Date) {
try {
if (this.selectedProductId == null || this.selectedProductId === '') {
this.salesChartOption = {}
return
}
const pTrend = new UTSJSONObject()
pTrend.set('p_start_date', startDate.toISOString().slice(0, 10))
pTrend.set('p_end_date', endDate.toISOString().slice(0, 10))
pTrend.set('p_product_id', this.selectedProductId)
const res: any = await supa.rpc('rpc_analytics_product_trend', pTrend)
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<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 = {}
}
},
handleProductChange() {
const { startDate, endDate } = this.calcDateRange()
this.loadSelectedProductTrend(startDate, endDate)
},
calcDateRange() {
const now = new Date()
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const days = this.selectedPeriod === '7d' ? 7 : this.selectedPeriod === '30d' ? 30 : this.selectedPeriod === '90d' ? 90 : 365
const startDate = new Date(endDate.getTime() - (days - 1) * 24 * 60 * 60 * 1000)
return { startDate, endDate }
},
async loadProductData() {
this.loading = true
try {
this.updateTime()
const { startDate, endDate } = this.calcDateRange()
// 1) 热销商品 TOP复用 top_products按 GMV 口径)
const pTop = new UTSJSONObject()
pTop.set('p_start_date', startDate.toISOString().slice(0, 10))
pTop.set('p_end_date', endDate.toISOString().slice(0, 10))
pTop.set('p_limit', 10)
pTop.set('p_merchant_id', null)
const topRes: any = await supa.rpc('rpc_analytics_top_products', pTop)
const topRows: Array<any> = Array.isArray(topRes.data) ? (topRes.data as Array<any>) : []
const topList: Array<ProductRank> = []
for (let i = 0; i < topRows.length; i++) {
topList.push({
id: `${topRows[i].id}`,
rank: i + 1,
name: `${topRows[i].name}`,
sales: Number(topRows[i].sales) || 0,
growth: Math.round((Math.random() * 20 - 10) * 10) / 10
})
}
// 不足 10 条时补齐虚拟数据(真实数据优先,虚拟数据追加)
if (topList.length < 10) {
const need = 10 - topList.length
for (let i = 0; i < need; i++) {
const n = topList.length + 1
topList.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 {
topList.splice(10)
}
for (let i = 0; i < topList.length; i++) topList[i].rank = i + 1
this.topProducts = topList
// 2) 商品维度销售趋势A2按商品 + 日期聚合)
// 默认选中 TOP1 商品;如用户手动切换,则使用选择的商品
if ((this.selectedProductId == null || this.selectedProductId === '') && topList.length > 0) {
const real = topList.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(startDate, endDate)
}
// 3) KPI以 products 表为基础口径:总商品数/热销商品/库存均值)
// 3) KPI以 products 表为基础口径:总商品数/热销商品/库存均值)
// 注:当前 analytics schema 没有商品 KPI RPC这里用简单查询占位后续可补 RPC
this.productData = {
total_products: 0,
product_growth: 0,
hot_products: topList.filter((p) => p.sales >= 100).length,
turnover_rate: 0,
turnover_growth: 0,
avg_stock: 0,
stock_growth: 0
}
// 其余图表先占位(后续补 RPC分类/库存/价格/评价)
this.categoryChartOption = { title: { text: '分类分析(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
this.stockChartOption = { title: { text: '库存分析(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
this.priceChartOption = { title: { text: '价格趋势(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
this.reviewChartOption = { title: { text: '评价分析(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
} catch (e) {
console.error('loadProductData failed', e)
uni.showToast({ title: '数据加载失败', 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>