380 lines
19 KiB
Vue
380 lines
19 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>
|
||
|
||
<!-- 编辑模式:左侧 JSON + 右侧卡片可视化 -->
|
||
<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">编辑: {{ editSiteName }}</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>
|
||
|
||
<!-- 主体 -->
|
||
<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">
|
||
<div style="font-size:12px">
|
||
<span style="font-weight:600">{{ p.name }}</span>
|
||
<span style="color:#909399;margin-left:4px">{{ typeLabel(p.type) }}</span>
|
||
</div>
|
||
</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"
|
||
:draggable="true"
|
||
@dragstart="onDragStart($event, idx)"
|
||
@dragover.prevent="onDragOver($event, idx)"
|
||
@dragend="dragIdx = null"
|
||
@drop="onDrop($event, idx)"
|
||
>
|
||
<!-- 卡片头部 -->
|
||
<div class="card-header" @dblclick="rule._fold = !rule._fold">
|
||
<div class="card-header-left">
|
||
<el-icon class="drag-handle"><Rank /></el-icon>
|
||
<el-select v-model="rule.type" size="small" @change="onTypeChange(rule)" style="width:100px">
|
||
<el-option v-for="x in typeOptions" :key="x.value" :label="x.label" :value="x.value" />
|
||
</el-select>
|
||
<span class="rule-fixed-text">{{ metricLabel(rule.metric) }} {{ condLabel(rule.condition) }} {{ rule.threshold }}{{ rule.unit || '' }}</span>
|
||
<span class="then-arrow">→</span>
|
||
<span style="color:#f56c6c;font-weight:600;font-size:13px">封号/删除账号</span>
|
||
</div>
|
||
<div class="card-header-right">
|
||
<span class="rule-index">#{{ idx + 1 }}</span>
|
||
<el-button size="small" text @click="rule._fold = !rule._fold">
|
||
<el-icon><component :is="rule._fold ? 'ArrowDown' : 'ArrowUp'" /></el-icon>
|
||
</el-button>
|
||
<el-button size="small" type="danger" text @click="confirmRemove(idx)" :icon="Delete" circle />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 卡片正文(可折叠) -->
|
||
<div v-show="!rule._fold" class="card-body">
|
||
|
||
<!-- 阈值 -->
|
||
<div class="field-row">
|
||
<span class="field-label">阈值</span>
|
||
<el-input v-model.number="rule.threshold" size="small" type="number" placeholder="阈值" @input="sync" style="width:120px" />
|
||
<span class="field-hint">{{ rule.unit || '' }}</span>
|
||
</div>
|
||
|
||
<!-- 适用条件 -->
|
||
<div class="field-row">
|
||
<span class="field-label">适用条件</span>
|
||
<el-select v-model="rule.applicable" size="small" @change="sync" clearable placeholder="无条件(始终适用)" style="width:200px">
|
||
<el-option v-for="x in applicableOptions" :key="x.value" :label="x.label" :value="x.value" />
|
||
</el-select>
|
||
</div>
|
||
|
||
<!-- 规则说明 -->
|
||
<div class="field-row">
|
||
<span class="field-label">说明</span>
|
||
<el-input v-model="rule.description" size="small" placeholder="可选说明" @input="sync" style="flex:1;max-width:500px" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 预设规则导入对话框 -->
|
||
<el-dialog v-model="showPresets" title="PT 站点管理规则模板" width="700px" :close-on-click-modal="false">
|
||
<div style="margin-bottom:12px;padding:10px 14px;background:#f0f9ff;border-radius:6px;font-size:13px;color:#606266">
|
||
<p style="margin:0 0 4px;font-weight:500">规则体系:等级豁免 → 账号存活 → 流量监控 → H&R 惩罚 → 分享率检查</p>
|
||
<p style="margin:0;font-size:12px;color:#909399">满足豁免条件的用户不受任何规则影响。系统会自动根据风险百分比触发告警(riskPct > 80%)。</p>
|
||
</div>
|
||
<div v-for="cat in presetCategories" :key="cat.title" style="margin-bottom:12px">
|
||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;color:#303133">{{ cat.title }}</div>
|
||
<el-checkbox-group v-model="selectedPresets">
|
||
<div v-for="p in cat.items" :key="p.name"
|
||
style="padding:8px 12px;border:1px solid #ebeef5;border-radius:6px;margin-bottom:4px;display:flex;align-items:center;gap:8px">
|
||
<el-checkbox :value="p.name" :label="p.name" style="margin-right:0;height:auto">
|
||
<span style="font-weight:500;font-size:13px">{{ p.name }}</span>
|
||
</el-checkbox>
|
||
<span style="color:#f56c6c;font-size:11px;font-weight:500">封号</span>
|
||
<span style="color:#909399;font-size:11px;flex:1">{{ presetDesc(p) }}</span>
|
||
</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, ArrowUp, Rank, Delete } from '@element-plus/icons-vue'
|
||
import api from '../api'
|
||
|
||
// ============ 选项配置 ============
|
||
const typeOptions = [
|
||
{ value: 'KILL', label: '封号规则' },
|
||
{ value: 'TRAFFIC', label: '流量监控' },
|
||
{ value: 'LOGIN', label: '登录监控' },
|
||
{ value: 'HD', label: 'H&R' },
|
||
{ value: 'RATIO', label: '分享率' },
|
||
]
|
||
const condOptions = [
|
||
{ value: 'GT', label: '大于' },
|
||
{ value: 'LT', label: '小于' },
|
||
{ value: 'GTE', label: '大于等于' },
|
||
{ value: 'LTE', label: '小于等于' },
|
||
]
|
||
const applicableOptions = [
|
||
{ value: '!sealed', label: '非封存账号' },
|
||
{ value: 'sealed', label: '仅封存账号' },
|
||
{ value: 'zero_traffic', label: '零流量用户' },
|
||
{ value: 'new_account_30d', label: '注册 ≤30 天' },
|
||
]
|
||
const metricOptions = [
|
||
{ value: 'days_since_last_login', label: '距上次登录天数' },
|
||
{ value: 'days_since_last_traffic', label: '距上次流量变化天数' },
|
||
{ value: 'days_since_registration', label: '注册天数' },
|
||
{ value: 'ratio', label: '分享率' },
|
||
{ value: 'upload_gb', label: '上传量(GB)' },
|
||
{ value: 'download_gb', label: '下载量(GB)' },
|
||
{ value: 'bonus', label: '魔力值' },
|
||
{ value: 'seeding', label: '做种数' },
|
||
{ value: 'hnr_count', label: 'H&R 违规次数' },
|
||
{ value: 'avg_seed_time', label: '平均做种时间(小时)' },
|
||
]
|
||
|
||
const typeLabel = (t) => typeOptions.find(x => x.value === t)?.label || t
|
||
|
||
const typeDefaults = {
|
||
KILL: { metric:'days_since_last_login', condition:'GT', unit:'天' },
|
||
TRAFFIC: { metric:'days_since_last_traffic', condition:'GT', unit:'天' },
|
||
LOGIN: { metric:'days_since_last_login', condition:'GT', unit:'天' },
|
||
HD: { metric:'hnr_count', condition:'GTE', unit:'次' },
|
||
RATIO: { metric:'ratio', condition:'LT', unit:'' },
|
||
}
|
||
const metricLabel = (m) => metricOptions.find(x => x.value === m)?.label || m || ''
|
||
const condLabel = (c) => condOptions.find(x => x.value === c)?.label || c || ''
|
||
|
||
const onTypeChange = (rule) => {
|
||
const d = typeDefaults[rule.type]
|
||
if (d) { rule.metric = d.metric; rule.condition = d.condition; rule.unit = d.unit }
|
||
sync()
|
||
}
|
||
|
||
// ============ 预设规则模板 ============
|
||
const presets = [
|
||
// 四、账号存活策略
|
||
{ name:'未封存不登录封号', type:'KILL', metric:'days_since_last_login', threshold:150, unit:'天', condition:'GT', applicable:'!sealed', description:'未封存账号连续150天不登录即封号' },
|
||
{ name:'封存账号不登录封号', type:'KILL', metric:'days_since_last_login', threshold:365, unit:'天', condition:'GT', applicable:'sealed', description:'封存账号连续365天不登录即封号' },
|
||
{ name:'零流量账号清理', type:'KILL', metric:'days_since_last_login', threshold:30, unit:'天', condition:'GT', applicable:'zero_traffic', description:'零流量用户连续30天不登录即封号' },
|
||
// 五、零活跃新用户清理
|
||
{ name:'零活跃不登录封号', type:'KILL', metric:'days_since_last_login', threshold:7, unit:'天', condition:'GT', applicable:'new_account_30d', description:'零活跃新用户连续7天不登录即封号' },
|
||
{ name:'零活跃注册天数封号', type:'KILL', metric:'days_since_registration', threshold:30, unit:'天', condition:'GT', applicable:'new_account_30d', description:'零活跃新用户注册满30天即封号' },
|
||
// 流量监控
|
||
{ name:'连续无流量封号', type:'TRAFFIC', metric:'days_since_last_traffic', threshold:90, unit:'天', condition:'GT', description:'连续90天无上传/下载流量即封号' },
|
||
// H&R
|
||
{ name:'H&R 封号', type:'HD', metric:'hnr_count', threshold:5, unit:'次', condition:'GTE', description:'H&R违规次数达到5次即封号' },
|
||
]
|
||
|
||
const presetCategories = [
|
||
{ title:'四、账号存活策略', items: presets.filter(p => p.name.includes('不登录') || p.name.includes('账号清理')) },
|
||
{ title:'五、零活跃新用户清理', items: presets.filter(p => p.name.includes('零活跃')) },
|
||
{ title:'流量监控', items: presets.filter(p => p.type === 'TRAFFIC') },
|
||
{ title:'H&R 惩罚', items: presets.filter(p => p.type === 'HD') },
|
||
]
|
||
|
||
const presetDesc = (p) => {
|
||
const condLabel = condOptions.find(c => c.value === p.condition)?.label || p.condition
|
||
const appLabel = applicableOptions.find(a => a.value === p.applicable)?.label || ''
|
||
return `${metricOptions.find(m=>m.value===p.metric)?.label||p.metric} ${condLabel} ${p.threshold}${p.unit||''}${appLabel?' ('+appLabel+')':''}`
|
||
}
|
||
|
||
// ============ 状态 ============
|
||
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 dragIdx = ref(null)
|
||
|
||
const fmt = (t) => t ? (t.substring(0,19)||'').replace('T',' ') : '-'
|
||
|
||
const parsed = computed({
|
||
get() { try { const arr = JSON.parse(editorJson.value); return Array.isArray(arr) ? arr : [] } catch { return [] } },
|
||
set(v) { editorJson.value = JSON.stringify(v, null, 2) }
|
||
})
|
||
|
||
const editSiteName = computed(() => {
|
||
if (!editType.value) return ''
|
||
const site = (sites.value||[]).find(s => s.siteType === editType.value)
|
||
return site ? (site.names||[]).join('/') || site.siteId || editType.value : editType.value
|
||
})
|
||
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 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 d = typeDefaults['KILL']
|
||
parsed.value = [...parsed.value, {
|
||
type:'KILL', metric:d.metric, threshold:0, unit:d.unit, condition:d.condition,
|
||
applicable:null, description:'', _fold:false
|
||
}]
|
||
}
|
||
|
||
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) => { parsed.value = [...parsed.value, { ...p, _fold: false }] }
|
||
|
||
const importPresets = () => {
|
||
const items = presets.filter(p => selectedPresets.value.includes(p.name)).map(p => ({ ...p, _fold: false }))
|
||
parsed.value = [...parsed.value, ...items]
|
||
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 = () => { 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-left: 4px solid #f56c6c;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
transition: box-shadow .2s;
|
||
cursor: default;
|
||
}
|
||
.rule-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,.08); }
|
||
|
||
.card-header {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 8px 12px; gap: 8px; user-select: none;
|
||
}
|
||
.card-header-left { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||
.card-header-right { display: flex; align-items: center; gap: 4px; 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; min-width: 24px; text-align: right; }
|
||
|
||
.card-body { padding: 6px 12px 10px 12px; display: flex; flex-direction: column; gap: 6px; }
|
||
.rule-fixed-text { font-size: 13px; color: #303133; }
|
||
|
||
.field-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 13px; }
|
||
.field-label { font-weight: 500; color: #606266; font-size: 12px; min-width: 56px; }
|
||
.field-hint { color: #909399; font-size: 12px; }
|
||
.then-arrow { color: #f56c6c; font-size: 14px; font-weight: 700; margin: 0 2px; }
|
||
</style>
|