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; } } -