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

620 lines
18 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(userData.total_users) }}</text>
<text class="kpi-meta">较上期:{{ formatPct(userData.user_growth) }}</text>
</view>
<view class="kpi-card">
<text class="kpi-label">新用户</text>
<text class="kpi-value">{{ formatInt(userData.new_users) }}</text>
<text class="kpi-meta">较上期:{{ formatPct(userData.new_user_growth) }}</text>
</view>
<view class="kpi-card">
<text class="kpi-label">用户活跃度</text>
<text class="kpi-value">{{ formatPct(userData.active_rate) }}</text>
<text class="kpi-meta">较上期:{{ formatPct(userData.active_growth) }}</text>
</view>
<view class="kpi-card">
<text class="kpi-label">复购率</text>
<text class="kpi-value">{{ formatPct(userData.repurchase_rate) }}</text>
<text class="kpi-meta">较上期:{{ formatPct(userData.repurchase_growth) }}</text>
</view>
</view>
<!-- 用户增长趋势 -->
<view class="card card-full">
<view class="card-head">
<text class="card-title">用户增长趋势</text>
<text class="card-desc">{{ selectedPeriodText }} · 新用户 vs 总用户</text>
</view>
<view v-if="loading || !growthChartOption || !growthChartOption.series || growthChartOption.series.length === 0" class="chart-loading">
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
</view>
<EChartsView v-else class="chart-box" :option="growthChartOption" />
</view>
<!-- 留存 / 新老 / 活跃 / 画像(统一公共块) -->
<view class="card card-full">
<view class="card-head">
<text class="card-title">用户洞察</text>
<text class="card-desc">留存率 / 新老对比 / 活跃度 / 画像</text>
</view>
<view class="quad-grid">
<view class="quad-item">
<view class="sub-head">
<text class="sub-title">用户留存率</text>
<text class="sub-desc">按留存天数统计</text>
</view>
<EChartsView class="chart-box chart-box-sm" :option="retentionChartOption" />
</view>
<view class="quad-item">
<view class="sub-head">
<text class="sub-title">新老用户对比</text>
<text class="sub-desc">GMV、订单数、客单价</text>
</view>
<EChartsView class="chart-box chart-box-sm" :option="comparisonChartOption" />
</view>
<view class="quad-item">
<view class="sub-head">
<text class="sub-title">用户活跃度</text>
<text class="sub-desc">日活/周活/月活</text>
</view>
<EChartsView class="chart-box chart-box-sm" :option="activityChartOption" />
</view>
<view class="quad-item">
<view class="sub-head">
<text class="sub-title">用户画像</text>
<text class="sub-desc">性别/年龄/地域</text>
</view>
<EChartsView class="chart-box chart-box-sm" :option="profileChartOption" />
</view>
</view>
</view>
<!-- 用户分群 + 流量来源 -->
<view class="card card-full">
<view class="card-head">
<text class="card-title">用户分群 & 流量来源</text>
<text class="card-desc">{{ selectedPeriodText }} · 分群占比 & 来源分布</text>
</view>
<view class="two-col">
<view class="two-col-item">
<EChartsView class="chart-box" :option="segmentChartOption" />
</view>
<view class="two-col-item">
<EChartsView class="chart-box" :option="trafficChartOption" />
</view>
</view>
</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 UserData = {
total_users: number
user_growth: number
new_users: number
new_user_growth: number
active_rate: number
active_growth: number
repurchase_rate: number
repurchase_growth: number
}
export default {
components: {
AnalyticsSidebarMenu,
AnalyticsTopBar,
EChartsView
},
data() {
return {
lastUpdateTime: '',
selectedPeriod: '7d',
showMoreMenu: false,
showSidebarMenu: false,
currentPath: '/pages/mall/analytics/user-analysis',
timePeriods: [
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
],
userData: {
total_users: 0,
user_growth: 0,
new_users: 0,
new_user_growth: 0,
active_rate: 0,
active_growth: 0,
repurchase_rate: 0,
repurchase_growth: 0
} as UserData,
growthChartOption: {} as any,
retentionChartOption: {} as any,
activityChartOption: {} as any,
comparisonChartOption: {} as any,
profileChartOption: {} as any,
segmentChartOption: {} as any,
trafficChartOption: {} as any,
loading: false
}
},
computed: {
selectedPeriodText(): string {
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
return p ? p.label : '7天'
}
},
onLoad() {
this.updateTime()
this.loadUserData()
},
methods: {
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 loadUserData() {
this.loading = true
try {
this.updateTime()
const { startDate, endDate } = this.calcDateRange()
const startStr = startDate.toISOString().slice(0, 10)
const endStr = endDate.toISOString().slice(0, 10)
const p = new UTSJSONObject()
p.set('p_start_date', startStr)
p.set('p_end_date', endStr)
// KPIRPC
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 => {
const n = Number(v)
return isFinite(n) ? n : 0
}
this.userData = {
total_users: Math.round(safe(row.total_users)),
user_growth: safe(row.user_growth),
new_users: Math.round(safe(row.new_users)),
new_user_growth: safe(row.new_user_growth),
active_rate: safe(row.active_rate),
active_growth: safe(row.active_growth),
repurchase_rate: safe(row.repurchase_rate),
repurchase_growth: safe(row.repurchase_growth)
}
// 增长趋势RPC
const tRes: any = await supa.rpc('rpc_analytics_user_growth_trend', p)
const rows: Array<any> = Array.isArray(tRes.data) ? (tRes.data as Array<any>) : []
const x: Array<string> = []
const newArr: Array<number> = []
const totalArr: Array<number> = []
for (let i = 0; i < rows.length; i++) {
const d = `${rows[i].date}`
x.push(d.slice(5))
newArr.push(Number(rows[i].new_users) || 0)
totalArr.push(Number(rows[i].total_users) || 0)
}
this.growthChartOption = {
grid: { left: 40, right: 18, top: 20, bottom: 40 },
tooltip: { trigger: 'axis' },
legend: { data: ['新用户', '总用户'], bottom: 0 },
xAxis: { type: 'category', data: x, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
yAxis: { type: 'value', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
series: [
{ name: '新用户', type: 'bar', data: newArr, barWidth: 14, itemStyle: { borderRadius: 6 } },
{ name: '总用户', type: 'line', data: totalArr, smooth: true, symbolSize: 6 }
]
}
// 用户分群RPC
const sRes: any = await supa.rpc('rpc_analytics_user_segments', p)
const sRows: Array<any> = Array.isArray(sRes.data) ? (sRes.data as Array<any>) : []
const segData: Array<any> = []
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<any> = Array.isArray(t2Res.data) ? (t2Res.data as Array<any>) : []
const srcNames: Array<string> = []
const srcVals: Array<number> = []
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()
}
},
selectPeriod(p: string) {
this.selectedPeriod = p
this.loadUserData()
},
refreshData() {
this.loadUserData()
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)}%`
},
handleMenu() {
this.showSidebarMenu = true
},
handleSidebarUpdate(visible: boolean) {
this.showSidebarMenu = visible
},
toggleMoreMenu() {
this.showMoreMenu = !this.showMoreMenu
},
closeMoreMenu() {
this.showMoreMenu = false
},
handleSearch() {
uni.showToast({ title: '搜索', icon: 'none' })
},
handleNotification() {
uni.showToast({ title: '通知', icon: 'none' })
},
handleFullscreen() {
uni.showToast({ title: '全屏', icon: 'none' })
},
handleMobile() {
uni.showToast({ title: '移动端', icon: 'none' })
},
handleDropdown() {
uni.showToast({ title: '下拉菜单', icon: 'none' })
},
handleSettings() {
uni.showToast({ title: '设置', icon: 'none' })
}
}
}
</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; /* 为固定顶部导航栏留出空间 */
}
/* 响应式:窄屏时全屏显示 */
@media screen and (max-width: 959px) {
.page-layout {
flex-direction: column !important;
}
.main-content {
width: 100%;
}
}
.container {
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 16px 16px 28px;
box-sizing: border-box;
}
/* 时间维度 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;
}
.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) {
.two-col {
flex-direction: column !important;
}
.quad-grid {
grid-template-columns: 1fr;
}
}
</style>