@@ -0,0 +1,228 @@
< script setup lang = "ts" >
import { ElMessage } from "element-plus" ;
import { computed , onMounted , reactive , ref } from "vue" ;
import { api } from "../services/api" ;
import type { AttentionConfig , AttentionPreviewMessage , AttentionPreviewResult } from "../types/api" ;
import AdminPagination from "./AdminPagination.vue" ;
const props = defineProps < { canConfig : boolean ; canPreview : boolean } > ( ) ;
const loading = ref ( false ) ;
const saving = ref ( false ) ;
const previewLoading = ref ( false ) ;
const previewMessages = ref < AttentionPreviewMessage [ ] > ( [ ] ) ;
const selectedMessages = ref < AttentionPreviewMessage [ ] > ( [ ] ) ;
const previewResults = ref < AttentionPreviewResult [ ] > ( [ ] ) ;
const keyword = ref ( "" ) ;
const pager = reactive ( { page : 1 , pageSize : 20 , total : 0 } ) ;
const terms = reactive ( { urgent : "" , important : "" , normal : "" } ) ;
const config = reactive < AttentionConfig > ( {
enabled : true ,
keywordEnabled : true ,
aiEnabled : false ,
knowledgeMissingEnabled : true ,
urgentTerms : [ ] ,
importantTerms : [ ] ,
normalTerms : [ ] ,
promptTemplate : "" ,
recognitionItems : [ ] ,
} ) ;
const canRunPreview = computed ( ( ) => props . canPreview && selectedMessages . value . length > 0 && ! previewLoading . value ) ;
onMounted ( async ( ) => {
if ( props . canConfig || props . canPreview ) await loadConfig ( ) ;
if ( props . canPreview ) await loadPreviewMessages ( ) ;
} ) ;
async function loadConfig ( ) {
loading . value = true ;
try {
assignConfig ( props . canConfig ? await api . attentionConfig ( ) : await api . attentionPreviewConfig ( ) ) ;
} finally {
loading . value = false ;
}
}
function assignConfig ( value : AttentionConfig ) {
Object . assign ( config , value ) ;
terms . urgent = value . urgentTerms . join ( "\n" ) ;
terms . important = value . importantTerms . join ( "\n" ) ;
terms . normal = value . normalTerms . join ( "\n" ) ;
}
function currentConfig ( ) : AttentionConfig {
return {
... config ,
urgentTerms : splitTerms ( terms . urgent ) ,
importantTerms : splitTerms ( terms . important ) ,
normalTerms : splitTerms ( terms . normal ) ,
} ;
}
function splitTerms ( value : string ) {
return [ ... new Set ( value . split ( /[\n,, ]+/ ) . map ( ( item ) => item . trim ( ) ) . filter ( Boolean ) ) ] ;
}
function addRecognitionItem ( ) {
if ( config . recognitionItems . length >= 30 ) {
ElMessage . warning ( "可识别项最多配置 30 个" ) ;
return ;
}
config . recognitionItems . push ( { name : "" , description : "" , priority : "normal" , enabled : true } ) ;
}
function removeRecognitionItem ( index : number ) {
config . recognitionItems . splice ( index , 1 ) ;
}
async function saveConfig ( ) {
saving . value = true ;
try {
assignConfig ( await api . saveAttentionConfig ( currentConfig ( ) ) ) ;
ElMessage . success ( "人工关注筛选规则已保存,后续新问答立即生效" ) ;
} catch ( error ) {
ElMessage . error ( error instanceof Error ? error . message : "配置保存失败" ) ;
} finally {
saving . value = false ;
}
}
async function loadPreviewMessages ( page = 1 , pageSize = pager . pageSize ) {
previewLoading . value = true ;
try {
const result = await api . attentionPreviewMessages ( { keyword : keyword . value . trim ( ) , page , pageSize } ) ;
previewMessages . value = result . items ;
selectedMessages . value = [ ] ;
Object . assign ( pager , { page : result . page , pageSize : result . pageSize , total : result . total } ) ;
} finally {
previewLoading . value = false ;
}
}
function onSelectionChange ( rows : AttentionPreviewMessage [ ] ) {
if ( rows . length > 20 ) {
ElMessage . warning ( "单次最多预览 20 条历史消息" ) ;
selectedMessages . value = rows . slice ( 0 , 20 ) ;
return ;
}
selectedMessages . value = rows ;
}
async function runPreview ( ) {
if ( ! canRunPreview . value ) return ;
previewLoading . value = true ;
try {
previewResults . value = await api . previewAttention (
selectedMessages . value . map ( ( item ) => item . messageId ) ,
currentConfig ( ) ,
) ;
ElMessage . success ( ` 已完成 ${ previewResults . value . length } 条历史消息筛选预览 ` ) ;
} catch ( error ) {
ElMessage . error ( error instanceof Error ? error . message : "提示词筛选预览失败" ) ;
} finally {
previewLoading . value = false ;
}
}
function priorityLabel ( value : string ) {
return ( { urgent : "紧急" , important : "重要" , normal : "普通" } as Record < string , string > ) [ value ] || "-" ;
}
function sourceLabel ( value : string ) {
return ( { keyword : "关键词" , knowledge _missing : "知识缺失" , ai : "AI 提示词" , rules : "未触发" , disabled : "总开关关闭" } as Record < string , string > ) [ value ] || value ;
}
< / script >
< template >
< section v-loading = "loading" class="attention-config-workspace" >
< el -alert
title = "运行顺序:紧急/重要/普通关键词 → 知识缺失 → AI 提示词。确定性规则命中后不再调用模型。"
type = "info"
:closable = "false"
show -icon
/ >
< article v-if = "canConfig" class="attention-config-card" >
< header > < div > < h3 > 自动筛选规则 < / h3 > < p > 配置只影响保存后的新问答 , 不会自动重跑已有记录 。 < / p > < / div > < el-button type = "primary" :loading = "saving" @click ="saveConfig" > 保存规则 < / el -button > < / header >
< div class = "attention-switch-grid" >
< label > < span > < strong > 启用人工关注自动筛选 < / strong > < small > 关闭后不再自动创建新的关注记录 < / small > < / span > < el-switch v-model = "config.enabled" / > < / label >
< label > < span > < strong > 启用关键词筛选 < / strong > < small > 优先执行 , 不产生模型调用费用 < / small > < / span > < el-switch v-model = "config.keywordEnabled" / > < / label >
< label > < span > < strong > 知识缺失自动关注 < / strong > < small > 知识库应答缺少可靠正式知识时触发 < / small > < / span > < el-switch v-model = "config.knowledgeMissingEnabled" / > < / label >
< label > < span > < strong > 启用 AI 提示词筛选 < / strong > < small > 仅在前面规则未命中时异步执行 , 不会拖慢用户收到回答 < / small > < / span > < el-switch v-model = "config.aiEnabled" / > < / label >
< / div >
< div class = "attention-term-grid" >
< label > < strong > 紧急关键词 < / strong > < small > 每行一个 , 命中后标记为紧急 < / small > < el-input v-model = "terms.urgent" type="textarea" :rows="6" / > < / label >
< label > < strong > 重要关键词 < / strong > < small > 每行一个 , 命中后标记为重要 < / small > < el-input v-model = "terms.important" type="textarea" :rows="6" / > < / label >
< label > < strong > 普通关键词 < / strong > < small > 每行一个 , 命中后标记为普通 < / small > < el-input v-model = "terms.normal" type="textarea" :rows="6" / > < / label >
< / div >
< section class = "recognition-item-editor" >
< header > < span > < strong > AI 可识别项 < / strong > < small > 语义筛选只会从已启用项目中选择 ; 可按业务需要继续增加 。 < / small > < / span > < el-button @click ="addRecognitionItem" > 新增可识别项 < / el -button > < / header >
< el-table :data = "config.recognitionItems" border >
< el-table-column label = "启用" width = "72" > < template # default = "{ row }" > < el-switch v-model = "row.enabled" / > < / template > < / el-table-column >
< el-table-column label = "项目名称" width = "180" > < template # default = "{ row }" > < el-input v-model = "row.name" maxlength="50" placeholder="例如:疑似投诉" / > < / template > < / el-table-column >
< el-table-column label = "识别说明" min -width = " 360 " > < template # default = "{ row }" > < el-input v-model = "row.description" maxlength="500" placeholder="说明什么情况下算命中,尽量写清边界" / > < / template > < / el-table-column >
< el-table-column label = "优先级" width = "130" > < template # default = "{ row }" > < el-select v-model = "row.priority"><el-option label="紧急" value="urgent" /><el-option label="重要" value="important" /><el-option label="普通" value="normal" / > < / el-select > < / template > < / el-table-column >
< el-table-column label = "操作" width = "80" > < template # default = "{ $index }" > < el-button link type = "danger" @click ="removeRecognitionItem($index)" > 删除 < / el -button > < / template > < / el-table-column >
< / el-table >
< / section >
< label class = "attention-prompt-editor" >
< span > < strong > AI 筛选提示词 < / strong > < small > 系统会自动追加历史问答数据 、 安全边界和固定 JSON 输出格式 , 无需在提示词中写变量 。 < / small > < / span >
< el-input v-model = "config.promptTemplate" type="textarea" :rows="12" maxlength="8000" show -word -limit / >
< / label >
< / article >
< article v-if = "canPreview" class="attention-config-card" >
< header > < div > < h3 > 历史消息筛选预览 < / h3 > < p > 可使用当前页面尚未保存的关键词和提示词运行 , 不会创建人工关注记录 。 < / p > < / div > < el-button type = "primary" :loading = "previewLoading" :disabled = "!canRunPreview" @click ="runPreview" > 预览选中消息 < / el -button > < / header >
< div class = "attention-preview-search" > < el-input v-model = "keyword" clearable placeholder="搜索历史问题、用户姓名或手机号" @keyup.enter="loadPreviewMessages(1)" /><el-button @click="loadPreviewMessages(1)" > 查询 < / el -button > < span > 已选 { { selectedMessages . length } } / 20 < / span > < / div >
< el-table :data = "previewMessages" stripe @ selection -change = " onSelectionChange " >
< el-table-column type = "selection" width = "48" / >
< el-table-column prop = "question" label = "历史问题" min -width = " 280 " show -overflow -tooltip / >
< el-table-column prop = "userName" label = "用户" width = "120" / >
< el-table-column prop = "sessionTitle" label = "会话" min -width = " 180 " show -overflow -tooltip / >
< el-table-column label = "知识缺失" width = "100" > < template # default = "{ row }" > < el-tag : type = "row.knowledgeMissing ? 'warning' : 'info'" > { { row . knowledgeMissing ? "是" : "否" } } < / el-tag > < / template > < / el-table-column >
< el-table-column prop = "createdAt" label = "提问时间" width = "180" / >
< / el-table >
< AdminPagination :page = "pager.page" :page-size = "pager.pageSize" :total = "pager.total" @change ="loadPreviewMessages" / >
< / article >
< article v-if = "previewResults.length" class="attention-config-card preview-result-card" >
< header > < div > < h3 > 筛选结果 < / h3 > < p > 结果仅供调整规则和提示词 , 不会写入关注记录 。 < / p > < / div > < / header >
< el-table :data = "previewResults" stripe >
< el-table-column label = "结果" width = "100" > < template # default = "{ row }" > < el-tag : type = "row.needsAttention ? 'danger' : 'success'" > { { row . needsAttention ? "需关注" : "不关注" } } < / el-tag > < / template > < / el-table-column >
< el-table-column label = "优先级" width = "90" > < template # default = "{ row }" > { { priorityLabel ( row . priority ) } } < / template > < / el-table-column >
< el-table-column label = "来源" width = "110" > < template # default = "{ row }" > { { sourceLabel ( row . source ) } } < / template > < / el-table-column >
< el-table-column prop = "matchedItem" label = "命中项目" width = "140" / >
< el-table-column prop = "question" label = "历史问题" min -width = " 220 " show -overflow -tooltip / >
< el-table-column prop = "reason" label = "判断理由" min -width = " 220 " / >
< el-table-column prop = "summary" label = "问题摘要" min -width = " 220 " / >
< el-table-column label = "详情" width = "100" > < template # default = "{ row }" > < el-popover placement = "left" :width = "560" trigger = "click" > < template # reference > < el-button link type = "primary" > 提示词 / 原始输出 < / el-button > < / template > < div class = "attention-preview-detail" > < strong > 最终提示词 < / strong > < pre > { { row . renderedPrompt || '关键词规则命中,未调用 AI' } } < / pre > < strong > 模型原始输出 < / strong > < pre > { { row . rawOutput || '无' } } < / pre > < / div > < / el-popover > < / template > < / el-table-column >
< / el-table >
< / article >
< / section >
< / template >
< style scoped >
. attention - config - workspace { display : grid ; gap : 18 px ; }
. attention - config - card { padding : 20 px ; border : 1 px solid # e3e9e7 ; border - radius : 14 px ; background : # fff ; }
. attention - config - card > header { display : flex ; align - items : flex - start ; justify - content : space - between ; gap : 18 px ; margin - bottom : 18 px ; }
. attention - config - card h3 { margin : 0 ; color : # 1 f2d29 ; }
. attention - config - card p { margin : 6 px 0 0 ; color : # 73817 c ; font - size : 13 px ; }
. attention - switch - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 12 px ; }
. attention - switch - grid label { display : flex ; align - items : center ; justify - content : space - between ; gap : 16 px ; padding : 14 px ; border : 1 px solid # e6ece9 ; border - radius : 10 px ; }
. attention - switch - grid span , . attention - prompt - editor > span { display : grid ; gap : 5 px ; }
. attention - switch - grid small , . attention - term - grid small , . attention - prompt - editor small { color : # 82908 b ; font - weight : 400 ; }
. attention - term - grid { display : grid ; grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr ) ) ; gap : 14 px ; margin - top : 16 px ; }
. attention - term - grid label { display : grid ; gap : 7 px ; }
. attention - prompt - editor { display : grid ; gap : 10 px ; margin - top : 18 px ; }
. recognition - item - editor { display : grid ; gap : 10 px ; margin - top : 18 px ; }
. recognition - item - editor > header { display : flex ; align - items : center ; justify - content : space - between ; gap : 16 px ; }
. recognition - item - editor > header span { display : grid ; gap : 5 px ; }
. recognition - item - editor small { color : # 82908 b ; font - weight : 400 ; }
. attention - preview - search { display : grid ; grid - template - columns : minmax ( 260 px , 520 px ) auto 1 fr ; align - items : center ; gap : 10 px ; margin - bottom : 14 px ; }
. attention - preview - search span { color : # 73817 c ; font - size : 13 px ; }
. attention - preview - detail { display : grid ; gap : 8 px ; }
. attention - preview - detail pre { max - height : 300 px ; margin : 0 0 10 px ; padding : 10 px ; overflow : auto ; border - radius : 8 px ; background : # f6f8f7 ; white - space : pre - wrap ; word - break : break - word ; }
@ media ( max - width : 980 px ) { . attention - switch - grid , . attention - term - grid { grid - template - columns : 1 fr ; } }
< / style >