知识库历史文件
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
@@ -57,4 +57,7 @@ public class KnowledgeFileVO extends BaseVO {
|
||||
|
||||
@Schema(description = "文件MD5值")
|
||||
private String fileMd5Hash;
|
||||
|
||||
@Schema(description = "上传人员名称")
|
||||
private String uploaderName;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
855
urbanLifelineWeb/example/DocumentSegmentDialog.vue
Normal file
855
urbanLifelineWeb/example/DocumentSegmentDialog.vue
Normal 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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
// ❌ 不推荐:路径模糊
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
export {default as DocumentDetail} from './DocumentDetail/DocumentDetail.vue'
|
||||
export * from './knowledge'
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as DocumentSegment } from './documentSegment/DocumentSegment.vue'
|
||||
export { default as DocumentDetail } from './documentDetail/DocumentDetail.vue'
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as FileUpload } from './fileupload/FileUpload.vue'
|
||||
export { default as FileHistory } from './fileHistory/FileHistory.vue'
|
||||
@@ -1 +0,0 @@
|
||||
export { default as FileUpload } from './FileUpload.vue'
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
* 文档分段请求体
|
||||
*/
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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: {}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
// OverviewView 样式占位符
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user