- DRATIO/RATIO 编辑区域增加缓刑天数输入框 - 规则摘要和卡片头部的缓刑天数文案改为动态读取 graceDays 字段 - DRATIO 预设模板增加 graceDays: 7 默认值 - 不填时默认7天,向后兼容
556 lines
28 KiB
Vue
556 lines
28 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 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">无流量且未登录 > {{ 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 时,分享率 <</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>
|