6 Commits

Author SHA1 Message Date
2e3fcadc14 feat(医疗模块): 更新关联用户功能,优化表单处理
- 将关联用户输入框的大小调整为大号,提升用户体验
- 在表单数据中添加用户ID字段,支持关联用户的选择
- 优化手机号和邮箱的自动补全逻辑,确保用户信息的准确性
- 增加调试信息以便于后续开发和问题排查
2026-03-23 17:42:39 +08:00
30237639c7 feat(医疗模块): 更新新增/修改弹窗表单,添加关联用户功能
- 在新增/修改弹窗中,添加关联用户的输入框,支持手机号/邮箱的自动补全
- 更新表单验证规则,确保关联用户为必填项
- 调整表单项的样式,统一标签宽度,提升用户体验
- 增加用户列表加载功能,优化用户选择流程
2026-03-23 16:02:59 +08:00
301b3a2582 feat(统计业务): 新增全部用户统计页面及相关功能
- 新增用户统计页面,支持按日、月、年统计用户数据
- 引入用户统计基础组件,包含数据展示和导出功能
- 更新主统计页面以集成新的统计选项
2026-03-23 16:02:46 +08:00
b5f280b02f style(统计业务): 统一统计页面样式和布局
- 引入公共样式文件,简化各统计页面的样式管理
- 优化统计页面的容器布局,采用flex布局以提升响应式表现
- 统一表单项的样式,确保一致性和可读性
- 移除冗余的样式定义,提升代码整洁度
2026-03-23 13:19:46 +08:00
44124b6931 feat(统计业务): 新增用户统计页面及相关组件
- 在路由中添加用户统计页面,支持按日、月、年统计用户数据
- 新增用户统计基础组件,包含数据展示和导出功能
- 实现用户统计明细表格,展示用户注册、登录等信息
- 添加用户留存率统计页面,支持选择月份并展示相关数据
- 集成数据导出功能,支持下载用户统计报表
2026-03-23 10:06:31 +08:00
f51dfda073 style(统计业务): 优化VIP统计页面表格样式和布局
- 为所有VIP统计页面的表格添加了统一的类名以便于样式管理
- 调整表格的最小高度,确保在不同数据情况下的显示效果
- 修改统计卡片的布局,采用flex布局以提高响应式表现
- 移除不必要的overflow属性,简化样式结构
2026-03-17 15:32:47 +08:00
20 changed files with 1031 additions and 258 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: '/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: '/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-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) { beforeEnter (to, from, next) {
let token = Vue.cookie.get('token') let token = Vue.cookie.get('token')

View File

@@ -61,7 +61,7 @@
<!-- 弹窗, 新增 / 修改 --> <!-- 弹窗, 新增 / 修改 -->
<el-dialog :visible.sync="addOrUpdateVisible" :close-on-click-modal="false" :append-to-body="true" :title="titlesub" <el-dialog :visible.sync="addOrUpdateVisible" :close-on-click-modal="false" :append-to-body="true" :title="titlesub"
width="50%" @close="cancleClose"> width="50%" @close="cancleClose">
<el-form :inline="true" :model="addForm" ref="addFormRef" :rules="addFormRule"> <el-form :inline="true" :model="addForm" ref="addFormRef" :rules="addFormRule" label-width="100px">
<el-row type="flex" justify="center"> <el-row type="flex" justify="center">
<el-form-item label="姓名" prop="name"> <el-form-item label="姓名" prop="name">
<el-input style="width:500px" v-model="addForm.name"></el-input> <el-input style="width:500px" v-model="addForm.name"></el-input>
@@ -76,17 +76,39 @@
</el-form-item> </el-form-item>
</el-row> </el-row>
<el-row type="flex" justify="center"> <el-row type="flex" justify="center">
<el-form-item label="url" prop="url" label-width="48px"> <el-form-item label="关联用户" prop="tel">
<el-autocomplete
size="large"
style="width: 500px;"
v-model="addForm.tel"
:debounce="500"
:fetch-suggestions="loadAll"
placeholder="请输入手机号/邮箱"
@select="handleSelect"
>
<template #default="{ item }">
<div class="custom-item">
<span>{{ item.tel ? item.tel : item.email }}</span>
<span style="color: gray; margin-left: 10px;" v-if="item.name"
>({{ item.name }})</span
>
</div>
</template>
</el-autocomplete>
</el-form-item>
</el-row>
<el-row type="flex" justify="center">
<el-form-item label="url" prop="url">
<el-input style="width:500px" v-model="addForm.url"></el-input> <el-input style="width:500px" v-model="addForm.url"></el-input>
</el-form-item> </el-form-item>
</el-row> </el-row>
<el-row type="flex" justify="center"> <el-row type="flex" justify="center">
<el-form-item label="内容" prop="content" label-width="48px"> <el-form-item label="内容" prop="content">
<el-input style="width:500px" v-model="addForm.content" :rows="5" type="textarea"></el-input> <el-input style="width:500px" v-model="addForm.content" :rows="5" type="textarea"></el-input>
</el-form-item> </el-form-item>
</el-row> </el-row>
<el-row type="flex" justify="center"> <el-row type="flex" justify="center">
<el-form-item label="地址" prop="cityId" label-width="48px"> <el-form-item label="地址" prop="cityId">
<el-select v-model="addForm.provId" placeholder="请选择省份" style="width:249px" @change="provinceChange"> <el-select v-model="addForm.provId" placeholder="请选择省份" style="width:249px" @change="provinceChange">
<el-option v-for="item in provinceEntity" :key="item.provId" :label="item.provName" :value="item.provId"> <el-option v-for="item in provinceEntity" :key="item.provId" :label="item.provName" :value="item.provId">
</el-option> </el-option>
@@ -98,17 +120,19 @@
</el-form-item> </el-form-item>
</el-row> </el-row>
<el-row type="flex" justify="center"> <el-row type="flex" justify="center">
<el-form-item label="图片" prop="img" label-width="48px"> <el-form-item label="图片" prop="img">
<el-upload class="el-uploadfeng " ref="files" <div style="width: 500px;">
:class="{ uoloadSty: dataForm.showBtnDealImg, disUoloadSty: dataForm.noneBtnImg }" <el-upload class="el-uploadfeng " ref="files"
:action="baseUrl + '/oss/fileoss'" list-type="picture-card" :file-list="fileList" :class="{ uoloadSty: dataForm.showBtnDealImg, disUoloadSty: dataForm.noneBtnImg }"
:on-success="handlePicSuccess" accept=".jpeg,.jpg,.gif,.png" :on-remove="handleRemove"> :action="baseUrl + '/oss/fileoss'" list-type="picture-card" :file-list="fileList"
<i class="el-icon-plus"></i> :on-success="handlePicSuccess" accept=".jpeg,.jpg,.gif,.png" :on-remove="handleRemove">
</el-upload> <i class="el-icon-plus"></i>
</el-upload>
</div>
</el-form-item> </el-form-item>
</el-row> </el-row>
<el-row type="flex" justify="center"> <el-row type="flex" justify="center">
<el-form-item label="排序" label-width="48px"> <el-form-item label="排序">
<el-input style="width:500px" v-model="addForm.sort"></el-input> <el-input style="width:500px" v-model="addForm.sort"></el-input>
</el-form-item> </el-form-item>
</el-row> </el-row>
@@ -131,6 +155,7 @@
cityEntity: [], cityEntity: [],
provinceEntity: [], //省地址 provinceEntity: [], //省地址
typeList: [], //类型列表 typeList: [], //类型列表
userList: [], //用户列表
booknameList: [], booknameList: [],
dataForm: { dataForm: {
dictType: '', //分类 dictType: '', //分类
@@ -145,6 +170,8 @@
content: '', content: '',
cityId: '', cityId: '',
provId: '', provId: '',
userId: '', //关联用户id
tel: '',
sort: 0 sort: 0
}, },
editId: '', editId: '',
@@ -153,6 +180,10 @@
required: true, required: true,
message: "请选择分类" message: "请选择分类"
}], }],
tel: [{
required: true,
message: "请选择关联用户"
}],
name: [{ name: [{
required: true, required: true,
message: "请输入姓名" message: "请输入姓名"
@@ -239,6 +270,33 @@
this.dataListLoading = false this.dataListLoading = false
}) })
}, },
loadAll(queryString, cb) {
console.log('queryString', queryString);
if (queryString == "") {
return false;
}
this.$http({
url: this.$http.adornUrl("/book/user/getUserList"),
method: "post",
data: this.$http.adornData({
page: 1,
limit: 20,
key: queryString
})
}).then(({ data }) => {
if (data && data.code === 0) {
var arr = data.user.records;
cb(arr);
} else {
cb([]);
}
});
},
handleSelect(data) {
console.log("data at line 161:", data);
this.addForm.userId = data.id;
this.addForm.tel = data.tel ? data.tel : data.email;
},
provinceChange() { provinceChange() {
this.addForm.cityId = '' this.addForm.cityId = ''
this.cityEntity = [] this.cityEntity = []
@@ -281,6 +339,8 @@
this.addForm.content = row.content this.addForm.content = row.content
this.addForm.typeId = row.type.toString() this.addForm.typeId = row.type.toString()
this.addForm.sort = row.sort this.addForm.sort = row.sort
this.addForm.tel = row.tel
this.addForm.userId = row.userId ? row.userId : ''
this.fileList = row.imgList this.fileList = row.imgList
for (var i = 0; i < this.provinceEntity.length; i++) { for (var i = 0; i < this.provinceEntity.length; i++) {
for (var j = 0; j < this.provinceEntity[i].cityList.length; j++) { for (var j = 0; j < this.provinceEntity[i].cityList.length; j++) {
@@ -311,6 +371,8 @@
content: '', content: '',
cityId: '', cityId: '',
provId: '', provId: '',
userId: '',
tel: '',
sort: 0 sort: 0
} }
this.fileList = [] this.fileList = []
@@ -382,7 +444,9 @@
"content": this.addForm.content, "content": this.addForm.content,
"type": this.addForm.typeId, "type": this.addForm.typeId,
"cityId": this.addForm.cityId, "cityId": this.addForm.cityId,
"sort": this.addForm.sort "sort": this.addForm.sort,
"userId": this.addForm.userId,
"tel": this.addForm.tel
}) })
}).then(({ }).then(({
data data

View File

@@ -217,22 +217,13 @@ export default {
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
@import '../styles/statistics-common.less';
.course-statistics-container { .course-statistics-container {
display: flex; .sb-page-height-with-table();
flex-direction: column;
height: calc(100vh - 220px);
.table-container {
flex: 1;
overflow: hidden;
}
} }
.el-form .el-form-item { .sb-form-item-ten();
margin-bottom: 10px !important;
}
/deep/ .el-dialog__wrapper .el-dialog__body { .sb-dialog-body-no-top-padding();
padding-top: 0 !important;
}
</style> </style>

View File

@@ -414,15 +414,10 @@ export default {
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.course-statistics-container { @import '../styles/statistics-common.less';
display: flex;
flex-direction: column;
height: calc(100vh - 220px);
.table-container { .course-statistics-container {
flex: 1; .sb-page-height-with-table();
overflow: hidden;
}
} }
.dialog-header { .dialog-header {
@@ -438,11 +433,7 @@ export default {
color: #303133; color: #303133;
} }
.el-form .el-form-item { .sb-form-item-ten();
margin-bottom: 10px !important;
}
/deep/ .el-dialog__wrapper .el-dialog__body { .sb-dialog-body-no-top-padding();
padding-top: 0 !important;
}
</style> </style>

View File

@@ -31,8 +31,8 @@ export default {
} }
</script> </script>
<style scoped> <style scoped lang="less">
.el-form .el-form-item { @import '../styles/statistics-common.less';
margin-bottom: 10px !important;
} .sb-form-item-ten();
</style> </style>

View File

@@ -177,18 +177,11 @@ export default {
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
@import '../styles/statistics-common.less';
.course-label-statistics-container { .course-label-statistics-container {
display: flex; .sb-page-height-with-table();
flex-direction: column;
height: calc(100vh - 220px);
.table-container {
flex: 1;
overflow: hidden;
}
} }
.el-form .el-form-item { .sb-form-item-ten();
margin-bottom: 10px !important;
}
</style> </style>

View File

@@ -0,0 +1,153 @@
.sb-form-item-zero() {
.el-form-item {
margin-bottom: 0 !important;
}
}
.sb-form-item-ten() {
.el-form .el-form-item {
margin-bottom: 10px !important;
}
}
.sb-table-compact() {
/deep/ .el-table .cell,
/deep/ .el-table th > .cell {
padding-top: 4px;
padding-bottom: 4px;
line-height: 20px;
}
}
.sb-statistics-table-min() {
/deep/ .statistics-table {
min-height: 500px;
}
/deep/ .statistics-table .el-table__body-wrapper,
/deep/ .statistics-table .el-table__empty-block {
min-height: 452px;
}
}
.sb-page-height-with-table() {
display: flex;
flex-direction: column;
height: calc(100vh - 220px);
.table-container {
flex: 1;
overflow: hidden;
}
}
.sb-dialog-body-no-top-padding() {
/deep/ .el-dialog__wrapper .el-dialog__body {
padding-top: 0 !important;
}
}
.sb-white-panel(@padding: 8px) {
background: #fff;
border-radius: 4px;
padding: @padding;
}
.sb-flex-column-fill() {
display: flex;
flex: 1;
flex-direction: column;
}
.sb-detail-header(@mb: 16px) {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: @mb;
}
.sb-title-16() {
font-size: 16px;
font-weight: 700;
color: #303133;
}
.sb-summary-card-text() {
.summary-card__label {
margin-bottom: 8px;
font-size: 14px;
color: #606266;
}
.summary-card__value {
font-size: 24px;
font-weight: 700;
color: #1f2d3d;
}
}
.sb-summary-section-flex(@gap: 12px) {
display: flex;
flex-wrap: wrap;
gap: @gap;
}
.sb-summary-card-shell(
@basis: 180px,
@width: 180px,
@minHeight: 180px,
@padding: 16px,
@bg: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%)
) {
display: flex;
flex: 1 1 @basis;
width: @width;
flex-direction: column;
min-height: @minHeight;
padding: @padding;
border: 1px solid #ebeef5;
border-radius: 8px;
background: @bg;
box-shadow: 0 4px 12px rgba(31, 45, 61, 0.06);
}
.sb-summary-card-two-columns(@basis: 280px, @width: 280px) {
flex-basis: @basis;
width: @width;
}
.sb-summary-card-title(@mb: 14px) {
margin-bottom: @mb;
.sb-title-16();
}
.sb-summary-card-body(@gap: 10px) {
display: flex;
flex: 1;
flex-direction: column;
gap: @gap;
}
.sb-summary-card-body-two-columns(@rowGap: 10px, @colGap: 30px) {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: @rowGap @colGap;
}
.sb-summary-line(@gap: 12px) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: @gap;
line-height: 1.5;
}
.sb-summary-line-label() {
color: #606266;
}
.sb-summary-line-value() {
font-weight: 700;
color: #1f2d3d;
text-align: right;
}

View File

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

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,43 @@
<template>
<div class="mod-config">
<el-tabs v-model="activeTab" type="card">
<el-tab-pane label="当日" name="dayUser" />
<el-tab-pane label="月度" name="monthUser" />
<el-tab-pane label="年度" name="yearUser" />
<el-tab-pane label="全部" name="allUser" />
<el-tab-pane label="留存率" name="retainUser" />
</el-tabs>
<keep-alive>
<component :is="activeTab" />
</keep-alive>
</div>
</template>
<script>
import allUser from './allUser'
import dayUser from './dayUser'
import monthUser from './monthUser'
import yearUser from './yearUser'
import retainUser from './retainUser'
export default {
components: {
dayUser,
monthUser,
yearUser,
allUser,
retainUser
},
data() {
return {
activeTab: 'dayUser'
}
}
}
</script>
<style scoped lang="less">
@import '../styles/statistics-common.less';
.sb-form-item-ten();
</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,230 @@
<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">
@import '../styles/statistics-common.less';
.retain-statistics-container {
display: flex;
flex-direction: column;
height: calc(100vh - 220px);
gap: 8px;
}
.query-section,
.table-section {
.sb-white-panel(8px);
}
.table-section {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
}
.sb-form-item-zero();
.table-container {
flex: 1;
min-height: 0;
overflow: hidden;
}
.sb-table-compact();
</style>

View File

@@ -0,0 +1,367 @@
<template>
<div class="user-statistics-page" v-loading="loading">
<div v-if="mode !== 'null'" 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 !== 'null' ? 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'
if (this.mode === 'year') return 'year'
return ''
},
displayFormat() {
if (this.mode === 'day') return 'yyyy-MM-dd'
if (this.mode === 'month') return 'yyyy-MM'
if (this.mode === 'year') return 'yyyy'
return ''
},
valueFormat() {
return this.displayFormat
},
timePrefix() {
// if (this.mode === 'day') return '当日'
// if (this.mode === 'month') return '当月'
// if (this.mode === 'year') 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)
if (this.mode === 'year') return formatYear(now)
return null
},
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',
timeout: 0
}).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">
@import '../styles/statistics-common.less';
.sb-form-item-zero();
.sb-table-compact();
.user-statistics-page {
display: flex;
flex-direction: column;
gap: 8px;
}
.query-section,
.summary-section,
.table-section {
.sb-white-panel(8px);
padding-bottom: 0;
}
.query-section {
padding-top: 0;
}
.summary-section {
display: flex;
flex-wrap: nowrap;
gap: 12px;
}
.summary-card {
.sb-summary-card-shell(180px, auto, auto);
min-width: 180px;
}
.summary-card__label {
margin-bottom: 8px;
}
.table-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.table-title {
.sb-title-16();
}
.sb-summary-card-text();
@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>

View File

@@ -43,6 +43,7 @@
<div ref="tableContainer" class="table-container"> <div ref="tableContainer" class="table-container">
<el-table <el-table
class="statistics-table"
:data="dataList" :data="dataList"
border border
:header-cell-style="{ textAlign: 'center', padding: '6px 0' }" :header-cell-style="{ textAlign: 'center', padding: '6px 0' }"
@@ -234,29 +235,21 @@ export default {
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.el-form-item { @import '../styles/statistics-common.less';
margin-bottom: 0 !important;
}
/deep/ .el-table .cell, .sb-form-item-zero();
/deep/ .el-table th > .cell { .sb-table-compact();
padding-top: 4px; .sb-statistics-table-min();
padding-bottom: 4px;
line-height: 20px;
}
.all-vip-statistics { .all-vip-statistics {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
height: calc(100vh - 220px);
} }
.summary-section, .summary-section,
.detail-section { .detail-section {
background: #fff; .sb-white-panel(8px);
border-radius: 4px;
padding: 8px;
} }
.summary-section { .summary-section {
@@ -267,22 +260,16 @@ export default {
.summary-block__title { .summary-block__title {
margin-bottom: 16px; margin-bottom: 16px;
font-size: 16px; .sb-title-16();
font-weight: 700;
color: #303133;
} }
.summary-grid { .summary-grid {
display: grid; .sb-summary-section-flex(12px);
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 12px;
} }
.summary-card { .summary-card {
padding: 16px; .sb-summary-card-shell(120px, 120px, auto);
border: 1px solid #ebeef5; min-width: 120px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(31, 45, 61, 0.06);
} }
.summary-card--expired { .summary-card--expired {
@@ -295,38 +282,23 @@ export default {
.summary-card__label { .summary-card__label {
margin-bottom: 8px; margin-bottom: 8px;
font-size: 14px;
color: #606266;
}
.summary-card__value {
font-size: 24px;
font-weight: 700;
color: #1f2d3d;
} }
.detail-section { .detail-section {
display: flex; .sb-flex-column-fill();
flex: 1;
flex-direction: column;
overflow: hidden;
} }
.detail-header { .detail-header {
display: flex; .sb-detail-header(16px);
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
} }
.detail-title { .detail-title {
font-size: 16px; .sb-title-16();
font-weight: 700;
color: #303133;
} }
.sb-summary-card-text();
.table-container { .table-container {
flex: 1; flex: 1;
overflow: hidden;
} }
</style> </style>

View File

@@ -27,6 +27,7 @@
<div ref="tableContainer" class="table-container"> <div ref="tableContainer" class="table-container">
<el-table <el-table
class="statistics-table"
:data="dataList" :data="dataList"
border border
:header-cell-style="{ textAlign: 'center', padding: '6px 0' }" :header-cell-style="{ textAlign: 'center', padding: '6px 0' }"
@@ -202,82 +203,61 @@ export default {
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
/deep/ .el-table .cell, @import '../styles/statistics-common.less';
/deep/ .el-table th > .cell {
padding-top: 4px; .sb-table-compact();
padding-bottom: 4px; .sb-statistics-table-min();
line-height: 20px;
}
.vip-expires-statistics { .vip-expires-statistics {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
height: calc(100vh - 220px);
} }
.summary-section, .summary-section,
.detail-section { .detail-section {
background: #fff; .sb-white-panel(8px);
border-radius: 4px;
padding: 8px;
} }
.detail-section { .detail-section {
display: flex; .sb-flex-column-fill();
flex: 1;
flex-direction: column;
overflow: hidden;
} }
.section-title { .section-title {
margin-bottom: 16px; margin-bottom: 16px;
font-size: 16px; .sb-title-16();
font-weight: 700;
color: #303133;
} }
.summary-grid { .summary-grid {
display: grid; .sb-summary-section-flex(12px);
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 12px;
} }
.summary-card { .summary-card {
padding: 16px; .sb-summary-card-shell(
border: 1px solid #ebeef5; 120px,
border-radius: 8px; 120px,
background: linear-gradient(180deg, #f7fbff 0%, #ffffff 100%); auto,
box-shadow: 0 4px 12px rgba(31, 45, 61, 0.06); 16px,
linear-gradient(180deg, #f7fbff 0%, #ffffff 100%)
);
min-width: 120px;
} }
.summary-card__label { .summary-card__label {
margin-bottom: 8px; margin-bottom: 8px;
font-size: 14px;
color: #606266;
}
.summary-card__value {
font-size: 24px;
font-weight: 700;
color: #1f2d3d;
} }
.detail-header { .detail-header {
display: flex; .sb-detail-header(16px);
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
} }
.detail-title { .detail-title {
font-size: 16px; .sb-title-16();
font-weight: 700;
color: #303133;
} }
.sb-summary-card-text();
.table-container { .table-container {
flex: 1; flex: 1;
overflow: hidden;
} }
</style> </style>

View File

@@ -41,8 +41,8 @@ export default {
} }
</script> </script>
<style scoped> <style scoped lang="less">
.el-form .el-form-item { @import '../styles/statistics-common.less';
margin-bottom: 10px !important;
} .sb-form-item-ten();
</style> </style>

View File

@@ -61,6 +61,7 @@
<div ref="tableContainer" class="table-container"> <div ref="tableContainer" class="table-container">
<el-table <el-table
class="statistics-table"
:data="dataList" :data="dataList"
border border
:header-cell-style="{ textAlign: 'center', padding: '6px 0' }" :header-cell-style="{ textAlign: 'center', padding: '6px 0' }"
@@ -214,123 +215,85 @@ export default {
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.el-form-item { @import '../styles/statistics-common.less';
margin-bottom: 0 !important;
}
/deep/ .el-table .cell, .sb-form-item-zero();
/deep/ .el-table th > .cell { .sb-table-compact();
padding-top: 4px; .sb-statistics-table-min();
padding-bottom: 4px;
line-height: 20px;
}
.month-vip-statistics { .month-vip-statistics {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
height: calc(100vh - 220px);
} }
.query-section, .query-section,
.summary-section, .summary-section,
.detail-section { .detail-section {
background: #fff; .sb-white-panel(8px);
border-radius: 4px; }
padding: 8px;
.query-section {
padding-top: 0;
padding-bottom: 0;
} }
.summary-section { .summary-section {
display: flex; .sb-summary-section-flex(12px);
flex-wrap: wrap;
gap: 12px;
} }
.summary-card { .summary-card {
display: flex; .sb-summary-card-shell(180px, 180px, 190px);
flex-direction: column;
flex: 1 1 260px;
width: 260px;
min-height: 190px;
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--two-columns { .summary-card--two-columns {
flex-basis: 360px; .sb-summary-card-two-columns(280px, 280px);
width: 360px;
} }
.summary-card__title { .summary-card__title {
margin-bottom: 14px; .sb-summary-card-title(14px);
font-size: 16px;
font-weight: 700;
color: #303133;
} }
.summary-card__body { .summary-card__body {
display: flex; .sb-summary-card-body(10px);
flex: 1;
flex-direction: column;
gap: 10px;
} }
.summary-card--two-columns .summary-card__body { .summary-card--two-columns .summary-card__body {
display: grid; .sb-summary-card-body-two-columns(10px, 30px);
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 30px;
} }
.summary-line { .summary-line {
display: flex; .sb-summary-line(12px);
align-items: flex-start;
justify-content: space-between;
gap: 12px;
line-height: 1.5;
} }
.summary-line__label { .summary-line__label {
color: #606266; .sb-summary-line-label();
} }
.summary-line__value { .summary-line__value {
font-weight: 700; .sb-summary-line-value();
color: #1f2d3d;
text-align: right;
} }
.detail-section { .detail-section {
display: flex; .sb-flex-column-fill();
flex: 1;
flex-direction: column;
overflow: hidden;
} }
.detail-header { .detail-header {
display: flex; .sb-detail-header(16px);
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
} }
.detail-title { .detail-title {
font-size: 16px; .sb-title-16();
font-weight: 700;
color: #303133;
} }
.table-container { .table-container {
flex: 1; flex: 1;
overflow: hidden;
} }
@media (max-width: 1680px) { @media (max-width: 1680px) {
.summary-card { .summary-card {
flex-basis: 240px; flex-basis: 170px;
width: 240px; width: 170px;
} }
} }

View File

@@ -232,16 +232,10 @@ export default {
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.el-form-item { @import '../styles/statistics-common.less';
margin-bottom: 0 !important;
}
/deep/ .el-table .cell, .sb-form-item-zero();
/deep/ .el-table th > .cell { .sb-table-compact();
padding-top: 4px;
padding-bottom: 4px;
line-height: 20px;
}
.year-vip-statistics { .year-vip-statistics {
display: flex; display: flex;
@@ -252,78 +246,53 @@ export default {
.query-section, .query-section,
.summary-section, .summary-section,
.table-section { .table-section {
padding: 8px; .sb-white-panel(8px);
background: #fff; }
border-radius: 4px;
.query-section {
padding-top: 0;
padding-bottom: 0;
} }
.summary-section { .summary-section {
display: flex; .sb-summary-section-flex(12px);
flex-wrap: wrap;
gap: 12px;
} }
.summary-card { .summary-card {
display: flex; .sb-summary-card-shell(180px, 180px, 180px);
flex: 1 1 260px;
width: 260px;
flex-direction: column;
min-height: 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--two-columns { .summary-card--two-columns {
flex-basis: 360px; .sb-summary-card-two-columns(280px, 280px);
width: 360px;
} }
.summary-card__title { .summary-card__title {
margin-bottom: 14px; .sb-summary-card-title(14px);
font-size: 16px;
font-weight: 700;
color: #303133;
} }
.summary-card__body { .summary-card__body {
display: flex; .sb-summary-card-body(10px);
flex: 1;
flex-direction: column;
gap: 10px;
} }
.summary-card--two-columns .summary-card__body { .summary-card--two-columns .summary-card__body {
display: grid; .sb-summary-card-body-two-columns(10px, 30px);
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 30px;
} }
.summary-line { .summary-line {
display: flex; .sb-summary-line(12px);
align-items: flex-start;
justify-content: space-between;
gap: 12px;
line-height: 1.5;
} }
.summary-line__label { .summary-line__label {
color: #606266; .sb-summary-line-label();
} }
.summary-line__value { .summary-line__value {
font-weight: 700; .sb-summary-line-value();
color: #1f2d3d;
text-align: right;
} }
.table-title { .table-title {
margin-bottom: 12px; margin-bottom: 12px;
font-size: 16px; .sb-title-16();
font-weight: 700;
color: #303133;
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {

View File

@@ -27,7 +27,7 @@
size="small" size="small"
style="width: 100%;" style="width: 100%;"
v-model="dataForm.userKey" v-model="dataForm.userKey"
debounce="500" :debounce="500"
:fetch-suggestions="loadAll" :fetch-suggestions="loadAll"
placeholder="请输入手机号/邮箱" placeholder="请输入手机号/邮箱"
@select="handleSelect" @select="handleSelect"