知识库历史文件

This commit is contained in:
2025-12-20 17:12:42 +08:00
parent dfd9bb8b95
commit 62850717eb
59 changed files with 2351 additions and 276 deletions

View File

@@ -243,13 +243,13 @@ public class KnowledgeController {
/**
* 获取文件历史版本获取fileRootId下所有version
* @param fileRootId 文件id
* @param fileRootId 文件根ID
* @author yslg
* @since 2025-12-18
*/
@PreAuthorize("hasAuthority('ai:knowledge:file:view')")
@GetMapping("/file/{fileId}/history")
public ResultDomain<TbKnowledgeFile> getFileHistory(@PathVariable("fileId") @NotBlank String fileRootId) {
@GetMapping("/file/{fileRootId}/history")
public ResultDomain<KnowledgeFileVO> getFileHistory(@PathVariable("fileRootId") @NotBlank String fileRootId) {
logger.info("获取文件历史: fileRootId={}", fileRootId);
return knowledgeService.getKnowledgeFileHistory(fileRootId);
}

View File

@@ -76,4 +76,14 @@ public interface TbKnowledgeFileMapper {
@Param("knowledgeId") String knowledgeId,
@Param("fileRootId") String fileRootId
);
/**
* 根据文件根ID查询最大版本的文件
*/
TbKnowledgeFile selectLatestVersionFile(@Param("fileRootId") String fileRootId);
/**
* 根据文件根ID查询所有版本包含文件详细信息
*/
List<KnowledgeFileVO> selectFileVersionsWithDetail(@Param("fileRootId") String fileRootId);
}

View File

@@ -55,7 +55,7 @@ public class KnowledgeServiceImpl implements KnowledgeService {
@Autowired
private DifyApiClient difyApiClient;
@DubboReference(version = "1.0.0", group = "file", timeout = 30000)
@DubboReference(version = "1.0.0", group = "file", timeout = 30000, retries = 0)
private FileService fileService;
@Autowired
@@ -615,14 +615,22 @@ public class KnowledgeServiceImpl implements KnowledgeService {
return ResultDomain.failure("知识库未关联Dify");
}
// 3. 获取旧版本
List<TbKnowledgeFile> oldVersions = knowledgeFileMapper.selectFileVersions(fileRootId);
if (oldVersions == null || oldVersions.isEmpty()) {
// 3. 获取最大版本的旧文件
TbKnowledgeFile latestOldFile = knowledgeFileMapper.selectLatestVersionFile(fileRootId);
if (latestOldFile == null) {
return ResultDomain.failure("原文件不存在");
}
// 4. 上传新版本到minio
ResultDomain<TbSysFileDTO> fileResult = fileService.uploadFileVersion(file, "knowledge", knowledgeId, fileRootId);
// 4. 上传新版本到minio(使用字节数组避免 Dubbo 序列化问题)
byte[] fileBytes;
try {
fileBytes = file.getBytes();
} catch (java.io.IOException e) {
logger.error("读取文件字节失败", e);
return ResultDomain.failure("读取文件字节失败: " + e.getMessage());
}
ResultDomain<TbSysFileDTO> fileResult = fileService.uploadFileBytesVersion(
fileBytes, file.getOriginalFilename(), file.getContentType(), "knowledge", knowledgeId, fileRootId);
if (!fileResult.getSuccess() || fileResult.getData() == null) {
return ResultDomain.failure("上传新版本文件失败: " + fileResult.getMessage());
}
@@ -631,11 +639,9 @@ public class KnowledgeServiceImpl implements KnowledgeService {
int newVersion = sysFile.getVersion();
logger.info("上传新版本到minio成功: fileId={}, version={}", newFileId, newVersion);
// 5. 删除Dify旧文档
for (TbKnowledgeFile oldFile : oldVersions) {
if (StringUtils.hasText(oldFile.getDifyFileId())) {
aiFileUploadService.deleteFileFromDify(knowledge.getDifyDatasetId(), oldFile.getDifyFileId());
}
// 5. 删除Dify最大版本的旧文档
if (StringUtils.hasText(latestOldFile.getDifyFileId())) {
aiFileUploadService.deleteFileFromDify(knowledge.getDifyDatasetId(), latestOldFile.getDifyFileId());
}
// 6. 上传新文件到Dify
@@ -734,17 +740,19 @@ public class KnowledgeServiceImpl implements KnowledgeService {
/**
* @description 获取文件历史版本
* @param fileRootId 文件根ID
* @return ResultDomain<TbKnowledgeFile> 文件历史版本列表
* @return ResultDomain<KnowledgeFileVO> 文件历史版本列表dataList
* @author yslg
* @since 2025-12-18
*/
@Override
public ResultDomain<TbKnowledgeFile> getKnowledgeFileHistory(String fileRootId) {
public ResultDomain<KnowledgeFileVO> getKnowledgeFileHistory(String fileRootId) {
if (!StringUtils.hasText(fileRootId)) {
return ResultDomain.failure("文件根ID不能为空");
}
List<TbKnowledgeFile> versions = knowledgeFileMapper.selectFileVersions(fileRootId);
return ResultDomain.success("查询成功", versions);
List<KnowledgeFileVO> versions = knowledgeFileMapper.selectFileVersionsWithDetail(fileRootId);
ResultDomain<KnowledgeFileVO> result = ResultDomain.success("查询成功");
result.setDataList(versions);
return result;
}
}

View File

@@ -33,6 +33,7 @@
<result column="file_url" property="fileUrl" jdbcType="VARCHAR"/>
<result column="file_extension" property="fileExtension" jdbcType="VARCHAR"/>
<result column="file_md5_hash" property="fileMd5Hash" jdbcType="VARCHAR"/>
<result column="uploader_name" property="uploaderName" jdbcType="VARCHAR"/>
</resultMap>
<sql id="Base_Column_List">
@@ -83,9 +84,17 @@
f.mime_type as file_mime_type,
f.url as file_url,
f.extension as file_extension,
f.md5_hash as file_md5_hash
f.md5_hash as file_md5_hash,
ui.username as uploader_name
FROM ai.tb_knowledge_file kf
INNER JOIN (
SELECT file_root_id, MAX(version) as max_version
FROM ai.tb_knowledge_file
WHERE knowledge_id = #{knowledgeId} AND deleted = false
GROUP BY file_root_id
) latest ON kf.file_root_id = latest.file_root_id AND kf.version = latest.max_version
LEFT JOIN file.tb_sys_file f ON kf.file_id = f.file_id AND f.deleted = false
LEFT JOIN sys.tb_sys_user_info ui ON f.uploader = ui.user_id AND ui.deleted = false
WHERE kf.knowledge_id = #{knowledgeId} AND kf.deleted = false
ORDER BY kf.create_time DESC
</select>
@@ -107,16 +116,24 @@
f.mime_type as file_mime_type,
f.url as file_url,
f.extension as file_extension,
f.md5_hash as file_md5_hash
f.md5_hash as file_md5_hash,
ui.username as uploader_name
FROM ai.tb_knowledge_file kf
INNER JOIN (
SELECT file_root_id, MAX(version) as max_version
FROM ai.tb_knowledge_file
WHERE knowledge_id = #{knowledgeId} AND deleted = false
GROUP BY file_root_id
) latest ON kf.file_root_id = latest.file_root_id AND kf.version = latest.max_version
LEFT JOIN file.tb_sys_file f ON kf.file_id = f.file_id AND f.deleted = false
LEFT JOIN sys.tb_sys_user_info ui ON f.uploader = ui.user_id AND ui.deleted = false
WHERE kf.knowledge_id = #{knowledgeId} AND kf.deleted = false
ORDER BY kf.create_time DESC
LIMIT #{pageParam.pageSize} OFFSET #{pageParam.offset}
</select>
<select id="countFiles" resultType="long">
SELECT COUNT(*)
SELECT COUNT(DISTINCT file_root_id)
FROM ai.tb_knowledge_file
WHERE knowledge_id = #{knowledgeId} AND deleted = false
</select>
@@ -133,4 +150,31 @@
FROM ai.tb_knowledge_file
WHERE knowledge_id = #{knowledgeId} AND file_root_id = #{fileRootId}
</select>
<select id="selectLatestVersionFile" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM ai.tb_knowledge_file
WHERE file_root_id = #{fileRootId} AND deleted = false
ORDER BY version DESC
LIMIT 1
</select>
<select id="selectFileVersionsWithDetail" resultMap="KnowledgeFileVOResultMap">
SELECT
kf.optsn, kf.knowledge_id, kf.file_root_id, kf.file_id, kf.dify_file_id, kf.version,
kf.create_time, kf.update_time, kf.delete_time, kf.deleted,
f.name as file_name,
f.path as file_path,
f.size as file_size,
f.mime_type as file_mime_type,
f.url as file_url,
f.extension as file_extension,
f.md5_hash as file_md5_hash,
ui.username as uploader_name
FROM ai.tb_knowledge_file kf
LEFT JOIN file.tb_sys_file f ON kf.file_id = f.file_id AND f.deleted = false
LEFT JOIN sys.tb_sys_user_info ui ON f.uploader = ui.user_id AND ui.deleted = false
WHERE kf.file_root_id = #{fileRootId} AND kf.deleted = false
ORDER BY kf.version DESC
</select>
</mapper>

View File

@@ -162,10 +162,10 @@ public interface KnowledgeService {
/**
* @description 获取文件历史版本
* @param fileRootId 文件根ID
* @return ResultDomain<TbKnowledgeFile> 文件历史版本列表
* @return ResultDomain<KnowledgeFileVO> 文件历史版本列表dataList
* @author yslg
* @since 2025-12-18
*/
ResultDomain<TbKnowledgeFile> getKnowledgeFileHistory(String fileRootId);
ResultDomain<KnowledgeFileVO> getKnowledgeFileHistory(String fileRootId);
}

View File

@@ -57,4 +57,7 @@ public class KnowledgeFileVO extends BaseVO {
@Schema(description = "文件MD5值")
private String fileMd5Hash;
@Schema(description = "上传人员名称")
private String uploaderName;
}

View File

@@ -108,4 +108,47 @@ public interface FileService {
*/
ResultDomain<TbSysFileDTO> uploadFileBytes(byte[] fileBytes, String fileName, String contentType, String module, String businessId);
/**
* @description 通过字节数组上传文件支持直接指定上传者用于未登录用户场景如AIChat
* @param fileBytes 文件字节数组
* @param fileName 文件名
* @param contentType 文件类型
* @param module 所属模块
* @param businessId 业务ID
* @param uploaderUserId 上传者用户ID可为null
* @return ResultDomain<TbSysFileDTO> 上传结果
* @author yslg
* @since 2025-12-20
*/
ResultDomain<TbSysFileDTO> uploadFileBytesWithUser(byte[] fileBytes, String fileName, String contentType, String module, String businessId, String uploaderUserId);
/**
* @description 通过字节数组上传新版本文件(用于跨模块 Dubbo 调用的文件版本更新)
* @param fileBytes 文件字节数组
* @param fileName 文件名
* @param contentType 文件类型
* @param module 所属模块
* @param businessId 业务ID
* @param fileRootId 文件根ID多版本一致
* @return ResultDomain<TbSysFileDTO> 上传结果,包含新版本文件信息
* @author yslg
* @since 2025-12-20
*/
ResultDomain<TbSysFileDTO> uploadFileBytesVersion(byte[] fileBytes, String fileName, String contentType, String module, String businessId, String fileRootId);
/**
* @description 通过字节数组上传新版本文件(支持直接指定上传者)
* @param fileBytes 文件字节数组
* @param fileName 文件名
* @param contentType 文件类型
* @param module 所属模块
* @param businessId 业务ID
* @param fileRootId 文件根ID多版本一致
* @param uploaderUserId 上传者用户ID可为null
* @return ResultDomain<TbSysFileDTO> 上传结果,包含新版本文件信息
* @author yslg
* @since 2025-12-20
*/
ResultDomain<TbSysFileDTO> uploadFileBytesVersionWithUser(byte[] fileBytes, String fileName, String contentType, String module, String businessId, String fileRootId, String uploaderUserId);
}

View File

@@ -34,10 +34,10 @@ public class AuthController {
@Autowired
private AuthService authService;
@DubboReference(version = "1.0.0", group = "system", timeout = 5000, check = false)
@DubboReference(version = "1.0.0", group = "system", timeout = 5000, check = false, retries = 0)
private SysUserService userService;
@DubboReference(version = "1.0.0", group = "message", timeout = 5000, check = false)
@DubboReference(version = "1.0.0", group = "message", timeout = 5000, check = false, retries = 0)
private MessageService messageService;
@Autowired

View File

@@ -59,10 +59,10 @@ public class AuthServiceImpl implements AuthService{
private RedisService redisService;
@DubboReference(version = "1.0.0", group = "system", timeout = 5000, check = false)
@DubboReference(version = "1.0.0", group = "system", timeout = 5000, check = false, retries = 0)
private SysUserService userService;
@DubboReference(version = "1.0.0", group = "system", timeout = 5000, check = false)
@DubboReference(version = "1.0.0", group = "system", timeout = 5000, check = false, retries = 0)
private ModulePermissionService modulePermissionService;
@Autowired

View File

@@ -84,17 +84,28 @@ public class LoginUtil {
}
/**
* 从请求头获取Token
* 从请求头或Dubbo RpcContext获取Token
*/
public static String getToken() {
// 1. 优先从HTTP请求头获取正常Web请求
HttpServletRequest request = getRequest();
if (request == null) {
return null;
if (request != null) {
String authHeader = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(authHeader) && authHeader.startsWith(BEARER_PREFIX)) {
return authHeader.substring(BEARER_PREFIX.length());
}
}
String authHeader = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(authHeader) && authHeader.startsWith(BEARER_PREFIX)) {
return authHeader.substring(BEARER_PREFIX.length());
// 2. 从Dubbo Provider ThreadLocal获取跨服务调用
try {
Class<?> filterClass = Class.forName("org.xyzh.common.auth.filter.DubboProviderContextFilter");
ThreadLocal<String> tokenHolder = (ThreadLocal<String>) filterClass.getField("TOKEN_HOLDER").get(null);
String token = tokenHolder.get();
if (StringUtils.hasText(token)) {
return token;
}
} catch (Exception e) {
// Dubbo Filter不存在或未加载忽略
}
return null;

View File

@@ -2,6 +2,10 @@ package org.xyzh.common.vo;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
import org.xyzh.common.dto.OrderField;
import com.alibaba.fastjson2.annotation.JSONField;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -54,5 +58,17 @@ public class BaseVO implements Serializable {
@Schema(description = "是否已删除", defaultValue = "false")
private Boolean deleted = false;
@Schema(description = "数量限制")
private Integer limit;
@Schema(description = "开始时间")
private Date startTime;
@Schema(description = "结束时间")
private Date endTime;
@Schema(description = "排序字段")
private List<OrderField> orderFields;
}

View File

@@ -21,7 +21,7 @@ public class WeChatKefuInit {
private static final Logger logger = LoggerFactory.getLogger(WeChatKefuInit.class);
@DubboReference(version = "1.0.0", group = "system", check = false)
@DubboReference(version = "1.0.0", group = "system", check = false, retries = 0)
private SysConfigService sysConfigService;
private static WeChatKefuConfig weChatConfig;

View File

@@ -29,7 +29,7 @@ public class KefuAccessTokenManager {
private final RestTemplate restTemplate = new RestTemplate();
@DubboReference(version = "1.0.0", group = "system", check = false)
@DubboReference(version = "1.0.0", group = "system", check = false, retries = 0)
private SysConfigService sysConfigService;
private String corpId;

View File

@@ -1,5 +1,8 @@
package org.xyzh.file.controller;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
@@ -94,9 +97,10 @@ public class FileController {
ResultDomain<TbSysFileDTO> fileInfo = fileService.getFileById(fileId);
String filename = fileInfo.getData() != null ? fileInfo.getData().getName() : "download";
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + encodedFilename + "\"; filename*=UTF-8''" + encodedFilename)
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(result.getData());
}

View File

@@ -9,6 +9,8 @@ import org.springframework.util.DigestUtils;
import org.springframework.web.multipart.MultipartFile;
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.core.domain.LoginDomain;
import org.xyzh.common.core.domain.ResultDomain;
import org.xyzh.file.config.MinioConfig;
import org.xyzh.file.mapper.FileMapper;
@@ -53,65 +55,7 @@ public class FileServiceImpl implements FileService {
if (file == null || file.isEmpty()) {
return ResultDomain.failure("文件不能为空");
}
// 生成文件信息
String originalFilename = file.getOriginalFilename();
String extension = getFileExtension(originalFilename);
String contentType = file.getContentType();
long size = file.getSize();
// 生成唯一的对象名称
String objectName = generateObjectName(originalFilename, module);
// 计算文件MD5
String md5Hash = calculateMD5(file.getBytes());
// 上传到MinIO
String bucketName = minioConfig.getBucketName();
boolean uploadSuccess = minioUtil.uploadFile(
bucketName,
objectName,
file.getInputStream(),
size,
contentType
);
if (!uploadSuccess) {
return ResultDomain.failure("文件上传到MinIO失败");
}
// 保存到数据库
TbSysFileDTO fileDTO = new TbSysFileDTO();
String fileId = UUID.randomUUID().toString();
fileDTO.setOptsn(UUID.randomUUID().toString());
fileDTO.setFileId(fileId);
fileDTO.setName(originalFilename);
fileDTO.setPath(objectName);
fileDTO.setSize(size);
fileDTO.setType(extension);
fileDTO.setStorageType("MINIO");
fileDTO.setMimeType(contentType);
// URL 设为 NULL前端通过后端接口 /api/file/download/{fileId} 下载
fileDTO.setUrl(null);
fileDTO.setStatus("NORMAL");
fileDTO.setModule(module);
fileDTO.setBusinessId(businessId);
fileDTO.setObjectName(objectName);
fileDTO.setBucketName(bucketName);
fileDTO.setMd5Hash(md5Hash);
fileDTO.setExtension(extension);
fileDTO.setCreateTime(new java.util.Date());
int result = fileMapper.insertFile(fileDTO);
if (result <= 0) {
// 如果数据库保存失败删除MinIO中的文件
minioUtil.deleteFile(bucketName, objectName);
return ResultDomain.failure("文件信息保存失败");
}
logger.info("文件上传成功: {}, 大小: {} bytes", originalFilename, size);
return ResultDomain.success("文件上传成功", fileDTO);
return uploadFileBytesWithUser(file.getBytes(), file.getOriginalFilename(), file.getContentType(), module, businessId, getCurrentUserId());
} catch (Exception e) {
logger.error("文件上传失败", e);
return ResultDomain.failure("文件上传失败: " + e.getMessage());
@@ -283,16 +227,6 @@ public class FileServiceImpl implements FileService {
}
}
/**
* @description 上传新版本文件用于文件更新fileRootId保持一致version递增
* @param file 文件对象
* @param module 所属模块
* @param businessId 业务ID
* @param fileRootId 文件根ID多版本一致
* @return ResultDomain<TbSysFileDTO> 上传结果,包含新版本文件信息
* @author yslg
* @since 2025-12-18
*/
@Override
public ResultDomain<TbSysFileDTO> uploadFileVersion(MultipartFile file, String module, String businessId, String fileRootId) {
try {
@@ -302,96 +236,105 @@ public class FileServiceImpl implements FileService {
if (fileRootId == null || fileRootId.isEmpty()) {
return ResultDomain.failure("文件根ID不能为空");
}
return uploadFileBytesVersionWithUser(file.getBytes(), file.getOriginalFilename(), file.getContentType(), module, businessId, fileRootId, getCurrentUserId());
} catch (Exception e) {
logger.error("新版本文件上传失败", e);
return ResultDomain.failure("文件上传失败: " + e.getMessage());
}
}
@Override
public ResultDomain<TbSysFileDTO> uploadFileBytes(byte[] fileBytes, String fileName, String contentType, String module, String businessId) {
return uploadFileBytesWithUser(fileBytes, fileName, contentType, module, businessId, getCurrentUserId());
}
@Override
public ResultDomain<TbSysFileDTO> uploadFileBytesWithUser(byte[] fileBytes, String fileName, String contentType, String module, String businessId, String uploaderUserId) {
try {
if (fileBytes == null || fileBytes.length == 0) {
return ResultDomain.failure("文件不能为空");
}
String extension = getFileExtension(fileName);
long size = fileBytes.length;
String objectName = generateObjectName(fileName, module);
String md5Hash = calculateMD5(fileBytes);
String bucketName = minioConfig.getBucketName();
java.io.ByteArrayInputStream inputStream = new java.io.ByteArrayInputStream(fileBytes);
boolean uploadSuccess = minioUtil.uploadFile(bucketName, objectName, inputStream, size, contentType);
if (!uploadSuccess) {
return ResultDomain.failure("文件上传到MinIO失败");
}
TbSysFileDTO fileDTO = new TbSysFileDTO();
String fileId = UUID.randomUUID().toString().replace("-", "");
fileDTO.setOptsn(UUID.randomUUID().toString());
fileDTO.setFileId(fileId);
fileDTO.setName(fileName);
fileDTO.setPath(objectName);
fileDTO.setUrl(null);
fileDTO.setSize(size);
fileDTO.setMimeType(contentType);
fileDTO.setExtension(extension);
fileDTO.setMd5Hash(md5Hash);
fileDTO.setModule(module);
fileDTO.setBusinessId(businessId);
fileDTO.setStorageType("MINIO");
fileDTO.setObjectName(objectName);
fileDTO.setBucketName(bucketName);
fileDTO.setVersion(1);
fileDTO.setFileRootId(fileId);
fileDTO.setCreator(uploaderUserId);
fileDTO.setUploader(uploaderUserId);
fileDTO.setCreateTime(new java.util.Date());
int result = fileMapper.insertFile(fileDTO);
if (result <= 0) {
minioUtil.deleteFile(bucketName, objectName);
return ResultDomain.failure("文件信息保存失败");
}
logger.info("字节数组文件上传成功: {}, uploader: {}", fileName, uploaderUserId);
return ResultDomain.success("文件上传成功", fileDTO);
} catch (Exception e) {
logger.error("字节数组文件上传失败", e);
return ResultDomain.failure("文件上传失败: " + e.getMessage());
}
}
@Override
public ResultDomain<TbSysFileDTO> uploadFileBytesVersion(byte[] fileBytes, String fileName, String contentType, String module, String businessId, String fileRootId) {
return uploadFileBytesVersionWithUser(fileBytes, fileName, contentType, module, businessId, fileRootId, getCurrentUserId());
}
@Override
public ResultDomain<TbSysFileDTO> uploadFileBytesVersionWithUser(byte[] fileBytes, String fileName, String contentType, String module, String businessId, String fileRootId, String uploaderUserId) {
try {
if (fileBytes == null || fileBytes.length == 0) {
return ResultDomain.failure("文件不能为空");
}
if (fileRootId == null || fileRootId.isEmpty()) {
return ResultDomain.failure("文件根ID不能为空");
}
// 1. 获取当前最大版本号
Integer maxVersion = fileMapper.selectMaxVersionByFileRootId(fileRootId);
int newVersion = (maxVersion != null ? maxVersion : 0) + 1;
// 2. 生成文件信息
String originalFilename = file.getOriginalFilename();
String extension = getFileExtension(originalFilename);
String contentType = file.getContentType();
long size = file.getSize();
// 3. 生成唯一的对象名称
String objectName = generateObjectName(originalFilename, module);
// 4. 计算文件MD5
String md5Hash = calculateMD5(file.getBytes());
// 5. 上传到MinIO
String bucketName = minioConfig.getBucketName();
boolean uploadSuccess = minioUtil.uploadFile(
bucketName,
objectName,
file.getInputStream(),
size,
contentType
);
if (!uploadSuccess) {
return ResultDomain.failure("文件上传到MinIO失败");
}
// 6. 构建文件访问URL
String fileUrl = minioConfig.buildFileUrl(objectName);
// 7. 保存到数据库(新版本记录)
TbSysFileDTO fileDTO = new TbSysFileDTO();
fileDTO.setOptsn(UUID.randomUUID().toString());
fileDTO.setFileId(UUID.randomUUID().toString());
fileDTO.setFileRootId(fileRootId);
fileDTO.setVersion(newVersion);
fileDTO.setName(originalFilename);
fileDTO.setPath(objectName);
fileDTO.setSize(size);
fileDTO.setType(extension);
fileDTO.setStorageType("MINIO");
fileDTO.setMimeType(contentType);
fileDTO.setUrl(fileUrl);
fileDTO.setStatus("NORMAL");
fileDTO.setModule(module);
fileDTO.setBusinessId(businessId);
fileDTO.setObjectName(objectName);
fileDTO.setBucketName(bucketName);
fileDTO.setMd5Hash(md5Hash);
fileDTO.setExtension(extension);
fileDTO.setCreateTime(new java.util.Date());
int result = fileMapper.insertFile(fileDTO);
if (result <= 0) {
// 如果数据库保存失败删除MinIO中的文件
minioUtil.deleteFile(bucketName, objectName);
return ResultDomain.failure("文件信息保存失败");
}
logger.info("新版本文件上传成功: {}, version: {}, fileRootId: {}", originalFilename, newVersion, fileRootId);
return ResultDomain.success("文件上传成功", fileDTO);
} catch (Exception e) {
logger.error("新版本文件上传失败", e);
return ResultDomain.failure("文件上传失败: " + e.getMessage());
}
}
@Override
public ResultDomain<TbSysFileDTO> uploadFileBytes(byte[] fileBytes, String fileName, String contentType, String module, String businessId) {
try {
if (fileBytes == null || fileBytes.length == 0) {
return ResultDomain.failure("文件不能为空");
}
// 生成文件信息
String extension = getFileExtension(fileName);
long size = fileBytes.length;
// 生成唯一的对象名称
// 3. 生成唯一的对象名称
String objectName = generateObjectName(fileName, module);
// 计算文件MD5
// 4. 计算文件MD5
String md5Hash = calculateMD5(fileBytes);
// 上传到MinIO
// 5. 上传到MinIO
String bucketName = minioConfig.getBucketName();
java.io.ByteArrayInputStream inputStream = new java.io.ByteArrayInputStream(fileBytes);
boolean uploadSuccess = minioUtil.uploadFile(
@@ -405,41 +348,45 @@ public class FileServiceImpl implements FileService {
if (!uploadSuccess) {
return ResultDomain.failure("文件上传到MinIO失败");
}
// 6. 构建文件访问URL
String fileUrl = minioConfig.buildFileUrl(objectName);
// 创建文件DTO
// 7. 保存到数据库(新版本记录)
TbSysFileDTO fileDTO = new TbSysFileDTO();
String fileId = UUID.randomUUID().toString().replace("-", "");
fileDTO.setOptsn(UUID.randomUUID().toString());
fileDTO.setFileId(fileId);
fileDTO.setFileId(UUID.randomUUID().toString());
fileDTO.setFileRootId(fileRootId);
fileDTO.setVersion(newVersion);
fileDTO.setName(fileName);
fileDTO.setPath(objectName);
// URL 设为 NULL前端通过后端接口 /api/file/download/{fileId} 下载
fileDTO.setUrl(null);
fileDTO.setSize(size);
fileDTO.setType(extension);
fileDTO.setStorageType("MINIO");
fileDTO.setMimeType(contentType);
fileDTO.setExtension(extension);
fileDTO.setMd5Hash(md5Hash);
fileDTO.setUrl(fileUrl);
fileDTO.setStatus("NORMAL");
fileDTO.setModule(module);
fileDTO.setBusinessId(businessId);
fileDTO.setStorageType("MINIO");
fileDTO.setObjectName(objectName);
fileDTO.setBucketName(bucketName);
fileDTO.setVersion(1);
fileDTO.setFileRootId(fileId);
fileDTO.setMd5Hash(md5Hash);
fileDTO.setExtension(extension);
fileDTO.setCreator(uploaderUserId);
fileDTO.setUploader(uploaderUserId);
fileDTO.setCreateTime(new java.util.Date());
// 保存到数据库
int result = fileMapper.insertFile(fileDTO);
if (result <= 0) {
minioUtil.deleteFile(bucketName, objectName);
return ResultDomain.failure("文件信息保存失败");
}
logger.info("字节数组文件上传成功: {}", fileName);
logger.info("新版本文件上传成功(bytes): {}, version: {}, fileRootId: {}, uploader: {}", fileName, newVersion, fileRootId, uploaderUserId);
return ResultDomain.success("文件上传成功", fileDTO);
} catch (Exception e) {
logger.error("字节数组文件上传失败", e);
logger.error("新版本文件上传失败(bytes)", e);
return ResultDomain.failure("文件上传失败: " + e.getMessage());
}
}
@@ -471,4 +418,19 @@ public class FileServiceImpl implements FileService {
private String calculateMD5(byte[] data) {
return DigestUtils.md5DigestAsHex(data);
}
/**
* 获取当前登录用户ID支持未登录场景
*/
private String getCurrentUserId() {
try {
LoginDomain loginDomain = LoginUtil.getCurrentLogin();
if (loginDomain != null && loginDomain.getUser() != null) {
return loginDomain.getUser().getUserId();
}
} catch (Exception e) {
logger.debug("获取当前登录用户失败: {}", e.getMessage());
}
return null;
}
}

View File

@@ -62,6 +62,8 @@
<if test="bucketName != null">, bucket_name</if>
<if test="md5Hash != null">, md5_hash</if>
<if test="extension != null">, extension</if>
<if test="fileRootId != null">, file_root_id</if>
<if test="version != null">, version</if>
) VALUES (
<!-- 必填字段值 -->
#{optsn}, #{fileId}, #{name}, #{path}, #{size}
@@ -86,6 +88,8 @@
<if test="bucketName != null">, #{bucketName}</if>
<if test="md5Hash != null">, #{md5Hash}</if>
<if test="extension != null">, #{extension}</if>
<if test="fileRootId != null">, #{fileRootId}</if>
<if test="version != null">, #{version}</if>
)
</insert>

View File

@@ -27,7 +27,7 @@ public class DynamicConfigLoader implements ApplicationRunner {
private static final Logger logger = LoggerFactory.getLogger(DynamicConfigLoader.class);
@DubboReference(version = "1.0.0", group = "system", timeout = 5000, check = false)
@DubboReference(version = "1.0.0", group = "system", timeout = 5000, check = false, retries = 0)
private SysConfigService sysConfigService;
@Autowired(required = false)

View File

@@ -30,10 +30,10 @@ public class KnowledgeInit {
private static final String CATEGORY_INTERNAL = "internal";
private static final String CATEGORY_EXTERNAL = "external";
@DubboReference(version = "1.0.0", group = "ai", timeout = 30000)
@DubboReference(version = "1.0.0", group = "ai", timeout = 30000, retries = 0)
private KnowledgeService knowledgeService;
@DubboReference(version = "1.0.0", group = "system", timeout = 30000)
@DubboReference(version = "1.0.0", group = "system", timeout = 30000, retries = 0)
private SysConfigService sysConfigService;
@Bean

View File

@@ -33,7 +33,7 @@ public class WorkcaseChatServiceImpl implements WorkcaseChatService{
private static final String CHAT_COUNT_KEY_PREFIX = "workcase:chat:count:";
private static final int TRANSFER_HUMAN_THRESHOLD = 3;
@DubboReference(version = "1.0.0", group = "ai", check = false)
@DubboReference(version = "1.0.0", group = "ai", check = false, retries = 0)
private AgentChatService agentChatService;
@Autowired

View File

@@ -0,0 +1,855 @@
<template>
<div>
<el-dialog
v-model="visible"
title="文档分段管理"
width="1200px"
:close-on-click-modal="false"
class="segment-dialog"
>
<!-- 顶部操作栏 -->
<div class="top-actions">
<div class="action-buttons">
<el-button
type="success"
@click="showAddSegmentDialog = true"
size="default"
:disabled="!props.canWrite"
:title="props.canWrite ? '添加分段' : '无添加权限'"
>
添加分段
</el-button>
<el-button
type="primary"
@click="loadSegments(1)"
size="default"
>
刷新
</el-button>
</div>
</div>
<!-- 分段列表 -->
<div class="segment-list" v-loading="loading">
<div
v-for="segment in segments"
:key="segment.id"
class="segment-item"
>
<div class="segment-header">
<span class="segment-index">分段 {{ segment.position }}</span>
<span class="segment-info">
{{ segment.word_count }} · {{ segment.tokens }} tokens
</span>
<div class="segment-actions">
<!-- 启用开关 -->
<el-switch
:model-value="segment.enabled"
:active-text="segment.enabled ? '已启用' : '已禁用'"
:loading="segment._switching"
:disabled="!props.canWrite"
@change="handleToggleEnabled(segment, $event)"
style="--el-switch-on-color: #67C23A; margin-right: 12px;"
:title="props.canWrite ? '' : '无修改权限'"
/>
<el-tag
:type="getStatusType(segment.status)"
size="small"
style="margin-right: 8px;"
>
{{ getStatusText(segment.status) }}
</el-tag>
<!-- 编辑按钮 -->
<el-button
v-if="!editingSegmentIds.has(segment.id)"
type="primary"
size="small"
@click="startEdit(segment)"
:disabled="!props.canWrite"
:title="props.canWrite ? '编辑分段' : '无编辑权限'"
>
编辑
</el-button>
<!-- 删除按钮 -->
<el-button
v-if="!editingSegmentIds.has(segment.id)"
type="danger"
size="small"
@click="handleDeleteSegment(segment)"
:loading="segment._deleting"
:disabled="!props.canDelete"
:title="props.canDelete ? '删除分段' : '无删除权限'"
>
删除
</el-button>
<!-- 保存和取消按钮 -->
<template v-else>
<el-button
type="success"
size="small"
@click="saveEdit(segment)"
:loading="segment._saving"
>
保存
</el-button>
<el-button
size="small"
@click="cancelEdit(segment)"
>
取消
</el-button>
</template>
</div>
</div>
<!-- 分段内容显示或编辑 -->
<div class="segment-content">
<!-- 编辑模式 -->
<template v-if="editingSegmentIds.has(segment.id)">
<el-input
v-model="editingContents[segment.id]"
type="textarea"
:rows="8"
placeholder="请输入分段内容"
/>
</template>
<!-- 只读模式 -->
<template v-else>
<div class="segment-text">
{{ segment.content }}
</div>
</template>
</div>
<!-- 关键词标签 -->
<div class="segment-keywords" v-if="segment.keywords?.length">
<el-tag
v-for="keyword in segment.keywords"
:key="keyword"
size="small"
style="margin-right: 8px;"
>
{{ keyword }}
</el-tag>
</div>
<!-- 分段元数据 -->
<div class="segment-meta">
<span class="meta-item">
<el-icon><Clock /></el-icon>
创建时间: {{ formatTimestamp(segment.created_at) }}
</span>
<span class="meta-item" v-if="segment.completed_at">
<el-icon><Check /></el-icon>
完成时间: {{ formatTimestamp(segment.completed_at) }}
</span>
<span class="meta-item">
<el-icon><View /></el-icon>
命中次数: {{ segment.hit_count }}
</span>
</div>
</div>
<!-- 空状态 -->
<div v-if="!loading && segments.length === 0" class="empty-state">
<p>暂无分段数据</p>
</div>
</div>
<!-- 分页组件 -->
<div class="pagination-container" v-if="totalCount > 0">
<el-pagination
:current-page="currentPage"
:page-size="pageSize"
:page-sizes="[10, 15, 20, 50]"
:total="totalCount"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-dialog>
<!-- 添加分段对话框 -->
<el-dialog
v-model="showAddSegmentDialog"
title="添加分段"
width="700px"
:close-on-click-modal="false"
>
<el-form :model="newSegmentForm" label-width="80px">
<el-form-item label="分段内容" required>
<el-input
v-model="newSegmentForm.content"
type="textarea"
:rows="10"
placeholder="请输入分段内容"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddSegmentDialog = false">取消</el-button>
<el-button
type="primary"
@click="handleCreateSegment"
:loading="creatingSegment"
>
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Clock, Check, View, Loading } from '@element-plus/icons-vue';
import { documentSegmentApi } from '../../../../../apis/ai';
defineOptions({
name: 'DocumentSegmentDialog'
});
interface Props {
/** 是否显示对话框 */
modelValue: boolean;
/** Dify数据集ID */
datasetId: string;
/** Dify文档ID */
documentId: string;
/** 是否可写(修改分段) */
canWrite?: boolean;
/** 是否可删除(删除分段) */
canDelete?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits(['update:modelValue']);
// 对话框显示状态
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
});
// 数据状态
const loading = ref(false);
const segments = ref<any[]>([]);
const currentPage = ref(1);
const pageSize = ref(10);
const totalCount = ref(0); // API 返回的总分段数
// 编辑状态
const editingSegmentIds = ref<Set<string>>(new Set());
const editingContents = ref<Record<string, string>>({});
const originalContents = ref<Record<string, string>>({});
// 新增分段相关
const showAddSegmentDialog = ref(false);
const creatingSegment = ref(false);
const newSegmentForm = ref({
content: '',
keywords: [] as string[]
});
const keywordInputVisible = ref(false);
const keywordInputValue = ref('');
const keywordInputRef = ref<any>(null);
// 统计信息
const totalSegments = computed(() => totalCount.value); // 使用API返回的总数
const totalWords = computed(() =>
segments.value.reduce((sum, seg) => sum + (seg.word_count || 0), 0)
);
const totalTokens = computed(() =>
segments.value.reduce((sum, seg) => sum + (seg.tokens || 0), 0)
);
// 组件挂载时自动加载数据v-if 确保每次打开都会重新挂载)
onMounted(() => {
loadSegments();
});
/**
* 加载分段数据
* @param page 页码,默认为当前页
*/
async function loadSegments(page = currentPage.value) {
try {
loading.value = true;
// 调用Dify API获取分段列表支持分页
const result = await documentSegmentApi.getDocumentSegments(
props.datasetId,
props.documentId,
page,
pageSize.value
);
if (result.success && result.pageDomain) {
const responseData = result.pageDomain as any;
// 重新加载数据
segments.value = responseData.dataList || [];
// 更新分页信息
currentPage.value = responseData.pageParam?.pageNumber || 1;
totalCount.value = responseData.pageParam?.totalElements || 0;
} else {
segments.value = [];
ElMessage.error(result.message || '加载分段失败');
}
} catch (error: any) {
console.error('加载分段失败:', error);
ElMessage.error(error.message || '加载分段失败');
} finally {
loading.value = false;
}
}
/**
* 处理每页条数改变
*/
function handleSizeChange(size: number) {
pageSize.value = size;
loadSegments(1);
}
/**
* 处理页码改变
*/
function handleCurrentChange(page: number) {
loadSegments(page);
}
/**
* 切换分段启用状态
*/
async function handleToggleEnabled(segment: any, enabled: boolean) {
if (!props.datasetId || !props.documentId || !segment.id) {
ElMessage.error('分段信息不完整');
return;
}
try {
(segment as any)._switching = true;
const result = await documentSegmentApi.updateSegment(
props.datasetId,
props.documentId,
segment.id,
{ enabled }
);
if (result.success) {
segment.enabled = enabled;
ElMessage.success(enabled ? '已启用分段' : '已禁用分段');
} else {
ElMessage.error(result.message || '更新失败');
}
} catch (error: any) {
console.error('更新分段状态失败:', error);
ElMessage.error('更新失败');
} finally {
(segment as any)._switching = false;
}
}
/**
* 开始编辑分段
*/
function startEdit(segment: any) {
editingSegmentIds.value.add(segment.id);
editingContents.value[segment.id] = segment.content;
originalContents.value[segment.id] = segment.content;
}
/**
* 保存编辑
*/
async function saveEdit(segment: any) {
const newContent = editingContents.value[segment.id];
if (!newContent || !newContent.trim()) {
ElMessage.warning('分段内容不能为空');
return;
}
if (newContent === originalContents.value[segment.id]) {
ElMessage.info('内容未修改');
cancelEdit(segment);
return;
}
try {
(segment as any)._saving = true;
const result = await documentSegmentApi.updateSegment(
props.datasetId,
props.documentId,
segment.id,
{ content: newContent }
);
if (result.success) {
segment.content = newContent;
editingSegmentIds.value.delete(segment.id);
delete editingContents.value[segment.id];
delete originalContents.value[segment.id];
ElMessage.success('保存成功');
// 重新加载数据以更新字数和 tokens
await loadSegments();
} else {
ElMessage.error(result.message || '保存失败');
}
} catch (error: any) {
console.error('保存分段失败:', error);
ElMessage.error('保存失败');
} finally {
(segment as any)._saving = false;
}
}
/**
* 取消编辑
*/
function cancelEdit(segment: any) {
editingSegmentIds.value.delete(segment.id);
delete editingContents.value[segment.id];
delete originalContents.value[segment.id];
}
/**
* 创建新分段
*/
async function handleCreateSegment() {
const content = newSegmentForm.value.content.trim();
if (!content) {
ElMessage.warning('分段内容不能为空');
return;
}
try {
creatingSegment.value = true;
const result = await documentSegmentApi.createSegment(
props.datasetId,
props.documentId,
[{
content
}]
);
if (result.success) {
ElMessage.success('添加成功');
showAddSegmentDialog.value = false;
// 重置表单
newSegmentForm.value = {
content: '',
keywords: []
};
// 重新加载数据
await loadSegments();
} else {
ElMessage.error(result.message || '添加失败');
}
} catch (error: any) {
console.error('创建分段失败:', error);
ElMessage.error('添加失败');
} finally {
creatingSegment.value = false;
}
}
/**
* 显示关键词输入框
*/
function showKeywordInput() {
keywordInputVisible.value = true;
nextTick(() => {
keywordInputRef.value?.focus();
});
}
/**
* 确认添加关键词
*/
function handleKeywordInputConfirm() {
const value = keywordInputValue.value.trim();
if (value && !newSegmentForm.value.keywords.includes(value)) {
newSegmentForm.value.keywords.push(value);
}
keywordInputVisible.value = false;
keywordInputValue.value = '';
}
/**
* 移除关键词
*/
function removeKeyword(keyword: string) {
const index = newSegmentForm.value.keywords.indexOf(keyword);
if (index > -1) {
newSegmentForm.value.keywords.splice(index, 1);
}
}
/**
* 删除分段
*/
async function handleDeleteSegment(segment: any) {
try {
await ElMessageBox.confirm(
`确定要删除分段 ${segment.position} 吗?此操作不可恢复。`,
'确认删除',
{
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
}
);
(segment as any)._deleting = true;
const result = await documentSegmentApi.deleteSegment(
props.datasetId,
props.documentId,
segment.id
);
if (result.success) {
ElMessage.success('删除成功');
// 重新加载数据
await loadSegments();
} else {
ElMessage.error(result.message || '删除失败');
}
} catch (error: any) {
if (error !== 'cancel') {
console.error('删除分段失败:', error);
ElMessage.error('删除失败');
}
} finally {
(segment as any)._deleting = false;
}
}
/**
* 获取状态类型
*/
function getStatusType(status: string): 'success' | 'info' | 'warning' | 'danger' {
const typeMap: Record<string, 'success' | 'info' | 'warning' | 'danger'> = {
'completed': 'success',
'indexing': 'warning',
'error': 'danger',
'paused': 'info'
};
return typeMap[status] || 'info';
}
/**
* 获取状态文本
*/
function getStatusText(status: string): string {
const textMap: Record<string, string> = {
'completed': '已完成',
'indexing': '索引中',
'error': '错误',
'paused': '已暂停'
};
return textMap[status] || status;
}
/**
* 格式化时间戳
*/
function formatTimestamp(timestamp: number): string {
if (!timestamp) return '-';
const date = new Date(timestamp * 1000);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
</script>
<style lang="scss" scoped>
.segment-dialog {
:deep(.el-dialog) {
border-radius: 14px;
.el-dialog__header {
padding: 24px 24px 16px;
border-bottom: 1px solid #F3F3F5;
.el-dialog__title {
font-size: 18px;
font-weight: 500;
color: #101828;
letter-spacing: -0.02em;
}
}
.el-dialog__body {
padding: 24px;
background: #FAFAFA;
}
.el-dialog__footer {
padding: 16px 24px;
border-top: 1px solid #F3F3F5;
}
}
}
.top-actions {
display: flex;
justify-content: end;
align-items: center;
margin-bottom: 10px;
padding: 8px 20px;
background: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 14px;
.stats-summary {
display: flex;
gap: 24px;
align-items: center;
.stat-text {
font-size: 14px;
color: #6A7282;
letter-spacing: -0.01em;
strong {
font-size: 16px;
color: #101828;
margin-left: 4px;
}
}
}
.action-buttons {
display: flex;
gap: 12px;
}
}
.segment-list {
max-height: 520px;
overflow-y: auto;
padding-right: 4px;
/* 自定义滚动条 */
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: #D1D5DB;
border-radius: 3px;
&:hover {
background: #9CA3AF;
}
}
.segment-item {
background: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 14px;
padding: 16px;
margin-bottom: 12px;
transition: all 0.2s;
&:hover {
border-color: #E7000B;
box-shadow: 0 4px 12px rgba(231, 0, 11, 0.12);
}
&:last-child {
margin-bottom: 0;
}
}
.segment-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
gap: 12px;
.segment-index {
font-weight: 500;
color: #101828;
font-size: 14px;
letter-spacing: -0.01em;
white-space: nowrap;
}
.segment-info {
flex: 1;
font-size: 12px;
color: #6A7282;
letter-spacing: -0.01em;
white-space: nowrap;
}
.segment-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
}
.segment-content {
margin-bottom: 12px;
.el-textarea {
textarea {
font-family: inherit;
font-size: 14px;
line-height: 1.6;
}
}
.segment-text {
padding: 12px;
background: #F9FAFB;
border: 1px solid transparent;
border-radius: 8px;
line-height: 1.6;
color: #4A5565;
font-size: 14px;
white-space: pre-wrap;
word-break: break-word;
letter-spacing: -0.01em;
}
}
.segment-keywords {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
:deep(.el-tag) {
background: #EFF6FF;
border: 1px solid transparent;
border-radius: 8px;
color: #1447E6;
font-size: 12px;
padding: 2px 8px;
height: 24px;
line-height: 20px;
}
}
.segment-meta {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 16px;
padding-top: 12px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
.meta-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #667085;
letter-spacing: -0.01em;
.el-icon {
font-size: 14px;
color: #9CA3AF;
}
}
}
.empty-state {
text-align: center;
padding: 80px 20px;
background: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 14px;
p {
font-size: 14px;
color: #6A7282;
margin: 0;
letter-spacing: -0.01em;
}
}
}
/* 添加分段对话框样式 */
:deep(.el-dialog__wrapper) {
.el-dialog {
border-radius: 14px;
.el-form-item__label {
font-size: 14px;
font-weight: 500;
color: #0A0A0A;
letter-spacing: -0.01em;
}
.el-input__wrapper {
background: #F3F3F5;
border: 1px solid transparent;
border-radius: 8px;
box-shadow: none;
&:hover {
border-color: rgba(231, 0, 11, 0.2);
}
&.is-focus {
border-color: #E7000B;
background: #FFFFFF;
}
}
.el-textarea__inner {
background: #F3F3F5;
border: 1px solid transparent;
border-radius: 8px;
box-shadow: none;
&:hover {
border-color: rgba(231, 0, 11, 0.2);
}
&:focus {
border-color: #E7000B;
background: #FFFFFF;
}
}
.el-select {
.el-input__wrapper {
background: #F3F3F5;
}
}
}
}
</style>

View File

@@ -6,15 +6,21 @@
// ========== 组件模块 ==========
declare module 'shared/components' {
export const FileUpload: any
export const FileHistory: any
export const DynamicFormItem: any
export const IframeView: any
}
declare module 'shared/components/FileUpload' {
declare module 'shared/components/file/FileUpload' {
import { DefineComponent } from 'vue'
const FileUpload: DefineComponent<{}, {}, any>
export default FileUpload
}
declare module 'shared/components/file/FileHistory' {
import { DefineComponent } from 'vue'
const FileHistory: DefineComponent<{}, {}, any>
export default FileHistory
}
declare module 'shared/components/DynamicFormItem' {
import { DefineComponent } from 'vue'
@@ -27,7 +33,17 @@ declare module 'shared/components/iframe/IframeView.vue' {
const IframeView: DefineComponent<{}, {}, any>
export default IframeView
}
declare module 'shared/components/ai/knowledge/DocumentSegment.vue' {
import { DefineComponent } from 'vue'
const DocumentSegment: DefineComponent<{}, {}, any>
export default DocumentSegment
}
declare module 'shared/components/ai/knowledge/DocumentDetail.vue' {
import { DefineComponent } from 'vue'
const DocumentDetail: DefineComponent<{}, {}, any>
export default DocumentDetail
}
// ========== API 模块 ==========
declare module 'shared/api' {
export const api: any

View File

@@ -68,21 +68,22 @@
import { ref, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import {
ChatDotRound,
Grid,
Connection,
Document,
Service,
DArrowLeft,
DArrowRight,
MessageCircle as ChatDotRound,
LayoutGrid as Grid,
Link as Connection,
FileText as Document,
Headphones as Service,
ChevronsLeft as DArrowLeft,
ChevronsRight as DArrowRight,
User,
Setting,
SwitchButton,
Refresh,
Back
} from '@element-plus/icons-vue'
Settings as Setting,
Power as SwitchButton,
RefreshCw as Refresh,
ArrowLeft as Back,
PanelLeftClose,
PanelLeftOpen
} from 'lucide-vue-next'
import { IframeView } from 'shared/components'
import { PanelLeftClose, PanelLeftOpen } from 'lucide-vue-next'
import { ElMessage } from 'element-plus'
import type { MenuItem } from 'shared/types'

View File

@@ -6,15 +6,21 @@
// ========== 组件模块 ==========
declare module 'shared/components' {
export const FileUpload: any
export const FileHistory: any
export const DynamicFormItem: any
export const IframeView: any
}
declare module 'shared/components/FileUpload' {
declare module 'shared/components/file/FileUpload' {
import { DefineComponent } from 'vue'
const FileUpload: DefineComponent<{}, {}, any>
export default FileUpload
}
declare module 'shared/components/file/FileHistory' {
import { DefineComponent } from 'vue'
const FileHistory: DefineComponent<{}, {}, any>
export default FileHistory
}
declare module 'shared/components/DynamicFormItem' {
import { DefineComponent } from 'vue'
@@ -28,6 +34,17 @@ declare module 'shared/components/iframe/IframeView.vue' {
export default IframeView
}
declare module 'shared/components/ai/knowledge/DocumentSegment.vue' {
import { DefineComponent } from 'vue'
const DocumentSegment: DefineComponent<{}, {}, any>
export default DocumentSegment
}
declare module 'shared/components/ai/knowledge/DocumentDetail.vue' {
import { DefineComponent } from 'vue'
const DocumentDetail: DefineComponent<{}, {}, any>
export default DocumentDetail
}
// ========== API 模块 ==========
declare module 'shared/api' {
export const api: any

View File

@@ -53,7 +53,7 @@
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { Search, Plus } from '@element-plus/icons-vue'
import { Search, Plus } from 'lucide-vue-next'
import { ElMessage } from 'element-plus'
import AgentCard from './components/AgentCard/AgentCard.vue'
import AgentEdit from './components/AgentEdit/AgentEdit.vue'

View File

@@ -115,9 +115,9 @@
<script lang="ts" setup>
import { ref, computed, watch } from 'vue'
import { Plus, Delete } from '@element-plus/icons-vue'
import { Plus, Trash2 as Delete } from 'lucide-vue-next'
import { ElMessage } from 'element-plus'
import FileUpload from 'shared/components/FileUpload'
import FileUpload from 'shared/components/file/FileUpload'
interface SuggestionCard {
text: string

View File

@@ -167,20 +167,20 @@
import { ref, computed, nextTick, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import {
ArrowDown,
ChevronDown as ArrowDown,
Paperclip,
Star,
Picture,
MoreFilled,
CameraFilled,
Microphone,
Promotion,
Image as Picture,
MoreHorizontal as MoreFilled,
Camera as CameraFilled,
Mic as Microphone,
Send as Promotion,
Plus,
Fold,
Expand,
ChatDotRound,
Delete
} from '@element-plus/icons-vue'
PanelLeftClose as Fold,
PanelLeftOpen as Expand,
MessageCircle as ChatDotRound,
Trash2 as Delete
} from 'lucide-vue-next'
import ChatDefault from './components/ChatDefault/ChatDefault.vue'
const route = useRoute()

View File

@@ -34,7 +34,7 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { OfficeBuilding, Warning, Cloudy } from '@element-plus/icons-vue'
import { Building2 as OfficeBuilding, AlertTriangle as Warning, Cloud as Cloudy } from 'lucide-vue-next'
interface Agent {
id: string

View File

@@ -55,6 +55,11 @@ export default defineConfig({
host: true,
cors: true,
open: '/', // 开发时自动打开到根路径
hmr: {
// 修复 base 路径导致的 WebSocket 连接问题
path: '/@vite/client',
port: 7001
},
proxy: {
'/api': {
target: 'http://localhost:8180',

View File

@@ -11,12 +11,12 @@
import { FileUpload, DynamicFormItem } from 'shared/components'
// 单独导入(推荐)
import FileUpload from 'shared/components/FileUpload'
import FileUpload from 'shared/components/file/FileUpload'
import DynamicFormItem from 'shared/components/DynamicFormItem'
```
**可用组件:**
- `shared/components/FileUpload` - 文件上传组件(支持 cover/dialog/content 三种模式)
- `shared/components/file/FileUpload` - 文件上传组件(支持 cover/dialog/content 三种模式)
- `shared/components/DynamicFormItem` - 动态表单项组件
---
@@ -100,7 +100,7 @@ import { authAPI } from 'shared/authAPI'
import { fileAPI } from 'shared/fileAPI'
// ✅ 新路径(推荐)
import FileUpload from 'shared/components/FileUpload'
import FileUpload from 'shared/components/file/FileUpload'
import { authAPI } from 'shared/api/auth'
import { fileAPI } from 'shared/api/file'
```
@@ -113,7 +113,7 @@ import { fileAPI } from 'shared/api/file'
```typescript
// ✅ 推荐:路径清晰,便于理解
import FileUpload from 'shared/components/FileUpload'
import FileUpload from 'shared/components/file/FileUpload'
import { authAPI } from 'shared/api/auth'
// ❌ 不推荐:路径模糊

View File

@@ -1,6 +1,6 @@
import { api } from '@/api/index'
import type { ResultDomain, PageRequest } from '@/types'
import type { TbKnowledge, TbKnowledgeFile, SegmentRequestBody, DocumentStatusRequestBody } from '@/types/ai'
import type { TbKnowledge, TbKnowledgeFile, KnowledgeFileVO, SegmentRequestBody, DocumentStatusRequestBody } from '@/types/ai'
/**
* @description AI知识库相关接口
@@ -171,8 +171,8 @@ export const aiKnowledgeAPI = {
* 获取文件历史版本
* @param fileRootId 文件根ID
*/
async getFileHistory(fileRootId: string): Promise<ResultDomain<TbKnowledgeFile>> {
const response = await api.get<TbKnowledgeFile>(`${this.baseUrl}/file/${fileRootId}/history`)
async getFileHistory(fileRootId: string): Promise<ResultDomain<KnowledgeFileVO[]>> {
const response = await api.get<KnowledgeFileVO[]>(`${this.baseUrl}/file/${fileRootId}/history`)
return response.data
},

View File

@@ -1 +1 @@
export {default as DocumentDetail} from './DocumentDetail/DocumentDetail.vue'
export * from './knowledge'

View File

@@ -0,0 +1,202 @@
.segment-dialog {
:deep(.el-dialog) {
border-radius: 14px;
.el-dialog__header {
padding: 24px 24px 16px;
border-bottom: 1px solid #F3F3F5;
.el-dialog__title {
font-size: 18px;
font-weight: 500;
color: #101828;
letter-spacing: -0.02em;
}
}
.el-dialog__body {
padding: 24px;
background: #FAFAFA;
}
.el-dialog__footer {
padding: 16px 24px;
border-top: 1px solid #F3F3F5;
}
}
}
.top-actions {
display: flex;
justify-content: flex-end;
align-items: center;
margin-bottom: 10px;
padding: 8px 20px;
background: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 14px;
.action-buttons {
display: flex;
gap: 12px;
}
}
.segment-list {
max-height: 520px;
overflow-y: auto;
padding-right: 4px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: #D1D5DB;
border-radius: 3px;
&:hover {
background: #9CA3AF;
}
}
.segment-item {
background: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 14px;
padding: 16px;
margin-bottom: 12px;
transition: all 0.2s;
&:hover {
border-color: #409eff;
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.12);
}
&:last-child {
margin-bottom: 0;
}
}
.segment-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
gap: 12px;
.segment-index {
font-weight: 500;
color: #101828;
font-size: 14px;
letter-spacing: -0.01em;
white-space: nowrap;
}
.segment-info {
flex: 1;
font-size: 12px;
color: #6A7282;
letter-spacing: -0.01em;
white-space: nowrap;
}
.segment-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
}
.segment-content {
margin-bottom: 12px;
.el-textarea {
textarea {
font-family: inherit;
font-size: 14px;
line-height: 1.6;
}
}
.segment-text {
padding: 12px;
background: #F9FAFB;
border: 1px solid transparent;
border-radius: 8px;
line-height: 1.6;
color: #4A5565;
font-size: 14px;
white-space: pre-wrap;
word-break: break-word;
letter-spacing: -0.01em;
}
}
.segment-keywords {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
:deep(.el-tag) {
background: #EFF6FF;
border: 1px solid transparent;
border-radius: 8px;
color: #1447E6;
font-size: 12px;
padding: 2px 8px;
height: 24px;
line-height: 20px;
}
}
.segment-meta {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 16px;
padding-top: 12px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
.meta-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #667085;
letter-spacing: -0.01em;
.el-icon {
font-size: 14px;
color: #9CA3AF;
}
}
}
.empty-state {
text-align: center;
padding: 80px 20px;
background: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 14px;
p {
font-size: 14px;
color: #6A7282;
margin: 0;
letter-spacing: -0.01em;
}
}
}
.pagination-container {
margin-top: 16px;
display: flex;
justify-content: center;
}

View File

@@ -0,0 +1,501 @@
<template>
<div>
<el-dialog
v-model="visible"
title="文档分段管理"
width="1200px"
:close-on-click-modal="false"
class="segment-dialog"
>
<!-- 顶部操作栏 -->
<div class="top-actions">
<div class="action-buttons">
<FileUpload
v-if="canUpdate"
mode="dialog"
title="版本更新"
button-text="版本更新"
button-type="warning"
accept=".pdf,.doc,.docx,.txt,.md"
:max-size="50 * 1024 * 1024"
:max-count="1"
:custom-upload="handleUpdateFile"
@upload-error="handleUpdateError"
/>
<el-button
type="success"
@click="showAddSegmentDialog = true"
size="default"
>
添加分段
</el-button>
<el-button
type="primary"
@click="loadSegments(1)"
size="default"
>
刷新
</el-button>
</div>
</div>
<!-- 分段列表 -->
<div class="segment-list" v-loading="loading">
<div
v-for="segment in segments"
:key="segment.id"
class="segment-item"
>
<div class="segment-header">
<span class="segment-index">分段 {{ segment.position }}</span>
<span class="segment-info">
{{ segment.word_count }} · {{ segment.tokens }} tokens
</span>
<div class="segment-actions">
<!-- 启用开关 -->
<el-switch
:model-value="segment.enabled"
:active-text="segment.enabled ? '已启用' : '已禁用'"
:loading="segment._switching"
@change="handleToggleEnabled(segment, $event)"
style="--el-switch-on-color: #67C23A; margin-right: 12px;"
/>
<el-tag
:type="getStatusType(segment.status)"
size="small"
style="margin-right: 8px;"
>
{{ getStatusText(segment.status) }}
</el-tag>
<!-- 编辑按钮 -->
<el-button
v-if="!editingSegmentIds.has(segment.id)"
type="primary"
size="small"
@click="startEdit(segment)"
>
编辑
</el-button>
<!-- 删除按钮 -->
<el-button
v-if="!editingSegmentIds.has(segment.id)"
type="danger"
size="small"
@click="handleDeleteSegment(segment)"
:loading="segment._deleting"
>
删除
</el-button>
<!-- 保存和取消按钮 -->
<template v-else>
<el-button
type="success"
size="small"
@click="saveEdit(segment)"
:loading="segment._saving"
>
保存
</el-button>
<el-button
size="small"
@click="cancelEdit(segment)"
>
取消
</el-button>
</template>
</div>
</div>
<!-- 分段内容显示或编辑 -->
<div class="segment-content">
<!-- 编辑模式 -->
<template v-if="editingSegmentIds.has(segment.id)">
<el-input
v-model="editingContents[segment.id]"
type="textarea"
:rows="8"
placeholder="请输入分段内容"
/>
</template>
<!-- 只读模式 -->
<template v-else>
<div class="segment-text">
{{ segment.content }}
</div>
</template>
</div>
<!-- 关键词标签 -->
<div class="segment-keywords" v-if="segment.keywords?.length">
<el-tag
v-for="keyword in segment.keywords"
:key="keyword"
size="small"
style="margin-right: 8px;"
>
{{ keyword }}
</el-tag>
</div>
<!-- 分段元数据 -->
<div class="segment-meta">
<span class="meta-item">
<el-icon><Clock /></el-icon>
创建时间: {{ formatTimestamp(segment.created_at) }}
</span>
<span class="meta-item" v-if="segment.completed_at">
<el-icon><Check /></el-icon>
完成时间: {{ formatTimestamp(segment.completed_at) }}
</span>
<span class="meta-item">
<el-icon><View /></el-icon>
命中次数: {{ segment.hit_count }}
</span>
</div>
</div>
<!-- 空状态 -->
<div v-if="!loading && segments.length === 0" class="empty-state">
<p>暂无分段数据</p>
</div>
</div>
<!-- 分页组件 -->
<div class="pagination-container" v-if="totalCount > 0">
<el-pagination
:current-page="currentPage"
:page-size="pageSize"
:page-sizes="[10, 15, 20, 50]"
:total="totalCount"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-dialog>
<!-- 添加分段对话框 -->
<el-dialog
v-model="showAddSegmentDialog"
title="添加分段"
width="700px"
:close-on-click-modal="false"
>
<el-form :model="newSegmentForm" label-width="80px">
<el-form-item label="分段内容" required>
<el-input
v-model="newSegmentForm.content"
type="textarea"
:rows="10"
placeholder="请输入分段内容"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddSegmentDialog = false">取消</el-button>
<el-button
type="primary"
@click="handleCreateSegment"
:loading="creatingSegment"
>
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Clock, Check, Eye as View } from 'lucide-vue-next'
import { aiKnowledgeAPI } from '@/api/ai'
import FileUpload from '@/components/file/fileupload/FileUpload.vue'
interface Props {
modelValue: boolean
datasetId: string
documentId: string
/** 知识库ID用于版本更新 */
knowledgeId?: string
/** 文件根ID用于版本更新 */
fileRootId?: string
}
const props = defineProps<Props>()
const emit = defineEmits(['update:modelValue', 'file-updated'])
// 是否可以更新文件
const canUpdate = computed(() => !!props.knowledgeId && !!props.fileRootId)
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const loading = ref(false)
const segments = ref<any[]>([])
const currentPage = ref(1)
const pageSize = ref(10)
const totalCount = ref(0)
const editingSegmentIds = ref<Set<string>>(new Set())
const editingContents = ref<Record<string, string>>({})
const originalContents = ref<Record<string, string>>({})
const showAddSegmentDialog = ref(false)
const creatingSegment = ref(false)
const newSegmentForm = ref({
content: '',
keywords: [] as string[]
})
// 版本更新相关
const handleUpdateFile = async (files: File[]) => {
if (!files.length) {
throw new Error('请选择要上传的文件')
}
if (!props.knowledgeId || !props.fileRootId) {
throw new Error('文件信息不完整,无法更新')
}
const result = await aiKnowledgeAPI.updateFile(
files[0],
props.knowledgeId,
props.fileRootId
)
if (result.success) {
ElMessage.success('文件更新成功')
emit('file-updated')
} else {
throw new Error(result.message || '更新失败')
}
}
const handleUpdateError = (error: string) => {
ElMessage.error(error)
}
watch(visible, (val) => {
if (val) {
loadSegments(1)
}
})
async function loadSegments(page = currentPage.value) {
if (!props.datasetId || !props.documentId) return
try {
loading.value = true
const result = await aiKnowledgeAPI.getDocumentSegments(
props.datasetId,
props.documentId
)
if (result.success && result.data) {
segments.value = result.data.data || []
totalCount.value = result.data.total || 0
currentPage.value = page
} else {
segments.value = []
ElMessage.error(result.message || '加载分段失败')
}
} catch (error: any) {
console.error('加载分段失败:', error)
ElMessage.error(error.message || '加载分段失败')
} finally {
loading.value = false
}
}
function handleSizeChange(size: number) {
pageSize.value = size
loadSegments(1)
}
function handleCurrentChange(page: number) {
loadSegments(page)
}
async function handleToggleEnabled(segment: any, enabled: boolean) {
if (!props.datasetId || !props.documentId || !segment.id) {
ElMessage.error('分段信息不完整')
return
}
try {
segment._switching = true
const result = await aiKnowledgeAPI.updateSegment(
props.datasetId,
props.documentId,
segment.id,
{ segment: { enabled } }
)
if (result.success) {
segment.enabled = enabled
ElMessage.success(enabled ? '已启用分段' : '已禁用分段')
} else {
ElMessage.error(result.message || '更新失败')
}
} catch (error: any) {
console.error('更新分段状态失败:', error)
ElMessage.error('更新失败')
} finally {
segment._switching = false
}
}
function startEdit(segment: any) {
editingSegmentIds.value.add(segment.id)
editingContents.value[segment.id] = segment.content
originalContents.value[segment.id] = segment.content
}
async function saveEdit(segment: any) {
const newContent = editingContents.value[segment.id]
if (!newContent || !newContent.trim()) {
ElMessage.warning('分段内容不能为空')
return
}
if (newContent === originalContents.value[segment.id]) {
ElMessage.info('内容未修改')
cancelEdit(segment)
return
}
try {
segment._saving = true
const result = await aiKnowledgeAPI.updateSegment(
props.datasetId,
props.documentId,
segment.id,
{ segment: { content: newContent } }
)
if (result.success) {
segment.content = newContent
editingSegmentIds.value.delete(segment.id)
delete editingContents.value[segment.id]
delete originalContents.value[segment.id]
ElMessage.success('保存成功')
await loadSegments()
} else {
ElMessage.error(result.message || '保存失败')
}
} catch (error: any) {
console.error('保存分段失败:', error)
ElMessage.error('保存失败')
} finally {
segment._saving = false
}
}
function cancelEdit(segment: any) {
editingSegmentIds.value.delete(segment.id)
delete editingContents.value[segment.id]
delete originalContents.value[segment.id]
}
async function handleCreateSegment() {
const content = newSegmentForm.value.content.trim()
if (!content) {
ElMessage.warning('分段内容不能为空')
return
}
try {
creatingSegment.value = true
const result = await aiKnowledgeAPI.createSegment(
props.datasetId,
props.documentId,
{ segments: [{ content }] }
)
if (result.success) {
ElMessage.success('添加成功')
showAddSegmentDialog.value = false
newSegmentForm.value = { content: '', keywords: [] }
await loadSegments()
} else {
ElMessage.error(result.message || '添加失败')
}
} catch (error: any) {
console.error('创建分段失败:', error)
ElMessage.error('添加失败')
} finally {
creatingSegment.value = false
}
}
async function handleDeleteSegment(segment: any) {
try {
await ElMessageBox.confirm(
`确定要删除分段 ${segment.position} 吗?此操作不可恢复。`,
'确认删除',
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }
)
segment._deleting = true
const result = await aiKnowledgeAPI.deleteSegment(
props.datasetId,
props.documentId,
segment.id
)
if (result.success) {
ElMessage.success('删除成功')
await loadSegments()
} else {
ElMessage.error(result.message || '删除失败')
}
} catch (error: any) {
if (error !== 'cancel') {
console.error('删除分段失败:', error)
ElMessage.error('删除失败')
}
} finally {
segment._deleting = false
}
}
function getStatusType(status: string): 'success' | 'info' | 'warning' | 'danger' {
const typeMap: Record<string, 'success' | 'info' | 'warning' | 'danger'> = {
'completed': 'success',
'indexing': 'warning',
'error': 'danger',
'paused': 'info'
}
return typeMap[status] || 'info'
}
function getStatusText(status: string): string {
const textMap: Record<string, string> = {
'completed': '已完成',
'indexing': '索引中',
'error': '错误',
'paused': '已暂停'
}
return textMap[status] || status
}
function formatTimestamp(timestamp: number): string {
if (!timestamp) return '-'
const date = new Date(timestamp * 1000)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
</script>
<style lang="scss" scoped>
@import url("./DocumentSegment.scss");
</style>

View File

@@ -0,0 +1,2 @@
export { default as DocumentSegment } from './documentSegment/DocumentSegment.vue'
export { default as DocumentDetail } from './documentDetail/DocumentDetail.vue'

View File

@@ -0,0 +1,33 @@
.file-history-dialog {
.el-dialog__body {
padding: 16px 20px;
}
.file-history-table {
width: 100%;
.file-name-cell {
display: flex;
align-items: center;
gap: 8px;
.file-icon {
color: #409eff;
font-size: 16px;
}
}
}
.action-buttons {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
// gap: 4px;
// width: 100%;
.el-button {
margin: 0 !important;
}
}
}

View File

@@ -0,0 +1,155 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="title"
:width="width"
class="file-history-dialog"
@close="handleClose"
>
<el-table
:data="dataList"
v-loading="loading"
class="file-history-table"
>
<el-table-column prop="fileName" label="文件名" min-width="200" fixed="left">
<template #default="{ row }">
<div class="file-name-cell">
<el-icon class="file-icon"><Document /></el-icon>
<span>{{ row.fileName }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="version" label="版本" width="80" align="center">
<template #default="{ row }">
<el-tag size="small">v{{ row.version }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="fileSize" label="文件大小" width="100" align="center">
<template #default="{ row }">
{{ formatFileSize(row.fileSize) }}
</template>
</el-table-column>
<el-table-column prop="uploaderName" label="上传人员" width="120" />
<el-table-column prop="createTime" label="上传时间" width="180" />
<el-table-column label="操作" :width="actionColumnWidth" align="center" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button
v-if="showPreview"
type="primary"
link
size="small"
@click="handlePreview(row)"
>
<el-icon><View /></el-icon>预览
</el-button>
<el-button
v-if="showDownload"
type="success"
link
size="small"
@click="handleDownload(row)"
>
<el-icon><Download /></el-icon>下载
</el-button>
<template v-for="action in customActions" :key="action.key">
<el-button
:type="action.type || 'primary'"
link
size="small"
@click="handleCustomAction(action.key, row)"
>
<el-icon v-if="action.icon"><component :is="action.icon" /></el-icon>
{{ action.label }}
</el-button>
</template>
</div>
</template>
</el-table-column>
</el-table>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from 'vue'
import { FileText as Document, Eye as View, Download } from 'lucide-vue-next'
import type { KnowledgeFileVO } from '@/types/ai'
export interface FileAction {
key: string
label: string
type?: 'primary' | 'success' | 'warning' | 'danger' | 'info'
icon?: any
}
interface Props {
modelValue: boolean
title?: string
width?: string
data?: KnowledgeFileVO[]
loading?: boolean
showPreview?: boolean
showDownload?: boolean
customActions?: FileAction[]
}
const props = withDefaults(defineProps<Props>(), {
title: '历史版本',
width: '800px',
data: () => [],
loading: false,
showPreview: true,
showDownload: true,
customActions: () => []
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'close'): void
(e: 'preview', file: KnowledgeFileVO): void
(e: 'download', file: KnowledgeFileVO): void
(e: 'action', key: string, file: KnowledgeFileVO): void
}>()
const dialogVisible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const dataList = computed(() => props.data)
const actionColumnWidth = computed(() => {
let width = 0
if (props.showPreview) width += 60
if (props.showDownload) width += 60
width += props.customActions.length * 70
return Math.max(width, 120)
})
const formatFileSize = (size?: number) => {
if (!size) return '-'
if (size < 1024) return size + ' B'
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'
return (size / 1024 / 1024).toFixed(1) + ' MB'
}
const handleClose = () => {
emit('close')
}
const handlePreview = (row: KnowledgeFileVO) => {
emit('preview', row)
}
const handleDownload = (row: KnowledgeFileVO) => {
emit('download', row)
}
const handleCustomAction = (key: string, row: KnowledgeFileVO) => {
emit('action', key, row)
}
</script>
<style lang="scss" scoped>
@import url('./FileHistory.scss');
</style>

View File

@@ -0,0 +1,2 @@
export { default as FileUpload } from './fileupload/FileUpload.vue'
export { default as FileHistory } from './fileHistory/FileHistory.vue'

View File

@@ -1 +0,0 @@
export { default as FileUpload } from './FileUpload.vue'

View File

@@ -1,7 +1,6 @@
export * from './fileupload'
export * from './base'
export * from './dynamicFormItem'
export * from './ai'
export * from './file'
// 通用视图组件
export { default as IframeView } from './iframe/IframeView.vue'

View File

@@ -1,4 +1,4 @@
import type { BaseDTO } from '../base'
import type { BaseDTO, BaseVO } from '../base'
/**
* 知识库配置
@@ -58,6 +58,41 @@ export interface TbKnowledgeFile extends BaseDTO {
version?: number
}
/**
* 知识库文件视图对象(关联文件信息)
*/
export interface KnowledgeFileVO extends BaseVO {
// TbKnowledgeFile 的字段
/** 知识库ID */
knowledgeId?: string
/** 文件ID */
fileId?: string
/** 文件根ID */
fileRootId?: string
/** Dify文件ID */
difyFileId?: string
/** 文件版本 */
version?: number
// TbSysFile 的字段
/** 文件名 */
fileName?: string
/** 文件路径 */
filePath?: string
/** 文件大小(字节) */
fileSize?: number
/** 文件MIME类型 */
fileMimeType?: string
/** 文件访问URL */
fileUrl?: string
/** 文件扩展名 */
fileExtension?: string
/** 文件MD5值 */
fileMd5Hash?: string
/** 上传人员名称 */
uploaderName?: string
}
/**
* 文档分段请求体
*/

View File

@@ -1,3 +1,13 @@
/**
* 排序字段
*/
export interface OrderField {
/** 排序字段 */
field: string
/** 排序方式 */
order: 'ASC' | 'DESC'
}
/**
* 基础DTO - 包含所有数据传输对象的公共字段
*/
@@ -20,6 +30,14 @@ export interface BaseDTO {
deleteTime?: string
/** 是否已删除 */
deleted?: boolean
/** 数量限制 */
limit?: number
/** 开始时间 */
startTime?: string
/** 结束时间 */
endTime?: string
/** 排序字段列表 */
orderFields?: OrderField[]
}
/**
@@ -28,11 +46,4 @@ export interface BaseDTO {
export interface BaseVO extends BaseDTO {
/** 主键ID */
id?: string
orderTypes?: OrderType[]
}
export interface OrderType {
field: string
order: 'ASC' | 'DESC'
}

View File

@@ -34,9 +34,12 @@ export default defineConfig({
exposes: {
// ========== 组件模块 ==========
'./components': './src/components/index.ts',
'./components/FileUpload': './src/components/fileupload/FileUpload.vue',
'./components/file/FileUpload': './src/components/file/fileupload/FileUpload.vue',
'./components/file/FileHistory': './src/components/file/fileHistory/FileHistory.vue',
'./components/DynamicFormItem': './src/components/dynamicFormItem/DynamicFormItem.vue',
'./components/iframe/IframeView.vue': './src/components/iframe/IframeView.vue',
'./components/ai/knowledge/DocumentSegment.vue': './src/components/ai/knowledge/documentSegment/DocumentSegment.vue',
'./components/ai/knowledge/DocumentDetail.vue': './src/components/ai/knowledge/documentDetail/DocumentDetail.vue',
// ========== API 模块 ==========
'./api': './src/api/index.ts',
@@ -72,7 +75,7 @@ export default defineConfig({
vue: {},
'vue-router': {},
'element-plus': {},
'@element-plus/icons-vue': {},
'lucide-vue-next': {},
axios: {}
}
})

View File

@@ -6,15 +6,21 @@
// ========== 组件模块 ==========
declare module 'shared/components' {
export const FileUpload: any
export const FileHistory: any
export const DynamicFormItem: any
export const IframeView: any
}
declare module 'shared/components/FileUpload' {
declare module 'shared/components/file/FileUpload' {
import { DefineComponent } from 'vue'
const FileUpload: DefineComponent<{}, {}, any>
export default FileUpload
}
declare module 'shared/components/file/FileHistory' {
import { DefineComponent } from 'vue'
const FileHistory: DefineComponent<{}, {}, any>
export default FileHistory
}
declare module 'shared/components/DynamicFormItem' {
import { DefineComponent } from 'vue'
@@ -28,6 +34,18 @@ declare module 'shared/components/iframe/IframeView.vue' {
export default IframeView
}
declare module 'shared/components/ai/knowledge/DocumentSegment.vue' {
import { DefineComponent } from 'vue'
const DocumentSegment: DefineComponent<{}, {}, any>
export default DocumentSegment
}
declare module 'shared/components/ai/knowledge/DocumentDetail.vue' {
import { DefineComponent } from 'vue'
const DocumentDetail: DefineComponent<{}, {}, any>
export default DocumentDetail
}
// ========== API 模块 ==========
declare module 'shared/api' {
export const api: any

View File

@@ -94,7 +94,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import AdminLayout from '@/views/admin/AdminLayout.vue'
import { Download, Search } from '@element-plus/icons-vue'
import { Download, Search } from 'lucide-vue-next'
import { ElMessage } from 'element-plus'
const dateRange = ref<[Date, Date] | null>(null)

View File

@@ -1,3 +1,14 @@
.knowledge-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.tab-desc {
color: #909399;
font-size: 13px;
margin-bottom: 12px;
}
.kb-categories {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
@@ -69,4 +80,7 @@
font-size: 18px;
}
}
.el-button {
margin: 0 !important;
}
}

View File

@@ -50,16 +50,22 @@
</el-table-column>
<el-table-column prop="uploader" label="上传人员" width="120" />
<el-table-column prop="uploadTime" label="上传时间" width="180" />
<el-table-column prop="wordCount" label="字数" width="100" />
<el-table-column label="状态" width="100">
<!-- <el-table-column prop="wordCount" label="字数" width="100" /> -->
<!-- <el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">
{{ row.enabled ? '已启用' : '已禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center">
</el-table-column> -->
<el-table-column label="操作" width="280" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openSegmentDialog(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button type="warning" link size="small" @click="openHistoryDialog(row)">
<el-icon><Clock /></el-icon>历史
</el-button>
<el-button type="primary" link size="small" @click="previewFile(row)">
<el-icon><View /></el-icon>预览
</el-button>
@@ -75,16 +81,35 @@
</div>
</el-card>
</div>
<!-- 分段编辑弹窗包含版本更新功能 -->
<DocumentSegment
v-model="showSegmentDialog"
:dataset-id="currentDatasetId"
:document-id="currentDifyDocId"
:knowledge-id="currentKnowledgeId"
:file-root-id="currentFileRootId"
@file-updated="handleFileUpdated"
/>
<!-- 历史版本弹窗 -->
<FileHistory
v-model="showHistoryDialog"
:data="historyList"
:loading="historyLoading"
@preview="previewHistoryFile"
@download="downloadHistoryFile"
/>
</AdminLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import AdminLayout from '@/views/admin/AdminLayout.vue'
import { Upload, Search, Document, View, Download, Delete } from '@element-plus/icons-vue'
import { Upload, Search, FileText as Document, Eye as View, Download, Trash2 as Delete, Pencil as Edit, Clock } from 'lucide-vue-next'
import { ElMessage, ElMessageBox } from 'element-plus'
import { aiKnowledgeAPI } from 'shared/api/ai'
import { FileUpload } from 'shared/components'
import { FileUpload, FileHistory } from 'shared/components'
import DocumentSegment from 'shared/components/ai/knowledge/DocumentSegment.vue'
import { FILE_DOWNLOAD_URL } from '@/config/index'
import type { TbKnowledge } from 'shared/types'
@@ -184,7 +209,7 @@ const fetchDocuments = async (knowledgeId: string) => {
documents.value = (result.pageDomain.dataList || []).map((file: any) => ({
id: file.fileId,
name: file.fileName || '-',
uploader: file.creator || '-',
uploader: file.uploaderName || '-',
uploadTime: file.createTime ? new Date(file.createTime).toLocaleString() : '-',
fileId: file.fileId,
fileRootId: file.fileRootId,
@@ -303,6 +328,89 @@ const handleUploadError = (error: string) => {
ElMessage.error(error)
}
// ====================== 分段编辑功能 ======================
const showSegmentDialog = ref(false)
const currentDatasetId = ref('')
const currentDifyDocId = ref('')
const currentKnowledgeId = ref('')
const currentFileRootId = ref('')
const openSegmentDialog = (row: DocumentItem) => {
if (!row.difyFileId) {
ElMessage.warning('该文件暂无分段信息')
return
}
// 获取当前知识库的 difyDatasetId
const kb = knowledges.value.find((k: TbKnowledge) => k.knowledgeId === activeKnowledgeId.value)
if (!kb?.difyDatasetId) {
ElMessage.warning('知识库信息不完整')
return
}
currentDatasetId.value = kb.difyDatasetId
currentDifyDocId.value = row.difyFileId
currentKnowledgeId.value = row.knowledgeId || ''
currentFileRootId.value = row.fileRootId || ''
showSegmentDialog.value = true
}
// 文件更新后刷新列表
const handleFileUpdated = () => {
fetchDocuments(activeKnowledgeId.value)
}
// ====================== 历史版本功能 ======================
const showHistoryDialog = ref(false)
const historyLoading = ref(false)
const historyList = ref<any[]>([])
const openHistoryDialog = async (row: DocumentItem) => {
if (!row.fileRootId) {
ElMessage.warning('文件信息不完整')
return
}
showHistoryDialog.value = true
historyLoading.value = true
try {
const result = await aiKnowledgeAPI.getFileHistory(row.fileRootId)
console.log('历史版本响应:', result)
if (result.success) {
// 兼容 data 和 dataList 两种返回格式
historyList.value = result.data || result.dataList || []
} else {
ElMessage.error(result.message || '获取历史版本失败')
}
} catch (error) {
console.error('获取历史版本失败:', error)
ElMessage.error('获取历史版本失败')
} finally {
historyLoading.value = false
}
}
const previewHistoryFile = (row: any) => {
if (!row.fileId) {
ElMessage.warning('文件信息不完整')
return
}
const fileUrl = `${FILE_DOWNLOAD_URL}${row.fileId}`
window.open(fileUrl, '_blank')
}
const downloadHistoryFile = (row: any) => {
if (!row.fileId) {
ElMessage.warning('文件信息不完整')
return
}
const fileUrl = `${FILE_DOWNLOAD_URL}${row.fileId}`
const link = document.createElement('a')
link.href = fileUrl
link.download = row.fileName || 'file'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
ElMessage.success('开始下载')
}
onMounted(() => {
fetchKnowledges()
})
@@ -310,16 +418,4 @@ onMounted(() => {
<style lang="scss" scoped>
@import url("./KnowLedgeView.scss");
.knowledge-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.tab-desc {
color: #909399;
font-size: 13px;
margin-bottom: 12px;
}
</style>

View File

@@ -98,7 +98,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import AdminLayout from '@/views/admin/AdminLayout.vue'
import { Download, Search } from '@element-plus/icons-vue'
import { Download, Search } from 'lucide-vue-next'
import { ElMessage } from 'element-plus'
const dateRange = ref<[Date, Date] | null>(null)

View File

@@ -97,7 +97,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import AdminLayout from '@/views/admin/AdminLayout.vue'
import { Download, Search } from '@element-plus/icons-vue'
import { Download, Search } from 'lucide-vue-next'
import { ElMessage } from 'element-plus'
const dateRange = ref<[Date, Date] | null>(null)

View File

@@ -96,7 +96,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import AdminLayout from '@/views/admin/AdminLayout.vue'
import { Download, Search } from '@element-plus/icons-vue'
import { Download, Search } from 'lucide-vue-next'
import { ElMessage } from 'element-plus'
const dateRange = ref<[Date, Date] | null>(null)

View File

@@ -0,0 +1 @@
// OverviewView 样式占位符

View File

@@ -122,7 +122,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import AdminLayout from '@/views/admin/AdminLayout.vue'
import { ChatDotRound, Clock, Select, Top, Right, Document, Tickets, Service } from '@element-plus/icons-vue'
import { MessageCircle as ChatDotRound, Clock, CheckSquare as Select, ArrowUp as Top, ArrowRight as Right, FileText as Document, Ticket as Tickets, Headphones as Service } from 'lucide-vue-next'
const questionStatPeriod = ref('today')

View File

@@ -154,7 +154,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import AdminLayout from '@/views/admin/AdminLayout.vue'
import { Plus, Search } from '@element-plus/icons-vue'
import { Plus, Search } from 'lucide-vue-next'
import { ElMessage, ElMessageBox } from 'element-plus'
import { workcaseAPI } from 'shared/api'
import type { TbWorkcaseDTO, TbWorkcaseProcessDTO } from 'shared/types'

View File

@@ -55,6 +55,11 @@ export default defineConfig(({ mode }) => ({
host: true,
cors: true,
open: '/workcase/', // 开发时自动打开到 /workcase/ 路径
hmr: {
// 修复 base 路径导致的 WebSocket 连接问题
path: '/@vite/client',
port: 7003
},
proxy: {
'/api': {
target: 'http://localhost:8180',