|
|
|
@ -1,11 +1,15 @@ |
|
|
|
<template> |
|
|
|
<t-card title="🛡️ 敏感词管理" :bordered="false"> |
|
|
|
<p class="desc-text">维护敏感词库,「警告」级别仅脱敏处理、「拦截」级别阻断回复;命中记录见「内容审计日志」。</p> |
|
|
|
|
|
|
|
<div class="toolbar"> |
|
|
|
<t-button theme="primary" size="small" @click="showAddDialog = true">+ 添加敏感词</t-button> |
|
|
|
<t-button theme="primary" size="small" @click="openCreate">+ 添加敏感词</t-button> |
|
|
|
<t-button variant="outline" size="small" @click="showImportDialog = true">📥 批量导入</t-button> |
|
|
|
<div style="flex:1;" /> |
|
|
|
<t-input v-model="searchKeyword" placeholder="搜索敏感词..." size="small" style="width:180px;" clearable @change="debouncedSearch" /> |
|
|
|
<t-select v-model="filterCategory" :options="catOpts" placeholder="全部分类" size="small" style="width:100px;" @change="loadList" /> |
|
|
|
<t-input v-model="searchKeyword" placeholder="搜索敏感词..." clearable size="small" style="width:220px;" @change="debouncedSearch" /> |
|
|
|
<t-select v-model="filterCategory" :options="categoryFilterOpts" placeholder="全部分类" clearable size="small" style="width:130px;" @change="onFilterChange" /> |
|
|
|
<t-select v-model="filterLevel" :options="levelFilterOpts" placeholder="全部级别" clearable size="small" style="width:120px;" @change="onFilterChange" /> |
|
|
|
<span class="total-hint">共 {{ total }} 个</span> |
|
|
|
</div> |
|
|
|
|
|
|
|
<t-table :data="words" :columns="columns" row-key="id" :loading="loading" |
|
|
|
@ -13,92 +17,241 @@ |
|
|
|
<template #category="{ row }"> |
|
|
|
<t-tag size="small" :style="{ background: catColor(row.category) }" variant="light">{{ catLabel(row.category) }}</t-tag> |
|
|
|
</template> |
|
|
|
<template #level="{ row }"><span :style="{color:row.level>=2?'var(--td-error-color)':'var(--td-warning-color)'}">{{ row.level>=2?'拦截':'警告' }}</span></template> |
|
|
|
<template #isActive="{ row }"><span>{{ row.isActive?'✅':'❌' }}</span></template> |
|
|
|
<template #level="{ row }"> |
|
|
|
<span :style="{ color: row.level >= 2 ? 'var(--td-error-color)' : 'var(--td-warning-color)' }">{{ row.level >= 2 ? '拦截' : '警告' }}</span> |
|
|
|
</template> |
|
|
|
<template #isActive="{ row }"> |
|
|
|
<t-switch v-model="row._active" size="small" @change="toggleActive(row)" /> |
|
|
|
</template> |
|
|
|
<template #op="{ row }"> |
|
|
|
<t-space :size="4"> |
|
|
|
<t-button size="small" variant="text" @click="editWord(row)">编辑</t-button> |
|
|
|
<t-button size="small" variant="text" theme="danger" @click="removeWord(row.id)">删除</t-button> |
|
|
|
<t-button size="small" variant="text" @click="openEdit(row)">编辑</t-button> |
|
|
|
<t-button size="small" variant="text" theme="danger" @click="doDelete(row)">删除</t-button> |
|
|
|
</t-space> |
|
|
|
</template> |
|
|
|
</t-table> |
|
|
|
|
|
|
|
<!-- 新增/编辑弹窗 --> |
|
|
|
<t-dialog v-model:visible="dialogVisible" :header="editingWord ? '编辑敏感词' : '添加敏感词'" width="420px" :footer="false" @close="closeDialog"> |
|
|
|
<t-dialog v-model:visible="showFormDialog" :header="editingId ? '编辑敏感词' : '添加敏感词'" width="480px" :footer="false"> |
|
|
|
<t-form label-align="top"> |
|
|
|
<t-form-item label="敏感词"><t-input v-model="form.word" placeholder="输入敏感词" /></t-form-item> |
|
|
|
<t-form-item label="分类"><t-select v-model="form.category" :options="catOpts2" /></t-form-item> |
|
|
|
<t-form-item label="敏感词 *"><t-input v-model="form.word" placeholder="输入敏感词" /></t-form-item> |
|
|
|
<t-form-item label="分类"><t-select v-model="form.category" :options="categoryOpts" /></t-form-item> |
|
|
|
<t-form-item label="级别"><t-select v-model="form.level" :options="levelOpts" /></t-form-item> |
|
|
|
<t-form-item label="是否启用"><t-switch v-model="form.isActive" :label="['启用', '停用']" /></t-form-item> |
|
|
|
<t-form-item label="备注"><t-input v-model="form.remark" placeholder="可选备注" /></t-form-item> |
|
|
|
</t-form> |
|
|
|
<div class="dialog-footer"> |
|
|
|
<t-button variant="outline" @click="closeDialog">取消</t-button> |
|
|
|
<t-button theme="primary" @click="saveWord" :disabled="!form.word">保存</t-button> |
|
|
|
<t-button variant="outline" @click="showFormDialog = false">取消</t-button> |
|
|
|
<t-button theme="primary" :loading="saving" :disabled="!form.word.trim()" @click="doSave">保存</t-button> |
|
|
|
</div> |
|
|
|
</t-dialog> |
|
|
|
|
|
|
|
<!-- 批量导入弹窗 --> |
|
|
|
<t-dialog v-model:visible="showImportDialog" header="批量导入敏感词" width="500px" :footer="false"> |
|
|
|
<p class="desc-text">每行一个敏感词,重复词将自动跳过。</p> |
|
|
|
<t-textarea v-model="importText" :autosize="{ minRows: 6, maxRows: 10 }" placeholder="每行一个敏感词" /> |
|
|
|
<t-row :gutter="16" style="margin-top:12px;"> |
|
|
|
<t-col :span="6"><t-select v-model="importCategory" :options="catOpts2" placeholder="分类" /></t-col> |
|
|
|
<t-col :span="6"><t-select v-model="importCategory" :options="categoryOpts" placeholder="分类" /></t-col> |
|
|
|
<t-col :span="6"><t-select v-model="importLevel" :options="levelOpts" placeholder="级别" /></t-col> |
|
|
|
</t-row> |
|
|
|
<div class="dialog-footer"> |
|
|
|
<t-button variant="outline" @click="showImportDialog = false">取消</t-button> |
|
|
|
<t-button theme="primary" @click="doImport" :disabled="!importText.trim()">导入</t-button> |
|
|
|
<t-button theme="primary" :loading="importing" :disabled="!importText.trim()" @click="doImport">导入</t-button> |
|
|
|
</div> |
|
|
|
</t-dialog> |
|
|
|
</t-card> |
|
|
|
</template> |
|
|
|
|
|
|
|
<script setup lang="ts"> |
|
|
|
import { ref, computed, onMounted } from 'vue' |
|
|
|
import { listSensitiveWords, createSensitiveWord, updateSensitiveWord, deleteSensitiveWord, batchImportSensitiveWords } from '@/api/sensitive-word' |
|
|
|
import { ref, onMounted } from 'vue' |
|
|
|
import { listSensitiveWords, createSensitiveWord, updateSensitiveWord, deleteSensitiveWord, batchImportSensitiveWords, toggleSensitiveWord } from '@/api/sensitive-word' |
|
|
|
import { toast } from '@/utils/toast' |
|
|
|
import { formatDate } from '@/utils/format' |
|
|
|
import { useConfirm } from '@/composables/useConfirm' |
|
|
|
import { useDebounce } from '@/composables/useDebounce' |
|
|
|
|
|
|
|
const { confirm } = useConfirm() |
|
|
|
const { debounce } = useDebounce() |
|
|
|
|
|
|
|
const words = ref<any[]>([]); const loading=ref(false); const page=ref(1); const pageSize=ref(20); const total=ref(0) |
|
|
|
const searchKeyword=ref(''); const filterCategory=ref('') |
|
|
|
const showAddDialog=ref(false); const showImportDialog=ref(false); const editingWord=ref<any>(null) |
|
|
|
const form=ref({word:'',category:'custom',level:1,remark:''}) |
|
|
|
const importText=ref(''); const importCategory=ref('custom'); const importLevel=ref(1) |
|
|
|
// ===== 分类 / 级别常量 ===== |
|
|
|
const categoryOpts = [ |
|
|
|
{ label: '政治', value: 'politics' }, |
|
|
|
{ label: '色情', value: 'porn' }, |
|
|
|
{ label: '辱骂', value: 'abuse' }, |
|
|
|
{ label: '自定义', value: 'custom' }, |
|
|
|
] |
|
|
|
const levelOpts = [ |
|
|
|
{ label: '警告(脱敏处理)', value: 1 }, |
|
|
|
{ label: '拦截(阻断回复)', value: 2 }, |
|
|
|
] |
|
|
|
const CATEGORY_LABEL: Record<string, string> = { politics: '政治', porn: '色情', abuse: '辱骂', custom: '自定义' } |
|
|
|
const CATEGORY_COLOR: Record<string, string> = { politics: '#dc3545', porn: '#e83e8c', abuse: '#fd7e14', custom: '#6c757d' } |
|
|
|
|
|
|
|
// 工具栏筛选选项(含「全部」) |
|
|
|
const categoryFilterOpts = [{ label: '全部分类', value: '' }, ...categoryOpts] |
|
|
|
const levelFilterOpts = [{ label: '全部级别', value: '' }, ...levelOpts] |
|
|
|
|
|
|
|
// ===== 列表状态 ===== |
|
|
|
const words = ref<any[]>([]) |
|
|
|
const loading = ref(false) |
|
|
|
const page = ref(1) |
|
|
|
const pageSize = ref(20) |
|
|
|
const total = ref(0) |
|
|
|
const searchKeyword = ref('') |
|
|
|
const filterCategory = ref('') |
|
|
|
const filterLevel = ref<number | ''>('') |
|
|
|
|
|
|
|
// ===== 弹窗状态 ===== |
|
|
|
const showFormDialog = ref(false) |
|
|
|
const showImportDialog = ref(false) |
|
|
|
const editingId = ref<string | null>(null) |
|
|
|
const saving = ref(false) |
|
|
|
const importing = ref(false) |
|
|
|
const form = ref({ word: '', category: 'custom', level: 1, isActive: true, remark: '' }) |
|
|
|
const importText = ref('') |
|
|
|
const importCategory = ref('custom') |
|
|
|
const importLevel = ref(1) |
|
|
|
|
|
|
|
const columns = [ |
|
|
|
{colKey:'word',title:'敏感词',width:160},{colKey:'category',title:'分类',width:80}, |
|
|
|
{colKey:'level',title:'级别',width:60},{colKey:'isActive',title:'状态',width:60},{colKey:'op',title:'操作',width:120}, |
|
|
|
{ colKey: 'word', title: '敏感词', width: 160, ellipsis: true }, |
|
|
|
{ colKey: 'category', title: '分类', width: 100 }, |
|
|
|
{ colKey: 'level', title: '级别', width: 80 }, |
|
|
|
{ colKey: 'isActive', title: '状态', width: 70 }, |
|
|
|
{ colKey: 'remark', title: '备注', width: 140, ellipsis: true }, |
|
|
|
{ colKey: 'updateTime', title: '更新时间', width: 160, cell: (_: any, { row }: any) => formatDate(row.updateTime) }, |
|
|
|
{ colKey: 'op', title: '操作', width: 120 }, |
|
|
|
] |
|
|
|
|
|
|
|
const dialogVisible = computed({get:()=>showAddDialog.value||!!editingWord.value,set:(v)=>{if(!v)closeDialog()}}) |
|
|
|
onMounted(() => loadList()) |
|
|
|
|
|
|
|
const catOpts=[{label:'全部分类',value:''},{label:'政治',value:'politics'},{label:'色情',value:'porn'},{label:'辱骂',value:'abuse'},{label:'自定义',value:'custom'}] |
|
|
|
const catOpts2=catOpts.filter(c=>c.value!=='') |
|
|
|
const levelOpts=[{label:'警告(脱敏处理)',value:1},{label:'拦截(阻断回复)',value:2}] |
|
|
|
async function loadList() { |
|
|
|
loading.value = true |
|
|
|
try { |
|
|
|
const level = filterLevel.value ? Number(filterLevel.value) : undefined |
|
|
|
const r = await listSensitiveWords(page.value, pageSize.value, searchKeyword.value || undefined, filterCategory.value || undefined, level) |
|
|
|
if (r.success) { |
|
|
|
const list = r.data?.records || r.data || [] |
|
|
|
words.value = list.map((w: any) => ({ ...w, _active: w.isActive })) |
|
|
|
total.value = r.data?.total || r.total || 0 |
|
|
|
} |
|
|
|
} catch (e: any) { |
|
|
|
toast('加载失败:' + e.message, 'error') |
|
|
|
} finally { |
|
|
|
loading.value = false |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
onMounted(()=>loadList()) |
|
|
|
function onPageChange(info: { current: number; pageSize: number }) { |
|
|
|
page.value = info.current |
|
|
|
pageSize.value = info.pageSize |
|
|
|
loadList() |
|
|
|
} |
|
|
|
|
|
|
|
function onFilterChange() { |
|
|
|
page.value = 1 |
|
|
|
loadList() |
|
|
|
} |
|
|
|
|
|
|
|
async function loadList(){loading.value=true |
|
|
|
try{const r=await listSensitiveWords(page.value,pageSize.value,searchKeyword.value||undefined,filterCategory.value||undefined) |
|
|
|
if(r.success){words.value=r.data?.records||r.data||[];total.value=r.data?.total||r.total||0}}catch(e:any){toast('加载失败:'+e.message,'error')}finally{loading.value=false}} |
|
|
|
function onPageChange(i:{current:number;pageSize:number}){page.value=i.current;pageSize.value=i.pageSize;loadList()} |
|
|
|
const debouncedSearch = debounce(() => { page.value = 1; loadList() }) |
|
|
|
|
|
|
|
function editWord(w:any){editingWord.value=w;form.value={word:w.word,category:w.category,level:w.level,remark:w.remark||''}} |
|
|
|
function closeDialog(){showAddDialog.value=false;editingWord.value=null;form.value={word:'',category:'custom',level:1,remark:''}} |
|
|
|
function openCreate() { |
|
|
|
editingId.value = null |
|
|
|
form.value = { word: '', category: 'custom', level: 1, isActive: true, remark: '' } |
|
|
|
showFormDialog.value = true |
|
|
|
} |
|
|
|
|
|
|
|
function openEdit(w: any) { |
|
|
|
editingId.value = w.id |
|
|
|
form.value = { word: w.word || '', category: w.category || 'custom', level: w.level || 1, isActive: w.isActive !== false, remark: w.remark || '' } |
|
|
|
showFormDialog.value = true |
|
|
|
} |
|
|
|
|
|
|
|
async function doSave() { |
|
|
|
const f = form.value |
|
|
|
const word = f.word.trim() |
|
|
|
if (!word) { |
|
|
|
toast('请填写敏感词', 'error') |
|
|
|
return |
|
|
|
} |
|
|
|
saving.value = true |
|
|
|
try { |
|
|
|
const d = { word, category: f.category, level: f.level, isActive: f.isActive, remark: f.remark } |
|
|
|
let r: any |
|
|
|
if (editingId.value) r = await updateSensitiveWord(editingId.value, d) |
|
|
|
else r = await createSensitiveWord(d) |
|
|
|
if (r.success) { |
|
|
|
toast(editingId.value ? '修改成功' : '添加成功', 'success') |
|
|
|
showFormDialog.value = false |
|
|
|
loadList() |
|
|
|
} else { |
|
|
|
toast(r.message || '操作失败', 'error') |
|
|
|
} |
|
|
|
} catch (e: any) { |
|
|
|
toast('操作失败:' + e.message, 'error') |
|
|
|
} finally { |
|
|
|
saving.value = false |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
async function saveWord(){try{if(editingWord.value){const r=await updateSensitiveWord(editingWord.value.id,form.value);if(r.success)toast('修改成功','success')}else{const r=await createSensitiveWord(form.value);if(r.success)toast('添加成功','success');else toast(r.message||'添加失败','error')}closeDialog();loadList()}catch(e:any){toast('操作失败:'+e.message,'error')}} |
|
|
|
async function doDelete(w: any) { |
|
|
|
if (!await confirm('确认删除敏感词「' + (w.word || w.id) + '」?')) return |
|
|
|
try { |
|
|
|
const r = await deleteSensitiveWord(w.id) |
|
|
|
if (r.success) { |
|
|
|
toast('删除成功', 'success') |
|
|
|
loadList() |
|
|
|
} else { |
|
|
|
toast(r.message || '删除失败', 'error') |
|
|
|
} |
|
|
|
} catch (e: any) { |
|
|
|
toast('删除失败:' + e.message, 'error') |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
async function removeWord(id:string){if(!await confirm('确认删除?'))return |
|
|
|
try{const r=await deleteSensitiveWord(id);if(r.success){toast('删除成功','success');loadList()}else toast(r.message||'删除失败','error')}catch(e:any){toast('删除失败:'+e.message,'error')}} |
|
|
|
async function toggleActive(w: any) { |
|
|
|
const target = !w.isActive |
|
|
|
try { |
|
|
|
const r = await toggleSensitiveWord(w.id, target) |
|
|
|
if (r.success) { |
|
|
|
w.isActive = target |
|
|
|
w._active = target |
|
|
|
toast(target ? '已启用' : '已禁用', 'success') |
|
|
|
} else { |
|
|
|
w._active = w.isActive |
|
|
|
toast(r.message || '操作失败', 'error') |
|
|
|
} |
|
|
|
} catch (e: any) { |
|
|
|
w._active = w.isActive |
|
|
|
toast('操作失败:' + e.message, 'error') |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
async function doImport(){const ws=importText.value.split('\n').map(s=>s.trim()).filter(Boolean);if(!ws.length)return toast('请输入敏感词','error') |
|
|
|
try{const r=await batchImportSensitiveWords({words:ws,category:importCategory.value,level:importLevel.value});if(r.success){toast(`成功导入 ${r.data||ws.length} 条`,'success');showImportDialog.value=false;importText.value='';loadList()}else toast(r.message||'导入失败','error')}catch(e:any){toast('导入失败:'+e.message,'error')}} |
|
|
|
async function doImport() { |
|
|
|
const ws = importText.value.split('\n').map(s => s.trim()).filter(Boolean) |
|
|
|
if (!ws.length) { |
|
|
|
toast('请输入敏感词', 'error') |
|
|
|
return |
|
|
|
} |
|
|
|
importing.value = true |
|
|
|
try { |
|
|
|
const r = await batchImportSensitiveWords({ words: ws, category: importCategory.value, level: importLevel.value }) |
|
|
|
if (r.success) { |
|
|
|
const imported = r.data?.imported ?? ws.length |
|
|
|
const skipped = (r.data?.total ?? ws.length) - imported |
|
|
|
toast(skipped > 0 ? `成功导入 ${imported} 条,跳过重复 ${skipped} 条` : `成功导入 ${imported} 条`, 'success') |
|
|
|
showImportDialog.value = false |
|
|
|
importText.value = '' |
|
|
|
loadList() |
|
|
|
} else { |
|
|
|
toast(r.message || '导入失败', 'error') |
|
|
|
} |
|
|
|
} catch (e: any) { |
|
|
|
toast('导入失败:' + e.message, 'error') |
|
|
|
} finally { |
|
|
|
importing.value = false |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
function catLabel(c:string){return{politics:'政治',porn:'色情',abuse:'辱骂',custom:'自定义'}[c]||c} |
|
|
|
function catColor(c:string){return{politics:'#dc3545',porn:'#e83e8c',abuse:'#fd7e14',custom:'#6c757d'}[c]||'#6c757d'} |
|
|
|
function catLabel(c: string) { return CATEGORY_LABEL[c] || c } |
|
|
|
function catColor(c: string) { return CATEGORY_COLOR[c] || '#6c757d' } |
|
|
|
</script> |
|
|
|
<style scoped></style> |