fix: 统一文档解析文件格式校验
- 统一知识库和工作流支持格式并增加前后端上传拦截 - 拒绝 XLS 与伪装 XLSX,避免空内容解析成功
This commit is contained in:
@@ -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<String> SUPPORTED_EXTENSION_ORDER =
|
||||
List.of("txt", "pdf", "docx", "md", "pptx", "xlsx", "csv");
|
||||
private static final Set<String> 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<String> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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()
|
||||
+ " 文件"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> SUPPORTED_EXTENSIONS =
|
||||
Set.of("txt", "pdf", "docx", "md", "pptx", "xlsx", "csv");
|
||||
|
||||
/**
|
||||
* 禁止实例化格式策略工具类。
|
||||
*/
|
||||
private DocumentImportFormatPolicy() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回只读的支持格式集合。
|
||||
*
|
||||
* @return 支持的文件扩展名
|
||||
*/
|
||||
public static Set<String> supportedExtensions() {
|
||||
return SUPPORTED_EXTENSIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断扩展名是否属于知识库导入支持范围。
|
||||
*
|
||||
* @param extension 已转换为小写的文件扩展名
|
||||
* @return 支持时返回 {@code true}
|
||||
*/
|
||||
public static boolean isSupported(String extension) {
|
||||
return SUPPORTED_EXTENSIONS.contains(extension);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
+ " 文档导入"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> 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()
|
||||
+ " 文件"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Object> 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 =
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带指定初始状态的设计器节点。
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 的非桥接文件仍拒绝访问回环地址。
|
||||
*/
|
||||
|
||||
@@ -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<string, string>) =>
|
||||
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<boolean>;
|
||||
|
||||
await expect(beforeUpload(new File(['legacy'], 'test.xls'))).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base">{{ $t('message.upload.title') }}</span>
|
||||
<span class="text-muted-foreground text-sm">{{
|
||||
$t('message.upload.description')
|
||||
$t('message.upload.description', {
|
||||
types: DocumentParseFilePolicy.supportedTypeLabel,
|
||||
})
|
||||
}}</span>
|
||||
</div>
|
||||
</ElUpload>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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": "文件上传失败,请重试",
|
||||
|
||||
@@ -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": "该操作会删除大模型,确定删除吗?",
|
||||
|
||||
@@ -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<string, string>) => {
|
||||
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(修改文件后缀无效)',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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<string, string>) =>
|
||||
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: '<div><slot :height="300" :width="600" /></div>' },
|
||||
ElTableV2: { template: '<div />' },
|
||||
}));
|
||||
|
||||
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 文件',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<LegacyFileInfo[]>([]);
|
||||
const filesPath = ref<any[]>([]);
|
||||
const dragUploadRef = ref<InstanceType<typeof DragFileUpload>>();
|
||||
@@ -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"
|
||||
/>
|
||||
<input
|
||||
@@ -546,6 +550,7 @@ async function createClientFileKey(relativePath: string) {
|
||||
class="native-file-input"
|
||||
type="file"
|
||||
multiple
|
||||
:accept="DocumentParseFilePolicy.accept"
|
||||
webkitdirectory
|
||||
@change="handleNativeSelection"
|
||||
/>
|
||||
@@ -564,7 +569,11 @@ async function createClientFileKey(relativePath: string) {
|
||||
{{ $t('documentCollection.importDoc.batchUploadTitle') }}
|
||||
</div>
|
||||
<div class="batch-drop-zone__description">
|
||||
{{ $t('documentCollection.importDoc.batchUploadDescription') }}
|
||||
{{
|
||||
$t('documentCollection.importDoc.batchUploadDescription', {
|
||||
types: DocumentParseFilePolicy.supportedTypeLabel,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<ElButton
|
||||
:icon="FolderOpened"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ElButton, ElIcon, ElLink, ElMessage } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { $t } from '#/locales';
|
||||
import { DocumentParseFilePolicy } from '#/utils/document-parse-file-policy';
|
||||
|
||||
import {
|
||||
appendWorkflowFileValues,
|
||||
@@ -19,7 +20,6 @@ import {
|
||||
normalizeWorkflowFileValues,
|
||||
validateWorkflowFileSelection,
|
||||
validateWorkflowFileValues,
|
||||
WORKFLOW_FILE_LIMITS,
|
||||
} from './workflowFileValue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -50,9 +50,6 @@ const fileInputRef = ref<HTMLInputElement | null>(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) {
|
||||
: '拖入文件或点击上传'
|
||||
}}
|
||||
</span>
|
||||
<small>单个文件不超过 {{ maxSingleFileSizeText }}</small>
|
||||
<small class="workflow-file-input__hint">
|
||||
<span>
|
||||
{{
|
||||
$t('message.upload.supportedTypes', {
|
||||
types: DocumentParseFilePolicy.supportedTypeLabel,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span>{{ $t('message.upload.singleFileLimit') }}</span>
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<string, string>) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user