diff --git a/apps/ai-game-creator-shell/src/components/modal/MaintenanceNotice.tsx b/apps/ai-game-creator-shell/src/components/modal/MaintenanceNotice.tsx new file mode 100644 index 000000000..874c2b87b --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/modal/MaintenanceNotice.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react'; + +import { CLIENT_MAINTENANCE_EVENT } from '../../services/clientApi'; +import { ThemedModal } from './ThemedModal'; + +/** 维护响应的唯一客户端出口,避免各业务面板把同一个 503 展示成不同兜底错误。 */ +export function MaintenanceNotice() { + const [open, setOpen] = useState(false); + + useEffect(() => { + const handleMaintenance = () => setOpen(true); + window.addEventListener(CLIENT_MAINTENANCE_EVENT, handleMaintenance); + return () => + window.removeEventListener(CLIENT_MAINTENANCE_EVENT, handleMaintenance); + }, []); + + return ( + setOpen(false)} + > + + + 🛠️ + + + 系统维护中 + + + 服务正在维护,当前操作暂时无法完成。请稍后再试。 + + setOpen(false)} + > + 我知道了 + + + + ); +} diff --git a/apps/ai-game-creator-shell/src/main.tsx b/apps/ai-game-creator-shell/src/main.tsx index 2bb5681dc..38aeea0bd 100644 --- a/apps/ai-game-creator-shell/src/main.tsx +++ b/apps/ai-game-creator-shell/src/main.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import { AuthenticatedClient, WorkspaceLauncher } from './App'; +import { MaintenanceNotice } from './components/modal/MaintenanceNotice'; import { WindowChrome } from './components/WindowChrome'; createRoot(document.getElementById('root') as HTMLElement).render( @@ -16,6 +17,7 @@ createRoot(document.getElementById('root') as HTMLElement).render( )} + , ); diff --git a/apps/ai-game-creator-shell/src/services/clientApi.ts b/apps/ai-game-creator-shell/src/services/clientApi.ts index 9c9416b6b..5531f4d17 100644 --- a/apps/ai-game-creator-shell/src/services/clientApi.ts +++ b/apps/ai-game-creator-shell/src/services/clientApi.ts @@ -11,6 +11,7 @@ import { } from '../../../../packages/shared/src'; import { getStoredAuthAccessToken } from './clientAuth'; import { fetchClientHttp, readClientHttpResponseText } from './clientHttp'; +import { emitClientMaintenanceEvent } from './clientMaintenance'; import { captureClientError } from './errorReporting'; import { currentPlatformSessionGeneration, @@ -22,36 +23,97 @@ export { getStoredAuthAccessToken, setStoredAuthAccessToken, } from './clientAuth'; +export { CLIENT_MAINTENANCE_EVENT } from './clientMaintenance'; + +type ClientApiErrorOptions = { + status?: number | null; + networkError?: boolean; + code?: string; + requestId?: string; +}; export class ClientAuthRequestError extends Error { readonly status: number | null; readonly networkError: boolean; + readonly code: string; + readonly requestId: string; - constructor( - message: string, - options: { status?: number | null; networkError?: boolean } = {}, - ) { + constructor(message: string, options: ClientApiErrorOptions = {}) { super(message); + this.name = 'ClientAuthRequestError'; this.status = options.status ?? null; this.networkError = options.networkError ?? false; + this.code = + options.code?.trim() || + (this.status ? `HTTP_${this.status}` : 'CLIENT_ERROR'); + this.requestId = options.requestId?.trim() ?? ''; } } -async function readApiErrorMessage( +export function isClientMaintenanceError( + error: unknown, +): error is ClientAuthRequestError { + return ( + error instanceof ClientAuthRequestError && + (error.code.toUpperCase() === 'MAINTENANCE' || + (error.status === 503 && error.message.includes('维护'))) + ); +} + +export function emitClientMaintenanceNotice(error: unknown) { + if (!isClientMaintenanceError(error) || typeof window === 'undefined') return; + const maintenanceError = error as ClientAuthRequestError; + emitClientMaintenanceEvent({ + code: maintenanceError.code, + status: maintenanceError.status, + requestId: maintenanceError.requestId || undefined, + }); +} + +type ParsedClientApiError = { + message: string; + code: string; + requestId: string; +}; + +async function readApiErrorInfo( response: Response, fallback: string, url: string, -) { +): Promise { const text = await readClientHttpResponseText(response, { url }); if (!text.trim()) { - return fallback; + return { + message: fallback, + code: `HTTP_${response.status}`, + requestId: '', + }; } try { - unwrapApiResponse(JSON.parse(text) as unknown); - } catch (error) { - return error instanceof Error ? error.message : fallback; + const parsed = JSON.parse(text) as { + error?: { code?: unknown; message?: unknown }; + meta?: { requestId?: unknown }; + }; + const message = + typeof parsed.error?.message === 'string' && parsed.error.message.trim() + ? parsed.error.message.trim() + : fallback; + const code = + typeof parsed.error?.code === 'string' && parsed.error.code.trim() + ? parsed.error.code.trim() + : `HTTP_${response.status}`; + const requestId = + typeof parsed.meta?.requestId === 'string' + ? parsed.meta.requestId.trim() + : ''; + return { message, code, requestId }; + } catch { + return { + message: fallback, + code: `HTTP_${response.status}`, + requestId: '', + }; } - return fallback; } function captureApiErrorStatus(url: string, response: Response) { @@ -118,10 +180,14 @@ export async function requestClientApi( } if (!response.ok) { captureApiErrorStatus(url, response); - throw new ClientAuthRequestError( - await readApiErrorMessage(response, fallbackMessage, url), - { status: response.status }, - ); + const errorInfo = await readApiErrorInfo(response, fallbackMessage, url); + const error = new ClientAuthRequestError(errorInfo.message, { + status: response.status, + code: errorInfo.code, + requestId: errorInfo.requestId, + }); + emitClientMaintenanceNotice(error); + throw error; } const text = await readClientHttpResponseText(response, { url }); return text ? unwrapApiResponse(JSON.parse(text) as T) : (null as T); @@ -155,10 +221,14 @@ export async function requestClientApiBytes( } if (!response.ok) { captureApiErrorStatus(url, response); - throw new ClientAuthRequestError( - await readApiErrorMessage(response, fallbackMessage, url), - { status: response.status }, - ); + const errorInfo = await readApiErrorInfo(response, fallbackMessage, url); + const error = new ClientAuthRequestError(errorInfo.message, { + status: response.status, + code: errorInfo.code, + requestId: errorInfo.requestId, + }); + emitClientMaintenanceNotice(error); + throw error; } return response; } diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index 13441c5ef..5d50699fd 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -22,6 +22,7 @@ import { getClientServerBaseUrl, readClientHttpResponseText, } from './clientHttp'; +import { emitClientMaintenanceEvent } from './clientMaintenance'; import { type ClientOperation, createClientOperation, @@ -249,10 +250,11 @@ async function requestAuthJson( }); } if (!response.ok) { - throw new ClientAuthRequestError( - await readAuthErrorMessage(response, fallbackMessage), - { status: response.status }, - ); + const message = await readAuthErrorMessage(response, fallbackMessage); + if (response.status === 503 && message.includes('维护')) { + emitClientMaintenanceEvent({ status: response.status }); + } + throw new ClientAuthRequestError(message, { status: response.status }); } const text = await readClientHttpResponseText(response, { url, diff --git a/apps/ai-game-creator-shell/src/services/clientMaintenance.ts b/apps/ai-game-creator-shell/src/services/clientMaintenance.ts new file mode 100644 index 000000000..91072149c --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/clientMaintenance.ts @@ -0,0 +1,15 @@ +export const CLIENT_MAINTENANCE_EVENT = + 'genarrative-client-maintenance-detected'; + +export type ClientMaintenanceDetail = { + code?: string; + status?: number | null; + requestId?: string; +}; + +export function emitClientMaintenanceEvent( + detail: ClientMaintenanceDetail = {}, +) { + if (typeof window === 'undefined') return; + window.dispatchEvent(new CustomEvent(CLIENT_MAINTENANCE_EVENT, { detail })); +} diff --git a/apps/ai-game-creator-shell/tests/maintenanceNotice.test.tsx b/apps/ai-game-creator-shell/tests/maintenanceNotice.test.tsx new file mode 100644 index 000000000..7b9813276 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/maintenanceNotice.test.tsx @@ -0,0 +1,45 @@ +/** @vitest-environment jsdom */ +import { act, cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { MaintenanceNotice } from '../src/components/modal/MaintenanceNotice'; +import { + CLIENT_MAINTENANCE_EVENT, + ClientAuthRequestError, + emitClientMaintenanceNotice, +} from '../src/services/clientApi'; + +afterEach(() => cleanup()); + +describe('客户端维护大弹窗', () => { + it('收到维护事件时统一展示大弹窗', () => { + render(); + + act(() => { + window.dispatchEvent( + new CustomEvent(CLIENT_MAINTENANCE_EVENT, { + detail: { code: 'MAINTENANCE', status: 503 }, + }), + ); + }); + + expect(screen.getByRole('dialog', { name: '系统维护中' })).toBeTruthy(); + expect( + screen.getByText('服务正在维护,当前操作暂时无法完成。请稍后再试。'), + ).toBeTruthy(); + expect(screen.getByRole('button', { name: '我知道了' })).toBeTruthy(); + }); + + it('普通业务错误不会上报维护事件', () => { + const received: Event[] = []; + const handler = (event: Event) => received.push(event); + window.addEventListener(CLIENT_MAINTENANCE_EVENT, handler); + + emitClientMaintenanceNotice( + new ClientAuthRequestError('创建平台游戏失败', { status: 500 }), + ); + + expect(received).toHaveLength(0); + window.removeEventListener(CLIENT_MAINTENANCE_EVENT, handler); + }); +}); diff --git a/docs/project-memory/plans/【实施计划】AGC统一错误诊断与验收反馈-2026-09-15.md b/docs/project-memory/plans/【实施计划】AGC统一错误诊断与验收反馈-2026-09-15.md index 031cd576c..8b103da65 100644 --- a/docs/project-memory/plans/【实施计划】AGC统一错误诊断与验收反馈-2026-09-15.md +++ b/docs/project-memory/plans/【实施计划】AGC统一错误诊断与验收反馈-2026-09-15.md @@ -33,3 +33,10 @@ Parent Milestone: `【里程碑】AGC统一错误诊断与验收反馈-2026-09-1 - 若前端详情读取失败,仍展示安全 `publicText`,不阻塞错误终态。 - 若素材身份无法映射,继续失败关闭并记录明确 code,不回退为路径字符串通过。 - 回滚可删除新事件写入和详情入口,保留旧 `failure.json` 读取兼容。 + +## 2026-09-23 维护态错误展示补充 + +- `apps/ai-game-creator-shell/src/services/clientApi.ts` 解析并保留维护响应的 `error.code`、HTTP 状态与 `meta.requestId`,识别网关 `MAINTENANCE` 后广播客户端维护事件。 +- `apps/ai-game-creator-shell/src/components/modal/MaintenanceNotice.tsx` 在客户端根部统一展示不可被局部业务兜底替代的大弹窗;发布、上传、资源换签等共用 `requestClientApi` 的请求均进入同一出口。 +- 普通 500、资源损坏和本地 `blob:` 图片预览失败不自动归类为维护;图片换签接口在维护期间失败时会触发统一弹窗,但本地刚选中的图片预览仍不依赖后端。 +- 验收补充:维护期间发布接口不能只显示“创建平台游戏失败”等局部文案;维护弹窗出现一次即可覆盖并发失败请求,关闭后业务页仍可重试。 diff --git a/src/active-main.tsx b/src/active-main.tsx index f71ccee81..2020f055f 100644 --- a/src/active-main.tsx +++ b/src/active-main.tsx @@ -7,6 +7,7 @@ import { StrictMode, Suspense } from 'react'; import { createRoot } from 'react-dom/client'; import { FloatingFeedbackEntry } from './components/common/FloatingFeedbackEntry'; +import { MaintenanceNotice } from './components/common/MaintenanceNotice'; import { stabilizeMobileViewportKeyboardFocus } from './mobileViewportKeyboardFocus'; import { lockMobileViewportZoom } from './mobileViewportZoomLock'; import { resolveAppRoute } from './routing/activeAppRoutes'; @@ -48,6 +49,7 @@ void refreshNativeAppHostRuntime(); root.render( {routeElement} + {route.kind === 'platform' ? : null} , ); diff --git a/src/components/common/MaintenanceNotice.tsx b/src/components/common/MaintenanceNotice.tsx new file mode 100644 index 000000000..b5d93385a --- /dev/null +++ b/src/components/common/MaintenanceNotice.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from 'react'; + +import { MAINTENANCE_EVENT } from '../../services/apiClient'; +import { PlatformActionButton } from './PlatformActionButton'; +import { UnifiedModal } from './UnifiedModal'; + +type MaintenanceEventDetail = { + code?: string; + status?: number; + requestId?: string; +}; + +function isMaintenanceEvent( + event: Event, +): event is CustomEvent { + return event.type === MAINTENANCE_EVENT; +} + +/** + * 维护态的唯一客户端出口。请求层识别到维护响应后广播事件,页面不再各自展示业务兜底错误。 + */ +export function MaintenanceNotice() { + const [open, setOpen] = useState(false); + + useEffect(() => { + const handleMaintenance = (event: Event) => { + if (isMaintenanceEvent(event)) { + setOpen(true); + } + }; + window.addEventListener(MAINTENANCE_EVENT, handleMaintenance); + return () => + window.removeEventListener(MAINTENANCE_EVENT, handleMaintenance); + }, []); + + return ( + setOpen(false)} + size="lg" + closeOnBackdrop={false} + showCloseButton={false} + portalTheme="light" + panelClassName="min-h-[min(42vh,28rem)]" + bodyClassName="flex flex-1 items-center justify-center px-6 py-10 sm:px-12 sm:py-16" + footerClassName="justify-center px-6 py-5 sm:px-12" + footer={ + setOpen(false)} + tone="primary" + size="md" + shape="pill" + className="min-w-36" + > + 我知道了 + + } + > + + 为避免维护期间出现不一致,相关页面错误会统一收口到此提示。 + + + ); +} diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index f594ca662..2a528d7b6 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -12,6 +12,7 @@ import { getHostRuntime } from './host-bridge/hostBridge'; const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1'; export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed'; +export const MAINTENANCE_EVENT = 'genarrative-maintenance-detected'; const REQUEST_ID_HEADER = 'x-request-id'; const API_VERSION_HEADER = 'x-api-version'; const ROUTE_VERSION_HEADER = 'x-route-version'; @@ -505,6 +506,38 @@ function shouldRetryResponse( ); } +export function isMaintenanceApiError(error: unknown) { + return ( + error instanceof ApiClientError && + (error.code.trim().toUpperCase() === 'MAINTENANCE' || + (error.status === 503 && error.message.includes('维护'))) + ); +} + +export function emitMaintenanceNotice(error?: unknown) { + if (typeof globalThis.dispatchEvent !== 'function') { + return; + } + + if (error !== undefined && !isMaintenanceApiError(error)) { + return; + } + + const detail = + error instanceof ApiClientError + ? { + code: error.code, + status: error.status, + requestId: error.meta.requestId, + } + : undefined; + const event = + typeof CustomEvent === 'function' + ? new CustomEvent(MAINTENANCE_EVENT, { detail }) + : new Event(MAINTENANCE_EVENT); + globalThis.dispatchEvent(event); +} + export function isAbortError(error: unknown) { return ( error instanceof Error && @@ -1001,7 +1034,7 @@ async function buildApiClientError( undefined; const baseMessage = parseApiErrorMessage(responseText, fallbackMessage); - return new ApiClientError({ + const error = new ApiClientError({ message: requestId ? `${baseMessage}(requestId: ${requestId})` : baseMessage, @@ -1024,6 +1057,10 @@ async function buildApiClientError( }, responseText, }); + if (isMaintenanceApiError(error)) { + emitMaintenanceNotice(error); + } + return error; } export async function requestJson(
+ 服务正在维护,当前操作暂时无法完成。请稍后再试。 +