Files
par/web/src/views/RiskRules.vue
mediabot-pt 6a41938671 fix: restore left JSON editor + right card layout for risk rules editor
- 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
2026-07-06 17:51:46 +08:00

391 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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