Files
medical-mall/pages/mall/analytics/index.uvue
2026-01-22 21:15:02 +08:00

735 lines
19 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">
<view class="container">
<!-- 顶部头部(白底,横排按钮) -->
<view class="topbar">
<view class="topbar-left">
<text class="title">数据分析中心</text>
<text class="subtitle">最后更新:{{ lastUpdateTime }}</text>
</view>
<view class="topbar-right">
<view class="icon-btn" @click="refreshAll">刷新</view>
<view class="icon-btn primary" @click="exportReport">导出</view>
</view>
</view>
<!-- KPI宽屏 4列窄屏 2列 -->
<view class="kpi-grid">
<view class="kpi-card">
<text class="kpi-label">实时 GMV</text>
<text class="kpi-value">¥{{ formatMoney(realTime.gmv) }}</text>
<text class="kpi-meta">较昨日同刻:{{ formatPct(realTime.gmv_growth) }}</text>
</view>
<view class="kpi-card">
<text class="kpi-label">实时订单</text>
<text class="kpi-value">{{ formatInt(realTime.orders) }}</text>
<text class="kpi-meta">较昨日同刻:{{ formatPct(realTime.order_growth) }}</text>
</view>
<view class="kpi-card">
<text class="kpi-label">在线用户</text>
<text class="kpi-value">{{ formatInt(realTime.online_users) }}</text>
<text class="kpi-meta">最近 5 分钟</text>
</view>
<view class="kpi-card">
<text class="kpi-label">转化率</text>
<text class="kpi-value">{{ formatPct(realTime.conversion_rate) }}</text>
<text class="kpi-meta">较昨日同刻:{{ formatPct(realTime.conversion_growth) }}</text>
</view>
</view>
<!-- 时间维度:横排 -->
<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>
<!-- 核心趋势:占满横向(柱+折 组合图) -->
<view class="card card-full">
<view class="card-head">
<text class="card-title">核心趋势GMV / 订单数)</text>
<text class="card-desc">{{ selectedPeriodText }} · 柱GMV · 线:订单数</text>
</view>
<AnalyticsComboChart
:xLabels="trend.x"
:gmv="trend.gmv"
:orders="trend.orders"
:height="320"
/>
</view>
<!-- 用户结构:横向占满 -->
<view class="card fullwide">
<view class="card-head">
<text class="card-title">用户结构(环形图)</text>
<text class="card-desc">未消费 / 首购 / 复购 / 回流</text>
</view>
<EChartsView class="chart-box" :option="userSegmentOption" />
</view>
<!-- 洞察区:宽屏左右分栏,窄屏自动上下 -->
<view class="insights-row">
<!-- 左侧:大图表 -->
<view class="insights-left card">
<view class="card-head">
<text class="card-title">流量来源(条形)</text>
<text class="card-desc">占比%</text>
</view>
<EChartsView class="chart-box" :option="trafficBarOption" />
</view>
<!-- 右侧:两个小卡片纵向堆叠 -->
<view class="insights-right">
<view class="card">
<view class="card-head">
<text class="card-title">热销商品 TOP</text>
<text class="card-desc">按销量</text>
</view>
<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>
<text class="rank-val">{{ p.sales }} 件</text>
</view>
</view>
</view>
<view class="card">
<view class="card-head">
<text class="card-title">商家排行 TOP</text>
<text class="card-desc">按 GMV</text>
</view>
<view class="rank-list">
<view v-for="m in topMerchants" :key="m.id" class="rank-item">
<text class="rank-no">{{ m.rank }}</text>
<text class="rank-name">{{ m.name }}</text>
<view class="rank-right">
<text class="rank-val">¥{{ formatMoney(m.sales) }}</text>
<text class="chip" :class="m.growth >= 0 ? 'pos' : 'neg'">
{{ m.growth >= 0 ? '+' : '' }}{{ m.growth }}%
</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 留白 -->
<view style="height: 24px;"></view>
</view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
import AnalyticsComboChart from '@/components/analytics/AnalyticsComboChart.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
type TimePeriod = { value: string; label: string }
type TrendData = { x: Array<string>; gmv: Array<number>; orders: Array<number> }
type SegmentItem = { name: string; value: number }
type TrafficItem = { name: string; value: number }
export default {
components: {
AnalyticsComboChart,
EChartsView
},
data() {
return {
lastUpdateTime: '',
selectedPeriod: '7d',
timePeriods: [
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
] as Array<TimePeriod>,
realTime: {
gmv: 0,
gmv_growth: 0,
orders: 0,
order_growth: 0,
online_users: 0,
conversion_rate: 0,
conversion_growth: 0
},
trend: {
x: [] as Array<string>,
gmv: [] as Array<number>,
orders: [] as Array<number>
} as TrendData,
userSegments: [
{ name: '未消费用户', value: 72 },
{ name: '消费一次用户', value: 14 },
{ name: '留存客户', value: 9 },
{ name: '回流客户', value: 5 }
] as Array<SegmentItem>,
trafficSources: [
{ name: '直接访问', value: 45 },
{ name: '搜索引擎', value: 28 },
{ name: '社交媒体', value: 18 },
{ name: '广告推广', value: 9 }
] as Array<TrafficItem>,
topProducts: [
{ id: '1', rank: 1, name: '苹果 iPhone 15', sales: 580 },
{ id: '2', rank: 2, name: '华为 Mate 60', sales: 456 },
{ id: '3', rank: 3, name: '小米 14 Pro', sales: 389 }
],
topMerchants: [
{ id: '1', rank: 1, name: '华强北电子城', sales: 580000, growth: 15.6 },
{ id: '2', rank: 2, name: '时尚服装馆', sales: 456000, growth: 12.3 },
{ id: '3', rank: 3, name: '美食天地', sales: 389000, growth: -2.1 }
],
// 图表 options
trafficBarOption: {} as any,
userSegmentOption: {} as any
}
},
computed: {
selectedPeriodText(): string {
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
return p ? p.label : '7天'
}
},
watch: {
trafficSources: {
handler() {
this.buildChartOptions()
},
deep: true
},
userSegments: {
handler() {
this.buildChartOptions()
},
deep: true
}
},
onLoad() {
this.refreshAll()
this.buildChartOptions()
},
methods: {
async refreshAll() {
this.updateTime()
await this.loadRealTime()
this.mockTrend() // 先给你可视化效果(你再替换成真实查询)
this.updateTime()
uni.showToast({ title: '已刷新', icon: 'success' })
},
selectPeriod(p: string) {
this.selectedPeriod = p
this.mockTrend()
},
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}`
},
// 组合趋势:先模拟(你可以换成 supa 查询 + 聚合)
mockTrend() {
const days = this.selectedPeriod === '7d' ? 7 : this.selectedPeriod === '30d' ? 30 : this.selectedPeriod === '90d' ? 90 : 12
const x: Array<string> = []
const gmv: Array<number> = []
const orders: Array<number> = []
for (let i = 0; i < days; i++) {
x.push(days === 12 ? `${i + 1}月` : `${i + 1}`)
// 假数据:你替换为真实聚合
const base = 80000 + i * 1200 + Math.round(Math.random() * 8000)
gmv.push(base)
orders.push(120 + i * 2 + Math.round(Math.random() * 30))
}
this.trend = { x, gmv, orders }
},
// 实时指标:核心是"强制数值化 + 兜底",避免对象直接渲染
async loadRealTime() {
try {
// 你可以把你原来的 supa 查询搬进来,这里只做"数值兜底示例"
// 注意:任何可能不是 number 的结果,都用 Number(...) 和 isFinite 处理
const safe = (v: any): number => {
const n = Number(v)
return isFinite(n) ? n : 0
}
const now = new Date()
const today0 = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const todayISO = today0.toISOString()
const ySame = new Date(now.getTime() - 24 * 60 * 60 * 1000)
const y0 = new Date(ySame.getFullYear(), ySame.getMonth(), ySame.getDate())
const y0ISO = y0.toISOString()
const ySameISO = ySame.toISOString()
// 今日订单(已支付 status=2
const { data: todayOrders } = await supa
.from('orders')
.select('total_amount, created_at, user_id')
.gte('created_at', todayISO)
.eq('status', 2)
// 昨日同时间段
const { data: yOrders } = await supa
.from('orders')
.select('total_amount, created_at, user_id')
.gte('created_at', y0ISO)
.lte('created_at', ySameISO)
.eq('status', 2)
let gmvT = 0
if (todayOrders != null) {
for (let i = 0; i < todayOrders.length; i++) {
gmvT += safe(todayOrders[i].total_amount)
}
}
let gmvY = 0
if (yOrders != null) {
for (let i = 0; i < yOrders.length; i++) {
gmvY += safe(yOrders[i].total_amount)
}
}
const gmvGrowth = gmvY > 0 ? ((gmvT - gmvY) / gmvY * 100) : (gmvT > 0 ? 100 : 0)
const ordersT = todayOrders != null ? todayOrders.length : 0
const ordersY = yOrders != null ? yOrders.length : 0
const orderGrowth = ordersY > 0 ? ((ordersT - ordersY) / ordersY * 100) : (ordersT > 0 ? 100 : 0)
// 在线用户(最近 5 分钟)
const fiveAgoISO = new Date(now.getTime() - 5 * 60 * 1000).toISOString()
const { count: onlineCnt } = await supa
.from('user_sessions')
.select('*', { count: 'exact', head: true })
.gte('last_active_at', fiveAgoISO)
.eq('is_active', true)
let online = safe(onlineCnt)
// 转化率(下单用户数 / 访问用户数)
const uniq: Record<string, boolean> = {}
if (todayOrders != null) {
for (let i = 0; i < todayOrders.length; i++) {
const uid = todayOrders[i].user_id
if (uid) uniq[uid] = true
}
}
const orderUsers = Object.keys(uniq).length
const { count: visitorsToday } = await supa
.from('user_sessions')
.select('*', { count: 'exact', head: true })
.gte('created_at', todayISO)
const vT = safe(visitorsToday)
const convT = vT > 0 ? (orderUsers / vT) * 100 : 0
// 昨日同时间段转化率
const uniqY: Record<string, boolean> = {}
if (yOrders != null) {
for (let i = 0; i < yOrders.length; i++) {
const uid = yOrders[i].user_id
if (uid) uniqY[uid] = true
}
}
const { count: visitorsY } = await supa
.from('user_sessions')
.select('*', { count: 'exact', head: true })
.gte('created_at', y0ISO)
.lte('created_at', ySameISO)
const vY = safe(visitorsY)
const convY = vY > 0 ? (Object.keys(uniqY).length / vY) * 100 : 0
const convGrowth = convY > 0 ? ((convT - convY) / convY * 100) : (convT > 0 ? 100 : 0)
this.realTime = {
gmv: Math.round(gmvT),
gmv_growth: safe(gmvGrowth),
orders: ordersT,
order_growth: safe(orderGrowth),
online_users: online,
conversion_rate: safe(convT),
conversion_growth: safe(convGrowth)
}
} catch (e) {
console.error(e)
}
},
exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
})
},
formatInt(n: number): string {
const v = isFinite(n) ? Math.round(n) : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toString()
},
formatMoney(n: number): string {
const v = isFinite(n) ? n : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toFixed(0)
},
formatPct(n: number): string {
const v = isFinite(n) ? n : 0
const sign = v > 0 ? '+' : ''
return `${sign}${v.toFixed(1)}%`
},
// 构建图表 options
buildChartOptions() {
// 流量来源条形图
const trafficX = this.trafficSources.map((it) => it.name)
const trafficY = this.trafficSources.map((it) => {
const n = Number(it.value)
return isFinite(n) ? n : 0
})
this.trafficBarOption = {
grid: { left: 80, right: 24, top: 18, bottom: 18 },
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: trafficX, axisTick: { show: false }, axisLabel: { color: 'rgba(0,0,0,0.65)' } },
series: [{ type: 'bar', data: trafficY, barWidth: 14, itemStyle: { borderRadius: 6 } }]
}
// 用户结构环形图
const segmentData = this.userSegments.map((it) => ({
name: it.name,
value: (() => {
const n = Number(it.value)
return isFinite(n) ? n : 0
})()
}))
this.userSegmentOption = {
tooltip: { trigger: 'item' },
legend: { left: 0, bottom: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 12 } },
series: [
{
type: 'pie',
radius: ['55%', '75%'],
center: ['50%', '45%'],
avoidLabelOverlap: true,
label: { show: true, formatter: '{b}\n{d}%' },
labelLine: { length: 10, length2: 10 },
data: segmentData
}
]
}
}
}
}
</script>
<style>
/* 页面:白底 + 宽屏居中 + 自适应 */
/* 说明uni-app 的 rpx 会随屏宽缩放,宽屏 H5 建议用 max-width 控制内容宽度。 */
.page {
min-height: 100vh;
background: #f6f7fb;
}
.container {
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 16px 16px 28px;
box-sizing: border-box;
}
/* 顶部 */
.topbar {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
padding: 14px 14px;
background: #fff;
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.06);
}
.topbar-left {
display: flex;
flex-direction: column;
gap: 6px;
}
.title {
font-size: 18px;
font-weight: 700;
color: #111;
}
.subtitle {
font-size: 12px;
color: rgba(0,0,0,0.55);
}
.topbar-right {
display: flex;
gap: 10px;
align-items: center;
}
.icon-btn {
padding: 8px 12px;
border-radius: 10px;
background: #f3f4f6;
color: #111;
font-size: 13px;
}
.icon-btn.primary {
background: #111;
color: #fff;
}
/* KPI默认 2列宽屏 4列 */
.kpi-grid {
margin-top: 12px;
display: flex;
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;
width: calc(50% - 6px);
}
.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);
}
/* 时间维度 tabs 横排 */
.tabs {
margin-top: 12px;
display: flex;
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;
}
/* 卡片 */
.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%;
}
.fullwide {
margin-top: 12px;
}
.card-head {
display: flex;
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);
}
/* 图表必须给高度H5 否则可能 0 高) */
.chart-box {
width: 100%;
height: 360px; /* 建议用 pxH5 更稳 */
}
.fullwide .chart-box {
height: 420px; /* 大图更高 */
}
/* 关键:左右分栏(宽屏) */
.insights-row {
display: flex;
gap: 12px;
align-items: stretch;
margin-top: 12px;
}
/* 左边更宽,右边更窄 */
.insights-left {
flex: 2;
min-width: 0; /* 防止图表撑破 */
}
.insights-right {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.insights-right .card {
margin-top: 0;
}
/* 列表样式 */
.rank-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.rank-item {
display: flex;
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;
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;
}
/* 宽屏KPI 4列 */
@media screen and (min-width: 960px) {
.kpi-card {
width: calc(25% - 9px);
}
}
/* 自适应:窄屏自动变一列(断点用 px */
@media screen and (max-width: 960px) {
.insights-row {
flex-direction: column;
}
.chart-box {
height: 320px;
}
.fullwide .chart-box {
height: 360px;
}
}
</style>