From 2df79c04045f13a40234023827835a4c58134aa2 Mon Sep 17 00:00:00 2001 From: JzoNg Date: Fri, 10 Apr 2026 12:04:51 +0800 Subject: [PATCH] refactor: input fields --- .../batch-test-panel/input-fields-tab.tsx | 238 +++--------------- .../input-fields-requirements.tsx | 45 ++++ .../input-fields/input-fields-utils.ts | 54 ++++ .../input-fields/upload-run-popover.tsx | 187 ++++++++++++++ .../input-fields/use-input-fields-actions.ts | 165 ++++++++++++ .../use-published-input-fields.ts | 29 +++ web/i18n/en-US/evaluation.json | 8 + web/i18n/zh-Hans/evaluation.json | 8 + 8 files changed, 529 insertions(+), 205 deletions(-) create mode 100644 web/app/components/evaluation/components/batch-test-panel/input-fields/input-fields-requirements.tsx create mode 100644 web/app/components/evaluation/components/batch-test-panel/input-fields/input-fields-utils.ts create mode 100644 web/app/components/evaluation/components/batch-test-panel/input-fields/upload-run-popover.tsx create mode 100644 web/app/components/evaluation/components/batch-test-panel/input-fields/use-input-fields-actions.ts create mode 100644 web/app/components/evaluation/components/batch-test-panel/input-fields/use-published-input-fields.ts diff --git a/web/app/components/evaluation/components/batch-test-panel/input-fields-tab.tsx b/web/app/components/evaluation/components/batch-test-panel/input-fields-tab.tsx index f70de8c5b2..8d147dbedf 100644 --- a/web/app/components/evaluation/components/batch-test-panel/input-fields-tab.tsx +++ b/web/app/components/evaluation/components/batch-test-panel/input-fields-tab.tsx @@ -1,58 +1,17 @@ -import type { ChangeEvent } from 'react' import type { EvaluationResourceProps } from '../../types' -import type { StartNodeType } from '@/app/components/workflow/nodes/start/types' -import type { InputVar, Node } from '@/app/components/workflow/types' -import { useMutation } from '@tanstack/react-query' -import { useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' -import { toast } from '@/app/components/base/ui/toast' -import { inputVarTypeToVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils' -import { BlockEnum, InputVarType } from '@/app/components/workflow/types' -import { upload } from '@/service/base' -import { useStartEvaluationRunMutation } from '@/service/use-evaluation' -import { useSnippetPublishedWorkflow } from '@/service/use-snippet-workflows' -import { useAppWorkflow } from '@/service/use-workflow' import { getEvaluationMockConfig } from '../../mock' -import { useEvaluationResource, useEvaluationStore } from '../../store' -import { buildEvaluationRunRequest } from '../../store-utils' +import InputFieldsRequirements from './input-fields/input-fields-requirements' +import UploadRunPopover from './input-fields/upload-run-popover' +import { useInputFieldsActions } from './input-fields/use-input-fields-actions' +import { usePublishedInputFields } from './input-fields/use-published-input-fields' type InputFieldsTabProps = EvaluationResourceProps & { isPanelReady: boolean isRunnable: boolean } -type InputField = { - name: string - type: string -} - -const getGraphNodes = (graph?: Record) => { - return Array.isArray(graph?.nodes) ? graph.nodes as Node[] : [] -} - -const getStartNodeInputFields = (nodes?: Node[]): InputField[] => { - const startNode = nodes?.find(node => node.data.type === BlockEnum.Start) as Node | undefined - const variables = startNode?.data.variables - - if (!Array.isArray(variables)) - return [] - - return variables - .filter((variable): variable is InputVar => typeof variable.variable === 'string' && !!variable.variable) - .map(variable => ({ - name: variable.variable, - type: inputVarTypeToVarType(variable.type ?? InputVarType.textInput), - })) -} - -const escapeCsvCell = (value: string) => { - if (!/[",\n\r]/.test(value)) - return value - - return `"${value.replace(/"/g, '""')}"` -} - const InputFieldsTab = ({ resourceType, resourceId, @@ -61,181 +20,50 @@ const InputFieldsTab = ({ }: InputFieldsTabProps) => { const { t } = useTranslation('evaluation') const config = getEvaluationMockConfig(resourceType) - const { data: currentAppWorkflow, isLoading: isAppWorkflowLoading } = useAppWorkflow(resourceType === 'apps' ? resourceId : '') - const { data: currentSnippetWorkflow, isLoading: isSnippetWorkflowLoading } = useSnippetPublishedWorkflow(resourceType === 'snippets' ? resourceId : '') - const inputFields = useMemo(() => { - if (resourceType === 'apps') - return getStartNodeInputFields(currentAppWorkflow?.graph.nodes) - - if (resourceType === 'snippets') - return getStartNodeInputFields(getGraphNodes(currentSnippetWorkflow?.graph)) - - return [] - }, [currentAppWorkflow?.graph.nodes, currentSnippetWorkflow?.graph, resourceType]) - const resource = useEvaluationResource(resourceType, resourceId) - const uploadedFileId = resource.uploadedFileId - const uploadedFileName = resource.uploadedFileName - const setBatchTab = useEvaluationStore(state => state.setBatchTab) - const setUploadedFile = useEvaluationStore(state => state.setUploadedFile) - const setUploadedFileName = useEvaluationStore(state => state.setUploadedFileName) - const startRunMutation = useStartEvaluationRunMutation() - const uploadMutation = useMutation({ - mutationFn: (file: File) => { - const formData = new FormData() - formData.append('file', file) - - return upload({ - xhr: new XMLHttpRequest(), - data: formData, - }) - }, - onSuccess: (uploadedFile) => { - setUploadedFile(resourceType, resourceId, { - id: uploadedFile.id, - name: typeof uploadedFile.name === 'string' ? uploadedFile.name : uploadedFileName ?? uploadedFile.id, - }) - }, - onError: () => { - setUploadedFile(resourceType, resourceId, null) - toast.error(t('batch.uploadError')) - }, + const { inputFields, isInputFieldsLoading } = usePublishedInputFields(resourceType, resourceId) + const actions = useInputFieldsActions({ + resourceType, + resourceId, + inputFields, + isInputFieldsLoading, + isPanelReady, + isRunnable, + templateFileName: config.templateFileName, }) - const fileInputRef = useRef(null) - const isFileUploading = uploadMutation.isPending - const isRunning = startRunMutation.isPending - const isInputFieldsLoading = (resourceType === 'apps' && isAppWorkflowLoading) - || (resourceType === 'snippets' && isSnippetWorkflowLoading) - const canDownloadTemplate = isPanelReady && !isInputFieldsLoading && inputFields.length > 0 - const isRunDisabled = !isRunnable || !uploadedFileId || isFileUploading || isRunning - - const handleDownloadTemplate = () => { - if (!inputFields.length) { - toast.warning(t('batch.noInputFields')) - return - } - - const content = `${inputFields.map(field => escapeCsvCell(field.name)).join(',')}\n` - const link = document.createElement('a') - link.href = `data:text/csv;charset=utf-8,${encodeURIComponent(content)}` - link.download = config.templateFileName - link.click() - } - - const handleRun = () => { - if (!isRunnable) { - toast.warning(t('batch.validation')) - return - } - - if (isFileUploading) { - toast.warning(t('batch.uploading')) - return - } - - if (!uploadedFileId) { - toast.warning(t('batch.fileRequired')) - return - } - - const body = buildEvaluationRunRequest(resource, uploadedFileId) - - if (!body) { - toast.warning(t('batch.validation')) - return - } - - startRunMutation.mutate({ - params: { - targetType: resourceType, - targetId: resourceId, - }, - body, - }, { - onSuccess: () => { - toast.success(t('batch.runStarted')) - setBatchTab(resourceType, resourceId, 'history') - }, - onError: () => { - toast.error(t('batch.runFailed')) - }, - }) - } - - const handleFileChange = (event: ChangeEvent) => { - const file = event.target.files?.[0] - event.target.value = '' - - if (!file) { - setUploadedFile(resourceType, resourceId, null) - return - } - - setUploadedFileName(resourceType, resourceId, file.name) - uploadMutation.mutate(file) - } return (
-
-
{t('batch.requirementsTitle')}
-
{t('batch.requirementsDescription')}
-
- {isInputFieldsLoading && ( -
- {t('batch.loadingInputFields')} -
- )} - {!isInputFieldsLoading && inputFields.length === 0 && ( -
- {t('batch.noInputFields')} -
- )} - {!isInputFieldsLoading && inputFields.map(field => ( -
-
- {field.name} -
-
- {field.type} -
-
- ))} -
-
+
- - - {isPanelReady && ( - - )}
{!isRunnable && (
{t('batch.validation')}
)} -
) } diff --git a/web/app/components/evaluation/components/batch-test-panel/input-fields/input-fields-requirements.tsx b/web/app/components/evaluation/components/batch-test-panel/input-fields/input-fields-requirements.tsx new file mode 100644 index 0000000000..83201ea5a7 --- /dev/null +++ b/web/app/components/evaluation/components/batch-test-panel/input-fields/input-fields-requirements.tsx @@ -0,0 +1,45 @@ +import type { InputField } from './input-fields-utils' +import { useTranslation } from 'react-i18next' + +type InputFieldsRequirementsProps = { + inputFields: InputField[] + isLoading: boolean +} + +const InputFieldsRequirements = ({ + inputFields, + isLoading, +}: InputFieldsRequirementsProps) => { + const { t } = useTranslation('evaluation') + + return ( +
+
{t('batch.requirementsTitle')}
+
{t('batch.requirementsDescription')}
+
+ {isLoading && ( +
+ {t('batch.loadingInputFields')} +
+ )} + {!isLoading && inputFields.length === 0 && ( +
+ {t('batch.noInputFields')} +
+ )} + {!isLoading && inputFields.map(field => ( +
+
+ {field.name} +
+
+ {field.type} +
+
+ ))} +
+
+ ) +} + +export default InputFieldsRequirements diff --git a/web/app/components/evaluation/components/batch-test-panel/input-fields/input-fields-utils.ts b/web/app/components/evaluation/components/batch-test-panel/input-fields/input-fields-utils.ts new file mode 100644 index 0000000000..5a71b81d06 --- /dev/null +++ b/web/app/components/evaluation/components/batch-test-panel/input-fields/input-fields-utils.ts @@ -0,0 +1,54 @@ +import type { StartNodeType } from '@/app/components/workflow/nodes/start/types' +import type { InputVar, Node } from '@/app/components/workflow/types' +import { inputVarTypeToVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils' +import { BlockEnum, InputVarType } from '@/app/components/workflow/types' + +export type InputField = { + name: string + type: string +} + +export const getGraphNodes = (graph?: Record) => { + return Array.isArray(graph?.nodes) ? graph.nodes as Node[] : [] +} + +export const getStartNodeInputFields = (nodes?: Node[]): InputField[] => { + const startNode = nodes?.find(node => node.data.type === BlockEnum.Start) as Node | undefined + const variables = startNode?.data.variables + + if (!Array.isArray(variables)) + return [] + + return variables + .filter((variable): variable is InputVar => typeof variable.variable === 'string' && !!variable.variable) + .map(variable => ({ + name: variable.variable, + type: inputVarTypeToVarType(variable.type ?? InputVarType.textInput), + })) +} + +const escapeCsvCell = (value: string) => { + if (!/[",\n\r]/.test(value)) + return value + + return `"${value.replace(/"/g, '""')}"` +} + +export const buildTemplateCsvContent = (inputFields: InputField[]) => { + return `${inputFields.map(field => escapeCsvCell(field.name)).join(',')}\n` +} + +export const getFileExtension = (fileName: string) => { + const extension = fileName.split('.').pop() + return extension && extension !== fileName ? extension.toUpperCase() : '' +} + +export const getExampleValue = (field: InputField, booleanLabel: string) => { + if (field.type === 'number') + return '0.7' + + if (field.type === 'boolean') + return booleanLabel + + return field.name +} diff --git a/web/app/components/evaluation/components/batch-test-panel/input-fields/upload-run-popover.tsx b/web/app/components/evaluation/components/batch-test-panel/input-fields/upload-run-popover.tsx new file mode 100644 index 0000000000..01c9d84770 --- /dev/null +++ b/web/app/components/evaluation/components/batch-test-panel/input-fields/upload-run-popover.tsx @@ -0,0 +1,187 @@ +import type { ChangeEvent, DragEvent } from 'react' +import type { InputField } from './input-fields-utils' +import { useRef } from 'react' +import { useTranslation } from 'react-i18next' +import Button from '@/app/components/base/button' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/app/components/base/ui/popover' +import { cn } from '@/utils/classnames' +import { getExampleValue } from './input-fields-utils' + +type UploadRunPopoverProps = { + open: boolean + onOpenChange: (open: boolean) => void + triggerDisabled: boolean + inputFields: InputField[] + currentFileName: string | null | undefined + currentFileExtension: string + currentFileSize: string | number + isFileUploading: boolean + isRunDisabled: boolean + isRunning: boolean + onUploadFile: (file: File | undefined) => void + onClearUploadedFile: () => void + onDownloadTemplate: () => void + onRun: () => void +} + +const UploadRunPopover = ({ + open, + onOpenChange, + triggerDisabled, + inputFields, + currentFileName, + currentFileExtension, + currentFileSize, + isFileUploading, + isRunDisabled, + isRunning, + onUploadFile, + onClearUploadedFile, + onDownloadTemplate, + onRun, +}: UploadRunPopoverProps) => { + const { t } = useTranslation('evaluation') + const { t: tCommon } = useTranslation('common') + const fileInputRef = useRef(null) + const previewFields = inputFields.slice(0, 3) + const booleanExampleValue = t('conditions.boolean.true') + + const handleFileChange = (event: ChangeEvent) => { + onUploadFile(event.target.files?.[0]) + event.target.value = '' + } + + const handleDropFile = (event: DragEvent) => { + event.preventDefault() + onUploadFile(event.dataTransfer.files?.[0]) + } + + return ( + + + {t('batch.uploadAndRun')} + + )} + /> + +
+
+ + {currentFileName + ? ( +
+
+
+
+
+ {currentFileName} +
+
+ {!!currentFileExtension && {currentFileExtension}} + {!!currentFileExtension && !!currentFileSize && ·} + {!!currentFileSize && {currentFileSize}} +
+
+
+ {isFileUploading && ( +
+
+ ) + : ( +
event.preventDefault()} + onDrop={handleDropFile} + > + +
+
+ {t('batch.uploadDropzonePrefix')} + {' '} + {t('batch.uploadDropzoneEmphasis')} + {' '} + {t('batch.uploadDropzoneSuffix')} +
+
+ {t('batch.uploadDropzoneDownloadPrefix')} + {' '} + +
+
+
+ )} + + {!!previewFields.length && ( +
+
{t('batch.example')}
+
+ {previewFields.map((field, index) => ( +
+
+ {field.name} +
+
+ {getExampleValue(field, booleanExampleValue)} +
+
+ ))} +
+
+ )} +
+
+ + +
+
+
+
+ ) +} + +export default UploadRunPopover diff --git a/web/app/components/evaluation/components/batch-test-panel/input-fields/use-input-fields-actions.ts b/web/app/components/evaluation/components/batch-test-panel/input-fields/use-input-fields-actions.ts new file mode 100644 index 0000000000..7740323569 --- /dev/null +++ b/web/app/components/evaluation/components/batch-test-panel/input-fields/use-input-fields-actions.ts @@ -0,0 +1,165 @@ +import type { EvaluationResourceProps } from '../../../types' +import type { InputField } from './input-fields-utils' +import { useMutation } from '@tanstack/react-query' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from '@/app/components/base/ui/toast' +import { upload } from '@/service/base' +import { useStartEvaluationRunMutation } from '@/service/use-evaluation' +import { formatFileSize } from '@/utils/format' +import { useEvaluationResource, useEvaluationStore } from '../../../store' +import { buildEvaluationRunRequest } from '../../../store-utils' +import { buildTemplateCsvContent, getFileExtension } from './input-fields-utils' + +type UploadedFileMeta = { + name: string + size: number +} + +type UseInputFieldsActionsParams = EvaluationResourceProps & { + inputFields: InputField[] + isInputFieldsLoading: boolean + isPanelReady: boolean + isRunnable: boolean + templateFileName: string +} + +export const useInputFieldsActions = ({ + resourceType, + resourceId, + inputFields, + isInputFieldsLoading, + isPanelReady, + isRunnable, + templateFileName, +}: UseInputFieldsActionsParams) => { + const { t } = useTranslation('evaluation') + const resource = useEvaluationResource(resourceType, resourceId) + const setBatchTab = useEvaluationStore(state => state.setBatchTab) + const setUploadedFile = useEvaluationStore(state => state.setUploadedFile) + const setUploadedFileName = useEvaluationStore(state => state.setUploadedFileName) + const startRunMutation = useStartEvaluationRunMutation() + const [isUploadPopoverOpen, setIsUploadPopoverOpen] = useState(false) + const [uploadedFileMeta, setUploadedFileMeta] = useState(null) + const uploadMutation = useMutation({ + mutationFn: (file: File) => { + const formData = new FormData() + formData.append('file', file) + + return upload({ + xhr: new XMLHttpRequest(), + data: formData, + }) + }, + onSuccess: (uploadedFile, file) => { + setUploadedFile(resourceType, resourceId, { + id: uploadedFile.id, + name: typeof uploadedFile.name === 'string' ? uploadedFile.name : file.name, + }) + }, + onError: () => { + setUploadedFileMeta(null) + setUploadedFile(resourceType, resourceId, null) + toast.error(t('batch.uploadError')) + }, + }) + + const isFileUploading = uploadMutation.isPending + const isRunning = startRunMutation.isPending + const uploadedFileId = resource.uploadedFileId + const currentFileName = uploadedFileMeta?.name ?? resource.uploadedFileName + const canDownloadTemplate = isPanelReady && !isInputFieldsLoading && inputFields.length > 0 + const isRunDisabled = !isRunnable || !uploadedFileId || isFileUploading || isRunning + const uploadButtonDisabled = !isPanelReady || isInputFieldsLoading || isRunning + + const handleDownloadTemplate = () => { + if (!inputFields.length) { + toast.warning(t('batch.noInputFields')) + return + } + + const content = buildTemplateCsvContent(inputFields) + const link = document.createElement('a') + link.href = `data:text/csv;charset=utf-8,${encodeURIComponent(content)}` + link.download = templateFileName + link.click() + } + + const handleRun = () => { + if (!isRunnable) { + toast.warning(t('batch.validation')) + return + } + + if (isFileUploading) { + toast.warning(t('batch.uploading')) + return + } + + if (!uploadedFileId) { + toast.warning(t('batch.fileRequired')) + return + } + + const body = buildEvaluationRunRequest(resource, uploadedFileId) + + if (!body) { + toast.warning(t('batch.validation')) + return + } + + startRunMutation.mutate({ + params: { + targetType: resourceType, + targetId: resourceId, + }, + body, + }, { + onSuccess: () => { + toast.success(t('batch.runStarted')) + setIsUploadPopoverOpen(false) + setBatchTab(resourceType, resourceId, 'history') + }, + onError: () => { + toast.error(t('batch.runFailed')) + }, + }) + } + + const handleUploadFile = (file: File | undefined) => { + if (!file) { + setUploadedFileMeta(null) + setUploadedFile(resourceType, resourceId, null) + return + } + + setUploadedFileMeta({ + name: file.name, + size: file.size, + }) + setUploadedFileName(resourceType, resourceId, file.name) + uploadMutation.mutate(file) + } + + const handleClearUploadedFile = () => { + setUploadedFileMeta(null) + setUploadedFile(resourceType, resourceId, null) + } + + return { + canDownloadTemplate, + currentFileExtension: currentFileName ? getFileExtension(currentFileName) : '', + currentFileName, + currentFileSize: uploadedFileMeta ? formatFileSize(uploadedFileMeta.size) : '', + handleClearUploadedFile, + handleDownloadTemplate, + handleRun, + handleUploadFile, + isFileUploading, + isRunning, + isRunDisabled, + isUploadPopoverOpen, + setIsUploadPopoverOpen, + uploadButtonDisabled, + } +} diff --git a/web/app/components/evaluation/components/batch-test-panel/input-fields/use-published-input-fields.ts b/web/app/components/evaluation/components/batch-test-panel/input-fields/use-published-input-fields.ts new file mode 100644 index 0000000000..a319603026 --- /dev/null +++ b/web/app/components/evaluation/components/batch-test-panel/input-fields/use-published-input-fields.ts @@ -0,0 +1,29 @@ +import type { EvaluationResourceType } from '../../../types' +import { useMemo } from 'react' +import { useSnippetPublishedWorkflow } from '@/service/use-snippet-workflows' +import { useAppWorkflow } from '@/service/use-workflow' +import { getGraphNodes, getStartNodeInputFields } from './input-fields-utils' + +export const usePublishedInputFields = ( + resourceType: EvaluationResourceType, + resourceId: string, +) => { + const { data: currentAppWorkflow, isLoading: isAppWorkflowLoading } = useAppWorkflow(resourceType === 'apps' ? resourceId : '') + const { data: currentSnippetWorkflow, isLoading: isSnippetWorkflowLoading } = useSnippetPublishedWorkflow(resourceType === 'snippets' ? resourceId : '') + + const inputFields = useMemo(() => { + if (resourceType === 'apps') + return getStartNodeInputFields(currentAppWorkflow?.graph.nodes) + + if (resourceType === 'snippets') + return getStartNodeInputFields(getGraphNodes(currentSnippetWorkflow?.graph)) + + return [] + }, [currentAppWorkflow?.graph.nodes, currentSnippetWorkflow?.graph, resourceType]) + + return { + inputFields, + isInputFieldsLoading: (resourceType === 'apps' && isAppWorkflowLoading) + || (resourceType === 'snippets' && isSnippetWorkflowLoading), + } +} diff --git a/web/i18n/en-US/evaluation.json b/web/i18n/en-US/evaluation.json index 57033d17e4..82a2c29250 100644 --- a/web/i18n/en-US/evaluation.json +++ b/web/i18n/en-US/evaluation.json @@ -2,11 +2,13 @@ "batch.description": "Execute batch evaluations and track performance history.", "batch.downloadTemplate": "Download Excel Template", "batch.emptyHistory": "No test history yet.", + "batch.example": "Example:", "batch.fileRequired": "Upload an evaluation dataset file before running the test.", "batch.loadingInputFields": "Loading input fields...", "batch.noInputFields": "No published start node input fields found.", "batch.noticeDescription": "Configuration incomplete. Select the Judge Model and Metrics on the left to generate your batch test template.", "batch.noticeTitle": "Quick start", + "batch.removeUploadedFile": "Remove uploaded file", "batch.requirementsDescription": "The input variables required to run this batch test. Ensure your uploaded dataset matches these fields.", "batch.requirementsTitle": "Data requirements", "batch.run": "Run Test", @@ -18,6 +20,12 @@ "batch.tabs.history": "Test History", "batch.tabs.input-fields": "Input Fields", "batch.title": "Batch Test", + "batch.uploadAndRun": "Upload & Run Test", + "batch.uploadDropzoneDownloadLink": "here", + "batch.uploadDropzoneDownloadPrefix": "Don't have the template? Download", + "batch.uploadDropzoneEmphasis": "filled", + "batch.uploadDropzonePrefix": "Drag and drop your", + "batch.uploadDropzoneSuffix": "Excel Template", "batch.uploadError": "Failed to upload file.", "batch.uploadHint": "Select a .csv or .xlsx file", "batch.uploadTitle": "Upload test file", diff --git a/web/i18n/zh-Hans/evaluation.json b/web/i18n/zh-Hans/evaluation.json index 2d54c30385..40c0478138 100644 --- a/web/i18n/zh-Hans/evaluation.json +++ b/web/i18n/zh-Hans/evaluation.json @@ -2,11 +2,13 @@ "batch.description": "执行批量评测并追踪性能历史。", "batch.downloadTemplate": "下载 Excel 模板", "batch.emptyHistory": "还没有测试历史。", + "batch.example": "示例:", "batch.fileRequired": "请先上传评估数据集文件,再运行测试。", "batch.loadingInputFields": "正在加载输入字段...", "batch.noInputFields": "未找到已发布 Start 节点的输入字段。", "batch.noticeDescription": "配置尚未完成。请先在左侧选择判定模型和指标,以生成批量测试模板。", "batch.noticeTitle": "快速开始", + "batch.removeUploadedFile": "移除已上传文件", "batch.requirementsDescription": "运行此批量测试所需的输入变量。请确保上传的数据集包含这些字段。", "batch.requirementsTitle": "数据要求", "batch.run": "运行测试", @@ -18,6 +20,12 @@ "batch.tabs.history": "测试历史", "batch.tabs.input-fields": "输入字段", "batch.title": "批量测试", + "batch.uploadAndRun": "上传并运行测试", + "batch.uploadDropzoneDownloadLink": "下载", + "batch.uploadDropzoneDownloadPrefix": "还没有模板?", + "batch.uploadDropzoneEmphasis": "已填写的", + "batch.uploadDropzonePrefix": "拖拽你的", + "batch.uploadDropzoneSuffix": "Excel 模板", "batch.uploadError": "文件上传失败。", "batch.uploadHint": "选择 .csv 或 .xlsx 文件", "batch.uploadTitle": "上传测试文件",