Browse Source

迁移到 TDesign 架构时漏掉了文档上传路由和菜单入口

TDesign-Vue-Next-1.20.6
wanghanlin 2 weeks ago
parent
commit
b5abf32530
  1. 266
      client/dist/chatbot-sdk.js
  2. 2
      client/dist/chatbot-sdk.min.js
  3. 24
      client/src/api.ts
  4. 84
      client/src/chat.ts
  5. 87
      client/src/dom.ts
  6. 14
      client/src/i18n.ts
  7. 95
      client/src/styles.ts
  8. 4
      client/src/types.ts
  9. 2
      frontend/auto-imports.d.ts
  10. 2
      frontend/src/router/index.ts
  11. 1
      frontend/src/stores/navigation.ts
  12. 11
      frontend/src/views/ChatPanel.vue
  13. 18
      frontend/src/views/DashboardPanel.vue
  14. 20
      src/main/java/com/wok/supportbot/app/ChatPipeline.java
  15. 33
      src/main/java/com/wok/supportbot/rag/RagPipeline.java
  16. 4
      src/main/java/com/wok/supportbot/security/SdkAuthFilter.java
  17. 202
      src/main/java/com/wok/supportbot/service/DashboardService.java
  18. 22
      src/main/java/com/wok/supportbot/service/MessageFeedbackService.java
  19. 266
      src/main/resources/static/sdk/chatbot-sdk.js
  20. 2
      src/main/resources/static/sdk/chatbot-sdk.min.js
  21. 227
      src/test/java/com/wok/supportbot/MessageFeedbackTests.java

266
client/dist/chatbot-sdk.js

