diff --git a/pages/mall/analytics/product-insights.uvue b/pages/mall/analytics/product-insights.uvue
index 3c042504..ff3addcc 100644
--- a/pages/mall/analytics/product-insights.uvue
+++ b/pages/mall/analytics/product-insights.uvue
@@ -68,65 +68,95 @@
商品销售分析
- {{ selectedPeriodText }} · 销售额趋势
+
+
+
-
+
+ {{ loading ? '加载中...' : '暂无数据' }}
+
+
-
-
-
- 商品分类分析
- 按分类统计销售额
+
+
+
+
+
+ 商品分类分析
+ 按分类统计销售额
+
+
+ {{ loading ? '加载中...' : '暂无数据' }}
+
+
-
-
-
-
-
- 热销商品排行 TOP 10
- 按销量排序
-
-
-
- {{ p.rank }}
- {{ p.name }}
-
- {{ p.sales }} 件
-
- {{ p.growth >= 0 ? '+' : '' }}{{ p.growth }}%
-
+
+
+
+ 热销商品排行 TOP 10
+ 按销量排序
+
+
+ {{ loading ? '加载中...' : '暂无数据' }}
+
+
+
+
+ {{ p.rank }}
+ {{ p.name }}
+
+ {{ p.sales }} 件
+
+ {{ p.growth >= 0 ? '+' : '' }}{{ p.growth }}%
+
+
+
-
-
-
- 商品库存分析
- 库存分布情况
+
+
+
+
+
+ 商品库存分析
+ 库存分布情况
+
+
+ {{ loading ? '加载中...' : '暂无数据' }}
+
+
+
+
+
+
+
+ 商品价格趋势
+ 平均价格变化
+
+
+ {{ loading ? '加载中...' : '暂无数据' }}
+
+
-
-
+
-
- 商品价格趋势
- 平均价格变化
-
-
-
-
-
-
商品评价分析
评分分布
-
+
+ {{ loading ? '加载中...' : '暂无数据' }}
+
+
@@ -154,6 +184,7 @@ type ProductData = {
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: {
@@ -191,7 +222,9 @@ export default {
categoryChartOption: {} as any,
stockChartOption: {} as any,
priceChartOption: {} as any,
- reviewChartOption: {} as any
+ reviewChartOption: {} as any,
+ selectedProductId: '' as string,
+ loading: false
}
},
@@ -199,6 +232,9 @@ export default {
selectedPeriodText(): string {
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
return p ? p.label : '7天'
+ },
+ realTopProducts(): Array {
+ return this.topProducts.filter((p) => !String(p.id).startsWith('fake-product-'))
}
},
@@ -208,10 +244,168 @@ export default {
},
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 = Array.isArray(res.data) ? (res.data as Array) : []
+
+ const x: Array = []
+ const gmv: Array = []
+ const qty: Array = []
+ const orders: Array = []
+
+ 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 = []
+ 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() {
- // TODO: 实现商品数据加载
- this.updateTime()
- this.buildChartOptions()
+ 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 = Array.isArray(topRes.data) ? (topRes.data as Array) : []
+ const topList: Array = []
+ 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) {
diff --git a/pages/mall/analytics/user-analysis.uvue b/pages/mall/analytics/user-analysis.uvue
index f40035e8..63716b03 100644
--- a/pages/mall/analytics/user-analysis.uvue
+++ b/pages/mall/analytics/user-analysis.uvue
@@ -70,43 +70,64 @@
用户增长趋势
{{ selectedPeriodText }} · 新用户 vs 总用户
-
-
-
-
-
-
- 用户留存率
- 按留存天数统计
+
+ {{ loading ? '加载中...' : '暂无数据' }}
-
+
-
-
-
- 用户活跃度分析
- 日活跃、周活跃、月活跃
-
-
-
-
-
+
- 新老用户对比
- GMV、订单数、客单价对比
+ 用户洞察
+ 留存率 / 新老对比 / 活跃度 / 画像
+
+
+
+
+ 用户留存率
+ 按留存天数统计
+
+
+
+
+
+ 新老用户对比
+ GMV、订单数、客单价
+
+
+
+
+
+ 用户活跃度
+ 日活/周活/月活
+
+
+
+
+
+ 用户画像
+ 性别/年龄/地域
+
+
+
-
-
-
+
+
- 用户画像分析
- 性别、年龄、地域分布
+ 用户分群 & 流量来源
+ {{ selectedPeriodText }} · 分群占比 & 来源分布
+
+
+
+
+
+
+
+
-
@@ -169,7 +190,10 @@ export default {
retentionChartOption: {} as any,
activityChartOption: {} as any,
comparisonChartOption: {} as any,
- profileChartOption: {} as any
+ profileChartOption: {} as any,
+ segmentChartOption: {} as any,
+ trafficChartOption: {} as any,
+ loading: false
}
},
@@ -195,6 +219,7 @@ export default {
},
async loadUserData() {
+ this.loading = true
try {
this.updateTime()
const { startDate, endDate } = this.calcDateRange()
@@ -205,7 +230,7 @@ export default {
p.set('p_start_date', startStr)
p.set('p_end_date', endStr)
- // KPI(新 RPC)
+ // KPI(RPC)
const res: any = await supa.rpc('rpc_analytics_user_kpis', p)
const row = Array.isArray(res.data) && res.data.length > 0 ? res.data[0] : (res.data || {})
const safe = (v: any): number => {
@@ -223,7 +248,7 @@ export default {
repurchase_growth: safe(row.repurchase_growth)
}
- // 增长趋势(新 RPC)
+ // 增长趋势(RPC)
const tRes: any = await supa.rpc('rpc_analytics_user_growth_trend', p)
const rows: Array = Array.isArray(tRes.data) ? (tRes.data as Array) : []
const x: Array = []
@@ -236,7 +261,6 @@ export default {
totalArr.push(Number(rows[i].total_users) || 0)
}
- // 构建图表(先把“用户增长趋势”做成真实动态图)
this.growthChartOption = {
grid: { left: 40, right: 18, top: 20, bottom: 40 },
tooltip: { trigger: 'axis' },
@@ -249,14 +273,62 @@ export default {
]
}
- // 其余图表:先用“有文案的占位”避免空白(后续可按业务字段继续增强)
+ // 用户分群(RPC)
+ const sRes: any = await supa.rpc('rpc_analytics_user_segments', p)
+ const sRows: Array = Array.isArray(sRes.data) ? (sRes.data as Array) : []
+ const segData: Array = []
+ for (let i = 0; i < sRows.length; i++) {
+ const name = `${sRows[i].name}`
+ const value = Number(sRows[i].value) || 0
+ segData.push({ name, value })
+ }
+ this.segmentChartOption = {
+ tooltip: { trigger: 'item' },
+ legend: { top: 10, left: 'center', padding: [12, 0, 24, 0] },
+ series: [
+ {
+ name: '用户分群',
+ type: 'pie',
+ // 下移饼图,避免被上方 legend 遮挡标签
+ center: ['48%', '60%'],
+ radius: ['42%', '66%'],
+ avoidLabelOverlap: true,
+ label: { show: true, formatter: '{b}\n{d}%' },
+ labelLine: { length: 14, length2: 10 },
+ data: segData
+ }
+ ]
+ }
+
+ // 流量来源(RPC)
+ const t2Res: any = await supa.rpc('rpc_analytics_traffic_sources', p)
+ const tRows: Array = Array.isArray(t2Res.data) ? (t2Res.data as Array) : []
+ const srcNames: Array = []
+ const srcVals: Array = []
+ for (let i = 0; i < tRows.length; i++) {
+ const name = `${tRows[i].name}`
+ const value = Number(tRows[i].value) || 0
+ srcNames.push(name)
+ srcVals.push(value)
+ }
+ this.trafficChartOption = {
+ grid: { left: 60, right: 18, top: 20, bottom: 40 },
+ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
+ xAxis: { type: 'value', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
+ yAxis: { type: 'category', data: srcNames, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
+ series: [{ type: 'bar', data: srcVals, barWidth: 14, itemStyle: { borderRadius: 6 } }]
+ }
+
+ // 四宫格:先占位(后续补 RPC)
this.retentionChartOption = { title: { text: '留存率(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
this.activityChartOption = { title: { text: '活跃度(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
this.comparisonChartOption = { title: { text: '新老用户对比(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
this.profileChartOption = { title: { text: '用户画像(待接入:需要性别/年龄/地域字段)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
} catch (e) {
console.error('loadUserData failed', e)
+ uni.showToast({ title: '数据加载失败', icon: 'none', duration: 2000 })
} finally {
+ this.loading = false
this.updateTime()
}
},
@@ -297,8 +369,6 @@ export default {
return `${sign}${v.toFixed(1)}%`
},
- buildChartOptions() {},
-
handleMenu() {
this.showSidebarMenu = true
},
@@ -375,139 +445,6 @@ export default {
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;
@@ -614,27 +551,69 @@ export default {
height: 360px;
}
-/* 响应式 */
-@media screen and (min-width: 960px) {
- .kpi-card {
- flex: 1 1 calc(25% - 9px);
- min-width: 200px;
- }
+.chart-box-sm {
+ height: 240px;
+}
+
+.chart-loading {
+ width: 100%;
+ height: 320px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: rgba(0,0,0,0.45);
+ font-size: 14px;
+}
+
+.two-col {
+ display: flex;
+ flex-direction: row !important;
+ gap: 12px;
+}
+
+.two-col-item {
+ flex: 1;
+ min-width: 0;
+}
+
+.quad-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.quad-item {
+ border: 1px solid rgba(0,0,0,0.06);
+ border-radius: 14px;
+ padding: 12px;
+ box-sizing: border-box;
+}
+
+.sub-head {
+ display: flex;
+ flex-direction: row !important;
+ justify-content: space-between;
+ align-items: baseline;
+ margin-bottom: 8px;
+}
+
+.sub-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: #111;
+}
+
+.sub-desc {
+ font-size: 12px;
+ color: rgba(0,0,0,0.55);
}
@media screen and (max-width: 960px) {
- .title,
- .subtitle {
- max-width: 200px;
+ .two-col {
+ flex-direction: column !important;
}
-
- .topbar-right .btn-hidden {
- display: none !important;
- }
-
- .more-btn {
- display: flex !important;
+ .quad-grid {
+ grid-template-columns: 1fr;
}
}
-