feat(security): 服务器安全日志页面(统计卡片+TOP攻击源+VxeTable列表)

This commit is contained in:
夏犀麟 2026-08-27 11:31:32 +08:00
parent 8b86b73424
commit e069a823b3
3 changed files with 279 additions and 0 deletions

View File

@ -1,6 +1,7 @@
# admin.xpcool.com 变更记录
> 倒序最新在上。格式YYYY-MM-DD | 类型 | 摘要
2026-08-27 | CHG | 服务器安全日志页挂在系统监控菜单下id=96views/monitor/security/index.vue——顶部 4 统计卡片SSH爆破/fail2ban封禁/成功登录/总数)+ TOP 攻击源标签区 + 筛选来源IP/事件类型/时间范围 t-date-range-picker+ Vben VxeTable 列表(时间/来源IP/来源端口/目标端口/事件类型标签/详情 tooltip事件标签按类型配色api/serverSecurity.tslist/stats 全 POST无 URL 参数typecheck 通过
2026-08-27 | CHG | 全量表格重构views 下全部 11 个 t-table 页面统一改为 Vben Vxe TableuseVbenVxeGrid#/adapter/vxe-table+ Vben TableAction 行操作——system/{log(范式),login-log,admin,role}、recruitment/{info,crawler,push}、house/{data,listing,community,presale}、dashboard/workbench两处本地小表 proxyConfig.enabled=false + gridApi.setGridOptions 写数据。要点gridOptions 需注解 VxeTableGridOptions否则 type:'seq' 推断 string 报错);单元格自定义用 columns[].slots.default + h(TDesign组件);远程分页走 proxyConfig.ajax.query 返回 {items,total};筛选区保留手写 TDesign 组件不动admin/role/listing/community 行操作 ActionItem 带 auth 权限码、删除 danger 标红listing 多选批量标记改 vxe checkboxConfig + gridApi.grid.getCheckboxRecords()confirm 弹窗交互(删除/重置密码)保留 DialogPlugin 不变typecheck 通过
2026-08-27 | FIX | 恢复丢失的前端代码recovery 分支经 rebase/改名(dev↔main)折腾后工作树回退,未提交改动全丢;从悬空提交 f02a514recovery 工作树快照,含 HEAD 缺失的全部文件)整体恢复 apps/web-tdesign/src——含 styles/theme.css 全局主题、dashboard/workbench 工作台页、system 五页卡片化美化、操作日志增强版(VxeTable)、house 预售/楼盘地图、recruitment 页面;另补 log 页卡片美化(wb-page-head+t-card)。已提交保护

View File

@ -0,0 +1,78 @@
// 服务器安全日志模块 API 客户端。
// 接口规范:全部 POSTURL 不含参数(查询/路径参数均禁止);入参一律走 body。
import { requestClient } from '#/api/request';
// ---------- 数据类型 ----------
export interface SecurityLogItem {
id: number;
logTime: string;
srcIp: string;
srcPort: number;
destPort: number;
eventType: string;
eventName: string;
detail: string;
createdAt: string;
}
export interface SecurityLogListResp {
list: SecurityLogItem[];
total: number;
}
export interface SecurityTopIp {
srcIp: string;
count: number;
}
export interface SecurityByType {
eventType: string;
eventName: string;
count: number;
}
export interface SecurityLogStatsResp {
total: number;
failed: number;
banned: number;
accepted: number;
topIps: SecurityTopIp[];
byType: SecurityByType[];
}
// ---------- 列表 ----------
export interface SecurityLogQuery {
page: number;
size: number;
srcIp?: string;
eventType?: string;
destPort?: number;
dateFrom?: string;
dateTo?: string;
}
export function getSecurityLogList(data: SecurityLogQuery) {
return requestClient.post<SecurityLogListResp>(
'/api/service/admin/server-security/log/list',
data,
);
}
// ---------- 统计 ----------
export function getSecurityLogStats(data: {
dateFrom?: string;
dateTo?: string;
}) {
return requestClient.post<SecurityLogStatsResp>(
'/api/service/admin/server-security/log/stats',
data,
);
}
// ---------- 事件类型选项(与后端 SecurityEventName 对应) ----------
export const SECURITY_EVENT_OPTIONS = [
{ label: 'SSH爆破尝试', value: 'failed_ssh' },
{ label: 'SSH成功登录', value: 'accepted_ssh' },
{ label: 'fail2ban封禁', value: 'banned' },
{ label: 'fail2ban解封', value: 'unbanned' },
];

View File

