fix: 页面配置+风险规则恢复站点列表首页+点击进入编辑模式

This commit is contained in:
mediabot-pt
2026-07-05 21:08:32 +08:00
parent 1ceaf89e0f
commit 91bd6e6aeb
2 changed files with 189 additions and 123 deletions

View File

@@ -2,109 +2,140 @@
<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>
<div style="display:flex;gap:8px" v-if="editType">
<el-button size="small" type="primary" @click="saveRules" :loading="saving">保存</el-button>
<el-button size="small" @click="editType=''">返回列表</el-button>
</div>
</div>
<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" />
<!-- 列表模式 -->
<template v-if="!editType">
<div style="display:flex;gap:12px;flex:1;min-height:0">
<div style="flex:1;overflow:auto;min-height:0">
<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="selectType(s.row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<!-- 右侧:可视化 -->
<div style="flex:1;overflow:auto;min-height:0">
<div v-if="!parsed.length" style="text-align:center;padding:60px;color:#909399">
选择站点后编辑 · 无规则 = 不受限
<!-- 编辑模式 -->
<template v-else>
<div style="display:flex;gap:12px;flex:1;min-height:0">
<div style="flex:0 0 420px;display:flex;flex-direction:column;min-height:0">
<div style="font-size:13px;font-weight:500;margin-bottom:4px">
编辑: {{ editType }}
<span style="color:#909399;font-size:12px;font-weight:normal">(影响所有同类型站点)</span>
</div>
<div style="flex:1;min-height:0;border-radius:4px;overflow:hidden">
<CodeEditor v-model="editorJson" language="json" />
</div>
</div>
<el-table v-else :data="parsed" stripe border style="font-size:13px" :row-style="{height:'42px'}">
<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 style="flex:1;overflow:auto;min-height:0">
<div v-if="!parsed.length" style="text-align:center;padding:40px;color:#909399">该类型暂无规则,配置后所有同类型站点生效</div>
<el-table v-else :data="parsed" stripe border style="font-size:13px" :row-style="{height:'42px'}">
<el-table-column label="级别" width="80">
<template #default="s">
<el-tag :type="s.row.level==='CRITICAL'?'danger':'warning'" 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="180">
<template #default="s">{{ s.row.metric }} {{ condText(s.row.condition) }} {{ s.row.threshold }} {{ s.row.unit||'' }}</template>
</el-table-column>
<el-table-column label="适用条件" width="140">
<template #default="s">{{ appText(s.row) || '-' }}</template>
</el-table-column>
</el-table>
<div style="margin-top:8px">
<el-button size="small" @click="addRule">+ 添加规则</el-button>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup>
import api from '../api'
const saving = ref(false), editorJson = ref('[]')
const allOptions = ref([]), sitesList = ref([]), editType = ref('')
const loading = ref(false), saving = ref(false)
const sites = ref([]), rulesMap = ref({})
const editType = ref(''), editorJson = ref('[]')
const parsed = computed(() => {
try { return JSON.parse(editorJson.value) } catch { return [] }
const fmt = (t) => t ? (t.substring(0,19)||'').replace('T',' ') : '-'
const condText = (c) => ({GT:'>',LT:'<',GTE:'>=',LTE:'<=',EQ:'='})[c]||c
const appText = (r) => r.applicable ? Object.entries(r.applicable).map(([k,v])=>k+'>='+v).join(', ') : null
const parsed = computed(() => { try { return JSON.parse(editorJson.value) } catch { return [] } })
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,
}))
})
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 {
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 [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 loadEditor = async () => {
if (!editType.value) return
const selectType = async (row) => {
editType.value = row.siteType
try {
const r = await api.raw.get('/admin/risk-rules/' + editType.value)
const r = await api.raw.get('/admin/risk-rules/' + row.siteType)
const data = r?.data || r
editorJson.value = data?.rules ? JSON.stringify(data.rules, null, 2) : '[]'
} catch { editorJson.value = '[]' }
}
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 removeRule = (idx) => {
const arr = parsed.value
arr.splice(idx, 1)
editorJson.value = JSON.stringify(arr, null, 2)
parsed.value.push({type:'TRAFFIC',name:'',metric:'',threshold:0,unit:'DAY',condition:'GT',level:'WARN'})
editorJson.value = JSON.stringify(parsed.value, null, 2)
}
const saveRules = async () => {
saving.value = true
try {
await api.raw.post('/risk-rules/'+editType.value, { rules: parsed.value })
ElMessage.success('已保存')
ElMessage.success('已保存'); editType.value = ''; fetchAll()
} catch { ElMessage.error('保存失败') }
finally { saving.value = false }
}

View File

@@ -2,102 +2,137 @@
<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="editSiteId" placeholder="选择站点" style="width:200px" filterable size="small">
<el-option v-for="s in allSiteOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
<el-button size="small" @click="loadEditor">加载</el-button>
<div style="display:flex;gap:8px" v-if="editSiteId">
<el-button size="small" type="primary" @click="savePages" :loading="saving">保存</el-button>
<el-button size="small" @click="editSiteId=''">返回列表</el-button>
</div>
</div>
<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" />
<!-- 列表模式 -->
<template v-if="!editSiteId">
<div style="display:flex;gap:12px;flex:1;min-height:0">
<div style="flex:1;overflow:auto;min-height:0">
<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 label="入口数" width="80" align="center">
<template #default="s">{{ s.row.entryCount || 0 }}</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="selectSite(s.row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<!-- 右侧:可视化 -->
<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 border style="font-size:13px" :row-style="{height:'42px'}">
<el-table-column prop="key" label="标识" width="100" />
<el-table-column prop="label" label="名称" width="110" />
<el-table-column prop="method" label="方法" width="70" />
<el-table-column label="完整URL" min-width="250">
<template #default="s">{{ resolveUrl(s.row.url) }}</template>
</el-table-column>
<el-table-column label="操作" width="60">
<template #default="s">
<el-button size="small" type="danger" text @click="removeEntry(s.$index)">×</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="parsed.length" style="margin-top:8px">
<el-button size="small" @click="addEntry">+ 添加</el-button>
<!-- 编辑模式 -->
<template v-else>
<div style="display:flex;gap:12px;flex:1;min-height:0">
<div style="flex:0 0 420px;display:flex;flex-direction:column;min-height:0">
<div style="font-size:13px;font-weight:500;margin-bottom:4px">编辑: {{ editSiteId }}</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 v-if="!parsed.length" style="text-align:center;padding:40px;color:#909399">暂无页面入口</div>
<el-table v-else :data="parsed" stripe border style="font-size:13px" :row-style="{height:'42px'}">
<el-table-column prop="key" label="标识" width="100" />
<el-table-column prop="label" label="名称" width="110" />
<el-table-column prop="method" label="方法" width="70" />
<el-table-column label="完整URL" min-width="250">
<template #default="s">{{ resolveUrl(s.row.url) }}</template>
</el-table-column>
</el-table>
<div style="margin-top:8px;display:flex;gap:8px">
<el-button size="small" @click="addEntry">+ 添加入口</el-button>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup>
import api from '../api'
const saving = ref(false), editorJson = ref('[]')
const allSiteOptions = ref([]), sitesList = ref([])
const editSiteId = ref('')
const loading = ref(false), saving = ref(false)
const sites = ref([]), pagesMap = ref({})
const editSiteId = ref(''), editorJson = ref('[]')
const sitesList = ref([])
const parsed = computed(() => {
try { return JSON.parse(editorJson.value) } catch { return [] }
})
const fmt = (t) => t ? (t.substring(0,19)||'').replace('T',' ') : '-'
const parsed = computed(() => { try { return JSON.parse(editorJson.value) } catch { return [] } })
const resolveUrl = (url) => {
if (!url) return ''
const site = sitesList.value.find(s => s.siteId === editSiteId.value)
const base = site?.urls?.[0] || '{base}'
return url.replace('{base}', base)
return url.replace('{base}', site?.urls?.[0] || '{base}')
}
const fetchSites = async () => {
const merged = computed(() => {
return (Array.isArray(sitesList.value)?sitesList.value:[]).map(s => ({
siteId: s.siteId, names: s.names,
version: pagesMap.value[s.siteId]?.version || null,
entryCount: pagesMap.value[s.siteId]?.entryCount || 0,
updatedAt: pagesMap.value[s.siteId]?.updatedAt || null,
entriesJson: pagesMap.value[s.siteId]?.entriesJson || null,
}))
})
const fetchAll = async () => {
loading.value = true
try {
sitesList.value = (await api.raw.get('/admin/sites'))?.data || []
allSiteOptions.value = sitesList.value.map(s => ({label: (s.names?.join('/')||s.siteId), value: s.siteId}))
} catch {}
const [sr, pr] = await Promise.allSettled([api.raw.get('/admin/sites'), api.raw.get('/admin/site-pages')])
sitesList.value = sr.status==='fulfilled' ? (sr.value?.data || sr.value || []) : []
const pages = pr.status==='fulfilled' ? (pr.value?.data || pr.value || []) : []
const map = {}
for (const p of pages) {
const entries = (() => { try { return JSON.parse(p.entriesJson||'[]') } catch {return []} })()
const v = p.version
if (!map[p.siteId] || v > map[p.siteId].version) {
map[p.siteId] = { version: v, entryCount: entries.length, updatedAt: p.updatedAt, entriesJson: p.entriesJson }
}
}
pagesMap.value = map
} finally { loading.value = false }
}
const loadEditor = async () => {
if (!editSiteId.value) return
const selectSite = async (row) => {
editSiteId.value = row.siteId
try {
const r = await api.raw.get('/admin/site-pages/' + editSiteId.value)
const r = await api.raw.get('/admin/site-pages/' + row.siteId)
const data = r?.data || r
editorJson.value = data?.entries ? JSON.stringify(data.entries, null, 2) : '[]'
} catch { editorJson.value = '[]' }
}
const addEntry = () => {
const arr = parsed.value
arr.push({key:'',label:'',url:'{base}',method:'GET'})
editorJson.value = JSON.stringify(arr, null, 2)
}
const removeEntry = (idx) => {
const arr = parsed.value
arr.splice(idx, 1)
editorJson.value = JSON.stringify(arr, null, 2)
parsed.value.push({key:'',label:'',url:'{base}',method:'GET'})
editorJson.value = JSON.stringify(parsed.value, null, 2)
}
const savePages = async () => {
saving.value = true
try {
await api.raw.put('/sites/'+editSiteId.value+'/pages', { entries: parsed.value })
ElMessage.success('已保存')
ElMessage.success('已保存'); editSiteId.value = ''; fetchAll()
} catch { ElMessage.error('保存失败') }
finally { saving.value = false }
}
onMounted(fetchSites)
onMounted(fetchAll)
</script>