Merge branch 'master' into docker

This commit is contained in:
2025-12-31 17:37:45 +08:00
95 changed files with 4842 additions and 1169 deletions

1
.gitignore vendored
View File

@@ -203,3 +203,4 @@ cython_debug/
THAI-Platform/* THAI-Platform/*
urbanLifelineWeb/packages/wechat_demo/* urbanLifelineWeb/packages/wechat_demo/*
urbanLifelineWeb/packages/workcase_wechat/unpackage/* urbanLifelineWeb/packages/workcase_wechat/unpackage/*
docs/AI训练资料

17
difyPlugin/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,17 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "DifyPlugin: FastAPI",
"type": "debugpy",
"request": "launch",
"program": "run.py",
"cwd": "${workspaceFolder}",
"python": "F:\\Environment\\conda\\envs\\difyPlugin\\python.exe",
"env": {
"PYTHONPATH": "${workspaceFolder}/difyPlugin"
},
"jinja": true
}
]
}

0
difyPlugin/.vscode/settings.json vendored Normal file
View File

View File

@@ -169,3 +169,20 @@ COMMENT ON COLUMN ai.tb_knowledge_file.update_time IS '更新时间';
COMMENT ON COLUMN ai.tb_knowledge_file.delete_time IS '删除时间'; COMMENT ON COLUMN ai.tb_knowledge_file.delete_time IS '删除时间';
COMMENT ON COLUMN ai.tb_knowledge_file.deleted IS '是否删除'; COMMENT ON COLUMN ai.tb_knowledge_file.deleted IS '是否删除';
DROP TABLE IF EXISTS ai.tb_knowledge_file_log CASCADE;
CREATE TABLE ai.tb_knowledge_file_log(
optsn VARCHAR(50) NOT NULL, -- 流水号
log_id VARCHAR(50) NOT NULL, -- 日志ID
knowledge_id VARCHAR(50) NOT NULL, -- 知识库ID
file_root_id VARCHAR(50) NOT NULL, -- 文件根ID
file_id VARCHAR(50) NOT NULL, -- 文件ID
file_name VARCHAR(100) NOT NULL, -- 文件名
service VARCHAR(50) NOT NULL, -- 所属服务 workcase、bidding
version INTEGER NOT NULL DEFAULT 1, -- 文件版本
action VARCHAR(50) NOT NULL, -- 操作类型 upload、update、delete
creator VARCHAR(50) NOT NULL, -- 创建者用户ID
creator_name VARCHAR(100) NOT NULL, -- 创建者姓名
create_time TIMESTAMPTZ NOT NULL DEFAULT now(), -- 创建时间
PRIMARY KEY (optsn),
UNIQUE (knowledge_id, file_id)
);

View File

@@ -30,7 +30,7 @@ DROP TABLE IF EXISTS workcase.tb_chat_room CASCADE;
CREATE TABLE workcase.tb_chat_room( CREATE TABLE workcase.tb_chat_room(
optsn VARCHAR(50) NOT NULL, -- 流水号 optsn VARCHAR(50) NOT NULL, -- 流水号
room_id VARCHAR(50) NOT NULL, -- 聊天室ID room_id VARCHAR(50) NOT NULL, -- 聊天室ID
workcase_id VARCHAR(50) DEFAULT NULL, -- 关联工单ID workcase_id VARCHAR(50) DEFAULT NULL, -- 关联工单ID
room_name VARCHAR(200) NOT NULL, -- 聊天室名称(如:工单#12345的客服支持 room_name VARCHAR(200) NOT NULL, -- 聊天室名称(如:工单#12345的客服支持
room_type VARCHAR(20) NOT NULL DEFAULT 'workcase', -- 聊天室类型workcase-工单客服 room_type VARCHAR(20) NOT NULL DEFAULT 'workcase', -- 聊天室类型workcase-工单客服
status VARCHAR(20) NOT NULL DEFAULT 'active', -- 状态active-活跃 closed-已关闭 archived-已归档 status VARCHAR(20) NOT NULL DEFAULT 'active', -- 状态active-活跃 closed-已关闭 archived-已归档
@@ -38,8 +38,10 @@ CREATE TABLE workcase.tb_chat_room(
guest_name VARCHAR(100) NOT NULL, -- 来客姓名 guest_name VARCHAR(100) NOT NULL, -- 来客姓名
ai_session_id VARCHAR(50) DEFAULT NULL, -- AI对话会话ID从ai.tb_chat同步 ai_session_id VARCHAR(50) DEFAULT NULL, -- AI对话会话ID从ai.tb_chat同步
message_count INTEGER NOT NULL DEFAULT 0, -- 消息总数 message_count INTEGER NOT NULL DEFAULT 0, -- 消息总数
device_code VARCHAR(50) NOT NULL, -- 设备代码
last_message_time TIMESTAMPTZ DEFAULT NULL, -- 最后消息时间 last_message_time TIMESTAMPTZ DEFAULT NULL, -- 最后消息时间
last_message TEXT DEFAULT NULL, -- 最后一条消息内容(用于列表展示) last_message TEXT DEFAULT NULL, -- 最后一条消息内容(用于列表展示)
comment_level INTEGER DEFAULT 0, -- 服务评分1-5
closed_by VARCHAR(50) DEFAULT NULL, -- 关闭人 closed_by VARCHAR(50) DEFAULT NULL, -- 关闭人
closed_time TIMESTAMPTZ DEFAULT NULL, -- 关闭时间 closed_time TIMESTAMPTZ DEFAULT NULL, -- 关闭时间
creator VARCHAR(50) NOT NULL, -- 创建人(系统自动创建) creator VARCHAR(50) NOT NULL, -- 创建人(系统自动创建)

View File

@@ -1,20 +1,6 @@
{ {
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{
"type": "java",
"name": "URLQRCodeParseTest",
"request": "launch",
"mainClass": "org.xyzh.workcase.test.URLQRCodeParseTest",
"projectName": "workcase"
},
{
"type": "java",
"name": "QRCodeTest",
"request": "launch",
"mainClass": "org.xyzh.workcase.test.QRCodeTest",
"projectName": "workcase"
},
{ {
"type": "java", "type": "java",
"name": "AesEncryptUtil", "name": "AesEncryptUtil",

View File

@@ -284,9 +284,8 @@ public class DifyApiClient {
dataMap.put("process_rule", defaultProcessRule); dataMap.put("process_rule", defaultProcessRule);
} }
// 默认设置文档形式和语言 // 只保留官方支持的参数
dataMap.put("doc_form", "text_model"); // doc_form 和 doc_language 不是请求参数,移除
dataMap.put("doc_language", "Chinese");
String dataJson = JSON.toJSONString(dataMap); String dataJson = JSON.toJSONString(dataMap);
logger.info("上传文档到知识库: datasetId={}, file={}, data={}", datasetId, originalFilename, dataJson); logger.info("上传文档到知识库: datasetId={}, file={}, data={}", datasetId, originalFilename, dataJson);
@@ -732,6 +731,56 @@ public class DifyApiClient {
} }
} }
/**
* 获取对话变量
* @param conversationId 会话ID
* @param userId 用户标识
* @param lastId 当前页最后面一条记录的ID,默认null
* @param limit 一次请求返回多少条记录,默认20条,最大100条,最小1条
* @param apiKey API密钥
* @return 对话变量响应
*/
public ConversationVariablesResponse getConversationVariables(
String conversationId,
String userId,
String lastId,
Integer limit,
String apiKey) {
StringBuilder urlBuilder = new StringBuilder(
difyConfig.getFullApiUrl("/conversations/" + conversationId + "/variables"));
urlBuilder.append("?user=").append(userId);
if (lastId != null && !lastId.isEmpty()) {
urlBuilder.append("&last_id=").append(lastId);
}
if (limit != null) {
urlBuilder.append("&limit=").append(limit);
}
try {
Request httpRequest = new Request.Builder()
.url(urlBuilder.toString())
.header("Authorization", "Bearer " + getApiKey(apiKey))
.get()
.build();
try (Response response = httpClient.newCall(httpRequest).execute()) {
String responseBody = response.body() != null ? response.body().string() : "";
if (!response.isSuccessful()) {
logger.error("获取对话变量失败: {} - {}", response.code(), responseBody);
throw new DifyException("获取对话变量失败: " + responseBody);
}
return JSON.parseObject(responseBody, ConversationVariablesResponse.class);
}
} catch (IOException e) {
logger.error("获取对话变量异常", e);
throw new DifyException("获取对话变量异常: " + e.getMessage(), e);
}
}
// ===================== 通用 HTTP 方法(用于代理转发)===================== // ===================== 通用 HTTP 方法(用于代理转发)=====================
/** /**

View File

@@ -0,0 +1,69 @@
package org.xyzh.ai.client.dto;
import com.alibaba.fastjson2.annotation.JSONField;
import lombok.Data;
import java.util.List;
/**
* @description 对话变量响应
* @filename ConversationVariablesResponse.java
* @author AI Assistant
* @copyright xyzh
* @since 2025-12-29
*/
@Data
public class ConversationVariablesResponse {
private Integer limit;
@JSONField(name = "has_more")
private Boolean hasMore;
private List<ConversationVariableItem> data;
/**
* 对话中的变量项
*/
@Data
public static class ConversationVariableItem {
/**
* 变量ID
*/
private String id;
/**
* 变量名称
*/
private String name;
/**
* 变量类型 (string, number, boolean 等)
*/
@JSONField(name = "value_type")
private String valueType;
/**
* 变量值
*/
private String value;
/**
* 变量描述
*/
private String description;
/**
* 创建时间戳
*/
@JSONField(name = "created_at")
private Long createdAt;
/**
* 最后更新时间戳
*/
@JSONField(name = "updated_at")
private Long updatedAt;
}
}

View File

@@ -8,6 +8,7 @@ import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import org.xyzh.ai.client.dto.ConversationVariablesResponse;
import org.xyzh.api.ai.dto.ChatPrepareData; import org.xyzh.api.ai.dto.ChatPrepareData;
import org.xyzh.api.ai.dto.TbChat; import org.xyzh.api.ai.dto.TbChat;
import org.xyzh.api.ai.dto.TbChatMessage; import org.xyzh.api.ai.dto.TbChatMessage;
@@ -46,6 +47,9 @@ public class ChatController {
@Autowired @Autowired
private AIFileUploadService fileUploadService; private AIFileUploadService fileUploadService;
@Autowired
private org.xyzh.ai.client.DifyApiClient difyApiClient;
// ====================== 会话管理 ====================== // ====================== 会话管理 ======================
/** /**
@@ -151,7 +155,7 @@ public class ChatController {
* @since 2025-12-17 * @since 2025-12-17
*/ */
@PostMapping("/conversation/page") @PostMapping("/conversation/page")
public ResultDomain<PageDomain<TbChat>> getChatPage(@RequestBody PageRequest<TbChat> pageRequest, @RequestHeader("Authorization") String token) { public ResultDomain<TbChat> getChatPage(@RequestBody PageRequest<TbChat> pageRequest, @RequestHeader("Authorization") String token) {
log.info("分页获取会话列表: agentId={}", pageRequest.getFilter().getAgentId()); log.info("分页获取会话列表: agentId={}", pageRequest.getFilter().getAgentId());
pageRequest.getFilter().setUserType(false); pageRequest.getFilter().setUserType(false);
@@ -164,6 +168,53 @@ public class ChatController {
return chatService.getChatPage(pageRequest); return chatService.getChatPage(pageRequest);
} }
/**
* @description 获取对话变量
* @param params 请求参数包含agentId, conversationId, userId, lastId, limit
* @author yslg
* @since 2025-12-29
*/
@PostMapping("/conversation/variables")
public ResultDomain<org.xyzh.ai.client.dto.ConversationVariablesResponse> getConversationVariables(
@RequestBody Map<String, Object> params,
@RequestHeader("Authorization") String token) {
// 参数验证
ValidationResult result = ValidationUtils.validateMap(params, Arrays.asList(
ValidationUtils.requiredString("agentId", "智能体ID", 1, 100),
ValidationUtils.requiredString("conversationId", "会话ID", 1, 100),
ValidationUtils.requiredString("userId", "用户ID", 1, 100)
));
if (!result.isValid()) {
return ResultDomain.failure(result.getAllErrors());
}
String agentId = (String) params.get("agentId");
String conversationId = (String) params.get("conversationId");
String userId = (String) params.get("userId");
String lastId = params.containsKey("lastId") ? (String) params.get("lastId") : null;
Integer limit = params.containsKey("limit") ?
Integer.parseInt(params.get("limit").toString()) : 20;
log.info("获取对话变量: agentId={}, conversationId={}, userId={}", agentId, conversationId, userId);
try {
// 获取智能体信息以获取 API Key
// 这里需要根据 agentId 获取对应的 API Key
// 暂时先使用一个占位符,实际使用时需要从数据库或配置中获取
// 或者通过 chatService 获取智能体配置
// 调用 Dify API 获取会话变量
ConversationVariablesResponse response =
difyApiClient.getConversationVariables(conversationId, userId, lastId, limit, agentId);
return ResultDomain.success("获取对话变量成功",response);
} catch (Exception e) {
log.error("获取对话变量失败", e);
return ResultDomain.failure("获取对话变量失败: " + e.getMessage());
}
}
// ====================== 消息管理 ====================== // ====================== 消息管理 ======================
/** /**

View File

@@ -7,7 +7,6 @@ import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -16,9 +15,11 @@ import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.xyzh.api.ai.service.DifyProxyService; import org.xyzh.api.ai.service.DifyProxyService;
import org.xyzh.api.ai.service.KnowledgeFileLogService;
import org.xyzh.api.ai.service.KnowledgeService; import org.xyzh.api.ai.service.KnowledgeService;
import org.xyzh.api.ai.dto.TbKnowledge; import org.xyzh.api.ai.dto.TbKnowledge;
import org.xyzh.api.ai.dto.TbKnowledgeFile; import org.xyzh.api.ai.dto.TbKnowledgeFile;
import org.xyzh.api.ai.dto.TbKnowledgeFileLog;
import org.xyzh.api.ai.vo.KnowledgeFileVO; import org.xyzh.api.ai.vo.KnowledgeFileVO;
import org.xyzh.common.core.domain.ResultDomain; import org.xyzh.common.core.domain.ResultDomain;
import org.xyzh.common.core.page.PageRequest; import org.xyzh.common.core.page.PageRequest;
@@ -46,6 +47,9 @@ public class KnowledgeController {
@Autowired @Autowired
private KnowledgeService knowledgeService; private KnowledgeService knowledgeService;
@Autowired
private KnowledgeFileLogService knowledgeFileLogService;
@Autowired @Autowired
private DifyProxyService difyProxyService; private DifyProxyService difyProxyService;
@@ -235,10 +239,10 @@ public class KnowledgeController {
* @since 2025-12-18 * @since 2025-12-18
*/ */
@PreAuthorize("hasAuthority('ai:knowledge:file:delete')") @PreAuthorize("hasAuthority('ai:knowledge:file:delete')")
@DeleteMapping("/file/{fileId}") @DeleteMapping("/file/{fileRootId}")
public ResultDomain<Boolean> deleteFile(@PathVariable("fileId") @NotBlank String fileId) { public ResultDomain<Boolean> deleteFile(@PathVariable("fileRootId") @NotBlank String fileRootId) {
logger.info("删除知识库文件: fileId={}", fileId); logger.info("删除知识库文件: fileId={}", fileRootId);
return knowledgeService.deleteKnowledgeFileById(fileId); return knowledgeService.deleteKnowledgeFileById(fileRootId);
} }
/** /**
@@ -347,4 +351,29 @@ public class KnowledgeController {
logger.info("更新文档状态: datasetId={}, action={}", datasetId, action); logger.info("更新文档状态: datasetId={}, action={}", datasetId, action);
return difyProxyService.updateDocumentStatus(datasetId, action, requestBody); return difyProxyService.updateDocumentStatus(datasetId, action, requestBody);
} }
// ================================ 知识库文件操作日志 =======================
/**
* @description 查询知识库操作日志列表
* @param fileLog
* @author yslg
* @since 2025-12-18
*/
@PreAuthorize("hasAuthority('ai:knowledge:file:view')")
@PostMapping("/datasets/log/list")
public ResultDomain<TbKnowledgeFileLog> getKnowledgeFileLogList(@RequestBody TbKnowledgeFileLog fileLog){
return knowledgeFileLogService.getKnowledgeFileLogList(fileLog);
}
/**
* @description 查询知识库操作日志分页
* @param pageRequest
* @author yslg
* @since 2025-12-18
*/
@PreAuthorize("hasAuthority('ai:knowledge:file:view')")
@PostMapping("/datasets/log/page")
public ResultDomain<TbKnowledgeFileLog> getKnowledgeFileLogPage(@RequestBody PageRequest<TbKnowledgeFileLog> pageRequest){
return knowledgeFileLogService.getKnowledgeFileLogPage(pageRequest);
}
} }

View File

@@ -0,0 +1,31 @@
package org.xyzh.ai.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.xyzh.api.ai.dto.TbKnowledgeFile;
import org.xyzh.api.ai.dto.TbKnowledgeFileLog;
import org.xyzh.api.ai.vo.KnowledgeFileVO;
import org.xyzh.common.core.page.PageParam;
import java.util.List;
/**
* @description 知识库文件数据访问层
* @filename KnowledgeFileMapper.java
* @author yslg
* @copyright xyzh
* @since 2025-12-17
*/
@Mapper
public interface TbKnowledgeFileLogMapper {
int addKnowledgeFileLog(TbKnowledgeFileLog tbKnowledgeFileLog);
List<TbKnowledgeFileLog> getKnowledgeFileLogList(@Param("filter") TbKnowledgeFileLog filter);
List<TbKnowledgeFileLog> getKnowledgeFileLogPage(@Param("pageParam") PageParam pageParam,@Param("filter") TbKnowledgeFileLog filter);
int countKnowledgeFileLog(@Param("filter") TbKnowledgeFileLog filter);
}

View File

