refactor: 页面配置+风险规则改为左编辑器右可视化的脚本管理同款布局
This commit is contained in:
@@ -1,194 +1,110 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>风险规则</h2>
|
||||
|
||||
<el-card style="margin-top:12px">
|
||||
<template #header><span style="font-weight:500">默认模板</span></template>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap">
|
||||
<div v-for="dt in defaults" :key="dt.type" style="border:1px solid #ebeef5;border-radius:4px;padding:10px 14px;min-width:200px">
|
||||
<div style="font-weight:500;margin-bottom:4px"><el-tag size="small">{{ dt.type }}</el-tag></div>
|
||||
<div style="font-size:12px;color:#909399;margin-bottom:8px">{{ dt.ruleCount }} 条规则</div>
|
||||
<div style="display:flex;gap:4px">
|
||||
<el-button size="small" @click="viewDefault(dt)">查看</el-button>
|
||||
<el-button size="small" type="primary" @click="editDefault(dt)">编辑默认</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div 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">风险规则</h2>
|
||||
<div style="display:flex;gap:8px">
|
||||
<el-select v-model="editType" placeholder="选择站点" style="width:250px" filterable size="small">
|
||||
<el-option v-for="s in allOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||
</el-select>
|
||||
<el-button size="small" @click="loadEditor">加载</el-button>
|
||||
<el-button size="small" type="primary" @click="saveRules" :loading="saving">保存</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-card style="margin-top:12px">
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px">
|
||||
<el-button @click="fetchAll" :loading="loading">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="merged" stripe size="small" v-loading="loading" empty-text="暂无站点">
|
||||
<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="70" 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="220" fixed="right">
|
||||
<template #default="s">
|
||||
<el-select v-model="ruleVer" placeholder="历史" size="small" style="width:70px" clearable
|
||||
@change="v=>loadRuleVersion(s.row,v)" @click.stop>
|
||||
<el-option v-for="v in s.row.versions||[]" :key="v" :label="'v'+v" :value="v" />
|
||||
</el-select>
|
||||
<el-button size="small" @click="startEdit(s.row)" :type="editSiteId===s.row.siteId?'warning':''">
|
||||
{{ editSiteId===s.row.siteId ? '收起' : '编辑' }}
|
||||
</el-button>
|
||||
<el-button v-if="!s.row.ruleCount" size="small" @click="loadDefaultRules(s.row)">读取默认</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div v-if="editSiteId" style="margin-top:16px;padding:12px;background:#fafafa;border-radius:4px">
|
||||
<div style="margin-bottom:8px;display:flex;gap:8px;align-items:center">
|
||||
<strong>编辑: {{ editSiteId }} ({{ editSiteType }})</strong>
|
||||
<span style="color:#909399;font-size:12px">修改会影响所有 {{ editSiteType }} 类型的站点</span>
|
||||
</div>
|
||||
<div style="height:400px;border-radius:4px;overflow:hidden;margin-bottom:8px">
|
||||
<div style="display:flex;gap:12px;flex:1;min-height:0">
|
||||
<!-- 左侧:编辑器 -->
|
||||
<div style="flex:0 0 440px;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 style="display:flex;gap:8px">
|
||||
<el-button size="small" type="primary" @click="saveRules" :loading="saving">保存</el-button>
|
||||
<el-button size="small" @click="editSiteId=''">取消</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:可视化 -->
|
||||
<div style="flex:1;overflow:auto;min-height:0">
|
||||
<div v-if="!parsed.length" style="text-align:center;padding:60px;color:#909399">
|
||||
选择站点后编辑 · 无规则 = 不受限
|
||||
</div>
|
||||
<el-table v-else :data="parsed" stripe size="small" border>
|
||||
<el-table-column label="级别" width="80">
|
||||
<template #default="s">
|
||||
<el-tag :type="s.row.level==='CRITICAL'?'danger':s.row.level==='WARN'?'warning':'info'" size="small">{{ s.row.level }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="type" label="类型" width="90" />
|
||||
<el-table-column prop="name" label="名称" min-width="180" />
|
||||
<el-table-column label="条件" width="160">
|
||||
<template #default="s">
|
||||
{{ s.row.metric }} {{ conditionText(s.row.condition) }} {{ s.row.threshold }} {{ s.row.unit||'' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="适用条件" width="140">
|
||||
<template #default="s">{{ applicableText(s.row) || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="60">
|
||||
<template #default="s">
|
||||
<el-button size="small" type="danger" text @click="removeRule(s.$index)">×</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="parsed.length" style="margin-top:8px">
|
||||
<el-button size="small" @click="addRule">+ 添加</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="viewVisible" :title="'默认规则: '+viewType" width="700px">
|
||||
<div style="height:400px;border-radius:4px;overflow:hidden">
|
||||
<CodeEditor v-model="viewJson" language="json" readonly />
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import api from '../api'
|
||||
|
||||
const loading = ref(false), saving = ref(false), viewVisible = ref(false), viewType = ref(''), viewJson = ref('')
|
||||
const sites = ref([]), rulesMap = ref({}), defaultRulesMap = ref({}), ruleVer = ref('')
|
||||
const editSiteId = ref(''), editSiteType = ref(''), editorJson = ref('')
|
||||
const saving = ref(false), editorJson = ref('[]')
|
||||
const allOptions = ref([]), sitesList = ref([]), editType = ref('')
|
||||
|
||||
const fallbackRules = { nexusphp: [{type:'KILL',name:'',metric:'',threshold:0,unit:'DAY',condition:'GT',level:'CRITICAL'}] }
|
||||
|
||||
const defaults = computed(() => {
|
||||
const types = [...new Set((Array.isArray(sites.value)?sites.value:[]).map(s=>s.siteType).filter(Boolean))]
|
||||
const base = ['nexusphp','mtorrent','gazelle','unit3d']
|
||||
return [...new Set([...types,...base])].map(t => {
|
||||
const rules = defaultRulesMap.value[t] || fallbackRules[t] || []
|
||||
return {type:t, ruleCount:rules.length, rules}
|
||||
})
|
||||
const parsed = computed(() => {
|
||||
try { return JSON.parse(editorJson.value) } catch { return [] }
|
||||
})
|
||||
|
||||
const fmt = (t) => t ? (t.substring(0,19)||'').replace('T',' ') : '-'
|
||||
|
||||
const merged = computed(() => {
|
||||
return (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,
|
||||
versions: rulesMap.value[s.siteType]?.versions || [],
|
||||
}))
|
||||
})
|
||||
const conditionText = (c) => ({GT:'>',LT:'<',GTE:'>=',LTE:'<=',EQ:'='})[c]||c
|
||||
const applicableText = (r) => {
|
||||
if (!r.applicable) return null
|
||||
return Object.entries(r.applicable).map(([k,v]) => `${k}>=${v}`).join(', ')
|
||||
}
|
||||
|
||||
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' ? (Array.isArray(sr.value?.data)?sr.value.data:sr.value||[]) : []
|
||||
const typeSet = new Set(sites.value.map(s=>s.siteType).filter(Boolean))
|
||||
siteTypes.value = [...typeSet]
|
||||
if (siteTypes.value.length===0) siteTypes.value = ['nexusphp','mtorrent','gazelle','unit3d']
|
||||
|
||||
const rulesData = rr.status==='fulfilled' ? (Array.isArray(rr.value?.data)?rr.value.data:rr.value||[]) : []
|
||||
const map = {}, verMap = {}
|
||||
for (const r of rulesData) {
|
||||
const cnt = (() => { try { return JSON.parse(r.rulesJson||'[]').length } catch { return 0 } })()
|
||||
const v = r.version
|
||||
if (!map[r.siteType] || v > map[r.siteType].version) {
|
||||
map[r.siteType] = { ruleCount: cnt, version: v, updatedAt: r.updatedAt, rulesJson: r.rulesJson }
|
||||
}
|
||||
if (!verMap[r.siteType]) verMap[r.siteType] = new Set()
|
||||
verMap[r.siteType].add(v)
|
||||
}
|
||||
for (const k in verMap) map[k].versions = [...verMap[k]].sort((a,b)=>b-a)
|
||||
rulesMap.value = map
|
||||
// 加载默认模板
|
||||
for (const t of siteTypes.value) {
|
||||
try {
|
||||
const r = await api.raw.get('/admin/default-rules/'+t)
|
||||
const data = r?.data || r
|
||||
if (data?.rules) defaultRulesMap.value[t] = data.rules
|
||||
} catch {}
|
||||
}
|
||||
} finally { loading.value = false }
|
||||
sitesList.value = (await api.raw.get('/admin/sites'))?.data || []
|
||||
const opts = sitesList.value.map(s => ({label: (s.names?.join('/')||s.siteId)+' ['+s.siteType+']', value: s.siteType}))
|
||||
allOptions.value = [...new Map(opts.map(o => [o.value, o])).values()]
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const viewDefault = (dt) => {
|
||||
viewType.value = dt.type
|
||||
viewJson.value = JSON.stringify(dt.rules, null, 2)
|
||||
viewVisible.value = true
|
||||
}
|
||||
|
||||
const editDefault = (dt) => {
|
||||
editSiteId.value = dt.type
|
||||
editSiteType.value = dt.type
|
||||
editorJson.value = JSON.stringify(dt.rules, null, 2)
|
||||
window.scrollTo({top: document.body.scrollHeight, behavior:'smooth'})
|
||||
}
|
||||
|
||||
const loadRuleVersion = async (row, ver) => {
|
||||
if (!ver) return
|
||||
const loadEditor = async () => {
|
||||
if (!editType.value) return
|
||||
try {
|
||||
const r = await api.raw.get('/admin/risk-rules/'+row.siteType+'/version/'+ver)
|
||||
editorJson.value = JSON.stringify(JSON.parse((r?.data||r)?.content||'[]'), null, 2)
|
||||
} catch { ElMessage.error('加载版本失败') }
|
||||
const r = await api.raw.get('/admin/risk-rules/' + editType.value)
|
||||
const data = r?.data || r
|
||||
editorJson.value = data?.rules ? JSON.stringify(data.rules, null, 2) : '[]'
|
||||
} catch { editorJson.value = '[]' }
|
||||
}
|
||||
|
||||
const loadDefaultRules = (row) => {
|
||||
editSiteId.value = row.siteId
|
||||
editSiteType.value = row.siteType
|
||||
const rules = defaultRulesMap.value[row.siteType] || fallbackRules[row.siteType] || []
|
||||
editorJson.value = JSON.stringify(rules, null, 2)
|
||||
window.scrollTo({top: document.body.scrollHeight, behavior:'smooth'})
|
||||
const addRule = () => {
|
||||
const arr = parsed.value
|
||||
arr.push({type:'TRAFFIC',name:'',metric:'',threshold:0,unit:'DAY',condition:'GT',level:'WARN'})
|
||||
editorJson.value = JSON.stringify(arr, null, 2)
|
||||
}
|
||||
|
||||
const startEdit = (row) => {
|
||||
if (editSiteId.value === row.siteId) { editSiteId.value = ''; return }
|
||||
editSiteId.value = row.siteId
|
||||
editSiteType.value = row.siteType
|
||||
try { editorJson.value = JSON.stringify(JSON.parse(row.rulesJson||'[]'), null, 2) }
|
||||
catch { editorJson.value = '[]' }
|
||||
const removeRule = (idx) => {
|
||||
const arr = parsed.value
|
||||
arr.splice(idx, 1)
|
||||
editorJson.value = JSON.stringify(arr, null, 2)
|
||||
}
|
||||
|
||||
const saveRules = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
const url = editSiteType.value.startsWith('default:')
|
||||
? '/admin/default-rules/'+editSiteType.value.replace('default:','')
|
||||
: '/risk-rules/'+editSiteType.value
|
||||
await api.raw.post(url, { rules: JSON.parse(editorJson.value) })
|
||||
ElMessage.success('已保存'); editSiteId.value = ''; fetchAll()
|
||||
await api.raw.post('/risk-rules/'+editType.value, { rules: parsed.value })
|
||||
ElMessage.success('已保存')
|
||||
} catch { ElMessage.error('保存失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user