Files
par/web/src/views/RiskRules.vue
mediabot-pt 8b004e5c45 feat: 风险规则缓刑天数可配置
- DRATIO/RATIO 编辑区域增加缓刑天数输入框
- 规则摘要和卡片头部的缓刑天数文案改为动态读取 graceDays 字段
- DRATIO 预设模板增加 graceDays: 7 默认值
- 不填时默认7天,向后兼容
2026-07-12 23:18:55 +08:00

556 lines
28 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 v-if="snapshotStatus" size="small" type="primary" @click="savePreview" :loading="savingPreview">保存预览版</el-button>
<el-button v-if="hasUnsavedChanges" size="small" @click="resetEditor" type="warning">重置</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==='TRAFFIC'" class="rule-fixed-text">无流量且未登录 &gt; {{ 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' ? '封号(缓刑' + (rule.graceDays || 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 时,分享率 &lt;</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>
<!-- 缓刑天数 -->
<div class="field-row" style="margin-top:4px">
<span class="field-label">缓刑天数</span>
<el-input v-model.number="rule.graceDays" size="small" type="number" placeholder="天数" @input="sync" style="width:100px" />
<span class="field-hint">天(不填则默认7天)</span>
</div>
</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>
<!-- TRAFFIC 连续无流量+未登录 -->
<template v-else-if="rule.type==='TRAFFIC'">
<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>
</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,缓刑后封号/删除账号)</span>
<span v-else class="field-hint">{{ rule.unit || '' }}</span>
</div>
<!-- RATIO 缓刑天数 -->
<div v-if="rule.type==='RATIO'" class="field-row" style="margin-top:4px">
<span class="field-label">缓刑天数</span>
<el-input v-model.number="rule.graceDays" size="small" type="number" placeholder="天数" @input="sync" style="width:100px" />
<span class="field-hint">天(不填则默认7天)</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:'LT', enabled:true },
{ metric:'download_gb', label:'下载量', threshold:10, unit:'GB', condition:'LT', enabled:true },
{ metric:'bonus', label:'魔力值', threshold:1000, unit:'', condition:'LT', enabled:true },
{ metric:'seeding_score', label:'做种积分', threshold:100, unit:'', condition:'LT', enabled:true },
{ metric:'avg_seed_time', label:'平均做种时间', threshold:24, unit:'小时', condition:'LT', 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', graceDays: 7, levels:[{threshold:0,ratio:1.0}], description:'下载量>0时,分享率<1.0即封号(缓刑7天)' },
// 等级豁免
{ name:'等级豁免', type:'EXEMPT', permRank:'Elite User', sealExemptRank:'Power User', description:'达到等级的账号可豁免清理' },
]
// ============ 状态 ============
const loading = ref(false), saving = 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 === 'TRAFFIC') {
return `无流量且未登录:${val} → <span style="color:#f56c6c;font-weight:500">封号/删除账号</span>${appText}`
}
if (r.type === 'DRATIO') {
const grace = r.graceDays != null ? `(缓刑${r.graceDays}天)` : '(缓刑7天)'
const levels = (r.levels||[]).map(l => `下载量≥${l.threshold}GB时分享率<${l.ratio}`).join(',')
return `分享率:${levels} → <span style="color:#e6a23c;font-weight:500">封号${grace}</span>${appText}`
}
if (r.type === 'RATIO') {
const grace = r.graceDays != null ? `(缓刑${r.graceDays}天)` : '(缓刑7天)'
return `分享率:${val} → <span style="color:#e6a23c;font-weight:500">封号${grace}</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('/risk-rules/' + editType.value, { rules: parsed.value })
originalJson.value = editorJson.value
// 再拍快照
const r = await api.raw.post('/admin/sites/' + editType.value + '/save-preview')
if (r.code !== 200) { ElMessage.error(r.message || '保存预览版失败'); return }
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 }
}
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>