diff --git a/easyflow-ui-admin/app/src/components/page/PageData.test.ts b/easyflow-ui-admin/app/src/components/page/PageData.test.ts index 6b10c86c..59670f8b 100644 --- a/easyflow-ui-admin/app/src/components/page/PageData.test.ts +++ b/easyflow-ui-admin/app/src/components/page/PageData.test.ts @@ -8,7 +8,7 @@ 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 } }); + .mockResolvedValue({ data: { records: [], totalRow: 50 } }); const wrapper = mount(PageData, { global: { directives: { loading: {} }, @@ -60,6 +60,37 @@ describe('page data recovery', () => { expect(wrapper.text()).not.toContain('数据加载失败,请重试'); }); + it('contracts an out-of-range page to the latest page with one retry', async () => { + const get = vi + .fn() + .mockResolvedValueOnce({ data: { records: [], totalRow: 21 } }) + .mockResolvedValueOnce({ + data: { records: [{ id: 'last-record' }], totalRow: 21 }, + }); + const wrapper = mount(PageData, { + global: { + directives: { loading: {} }, + }, + props: { + initialPageNumber: 4, + pageSize: 10, + pageUrl: '/page', + requestClient: { get }, + }, + }); + + await flushPromises(); + + expect(get).toHaveBeenCalledTimes(2); + expect(get.mock.calls.map((call) => call[1].params.pageNumber)).toEqual([ + 4, 3, + ]); + expect((wrapper.vm as any).getPageState()).toEqual({ + pageNumber: 3, + pageSize: 10, + }); + }); + it('coalesces repeated reloads while a page request is still running', async () => { let resolveInitialRequest: (value: { data: { records: never[]; totalRow: number }; @@ -90,4 +121,85 @@ describe('page data recovery', () => { expect(get).toHaveBeenCalledTimes(2); }); + + it('restores a mounted list to a new route state with one target request', async () => { + const get = vi + .fn() + .mockResolvedValue({ data: { records: [], totalRow: 100 } }); + const wrapper = mount(PageData, { + global: { + directives: { loading: {} }, + }, + props: { pageUrl: '/page', requestClient: { get } }, + }); + await flushPromises(); + + expect( + (wrapper.vm as any).restoreState({ + pageNumber: 4, + pageSize: 20, + queryParams: { title: '月报' }, + }), + ).toBe(true); + await flushPromises(); + + expect(get).toHaveBeenCalledTimes(2); + expect(get).toHaveBeenLastCalledWith('/page', { + params: { pageNumber: 4, pageSize: 20, title: '月报' }, + }); + }); + + it('does not request again when the restored state is unchanged', async () => { + const get = vi + .fn() + .mockResolvedValue({ data: { records: [], totalRow: 50 } }); + const wrapper = mount(PageData, { + global: { + directives: { loading: {} }, + }, + props: { + initialPageNumber: 3, + initialQueryParams: { categoryId: undefined, title: '月报' }, + pageSize: 20, + pageUrl: '/page', + requestClient: { get }, + }, + }); + await flushPromises(); + + expect( + (wrapper.vm as any).restoreState({ + pageNumber: 3, + pageSize: 20, + queryParams: { title: '月报' }, + }), + ).toBe(false); + await flushPromises(); + + expect(get).toHaveBeenCalledTimes(1); + }); + + it('detaches query state from the caller reactive object', async () => { + const get = vi + .fn() + .mockResolvedValue({ data: { records: [], totalRow: 0 } }); + const wrapper = mount(PageData, { + global: { + directives: { loading: {} }, + }, + props: { pageUrl: '/page', requestClient: { get } }, + }); + await flushPromises(); + + const query = { title: '月报' }; + (wrapper.vm as any).setQuery(query); + await flushPromises(); + query.title = '未提交草稿'; + await flushPromises(); + + expect(get).toHaveBeenCalledTimes(2); + expect(get).toHaveBeenLastCalledWith('/page', { + params: { pageNumber: 1, pageSize: 10, title: '月报' }, + }); + }); }); diff --git a/easyflow-ui-admin/app/src/components/page/PageData.vue b/easyflow-ui-admin/app/src/components/page/PageData.vue index 9953b2fa..74588d0f 100644 --- a/easyflow-ui-admin/app/src/components/page/PageData.vue +++ b/easyflow-ui-admin/app/src/components/page/PageData.vue @@ -24,6 +24,10 @@ interface PageDataState { pageSize: number; } +interface PageDataRestoreState extends PageDataState { + queryParams?: Record; +} + interface PageDataReloadOptions { silent?: boolean; } @@ -49,7 +53,9 @@ const emit = defineEmits<{ const pageList = ref([]); const loading = ref(false); const loadError = ref(); -const queryParams = ref>({ ...props.initialQueryParams }); +const queryParams = ref>( + normalizeQueryParams(props.initialQueryParams), +); let activePageRequest: null | Promise = null; let pendingPageRequest: null | PageDataRequest = null; let pageRequestVersion = 0; @@ -80,8 +86,16 @@ const loadPageListOnce = async (request: PageDataRequest) => { ...queryParams.value, }); if (request.version === pageRequestVersion) { + const rawTotal = Number(res.data?.totalRow || 0); + const total = Number.isFinite(rawTotal) ? Math.max(0, rawTotal) : 0; + const lastPage = Math.max(1, Math.ceil(total / pageInfo.pageSize)); + pageInfo.total = total; + if (pageInfo.pageNumber > lastPage) { + pageInfo.pageNumber = lastPage; + emitPageState(); + return; + } pageList.value = res.data?.records || []; - pageInfo.total = res.data?.totalRow || 0; } } catch (error) { if (request.version === pageRequestVersion) { @@ -151,6 +165,24 @@ const getPageState = (): PageDataState => ({ pageSize: pageInfo.pageSize, }); +function normalizeQueryParams(value: Record = {}) { + return Object.fromEntries( + Object.entries(value).filter(([, item]) => item !== undefined), + ); +} + +function queryParamsEqual( + left: Record, + right: Record, +) { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every((key) => Object.is(left[key], right[key])) + ); +} + const emitPageState = () => { emit('stateChange', getPageState()); }; @@ -178,15 +210,37 @@ const patchRowById = ( const setQuery = (newQueryParams: Record) => { pageInfo.pageNumber = 1; pageInfo.pageSize = props.pageSize; - queryParams.value = newQueryParams; + queryParams.value = normalizeQueryParams(newQueryParams); emitPageState(); }; +/** + * 将已挂载的列表恢复为路由指定状态,同一状态不会重复发起请求。 + */ +const restoreState = (state: PageDataRestoreState) => { + const nextPageNumber = Math.max(1, Math.trunc(state.pageNumber)); + const nextPageSize = Math.max(1, Math.trunc(state.pageSize)); + const nextQueryParams = normalizeQueryParams(state.queryParams); + if ( + pageInfo.pageNumber === nextPageNumber && + pageInfo.pageSize === nextPageSize && + queryParamsEqual(queryParams.value, nextQueryParams) + ) { + return false; + } + pageInfo.pageNumber = nextPageNumber; + pageInfo.pageSize = nextPageSize; + queryParams.value = nextQueryParams; + emitPageState(); + return true; +}; + // 暴露方法给父组件 defineExpose({ getPageState, reload, patchRowById, + restoreState, setQuery, }); diff --git a/easyflow-ui-admin/app/src/composables/useListRouteState.test.ts b/easyflow-ui-admin/app/src/composables/useListRouteState.test.ts new file mode 100644 index 00000000..fcfe3099 --- /dev/null +++ b/easyflow-ui-admin/app/src/composables/useListRouteState.test.ts @@ -0,0 +1,129 @@ +import { nextTick, reactive } from 'vue'; +import { createMemoryHistory, createRouter } from 'vue-router'; + +import { describe, expect, it, vi } from 'vitest'; + +import { + buildListRouteFullPath, + buildListRouteStateQuery, + defineListRouteStateSchema, + enumListRouteField, + mergeListRouteStateQuery, + parseListRouteState, + positiveIntegerListRouteField, + stringListRouteField, + watchListRouteState, +} from './useListRouteState'; + +interface TestListState { + categoryId: string; + keyword: string; + pageNumber: number; + pageSize: number; + status: 'all' | 'enabled'; +} + +const schema = defineListRouteStateSchema({ + path: '/items', + fields: { + categoryId: stringListRouteField(), + keyword: stringListRouteField({ trim: true }), + pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }), + pageSize: positiveIntegerListRouteField({ + allowedValues: [10, 20], + defaultValue: 10, + }), + status: enumListRouteField(['all', 'enabled'], 'all'), + }, +}); + +describe('list route state', () => { + it('parses valid state and safely falls back for invalid values', () => { + expect( + parseListRouteState( + { + categoryId: '7', + keyword: ' 月报 ', + pageNumber: '-2', + pageSize: '999', + status: 'unknown', + }, + schema, + ), + ).toEqual({ + categoryId: '7', + keyword: '月报', + pageNumber: 1, + pageSize: 10, + status: 'all', + }); + }); + + it('omits defaults and preserves query fields owned by other features', () => { + const state: TestListState = { + categoryId: '', + keyword: '', + pageNumber: 1, + pageSize: 10, + status: 'all', + }; + + expect(buildListRouteStateQuery(state, schema)).toEqual({}); + expect( + mergeListRouteStateQuery( + { + devLogin: 'admin', + keyword: '旧关键字', + pageNumber: '4', + }, + state, + schema, + ), + ).toEqual({ devLogin: 'admin' }); + }); + + it('builds the exact current list full path', () => { + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ component: {}, path: '/items' }], + }); + + const state: TestListState = { + categoryId: '7', + keyword: '月报', + pageNumber: 3, + pageSize: 20, + status: 'enabled', + }; + + expect( + buildListRouteFullPath(router, { devLogin: 'admin' }, state, schema), + ).toBe( + '/items?devLogin=admin&categoryId=7&keyword=%E6%9C%88%E6%8A%A5&pageNumber=3&pageSize=20&status=enabled', + ); + }); + + it('emits parsed state when the current list full path changes', async () => { + const route = reactive({ + fullPath: '/items', + path: '/items', + query: {}, + }) as any; + const onChange = vi.fn(); + const stop = watchListRouteState(route, schema, onChange); + + route.query = { keyword: '月报', pageNumber: '4', pageSize: '20' }; + route.fullPath = '/items?keyword=月报&pageNumber=4&pageSize=20'; + await nextTick(); + + expect(onChange).toHaveBeenCalledOnce(); + expect(onChange).toHaveBeenCalledWith({ + categoryId: '', + keyword: '月报', + pageNumber: 4, + pageSize: 20, + status: 'all', + }); + stop(); + }); +}); diff --git a/easyflow-ui-admin/app/src/composables/useListRouteState.ts b/easyflow-ui-admin/app/src/composables/useListRouteState.ts new file mode 100644 index 00000000..5bd4cc33 --- /dev/null +++ b/easyflow-ui-admin/app/src/composables/useListRouteState.ts @@ -0,0 +1,214 @@ +import type { + LocationQuery, + LocationQueryRaw, + RouteLocationNormalizedLoaded, + Router, +} from 'vue-router'; + +import { watch } from 'vue'; + +type ListRoutePrimitive = number | string; + +interface ListRouteStateField { + defaultValue: Value; + parse: (value: string) => Value; + queryKey?: string; + serialize: (value: Value) => string | undefined; +} + +type ListRouteStateSchema = { + fields: { + [Key in keyof State]: ListRouteStateField< + Extract + >; + }; + path: string; +}; + +interface StringListRouteFieldOptions { + defaultValue?: string; + queryKey?: string; + trim?: boolean; +} + +interface PositiveIntegerListRouteFieldOptions { + allowedValues?: readonly number[]; + defaultValue: number; + queryKey?: string; +} + +function readListRouteQueryValue(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 stringListRouteField( + options: StringListRouteFieldOptions = {}, +): ListRouteStateField { + const defaultValue = options.defaultValue ?? ''; + const normalize = (value: string) => + options.trim ? value.trim() : String(value || ''); + return { + defaultValue, + parse(value) { + return normalize(value) || defaultValue; + }, + queryKey: options.queryKey, + serialize(value) { + const normalized = normalize(value) || defaultValue; + return normalized === defaultValue ? undefined : normalized; + }, + }; +} + +function enumListRouteField< + const AllowedValues extends readonly string[], + const DefaultValue extends string, +>( + allowedValues: AllowedValues, + defaultValue: DefaultValue, + queryKey?: string, +): ListRouteStateField { + const allowed = new Set(allowedValues); + return { + defaultValue, + parse(value) { + return allowed.has(value) + ? (value as AllowedValues[number]) + : defaultValue; + }, + queryKey, + serialize(value) { + return value === defaultValue || !allowed.has(value) ? undefined : value; + }, + }; +} + +function positiveIntegerListRouteField( + options: PositiveIntegerListRouteFieldOptions, +): ListRouteStateField { + const allowedValues = options.allowedValues + ? new Set(options.allowedValues) + : undefined; + const isValid = (value: number) => + Number.isSafeInteger(value) && + value > 0 && + (!allowedValues || allowedValues.has(value)); + return { + defaultValue: options.defaultValue, + parse(value) { + if (!/^\d+$/.test(value)) return options.defaultValue; + const parsed = Number(value); + return isValid(parsed) ? parsed : options.defaultValue; + }, + queryKey: options.queryKey, + serialize(value) { + return value === options.defaultValue || !isValid(value) + ? undefined + : String(value); + }, + }; +} + +function defineListRouteStateSchema( + schema: ListRouteStateSchema, +): ListRouteStateSchema { + return schema; +} + +function listRouteFieldEntries( + schema: ListRouteStateSchema, +) { + return (Object.keys(schema.fields) as Array).map((key) => ({ + field: schema.fields[key], + key, + queryKey: schema.fields[key].queryKey || String(key), + })); +} + +function parseListRouteState( + query: LocationQuery, + schema: ListRouteStateSchema, +): State { + const state = {} as State; + for (const { field, key, queryKey } of listRouteFieldEntries(schema)) { + state[key] = field.parse( + readListRouteQueryValue(query, queryKey), + ) as State[typeof key]; + } + return state; +} + +function buildListRouteStateQuery( + state: State, + schema: ListRouteStateSchema, +): LocationQueryRaw { + const query: LocationQueryRaw = {}; + for (const { field, key, queryKey } of listRouteFieldEntries(schema)) { + const value = field.serialize( + state[key] as Extract, + ); + if (value !== undefined) query[queryKey] = value; + } + return query; +} + +function mergeListRouteStateQuery( + query: LocationQuery, + state: State, + schema: ListRouteStateSchema, +): LocationQueryRaw { + const ownedKeys = new Set( + listRouteFieldEntries(schema).map(({ queryKey }) => queryKey), + ); + return { + ...Object.fromEntries( + Object.entries(query).filter(([key]) => !ownedKeys.has(key)), + ), + ...buildListRouteStateQuery(state, schema), + }; +} + +function buildListRouteFullPath( + router: Router, + query: LocationQuery, + state: State, + schema: ListRouteStateSchema, +): string { + return router.resolve({ + path: schema.path, + query: mergeListRouteStateQuery(query, state, schema), + }).fullPath; +} + +function watchListRouteState( + route: RouteLocationNormalizedLoaded, + schema: ListRouteStateSchema, + onChange: (state: State) => void, +) { + return watch( + () => route.fullPath, + () => { + if (route.path !== schema.path) return; + onChange(parseListRouteState(route.query, schema)); + }, + { flush: 'post' }, + ); +} + +export { + buildListRouteFullPath, + buildListRouteStateQuery, + defineListRouteStateSchema, + enumListRouteField, + mergeListRouteStateQuery, + parseListRouteState, + positiveIntegerListRouteField, + readListRouteQueryValue, + stringListRouteField, + watchListRouteState, +}; +export type { ListRouteStateField, ListRouteStateSchema }; diff --git a/easyflow-ui-admin/app/src/router/__tests__/detail-return-navigation.test.ts b/easyflow-ui-admin/app/src/router/__tests__/detail-return-navigation.test.ts index 8c340cca..fb90bae3 100644 --- a/easyflow-ui-admin/app/src/router/__tests__/detail-return-navigation.test.ts +++ b/easyflow-ui-admin/app/src/router/__tests__/detail-return-navigation.test.ts @@ -1,78 +1,59 @@ -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; function readViewSource(relativePath: string) { - return readFileSync(resolve('app/src/views', relativePath), 'utf8'); + const workspaceViews = resolve('app/src/views'); + const viewsRoot = existsSync(workspaceViews) + ? workspaceViews + : resolve('src/views'); + return readFileSync(resolve(viewsRoot, relativePath), 'utf8'); } describe('detail return navigation', () => { it.each([ - ['system/sysFeedback/sysFeedbackDetail.vue', '/sys/sysFeedback'], - ['system/sysJob/SysJobLogList.vue', '/sys/sysJob'], - ['ai/workflow/WorkflowDesign.vue', '/ai/workflow'], - ['ai/workflow/components/WorkflowChatPage.vue', '/ai/workflow'], - ['ai/workflow/execute/WorkflowExecResultList.vue', '/ai/workflow'], - ])('returns %s to its fixed parent list', (relativePath, parentPath) => { - const source = readViewSource(relativePath); + [ + 'ai/documentCollection/DocumentCollection.vue', + 'ai/documentCollection/Document.vue', + ], + ['ai/skill/SkillList.vue', 'ai/skill/SkillDetail.vue'], + ['ai/agents/AgentList.vue', 'ai/agents/AgentDesigner.vue'], + ['ai/bots/index.vue', 'ai/bots/pages/setting/index.vue'], + [ + 'system/approval/ApprovalManage.vue', + 'system/approval/ApprovalDetail.vue', + ], + ['system/sysJob/SysJobList.vue', 'system/sysJob/SysJobLogList.vue'], + [ + 'system/sysFeedback/sysFeedbackList.vue', + 'system/sysFeedback/sysFeedbackDetail.vue', + ], + ['ai/workflow/WorkflowList.vue', 'ai/workflow/WorkflowDesign.vue'], + [ + 'ai/workflow/WorkflowList.vue', + 'ai/workflow/components/WorkflowChatPage.vue', + ], + ['ai/plugin/Plugin.vue', 'ai/plugin/PluginTools.vue'], + ['ai/plugin/PluginToolTable.vue', 'ai/plugin/PluginToolEdit.vue'], + ])( + 'passes an exact return target from %s and validates it in %s', + (listPath, detailPath) => { + expect(readViewSource(listPath)).toContain('withListReturnTo'); + expect(readViewSource(detailPath)).toContain('navigateBackToList'); + }, + ); - expect(source).not.toMatch(/router\.(?:back|go)\s*\(/); - expect(source).toContain(`path: '${parentPath}'`); - }); - - it('returns approval details to the originating approval tab', () => { - const detailSource = readViewSource('system/approval/ApprovalDetail.vue'); - const listSource = readViewSource('system/approval/ApprovalManage.vue'); - - expect(detailSource).not.toMatch(/router\.(?:back|go)\s*\(/); - expect(detailSource).toContain("path: '/sys/approval'"); - expect(detailSource).toContain('query: { tab }'); - 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', () => { - 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', () => { - const stepSource = readViewSource( - 'ai/workflow/execute/WorkflowExecStepList.vue', - ); + it('preserves the nested execution-record list before opening a step', () => { const recordSource = readViewSource( 'ai/workflow/execute/WorkflowExecResultList.vue', ); + const stepSource = readViewSource( + 'ai/workflow/execute/WorkflowExecStepList.vue', + ); - expect(stepSource).not.toMatch(/router\.(?:back|go)\s*\(/); - expect(stepSource).toContain("name: 'ExecRecord'"); - expect(stepSource).toContain('query: workflowId ? { workflowId } : {}'); - expect(recordSource).toContain('workflowId: $route.query.workflowId'); + expect(recordSource).toContain('withListReturnTo(currentListFullPath())'); + expect(stepSource).toContain('navigateBackToList'); + expect(stepSource).toContain("['/ai/workflow/executeRecords']"); }); }); diff --git a/easyflow-ui-admin/app/src/router/list-return-context.test.ts b/easyflow-ui-admin/app/src/router/list-return-context.test.ts new file mode 100644 index 00000000..c61593b6 --- /dev/null +++ b/easyflow-ui-admin/app/src/router/list-return-context.test.ts @@ -0,0 +1,54 @@ +import { createMemoryHistory, createRouter } from 'vue-router'; + +import { describe, expect, it } from 'vitest'; + +import { + readListReturnTo, + resolveListReturnPath, + withListReturnTo, +} from './list-return-context'; + +function createTestRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { component: {}, path: '/items' }, + { component: {}, path: '/other' }, + ], + }); +} + +describe('list return context', () => { + it('round trips an exact internal list URL', () => { + const router = createTestRouter(); + const query = withListReturnTo('/items?pageNumber=4&keyword=月报', { + id: '7', + }); + + expect(readListReturnTo(query as any)).toBe( + '/items?pageNumber=4&keyword=月报', + ); + expect( + resolveListReturnPath(router, query as any, ['/items'], '/items'), + ).toBe('/items?pageNumber=4&keyword=月报'); + }); + + it.each([ + 'https://example.com/items?pageNumber=4', + '//example.com/items?pageNumber=4', + '/other?pageNumber=4', + '/missing?pageNumber=4', + '/items#unsafe', + String.raw`/items\unsafe`, + `/items?value=${'x'.repeat(4096)}`, + ])('rejects an unsafe or unrelated return target: %s', (returnTo) => { + expect( + resolveListReturnPath( + createTestRouter(), + { returnTo }, + ['/items'], + '/items', + ), + ).toBe('/items'); + }); +}); diff --git a/easyflow-ui-admin/app/src/router/list-return-context.ts b/easyflow-ui-admin/app/src/router/list-return-context.ts new file mode 100644 index 00000000..6fa082a0 --- /dev/null +++ b/easyflow-ui-admin/app/src/router/list-return-context.ts @@ -0,0 +1,105 @@ +import type { LocationQuery, LocationQueryRaw, Router } from 'vue-router'; + +const LIST_RETURN_QUERY_KEY = 'returnTo'; +const MAX_LIST_RETURN_LENGTH = 4096; + +function hasUnsafeReturnCharacter(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0) || 0; + if (character === '\\' || codePoint <= 31 || codePoint === 127) { + return true; + } + } + return false; +} + +function readListReturnTo(query: LocationQuery): string { + const value = query[LIST_RETURN_QUERY_KEY]; + const normalized = Array.isArray(value) ? value[0] : value; + return normalized === null || normalized === undefined + ? '' + : String(normalized); +} + +function withListReturnTo( + returnTo: string, + query: LocationQueryRaw = {}, +): LocationQueryRaw { + return { + ...query, + [LIST_RETURN_QUERY_KEY]: returnTo, + }; +} + +function resolveListReturnPath( + router: Router, + query: LocationQuery, + allowedListPaths: readonly string[], + fallbackPath: string, +): string { + const raw = readListReturnTo(query); + if ( + !raw || + raw.length > MAX_LIST_RETURN_LENGTH || + !raw.startsWith('/') || + raw.startsWith('//') || + hasUnsafeReturnCharacter(raw) + ) { + return fallbackPath; + } + + try { + const parsed = new URL(raw, 'https://easyflow.local'); + if (parsed.origin !== 'https://easyflow.local' || parsed.hash) { + return fallbackPath; + } + if (!allowedListPaths.includes(parsed.pathname)) { + return fallbackPath; + } + const resolved = router.resolve(raw); + return resolved.matched.length > 0 ? resolved.fullPath : fallbackPath; + } catch { + return fallbackPath; + } +} + +function resolveHistoryBackPath(router: Router): string { + if (typeof window === 'undefined') return ''; + const raw = window.history.state?.back; + if (typeof raw !== 'string' || !raw) return ''; + const hashIndex = raw.indexOf('#'); + const candidate = hashIndex === -1 ? raw : raw.slice(hashIndex + 1); + if (!candidate.startsWith('/')) return ''; + try { + return router.resolve(candidate).fullPath; + } catch { + return ''; + } +} + +async function navigateBackToList( + router: Router, + query: LocationQuery, + allowedListPaths: readonly string[], + fallbackPath: string, +): Promise { + const target = resolveListReturnPath( + router, + query, + allowedListPaths, + fallbackPath, + ); + if (resolveHistoryBackPath(router) === target) { + router.back(); + return; + } + await router.replace(target); +} + +export { + LIST_RETURN_QUERY_KEY, + navigateBackToList, + readListReturnTo, + resolveListReturnPath, + withListReturnTo, +}; diff --git a/easyflow-ui-admin/app/src/views/ai/agents/AgentDesigner.vue b/easyflow-ui-admin/app/src/views/ai/agents/AgentDesigner.vue index b32b671b..d6b40ae4 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/AgentDesigner.vue +++ b/easyflow-ui-admin/app/src/views/ai/agents/AgentDesigner.vue @@ -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, + ); } diff --git a/easyflow-ui-admin/app/src/views/ai/agents/AgentList.vue b/easyflow-ui-admin/app/src/views/ai/agents/AgentList.vue index 0862775d..4fd46d09 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/AgentList.vue +++ b/easyflow-ui-admin/app/src/views/ai/agents/AgentList.vue @@ -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([]); +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) {
@@ -341,6 +440,7 @@ async function handleDeleteAction(row: AgentInfo) { label-key="categoryName" value-key="id" :menus="sideList" + :default-selected="selectedCategoryId" @change="changeCategory" />
@@ -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" >