feat: 统一恢复管理端列表上下文
- 统一列表路由状态、分页初始化与安全返回契约 - 接入知识库、工作流、插件、审批、Skill、Agent、Bot、反馈和定时任务链路 - 补齐公共能力与关键返回路径自动化测试
This commit is contained in:
@@ -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: '月报' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,10 @@ interface PageDataState {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
interface PageDataRestoreState extends PageDataState {
|
||||
queryParams?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface PageDataReloadOptions {
|
||||
silent?: boolean;
|
||||
}
|
||||
@@ -49,7 +53,9 @@ const emit = defineEmits<{
|
||||
const pageList = ref<PageDataRow[]>([]);
|
||||
const loading = ref(false);
|
||||
const loadError = ref<unknown>();
|
||||
const queryParams = ref<Record<string, any>>({ ...props.initialQueryParams });
|
||||
const queryParams = ref<Record<string, any>>(
|
||||
normalizeQueryParams(props.initialQueryParams),
|
||||
);
|
||||
let activePageRequest: null | Promise<void> = 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<string, any> = {}) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter(([, item]) => item !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
function queryParamsEqual(
|
||||
left: Record<string, any>,
|
||||
right: Record<string, any>,
|
||||
) {
|
||||
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<string, any>) => {
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user