feat: improve chat feedback navigation

This commit is contained in:
2026-08-31 11:41:56 +08:00
parent 5740bd26e7
commit f4b2836c8a
11 changed files with 422 additions and 41 deletions

View File

@@ -17,9 +17,15 @@ const emit = defineEmits<{ "update:modelValue": [value: boolean] }>();
const detail = ref<ChatDetail | null>(null);
const loading = ref(false);
const messagesLoading = ref(false);
const aiLogsLoading = ref(false);
const aiLogDetailsLoading = ref<number[]>([]);
const activeAiLogIds = ref<number[]>([]);
const messageKeyword = ref("");
const appliedMessageKeyword = ref("");
const exporting = ref(false);
const bodyRef = ref<HTMLElement | null>(null);
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
const aiPager = reactive({ page: 1, pageSize: 20, total: 0 });
watch(
() => [props.modelValue, props.sessionId, props.focusMessageId] as const,
@@ -27,6 +33,10 @@ watch(
if (!open || !sessionId) return;
detail.value = null;
Object.assign(pager, { page: 1, total: 0 });
Object.assign(aiPager, { page: 1, total: 0 });
messageKeyword.value = "";
appliedMessageKeyword.value = "";
activeAiLogIds.value = [];
await loadDetail(sessionId, 1, pager.pageSize);
},
);
@@ -45,6 +55,7 @@ async function loadDetail(sessionId: number, page: number, pageSize: number) {
pageSize: messagePage?.pageSize ?? pageSize,
total: messagePage?.total ?? detail.value.messages.length,
});
void loadAiLogs(sessionId, 1, aiPager.pageSize);
await scrollToFocusedMessage();
} catch (error) {
ElMessage.error(
@@ -68,7 +79,11 @@ async function changeMessagePage(page: number, pageSize: number) {
if (!props.sessionId || !detail.value) return;
messagesLoading.value = true;
try {
const result = await api.chatMessages(props.sessionId, { page, pageSize });
const result = await api.chatMessages(props.sessionId, {
keyword: appliedMessageKeyword.value || undefined,
page,
pageSize,
});
detail.value = {
...detail.value,
messages: result.items,
@@ -80,7 +95,7 @@ async function changeMessagePage(page: number, pageSize: number) {
total: result.total,
});
await nextTick();
bodyRef.value?.scrollTo({ top: 0, behavior: "smooth" });
bodyRef.value?.querySelector(".conversation-title-row")?.scrollIntoView({ behavior: "smooth", block: "start" });
} catch (error) {
ElMessage.error(
error instanceof Error ? error.message : "聊天消息加载失败",
@@ -90,6 +105,75 @@ async function changeMessagePage(page: number, pageSize: number) {
}
}
async function searchMessages() {
appliedMessageKeyword.value = messageKeyword.value.trim();
await changeMessagePage(1, pager.pageSize);
}
async function clearMessageSearch() {
messageKeyword.value = "";
if (!appliedMessageKeyword.value) return;
appliedMessageKeyword.value = "";
await changeMessagePage(1, pager.pageSize);
}
function messageParts(content: string) {
const keyword = appliedMessageKeyword.value;
if (!keyword) return [{ text: content, matched: false }];
const parts: Array<{ text: string; matched: boolean }> = [];
const lowerContent = content.toLocaleLowerCase();
const lowerKeyword = keyword.toLocaleLowerCase();
let cursor = 0;
let index = lowerContent.indexOf(lowerKeyword);
while (index >= 0) {
if (index > cursor) parts.push({ text: content.slice(cursor, index), matched: false });
parts.push({ text: content.slice(index, index + keyword.length), matched: true });
cursor = index + keyword.length;
index = lowerContent.indexOf(lowerKeyword, cursor);
}
if (cursor < content.length) parts.push({ text: content.slice(cursor), matched: false });
return parts.length ? parts : [{ text: content, matched: false }];
}
async function loadAiLogs(sessionId: number, page: number, pageSize: number) {
aiLogsLoading.value = true;
try {
const result = await api.aiLogs({ sessionId, page, pageSize });
if (!detail.value || props.sessionId !== sessionId) return;
detail.value.aiLogs = result.items;
Object.assign(aiPager, {
page: result.page,
pageSize: result.pageSize,
total: result.total,
});
activeAiLogIds.value = [];
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "关联 AI 请求加载失败");
} finally {
aiLogsLoading.value = false;
}
}
async function handleAiLogChange(activeNames: string | number | Array<string | number>) {
const ids = (Array.isArray(activeNames) ? activeNames : [activeNames])
.map(Number)
.filter(Number.isFinite);
for (const id of ids) {
const log = detail.value?.aiLogs.find((item) => item.id === id);
if (!log || Object.prototype.hasOwnProperty.call(log, "prompt") || aiLogDetailsLoading.value.includes(id)) continue;
aiLogDetailsLoading.value = [...aiLogDetailsLoading.value, id];
try {
const fullLog = await api.aiLogDetail(id);
const index = detail.value?.aiLogs.findIndex((item) => item.id === id) ?? -1;
if (detail.value && index >= 0) detail.value.aiLogs[index] = fullLog;
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "AI 请求详情加载失败");
} finally {
aiLogDetailsLoading.value = aiLogDetailsLoading.value.filter((item) => item !== id);
}
}
}
async function exportConversation() {
if (!props.sessionId) return;
exporting.value = true;
@@ -193,6 +277,11 @@ async function exportConversation() {
</section></el-collapse-item
></el-collapse
>
<el-empty
v-if="!aiLogsLoading && !detail.aiLogs.length"
description="该会话暂无关联 AI 请求"
:image-size="64"
/>
<el-empty
v-else
description="该会话暂无用户生成的求助卡"
@@ -223,7 +312,22 @@ async function exportConversation() {
description="该会话暂无用户生成的班级分享稿"
:image-size="64"
/>
<h3 class="detail-title">完整对话</h3>
<div class="conversation-title-row">
<h3 class="detail-title">完整对话</h3>
<span v-if="appliedMessageKeyword">找到 {{ pager.total }} 条匹配消息</span>
</div>
<div class="conversation-search">
<el-input
v-model="messageKeyword"
clearable
maxlength="100"
placeholder="输入关键字搜索当前会话"
@keyup.enter="searchMessages"
@clear="clearMessageSearch"
/>
<el-button type="primary" :loading="messagesLoading" @click="searchMessages">搜索</el-button>
<el-button v-if="appliedMessageKeyword" @click="clearMessageSearch">返回完整对话</el-button>
</div>
<AdminPagination
class="chat-detail-pagination top"
:page="pager.page"
@@ -253,7 +357,7 @@ async function exportConversation() {
>触发人工关注的消息</el-tag
><span>{{ formatDateTime(message.createdAt) }}</span>
</div>
<pre>{{ message.content }}</pre>
<pre><template v-for="(part, index) in messageParts(message.content)" :key="index"><mark v-if="part.matched">{{ part.text }}</mark><template v-else>{{ part.text }}</template></template></pre>
<div class="message-extra">
<span>状态{{ message.messageStatus }}</span
><span v-if="message.tokenInput"
@@ -275,6 +379,9 @@ async function exportConversation() {
/>
<h3 class="detail-title">关联 AI 请求</h3>
<el-collapse
v-model="activeAiLogIds"
v-loading="aiLogsLoading"
@change="handleAiLogChange"
><el-collapse-item
v-for="log in detail.aiLogs"
:key="log.id"
@@ -287,8 +394,9 @@ async function exportConversation() {
><span>耗时{{ log.costMs || "-" }}ms</span
><span>时间{{ formatDateTime(log.createdAt) }}</span>
</div>
<p v-if="aiLogDetailsLoading.includes(log.id)" class="ai-log-loading">正在加载请求详情</p>
<section
v-if="log.retrievedChunks?.length"
v-else-if="log.retrievedChunks?.length"
class="retrieval-chunks"
>
<article
@@ -317,7 +425,7 @@ async function exportConversation() {
description="本条请求未保存召回片段"
:image-size="64"
/>
<pre class="prompt-preview">{{
<pre v-if="!aiLogDetailsLoading.includes(log.id)" class="prompt-preview">{{
log.prompt || " Prompt 记录"
}}</pre>
<p v-if="log.errorMessage" class="error-text">
@@ -325,6 +433,13 @@ async function exportConversation() {
</p></el-collapse-item
></el-collapse
>
<AdminPagination
v-if="aiPager.total"
:page="aiPager.page"
:page-size="aiPager.pageSize"
:total="aiPager.total"
@change="(page, size) => sessionId && loadAiLogs(sessionId, page, size)"
/>
</template>
<el-empty
v-else-if="!loading"
@@ -342,10 +457,54 @@ async function exportConversation() {
margin-bottom: 14px;
}
.conversation-title-row,
.conversation-search {
display: flex;
align-items: center;
gap: 10px;
}
.conversation-title-row {
justify-content: space-between;
}
.conversation-title-row > span {
color: #7b8494;
font-size: 13px;
}
.conversation-search {
margin: 0 0 12px;
}
.conversation-search :deep(.el-input) {
max-width: 360px;
}
.conversation-message mark {
padding: 1px 3px;
border-radius: 4px;
background: #ffe58f;
color: inherit;
}
.ai-log-loading {
padding: 16px;
color: #7b8494;
text-align: center;
}
.conversation-message.focus-message {
border: 2px solid #e6a23c;
background: #fffaf0;
box-shadow: 0 0 0 4px rgb(230 162 60 / 10%);
animation: focus-message-pulse 1.2s ease-out;
}
@keyframes focus-message-pulse {
0% { box-shadow: 0 0 0 0 rgb(230 162 60 / 38%); }
65% { box-shadow: 0 0 0 12px rgb(230 162 60 / 8%); }
100% { box-shadow: 0 0 0 4px rgb(230 162 60 / 10%); }
}
.conversation-message.focus-message .message-meta {

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { onMounted, ref } from "vue";
import { nextTick, onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type { FeedbackDetail, FeedbackItem } from "../types/api";
@@ -19,6 +19,9 @@ const readStatus = ref("all");
const dateFrom = ref("");
const dateTo = ref("");
const detail = ref<FeedbackDetail | null>(null);
const detailLoading = ref(false);
const detailBodyRef = ref<HTMLElement | null>(null);
const detailPager = reactive({ page: 1, pageSize: 20, total: 0 });
async function load() {
loading.value = true;
@@ -78,8 +81,49 @@ async function exportExcel() {
}
async function open(item: FeedbackItem) {
detail.value = await api.feedbackDetail(item.id);
item.isRead = true;
detail.value = null;
Object.assign(detailPager, { page: 1, total: 0 });
await loadDetail(item.id);
if (detail.value) item.isRead = true;
}
async function loadDetail(feedbackId: number, messagePage?: number, messagePageSize = detailPager.pageSize) {
detailLoading.value = true;
try {
detail.value = await api.feedbackDetail(feedbackId, {
messagePage,
messagePageSize,
});
Object.assign(detailPager, {
page: detail.value.messagesPage.page,
pageSize: detail.value.messagesPage.pageSize,
total: detail.value.messagesPage.total,
});
await scrollToTarget();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "反馈详情加载失败");
} finally {
detailLoading.value = false;
}
}
async function changeDetailPage(nextPage: number, nextPageSize: number) {
if (!detail.value) return;
await loadDetail(detail.value.id, nextPage, nextPageSize);
if (!detail.value.messages.some((message) => message.isTarget)) {
await nextTick();
detailBodyRef.value?.querySelector("article")?.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
async function scrollToTarget() {
await nextTick();
const target = detailBodyRef.value?.querySelector<HTMLElement>("[data-feedback-target='true']");
target?.scrollIntoView({ behavior: "smooth", block: "center" });
}
function closeDetail() {
detail.value = null;
}
async function remove(item: FeedbackItem) {
@@ -124,14 +168,19 @@ onMounted(load);
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button v-if="canDetail" link type="primary" @click="open(row)">查看</el-button><el-button v-if="canDelete" link type="danger" @click="remove(row)">删除</el-button></template></el-table-column>
</el-table>
<AdminPagination v-if="total" :page="page" :page-size="pageSize" :total="total" @change="changePage" />
<el-drawer :model-value="Boolean(detail)" title="反馈详情" size="620px" @close="detail = null">
<el-drawer :model-value="Boolean(detail) || detailLoading" title="反馈详情" size="680px" @close="closeDetail">
<template v-if="detail">
<div ref="detailBodyRef" v-loading="detailLoading" class="feedback-detail-body">
<section class="feedback-summary"><span>用户反馈</span><strong>{{ detail.content }}</strong><small>{{ detail.userName }} · {{ formatDateTime(detail.createdAt) }}</small></section>
<h3>当时对话记录</h3>
<div class="feedback-history"><article v-for="message in detail.messages" :key="message.id" :class="[message.role, { target: message.isTarget }]">
<div><b>{{ message.role === 'user' ? '用户' : 'AI' }}</b><el-tag v-if="message.isTarget" type="warning">用户反馈的这条回答</el-tag></div><p>{{ message.content }}</p>
<AdminPagination class="feedback-detail-pagination" :page="detailPager.page" :page-size="detailPager.pageSize" :total="detailPager.total" @change="changeDetailPage" />
<div class="feedback-history"><article v-for="message in detail.messages" :key="message.id" :class="[message.role, { target: message.isTarget }]" :data-feedback-target="message.isTarget ? 'true' : undefined">
<div><b>{{ message.role === 'user' ? '用户' : 'AI' }}</b><el-tag v-if="message.isTarget" type="warning">用户反馈的这条回答</el-tag><time>{{ formatDateTime(message.createdAt) }}</time></div><p>{{ message.content }}</p>
</article></div>
<AdminPagination class="feedback-detail-pagination" :page="detailPager.page" :page-size="detailPager.pageSize" :total="detailPager.total" @change="changeDetailPage" />
</div>
</template>
<div v-else v-loading="detailLoading" class="feedback-detail-loading"></div>
</el-drawer>
</section>
</template>
@@ -142,6 +191,10 @@ onMounted(load);
.feedback-toolbar { align-items:center; margin-bottom:18px; padding:14px 16px; border:1px solid #e7ebf0; border-radius:12px; background:#fafbfc; }
.feedback-toolbar > :deep(.el-select) { width:140px; }
.feedback-summary { display:grid; gap:9px; padding:16px; border-radius:12px; background:#f7f9fc; }.feedback-summary span,.feedback-summary small { color:#87909f; }
.feedback-history { display:grid; gap:12px; }.feedback-history article { padding:14px 16px; border:1px solid #e6eaf0; border-radius:12px; }.feedback-history article.user { margin-left:42px; background:#f5f8ff; }.feedback-history article.assistant { margin-right:42px; }.feedback-history article.target { border:2px solid #f0a020; background:#fffaf0; }.feedback-history article div { display:flex; align-items:center; justify-content:space-between; }.feedback-history p { white-space:pre-wrap; line-height:1.65; margin:8px 0 0; }
.feedback-detail-body { min-height:260px; }
.feedback-detail-loading { min-height:260px; }
.feedback-detail-pagination { margin:12px 0; }
.feedback-history { display:grid; gap:12px; }.feedback-history article { padding:14px 16px; border:1px solid #e6eaf0; border-radius:12px; }.feedback-history article.user { margin-left:42px; background:#f5f8ff; }.feedback-history article.assistant { margin-right:42px; }.feedback-history article.target { border:2px solid #f0a020; background:#fffaf0; box-shadow:0 0 0 4px rgb(240 160 32 / 10%); animation:feedback-target-pulse 1.2s ease-out; }.feedback-history article div { display:flex; align-items:center; gap:10px; }.feedback-history article time { margin-left:auto; color:#87909f; font-size:12px; }.feedback-history p { white-space:pre-wrap; line-height:1.65; margin:8px 0 0; }
@keyframes feedback-target-pulse { 0% { box-shadow:0 0 0 0 rgb(240 160 32 / 38%); } 65% { box-shadow:0 0 0 12px rgb(240 160 32 / 8%); } 100% { box-shadow:0 0 0 4px rgb(240 160 32 / 10%); } }
@media (max-width: 900px) { .feedback-toolbar { align-items:stretch; }.feedback-toolbar .record-date-range { width:100%; }.feedback-toolbar .record-date-field { flex:1; width:auto; }.feedback-toolbar .record-filter-actions { width:100%; } }
</style>

View File

@@ -342,7 +342,7 @@ export const api = {
chats: (query: ChatRecordQuery = {}) => request<PageResult<ChatRecord>>(`/admin/chat/list${queryString(query)}`),
chatDetail: (sessionId: number, query: { messagePage?: number; messagePageSize?: number; focusMessageId?: number } = {}) =>
request<ChatDetail>(`/admin/chat/${sessionId}${queryString(query)}`),
chatMessages: (sessionId: number, query: { page?: number; pageSize?: number } = {}) =>
chatMessages: (sessionId: number, query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
request<PageResult<ChatMessageRecord>>(`/admin/chat/${sessionId}/messages${queryString(query)}`),
exportChatDetail: (sessionId: number, filename = `完整对话_会话${sessionId}.xlsx`) =>
download(`/admin/chat/${sessionId}/export`, filename),
@@ -380,7 +380,8 @@ export const api = {
feedbackList: (query: { readStatus?: string; startDate?: string; endDate?: string; page?: number; pageSize?: number } = {}) => request<PageResult<import("../types/api").FeedbackItem>>(`/feedback/admin/list${queryString(query)}`),
exportFeedback: (query: { readStatus?: string; startDate: string; endDate: string }) =>
download(`/feedback/admin/export${queryString(query)}`, `反馈记录_${query.startDate}_${query.endDate}.xlsx`),
feedbackDetail: (id: number) => request<import("../types/api").FeedbackDetail>(`/feedback/admin/${id}`),
feedbackDetail: (id: number, query: { messagePage?: number; messagePageSize?: number } = {}) =>
request<import("../types/api").FeedbackDetail>(`/feedback/admin/${id}${queryString(query)}`),
deleteFeedback: (id: number) => request<null>(`/feedback/admin/${id}`, { method: "DELETE" }),
};

View File

@@ -924,4 +924,5 @@ export interface FeedbackItem {
export interface FeedbackDetail extends FeedbackItem {
sessionTitle: string;
messages: Array<{ id: number; role: "user" | "assistant"; content: string; createdAt: string; isTarget: boolean }>;
messagesPage: PageResult<{ id: number; role: "user" | "assistant"; content: string; createdAt: string; isTarget: boolean }>;
}