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

@@ -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>