diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 8a4b2d0c6..6368883c8 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -213,6 +213,8 @@ module.exports = { '!src/services/clipboard.test.ts', '!src/services/frontendRuntimeConfigService.ts', '!src/services/frontendRuntimeConfigService.test.ts', + '!src/services/gameDistributionClient.ts', + '!src/services/gameDistributionClient.test.ts', '!src/services/sseStream.ts', '!src/services/sseStream.test.ts', 'src/AdventurePanel.tsx', diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index 707e5f7a6..b84b9f901 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -98,6 +98,12 @@ jobs: - name: Report isolated Rust compilation cache if: always() run: bash scripts/ci-rust-cache.sh report + - name: Publish master Rust cache artifact + continue-on-error: true + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }} + env: + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: python3 scripts/export-gitea-rust-cache.py ai-game-creator-shell-rust-lane-2: name: AI game creator shell Rust lane 2/2 @@ -141,6 +147,12 @@ jobs: - name: Report isolated Rust compilation cache if: always() run: bash scripts/ci-rust-cache.sh report + - name: Publish master Rust cache artifact + continue-on-error: true + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }} + env: + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: python3 scripts/export-gitea-rust-cache.py # agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样不装 npm 依赖, # 单独一个 job,免得把已经压到 4 分钟级的片 job 拖长。 @@ -183,6 +195,12 @@ jobs: - name: Report isolated Rust compilation cache if: always() run: bash scripts/ci-rust-cache.sh report + - name: Publish master Rust cache artifact + continue-on-error: true + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }} + env: + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: python3 scripts/export-gitea-rust-cache.py # AGC 壳依赖的共享 / 平台和编辑器插件 crate 各自预热独立 manifest,再运行对应测试。 ai-game-creator-shell-rust-crates: @@ -291,6 +309,12 @@ jobs: - name: Report isolated Rust compilation cache if: always() run: bash scripts/ci-rust-cache.sh report + - name: Publish master Rust cache artifact + continue-on-error: true + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }} + env: + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: python3 scripts/export-gitea-rust-cache.py backend-tests: name: Backend tests @@ -380,6 +404,12 @@ jobs: - name: Report isolated Rust compilation cache if: always() run: bash scripts/ci-rust-cache.sh report + - name: Publish master Rust cache artifact + continue-on-error: true + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }} + env: + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: python3 scripts/export-gitea-rust-cache.py # 客户端的壳级与契约门禁:静态契约断言、H5 / 微信 / 移动 / 桌面壳运行时门禁, # 以及依赖发布产物的构建 smoke。 @@ -442,6 +472,12 @@ jobs: - name: Report isolated Rust compilation cache if: always() run: bash scripts/ci-rust-cache.sh report + - name: Publish master Rust cache artifact + continue-on-error: true + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }} + env: + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: python3 scripts/export-gitea-rust-cache.py frontend-tests: name: Frontend tests @@ -524,6 +560,9 @@ jobs: - name: Install npm dependencies run: bash scripts/ci-npm-ci-with-retry.sh + - name: Validate CI cache maintenance behavior + run: python3 -m unittest discover -s scripts -p 'test_gitea_cache_*.py' + - name: Run repository checks run: npm run check:repository-ci diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index 16b39ca89..a5c27f641 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -7,9 +7,12 @@ import { getAdminFeatureGateConfig, getAdminUserDetail, importAdminAgcTemplates, + listAdminGameDistributionReviews, listAdminRechargeOrders, reconcileAdminUserConsumption, resolveAdminRechargeRefundManualReview, + reviewAdminGameDistributionVersion, + suspendAdminGameDistributionGame, updateAdminAccount, updateAdminAgcTemplate, uploadAdminEditorShowcaseCampaignImage, @@ -454,3 +457,134 @@ test('退款人工复核使用独立 resolve 管理员路由', async () => { }), ); }); + +test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键', async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ entries: [], nextCursor: null }), { + status: 200, + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await listAdminGameDistributionReviews('admin-token'); + await reviewAdminGameDistributionVersion( + 'admin-token', + 'gamever/1', + 'game-review-key-1', + { + decision: 'approve', + expectedPublicationRevision: 3, + entryUrl: 'https://games.example.test/releases/game_1/index.html', + }, + ); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + '/admin/api/game-distribution/reviews?limit=48', + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe( + '/admin/api/game-distribution/versions/gamever%2F1/review', + ); + expect(fetchMock.mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer admin-token', + 'Idempotency-Key': 'game-review-key-1', + }), + body: JSON.stringify({ + decision: 'approve', + expectedPublicationRevision: 3, + entryUrl: 'https://games.example.test/releases/game_1/index.html', + }), + }), + ); +}); + +test('安全下架请求携带公开修订号、原因与幂等键', async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ game: {}, replayed: false }), { + status: 200, + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await suspendAdminGameDistributionGame( + 'admin-token', + 'game/1', + 'game-suspend-key-1', + { expectedPublicationRevision: 7, reason: '版权投诉' }, + ); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + '/admin/api/game-distribution/games/game%2F1/suspend', + ); + expect(fetchMock.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer admin-token', + 'Idempotency-Key': 'game-suspend-key-1', + }), + body: JSON.stringify({ + expectedPublicationRevision: 7, + reason: '版权投诉', + }), + }), + ); + + expect(() => + suspendAdminGameDistributionGame('admin-token', ' ', 'key', { + expectedPublicationRevision: 1, + }), + ).toThrow('缺少游戏 ID'); + expect(() => + suspendAdminGameDistributionGame('admin-token', 'game-1', ' ', { + expectedPublicationRevision: 1, + }), + ).toThrow('下架幂等键必须是 1 到 128 个字符'); + expect(fetchMock).toHaveBeenCalledTimes(1); +}); + +test('游戏审核拒绝请求携带理由,空幂等键在本地失败关闭', async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ version: {}, replayed: false }), { + status: 200, + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await reviewAdminGameDistributionVersion( + 'admin-token', + 'version-1', + 'game-review-key-2', + { + decision: 'reject', + expectedPublicationRevision: 0, + reviewReason: '运行时报错', + }, + ); + expect(fetchMock.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + body: JSON.stringify({ + decision: 'reject', + expectedPublicationRevision: 0, + reviewReason: '运行时报错', + }), + }), + ); + + expect(() => + reviewAdminGameDistributionVersion('admin-token', 'version-1', ' ', { + decision: 'reject', + expectedPublicationRevision: 0, + reviewReason: 'x', + }), + ).toThrow('审核幂等键必须是 1 到 128 个字符'); + expect(fetchMock).toHaveBeenCalledTimes(1); +}); diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index d85e5a8c8..4688975e0 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -30,6 +30,9 @@ import type { AdminExternalApiKeyListQuery, AdminExternalApiKeyListResponse, AdminFeatureGateConfigResponse, + AdminGameDistributionReviewListResponse, + AdminGameDistributionReviewRequest, + AdminGameDistributionReviewResponse, AdminImportAgcTemplatesResponse, AdminLoginResponse, AdminMeResponse, @@ -1200,6 +1203,75 @@ export function saveAgcModelCatalog( ); } +export function listAdminGameDistributionReviews(token: string, limit = 48) { + const normalizedLimit = Number.isFinite(limit) + ? Math.min(Math.max(Math.trunc(limit), 1), 48) + : 48; + return request( + `/admin/api/game-distribution/reviews?limit=${normalizedLimit}`, + { token }, + ); +} + +/** + * 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生 + * 两条审核结论。 + */ +/** + * 安全下架整个游戏。管理员下架同样要求 CAS 修订号与幂等键,避免并发审核互相覆盖。 + */ +export function suspendAdminGameDistributionGame( + token: string, + gameId: string, + idempotencyKey: string, + payload: import('./adminApiTypes').AdminGameDistributionSuspendRequest, +) { + const normalizedGameId = gameId.trim(); + const normalizedKey = idempotencyKey.trim(); + if (!normalizedGameId) { + throw new Error('缺少游戏 ID'); + } + if (!normalizedKey || normalizedKey.length > 128) { + throw new Error('下架幂等键必须是 1 到 128 个字符'); + } + return request< + import('./adminApiTypes').AdminGameDistributionSuspendResponse + >( + `/admin/api/game-distribution/games/${encodeURIComponent(normalizedGameId)}/suspend`, + { + method: 'POST', + token, + headers: { 'Idempotency-Key': normalizedKey }, + body: payload, + }, + ); +} + +export function reviewAdminGameDistributionVersion( + token: string, + versionId: string, + idempotencyKey: string, + payload: AdminGameDistributionReviewRequest, +) { + const normalizedVersionId = versionId.trim(); + const normalizedKey = idempotencyKey.trim(); + if (!normalizedVersionId) { + throw new Error('缺少发行版本 ID'); + } + if (!normalizedKey || normalizedKey.length > 128) { + throw new Error('审核幂等键必须是 1 到 128 个字符'); + } + return request( + `/admin/api/game-distribution/versions/${encodeURIComponent(normalizedVersionId)}/review`, + { + method: 'POST', + token, + headers: { 'Idempotency-Key': normalizedKey }, + body: payload, + }, + ); +} + export function getAdminAgcTemplates(token: string, signal?: AbortSignal) { return request('/admin/api/agc-templates', { token, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 44b833586..161ef7320 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1043,6 +1043,51 @@ export interface AdminAgcModelCatalog { models: AdminAgcModel[]; } +export interface AdminGameDistributionReviewEntry { + versionId: string; + gameId: string; + versionNumber: number; + packageSha256: string; + packageBytes: number; + status: string; + publicationRevision: number; + reviewReason: string | null; + createdAt: string; + updatedAt: string; +} + +export interface AdminGameDistributionReviewListResponse { + entries: AdminGameDistributionReviewEntry[]; + nextCursor: string | null; +} + +export interface AdminGameDistributionReviewRequest { + decision: 'approve' | 'reject'; + expectedPublicationRevision: number; + reviewReason?: string; + entryUrl?: string; +} + +export interface AdminGameDistributionReviewResponse { + version: AdminGameDistributionReviewEntry; + replayed: boolean; +} + +export interface AdminGameDistributionSuspendRequest { + expectedPublicationRevision: number; + reason?: string; +} + +export interface AdminGameDistributionSuspendResponse { + game: { + id: string; + title: string; + status: string; + publicationRevision: number; + }; + replayed: boolean; +} + export interface AdminAgcTemplatePayload { id: string; title: string; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index f09af530e..e91c6ca8a 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -27,6 +27,7 @@ import { AdminEditorAssetQueryPage } from '../pages/AdminEditorAssetQueryPage'; import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage'; import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage'; import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage'; +import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage'; import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage'; import { AdminInviteCodePage } from '../pages/AdminInviteCodePage'; import { AdminLoginPage } from '../pages/AdminLoginPage'; @@ -307,6 +308,12 @@ export function AdminApp() { onUnauthorized={handleUnauthorized} /> ) : null} + {activeRouteId === 'game-distribution' ? ( + + ) : null} {activeRouteId === 'editor-assets' ? ( { + expect(adminRoutes).toContainEqual({ + id: 'game-distribution', + label: '游戏审核', + hash: '#game-distribution', + }); + expect(resolveAdminRoute('#game-distribution')).toBe('game-distribution'); + expect(routeHash('game-distribution')).toBe('#game-distribution'); +}); + +test('member 可单独获得游戏审核 Tab 权限', () => { + const routes = getAccessibleAdminRoutes({ + accountRole: 'member', + tabPermissions: ['game-distribution'], + }); + expect(routes.map((route) => route.id)).toEqual(['game-distribution']); + expect(resolveAccessibleAdminRoute('#game-distribution', routes)).toBe( + 'game-distribution', + ); +}); + test('模板管理只对 owner 或具有 agc-templates 权限的 member 可见', () => { expect(adminRoutes).toContainEqual({ id: 'agc-templates', diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index 138ac070c..11764bbe9 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -15,6 +15,7 @@ export type AdminRouteId = | 'recharge-orders' | 'editor-generation-pricing' | 'editor-showcase' + | 'game-distribution' | 'editor-assets' | 'project-snapshots' | 'agc-models' @@ -56,6 +57,7 @@ export const adminRoutes: AdminRouteDefinition[] = [ { id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true }, { id: 'agc-templates', label: '模板管理', hash: '#agc-templates' }, { id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' }, + { id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' }, { id: 'editor-assets', label: '素材查询', hash: '#editor-assets' }, { id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' }, { id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true }, diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx new file mode 100644 index 000000000..ae00212d9 --- /dev/null +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx @@ -0,0 +1,186 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, expect, test, vi } from 'vitest'; + +import { + listAdminGameDistributionReviews, + reviewAdminGameDistributionVersion, + suspendAdminGameDistributionGame, +} from '../api/adminApiClient'; +import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes'; +import { + AdminGameDistributionReviewPage, + resolveGameReleaseEntryUrlError, +} from './AdminGameDistributionReviewPage'; + +vi.mock('../api/adminApiClient', () => ({ + isAdminApiError: vi.fn( + (error: unknown) => + typeof error === 'object' && + error !== null && + 'status' in error && + typeof error.status === 'number', + ), + formatAdminApiError: vi.fn((error: unknown) => + error instanceof Error ? error.message : '请求失败', + ), + listAdminGameDistributionReviews: vi.fn(), + reviewAdminGameDistributionVersion: vi.fn(), + suspendAdminGameDistributionGame: vi.fn(), +})); + +const entry: AdminGameDistributionReviewEntry = { + versionId: 'version-1', + gameId: 'game_1', + versionNumber: 2, + packageSha256: 'a'.repeat(64), + packageBytes: 2048, + status: 'pending_review', + publicationRevision: 4, + reviewReason: null, + createdAt: '2026-09-20T08:00:00Z', + updatedAt: '2026-09-20T08:00:00Z', +}; + +beforeEach(() => { + vi.mocked(listAdminGameDistributionReviews).mockReset(); + vi.mocked(reviewAdminGameDistributionVersion).mockReset(); + vi.mocked(suspendAdminGameDistributionGame).mockReset(); + vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({ + entries: [entry], + nextCursor: null, + }); +}); + +test('发行入口必须是带完整来源的 HTTPS 地址', () => { + expect(resolveGameReleaseEntryUrlError('')).toBe('请填写发行入口'); + expect( + resolveGameReleaseEntryUrlError('http://games.test/a/index.html'), + ).toBe('发行入口必须以 https:// 开头'); + expect( + resolveGameReleaseEntryUrlError('https://games.test/a/index.html?token=1'), + ).toBe('发行入口不能包含 query 或 fragment'); + expect( + resolveGameReleaseEntryUrlError('https://u:p@games.test/a/index.html'), + ).toBe('发行入口不能包含凭据'); + expect( + resolveGameReleaseEntryUrlError('https://games.test/a/index.html'), + ).toBe(''); +}); + +test('通过审核时提交当前 publicationRevision 与发行入口并刷新列表', async () => { + vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({ + version: { ...entry, status: 'published' }, + replayed: false, + }); + + render( + , + ); + await screen.findByText('game_1'); + + fireEvent.change(screen.getByLabelText('发行入口'), { + target: { value: 'https://games.test/releases/game_1/index.html' }, + }); + fireEvent.click(screen.getByRole('button', { name: '通过' })); + + await waitFor(() => + expect(reviewAdminGameDistributionVersion).toHaveBeenCalledTimes(1), + ); + const [token, versionId, idempotencyKey, payload] = + vi.mocked(reviewAdminGameDistributionVersion).mock.calls[0] ?? []; + expect(token).toBe('admin-token'); + expect(versionId).toBe('version-1'); + expect(String(idempotencyKey)).toContain('version-1'); + expect(payload).toEqual({ + decision: 'approve', + expectedPublicationRevision: 4, + entryUrl: 'https://games.test/releases/game_1/index.html', + }); + await waitFor(() => + expect(vi.mocked(listAdminGameDistributionReviews)).toHaveBeenCalledTimes( + 2, + ), + ); +}); + +test('缺少拒绝理由时不调用审核接口', async () => { + render( + , + ); + await screen.findByText('game_1'); + + fireEvent.click(screen.getByRole('button', { name: '拒绝' })); + + expect(await screen.findByText('拒绝审核必须填写理由')).toBeTruthy(); + expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled(); +}); + +test('安全下架需要二次确认,并携带公开修订号与原因', async () => { + vi.mocked(suspendAdminGameDistributionGame).mockResolvedValue({ + game: { + id: 'game_1', + title: '测试游戏', + status: 'suspended', + publicationRevision: 5, + }, + replayed: false, + }); + + render( + , + ); + await screen.findByText('game_1'); + + fireEvent.change(screen.getByLabelText('下架原因'), { + target: { value: '盗用素材' }, + }); + fireEvent.click(screen.getByRole('button', { name: '安全下架' })); + + // 第一次点击只弹出确认面板,不直接调用后端。 + expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled(); + expect(await screen.findByRole('dialog')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => + expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1), + ); + const [token, gameId, idempotencyKey, payload] = + vi.mocked(suspendAdminGameDistributionGame).mock.calls[0] ?? []; + expect(token).toBe('admin-token'); + expect(gameId).toBe('game_1'); + expect(String(idempotencyKey)).toContain('game_1'); + expect(payload).toEqual({ + expectedPublicationRevision: 4, + reason: '盗用素材', + }); + expect(await screen.findByText(/已安全下架/u)).toBeTruthy(); +}); + +test('取消确认时不下架', async () => { + render( + , + ); + await screen.findByText('game_1'); + + fireEvent.click(screen.getByRole('button', { name: '安全下架' })); + await screen.findByRole('dialog'); + fireEvent.click(screen.getByRole('button', { name: '取消' })); + + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled(); +}); diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx new file mode 100644 index 000000000..b1e993ed7 --- /dev/null +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx @@ -0,0 +1,365 @@ +import { RefreshCcw } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; + +import { + listAdminGameDistributionReviews, + reviewAdminGameDistributionVersion, + suspendAdminGameDistributionGame, +} from '../api/adminApiClient'; +import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes'; +import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; +import { handlePageError } from './pageUtils'; + +interface AdminGameDistributionReviewPageProps { + token: string; + onUnauthorized: (message?: string) => void; +} + +function formatBytes(value: number) { + if (value >= 1024 * 1024) { + return `${(value / (1024 * 1024)).toFixed(1)} MiB`; + } + if (value >= 1024) { + return `${(value / 1024).toFixed(1)} KiB`; + } + return `${value} B`; +} + +function formatTime(value: string) { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleString('zh-CN', { hour12: false }); +} + +function createSuspendIdempotencyKey(gameId: string) { + const random = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `game-suspend-${gameId}-${random}`.slice(0, 128); +} + +function createReviewIdempotencyKey(versionId: string) { + const random = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `game-review-${versionId}-${random}`.slice(0, 128); +} + +export function resolveGameReleaseEntryUrlError(value: string) { + const normalized = value.trim(); + if (!normalized) return '请填写发行入口'; + if (!normalized.startsWith('https://')) { + return '发行入口必须以 https:// 开头'; + } + if (normalized.includes('?') || normalized.includes('#')) { + return '发行入口不能包含 query 或 fragment'; + } + try { + const parsed = new URL(normalized); + if (parsed.username || parsed.password) { + return '发行入口不能包含凭据'; + } + } catch { + return '发行入口不是合法 URL'; + } + return ''; +} + +export function AdminGameDistributionReviewPage({ + token, + onUnauthorized, +}: AdminGameDistributionReviewPageProps) { + const [entries, setEntries] = useState( + [], + ); + const [isLoading, setIsLoading] = useState(false); + const [busyVersionId, setBusyVersionId] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [statusMessage, setStatusMessage] = useState(''); + const [entryUrlByVersion, setEntryUrlByVersion] = useState< + Record + >({}); + const [reasonByVersion, setReasonByVersion] = useState< + Record + >({}); + const [suspendReasonByGame, setSuspendReasonByGame] = useState< + Record + >({}); + const [busyGameId, setBusyGameId] = useState(''); + const writeConfirm = useAdminWriteConfirm(); + + const loadReviews = useCallback(async () => { + setIsLoading(true); + setErrorMessage(''); + try { + const response = await listAdminGameDistributionReviews(token); + setEntries(response.entries); + } catch (error) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsLoading(false); + } + }, [token, onUnauthorized]); + + useEffect(() => { + void loadReviews(); + }, [loadReviews]); + + async function submitReview( + entry: AdminGameDistributionReviewEntry, + decision: 'approve' | 'reject', + ) { + const entryUrl = (entryUrlByVersion[entry.versionId] ?? '').trim(); + const reason = (reasonByVersion[entry.versionId] ?? '').trim(); + if (decision === 'approve') { + const invalid = resolveGameReleaseEntryUrlError(entryUrl); + if (invalid) { + setErrorMessage(invalid); + return; + } + } else if (!reason) { + setErrorMessage('拒绝审核必须填写理由'); + return; + } + setBusyVersionId(entry.versionId); + setErrorMessage(''); + setStatusMessage(''); + try { + await reviewAdminGameDistributionVersion( + token, + entry.versionId, + createReviewIdempotencyKey(entry.versionId), + decision === 'approve' + ? { + decision, + expectedPublicationRevision: entry.publicationRevision, + entryUrl, + } + : { + decision, + expectedPublicationRevision: entry.publicationRevision, + reviewReason: reason, + }, + ); + setStatusMessage( + decision === 'approve' + ? `版本 v${entry.versionNumber} 已通过审核` + : `版本 v${entry.versionNumber} 已拒绝`, + ); + await loadReviews(); + } catch (error) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setBusyVersionId(''); + } + } + + /** + * 管理员安全下架:先二次确认,再带当前公开修订号调用后端;并发审核导致修订号变化时 + * 由服务端返回冲突,前端只提示刷新,不静默重试。 + */ + async function suspendGame(entry: AdminGameDistributionReviewEntry) { + const reason = (suspendReasonByGame[entry.gameId] ?? '').trim(); + const confirmed = await writeConfirm.confirmWrite({ + action: '安全下架游戏', + target: `${entry.gameId}(版本 v${entry.versionNumber})`, + }); + if (!confirmed) return; + setBusyGameId(entry.gameId); + setErrorMessage(''); + setStatusMessage(''); + try { + await suspendAdminGameDistributionGame( + token, + entry.gameId, + createSuspendIdempotencyKey(entry.gameId), + { + expectedPublicationRevision: entry.publicationRevision, + ...(reason ? { reason } : {}), + }, + ); + setStatusMessage(`游戏 ${entry.gameId} 已安全下架,发行入口已关闭`); + setSuspendReasonByGame((current) => ({ ...current, [entry.gameId]: '' })); + await loadReviews(); + } catch (error) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setBusyGameId(''); + } + } + + return ( +
+
+

