fix: 统一文档解析文件格式校验
- 统一知识库和工作流支持格式并增加前后端上传拦截 - 拒绝 XLS 与伪装 XLSX,避免空内容解析成功
This commit is contained in:
@@ -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