@ -0,0 +1,200 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SecurityLogStatsResp } from '#/api/serverSecurity';
import { onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
getSecurityLogList,
getSecurityLogStats,
SECURITY_EVENT_OPTIONS,
} from '#/api/serverSecurity';
// 宿 SSH //fail2ban
const query = reactive({
srcIp: '',
eventType: '',
dateRange: [] as string[],
});
// 24
const stats = ref<SecurityLogStatsResp>({
total: 0,
failed: 0,
banned: 0,
accepted: 0,
topIps: [],
byType: [],
});
/** 把日期数组转成后端时间范围HH:MM:SS 补齐) */
function rangeToParam(): { dateFrom?: string; dateTo?: string } {
if (
query.dateRange.length === 2 &&
query.dateRange[0] &&
query.dateRange[1]
) {
return {
dateFrom: `${query.dateRange[0]} 00:00:00`,
dateTo: `${query.dateRange[1]} 23:59:59`,
};
}
return {};
}
async function loadStats() {
try {
stats.value = (await getSecurityLogStats(
rangeToParam(),
)) as SecurityLogStatsResp;
} catch {
//
}
}
const gridOptions: VxeTableGridOptions = {
columns: [
{ type: 'seq', width: 50, title: '#' },
{ field: 'logTime', title: '时间', width: 165 },
{ field: 'srcIp', title: '来源IP', width: 140 },
{ field: 'srcPort', title: '来源端口', width: 100 },
{ field: 'destPort', title: '目标端口', width: 100 },
{
field: 'eventName',
title: '事件类型',
width: 130,
slots: { default: 'event_type' },
},
{
field: 'detail',
title: '日志详情',
minWidth: 320,
showOverflow: 'tooltip',
},
],
pagerConfig: { pageSize: 20 },
proxyConfig: {
ajax: {
query: async ({
page,
}: {
page: { currentPage: number; pageSize: number };
}) => {
const data = await getSecurityLogList({
page: page.currentPage,
size: page.pageSize,
srcIp: query.srcIp || undefined,
eventType: query.eventType || undefined,
...rangeToParam(),
});
return { items: data.list ?? [], total: data.total ?? 0 };
},
},
},
};
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
function search() {
loadStats();
gridApi.query();
}
function reset() {
query.srcIp = '';
query.eventType = '';
query.dateRange = [];
loadStats();
gridApi.reload();
}
onMounted(loadStats);
// //绿/
const typeColor: Record<string, string> = {
failed_ssh: 'danger',
banned: 'warning',
accepted_ssh: 'success',
unbanned: 'primary',
};
</script>
<template>
<Page auto-content-height>
<!-- 统计卡片 -->
<div class="mb-3 grid grid-cols-2 gap-3 lg:grid-cols-4">
<t-card :bordered="false" theme="danger">
<div class="text-sm text-gray-500">SSH爆破尝试</div>
<div class="mt-1 text-2xl font-bold">{{ stats.failed }}</div>
</t-card>
<t-card :bordered="false" theme="warning">
<div class="text-sm text-gray-500">fail2ban封禁</div>
<div class="mt-1 text-2xl font-bold">{{ stats.banned }}</div>
</t-card>
<t-card :bordered="false" theme="success">
<div class="text-sm text-gray-500">SSH成功登录</div>
<div class="mt-1 text-2xl font-bold">{{ stats.accepted }}</div>
</t-card>
<t-card :bordered="false">
<div class="text-sm text-gray-500">日志总数</div>
<div class="mt-1 text-2xl font-bold">{{ stats.total }}</div>
</t-card>
</div>
<!-- 筛选 -->
<div class="mb-3 flex flex-wrap items-center gap-2">
<t-input
v-model="query.srcIp"
clearable
placeholder="来源IP"
style="width: 160px"
@enter="search"
/>
<t-select
v-model="query.eventType"
clearable
:options="SECURITY_EVENT_OPTIONS"
placeholder="事件类型"
style="width: 150px"
/>
<t-date-range-picker
v-model="query.dateRange"
clearable
placeholder="发生时间范围"
style="width: 260px"
/>
<t-button theme="primary" @click="search">查询</t-button>
<t-button variant="outline" @click="reset">重置</t-button>
</div>
<!-- TOP 攻击源 -->
<t-card v-if="stats.topIps.length > 0" :bordered="false" class="mb-3">
<template #header>
<span class="font-medium">TOP 攻击来源爆破+封禁</span>
</template>
<div class="flex flex-wrap gap-3">
<div
v-for="(ip, idx) in stats.topIps.slice(0, 8)"
:key="ip.srcIp"
class="flex items-center gap-2 rounded border border-gray-200 px-3 py-1.5 dark:border-gray-600"
>
<span class="text-gray-400">#{{ idx + 1 }}</span>
<span class="font-mono">{{ ip.srcIp }}</span>
<t-tag theme="danger" variant="light">{{ ip.count }}</t-tag>
</div>
</div>
</t-card>
<!-- 日志列表 -->
<Grid>
<template #event_type="{ row }">
<t-tag :theme="typeColor[row.eventType] || 'default'" variant="light">
{{ row.eventName }}
</t-tag>
</template>
</Grid>
</Page>
</template>