Files
par/web/src/views/RiskRules.vue

380 lines
19 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>
<!-- 编辑模式:左侧 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 &gt; 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>