知识库历史文件

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

View File

@@ -76,4 +76,14 @@ public interface TbKnowledgeFileMapper {
@Param("knowledgeId") String knowledgeId, @Param("knowledgeId") String knowledgeId,
@Param("fileRootId") String fileRootId @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 @Autowired
private DifyApiClient difyApiClient; 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; private FileService fileService;
@Autowired @Autowired
@@ -615,14 +615,22 @@ public class KnowledgeServiceImpl implements KnowledgeService {
return ResultDomain.failure("知识库未关联Dify"); return ResultDomain.failure("知识库未关联Dify");
} }
// 3. 获取旧版本 // 3. 获取最大版本的旧文件
List<TbKnowledgeFile> oldVersions = knowledgeFileMapper.selectFileVersions(fileRootId); TbKnowledgeFile latestOldFile = knowledgeFileMapper.selectLatestVersionFile(fileRootId);
if (oldVersions == null || oldVersions.isEmpty()) { if (latestOldFile == null) {
return ResultDomain.failure("原文件不存在"); return ResultDomain.failure("原文件不存在");
} }
// 4. 上传新版本到minio // 4. 上传新版本到minio(使用字节数组避免 Dubbo 序列化问题)
ResultDomain<TbSysFileDTO> fileResult = fileService.uploadFileVersion(file, "knowledge", knowledgeId, fileRootId); 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) { if (!fileResult.getSuccess() || fileResult.getData() == null) {
return ResultDomain.failure("上传新版本文件失败: " + fileResult.getMessage()); return ResultDomain.failure("上传新版本文件失败: " + fileResult.getMessage());
} }
@@ -631,11 +639,9 @@ public class KnowledgeServiceImpl implements KnowledgeService {
int newVersion = sysFile.getVersion(); int newVersion = sysFile.getVersion();
logger.info("上传新版本到minio成功: fileId={}, version={}", newFileId, newVersion); logger.info("上传新版本到minio成功: fileId={}, version={}", newFileId, newVersion);
// 5. 删除Dify旧文档 // 5. 删除Dify最大版本的旧文档
for (TbKnowledgeFile oldFile : oldVersions) { if (StringUtils.hasText(latestOldFile.getDifyFileId())) {
if (StringUtils.hasText(oldFile.getDifyFileId())) { aiFileUploadService.deleteFileFromDify(knowledge.getDifyDatasetId(), latestOldFile.getDifyFileId());
aiFileUploadService.deleteFileFromDify(knowledge.getDifyDatasetId(), oldFile.getDifyFileId());
}
} }
// 6. 上传新文件到Dify // 6. 上传新文件到Dify
@@ -734,17 +740,19 @@ public class KnowledgeServiceImpl implements KnowledgeService {
/** /**
* @description 获取文件历史版本 * @description 获取文件历史版本
* @param fileRootId 文件根ID * @param fileRootId 文件根ID
* @return ResultDomain<TbKnowledgeFile> 文件历史版本列表 * @return ResultDomain<KnowledgeFileVO> 文件历史版本列表dataList
* @author yslg * @author yslg
* @since 2025-12-18 * @since 2025-12-18
*/ */
@Override @Override
public ResultDomain<TbKnowledgeFile> getKnowledgeFileHistory(String fileRootId) { public ResultDomain<KnowledgeFileVO> getKnowledgeFileHistory(String fileRootId) {
if (!StringUtils.hasText(fileRootId)) { if (!StringUtils.hasText(fileRootId)) {
return ResultDomain.failure("文件根ID不能为空"); return ResultDomain.failure("文件根ID不能为空");
} }
List<TbKnowledgeFile> versions = knowledgeFileMapper.selectFileVersions(fileRootId); List<KnowledgeFileVO> versions = knowledgeFileMapper.selectFileVersionsWithDetail(fileRootId);
return ResultDomain.success("查询成功", versions); 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_url" property="fileUrl" jdbcType="VARCHAR"/>
<result column="file_extension" property="fileExtension" jdbcType="VARCHAR"/> <result column="file_extension" property="fileExtension" jdbcType="VARCHAR"/>
<result column="file_md5_hash" property="fileMd5Hash" jdbcType="VARCHAR"/> <result column="file_md5_hash" property="fileMd5Hash" jdbcType="VARCHAR"/>
<result column="uploader_name" property="uploaderName" jdbcType="VARCHAR"/>
</resultMap> </resultMap>
<sql id="Base_Column_List"> <sql id="Base_Column_List">
@@ -83,9 +84,17 @@
f.mime_type as file_mime_type, f.mime_type as file_mime_type,
f.url as file_url, f.url as file_url,
f.extension as file_extension, 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 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 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 WHERE kf.knowledge_id = #{knowledgeId} AND kf.deleted = false
ORDER BY kf.create_time DESC ORDER BY kf.create_time DESC
</select> </select>
@@ -107,16 +116,24 @@
f.mime_type as file_mime_type, f.mime_type as file_mime_type,
f.url as file_url, f.url as file_url,
f.extension as file_extension, 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 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 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 WHERE kf.knowledge_id = #{knowledgeId} AND kf.deleted = false
ORDER BY kf.create_time DESC ORDER BY kf.create_time DESC
LIMIT #{pageParam.pageSize} OFFSET #{pageParam.offset} LIMIT #{pageParam.pageSize} OFFSET #{pageParam.offset}
</select> </select>
<select id="countFiles" resultType="long"> <select id="countFiles" resultType="long">
SELECT COUNT(*) SELECT COUNT(DISTINCT file_root_id)
FROM ai.tb_knowledge_file FROM ai.tb_knowledge_file
WHERE knowledge_id = #{knowledgeId} AND deleted = false WHERE knowledge_id = #{knowledgeId} AND deleted = false
</select> </select>
@@ -133,4 +150,31 @@
FROM ai.tb_knowledge_file FROM ai.tb_knowledge_file
WHERE knowledge_id = #{knowledgeId} AND file_root_id = #{fileRootId} WHERE knowledge_id = #{knowledgeId} AND file_root_id = #{fileRootId}
</select> </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> </mapper>

View File

@@ -162,10 +162,10 @@ public interface KnowledgeService {
/** /**
* @description 获取文件历史版本 * @description 获取文件历史版本
* @param fileRootId 文件根ID * @param fileRootId 文件根ID
* @return ResultDomain<TbKnowledgeFile> 文件历史版本列表 * @return ResultDomain<KnowledgeFileVO> 文件历史版本列表dataList
* @author yslg * @author yslg
* @since 2025-12-18 * @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值") @Schema(description = "文件MD5值")
private String fileMd5Hash; 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); 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 @Autowired
private AuthService authService; 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; 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; private MessageService messageService;
@Autowired @Autowired

View File

@@ -59,10 +59,10 @@ public class AuthServiceImpl implements AuthService{
private RedisService redisService; 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; 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; private ModulePermissionService modulePermissionService;
@Autowired @Autowired

View File

@@ -84,17 +84,28 @@ public class LoginUtil {
} }
/** /**
* 从请求头获取Token * 从请求头或Dubbo RpcContext获取Token
*/ */
public static String getToken() { public static String getToken() {
// 1. 优先从HTTP请求头获取正常Web请求
HttpServletRequest request = getRequest(); HttpServletRequest request = getRequest();
if (request == null) { if (request != null) {
return 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); // 2. 从Dubbo Provider ThreadLocal获取跨服务调用
if (StringUtils.hasText(authHeader) && authHeader.startsWith(BEARER_PREFIX)) { try {
return authHeader.substring(BEARER_PREFIX.length()); 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; return null;

View File

@@ -2,6 +2,10 @@ package org.xyzh.common.vo;
import java.io.Serializable; import java.io.Serializable;
import java.util.Date; import java.util.Date;
import java.util.List;
import org.xyzh.common.dto.OrderField;
import com.alibaba.fastjson2.annotation.JSONField; import com.alibaba.fastjson2.annotation.JSONField;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
@@ -54,5 +58,17 @@ public class BaseVO implements Serializable {
@Schema(description = "是否已删除", defaultValue = "false") @Schema(description = "是否已删除", defaultValue = "false")
private Boolean deleted = 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); 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 SysConfigService sysConfigService;
private static WeChatKefuConfig weChatConfig; private static WeChatKefuConfig weChatConfig;

View File

@@ -29,7 +29,7 @@ public class KefuAccessTokenManager {
private final RestTemplate restTemplate = new RestTemplate(); 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 SysConfigService sysConfigService;
private String corpId; private String corpId;

View File

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

View File

@@ -9,6 +9,8 @@ import org.springframework.util.DigestUtils;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.xyzh.api.file.dto.TbSysFileDTO; import org.xyzh.api.file.dto.TbSysFileDTO;
import org.xyzh.api.file.service.FileService; 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.common.core.domain.ResultDomain;
import org.xyzh.file.config.MinioConfig; import org.xyzh.file.config.MinioConfig;
import org.xyzh.file.mapper.FileMapper; import org.xyzh.file.mapper.FileMapper;
@@ -53,65 +55,7 @@ public class FileServiceImpl implements FileService {
if (file == null || file.isEmpty()) { if (file == null || file.isEmpty()) {
return ResultDomain.failure("文件不能为空"); return ResultDomain.failure("文件不能为空");
} }
return uploadFileBytesWithUser(file.getBytes(), file.getOriginalFilename(), file.getContentType(), module, businessId, getCurrentUserId());
// 生成文件信息
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);
} catch (Exception e) { } catch (Exception e) {
logger.error("文件上传失败", e); logger.error("文件上传失败", e);
return ResultDomain.failure("文件上传失败: " + e.getMessage()); 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 @Override
public ResultDomain<TbSysFileDTO> uploadFileVersion(MultipartFile file, String module, String businessId, String fileRootId) { public ResultDomain<TbSysFileDTO> uploadFileVersion(MultipartFile file, String module, String businessId, String fileRootId) {
try { try {
@@ -302,29 +236,111 @@ public class FileServiceImpl implements FileService {
if (fileRootId == null || fileRootId.isEmpty()) { if (fileRootId == null || fileRootId.isEmpty()) {
return ResultDomain.failure("文件根ID不能为空"); 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. 获取当前最大版本号 // 1. 获取当前最大版本号
Integer maxVersion = fileMapper.selectMaxVersionByFileRootId(fileRootId); Integer maxVersion = fileMapper.selectMaxVersionByFileRootId(fileRootId);
int newVersion = (maxVersion != null ? maxVersion : 0) + 1; int newVersion = (maxVersion != null ? maxVersion : 0) + 1;
// 2. 生成文件信息 // 2. 生成文件信息
String originalFilename = file.getOriginalFilename(); String extension = getFileExtension(fileName);
String extension = getFileExtension(originalFilename); long size = fileBytes.length;
String contentType = file.getContentType();
long size = file.getSize();
// 3. 生成唯一的对象名称 // 3. 生成唯一的对象名称
String objectName = generateObjectName(originalFilename, module); String objectName = generateObjectName(fileName, module);
// 4. 计算文件MD5 // 4. 计算文件MD5
String md5Hash = calculateMD5(file.getBytes()); String md5Hash = calculateMD5(fileBytes);
// 5. 上传到MinIO // 5. 上传到MinIO
String bucketName = minioConfig.getBucketName(); String bucketName = minioConfig.getBucketName();
java.io.ByteArrayInputStream inputStream = new java.io.ByteArrayInputStream(fileBytes);
boolean uploadSuccess = minioUtil.uploadFile( boolean uploadSuccess = minioUtil.uploadFile(
bucketName, bucketName,
objectName, objectName,
file.getInputStream(), inputStream,
size, size,
contentType contentType
); );
@@ -342,7 +358,7 @@ public class FileServiceImpl implements FileService {
fileDTO.setFileId(UUID.randomUUID().toString()); fileDTO.setFileId(UUID.randomUUID().toString());
fileDTO.setFileRootId(fileRootId); fileDTO.setFileRootId(fileRootId);
fileDTO.setVersion(newVersion); fileDTO.setVersion(newVersion);
fileDTO.setName(originalFilename); fileDTO.setName(fileName);
fileDTO.setPath(objectName); fileDTO.setPath(objectName);
fileDTO.setSize(size); fileDTO.setSize(size);
fileDTO.setType(extension); fileDTO.setType(extension);
@@ -356,90 +372,21 @@ public class FileServiceImpl implements FileService {
fileDTO.setBucketName(bucketName); fileDTO.setBucketName(bucketName);
fileDTO.setMd5Hash(md5Hash); fileDTO.setMd5Hash(md5Hash);
fileDTO.setExtension(extension); fileDTO.setExtension(extension);
fileDTO.setCreator(uploaderUserId);
fileDTO.setUploader(uploaderUserId);
fileDTO.setCreateTime(new java.util.Date()); 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;
// 生成唯一的对象名称
String objectName = generateObjectName(fileName, module);
// 计算文件MD5
String md5Hash = calculateMD5(fileBytes);
// 上传到MinIO
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失败");
}
// 创建文件DTO
TbSysFileDTO fileDTO = new TbSysFileDTO();
String fileId = UUID.randomUUID().toString().replace("-", "");
fileDTO.setOptsn(UUID.randomUUID().toString());
fileDTO.setFileId(fileId);
fileDTO.setName(fileName);
fileDTO.setPath(objectName);
// URL 设为 NULL前端通过后端接口 /api/file/download/{fileId} 下载
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.setCreateTime(new java.util.Date());
// 保存到数据库
int result = fileMapper.insertFile(fileDTO); int result = fileMapper.insertFile(fileDTO);
if (result <= 0) { if (result <= 0) {
minioUtil.deleteFile(bucketName, objectName); minioUtil.deleteFile(bucketName, objectName);
return ResultDomain.failure("文件信息保存失败"); return ResultDomain.failure("文件信息保存失败");
} }
logger.info("字节数组文件上传成功: {}", fileName); logger.info("新版本文件上传成功(bytes): {}, version: {}, fileRootId: {}, uploader: {}", fileName, newVersion, fileRootId, uploaderUserId);
return ResultDomain.success("文件上传成功", fileDTO); return ResultDomain.success("文件上传成功", fileDTO);
} catch (Exception e) { } catch (Exception e) {
logger.error("字节数组文件上传失败", e); logger.error("新版本文件上传失败(bytes)", e);
return ResultDomain.failure("文件上传失败: " + e.getMessage()); return ResultDomain.failure("文件上传失败: " + e.getMessage());
} }
} }
@@ -471,4 +418,19 @@ public class FileServiceImpl implements FileService {
private String calculateMD5(byte[] data) { private String calculateMD5(byte[] data) {
return DigestUtils.md5DigestAsHex(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="bucketName != null">, bucket_name</if>
<if test="md5Hash != null">, md5_hash</if> <if test="md5Hash != null">, md5_hash</if>
<if test="extension != null">, extension</if> <if test="extension != null">, extension</if>
<if test="fileRootId != null">, file_root_id</if>
<if test="version != null">, version</if>
) VALUES ( ) VALUES (
<!-- 必填字段值 --> <!-- 必填字段值 -->
#{optsn}, #{fileId}, #{name}, #{path}, #{size} #{optsn}, #{fileId}, #{name}, #{path}, #{size}
@@ -86,6 +88,8 @@
<if test="bucketName != null">, #{bucketName}</if> <if test="bucketName != null">, #{bucketName}</if>
<if test="md5Hash != null">, #{md5Hash}</if> <if test="md5Hash != null">, #{md5Hash}</if>
<if test="extension != null">, #{extension}</if> <if test="extension != null">, #{extension}</if>
<if test="fileRootId != null">, #{fileRootId}</if>
<if test="version != null">, #{version}</if>
) )
</insert> </insert>

View File

@@ -27,7 +27,7 @@ public class DynamicConfigLoader implements ApplicationRunner {
private static final Logger logger = LoggerFactory.getLogger(DynamicConfigLoader.class); 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; private SysConfigService sysConfigService;
@Autowired(required = false) @Autowired(required = false)

View File

@@ -30,10 +30,10 @@ public class KnowledgeInit {
private static final String CATEGORY_INTERNAL = "internal"; private static final String CATEGORY_INTERNAL = "internal";
private static final String CATEGORY_EXTERNAL = "external"; 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; 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; private SysConfigService sysConfigService;
@Bean @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 String CHAT_COUNT_KEY_PREFIX = "workcase:chat:count:";
private static final int TRANSFER_HUMAN_THRESHOLD = 3; 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; private AgentChatService agentChatService;
@Autowired @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' { declare module 'shared/components' {
export const FileUpload: any export const FileUpload: any
export const FileHistory: any
export const DynamicFormItem: any export const DynamicFormItem: any
export const IframeView: any export const IframeView: any
} }
declare module 'shared/components/FileUpload' { declare module 'shared/components/file/FileUpload' {
import { DefineComponent } from 'vue' import { DefineComponent } from 'vue'
const FileUpload: DefineComponent<{}, {}, any> const FileUpload: DefineComponent<{}, {}, any>
export default FileUpload 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' { declare module 'shared/components/DynamicFormItem' {
import { DefineComponent } from 'vue' import { DefineComponent } from 'vue'
@@ -27,7 +33,17 @@ declare module 'shared/components/iframe/IframeView.vue' {
const IframeView: DefineComponent<{}, {}, any> const IframeView: DefineComponent<{}, {}, any>
export default IframeView 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 模块 ========== // ========== API 模块 ==========
declare module 'shared/api' { declare module 'shared/api' {
export const api: any export const api: any

View File

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

View File

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

View File

@@ -53,7 +53,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRouter } from 'vue-router' 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 { ElMessage } from 'element-plus'
import AgentCard from './components/AgentCard/AgentCard.vue' import AgentCard from './components/AgentCard/AgentCard.vue'
import AgentEdit from './components/AgentEdit/AgentEdit.vue' import AgentEdit from './components/AgentEdit/AgentEdit.vue'

View File

@@ -115,9 +115,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed, watch } from 'vue' 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 { ElMessage } from 'element-plus'
import FileUpload from 'shared/components/FileUpload' import FileUpload from 'shared/components/file/FileUpload'
interface SuggestionCard { interface SuggestionCard {
text: string text: string

View File

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

View File

@@ -34,7 +34,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue' 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 { interface Agent {
id: string id: string

View File

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

View File

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

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, SegmentRequestBody, DocumentStatusRequestBody } from '@/types/ai' import type { TbKnowledge, TbKnowledgeFile, KnowledgeFileVO, SegmentRequestBody, DocumentStatusRequestBody } from '@/types/ai'
/** /**
* @description AI知识库相关接口 * @description AI知识库相关接口
@@ -171,8 +171,8 @@ export const aiKnowledgeAPI = {
* 获取文件历史版本 * 获取文件历史版本
* @param fileRootId 文件根ID * @param fileRootId 文件根ID
*/ */
async getFileHistory(fileRootId: string): Promise<ResultDomain<TbKnowledgeFile>> { async getFileHistory(fileRootId: string): Promise<ResultDomain<KnowledgeFileVO[]>> {
const response = await api.get<TbKnowledgeFile>(`${this.baseUrl}/file/${fileRootId}/history`) const response = await api.get<KnowledgeFileVO[]>(`${this.baseUrl}/file/${fileRootId}/history`)
return response.data 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 './base'
export * from './dynamicFormItem' export * from './dynamicFormItem'
export * from './ai' export * from './ai'
export * from './file'
// 通用视图组件 // 通用视图组件
export { default as IframeView } from './iframe/IframeView.vue' 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 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 - 包含所有数据传输对象的公共字段 * 基础DTO - 包含所有数据传输对象的公共字段
*/ */
@@ -20,6 +30,14 @@ export interface BaseDTO {
deleteTime?: string deleteTime?: string
/** 是否已删除 */ /** 是否已删除 */
deleted?: boolean deleted?: boolean
/** 数量限制 */
limit?: number
/** 开始时间 */
startTime?: string
/** 结束时间 */
endTime?: string
/** 排序字段列表 */
orderFields?: OrderField[]
} }
/** /**
@@ -28,11 +46,4 @@ export interface BaseDTO {
export interface BaseVO extends BaseDTO { export interface BaseVO extends BaseDTO {
/** 主键ID */ /** 主键ID */
id?: string id?: string
orderTypes?: OrderType[]
}
export interface OrderType {
field: string
order: 'ASC' | 'DESC'
} }

View File

@@ -34,9 +34,12 @@ export default defineConfig({
exposes: { exposes: {
// ========== 组件模块 ========== // ========== 组件模块 ==========
'./components': './src/components/index.ts', './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/DynamicFormItem': './src/components/dynamicFormItem/DynamicFormItem.vue',
'./components/iframe/IframeView.vue': './src/components/iframe/IframeView.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 模块 ==========
'./api': './src/api/index.ts', './api': './src/api/index.ts',
@@ -72,7 +75,7 @@ export default defineConfig({
vue: {}, vue: {},
'vue-router': {}, 'vue-router': {},
'element-plus': {}, 'element-plus': {},
'@element-plus/icons-vue': {}, 'lucide-vue-next': {},
axios: {} axios: {}
} }
}) })

View File

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

View File

@@ -94,7 +94,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import AdminLayout from '@/views/admin/AdminLayout.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' import { ElMessage } from 'element-plus'
const dateRange = ref<[Date, Date] | null>(null) 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 { .kb-categories {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
@@ -69,4 +80,7 @@
font-size: 18px; font-size: 18px;
} }
} }
.el-button {
margin: 0 !important;
}
} }

View File

@@ -50,16 +50,22 @@
</el-table-column> </el-table-column>
<el-table-column prop="uploader" label="上传人员" width="120" /> <el-table-column prop="uploader" label="上传人员" width="120" />
<el-table-column prop="uploadTime" label="上传时间" width="180" /> <el-table-column prop="uploadTime" label="上传时间" width="180" />
<el-table-column prop="wordCount" label="字数" width="100" /> <!-- <el-table-column prop="wordCount" label="字数" width="100" /> -->
<el-table-column label="状态" width="100"> <!-- <el-table-column label="状态" width="100">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'" size="small"> <el-tag :type="row.enabled ? 'success' : 'info'" size="small">
{{ row.enabled ? '已启用' : '已禁用' }} {{ row.enabled ? '已启用' : '已禁用' }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column> -->
<el-table-column label="操作" width="180" align="center"> <el-table-column label="操作" width="280" align="center" fixed="right">
<template #default="{ row }"> <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-button type="primary" link size="small" @click="previewFile(row)">
<el-icon><View /></el-icon>预览 <el-icon><View /></el-icon>预览
</el-button> </el-button>
@@ -75,16 +81,35 @@
</div> </div>
</el-card> </el-card>
</div> </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> </AdminLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import AdminLayout from '@/views/admin/AdminLayout.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 { ElMessage, ElMessageBox } from 'element-plus'
import { aiKnowledgeAPI } from 'shared/api/ai' 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 { FILE_DOWNLOAD_URL } from '@/config/index'
import type { TbKnowledge } from 'shared/types' import type { TbKnowledge } from 'shared/types'
@@ -184,7 +209,7 @@ const fetchDocuments = async (knowledgeId: string) => {
documents.value = (result.pageDomain.dataList || []).map((file: any) => ({ documents.value = (result.pageDomain.dataList || []).map((file: any) => ({
id: file.fileId, id: file.fileId,
name: file.fileName || '-', name: file.fileName || '-',
uploader: file.creator || '-', uploader: file.uploaderName || '-',
uploadTime: file.createTime ? new Date(file.createTime).toLocaleString() : '-', uploadTime: file.createTime ? new Date(file.createTime).toLocaleString() : '-',
fileId: file.fileId, fileId: file.fileId,
fileRootId: file.fileRootId, fileRootId: file.fileRootId,
@@ -303,6 +328,89 @@ const handleUploadError = (error: string) => {
ElMessage.error(error) 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(() => { onMounted(() => {
fetchKnowledges() fetchKnowledges()
}) })
@@ -310,16 +418,4 @@ onMounted(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
@import url("./KnowLedgeView.scss"); @import url("./KnowLedgeView.scss");
.knowledge-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.tab-desc {
color: #909399;
font-size: 13px;
margin-bottom: 12px;
}
</style> </style>

View File

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

View File

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

View File

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

View File

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

View File

@@ -122,7 +122,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import AdminLayout from '@/views/admin/AdminLayout.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') const questionStatPeriod = ref('today')

View File

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

View File

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