510 lines
14 KiB
Vue
510 lines
14 KiB
Vue
<!--
|
||
=============================================================================
|
||
企微IT智能服务台 — 知识库优化建议审核页 (P2-13)
|
||
=============================================================================
|
||
说明:知识库自动迭代建议的审核管理
|
||
- 查看待审核建议列表
|
||
- 审核通过/拒绝建议
|
||
- 统计分析
|
||
-->
|
||
<template>
|
||
<div class="knowledge-suggestions-page">
|
||
<!-- 页面标题 -->
|
||
<div class="page-title">知识库优化建议</div>
|
||
<div class="page-desc">AI分析标注数据和会话数据生成的优化建议,审核通过后自动更新知识库。</div>
|
||
|
||
<!-- 统计卡片 -->
|
||
<div class="stats-cards">
|
||
<el-card class="stat-card">
|
||
<div class="stat-card-content">
|
||
<div class="stat-value">{{ stats.total }}</div>
|
||
<div class="stat-label">总建议数</div>
|
||
</div>
|
||
</el-card>
|
||
<el-card class="stat-card stat-card-warning">
|
||
<div class="stat-card-content">
|
||
<div class="stat-value">{{ stats.pending }}</div>
|
||
<div class="stat-label">待审核</div>
|
||
</div>
|
||
</el-card>
|
||
<el-card class="stat-card stat-card-success">
|
||
<div class="stat-card-content">
|
||
<div class="stat-value">{{ stats.approved }}</div>
|
||
<div class="stat-label">已通过</div>
|
||
</div>
|
||
</el-card>
|
||
<el-card class="stat-card stat-card-danger">
|
||
<div class="stat-card-content">
|
||
<div class="stat-value">{{ stats.rejected }}</div>
|
||
<div class="stat-label">已拒绝</div>
|
||
</div>
|
||
</el-card>
|
||
</div>
|
||
|
||
<!-- 顶部操作栏 -->
|
||
<div class="toolbar">
|
||
<!-- 状态筛选 -->
|
||
<el-radio-group v-model="filters.status" @change="loadSuggestions">
|
||
<el-radio-button value="">全部</el-radio-button>
|
||
<el-radio-button value="pending">待审核</el-radio-button>
|
||
<el-radio-button value="approved">已通过</el-radio-button>
|
||
<el-radio-button value="rejected">已拒绝</el-radio-button>
|
||
<el-radio-button value="applied">已应用</el-radio-button>
|
||
</el-radio-group>
|
||
|
||
<!-- 触发分析按钮 -->
|
||
<el-button type="primary" @click="triggerAnalysis">
|
||
<el-icon><Refresh /></el-icon>
|
||
触发AI分析
|
||
</el-button>
|
||
</div>
|
||
|
||
<!-- 建议列表 -->
|
||
<el-table :data="suggestions" v-loading="loading" style="width: 100%">
|
||
<el-table-column prop="suggestion_type" label="类型" width="100">
|
||
<template #default="{ row }">
|
||
<el-tag :type="getTypeTagType(row.suggestion_type)">
|
||
{{ getTypeLabel(row.suggestion_type) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column prop="title" label="标题" min-width="200" />
|
||
|
||
<el-table-column prop="category" label="分类" width="100">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" type="info">{{ row.category }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column prop="status" label="状态" width="100">
|
||
<template #default="{ row }">
|
||
<el-tag :type="getStatusTagType(row.status)">
|
||
{{ getStatusLabel(row.status) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column prop="source_type" label="来源" width="120">
|
||
<template #default="{ row }">
|
||
<span>{{ getSourceLabel(row.source_type) }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column prop="reason" label="生成理由" min-width="200" show-overflow-tooltip />
|
||
|
||
<el-table-column prop="created_at" label="创建时间" width="160">
|
||
<template #default="{ row }">
|
||
{{ formatDate(row.created_at) }}
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column label="操作" width="180" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button size="small" @click="viewDetail(row)">查看</el-button>
|
||
<template v-if="row.status === 'pending'">
|
||
<el-button size="small" type="success" @click="approveSuggestion(row)">
|
||
通过
|
||
</el-button>
|
||
<el-button size="small" type="danger" @click="openRejectDialog(row)">
|
||
拒绝
|
||
</el-button>
|
||
</template>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<!-- 分页 -->
|
||
<div class="pagination-wrapper">
|
||
<el-pagination
|
||
v-model:current-page="pagination.page"
|
||
v-model:page-size="pagination.pageSize"
|
||
:total="pagination.total"
|
||
:page-sizes="[10, 20, 50]"
|
||
layout="total, sizes, prev, pager, next"
|
||
@size-change="loadSuggestions"
|
||
@current-change="loadSuggestions"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 详情对话框 -->
|
||
<el-dialog v-model="detailDialogVisible" title="建议详情" width="600px">
|
||
<el-descriptions :column="1" border v-if="currentSuggestion">
|
||
<el-descriptions-item label="类型">
|
||
<el-tag :type="getTypeTagType(currentSuggestion.suggestion_type)">
|
||
{{ getTypeLabel(currentSuggestion.suggestion_type) }}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="标题">{{ currentSuggestion.title }}</el-descriptions-item>
|
||
<el-descriptions-item label="分类">{{ currentSuggestion.category }}</el-descriptions-item>
|
||
<el-descriptions-item label="状态">
|
||
<el-tag :type="getStatusTagType(currentSuggestion.status)">
|
||
{{ getStatusLabel(currentSuggestion.status) }}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="来源">{{ getSourceLabel(currentSuggestion.source_type) }}</el-descriptions-item>
|
||
<el-descriptions-item label="生成理由">{{ currentSuggestion.reason }}</el-descriptions-item>
|
||
<el-descriptions-item label="答案内容">
|
||
<div class="content-box">{{ currentSuggestion.content }}</div>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item v-if="currentSuggestion.tags?.length" label="标签">
|
||
<el-tag v-for="tag in currentSuggestion.tags" :key="tag" size="small" class="mr-2">
|
||
{{ tag }}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item v-if="currentSuggestion.reject_reason" label="拒绝理由">
|
||
<span style="color: var(--el-color-danger)">{{ currentSuggestion.reject_reason }}</span>
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
<template #footer>
|
||
<el-button @click="detailDialogVisible = false">关闭</el-button>
|
||
<template v-if="currentSuggestion?.status === 'pending'">
|
||
<el-button type="success" @click="approveSuggestion(currentSuggestion)">通过</el-button>
|
||
<el-button type="danger" @click="openRejectDialog(currentSuggestion)">拒绝</el-button>
|
||
</template>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 拒绝对话框 -->
|
||
<el-dialog v-model="rejectDialogVisible" title="拒绝建议" width="400px">
|
||
<el-form @submit.prevent="rejectSuggestion">
|
||
<el-form-item label="拒绝理由" required>
|
||
<el-input
|
||
v-model="rejectForm.reject_reason"
|
||
type="textarea"
|
||
:rows="3"
|
||
placeholder="请输入拒绝理由"
|
||
maxlength="500"
|
||
/>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="rejectDialogVisible = false">取消</el-button>
|
||
<el-button type="danger" @click="rejectSuggestion" :disabled="!rejectForm.reject_reason">
|
||
确认拒绝
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, onMounted } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { Refresh } from '@element-plus/icons-vue'
|
||
import { adminApi } from '@/api/admin'
|
||
|
||
// 状态筛选
|
||
const filters = reactive({
|
||
status: '',
|
||
})
|
||
|
||
// 列表数据
|
||
const loading = ref(false)
|
||
const suggestions = ref([])
|
||
|
||
// 分页
|
||
const pagination = reactive({
|
||
page: 1,
|
||
pageSize: 20,
|
||
total: 0,
|
||
})
|
||
|
||
// 统计数据
|
||
const stats = reactive({
|
||
total: 0,
|
||
pending: 0,
|
||
approved: 0,
|
||
rejected: 0,
|
||
applied: 0,
|
||
})
|
||
|
||
// 详情对话框
|
||
const detailDialogVisible = ref(false)
|
||
const currentSuggestion = ref(null)
|
||
|
||
// 拒绝对话框
|
||
const rejectDialogVisible = ref(false)
|
||
const rejectForm = reactive({
|
||
suggestion_id: '',
|
||
reject_reason: '',
|
||
})
|
||
|
||
// 加载建议列表
|
||
async function loadSuggestions() {
|
||
loading.value = true
|
||
try {
|
||
const params = {
|
||
page: pagination.page,
|
||
page_size: pagination.pageSize,
|
||
}
|
||
if (filters.status) {
|
||
params.status = filters.status
|
||
}
|
||
|
||
const res = await adminApi.getKnowledgeSuggestions(params)
|
||
if (res.code === 0) {
|
||
suggestions.value = res.data.items
|
||
pagination.total = res.data.total
|
||
}
|
||
} catch (error) {
|
||
console.error('加载建议列表失败:', error)
|
||
ElMessage.error('加载建议列表失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 加载统计数据
|
||
async function loadStats() {
|
||
try {
|
||
const res = await adminApi.getKnowledgeSuggestionStats()
|
||
if (res.code === 0) {
|
||
Object.assign(stats, res.data)
|
||
}
|
||
} catch (error) {
|
||
console.error('加载统计失败:', error)
|
||
}
|
||
}
|
||
|
||
// 触发AI分析
|
||
async function triggerAnalysis() {
|
||
try {
|
||
await ElMessageBox.confirm('将分析过去7天的标注数据和会话数据,生成优化建议。是否继续?', '触发分析', {
|
||
confirmButtonText: '确认',
|
||
cancelButtonText: '取消',
|
||
type: 'info',
|
||
})
|
||
|
||
const res = await adminApi.triggerKnowledgeIterationAnalysis({ days: 7 })
|
||
if (res.code === 0) {
|
||
ElMessage.success('分析完成')
|
||
loadSuggestions()
|
||
loadStats()
|
||
} else {
|
||
ElMessage.error(res.message || '分析失败')
|
||
}
|
||
} catch (error) {
|
||
if (error !== 'cancel') {
|
||
console.error('触发分析失败:', error)
|
||
ElMessage.error('触发分析失败')
|
||
}
|
||
}
|
||
}
|
||
|
||
// 查看详情
|
||
function viewDetail(row) {
|
||
currentSuggestion.value = row
|
||
detailDialogVisible.value = true
|
||
}
|
||
|
||
// 通过建议
|
||
async function approveSuggestion(row) {
|
||
try {
|
||
await ElMessageBox.confirm('审核通过后,该建议将应用到知识库。是否继续?', '审核通过', {
|
||
confirmButtonText: '确认',
|
||
cancelButtonText: '取消',
|
||
type: 'success',
|
||
})
|
||
|
||
const res = await adminApi.approveKnowledgeSuggestion(row.id)
|
||
if (res.code === 0) {
|
||
ElMessage.success('审核通过')
|
||
loadSuggestions()
|
||
loadStats()
|
||
detailDialogVisible.value = false
|
||
} else {
|
||
ElMessage.error(res.message || '操作失败')
|
||
}
|
||
} catch (error) {
|
||
if (error !== 'cancel') {
|
||
console.error('审核通过失败:', error)
|
||
ElMessage.error('操作失败')
|
||
}
|
||
}
|
||
}
|
||
|
||
// 打开拒绝对话框
|
||
function openRejectDialog(row) {
|
||
rejectForm.suggestion_id = row.id
|
||
rejectForm.reject_reason = ''
|
||
rejectDialogVisible.value = true
|
||
}
|
||
|
||
// 拒绝建议
|
||
async function rejectSuggestion() {
|
||
if (!rejectForm.reject_reason) {
|
||
ElMessage.warning('请输入拒绝理由')
|
||
return
|
||
}
|
||
|
||
try {
|
||
const res = await adminApi.rejectKnowledgeSuggestion(rejectForm.suggestion_id, {
|
||
reject_reason: rejectForm.reject_reason,
|
||
})
|
||
if (res.code === 0) {
|
||
ElMessage.success('已拒绝')
|
||
loadSuggestions()
|
||
loadStats()
|
||
rejectDialogVisible.value = false
|
||
detailDialogVisible.value = false
|
||
} else {
|
||
ElMessage.error(res.message || '操作失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('拒绝建议失败:', error)
|
||
ElMessage.error('操作失败')
|
||
}
|
||
}
|
||
|
||
// 工具函数
|
||
function getTypeLabel(type) {
|
||
const map = {
|
||
new_faq: '新增FAQ',
|
||
update: '更新',
|
||
outdated: '标记过时',
|
||
}
|
||
return map[type] || type
|
||
}
|
||
|
||
function getTypeTagType(type) {
|
||
const map = {
|
||
new_faq: 'success',
|
||
update: 'warning',
|
||
outdated: 'info',
|
||
}
|
||
return map[type] || ''
|
||
}
|
||
|
||
function getStatusLabel(status) {
|
||
const map = {
|
||
pending: '待审核',
|
||
approved: '已通过',
|
||
rejected: '已拒绝',
|
||
applied: '已应用',
|
||
}
|
||
return map[status] || status
|
||
}
|
||
|
||
function getStatusTagType(status) {
|
||
const map = {
|
||
pending: 'warning',
|
||
approved: 'success',
|
||
rejected: 'danger',
|
||
applied: 'info',
|
||
}
|
||
return map[status] || ''
|
||
}
|
||
|
||
function getSourceLabel(source) {
|
||
const map = {
|
||
annotation: '标注数据',
|
||
conversation: '会话数据',
|
||
ai_uncertain: 'AI不确定',
|
||
}
|
||
return map[source] || source
|
||
}
|
||
|
||
function formatDate(dateStr) {
|
||
if (!dateStr) return '-'
|
||
const date = new Date(dateStr)
|
||
return date.toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadSuggestions()
|
||
loadStats()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.knowledge-suggestions-page {
|
||
padding: 20px;
|
||
}
|
||
|
||
.page-title {
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.page-desc {
|
||
color: var(--text-secondary, #909399);
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
/* 统计卡片 */
|
||
.stats-cards {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 16px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.stat-card {
|
||
text-align: center;
|
||
}
|
||
|
||
.stat-card-content {
|
||
padding: 10px 0;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 28px;
|
||
font-weight: 600;
|
||
color: var(--el-color-primary);
|
||
}
|
||
|
||
.stat-card-warning .stat-value {
|
||
color: var(--el-color-warning);
|
||
}
|
||
|
||
.stat-card-success .stat-value {
|
||
color: var(--el-color-success);
|
||
}
|
||
|
||
.stat-card-danger .stat-value {
|
||
color: var(--el-color-danger);
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 14px;
|
||
color: var(--text-secondary, #909399);
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* 工具栏 */
|
||
.toolbar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
/* 分页 */
|
||
.pagination-wrapper {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
/* 内容框 */
|
||
.content-box {
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
padding: 8px;
|
||
background: var(--el-fill-color-light);
|
||
border-radius: 4px;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.mr-2 {
|
||
margin-right: 8px;
|
||
}
|
||
</style>
|