发布 v1.10 #5

Merged
czm merged 147 commits from develop into main 2026-08-20 11:36:27 +08:00
8 changed files with 386 additions and 9 deletions
Showing only changes of commit c2ed5a24a3 - Show all commits

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, ref } from 'vue'; import { computed, ref, watch } from 'vue';
import { ArrowDown, Search } from '@element-plus/icons-vue'; import { ArrowDown, Search } from '@element-plus/icons-vue';
import { import {
@@ -31,6 +31,10 @@ const props = defineProps({
type: Number, type: Number,
default: 3, default: 3,
}, },
initialValue: {
type: String,
default: '',
},
searchPlaceholder: { searchPlaceholder: {
type: String, type: String,
default: $t('common.searchPlaceholder'), default: $t('common.searchPlaceholder'),
@@ -39,7 +43,14 @@ const props = defineProps({
const emit = defineEmits(['search', 'buttonClick', 'reset']); const emit = defineEmits(['search', 'buttonClick', 'reset']);
const searchValue = ref(''); const searchValue = ref(props.initialValue);
watch(
() => props.initialValue,
(value) => {
searchValue.value = value;
},
);
const filterButtonsByPermission = (buttons) => { const filterButtonsByPermission = (buttons) => {
return buttons.filter((button) => { return buttons.filter((button) => {

View File

@@ -5,6 +5,40 @@ import { describe, expect, it, vi } from 'vitest';
import PageData from './PageData.vue'; import PageData from './PageData.vue';
describe('page data recovery', () => { describe('page data recovery', () => {
it('loads the restored page and query without requesting the first page', async () => {
const get = vi
.fn()
.mockResolvedValue({ data: { records: [], totalRow: 30 } });
const wrapper = mount(PageData, {
global: {
directives: { loading: {} },
},
props: {
initialPageNumber: 3,
initialQueryParams: { categoryId: '7', title: '月报' },
pageSize: 18,
pageUrl: '/page',
requestClient: { get },
},
});
await flushPromises();
expect(get).toHaveBeenCalledTimes(1);
expect(get).toHaveBeenCalledWith('/page', {
params: {
categoryId: '7',
pageNumber: 3,
pageSize: 18,
title: '月报',
},
});
expect((wrapper.vm as any).getPageState()).toEqual({
pageNumber: 3,
pageSize: 18,
});
});
it('shows a retry action after a page request fails', async () => { it('shows a retry action after a page request fails', async () => {
const get = vi const get = vi
.fn() .fn()

View File

@@ -13,27 +13,38 @@ interface PageDataProps {
pageSize?: number; pageSize?: number;
pageSizes?: number[]; pageSizes?: number[];
extraQueryParams?: Record<string, any>; extraQueryParams?: Record<string, any>;
initialPageNumber?: number;
initialQueryParams?: Record<string, any>;
requestClient?: any; requestClient?: any;
} }
type PageDataRow = Record<string, any>; type PageDataRow = Record<string, any>;
interface PageDataState {
pageNumber: number;
pageSize: number;
}
const props = withDefaults(defineProps<PageDataProps>(), { const props = withDefaults(defineProps<PageDataProps>(), {
pageSize: 10, pageSize: 10,
pageSizes: () => [10, 20, 50, 100], pageSizes: () => [10, 20, 50, 100],
extraQueryParams: () => ({}), extraQueryParams: () => ({}),
initialPageNumber: 1,
initialQueryParams: () => ({}),
requestClient: () => api, requestClient: () => api,
}); });
const emit = defineEmits<{
(e: 'stateChange', state: PageDataState): void;
}>();
// 响应式数据 // 响应式数据
const pageList = ref<PageDataRow[]>([]); const pageList = ref<PageDataRow[]>([]);
const loading = ref(false); const loading = ref(false);
const loadError = ref<unknown>(); const loadError = ref<unknown>();
const queryParams = ref<Record<string, any>>({}); const queryParams = ref<Record<string, any>>({ ...props.initialQueryParams });
let pageRequest = 0; let pageRequest = 0;
const pageInfo = reactive({ const pageInfo = reactive({
pageNumber: 1, pageNumber: Math.max(1, Math.trunc(props.initialPageNumber)),
pageSize: props.pageSize, pageSize: props.pageSize,
total: 0, total: 0,
}); });
@@ -80,10 +91,21 @@ const getPageList = async () => {
const handleSizeChange = (newSize: number) => { const handleSizeChange = (newSize: number) => {
pageInfo.pageSize = newSize; pageInfo.pageSize = newSize;
pageInfo.pageNumber = 1; // 重置到第一页 pageInfo.pageNumber = 1; // 重置到第一页
emitPageState();
}; };
const handleCurrentChange = (newPage: number) => { const handleCurrentChange = (newPage: number) => {
pageInfo.pageNumber = newPage; pageInfo.pageNumber = newPage;
emitPageState();
};
const getPageState = (): PageDataState => ({
pageNumber: pageInfo.pageNumber,
pageSize: pageInfo.pageSize,
});
const emitPageState = () => {
emit('stateChange', getPageState());
}; };
const patchRowById = ( const patchRowById = (
@@ -110,10 +132,12 @@ const setQuery = (newQueryParams: Record<string, any>) => {
pageInfo.pageNumber = 1; pageInfo.pageNumber = 1;
pageInfo.pageSize = props.pageSize; pageInfo.pageSize = props.pageSize;
queryParams.value = newQueryParams; queryParams.value = newQueryParams;
emitPageState();
}; };
// 暴露方法给父组件 // 暴露方法给父组件
defineExpose({ defineExpose({
getPageState,
reload: getPageList, reload: getPageList,
patchRowById, patchRowById,
setQuery, setQuery,
@@ -130,6 +154,7 @@ watch(
// 生命周期 // 生命周期
onMounted(() => { onMounted(() => {
emitPageState();
getPageList(); getPageList();
}); });
</script> </script>

View File

@@ -31,6 +31,20 @@ describe('detail return navigation', () => {
expect(listSource).toContain('tab: activeTab.value'); expect(listSource).toContain('tab: activeTab.value');
}); });
it('returns workflow design to the originating list page and filters', () => {
const detailSource = readViewSource('ai/workflow/WorkflowDesign.vue');
const listSource = readViewSource('ai/workflow/WorkflowList.vue');
expect(detailSource).toContain(
'parseWorkflowDesignReturnState(route.query)',
);
expect(detailSource).toContain(
'query: buildWorkflowListRouteQuery(listState)',
);
expect(listSource).toContain('buildWorkflowDesignReturnQuery');
expect(listSource).toContain('getPageState');
});
it('returns plugin tool editing to its plugin tool list with a safe fallback', () => { it('returns plugin tool editing to its plugin tool list with a safe fallback', () => {
const editSource = readViewSource('ai/plugin/PluginToolEdit.vue'); const editSource = readViewSource('ai/plugin/PluginToolEdit.vue');
const listSource = readViewSource('ai/plugin/PluginToolTable.vue'); const listSource = readViewSource('ai/plugin/PluginToolTable.vue');

View File

@@ -51,6 +51,10 @@ import {
isWorkflowDataEmpty, isWorkflowDataEmpty,
normalizeWorkflowStartNodes, normalizeWorkflowStartNodes,
} from '../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields'; } from '../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields';
import {
buildWorkflowListRouteQuery,
parseWorkflowDesignReturnState,
} from './workflow-list-route-state';
import '@tinyflow-ai/vue/dist/index.css'; import '@tinyflow-ai/vue/dist/index.css';
@@ -160,7 +164,11 @@ async function initializeWorkflow() {
} }
function backToWorkflowList() { function backToWorkflowList() {
router.replace({ path: '/ai/workflow' }); const listState = parseWorkflowDesignReturnState(route.query);
router.replace({
path: '/ai/workflow',
query: buildWorkflowListRouteQuery(listState),
});
} }
const WORKFLOW_DRAFT_WRITE_DELAY = 320; const WORKFLOW_DRAFT_WRITE_DELAY = 320;
let draftWriteTimer: ReturnType<typeof setTimeout> | undefined; let draftWriteTimer: ReturnType<typeof setTimeout> | undefined;

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FormInstance } from 'element-plus'; import type { FormInstance } from 'element-plus';
import type { WorkflowListRouteState } from './workflow-list-route-state';
import type { import type {
ActionButton, ActionButton,
CardPrimaryAction, CardPrimaryAction,
@@ -15,6 +17,7 @@ import {
onMounted, onMounted,
ref, ref,
} from 'vue'; } from 'vue';
import { useRoute } from 'vue-router';
import { useAccess } from '@easyflow/access'; import { useAccess } from '@easyflow/access';
import { EasyFlowFormModal } from '@easyflow/common-ui'; import { EasyFlowFormModal } from '@easyflow/common-ui';
@@ -75,6 +78,11 @@ import {
resolveAiResourceDisplayStatus, resolveAiResourceDisplayStatus,
} from '#/views/ai/shared/publish-status'; } from '#/views/ai/shared/publish-status';
import {
buildWorkflowDesignReturnQuery,
mergeWorkflowListRouteQuery,
parseWorkflowListRouteState,
} from './workflow-list-route-state';
import WorkflowModal from './WorkflowModal.vue'; import WorkflowModal from './WorkflowModal.vue';
const ElXMarkdown = defineAsyncComponent( const ElXMarkdown = defineAsyncComponent(
@@ -82,6 +90,8 @@ const ElXMarkdown = defineAsyncComponent(
); );
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD); const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
const route = useRoute();
const initialListState = parseWorkflowListRouteState(route.query);
interface FieldDefinition { interface FieldDefinition {
// 字段名称 // 字段名称
@@ -276,8 +286,15 @@ onMounted(() => {
}); });
const pageDataRef = ref(); const pageDataRef = ref();
const saveDialog = ref(); const saveDialog = ref();
const selectedCategoryId = ref<number | string>(''); const selectedCategoryId = ref<number | string>(initialListState.categoryId);
const searchKeyword = ref(''); const searchKeyword = ref(initialListState.keyword);
const currentPageNumber = ref(initialListState.pageNumber);
const currentPageSize = ref(initialListState.pageSize);
const suppressInitialCategoryChange = ref(Boolean(initialListState.categoryId));
const initialQueryParams = {
categoryId: initialListState.categoryId || undefined,
title: initialListState.keyword || undefined,
};
const dictStore = useDictStore(); const dictStore = useDictStore();
const headerButtons = [ const headerButtons = [
{ {
@@ -353,6 +370,32 @@ function applyFilters() {
title: searchKeyword.value || undefined, title: searchKeyword.value || undefined,
}); });
} }
function currentListState(): WorkflowListRouteState {
const pageState = pageDataRef.value?.getPageState?.();
return {
categoryId: String(selectedCategoryId.value || ''),
keyword: searchKeyword.value,
pageNumber: pageState?.pageNumber ?? currentPageNumber.value,
pageSize: pageState?.pageSize ?? currentPageSize.value,
};
}
function syncListRouteState() {
const target = {
path: '/ai/workflow',
query: mergeWorkflowListRouteQuery(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();
}
function handleSearch(keyword: string) { function handleSearch(keyword: string) {
searchKeyword.value = keyword; searchKeyword.value = keyword;
applyFilters(); applyFilters();
@@ -859,6 +902,7 @@ function toDesignPage(row: any) {
id: row.id, id: row.id,
pageKey: '/ai/workflow', pageKey: '/ai/workflow',
navTitle: resolveNavTitle(row), navTitle: resolveNavTitle(row),
...buildWorkflowDesignReturnQuery(currentListState()),
}, },
}); });
} }
@@ -979,7 +1023,17 @@ const formRules = computed(() => {
}); });
function changeCategory(category: any) { function changeCategory(category: any) {
selectedCategoryId.value = category?.id ?? ''; const categoryId = category?.id ?? '';
if (
suppressInitialCategoryChange.value &&
String(categoryId) === initialListState.categoryId
) {
suppressInitialCategoryChange.value = false;
selectedCategoryId.value = categoryId;
return;
}
suppressInitialCategoryChange.value = false;
selectedCategoryId.value = categoryId;
applyFilters(); applyFilters();
} }
function showControlDialog(item: any) { function showControlDialog(item: any) {
@@ -1045,6 +1099,17 @@ const getSideList = async () => {
}, },
...res.data, ...res.data,
]; ];
if (
selectedCategoryId.value &&
!res.data.some(
(category: any) =>
String(category.id) === String(selectedCategoryId.value),
)
) {
suppressInitialCategoryChange.value = false;
selectedCategoryId.value = '';
applyFilters();
}
} }
}; };
function handleHeaderButtonClick(data: any) { function handleHeaderButtonClick(data: any) {
@@ -1085,6 +1150,7 @@ function handleHeaderButtonClick(data: any) {
</ElDialog> </ElDialog>
<HeaderSearch <HeaderSearch
:buttons="headerButtons" :buttons="headerButtons"
:initial-value="searchKeyword"
@search="handleSearch" @search="handleSearch"
@button-click="handleHeaderButtonClick" @button-click="handleHeaderButtonClick"
/> />
@@ -1103,7 +1169,10 @@ function handleHeaderButtonClick(data: any) {
ref="pageDataRef" ref="pageDataRef"
page-url="/api/v1/workflow/page" page-url="/api/v1/workflow/page"
:page-sizes="[12, 18, 24]" :page-sizes="[12, 18, 24]"
:page-size="12" :page-size="initialListState.pageSize"
:initial-page-number="initialListState.pageNumber"
:initial-query-params="initialQueryParams"
@state-change="handlePageStateChange"
> >
<template #default="{ pageList }"> <template #default="{ pageList }">
<CardList <CardList

View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import {
buildWorkflowDesignReturnQuery,
buildWorkflowListRouteQuery,
mergeWorkflowListRouteQuery,
parseWorkflowDesignReturnState,
parseWorkflowListRouteState,
} from './workflow-list-route-state';
describe('workflow list route state', () => {
it('restores page, page size, category and keyword from list query', () => {
expect(
parseWorkflowListRouteState({
categoryId: '7',
keyword: '月报',
pageNumber: '3',
pageSize: '18',
}),
).toEqual({
categoryId: '7',
keyword: '月报',
pageNumber: 3,
pageSize: 18,
});
});
it('falls back safely for invalid pagination query', () => {
expect(
parseWorkflowListRouteState({
pageNumber: '-2',
pageSize: '999',
}),
).toEqual({
categoryId: '',
keyword: '',
pageNumber: 1,
pageSize: 12,
});
});
it('round trips list state through workflow design return query', () => {
const state = {
categoryId: '7',
keyword: '月报',
pageNumber: 3,
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: '月报',
pageNumber: '3',
pageSize: '18',
});
});
it('preserves unrelated query while removing cleared list state', () => {
expect(
mergeWorkflowListRouteQuery(
{
categoryId: '7',
devLogin: 'admin',
keyword: '旧关键字',
pageNumber: '5',
},
{
categoryId: '',
keyword: '',
pageNumber: 1,
pageSize: 12,
},
),
).toEqual({ devLogin: 'admin' });
});
});

View File

@@ -0,0 +1,131 @@
import type { LocationQuery } 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;
}
interface WorkflowListRouteState {
categoryId: string;
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 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 } : {}),
};
}
function parseWorkflowListRouteState(
query: LocationQuery,
): WorkflowListRouteState {
return parseState(query, LIST_QUERY_KEYS);
}
function parseWorkflowDesignReturnState(
query: LocationQuery,
): WorkflowListRouteState {
return parseState(query, RETURN_QUERY_KEYS);
}
function buildWorkflowListRouteQuery(
state: WorkflowListRouteState,
): LocationQuery {
return buildStateQuery(state, LIST_QUERY_KEYS);
}
function buildWorkflowDesignReturnQuery(
state: WorkflowListRouteState,
): LocationQuery {
return buildStateQuery(state, RETURN_QUERY_KEYS);
}
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),
};
}
export {
buildWorkflowDesignReturnQuery,
buildWorkflowListRouteQuery,
mergeWorkflowListRouteQuery,
parseWorkflowDesignReturnState,
parseWorkflowListRouteState,
};
export type { WorkflowListRouteState };