From 72f53178f37e02a17ac5a4cb78560eade16e69fe Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Nov 2026 11:11:26 +0800 Subject: [PATCH 1/3] fix: restore homepage login state and basic workflows --- frontend/package.json | 1 + frontend/src/hooks/useFetchData.ts | 108 +++-- frontend/src/hooks/useSliceUpload.tsx | 4 +- frontend/src/main.tsx | 33 +- .../pages/DataAnnotation/annotation.api.ts | 6 +- .../pages/DataCleansing/Detail/TaskDetail.tsx | 20 +- .../Detail/components/FileTable.tsx | 18 +- .../Detail/components/LogsTable.tsx | 4 +- .../pages/DataCleansing/cleansing.const.tsx | 12 +- .../DataEvaluation/Home/DataEvaluation.tsx | 5 +- .../pages/DataEvaluation/evaluation.api.ts | 8 +- .../Detail/useFilesOperation.ts | 4 +- .../RatioTask/Create/CreateRatioTask.tsx | 14 +- .../RatioTask/Detail/RatioTaskDetail.tsx | 6 +- .../src/pages/RatioTask/Home/RatioTask.tsx | 3 +- frontend/src/pages/RatioTask/ratio.api.ts | 6 +- frontend/src/utils/request.ts | 145 +++---- frontend/tests/basic-functionality.test.cjs | 391 ++++++++++++++++++ frontend/tests/bootstrap-auth.test.cjs | 147 +++++++ frontend/tests/helpers.cjs | 110 +++++ .../module/evaluation/interface/evaluation.py | 4 + .../tests/test_evaluation_list_filters.py | 73 ++++ 22 files changed, 923 insertions(+), 199 deletions(-) create mode 100644 frontend/tests/basic-functionality.test.cjs create mode 100644 frontend/tests/bootstrap-auth.test.cjs create mode 100644 frontend/tests/helpers.cjs create mode 100644 runtime/datamate-python/tests/test_evaluation_list_filters.py diff --git a/frontend/package.json b/frontend/package.json index b3be598e5..17b069cb2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,7 @@ "dev": "vite", "mock": "cd src/mock && nodemon --config nodemon.json --inspect=0.0.0.0:9229 mock.cjs --env=development --port=8002", "build": "vite build", + "test": "node --test tests/basic-functionality.test.cjs tests/bootstrap-auth.test.cjs", "lint": "eslint .", "preview": "vite preview" }, diff --git a/frontend/src/hooks/useFetchData.ts b/frontend/src/hooks/useFetchData.ts index 3af160e38..a596a2a26 100644 --- a/frontend/src/hooks/useFetchData.ts +++ b/frontend/src/hooks/useFetchData.ts @@ -12,12 +12,21 @@ // stopPolling(); // 停止轮询 // 手动调用 fetchData() 时,如果正在轮询,会重新开始轮询计时 // 轮询时会同时执行主要的 fetchFunction 和所有额外的轮询函数 -import { useState, useRef, useEffect, useCallback, useMemo } from "react"; -import { useDebouncedEffect } from "./useDebouncedEffect"; +import { useState, useRef, useEffect, useCallback } from "react"; import Loading from "@/utils/loading"; import { App } from "antd"; import { useTranslation } from "react-i18next"; +interface SearchFilters { + type?: string[]; + status?: string[]; + tags?: string[]; + categories?: string[][]; + selectedStar?: boolean; + builtIn?: string[]; + [key: string]: string[] | string[][] | boolean | undefined; +} + export default function useFetchData( fetchFunc: (params?: any) => Promise, mapDataFunc: (data: Partial) => T = (data) => data as T, @@ -48,7 +57,7 @@ export default function useFetchData( tags: [] as string[], categories: [] as string[][], selectedStar: false, - }, + } as SearchFilters, current: 1, pageSize: 12, }); @@ -60,9 +69,6 @@ export default function useFetchData( // 组件挂载状态 ref const isMountedRef = useRef(true); - // 跟踪上一次的 searchParams,用于避免重复请求 - const prevSearchParamsRef = useRef(""); - // Pagination configuration const [pagination, setPagination] = useState({ total: 0, @@ -94,7 +100,7 @@ export default function useFetchData( }); }; - function getFirstOfArray(arr: string[]) { + function getFirstOfArray(arr?: string[]) { if (!arr || arr.length === 0 || !Array.isArray(arr)) return undefined; if (arr[0] === "all") return undefined; return arr[0]; @@ -123,19 +129,26 @@ export default function useFetchData( } try { - const apiParams = { + const builtIn = getFirstOfArray(filter.builtIn); + const apiParams: Record = { categories: filter.categories, ...extraParams, keyword, isStar: filter.selectedStar ? true : undefined, type: getFirstOfArray(filter?.type) || undefined, status: getFirstOfArray(filter?.status) || undefined, - built_in: filter?.builtIn !== undefined ? (getFirstOfArray(filter?.builtIn) === "true") : undefined, + built_in: builtIn === undefined ? undefined : builtIn === "true", tags: filter?.tags?.length ? filter.tags.join(",") : undefined, page: current - pageOffset, size: pageSize, }; + Object.entries(filter).forEach(([key, value]) => { + if (!["type", "status", "tags", "categories", "selectedStar", "builtIn"].includes(key)) { + apiParams[key] = Array.isArray(value) ? getFirstOfArray(value as string[]) : value; + } + }); + Object.keys(searchParamsRef.current).forEach(key => { if (!['keyword', 'filter', 'current', 'pageSize'].includes(key) && apiParams[key as keyof typeof apiParams] === undefined) { (apiParams as any)[key] = (searchParamsRef.current as any)[key]; @@ -160,37 +173,36 @@ export default function useFetchData( })); let result = []; if (mapDataFunc) { - result = data?.content.map(mapDataFunc) ?? []; + result = data?.content?.map(mapDataFunc) ?? []; } setTableData(result); - - if (wasPolling) { - const poll = () => { - pollingTimerRef.current = setTimeout(() => { - fetchData({}, true).then(() => { - if (pollingTimerRef.current && isMountedRef.current) { - poll(); - } - }); - }, pollingInterval); - }; - poll(); - } } catch (error) { if (!isMountedRef.current) { return; } - - if (error.status === 401) { - message.warn(t('hooks.fetchData.loginRequired')); + + if (error && typeof error === "object" && "status" in error && error.status === 401) { + message.warning(t('hooks.fetchData.loginRequired')); } else { message.error(t('hooks.fetchData.fetchFailed')); } } finally { - if (isMountedRef.current) { + if (isMountedRef.current && !skipPollingRestart) { Loading.hide(); setLoading(false); } + if (wasPolling && isPollingRef.current && isMountedRef.current) { + const poll = () => { + pollingTimerRef.current = setTimeout(() => { + fetchDataRef.current({}, true).then(() => { + if (isPollingRef.current && isMountedRef.current) { + poll(); + } + }); + }, pollingInterval); + }; + poll(); + } } }, [ @@ -213,8 +225,8 @@ export default function useFetchData( const poll = () => { pollingTimerRef.current = setTimeout(() => { - fetchData({}, true).then(() => { - if (pollingTimerRef.current && isMountedRef.current) { + fetchDataRef.current({}, true).then(() => { + if (isPollingRef.current && isMountedRef.current) { poll(); } }); @@ -231,45 +243,23 @@ export default function useFetchData( isPollingRef.current = false; }, [clearPollingTimer]); - // 搜索参数变化时,自动刷新数据 - // 使用 useEffect + 深比较,而不是将对象作为依赖项 + // 使用最新回调,避免内联映射函数变化取消尚未执行的搜索请求。 + const fetchDataRef = useRef(fetchData); + fetchDataRef.current = fetchData; + const searchParamsKey = JSON.stringify(searchParams); + useEffect(() => { - if (!isMountedRef.current) return; - - // 序列化当前参数 - const currentParamsString = JSON.stringify({ - keyword: searchParams.keyword, - filterType: searchParams.filter.type, - filterStatus: searchParams.filter.status, - filterTags: searchParams.filter.tags, - filterCategories: searchParams.filter.categories, - selectedStar: searchParams.filter.selectedStar, - current: searchParams.current, - pageSize: searchParams.pageSize, - }); - - // 检查参数是否真的变化了 - if (currentParamsString === prevSearchParamsRef.current) { - return; - } - prevSearchParamsRef.current = currentParamsString; - // 防抖处理 const timer = setTimeout(() => { if (isMountedRef.current) { - fetchData(); + fetchDataRef.current(); } - }, searchParams?.keyword ? 500 : 0); + }, searchParamsRef.current.keyword ? 500 : 0); return () => { clearTimeout(timer); }; - }, [searchParams, fetchData]); - - // 组件挂载时重置 prevSearchParamsRef,解决 StrictMode 双重挂载问题 - useEffect(() => { - prevSearchParamsRef.current = ""; - }, []); + }, [searchParamsKey]); // 组件卸载时清理轮询和状态 useEffect(() => { diff --git a/frontend/src/hooks/useSliceUpload.tsx b/frontend/src/hooks/useSliceUpload.tsx index b8f325ecf..97558ebb0 100644 --- a/frontend/src/hooks/useSliceUpload.tsx +++ b/frontend/src/hooks/useSliceUpload.tsx @@ -107,6 +107,7 @@ export function useFileSliceUpload( let newTask = { ...task }; // 使用 datasetId 调用 API,key 用于唯一标识任务 await uploadChunk(datasetId || key, formData, { + signal: task.controller.signal, onUploadProgress: (e) => { const loadedSize = loaded + e.loaded; const curPercent = Number((loadedSize / totalSize) * 100).toFixed(2); @@ -185,12 +186,13 @@ export function useFileSliceUpload( const totalSize = files.reduce((acc, file) => acc + file.size, 0); await uploadFile({ task, files, totalSize }); } catch (err) { + if (task.controller.signal.aborted) return; console.error(err); message.error(t('hooks.sliceUpload.uploadFailed')); removeTask({ ...task, - isCancel: true, ...taskListRef.current.find((item) => item.key === task.key), + isCancel: true, }); } }; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 6fe167976..d25972f3f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -65,13 +65,13 @@ function getAuthToken(): string | null { * 在任何渲染之前检查系统参数 sys.home.page.url,若已配置则立即跳转,确保无闪烁。 * 使用原始 fetch 但携带 JWT token,避免已登录用户仍收到 401。 */ -async function checkHomePageRedirect(requireLogin: boolean): Promise<{ redirected: boolean; authNeeded: boolean }> { +async function checkHomePageRedirect(requireLogin: boolean, authenticated: boolean): Promise<{ redirected: boolean; authNeeded: boolean }> { if (window.location.pathname !== '/') { return { redirected: false, authNeeded: false }; } - // 如果需要登录,检查是否有缓存的登录页URL - if (requireLogin) { + // 尚未登录且要求登录时,检查是否有缓存的登录页URL + if (requireLogin && !authenticated) { const cachedUrl = getCachedHomePageUrl(); if (cachedUrl) { window.location.replace(cachedUrl); @@ -81,7 +81,7 @@ async function checkHomePageRedirect(requireLogin: boolean): Promise<{ redirecte return { redirected: false, authNeeded: true }; } - // 不需要登录时,检查自定义首页 + // 已认证或允许匿名访问时,检查自定义首页 const headers: Record = { 'Content-Type': 'application/json' }; const token = getAuthToken(); if (token) { @@ -118,27 +118,34 @@ async function checkHomePageRedirect(requireLogin: boolean): Promise<{ redirecte } /** - * 检查是否需要登录(从后端获取配置) + * 检查登录策略和当前认证状态(从后端获取) * 这个检查应该在应用启动时总是执行,无论当前路径是什么 */ -async function checkRequireLoginMode(): Promise { +async function checkLoginState(): Promise<{ requireLogin: boolean; authenticated: boolean }> { + const headers: Record = { 'Content-Type': 'application/json' }; + const token = getAuthToken(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + try { const userResponse = await fetch('/api/user/me', { method: 'GET', credentials: 'include', - headers: { 'Content-Type': 'application/json' }, + headers, }); if (userResponse.ok) { const userResult = await userResponse.json(); const requireLogin = userResult?.data?.requireLogin ?? false; - // 设置全局标记,供 request.ts 使用 + const authenticated = userResult?.data?.authenticated === true; + // 登录策略与认证状态分别保存,已登录时仍需处理后续令牌过期。 setRequireLoginMode(requireLogin); - return requireLogin; + return { requireLogin, authenticated }; } else if (userResponse.status === 401) { // /api/user/me 本身返回 401,说明需要登录 setRequireLoginMode(true); - return true; + return { requireLogin: true, authenticated: false }; } } catch (e) { console.error('[bootstrap] Failed to check login requirement:', e); @@ -146,7 +153,7 @@ async function checkRequireLoginMode(): Promise { // 默认不需要登录 setRequireLoginMode(false); - return false; + return { requireLogin: false, authenticated: false }; } async function bootstrap() { @@ -154,10 +161,10 @@ async function bootstrap() { if (!container) return; // 首先检查是否需要登录(无论当前路径是什么) - const requireLogin = await checkRequireLoginMode(); + const { requireLogin, authenticated } = await checkLoginState(); // 然后检查自定义首页重定向(只在根路径时) - const { redirected, authNeeded } = await checkHomePageRedirect(requireLogin); + const { redirected, authNeeded } = await checkHomePageRedirect(requireLogin, authenticated); if (redirected) { return; diff --git a/frontend/src/pages/DataAnnotation/annotation.api.ts b/frontend/src/pages/DataAnnotation/annotation.api.ts index 1fb21f368..4bae7fc48 100644 --- a/frontend/src/pages/DataAnnotation/annotation.api.ts +++ b/frontend/src/pages/DataAnnotation/annotation.api.ts @@ -34,7 +34,11 @@ export function getTagConfigUsingGet() { // 标注模板管理 export function queryAnnotationTemplatesUsingGet(params?: any) { - return get("/api/annotation/template", params); + const { built_in, ...rest } = params || {}; + return get("/api/annotation/template", { + ...rest, + builtIn: built_in ?? rest.builtIn, + }); } export function createAnnotationTemplateUsingPost(data: any) { diff --git a/frontend/src/pages/DataCleansing/Detail/TaskDetail.tsx b/frontend/src/pages/DataCleansing/Detail/TaskDetail.tsx index bae3c10f9..91facebf9 100644 --- a/frontend/src/pages/DataCleansing/Detail/TaskDetail.tsx +++ b/frontend/src/pages/DataCleansing/Detail/TaskDetail.tsx @@ -17,12 +17,12 @@ import { queryCleaningTaskByIdUsingGet, queryCleaningTaskLogByIdUsingGet, queryCleaningTaskResultByIdUsingGet, stopCleaningTaskUsingPost, } from "../cleansing.api"; -import {getTaskStatusMap, mapTask} from "../cleansing.const"; +import {mapTask} from "../cleansing.const"; import {CleansingResult, TaskStatus} from "@/pages/DataCleansing/cleansing.model"; import BasicInfo from "./components/BasicInfo"; import OperatorTable from "./components/OperatorTable"; import FileTable from "./components/FileTable"; -import LogsTable from "./components/LogsTable"; +import LogsTable, {type LogEntry} from "./components/LogsTable"; import {formatExecutionDuration} from "@/utils/unit.ts"; import {ReloadOutlined} from "@ant-design/icons"; import { useTranslation } from "react-i18next"; @@ -63,7 +63,7 @@ export default function CleansingTaskDetail() { navigate("/data/cleansing"); }; - const [result, setResult] = useState(); + const [result, setResult] = useState([]); const fetchTaskResult = async () => { if (!id) return; @@ -76,7 +76,7 @@ export default function CleansingTaskDetail() { } }; - const [taskLog, setTaskLog] = useState(); + const [taskLog, setTaskLog] = useState([]); const fetchTaskLog = async (retryCount: number) => { if (!id) return; @@ -92,20 +92,20 @@ export default function CleansingTaskDetail() { const handleRefresh = async () => { fetchTaskDetail(); {activeTab === "files" && await fetchTaskResult()} - {activeTab === "logs" && await fetchTaskLog(task.retryCount)} + {activeTab === "logs" && await fetchTaskLog(task?.retryCount ?? 0)} }; useEffect(() => { fetchTaskDetail(); }, [id, t]); - const [task, setTask] = useState(null); + const [task, setTask] = useState | null>(null); const [activeTab, setActiveTab] = useState("basic"); const headerData = { ...task, icon: , - status: getTaskStatusMap(t)[task?.status], + status: task?.status, createdAt: task?.createdAt, lastUpdated: task?.updatedAt, }; @@ -114,7 +114,7 @@ export default function CleansingTaskDetail() { { icon: , label: t("dataCleansing.detail.statistics.totalDuration"), - value: formatExecutionDuration(task?.startedAt, task?.finishedAt) || "--", + value: formatExecutionDuration(task?.startedAt ?? "", task?.finishedAt ?? "") || "--", }, { icon: , @@ -210,12 +210,12 @@ export default function CleansingTaskDetail() {
- {activeTab === "basic" && ( + {activeTab === "basic" && task && ( )} {activeTab === "operators" && } {activeTab === "files" && } - {activeTab === "logs" && } + {activeTab === "logs" && }
diff --git a/frontend/src/pages/DataCleansing/Detail/components/FileTable.tsx b/frontend/src/pages/DataCleansing/Detail/components/FileTable.tsx index a577a61d4..de59bad21 100644 --- a/frontend/src/pages/DataCleansing/Detail/components/FileTable.tsx +++ b/frontend/src/pages/DataCleansing/Detail/components/FileTable.tsx @@ -1,8 +1,8 @@ -import {Button, Modal, Table, Badge, Input, Popover} from "antd"; +import {App, Button, Modal, Table, Badge, Input, Popover} from "antd"; import { Download } from "lucide-react"; import {useEffect, useState} from "react"; import {useParams} from "react-router"; -import {TaskStatus} from "@/pages/DataCleansing/cleansing.model.ts"; +import {type CleansingResult, TaskStatus} from "@/pages/DataCleansing/cleansing.model.ts"; import {getTaskStatusMap} from "@/pages/DataCleansing/cleansing.const.tsx"; import { useTranslation } from "react-i18next"; @@ -73,9 +73,13 @@ function JsonValue({ value, depth = 0 }: { value: any; depth?: number }) { } // 模拟文件列表数据 -export default function FileTable({result, fetchTaskResult}) { +export default function FileTable({result = [], fetchTaskResult}: { + result?: CleansingResult[]; + fetchTaskResult: () => Promise; +}) { const { id = "" } = useParams(); const { t } = useTranslation(); + const { message } = App.useApp(); const [showFileCompareDialog, setShowFileCompareDialog] = useState(false); const [selectedFile, setSelectedFile] = useState(null); const [selectedFileIds, setSelectedFileIds] = useState([]); @@ -191,7 +195,7 @@ export default function FileTable({result, fetchTaskResult}) { ), onFilter: (value: string, record: any) => - record.srcName.toLowerCase().includes(value.toLowerCase()), + (record.srcName || "").toLowerCase().includes(value.toLowerCase()), render: (text: string) => ( {text?.replace(/\.[^/.]+$/, "")} ), @@ -228,7 +232,7 @@ export default function FileTable({result, fetchTaskResult}) { ), onFilter: (value: string, record: any) => - record.destName.toLowerCase().includes(value.toLowerCase()), + (record.destName || "").toLowerCase().includes(value.toLowerCase()), render: (text: string) => ( {text?.replace(/\.[^/.]+$/, "")} ), @@ -264,7 +268,7 @@ export default function FileTable({result, fetchTaskResult}) { ), onFilter: (value: string, record: any) => - record.srcType.toLowerCase().includes(value.toLowerCase()), + (record.srcType || "").toLowerCase().includes(value.toLowerCase()), render: (text: string) => ( {text} ), @@ -300,7 +304,7 @@ export default function FileTable({result, fetchTaskResult}) { ), onFilter: (value: string, record: any) => - record.destType.toLowerCase().includes(value.toLowerCase()), + (record.destType || "").toLowerCase().includes(value.toLowerCase()), render: (text: string) => ( {text || "-"} ), diff --git a/frontend/src/pages/DataCleansing/Detail/components/LogsTable.tsx b/frontend/src/pages/DataCleansing/Detail/components/LogsTable.tsx index 7c7e8a526..b8efa8bba 100644 --- a/frontend/src/pages/DataCleansing/Detail/components/LogsTable.tsx +++ b/frontend/src/pages/DataCleansing/Detail/components/LogsTable.tsx @@ -7,7 +7,7 @@ import VirtualList from 'rc-virtual-list'; import { streamCleaningTaskLog, downloadCleaningTaskLog } from "../../cleansing.api"; import { TaskStatus } from "../../cleansing.model"; -interface LogEntry { +export interface LogEntry { level: string; message: string; } @@ -24,7 +24,7 @@ export default function LogsTable({ taskStatus }: { taskLog: LogEntry[], - fetchTaskLog: () => Promise, + fetchTaskLog: (retryCount: number) => Promise, retryCount: number, taskName: string, taskStatus?: TaskStatus diff --git a/frontend/src/pages/DataCleansing/cleansing.const.tsx b/frontend/src/pages/DataCleansing/cleansing.const.tsx index 111503cdb..e8eb096a6 100644 --- a/frontend/src/pages/DataCleansing/cleansing.const.tsx +++ b/frontend/src/pages/DataCleansing/cleansing.const.tsx @@ -54,17 +54,19 @@ export const mapTask = (task: CleansingTask, t: (key: string) => string) => { const finishedAt = formatDateTime(task.finishedAt); const startedAt = formatDateTime(task.startedAt); const createdAt = formatDateTime(task.createdAt); + const timestamps = [task.updatedAt, task.finishedAt, task.startedAt, task.createdAt] + .filter(Boolean) + .map((value) => new Date(value).getTime()) + .filter(Number.isFinite); return { ...task, ...task.progress, createdAt, startedAt, finishedAt, - updatedAt: formatDateTime( - new Date(Math.max(...[ - new Date(task.finishedAt).getTime(), - new Date(task.startedAt).getTime(), - new Date(task.createdAt).getTime()])).toISOString()), + updatedAt: timestamps.length + ? formatDateTime(new Date(Math.max(...timestamps)).toISOString()) + : "", icon: , status, duration, diff --git a/frontend/src/pages/DataEvaluation/Home/DataEvaluation.tsx b/frontend/src/pages/DataEvaluation/Home/DataEvaluation.tsx index c2a177073..51712aacd 100644 --- a/frontend/src/pages/DataEvaluation/Home/DataEvaluation.tsx +++ b/frontend/src/pages/DataEvaluation/Home/DataEvaluation.tsx @@ -45,6 +45,7 @@ export default function DataEvaluationPage() { searchParams, setSearchParams, handleFiltersChange, + handleKeywordChange, fetchData, } = useFetchData( getPagedEvaluationTaskUsingGet, @@ -220,9 +221,7 @@ export default function DataEvaluationPage() { {/* 搜索、筛选和视图控制 */} - setSearchParams({ ...searchParams, keyword }) - } + onSearchChange={handleKeywordChange} searchPlaceholder={t("dataEvaluation.home.searchPlaceholder")} filters={filterOptions} onFiltersChange={handleFiltersChange} diff --git a/frontend/src/pages/DataEvaluation/evaluation.api.ts b/frontend/src/pages/DataEvaluation/evaluation.api.ts index 5794f940e..a3af2212e 100644 --- a/frontend/src/pages/DataEvaluation/evaluation.api.ts +++ b/frontend/src/pages/DataEvaluation/evaluation.api.ts @@ -5,7 +5,13 @@ export function createEvaluationTaskUsingPost(data: any) { } export function getPagedEvaluationTaskUsingGet(params?: any) { - return get("/api/evaluation/tasks", params); + const { keyword, taskType, evalMethod, ...rest } = params || {}; + return get("/api/evaluation/tasks", { + ...rest, + name: keyword || rest.name, + task_type: taskType || rest.task_type, + eval_method: evalMethod || rest.eval_method, + }); } export function deleteEvaluationTaskUsingGet(id: string) { diff --git a/frontend/src/pages/DataManagement/Detail/useFilesOperation.ts b/frontend/src/pages/DataManagement/Detail/useFilesOperation.ts index 158f65277..c701a5fdd 100644 --- a/frontend/src/pages/DataManagement/Detail/useFilesOperation.ts +++ b/frontend/src/pages/DataManagement/Detail/useFilesOperation.ts @@ -490,8 +490,8 @@ export function useFilesOperation(dataset: Dataset, onDatasetUpdate?: () => Prom } }, handleRenameFile: async (file, newBaseName: string) => { + const trimmed = (newBaseName || "").trim(); try { - const trimmed = (newBaseName || "").trim(); if (!trimmed) { message.warning({ content: "请输入文件名称" }); return; @@ -512,8 +512,8 @@ export function useFilesOperation(dataset: Dataset, onDatasetUpdate?: () => Prom } }, handleRenameDirectory: async (directoryPath: string, oldName: string, newName: string) => { + const trimmed = (newName || "").trim(); try { - const trimmed = (newName || "").trim(); if (!trimmed) { message.warning({ content: "请输入文件夹名称" }); return; diff --git a/frontend/src/pages/RatioTask/Create/CreateRatioTask.tsx b/frontend/src/pages/RatioTask/Create/CreateRatioTask.tsx index 65de09da3..41604f1d7 100644 --- a/frontend/src/pages/RatioTask/Create/CreateRatioTask.tsx +++ b/frontend/src/pages/RatioTask/Create/CreateRatioTask.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useCallback, useState } from "react"; import { Button, Form, message } from "antd"; import { ArrowLeft, ChevronRight } from "lucide-react"; import { createRatioTaskUsingPost } from "@/pages/RatioTask/ratio.api.ts"; @@ -28,9 +28,17 @@ export default function CreateRatioTask() { const [datasets, setDatasets] = useState([]); const [creating, setCreating] = useState(false); const [distributions, setDistributions] = useState< - Record> + Record>> >({}); + const handleDatasetsChange = useCallback((list: Dataset[]) => { + setDatasets((previous) => { + const merged = new Map(previous.map((dataset) => [String(dataset.id), dataset])); + list.forEach((dataset) => merged.set(String(dataset.id), dataset)); + return Array.from(merged.values()); + }); + }, []); + const handleCreateRatioTask = async () => { try { const values = await form.validateFields(); @@ -119,7 +127,7 @@ export default function CreateRatioTask() { })); }} onDistributionsChange={(next) => setDistributions(next)} - onDatasetsChange={(list) => setDatasets(list)} + onDatasetsChange={handleDatasetsChange} /> { fetchRatioTask(); - }, [t]); + }, [fetchRatioTask]); const handleRefresh = useCallback( async (showMessage = true) => { @@ -73,7 +73,7 @@ export default function RatioTaskDetail() { const handleDelete = async () => { await deleteRatioTasksUsingDelete(id as string); - navigate("/ratio/task"); + navigate("/data/synthesis/ratio-task"); message.success(t("ratioTask.detail.messages.deleteSuccess")); }; @@ -113,7 +113,7 @@ export default function RatioTaskDetail() { danger: true, confirm: { title: t("ratioTask.detail.confirm.deleteTitle"), - description: t("ratioTask.detail.confirm.deleteDesc", { itemName: task.name }), + description: t("ratioTask.detail.confirm.deleteDesc", { itemName: ratioTask.name }), okText: t("dataManagement.confirm.deleteConfirm"), cancelText: t("dataManagement.confirm.deleteCancel"), okType: "danger", diff --git a/frontend/src/pages/RatioTask/Home/RatioTask.tsx b/frontend/src/pages/RatioTask/Home/RatioTask.tsx index 601dd5f5c..82c14cd73 100644 --- a/frontend/src/pages/RatioTask/Home/RatioTask.tsx +++ b/frontend/src/pages/RatioTask/Home/RatioTask.tsx @@ -3,6 +3,7 @@ import { Button, Card, Table, App, Badge, Tooltip } from "antd"; import { Plus } from "lucide-react"; import { DeleteOutlined } from "@ant-design/icons"; import type { RatioTaskItem } from "@/pages/RatioTask/ratio.model"; +import { RatioStatus } from "@/pages/RatioTask/ratio.model"; import { useNavigate } from "react-router"; import { useTranslation } from "react-i18next"; import CardView from "@/components/CardView"; @@ -79,7 +80,7 @@ export default function RatioTasksPage() { { label: t("ratioTask.home.filters.allStatus"), value: "all" }, { label: t("ratioTask.home.filters.pending"), value: "PENDING" }, { label: t("ratioTask.home.filters.running"), value: "RUNNING" }, - { label: t("ratioTask.home.filters.success"), value: "SUCCESS" }, + { label: t("ratioTask.home.filters.success"), value: RatioStatus.COMPLETED }, { label: t("ratioTask.home.filters.failed"), value: "FAILED" }, { label: t("ratioTask.home.filters.paused"), value: "PAUSED" }, ], diff --git a/frontend/src/pages/RatioTask/ratio.api.ts b/frontend/src/pages/RatioTask/ratio.api.ts index bcd095084..f5812656f 100644 --- a/frontend/src/pages/RatioTask/ratio.api.ts +++ b/frontend/src/pages/RatioTask/ratio.api.ts @@ -2,7 +2,11 @@ import { get, post, put, del, download } from "@/utils/request"; // 查询配比任务列表(分页) export function queryRatioTasksUsingGet(params?: any) { - return get("/api/synthesis/ratio-task", params); + const { keyword, ...rest } = params || {}; + return get("/api/synthesis/ratio-task", { + ...rest, + name: keyword || rest.name, + }); } // 查询配比任务详情 diff --git a/frontend/src/utils/request.ts b/frontend/src/utils/request.ts index 8aa28c223..1e8f4bf59 100644 --- a/frontend/src/utils/request.ts +++ b/frontend/src/utils/request.ts @@ -20,23 +20,20 @@ class Request { } _count = 0; - $interval; get count() { return this._count; } set count(value) { - clearTimeout(this.$interval); - if (value > 0) { + const nextCount = Math.max(0, value); + if (this._count === 0 && nextCount > 0) { Loading.show(); } - if (value <= 0) { - this.$interval = setTimeout(() => { - Loading.hide(); - }, 300); + if (this._count > 0 && nextCount === 0) { + Loading.hide(); } - this._count = value >= 0 ? value : 0; + this._count = nextCount; } /** @@ -84,71 +81,56 @@ class Request { */ createXHRWithProgress(url, config, onProgress, onDownloadProgress) { return new Promise((resolve, reject) => { + if (config.signal?.aborted) { + reject(new DOMException("上传已取消", "AbortError")); + return; + } const xhr = new XMLHttpRequest(); - xhr.open("POST", url); + xhr.open(config.method || "POST", url); + xhr.withCredentials = config.credentials === "include"; + if (config.timeout) xhr.timeout = config.timeout; - // 设置请求头 if (config.headers) { Object.keys(config.headers).forEach((key) => { xhr.setRequestHeader(key, config.headers[key]); }); } - // 监听上传进度 - xhr.upload.addEventListener("progress", function (event) { - if (event.lengthComputable) { - if (onProgress) { - onProgress(event); - } - if (onDownloadProgress) { - onDownloadProgress(event); - } - } + xhr.upload.addEventListener("progress", (event) => { + if (event.lengthComputable) onProgress?.(event); + }); + xhr.addEventListener("progress", (event) => { + if (event.lengthComputable) onDownloadProgress?.(event); }); - // 请求完成处理 xhr.addEventListener("load", () => { - if (xhr.status >= 200 && xhr.status < 300) { - let response; - try { - // 尝试解析JSON - const contentType = xhr.getResponseHeader("content-type"); - if (contentType && contentType.includes("application/json")) { - response = JSON.parse(xhr.responseText); - } else { - response = xhr.responseText; - } - } catch (e) { - response = xhr.responseText; + let response = xhr.responseText; + try { + const contentType = xhr.getResponseHeader("content-type"); + if (contentType?.includes("application/json")) { + response = JSON.parse(xhr.responseText); } - - resolve({ - data: response, - status: xhr.status, - statusText: xhr.statusText, - headers: xhr.getAllResponseHeaders(), - xhr: xhr, - }); - } else { - reject(new Error(`HTTP error! status: ${xhr.status}`)); + } catch { + // 保留非 JSON 响应,由响应拦截器处理错误。 } + resolve({ + data: response, + status: xhr.status, + statusText: xhr.statusText, + headers: xhr.getAllResponseHeaders(), + xhr, + }); }); - - // 请求错误 - xhr.addEventListener("error", function () { - console.error("网络错误"); - if (onError) onError(new Error("网络错误")); - }); - - // 请求中止 - xhr.addEventListener("abort", function () { - console.log("上传已取消"); - if (onError) onError(new Error("上传已取消")); - }); - + xhr.addEventListener("error", () => reject(new Error("网络错误"))); + xhr.addEventListener("abort", () => reject(new DOMException("上传已取消", "AbortError"))); + xhr.addEventListener("timeout", () => reject(new Error("请求超时"))); + + const abort = () => xhr.abort(); + config.signal?.addEventListener("abort", abort, { once: true }); + xhr.addEventListener("loadend", () => { + config.signal?.removeEventListener("abort", abort); + }, { once: true }); xhr.send(config.body); - - return xhr; // 返回 xhr 对象以便后续控制 }); } @@ -174,11 +156,6 @@ class Request { * 处理响应 */ async handleResponse(response, config) { - // 如果显示了loading,需要隐藏 - if (config.showLoading) { - this.count--; - } - // 执行响应拦截器 const processedResponse = await this.executeResponseInterceptors( response, @@ -252,29 +229,23 @@ class Request { * 通用请求方法 */ async request(url, config) { - // 处理showLoading参数 - if (config.showLoading) { - this.count++; - } - - // 执行请求拦截器 - const processedConfig = await this.executeRequestInterceptors(config); - - // 如果需要进度监听,使用XMLHttpRequest - if (config.onUploadProgress || config.onDownloadProgress) { - const xhrResponse = await this.createXHRWithProgress( - url, - processedConfig, - config.onUploadProgress, - config.onDownloadProgress - ); - return await this.handleXHRResponse(xhrResponse, processedConfig); - } - // 否则使用fetch - if (processedConfig.body instanceof FormData) { + if (config.showLoading) this.count++; + try { + const processedConfig = await this.executeRequestInterceptors(config); + if (processedConfig.onUploadProgress || processedConfig.onDownloadProgress) { + const xhrResponse = await this.createXHRWithProgress( + url, + processedConfig, + processedConfig.onUploadProgress, + processedConfig.onDownloadProgress + ); + return await this.handleXHRResponse(xhrResponse, processedConfig); + } + const response = await fetch(url, processedConfig); + return await this.handleResponse(response, processedConfig); + } finally { + if (config.showLoading) this.count--; } - const response = await fetch(url, processedConfig); - return await this.handleResponse(response, processedConfig); } /** @@ -623,7 +594,7 @@ request.addResponseInterceptor(async (response, config) => { const { status } = response; - let resData: {}; + let resData: { code?: string | number; message?: string; msg?: string }; try { // 关键点 1: response.data 在原生 fetch 中是不存在的,必须用 .json() 解析 @@ -643,7 +614,7 @@ request.addResponseInterceptor(async (response, config) => { // 3. 判断成功 (根据你的后端约定:200/0 为成功) // 如果是成功状态,直接返回 response,不拦截 - if (status === 200 && (code === 200 || code === 0 || code === '0')) { + if (response.ok && (resData.code == null || [200, '200', 0, '0'].includes(code))) { return response; } diff --git a/frontend/tests/basic-functionality.test.cjs b/frontend/tests/basic-functionality.test.cjs new file mode 100644 index 000000000..bdef7dfb0 --- /dev/null +++ b/frontend/tests/basic-functionality.test.cjs @@ -0,0 +1,391 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { loadSource, hookRunner, fakeClock } = require('./helpers.cjs'); + +const tick = () => new Promise(setImmediate); +const translate = key => key; + +function requestFixture(fetch = async () => new Response('{"code":"0"}', { headers: { 'content-type': 'application/json' } })) { + const instances = []; + const loading = { count: 0, show() { this.count++; }, hide() { this.count--; } }; + class XHR extends EventTarget { + constructor() { super(); this.upload = new EventTarget(); instances.push(this); } + open(method, url) { this.method = method; this.url = url; } + setRequestHeader() {} + getResponseHeader() { return 'application/json'; } + getAllResponseHeaders() { return ''; } + send(body) { this.body = body; } + abort() { this.emit('abort'); } + emit(event) { this.dispatchEvent(new Event(event)); this.dispatchEvent(new Event('loadend')); } + respond(status = 200, data = { code: '0' }) { + this.status = status; + this.statusText = String(status); + this.responseText = JSON.stringify(data); + this.emit('load'); + } + } + const source = loadSource('src/utils/request.ts', { + antd: { message: { error() {} } }, + './loading': { __esModule: true, default: loading }, + '@/utils/errorConfigStore.ts': { errorConfigStore: { getMessage() {} } }, + '@/i18n': { __esModule: true, default: { language: 'zh' } }, + }, { fetch, XMLHttpRequest: XHR, localStorage: { getItem() { return null; } } }); + return { request: source.default, loading, instances }; +} + +test('concurrent requests release loading after both complete', async () => { + const resolves = []; + const fixture = requestFixture(() => new Promise(resolve => resolves.push(resolve))); + const first = fixture.request.get('/one', null, { showLoading: true }); + const second = fixture.request.get('/two', null, { showLoading: true }); + await tick(); + assert.equal(fixture.loading.count, 1); + const response = () => new Response('{"code":"0"}', { headers: { 'content-type': 'application/json' } }); + resolves[0](response()); + await first; + assert.equal(fixture.loading.count, 1); + resolves[1](response()); + await second; + assert.equal(fixture.loading.count, 0); + assert.equal(fixture.request.count, 0); +}); + +test('network and interceptor failures release loading', async () => { + const fixture = requestFixture(async () => { throw new Error('offline'); }); + await assert.rejects(fixture.request.get('/offline', null, { showLoading: true }), /offline/); + assert.equal(fixture.loading.count, 0); + fixture.request.addRequestInterceptor(() => { throw new Error('interceptor failed'); }); + await assert.rejects(fixture.request.get('/fail', null, { showLoading: true }), /interceptor failed/); + assert.equal(fixture.loading.count, 0); +}); + +for (const event of ['error', 'abort', 'timeout']) { + test(`upload ${event} rejects and releases loading`, async () => { + const fixture = requestFixture(); + const promise = fixture.request.post('/upload', new FormData(), { onUploadProgress() {}, showLoading: true }); + const rejection = assert.rejects(promise); + await tick(); + assert.equal(fixture.instances[0].withCredentials, true); + fixture.instances[0].emit(event); + await rejection; + assert.equal(fixture.loading.count, 0); + }); +} + +test('XHR honors cancellation and successful uploads release loading', async () => { + const fixture = requestFixture(); + const controller = new AbortController(); + const cancelled = fixture.request.post('/upload', new FormData(), { signal: controller.signal, onUploadProgress() {} }); + const rejection = assert.rejects(cancelled, { name: 'AbortError' }); + await tick(); + controller.abort(); + await rejection; + const successful = fixture.request.post('/upload', new FormData(), { onUploadProgress() {}, showLoading: true }); + await tick(); + fixture.instances[1].respond(); + await successful; + assert.equal(fixture.loading.count, 0); +}); + +test('HTTP 201/204 succeed while business failures still reject', async () => { + for (const status of [201, 204]) { + const fixture = requestFixture(async () => status === 204 + ? new Response(null, { status }) + : new Response('{"code":"200"}', { status, headers: { 'content-type': 'application/json' } })); + await fixture.request.post('/create'); + } + const fixture = requestFixture(async () => new Response('{"code":"failed","message":"rejected"}', { headers: { 'content-type': 'application/json' } })); + await assert.rejects(fixture.request.get('/failed'), error => error.message === 'rejected'); +}); + +function listFixture() { + const runner = hookRunner(); + const clock = fakeClock(); + const calls = []; + const messages = []; + const message = { error: value => messages.push(value), warning: value => messages.push(value) }; + let fail = false; + let loadingCount = 0; + const useFetchData = loadSource('src/hooks/useFetchData.ts', { + react: runner.react, + antd: { App: { useApp: () => ({ message }) } }, + 'react-i18next': { useTranslation: () => ({ t: translate }) }, + '@/utils/loading': { __esModule: true, default: { + show() { loadingCount++; }, hide() { loadingCount--; }, hideAll() { loadingCount = 0; }, + } }, + }, clock).default; + const fetch = async params => { + calls.push(params); + if (fail) throw fail === true ? new Error('offline') : fail; + return { data: { content: [{ id: 'row' }], totalElements: 1 } }; + }; + return { runner, clock, calls, messages, setFail(value) { fail = value; }, get loadingCount() { return loadingCount; }, + render: () => runner.render(() => useFetchData(fetch, item => item)) }; +} + +test('template and evaluation filters reach the list query, including clearing built-in filters', async () => { + const fixture = listFixture(); + let list = fixture.render(); + await fixture.clock.run(0); + list.handleFiltersChange({ builtIn: ['true'], category: ['nlp'], dataType: ['TEXT'], labelingType: ['classification'], taskType: ['QA'], evalMethod: ['AUTO'] }); + list = fixture.render(); + await fixture.clock.run(0); + const params = fixture.calls.at(-1); + assert.equal(params.built_in, true); + assert.equal(params.category, 'nlp'); + assert.equal(params.dataType, 'TEXT'); + assert.equal(params.labelingType, 'classification'); + assert.equal(params.taskType, 'QA'); + assert.equal(params.evalMethod, 'AUTO'); + list.handleFiltersChange({ builtIn: ['all'] }); + list = fixture.render(); + await fixture.clock.run(0); + assert.equal(fixture.calls.at(-1).built_in, undefined); + list.setSearchParams(previous => ({ ...previous, filter: {} })); + fixture.render(); + await fixture.clock.run(0); + assert.equal(fixture.messages.length, 0); +}); + +test('debounced search survives unrelated renders and resets pagination', async () => { + const fixture = listFixture(); + let list = fixture.render(); + await fixture.clock.run(0); + list.pagination.onChange(3, 12); + list = fixture.render(); + await fixture.clock.run(0); + list.handleKeywordChange('query'); + fixture.render(); + fixture.render(); + fixture.render(); + await fixture.clock.run(500); + assert.equal(fixture.calls.at(-1).keyword, 'query'); + assert.equal(fixture.calls.at(-1).page, 0); + assert.equal(fixture.calls.length, 3); +}); + +test('initial list query survives StrictMode effect remount', async () => { + const fixture = listFixture(); + fixture.render(); + fixture.runner.strictRemount(); + await fixture.clock.run(0); + assert.equal(fixture.calls.length, 1); +}); + +test('an expired login produces a warning and releases list loading', async () => { + const fixture = listFixture(); + const list = fixture.render(); + fixture.setFail({ status: 401 }); + await list.fetchData(); + assert.equal(fixture.messages[0], 'hooks.fetchData.loginRequired'); + assert.equal(fixture.loadingCount, 0); +}); + +test('polling resumes after a failed manual refresh and background polling preserves other loading state', async () => { + const fixture = listFixture(); + let list = fixture.render(); + await fixture.clock.run(0); + list.startPolling(); + list = fixture.render(); + fixture.setFail(true); + await list.fetchData(); + fixture.setFail(false); + const before = fixture.calls.length; + await fixture.clock.run(30000); + assert.equal(fixture.calls.length, before + 1); + assert.equal(fixture.loadingCount, 0); + fixture.runner.result.stopPolling(); + assert.equal(fixture.clock.size, 0); +}); + +test('list API adapters translate UI search and filter names to backend parameters', async () => { + const calls = []; + const request = { get: async (url, params) => { calls.push({ url, params }); } }; + const ratio = loadSource('src/pages/RatioTask/ratio.api.ts', { '@/utils/request': request }); + const evaluation = loadSource('src/pages/DataEvaluation/evaluation.api.ts', { '@/utils/request': request }); + const annotation = loadSource('src/pages/DataAnnotation/annotation.api.ts', { '@/utils/request': request }); + await ratio.queryRatioTasksUsingGet({ keyword: 'ratio', status: 'COMPLETED', page: 2 }); + assert.equal(calls.at(-1).params.name, 'ratio'); + assert.equal(calls.at(-1).params.page, 2); + assert.equal(calls.at(-1).params.status, 'COMPLETED'); + await evaluation.getPagedEvaluationTaskUsingGet({ keyword: 'eval', taskType: 'QA', evalMethod: 'AUTO' }); + assert.equal(calls.at(-1).params.name, 'eval'); + assert.equal(calls.at(-1).params.task_type, 'QA'); + assert.equal(calls.at(-1).params.eval_method, 'AUTO'); + await annotation.queryAnnotationTemplatesUsingGet({ built_in: false, dataType: 'TEXT' }); + assert.equal(calls.at(-1).params.builtIn, false); + assert.equal(calls.at(-1).params.dataType, 'TEXT'); +}); + +test('duplicate file and directory names show the conflict instead of throwing', async () => { + const runner = hookRunner(); + const errors = []; + const api = { + renameDatasetFileUsingPut: async () => { throw new Error('already exists'); }, + renameDirectoryUsingPut: async () => { throw new Error('duplicate'); }, + }; + const useFilesOperation = loadSource('src/pages/DataManagement/Detail/useFilesOperation.ts', { + react: runner.react, + antd: { App: { useApp: () => ({ message: { error: value => errors.push(value.content), warning() {} } }) } }, + jszip: { __esModule: true, default: class {} }, + '../dataset.api': api, + 'react-router': { useParams: () => ({ id: 'dataset' }) }, + }).useFilesOperation; + const operations = runner.render(() => useFilesOperation({ id: 'dataset' })); + await operations.handleRenameFile({ id: 'file', fileName: 'old.txt' }, ' new.txt '); + await operations.handleRenameDirectory('dir/', 'old', ' new '); + assert.equal(errors.length, 2); + assert.match(errors[0], /"new.txt" 已存在/); + assert.match(errors[1], /"new" 已存在/); +}); + +test('cleaning tasks with missing or invalid execution dates still map for display', () => { + const unit = loadSource('src/utils/unit.ts'); + const model = loadSource('src/pages/DataCleansing/cleansing.model.ts'); + const { mapTask } = loadSource('src/pages/DataCleansing/cleansing.const.tsx', { + '@/utils/unit': unit, + '@/pages/DataCleansing/cleansing.model': model, + 'lucide-react': { BrushCleaning: 'BrushCleaning', Layout: 'Layout' }, + }); + const task = { id: 'task', name: 'pending', status: 'PENDING', createdAt: '2026-11-06T08:00:00Z' }; + assert.equal(mapTask(task, translate).updatedAt, unit.formatDateTime(task.createdAt)); + assert.equal(mapTask({ ...task, finishedAt: 'invalid', updatedAt: '2026-11-06T10:00:00Z' }, translate).updatedAt, unit.formatDateTime('2026-11-06T10:00:00Z')); + assert.equal(mapTask({ status: 'PENDING' }, translate).updatedAt, ''); +}); + +function findElement(tree, predicate) { + if (!tree || typeof tree !== 'object') return undefined; + if (Array.isArray(tree)) return tree.map(child => findElement(child, predicate)).find(Boolean); + if (predicate(tree)) return tree; + return findElement(tree.props?.children, predicate); +} + +test('ratio detail renders before loading and deletion returns to its task list', async () => { + const runner = hookRunner(); + const deleted = []; + const navigated = []; + const Detail = loadSource('src/pages/RatioTask/Detail/RatioTaskDetail.tsx', { + react: { ...runner.react, useEffect() {} }, + antd: { App: { useApp: () => ({ message: { success() {} } }) } }, + '@ant-design/icons': {}, + 'react-i18next': { useTranslation: () => ({ t: translate }) }, + '@/components/DetailHeader': { __esModule: true, default: 'DetailHeader' }, + 'react-router': { useParams: () => ({ id: 'ratio-1' }), useNavigate: () => path => navigated.push(path) }, + '@/pages/RatioTask/ratio.api': { deleteRatioTasksUsingDelete: async id => deleted.push(id) }, + '@/utils/request': {}, + '../ratio.const': {}, + './DataRatioChart': {}, './RatioDisplay': {}, './DataMetrics': {}, + }).default; + const tree = runner.render(() => Detail()); + const header = findElement(tree, element => element.type === 'DetailHeader'); + await header.props.operations.find(operation => operation.key === 'delete').onClick(); + assert.deepEqual(deleted, ['ratio-1']); + assert.deepEqual(navigated, ['/data/synthesis/ratio-task']); +}); + +test('cleaning file filters tolerate unfinished results and failed downloads show an error', async () => { + const runner = hookRunner(); + const errors = []; + const FileTable = loadSource('src/pages/DataCleansing/Detail/components/FileTable.tsx', { + react: { ...runner.react, useEffect() {} }, + antd: { Table: 'Table', Button: 'Button', App: { useApp: () => ({ message: { error: value => errors.push(value) } }) } }, + 'lucide-react': {}, + 'react-router': { useParams: () => ({ id: 'cleaning-1' }) }, + '@/pages/DataCleansing/cleansing.model.ts': {}, + '@/pages/DataCleansing/cleansing.const.tsx': {}, + 'react-i18next': { useTranslation: () => ({ t: translate }) }, + }, { fetch: async () => ({ ok: false }), console: { error() {} } }).default; + const tree = runner.render(() => FileTable({ fetchTaskResult() {} })); + const table = findElement(tree, element => element.type === 'Table'); + assert.equal(table.props.dataSource.length, 0); + for (const column of table.props.columns.filter(column => column.onFilter)) { + assert.equal(column.onFilter('text', {}), false); + } + const actions = table.props.columns.find(column => column.key === 'action').render('', {}); + const download = findElement(actions, element => element.type === 'Button' && element.props.children === 'dataCleansing.actions.download'); + await download.props.onClick(); + assert.equal(errors.length, 1); +}); + +test('cleaning logs can open while task details are still loading', () => { + const runner = hookRunner(); + const model = loadSource('src/pages/DataCleansing/cleansing.model.ts'); + const Detail = loadSource('src/pages/DataCleansing/Detail/TaskDetail.tsx', { + react: { ...runner.react, useEffect() {}, useState: initial => runner.react.useState(initial === 'basic' ? 'logs' : initial) }, + antd: { App: { useApp: () => ({ message: {} }) } }, + 'lucide-react': {}, + '@ant-design/icons': {}, + '@/components/DetailHeader': { __esModule: true, default: 'DetailHeader' }, + 'react-router': { useParams: () => ({ id: 'task' }), useNavigate: () => () => {} }, + '../cleansing.api': {}, '../cleansing.const': {}, + '@/pages/DataCleansing/cleansing.model': model, + './components/BasicInfo': {}, './components/OperatorTable': {}, './components/FileTable': {}, + './components/LogsTable': { __esModule: true, default: 'LogsTable' }, + '@/utils/unit.ts': loadSource('src/utils/unit.ts'), + 'react-i18next': { useTranslation: () => ({ t: translate }) }, + }).default; + const tree = runner.render(() => Detail()); + const logs = findElement(tree, element => element.type === 'LogsTable'); + assert.equal(logs.props.retryCount, 0); + assert.equal(logs.props.taskName, ''); +}); + +test('ratio configuration keeps selected dataset details across dataset pages', () => { + const runner = hookRunner(); + const Create = loadSource('src/pages/RatioTask/Create/CreateRatioTask.tsx', { + react: runner.react, + antd: { Form: { useForm: () => [{}] } }, + 'lucide-react': {}, + '@/pages/RatioTask/ratio.api.ts': {}, + 'react-router': { useNavigate: () => () => {} }, + 'react-i18next': { useTranslation: () => ({ t: translate }) }, + '@/pages/RatioTask/Create/components/SelectDataset.tsx': { __esModule: true, default: 'SelectDataset' }, + '@/pages/RatioTask/Create/components/BasicInformation.tsx': {}, + '@/pages/RatioTask/Create/components/RatioConfig.tsx': { __esModule: true, default: 'RatioConfig' }, + '@/utils/unit.ts': {}, + }).default; + let tree = runner.render(() => Create()); + let selector = findElement(tree, element => element.type === 'SelectDataset'); + selector.props.onDatasetsChange([{ id: 'first', name: 'First page dataset' }]); + selector.props.onSelectedDatasetsChange(['first']); + tree = runner.render(() => Create()); + selector = findElement(tree, element => element.type === 'SelectDataset'); + selector.props.onDatasetsChange([{ id: 'second', name: 'Second page dataset' }]); + tree = runner.render(() => Create()); + const config = findElement(tree, element => element.type === 'RatioConfig'); + assert.equal(config.props.selectedDatasets[0], 'first'); + assert.equal(config.props.datasets.find(dataset => dataset.id === 'first').name, 'First page dataset'); +}); + +test('cancelling a file upload aborts its chunk and prevents remaining chunks', async () => { + const runner = hookRunner(); + const errors = []; + const chunks = []; + const cancelled = []; + const useUpload = loadSource('src/hooks/useSliceUpload.tsx', { + react: runner.react, + antd: { App: { useApp: () => ({ message: { error: value => errors.push(value) } }) } }, + 'react-i18next': { useTranslation: () => ({ t: translate }) }, + '@/utils/file.util': { calculateSHA256: async () => 'checksum', checkIsFilesExist: async () => false }, + }, { AbortController, Event, CustomEvent, window: { dispatchEvent() {} }, console: { log() {}, error() {} } }).useFileSliceUpload; + const options = { + preUpload: async () => ({ data: 1 }), + uploadChunk: async (id, form, config) => { + chunks.push({ id, form, config }); + return new Promise((resolve, reject) => config.signal?.addEventListener('abort', () => reject(new DOMException('cancelled', 'AbortError')), { once: true })); + }, + cancelUpload: async id => cancelled.push(id), + }; + let upload = runner.render(() => useUpload(options, false)); + const task = upload.createTask({ dataset: { id: 'dataset', name: 'dataset' } }); + const promise = upload.handleUpload({ task, files: [{ name: 'file.txt', size: 2, slices: [new Blob(['a']), new Blob(['b'])] }] }); + await tick(); + assert.equal(chunks.length, 1); + assert.equal(chunks[0].config.signal, task.controller.signal); + upload = runner.render(() => useUpload(options, false)); + upload.removeTask({ ...upload.taskList[0], isCancel: true }); + await promise; + assert.equal(chunks.length, 1); + assert.equal(errors.length, 0); + assert.deepEqual(cancelled, [1]); +}); diff --git a/frontend/tests/bootstrap-auth.test.cjs b/frontend/tests/bootstrap-auth.test.cjs new file mode 100644 index 000000000..fe00da27d --- /dev/null +++ b/frontend/tests/bootstrap-auth.test.cjs @@ -0,0 +1,147 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { loadSource, fakeClock } = require('./helpers.cjs'); + +async function bootstrapFixture({ + session = null, + authenticated, + requireLogin = true, + userStatus = 200, + homeUrl = '', + cachedHomeUrl = null, + pathname = '/', +} = {}) { + const clock = fakeClock(); + const requests = []; + const loginModes = []; + const events = []; + const redirects = []; + const cacheWrites = []; + let rendered = false; + const container = { innerHTML: '' }; + loadSource('src/main.tsx', { + react: { StrictMode: 'StrictMode', Suspense: 'Suspense' }, + 'react-dom/client': { createRoot: () => ({ render() { rendered = true; } }) }, + 'react-router': { RouterProvider: 'RouterProvider' }, + './routes/routes': {}, + antd: { App: 'App', Spin: 'Spin', ConfigProvider: 'ConfigProvider' }, + './index.css': {}, + './components/TopLoadingBar': {}, + './components/AuthGuard': {}, + './store': { store: {} }, + 'react-redux': { Provider: 'Provider' }, + './theme': {}, + '@/utils/errorConfigStore.ts': { errorConfigStore: { async loadConfig() {} } }, + '@/utils/systemParam': { + getCachedHomePageUrl: () => cachedHomeUrl, + setCachedHomePageUrl: value => cacheWrites.push(value), + }, + '@/utils/request': { setRequireLoginMode: value => loginModes.push(value) }, + '@/i18n': {}, + }, { + ...clock, + document: { getElementById: () => container }, + localStorage: { getItem: key => key === 'session' ? session : null }, + window: { + location: { pathname, replace: url => redirects.push(url) }, + dispatchEvent: event => events.push(event.type), + }, + CustomEvent: class { constructor(type) { this.type = type; } }, + fetch: async (url, config) => { + requests.push({ url, config }); + if (url === '/api/user/me') { + const loggedIn = authenticated ?? (config.headers.Authorization === 'Bearer valid-jwt'); + return new Response(JSON.stringify({ + code: '0', data: { requireLogin, authenticated: loggedIn }, + }), { status: userStatus, headers: { 'content-type': 'application/json' } }); + } + assert.equal(url, '/api/sys-param/sys.home.page.url'); + return new Response(JSON.stringify({ code: '0', data: { paramValue: homeUrl } }), { + headers: { 'content-type': 'application/json' }, + }); + }, + }); + await new Promise(setImmediate); + await clock.run(500); + return { requests, loginModes, events, redirects, cacheWrites, rendered }; +} + +test('bootstrap restores a local JWT login on the home page without reopening login', async () => { + const fixture = await bootstrapFixture({ session: JSON.stringify({ token: 'valid-jwt' }) }); + assert.equal(fixture.requests[0].config.headers.Authorization, 'Bearer valid-jwt'); + assert.equal(fixture.requests[0].config.credentials, 'include'); + assert.deepEqual(fixture.loginModes, [true]); + assert.equal(fixture.rendered, true); + assert.deepEqual(fixture.events, []); + assert.deepEqual(fixture.redirects, []); +}); + +test('bootstrap distinguishes mandatory login from an already authenticated SSO session', async () => { + const fixture = await bootstrapFixture({ authenticated: true }); + assert.equal(fixture.requests[0].config.credentials, 'include'); + assert.deepEqual(fixture.loginModes, [true]); + assert.equal(fixture.rendered, true); + assert.deepEqual(fixture.events, []); +}); + +test('bootstrap still prompts an unauthenticated user when login is required', async () => { + const fixture = await bootstrapFixture(); + assert.equal(fixture.rendered, true); + assert.deepEqual(fixture.events, ['show-login']); + assert.equal(fixture.requests.length, 1); +}); + +test('bootstrap does not trust a rejected token merely because a session exists', async () => { + const fixture = await bootstrapFixture({ + session: JSON.stringify({ token: 'expired-jwt' }), authenticated: false, + }); + assert.deepEqual(fixture.events, ['show-login']); + assert.deepEqual(fixture.loginModes, [true]); +}); + +test('bootstrap handles an explicit 401 as unauthenticated', async () => { + const fixture = await bootstrapFixture({ userStatus: 401 }); + assert.deepEqual(fixture.events, ['show-login']); + assert.deepEqual(fixture.loginModes, [true]); +}); + +test('bootstrap permits anonymous home access when login is disabled', async () => { + const fixture = await bootstrapFixture({ requireLogin: false }); + assert.equal(fixture.rendered, true); + assert.deepEqual(fixture.loginModes, [false]); + assert.deepEqual(fixture.events, []); +}); + +test('bootstrap checks the configured home page with the authenticated JWT', async () => { + const fixture = await bootstrapFixture({ + session: JSON.stringify({ token: 'valid-jwt' }), homeUrl: '/data/management', + }); + assert.equal(fixture.requests[1].config.headers.Authorization, 'Bearer valid-jwt'); + assert.deepEqual(fixture.redirects, ['/data/management']); + assert.equal(fixture.rendered, false); + assert.deepEqual(fixture.events, []); +}); + +test('bootstrap clears a removed home URL instead of redirecting an authenticated user to its stale cache', async () => { + const fixture = await bootstrapFixture({ authenticated: true, cachedHomeUrl: '/old-home' }); + assert.deepEqual(fixture.redirects, []); + assert.deepEqual(fixture.cacheWrites, [null]); + assert.equal(fixture.rendered, true); + assert.deepEqual(fixture.events, []); +}); + +test('bootstrap ignores home redirects outside the root route while restoring the JWT', async () => { + const fixture = await bootstrapFixture({ + session: JSON.stringify({ token: 'valid-jwt' }), pathname: '/data/management', + }); + assert.equal(fixture.requests[0].config.headers.Authorization, 'Bearer valid-jwt'); + assert.equal(fixture.requests.length, 1); + assert.equal(fixture.rendered, true); + assert.deepEqual(fixture.events, []); +}); + +test('bootstrap treats malformed session storage as unauthenticated without crashing', async () => { + const fixture = await bootstrapFixture({ session: 'invalid-json' }); + assert.deepEqual(fixture.events, ['show-login']); + assert.equal(fixture.rendered, true); +}); diff --git a/frontend/tests/helpers.cjs b/frontend/tests/helpers.cjs new file mode 100644 index 000000000..7f73b086d --- /dev/null +++ b/frontend/tests/helpers.cjs @@ -0,0 +1,110 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const ts = require('typescript'); + +function loadSource(file, imports = {}, globals = {}) { + const root = process.env.DATAMATE_TEST_SOURCE_ROOT || path.resolve(__dirname, '..'); + const filename = path.join(root, file); + const code = ts.transpileModule(fs.readFileSync(filename, 'utf8'), { + fileName: filename, + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.ReactJSX, + esModuleInterop: true, + }, + }).outputText; + const exports = {}; + vm.runInNewContext(code, { + exports, + require(name) { + if (Object.hasOwn(imports, name)) return imports[name]; + if (name === 'react/jsx-runtime') { + const jsx = (type, props) => ({ type, props }); + return { jsx, jsxs: jsx, Fragment: 'Fragment' }; + } + throw new Error(`Missing test dependency: ${name} in ${file}`); + }, + console, + setTimeout, + clearTimeout, + URLSearchParams, + FormData, + DOMException, + ...globals, + }, { filename }); + return exports; +} + +// Exercise hooks, effects, and timer cancellation without requiring a DOM runtime. +function hookRunner() { + const slots = []; + let index = 0; + let pending = []; + let result; + const changed = (old, next) => !old || !next || old.length !== next.length || old.some((value, i) => !Object.is(value, next[i])); + const react = { + useState(initial) { + const key = index++; + if (!slots[key]) slots[key] = { value: typeof initial === 'function' ? initial() : initial }; + return [slots[key].value, value => { + slots[key].value = typeof value === 'function' ? value(slots[key].value) : value; + }]; + }, + useRef(initial) { + const key = index++; + return (slots[key] ??= { current: initial }); + }, + useMemo(factory, deps) { + const key = index++; + if (changed(slots[key]?.deps, deps)) slots[key] = { deps, value: factory() }; + return slots[key].value; + }, + useCallback(fn, deps) { return react.useMemo(() => fn, deps); }, + useEffect(effect, deps) { + const key = index++; + if (changed(slots[key]?.deps, deps)) { + const previous = slots[key]; + const slot = slots[key] = { deps, effect }; + pending.push(() => { previous?.cleanup?.(); slot.cleanup = effect(); }); + } + }, + }; + return { + react, + render(render) { + index = 0; + pending = []; + result = render(); + pending.forEach(effect => effect()); + return result; + }, + get result() { return result; }, + strictRemount() { + const effects = slots.filter(slot => slot.effect); + effects.forEach(slot => slot.cleanup?.()); + effects.forEach(slot => { slot.cleanup = slot.effect(); }); + }, + unmount() { slots.forEach(slot => slot.cleanup?.()); }, + }; +} + +function fakeClock() { + let nextId = 1; + const timers = new Map(); + return { + setTimeout(callback, delay) { const id = nextId++; timers.set(id, { callback, delay }); return id; }, + clearTimeout(id) { timers.delete(id); }, + get size() { return timers.size; }, + async run(delay) { + const ready = [...timers].filter(([, timer]) => timer.delay === delay); + for (const [id, timer] of ready) { + if (timers.delete(id)) await timer.callback(); + } + await new Promise(setImmediate); + }, + }; +} + +module.exports = { loadSource, hookRunner, fakeClock }; diff --git a/runtime/datamate-python/app/module/evaluation/interface/evaluation.py b/runtime/datamate-python/app/module/evaluation/interface/evaluation.py index f5525be26..0bd5a29e2 100644 --- a/runtime/datamate-python/app/module/evaluation/interface/evaluation.py +++ b/runtime/datamate-python/app/module/evaluation/interface/evaluation.py @@ -115,6 +115,7 @@ async def list_evaluation_tasks( name: Optional[str] = Query(None, description="任务名称模糊查询"), status: Optional[str] = Query(None, description="任务状态过滤"), task_type: Optional[str] = Query(None, description="任务类型过滤"), + eval_method: Optional[str] = Query(None, description="评估方式过滤"), db: AsyncSession = Depends(get_db), ): """ @@ -126,6 +127,7 @@ async def list_evaluation_tasks( name: 任务名称模糊查询 status: 任务状态过滤 task_type: 任务类型过滤 + eval_method: 评估方式过滤 db: 数据库会话 Returns: @@ -140,6 +142,8 @@ async def list_evaluation_tasks( query = query.where(EvaluationTask.status == status) if task_type: query = query.where(EvaluationTask.task_type == task_type) + if eval_method: + query = query.where(EvaluationTask.eval_method == eval_method) # 获取总数 count_query = select(func.count()).select_from(query.subquery()) diff --git a/runtime/datamate-python/tests/test_evaluation_list_filters.py b/runtime/datamate-python/tests/test_evaluation_list_filters.py new file mode 100644 index 000000000..8b512abf0 --- /dev/null +++ b/runtime/datamate-python/tests/test_evaluation_list_filters.py @@ -0,0 +1,73 @@ +"""Exercise the list handler without importing evaluation execution dependencies.""" + +import ast +import asyncio +import math +from pathlib import Path +from types import SimpleNamespace +from typing import Optional +from unittest.mock import AsyncMock + +import pytest +from fastapi import Depends, Query +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import EvaluationTask + + +def _load_list_handler(): + source = Path(__file__).resolve().parents[1] / "app/module/evaluation/interface/evaluation.py" + tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) + handler = next(node for node in tree.body if isinstance(node, ast.AsyncFunctionDef) and node.name == "list_evaluation_tasks") + handler.decorator_list = [] + namespace = { + "Optional": Optional, + "Query": Query, + "Depends": Depends, + "AsyncSession": AsyncSession, + "get_db": lambda: None, + "select": select, + "func": func, + "math": math, + "EvaluationTask": EvaluationTask, + "SuccessResponse": SimpleNamespace, + "PagedEvaluationTaskResponse": SimpleNamespace, + "_map_to_task_detail_response": lambda task: task, + } + exec(compile(ast.Module(body=[handler], type_ignores=[]), str(source), "exec"), namespace) + return namespace["list_evaluation_tasks"] + + +@pytest.mark.parametrize("method", [None, "AUTO", "MANUAL"]) +def test_evaluation_filters_apply_to_count_and_page(method): + empty_result = SimpleNamespace( + scalar_one=lambda: 0, + scalars=lambda: SimpleNamespace(all=lambda: []), + ) + session = SimpleNamespace(execute=AsyncMock(return_value=empty_result)) + response = asyncio.run(_load_list_handler()( + page=2, + size=12, + name="evaluation", + status="COMPLETED", + task_type="QA", + eval_method=method, + db=session, + )) + assert response.data.content == [] + assert response.data.page == 2 + assert session.execute.await_count == 2 + for call in session.execute.await_args_list: + statement = call.args[0] + params = statement.compile().params + assert params["name_1"] == "%evaluation%" + assert params["status_1"] == "COMPLETED" + assert params["task_type_1"] == "QA" + if method is None: + assert "eval_method_1" not in params + else: + assert params["eval_method_1"] == method + page_params = session.execute.await_args_list[1].args[0].compile().params + assert page_params["param_1"] == 12 + assert page_params["param_2"] == 12 From ca3abb528506977b7f3a2bcfdcdad7bea83f1b47 Mon Sep 17 00:00:00 2001 From: hhhhsc <1710496817@qq.com> Date: Mon, 9 Nov 2026 11:29:59 +0800 Subject: [PATCH 2/3] fix --- deployment/docker/datamate/.env.example | 4 +++- deployment/docker/datamate/docker-compose.yml | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/deployment/docker/datamate/.env.example b/deployment/docker/datamate/.env.example index ed68e8f5c..e714b4c98 100644 --- a/deployment/docker/datamate/.env.example +++ b/deployment/docker/datamate/.env.example @@ -10,7 +10,9 @@ DB_PASSWORD=your-secure-password-here # JWT Authentication -JWT_SECRET=your-secure-jwt-secret-here +# Generate a random secret: openssl rand -hex 64 +# Use the same value for gateway and backend; at least 64 UTF-8 bytes for HS256/HS512. +JWT_SECRET= DATAMATE_JWT_ENABLE=false # MinIO (for Milvus storage) diff --git a/deployment/docker/datamate/docker-compose.yml b/deployment/docker/datamate/docker-compose.yml index d12b1aa50..38e16b109 100644 --- a/deployment/docker/datamate/docker-compose.yml +++ b/deployment/docker/datamate/docker-compose.yml @@ -68,7 +68,8 @@ services: ports: - '8080:8080' environment: - - JWT_SECRET=${JWT_SECRET:-} + - DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required. Set in .env file} + - JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required. Set in .env file} - datamate.jwt.enable=${DATAMATE_JWT_ENABLE:-false} - LOG_ROTATION_MAX_SIZE=${LOG_ROTATION_MAX_SIZE:-100MB} - LOG_ROTATION_BACKUP_COUNT=${LOG_ROTATION_BACKUP_COUNT:-30} From 68d1dc8c9e60240d9400449f5de8c56ef2375e88 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 10 Nov 2026 17:32:40 +0800 Subject: [PATCH 3/3] fix: authenticate cleaning logs and simplify evaluation UI --- frontend/package.json | 2 +- frontend/src/i18n/locales/en/common.json | 2 + frontend/src/i18n/locales/zh/common.json | 2 + .../Detail/components/LogsTable.tsx | 4 +- .../src/pages/DataCleansing/cleansing.api.ts | 67 ++++++++- .../pages/DataEvaluation/evaluation.const.tsx | 4 +- .../src/pages/SynthesisTask/DataSynthesis.tsx | 14 -- .../components/SynthesisTaskTab.tsx | 16 ++- frontend/src/utils/request.ts | 11 +- frontend/tests/cleaning-logs.test.cjs | 118 ++++++++++++++++ .../module/evaluation/interface/evaluation.py | 13 +- .../module/evaluation/schema/evaluation.py | 14 +- .../module/evaluation/service/evaluation.py | 6 + .../tests/test_evaluation_task_creation.py | 130 ++++++++++++++++++ 14 files changed, 361 insertions(+), 42 deletions(-) create mode 100644 frontend/tests/cleaning-logs.test.cjs create mode 100644 runtime/datamate-python/tests/test_evaluation_task_creation.py diff --git a/frontend/package.json b/frontend/package.json index 17b069cb2..53c95e0f1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,7 @@ "dev": "vite", "mock": "cd src/mock && nodemon --config nodemon.json --inspect=0.0.0.0:9229 mock.cjs --env=development --port=8002", "build": "vite build", - "test": "node --test tests/basic-functionality.test.cjs tests/bootstrap-auth.test.cjs", + "test": "node --test tests/basic-functionality.test.cjs tests/bootstrap-auth.test.cjs tests/cleaning-logs.test.cjs", "lint": "eslint .", "preview": "vite preview" }, diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index af81d1063..c5576e9d3 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -2338,11 +2338,13 @@ "evalTitle": "Create Evaluation Task", "evalName": "Evaluation Task Name", "evalMethod": { + "label": "Evaluation Method", "auto": "Auto Evaluation", "cot": "CoT Evaluation", "qa": "QA Evaluation" }, "evalModel": "Evaluation Model", + "evalObject": "Evaluation Object", "evalSource": "Source: Synthesis Task (SYNTHESIS)", "sourceName": "Source Name: {{name}}", "startEval": "Start Evaluation", diff --git a/frontend/src/i18n/locales/zh/common.json b/frontend/src/i18n/locales/zh/common.json index 643278dbc..0a68a89f3 100644 --- a/frontend/src/i18n/locales/zh/common.json +++ b/frontend/src/i18n/locales/zh/common.json @@ -2337,11 +2337,13 @@ "evalTitle": "创建评估任务", "evalName": "评估任务名称", "evalMethod": { + "label": "评估方式", "auto": "自动评估", "cot": "CoT 评估", "qa": "QA 评估" }, "evalModel": "评估模型", + "evalObject": "评估对象", "evalSource": "来源:合成任务(SYNTHESIS)", "sourceName": "源名称:{{name}}", "startEval": "开始评估", diff --git a/frontend/src/pages/DataCleansing/Detail/components/LogsTable.tsx b/frontend/src/pages/DataCleansing/Detail/components/LogsTable.tsx index b8efa8bba..a16c1f942 100644 --- a/frontend/src/pages/DataCleansing/Detail/components/LogsTable.tsx +++ b/frontend/src/pages/DataCleansing/Detail/components/LogsTable.tsx @@ -34,7 +34,7 @@ export default function LogsTable({ const [selectedLog, setSelectedLog] = useState(retryCount + 1); const [streamingLogs, setStreamingLogs] = useState([]); const [isStreaming, setIsStreaming] = useState(false); - const eventSourceRef = useRef(null); + const eventSourceRef = useRef | null>(null); const { message } = App.useApp(); // Only stream when task is RUNNING and viewing the latest run @@ -123,7 +123,7 @@ export default function LogsTable({ const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `${taskName}_${t("dataCleansing.logTable.nthRun", { num: selectedLog })}.log`; + a.download = `${taskName}_${t("dataCleansing.detail.logTable.nthRun", { num: selectedLog })}.log`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); diff --git a/frontend/src/pages/DataCleansing/cleansing.api.ts b/frontend/src/pages/DataCleansing/cleansing.api.ts index a8bf7ab52..75b3a93d3 100644 --- a/frontend/src/pages/DataCleansing/cleansing.api.ts +++ b/frontend/src/pages/DataCleansing/cleansing.api.ts @@ -31,18 +31,71 @@ export function queryCleaningTaskLogByIdUsingGet(taskId: string | number, retryC return get(`/api/cleaning/tasks/${taskId}/log/${retryCount}`); } -export function streamCleaningTaskLog(taskId: string | number, retryCount: number = 0): EventSource { +export interface CleaningTaskLogStream { + onmessage: ((event: { data: string }) => void) | null; + onerror: ((error: unknown) => void) | null; + close: () => void; +} + +export function streamCleaningTaskLog(taskId: string | number, retryCount: number = 0): CleaningTaskLogStream { const url = `/api/cleaning/tasks/${taskId}/log/stream?retry_count=${retryCount}`; - return new EventSource(url); + const controller = new AbortController(); + const stream: CleaningTaskLogStream = { + onmessage: null, + onerror: null, + close: () => controller.abort(), + }; + + // Fetch supports the Authorization header supplied by the request interceptor. + void (async () => { + try { + const response = await get(url, null, { + responseType: "stream", + headers: { Accept: "text/event-stream" }, + signal: controller.signal, + }); + if (!response.headers.get("content-type")?.includes("text/event-stream") || !response.body) { + throw new Error("Invalid log stream response"); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let data: string[] = []; + try { + while (!controller.signal.aborted) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let end: number; + while (!controller.signal.aborted && (end = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, end).replace(/\r$/, ""); + buffer = buffer.slice(end + 1); + if (line === "") { + if (data.length) stream.onmessage?.({ data: data.join("\n") }); + data = []; + } else if (line === "data" || line.startsWith("data:")) { + data.push(line.slice(5).replace(/^ /, "")); + } + } + } + } finally { + await reader.cancel(); + reader.releaseLock(); + } + if (!controller.signal.aborted) { + throw new Error("Log stream ended"); + } + } catch (error) { + if (!controller.signal.aborted) stream.onerror?.(error); + } + })(); + + return stream; } export async function downloadCleaningTaskLog(taskId: string | number, retryCount: number = 0): Promise { const url = `/api/cleaning/tasks/${taskId}/log/${retryCount}/download`; - const response = await fetch(url); - if (!response.ok) { - throw new Error('Failed to download log file'); - } - return response.blob(); + return get(url, null, { responseType: "blob" }); } export function updateCleaningTaskByIdUsingPut(taskId: string | number, data: any) { diff --git a/frontend/src/pages/DataEvaluation/evaluation.const.tsx b/frontend/src/pages/DataEvaluation/evaluation.const.tsx index 5371b56ca..f81b23c94 100644 --- a/frontend/src/pages/DataEvaluation/evaluation.const.tsx +++ b/frontend/src/pages/DataEvaluation/evaluation.const.tsx @@ -23,7 +23,6 @@ export function getTaskTypes(t: TFunction) { export function getEvalMethods(t: TFunction) { return [ { label: t("dataEvaluation.create.evalMethods.auto"), value: "AUTO" }, - { label: t("dataEvaluation.create.evalMethods.manual"), value: "MANUAL" }, ]; } @@ -33,6 +32,9 @@ export const getEvalType = (type: string, t?: TFunction) => { }; export const getEvalMethod = (type: string, t?: TFunction) => { + if (type === "MANUAL") { + return t ? t("dataEvaluation.create.evalMethods.manual") : "人工评估"; + } if (t) return getEvalMethods(t).find((item) => item.value === type)?.label; return EVAL_METHODS.find((item) => item.value === type)?.label; }; diff --git a/frontend/src/pages/SynthesisTask/DataSynthesis.tsx b/frontend/src/pages/SynthesisTask/DataSynthesis.tsx index c2fca0051..9e886f7df 100644 --- a/frontend/src/pages/SynthesisTask/DataSynthesis.tsx +++ b/frontend/src/pages/SynthesisTask/DataSynthesis.tsx @@ -4,7 +4,6 @@ import { PlusOutlined } from "@ant-design/icons"; import { Plus, ArrowRight } from "lucide-react"; import { useNavigate } from "react-router"; import { useTranslation } from "react-i18next"; -import InstructionTemplateTab from "./components/InstructionTemplateTab"; import SynthesisTaskTab from "./components/SynthesisTaskTab"; export default function DataSynthesisPage() { @@ -36,14 +35,6 @@ export default function DataSynthesisPage() {

{t('synthesisTask.title')}

-