Files
api-client/src/views/index/components/TrackingCharts.vue
2026-02-21 00:44:51 +08:00

529 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="tracking-charts">
<el-row :gutter="20" style="margin-bottom: 20px">
<el-col :span="4">
<el-card shadow="never">
<div style="font-size: 14px; color: #909399">当前上报总数</div>
<div style="font-size: 24px; font-weight: bold; color: #303133; margin-top: 5px">{{ list.length }}</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="never">
<div style="font-size: 14px; color: #909399">用户数</div>
<div style="font-size: 24px; font-weight: bold; color: #303133; margin-top: 5px">{{ userOptions.length }}</div>
</el-card>
</el-col>
</el-row>
<el-card class="filter-card" shadow="never">
<el-form :inline="true" :model="queryForm" size="small" @submit.native.prevent>
<el-form-item label="App">
<el-select v-model="queryForm.appId" filterable>
<el-option v-for="(label, value) in appMap" :key="value" :label="label" :value="value">
<span style="float: left">{{ label }}</span>
<span style="float: right; color: #8492a6; font-size: 13px">{{ value }}</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="User ID">
<el-select v-model="queryForm.userId" allow-create clearable default-first-option filterable placeholder="请输入或选择User ID">
<el-option v-for="item in userOptions" :key="item.value" :label="item.label" :value="item.value">
<span style="float: left">{{ item.label }}</span>
<span v-if="item.value !== item.label" style="float: right; color: #8492a6; font-size: 13px; margin-left: 20px">
{{ item.value }}
</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="事件名称">
<el-select v-model="queryForm.eventName" clearable filterable placeholder="全部事件">
<el-option v-for="(label, value) in eventNameMap" :key="value" :label="label" :value="value">
<span style="float: left">{{ label }}</span>
<span style="float: right; color: #8492a6; font-size: 13px">{{ value }}</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="事件类型">
<el-select v-model="queryForm.eventType" clearable placeholder="全部类型">
<el-option v-for="(label, value) in eventTypeMap" :key="value" :label="label" :value="value" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="queryForm.dateRange"
:default-time="['00:00:00', '23:59:59']"
end-placeholder="结束日期"
:picker-options="pickerOptions"
range-separator=""
start-placeholder="开始日期"
type="datetimerange"
value-format="yyyy-MM-dd HH:mm:ss"
/>
</el-form-item>
<el-form-item>
<el-button icon="el-icon-search" type="primary" @click="handleQuery">查询</el-button>
<el-button icon="el-icon-refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<div v-loading="loading" class="charts-container">
<el-row :gutter="20">
<el-col :span="24">
<el-card class="chart-card" shadow="never">
<div slot="header"><span>时段趋势</span></div>
<v-chart autoresize class="chart" :option="trendOption" />
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px">
<el-col :span="12">
<el-card class="chart-card" shadow="never">
<div slot="header"><span>事件名称分布</span></div>
<v-chart autoresize class="chart" :option="eventNameOption" @click="handleEventNameClick" />
</el-card>
</el-col>
<el-col :span="12">
<el-card class="chart-card" shadow="never">
<div slot="header"><span>事件类型分布</span></div>
<v-chart autoresize class="chart" :option="eventTypeOption" @click="handleEventTypeClick" />
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px">
<el-col :span="24">
<el-card class="chart-card" shadow="never">
<div slot="header"><span>页面访问排行</span></div>
<v-chart autoresize class="chart" :option="pageOption" />
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px">
<el-col :span="24">
<el-card shadow="never">
<div slot="header" style="display: flex; justify-content: space-between; align-items: center">
<span>
详细日志列表
<span v-if="filterText" style="font-size: 12px; color: #666">({{ filterText }})</span>
</span>
<el-button v-if="isFiltered" size="mini" type="text" @click="clearFilter">清除筛选</el-button>
</div>
<el-table :data="paginatedList" style="width: 100%">
<el-table-column label="时间" prop="createdAt" width="160">
<template slot-scope="{ row }">
{{ formatTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="事件名称" prop="eventName" show-overflow-tooltip>
<template slot-scope="{ row }">
{{ eventNameMap[row.eventName] || row.eventName }}
</template>
</el-table-column>
<el-table-column label="事件类型" prop="eventType" show-overflow-tooltip>
<template slot-scope="{ row }">
{{ eventTypeMap[row.eventType] || row.eventType }}
</template>
</el-table-column>
<el-table-column label="页面" prop="page" show-overflow-tooltip />
<el-table-column label="元素ID" prop="elementId" show-overflow-tooltip />
<el-table-column label="访问地址" prop="address" show-overflow-tooltip />
<el-table-column label="元素内容" prop="elementContent" show-overflow-tooltip />
<el-table-column label="User ID" prop="userId" show-overflow-tooltip>
<template slot-scope="{ row }">
<el-link type="primary" :underline="false" @click="goToUser(row.userId)">{{ row.userId }}</el-link>
</template>
</el-table-column>
</el-table>
<el-pagination
background
:current-page="currentPage"
layout="total, sizes, prev, pager, next, jumper"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
style="margin-top: 20px; text-align: right"
:total="total"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
/>
</el-card>
</el-col>
</el-row>
</div>
</div>
</template>
<script>
import { getTrackingLogsList } from '@/api/system'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, PieChart, BarChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent, TitleComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import dayjs from 'dayjs'
import { groupBy, countBy } from 'lodash'
import { eventNameMap, eventTypeMap, appMap, userMap } from '../utils'
use([CanvasRenderer, LineChart, PieChart, BarChart, GridComponent, TooltipComponent, LegendComponent, TitleComponent])
export default {
name: 'TrackingCharts',
components: {
VChart,
},
data() {
return {
eventNameMap,
eventTypeMap,
appMap,
userMap,
loading: false,
queryForm: {
appId: '69665538a49b8ae3be50fe5d',
userId: '',
eventName: '',
eventType: '',
dateRange: [],
},
userOptions: [],
pickerOptions: {
shortcuts: [
{
text: '今天',
onClick(picker) {
const end = new Date()
const start = new Date()
start.setHours(0, 0, 0, 0)
end.setHours(23, 59, 59, 999)
picker.$emit('pick', [start, end])
},
},
{
text: '昨天',
onClick(picker) {
const end = new Date()
const start = new Date()
start.setTime(start.getTime() - 3600 * 1000 * 24)
start.setHours(0, 0, 0, 0)
end.setTime(end.getTime() - 3600 * 1000 * 24)
end.setHours(23, 59, 59, 999)
picker.$emit('pick', [start, end])
},
},
{
text: '最近一周',
onClick(picker) {
const end = new Date()
const start = new Date()
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7)
picker.$emit('pick', [start, end])
},
},
],
},
list: [],
filteredList: [],
currentPage: 1,
pageSize: 10,
isFiltered: false,
filterText: '',
trendOption: {},
eventNameOption: {},
eventTypeOption: {},
pageOption: {},
}
},
computed: {
total() {
return this.filteredList.length
},
paginatedList() {
const start = (this.currentPage - 1) * this.pageSize
const end = start + this.pageSize
return this.filteredList.slice(start, end)
},
},
created() {
// 默认选中今天
const end = new Date()
const start = new Date()
start.setHours(0, 0, 0, 0)
end.setHours(23, 59, 59, 999)
this.queryForm.dateRange = [dayjs(start).format('YYYY-MM-DD HH:mm:ss'), dayjs(end).format('YYYY-MM-DD HH:mm:ss')]
this.fetchData()
},
methods: {
formatTime(time) {
return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
},
async fetchData() {
this.loading = true
try {
const params = {
appId: this.queryForm.appId,
userId: this.queryForm.userId || undefined,
eventName: this.queryForm.eventName || undefined,
eventType: this.queryForm.eventType || undefined,
startTime: this.queryForm.dateRange ? this.queryForm.dateRange[0] : undefined,
endTime: this.queryForm.dateRange ? this.queryForm.dateRange[1] : undefined,
pageNo: 1,
pageSize: 10000, // 获取足够多的数据进行前端统计,或者应该依赖后端聚合接口
}
const { data } = await getTrackingLogsList(params)
this.list = data.list || []
// 如果没有筛选用户,则更新用户选项列表
if (!this.queryForm.userId) {
const userIds = this.list.map((item) => item.userId).filter(Boolean)
const uniqueUsers = [...new Set(userIds)]
this.userOptions = uniqueUsers.map((id) => ({ value: id, label: this.userMap[id] || id }))
}
this.filteredList = [...this.list]
this.isFiltered = false
this.filterText = ''
this.currentPage = 1
this.processData()
} catch (error) {
console.error(error)
this.$baseMessage('获取数据失败', 'error')
} finally {
this.loading = false
}
},
handleQuery() {
this.fetchData()
},
resetQuery() {
this.queryForm.appId = '69665538a49b8ae3be50fe5d'
this.queryForm.userId = ''
this.queryForm.eventName = ''
this.queryForm.eventType = ''
const end = new Date()
const start = new Date()
start.setHours(0, 0, 0, 0)
end.setHours(23, 59, 59, 999)
this.queryForm.dateRange = [dayjs(start).format('YYYY-MM-DD HH:mm:ss'), dayjs(end).format('YYYY-MM-DD HH:mm:ss')]
this.fetchData()
},
handleSizeChange(val) {
this.pageSize = val
this.currentPage = 1
},
handleCurrentChange(val) {
this.currentPage = val
},
clearFilter() {
this.filteredList = [...this.list]
this.isFiltered = false
this.filterText = ''
this.currentPage = 1
},
goToUser(userId) {
if (!userId) return
this.$router.push({
path: '/spring/user/user',
query: { userId },
})
},
handleEventNameClick(params) {
const name = params.name
// name 可能是中文映射名,需要反向查找 key或者在 processData 时把 key 存入
// 简单起见processData 中 name 已经是中文名或者 key 了。
// 如果我们用中文名展示,过滤时比较麻烦。
// 最好在 processData 中让 echarts data item 包含原始 key。
// 重新检查 processData
// const eventNameData = Object.keys(eventNameCounts).map((key) => ({
// name: this.eventNameMap[key] || key,
// value: eventNameCounts[key],
// key: key // 添加原始 key
// }))
const key = params.data.key || params.name // 如果没有 key 属性,回退到 name
this.filteredList = this.list.filter((item) => {
const itemKey = item.eventName
const itemName = this.eventNameMap[itemKey] || itemKey
// 如果 params.data.key 存在,则精确匹配 key
if (params.data.key) {
return item.eventName === params.data.key
}
return itemName === name
})
this.isFiltered = true
this.filterText = `筛选事件: ${name}`
this.currentPage = 1
},
handleEventTypeClick(params) {
const name = params.name
const key = params.data.key || params.name
this.filteredList = this.list.filter((item) => {
if (params.data.key) {
return item.eventType === params.data.key
}
const itemKey = item.eventType
const itemName = this.eventTypeMap[itemKey] || itemKey
return itemName === name
})
this.isFiltered = true
this.filterText = `筛选类型: ${name}`
this.currentPage = 1
},
processData() {
// 1. 时段趋势
const logsByHour = groupBy(this.list, (item) => dayjs(item.createdAt).format('HH:00'))
const hours = []
const counts = []
for (let i = 0; i < 24; i++) {
const hour = `${String(i).padStart(2, '0')}:00`
hours.push(hour)
counts.push(logsByHour[hour] ? logsByHour[hour].length : 0)
}
this.trendOption = {
tooltip: {
trigger: 'axis',
},
xAxis: {
type: 'category',
data: hours,
},
yAxis: {
type: 'value',
},
series: [
{
data: counts,
type: 'line',
smooth: true,
areaStyle: {},
},
],
}
// 2. 事件名称分布
const eventNameCounts = countBy(this.list, 'eventName')
const eventNameData = Object.keys(eventNameCounts).map((key) => ({
name: this.eventNameMap[key] || key,
value: eventNameCounts[key],
key: key, // 保存原始key
}))
this.eventNameOption = {
tooltip: {
trigger: 'item',
formatter: '{b}: {c} ({d}%)',
},
legend: {
orient: 'vertical',
left: 'left',
},
series: [
{
name: '事件名称',
type: 'pie',
radius: '50%',
data: eventNameData,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
},
],
}
// 3. 事件类型分布
const eventTypeCounts = countBy(this.list, 'eventType')
const eventTypeData = Object.keys(eventTypeCounts).map((key) => ({
name: this.eventTypeMap[key] || key,
value: eventTypeCounts[key],
key: key, // 保存原始key
}))
this.eventTypeOption = {
tooltip: {
trigger: 'item',
formatter: '{b}: {c} ({d}%)',
},
legend: {
orient: 'vertical',
left: 'left',
},
series: [
{
name: '事件类型',
type: 'pie',
radius: '50%',
data: eventTypeData,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
},
],
}
// 4. 页面访问排行
const pageCounts = countBy(this.list, 'page')
// 排序并取前10
const sortedPages = Object.keys(pageCounts)
.sort((a, b) => pageCounts[b] - pageCounts[a])
.slice(0, 10)
const pageData = sortedPages.map((key) => pageCounts[key])
this.pageOption = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'value',
boundaryGap: [0, 0.01],
},
yAxis: {
type: 'category',
data: sortedPages,
},
series: [
{
name: '访问次数',
type: 'bar',
data: pageData,
},
],
}
},
},
}
</script>
<style scoped>
.tracking-charts {
padding: 20px;
}
.filter-card {
margin-bottom: 20px;
}
.chart-card {
height: 400px;
}
.chart {
height: 300px;
}
</style>