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" + > +
+
+ +
+
+ {message} +
-
+ ); } 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} + ); }