diff --git a/frontend/src/hooks/useFetchData.ts b/frontend/src/hooks/useFetchData.ts index 39aeb77..ceedd5c 100644 --- a/frontend/src/hooks/useFetchData.ts +++ b/frontend/src/hooks/useFetchData.ts @@ -1,238 +1,252 @@ -// 首页数据获取 -// 支持轮询功能,使用示例: -// const { fetchData, startPolling, stopPolling, isPolling } = useFetchData( -// fetchFunction, -// mapFunction, -// 5000, // 5秒轮询一次,默认30秒 -// true, // 是否自动开始轮询,默认 true -// [fetchStatistics, fetchOtherData] // 额外的轮询函数数组 -// ); -// -// startPolling(); // 开始轮询 -// stopPolling(); // 停止轮询 -// 手动调用 fetchData() 时,如果正在轮询,会重新开始轮询计时 -// 轮询时会同时执行主要的 fetchFunction 和所有额外的轮询函数 -import { useState, useRef, useEffect, useCallback } from "react"; -import { useDebouncedEffect } from "./useDebouncedEffect"; -import Loading from "@/utils/loading"; -import { App } from "antd"; - -export default function useFetchData( - fetchFunc: (params?: any) => Promise, - mapDataFunc: (data: Partial) => T = (data) => data as T, - pollingInterval: number = 30000, // 默认30秒轮询一次 - autoRefresh: boolean = false, // 是否自动开始轮询,默认 false - additionalPollingFuncs: (() => Promise)[] = [], // 额外的轮询函数 - pageOffset: number = 1 -) { - const { message } = App.useApp(); - - // 轮询相关状态 - const [isPolling, setIsPolling] = useState(false); - const pollingTimerRef = useRef(null); - - // 表格数据 - const [tableData, setTableData] = useState([]); - // 设置加载状态 - const [loading, setLoading] = useState(false); - - // 搜索参数 - const [searchParams, setSearchParams] = useState({ - keyword: "", - filter: { - type: [] as string[], - status: [] as string[], - tags: [] as string[], - // 通用分类筛选(如算子市场的分类 ID 列表) - categories: [] as string[][], - selectedStar: false, - }, - current: 1, - pageSize: 12, - }); - - // 分页配置 - const [pagination, setPagination] = useState({ - total: 0, - showSizeChanger: true, - pageSizeOptions: ["12", "24", "48"], - showTotal: (total: number) => `共 ${total} 条`, - onChange: (current: number, pageSize?: number) => { - setSearchParams((prev) => ({ - ...prev, - current, - pageSize: pageSize || prev.pageSize, - })); - }, - }); - - const handleFiltersChange = (searchFilters: { [key: string]: string[] }) => { - setSearchParams({ - ...searchParams, - current: 1, - filter: { ...searchParams.filter, ...searchFilters }, - }); - }; - - const handleKeywordChange = (keyword: string) => { - setSearchParams({ - ...searchParams, - current: 1, - keyword: keyword, - }); - }; - - function getFirstOfArray(arr: string[]) { - if (!arr || arr.length === 0 || !Array.isArray(arr)) return undefined; - if (arr[0] === "all") return undefined; - return arr[0]; - } - - // 清除轮询定时器 - const clearPollingTimer = useCallback(() => { - if (pollingTimerRef.current) { - clearTimeout(pollingTimerRef.current); - pollingTimerRef.current = null; - } - }, []); - - const fetchData = useCallback( - async (extraParams = {}, skipPollingRestart = false) => { - const { keyword, filter, current, pageSize } = searchParams; - if (!skipPollingRestart) { - Loading.show(); - setLoading(true); - } - - // 如果正在轮询且不是轮询触发的调用,先停止当前轮询 - const wasPolling = isPolling && !skipPollingRestart; - if (wasPolling) { - clearPollingTimer(); - } - - try { - // 同时执行主要数据获取和额外的轮询函数 - const promises = [ - fetchFunc({ - categories: filter.categories, - ...extraParams, - keyword, - isStar: filter.selectedStar ? true : undefined, - type: getFirstOfArray(filter?.type) || undefined, - status: getFirstOfArray(filter?.status) || undefined, - tags: filter?.tags?.length ? filter.tags.join(",") : undefined, - page: current - pageOffset, - size: pageSize, // Use camelCase for HTTP query params - }), - ...additionalPollingFuncs.map((func) => func()), - ]; - - const results = await Promise.all(promises); - const { data } = results[0]; // 主要数据结果 - - setPagination((prev) => ({ - ...prev, - total: data?.totalElements ?? data?.total ?? 0, - })); - let result = []; - if (mapDataFunc) { - result = data?.content.map(mapDataFunc) ?? []; - } - setTableData(result); - - // 如果之前正在轮询且不是轮询触发的调用,重新开始轮询 - if (wasPolling) { - const poll = () => { - pollingTimerRef.current = setTimeout(() => { - fetchData({}, true).then(() => { - if (pollingTimerRef.current) { - poll(); - } - }); - }, pollingInterval); - }; - poll(); - } - } catch (error) { - console.error(error); - message.error("数据获取失败,请稍后重试"); - } finally { - Loading.hide(); - setLoading(false); - } - }, - [ - searchParams, - fetchFunc, - mapDataFunc, - isPolling, - clearPollingTimer, - pollingInterval, - message, - additionalPollingFuncs, - ] - ); - - // 开始轮询 - const startPolling = useCallback(() => { - clearPollingTimer(); - setIsPolling(true); - - const poll = () => { - pollingTimerRef.current = setTimeout(() => { - fetchData({}, true).then(() => { - if (pollingTimerRef.current) { - poll(); - } - }); - }, pollingInterval); - }; - - poll(); - }, [pollingInterval, clearPollingTimer, fetchData]); - - // 停止轮询 - const stopPolling = useCallback(() => { - clearPollingTimer(); - setIsPolling(false); - }, [clearPollingTimer]); - - // 搜索参数变化时,自动刷新数据 - // keyword 变化时,防抖500ms后刷新 - useDebouncedEffect( - () => { - fetchData(); - }, - [searchParams], - searchParams?.keyword ? 500 : 0 - ); - - // 组件卸载时清理轮询 - useEffect(() => { - if (autoRefresh) { - startPolling(); - } - return () => { - clearPollingTimer(); - }; - }, [clearPollingTimer]); - - return { - loading, - tableData, - pagination: { - ...pagination, - current: searchParams.current, - pageSize: searchParams.pageSize, - }, - searchParams, - setSearchParams, - setPagination, - handleFiltersChange, - handleKeywordChange, - fetchData, - isPolling, - startPolling, - stopPolling, - }; -} +// 首页数据获取 +// 支持轮询功能,使用示例: +// const { fetchData, startPolling, stopPolling, isPolling } = useFetchData( +// fetchFunction, +// mapFunction, +// 5000, // 5秒轮询一次,默认30秒 +// true, // 是否自动开始轮询,默认 true +// [fetchStatistics, fetchOtherData] // 额外的轮询函数数组 +// ); +// +// startPolling(); // 开始轮询 +// stopPolling(); // 停止轮询 +// 手动调用 fetchData() 时,如果正在轮询,会重新开始轮询计时 +// 轮询时会同时执行主要的 fetchFunction 和所有额外的轮询函数 +import { useState, useRef, useEffect, useCallback } from "react"; +import { useDebouncedEffect } from "./useDebouncedEffect"; +import Loading from "@/utils/loading"; +import { App } from "antd"; + +type FetchParams = Record; +type FetchResult = { + data?: { + content?: Partial[]; + totalElements?: number; + total?: number; + }; +}; + +export default function useFetchData( + fetchFunc: (params?: FetchParams) => Promise>, + mapDataFunc: (data: Partial) => T = (data) => data as T, + pollingInterval: number = 30000, // 默认30秒轮询一次 + autoRefresh: boolean = false, // 是否自动开始轮询,默认 false + additionalPollingFuncs: (() => Promise)[] = [], // 额外的轮询函数 + pageOffset: number = 1, + filterParamMapper?: (filters: Record) => Record +) { + const { message } = App.useApp(); + + // 轮询相关状态 + const [isPolling, setIsPolling] = useState(false); + const pollingTimerRef = useRef(null); + + // 表格数据 + const [tableData, setTableData] = useState([]); + // 设置加载状态 + const [loading, setLoading] = useState(false); + + // 搜索参数 + const [searchParams, setSearchParams] = useState({ + keyword: "", + filter: { + type: [] as string[], + status: [] as string[], + tags: [] as string[], + // 通用分类筛选(如算子市场的分类 ID 列表) + categories: [] as string[][], + selectedStar: false, + }, + current: 1, + pageSize: 12, + }); + + // 分页配置 + const [pagination, setPagination] = useState({ + total: 0, + showSizeChanger: true, + pageSizeOptions: ["12", "24", "48"], + showTotal: (total: number) => `共 ${total} 条`, + onChange: (current: number, pageSize?: number) => { + setSearchParams((prev) => ({ + ...prev, + current, + pageSize: pageSize || prev.pageSize, + })); + }, + }); + + const handleFiltersChange = (searchFilters: { [key: string]: string[] }) => { + setSearchParams({ + ...searchParams, + current: 1, + filter: { ...searchParams.filter, ...searchFilters }, + }); + }; + + const handleKeywordChange = (keyword: string) => { + setSearchParams({ + ...searchParams, + current: 1, + keyword: keyword, + }); + }; + + function getFirstOfArray(arr: string[]) { + if (!arr || arr.length === 0 || !Array.isArray(arr)) return undefined; + if (arr[0] === "all") return undefined; + return arr[0]; + } + + // 清除轮询定时器 + const clearPollingTimer = useCallback(() => { + if (pollingTimerRef.current) { + clearTimeout(pollingTimerRef.current); + pollingTimerRef.current = null; + } + }, []); + + const fetchData = useCallback( + async (extraParams = {}, skipPollingRestart = false) => { + const { keyword, filter, current, pageSize } = searchParams; + if (!skipPollingRestart) { + Loading.show(); + setLoading(true); + } + + // 如果正在轮询且不是轮询触发的调用,先停止当前轮询 + const wasPolling = isPolling && !skipPollingRestart; + if (wasPolling) { + clearPollingTimer(); + } + + try { + const mappedFilterParams = filterParamMapper ? filterParamMapper(filter) : {}; + // 同时执行主要数据获取和额外的轮询函数 + const promises = [ + fetchFunc({ + categories: filter.categories, + ...extraParams, + keyword, + isStar: filter.selectedStar ? true : undefined, + type: getFirstOfArray(filter?.type) || undefined, + status: getFirstOfArray(filter?.status) || undefined, + tags: filter?.tags?.length ? filter.tags.join(",") : undefined, + ...mappedFilterParams, + page: current - pageOffset, + size: pageSize, // Use camelCase for HTTP query params + }), + ...additionalPollingFuncs.map((func) => func()), + ]; + + const results = await Promise.all(promises); + const { data } = results[0]; // 主要数据结果 + + setPagination((prev) => ({ + ...prev, + total: data?.totalElements ?? data?.total ?? 0, + })); + let result = []; + if (mapDataFunc) { + result = data?.content.map(mapDataFunc) ?? []; + } + setTableData(result); + + // 如果之前正在轮询且不是轮询触发的调用,重新开始轮询 + if (wasPolling) { + const poll = () => { + pollingTimerRef.current = setTimeout(() => { + fetchData({}, true).then(() => { + if (pollingTimerRef.current) { + poll(); + } + }); + }, pollingInterval); + }; + poll(); + } + } catch (error) { + console.error(error); + message.error("数据获取失败,请稍后重试"); + } finally { + Loading.hide(); + setLoading(false); + } + }, + [ + searchParams, + fetchFunc, + mapDataFunc, + isPolling, + clearPollingTimer, + pageOffset, + pollingInterval, + message, + additionalPollingFuncs, + filterParamMapper, + ] + ); + + // 开始轮询 + const startPolling = useCallback(() => { + clearPollingTimer(); + setIsPolling(true); + + const poll = () => { + pollingTimerRef.current = setTimeout(() => { + fetchData({}, true).then(() => { + if (pollingTimerRef.current) { + poll(); + } + }); + }, pollingInterval); + }; + + poll(); + }, [pollingInterval, clearPollingTimer, fetchData]); + + // 停止轮询 + const stopPolling = useCallback(() => { + clearPollingTimer(); + setIsPolling(false); + }, [clearPollingTimer]); + + // 搜索参数变化时,自动刷新数据 + // keyword 变化时,防抖500ms后刷新 + useDebouncedEffect( + () => { + fetchData(); + }, + [searchParams], + searchParams?.keyword ? 500 : 0 + ); + + // 组件卸载时清理轮询 + useEffect(() => { + if (autoRefresh) { + startPolling(); + } + return () => { + clearPollingTimer(); + }; + }, [autoRefresh, startPolling, clearPollingTimer]); + + return { + loading, + tableData, + pagination: { + ...pagination, + current: searchParams.current, + pageSize: searchParams.pageSize, + }, + searchParams, + setSearchParams, + setPagination, + handleFiltersChange, + handleKeywordChange, + fetchData, + isPolling, + startPolling, + stopPolling, + }; +} diff --git a/frontend/src/pages/DataAnnotation/Template/TemplateList.tsx b/frontend/src/pages/DataAnnotation/Template/TemplateList.tsx index 5278169..b9c3d18 100644 --- a/frontend/src/pages/DataAnnotation/Template/TemplateList.tsx +++ b/frontend/src/pages/DataAnnotation/Template/TemplateList.tsx @@ -56,6 +56,31 @@ const TemplateList: React.FC = () => { }, ]; + const BUILT_IN_FLAG = { + TRUE: "true", + FALSE: "false", + } as const; + + const mapTemplateFilters = (filters: Record) => { + const getFirstValue = (values?: string[]) => + values && values.length > 0 ? values[0] : undefined; + + const builtInRaw = getFirstValue(filters.builtIn); + const builtIn = + builtInRaw === BUILT_IN_FLAG.TRUE + ? true + : builtInRaw === BUILT_IN_FLAG.FALSE + ? false + : undefined; + + return { + category: getFirstValue(filters.category), + dataType: getFirstValue(filters.dataType), + labelingType: getFirstValue(filters.labelingType), + builtIn, + }; + }; + // Modals const [isFormVisible, setIsFormVisible] = useState(false); const [isDetailVisible, setIsDetailVisible] = useState(false); @@ -71,7 +96,15 @@ const TemplateList: React.FC = () => { fetchData, handleFiltersChange, handleKeywordChange, - } = useFetchData(queryAnnotationTemplatesUsingGet, undefined, undefined, undefined, undefined, 0); + } = useFetchData( + queryAnnotationTemplatesUsingGet, + undefined, + undefined, + undefined, + undefined, + 0, + mapTemplateFilters + ); const handleCreate = () => { setFormMode("create"); diff --git a/runtime/datamate-python/app/module/annotation/interface/template.py b/runtime/datamate-python/app/module/annotation/interface/template.py index 6ab8a76..bb24704 100644 --- a/runtime/datamate-python/app/module/annotation/interface/template.py +++ b/runtime/datamate-python/app/module/annotation/interface/template.py @@ -67,6 +67,7 @@ async def get_template( async def list_template( page: int = Query(1, ge=1, description="页码"), size: int = Query(10, ge=1, le=100, description="每页大小"), + keyword: Optional[str] = Query(None, description="关键词"), category: Optional[str] = Query(None, description="分类筛选"), dataType: Optional[str] = Query(None, alias="dataType", description="数据类型筛选"), labelingType: Optional[str] = Query(None, alias="labelingType", description="标注类型筛选"), @@ -78,6 +79,7 @@ async def list_template( - **page**: 页码(从1开始) - **size**: 每页大小(1-100) + - **keyword**: 关键词(匹配名称/描述) - **category**: 模板分类筛选 - **dataType**: 数据类型筛选 - **labelingType**: 标注类型筛选 @@ -90,7 +92,8 @@ async def list_template( category=category, data_type=dataType, labeling_type=labelingType, - built_in=builtIn + built_in=builtIn, + keyword=keyword ) return StandardResponse(code=200, message="success", data=templates) diff --git a/runtime/datamate-python/app/module/annotation/service/template.py b/runtime/datamate-python/app/module/annotation/service/template.py index 478bd4f..350afad 100644 --- a/runtime/datamate-python/app/module/annotation/service/template.py +++ b/runtime/datamate-python/app/module/annotation/service/template.py @@ -3,7 +3,7 @@ Annotation Template Service """ from typing import Optional, List from datetime import datetime -from sqlalchemy import select, func +from sqlalchemy import select, func, or_ from sqlalchemy.ext.asyncio import AsyncSession from uuid import uuid4 from fastapi import HTTPException @@ -185,7 +185,8 @@ class AnnotationTemplateService: category: Optional[str] = None, data_type: Optional[str] = None, labeling_type: Optional[str] = None, - built_in: Optional[bool] = None + built_in: Optional[bool] = None, + keyword: Optional[str] = None ) -> AnnotationTemplateListResponse: """ 获取模板列表 @@ -213,6 +214,14 @@ class AnnotationTemplateService: conditions.append(AnnotationTemplate.labeling_type == labeling_type) # type: ignore if built_in is not None: conditions.append(AnnotationTemplate.built_in == built_in) # type: ignore + if keyword: + like_keyword = f"%{keyword}%" + conditions.append( + or_( + AnnotationTemplate.name.ilike(like_keyword), # type: ignore + AnnotationTemplate.description.ilike(like_keyword) # type: ignore + ) + ) # 查询总数 count_result = await db.execute( @@ -273,13 +282,14 @@ class AnnotationTemplateService: for field, value in update_data.items(): if field == 'configuration' and value is not None: # 验证配置JSON - config_dict = value.model_dump(mode='json', by_alias=False) + config = value if isinstance(value, TemplateConfiguration) else TemplateConfiguration.model_validate(value) + config_dict = config.model_dump(mode='json', by_alias=False) valid, error = LabelStudioConfigValidator.validate_configuration_json(config_dict) if not valid: raise HTTPException(status_code=400, detail=f"Invalid configuration: {error}") - + # 重新生成Label Studio XML配置(用于验证) - label_config = self.generate_label_studio_config(value) + label_config = self.generate_label_studio_config(config) # 验证生成的XML valid, error = LabelStudioConfigValidator.validate_xml(label_config)