From 9f6d60e6fd3baacca6993b728ed7b042f69c2f70 Mon Sep 17 00:00:00 2001 From: lhk Date: Wed, 30 Sep 2026 08:12:41 +0100 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A1=B9=E7=9B=AE=E5=B7=A5?= =?UTF-8?q?=E7=A8=8B=E6=81=A2=E5=A4=8D=E9=BB=98=E8=AE=A4=E9=A1=BA=E5=BA=8F?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=90=8E=E7=9A=84=E5=88=86=E9=A1=B5=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 远端第一页读取成功后统一切换排序模式与分页游标 补充恢复失败保留本地分页及重试成功的回归测试 同步后台项目工程恢复默认顺序的技术约定 --- .../pages/AdminProjectSnapshotsPage.test.tsx | 57 +++++++++++++++++++ .../src/pages/AdminProjectSnapshotsPage.tsx | 20 +++++-- ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx index 67e384e19..1e185e9a5 100644 --- a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx +++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx @@ -145,6 +145,63 @@ test('全量读取后按同步时间和身份排序,本地分页并支持刷 ); }); +test('恢复默认顺序失败保留排序分页,重试成功后切换远端第一页及游标', async () => { + const projects = Array.from({ length: 45 }, (_, index) => ({ + ...entry, + projectId: `sorted-${index}`, + projectName: `排序项目 ${index}`, + syncedAtMs: entry.syncedAtMs - index, + })); + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: null }) + .mockResolvedValueOnce({ items: projects, nextCursor: null }) + .mockRejectedValueOnce(new Error('恢复失败')) + .mockResolvedValueOnce({ + items: [{ ...entry, projectName: '默认第一页' }], + nextCursor: 'default-next', + }) + .mockResolvedValueOnce({ + items: [{ ...entry, projectName: '默认第二页' }], + nextCursor: null, + }); + render(); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + await screen.findByText('排序项目 0'); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' })); + await screen.findByText('恢复失败'); + expect(screen.getByText('排序项目 20')).toBeTruthy(); + expect(screen.getByRole('navigation').textContent).toContain('第 2 页'); + expect(screen.getByRole('navigation').textContent).toContain('共 45 个'); + expect(screen.getByRole('button', { name: '恢复默认顺序' })).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + expect(screen.getByText('排序项目 40')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '上一页' })); + expect(screen.getByText('排序项目 20')).toBeTruthy(); + expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(3); + + fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' })); + await screen.findByText('默认第一页'); + expect(screen.getByRole('navigation').textContent).toContain('第 1 页'); + expect(screen.getByRole('button', { name: '按同步时间排序' })).toBeTruthy(); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 4, + 'token', + { cursor: null, limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + await screen.findByText('默认第二页'); + expect(screen.getByRole('navigation').textContent).toContain('第 2 页'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 5, + 'token', + { cursor: 'default-next', limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); +}); + test('全量排序中途失败不发布部分结果,重试刷新失败保留完整排序', async () => { vi.mocked(listAdminProjectSnapshots) .mockResolvedValueOnce({ items: [entry], nextCursor: 'original-next' }) diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx index a99faafc2..b8c6d7b3a 100644 --- a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx +++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx @@ -81,8 +81,13 @@ export function AdminProjectSnapshotsPage({ const sortedItems = useRef(null); const loadPage = useCallback( - async (cursor: string | null, limit: number, page: number) => { - if (sortedItems.current !== null) { + async ( + cursor: string | null, + limit: number, + page: number, + restoreDefault = false, + ) => { + if (!restoreDefault && sortedItems.current !== null) { setItems(sortedItems.current.slice((page - 1) * limit, page * limit)); setPageIndex(page); setHasLoaded(true); @@ -101,6 +106,12 @@ export function AdminProjectSnapshotsPage({ controller.signal, ); if (controller.signal.aborted) return; + // 远端第一页读取成功后再切换模式,失败时保留完整排序和分页。 + if (restoreDefault) { + sortedItems.current = null; + setIsSorted(false); + pageCursors.current = [null]; + } setItems(response.items); setNextCursor(response.nextCursor); setPageIndex(page); @@ -278,10 +289,7 @@ export function AdminProjectSnapshotsPage({ } function restoreDefaultOrder() { - sortedItems.current = null; - setIsSorted(false); - pageCursors.current = [null]; - void loadPage(null, pageSize, 1); + void loadPage(null, pageSize, 1, true); } async function downloadProject(entry: AdminProjectSnapshotEntry) { diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 518935205..b77cd1426 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1781,7 +1781,7 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面 - 清单补充可选 `projectName` 与 `pendingFiles`:名称来自本地 manifest;`pendingFiles` 是本轮失败、延后、并发变动与非策略排除的跳过文件数量。`0` 表示扫描范围已同步;大文件等被跳过不能标成完整。旧清单字段缺失表示完整性未知,维持可读取兼容,不反写旧清单。 - 项目名称或完整性发生变化时,即使文件内容没有差异也要提交新清单;本机索引记录上次已提交的这两个字段。实际客户端下一次正常同步可补齐历史清单元数据;后台只读访问不迁移旧清单。缺失 `pendingFiles` 不能默认成 0,临时跳过原因消失后允许无文件上传的 `partial → ready` 转换。 - 后台增加“项目工程”入口,仅 owner 及拥有 `project-snapshots` 页签权限的管理员可访问。`GET /admin/api/project-snapshots?cursor=&limit=20` 读取私有 OSS 清单并返回 `{items,nextCursor}`;单页最多 100 个,游标由服务端校验,目录与清单读取有界。条目为 `{userId,projectId,projectName,syncRevision,syncedAtMs,fileCount,totalBytes,status}`,状态为 `ready / partial / unverified`,名称缺失时显示 projectId。 -- 后台“按同步时间排序”是主动读取当前渠道全量项目的临时展示操作:从首游标开始,以每次 100 条沿 `nextCursor` 顺序读取,只有全部读取成功后才切换为 `syncedAtMs` 降序、`userId` 字符串升序、`projectId` 字符串升序;不能只排序当前远端页。成功后回到第一页,20/50/100 条分页及上一页/下一页复用浏览器中的完整列表,不再请求远端;“刷新”重新读取全量并回到第一页,“恢复默认顺序”回到远端目录分页第一页。切换渠道、令牌或离开页面销毁临时结果并取消在途读取,不跨会话持久化。 +- 后台“按同步时间排序”是主动读取当前渠道全量项目的临时展示操作:从首游标开始,以每次 100 条沿 `nextCursor` 顺序读取,只有全部读取成功后才切换为 `syncedAtMs` 降序、`userId` 字符串升序、`projectId` 字符串升序;不能只排序当前远端页。成功后回到第一页,20/50/100 条分页及上一页/下一页复用浏览器中的完整列表,不再请求远端;“刷新”重新读取全量并回到第一页,“恢复默认顺序”在远端目录第一页读取成功后统一切换列表、排序模式和分页游标;读取失败保留原排序集合、当前页码和本地分页,支持重试。切换渠道、令牌或离开页面销毁临时结果并取消在途读取,不跨会话持久化。 - 全量读取展示已取得项目数并允许取消;单次最多 200 个远端页、10,000 个唯一项目、60 秒,重复游标或超限明确报错。读取失败、鉴权失败、超时或取消保留原来的列表和分页,不能发布部分数据作为排序完成的结果。并发同步期间重复项目按用户/项目身份合并,保留同步时间较新的一份,相同时间取后读到的清单;该列表是遍历期间读到的数据集合,不承诺 OSS 全局事务快照,后续同步由管理员刷新读取。仍由原接口实施渠道和页签鉴权,不新增 OSS 索引、数据库表、API 参数或客户端迁移。 - 排序验收覆盖远端多页与空中间页、时间相同的身份排序、本地翻页/页容量、刷新与恢复默认、失败/取消/超时/限额/重复游标、并发重复身份,以及渠道/令牌切换和卸载后的旧响应隔离;证据入口为 `apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx`、后台类型检查和编码/文档索引检查。 - `GET /admin/api/project-snapshots/{userId}/{projectId}/download` 只读取该用户/项目的固定清单与其引用对象,返回 `application/zip` 附件。ZIP 中路径直接使用原始相对路径,不包含 userId、摘要目录或 OSS 前缀;名称使用经过安全处理的项目名和 revision。下载固定本次读到的清单,远端并发回收导致对象缺失则整体失败,不能静默遗漏。