feat(统计业务): 新增用户统计页面及相关组件

- 在路由中添加用户统计页面,支持按日、月、年统计用户数据
- 新增用户统计基础组件,包含数据展示和导出功能
- 实现用户统计明细表格,展示用户注册、登录等信息
- 添加用户留存率统计页面,支持选择月份并展示相关数据
- 集成数据导出功能,支持下载用户统计报表
This commit is contained in:
2026-03-23 10:06:31 +08:00
parent f51dfda073
commit 44124b6931
7 changed files with 704 additions and 1 deletions

View File

@@ -94,7 +94,8 @@ const mainRoutes = {
{ path: '/reportList-lingshuFullYear', component: _import('modules/reportList/lingshuFullYear'), name: 'reportList-lingshuFullYear', meta: { title: '灵枢年度报表', isTab: true } },
{ path: '/content-psychologicalForum', component: _import('modules/content/psychologicalForum'), name: 'content-psychologicalForum', meta: { title: '心理论坛', isTab: true } },
{ path: '/statisticsBusiness-courseStatistics', component: _import('modules/statisticsBusiness/courseStatistics/index'), name: 'statisticsBusiness-courseStatistics', meta: { title: '课程统计', isTab: true } },
{ path: '/statisticsBusiness-vipStatistics', component: _import('modules/statisticsBusiness/vipStatistics/index'), name: 'statisticsBusiness-vipStatistics', meta: { title: 'VIP统计', isTab: true } }
{ path: '/statisticsBusiness-vipStatistics', component: _import('modules/statisticsBusiness/vipStatistics/index'), name: 'statisticsBusiness-vipStatistics', meta: { title: 'VIP统计', isTab: true } },
{ path: '/statisticsBusiness-userStatistics', component: _import('modules/statisticsBusiness/userStatistics/index'), name: 'statisticsBusiness-userStatistics', meta: { title: '用户统计', isTab: true } },
],
beforeEnter (to, from, next) {
let token = Vue.cookie.get('token')

View File

@@ -0,0 +1,14 @@
<template>
<userStatisticsBase mode="day" />
</template>
<script>
import userStatisticsBase from './userStatisticsBase'
export default {
components: {
userStatisticsBase
}
}
</script>

View File

@@ -0,0 +1,41 @@
<template>
<div class="mod-config">
<el-tabs v-model="activeTab" type="card">
<el-tab-pane label="首页" name="homeUser" />
<el-tab-pane label="月度" name="monthUser" />
<el-tab-pane label="年度" name="yearUser" />
<el-tab-pane label="留存率" name="retainUser" />
</el-tabs>
<keep-alive>
<component :is="activeTab" />
</keep-alive>
</div>
</template>
<script>
import homeUser from './homeUser'
import monthUser from './monthUser'
import yearUser from './yearUser'
import retainUser from './retainUser'
export default {
components: {
homeUser,
monthUser,
yearUser,
retainUser
},
data() {
return {
activeTab: 'homeUser'
}
}
}
</script>
<style scoped>
.el-form .el-form-item {
margin-bottom: 10px !important;
}
</style>

View File

@@ -0,0 +1,14 @@
<template>
<userStatisticsBase mode="month" />
</template>
<script>
import userStatisticsBase from './userStatisticsBase'
export default {
components: {
userStatisticsBase
}
}
</script>

View File

@@ -0,0 +1,236 @@
<template>
<div class="retain-statistics-container" v-loading="loading">
<div class="query-section">
<el-form :inline="true" @keyup.enter.native="getDataList">
<el-form-item label="统计月份">
<el-date-picker
v-model="currentMonth"
type="month"
format="yyyy-MM"
value-format="yyyy-MM"
placeholder="请选择月份"
size="small"
popper-append-to-body
:picker-options="pickerOptions"
@change="getDataList"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" size="small" @click="getDataList">刷新</el-button>
</el-form-item>
<el-form-item>
<el-button
type="success"
size="small"
:loading="exportLoading"
@click="exportData"
>
下载报表
</el-button>
</el-form-item>
</el-form>
</div>
<div class="table-section">
<div ref="tableContainer" class="table-container">
<el-table
:data="tableData"
border
:max-height="tableHeight"
:header-cell-style="{ textAlign: 'center', padding: '6px 0' }"
:cell-style="{ textAlign: 'center', padding: '6px 0' }"
>
<el-table-column prop="dayTime" label="日期" min-width="120" />
<el-table-column prop="count" label="新增用户数" min-width="110" />
<el-table-column prop="nextDay" label="次日留存用户数" min-width="130" />
<el-table-column prop="nextRate" label="次日留存率" min-width="110" />
<el-table-column prop="day7" label="7日留存用户数" min-width="120" />
<el-table-column prop="rate7" label="7日留存率" min-width="100" />
<el-table-column prop="day30" label="30日留存用户数" min-width="130" />
<el-table-column prop="rate30" label="30日留存率" min-width="110" />
</el-table>
</div>
</div>
</div>
</template>
<script>
import http from '@/utils/httpRequest'
function formatMonth(date) {
const y = date.getFullYear()
const m = `${date.getMonth() + 1}`.padStart(2, '0')
return `${y}-${m}`
}
function normalizeNumber(value) {
if (value === null || value === undefined || value === '') return 0
const num = Number(value)
return Number.isNaN(num) ? 0 : num
}
function formatRate(value) {
if (value === null || value === undefined || value === '') return '0%'
if (typeof value === 'string') {
const text = value.trim()
if (!text) return '0%'
if (text.includes('%')) return text
const num = Number(text)
if (Number.isNaN(num)) return '0%'
if (num <= 1) return `${(num * 100).toFixed(0)}%`
return `${num.toFixed(0)}%`
}
const num = Number(value)
if (Number.isNaN(num)) return '0%'
if (num <= 1) return `${(num * 100).toFixed(0)}%`
return `${num.toFixed(0)}%`
}
export default {
data() {
return {
loading: false,
exportLoading: false,
currentMonth: '',
tableData: [],
tableHeight: 500,
pickerOptions: {
disabledDate(time) {
const now = new Date()
return time.getTime() > now.getTime()
}
}
}
},
activated() {
if (!this.currentMonth) {
this.currentMonth = formatMonth(new Date())
}
this.getDataList()
this.$nextTick(() => {
this.calculateTableHeight()
})
},
mounted() {
window.addEventListener('resize', this.handleResize)
this.$nextTick(() => {
this.calculateTableHeight()
})
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize)
},
methods: {
handleResize() {
this.calculateTableHeight()
},
calculateTableHeight() {
if (this.$refs.tableContainer) {
this.tableHeight = this.$refs.tableContainer.offsetHeight || 500
} else {
this.tableHeight = 500
}
},
normalizeRows(userList = []) {
return userList.map((item) => ({
dayTime: item.dayTime || '--',
count: normalizeNumber(item.count),
nextDay: normalizeNumber(item.nextDay),
nextRate: formatRate(item.nextRate),
day7: normalizeNumber(item['7Day']),
rate7: formatRate(item['7Rate']),
day30: normalizeNumber(item['30Day']),
rate30: formatRate(item['30Rate'])
}))
},
getDataList() {
if (!this.currentMonth) {
this.currentMonth = formatMonth(new Date())
}
this.loading = true
http({
url: http.adornUrl('/master/statisticsBusinessUser/getUserRegisterStayInfo'),
method: 'post',
data: http.adornData({
date: this.currentMonth
})
}).then(({ data }) => {
if (data && data.code === 0) {
this.tableData = this.normalizeRows(data.userList || [])
} else {
this.tableData = []
}
}).finally(() => {
this.loading = false
this.$nextTick(() => {
this.calculateTableHeight()
})
})
},
exportData() {
if (!this.currentMonth) {
this.currentMonth = formatMonth(new Date())
}
this.exportLoading = true
http({
url: http.adornUrl('/master/statisticsBusinessUser/exportUserRegisterStayInfo'),
method: 'post',
data: http.adornData({
date: this.currentMonth
}),
responseType: 'blob'
}).then((response) => {
const blob = new Blob([response.data])
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `留存率统计报表-${this.currentMonth}.xlsx`
link.click()
URL.revokeObjectURL(link.href)
}).catch(() => {
this.$message.error('下载失败,请稍后重试')
}).finally(() => {
this.exportLoading = false
})
}
}
}
</script>
<style scoped lang="less">
.retain-statistics-container {
display: flex;
flex-direction: column;
height: calc(100vh - 220px);
gap: 8px;
}
.query-section,
.table-section {
background: #fff;
border-radius: 4px;
}
.table-section {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
}
.el-form-item {
margin-bottom: 0 !important;
}
.table-container {
flex: 1;
min-height: 0;
overflow: hidden;
}
/deep/ .el-table .cell,
/deep/ .el-table th > .cell {
padding-top: 4px;
padding-bottom: 4px;
line-height: 20px;
}
</style>