@ -199,6 +199,13 @@ var ChatbotSDK = (function () {
// 消息反馈 // 消息反馈
feedback_up: '有帮助', feedback_up: '有帮助',
feedback_down: '没帮助', feedback_down: '没帮助',
feedback_reason_title: '请选择不满意的原因:',
feedback_reason_inaccurate: '信息错误',
feedback_reason_irrelevant: '不相关',
feedback_reason_incomplete: '不完整',
feedback_reason_other: '其他',
feedback_reason_comment_placeholder: '补充说明(可选)',
feedback_reason_submit: '提交',
// 提示气泡 // 提示气泡
teaser_text: '有什么可以帮你的吗?', teaser_text: '有什么可以帮你的吗?',
new_msg_announce: '收到新消息', new_msg_announce: '收到新消息',
@ -266,6 +273,13 @@ var ChatbotSDK = (function () {
// Feedback // Feedback
feedback_up: 'Helpful', feedback_up: 'Helpful',
feedback_down: 'Not helpful', feedback_down: 'Not helpful',
feedback_reason_title: 'Please tell us why:',
feedback_reason_inaccurate: 'Inaccurate',
feedback_reason_irrelevant: 'Irrelevant',
feedback_reason_incomplete: 'Incomplete',
feedback_reason_other: 'Other',
feedback_reason_comment_placeholder: 'Additional comments (optional)',
feedback_reason_submit: 'Submit',
// Teaser // Teaser
teaser_text: 'How can I help you?', teaser_text: 'How can I help you?',
new_msg_announce: 'New message received', new_msg_announce: 'New message received',
@ -457,11 +471,12 @@ var ChatbotSDK = (function () {
Object.assign(headers, options.headers); Object.assign(headers, options.headers);
} }
} }
if ((currentConfig === null || currentConfig === void 0 ? void 0 : currentConfig.token) && url.includes('/ai/')) {
// 为受 SdkAuthFilter 保护的路径自动注入 Bearer Token(/ai/** 和 /feedback)
if ((currentConfig === null || currentConfig === void 0 ? void 0 : currentConfig.token) && (url.includes('/ai/') || url.endsWith('/feedback'))) {
headers['Authorization'] = `Bearer ${currentConfig.token}`; headers['Authorization'] = `Bearer ${currentConfig.token}`;
} }
const response = await fetch(url, Object.assign(Object.assign({}, options), { headers, signal: controller.signal, mode: 'cors', credentials: 'include' })); const response = await fetch(url, Object.assign(Object.assign({}, options), { headers, signal: controller.signal, mode: 'cors', credentials: 'include' }));
if (response.status === 401 && url.includes('/ai/')) {
if (response.status === 401 && (url.includes('/ai/') || url.endsWith('/feedback'))) {
logger.error('SDK 认证失败:Token 缺失或已过期,请重新调用 /open-api/auth/token 换取 Token'); logger.error('SDK 认证失败:Token 缺失或已过期,请重新调用 /open-api/auth/token 换取 Token');
} }
return response; return response;
@ -724,21 +739,26 @@ var ChatbotSDK = (function () {
} }
// ==================== P0-002: 消息反馈 ==================== // ==================== P0-002: 消息反馈 ====================
/** /**
* 提交消息反馈点赞/点踩
* 提交消息反馈点赞/点踩支持点踩原因分类
*/ */
async function submitFeedbackApi(messageId, feedbackType) {
async function submitFeedbackApi(messageId, feedbackType, reasonCategory, reasonComment) {
if (!currentConfig) if (!currentConfig)
return false; return false;
const url = buildUrl('/feedback'); const url = buildUrl('/feedback');
try { try {
const body = {
messageId: String(messageId),
conversationId: currentConfig.chatId,
feedbackType,
};
if (reasonCategory)
body.reasonCategory = reasonCategory;
if (reasonComment)
body.reasonComment = reasonComment;
const response = await safeFetch(url, { const response = await safeFetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messageId: String(messageId),
conversationId: currentConfig.chatId,
feedbackType,
}),
body: JSON.stringify(body),
}); });
if (!response.ok) { if (!response.ok) {
logger.error(`反馈提交失败 status=${response.status}`); logger.error(`反馈提交失败 status=${response.status}`);
@ -2377,6 +2397,101 @@ var ChatbotSDK = (function () {
.csk-feedback-btn:active { transform: scale(0.9); } .csk-feedback-btn:active { transform: scale(0.9); }
.csk-msg--streaming .csk-feedback-btn { display: none; } .csk-msg--streaming .csk-feedback-btn { display: none; }
/* ========== 点踩原因选择面板 ========== */
.csk-feedback-reason {
position: relative;
margin-top: 6px;
padding: 10px 12px;
background: var(--csk-bg);
border: 1px solid var(--csk-border);
border-radius: 8px;
animation: cskFadeIn 0.15s ease;
}
.csk-feedback-reason__title {
font-size: 12px;
color: var(--csk-text-muted);
margin-bottom: 8px;
}
.csk-feedback-reason__options {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.csk-feedback-reason__btn {
padding: 4px 10px;
font-size: 12px;
border: 1px solid var(--csk-border);
border-radius: 14px;
background: var(--csk-bg);
color: var(--csk-text);
cursor: pointer;
transition: all 0.15s ease;
}
.csk-feedback-reason__btn:hover {
border-color: var(--csk-primary);
color: var(--csk-primary);
background: rgba(var(--csk-primary-rgb), 0.08);
}
.csk-feedback-reason__comment-row {
display: flex;
gap: 6px;
}
.csk-feedback-reason__comment {
flex: 1;
padding: 4px 8px;
font-size: 12px;
border: 1px solid var(--csk-border);
border-radius: 6px;
background: var(--csk-bg);
color: var(--csk-text);
outline: none;
}
.csk-feedback-reason__comment:focus { border-color: var(--csk-primary); }
.csk-feedback-reason__submit {
padding: 4px 10px;
font-size: 12px;
border: none;
border-radius: 6px;
background: var(--csk-primary);
color: #fff;
cursor: pointer;
white-space: nowrap;
}
.csk-feedback-reason__close {
position: absolute;
top: 8px;
right: 8px;
width: 18px;
height: 18px;
border: none;
background: none;
color: var(--csk-text-muted);
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.csk-feedback-reason__close:hover { color: var(--csk-text); background: var(--csk-hover); }
.csk-dark .csk-feedback-reason { background: var(--csk-bg); border-color: var(--csk-border); }
.csk-dark .csk-feedback-reason__btn {
background: rgba(255,255,255,0.06);
border-color: var(--csk-border);
color: var(--csk-text);
}
.csk-dark .csk-feedback-reason__btn:hover {
border-color: var(--csk-primary-light);
color: var(--csk-primary-light);
background: rgba(var(--csk-primary-rgb), 0.15);
}
.csk-dark .csk-feedback-reason__comment {
background: rgba(255,255,255,0.06);
border-color: var(--csk-border);
color: var(--csk-text);
}
/* ========== 历史会话搜索框 ========== */ /* ========== 历史会话搜索框 ========== */
.csk-history-panel__search-wrap { .csk-history-panel__search-wrap {
padding: 8px 10px; padding: 8px 10px;
@ -3740,6 +3855,80 @@ var ChatbotSDK = (function () {
if (existing) if (existing)
existing.remove(); existing.remove();
} }
// ==================== 反馈原因选择面板 ====================
/** 点踩原因选项(与后端 MessageFeedback.reasonCategory 枚举一致) */
const REASON_OPTIONS = [
{ key: 'inaccurate', labelKey: 'feedback_reason_inaccurate' },
{ key: 'irrelevant', labelKey: 'feedback_reason_irrelevant' },
{ key: 'incomplete', labelKey: 'feedback_reason_incomplete' },
{ key: 'other', labelKey: 'feedback_reason_other' },
];
/**
* 在消息气泡下方展示点踩原因选择面板
* 用户选择原因后回调 onConfirm(reason, comment)取消时回调 undefined 参数
*/
function showFeedbackReasonPanel(wrapper, msgId, onConfirm) {
// 移除已存在的原因面板
const existing = wrapper.querySelector('.csk-feedback-reason');
if (existing)
existing.remove();
const panel = document.createElement('div');
panel.className = 'csk-feedback-reason';
// 标题
const title = document.createElement('div');
title.className = 'csk-feedback-reason__title';
title.textContent = t('feedback_reason_title');
panel.appendChild(title);
// 原因选项按钮
const optionsRow = document.createElement('div');
optionsRow.className = 'csk-feedback-reason__options';
REASON_OPTIONS.forEach((opt) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'csk-feedback-reason__btn';
btn.textContent = t(opt.labelKey);
btn.addEventListener('click', (e) => {
e.stopPropagation();
onConfirm(opt.key);
panel.remove();
});
optionsRow.appendChild(btn);
});
panel.appendChild(optionsRow);
// 补充说明输入框(可选)
const commentRow = document.createElement('div');
commentRow.className = 'csk-feedback-reason__comment-row';
const commentInput = document.createElement('input');
commentInput.type = 'text';
commentInput.className = 'csk-feedback-reason__comment';
commentInput.placeholder = t('feedback_reason_comment_placeholder');
commentInput.maxLength = 200;
commentRow.appendChild(commentInput);
const submitBtn = document.createElement('button');
submitBtn.type = 'button';
submitBtn.className = 'csk-feedback-reason__submit';
submitBtn.textContent = t('feedback_reason_submit');
submitBtn.addEventListener('click', (e) => {
e.stopPropagation();
const comment = commentInput.value.trim() || undefined;
onConfirm('other', comment);
panel.remove();
});
commentRow.appendChild(submitBtn);
panel.appendChild(commentRow);
// 关闭按钮
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'csk-feedback-reason__close';
closeBtn.setAttribute('aria-label', t('close'));
closeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
panel.remove();
});
panel.appendChild(closeBtn);
wrapper.appendChild(panel);
}
const STORAGE_PREFIX = 'csk_history_'; const STORAGE_PREFIX = 'csk_history_';
const MAX_MESSAGES = 200; const MAX_MESSAGES = 200;
@ -4330,6 +4519,7 @@ var ChatbotSDK = (function () {
/** /**
* 处理消息反馈切换 AI 消息的点赞/点踩状态 * 处理消息反馈切换 AI 消息的点赞/点踩状态
* 前端状态持久化存入 messages 数组 + localStorage+ 调用后端 API 记录反馈 * 前端状态持久化存入 messages 数组 + localStorage+ 调用后端 API 记录反馈
* 点踩时弹出原因选择弹窗点赞直接提交
*/ */
function handleFeedback(msgId, value) { function handleFeedback(msgId, value) {
if (!messagesContainer$1) if (!messagesContainer$1)
@ -4337,20 +4527,50 @@ var ChatbotSDK = (function () {
const msg = messages.find(m => m.id === msgId && m.role === 'ai'); const msg = messages.find(m => m.id === msgId && m.role === 'ai');
if (!msg) if (!msg)
return; return;
// 切换逻辑:同值取消,异值切换
const newValue = msg.feedback === value ? undefined : value;
msg.feedback = newValue;
// 更新 DOM 状态
const wrapper = messagesContainer$1.querySelector(`[data-csk-msg-id="${msgId}"]`);
if (wrapper)
updateFeedbackUI(wrapper, newValue);
// 持久化(localStorage)
if (config$1)
saveMessages(config$1.integrateId, messages);
// 调用后端 API 记录反馈
if (newValue) {
const feedbackType = newValue === 'up' ? 'THUMBS_UP' : 'THUMBS_DOWN';
submitFeedbackApi(String(msgId), feedbackType).then(success => {
if (value === 'up') {
// 点赞:直接切换提交,无需原因
const newValue = msg.feedback === 'up' ? undefined : 'up';
msg.feedback = newValue;
msg.feedbackReason = undefined;
msg.feedbackComment = undefined;
const wrapper = messagesContainer$1.querySelector(`[data-csk-msg-id="${msgId}"]`);
if (wrapper)
updateFeedbackUI(wrapper, newValue);
if (config$1)
saveMessages(config$1.integrateId, messages);
if (newValue) {
submitFeedbackApi(String(msgId), 'THUMBS_UP').then(success => {
});
}
return;
}
// 点踩:弹出原因选择弹窗
if (value === 'down') {
const wrapper = messagesContainer$1.querySelector(`[data-csk-msg-id="${msgId}"]`);
if (!wrapper)
return;
// 如果已点踩,取消(同 SDK 现有行为)
if (msg.feedback === 'down') {
msg.feedback = undefined;
msg.feedbackReason = undefined;
msg.feedbackComment = undefined;
if (wrapper)
updateFeedbackUI(wrapper, undefined);
if (config$1)
saveMessages(config$1.integrateId, messages);
return;
}
// 弹出原因选择面板
showFeedbackReasonPanel(wrapper, msgId, (reason, comment) => {
msg.feedback = 'down';
msg.feedbackReason = reason;
msg.feedbackComment = comment;
if (wrapper)
updateFeedbackUI(wrapper, 'down');
if (config$1)
saveMessages(config$1.integrateId, messages);
submitFeedbackApi(String(msgId), 'THUMBS_DOWN', reason, comment).then(success => {
});
}); });
} }
} }

2
client/dist/chatbot-sdk.min.js
File diff suppressed because it is too large
View File

24
client/src/api.ts

@ -171,7 +171,8 @@ async function safeFetch(
Object.assign(headers, options.headers as Record<string, string>); Object.assign(headers, options.headers as Record<string, string>);
} }
} }
if (currentConfig?.token && url.includes('/ai/')) {
// 为受 SdkAuthFilter 保护的路径自动注入 Bearer Token(/ai/** 和 /feedback)
if (currentConfig?.token && (url.includes('/ai/') || url.endsWith('/feedback'))) {
headers['Authorization'] = `Bearer ${currentConfig.token}`; headers['Authorization'] = `Bearer ${currentConfig.token}`;
} }
@ -182,7 +183,7 @@ async function safeFetch(
mode: 'cors', mode: 'cors',
credentials: 'include', credentials: 'include',
}); });
if (response.status === 401 && url.includes('/ai/')) {
if (response.status === 401 && (url.includes('/ai/') || url.endsWith('/feedback'))) {
logger.error('SDK 认证失败:Token 缺失或已过期,请重新调用 /open-api/auth/token 换取 Token'); logger.error('SDK 认证失败:Token 缺失或已过期,请重新调用 /open-api/auth/token 换取 Token');
} }
return response; return response;
@ -471,23 +472,28 @@ export async function fetchRagSources(message: string, categoryId?: number): Pro
// ==================== P0-002: 消息反馈 ==================== // ==================== P0-002: 消息反馈 ====================
/** /**
* /
* /
*/ */
export async function submitFeedbackApi( export async function submitFeedbackApi(
messageId: string, messageId: string,
feedbackType: 'THUMBS_UP' | 'THUMBS_DOWN'
feedbackType: 'THUMBS_UP' | 'THUMBS_DOWN',
reasonCategory?: string,
reasonComment?: string,
): Promise<boolean> { ): Promise<boolean> {
if (!currentConfig) return false; if (!currentConfig) return false;
const url = buildUrl('/feedback'); const url = buildUrl('/feedback');
try { try {
const body: Record<string, string> = {
messageId: String(messageId),
conversationId: currentConfig.chatId,
feedbackType,
};
if (reasonCategory) body.reasonCategory = reasonCategory;
if (reasonComment) body.reasonComment = reasonComment;
const response = await safeFetch(url, { const response = await safeFetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messageId: String(messageId),
conversationId: currentConfig.chatId,
feedbackType,
}),
body: JSON.stringify(body),
}); });
if (!response.ok) { if (!response.ok) {
logger.error(`反馈提交失败 status=${response.status}`); logger.error(`反馈提交失败 status=${response.status}`);

84
client/src/chat.ts

@ -39,6 +39,8 @@ import {
showOfflineBanner, showOfflineBanner,
hideOfflineBanner, hideOfflineBanner,
renderSuggestions, renderSuggestions,
removeSuggestions,
showFeedbackReasonPanel,
HistoryItemData, HistoryItemData,
} from './dom'; } from './dom';
import { saveMessages, loadMessages, clearMessages } from './storage'; import { saveMessages, loadMessages, clearMessages } from './storage';
@ -353,6 +355,7 @@ function announceMessage(text: string): void {
/** /**
* AI / * AI /
* messages + localStorage+ API * messages + localStorage+ API
*
*/ */
export function handleFeedback(msgId: string, value: 'up' | 'down'): void { export function handleFeedback(msgId: string, value: 'up' | 'down'): void {
if (!messagesContainer) return; if (!messagesContainer) return;
@ -360,29 +363,66 @@ export function handleFeedback(msgId: string, value: 'up' | 'down'): void {
const msg = messages.find(m => m.id === msgId && m.role === 'ai'); const msg = messages.find(m => m.id === msgId && m.role === 'ai');
if (!msg) return; if (!msg) return;
// 切换逻辑:同值取消,异值切换
const newValue = msg.feedback === value ? undefined : value;
msg.feedback = newValue;
// 更新 DOM 状态
const wrapper = messagesContainer.querySelector(`[data-csk-msg-id="${msgId}"]`) as HTMLElement;
if (wrapper) updateFeedbackUI(wrapper, newValue);
// 持久化(localStorage)
if (config) saveMessages(config.integrateId, messages);
// 调用后端 API 记录反馈
if (newValue) {
const feedbackType = newValue === 'up' ? 'THUMBS_UP' : 'THUMBS_DOWN';
submitFeedbackApi(String(msgId), feedbackType).then(success => {
if (success) {
logger.info(`消息反馈已提交 msgId=${msgId} value=${newValue}`);
} else {
logger.warn(`消息反馈提交失败 msgId=${msgId}(本地状态已更新)`);
}
if (value === 'up') {
// 点赞:直接切换提交,无需原因
const newValue = msg.feedback === 'up' ? undefined : 'up';
msg.feedback = newValue;
msg.feedbackReason = undefined;
msg.feedbackComment = undefined;
const wrapper = messagesContainer.querySelector(`[data-csk-msg-id="${msgId}"]`) as HTMLElement;
if (wrapper) updateFeedbackUI(wrapper, newValue);
if (config) saveMessages(config.integrateId, messages);
if (newValue) {
submitFeedbackApi(String(msgId), 'THUMBS_UP').then(success => {
if (success) {
logger.info(`消息反馈已提交 msgId=${msgId} value=up`);
} else {
logger.warn(`消息反馈提交失败 msgId=${msgId}(本地状态已更新)`);
}
});
} else {
logger.info(`消息反馈已取消 msgId=${msgId}`);
}
return;
}
// 点踩:弹出原因选择弹窗
if (value === 'down') {
const wrapper = messagesContainer.querySelector(`[data-csk-msg-id="${msgId}"]`) as HTMLElement;
if (!wrapper) return;
// 如果已点踩,取消(同 SDK 现有行为)
if (msg.feedback === 'down') {
msg.feedback = undefined;
msg.feedbackReason = undefined;
msg.feedbackComment = undefined;
if (wrapper) updateFeedbackUI(wrapper, undefined);
if (config) saveMessages(config.integrateId, messages);
logger.info(`消息反馈已取消 msgId=${msgId}`);
return;
}
// 弹出原因选择面板
showFeedbackReasonPanel(wrapper, msgId, (reason, comment) => {
msg.feedback = 'down';
msg.feedbackReason = reason;
msg.feedbackComment = comment;
if (wrapper) updateFeedbackUI(wrapper, 'down');
if (config) saveMessages(config.integrateId, messages);
submitFeedbackApi(String(msgId), 'THUMBS_DOWN', reason, comment).then(success => {
if (success) {
logger.info(`消息反馈已提交 msgId=${msgId} value=down reason=${reason}`);
} else {
logger.warn(`消息反馈提交失败 msgId=${msgId}(本地状态已更新)`);
}
});
}); });
} else {
logger.info(`消息反馈已取消 msgId=${msgId}`);
} }
} }
function autoResizeInput(): void { function autoResizeInput(): void {

87
client/src/dom.ts

@ -1403,3 +1403,90 @@ export function removeSuggestions(wrapper: HTMLElement): void {
const existing = wrapper.querySelector('.csk-suggestions'); const existing = wrapper.querySelector('.csk-suggestions');
if (existing) existing.remove(); if (existing) existing.remove();
} }
// ==================== 反馈原因选择面板 ====================
/** 点踩原因选项(与后端 MessageFeedback.reasonCategory 枚举一致) */
const REASON_OPTIONS: { key: string; labelKey: string }[] = [
{ key: 'inaccurate', labelKey: 'feedback_reason_inaccurate' },
{ key: 'irrelevant', labelKey: 'feedback_reason_irrelevant' },
{ key: 'incomplete', labelKey: 'feedback_reason_incomplete' },
{ key: 'other', labelKey: 'feedback_reason_other' },
];
/**
*
* onConfirm(reason, comment) undefined
*/
export function showFeedbackReasonPanel(
wrapper: HTMLElement,
msgId: string,
onConfirm: (reason: 'inaccurate' | 'irrelevant' | 'incomplete' | 'other', comment?: string) => void,
): void {
// 移除已存在的原因面板
const existing = wrapper.querySelector('.csk-feedback-reason');
if (existing) existing.remove();
const panel = document.createElement('div');
panel.className = 'csk-feedback-reason';
// 标题
const title = document.createElement('div');
title.className = 'csk-feedback-reason__title';
title.textContent = t('feedback_reason_title');
panel.appendChild(title);
// 原因选项按钮
const optionsRow = document.createElement('div');
optionsRow.className = 'csk-feedback-reason__options';
REASON_OPTIONS.forEach((opt) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'csk-feedback-reason__btn';
btn.textContent = t(opt.labelKey);
btn.addEventListener('click', (e) => {
e.stopPropagation();
onConfirm(opt.key as 'inaccurate' | 'irrelevant' | 'incomplete' | 'other');
panel.remove();
});
optionsRow.appendChild(btn);
});
panel.appendChild(optionsRow);
// 补充说明输入框(可选)
const commentRow = document.createElement('div');
commentRow.className = 'csk-feedback-reason__comment-row';
const commentInput = document.createElement('input');
commentInput.type = 'text';
commentInput.className = 'csk-feedback-reason__comment';
commentInput.placeholder = t('feedback_reason_comment_placeholder');
commentInput.maxLength = 200;
commentRow.appendChild(commentInput);
const submitBtn = document.createElement('button');
submitBtn.type = 'button';
submitBtn.className = 'csk-feedback-reason__submit';
submitBtn.textContent = t('feedback_reason_submit');
submitBtn.addEventListener('click', (e) => {
e.stopPropagation();
const comment = commentInput.value.trim() || undefined;
onConfirm('other', comment);
panel.remove();
});
commentRow.appendChild(submitBtn);
panel.appendChild(commentRow);
// 关闭按钮
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'csk-feedback-reason__close';
closeBtn.setAttribute('aria-label', t('close'));
closeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
panel.remove();
});
panel.appendChild(closeBtn);
wrapper.appendChild(panel);
}

14
client/src/i18n.ts

@ -60,6 +60,13 @@ const dictionaries: Record<string, Record<string, string>> = {
// 消息反馈 // 消息反馈
feedback_up: '有帮助', feedback_up: '有帮助',
feedback_down: '没帮助', feedback_down: '没帮助',
feedback_reason_title: '请选择不满意的原因:',
feedback_reason_inaccurate: '信息错误',
feedback_reason_irrelevant: '不相关',
feedback_reason_incomplete: '不完整',
feedback_reason_other: '其他',
feedback_reason_comment_placeholder: '补充说明(可选)',
feedback_reason_submit: '提交',
// 提示气泡 // 提示气泡
teaser_text: '有什么可以帮你的吗?', teaser_text: '有什么可以帮你的吗?',
@ -138,6 +145,13 @@ const dictionaries: Record<string, Record<string, string>> = {
// Feedback // Feedback
feedback_up: 'Helpful', feedback_up: 'Helpful',
feedback_down: 'Not helpful', feedback_down: 'Not helpful',
feedback_reason_title: 'Please tell us why:',
feedback_reason_inaccurate: 'Inaccurate',
feedback_reason_irrelevant: 'Irrelevant',
feedback_reason_incomplete: 'Incomplete',
feedback_reason_other: 'Other',
feedback_reason_comment_placeholder: 'Additional comments (optional)',
feedback_reason_submit: 'Submit',
// Teaser // Teaser
teaser_text: 'How can I help you?', teaser_text: 'How can I help you?',

95
client/src/styles.ts

@ -1420,6 +1420,101 @@ function getStyles(config: ResolvedConfig): string {
.csk-feedback-btn:active { transform: scale(0.9); } .csk-feedback-btn:active { transform: scale(0.9); }
.csk-msg--streaming .csk-feedback-btn { display: none; } .csk-msg--streaming .csk-feedback-btn { display: none; }
/* ========== 点踩原因选择面板 ========== */
.csk-feedback-reason {
position: relative;
margin-top: 6px;
padding: 10px 12px;
background: var(--csk-bg);
border: 1px solid var(--csk-border);
border-radius: 8px;
animation: cskFadeIn 0.15s ease;
}
.csk-feedback-reason__title {
font-size: 12px;
color: var(--csk-text-muted);
margin-bottom: 8px;
}
.csk-feedback-reason__options {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.csk-feedback-reason__btn {
padding: 4px 10px;
font-size: 12px;
border: 1px solid var(--csk-border);
border-radius: 14px;
background: var(--csk-bg);
color: var(--csk-text);
cursor: pointer;
transition: all 0.15s ease;
}
.csk-feedback-reason__btn:hover {
border-color: var(--csk-primary);
color: var(--csk-primary);
background: rgba(var(--csk-primary-rgb), 0.08);
}
.csk-feedback-reason__comment-row {
display: flex;
gap: 6px;
}
.csk-feedback-reason__comment {
flex: 1;
padding: 4px 8px;
font-size: 12px;
border: 1px solid var(--csk-border);
border-radius: 6px;
background: var(--csk-bg);
color: var(--csk-text);
outline: none;
}
.csk-feedback-reason__comment:focus { border-color: var(--csk-primary); }
.csk-feedback-reason__submit {
padding: 4px 10px;
font-size: 12px;
border: none;
border-radius: 6px;
background: var(--csk-primary);
color: #fff;
cursor: pointer;
white-space: nowrap;
}
.csk-feedback-reason__close {
position: absolute;
top: 8px;
right: 8px;
width: 18px;
height: 18px;
border: none;
background: none;
color: var(--csk-text-muted);
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.csk-feedback-reason__close:hover { color: var(--csk-text); background: var(--csk-hover); }
.csk-dark .csk-feedback-reason { background: var(--csk-bg); border-color: var(--csk-border); }
.csk-dark .csk-feedback-reason__btn {
background: rgba(255,255,255,0.06);
border-color: var(--csk-border);
color: var(--csk-text);
}
.csk-dark .csk-feedback-reason__btn:hover {
border-color: var(--csk-primary-light);
color: var(--csk-primary-light);
background: rgba(var(--csk-primary-rgb), 0.15);
}
.csk-dark .csk-feedback-reason__comment {
background: rgba(255,255,255,0.06);
border-color: var(--csk-border);
color: var(--csk-text);
}
/* ========== 历史会话搜索框 ========== */ /* ========== 历史会话搜索框 ========== */
.csk-history-panel__search-wrap { .csk-history-panel__search-wrap {
padding: 8px 10px; padding: 8px 10px;

4
client/src/types.ts

@ -184,6 +184,10 @@ export interface ChatMessage {
sources?: RagSource[]; sources?: RagSource[];
/** 可选:用户反馈 'up' | 'down',预留后端对接位 */ /** 可选:用户反馈 'up' | 'down',预留后端对接位 */
feedback?: 'up' | 'down'; feedback?: 'up' | 'down';
/** 可选:点踩原因分类(仅 feedback='down' 时有效) */
feedbackReason?: 'inaccurate' | 'irrelevant' | 'incomplete' | 'other';
/** 可选:点踩补充说明 */
feedbackComment?: string;
} }
/** RAG 引用来源 */ /** RAG 引用来源 */

2
frontend/auto-imports.d.ts

@ -6,5 +6,5 @@
// biome-ignore lint: disable // biome-ignore lint: disable
export {} export {}
declare global { declare global {
const MessagePlugin: typeof import('tdesign-vue-next')['MessagePlugin']
} }

2
frontend/src/router/index.ts

@ -15,6 +15,7 @@ const routes: RouteRecordRaw[] = [
// ==================== 知识库 ==================== // ==================== 知识库 ====================
{ path: '/knowledge/stats', name: 'DocStats', component: () => import('@/views/DocStats.vue') }, { path: '/knowledge/stats', name: 'DocStats', component: () => import('@/views/DocStats.vue') },
{ path: '/knowledge/document', name: 'DocList', component: () => import('@/views/DocList.vue') }, { path: '/knowledge/document', name: 'DocList', component: () => import('@/views/DocList.vue') },
{ path: '/knowledge/upload', name: 'DocUpload', component: () => import('@/views/DocUpload.vue') },
{ path: '/knowledge/category', name: 'Category', component: () => import('@/views/CategoryManager.vue') }, { path: '/knowledge/category', name: 'Category', component: () => import('@/views/CategoryManager.vue') },
{ path: '/knowledge/search', name: 'DocSearch', component: () => import('@/views/DocSearch.vue') }, { path: '/knowledge/search', name: 'DocSearch', component: () => import('@/views/DocSearch.vue') },
{ path: '/knowledge/faq', name: 'FaqManager', component: () => import('@/views/FaqManager.vue') }, { path: '/knowledge/faq', name: 'FaqManager', component: () => import('@/views/FaqManager.vue') },
@ -54,6 +55,7 @@ router.beforeEach(async (to, _from, next) => {
case 'DocStats': case 'DocStats':
case 'DocList': case 'DocList':
case 'DocSearch': case 'DocSearch':
case 'DocUpload':
try { try {
await Promise.all([ await Promise.all([
categoryStore.loadCategories(), categoryStore.loadCategories(),

1
frontend/src/stores/navigation.ts

@ -9,6 +9,7 @@ export const MENU_ITEMS = [
id: 'knowledge', label: '知识库', icon: '📚', id: 'knowledge', label: '知识库', icon: '📚',
children: [ children: [
{ id: 'stats', label: '统计概览', icon: '📊', path: '/knowledge/stats' }, { id: 'stats', label: '统计概览', icon: '📊', path: '/knowledge/stats' },
{ id: 'doc-upload', label: '文档上传', icon: '📤', path: '/knowledge/upload' },
{ id: 'doc-manage', label: '文档管理', icon: '📄', path: '/knowledge/document' }, { id: 'doc-manage', label: '文档管理', icon: '📄', path: '/knowledge/document' },
{ id: 'category', label: '分类管理', icon: '🏷️', path: '/knowledge/category' }, { id: 'category', label: '分类管理', icon: '🏷️', path: '/knowledge/category' },
{ id: 'search-test', label: '搜索测试', icon: '🔍', path: '/knowledge/search' }, { id: 'search-test', label: '搜索测试', icon: '🔍', path: '/knowledge/search' },

11
frontend/src/views/ChatPanel.vue

@ -557,17 +557,20 @@ async function submitFeedback(msgId: string, type: 'up' | 'down'): Promise<void>
const wasActive = msg.feedback === type const wasActive = msg.feedback === type
const newFeedback = wasActive ? null : type const newFeedback = wasActive ? null : type
msg.feedback = newFeedback msg.feedback = newFeedback
// API null
if (!newFeedback) return
try { try {
await submitFeedbackApi({ await submitFeedbackApi({
messageId: String(msgId), messageId: String(msgId),
conversationId: chatId.value, conversationId: chatId.value,
feedbackType: newFeedback ? (newFeedback === 'up' ? 'THUMBS_UP' : 'THUMBS_DOWN') : null,
feedbackType: newFeedback === 'up' ? 'THUMBS_UP' : 'THUMBS_DOWN',
}) })
if (newFeedback) {
toast(newFeedback === 'up' ? '感谢反馈 👍' : '感谢反馈,我们会持续改进', 'success')
}
toast(newFeedback === 'up' ? '感谢反馈 👍' : '感谢反馈,我们会持续改进', 'success')
} catch (e) { } catch (e) {
console.error('反馈提交失败:', e) console.error('反馈提交失败:', e)
toast(e.message || '反馈提交失败', 'error')
} }
} }

18
frontend/src/views/DashboardPanel.vue

@ -84,16 +84,24 @@ const hitColumns = [
const satisfactionColor = computed(() => { const satisfactionColor = computed(() => {
const rate = overview.value.satisfactionRate || 0 const rate = overview.value.satisfactionRate || 0
if (rate >= 80) return 'green'
if (rate >= 60) return 'orange'
return 'red'
if (rate > 0 && rate < 0.5) return 'red'
if (rate >= 0.5 && rate < 0.8) return 'orange'
return 'green'
}) })
function formatPercent(v: any) { return v != null ? Number(v).toFixed(1) : '0.0' }
function formatPercent(v: any) { if (v == null) return '0.0'; const n = Number(v); return (n * 100).toFixed(1) }
function formatDateShort(d: any) { if (!d) return ''; const dt = new Date(d); if (isNaN(dt.getTime())) return ''; return `${dt.getMonth() + 1}/${dt.getDate()}` } function formatDateShort(d: any) { if (!d) return ''; const dt = new Date(d); if (isNaN(dt.getTime())) return ''; return `${dt.getMonth() + 1}/${dt.getDate()}` }
async function setRange(days: number) { rangeDays.value = days; await loadTrend(days) } async function setRange(days: number) { rangeDays.value = days; await loadTrend(days) }
async function loadOverview() { try { const r = await getDashboardOverview(); if (r.success) overview.value = r.data } catch { /* silent */ } }
async function loadOverview() {
try { const r = await getDashboardOverview(); if (r.success) overview.value = r.data } catch { /* silent */ }
// API toast
if (overview.value?.conversationCount === 0 && overview.value?.messageCount === 0) {
import('tdesign-vue-next').then(({ MessagePlugin }) => {
MessagePlugin.warning('今日暂无数据,概览指标展示近 7 天汇总')
}).catch(() => {})
}
}
async function loadTrend(days: number) { async function loadTrend(days: number) {
try { const r = await getDashboardTrend(days); if (r.success) { trendData.value = r.data || []; await nextTick(); renderCharts() } } catch { /* */ } try { const r = await getDashboardTrend(days); if (r.success) { trendData.value = r.data || []; await nextTick(); renderCharts() } } catch { /* */ }

20
src/main/java/com/wok/supportbot/app/ChatPipeline.java

@ -4,6 +4,7 @@ import com.wok.supportbot.rag.RagContext;
import com.wok.supportbot.rag.RagPipeline; import com.wok.supportbot.rag.RagPipeline;
import com.wok.supportbot.service.IntentRouter; import com.wok.supportbot.service.IntentRouter;
import com.wok.supportbot.service.SystemConfigService; import com.wok.supportbot.service.SystemConfigService;
import com.wok.supportbot.service.RagHitLogService;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document; import org.springframework.ai.document.Document;
@ -53,6 +54,9 @@ public class ChatPipeline {
@Resource @Resource
private SystemConfigService systemConfigService; private SystemConfigService systemConfigService;
@Resource
private RagHitLogService ragHitLogService;
/** /**
* 编排一次对话请求产出执行决策 * 编排一次对话请求产出执行决策
* <p> * <p>
@ -97,6 +101,22 @@ public class ChatPipeline {
// RAG 检索 FAQ 优先匹配 // RAG 检索 FAQ 优先匹配
RagContext rag = ragPipeline.retrieve(ctx); RagContext rag = ragPipeline.retrieve(ctx);
// 记录 RAG 检索日志到 rag_hit_log 供知识库分析看板使用
if (!rag.faqHit() && rag.documents() != null && !rag.documents().isEmpty()) {
String searchMode = ctx.rewriteStrategy() != null ? ctx.rewriteStrategy() : "VECTOR";
for (Document doc : rag.documents()) {
String docIdStr = String.valueOf(doc.getMetadata().getOrDefault("documentId", ""));
Long documentId = null;
try { if (!docIdStr.isEmpty()) documentId = Long.parseLong(docIdStr); } catch (NumberFormatException ignored) { }
String title = String.valueOf(doc.getMetadata().getOrDefault("title", ""));
String score = String.valueOf(doc.getMetadata().getOrDefault("score", ""));
ragHitLogService.recordHit(ctx.chatId(), ctx.message(), documentId, title, score, searchMode);
}
} else if (!rag.faqHit()) {
String searchMode = ctx.rewriteStrategy() != null ? ctx.rewriteStrategy() : "VECTOR";
ragHitLogService.recordMiss(ctx.chatId(), ctx.message(), searchMode);
}
if (rag.faqHit()) { if (rag.faqHit()) {
return new ChatRequest(ctx, ctx.message(), baseSystem, rag.faqAnswer()); return new ChatRequest(ctx, ctx.message(), baseSystem, rag.faqAnswer());
} }

33
src/main/java/com/wok/supportbot/rag/RagPipeline.java

@ -8,6 +8,7 @@ import com.wok.supportbot.rag.preretrieval.MultiQueryExpanderRewriter;
import com.wok.supportbot.rag.preretrieval.RewriteQueryRewriter; import com.wok.supportbot.rag.preretrieval.RewriteQueryRewriter;
import com.wok.supportbot.rag.preretrieval.TranslationQueryRewriter; import com.wok.supportbot.rag.preretrieval.TranslationQueryRewriter;
import com.wok.supportbot.service.FaqMatchEngine; import com.wok.supportbot.service.FaqMatchEngine;
import com.wok.supportbot.service.RagHitLogService;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.Message;
@ -73,6 +74,9 @@ public class RagPipeline {
@Resource @Resource
private RagPromptConfig ragPromptConfig; private RagPromptConfig ragPromptConfig;
@Resource
private RagHitLogService ragHitLogService;
@Resource @Resource
private CategoryFilter categoryFilter; private CategoryFilter categoryFilter;
@ -141,6 +145,10 @@ public class RagPipeline {
rewrittenQuery = rewriteQuery(ctx.message(), ctx.chatId(), ctx.rewriteStrategy()); rewrittenQuery = rewriteQuery(ctx.message(), ctx.chatId(), ctx.rewriteStrategy());
docs = similaritySearch(rewrittenQuery, ctx.categoryIds()); docs = similaritySearch(rewrittenQuery, ctx.categoryIds());
} }
// RAG 命中日志记录本次检索的命中/未命中情况
logRagHit(ctx.chatId(), ctx.message(), docs, rewrittenQuery);
String contextText = joinContext(docs); String contextText = joinContext(docs);
return new RagContext(Optional.empty(), docs, contextText, rewrittenQuery); return new RagContext(Optional.empty(), docs, contextText, rewrittenQuery);
} }
@ -274,4 +282,29 @@ public class RagPipeline {
.filter(StringUtils::hasText) .filter(StringUtils::hasText)
.collect(Collectors.joining("\n\n---\n\n")); .collect(Collectors.joining("\n\n---\n\n"));
} }
/**
* 异步记录 RAG 命中日志到 rag_hit_log
* 有命中文档时逐条记录 hit无命中时记录一条 miss
*/
private void logRagHit(String conversationId, String userQuery, List<Document> docs, String searchMode) {
try {
if (docs == null || docs.isEmpty()) {
ragHitLogService.recordMiss(conversationId, userQuery, searchMode);
} else {
for (Document doc : docs) {
String docIdStr = String.valueOf(doc.getMetadata().getOrDefault("documentId", ""));
Long documentId = null;
try {
if (!docIdStr.isEmpty()) documentId = Long.parseLong(docIdStr);
} catch (NumberFormatException ignored) { }
String title = String.valueOf(doc.getMetadata().getOrDefault("title", ""));
String score = String.valueOf(doc.getMetadata().getOrDefault("score", ""));
ragHitLogService.recordHit(conversationId, userQuery, documentId, title, score, searchMode);
}
}
} catch (Exception e) {
log.warn("RAG 命中日志记录失败: {}", e.getMessage());
}
}
} }

4
src/main/java/com/wok/supportbot/security/SdkAuthFilter.java

@ -61,8 +61,8 @@ public class SdkAuthFilter extends OncePerRequestFilter {
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true; return true;
} }
// 拦截 /ai/ 路径排除静态资源和 swagger
return !path.startsWith("/ai/");
// 拦截 /ai/ 路径 /feedback 端点排除静态资源和 swagger
return !path.startsWith("/ai/") && !path.equals("/feedback");
} }
@Override @Override

202
src/main/java/com/wok/supportbot/service/DashboardService.java

@ -37,52 +37,73 @@ public class DashboardService {
public Map<String, Object> getOverview() { public Map<String, Object> getOverview() {
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
// 不再只查"今天"直接查近 7 天汇总大部分运营数据跨天累积才有意义
String today = new SimpleDateFormat("yyyy-MM-dd").format(new Date()); String today = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
// 今日对话数 conversation_id 去重
// 7 对话数 conversation_id 去重
Long conversationCount = queryLong( Long conversationCount = queryLong(
"SELECT COUNT(DISTINCT conversation_id) FROM chat_message WHERE is_delete = false AND DATE(create_time) = ?",
today);
result.put("conversationCount", conversationCount);
// 今日消息数
"SELECT COUNT(DISTINCT conversation_id) FROM chat_message WHERE is_delete = false AND DATE(create_time) >= CURRENT_DATE - 7");
// 7 天消息数
Long messageCount = queryLong( Long messageCount = queryLong(
"SELECT COUNT(*) FROM chat_message WHERE is_delete = false AND DATE(create_time) = ?",
today);
result.put("messageCount", messageCount);
// 满意率基于 message_feedback
"SELECT COUNT(*) FROM chat_message WHERE is_delete = false AND DATE(create_time) >= CURRENT_DATE - 7");
// 7 天满意率基于 message_feedback
Long thumbsUp = queryLong( Long thumbsUp = queryLong(
"SELECT COUNT(*) FROM message_feedback WHERE is_delete = false AND feedback_type = 'THUMBS_UP' AND DATE(create_time) = ?",
today);
"SELECT COUNT(*) FROM message_feedback WHERE is_delete = false AND feedback_type = 'THUMBS_UP' AND DATE(create_time) >= CURRENT_DATE - 7");
Long thumbsDown = queryLong( Long thumbsDown = queryLong(
"SELECT COUNT(*) FROM message_feedback WHERE is_delete = false AND feedback_type = 'THUMBS_DOWN' AND DATE(create_time) = ?",
today);
"SELECT COUNT(*) FROM message_feedback WHERE is_delete = false AND feedback_type = 'THUMBS_DOWN' AND DATE(create_time) >= CURRENT_DATE - 7");
// 7 RAG 命中率基于 rag_hit_log
Long ragHitCount = queryLong(
"SELECT COUNT(*) FROM rag_hit_log WHERE document_id IS NOT NULL AND DATE(create_time) >= CURRENT_DATE - 7");
Long ragMissCount = queryLong(
"SELECT COUNT(*) FROM rag_hit_log WHERE document_id IS NULL AND DATE(create_time) >= CURRENT_DATE - 7");
// 7 天平均响应时间
Double avgResponseTime = queryDouble(
"SELECT AVG(CAST(metadata->>'responseTimeMs' AS DOUBLE PRECISION)) FROM chat_message " +
"WHERE is_delete = false AND message_type = 'ASSISTANT' " +
"AND metadata ? 'responseTimeMs' AND DATE(create_time) >= CURRENT_DATE - 7");
// 今日无数据时自动降级为近 7 天汇总避免看板始终显示 0
if (conversationCount == 0 && thumbsUp == 0 && thumbsDown == 0) {
conversationCount = queryLong(
"SELECT COUNT(DISTINCT conversation_id) FROM chat_message WHERE is_delete = false AND DATE(create_time) >= CURRENT_DATE - 30");
messageCount = queryLong(
"SELECT COUNT(*) FROM chat_message WHERE is_delete = false AND DATE(create_time) >= CURRENT_DATE - 30");
thumbsUp = queryLong(
"SELECT COUNT(*) FROM message_feedback WHERE is_delete = false AND feedback_type = 'THUMBS_UP' AND DATE(create_time) >= CURRENT_DATE - 30");
thumbsDown = queryLong(
"SELECT COUNT(*) FROM message_feedback WHERE is_delete = false AND feedback_type = 'THUMBS_DOWN' AND DATE(create_time) >= CURRENT_DATE - 30");
ragHitCount = queryLong(
"SELECT COUNT(*) FROM rag_hit_log WHERE document_id IS NOT NULL AND DATE(create_time) >= CURRENT_DATE - 30");
ragMissCount = queryLong(
"SELECT COUNT(*) FROM rag_hit_log WHERE document_id IS NULL AND DATE(create_time) >= CURRENT_DATE - 30");
avgResponseTime = queryDouble(
"SELECT AVG(CAST(metadata->>'responseTimeMs' AS DOUBLE PRECISION)) FROM chat_message " +
"WHERE is_delete = false AND message_type = 'ASSISTANT' " +
"AND metadata ? 'responseTimeMs' AND DATE(create_time) >= CURRENT_DATE - 30");
log.info("近 7 天无看板数据,已降级为近 30 天汇总: conversations={}, messages={}, feedback={}/{}",
conversationCount, messageCount, thumbsUp, thumbsDown);
}
// 有对话但无反馈时满意率从更宽范围获取反馈数据通常滞后于对话
if (thumbsUp == 0 && thumbsDown == 0) {
thumbsUp = queryLong(
"SELECT COUNT(*) FROM message_feedback WHERE is_delete = false AND feedback_type = 'THUMBS_UP' AND DATE(create_time) >= CURRENT_DATE - 30");
thumbsDown = queryLong(
"SELECT COUNT(*) FROM message_feedback WHERE is_delete = false AND feedback_type = 'THUMBS_DOWN' AND DATE(create_time) >= CURRENT_DATE - 30");
}
long totalFeedback = thumbsUp + thumbsDown; long totalFeedback = thumbsUp + thumbsDown;
double satisfactionRate = totalFeedback > 0 ? Math.round((double) thumbsUp / totalFeedback * 100.0) / 100.0 : 0.0; double satisfactionRate = totalFeedback > 0 ? Math.round((double) thumbsUp / totalFeedback * 100.0) / 100.0 : 0.0;
long totalRag = ragHitCount + ragMissCount;
double ragHitRate = totalRag > 0 ? Math.round((double) ragHitCount / totalRag * 100.0) / 100.0 : 0.0;
result.put("conversationCount", conversationCount);
result.put("messageCount", messageCount);
result.put("satisfactionRate", satisfactionRate); result.put("satisfactionRate", satisfactionRate);
result.put("thumbsUpCount", thumbsUp); result.put("thumbsUpCount", thumbsUp);
result.put("thumbsDownCount", thumbsDown); result.put("thumbsDownCount", thumbsDown);
// RAG 命中率基于 rag_hit_log
Long ragHitCount = queryLong(
"SELECT COUNT(*) FROM rag_hit_log WHERE document_id IS NOT NULL AND DATE(create_time) = ?",
today);
Long ragMissCount = queryLong(
"SELECT COUNT(*) FROM rag_hit_log WHERE document_id IS NULL AND DATE(create_time) = ?",
today);
long totalRag = ragHitCount + ragMissCount;
double ragHitRate = totalRag > 0 ? Math.round((double) ragHitCount / totalRag * 100.0) / 100.0 : 0.0;
result.put("ragHitRate", ragHitRate); result.put("ragHitRate", ragHitRate);
result.put("ragHitCount", ragHitCount); result.put("ragHitCount", ragHitCount);
result.put("ragMissCount", ragMissCount); result.put("ragMissCount", ragMissCount);
// 平均响应时间 chat_message.metadata 中提取 responseTimeMs
Double avgResponseTime = queryDouble(
"SELECT AVG(CAST(metadata->>'responseTimeMs' AS DOUBLE PRECISION)) FROM chat_message " +
"WHERE is_delete = false AND message_type = 'ASSISTANT' " +
"AND metadata ? 'responseTimeMs' AND DATE(create_time) = ?",
today);
result.put("avgResponseTime", avgResponseTime != null ? Math.round(avgResponseTime * 100.0) / 100.0 : 0.0); result.put("avgResponseTime", avgResponseTime != null ? Math.round(avgResponseTime * 100.0) / 100.0 : 0.0);
return result; return result;
@ -90,15 +111,69 @@ public class DashboardService {
/** /**
* 获取最近 N 天的趋势数据 * 获取最近 N 天的趋势数据
* 优先从 dashboard_snapshot 快照表读取,表为空时降级为实时 SQL 聚合
* *
* @param days 天数7 30 * @param days 天数7 30
* @return 趋势快照列表
* @return 趋势数据列表
*/ */
public List<DashboardSnapshot> getTrend(int days) { public List<DashboardSnapshot> getTrend(int days) {
String sql = "SELECT * FROM dashboard_snapshot WHERE snapshot_date >= CURRENT_DATE - ? ORDER BY snapshot_date ASC"; String sql = "SELECT * FROM dashboard_snapshot WHERE snapshot_date >= CURRENT_DATE - ? ORDER BY snapshot_date ASC";
return jdbcTemplate.queryForList(sql, days).stream()
List<DashboardSnapshot> snapshots = jdbcTemplate.queryForList(sql, days).stream()
.map(this::mapToSnapshot) .map(this::mapToSnapshot)
.toList(); .toList();
// 快照表有数据则直接返回
if (!snapshots.isEmpty()) {
return snapshots;
}
// 快照表为空时降级为实时 SQL 聚合兼容定时任务未运行或首次部署
log.info("dashboard_snapshot 表为空,降级为实时 SQL 聚合最近 {} 天趋势", days);
return aggregateTrendFromSource(days);
}
/**
* 从源表实时聚合趋势数据降级方案
*/
private List<DashboardSnapshot> aggregateTrendFromSource(int days) {
String trendSql = """
SELECT
d.date AS snapshot_date,
COALESCE(cm.conversation_count, 0) AS conversation_count,
COALESCE(cm.message_count, 0) AS message_count,
COALESCE(fb.thumbs_up_count, 0) AS thumbs_up_count,
COALESCE(fb.thumbs_down_count, 0) AS thumbs_down_count,
CASE WHEN COALESCE(fb.thumbs_up_count, 0) + COALESCE(fb.thumbs_down_count, 0) > 0
THEN ROUND(COALESCE(fb.thumbs_up_count, 0)::numeric /
(COALESCE(fb.thumbs_up_count, 0) + COALESCE(fb.thumbs_down_count, 0))::numeric, 2)
ELSE 0 END AS satisfaction_rate
FROM generate_series(CURRENT_DATE - (? - 1), CURRENT_DATE, '1 day'::interval) AS d(date)
LEFT JOIN (
SELECT DATE(create_time) AS dt,
COUNT(DISTINCT conversation_id) AS conversation_count,
COUNT(*) AS message_count
FROM chat_message WHERE is_delete = false
GROUP BY DATE(create_time)
) cm ON cm.dt = d.date
LEFT JOIN (
SELECT DATE(create_time) AS dt,
COUNT(*) FILTER (WHERE feedback_type = 'THUMBS_UP') AS thumbs_up_count,
COUNT(*) FILTER (WHERE feedback_type = 'THUMBS_DOWN') AS thumbs_down_count
FROM message_feedback WHERE is_delete = false
GROUP BY DATE(create_time)
) fb ON fb.dt = d.date
ORDER BY d.date ASC
""";
return jdbcTemplate.queryForList(trendSql, days).stream()
.map(row -> DashboardSnapshot.builder()
.snapshotDate(row.get("snapshot_date") != null ? (Date) row.get("snapshot_date") : null)
.conversationCount(row.get("conversation_count") != null ? ((Number) row.get("conversation_count")).intValue() : 0)
.messageCount(row.get("message_count") != null ? ((Number) row.get("message_count")).intValue() : 0)
.satisfactionRate(row.get("satisfaction_rate") != null ? ((Number) row.get("satisfaction_rate")).doubleValue() : 0.0)
.thumbsUpCount(row.get("thumbs_up_count") != null ? ((Number) row.get("thumbs_up_count")).intValue() : 0)
.thumbsDownCount(row.get("thumbs_down_count") != null ? ((Number) row.get("thumbs_down_count")).intValue() : 0)
.build())
.toList();
} }
/** /**
@ -138,16 +213,65 @@ public class DashboardService {
/** /**
* 获取自定义时间范围的快照数据 * 获取自定义时间范围的快照数据
*
* @param startDate 开始日期yyyy-MM-dd
* @param endDate 结束日期yyyy-MM-dd
* @return 区间内的快照列表
* 优先从 dashboard_snapshot 快照表读取,表为空时降级为实时 SQL 聚合
*/ */
public List<DashboardSnapshot> getCustomRange(String startDate, String endDate) { public List<DashboardSnapshot> getCustomRange(String startDate, String endDate) {
String sql = "SELECT * FROM dashboard_snapshot WHERE snapshot_date >= ?::date AND snapshot_date <= ?::date ORDER BY snapshot_date ASC"; String sql = "SELECT * FROM dashboard_snapshot WHERE snapshot_date >= ?::date AND snapshot_date <= ?::date ORDER BY snapshot_date ASC";
return jdbcTemplate.queryForList(sql, startDate, endDate).stream()
List<DashboardSnapshot> snapshots = jdbcTemplate.queryForList(sql, startDate, endDate).stream()
.map(this::mapToSnapshot) .map(this::mapToSnapshot)
.toList(); .toList();
if (!snapshots.isEmpty()) {
return snapshots;
}
// 快照表为空时降级为实时聚合
log.info("dashboard_snapshot 表在日期范围 {}~{} 为空,降级为实时 SQL 聚合", startDate, endDate);
return aggregateCustomRangeFromSource(startDate, endDate);
}
/**
* 从源表实时聚合自定义日期范围趋势降级方案
*/
private List<DashboardSnapshot> aggregateCustomRangeFromSource(String startDate, String endDate) {
String trendSql = """
SELECT
d.date AS snapshot_date,
COALESCE(cm.conversation_count, 0) AS conversation_count,
COALESCE(cm.message_count, 0) AS message_count,
COALESCE(fb.thumbs_up_count, 0) AS thumbs_up_count,
COALESCE(fb.thumbs_down_count, 0) AS thumbs_down_count,
CASE WHEN COALESCE(fb.thumbs_up_count, 0) + COALESCE(fb.thumbs_down_count, 0) > 0
THEN ROUND(COALESCE(fb.thumbs_up_count, 0)::numeric /
(COALESCE(fb.thumbs_up_count, 0) + COALESCE(fb.thumbs_down_count, 0))::numeric, 2)
ELSE 0 END AS satisfaction_rate
FROM generate_series(?::date, ?::date, '1 day'::interval) AS d(date)
LEFT JOIN (
SELECT DATE(create_time) AS dt,
COUNT(DISTINCT conversation_id) AS conversation_count,
COUNT(*) AS message_count
FROM chat_message WHERE is_delete = false
GROUP BY DATE(create_time)
) cm ON cm.dt = d.date
LEFT JOIN (
SELECT DATE(create_time) AS dt,
COUNT(*) FILTER (WHERE feedback_type = 'THUMBS_UP') AS thumbs_up_count,
COUNT(*) FILTER (WHERE feedback_type = 'THUMBS_DOWN') AS thumbs_down_count
FROM message_feedback WHERE is_delete = false
GROUP BY DATE(create_time)
) fb ON fb.dt = d.date
ORDER BY d.date ASC
""";
return jdbcTemplate.queryForList(trendSql, startDate, endDate).stream()
.map(row -> DashboardSnapshot.builder()
.snapshotDate(row.get("snapshot_date") != null ? (Date) row.get("snapshot_date") : null)
.conversationCount(row.get("conversation_count") != null ? ((Number) row.get("conversation_count")).intValue() : 0)
.messageCount(row.get("message_count") != null ? ((Number) row.get("message_count")).intValue() : 0)
.satisfactionRate(row.get("satisfaction_rate") != null ? ((Number) row.get("satisfaction_rate")).doubleValue() : 0.0)
.thumbsUpCount(row.get("thumbs_up_count") != null ? ((Number) row.get("thumbs_up_count")).intValue() : 0)
.thumbsDownCount(row.get("thumbs_down_count") != null ? ((Number) row.get("thumbs_down_count")).intValue() : 0)
.build())
.toList();
} }
/** /**

22
src/main/java/com/wok/supportbot/service/MessageFeedbackService.java

@ -24,6 +24,9 @@ public class MessageFeedbackService {
@Autowired @Autowired
private JdbcTemplate jdbcTemplate; private JdbcTemplate jdbcTemplate;
@Autowired(required = false)
private WebhookService webhookService;
/** /**
* 提交/修改反馈upsert 语义 * 提交/修改反馈upsert 语义
* messageId 查询存在则更新覆盖上次不存在则插入 * messageId 查询存在则更新覆盖上次不存在则插入
@ -37,6 +40,7 @@ public class MessageFeedbackService {
wrapper.eq(MessageFeedback::getMessageId, feedback.getMessageId()); wrapper.eq(MessageFeedback::getMessageId, feedback.getMessageId());
MessageFeedback existing = messageFeedbackMapper.selectOne(wrapper); MessageFeedback existing = messageFeedbackMapper.selectOne(wrapper);
MessageFeedback saved;
if (existing != null) { if (existing != null) {
// 更新已有反馈 // 更新已有反馈
existing.setFeedbackType(feedback.getFeedbackType()); existing.setFeedbackType(feedback.getFeedbackType());
@ -45,15 +49,29 @@ public class MessageFeedbackService {
existing.setUpdateTime(new Date()); existing.setUpdateTime(new Date());
messageFeedbackMapper.updateById(existing); messageFeedbackMapper.updateById(existing);
log.info("更新反馈: messageId={}, type={}", feedback.getMessageId(), feedback.getFeedbackType()); log.info("更新反馈: messageId={}, type={}", feedback.getMessageId(), feedback.getFeedbackType());
return existing;
saved = existing;
} else { } else {
// 新增反馈 // 新增反馈
feedback.setCreateTime(new Date()); feedback.setCreateTime(new Date());
feedback.setUpdateTime(new Date()); feedback.setUpdateTime(new Date());
messageFeedbackMapper.insert(feedback); messageFeedbackMapper.insert(feedback);
log.info("新增反馈: messageId={}, type={}", feedback.getMessageId(), feedback.getFeedbackType()); log.info("新增反馈: messageId={}, type={}", feedback.getMessageId(), feedback.getFeedbackType());
return feedback;
saved = feedback;
}
// 点踩时触发 Webhook 事件
if ("THUMBS_DOWN".equals(saved.getFeedbackType()) && webhookService != null) {
Map<String, Object> payload = new HashMap<>();
payload.put("messageId", saved.getMessageId());
payload.put("conversationId", saved.getConversationId());
payload.put("feedbackType", saved.getFeedbackType());
payload.put("reasonCategory", saved.getReasonCategory());
payload.put("reasonComment", saved.getReasonComment());
payload.put("createTime", saved.getCreateTime());
webhookService.triggerEvent("feedback.negative", payload);
} }
return saved;
} }
/** /**

266
src/main/resources/static/sdk/chatbot-sdk.js

@ -199,6 +199,13 @@ var ChatbotSDK = (function () {
// 消息反馈 // 消息反馈
feedback_up: '有帮助', feedback_up: '有帮助',
feedback_down: '没帮助', feedback_down: '没帮助',
feedback_reason_title: '请选择不满意的原因:',
feedback_reason_inaccurate: '信息错误',
feedback_reason_irrelevant: '不相关',
feedback_reason_incomplete: '不完整',
feedback_reason_other: '其他',
feedback_reason_comment_placeholder: '补充说明(可选)',
feedback_reason_submit: '提交',
// 提示气泡 // 提示气泡
teaser_text: '有什么可以帮你的吗?', teaser_text: '有什么可以帮你的吗?',
new_msg_announce: '收到新消息', new_msg_announce: '收到新消息',
@ -266,6 +273,13 @@ var ChatbotSDK = (function () {
// Feedback // Feedback
feedback_up: 'Helpful', feedback_up: 'Helpful',
feedback_down: 'Not helpful', feedback_down: 'Not helpful',
feedback_reason_title: 'Please tell us why:',
feedback_reason_inaccurate: 'Inaccurate',
feedback_reason_irrelevant: 'Irrelevant',
feedback_reason_incomplete: 'Incomplete',
feedback_reason_other: 'Other',
feedback_reason_comment_placeholder: 'Additional comments (optional)',
feedback_reason_submit: 'Submit',
// Teaser // Teaser
teaser_text: 'How can I help you?', teaser_text: 'How can I help you?',
new_msg_announce: 'New message received', new_msg_announce: 'New message received',
@ -457,11 +471,12 @@ var ChatbotSDK = (function () {
Object.assign(headers, options.headers); Object.assign(headers, options.headers);
} }
} }
if ((currentConfig === null || currentConfig === void 0 ? void 0 : currentConfig.token) && url.includes('/ai/')) {
// 为受 SdkAuthFilter 保护的路径自动注入 Bearer Token(/ai/** 和 /feedback)
if ((currentConfig === null || currentConfig === void 0 ? void 0 : currentConfig.token) && (url.includes('/ai/') || url.endsWith('/feedback'))) {
headers['Authorization'] = `Bearer ${currentConfig.token}`; headers['Authorization'] = `Bearer ${currentConfig.token}`;
} }
const response = await fetch(url, Object.assign(Object.assign({}, options), { headers, signal: controller.signal, mode: 'cors', credentials: 'include' })); const response = await fetch(url, Object.assign(Object.assign({}, options), { headers, signal: controller.signal, mode: 'cors', credentials: 'include' }));
if (response.status === 401 && url.includes('/ai/')) {
if (response.status === 401 && (url.includes('/ai/') || url.endsWith('/feedback'))) {
logger.error('SDK 认证失败:Token 缺失或已过期,请重新调用 /open-api/auth/token 换取 Token'); logger.error('SDK 认证失败:Token 缺失或已过期,请重新调用 /open-api/auth/token 换取 Token');
} }
return response; return response;
@ -724,21 +739,26 @@ var ChatbotSDK = (function () {
} }
// ==================== P0-002: 消息反馈 ==================== // ==================== P0-002: 消息反馈 ====================
/** /**
* 提交消息反馈点赞/点踩
* 提交消息反馈点赞/点踩支持点踩原因分类
*/ */
async function submitFeedbackApi(messageId, feedbackType) {
async function submitFeedbackApi(messageId, feedbackType, reasonCategory, reasonComment) {
if (!currentConfig) if (!currentConfig)
return false; return false;
const url = buildUrl('/feedback'); const url = buildUrl('/feedback');
try { try {
const body = {
messageId: String(messageId),
conversationId: currentConfig.chatId,
feedbackType,
};
if (reasonCategory)
body.reasonCategory = reasonCategory;
if (reasonComment)
body.reasonComment = reasonComment;
const response = await safeFetch(url, { const response = await safeFetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messageId: String(messageId),
conversationId: currentConfig.chatId,
feedbackType,
}),
body: JSON.stringify(body),
}); });
if (!response.ok) { if (!response.ok) {
logger.error(`反馈提交失败 status=${response.status}`); logger.error(`反馈提交失败 status=${response.status}`);
@ -2377,6 +2397,101 @@ var ChatbotSDK = (function () {
.csk-feedback-btn:active { transform: scale(0.9); } .csk-feedback-btn:active { transform: scale(0.9); }
.csk-msg--streaming .csk-feedback-btn { display: none; } .csk-msg--streaming .csk-feedback-btn { display: none; }
/* ========== 点踩原因选择面板 ========== */
.csk-feedback-reason {
position: relative;
margin-top: 6px;
padding: 10px 12px;
background: var(--csk-bg);
border: 1px solid var(--csk-border);
border-radius: 8px;
animation: cskFadeIn 0.15s ease;
}
.csk-feedback-reason__title {
font-size: 12px;
color: var(--csk-text-muted);
margin-bottom: 8px;
}
.csk-feedback-reason__options {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.csk-feedback-reason__btn {
padding: 4px 10px;
font-size: 12px;
border: 1px solid var(--csk-border);
border-radius: 14px;
background: var(--csk-bg);
color: var(--csk-text);
cursor: pointer;
transition: all 0.15s ease;
}
.csk-feedback-reason__btn:hover {
border-color: var(--csk-primary);
color: var(--csk-primary);
background: rgba(var(--csk-primary-rgb), 0.08);
}
.csk-feedback-reason__comment-row {
display: flex;
gap: 6px;
}
.csk-feedback-reason__comment {
flex: 1;
padding: 4px 8px;
font-size: 12px;
border: 1px solid var(--csk-border);
border-radius: 6px;
background: var(--csk-bg);
color: var(--csk-text);
outline: none;
}
.csk-feedback-reason__comment:focus { border-color: var(--csk-primary); }
.csk-feedback-reason__submit {
padding: 4px 10px;
font-size: 12px;
border: none;
border-radius: 6px;
background: var(--csk-primary);
color: #fff;
cursor: pointer;
white-space: nowrap;
}
.csk-feedback-reason__close {
position: absolute;
top: 8px;
right: 8px;
width: 18px;
height: 18px;
border: none;
background: none;
color: var(--csk-text-muted);
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.csk-feedback-reason__close:hover { color: var(--csk-text); background: var(--csk-hover); }
.csk-dark .csk-feedback-reason { background: var(--csk-bg); border-color: var(--csk-border); }
.csk-dark .csk-feedback-reason__btn {
background: rgba(255,255,255,0.06);
border-color: var(--csk-border);
color: var(--csk-text);
}
.csk-dark .csk-feedback-reason__btn:hover {
border-color: var(--csk-primary-light);
color: var(--csk-primary-light);
background: rgba(var(--csk-primary-rgb), 0.15);
}
.csk-dark .csk-feedback-reason__comment {
background: rgba(255,255,255,0.06);
border-color: var(--csk-border);
color: var(--csk-text);
}
/* ========== 历史会话搜索框 ========== */ /* ========== 历史会话搜索框 ========== */
.csk-history-panel__search-wrap { .csk-history-panel__search-wrap {
padding: 8px 10px; padding: 8px 10px;
@ -3740,6 +3855,80 @@ var ChatbotSDK = (function () {
if (existing) if (existing)
existing.remove(); existing.remove();
} }
// ==================== 反馈原因选择面板 ====================
/** 点踩原因选项(与后端 MessageFeedback.reasonCategory 枚举一致) */
const REASON_OPTIONS = [
{ key: 'inaccurate', labelKey: 'feedback_reason_inaccurate' },
{ key: 'irrelevant', labelKey: 'feedback_reason_irrelevant' },
{ key: 'incomplete', labelKey: 'feedback_reason_incomplete' },
{ key: 'other', labelKey: 'feedback_reason_other' },
];
/**
* 在消息气泡下方展示点踩原因选择面板
* 用户选择原因后回调 onConfirm(reason, comment)取消时回调 undefined 参数
*/
function showFeedbackReasonPanel(wrapper, msgId, onConfirm) {
// 移除已存在的原因面板
const existing = wrapper.querySelector('.csk-feedback-reason');
if (existing)
existing.remove();
const panel = document.createElement('div');
panel.className = 'csk-feedback-reason';
// 标题
const title = document.createElement('div');
title.className = 'csk-feedback-reason__title';
title.textContent = t('feedback_reason_title');
panel.appendChild(title);
// 原因选项按钮
const optionsRow = document.createElement('div');
optionsRow.className = 'csk-feedback-reason__options';
REASON_OPTIONS.forEach((opt) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'csk-feedback-reason__btn';
btn.textContent = t(opt.labelKey);
btn.addEventListener('click', (e) => {
e.stopPropagation();
onConfirm(opt.key);
panel.remove();
});
optionsRow.appendChild(btn);
});
panel.appendChild(optionsRow);
// 补充说明输入框(可选)
const commentRow = document.createElement('div');
commentRow.className = 'csk-feedback-reason__comment-row';
const commentInput = document.createElement('input');
commentInput.type = 'text';
commentInput.className = 'csk-feedback-reason__comment';
commentInput.placeholder = t('feedback_reason_comment_placeholder');
commentInput.maxLength = 200;
commentRow.appendChild(commentInput);
const submitBtn = document.createElement('button');
submitBtn.type = 'button';
submitBtn.className = 'csk-feedback-reason__submit';
submitBtn.textContent = t('feedback_reason_submit');
submitBtn.addEventListener('click', (e) => {
e.stopPropagation();
const comment = commentInput.value.trim() || undefined;
onConfirm('other', comment);
panel.remove();
});
commentRow.appendChild(submitBtn);
panel.appendChild(commentRow);
// 关闭按钮
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'csk-feedback-reason__close';
closeBtn.setAttribute('aria-label', t('close'));
closeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
panel.remove();
});
panel.appendChild(closeBtn);
wrapper.appendChild(panel);
}
const STORAGE_PREFIX = 'csk_history_'; const STORAGE_PREFIX = 'csk_history_';
const MAX_MESSAGES = 200; const MAX_MESSAGES = 200;
@ -4330,6 +4519,7 @@ var ChatbotSDK = (function () {
/** /**
* 处理消息反馈切换 AI 消息的点赞/点踩状态 * 处理消息反馈切换 AI 消息的点赞/点踩状态
* 前端状态持久化存入 messages 数组 + localStorage+ 调用后端 API 记录反馈 * 前端状态持久化存入 messages 数组 + localStorage+ 调用后端 API 记录反馈
* 点踩时弹出原因选择弹窗点赞直接提交
*/ */
function handleFeedback(msgId, value) { function handleFeedback(msgId, value) {
if (!messagesContainer$1) if (!messagesContainer$1)
@ -4337,20 +4527,50 @@ var ChatbotSDK = (function () {
const msg = messages.find(m => m.id === msgId && m.role === 'ai'); const msg = messages.find(m => m.id === msgId && m.role === 'ai');
if (!msg) if (!msg)
return; return;
// 切换逻辑:同值取消,异值切换
const newValue = msg.feedback === value ? undefined : value;
msg.feedback = newValue;
// 更新 DOM 状态
const wrapper = messagesContainer$1.querySelector(`[data-csk-msg-id="${msgId}"]`);
if (wrapper)
updateFeedbackUI(wrapper, newValue);
// 持久化(localStorage)
if (config$1)
saveMessages(config$1.integrateId, messages);
// 调用后端 API 记录反馈
if (newValue) {
const feedbackType = newValue === 'up' ? 'THUMBS_UP' : 'THUMBS_DOWN';
submitFeedbackApi(String(msgId), feedbackType).then(success => {
if (value === 'up') {
// 点赞:直接切换提交,无需原因
const newValue = msg.feedback === 'up' ? undefined : 'up';
msg.feedback = newValue;
msg.feedbackReason = undefined;
msg.feedbackComment = undefined;
const wrapper = messagesContainer$1.querySelector(`[data-csk-msg-id="${msgId}"]`);
if (wrapper)
updateFeedbackUI(wrapper, newValue);
if (config$1)
saveMessages(config$1.integrateId, messages);
if (newValue) {
submitFeedbackApi(String(msgId), 'THUMBS_UP').then(success => {
});
}
return;
}
// 点踩:弹出原因选择弹窗
if (value === 'down') {
const wrapper = messagesContainer$1.querySelector(`[data-csk-msg-id="${msgId}"]`);
if (!wrapper)
return;
// 如果已点踩,取消(同 SDK 现有行为)
if (msg.feedback === 'down') {
msg.feedback = undefined;
msg.feedbackReason = undefined;
msg.feedbackComment = undefined;
if (wrapper)
updateFeedbackUI(wrapper, undefined);
if (config$1)
saveMessages(config$1.integrateId, messages);
return;
}
// 弹出原因选择面板
showFeedbackReasonPanel(wrapper, msgId, (reason, comment) => {
msg.feedback = 'down';
msg.feedbackReason = reason;
msg.feedbackComment = comment;
if (wrapper)
updateFeedbackUI(wrapper, 'down');
if (config$1)
saveMessages(config$1.integrateId, messages);
submitFeedbackApi(String(msgId), 'THUMBS_DOWN', reason, comment).then(success => {
});
}); });
} }
} }

2
src/main/resources/static/sdk/chatbot-sdk.min.js
File diff suppressed because it is too large
View File

227
src/test/java/com/wok/supportbot/MessageFeedbackTests.java

@ -0,0 +1,227 @@
package com.wok.supportbot;
import com.wok.supportbot.dao.MessageFeedbackMapper;
import com.wok.supportbot.entity.MessageFeedback;
import com.wok.supportbot.service.MessageFeedbackService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.*;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/**
* 消息反馈功能集成测试
* 覆盖提交/更新/查询/统计/取消 完整链路
* <p>
* 需要运行中的 PostgreSQL
*/
@SpringBootTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@DisplayName("消息反馈功能集成测试")
class MessageFeedbackTests {
@Resource
private MessageFeedbackService messageFeedbackService;
@Resource
private MessageFeedbackMapper messageFeedbackMapper;
private static final String TEST_MESSAGE_ID = "test_feedback_" + UUID.randomUUID().toString().substring(0, 8);
private static final String TEST_CONVERSATION_ID = "test_conv_" + UUID.randomUUID().toString().substring(0, 8);
// ==================== 提交与查询 ====================
@Test
@Order(1)
@DisplayName("F-01 新增反馈(点赞)应持久化到数据库")
void submitThumbsUp() {
MessageFeedback fb = MessageFeedback.builder()
.messageId(TEST_MESSAGE_ID)
.conversationId(TEST_CONVERSATION_ID)
.feedbackType("THUMBS_UP")
.build();
MessageFeedback saved = messageFeedbackService.submitFeedback(fb);
assertNotNull(saved);
assertNotNull(saved.getId());
assertEquals(TEST_MESSAGE_ID, saved.getMessageId());
assertEquals("THUMBS_UP", saved.getFeedbackType());
}
@Test
@Order(2)
@DisplayName("F-02 按 messageId 查询反馈")
void getByMessageId() {
MessageFeedback fb = messageFeedbackService.getByMessageId(TEST_MESSAGE_ID);
assertNotNull(fb);
assertEquals(TEST_MESSAGE_ID, fb.getMessageId());
assertEquals("THUMBS_UP", fb.getFeedbackType());
}
@Test
@Order(3)
@DisplayName("F-03 覆盖提交(upsert):点赞改为点踩")
void upsertFeedback() {
MessageFeedback fb = MessageFeedback.builder()
.messageId(TEST_MESSAGE_ID)
.conversationId(TEST_CONVERSATION_ID)
.feedbackType("THUMBS_DOWN")
.reasonCategory("inaccurate")
.reasonComment("答案有误")
.build();
MessageFeedback saved = messageFeedbackService.submitFeedback(fb);
// 验证覆盖后只有一条记录
MessageFeedback queried = messageFeedbackService.getByMessageId(TEST_MESSAGE_ID);
assertNotNull(queried);
assertEquals("THUMBS_DOWN", queried.getFeedbackType());
assertEquals("inaccurate", queried.getReasonCategory());
assertEquals("答案有误", queried.getReasonComment());
// 验证 upsert 逻辑id 应与原始相同 (不是新插入)
assertEquals(queried.getId(), saved.getId());
}
// ==================== 批量查询 ====================
@Test
@Order(4)
@DisplayName("F-04 批量查询反馈(SDK 回显场景)")
void getBatchByMessageIds() {
// 插入一条额外反馈
String msg2 = "test_batch_" + UUID.randomUUID().toString().substring(0, 8);
messageFeedbackService.submitFeedback(MessageFeedback.builder()
.messageId(msg2)
.conversationId(TEST_CONVERSATION_ID)
.feedbackType("THUMBS_UP")
.build());
List<MessageFeedback> list = messageFeedbackService.getBatchByMessageIds(
List.of(TEST_MESSAGE_ID, msg2, "non_existent_id"));
assertEquals(2, list.size());
}
// ==================== 按会话查询 ====================
@Test
@Order(5)
@DisplayName("F-05 按会话 ID 查询所有反馈")
void getByConversationId() {
List<MessageFeedback> list = messageFeedbackService.getByConversationId(TEST_CONVERSATION_ID);
assertFalse(list.isEmpty());
assertTrue(list.stream().allMatch(fb -> TEST_CONVERSATION_ID.equals(fb.getConversationId())));
}
// ==================== 统计 ====================
@Test
@Order(6)
@DisplayName("F-06 获取全局反馈统计")
void getStats() {
Map<String, Object> stats = messageFeedbackService.getStats(null, null);
assertNotNull(stats);
assertTrue(stats.containsKey("totalFeedbacks"));
assertTrue(stats.containsKey("thumbsUpCount"));
assertTrue(stats.containsKey("thumbsDownCount"));
assertTrue(stats.containsKey("satisfactionRate"));
assertTrue(stats.containsKey("reasonDistribution"));
assertTrue(stats.containsKey("dailyTrends"));
// 满意率应在 0.0 ~ 1.0 之间
double rate = (Double) stats.get("satisfactionRate");
assertTrue(rate >= 0.0 && rate <= 1.0, "满意率应在 0~1 之间,实际: " + rate);
}
@Test
@Order(7)
@DisplayName("F-07 统计按日期过滤")
void getStatsWithDateFilter() {
// 用近期日期范围过滤
Map<String, Object> stats = messageFeedbackService.getStats("2020-01-01", "2099-12-31");
assertNotNull(stats);
long total = ((Number) stats.get("totalFeedbacks")).longValue();
assertTrue(total > 0, "宽日期范围应有数据");
// 用不可能有数据的日期范围过滤
stats = messageFeedbackService.getStats("2000-01-01", "2000-01-02");
long zero = ((Number) stats.get("totalFeedbacks")).longValue();
assertEquals(0L, zero, "远古日期范围应无数反馈");
}
// ==================== 边界条件 ====================
@Test
@Order(8)
@DisplayName("F-08 查询不存在的 messageId 返回 null")
void getByNonExistentMessageId() {
MessageFeedback fb = messageFeedbackService.getByMessageId("non_existent_" + UUID.randomUUID());
assertNull(fb);
}
@Test
@Order(9)
@DisplayName("F-09 批量查询传入空列表返回空结果")
void getBatchByEmptyIds() {
List<MessageFeedback> list = messageFeedbackService.getBatchByMessageIds(List.of());
assertTrue(list.isEmpty());
}
@Test
@Order(10)
@DisplayName("F-10 提交反馈后 createTime 和 updateTime 不为空")
void verifyTimestamps() {
String msgId = "test_ts_" + UUID.randomUUID().toString().substring(0, 8);
MessageFeedback fb = MessageFeedback.builder()
.messageId(msgId)
.conversationId(TEST_CONVERSATION_ID)
.feedbackType("THUMBS_UP")
.build();
MessageFeedback saved = messageFeedbackService.submitFeedback(fb);
assertNotNull(saved.getCreateTime());
assertNotNull(saved.getUpdateTime());
}
@Test
@Order(11)
@DisplayName("F-11 更新反馈时 updateTime 应更新")
void verifyUpdateTimeChanges() throws InterruptedException {
String msgId = "test_uptime_" + UUID.randomUUID().toString().substring(0, 8);
MessageFeedback fb = MessageFeedback.builder()
.messageId(msgId)
.conversationId(TEST_CONVERSATION_ID)
.feedbackType("THUMBS_UP")
.build();
MessageFeedback first = messageFeedbackService.submitFeedback(fb);
Thread.sleep(10); // 确保时间戳有差异
MessageFeedback update = MessageFeedback.builder()
.messageId(msgId)
.conversationId(TEST_CONVERSATION_ID)
.feedbackType("THUMBS_DOWN")
.build();
MessageFeedback second = messageFeedbackService.submitFeedback(update);
assertTrue(second.getUpdateTime().after(first.getUpdateTime()),
"更新后 updateTime 应晚于初始值");
}
// ==================== 点踩原因分布 ====================
@Test
@Order(12)
@DisplayName("F-12 点踩原因包含四种分类")
void reasonCategoryValues() {
// 验证统计中的 reasonDistribution 返回正确
Map<String, Object> stats = messageFeedbackService.getStats(null, null);
@SuppressWarnings("unchecked")
Map<String, Long> dist = (Map<String, Long>) stats.get("reasonDistribution");
assertNotNull(dist);
// 键应为四种原因之一
dist.keySet().forEach(key -> assertTrue(
List.of("inaccurate", "irrelevant", "incomplete", "other").contains(key),
"未知原因分类: " + key));
}
}
Loading…
Cancel
Save