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

532 lines
27 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 v-if="snapshotStatus" size="small" :type="snapshotStatus.hasPendingPublish ? 'warning' : snapshotStatus.draft.exists ? 'info' : 'success'">
{{ snapshotStatus.hasPendingPublish ? '预览版 v' + snapshotStatus.draft.version : snapshotStatus.draft.exists ? '预览版 v' + snapshotStatus.draft.version : snapshotStatus.published.exists ? '正式版 v' + snapshotStatus.published.version : '未发布' }}
</el-tag>
<el-tag v-if="hasUnsavedChanges" size="small" type="danger" effect="dark">未保存</el-tag>
<el-tag size="small" type="info">{{ parsed.length }} 条规则</el-tag>
</div>
<div style="display:flex;gap:8px">
<el-button size="small" type="primary" @click="saveRules" :loading="saving">保存</el-button>
<el-button v-if="hasUnsavedChanges" size="small" @click="resetEditor" type="warning">重置</el-button>
<el-button size="small" @click="uploadRules" :loading="uploading">上传</el-button>
<el-button v-if="snapshotStatus" size="small" @click="savePreview" :loading="savingPreview">保存预览版</el-button>
<el-button v-if="snapshotStatus?.draft.exists" size="small" type="success" @click="publishSnapshot" :loading="publishing">发布正式版</el-button>
<el-button v-if="snapshotStatus?.draft.exists" size="small" type="danger" @click="discardDraft" :loading="discardingDraft">丢弃预览版</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-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" :disabled="existsRule(p)">
<div style="font-size:12px">
<span :style="{fontWeight:600,color:existsRule(p)?'#c0c4cc':''}">{{ p.name }}</span>
<span :style="{color:existsRule(p)?'#c0c4cc':'#909399',marginLeft:'4px'}">{{ typeLabel(p.type) }}</span>
<span v-if="existsRule(p)" style="color:#c0c4cc;margin-left:4px;font-size:11px">(已添加)</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">
<div class="card-header-left">
<el-icon class="drag-handle"><Rank /></el-icon>
<span class="type-tag">{{ typeLabel(rule.type) }}</span>
<span v-if="rule.type==='TASK'" class="rule-fixed-text">考核周期 {{ rule.threshold }}天</span>
<span v-else-if="rule.type==='DRATIO'" class="rule-fixed-text">下载量分级控制({{ (rule.levels||[]).length }} 级)</span>
<span v-else-if="rule.type==='EXEMPT'" class="rule-fixed-text">永久保留 ≥ {{ rankDisplay(rule.permRank) }} / 封存豁免 ≥ {{ rankDisplay(rule.sealExemptRank) }}</span>
<span v-else class="rule-fixed-text">{{ metricLabel(rule.metric) }} {{ condLabel(rule.condition) }} {{ rule.threshold }}{{ rule.unit || '' }}</span>
<span class="then-arrow" :style="rule.type==='EXEMPT'?'color:#67c23a':rule.type==='RATIO'||rule.type==='DRATIO'?'color:#e6a23c':''">→</span>
<span :style="{fontWeight:'600',fontSize:'13px',color:rule.type==='EXEMPT'?'#67c23a':rule.type==='RATIO'||rule.type==='DRATIO'?'#e6a23c':'#f56c6c'}">{{ rule.type==='EXEMPT' ? '保号' : rule.type==='RATIO'||rule.type==='DRATIO' ? '封号(缓刑7天)' : '封号/删除账号' }}</span>
<span v-if="rule.applicable" class="applicable-tag">{{ applicableLabel(rule.applicable) }}</span>
</div>
<div class="card-header-right">
<span class="rule-index">#{{ idx + 1 }}</span>
<el-button size="small" type="danger" text @click="confirmRemove(idx)" :icon="Delete" circle />
</div>
</div>
<div class="card-body">
<!-- TASK 考核规则 -->
<template v-if="rule.type==='TASK'">
<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:100px" />
<span class="field-hint">天内完成以下要求,否则封号/删除账号</span>
</div>
<div class="req-divider">考核要求</div>
<div v-for="(r, ri) in (rule.requirements||[])" :key="ri" class="field-row">
<el-switch v-model="r.enabled" size="small" @change="sync" style="margin-right:2px" />
<span class="field-label" :style="r.enabled?{}:{color:'#c0c4cc'}">{{ r.label }}</span>
<span class="condition-text">不低于</span>
<el-input v-model.number="r.threshold" size="small" type="number" @input="sync" :disabled="!r.enabled" style="width:100px" />
<span class="field-hint">{{ r.unit }}</span>
</div>
</template>
<!-- DRATIO 动态分享率 -->
<template v-else-if="rule.type==='DRATIO'">
<div v-for="(lv, li) in (rule.levels||[])" :key="li" class="field-row">
<span class="field-label">下载量 ≥</span>
<el-input v-model.number="lv.threshold" size="small" type="number" placeholder="GB" @input="sync" style="width:90px" />
<span class="field-hint">GB 时,分享率 ≥</span>
<el-input v-model.number="lv.ratio" size="small" type="number" :step="0.1" placeholder="分享率" @input="sync" style="width:80px" />
<el-button size="small" type="danger" text :icon="Delete" @click="removeDRatioLevel(rule, li)" />
</div>
<el-button size="small" text type="primary" @click="addDRatioLevel(rule)" style="align-self:flex-start">
<el-icon style="margin-right:2px"><Plus /></el-icon>添加等级
</el-button>
</template>
<!-- EXEMPT 等级豁免 -->
<template v-else-if="rule.type==='EXEMPT'">
<div class="field-row">
<span class="field-label">永久保留等级</span>
<el-select v-model="rule.permRank" size="small" @change="sync" style="width:220px">
<el-option v-for="r in rankOptions" :key="r.value" :label="`${r.level}. ${r.value}`" :value="r.value" />
</el-select>
<span class="field-hint">该等级及以上用户永久保留,不受任何清理规则影响</span>
</div>
<div class="field-row">
<span class="field-label">封存豁免等级</span>
<el-select v-model="rule.sealExemptRank" size="small" @change="sync" style="width:220px">
<el-option v-for="r in rankOptions" :key="r.value" :label="`${r.level}. ${r.value}`" :value="r.value" />
</el-select>
<span class="field-hint">该等级及以上用户封存后不会被删除</span>
</div>
</template>
<!-- 非 TASK:普通规则 -->
<template v-else>
<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 v-if="rule.type==='RATIO'" class="field-hint">(低于此分享率则降级至Peasant,7天缓刑后封号/删除账号)</span>
<span v-else class="field-hint">{{ rule.unit || '' }}</span>
</div>
</template>
</div>
</div>
</div>
<!-- 规则摘要(固定排序) -->
<div v-if="parsed.length" style="margin-top:16px;padding:10px 12px;background:#fafafa;border-radius:4px;font-size:13px;line-height:1.8;color:#303133">
<div style="font-weight:600;margin-bottom:6px;color:#606266;font-size:12px">规则摘要</div>
<div v-for="(r, i) in sortedRules" :key="i">
<span style="font-weight:500">{{ i + 1 }}.</span> <span v-html="ruleText(r)" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ArrowDown, Rank, Delete, Refresh, Plus } from '@element-plus/icons-vue'
import api from '../api'
// ============ 选项配置 ============
const typeOptions = [
{ value: 'KILL', label: '活跃度' },
{ value: 'TRAFFIC', label: '流量' },
{ value: 'HD', label: 'H&R' },
{ value: 'RATIO', label: '分享率' },
{ value: 'TASK', label: '新人考核' },
{ value: 'DRATIO', label: '动态分享率' },
{ value: 'EXEMPT', 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 rankOptions = [
{ level: 0, value: 'Peasant', label: 'Peasant' },
{ level: 1, value: 'User', label: 'User' },
{ level: 2, value: 'Power User', label: 'Power User' },
{ level: 3, value: 'Elite User', label: 'Elite User' },
{ level: 4, value: 'Crazy User', label: 'Crazy User' },
{ level: 5, value: 'Insane User', label: 'Insane User' },
{ level: 6, value: 'Veteran User', label: 'Veteran User' },
{ level: 7, value: 'Extreme User', label: 'Extreme User' },
{ level: 8, value: 'Ultimate User', label: 'Ultimate User' },
{ level: 9, value: 'Nexus Master', label: 'Nexus Master' },
]
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: 'seeding_score', label: '做种积分' },
{ value: 'hnr_count', label: 'H&R 违规次数' },
{ value: 'avg_seed_time', label: '平均做种时间(小时)' },
]
const typeLabel = (t) => typeOptions.find(x => x.value === t)?.label || t
const metricLabel = (m) => metricOptions.find(x => x.value === m)?.label || m || ''
const condLabel = (c) => condOptions.find(x => x.value === c)?.label || c || ''
const rankDisplay = (v) => {
if (!v) return '—'
const r = rankOptions.find(x => x.value === v)
return r ? `${r.level}. ${r.value}` : v
}
const taskReqDefaults = [
{ metric:'upload_gb', label:'上传量', threshold:10, unit:'GB', condition:'GTE', enabled:true },
{ metric:'download_gb', label:'下载量', threshold:10, unit:'GB', condition:'GTE', enabled:true },
{ metric:'bonus', label:'魔力值', threshold:1000, unit:'', condition:'GTE', enabled:true },
{ metric:'seeding_score', label:'做种积分', threshold:100, unit:'', condition:'GTE', enabled:true },
{ metric:'avg_seed_time', label:'平均做种时间', threshold:24, unit:'小时', condition:'GTE', enabled:true },
]
const applicableLabel = (v) => applicableOptions.find(x => x.value === v)?.label || v
// ============ 预设规则模板 ============
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次即封号' },
// 新人考核
{ name:'新人考核', type:'TASK', threshold:30, unit:'天', description:'新注册用户需在考核周期内完成各项要求' },
// 动态分享率
{ name:'动态分享率', type:'DRATIO', levels:[{threshold:100,ratio:1.0},{threshold:500,ratio:2.0},{threshold:1000,ratio:3.0}], description:'下载量分级控制分享率' },
// 等级豁免
{ name:'等级豁免', type:'EXEMPT', permRank:'Elite User', sealExemptRank:'Power User', description:'达到等级的账号可豁免清理' },
]
// ============ 状态 ============
const loading = ref(false), saving = ref(false), uploading = ref(false), savingPreview = ref(false), publishing = ref(false), discardingDraft = ref(false)
const sites = ref([]), rulesMap = ref({})
const editType = ref(''), editorJson = ref('[]'), originalJson = ref('')
const snapshotStatus = ref(null)
const dragIdx = ref(null)
const hasUnsavedChanges = computed(() => {
if (!editType.value) return false
try { return JSON.stringify(JSON.parse(editorJson.value)) !== JSON.stringify(JSON.parse(originalJson.value || '[]')) }
catch { return false }
})
const fmt = (t) => t ? (t.substring(0,19)||'').replace('T',' ') : '-'
const parsed = computed({
get() {
try {
const arr = JSON.parse(editorJson.value)
if (!Array.isArray(arr)) return []
// migrate old DRATIO format
for (const r of arr) {
if (r.type === 'DRATIO' && !r.levels && r.threshold !== undefined) {
r.levels = [{ threshold: r.threshold || 100, ratio: r.minRatio || 1.0 }]
delete r.threshold; delete r.minRatio
}
}
return 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.siteId === editType.value)
return site ? (site.names||[]).join('/') || site.siteId || editType.value : editType.value
})
const typeOrder = ['EXEMPT', 'TASK', 'HD', 'KILL', 'RATIO', 'DRATIO', 'TRAFFIC']
const sortedRules = computed(() => {
return [...parsed.value].sort((a, b) => {
const ai = typeOrder.indexOf(a.type), bi = typeOrder.indexOf(b.type)
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
})
})
const ruleText = (r) => {
const appText = r.applicable ? `(${applicableLabel(r.applicable)})` : ''
if (r.type === 'EXEMPT') {
return `等级豁免:永久保留 ≥ ${rankDisplay(r.permRank)}、封存豁免 ≥ ${rankDisplay(r.sealExemptRank)} → <span style="color:#67c23a;font-weight:500">保号</span>`
}
if (r.type === 'TASK') {
const reqs = (r.requirements||[]).filter(x => x.enabled).map(x => `${x.label} ≥ ${x.threshold}${x.unit}`).join('、')
return `新人考核:${r.threshold||30}天内,${reqs} → <span style="color:#f56c6c;font-weight:500">封号/删除账号</span>${appText}`
}
const metric = metricLabel(r.metric)
const cond = condLabel(r.condition)
const val = `${metric}${cond}${r.threshold}${r.unit||''}`
if (r.type === 'DRATIO') {
const levels = (r.levels||[]).map(l => `下载量≥${l.threshold}GB时分享率≥${l.ratio}`).join(',')
return `动态分享率:${levels} → <span style="color:#e6a23c;font-weight:500">封号(缓刑7天)</span>${appText}`
}
if (r.type === 'RATIO') {
return `分享率:${val} → <span style="color:#e6a23c;font-weight:500">封号(缓刑7天)</span>${appText}`
}
return `${typeLabel(r.type)}:${val} → <span style="color:#f56c6c;font-weight:500">封号/删除账号</span>${appText}`
}
const merged = computed(() => (Array.isArray(sites.value)?sites.value:[]).map(s => {
const entry = rulesMap.value[s.siteId] || {}
return {
siteId: s.siteId, names: s.names, siteType: s.siteType,
ruleCount: entry.ruleCount || 0, version: entry.version || null,
updatedAt: entry.updatedAt || null, rulesJson: entry.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 } })()
const key = r.siteId
if (!key) continue
if (!map[key] || r.version > map[key].version)
map[key] = { 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.siteId
try {
const r = await api.raw.get('/admin/risk-rules/'+row.siteId)
editorJson.value = (r?.data||r)?.rules ? JSON.stringify((r.data||r).rules, null, 2) : '[]'
} catch { editorJson.value = '[]' }
originalJson.value = editorJson.value
fetchSnapshotStatus()
}
const fetchSnapshotStatus = async () => {
try { snapshotStatus.value = (await api.raw.get('/admin/sites/' + editType.value + '/snapshot-status')).data || null }
catch { snapshotStatus.value = null }
}
const savePreview = async () => {
savingPreview.value = true
try { await api.raw.post('/admin/sites/' + editType.value + '/save-preview'); ElMessage.success('预览版已保存'); fetchSnapshotStatus() }
catch { ElMessage.error('保存预览版失败') }
finally { savingPreview.value = false }
}
const publishSnapshot = async () => {
publishing.value = true
try { await api.raw.post('/admin/sites/' + editType.value + '/publish'); ElMessage.success('已发布为正式版'); fetchSnapshotStatus() }
catch { ElMessage.error('发布失败') }
finally { publishing.value = false }
}
const resetEditor = () => {
editorJson.value = originalJson.value
ElMessage.success('已重置到上次保存的状态')
}
const discardDraft = async () => {
try { await ElMessageBox.confirm('确认丢弃预览版?将回退到正式版。', '丢弃预览版', {confirmButtonText:'确认丢弃',type:'warning'}) } catch { return }
discardingDraft.value = true
try {
await api.raw.post('/admin/sites/' + editType.value + '/discard-draft')
ElMessage.success('预览版已丢弃,当前为正式版')
fetchSnapshotStatus()
} catch { ElMessage.error('丢弃预览版失败') }
finally { discardingDraft.value = false }
}
const sync = () => { editorJson.value = JSON.stringify(parsed.value, null, 2) }
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 ensureTaskReqs = (rule) => {
if (rule.type === 'TASK' && !rule.requirements) {
rule.requirements = JSON.parse(JSON.stringify(taskReqDefaults))
}
}
const addDRatioLevel = (rule) => {
if (!rule.levels) rule.levels = []
rule.levels.push({ threshold: 100, ratio: 1.0 })
sync()
}
const removeDRatioLevel = (rule, li) => {
rule.levels.splice(li, 1)
sync()
}
const ensureDRatioLevels = (rule) => {
if (rule.type === 'DRATIO') {
if (rule.levels && rule.levels.length) return
// migrate old single-value format
rule.levels = [{ threshold: rule.threshold || 100, ratio: rule.minRatio || 1.0 }]
delete rule.threshold; delete rule.minRatio
}
}
const ruleKey = (r) => `${r.type}|${r.applicable||''}`
const existsRule = (rule) => {
return parsed.value.some(r => ruleKey(r) === ruleKey(rule))
}
const addPreset = (p) => {
const r = { ...p }
ensureTaskReqs(r)
ensureDRatioLevels(r)
if (existsRule(r)) {
ElMessage.warning(`规则「${p.name}」已存在,同类型+适用条件只能添加一次`)
return
}
parsed.value = [...parsed.value, r]
}
// 拖拽排序
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 })
originalJson.value = editorJson.value
ElMessage.success('已保存')
} 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-radius: 4px;
background: #fff;
cursor: default;
}
.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; }
.type-tag { font-size: 12px; color: #303133; font-weight: 500; }
.applicable-tag { font-size: 11px; color: #909399; margin-left: 2px; }
.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; }
.condition-text { color: #909399; font-size: 12px; }
.req-divider { font-size: 12px; font-weight: 500; color: #303133; padding-top: 4px; border-top: 1px dashed #dcdfe6; }
.then-arrow { color: #f56c6c; font-size: 14px; font-weight: 700; margin: 0 2px; }
</style>