数据统计页面接口对接

This commit is contained in:
雷校云
2026-03-05 15:29:53 +08:00
parent 8b5bfa316a
commit ff1f5b4cab
8 changed files with 791 additions and 4 deletions

View File

@@ -31,3 +31,19 @@ export const ApiContactsExport = (data: any) => {
method: 'get'
})
}
// 获取总览统计数据
export const ApiStats = (data: any) => {
return request({
url: `/api/stats?period=${data.period}`,
method: 'get'
})
}
// 获取总览统计数据
export const ApiStatsDaily = (data: any) => {
return request({
url: `/api/stats/daily?days=${data.days}`,
method: 'get'
})
}

View File

@@ -324,6 +324,25 @@ export const constantRoutes: RouteRecordRaw[] = [
}
}
]
},
{
path: '/data',
name: 'Data',
component: Layout,
meta: {
title: '数据统计',
icon: 'setting'
},
children: [
{
path: 'dataStatistics',
name: 'DataStatistics',
component: () => import('@/views/DataStatistics/index.vue'),
meta: {
title: '数据统计'
}
}
]
}
// 注册平台登记
// {

View File

@@ -187,11 +187,19 @@ export function deepCloneByJSON(obj: any) {
}
/**
* 将 ISO 8601 时间格式转换为年月日时分秒格式
* 将 ISO 8601 时间格式转换为指定格式
* @param isoString ISO 8601 格式的时间字符串,如 "2026-02-28T14:10:51.966269"
* @returns 格式化后的时间字符串,如 "2026-02-28 14:10:51"
* @param format 格式化模板,默认 'YYYY-MM-DD HH:mm:ss'
* @returns 格式化后的时间字符串
*
* @example
* formatISOToDateTime('2026-02-28T14:10:51.966269') // '2026-02-28 14:10:51'
* formatISOToDateTime('2026-02-28T14:10:51.966269', 'YYYY-MM-DD') // '2026-02-28'
* formatISOToDateTime('2026-02-28T14:10:51.966269', 'YYYY/MM/DD HH:mm') // '2026/02/28 14:10'
* formatISOToDateTime('2026-02-28T14:10:51.966269', 'MM-DD HH:mm') // '02-28 14:10'
* formatISOToDateTime('2026-02-28T14:10:51.966269', 'YYYY 年 MM 月 DD 日') // '2026 年 02 月 28 日'
*/
export function formatISOToDateTime(isoString: string): string {
export function formatISOToDateTime(isoString: string, format = 'YYYY-MM-DD HH:mm:ss'): string {
if (!isoString) return ''
try {
@@ -210,7 +218,14 @@ export function formatISOToDateTime(isoString: string): string {
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
// 替换格式化模板中的占位符
return format
.replace('YYYY', String(year))
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds)
} catch (error) {
console.error('日期格式化错误:', error)
return isoString

View File

@@ -0,0 +1,199 @@
<template>
<div class="app-container">
<div class="chart-header">
<div class="chart-title">每日数据明细</div>
<el-radio-group v-model="queryParams.days" size="small" @change="handleTimeChange">
<el-radio-button v-for="item in timeOptions" :key="item.value" :value="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
</div>
<el-card shadow="hover" class="data-table">
<div class="data-table__toolbar">
<div class="data-table__toolbar--actions">
<!-- <el-button type="success" icon="plus" @click="handleOpenDialog()">新增</el-button>-->
</div>
</div>
<el-table
ref="dataTableRef"
v-loading="loading"
:data="roleList"
highlight-current-row
border
class="data-table__content"
>
<el-table-column label="联系人数" prop="contacts" />
<el-table-column label="回复人数" prop="replied" />
<el-table-column label="微信相关数量" prop="wechat"></el-table-column>
<el-table-column label="回复率" prop="reply_rate" />
<el-table-column label="日期时间" prop="date">
<template #default="scope">
{{ formatISOToDateTime(scope.row?.date || '') }}
</template>
</el-table-column>
</el-table>
<pagination
v-if="total > 0"
v-model:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="fetchData"
/>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ApiStatsDaily } from '@/api/ContactInformation'
import { formatISOToDateTime } from '@/utils/auxiliaryFunction'
import { ref } from 'vue'
defineOptions({
name: 'Role',
inheritAttrs: false
})
// const queryFormRef = ref()
const loading = ref(false)
const total = ref(0)
const queryParams = reactive<any>({
pageNum: 1,
pageSize: 10,
days: '1'
})
// 表格数据
const roleList = ref<any[]>()
const timeOptions = [
{ label: '今日', value: '1' },
{ label: '近 7 天', value: '7' },
{ label: '近 30 天', value: '30' }
]
// // 弹窗
// const dialog = reactive({
// title: '',
// visible: false
// })
// 获取数据
function fetchData() {
loading.value = true
ApiStatsDaily(queryParams)
.then((res: any) => {
roleList.value = res.data
// total.value = res.data.total
})
.finally(() => {
loading.value = false
})
}
// 查询(重置页码后获取数据)
function handleQuery() {
queryParams.pageNum = 1
fetchData()
}
// // 重置查询
// function handleResetQuery() {
// if (queryFormRef.value) queryFormRef.value?.resetFields()
// queryParams.pageNum = 1
// fetchData()
// }
const handleTimeChange = () => {
handleQuery()
}
onMounted(() => {
handleQuery()
})
</script>
<style lang="scss" scoped>
.headline-statistics {
display: flex;
gap: 20px;
margin-bottom: 16px;
height: 140px;
}
::v-deep(.statistics-box) {
display: flex;
//justify-content: center;
align-items: center;
color: #fff;
font-size: 30px;
gap: 20px;
.statistics-box-img {
width: 70px;
height: 70px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 15px;
}
.img-bg1 {
background: linear-gradient(135deg, #7f7fd5 0%, #9156e8 100%);
}
.img-bg2 {
background: linear-gradient(135deg, #f199ee 0%, #ef6b8b 100%);
}
.img-bg3 {
background: linear-gradient(135deg, #56bafd 0%, #16e9fd 100%);
}
.img-bg4 {
background: linear-gradient(135deg, #51eb90 0%, #49f7d2 100%);
}
.statistics-box-text {
//flex: 1;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
color: #adb0b3;
& > span:nth-child(1) {
font-size: 14px;
}
& > span:nth-child(2) {
font-size: 26px;
color: #000;
font-weight: 700;
}
& > span:nth-child(3) {
font-size: 12px;
}
}
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.chart-title {
font-size: 20px;
font-weight: 600;
color: #303133;
}
.time-filter {
display: flex;
gap: 8px;
:deep(.el-button) {
padding: 5px 12px;
font-size: 13px;
&.el-button--primary {
background-color: #409eff;
border-color: #409eff;
color: #fff;
}
}
}
</style>

View File

@@ -0,0 +1,224 @@
<template>
<div class="chart-container">
<div ref="chartRef" class="trend-chart"></div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import * as echarts from 'echarts'
import type { EChartsOption } from 'echarts'
import { ApiStatsDaily } from '@/api/ContactInformation'
import { formatISOToDateTime } from '@/utils/auxiliaryFunction'
interface Props {
height?: string
}
withDefaults(defineProps<Props>(), {
height: '350px'
})
const data = ref<any>({
xAxis: [] as string[],
series: [
{
name: '联系人数',
data: []
},
{
name: '已回复',
data: []
},
{
name: '微信交换',
data: []
}
]
})
const chartRef = ref<HTMLElement | null>(null)
let chartInstance: echarts.ECharts | null = null
// 颜色配置
const seriesColors = ['#5B9BFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399']
// 初始化图表
const initChart = () => {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value)
const option: EChartsOption = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
top: '10%',
containLabel: true
},
xAxis: {
type: 'category',
data: data.value.xAxis,
axisTick: {
show: false
},
axisLine: {
show: true,
lineStyle: {
color: '#D7DDE3'
}
},
axisLabel: {
color: '#909399',
margin: 20,
rotate: 45,
interval: 0,
fontSize: 12
}
},
yAxis: {
type: 'value',
min: 0,
max: 1,
interval: 0.2,
axisLine: {
show: false
},
axisTick: {
show: false
},
splitLine: {
show: true,
lineStyle: {
color: '#E8E8E8',
type: 'solid'
}
},
axisLabel: {
color: '#909399',
fontSize: 12
}
},
series: data.value.series.map((item: any, index: number) => ({
name: item.name,
type: 'bar',
barGap: '10%',
data: item.data,
itemStyle: {
color: seriesColors[index % seriesColors.length],
borderRadius: [2, 2, 0, 0]
},
emphasis: {
itemStyle: {
opacity: 0.8
}
}
})),
legend: {
data: data.value.series.map((item: any) => item.name),
bottom: 0,
icon: 'rect',
itemWidth: 14,
itemHeight: 14,
itemGap: 20,
textStyle: {
color: '#606266',
fontSize: 12
}
}
}
chartInstance.setOption(option)
}
const onApiStatsDaily = (days: string) => {
ApiStatsDaily({ days }).then((res: { data: any[] }) => {
data.value = {
xAxis: res.data?.map((item) => formatISOToDateTime(item.date, 'YYYY-MM-DD')),
series: [
{
name: '联系人数',
data: res.data?.map((item) => item.contacts)
},
{
name: '已回复',
data: res.data?.map((item) => item.replied)
},
{
name: '微信交换',
data: res.data?.map((item) => item.wechat)
}
]
}
})
}
// 监听数据变化
watch(
() => data.value,
(newData: any) => {
if (chartInstance) {
chartInstance.setOption({
xAxis: { data: newData.xAxis },
series: newData.series.map((item: any, index: number) => ({
name: item.name,
data: item.data,
itemStyle: {
color: seriesColors[index % seriesColors.length]
}
})),
legend: {
data: newData.series.map((item: any) => item.name)
}
})
}
},
{ deep: true }
)
// 监听窗口大小变化
const handleResize = () => {
if (chartInstance) {
chartInstance.resize()
}
}
onMounted(() => {
initChart()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
window.removeEventListener('resize', handleResize)
})
defineExpose({
resize: handleResize,
onApiStatsDaily
})
</script>
<style scoped lang="scss">
.chart-container {
width: 100%;
padding: 20px;
background-color: #fff;
border-radius: 4px;
}
.trend-chart {
width: 100%;
height: v-bind(height);
}
</style>

View File

@@ -0,0 +1,7 @@
<template>
<div>TaskDetails</div>
</template>
<script setup lang="ts"></script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,110 @@
<template>
<div class="pre-registration-form">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="auto"
label-position="top"
>
<el-form-item label="任务类型" prop="task_type">
<!-- <el-input v-model="formData.task_type" placeholder="请输入" />-->
<el-select v-model="formData.task_type" placeholder="请选择">
<el-option label="检查登录" value="check_login" />
<el-option label="招聘" value="boss_recruit" />
</el-select>
</el-form-item>
<el-form-item label="任务参数" prop="params">
<el-input v-model="formData.params" placeholder="请输入" />
</el-form-item>
</el-form>
</div>
</template>
<script lang="ts" setup>
import { ElMessage } from 'element-plus'
import { deepCloneByJSON } from '@/utils/auxiliaryFunction'
const props = defineProps({
newData: {
type: Object,
default: () => ({})
}
})
const formRef = ref()
// 表单数据
const formData = reactive<any>({
task_type: '',
worker_id: '',
account_name: '',
params: ''
})
const formRules = reactive<any>({
task_type: [{ required: true, message: '请输入', trigger: 'blur' }],
params: [{ required: true, message: '请输入', trigger: 'blur' }]
})
onMounted(() => {
setFormData(props.newData)
})
const getForm = () => {
return formData
}
const setFormData = (data: any) => {
if (data && Object.keys(data).length > 0) {
const data1 = deepCloneByJSON(data)
Object.assign(formData, data1)
}
}
const submit = (): Promise<boolean> => {
return new Promise((resolve, reject) => {
formRef.value?.validate((valid: boolean) => {
if (valid) {
resolve(true)
} else {
ElMessage.error('请完善必填信息')
reject(false)
}
})
})
}
defineExpose({
submit,
getForm
})
</script>
<style scoped lang="scss">
.pre-registration-form {
width: 100%;
padding-right: 20px;
overflow: hidden;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.3);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.5);
}
}
.section {
display: flex;
flex-direction: column;
gap: 20px;
}
</style>

View File

@@ -0,0 +1,197 @@
<template>
<div class="app-container">
<div class="headline-statistics">
<el-card shadow="hover" body-class="statistics-box" style="flex: 1">
<div class="statistics-box-img img-bg1">
<el-icon><User /></el-icon>
</div>
<div class="statistics-box-text">
<span>总联系人数</span>
<span>{{ apiStatsData.contacts?.total || 0 }}</span>
<span>今日{{ apiStatsData.contacts?.today || 0 }}</span>
</div>
</el-card>
<el-card shadow="hover" body-class="statistics-box" style="flex: 1">
<div class="statistics-box-img img-bg2">
<el-icon><ChatDotRound /></el-icon>
</div>
<div class="statistics-box-text">
<span>已回复人数</span>
<span>{{ apiStatsData.contacts?.replied || 0 }}</span>
<span>回复率{{ apiStatsData.contacts?.reply_rate || 0 }}%</span>
</div>
</el-card>
<el-card shadow="hover" body-class="statistics-box" style="flex: 1">
<div class="statistics-box-img img-bg3">
<el-icon><ChatLineRound /></el-icon>
</div>
<div class="statistics-box-text">
<span>微信交换数</span>
<span>{{ apiStatsData.wechat?.total || 0 }}</span>
<span>成功率{{ apiStatsData.wechat?.success_rate || 0 }}%</span>
</div>
</el-card>
<el-card shadow="hover" body-class="statistics-box" style="flex: 1">
<div class="statistics-box-img img-bg4">
<el-icon><Monitor /></el-icon>
</div>
<div class="statistics-box-text">
<span>在线Worker</span>
<span>{{ apiStatsData?.accounts?.logged_in || 0 }}</span>
<span>总数{{ apiStatsData?.accounts?.total || 0 }}</span>
</div>
</el-card>
</div>
<el-card shadow="hover">
<div class="chart-header">
<div class="chart-title">数据趋势</div>
<el-radio-group v-model="currentTime" size="small" @change="handleTimeChange">
<el-radio-button v-for="item in timeOptions" :key="item.value" :value="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
</div>
<DataTrendChart ref="dataTrendChartRef" />
</el-card>
<el-card shadow="hover">
<DailyDataBreakdown></DailyDataBreakdown>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ApiStats } from '@/api/ContactInformation'
import { Monitor, User, ChatDotRound, ChatLineRound } from '@element-plus/icons-vue'
import DataTrendChart from './components/DataTrendChart.vue'
import { ref } from 'vue'
import DailyDataBreakdown from '@/views/DataStatistics/components/DailyDataBreakdown.vue'
defineOptions({
name: 'Role',
inheritAttrs: false
})
// const queryFormRef = ref()
const dataTrendChartRef = ref()
// 数据
const currentTime = ref('today')
const timeOptions = [
{ label: '今日', value: 'today' },
{ label: '近 7 天', value: 'week' },
{ label: '近 30 天', value: 'month' },
{ label: '全部', value: 'all' }
]
const apiStatsData = ref<any>({})
// // 弹窗
// const dialog = reactive({
// title: '',
// visible: false
// })
// 获取数据
const onApiStats = () => {
ApiStats({ period: currentTime.value }).then((res: any) => {
apiStatsData.value = res.data
})
}
// // 重置查询
// function handleResetQuery() {
// if (queryFormRef.value) queryFormRef.value?.resetFields()
// queryParams.pageNum = 1
// fetchData()
// }
const handleTimeChange = () => {
onApiStats()
}
onMounted(() => {
onApiStats()
dataTrendChartRef.value?.onApiStatsDaily('7')
})
</script>
<style lang="scss" scoped>
.headline-statistics {
display: flex;
gap: 20px;
margin-bottom: 16px;
height: 140px;
}
::v-deep(.statistics-box) {
display: flex;
//justify-content: center;
align-items: center;
color: #fff;
font-size: 30px;
gap: 20px;
.statistics-box-img {
width: 70px;
height: 70px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 15px;
}
.img-bg1 {
background: linear-gradient(135deg, #7f7fd5 0%, #9156e8 100%);
}
.img-bg2 {
background: linear-gradient(135deg, #f199ee 0%, #ef6b8b 100%);
}
.img-bg3 {
background: linear-gradient(135deg, #56bafd 0%, #16e9fd 100%);
}
.img-bg4 {
background: linear-gradient(135deg, #51eb90 0%, #49f7d2 100%);
}
.statistics-box-text {
//flex: 1;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
color: #adb0b3;
& > span:nth-child(1) {
font-size: 14px;
}
& > span:nth-child(2) {
font-size: 26px;
color: #000;
font-weight: 700;
}
& > span:nth-child(3) {
font-size: 12px;
}
}
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.chart-title {
font-size: 20px;
font-weight: 600;
color: #303133;
}
.time-filter {
display: flex;
gap: 8px;
:deep(.el-button) {
padding: 5px 12px;
font-size: 13px;
&.el-button--primary {
background-color: #409eff;
border-color: #409eff;
color: #fff;
}
}
}
</style>