Commit 710c5e1c by Shucai Chen

修复

parent 25c49386
...@@ -7,24 +7,25 @@ import cn.iocoder.yudao.module.ai.dal.dataobject.chat.AiChatSummaryDO; ...@@ -7,24 +7,25 @@ import cn.iocoder.yudao.module.ai.dal.dataobject.chat.AiChatSummaryDO;
import cn.iocoder.yudao.module.ai.dal.dataobject.knowledge.AiKnowledgeDocumentDO; import cn.iocoder.yudao.module.ai.dal.dataobject.knowledge.AiKnowledgeDocumentDO;
import cn.iocoder.yudao.module.ai.dal.mysql.chat.AiChatAnalysisMapper; import cn.iocoder.yudao.module.ai.dal.mysql.chat.AiChatAnalysisMapper;
import cn.iocoder.yudao.module.ai.dal.mysql.chat.AiChatSummaryMapper; import cn.iocoder.yudao.module.ai.dal.mysql.chat.AiChatSummaryMapper;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.client.RestTemplate;
import java.time.DayOfWeek; import java.time.DayOfWeek;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.ArrayList; import java.util.*;
import java.util.HashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.*; import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.*;
/** /**
...@@ -43,6 +44,43 @@ public class AiChatAnalysisServiceImpl implements AiChatAnalysisService { ...@@ -43,6 +44,43 @@ public class AiChatAnalysisServiceImpl implements AiChatAnalysisService {
@Resource @Resource
private AiChatSummaryMapper chatSummaryMapper; private AiChatSummaryMapper chatSummaryMapper;
@Resource
private RestTemplate restTemplate;
@Resource
private AdminUserApi adminUserApi;
// ==================== FastGPT 知识库配置 ====================
private static final String FASTGPT_BASE_URL = "http://192.168.252.71:18089";
private static final String FASTGPT_OPENAPI_BASE_URL = "http://192.168.252.71:18089/api/v1";
/** 测试智能体 key(测试按钮使用) */
private static final String TEST_AGENT_KEY = "fastgpt-cVpF0oIC2aqU0Xk9kzENXE6JsuykGt6c63pLitJLHHAETWqKLzQavD4QG3WX";
/** 测试知识库 ID(上传时先上传到这里) */
private static final Map<String, String> TEST_KNOWLEDGE_BASE_MAP = Map.of(
"公租房", "6a471965df2c00b51ed97c4e",
"长租房", "6a471957df2c00b51ed97b29"
);
/** 正式知识库 ID(测试通过后上传到这里) */
private static final Map<String, String> PRODUCTION_KNOWLEDGE_BASE_MAP = Map.of(
"公租房", "6a4afb9bdf2c00b51eff4ca1",
"长租房", "6a4afba5df2c00b51eff4dba"
);
/** 上传使用的 API key(测试/正式知识库共用) */
private static final Map<String, String> FASTGPT_API_KEY_MAP = Map.of(
"公租房", "fastgpt-dU1AvlAtczVI95eEzmN7Dkx9O3hXArmUtUFYrbIbwUDpFHFPIPIqKw4m3S1jt2uB",
"长租房", "fastgpt-t5H5QVcn3jb1Jj9mW169VbfZcphuFQrsOeiAIfdQbXD7Bld789QJle2ERt7ex"
);
/** 测试知识库集合 ID 缓存 */
private static final Map<String, String> TEST_COLLECTION_ID_CACHE = new ConcurrentHashMap<>();
/** 正式知识库集合 ID 缓存 */
private static final Map<String, String> PRODUCTION_COLLECTION_ID_CACHE = new ConcurrentHashMap<>();
private static final String COLLECTION_NAME = "对话知识库分析";
public AiChatAnalysisDO validateChatAnalysisExists(Long id) { public AiChatAnalysisDO validateChatAnalysisExists(Long id) {
AiChatAnalysisDO conversation = chatAnalysisMapper.selectById(id); AiChatAnalysisDO conversation = chatAnalysisMapper.selectById(id);
...@@ -185,9 +223,6 @@ public class AiChatAnalysisServiceImpl implements AiChatAnalysisService { ...@@ -185,9 +223,6 @@ public class AiChatAnalysisServiceImpl implements AiChatAnalysisService {
throw exception(CHAT_ANALYSIS_NOT_EXISTS); throw exception(CHAT_ANALYSIS_NOT_EXISTS);
} }
LocalDateTime summaryTime = createReqVO.getSummaryTime(); LocalDateTime summaryTime = createReqVO.getSummaryTime();
if (summaryTime != null) {
summaryTime = summaryTime.withHour(0).withMinute(0).withSecond(0).withNano(0);
}
// 2. 创建 AiChatConversationDO 聊天对话 // 2. 创建 AiChatConversationDO 聊天对话
AiChatSummaryDO conversation = new AiChatSummaryDO().setChatId(createReqVO.getChatId()) AiChatSummaryDO conversation = new AiChatSummaryDO().setChatId(createReqVO.getChatId())
.setSummary(createReqVO.getSummary()) .setSummary(createReqVO.getSummary())
...@@ -211,9 +246,234 @@ public class AiChatAnalysisServiceImpl implements AiChatAnalysisService { ...@@ -211,9 +246,234 @@ public class AiChatAnalysisServiceImpl implements AiChatAnalysisService {
throw exception(CHAT_ANALYSIS_NOT_EXISTS); throw exception(CHAT_ANALYSIS_NOT_EXISTS);
} }
// 2. 更新状态 // 2. 获取当前用户昵称
String modifierName = null;
try {
Long userId = getLoginUserId();
if (userId != null) {
// 优先从安全上下文获取昵称
modifierName = cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserNickname();
// 如果昵称为空,从 AdminUserApi 获取
if (cn.hutool.core.util.StrUtil.isBlank(modifierName)) {
AdminUserRespDTO user = adminUserApi.getUser(userId);
if (user != null) {
modifierName = user.getNickname();
}
}
// 如果仍然为空,使用"管理员"作为默认显示名
if (cn.hutool.core.util.StrUtil.isBlank(modifierName)) {
modifierName = "管理员";
}
}
} catch (Exception e) {
log.warn("[updateSummaryStatus] 获取当前用户信息失败", e);
}
// 3. 更新状态 + 处理人昵称
chatSummaryMapper.updateById(new AiChatSummaryDO() chatSummaryMapper.updateById(new AiChatSummaryDO()
.setId(reqVO.getId()).setStatus(reqVO.getStatus())); .setId(reqVO.getId())
.setStatus(reqVO.getStatus())
.setModifierName(modifierName));
}
@Override
public void uploadToFastGpt(AiChatSummaryUploadReqVO reqVO) {
uploadToKnowledgeBase(reqVO, TEST_KNOWLEDGE_BASE_MAP, TEST_COLLECTION_ID_CACHE, "测试");
}
@Override
public void uploadToProductionFastGpt(AiChatSummaryUploadReqVO reqVO) {
uploadToKnowledgeBase(reqVO, PRODUCTION_KNOWLEDGE_BASE_MAP, PRODUCTION_COLLECTION_ID_CACHE, "正式");
}
private void uploadToKnowledgeBase(AiChatSummaryUploadReqVO reqVO, Map<String, String> knowledgeBaseMap,
Map<String, String> collectionIdCache, String envName) {
String datasetId = knowledgeBaseMap.get(reqVO.getCategory());
if (datasetId == null) {
throw exception(FASTGPT_CONFIG_ERROR);
}
try {
String token = getFastGptToken(reqVO.getCategory());
String collectionId = collectionIdCache.get(datasetId);
if (collectionId == null) {
synchronized (collectionIdCache) {
collectionId = collectionIdCache.get(datasetId);
if (collectionId == null) {
collectionId = createTextCollection(token, datasetId);
collectionIdCache.put(datasetId, collectionId);
log.info("[uploadTo{}KnowledgeBase] 首次创建集合, collectionId={}", envName, collectionId);
}
}
} else {
log.info("[uploadTo{}KnowledgeBase] 复用已有集合, collectionId={}", envName, collectionId);
}
pushQaData(token, collectionId, reqVO.getKbTitle(), reqVO.getKbContent());
log.info("[uploadTo{}KnowledgeBase] QA 数据推送成功", envName);
} catch (Exception e) {
log.error("[uploadTo{}KnowledgeBase] 上传至 FastGPT 失败", envName, e);
throw exception(FASTGPT_UPLOAD_FAILED);
}
}
// ==================== FastGPT API 调用 ====================
/**
* 获取 FastGPT 鉴权 token(按知识库区分)
*/
private String getFastGptToken(String category) {
String apiKey = FASTGPT_API_KEY_MAP.get(category);
if (apiKey == null) {
throw new RuntimeException("未找到分类 " + category + " 对应的 FastGPT API Key");
}
return apiKey;
}
/**
* 创建文本集合,返回 collectionId
* 接口:POST /api/core/dataset/collection/create/text
*/
private String createTextCollection(String token, String datasetId) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(token);
Map<String, Object> body = new HashMap<>();
body.put("datasetId", datasetId);
body.put("name", COLLECTION_NAME);
body.put("text", ""); // 空文本,避免自动生成多余 chunk
body.put("trainingType", "chunk");
body.put("chunkSize", 500);
HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(
FASTGPT_BASE_URL + "/api/core/dataset/collection/create/text",
request, Map.class);
if (response.getStatusCode() != HttpStatus.OK || response.getBody() == null) {
throw new RuntimeException("创建文本集合失败, status=" + response.getStatusCode());
}
Map<String, Object> responseBody = response.getBody();
Integer code = (Integer) responseBody.get("code");
if (code == null || code != 200) {
throw new RuntimeException("创建文本集合失败, response=" + responseBody);
}
Map<String, Object> data = (Map<String, Object>) responseBody.get("data");
if (data == null || !data.containsKey("collectionId")) {
throw new RuntimeException("创建文本集合响应中未找到 collectionId");
}
return (String) data.get("collectionId");
}
/**
* 以 QA 模式推送数据到集合(q=用户问题, a=AI回答)
* 接口:POST /api/core/dataset/data/pushData
*/
private void pushQaData(String token, String collectionId, String q, String a) {
// 创建集合后需要短暂等待,确保集合已就绪
try {
Thread.sleep(2000);
} catch (InterruptedException ignored) {
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(token);
// 构建单个 QA 数据项
Map<String, Object> qaItem = new HashMap<>();
qaItem.put("q", q);
qaItem.put("a", a);
Map<String, Object> body = new HashMap<>();
body.put("collectionId", collectionId);
body.put("trainingMode", "chunk");
body.put("data", Collections.singletonList(qaItem));
HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(
FASTGPT_BASE_URL + "/api/core/dataset/data/pushData",
request, Map.class);
if (response.getStatusCode() != HttpStatus.OK || response.getBody() == null) {
throw new RuntimeException("推送 QA 数据失败, status=" + response.getStatusCode());
}
Map<String, Object> responseBody = response.getBody();
Integer code = (Integer) responseBody.get("code");
if (code == null || code != 200) {
throw new RuntimeException("推送 QA 数据失败, response=" + responseBody);
}
// insertLen 在 responseBody.data 内部
Map<String, Object> data = (Map<String, Object>) responseBody.get("data");
Integer insertLen = data != null ? (Integer) data.get("insertLen") : 0;
if (insertLen == null || insertLen == 0) {
throw new RuntimeException("推送 QA 数据成功但 insertLen=0,数据可能未被索引");
}
}
@Override
public String testFastGpt(String question, String category) {
// 使用测试智能体 key,通过 house_type 变量区分公租房/长租房
String token = TEST_AGENT_KEY;
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(token);
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("messages", Collections.singletonList(Map.of("role", "user", "content", question)));
requestBody.put("stream", false);
requestBody.put("variables", Map.of("house_type", category));
HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(
FASTGPT_OPENAPI_BASE_URL + "/chat/completions",
request, Map.class);
if (response.getStatusCode() != HttpStatus.OK || response.getBody() == null) {
throw new RuntimeException("FastGPT 测试连接失败, status=" + response.getStatusCode());
}
Map<String, Object> responseBody = response.getBody();
Integer code = (Integer) responseBody.get("code");
if (code != null && code != 200) {
throw new RuntimeException("FastGPT 测试失败, response=" + responseBody);
}
// 兼容两种响应格式:OpenAI 兼容格式 或 FastGPT 内部格式
// OpenAI: { choices: [{ message: { content: "..." } }] }
// FastGPT: { data: { choices: [{ message: { content: "..." } }] } }
Map<String, Object> data = (Map<String, Object>) responseBody.get("data");
List<Map<String, Object>> choices;
if (data != null && data.containsKey("choices")) {
choices = (List<Map<String, Object>>) data.get("choices");
} else if (responseBody.containsKey("choices")) {
choices = (List<Map<String, Object>>) responseBody.get("choices");
} else {
// 如果 responseBody 本身就有 message/content 字段(部分 FastGPT 版本)
Object directContent = responseBody.get("content");
if (directContent != null) {
return directContent.toString();
}
throw new RuntimeException("FastGPT 响应格式异常,无法解析回复");
}
if (choices == null || choices.isEmpty()) {
throw new RuntimeException("FastGPT 返回空结果");
}
Map<String, Object> message = (Map<String, Object>) choices.get(0).get("message");
if (message == null || !message.containsKey("content")) {
throw new RuntimeException("FastGPT 响应中未找到回复内容");
}
return (String) message.get("content");
} }
private Double calculatePercent(Long current, Long previous) { private Double calculatePercent(Long current, Long previous) {
......
<template> <template>
<!-- ==================== 视图1: 表格页 ==================== -->
<div v-show="currentView === 'table'">
<ContentWrap> <ContentWrap>
<!-- 搜索工作栏 --> <div class="filter-card">
<el-form <div class="filter-item">
class="-mb-15px" <span class="filter-label">分类</span>
:model="queryParams" <el-select v-model="queryParams.type" placeholder="请选择分类" clearable style="width:220px">
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="分类" prop="type">
<el-select
v-model="queryParams.type"
placeholder="请选择分类"
clearable
class="!w-240px"
>
<el-option label="公租房" value="公租房" /> <el-option label="公租房" value="公租房" />
<el-option label="长租房" value="长租房" /> <el-option label="长租房" value="长租房" />
</el-select> </el-select>
</el-form-item> </div>
<el-form-item label="状态" prop="status"> <div class="filter-item">
<el-select <span class="filter-label">状态</span>
v-model="queryParams.status" <el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width:220px">
placeholder="请选择状态"
clearable
class="!w-240px"
>
<el-option label="待处理" :value="0" /> <el-option label="待处理" :value="0" />
<el-option label="已处理" :value="1" /> <el-option label="已处理" :value="1" />
</el-select> </el-select>
</el-form-item> </div>
<el-form-item> <button class="btn" @click="handleQuery">🔍 搜索</button>
<el-button @click="handleQuery"> <button class="btn" @click="resetQuery">↻ 重置</button>
<Icon icon="ep:search" class="mr-5px" /> 搜索 </div>
</el-button>
<el-button @click="resetQuery">
<Icon icon="ep:refresh" class="mr-5px" /> 重置
</el-button>
</el-form-item>
</el-form>
</ContentWrap> </ContentWrap>
<!-- 列表 -->
<ContentWrap> <ContentWrap>
<el-table <div class="table-card">
v-loading="loading" <table>
:data="list" <thead>
:stripe="true" <tr>
:show-overflow-tooltip="true" <th class="col-index">序号</th>
> <th class="col-category">分类</th>
<!-- <el-table-column label="编号" align="center" prop="id" width="100px" /> --> <th>建议内容</th>
<el-table-column label="序号" align="center" type="index" width="100px" /> <th class="col-time">分析时间</th>
<el-table-column label="分类" align="center" prop="type" min-width="120px" /> <th class="col-status">状态</th>
<el-table-column label="建议内容" align="left" prop="summary" min-width="300px"> <th class="col-action">操作</th>
<template #default="scope"> </tr>
<div class="summary-content">{{ scope.row.summary || '-' }}</div> </thead>
</template> <tbody id="tableBody">
</el-table-column> <tr
<!-- 新增:状态标签列 --> v-for="(row, idx) in list" :key="row.id"
<el-table-column label="状态" align="center" width="100px"> :data-id="row.id"
<template #default="scope"> :data-category="row.type"
<el-tag :type="scope.row.status === 1 ? 'success' : 'warning'"> :data-original-question="row.originalQuestion || ''">
{{ scope.row.status === 1 ? '已处理' : '待处理' }} <td class="col-index">{{ (queryParams.pageNo - 1) * queryParams.pageSize + idx + 1 }}</td>
</el-tag> <td class="col-category">{{ row.type }}</td>
</template> <td class="col-content">{{ row.summary || '-' }}</td>
</el-table-column> <td class="col-time">{{ formatTime(row.summaryTime || row.createTime) }}</td>
<el-table-column label="是否已修改" align="center" prop="status" width="100px"> <td class="col-status">
<template #default="scope"> <span :class="['status-tag', row.status === 1 ? 'done' : '']">
<el-checkbox {{ row.status === 1 ? '已完成' : '待处理' }}
v-model="scope.row.status" </span>
:true-value="1" <div v-if="row.status === 1" class="completed-info">
:false-value="0" <div v-if="row.modifierName">处理人:{{ row.modifierName }}</div>
@change="handleStatusChange(scope.row, $event)" <div v-if="row.updateTime">{{ formatTime(row.updateTime) }}</div>
/> </div>
</template> </td>
</el-table-column> <td class="col-action">
</el-table> <button class="btn-handle" @click="goToEdit(row)">处理</button>
</td>
<!-- 分页 --> </tr>
</tbody>
</table>
</div>
<Pagination <Pagination
:total="total" :total="total"
v-model:page="queryParams.pageNo" v-model:page="queryParams.pageNo"
...@@ -85,23 +68,136 @@ ...@@ -85,23 +68,136 @@
@pagination="getList" @pagination="getList"
/> />
</ContentWrap> </ContentWrap>
</div>
<!-- ==================== 视图2: 编辑页 ==================== -->
<div v-show="currentView === 'edit'">
<ContentWrap>
<div class="steps">
<div class="step active"><span class="step-num">1</span><span>编辑知识库</span></div>
<span class="step-line"></span>
<div class="step"><span class="step-num">2</span><span>测试验证</span></div>
<span class="step-line"></span>
<div class="step"><span class="step-num">3</span><span>完成处理</span></div>
</div>
<div class="page-header">
<div class="page-title">📝 知识库编辑</div>
</div>
<!-- 原对话 -->
<div class="dialog-card">
<div class="ref-label">💬 原对话</div>
<div class="dialog-bubble user">
<span class="bubble-label">用户</span>
<span class="bubble-text" id="dlgUserQ">{{ editForm.question }}</span>
</div>
<div class="dialog-bubble ai">
<span class="bubble-label">AI</span>
<span class="bubble-text" id="dlgAiA">{{ editForm.answer }}</span>
</div>
</div>
<!-- 分析建议 -->
<div class="ref-card">
<div class="ref-label">{{ '📌 对话分析建议(分类:' + editForm.category + ')' }}</div>
<div class="ref-content">{{ editForm.suggestionContent }}</div>
</div>
<!-- 编辑表单 -->
<div class="form-card">
<div class="form-group">
<div class="form-label">
<span class="required">*</span>问答标题<span class="label-tip">(可修改)</span>
</div>
<input class="form-input" v-model="editForm.kbTitle" type="text" placeholder="请输入知识库条目标题" />
</div>
<div class="form-group">
<div class="form-label"><span class="required">*</span>正文内容</div>
<textarea class="form-textarea" v-model="editForm.kbContent" placeholder="请输入知识库条目正文内容&#10;&#10;提示:请根据原始建议完善知识内容,确保信息准确、完整、易于检索回答。"></textarea>
<div class="char-count"><span>{{ editForm.kbContent.length }}</span></div>
<div class="tip-text">💡 编辑完成后点击"保存并测试",将进入测试验证环节确认知识库条目是否正确回答问题。</div>
</div>
</div>
<div class="btn-row">
<button class="btn" @click="switchView('table')">← 返回列表</button>
<button class="btn btn-primary" :disabled="saving" @click="saveAndTest">💾 保存并测试</button>
</div>
</ContentWrap>
</div>
<!-- ==================== 视图3: 测试页 ==================== -->
<div v-show="currentView === 'test'">
<ContentWrap>
<div class="steps">
<div class="step done"><span class="step-num">1</span><span>编辑知识库</span></div>
<span class="step-line"></span>
<div class="step active"><span class="step-num">2</span><span>测试验证</span></div>
<span class="step-line"></span>
<div class="step"><span class="step-num">3</span><span>完成处理</span></div>
</div>
<div class="page-header">
<div class="page-title">🧪 知识测试验证</div>
</div>
<div class="info-card">
<div class="info-row"><span class="info-label">原始问题</span><span class="info-value">{{ testInfo.originalQuestion }}</span></div>
<div class="info-row"><span class="info-label">建议分类</span><span class="info-value"><span class="cat-tag">{{ testInfo.category }}</span></span></div>
<div class="info-row"><span class="info-label">知识标题</span><span class="info-value">{{ testInfo.kbTitle }}</span></div>
</div>
<div class="test-list">
<div :class="['test-item', testResultPassed ? 'pass-border' : 'fail-border']">
<div class="test-header">
<div class="test-question"><span class="q-label original">原始问题</span><span>{{ testInfo.originalQuestion }}</span></div>
</div>
<div class="test-body open">
<table class="compare-table">
<tr>
<th class="col-label">AI 回答</th>
<td class="ai-answer">{{ testInfo.aiAnswer || '等待测试...' }}</td>
</tr>
<tr>
<th class="col-label">期望回答</th>
<td class="expected-answer">{{ testInfo.kbContent || '-' }}</td>
</tr>
</table>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="btn-row">
<button class="btn" @click="switchView('edit')">← 返回编辑</button>
<button class="btn btn-success" id="btnWrite" :disabled="!testDone" @click="writeToKB">测试通过</button>
</div>
</ContentWrap>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage } from 'element-plus'
import { ChatAnalysisApi, ChatAnalysisVO } from '@/api/ai/chat/analysis' import { ChatAnalysisApi } from '@/api/ai/chat/analysis'
/** AI 对话总结列表 */
defineOptions({ name: 'AiChatSummary' }) defineOptions({ name: 'AiChatSummary' })
const message = ElMessage const message = ElMessage
// ==================== 视图切换 ====================
const currentView = ref('table')
function switchView(view: string) {
currentView.value = view
}
// ==================== 表格数据 ====================
const loading = ref(true) const loading = ref(true)
const list = ref<ChatAnalysisVO[]>([]) const list = ref<any[]>([])
const total = ref(0) const total = ref(0)
const queryFormRef = ref()
// 查询参数
const queryParams = reactive({ const queryParams = reactive({
pageNo: 1, pageNo: 1,
pageSize: 10, pageSize: 10,
...@@ -109,8 +205,7 @@ const queryParams = reactive({ ...@@ -109,8 +205,7 @@ const queryParams = reactive({
status: undefined as number | undefined status: undefined as number | undefined
}) })
/** 查询列表 */ async function getList() {
const getList = async () => {
loading.value = true loading.value = true
try { try {
const data = await ChatAnalysisApi.getChatSummaryPage(queryParams) const data = await ChatAnalysisApi.getChatSummaryPage(queryParams)
...@@ -121,59 +216,590 @@ const getList = async () => { ...@@ -121,59 +216,590 @@ const getList = async () => {
} }
} }
/** 搜索按钮操作 */ function handleQuery() {
const handleQuery = () => {
queryParams.pageNo = 1 queryParams.pageNo = 1
getList() getList()
} }
/** 重置按钮操作 */ function resetQuery() {
const resetQuery = () => { queryParams.type = undefined
queryFormRef.value?.resetFields() queryParams.status = undefined
handleQuery() handleQuery()
} }
/** 修改状态操作(使用 switch 切换时触发) */ const formatTime = (time: string | number | undefined | null) => {
const handleStatusChange = async (row: ChatAnalysisVO, newStatus: number) => { if (time === undefined || time === null || time === '') return '-'
const oldStatus = row.status if (typeof time === 'number') {
const d = new Date(time)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
const t = time.replace('T', ' ')
return t.length > 16 ? t.substring(0, 19) : t
}
// ==================== 编辑页数据 ====================
const editForm = reactive({
id: 0,
chatId: '',
category: '',
question: '',
answer: '',
suggestionContent: '',
kbTitle: '',
kbContent: ''
})
/** 从列表点击"处理"进入编辑 */
async function goToEdit(row: any) {
editForm.id = row.id
editForm.chatId = row.chatId || ''
editForm.category = row.type || ''
editForm.suggestionContent = row.summary || ''
editForm.kbTitle = ''
editForm.kbContent = ''
// 加载对话消息
if (editForm.chatId) {
try { try {
// 二次确认 const msgs = await ChatAnalysisApi.getChatList(editForm.chatId)
// await ElMessageBox.confirm( if (msgs && msgs.length > 0) {
// `确认要将该记录状态改为 ${newStatus === 1 ? '已处理' : '未处理'} 吗?`, // 【修复】优先根据 summaryTime 匹配生成该总结的那条消息,而不是取第一条
// '提示', // 因为同一个对话(chatId)下有多条 Q&A,总结是从某一条特定问答生成的
// { const matchedMsg = row.summaryTime
// confirmButtonText: '确定', ? msgs.find((m: any) => {
// cancelButtonText: '取消', // 比较时间,处理可能的格式差异(ISO字符串 / 时间戳 / 数组)
// type: 'warning' const msgTime = m.chatTime
// } return msgTime && String(msgTime) === String(row.summaryTime)
//)
// 调用更新接口
await ChatAnalysisApi.updateSummaryStatus({
id: row.id,
status: newStatus
}) })
message.success('状态更新成功') : null
// 刷新列表(保持当前页码) const targetMsg = matchedMsg || msgs.find((m: any) => m.question)
await getList() editForm.question = targetMsg?.question || '-'
} catch (error) { editForm.answer = targetMsg?.answer || '-'
// 用户取消或接口失败时,恢复原来的状态 // 自动预填标题(用匹配到的消息的question)
row.status = oldStatus if (targetMsg?.question) {
if (error !== 'cancel') { editForm.kbTitle = targetMsg.question
message.error('状态更新失败,请稍后重试') }
}
} catch {
editForm.question = '-'
editForm.answer = '-'
message.warning('加载对话消息失败')
} }
} }
// 重置测试状态
saving.value = false
testResultPassed.value = false
testDone.value = false
testInfo.originalQuestion = editForm.question
testInfo.aiAnswer = ''
testInfo.kbContent = ''
testInfo.category = editForm.category
testInfo.kbTitle = editForm.kbTitle
switchView('edit')
}
// ==================== 测试页数据 ====================
const testResultPassed = ref(false)
const testDone = ref(false)
const uploading = ref(false)
const testInfo = reactive({
originalQuestion: '',
category: '',
kbTitle: '',
kbContent: '',
aiAnswer: ''
})
const saving = ref(false)
/** 保存并测试 - 点击后立即跳转测试页,后台慢慢保存 */
async function saveAndTest() {
if (saving.value) return
if (!editForm.kbTitle.trim()) {
message.warning('请输入知识标题')
return
}
if (!editForm.kbContent.trim()) {
message.warning('请输入正文内容')
return
}
// 立即禁用按钮并跳转测试页
saving.value = true
testInfo.originalQuestion = editForm.question
testInfo.category = editForm.category
testInfo.kbTitle = editForm.kbTitle
testInfo.kbContent = editForm.kbContent
testInfo.aiAnswer = '正在保存并测试,请稍候...'
testResultPassed.value = false
testDone.value = false
switchView('test')
// 后台异步执行上传 + 测试
executeSaveAndTest()
}
async function executeSaveAndTest() {
// 先上传至测试知识库
uploading.value = true
try {
await ChatAnalysisApi.uploadToFastGpt({
id: editForm.id,
kbTitle: editForm.kbTitle.trim(),
kbContent: editForm.kbContent.trim(),
category: editForm.category
})
} catch (e: any) {
message.error('上传至测试知识库失败:' + (e?.message || e?.userMsg || '连接异常'))
uploading.value = false
return
}
uploading.value = false
// 自动调用 FastGPT 测试
await runTest()
}
/** 执行测试 - 调用 FastGPT */
async function runTest() {
if (!testInfo.originalQuestion || testInfo.originalQuestion === '-') {
message.warning('无原始问题可用于测试')
return
}
testDone.value = false
testResultPassed.value = false
try {
const result = await ChatAnalysisApi.testFastGpt({
question: testInfo.originalQuestion,
category: testInfo.category
})
testInfo.aiAnswer = result
testDone.value = true
// 简单的通过判断:不包含"抱歉""暂无"即为通过
testResultPassed.value = result.indexOf('抱歉') === -1 && result.indexOf('暂无') === -1
if (!testResultPassed.value) {
message.warning('AI 回答未能命中知识库,请检查知识内容')
}
} catch (e: any) {
testInfo.aiAnswer = '测试失败:' + (e?.message || e?.userMsg || '连接异常')
testDone.value = true
testResultPassed.value = false
}
}
/** 测试通过 - 自动上传正式知识库 → 标记已完成 → 返回列表 */
async function writeToKB() {
// 1. 先上传至正式知识库
uploading.value = true
try {
await ChatAnalysisApi.uploadToProductionFastGpt({
id: editForm.id,
kbTitle: editForm.kbTitle.trim(),
kbContent: editForm.kbContent.trim(),
category: editForm.category
})
} catch (e: any) {
message.error('上传正式知识库失败:' + (e?.message || e?.userMsg || '连接异常'))
uploading.value = false
return
}
uploading.value = false
// 2. 标记为已完成
message.success('知识库写入成功!正在返回列表页...')
setTimeout(async () => {
try {
await ChatAnalysisApi.updateSummaryStatus({
id: editForm.id,
status: 1
})
} catch {
// 静默处理
}
await getList()
switchView('table')
}, 1500)
} }
/** 初始化加载数据 */
onMounted(() => { onMounted(() => {
getList() getList()
}) })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.summary-content { /* ====== 筛选卡片 ====== */
white-space: pre-wrap; .filter-card {
word-break: break-all; background: #fff;
line-height: 1.5; border-radius: 4px;
padding: 16px 20px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 16px;
}
.filter-item {
display: flex;
align-items: center;
gap: 8px;
}
.filter-label {
font-size: 13px;
color: #333;
white-space: nowrap;
}
/* ====== 通用按钮 ====== */
.btn {
height: 32px;
padding: 0 16px;
border-radius: 4px;
border: 1px solid #d9d9d9;
background: #fff;
cursor: pointer;
font-size: 13px;
display: inline-flex;
align-items: center;
gap: 4px;
color: #333;
&:hover { border-color: #1890ff; color: #1890ff; }
}
.btn-primary {
background: #1890ff;
color: #fff;
border-color: #1890ff;
&:hover { background: #40a9ff; border-color: #40a9ff; color: #fff; }
&:disabled { background: #91caff; border-color: #91caff; cursor: not-allowed; }
}
.btn-success {
background: #52c41a;
color: #fff;
border-color: #52c41a;
&:hover { background: #73d13d; border-color: #73d13d; color: #fff; }
&:disabled { background: #b7eb8f; border-color: #b7eb8f; cursor: not-allowed; }
}
/* ====== 表格 ====== */
.table-card {
background: #fff;
border-radius: 4px;
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
background: #fafafa;
padding: 12px 16px;
text-align: left;
font-size: 13px;
font-weight: 500;
color: #333;
border-bottom: 1px solid #e8e8e8;
}
td {
padding: 14px 16px;
font-size: 13px;
color: #333;
border-bottom: 1px solid #f0f0f0;
vertical-align: top;
word-wrap: break-word;
}
tr:hover td { background: #fafafa; }
tr:nth-child(even) td { background: #fafbfc; }
tr:nth-child(even):hover td { background: #f0f5ff; }
.col-index { width: 60px; text-align: center; }
.col-category { width: 100px; text-align: center; }
.col-status { width: 180px; text-align: center; }
.col-time { width: 150px; text-align: center; white-space: nowrap; }
.col-action { width: 80px; text-align: center; }
.col-content { line-height: 1.6; }
.status-tag {
display: inline-block;
padding: 2px 10px;
background: #fff7e6;
color: #fa8c16;
border: 1px solid #ffd591;
border-radius: 2px;
font-size: 12px;
&.done { background: #f6ffed; color: #52c41a; border-color: #b7eb8f; }
}
.completed-info { font-size: 11px; color: #999; margin-top: 4px; line-height: 1.5; }
.btn-handle {
height: 28px;
padding: 0 12px;
background: #1890ff;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
white-space: nowrap;
&:hover { background: #40a9ff; }
}
/* ====== 步骤条 ====== */
.steps {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
}
.step {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #999;
&.active { color: #1890ff; font-weight: 500; }
&.done { color: #52c41a; }
}
.step-num {
width: 22px;
height: 22px;
border-radius: 50%;
background: #d9d9d9;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
.step.active & { background: #1890ff; }
.step.done & { background: #52c41a; }
}
.step-line {
width: 40px;
height: 1px;
background: #d9d9d9;
.step.done + & { background: #52c41a; }
}
/* ====== 页面标题 ====== */
.page-header { margin-bottom: 20px; }
.page-title { font-size: 18px; font-weight: 500; color: #333; margin-bottom: 8px; }
/* ====== 参考卡片 ====== */
.ref-card {
background: #fff;
border-radius: 4px;
padding: 16px 20px;
margin-bottom: 16px;
border-left: 3px solid #1890ff;
}
.ref-label {
font-size: 12px;
color: #999;
margin-bottom: 6px;
display: inline-block;
}
.ref-content {
font-size: 13px;
color: #555;
line-height: 1.6;
background: #fafafa;
padding: 12px;
border-radius: 4px;
}
/* ====== 对话卡片 ====== */
.dialog-card {
background: #fff;
border-radius: 4px;
padding: 16px 20px;
margin-bottom: 16px;
border-left: 3px solid #52c41a;
}
.dialog-bubble {
display: flex;
align-items: flex-start;
margin-bottom: 10px;
line-height: 1.6;
&:last-child { margin-bottom: 0; }
.bubble-label {
display: inline-block;
width: 36px;
height: 22px;
line-height: 22px;
text-align: center;
border-radius: 3px;
font-size: 11px;
color: #fff;
flex-shrink: 0;
margin-right: 10px;
margin-top: 2px;
}
&.user .bubble-label { background: #1890ff; }
&.ai .bubble-label { background: #52c41a; }
.bubble-text {
font-size: 13px;
color: #333;
background: #fafafa;
padding: 8px 12px;
border-radius: 4px;
flex: 1;
}
}
/* ====== 表单卡片 ====== */
.form-card {
background: #fff;
border-radius: 4px;
padding: 24px 20px;
margin-bottom: 16px;
}
.form-group {
margin-bottom: 20px;
&:last-child { margin-bottom: 0; }
}
.form-label {
font-size: 13px;
font-weight: 500;
color: #333;
margin-bottom: 8px;
display: flex;
align-items: center;
.required { color: #ff4d4f; margin-right: 4px; }
.label-tip { color: #999; font-weight: 400; margin-left: 4px; font-size: 12px; }
}
.form-input {
width: 100%;
height: 36px;
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 0 12px;
font-size: 13px;
color: #333;
outline: none;
transition: border-color 0.2s;
&:focus { border-color: #1890ff; box-shadow: 0 0 0 2px rgba(24,144,255,0.1); }
}
.form-textarea {
width: 100%;
min-height: 260px;
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 12px;
font-size: 13px;
color: #333;
outline: none;
resize: vertical;
line-height: 1.8;
font-family: inherit;
transition: border-color 0.2s;
&:focus { border-color: #1890ff; box-shadow: 0 0 0 2px rgba(24,144,255,0.1); }
}
.char-count {
text-align: right;
font-size: 12px;
color: #999;
margin-top: 4px;
}
.tip-text {
font-size: 12px;
color: #999;
margin-top: 8px;
}
/* ====== 按钮行 ====== */
.btn-row {
display: flex;
gap: 12px;
justify-content: flex-end;
background: #fff;
border-radius: 4px;
padding: 16px 20px;
margin-top: 16px;
}
/* ====== 测试页 ====== */
.info-card {
background: #fff;
border-radius: 4px;
padding: 16px 20px;
margin-bottom: 16px;
}
.info-row {
display: flex;
margin-bottom: 10px;
font-size: 13px;
&:last-child { margin-bottom: 0; }
}
.info-label { color: #999; width: 80px; flex-shrink: 0; }
.info-value { color: #333; line-height: 1.5; }
.cat-tag {
display: inline-block;
padding: 2px 10px;
background: #e6f7ff;
color: #1890ff;
border-radius: 2px;
font-size: 12px;
}
.test-list { margin-bottom: 20px; }
.test-item {
background: #fff;
border-radius: 4px;
margin-bottom: 12px;
overflow: hidden;
border: 1px solid #e8e8e8;
&.pass-border { border-left: 3px solid #52c41a; }
&.fail-border { border-left: 3px solid #ff4d4f; }
}
.test-header {
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f0f0f0;
}
.test-question {
font-size: 13px;
font-weight: 500;
color: #333;
display: flex;
align-items: center;
gap: 8px;
}
.q-label {
display: inline-block;
padding: 2px 8px;
border-radius: 2px;
font-size: 11px;
font-weight: 500;
&.original { background: #e6f7ff; color: #1890ff; }
}
.test-body { display: block; }
.compare-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th {
background: #fafafa;
padding: 10px 12px;
text-align: left;
font-weight: 500;
color: #555;
border: 1px solid #e8e8e8;
}
td {
padding: 10px 12px;
border: 1px solid #e8e8e8;
vertical-align: top;
line-height: 1.6;
color: #333;
}
.col-label { width: 90px; color: #999; }
} }
.ai-answer { background: #f6ffed; color: #333; }
.expected-answer { background: #e6f7ff; color: #333; }
</style> </style>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment