Files
tyapi-frontend/src/pages/admin/statistics/components/RequestFlowGlobe.vue
2026-03-20 13:24:44 +08:00

338 lines
8.0 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="request-flow-page">
<div class="toolbar">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
format="YYYY-MM-DD HH:mm:ss"
class="toolbar-item"
/>
<el-input-number v-model="topN" :min="10" :max="1000" :step="10" class="toolbar-item" />
<el-button type="primary" :loading="loading" @click="loadData">刷新请求流</el-button>
<el-button v-if="selectedIP || selectedPath" @click="clearFilter">清空筛选</el-button>
</div>
<div class="content">
<div ref="chartRef" class="chart"></div>
<div class="side-list">
<h4>TOP 可疑来源</h4>
<div v-if="rows.length === 0" class="empty">暂无数据</div>
<div
v-for="(item, index) in rows.slice(0, 20)"
:key="`${item.ip}-${item.path}-${index}`"
class="row clickable"
@click="selectFlow(item)"
>
<div class="name">{{ item.from_name }} -> {{ item.path }}</div>
<div class="value">{{ item.value }}</div>
</div>
</div>
</div>
<div class="table-card">
<div class="table-title">
可疑IP明细
<span v-if="selectedIP || selectedPath" class="hint">
筛选{{ selectedIP || '-' }} / {{ selectedPath || '-' }}
</span>
</div>
<el-table :data="listRows" border size="small" height="360">
<el-table-column prop="ip" label="IP" width="150" />
<el-table-column prop="path" label="接口路径" min-width="240" />
<el-table-column prop="method" label="方法" width="90" />
<el-table-column prop="request_count" label="次数" width="90" />
<el-table-column prop="trigger_reason" label="触发原因" width="160" />
<el-table-column prop="created_at" label="时间" width="180" />
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next"
@current-change="loadList"
@size-change="handlePageSizeChange"
/>
</div>
</div>
</div>
</template>
<script setup>
import { adminGetSuspiciousIPGeoStream, adminGetSuspiciousIPList } from '@/api/statistics'
import * as echarts from 'echarts'
import { ElMessage } from 'element-plus'
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
const loading = ref(false)
const topN = ref(200)
const dateRange = ref([])
const rows = ref([])
const listRows = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const selectedIP = ref('')
const selectedPath = ref('')
const chartRef = ref(null)
let chart = null
const getDefaultRange = () => {
const now = new Date()
const start = new Date(now.getTime() - 24 * 60 * 60 * 1000)
const format = (d) => {
const y = d.getFullYear()
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
const h = `${d.getHours()}`.padStart(2, '0')
const mi = `${d.getMinutes()}`.padStart(2, '0')
const s = `${d.getSeconds()}`.padStart(2, '0')
return `${y}-${m}-${day} ${h}:${mi}:${s}`
}
return [format(start), format(now)]
}
const renderChart = () => {
if (!chartRef.value) return
if (!chart) chart = echarts.init(chartRef.value)
const lineData = rows.value.map(item => ({
coords: [
[item.from_lng, item.from_lat],
[item.to_lng, item.to_lat]
],
value: item.value
}))
const pointData = rows.value.map(item => ({
name: item.from_name,
value: [item.from_lng, item.from_lat, item.value]
}))
chart.setOption({
backgroundColor: '#040b1b',
tooltip: {
trigger: 'item'
},
xAxis: {
type: 'value',
min: -180,
max: 180,
axisLabel: { color: '#6b7280' },
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.08)' } }
},
yAxis: {
type: 'value',
min: -90,
max: 90,
axisLabel: { color: '#6b7280' },
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.08)' } }
},
series: [
{
name: '请求流',
type: 'lines',
coordinateSystem: 'cartesian2d',
zlevel: 2,
effect: {
show: true,
period: 4,
symbol: 'arrow',
symbolSize: 6
},
lineStyle: {
width: 1,
color: '#4cc9f0',
curveness: 0.2
},
data: lineData
},
{
name: '来源点',
type: 'scatter',
coordinateSystem: 'cartesian2d',
symbolSize: (val) => Math.max(6, Math.min(18, (val[2] || 1) / 2)),
itemStyle: {
color: '#f72585'
},
data: pointData
}
]
})
chart.off('click')
chart.on('click', (params) => {
if (params.seriesName === '来源点' && params.dataIndex >= 0) {
const item = rows.value[params.dataIndex]
if (item) selectFlow(item)
}
})
}
const selectFlow = (item) => {
selectedIP.value = item.ip || ''
selectedPath.value = item.path || ''
page.value = 1
loadList()
}
const clearFilter = () => {
selectedIP.value = ''
selectedPath.value = ''
page.value = 1
loadList()
}
const loadList = async () => {
try {
if (!dateRange.value || dateRange.value.length !== 2) {
dateRange.value = getDefaultRange()
}
const res = await adminGetSuspiciousIPList({
page: page.value,
page_size: pageSize.value,
start_time: dateRange.value[0],
end_time: dateRange.value[1],
ip: selectedIP.value || undefined,
path: selectedPath.value || undefined
})
listRows.value = res.data?.items || []
total.value = res.data?.total || 0
} catch (error) {
console.error('加载可疑IP明细失败:', error)
ElMessage.error('加载可疑IP明细失败')
}
}
const handlePageSizeChange = (size) => {
pageSize.value = size
page.value = 1
loadList()
}
const loadData = async () => {
loading.value = true
try {
if (!dateRange.value || dateRange.value.length !== 2) {
dateRange.value = getDefaultRange()
}
const res = await adminGetSuspiciousIPGeoStream({
start_time: dateRange.value[0],
end_time: dateRange.value[1],
top_n: topN.value
})
rows.value = res.data || []
await nextTick()
renderChart()
loadList()
} catch (error) {
console.error('加载请求流失败:', error)
ElMessage.error('加载请求流失败')
} finally {
loading.value = false
}
}
onMounted(() => {
dateRange.value = getDefaultRange()
loadData()
})
onUnmounted(() => {
if (chart) {
chart.dispose()
chart = null
}
})
</script>
<style scoped>
.request-flow-page {
width: 100%;
}
.toolbar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.toolbar-item {
min-width: 220px;
}
.content {
display: grid;
grid-template-columns: 1fr 320px;
gap: 12px;
}
.chart {
height: 620px;
border-radius: 10px;
overflow: hidden;
}
.side-list {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 12px;
max-height: 620px;
overflow-y: auto;
}
.row {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
padding: 8px 0;
border-bottom: 1px solid #f3f4f6;
}
.clickable {
cursor: pointer;
}
.clickable:hover {
background: #f9fafb;
}
.name {
color: #374151;
word-break: break-all;
}
.value {
color: #111827;
font-weight: 600;
}
.empty {
color: #9ca3af;
}
.table-card {
margin-top: 12px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 12px;
}
.table-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
}
.hint {
margin-left: 6px;
font-size: 12px;
color: #6b7280;
}
.pager {
margin-top: 10px;
display: flex;
justify-content: flex-end;
}
@media (max-width: 1200px) {
.content {
grid-template-columns: 1fr;
}
}
</style>