- Left pane: fixed 400px CodeEditor for JSON editing - Right pane: card-based visual editor with natural language, drag-and-drop, presets - Remove JSON toggle button, always show both panes
391 lines
16 KiB
Vue
391 lines
16 KiB
Vue
<template>
|
||
<!-- 列表模式 -->
|
||
<div v-if="!editType">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||
<h2 style="margin:0">风险规则</h2>
|
||
<el-button size="small" @click="fetchAll" :icon="Refresh">刷新</el-button>
|
||
</div>
|
||
<el-table :data="merged" stripe v-loading="loading" border empty-text="暂无站点" style="font-size:13px" :row-style="{height:'44px'}">
|
||
<el-table-column prop="siteId" label="站点" width="140" />
|
||
<el-table-column label="名称" min-width="130">
|
||
<template #default="s">{{ (s.row.names||[]).join('/') || '-' }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="siteType" label="类型" width="100" />
|
||
<el-table-column label="规则数" width="80" align="center">
|
||
<template #default="s"><el-tag size="small">{{ s.row.ruleCount || 0 }}</el-tag></template>
|
||
</el-table-column>
|
||
<el-table-column label="版本" width="80" align="center">
|
||
<template #default="s">{{ s.row.version || '-' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="更新时间" width="150">
|
||
<template #default="s">{{ fmt(s.row.updatedAt) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="100">
|
||
<template #default="s"><el-button size="small" @click="editRule(s.row)">编辑</el-button></template>
|
||
</el-table-column>
|
||
</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: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="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">
|
||
<!-- 左侧:JSON 编辑器 -->
|
||
<div style="flex:0 0 400px;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="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>
|
||
</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 { ref, computed, onMounted } from 'vue'
|
||
import { Plus, ArrowDown, Rank, Delete } from '@element-plus/icons-vue'
|
||
import api from '../api'
|
||
|
||
// 选项配置
|
||
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 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({
|
||
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 {
|
||
const [sr, rr] = await Promise.allSettled([api.raw.get('/admin/sites'), api.raw.get('/admin/risk-rules')])
|
||
sites.value = sr.status==='fulfilled' ? (sr.value?.data||sr.value||[]) : []
|
||
const rulesData = rr.status==='fulfilled' ? (rr.value?.data||rr.value||[]) : []
|
||
const map = {}
|
||
for (const r of rulesData) {
|
||
const cnt = (() => { try { return JSON.parse(r.rulesJson||'[]').length } catch { return 0 } })()
|
||
if (!map[r.siteType] || r.version > map[r.siteType].version)
|
||
map[r.siteType] = { ruleCount: cnt, version: r.version, updatedAt: r.updatedAt, rulesJson: r.rulesJson }
|
||
}
|
||
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 = '[]' }
|
||
}
|
||
|
||
const sync = () => { editorJson.value = JSON.stringify(parsed.value, null, 2) }
|
||
|
||
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('上传失败') }
|
||
finally { uploading.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>
|