发布 v1.10 #5
@@ -105,9 +105,22 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
|
||||
return Result.ok(pluginService.preparePluginsForCurrentUser(plugins, true, false));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按分类分页查询插件,并支持按名称模糊查询。
|
||||
*
|
||||
* @param request 当前请求
|
||||
* @param sortKey 排序字段
|
||||
* @param sortType 排序方向
|
||||
* @param pageNumber 页码
|
||||
* @param pageSize 每页数量
|
||||
* @param category 分类 ID,0 表示全部分类
|
||||
* @param name 插件名称关键字
|
||||
* @return 插件分页结果
|
||||
*/
|
||||
@GetMapping("/pageByCategory")
|
||||
@SaCheckPermission("/api/v1/plugin/query")
|
||||
public Result<Page<Plugin>> pageByCategory(HttpServletRequest request, String sortKey, String sortType, Long pageNumber, Long pageSize, int category) {
|
||||
public Result<Page<Plugin>> pageByCategory(HttpServletRequest request, String sortKey, String sortType,
|
||||
Long pageNumber, Long pageSize, int category, String name) {
|
||||
if (pageNumber == null || pageNumber < 1) {
|
||||
pageNumber = 1L;
|
||||
}
|
||||
@@ -120,7 +133,7 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
|
||||
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
|
||||
return Result.ok(queryPage(new Page<>(pageNumber, pageSize), queryWrapper));
|
||||
} else {
|
||||
Result<Page<Plugin>> result = pluginService.pageByCategory(pageNumber, pageSize, category);
|
||||
Result<Page<Plugin>> result = pluginService.pageByCategory(pageNumber, pageSize, category, name);
|
||||
if (result != null && result.getData() != null) {
|
||||
aiResourceCreatorNameSupport.fillPluginCreatorNames(result.getData().getRecords());
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package tech.easyflow.ai.service;
|
||||
|
||||
import com.mybatisflex.core.paginate.Page;
|
||||
import com.mybatisflex.core.service.IService;
|
||||
import tech.easyflow.ai.entity.Plugin;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
@@ -20,7 +21,16 @@ public interface PluginService extends IService<Plugin> {
|
||||
|
||||
List<Plugin> getList();
|
||||
|
||||
Result pageByCategory(Long pageNumber, Long pageSize, int category);
|
||||
/**
|
||||
* 按分类分页查询插件。
|
||||
*
|
||||
* @param pageNumber 页码
|
||||
* @param pageSize 每页数量
|
||||
* @param category 分类 ID
|
||||
* @param name 插件名称关键字
|
||||
* @return 插件分页结果
|
||||
*/
|
||||
Result<Page<Plugin>> pageByCategory(Long pageNumber, Long pageSize, int category, String name);
|
||||
|
||||
boolean updatePlugin(Plugin plugin);
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ public class PluginServiceImpl extends ServiceImpl<PluginMapper, Plugin> impleme
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<Page<Plugin>> pageByCategory(Long pageNumber, Long pageSize, int category) {
|
||||
public Result<Page<Plugin>> pageByCategory(Long pageNumber, Long pageSize, int category, String name) {
|
||||
RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess("PLUGIN");
|
||||
QueryWrapper queryWrapper = QueryWrapper.create().select(PluginCategoryMapping::getPluginId)
|
||||
.eq(PluginCategoryMapping::getCategoryId, category);
|
||||
@@ -158,7 +158,8 @@ public class PluginServiceImpl extends ServiceImpl<PluginMapper, Plugin> impleme
|
||||
return Result.ok(new Page<>(Collections.emptyList(), pageNumber, pageSize, 0L));
|
||||
}
|
||||
|
||||
List<Plugin> totalPlugins = preparePluginsForCurrentUser(queryPluginsByIds(visiblePluginIds), true, false);
|
||||
List<Plugin> totalPlugins = preparePluginsForCurrentUser(
|
||||
queryPluginsByIds(visiblePluginIds, name), true, false);
|
||||
int fromIndex = Math.max(0, Math.toIntExact((pageNumber - 1) * pageSize));
|
||||
if (fromIndex >= totalPlugins.size()) {
|
||||
return Result.ok(new Page<>(Collections.emptyList(), pageNumber, pageSize, totalPlugins.size()));
|
||||
@@ -251,11 +252,21 @@ public class PluginServiceImpl extends ServiceImpl<PluginMapper, Plugin> impleme
|
||||
return pluginMapper.selectListByQueryAs(creatorPluginWrapper, BigInteger.class);
|
||||
}
|
||||
|
||||
private List<Plugin> queryPluginsByIds(List<BigInteger> pluginIds) {
|
||||
/**
|
||||
* 按给定顺序查询插件,并按名称关键字过滤。
|
||||
*
|
||||
* @param pluginIds 插件 ID 列表
|
||||
* @param name 插件名称关键字
|
||||
* @return 保持输入 ID 顺序的插件列表
|
||||
*/
|
||||
private List<Plugin> queryPluginsByIds(List<BigInteger> pluginIds, String name) {
|
||||
if (CollectionUtil.isEmpty(pluginIds)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
QueryWrapper queryPluginWrapper = QueryWrapper.create().select().in(Plugin::getId, pluginIds);
|
||||
if (name != null && !name.isBlank()) {
|
||||
queryPluginWrapper.like(Plugin::getName, name.trim());
|
||||
}
|
||||
List<Plugin> plugins = pluginMapper.selectListWithRelationsByQuery(queryPluginWrapper);
|
||||
Map<BigInteger, Plugin> pluginMap = plugins.stream().collect(Collectors.toMap(
|
||||
Plugin::getId,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package tech.easyflow.ai.service.impl;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import tech.easyflow.ai.entity.Plugin;
|
||||
import tech.easyflow.ai.mapper.PluginCategoryMappingMapper;
|
||||
import tech.easyflow.ai.mapper.PluginMapper;
|
||||
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 插件分类分页查询测试。
|
||||
*/
|
||||
public class PluginServicePageQueryTest {
|
||||
|
||||
/**
|
||||
* 验证具体分类下仍会按插件名称过滤。
|
||||
*/
|
||||
@Test
|
||||
public void shouldFilterCategorizedPluginsByName() {
|
||||
PluginMapper pluginMapper = mock(PluginMapper.class);
|
||||
PluginCategoryMappingMapper mappingMapper = mock(PluginCategoryMappingMapper.class);
|
||||
CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class);
|
||||
RoleCategoryAccessSnapshot access = mock(RoleCategoryAccessSnapshot.class);
|
||||
PluginServiceImpl service = new PluginServiceImpl();
|
||||
service.pluginMapper = pluginMapper;
|
||||
service.pluginCategoryMappingMapper = mappingMapper;
|
||||
setField(service, "categoryPermissionService", categoryPermissionService);
|
||||
|
||||
when(categoryPermissionService.getCurrentAccess("PLUGIN")).thenReturn(access);
|
||||
when(access.isRestricted()).thenReturn(false);
|
||||
when(mappingMapper.selectListByQueryAs(any(QueryWrapper.class), eq(BigInteger.class)))
|
||||
.thenReturn(List.of(BigInteger.ONE));
|
||||
when(pluginMapper.selectListWithRelationsByQuery(any(QueryWrapper.class)))
|
||||
.thenReturn(Collections.emptyList());
|
||||
|
||||
service.pageByCategory(1L, 12L, 7, " 1213 ");
|
||||
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(pluginMapper).selectListWithRelationsByQuery(queryCaptor.capture());
|
||||
String sql = queryCaptor.getValue().toSQL().toLowerCase(Locale.ROOT);
|
||||
Assert.assertTrue(sql.contains("name"));
|
||||
Assert.assertTrue(sql.contains("like"));
|
||||
Assert.assertTrue(sql.contains("1213"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过反射设置私有依赖。
|
||||
*
|
||||
* @param target 目标对象
|
||||
* @param fieldName 字段名称
|
||||
* @param value 字段值
|
||||
*/
|
||||
private static void setField(Object target, String fieldName, Object value) {
|
||||
Class<?> current = target.getClass();
|
||||
while (current != null) {
|
||||
try {
|
||||
java.lang.reflect.Field field = current.getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
return;
|
||||
} catch (NoSuchFieldException ignored) {
|
||||
current = current.getSuperclass();
|
||||
} catch (IllegalAccessException e) {
|
||||
throw new IllegalStateException("设置测试字段失败: " + fieldName, e);
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("未找到字段: " + fieldName);
|
||||
}
|
||||
}
|
||||
@@ -47,12 +47,19 @@ describe('detail return navigation', () => {
|
||||
|
||||
it('returns plugin tool editing to its plugin tool list with a safe fallback', () => {
|
||||
const editSource = readViewSource('ai/plugin/PluginToolEdit.vue');
|
||||
const toolsSource = readViewSource('ai/plugin/PluginTools.vue');
|
||||
const pluginListSource = readViewSource('ai/plugin/Plugin.vue');
|
||||
const listSource = readViewSource('ai/plugin/PluginToolTable.vue');
|
||||
|
||||
expect(editSource).not.toMatch(/router\.(?:back|go)\s*\(/);
|
||||
expect(editSource).toContain("path: '/ai/plugin/tools'");
|
||||
expect(editSource).toContain("path: '/ai/plugin'");
|
||||
expect(editSource).toContain('buildPluginToolsRouteQueryFromEdit');
|
||||
expect(toolsSource).toContain('parsePluginToolsReturnState');
|
||||
expect(toolsSource).toContain('buildPluginListRouteQuery');
|
||||
expect(pluginListSource).toContain('buildPluginToolsReturnQuery');
|
||||
expect(listSource).toContain('pluginId: props.pluginId');
|
||||
expect(listSource).toContain('...route.query');
|
||||
});
|
||||
|
||||
it('returns execution steps to the filtered execution record list', () => {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { PluginListRouteState } from './plugin-route-state';
|
||||
|
||||
import type {
|
||||
ActionButton,
|
||||
CardPrimaryAction,
|
||||
} from '#/components/page/CardList.vue';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||
import { $t } from '@easyflow/locales';
|
||||
@@ -29,7 +31,16 @@ import PageData from '#/components/page/PageData.vue';
|
||||
import PageSide from '#/components/page/PageSide.vue';
|
||||
import AddPluginModal from '#/views/ai/plugin/AddPluginModal.vue';
|
||||
|
||||
import { buildPluginPageQueryParams } from './plugin-query';
|
||||
import {
|
||||
buildPluginToolsReturnQuery,
|
||||
mergePluginListRouteQuery,
|
||||
parsePluginListRouteState,
|
||||
} from './plugin-route-state';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const initialListState = parsePluginListRouteState(route.query);
|
||||
|
||||
interface PluginCategory {
|
||||
id: string;
|
||||
@@ -61,6 +72,7 @@ function openPluginTools(item: PluginRecord) {
|
||||
id: item.id,
|
||||
pageKey: '/ai/plugin',
|
||||
navTitle: resolveNavTitle(item),
|
||||
...buildPluginToolsReturnQuery(currentListState()),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -100,6 +112,15 @@ const pluginTypeTagMap = {
|
||||
2: $t('plugin.typeWorkflow'),
|
||||
};
|
||||
const categoryList = ref<PluginCategory[]>([]);
|
||||
const selectedCategoryId = ref(initialListState.categoryId);
|
||||
const searchKeyword = ref(initialListState.keyword.trim());
|
||||
const currentPageNumber = ref(initialListState.pageNumber);
|
||||
const currentPageSize = ref(initialListState.pageSize);
|
||||
const suppressInitialCategoryChange = ref(true);
|
||||
const initialQueryParams = buildPluginPageQueryParams(
|
||||
initialListState.categoryId,
|
||||
initialListState.keyword,
|
||||
);
|
||||
const controlBtns = [
|
||||
{
|
||||
icon: Edit,
|
||||
@@ -138,6 +159,17 @@ const getPluginCategoryList = async () => {
|
||||
{ id: '0', name: $t('common.allCategories') },
|
||||
...serverCategories,
|
||||
];
|
||||
if (
|
||||
selectedCategoryId.value !== '0' &&
|
||||
!serverCategories.some(
|
||||
(category) =>
|
||||
String(category.id) === String(selectedCategoryId.value),
|
||||
)
|
||||
) {
|
||||
suppressInitialCategoryChange.value = false;
|
||||
selectedCategoryId.value = '0';
|
||||
applyFilters();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -154,7 +186,7 @@ const handleDelete = (item: PluginRecord) => {
|
||||
api.post('/api/v1/plugin/plugin/remove', { id: item.id }).then((res) => {
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('message.deleteOkMessage'));
|
||||
pageDataRef.value.setQuery({});
|
||||
pageDataRef.value?.reload?.();
|
||||
}
|
||||
});
|
||||
})
|
||||
@@ -172,7 +204,6 @@ const headerButtons = [
|
||||
data: { action: 'add' },
|
||||
},
|
||||
];
|
||||
const pluginCategoryId = ref('0');
|
||||
const dialogVisible = ref(false); // 弹窗显隐
|
||||
const isEdit = ref(false); // 是否为编辑模式
|
||||
const formData = ref<CategoryFormData>({ name: '', id: '' });
|
||||
@@ -195,8 +226,48 @@ const handleButtonClick = (event: HeaderActionEvent, _item: unknown) => {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function applyFilters() {
|
||||
pageDataRef.value?.setQuery(
|
||||
buildPluginPageQueryParams(selectedCategoryId.value, searchKeyword.value),
|
||||
);
|
||||
}
|
||||
|
||||
function currentListState(): PluginListRouteState {
|
||||
const pageState = pageDataRef.value?.getPageState?.();
|
||||
return {
|
||||
categoryId: String(selectedCategoryId.value || '0'),
|
||||
keyword: searchKeyword.value,
|
||||
pageNumber: pageState?.pageNumber ?? currentPageNumber.value,
|
||||
pageSize: pageState?.pageSize ?? currentPageSize.value,
|
||||
};
|
||||
}
|
||||
|
||||
function syncListRouteState() {
|
||||
const target = {
|
||||
path: '/ai/plugin',
|
||||
query: mergePluginListRouteQuery(route.query, currentListState()),
|
||||
};
|
||||
if (router.resolve(target).fullPath !== route.fullPath) {
|
||||
void router.replace(target);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageStateChange(state: {
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
}) {
|
||||
currentPageNumber.value = state.pageNumber;
|
||||
currentPageSize.value = state.pageSize;
|
||||
syncListRouteState();
|
||||
}
|
||||
|
||||
const handleSearch = (params?: string) => {
|
||||
pageDataRef.value.setQuery({ title: params ?? '', isQueryOr: true });
|
||||
searchKeyword.value = params?.trim() ?? '';
|
||||
applyFilters();
|
||||
};
|
||||
const reloadCurrentList = () => {
|
||||
pageDataRef.value?.reload?.();
|
||||
};
|
||||
const handleEditCategory = (params: CategoryFormData) => {
|
||||
api
|
||||
@@ -230,7 +301,17 @@ const handleDeleteCategory = (params: PluginCategory) => {
|
||||
});
|
||||
};
|
||||
const handleClickCategory = (item: PluginCategory) => {
|
||||
pageDataRef.value.setQuery({ category: item.id });
|
||||
if (
|
||||
suppressInitialCategoryChange.value &&
|
||||
String(item.id) === initialListState.categoryId
|
||||
) {
|
||||
suppressInitialCategoryChange.value = false;
|
||||
selectedCategoryId.value = item.id;
|
||||
return;
|
||||
}
|
||||
suppressInitialCategoryChange.value = false;
|
||||
selectedCategoryId.value = item.id;
|
||||
applyFilters();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -239,6 +320,7 @@ const handleClickCategory = (item: PluginCategory) => {
|
||||
<div class="knowledge-header">
|
||||
<HeaderSearch
|
||||
:buttons="headerButtons"
|
||||
:initial-value="searchKeyword"
|
||||
:search-placeholder="$t('plugin.searchUsers')"
|
||||
@search="handleSearch"
|
||||
@button-click="handleButtonClick"
|
||||
@@ -252,7 +334,7 @@ const handleClickCategory = (item: PluginCategory) => {
|
||||
:menus="categoryList"
|
||||
:control-btns="controlBtns"
|
||||
:footer-button="footerButton"
|
||||
default-selected="0"
|
||||
:default-selected="selectedCategoryId"
|
||||
@change="handleClickCategory"
|
||||
/>
|
||||
</div>
|
||||
@@ -261,9 +343,11 @@ const handleClickCategory = (item: PluginCategory) => {
|
||||
<PageData
|
||||
ref="pageDataRef"
|
||||
page-url="/api/v1/plugin/pageByCategory"
|
||||
:page-size="12"
|
||||
:page-size="initialListState.pageSize"
|
||||
:page-sizes="[12, 24, 36, 48]"
|
||||
:extra-query-params="{ category: pluginCategoryId }"
|
||||
:initial-page-number="initialListState.pageNumber"
|
||||
:initial-query-params="initialQueryParams"
|
||||
@state-change="handlePageStateChange"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<CardPage
|
||||
@@ -294,7 +378,7 @@ const handleClickCategory = (item: PluginCategory) => {
|
||||
</PageData>
|
||||
</div>
|
||||
</div>
|
||||
<AddPluginModal ref="aiPluginModalRef" @reload="handleSearch" />
|
||||
<AddPluginModal ref="aiPluginModalRef" @reload="reloadCurrentList" />
|
||||
<EasyFlowFormModal
|
||||
:title="isEdit ? `${$t('button.edit')}` : `${$t('button.add')}`"
|
||||
v-model:open="dialogVisible"
|
||||
|
||||
@@ -23,6 +23,12 @@ import PluginInputAndOutParams from '#/views/ai/plugin/PluginInputAndOutParams.v
|
||||
import PluginRunTestModal from '#/views/ai/plugin/PluginRunTestModal.vue';
|
||||
import WorkflowApprovalSnapshotPreview from '#/views/system/approval/components/WorkflowApprovalSnapshotPreview.vue';
|
||||
|
||||
import {
|
||||
buildPluginListRouteQuery,
|
||||
buildPluginToolsRouteQueryFromEdit,
|
||||
parsePluginToolsReturnState,
|
||||
} from './plugin-route-state';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -168,11 +174,14 @@ function back() {
|
||||
if (pluginId) {
|
||||
void router.replace({
|
||||
path: '/ai/plugin/tools',
|
||||
query: { id: pluginId },
|
||||
query: buildPluginToolsRouteQueryFromEdit(route.query, pluginId),
|
||||
});
|
||||
return;
|
||||
}
|
||||
void router.replace({ path: '/ai/plugin' });
|
||||
void router.replace({
|
||||
path: '/ai/plugin',
|
||||
query: buildPluginListRouteQuery(parsePluginToolsReturnState(route.query)),
|
||||
});
|
||||
}
|
||||
|
||||
function updatePluginTool(index: number) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
@@ -20,6 +20,8 @@ import { api } from '#/api/request';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import AiPluginToolModal from '#/views/ai/plugin/AiPluginToolModal.vue';
|
||||
|
||||
import { buildPluginToolPageQueryParams } from './plugin-query';
|
||||
|
||||
const props = defineProps({
|
||||
pluginId: {
|
||||
required: true,
|
||||
@@ -29,21 +31,39 @@ const props = defineProps({
|
||||
default: 1,
|
||||
type: Number,
|
||||
},
|
||||
initialKeyword: {
|
||||
default: '',
|
||||
type: String,
|
||||
},
|
||||
initialPageNumber: {
|
||||
default: 1,
|
||||
type: Number,
|
||||
},
|
||||
initialPageSize: {
|
||||
default: 10,
|
||||
type: Number,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'stateChange',
|
||||
state: {
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
},
|
||||
): void;
|
||||
}>();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
defineExpose({
|
||||
openPluginToolModal() {
|
||||
aiPluginToolRef.value.openDialog();
|
||||
},
|
||||
reload: () => {
|
||||
pageDataRef.value.setQuery({ pluginId: props.pluginId });
|
||||
pageDataRef.value?.reload?.();
|
||||
},
|
||||
handleSearch: (params: string) => {
|
||||
pageDataRef.value.setQuery({
|
||||
pluginId: props.pluginId,
|
||||
isQueryOr: true,
|
||||
name: params,
|
||||
});
|
||||
pageDataRef.value.setQuery(buildPluginToolPageQueryParams(params));
|
||||
},
|
||||
});
|
||||
const pageDataRef = ref();
|
||||
@@ -51,6 +71,7 @@ const handleEdit = (row: any) => {
|
||||
router.push({
|
||||
path: '/ai/plugin/tool/edit',
|
||||
query: {
|
||||
...route.query,
|
||||
id: row.id,
|
||||
pageKey: '/ai/plugin',
|
||||
pluginId: props.pluginId,
|
||||
@@ -67,14 +88,20 @@ const handleDelete = (row: any) => {
|
||||
api.post('/api/v1/pluginItem/remove', { id: row.id }).then((res) => {
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('message.deleteOkMessage'));
|
||||
pageDataRef.value.setQuery({ pluginId: props.pluginId });
|
||||
pageDataRef.value?.reload?.();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
const aiPluginToolRef = ref();
|
||||
const pluginToolReload = () => {
|
||||
pageDataRef.value.setQuery({ pluginId: props.pluginId });
|
||||
pageDataRef.value?.reload?.();
|
||||
};
|
||||
const handlePageStateChange = (state: {
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
}) => {
|
||||
emit('stateChange', state);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -82,8 +109,11 @@ const pluginToolReload = () => {
|
||||
<PageData
|
||||
page-url="/api/v1/pluginItem/page"
|
||||
ref="pageDataRef"
|
||||
:page-size="10"
|
||||
:page-size="props.initialPageSize"
|
||||
:initial-page-number="props.initialPageNumber"
|
||||
:initial-query-params="buildPluginToolPageQueryParams(props.initialKeyword)"
|
||||
:extra-query-params="{ pluginId: props.pluginId }"
|
||||
@state-change="handlePageStateChange"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<ElTable :data="pageList" style="width: 100%" size="large">
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { PluginToolListRouteState } from './plugin-route-state';
|
||||
|
||||
import { computed, markRaw, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
@@ -10,12 +12,23 @@ import { api } from '#/api/request';
|
||||
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||||
import PluginToolTable from '#/views/ai/plugin/PluginToolTable.vue';
|
||||
|
||||
import {
|
||||
buildPluginListRouteQuery,
|
||||
mergePluginToolListRouteQuery,
|
||||
parsePluginToolListRouteState,
|
||||
parsePluginToolsReturnState,
|
||||
} from './plugin-route-state';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const initialToolListState = parsePluginToolListRouteState(route.query);
|
||||
|
||||
const pluginId = ref<string>((route.query.id as string) || '');
|
||||
const pluginInfo = ref<any>({});
|
||||
const pluginToolRef = ref();
|
||||
const toolSearchKeyword = ref(initialToolListState.keyword.trim());
|
||||
const currentToolPageNumber = ref(initialToolListState.pageNumber);
|
||||
const currentToolPageSize = ref(initialToolListState.pageSize);
|
||||
|
||||
const headerButtons = computed<any[]>(() => {
|
||||
const buttons: any[] = [
|
||||
@@ -52,14 +65,47 @@ async function loadPluginInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch(params: any) {
|
||||
pluginToolRef.value.handleSearch(params);
|
||||
function currentToolListState(): PluginToolListRouteState {
|
||||
return {
|
||||
keyword: toolSearchKeyword.value,
|
||||
pageNumber: currentToolPageNumber.value,
|
||||
pageSize: currentToolPageSize.value,
|
||||
};
|
||||
}
|
||||
|
||||
function syncToolListRouteState() {
|
||||
const target = {
|
||||
path: '/ai/plugin/tools',
|
||||
query: mergePluginToolListRouteQuery(route.query, currentToolListState()),
|
||||
};
|
||||
if (router.resolve(target).fullPath !== route.fullPath) {
|
||||
void router.replace(target);
|
||||
}
|
||||
}
|
||||
|
||||
function handleToolPageStateChange(state: {
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
}) {
|
||||
currentToolPageNumber.value = state.pageNumber;
|
||||
currentToolPageSize.value = state.pageSize;
|
||||
syncToolListRouteState();
|
||||
}
|
||||
|
||||
function handleSearch(params: string) {
|
||||
toolSearchKeyword.value = params.trim();
|
||||
pluginToolRef.value.handleSearch(toolSearchKeyword.value);
|
||||
}
|
||||
|
||||
function handleButtonClick(event: any) {
|
||||
switch (event.key) {
|
||||
case 'back': {
|
||||
router.push({ path: '/ai/plugin' });
|
||||
void router.replace({
|
||||
path: '/ai/plugin',
|
||||
query: buildPluginListRouteQuery(
|
||||
parsePluginToolsReturnState(route.query),
|
||||
),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'createTool': {
|
||||
@@ -74,6 +120,7 @@ function handleButtonClick(event: any) {
|
||||
<div class="flex h-full flex-col gap-6 p-6">
|
||||
<HeaderSearch
|
||||
:buttons="headerButtons"
|
||||
:initial-value="toolSearchKeyword"
|
||||
@search="handleSearch"
|
||||
@button-click="handleButtonClick"
|
||||
/>
|
||||
@@ -83,6 +130,10 @@ function handleButtonClick(event: any) {
|
||||
ref="pluginToolRef"
|
||||
:plugin-id="pluginId"
|
||||
:plugin-type="pluginInfo.type"
|
||||
:initial-keyword="initialToolListState.keyword"
|
||||
:initial-page-number="initialToolListState.pageNumber"
|
||||
:initial-page-size="initialToolListState.pageSize"
|
||||
@state-change="handleToolPageStateChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildPluginPageQueryParams,
|
||||
buildPluginToolPageQueryParams,
|
||||
} from './plugin-query';
|
||||
|
||||
describe('plugin query params', () => {
|
||||
it('uses the persisted name field and an explicit like operator', () => {
|
||||
expect(buildPluginPageQueryParams('7', ' 1213 ')).toEqual({
|
||||
category: '7',
|
||||
name: '1213',
|
||||
name__op: 'like',
|
||||
});
|
||||
});
|
||||
|
||||
it('retains category while clearing the keyword', () => {
|
||||
expect(buildPluginPageQueryParams('7', ' ')).toEqual({
|
||||
category: '7',
|
||||
name: undefined,
|
||||
name__op: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses contains matching for plugin tool names', () => {
|
||||
expect(buildPluginToolPageQueryParams(' HTTP ')).toEqual({
|
||||
name: 'HTTP',
|
||||
name__op: 'like',
|
||||
});
|
||||
});
|
||||
});
|
||||
22
easyflow-ui-admin/app/src/views/ai/plugin/plugin-query.ts
Normal file
22
easyflow-ui-admin/app/src/views/ai/plugin/plugin-query.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
function normalizeKeyword(keyword?: string): string {
|
||||
return keyword?.trim() || '';
|
||||
}
|
||||
|
||||
function buildPluginPageQueryParams(categoryId: string, keyword?: string) {
|
||||
const name = normalizeKeyword(keyword);
|
||||
return {
|
||||
category: categoryId || '0',
|
||||
name: name || undefined,
|
||||
name__op: name ? 'like' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPluginToolPageQueryParams(keyword?: string) {
|
||||
const name = normalizeKeyword(keyword);
|
||||
return {
|
||||
name: name || undefined,
|
||||
name__op: name ? 'like' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export { buildPluginPageQueryParams, buildPluginToolPageQueryParams };
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildPluginListRouteQuery,
|
||||
buildPluginToolsReturnQuery,
|
||||
buildPluginToolsRouteQueryFromEdit,
|
||||
mergePluginListRouteQuery,
|
||||
mergePluginToolListRouteQuery,
|
||||
parsePluginListRouteState,
|
||||
parsePluginToolListRouteState,
|
||||
parsePluginToolsReturnState,
|
||||
} from './plugin-route-state';
|
||||
|
||||
describe('plugin route state', () => {
|
||||
it('restores plugin list page, category and keyword', () => {
|
||||
expect(
|
||||
parsePluginListRouteState({
|
||||
categoryId: '7',
|
||||
keyword: '天气',
|
||||
pageNumber: '3',
|
||||
pageSize: '24',
|
||||
}),
|
||||
).toEqual({
|
||||
categoryId: '7',
|
||||
keyword: '天气',
|
||||
pageNumber: 3,
|
||||
pageSize: 24,
|
||||
});
|
||||
});
|
||||
|
||||
it('round trips the plugin list state through plugin tools', () => {
|
||||
const state = {
|
||||
categoryId: '7',
|
||||
keyword: '天气',
|
||||
pageNumber: 3,
|
||||
pageSize: 24,
|
||||
};
|
||||
const returnQuery = buildPluginToolsReturnQuery(state);
|
||||
|
||||
expect(returnQuery).toEqual({
|
||||
returnCategoryId: '7',
|
||||
returnKeyword: '天气',
|
||||
returnPageNumber: '3',
|
||||
returnPageSize: '24',
|
||||
});
|
||||
expect(parsePluginToolsReturnState(returnQuery)).toEqual(state);
|
||||
expect(buildPluginListRouteQuery(state)).toEqual({
|
||||
categoryId: '7',
|
||||
keyword: '天气',
|
||||
pageNumber: '3',
|
||||
pageSize: '24',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back safely for invalid plugin and tool pagination', () => {
|
||||
expect(
|
||||
parsePluginListRouteState({
|
||||
pageNumber: '-1',
|
||||
pageSize: '999',
|
||||
}),
|
||||
).toEqual({
|
||||
categoryId: '0',
|
||||
keyword: '',
|
||||
pageNumber: 1,
|
||||
pageSize: 12,
|
||||
});
|
||||
expect(
|
||||
parsePluginToolListRouteState({
|
||||
toolPageNumber: '0',
|
||||
toolPageSize: '999',
|
||||
}),
|
||||
).toEqual({
|
||||
keyword: '',
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves unrelated route query while updating list state', () => {
|
||||
expect(
|
||||
mergePluginListRouteQuery(
|
||||
{
|
||||
devLogin: 'admin',
|
||||
keyword: '旧关键字',
|
||||
pageNumber: '4',
|
||||
},
|
||||
{
|
||||
categoryId: '0',
|
||||
keyword: '',
|
||||
pageNumber: 1,
|
||||
pageSize: 12,
|
||||
},
|
||||
),
|
||||
).toEqual({ devLogin: 'admin' });
|
||||
});
|
||||
|
||||
it('keeps plugin return context when tool list state changes', () => {
|
||||
expect(
|
||||
mergePluginToolListRouteQuery(
|
||||
{
|
||||
id: '88',
|
||||
returnCategoryId: '7',
|
||||
returnPageNumber: '3',
|
||||
toolKeyword: '旧工具',
|
||||
},
|
||||
{
|
||||
keyword: '查询工具',
|
||||
pageNumber: 2,
|
||||
pageSize: 20,
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
id: '88',
|
||||
returnCategoryId: '7',
|
||||
returnPageNumber: '3',
|
||||
toolKeyword: '查询工具',
|
||||
toolPageNumber: '2',
|
||||
toolPageSize: '20',
|
||||
});
|
||||
});
|
||||
|
||||
it('restores plugin tools query after editing a tool', () => {
|
||||
expect(
|
||||
buildPluginToolsRouteQueryFromEdit(
|
||||
{
|
||||
id: 'tool-1',
|
||||
pluginId: 'plugin-1',
|
||||
returnCategoryId: '7',
|
||||
toolPageNumber: '2',
|
||||
},
|
||||
'plugin-1',
|
||||
),
|
||||
).toEqual({
|
||||
id: 'plugin-1',
|
||||
returnCategoryId: '7',
|
||||
toolPageNumber: '2',
|
||||
});
|
||||
});
|
||||
});
|
||||
197
easyflow-ui-admin/app/src/views/ai/plugin/plugin-route-state.ts
Normal file
197
easyflow-ui-admin/app/src/views/ai/plugin/plugin-route-state.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type { LocationQuery } from 'vue-router';
|
||||
|
||||
const DEFAULT_PLUGIN_PAGE_NUMBER = 1;
|
||||
const DEFAULT_PLUGIN_PAGE_SIZE = 12;
|
||||
const DEFAULT_PLUGIN_CATEGORY_ID = '0';
|
||||
const PLUGIN_PAGE_SIZES = new Set([12, 24, 36, 48]);
|
||||
|
||||
const DEFAULT_TOOL_PAGE_NUMBER = 1;
|
||||
const DEFAULT_TOOL_PAGE_SIZE = 10;
|
||||
const TOOL_PAGE_SIZES = new Set([10, 20, 50, 100]);
|
||||
|
||||
const PLUGIN_LIST_QUERY_KEYS = {
|
||||
categoryId: 'categoryId',
|
||||
keyword: 'keyword',
|
||||
pageNumber: 'pageNumber',
|
||||
pageSize: 'pageSize',
|
||||
} as const;
|
||||
|
||||
const PLUGIN_RETURN_QUERY_KEYS = {
|
||||
categoryId: 'returnCategoryId',
|
||||
keyword: 'returnKeyword',
|
||||
pageNumber: 'returnPageNumber',
|
||||
pageSize: 'returnPageSize',
|
||||
} as const;
|
||||
|
||||
const TOOL_LIST_QUERY_KEYS = {
|
||||
keyword: 'toolKeyword',
|
||||
pageNumber: 'toolPageNumber',
|
||||
pageSize: 'toolPageSize',
|
||||
} as const;
|
||||
|
||||
interface PluginListRouteState {
|
||||
categoryId: string;
|
||||
keyword: string;
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
interface PluginToolListRouteState {
|
||||
keyword: string;
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
function readQueryValue(query: LocationQuery, key: string): string {
|
||||
const value = query[key];
|
||||
const normalized = Array.isArray(value) ? value[0] : value;
|
||||
return normalized === null || normalized === undefined
|
||||
? ''
|
||||
: String(normalized);
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string, fallback: number): number {
|
||||
if (!/^\d+$/.test(value)) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function parsePluginState(
|
||||
query: LocationQuery,
|
||||
keys: typeof PLUGIN_LIST_QUERY_KEYS | typeof PLUGIN_RETURN_QUERY_KEYS,
|
||||
): PluginListRouteState {
|
||||
const pageSize = parsePositiveInteger(
|
||||
readQueryValue(query, keys.pageSize),
|
||||
DEFAULT_PLUGIN_PAGE_SIZE,
|
||||
);
|
||||
return {
|
||||
categoryId:
|
||||
readQueryValue(query, keys.categoryId) || DEFAULT_PLUGIN_CATEGORY_ID,
|
||||
keyword: readQueryValue(query, keys.keyword),
|
||||
pageNumber: parsePositiveInteger(
|
||||
readQueryValue(query, keys.pageNumber),
|
||||
DEFAULT_PLUGIN_PAGE_NUMBER,
|
||||
),
|
||||
pageSize: PLUGIN_PAGE_SIZES.has(pageSize)
|
||||
? pageSize
|
||||
: DEFAULT_PLUGIN_PAGE_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPluginStateQuery(
|
||||
state: PluginListRouteState,
|
||||
keys: typeof PLUGIN_LIST_QUERY_KEYS | typeof PLUGIN_RETURN_QUERY_KEYS,
|
||||
): LocationQuery {
|
||||
return {
|
||||
...(state.pageNumber === DEFAULT_PLUGIN_PAGE_NUMBER
|
||||
? {}
|
||||
: { [keys.pageNumber]: String(state.pageNumber) }),
|
||||
...(state.pageSize === DEFAULT_PLUGIN_PAGE_SIZE
|
||||
? {}
|
||||
: { [keys.pageSize]: String(state.pageSize) }),
|
||||
...(state.categoryId === DEFAULT_PLUGIN_CATEGORY_ID
|
||||
? {}
|
||||
: { [keys.categoryId]: state.categoryId }),
|
||||
...(state.keyword ? { [keys.keyword]: state.keyword } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePluginListRouteState(query: LocationQuery): PluginListRouteState {
|
||||
return parsePluginState(query, PLUGIN_LIST_QUERY_KEYS);
|
||||
}
|
||||
|
||||
function parsePluginToolsReturnState(
|
||||
query: LocationQuery,
|
||||
): PluginListRouteState {
|
||||
return parsePluginState(query, PLUGIN_RETURN_QUERY_KEYS);
|
||||
}
|
||||
|
||||
function buildPluginListRouteQuery(state: PluginListRouteState): LocationQuery {
|
||||
return buildPluginStateQuery(state, PLUGIN_LIST_QUERY_KEYS);
|
||||
}
|
||||
|
||||
function buildPluginToolsReturnQuery(
|
||||
state: PluginListRouteState,
|
||||
): LocationQuery {
|
||||
return buildPluginStateQuery(state, PLUGIN_RETURN_QUERY_KEYS);
|
||||
}
|
||||
|
||||
function mergePluginListRouteQuery(
|
||||
query: LocationQuery,
|
||||
state: PluginListRouteState,
|
||||
): LocationQuery {
|
||||
const listKeys = new Set<string>(Object.values(PLUGIN_LIST_QUERY_KEYS));
|
||||
return {
|
||||
...Object.fromEntries(
|
||||
Object.entries(query).filter(([key]) => !listKeys.has(key)),
|
||||
),
|
||||
...buildPluginListRouteQuery(state),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePluginToolListRouteState(
|
||||
query: LocationQuery,
|
||||
): PluginToolListRouteState {
|
||||
const pageSize = parsePositiveInteger(
|
||||
readQueryValue(query, TOOL_LIST_QUERY_KEYS.pageSize),
|
||||
DEFAULT_TOOL_PAGE_SIZE,
|
||||
);
|
||||
return {
|
||||
keyword: readQueryValue(query, TOOL_LIST_QUERY_KEYS.keyword),
|
||||
pageNumber: parsePositiveInteger(
|
||||
readQueryValue(query, TOOL_LIST_QUERY_KEYS.pageNumber),
|
||||
DEFAULT_TOOL_PAGE_NUMBER,
|
||||
),
|
||||
pageSize: TOOL_PAGE_SIZES.has(pageSize) ? pageSize : DEFAULT_TOOL_PAGE_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPluginToolListRouteQuery(
|
||||
state: PluginToolListRouteState,
|
||||
): LocationQuery {
|
||||
return {
|
||||
...(state.pageNumber === DEFAULT_TOOL_PAGE_NUMBER
|
||||
? {}
|
||||
: { [TOOL_LIST_QUERY_KEYS.pageNumber]: String(state.pageNumber) }),
|
||||
...(state.pageSize === DEFAULT_TOOL_PAGE_SIZE
|
||||
? {}
|
||||
: { [TOOL_LIST_QUERY_KEYS.pageSize]: String(state.pageSize) }),
|
||||
...(state.keyword ? { [TOOL_LIST_QUERY_KEYS.keyword]: state.keyword } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mergePluginToolListRouteQuery(
|
||||
query: LocationQuery,
|
||||
state: PluginToolListRouteState,
|
||||
): LocationQuery {
|
||||
const listKeys = new Set<string>(Object.values(TOOL_LIST_QUERY_KEYS));
|
||||
return {
|
||||
...Object.fromEntries(
|
||||
Object.entries(query).filter(([key]) => !listKeys.has(key)),
|
||||
),
|
||||
...buildPluginToolListRouteQuery(state),
|
||||
};
|
||||
}
|
||||
|
||||
function buildPluginToolsRouteQueryFromEdit(
|
||||
query: LocationQuery,
|
||||
pluginId: string,
|
||||
): LocationQuery {
|
||||
const nextQuery: LocationQuery = { ...query, id: pluginId };
|
||||
delete nextQuery.pluginId;
|
||||
return nextQuery;
|
||||
}
|
||||
|
||||
export {
|
||||
buildPluginListRouteQuery,
|
||||
buildPluginToolsReturnQuery,
|
||||
buildPluginToolsRouteQueryFromEdit,
|
||||
mergePluginListRouteQuery,
|
||||
mergePluginToolListRouteQuery,
|
||||
parsePluginListRouteState,
|
||||
parsePluginToolListRouteState,
|
||||
parsePluginToolsReturnState,
|
||||
};
|
||||
export type { PluginListRouteState, PluginToolListRouteState };
|
||||
Reference in New Issue
Block a user