本地 RAG 知识库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

324 lines
17 KiB

<template>
<t-card title="💡 反馈运营看板" :bordered="false">
<!-- 筛选栏 -->
<div class="toolbar">
<t-select v-model="filterFeedbackType" :options="feedbackTypeOptions" placeholder="全部类型" clearable size="small" style="width:140px;" />
<t-select v-model="filterReasonCategory" :options="reasonCategoryOptions" placeholder="全部原因" clearable size="small" style="width:120px;" />
<t-select v-model="filterProcessed" :options="processedOptions" placeholder="全部状态" clearable size="small" style="width:120px;" />
<t-date-picker v-model="filterDate" size="small" style="width:240px;" placeholder="选择日期范围" />
<t-button size="small" @click="loadList(1)">搜索</t-button>
<t-button variant="outline" size="small" @click="loadList(1)">刷新</t-button>
</div>
<!-- 反馈列表 -->
<t-table :data="feedbacks" :columns="columns" row-key="conversationId" :loading="loading"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange"
empty="暂无反馈数据,用户在 SDK 端对 AI 回复点赞或点踩后将在此展示。">
<template #feedbackType="{ row }">
<t-tag :theme="row.feedbackType === 'THUMBS_UP' ? 'success' : 'danger'" variant="light">
{{ row.feedbackType === 'THUMBS_UP' ? '👍 有帮助' : '👎 没帮助' }}
</t-tag>
</template>
<template #reasonCategory="{ row }">
<t-tag v-if="row.reasonCategory" theme="warning" variant="light">{{ reasonLabel(row.reasonCategory) }}</t-tag>
<span v-else>-</span>
</template>
<template #reasonComment="{ row }">
<span v-if="row.reasonComment" :title="row.reasonComment" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;">{{ truncate(row.reasonComment, 40) }}</span>
<span v-else>-</span>
</template>
<template #processed="{ row }">
<t-tag :theme="row.processed ? 'success' : 'default'" variant="light">{{ row.processed ? '已处理' : '待处理' }}</t-tag>
</template>
<template #userQuestion="{ row }">
<span :title="row.userQuestion" style="max-width:250px;overflow:hidden;text-overflow:ellipsis;display:inline-block;">{{ truncate(row.userQuestion, 60) }}</span>
</template>
<template #assistantAnswer="{ row }">
<span :title="row.assistantAnswer" style="max-width:250px;overflow:hidden;text-overflow:ellipsis;display:inline-block;">{{ truncate(row.assistantAnswer, 60) }}</span>
</template>
<template #op="{ row }">
<t-space :size="4">
<t-button size="small" variant="text" @click="viewDetail(row)">查看</t-button>
<t-button size="small" variant="text" theme="primary" @click="createFaqFromRow(row)" v-if="row.feedbackType === 'THUMBS_UP'">生成 FAQ</t-button>
<t-button size="small" variant="text" theme="warning" @click="openFaqAction(row)" v-if="row.feedbackType === 'THUMBS_DOWN'">关联 FAQ</t-button>
<t-button size="small" variant="text" v-if="!row.processed" @click="markHandled(row)">标记已处理</t-button>
<t-button size="small" variant="text" theme="danger" @click="removeFeedback(row)">删除</t-button>
</t-space>
</template>
</t-table>
<!-- 消息详情弹窗 -->
<t-dialog v-model:visible="detailModal.visible" header="会话消息详情" width="800px" :footer="false">
<div v-if="!detailModal.messages?.length"><t-empty /></div>
<div v-else class="msg-list" style="max-height:400px;overflow-y:auto;">
<div v-for="(msg, i) in detailModal.messages" :key="i" :class="['msg-item', msg.messageType === 'USER' ? 'msg-user' : msg.messageType === 'ASSISTANT' ? 'msg-assistant' : 'msg-system']">
<div class="msg-meta">
<t-tag size="small" :theme="msg.messageType === 'USER' ? 'primary' : msg.messageType === 'ASSISTANT' ? 'success' : 'default'" variant="light">
{{ msg.messageType === 'USER' ? '用户' : msg.messageType === 'ASSISTANT' ? 'AI助手' : '系统' }}
</t-tag>
<t-tag v-if="msg.feedback" size="small" :theme="msg.feedback.feedbackType === 'THUMBS_UP' ? 'success' : 'danger'" variant="light">
{{ msg.feedback.feedbackType === 'THUMBS_UP' ? '👍 有帮助' : '👎 没帮助' }}
<span v-if="msg.feedback.reasonCategory"> · {{ reasonLabel(msg.feedback.reasonCategory) }}</span>
</t-tag>
</div>
<div v-if="msg.feedback && msg.feedback.reasonComment" class="msg-comment">
💬 补充说明:{{ msg.feedback.reasonComment }}
</div>
<div class="msg-body" v-html="msg.messageType === 'ASSISTANT' ? renderMarkdown(msg.content) : msg.content"></div>
</div>
</div>
</t-dialog>
<!-- FAQ 操作弹窗(复用) -->
<t-dialog v-model:visible="faqModal.visible" :header="faqModal.title" width="600px" :footer="false">
<t-form label-align="top">
<t-form-item v-if="faqModal.mode === 'create'" label="标准问题 *"><t-input v-model="faqModal.question" /></t-form-item>
<t-form-item v-if="faqModal.mode === 'create'" label="标准答案 *"><t-textarea v-model="faqModal.answer" :autosize="{ minRows: 3, maxRows: 6 }" /></t-form-item>
<t-form-item v-if="faqModal.mode === 'append'" label="选择目标 FAQ"><t-select v-model="faqModal.targetFaqId" :options="faqOptions" placeholder="搜索并选择" filterable /></t-form-item>
<t-form-item v-if="faqModal.mode === 'append'" label="相似问法"><t-input v-model="faqModal.similarQuestion" /></t-form-item>
<t-form-item v-if="faqModal.mode === 'edit'" label="选择目标 FAQ"><t-select v-model="faqModal.targetFaqId" :options="faqOptions" placeholder="搜索并选择" filterable /></t-form-item>
<t-form-item v-if="faqModal.mode === 'edit'" label="修正后的答案"><t-textarea v-model="faqModal.answer" :autosize="{ minRows: 3, maxRows: 6 }" /></t-form-item>
<t-form-item v-if="faqModal.mode === 'create'" label="分类"><t-select v-model="faqModal.categoryId" :options="categorySelectOptions" placeholder="不分类" clearable /></t-form-item>
<t-form-item v-if="faqModal.mode === 'create'" label="优先级"><t-input-number v-model="faqModal.priority" :min="0" style="width:100%;" /></t-form-item>
</t-form>
<div class="dialog-footer">
<t-button variant="outline" @click="faqModal.visible = false">取消</t-button>
<t-button theme="primary" @click="submitFaqAction" :disabled="!canSubmit">确定</t-button>
</div>
</t-dialog>
</t-card>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { listFeedbackConversations, getFeedbackMessages, markFeedbackProcessed, deleteFeedback } from '@/api/feedback'
import { listFaqs, createFaqFromFeedback, appendFaqFromFeedback, editFaqFromFeedback } from '@/api/faq'
import { getCategoryTree } from '@/api/category'
import { useConfirm } from '@/composables/useConfirm'
import { toast } from '@/utils/toast'
import { renderMarkdown } from '@/utils/markdown'
import { formatDate } from '@/utils/format'
const { confirm } = useConfirm()
const filterFeedbackType = ref(''); const filterReasonCategory = ref(''); const filterProcessed = ref(''); const filterDate = ref('')
const feedbacks = ref<any[]>([]); const loading = ref(false)
const page = ref(1); const pageSize = ref(20); const total = ref(0)
const detailModal = ref({ visible: false, messages: [] as any[] })
const faqModal = ref<{
visible: boolean; title: string; mode: 'create' | 'append' | 'edit';
question: string; answer: string; similarQuestion: string;
targetFaqId: string; categoryId: any; priority: number;
sourceRow: any;
}>({ visible: false, title: '', mode: 'create', question: '', answer: '', similarQuestion: '', targetFaqId: '', categoryId: null, priority: 0, sourceRow: null })
const faqList = ref<any[]>([]); const flatCategories = ref<any[]>([])
const feedbackTypeOptions = [
{ label: '👍 有帮助', value: 'THUMBS_UP' },
{ label: '👎 没帮助', value: 'THUMBS_DOWN' },
]
const reasonCategoryOptions = [
{ label: '不准确', value: 'inaccurate' },
{ label: '不相关', value: 'irrelevant' },
{ label: '不完整', value: 'incomplete' },
{ label: '其他', value: 'other' },
]
const processedOptions = [
{ label: '待处理', value: 'false' },
{ label: '已处理', value: 'true' },
]
const columns = [
{ colKey: 'feedbackType', title: '反馈', width: 90, cell: 'feedbackType' },
{ colKey: 'reasonCategory', title: '原因', width: 80, cell: 'reasonCategory' },
{ colKey: 'reasonComment', title: '补充说明', width: 160, ellipsis: true, cell: 'reasonComment' },
{ colKey: 'userQuestion', title: '用户问题', width: 200, ellipsis: true, cell: 'userQuestion' },
{ colKey: 'assistantAnswer', title: 'AI 回复', width: 200, ellipsis: true, cell: 'assistantAnswer' },
{ colKey: 'processed', title: '状态', width: 70, cell: 'processed' },
{ colKey: 'feedbackTime', title: '时间', width: 150, cell: (_: any, { row }: any) => formatDate(row.feedbackTime) },
{ colKey: 'op', title: '操作', width: 240, cell: 'op' },
]
const canSubmit = computed(() => {
const m = faqModal.value
if (m.mode === 'create') return !!(m.question && m.answer)
if (m.mode === 'append') return !!(m.targetFaqId && m.similarQuestion)
if (m.mode === 'edit') return !!(m.targetFaqId && m.answer)
return false
})
const categorySelectOptions = computed(() =>
flatCategories.value.map((c: any) => ({ label: c.path || c.name, value: c.id }))
)
const faqOptions = computed(() =>
faqList.value.map((f: any) => ({ label: f.question, value: String(f.id) }))
)
function reasonLabel(key: string): string {
const map: Record<string, string> = { inaccurate: '不准确', irrelevant: '不相关', incomplete: '不完整', other: '其他' }
return map[key] || key || '-'
}
function truncate(text: string, maxLen: number): string {
if (!text) return ''
return text.length > maxLen ? text.substring(0, maxLen) + '…' : text
}
/** 将后端返回的 snake_case 字段归一化为 camelCase,统一后续访问方式 */
function normalizeFeedbackRow(item: any): any {
return {
...item,
feedbackId: item.feedback_id || item.feedbackId,
feedbackType: item.feedback_type || item.feedbackType,
reasonCategory: item.reason_category || item.reasonCategory,
reasonComment: item.reason_comment || item.reasonComment,
feedbackTime: item.feedback_time || item.feedbackTime,
userQuestion: item.user_question || item.userQuestion,
assistantAnswer: item.assistant_answer || item.assistantAnswer,
conversationId: item.conversation_id || item.conversationId,
}
}
onMounted(() => { loadList(1); loadFaqList(); loadCategories() })
async function loadFaqList() { try { const r = await listFaqs(1, 100); if (r.success) faqList.value = r.data?.records || r.data || [] } catch {/* */} }
async function loadCategories() {
try { const r = await getCategoryTree(); if (r.success) { flatCategories.value = flattenTree(r.data) } } catch {/* */}
}
function flattenTree(tree: any[], prefix = ''): any[] {
const r: any[] = []
for (const n of (tree || [])) { const p = prefix ? `${prefix}/${n.name}` : n.name; r.push({ id: n.id, name: n.name, path: p }); if (n.children?.length) r.push(...flattenTree(n.children, p)) }
return r
}
async function loadList(p: number) {
page.value = p; loading.value = true
try {
const params: Record<string, any> = { page: p, size: pageSize.value }
if (filterFeedbackType.value) params.feedbackType = filterFeedbackType.value
if (filterReasonCategory.value) params.reasonCategory = filterReasonCategory.value
if (filterProcessed.value) params.processed = filterProcessed.value === 'true'
if (filterDate.value) {
// TDesign DatePicker 返回格式视配置而定,这里兼容数组和字符串
if (Array.isArray(filterDate.value) && filterDate.value.length >= 2) {
params.startDate = filterDate.value[0]
params.endDate = filterDate.value[1]
}
}
const r = await listFeedbackConversations(params)
if (r.success) {
feedbacks.value = (r.data?.records || []).map(normalizeFeedbackRow)
total.value = r.data?.total || 0
}
else toast(r.message || '查询失败', 'error')
} catch (e: any) { toast('加载失败:' + e.message, 'error') }
finally { loading.value = false }
}
function onPageChange(i: { current: number; pageSize: number }) { pageSize.value = i.pageSize; loadList(i.current) }
async function viewDetail(row: any) {
try {
const r = await getFeedbackMessages(row.conversationId || row.conversation_id)
detailModal.value.visible = true; detailModal.value.messages = r.data || []
} catch (e: any) { toast('加载消息失败:' + e.message, 'error') }
}
async function markHandled(row: any) {
const fbId = row.feedbackId || row.feedback_id
if (!fbId) { toast('未找到反馈记录', 'error'); return }
try {
await markFeedbackProcessed(fbId, { remark: '手动标记已处理' })
toast('已标记', 'success')
loadList(page.value)
} catch (e: any) { toast('操作失败:' + e.message, 'error') }
}
async function removeFeedback(row: any) {
const fbId = row.feedbackId || row.feedback_id
if (!fbId) { toast('未找到反馈记录', 'error'); return }
if (!await confirm('确认删除该反馈记录?')) return
try {
const r = await deleteFeedback(fbId)
if (r.success) { toast('删除成功', 'success'); loadList(page.value) }
else toast(r.message || '删除失败', 'error')
} catch (e: any) { toast('删除失败:' + e.message, 'error') }
}
function createFaqFromRow(row: any) {
// 预填问题来自后端 SQL 中的 user_question / userQuestion 字段名
faqModal.value = {
visible: true, title: '从点赞反馈生成 FAQ', mode: 'create',
question: truncate(row.userQuestion || row.user_question || '', 500),
answer: row.assistantAnswer || row.assistant_answer || '',
similarQuestion: '', targetFaqId: '', categoryId: null, priority: 0, sourceRow: row,
}
}
function openFaqAction(row: any) {
loadFaqList()
const q = truncate(row.userQuestion || row.user_question || '', 500)
if (row.reasonCategory === 'inaccurate' || row.reasonCategory === 'incomplete') {
faqModal.value = {
visible: true, title: '修正 FAQ 答案', mode: 'edit',
question: '', answer: row.assistantAnswer || row.assistant_answer || '',
similarQuestion: '', targetFaqId: '', categoryId: null, priority: 0, sourceRow: row,
}
} else {
faqModal.value = {
visible: true, title: '补充到已有 FAQ', mode: 'append',
question: '', answer: '', similarQuestion: q,
targetFaqId: '', categoryId: null, priority: 0, sourceRow: row,
}
}
}
async function submitFaqAction() {
const m = faqModal.value
const row = m.sourceRow
const userQ = (row.userQuestion || row.user_question || '').substring(0, 500)
const aiA = (row.assistantAnswer || row.assistant_answer || '').substring(0, 10000)
const fbId = row.feedbackId || row.feedback_id
try {
if (m.mode === 'create') {
const r = await createFaqFromFeedback({
feedbackId: fbId,
question: m.question, answer: m.answer,
categoryId: m.categoryId, priority: m.priority,
source: 'feedback_positive',
originalQuestion: userQ, originalAnswer: aiA,
})
if (r.success) { toast('FAQ 创建成功', 'success'); faqModal.value.visible = false; loadList(page.value) }
else toast(r.message || '创建失败', 'error')
} else if (m.mode === 'append') {
const r = await appendFaqFromFeedback(m.targetFaqId, {
feedbackId: fbId, similarQuestion: m.similarQuestion, originalAnswer: aiA,
})
if (r.success) { toast('补充成功', 'success'); faqModal.value.visible = false; loadList(page.value) }
else toast(r.message || '补充失败', 'error')
} else if (m.mode === 'edit') {
const r = await editFaqFromFeedback(m.targetFaqId, {
feedbackId: fbId, answer: m.answer, originalQuestion: userQ,
})
if (r.success) { toast('修正成功', 'success'); faqModal.value.visible = false; loadList(page.value) }
else toast(r.message || '修正失败', 'error')
}
} catch (e: any) { toast('操作失败:' + e.message, 'error') }
}
</script>
<style scoped>
.msg-item { padding: 12px; border-radius: 8px; margin-bottom: 12px; }
.msg-user { background: var(--color-info-bg); }
.msg-assistant { background: var(--color-bg-subtle); }
.msg-system { background: var(--color-warning-bg); }
.msg-meta { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.msg-comment { font-size: 12px; color: var(--color-text-secondary, #666); background: var(--color-warning-bg); border-radius: 6px; padding: 6px 10px; margin-bottom: 6px; white-space: pre-wrap; word-break: break-word; }
.msg-body { font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
.dialog-footer { margin-top: 16px; display: flex; justify-content: flex-end; gap: 8px; }
</style>