feat(analytics): C1 敏感词页——词库CRUD/预览警示/批量处置/上下文跳转

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-04 23:03:35 +08:00
parent 7f32f1a43c
commit 518d02bc8f
+503 -4
View File
@@ -1,8 +1,507 @@
<template>
<div class="page">
<h2>敏感词</h2>
<p>实现于后续任务</p>
<div class="sensitive-page">
<div class="toolbar">
<span class="kind-hint">敏感词命中治理:默认只显示待处置+已确认;处置后离开队列。</span>
<span v-if="asOfText" class="as-of">数据截至 {{ asOfText }}</span>
</div>
<!-- el-tabs 的事件名是 tab-change(无 change;v-model 只换值不触发重查) -->
<el-tabs v-model="tab" @tab-change="onTabChange">
<el-tab-pane label="命中列表" name="hits">
<div class="hit-toolbar">
<el-select v-model="disposition" class="disp-select" @change="reloadHits">
<!-- 'all'=默认(pending+confirmed)。EP 对空串 value 视为未选中显示
英文占位符,故用哨兵值而非 '' -->
<el-option value="all" label="待处置+已确认" />
<el-option value="pending" label="待处置" />
<el-option value="confirmed" label="已确认" />
<el-option value="false_positive" label="误报" />
<el-option value="ignored" label="已忽略" />
</el-select>
<el-button
type="primary"
:disabled="!selectedHits.length"
:loading="disposing"
@click="disposeSelected('confirmed')"
>
确认
</el-button>
<el-button
:disabled="!selectedHits.length || disposing"
@click="disposeSelected('false_positive')"
>
误报
</el-button>
<el-button :disabled="!selectedHits.length || disposing" @click="disposeSelected('ignored')">
忽略
</el-button>
<span v-if="selectedHits.length" class="sel-hint">已选 {{ selectedHits.length }} 条</span>
</div>
<el-alert v-if="hitsError" type="error" :title="hitsError" :closable="false" class="error-bar" />
<el-table
v-loading="hitsLoading"
:data="hitRows"
empty-text="暂无命中记录"
@selection-change="onHitSelection"
>
<el-table-column type="selection" width="42" />
<el-table-column prop="word" label="词" width="150" show-overflow-tooltip />
<el-table-column label="分类" width="120">
<template #default="{ row }">{{ row.category || '-' }}</template>
</el-table-column>
<el-table-column prop="snippet" label="上下文" min-width="260" show-overflow-tooltip>
<template #default="{ row }">{{ row.snippet || '-' }}</template>
</el-table-column>
<el-table-column label="时间" width="170">
<template #default="{ row }">{{ formatTime(row.ts) }}</template>
</el-table-column>
<el-table-column label="处置状态" width="100">
<template #default="{ row }">
<el-tag size="small" :type="dispMeta(row.disposition).type">
{{ dispMeta(row.disposition).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="goContext(row)">上下文</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
:current-page="hitPage"
:page-size="hitPageSize"
:total="hitTotal"
layout="total, prev, pager, next"
class="pager"
@current-change="onHitPageChange"
/>
</el-tab-pane>
<el-tab-pane label="词库管理" name="words">
<div class="add-form">
<el-input
v-model="newWord"
class="ipt-word"
placeholder="新词(建议 ≥2 字;单字词不做预览)"
:disabled="adding"
/>
<el-input v-model="newCategory" class="ipt-cat" placeholder="分类(可选,如 飞单)" :disabled="adding" />
<el-input
v-model="newExcludes"
class="ipt-excl"
placeholder="排除词,逗号分隔(可选)"
:disabled="adding"
/>
<el-button type="primary" :loading="adding" @click="onAddWord">预览并添加</el-button>
</div>
<el-alert v-if="wordsError" type="error" :title="wordsError" :closable="false" class="error-bar" />
<el-table v-loading="wordsLoading" :data="wordRows" empty-text="词库为空">
<el-table-column prop="word" label="词" min-width="130" />
<el-table-column label="分类" width="130">
<template #default="{ row }">{{ row.category || '-' }}</template>
</el-table-column>
<el-table-column label="排除词" min-width="200">
<template #default="{ row }">{{ row.exclude_words.length ? row.exclude_words.join(',') : '-' }}</template>
</el-table-column>
<el-table-column label="启用" width="80">
<template #default="{ row }">
<el-switch v-model="row.enabled" @change="onToggleEnabled(row, $event)" />
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button link type="danger" size="small" :disabled="deleting" @click="onDeleteWord(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
// C1 敏感词治理(spec §3.6):双 tab——命中列表(disposition 筛选,默认空值=
// 待处置+已确认;多选批量 确认/误报/忽略;行「上下文」按 群/微信/WA 三路
// 跳聊天窗)+ 词库管理(加词先预览命中量:≥500 警示确认,MySQL 单字词 400
// 分支提示后放行;启停 switch/删除带级联确认)。
// 控敏红线:确认文案不回显命中正文,console 不打词与 snippet,错误只透传
// 后端文案(后端已保证不含词/正文)。
// health 挂载拉一次做「数据截至」角标;seq 三路径守卫/分页/tab-change/
// kind-hint 均照 ChurnView/UnrepliedView 定型。
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createWord,
deleteWord,
disposeHits,
getAnalyticsHealth,
getHits,
getWords,
patchWord,
previewWord,
type SensitiveHitRow,
type SensitiveWordRow,
} from '@/api/analytics'
import { formatTime, formatTimeShort } from '@/utils/format'
type TabName = 'hits' | 'words'
type DisposeAction = 'confirmed' | 'false_positive' | 'ignored'
// 后端 DispositionParam 枚举的展示映射(乱值防御回退原值灰 tag)
const DISPOSITION_META: Record<string, { label: string; type: 'warning' | 'danger' | 'info' }> = {
pending: { label: '待处置', type: 'warning' },
confirmed: { label: '已确认', type: 'danger' },
false_positive: { label: '误报', type: 'info' },
ignored: { label: '已忽略', type: 'info' },
}
function dispMeta(d: string): { label: string; type: 'warning' | 'danger' | 'info' } {
return DISPOSITION_META[d] ?? { label: d, type: 'info' }
}
const router = useRouter()
const tab = ref<TabName>('hits')
const asOf = ref<number | null>(null)
const asOfText = computed(() => formatTimeShort(asOf.value))
// ---- 命中列表 tab ----
// 'all'=默认(pending+confirmed,后端不传 disposition 即此语义);其余必须是
// 枚举单值——乱值后端 Literal 校验直接 422(空串同理,所以用哨兵值)
const disposition = ref('all')
const hitPage = ref(1)
const hitPageSize = 20
const hitRows = ref<SensitiveHitRow[]>([])
const hitTotal = ref(0)
const hitsLoading = ref(false)
const hitsError = ref('')
const selectedHits = ref<SensitiveHitRow[]>([])
const disposing = ref(false)
let hitsSeq = 0 // 竞态守卫:disposition/翻页/tab 快速切换时旧响应作废(三路径同 ChurnView)
async function loadHits(): Promise<void> {
const my = ++hitsSeq
hitsLoading.value = true
hitsError.value = ''
try {
const params: Record<string, unknown> = { page: hitPage.value, page_size: hitPageSize }
if (disposition.value !== 'all') params.disposition = disposition.value
const resp = await getHits(params)
if (my !== hitsSeq) return
hitRows.value = resp.items
hitTotal.value = resp.total
// 处置后行离开默认队列,本页可能被清空且翻页越界——回落末页重查一次
const lastPage = Math.max(1, Math.ceil(hitTotal.value / hitPageSize))
if (!resp.items.length && hitTotal.value > 0 && hitPage.value > lastPage) {
hitPage.value = lastPage
void loadHits()
return
}
} catch (e) {
if (my === hitsSeq) hitsError.value = e instanceof Error ? e.message : String(e)
} finally {
if (my === hitsSeq) hitsLoading.value = false
}
}
// 筛选切换回第一页再查(el-select change 直挂,参数忽略——ChurnView 惯例)
function reloadHits(): void {
hitPage.value = 1
void loadHits()
}
// 分页翻页:vue-tsc strict 不推 inline-arrow 参数,单列包装(项目惯例)
function onHitPageChange(p: number): void {
hitPage.value = p
void loadHits()
}
// selection-change 的模板内联箭头带 TS 注解会触发 vue 模板解析错误,收敛到
// script(ContactsPane 惯例)
function onHitSelection(rows: SensitiveHitRow[]): void {
selectedHits.value = rows
}
async function disposeSelected(d: DisposeAction): Promise<void> {
const ids = selectedHits.value.map((r) => r.id)
if (!ids.length) {
ElMessage.warning('请先勾选命中记录')
return
}
disposing.value = true
try {
// 单页勾选 ≤ page_size(20),远低于后端 ids 上限 1000
const resp = await disposeHits(ids, d)
ElMessage.success(`已标记 ${resp.updated} 条:${dispMeta(d).label}`)
await loadHits() // 处置完即出默认队列,重查当前页(越界自动回落末页)
} catch (e) {
ElMessage.error(`处置失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
disposing.value = false
}
}
/**
* 行「上下文」跳聊天窗定位原消息:群命中→群聊时间轴;微信单聊→员工聊天窗
* single tab 带 ?msgid=;WA→ChatTimeline。owner 缺失无法定位,响亮提示
* (同 ChurnView/UnrepliedView)。跳转参数只带 id,不带词/snippet(控敏)。
*/
function goContext(row: SensitiveHitRow): void {
if (row.room_id) {
if (!row.owner_account_id) return warnNoOwner()
router.push({
name: 'group-chat',
params: { channel: row.channel, ownerId: row.owner_account_id, roomId: row.room_id },
})
return
}
if (row.channel === 'wechat') {
if (!row.owner_account_id) return warnNoOwner()
const params: Record<string, string> = { ownerId: row.owner_account_id, tab: 'single' }
if (row.talker_id) params.talkerId = row.talker_id
router.push({
name: 'staff-chat',
params,
// msgid 定位依赖 talker 定位到会话,缺 talker 时丢弃
query: row.talker_id && row.msg_id ? { msgid: row.msg_id } : undefined,
})
return
}
if (!row.owner_account_id) return warnNoOwner()
const params: Record<string, string> = { channel: row.channel, ownerId: row.owner_account_id }
if (row.talker_id) params.talkerId = row.talker_id
router.push({ name: 'chat', params })
}
function warnNoOwner(): void {
ElMessage.warning('该命中缺少归属账号,无法查看聊天记录')
}
// ---- 词库管理 tab ----
const newWord = ref('')
const newCategory = ref('')
const newExcludes = ref('')
const adding = ref(false)
const deleting = ref(false)
const wordRows = ref<SensitiveWordRow[]>([])
const wordsLoading = ref(false)
const wordsError = ref('')
let wordsSeq = 0 // 同 hitsSeq:加词/删词/启停与 tab 切换并发时旧响应作废
async function loadWords(): Promise<void> {
const my = ++wordsSeq
wordsLoading.value = true
wordsError.value = ''
try {
const resp = await getWords()
if (my !== wordsSeq) return
wordRows.value = resp
} catch (e) {
if (my === wordsSeq) wordsError.value = e instanceof Error ? e.message : String(e)
} finally {
if (my === wordsSeq) wordsLoading.value = false
}
}
// 逗号分隔(全角/半角都可)→去空白去空串
function parseExcludes(s: string): string[] {
return s
.split(/[,,]/)
.map((t) => t.trim())
.filter(Boolean)
}
/**
* 预览阶段的两个分支,返回「是否继续添加」:
* ① 400 单字词(MySQL 生产拒预览防全扫;SQLite 测试库不触发)——提示后
* 放行,跳过预览直接走添加;
* ② warn=true(count≥500)——confirm 警示,取消则中止。
* 其他错误(网络等)向上抛,由 onAddWord 统一 toast。
*/
async function previewStage(word: string, excludeWords: string[]): Promise<boolean> {
let prev: { count: number; warn: boolean }
try {
prev = await previewWord({ word, exclude_words: excludeWords })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
if (msg.includes('单字') || msg.includes('不做预览')) return true // 分支①
throw e
}
if (prev.warn) {
try {
await ElMessageBox.confirm(
`预计命中 ${prev.count} 条(≥500),建议换长词或加排除词。仍要添加吗?`,
'命中量警示',
{ type: 'warning', confirmButtonText: '仍要添加', cancelButtonText: '返回修改' },
)
} catch {
return false // 用户取消,中止(不进外层 catch——这里吞掉 'cancel')
}
}
return true
}
async function onAddWord(): Promise<void> {
const word = newWord.value.trim()
if (!word) {
ElMessage.warning('请输入敏感词')
return
}
const excludeWords = parseExcludes(newExcludes.value)
adding.value = true
try {
if (!(await previewStage(word, excludeWords))) return
await createWord({
word,
category: newCategory.value.trim() || undefined,
exclude_words: excludeWords.length ? excludeWords : undefined,
})
ElMessage({
type: 'success',
message: '已添加(下次 4h 扫描生效;要立即生效可让管理员跑 CLI sensitive-scan --full 扫历史)',
duration: 5000,
})
newWord.value = ''
newCategory.value = ''
newExcludes.value = ''
await loadWords()
} catch (e) {
// 409 词已存在等后端文案直接透出(后端错误只含 id 不含词,控敏安全)
ElMessage.error(`添加失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
adding.value = false
}
}
// switch change 的模板内联箭头带注解会炸模板编译,收敛 script;失败也重查,
// 把 switch 视觉回滚到服务端真值
function onToggleEnabled(row: SensitiveWordRow, val: string | number | boolean): void {
void applyEnabled(row.id, Boolean(val))
}
async function applyEnabled(id: number, enabled: boolean): Promise<void> {
try {
await patchWord(id, { enabled })
await loadWords()
} catch (e) {
ElMessage.error(`操作失败:${e instanceof Error ? e.message : String(e)}`)
await loadWords()
}
}
async function onDeleteWord(row: SensitiveWordRow): Promise<void> {
// 控敏:确认文案只说「该词」,不回显词与任何命中正文
try {
await ElMessageBox.confirm(
'删除该词将连带删除其全部命中记录,不可恢复。确认删除?',
'删除敏感词',
{
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
confirmButtonClass: 'el-button--danger',
},
)
} catch {
return // 用户放弃
}
deleting.value = true
try {
await deleteWord(row.id)
ElMessage.success('已删除')
await loadWords()
} catch (e) {
ElMessage.error(`删除失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
deleting.value = false
}
}
// ---- tab 切换与挂载 ----
// 每次切 tab 都重查对应列表(数据最新,与 ChurnView tab-change→reload 一致)
function onTabChange(name: string | number): void {
if (name === 'words') void loadWords()
else void loadHits()
}
onMounted(() => {
void loadHits()
// health(数据截至)与筛选无关,挂载拉一次即可;失败只影响角标(静默隐藏),
// 不进页面 error alert
getAnalyticsHealth()
.then((h) => {
asOf.value = h.data_as_of
})
.catch(() => {})
})
</script>
<style scoped>
.toolbar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 8px;
}
.kind-hint {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.as-of {
color: var(--el-text-color-secondary);
font-size: 12px;
margin-left: auto;
white-space: nowrap;
}
.hit-toolbar {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 12px;
}
.disp-select {
width: 160px;
}
.sel-hint {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.add-form {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 12px;
}
.ipt-word {
width: 230px;
}
.ipt-cat {
width: 160px;
}
.ipt-excl {
width: 230px;
}
.error-bar {
margin-bottom: 12px;
}
.pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
</style>