This commit is contained in:
Mrx
2026-03-20 13:24:44 +08:00
parent 4cdc6e0308
commit dfa908f4a3
4 changed files with 381 additions and 0 deletions

1
components.d.ts vendored
View File

@@ -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']

View File

@@ -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
})
}

View File

@@ -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('')

View 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>