View File

@@ -0,0 +1,383 @@
<template>
<div class="user-statistics-page" v-loading="loading">
<div v-if="mode !== 'day'" class="query-section">
<el-form :inline="true" @keyup.enter.native="getDataList">
<el-form-item :label="queryLabel">
<el-date-picker
v-model="currentDate"
:type="pickerType"
:format="displayFormat"
:value-format="valueFormat"
placeholder="请选择"
size="small"
popper-append-to-body
:picker-options="pickerOptions"
@change="handleQueryChange"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" size="small" @click="refreshData">刷新</el-button>
</el-form-item>
</el-form>
</div>
<div class="summary-section">
<div class="summary-card">
<div class="summary-card__label">{{ timePrefix }}登录人数</div>
<div class="summary-card__value">{{ summary.loginCount }}</div>
</div>
<div class="summary-card">
<div class="summary-card__label">{{ timePrefix }}注册人数</div>
<div class="summary-card__value">{{ summary.createCount }}</div>
</div>
<div class="summary-card">
<div class="summary-card__label">截止今日 近30天内活跃人数</div>
<div class="summary-card__value">{{ summary.userCountFor30Day }}</div>
</div>
</div>
<div class="table-section">
<div class="table-header">
<div class="table-title">用户统计明细{{ total }}</div>
<el-button
type="success"
size="small"
:loading="exportLoading"
@click="exportData"
>下载报表</el-button>
</div>
<el-table
:data="tableData"
border
:height="mode !== 'day' ? 470 : 520"
:header-cell-style="{ textAlign: 'center', padding: '6px 0' }"
:cell-style="{ textAlign: 'center', padding: '6px 0' }"
>
<el-table-column type="index" label="序号" width="70" :index="indexMethod" />
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column prop="tel" label="电话" min-width="130" />
<el-table-column prop="createTime" label="注册时间" min-width="120" />
<el-table-column prop="loginCity" label="城市" min-width="100" />
<el-table-column prop="come" label="注册来源" min-width="110" />
<el-table-column prop="socialIdentity" label="社会身份" min-width="120" />
<el-table-column prop="identity" label="app用户身份" min-width="120" />
<el-table-column prop="jf" label="天医币" min-width="90" />
<el-table-column prop="point" label="积分" min-width="90" />
<el-table-column prop="beforVip" label="曾vip" min-width="100" />
<el-table-column prop="nowVip" label="现vip" min-width="100" />
<el-table-column prop="ubo" label="是否买过课程" min-width="110" />
<el-table-column prop="ucbl" label="是否复购" min-width="90" />
<el-table-column prop="ucerCount" label="课程证几张" min-width="100" />
<el-table-column prop="loginTime" label="上次登录时间" min-width="160" />
<el-table-column prop="loginMachine" label="设备" min-width="90" />
<el-table-column prop="sex" label="性别" min-width="80" />
<el-table-column prop="nowWatch" label="当日学习时长" min-width="110" />
<el-table-column prop="watch7" label="近七天学习时间" min-width="120" />
<el-table-column prop="totalWatch" label="总学习时长" min-width="100" />
</el-table>
<el-pagination
:current-page="page"
:page-size="limit"
:total="total"
layout="total, prev, pager, next, sizes"
:page-sizes="[10, 20, 30, 50]"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
style="margin-top: 15px; text-align: right;"
/>
</div>
</div>
</template>
<script>
import http from '@/utils/httpRequest'
function formatDate(date) {
const y = date.getFullYear()
const m = `${date.getMonth() + 1}`.padStart(2, '0')
const d = `${date.getDate()}`.padStart(2, '0')
return `${y}-${m}-${d}`
}
function formatMonth(date) {
const y = date.getFullYear()
const m = `${date.getMonth() + 1}`.padStart(2, '0')
return `${y}-${m}`
}
function formatYear(date) {
return `${date.getFullYear()}`
}
function normalizeText(value) {
if (value === null || value === undefined || value === '') {
return '--'
}
const text = String(value).trim()
return text || '--'
}
function normalizeNumber(value) {
if (value === null || value === undefined || value === '') {
return 0
}
const num = Number(value)
return Number.isNaN(num) ? 0 : num
}
export default {
props: {
mode: {
type: String,
required: true
}
},
data() {
return {
loading: false,
exportLoading: false,
currentDate: '',
summary: {
loginCount: 0,
createCount: 0,
userCountFor30Day: 0
},
tableData: [],
page: 1,
limit: 10,
total: 0
}
},
computed: {
queryLabel() {
if (this.mode === 'day') return '统计日期'
if (this.mode === 'month') return '统计月份'
return '统计年份'
},
pickerType() {
if (this.mode === 'day') return 'date'
if (this.mode === 'month') return 'month'
return 'year'
},
displayFormat() {
if (this.mode === 'day') return 'yyyy-MM-dd'
if (this.mode === 'month') return 'yyyy-MM'
return 'yyyy'
},
valueFormat() {
return this.displayFormat
},
timePrefix() {
if (this.mode === 'day') return '当日'
if (this.mode === 'month') return '当月'
return '当年'
},
pickerOptions() {
return {
disabledDate(time) {
const now = new Date()
return time.getTime() > now.getTime()
}
}
}
},
activated() {
if (!this.currentDate) {
this.currentDate = this.getDefaultDate()
}
this.getDataList()
},
methods: {
indexMethod(index) {
return (this.page - 1) * this.limit + index + 1
},
handleQueryChange() {
this.page = 1
this.getDataList()
},
refreshData() {
this.page = 1
this.getDataList()
},
handleSizeChange(val) {
this.limit = val
this.page = 1
this.getDataList()
},
handleCurrentChange(val) {
this.page = val
this.getDataList()
},
getDefaultDate() {
const now = new Date()
if (this.mode === 'day') return formatDate(now)
if (this.mode === 'month') return formatMonth(now)
return formatYear(now)
},
normalizeRows(userList = []) {
return userList.map((item) => ({
name: normalizeText(item.name),
tel: normalizeText(item.tel),
createTime: normalizeText(item.createTime),
loginCity: normalizeText(item.loginCity),
come: normalizeText(item.come),
socialIdentity: normalizeText(item.socialIdentity),
identity: normalizeText(item.identity),
jf: normalizeNumber(item.jf),
point: normalizeNumber(item.point),
beforVip: normalizeText(item.beforVip),
nowVip: normalizeText(item.nowVip),
ubo: normalizeText(item.ubo),
ucbl: normalizeText(item.ucbl),
ucerCount: normalizeNumber(item.ucerCount),
loginTime: normalizeText(item.loginTime),
loginMachine: normalizeText(item.loginMachine),
sex: normalizeText(item.sex),
nowWatch: normalizeNumber(item.nowWatch),
watch7: normalizeNumber(item['7Watch']),
totalWatch: normalizeNumber(item.totalWatch)
}))
},
getDataList() {
if (!this.currentDate) {
this.currentDate = this.getDefaultDate()
}
this.loading = true
http({
url: http.adornUrl('/master/statisticsBusinessUser/getUserStatistics'),
method: 'post',
data: http.adornData({
date: this.currentDate,
page: this.page,
limit: this.limit
})
}).then(({ data }) => {
if (data && data.code === 0) {
this.summary = {
loginCount: normalizeNumber(data.loginCount),
createCount: normalizeNumber(data.createCount),
userCountFor30Day: normalizeNumber(data.userCountFor30Day)
}
this.tableData = this.normalizeRows(data.userList || [])
this.total = normalizeNumber(data.totalSize)
} else {
this.summary = { loginCount: 0, createCount: 0, userCountFor30Day: 0 }
this.tableData = []
this.total = 0
}
}).finally(() => {
this.loading = false
})
},
exportData() {
if (!this.currentDate) {
this.currentDate = this.getDefaultDate()
}
this.exportLoading = true
http({
url: http.adornUrl('/master/statisticsBusinessUser/exportUserStatistics'),
method: 'post',
data: http.adornData({
date: this.currentDate
}),
responseType: 'blob'
}).then((response) => {
const blob = new Blob([response.data])
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `用户统计报表-${this.currentDate}.xlsx`
link.click()
URL.revokeObjectURL(link.href)
}).catch(() => {
this.$message.error('下载失败,请稍后重试')
}).finally(() => {
this.exportLoading = false
})
}
}
}
</script>
<style scoped lang="less">
.el-form-item {
margin-bottom: 0 !important;
}
/deep/ .el-table .cell,
/deep/ .el-table th > .cell {
padding-top: 4px;
padding-bottom: 4px;
line-height: 20px;
}
.user-statistics-page {
display: flex;
flex-direction: column;
gap: 8px;
}
.query-section,
.summary-section,
.table-section {
padding: 8px;
padding-bottom: 0;
background: #fff;
border-radius: 4px;
}
.query-section {
padding-top: 0;
}
.summary-section {
display: flex;
flex-wrap: nowrap;
gap: 12px;
}
.summary-card {
flex: 1 1 180px;
min-width: 180px;
padding: 16px;
border: 1px solid #ebeef5;
border-radius: 8px;
background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
box-shadow: 0 4px 12px rgba(31, 45, 61, 0.06);
}
.summary-card__label {
margin-bottom: 8px;
font-size: 14px;
color: #606266;
}
.summary-card__value {
font-size: 24px;
font-weight: 700;
color: #1f2d3d;
}
.table-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.table-title {
font-size: 16px;
font-weight: 700;
color: #303133;
}
@media (max-width: 1200px) {
.summary-section {
flex-wrap: wrap;
}
.summary-card {
min-width: 200px;
}
}
</style>

View File

@@ -0,0 +1,14 @@
<template>
<userStatisticsBase mode="year" />
</template>
<script>
import userStatisticsBase from './userStatisticsBase'
export default {
components: {
userStatisticsBase
}
}
</script>