游戏审核

+ +
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + {statusMessage ? ( +
+ {statusMessage} +
+ ) : null} + +
+
+

待审版本

+ 共 {entries.length} 条 +
+ + {isLoading ? ( +

正在加载待审版本…

+ ) : null} + + {!isLoading && entries.length === 0 ? ( +

当前没有待审核的游戏版本。

+ ) : null} + + {!isLoading && entries.length > 0 ? ( +
+ + + + + + + + + + + + {entries.map((entry) => { + const busy = busyVersionId === entry.versionId; + return ( + + + + + + + + ); + })} + +
游戏版本发行包提交时间审核
+ {entry.gameId} + + v{entry.versionNumber} +
{entry.status}
+ {entry.reviewReason ? ( +
+ {entry.reviewReason} +
+ ) : null} +
+ {formatBytes(entry.packageBytes)} +
+ {entry.packageSha256.slice(0, 12)} +
+
{formatTime(entry.createdAt)} +
+
+ + + setEntryUrlByVersion((current) => ({ + ...current, + [entry.versionId]: event.target.value, + })) + } + disabled={busy} + /> +
+ +
+ + + setReasonByVersion((current) => ({ + ...current, + [entry.versionId]: event.target.value, + })) + } + disabled={busy} + /> +
+ +
+ + + setSuspendReasonByGame((current) => ({ + ...current, + [entry.gameId]: event.target.value, + })) + } + disabled={busy} + /> +
+ +
+
+
+ ) : null} +
+ {writeConfirm.confirmDialog} +
+ ); +} diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 297415396..967bf3eb5 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -200,15 +200,16 @@ const allowedUncalledTauriCommands = [ 'read_agc_plugin_panel', 'set_agc_plugin_enabled', // 下面这些命令的调用方只有随 Project Supervisor 前端链路一起删除的旧命令聊天入口; - // 现在 App 前端、工作台与策划聊天都没有接线(检查点 / 恢复 / 索引 / 导出包 / + // 现在 App 前端、工作台与策划聊天都没有接线(检查点 / 恢复 / 索引 / // 画板同步 / 素材登记 / 权限策略 / 本地草案 / 平台美术),Rust 侧只剩注册与实现, // `*_at` helper 仍由 Rust 用例覆盖。接回新入口还是删除属于 native 能力取舍,先按 // native-only 登记,避免孤儿检查一直报错。 // 预览不在本清单:`activate_local_game_preview` 已按 ADR 回接到 App 的「运行」入口。 + // 导出试玩包同样不在本清单:发布链路(`requestGamePublish`)已把它接回 + // DirectProject 聊天头的「发布」入口。 'build_local_project_index', 'control_agent_run', 'create_local_project_checkpoint', - 'export_local_project_package', 'generate_local_game_draft', 'generate_platform_art_asset', 'import_canvas_asset', diff --git a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json index b62ed9e70..753d89477 100644 --- a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json +++ b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json @@ -16,7 +16,8 @@ { "url": "https://www.genarrative.world/api/*" }, { "url": "https://*/api/*" }, { "url": "http://localhost:*/*" }, - { "url": "http://127.0.0.1:*/*" } + { "url": "http://127.0.0.1:*/*" }, + { "url": "https://*.aliyuncs.com/*" } ] }, "opener:default", diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index fa4163a4d..96468794f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5985,6 +5985,16 @@ pub(crate) fn export_local_project_package( export_local_project_package_at(root) } +#[tauri::command] +pub(crate) fn read_local_project_export_package( + project_path: String, + package_relative_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.export_package")?; + read_local_project_export_package_at(root, package_relative_path.trim()) +} + #[tauri::command] pub(crate) fn list_local_project_export_packages( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 151a18cfe..5f3cfe2e6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2717,6 +2717,7 @@ fn main() { build_local_project_index, create_local_project_checkpoint, export_local_project_package, + read_local_project_export_package, list_local_project_export_packages, diff_local_project_checkpoint, restore_local_project_checkpoint, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs index 6b4f7f43c..cb8fc948c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs @@ -1,5 +1,27 @@ use super::*; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::io::{Cursor, Read}; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalProjectExportPackageFileDigest { + pub(crate) path: String, + pub(crate) size_bytes: u64, + pub(crate) sha256: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalProjectExportPackagePayload { + pub(crate) package_relative_path: String, + pub(crate) package_bytes: Vec, + pub(crate) package_sha256: String, + pub(crate) package_size_bytes: u64, + pub(crate) files: Vec, +} + pub(crate) fn export_local_project_package_at( root: &Path, ) -> Result { @@ -110,6 +132,114 @@ pub(crate) fn export_local_project_package_at( }) } +/// Read a previously exported package for the explicit AGC publish flow. +/// +/// The caller receives the package bytes and a deterministic file manifest, but +/// never receives a filesystem path that it could accidentally send to the API. +pub(crate) fn read_local_project_export_package_at( + root: &Path, + package_relative_path: &str, +) -> Result { + validate_project_root(root)?; + let normalized = normalize_export_package_entry_path(package_relative_path)?; + if !normalized.starts_with("exports/playtest-package-") + || !normalized.ends_with(".zip") + || normalized.contains('/') && normalized.split('/').count() != 2 + { + return Err("发行包路径必须是 exports/playtest-package-*.zip".to_string()); + } + let package_path = resolve_local_project_path(root, &normalized)?; + prepare_game_creator_private_path_for_read(&package_path, false, "发行包")?; + let metadata = checked_export_package_metadata(&package_path, &normalized)?; + if !metadata.is_file() { + return Err("发行包必须是普通文件".to_string()); + } + if metadata.len() == 0 || metadata.len() > MAX_PROJECT_EXPORT_PACKAGE_BYTES { + return Err("发行包大小超出本地发布上限".to_string()); + } + let package_bytes = + fs::read(&package_path).map_err(|error| format!("读取发行包失败:{error}"))?; + if package_bytes.len() as u64 != metadata.len() { + return Err("发行包在读取期间发生变化,请重新导出".to_string()); + } + + let mut archive = zip::ZipArchive::new(Cursor::new(&package_bytes)) + .map_err(|error| format!("读取发行包 ZIP 失败:{error}"))?; + let mut entries = Vec::with_capacity(archive.len()); + let mut seen = BTreeSet::new(); + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .map_err(|error| format!("读取发行包条目失败:{error}"))?; + if entry.is_dir() { + continue; + } + let source_path = normalize_export_package_entry_path(entry.name())?; + // 本地试玩包以 game/index.html 为入口,而平台发行合同要求根 + // index.html。把 game/ 前缀剥离到内存 ZIP,避免上传本地路径或修改 + // 工作区里的原始导出文件;根目录的 README/assets 等公共条目原样保留。 + let path = source_path + .strip_prefix("game/") + .unwrap_or(source_path.as_str()) + .to_string(); + let path = normalize_export_package_entry_path(&path)?; + if !seen.insert(path.clone()) { + return Err(format!("发行包包含重复条目:{path}")); + } + let expected_size = entry.size(); + let mut content = Vec::with_capacity(expected_size.min(16 * 1024 * 1024) as usize); + entry + .read_to_end(&mut content) + .map_err(|error| format!("读取发行包文件失败:{path}: {error}"))?; + if content.len() as u64 != expected_size { + return Err(format!("发行包条目长度不一致:{path}")); + } + entries.push((path, content)); + } + entries.sort_by(|left, right| left.0.cmp(&right.0)); + if entries.is_empty() { + return Err("发行包没有可上传文件".to_string()); + } + + let mut normalized_writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + for (path, content) in &entries { + normalized_writer + .start_file(path, options) + .map_err(|error| format!("写入发行包条目失败:{path}: {error}"))?; + normalized_writer + .write_all(content) + .map_err(|error| format!("写入发行包文件失败:{path}: {error}"))?; + } + let normalized_cursor = normalized_writer + .finish() + .map_err(|error| format!("完成发行包失败:{error}"))?; + let package_bytes = normalized_cursor.into_inner(); + if package_bytes.is_empty() || package_bytes.len() as u64 > MAX_PROJECT_EXPORT_PACKAGE_BYTES { + return Err("归一化发行包大小超出本地发布上限".to_string()); + } + let package_sha256 = format!("{:x}", Sha256::digest(&package_bytes)); + let files = entries + .into_iter() + .map(|(path, content)| LocalProjectExportPackageFileDigest { + size_bytes: content.len() as u64, + sha256: format!("{:x}", Sha256::digest(&content)), + path, + }) + .collect::>(); + if !files.iter().any(|file| file.path == "index.html") { + return Err("归一化发行包缺少根 index.html".to_string()); + } + Ok(LocalProjectExportPackagePayload { + package_relative_path: normalized, + package_size_bytes: package_bytes.len() as u64, + package_bytes, + package_sha256, + files, + }) +} + pub(crate) fn next_project_export_package_relative_path(root: &Path) -> Result { let seed = unix_millis(); for suffix in 0..1000 { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 6e9abb9e8..a67c171f5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -3935,6 +3935,54 @@ fn local_project_export_package_uses_runtime_whitelist_and_records() { fs::remove_dir_all(root).ok(); } +#[test] +fn local_project_export_package_publish_payload_contains_bytes_and_file_digests() { + let root = unique_project_path(); + init_existing_html_project_at(&root, "project-publish", "在线试玩项目").expect("project init"); + write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html) + .expect("write playable html"); + write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme"); + + let exported = export_local_project_package_at(&root).expect("export package"); + let payload = read_local_project_export_package_at(&root, &exported.package_relative_path) + .expect("read publish payload"); + + assert_eq!( + payload.package_relative_path, + exported.package_relative_path + ); + assert_eq!( + payload.package_size_bytes, + payload.package_bytes.len() as u64 + ); + assert_eq!(payload.files.len(), 2); + assert!(payload.files.iter().any(|file| file.path == "index.html")); + assert!(payload + .files + .iter() + .any(|file| file.path == "exports/README.md")); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&payload.package_bytes)) + .expect("read normalized package"); + let names = (0..archive.len()) + .map(|index| { + archive + .by_index(index) + .expect("normalized entry") + .name() + .to_string() + }) + .collect::>(); + assert!(names.iter().any(|name| name == "index.html")); + assert!(!names.iter().any(|name| name.starts_with("game/"))); + assert_eq!(payload.package_sha256.len(), 64); + assert!(payload + .package_sha256 + .chars() + .all(|value| value.is_ascii_hexdigit())); + + fs::remove_dir_all(root).ok(); +} + #[test] fn local_project_export_package_list_only_returns_recent_playtest_zips() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index eeb39a38d..7884f201f 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -40,12 +40,14 @@ import type { LocalGameProjectRevisionStatus, LocalPreviewResult, LocalPreviewStatus, + LocalProjectExportPackageResult, LocalProjectFileResult, LocalProjectKind, PendingUiConfirmation, ProjectPermissionPolicyView, TauriInvoke, } from './app/types'; +import { GameDistributionPublishPanel } from './components/game-distribution/GameDistributionPublishPanel'; import { agentConversationId, agentRuntimeStateFromResult, @@ -91,6 +93,7 @@ import { type ResourceReferenceInsertEventDetail, } from './features/project-workspace/resourceReferences'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; +import { readGamePublishAvailability } from './services/gameDistributionPublish'; import { setAgcPluginProjectPath, startAvailableAgcEditorPlugins, @@ -363,6 +366,12 @@ export function App({ const chatComposerRef = useRef(null); const [chatAgentBusy, setChatAgentBusy] = useState(false); + // 发布到游戏广场:试玩包导出结果与面板开关由工作台壳持有,聊天容器只负责触发。 + const [publishPackageResult, setPublishPackageResult] = + useState(null); + const [publishPanelOpen, setPublishPanelOpen] = useState(false); + // 发布灰度:只有命中的账号才把「发布到游戏广场」入口交给聊天头;读取失败按不开放处理。 + const [gamePublishAllowed, setGamePublishAllowed] = useState(false); const [projectChatError, setProjectChatError] = useState(''); const [designAgentTransientReply, setDesignAgentTransientReplyVisible] = useState(''); @@ -1179,6 +1188,71 @@ export function App({ } } + useEffect(() => { + let cancelled = false; + void readGamePublishAvailability() + .then((allowed) => { + if (!cancelled) setGamePublishAllowed(allowed); + }) + .catch(() => { + if (!cancelled) setGamePublishAllowed(false); + }); + return () => { + cancelled = true; + }; + }, [localProject?.projectPath]); + + /** + * 导出试玩包并打开发布面板。 + * + * 权限口径沿用本地命令:`project.export_package` 需要确认时先入队,确认后再导出; + * 导出结果只留在壳里,发布面板关闭即丢弃,不写入项目。 + */ + async function requestGamePublish() { + const invoke = resolveTauriInvoke(); + if (!invoke) { + setWorkspaceStatus('需要在 Tauri App 内发布'); + return; + } + const nextProjectPath = + resolveChatProjectPath(localProject) ?? projectPath.trim(); + if (!nextProjectPath) { + setWorkspaceStatus('先打开一个项目再发布'); + return; + } + const runExport = async () => { + try { + const result = await invoke( + 'export_local_project_package', + { projectPath: nextProjectPath }, + ); + setWorkspaceStatus(`已导出本地试玩包:${result.packageRelativePath}`); + setPublishPackageResult(result); + setPublishPanelOpen(true); + appendLocalPermissionLog( + nextProjectPath, + 'command.auto', + 'project.export_package', + ); + } catch (error) { + setWorkspaceStatus( + error instanceof Error ? error.message : String(error), + ); + } + }; + const queued = await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'project.export_package', + nextProjectPath, + '导出试玩包并打开「发布到游戏广场」面板。', + '导出试玩包需要确认,确认后继续。', + () => void runExport(), + ); + if (!queued) { + await runExport(); + } + } + async function confirmUiCommand() { const pending = pendingUiConfirmation; if (!pending) { @@ -2232,13 +2306,25 @@ export function App({ // 普通项目固定走 DirectProject 自己的聊天容器:订阅、历史、发送、队列和附件都由 // 容器持有,工作台壳只提供项目身份、入口首轮需求和两条权限门。 return ( - + <> + + setPublishPanelOpen(false)} + /> + ); } @@ -2384,6 +2470,13 @@ export function App({ onClose={() => setRuntimeConfigOpen(false)} /> ) : null} + setPublishPanelOpen(false)} + /> ); } diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 210cbd489..227966041 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -801,6 +801,20 @@ export interface LocalProjectExportPackageResult { totalBytes: number; } +export interface LocalProjectExportPackageFileDigest { + path: string; + sizeBytes: number; + sha256: string; +} + +export interface LocalProjectExportPackagePayload { + packageRelativePath: string; + packageBytes: number[]; + packageSha256: string; + packageSizeBytes: number; + files: LocalProjectExportPackageFileDigest[]; +} + export interface LocalProjectExportPackageSummary { packagePath: string; packageRelativePath: string; diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx new file mode 100644 index 000000000..5f11e3f37 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx @@ -0,0 +1,511 @@ +import { useEffect, useRef, useState } from 'react'; + +import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { resolveTauriInvoke } from '../../app/tauri'; +import type { LocalProjectExportPackageResult } from '../../app/types'; +import { uploadPlatformMediaAsset } from '../../services/assetDirectUpload'; +import { + createGameDistributionPublishKey, + type GameDistributionPublishMetadata, + type GameDistributionPublishResult, + MAX_AGC_GAME_SCREENSHOTS, + publishLocalProjectGame, +} from '../../services/gameDistributionPublish'; +import { ThemedModal } from '../modal/ThemedModal'; + +/** 封面与截图都是公开展示素材,限制单张体积,避免手机原图直传拖垮发布流程。 */ +const PANEL_IMAGE_MAX_BYTES = 6 * 1024 * 1024; +const PANEL_IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif'; + +type PanelImageKind = 'cover' | 'screenshot'; + +type PanelImageAsset = { + /** 文件签名;同一文件重复选择时复用已上传素材,不重复直传。 */ + signature: string; + name: string; + assetObjectId: string; + previewUrl: string; +}; + +function resolvePanelImageLabel(kind: PanelImageKind) { + return kind === 'cover' ? '游戏封面' : '游戏截图'; +} + +/** 本地预检;服务端仍会独立校验素材归属与图片类型。 */ +function resolvePanelImageFileError(file: File, kind: PanelImageKind) { + const label = resolvePanelImageLabel(kind); + if (file.size <= 0) return `${label}文件为空,请重新选择`; + if (file.size > PANEL_IMAGE_MAX_BYTES) { + return `${label}过大,请压缩后再上传(最多 6MB)`; + } + const contentType = file.type.trim(); + if (contentType && !contentType.startsWith('image/')) { + return `${label}必须是图片文件`; + } + return ''; +} + +function buildPanelImageSignature(file: File, kind: PanelImageKind) { + return `${kind}:${file.name}:${file.size}:${file.lastModified}`; +} + +/** 预览只在 WebView 支持 object URL 时生成;否则退回文字占位,不影响上传。 */ +function buildPanelImagePreviewUrl(file: File) { + if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') { + return ''; + } + try { + return URL.createObjectURL(file); + } catch { + return ''; + } +} + +const CATEGORIES = [ + '休闲', + '益智', + '动作', + '冒险', + '模拟', + '策略', + '其他', +] as const; + +export function GameDistributionPublishPanel({ + open, + projectPath, + manifest, + packageResult, + onClose, + onPublished, +}: { + open: boolean; + projectPath: string; + manifest: GameCreationAppManifest; + packageResult: LocalProjectExportPackageResult | null; + onClose: () => void; + onPublished?: (result: GameDistributionPublishResult) => void; +}) { + const [title, setTitle] = useState(''); + const [summary, setSummary] = useState(''); + const [category, setCategory] = + useState('其他'); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [result, setResult] = useState( + null, + ); + const [cover, setCover] = useState(null); + const [screenshots, setScreenshots] = useState([]); + const [uploadingLabel, setUploadingLabel] = useState(''); + const publishIdempotencyKeyRef = useRef(''); + const coverInputRef = useRef(null); + const screenshotInputRef = useRef(null); + // 文件签名 → 素材 ID:同一次打开面板重复提交不会重复上传同一张图。 + const uploadedAssetCacheRef = useRef(new Map()); + const objectUrlsRef = useRef(new Set()); + + useEffect(() => { + if (!open) return; + setTitle(manifest.name.trim()); + setSummary( + (manifest.goal ?? '由陶泥儿创作的可在线游玩游戏').trim().slice(0, 120), + ); + setCategory('其他'); + setBusy(false); + setError(''); + setResult(null); + }, [manifest.goal, manifest.name, open, packageResult?.packageRelativePath]); + + useEffect(() => { + if (open) { + publishIdempotencyKeyRef.current = createGameDistributionPublishKey(); + } + }, [open, packageResult?.packageRelativePath]); + + useEffect(() => { + const objectUrls = objectUrlsRef.current; + return () => { + objectUrls.forEach((url) => { + try { + URL.revokeObjectURL(url); + } catch { + // 预览地址释放失败不影响关闭面板。 + } + }); + objectUrls.clear(); + }; + }, []); + + async function resolveAssetObjectId(file: File, kind: PanelImageKind) { + const signature = buildPanelImageSignature(file, kind); + const cached = uploadedAssetCacheRef.current.get(signature); + if (cached) return cached; + const uploaded = await uploadPlatformMediaAsset({ + file, + assetKind: + kind === 'cover' + ? 'game_distribution_cover' + : 'game_distribution_screenshot', + pathSegments: ['game-distribution', kind, `${Date.now()}`], + entityId: `game-distribution-${kind}`, + metadata: { game_distribution_media: kind }, + }); + uploadedAssetCacheRef.current.set(signature, uploaded.assetObjectId); + return uploaded.assetObjectId; + } + + function buildPanelImageAsset( + file: File, + kind: PanelImageKind, + assetObjectId: string, + ) { + const previewUrl = buildPanelImagePreviewUrl(file); + if (previewUrl) objectUrlsRef.current.add(previewUrl); + return { + signature: buildPanelImageSignature(file, kind), + name: file.name.trim() || resolvePanelImageLabel(kind), + assetObjectId, + previewUrl, + }; + } + + async function handleCoverSelected(file: File | null) { + if (!file) return; + setError(''); + const fileError = resolvePanelImageFileError(file, 'cover'); + if (fileError) { + setError(fileError); + return; + } + setUploadingLabel('正在上传封面…'); + try { + const assetObjectId = await resolveAssetObjectId(file, 'cover'); + setCover(buildPanelImageAsset(file, 'cover', assetObjectId)); + } catch (uploadError) { + setError( + uploadError instanceof Error + ? uploadError.message + : '封面上传失败,请重试', + ); + } finally { + setUploadingLabel(''); + if (coverInputRef.current) coverInputRef.current.value = ''; + } + } + + async function handleScreenshotsSelected(files: File[]) { + if (files.length === 0) return; + const remaining = MAX_AGC_GAME_SCREENSHOTS - screenshots.length; + if (files.length > remaining) { + setError( + remaining > 0 + ? `游戏截图最多 6 张,还可以再选 ${remaining} 张` + : '游戏截图最多 6 张', + ); + if (screenshotInputRef.current) screenshotInputRef.current.value = ''; + return; + } + setError(''); + setUploadingLabel('正在上传截图…'); + try { + for (const file of files) { + const fileError = resolvePanelImageFileError(file, 'screenshot'); + if (fileError) { + setError(fileError); + return; + } + const assetObjectId = await resolveAssetObjectId(file, 'screenshot'); + // 逐张入库:中途失败时已传好的截图保留,作者不用重新选择。 + setScreenshots((current) => + current.length >= MAX_AGC_GAME_SCREENSHOTS + ? current + : [ + ...current, + buildPanelImageAsset(file, 'screenshot', assetObjectId), + ], + ); + } + } catch (uploadError) { + setError( + uploadError instanceof Error + ? uploadError.message + : '截图上传失败,请重试', + ); + } finally { + setUploadingLabel(''); + if (screenshotInputRef.current) screenshotInputRef.current.value = ''; + } + } + + async function handleSubmit() { + if (!packageResult || !projectPath.trim()) { + setError('请先导出有效的试玩包'); + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setError('需要在 Tauri App 内发布'); + return; + } + if (!cover) { + setError('请先选择游戏封面(JPG/PNG/WebP)'); + return; + } + if (uploadingLabel) { + setError('素材还在上传中,请稍候再发布'); + return; + } + setBusy(true); + setError(''); + try { + const next = await publishLocalProjectGame({ + invoke, + projectPath, + packageRelativePath: packageResult.packageRelativePath, + manifest, + metadata: { + title, + summary, + category, + coverAssetId: cover.assetObjectId, + screenshots: screenshots.map((item) => item.assetObjectId), + }, + idempotencyKey: publishIdempotencyKeyRef.current, + }); + setResult(next); + onPublished?.(next); + } catch (nextError) { + setError( + nextError instanceof Error ? nextError.message : String(nextError), + ); + } finally { + setBusy(false); + } + } + + return ( + undefined : onClose} + panelClassName="game-distribution-publish-panel" + closeOnBackdrop={!busy} + closeOnEscape={!busy} + > +
+
+ + 发布到游戏广场 + +

让玩家现在就能试玩

+
+ +
+ {result ? ( +
+ 已提交审核 +

版本已进入审核队列,审核通过后才会在游戏广场公开展示。

+

+ 版本 {result.versionNumber} ·{' '} + {result.packageSizeBytes.toLocaleString()} B ·{' '} + {result.packageSha256.slice(0, 16)}… +

+
+ +
+
+ ) : ( + <> +

+ 仅上传已导出的 ZIP 字节和摘要信息;不会上传本地路径或项目源码快照。 +

+
+ {packageResult?.packageRelativePath ?? '未找到试玩包'} + + {packageResult + ? `${packageResult.fileCount} 个文件 · ${packageResult.totalBytes.toLocaleString()} B` + : '请先导出'} + +
+
+ +