feat: 统一恢复管理端列表上下文

- 统一列表路由状态、分页初始化与安全返回契约

- 接入知识库、工作流、插件、审批、Skill、Agent、Bot、反馈和定时任务链路

- 补齐公共能力与关键返回路径自动化测试
This commit is contained in:
2026-08-10 16:12:43 +08:00
parent 9d2fa39a2d
commit 6bfd440214
42 changed files with 2359 additions and 570 deletions

View File

@@ -14,6 +14,7 @@ import { ElButton, ElMessage, ElMessageBox } from 'element-plus';
import { tryit } from 'radash';
import { api } from '#/api/request';
import { navigateBackToList } from '#/router/list-return-context';
import {
canAiResourceOffline,
canAiResourcePublish,
@@ -291,8 +292,7 @@ async function loadMcpToolsForOption(id: number | string) {
String(item.value) === key
? {
...item,
label:
currentOption.label || 'MCP',
label: currentOption.label || 'MCP',
raw: mergedResource,
}
: item,
@@ -481,8 +481,13 @@ function handleCloseTryout() {
selectBase();
}
function handleBack() {
router.push(AGENT_TAB_PAGE_KEY);
async function handleBack() {
await navigateBackToList(
router,
route.query,
[AGENT_TAB_PAGE_KEY],
AGENT_TAB_PAGE_KEY,
);
}
</script>

View File

@@ -6,10 +6,9 @@ import type {
ActionButton,
CardPrimaryAction,
} from '#/components/page/CardList.vue';
import CardList from '#/components/page/CardList.vue';
import { computed, markRaw, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { useAccess } from '@easyflow/access';
import { defaultAssistantAvatar } from '@easyflow/common-ui';
@@ -27,9 +26,17 @@ import { ElIcon, ElMessage, ElMessageBox, ElPopover } from 'element-plus';
import { tryit } from 'radash';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import CardList from '#/components/page/CardList.vue';
import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue';
import {
buildListRouteFullPath,
mergeListRouteStateQuery,
parseListRouteState,
watchListRouteState,
} from '#/composables/useListRouteState';
import { $t } from '#/locales';
import { withListReturnTo } from '#/router/list-return-context';
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
import {
canAiResourceDelete,
@@ -40,6 +47,7 @@ import {
resolveAiResourceDisplayStatus,
} from '#/views/ai/shared/publish-status';
import { agentListRouteSchema } from './agent-list-route-state';
import {
getAgentCategories,
submitAgentDeleteApproval,
@@ -48,9 +56,35 @@ import {
updateAgentVisibilityScope,
} from './api';
const route = useRoute();
const router = useRouter();
const initialListState = parseListRouteState(route.query, agentListRouteSchema);
const pageDataRef = ref();
const sideList = ref<any[]>([]);
const selectedCategoryId = ref(initialListState.categoryId);
const searchKeyword = ref(initialListState.keyword);
const currentPageNumber = ref(initialListState.pageNumber);
const currentPageSize = ref(initialListState.pageSize);
let initialCategoryChangePending = Boolean(initialListState.categoryId);
watchListRouteState(route, agentListRouteSchema, (state) => {
if (state.categoryId !== selectedCategoryId.value) {
initialCategoryChangePending = Boolean(state.categoryId);
}
selectedCategoryId.value = state.categoryId;
searchKeyword.value = state.keyword;
currentPageNumber.value = state.pageNumber;
currentPageSize.value = state.pageSize;
pageDataRef.value?.restoreState?.({
pageNumber: state.pageNumber,
pageSize: state.pageSize,
queryParams: {
categoryId: state.categoryId || undefined,
description: state.keyword || undefined,
isQueryOr: true,
name: state.keyword || undefined,
},
});
});
const AGENT_TAB_PAGE_KEY = '/ai/agents';
const DEFAULT_AGENT_TITLE = '未命名智能体';
type VisibilityScope = 'DEPT' | 'PRIVATE' | 'PUBLIC';
@@ -104,6 +138,7 @@ const primaryAction: CardPrimaryAction = {
query: {
pageKey: AGENT_TAB_PAGE_KEY,
navTitle: resolveNavTitle(row),
...withListReturnTo(currentListFullPath()),
},
});
},
@@ -149,10 +184,12 @@ onMounted(() => {
});
function handleSearch(keyword: string) {
searchKeyword.value = keyword;
pageDataRef.value?.setQuery({
categoryId: selectedCategoryId.value || undefined,
isQueryOr: true,
name: keyword,
description: keyword,
name: keyword || undefined,
description: keyword || undefined,
});
}
@@ -163,6 +200,7 @@ function handleButtonClick(payload: any) {
query: {
pageKey: AGENT_TAB_PAGE_KEY,
navTitle: DEFAULT_AGENT_TITLE,
...withListReturnTo(currentListFullPath()),
},
});
}
@@ -221,7 +259,19 @@ async function updateVisibilityScope(
}
function changeCategory(category: any) {
pageDataRef.value?.setQuery({ categoryId: category.id });
const categoryId = String(category?.id || '');
if (initialCategoryChangePending && categoryId === selectedCategoryId.value) {
initialCategoryChangePending = false;
return;
}
initialCategoryChangePending = false;
selectedCategoryId.value = categoryId;
pageDataRef.value?.setQuery({
categoryId: categoryId || undefined,
isQueryOr: true,
name: searchKeyword.value || undefined,
description: searchKeyword.value || undefined,
});
}
async function loadCategories() {
@@ -231,9 +281,57 @@ async function loadCategories() {
{ id: '', categoryName: $t('common.allCategories') },
...(res.data || []),
];
if (
selectedCategoryId.value &&
!sideList.value.some(
(item) => String(item.id) === selectedCategoryId.value,
)
) {
selectedCategoryId.value = '';
initialCategoryChangePending = false;
pageDataRef.value?.setQuery?.({
isQueryOr: true,
name: searchKeyword.value || undefined,
description: searchKeyword.value || undefined,
});
}
}
}
function currentListFullPath() {
return buildListRouteFullPath(
router,
route.query,
{
categoryId: selectedCategoryId.value,
keyword: searchKeyword.value,
pageNumber: currentPageNumber.value,
pageSize: currentPageSize.value,
},
agentListRouteSchema,
);
}
function handlePageStateChange(state: {
pageNumber: number;
pageSize: number;
}) {
currentPageNumber.value = state.pageNumber;
currentPageSize.value = state.pageSize;
const query = mergeListRouteStateQuery(
route.query,
{
categoryId: selectedCategoryId.value,
keyword: searchKeyword.value,
pageNumber: state.pageNumber,
pageSize: state.pageSize,
},
agentListRouteSchema,
);
const target = router.resolve({ path: agentListRouteSchema.path, query });
if (target.fullPath !== route.fullPath) void router.replace(target);
}
function resolvePublishStatusMeta(
displayPublishStatus?: string,
publishStatus?: string,
@@ -333,6 +431,7 @@ async function handleDeleteAction(row: AgentInfo) {
<div class="agent-list-page">
<HeaderSearch
:buttons="headerButtons"
:initial-value="searchKeyword"
@search="handleSearch"
@button-click="handleButtonClick"
/>
@@ -341,6 +440,7 @@ async function handleDeleteAction(row: AgentInfo) {
label-key="categoryName"
value-key="id"
:menus="sideList"
:default-selected="selectedCategoryId"
@change="changeCategory"
/>
<div class="agent-list-page__content">
@@ -348,7 +448,15 @@ async function handleDeleteAction(row: AgentInfo) {
ref="pageDataRef"
page-url="/api/v1/agent/page"
:page-sizes="[12, 18, 24]"
:page-size="12"
:page-size="initialListState.pageSize"
:initial-page-number="initialListState.pageNumber"
:initial-query-params="{
categoryId: initialListState.categoryId || undefined,
isQueryOr: true,
name: initialListState.keyword || undefined,
description: initialListState.keyword || undefined,
}"
@state-change="handlePageStateChange"
>
<template #default="{ pageList }">
<CardList
@@ -366,13 +474,12 @@ async function handleDeleteAction(row: AgentInfo) {
<template #publish>
<div
class="agent-publish-chip"
:class="
'agent-publish-chip--' +
:class="`agent-publish-chip--${
resolvePublishStatusMeta(
item.displayPublishStatus,
item.publishStatus,
).tone
"
}`"
>
<span class="agent-publish-chip__dot"></span>
<span>{{

View File

@@ -0,0 +1,28 @@
import {
defineListRouteStateSchema,
positiveIntegerListRouteField,
stringListRouteField,
} from '#/composables/useListRouteState';
interface AgentListRouteState {
categoryId: string;
keyword: string;
pageNumber: number;
pageSize: number;
}
const agentListRouteSchema = defineListRouteStateSchema<AgentListRouteState>({
path: '/ai/agents',
fields: {
categoryId: stringListRouteField(),
keyword: stringListRouteField(),
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
pageSize: positiveIntegerListRouteField({
allowedValues: [12, 18, 24],
defaultValue: 12,
}),
},
});
export { agentListRouteSchema };
export type { AgentListRouteState };

View File

@@ -0,0 +1,28 @@
import {
defineListRouteStateSchema,
positiveIntegerListRouteField,
stringListRouteField,
} from '#/composables/useListRouteState';
interface BotListRouteState {
categoryId: string;
keyword: string;
pageNumber: number;
pageSize: number;
}
const botListRouteSchema = defineListRouteStateSchema<BotListRouteState>({
path: '/ai/bots',
fields: {
categoryId: stringListRouteField(),
keyword: stringListRouteField(),
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
pageSize: positiveIntegerListRouteField({
allowedValues: [12, 18, 24],
defaultValue: 12,
}),
},
});
export { botListRouteSchema };
export type { BotListRouteState };

View File

@@ -9,7 +9,7 @@ import type {
} from '#/components/page/CardList.vue';
import { computed, markRaw, 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';
@@ -37,6 +37,13 @@ import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import CardList from '#/components/page/CardList.vue';
import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue';
import {
buildListRouteFullPath,
mergeListRouteStateQuery,
parseListRouteState,
watchListRouteState,
} from '#/composables/useListRouteState';
import { withListReturnTo } from '#/router/list-return-context';
import {
confirmPublishSubmission,
} from '#/views/ai/shared/approval-application-reason';
@@ -50,6 +57,7 @@ import {
} from '#/views/ai/shared/publish-status';
import { useDictStore } from '#/store';
import { botListRouteSchema } from './bot-list-route-state';
import Modal from './modal.vue';
interface FieldDefinition {
@@ -70,8 +78,33 @@ onMounted(() => {
getSideList();
});
const route = useRoute();
const router = useRouter();
const initialListState = parseListRouteState(route.query, botListRouteSchema);
const pageDataRef = ref();
const selectedCategoryId = ref(initialListState.categoryId);
const searchKeyword = ref(initialListState.keyword);
const currentPageNumber = ref(initialListState.pageNumber);
const currentPageSize = ref(initialListState.pageSize);
let initialCategoryChangePending = Boolean(initialListState.categoryId);
watchListRouteState(route, botListRouteSchema, (state) => {
if (state.categoryId !== selectedCategoryId.value) {
initialCategoryChangePending = Boolean(state.categoryId);
}
selectedCategoryId.value = state.categoryId;
searchKeyword.value = state.keyword;
currentPageNumber.value = state.pageNumber;
currentPageSize.value = state.pageSize;
pageDataRef.value?.restoreState?.({
pageNumber: state.pageNumber,
pageSize: state.pageSize,
queryParams: {
categoryId: state.categoryId || undefined,
isQueryOr: true,
title: state.keyword || undefined,
},
});
});
const modalRef = ref<InstanceType<typeof Modal>>();
const dictStore = useDictStore();
@@ -98,6 +131,7 @@ const primaryAction: CardPrimaryAction = {
query: {
pageKey: '/ai/bots',
navTitle: resolveNavTitle(row),
...withListReturnTo(currentListFullPath()),
},
});
},
@@ -273,7 +307,12 @@ function resolvePublishStatusMetaByInstance(
}
const handleSearch = (params: string) => {
pageDataRef.value.setQuery({ title: params, isQueryOr: true });
searchKeyword.value = params;
pageDataRef.value.setQuery({
categoryId: selectedCategoryId.value || undefined,
title: params || undefined,
isQueryOr: true,
});
};
const handleButtonClick = () => {
modalRef.value?.open('create');
@@ -347,7 +386,18 @@ function initDict() {
dictStore.fetchDictionary('dataStatus');
}
function changeCategory(category: any) {
pageDataRef.value.setQuery({ categoryId: category.id });
const categoryId = String(category?.id || '');
if (initialCategoryChangePending && categoryId === selectedCategoryId.value) {
initialCategoryChangePending = false;
return;
}
initialCategoryChangePending = false;
selectedCategoryId.value = categoryId;
pageDataRef.value.setQuery({
categoryId: categoryId || undefined,
title: searchKeyword.value || undefined,
isQueryOr: true,
});
}
function showControlDialog(item: any) {
formRef.value?.resetFields();
@@ -412,14 +462,60 @@ const getSideList = async () => {
},
...res.data,
];
if (
selectedCategoryId.value &&
!sideList.value.some(
(category) => String(category.id) === selectedCategoryId.value,
)
) {
selectedCategoryId.value = '';
initialCategoryChangePending = false;
pageDataRef.value?.setQuery?.({
title: searchKeyword.value || undefined,
isQueryOr: true,
});
}
}
};
function currentListFullPath() {
return buildListRouteFullPath(
router,
route.query,
{
categoryId: selectedCategoryId.value,
keyword: searchKeyword.value,
pageNumber: currentPageNumber.value,
pageSize: currentPageSize.value,
},
botListRouteSchema,
);
}
function handlePageStateChange(state: {
pageNumber: number;
pageSize: number;
}) {
currentPageNumber.value = state.pageNumber;
currentPageSize.value = state.pageSize;
const query = mergeListRouteStateQuery(
route.query,
{
categoryId: selectedCategoryId.value,
keyword: searchKeyword.value,
pageNumber: state.pageNumber,
pageSize: state.pageSize,
},
botListRouteSchema,
);
const target = router.resolve({ path: botListRouteSchema.path, query });
if (target.fullPath !== route.fullPath) void router.replace(target);
}
</script>
<template>
<div class="flex h-full flex-col gap-6 p-6">
<HeaderSearch
:buttons="headerButtons"
:initial-value="searchKeyword"
@search="handleSearch"
@button-click="handleButtonClick"
/>
@@ -430,6 +526,7 @@ const getSideList = async () => {
:menus="sideList"
:control-btns="controlBtns"
:footer-button="footerButton"
:default-selected="selectedCategoryId"
@change="changeCategory"
/>
<div class="h-[calc(100vh-192px)] flex-1 overflow-auto">
@@ -437,7 +534,14 @@ const getSideList = async () => {
ref="pageDataRef"
page-url="/api/v1/bot/page"
:page-sizes="[12, 18, 24]"
:page-size="12"
:page-size="initialListState.pageSize"
:initial-page-number="initialListState.pageNumber"
:initial-query-params="{
categoryId: initialListState.categoryId || undefined,
title: initialListState.keyword || undefined,
isQueryOr: true,
}"
@state-change="handlePageStateChange"
>
<template #default="{ pageList }">
<CardList
@@ -462,7 +566,7 @@ const getSideList = async () => {
</div>
</div>
<!-- 创建&编辑Bot弹窗 -->
<Modal ref="modalRef" @success="pageDataRef.setQuery({})" />
<Modal ref="modalRef" @success="pageDataRef.reload()" />
<EasyFlowFormModal
v-model:open="dialogVisible"

View File

@@ -4,10 +4,14 @@ import type { BotInfo } from '@easyflow/types';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ArrowLeft } from '@element-plus/icons-vue';
import { ElButton } from 'element-plus';
import { tryit } from 'radash';
import { getBotDetails } from '#/api';
import { hasPermission } from '#/api/common/hasPermission';
import { $t } from '#/locales';
import { navigateBackToList } from '#/router/list-return-context';
import Config from './config.vue';
import Preview from './preview.vue';
@@ -55,10 +59,16 @@ const fetchBotDetail = async (id: string) => {
syncNavTitle((res.data?.title || res.data?.name || '') as string);
}
};
async function backToBotList() {
await navigateBackToList(router, route.query, ['/ai/bots'], '/ai/bots');
}
</script>
<template>
<div class="settings-container">
<ElButton :icon="ArrowLeft" class="settings-back" @click="backToBotList">
{{ $t('button.back') }}
</ElButton>
<div class="row-container">
<div class="row-item">
<Prompt :bot="bot" :has-save-permission="hasSavePermission" />
@@ -74,14 +84,22 @@ const fetchBotDetail = async (id: string) => {
</template>
<style scoped>
.settings-container {
display: flex;
flex-direction: column;
gap: 16px;
height: calc(100vh - 90px);
padding: 20px;
}
.settings-back {
align-self: flex-start;
}
.row-container {
display: flex;
gap: 20px;
height: 100%;
flex: 1;
min-height: 0;
}
.row-item {

View File

@@ -12,6 +12,7 @@ import { ElButton, ElIcon, ElImage, ElMessage } from 'element-plus';
import { api } from '#/api/request';
import bookIcon from '#/assets/ai/knowledge/book.svg';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import { navigateBackToList } from '#/router/list-return-context';
import { createLazyComponentController } from '#/utils/lazy-component';
import DocumentImportBatchStatus from '#/views/ai/documentCollection/DocumentImportBatchStatus.vue';
@@ -150,8 +151,13 @@ const getKnowledge = () => {
onMounted(() => {
getKnowledge();
});
const back = () => {
router.push({ path: '/ai/documentCollection' });
const back = async () => {
await navigateBackToList(
router,
route.query,
['/ai/documentCollection'],
'/ai/documentCollection',
);
};
const isFaqCollection = computed(
() => knowledgeInfo.value.collectionType === 'FAQ',

View File

@@ -5,9 +5,10 @@ import type {
ActionButton,
CardPrimaryAction,
} from '#/components/page/CardList.vue';
import type { OfflineImpactCheck } from '#/views/ai/shared/offline-impact';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { useAccess } from '@easyflow/access';
import { EasyFlowFormModal } from '@easyflow/common-ui';
@@ -43,15 +44,20 @@ import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import CardPage from '#/components/page/CardList.vue';
import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue';
import {
buildListRouteFullPath,
mergeListRouteStateQuery,
parseListRouteState,
watchListRouteState,
} from '#/composables/useListRouteState';
import { withListReturnTo } from '#/router/list-return-context';
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
import { createLazyComponentController } from '#/utils/lazy-component';
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
import { documentCollectionListRouteSchema } from '#/views/ai/documentCollection/document-collection-list-route-state';
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason';
import {
buildOfflineImpactMessage,
type OfflineImpactCheck,
} from '#/views/ai/shared/offline-impact';
import { buildOfflineImpactMessage } from '#/views/ai/shared/offline-impact';
import {
canAiResourceDelete,
canAiResourceOffline,
@@ -61,7 +67,35 @@ import {
resolveAiResourceDisplayStatus,
} from '#/views/ai/shared/publish-status';
const route = useRoute();
const router = useRouter();
const initialListState = parseListRouteState(
route.query,
documentCollectionListRouteSchema,
);
const selectedCategoryId = ref(initialListState.categoryId);
const searchKeyword = ref(initialListState.keyword);
const currentPageNumber = ref(initialListState.pageNumber);
const currentPageSize = ref(initialListState.pageSize);
let initialCategoryChangePending = Boolean(initialListState.categoryId);
watchListRouteState(route, documentCollectionListRouteSchema, (state) => {
if (state.categoryId !== selectedCategoryId.value) {
initialCategoryChangePending = Boolean(state.categoryId);
}
selectedCategoryId.value = state.categoryId;
searchKeyword.value = state.keyword;
currentPageNumber.value = state.pageNumber;
currentPageSize.value = state.pageSize;
pageDataRef.value?.restoreState?.({
pageNumber: state.pageNumber,
pageSize: state.pageSize,
queryParams: {
categoryId: state.categoryId || undefined,
isQueryOr: true,
title: state.keyword || undefined,
},
});
});
const userStore = useUserStore();
const { hasAccessByCodes } = useAccess();
const collectionTypeLabelMap = {
@@ -153,6 +187,20 @@ function resolveNavTitle(row: Record<string, any>) {
return row?.title || row?.name || '';
}
function currentListFullPath() {
return buildListRouteFullPath(
router,
route.query,
{
categoryId: selectedCategoryId.value,
keyword: searchKeyword.value,
pageNumber: currentPageNumber.value,
pageSize: currentPageSize.value,
},
documentCollectionListRouteSchema,
);
}
function openKnowledgeDetail(row: {
id: string;
name?: string;
@@ -164,6 +212,7 @@ function openKnowledgeDetail(row: {
id: row.id,
pageKey: '/ai/documentCollection',
navTitle: resolveNavTitle(row),
...withListReturnTo(currentListFullPath()),
},
});
}
@@ -243,6 +292,7 @@ const actions: ActionButton[] = [
pageKey: '/ai/documentCollection',
navTitle: resolveNavTitle(row),
activeMenu: 'knowledgeSearch',
...withListReturnTo(currentListFullPath()),
},
});
},
@@ -451,18 +501,18 @@ function resolvePublishStatusMeta(
tone: 'danger',
};
}
case 'OFFLINE_PENDING': {
return {
label: $t('documentCollection.publishStatusOfflinePending'),
tone: 'pending',
};
}
case 'OFFLINE': {
return {
label: $t('documentCollection.publishStatusOffline'),
tone: 'draft',
};
}
case 'OFFLINE_PENDING': {
return {
label: $t('documentCollection.publishStatusOfflinePending'),
tone: 'pending',
};
}
case 'PUBLISH_PENDING': {
return {
label: $t('documentCollection.publishStatusPublishPending'),
@@ -537,7 +587,12 @@ const formRules = computed(() => {
return rules;
});
const handleSearch = (params: any) => {
pageDataRef.value.setQuery({ title: params, isQueryOr: true });
searchKeyword.value = String(params || '');
pageDataRef.value.setQuery({
categoryId: selectedCategoryId.value || undefined,
title: searchKeyword.value || undefined,
isQueryOr: true,
});
};
const reloadKnowledgeList = () => {
pageDataRef.value?.reload?.();
@@ -567,6 +622,19 @@ const getCategoryList = async () => {
},
...res.data,
];
if (
selectedCategoryId.value &&
!categoryList.value.some(
(item) => String(item.id) === selectedCategoryId.value,
)
) {
selectedCategoryId.value = '';
initialCategoryChangePending = false;
pageDataRef.value?.setQuery?.({
title: searchKeyword.value || undefined,
isQueryOr: true,
});
}
}
};
function removeCategory(row: any) {
@@ -686,7 +754,42 @@ function handleSubmit() {
});
}
function changeCategory(category: any) {
pageDataRef.value.setQuery({ categoryId: category.id });
const categoryId = String(category?.id || '');
if (initialCategoryChangePending && categoryId === selectedCategoryId.value) {
initialCategoryChangePending = false;
return;
}
initialCategoryChangePending = false;
selectedCategoryId.value = categoryId;
pageDataRef.value.setQuery({
categoryId: categoryId || undefined,
title: searchKeyword.value || undefined,
isQueryOr: true,
});
}
function handlePageStateChange(state: {
pageNumber: number;
pageSize: number;
}) {
currentPageNumber.value = state.pageNumber;
currentPageSize.value = state.pageSize;
const query = mergeListRouteStateQuery(
route.query,
{
categoryId: selectedCategoryId.value,
keyword: searchKeyword.value,
pageNumber: state.pageNumber,
pageSize: state.pageSize,
},
documentCollectionListRouteSchema,
);
const target = router.resolve({
path: documentCollectionListRouteSchema.path,
query,
});
if (target.fullPath !== route.fullPath) {
void router.replace(target);
}
}
</script>
@@ -695,6 +798,7 @@ function changeCategory(category: any) {
<div class="knowledge-header">
<HeaderSearch
:buttons="headerButtons"
:initial-value="searchKeyword"
@search="handleSearch"
@button-click="handleButtonClick"
/>
@@ -706,15 +810,22 @@ function changeCategory(category: any) {
:menus="categoryList"
:control-btns="controlBtns"
:footer-button="footerButton"
:default-selected="selectedCategoryId"
@change="changeCategory"
/>
<div class="h-full flex-1 overflow-auto">
<PageData
ref="pageDataRef"
page-url="/api/v1/documentCollection/page"
:page-size="12"
:page-size="initialListState.pageSize"
:page-sizes="[12, 24, 36, 48]"
:init-query-params="{ status: 1 }"
:initial-page-number="initialListState.pageNumber"
:initial-query-params="{
categoryId: initialListState.categoryId || undefined,
title: initialListState.keyword || undefined,
isQueryOr: true,
}"
@state-change="handlePageStateChange"
>
<template #default="{ pageList }">
<CardPage

View File

@@ -0,0 +1,29 @@
import {
defineListRouteStateSchema,
positiveIntegerListRouteField,
stringListRouteField,
} from '#/composables/useListRouteState';
interface DocumentCollectionListRouteState {
categoryId: string;
keyword: string;
pageNumber: number;
pageSize: number;
}
const documentCollectionListRouteSchema =
defineListRouteStateSchema<DocumentCollectionListRouteState>({
path: '/ai/documentCollection',
fields: {
categoryId: stringListRouteField(),
keyword: stringListRouteField(),
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
pageSize: positiveIntegerListRouteField({
allowedValues: [12, 24, 36, 48],
defaultValue: 12,
}),
},
});
export { documentCollectionListRouteSchema };
export type { DocumentCollectionListRouteState };

View File

@@ -29,13 +29,18 @@ import PluginToolIcon from '#/components/icons/PluginToolIcon.vue';
import CardPage from '#/components/page/CardList.vue';
import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue';
import {
buildListRouteFullPath,
watchListRouteState,
} from '#/composables/useListRouteState';
import { withListReturnTo } from '#/router/list-return-context';
import { createLazyComponentController } from '#/utils/lazy-component';
import { buildPluginPageQueryParams } from './plugin-query';
import {
buildPluginToolsReturnQuery,
mergePluginListRouteQuery,
parsePluginListRouteState,
pluginListRouteSchema,
} from './plugin-route-state';
const route = useRoute();
@@ -95,7 +100,7 @@ function openPluginTools(item: PluginRecord) {
id: item.id,
pageKey: '/ai/plugin',
navTitle: resolveNavTitle(item),
...buildPluginToolsReturnQuery(currentListState()),
...withListReturnTo(currentListFullPath()),
},
});
}
@@ -217,6 +222,20 @@ const handleDelete = (item: PluginRecord) => {
};
const pageDataRef = ref();
watchListRouteState(route, pluginListRouteSchema, (state) => {
if (String(state.categoryId) !== String(selectedCategoryId.value)) {
suppressInitialCategoryChange.value = true;
}
selectedCategoryId.value = state.categoryId;
searchKeyword.value = state.keyword.trim();
currentPageNumber.value = state.pageNumber;
currentPageSize.value = state.pageSize;
pageDataRef.value?.restoreState?.({
pageNumber: state.pageNumber,
pageSize: state.pageSize,
queryParams: buildPluginPageQueryParams(state.categoryId, state.keyword),
});
});
const headerButtons = [
{
key: 'add',
@@ -265,6 +284,15 @@ function currentListState(): PluginListRouteState {
};
}
function currentListFullPath() {
return buildListRouteFullPath(
router,
route.query,
currentListState(),
pluginListRouteSchema,
);
}
function syncListRouteState() {
const target = {
path: '/ai/plugin',
@@ -325,7 +353,7 @@ const handleDeleteCategory = (params: PluginCategory) => {
const handleClickCategory = (item: PluginCategory) => {
if (
suppressInitialCategoryChange.value &&
String(item.id) === initialListState.categoryId
String(item.id) === String(selectedCategoryId.value)
) {
suppressInitialCategoryChange.value = false;
selectedCategoryId.value = item.id;

View File

@@ -19,16 +19,11 @@ import {
} from 'element-plus';
import { api } from '#/api/request';
import { navigateBackToList } from '#/router/list-return-context';
import PluginInputAndOutParams from '#/views/ai/plugin/PluginInputAndOutParams.vue';
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();
@@ -169,19 +164,20 @@ function handleClickHeader(index: number) {
}
}
function back() {
async function back() {
const pluginId = String(route.query.pluginId || '');
if (pluginId) {
void router.replace({
path: '/ai/plugin/tools',
query: buildPluginToolsRouteQueryFromEdit(route.query, pluginId),
});
return;
}
void router.replace({
path: '/ai/plugin',
query: buildPluginListRouteQuery(parsePluginToolsReturnState(route.query)),
});
const fallbackPath = pluginId
? router.resolve({
path: '/ai/plugin/tools',
query: { id: pluginId },
}).fullPath
: '/ai/plugin';
await navigateBackToList(
router,
route.query,
pluginId ? ['/ai/plugin/tools'] : ['/ai/plugin'],
fallbackPath,
);
}
function updatePluginTool(index: number) {

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useRouter } from 'vue-router';
import { $t } from '@easyflow/locales';
@@ -18,6 +18,7 @@ import {
import { api } from '#/api/request';
import PageData from '#/components/page/PageData.vue';
import { withListReturnTo } from '#/router/list-return-context';
import AiPluginToolModal from '#/views/ai/plugin/AiPluginToolModal.vue';
import { buildPluginToolPageQueryParams } from './plugin-query';
@@ -31,6 +32,10 @@ const props = defineProps({
default: 1,
type: Number,
},
returnTo: {
default: '/ai/plugin/tools',
type: String,
},
initialKeyword: {
default: '',
type: String,
@@ -53,7 +58,6 @@ const emit = defineEmits<{
},
): void;
}>();
const route = useRoute();
const router = useRouter();
defineExpose({
openPluginToolModal() {
@@ -65,17 +69,27 @@ defineExpose({
handleSearch: (params: string) => {
pageDataRef.value.setQuery(buildPluginToolPageQueryParams(params));
},
restoreState: (state: {
keyword: string;
pageNumber: number;
pageSize: number;
}) => {
return pageDataRef.value?.restoreState?.({
pageNumber: state.pageNumber,
pageSize: state.pageSize,
queryParams: buildPluginToolPageQueryParams(state.keyword),
});
},
});
const pageDataRef = ref();
const handleEdit = (row: any) => {
router.push({
path: '/ai/plugin/tool/edit',
query: {
...route.query,
query: withListReturnTo(props.returnTo, {
id: row.id,
pageKey: '/ai/plugin',
pluginId: props.pluginId,
},
}),
});
};

View File

@@ -10,13 +10,17 @@ import { Back, Plus } from '@element-plus/icons-vue';
import { api } from '#/api/request';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import {
buildListRouteFullPath,
watchListRouteState,
} from '#/composables/useListRouteState';
import { navigateBackToList } from '#/router/list-return-context';
import PluginToolTable from '#/views/ai/plugin/PluginToolTable.vue';
import {
buildPluginListRouteQuery,
mergePluginToolListRouteQuery,
parsePluginToolListRouteState,
parsePluginToolsReturnState,
pluginToolListRouteSchema,
} from './plugin-route-state';
const route = useRoute();
@@ -29,6 +33,20 @@ const pluginToolRef = ref();
const toolSearchKeyword = ref(initialToolListState.keyword.trim());
const currentToolPageNumber = ref(initialToolListState.pageNumber);
const currentToolPageSize = ref(initialToolListState.pageSize);
watchListRouteState(route, pluginToolListRouteSchema, (state) => {
const nextPluginId = String(route.query.id || '');
const pluginChanged = nextPluginId !== pluginId.value;
pluginId.value = nextPluginId;
toolSearchKeyword.value = state.keyword.trim();
currentToolPageNumber.value = state.pageNumber;
currentToolPageSize.value = state.pageSize;
const restored = pluginToolRef.value?.restoreState?.(state);
if (pluginChanged) {
pluginInfo.value = {};
if (!restored) pluginToolRef.value?.reload?.();
void loadPluginInfo();
}
});
const headerButtons = computed<any[]>(() => {
const buttons: any[] = [
@@ -73,6 +91,15 @@ function currentToolListState(): PluginToolListRouteState {
};
}
function currentToolListFullPath() {
return buildListRouteFullPath(
router,
route.query,
currentToolListState(),
pluginToolListRouteSchema,
);
}
function syncToolListRouteState() {
const target = {
path: '/ai/plugin/tools',
@@ -100,12 +127,12 @@ function handleSearch(params: string) {
function handleButtonClick(event: any) {
switch (event.key) {
case 'back': {
void router.replace({
path: '/ai/plugin',
query: buildPluginListRouteQuery(
parsePluginToolsReturnState(route.query),
),
});
void navigateBackToList(
router,
route.query,
['/ai/plugin'],
'/ai/plugin',
);
break;
}
case 'createTool': {
@@ -133,6 +160,7 @@ function handleButtonClick(event: any) {
:initial-keyword="initialToolListState.keyword"
:initial-page-number="initialToolListState.pageNumber"
:initial-page-size="initialToolListState.pageSize"
:return-to="currentToolListFullPath()"
@state-change="handleToolPageStateChange"
/>
</div>

View File

@@ -2,13 +2,11 @@ import { describe, expect, it } from 'vitest';
import {
buildPluginListRouteQuery,
buildPluginToolsReturnQuery,
buildPluginToolsRouteQueryFromEdit,
buildPluginToolListRouteQuery,
mergePluginListRouteQuery,
mergePluginToolListRouteQuery,
parsePluginListRouteState,
parsePluginToolListRouteState,
parsePluginToolsReturnState,
} from './plugin-route-state';
describe('plugin route state', () => {
@@ -28,22 +26,13 @@ describe('plugin route state', () => {
});
});
it('round trips the plugin list state through plugin tools', () => {
it('serializes plugin list state into canonical list query', () => {
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: '天气',
@@ -66,8 +55,8 @@ describe('plugin route state', () => {
});
expect(
parsePluginToolListRouteState({
toolPageNumber: '0',
toolPageSize: '999',
pageNumber: '0',
pageSize: '999',
}),
).toEqual({
keyword: '',
@@ -99,9 +88,8 @@ describe('plugin route state', () => {
mergePluginToolListRouteQuery(
{
id: '88',
returnCategoryId: '7',
returnPageNumber: '3',
toolKeyword: '旧工具',
returnTo: '/ai/plugin?categoryId=7&pageNumber=3',
keyword: '旧工具',
},
{
keyword: '查询工具',
@@ -111,29 +99,24 @@ describe('plugin route state', () => {
),
).toEqual({
id: '88',
returnCategoryId: '7',
returnPageNumber: '3',
toolKeyword: '查询工具',
toolPageNumber: '2',
toolPageSize: '20',
keyword: '查询工具',
pageNumber: '2',
pageSize: '20',
returnTo: '/ai/plugin?categoryId=7&pageNumber=3',
});
});
it('restores plugin tools query after editing a tool', () => {
it('serializes plugin tool list state with common query keys', () => {
expect(
buildPluginToolsRouteQueryFromEdit(
{
id: 'tool-1',
pluginId: 'plugin-1',
returnCategoryId: '7',
toolPageNumber: '2',
},
'plugin-1',
),
buildPluginToolListRouteQuery({
keyword: '查询工具',
pageNumber: 2,
pageSize: 20,
}),
).toEqual({
id: 'plugin-1',
returnCategoryId: '7',
toolPageNumber: '2',
keyword: '查询工具',
pageNumber: '2',
pageSize: '20',
});
});
});

View File

@@ -1,33 +1,13 @@
import type { LocationQuery } from 'vue-router';
import type { LocationQuery, LocationQueryRaw } 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;
import {
buildListRouteStateQuery,
defineListRouteStateSchema,
mergeListRouteStateQuery,
parseListRouteState,
positiveIntegerListRouteField,
stringListRouteField,
} from '#/composables/useListRouteState';
interface PluginListRouteState {
categoryId: string;
@@ -42,156 +22,74 @@ interface PluginToolListRouteState {
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);
const pluginListRouteSchema = defineListRouteStateSchema<PluginListRouteState>({
path: '/ai/plugin',
fields: {
categoryId: stringListRouteField({ defaultValue: '0' }),
keyword: stringListRouteField(),
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
pageSize: positiveIntegerListRouteField({
allowedValues: [12, 24, 36, 48],
defaultValue: 12,
}),
},
});
const pluginToolListRouteSchema =
defineListRouteStateSchema<PluginToolListRouteState>({
path: '/ai/plugin/tools',
fields: {
keyword: stringListRouteField(),
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
pageSize: positiveIntegerListRouteField({
allowedValues: [10, 20, 50, 100],
defaultValue: 10,
}),
},
});
function parsePluginListRouteState(query: LocationQuery) {
return parseListRouteState(query, pluginListRouteSchema);
}
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(
function buildPluginListRouteQuery(
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);
): LocationQueryRaw {
return buildListRouteStateQuery(state, pluginListRouteSchema);
}
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),
};
): LocationQueryRaw {
return mergeListRouteStateQuery(query, state, pluginListRouteSchema);
}
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 parsePluginToolListRouteState(query: LocationQuery) {
return parseListRouteState(query, pluginToolListRouteSchema);
}
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 } : {}),
};
): LocationQueryRaw {
return buildListRouteStateQuery(state, pluginToolListRouteSchema);
}
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;
): LocationQueryRaw {
return mergeListRouteStateQuery(query, state, pluginToolListRouteSchema);
}
export {
buildPluginListRouteQuery,
buildPluginToolsReturnQuery,
buildPluginToolsRouteQueryFromEdit,
buildPluginToolListRouteQuery,
mergePluginListRouteQuery,
mergePluginToolListRouteQuery,
parsePluginListRouteState,
parsePluginToolListRouteState,
parsePluginToolsReturnState,
pluginListRouteSchema,
pluginToolListRouteSchema,
};
export type { PluginListRouteState, PluginToolListRouteState };

View File

@@ -37,6 +37,10 @@ import {
} from 'element-plus';
import { hasPermission } from '#/api/common/hasPermission';
import {
navigateBackToList,
resolveListReturnPath,
} from '#/router/list-return-context';
import {
canAiResourceDelete,
canAiResourceOffline,
@@ -211,15 +215,21 @@ async function confirmUnsavedNavigation() {
onBeforeRouteLeave(confirmUnsavedNavigation);
onBeforeRouteUpdate(confirmUnsavedNavigation);
async function replaceRouteDuringOperation(path: string) {
async function returnToSkillListDuringOperation() {
allowOperationNavigation = true;
try {
await router.replace(path);
await router.replace(
resolveListReturnPath(router, route.query, ['/ai/skill'], '/ai/skill'),
);
} finally {
allowOperationNavigation = false;
}
}
async function backToSkillList() {
await navigateBackToList(router, route.query, ['/ai/skill'], '/ai/skill');
}
async function init() {
const request = ++initRequest;
loading.value = true;
@@ -227,7 +237,7 @@ async function init() {
loadAccessDenied.value = false;
try {
if (isNew.value) {
await replaceRouteDuringOperation('/ai/skill');
await returnToSkillListDuringOperation();
return;
}
await loadCategories(request);
@@ -355,9 +365,8 @@ async function saveFiles(allowLifecycleAction = false, showFeedback = true) {
}
async function flushPendingChanges(allowLifecycleAction = false) {
if (resourceDirty.value) {
if (!(await saveFiles(allowLifecycleAction, false))) return false;
}
if (resourceDirty.value && !(await saveFiles(allowLifecycleAction, false)))
return false;
const panel = capabilityPanelRef.value;
if (capabilityDirty.value || panel?.hasDirty()) {
if (!canBindCapabilities.value || !panel) {
@@ -524,7 +533,7 @@ async function remove() {
if (res.errorCode === 0) {
if (res.data === null || res.data === undefined) {
ElMessage.success(res.message || 'Skill 已删除');
await replaceRouteDuringOperation('/ai/skill');
await returnToSkillListDuringOperation();
} else {
ElMessage.success(res.message || '已提交删除审批');
await init();
@@ -561,7 +570,7 @@ function handleSaveShortcut(event: KeyboardEvent) {
text
:disabled="operationLocked"
aria-label="返回 Skill 列表"
@click="router.push('/ai/skill')"
@click="backToSkillList"
/>
<div class="skill-detail-page__title">
<div>
@@ -674,7 +683,7 @@ function handleSaveShortcut(event: KeyboardEvent) {
<template #extra>
<ElButton
:type="loadAccessDenied ? 'primary' : 'default'"
@click="router.push('/ai/skill')"
@click="backToSkillList"
>
返回 Skill 列表
</ElButton>

View File

@@ -18,7 +18,7 @@ import {
reactive,
ref,
} from 'vue';
import { useRouter } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { downloadFileFromBlob, formatDate } from '@easyflow/utils';
@@ -61,7 +61,14 @@ import { hasPermission } from '#/api/common/hasPermission';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue';
import {
buildListRouteFullPath,
mergeListRouteStateQuery,
parseListRouteState,
watchListRouteState,
} from '#/composables/useListRouteState';
import { $t } from '#/locales';
import { withListReturnTo } from '#/router/list-return-context';
import {
canAiResourceDelete,
canAiResourceOffline,
@@ -95,19 +102,43 @@ import {
resolveSkillImportConflictReasonLabel,
resolveSkillImportStep,
} from './skill-import';
import { skillListRouteSchema } from './skill-list-route-state';
import SkillCategoryFormDialog from './SkillCategoryFormDialog.vue';
import SkillCreateDialog from './SkillCreateDialog.vue';
const route = useRoute();
const router = useRouter();
const initialListState = parseListRouteState(route.query, skillListRouteSchema);
const pageDataRef = ref<any>();
const headerSearchRef = ref<{ reset: () => void }>();
const importInputRef = ref<HTMLInputElement>();
const categories = ref<SkillCategory[]>([]);
const categoryLoading = ref(false);
const selectedRows = ref<SkillInfo[]>([]);
const selectedCategoryId = ref<number | string>('');
const selectedCategoryId = ref<number | string>(initialListState.categoryId);
const filters = reactive({
keyword: '',
keyword: initialListState.keyword,
});
const currentPageNumber = ref(initialListState.pageNumber);
const currentPageSize = ref(initialListState.pageSize);
let initialCategoryChangePending = Boolean(initialListState.categoryId);
watchListRouteState(route, skillListRouteSchema, (state) => {
if (String(state.categoryId) !== String(selectedCategoryId.value)) {
initialCategoryChangePending = Boolean(state.categoryId);
}
selectedCategoryId.value = state.categoryId;
filters.keyword = state.keyword;
currentPageNumber.value = state.pageNumber;
currentPageSize.value = state.pageSize;
selectedRows.value = [];
pageDataRef.value?.restoreState?.({
pageNumber: state.pageNumber,
pageSize: state.pageSize,
queryParams: {
categoryId: state.categoryId || undefined,
displayName: state.keyword || undefined,
},
});
});
const importDialogOpen = ref(false);
@@ -311,6 +342,16 @@ async function loadCategories() {
const res = await getSkillCategories();
if (res.errorCode !== 0) throw new Error(res.message);
categories.value = res.data || [];
if (
selectedCategoryId.value &&
!flatCategories.value.some(
(category) => String(category.id) === String(selectedCategoryId.value),
)
) {
selectedCategoryId.value = '';
initialCategoryChangePending = false;
applyFilters();
}
} catch (error) {
categories.value = [];
ElMessage.error(
@@ -362,10 +403,53 @@ function selectedTargetCategory() {
}
function selectCategory(data?: SkillCategory) {
selectedCategoryId.value = data?.id || '';
const categoryId = data?.id || '';
if (
initialCategoryChangePending &&
String(categoryId) === String(selectedCategoryId.value)
) {
initialCategoryChangePending = false;
return;
}
initialCategoryChangePending = false;
selectedCategoryId.value = categoryId;
applyFilters();
}
function currentListFullPath() {
return buildListRouteFullPath(
router,
route.query,
{
categoryId: String(selectedCategoryId.value || ''),
keyword: filters.keyword,
pageNumber: currentPageNumber.value,
pageSize: currentPageSize.value,
},
skillListRouteSchema,
);
}
function handlePageStateChange(state: {
pageNumber: number;
pageSize: number;
}) {
currentPageNumber.value = state.pageNumber;
currentPageSize.value = state.pageSize;
const query = mergeListRouteStateQuery(
route.query,
{
categoryId: String(selectedCategoryId.value || ''),
keyword: filters.keyword,
pageNumber: state.pageNumber,
pageSize: state.pageSize,
},
skillListRouteSchema,
);
const target = router.resolve({ path: skillListRouteSchema.path, query });
if (target.fullPath !== route.fullPath) void router.replace(target);
}
function formatModified(value?: string) {
return value ? formatDate(value, 'YYYY-MM-DD HH:mm') : '—';
}
@@ -388,6 +472,7 @@ function openDetail(row: SkillInfo) {
query: {
navTitle: row.displayName || row.name || 'Skill 详情',
pageKey: '/ai/skill',
...withListReturnTo(currentListFullPath()),
},
});
}
@@ -402,6 +487,7 @@ function handleSkillCreated(payload: {
...(payload.intent === 'PUBLISH' ? { publishIntent: '1' } : {}),
navTitle: payload.skill.displayName || payload.skill.name || 'Skill 详情',
pageKey: '/ai/skill',
...withListReturnTo(currentListFullPath()),
},
});
}
@@ -1045,6 +1131,7 @@ function isRowBusy(row: SkillInfo) {
<HeaderSearch
ref="headerSearchRef"
:buttons="headerButtons"
:initial-value="filters.keyword"
search-placeholder="请输入 Skill 名称或描述"
@search="handleHeaderSearch"
@button-click="handleHeaderButtonClick"
@@ -1094,8 +1181,14 @@ function isRowBusy(row: SkillInfo) {
<PageData
ref="pageDataRef"
page-url="/api/v1/skill/page"
:page-size="12"
:page-size="initialListState.pageSize"
:page-sizes="[12, 24, 48]"
:initial-page-number="initialListState.pageNumber"
:initial-query-params="{
categoryId: initialListState.categoryId || undefined,
displayName: initialListState.keyword || undefined,
}"
@state-change="handlePageStateChange"
>
<template #default="{ pageList }">
<ElTable

View File

@@ -0,0 +1,28 @@
import {
defineListRouteStateSchema,
positiveIntegerListRouteField,
stringListRouteField,
} from '#/composables/useListRouteState';
interface SkillListRouteState {
categoryId: string;
keyword: string;
pageNumber: number;
pageSize: number;
}
const skillListRouteSchema = defineListRouteStateSchema<SkillListRouteState>({
path: '/ai/skill',
fields: {
categoryId: stringListRouteField(),
keyword: stringListRouteField(),
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
pageSize: positiveIntegerListRouteField({
allowedValues: [12, 24, 48],
defaultValue: 12,
}),
},
});
export { skillListRouteSchema };
export type { SkillListRouteState };

View File

@@ -9,7 +9,7 @@ import {
ref,
shallowRef,
} from 'vue';
import {useRoute} from 'vue-router';
import { useRoute } from 'vue-router';
import {usePreferences} from '@easyflow/preferences';
import {getOptions, sortNodes} from '@easyflow/utils';
@@ -22,6 +22,7 @@ import {api} from '#/api/request';
import CommonSelectDataModal from '#/components/commonSelectModal/CommonSelectDataModal.vue';
import {$t} from '#/locales';
import {router} from '#/router';
import { navigateBackToList } from '#/router/list-return-context';
import {
resolveWorkflowShareFailureReason,
resolveWorkflowShareWorkflowId,
@@ -51,11 +52,6 @@ import {
isWorkflowDataEmpty,
normalizeWorkflowStartNodes,
} from '../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields';
import {
buildWorkflowListRouteQuery,
parseWorkflowDesignReturnState,
} from './workflow-list-route-state';
import '@tinyflow-ai/vue/dist/index.css';
const WORKFLOW_VISIBLE_RENDER_NODE_THRESHOLD = 40;
@@ -161,12 +157,13 @@ async function initializeWorkflow() {
}
}
function backToWorkflowList() {
const listState = parseWorkflowDesignReturnState(route.query);
router.replace({
path: '/ai/workflow',
query: buildWorkflowListRouteQuery(listState),
});
async function backToWorkflowList() {
await navigateBackToList(
router,
route.query,
['/ai/workflow'],
'/ai/workflow',
);
}
const WORKFLOW_DRAFT_WRITE_DELAY = 320;
let draftWriteTimer: ReturnType<typeof setTimeout> | undefined;

View File

@@ -60,8 +60,13 @@ import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import CardList from '#/components/page/CardList.vue';
import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue';
import {
buildListRouteFullPath,
watchListRouteState,
} from '#/composables/useListRouteState';
import { $t } from '#/locales';
import { router } from '#/router';
import { withListReturnTo } from '#/router/list-return-context';
import { useDictStore } from '#/store';
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
import { createLazyComponentController } from '#/utils/lazy-component';
@@ -79,9 +84,9 @@ import {
} from '#/views/ai/shared/publish-status';
import {
buildWorkflowDesignReturnQuery,
mergeWorkflowListRouteQuery,
parseWorkflowListRouteState,
workflowListRouteSchema,
} from './workflow-list-route-state';
const ElXMarkdown = defineAsyncComponent(
@@ -193,6 +198,7 @@ const actions: ActionButton[] = [
name: 'RunPage',
query: {
id: row.id,
...withListReturnTo(currentListFullPath()),
},
});
},
@@ -219,6 +225,7 @@ const actions: ActionButton[] = [
name: 'ExecRecord',
query: {
workflowId: row.id,
...withListReturnTo(currentListFullPath()),
},
});
},
@@ -305,6 +312,23 @@ const initialQueryParams = {
categoryId: initialListState.categoryId || undefined,
title: initialListState.keyword || undefined,
};
watchListRouteState(route, workflowListRouteSchema, (state) => {
if (String(state.categoryId) !== String(selectedCategoryId.value)) {
suppressInitialCategoryChange.value = Boolean(state.categoryId);
}
selectedCategoryId.value = state.categoryId;
searchKeyword.value = state.keyword;
currentPageNumber.value = state.pageNumber;
currentPageSize.value = state.pageSize;
pageDataRef.value?.restoreState?.({
pageNumber: state.pageNumber,
pageSize: state.pageSize,
queryParams: {
categoryId: state.categoryId || undefined,
title: state.keyword || undefined,
},
});
});
const dictStore = useDictStore();
const headerButtons = [
{
@@ -389,6 +413,14 @@ function currentListState(): WorkflowListRouteState {
pageSize: pageState?.pageSize ?? currentPageSize.value,
};
}
function currentListFullPath() {
return buildListRouteFullPath(
router,
route.query,
currentListState(),
workflowListRouteSchema,
);
}
function syncListRouteState() {
const target = {
path: '/ai/workflow',
@@ -1134,7 +1166,7 @@ function toDesignPage(row: any) {
id: row.id,
pageKey: '/ai/workflow',
navTitle: resolveNavTitle(row),
...buildWorkflowDesignReturnQuery(currentListState()),
...withListReturnTo(currentListFullPath()),
},
});
}
@@ -1258,7 +1290,7 @@ function changeCategory(category: any) {
const categoryId = category?.id ?? '';
if (
suppressInitialCategoryChange.value &&
String(categoryId) === initialListState.categoryId
String(categoryId) === String(selectedCategoryId.value)
) {
suppressInitialCategoryChange.value = false;
selectedCategoryId.value = categoryId;

View File

@@ -40,6 +40,7 @@ import {
import { api, SseClient } from '#/api/request';
import workflowIcon from '#/assets/ai/workflow/workflowIcon.png';
import { router } from '#/router';
import { navigateBackToList } from '#/router/list-return-context';
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
import { resolveWorkflowShareFailureReason } from '#/utils/workflow-share-context';
@@ -266,6 +267,15 @@ function initializeAdditionalValues() {
extraSubmitting.value = false;
}
async function backToWorkflowList() {
await navigateBackToList(
router,
route.query,
['/ai/workflow'],
'/ai/workflow',
);
}
async function submitAdditionalInputs() {
if (extraSubmitted.value) {
return true;
@@ -865,7 +875,7 @@ function executionTraceText(
circle
text
aria-label="返回工作流"
@click="router.replace({ path: '/ai/workflow' })"
@click="backToWorkflowList"
/>
<ElAvatar
:size="40"

View File

@@ -22,18 +22,53 @@ import {
import { api } from '#/api/request';
import PageData from '#/components/page/PageData.vue';
import {
buildListRouteFullPath,
mergeListRouteStateQuery,
parseListRouteState,
watchListRouteState,
} from '#/composables/useListRouteState';
import { $t } from '#/locales';
import {
navigateBackToList,
withListReturnTo,
} from '#/router/list-return-context';
import { useDictStore } from '#/store';
import { workflowExecListRouteSchema } from './workflow-exec-list-route-state';
const router = useRouter();
const $route = useRoute();
const initialListState = parseListRouteState(
$route.query,
workflowExecListRouteSchema,
);
onMounted(() => {
initDict();
});
const formRef = ref<FormInstance>();
const pageDataRef = ref();
const formInline = ref({
execKey: '',
execKey: initialListState.execKey,
});
const appliedExecKey = ref(initialListState.execKey);
const currentPageNumber = ref(initialListState.pageNumber);
const currentPageSize = ref(initialListState.pageSize);
let currentWorkflowId = String($route.query.workflowId || '');
watchListRouteState($route, workflowExecListRouteSchema, (state) => {
const nextWorkflowId = String($route.query.workflowId || '');
const workflowChanged = nextWorkflowId !== currentWorkflowId;
currentWorkflowId = nextWorkflowId;
formInline.value.execKey = state.execKey;
appliedExecKey.value = state.execKey;
currentPageNumber.value = state.pageNumber;
currentPageSize.value = state.pageSize;
const restored = pageDataRef.value?.restoreState?.({
pageNumber: state.pageNumber,
pageSize: state.pageSize,
queryParams: { execKey: state.execKey || undefined },
});
if (workflowChanged && !restored) pageDataRef.value?.reload?.();
});
const dictStore = useDictStore();
function initDict() {
@@ -42,12 +77,17 @@ function initDict() {
function search(formEl: FormInstance | undefined) {
formEl?.validate((valid) => {
if (valid) {
pageDataRef.value.setQuery(formInline.value);
appliedExecKey.value = formInline.value.execKey;
pageDataRef.value.setQuery({
execKey: appliedExecKey.value || undefined,
});
}
});
}
function reset(formEl: FormInstance | undefined) {
formEl?.resetFields();
formInline.value.execKey = '';
appliedExecKey.value = '';
formEl?.clearValidate();
pageDataRef.value.setQuery({});
}
function remove(row: any) {
@@ -64,7 +104,7 @@ function remove(row: any) {
instance.confirmButtonLoading = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
reset(formRef.value);
pageDataRef.value?.reload?.();
done();
}
})
@@ -83,9 +123,51 @@ function toStepPage(row: any) {
query: {
recordId: row.id,
workflowId: $route.query.workflowId,
...withListReturnTo(currentListFullPath()),
},
});
}
function currentListFullPath() {
return buildListRouteFullPath(
router,
$route.query,
{
execKey: appliedExecKey.value,
pageNumber: currentPageNumber.value,
pageSize: currentPageSize.value,
},
workflowExecListRouteSchema,
);
}
function handlePageStateChange(state: {
pageNumber: number;
pageSize: number;
}) {
currentPageNumber.value = state.pageNumber;
currentPageSize.value = state.pageSize;
const query = mergeListRouteStateQuery(
$route.query,
{
execKey: appliedExecKey.value,
pageNumber: state.pageNumber,
pageSize: state.pageSize,
},
workflowExecListRouteSchema,
);
const target = router.resolve({
path: workflowExecListRouteSchema.path,
query,
});
if (target.fullPath !== $route.fullPath) void router.replace(target);
}
async function backToWorkflowList() {
await navigateBackToList(
router,
$route.query,
['/ai/workflow'],
'/ai/workflow',
);
}
function getTagType(row: any) {
switch (row.status) {
case 1: {
@@ -113,10 +195,7 @@ function getTagType(row: any) {
<template>
<div class="page-container border-border border">
<div class="mb-3">
<ElButton
:icon="ArrowLeft"
@click="router.replace({ path: '/ai/workflow' })"
>
<ElButton :icon="ArrowLeft" @click="backToWorkflowList">
{{ $t('button.back') }}
</ElButton>
</div>
@@ -140,10 +219,15 @@ function getTagType(row: any) {
<PageData
ref="pageDataRef"
page-url="/api/v1/workflowExecResult/page"
:page-size="10"
:page-size="initialListState.pageSize"
:initial-page-number="initialListState.pageNumber"
:initial-query-params="{
execKey: initialListState.execKey || undefined,
}"
:extra-query-params="{
workflowId: $route.query.workflowId,
}"
@state-change="handlePageStateChange"
>
<template #default="{ pageList }">
<ElTable :data="pageList" border>

View File

@@ -17,6 +17,7 @@ import {
import PageData from '#/components/page/PageData.vue';
import { $t } from '#/locales';
import { navigateBackToList } from '#/router/list-return-context';
import { useDictStore } from '#/store';
const router = useRouter();
@@ -44,12 +45,18 @@ function reset(formEl: FormInstance | undefined) {
formEl?.resetFields();
pageDataRef.value.setQuery({});
}
function backToExecRecords() {
async function backToExecRecords() {
const workflowId = $route.query.workflowId;
void router.replace({
const fallbackPath = router.resolve({
name: 'ExecRecord',
query: workflowId ? { workflowId } : {},
});
}).fullPath;
await navigateBackToList(
router,
$route.query,
['/ai/workflow/executeRecords'],
fallbackPath,
);
}
function getTagType(row: any) {
switch (row.status) {

View File

@@ -0,0 +1,27 @@
import {
defineListRouteStateSchema,
positiveIntegerListRouteField,
stringListRouteField,
} from '#/composables/useListRouteState';
interface WorkflowExecListRouteState {
execKey: string;
pageNumber: number;
pageSize: number;
}
const workflowExecListRouteSchema =
defineListRouteStateSchema<WorkflowExecListRouteState>({
path: '/ai/workflow/executeRecords',
fields: {
execKey: stringListRouteField(),
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
pageSize: positiveIntegerListRouteField({
allowedValues: [10, 20, 50, 100],
defaultValue: 10,
}),
},
});
export { workflowExecListRouteSchema };
export type { WorkflowExecListRouteState };

View File

@@ -1,10 +1,8 @@
import { describe, expect, it } from 'vitest';
import {
buildWorkflowDesignReturnQuery,
buildWorkflowListRouteQuery,
mergeWorkflowListRouteQuery,
parseWorkflowDesignReturnState,
parseWorkflowListRouteState,
} from './workflow-list-route-state';
@@ -39,7 +37,7 @@ describe('workflow list route state', () => {
});
});
it('round trips list state through workflow design return query', () => {
it('serializes workflow list state into canonical list query', () => {
const state = {
categoryId: '7',
keyword: '月报',
@@ -47,15 +45,6 @@ describe('workflow list route state', () => {
pageSize: 18,
};
const designQuery = buildWorkflowDesignReturnQuery(state);
expect(designQuery).toEqual({
returnCategoryId: '7',
returnKeyword: '月报',
returnPageNumber: '3',
returnPageSize: '18',
});
expect(parseWorkflowDesignReturnState(designQuery)).toEqual(state);
expect(buildWorkflowListRouteQuery(state)).toEqual({
categoryId: '7',
keyword: '月报',

View File

@@ -1,29 +1,13 @@
import type { LocationQuery } from 'vue-router';
import type { LocationQuery, LocationQueryRaw } from 'vue-router';
const DEFAULT_PAGE_NUMBER = 1;
const DEFAULT_PAGE_SIZE = 12;
const WORKFLOW_PAGE_SIZES = new Set([12, 18, 24]);
const LIST_QUERY_KEYS = {
categoryId: 'categoryId',
keyword: 'keyword',
pageNumber: 'pageNumber',
pageSize: 'pageSize',
} as const;
const RETURN_QUERY_KEYS = {
categoryId: 'returnCategoryId',
keyword: 'returnKeyword',
pageNumber: 'returnPageNumber',
pageSize: 'returnPageSize',
} as const;
interface WorkflowListQueryKeys {
readonly categoryId: string;
readonly keyword: string;
readonly pageNumber: string;
readonly pageSize: string;
}
import {
buildListRouteStateQuery,
defineListRouteStateSchema,
mergeListRouteStateQuery,
parseListRouteState,
positiveIntegerListRouteField,
stringListRouteField,
} from '#/composables/useListRouteState';
interface WorkflowListRouteState {
categoryId: string;
@@ -32,100 +16,43 @@ interface WorkflowListRouteState {
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 parseState(
query: LocationQuery,
keys: WorkflowListQueryKeys,
): WorkflowListRouteState {
const pageSize = parsePositiveInteger(
readQueryValue(query, keys.pageSize),
DEFAULT_PAGE_SIZE,
);
return {
categoryId: readQueryValue(query, keys.categoryId),
keyword: readQueryValue(query, keys.keyword),
pageNumber: parsePositiveInteger(
readQueryValue(query, keys.pageNumber),
DEFAULT_PAGE_NUMBER,
),
pageSize: WORKFLOW_PAGE_SIZES.has(pageSize) ? pageSize : DEFAULT_PAGE_SIZE,
};
}
function buildStateQuery(
state: WorkflowListRouteState,
keys: WorkflowListQueryKeys,
): LocationQuery {
return {
...(state.pageNumber === DEFAULT_PAGE_NUMBER
? {}
: { [keys.pageNumber]: String(state.pageNumber) }),
...(state.pageSize === DEFAULT_PAGE_SIZE
? {}
: { [keys.pageSize]: String(state.pageSize) }),
...(state.categoryId ? { [keys.categoryId]: state.categoryId } : {}),
...(state.keyword ? { [keys.keyword]: state.keyword } : {}),
};
}
const workflowListRouteSchema =
defineListRouteStateSchema<WorkflowListRouteState>({
path: '/ai/workflow',
fields: {
categoryId: stringListRouteField(),
keyword: stringListRouteField(),
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
pageSize: positiveIntegerListRouteField({
allowedValues: [12, 18, 24],
defaultValue: 12,
}),
},
});
function parseWorkflowListRouteState(
query: LocationQuery,
): WorkflowListRouteState {
return parseState(query, LIST_QUERY_KEYS);
}
function parseWorkflowDesignReturnState(
query: LocationQuery,
): WorkflowListRouteState {
return parseState(query, RETURN_QUERY_KEYS);
return parseListRouteState(query, workflowListRouteSchema);
}
function buildWorkflowListRouteQuery(
state: WorkflowListRouteState,
): LocationQuery {
return buildStateQuery(state, LIST_QUERY_KEYS);
}
function buildWorkflowDesignReturnQuery(
state: WorkflowListRouteState,
): LocationQuery {
return buildStateQuery(state, RETURN_QUERY_KEYS);
): LocationQueryRaw {
return buildListRouteStateQuery(state, workflowListRouteSchema);
}
function mergeWorkflowListRouteQuery(
query: LocationQuery,
state: WorkflowListRouteState,
): LocationQuery {
const listQueryKeys = new Set<string>(Object.values(LIST_QUERY_KEYS));
const nextQuery = Object.fromEntries(
Object.entries(query).filter(([key]) => !listQueryKeys.has(key)),
) as LocationQuery;
return {
...nextQuery,
...buildWorkflowListRouteQuery(state),
};
): LocationQueryRaw {
return mergeListRouteStateQuery(query, state, workflowListRouteSchema);
}
export {
buildWorkflowDesignReturnQuery,
buildWorkflowListRouteQuery,
mergeWorkflowListRouteQuery,
parseWorkflowDesignReturnState,
parseWorkflowListRouteState,
workflowListRouteSchema,
};
export type { WorkflowListRouteState };