Browse Source

feat(sensitive-word): 敏感词管理页布局与交互优化

- 工具栏新增级别筛选、统计提示,表格新增备注/更新时间列
- 状态列改为 t-switch 一键启用/禁用(失败自动回滚)
- 编辑弹窗加宽并新增启用开关、保存 loading、必填校验
- 批量导入提示成功/跳过重复数,删除确认带词内容
- 后端 list 接口新增 level 级别过滤参数
TDesign-Vue-Next-1.20.6
wanghanlin 1 week ago
parent
commit
61b0ae5639
  1. 5
      frontend/src/api/sensitive-word.ts
  2. 239
      frontend/src/views/SensitiveWordManager.vue
  3. 5
      src/main/java/com/wok/supportbot/controller/SensitiveWordController.java
  4. 9
      src/main/java/com/wok/supportbot/service/SensitiveWordService.java

5
frontend/src/api/sensitive-word.ts

@ -1,14 +1,17 @@
import request from './request' import request from './request'
import type { ApiResponse } from '@/types/api' import type { ApiResponse } from '@/types/api'
export function listSensitiveWords(page = 1, size = 20, keyword?: string, category?: string): Promise<ApiResponse> {
export function listSensitiveWords(page = 1, size = 20, keyword?: string, category?: string, level?: number): Promise<ApiResponse> {
let path = `/sensitive-word/list?page=${page}&size=${size}` let path = `/sensitive-word/list?page=${page}&size=${size}`
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` if (keyword) path += `&keyword=${encodeURIComponent(keyword)}`
if (category) path += `&category=${encodeURIComponent(category)}` if (category) path += `&category=${encodeURIComponent(category)}`
if (level !== undefined && level !== null) path += `&level=${level}`
return request.get(path).then(r => r.data) return request.get(path).then(r => r.data)
} }
export function createSensitiveWord(data: any): Promise<ApiResponse> { return request.post('/sensitive-word', data).then(r => r.data) } export function createSensitiveWord(data: any): Promise<ApiResponse> { return request.post('/sensitive-word', data).then(r => r.data) }
export function updateSensitiveWord(id: string, data: any): Promise<ApiResponse> { return request.put(`/sensitive-word/${id}`, data).then(r => r.data) } export function updateSensitiveWord(id: string, data: any): Promise<ApiResponse> { return request.put(`/sensitive-word/${id}`, data).then(r => r.data) }
/** 切换启用/禁用(复用 update 接口,仅传 isActive 字段) */
export function toggleSensitiveWord(id: string, isActive: boolean): Promise<ApiResponse> { return updateSensitiveWord(id, { isActive }) }
export function deleteSensitiveWord(id: string): Promise<ApiResponse> { return request.delete(`/sensitive-word/${id}`).then(r => r.data) } export function deleteSensitiveWord(id: string): Promise<ApiResponse> { return request.delete(`/sensitive-word/${id}`).then(r => r.data) }
export function batchImportSensitiveWords(data: { words: string[]; category: string; level: number }): Promise<ApiResponse> { return request.post('/sensitive-word/batch-import', data).then(r => r.data) } export function batchImportSensitiveWords(data: { words: string[]; category: string; level: number }): Promise<ApiResponse> { return request.post('/sensitive-word/batch-import', data).then(r => r.data) }
export function listAuditLogs(page = 1, size = 20, sessionId?: string): Promise<ApiResponse> { export function listAuditLogs(page = 1, size = 20, sessionId?: string): Promise<ApiResponse> {

239
frontend/src/views/SensitiveWordManager.vue

@ -1,104 +1,257 @@
<template> <template>
<t-card title="🛡️ 敏感词管理" :bordered="false"> <t-card title="🛡️ 敏感词管理" :bordered="false">
<p class="desc-text">维护敏感词库警告级别仅脱敏处理拦截级别阻断回复命中记录见内容审计日志</p>
<div class="toolbar"> <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> <t-button variant="outline" size="small" @click="showImportDialog = true">📥 批量导入</t-button>
<div style="flex:1;" /> <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> </div>
<t-table :data="words" :columns="columns" row-key="id" :loading="loading" <t-table :data="words" :columns="columns" row-key="id" :loading="loading"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange"> :pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange">
<template #category="{ row }"> <template #category="{ row }">
<t-tag size="small" :style="{background:catColor(row.category)}" variant="light">{{ catLabel(row.category) }}</t-tag>
<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 }">
<t-switch v-model="row._active" size="small" @change="toggleActive(row)" />
</template> </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 #op="{ row }"> <template #op="{ row }">
<t-space :size="4"> <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> </t-space>
</template> </template>
</t-table> </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 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-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-item label="备注"><t-input v-model="form.remark" placeholder="可选备注" /></t-form-item>
</t-form> </t-form>
<div class="dialog-footer"> <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> </div>
</t-dialog> </t-dialog>
<!-- 批量导入弹窗 --> <!-- 批量导入弹窗 -->
<t-dialog v-model:visible="showImportDialog" header="批量导入敏感词" width="500px" :footer="false"> <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-textarea v-model="importText" :autosize="{ minRows: 6, maxRows: 10 }" placeholder="每行一个敏感词" />
<t-row :gutter="16" style="margin-top:12px;"> <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-col :span="6"><t-select v-model="importLevel" :options="levelOpts" placeholder="级别" /></t-col>
</t-row> </t-row>
<div class="dialog-footer"> <div class="dialog-footer">
<t-button variant="outline" @click="showImportDialog = false">取消</t-button> <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> </div>
</t-dialog> </t-dialog>
</t-card> </t-card>
</template> </template>
<script setup lang="ts"> <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 { toast } from '@/utils/toast'
import { formatDate } from '@/utils/format'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import { useDebounce } from '@/composables/useDebounce' import { useDebounce } from '@/composables/useDebounce'
const { confirm } = useConfirm() const { confirm } = useConfirm()
const { debounce } = useDebounce() 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 = [ 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())
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
}
}
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}]
function onPageChange(info: { current: number; pageSize: number }) {
page.value = info.current
pageSize.value = info.pageSize
loadList()
}
onMounted(()=>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() }) 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> </script>
<style scoped></style>

5
src/main/java/com/wok/supportbot/controller/SensitiveWordController.java

@ -38,9 +38,10 @@ public class SensitiveWordController {
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size, @RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String keyword, @RequestParam(required = false) String keyword,
@RequestParam(required = false) String category) {
@RequestParam(required = false) String category,
@RequestParam(required = false) Integer level) {
try { try {
Map<String, Object> result = sensitiveWordService.list(page, size, keyword, category);
Map<String, Object> result = sensitiveWordService.list(page, size, keyword, category, level);
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("success", true); data.put("success", true);

9
src/main/java/com/wok/supportbot/service/SensitiveWordService.java

@ -32,9 +32,10 @@ public class SensitiveWordService {
* @param size 每页条数 * @param size 每页条数
* @param keyword 关键词搜索匹配 word 字段 * @param keyword 关键词搜索匹配 word 字段
* @param category 分类筛选 * @param category 分类筛选
* @param level 级别筛选1=警告2=拦截可为空
* @return 包含 records/total/page/size/pages 的结果 * @return 包含 records/total/page/size/pages 的结果
*/ */
public Map<String, Object> list(int page, int size, String keyword, String category) {
public Map<String, Object> list(int page, int size, String keyword, String category, Integer level) {
// 构建计数条件 // 构建计数条件
QueryWrapper<SensitiveWord> countWrapper = new QueryWrapper<>(); QueryWrapper<SensitiveWord> countWrapper = new QueryWrapper<>();
if (keyword != null && !keyword.isBlank()) { if (keyword != null && !keyword.isBlank()) {
@ -43,6 +44,9 @@ public class SensitiveWordService {
if (category != null && !category.isBlank()) { if (category != null && !category.isBlank()) {
countWrapper.eq("category", category); countWrapper.eq("category", category);
} }
if (level != null) {
countWrapper.eq("level", level);
}
Long total = sensitiveWordMapper.selectCount(countWrapper); Long total = sensitiveWordMapper.selectCount(countWrapper);
// 构建列表查询条件 // 构建列表查询条件
@ -53,6 +57,9 @@ public class SensitiveWordService {
if (category != null && !category.isBlank()) { if (category != null && !category.isBlank()) {
listWrapper.eq("category", category); listWrapper.eq("category", category);
} }
if (level != null) {
listWrapper.eq("level", level);
}
listWrapper.orderByDesc("create_time"); listWrapper.orderByDesc("create_time");
listWrapper.last("LIMIT " + size + " OFFSET " + (long) (page - 1) * size); listWrapper.last("LIMIT " + size + " OFFSET " + (long) (page - 1) * size);
List<SensitiveWord> records = sensitiveWordMapper.selectList(listWrapper); List<SensitiveWord> records = sensitiveWordMapper.selectList(listWrapper);

Loading…
Cancel
Save