feat: full PT site management rules system in risk rules editor

Type coverage: KILL/DEMOTE/TASK/TRAFFIC/LOGIN/HD/RATIO matching all 6 rule categories

Advanced fields: applicable conditions (!sealed/sealed/zero_traffic/new_account_30d), requirements for TASK type, downloadThreshold for DEMOTE type

12 preset templates covering: dynamic ratio, survival policy, zero-activity cleanup, novice exam, HnR penalty, traffic anomaly

Card fold/expand via double-click or arrow button, metric suggestions with filterable select, cleaner field layout
This commit is contained in:
mediabot-pt
2026-07-06 21:06:20 +08:00
parent 6a41938671
commit 9b5cee6439

View File

@@ -26,7 +26,7 @@
</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">
@@ -35,14 +35,14 @@
<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" @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">
@@ -54,25 +54,25 @@
<!-- 右侧:卡片式可视化编辑 -->
<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-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>
<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>
<div style="font-size:12px">
<span :style="{color:levelColor(p.level),fontWeight: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>
<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()]"
@@ -83,46 +83,87 @@
@drop="onDrop($event, idx)"
>
<!-- 卡片头部 -->
<div class="card-header">
<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.level" size="small" @change="sync" style="width:90px">
<el-select v-model="rule.type" size="small" @change="onTypeChange(rule)" style="width:90px">
<el-option v-for="x in typeOptions" :key="x.value" :label="x.label" :value="x.value" />
</el-select>
<el-select v-model="rule.level" size="small" @change="sync" style="width:85px">
<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" />
<el-input v-model="rule.name" size="small" placeholder="规则名称" @input="sync" style="width:180px" />
</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 class="card-body">
<div class="condition-row">
<span class="condition-label">触发条件</span>
<!-- 卡片正文(可折叠) -->
<div v-show="!rule._fold" class="card-body">
<!-- 适用条件(高级) -->
<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>
<span class="field-hint">{{ applicableHint(rule.applicable) }}</span>
</div>
<!-- 触发条件:指标 + 比较 + 阈值 + 单位 -->
<div class="field-row">
<span class="field-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-select v-model="rule.metric" size="small" @change="sync" filterable allow-create placeholder="指标" style="width:160px">
<el-option v-for="x in metricOptions" :key="x.value" :label="x.label" :value="x.value" />
</el-select>
<el-select v-model="rule.condition" size="small" @change="sync" style="width:105px">
<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 class="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 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>
<!-- TASK 类型:多要求子项 -->
<div v-if="rule.type === 'TASK'" class="task-section">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<span class="field-label" style="margin:0">考核要求</span>
<el-button size="small" text type="primary" @click="addRequirement(rule)">+ 添加</el-button>
</div>
<div v-for="(req, ri) in (rule.requirements || [])" :key="ri" class="req-row">
<el-input v-model="req.label" size="small" placeholder="项目名" @input="sync" style="width:90px" />
<el-select v-model="req.metric" size="small" @change="sync" filterable allow-create style="width:120px">
<el-option v-for="x in metricOptions" :key="x.value" :label="x.label" :value="x.value" />
</el-select>
<el-select v-model="req.condition" size="small" @change="sync" style="width:80px">
<el-option v-for="x in condOptions" :key="x.value" :label="x.label" :value="x.value" />
</el-select>
<el-input v-model.number="req.threshold" size="small" type="number" placeholder="阈值" @input="sync" style="width:80px" />
<el-input v-model="req.unit" size="small" placeholder="单位" @input="sync" style="width:60px" />
<el-button size="small" type="danger" text @click="removeRequirement(rule, ri)" :icon="Delete" circle />
</div>
</div>
<!-- DEMOTE 类型:动态分享率配置 -->
<div v-if="rule.type === 'DEMOTE'" class="field-row">
<span class="field-label">下载量阈值</span>
<el-input v-model.number="rule.downloadThreshold" size="small" type="number" placeholder="达到此下载量后生效" @input="sync" style="width:100px" />
<span style="color:#909399;font-size:12px">GB,达到后要求分享率不低于阈值</span>
</div>
</div>
</div>
@@ -130,15 +171,22 @@
</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-dialog v-model="showPresets" title="PT 站点核心管理规则模板" width="750px" :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">满足豁免条件的用户不受任何清理规则影响。各参数值可在导入后按需调整。</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 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>
<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:levelColor(p.level),fontSize:'11px',fontWeight:500}">{{ levelText(p.level) }}</span>
<span style="color:#909399;font-size:11px;flex:1">{{ presetDesc(p) }}</span>
</div>
</el-checkbox-group>
</div>
@@ -152,21 +200,23 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Plus, ArrowDown, Rank, Delete } from '@element-plus/icons-vue'
import { Plus, ArrowDown, ArrowUp, Rank, Delete } from '@element-plus/icons-vue'
import api from '../api'
// 选项配置
// ============ 选项配置 ============
const levelOptions = [
{ value: 'CRITICAL', label: '🔴 严重' },
{ value: 'WARN', label: '🟠 关注' },
{ value: 'INFO', label: '🔵 提示' },
{ 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' },
{ value: 'KILL', label: '封号/删号' },
{ value: 'DEMOTE', label: '降级' },
{ value: 'TASK', label: '考核' },
{ value: 'TRAFFIC',label: '流量' },
{ value: 'LOGIN', label: '登录' },
{ value: 'HD', label: 'H&R' },
{ value: 'RATIO', label: '分享率' },
]
const condOptions = [
{ value: 'GT', label: '大于' },
@@ -174,36 +224,102 @@ const condOptions = [
{ 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 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 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 typeLabel = (t) => typeOptions.find(x => x.value === t)?.label || t
const levelColor = (lv) => ({ CRITICAL: '#f56c6c', WARN: '#e6a23c', INFO: '#409eff' }[lv] || '#909399')
const levelText = (lv) => ({ CRITICAL: '严重', WARN: '关注', INFO: '提示' }[lv] || lv)
const applicableHint = (a) => {
const m = { '!sealed':'未封存用户', 'sealed':'已封存用户', 'zero_traffic':'上传/下载均为 0', 'new_account_30d':'从未完成种子且无流量增量' }
return a ? `— ${m[a] || ''}` : ''
}
// 状态
// ============ 预设规则模板(按 PT 管理规范 6 大类) ============
const presets = [
// 三、动态分享率
{ name:'动态分享率-降级', type:'DEMOTE', level:'WARN', metric:'ratio', threshold:0.95, unit:'倍', condition:'LT', downloadThreshold:100, applicable:'!sealed', description:'下载量达100GB后分享率低于0.95时降级' },
{ name:'动态分享率-严重', type:'DEMOTE', level:'CRITICAL', metric:'ratio', threshold:0.5, unit:'倍', condition:'LT', downloadThreshold:500, applicable:'!sealed', description:'下载量达500GB后分享率低于0.5时严重警告' },
// 四、账号存活策略
{ name:'未封存不登录清理', type:'KILL', level:'CRITICAL', metric:'days_since_last_login', threshold:150, unit:'天', condition:'GT', applicable:'!sealed', description:'未封存账号连续150天不登录删除' },
{ name:'封存账号不登录清理', type:'KILL', level:'CRITICAL', metric:'days_since_last_login', threshold:365, unit:'天', condition:'GT', applicable:'sealed', description:'封存账号连续365天不登录删除' },
{ name:'零流量账号清理', type:'KILL', level:'CRITICAL', metric:'days_since_last_login', threshold:30, unit:'天', condition:'GT', applicable:'zero_traffic', description:'零流量用户连续30天不登录删除' },
// 五、零活跃新用户清理
{ name:'零活跃不登录清理', type:'KILL', level:'CRITICAL', metric:'days_since_last_login', threshold:7, unit:'天', condition:'GT', applicable:'new_account_30d', description:'零活跃新用户连续7天不登录删除' },
{ name:'零活跃注册天数清理', type:'KILL', level:'CRITICAL', metric:'days_since_registration', threshold:30, unit:'天', condition:'GT', applicable:'new_account_30d', description:'零活跃新用户注册满30天删除' },
// 六、H&R 惩罚
{ name:'H&R 封号', type:'HD', level:'CRITICAL', metric:'hnr_count', threshold:5, unit:'次', condition:'GTE', description:'H&R违规次数达到5次即封号' },
{ name:'H&R 警告', type:'HD', level:'WARN', metric:'hnr_count', threshold:3, unit:'次', condition:'GTE', description:'H&R违规次数达到3次告警' },
// 补充:流量异常
{ name:'连续无流量封号', type:'TRAFFIC', level:'CRITICAL', metric:'days_since_last_traffic', threshold:90, unit:'天', condition:'GT', description:'连续90天无上传/下载流量封号' },
{ name:'长期未登录提醒', type:'LOGIN', level:'WARN', metric:'days_since_last_login', threshold:30, unit:'天', condition:'GT', description:'超过30天未登录提醒' },
]
// 新手考核模板(TASK 类型,含多要求)
const taskPresets = [
{
name:'新手考核', type:'TASK', level:'CRITICAL', metric:'task', threshold:0, unit:'', condition:'GT',
applicable:'new_account_30d',
description:'新用户需在30天内完成考核:上传≥50GB、下载≥20GB、魔力≥10000、做种积分≥5000、平均做种≥24h',
requirements: [
{ label:'上传量', metric:'upload_gb', threshold:50, condition:'LT', unit:'GB' },
{ label:'下载量', metric:'download_gb', threshold:20, condition:'LT', unit:'GB' },
{ label:'魔力值', metric:'bonus', threshold:10000,condition:'LT', unit:'' },
{ label:'做种积分',metric:'seeding', threshold:5000, condition:'LT', unit:'' },
{ label:'平均做种',metric:'avg_seed_time', threshold:24, condition:'LT', unit:'h' },
]
},
]
const allPresets = [...presets, ...taskPresets]
const presetCategories = [
{ title:'三、动态分享率', items: presets.filter(p => p.type === 'DEMOTE') },
{ title:'四、账号存活策略', items: presets.filter(p => p.name.includes('不登录清理') || p.name.includes('账号清理')) },
{ title:'五、零活跃新用户清理', items: presets.filter(p => p.name.includes('零活跃')) },
{ title:'二、新手考核', items: taskPresets },
{ title:'六、H&R 惩罚', items: presets.filter(p => p.type === 'HD') },
{ title:'补充规则', items: presets.filter(p => !['DEMOTE','HD'].includes(p.type) && !p.name.includes('不登录') && !p.name.includes('零活跃')) },
]
const presetDesc = (p) => {
if (p.type === 'TASK' && p.requirements) {
return p.requirements.map(r => `${r.label}≥${r.threshold}${r.unit||''}`).join(',')
}
if (p.type === 'DEMOTE') return `下载≥${p.downloadThreshold}GB后分享率${condOptions.find(c=>c.value===p.condition)?.label||p.condition}${p.threshold}`
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 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 [] } },
get() { try { const arr = JSON.parse(editorJson.value); return Array.isArray(arr) ? arr : [] } catch { return [] } },
set(v) { editorJson.value = JSON.stringify(v, null, 2) }
})
@@ -213,10 +329,7 @@ const merged = computed(() => (Array.isArray(sites.value)?sites.value:[]).map(s
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 {
@@ -244,58 +357,59 @@ const editRule = async (row) => {
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
parsed.value = [...parsed.value, {
type:'KILL', name:'', level:'CRITICAL', metric:'days_since_last_login', threshold:0, unit:'天', condition:'GT',
applicable:null, description:'', requirements:null, _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 arr = [...parsed.value]; arr.splice(idx, 1); parsed.value = arr
}
const addPreset = (p) => {
const arr = [...parsed.value, {...p}]
parsed.value = arr
const onTypeChange = (rule) => {
if (rule.type === 'TASK' && !rule.requirements) rule.requirements = []
if (rule.type === 'DEMOTE' && rule.downloadThreshold == null) rule.downloadThreshold = 100
sync()
}
const addRequirement = (rule) => {
if (!rule.requirements) rule.requirements = []
rule.requirements.push({ label:'', metric:'upload_gb', threshold:0, condition:'LT', unit:'GB' })
sync()
}
const removeRequirement = (rule, ri) => {
rule.requirements.splice(ri, 1)
if (!rule.requirements.length) rule.requirements = null
sync()
}
const addPreset = (p) => { parsed.value = [...parsed.value, { ...p, _fold: false }] }
const importPresets = () => {
const items = presets.filter(p => selectedPresets.value.includes(p.name))
const arr = [...parsed.value, ...items]
parsed.value = arr
const items = allPresets.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 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 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 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()
ElMessage.success('已保存'); editType.value = ''; fetchAll()
} catch { ElMessage.error('保存失败') }
finally { saving.value = false }
}
@@ -317,74 +431,35 @@ onMounted(fetchAll)
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #fff;
transition: box-shadow .2s, border-color .2s;
transition: box-shadow .2s;
cursor: default;
}
.rule-card:hover {
box-shadow: 0 2px 8px rgba(0,0,0,.08);
}
.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;
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; }
.rule-index {
font-size: 11px;
color: #c0c4cc;
font-weight: 500;
}
.card-body { padding: 4px 12px 10px 34px; display: flex; flex-direction: column; gap: 6px; }
.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;
.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: 11px; }
.condition-text { color: #909399; font-size: 12px; }
.then-arrow { color: #409eff; font-size: 16px; font-weight: 700; margin: 0 2px; }
.task-section {
margin-top: 2px; padding: 8px 10px;
border: 1px dashed #d9ecff; border-radius: 6px; background: #f5f9ff;
}
.req-row { display: flex; align-items: center; gap: 6px; margin-top: 4px; }
</style>