@@ -32,6 +32,8 @@ public interface TbKnowledgeMapper {
*/ */
int deleteKnowledge(TbKnowledge knowledge); int deleteKnowledge(TbKnowledge knowledge);
int updateKnowledgeFileCount(@Param("knowledgeId") String knowledgeId, @Param("num") Integer num);
/** /**
* 根据ID查询知识库 * 根据ID查询知识库
*/ */

View File

@@ -1,5 +1,6 @@
package org.xyzh.ai.service.impl; package org.xyzh.ai.service.impl;
import org.apache.dubbo.config.annotation.DubboReference;
import org.apache.dubbo.config.annotation.DubboService; import org.apache.dubbo.config.annotation.DubboService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -13,15 +14,18 @@ import org.xyzh.ai.client.dto.DocumentUploadResponse;
import org.xyzh.api.ai.dto.TbAgent; import org.xyzh.api.ai.dto.TbAgent;
import org.xyzh.api.ai.service.AIFileUploadService; import org.xyzh.api.ai.service.AIFileUploadService;
import org.xyzh.api.ai.service.AgentService; import org.xyzh.api.ai.service.AgentService;
import org.xyzh.api.file.dto.TbSysFileDTO;
import org.xyzh.api.file.service.FileService;
import org.xyzh.common.auth.utils.LoginUtil; import org.xyzh.common.auth.utils.LoginUtil;
import org.xyzh.common.core.domain.ResultDomain; import org.xyzh.common.core.domain.ResultDomain;
import java.io.File; import java.io.File;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* @description AI文件上传服务实现只负责与Dify交互不处理minio和数据库 * @description AI文件上传服务实现同时上传到MinIO和Dify
* @filename AIFileUploadServiceImpl.java * @filename AIFileUploadServiceImpl.java
* @author yslg * @author yslg
* @copyright xyzh * @copyright xyzh
@@ -37,6 +41,9 @@ public class AIFileUploadServiceImpl implements AIFileUploadService {
@Autowired @Autowired
private AgentService agentService; private AgentService agentService;
@DubboReference(version = "1.0.0", group = "file", timeout = 30000, retries = 0)
private FileService fileService;
// ============================ 对话文件管理 ============================ // ============================ 对话文件管理 ============================
@Override @Override
@@ -56,31 +63,58 @@ public class AIFileUploadServiceImpl implements AIFileUploadService {
} }
TbAgent agent = agentResult.getData(); TbAgent agent = agentResult.getData();
// 3. 获取当前用户
String userId = LoginUtil.getCurrentUserId();
if (!StringUtils.hasText(userId)) {
userId = "anonymous";
}
File tempFile = null; File tempFile = null;
String sysFileId = null;
String sysFileUrl = null;
try { try {
// 3. 将MultipartFile转换为临时File // 4. 上传到MinIO通过FileService使用字节数组方式
byte[] fileBytes = file.getBytes();
String fileName = file.getOriginalFilename();
String contentType = file.getContentType();
ResultDomain<TbSysFileDTO> fileResult = fileService.uploadFileBytes(fileBytes, fileName, contentType, "ai-chat", agentId);
if (fileResult.getSuccess() && fileResult.getData() != null) {
TbSysFileDTO sysFile = fileResult.getData();
sysFileId = sysFile.getFileId();
sysFileUrl = sysFile.getUrl();
logger.info("上传文件到MinIO成功: fileId={}, url={}", sysFileId, sysFileUrl);
} else {
logger.warn("上传文件到MinIO失败: {}", fileResult.getMessage());
// MinIO上传失败不阻断流程继续上传到Dify
}
// 5. 将MultipartFile转换为临时File用于Dify上传
tempFile = File.createTempFile("upload_", "_" + file.getOriginalFilename()); tempFile = File.createTempFile("upload_", "_" + file.getOriginalFilename());
file.transferTo(tempFile); file.transferTo(tempFile);
// 4. 获取当前用户 // 6. 上传到Dify
String userId = LoginUtil.getCurrentUserId();
if (!StringUtils.hasText(userId)) {
userId = "anonymous";
}
// 5. 上传到Dify
DifyFileInfo difyFile = difyApiClient.uploadFileForChat(tempFile, file.getOriginalFilename(), userId, agent.getApiKey()); DifyFileInfo difyFile = difyApiClient.uploadFileForChat(tempFile, file.getOriginalFilename(), userId, agent.getApiKey());
if (difyFile != null && StringUtils.hasText(difyFile.getId())) { if (difyFile != null && StringUtils.hasText(difyFile.getId())) {
logger.info("上传对话文件成功: agentId={}, fileId={}", agentId, difyFile.getId()); logger.info("上传对话文件到Dify成功: agentId={}, difyFileId={}", agentId, difyFile.getId());
Map<String, Object> result = new java.util.HashMap<>();
Map<String, Object> result = new HashMap<>();
// Dify返回的信息
result.put("id", difyFile.getId()); result.put("id", difyFile.getId());
result.put("name", difyFile.getName()); result.put("name", difyFile.getName());
result.put("size", difyFile.getSize()); result.put("size", difyFile.getSize());
result.put("type", difyFile.getType()); result.put("type", difyFile.getType());
result.put("extension", difyFile.getExtension());
result.put("mime_type", difyFile.getMimeType());
result.put("upload_file_id", difyFile.getUploadFileId()); result.put("upload_file_id", difyFile.getUploadFileId());
// 系统文件信息(用于前端展示和数据库存储)
result.put("sys_file_id", sysFileId);
result.put("preview_url", sysFileUrl);
result.put("source_url", sysFileUrl);
return ResultDomain.success("上传成功", result); return ResultDomain.success("上传成功", result);
} }
return ResultDomain.failure("上传文件失败"); return ResultDomain.failure("上传文件到Dify失败");
} catch (Exception e) { } catch (Exception e) {
logger.error("上传对话文件异常: {}", e.getMessage(), e); logger.error("上传对话文件异常: {}", e.getMessage(), e);
return ResultDomain.failure("上传文件异常: " + e.getMessage()); return ResultDomain.failure("上传文件异常: " + e.getMessage());

View File

@@ -1,5 +1,6 @@
package org.xyzh.ai.service.impl; package org.xyzh.ai.service.impl;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.JSONObject;
import org.apache.dubbo.config.annotation.DubboService; import org.apache.dubbo.config.annotation.DubboService;
@@ -12,6 +13,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import org.xyzh.ai.client.DifyApiClient; import org.xyzh.ai.client.DifyApiClient;
import org.xyzh.ai.client.callback.StreamCallback; import org.xyzh.ai.client.callback.StreamCallback;
import org.xyzh.ai.client.dto.ChatRequest; import org.xyzh.ai.client.dto.ChatRequest;
import org.xyzh.ai.config.DifyConfig;
import org.xyzh.ai.mapper.TbChatMapper; import org.xyzh.ai.mapper.TbChatMapper;
import org.xyzh.ai.mapper.TbChatMessageMapper; import org.xyzh.ai.mapper.TbChatMessageMapper;
import org.xyzh.api.ai.dto.ChatPrepareData; import org.xyzh.api.ai.dto.ChatPrepareData;
@@ -19,17 +21,23 @@ import org.xyzh.api.ai.dto.DifyFileInfo;
import org.xyzh.api.ai.dto.TbAgent; import org.xyzh.api.ai.dto.TbAgent;
import org.xyzh.api.ai.dto.TbChat; import org.xyzh.api.ai.dto.TbChat;
import org.xyzh.api.ai.dto.TbChatMessage; import org.xyzh.api.ai.dto.TbChatMessage;
import org.xyzh.api.ai.dto.TbKnowledge;
import org.xyzh.api.ai.service.AgentChatService; import org.xyzh.api.ai.service.AgentChatService;
import org.xyzh.api.ai.service.AgentService; import org.xyzh.api.ai.service.AgentService;
import org.xyzh.api.ai.service.KnowledgeService;
import org.xyzh.api.system.service.GuestService;
import org.xyzh.common.core.domain.LoginDomain;
import org.xyzh.common.core.domain.ResultDomain; import org.xyzh.common.core.domain.ResultDomain;
import org.xyzh.common.core.page.PageDomain; import org.xyzh.common.core.page.PageDomain;
import org.xyzh.common.core.page.PageParam; import org.xyzh.common.core.page.PageParam;
import org.xyzh.common.core.page.PageRequest; import org.xyzh.common.core.page.PageRequest;
import org.xyzh.common.redis.service.RedisService; import org.xyzh.common.redis.service.RedisService;
import org.xyzh.common.utils.NonUtils;
import org.xyzh.common.utils.id.IdUtil; import org.xyzh.common.utils.id.IdUtil;
import org.xyzh.common.auth.utils.LoginUtil; import org.xyzh.common.auth.utils.LoginUtil;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -69,20 +77,11 @@ public class AgentChatServiceImpl implements AgentChatService {
@Autowired @Autowired
private RedisService redisService; private RedisService redisService;
/** @Autowired
* @description 根据 userType 获取用户ID private KnowledgeService knowledgeService;
* @param chat 会话信息(包含 userId 和 userType
* @return 真实的系统用户ID @Autowired
*/ private DifyConfig difyConfig;
private String getUserIdByType(TbChat chat) {
if (!chat.getUserType()) {
// 来客userType=false直接返回传入的 userId已经是真正的系统 userId
return chat.getUserId();
} else {
// 员工userType=true从登录信息获取 userId
return LoginUtil.getCurrentUserId();
}
}
/** /**
* @description 判断智能体是否是outer * @description 判断智能体是否是outer
@@ -130,7 +129,8 @@ public class AgentChatServiceImpl implements AgentChatService {
} }
// 2. 获取用户ID并校验权限 // 2. 获取用户ID并校验权限
String userId = getUserIdByType(chat); LoginDomain loginDomain = LoginUtil.getCurrentLogin();
String userId = loginDomain.getUser().getUserId();
if (userId == null) { if (userId == null) {
return ResultDomain.failure("用户信息获取失败"); return ResultDomain.failure("用户信息获取失败");
} }
@@ -164,7 +164,8 @@ public class AgentChatServiceImpl implements AgentChatService {
return ResultDomain.failure("智能体不可用"); return ResultDomain.failure("智能体不可用");
} }
// 2. 获取用户ID并校验权限 // 2. 获取用户ID并校验权限
String userId = getUserIdByType(filter); LoginDomain loginDomain = LoginUtil.getCurrentLogin();
String userId = loginDomain.getUser().getUserId();
if (userId == null) { if (userId == null) {
return ResultDomain.failure("用户信息获取失败"); return ResultDomain.failure("用户信息获取失败");
} }
@@ -189,12 +190,9 @@ public class AgentChatServiceImpl implements AgentChatService {
@Override @Override
public ResultDomain<TbChat> getChatList(TbChat filter) { public ResultDomain<TbChat> getChatList(TbChat filter) {
// 判断agent是否是outer
if(!isOuterAgent(filter.getAgentId())){
return ResultDomain.failure("智能体不可用");
}
// 获取用户ID // 获取用户ID
String userId = getUserIdByType(filter); String userId = LoginUtil.getCurrentUserId();
if (userId == null) { if (userId == null) {
return ResultDomain.failure("用户信息获取失败"); return ResultDomain.failure("用户信息获取失败");
} }
@@ -204,16 +202,16 @@ public class AgentChatServiceImpl implements AgentChatService {
} }
@Override @Override
public ResultDomain<PageDomain<TbChat>> getChatPage(PageRequest<TbChat> pageRequest) { public ResultDomain<TbChat> getChatPage(PageRequest<TbChat> pageRequest) {
TbChat filter = pageRequest.getFilter(); TbChat filter = pageRequest.getFilter();
// 判断agent是否是outer来客才需要校验 // 判断agent是否是outer来客才需要校验
if (!filter.getUserType() && !isOuterAgent(filter.getAgentId())) { if (!filter.getUserType() && !isOuterAgent(filter.getAgentId())) {
return ResultDomain.<PageDomain<TbChat>>failure("智能体不可用"); return ResultDomain.<TbChat>failure("智能体不可用");
} }
// 获取用户ID // 获取用户ID
String userId = getUserIdByType(filter); String userId = LoginUtil.getCurrentUserId();
if (userId == null) { if (userId == null) {
return ResultDomain.<PageDomain<TbChat>>failure("用户信息获取失败"); return ResultDomain.<TbChat>failure("用户信息获取失败");
} }
filter.setUserId(userId); filter.setUserId(userId);
@@ -224,7 +222,7 @@ public class AgentChatServiceImpl implements AgentChatService {
pageParam.setTotal((int) total); pageParam.setTotal((int) total);
PageDomain<TbChat> pageDomain = new PageDomain<>(pageParam, chatList); PageDomain<TbChat> pageDomain = new PageDomain<>(pageParam, chatList);
return ResultDomain.<PageDomain<TbChat>>success("查询成功", pageDomain); return ResultDomain.<TbChat>success("查询成功", pageDomain);
} }
// ====================== 智能体聊天管理 ====================== // ====================== 智能体聊天管理 ======================
@@ -241,7 +239,7 @@ public class AgentChatServiceImpl implements AgentChatService {
return ResultDomain.failure("智能体不可用"); return ResultDomain.failure("智能体不可用");
} }
// 2. 获取用户ID并校验权限 // 2. 获取用户ID并校验权限
String userId = getUserIdByType(filter); String userId = LoginUtil.getCurrentUserId();
if (userId == null) { if (userId == null) {
return ResultDomain.failure("用户信息获取失败"); return ResultDomain.failure("用户信息获取失败");
} }
@@ -272,7 +270,8 @@ public class AgentChatServiceImpl implements AgentChatService {
chatFilter.setUserId(prepareData.getUserId()); chatFilter.setUserId(prepareData.getUserId());
chatFilter.setUserType(prepareData.getUserType()); chatFilter.setUserType(prepareData.getUserType());
String userId = getUserIdByType(chatFilter); LoginDomain loginDomain = LoginUtil.getCurrentLogin();
String userId = loginDomain.getUser().getUserId();
if (userId == null) { if (userId == null) {
return ResultDomain.failure("用户信息获取失败"); return ResultDomain.failure("用户信息获取失败");
} }
@@ -299,6 +298,9 @@ public class AgentChatServiceImpl implements AgentChatService {
sessionData.put("userId", userId); sessionData.put("userId", userId);
sessionData.put("filesData", prepareData.getFiles()); sessionData.put("filesData", prepareData.getFiles());
sessionData.put("apiKey", agent.getApiKey()); sessionData.put("apiKey", agent.getApiKey());
sessionData.put("outer", agent.getIsOuter());
sessionData.put("service", prepareData.getService());
sessionData.put("isGuest", "guest".equals(loginDomain.getUser().getStatus()));
String cacheKey = CHAT_SESSION_PREFIX + sessionId; String cacheKey = CHAT_SESSION_PREFIX + sessionId;
redisService.set(cacheKey, sessionData, SESSION_TTL, TimeUnit.SECONDS); redisService.set(cacheKey, sessionData, SESSION_TTL, TimeUnit.SECONDS);
@@ -332,6 +334,10 @@ public class AgentChatServiceImpl implements AgentChatService {
String query = (String) sessionData.get("query"); String query = (String) sessionData.get("query");
String userId = (String) sessionData.get("userId"); String userId = (String) sessionData.get("userId");
String apiKey = (String) sessionData.get("apiKey"); String apiKey = (String) sessionData.get("apiKey");
String service = (String) sessionData.get("service");
Boolean outer = (Boolean) sessionData.get("outer");
Boolean isGuest = (Boolean) sessionData.get("isGuest");
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
List<DifyFileInfo> filesData = (List<DifyFileInfo>) sessionData.get("filesData"); List<DifyFileInfo> filesData = (List<DifyFileInfo>) sessionData.get("filesData");
@@ -346,6 +352,18 @@ public class AgentChatServiceImpl implements AgentChatService {
userMessage.setChatId(chatId); userMessage.setChatId(chatId);
userMessage.setRole("user"); userMessage.setRole("user");
userMessage.setContent(query); userMessage.setContent(query);
// 提取系统文件ID列表保存到消息中
if (filesData != null && !filesData.isEmpty()) {
List<String> sysFileIds = filesData.stream()
.map(DifyFileInfo::getSysFileId)
.filter(StringUtils::hasText)
.collect(java.util.stream.Collectors.toList());
if (!sysFileIds.isEmpty()) {
userMessage.setFiles(sysFileIds);
}
}
chatMessageMapper.insertChatMessage(userMessage); chatMessageMapper.insertChatMessage(userMessage);
// 5. 构建Dify请求 // 5. 构建Dify请求
@@ -353,7 +371,23 @@ public class AgentChatServiceImpl implements AgentChatService {
chatRequest.setQuery(query); chatRequest.setQuery(query);
chatRequest.setUser(userId); chatRequest.setUser(userId);
chatRequest.setResponseMode("streaming"); chatRequest.setResponseMode("streaming");
chatRequest.setInputs(new HashMap<>()); // Dify API 要求 inputs 必传 Map<String, Object> inputsMap = new HashMap<>();
chatRequest.setInputs(inputsMap); // Dify API 要求 inputs 必传
// 处理动态知识库的问题
if(outer && NonUtils.isNotEmpty(service)){
TbKnowledge filter = new TbKnowledge();
filter.setService(service);
filter.setCategory(isGuest?"external":"internal");
ResultDomain<TbKnowledge> knowledgeRD = knowledgeService.listKnowledges(filter);
List<String> datasets = new ArrayList<>();
if(knowledgeRD.getSuccess()){
datasets = knowledgeRD.getDataList().stream().map(TbKnowledge::getDifyDatasetId).toList();
}
inputsMap.put("datasets", JSON.toJSONString(datasets));
inputsMap.put("dataset_apikey", difyConfig.getKnowledgeApiKey());
}
if (filesData != null && !filesData.isEmpty()) { if (filesData != null && !filesData.isEmpty()) {
chatRequest.setFiles(filesData); chatRequest.setFiles(filesData);
@@ -442,7 +476,7 @@ public class AgentChatServiceImpl implements AgentChatService {
TbAgent agent = agentResult.getData(); TbAgent agent = agentResult.getData();
// 2. 获取用户ID // 2. 获取用户ID
String userId = getUserIdByType(filter); String userId = LoginUtil.getCurrentUserId();
if (userId == null) { if (userId == null) {
return ResultDomain.failure("用户信息获取失败"); return ResultDomain.failure("用户信息获取失败");
} }
@@ -468,7 +502,7 @@ public class AgentChatServiceImpl implements AgentChatService {
} }
// 2. 获取用户ID // 2. 获取用户ID
String userId = getUserIdByType(filter); String userId = LoginUtil.getCurrentUserId();
if (userId == null) { if (userId == null) {
return ResultDomain.failure("用户信息获取失败"); return ResultDomain.failure("用户信息获取失败");
} }

View File

@@ -0,0 +1,88 @@
package org.xyzh.ai.service.impl;
import java.util.Arrays;
import java.util.List;
import org.apache.dubbo.config.annotation.DubboService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.xyzh.ai.mapper.TbKnowledgeFileLogMapper;
import org.xyzh.api.ai.dto.TbKnowledgeFileLog;
import org.xyzh.api.ai.service.KnowledgeFileLogService;
import org.xyzh.common.core.domain.ResultDomain;
import org.xyzh.common.core.page.PageDomain;
import org.xyzh.common.core.page.PageParam;
import org.xyzh.common.core.page.PageRequest;
import org.xyzh.common.utils.NonUtils;
import org.xyzh.common.utils.id.IdUtil;
import org.xyzh.common.utils.validation.ValidationResult;
import org.xyzh.common.utils.validation.ValidationUtils;
@DubboService(version="1.0.0", group="ai", timeout=3000, retries=0)
public class KnowledgeFileLogServiceImpl implements KnowledgeFileLogService{
private static final Logger logger = LoggerFactory.getLogger(KnowledgeServiceImpl.class);
@Autowired
private TbKnowledgeFileLogMapper knowledgeFileLogMapper;
/**
* @description 新增知识库文件操作日志
* @param knowledgeFileLog
* @return 日志
* @author yslg
* @since 2025-12-31
*/
@Override
public ResultDomain<TbKnowledgeFileLog> addKnowledgeFileLog(TbKnowledgeFileLog knowledgeFileLog){
knowledgeFileLog.setOptsn(IdUtil.getOptsn());
knowledgeFileLog.setLogId(IdUtil.generateID());
ValidationResult rt = ValidationUtils.validate(knowledgeFileLog, Arrays.asList(
));
if(!rt.isValid()){
return ResultDomain.failure("日志参数校验失败");
}
int result = knowledgeFileLogMapper.addKnowledgeFileLog(knowledgeFileLog);
if(result >0){
return ResultDomain.success("添加知识库文件日志成功", knowledgeFileLog);
}else {
return ResultDomain.failure("添加知识库文件日志失败");
}
}
/**
* @description 查询知识库日志操作列表
* @param filter
* @return 日志列表
* @author yslg
* @since 2025-12-31
*/
@Override
public ResultDomain<TbKnowledgeFileLog> getKnowledgeFileLogList(TbKnowledgeFileLog filter){
List<TbKnowledgeFileLog> logs = knowledgeFileLogMapper.getKnowledgeFileLogList(filter);
return ResultDomain.success("查询知识库日志成功",logs);
}
/**
* @description 查询知识库日志操作分页
* @param pageRequest
* @return 日志分页数据
* @author yslg
* @since 2025-12-31
*/
@Override
public ResultDomain<TbKnowledgeFileLog> getKnowledgeFileLogPage(PageRequest<TbKnowledgeFileLog> pageRequest){
List<TbKnowledgeFileLog> logs = knowledgeFileLogMapper.getKnowledgeFileLogPage(pageRequest.getPageParam(), pageRequest.getFilter());
int total = knowledgeFileLogMapper.countKnowledgeFileLog(pageRequest.getFilter());
PageDomain<TbKnowledgeFileLog> pageDomain = new PageDomain<>();
pageDomain.setDataList(logs);
PageParam pageParam = pageRequest.getPageParam();
pageParam.setTotal(total);
pageDomain.setPageParam(pageParam);
return ResultDomain.success("查询知识库日志成功", pageDomain);
}
}

View File

@@ -16,7 +16,10 @@ import org.xyzh.ai.mapper.TbKnowledgeFileMapper;
import org.xyzh.ai.mapper.TbKnowledgeMapper; import org.xyzh.ai.mapper.TbKnowledgeMapper;
import org.xyzh.api.ai.dto.TbKnowledge; import org.xyzh.api.ai.dto.TbKnowledge;
import org.xyzh.api.ai.dto.TbKnowledgeFile; import org.xyzh.api.ai.dto.TbKnowledgeFile;
import org.xyzh.api.ai.dto.TbKnowledgeFileLog;
import org.xyzh.api.ai.constance.KnowledgeFileLogAction;
import org.xyzh.api.ai.service.AIFileUploadService; import org.xyzh.api.ai.service.AIFileUploadService;
import org.xyzh.api.ai.service.KnowledgeFileLogService;
import org.xyzh.api.ai.service.KnowledgeService; import org.xyzh.api.ai.service.KnowledgeService;
import org.xyzh.api.ai.vo.KnowledgeFileVO; import org.xyzh.api.ai.vo.KnowledgeFileVO;
import org.xyzh.api.file.dto.TbSysFileDTO; import org.xyzh.api.file.dto.TbSysFileDTO;
@@ -58,6 +61,9 @@ public class KnowledgeServiceImpl implements KnowledgeService {
@DubboReference(version = "1.0.0", group = "file", timeout = 30000, retries = 0) @DubboReference(version = "1.0.0", group = "file", timeout = 30000, retries = 0)
private FileService fileService; private FileService fileService;
@Autowired
private KnowledgeFileLogService knowledgeFileLogService;
@Autowired @Autowired
private AIFileUploadService aiFileUploadService; private AIFileUploadService aiFileUploadService;
@@ -533,9 +539,22 @@ public class KnowledgeServiceImpl implements KnowledgeService {
knowledgeFile.setDifyFileId(difyFileId); knowledgeFile.setDifyFileId(difyFileId);
knowledgeFile.setVersion(1); knowledgeFile.setVersion(1);
knowledgeMapper.updateKnowledgeFileCount(knowledgeId, 1);
int rows = knowledgeFileMapper.insertKnowledgeFile(knowledgeFile); int rows = knowledgeFileMapper.insertKnowledgeFile(knowledgeFile);
if (rows > 0) { if (rows > 0) {
logger.info("保存知识库文件记录成功: knowledgeId={}, fileId={}, difyFileId={}", knowledgeId, fileId, difyFileId); logger.info("保存知识库文件记录成功: knowledgeId={}, fileId={}, difyFileId={}", knowledgeId, fileId, difyFileId);
// 记录日志
TbKnowledgeFileLog log = new TbKnowledgeFileLog();
log.setKnowledgeId(knowledgeId);
log.setFileRootId(fileId);
log.setFileId(fileId);
log.setFileName(file.getOriginalFilename());
log.setVersion(1);
log.setAction(KnowledgeFileLogAction.UPLOAD.getAction());
log.setService("workcase");
log.setCreator(LoginUtil.getCurrentUserId());
log.setCreatorName(LoginUtil.getCurrentUserName());
knowledgeFileLogService.addKnowledgeFileLog(log);
return ResultDomain.success("上传成功", knowledgeFile); return ResultDomain.success("上传成功", knowledgeFile);
} }
@@ -682,6 +701,18 @@ public class KnowledgeServiceImpl implements KnowledgeService {
int rows = knowledgeFileMapper.insertKnowledgeFile(newKnowledgeFile); int rows = knowledgeFileMapper.insertKnowledgeFile(newKnowledgeFile);
if (rows > 0) { if (rows > 0) {
logger.info("保存新版本记录成功: knowledgeId={}, fileRootId={}, newVersion={}", knowledgeId, fileRootId, newVersion); logger.info("保存新版本记录成功: knowledgeId={}, fileRootId={}, newVersion={}", knowledgeId, fileRootId, newVersion);
// 记录日志
TbKnowledgeFileLog log = new TbKnowledgeFileLog();
log.setKnowledgeId(knowledgeId);
log.setFileRootId(fileRootId);
log.setFileId(newFileId);
log.setFileName(file.getOriginalFilename());
log.setVersion(newVersion);
log.setAction(KnowledgeFileLogAction.UPDATE.getAction());
log.setService("workcase");
log.setCreator(LoginUtil.getCurrentUserId());
log.setCreatorName(LoginUtil.getCurrentUserName());
knowledgeFileLogService.addKnowledgeFileLog(log);
return ResultDomain.success("更新成功", newKnowledgeFile); return ResultDomain.success("更新成功", newKnowledgeFile);
} }
@@ -722,15 +753,27 @@ public class KnowledgeServiceImpl implements KnowledgeService {
if (!difyDocIds.isEmpty()) { if (!difyDocIds.isEmpty()) {
aiFileUploadService.batchDeleteFilesFromDify(knowledge.getDifyDatasetId(), difyDocIds); aiFileUploadService.batchDeleteFilesFromDify(knowledge.getDifyDatasetId(), difyDocIds);
} }
}else{
return ResultDomain.failure("知识库未关联Dify");
} }
// 3. 软删除本地记录和minio文件 // 3. 软删除本地记录和minio文件
int rows = knowledgeFileMapper.deleteFilesByRootId(fileRootId); int rows = knowledgeFileMapper.deleteFilesByRootId(fileRootId);
knowledgeMapper.updateKnowledgeFileCount(knowledge.getKnowledgeId(), -1);
if (rows > 0) { if (rows > 0) {
logger.info("删除知识库文件成功: fileRootId={}", fileRootId); logger.info("删除知识库文件成功: fileRootId={}", fileRootId);
for (TbKnowledgeFile file : versions) { for (TbKnowledgeFile file : versions) {
fileService.deleteFile(file.getFileId()); fileService.deleteFile(file.getFileId());
} }
// 记录日志
TbKnowledgeFileLog log = new TbKnowledgeFileLog();
log.setKnowledgeId(knowledge.getKnowledgeId());
log.setFileRootId(fileRootId);
log.setAction(KnowledgeFileLogAction.DELETE.getAction());
log.setService("workcase");
log.setCreator(LoginUtil.getCurrentUserId());
log.setCreatorName(LoginUtil.getCurrentUserName());
knowledgeFileLogService.addKnowledgeFileLog(log);
return ResultDomain.success("删除成功", true); return ResultDomain.success("删除成功", true);
} }

View File

@@ -29,7 +29,12 @@ security:
spring: spring:
application: application:
name: ai-service name: ai-service
# 文件上传配置
servlet:
multipart:
enabled: true
max-file-size: 500MB
max-request-size: 500MB
# ================== Spring Cloud Nacos ================== # ================== Spring Cloud Nacos ==================
cloud: cloud:
nacos: nacos:
@@ -72,6 +77,7 @@ dubbo:
name: urban-lifeline-agent name: urban-lifeline-agent
qos-enable: false qos-enable: false
protocol: protocol:
payload: 110100480
name: dubbo name: dubbo
port: -1 port: -1
registry: registry:

View File

@@ -72,6 +72,7 @@ dubbo:
name: urban-lifeline-agent name: urban-lifeline-agent
qos-enable: false qos-enable: false
protocol: protocol:
payload: 110100480
name: dubbo name: dubbo
port: -1 port: -1
registry: registry:

View File

@@ -47,7 +47,7 @@
<AppenderRef ref="RollingFile"/> <AppenderRef ref="RollingFile"/>
</Logger> </Logger>
<Logger name="org.xyzh.agent" level="debug" additivity="false"> <Logger name="org.xyzh.ai" level="debug" additivity="false">
<AppenderRef ref="Console"/> <AppenderRef ref="Console"/>
<AppenderRef ref="RollingFile"/> <AppenderRef ref="RollingFile"/>
</Logger> </Logger>

View File

@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.xyzh.ai.mapper.TbKnowledgeFileLogMapper">
<resultMap id="BaseResultMap" type="org.xyzh.api.ai.dto.TbKnowledgeFileLog">
<result column="optsn" property="optsn" jdbcType="VARCHAR"/>
<result column="log_id" property="logId" jdbcType="VARCHAR"/>
<result column="knowledge_id" property="knowledgeId" jdbcType="VARCHAR"/>
<result column="file_root_id" property="fileRootId" jdbcType="VARCHAR"/>
<result column="file_id" property="fileId" jdbcType="VARCHAR"/>
<result column="file_name" property="fileName" jdbcType="VARCHAR"/>
<result column="action" property="action" jdbcType="VARCHAR"/>
<result column="version" property="version" jdbcType="INTEGER"/>
<result column="creator" property="creator" jdbcType="VARCHAR"/>
<result column="creator_name" property="creatorName" jdbcType="VARCHAR"/>
<result column="create_time" property="createTime" jdbcType="TIMESTAMP"/>
</resultMap>
<sql id="Base_Column_List">
optsn, log_id, knowledge_id, file_root_id, file_id, file_name, version,
action, creator, creator_name, create_time
</sql>
<insert id="addKnowledgeFileLog" parameterType="org.xyzh.api.ai.dto.TbKnowledgeFileLog">
INSERT INTO ai.tb_knowledge_file_log (
optsn, log_id, knowledge_id, file_root_id, file_id, file_name, version,
action, service, creator, creator_name, create_time
) VALUES (
#{optsn}, #{logId}, #{knowledgeId}, #{fileRootId}, #{fileId}, #{fileName}, #{version},
#{action}, #{service}, #{creator}, #{creatorName}, NOW()
)
</insert>
<select id="getKnowledgeFileLogList" resultMap="BaseResultMap">
SELECT
<include refid="Base_Column_List"/>
FROM ai.tb_knowledge_file_log
WHERE 1=1
<if test="filter.knowledgeId != null and filter.knowledgeId != ''">
AND knowledge_id = #{filter.knowledgeId}
</if>
<if test="filter.service != null and filter.service != ''">
AND service = #{filter.service}
</if>
<if test="filter.fileName != null and filter.fileName != ''">
AND file_name LIKE CONCAT('%', #{filter.fileName}, '%')
</if>
<if test="filter.creatorName != null and filter.creatorName != ''">
AND creator_name LIKE CONCAT('%', #{filter.creatorName}, '%')
</if>
ORDER BY create_time DESC
</select>
<select id="getKnowledgeFileLogPage" resultMap="BaseResultMap">
SELECT
<include refid="Base_Column_List"/>
FROM ai.tb_knowledge_file_log
WHERE 1=1
<if test="filter.knowledgeId != null and filter.knowledgeId != ''">
AND knowledge_id = #{filter.knowledgeId}
</if>
<if test="filter.service != null and filter.service != ''">
AND service = #{filter.service}
</if>
<if test="filter.fileName != null and filter.fileName != ''">
AND file_name LIKE CONCAT('%', #{filter.fileName}, '%')
</if>
<if test="filter.creatorName != null and filter.creatorName != ''">
AND creator_name LIKE CONCAT('%', #{filter.creatorName}, '%')
</if>
ORDER BY create_time DESC
LIMIT #{pageParam.pageSize} OFFSET #{pageParam.offset}
</select>
<select id="countKnowledgeFileLog" resultType="int">
SELECT
count(log_id)
FROM ai.tb_knowledge_file_log
WHERE 1=1
<if test="filter.knowledgeId != null and filter.knowledgeId != ''">
AND knowledge_id = #{filter.knowledgeId}
</if>
<if test="filter.service != null and filter.service != ''">
AND service = #{filter.service}
</if>
<if test="filter.fileName != null and filter.fileName != ''">
AND file_name LIKE CONCAT('%', #{filter.fileName}, '%')
</if>
<if test="filter.creatorName != null and filter.creatorName != ''">
AND creator_name LIKE CONCAT('%', #{filter.creatorName}, '%')
</if>
</select>
</mapper>

View File

@@ -112,6 +112,12 @@
WHERE knowledge_id = #{knowledgeId} AND deleted = false WHERE knowledge_id = #{knowledgeId} AND deleted = false
</update> </update>
<update id="updateKnowledgeFileCount">
UPDATE ai.tb_knowledge
SET document_count = document_count + #{num}
WHERE knowledge_id = #{knowledgeId} AND deleted = false
</update>
<select id="selectKnowledgeById" resultMap="BaseResultMap"> <select id="selectKnowledgeById" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/> SELECT <include refid="Base_Column_List"/>
FROM ai.tb_knowledge FROM ai.tb_knowledge

View File

@@ -0,0 +1,28 @@
package org.xyzh.api.ai.constance;
public enum KnowledgeFileLogAction {
UPLOAD("upload", "上传文件"),
DELETE("delete", "删除文件"),
DOWNLOAD("download", "下载文件"),
UPDATE("update", "更新文件"),
SEGMENT_CREATE("segment_create", "创建分段"),
SEGMENT_UPDATE("segment_update", "更新分段"),
SEGMENT_DELETE("segment_delete", "更新分段");
private String action;
private String description;
private KnowledgeFileLogAction(String action, String description) {
this.action = action;
this.description = description;
}
public String getAction() {
return action;
}
public String getDescription(){
return description;
}
}

View File

@@ -29,4 +29,6 @@ public class ChatPrepareData implements Serializable {
@Schema(description = "用户类型false=来客true=员工)") @Schema(description = "用户类型false=来客true=员工)")
private Boolean userType; private Boolean userType;
@Schema(description = "服务名称")
private String service;
} }

View File

@@ -0,0 +1,38 @@
package org.xyzh.api.ai.dto;
import org.xyzh.common.dto.BaseDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "知识库文件日志")
public class TbKnowledgeFileLog extends BaseDTO {
private static final long serialVersionUID = 1L;
@Schema(description = "日志Id")
private String logId;
@Schema(description = "知识库Id")
private String knowledgeId;
@Schema(description = "文件根Id")
private String fileRootId;
@Schema(description = "文件Id")
private String fileId;
@Schema(description = "文件名称")
private String fileName;
@Schema(description = "服务名称")
private String service;
@Schema(description = "文件版本")
private Integer version;
@Schema(description = "操作")
private String action;
@Schema(description = "操作人用户名")
private String creatorName;
}

View File

@@ -48,7 +48,7 @@ public interface AgentChatService {
* @param pageRequest 分页请求参数 * @param pageRequest 分页请求参数
* @return 分页会话列表 * @return 分页会话列表
*/ */
ResultDomain<PageDomain<TbChat>> getChatPage(PageRequest<TbChat> pageRequest); ResultDomain<TbChat> getChatPage(PageRequest<TbChat> pageRequest);
// ====================== 智能体聊天管理 ====================== // ====================== 智能体聊天管理 ======================

View File

@@ -0,0 +1,43 @@
package org.xyzh.api.ai.service;
import org.xyzh.api.ai.dto.TbKnowledgeFileLog;
import org.xyzh.common.core.domain.ResultDomain;
import org.xyzh.common.core.page.PageRequest;
/**
* @description 知识库文件操作日志
* @filename KnowledgeFileLogService.java
* @author yslg
* @copyright yslg
* @since 2025-12-31
*/
public interface KnowledgeFileLogService {
/**
* @description 新增知识库文件操作日志
* @param knowledgeFileLog
* @return 日志
* @author yslg
* @since 2025-12-31
*/
ResultDomain<TbKnowledgeFileLog> addKnowledgeFileLog(TbKnowledgeFileLog knowledgeFileLog);
/**
* @description 查询知识库日志操作列表
* @param filter
* @return 日志列表
* @author yslg
* @since 2025-12-31
*/
ResultDomain<TbKnowledgeFileLog> getKnowledgeFileLogList(TbKnowledgeFileLog filter);
/**
* @description 查询知识库日志操作分页
* @param pageRequest
* @return 日志分页数据
* @author yslg
* @since 2025-12-31
*/
ResultDomain<TbKnowledgeFileLog> getKnowledgeFileLogPage(PageRequest<TbKnowledgeFileLog> pageRequest);
}

View File

@@ -0,0 +1,8 @@
package org.xyzh.api.system.constance;
/**
* 通过redis事件实现数据库更新配置更新其他服务的bean数据
*/
public class SysConfigRedisPrefix {
public static final String SYS_CONFIG_DIFY="sys:config:dify";
}

View File

@@ -60,6 +60,12 @@ public class TbChatRoomDTO extends BaseDTO {
@Schema(description = "最后一条消息内容") @Schema(description = "最后一条消息内容")
private String lastMessage; private String lastMessage;
@Schema(description = "服务评分1-5星")
private Integer commentLevel;
@Schema(description = "设备代码")
private String deviceCode;
@Schema(description = "关闭人") @Schema(description = "关闭人")
private String closedBy; private String closedBy;

View File

@@ -208,4 +208,16 @@ public interface ChatRoomService {
*/ */
ResultDomain<CustomerServiceVO> assignCustomerService(String roomId); ResultDomain<CustomerServiceVO> assignCustomerService(String roomId);
// ========================= 聊天室评分管理 ==========================
/**
* @description 提交聊天室服务评分
* @param roomId 聊天室ID
* @param commentLevel 评分1-5星
* @param userId 评分用户ID
* @author cascade
* @since 2025-12-29
*/
ResultDomain<Boolean> submitCommentLevel(String roomId, Integer commentLevel, String userId);
} }

View File

@@ -3,6 +3,7 @@ package org.xyzh.api.workcase.service;
import org.xyzh.api.workcase.dto.TbWorkcaseDTO; import org.xyzh.api.workcase.dto.TbWorkcaseDTO;
import org.xyzh.api.workcase.dto.TbWorkcaseDeviceDTO; import org.xyzh.api.workcase.dto.TbWorkcaseDeviceDTO;
import org.xyzh.api.workcase.dto.TbWorkcaseProcessDTO; import org.xyzh.api.workcase.dto.TbWorkcaseProcessDTO;
import org.xyzh.api.workcase.vo.WorkcaseProcessVO;
import org.xyzh.common.core.domain.ResultDomain; import org.xyzh.common.core.domain.ResultDomain;
import org.xyzh.common.core.page.PageRequest; import org.xyzh.common.core.page.PageRequest;
@@ -110,7 +111,7 @@ public interface WorkcaseService {
* @author yslg * @author yslg
* @since 2025-12-19 * @since 2025-12-19
*/ */
ResultDomain<TbWorkcaseProcessDTO> getWorkcaseProcessList(TbWorkcaseProcessDTO filter); ResultDomain<WorkcaseProcessVO> getWorkcaseProcessList(TbWorkcaseProcessDTO filter);
/** /**
* @description 获取工单处理过程分页 * @description 获取工单处理过程分页
@@ -118,7 +119,7 @@ public interface WorkcaseService {
* @author yslg * @author yslg
* @since 2025-12-19 * @since 2025-12-19
*/ */
ResultDomain<TbWorkcaseProcessDTO> getWorkcaseProcessPage(PageRequest<TbWorkcaseProcessDTO> pageRequest); ResultDomain<WorkcaseProcessVO> getWorkcaseProcessPage(PageRequest<TbWorkcaseProcessDTO> pageRequest);
// ====================== 工单设备管理 ====================== // ====================== 工单设备管理 ======================
/** /**

View File

@@ -63,6 +63,12 @@ public class ChatRoomVO extends BaseVO {
@Schema(description = "最后一条消息内容") @Schema(description = "最后一条消息内容")
private String lastMessage; private String lastMessage;
@Schema(description = "服务评分1-5星")
private Integer commentLevel;
@Schema(description = "设备代码")
private String deviceCode;
@Schema(description = "关闭人") @Schema(description = "关闭人")
private String closedBy; private String closedBy;

View File

@@ -1,59 +0,0 @@
package org.xyzh.api.workcase.vo;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.xyzh.common.vo.BaseVO;
import io.swagger.v3.oas.annotations.media.Schema;
import com.alibaba.fastjson2.annotation.JSONField;
import java.util.Date;
/**
* 工单列表VO
* 用于前端列表展示(简化版)
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "工单列表VO")
public class TicketListVO extends BaseVO {
private static final long serialVersionUID = 1L;
@Schema(description = "工单ID")
private String ticketId;
@Schema(description = "工单编号")
private String ticketNo;
@Schema(description = "客户姓名")
private String customerName;
@Schema(description = "工单标题")
private String title;
@Schema(description = "工单类型名称")
private String ticketTypeName;
@Schema(description = "优先级")
private String priority;
@Schema(description = "优先级名称")
private String priorityName;
@Schema(description = "工单状态")
private String ticketStatus;
@Schema(description = "工单状态名称")
private String ticketStatusName;
@Schema(description = "处理人姓名")
private String assignedToName;
@Schema(description = "SLA截止时间", format = "date-time")
@JSONField(format = "yyyy-MM-dd HH:mm:ss")
private Date slaDeadline;
@Schema(description = "是否逾期", defaultValue = "false")
private Boolean isOverdue;
@Schema(description = "创建者姓名")
private String creatorName;
}

View File

@@ -1,151 +0,0 @@
package org.xyzh.api.workcase.vo;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.xyzh.common.vo.BaseVO;
import io.swagger.v3.oas.annotations.media.Schema;
import com.alibaba.fastjson2.annotation.JSONField;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.Date;
import java.util.List;
/**
* 工单VO
* 用于前端展示工单信息
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "工单VO")
public class TicketVO extends BaseVO {
private static final long serialVersionUID = 1L;
@Schema(description = "工单ID")
private String ticketId;
@Schema(description = "工单编号")
private String ticketNo;
@Schema(description = "客户ID")
private String customerId;
@Schema(description = "客户姓名")
private String customerName;
@Schema(description = "客户电话")
private String customerPhone;
@Schema(description = "关联会话ID")
private String conversationId;
@Schema(description = "工单类型")
private String ticketType;
@Schema(description = "工单类型名称")
private String ticketTypeName;
@Schema(description = "工单分类")
private String ticketCategory;
@Schema(description = "优先级")
private String priority;
@Schema(description = "优先级名称")
private String priorityName;
@Schema(description = "优先级颜色")
private String priorityColor;
@Schema(description = "工单标题")
private String title;
@Schema(description = "问题描述")
private String description;
@Schema(description = "附件ID数组")
private List<String> attachments;
@Schema(description = "附件数量")
private Integer attachmentCount;
@Schema(description = "工单来源")
private String ticketSource;
@Schema(description = "工单来源名称")
private String ticketSourceName;
@Schema(description = "分配给处理人ID")
private String assignedTo;
@Schema(description = "处理人姓名")
private String assignedToName;
@Schema(description = "分配部门")
private String assignedDept;
@Schema(description = "分配部门名称")
private String assignedDeptName;
@Schema(description = "工单状态")
private String ticketStatus;
@Schema(description = "工单状态名称")
private String ticketStatusName;
@Schema(description = "工单状态颜色")
private String statusColor;
@Schema(description = "解决方案")
private String resolution;
@Schema(description = "解决时间", format = "date-time")
@JSONField(format = "yyyy-MM-dd HH:mm:ss")
private Date resolutionTime;
@Schema(description = "关闭时间", format = "date-time")
@JSONField(format = "yyyy-MM-dd HH:mm:ss")
private Date closeTime;
@Schema(description = "首次响应时间", format = "date-time")
@JSONField(format = "yyyy-MM-dd HH:mm:ss")
private Date responseTime;
@Schema(description = "SLA截止时间", format = "date-time")
@JSONField(format = "yyyy-MM-dd HH:mm:ss")
private Date slaDeadline;
@Schema(description = "是否逾期", defaultValue = "false")
private Boolean isOverdue;
@Schema(description = "距离SLA截止的剩余时间分钟")
private Integer slaRemainingMinutes;
@Schema(description = "客户评分1-5星")
private Integer customerRating;
@Schema(description = "客户反馈")
private String customerFeedback;
@Schema(description = "CRM系统工单ID")
private String crmTicketId;
@Schema(description = "同步状态")
private String syncStatus;
@Schema(description = "同步状态名称")
private String syncStatusName;
@Schema(description = "工单标签")
private List<String> tags;
@Schema(description = "工单元数据")
private JsonNode metadata;
@Schema(description = "处理记录数量")
private Integer logCount;
@Schema(description = "创建者姓名")
private String creatorName;
@Schema(description = "更新者姓名")
private String updaterName;
}

View File

@@ -0,0 +1,44 @@
package org.xyzh.api.workcase.vo;
import java.util.List;
import org.xyzh.common.vo.BaseVO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* @description 工单过程VO
* @filename WorkcaseProcessVO.java
* @author yslg
* @copyright xyzh
* @since 2025-12-31
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "工单过程VO")
public class WorkcaseProcessVO extends BaseVO {
private static final long serialVersionUID = 1L;
@Schema(description = "工单ID")
private String workcaseId;
@Schema(description = "过程ID")
private String processId;
@Schema(description = "动作 info记录assign指派redeploy转派repeal撤销finish完成")
private String action;
@Schema(description = "消息")
private String message;
@Schema(description = "携带文件列表")
private List<String> files;
@Schema(description = "处理人ID指派、转派专属")
private String processor;
@Schema(description = "处理人名称")
private String processorName;
}

View File

@@ -0,0 +1,368 @@
app:
description: 根据用户指定的知识库进行检索,返回相似片段结构化数据。
icon: 🤖
icon_background: '#FFEAD5'
mode: workflow
name: 动态知识库检索
use_icon_as_answer_icon: false
dependencies: []
kind: app
version: 0.5.0
workflow:
conversation_variables: []
environment_variables: []
features:
file_upload:
allowed_file_extensions:
- .JPG
- .JPEG
- .PNG
- .GIF
- .WEBP
- .SVG
allowed_file_types:
- image
allowed_file_upload_methods:
- local_file
- remote_url
enabled: false
fileUploadConfig:
audio_file_size_limit: 50
batch_count_limit: 5
file_size_limit: 15
image_file_batch_limit: 10
image_file_size_limit: 10
single_chunk_attachment_limit: 10
video_file_size_limit: 100
workflow_file_upload_limit: 10
image:
enabled: false
number_limits: 3
transfer_methods:
- local_file
- remote_url
number_limits: 3
opening_statement: ''
retriever_resource:
enabled: true
sensitive_word_avoidance:
enabled: false
speech_to_text:
enabled: false
suggested_questions: []
suggested_questions_after_answer:
enabled: false
text_to_speech:
enabled: false
language: ''
voice: ''
graph:
edges:
- data:
isInIteration: false
isInLoop: false
sourceType: code
targetType: iteration
id: 1747125551298-source-1747125586388-target
selected: false
source: '1747125551298'
sourceHandle: source
target: '1747125586388'
targetHandle: target
type: custom
zIndex: 0
- data:
isInIteration: true
isInLoop: false
iteration_id: '1747125586388'
sourceType: iteration-start
targetType: http-request
id: 1747125586388start-source-1747125795256-target
source: 1747125586388start
sourceHandle: source
target: '1747125795256'
targetHandle: target
type: custom
zIndex: 1002
- data:
isInIteration: false
isInLoop: false
sourceType: iteration
targetType: code
id: 1747125586388-source-1747125859504-target
source: '1747125586388'
sourceHandle: source
target: '1747125859504'
targetHandle: target
type: custom
zIndex: 0
- data:
isInIteration: false
isInLoop: false
sourceType: code
targetType: end
id: 1747125859504-source-1747125871123-target
source: '1747125859504'
sourceHandle: source
target: '1747125871123'
targetHandle: target
type: custom
zIndex: 0
- data:
isInLoop: false
sourceType: start
targetType: code
id: 1747125462435-source-1747125551298-target
source: '1747125462435'
sourceHandle: source
target: '1747125551298'
targetHandle: target
type: custom
zIndex: 0
nodes:
- data:
desc: ''
selected: false
title: 开始
type: start
variables:
- label: 知识库集多个Id使用英文逗号隔开
max_length: 256
options: []
required: true
type: text-input
variable: dataset_ids
- label: 用户问题
max_length: 256
options: []
required: true
type: text-input
variable: query
- default: ''
hint: ''
label: dataset_apikey
max_length: 100
options: []
placeholder: ''
required: true
type: text-input
variable: dataset_apikey
height: 161
id: '1747125462435'
position:
x: 80
y: 282
positionAbsolute:
x: 80
y: 282
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 242
- data:
code: "\nimport json\ndef main(dataset_ids:str):\n return {\n \"\
result\": json.loads(dataset_ids)\n }\n"
code_language: python3
desc: ''
outputs:
result:
children: null
type: array[string]
selected: false
title: 知识库集
type: code
variables:
- value_selector:
- '1747125462435'
- dataset_ids
value_type: string
variable: dataset_ids
height: 52
id: '1747125551298'
position:
x: 682
y: 282
positionAbsolute:
x: 682
y: 282
selected: true
sourcePosition: right
targetPosition: left
type: custom
width: 242
- data:
desc: ''
error_handle_mode: terminated
height: 231
is_parallel: true
iterator_selector:
- '1747125551298'
- result
output_selector:
- '1747125795256'
- body
output_type: array[string]
parallel_nums: 10
selected: false
start_node_id: 1747125586388start
title: 迭代
type: iteration
width: 388
height: 231
id: '1747125586388'
position:
x: 988
y: 282
positionAbsolute:
x: 988
y: 282
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 388
zIndex: 1
- data:
desc: ''
isInIteration: true
selected: false
title: ''
type: iteration-start
draggable: false
height: 48
id: 1747125586388start
parentId: '1747125586388'
position:
x: 24
y: 68
positionAbsolute:
x: 1012
y: 350
selectable: false
sourcePosition: right
targetPosition: left
type: custom-iteration-start
width: 44
zIndex: 1002
- data:
authorization:
config: null
type: no-auth
body:
data:
- id: key-value-274
key: ''
type: text
value: '{
"query": {{#1747125462435.query#}}
}'
type: json
desc: ''
headers: 'Authorization:Bearer {{#1747125462435.dataset_apikey#}}
Content-Type:application/json'
isInIteration: true
isInLoop: false
iteration_id: '1747125586388'
method: post
params: ''
retry_config:
max_retries: 3
retry_enabled: false
retry_interval: 100
selected: false
ssl_verify: true
timeout:
max_connect_timeout: 0
max_read_timeout: 0
max_write_timeout: 0
title: HTTP 请求
type: http-request
url: http://nginx:80/v1/datasets/{{#1747125586388.item#}}/retrieve
variables: []
height: 111
id: '1747125795256'
parentId: '1747125586388'
position:
x: 120.421417167448
y: 54
positionAbsolute:
x: 1108.421417167448
y: 336
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 242
zIndex: 1002
- data:
code: "\nfunction main({arg1}) {\n const result = [];\n \n // 判断arg1是否为空\n\
\ if (!arg1 || arg1.length === 0) {\n return {\n result:\
\ JSON.stringify([]) // 返回空数组的JSON字符串\n };\n }\n \n //\
\ 遍历arg1中的每个元素\n for (const str of arg1) {\n try {\n \
\ // 解析JSON\n const parsed = JSON.parse(str);\n \
\ // 检查是否有records属性且不为空数组\n if (parsed.records && Array.isArray(parsed.records)\
\ && parsed.records.length > 0) {\n result.push(...parsed.records)\n\
\ }\n } catch (e) {\n // 如果JSON解析失败,跳过这个元素\n\
\ console.error('Failed to parse JSON:', e);\n }\n \
\ }\n \n // 将结果数组转换为JSON字符串返回\n return {\n result: JSON.stringify(result)\n\
\ }\n}\n"
code_language: javascript
desc: ''
outputs:
result:
children: null
type: string
selected: false
title: 知识库检索结果整理
type: code
variables:
- value_selector:
- '1747125586388'
- output
variable: arg1
height: 52
id: '1747125859504'
position:
x: 1436
y: 282
positionAbsolute:
x: 1436
y: 282
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 242
- data:
desc: ''
outputs:
- value_selector:
- '1747125859504'
- result
value_type: string
variable: output
selected: false
title: 结束
type: end
height: 88
id: '1747125871123'
position:
x: 1739
y: 282
positionAbsolute:
x: 1739
y: 282
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 242
viewport:
x: -33.22404773079188
y: -134.2994836345366
zoom: 0.8705505632961247
rag_pipeline_variables: []

View File

@@ -0,0 +1,559 @@
app:
description: ''
icon: 🤖
icon_background: '#FFEAD5'
mode: advanced-chat
name: 泰豪小电
use_icon_as_answer_icon: false
dependencies:
- current_identifier: null
type: marketplace
value:
marketplace_plugin_unique_identifier: langgenius/siliconflow:0.0.38@4795747d4fca05fee9daf34b1bcc110ffbbfcd9112f5f9e914f90b8b5dd549e5
version: null
kind: app
version: 0.5.0
workflow:
conversation_variables:
- description: 设备代码
id: 8a1c2cb7-376c-424a-914a-52e4155d9398
name: device_code
selector:
- conversation
- device_code
value: ''
value_type: string
- description: 知识库访问apiKey
id: 9e385516-ad32-4ab5-ac90-1fa7c5126d06
name: dataset_apikey
selector:
- conversation
- dataset_apikey
value: ''
value_type: string
- description: 知识库id集合json序列化
id: a9aaff15-cf7a-4054-a607-8ce319005703
name: datasets
selector:
- conversation
- datasets
value: ''
value_type: string
environment_variables: []
features:
file_upload:
allowed_file_extensions:
- .JPG
- .JPEG
- .PNG
- .GIF
- .WEBP
- .SVG
allowed_file_types:
- image
allowed_file_upload_methods:
- local_file
- remote_url
enabled: false
fileUploadConfig:
audio_file_size_limit: 50
batch_count_limit: 5
file_size_limit: 500
image_file_batch_limit: 10
image_file_size_limit: 10
single_chunk_attachment_limit: 10
video_file_size_limit: 100
workflow_file_upload_limit: 10
image:
enabled: false
number_limits: 3
transfer_methods:
- local_file
- remote_url
number_limits: 3
opening_statement: ''
retriever_resource:
enabled: true
sensitive_word_avoidance:
enabled: false
speech_to_text:
enabled: false
suggested_questions: []
suggested_questions_after_answer:
enabled: false
text_to_speech:
enabled: false
language: ''
voice: ''
graph:
edges:
- data:
isInIteration: false
isInLoop: false
sourceType: start
targetType: if-else
id: 1766469212796-source-1766998282613-target
selected: false
source: '1766469212796'
sourceHandle: source
target: '1766998282613'
targetHandle: target
type: custom
zIndex: 0
- data:
isInIteration: false
isInLoop: false
sourceType: start
targetType: if-else
id: 1766469212796-source-1766998308433-target
selected: false
source: '1766469212796'
sourceHandle: source
target: '1766998308433'
targetHandle: target
type: custom
zIndex: 0
- data:
isInIteration: false
isInLoop: false
sourceType: if-else
targetType: assigner
id: 1766998282613-true-1766998338007-target
selected: false
source: '1766998282613'
sourceHandle: 'true'
target: '1766998338007'
targetHandle: target
type: custom
zIndex: 0
- data:
isInIteration: false
isInLoop: false
sourceType: if-else
targetType: assigner
id: 1766998308433-true-1766998352957-target
selected: false
source: '1766998308433'
sourceHandle: 'true'
target: '1766998352957'
targetHandle: target
type: custom
zIndex: 0
- data:
isInIteration: false
isInLoop: false
sourceType: assigner
targetType: tool
id: 1766998338007-source-1766998463133-target
selected: false
source: '1766998338007'
sourceHandle: source
target: '1766998463133'
targetHandle: target
type: custom
zIndex: 0
- data:
isInLoop: false
sourceType: assigner
targetType: tool
id: 1766998352957-source-1766998463133-target
selected: false
source: '1766998352957'
sourceHandle: source
target: '1766998463133'
targetHandle: target
type: custom
zIndex: 0
- data:
isInLoop: false
sourceType: if-else
targetType: tool
id: 1766998282613-false-1766998463133-target
selected: false
source: '1766998282613'
sourceHandle: 'false'
target: '1766998463133'
targetHandle: target
type: custom
zIndex: 0
- data:
isInLoop: false
sourceType: if-else
targetType: tool
id: 1766998308433-false-1766998463133-target
selected: false
source: '1766998308433'
sourceHandle: 'false'
target: '1766998463133'
targetHandle: target
type: custom
zIndex: 0
- data:
isInLoop: false
sourceType: tool
targetType: llm
id: 1766998463133-source-1766998751026-target
source: '1766998463133'
sourceHandle: source
target: '1766998751026'
targetHandle: target
type: custom
zIndex: 0
- data:
isInLoop: false
sourceType: llm
targetType: answer
id: 1766998751026-source-answer-target
source: '1766998751026'
sourceHandle: source
target: answer
targetHandle: target
type: custom
zIndex: 0
nodes:
- data:
selected: false
title: 用户输入
type: start
variables:
- default: ''
hint: ''
label: 知识库id集合json序列化
max_length: 4096
options: []
placeholder: ''
required: true
type: paragraph
variable: datasets
- default: ''
hint: ''
label: 知识库访问apiKey
max_length: 100
options: []
placeholder: ''
required: true
type: text-input
variable: dataset_apikey
height: 135
id: '1766469212796'
position:
x: 0
y: 33
positionAbsolute:
x: 0
y: 33
selected: true
sourcePosition: right
targetPosition: left
type: custom
width: 242
- data:
answer: '{{#1766998751026.text#}}'
selected: false
title: 直接回复
type: answer
variables: []
height: 103
id: answer
position:
x: 1770
y: 87
positionAbsolute:
x: 1770
y: 87
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 242
- data:
cases:
- case_id: 'true'
conditions:
- comparison_operator: empty
id: 8edef3b0-823d-481c-a4eb-254a0bd42535
value: ''
varType: string
variable_selector:
- conversation
- datasets
id: 'true'
logical_operator: and
selected: false
title: datasets初始化
type: if-else
height: 124
id: '1766998282613'
position:
x: 362
y: 16
positionAbsolute:
x: 362
y: 16
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 242
- data:
cases:
- case_id: 'true'
conditions:
- comparison_operator: empty
id: 2976abfb-0b2d-43ec-bb02-f547892e39de
value: ''
varType: string
variable_selector:
- conversation
- dataset_apikey
id: 'true'
logical_operator: and
selected: false
title: 条件分支 2
type: if-else
height: 124
id: '1766998308433'
position:
x: 362
y: 220
positionAbsolute:
x: 362
y: 220
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 242
- data:
items:
- input_type: variable
operation: over-write
value:
- '1766469212796'
- datasets
variable_selector:
- conversation
- datasets
write_mode: over-write
selected: false
title: 变量赋值
type: assigner
version: '2'
height: 84
id: '1766998338007'
position:
x: 724
y: 0
positionAbsolute:
x: 724
y: 0
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 242
- data:
items:
- input_type: variable
operation: over-write
value:
- '1766469212796'
- dataset_apikey
variable_selector:
- conversation
- dataset_apikey
write_mode: over-write
selected: false
title: 变量赋值 2
type: assigner
version: '2'
height: 84
id: '1766998352957'
position:
x: 724
y: 185
positionAbsolute:
x: 724
y: 185
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 242
- data:
is_team_authorization: true
paramSchemas:
- auto_generate: null
default: null
form: llm
human_description:
en_US: ''
ja_JP: ''
pt_BR: ''
zh_Hans: ''
label:
en_US: 知识库集多个Id使用英文逗号隔开
ja_JP: 知识库集多个Id使用英文逗号隔开
pt_BR: 知识库集多个Id使用英文逗号隔开
zh_Hans: 知识库集多个Id使用英文逗号隔开
llm_description: ''
max: null
min: null
name: dataset_ids
options: []
placeholder:
en_US: ''
ja_JP: ''
pt_BR: ''
zh_Hans: ''
precision: null
required: true
scope: null
template: null
type: string
- auto_generate: null
default: null
form: llm
human_description:
en_US: ''
ja_JP: ''
pt_BR: ''
zh_Hans: ''
label:
en_US: 用户问题
ja_JP: 用户问题
pt_BR: 用户问题
zh_Hans: 用户问题
llm_description: ''
max: null
min: null
name: query
options: []
placeholder:
en_US: ''
ja_JP: ''
pt_BR: ''
zh_Hans: ''
precision: null
required: true
scope: null
template: null
type: string
- auto_generate: null
default: ''
form: llm
human_description:
en_US: ''
ja_JP: ''
pt_BR: ''
zh_Hans: ''
label:
en_US: dataset_apikey
ja_JP: dataset_apikey
pt_BR: dataset_apikey
zh_Hans: dataset_apikey
llm_description: ''
max: null
min: null
name: dataset_apikey
options: []
placeholder:
en_US: ''
ja_JP: ''
pt_BR: ''
zh_Hans: ''
precision: null
required: true
scope: null
template: null
type: string
params:
dataset_apikey: ''
dataset_ids: ''
query: ''
plugin_id: null
plugin_unique_identifier: null
provider_icon:
background: '#FFEAD5'
content: 🤖
provider_id: 5d6141ab-e1ad-401f-a8b6-da6ce4dd682f
provider_name: 动态知识库检索
provider_type: workflow
selected: false
title: 动态知识库检索
tool_configurations: {}
tool_description: 根据用户指定的知识库进行检索,返回相似片段结构化数据。
tool_label: 动态知识库检索
tool_name: dynamic_dataset
tool_node_version: '2'
tool_parameters:
dataset_apikey:
type: mixed
value: '{{#conversation.dataset_apikey#}}'
dataset_ids:
type: mixed
value: '{{#conversation.datasets#}}'
query:
type: mixed
value: '{{#sys.query#}}'
type: tool
height: 52
id: '1766998463133'
position:
x: 1086
y: 113
positionAbsolute:
x: 1086
y: 113
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 242
- data:
context:
enabled: true
variable_selector:
- '1766998463133'
- text
model:
completion_params:
temperature: 0.7
mode: chat
name: Pro/moonshotai/Kimi-K2-Instruct
provider: langgenius/siliconflow/siliconflow
prompt_config:
jinja2_variables:
- value_selector:
- '1766998463133'
- text
variable: text
prompt_template:
- edition_type: jinja2
id: 1ee839fc-fff4-41b5-bbe3-b778b428cef4
jinja2_text: 根据知识库内容{{ text }}回答用户问题
role: system
text: ''
selected: false
title: LLM
type: llm
vision:
enabled: false
height: 88
id: '1766998751026'
position:
x: 1428
y: 95
positionAbsolute:
x: 1428
y: 95
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 242
viewport:
x: -671.7
y: 334.55
zoom: 0.7
rag_pipeline_variables: []

View File

@@ -29,7 +29,6 @@ security:
spring: spring:
application: application:
name: file-service name: file-service
# ================== Spring Cloud Nacos ================== # ================== Spring Cloud Nacos ==================
cloud: cloud:
nacos: nacos:
@@ -57,8 +56,8 @@ spring:
servlet: servlet:
multipart: multipart:
enabled: true enabled: true
max-file-size: 100MB max-file-size: 500MB
max-request-size: 100MB max-request-size: 500MB
# ================== SpringDoc ================== # ================== SpringDoc ==================
springdoc: springdoc:
@@ -79,6 +78,7 @@ dubbo:
name: urban-lifeline-file name: urban-lifeline-file
qos-enable: false qos-enable: false
protocol: protocol:
payload: 110100480
name: dubbo name: dubbo
port: -1 port: -1
registry: registry:

View File

@@ -1,6 +1,7 @@
package org.xyzh.workcase.controller; package org.xyzh.workcase.controller;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
@@ -40,6 +41,7 @@ import io.swagger.v3.oas.annotations.Operation;
import java.util.Arrays; import java.util.Arrays;
import java.util.Date; import java.util.Date;
import java.util.Map;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@@ -58,7 +60,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
@Validated @Validated
@RestController @RestController
@RequestMapping("/workcase/chat") @RequestMapping("/workcase/chat")
public class WorkcaseChatContorller { public class WorkcaseChatController {
@Autowired @Autowired
private WorkcaseChatService workcaseChatService; private WorkcaseChatService workcaseChatService;
@@ -76,7 +78,8 @@ public class WorkcaseChatContorller {
@PostMapping("/room") @PostMapping("/room")
public ResultDomain<TbChatRoomDTO> createChatRoom(@RequestBody TbChatRoomDTO chatRoom) { public ResultDomain<TbChatRoomDTO> createChatRoom(@RequestBody TbChatRoomDTO chatRoom) {
ValidationResult vr = ValidationUtils.validate(chatRoom, Arrays.asList( ValidationResult vr = ValidationUtils.validate(chatRoom, Arrays.asList(
ValidationUtils.requiredString("guestId", "来客ID") ValidationUtils.requiredString("guestId", "来客ID"),
ValidationUtils.requiredString("deviceCode", "设备代码")
)); ));
if (!vr.isValid()) { if (!vr.isValid()) {
return ResultDomain.failure(vr.getAllErrors()); return ResultDomain.failure(vr.getAllErrors());
@@ -109,6 +112,31 @@ public class WorkcaseChatContorller {
return chatRoomService.closeChatRoom(roomId, closedBy); return chatRoomService.closeChatRoom(roomId, closedBy);
} }
/**
* 提交聊天室服务评分
* @param roomId 聊天室ID
* @param commentLevel 评分1-5星
* @return 提交结果
*/
@Operation(summary = "提交聊天室服务评分")
@PreAuthorize("hasAuthority('workcase:room:view')")
@PostMapping("/room/{roomId}/comment")
public ResultDomain<Boolean> submitComment(
@PathVariable("roomId") String roomId,
@RequestBody Map<String, Integer> body) {
Integer commentLevel = body.get("commentLevel");
if (commentLevel == null || commentLevel < 1 || commentLevel > 5) {
return ResultDomain.failure("评分必须在1-5之间");
}
// 获取当前登录用户ID
String userId = LoginUtil.getCurrentUserId();
// 调用服务层提交评分
return chatRoomService.submitCommentLevel(roomId, commentLevel, userId);
}
@Operation(summary = "获取聊天室详情") @Operation(summary = "获取聊天室详情")
@PreAuthorize("hasAuthority('workcase:room:view')") @PreAuthorize("hasAuthority('workcase:room:view')")
@GetMapping("/room/{roomId}") @GetMapping("/room/{roomId}")

View File

@@ -14,6 +14,7 @@ import org.xyzh.api.workcase.dto.TbWorkcaseDTO;
import org.xyzh.api.workcase.dto.TbWorkcaseDeviceDTO; import org.xyzh.api.workcase.dto.TbWorkcaseDeviceDTO;
import org.xyzh.api.workcase.dto.TbWorkcaseProcessDTO; import org.xyzh.api.workcase.dto.TbWorkcaseProcessDTO;
import org.xyzh.api.workcase.service.WorkcaseService; import org.xyzh.api.workcase.service.WorkcaseService;
import org.xyzh.api.workcase.vo.WorkcaseProcessVO;
import org.xyzh.common.auth.utils.LoginUtil; import org.xyzh.common.auth.utils.LoginUtil;
import org.xyzh.common.core.domain.LoginDomain; import org.xyzh.common.core.domain.LoginDomain;
import org.xyzh.common.core.domain.ResultDomain; import org.xyzh.common.core.domain.ResultDomain;
@@ -49,7 +50,8 @@ public class WorkcaseController {
@PostMapping @PostMapping
public ResultDomain<TbWorkcaseDTO> createWorkcase(@RequestBody TbWorkcaseDTO workcase) { public ResultDomain<TbWorkcaseDTO> createWorkcase(@RequestBody TbWorkcaseDTO workcase) {
ValidationResult vr = ValidationUtils.validate(workcase, Arrays.asList( ValidationResult vr = ValidationUtils.validate(workcase, Arrays.asList(
ValidationUtils.requiredString("deviceNamePlateImg", "设备名称牌图片"), ValidationUtils.requiredString("deviceNamePlate", "设备名称牌图片"),
ValidationUtils.requiredString("deviceCode", "设备代码"),
ValidationUtils.requiredString("type", "问题类型"), ValidationUtils.requiredString("type", "问题类型"),
ValidationUtils.requiredString("userId", "用户ID"), ValidationUtils.requiredString("userId", "用户ID"),
ValidationUtils.requiredString("username", "用户名称") ValidationUtils.requiredString("username", "用户名称")
@@ -176,14 +178,14 @@ public class WorkcaseController {
@Operation(summary = "查询工单处理过程列表") @Operation(summary = "查询工单处理过程列表")
@PreAuthorize("hasAuthority('workcase:ticket:process')") @PreAuthorize("hasAuthority('workcase:ticket:process')")
@PostMapping("/process/list") @PostMapping("/process/list")
public ResultDomain<TbWorkcaseProcessDTO> getWorkcaseProcessList(@RequestBody TbWorkcaseProcessDTO filter) { public ResultDomain<WorkcaseProcessVO> getWorkcaseProcessList(@RequestBody TbWorkcaseProcessDTO filter) {
return workcaseService.getWorkcaseProcessList(filter); return workcaseService.getWorkcaseProcessList(filter);
} }
@Operation(summary = "分页查询工单处理过程") @Operation(summary = "分页查询工单处理过程")
@PreAuthorize("hasAuthority('workcase:ticket:process')") @PreAuthorize("hasAuthority('workcase:ticket:process')")
@PostMapping("/process/page") @PostMapping("/process/page")
public ResultDomain<TbWorkcaseProcessDTO> getWorkcaseProcessPage(@RequestBody PageRequest<TbWorkcaseProcessDTO> pageRequest) { public ResultDomain<WorkcaseProcessVO> getWorkcaseProcessPage(@RequestBody PageRequest<TbWorkcaseProcessDTO> pageRequest) {
ValidationResult vr = ValidationUtils.validate(pageRequest, Arrays.asList( ValidationResult vr = ValidationUtils.validate(pageRequest, Arrays.asList(
ValidationUtils.requiredNumber("pageParam.page", "页码", 1, null), ValidationUtils.requiredNumber("pageParam.page", "页码", 1, null),
ValidationUtils.requiredNumber("pageParam.pageSize", "每页数量", 1, 100) ValidationUtils.requiredNumber("pageParam.pageSize", "每页数量", 1, 100)

View File

@@ -67,6 +67,21 @@ public class ChatMessageListener implements MessageListener {
// 转发到聊天室列表订阅者,前端刷新列表状态 // 转发到聊天室列表订阅者,前端刷新列表状态
messagingTemplate.convertAndSend("/topic/chat/list-update", chatMessage); messagingTemplate.convertAndSend("/topic/chat/list-update", chatMessage);
logger.debug("列表更新已转发到STOMP: /topic/chat/list-update"); logger.debug("列表更新已转发到STOMP: /topic/chat/list-update");
// 同时转发到对应聊天室频道,确保聊天窗口也能收到消息
String roomId = chatMessage.getRoomId();
if (roomId != null && !roomId.isEmpty()) {
// 查询完整的VO数据
ChatRoomMessageVO messageVO = chatMessageMapper.selectChatMessageVOById(chatMessage.getMessageId());
if (messageVO != null) {
messagingTemplate.convertAndSend("/topic/chat/" + roomId, messageVO);
logger.debug("列表更新消息同时转发到聊天室: /topic/chat/{}", roomId);
} else {
// 如果查不到VO可能事务未提交直接用DTO转发
logger.warn("未找到消息VO使用DTO转发: messageId={}", chatMessage.getMessageId());
messagingTemplate.convertAndSend("/topic/chat/" + roomId, chatMessage);
}
}
} }
} catch (Exception e) { } catch (Exception e) {
logger.error("处理Redis消息失败", e); logger.error("处理Redis消息失败", e);

View File

@@ -5,6 +5,7 @@ import java.util.List;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.xyzh.api.workcase.dto.TbWorkcaseProcessDTO; import org.xyzh.api.workcase.dto.TbWorkcaseProcessDTO;
import org.xyzh.api.workcase.vo.WorkcaseProcessVO;
import org.xyzh.common.core.page.PageParam; import org.xyzh.common.core.page.PageParam;
/** /**
@@ -40,12 +41,12 @@ public interface TbWorkcaseProcessMapper {
/** /**
* 查询工单过程列表 * 查询工单过程列表
*/ */
List<TbWorkcaseProcessDTO> selectWorkcaseProcessList(@Param("filter") TbWorkcaseProcessDTO filter); List<WorkcaseProcessVO> selectWorkcaseProcessList(@Param("filter") TbWorkcaseProcessDTO filter);
/** /**
* 分页查询工单过程 * 分页查询工单过程
*/ */
List<TbWorkcaseProcessDTO> selectWorkcaseProcessPage(@Param("filter") TbWorkcaseProcessDTO filter, @Param("pageParam") PageParam pageParam); List<WorkcaseProcessVO> selectWorkcaseProcessPage(@Param("filter") TbWorkcaseProcessDTO filter, @Param("pageParam") PageParam pageParam);
/** /**
* 统计工单过程数量 * 统计工单过程数量

View File

@@ -122,7 +122,11 @@ public class ChatRoomServiceImpl implements ChatRoomService {
} }
// 从AI同步对话历史 // 从AI同步对话历史
if(NonUtils.isNotEmpty(chatRoom.getAiSessionId())){ if(NonUtils.isNotEmpty(chatRoom.getAiSessionId())){
syncAiChatMessages(chatRoom); try{
syncAiChatMessages(chatRoom);
}catch(Exception ex){
return ResultDomain.failure("创建失败");
}
} }
return ResultDomain.success("创建成功", chatRoom); return ResultDomain.success("创建成功", chatRoom);
@@ -680,7 +684,8 @@ public class ChatRoomServiceImpl implements ChatRoomService {
roomMsg.setMessageType("text"); roomMsg.setMessageType("text");
roomMsg.setStatus("sent"); roomMsg.setStatus("sent");
roomMsg.setFiles(aiMsg.getFiles()); roomMsg.setFiles(aiMsg.getFiles());
roomMsg.setSendTime(new Date(baseTime + i * 1000L)); roomMsg.setSendTime(aiMsg.getCreateTime());
roomMsg.setCreateTime(aiMsg.getCreateTime());
roomMsg.setIsAiMessage(true); roomMsg.setIsAiMessage(true);
roomMsg.setAiMessageId(aiMsg.getMessageId()); roomMsg.setAiMessageId(aiMsg.getMessageId());
roomMsg.setCreator(chatRoom.getGuestId()); roomMsg.setCreator(chatRoom.getGuestId());
@@ -764,4 +769,60 @@ public class ChatRoomServiceImpl implements ChatRoomService {
logger.error("发布列表更新到Redis失败", e); logger.error("发布列表更新到Redis失败", e);
} }
} }
// ========================= 聊天室评分管理 ==========================
@Override
@Transactional
public ResultDomain<Boolean> submitCommentLevel(String roomId, Integer commentLevel, String userId) {
logger.info("提交聊天室服务评分: roomId={}, commentLevel={}, userId={}", roomId, commentLevel, userId);
// 参数校验
if (NonUtils.isEmpty(roomId)) {
return ResultDomain.failure("聊天室ID不能为空");
}
if (commentLevel == null || commentLevel < 1 || commentLevel > 5) {
return ResultDomain.failure("评分必须在1-5星之间");
}
try {
// 1. 检查聊天室是否存在
TbChatRoomDTO chatRoom = chatRoomMapper.selectChatRoomById(roomId);
if (chatRoom == null) {
return ResultDomain.failure("聊天室不存在");
}
// 2. 检查用户是否是聊天室成员(来客)
if (!userId.equals(chatRoom.getGuestId())) {
return ResultDomain.failure("只有来客可以对服务进行评分");
}
// 3. 检查是否已评分
if (chatRoom.getCommentLevel() != null && chatRoom.getCommentLevel() > 0) {
return ResultDomain.failure("已经评分过了,不能重复评分");
}
// 4. 更新评分
TbChatRoomDTO updateRoom = new TbChatRoomDTO();
updateRoom.setRoomId(roomId);
updateRoom.setCommentLevel(commentLevel);
int rows = chatRoomMapper.updateChatRoom(updateRoom);
if (rows > 0) {
logger.info("聊天室服务评分成功: roomId={}, commentLevel={}", roomId, commentLevel);
// TODO: 后续可以在这里更新客服人员的平均满意度评分
// updateCustomerServiceSatisfaction(chatRoom, commentLevel);
return ResultDomain.success("评分成功",true);
} else {
return ResultDomain.failure("评分提交失败");
}
} catch (Exception e) {
logger.error("提交聊天室服务评分失败: roomId={}, commentLevel={}", roomId, commentLevel, e);
return ResultDomain.failure("评分提交失败: " + e.getMessage());
}
}
} }

View File

@@ -25,6 +25,7 @@ import org.xyzh.api.workcase.dto.TbWorkcaseDTO;
import org.xyzh.api.workcase.dto.TbWorkcaseDeviceDTO; import org.xyzh.api.workcase.dto.TbWorkcaseDeviceDTO;
import org.xyzh.api.workcase.dto.TbWorkcaseProcessDTO; import org.xyzh.api.workcase.dto.TbWorkcaseProcessDTO;
import org.xyzh.api.workcase.service.WorkcaseService; import org.xyzh.api.workcase.service.WorkcaseService;
import org.xyzh.api.workcase.vo.WorkcaseProcessVO;
import org.xyzh.common.auth.utils.LoginUtil; import org.xyzh.common.auth.utils.LoginUtil;
import org.xyzh.common.core.domain.ResultDomain; import org.xyzh.common.core.domain.ResultDomain;
import org.xyzh.common.core.page.PageDomain; import org.xyzh.common.core.page.PageDomain;
@@ -370,10 +371,38 @@ public class WorkcaseServiceImpl implements WorkcaseService {
workcaseMapper.updateWorkcase(workcase); workcaseMapper.updateWorkcase(workcase);
} }
} else if (WorkcaseProcessAction.FINISH.getName().equals(action)) { } else if (WorkcaseProcessAction.FINISH.getName().equals(action)) {
// 1. 更新工单状态为已完成
TbWorkcaseDTO workcase = new TbWorkcaseDTO(); TbWorkcaseDTO workcase = new TbWorkcaseDTO();
workcase.setWorkcaseId(workcaseProcess.getWorkcaseId()); workcase.setWorkcaseId(workcaseProcess.getWorkcaseId());
workcase.setStatus("done"); workcase.setStatus("done");
workcaseMapper.updateWorkcase(workcase); workcaseMapper.updateWorkcase(workcase);
// 2. 发送系统评分消息到聊天室
try {
TbWorkcaseDTO workcaseData = workcaseMapper.selectWorkcaseById(workcaseProcess.getWorkcaseId());
if (workcaseData != null && workcaseData.getRoomId() != null) {
// 创建系统评分消息
org.xyzh.api.workcase.dto.TbChatRoomMessageDTO commentMessage = new org.xyzh.api.workcase.dto.TbChatRoomMessageDTO();
commentMessage.setMessageId(IdUtil.generateUUID());
commentMessage.setOptsn(IdUtil.getOptsn());
commentMessage.setRoomId(workcaseData.getRoomId());
commentMessage.setSenderId("system");
commentMessage.setSenderType("system"); // 系统消息
commentMessage.setSenderName("系统");
commentMessage.setMessageType("comment"); // 评分消息
commentMessage.setContent("请为本次服务评分");
commentMessage.setStatus("sent");
commentMessage.setCreator("system");
// 发送消息到聊天室
chatRoomService.sendMessage(commentMessage);
logger.info("工单完成,已发送系统评分消息: workcaseId={}, roomId={}",
workcaseProcess.getWorkcaseId(), workcaseData.getRoomId());
}
} catch (Exception e) {
logger.error("发送系统评分消息失败: workcaseId={}", workcaseProcess.getWorkcaseId(), e);
// 不影响工单完成流程,只记录错误日志
}
} else if (WorkcaseProcessAction.REPEAL.getName().equals(action)) { } else if (WorkcaseProcessAction.REPEAL.getName().equals(action)) {
TbWorkcaseDTO workcase = new TbWorkcaseDTO(); TbWorkcaseDTO workcase = new TbWorkcaseDTO();
workcase.setWorkcaseId(workcaseProcess.getWorkcaseId()); workcase.setWorkcaseId(workcaseProcess.getWorkcaseId());
@@ -417,21 +446,21 @@ public class WorkcaseServiceImpl implements WorkcaseService {
} }
@Override @Override
public ResultDomain<TbWorkcaseProcessDTO> getWorkcaseProcessList(TbWorkcaseProcessDTO filter) { public ResultDomain<WorkcaseProcessVO> getWorkcaseProcessList(TbWorkcaseProcessDTO filter) {
List<TbWorkcaseProcessDTO> list = workcaseProcessMapper.selectWorkcaseProcessList(filter); List<WorkcaseProcessVO> list = workcaseProcessMapper.selectWorkcaseProcessList(filter);
return ResultDomain.success("查询成功", list); return ResultDomain.success("查询成功", list);
} }
@Override @Override
public ResultDomain<TbWorkcaseProcessDTO> getWorkcaseProcessPage(PageRequest<TbWorkcaseProcessDTO> pageRequest) { public ResultDomain<WorkcaseProcessVO> getWorkcaseProcessPage(PageRequest<TbWorkcaseProcessDTO> pageRequest) {
TbWorkcaseProcessDTO filter = pageRequest.getFilter(); TbWorkcaseProcessDTO filter = pageRequest.getFilter();
PageParam pageParam = pageRequest.getPageParam(); PageParam pageParam = pageRequest.getPageParam();
List<TbWorkcaseProcessDTO> list = workcaseProcessMapper.selectWorkcaseProcessPage(filter, pageParam); List<WorkcaseProcessVO> list = workcaseProcessMapper.selectWorkcaseProcessPage(filter, pageParam);
long total = workcaseProcessMapper.countWorkcaseProcesses(filter); long total = workcaseProcessMapper.countWorkcaseProcesses(filter);
pageParam.setTotal((int) total); pageParam.setTotal((int) total);
PageDomain<TbWorkcaseProcessDTO> pageDomain = new PageDomain<>(pageParam, list); PageDomain<WorkcaseProcessVO> pageDomain = new PageDomain<>(pageParam, list);
return ResultDomain.success("查询成功", pageDomain); return ResultDomain.success("查询成功", pageDomain);
} }

View File

@@ -32,7 +32,12 @@ security:
spring: spring:
application: application:
name: workcase-service name: workcase-service
# 文件上传配置
servlet:
multipart:
enabled: true
max-file-size: 500MB
max-request-size: 500MB
# ================== Spring Cloud Nacos ================== # ================== Spring Cloud Nacos ==================
cloud: cloud:
nacos: nacos:
@@ -75,6 +80,7 @@ dubbo:
name: urban-lifeline-workcase name: urban-lifeline-workcase
qos-enable: false qos-enable: false
protocol: protocol:
payload: 110100480
name: dubbo name: dubbo
port: -1 port: -1
registry: registry:

View File

@@ -54,7 +54,7 @@
<insert id="insertChatMessage" parameterType="org.xyzh.api.workcase.dto.TbChatRoomMessageDTO"> <insert id="insertChatMessage" parameterType="org.xyzh.api.workcase.dto.TbChatRoomMessageDTO">
INSERT INTO workcase.tb_chat_room_message ( INSERT INTO workcase.tb_chat_room_message (
optsn, message_id, room_id, sender_id, sender_type, sender_name, content, creator optsn, message_id, room_id, sender_id, sender_type, sender_name, content, creator,send_time
<if test="messageType != null">, message_type</if> <if test="messageType != null">, message_type</if>
<if test="files != null">, files</if> <if test="files != null">, files</if>
<if test="contentExtra != null">, content_extra</if> <if test="contentExtra != null">, content_extra</if>
@@ -62,8 +62,9 @@
<if test="isAiMessage != null">, is_ai_message</if> <if test="isAiMessage != null">, is_ai_message</if>
<if test="aiMessageId != null">, ai_message_id</if> <if test="aiMessageId != null">, ai_message_id</if>
<if test="status != null">, status</if> <if test="status != null">, status</if>
<if test="createTime != null">, create_time</if>
) VALUES ( ) VALUES (
#{optsn}, #{messageId}, #{roomId}, #{senderId}, #{senderType}, #{senderName}, #{content}, #{creator} #{optsn}, #{messageId}, #{roomId}, #{senderId}, #{senderType}, #{senderName}, #{content}, #{creator},#{sendTime}
<if test="messageType != null">, #{messageType}</if> <if test="messageType != null">, #{messageType}</if>
<if test="files != null">, #{files, typeHandler=org.xyzh.common.jdbc.handler.StringArrayTypeHandler}</if> <if test="files != null">, #{files, typeHandler=org.xyzh.common.jdbc.handler.StringArrayTypeHandler}</if>
<if test="contentExtra != null">, #{contentExtra, typeHandler=org.xyzh.common.jdbc.handler.FastJson2TypeHandler}::jsonb</if> <if test="contentExtra != null">, #{contentExtra, typeHandler=org.xyzh.common.jdbc.handler.FastJson2TypeHandler}::jsonb</if>
@@ -71,6 +72,7 @@
<if test="isAiMessage != null">, #{isAiMessage}</if> <if test="isAiMessage != null">, #{isAiMessage}</if>
<if test="aiMessageId != null">, #{aiMessageId}</if> <if test="aiMessageId != null">, #{aiMessageId}</if>
<if test="status != null">, #{status}</if> <if test="status != null">, #{status}</if>
<if test="createTime != null">, #{createTime}</if>
) )
</insert> </insert>

View File

@@ -13,8 +13,10 @@
<result column="guest_name" property="guestName" jdbcType="VARCHAR"/> <result column="guest_name" property="guestName" jdbcType="VARCHAR"/>
<result column="ai_session_id" property="aiSessionId" jdbcType="VARCHAR"/> <result column="ai_session_id" property="aiSessionId" jdbcType="VARCHAR"/>
<result column="message_count" property="messageCount" jdbcType="INTEGER"/> <result column="message_count" property="messageCount" jdbcType="INTEGER"/>
<result column="device_code" property="deviceCode" jdbcType="VARCHAR"/>
<result column="last_message_time" property="lastMessageTime" jdbcType="TIMESTAMP"/> <result column="last_message_time" property="lastMessageTime" jdbcType="TIMESTAMP"/>
<result column="last_message" property="lastMessage" jdbcType="VARCHAR"/> <result column="last_message" property="lastMessage" jdbcType="VARCHAR"/>
<result column="comment_level" property="commentLevel" jdbcType="INTEGER"/>
<result column="closed_by" property="closedBy" jdbcType="VARCHAR"/> <result column="closed_by" property="closedBy" jdbcType="VARCHAR"/>
<result column="closed_time" property="closedTime" jdbcType="TIMESTAMP"/> <result column="closed_time" property="closedTime" jdbcType="TIMESTAMP"/>
<result column="creator" property="creator" jdbcType="VARCHAR"/> <result column="creator" property="creator" jdbcType="VARCHAR"/>
@@ -35,9 +37,11 @@
<result column="guest_name" property="guestName" jdbcType="VARCHAR"/> <result column="guest_name" property="guestName" jdbcType="VARCHAR"/>
<result column="ai_session_id" property="aiSessionId" jdbcType="VARCHAR"/> <result column="ai_session_id" property="aiSessionId" jdbcType="VARCHAR"/>
<result column="message_count" property="messageCount" jdbcType="INTEGER"/> <result column="message_count" property="messageCount" jdbcType="INTEGER"/>
<result column="device_code" property="deviceCode" jdbcType="VARCHAR"/>
<result column="unread_count" property="unreadCount" jdbcType="INTEGER"/> <result column="unread_count" property="unreadCount" jdbcType="INTEGER"/>
<result column="last_message_time" property="lastMessageTime" jdbcType="TIMESTAMP"/> <result column="last_message_time" property="lastMessageTime" jdbcType="TIMESTAMP"/>
<result column="last_message" property="lastMessage" jdbcType="VARCHAR"/> <result column="last_message" property="lastMessage" jdbcType="VARCHAR"/>
<result column="comment_level" property="commentLevel" jdbcType="INTEGER"/>
<result column="closed_by" property="closedBy" jdbcType="VARCHAR"/> <result column="closed_by" property="closedBy" jdbcType="VARCHAR"/>
<result column="closed_time" property="closedTime" jdbcType="TIMESTAMP"/> <result column="closed_time" property="closedTime" jdbcType="TIMESTAMP"/>
<result column="creator" property="creator" jdbcType="VARCHAR"/> <result column="creator" property="creator" jdbcType="VARCHAR"/>
@@ -49,13 +53,13 @@
<sql id="Base_Column_List"> <sql id="Base_Column_List">
room_id, optsn, workcase_id, room_name, room_type, status, guest_id, guest_name, room_id, optsn, workcase_id, room_name, room_type, status, guest_id, guest_name,
ai_session_id, message_count, last_message_time, last_message, closed_by, closed_time, ai_session_id, message_count, device_code, last_message_time, last_message, comment_level, closed_by, closed_time,
creator, create_time, update_time, delete_time, deleted creator, create_time, update_time, delete_time, deleted
</sql> </sql>
<insert id="insertChatRoom" parameterType="org.xyzh.api.workcase.dto.TbChatRoomDTO"> <insert id="insertChatRoom" parameterType="org.xyzh.api.workcase.dto.TbChatRoomDTO">
INSERT INTO workcase.tb_chat_room ( INSERT INTO workcase.tb_chat_room (
optsn, room_id, workcase_id, room_name, guest_id, guest_name, creator optsn, room_id, workcase_id, room_name, guest_id, guest_name, device_code, creator
<if test="roomType != null">, room_type</if> <if test="roomType != null">, room_type</if>
<if test="status != null">, status</if> <if test="status != null">, status</if>
<if test="aiSessionId != null">, ai_session_id</if> <if test="aiSessionId != null">, ai_session_id</if>
@@ -63,7 +67,7 @@
<if test="lastMessageTime != null">, last_message_time</if> <if test="lastMessageTime != null">, last_message_time</if>
<if test="lastMessage != null">, last_message</if> <if test="lastMessage != null">, last_message</if>
) VALUES ( ) VALUES (
#{optsn}, #{roomId}, #{workcaseId}, #{roomName}, #{guestId}, #{guestName}, #{creator} #{optsn}, #{roomId}, #{workcaseId}, #{roomName}, #{guestId}, #{guestName}, #{deviceCode}, #{creator}
<if test="roomType != null">, #{roomType}</if> <if test="roomType != null">, #{roomType}</if>
<if test="status != null">, #{status}</if> <if test="status != null">, #{status}</if>
<if test="aiSessionId != null">, #{aiSessionId}</if> <if test="aiSessionId != null">, #{aiSessionId}</if>
@@ -82,8 +86,10 @@
<if test="status != null and status != ''">status = #{status},</if> <if test="status != null and status != ''">status = #{status},</if>
<if test="aiSessionId != null">ai_session_id = #{aiSessionId},</if> <if test="aiSessionId != null">ai_session_id = #{aiSessionId},</if>
<if test="messageCount != null">message_count = #{messageCount},</if> <if test="messageCount != null">message_count = #{messageCount},</if>
<if test="deviceCode != null and deviceCode != ''">device_code = #{deviceCode},</if>
<if test="lastMessageTime != null">last_message_time = #{lastMessageTime},</if> <if test="lastMessageTime != null">last_message_time = #{lastMessageTime},</if>
<if test="lastMessage != null">last_message = #{lastMessage},</if> <if test="lastMessage != null">last_message = #{lastMessage},</if>
<if test="commentLevel != null">comment_level = #{commentLevel},</if>
<if test="closedBy != null">closed_by = #{closedBy},</if> <if test="closedBy != null">closed_by = #{closedBy},</if>
<if test="closedTime != null">closed_time = #{closedTime},</if> <if test="closedTime != null">closed_time = #{closedTime},</if>
update_time = now() update_time = now()
@@ -121,8 +127,8 @@
<select id="selectChatRoomPage" resultMap="VOResultMap"> <select id="selectChatRoomPage" resultMap="VOResultMap">
SELECT r.room_id, r.optsn, r.workcase_id, r.room_name, r.room_type, r.status, SELECT r.room_id, r.optsn, r.workcase_id, r.room_name, r.room_type, r.status,
r.guest_id, r.guest_name, r.ai_session_id, r.message_count, r.guest_id, r.guest_name, r.ai_session_id, r.message_count, r.device_code,
r.last_message_time, r.last_message, r.closed_by, r.closed_time, r.last_message_time, r.last_message, r.comment_level, r.closed_by, r.closed_time,
r.creator, r.create_time, r.update_time, r.delete_time, r.deleted, r.creator, r.create_time, r.update_time, r.delete_time, r.deleted,
COALESCE(m.unread_count, 0) as unread_count COALESCE(m.unread_count, 0) as unread_count
FROM workcase.tb_chat_room r FROM workcase.tb_chat_room r

View File

@@ -34,10 +34,9 @@
<insert id="insertWorkcase" parameterType="org.xyzh.api.workcase.dto.TbWorkcaseDTO"> <insert id="insertWorkcase" parameterType="org.xyzh.api.workcase.dto.TbWorkcaseDTO">
INSERT INTO workcase.tb_workcase ( INSERT INTO workcase.tb_workcase (
optsn, workcase_id, room_id, user_id, username, phone, type, device_name_plate_img, creator optsn, workcase_id, room_id, user_id, username, phone, type, device_code,device_name_plate, creator
<if test="device != null">, device</if> <if test="device != null">, device</if>
<if test="deviceCode != null">, device_code</if> <if test="deviceNamePlateImg != null">, device_name_plate_img</if>
<if test="deviceNamePlate != null">, device_name_plate</if>
<if test="address != null">, address</if> <if test="address != null">, address</if>
<if test="description != null">, description</if> <if test="description != null">, description</if>
<if test="imgs != null">, imgs</if> <if test="imgs != null">, imgs</if>
@@ -45,10 +44,9 @@
<if test="status != null">, status</if> <if test="status != null">, status</if>
<if test="processor != null">, processor</if> <if test="processor != null">, processor</if>
) VALUES ( ) VALUES (
#{optsn}, #{workcaseId}, #{roomId}, #{userId}, #{username}, #{phone}, #{type}, #{deviceNamePlateImg}, #{creator} #{optsn}, #{workcaseId}, #{roomId}, #{userId}, #{username}, #{phone}, #{type}, #{deviceCode}, #{deviceNamePlate}, #{creator}
<if test="device != null">, #{device}</if> <if test="device != null">, #{device}</if>
<if test="deviceCode != null">, #{deviceCode}</if> <if test="deviceNamePlateImg != null">, #{deviceNamePlateImg}</if>
<if test="deviceNamePlate != null">, #{deviceNamePlate}</if>
<if test="address != null">, #{address}</if> <if test="address != null">, #{address}</if>
<if test="description != null">, #{description}</if> <if test="description != null">, #{description}</if>
<if test="imgs != null">, #{imgs, typeHandler=org.xyzh.common.jdbc.handler.StringArrayTypeHandler}</if> <if test="imgs != null">, #{imgs, typeHandler=org.xyzh.common.jdbc.handler.StringArrayTypeHandler}</if>

View File

@@ -15,6 +15,21 @@
<result column="create_time" property="createTime" jdbcType="TIMESTAMP"/> <result column="create_time" property="createTime" jdbcType="TIMESTAMP"/>
</resultMap> </resultMap>
<resultMap id="VOResultMap" type="org.xyzh.api.workcase.vo.WorkcaseProcessVO">
<id column="process_id" property="processId" jdbcType="VARCHAR"/>
<result column="optsn" property="optsn" jdbcType="VARCHAR"/>
<result column="workcase_id" property="workcaseId" jdbcType="VARCHAR"/>
<result column="action" property="action" jdbcType="VARCHAR"/>
<result column="message" property="message" jdbcType="VARCHAR"/>
<result column="files" property="files" jdbcType="ARRAY" typeHandler="org.xyzh.common.jdbc.handler.StringArrayTypeHandler"/>
<result column="processor" property="processor" jdbcType="VARCHAR"/>
<result column="processor_name" property="processorName" jdbcType="VARCHAR"/>
<result column="remark" property="remark" jdbcType="VARCHAR"/>
<result column="creator" property="creator" jdbcType="VARCHAR"/>
<result column="creator_name" property="creatorName" jdbcType="VARCHAR"/>
<result column="create_time" property="createTime" jdbcType="TIMESTAMP"/>
</resultMap>
<sql id="Base_Column_List"> <sql id="Base_Column_List">
process_id, optsn, workcase_id, action, message, files, processor, remark, creator, create_time process_id, optsn, workcase_id, action, message, files, processor, remark, creator, create_time
</sql> </sql>
@@ -58,50 +73,64 @@
WHERE process_id = #{processId} WHERE process_id = #{processId}
</select> </select>
<select id="selectWorkcaseProcessList" resultMap="BaseResultMap"> <select id="selectWorkcaseProcessList" resultMap="VOResultMap">
SELECT <include refid="Base_Column_List"/> SELECT p.process_id, p.optsn, p.workcase_id, p.action, p.message, p.files,
FROM workcase.tb_workcase_process p.processor, p.remark, p.creator, p.create_time,
COALESCE(u1.username, g1.name) as creator_name,
COALESCE(u2.username, g2.name) as processor_name
FROM workcase.tb_workcase_process p
LEFT JOIN sys.tb_sys_user_info u1 ON p.creator = u1.user_id
LEFT JOIN sys.tb_guest g1 ON p.creator = g1.user_id
LEFT JOIN sys.tb_sys_user_info u2 ON p.processor = u2.user_id
LEFT JOIN sys.tb_guest g2 ON p.processor = g2.user_id
<where> <where>
<if test="filter.processId != null and filter.processId != ''"> <if test="filter.processId != null and filter.processId != ''">
AND process_id = #{filter.processId} AND p.process_id = #{filter.processId}
</if> </if>
<if test="filter.workcaseId != null and filter.workcaseId != ''"> <if test="filter.workcaseId != null and filter.workcaseId != ''">
AND workcase_id = #{filter.workcaseId} AND p.workcase_id = #{filter.workcaseId}
</if> </if>
<if test="filter.action != null and filter.action != ''"> <if test="filter.action != null and filter.action != ''">
AND action = #{filter.action} AND p.action = #{filter.action}
</if> </if>
<if test="filter.processor != null and filter.processor != ''"> <if test="filter.processor != null and filter.processor != ''">
AND processor = #{filter.processor} AND p.processor = #{filter.processor}
</if> </if>
<if test="filter.creator != null and filter.creator != ''"> <if test="filter.creator != null and filter.creator != ''">
AND creator = #{filter.creator} AND p.creator = #{filter.creator}
</if> </if>
</where> </where>
ORDER BY create_time ASC ORDER BY p.create_time ASC
</select> </select>
<select id="selectWorkcaseProcessPage" resultMap="BaseResultMap"> <select id="selectWorkcaseProcessPage" resultMap="VOResultMap">
SELECT <include refid="Base_Column_List"/> SELECT p.process_id, p.optsn, p.workcase_id, p.action, p.message, p.files,
FROM workcase.tb_workcase_process p.processor, p.remark, p.creator, p.create_time,
COALESCE(u1.username, g1.name) as creator_name,
COALESCE(u2.username, g2.name) as processor_name
FROM workcase.tb_workcase_process p
LEFT JOIN sys.tb_sys_user_info u1 ON p.creator = u1.user_id
LEFT JOIN sys.tb_guest g1 ON p.creator = g1.user_id
LEFT JOIN sys.tb_sys_user_info u2 ON p.processor = u2.user_id
LEFT JOIN sys.tb_guest g2 ON p.processor = g2.user_id
<where> <where>
<if test="filter.processId != null and filter.processId != ''"> <if test="filter.processId != null and filter.processId != ''">
AND process_id = #{filter.processId} AND p.process_id = #{filter.processId}
</if> </if>
<if test="filter.workcaseId != null and filter.workcaseId != ''"> <if test="filter.workcaseId != null and filter.workcaseId != ''">
AND workcase_id = #{filter.workcaseId} AND p.workcase_id = #{filter.workcaseId}
</if> </if>
<if test="filter.action != null and filter.action != ''"> <if test="filter.action != null and filter.action != ''">
AND action = #{filter.action} AND p.action = #{filter.action}
</if> </if>
<if test="filter.processor != null and filter.processor != ''"> <if test="filter.processor != null and filter.processor != ''">
AND processor = #{filter.processor} AND p.processor = #{filter.processor}
</if> </if>
<if test="filter.creator != null and filter.creator != ''"> <if test="filter.creator != null and filter.creator != ''">
AND creator = #{filter.creator} AND p.creator = #{filter.creator}
</if> </if>
</where> </where>
ORDER BY create_time ASC ORDER BY p.create_time ASC
LIMIT #{pageParam.pageSize} OFFSET #{pageParam.offset} LIMIT #{pageParam.pageSize} OFFSET #{pageParam.offset}
</select> </select>

View File

@@ -134,10 +134,9 @@ async function handleLogin() {
if (response.success && response.data) { if (response.success && response.data) {
const loginData = response.data const loginData = response.data
// 8. 保存 Token // 8. 保存 Token(只用 TokenManager避免格式不一致
if (loginData.token) { if (loginData.token) {
TokenManager.setToken(loginData.token, loginForm.rememberMe) TokenManager.setToken(loginData.token, loginForm.rememberMe)
localStorage.setItem('token', loginData.token)
} }
// 9. 保存 LoginDomain 到 LocalStorage // 9. 保存 LoginDomain 到 LocalStorage

View File

@@ -1,6 +1,6 @@
import { api } from '@/api/index' import { api } from '@/api/index'
import type { ResultDomain, PageRequest } from '@/types' import type { ResultDomain, PageRequest } from '@/types'
import type { TbKnowledge, TbKnowledgeFile, KnowledgeFileVO, SegmentRequestBody, DocumentStatusRequestBody } from '@/types/ai' import type { TbKnowledge, TbKnowledgeFile, KnowledgeFileVO, SegmentRequestBody, DocumentStatusRequestBody, TbKnowledgeFileLog } from '@/types/ai'
/** /**
* @description AI知识库相关接口 * @description AI知识库相关接口
@@ -162,8 +162,8 @@ export const aiKnowledgeAPI = {
* 删除知识库文件 * 删除知识库文件
* @param fileId 文件ID * @param fileId 文件ID
*/ */
async deleteFile(fileId: string): Promise<ResultDomain<boolean>> { async deleteFile(fileRootId: string): Promise<ResultDomain<boolean>> {
const response = await api.delete<boolean>(`${this.baseUrl}/file/${fileId}`) const response = await api.delete<boolean>(`${this.baseUrl}/file/${fileRootId}`)
return response.data return response.data
}, },
@@ -263,5 +263,25 @@ export const aiKnowledgeAPI = {
requestBody requestBody
) )
return response.data return response.data
},
// ====================== 日志管理 ======================
/**
* 查询知识库操作日志列表
* @param fileLog 查询条件
*/
async getFileLogList(fileLog: TbKnowledgeFileLog): Promise<ResultDomain<TbKnowledgeFileLog>> {
const response = await api.post<TbKnowledgeFileLog>(`${this.baseUrl}/datasets/log/list`, fileLog)
return response.data
},
/**
* 分页查询知识库操作日志
* @param pageRequest 分页请求
*/
async getFileLogPage(pageRequest: PageRequest<TbKnowledgeFileLog>): Promise<ResultDomain<TbKnowledgeFileLog>> {
const response = await api.post<TbKnowledgeFileLog>(`${this.baseUrl}/datasets/log/page`, pageRequest)
return response.data
} }
} }

View File

@@ -119,7 +119,7 @@ export const aiChatAPI = {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
formData.append('agentId', agentId) formData.append('agentId', agentId)
const response = await api.uploadPut<DifyFileInfo>(`${this.baseUrl}/file/upload`, formData) const response = await api.upload<DifyFileInfo>(`${this.baseUrl}/file/upload`, formData)
return response.data return response.data
} }
} }

View File

@@ -48,7 +48,7 @@ export const fileAPI = {
if (param.uploader) { if (param.uploader) {
formData.append('uploader', param.uploader); formData.append('uploader', param.uploader);
} }
const response = await api.upload<TbSysFileDTO>(`${this.baseUrl}/batch-upload`, formData); const response = await api.upload<TbSysFileDTO>(`${this.baseUrl}/upload/batch`, formData);
return response.data; return response.data;
}, },

View File

@@ -130,7 +130,7 @@
</div> </div>
<div class="info"> <div class="info">
<div class="name">{{ file.name || file.fileName || '未知文件' }}</div> <div class="name">{{ file.name || '未知文件' }}</div>
<div class="size">{{ file.size ? formatFileSize(file.size) : '' }}</div> <div class="size">{{ file.size ? formatFileSize(file.size) : '' }}</div>
</div> </div>
@@ -148,7 +148,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { FILE_DOWNLOAD_URL } from '@/config' import { FILE_DOWNLOAD_URL, FILE_MAX_SIZE } from '@/config'
import { fileAPI } from '@/api/file/file' import { fileAPI } from '@/api/file/file'
import type { TbSysFileDTO } from '@/types/file/file' import type { TbSysFileDTO } from '@/types/file/file'
import { ElButton, ElDialog } from 'element-plus' import { ElButton, ElDialog } from 'element-plus'
@@ -179,7 +179,7 @@ const props = withDefaults(defineProps<Props>(), {
coverImg: '', coverImg: '',
fileList: () => [], fileList: () => [],
accept: '', accept: '',
maxSize: 10 * 1024 * 1024, maxSize: FILE_MAX_SIZE,
maxCount: 10, maxCount: 10,
title: '文件上传', title: '文件上传',
buttonText: '上传文件', buttonText: '上传文件',
@@ -226,7 +226,7 @@ const currentFileList = computed(() => {
// 判断已上传文件是否为图片 // 判断已上传文件是否为图片
const isUploadedImageFile = (file: InternalFile): boolean => { const isUploadedImageFile = (file: InternalFile): boolean => {
if (file.localPreviewUrl) return true // 有本地预览说明是图片 if (file.localPreviewUrl) return true // 有本地预览说明是图片
const mimeType = file.mimeType || file.extension || '' const mimeType = file.mimeType || ''
return mimeType.includes('image') || /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(file.name || '') return mimeType.includes('image') || /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(file.name || '')
} }
@@ -240,7 +240,7 @@ const getUploadedFileUrl = (file: InternalFile): string => {
// 获取已上传文件的类型图标 // 获取已上传文件的类型图标
const getUploadedFileTypeIcon = (file: InternalFile): string => { const getUploadedFileTypeIcon = (file: InternalFile): string => {
const ext = file.extension || file.name?.split('.').pop() || '' const ext = file.type || file.name?.split('.').pop() || ''
const iconMap: Record<string, string> = { const iconMap: Record<string, string> = {
pdf: '📄', pdf: '📄',
doc: '📝', doc: '📝',

View File

@@ -205,6 +205,9 @@ export const FILE_DOWNLOAD_URL = config.file.downloadUrl;
export const PUBLIC_IMG_PATH = config.publicImgPath; export const PUBLIC_IMG_PATH = config.publicImgPath;
export const PUBLIC_WEB_PATH = config.publicWebPath; export const PUBLIC_WEB_PATH = config.publicWebPath;
// 文件上传大小限制100MB
export const FILE_MAX_SIZE = 100 * 1024 * 1024;
// 导出完整配置对象 // 导出完整配置对象
export const APP_CONFIG = { export const APP_CONFIG = {
// 应用标题 // 应用标题

View File

@@ -13,27 +13,27 @@ export interface DifyFileInfo {
/** 文件扩展名 */ /** 文件扩展名 */
extension?: string extension?: string
/** 文件MIME类型 */ /** 文件MIME类型 */
mimeType?: string mime_type?: string
/** 上传人ID */ /** 上传人ID */
createdBy?: string created_by?: string
/** 上传时间(时间戳) */ /** 上传时间(时间戳) */
createdAt?: number created_at?: number
/** 预览URL */ /** 预览URL */
previewUrl?: string preview_url?: string
/** 源文件URL */ /** 源文件URL */
sourceUrl?: string source_url?: string
/** 文件类型image、document、audio、video、file */ /** 文件类型image、document、audio、video、file */
type?: string type?: string
/** 传输方式remote_url、local_file */ /** 传输方式remote_url、local_file */
transferMethod?: string transfer_method?: string
/** 文件URL或ID */ /** 文件URL或ID */
url?: string url?: string
/** 本地文件上传ID */ /** 本地文件上传ID */
uploadFileId?: string upload_file_id?: string
/** 系统文件ID */ /** 系统文件ID */
sysFileId?: string sys_file_id?: string
/** 文件路径(从系统文件表获取) */ /** 文件路径(从系统文件表获取) */
filePath?: string file_path?: string
} }
/** /**
@@ -88,6 +88,7 @@ export interface ChatPrepareData {
userId?: string userId?: string
/** 用户类型false=来客true=员工) */ /** 用户类型false=来客true=员工) */
userType?: boolean userType?: boolean
service?: string
} }
// ==================== 请求参数类型(必传校验) ==================== // ==================== 请求参数类型(必传校验) ====================
@@ -106,24 +107,6 @@ export interface CreateChatParam {
title?: string title?: string
} }
/**
* 准备流式对话参数
*/
export interface PrepareChatParam {
/** 对话ID必传 */
chatId: string
/** 用户问题(必传) */
query: string
/** 智能体ID必传 */
agentId: string
/** 用户类型(必传) */
userType: boolean
/** 用户ID */
userId?: string
/** 文件列表 */
files?: DifyFileInfo[]
}
/** /**
* 停止对话参数 * 停止对话参数
*/ */

View File

@@ -106,3 +106,27 @@ export interface SegmentRequestBody {
export interface DocumentStatusRequestBody { export interface DocumentStatusRequestBody {
[key: string]: any [key: string]: any
} }
/**
* 知识库文件操作日志
*/
export interface TbKnowledgeFileLog extends BaseDTO {
/** 日志ID */
logId?: string
/** 知识库ID */
knowledgeId?: string
/** 文件根ID */
fileRootId?: string
/** 文件ID */
fileId?: string
/** 文件名称 */
fileName?: string
/** 服务名称 */
service?: string
/** 文件版本 */
version?: number
/** 操作类型 upload/download/delete/update */
action?: string
/** 操作人用户名 */
creatorName?: string
}

View File

@@ -1,6 +1,6 @@
import { api } from 'shared/api' import { api } from 'shared/api'
import type { ResultDomain, PageRequest } from 'shared/types' import type { ResultDomain, PageRequest } from 'shared/types'
import type { TbWorkcaseDTO, TbWorkcaseProcessDTO, TbWorkcaseDeviceDTO } from '@/types/workcase' import type { TbWorkcaseDTO, TbWorkcaseProcessDTO, TbWorkcaseDeviceDTO, WorkcaseProcessVO } from '@/types/workcase'
/** /**
* @description 工单管理接口 * @description 工单管理接口
@@ -121,8 +121,8 @@ export const workcaseAPI = {
* 查询工单处理过程列表 * 查询工单处理过程列表
* @param filter 筛选条件 * @param filter 筛选条件
*/ */
async getWorkcaseProcessList(filter?: TbWorkcaseProcessDTO): Promise<ResultDomain<TbWorkcaseProcessDTO>> { async getWorkcaseProcessList(filter?: TbWorkcaseProcessDTO): Promise<ResultDomain<WorkcaseProcessVO>> {
const response = await api.post<TbWorkcaseProcessDTO>(`${this.baseUrl}/process/list`, filter || {}) const response = await api.post<WorkcaseProcessVO>(`${this.baseUrl}/process/list`, filter || {})
return response.data return response.data
}, },
@@ -130,8 +130,8 @@ export const workcaseAPI = {
* 分页查询工单处理过程 * 分页查询工单处理过程
* @param pageRequest 分页请求 * @param pageRequest 分页请求
*/ */
async getWorkcaseProcessPage(pageRequest: PageRequest<TbWorkcaseProcessDTO>): Promise<ResultDomain<TbWorkcaseProcessDTO>> { async getWorkcaseProcessPage(pageRequest: PageRequest<TbWorkcaseProcessDTO>): Promise<ResultDomain<WorkcaseProcessVO>> {
const response = await api.post<TbWorkcaseProcessDTO>(`${this.baseUrl}/process/page`, pageRequest) const response = await api.post<WorkcaseProcessVO>(`${this.baseUrl}/process/page`, pageRequest)
return response.data return response.data
}, },

View File

@@ -272,5 +272,19 @@ export const workcaseChatAPI = {
async endVideoMeeting(meetingId: string): Promise<ResultDomain<VideoMeetingVO>> { async endVideoMeeting(meetingId: string): Promise<ResultDomain<VideoMeetingVO>> {
const response = await api.post<VideoMeetingVO>(`${this.baseUrl}/meeting/${meetingId}/end`) const response = await api.post<VideoMeetingVO>(`${this.baseUrl}/meeting/${meetingId}/end`)
return response.data return response.data
},
// ====================== 聊天室评分管理 ======================
/**
* 提交聊天室服务评分
* @param roomId 聊天室ID
* @param commentLevel 评分1-5星
*/
async submitComment(roomId: string, commentLevel: number): Promise<ResultDomain<boolean>> {
const response = await api.post<boolean>(`/urban-lifeline/workcase/chat/room/${roomId}/comment`, null, {
params: { commentLevel }
})
return response.data
} }
} }

View File

@@ -1,5 +1,10 @@
// 全局EL分页组件样式 // 全局EL分页组件样式
// ==================== 品牌色变量 ====================
$brand-color: #0055AA;
$brand-color-light: #EBF5FF;
$brand-color-hover: #004488;
.content-header { .content-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -190,11 +195,32 @@
align-items: center; align-items: center;
gap: 10px; gap: 10px;
} }
// 分页样式
.table-pagination { .table-pagination {
margin-top: 12px; padding: 16px 20px;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
border-top: 1px solid #f1f5f9;
background: #fff;
}
// 全局分页样式(不需要 :deep因为这是全局样式
.el-pagination {
.el-pager {
li {
border-radius: 6px;
&.is-active {
background: $brand-color;
color: #fff;
}
}
}
.btn-prev,
.btn-next {
border-radius: 6px;
}
} }
.file-name-cell { .file-name-cell {

View File

@@ -19,7 +19,7 @@
v-for="engineer in availableEngineers" v-for="engineer in availableEngineers"
:key="engineer.userId" :key="engineer.userId"
:label="`${engineer.username} (${engineer.statusName || '未知状态'})`" :label="`${engineer.username} (${engineer.statusName || '未知状态'})`"
:value="engineer.userId" :value="engineer.userId!"
> >
<div style="display: flex; justify-content: space-between; align-items: center;"> <div style="display: flex; justify-content: space-between; align-items: center;">
<span>{{ engineer.username }}</span> <span>{{ engineer.username }}</span>
@@ -46,7 +46,7 @@
ref="fileUploadRef" ref="fileUploadRef"
mode="content" mode="content"
:max-count="5" :max-count="5"
:max-size="10 * 1024 * 1024" :max-size="FILE_MAX_SIZE"
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx"
:auto-upload="false" :auto-upload="false"
:custom-upload="handleFilesUpload" :custom-upload="handleFilesUpload"
@@ -65,12 +65,13 @@
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { ElDialog, ElButton, ElInput, ElSelect, ElOption, ElMessage } from 'element-plus' import { ElDialog, ElButton, ElInput, ElSelect, ElOption, ElMessage } from 'element-plus'
import { FileUpload } from 'shared/components' import { FileUpload } from 'shared/components'
import { fileAPI } from 'shared/api' import { fileAPI } from 'shared/api/file'
import { workcaseAPI } from '@/api/workcase' import { workcaseAPI } from '@/api/workcase'
import { workcaseChatAPI } from '@/api/workcase/workcaseChat' import { workcaseChatAPI } from '@/api/workcase/workcaseChat'
import type { TbWorkcaseProcessDTO } from '@/types/workcase/workcase' import type { TbWorkcaseProcessDTO } from '@/types/workcase/workcase'
import type { CustomerServiceVO } from '@/types/workcase/customer' import type { CustomerServiceVO } from '@/types/workcase/customer'
import type { TbSysFileDTO } from 'shared/types' import type { TbSysFileDTO } from 'shared/types'
import { FILE_MAX_SIZE } from '@/config'
interface Props { interface Props {
/** 是否显示弹窗 */ /** 是否显示弹窗 */

View File

@@ -216,6 +216,9 @@ export const FILE_UPLOAD_URL = config.file.uploadUrl;
export const PUBLIC_IMG_PATH = config.publicImgPath; export const PUBLIC_IMG_PATH = config.publicImgPath;
export const PUBLIC_WEB_PATH = config.publicWebPath; export const PUBLIC_WEB_PATH = config.publicWebPath;
// 文件上传大小限制100MB
export const FILE_MAX_SIZE = 100 * 1024 * 1024;
// 导出完整配置对象 // 导出完整配置对象
export const APP_CONFIG = { export const APP_CONFIG = {
// 应用标题 // 应用标题

View File

@@ -64,9 +64,9 @@ router.beforeEach(async (to, from, next) => {
const newToken = loginDomain.token const newToken = loginDomain.token
// 保存到localStorage覆盖旧的登录状态 // 保存到localStorage覆盖旧的登录状态
localStorage.setItem('token', newToken) // 只用 TokenManager 存储 token避免格式不一致
localStorage.setItem('loginDomain', JSON.stringify(loginDomain))
TokenManager.setToken(newToken) TokenManager.setToken(newToken)
localStorage.setItem('loginDomain', JSON.stringify(loginDomain))
console.log('[Workcase Router] Token验证成功登录状态已刷新') console.log('[Workcase Router] Token验证成功登录状态已刷新')
} else { } else {

View File

@@ -103,6 +103,8 @@ declare module 'shared/types' {
export interface BaseVO extends BaseDTO { export interface BaseVO extends BaseDTO {
id?: string id?: string
creatorName?: string
updaterName?: string
} }
// 重新导出 response // 重新导出 response
@@ -131,14 +133,14 @@ declare module 'shared/types' {
DifyFileInfo, DifyFileInfo,
ChatPrepareData, ChatPrepareData,
CreateChatParam, CreateChatParam,
PrepareChatParam,
StopChatParam, StopChatParam,
CommentMessageParam, CommentMessageParam,
ChatListParam, ChatListParam,
ChatMessageListParam, ChatMessageListParam,
SSEMessageData, SSEMessageData,
SSECallbacks, SSECallbacks,
SSETask SSETask,
TbKnowledgeFileLog
} from '../../../shared/src/types/ai' } from '../../../shared/src/types/ai'
// 重新导出 menu // 重新导出 menu

View File

@@ -13,11 +13,13 @@ export interface TbChatRoomDTO extends BaseDTO {
status?: string status?: string
guestId?: string guestId?: string
guestName?: string guestName?: string
deviceCode?: string
aiSessionId?: string aiSessionId?: string
currentAgentId?: string currentAgentId?: string
agentCount?: number agentCount?: number
messageCount?: number messageCount?: number
unreadCount?: number unreadCount?: number
commentLevel?: number
lastMessageTime?: string lastMessageTime?: string
lastMessage?: string lastMessage?: string
closedBy?: string closedBy?: string
@@ -163,12 +165,14 @@ export interface ChatRoomVO extends BaseVO {
status?: string status?: string
guestId?: string guestId?: string
guestName?: string guestName?: string
deviceCode?: string
aiSessionId?: string aiSessionId?: string
currentAgentId?: string currentAgentId?: string
currentAgentName?: string currentAgentName?: string
agentCount?: number agentCount?: number
messageCount?: number messageCount?: number
unreadCount?: number unreadCount?: number
commentLevel?: number
lastMessageTime?: string lastMessageTime?: string
lastMessage?: string lastMessage?: string
closedBy?: string closedBy?: string

View File

@@ -1,4 +1,4 @@
import type { BaseDTO } from 'shared/types' import type { BaseDTO, BaseVO } from 'shared/types'
/** /**
* 工单表对象 * 工单表对象
@@ -72,3 +72,23 @@ export interface TbWorkcaseDeviceDTO extends BaseDTO {
/** 文件根ID */ /** 文件根ID */
fileRootId?: string fileRootId?: string
} }
export interface WorkcaseProcessVO extends BaseVO {
/** 工单ID */
workcaseId?: string
/** 过程ID */
processId?: string
/** 动作 info记录assign指派redeploy转派repeal撤销finish完成 */
action?: 'info' | 'assign' | 'redeploy' | 'repeal' | 'finish'
/** 消息 */
message?: string
/** 携带文件列表 */
files?: string[]
/** 处理人(指派、转派专属) */
processor?: string
processorName?: string
/** 创建时间 */
createTime?: string
/** 更新时间 */
updateTime?: string
}

View File

@@ -8,7 +8,7 @@
:title="'上传文档到:' + currentKnowledgeName" :title="'上传文档到:' + currentKnowledgeName"
button-text="上传文档" button-text="上传文档"
accept=".pdf,.doc,.docx,.txt,.md" accept=".pdf,.doc,.docx,.txt,.md"
:max-size="50 * 1024 * 1024" :max-size="FILE_MAX_SIZE"
:max-count="10" :max-count="10"
:custom-upload="customKnowledgeUpload" :custom-upload="customKnowledgeUpload"
@upload-error="handleUploadError" @upload-error="handleUploadError"
@@ -110,7 +110,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { aiKnowledgeAPI } from 'shared/api/ai' import { aiKnowledgeAPI } from 'shared/api/ai'
import { FileUpload, FileHistory } from 'shared/components' import { FileUpload, FileHistory } from 'shared/components'
import DocumentSegment from 'shared/components/ai/knowledge/DocumentSegment.vue' import DocumentSegment from 'shared/components/ai/knowledge/DocumentSegment.vue'
import { FILE_DOWNLOAD_URL } from '@/config/index' import { FILE_DOWNLOAD_URL, FILE_MAX_SIZE } from '@/config/index'
import type { TbKnowledge } from 'shared/types' import type { TbKnowledge } from 'shared/types'
// Tab 配置 // Tab 配置
@@ -278,7 +278,7 @@ const deleteFile = async (row: DocumentItem) => {
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'warning' type: 'warning'
}) })
const result = await aiKnowledgeAPI.deleteFile(row.id) const result = await aiKnowledgeAPI.deleteFile(row.fileRootId)
if (result.success) { if (result.success) {
ElMessage.success('删除成功') ElMessage.success('删除成功')
fetchDocuments(activeKnowledgeId.value) fetchDocuments(activeKnowledgeId.value)
@@ -306,7 +306,7 @@ const customKnowledgeUpload = async (files: File[]) => {
const result = await aiKnowledgeAPI.uploadToKnowledge(files[0], targetKnowledgeId) const result = await aiKnowledgeAPI.uploadToKnowledge(files[0], targetKnowledgeId)
if (result.success) { if (result.success) {
ElMessage.success('文件上传成功') ElMessage.success('文件上传成功')
fetchKnowledges() // fetchKnowledges()
fetchDocuments(activeKnowledgeId.value) fetchDocuments(activeKnowledgeId.value)
} else { } else {
throw new Error(result.message || '上传失败') throw new Error(result.message || '上传失败')
@@ -316,7 +316,7 @@ const customKnowledgeUpload = async (files: File[]) => {
const result = await aiKnowledgeAPI.batchUploadToKnowledge(files, targetKnowledgeId) const result = await aiKnowledgeAPI.batchUploadToKnowledge(files, targetKnowledgeId)
if (result.success) { if (result.success) {
ElMessage.success('文件上传成功') ElMessage.success('文件上传成功')
fetchKnowledges() // fetchKnowledges()
fetchDocuments(activeKnowledgeId.value) fetchDocuments(activeKnowledgeId.value)
} else { } else {
throw new Error(result.message || '上传失败') throw new Error(result.message || '上传失败')

View File

@@ -11,49 +11,72 @@
<!-- 筛选区域 --> <!-- 筛选区域 -->
<el-card class="filter-card"> <el-card class="filter-card">
<div class="ticket-filters"> <div class="ticket-filters">
<el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" style="width: 280px;" /> <el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 280px;"
@change="handleSearch"
/>
<div class="filter-right"> <div class="filter-right">
<el-select v-model="operationFilter" placeholder="操作类型" clearable style="width: 140px;"> <el-select v-model="filter.action" placeholder="操作类型" clearable style="width: 140px;" @change="handleSearch">
<el-option label="上传" value="upload" /> <el-option label="上传" value="upload" />
<el-option label="下载" value="download" /> <el-option label="下载" value="download" />
<el-option label="删除" value="delete" /> <el-option label="删除" value="delete" />
<el-option label="更新" value="update" /> <el-option label="更新" value="update" />
</el-select> </el-select>
<el-select v-model="kbTypeFilter" placeholder="知识库类型" clearable style="width: 140px;"> <el-select v-model="filter.knowledgeId" placeholder="知识库" clearable style="width: 180px;" @change="handleSearch">
<el-option label="外部知识库" value="external" /> <el-option
<el-option label="内部知识库" value="internal" /> v-for="kb in knowledgeList"
:key="kb.knowledgeId"
:label="kb.title"
:value="kb.knowledgeId"
/>
</el-select> </el-select>
<el-input v-model="searchKeyword" placeholder="搜索文件名/操作人" style="width: 200px;" :prefix-icon="Search" clearable /> <el-input
v-model="filter.fileName"
placeholder="搜索文件名"
style="width: 200px;"
:prefix-icon="Search"
clearable
@clear="handleSearch"
@keyup.enter="handleSearch"
/>
</div> </div>
</div> </div>
</el-card> </el-card>
<!-- 日志列表 --> <!-- 日志列表 -->
<el-card> <el-card v-loading="loading">
<el-table :data="filteredLogs" style="width: 100%"> <el-table :data="logs" style="width: 100%">
<el-table-column prop="logId" label="日志ID" width="120"> <el-table-column prop="logId" label="日志ID" width="180">
<template #default="{ row }"> <template #default="{ row }">
<span style="color: #409eff; font-weight: 500;">{{ row.logId }}</span> <span style="color: #409eff; font-weight: 500;">{{ row.logId }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="fileName" label="文件名" min-width="200" /> <el-table-column prop="fileName" label="文件名" min-width="200" show-overflow-tooltip />
<el-table-column prop="operation" label="操作类型" width="100"> <el-table-column prop="action" label="操作类型" width="100">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="getOperationType(row.operation)" size="small"> <el-tag :type="getOperationType(row.action)" size="small">
{{ row.operationName }} {{ getOperationName(row.action) }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="kbType" label="知识库" width="100"> <el-table-column prop="version" label="版本" width="80">
<template #default="{ row }"> <template #default="{ row }">
<span>{{ row.kbType === 'external' ? '外部' : '内部' }}</span> <span>v{{ row.version || 1 }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="operator" label="操作人" width="100" /> <el-table-column prop="creatorName" label="操作人" width="120" />
<el-table-column prop="operationTime" label="操作时间" width="160" /> <el-table-column prop="createTime" label="操作时间" width="180">
<el-table-column prop="fileSize" label="文件大小" width="100" /> <template #default="{ row }">
<el-table-column label="操作" width="120" fixed="right"> {{ formatTime(row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" link size="small" @click="viewDetail(row)">详情</el-button> <el-button type="primary" link size="small" @click="viewDetail(row)">详情</el-button>
</template> </template>
@@ -61,7 +84,15 @@
</el-table> </el-table>
<div class="table-pagination"> <div class="table-pagination">
<el-pagination v-model:current-page="currentPage" :page-size="10" :total="logs.length" layout="total, prev, pager, next" /> <el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50]"
:total="pagination.total"
layout="total, sizes, prev, pager, next"
@size-change="loadLogs"
@current-change="loadLogs"
/>
</div> </div>
</el-card> </el-card>
</div> </div>
@@ -76,19 +107,22 @@
<span>{{ selectedLog.fileName }}</span> <span>{{ selectedLog.fileName }}</span>
</el-form-item> </el-form-item>
<el-form-item label="操作类型"> <el-form-item label="操作类型">
<el-tag :type="getOperationType(selectedLog.operation)">{{ selectedLog.operationName }}</el-tag> <el-tag :type="getOperationType(selectedLog.action)">{{ getOperationName(selectedLog.action) }}</el-tag>
</el-form-item> </el-form-item>
<el-form-item label="知识库"> <el-form-item label="文件版本">
<span>{{ selectedLog.kbType === 'external' ? '外部知识库' : '内部知识库' }}</span> <span>v{{ selectedLog.version || 1 }}</span>
</el-form-item> </el-form-item>
<el-form-item label="操作人"> <el-form-item label="操作人">
<span>{{ selectedLog.operator }}</span> <span>{{ selectedLog.creatorName }}</span>
</el-form-item> </el-form-item>
<el-form-item label="操作时间"> <el-form-item label="操作时间">
<span>{{ selectedLog.operationTime }}</span> <span>{{ formatTime(selectedLog.createTime) }}</span>
</el-form-item> </el-form-item>
<el-form-item label="文件大小"> <el-form-item label="知识库ID">
<span>{{ selectedLog.fileSize }}</span> <span>{{ selectedLog.knowledgeId }}</span>
</el-form-item>
<el-form-item label="文件ID">
<span>{{ selectedLog.fileId }}</span>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-dialog> </el-dialog>
@@ -96,63 +130,140 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, reactive, onMounted } from 'vue'
import AdminLayout from '@/views/admin/AdminLayout.vue' import AdminLayout from '@/views/admin/AdminLayout.vue'
import { Download, Search } from 'lucide-vue-next' import { Download, Search } from 'lucide-vue-next'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { aiKnowledgeAPI } from 'shared/api/ai'
import type { TbKnowledgeFileLog, TbKnowledge } from 'shared/types'
const loading = ref(false)
const dateRange = ref<[Date, Date] | null>(null) const dateRange = ref<[Date, Date] | null>(null)
const operationFilter = ref('')
const kbTypeFilter = ref('')
const searchKeyword = ref('')
const currentPage = ref(1)
const showDetailDialog = ref(false) const showDetailDialog = ref(false)
const selectedLog = ref<any>(null) const selectedLog = ref<TbKnowledgeFileLog | null>(null)
const logs = ref([ // 筛选条件
{ logId: 'LOG001', fileName: 'TH-500GF操作手册.pdf', operation: 'upload', operationName: '上传', kbType: 'external', operator: '张三', operationTime: '2024-12-10 14:30', fileSize: '2.5MB' }, const filter = reactive<TbKnowledgeFileLog>({
{ logId: 'LOG002', fileName: 'TH-300D故障排查指南.pdf', operation: 'upload', operationName: '上传', kbType: 'external', operator: '李四', operationTime: '2024-12-09 10:15', fileSize: '1.8MB' }, action: '',
{ logId: 'LOG003', fileName: '内部技术规范v2.0.pdf', operation: 'update', operationName: '更新', kbType: 'internal', operator: '赵六', operationTime: '2024-12-11 16:20', fileSize: '3.2MB' }, knowledgeId: '',
{ logId: 'LOG004', fileName: '售后服务流程.pdf', operation: 'download', operationName: '下载', kbType: 'internal', operator: '孙七', operationTime: '2024-12-10 11:00', fileSize: '1.1MB' }, fileName: '',
{ logId: 'LOG005', fileName: '员工培训手册.pdf', operation: 'delete', operationName: '删除', kbType: 'internal', operator: '周八', operationTime: '2024-12-09 15:30', fileSize: '2.0MB' } service: 'workcase'
])
const filteredLogs = computed(() => {
let result = logs.value
if (operationFilter.value) {
result = result.filter(l => l.operation === operationFilter.value)
}
if (kbTypeFilter.value) {
result = result.filter(l => l.kbType === kbTypeFilter.value)
}
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
result = result.filter(l =>
l.fileName.toLowerCase().includes(keyword) ||
l.operator.toLowerCase().includes(keyword)
)
}
return result.slice((currentPage.value - 1) * 10, currentPage.value * 10)
}) })
const getOperationType = (operation: string) => { // 分页
const map: Record<string, string> = { const pagination = reactive({
upload: 'success', page: 1,
download: 'info', pageSize: 10,
delete: 'danger', total: 0
update: 'warning' })
}
return map[operation] || 'info' // 日志列表
const logs = ref<TbKnowledgeFileLog[]>([])
// 知识库列表(用于筛选)
const knowledgeList = ref<TbKnowledge[]>([])
// 操作类型映射
const operationTypeMap: Record<string, string> = {
upload: 'success',
download: 'info',
delete: 'danger',
update: 'warning'
} }
const viewDetail = (row: any) => { const operationNameMap: Record<string, string> = {
upload: '上传',
download: '下载',
delete: '删除',
update: '更新'
}
const getOperationType = (action: string) => operationTypeMap[action] || 'info'
const getOperationName = (action: string) => operationNameMap[action] || action
// 格式化时间
const formatTime = (time?: string | number): string => {
if (!time) return '-'
const date = new Date(time)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
// 加载知识库列表
const loadKnowledgeList = async () => {
try {
const result = await aiKnowledgeAPI.listKnowledges({})
if (result.success && result.dataList) {
knowledgeList.value = result.dataList
}
} catch (error) {
console.error('加载知识库列表失败:', error)
}
}
// 加载日志列表
const loadLogs = async () => {
loading.value = true
try {
// 构建查询条件
const queryFilter: TbKnowledgeFileLog = { ...filter }
console.log(queryFilter)
// 处理日期范围
if (dateRange.value && dateRange.value[0] && dateRange.value[1]) {
queryFilter.createTimeStart = dateRange.value[0].toISOString()
queryFilter.createTimeEnd = dateRange.value[1].toISOString()
}
const result = await aiKnowledgeAPI.getFileLogPage({
filter: queryFilter,
pageParam: {
page: pagination.page,
pageSize: pagination.pageSize,
total: 0
}
})
if (result.success) {
logs.value = result.dataList || []
pagination.total = result.pageParam?.total || 0
} else {
ElMessage.error(result.message || '加载日志失败')
}
} catch (error) {
console.error('加载日志失败:', error)
ElMessage.error('加载日志失败')
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.page = 1
loadLogs()
}
// 查看详情
const viewDetail = (row: TbKnowledgeFileLog) => {
selectedLog.value = row selectedLog.value = row
showDetailDialog.value = true showDetailDialog.value = true
} }
// 导出日志
const exportLogs = () => { const exportLogs = () => {
ElMessage.success('日志导出功') ElMessage.success('日志导出功能开发中')
} }
onMounted(() => {
loadKnowledgeList()
loadLogs()
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -1,5 +1,5 @@
<template> <template>
<AdminLayout title="工单日志" info="查看工单操作记录"> <AdminLayout title="工单日志" info="查看工单流程处理记录">
<template #action> <template #action>
<el-button type="primary" @click="exportLogs"> <el-button type="primary" @click="exportLogs">
<el-icon><Download /></el-icon> <el-icon><Download /></el-icon>
@@ -11,46 +11,52 @@
<!-- 筛选区域 --> <!-- 筛选区域 -->
<el-card class="filter-card"> <el-card class="filter-card">
<div class="ticket-filters"> <div class="ticket-filters">
<el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" style="width: 280px;" /> <el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" style="width: 280px;" @change="handleSearch" />
<div class="filter-right"> <div class="filter-right">
<el-select v-model="operationFilter" placeholder="操作类型" clearable style="width: 140px;"> <el-select v-model="filter.action" placeholder="操作类型" clearable style="width: 140px;" @change="handleSearch">
<el-option label="创建" value="create" /> <el-option v-for="item in actionOptions" :key="item.value" :label="item.label" :value="item.value" />
<el-option label="更新" value="update" />
<el-option label="指派" value="assign" />
<el-option label="完成" value="complete" />
<el-option label="关闭" value="close" />
</el-select> </el-select>
<el-select v-model="operatorFilter" placeholder="操作人" clearable style="width: 120px;"> <el-input v-model="filter.workcaseId" placeholder="搜索工单ID" style="width: 200px;" :prefix-icon="Search" clearable @keyup.enter="handleSearch" @clear="handleSearch" />
<el-option label="王五" value="wangwu" /> <el-button type="primary" @click="handleSearch">搜索</el-button>
<el-option label="赵六" value="zhaoliu" />
<el-option label="孙七" value="sunqi" />
</el-select>
<el-input v-model="searchKeyword" placeholder="搜索工单号/内容" style="width: 200px;" :prefix-icon="Search" clearable />
</div> </div>
</div> </div>
</el-card> </el-card>
<!-- 日志列表 --> <!-- 日志列表 -->
<el-card> <el-card>
<el-table :data="filteredLogs" style="width: 100%"> <el-table :data="processLogs" style="width: 100%" v-loading="loading">
<el-table-column prop="logId" label="日志ID" width="120"> <el-table-column prop="processId" label="流程ID" width="180">
<template #default="{ row }"> <template #default="{ row }">
<span style="color: #409eff; font-weight: 500;">{{ row.logId }}</span> <span style="color: #409eff; font-weight: 500;">{{ row.processId }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="ticketNo" label="工单" width="120" /> <el-table-column prop="workcaseId" label="工单ID" width="180" />
<el-table-column prop="operation" label="操作类型" width="100"> <el-table-column prop="action" label="操作类型" width="100">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="getOperationType(row.operation)" size="small"> <el-tag :type="getActionTagType(row.action)" size="small">
{{ row.operationName }} {{ getActionLabel(row.action) }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="operator" label="操作" width="100" /> <el-table-column prop="message" label="操作内容" min-width="200" show-overflow-tooltip />
<el-table-column prop="content" label="操作内容" min-width="200" /> <el-table-column prop="processorName" label="处理人" width="120">
<el-table-column prop="operationTime" label="操作时间" width="160" /> <template #default="{ row }">
<el-table-column prop="ipAddress" label="IP地址" width="130" /> {{ row.processorName || '-' }}
</template>
</el-table-column>
<el-table-column prop="files" label="附件" width="80">
<template #default="{ row }">
<el-tag v-if="row.files?.length" size="small" type="info">{{ row.files.length }}</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="creatorName" label="操作人" width="120">
<template #default="{ row }">
{{ row.creatorName || row.creator || '-' }}
</template>
</el-table-column>
<el-table-column prop="createTime" label="操作时间" width="170" />
<el-table-column label="操作" width="120" fixed="right"> <el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" link size="small" @click="viewDetail(row)">详情</el-button> <el-button type="primary" link size="small" @click="viewDetail(row)">详情</el-button>
@@ -59,99 +65,154 @@
</el-table> </el-table>
<div class="table-pagination"> <div class="table-pagination">
<el-pagination v-model:current-page="currentPage" :page-size="10" :total="logs.length" layout="total, prev, pager, next" /> <el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.size"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="handleSearch"
@current-change="handleSearch"
/>
</div> </div>
</el-card> </el-card>
</div> </div>
<!-- 日志详情弹窗 --> <!-- 日志详情弹窗 -->
<el-dialog v-model="showDetailDialog" title="日志详情" width="600px"> <el-dialog v-model="showDetailDialog" title="流程详情" width="600px">
<el-form v-if="selectedLog" label-width="100px"> <el-descriptions v-if="selectedLog" :column="1" border>
<el-form-item label="日志ID"> <el-descriptions-item label="流程ID">{{ selectedLog.processId }}</el-descriptions-item>
<span>{{ selectedLog.logId }}</span> <el-descriptions-item label="工单ID">{{ selectedLog.workcaseId }}</el-descriptions-item>
</el-form-item> <el-descriptions-item label="操作类型">
<el-form-item label="工单号"> <el-tag :type="getActionTagType(selectedLog.action)">{{ getActionLabel(selectedLog.action) }}</el-tag>
<span>{{ selectedLog.ticketNo }}</span> </el-descriptions-item>
</el-form-item> <el-descriptions-item label="操作内容">{{ selectedLog.message || '-' }}</el-descriptions-item>
<el-form-item label="操作类型"> <el-descriptions-item label="处理人">{{ selectedLog.processorName || '-' }}</el-descriptions-item>
<el-tag :type="getOperationType(selectedLog.operation)">{{ selectedLog.operationName }}</el-tag> <el-descriptions-item label="操作人">{{ selectedLog.creatorName || selectedLog.creator || '-' }}</el-descriptions-item>
</el-form-item> <el-descriptions-item label="操作时间">{{ selectedLog.createTime }}</el-descriptions-item>
<el-form-item label="操作人"> <el-descriptions-item v-if="selectedLog.files?.length" label="附件">
<span>{{ selectedLog.operator }}</span> <div class="file-list">
</el-form-item> <el-tag v-for="(file, index) in selectedLog.files" :key="index" size="small" style="margin-right: 8px; margin-bottom: 4px;">
<el-form-item label="操作内容"> {{ file }}
<span>{{ selectedLog.content }}</span> </el-tag>
</el-form-item> </div>
<el-form-item label="操作时间"> </el-descriptions-item>
<span>{{ selectedLog.operationTime }}</span> </el-descriptions>
</el-form-item>
<el-form-item label="IP地址">
<span>{{ selectedLog.ipAddress }}</span>
</el-form-item>
</el-form>
</el-dialog> </el-dialog>
</AdminLayout> </AdminLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, reactive, onMounted } from 'vue'
import AdminLayout from '@/views/admin/AdminLayout.vue' import AdminLayout from '@/views/admin/AdminLayout.vue'
import { Download, Search } from 'lucide-vue-next' import { Download, Search } from 'lucide-vue-next'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { workcaseAPI } from '@/api/workcase'
import type { TbWorkcaseProcessDTO, WorkcaseProcessVO } from '@/types/workcase'
import type { PageRequest, PageParam } from 'shared/types'
const dateRange = ref<[Date, Date] | null>(null) // 操作类型选项
const operationFilter = ref('') const actionOptions = [
const operatorFilter = ref('') { value: 'info', label: '记录' },
const searchKeyword = ref('') { value: 'assign', label: '指派' },
const currentPage = ref(1) { value: 'redeploy', label: '转派' },
{ value: 'repeal', label: '撤销' },
{ value: 'finish', label: '完成' }
]
// 状态
const loading = ref(false)
const dateRange = ref<[string, string] | null>(null)
const showDetailDialog = ref(false) const showDetailDialog = ref(false)
const selectedLog = ref<any>(null) const selectedLog = ref<WorkcaseProcessVO | null>(null)
const processLogs = ref<WorkcaseProcessVO[]>([])
const logs = ref([ // 筛选条件
{ logId: 'LOG001', ticketNo: 'TK001', operation: 'create', operationName: '创建', operator: '王五', content: '创建工单,客户反映设备显示屏不亮', operationTime: '2024-12-13 10:30', ipAddress: '192.168.1.100' }, const filter = reactive<TbWorkcaseProcessDTO>({
{ logId: 'LOG002', ticketNo: 'TK001', operation: 'assign', operationName: '指派', operator: '赵六', content: '将工单指派给技术人员处理', operationTime: '2024-12-13 10:35', ipAddress: '192.168.1.101' }, workcaseId: '',
{ logId: 'LOG003', ticketNo: 'TK002', operation: 'create', operationName: '创建', operator: '孙七', content: '创建工单,客户反映机械故障', operationTime: '2024-12-13 09:15', ipAddress: '192.168.1.102' }, action: undefined
{ logId: 'LOG004', ticketNo: 'TK002', operation: 'update', operationName: '更新', operator: '王五', content: '更新工单状态为处理中', operationTime: '2024-12-13 09:45', ipAddress: '192.168.1.100' },
{ logId: 'LOG005', ticketNo: 'TK003', operation: 'complete', operationName: '完成', operator: '赵六', content: '工单处理完成,客户已确认', operationTime: '2024-12-12 14:20', ipAddress: '192.168.1.101' }
])
const filteredLogs = computed(() => {
let result = logs.value
if (operationFilter.value) {
result = result.filter(l => l.operation === operationFilter.value)
}
if (operatorFilter.value) {
result = result.filter(l => l.operator === operatorFilter.value)
}
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
result = result.filter(l =>
l.ticketNo.toLowerCase().includes(keyword) ||
l.content.toLowerCase().includes(keyword)
)
}
return result.slice((currentPage.value - 1) * 10, currentPage.value * 10)
}) })
const getOperationType = (operation: string) => { // 分页
const pagination = reactive({
current: 1,
size: 10,
total: 0
})
// 获取操作类型标签
const getActionLabel = (action?: string) => {
const map: Record<string, string> = { const map: Record<string, string> = {
create: 'success', info: '记录',
update: 'info', assign: '指派',
assign: 'warning', redeploy: '转派',
complete: 'success', repeal: '撤销',
close: 'danger' finish: '完成'
} }
return map[operation] || 'info' return map[action || ''] || action || '-'
} }
const viewDetail = (row: any) => { // 获取操作类型标签样式
const getActionTagType = (action?: string) => {
const map: Record<string, string> = {
info: 'info',
assign: 'warning',
redeploy: '',
repeal: 'danger',
finish: 'success'
}
return map[action || ''] || 'info'
}
// 查询数据
const handleSearch = async () => {
loading.value = true
try {
const filterData: TbWorkcaseProcessDTO = { ...filter }
// 日期范围
if (dateRange.value) {
filterData.startTime = dateRange.value[0]
filterData.endTime = dateRange.value[1]
}
const pageParam: PageParam = {
pageNumber: pagination.current,
pageSize: pagination.size
}
const pageRequest: PageRequest<TbWorkcaseProcessDTO> = {
filter: filterData,
pageParam
}
const res = await workcaseAPI.getWorkcaseProcessPage(pageRequest)
if (res.success) {
processLogs.value = res.dataList || res.pageDomain?.dataList || []
pagination.total = res.pageDomain?.pageParam?.totalElements || 0
}
} catch (error) {
console.error('查询工单流程失败:', error)
ElMessage.error('查询失败')
} finally {
loading.value = false
}
}
// 查看详情
const viewDetail = (row: WorkcaseProcessVO) => {
selectedLog.value = row selectedLog.value = row
showDetailDialog.value = true showDetailDialog.value = true
} }
// 导出日志
const exportLogs = () => { const exportLogs = () => {
ElMessage.success('日志导出功') ElMessage.success('日志导出功能开发中')
} }
onMounted(() => {
handleSearch()
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -162,4 +223,9 @@ const exportLogs = () => {
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
} }
.file-list {
display: flex;
flex-wrap: wrap;
}
</style> </style>

View File

@@ -247,32 +247,6 @@ $brand-color-hover: #004488;
} }
} }
// 分页样式
.table-pagination {
padding: 16px 20px;
display: flex;
justify-content: flex-end;
border-top: 1px solid #f1f5f9;
background: #fff;
}
:deep(.el-pagination) {
.el-pager {
li {
border-radius: 6px;
&.is-active {
background: $brand-color;
color: #fff;
}
}
}
.btn-prev,
.btn-next {
border-radius: 6px;
}
}
// 弹窗样式 // 弹窗样式
:deep(.el-dialog) { :deep(.el-dialog) {

View File

@@ -83,7 +83,7 @@
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" link size="small" @click="viewDetail(row)">详情</el-button> <el-button type="primary" link size="small" @click="viewDetail(row)">详情</el-button>
<el-button v-if="row.status === 'pending'" type="warning" link size="small" @click="assignTicket(row)">指派</el-button> <el-button v-if="row.status === 'pending'" type="warning" link size="small" @click="assignTicket(row)">指派</el-button>
<el-button v-if="row.status === 'processing'" type="success" link size="small" @click="completeTicket(row)">完成</el-button> <el-button v-if="row.status === 'processing' || row.status === 'process'" type="success" link size="small" @click="completeTicket(row)">完成</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -153,9 +153,10 @@
<el-dialog v-model="showDetailDialog" title="工单详情" width="900px" destroy-on-close> <el-dialog v-model="showDetailDialog" title="工单详情" width="900px" destroy-on-close>
<WorkcaseDetail <WorkcaseDetail
v-if="showDetailDialog" v-if="showDetailDialog"
:workcase="currentWorkcase" :workcase-id="currentWorkcase.workcaseId"
mode="view" mode="view"
@cancel="showDetailDialog = false" @cancel="showDetailDialog = false"
@complete="handleCompleteFromDetail"
/> />
</el-dialog> </el-dialog>
@@ -303,15 +304,25 @@ const completeWorkcaseAPI = async (workcase: TbWorkcaseDTO) => {
} }
} }
// ========================= Mock 数据 ========================= /**
const mockTickets = [ * 从详情页完成工单
{ workcaseId: 'WC001', optsn: 'TK001', username: '张三', phone: '13800138000', type: 'electrical', device: 'TH-500GF', emergency: 'emergency' as const, status: 'pending' as const, processor: '', createTime: '2024-12-13 10:30' }, */
{ workcaseId: 'WC002', optsn: 'TK002', username: '李四', phone: '13800138001', type: 'mechanical', device: 'TH-300D', emergency: 'normal' as const, status: 'processing' as const, processor: '王五', createTime: '2024-12-13 09:15' }, const handleCompleteFromDetail = async (workcaseId: string) => {
{ workcaseId: 'WC003', optsn: 'TK003', username: '王五', phone: '13800138002', type: 'control', device: 'S-200X', emergency: 'normal' as const, status: 'done' as const, processor: '赵六', createTime: '2024-12-12 14:20' }, const process: TbWorkcaseProcessDTO = {
{ workcaseId: 'WC004', optsn: 'TK004', username: '赵六', phone: '13800138003', type: 'parts', device: 'TH-800GF', emergency: 'normal' as const, status: 'pending' as const, processor: '', createTime: '2024-12-13 11:00' }, workcaseId: workcaseId,
{ workcaseId: 'WC005', optsn: 'TK005', username: '孙七', phone: '13800138004', type: 'install', device: 'G-100S', emergency: 'emergency' as const, status: 'processing' as const, processor: '李四', createTime: '2024-12-13 08:45' } action: 'finish',
] message: '工单完成'
}
const res = await workcaseAPI.createWorkcaseProcess(process)
if (res.success) {
ElMessage.success('完成成功')
showDetailDialog.value = false
loadWorkcases()
} else {
ElMessage.error(res.message || '操作失败')
}
}
// ========================= 字段映射 ========================= // ========================= 字段映射 =========================
@@ -331,6 +342,7 @@ const emergencyMap: Record<string, string> = {
const statusMap: Record<string, string> = { const statusMap: Record<string, string> = {
pending: '待处理', pending: '待处理',
process: '处理中',
processing: '处理中', processing: '处理中',
done: '已完成' done: '已完成'
} }
@@ -363,6 +375,7 @@ const filteredTickets = computed(() => {
const getStatusType = (status: string) => { const getStatusType = (status: string) => {
const map: Record<string, string> = { const map: Record<string, string> = {
pending: 'warning', pending: 'warning',
process: 'info',
processing: 'info', processing: 'info',
done: 'success' done: 'success'
} }

View File

@@ -386,15 +386,18 @@ $brand-color-hover: #004488;
&.user { &.user {
flex-direction: row-reverse; flex-direction: row-reverse;
.message-content {
align-items: flex-end;
}
.message-bubble { .message-bubble {
background: $brand-color; background: $brand-color;
color: #fff; color: #fff;
border-radius: 16px 16px 4px 16px; border-radius: 16px 16px 4px 16px;
}
.message-time { .message-time {
text-align: right; text-align: right;
color: rgba(255, 255, 255, 0.7);
}
} }
} }
} }
@@ -419,8 +422,18 @@ $brand-color-hover: #004488;
} }
} }
.message-bubble { .message-content {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 80%; max-width: 80%;
&.user {
align-items: flex-end;
}
}
.message-bubble {
padding: 12px 16px; padding: 12px 16px;
border-radius: 16px 16px 16px 4px; border-radius: 16px 16px 16px 4px;
background: #fff; background: #fff;
@@ -458,11 +471,17 @@ $brand-color-hover: #004488;
&:nth-child(3) { animation-delay: 0s; } &:nth-child(3) { animation-delay: 0s; }
} }
} }
}
.message-time {
font-size: 12px;
color: #94a3b8;
}
// 用户消息气泡中的样式
.message-row.user .message-bubble {
.message-time { .message-time {
font-size: 12px; color: rgba(255, 255, 255, 0.7);
color: #94a3b8;
margin-top: 8px;
} }
} }
} }
@@ -538,11 +557,14 @@ $brand-color-hover: #004488;
} }
.input-row { .input-row {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 12px 16px; padding: 12px 16px;
} }
.chat-textarea { .chat-textarea {
width: 100%; flex: 1;
border: none; border: none;
outline: none; outline: none;
resize: none; resize: none;
@@ -551,25 +573,13 @@ $brand-color-hover: #004488;
background: transparent; background: transparent;
line-height: 1.5; line-height: 1.5;
max-height: 120px; max-height: 120px;
min-height: 24px;
&::placeholder { &::placeholder {
color: #94a3b8; color: #94a3b8;
} }
} }
.toolbar-row {
padding: 12px 16px;
display: flex;
justify-content: flex-end;
border-top: 1px solid #f8fafc;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.tool-btn { .tool-btn {
padding: 8px; padding: 8px;
color: #94a3b8; color: #94a3b8;
@@ -578,11 +588,21 @@ $brand-color-hover: #004488;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
flex-shrink: 0;
&:hover { &:hover {
color: #64748b; color: #64748b;
background: #f1f5f9; background: #f1f5f9;
} }
&.uploading {
cursor: not-allowed;
opacity: 0.6;
}
.spin {
animation: spin 1s linear infinite;
}
} }
.send-btn { .send-btn {
@@ -593,6 +613,7 @@ $brand-color-hover: #004488;
border-radius: 12px; border-radius: 12px;
cursor: not-allowed; cursor: not-allowed;
transition: all 0.2s; transition: all 0.2s;
flex-shrink: 0;
&.active { &.active {
background: $brand-color; background: $brand-color;
@@ -717,3 +738,137 @@ $brand-color-hover: #004488;
text-decoration: underline; text-decoration: underline;
} }
} }
// ==================== 文件上传相关样式 ====================
.uploaded-files {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
}
.uploaded-file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
max-width: 200px;
.file-preview {
width: 32px;
height: 32px;
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
&.image {
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&.document {
background: $brand-color-light;
color: $brand-color;
}
}
.file-name {
flex: 1;
font-size: 12px;
color: #374151;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.remove-file-btn {
padding: 4px;
color: #94a3b8;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
&:hover {
color: #ef4444;
background: #fef2f2;
}
}
}
// 消息气泡外的文件样式
.message-files {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.message-file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
max-width: 220px;
text-decoration: none;
color: #374151;
transition: all 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
&:hover {
border-color: $brand-color;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.file-preview {
width: 36px;
height: 36px;
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
&.image {
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&.document {
background: $brand-color-light;
color: $brand-color;
}
}
.file-name {
flex: 1;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

View File

@@ -122,17 +122,38 @@
<Headphones v-else :size="16" /> <Headphones v-else :size="16" />
</div> </div>
<!-- 消息内容 --> <!-- 消息内容区域 -->
<div class="message-bubble" :class="msg.role"> <div class="message-content" :class="msg.role">
<div <!-- 文字气泡 -->
v-if="msg.text" <div v-if="msg.text || (isStreaming && msg.role === 'assistant' && messages.indexOf(msg) === messages.length - 1)" class="message-bubble" :class="msg.role">
class="message-text" <div
v-html="msg.role === 'assistant' ? renderMarkdown(msg.text) : msg.text" v-if="msg.text"
> class="message-text"
v-html="msg.role === 'assistant' ? renderMarkdown(msg.text) : msg.text"
>
</div>
<span v-if="isStreaming && msg.role === 'assistant' && messages.indexOf(msg) === messages.length - 1" class="typing-cursor">|</span>
<div v-if="!msg.text && isStreaming && msg.role === 'assistant'" class="loading-dots">
<span></span><span></span><span></span>
</div>
</div> </div>
<span v-if="isStreaming && msg.role === 'assistant' && messages.indexOf(msg) === messages.length - 1" class="typing-cursor">|</span> <!-- 消息携带的文件在气泡外面 -->
<div v-if="!msg.text && isStreaming && msg.role === 'assistant'" class="loading-dots"> <div v-if="msg.files && msg.files.length > 0" class="message-files">
<span></span><span></span><span></span> <a
v-for="fileId in msg.files"
:key="fileId"
:href="getFileDownloadUrl(fileId)"
target="_blank"
class="message-file-item"
>
<div v-if="isImageFileById(fileId)" class="file-preview image">
<img :src="getFileDownloadUrl(fileId)" :alt="getFileName(fileId)" />
</div>
<div v-else class="file-preview document">
<FileIcon :size="18" />
</div>
<span class="file-name">{{ getFileName(fileId) }}</span>
</a>
</div> </div>
<div class="message-time">{{ msg.time }}</div> <div class="message-time">{{ msg.time }}</div>
</div> </div>
@@ -160,8 +181,46 @@
<div class="input-wrapper"> <div class="input-wrapper">
<!-- 输入卡片 --> <!-- 输入卡片 -->
<div class="input-card"> <div class="input-card">
<!-- 输入框 --> <!-- 已上传文件预览 -->
<div v-if="uploadedFiles.length > 0" class="uploaded-files">
<div
v-for="(file, index) in uploadedFiles"
:key="file.id || index"
class="uploaded-file-item"
>
<div v-if="isImageFile(file)" class="file-preview image">
<img :src="getFilePreviewUrl(file)" :alt="file.name" />
</div>
<div v-else class="file-preview document">
<FileIcon :size="20" />
</div>
<span class="file-name">{{ file.name }}</span>
<button class="remove-file-btn" @click="removeUploadedFile(index)">
<X :size="14" />
</button>
</div>
</div>
<!-- 输入框行包含输入框和按钮 -->
<div class="input-row"> <div class="input-row">
<!-- 隐藏的文件输入 -->
<input
ref="fileInputRef"
type="file"
multiple
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt"
style="display: none"
@change="handleFileSelect"
/>
<button
class="tool-btn"
:class="{ uploading: isUploading }"
:disabled="isUploading"
@click="triggerFileUpload"
title="添加附件"
>
<Loader2 v-if="isUploading" :size="18" class="spin" />
<Paperclip v-else :size="18" />
</button>
<textarea <textarea
ref="textareaRef" ref="textareaRef"
v-model="inputText" v-model="inputText"
@@ -171,22 +230,14 @@
:rows="1" :rows="1"
class="chat-textarea" class="chat-textarea"
/> />
</div> <button
<!-- 工具栏 --> class="send-btn"
<div class="toolbar-row"> :class="{ active: inputText.trim() || uploadedFiles.length > 0 }"
<div class="toolbar-actions"> :disabled="(!inputText.trim() && uploadedFiles.length === 0) || isStreaming"
<button class="tool-btn" title="添加附件"> @click="sendMessage"
<Paperclip :size="18" /> >
</button> <Send :size="18" />
<button </button>
class="send-btn"
:class="{ active: inputText.trim() }"
:disabled="!inputText.trim()"
@click="sendMessage"
>
<Send :size="18" />
</button>
</div>
</div> </div>
</div> </div>
<p class="disclaimer">AI 生成内容仅供参考 · 泰豪集团内部绝密信息请勿上传</p> <p class="disclaimer">AI 生成内容仅供参考 · 泰豪集团内部绝密信息请勿上传</p>
@@ -212,27 +263,24 @@ import {
Paperclip, Paperclip,
Send, Send,
User, User,
Headphones Headphones,
X,
Image,
File as FileIcon,
Loader2
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { aiChatAPI, agentAPI } from 'shared/api/ai' import { aiChatAPI, agentAPI } from 'shared/api/ai'
import { fileAPI } from 'shared/api/file'
import type { import type {
TbChat, TbChat,
TbChatMessage, TbChatMessage,
TbAgent, TbAgent,
PrepareChatParam, ChatPrepareData,
SSEMessageData, SSEMessageData,
DifyFileInfo DifyFileInfo,
TbSysFileDTO
} from 'shared/types' } from 'shared/types'
import { AGENT_ID } from '@/config' import { AGENT_ID, FILE_DOWNLOAD_URL, FILE_MAX_SIZE } from '@/config'
// 显示用消息接口
interface DisplayMessage {
id: string
role: 'user' | 'assistant'
text: string
time: string
messageId?: string
}
// 用户信息TODO: 从实际用户store获取 // 用户信息TODO: 从实际用户store获取
const userId = computed(()=>{ const userId = computed(()=>{
@@ -255,7 +303,7 @@ const chatHistory = ref<TbChat[]>([])
const currentChatTitle = ref<string>('') const currentChatTitle = ref<string>('')
// 聊天消息列表 // 聊天消息列表
const messages = ref<DisplayMessage[]>([]) const messages = ref<TbChatMessage[]>([])
// 流式对话状态 // 流式对话状态
const isStreaming = ref(false) const isStreaming = ref(false)
@@ -265,6 +313,14 @@ const eventSource = ref<EventSource | null>(null)
// 输入框文本 // 输入框文本
const inputText = ref('') const inputText = ref('')
// 上传的文件列表
const uploadedFiles = ref<DifyFileInfo[]>([])
const isUploading = ref(false)
const fileInputRef = ref<HTMLInputElement | null>(null)
// 文件信息缓存 (fileId -> TbSysFileDTO)
const fileInfoCache = ref<Map<string, TbSysFileDTO>>(new Map())
// 消息容器引用 // 消息容器引用
const messagesRef = ref<HTMLElement | null>(null) const messagesRef = ref<HTMLElement | null>(null)
const textareaRef = ref<HTMLTextAreaElement | null>(null) const textareaRef = ref<HTMLTextAreaElement | null>(null)
@@ -288,6 +344,7 @@ const startNewChat = async () => {
currentChatTitle.value = '' currentChatTitle.value = ''
messages.value = [] messages.value = []
inputText.value = '' inputText.value = ''
uploadedFiles.value = []
// 创建新会话 // 创建新会话
if (agentId && userId.value) { if (agentId && userId.value) {
@@ -320,12 +377,14 @@ const loadChat = async (chatId: string) => {
if (result.success && result.dataList) { if (result.success && result.dataList) {
const messageList = Array.isArray(result.dataList) ? result.dataList : [result.dataList] const messageList = Array.isArray(result.dataList) ? result.dataList : [result.dataList]
messages.value = messageList.map((msg: TbChatMessage) => ({ messages.value = messageList.map((msg: TbChatMessage) => ({
...msg,
id: msg.messageId || String(Date.now()), id: msg.messageId || String(Date.now()),
role: msg.role === 'user' ? 'user' : 'assistant',
text: msg.content || '', text: msg.content || '',
time: formatTime(msg.createTime), time: formatTime(msg.createTime)
messageId: msg.messageId } as TbChatMessage))
} as DisplayMessage))
// 加载消息中的文件信息
await loadMessagesFilesInfo(messageList)
} }
} catch (error) { } catch (error) {
console.error('加载对话消息失败:', error) console.error('加载对话消息失败:', error)
@@ -360,20 +419,36 @@ const scrollToBottom = () => {
// 发送消息 // 发送消息
const sendMessage = async () => { const sendMessage = async () => {
if (!inputText.value.trim() || isStreaming.value) return // 允许只有文件或只有文本
if ((!inputText.value.trim() && uploadedFiles.value.length === 0) || isStreaming.value) return
if (!agentId) { if (!agentId) {
console.error('未选择智能体') console.error('未选择智能体')
return return
} }
const query = inputText.value.trim() const query = inputText.value.trim() || '[文件]'
const userMessage: DisplayMessage = { const currentFiles = [...uploadedFiles.value] // 保存当前文件列表副本
const userMessage: TbChatMessage = {
id: String(Date.now()), id: String(Date.now()),
role: 'user', role: 'user',
text: query, text: inputText.value.trim(),
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
files: currentFiles.length > 0 ? currentFiles.map(item => item.sys_file_id): undefined
} }
// 将上传文件的信息缓存起来,用于立即渲染
currentFiles.forEach(f => {
if (f.sys_file_id) {
fileInfoCache.value.set(f.sys_file_id, {
fileId: f.sys_file_id,
name: f.name,
url: f.preview_url || f.source_url,
extension: f.extension,
mimeType: f.mime_type
} as TbSysFileDTO)
}
})
messages.value.push(userMessage) messages.value.push(userMessage)
inputText.value = '' inputText.value = ''
@@ -404,14 +479,19 @@ const sendMessage = async () => {
} }
// 准备流式对话参数 // 准备流式对话参数
const prepareParam: PrepareChatParam = { const prepareParam: ChatPrepareData = {
chatId: currentChatId.value!, chatId: currentChatId.value!,
query: query, query: query,
agentId: agentId, agentId: agentId,
userType: userType.value, userType: userType.value,
userId: userId.value userId: userId.value,
files: uploadedFiles.value.length > 0 ? uploadedFiles.value : undefined,
service: "workcase"
} }
// 清空已上传的文件
uploadedFiles.value = []
try { try {
// 准备流式对话 // 准备流式对话
const prepareResult = await aiChatAPI.prepareStreamChat(prepareParam) const prepareResult = await aiChatAPI.prepareStreamChat(prepareParam)
@@ -422,7 +502,7 @@ const sendMessage = async () => {
const sessionId = prepareResult.data const sessionId = prepareResult.data
// 创建AI回复消息占位 // 创建AI回复消息占位
const assistantMessage: DisplayMessage = { const assistantMessage: TbChatMessage = {
id: String(Date.now() + 1), id: String(Date.now() + 1),
role: 'assistant', role: 'assistant',
text: '', text: '',
@@ -631,6 +711,126 @@ const handleKeyDown = (e: KeyboardEvent) => {
} }
} }
// 触发文件选择
const triggerFileUpload = () => {
fileInputRef.value?.click()
}
// 处理文件选择
const handleFileSelect = async (e: Event) => {
const target = e.target as HTMLInputElement
const files = target.files
if (!files || files.length === 0) return
for (const file of Array.from(files)) {
await uploadFile(file)
}
// 清空input允许重复选择同一文件
target.value = ''
}
// 上传单个文件
const uploadFile = async (file: File) => {
if (!agentId) {
console.error('未选择智能体')
return
}
// 文件大小限制
if (file.size > FILE_MAX_SIZE) {
console.error(`文件大小超过${FILE_MAX_SIZE / 1024 / 1024}MB限制`)
return
}
isUploading.value = true
try {
const result = await aiChatAPI.uploadFileForChat(file, agentId)
if (result.success && result.data) {
uploadedFiles.value.push(result.data)
} else {
console.error('文件上传失败:', result.message)
}
} catch (error) {
console.error('文件上传失败:', error)
} finally {
isUploading.value = false
}
}
// 移除已上传的文件
const removeUploadedFile = (index: number) => {
uploadedFiles.value.splice(index, 1)
}
// 判断是否为图片文件
const isImageFile = (file: DifyFileInfo): boolean => {
return file.type === 'image' || file.mime_type?.startsWith('image/') || false
}
// 获取文件预览URL
const getFilePreviewUrl = (file: DifyFileInfo): string => {
return file.preview_url || file.source_url || file.url || ''
}
// 加载消息中的文件信息
const loadMessagesFilesInfo = async (messageList: TbChatMessage[]) => {
// 收集所有文件ID
const fileIds: string[] = []
messageList.forEach(msg => {
if (msg.files) {
const filesArray = Array.isArray(msg.files) ? msg.files : [msg.files]
filesArray.forEach((id: string) => {
if (id && !fileInfoCache.value.has(id)) {
fileIds.push(id)
}
})
}
})
if (fileIds.length === 0) return
// 逐个查询文件信息
for (const fileId of fileIds) {
try {
const res = await fileAPI.getFileById(fileId)
if (res.success && res.data) {
fileInfoCache.value.set(fileId, res.data)
}
} catch (error) {
console.error(`加载文件信息失败: ${fileId}`, error)
}
}
}
// 获取缓存的文件信息
const getFileInfo = (fileId: string): TbSysFileDTO | undefined => {
return fileInfoCache.value.get(fileId)
}
// 获取文件名(从缓存)
const getFileName = (fileId: string): string => {
const fileInfo = fileInfoCache.value.get(fileId)
return fileInfo?.name || fileId.substring(0, 8) + '...'
}
// 获取文件下载URL
const getFileDownloadUrl = (fileId: string): string => {
if (!fileId) return ''
const fileInfo = fileInfoCache.value.get(fileId)
if (fileInfo?.url) return fileInfo.url
return `${FILE_DOWNLOAD_URL}${fileId}`
}
// 判断文件ID对应的文件是否为图片
const isImageFileById = (fileId: string): boolean => {
const fileInfo = fileInfoCache.value.get(fileId)
if (!fileInfo) return false
const ext = (fileInfo.extension || fileInfo.name?.split('.').pop() || '').toLowerCase()
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(ext)
}
// 组件挂载 // 组件挂载
onMounted(async () => { onMounted(async () => {
// TODO: 根据路由参数或配置获取智能体ID // TODO: 根据路由参数或配置获取智能体ID

View File

@@ -94,11 +94,14 @@
:room-name="currentRoom?.roomName" :room-name="currentRoom?.roomName"
:file-download-url="FILE_DOWNLOAD_URL" :file-download-url="FILE_DOWNLOAD_URL"
:has-more="hasMore" :has-more="hasMore"
:guest-id="currentRoom?.guestId"
:comment-level="currentRoom?.commentLevel"
:loading-more="loadingMore" :loading-more="loadingMore"
@send-message="handleSendMessage" @send-message="handleSendMessage"
@start-meeting="startMeeting" @start-meeting="startMeeting()"
@download-file="downloadFile" @download-file="downloadFile"
@load-more="loadMoreMessages" @load-more="loadMoreMessages"
@submit-comment="handleSubmitComment"
> >
<template #header> <template #header>
<div class="chat-room-header"> <div class="chat-room-header">
@@ -171,6 +174,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue' import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { ElButton, ElInput, ElDialog, ElMessage } from 'element-plus' import { ElButton, ElInput, ElDialog, ElMessage } from 'element-plus'
import { Search, FileText, MessageSquare, MessageCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next' import { Search, FileText, MessageSquare, MessageCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next'
import ChatRoom from './chatRoom/ChatRoom.vue' import ChatRoom from './chatRoom/ChatRoom.vue'
@@ -185,12 +189,12 @@ import { Client } from '@stomp/stompjs'
// WebSocket配置 (通过Nginx代理访问网关再到workcase服务) // WebSocket配置 (通过Nginx代理访问网关再到workcase服务)
// SockJS URL (http://) // SockJS URL (http://)
const getWsUrl = () => { const getWsUrl = () => {
const token = JSON.parse(localStorage.getItem('token')).value || '' const token = JSON.parse(localStorage.getItem('token')!).value
const protocol = window.location.protocol const protocol = window.location.protocol
const host = window.location.host const host = window.location.host
return `${protocol}//${host}/api/urban-lifeline/workcase/ws/chat-sockjs?token=${encodeURIComponent(token)}` return `${protocol}//${host}/api/urban-lifeline/workcase/ws/chat-sockjs?token=${encodeURIComponent(token)}`
} }
const router = useRouter()
// STOMP客户端 // STOMP客户端
let stompClient: any = null let stompClient: any = null
let roomSubscription: any = null let roomSubscription: any = null
@@ -488,6 +492,23 @@ const handleSendMessage = async (content: string, files: File[]) => {
} }
} }
// 处理评分提交从ChatRoom组件触发
const handleSubmitComment = async (rating: number) => {
if (!currentRoomId.value) return
try {
const result = await workcaseChatAPI.submitComment(currentRoomId.value, rating)
if (result.success) {
ElMessage.success('感谢您的评分!')
} else {
ElMessage.error(result.message || '评分提交失败')
}
} catch (error) {
console.error('评分提交失败:', error)
ElMessage.error('评分提交失败,请稍后重试')
}
}
// 下载文件 // 下载文件
const downloadFile = (fileId: string) => { const downloadFile = (fileId: string) => {
window.open(`${FILE_DOWNLOAD_URL}/${fileId}`, '_blank') window.open(`${FILE_DOWNLOAD_URL}/${fileId}`, '_blank')
@@ -525,43 +546,17 @@ const startMeeting = async () => {
const activeResult = await workcaseChatAPI.getActiveMeeting(currentRoomId.value) const activeResult = await workcaseChatAPI.getActiveMeeting(currentRoomId.value)
if (activeResult.success && activeResult.data) { if (activeResult.success && activeResult.data) {
// 已有活跃会议,直接加入 // 已有活跃会议,直接加入
currentMeetingId.value = activeResult.data.meetingId! const currentMeetingId = activeResult.data.meetingId!
const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId.value!) const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId)
if (joinResult.success && joinResult.data?.iframeUrl) { if (joinResult.success && joinResult.data?.iframeUrl) {
// 使用router跳转到JitsiMeetingView页面附加roomId参数用于返回 // 使用router跳转到JitsiMeetingView页面附加roomId参数用于返回
const meetingUrl = joinResult.data.iframeUrl + `&roomId=${currentRoomId.value}` const meetingUrl = joinResult.data.iframeUrl + `&roomId=${currentRoomId}`
router.push(meetingUrl) router.push(meetingUrl)
} else { } else {
ElMessage.error(joinResult.message || '加入会议失败') ElMessage.error(joinResult.message || '加入会议失败')
} }
return return
} }
// 没有活跃会议,创建新会议
const createResult = await workcaseChatAPI.createVideoMeeting({
roomId: currentRoomId.value,
meetingName: currentRoom.value?.roomName || '视频会议'
})
if (createResult.success && createResult.data) {
currentMeetingId.value = createResult.data.meetingId!
// 开始会议
await workcaseChatAPI.startVideoMeeting(currentMeetingId.value!)
// 加入会议获取会议页面URL
const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId.value!)
if (joinResult.success && joinResult.data?.iframeUrl) {
// 使用router跳转到JitsiMeetingView页面附加roomId参数用于返回
const meetingUrl = joinResult.data.iframeUrl + `&roomId=${currentRoomId.value}`
router.push(meetingUrl)
ElMessage.success('会议已创建')
} else {
ElMessage.error(joinResult.message || '获取会议链接失败')
}
} else {
ElMessage.error(createResult.message || '创建会议失败')
}
} catch (error) { } catch (error) {
console.error('发起会议失败:', error) console.error('发起会议失败:', error)
ElMessage.error('发起会议失败') ElMessage.error('发起会议失败')
@@ -686,8 +681,16 @@ const subscribeToRoom = (roomId: string) => {
// 避免重复添加自己发送的普通消息 // 避免重复添加自己发送的普通消息
// 但会议消息meet类型始终添加因为它是系统生成的通知 // 但会议消息meet类型始终添加因为它是系统生成的通知
if (chatMessage.messageType === 'meet' || chatMessage.senderId !== loginDomain.user.userId) { if (chatMessage.messageType === 'meet' || chatMessage.senderId !== loginDomain.user.userId) {
messages.value.push(chatMessage) // 会议消息延时处理,等待数据库事务提交
scrollToBottom() if (chatMessage.messageType === 'meet') {
console.log('[ChatRoom] 收到会议消息延时1秒后刷新')
setTimeout(() => {
loadMessages(roomId)
}, 1000)
} else {
messages.value.push(chatMessage)
scrollToBottom()
}
} }
}) })
} }

View File

@@ -137,6 +137,35 @@ $brand-color-hover: #004488;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
} }
} }
// 系统消息样式(居中显示)
&.is-system {
justify-content: center;
margin-bottom: 32px;
.system-message-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
max-width: 80%;
.system-message-text {
padding: 8px 16px;
background: rgba(148, 163, 184, 0.15);
border-radius: 16px;
font-size: 13px;
color: #64748b;
text-align: center;
}
.message-time {
font-size: 12px;
color: #94a3b8;
text-align: center;
}
}
}
} }
.message-avatar { .message-avatar {

View File

@@ -21,53 +21,76 @@
v-for="message in messages" v-for="message in messages"
:key="message.messageId" :key="message.messageId"
class="message-row" class="message-row"
:class="message.senderId === currentUserId ? 'is-me' : 'other'" :class="getMessageClass(message)"
> >
<div> <!-- 系统消息居中显示 -->
<!-- 头像 --> <template v-if="message.senderType === 'system'">
<div class="message-avatar"> <div class="system-message-container">
<img v-if="message.senderAvatar" :src="FILE_DOWNLOAD_URL + message.senderAvatar" /> <!-- 评分消息卡片 -->
<span v-else class="avatar-text">{{ message.senderName?.charAt(0) || '?' }}</span> <template v-if="message.messageType === 'comment'">
</div> <CommentMessageCard
<div class="sender-name">{{ message.senderName || '未知用户' }}</div> :room-id="roomId"
</div> :can-comment="canComment"
:initial-rating="commentLevel"
<!-- 消息内容 --> @submit="handleCommentSubmit"
<div class="message-content-wrapper"> />
<!-- 会议消息卡片 --> </template>
<template v-if="message.messageType === 'meet'"> <!-- 其他系统消息 -->
<MeetingCard :meetingId="getMeetingId(message.contentExtra)" @join="handleJoinMeeting" /> <template v-else>
<div class="system-message-text">{{ message.content }}</div>
</template>
<div class="message-time">{{ formatTime(message.sendTime) }}</div> <div class="message-time">{{ formatTime(message.sendTime) }}</div>
</template> </div>
</template>
<!-- 普通消息气泡 --> <!-- 普通用户/客服消息 -->
<template v-else> <template v-else>
<div class="message-bubble"> <div>
<div <!-- 头像 -->
class="message-text" <div class="message-avatar">
v-html="renderMarkdown(message.content || '')" <img v-if="message.senderAvatar" :src="FILE_DOWNLOAD_URL + message.senderAvatar" />
></div> <span v-else class="avatar-text">{{ message.senderName?.charAt(0) || '?' }}</span>
</div>
<div class="sender-name">{{ message.senderName || '未知用户' }}</div>
</div>
<!-- 文件列表 --> <!-- 消息内容 -->
<div v-if="message.files && message.files.length > 0" class="message-files"> <div class="message-content-wrapper">
<!-- 会议消息卡片 -->
<template v-if="message.messageType === 'meet'">
<MeetingCard :meetingId="getMeetingId(message.contentExtra)" @join="handleJoinMeeting" />
<div class="message-time">{{ formatTime(message.sendTime) }}</div>
</template>
<!-- 普通消息气泡 -->
<template v-else>
<div class="message-bubble">
<div <div
v-for="file in message.files" class="message-text"
:key="file" v-html="renderMarkdown(message.content || '')"
class="file-item" ></div>
@click="$emit('download-file', file)"
> <!-- 文件列表 -->
<div class="file-icon"> <div v-if="message.files && message.files.length > 0" class="message-files">
<FileText :size="16" /> <div
</div> v-for="file in message.files"
<div class="file-info"> :key="file"
<div class="file-name">附件</div> class="file-item"
@click="$emit('download-file', file)"
>
<div class="file-icon">
<FileText :size="16" />
</div>
<div class="file-info">
<div class="file-name">附件</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> <div class="message-time">{{ formatTime(message.sendTime) }}</div>
<div class="message-time">{{ formatTime(message.sendTime) }}</div> </template>
</template> </div>
</div> </template>
</div> </div>
</div> </div>
</div> </div>
@@ -141,15 +164,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, nextTick } from 'vue' import { ref, nextTick, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { FileText, Video, Paperclip, Send } from 'lucide-vue-next' import { FileText, Video, Paperclip, Send } from 'lucide-vue-next'
import MeetingCreate from '../MeetingCreate/MeetingCreate.vue' import MeetingCreate from '../MeetingCreate/MeetingCreate.vue'
import MeetingCard from '../MeetingCard/MeetingCard.vue' import MeetingCard from '../MeetingCard/MeetingCard.vue'
import CommentMessageCard from './CommentMessageCard/CommentMessageCard.vue'
import type { ChatRoomMessageVO, VideoMeetingVO } from '@/types/workcase' import type { ChatRoomMessageVO, VideoMeetingVO } from '@/types/workcase'
import { workcaseChatAPI } from '@/api/workcase' import { workcaseChatAPI } from '@/api/workcase'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { FILE_DOWNLOAD_URL } from '@/config'
const router = useRouter() const router = useRouter()
interface Props { interface Props {
@@ -158,25 +182,31 @@ interface Props {
roomId: string roomId: string
roomName?: string roomName?: string
workcaseId?: string workcaseId?: string
fileDownloadUrl?: string commentLevel?: number
hasMore?: boolean hasMore?: boolean
loadingMore?: boolean loadingMore?: boolean
guestId?: string // 聊天室访客ID用于判断评价权限
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
roomName: '聊天室', roomName: '聊天室',
fileDownloadUrl: '',
hasMore: true, hasMore: true,
loadingMore: false loadingMore: false,
guestId: ''
}) })
const FILE_DOWNLOAD_URL = props.fileDownloadUrl
// 计算当前用户是否可以评价(只有访客可以评价)
const canComment = computed(() => {
return props.currentUserId === props.guestId
})
const emit = defineEmits<{ const emit = defineEmits<{
'send-message': [content: string, files: File[]] 'send-message': [content: string, files: File[]]
'download-file': [fileId: string] 'download-file': [fileId: string]
'load-more': [] 'load-more': []
'start-meeting': [] 'start-meeting': []
'submit-comment': [rating: number]
}>() }>()
// 会议相关状态 // 会议相关状态
@@ -251,7 +281,31 @@ const handleFileSelect = (e: Event) => {
const scrollToBottom = () => { const scrollToBottom = () => {
nextTick(() => { nextTick(() => {
if (messagesRef.value) { if (messagesRef.value) {
messagesRef.value.scrollTop = messagesRef.value.scrollHeight // 使用 smooth 滚动到底部
messagesRef.value.scrollTo({
top: messagesRef.value.scrollHeight,
behavior: 'instant'
})
// 延迟再次确保滚动到底部(处理内容动态渲染的情况)
setTimeout(() => {
if (messagesRef.value) {
messagesRef.value.scrollTo({
top: messagesRef.value.scrollHeight,
behavior: 'instant'
})
}
}, 100)
// 最后一次确保(处理图片等异步内容加载)
setTimeout(() => {
if (messagesRef.value) {
messagesRef.value.scrollTo({
top: messagesRef.value.scrollHeight,
behavior: 'instant'
})
}
}, 300)
} }
}) })
} }
@@ -333,6 +387,25 @@ function getMeetingId(contentExtra: Record<string, any> | undefined): string {
return contentExtra.meetingId as string return contentExtra.meetingId as string
} }
// 获取消息的CSS类
const getMessageClass = (message: ChatRoomMessageVO) => {
if (message.senderType === 'system') {
return 'is-system'
}
return message.senderId === props.currentUserId ? 'is-me' : 'other'
}
// 处理评分提交
const handleCommentSubmit = async (rating: number) => {
try {
emit('submit-comment', rating)
ElMessage.success('感谢您的评分!')
} catch (error) {
console.error('提交评分失败:', error)
ElMessage.error('提交评分失败,请稍后重试')
}
}
// Markdown渲染函数 // Markdown渲染函数
const renderMarkdown = (text: string): string => { const renderMarkdown = (text: string): string => {
if (!text) return '' if (!text) return ''

View File

@@ -0,0 +1,103 @@
.comment-message-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
padding: 20px;
color: #fff;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
max-width: 360px;
margin: 0 auto;
.comment-header {
margin-bottom: 16px;
text-align: center;
.comment-title {
font-size: 16px;
font-weight: 500;
}
}
.comment-body {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
.star-rating {
display: flex;
gap: 8px;
.star-item {
cursor: pointer;
transition: transform 0.2s ease;
&:hover:not(.is-disabled) {
transform: scale(1.15);
}
&.is-disabled {
cursor: not-allowed;
opacity: 0.8;
}
}
}
.rating-desc {
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
.submitted-status {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: rgba(255, 255, 255, 0.2);
border-radius: 20px;
font-size: 14px;
font-weight: 500;
}
.no-permission {
padding: 8px 16px;
background: rgba(255, 255, 255, 0.15);
border-radius: 20px;
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
}
.submit-btn {
padding: 10px 28px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 24px;
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-1px);
}
&.is-active {
background: rgba(255, 255, 255, 0.95);
color: #667eea;
border-color: transparent;
&:hover:not(:disabled) {
background: #fff;
}
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
}
}

View File

@@ -0,0 +1,134 @@
<template>
<div class="comment-message-card">
<div class="comment-header">
<span class="comment-title">{{ title }}</span>
</div>
<div class="comment-body">
<!-- 星级评分 -->
<div class="star-rating">
<div
v-for="star in 5"
:key="star"
class="star-item"
:class="{
'is-active': star <= currentRating,
'is-disabled': !canComment || isSubmitted
}"
@click="handleStarClick(star)"
@mouseenter="handleStarHover(star)"
@mouseleave="handleStarLeave"
>
<Star
:size="28"
:fill="star <= (hoverRating || currentRating) ? '#FFD700' : 'none'"
:stroke="star <= (hoverRating || currentRating) ? '#FFD700' : '#d0d0d0'"
/>
</div>
</div>
<!-- 评分描述 -->
<div v-if="currentRating > 0" class="rating-desc">
{{ getRatingDesc(currentRating) }}
</div>
<!-- 不可评价提示 -->
<div v-if="!canComment && !isSubmitted" class="no-permission">
仅访客可评价
</div>
<!-- 已评分状态 -->
<div v-else-if="isSubmitted" class="submitted-status">
<Check :size="16" />
已评分
</div>
<!-- 提交按钮 -->
<button
v-else-if="canComment"
class="submit-btn"
:class="{ 'is-active': currentRating > 0 }"
:disabled="currentRating === 0 || submitting"
@click="handleSubmit"
>
{{ submitting ? '提交中...' : '提交评分' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Star, Check } from 'lucide-vue-next'
interface Props {
roomId: string
initialRating?: number
canComment?: boolean // 是否可以评价当前用户是否为guestId
title?: string
}
const props = withDefaults(defineProps<Props>(), {
initialRating: 0,
canComment: false,
title: '请为本次服务评分'
})
const emit = defineEmits<{
'submit': [rating: number]
}>()
const currentRating = ref(props.initialRating)
const hoverRating = ref(0)
const submitting = ref(false)
const isSubmitted = ref(props.initialRating > 0)
// 监听 initialRating 变化
watch(() => props.initialRating, (newVal) => {
currentRating.value = newVal
isSubmitted.value = newVal > 0
})
// 星级描述映射
const ratingDescriptions: Record<number, string> = {
1: '非常不满意',
2: '不满意',
3: '一般',
4: '满意',
5: '非常满意'
}
const getRatingDesc = (rating: number): string => {
return ratingDescriptions[rating] || ''
}
const handleStarClick = (star: number) => {
if (!props.canComment || isSubmitted.value) return
currentRating.value = star
}
const handleStarHover = (star: number) => {
if (!props.canComment || isSubmitted.value) return
hoverRating.value = star
}
const handleStarLeave = () => {
hoverRating.value = 0
}
const handleSubmit = async () => {
if (currentRating.value === 0 || !props.canComment || isSubmitted.value) return
submitting.value = true
try {
emit('submit', currentRating.value)
isSubmitted.value = true
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
@import url('./CommentMessageCard.scss');
</style>

View File

@@ -149,8 +149,13 @@
<div class="title-bar"></div> <div class="title-bar"></div>
处理记录 处理记录
</div> </div>
<!-- 工单处理人记录工单过程--> <!-- 工单处理人记录工单过程 - 仅在处理中状态显示-->
<ElButton v-if="isProcessor" type="primary" size="small" @click="showAddProcessDialog = true"> <ElButton
v-if="isProcessor && (formData.status === 'processing')"
type="primary"
size="small"
@click="showAddProcessDialog = true"
>
添加处理记录 添加处理记录
</ElButton> </ElButton>
</div> </div>
@@ -211,14 +216,14 @@
指派工程师 指派工程师
</ElButton> </ElButton>
<ElButton <ElButton
v-if="mode === 'view' && !isCreator && formData.status === 'processing'" v-if="mode === 'view' && !isCreator && (formData.status === 'processing')"
type="warning" type="warning"
@click="handleRedeploy" @click="handleRedeploy"
> >
转派工程师 转派工程师
</ElButton> </ElButton>
<ElButton <ElButton
v-if="mode === 'view' && !isCreator && formData.status === 'processing'" v-if="mode === 'view' && !isCreator && (formData.status === 'processing')"
type="success" type="success"
@click="handleComplete" @click="handleComplete"
> >
@@ -251,7 +256,7 @@
ref="processFileUploadRef" ref="processFileUploadRef"
mode="content" mode="content"
:max-count="5" :max-count="5"
:max-size="10 * 1024 * 1024" :max-size="FILE_MAX_SIZE"
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx"
v-model:file-list="processUploadedFiles" v-model:file-list="processUploadedFiles"
@upload-success="handleProcessUploadSuccess" @upload-success="handleProcessUploadSuccess"
@@ -281,27 +286,24 @@ import { ref, watch, onMounted, computed } from 'vue'
import { ChatMessage } from '@/views/public/ChatRoom/' import { ChatMessage } from '@/views/public/ChatRoom/'
import { ElButton, ElInput, ElSelect, ElOption, ElDialog, ElMessage, ElLoading } from 'element-plus' import { ElButton, ElInput, ElSelect, ElOption, ElDialog, ElMessage, ElLoading } from 'element-plus'
import { MessageSquare, ImageIcon as ImageIcon, Plus } from 'lucide-vue-next' import { MessageSquare, ImageIcon as ImageIcon, Plus } from 'lucide-vue-next'
import type { TbWorkcaseDTO, TbWorkcaseProcessDTO } from '@/types/workcase/workcase' import type { TbWorkcaseDTO, TbWorkcaseProcessDTO, WorkcaseProcessVO } from '@/types/workcase/workcase'
import type { TbSysFileDTO } from 'shared/types' import type { TbSysFileDTO } from 'shared/types'
import { workcaseAPI } from '@/api/workcase' import { workcaseAPI } from '@/api/workcase'
import { fileAPI } from 'shared/api/file' import { fileAPI } from 'shared/api/file'
import { FileUpload } from 'shared/components' import { FileUpload } from 'shared/components'
import { WorkcaseAssign } from '@/components' import { WorkcaseAssign } from '@/components'
import { FILE_DOWNLOAD_URL } from '@/config' import { FILE_DOWNLOAD_URL, FILE_MAX_SIZE } from '@/config'
interface Props { interface Props {
mode?: 'view' | 'edit' | 'create' mode?: 'view' | 'edit' | 'create'
workcaseId?: string // 查看/编辑模式传入 workcaseId组件内部加载数据 workcaseId?: string // 查看/编辑模式传入 workcaseId组件内部加载数据
roomId?: string // 创建模式传入 roomId roomId?: string // 创建模式传入 roomId
workcase?: TbWorkcaseDTO // 兼容旧用法,直接传入数据
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
mode: 'view', mode: 'view',
workcaseId: '', workcaseId: '',
roomId: '', roomId: ''})
workcase: () => ({} as TbWorkcaseDTO)
})
const emit = defineEmits<{ const emit = defineEmits<{
cancel: [] cancel: []
@@ -313,7 +315,6 @@ const emit = defineEmits<{
const loading = ref(false) const loading = ref(false)
const formData = ref<TbWorkcaseDTO>({ const formData = ref<TbWorkcaseDTO>({
...props.workcase
}) })
// 故障类型选项(与微信端保持一致) // 故障类型选项(与微信端保持一致)
@@ -321,7 +322,7 @@ const faultTypes = ['电气系统故障', '机械故障', '控制系统故障',
const showChatMessage = ref(false) const showChatMessage = ref(false)
const currentRoomId = ref<string>('') const currentRoomId = ref<string>('')
const processList = ref<TbWorkcaseProcessDTO[]>([]) const processList = ref<WorkcaseProcessVO[]>([])
// 文件信息缓存 (fileId -> TbSysFileDTO) // 文件信息缓存 (fileId -> TbSysFileDTO)
const fileInfoCache = ref<Map<string, TbSysFileDTO>>(new Map()) const fileInfoCache = ref<Map<string, TbSysFileDTO>>(new Map())
@@ -390,7 +391,7 @@ const loadProcessList = async (workcaseId: string) => {
} }
// 加载处理记录中的文件信息 // 加载处理记录中的文件信息
const loadFilesInfo = async (processes: TbWorkcaseProcessDTO[]) => { const loadFilesInfo = async (processes: WorkcaseProcessVO[]) => {
// 收集所有文件ID // 收集所有文件ID
const fileIds: string[] = [] const fileIds: string[] = []
processes.forEach(p => { processes.forEach(p => {
@@ -526,16 +527,7 @@ onMounted(() => {
if (props.mode === 'view' || props.mode === 'edit') { if (props.mode === 'view' || props.mode === 'edit') {
// 查看/编辑模式:通过 workcaseId 加载数据 // 查看/编辑模式:通过 workcaseId 加载数据
if (props.workcaseId) { loadWorkcaseDetail(props.workcaseId)
loadWorkcaseDetail(props.workcaseId)
} else if (props.workcase?.workcaseId) {
// 兼容旧用法
formData.value = { ...props.workcase }
currentRoomId.value = props.workcase.roomId || ''
if (props.workcase.workcaseId) {
loadProcessList(props.workcase.workcaseId)
}
}
} else if (props.mode === 'create') { } else if (props.mode === 'create') {
// 创建模式:初始化空表单,设置 roomId // 创建模式:初始化空表单,设置 roomId
formData.value = { formData.value = {
@@ -556,16 +548,10 @@ watch(() => props.workcaseId, (newVal) => {
} }
}) })
// 兼容旧用法:监听 workcase prop 变化
watch(() => props.workcase, (newVal) => {
if (newVal && !props.workcaseId) {
formData.value = { ...newVal }
}
}, { deep: true })
const statusLabel = (status: string) => { const statusLabel = (status: string) => {
const map: Record<string, string> = { const map: Record<string, string> = {
pending: '待处理', pending: '待处理',
process: '处理中',
processing: '处理中', processing: '处理中',
done: '已完成' done: '已完成'
} }
@@ -575,6 +561,7 @@ const statusLabel = (status: string) => {
const statusClass = (status: string) => { const statusClass = (status: string) => {
const map: Record<string, string> = { const map: Record<string, string> = {
pending: 'status-pending', pending: 'status-pending',
process: 'status-processing',
processing: 'status-processing', processing: 'status-processing',
done: 'status-done' done: 'status-done'
} }
@@ -729,8 +716,7 @@ const submitProcessRecord = async () => {
try { try {
// 从已上传文件列表获取文件ID // 从已上传文件列表获取文件ID
const fileIds = processUploadedFiles.value const fileIds = processUploadedFiles.value
.map(f => f.fileId) .map((f: TbSysFileDTO) => f.fileId)
.filter((id): id is string => !!id)
// 提交处理记录 // 提交处理记录
const params: TbWorkcaseProcessDTO = { const params: TbWorkcaseProcessDTO = {

View File

@@ -1,17 +1,18 @@
import { request } from '../base' import { request, uploadFile } from '../base'
import type { ResultDomain } from '../../types' import type { ResultDomain } from '../../types'
import type { import type {
TbChat, TbChat,
TbChatMessage, TbChatMessage,
CreateChatParam, CreateChatParam,
PrepareChatParam, ChatPrepareData,
StopChatParam, StopChatParam,
CommentMessageParam, CommentMessageParam,
ChatListParam, ChatListParam,
ChatMessageListParam, ChatMessageListParam,
SSECallbacks, SSECallbacks,
SSETask, SSETask,
SSEMessageData SSEMessageData,
DifyFileInfo
} from '../../types/ai/aiChat' } from '../../types/ai/aiChat'
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
@@ -71,7 +72,7 @@ export const aiChatAPI = {
* 准备流式对话会话 * 准备流式对话会话
* @param param agentId、chatId、query、userId 必传 * @param param agentId、chatId、query、userId 必传
*/ */
prepareChatMessageSession(param: PrepareChatParam): Promise<ResultDomain<string>> { prepareChatMessageSession(param: ChatPrepareData): Promise<ResultDomain<string>> {
return request<string>({ url: `${this.baseUrl}/stream/prepare`, method: 'POST', data: param }) return request<string>({ url: `${this.baseUrl}/stream/prepare`, method: 'POST', data: param })
}, },
@@ -190,5 +191,21 @@ export const aiChatAPI = {
*/ */
commentChatMessage(param: CommentMessageParam): Promise<ResultDomain<boolean>> { commentChatMessage(param: CommentMessageParam): Promise<ResultDomain<boolean>> {
return request<boolean>({ url: `${this.baseUrl}/comment`, method: 'POST', data: param }) return request<boolean>({ url: `${this.baseUrl}/comment`, method: 'POST', data: param })
},
// ====================== 文件上传 ======================
/**
* 上传文件用于对话(图文多模态)
* @param filePath 文件临时路径
* @param agentId 智能体ID
*/
uploadFileForChat(filePath: string, agentId: string): Promise<ResultDomain<DifyFileInfo>> {
return uploadFile<DifyFileInfo>({
url: `${this.baseUrl}/file/upload`,
filePath: filePath,
name: 'file',
formData: { agentId: agentId }
})
} }
} }

View File

@@ -1,6 +1,6 @@
import { request } from '../base' import { request } from '../base'
import type { ResultDomain, PageRequest } from '../../types' import type { ResultDomain, PageRequest } from '../../types'
import type { TbWorkcaseDTO, TbWorkcaseProcessDTO, TbWorkcaseDeviceDTO } from '../../types/workcase' import type { TbWorkcaseDTO, TbWorkcaseProcessDTO, TbWorkcaseDeviceDTO, WorkcaseProcessVO } from '../../types/workcase'
/** /**
* @description 工单管理接口 * @description 工单管理接口
@@ -110,16 +110,16 @@ export const workcaseAPI = {
* 查询工单处理过程列表 * 查询工单处理过程列表
* @param filter 筛选条件 * @param filter 筛选条件
*/ */
getWorkcaseProcessList(filter?: TbWorkcaseProcessDTO): Promise<ResultDomain<TbWorkcaseProcessDTO>> { getWorkcaseProcessList(filter?: TbWorkcaseProcessDTO): Promise<ResultDomain<WorkcaseProcessVO>> {
return request<TbWorkcaseProcessDTO>({ url: `${this.baseUrl}/process/list`, method: 'POST', data: filter || {} }) return request<WorkcaseProcessVO>({ url: `${this.baseUrl}/process/list`, method: 'POST', data: filter || {} })
}, },
/** /**
* 分页查询工单处理过程 * 分页查询工单处理过程
* @param pageRequest 分页请求 * @param pageRequest 分页请求
*/ */
getWorkcaseProcessPage(pageRequest: PageRequest<TbWorkcaseProcessDTO>): Promise<ResultDomain<TbWorkcaseProcessDTO>> { getWorkcaseProcessPage(pageRequest: PageRequest<TbWorkcaseProcessDTO>): Promise<ResultDomain<WorkcaseProcessVO>> {
return request<TbWorkcaseProcessDTO>({ url: `${this.baseUrl}/process/page`, method: 'POST', data: pageRequest }) return request<WorkcaseProcessVO>({ url: `${this.baseUrl}/process/page`, method: 'POST', data: pageRequest })
}, },
// ========================= 工单设备管理 ========================= // ========================= 工单设备管理 =========================

View File

@@ -16,7 +16,7 @@ import type {
TbChat, TbChat,
TbChatMessage, TbChatMessage,
CreateChatParam, CreateChatParam,
PrepareChatParam, ChatPrepareData,
StopChatParam, StopChatParam,
CommentMessageParam, CommentMessageParam,
ChatListParam, ChatListParam,
@@ -263,5 +263,20 @@ export const workcaseChatAPI = {
*/ */
endVideoMeeting(meetingId: string): Promise<ResultDomain<any>> { endVideoMeeting(meetingId: string): Promise<ResultDomain<any>> {
return request({ url: `${this.baseUrl}/meeting/${meetingId}/end`, method: 'POST' }) return request({ url: `${this.baseUrl}/meeting/${meetingId}/end`, method: 'POST' })
},
// ====================== 聊天室评分管理 ======================
/**
* 提交聊天室服务评分
* @param roomId 聊天室ID
* @param commentLevel 评分1-5星
*/
submitComment(roomId: string, commentLevel: number): Promise<ResultDomain<boolean>> {
return request({
url: `/urban-lifeline/workcase/chat/room/${roomId}/comment`,
method: 'POST',
data: { commentLevel }
})
} }
} }

View File

@@ -1,3 +1,4 @@
export const AGENT_ID = '17664699513920001' export const AGENT_ID = '17664699513920001'
export const BASE_URL = 'http://localhost:8180' export const BASE_URL = 'http://localhost:8180'
export const WS_HOST = 'localhost:8180' // WebSocket host不包含协议 export const WS_HOST = 'localhost:8180' // WebSocket host不包含协议
export const FILE_DOWNLOAD_URL = 'http://localhost:8180/urban-lifeline/sys-file/download?fileId='

View File

@@ -0,0 +1,118 @@
.comment-message-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 24rpx;
padding: 40rpx;
color: #fff;
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.3);
max-width: 600rpx;
margin: 0 auto;
display: flex;
flex-direction: column;
.comment-header {
margin-bottom: 32rpx;
text-align: center;
.comment-title {
font-size: 32rpx;
font-weight: 500;
}
}
.comment-body {
display: flex;
flex-direction: column;
align-items: center;
gap: 32rpx;
.star-rating {
display: flex;
flex-direction: row;
gap: 16rpx;
.star-item {
transition: transform 0.2s ease;
&:active:not(.is-disabled) {
transform: scale(1.15);
}
&.is-disabled {
opacity: 0.8;
}
.star-icon {
font-size: 56rpx;
line-height: 1;
&.star-filled {
color: #FFD700;
}
&.star-empty {
color: rgba(255, 255, 255, 0.4);
}
}
}
}
.rating-desc {
.rating-desc-text {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
}
.submitted-status {
display: flex;
align-items: center;
padding: 16rpx 32rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 40rpx;
.submitted-text {
font-size: 28rpx;
font-weight: 500;
}
}
.no-permission {
padding: 16rpx 32rpx;
background: rgba(255, 255, 255, 0.15);
border-radius: 40rpx;
.no-permission-text {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.7);
}
}
.submit-btn {
padding: 20rpx 56rpx;
background: rgba(255, 255, 255, 0.2);
border: 2rpx solid rgba(255, 255, 255, 0.4);
border-radius: 48rpx;
color: #fff;
font-size: 28rpx;
font-weight: 500;
&.is-active {
background: rgba(255, 255, 255, 0.95);
border-color: transparent;
.submit-btn-text {
color: #667eea;
}
}
&[disabled] {
opacity: 0.6;
}
.submit-btn-text {
color: #fff;
}
}
}
}

View File

@@ -0,0 +1,116 @@
<template>
<view class="comment-message-card">
<view class="comment-header">
<text class="comment-title">{{ title }}</text>
</view>
<view class="comment-body">
<!-- 星级评分 -->
<view class="star-rating">
<view
v-for="star in 5"
:key="star"
class="star-item"
:class="{
'is-active': star <= currentRating,
'is-disabled': !canComment || isSubmitted
}"
@tap="handleStarClick(star)"
>
<text class="star-icon" :class="star <= currentRating ? 'star-filled' : 'star-empty'">★</text>
</view>
</view>
<!-- 评分描述 -->
<view v-if="currentRating > 0" class="rating-desc">
<text class="rating-desc-text">{{ getRatingDesc(currentRating) }}</text>
</view>
<!-- 不可评价提示 -->
<view v-if="!canComment && !isSubmitted" class="no-permission">
<text class="no-permission-text">仅访客可评价</text>
</view>
<!-- 已评分状态 -->
<view v-else-if="isSubmitted" class="submitted-status">
<text class="submitted-text">✓ 已评分</text>
</view>
<!-- 提交按钮 -->
<button
v-else-if="canComment"
class="submit-btn"
:class="{ 'is-active': currentRating > 0 }"
:disabled="currentRating === 0 || submitting"
@tap="handleSubmit"
>
<text class="submit-btn-text">{{ submitting ? '提交中...' : '提交评分' }}</text>
</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
interface Props {
roomId: string
initialRating?: number
canComment?: boolean // 是否可以评价当前用户是否为guestId
title?: string
}
const props = withDefaults(defineProps<Props>(), {
initialRating: 0,
canComment: false,
title: '请为本次服务评分'
})
const emit = defineEmits<{
submit: [rating: number]
}>()
const currentRating = ref(props.initialRating)
const submitting = ref(false)
const isSubmitted = ref(props.initialRating > 0)
// 监听 initialRating 变化
watch(() => props.initialRating, (newVal) => {
currentRating.value = newVal
isSubmitted.value = newVal > 0
})
// 星级描述映射
const ratingDescriptions = {
1: '非常不满意',
2: '不满意',
3: '一般',
4: '满意',
5: '非常满意'
}
const getRatingDesc = (rating: number): string => {
return ratingDescriptions[rating as keyof typeof ratingDescriptions] || ''
}
const handleStarClick = (star: number) => {
if (!props.canComment || isSubmitted.value) return
currentRating.value = star
}
const handleSubmit = () => {
if (currentRating.value === 0 || !props.canComment || isSubmitted.value) return
submitting.value = true
try {
emit('submit', currentRating.value)
isSubmitted.value = true
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
@import './CommentMessageCard.scss';
</style>

View File

@@ -329,6 +329,7 @@
flex-direction: row; flex-direction: row;
align-items: flex-start; align-items: flex-start;
gap: 16rpx; gap: 16rpx;
width: 100%;
} }
.other-row { .other-row {
@@ -363,12 +364,13 @@
.message-content { .message-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-width: 480rpx; max-width: 70%;
min-width: 0; // 允许flex子元素收缩
} }
// 会议卡片需要更宽的空间 // 会议卡片需要更宽的空间
.message-content.meeting-card-wrapper { .message-content.meeting-card-wrapper {
max-width: 600rpx; max-width: 80%;
} }
.self-row .message-content { .self-row .message-content {
@@ -382,11 +384,13 @@
} }
.bubble { .bubble {
max-width: 480rpx; max-width: 100%;
padding: 18rpx 20rpx; padding: 18rpx 20rpx;
border-radius: 18rpx; border-radius: 18rpx;
font-size: 28rpx; font-size: 28rpx;
line-height: 1.6; line-height: 1.6;
word-break: break-all; // 长单词/URL换行
overflow-wrap: break-word;
} }
.other-bubble { .other-bubble {
@@ -403,6 +407,15 @@
font-size: 28rpx; font-size: 28rpx;
line-height: 1.6; line-height: 1.6;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all;
display: block;
}
.message-rich-text {
font-size: 28rpx;
line-height: 1.6;
word-break: break-all;
overflow-wrap: break-word;
} }
.message-time { .message-time {
@@ -462,3 +475,25 @@
font-size: 36rpx; font-size: 36rpx;
color: #4b87ff; color: #4b87ff;
} }
// ==================== 系统消息样式 ====================
.system-row {
justify-content: center;
margin-bottom: 48rpx;
}
.system-message-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
max-width: 80%;
}
.system-message-text {
padding: 16rpx 32rpx;
background: rgba(148, 163, 184, 0.15);
border-radius: 32rpx;
font-size: 26rpx;
color: #64748b;
text-align: center;
}

View File

@@ -43,6 +43,7 @@
</view> </view>
<!-- 聊天消息区域 --> <!-- 聊天消息区域 -->
<scroll-view class="chat-area" scroll-y="true" :scroll-top="scrollTop" <scroll-view class="chat-area" scroll-y="true" :scroll-top="scrollTop"
:scroll-with-animation="false"
:style="{ top: (headerPaddingTop + 88) + 'px' }" :style="{ top: (headerPaddingTop + 88) + 'px' }"
@scrolltoupper="loadMoreMessages" @scrolltoupper="loadMoreMessages"
upper-threshold="50"> upper-threshold="50">
@@ -54,8 +55,31 @@
<text class="loading-more-text">没有更多消息了</text> <text class="loading-more-text">没有更多消息了</text>
</view> </view>
<view class="message-list"> <view class="message-list">
<view class="message-item" v-for="msg in messages" :key="msg.messageId" <view class="message-item" v-for="msg in messages" :key="msg.messageId">
:class="msg.senderType === 'guest' ? 'self' : 'other'"> <!-- 系统消息(居中显示) -->
<view class="message-row system-row" v-if="msg.senderType === 'system'">
<view class="system-message-container">
<!-- 评分消息卡片 -->
<template v-if="msg.messageType === 'comment'">
<CommentMessageCard
:room-id="roomId"
:can-comment="getCanComment()"
:initial-rating="commentLevel"
@submit="handleCommentSubmit"
/>
</template>
<!-- 其他系统消息 -->
<template v-else>
<view class="system-message-text">
<text>{{ msg.content }}</text>
</view>
</template>
<text class="message-time">{{ formatTime(msg.sendTime) }}</text>
</view>
</view>
<!-- 普通用户/客服消息 -->
<view v-else :class="msg.senderType === 'guest' ? 'self' : 'other'">
<!-- 对方消息(左侧) --> <!-- 对方消息(左侧) -->
<view class="message-row other-row" v-if="msg.senderType !== 'guest'"> <view class="message-row other-row" v-if="msg.senderType !== 'guest'">
<view> <view>
@@ -95,6 +119,7 @@
<text class="avatar-text">我</text> <text class="avatar-text">我</text>
</view> </view>
</view> </view>
</view>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
@@ -120,6 +145,7 @@
import { ref, reactive, computed, nextTick, onMounted, onUnmounted, watch } from 'vue' import { ref, reactive, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
import MeetingCard from '../../meeting/meetingCard/MeetingCard.uvue' import MeetingCard from '../../meeting/meetingCard/MeetingCard.uvue'
import CommentMessageCard from './CommentMessageCard/CommentMessageCard.uvue'
import type { ChatRoomMessageVO, CustomerVO, ChatMemberVO, TbChatRoomMessageDTO, VideoMeetingVO } from '@/types/workcase' import type { ChatRoomMessageVO, CustomerVO, ChatMemberVO, TbChatRoomMessageDTO, VideoMeetingVO } from '@/types/workcase'
import { workcaseChatAPI } from '@/api/workcase' import { workcaseChatAPI } from '@/api/workcase'
import { wsClient } from '@/utils/websocket' import { wsClient } from '@/utils/websocket'
@@ -130,6 +156,9 @@ const headerTotalHeight = ref<number>(88)
const roomId = ref<string>('') const roomId = ref<string>('')
const workcaseId = ref<string>('') const workcaseId = ref<string>('')
const roomName = ref<string>('聊天室') const roomName = ref<string>('聊天室')
const guestId = ref<string>('') // 聊天室访客ID
const deviceCode = ref<string>('') // 聊天室设备代码
const commentLevel = ref<number>(0) // 已有评分
const inputText = ref<string>('') const inputText = ref<string>('')
const scrollTop = ref<number>(0) const scrollTop = ref<number>(0)
const loading = ref<boolean>(false) const loading = ref<boolean>(false)
@@ -211,6 +240,9 @@ const totalMembers = computed<MemberDisplay[]>(() => {
return Array.from(memberMap.values()) return Array.from(memberMap.values())
}) })
function getCanComment(): boolean {
return currentUserId.value === guestId.value
}
// 生命周期 // 生命周期
onMounted(() => { onMounted(() => {
const windowInfo = uni.getWindowInfo() const windowInfo = uni.getWindowInfo()
@@ -280,6 +312,9 @@ async function refreshChatRoomInfo() {
if (roomRes.success && roomRes.data) { if (roomRes.success && roomRes.data) {
roomName.value = roomRes.data.roomName || '聊天室' roomName.value = roomRes.data.roomName || '聊天室'
workcaseId.value = roomRes.data.workcaseId || '' workcaseId.value = roomRes.data.workcaseId || ''
guestId.value = roomRes.data.guestId || ''
deviceCode.value = roomRes.data.deviceCode || ''
commentLevel.value = roomRes.data.commentLevel || 0
messageTotal.value = roomRes.data.messageCount || 0 messageTotal.value = roomRes.data.messageCount || 0
} }
} catch (e) { } catch (e) {
@@ -311,7 +346,10 @@ async function loadChatRoom() {
if (roomRes.success && roomRes.data) { if (roomRes.success && roomRes.data) {
roomName.value = roomRes.data.roomName || '聊天室' roomName.value = roomRes.data.roomName || '聊天室'
workcaseId.value = roomRes.data.workcaseId || '' workcaseId.value = roomRes.data.workcaseId || ''
guestId.value = roomRes.data.guestId || ''
deviceCode.value = roomRes.data.deviceCode || ''
messageTotal.value = roomRes.data.messageCount || 0 messageTotal.value = roomRes.data.messageCount || 0
commentLevel.value = roomRes.data.commentLevel!
} }
// 后端是降序查询page1是最新消息 // 后端是降序查询page1是最新消息
currentPage.value = 1 currentPage.value = 1
@@ -346,9 +384,14 @@ async function loadMessages() {
messages.splice(0, messages.length, ...reversedList) messages.splice(0, messages.length, ...reversedList)
console.log('[loadMessages] 加载完成, 消息数:', messages.length) console.log('[loadMessages] 加载完成, 消息数:', messages.length)
// 加载完第一页后检查是否需要自动填充 // 加载完第一页后滚动到底部,需要等待 DOM 完全渲染
if (currentPage.value === 1) { if (currentPage.value === 1) {
nextTick(() => scrollToBottom()) // 使用 setTimeout 确保 DOM 完全渲染后再滚动
nextTick(() => {
setTimeout(() => {
scrollToBottom()
}, 300)
})
} else { } else {
nextTick(() => scrollToBottom()) nextTick(() => scrollToBottom())
} }
@@ -436,10 +479,10 @@ function renderMarkdown(text: string): string {
html = html.replace(/(?<!\*)\*([^\*]+)\*(?!\*)/g, '<em>$1</em>') html = html.replace(/(?<!\*)\*([^\*]+)\*(?!\*)/g, '<em>$1</em>')
// 处理行内代码(`语法) // 处理行内代码(`语法)
html = html.replace(/`([^`]+)`/g, '<code style="background-color:#f5f5f5;padding:2px 6px;border-radius:3px;font-family:monospace;color:#e53e3e;">$1</code>') html = html.replace(/`([^`]+)`/g, '<code style="background-color:#f5f5f5;padding:2px 6px;border-radius:3px;font-family:monospace;color:#e53e3e;word-break:break-all;">$1</code>')
// 处理链接([text](url)语法) // 处理链接([text](url)语法)
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" style="color:#0055AA;text-decoration:underline;">$1</a>') html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" style="color:#0055AA;text-decoration:underline;word-break:break-all;">$1</a>')
// 处理标题(# ## ###等) // 处理标题(# ## ###等)
html = html.replace(/^### (.+)$/gm, '<div style="font-size:16px;font-weight:600;margin:8px 0 4px;">$1</div>') html = html.replace(/^### (.+)$/gm, '<div style="font-size:16px;font-weight:600;margin:8px 0 4px;">$1</div>')
@@ -452,7 +495,8 @@ function renderMarkdown(text: string): string {
// 处理换行 // 处理换行
html = html.replace(/\n/g, '<br/>') html = html.replace(/\n/g, '<br/>')
return html // 包裹在带有换行样式的容器中
return `<div style="word-break:break-all;overflow-wrap:break-word;white-space:pre-wrap;">${html}</div>`
} }
// 发送消息 // 发送消息
@@ -517,7 +561,14 @@ async function sendMessage() {
// 滚动到底部 // 滚动到底部
function scrollToBottom() { function scrollToBottom() {
scrollTop.value = 999999 // uni-app 的 scroll-view 需要 scroll-top 值发生变化才会触发滚动
// 先重置为 0
scrollTop.value = 0
// 使用 setTimeout 确保重置生效
setTimeout(() => {
// 使用一个足够大的值确保滚动到底部
scrollTop.value = 999999
}, 50)
} }
// 处理工单操作 // 处理工单操作
@@ -539,8 +590,8 @@ function handleWorkcaseAction() {
} }
}) })
} else { } else {
// 跳转到创建工单页面 // 跳转到创建工单页面,携带 deviceCode
const url = `/pages/workcase/workcaseDetail/workcaseDetail?mode=create&roomId=${roomId.value}` const url = `/pages/workcase/workcaseDetail/workcaseDetail?mode=create&roomId=${roomId.value}&deviceCode=${encodeURIComponent(deviceCode.value || '')}`
console.log('[handleWorkcaseAction] 创建工单跳转URL:', url) console.log('[handleWorkcaseAction] 创建工单跳转URL:', url)
uni.navigateTo({ uni.navigateTo({
url: url, url: url,
@@ -644,6 +695,31 @@ async function handleJoinMeeting(meetingId: string) {
} }
} }
// 处理评分提交
async function handleCommentSubmit(rating: number) {
console.log('[handleCommentSubmit] 提交评分:', rating)
try {
const result = await workcaseChatAPI.submitComment(roomId.value, rating)
if (result.success) {
uni.showToast({
title: '感谢您的评分!',
icon: 'success'
})
} else {
uni.showToast({
title: result.message || '评分提交失败',
icon: 'none'
})
}
} catch (error) {
console.error('[handleCommentSubmit] 评分提交失败:', error)
uni.showToast({
title: '评分提交失败,请稍后重试',
icon: 'none'
})
}
}
// 返回上一页 // 返回上一页
function goBack() { function goBack() {
uni.navigateBack() uni.navigateBack()
@@ -711,6 +787,16 @@ function handleNewMessage(message: ChatRoomMessageVO) {
return return
} }
// 会议消息延时处理,等待数据库事务提交
if (message.messageType === 'meet') {
console.log('[chatRoom] 收到会议消息延时1秒后刷新')
setTimeout(async () => {
// 重新加载最新消息,确保获取到完整的会议消息数据
await loadMessages()
}, 1000)
return
}
// 添加新消息到列表 // 添加新消息到列表
messages.push(message) messages.push(message)
nextTick(() => scrollToBottom()) nextTick(() => scrollToBottom())

View File

@@ -261,6 +261,15 @@
gap: 8px; gap: 8px;
} }
// 消息内容包装器(包含气泡和文件列表)
.message-content-wrapper {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
max-width: 260px;
}
.bot-message-content { .bot-message-content {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -328,6 +337,17 @@
word-wrap: break-word; word-wrap: break-word;
} }
// rich-text 组件样式(用于 Markdown 渲染)
.message-rich-text {
font-size: 14px;
line-height: 1.5;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
white-space: normal;
max-width: 100%;
}
.message-time { .message-time {
font-size: 11px; font-size: 11px;
color: #999999; color: #999999;
@@ -426,18 +446,121 @@
.chat-input-wrap { .chat-input-wrap {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: row; flex-direction: column;
align-items: center;
background: #fff; background: #fff;
border-radius: 24px; border-radius: 24px;
padding: 4px; padding: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
} }
// 已上传文件预览区
.uploaded-files {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
padding: 8px 8px 4px;
border-bottom: 1px solid #f0f0f0;
}
.uploaded-file-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 80px;
background: #f5f5f5;
border-radius: 8px;
padding: 8px;
gap: 4px;
}
.file-preview-image {
width: 64px;
height: 64px;
border-radius: 6px;
overflow: hidden;
}
.preview-img {
width: 100%;
height: 100%;
}
.file-preview-doc {
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 6px;
}
.doc-icon {
font-size: 32px;
}
.file-name {
font-size: 11px;
color: #666;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.remove-file-btn {
position: absolute;
top: -4px;
right: -4px;
width: 20px;
height: 20px;
border-radius: 10px;
background: #ff4d4f;
display: flex;
align-items: center;
justify-content: center;
}
.remove-icon {
font-size: 12px;
color: #fff;
}
// 输入行(上传按钮+输入框+发送按钮)
.input-row {
display: flex;
flex-direction: row;
align-items: center;
padding: 4px;
}
.upload-btn {
width: 40px;
height: 40px;
border-radius: 20px;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 4px;
}
.upload-btn.uploading {
background: #e6f7ff;
}
.upload-icon {
font-size: 20px;
}
.chat-input { .chat-input {
flex: 1; flex: 1;
height: 40px; height: 40px;
padding: 0 16px; padding: 0 12px;
background: transparent; background: transparent;
border: none; border: none;
font-size: 14px; font-size: 14px;
@@ -458,6 +581,16 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
margin-left: 4px;
}
.send-btn.active {
background: linear-gradient(135deg, #5b9eff 0%, #4b87ff 100%);
border-color: #4b87ff;
}
.send-btn.active .send-icon {
color: #fff;
} }
.send-icon { .send-icon {
@@ -465,6 +598,65 @@
color: #4b87ff; color: #4b87ff;
} }
// 消息中的文件列表
.message-files {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
width: 100%;
}
.message-file-item {
display: flex;
flex-direction: column;
align-items: center;
width: 70px;
}
.file-thumb {
width: 70px;
height: 70px;
border-radius: 8px;
overflow: hidden;
background: #f5f5f5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
}
.file-thumb.image {
border: 1px solid #e5e5e5;
background: #fff;
}
.file-img {
width: 100%;
height: 100%;
}
.file-thumb.doc {
background: #f5f5f5;
padding: 8px;
}
.file-icon {
font-size: 28px;
}
.file-name-small {
font-size: 10px;
color: #666;
text-align: center;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 4px;
}
// 打字指示器动画 // 打字指示器动画
.typing-indicator { .typing-indicator {
display: flex; display: flex;
@@ -504,3 +696,110 @@
opacity: 1; opacity: 1;
} }
} }
// 设备代码输入弹窗样式
.device-code-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
position: relative;
background-color: white;
border-radius: 16px;
width: 80%;
max-width: 320px;
padding: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.modal-header {
margin-bottom: 20px;
}
.modal-title {
font-size: 18px;
font-weight: 600;
color: #333;
text-align: center;
display: block;
}
.modal-body {
margin-bottom: 20px;
}
.device-code-input {
width: 100%;
height: 44px;
padding: 0 12px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 16px;
background-color: #f9f9f9;
box-sizing: border-box;
}
.device-code-input:focus {
border-color: #007AFF;
background-color: white;
outline: none;
}
.modal-footer {
display: flex;
flex-direction: row;
gap: 12px;
}
.modal-btn {
flex: 1;
height: 44px;
border-radius: 8px;
border: none;
font-size: 16px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.modal-btn.cancel {
background-color: #f5f5f5;
color: #666;
}
.modal-btn.cancel:active {
background-color: #e5e5e5;
}
.modal-btn.confirm {
background-color: #007AFF;
color: white;
}
.modal-btn.confirm:active {
background-color: #0056b3;
}
.modal-btn .btn-text {
font-size: 16px;
}

View File

@@ -52,8 +52,23 @@
:class="item.type === 'user' ? 'user-message' : 'bot-message'"> :class="item.type === 'user' ? 'user-message' : 'bot-message'">
<!-- 用户消息(右侧) --> <!-- 用户消息(右侧) -->
<view class="user-message-content" v-if="item.type === 'user'"> <view class="user-message-content" v-if="item.type === 'user'">
<view class="message-bubble user-bubble"> <view class="message-content-wrapper">
<text class="message-text">{{item.content}}</text> <!-- 文字气泡 -->
<view class="message-bubble user-bubble" v-if="item.content">
<text class="message-text">{{item.content}}</text>
</view>
<!-- 用户消息的文件列表(在气泡外面) -->
<view v-if="item.files && item.files.length > 0" class="message-files">
<view v-for="fileId in item.files" :key="fileId" class="message-file-item" @tap="previewFile(fileId)">
<view v-if="isImageFileById(fileId)" class="file-thumb image">
<image :src="getFileDownloadUrl(fileId)" mode="aspectFill" class="file-img" />
</view>
<view v-else class="file-thumb doc">
<text class="file-icon">📄</text>
<text class="file-name-small">{{getFileName(fileId)}}</text>
</view>
</view>
</view>
</view> </view>
<view class="avatar user-avatar"> <view class="avatar user-avatar">
<text class="avatar-text">我</text> <text class="avatar-text">我</text>
@@ -103,27 +118,78 @@
<!-- 输入区域 --> <!-- 输入区域 -->
<view class="chat-input-wrap"> <view class="chat-input-wrap">
<input class="chat-input" v-model="inputText" placeholder="输入问题 来问问我~" @confirm="sendMessage" /> <!-- 已上传文件预览 -->
<view class="send-btn" @tap="sendMessage"> <view v-if="uploadedFiles.length > 0" class="uploaded-files">
<text class="send-icon">➤</text> <view v-for="(file, index) in uploadedFiles" :key="file.id || index" class="uploaded-file-item">
<view v-if="isImageFile(file)" class="file-preview-image">
<image :src="getFilePreviewUrl(file)" mode="aspectFill" class="preview-img" />
</view>
<view v-else class="file-preview-doc">
<text class="doc-icon">📄</text>
</view>
<text class="file-name">{{file.name || '文件'}}</text>
<view class="remove-file-btn" @tap="removeUploadedFile(index)">
<text class="remove-icon">✕</text>
</view>
</view>
</view>
<!-- 输入框 -->
<view class="input-row">
<view class="upload-btn" :class="{ uploading: isUploading }" @tap="showUploadOptions">
<text v-if="isUploading" class="upload-icon">⏳</text>
<text v-else class="upload-icon">📎</text>
</view>
<input class="chat-input" v-model="inputText" placeholder="输入问题 来问问我~" @confirm="sendMessage" />
<view class="send-btn" :class="{ active: inputText.trim() || uploadedFiles.length > 0 }" @tap="sendMessage">
<text class="send-icon">➤</text>
</view>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
<!-- 设备代码输入弹窗 -->
<view class="device-code-modal" v-if="showDeviceCodeDialog">
<view class="modal-mask" @tap="cancelDeviceCodeInput"></view>
<view class="modal-content">
<view class="modal-header">
<text class="modal-title">请输入设备代码</text>
</view>
<view class="modal-body">
<input
class="device-code-input"
v-model="deviceCodeInput"
placeholder="请输入设备代码"
focus
@confirm="confirmDeviceCodeInput"
/>
</view>
<view class="modal-footer">
<button class="modal-btn cancel" @tap="cancelDeviceCodeInput">
<text class="btn-text">取消</text>
</button>
<button class="modal-btn confirm" @tap="confirmDeviceCodeInput">
<text class="btn-text">确定</text>
</button>
</view>
</view>
</view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, nextTick, onMounted } from 'vue' import { ref, nextTick, onMounted } from 'vue'
import { guestAPI, aiChatAPI, workcaseChatAPI } from '@/api' import { guestAPI, aiChatAPI, workcaseChatAPI } from '@/api'
import type { TbWorkcaseDTO } from '@/types' import type { TbWorkcaseDTO } from '@/types'
import { AGENT_ID } from '@/config' import type { DifyFileInfo } from '@/types/ai/aiChat'
import { AGENT_ID, FILE_DOWNLOAD_URL } from '@/config'
// 前端消息展示类型 // 前端消息展示类型
interface ChatMessageItem { interface ChatMessageItem {
type: 'user' | 'bot' type: 'user' | 'bot'
content: string content: string
time: string time: string
actions?: string[] | null actions?: string[] | null
files?: string[] // 文件ID数组
} }
const agentId = AGENT_ID const agentId = AGENT_ID
// 响应式数据 // 响应式数据
@@ -136,6 +202,12 @@
const headerPaddingTop = ref<number>(44) // header顶部padding默认44px const headerPaddingTop = ref<number>(44) // header顶部padding默认44px
const headerTotalHeight = ref<number>(76) // header总高度默认76px const headerTotalHeight = ref<number>(76) // header总高度默认76px
// 文件上传相关
const uploadedFiles = ref<DifyFileInfo[]>([])
const isUploading = ref<boolean>(false)
// 文件信息缓存 (fileId -> DifyFileInfo)
const fileInfoCache = ref<Map<string, DifyFileInfo>>(new Map())
// 用户信息 // 用户信息
const userInfo = ref({ const userInfo = ref({
wechatId: '', wechatId: '',
@@ -149,6 +221,12 @@
const chatId = ref<string>('') // 当前会话ID const chatId = ref<string>('') // 当前会话ID
const currentTaskId = ref<string>('') // 当前任务ID用于停止 const currentTaskId = ref<string>('') // 当前任务ID用于停止
// 设备代码相关
const deviceCode = ref<string>('') // 设备代码
const showDeviceCodeDialog = ref<boolean>(false) // 是否显示设备代码输入弹窗
const deviceCodeInput = ref<string>('') // 弹窗中的设备代码输入
const pendingAction = ref<'workcase' | 'human' | ''>('') // 待执行的操作类型
// 初始化用户信息 // 初始化用户信息
async function initUserInfo() { async function initUserInfo() {
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
@@ -257,18 +335,38 @@
// 发送消息 // 发送消息
async function sendMessage() { async function sendMessage() {
const text = inputText.value.trim() const text = inputText.value.trim()
if (!text || isTyping.value) return // 允许只有文件或只有文本
if ((!text && uploadedFiles.value.length === 0) || isTyping.value) return
// 添加用户消息 const query = text || '[文件]'
addMessage('user', text) const currentFiles = [...uploadedFiles.value] // 保存当前文件列表副本
// 将文件信息缓存起来,用于立即渲染
currentFiles.forEach(f => {
if (f.sys_file_id) {
fileInfoCache.value.set(f.sys_file_id, f)
}
})
// 添加用户消息(包含文件)
const userMessage: ChatMessageItem = {
type: 'user',
content: text,
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
files: currentFiles.length > 0 ? currentFiles.map(item => item.sys_file_id || '') : undefined
}
messages.value.push(userMessage)
inputText.value = '' inputText.value = ''
// 调用AI聊天接口 // 清空已上传的文件
await callAIChat(text) uploadedFiles.value = []
// 调用AI聊天接口携带文件
await callAIChat(query, currentFiles)
} }
// 调用AI聊天接口 // 调用AI聊天接口
async function callAIChat(query : string) { async function callAIChat(query : string, files : DifyFileInfo[] = []) {
isTyping.value = true isTyping.value = true
try { try {
@@ -288,14 +386,19 @@
} }
} }
// 准备流式对话 // 准备流式对话(包含文件)
const prepareRes = await aiChatAPI.prepareChatMessageSession({ const prepareData: ChatPrepareData = {
chatId: chatId.value, chatId: chatId.value,
query: query, query: query,
agentId: agentId, agentId: agentId,
userType: userType.value, userType: userType.value,
userId: userInfo.value.userId userId: userInfo.value.userId,
}) files: files.length > 0 ? files : undefined,
service: "workcase"
}
console.log('准备流式对话参数:', JSON.stringify(prepareData))
const prepareRes = await aiChatAPI.prepareChatMessageSession(prepareData)
if (!prepareRes.success || !prepareRes.data) { if (!prepareRes.success || !prepareRes.data) {
throw new Error(prepareRes.message || '准备对话失败') throw new Error(prepareRes.message || '准备对话失败')
} }
@@ -419,10 +522,54 @@
} }
} }
// 直接跳转到工单详情页的 create 模式(复用 workcaseDetail 页面) // 检查并获取设备代码
async function showCreator() { function checkDeviceCode(action: 'workcase' | 'human') {
// 首页直接创建工单为了让工单和聊天室绑定这里先创建一个聊天室workcase类型再带 roomId 跳转 if (!deviceCode.value) {
// 如果你希望“无聊天室也能创建工单”,后端 WorkcaseServiceImpl 也支持 roomId 为空时自动创建聊天室 // 如果没有设备代码,显示输入弹窗
pendingAction.value = action
deviceCodeInput.value = ''
showDeviceCodeDialog.value = true
} else {
// 如果已有设备代码,直接执行对应操作
if (action === 'workcase') {
doCreateWorkcase()
} else {
doContactHuman()
}
}
}
// 确认输入设备代码
function confirmDeviceCodeInput() {
if (!deviceCodeInput.value.trim()) {
uni.showToast({
title: '请输入设备代码',
icon: 'none'
})
return
}
deviceCode.value = deviceCodeInput.value.trim()
showDeviceCodeDialog.value = false
// 执行待处理的操作
if (pendingAction.value === 'workcase') {
doCreateWorkcase()
} else if (pendingAction.value === 'human') {
doContactHuman()
}
pendingAction.value = ''
}
// 取消输入设备代码
function cancelDeviceCodeInput() {
showDeviceCodeDialog.value = false
deviceCodeInput.value = ''
pendingAction.value = ''
}
// 实际创建工单
async function doCreateWorkcase() {
uni.showLoading({ title: '正在创建工单...' }) uni.showLoading({ title: '正在创建工单...' })
try { try {
const res = await workcaseChatAPI.createChatRoom({ const res = await workcaseChatAPI.createChatRoom({
@@ -431,6 +578,7 @@
roomName: `${userInfo.value.username || '访客'}的工单`, roomName: `${userInfo.value.username || '访客'}的工单`,
roomType: 'workcase', roomType: 'workcase',
status: 'active', status: 'active',
deviceCode: deviceCode.value,
aiSessionId: chatId.value || '' aiSessionId: chatId.value || ''
}) })
uni.hideLoading() uni.hideLoading()
@@ -451,6 +599,51 @@
} }
} }
// 实际联系人工
async function doContactHuman() {
uni.showLoading({ title: '正在连接客服...' })
try {
// 创建聊天室
const res = await workcaseChatAPI.createChatRoom({
guestId: userInfo.value.userId || userInfo.value.wechatId,
guestName: userInfo.value.username || '访客',
roomName: `${userInfo.value.username || '访客'}的咨询`,
roomType: 'guest',
status: 'active',
deviceCode: deviceCode.value,
aiSessionId: chatId.value || ''
})
uni.hideLoading()
if (res.success && res.data) {
const roomId = res.data.roomId
console.log('创建聊天室成功:', roomId)
// 跳转到聊天室页面
uni.navigateTo({
url: `/pages/chatRoom/chatRoom/chatRoom?roomId=${roomId}&roomName=${encodeURIComponent(res.data.roomName || '人工客服')}`
})
} else {
uni.showToast({
title: res.message || '连接客服失败',
icon: 'none'
})
}
} catch (error: any) {
uni.hideLoading()
console.error('创建聊天室失败:', error)
uni.showToast({
title: '连接客服失败,请稍后重试',
icon: 'none'
})
}
}
// 直接跳转到工单详情页的 create 模式(复用 workcaseDetail 页面)
async function showCreator() {
// 检查设备代码
checkDeviceCode('workcase')
}
// 兼容旧逻辑:不再使用页面内工单创建器 // 兼容旧逻辑:不再使用页面内工单创建器
function hideCreator() { function hideCreator() {
showWorkcaseCreator.value = false showWorkcaseCreator.value = false
@@ -487,40 +680,8 @@
// 联系人工客服 - 创建聊天室并进入 // 联系人工客服 - 创建聊天室并进入
async function contactHuman() { async function contactHuman() {
uni.showLoading({ title: '正在连接客服...' }) // 检查设备代码
try { checkDeviceCode('human')
// 创建聊天室
const res = await workcaseChatAPI.createChatRoom({
guestId: userInfo.value.userId || userInfo.value.wechatId,
guestName: userInfo.value.username || '访客',
roomName: `${userInfo.value.username || '访客'}的咨询`,
roomType: 'guest',
status: 'active',
aiSessionId: chatId.value || ''
})
uni.hideLoading()
if (res.success && res.data) {
const roomId = res.data.roomId
console.log('创建聊天室成功:', roomId)
// 跳转到聊天室页面
uni.navigateTo({
url: `/pages/chatRoom/chatRoom/chatRoom?roomId=${roomId}&roomName=${encodeURIComponent(res.data.roomName || '人工客服')}`
})
} else {
uni.showToast({
title: res.message || '连接客服失败',
icon: 'none'
})
}
} catch (error: any) {
uni.hideLoading()
console.error('创建聊天室失败:', error)
uni.showToast({
title: '连接客服失败,请稍后重试',
icon: 'none'
})
}
} }
// 处理快速问题 // 处理快速问题
@@ -529,7 +690,7 @@
await callAIChat(question) await callAIChat(question)
} }
// Markdown渲染函数返回富文本节点 // Markdown渲染函数(返回富文本节点)
function renderMarkdown(text : string) : string { function renderMarkdown(text : string) : string {
if (!text) return '' if (!text) return ''
@@ -539,30 +700,31 @@
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
// 处理粗体**语法 // 处理粗体(**语法)
html = html.replace(/\*\*([^\*]+)\*\*/g, '<strong>$1</strong>') html = html.replace(/\*\*([^\*]+)\*\*/g, '<strong>$1</strong>')
// 处理斜体*语法但要避免和粗体冲突 // 处理斜体(*语法,但要避免和粗体冲突)
html = html.replace(/(?<!\*)\*([^\*]+)\*(?!\*)/g, '<em>$1</em>') html = html.replace(/(?<!\*)\*([^\*]+)\*(?!\*)/g, '<em>$1</em>')
// 处理行内代码`语法 // 处理行内代码(`语法)
html = html.replace(/`([^`]+)`/g, '<code style="background-color:#f5f5f5;padding:2px 6px;border-radius:3px;font-family:monospace;color:#e53e3e;">$1</code>') html = html.replace(/`([^`]+)`/g, '<code style="background-color:#f5f5f5;padding:2px 6px;border-radius:3px;font-family:monospace;color:#e53e3e;word-break:break-all;">$1</code>')
// 处理链接[text](url)语法 // 处理链接([text](url)语法)
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" style="color:#0055AA;text-decoration:underline;">$1</a>') html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" style="color:#0055AA;text-decoration:underline;word-break:break-all;overflow-wrap:break-word;">$1</a>')
// 处理标题# ## ###等 // 处理标题(# ## ###等)
html = html.replace(/^### (.+)$/gm, '<div style="font-size:16px;font-weight:600;margin:8px 0 4px;">$1</div>') html = html.replace(/^### (.+)$/gm, '<div style="font-size:16px;font-weight:600;margin:8px 0 4px;word-break:break-word;">$1</div>')
html = html.replace(/^## (.+)$/gm, '<div style="font-size:18px;font-weight:600;margin:10px 0 6px;">$1</div>') html = html.replace(/^## (.+)$/gm, '<div style="font-size:18px;font-weight:600;margin:10px 0 6px;word-break:break-word;">$1</div>')
html = html.replace(/^# (.+)$/gm, '<div style="font-size:20px;font-weight:700;margin:12px 0 8px;">$1</div>') html = html.replace(/^# (.+)$/gm, '<div style="font-size:20px;font-weight:700;margin:12px 0 8px;word-break:break-word;">$1</div>')
// 处理无序列表- 或 * 开头 // 处理无序列表(- 或 * 开头)
html = html.replace(/^[*-] (.+)$/gm, '<div style="margin-left:16px;">• $1</div>') html = html.replace(/^[*-] (.+)$/gm, '<div style="margin-left:16px;word-break:break-word;">• $1</div>')
// 处理换行 // 处理换行
html = html.replace(/\n/g, '<br/>') html = html.replace(/\n/g, '<br/>')
return html // 包裹在一个具有换行样式的容器中
return `<div style="word-break:break-word;overflow-wrap:break-word;white-space:normal;max-width:100%;">${html}</div>`
} }
// 显示上传选项 // 显示上传选项
@@ -594,10 +756,9 @@
count: 1, count: 1,
sourceType: ['camera'], sourceType: ['camera'],
success: (res) => { success: (res) => {
// 处理图片上传逻辑 if (res.tempFilePaths && res.tempFilePaths.length > 0) {
console.log('选择的图片:', res.tempFilePaths) uploadSingleFile(res.tempFilePaths[0])
addMessage('user', '[图片]') }
simulateAIResponse('收到您发送的图片')
} }
}) })
} }
@@ -605,24 +766,177 @@
// 从相册选择 // 从相册选择
function chooseImageFromAlbum() { function chooseImageFromAlbum() {
uni.chooseImage({ uni.chooseImage({
count: 1, count: 9,
sourceType: ['album'], sourceType: ['album'],
success: (res) => { success: (res) => {
// 处理图片上传逻辑 if (res.tempFilePaths && res.tempFilePaths.length > 0) {
console.log('选择的图片:', res.tempFilePaths) res.tempFilePaths.forEach((filePath: string) => {
addMessage('user', '[图片]') uploadSingleFile(filePath)
simulateAIResponse('收到您发送的图片') })
}
} }
}) })
} }
// 选择文件 // 选择文件
function chooseFile() { function chooseFile() {
// 这里可以扩展文件选择功能 // #ifdef MP-WEIXIN
uni.showToast({ // 微信小程序使用 chooseMessageFile
title: '文件选择功能开发中', uni.chooseMessageFile({
icon: 'none' count: 5,
type: 'file',
extension: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'txt'],
success: (res: any) => {
console.log('选择文件成功:', res)
if (res.tempFiles && res.tempFiles.length > 0) {
res.tempFiles.forEach((file: any) => {
uploadSingleFile(file.path)
})
}
},
fail: (err: any) => {
console.error('选择文件失败:', err)
uni.showToast({
title: '选择文件失败',
icon: 'none'
})
}
}) })
// #endif
// #ifndef MP-WEIXIN
// 非微信小程序环境
// @ts-ignore
if (typeof uni.chooseFile === 'function') {
// @ts-ignore
uni.chooseFile({
count: 5,
extension: ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.txt'],
success: (res: any) => {
console.log('选择文件成功:', res)
if (res.tempFilePaths && res.tempFilePaths.length > 0) {
res.tempFilePaths.forEach((filePath: string) => {
uploadSingleFile(filePath)
})
}
},
fail: (err: any) => {
console.error('选择文件失败:', err)
uni.showToast({
title: '选择文件失败',
icon: 'none'
})
}
})
} else {
uni.showToast({
title: '当前环境不支持文件选择',
icon: 'none'
})
}
// #endif
}
// 上传单个文件
async function uploadSingleFile(filePath: string) {
console.log('开始上传文件:', filePath)
if (!agentId) {
uni.showToast({ title: '智能体未配置', icon: 'none' })
return
}
isUploading.value = true
uni.showLoading({ title: '上传中...' })
try {
const result = await aiChatAPI.uploadFileForChat(filePath, agentId)
console.log('上传结果:', result)
if (result.success && result.data) {
uploadedFiles.value.push(result.data)
uni.showToast({ title: '上传成功', icon: 'success', duration: 1000 })
} else {
uni.showToast({ title: result.message || '上传失败', icon: 'none' })
}
} catch (error: any) {
console.error('文件上传失败:', error)
uni.showToast({ title: '上传失败: ' + (error.message || '未知错误'), icon: 'none' })
} finally {
isUploading.value = false
uni.hideLoading()
}
}
// 移除已上传的文件
function removeUploadedFile(index: number) {
uploadedFiles.value.splice(index, 1)
}
// 判断是否为图片文件
function isImageFile(file: DifyFileInfo): boolean {
return file.type === 'image' || file.mime_type?.startsWith('image/') || false
}
// 获取文件预览URL
function getFilePreviewUrl(file: DifyFileInfo): string {
return file.preview_url || file.source_url || file.url || ''
}
// 获取文件下载URL通过文件ID
function getFileDownloadUrl(fileId: string): string {
return `${FILE_DOWNLOAD_URL}${fileId}`
}
// 判断文件ID对应的文件是否为图片
function isImageFileById(fileId: string): boolean {
// 从缓存中查找文件信息
const file = fileInfoCache.value.get(fileId)
if (file) {
return isImageFile(file)
}
// 如果缓存中没有尝试从uploadedFiles中查找
const uploadedFile = uploadedFiles.value.find(f => f.sys_file_id === fileId)
if (uploadedFile) {
return isImageFile(uploadedFile)
}
return false
}
// 获取文件名(从缓存)
function getFileName(fileId: string): string {
const file = fileInfoCache.value.get(fileId)
return file?.name || fileId.substring(0, 8) + '...'
}
// 文件预览
function previewFile(fileId: string) {
const url = getFileDownloadUrl(fileId)
// 如果是图片,使用图片预览
if (isImageFileById(fileId)) {
uni.previewImage({
urls: [url],
current: url
})
} else {
// 其他文件,提示下载或打开
uni.showModal({
title: '提示',
content: '是否下载该文件?',
success: (res) => {
if (res.confirm) {
uni.downloadFile({
url: url,
success: (downloadRes) => {
if (downloadRes.statusCode === 200) {
uni.showToast({ title: '下载成功', icon: 'success' })
}
}
})
}
}
})
}
} }
</script> </script>

View File

@@ -1,116 +1,185 @@
.meeting-create-page { .page {
min-height: 100vh; background: #f8fafc;
background-color: #f5f7fa;
padding-bottom: 120rpx;
} }
.page-header { .nav {
background-color: #fff; position: fixed;
padding: 32rpx; top: 0;
border-bottom: 1px solid #ebeef5; left: 0;
right: 0;
background: #fff;
flex-direction: row;
align-items: flex-end;
padding-left: 24rpx;
padding-right: 24rpx;
padding-bottom: 16rpx;
z-index: 100;
border-bottom: 1rpx solid #e5e7eb;
} }
.page-title { .nav-back {
font-size: 36rpx; width: 60rpx;
font-weight: 600; height: 64rpx;
color: #303133; align-items: center;
justify-content: center;
} }
.nav-back-icon {
width: 20rpx;
height: 20rpx;
border-left-width: 4rpx;
border-left-style: solid;
border-left-color: #333;
border-bottom-width: 4rpx;
border-bottom-style: solid;
border-bottom-color: #333;
transform: rotate(45deg);
}
.nav-title {
flex: 1;
font-size: 34rpx;
font-weight: 500;
color: #333;
text-align: center;
line-height: 64rpx;
}
.nav-capsule {
width: 174rpx;
height: 64rpx;
}
.content {
padding: 24rpx;
padding-bottom: 140rpx;
}
.section {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
// 表单容器
.form-container { .form-container {
background-color: #fff; background: #fff;
margin-top: 16rpx; border-radius: 16rpx;
overflow: hidden;
} }
.form-item { .form-item {
padding: 24rpx 32rpx; padding: 24rpx;
border-bottom: 1px solid #ebeef5; border-bottom-width: 1rpx;
} border-bottom-style: solid;
border-bottom-color: #f3f4f6;
.form-item.required .label-text::after {
content: ' *';
color: #f56c6c;
} }
.form-label { .form-label {
display: flex; font-size: 26rpx;
align-items: center; color: #6b7280;
margin-bottom: 16rpx; margin-bottom: 16rpx;
} font-weight: 500;
.label-text {
font-size: 28rpx;
color: #606266;
font-weight: 500;
}
.required-star {
color: #f56c6c;
margin-left: 8rpx;
} }
.form-input { .form-input {
width: 100%; padding: 0 24rpx;
padding: 16rpx 24rpx; height: 68rpx;
border: 1px solid #dcdfe6; background-color: #f9fafb;
border-radius: 8rpx; border-width: 1rpx;
font-size: 28rpx; border-style: solid;
color: #303133; border-color: #e5e7eb;
} border-radius: 8rpx;
font-size: 28rpx;
.picker-display { color: #111827;
padding: 16rpx 24rpx;
border: 1px solid #dcdfe6;
border-radius: 8rpx;
font-size: 28rpx;
color: #303133;
}
.picker-display .placeholder {
color: #c0c4cc;
} }
.form-tip { .form-tip {
margin-top: 8rpx; font-size: 24rpx;
color: #9ca3af;
margin-top: 12rpx;
} }
.form-tip text { .required {
font-size: 24rpx; color: #ef4444;
color: #909399; margin-left: 4rpx;
} }
.form-footer { .picker-content {
position: fixed; flex-direction: row;
bottom: 0; align-items: center;
left: 0; justify-content: space-between;
right: 0; height: 68rpx;
display: flex; padding: 0 24rpx;
justify-content: space-between; background-color: #f9fafb;
padding: 24rpx 32rpx; border-width: 1rpx;
background-color: #fff; border-style: solid;
border-top: 1px solid #ebeef5; border-color: #e5e7eb;
box-shadow: 0 -2rpx 8rpx rgba(0, 0, 0, 0.05); border-radius: 8rpx;
} }
.btn { .picker-text {
flex: 1; flex: 1;
padding: 24rpx 0; font-size: 28rpx;
border-radius: 8rpx; color: #111827;
font-size: 32rpx;
text-align: center;
border: none;
} }
.btn-cancel { .picker-text.placeholder {
background-color: #f5f7fa; color: #9ca3af;
color: #606266;
margin-right: 16rpx;
} }
.btn-submit { .picker-arrow {
background-color: #409eff; font-size: 28rpx;
color: #fff; color: #9ca3af;
margin-left: 16rpx;
} }
.btn-submit[loading] { // 底部占位
opacity: 0.7; .footer-placeholder {
height: 120rpx;
}
// 底部操作栏
.footer-actions {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: #fff;
border-top-width: 1rpx;
border-top-style: solid;
border-top-color: #e5e7eb;
padding: 24rpx;
flex-direction: row;
z-index: 99;
}
.action-button {
flex: 1;
height: 80rpx;
border-radius: 8rpx;
align-items: center;
justify-content: center;
border-width: 1rpx;
border-style: solid;
border-color: #e5e7eb;
background-color: #fff;
margin-right: 24rpx;
}
.action-button.primary {
background-color: #4b87ff;
border-color: #4b87ff;
margin-right: 0;
}
.action-button.primary .button-text {
color: #fff;
}
.button-text {
font-size: 28rpx;
color: #6b7280;
font-weight: 500;
} }

View File

@@ -1,122 +1,136 @@
<template> <template>
<view class="meeting-create-page"> <!-- #ifdef APP -->
<view class="page-header"> <scroll-view style="flex:1">
<text class="page-title">创建视频会议</text> <!-- #endif -->
<view class="page">
<!-- 导航栏 -->
<view class="nav" :style="{ paddingTop: headerPaddingTop + 'px', height: headerTotalHeight + 'px' }">
<view class="nav-back" @tap="handleCancel">
<view class="nav-back-icon"></view>
</view>
<text class="nav-title">创建视频会议</text>
<view class="nav-capsule"></view>
</view> </view>
<view class="form-container"> <!-- 内容区域 -->
<!-- 会议名称 --> <scroll-view class="content" scroll-y="true" :style="{ marginTop: headerTotalHeight + 'px' }">
<view class="form-item"> <!-- 表单区域 -->
<view class="form-label"> <view class="section">
<text class="label-text">会议名称</text> <view class="form-container">
</view> <!-- 会议名称 -->
<input <view class="form-item">
v-model="formData.meetingName" <text class="form-label">会议名称</text>
class="form-input" <input
placeholder="请输入会议名称" v-model="formData.meetingName"
maxlength="50" class="form-input"
/> placeholder="请输入会议名称"
</view> maxlength="50"
/>
<!-- 开始时间 -->
<view class="form-item required">
<view class="form-label">
<text class="label-text">开始时间</text>
<text class="required-star">*</text>
</view>
<picker
mode="multiSelector"
:value="startTimePickerValue"
:range="timePickerRange"
@change="handleStartTimeChange"
>
<view class="picker-display">
<text :class="formData.startTime ? '' : 'placeholder'">
{{ formData.startTime || '请选择开始时间' }}
</text>
</view> </view>
</picker>
</view>
<!-- 结束时间 --> <!-- 开始时间 -->
<view class="form-item required"> <view class="form-item">
<view class="form-label"> <text class="form-label">开始时间<text class="required">*</text></text>
<text class="label-text">结束时间</text> <picker
<text class="required-star">*</text> mode="multiSelector"
</view> :value="startTimePickerValue"
<picker :range="timePickerRange"
mode="multiSelector" @change="handleStartTimeChange"
:value="endTimePickerValue" >
:range="timePickerRange" <view class="picker-content">
@change="handleEndTimeChange" <text class="picker-text" :class="{ placeholder: !formData.startTime }">
> {{ formData.startTime || '请选择开始时间' }}
<view class="picker-display"> </text>
<text :class="formData.endTime ? '' : 'placeholder'"> <text class="picker-arrow">></text>
{{ formData.endTime || '请选择结束时间' }} </view>
</text> </picker>
</view> </view>
</picker>
</view>
<!-- 提前入会 --> <!-- 结束时间 -->
<view class="form-item"> <view class="form-item">
<view class="form-label"> <text class="form-label">结束时间<text class="required">*</text></text>
<text class="label-text">提前入会(分钟)</text> <picker
</view> mode="multiSelector"
<input :value="endTimePickerValue"
v-model.number="formData.advance" :range="timePickerRange"
class="form-input" @change="handleEndTimeChange"
type="number" >
placeholder="提前入会时间" <view class="picker-content">
/> <text class="picker-text" :class="{ placeholder: !formData.endTime }">
<view class="form-tip"> {{ formData.endTime || '请选择结束时间' }}
<text>用户可在会议开始前N分钟加入</text> </text>
<text class="picker-arrow">></text>
</view>
</picker>
</view>
<!-- 提前入会 -->
<view class="form-item">
<text class="form-label">提前入会(分钟)</text>
<input
v-model.number="formData.advance"
class="form-input"
type="number"
placeholder="提前入会时间"
/>
<text class="form-tip">用户可在会议开始前N分钟加入</text>
</view>
<!-- 会议密码 -->
<view class="form-item">
<text class="form-label">会议密码</text>
<input
v-model="formData.meetingPassword"
class="form-input"
type="text"
password
placeholder="可选,留空则无密码"
maxlength="20"
/>
</view>
<!-- 最大人数 -->
<view class="form-item">
<text class="form-label">最大人数</text>
<input
v-model.number="formData.maxParticipants"
class="form-input"
type="number"
placeholder="最大参与人数"
/>
</view>
</view> </view>
</view> </view>
<!-- 会议密码 --> <!-- 底部占位 -->
<view class="form-item"> <view class="footer-placeholder"></view>
<view class="form-label"> </scroll-view>
<text class="label-text">会议密码</text>
</view>
<input
v-model="formData.meetingPassword"
class="form-input"
type="text"
password
placeholder="可选,留空则无密码"
maxlength="20"
/>
</view>
<!-- 最大人数 --> <!-- 底部操作栏 -->
<view class="form-item"> <view class="footer-actions">
<view class="form-label"> <view class="action-button" @tap="handleCancel">
<text class="label-text">最大人数</text> <text class="button-text">取消</text>
</view> </view>
<input <view class="action-button primary" @tap="handleSubmit">
v-model.number="formData.maxParticipants" <text class="button-text">创建会议</text>
class="form-input"
type="number"
placeholder="最大参与人数"
/>
</view> </view>
</view>
<!-- 底部按钮 -->
<view class="form-footer">
<button class="btn btn-cancel" @click="handleCancel">取消</button>
<button class="btn btn-submit" :loading="submitting" @click="handleSubmit">创建会议</button>
</view> </view>
</view> </view>
<!-- #ifdef APP -->
</scroll-view>
<!-- #endif -->
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { workcaseChatAPI } from '../../../api/workcase/workcaseChat' import { workcaseChatAPI } from '../../../api/workcase/workcaseChat'
import type { CreateMeetingParam } from '../../../types/workcase/chatRoom' import type { CreateMeetingParam } from '../../../types/workcase/chatRoom'
// 响应式数据
const headerPaddingTop = ref<number>(44)
const headerTotalHeight = ref<number>(88)
// 路由参数 // 路由参数
const roomId = ref('') const roomId = ref('')
const workcaseId = ref('') const workcaseId = ref('')
@@ -136,8 +150,8 @@ const formData = reactive<CreateMeetingParam>({
const submitting = ref(false) const submitting = ref(false)
// 时间选择器数据 // 时间选择器数据
const startTimePickerValue = ref([0, 0, 0, 0]) const startTimePickerValue = ref([0, 0, 0])
const endTimePickerValue = ref([0, 0, 0, 0]) const endTimePickerValue = ref([0, 0, 0])
// 生成时间选择器范围 // 生成时间选择器范围
const timePickerRange = computed(() => { const timePickerRange = computed(() => {
@@ -179,6 +193,26 @@ onLoad((options: any) => {
} }
}) })
onMounted(() => {
const windowInfo = uni.getWindowInfo()
const statusBarHeight = windowInfo.statusBarHeight || 44
// #ifdef MP-WEIXIN
try {
const menuButtonInfo = uni.getMenuButtonBoundingClientRect()
headerPaddingTop.value = menuButtonInfo.top
headerTotalHeight.value = menuButtonInfo.bottom + 8
} catch (e) {
headerPaddingTop.value = statusBarHeight
headerTotalHeight.value = statusBarHeight + 44
}
// #endif
// #ifndef MP-WEIXIN
headerPaddingTop.value = statusBarHeight
headerTotalHeight.value = statusBarHeight + 44
// #endif
})
// 处理开始时间选择 // 处理开始时间选择
function handleStartTimeChange(e: any) { function handleStartTimeChange(e: any) {
const val = e.detail.value const val = e.detail.value
@@ -214,69 +248,45 @@ function handleEndTimeChange(e: any) {
// 验证表单 // 验证表单
function validateForm(): boolean { function validateForm(): boolean {
if (!formData.startTime) { if (!formData.startTime) {
uni.showToast({ uni.showToast({ title: '请选择开始时间', icon: 'none' })
title: '请选择开始时间',
icon: 'none'
})
return false return false
} }
if (!formData.endTime) { if (!formData.endTime) {
uni.showToast({ uni.showToast({ title: '请选择结束时间', icon: 'none' })
title: '请选择结束时间',
icon: 'none'
})
return false return false
} }
const start = new Date(formData.startTime).getTime() const start = new Date(formData.startTime.replace(' ', 'T')).getTime()
const end = new Date(formData.endTime).getTime() const end = new Date(formData.endTime.replace(' ', 'T')).getTime()
if (start < Date.now()) { if (start < Date.now()) {
uni.showToast({ uni.showToast({ title: '开始时间不能早于当前时间', icon: 'none' })
title: '开始时间不能早于当前时间',
icon: 'none'
})
return false return false
} }
if (end <= start) { if (end <= start) {
uni.showToast({ uni.showToast({ title: '结束时间必须晚于开始时间', icon: 'none' })
title: '结束时间必须晚于开始时间',
icon: 'none'
})
return false return false
} }
if (end - start < 5 * 60 * 1000) { if (end - start < 5 * 60 * 1000) {
uni.showToast({ uni.showToast({ title: '会议时长不能少于5分钟', icon: 'none' })
title: '会议时长不能少于5分钟',
icon: 'none'
})
return false return false
} }
if (end - start > 24 * 60 * 60 * 1000) { if (end - start > 24 * 60 * 60 * 1000) {
uni.showToast({ uni.showToast({ title: '会议时长不能超过24小时', icon: 'none' })
title: '会议时长不能超过24小时',
icon: 'none'
})
return false return false
} }
if (formData.advance !== undefined && (formData.advance < 0 || formData.advance > 60)) { if (formData.advance !== undefined && (formData.advance < 0 || formData.advance > 60)) {
uni.showToast({ uni.showToast({ title: '提前入会时间范围为0-60分钟', icon: 'none' })
title: '提前入会时间范围为0-60分钟',
icon: 'none'
})
return false return false
} }
if (formData.maxParticipants !== undefined && (formData.maxParticipants < 2 || formData.maxParticipants > 100)) { if (formData.maxParticipants !== undefined && (formData.maxParticipants < 2 || formData.maxParticipants > 100)) {
uni.showToast({ uni.showToast({ title: '参与人数范围为2-100人', icon: 'none' })
title: '参与人数范围为2-100人',
icon: 'none'
})
return false return false
} }
@@ -289,33 +299,24 @@ async function handleSubmit() {
return return
} }
try { if (submitting.value) return
submitting.value = true submitting.value = true
try {
const result = await workcaseChatAPI.createVideoMeeting(formData) const result = await workcaseChatAPI.createVideoMeeting(formData)
if (result.success && result.data) { if (result.success && result.data) {
uni.showToast({ uni.showToast({ title: '会议创建成功', icon: 'success' })
title: '会议创建成功',
icon: 'success'
})
// 延迟返回,让用户看到成功提示 // 延迟返回,让用户看到成功提示
setTimeout(() => { setTimeout(() => {
uni.navigateBack() uni.navigateBack()
}, 1500) }, 1500)
} else { } else {
uni.showToast({ uni.showToast({ title: result.message || '创建会议失败', icon: 'none' })
title: result.message || '创建会议失败',
icon: 'none'
})
} }
} catch (error) { } catch (error) {
console.error('创建会议失败:', error) console.error('创建会议失败:', error)
uni.showToast({ uni.showToast({ title: '创建会议失败,请重试', icon: 'none' })
title: '创建会议失败,请重试',
icon: 'none'
})
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -327,6 +328,6 @@ function handleCancel() {
} }
</script> </script>
<style scoped lang="scss"> <style lang="scss" scoped>
@import url('./MeetingCreate.scss') @import "./MeetingCreate.scss";
</style> </style>

View File

@@ -95,14 +95,14 @@
<!-- 设备铭牌 --> <!-- 设备铭牌 -->
<view class="form-item"> <view class="form-item">
<text class="form-label">设备铭牌</text> <text class="form-label">设备铭牌<text class="required" v-if="mode === 'create'">*</text></text>
<input v-if="mode === 'create'" class="form-input" v-model="workcase.deviceNamePlate" placeholder="请输入设备铭牌"/> <input v-if="mode === 'create'" class="form-input" v-model="workcase.deviceNamePlate" placeholder="请输入设备铭牌"/>
<text v-else class="form-value">{{ workcase.deviceNamePlate || '-' }}</text> <text v-else class="form-value">{{ workcase.deviceNamePlate || '-' }}</text>
</view> </view>
<!-- 铭牌照片 --> <!-- 铭牌照片 -->
<view class="form-item"> <view class="form-item">
<text class="form-label">铭牌照片<text class="required" v-if="mode === 'create'">*</text></text> <text class="form-label">铭牌照片</text>
<!-- 创建模式:上传铭牌 --> <!-- 创建模式:上传铭牌 -->
<view v-if="mode === 'create'" class="nameplate-upload"> <view v-if="mode === 'create'" class="nameplate-upload">
<view v-if="workcase.deviceNamePlateImg" class="nameplate-preview" @tap="previewNameplateImage"> <view v-if="workcase.deviceNamePlateImg" class="nameplate-preview" @tap="previewNameplateImage">
@@ -164,8 +164,8 @@
<view class="title-bar"></view> <view class="title-bar"></view>
<text class="title-text">处理记录</text> <text class="title-text">处理记录</text>
</view> </view>
<!-- 处理人记录处理过程 --> <!-- 处理人记录处理过程 - 仅在处理中状态显示 -->
<view v-if="isProcessor" class="add-process-section"> <view v-if="isProcessor && workcase.status === 'processing'" class="add-process-section">
<button class="add-process-btn" type="primary" size="mini" @tap="navigateToAddProcess"> <button class="add-process-btn" type="primary" size="mini" @tap="navigateToAddProcess">
添加处理记录 添加处理记录
</button> </button>
@@ -380,7 +380,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, reactive, computed } from 'vue' import { ref, onMounted, reactive, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import type { TbWorkcaseDTO, TbWorkcaseProcessDTO } from '@/types/workcase' import type { TbWorkcaseDTO, TbWorkcaseProcessDTO, WorkcaseProcessVO } from '@/types/workcase'
import type { CustomerServiceVO } from '@/types/workcase/chatRoom' import type { CustomerServiceVO } from '@/types/workcase/chatRoom'
import { workcaseAPI, fileAPI, workcaseChatAPI } from '@/api' import { workcaseAPI, fileAPI, workcaseChatAPI } from '@/api'
@@ -401,7 +401,7 @@ const userId = JSON.parse(uni.getStorageSync('loginDomain')).userInfo.userId
const workcase = reactive<TbWorkcaseDTO>({}) const workcase = reactive<TbWorkcaseDTO>({})
// 处理记录 // 处理记录
const processList = reactive<TbWorkcaseProcessDTO[]>([]) const processList = reactive<WorkcaseProcessVO[]>([])
// 文件信息缓存 // 文件信息缓存
const fileInfoCache = reactive<Map<string, any>>(new Map()) const fileInfoCache = reactive<Map<string, any>>(new Map())
@@ -457,6 +457,7 @@ onLoad((options: any) => {
mode.value = 'create' mode.value = 'create'
// create 模式必须从上一页带入 roomId前端先建 room 的策略) // create 模式必须从上一页带入 roomId前端先建 room 的策略)
const roomId = options.roomId || '' const roomId = options.roomId || ''
const deviceCodeParam = decodeURIComponent(options.deviceCode || '')
if (!roomId) { if (!roomId) {
uni.showToast({ title: '缺少roomId无法创建工单', icon: 'none' }) uni.showToast({ title: '缺少roomId无法创建工单', icon: 'none' })
// 直接退出,避免后续提交失败 // 直接退出,避免后续提交失败
@@ -485,6 +486,8 @@ onLoad((options: any) => {
phone, phone,
userId, userId,
roomId, roomId,
deviceCode: deviceCodeParam,
deviceNamePlate: deviceCodeParam,
device: '', device: '',
type: '', type: '',
address: '', address: '',
@@ -1102,8 +1105,8 @@ async function submitWorkcase() {
uni.showToast({ title: '请输入故障描述', icon: 'none' }) uni.showToast({ title: '请输入故障描述', icon: 'none' })
return return
} }
if (!workcase.deviceNamePlateImg) { if (!workcase.deviceNamePlate) {
uni.showToast({ title: '请上传设备铭牌照片', icon: 'none' }) uni.showToast({ title: '请输入设备铭牌', icon: 'none' })
return return
} }
if (!workcase.imgs || workcase.imgs.length === 0) { if (!workcase.imgs || workcase.imgs.length === 0) {
@@ -1236,15 +1239,15 @@ async function submitProcessRecord() {
submittingProcess.value = true submittingProcess.value = true
try { try {
const fileIds = processForm.files.map(f => f.fileId).join(',') const fileIds = processForm.files.map(f => f.fileId)
const params: TbWorkcaseProcessDTO = { const params: TbWorkcaseProcessDTO = {
workcaseId: workcase.workcaseId, workcaseId: workcase.workcaseId,
action: 'info', action: 'info',
message: processForm.message, message: processForm.message,
files: fileIds || undefined files: fileIds.length > 0 ? fileIds : undefined
} }
const res = await workcaseAPI.addWorkcaseProcess(params) const res = await workcaseAPI.createWorkcaseProcess(params)
if (res.success) { if (res.success) {
uni.showToast({ title: '处理记录添加成功', icon: 'success' }) uni.showToast({ title: '处理记录添加成功', icon: 'success' })
closeAddProcessDialog() closeAddProcessDialog()

View File

@@ -12,27 +12,27 @@ export interface DifyFileInfo {
/** 文件扩展名 */ /** 文件扩展名 */
extension?: string extension?: string
/** 文件MIME类型 */ /** 文件MIME类型 */
mimeType?: string mime_type?: string
/** 上传人ID */ /** 上传人ID */
createdBy?: string created_by?: string
/** 上传时间(时间戳) */ /** 上传时间(时间戳) */
createdAt?: number created_at?: number
/** 预览URL */ /** 预览URL */
previewUrl?: string preview_url?: string
/** 源文件URL */ /** 源文件URL */
sourceUrl?: string source_url?: string
/** 文件类型image、document、audio、video、file */ /** 文件类型image、document、audio、video、file */
type?: string type?: string
/** 传输方式remote_url、local_file */ /** 传输方式remote_url、local_file */
transferMethod?: string transfer_method?: string
/** 文件URL或ID */ /** 文件URL或ID */
url?: string url?: string
/** 本地文件上传ID */ /** 本地文件上传ID */
uploadFileId?: string upload_file_id?: string
/** 系统文件ID */ /** 系统文件ID */
sysFileId?: string sys_file_id?: string
/** 文件路径(从系统文件表获取) */ /** 文件路径(从系统文件表获取) */
filePath?: string file_path?: string
} }
/** /**
@@ -87,6 +87,7 @@ export interface ChatPrepareData {
userId?: string userId?: string
/** 用户类型false=来客true=员工) */ /** 用户类型false=来客true=员工) */
userType?: boolean userType?: boolean
service?: string
} }
/** /**
@@ -125,23 +126,6 @@ export interface CreateChatParam {
title?: string title?: string
} }
/**
* 准备流式对话参数
*/
export interface PrepareChatParam {
/** 对话ID必传 */
chatId: string
/** 用户问题(必传) */
query: string
/** 智能体ID */
agentId: string
userType: boolean
/** 用户ID */
userId?: string
/** 用户类型 */
/** 文件列表 */
files?: DifyFileInfo[]
}
/** /**
* 停止对话参数 * 停止对话参数

View File

@@ -14,6 +14,8 @@ export interface TbChatRoomDTO extends BaseDTO {
status?: string status?: string
guestId?: string guestId?: string
guestName?: string guestName?: string
deviceCode?: string
commentLevel?: number
aiSessionId?: string aiSessionId?: string
currentAgentId?: string currentAgentId?: string
agentCount?: number agentCount?: number
@@ -164,6 +166,8 @@ export interface ChatRoomVO extends BaseVO {
status?: string status?: string
guestId?: string guestId?: string
guestName?: string guestName?: string
commentLevel?: string
deviceCode?: string
aiSessionId?: string aiSessionId?: string
currentAgentId?: string currentAgentId?: string
currentAgentName?: string currentAgentName?: string

View File

@@ -1,4 +1,4 @@
import type { BaseDTO } from '../base' import type { BaseDTO, BaseVO } from '../base'
/** /**
* 工单表对象 * 工单表对象
@@ -74,3 +74,23 @@ export interface TbWorkcaseDeviceDTO extends BaseDTO {
/** 文件根ID */ /** 文件根ID */
fileRootId?: string fileRootId?: string
} }
export interface WorkcaseProcessVO extends BaseVO {
/** 工单ID */
workcaseId?: string
/** 过程ID */
processId?: string
/** 动作 info记录assign指派redeploy转派repeal撤销finish完成 */
action?: 'info' | 'assign' | 'redeploy' | 'repeal' | 'finish'
/** 消息 */
message?: string
/** 携带文件列表 */
files?: string[]
/** 处理人(指派、转派专属) */
processor?: string
processorName?: string
/** 创建时间 */
createTime?: string
/** 更新时间 */
updateTime?: string
}

4
修改点.md Normal file
View File

@@ -0,0 +1,4 @@
1. createTableWorkcase.sql 修改了tb_chat_room 增加了device_code字段。修改相关dto\vo\xml。
2. WorkcaseChatController.java 修改创建聊天室的接口增加了deviceCode字段必传。
3. 修改workcase/types/workcase/chatRoom.ts里的dto和vo。修改workcase_wechat/types/workcase/chatRoom.ts的dto和vo
4. 修改workcase_wechat/pages/index/index.uvue。新增const deviceCode = ref('');只有这个有值时,才让用户创建聊天室和工单(工单自动填入表单),否则弹窗让用户填写