add
This commit is contained in:
1
components.d.ts
vendored
1
components.d.ts
vendored
@@ -55,6 +55,7 @@ declare module 'vue' {
|
|||||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||||
ElRow: typeof import('element-plus/es')['ElRow']
|
ElRow: typeof import('element-plus/es')['ElRow']
|
||||||
|
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
|
|||||||
@@ -659,3 +659,31 @@ export function adminGetTodayCertifiedEnterprises(params = {}) {
|
|||||||
params
|
params
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================ 管理员安全可视化接口 ================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取可疑IP列表
|
||||||
|
* @param {Object} params - 查询参数
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export function adminGetSuspiciousIPList(params = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/admin/security/suspicious-ip/list',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取可疑IP地球请求流
|
||||||
|
* @param {Object} params - 查询参数
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export function adminGetSuspiciousIPGeoStream(params = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/admin/security/suspicious-ip/geo-stream',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="list-page-card">
|
<div class="list-page-card">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="text-sm text-gray-500">统计视图切换</div>
|
||||||
|
<el-segmented
|
||||||
|
v-model="viewMode"
|
||||||
|
:options="[
|
||||||
|
{ label: '统计仪表盘', value: 'dashboard' },
|
||||||
|
{ label: '请求流可视化', value: 'request-globe' }
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<RequestFlowGlobe v-if="viewMode === 'request-globe'" />
|
||||||
|
<template v-else>
|
||||||
<!-- 加载状态 -->
|
<!-- 加载状态 -->
|
||||||
<div v-if="loading" class="text-center py-8">
|
<div v-if="loading" class="text-center py-8">
|
||||||
<el-icon size="32" class="animate-spin">
|
<el-icon size="32" class="animate-spin">
|
||||||
@@ -466,6 +478,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -480,6 +493,7 @@ import {
|
|||||||
adminGetUserCallRanking,
|
adminGetUserCallRanking,
|
||||||
adminGetUserDomainStatistics
|
adminGetUserDomainStatistics
|
||||||
} from '@/api/statistics'
|
} from '@/api/statistics'
|
||||||
|
import RequestFlowGlobe from '@/pages/admin/statistics/components/RequestFlowGlobe.vue'
|
||||||
import DanmakuBar from '@/components/common/DanmakuBar.vue'
|
import DanmakuBar from '@/components/common/DanmakuBar.vue'
|
||||||
import { Check, Loading, Money, Refresh, TrendCharts, User } from '@element-plus/icons-vue'
|
import { Check, Loading, Money, Refresh, TrendCharts, User } from '@element-plus/icons-vue'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
@@ -488,6 +502,7 @@ import { nextTick, onMounted, onUnmounted, ref } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const viewMode = ref('dashboard')
|
||||||
// 响应式数据
|
// 响应式数据
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|||||||
337
src/pages/admin/statistics/components/RequestFlowGlobe.vue
Normal file
337
src/pages/admin/statistics/components/RequestFlowGlobe.vue
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
Reference in New Issue
Block a user