338 lines
8.0 KiB
Vue
338 lines
8.0 KiB
Vue
<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>
|
||
|