feat: add unified modal shell
This commit is contained in:
@@ -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 游戏全剧情的工作流程与交付模板。
|
||||
|
||||
93
docs/design/UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md
Normal file
93
docs/design/UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md
Normal file
@@ -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` 时都不会关闭。
|
||||
- 类型检查、编码检查通过。
|
||||
@@ -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 (
|
||||
<div className="fixed inset-0 z-[160] flex items-center justify-center bg-slate-950/58 px-4 py-6 backdrop-blur-sm">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="big-fish-result-error-title"
|
||||
className="w-full max-w-sm rounded-[1.6rem] border border-red-100/80 bg-white p-5 text-slate-950 shadow-2xl"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-600">
|
||||
<Waves className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
id="big-fish-result-error-title"
|
||||
className="text-base font-black text-slate-950"
|
||||
>
|
||||
发布失败
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-slate-600">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UnifiedModal
|
||||
open
|
||||
title="发布失败"
|
||||
onClose={onClose}
|
||||
closeOnBackdrop={false}
|
||||
showCloseButton={false}
|
||||
size="sm"
|
||||
zIndexClassName="z-[160]"
|
||||
overlayClassName="bg-slate-950/58"
|
||||
panelClassName="border-red-100/80 bg-white text-slate-950 shadow-2xl"
|
||||
bodyClassName="p-5"
|
||||
footer={(
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="mt-5 inline-flex w-full items-center justify-center rounded-full bg-slate-950 px-4 py-2.5 text-sm font-bold text-white"
|
||||
className="inline-flex w-full items-center justify-center rounded-full bg-slate-950 px-4 py-2.5 text-sm font-bold text-white"
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
)}
|
||||
footerClassName="border-t-0 px-5 pb-5 pt-0"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-600">
|
||||
<Waves className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-sm leading-6 text-slate-600">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
58
src/components/common/UnifiedModal.test.tsx
Normal file
58
src/components/common/UnifiedModal.test.tsx
Normal file
@@ -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(
|
||||
<UnifiedModal open title="统一弹窗" onClose={() => {}} portal={false}>
|
||||
<div>窗口内容</div>
|
||||
</UnifiedModal>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '统一弹窗' })).toBeTruthy();
|
||||
expect(screen.getByText('窗口内容')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('closes through backdrop and escape', () => {
|
||||
const onClose = vi.fn();
|
||||
const { rerender } = render(
|
||||
<UnifiedModal open title="统一弹窗" onClose={onClose} portal={false}>
|
||||
<div>窗口内容</div>
|
||||
</UnifiedModal>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('dialog').parentElement as HTMLElement);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<UnifiedModal open title="统一弹窗" onClose={onClose} portal={false}>
|
||||
<div>窗口内容</div>
|
||||
</UnifiedModal>,
|
||||
);
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('respects closeDisabled for every default close path', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<UnifiedModal
|
||||
open
|
||||
title="生成中"
|
||||
onClose={onClose}
|
||||
closeDisabled
|
||||
portal={false}
|
||||
>
|
||||
<div>窗口内容</div>
|
||||
</UnifiedModal>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('dialog').parentElement as HTMLElement);
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
220
src/components/common/UnifiedModal.tsx
Normal file
220
src/components/common/UnifiedModal.tsx
Normal file
@@ -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<UnifiedModalSize, string> = {
|
||||
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<UnifiedModalSize, string> = {
|
||||
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<string | false | null | undefined>) {
|
||||
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<UnifiedModalProps, 'portal'>) {
|
||||
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 (
|
||||
<div
|
||||
className={joinClassNames(overlayClasses, zIndexClassName, overlayClassName)}
|
||||
onClick={(event) => {
|
||||
if (
|
||||
closeOnBackdrop &&
|
||||
!closeDisabled &&
|
||||
event.target === event.currentTarget
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={description ? descriptionId : undefined}
|
||||
className={joinClassNames(panelClasses, sizeClassName, panelClassName)}
|
||||
style={getPanelStyle(variant, panelStyle)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className={joinClassNames(headerClasses, headerClassName)}>
|
||||
<div className="min-w-0">
|
||||
<div id={titleId} className={titleClasses}>
|
||||
{title}
|
||||
</div>
|
||||
{description ? (
|
||||
<div id={descriptionId} className={descriptionClasses}>
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{showCloseButton ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={closeLabel}
|
||||
onClick={onClose}
|
||||
disabled={closeDisabled}
|
||||
className={closeButtonClasses}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={joinClassNames(bodyClasses, bodyClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className={joinClassNames(footerClasses, footerClassName)}>
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一模态窗口外壳。
|
||||
* 业务组件只传入标题、内容和操作区;遮罩、无障碍属性、Escape 与移动端布局在这里收口。
|
||||
*/
|
||||
export function UnifiedModal({ portal = true, ...props }: UnifiedModalProps) {
|
||||
if (!portal || typeof document === 'undefined') {
|
||||
return <UnifiedModalContent {...props} />;
|
||||
}
|
||||
|
||||
return createPortal(<UnifiedModalContent {...props} />, document.body);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="platform-overlay fixed inset-0 z-[90] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4">
|
||||
<div className="platform-modal-shell w-full max-w-3xl overflow-hidden rounded-[1.8rem]">
|
||||
<div className="bg-transparent">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
选择创作类型
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[var(--platform-text-base)]">
|
||||
先选玩法类型,再进入对应创作工作台。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isBusy}
|
||||
className="platform-icon-button disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-4 sm:px-5 sm:py-5">
|
||||
<div className="grid gap-3 sm:grid-cols-5">
|
||||
{PLATFORM_CREATION_TYPES.map((item) => (
|
||||
<CreationTypeCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
busy={isBusy}
|
||||
onSelect={() => {
|
||||
if (item.id === 'rpg') {
|
||||
onSelectRpg();
|
||||
}
|
||||
if (item.id === 'big-fish') {
|
||||
onSelectBigFish();
|
||||
}
|
||||
if (item.id === 'puzzle') {
|
||||
onSelectPuzzle();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<UnifiedModal
|
||||
open={isOpen}
|
||||
title="选择创作类型"
|
||||
description="先选玩法类型,再进入对应创作工作台。"
|
||||
onClose={onClose}
|
||||
closeDisabled={isBusy}
|
||||
size="lg"
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-5">
|
||||
{PLATFORM_CREATION_TYPES.map((item) => (
|
||||
<CreationTypeCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
busy={isBusy}
|
||||
onSelect={() => {
|
||||
if (item.id === 'rpg') {
|
||||
onSelectRpg();
|
||||
}
|
||||
if (item.id === 'big-fish') {
|
||||
onSelectBigFish();
|
||||
}
|
||||
if (item.id === 'puzzle') {
|
||||
onSelectPuzzle();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user