feat(template): 添加模板搜索功能和优化数据获取

- 添加 keyword 参数支持模板名称和描述模糊搜索
- 在 useFetchData hook 中添加 filterParamMapper 参数用于过滤参数映射
- 为模板列表页面实现内置标志过滤器映射功能
- 优化模板配置更新逻辑,改进数据验证和转换流程
- 完善模板服务中的条件查询,支持多字段模糊匹配
- 更新数据获取 hook 的依赖数组以正确处理轮询逻辑
This commit is contained in:
2026-01-22 21:25:04 +08:00
parent d22d677efe
commit ccb581d501
4 changed files with 305 additions and 245 deletions

View File

@@ -1,238 +1,252 @@
// 首页数据获取 // 首页数据获取
// 支持轮询功能,使用示例: // 支持轮询功能,使用示例:
// const { fetchData, startPolling, stopPolling, isPolling } = useFetchData( // const { fetchData, startPolling, stopPolling, isPolling } = useFetchData(
// fetchFunction, // fetchFunction,
// mapFunction, // mapFunction,
// 5000, // 5秒轮询一次,默认30秒 // 5000, // 5秒轮询一次,默认30秒
// true, // 是否自动开始轮询,默认 true // true, // 是否自动开始轮询,默认 true
// [fetchStatistics, fetchOtherData] // 额外的轮询函数数组 // [fetchStatistics, fetchOtherData] // 额外的轮询函数数组
// ); // );
// //
// startPolling(); // 开始轮询 // startPolling(); // 开始轮询
// stopPolling(); // 停止轮询 // stopPolling(); // 停止轮询
// 手动调用 fetchData() 时,如果正在轮询,会重新开始轮询计时 // 手动调用 fetchData() 时,如果正在轮询,会重新开始轮询计时
// 轮询时会同时执行主要的 fetchFunction 和所有额外的轮询函数 // 轮询时会同时执行主要的 fetchFunction 和所有额外的轮询函数
import { useState, useRef, useEffect, useCallback } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { useDebouncedEffect } from "./useDebouncedEffect"; import { useDebouncedEffect } from "./useDebouncedEffect";
import Loading from "@/utils/loading"; import Loading from "@/utils/loading";
import { App } from "antd"; import { App } from "antd";
export default function useFetchData<T>( type FetchParams = Record<string, unknown>;
fetchFunc: (params?: any) => Promise<any>, type FetchResult<T> = {
mapDataFunc: (data: Partial<T>) => T = (data) => data as T, data?: {
pollingInterval: number = 30000, // 默认30秒轮询一次 content?: Partial<T>[];
autoRefresh: boolean = false, // 是否自动开始轮询,默认 false totalElements?: number;
additionalPollingFuncs: (() => Promise<any>)[] = [], // 额外的轮询函数 total?: number;
pageOffset: number = 1 };
) { };
const { message } = App.useApp();
export default function useFetchData<T>(
// 轮询相关状态 fetchFunc: (params?: FetchParams) => Promise<FetchResult<T>>,
const [isPolling, setIsPolling] = useState(false); mapDataFunc: (data: Partial<T>) => T = (data) => data as T,
const pollingTimerRef = useRef<NodeJS.Timeout | null>(null); pollingInterval: number = 30000, // 默认30秒轮询一次
autoRefresh: boolean = false, // 是否自动开始轮询,默认 false
// 表格数据 additionalPollingFuncs: (() => Promise<unknown>)[] = [], // 额外的轮询函数
const [tableData, setTableData] = useState<T[]>([]); pageOffset: number = 1,
// 设置加载状态 filterParamMapper?: (filters: Record<string, unknown>) => Record<string, unknown>
const [loading, setLoading] = useState(false); ) {
const { message } = App.useApp();
// 搜索参数
const [searchParams, setSearchParams] = useState({ // 轮询相关状态
keyword: "", const [isPolling, setIsPolling] = useState(false);
filter: { const pollingTimerRef = useRef<NodeJS.Timeout | null>(null);
type: [] as string[],
status: [] as string[], // 表格数据
tags: [] as string[], const [tableData, setTableData] = useState<T[]>([]);
// 通用分类筛选(如算子市场的分类 ID 列表) // 设置加载状态
categories: [] as string[][], const [loading, setLoading] = useState(false);
selectedStar: false,
}, // 搜索参数
current: 1, const [searchParams, setSearchParams] = useState({
pageSize: 12, keyword: "",
}); filter: {
type: [] as string[],
// 分页配置 status: [] as string[],
const [pagination, setPagination] = useState({ tags: [] as string[],
total: 0, // 通用分类筛选(如算子市场的分类 ID 列表)
showSizeChanger: true, categories: [] as string[][],
pageSizeOptions: ["12", "24", "48"], selectedStar: false,
showTotal: (total: number) => `${total}`, },
onChange: (current: number, pageSize?: number) => { current: 1,
setSearchParams((prev) => ({ pageSize: 12,
...prev, });
current,
pageSize: pageSize || prev.pageSize, // 分页配置
})); const [pagination, setPagination] = useState({
}, total: 0,
}); showSizeChanger: true,
pageSizeOptions: ["12", "24", "48"],
const handleFiltersChange = (searchFilters: { [key: string]: string[] }) => { showTotal: (total: number) => `${total}`,
setSearchParams({ onChange: (current: number, pageSize?: number) => {
...searchParams, setSearchParams((prev) => ({
current: 1, ...prev,
filter: { ...searchParams.filter, ...searchFilters }, current,
}); pageSize: pageSize || prev.pageSize,
}; }));
},
const handleKeywordChange = (keyword: string) => { });
setSearchParams({
...searchParams, const handleFiltersChange = (searchFilters: { [key: string]: string[] }) => {
current: 1, setSearchParams({
keyword: keyword, ...searchParams,
}); current: 1,
}; filter: { ...searchParams.filter, ...searchFilters },
});
function getFirstOfArray(arr: string[]) { };
if (!arr || arr.length === 0 || !Array.isArray(arr)) return undefined;
if (arr[0] === "all") return undefined; const handleKeywordChange = (keyword: string) => {
return arr[0]; setSearchParams({
} ...searchParams,
current: 1,
// 清除轮询定时器 keyword: keyword,
const clearPollingTimer = useCallback(() => { });
if (pollingTimerRef.current) { };
clearTimeout(pollingTimerRef.current);
pollingTimerRef.current = null; function getFirstOfArray(arr: string[]) {
} if (!arr || arr.length === 0 || !Array.isArray(arr)) return undefined;
}, []); if (arr[0] === "all") return undefined;
return arr[0];
const fetchData = useCallback( }
async (extraParams = {}, skipPollingRestart = false) => {
const { keyword, filter, current, pageSize } = searchParams; // 清除轮询定时器
if (!skipPollingRestart) { const clearPollingTimer = useCallback(() => {
Loading.show(); if (pollingTimerRef.current) {
setLoading(true); clearTimeout(pollingTimerRef.current);
} pollingTimerRef.current = null;
}
// 如果正在轮询且不是轮询触发的调用,先停止当前轮询 }, []);
const wasPolling = isPolling && !skipPollingRestart;
if (wasPolling) { const fetchData = useCallback(
clearPollingTimer(); async (extraParams = {}, skipPollingRestart = false) => {
} const { keyword, filter, current, pageSize } = searchParams;
if (!skipPollingRestart) {
try { Loading.show();
// 同时执行主要数据获取和额外的轮询函数 setLoading(true);
const promises = [ }
fetchFunc({
categories: filter.categories, // 如果正在轮询且不是轮询触发的调用,先停止当前轮询
...extraParams, const wasPolling = isPolling && !skipPollingRestart;
keyword, if (wasPolling) {
isStar: filter.selectedStar ? true : undefined, clearPollingTimer();
type: getFirstOfArray(filter?.type) || undefined, }
status: getFirstOfArray(filter?.status) || undefined,
tags: filter?.tags?.length ? filter.tags.join(",") : undefined, try {
page: current - pageOffset, const mappedFilterParams = filterParamMapper ? filterParamMapper(filter) : {};
size: pageSize, // Use camelCase for HTTP query params // 同时执行主要数据获取和额外的轮询函数
}), const promises = [
...additionalPollingFuncs.map((func) => func()), fetchFunc({
]; categories: filter.categories,
...extraParams,
const results = await Promise.all(promises); keyword,
const { data } = results[0]; // 主要数据结果 isStar: filter.selectedStar ? true : undefined,
type: getFirstOfArray(filter?.type) || undefined,
setPagination((prev) => ({ status: getFirstOfArray(filter?.status) || undefined,
...prev, tags: filter?.tags?.length ? filter.tags.join(",") : undefined,
total: data?.totalElements ?? data?.total ?? 0, ...mappedFilterParams,
})); page: current - pageOffset,
let result = []; size: pageSize, // Use camelCase for HTTP query params
if (mapDataFunc) { }),
result = data?.content.map(mapDataFunc) ?? []; ...additionalPollingFuncs.map((func) => func()),
} ];
setTableData(result);
const results = await Promise.all(promises);
// 如果之前正在轮询且不是轮询触发的调用,重新开始轮询 const { data } = results[0]; // 主要数据结果
if (wasPolling) {
const poll = () => { setPagination((prev) => ({
pollingTimerRef.current = setTimeout(() => { ...prev,
fetchData({}, true).then(() => { total: data?.totalElements ?? data?.total ?? 0,
if (pollingTimerRef.current) { }));
poll(); let result = [];
} if (mapDataFunc) {
}); result = data?.content.map(mapDataFunc) ?? [];
}, pollingInterval); }
}; setTableData(result);
poll();
} // 如果之前正在轮询且不是轮询触发的调用,重新开始轮询
} catch (error) { if (wasPolling) {
console.error(error); const poll = () => {
message.error("数据获取失败,请稍后重试"); pollingTimerRef.current = setTimeout(() => {
} finally { fetchData({}, true).then(() => {
Loading.hide(); if (pollingTimerRef.current) {
setLoading(false); poll();
} }
}, });
[ }, pollingInterval);
searchParams, };
fetchFunc, poll();
mapDataFunc, }
isPolling, } catch (error) {
clearPollingTimer, console.error(error);
pollingInterval, message.error("数据获取失败,请稍后重试");
message, } finally {
additionalPollingFuncs, Loading.hide();
] setLoading(false);
); }
},
// 开始轮询 [
const startPolling = useCallback(() => { searchParams,
clearPollingTimer(); fetchFunc,
setIsPolling(true); mapDataFunc,
isPolling,
const poll = () => { clearPollingTimer,
pollingTimerRef.current = setTimeout(() => { pageOffset,
fetchData({}, true).then(() => { pollingInterval,
if (pollingTimerRef.current) { message,
poll(); additionalPollingFuncs,
} filterParamMapper,
}); ]
}, pollingInterval); );
};
// 开始轮询
poll(); const startPolling = useCallback(() => {
}, [pollingInterval, clearPollingTimer, fetchData]); clearPollingTimer();
setIsPolling(true);
// 停止轮询
const stopPolling = useCallback(() => { const poll = () => {
clearPollingTimer(); pollingTimerRef.current = setTimeout(() => {
setIsPolling(false); fetchData({}, true).then(() => {
}, [clearPollingTimer]); if (pollingTimerRef.current) {
poll();
// 搜索参数变化时,自动刷新数据 }
// keyword 变化时,防抖500ms后刷新 });
useDebouncedEffect( }, pollingInterval);
() => { };
fetchData();
}, poll();
[searchParams], }, [pollingInterval, clearPollingTimer, fetchData]);
searchParams?.keyword ? 500 : 0
); // 停止轮询
const stopPolling = useCallback(() => {
// 组件卸载时清理轮询 clearPollingTimer();
useEffect(() => { setIsPolling(false);
if (autoRefresh) { }, [clearPollingTimer]);
startPolling();
} // 搜索参数变化时,自动刷新数据
return () => { // keyword 变化时,防抖500ms后刷新
clearPollingTimer(); useDebouncedEffect(
}; () => {
}, [clearPollingTimer]); fetchData();
},
return { [searchParams],
loading, searchParams?.keyword ? 500 : 0
tableData, );
pagination: {
...pagination, // 组件卸载时清理轮询
current: searchParams.current, useEffect(() => {
pageSize: searchParams.pageSize, if (autoRefresh) {
}, startPolling();
searchParams, }
setSearchParams, return () => {
setPagination, clearPollingTimer();
handleFiltersChange, };
handleKeywordChange, }, [autoRefresh, startPolling, clearPollingTimer]);
fetchData,
isPolling, return {
startPolling, loading,
stopPolling, tableData,
}; pagination: {
} ...pagination,
current: searchParams.current,
pageSize: searchParams.pageSize,
},
searchParams,
setSearchParams,
setPagination,
handleFiltersChange,
handleKeywordChange,
fetchData,
isPolling,
startPolling,
stopPolling,
};
}

View File

@@ -56,6 +56,31 @@ const TemplateList: React.FC = () => {
}, },
]; ];
const BUILT_IN_FLAG = {
TRUE: "true",
FALSE: "false",
} as const;
const mapTemplateFilters = (filters: Record<string, string[]>) => {
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 // Modals
const [isFormVisible, setIsFormVisible] = useState(false); const [isFormVisible, setIsFormVisible] = useState(false);
const [isDetailVisible, setIsDetailVisible] = useState(false); const [isDetailVisible, setIsDetailVisible] = useState(false);
@@ -71,7 +96,15 @@ const TemplateList: React.FC = () => {
fetchData, fetchData,
handleFiltersChange, handleFiltersChange,
handleKeywordChange, handleKeywordChange,
} = useFetchData(queryAnnotationTemplatesUsingGet, undefined, undefined, undefined, undefined, 0); } = useFetchData(
queryAnnotationTemplatesUsingGet,
undefined,
undefined,
undefined,
undefined,
0,
mapTemplateFilters
);
const handleCreate = () => { const handleCreate = () => {
setFormMode("create"); setFormMode("create");

View File

@@ -67,6 +67,7 @@ async def get_template(
async def list_template( async def list_template(
page: int = Query(1, ge=1, description="页码"), page: int = Query(1, ge=1, description="页码"),
size: int = Query(10, ge=1, le=100, description="每页大小"), size: int = Query(10, ge=1, le=100, description="每页大小"),
keyword: Optional[str] = Query(None, description="关键词"),
category: Optional[str] = Query(None, description="分类筛选"), category: Optional[str] = Query(None, description="分类筛选"),
dataType: Optional[str] = Query(None, alias="dataType", description="数据类型筛选"), dataType: Optional[str] = Query(None, alias="dataType", description="数据类型筛选"),
labelingType: Optional[str] = Query(None, alias="labelingType", description="标注类型筛选"), labelingType: Optional[str] = Query(None, alias="labelingType", description="标注类型筛选"),
@@ -78,6 +79,7 @@ async def list_template(
- **page**: 页码(从1开始) - **page**: 页码(从1开始)
- **size**: 每页大小(1-100) - **size**: 每页大小(1-100)
- **keyword**: 关键词(匹配名称/描述)
- **category**: 模板分类筛选 - **category**: 模板分类筛选
- **dataType**: 数据类型筛选 - **dataType**: 数据类型筛选
- **labelingType**: 标注类型筛选 - **labelingType**: 标注类型筛选
@@ -90,7 +92,8 @@ async def list_template(
category=category, category=category,
data_type=dataType, data_type=dataType,
labeling_type=labelingType, labeling_type=labelingType,
built_in=builtIn built_in=builtIn,
keyword=keyword
) )
return StandardResponse(code=200, message="success", data=templates) return StandardResponse(code=200, message="success", data=templates)

View File

@@ -3,7 +3,7 @@ Annotation Template Service
""" """
from typing import Optional, List from typing import Optional, List
from datetime import datetime from datetime import datetime
from sqlalchemy import select, func from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from uuid import uuid4 from uuid import uuid4
from fastapi import HTTPException from fastapi import HTTPException
@@ -185,7 +185,8 @@ class AnnotationTemplateService:
category: Optional[str] = None, category: Optional[str] = None,
data_type: Optional[str] = None, data_type: Optional[str] = None,
labeling_type: Optional[str] = None, labeling_type: Optional[str] = None,
built_in: Optional[bool] = None built_in: Optional[bool] = None,
keyword: Optional[str] = None
) -> AnnotationTemplateListResponse: ) -> AnnotationTemplateListResponse:
""" """
获取模板列表 获取模板列表
@@ -213,6 +214,14 @@ class AnnotationTemplateService:
conditions.append(AnnotationTemplate.labeling_type == labeling_type) # type: ignore conditions.append(AnnotationTemplate.labeling_type == labeling_type) # type: ignore
if built_in is not None: if built_in is not None:
conditions.append(AnnotationTemplate.built_in == built_in) # type: ignore 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( count_result = await db.execute(
@@ -273,13 +282,14 @@ class AnnotationTemplateService:
for field, value in update_data.items(): for field, value in update_data.items():
if field == 'configuration' and value is not None: if field == 'configuration' and value is not None:
# 验证配置JSON # 验证配置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) valid, error = LabelStudioConfigValidator.validate_configuration_json(config_dict)
if not valid: if not valid:
raise HTTPException(status_code=400, detail=f"Invalid configuration: {error}") raise HTTPException(status_code=400, detail=f"Invalid configuration: {error}")
# 重新生成Label Studio XML配置(用于验证) # 重新生成Label Studio XML配置(用于验证)
label_config = self.generate_label_studio_config(value) label_config = self.generate_label_studio_config(config)
# 验证生成的XML # 验证生成的XML
valid, error = LabelStudioConfigValidator.validate_xml(label_config) valid, error = LabelStudioConfigValidator.validate_xml(label_config)