A:API调试
This commit is contained in:
1 parent
5c7220a804
commit
628b8e4d8c
4 files changed
+1935
-134
No files matched your search
@@ -0,0 +1,49 @@
|
||||
### 获取所有规则
|
||||
GET http://localhost:8268/api/firewall/rules
|
||||
|
||||
###
|
||||
|
||||
### 搜索 Chrome 相关规则
|
||||
GET http://localhost:8268/api/firewall/rules?search=Chrome&limit=10
|
||||
|
||||
###
|
||||
|
||||
### 搜索出站规则中包含 Chrome 的
|
||||
GET http://localhost:8268/api/firewall/rules?search=Chrome&direction=Outbound&limit=10
|
||||
|
||||
###
|
||||
|
||||
### 按端口搜索
|
||||
GET http://localhost:8268/api/firewall/rules?search=80&limit=10
|
||||
|
||||
###
|
||||
|
||||
### 获取已启用的 TCP 规则
|
||||
GET http://localhost:8268/api/firewall/rules?enabled=true&protocol=TCP&limit=10
|
||||
|
||||
|
||||
### 测试
|
||||
GET http://localhost:8268/api/firewall/rules
|
||||
|
||||
### 参数说明
|
||||
|
||||
# Direction (方向):
|
||||
# 1 = Inbound (入站)
|
||||
# 2 = Outbound (出站)
|
||||
|
||||
# Protocol (协议):
|
||||
# 1 = ICMPV4
|
||||
# 6 = TCP
|
||||
# 17 = UDP
|
||||
# 256 = Any
|
||||
|
||||
# Action (动作):
|
||||
# 0 = Block (阻止)
|
||||
# 1 = Allow (允许)
|
||||
|
||||
# Profiles (配置文件):
|
||||
# 1 = Domain (域)
|
||||
# 2 = Private (私有)
|
||||
# 4 = Public (公共)
|
||||
# 7 = All (所有,即1+2+4)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { FirewallRule } from '@/types/firewall'
|
||||
|
||||
/**
|
||||
* 防火墙对象 - 展示所用
|
||||
*/
|
||||
export interface FirewallRuleShow {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
enabled: boolean
|
||||
/**
|
||||
* 入站/出站
|
||||
*/
|
||||
direction: string
|
||||
/**
|
||||
* 禁止/放行
|
||||
*/
|
||||
action: string
|
||||
program: string
|
||||
protocol: string
|
||||
port: string
|
||||
profiles: string[]
|
||||
}
|
||||
|
||||
export interface FirewallRuleResponse {
|
||||
rules: FirewallRule[]
|
||||
totalCount: number
|
||||
startIndex: number
|
||||
limit: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 防火墙请求规则
|
||||
*/
|
||||
export interface FirewallRulesParam {
|
||||
start?: number
|
||||
limit?: number
|
||||
name?: string
|
||||
direction?: string
|
||||
enabled?: boolean
|
||||
protocol?: number
|
||||
action?: number
|
||||
application?: string
|
||||
port?: number
|
||||
search?: string
|
||||
}
|
||||
@@ -164,9 +164,10 @@
|
||||
<!-- 虚拟滚动列表 -->
|
||||
<RecycleScroller
|
||||
class="rules-scroller"
|
||||
:items="filteredRules"
|
||||
:items="validFirewallRules"
|
||||
:item-size="80"
|
||||
key-field="id"
|
||||
:key-field="'id'"
|
||||
@update="onVisibleRangeUpdate"
|
||||
v-slot="{ item }"
|
||||
>
|
||||
<div class="rule-item" :class="{ selected: checkedRowKeys.includes(item.id) }">
|
||||
@@ -256,7 +257,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h } from 'vue'
|
||||
import { ref, computed, h, watch } from 'vue'
|
||||
import {
|
||||
NButton,
|
||||
NIcon,
|
||||
@@ -285,9 +286,14 @@ import {
|
||||
RefreshOutline,
|
||||
} from '@vicons/ionicons5'
|
||||
import { httpClient } from '@/utils/http'
|
||||
import type { FirewallStatus } from '@/types/firewall'
|
||||
import type { FirewallStatus, FirewallRule, RuleDirection, RuleAction, ProtocolType } from '@/types/firewall'
|
||||
import { ApiResponse } from '@/types/http'
|
||||
import StringUtils from '@/utils/stringUtils'
|
||||
import {
|
||||
FirewallRuleResponse,
|
||||
FirewallRuleShow,
|
||||
FirewallRulesParam,
|
||||
} from '@/types/firewallFrond'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
@@ -375,136 +381,226 @@ httpClient.get('/firewall/status').then((res: ApiResponse<FirewallStatus>) => {
|
||||
stats.value.disableRules = res.data!.totalRules - res.data!.enabledRules
|
||||
})
|
||||
|
||||
// 模拟规则数据
|
||||
const mockRules = ref([
|
||||
{
|
||||
id: '1',
|
||||
name: 'Windows 远程桌面',
|
||||
description: '允许远程桌面连接',
|
||||
enabled: true,
|
||||
direction: 'inbound',
|
||||
action: 'allow',
|
||||
program: '系统服务',
|
||||
protocol: 'TCP',
|
||||
port: '3389',
|
||||
profiles: ['域', '专用'],
|
||||
priority: 100,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Microsoft Edge',
|
||||
description: '允许浏览器访问网络',
|
||||
enabled: true,
|
||||
direction: 'outbound',
|
||||
action: 'allow',
|
||||
program: 'C:\\Program Files\\Microsoft\\Edge\\msedge.exe',
|
||||
protocol: '任意',
|
||||
port: '任意',
|
||||
profiles: ['域', '专用', '公用'],
|
||||
priority: 200,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: '阻止 Telnet',
|
||||
description: '安全策略:阻止 Telnet 连接',
|
||||
enabled: true,
|
||||
direction: 'inbound',
|
||||
action: 'block',
|
||||
program: '任意',
|
||||
protocol: 'TCP',
|
||||
port: '23',
|
||||
profiles: ['域', '专用', '公用'],
|
||||
priority: 300,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: '文件和打印机共享',
|
||||
description: '允许局域网内文件共享',
|
||||
enabled: false,
|
||||
direction: 'inbound',
|
||||
action: 'allow',
|
||||
program: '系统服务',
|
||||
protocol: 'TCP',
|
||||
port: '445, 139',
|
||||
profiles: ['专用'],
|
||||
priority: 400,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'HTTP 服务器',
|
||||
description: '允许 Web 服务器接收请求',
|
||||
enabled: true,
|
||||
direction: 'inbound',
|
||||
action: 'allow',
|
||||
program: '任意',
|
||||
protocol: 'TCP',
|
||||
port: '80, 443',
|
||||
profiles: ['域', '专用', '公用'],
|
||||
priority: 500,
|
||||
},
|
||||
// 添加更多模拟数据以测试虚拟滚动
|
||||
...Array.from({ length: 50 }, (_, i) => ({
|
||||
id: `${i + 6}`,
|
||||
name: `规则 ${i + 6}`,
|
||||
description: `这是第 ${i + 6} 个测试规则`,
|
||||
enabled: Math.random() > 0.5,
|
||||
direction: Math.random() > 0.5 ? 'inbound' : 'outbound',
|
||||
action: Math.random() > 0.5 ? 'allow' : 'block',
|
||||
program: `程序 ${i + 6}`,
|
||||
protocol: ['TCP', 'UDP', '任意'][Math.floor(Math.random() * 3)],
|
||||
port: Math.floor(Math.random() * 65535).toString(),
|
||||
profiles: ['域', '专用', '公用'].slice(0, Math.floor(Math.random() * 3) + 1),
|
||||
priority: (i + 6) * 100,
|
||||
})),
|
||||
])
|
||||
// 当前筛选条件下的总数量(从接口获取)
|
||||
const totalCount = ref(0)
|
||||
const pageSize = 50
|
||||
// 已加载的页索引(防止重复加载)
|
||||
const loadedPages = new Set<number>()
|
||||
// 防火墙规则列表
|
||||
const firewallRule = ref<(FirewallRuleShow | null)[]>([])
|
||||
const loadedRanges = new Set<string>() // 例如:"100-149"
|
||||
|
||||
|
||||
// 计算属性
|
||||
const filteredRules = computed(() => {
|
||||
let rules = mockRules.value
|
||||
|
||||
// 搜索过滤
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
rules = rules.filter(
|
||||
(rule) =>
|
||||
rule.name.toLowerCase().includes(query) || rule.description.toLowerCase().includes(query),
|
||||
)
|
||||
}
|
||||
|
||||
// 方向过滤
|
||||
if (filters.value.direction) {
|
||||
rules = rules.filter((rule) => rule.direction === filters.value.direction)
|
||||
}
|
||||
|
||||
// 操作过滤
|
||||
if (filters.value.action) {
|
||||
rules = rules.filter((rule) => rule.action === filters.value.action)
|
||||
}
|
||||
|
||||
// 状态过滤
|
||||
if (filters.value.enabled !== null) {
|
||||
rules = rules.filter((rule) => rule.enabled === filters.value.enabled)
|
||||
}
|
||||
|
||||
// 协议过滤
|
||||
if (filters.value.protocol) {
|
||||
rules = rules.filter((rule) => rule.protocol === filters.value.protocol)
|
||||
}
|
||||
|
||||
return rules
|
||||
// 过滤掉 null 值的计算属性
|
||||
const validFirewallRules = computed(() => {
|
||||
return firewallRule.value.filter((rule): rule is FirewallRuleShow => rule !== null)
|
||||
})
|
||||
|
||||
// 数据加载状态
|
||||
const loading = ref(false)
|
||||
const initialLoading = ref(true)
|
||||
|
||||
// 数据转换函数
|
||||
function convertFirewallRuleToShow(rule: FirewallRule): FirewallRuleShow {
|
||||
return {
|
||||
id: crypto.randomUUID(), // 生成唯一ID
|
||||
name: rule.name,
|
||||
description: rule.description || '',
|
||||
enabled: rule.enabled,
|
||||
direction: rule.direction === 1 ? 'inbound' : 'outbound',
|
||||
action: rule.action === 1 ? 'allow' : 'block',
|
||||
program: StringUtils.isBlank(rule.serviceName) ? rule.applicationName : rule.serviceName,
|
||||
protocol: getProtocolName(rule.protocol),
|
||||
port: rule.localPorts || '任意',
|
||||
profiles: parseProfiles(rule.profiles),
|
||||
}
|
||||
}
|
||||
|
||||
// 协议名称转换
|
||||
function getProtocolName(protocol: ProtocolType): string {
|
||||
switch (protocol) {
|
||||
case 6: return 'TCP'
|
||||
case 17: return 'UDP'
|
||||
case 1: return 'ICMPV4'
|
||||
case 256: return '任意'
|
||||
default: return String(protocol)
|
||||
}
|
||||
}
|
||||
|
||||
function parseProfiles(profiles: number) {
|
||||
const result = []
|
||||
if (profiles & 1) result.push('域')
|
||||
if (profiles & 2) result.push('专用')
|
||||
if (profiles & 4) result.push('公用')
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取防火墙信息
|
||||
* @param arg
|
||||
*/
|
||||
async function getFirewallRules(arg: FirewallRulesParam): Promise<FirewallRuleResponse | null> {
|
||||
// 请求规则数据
|
||||
const firewall: ApiResponse<FirewallRuleResponse> = await httpClient.get('/firewall/rules', arg)
|
||||
if (!firewall.success) {
|
||||
const errorMsg = StringUtils.isBlank(firewall.message)
|
||||
? '数据请求出错,错误信息为空!'
|
||||
: firewall.message
|
||||
message.error(errorMsg)
|
||||
return null
|
||||
}
|
||||
return firewall.data
|
||||
}
|
||||
|
||||
// 构建查询参数
|
||||
function buildQueryParams(baseParams: Partial<FirewallRulesParam> = {}): FirewallRulesParam {
|
||||
const params: FirewallRulesParam = {
|
||||
...baseParams,
|
||||
}
|
||||
|
||||
// 添加搜索关键词
|
||||
if (searchQuery.value.trim()) {
|
||||
params.search = searchQuery.value.trim()
|
||||
}
|
||||
|
||||
// 添加过滤条件
|
||||
if (filters.value.direction) {
|
||||
params.direction = filters.value.direction
|
||||
}
|
||||
|
||||
if (filters.value.enabled !== null) {
|
||||
params.enabled = filters.value.enabled
|
||||
}
|
||||
|
||||
if (filters.value.action) {
|
||||
params.action = filters.value.action === 'allow' ? 1 : 0
|
||||
}
|
||||
|
||||
if (filters.value.protocol) {
|
||||
params.protocol = getProtocolValue(filters.value.protocol)
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// 协议值转换
|
||||
function getProtocolValue(protocol: string): number {
|
||||
switch (protocol) {
|
||||
case 'TCP': return 6
|
||||
case 'UDP': return 17
|
||||
case 'ICMPV4': return 1
|
||||
case '任意': return 256
|
||||
default: return 256
|
||||
}
|
||||
}
|
||||
|
||||
// 分段加载数据
|
||||
async function loadRange(startIndex: number, limit = pageSize) {
|
||||
if (loading.value) return
|
||||
|
||||
const rangeKey = `${startIndex}-${startIndex + limit - 1}`
|
||||
if (loadedRanges.has(rangeKey)) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const params = buildQueryParams({
|
||||
start: startIndex,
|
||||
limit: limit,
|
||||
})
|
||||
|
||||
const res = await getFirewallRules(params)
|
||||
if (!res) return
|
||||
|
||||
// 更新总数
|
||||
totalCount.value = res.totalCount
|
||||
|
||||
// 确保数组足够大
|
||||
if (firewallRule.value.length < res.totalCount) {
|
||||
firewallRule.value = Array(res.totalCount).fill(null)
|
||||
}
|
||||
|
||||
// 填充数据
|
||||
for (let i = 0; i < res.rules.length; i++) {
|
||||
const index = startIndex + i
|
||||
if (index < firewallRule.value.length) {
|
||||
firewallRule.value[index] = convertFirewallRuleToShow(res.rules[i])
|
||||
}
|
||||
}
|
||||
|
||||
loadedRanges.add(rangeKey)
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error)
|
||||
message.error('数据加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置并加载首页数据
|
||||
async function resetAndLoadFirst() {
|
||||
try {
|
||||
initialLoading.value = true
|
||||
loadedRanges.clear()
|
||||
firewallRule.value = []
|
||||
|
||||
const params = buildQueryParams({
|
||||
start: 0,
|
||||
limit: pageSize,
|
||||
})
|
||||
|
||||
const res = await getFirewallRules(params)
|
||||
if (!res) return
|
||||
|
||||
totalCount.value = res.totalCount
|
||||
firewallRule.value = Array(res.totalCount).fill(null)
|
||||
|
||||
// 填充首页数据
|
||||
for (let i = 0; i < res.rules.length; i++) {
|
||||
firewallRule.value[i] = convertFirewallRuleToShow(res.rules[i])
|
||||
}
|
||||
|
||||
loadedRanges.add(`0-${pageSize - 1}`)
|
||||
} catch (error) {
|
||||
console.error('加载首页数据失败:', error)
|
||||
message.error('数据加载失败')
|
||||
} finally {
|
||||
initialLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动事件
|
||||
* @param startIndex
|
||||
* @param endIndex
|
||||
*/
|
||||
function onVisibleRangeUpdate({ startIndex, endIndex }) {
|
||||
console.log('可见范围:', startIndex, endIndex)
|
||||
const alignedStart = Math.floor(startIndex / pageSize) * pageSize
|
||||
const alignedEnd = Math.ceil((endIndex + 1) / pageSize) * pageSize
|
||||
|
||||
for (let i = alignedStart; i < alignedEnd; i += pageSize) {
|
||||
if (i < totalCount.value) {
|
||||
loadRange(i, pageSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化数据
|
||||
resetAndLoadFirst()
|
||||
|
||||
// 监听搜索和过滤条件变化,重新加载数据
|
||||
watch([searchQuery, filters], () => {
|
||||
resetAndLoadFirst()
|
||||
}, { deep: true })
|
||||
|
||||
// 全选相关计算属性
|
||||
const allSelected = computed({
|
||||
get: () => {
|
||||
return (
|
||||
filteredRules.value.length > 0 && checkedRowKeys.value.length === filteredRules.value.length
|
||||
)
|
||||
const validRules = validFirewallRules.value
|
||||
return validRules.length > 0 && checkedRowKeys.value.length === validRules.length
|
||||
},
|
||||
set: (value: boolean) => {
|
||||
if (value) {
|
||||
checkedRowKeys.value = filteredRules.value.map((rule) => rule.id)
|
||||
checkedRowKeys.value = validFirewallRules.value.map((rule) => rule.id)
|
||||
} else {
|
||||
checkedRowKeys.value = []
|
||||
}
|
||||
@@ -512,7 +608,8 @@ const allSelected = computed({
|
||||
})
|
||||
|
||||
const indeterminate = computed(() => {
|
||||
return checkedRowKeys.value.length > 0 && checkedRowKeys.value.length < filteredRules.value.length
|
||||
const validRules = validFirewallRules.value
|
||||
return checkedRowKeys.value.length > 0 && checkedRowKeys.value.length < validRules.length
|
||||
})
|
||||
|
||||
// 方法
|
||||
@@ -526,10 +623,14 @@ const editRule = (rule: unknown) => {
|
||||
showRuleForm.value = true
|
||||
}
|
||||
|
||||
const deleteRule = (id: string) => {
|
||||
const index = mockRules.value.findIndex((rule) => rule.id === id)
|
||||
const deleteRule = async (id: string) => {
|
||||
// TODO: 实现删除规则API调用
|
||||
console.log('删除规则:', id)
|
||||
|
||||
// 从本地数据中移除(临时方案,应该调用API)
|
||||
const index = firewallRule.value.findIndex((rule) => rule?.id === id)
|
||||
if (index !== -1) {
|
||||
mockRules.value.splice(index, 1)
|
||||
firewallRule.value.splice(index, 1)
|
||||
// 从选中项中移除
|
||||
checkedRowKeys.value = checkedRowKeys.value.filter((key) => key !== id)
|
||||
message.success('规则删除成功')
|
||||
@@ -559,18 +660,25 @@ const clearFilters = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveRule = (rule: unknown) => {
|
||||
const handleSaveRule = async (rule: unknown) => {
|
||||
// TODO: 实现保存规则API调用
|
||||
console.log('保存规则:', rule)
|
||||
|
||||
if (rule.id) {
|
||||
// 编辑现有规则
|
||||
const index = mockRules.value.findIndex((r) => r.id === rule.id)
|
||||
const index = firewallRule.value.findIndex((r) => r?.id === rule.id)
|
||||
if (index !== -1) {
|
||||
mockRules.value[index] = rule
|
||||
firewallRule.value[index] = rule as FirewallRuleShow
|
||||
message.success('规则更新成功')
|
||||
}
|
||||
} else {
|
||||
// 添加新规则
|
||||
rule.id = Date.now().toString()
|
||||
mockRules.value.unshift(rule)
|
||||
const newRule = {
|
||||
...rule,
|
||||
id: crypto.randomUUID()
|
||||
} as FirewallRuleShow
|
||||
firewallRule.value.unshift(newRule)
|
||||
totalCount.value += 1
|
||||
message.success('规则创建成功')
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,1597 @@
|
||||
<template>
|
||||
<div class="firewall-view">
|
||||
<div class="firewall-container">
|
||||
<!-- 顶部工具栏 -->
|
||||
<div class="firewall-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<h2 class="view-title">防火墙规则管理</h2>
|
||||
<p class="view-subtitle">配置和管理网络防护规则</p>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<!-- 防火墙总开关 -->
|
||||
<div class="firewall-status">
|
||||
<span class="status-label">防火墙状态</span>
|
||||
<n-switch v-model:value="firewallEnabled" size="medium" />
|
||||
</div>
|
||||
<n-button type="primary" size="medium" @click="openRuleForm()">
|
||||
<template #icon>
|
||||
<n-icon :component="AddOutline" />
|
||||
</template>
|
||||
<span class="button-text">新建规则</span>
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 规则统计卡片 - 响应式隐藏 -->
|
||||
<div class="stats-grid stats-hidden-on-small">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon-wrapper primary">
|
||||
<n-icon :component="ListOutline" size="24" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-number">{{ stats.disableRules }}</div>
|
||||
<div class="stat-label">禁用规则</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon-wrapper success">
|
||||
<n-icon :component="CheckmarkCircleOutline" size="24" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-number">{{ stats.active }}</div>
|
||||
<div class="stat-label">启用规则</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon-wrapper warning">
|
||||
<n-icon :component="PauseCircleOutline" size="24" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-number">{{ stats.outboundRules }}</div>
|
||||
<div class="stat-label">出站规则</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon-wrapper error">
|
||||
<n-icon :component="CloseCircleOutline" size="24" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-number">{{ stats.inboundRules }}</div>
|
||||
<div class="stat-label">入站规则</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 规则列表 -->
|
||||
<div class="rules-panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">防火墙规则列表</h3>
|
||||
<div class="panel-controls">
|
||||
<n-input
|
||||
v-model:value="searchQuery"
|
||||
placeholder="搜索规则名称..."
|
||||
clearable
|
||||
style="width: 280px"
|
||||
>
|
||||
<template #prefix>
|
||||
<n-icon :component="SearchOutline" />
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 优化后的筛选区域 -->
|
||||
<div class="filter-section">
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">方向</span>
|
||||
<n-select
|
||||
v-model:value="filters.direction"
|
||||
size="small"
|
||||
style="width: 120px"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
:options="directionOptions"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">操作</span>
|
||||
<n-select
|
||||
v-model:value="filters.action"
|
||||
size="small"
|
||||
style="width: 120px"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
:options="actionOptions"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">状态</span>
|
||||
<n-select
|
||||
v-model:value="filters.enabled"
|
||||
size="small"
|
||||
style="width: 120px"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
:options="enabledOptions"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">协议</span>
|
||||
<n-select
|
||||
v-model:value="filters.protocol"
|
||||
size="small"
|
||||
style="width: 120px"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
:options="protocolOptions"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<n-button size="small" quaternary @click="clearFilters">
|
||||
<template #icon>
|
||||
<n-icon :component="RefreshOutline" />
|
||||
</template>
|
||||
重置筛选
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 虚拟滚动规则列表 -->
|
||||
<div class="rules-list-container">
|
||||
<!-- 列表头部 -->
|
||||
<div class="list-header">
|
||||
<div class="header-cell checkbox-cell">
|
||||
<n-checkbox
|
||||
v-model:checked="allSelected"
|
||||
:indeterminate="indeterminate"
|
||||
@update:checked="handleSelectAll"
|
||||
/>
|
||||
</div>
|
||||
<div class="header-cell name-cell">规则名称</div>
|
||||
<div class="header-cell status-cell">状态</div>
|
||||
<div class="header-cell direction-cell">方向</div>
|
||||
<div class="header-cell action-cell">操作</div>
|
||||
<div class="header-cell program-cell">程序/服务</div>
|
||||
<div class="header-cell protocol-cell">协议</div>
|
||||
<div class="header-cell port-cell">端口</div>
|
||||
<div class="header-cell profiles-cell">配置文件</div>
|
||||
<div class="header-cell actions-cell">操作</div>
|
||||
</div>
|
||||
|
||||
<!-- 虚拟滚动列表 -->
|
||||
<RecycleScroller
|
||||
class="rules-scroller"
|
||||
:items="firewallRule"
|
||||
:item-size="80"
|
||||
:key-field="'id'"
|
||||
@update="onVisibleRangeUpdate"
|
||||
v-slot="{ item }"
|
||||
>
|
||||
<div class="rule-item" :class="{ selected: checkedRowKeys.includes(item.id) }">
|
||||
<div class="rule-cell checkbox-cell">
|
||||
<n-checkbox
|
||||
:checked="checkedRowKeys.includes(item.id)"
|
||||
@update:checked="(checked) => handleRuleCheck(item.id, checked)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rule-cell name-cell">
|
||||
<div class="rule-name">{{ item.name }}</div>
|
||||
<div class="rule-description">{{ item.description }}</div>
|
||||
</div>
|
||||
|
||||
<div class="rule-cell status-cell">
|
||||
<n-tag :type="item.enabled ? 'success' : 'default'" size="small">
|
||||
{{ item.enabled ? '启用' : '禁用' }}
|
||||
</n-tag>
|
||||
</div>
|
||||
|
||||
<div class="rule-cell direction-cell">
|
||||
<div class="direction-content">
|
||||
<n-icon
|
||||
:component="item.direction === 'inbound' ? ArrowDownOutline : ArrowUpOutline"
|
||||
size="16"
|
||||
/>
|
||||
<span>{{ item.direction === 'inbound' ? '入站' : '出站' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rule-cell action-cell">
|
||||
<n-tag :type="item.action === 'allow' ? 'success' : 'error'" size="small">
|
||||
{{ item.action === 'allow' ? '允许' : '阻止' }}
|
||||
</n-tag>
|
||||
</div>
|
||||
|
||||
<div class="rule-cell program-cell">
|
||||
<div class="program-text" :title="item.program">{{ item.program }}</div>
|
||||
</div>
|
||||
|
||||
<div class="rule-cell protocol-cell">
|
||||
<span>{{ item.protocol }}</span>
|
||||
</div>
|
||||
|
||||
<div class="rule-cell port-cell">
|
||||
<span>{{ item.port }}</span>
|
||||
</div>
|
||||
|
||||
<div class="rule-cell profiles-cell">
|
||||
<div class="profiles-container">
|
||||
<n-tag
|
||||
v-for="profile in item.profiles"
|
||||
:key="profile"
|
||||
type="info"
|
||||
size="small"
|
||||
class="profile-tag"
|
||||
>
|
||||
{{ profile }}
|
||||
</n-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rule-cell actions-cell">
|
||||
<div class="action-buttons">
|
||||
<n-button size="small" quaternary @click="editRule(item)">
|
||||
<template #icon>
|
||||
<n-icon :component="CreateOutline" size="14" />
|
||||
</template>
|
||||
</n-button>
|
||||
<n-button size="small" quaternary type="error" @click="deleteRule(item.id)">
|
||||
<template #icon>
|
||||
<n-icon :component="TrashOutline" size="14" />
|
||||
</template>
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RecycleScroller>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 防火墙规则表单 -->
|
||||
<FirewallRuleForm v-model="showRuleForm" :edit-rule="currentEditRule" @save="handleSaveRule" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h } from 'vue'
|
||||
import {
|
||||
NButton,
|
||||
NIcon,
|
||||
NInput,
|
||||
NSwitch,
|
||||
NTag,
|
||||
NDropdown,
|
||||
NCheckbox,
|
||||
NSelect,
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
import { RecycleScroller, DynamicScroller } from 'vue-virtual-scroller'
|
||||
import FirewallRuleForm from '@/components/FirewallRuleForm.vue'
|
||||
import {
|
||||
AddOutline,
|
||||
SearchOutline,
|
||||
CheckmarkCircleOutline,
|
||||
PauseCircleOutline,
|
||||
CloseCircleOutline,
|
||||
ListOutline,
|
||||
ChevronDownOutline,
|
||||
CreateOutline,
|
||||
TrashOutline,
|
||||
ArrowUpOutline,
|
||||
ArrowDownOutline,
|
||||
RefreshOutline,
|
||||
} from '@vicons/ionicons5'
|
||||
import { httpClient } from '@/utils/http'
|
||||
import type { FirewallStatus, FirewallRule } from '@/types/firewall'
|
||||
import { ApiResponse } from '@/types/http'
|
||||
import StringUtils from '@/utils/stringUtils'
|
||||
import {
|
||||
FirewallRuleResponse,
|
||||
FirewallRuleShow,
|
||||
FirewallRulesPara,
|
||||
FirewallRulesParam,
|
||||
} from '@/types/firewallFrond'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
// 界面状态
|
||||
const showRuleForm = ref(false)
|
||||
const currentEditRule = ref(null)
|
||||
const firewallEnabled = ref(true)
|
||||
const checkedRowKeys = ref<string[]>([])
|
||||
|
||||
// 搜索与过滤
|
||||
const searchQuery = ref('')
|
||||
const filters = ref({
|
||||
direction: null as string | null,
|
||||
action: null as string | null,
|
||||
enabled: null as boolean | null,
|
||||
protocol: null as string | null,
|
||||
})
|
||||
|
||||
// 过滤选项
|
||||
const directionOptions = [
|
||||
{ label: '入站', value: 'inbound' },
|
||||
{ label: '出站', value: 'outbound' },
|
||||
]
|
||||
|
||||
const actionOptions = [
|
||||
{ label: '允许', value: 'allow' },
|
||||
{ label: '阻止', value: 'block' },
|
||||
]
|
||||
|
||||
const enabledOptions = [
|
||||
{ label: '已启用', value: true },
|
||||
{ label: '已禁用', value: false },
|
||||
]
|
||||
|
||||
const protocolOptions = [
|
||||
{ label: 'TCP', value: 'TCP' },
|
||||
{ label: 'UDP', value: 'UDP' },
|
||||
{ label: '任意', value: '任意' },
|
||||
]
|
||||
|
||||
// 批量操作选项
|
||||
const batchOptions = [
|
||||
{
|
||||
label: '启用选中',
|
||||
key: 'enable',
|
||||
icon: () => h(NIcon, { component: CheckmarkCircleOutline }),
|
||||
},
|
||||
{
|
||||
label: '禁用选中',
|
||||
key: 'disable',
|
||||
icon: () => h(NIcon, { component: PauseCircleOutline }),
|
||||
},
|
||||
{
|
||||
label: '删除选中',
|
||||
key: 'delete',
|
||||
icon: () => h(NIcon, { component: TrashOutline }),
|
||||
},
|
||||
]
|
||||
|
||||
// 统计数据
|
||||
const stats = ref({
|
||||
//禁用规则
|
||||
disableRules: 0,
|
||||
//启动规则
|
||||
active: 0,
|
||||
// 出战规则
|
||||
outboundRules: 0,
|
||||
// 进站规则
|
||||
inboundRules: 0,
|
||||
})
|
||||
|
||||
// 请求统计数据
|
||||
httpClient.get('/firewall/status').then((res: ApiResponse<FirewallStatus>) => {
|
||||
if (!res.success) {
|
||||
message.error(StringUtils.isBlank(res.message) ? '错误信息为空!' : res.message)
|
||||
return
|
||||
}
|
||||
if (res.data === null || res.data === undefined) {
|
||||
message.error('获取数据为空!')
|
||||
return
|
||||
}
|
||||
stats.value.inboundRules = res.data!.inboundRules
|
||||
stats.value.outboundRules = res.data!.outboundRules
|
||||
stats.value.active = res.data!.enabledRules
|
||||
stats.value.disableRules = res.data!.totalRules - res.data!.enabledRules
|
||||
})
|
||||
|
||||
// 模拟规则数据
|
||||
const mockRules = ref([
|
||||
{
|
||||
id: '1',
|
||||
name: 'Windows 远程桌面',
|
||||
description: '允许远程桌面连接',
|
||||
enabled: true,
|
||||
direction: 'inbound',
|
||||
action: 'allow',
|
||||
program: '系统服务',
|
||||
protocol: 'TCP',
|
||||
port: '3389',
|
||||
profiles: ['域', '专用'],
|
||||
priority: 100,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Microsoft Edge',
|
||||
description: '允许浏览器访问网络',
|
||||
enabled: true,
|
||||
direction: 'outbound',
|
||||
action: 'allow',
|
||||
program: 'C:\\Program Files\\Microsoft\\Edge\\msedge.exe',
|
||||
protocol: '任意',
|
||||
port: '任意',
|
||||
profiles: ['域', '专用', '公用'],
|
||||
priority: 200,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: '阻止 Telnet',
|
||||
description: '安全策略:阻止 Telnet 连接',
|
||||
enabled: true,
|
||||
direction: 'inbound',
|
||||
action: 'block',
|
||||
program: '任意',
|
||||
protocol: 'TCP',
|
||||
port: '23',
|
||||
profiles: ['域', '专用', '公用'],
|
||||
priority: 300,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: '文件和打印机共享',
|
||||
description: '允许局域网内文件共享',
|
||||
enabled: false,
|
||||
direction: 'inbound',
|
||||
action: 'allow',
|
||||
program: '系统服务',
|
||||
protocol: 'TCP',
|
||||
port: '445, 139',
|
||||
profiles: ['专用'],
|
||||
priority: 400,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'HTTP 服务器',
|
||||
description: '允许 Web 服务器接收请求',
|
||||
enabled: true,
|
||||
direction: 'inbound',
|
||||
action: 'allow',
|
||||
program: '任意',
|
||||
protocol: 'TCP',
|
||||
port: '80, 443',
|
||||
profiles: ['域', '专用', '公用'],
|
||||
priority: 500,
|
||||
},
|
||||
// 添加更多模拟数据以测试虚拟滚动
|
||||
...Array.from({ length: 50 }, (_, i) => ({
|
||||
id: `${i + 6}`,
|
||||
name: `规则 ${i + 6}`,
|
||||
description: `这是第 ${i + 6} 个测试规则`,
|
||||
enabled: Math.random() > 0.5,
|
||||
direction: Math.random() > 0.5 ? 'inbound' : 'outbound',
|
||||
action: Math.random() > 0.5 ? 'allow' : 'block',
|
||||
program: `程序 ${i + 6}`,
|
||||
protocol: ['TCP', 'UDP', '任意'][Math.floor(Math.random() * 3)],
|
||||
port: Math.floor(Math.random() * 65535).toString(),
|
||||
profiles: ['域', '专用', '公用'].slice(0, Math.floor(Math.random() * 3) + 1),
|
||||
priority: (i + 6) * 100,
|
||||
})),
|
||||
])
|
||||
|
||||
// 当前筛选条件下的总数量(从接口获取)
|
||||
const totalCount = ref(0)
|
||||
const pageSize = 100
|
||||
// 已加载的页索引(防止重复加载)
|
||||
const loadedPages = new Set<number>()
|
||||
// 防火墙规则列表
|
||||
const firewallRule = ref<(FirewallRuleShow | null)[]>([])
|
||||
const loadedRanges = new Set<string>() // 例如:"100-149"
|
||||
|
||||
async function loadRange(startIndex: number, limit = pageSize) {
|
||||
const rangeKey = `${startIndex}-${startIndex + limit - 1}`
|
||||
if (loadedRanges.has(rangeKey)) return
|
||||
|
||||
const res: FirewallRuleResponse | null = await getFirewallRules({
|
||||
start: 0,
|
||||
limit: pageSize,
|
||||
search: searchQuery.value,
|
||||
direction: filters.value.direction,
|
||||
enabled: filters.value.enable,
|
||||
protocol: filters.value.protocol,
|
||||
action: filters.value.action,
|
||||
})
|
||||
|
||||
if (res.rules && res.rules.length > 0) {
|
||||
for (let i = 0; i < res.rules.length; i++) {
|
||||
firewallRule.value[startIndex + i] = res.rules[i]
|
||||
}
|
||||
}
|
||||
|
||||
if (res.totalCount !== undefined) {
|
||||
totalCount.value = res.totalCount
|
||||
}
|
||||
|
||||
loadedRanges.add(rangeKey)
|
||||
}
|
||||
|
||||
resetAndLoadFirst()
|
||||
|
||||
async function resetAndLoadFirst() {
|
||||
loadedRanges.clear()
|
||||
const arg = {
|
||||
start: 0,
|
||||
limit: pageSize,
|
||||
search: searchQuery.value,
|
||||
direction: filters.value.direction,
|
||||
enabled: filters.value.enable,
|
||||
protocol: filters.value.protocol,
|
||||
action: filters.value.action,
|
||||
}
|
||||
const res: FirewallRuleResponse | null = await getFirewallRules(arg)
|
||||
if (res === null || res === undefined) {
|
||||
console.log('数据为空!')
|
||||
return
|
||||
}
|
||||
totalCount.value = res.totalCount
|
||||
firewallRule.value = Array(res.totalCount).fill(null)
|
||||
|
||||
for (let i = 0; i < res.rules.length; i++) {
|
||||
const firewallRule1 = res.rules[i]
|
||||
|
||||
firewallRule.value[i] = {
|
||||
id: crypto.randomUUID(),
|
||||
name: firewallRule1.name,
|
||||
description: firewallRule1.description,
|
||||
enabled: firewallRule1.enabled,
|
||||
direction: firewallRule1.direction,
|
||||
action: firewallRule1.action,
|
||||
program: StringUtils.isBlank(firewallRule1.serviceName)
|
||||
? firewallRule1.applicationName
|
||||
: firewallRule1.serviceName,
|
||||
protocol: firewallRule1.protocol,
|
||||
port: firewallRule1.localPorts,
|
||||
profiles: parseProfiles(firewallRule1.profiles),
|
||||
}
|
||||
}
|
||||
|
||||
loadedRanges.add(`0-${pageSize - 1}`)
|
||||
}
|
||||
|
||||
function parseProfiles(profiles: number) {
|
||||
const result = []
|
||||
if (profiles & 1) result.push('域')
|
||||
if (profiles & 2) result.push('专用')
|
||||
if (profiles & 4) result.push('公用')
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取防火墙信息
|
||||
* @param arg
|
||||
*/
|
||||
async function getFirewallRules(arg: FirewallRulesParam): FirewallRuleResponse | null {
|
||||
// 请求规则数据
|
||||
const firewall: ApiResponse<FirewallRuleResponse> = await httpClient.get('/firewall/rules', arg)
|
||||
if (!firewall.success) {
|
||||
const errorMsg = StringUtils.isBlank(firewall.message)
|
||||
? firewall.message
|
||||
: '数据请求出错,错误信息为空!'
|
||||
message.error(errorMsg)
|
||||
return null
|
||||
}
|
||||
return firewall.data
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const filteredRules = computed(() => {
|
||||
let rules = mockRules.value
|
||||
|
||||
// 搜索过滤
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
rules = rules.filter(
|
||||
(rule) =>
|
||||
rule.name.toLowerCase().includes(query) || rule.description.toLowerCase().includes(query),
|
||||
)
|
||||
}
|
||||
|
||||
// 方向过滤
|
||||
if (filters.value.direction) {
|
||||
rules = rules.filter((rule) => rule.direction === filters.value.direction)
|
||||
}
|
||||
|
||||
// 操作过滤
|
||||
if (filters.value.action) {
|
||||
rules = rules.filter((rule) => rule.action === filters.value.action)
|
||||
}
|
||||
|
||||
// 状态过滤
|
||||
if (filters.value.enabled !== null) {
|
||||
rules = rules.filter((rule) => rule.enabled === filters.value.enabled)
|
||||
}
|
||||
|
||||
// 协议过滤
|
||||
if (filters.value.protocol) {
|
||||
rules = rules.filter((rule) => rule.protocol === filters.value.protocol)
|
||||
}
|
||||
|
||||
return rules
|
||||
})
|
||||
|
||||
// 全选相关计算属性
|
||||
const allSelected = computed({
|
||||
get: () => {
|
||||
return (
|
||||
filteredRules.value.length > 0 && checkedRowKeys.value.length === filteredRules.value.length
|
||||
)
|
||||
},
|
||||
set: (value: boolean) => {
|
||||
if (value) {
|
||||
checkedRowKeys.value = filteredRules.value.map((rule) => rule.id)
|
||||
} else {
|
||||
checkedRowKeys.value = []
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const indeterminate = computed(() => {
|
||||
return checkedRowKeys.value.length > 0 && checkedRowKeys.value.length < filteredRules.value.length
|
||||
})
|
||||
|
||||
/**
|
||||
* 滚动事件
|
||||
* @param startIndex
|
||||
* @param endIndex
|
||||
*/
|
||||
function onVisibleRangeUpdate({ startIndex, endIndex }) {
|
||||
console.log(startIndex, endIndex)
|
||||
const alignedStart = Math.floor(startIndex / pageSize) * pageSize
|
||||
const alignedEnd = Math.ceil((endIndex + 1) / pageSize) * pageSize
|
||||
|
||||
for (let i = alignedStart; i < alignedEnd; i += pageSize) {
|
||||
loadRange(i, pageSize)
|
||||
}
|
||||
}
|
||||
|
||||
// 方法
|
||||
const openRuleForm = (rule: unknown = null) => {
|
||||
currentEditRule.value = rule
|
||||
showRuleForm.value = true
|
||||
}
|
||||
|
||||
const editRule = (rule: unknown) => {
|
||||
currentEditRule.value = { ...rule }
|
||||
showRuleForm.value = true
|
||||
}
|
||||
|
||||
const deleteRule = (id: string) => {
|
||||
const index = mockRules.value.findIndex((rule) => rule.id === id)
|
||||
if (index !== -1) {
|
||||
mockRules.value.splice(index, 1)
|
||||
// 从选中项中移除
|
||||
checkedRowKeys.value = checkedRowKeys.value.filter((key) => key !== id)
|
||||
message.success('规则删除成功')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRuleCheck = (id: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
if (!checkedRowKeys.value.includes(id)) {
|
||||
checkedRowKeys.value.push(id)
|
||||
}
|
||||
} else {
|
||||
checkedRowKeys.value = checkedRowKeys.value.filter((key) => key !== id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
allSelected.value = checked
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
filters.value = {
|
||||
direction: null,
|
||||
action: null,
|
||||
enabled: null,
|
||||
protocol: null,
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveRule = (rule: unknown) => {
|
||||
if (rule.id) {
|
||||
// 编辑现有规则
|
||||
const index = mockRules.value.findIndex((r) => r.id === rule.id)
|
||||
if (index !== -1) {
|
||||
mockRules.value[index] = rule
|
||||
message.success('规则更新成功')
|
||||
}
|
||||
} else {
|
||||
// 添加新规则
|
||||
rule.id = Date.now().toString()
|
||||
mockRules.value.unshift(rule)
|
||||
message.success('规则创建成功')
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
currentEditRule.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.firewall-view {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.firewall-container {
|
||||
padding: 24px;
|
||||
max-width: 2000px;
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0; /* 允许容器缩小 */
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.firewall-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
min-width: 0; /* 允许子元素缩小 */
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
flex: 1;
|
||||
min-width: 0; /* 允许内容缩小 */
|
||||
}
|
||||
|
||||
.view-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.view-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin: 4px 0 0 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
min-width: 0; /* 允许内容缩小 */
|
||||
}
|
||||
|
||||
.firewall-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.button-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.stat-icon-wrapper {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-icon-wrapper.primary {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.stat-icon-wrapper.success {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: var(--accent-success);
|
||||
}
|
||||
|
||||
.stat-icon-wrapper.warning {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--accent-warning);
|
||||
}
|
||||
|
||||
.stat-icon-wrapper.error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--accent-error);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 规则面板 */
|
||||
.rules-panel {
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
margin-bottom: 50px;
|
||||
min-width: 0; /* 允许面板缩小 */
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.panel-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 优化后的筛选区域 */
|
||||
.filter-section {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
background: var(--bg-tertiary);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
/* 规则列表容器 */
|
||||
.rules-list-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0; /* 允许容器缩小 */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 列表头部 */
|
||||
.list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
height: 40px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
min-width: 0; /* 允许头部缩小 */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 超超大屏幕 1801px-2000px */
|
||||
@media (min-width: 1801px) and (max-width: 2000px) {
|
||||
.checkbox-cell {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.status-cell {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.direction-cell {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.program-cell {
|
||||
width: 320px;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.protocol-cell {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.port-cell {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.profiles-cell {
|
||||
width: 180px;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 极大屏幕 >2000px */
|
||||
@media (min-width: 2001px) {
|
||||
.checkbox-cell {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.status-cell {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.direction-cell {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.program-cell {
|
||||
width: 380px;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.protocol-cell {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.port-cell {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.profiles-cell {
|
||||
width: 200px;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 超大屏幕 1601px-1800px */
|
||||
@media (min-width: 1601px) and (max-width: 1800px) {
|
||||
.checkbox-cell {
|
||||
width: 55px;
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
width: 230px;
|
||||
}
|
||||
|
||||
.status-cell {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.direction-cell {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.program-cell {
|
||||
width: 280px;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.protocol-cell {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.port-cell {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.profiles-cell {
|
||||
width: 160px;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
width: 110px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 基础列宽 - 针对超大屏幕(1401px-1600px) */
|
||||
@media (min-width: 1401px) and (max-width: 1600px) {
|
||||
.checkbox-cell {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.status-cell {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.direction-cell {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.program-cell {
|
||||
width: 250px;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.protocol-cell {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.port-cell {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.profiles-cell {
|
||||
width: 150px;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 大屏幕 1201px-1400px */
|
||||
@media (min-width: 1201px) and (max-width: 1400px) {
|
||||
.checkbox-cell {
|
||||
width: 45px;
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.status-cell {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.direction-cell {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.program-cell {
|
||||
width: 220px;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.protocol-cell {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.port-cell {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.profiles-cell {
|
||||
width: 130px;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
width: 90px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 中等屏幕 1025px-1200px */
|
||||
@media (min-width: 1025px) and (max-width: 1200px) {
|
||||
.checkbox-cell {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.status-cell {
|
||||
width: 65px;
|
||||
}
|
||||
|
||||
.direction-cell {
|
||||
width: 65px;
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
width: 65px;
|
||||
}
|
||||
|
||||
.program-cell {
|
||||
width: 200px;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.protocol-cell {
|
||||
width: 65px;
|
||||
}
|
||||
|
||||
.port-cell {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.profiles-cell {
|
||||
width: 120px;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
width: 85px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏幕 769px-1024px */
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.checkbox-cell {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.status-cell {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.direction-cell {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.program-cell {
|
||||
width: 180px;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.protocol-cell {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.port-cell {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.profiles-cell {
|
||||
width: 100px;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
width: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 虚拟滚动列表 */
|
||||
.rules-scroller {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.rule-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
height: 80px;
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
transition: var(--transition);
|
||||
background: var(--bg-card);
|
||||
min-width: 0; /* 允许项目缩小 */
|
||||
}
|
||||
|
||||
.rule-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.rule-item.selected {
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
border-left: 3px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
/* 斑马纹效果 */
|
||||
.rule-item:nth-child(even) {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.rule-item:nth-child(odd) {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
/* 暗色模式下的斑马纹效果 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.rule-item:nth-child(even) {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
}
|
||||
|
||||
.rule-item:hover {
|
||||
background: var(--bg-tertiary) !important;
|
||||
}
|
||||
|
||||
.rule-item.selected {
|
||||
background: rgba(59, 130, 246, 0.05) !important;
|
||||
border-left: 3px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
.rule-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
min-height: 60px;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
min-width: 0; /* 允许单元格缩小 */
|
||||
}
|
||||
|
||||
.rule-cell.name-cell {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.rule-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rule-description {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.direction-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-secondary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.direction-content span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.program-text {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.profiles-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
|
||||
/* 在小屏幕上隐藏统计卡片 */
|
||||
@media (max-width: 1024px), (max-height: 700px) {
|
||||
.stats-hidden-on-small {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 顶部工具栏响应式 */
|
||||
@media (max-width: 900px) {
|
||||
.firewall-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.view-subtitle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.status-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.button-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.firewall-status {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 超小屏幕特殊处理 */
|
||||
@media (max-width: 768px) {
|
||||
.firewall-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panel-controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
padding: 12px 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 12px;
|
||||
min-width: 35px;
|
||||
}
|
||||
|
||||
/* 隐藏部分列 */
|
||||
.program-cell,
|
||||
.profiles-cell {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.checkbox-cell {
|
||||
width: 35px;
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.status-cell {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.direction-cell {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.protocol-cell {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.port-cell {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
width: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 高度不足时的优化 */
|
||||
@media (max-height: 600px) {
|
||||
.firewall-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.view-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.view-subtitle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.firewall-toolbar {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.rules-panel {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Naive UI 组件样式覆盖 */
|
||||
:deep(.n-input) {
|
||||
--n-border: 1px solid var(--border-tertiary);
|
||||
--n-border-hover: 1px solid var(--border-hover);
|
||||
--n-border-focus: 1px solid var(--accent-primary);
|
||||
--n-color: var(--bg-card);
|
||||
--n-text-color: var(--text-primary);
|
||||
}
|
||||
|
||||
:deep(.n-select) {
|
||||
--n-color: var(--bg-card);
|
||||
--n-border: 1px solid var(--border-tertiary);
|
||||
--n-border-hover: 1px solid var(--border-hover);
|
||||
--n-border-focus: 1px solid var(--accent-primary);
|
||||
--n-text-color: var(--text-primary);
|
||||
}
|
||||
|
||||
:deep(.n-dropdown-menu) {
|
||||
--n-color: var(--bg-card);
|
||||
--n-option-text-color: var(--text-primary);
|
||||
}
|
||||
|
||||
:deep(.n-switch) {
|
||||
--n-rail-color-active: var(--accent-success);
|
||||
}
|
||||
|
||||
:deep(.n-tag) {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:deep(.n-checkbox) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* vue-virtual-scroller 样式 */
|
||||
:deep(.vue-recycle-scroller) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.vue-recycle-scroller__item-wrapper) {
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user