From 3206f8f49b41d53ee8c951cc8027002307bcbe81 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 17:14:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9C=A8=E7=BA=BF=E6=B8=B8?= =?UTF-8?q?=E7=8E=A9=E5=8A=A0=E8=BD=BD=E4=BD=93=E9=AA=8C=EF=BC=9A=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E6=9C=9F=E9=BB=91=E5=B1=8F/=E7=A9=BA=E7=99=BD?= =?UTF-8?q?=E4=B8=8E=E5=8F=91=E8=A1=8C=E8=B5=84=E6=BA=90=E4=BC=A0=E8=BE=93?= =?UTF-8?q?=E5=BC=80=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/shared 新增共享游戏加载面 PlatformGameLoadingSurface:封面/标题 + 动效进度 + 按时长推进的阶段文案(准备/资源/引擎)+ 慢加载提示,附用例 - 网页游玩页用共享加载面替换近黑壳上的一行小字,加载上限改为共享常量 PLATFORM_GAME_LOADING_TIMEOUT_MS - 后台审核试玩补加载面、20 秒超时与「重新创建试玩会话」入口,新会话重置加载态;补两条用例 - 发行网关(api-server)对文本类发行资源按 Accept-Encoding 下发 gzip,并下发强 ETag、命中 If-None-Match 返回 304;补 5 条 Rust 用例 - E2E 脚本 check-game-distribution-ops-rollback-e2e 补压缩/Vary/ETag/304/不接受 gzip 四条断言 - 同步 pitfalls、开发运维文档、nginx README 与两份里程碑记录 --- .../AdminGameDistributionReviewPage.test.tsx | 99 ++++ .../pages/AdminGameDistributionReviewPage.tsx | 66 ++- apps/admin-web/src/styles/admin.css | 14 +- deploy/nginx/README.md | 4 +- ...】后台游戏审核详情与待审版本试玩-2026-10-02.md | 7 + ...‹碑】游戏分发目录详情与在线游玩-2026-09-18.md | 6 + docs/project-memory/shared-memory/pitfalls.md | 9 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 1 + .../PlatformGameLoadingSurface.test.tsx | 98 ++++ .../components/PlatformGameLoadingSurface.tsx | 113 +++++ packages/shared/src/components/index.ts | 12 + packages/shared/src/components/styles.css | 173 ++++++- ...eck-game-distribution-ops-rollback-e2e.mjs | 73 +++ server-rs/Cargo.lock | 1 + server-rs/crates/api-server/Cargo.toml | 1 + .../src/modules/game_distribution.rs | 443 +++++++++++++++++- .../GameDistributionPages.test.tsx | 22 + .../game-distribution/GamePlayPage.tsx | 18 +- .../game-distribution/gameDistribution.css | 10 - 19 files changed, 1141 insertions(+), 29 deletions(-) create mode 100644 packages/shared/src/components/PlatformGameLoadingSurface.test.tsx create mode 100644 packages/shared/src/components/PlatformGameLoadingSurface.tsx diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx index 3ca7ab6be..c2be88fb8 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx @@ -1,6 +1,7 @@ /* @vitest-environment jsdom */ import { + act, fireEvent, render, screen, @@ -9,6 +10,8 @@ import { } from '@testing-library/react'; import { beforeEach, expect, test, vi } from 'vitest'; +import { PLATFORM_GAME_LOADING_TIMEOUT_MS } from '@genarrative/shared/components'; + import { createAdminGameDistributionPreviewSession, getAdminGameDistributionVersion, @@ -317,3 +320,99 @@ test('详情展示发布者和冻结资料,并试玩当前待审版本', async ), ); }); + +/** 打开详情并点开试玩,返回试玩 iframe。 */ +async function openPendingVersionPreview() { + render( + , + ); + await screen.findByText(gameTitle); + fireEvent.click(screen.getByRole('button', { name: '详情' })); + await screen.findAllByText(/测试作者/u); + fireEvent.click(screen.getByRole('button', { name: '试玩当前待审版本' })); + return screen.findByTitle('测试游戏 待审版本试玩'); +} + +test('试玩期间先给加载面,待审版本真正载入后让位给画面', async () => { + vi.mocked(getAdminGameDistributionVersion).mockResolvedValue(detailResponse); + vi.mocked(createAdminGameDistributionPreviewSession).mockResolvedValue({ + previewUrl: + 'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-1/', + expiresAt: '2026-10-02T12:10:00Z', + versionId: 'version-1', + }); + + const frame = await openPendingVersionPreview(); + + // 加载期不能只留一片空白:审核员要看到正在加载哪个游戏。 + const loadingSurface = screen.getByRole('status'); + expect(loadingSurface.textContent).toContain('测试游戏'); + expect( + loadingSurface.classList.contains('platform-game-loading--panel'), + ).toBe(true); + + fireEvent.load(frame); + await waitFor(() => expect(screen.queryByRole('status')).toBeNull()); +}); + +test('试玩加载超过上限后给出超时说明与重建会话入口', async () => { + vi.mocked(getAdminGameDistributionVersion).mockResolvedValue(detailResponse); + vi.mocked(createAdminGameDistributionPreviewSession) + .mockResolvedValueOnce({ + previewUrl: + 'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-1/', + expiresAt: '2026-10-02T12:10:00Z', + versionId: 'version-1', + }) + .mockResolvedValueOnce({ + previewUrl: + 'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-2/', + expiresAt: '2026-10-02T12:20:00Z', + versionId: 'version-1', + }); + + render( + , + ); + await screen.findByText(gameTitle); + fireEvent.click(screen.getByRole('button', { name: '详情' })); + await screen.findAllByText(/测试作者/u); + + // 试玩会话与超时定时器都必须落在假定时器窗口内,才能按上限推进等待。 + vi.useFakeTimers(); + try { + fireEvent.click(screen.getByRole('button', { name: '试玩当前待审版本' })); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTitle('测试游戏 待审版本试玩')).toBeTruthy(); + expect(screen.queryByText('试玩版本加载超时')).toBeNull(); + + await act(async () => { + vi.advanceTimersByTime(PLATFORM_GAME_LOADING_TIMEOUT_MS); + }); + expect(screen.getByRole('status').textContent).toContain( + '试玩版本加载超时', + ); + + fireEvent.click(screen.getByRole('button', { name: '重新创建试玩会话' })); + await act(async () => { + await Promise.resolve(); + }); + expect(createAdminGameDistributionPreviewSession).toHaveBeenCalledTimes(2); + // 新会话必须回到加载态,不能沿用上一个会话的「已就绪」或超时结论。 + const rebuilt = screen.getByTitle('测试游戏 待审版本试玩'); + expect(rebuilt.getAttribute('src')).toBe( + 'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-2/', + ); + expect(screen.queryByText('试玩版本加载超时')).toBeNull(); + } finally { + vi.useRealTimers(); + } +}); diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx index f63a9c79e..eb816f0b2 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx @@ -10,6 +10,8 @@ import { formatAdminDateTime, GameDetailDisplay, Modal, + PLATFORM_GAME_LOADING_TIMEOUT_MS, + PlatformGameLoadingSurface, TextField, } from '@genarrative/shared/components'; import { RefreshCcw } from 'lucide-react'; @@ -591,6 +593,20 @@ function AdminGameDistributionReviewDetailView({ onReject: () => void; onApprove: () => void; }) { + // 试玩会话是按 version 现建的:新会话必须回到加载态,不能沿用上一个会话的 ready。 + const [isPreviewFrameReady, setIsPreviewFrameReady] = useState(false); + const [isPreviewTimedOut, setIsPreviewTimedOut] = useState(false); + + useEffect(() => { + setIsPreviewFrameReady(false); + setIsPreviewTimedOut(false); + if (!previewUrl) return undefined; + const timer = window.setTimeout(() => { + setIsPreviewTimedOut(true); + }, PLATFORM_GAME_LOADING_TIMEOUT_MS); + return () => window.clearTimeout(timer); + }, [previewUrl]); + if (!detail) { return ( @@ -606,6 +622,10 @@ function AdminGameDistributionReviewDetailView({ ); } + const coverUrl = detail.game.coverObjectKey + ? assetUrls[detail.game.coverObjectKey] + : ''; + return ( {previewError} ) : null} {previewUrl ? ( -