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
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>
|