diff --git a/docs/design/README.md b/docs/design/README.md
index aaac0fbf..e365c5e8 100644
--- a/docs/design/README.md
+++ b/docs/design/README.md
@@ -11,6 +11,7 @@
- [CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md](./CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md):把模板依赖逐步迁成自定义世界自有设定层,并保证不破坏当前生成流程的优化方案。
- [MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md](./MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md):移动端创作页新建作品模块最多占用首屏约 1/3 高度的紧凑布局设计。
- [PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md](./PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md):平台入口新增分类 Tab、登录态导航裁剪与创作 Tab 视觉强化设计。
+- [UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md](./UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md):统一平台风与 RPG 像素风模态窗口外壳、交互边界和迁移顺序。
- [AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md](./AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md):运行时物品生成系统重设计。
- [LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md](./LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md):等级成长、章节经验节奏与 NPC 自动定级设计。
- [RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md](./RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md):专业剧情策划构建 RPG 游戏全剧情的工作流程与交付模板。
diff --git a/docs/design/UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md b/docs/design/UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md
new file mode 100644
index 00000000..f18dd21e
--- /dev/null
+++ b/docs/design/UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md
@@ -0,0 +1,93 @@
+# 统一模态窗口设计 2026-04-25
+
+## 背景
+
+当前前端已有两套稳定视觉资产:
+
+- 平台侧:`platform-overlay`、`platform-modal-shell`、`platform-auth-card` 等主题变量。
+- RPG 运行时:`pixel-nine-slice`、`pixel-modal-shell` 与 `UI_CHROME.modalPanel` 九宫格边框。
+
+但弹窗结构仍分散在业务组件内,常见重复包括遮罩层、点击遮罩关闭、`role="dialog"`、`aria-modal`、移动端底部贴边、桌面居中、最大高度、滚动区域和关闭按钮。新增弹窗时容易出现 z-index、无障碍属性、移动端高度和视觉边界不一致。
+
+## 目标
+
+新增统一组件 `UnifiedModal`,只负责弹窗外壳和交互边界,不接管业务内容:
+
+- 统一遮罩、面板、标题区、内容区、底部区结构。
+- 支持平台风与像素风两种外观,不混用两套视觉资产。
+- 默认移动端优先,平台风移动端底部弹出、桌面居中;像素风保持游戏内居中弹窗。
+- 默认提供 `role="dialog"`、`aria-modal`、标题关联、Escape 关闭和遮罩点击关闭。
+- 支持禁用关闭,用于生成中、保存中等不可打断流程。
+- 支持 Portal 渲染到 `document.body`,避免被父层 `overflow` 裁剪。
+
+## 非目标
+
+- 不一次性迁移所有旧弹窗,避免运行时大面积回归。
+- 不把业务按钮、表单、状态文案放进通用组件。
+- 不改变现有主题变量、九宫格素材、平台和 RPG 的视觉风格。
+- 不新增第三方弹窗库。
+
+## 组件接口
+
+`UnifiedModal` 核心参数:
+
+| 参数 | 说明 |
+| --- | --- |
+| `open` | 是否显示。为 `false` 时返回 `null`。 |
+| `variant` | `platform` 或 `pixel`。默认 `platform`。 |
+| `title` | 标题,同时作为默认 `aria-label` 来源。 |
+| `description` | 可选副标题,显示在标题下方。 |
+| `children` | 主内容区。 |
+| `footer` | 可选底部操作区。 |
+| `onClose` | 关闭回调。 |
+| `closeDisabled` | 禁止遮罩、Escape 和关闭按钮触发关闭。 |
+| `closeOnBackdrop` | 是否允许点击遮罩关闭,默认允许。 |
+| `showCloseButton` | 是否显示右上关闭按钮,默认显示。 |
+| `size` | `sm`、`md`、`lg`、`xl`、`fullscreen`。 |
+| `zIndexClassName` | z-index class,默认 `z-[90]`。 |
+| `panelClassName` / `bodyClassName` / `footerClassName` | 局部样式扩展。 |
+| `portal` | 是否渲染到 `document.body`,默认开启。 |
+
+## 使用边界
+
+### 平台风弹窗
+
+用于平台首页、登录注册、作品结果、创作工作台等非 RPG 运行时界面。
+
+要求:
+
+- 使用 `variant="platform"`。
+- 面板使用 `platform-modal-shell` 主题变量。
+- 移动端优先底部贴边,大屏居中。
+- 不在弹窗内放功能说明式长文案,只放任务所需信息。
+
+### 像素风弹窗
+
+用于 RPG 运行时、地图、背包、角色详情、NPC 交易等游戏内面板。
+
+要求:
+
+- 使用 `variant="pixel"`。
+- 面板使用 `pixel-nine-slice pixel-modal-shell`。
+- 默认使用 `getNineSliceStyle(UI_CHROME.modalPanel)`。
+- 标题、内容和底部仍由业务方控制,避免通用组件内写入玩法解释。
+
+## 首批迁移
+
+首批只迁移平台入口创作类型弹窗:
+
+- 文件:`src/components/platform-entry/PlatformEntryCreationTypeModal.tsx`
+- 目的:验证平台风布局、关闭禁用、标题区、内容区与错误区都可由统一组件承载。
+
+后续可按风险由低到高迁移:
+
+1. 结果页小弹窗:`PuzzleResultView`、`BigFishResultView`。
+2. 平台创作页编辑器弹窗:`RpgCreationEntityEditorShared` 内局部 `ModalShell`。
+3. RPG 运行时像素风弹窗:`RpgAdventurePanelOverlays`、`AdventureEntityModal`、`NpcModals`。
+
+## 验收标准
+
+- 新增弹窗优先使用 `UnifiedModal`,不再手写完整 overlay + panel 结构。
+- 迁移后的弹窗保留原有移动端和桌面布局。
+- 关闭按钮、遮罩关闭、Escape 行为一致,`closeDisabled` 时都不会关闭。
+- 类型检查、编码检查通过。
diff --git a/src/components/big-fish-result/BigFishResultView.tsx b/src/components/big-fish-result/BigFishResultView.tsx
index 27447760..83829875 100644
--- a/src/components/big-fish-result/BigFishResultView.tsx
+++ b/src/components/big-fish-result/BigFishResultView.tsx
@@ -16,6 +16,7 @@ import type {
BigFishSessionSnapshotResponse,
ExecuteBigFishActionRequest,
} from '../../../packages/shared/src/contracts/bigFish';
+import { UnifiedModal } from '../common/UnifiedModal';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type BigFishAssetStudioTarget =
@@ -537,38 +538,37 @@ function BigFishResultErrorModal({
onClose: () => void;
}) {
return (
-
-
-
-
-
-
-
-
- 发布失败
-
-
- {message}
-
-
-
+
知道了
+ )}
+ footerClassName="border-t-0 px-5 pb-5 pt-0"
+ >
+
-
+
);
}
diff --git a/src/components/common/UnifiedModal.test.tsx b/src/components/common/UnifiedModal.test.tsx
new file mode 100644
index 00000000..29997ac7
--- /dev/null
+++ b/src/components/common/UnifiedModal.test.tsx
@@ -0,0 +1,58 @@
+/* @vitest-environment jsdom */
+
+import { fireEvent, render, screen } from '@testing-library/react';
+import { expect, test, vi } from 'vitest';
+
+import { UnifiedModal } from './UnifiedModal';
+
+test('renders an accessible platform modal', () => {
+ render(
+
{}} portal={false}>
+ 窗口内容
+ ,
+ );
+
+ expect(screen.getByRole('dialog', { name: '统一弹窗' })).toBeTruthy();
+ expect(screen.getByText('窗口内容')).toBeTruthy();
+});
+
+test('closes through backdrop and escape', () => {
+ const onClose = vi.fn();
+ const { rerender } = render(
+
+ 窗口内容
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('dialog').parentElement as HTMLElement);
+ expect(onClose).toHaveBeenCalledTimes(1);
+
+ rerender(
+
+ 窗口内容
+ ,
+ );
+ fireEvent.keyDown(window, { key: 'Escape' });
+ expect(onClose).toHaveBeenCalledTimes(2);
+});
+
+test('respects closeDisabled for every default close path', () => {
+ const onClose = vi.fn();
+ render(
+
+ 窗口内容
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('dialog').parentElement as HTMLElement);
+ fireEvent.keyDown(window, { key: 'Escape' });
+ fireEvent.click(screen.getByRole('button', { name: '关闭' }));
+
+ expect(onClose).not.toHaveBeenCalled();
+});
diff --git a/src/components/common/UnifiedModal.tsx b/src/components/common/UnifiedModal.tsx
new file mode 100644
index 00000000..ed442562
--- /dev/null
+++ b/src/components/common/UnifiedModal.tsx
@@ -0,0 +1,220 @@
+import { X } from 'lucide-react';
+import {
+ type CSSProperties,
+ type ReactNode,
+ useEffect,
+ useId,
+} from 'react';
+import { createPortal } from 'react-dom';
+
+import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
+
+type UnifiedModalVariant = 'platform' | 'pixel';
+type UnifiedModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen';
+
+type UnifiedModalProps = {
+ open: boolean;
+ title: string;
+ description?: ReactNode;
+ children: ReactNode;
+ footer?: ReactNode;
+ onClose: () => void;
+ variant?: UnifiedModalVariant;
+ size?: UnifiedModalSize;
+ closeDisabled?: boolean;
+ closeOnBackdrop?: boolean;
+ showCloseButton?: boolean;
+ closeLabel?: string;
+ portal?: boolean;
+ zIndexClassName?: string;
+ overlayClassName?: string;
+ panelClassName?: string;
+ headerClassName?: string;
+ bodyClassName?: string;
+ footerClassName?: string;
+ panelStyle?: CSSProperties;
+};
+
+const PLATFORM_SIZE_CLASS: Record
= {
+ sm: 'max-w-md',
+ md: 'max-w-xl',
+ lg: 'max-w-3xl',
+ xl: 'max-w-5xl',
+ fullscreen: 'max-w-[min(100vw,76rem)] sm:h-[min(92vh,60rem)]',
+};
+
+const PIXEL_SIZE_CLASS: Record = {
+ sm: 'max-w-sm',
+ md: 'max-w-md',
+ lg: 'max-w-3xl',
+ xl: 'max-w-5xl',
+ fullscreen: 'max-w-[min(96vw,64rem)]',
+};
+
+function joinClassNames(...classNames: Array) {
+ return classNames.filter(Boolean).join(' ');
+}
+
+function getPanelStyle(
+ variant: UnifiedModalVariant,
+ panelStyle: CSSProperties | undefined,
+) {
+ if (variant !== 'pixel') {
+ return panelStyle;
+ }
+
+ return {
+ ...getNineSliceStyle(UI_CHROME.modalPanel),
+ ...panelStyle,
+ };
+}
+
+function UnifiedModalContent({
+ open,
+ title,
+ description,
+ children,
+ footer,
+ onClose,
+ variant = 'platform',
+ size = 'md',
+ closeDisabled = false,
+ closeOnBackdrop = true,
+ showCloseButton = true,
+ closeLabel = '关闭',
+ zIndexClassName = 'z-[90]',
+ overlayClassName,
+ panelClassName,
+ headerClassName,
+ bodyClassName,
+ footerClassName,
+ panelStyle,
+}: Omit) {
+ const titleId = useId();
+ const descriptionId = useId();
+
+ useEffect(() => {
+ if (!open || closeDisabled) {
+ return;
+ }
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ onClose();
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [closeDisabled, onClose, open]);
+
+ if (!open) {
+ return null;
+ }
+
+ const isPixel = variant === 'pixel';
+ const sizeClassName = isPixel
+ ? PIXEL_SIZE_CLASS[size]
+ : PLATFORM_SIZE_CLASS[size];
+
+ const overlayClasses = isPixel
+ ? 'fixed inset-0 flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4'
+ : 'platform-overlay fixed inset-0 flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4';
+
+ const panelClasses = isPixel
+ ? 'pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,58rem)] w-full flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]'
+ : 'platform-modal-shell flex max-h-[min(92vh,58rem)] w-full flex-col overflow-hidden rounded-t-[1.75rem] sm:rounded-[1.75rem]';
+
+ const headerClasses = isPixel
+ ? 'flex items-start justify-between gap-3 border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4'
+ : 'flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5';
+
+ const titleClasses = isPixel
+ ? 'truncate text-sm font-semibold text-white'
+ : 'text-base font-semibold text-[var(--platform-text-strong)]';
+
+ const descriptionClasses = isPixel
+ ? 'mt-1 text-xs leading-5 text-zinc-400'
+ : 'mt-1 text-xs leading-5 text-[var(--platform-text-base)]';
+
+ const bodyClasses = isPixel
+ ? 'min-h-0 flex-1 overflow-y-auto p-4 sm:p-5'
+ : 'min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-5 sm:py-5';
+
+ const footerClasses = isPixel
+ ? 'flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-4 py-3 sm:px-5 sm:py-4'
+ : 'flex flex-wrap items-center justify-end gap-3 border-t border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5';
+
+ const closeButtonClasses = isPixel
+ ? 'rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-45'
+ : 'platform-icon-button disabled:cursor-not-allowed disabled:opacity-45';
+
+ return (
+ {
+ if (
+ closeOnBackdrop &&
+ !closeDisabled &&
+ event.target === event.currentTarget
+ ) {
+ onClose();
+ }
+ }}
+ >
+
event.stopPropagation()}
+ >
+
+
+
+ {title}
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+ {showCloseButton ? (
+
+ ) : null}
+
+
+ {children}
+
+ {footer ? (
+
+ {footer}
+
+ ) : null}
+
+
+ );
+}
+
+/**
+ * 统一模态窗口外壳。
+ * 业务组件只传入标题、内容和操作区;遮罩、无障碍属性、Escape 与移动端布局在这里收口。
+ */
+export function UnifiedModal({ portal = true, ...props }: UnifiedModalProps) {
+ if (!portal || typeof document === 'undefined') {
+ return ;
+ }
+
+ return createPortal(, document.body);
+}
diff --git a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx
index 5dc2fddb..fdde7c8b 100644
--- a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx
+++ b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx
@@ -1,5 +1,6 @@
-import { ArrowRight, X } from 'lucide-react';
+import { ArrowRight } from 'lucide-react';
+import { UnifiedModal } from '../common/UnifiedModal';
import { PLATFORM_CREATION_TYPES } from './platformEntryCreationTypes';
export interface PlatformEntryCreationTypeModalProps {
@@ -79,58 +80,40 @@ export function PlatformEntryCreationTypeModal({
}
return (
-
-
-
-
-
-
- 选择创作类型
-
-
- 先选玩法类型,再进入对应创作工作台。
-
-
-
-
-
-
-
- {PLATFORM_CREATION_TYPES.map((item) => (
- {
- if (item.id === 'rpg') {
- onSelectRpg();
- }
- if (item.id === 'big-fish') {
- onSelectBigFish();
- }
- if (item.id === 'puzzle') {
- onSelectPuzzle();
- }
- }}
- />
- ))}
-
-
- {error ? (
-
- {error}
-
- ) : null}
-
-
+
+
+ {PLATFORM_CREATION_TYPES.map((item) => (
+ {
+ if (item.id === 'rpg') {
+ onSelectRpg();
+ }
+ if (item.id === 'big-fish') {
+ onSelectBigFish();
+ }
+ if (item.id === 'puzzle') {
+ onSelectPuzzle();
+ }
+ }}
+ />
+ ))}
-
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
);
}