merge: branch 'huangzhenbao-admin' into comclib-analytics, keeping local RPC integration versions
This commit is contained in:
503
pages/mall/admin/user/statistics/index.uvue
Normal file
503
pages/mall/admin/user/statistics/index.uvue
Normal file
@@ -0,0 +1,503 @@
|
||||
<template>
|
||||
<view class="statistic-page">
|
||||
<!-- 筛选栏 -->
|
||||
<view class="filter-card">
|
||||
<view class="filter-item">
|
||||
<text class="filter-label">用户渠道:</text>
|
||||
<view class="select-box">
|
||||
<text class="select-text">全部</text>
|
||||
<text class="select-arrow">▼</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="filter-item">
|
||||
<text class="filter-label">选择月份:</text>
|
||||
<AnalyticsDateRangePicker
|
||||
:initialStartDate="startDate"
|
||||
:initialEndDate="endDate"
|
||||
@apply="onDateRangeApply"
|
||||
@clear="onDateRangeClear"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="filter-btns">
|
||||
<button class="btn primary" @click="onSearch">查询</button>
|
||||
<button class="btn" @click="onExport">导出</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 用户概况卡片区 -->
|
||||
<view class="section-card">
|
||||
<view class="section-header">
|
||||
<text class="section-title">用户概况</text>
|
||||
<text class="info-icon">ⓘ</text>
|
||||
</view>
|
||||
|
||||
<view class="kpi-row">
|
||||
<view class="kpi-card" v-for="item in kpiData" :key="item.title">
|
||||
<view class="kpi-icon-box" :style="{ backgroundColor: item.bg }">
|
||||
<text class="kpi-icon">{{ item.icon }}</text>
|
||||
</view>
|
||||
<view class="kpi-content">
|
||||
<text class="kpi-label">{{ item.title }}</text>
|
||||
<text class="kpi-value">{{ item.value }}</text>
|
||||
<view class="kpi-meta">
|
||||
<text class="meta-label">环比增长:</text>
|
||||
<text class="meta-value" :class="item.trend">{{ item.percent }} {{ item.trend === 'up' ? '▲' : '▼' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 图表区 -->
|
||||
<view class="chart-container">
|
||||
<view class="chart-header">
|
||||
<view class="header-left"></view>
|
||||
<view class="header-right">
|
||||
<text class="download-icon">📥</text>
|
||||
</view>
|
||||
</view>
|
||||
<AnalyticsMultiLineChart
|
||||
:xLabels="chartData.x"
|
||||
:series="chartData.series"
|
||||
:height="450"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 地域分布与性别比例 -->
|
||||
<view class="analysis-row">
|
||||
<view class="map-col">
|
||||
<AnalyticsUserMapTable />
|
||||
</view>
|
||||
<view class="gender-col">
|
||||
<AnalyticsUserGenderSection :startDate="startDate" :endDate="endDate" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import AnalyticsMultiLineChart from '@/components/analytics/AnalyticsMultiLineChart.uvue'
|
||||
import AnalyticsUserMapTable from '@/components/analytics/AnalyticsUserMapTable.uvue'
|
||||
import AnalyticsUserGenderSection from '@/components/analytics/AnalyticsUserGenderSection.uvue'
|
||||
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
|
||||
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance.uts'
|
||||
import { ensureAnalyticsLogin } from '@/services/analytics/authGuard.uts'
|
||||
import { getCurrentUser } from '@/utils/store.uts'
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
|
||||
type KpiCard = {
|
||||
title: string
|
||||
value: string
|
||||
percent: string
|
||||
trend: string
|
||||
icon: string
|
||||
bg: string
|
||||
}
|
||||
|
||||
type LineSeries = {
|
||||
name: string
|
||||
color: string
|
||||
data: Array<number>
|
||||
}
|
||||
|
||||
const selectedMonth = ref<string>('')
|
||||
const startDate = ref<string>('')
|
||||
const endDate = ref<string>('')
|
||||
|
||||
const dateRangeText = computed((): string => {
|
||||
if (!startDate.value || !endDate.value) return '请选择月份'
|
||||
const fmt = (s: string): string => s.replace(/-/g, '/')
|
||||
return `${fmt(startDate.value)} - ${fmt(endDate.value)}`
|
||||
})
|
||||
|
||||
const kpiData = ref<Array<KpiCard>>([
|
||||
{ title: '累计用户', value: '-', percent: '-', trend: 'up', icon: '👤', bg: '#f3e8ff' },
|
||||
{ title: '活跃用户', value: '-', percent: '-', trend: 'up', icon: '👤', bg: '#e0f2fe' },
|
||||
{ title: '新增用户数', value: '-', percent: '-', trend: 'up', icon: '👤', bg: '#ffedd5' },
|
||||
{ title: '下单用户数', value: '-', percent: '-', trend: 'up', icon: '👤', bg: '#dcfce7' },
|
||||
{ title: '支付用户数', value: '-', percent: '-', trend: 'up', icon: '👤', bg: '#f3e8ff' },
|
||||
{ title: '复购率', value: '-', percent: '-', trend: 'up', icon: '💎', bg: '#f3e8ff' }
|
||||
])
|
||||
|
||||
const chartData = ref<{ x: Array<string>; series: Array<LineSeries> }>({
|
||||
x: [],
|
||||
series: [
|
||||
{ name: '新增用户数', color: '#1890ff', data: [] },
|
||||
{ name: '活跃用户', color: '#52c41a', data: [] }
|
||||
]
|
||||
})
|
||||
|
||||
function safeNumber(v: any): number {
|
||||
const n = Number(v)
|
||||
return isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
function formatInt(v: any): string {
|
||||
const n = Math.round(safeNumber(v))
|
||||
return n.toString()
|
||||
}
|
||||
|
||||
function formatPct(v: any): string {
|
||||
const n = safeNumber(v)
|
||||
const s = n.toFixed(2) + '%'
|
||||
return s
|
||||
}
|
||||
|
||||
function trendOf(pct: number): string {
|
||||
return pct >= 0 ? 'up' : 'down'
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
await ensureSupabaseReady()
|
||||
|
||||
if (!startDate.value || !endDate.value) {
|
||||
uni.showToast({ title: '请先选择月份', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const startStr = startDate.value
|
||||
const endStr = endDate.value
|
||||
|
||||
const p = new UTSJSONObject()
|
||||
p.set('p_start_date', startStr)
|
||||
p.set('p_end_date', endStr)
|
||||
|
||||
const kRes: any = await supa.rpc('rpc_analytics_user_kpis', p)
|
||||
const row = Array.isArray(kRes.data) && kRes.data.length > 0 ? kRes.data[0] : (kRes.data || {})
|
||||
|
||||
const totalUsers = safeNumber(row.total_users)
|
||||
const totalGrowth = safeNumber(row.user_growth)
|
||||
const newUsers = safeNumber(row.new_users)
|
||||
const newGrowth = safeNumber(row.new_user_growth)
|
||||
const activeUsers = safeNumber(row.active_users)
|
||||
const activeGrowth = safeNumber(row.active_growth)
|
||||
const orderingUsers = safeNumber(row.ordering_users)
|
||||
const orderingGrowth = safeNumber(row.ordering_growth)
|
||||
const paidUsers = safeNumber(row.paid_users)
|
||||
const paidGrowth = safeNumber(row.paid_growth)
|
||||
const repurchaseRate = safeNumber(row.repurchase_rate)
|
||||
const repurchaseGrowth = safeNumber(row.repurchase_growth)
|
||||
|
||||
kpiData.value = [
|
||||
{ title: '累计用户', value: formatInt(totalUsers), percent: formatPct(totalGrowth), trend: trendOf(totalGrowth), icon: '👤', bg: '#f3e8ff' },
|
||||
{ title: '活跃用户', value: formatInt(activeUsers), percent: formatPct(activeGrowth), trend: trendOf(activeGrowth), icon: '👤', bg: '#e0f2fe' },
|
||||
{ title: '新增用户数', value: formatInt(newUsers), percent: formatPct(newGrowth), trend: trendOf(newGrowth), icon: '👤', bg: '#ffedd5' },
|
||||
{ title: '下单用户数', value: formatInt(orderingUsers), percent: formatPct(orderingGrowth), trend: trendOf(orderingGrowth), icon: '👤', bg: '#dcfce7' },
|
||||
{ title: '支付用户数', value: formatInt(paidUsers), percent: formatPct(paidGrowth), trend: trendOf(paidGrowth), icon: '👤', bg: '#f3e8ff' },
|
||||
{ title: '复购率', value: formatPct(repurchaseRate), percent: formatPct(repurchaseGrowth), trend: trendOf(repurchaseGrowth), icon: '💎', bg: '#f3e8ff' }
|
||||
]
|
||||
|
||||
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 activeArr: 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)
|
||||
activeArr.push(Number(rows[i].active_users) || 0)
|
||||
}
|
||||
|
||||
chartData.value = {
|
||||
x,
|
||||
series: [
|
||||
{ name: '新增用户数', color: '#1890ff', data: newArr },
|
||||
{ name: '活跃用户', color: '#52c41a', data: activeArr }
|
||||
]
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('admin user statistic loadData failed', e)
|
||||
uni.showToast({ title: '数据加载失败', icon: 'none', duration: 2000 })
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onExport() {
|
||||
uni.showToast({ title: '导出功能待接入', icon: 'none' })
|
||||
}
|
||||
|
||||
function onDateRangeApply(range: { start: string; end: string }) {
|
||||
startDate.value = range.start
|
||||
endDate.value = range.end
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onDateRangeClear() {
|
||||
startDate.value = ''
|
||||
endDate.value = ''
|
||||
kpiData.value.forEach(item => {
|
||||
item.value = '-'
|
||||
item.percent = '-'
|
||||
})
|
||||
chartData.value.x = []
|
||||
chartData.value.series.forEach(s => s.data = [])
|
||||
}
|
||||
|
||||
// 初始化默认月份为当前月
|
||||
function initCurrentMonth() {
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = now.getMonth() + 1
|
||||
const firstDay = new Date(year, month - 1, 1)
|
||||
const lastDay = new Date(year, month, 0)
|
||||
startDate.value = firstDay.toISOString().slice(0, 10)
|
||||
endDate.value = lastDay.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
onLoad(async () => {
|
||||
if (!ensureAnalyticsLogin({ toastTitle: '请先登录以访问用户统计' })) return
|
||||
|
||||
const profile = await getCurrentUser()
|
||||
const role = profile?.role
|
||||
if (!role || !['admin', 'analytics'].includes(role)) {
|
||||
uni.showToast({ title: '权限不足', icon: 'none' })
|
||||
setTimeout(() => uni.switchTab({ url: '/pages/mall/consumer/index' }), 800)
|
||||
return
|
||||
}
|
||||
|
||||
initCurrentMonth()
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.statistic-page {
|
||||
padding: 16px;
|
||||
background-color: #f0f2f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
background-color: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.select-box {
|
||||
width: 180px;
|
||||
height: 32px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.select-text { font-size: 14px; color: #333; }
|
||||
.select-arrow { font-size: 10px; color: #bfbfbf; }
|
||||
|
||||
.date-picker-box {
|
||||
min-width: 240px;
|
||||
height: 32px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.date-icon { font-size: 14px; }
|
||||
.date-text { font-size: 14px; color: #333; }
|
||||
|
||||
.filter-btns {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 2px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #d9d9d9;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background-color: #1890ff;
|
||||
border-color: #1890ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background-color: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
font-size: 14px;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
.kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.kpi-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.kpi-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.kpi-icon-box {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.kpi-icon { font-size: 14px; }
|
||||
|
||||
.kpi-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.kpi-value {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kpi-meta {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: #8c8c8c;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.meta-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.meta-value.up { color: #ff4d4f; }
|
||||
.meta-value.down { color: #52c41a; }
|
||||
|
||||
.chart-container {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.download-icon {
|
||||
font-size: 18px;
|
||||
color: #8c8c8c;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.analysis-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.map-col {
|
||||
flex: 7;
|
||||
}
|
||||
|
||||
.gender-col {
|
||||
flex: 3;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user