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']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
|
||||
@@ -659,3 +659,31 @@ export function adminGetTodayCertifiedEnterprises(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>
|
||||
<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">
|
||||
<el-icon size="32" class="animate-spin">
|
||||
@@ -466,6 +478,7 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -480,6 +493,7 @@ import {
|
||||
adminGetUserCallRanking,
|
||||
adminGetUserDomainStatistics
|
||||
} from '@/api/statistics'
|
||||
import RequestFlowGlobe from '@/pages/admin/statistics/components/RequestFlowGlobe.vue'
|
||||
import DanmakuBar from '@/components/common/DanmakuBar.vue'
|
||||
import { Check, Loading, Money, Refresh, TrendCharts, User } from '@element-plus/icons-vue'
|
||||
import * as echarts from 'echarts'
|
||||
@@ -488,6 +502,7 @@ import { nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const viewMode = ref('dashboard')
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
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