From 3540c5660fd294b70e054810b5384a2855908df5 Mon Sep 17 00:00:00 2001 From: lhk Date: Wed, 30 Sep 2026 07:50:02 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=E4=B8=BA=E5=90=8E=E5=8F=B0=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=B7=A5=E7=A8=8B=E6=96=B0=E5=A2=9E=E6=8C=89=E9=9C=80?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E5=90=8C=E6=AD=A5=E6=97=B6=E9=97=B4=E6=8E=92?= =?UTF-8?q?=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增按钮读取所选渠道全部项目并按同步时间降序、用户与项目 ID 升序展示 复用本地分页并支持全量刷新、恢复默认顺序、进度提示与取消读取 为全量读取增加超时、数量和游标边界,失败时保留原结果 补充三项关键交互测试并同步技术方案、运维说明与共享记忆 --- .../pages/AdminProjectSnapshotsPage.test.tsx | 183 +++++++++++++++++- .../src/pages/AdminProjectSnapshotsPage.tsx | 160 ++++++++++++++- .../shared-memory/decision-log.md | 2 +- ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 3 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 + 5 files changed, 346 insertions(+), 4 deletions(-) diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx index 7916e60e3..67e384e19 100644 --- a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx +++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx @@ -16,7 +16,10 @@ import { getAdminProjectSnapshotChannels, listAdminProjectSnapshots, } from '../api/adminApiClient'; -import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes'; +import type { + AdminProjectSnapshotEntry, + AdminProjectSnapshotListResponse, +} from '../api/adminApiTypes'; import { AdminProjectSnapshotsPage } from './AdminProjectSnapshotsPage'; vi.mock('../api/adminApiClient', async () => ({ @@ -58,6 +61,184 @@ afterEach(() => { vi.useRealTimers(); }); +test('全量读取后按同步时间和身份排序,本地分页并支持刷新和恢复默认', async () => { + const olderProjects = Array.from({ length: 22 }, (_, index) => ({ + ...entry, + projectId: `older-${index}`, + projectName: `旧项目 ${index}`, + syncedAtMs: entry.syncedAtMs + index, + })); + const tiedProjects = [ + { ...entry, userId: 'user-2', projectId: 'p-1', projectName: '同秒乙' }, + { ...entry, userId: 'user-1', projectId: 'p-2', projectName: '同秒甲二' }, + { ...entry, userId: 'user-1', projectId: 'p-1', projectName: '同秒甲一' }, + ].map((project) => ({ ...project, syncedAtMs: entry.syncedAtMs + 100 })); + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'original-next' }) + .mockResolvedValueOnce({ items: olderProjects, nextCursor: 'scan-2' }) + // 后端有界扫描可能返回带下一游标的空页,不能提前结束。 + .mockResolvedValueOnce({ items: [], nextCursor: 'scan-3' }) + .mockResolvedValueOnce({ + items: [ + ...tiedProjects, + { + ...olderProjects[0]!, + projectName: '更新项目', + syncedAtMs: entry.syncedAtMs + 200, + }, + ], + nextCursor: null, + }); + render(); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + await screen.findByRole('button', { name: '恢复默认顺序' }); + const rowNames = () => + screen + .getAllByRole('row') + .map((row) => row.querySelector('td strong')?.textContent) + .filter(Boolean); + expect(rowNames().slice(0, 4)).toEqual([ + '更新项目', + '同秒甲一', + '同秒甲二', + '同秒乙', + ]); + expect(rowNames()).toHaveLength(20); + expect(screen.getByRole('navigation').textContent).toContain('共 25 个'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 4, + 'token', + { cursor: 'scan-3', limit: 100, channel: 'dev' }, + expect.any(AbortSignal), + ); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + expect(rowNames()).toHaveLength(5); + expect(screen.getByRole('navigation').textContent).toContain('第 2 页'); + fireEvent.click(screen.getByRole('button', { name: '上一页' })); + expect(rowNames()[0]).toBe('更新项目'); + fireEvent.change(screen.getByLabelText('每页条数'), { + target: { value: '50' }, + }); + expect(rowNames()).toHaveLength(25); + expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(4); + + vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({ + items: [{ ...entry, projectName: '刷新后的项目' }], + nextCursor: null, + }); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + await screen.findByText('刷新后的项目'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 5, + 'token', + { cursor: null, limit: 100, channel: 'dev' }, + expect.any(AbortSignal), + ); + fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' })); + await screen.findByText('三消工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 6, + 'token', + { cursor: null, limit: 50, channel: 'dev' }, + expect.any(AbortSignal), + ); +}); + +test('全量排序中途失败不发布部分结果,重试刷新失败保留完整排序', async () => { + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'original-next' }) + .mockResolvedValueOnce({ + items: [{ ...entry, projectName: '部分读取项目' }], + nextCursor: 'scan-next', + }) + .mockRejectedValueOnce(new Error('全量读取失败')); + render(); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + await screen.findByText('全量读取失败'); + expect(screen.getByText('三消工程')).toBeTruthy(); + expect(screen.queryByText('部分读取项目')).toBeNull(); + expect(screen.queryByRole('button', { name: '恢复默认顺序' })).toBeNull(); + expect( + screen.getByRole('button', { name: '下一页' }).hasAttribute('disabled'), + ).toBe(false); + + vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({ + items: [{ ...entry, projectName: '完整排序项目' }], + nextCursor: null, + }); + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + await screen.findByText('完整排序项目'); + vi.mocked(listAdminProjectSnapshots).mockRejectedValueOnce( + new Error('刷新读取失败'), + ); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + await screen.findByText('刷新读取失败'); + expect(screen.getByText('完整排序项目')).toBeTruthy(); + expect(screen.getByRole('navigation').textContent).toContain('共 1 个'); +}); + +test('取消或切换渠道时中断全量读取,忽略随后到达的旧响应', async () => { + vi.mocked(getAdminProjectSnapshotChannels).mockResolvedValue({ + defaultChannel: 'dev', + channels: ['dev', 'release'], + }); + let finishCancelled!: (value: AdminProjectSnapshotListResponse) => void; + let finishOldChannel!: (value: AdminProjectSnapshotListResponse) => void; + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: null }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishCancelled = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishOldChannel = resolve; + }), + ) + .mockResolvedValueOnce({ + items: [{ ...entry, channel: 'release', projectName: '正式渠道项目' }], + nextCursor: null, + }); + render(); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + const cancelledSignal = vi.mocked(listAdminProjectSnapshots).mock + .calls[1]![2]!; + fireEvent.click(screen.getByRole('button', { name: '取消排序' })); + expect(cancelledSignal.aborted).toBe(true); + await act(async () => { + finishCancelled({ + items: [{ ...entry, projectName: '已取消项目' }], + nextCursor: null, + }); + }); + expect(screen.getByText('三消工程')).toBeTruthy(); + expect(screen.queryByText('已取消项目')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + const oldChannelSignal = vi.mocked(listAdminProjectSnapshots).mock + .calls[2]![2]!; + fireEvent.change(screen.getByLabelText('项目工程渠道'), { + target: { value: 'release' }, + }); + await screen.findByText('正式渠道项目'); + expect(oldChannelSignal.aborted).toBe(true); + await act(async () => { + finishOldChannel({ + items: [{ ...entry, projectName: '旧渠道项目' }], + nextCursor: null, + }); + }); + expect(screen.getByText('正式渠道项目')).toBeTruthy(); + expect(screen.queryByText('旧渠道项目')).toBeNull(); + expect(screen.getByRole('button', { name: '按同步时间排序' })).toBeTruthy(); +}); + test('按项目展示完整性并限制未完成工程下载', async () => { vi.mocked(listAdminProjectSnapshots).mockResolvedValue({ items: [ diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx index 026adec6b..a99faafc2 100644 --- a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx +++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx @@ -10,7 +10,7 @@ import { AdminPagination, AdminStatusPill, } from '@genarrative/shared/components'; -import { Download, RefreshCcw, X } from 'lucide-react'; +import { ArrowDownWideNarrow, Download, RefreshCcw, X } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { @@ -50,6 +50,9 @@ const snapshotStatuses: Record< const DEFAULT_PAGE_SIZE = 20; const PAGE_SIZE_OPTIONS = [20, 50, 100]; +const MAX_SORT_PAGES = 200; +const MAX_SORT_PROJECTS = 10_000; +const SORT_TIMEOUT_MS = 60_000; export function AdminProjectSnapshotsPage({ token, @@ -64,6 +67,9 @@ export function AdminProjectSnapshotsPage({ const [channelOptions, setChannelOptions] = useState([]); const [isLoading, setIsLoading] = useState(false); const [hasLoaded, setHasLoaded] = useState(false); + const [isSorted, setIsSorted] = useState(false); + const [isSorting, setIsSorting] = useState(false); + const [sortProgress, setSortProgress] = useState(0); const [errorMessage, setErrorMessage] = useState(''); const [downloadingKey, setDownloadingKey] = useState(null); const listController = useRef(null); @@ -71,9 +77,18 @@ export function AdminProjectSnapshotsPage({ // 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回, // 因此按页记录已取得的游标,翻页只在这些游标之间移动。 const pageCursors = useRef<(string | null)[]>([null]); + // 全量读取完成后才发布;分页只切片本次读到的完整集合。 + const sortedItems = useRef(null); const loadPage = useCallback( async (cursor: string | null, limit: number, page: number) => { + if (sortedItems.current !== null) { + setItems(sortedItems.current.slice((page - 1) * limit, page * limit)); + setPageIndex(page); + setHasLoaded(true); + setErrorMessage(''); + return; + } listController.current?.abort(); const controller = new AbortController(); listController.current = controller; @@ -126,8 +141,16 @@ export function AdminProjectSnapshotsPage({ return () => controller.abort(); }, [token, onUnauthorized]); + useEffect(() => { + sortedItems.current = null; + setIsSorted(false); + }, [token, channel]); + useEffect(() => { if (channel === null) return undefined; + setIsSorting(false); + setIsLoading(false); + setSortProgress(0); pageCursors.current = [null]; setItems([]); setNextCursor(null); @@ -144,6 +167,10 @@ export function AdminProjectSnapshotsPage({ }, [loadPage, pageSize, channel]); function goToNextPage() { + if (sortedItems.current !== null) { + void loadPage(null, pageSize, pageIndex + 1); + return; + } if (!nextCursor) return; pageCursors.current[pageIndex] = nextCursor; void loadPage(nextCursor, pageSize, pageIndex + 1); @@ -159,6 +186,10 @@ export function AdminProjectSnapshotsPage({ } function refreshCurrentPage() { + if (sortedItems.current !== null) { + void sortAllProjects(); + return; + } void loadPage( pageCursors.current[pageIndex - 1] ?? null, pageSize, @@ -166,6 +197,93 @@ export function AdminProjectSnapshotsPage({ ); } + async function sortAllProjects() { + if (channel === null) return; + listController.current?.abort(); + const controller = new AbortController(); + listController.current = controller; + setIsLoading(true); + setIsSorting(true); + setSortProgress(0); + setErrorMessage(''); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, SORT_TIMEOUT_MS); + try { + const projects = new Map(); + const seenCursors = new Set(); + let cursor: string | null = null; + for (let page = 0; page < MAX_SORT_PAGES; page += 1) { + const response = await listAdminProjectSnapshots( + token, + { cursor, limit: 100, channel }, + controller.signal, + ); + if (controller.signal.aborted) return; + for (const project of response.items) { + const key = snapshotKey(project); + const previous = projects.get(key); + if (!previous || project.syncedAtMs >= previous.syncedAtMs) { + projects.set(key, project); + } + } + if (projects.size > MAX_SORT_PROJECTS) { + throw new Error('项目超过 10,000 个,无法完成全量排序'); + } + setSortProgress(projects.size); + if (!response.nextCursor) { + const result = [...projects.values()].sort( + (left, right) => + right.syncedAtMs - left.syncedAtMs || + compareIds(left.userId, right.userId) || + compareIds(left.projectId, right.projectId), + ); + sortedItems.current = result; + setIsSorted(true); + setItems(result.slice(0, pageSize)); + setPageIndex(1); + setNextCursor(null); + setHasLoaded(true); + return; + } + if (seenCursors.has(response.nextCursor)) { + throw new Error('项目列表游标重复,无法完成全量排序,请重试'); + } + seenCursors.add(response.nextCursor); + cursor = response.nextCursor; + } + throw new Error('项目读取超过 200 页,无法完成全量排序'); + } catch (error: unknown) { + if (!controller.signal.aborted) { + handlePageError(error, onUnauthorized, setErrorMessage); + } + } finally { + clearTimeout(timeout); + if (listController.current === controller) { + listController.current = null; + setIsLoading(false); + setIsSorting(false); + if (timedOut) setErrorMessage('全量项目读取超时,请重试'); + } + } + } + + function cancelSorting() { + listController.current?.abort(); + listController.current = null; + setIsLoading(false); + setIsSorting(false); + } + + function restoreDefaultOrder() { + sortedItems.current = null; + setIsSorted(false); + pageCursors.current = [null]; + void loadPage(null, pageSize, 1); + } + async function downloadProject(entry: AdminProjectSnapshotEntry) { if (downloadController.current || entry.status === 'partial') return; const controller = new AbortController(); @@ -236,6 +354,32 @@ export function AdminProjectSnapshotsPage({ ))} + void sortAllProjects() + } + > + + {isSorting ? ( + <> + + 正在读取全部项目,已读取 {sortProgress} 个 + + + + + ) : null} 第 {pageIndex} 页 {items.length ? `,本页 ${items.length} 个项目` : ''} + {isSorted + ? `,共 ${sortedItems.current?.length ?? 0} 个,按同步时间从新到旧` + : ''} } pageSize={{ @@ -279,7 +426,12 @@ export function AdminProjectSnapshotsPage({ }} busy={isLoading} previous={{ disabled: pageIndex <= 1, onClick: goToPreviousPage }} - next={{ disabled: !nextCursor, onClick: goToNextPage }} + next={{ + disabled: isSorted + ? pageIndex * pageSize >= (sortedItems.current?.length ?? 0) + : !nextCursor, + onClick: goToNextPage, + }} /> ) : null } @@ -375,6 +527,10 @@ function snapshotKey(entry: AdminProjectSnapshotEntry) { return `${entry.userId}/${entry.projectId}`; } +function compareIds(left: string, right: string) { + return left < right ? -1 : left > right ? 1 : 0; +} + function formatBytes(bytes: number) { const units = ['B', 'KiB', 'MiB', 'GiB']; const unit = Math.min( diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 84b49623e..381107c7c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9159,7 +9159,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 列表排序(新增能力,同日):`AdminListPanel` 增加 `sortable` / `sortValue` / `sortDescription`——列头渲染与表查询一致的排序按钮(`admin-table-sort-button` + 升/降/双向图标),点击按「正序 → 倒序 → 不排序」循环,同步 `th[aria-sort]`,排序是稳定排序(值相同保持服务端原顺序)。已接入**一次取全**的 5 个列表:邀请码列表、操作记录、灰度 Gate 列表、可配置开关、任务配置列表(这些接口没有分页,前端排序语义正确)。 - 列表实现全量收口(同日续做):把剩余 **15 处** `children` 形态列表全部迁到 `columns` + `renderRow`(表体 JSX 原样搬进 `renderRow`,外壳/状态/分页槽位不变):`AdminRedeemCodePage`(2)、`AdminRechargeProductPage`、`AdminProjectSnapshotsPage`、`AdminErrorReportsPage`、`AdminAgcTrackingPage`、`AdminGameDistributionReviewPage`、`AdminGameManagementPage`(主列表 + 版本历史)、`AdminUserDetailDialog`(充值订单)、`AdminRechargeOrderPage`、`AdminAgcModelsPage`、`AdminEditorAssetQueryPage`、`AdminEditorShowcaseReviewPage`、`AdminAgcTemplatesPage`。迁移后全仓统计:`AdminListPanel` **26 处 / 21 个文件**,其中 **24 处 columns 形态**(含 2 处弹窗内 `surface="plain"`),仅剩 2 处非表格列表仍是 children 形态(账号管理卡片列表、账号配置的键值列表);`` 作为 children 的写法已归零。可排序列累计 **45 个**(新增写入 `AdminAgcTemplatesPage` 列头 4 个:模板/引擎版本/包大小/状态)。 - 迁移中顺带处理的形态差异:① `AdminErrorReportsPage` 的详情弹窗原本嵌在列表面板里,随表格一起搬到面板外(遮罩是 fixed,视觉不变);② `AdminAgcModelsPage` 的工具栏与状态行改为走 `toolbar` 槽位(列表面板按 toolbar → 加载行 → 表格渲染,顺序与原来一致);③ `AdminAgcTemplatesPage` 的列表从共享 `ui/Table`(`genarrative-ui-table*`)换成 `AdminTable`,与其它页签视觉统一——该页唯一的 ui Table 只剩「上传模板」弹窗里的待上传队列表(有逐行校验/进度状态的编辑态表格,未纳入列表组件,属有意保留)。 -- 排序能力边界(重要):后端目前**只有** `GET /admin/api/database/tables/{table}/rows` 与 `GET /admin/api/external-api-keys` 接受 `sortColumn`/`sortDirection`;其余列表在 handler 里写死顺序(例如埋点数据固定 `occurred_at desc`、错误报告按时间倒序)。因此埋点数据、客户端埋点、错误报告、项目工程、充值订单这类**分页明细列表暂时不能排序**——只在前端排「当前页」会给出错误结论,必须给对应接口加排序参数(DTO + handler + `adminApiTypes` + 契约/测试)后前端复用同一列头。账号管理是卡片列表(`children` 形态),本轮未加排序。 +- 排序能力边界(重要):后端目前**只有** `GET /admin/api/database/tables/{table}/rows` 与 `GET /admin/api/external-api-keys` 接受 `sortColumn`/`sortDirection`;其余列表在 handler 里写死顺序(例如埋点数据固定 `occurred_at desc`、错误报告按时间倒序)。埋点数据、客户端埋点、错误报告、充值订单等分页明细不能只在前端排「当前页」,必须先让对应接口支持全局排序,再复用列头。项目工程采用独立的主动全量读取按钮:当前渠道完整读取成功后按同步时间降序、用户/项目 ID 升序,本地分页;原目录接口及上传/下载契约保持不变,不新增 OSS 索引或数据库表。读取限制为 200 页、10,000 个唯一项目、60 秒,可取消;失败保留原列表,切换渠道/令牌取消请求并销毁临时集合。账号管理是卡片列表(`children` 形态),本轮未加排序。 - 本地假数据补齐:`scripts/admin-web-fake-api.mjs` 新增 `agc-models`、`game-distribution/games`、`profile/recharge-products`、`profile/redeem-codes`、`profile/tasks`、`agc/tracking-events` 夹具,并对 `profile/recharge-orders` 按 `AdminRechargeOrderEntryPayload` 的真实字段补齐(缺字段会让页面抛 `Cannot read properties of undefined`——后台没有 error boundary,整页会白屏)。**已知缺口**:充值管理页仍缺一处夹具字段(页面读 `undefined.find`),本轮没能出图;该页自身 21 条单测通过、类型检查通过,仅缺截图。 - 排序验证:共享组件新增用例覆盖「正序 / 倒序 / 取消 + aria-sort + 行序」;现场实测邀请码列表按「创建」排序:正序 `EXPIRED-CODE, BETA-CREATOR, TAONIER-VIP-2026`、倒序翻回 `TAONIER…, BETA…, EXPIRED…`,`th[aria-sort]` 依次为 `ascending` / `descending`。 - 边界(未完成):未跑生产后台构建与真实后台接口联调(截图用假数据);`apps/admin-web` 目前没有 error boundary,任何接口形状不符仍会把整页渲染清空(本次只加固了 `AdminAgcTrackingPage` 一处,其余页面同类写法未逐个排查);`AdminAgcTemplatesPage` 的列表面板是本次新增的外壳(原页面没有面板),视觉上多了白底卡片。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 5315d44f8..518935205 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1781,6 +1781,9 @@ 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 条分页及上一页/下一页复用浏览器中的完整列表,不再请求远端;“刷新”重新读取全量并回到第一页,“恢复默认顺序”回到远端目录分页第一页。切换渠道、令牌或离开页面销毁临时结果并取消在途读取,不跨会话持久化。 +- 全量读取展示已取得项目数并允许取消;单次最多 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。下载固定本次读到的清单,远端并发回收导致对象缺失则整体失败,不能静默遗漏。 - `partial` 快照下载返回 409;`unverified` 历史快照可导出已同步文件,列表明确显示“完整性未知”,动作称“下载已存文件”。`ready` 才显示“下载完整工程”。ZIP 构建核验每一文件的长度与 fnv1a64 摘要,拒绝穿越、绝对路径、重复/大小写冲突路径、非法项目身份;缺失或损坏整体失败,不返回成功的残缺 ZIP。 - ZIP 使用服务端临时文件并限制并发,不将 2 GiB 工程整体驻留内存;成功、失败、客户端取消均清理临时文件。单文件、总量、文件数沿用上传上限,超限明确拒绝。零字节工程文件可以上传和导出。OSS 凭据与签名不下发浏览器,列表失败保留错误而非伪造空列表。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index c0f0a9078..a97c5db52 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -601,6 +601,8 @@ curl -fsS --max-time 5 http://127.0.0.1/api/editor/showcase/resources >/dev/null 后台“项目工程”(`/admin/#project-snapshots`)按项目列出远端快照,默认只看本部署渠道,顶部“渠道”选择框可切换远端已存在的其它渠道;列表按游标分页(每页 20/50/100,上一页复用已取得的游标,远端不给总数所以只显示当前页)。完整快照提供“下载完整工程”,按原始目录返回 ZIP;未完成同步的项目暂不可下载,旧清单缺少完整性声明时显示“完整性未知”,只能“下载已存文件”。“用户”列与“素材查询”同口径展示昵称与陶泥号,并可点开用户详情;不要直接把 OSS 的 `files/{size}-{digest}/` 目录下载当成工程。 +“按同步时间排序”按钮按需读取所选渠道全部项目,完成后按同步时间从新到旧、用户 ID 和项目 ID 字符串升序显示,并在浏览器本地按 20/50/100 条分页;列表会显示本轮项目总数。读取中可取消,失败保留原结果。排序模式下“刷新”重新读取全部项目,“恢复默认顺序”返回远端目录分页;切换渠道或重新登录清除临时排序结果。每次全量读取上限为 200 页、10,000 个唯一项目和 60 秒,超限报错,不将部分集合视作排序完成。该操作增加当次 OSS 清单读取量,不写 OSS 索引、不改数据库或 AGC 上传契约;并发同步结果以本轮读取集合为准,最新变化需刷新。 + 自动上传以原生登记的活动工程为准:打开即首传、每 300 秒周期同步、切换/关闭补传。排障同时核对 AppData `project-snapshots` 索引、`project_snapshot.sync.*` 日志和远端清单;只有测试项目的历史清单不能证明现役项目同步生效。前端在同一窗口内切项目时必须登记生命周期,不能只检查 URL 是否包含 `projectPath`。 后台枚举另外需要 AGC 私有前缀的 `ListObjects`(RAM `oss:ListObjects`,限制 prefix)与 `GetObject` 权限;下载不需要写入权限。客户端修复、后台页面与 api-server 必须分别发布才能在安装版和线上后台使用。本地定向测试及页面模拟不能代替发布后的真实上传与 ZIP 下载验收。 -- 2.52.0 From 9f6d60e6fd3baacca6993b728ed7b042f69c2f70 Mon Sep 17 00:00:00 2001 From: lhk Date: Wed, 30 Sep 2026 08:12:41 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=B7=A5=E7=A8=8B=E6=81=A2=E5=A4=8D=E9=BB=98=E8=AE=A4=E9=A1=BA?= =?UTF-8?q?=E5=BA=8F=E5=A4=B1=E8=B4=A5=E5=90=8E=E7=9A=84=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E7=8A=B6=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。下载固定本次读到的清单,远端并发回收导致对象缺失则整体失败,不能静默遗漏。 -- 2.52.0