From d7b0d442eb77fb416fe3bd798b5b989db6b132a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Wed, 2 Sep 2026 19:15:01 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E6=96=87=E4=BB=B6=E6=A0=BC=E5=BC=8F=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统一知识库和工作流支持格式并增加前后端上传拦截 - 拒绝 XLS 与伪装 XLSX,避免空内容解析成功 --- .../support/DocumentParseFilePolicy.java | 86 ++++++++++++++++++ .../task/DocumentImportBatchAppService.java | 9 +- .../task/DocumentImportFormatPolicy.java | 40 --------- ...KnowledgeDocumentImportTaskAppService.java | 9 +- .../task/KnowledgeImportBatchFacade.java | 11 ++- .../service/TinyFlowService.java | 5 ++ .../easyflow/ai/node/DefaultReadService.java | 6 +- .../ai/node/DocNodeFileContentExtractor.java | 17 +++- .../task/CsvImportSupportPolicyTest.java | 12 ++- .../service/TinyFlowServiceTest.java | 35 ++++++++ .../ai/node/DefaultReadServiceTest.java | 32 +++++++ .../node/DocNodeFileContentExtractorTest.java | 57 +++++++++++- .../components/upload/DragFileUpload.test.ts | 40 +++++++++ .../src/components/upload/DragFileUpload.vue | 24 +++-- .../langs/en-US/documentCollection.json | 6 +- .../app/src/locales/langs/en-US/message.json | 9 +- .../langs/zh-CN/documentCollection.json | 6 +- .../app/src/locales/langs/zh-CN/message.json | 9 +- .../utils/document-parse-file-policy.test.ts | 52 +++++++++++ .../src/utils/document-parse-file-policy.ts | 83 +++++++++++++++++ .../ImportKnowledgeFileContainer.test.ts | 52 +++++++++++ .../ImportKnowledgeFileContainer.vue | 41 +++++---- .../workflow/components/WorkflowFileInput.vue | 28 ++++-- .../__tests__/workflowFileInput.test.ts | 90 ++++++++++++++++++- 24 files changed, 658 insertions(+), 101 deletions(-) create mode 100644 easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/document/support/DocumentParseFilePolicy.java delete mode 100644 easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportFormatPolicy.java create mode 100644 easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/DefaultReadServiceTest.java create mode 100644 easyflow-ui-admin/app/src/components/upload/DragFileUpload.test.ts create mode 100644 easyflow-ui-admin/app/src/utils/document-parse-file-policy.test.ts create mode 100644 easyflow-ui-admin/app/src/utils/document-parse-file-policy.ts create mode 100644 easyflow-ui-admin/app/src/views/ai/documentCollection/ImportKnowledgeFileContainer.test.ts diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/document/support/DocumentParseFilePolicy.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/document/support/DocumentParseFilePolicy.java new file mode 100644 index 00000000..827c5987 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/document/support/DocumentParseFilePolicy.java @@ -0,0 +1,86 @@ +package tech.easyflow.ai.document.support; + +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 知识库导入与工作流文档解析共用的文件格式策略。 + * + * @author Codex + * @since 2026-09-02 + */ +public final class DocumentParseFilePolicy { + + private static final List SUPPORTED_EXTENSION_ORDER = + List.of("txt", "pdf", "docx", "md", "pptx", "xlsx", "csv"); + private static final Set SUPPORTED_EXTENSIONS = + Set.copyOf(SUPPORTED_EXTENSION_ORDER); + private static final String SUPPORTED_TYPE_LABEL = + SUPPORTED_EXTENSION_ORDER.stream() + .map(extension -> extension.toUpperCase(Locale.ROOT)) + .collect(Collectors.joining("、")); + + /** + * 禁止实例化格式策略工具类。 + */ + private DocumentParseFilePolicy() { + } + + /** + * 返回只读的支持格式集合。 + * + * @return 支持的文件扩展名 + */ + public static Set supportedExtensions() { + return SUPPORTED_EXTENSIONS; + } + + /** + * 返回面向用户展示的支持格式列表。 + * + * @return 大写扩展名列表 + */ + public static String supportedTypeLabel() { + return SUPPORTED_TYPE_LABEL; + } + + /** + * 判断扩展名是否属于文档解析支持范围。 + * + * @param extension 文件扩展名 + * @return 支持时返回 {@code true} + */ + public static boolean isSupportedExtension(String extension) { + return SUPPORTED_EXTENSIONS.contains(normalizeExtension(extension)); + } + + /** + * 判断文件名是否属于文档解析支持范围。 + * + * @param fileName 文件名 + * @return 支持时返回 {@code true} + */ + public static boolean isSupportedFileName(String fileName) { + return isSupportedExtension(extensionOf(fileName)); + } + + /** + * 提取文件名中的小写扩展名。 + * + * @param fileName 文件名 + * @return 小写扩展名;无扩展名时返回空字符串 + */ + public static String extensionOf(String fileName) { + String normalizedName = fileName == null ? "" : fileName.trim(); + int dotIndex = normalizedName.lastIndexOf('.'); + return dotIndex < 0 || dotIndex == normalizedName.length() - 1 + ? "" + : normalizeExtension(normalizedName.substring(dotIndex + 1)); + } + + private static String normalizeExtension(String extension) { + return extension == null ? "" : extension.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportBatchAppService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportBatchAppService.java index e3ad7e5a..e9e3b3e9 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportBatchAppService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportBatchAppService.java @@ -10,6 +10,7 @@ import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +import tech.easyflow.ai.document.support.DocumentParseFilePolicy; import tech.easyflow.ai.documentimport.DocumentImportBatchCreateContext; import tech.easyflow.ai.documentimport.DocumentImportBatchDtos; import tech.easyflow.ai.documentimport.DocumentImportBatchRetryResult; @@ -63,7 +64,7 @@ public class DocumentImportBatchAppService { private static final Logger LOG = LoggerFactory.getLogger(DocumentImportBatchAppService.class); private static final Set SUPPORTED_EXTENSIONS = - DocumentImportFormatPolicy.supportedExtensions(); + DocumentParseFilePolicy.supportedExtensions(); private static final Duration BATCH_MUTATION_LOCK_LEASE = Duration.ofMinutes(30); private static final Duration RECOVERY_DISPATCH_LEASE = Duration.ofMinutes(2); private static final Duration RECOVERY_LEASE_RENEW_INTERVAL = @@ -1456,7 +1457,11 @@ public class DocumentImportBatchAppService { int dotIndex = fileName == null ? -1 : fileName.lastIndexOf('.'); String extension = dotIndex < 0 ? "" : fileName.substring(dotIndex + 1).toLowerCase(Locale.ROOT); if (!SUPPORTED_EXTENSIONS.contains(extension)) { - throw new BusinessException("暂不支持该文件格式"); + throw new BusinessException( + "暂不支持该文件格式,仅支持 " + + DocumentParseFilePolicy.supportedTypeLabel() + + " 文件" + ); } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportFormatPolicy.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportFormatPolicy.java deleted file mode 100644 index e40035de..00000000 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportFormatPolicy.java +++ /dev/null @@ -1,40 +0,0 @@ -package tech.easyflow.ai.documentimport.task; - -import java.util.Set; - -/** - * 知识库文档导入格式统一策略。 - * - * @author Codex - * @since 2026-08-04 - */ -public final class DocumentImportFormatPolicy { - - private static final Set SUPPORTED_EXTENSIONS = - Set.of("txt", "pdf", "docx", "md", "pptx", "xlsx", "csv"); - - /** - * 禁止实例化格式策略工具类。 - */ - private DocumentImportFormatPolicy() { - } - - /** - * 返回只读的支持格式集合。 - * - * @return 支持的文件扩展名 - */ - public static Set supportedExtensions() { - return SUPPORTED_EXTENSIONS; - } - - /** - * 判断扩展名是否属于知识库导入支持范围。 - * - * @param extension 已转换为小写的文件扩展名 - * @return 支持时返回 {@code true} - */ - public static boolean isSupported(String extension) { - return SUPPORTED_EXTENSIONS.contains(extension); - } -} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/KnowledgeDocumentImportTaskAppService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/KnowledgeDocumentImportTaskAppService.java index cf4a26a3..79b61987 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/KnowledgeDocumentImportTaskAppService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/KnowledgeDocumentImportTaskAppService.java @@ -42,6 +42,7 @@ import tech.easyflow.ai.document.model.DocumentSourceRef; import tech.easyflow.ai.document.exception.DocumentParseBridgeException; import tech.easyflow.ai.document.service.DocumentParseBridgeService; import tech.easyflow.ai.document.support.DocumentInputStreamSupport; +import tech.easyflow.ai.document.support.DocumentParseFilePolicy; import tech.easyflow.ai.documentimport.DocumentImportDtos; import tech.easyflow.ai.documentimport.DocumentImportKeys; import tech.easyflow.ai.documentimport.DocumentImportPreviewService; @@ -4600,8 +4601,12 @@ public class KnowledgeDocumentImportTaskAppService { } private void assertSupportedImportFile(String fileExt) { - if (!DocumentImportFormatPolicy.isSupported(fileExt)) { - throw new BusinessException("当前仅支持 pdf/docx/txt/md/pptx/xlsx/csv 文档导入"); + if (!DocumentParseFilePolicy.isSupportedExtension(fileExt)) { + throw new BusinessException( + "当前仅支持 " + + DocumentParseFilePolicy.supportedTypeLabel() + + " 文档导入" + ); } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/KnowledgeImportBatchFacade.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/KnowledgeImportBatchFacade.java index b21b6892..64364b17 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/KnowledgeImportBatchFacade.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/KnowledgeImportBatchFacade.java @@ -8,6 +8,7 @@ import org.slf4j.LoggerFactory; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import tech.easyflow.ai.document.support.DocumentParseFilePolicy; import tech.easyflow.ai.documentimport.DocumentImportBatchCreateContext; import tech.easyflow.ai.documentimport.DocumentImportBatchDtos; import tech.easyflow.ai.documentimport.DocumentImportBatchRetryResult; @@ -60,7 +61,7 @@ public class KnowledgeImportBatchFacade { private static final Duration INCOMPLETE_SUBMISSION_TIMEOUT = Duration.ofMinutes(30); private static final Set SUPPORTED_EXTENSIONS = - DocumentImportFormatPolicy.supportedExtensions(); + DocumentParseFilePolicy.supportedExtensions(); private final DocumentImportBatchAppService batchAppService; private final DocumentImportBatchTracker batchTracker; @@ -889,7 +890,13 @@ public class KnowledgeImportBatchFacade { */ private void assertSupportedExtension(String fileName) { if (!SUPPORTED_EXTENSIONS.contains(extension(fileName))) { - throw new BusinessException(415, 41502, "暂不支持该文件格式"); + throw new BusinessException( + 415, + 41502, + "暂不支持该文件格式,仅支持 " + + DocumentParseFilePolicy.supportedTypeLabel() + + " 文件" + ); } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowService.java index f32a14ef..819bf454 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowService.java @@ -1,5 +1,6 @@ package tech.easyflow.ai.easyagentsflow.service; +import com.easyagents.document.core.exception.DocumentParseException; import com.easyagents.flow.core.chain.ChainState; import com.easyagents.flow.core.chain.ExceptionSummary; import com.easyagents.flow.core.chain.NodeState; @@ -165,6 +166,10 @@ public class TinyFlowService { String rootMessage = StringUtil.hasText(error.getRootCauseMessage()) ? error.getRootCauseMessage() : error.getMessage(); + if (DocumentParseException.class.getName().equals(rootClass) + && StringUtil.hasText(rootMessage)) { + return rootMessage; + } if (StringUtil.noText(rootClass)) { return rootMessage; } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/DefaultReadService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/DefaultReadService.java index 91d69654..22c5ac8b 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/DefaultReadService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/DefaultReadService.java @@ -26,10 +26,6 @@ public class DefaultReadService implements ReadDocService { @Override public String read(String fileName, InputStream is) { String suffix = DocUtil.getSuffix(fileName); - if ("pdf".equals(suffix)) { - return DocUtil.readPdfFile(is); - } else { - return DocUtil.readWordFile(suffix, is); - } + return DocUtil.readPreviewContent(suffix, is); } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/DocNodeFileContentExtractor.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/DocNodeFileContentExtractor.java index 1a6e5f32..1a6645a3 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/DocNodeFileContentExtractor.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/DocNodeFileContentExtractor.java @@ -12,6 +12,7 @@ import tech.easyflow.ai.document.model.DocumentSourceRef; import tech.easyflow.ai.document.exception.DocumentParseBridgeException; import tech.easyflow.ai.document.service.DocumentParseBridgeService; import tech.easyflow.ai.document.support.DocumentInputStreamSupport; +import tech.easyflow.ai.document.support.DocumentParseFilePolicy; import tech.easyflow.ai.document.support.DocumentParseSourceType; import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader; import tech.easyflow.common.filestorage.FileStorageService; @@ -206,6 +207,16 @@ public class DocNodeFileContentExtractor { if (!StringUtil.hasText(sourceRef.getFilePath())) { throw new BusinessException("文件输入缺少 filePath"); } + if (!DocumentParseFilePolicy.isSupportedFileName( + sourceRef.getFileName())) { + throw new BusinessException( + "文件“" + + sourceRef.getFileName() + + "”格式不支持,文档解析仅支持 " + + DocumentParseFilePolicy.supportedTypeLabel() + + " 文件" + ); + } } private void collectFileValues(Object value, List result) { @@ -285,8 +296,12 @@ public class DocNodeFileContentExtractor { "document:default-reader"); InputStream inputStream = Files.newInputStream(temporaryFile)) { - return readerManager.getReader().read( + String content = readerManager.getReader().read( sourceRef.getFileName(), inputStream); + if (!StringUtil.hasText(content)) { + throw new BusinessException("文档解析结果为空"); + } + return content; } } catch (IOException e) { DocumentInputStreamSupport.SizeLimitExceededException sizeError = diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/documentimport/task/CsvImportSupportPolicyTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/documentimport/task/CsvImportSupportPolicyTest.java index b6cd5477..6debbef5 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/documentimport/task/CsvImportSupportPolicyTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/documentimport/task/CsvImportSupportPolicyTest.java @@ -2,6 +2,7 @@ package tech.easyflow.ai.documentimport.task; import org.junit.Assert; import org.junit.Test; +import tech.easyflow.ai.document.support.DocumentParseFilePolicy; import java.lang.reflect.Field; import java.lang.reflect.Method; @@ -20,6 +21,13 @@ public class CsvImportSupportPolicyTest { @Test public void shouldAllowCsvAcrossAllKnowledgeImportEntrypoints() throws Exception { + Assert.assertFalse( + DocumentParseFilePolicy.isSupportedFileName("legacy.xls")); + Assert.assertTrue( + DocumentParseFilePolicy.isSupportedFileName("REPORT.XLSX")); + Assert.assertEquals( + "TXT、PDF、DOCX、MD、PPTX、XLSX、CSV", + DocumentParseFilePolicy.supportedTypeLabel()); KnowledgeDocumentImportTaskAppService taskService = new KnowledgeDocumentImportTaskAppService(); Method assertSupported = KnowledgeDocumentImportTaskAppService.class @@ -43,10 +51,10 @@ public class CsvImportSupportPolicyTest { Assert.assertTrue(readSupportedExtensions( KnowledgeImportBatchFacade.class).contains("csv")); Assert.assertSame( - DocumentImportFormatPolicy.supportedExtensions(), + DocumentParseFilePolicy.supportedExtensions(), readSupportedExtensions(DocumentImportBatchAppService.class)); Assert.assertSame( - DocumentImportFormatPolicy.supportedExtensions(), + DocumentParseFilePolicy.supportedExtensions(), readSupportedExtensions(KnowledgeImportBatchFacade.class)); } diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowServiceTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowServiceTest.java index c8b2999f..0a98a4a1 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowServiceTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowServiceTest.java @@ -1,5 +1,6 @@ package tech.easyflow.ai.easyagentsflow.service; +import com.easyagents.document.core.exception.DocumentParseException; import com.easyagents.flow.core.chain.ChainState; import com.easyagents.flow.core.chain.ChainStatus; import com.easyagents.flow.core.chain.ExceptionSummary; @@ -191,6 +192,40 @@ public class TinyFlowServiceTest { result.getNodes().get(NODE_ID).getMessage()); } + /** + * 验证文档格式错误只展示可操作提示,不暴露底层异常类名。 + * + * @throws Exception 测试依赖注入失败时抛出 + */ + @Test + public void shouldExposeDocumentParseMessageWithoutExceptionClass() + throws Exception { + ChainExecutor chainExecutor = mock(ChainExecutor.class); + ChainStateRepository chainStateRepository = + mock(ChainStateRepository.class); + NodeStateRepository nodeStateRepository = + mock(NodeStateRepository.class); + String message = "文件不是标准 XLSX,请另存为 XLSX 后重试"; + ExceptionSummary error = new ExceptionSummary( + new RuntimeException( + "文档解析失败", + new DocumentParseException(message))); + ChainState chainState = new ChainState(); + chainState.setStatus(ChainStatus.FAILED); + chainState.setError(error); + when(chainExecutor.getChainStateRepository()) + .thenReturn(chainStateRepository); + when(chainExecutor.getNodeStateRepository()) + .thenReturn(nodeStateRepository); + when(chainStateRepository.load(EXECUTE_ID)) + .thenReturn(chainState); + TinyFlowService service = service(chainExecutor); + + ChainInfo result = service.getChainStatus(EXECUTE_ID, null); + + Assert.assertEquals(message, result.getMessage()); + } + /** * 创建带指定初始状态的设计器节点。 * diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/DefaultReadServiceTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/DefaultReadServiceTest.java new file mode 100644 index 00000000..f80f392d --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/DefaultReadServiceTest.java @@ -0,0 +1,32 @@ +package tech.easyflow.ai.node; + +import org.junit.Assert; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; + +/** + * {@link DefaultReadService} 测试。 + */ +public class DefaultReadServiceTest { + + /** + * 验证知识库与工作流共同允许的纯文本格式可以被读取。 + */ + @Test + public void shouldReadSupportedPlainTextFormats() { + DefaultReadService service = new DefaultReadService(); + + for (String fileName : new String[] {"notes.txt", "README.md", "table.csv"}) { + String content = service.read( + fileName, + new ByteArrayInputStream( + "标题,内容".getBytes(StandardCharsets.UTF_8) + ) + ); + + Assert.assertEquals("标题,内容", content); + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/DocNodeFileContentExtractorTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/DocNodeFileContentExtractorTest.java index 5c154279..61e9e279 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/DocNodeFileContentExtractorTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/DocNodeFileContentExtractorTest.java @@ -117,10 +117,10 @@ public class DocNodeFileContentExtractorTest { } /** - * 验证非桥接类型文件会继续走默认读取器。 + * 验证支持的纯文本文件会继续走默认读取器。 */ @Test - public void shouldUseDefaultReaderForUnsupportedType() { + public void shouldUseDefaultReaderForPlainText() { RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService(); DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor( bridgeService, @@ -134,6 +134,36 @@ public class DocNodeFileContentExtractorTest { Assert.assertNull(bridgeService.lastSource); } + /** + * 验证旧版 XLS 在进入默认读取器前被明确拒绝。 + */ + @Test + public void shouldRejectLegacyXlsBeforeDefaultReader() { + RecordingDocumentParseBridgeService bridgeService = + new RecordingDocumentParseBridgeService(); + DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor( + bridgeService, + new FakeFileStorageService(), + new FakeReaderManager("should not be used") + ); + + BusinessException error = Assert.assertThrows( + BusinessException.class, + () -> extractor.extract(buildFileValue( + "test.xls", + "/files/test.xls", + "application/vnd.ms-excel" + )) + ); + + Assert.assertEquals( + "文件“test.xls”格式不支持,文档解析仅支持 " + + "TXT、PDF、DOCX、MD、PPTX、XLSX、CSV 文件", + error.getMessage() + ); + Assert.assertNull(bridgeService.lastSource); + } + /** * 验证缺少 filePath 时会抛出明确异常。 */ @@ -176,6 +206,29 @@ public class DocNodeFileContentExtractorTest { } } + /** + * 验证默认读取器返回空内容时节点不会伪装成功。 + */ + @Test + public void shouldFailWhenDefaultReaderResultIsEmpty() { + DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor( + new RecordingDocumentParseBridgeService(), + new FakeFileStorageService(), + new FakeReaderManager(" ") + ); + + BusinessException error = Assert.assertThrows( + BusinessException.class, + () -> extractor.extract(buildFileValue( + "empty.txt", + "/files/empty.txt", + "text/plain" + )) + ); + + Assert.assertEquals("文档解析结果为空", error.getMessage()); + } + /** * 验证普通远端素材 URL 的非桥接文件仍拒绝访问回环地址。 */ diff --git a/easyflow-ui-admin/app/src/components/upload/DragFileUpload.test.ts b/easyflow-ui-admin/app/src/components/upload/DragFileUpload.test.ts new file mode 100644 index 00000000..336cf16a --- /dev/null +++ b/easyflow-ui-admin/app/src/components/upload/DragFileUpload.test.ts @@ -0,0 +1,40 @@ +import { mount } from '@vue/test-utils'; + +import { describe, expect, it, vi } from 'vitest'; + +import DragFileUpload from './DragFileUpload.vue'; + +vi.mock('@easyflow/hooks', () => ({ + useAppConfig: () => ({ apiURL: '' }), +})); + +vi.mock('@easyflow/locales', () => ({ + $t: (key: string) => key, +})); + +vi.mock('@easyflow/stores', () => ({ + useAccessStore: () => ({ accessToken: 'test-token' }), +})); + +vi.mock('#/locales', () => ({ + $t: (key: string, params?: Record) => + key === 'message.upload.unsupportedFileType' + ? `“${params?.fileName}”格式不支持,仅支持 ${params?.types} 文件。` + : key, +})); + +describe('drag file upload', () => { + it('uses the shared document formats and rejects XLS drops', async () => { + const wrapper = mount(DragFileUpload); + const upload = wrapper.getComponent({ name: 'ElUpload' }); + + expect(upload.props('accept')).toBe('.txt,.pdf,.docx,.md,.pptx,.xlsx,.csv'); + const beforeUpload = upload.props('beforeUpload') as ( + file: File, + ) => Promise; + + await expect(beforeUpload(new File(['legacy'], 'test.xls'))).resolves.toBe( + false, + ); + }); +}); diff --git a/easyflow-ui-admin/app/src/components/upload/DragFileUpload.vue b/easyflow-ui-admin/app/src/components/upload/DragFileUpload.vue index bffe3e85..8a7b3a0f 100644 --- a/easyflow-ui-admin/app/src/components/upload/DragFileUpload.vue +++ b/easyflow-ui-admin/app/src/components/upload/DragFileUpload.vue @@ -10,6 +10,7 @@ import { useAccessStore } from '@easyflow/stores'; import { UploadFilled } from '@element-plus/icons-vue'; import { ElIcon, ElMessage, ElUpload } from 'element-plus'; +import { DocumentParseFilePolicy } from '#/utils/document-parse-file-policy'; import { normalizeUploadError, resolveUploadPath, @@ -28,7 +29,6 @@ const props = defineProps({ const emit = defineEmits(['success', 'error', 'onChange']); const MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024; -const ACCEPTED_FILE_TYPES = '.txt,.pdf,.docx,.md,.pptx,.xlsx'; const accessStore = useAccessStore(); const headers = ref({ 'easyflow-token': accessStore.accessToken, @@ -54,12 +54,18 @@ const handleError: UploadProps['onError'] = (error) => { emit('error', normalizedError); }; -const beforeUpload: UploadProps['beforeUpload'] = (rawFile) => { - if (rawFile.size <= MAX_FILE_SIZE_BYTES) { - return true; +const beforeUpload: UploadProps['beforeUpload'] = async (rawFile) => { + try { + await DocumentParseFilePolicy.validateFiles([rawFile]); + } catch (error: any) { + ElMessage.warning(error?.message || $t('message.notSupported')); + return false; } - ElMessage.warning($t('message.upload.fileTooLarge')); - return false; + if (rawFile.size > MAX_FILE_SIZE_BYTES) { + ElMessage.warning($t('message.upload.fileTooLarge')); + return false; + } + return true; }; // 文件状态变化回调 @@ -97,7 +103,7 @@ defineExpose({ drag :headers="headers" :action="`${apiURL}${props.action}`" - :accept="ACCEPTED_FILE_TYPES" + :accept="DocumentParseFilePolicy.accept" :before-upload="beforeUpload" :on-success="handleSuccess" :on-error="handleError" @@ -111,7 +117,9 @@ defineExpose({
{{ $t('message.upload.title') }} {{ - $t('message.upload.description') + $t('message.upload.description', { + types: DocumentParseFilePolicy.supportedTypeLabel, + }) }}
diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json b/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json index 8f47aa25..6f2f0498 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json @@ -79,7 +79,7 @@ "progressUpload": "Progress of file upload", "fileSize": "File size", "batchUploadTitle": "Select files or drop them here", - "batchUploadDescription": "TXT, PDF, DOCX, MD, PPTX and XLSX. Up to 100MB per file and 1GB per folder.", + "batchUploadDescription": "Supported formats: {types}. Up to 100MB per file and 1GB per folder.", "batchUploadTip": "When upload completes, choose manual or automatic import.", "selectFolder": "Select Folder", "manualImport": "Manual Import", @@ -95,8 +95,8 @@ "singleFileLimit": "Each file must be no larger than 100MB", "folderSizeLimit": "The folder must be no larger than 1GB", "fileCountLimit": "A batch can contain up to 2000 files", - "noSupportedFiles": "No supported documents found", - "unsupportedSkipped": "Unsupported files were skipped", + "noSupportedFiles": "No supported documents found. Supported formats: {types}.", + "unsupportedSkipped": "Unsupported files were skipped. Supported formats: {types}.", "createBatchFailed": "Failed to create upload batch", "cancelBatchFailed": "Failed to cancel the upload batch. Please retry.", "uploadFailed": "Upload failed. Please retry.", diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/message.json b/easyflow-ui-admin/app/src/locales/langs/en-US/message.json index 2da70aef..6e82c282 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/message.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/message.json @@ -27,8 +27,13 @@ "copyFail": "Copy fail", "upload": { "title": "Click or drag and drop files here to upload", - "description": "TXT, PDF, DOCX, MD, PPTX, and XLSX files are supported, up to 100 MB each.", - "fileTooLarge": "Each file must not exceed 100 MB" + "description": "Supported formats: {types}. Up to 100 MB per file.", + "supportedTypes": "Supported formats: {types}.", + "singleFileLimit": "Up to 100 MB per file.", + "fileTooLarge": "Each file must not exceed 100 MB", + "unsupportedFileType": "“{fileName}” is not supported. Supported formats: {types}.", + "legacyExcelAsXlsx": "“{fileName}” is not a standard XLSX file. It may be a legacy XLS or an encrypted file. Remove protection and save it as XLSX in Excel/WPS; renaming the extension does not work.", + "invalidXlsx": "“{fileName}” is not a standard XLSX file. Its content does not match the extension or the file is damaged. Save it as XLSX in Excel/WPS and try again." }, "uploadFileFirst": "Please upload the file first", "deleteModelAlert": "This operation will delete the large model. Are you sure to delete it?", diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json index 538d2ed0..c9c88e28 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json @@ -79,7 +79,7 @@ "progressUpload": "文件上传进度", "fileSize": "文件大小", "batchUploadTitle": "点击选择文件,或将文件拖到这里上传", - "batchUploadDescription": "支持 TXT、PDF、DOCX、MD、PPTX、XLSX;单个文件不超过 100MB,文件夹总大小不超过 1GB。", + "batchUploadDescription": "支持 {types} 文件;单个文件不超过 100MB,文件夹总大小不超过 1GB。", "batchUploadTip": "上传完成后,可选择手动导入或自动导入。", "selectFolder": "选择文件夹", "manualImport": "手动导入", @@ -95,8 +95,8 @@ "singleFileLimit": "单个文件不能超过 100MB", "folderSizeLimit": "文件夹总大小不能超过 1GB", "fileCountLimit": "单批次文件数不能超过 2000", - "noSupportedFiles": "未找到支持的文档", - "unsupportedSkipped": "已跳过不支持的文件", + "noSupportedFiles": "未找到支持的文档,仅支持 {types} 文件", + "unsupportedSkipped": "已跳过不支持的文件;仅支持 {types} 文件", "createBatchFailed": "创建上传批次失败", "cancelBatchFailed": "取消上传批次失败,请重试", "uploadFailed": "文件上传失败,请重试", diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/message.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/message.json index 45e9f268..c7e03a07 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/message.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/message.json @@ -27,8 +27,13 @@ "copyFail": "复制失败", "upload": { "title": "点击或将文件拖拽到这里上传", - "description": "支持 TXT、PDF、DOCX、MD、PPTX、XLSX,单个文件不超过 100 MB。", - "fileTooLarge": "单个文件大小不能超过 100 MB" + "description": "支持 {types} 文件,单个文件不超过 100 MB。", + "supportedTypes": "支持 {types} 文件", + "singleFileLimit": "单个文件不超过 100 MB。", + "fileTooLarge": "单个文件大小不能超过 100 MB", + "unsupportedFileType": "“{fileName}”格式不支持,仅支持 {types} 文件。", + "legacyExcelAsXlsx": "“{fileName}”不是标准 XLSX,可能是旧版 XLS 或已加密文件。请解除保护后用 Excel/WPS 另存为 XLSX(修改文件后缀无效)", + "invalidXlsx": "“{fileName}”不是标准 XLSX,文件内容与扩展名不一致或文件已损坏。请用 Excel/WPS 另存为 XLSX 后重试" }, "uploadFileFirst": "请先上传文件", "deleteModelAlert": "该操作会删除大模型,确定删除吗?", diff --git a/easyflow-ui-admin/app/src/utils/document-parse-file-policy.test.ts b/easyflow-ui-admin/app/src/utils/document-parse-file-policy.test.ts new file mode 100644 index 00000000..d660eccc --- /dev/null +++ b/easyflow-ui-admin/app/src/utils/document-parse-file-policy.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { DocumentParseFilePolicy } from './document-parse-file-policy'; + +vi.mock('#/locales', () => ({ + $t: (key: string, params?: Record) => { + if (key === 'message.upload.unsupportedFileType') { + return `“${params?.fileName}”格式不支持,仅支持 ${params?.types} 文件。`; + } + if (key === 'message.upload.legacyExcelAsXlsx') { + return `“${params?.fileName}”不是标准 XLSX,可能是旧版 XLS 或已加密文件。请解除保护后用 Excel/WPS 另存为 XLSX(修改文件后缀无效)`; + } + return key; + }, +})); + +describe('document parse file policy', () => { + it('provides one accept list for knowledge and workflow uploads', () => { + expect(DocumentParseFilePolicy.accept).toBe( + '.txt,.pdf,.docx,.md,.pptx,.xlsx,.csv', + ); + expect(DocumentParseFilePolicy.supports('REPORT.XLSX')).toBe(true); + expect(DocumentParseFilePolicy.supports('legacy.xls')).toBe(false); + }); + + it('accepts a standard XLSX container signature', async () => { + const file = new File([new Uint8Array([80, 75, 3, 4, 0])], '清单.XLSX'); + + await expect( + DocumentParseFilePolicy.validateFiles([file]), + ).resolves.toBeUndefined(); + }); + + it('rejects unsupported extensions with the supported type list', async () => { + await expect( + DocumentParseFilePolicy.validateFiles([new File(['xls'], 'test.xls')]), + ).rejects.toThrow( + '“test.xls”格式不支持,仅支持 TXT、PDF、DOCX、MD、PPTX、XLSX、CSV 文件。', + ); + }); + + it('rejects legacy XLS content disguised as XLSX', async () => { + const file = new File( + [new Uint8Array([208, 207, 17, 224, 161, 177, 26, 225])], + '整理发布清单.xlsx', + ); + + await expect(DocumentParseFilePolicy.validateFiles([file])).rejects.toThrow( + '“整理发布清单.xlsx”不是标准 XLSX,可能是旧版 XLS 或已加密文件。请解除保护后用 Excel/WPS 另存为 XLSX(修改文件后缀无效)', + ); + }); +}); diff --git a/easyflow-ui-admin/app/src/utils/document-parse-file-policy.ts b/easyflow-ui-admin/app/src/utils/document-parse-file-policy.ts new file mode 100644 index 00000000..0fbdb130 --- /dev/null +++ b/easyflow-ui-admin/app/src/utils/document-parse-file-policy.ts @@ -0,0 +1,83 @@ +import { $t } from '#/locales'; + +const OLE2_SIGNATURE = [208, 207, 17, 224, 161, 177, 26, 225]; + +const supportedExtensions = Object.freeze([ + 'txt', + 'pdf', + 'docx', + 'md', + 'pptx', + 'xlsx', + 'csv', +]); +const supportedExtensionSet = new Set(supportedExtensions); +const supportedTypeLabel = supportedExtensions + .map((extension) => extension.toUpperCase()) + .join('、'); + +function extensionOf(fileName: string) { + const normalizedName = String(fileName || '') + .trim() + .toLowerCase(); + const dotIndex = normalizedName.lastIndexOf('.'); + return dotIndex === -1 ? '' : normalizedName.slice(dotIndex + 1); +} + +function supports(fileName: string) { + return supportedExtensionSet.has(extensionOf(fileName)); +} + +async function validateFiles(files: File[]) { + for (const file of files) { + const extension = extensionOf(file.name); + if (!supportedExtensionSet.has(extension)) { + throw new Error( + $t('message.upload.unsupportedFileType', { + fileName: file.name, + types: supportedTypeLabel, + }), + ); + } + if (extension !== 'xlsx') { + continue; + } + const prefix = new Uint8Array(await file.slice(0, 8).arrayBuffer()); + if (hasZipSignature(prefix)) { + continue; + } + const messageKey = startsWith(prefix, OLE2_SIGNATURE) + ? 'message.upload.legacyExcelAsXlsx' + : 'message.upload.invalidXlsx'; + throw new Error($t(messageKey, { fileName: file.name })); + } +} + +/** + * 知识库导入与工作流文档解析共用的文件格式策略。 + */ +export const DocumentParseFilePolicy = Object.freeze({ + accept: supportedExtensions.map((extension) => `.${extension}`).join(','), + supportedExtensions, + supportedTypeLabel, + supports, + validateFiles, +}); + +function hasZipSignature(prefix: Uint8Array) { + return ( + prefix.length >= 4 && + prefix[0] === 80 && + prefix[1] === 75 && + ((prefix[2] === 3 && prefix[3] === 4) || + (prefix[2] === 5 && prefix[3] === 6) || + (prefix[2] === 7 && prefix[3] === 8)) + ); +} + +function startsWith(prefix: Uint8Array, signature: number[]) { + return ( + prefix.length >= signature.length && + signature.every((value, index) => prefix[index] === value) + ); +} diff --git a/easyflow-ui-admin/app/src/views/ai/documentCollection/ImportKnowledgeFileContainer.test.ts b/easyflow-ui-admin/app/src/views/ai/documentCollection/ImportKnowledgeFileContainer.test.ts new file mode 100644 index 00000000..84dc354c --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/documentCollection/ImportKnowledgeFileContainer.test.ts @@ -0,0 +1,52 @@ +import { shallowMount } from '@vue/test-utils'; + +import { describe, expect, it, vi } from 'vitest'; + +import ImportKnowledgeFileContainer from './ImportKnowledgeFileContainer.vue'; + +vi.mock('@easyflow/hooks', () => ({ + useAppConfig: () => ({ apiURL: '' }), +})); + +vi.mock('@easyflow/locales', () => ({ + $t: (key: string, params?: Record) => + key === 'documentCollection.importDoc.batchUploadDescription' + ? `支持 ${params?.types} 文件` + : key, +})); + +vi.mock('@easyflow/stores', () => ({ + useAccessStore: () => ({ accessToken: 'test-token' }), +})); + +vi.mock('#/api/request', () => ({ api: {} })); + +vi.mock('#/locales', () => ({ + $t: (key: string) => key, +})); + +vi.mock('element-plus/es/components/table-v2/index.mjs', () => ({ + ElAutoResizer: { template: '
' }, + ElTableV2: { template: '
' }, +})); + +vi.mock('element-plus/es/components/table-v2/style/css.mjs', () => ({})); + +describe('import knowledge file container', () => { + it('uses the shared document formats for file and folder selection', () => { + const wrapper = shallowMount(ImportKnowledgeFileContainer, { + props: { batchMode: true }, + }); + const inputs = wrapper.findAll('input[type="file"]'); + + expect(inputs).toHaveLength(2); + for (const input of inputs) { + expect(input.attributes('accept')).toBe( + '.txt,.pdf,.docx,.md,.pptx,.xlsx,.csv', + ); + } + expect(wrapper.text()).toContain( + '支持 TXT、PDF、DOCX、MD、PPTX、XLSX、CSV 文件', + ); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/ai/documentCollection/ImportKnowledgeFileContainer.vue b/easyflow-ui-admin/app/src/views/ai/documentCollection/ImportKnowledgeFileContainer.vue index d27ae9d3..1ff9f732 100644 --- a/easyflow-ui-admin/app/src/views/ai/documentCollection/ImportKnowledgeFileContainer.vue +++ b/easyflow-ui-admin/app/src/views/ai/documentCollection/ImportKnowledgeFileContainer.vue @@ -22,6 +22,7 @@ import { import { formatFileSize } from '#/api/common/file'; import { api } from '#/api/request'; import DragFileUpload from '#/components/upload/DragFileUpload.vue'; +import { DocumentParseFilePolicy } from '#/utils/document-parse-file-policy'; import { resolveDocumentUploadResponse } from './document-import-upload-response'; @@ -80,16 +81,6 @@ const emit = defineEmits<{ const MAX_FILE_COUNT = 2000; const MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024; const MAX_TOTAL_SIZE_BYTES = 1024 * 1024 * 1024; -const SUPPORTED_EXTENSIONS = new Set([ - 'csv', - 'docx', - 'md', - 'pdf', - 'pptx', - 'txt', - 'xlsx', -]); - const fileData = ref([]); const filesPath = ref([]); const dragUploadRef = ref>(); @@ -331,8 +322,7 @@ async function prepareBatch(files: File[]) { ignoredCount++; continue; } - const extension = file.name.split('.').pop()?.toLowerCase() || ''; - if (!SUPPORTED_EXTENSIONS.has(extension)) { + if (!DocumentParseFilePolicy.supports(file.name)) { ignoredCount++; continue; } @@ -344,7 +334,11 @@ async function prepareBatch(files: File[]) { accepted.push(file); } if (accepted.length === 0) { - ElMessage.warning($t('documentCollection.importDoc.noSupportedFiles')); + ElMessage.warning( + $t('documentCollection.importDoc.noSupportedFiles', { + types: DocumentParseFilePolicy.supportedTypeLabel, + }), + ); return; } if (accepted.length > MAX_FILE_COUNT) { @@ -356,7 +350,17 @@ async function prepareBatch(files: File[]) { return; } if (ignoredCount > 0) { - ElMessage.info($t('documentCollection.importDoc.unsupportedSkipped')); + ElMessage.info( + $t('documentCollection.importDoc.unsupportedSkipped', { + types: DocumentParseFilePolicy.supportedTypeLabel, + }), + ); + } + try { + await DocumentParseFilePolicy.validateFiles(accepted); + } catch (error: any) { + ElMessage.warning(error?.message || $t('message.notSupported')); + return; } batchFiles.value = await Promise.all( @@ -538,7 +542,7 @@ async function createClientFileKey(relativePath: string) { class="native-file-input" type="file" multiple - accept=".txt,.pdf,.docx,.md,.pptx,.xlsx,.csv" + :accept="DocumentParseFilePolicy.accept" @change="handleNativeSelection" /> @@ -564,7 +569,11 @@ async function createClientFileKey(relativePath: string) { {{ $t('documentCollection.importDoc.batchUploadTitle') }}
- {{ $t('documentCollection.importDoc.batchUploadDescription') }} + {{ + $t('documentCollection.importDoc.batchUploadDescription', { + types: DocumentParseFilePolicy.supportedTypeLabel, + }) + }}
(null); const currentFiles = computed(() => normalizeWorkflowFileValues(props.modelValue), ); -const maxSingleFileSizeText = formatWorkflowFileSize( - WORKFLOW_FILE_LIMITS.maxSingleSize, -).replace('.0 ', ' '); function triggerSelectFile() { if (props.disabled || uploadLoading.value) { @@ -69,6 +66,7 @@ async function uploadFiles(files: File[]) { uploadLoading.value = true; try { validateWorkflowFileSelection(currentFiles.value, files); + await DocumentParseFilePolicy.validateFiles(files); const uploadedFiles = []; for (const file of files) { const res = await api.upload( @@ -134,6 +132,7 @@ function removeFile(filePath: string) { ref="fileInputRef" class="workflow-file-input__native" type="file" + :accept="DocumentParseFilePolicy.accept" :disabled="disabled" multiple @change="handleNativeFileChange" @@ -167,7 +166,16 @@ function removeFile(filePath: string) { : '拖入文件或点击上传' }} - 单个文件不超过 {{ maxSingleFileSizeText }} + + + {{ + $t('message.upload.supportedTypes', { + types: DocumentParseFilePolicy.supportedTypeLabel, + }) + }} + + {{ $t('message.upload.singleFileLimit') }} + @@ -287,13 +295,15 @@ function removeFile(filePath: string) { .workflow-file-input__dropzone-copy { display: flex; - flex-flow: row wrap; - gap: var(--space-1) var(--space-2); - align-items: center; + flex-direction: column; + gap: var(--space-1); + align-items: flex-start; line-height: 1.4; } -.workflow-file-input__dropzone-copy small { +.workflow-file-input__hint { + display: flex; + flex-direction: column; font-size: 11px; color: var(--el-text-color-placeholder); } diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/components/__tests__/workflowFileInput.test.ts b/easyflow-ui-admin/app/src/views/ai/workflow/components/__tests__/workflowFileInput.test.ts index a50ca49d..89ce87f1 100644 --- a/easyflow-ui-admin/app/src/views/ai/workflow/components/__tests__/workflowFileInput.test.ts +++ b/easyflow-ui-admin/app/src/views/ai/workflow/components/__tests__/workflowFileInput.test.ts @@ -1,11 +1,62 @@ -import { mount } from '@vue/test-utils'; +import { flushPromises, mount } from '@vue/test-utils'; import { defineComponent, nextTick, ref } from 'vue'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import WorkflowFileInput from '../WorkflowFileInput.vue'; +const requestMocks = vi.hoisted(() => ({ + upload: vi.fn(), +})); + +vi.mock('#/api/request', () => ({ + api: { + upload: requestMocks.upload, + }, +})); + +vi.mock('#/locales', () => ({ + $t: (key: string, params?: Record) => { + if (key === 'message.upload.supportedTypes') { + return `支持 ${params?.types} 文件`; + } + if (key === 'message.upload.singleFileLimit') { + return '单个文件不超过 100 MB。'; + } + if (key === 'message.upload.unsupportedFileType') { + return `“${params?.fileName}”格式不支持,仅支持 ${params?.types} 文件。`; + } + if (key === 'message.upload.legacyExcelAsXlsx') { + return `“${params?.fileName}”不是标准 XLSX`; + } + return key; + }, +})); + describe('workflow file input', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('limits the file chooser and shows the supported formats', () => { + const wrapper = mount(WorkflowFileInput); + + expect(wrapper.get('input[type="file"]').attributes('accept')).toBe( + '.txt,.pdf,.docx,.md,.pptx,.xlsx,.csv', + ); + expect(wrapper.text()).toContain( + '支持 TXT、PDF、DOCX、MD、PPTX、XLSX、CSV 文件', + ); + expect( + wrapper + .findAll('.workflow-file-input__hint > span') + .map((item) => item.text()), + ).toEqual([ + '支持 TXT、PDF、DOCX、MD、PPTX、XLSX、CSV 文件', + '单个文件不超过 100 MB。', + ]); + }); + it('shows the upload area again after the uploaded file is deleted', async () => { const Host = defineComponent({ components: { WorkflowFileInput }, @@ -55,4 +106,39 @@ describe('workflow file input', () => { wrapper.get('.workflow-file-input__upload-trigger').attributes(), ).toHaveProperty('disabled'); }); + + it('rejects legacy XLS content before starting upload', async () => { + requestMocks.upload.mockReset(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const wrapper = mount(WorkflowFileInput); + const input = wrapper.get('input[type="file"]'); + const file = new File( + [new Uint8Array([208, 207, 17, 224, 161, 177, 26, 225])], + 'legacy.xlsx', + ); + Object.defineProperty(input.element, 'files', { + configurable: true, + value: [file], + }); + + await input.trigger('change'); + await flushPromises(); + + expect(requestMocks.upload).not.toHaveBeenCalled(); + }); + + it('rejects a dragged legacy XLS file before starting upload', async () => { + requestMocks.upload.mockReset(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const wrapper = mount(WorkflowFileInput); + + await wrapper.get('.workflow-file-input__dropzone').trigger('drop', { + dataTransfer: { + files: [new File(['legacy'], 'test.xls')], + }, + }); + await flushPromises(); + + expect(requestMocks.upload).not.toHaveBeenCalled(); + }); });