feat(analytics): C1 敏感词页——词库CRUD/预览警示/批量处置/上下文跳转
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user