284 行
9.2 KiB
Vue
284 行
9.2 KiB
Vue
|
|
<template>
|
|||
|
|
<div>
|
|||
|
|
<h2 style="margin-bottom:12px">风控配置</h2>
|
|||
|
|
<p style="margin:0 0 24px;color:#606266">
|
|||
|
|
管理敏感词库与全局风控规则,保障平台安全运行。
|
|||
|
|
</p>
|
|||
|
|
|
|||
|
|
<!-- 全局风控规则 -->
|
|||
|
|
<el-card style="margin-bottom: 16px">
|
|||
|
|
<template #header>
|
|||
|
|
<span>全局风控规则</span>
|
|||
|
|
</template>
|
|||
|
|
<el-form :model="ruleForm" label-width="160px" :label-position="isMobile ? 'top' : 'right'">
|
|||
|
|
<el-form-item label="IP限流阈值(次/分)">
|
|||
|
|
<el-input-number v-model="ruleForm.ipRateLimit" :min="10" :max="10000" />
|
|||
|
|
</el-form-item>
|
|||
|
|
<el-form-item label="登录失败次数阈值">
|
|||
|
|
<el-input-number v-model="ruleForm.loginFailThreshold" :min="3" :max="20" />
|
|||
|
|
</el-form-item>
|
|||
|
|
<el-form-item label="登录失败锁定时长(分)">
|
|||
|
|
<el-input-number v-model="ruleForm.loginLockMinutes" :min="5" :max="1440" />
|
|||
|
|
</el-form-item>
|
|||
|
|
<el-form-item label="账号异常检测">
|
|||
|
|
<el-switch v-model="ruleForm.abnormalDetection" active-text="开启" inactive-text="关闭" />
|
|||
|
|
</el-form-item>
|
|||
|
|
<el-form-item>
|
|||
|
|
<el-button type="primary" @click="saveRules" :loading="ruleLoading">保存配置</el-button>
|
|||
|
|
</el-form-item>
|
|||
|
|
</el-form>
|
|||
|
|
</el-card>
|
|||
|
|
|
|||
|
|
<!-- 敏感词库 -->
|
|||
|
|
<el-card>
|
|||
|
|
<template #header>
|
|||
|
|
<div class="card-header">
|
|||
|
|
<span>敏感词库</span>
|
|||
|
|
<el-button type="primary" @click="openDialog()">新增敏感词</el-button>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<el-table :data="wordList" v-loading="tableLoading" border stripe>
|
|||
|
|
<el-table-column prop="word" label="敏感词" min-width="160" />
|
|||
|
|
<el-table-column prop="level" label="风险等级" width="120">
|
|||
|
|
<template #default="{ row }">
|
|||
|
|
<el-tag :type="levelTagType(row.level)">{{ row.level }}</el-tag>
|
|||
|
|
</template>
|
|||
|
|
</el-table-column>
|
|||
|
|
<el-table-column prop="category" label="分类" width="140" />
|
|||
|
|
<el-table-column prop="enabled" label="状态" width="100">
|
|||
|
|
<template #default="{ row }">
|
|||
|
|
<el-switch v-model="row.enabled" @change="toggleWord(row)" />
|
|||
|
|
</template>
|
|||
|
|
</el-table-column>
|
|||
|
|
<el-table-column prop="updatedAt" label="更新时间" width="180">
|
|||
|
|
<template #default="{ row }">{{ fmt(row.updatedAt) }}</template>
|
|||
|
|
</el-table-column>
|
|||
|
|
<el-table-column label="操作" width="140" fixed="right">
|
|||
|
|
<template #default="{ row }">
|
|||
|
|
<el-button link type="primary" @click="openDialog(row)">编辑</el-button>
|
|||
|
|
<el-button link type="danger" @click="deleteWord(row)">删除</el-button>
|
|||
|
|
</template>
|
|||
|
|
</el-table-column>
|
|||
|
|
</el-table>
|
|||
|
|
|
|||
|
|
<div class="pagination-wrap">
|
|||
|
|
<el-pagination
|
|||
|
|
v-model:current-page="page"
|
|||
|
|
v-model:page-size="size"
|
|||
|
|
:page-sizes="[10, 20, 50]"
|
|||
|
|
:total="total"
|
|||
|
|
layout="total, sizes, prev, pager, next"
|
|||
|
|
@size-change="loadWords"
|
|||
|
|
@current-change="loadWords"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</el-card>
|
|||
|
|
|
|||
|
|
<!-- 新增/编辑弹窗 -->
|
|||
|
|
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="520px" destroy-on-close>
|
|||
|
|
<el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
|
|||
|
|
<el-form-item label="敏感词" prop="word">
|
|||
|
|
<el-input v-model="form.word" placeholder="请输入敏感词" />
|
|||
|
|
</el-form-item>
|
|||
|
|
<el-form-item label="风险等级" prop="level">
|
|||
|
|
<el-select v-model="form.level" placeholder="请选择风险等级" style="width:100%">
|
|||
|
|
<el-option label="低" value="低" />
|
|||
|
|
<el-option label="中" value="中" />
|
|||
|
|
<el-option label="高" value="高" />
|
|||
|
|
</el-select>
|
|||
|
|
</el-form-item>
|
|||
|
|
<el-form-item label="分类" prop="category">
|
|||
|
|
<el-input v-model="form.category" placeholder="如:政治、色情、广告" />
|
|||
|
|
</el-form-item>
|
|||
|
|
<el-form-item label="启用状态">
|
|||
|
|
<el-switch v-model="form.enabled" />
|
|||
|
|
</el-form-item>
|
|||
|
|
</el-form>
|
|||
|
|
<template #footer>
|
|||
|
|
<el-button @click="dialogVisible = false">取消</el-button>
|
|||
|
|
<el-button type="primary" @click="submitForm" :loading="submitLoading">确定</el-button>
|
|||
|
|
</template>
|
|||
|
|
</el-dialog>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { ref, reactive, computed, onMounted } from 'vue'
|
|||
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|||
|
|
import type { FormInstance, FormRules } from 'element-plus'
|
|||
|
|
import { opsApi, type RiskRuleForm, type SensitiveWord } from '@/api/ops'
|
|||
|
|
|
|||
|
|
const isMobile = ref(window.innerWidth < 768)
|
|||
|
|
const updateViewport = () => { isMobile.value = window.innerWidth < 768 }
|
|||
|
|
onMounted(() => { window.addEventListener('resize', updateViewport) })
|
|||
|
|
|
|||
|
|
/* ---------- 全局规则 ---------- */
|
|||
|
|
const ruleForm = reactive<RiskRuleForm>({
|
|||
|
|
ipRateLimit: 300,
|
|||
|
|
loginFailThreshold: 5,
|
|||
|
|
loginLockMinutes: 30,
|
|||
|
|
abnormalDetection: true,
|
|||
|
|
})
|
|||
|
|
const ruleLoading = ref(false)
|
|||
|
|
|
|||
|
|
async function saveRules() {
|
|||
|
|
ruleLoading.value = true
|
|||
|
|
try {
|
|||
|
|
// TODO: 接入后端接口
|
|||
|
|
// await opsApi.saveRiskRules({ ...ruleForm })
|
|||
|
|
await new Promise(r => setTimeout(r, 500))
|
|||
|
|
ElMessage.success('配置已保存')
|
|||
|
|
} catch {
|
|||
|
|
ElMessage.error('保存失败')
|
|||
|
|
} finally {
|
|||
|
|
ruleLoading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ---------- 敏感词 ---------- */
|
|||
|
|
const wordList = ref<SensitiveWord[]>([])
|
|||
|
|
const tableLoading = ref(false)
|
|||
|
|
const page = ref(1)
|
|||
|
|
const size = ref(20)
|
|||
|
|
const total = ref(0)
|
|||
|
|
|
|||
|
|
const dialogVisible = ref(false)
|
|||
|
|
const dialogTitle = ref('新增敏感词')
|
|||
|
|
const formRef = ref<FormInstance>()
|
|||
|
|
const submitLoading = ref(false)
|
|||
|
|
const isEdit = ref(false)
|
|||
|
|
|
|||
|
|
const form = reactive<Partial<SensitiveWord>>({
|
|||
|
|
word: '',
|
|||
|
|
level: '中',
|
|||
|
|
category: '',
|
|||
|
|
enabled: true,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const formRules: FormRules = {
|
|||
|
|
word: [{ required: true, message: '请输入敏感词', trigger: 'blur' }],
|
|||
|
|
level: [{ required: true, message: '请选择风险等级', trigger: 'change' }],
|
|||
|
|
category: [{ required: true, message: '请输入分类', trigger: 'blur' }],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function levelTagType(level: string) {
|
|||
|
|
if (level === '高') return 'danger'
|
|||
|
|
if (level === '中') return 'warning'
|
|||
|
|
return 'info'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadWords() {
|
|||
|
|
tableLoading.value = true
|
|||
|
|
try {
|
|||
|
|
// TODO: 接入后端接口
|
|||
|
|
// const res = await opsApi.listSensitiveWords(page.value - 1, size.value)
|
|||
|
|
await new Promise(r => setTimeout(r, 300))
|
|||
|
|
wordList.value = generateMockWords(page.value, size.value)
|
|||
|
|
total.value = 63
|
|||
|
|
} catch {
|
|||
|
|
ElMessage.error('加载失败')
|
|||
|
|
} finally {
|
|||
|
|
tableLoading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function generateMockWords(pageNum: number, pageSize: number): SensitiveWord[] {
|
|||
|
|
const words = ['违规', '垃圾广告', '诈骗', '恶意攻击', '敏感信息', '暴力', '赌博', '毒品']
|
|||
|
|
const levels: Array<'高' | '中' | '低'> = ['高', '中', '低']
|
|||
|
|
const categories = ['政治', '色情', '广告', '欺诈', '暴力']
|
|||
|
|
const result: SensitiveWord[] = []
|
|||
|
|
const start = (pageNum - 1) * pageSize
|
|||
|
|
for (let i = 0; i < pageSize; i++) {
|
|||
|
|
const idx = start + i
|
|||
|
|
if (idx >= 63) break
|
|||
|
|
result.push({
|
|||
|
|
id: `word_${idx}`,
|
|||
|
|
word: `${words[idx % words.length]}_${idx + 1}`,
|
|||
|
|
level: levels[idx % levels.length],
|
|||
|
|
category: categories[idx % categories.length],
|
|||
|
|
enabled: idx % 3 !== 0,
|
|||
|
|
updatedAt: new Date(Date.now() - idx * 86400000).toISOString(),
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openDialog(row?: SensitiveWord) {
|
|||
|
|
isEdit.value = !!row
|
|||
|
|
dialogTitle.value = row ? '编辑敏感词' : '新增敏感词'
|
|||
|
|
if (row) {
|
|||
|
|
Object.assign(form, { ...row })
|
|||
|
|
} else {
|
|||
|
|
Object.assign(form, { word: '', level: '中', category: '', enabled: true })
|
|||
|
|
}
|
|||
|
|
dialogVisible.value = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function submitForm() {
|
|||
|
|
const valid = await formRef.value?.validate().catch(() => false)
|
|||
|
|
if (!valid) return
|
|||
|
|
submitLoading.value = true
|
|||
|
|
try {
|
|||
|
|
// TODO: 接入后端接口
|
|||
|
|
// if (isEdit.value && form.id) {
|
|||
|
|
// await opsApi.updateSensitiveWord(form.id, form as SensitiveWord)
|
|||
|
|
// } else {
|
|||
|
|
// await opsApi.createSensitiveWord(form as SensitiveWord)
|
|||
|
|
// }
|
|||
|
|
await new Promise(r => setTimeout(r, 400))
|
|||
|
|
ElMessage.success(isEdit.value ? '编辑成功' : '新增成功')
|
|||
|
|
dialogVisible.value = false
|
|||
|
|
loadWords()
|
|||
|
|
} catch {
|
|||
|
|
ElMessage.error('操作失败')
|
|||
|
|
} finally {
|
|||
|
|
submitLoading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function toggleWord(row: SensitiveWord) {
|
|||
|
|
try {
|
|||
|
|
// TODO: 接入后端接口
|
|||
|
|
// await opsApi.toggleSensitiveWord(row.id, row.enabled)
|
|||
|
|
ElMessage.success('状态已更新')
|
|||
|
|
} catch {
|
|||
|
|
row.enabled = !row.enabled
|
|||
|
|
ElMessage.error('更新失败')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function deleteWord(row: SensitiveWord) {
|
|||
|
|
try {
|
|||
|
|
await ElMessageBox.confirm(`确定删除敏感词「${row.word}」吗?`, '提示', { type: 'warning' })
|
|||
|
|
// TODO: 接入后端接口
|
|||
|
|
// await opsApi.deleteSensitiveWord(row.id)
|
|||
|
|
ElMessage.success('删除成功')
|
|||
|
|
loadWords()
|
|||
|
|
} catch {
|
|||
|
|
// cancel
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function fmt(value: string) {
|
|||
|
|
return value ? new Date(value).toLocaleString('zh-CN') : '-'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(() => {
|
|||
|
|
loadWords()
|
|||
|
|
})
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.card-header {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
}
|
|||
|
|
.pagination-wrap {
|
|||
|
|
margin-top: 16px;
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: flex-end;
|
|||
|
|
}
|
|||
|
|
</style>
|