feat: implement V2 alert center and scoped alert governance

This commit is contained in:
Yvan
2026-11-05 20:08:29 +08:00
parent f8e26c5baf
commit 999c9a6315
57 changed files with 2711 additions and 216 deletions
+11
View File
@@ -11,3 +11,14 @@ export const listAlertRecords = (params, options = {}) => service({ ...options,
export const getAlertRecord = (id, options = {}) => service({ ...options, url: `/sw/alertRecord/detail/${id}`, method: 'get' })
export const processAlertRecord = (action, data) => service({ url: `/sw/alertRecord/${action}`, method: 'post', data })
export const addAlertNote = (data) => service({ url: '/sw/alertRecord/addNote', method: 'post', data })
export const ignoreAlerts = (ids) => service({ url: '/sw/alertRecord/ignore', method: 'post', data: { ids } })
export const getAlertGap = () => service({ url: '/sw/alertRule/gap', method: 'get' })
export const saveAlertGap = (data) => service({ url: '/sw/alertRule/gap', method: 'put', data })
export const listAlertExemptions = (params) => service({ url: '/sw/alertExemption/list', method: 'get', params })
export const addAlertExemption = (data) => service({ url: '/sw/alertExemption/add', method: 'post', data })
export const removeAlertExemption = (id) => service({ url: `/sw/alertExemption/remove/${id}`, method: 'put' })
export const getAlertPlatforms = () => service({ url: '/sw/alertRecord/platforms', method: 'get' })
export const alertExemptionCandidates = (keyword) => service({ url: '/sw/alertExemption/candidates', method: 'get', params: { keyword } })
+1
View File
@@ -59,6 +59,7 @@
"/src/view/sw/ChannelActivationList.vue": "SwChannelActivationList",
"/src/view/sw/ChannelDataStatistics.vue": "SwChannelDataStatistics",
"/src/view/sw/ChannelList.vue": "SwChannelList",
"/src/view/sw/ExemptChannels.vue": "SwExemptChannels",
"/src/view/sw/GameAnalytics.vue": "SwGameAnalytics",
"/src/view/sw/OtherSettings.vue": "SwOtherSettings",
"/src/view/sw/RankingAnalysis.vue": "RankingAnalysis",
+48 -13
View File
@@ -11,6 +11,8 @@
<el-form-item label="规则名称"><el-input v-model="query.ruleName" placeholder="规则名称" clearable /></el-form-item>
<el-form-item label="预警对象"><el-input v-model="query.targetPromoteName" placeholder="渠道名称" clearable /></el-form-item>
<el-form-item label="规则类型"><el-select v-model="query.ruleType" clearable placeholder="全部类型" style="width: 160px"><el-option v-for="type in types" :key="type" :label="type" :value="type" /></el-select></el-form-item>
<el-form-item label="预警内容"><el-input v-model="query.alertContent" clearable placeholder="输入预警内容" /></el-form-item>
<el-form-item label="流失状态"><el-select v-model="query.lost" clearable placeholder="全部" style="width: 130px"><el-option label="未流失" :value="false" /><el-option label="已流失" :value="true" /></el-select></el-form-item>
<el-form-item label="处理状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width: 150px"><el-option v-for="(label, value) in statuses" :key="value" :label="label" :value="Number(value)" /></el-select></el-form-item>
<el-form-item label="归属商务"><el-input v-model="query.businessName" clearable placeholder="归属商务" /></el-form-item>
<el-form-item><el-button type="primary" icon="search" native-type="submit">搜索</el-button></el-form-item>
@@ -19,16 +21,21 @@
<div class="kra-table-box">
<el-alert v-if="!available" class="mb-4" type="warning" :closable="false" title="渠道数据尚未接入,暂时无法核对预警可见范围。" />
<el-alert v-else-if="!isAdmin" class="mb-4" type="info" :closable="false" title="仅展示当前账号自身及全部下级商务渠道产生的预警;上级、平级其他商务渠道预警不可见。" />
<el-table scrollbar-always-on v-loading="loading" :data="rows" row-key="id">
<div v-if="canGovern" class="kra-btn-list"><el-button type="warning" :disabled="!selected.length || acting" @click="ignore(selected.map(row => row.id))">批量忽略({{ selected.length }})</el-button></div>
<el-table scrollbar-always-on v-loading="loading" :data="rows" row-key="id" :row-class-name="({ row }) => row.status === 6 || row.lost ? 'alert-muted' : ''" @selection-change="selected = $event">
<el-table-column v-if="canGovern" type="selection" width="46" :selectable="row => row.status !== 6" />
<el-table-column prop="platformName" label="所属平台" width="104"><template #default="{ row }"><platform-tag :code="row.sourceDb || row.platformCode" :label="row.platformName" /></template></el-table-column>
<el-table-column label="触发时间" min-width="120"><template #default="{ row }">{{ shortTime(row.createdAt) }}</template></el-table-column>
<el-table-column prop="ruleName" label="规则名称" min-width="130" show-overflow-tooltip />
<el-table-column prop="ruleType" label="规则类型" min-width="130" show-overflow-tooltip />
<el-table-column label="规则类型" min-width="130"><template #default="{ row }"><el-tag effect="plain" :style="row.warningColor ? { color: row.warningColor, borderColor: row.warningColor } : {}">{{ row.ruleTypeLabel || row.ruleType }}</el-tag></template></el-table-column>
<el-table-column prop="targetPromoteName" label="预警对象" min-width="160" show-overflow-tooltip />
<el-table-column prop="businessName" label="归属商务" min-width="130" show-overflow-tooltip />
<el-table-column label="流失状态" width="105"><template #default="{ row }">{{ row.lost == null ? '未接入' : row.lost ? '已流失' : '未流失' }}</template></el-table-column>
<el-table-column label="总注册玩家" width="120"><template #default="{ row }"><span title="预警触发时的累计快照">{{ row.totalPlayers ?? '—' }}</span></template></el-table-column>
<el-table-column label="总充值流水" width="140"><template #default="{ row }"><span title="预警触发时的累计快照">{{ row.totalPay == null ? '—' : `¥${row.totalPay}` }}</span></template></el-table-column>
<el-table-column prop="alertContent" label="预警内容" min-width="280" show-overflow-tooltip />
<el-table-column label="处理状态" width="110"><template #default="{ row }"><el-tag :type="statusTone(row.status)" effect="plain">{{ row.statusLabel || statuses[row.status] }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="115" fixed="right"><template #default="{ row }"><el-button v-if="canQuery" link type="primary" @click="open(row)">{{ actionLabel(row.status) }}</el-button></template></el-table-column>
<el-table-column label="操作" :width="canGovern ? 230 : 115" fixed="right"><template #default="{ row }"><el-button v-if="canQuery" link type="primary" @click="open(row)">{{ actionLabel(row.status) }}</el-button><el-button v-if="canGovern && row.status !== 6" link type="warning" :disabled="acting" @click="ignore([row.id])">忽略</el-button><el-button v-if="canGovern" link type="primary" :disabled="acting" @click="exempt(row)">免预警</el-button></template></el-table-column>
</el-table>
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
</div>
@@ -39,10 +46,10 @@
<script setup>
import PlatformTag from './components/PlatformTag.vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/pinia/modules/user'
import { useBtnAuth } from '@/utils/btnAuth'
import { listAlertRecords, getAlertRuleTypes } from '@/api/alert'
import { listAlertRecords, getAlertRuleTypes, ignoreAlerts, addAlertExemption, getAlertPlatforms } from '@/api/alert'
import AlertHandleDrawer from './components/AlertHandleDrawer.vue'
import { alertStatuses as statuses, alertStatusTone as statusTone, alertShortTime as shortTime } from './alert-display'
@@ -53,10 +60,14 @@ const isAdmin = computed(() => Number(user.userInfo.authorityId) === 888)
const canProcess = computed(() => isAdmin.value || !!buttons['sw:alertRecord:process'])
const canQuery = computed(() => isAdmin.value || !!buttons['sw:alertRecord:query'])
const canAudit = computed(() => Number(user.userInfo.level) === 1 && (isAdmin.value || !!buttons['sw:alertRecord:audit']))
const platformOptions = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
const platforms = ref(Object.keys(platformOptions))
const canGovern = computed(() => isAdmin.value || Number(user.userInfo.level) === 1)
const selected = ref([])
const acting = ref(false)
const platformNames = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
const platformOptions = ref({})
const platforms = ref([])
const types = ref([])
const query = reactive({ pageNum: 1, pageSize: 10, ruleName: '', targetPromoteName: '', ruleType: '', status: undefined, businessName: '' })
const query = reactive({ pageNum: 1, pageSize: 10, ruleName: '', targetPromoteName: '', ruleType: '', status: undefined, businessName: '', alertContent: '', lost: undefined })
const rows = ref([])
const total = ref(0)
const counts = ref({})
@@ -65,7 +76,7 @@ const loading = ref(false)
const drawer = ref(false)
const currentID = ref('')
let requestVersion = 0
const clear = () => { rows.value = []; total.value = 0; counts.value = {} }
const clear = () => { rows.value = []; total.value = 0; counts.value = {}; selected.value = [] }
const load = async () => {
const version = ++requestVersion
if (!platforms.value.length) { clear(); available.value = true; loading.value = false; return }
@@ -77,6 +88,7 @@ const load = async () => {
rows.value = res.data.pageData.rows
total.value = res.data.pageData.total
counts.value = res.data.statusCount
selected.value = []
available.value = res.data.available !== false
} catch { if (version === requestVersion) clear() } finally { if (version === requestVersion) loading.value = false }
}
@@ -92,15 +104,38 @@ const actionLabel = (status) => {
return { 1: '处理', 2: '继续处理', 4: '重新处理' }[status] || '查看'
}
const open = (row) => { currentID.value = row.id; drawer.value = true }
const ignore = async (ids) => {
if (acting.value || !ids.length) return
acting.value = true
try {
if (!await ElMessageBox.confirm(`确认忽略这 ${ids.length} 条预警?历史记录会保留,再次命中规则可生成新预警。`, '忽略预警', { type: 'warning' }).then(() => true).catch(() => false)) return
const res = await ignoreAlerts(ids)
if (res.code === 0) { ElMessage.success('已忽略'); await load() }
} finally { acting.value = false }
}
const exempt = async (row) => {
if (acting.value) return
acting.value = true
try {
const result = await ElMessageBox.prompt(`将「${row.targetPromoteName}」加入免预警名单后,该渠道不再产生新预警,历史预警保留。请填写加入原因。`, '加入免预警渠道', { inputValidator: value => !!value?.trim() && value.length <= 500 || '请填写 1 至 500 字的原因', confirmButtonText: '确认加入' }).catch(() => null)
if (!result) return
const res = await addAlertExemption({ sourceDb: row.platformCode, promoteId: String(row.targetPromoteId), reason: result.value.trim() })
if (res.code === 0) ElMessage.success('已加入免预警渠道')
} finally { acting.value = false }
}
onMounted(async () => {
void load()
const res = await getAlertRuleTypes()
if (res.code === 0) types.value = res.data
const results = await Promise.allSettled([getAlertPlatforms(), getAlertRuleTypes()])
const bound = results[0].status === 'fulfilled' ? results[0].value : null
if (bound?.code === 0) { platforms.value = bound.data; platformOptions.value = Object.fromEntries(bound.data.map(code => [code, platformNames[code] || code])) }
const labels = results[1].status === 'fulfilled' ? results[1].value : null
if (labels?.code === 0) types.value = labels.data
await load()
})
</script>
<style scoped>
.alert-summary-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 16px; margin-bottom: 16px; }
:deep(.alert-muted) { color: var(--el-text-color-placeholder); background: var(--el-fill-color-light); }
.alert-summary-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 16px; margin-bottom: 16px; }
.alert-summary-card { display: flex; flex-direction: column; gap: 12px; text-align: left; padding: 20px; background: var(--el-bg-color); color: var(--el-text-color-regular); border: 1px solid var(--el-border-color-light); border-radius: 8px; cursor: pointer; }
.alert-summary-card strong { font-size: 26px; color: var(--el-text-color-primary); }
.alert-summary-card.active, .alert-summary-card:focus-visible { border-color: var(--el-color-primary); outline: 1px solid var(--el-color-primary); }
+55 -26
View File
@@ -4,39 +4,47 @@
<el-form :inline="true" :model="query" @submit.prevent="search">
<el-form-item label="监控指标"><el-select v-model="query.monitorMetric" clearable placeholder="全部监控指标" style="width: 160px"><el-option v-for="(label, value) in metrics" :key="value" :value="Number(value)" :label="label" /></el-select></el-form-item>
<el-form-item label="规则名称"><el-input v-model="query.ruleName" clearable placeholder="输入规则名称" /></el-form-item>
<el-form-item><el-button type="primary" icon="search" native-type="submit">搜索</el-button><el-button icon="refresh" @click="reset">重置</el-button></el-form-item>
<el-form-item><el-button type="primary" icon="search" native-type="submit">搜索</el-button><el-button icon="refresh" @click="reset">重置</el-button><el-button @click="openGap">预警间隔:{{ gapLoaded ? gap.enabled ? `${gap.days} 天` : '未启用' : '未加载' }}</el-button><el-button v-if="canCreate" type="primary" icon="plus" @click="open()">新建规则</el-button></el-form-item>
</el-form>
</div>
<div class="kra-table-box">
<div class="kra-btn-list"><el-button v-if="canCreate" type="primary" icon="plus" @click="open()">新建规则</el-button></div>
<el-alert class="mb-4" type="info" :closable="false" title="同一平台、同一渠道、同类预警未关闭时不重复新建。未处理预警再次命中会刷新;处理中、待审核和审核驳回的记录保持不变。" />
<el-alert v-if="!manage" class="mb-4" type="info" :closable="false" title="只读模式:仅一级商务可配置预警规则。" />
<el-alert class="mb-4" type="info" :closable="false" :title="`未处理预警再次命中会刷新;处理中、待审核和审核驳回保持不变。绝对值规则分别生成预警。${gap.enabled ? `同一渠道同类预警间隔 ${gap.days} 天。` : '预警间隔未启用。'}`" />
<el-table scrollbar-always-on v-loading="loading" :data="rows" row-key="id">
<el-table-column label="启用状态" width="110"><template #default="{ row }"><el-switch :model-value="row.enabled" :active-value="1" :inactive-value="0" :disabled="!canEdit || busy === row.id" @change="toggle(row, $event)" /></template></el-table-column>
<el-table-column label="启用状态" width="110"><template #default="{ row }"><el-switch :model-value="row.enabled" :active-value="1" :inactive-value="0" :disabled="!canEdit || busy.has(row.id)" @change="toggle(row, $event)" /></template></el-table-column>
<el-table-column prop="ruleName" label="规则名称" min-width="150" />
<el-table-column label="规则类型" min-width="130"><template #default="{ row }"><el-tag effect="plain" :style="{ color: row.warningColor, borderColor: row.warningColor }">{{ row.ruleTypeLabel }}</el-tag></template></el-table-column>
<el-table-column prop="ruleDescription" label="规则描述" min-width="320" show-overflow-tooltip />
<el-table-column prop="creatorName" label="创建人" min-width="120" />
<el-table-column label="作用范围" min-width="150"><template #default>仅对一级渠道生效</template></el-table-column>
<el-table-column label="作用范围" min-width="150"><template #default>一级渠道(汇总子渠道)</template></el-table-column>
<el-table-column v-if="canEdit || canDelete" label="操作" width="130" fixed="right"><template #default="{ row }"><el-button v-if="canEdit" type="primary" link @click="open(row)">编辑</el-button><el-button v-if="canDelete" type="danger" link :loading="removing === row.id" :disabled="!!removing" @click="remove(row)">删除</el-button></template></el-table-column>
</el-table>
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
</div>
<el-drawer class="sw-dialog" v-model="drawer" append-to-body :title="form.id ? '编辑规则' : '新建规则'" size="480px" destroy-on-close>
<el-drawer class="sw-dialog" v-model="drawer" append-to-body :title="form.id ? '编辑规则' : '新建规则'" size="min(520px, 100vw)" :close-on-click-modal="!saving" :close-on-press-escape="!saving" :show-close="!saving" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="规则名称" prop="ruleName"><el-input v-model="form.ruleName" maxlength="50" placeholder="请输入规则名称" /></el-form-item>
<el-form-item label="规则类型" prop="ruleType"><el-input v-model="form.ruleType" maxlength="20" placeholder="如:注册异常 / 充值异常 / 登录异常" /></el-form-item>
<el-form-item label="规则名称" prop="ruleName"><el-input v-model="form.ruleName" maxlength="100" placeholder="请输入规则名称" /></el-form-item>
<el-form-item label="规则类型" prop="ruleType"><el-input v-model="form.ruleType" maxlength="50" placeholder="如:注册异常 / 充值异常 / 登录异常" /></el-form-item>
<el-form-item label="监控指标" prop="monitorMetric"><el-select v-model="form.monitorMetric"><el-option v-for="(label, value) in metrics" :key="value" :value="Number(value)" :label="label" /></el-select></el-form-item>
<el-form-item v-if="form.monitorMetric === 2" label="判定方式"><el-select v-model="form.conditionMode"><el-option label="环比波动(百分比)" :value="1" /><el-option label="绝对值阈值(满 / 未满 X 元)" :value="2" /></el-select></el-form-item>
<template v-if="form.monitorMetric !== 3">
<el-form-item label="统计周期(天)" prop="statPeriodDays"><el-input-number v-model="form.statPeriodDays" :min="1" :max="365" :precision="0" /></el-form-item>
<el-form-item label="环比方向" prop="momDirection"><el-select v-model="form.momDirection"><el-option label="环比上升 (异常暴涨)" :value="1" /><el-option label="环比下降 (异常恶化)" :value="2" /></el-select></el-form-item>
<el-form-item label="环比阈值 (%)" prop="momThreshold"><el-input-number v-model="form.momThreshold" :min="1" :max="9999" :precision="2" /></el-form-item>
<el-form-item label="统计周期(天)" prop="statPeriodDays"><el-input-number v-model="form.statPeriodDays" :min="1" :precision="0" /></el-form-item>
<el-form-item v-if="!absolute" label="环比方向" prop="momDirection"><el-select v-model="form.momDirection"><el-option label="环比上升 (异常暴涨)" :value="1" /><el-option label="环比下降 (异常恶化)" :value="2" /></el-select></el-form-item>
<el-form-item v-if="!absolute" label="环比阈值 (%)" prop="momThreshold"><el-input-number v-model="form.momThreshold" :min="0.01" :max="9999" :precision="2" /></el-form-item>
<el-form-item v-if="absolute" label="金额条件"><el-select v-model="form.amountDirection"><el-option label="满(大于等于)" :value="1" /><el-option label="未满(小于)" :value="2" /></el-select></el-form-item>
<el-form-item v-if="absolute" label="金额(元)" prop="amountThreshold"><el-input-number v-model="form.amountThreshold" :min="0.01" :max="999999999999.99" :precision="2" /></el-form-item>
</template>
<el-form-item v-else label="未登录天数" prop="unloginDays"><el-input-number v-model="form.unloginDays" :min="1" :max="365" :precision="0" /></el-form-item>
<el-form-item v-else label="未登录天数" prop="unloginDays"><el-input-number v-model="form.unloginDays" :min="1" :precision="0" /></el-form-item>
<el-form-item label="预警颜色" prop="warningColor"><el-color-picker v-model="form.warningColor" :predefine="colors" color-format="hex" /></el-form-item>
<el-text type="info">作用范围固定为一级渠道。</el-text>
<el-text type="info">仅向一级渠道预警,监测数据汇总其二、三级子渠道。</el-text>
</el-form>
<template #footer><el-button @click="drawer = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
<template #footer><el-button v-if="form.id && canDelete" type="danger" :disabled="saving" @click="remove(form)">删除规则</el-button><el-button :disabled="saving" @click="drawer = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
</el-drawer>
<el-dialog v-model="gapDialog" title="预警间隔" width="min(460px, 95vw)" :close-on-click-modal="!gapSaving">
<el-form label-position="top"><el-form-item label="启用预警间隔"><el-switch v-model="gapForm.enabled" :disabled="!canEdit || gapSaving" /></el-form-item><el-form-item label="间隔天数"><el-input-number :key="String(gapForm.enabled)" v-model="gapForm.days" :min="1" :precision="0" :disabled="!canEdit || !gapForm.enabled || gapSaving" /></el-form-item></el-form>
<p>统一作用于当前一级商务的全部规则。从上次命中起计时,即使预警已闭环,间隔内也不再生成;到期后仍满足规则才会再次预警。</p>
<template #footer><el-button :disabled="gapSaving" @click="gapDialog = false">取消</el-button><el-button v-if="canEdit" type="primary" :loading="gapSaving" @click="saveGap">保存</el-button></template>
</el-dialog>
</div>
</template>
@@ -45,7 +53,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/pinia/modules/user'
import { useBtnAuth } from '@/utils/btnAuth'
import { listAlertRules, createAlertRule, updateAlertRule, deleteAlertRule, toggleAlertRule } from '@/api/alert'
import { listAlertRules, createAlertRule, updateAlertRule, deleteAlertRule, toggleAlertRule, getAlertGap, saveAlertGap } from '@/api/alert'
defineOptions({ name: 'SwAlertRules' })
const user = useUserStore()
@@ -63,39 +71,60 @@ const total = ref(0)
const loading = ref(false)
const saving = ref(false)
const drawer = ref(false)
const busy = ref(0)
const busy = reactive(new Set())
const removing = ref(0)
const formRef = ref()
const form = ref({})
const rules = Object.fromEntries(['ruleName', 'ruleType', 'monitorMetric', 'statPeriodDays', 'momDirection', 'momThreshold', 'unloginDays', 'warningColor'].map((name) => [name, [{ required: true, message: '请填写完整规则信息', trigger: 'change' }]]))
const absolute = computed(() => form.value.monitorMetric === 2 && form.value.conditionMode === 2)
const gap = ref({ enabled: false, days: 1 })
const gapLoaded = ref(false)
const loadGap = async () => {
try { const res = await getAlertGap(); if (res.code !== 0) return false; gap.value = res.data; gapLoaded.value = true; return true } catch { return false }
}
const gapForm = ref({})
const gapDialog = ref(false)
const gapSaving = ref(false)
const openGap = async () => { if (!await loadGap()) return; gapForm.value = { ...gap.value }; gapDialog.value = true }
const saveGap = async () => {
if (gapSaving.value || !Number.isInteger(gapForm.value.days) || gapForm.value.days < 1) return
gapSaving.value = true
try { const res = await saveAlertGap(gapForm.value); if (res.code === 0) { gap.value = { ...gapForm.value }; gapDialog.value = false; ElMessage.success('预警间隔已保存') } } finally { gapSaving.value = false }
}
const rules = Object.fromEntries(['ruleName', 'ruleType', 'monitorMetric', 'statPeriodDays', 'momDirection', 'momThreshold', 'unloginDays', 'warningColor', 'amountThreshold'].map((name) => [name, [{ required: true, message: '请填写完整规则信息', trigger: 'change' }]]))
let requestVersion = 0
const load = async () => {
const version = ++requestVersion
loading.value = true
try { const res = await listAlertRules(query); if (res.code === 0) { rows.value = res.data.rows; total.value = res.data.total } } finally { loading.value = false }
try { const res = await listAlertRules(query); if (version === requestVersion) { rows.value = res.code === 0 ? res.data.rows : []; total.value = res.code === 0 ? res.data.total : 0 } } catch { if (version === requestVersion) { rows.value = []; total.value = 0 } } finally { if (version === requestVersion) loading.value = false }
}
const search = () => { query.pageNum = 1; return load() }
const reset = () => { query.monitorMetric = undefined; query.ruleName = ''; return search() }
const open = (row) => {
form.value = { ruleName: '', ruleType: '', monitorMetric: 1, statPeriodDays: 7, momDirection: 1, momThreshold: 20, unloginDays: 7, warningColor: colors[0], ...row }
form.value = { conditionMode: 1, amountDirection: 2, amountThreshold: 3000, ruleName: '', ruleType: '', monitorMetric: 1, statPeriodDays: 7, momDirection: 1, momThreshold: 20, unloginDays: 7, warningColor: colors[0], ...row }
if (form.value.momThreshold != null) form.value.momThreshold = Number(form.value.momThreshold)
if (form.value.amountThreshold != null) form.value.amountThreshold = Number(form.value.amountThreshold)
drawer.value = true
}
const save = async () => {
if (!await formRef.value.validate().catch(() => false)) return
if (saving.value) return
saving.value = true
try { const res = await (form.value.id ? updateAlertRule : createAlertRule)(form.value); if (res.code === 0) { ElMessage.success('保存成功'); drawer.value = false; await load() } } finally { saving.value = false }
try {
if (!await formRef.value.validate().catch(() => false)) return
const res = await (form.value.id ? updateAlertRule : createAlertRule)({ ...form.value, ruleName: form.value.ruleName.trim(), ruleType: form.value.ruleType.trim(), conditionMode: absolute.value ? 2 : 1 }); if (res.code === 0) { ElMessage.success('保存成功'); drawer.value = false; await load() } } finally { saving.value = false }
}
const toggle = async (row, enabled) => {
busy.value = row.id
try { const res = await toggleAlertRule(row.id, enabled); if (res.code === 0) row.enabled = enabled } finally { busy.value = 0 }
if (busy.has(row.id)) return
busy.add(row.id)
try { const res = await toggleAlertRule(row.id, enabled); if (res.code === 0) row.enabled = enabled } finally { busy.delete(row.id) }
}
const remove = async (row) => {
if (removing.value) return
if (!await ElMessageBox.confirm(`确认删除规则「${row.ruleName}」?`, '删除确认', { type: 'warning' }).then(() => true).catch(() => false)) return
removing.value = row.id
try {
if (!await ElMessageBox.confirm(`确认删除规则「${row.ruleName}」?`, '删除确认', { type: 'warning' }).then(() => true).catch(() => false)) return
const res = await deleteAlertRule(row.id)
if (res.code === 0) { ElMessage.success('删除成功'); await load() }
if (res.code === 0) { ElMessage.success('删除成功'); drawer.value = false; if (rows.value.length === 1 && query.pageNum > 1) query.pageNum--; await load() }
} finally { removing.value = 0 }
}
onMounted(load)
onMounted(() => { void load(); void loadGap() })
</script>
+100
View File
@@ -0,0 +1,100 @@
<template>
<div class="sw-page">
<div class="kra-search-box">
<el-form :inline="true" :model="query" @submit.prevent="search">
<el-form-item label="渠道名称"><el-input v-model="query.channelName" clearable placeholder="输入渠道名称" /></el-form-item>
<el-form-item label="状态"><el-select v-model="query.active" clearable placeholder="全部状态" style="width: 150px"><el-option label="在名单" :value="true" /><el-option label="已移出" :value="false" /></el-select></el-form-item>
<el-form-item><el-button type="primary" native-type="submit">搜索</el-button><el-button @click="reset">重置</el-button><el-button v-if="canManage" type="primary" @click="openAdd">加入渠道</el-button></el-form-item>
</el-form>
</div>
<div class="kra-table-box">
<el-alert class="mb-4" type="info" :closable="false" title="名单内的渠道不再产生新预警,历史预警保留。渠道进入激活池后自动移出,加入与移出记录永久保留。" />
<el-table v-loading="loading" :data="rows" row-key="id" scrollbar-always-on :row-class-name="({ row }) => row.active ? '' : 'exempt-removed'">
<el-table-column label="平台" width="95"><template #default="{ row }"><platform-tag :code="row.sourceDb" /></template></el-table-column>
<el-table-column prop="channelName" label="渠道名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="addedName" label="加入人" min-width="110" />
<el-table-column prop="addedAt" label="加入时间" width="170" />
<el-table-column prop="reason" label="加入原因" min-width="210" show-overflow-tooltip />
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.active ? 'success' : 'info'">{{ row.active ? '在名单' : '已移出' }}</el-tag></template></el-table-column>
<el-table-column prop="removedName" label="移出人" min-width="140" />
<el-table-column prop="removedAt" label="移出时间" width="170" />
<el-table-column prop="removeReason" label="移出原因" min-width="190"><template #default="{ row }"><span :title="row.removeReason.includes('自动') ? '30 天累计流水不足 ¥500,或连续未登录超过 15 天。系统自动判定,恢复预警。' : ''">{{ row.removeReason || '—' }}</span></template></el-table-column>
<el-table-column label="操作" fixed="right" width="80"><template #default="{ row }"><el-button v-if="row.active && canManage" link type="danger" :disabled="busy" @click="remove(row)">移出</el-button><span v-else>—</span></template></el-table-column>
</el-table>
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
</div>
<el-dialog v-model="adding" title="加入免预警渠道" width="min(500px, 95vw)" :close-on-click-modal="!busy" :close-on-press-escape="!busy" :show-close="!busy">
<el-form label-position="top">
<el-form-item label="一级渠道" required><el-select v-model="selected" filterable remote :remote-method="findChannels" :loading="finding" placeholder="输入渠道名称搜索" style="width: 100%"><el-option v-for="channel in channels" :key="`${channel.sourceDb}/${channel.id}`" :value="`${channel.sourceDb}/${channel.id}`" :label="`${platforms[channel.sourceDb] || channel.sourceDb} · ${channel.account}`" /></el-select></el-form-item>
<el-form-item label="加入原因" required><el-input v-model="reason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item>
</el-form>
<template #footer><el-button :disabled="busy" @click="adding = false">取消</el-button><el-button type="primary" :loading="busy" @click="add">加入名单</el-button></template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/pinia/modules/user'
import { listAlertExemptions, addAlertExemption, removeAlertExemption, alertExemptionCandidates } from '@/api/alert'
import PlatformTag from './components/PlatformTag.vue'
defineOptions({ name: 'SwExemptChannels' })
const user = useUserStore()
const canManage = computed(() => Number(user.userInfo.authorityId) === 888 || Number(user.userInfo.level) === 1)
const query = reactive({ pageNum: 1, pageSize: 10, channelName: '', active: undefined })
const rows = ref([])
const total = ref(0)
const loading = ref(false)
const busy = ref(false)
const adding = ref(false)
const selected = ref('')
const reason = ref('')
const channels = ref([])
const finding = ref(false)
const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
let version = 0
let channelVersion = 0
const load = async () => {
const current = ++version
loading.value = true
try {
const res = await listAlertExemptions({ ...query, channelName: query.channelName.trim() })
if (current === version) { rows.value = res.code === 0 ? res.data.rows : []; total.value = res.code === 0 ? res.data.total : 0 }
} catch { if (current === version) { rows.value = []; total.value = 0 } } finally { if (current === version) loading.value = false }
}
const search = () => { query.pageNum = 1; return load() }
const reset = () => { query.channelName = ''; query.active = undefined; return search() }
const findChannels = async (keyword) => {
const current = ++channelVersion
finding.value = true
try { const res = await alertExemptionCandidates(keyword.trim()); if (current === channelVersion) channels.value = res.code === 0 ? res.data : [] } catch { if (current === channelVersion) channels.value = [] } finally { if (current === channelVersion) finding.value = false }
}
const openAdd = () => { selected.value = ''; reason.value = ''; adding.value = true; void findChannels('') }
const add = async () => {
if (busy.value) return
if (!selected.value || !reason.value.trim()) { ElMessage.warning('请选择渠道并填写加入原因'); return }
busy.value = true
try {
if (!await ElMessageBox.confirm('确认加入免预警名单?该渠道将停止产生新预警,历史预警保留。', '加入确认', { type: 'warning' }).then(() => true).catch(() => false)) return
const [sourceDb, promoteId] = selected.value.split('/')
const res = await addAlertExemption({ sourceDb, promoteId, reason: reason.value.trim() })
if (res.code === 0) { adding.value = false; ElMessage.success('已加入免预警名单'); await search() }
} finally { busy.value = false }
}
const remove = async (row) => {
if (busy.value) return
busy.value = true
try {
if (!await ElMessageBox.confirm(`确认将「${row.channelName}」移出免预警名单?移出后恢复预警。`, '移出确认', { type: 'warning' }).then(() => true).catch(() => false)) return
const res = await removeAlertExemption(row.id)
if (res.code === 0) { ElMessage.success('已移出'); if (query.active === true && rows.value.length === 1 && query.pageNum > 1) query.pageNum--; await load() }
} finally { busy.value = false }
}
onMounted(load)
</script>
<style scoped>
:deep(.exempt-removed) { color: var(--el-text-color-placeholder); background: var(--el-fill-color-light); }
</style>
+2 -2
View File
@@ -1,4 +1,4 @@
export const alertStatuses = { 1: '未处理', 2: '处理中', 3: '待审核', 4: '审核驳回', 5: '已处理' }
export const alertStatuses = { 1: '未处理', 2: '处理中', 3: '待审核', 4: '审核驳回', 5: '已处理', 6: '已忽略' }
export const alertStatusTone = (status) => ({ 1: 'danger', 2: 'warning', 3: 'info', 4: 'danger', 5: 'success' })[status] || 'info'
export const alertShortTime = (value) => value ? value.replace('T', ' ').slice(5, 16) : '—'
export const alertActions = { start: '开始处理', submit: '提交审核', pass: '审核通过', reject: '审核驳回', reprocess: '重新处理' }
export const alertActions = { start: '开始处理', submit: '提交审核', pass: '审核通过', reject: '审核驳回', reprocess: '重新处理', ignore: '忽略' }
@@ -71,7 +71,7 @@ const current = ref(null)
const logs = ref([])
const detailCanAudit = ref(false)
const auditAllowed = computed(() => props.canAudit && detailCanAudit.value)
const canNote = computed(() => props.canProcess && current.value && ([1, 2, 4].includes(current.value.status) || (current.value.status === 3 && auditAllowed.value)))
const canNote = computed(() => props.canProcess && current.value && [1, 2, 3, 4].includes(current.value.status))
const loading = ref(false)
const savingNote = ref(false)
const processing = ref(false)