统一客户端维护态错误弹窗
Project CI / AI game creator shell Rust crates (push) Successful in 1m30s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m57s
Project CI / Backend tests (push) Successful in 3m54s
Project CI / Frontend tests (push) Successful in 2m5s
Project CI / Native shell tests (push) Successful in 6m4s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m14s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m19s
Project CI / AI game creator shell web tests (push) Successful in 1m23s
Project CI / Repository checks (push) Successful in 1m55s

客户端维护响应统一识别并广播维护事件

AGC 与网页端根部展示维护大弹窗,收口发布、上传、资源换签和登录错误

补充维护态弹窗测试、类型检查与实施计划说明
This commit is contained in:
2026-09-23 23:16:23 +08:00
parent 093f832ef9
commit efbdf7031e
10 changed files with 318 additions and 24 deletions
@@ -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 (
<ThemedModal
open={open}
ariaLabel="系统维护中"
theme="light"
closeOnBackdrop={false}
closeOnEscape={false}
panelClassName="w-full max-w-2xl rounded-3xl border border-white/70 px-7 py-10 shadow-2xl sm:px-14 sm:py-14"
onClose={() => setOpen(false)}
>
<div className="flex min-h-[min(38vh,24rem)] flex-col items-center justify-center text-center">
<div className="mb-5 text-5xl" aria-hidden="true">
🛠
</div>
<h1 className="text-3xl font-semibold tracking-tight text-[var(--platform-text-strong)]">
</h1>
<p className="mt-5 max-w-lg text-base leading-8 text-[var(--platform-text-base)] sm:text-lg">
</p>
<button
type="button"
className="mt-9 min-w-36 rounded-full bg-[var(--platform-accent)] px-6 py-3 text-base font-semibold text-white shadow-sm transition hover:brightness-105"
onClick={() => setOpen(false)}
>
</button>
</div>
</ThemedModal>
);
}
+2
View File
@@ -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(
<WorkspaceLauncher currentUser={user} onLogout={logout} />
)}
</AuthenticatedClient>
<MaintenanceNotice />
</WindowChrome>
</React.StrictMode>,
);
@@ -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<ParsedClientApiError> {
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<T>(
}
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<T>(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;
}
@@ -22,6 +22,7 @@ import {
getClientServerBaseUrl,
readClientHttpResponseText,
} from './clientHttp';
import { emitClientMaintenanceEvent } from './clientMaintenance';
import {
type ClientOperation,
createClientOperation,
@@ -249,10 +250,11 @@ async function requestAuthJson<T>(
});
}
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,
@@ -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 }));
}
@@ -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(<MaintenanceNotice />);
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);
});
});
@@ -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:` 图片预览失败不自动归类为维护;图片换签接口在维护期间失败时会触发统一弹窗,但本地刚选中的图片预览仍不依赖后端。
- 验收补充:维护期间发布接口不能只显示“创建平台游戏失败”等局部文案;维护弹窗出现一次即可覆盖并发失败请求,关闭后业务页仍可重试。
+2
View File
@@ -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(
<StrictMode>
<Suspense fallback={null}>{routeElement}</Suspense>
<MaintenanceNotice />
{route.kind === 'platform' ? <FloatingFeedbackEntry /> : null}
</StrictMode>,
);
@@ -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<MaintenanceEventDetail> {
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 (
<UnifiedModal
open={open}
title="系统维护中"
description="服务正在维护,当前操作暂时无法完成。请稍后再试。"
onClose={() => 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={
<PlatformActionButton
type="button"
onClick={() => setOpen(false)}
tone="primary"
size="md"
shape="pill"
className="min-w-36"
>
</PlatformActionButton>
}
>
<div className="text-center text-base leading-7 text-[var(--platform-text-base)] sm:text-lg">
</div>
</UnifiedModal>
);
}
+38 -1
View File
@@ -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<T>(