feat: redesign risk rules editor with card-based visual UI
- Replace table-based editor with intuitive card layout showing rules as readable sentences - Add drag-and-drop reordering for risk rules - Add 8 preset rule templates with one-click import (traffic, ratio, kill, HnR, login) - Collapse JSON editor by default, toggle via button for power users - Show condition as natural language (GT=greater than, LT=less than, etc.) - Color-coded left border by severity (red=CRITICAL, orange=WARN, blue=INFO) - Add optional rule description field
This commit is contained in:
@@ -26,71 +26,202 @@
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 编辑模式:全屏双栏 -->
|
||||
<!-- 编辑模式:卡片式可视化 -->
|
||||
<div v-else style="display:flex;flex-direction:column;height:calc(100vh - 80px)">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<h2 style="margin:0">编辑: {{ editType }}</h2>
|
||||
<!-- 顶部工具栏 -->
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;flex-shrink:0">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<h2 style="margin:0">编辑: {{ editType }}</h2>
|
||||
<el-tag size="small" type="info">{{ parsed.length }} 条规则</el-tag>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<el-button size="small" @click="showJson = !showJson">
|
||||
<el-icon style="margin-right:4px"><component :is="showJson ? 'Hide' : 'View'"/></el-icon>
|
||||
{{ showJson ? '收起' : 'JSON' }}
|
||||
</el-button>
|
||||
<el-button size="small" @click="showPresets = true">模板</el-button>
|
||||
<el-button size="small" type="primary" @click="saveRules" :loading="saving">保存</el-button>
|
||||
<el-button size="small" @click="uploadRules" :loading="uploading">上传</el-button>
|
||||
<el-button size="small" @click="editType=''">返回列表</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主体:卡片列表 + JSON(可选展开) -->
|
||||
<div style="display:flex;gap:12px;flex:1;min-height:0">
|
||||
<div style="flex:0 0 400px;display:flex;flex-direction:column;min-height:0">
|
||||
<!-- 左侧:规则卡片列表 -->
|
||||
<div :style="{flex:showJson?'0 0 60%':'1',overflow:'auto',minHeight:0,paddingRight:'4px'}">
|
||||
<!-- 添加按钮 + 快速模板 -->
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:10px;flex-wrap:wrap">
|
||||
<el-button size="small" type="primary" @click="addRule">
|
||||
<el-icon style="margin-right:2px"><Plus /></el-icon>添加规则
|
||||
</el-button>
|
||||
<el-dropdown @command="addPreset">
|
||||
<el-button size="small">从模板添加<el-icon style="margin-left:2px"><ArrowDown /></el-icon></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-for="p in presets" :key="p.name" :command="p">
|
||||
<span style="font-size:12px">{{ p.name }}</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<span v-if="!parsed.length" style="color:#909399;font-size:13px">— 该类型暂无规则,请添加或从模板导入</span>
|
||||
</div>
|
||||
|
||||
<!-- 规则卡片 -->
|
||||
<div v-if="parsed.length" style="display:flex;flex-direction:column;gap:10px">
|
||||
<div v-for="(rule, idx) in parsed" :key="idx"
|
||||
:class="['rule-card', rule.level.toLowerCase()]"
|
||||
:draggable="true"
|
||||
@dragstart="onDragStart($event, idx)"
|
||||
@dragover.prevent="onDragOver($event, idx)"
|
||||
@dragend="dragIdx = null"
|
||||
@drop="onDrop($event, idx)"
|
||||
>
|
||||
<!-- 卡片头部 -->
|
||||
<div class="card-header">
|
||||
<div class="card-header-left">
|
||||
<!-- 拖拽手柄 -->
|
||||
<el-icon class="drag-handle"><Rank /></el-icon>
|
||||
<!-- 级别标签 -->
|
||||
<el-select v-model="rule.level" size="small" @change="sync" style="width:90px">
|
||||
<el-option v-for="x in levelOptions" :key="x.value" :label="x.label" :value="x.value">
|
||||
<span :style="{color:levelColor(x.value),fontWeight:600}">{{ x.label }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<!-- 类型标签 -->
|
||||
<el-select v-model="rule.type" size="small" @change="sync" style="width:100px">
|
||||
<el-option v-for="x in typeOptions" :key="x.value" :label="x.label" :value="x.value" />
|
||||
</el-select>
|
||||
<!-- 规则名称 -->
|
||||
<el-input v-model="rule.name" size="small" placeholder="规则名称" @input="sync" style="width:160px" />
|
||||
</div>
|
||||
<div class="card-header-right">
|
||||
<span class="rule-index">#{{ idx + 1 }}</span>
|
||||
<el-button size="small" type="danger" text @click="confirmRemove(idx)" :icon="Delete" circle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片正文:自然语言描述 -->
|
||||
<div class="card-body">
|
||||
<div class="condition-row">
|
||||
<span class="condition-label">触发条件</span>
|
||||
<span class="condition-text">当</span>
|
||||
<el-input v-model="rule.metric" size="small" placeholder="指标名" @input="sync" style="width:110px" />
|
||||
<el-select v-model="rule.condition" size="small" @change="sync" style="width:110px">
|
||||
<el-option v-for="x in condOptions" :key="x.value" :label="x.label" :value="x.value" />
|
||||
</el-select>
|
||||
<el-input v-model.number="rule.threshold" size="small" type="number" placeholder="阈值" @input="sync" style="width:90px" />
|
||||
<el-input v-model="rule.unit" size="small" placeholder="单位" @input="sync" style="width:70px" />
|
||||
<span class="condition-label then-arrow">→</span>
|
||||
<span :style="{color:levelColor(rule.level),fontWeight:600}">{{ levelText(rule.level) }}</span>
|
||||
</div>
|
||||
<!-- 规则描述 -->
|
||||
<div class="description-row" v-if="showDesc[idx] || rule.description">
|
||||
<el-input v-model="rule.description" size="small" placeholder="规则说明(可选)" @input="sync" style="flex:1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:JSON 编辑器(可折叠) -->
|
||||
<div v-if="showJson" style="flex:0 0 38%;display:flex;flex-direction:column;min-height:0">
|
||||
<div style="font-size:13px;font-weight:500;margin-bottom:4px">JSON 配置</div>
|
||||
<div style="flex:1;min-height:0;border-radius:4px;overflow:hidden">
|
||||
<CodeEditor v-model="editorJson" language="json" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1;overflow:auto;min-height:0">
|
||||
<div style="font-size:13px;font-weight:500;margin-bottom:4px">双向可视化</div>
|
||||
<el-table :data="parsed" stripe border style="font-size:13px" :row-style="{height:'42px'}" v-if="parsed.length">
|
||||
<el-table-column label="级别" width="80">
|
||||
<template #default="s"><el-select v-model="s.row.level" size="small" @change="sync"><el-option v-for="x in ['CRITICAL','WARN','INFO']" :key="x" :label="x" :value="x"/></el-select></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="90">
|
||||
<template #default="s"><el-select v-model="s.row.type" size="small" @change="sync"><el-option v-for="x in ['KILL','TRAFFIC','RATIO','LOGIN','HD']" :key="x" :label="x" :value="x"/></el-select></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="名称" min-width="160">
|
||||
<template #default="s"><el-input v-model="s.row.name" size="small" @input="sync"/></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="metric" label="指标" width="120">
|
||||
<template #default="s"><el-input v-model="s.row.metric" size="small" @input="sync"/></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="threshold" label="阈值" width="70">
|
||||
<template #default="s"><el-input v-model.number="s.row.threshold" size="small" type="number" @input="sync"/></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unit" label="单位" width="60">
|
||||
<template #default="s"><el-input v-model="s.row.unit" size="small" @input="sync"/></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="条件" width="70">
|
||||
<template #default="s"><el-select v-model="s.row.condition" size="small" @change="sync"><el-option v-for="x in ['GT','LT','GTE','LTE']" :key="x" :label="x" :value="x"/></el-select></template>
|
||||
</el-table-column>
|
||||
<el-table-column width="50">
|
||||
<template #default="s"><el-button size="small" type="danger" text @click="remove(s.$index)">×</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-else style="text-align:center;padding:40px;color:#909399">该类型暂无规则</div>
|
||||
<div style="margin-top:8px"><el-button size="small" @click="addRule">+ 添加</el-button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预设规则对话框 -->
|
||||
<el-dialog v-model="showPresets" title="预设规则模板" width="700px" :close-on-click-modal="false">
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<el-checkbox-group v-model="selectedPresets">
|
||||
<div v-for="p in presets" :key="p.name" style="padding:10px 14px;border:1px solid #ebeef5;border-radius:6px;margin-bottom:6px">
|
||||
<el-checkbox :value="p.name" :label="p.name" style="width:100%">
|
||||
<span style="font-weight:500">{{ p.name }}</span>
|
||||
<span style="color:#909399;font-size:12px;margin-left:8px">{{ presetDesc(p) }}</span>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showPresets = false">取消</el-button>
|
||||
<el-button type="primary" @click="importPresets">导入选中 ({{ selectedPresets.length }})</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import api from '../api'; import { Refresh } from '@element-plus/icons-vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Plus, ArrowDown, Rank, Delete, View, Hide } from '@element-plus/icons-vue'
|
||||
import api from '../api'
|
||||
|
||||
const loading = ref(false), saving = ref(false), uploading = ref(false), sites = ref([]), rulesMap = ref({})
|
||||
// 选项配置
|
||||
const levelOptions = [
|
||||
{ value: 'CRITICAL', label: '🔴 严重' },
|
||||
{ value: 'WARN', label: '🟠 关注' },
|
||||
{ value: 'INFO', label: '🔵 提示' },
|
||||
]
|
||||
const typeOptions = [
|
||||
{ value: 'KILL', label: '封号' },
|
||||
{ value: 'TRAFFIC', label: '流量' },
|
||||
{ value: 'RATIO', label: '分享率' },
|
||||
{ value: 'LOGIN', label: '登录' },
|
||||
{ value: 'HD', label: 'H&R' },
|
||||
]
|
||||
const condOptions = [
|
||||
{ value: 'GT', label: '大于' },
|
||||
{ value: 'LT', label: '小于' },
|
||||
{ value: 'GTE', label: '大于等于' },
|
||||
{ value: 'LTE', label: '小于等于' },
|
||||
]
|
||||
|
||||
// 预设规则模板
|
||||
const presets = [
|
||||
{ name: '流量过高告警', type:'TRAFFIC', metric:'upload', threshold:1024, unit:'GB', condition:'GT', level:'WARN', description:'上传流量超过1TB时告警' },
|
||||
{ name: '流量严重超标', type:'TRAFFIC', metric:'upload', threshold:5120, unit:'GB', condition:'GT', level:'CRITICAL', description:'上传流量超过5TB时严重警告' },
|
||||
{ name: '分享率过低', type:'RATIO', metric:'ratio', threshold:0.5, unit:'', condition:'LT', level:'WARN', description:'分享率低于0.5时告警' },
|
||||
{ name: '分享率极低', type:'RATIO', metric:'ratio', threshold:0.1, unit:'', condition:'LT', level:'CRITICAL', description:'分享率低于0.1时严重警告' },
|
||||
{ name: '封号检测', type:'KILL', metric:'disabled', threshold:1, unit:'次', condition:'GTE',level:'CRITICAL', description:'账号被禁用时触发' },
|
||||
{ name: 'H&R 未达标', type:'HD', metric:'hnr', threshold:1, unit:'个', condition:'GTE',level:'WARN', description:'有未完成的H&R任务' },
|
||||
{ name: '长期未登录', type:'LOGIN', metric:'lastSeen', threshold:30, unit:'天', condition:'GT', level:'WARN', description:'超过30天未登录' },
|
||||
{ name: '下载量过大', type:'TRAFFIC', metric:'download', threshold:2048, unit:'GB', condition:'GT', level:'INFO', description:'下载流量超过2TB时提示' },
|
||||
]
|
||||
|
||||
const presetDesc = (p) => `当 ${p.metric} ${condOptions.find(c=>c.value===p.condition)?.label||p.condition} ${p.threshold}${p.unit||''} → ${levelOptions.find(l=>l.value===p.level)?.label||p.level}`
|
||||
|
||||
// 状态
|
||||
const loading = ref(false), saving = ref(false), uploading = ref(false)
|
||||
const sites = ref([]), rulesMap = ref({})
|
||||
const editType = ref(''), editorJson = ref('[]')
|
||||
const showJson = ref(false)
|
||||
const showPresets = ref(false)
|
||||
const selectedPresets = ref([])
|
||||
const showDesc = ref({})
|
||||
const dragIdx = ref(null)
|
||||
|
||||
// 格式化
|
||||
const fmt = (t) => t ? (t.substring(0,19)||'').replace('T',' ') : '-'
|
||||
const parsed = computed(() => { try { return JSON.parse(editorJson.value) } catch { return [] } })
|
||||
|
||||
// 解析规则
|
||||
const parsed = computed({
|
||||
get() { try { return JSON.parse(editorJson.value) } catch { return [] } },
|
||||
set(v) { editorJson.value = JSON.stringify(v, null, 2) }
|
||||
})
|
||||
|
||||
const merged = computed(() => (Array.isArray(sites.value)?sites.value:[]).map(s => ({
|
||||
siteId: s.siteId, names: s.names, siteType: s.siteType,
|
||||
ruleCount: rulesMap.value[s.siteType]?.ruleCount || 0, version: rulesMap.value[s.siteType]?.version || null,
|
||||
updatedAt: rulesMap.value[s.siteType]?.updatedAt || null, rulesJson: rulesMap.value[s.siteType]?.rulesJson || null,
|
||||
})))
|
||||
|
||||
const levelColor = (lv) => ({ CRITICAL: '#f56c6c', WARN: '#e6a23c', INFO: '#409eff' }[lv] || '#909399')
|
||||
const levelText = (lv) => ({ CRITICAL: '严重', WARN: '关注', INFO: '提示' }[lv] || lv)
|
||||
|
||||
// 数据操作
|
||||
const fetchAll = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -106,26 +237,159 @@ const fetchAll = async () => {
|
||||
rulesMap.value = map
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
const editRule = async (row) => {
|
||||
editType.value = row.siteType
|
||||
try { const r = await api.raw.get('/admin/risk-rules/'+row.siteType); editorJson.value = (r?.data||r)?.rules ? JSON.stringify((r.data||r).rules, null, 2) : '[]' }
|
||||
catch { editorJson.value = '[]' }
|
||||
try {
|
||||
const r = await api.raw.get('/admin/risk-rules/'+row.siteType)
|
||||
editorJson.value = (r?.data||r)?.rules ? JSON.stringify((r.data||r).rules, null, 2) : '[]'
|
||||
} catch { editorJson.value = '[]' }
|
||||
}
|
||||
|
||||
const sync = () => { editorJson.value = JSON.stringify(parsed.value, null, 2) }
|
||||
const remove = (idx) => { parsed.value.splice(idx,1); sync() }
|
||||
const addRule = () => { parsed.value.push({type:'TRAFFIC',name:'',metric:'',threshold:0,unit:'DAY',condition:'GT',level:'WARN'}); sync() }
|
||||
|
||||
const addRule = () => {
|
||||
const arr = [...parsed.value, {type:'TRAFFIC',name:'',metric:'',threshold:0,unit:'DAY',condition:'GT',level:'WARN',description:''}]
|
||||
parsed.value = arr
|
||||
}
|
||||
|
||||
const confirmRemove = async (idx) => {
|
||||
try { await ElMessageBox.confirm('确定删除此规则?', '确认', { type:'warning', confirmButtonText:'删除' }) } catch { return }
|
||||
const arr = [...parsed.value]
|
||||
arr.splice(idx, 1)
|
||||
parsed.value = arr
|
||||
}
|
||||
|
||||
const addPreset = (p) => {
|
||||
const arr = [...parsed.value, {...p}]
|
||||
parsed.value = arr
|
||||
}
|
||||
|
||||
const importPresets = () => {
|
||||
const items = presets.filter(p => selectedPresets.value.includes(p.name))
|
||||
const arr = [...parsed.value, ...items]
|
||||
parsed.value = arr
|
||||
selectedPresets.value = []
|
||||
showPresets.value = false
|
||||
ElMessage.success(`已导入 ${items.length} 条规则`)
|
||||
}
|
||||
|
||||
// 拖拽排序
|
||||
const onDragStart = (e, idx) => {
|
||||
dragIdx.value = idx
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
const onDragOver = (e, idx) => {
|
||||
if (dragIdx.value === null || dragIdx.value === idx) return
|
||||
const arr = [...parsed.value]
|
||||
const [item] = arr.splice(dragIdx.value, 1)
|
||||
arr.splice(idx, 0, item)
|
||||
parsed.value = arr
|
||||
dragIdx.value = idx
|
||||
}
|
||||
|
||||
const onDrop = (e, idx) => {
|
||||
dragIdx.value = null
|
||||
}
|
||||
|
||||
// 保存/上传
|
||||
const saveRules = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
await api.raw.post('/risk-rules/'+editType.value, { rules: parsed.value })
|
||||
ElMessage.success('已保存')
|
||||
editType.value = ''
|
||||
fetchAll()
|
||||
} catch { ElMessage.error('保存失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
const uploadRules = async () => {
|
||||
uploading.value = true
|
||||
try { await api.raw.post('/risk-rules/'+editType.value+'/upload', { rules: parsed.value }); ElMessage.success('已上传') }
|
||||
catch { ElMessage.error('上传失败') }
|
||||
try {
|
||||
await api.raw.post('/risk-rules/'+editType.value+'/upload', { rules: parsed.value })
|
||||
ElMessage.success('已上传')
|
||||
} catch { ElMessage.error('上传失败') }
|
||||
finally { uploading.value = false }
|
||||
}
|
||||
|
||||
const saveRules = async () => {
|
||||
saving.value = true
|
||||
try { await api.raw.post('/risk-rules/'+editType.value, { rules: parsed.value }); ElMessage.success('已保存'); editType.value=''; fetchAll() }
|
||||
catch { ElMessage.error('保存失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
onMounted(fetchAll)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rule-card {
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
transition: box-shadow .2s, border-color .2s;
|
||||
cursor: default;
|
||||
}
|
||||
.rule-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.08);
|
||||
}
|
||||
.rule-card.critical { border-left: 4px solid #f56c6c; }
|
||||
.rule-card.warn { border-left: 4px solid #e6a23c; }
|
||||
.rule-card.info { border-left: 4px solid #409eff; }
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
.card-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.card-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
color: #c0c4cc;
|
||||
font-size: 16px;
|
||||
}
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
|
||||
.rule-index {
|
||||
font-size: 11px;
|
||||
color: #c0c4cc;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0 14px 12px 34px;
|
||||
}
|
||||
.condition-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
.condition-label {
|
||||
font-weight: 500;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
.condition-text {
|
||||
color: #606266;
|
||||
}
|
||||
.then-arrow {
|
||||
color: #409eff;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.description-row {
|
||||
margin-top: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user