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:
mediabot-pt
2026-07-06 17:19:57 +08:00
parent 773ad8fdb7
commit 2cdf502831

View File

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