修复项目工程恢复默认顺序失败后的分页状态
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled

远端第一页读取成功后统一切换排序模式与分页游标
补充恢复失败保留本地分页及重试成功的回归测试
同步后台项目工程恢复默认顺序的技术约定
This commit is contained in:
2026-09-30 08:12:41 +01:00
parent 3540c5660f
commit 9f6d60e6fd
3 changed files with 72 additions and 7 deletions
@@ -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(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
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' })
@@ -81,8 +81,13 @@ export function AdminProjectSnapshotsPage({
const sortedItems = useRef<AdminProjectSnapshotEntry[] | null>(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) {
@@ -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。下载固定本次读到的清单,远端并发回收导致对象缺失则整体失败,不能静默遗漏。