fix: 修复插件分类查询并保留返回状态

- 统一按插件名称进行模糊查询并支持分类筛选

- 恢复插件列表与工具编辑的页码、分类和搜索条件
This commit is contained in:
2026-07-31 09:54:45 +08:00
parent c2ed5a24a3
commit 1cbee6b018
13 changed files with 717 additions and 30 deletions

View File

@@ -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 分类 ID0 表示全部分类
* @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());
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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', () => {

View File

@@ -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"

View File

@@ -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) {

View File

@@ -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">

View File

@@ -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>

View File

@@ -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',
});
});
});

View 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 };

View File

@@ -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',
});
});
});

View 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 };