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

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

View File

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