继续收口工具弹窗与分段切换预设

新增 PlatformToolModalShell 承接白底工具弹窗壳层和固定可访问名称

新增 PlatformSegmentedTabPresets 沉淀频道下划线、创作 pill rail 与二列 option segment

迁移拼图、抓大鹅、历史素材弹窗和首页 / 作品架 / 充值切换的重复组件写法

同步 PlatformUiKit 文档和 Hermes 决策记录
This commit is contained in:
2026-06-11 16:32:56 +08:00
parent 7c47ad3358
commit ffcffef6d2
15 changed files with 848 additions and 621 deletions

View File

@@ -2085,11 +2085,11 @@
- 背景:个人中心 profile 弹层已抽成独立组件,但 `error / loading / empty / content` 仍在多个 modal 中重复分支,继续沿业务页各写一套会让后续 profile 面板收口越来越碎。 - 背景:个人中心 profile 弹层已抽成独立组件,但 `error / loading / empty / content` 仍在多个 modal 中重复分支,继续沿业务页各写一套会让后续 profile 面板收口越来越碎。
- 决策:新增 `src/components/common/PlatformAsyncStatePanel.tsx` 作为互斥异步状态骨架,只承接 `errorState / loadingState / emptyState / children` 四类 slot 的优先级切换;`PlatformProfileWalletLedgerModal.tsx``PlatformProfileTaskCenterModal.tsx``PlatformProfileRechargeModal.tsx``PlatformProfilePlayedWorksModal.tsx``PlatformProfileReferralModal.tsx` 已接入。若错误或成功提示需要与内容并存,继续留在业务组件外层,不把 `PlatformAsyncStatePanel` 扩成全能状态机。 - 决策:新增 `src/components/common/PlatformAsyncStatePanel.tsx` 作为互斥异步状态骨架,只承接 `errorState / loadingState / emptyState / children` 四类 slot 的优先级切换;`PlatformProfileWalletLedgerModal.tsx``PlatformProfileTaskCenterModal.tsx``PlatformProfileRechargeModal.tsx``PlatformProfilePlayedWorksModal.tsx``PlatformProfileReferralModal.tsx` 已接入。若错误或成功提示需要与内容并存,继续留在业务组件外层,不把 `PlatformAsyncStatePanel` 扩成全能状态机。
- 决策:`src/components/common/PlatformSegmentedTabs.tsx` 支持 `layout="scroll"`,用于横向可滚动 tab rail`CustomWorldCreationStartCard.tsx``CustomWorldWorkTabs.tsx` 以及 `RpgEntryHomeView.tsx` 的排行 / 分类筛选已接入。共享组件负责 tab 语义、滚动容器和基础交互,业务页通过 `itemClassName` 保留本地皮肤,不额外抽“频道 tab”视觉 preset - 决策:`src/components/common/PlatformSegmentedTabs.tsx` 支持 `layout="scroll"`,用于横向可滚动 tab rail`CustomWorldCreationStartCard.tsx``CustomWorldWorkTabs.tsx` 以及 `RpgEntryHomeView.tsx` 的排行 / 分类筛选已接入。共享组件负责 tab 语义、滚动容器和基础交互;当同一类皮肤在首页、作品架、分类筛选或个人中心中重复出现时,沉淀到 `src/components/common/PlatformSegmentedTabPresets.tsx` 的薄 preset业务页不再重复复制长 `itemClassName`
- 决策:`src/components/PixelCloseButton.tsx` 保持为 RPG 语义薄封装,底层统一复用 `src/components/common/PlatformModalCloseButton.tsx``variant="pixel"`;共享 close button 现在负责 `absolute / inline` placement、默认 `title=label` 和可选 `stopPropagation` 点击拦截,业务 importer 不再各自维护像素风关闭按钮壳和冒泡控制。 - 决策:`src/components/PixelCloseButton.tsx` 保持为 RPG 语义薄封装,底层统一复用 `src/components/common/PlatformModalCloseButton.tsx``variant="pixel"`;共享 close button 现在负责 `absolute / inline` placement、默认 `title=label` 和可选 `stopPropagation` 点击拦截,业务 importer 不再各自维护像素风关闭按钮壳和冒泡控制。
- 决策:`PlatformSegmentedTabs` 继续承接首页 / 结果页剩余的横向 rail 与二选一切换;`RpgEntryHomeView.tsx` 的 discover channel bar、移动端 / 桌面端分类 chip rail`CustomWorldEntityCatalog.tsx``RESULT_TABS` sticky rail以及 `PlatformProfileRechargeModal.tsx` 的“泥点充值 / 会员卡”切换条已迁移。像 `CustomWorldEntityCatalog` 这种“标题 + count”内容直接走 `ReactNode label`,业务皮肤继续落在 `itemClassName`同类切换在测试里应优先按 `role="tablist" / "tab"` 查询,而不是把它们继续当普通 button。 - 决策:`PlatformSegmentedTabs` 继续承接首页 / 结果页剩余的横向 rail 与二选一切换;`RpgEntryHomeView.tsx` 的 discover channel bar、移动端 / 桌面端分类 chip rail`CustomWorldEntityCatalog.tsx``RESULT_TABS` sticky rail以及 `PlatformProfileRechargeModal.tsx` 的“泥点充值 / 会员卡”切换条已迁移。像 `CustomWorldEntityCatalog` 这种“标题 + count”内容直接走 `ReactNode label`;首页 / 创作入口 / 作品架 / 个人中心里稳定复用的频道下划线、创作 pill rail、二列 option segment 皮肤走 `PlatformSegmentedTabPresets`同类切换在测试里应优先按 `role="tablist" / "tab"` 查询,而不是把它们继续当普通 button。
- 决策:简单泥点确认流的开关状态机统一收口到 `src/components/common/useMudPointConfirmController.ts`,只暴露 `open / requestOpen / close / confirm`,不持有点数、标题、描述或禁用态等业务字段;`PuzzleCreationWorkspace.tsx``Match3DCreationWorkspace.tsx``Match3DResultView.tsx` 的两个批量素材面板已接入。`PuzzleResultView.tsx``RpgCreationRoleAssetStudioModalImpl.tsx` 这类节奏不同或携带 pending payload 的场景继续保留本地状态机,避免把简单 hook 扩成泛型动作路由器。 - 决策:简单泥点确认流的开关状态机统一收口到 `src/components/common/useMudPointConfirmController.ts`,只暴露 `open / requestOpen / close / confirm`,不持有点数、标题、描述或禁用态等业务字段;`PuzzleCreationWorkspace.tsx``Match3DCreationWorkspace.tsx``Match3DResultView.tsx` 的两个批量素材面板已接入。`PuzzleResultView.tsx``RpgCreationRoleAssetStudioModalImpl.tsx` 这类节奏不同或携带 pending payload 的场景继续保留本地状态机,避免把简单 hook 扩成泛型动作路由器。
- 决策:标准平台 modal header 的关闭入口继续统一到 `PlatformModalCloseButton variant="platformIcon"``PuzzleResultView.tsx` 的关卡详情 / 发布弹窗、`RpgCreationResultActionBar.tsx` 的发布检查弹窗,以及 `PuzzleHistoryAssetPickerDialog.tsx` 的历史素材弹窗已迁移。像素风 runtime、drawer collapse、玩法规则面板和运行态 overlay 不跟这条线混收,继续保留局部 close 语义。 - 决策:标准平台 modal header 的关闭入口继续统一到 `PlatformModalCloseButton variant="platformIcon"`结果页 / 工具页重复的白底 portal 弹窗壳层收口到 `src/components/common/PlatformToolModalShell.tsx`,由它统一承接平台主题 overlay、白底 remap panel、标准 header/body/footer spacing、关闭按钮和遮罩 / Escape 关闭策略。`PuzzleResultView.tsx` 的关卡详情 / 发布弹窗、`Match3DResultView.tsx` 的封面 / 发布工具弹窗,以及 `PuzzleHistoryAssetPickerDialog.tsx` 的历史素材弹窗已迁移`UnifiedModal` 新增 `ariaLabel` 支持可见标题动态、可访问名称固定的场景。像素风 runtime、drawer collapse、玩法规则面板和运行态 overlay 不跟这条线混收,继续保留局部 close 语义。
- 决策:平台入口的创作前置泥点阻断提示只在 `platform-entry` 局部抽成 `src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx`,并使用 `DraftGenerationPointNotice` union`insufficient-points` / `balance-load-failed`)承接业务真相;不要在 `common/` 再抽一个泛化 `BlockingNoticeDialog`,否则会把 `PlatformAcknowledgeStatusDialog` 的样式透传再包装一层而不缩小调用面。 - 决策:平台入口的创作前置泥点阻断提示只在 `platform-entry` 局部抽成 `src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx`,并使用 `DraftGenerationPointNotice` union`insufficient-points` / `balance-load-failed`)承接业务真相;不要在 `common/` 再抽一个泛化 `BlockingNoticeDialog`,否则会把 `PlatformAcknowledgeStatusDialog` 的样式透传再包装一层而不缩小调用面。
- 决策:`PlatformAsyncStatePanel` 从 profile modal 扩展到作品架类白底 panel`CustomWorldCreationHub.tsx` 的作品架主体现在也统一走 `loadingState / emptyState / children` 三段 slot但 error + 重试继续留在业务层外侧不把共享组件扩成“banner + retry + content”全能状态机。后续白底作品架或列表 panel 若只是互斥的 `loading / empty / content`,优先直接复用这套骨架。 - 决策:`PlatformAsyncStatePanel` 从 profile modal 扩展到作品架类白底 panel`CustomWorldCreationHub.tsx` 的作品架主体现在也统一走 `loadingState / emptyState / children` 三段 slot但 error + 重试继续留在业务层外侧不把共享组件扩成“banner + retry + content”全能状态机。后续白底作品架或列表 panel 若只是互斥的 `loading / empty / content`,优先直接复用这套骨架。
- 决策:`CopyFeedbackButton.tsx``actionSurface` 分支继续收口到 `PlatformActionButton``pill` 分支继续保留 `PlatformPillBadge` 风格;复制反馈按钮不再直接调用 `getPlatformActionButtonClassName` 手拼平台按钮基础 chrome。后续同类“复制状态机 + 平台动作按钮”组合优先直接复用 `CopyFeedbackButton`不要在业务页重新混写图标、文案、aria 和动作按钮 class。 - 决策:`CopyFeedbackButton.tsx``actionSurface` 分支继续收口到 `PlatformActionButton``pill` 分支继续保留 `PlatformPillBadge` 风格;复制反馈按钮不再直接调用 `getPlatformActionButtonClassName` 手拼平台按钮基础 chrome。后续同类“复制状态机 + 平台动作按钮”组合优先直接复用 `CopyFeedbackButton`不要在业务页重新混写图标、文案、aria 和动作按钮 class。

View File

@@ -250,12 +250,12 @@
19.3.24. 平台未保存离开确认弹窗收口到 `src/components/common/PlatformUnsavedLeaveConfirmDialog.tsx`;组件固定承接“继续编辑 + 确认离开”的标准骨架,并按 `platform / pixel` 两类确认风格兜底默认遮罩和面板样式。`RpgCreationEntityEditorShared.tsx` 中的关闭未保存修改确认、生成结果未保存退出确认和普通结果未保存退出确认已迁移;业务页只保留标题、确认按钮文案和未保存提示内容,不再各自拼接 `UnifiedConfirmDialog` 的 cancel/confirm 组合和重复壳层 class。 19.3.24. 平台未保存离开确认弹窗收口到 `src/components/common/PlatformUnsavedLeaveConfirmDialog.tsx`;组件固定承接“继续编辑 + 确认离开”的标准骨架,并按 `platform / pixel` 两类确认风格兜底默认遮罩和面板样式。`RpgCreationEntityEditorShared.tsx` 中的关闭未保存修改确认、生成结果未保存退出确认和普通结果未保存退出确认已迁移;业务页只保留标题、确认按钮文案和未保存提示内容,不再各自拼接 `UnifiedConfirmDialog` 的 cancel/confirm 组合和重复壳层 class。
19.3.25. 平台单按钮已读状态弹窗收口到 `src/components/common/PlatformAcknowledgeStatusDialog.tsx`;组件固定承接“状态提示 + 知道了”这一类单按钮确认已读语义,并透传 action surface / size / fullWidth / class、header、关闭路径和局部 panel 覆写。`BigFishResultView.tsx` 的发布失败提示、`RpgEntryHomeView.tsx` 的支付结果提示、`RpgCreationEntityEditorShared.tsx` 的编辑器 notice、`PlatformEntryFlowShellImpl.tsx` 的泥点提示 / 作品不可用 / 搜索未命中提示,以及 `CustomWorldEntityCatalog.tsx` 的“无法删除”阻断提示已迁移;业务页继续保留 status、标题、说明和关闭回调不再各自手写 `PlatformStatusDialog``action={{ label: '知道了', onClick: onClose }}` 结构。 19.3.25. 平台单按钮已读状态弹窗收口到 `src/components/common/PlatformAcknowledgeStatusDialog.tsx`;组件固定承接“状态提示 + 知道了”这一类单按钮确认已读语义,并透传 action surface / size / fullWidth / class、header、关闭路径和局部 panel 覆写。`BigFishResultView.tsx` 的发布失败提示、`RpgEntryHomeView.tsx` 的支付结果提示、`RpgCreationEntityEditorShared.tsx` 的编辑器 notice、`PlatformEntryFlowShellImpl.tsx` 的泥点提示 / 作品不可用 / 搜索未命中提示,以及 `CustomWorldEntityCatalog.tsx` 的“无法删除”阻断提示已迁移;业务页继续保留 status、标题、说明和关闭回调不再各自手写 `PlatformStatusDialog``action={{ label: '知道了', onClick: onClose }}` 结构。
19.3.26. profile 侧重复的 `error / loading / empty / content` 分支统一收口到 `src/components/common/PlatformAsyncStatePanel.tsx`;该 Module 只承接互斥状态切换,不承接需要和内容并存的 success / error banner。`PlatformProfileReferralModal.tsx``PlatformProfileWalletLedgerModal.tsx``PlatformProfilePlayedWorksModal.tsx``PlatformProfileTaskCenterModal.tsx``PlatformProfileRechargeModal.tsx` 已接入。后续 profile 或白底 panel 侧若只是同形态互斥异步状态,优先传 slot 复用该骨架,不再把 `loading skeleton` / `empty state` / `retry error` 直接写回业务页。 19.3.26. profile 侧重复的 `error / loading / empty / content` 分支统一收口到 `src/components/common/PlatformAsyncStatePanel.tsx`;该 Module 只承接互斥状态切换,不承接需要和内容并存的 success / error banner。`PlatformProfileReferralModal.tsx``PlatformProfileWalletLedgerModal.tsx``PlatformProfilePlayedWorksModal.tsx``PlatformProfileTaskCenterModal.tsx``PlatformProfileRechargeModal.tsx` 已接入。后续 profile 或白底 panel 侧若只是同形态互斥异步状态,优先传 slot 复用该骨架,不再把 `loading skeleton` / `empty state` / `retry error` 直接写回业务页。
19.3.27. `PlatformSegmentedTabs` 支持 `layout="scroll"` 承接横向可滚动 tab rail`CustomWorldCreationStartCard.tsx``CustomWorldWorkTabs.tsx``RpgEntryHomeView.tsx` 的排行 tab、分类筛选项已接入。共享组件统一 `tablist/tab` 语义、滚动容器和基础交互,业务视觉仍通过 `itemClassName` 保留本地样式,不在 `common/` 新增“频道 tab”皮肤 preset后续同类横向 tab 优先扩展这套 `scroll + itemClassName` 组合 19.3.27. `PlatformSegmentedTabs` 支持 `layout="scroll"` 承接横向可滚动 tab rail`CustomWorldCreationStartCard.tsx``CustomWorldWorkTabs.tsx``RpgEntryHomeView.tsx` 的排行 tab、分类筛选项已接入。共享组件统一 `tablist/tab` 语义、滚动容器和基础交互;当同一类皮肤在首页、作品架、分类筛选或个人中心内重复出现时,再沉淀到 `src/components/common/PlatformSegmentedTabPresets.tsx` 的薄 preset避免业务页继续复制 `itemClassName`,也避免把一次性玩法配置项抽成过胖公共组件
19.3.28. `PixelCloseButton.tsx` 保持为 RPG 语义薄封装,底层改为复用 `src/components/common/PlatformModalCloseButton.tsx``variant="pixel"`;共享 close button 统一承接像素风基础 chrome、`absolute / inline` placement、默认 `title=label` 和可选 `stopPropagation` 冒泡拦截,`CharacterChatModal.tsx``MapModal.tsx` 的 inline / absolute 真实 importer 已补测试。后续需要像素风关闭按钮时优先使用 `PlatformModalCloseButton variant="pixel"` 或继续复用 `PixelCloseButton` 语义壳,不再手写本地 close button。 19.3.28. `PixelCloseButton.tsx` 保持为 RPG 语义薄封装,底层改为复用 `src/components/common/PlatformModalCloseButton.tsx``variant="pixel"`;共享 close button 统一承接像素风基础 chrome、`absolute / inline` placement、默认 `title=label` 和可选 `stopPropagation` 冒泡拦截,`CharacterChatModal.tsx``MapModal.tsx` 的 inline / absolute 真实 importer 已补测试。后续需要像素风关闭按钮时优先使用 `PlatformModalCloseButton variant="pixel"` 或继续复用 `PixelCloseButton` 语义壳,不再手写本地 close button。
19.3.29. 平台入口创作前置泥点阻断提示抽到 `src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx`,并用 `DraftGenerationPointNotice` union`insufficient-points` / `balance-load-failed`)承接业务真相;`PlatformEntryFlowShellImpl.tsx` 不再直接拼 `PlatformAcknowledgeStatusDialog` 的标题、说明和 amber icon 条件分支。后续若只是平台入口里的泥点前置检查提示,优先继续扩展这个局部语义 wrapper不要急着在 `common/` 抽泛化 `BlockingNoticeDialog`,避免把底层状态弹窗的样式透传再次包装一层。 19.3.29. 平台入口创作前置泥点阻断提示抽到 `src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx`,并用 `DraftGenerationPointNotice` union`insufficient-points` / `balance-load-failed`)承接业务真相;`PlatformEntryFlowShellImpl.tsx` 不再直接拼 `PlatformAcknowledgeStatusDialog` 的标题、说明和 amber icon 条件分支。后续若只是平台入口里的泥点前置检查提示,优先继续扩展这个局部语义 wrapper不要急着在 `common/` 抽泛化 `BlockingNoticeDialog`,避免把底层状态弹窗的样式透传再次包装一层。
19.3.30. `PlatformSegmentedTabs` 继续承接首页 / 结果页里剩余的横向 rail 与二选一切换:`RpgEntryHomeView.tsx` 的 discover channel bar、移动端 / 桌面端分类 chip rail`CustomWorldEntityCatalog.tsx``RESULT_TABS` sticky rail以及 `PlatformProfileRechargeModal.tsx` 的“泥点充值 / 会员卡”切换条已迁移。`CustomWorldEntityCatalog` 通过 `ReactNode label` 保留“标题 + count”两行内容`RpgEntryHomeView` 和个人中心切换条继续通过 `itemClassName` 贴回本地皮肤;同类 rail 优先直接复用 `PlatformSegmentedTabs`,测试也应`role="tablist" / "tab"` 查询,不再把这些切换项当普通 button。 19.3.30. `PlatformSegmentedTabs` 继续承接首页 / 结果页里剩余的横向 rail 与二选一切换:`RpgEntryHomeView.tsx` 的 discover channel bar、移动端 / 桌面端分类 chip rail`CustomWorldEntityCatalog.tsx``RESULT_TABS` sticky rail以及 `PlatformProfileRechargeModal.tsx` 的“泥点充值 / 会员卡”切换条已迁移。`CustomWorldEntityCatalog` 通过 `ReactNode label` 保留“标题 + count”两行内容`RpgEntryHomeView`、创作入口、作品架和个人中心里稳定复用的频道下划线、创作 pill rail、二列 option segment 皮肤沉淀到 `PlatformSegmentedTabPresets`,业务页只保留 items、activeId 和回调。同类切换在测试里应优先`role="tablist" / "tab"` 查询,不再把这些切换项当普通 button;一次性玩法配置项继续直接组合 `PlatformSegmentedTabs`
19.3.31. 简单泥点确认流的开关状态机收口到 `src/components/common/useMudPointConfirmController.ts`;该 hook 只承接 `open / requestOpen / close / confirm` 四个动作,`confirm` 固定先关弹窗再执行回调,不持有 `points / title / description / confirmDisabled` 之类业务字段。`PuzzleCreationWorkspace.tsx``Match3DCreationWorkspace.tsx``Match3DResultView.tsx` 的两个批量素材面板已接入;`PuzzleCreationWorkspace` 仍在业务页判断“只有 `aiRedraw` 才弹确认”。`PuzzleResultView.tsx``RpgCreationRoleAssetStudioModalImpl.tsx` 这类要么节奏不同、要么携带 pending payload 的场景先保留本地状态机,不把 hook 扩成泛型动作路由器。 19.3.31. 简单泥点确认流的开关状态机收口到 `src/components/common/useMudPointConfirmController.ts`;该 hook 只承接 `open / requestOpen / close / confirm` 四个动作,`confirm` 固定先关弹窗再执行回调,不持有 `points / title / description / confirmDisabled` 之类业务字段。`PuzzleCreationWorkspace.tsx``Match3DCreationWorkspace.tsx``Match3DResultView.tsx` 的两个批量素材面板已接入;`PuzzleCreationWorkspace` 仍在业务页判断“只有 `aiRedraw` 才弹确认”。`PuzzleResultView.tsx``RpgCreationRoleAssetStudioModalImpl.tsx` 这类要么节奏不同、要么携带 pending payload 的场景先保留本地状态机,不把 hook 扩成泛型动作路由器。
19.3.32. 标准平台 modal header 的关闭入口继续统一到 `PlatformModalCloseButton variant="platformIcon"``PuzzleResultView.tsx` 的关卡详情 / 发布弹窗、`RpgCreationResultActionBar.tsx` 的发布检查弹窗,以及 `PuzzleHistoryAssetPickerDialog.tsx` 的历史素材弹窗已迁移。像素风 runtime、drawer collapse、玩法规则面板和运行态专属 overlay 继续保留本地 close 语义,不把 `PlatformModalCloseButton` 硬塞进非平台 modal header 场景。 19.3.32. 标准平台 modal header 的关闭入口继续统一到 `PlatformModalCloseButton variant="platformIcon"`结果页 / 工具页里重复的白底 portal 弹窗壳层进一步收口到 `src/components/common/PlatformToolModalShell.tsx`,由它统一承接平台主题 overlay、白底 remap panel、标准 header/body/footer spacing、关闭按钮和遮罩 / Escape 关闭策略。`PuzzleResultView.tsx` 的关卡详情 / 发布弹窗、`Match3DResultView.tsx` 的封面 / 发布工具弹窗,以及 `PuzzleHistoryAssetPickerDialog.tsx` 的历史素材弹窗已迁移`UnifiedModal` 新增 `ariaLabel` 以支持“可见标题随业务对象变化、可访问名称保持固定”的场景。像素风 runtime、drawer collapse、玩法规则面板和运行态专属 overlay 继续保留本地 close 语义,不把 `PlatformToolModalShell` 硬塞进非平台白底工具弹窗场景。
19.3.33. `PlatformAsyncStatePanel` 从 profile modal 扩展到作品架:`CustomWorldCreationHub.tsx` 的作品架主体现在也统一通过 `loadingState / emptyState / children` 三个 slot 切换,保留外层 error + 重试提示不并入共享状态骨架。后续白底作品架或列表 panel 若只是互斥的 `loading / empty / content`,优先直接复用 `PlatformAsyncStatePanel`,不要再在业务 JSX 中重复拼 skeleton 和“当前筛选下没有内容”的分支。验证命令:`npx vitest run src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx``npm run check:encoding` 19.3.33. `PlatformAsyncStatePanel` 从 profile modal 扩展到作品架:`CustomWorldCreationHub.tsx` 的作品架主体现在也统一通过 `loadingState / emptyState / children` 三个 slot 切换,保留外层 error + 重试提示不并入共享状态骨架。后续白底作品架或列表 panel 若只是互斥的 `loading / empty / content`,优先直接复用 `PlatformAsyncStatePanel`,不要再在业务 JSX 中重复拼 skeleton 和“当前筛选下没有内容”的分支。验证命令:`npx vitest run src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx``npm run check:encoding`
19.3.34. `CopyFeedbackButton.tsx``actionSurface` 分支继续向共享按钮收口:带平台动作外观的复制按钮现在直接组合 `PlatformActionButton`,仅保留 `pill` 分支继续复用 `PlatformPillBadge` 风格。复制反馈按钮不再手动调用 `getPlatformActionButtonClassName` 拼平台按钮基础 chrome后续同类“复制状态机 + 平台动作按钮”组合也优先走 `CopyFeedbackButton + PlatformActionButton`不要在业务页或按钮组件里重新混写图标、文案、aria 和 class。验证命令`npm run test -- src/components/common/CopyFeedbackButton.test.tsx src/components/common/PlatformActionButton.test.tsx` 19.3.34. `CopyFeedbackButton.tsx``actionSurface` 分支继续向共享按钮收口:带平台动作外观的复制按钮现在直接组合 `PlatformActionButton`,仅保留 `pill` 分支继续复用 `PlatformPillBadge` 风格。复制反馈按钮不再手动调用 `getPlatformActionButtonClassName` 拼平台按钮基础 chrome后续同类“复制状态机 + 平台动作按钮”组合也优先走 `CopyFeedbackButton + PlatformActionButton`不要在业务页或按钮组件里重新混写图标、文案、aria 和 class。验证命令`npm run test -- src/components/common/CopyFeedbackButton.test.tsx src/components/common/PlatformActionButton.test.tsx`
19.3.35. 详情页头部动作组合收口到 `src/components/common/PlatformDetailTopbar.tsx``src/components/common/PlatformDetailShareActions.tsx`;前者只承接“返回 / 标题 / 右侧动作槽位”的 topbar 骨架,并允许 `pill` / `icon` 两种返回按钮语义,后者只承接“前置 badge 区块 + 作品号复制 + 分享复制”这一组稳定动作,不吸收详情页自己的标题、摘要、作者、封面轮播或业务 CTA。`RpgEntryWorldDetailView.tsx` 已接入完整的 overlay 版动作组合,统一世界主题 badge、作者、发布时间、作品号和分享入口`PlatformWorkDetailView.tsx` 已接入 icon topbar 与 solid 版作品号复制动作,并继续保留公开详情页自己的顶部 icon 分享入口和分享反馈提示。后续同类详情页若只是复用返回按钮骨架、标题居中布局或作品号 / 分享动作排列,优先直接组合这两个 Module不要把整页 detail header 抽成巨型配置对象。验证命令:`npx vitest run src/components/common/PlatformDetailTopbar.test.tsx src/components/common/PlatformDetailShareActions.test.tsx src/components/rpg-entry/RpgEntryWorldDetailView.test.tsx src/components/platform-entry/PlatformWorkDetailView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check -- src/components/common/PlatformDetailTopbar.tsx src/components/common/PlatformDetailTopbar.test.tsx src/components/common/PlatformDetailShareActions.tsx src/components/common/PlatformDetailShareActions.test.tsx src/components/rpg-entry/RpgEntryWorldDetailView.tsx src/components/platform-entry/PlatformWorkDetailView.tsx` 19.3.35. 详情页头部动作组合收口到 `src/components/common/PlatformDetailTopbar.tsx``src/components/common/PlatformDetailShareActions.tsx`;前者只承接“返回 / 标题 / 右侧动作槽位”的 topbar 骨架,并允许 `pill` / `icon` 两种返回按钮语义,后者只承接“前置 badge 区块 + 作品号复制 + 分享复制”这一组稳定动作,不吸收详情页自己的标题、摘要、作者、封面轮播或业务 CTA。`RpgEntryWorldDetailView.tsx` 已接入完整的 overlay 版动作组合,统一世界主题 badge、作者、发布时间、作品号和分享入口`PlatformWorkDetailView.tsx` 已接入 icon topbar 与 solid 版作品号复制动作,并继续保留公开详情页自己的顶部 icon 分享入口和分享反馈提示。后续同类详情页若只是复用返回按钮骨架、标题居中布局或作品号 / 分享动作排列,优先直接组合这两个 Module不要把整页 detail header 抽成巨型配置对象。验证命令:`npx vitest run src/components/common/PlatformDetailTopbar.test.tsx src/components/common/PlatformDetailShareActions.test.tsx src/components/rpg-entry/RpgEntryWorldDetailView.test.tsx src/components/platform-entry/PlatformWorkDetailView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check -- src/components/common/PlatformDetailTopbar.tsx src/components/common/PlatformDetailTopbar.test.tsx src/components/common/PlatformDetailShareActions.tsx src/components/common/PlatformDetailShareActions.test.tsx src/components/rpg-entry/RpgEntryWorldDetailView.tsx src/components/platform-entry/PlatformWorkDetailView.tsx`

View File

@@ -0,0 +1,135 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import {
PlatformOptionSegment,
PlatformPillTabRail,
PlatformUnderlineTabRail,
} from './PlatformSegmentedTabPresets';
test('underline tab rail keeps channel preset classes and tab semantics', () => {
const onChange = vi.fn();
render(
<PlatformUnderlineTabRail
items={[
{ id: 'recommend', label: '推荐' },
{ id: 'ranking', label: '排行' },
]}
activeId="recommend"
onChange={onChange}
ariaLabel="发现频道"
className="min-w-0"
/>,
);
const tablist = screen.getByRole('tablist', { name: '发现频道' });
const recommendTab = screen.getByRole('tab', { name: '推荐' });
const rankingTab = screen.getByRole('tab', { name: '排行' });
expect(tablist.className).toContain('platform-mobile-home-channelbar');
expect(tablist.className).toContain('min-w-0');
expect(recommendTab.className).toContain('platform-mobile-home-channel');
expect(recommendTab.className).toContain('platform-mobile-home-channel--active');
expect(rankingTab.className).not.toContain(
'platform-mobile-home-channel--active',
);
fireEvent.click(rankingTab);
expect(onChange).toHaveBeenCalledWith('ranking');
});
test('underline tab rail supports ranking preset', () => {
render(
<PlatformUnderlineTabRail
items={[
{ id: 'hot', label: '热门' },
{ id: 'new', label: '最新' },
]}
activeId="hot"
onChange={vi.fn()}
ariaLabel="作品排行"
variant="ranking"
/>,
);
const hotTab = screen.getByRole('tab', { name: '热门' });
expect(hotTab.closest('div')?.className).toContain('platform-ranking-tabs');
expect(hotTab.className).toContain('platform-ranking-tab');
expect(hotTab.className).toContain('platform-ranking-tab--active');
});
test('option segment keeps category filter preset classes', () => {
render(
<PlatformOptionSegment
items={[
{ id: 'all', label: '全部' },
{ id: 'rpg', label: '文字冒险' },
]}
activeId="all"
onChange={vi.fn()}
variant="categoryFilter"
/>,
);
const allButton = screen.getByRole('button', { name: '全部' });
expect(allButton.closest('div')?.className).toContain(
'platform-category-filter-dialog__options',
);
expect(allButton.className).toContain('platform-category-filter-dialog__option');
expect(allButton.className).toContain(
'platform-category-filter-dialog__option--active',
);
});
test('option segment supports profile tab semantics', () => {
const onChange = vi.fn();
render(
<PlatformOptionSegment
items={[
{ id: 'points', label: '泥点充值' },
{ id: 'membership', label: '会员卡' },
]}
activeId="points"
onChange={onChange}
variant="profile"
ariaLabel="充值类型"
/>,
);
const tablist = screen.getByRole('tablist', { name: '充值类型' });
const membershipTab = screen.getByRole('tab', { name: '会员卡' });
expect(tablist.className).toContain('grid-cols-2');
expect(membershipTab.className).toContain('w-full');
fireEvent.click(membershipTab);
expect(onChange).toHaveBeenCalledWith('membership');
});
test('pill tab rail keeps creation entry preset classes', () => {
render(
<PlatformPillTabRail
items={[
{ id: 'recent', label: '最近创作' },
{ id: 'recommend', label: '热门推荐' },
]}
activeId="recent"
onChange={vi.fn()}
ariaLabel="创作入口页签"
/>,
);
const recentTab = screen.getByRole('tab', { name: '最近创作' });
expect(recentTab.closest('div')?.className).toContain('snap-x');
expect(recentTab.className).toContain('snap-start');
expect(recentTab.className).toContain('after:bg-[#d9793f]');
});

View File

@@ -0,0 +1,182 @@
import {
PlatformSegmentedTabs,
type PlatformSegmentedTabItem,
} from './PlatformSegmentedTabs';
type PlatformSegmentedTabPresetProps<TId extends string> = {
items: readonly PlatformSegmentedTabItem<TId>[];
activeId: TId;
onChange: (id: TId) => void;
ariaLabel?: string;
className?: string;
};
export type PlatformUnderlineTabRailVariant = 'channel' | 'ranking';
const PLATFORM_UNDERLINE_TAB_RAIL_CLASS: Record<
PlatformUnderlineTabRailVariant,
string
> = {
channel: 'platform-mobile-home-channelbar pb-1',
ranking: 'platform-ranking-tabs pb-1',
};
const PLATFORM_UNDERLINE_TAB_ITEM_CLASS: Record<
PlatformUnderlineTabRailVariant,
{ base: string; active: string }
> = {
channel: {
base: 'platform-mobile-home-channel shrink-0 !min-h-8 !rounded-none !border-0 !bg-transparent !px-0 !shadow-none hover:!bg-transparent',
active: 'platform-mobile-home-channel--active',
},
ranking: {
base: 'platform-ranking-tab shrink-0 !min-h-[2.35rem] !rounded-none !border-0 !bg-transparent !px-[0.15rem] !shadow-none hover:!bg-transparent',
active: 'platform-ranking-tab--active',
},
};
export type PlatformOptionSegmentVariant = 'categoryFilter' | 'profile';
const PLATFORM_OPTION_SEGMENT_CLASS: Record<
PlatformOptionSegmentVariant,
{
rail: string;
active: string;
idle: string;
}
> = {
categoryFilter: {
rail: 'platform-category-filter-dialog__options',
active:
'platform-category-filter-dialog__option--active !border-[var(--platform-cool-border)] !bg-[var(--platform-cool-bg)] !text-[var(--platform-cool-text)]',
idle:
'platform-category-filter-dialog__option !min-h-[2.35rem] !rounded-[0.78rem] !border !border-[var(--platform-subpanel-border)] !bg-[var(--platform-subpanel-fill)] !px-3 !text-[0.88rem] !font-black !text-[var(--platform-text-base)] !shadow-none hover:!bg-[var(--platform-subpanel-fill)]',
},
profile: {
rail: '',
active:
'!border-[var(--platform-cool-border)] !bg-[var(--platform-cool-bg)] !text-[var(--platform-cool-text)]',
idle:
'w-full !min-h-[2.25rem] !rounded-[0.78rem] !border !border-[var(--platform-subpanel-border)] !bg-[rgba(255,255,255,0.04)] !px-3 !text-sm !font-extrabold !text-[var(--platform-text-base)] !shadow-none hover:!bg-[rgba(255,255,255,0.08)]',
},
};
/**
* 统一首页、作品架这类横向文字 rail只沉淀稳定的滚动与下划线皮肤。
*/
export function PlatformUnderlineTabRail<TId extends string>({
items,
activeId,
onChange,
ariaLabel,
className,
variant = 'channel',
}: PlatformSegmentedTabPresetProps<TId> & {
variant?: PlatformUnderlineTabRailVariant;
}) {
return (
<PlatformSegmentedTabs
items={items}
activeId={activeId}
onChange={onChange}
layout="scroll"
gap="md"
frame="bare"
surface="transparent"
size="sm"
tone="neutral"
semantics="tabs"
ariaLabel={ariaLabel}
className={[PLATFORM_UNDERLINE_TAB_RAIL_CLASS[variant], className]
.filter(Boolean)
.join(' ')}
itemClassName={(_, active) =>
[
PLATFORM_UNDERLINE_TAB_ITEM_CLASS[variant].base,
active ? PLATFORM_UNDERLINE_TAB_ITEM_CLASS[variant].active : null,
]
.filter(Boolean)
.join(' ')
}
/>
);
}
/**
* 统一二列按钮式切换,只负责稳定的视觉 preset不承接业务语义。
*/
export function PlatformOptionSegment<TId extends string>({
items,
activeId,
onChange,
ariaLabel,
className,
variant,
}: PlatformSegmentedTabPresetProps<TId> & {
variant: PlatformOptionSegmentVariant;
}) {
return (
<PlatformSegmentedTabs
items={items}
activeId={activeId}
onChange={onChange}
columns="two"
layout="grid"
gap={variant === 'profile' ? 'sm' : 'md'}
frame="bare"
surface="transparent"
size="sm"
semantics={variant === 'profile' ? 'tabs' : 'segment'}
ariaLabel={ariaLabel}
className={[PLATFORM_OPTION_SEGMENT_CLASS[variant].rail, className]
.filter(Boolean)
.join(' ')}
itemClassName={(_, active) =>
[
PLATFORM_OPTION_SEGMENT_CLASS[variant].idle,
active ? PLATFORM_OPTION_SEGMENT_CLASS[variant].active : null,
]
.filter(Boolean)
.join(' ')
}
/>
);
}
/**
* 创作入口使用的轻量 pill rail保留 snap 与下划线的组合语义。
*/
export function PlatformPillTabRail<TId extends string>({
items,
activeId,
onChange,
ariaLabel,
className,
}: PlatformSegmentedTabPresetProps<TId>) {
return (
<PlatformSegmentedTabs
items={items}
activeId={activeId}
onChange={onChange}
layout="scroll"
gap="md"
frame="bare"
surface="transparent"
size="sm"
tone="neutral"
semantics="tabs"
ariaLabel={ariaLabel}
className={['-mx-0.5 snap-x px-0.5 pb-1 scroll-px-2 sm:!gap-3', className]
.filter(Boolean)
.join(' ')}
itemClassName={(_, active) =>
[
"relative shrink-0 snap-start !min-h-8 !rounded-full !border-0 !bg-transparent !px-2.5 !text-xs !font-black !shadow-none sm:!min-h-9 sm:!px-3.5 sm:!text-sm",
active
? "!text-[#6f2f21] after:absolute after:bottom-0 after:left-3 after:right-3 after:h-1 after:rounded-full after:bg-[#d9793f] after:content-['']"
: '!text-[#7a6558] hover:!bg-transparent hover:!text-[#6f2f21]',
].join(' ')
}
/>
);
}

View File

@@ -0,0 +1,54 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformToolModalShell } from './PlatformToolModalShell';
vi.mock('../auth/AuthUiContext', () => ({
useAuthUi: () => ({ platformTheme: 'light' }),
}));
test('renders shared platform tool modal shell with remapped panel chrome', () => {
render(
<PlatformToolModalShell
open
title="发布拼图作品"
onClose={() => {}}
footer={<button type="button"></button>}
panelClassName="!max-h-[min(90vh,42rem)]"
>
<div></div>
</PlatformToolModalShell>,
);
const dialog = screen.getByRole('dialog', { name: '发布拼图作品' });
expect(dialog.parentElement?.className).toContain('platform-theme--light');
expect(dialog.className).toContain('platform-remap-surface');
expect(dialog.className).toContain('shadow-[0_24px_80px_rgba(0,0,0,0.55)]');
expect(dialog.className).toContain('!max-h-[min(90vh,42rem)]');
expect(within(dialog).getByText('这里是正文')).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '取消' })).toBeTruthy();
});
test('supports fixed aria label while keeping visible title text', () => {
const onClose = vi.fn();
render(
<PlatformToolModalShell
open
title="雨夜猫街"
ariaLabel="关卡详情"
onClose={onClose}
>
<div></div>
</PlatformToolModalShell>,
);
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(within(dialog).getByText('雨夜猫街')).toBeTruthy();
expect(screen.getByRole('button', { name: '关闭关卡详情' })).toBeTruthy();
fireEvent.click(dialog.parentElement as HTMLElement);
expect(onClose).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,90 @@
import type { ReactNode } from 'react';
import { useAuthUi } from '../auth/AuthUiContext';
import { UnifiedModal } from './UnifiedModal';
type PlatformToolModalShellProps = {
open: boolean;
title: string;
ariaLabel?: string;
description?: ReactNode;
children: ReactNode;
footer?: ReactNode;
onClose: () => void;
size?: 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen';
closeLabel?: string;
closeDisabled?: boolean;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
zIndexClassName?: string;
panelClassName?: string;
titleClassName?: string;
bodyClassName?: string;
footerClassName?: string;
};
function joinClassNames(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(' ');
}
/**
* 结果页 / 工具页里的白底 portal 弹窗壳。
* 这里只收口平台主题 overlay、白底 panel 和标准 header/body/footer 节奏,不吸收各玩法正文与动作语义。
*/
export function PlatformToolModalShell({
open,
title,
ariaLabel,
description,
children,
footer,
onClose,
size = 'xl',
closeLabel,
closeDisabled = false,
closeOnBackdrop = true,
closeOnEscape = true,
zIndexClassName = 'z-[140]',
panelClassName,
titleClassName,
bodyClassName,
footerClassName,
}: PlatformToolModalShellProps) {
const resolvedPlatformTheme =
useAuthUi()?.platformTheme ?? 'light';
return (
<UnifiedModal
open={open}
title={title}
// 某些工具弹窗标题会直接显示当前关卡/物品名,但读屏和测试更适合使用稳定的弹窗语义名。
ariaLabel={ariaLabel}
description={description}
onClose={onClose}
footer={footer}
size={size}
closeLabel={closeLabel ?? `关闭${ariaLabel ?? title}`}
closeDisabled={closeDisabled}
closeOnBackdrop={closeOnBackdrop}
closeOnEscape={closeOnEscape}
zIndexClassName={zIndexClassName}
overlayClassName={`platform-theme platform-theme--${resolvedPlatformTheme}`}
panelClassName={joinClassNames(
'platform-remap-surface shadow-[0_24px_80px_rgba(0,0,0,0.55)]',
panelClassName,
)}
headerClassName="!items-center !px-5 !py-4"
titleClassName={titleClassName}
bodyClassName={joinClassNames(
'!px-5 !py-4 sm:!px-5 sm:!py-4',
bodyClassName,
)}
footerClassName={joinClassNames(
'!px-5 !py-4 sm:!px-5 sm:!py-4',
footerClassName,
)}
>
{children}
</UnifiedModal>
);
}

View File

@@ -85,6 +85,25 @@ test('supports headerless dialogs while preserving the accessible name', () => {
expect(screen.getByText('窗口内容')).toBeTruthy(); expect(screen.getByText('窗口内容')).toBeTruthy();
}); });
test('supports a stable aria label while keeping the visible title', () => {
render(
<UnifiedModal
open
title="雨夜猫街"
ariaLabel="关卡详情"
onClose={() => {}}
portal={false}
>
<div></div>
</UnifiedModal>,
);
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(dialog).toBeTruthy();
expect(screen.getByText('雨夜猫街')).toBeTruthy();
});
test('respects closeDisabled for every default close path', () => { test('respects closeDisabled for every default close path', () => {
const onClose = vi.fn(); const onClose = vi.fn();
render( render(

View File

@@ -22,6 +22,7 @@ type UnifiedModalCloseIcon = ComponentProps<
type UnifiedModalProps = { type UnifiedModalProps = {
open: boolean; open: boolean;
title: string; title: string;
ariaLabel?: string;
titleId?: string; titleId?: string;
description?: ReactNode; description?: ReactNode;
children: ReactNode; children: ReactNode;
@@ -86,6 +87,7 @@ function getPanelStyle(
function UnifiedModalContent({ function UnifiedModalContent({
open, open,
title, title,
ariaLabel,
titleId: titleIdProp, titleId: titleIdProp,
description, description,
children, children,
@@ -183,8 +185,8 @@ function UnifiedModalContent({
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby={showHeader ? titleId : undefined} aria-labelledby={showHeader && !ariaLabel ? titleId : undefined}
aria-label={showHeader ? undefined : title} aria-label={ariaLabel ?? (!showHeader ? title : undefined)}
aria-describedby={description ? descriptionId : undefined} aria-describedby={description ? descriptionId : undefined}
className={joinClassNames(panelClasses, sizeClassName, panelClassName)} className={joinClassNames(panelClasses, sizeClassName, panelClassName)}
style={getPanelStyle(variant, panelStyle)} style={getPanelStyle(variant, panelStyle)}

View File

@@ -5,7 +5,7 @@ import type {
CreationEntryConfig, CreationEntryConfig,
CreationEntryEventBannerConfig, CreationEntryEventBannerConfig,
} from '../../services/creationEntryConfigService'; } from '../../services/creationEntryConfigService';
import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs'; import { PlatformPillTabRail } from '../common/PlatformSegmentedTabPresets';
import { import {
groupVisiblePlatformCreationTypes, groupVisiblePlatformCreationTypes,
type PlatformCreationTypeCard, type PlatformCreationTypeCard,
@@ -280,27 +280,11 @@ export function CustomWorldCreationStartCard({
</section> </section>
<section className="creation-template-list -mx-1 px-1 sm:-mx-2 sm:px-2"> <section className="creation-template-list -mx-1 px-1 sm:-mx-2 sm:px-2">
<PlatformSegmentedTabs <PlatformPillTabRail
items={categoryTabs} items={categoryTabs}
activeId={activeTabId ?? ''} activeId={activeTabId ?? ''}
onChange={setActiveCategoryId} onChange={setActiveCategoryId}
layout="scroll"
gap="md"
frame="bare"
surface="transparent"
size="sm"
tone="neutral"
semantics="tabs"
ariaLabel="创作入口页签" ariaLabel="创作入口页签"
className="-mx-0.5 snap-x px-0.5 pb-1 scroll-px-2 sm:!gap-3"
itemClassName={(_, active) =>
[
"relative shrink-0 snap-start !min-h-8 !rounded-full !border-0 !bg-transparent !px-2.5 !text-xs !font-black !shadow-none sm:!min-h-9 sm:!px-3.5 sm:!text-sm",
active
? "!text-[#6f2f21] after:absolute after:bottom-0 after:left-3 after:right-3 after:h-1 after:rounded-full after:bg-[#d9793f] after:content-['']"
: '!text-[#7a6558] hover:!bg-transparent hover:!text-[#6f2f21]',
].join(' ')
}
/> />
{isRecentTabActive ? ( {isRecentTabActive ? (

View File

@@ -1,4 +1,4 @@
import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs'; import { PlatformUnderlineTabRail } from '../common/PlatformSegmentedTabPresets';
export type CustomWorldWorkFilter = 'all' | 'draft' | 'published'; export type CustomWorldWorkFilter = 'all' | 'draft' | 'published';
@@ -39,27 +39,12 @@ export function CustomWorldWorkTabs({
}); });
return ( return (
<PlatformSegmentedTabs <PlatformUnderlineTabRail
items={filterTabs} items={filterTabs}
activeId={activeFilter} activeId={activeFilter}
onChange={onChange} onChange={onChange}
layout="scroll"
gap="md"
frame="bare"
surface="transparent"
size="sm"
tone="neutral"
semantics="tabs"
ariaLabel="作品筛选" ariaLabel="作品筛选"
className="pb-1 !gap-4 xl:pb-0" className="pb-1 !gap-4 xl:pb-0"
itemClassName={(_, active) =>
[
'platform-mobile-home-channel shrink-0 !min-h-8 !rounded-none !border-0 !bg-transparent !px-0 !shadow-none hover:!bg-transparent',
active ? 'platform-mobile-home-channel--active' : null,
]
.filter(Boolean)
.join(' ')
}
/> />
); );
} }

View File

@@ -19,7 +19,6 @@ import {
useMemo, useMemo,
useState, useState,
} from 'react'; } from 'react';
import { createPortal } from 'react-dom';
import type { CreationAudioAsset } from '../../../packages/shared/src/contracts/creationAudio'; import type { CreationAudioAsset } from '../../../packages/shared/src/contracts/creationAudio';
import type { Match3DResultDraft } from '../../../packages/shared/src/contracts/match3dAgent'; import type { Match3DResultDraft } from '../../../packages/shared/src/contracts/match3dAgent';
@@ -51,7 +50,6 @@ import {
type Match3DDecodedSpritesheetRegion, type Match3DDecodedSpritesheetRegion,
} from '../../services/match3dSpritesheetParser'; } from '../../services/match3dSpritesheetParser';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage'; import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformBackActionButton } from '../common/PlatformBackActionButton'; import { PlatformBackActionButton } from '../common/PlatformBackActionButton';
import { PlatformAssetPickerGrid } from '../common/PlatformAssetPickerCard'; import { PlatformAssetPickerGrid } from '../common/PlatformAssetPickerCard';
@@ -59,7 +57,6 @@ import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformMediaFrame } from '../common/PlatformMediaFrame'; import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
import { PlatformMediaTileGrid } from '../common/PlatformMediaTileGrid'; import { PlatformMediaTileGrid } from '../common/PlatformMediaTileGrid';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { PlatformMudPointConfirmDialog } from '../common/PlatformMudPointConfirmDialog'; import { PlatformMudPointConfirmDialog } from '../common/PlatformMudPointConfirmDialog';
import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformPillSwitch } from '../common/PlatformPillSwitch'; import { PlatformPillSwitch } from '../common/PlatformPillSwitch';
@@ -70,6 +67,7 @@ import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel'; import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformTagEditor } from '../common/PlatformTagEditor'; import { PlatformTagEditor } from '../common/PlatformTagEditor';
import { PlatformTextField } from '../common/PlatformTextField'; import { PlatformTextField } from '../common/PlatformTextField';
import { PlatformToolModalShell } from '../common/PlatformToolModalShell';
import { PlatformUploadPreviewCard } from '../common/PlatformUploadPreviewCard'; import { PlatformUploadPreviewCard } from '../common/PlatformUploadPreviewCard';
import { useMudPointConfirmController } from '../common/useMudPointConfirmController'; import { useMudPointConfirmController } from '../common/useMudPointConfirmController';
import { import {
@@ -1459,43 +1457,18 @@ function Match3DModalShell({
children: ReactNode; children: ReactNode;
onClose: () => void; onClose: () => void;
}) { }) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light'; return (
if (typeof document === 'undefined') { <PlatformToolModalShell
return null; open
} title={title}
onClose={onClose}
return createPortal( closeLabel="关闭"
<div zIndexClassName="z-[146]"
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[146] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`} size="xl"
onClick={(event) => { panelClassName="!max-h-[min(92vh,48rem)]"
if (event.target === event.currentTarget) {
onClose();
}
}}
> >
<div
role="dialog"
aria-modal="true"
aria-label={title}
className="platform-modal-shell platform-remap-surface flex max-h-[min(92vh,48rem)] w-full max-w-5xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
{title}
</div>
<PlatformModalCloseButton
onClick={onClose}
label="关闭"
variant="platformIcon"
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{children} {children}
</div> </PlatformToolModalShell>
</div>
</div>,
document.body,
); );
} }
@@ -1766,43 +1739,43 @@ function Match3DPublishDialog({
onUploadedImageRemove: () => void; onUploadedImageRemove: () => void;
onSubmitCover: () => void; onSubmitCover: () => void;
}) { }) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const publishReady = blockers.length === 0; const publishReady = blockers.length === 0;
if (typeof document === 'undefined') { return (
return null; <PlatformToolModalShell
} open
title="发布抓大鹅作品"
return createPortal( onClose={onClose}
<div closeLabel="关闭"
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[146] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`} closeDisabled={isGeneratingCover}
onClick={(event) => { closeOnBackdrop={!isGeneratingCover}
if (event.target === event.currentTarget && !isGeneratingCover) { zIndexClassName="z-[146]"
onClose(); size="xl"
} panelClassName="!max-h-[min(92vh,50rem)]"
}} footerClassName="flex-col-reverse sm:flex-row sm:justify-end"
> footer={
<div <>
role="dialog" <PlatformActionButton
aria-modal="true"
aria-label="发布抓大鹅作品"
className="platform-modal-shell platform-remap-surface flex max-h-[min(92vh,50rem)] w-full max-w-5xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<PlatformModalCloseButton
onClick={onClose} onClick={onClose}
disabled={isGeneratingCover} disabled={isGeneratingCover || isPublishing}
label="关闭" tone="ghost"
variant="platformIcon" >
className={isGeneratingCover ? 'cursor-not-allowed opacity-55' : ''}
/> </PlatformActionButton>
</div> <PlatformActionButton
onClick={onPublish}
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4"> disabled={!publishReady || isBusy}
>
{isPublishing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
广
</PlatformActionButton>
</>
}
>
<div className="mb-4 space-y-2"> <div className="mb-4 space-y-2">
<PlatformFieldLabel variant="section"></PlatformFieldLabel> <PlatformFieldLabel variant="section"></PlatformFieldLabel>
{publishError ? ( {publishError ? (
@@ -1849,31 +1822,7 @@ function Match3DPublishDialog({
onUploadedImageRemove={onUploadedImageRemove} onUploadedImageRemove={onUploadedImageRemove}
onSubmit={onSubmitCover} onSubmit={onSubmitCover}
/> />
</div> </PlatformToolModalShell>
<div className="flex flex-col-reverse gap-3 border-t border-[var(--platform-subpanel-border)] px-5 py-4 sm:flex-row sm:justify-end">
<PlatformActionButton
onClick={onClose}
disabled={isGeneratingCover || isPublishing}
tone="ghost"
>
</PlatformActionButton>
<PlatformActionButton
onClick={onPublish}
disabled={!publishReady || isBusy}
>
{isPublishing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
广
</PlatformActionButton>
</div>
</div>
</div>,
document.body,
); );
} }

View File

@@ -10,7 +10,7 @@ import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList'; import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs'; import { PlatformOptionSegment } from '../common/PlatformSegmentedTabPresets';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel'; import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformProfileModalShell } from './PlatformProfileModalShell'; import { PlatformProfileModalShell } from './PlatformProfileModalShell';
@@ -173,29 +173,12 @@ export function PlatformProfileRechargeModal({
panelClassName="platform-recharge-modal !max-w-[34rem] rounded-[1.4rem]" panelClassName="platform-recharge-modal !max-w-[34rem] rounded-[1.4rem]"
bodyClassName="max-h-[min(76vh,36rem)] overflow-y-auto px-5 py-5" bodyClassName="max-h-[min(76vh,36rem)] overflow-y-auto px-5 py-5"
> >
<PlatformSegmentedTabs <PlatformOptionSegment
items={RECHARGE_TAB_ITEMS} items={RECHARGE_TAB_ITEMS}
activeId={activeTab} activeId={activeTab}
onChange={onTabChange} onChange={onTabChange}
columns="two" variant="profile"
layout="grid"
gap="sm"
frame="bare"
surface="transparent"
size="sm"
tone="neutral"
semantics="tabs"
ariaLabel="充值类型" ariaLabel="充值类型"
itemClassName={(_, active) =>
[
'w-full !min-h-[2.25rem] !rounded-[0.78rem] !border !px-3 !text-sm !font-extrabold !shadow-none',
active
? '!border-[var(--platform-cool-border)] !bg-[var(--platform-cool-bg)] !text-[var(--platform-cool-text)]'
: '!border-[var(--platform-subpanel-border)] !bg-[rgba(255,255,255,0.04)] !text-[var(--platform-text-base)] hover:!bg-[rgba(255,255,255,0.08)]',
]
.filter(Boolean)
.join(' ')
}
/> />
<PlatformAsyncStatePanel <PlatformAsyncStatePanel

View File

@@ -7,7 +7,6 @@ import {
Trash2, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { CreativeDraftEditResult } from '../../../packages/shared/src/contracts/creativeAgent'; import type { CreativeDraftEditResult } from '../../../packages/shared/src/contracts/creativeAgent';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions'; import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
@@ -19,7 +18,6 @@ import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/co
import { updatePuzzleWork } from '../../services/puzzle-works'; import { updatePuzzleWork } from '../../services/puzzle-works';
import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works/puzzleHistoryAsset'; import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works/puzzleHistoryAsset';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage'; import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { useAuthUi } from '../auth/AuthUiContext';
import { CreativeImageInputPanel } from '../common/CreativeImageInputPanel'; import { CreativeImageInputPanel } from '../common/CreativeImageInputPanel';
import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformBackActionButton } from '../common/PlatformBackActionButton'; import { PlatformBackActionButton } from '../common/PlatformBackActionButton';
@@ -28,7 +26,6 @@ import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformIconBadge } from '../common/PlatformIconBadge'; import { PlatformIconBadge } from '../common/PlatformIconBadge';
import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformMediaFrame } from '../common/PlatformMediaFrame'; import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { PlatformMudPointConfirmDialog } from '../common/PlatformMudPointConfirmDialog'; import { PlatformMudPointConfirmDialog } from '../common/PlatformMudPointConfirmDialog';
import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformProgressBar } from '../common/PlatformProgressBar'; import { PlatformProgressBar } from '../common/PlatformProgressBar';
@@ -37,6 +34,7 @@ import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel'; import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformTagEditor } from '../common/PlatformTagEditor'; import { PlatformTagEditor } from '../common/PlatformTagEditor';
import { PlatformTextField } from '../common/PlatformTextField'; import { PlatformTextField } from '../common/PlatformTextField';
import { PlatformToolModalShell } from '../common/PlatformToolModalShell';
import { PlatformUploadPreviewCard } from '../common/PlatformUploadPreviewCard'; import { PlatformUploadPreviewCard } from '../common/PlatformUploadPreviewCard';
import PuzzleHistoryAssetPickerDialog from '../unified-creation/shared/PuzzleHistoryAssetPickerDialog'; import PuzzleHistoryAssetPickerDialog from '../unified-creation/shared/PuzzleHistoryAssetPickerDialog';
import { import {
@@ -513,7 +511,6 @@ function PuzzleLevelDetailDialog({
onLevelChange: (nextLevel: PuzzleDraftLevel) => void; onLevelChange: (nextLevel: PuzzleDraftLevel) => void;
onStartTestRun?: (levelId: string) => void; onStartTestRun?: (levelId: string) => void;
}) { }) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const [referenceImageSrc, setReferenceImageSrc] = useState(''); const [referenceImageSrc, setReferenceImageSrc] = useState('');
const [referenceImageLabel, setReferenceImageLabel] = useState(''); const [referenceImageLabel, setReferenceImageLabel] = useState('');
const [referenceImageError, setReferenceImageError] = useState<string | null>( const [referenceImageError, setReferenceImageError] = useState<string | null>(
@@ -622,38 +619,50 @@ function PuzzleLevelDetailDialog({
} }
}; };
if (typeof document === 'undefined') { return (
return null; <PlatformToolModalShell
} open
title={level.levelName || '关卡详情'}
return createPortal( ariaLabel="关卡详情"
<div onClose={onClose}
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[138] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`} closeLabel="关闭关卡详情"
onClick={(event) => { zIndexClassName="z-[138]"
if (event.target === event.currentTarget) { size="xl"
onClose(); panelClassName="!max-h-[min(94vh,50rem)] !max-w-[56rem]"
} titleClassName="truncate"
}} footerClassName="!block shrink-0 space-y-3 bg-[var(--platform-page-fill)] pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]"
footer={
<>
{onStartTestRun && hasFormalImage ? (
<PlatformActionButton
disabled={isBusy}
onClick={() => onStartTestRun(level.levelId)}
tone="secondary"
fullWidth
> >
<div <span className="inline-flex items-center gap-2">
role="dialog" <Play className="h-4 w-4" />
aria-modal="true"
aria-label="关卡详情" </span>
className="platform-modal-shell platform-remap-surface flex max-h-[min(94vh,50rem)] w-full max-w-[56rem] flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]" </PlatformActionButton>
onClick={(event) => event.stopPropagation()} ) : null}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div className="min-w-0 truncate text-base font-semibold text-[var(--platform-text-strong)]">
{level.levelName || '关卡详情'}
</div>
<PlatformModalCloseButton
variant="platformIcon"
label="关闭关卡详情"
onClick={onClose}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4"> {generationProgress.isGenerating ? (
<PlatformProgressBar
value={generationProgress.progressPercent}
size="lg"
ariaLabel="画面生成进度"
fillClassName="bg-amber-600"
>
<div className="absolute inset-0 flex items-center justify-center gap-2 px-4 text-sm font-bold text-white">
<Loader2 className="h-4 w-4 animate-spin" />
{generationProgress.secondsLeft}
</div>
</PlatformProgressBar>
) : null}
</>
}
>
<div className="puzzle-level-detail-list divide-y divide-[var(--platform-subpanel-border)]"> <div className="puzzle-level-detail-list divide-y divide-[var(--platform-subpanel-border)]">
<section className="grid gap-2 pb-4 sm:grid-cols-[7.5rem_minmax(0,1fr)] sm:items-center"> <section className="grid gap-2 pb-4 sm:grid-cols-[7.5rem_minmax(0,1fr)] sm:items-center">
<label <label
@@ -777,37 +786,6 @@ function PuzzleLevelDetailDialog({
/> />
</section> </section>
</div> </div>
</div>
<div className="shrink-0 space-y-3 border-t border-[var(--platform-subpanel-border)] bg-[var(--platform-page-fill)] px-5 py-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]">
{onStartTestRun && hasFormalImage ? (
<PlatformActionButton
disabled={isBusy}
onClick={() => onStartTestRun(level.levelId)}
tone="secondary"
fullWidth
>
<span className="inline-flex items-center gap-2">
<Play className="h-4 w-4" />
</span>
</PlatformActionButton>
) : null}
{generationProgress.isGenerating ? (
<PlatformProgressBar
value={generationProgress.progressPercent}
size="lg"
ariaLabel="画面生成进度"
fillClassName="bg-amber-600"
>
<div className="absolute inset-0 flex items-center justify-center gap-2 px-4 text-sm font-bold text-white">
<Loader2 className="h-4 w-4 animate-spin" />
{generationProgress.secondsLeft}
</div>
</PlatformProgressBar>
) : null}
</div>
<PlatformMudPointConfirmDialog <PlatformMudPointConfirmDialog
open={isCostConfirmOpen} open={isCostConfirmOpen}
@@ -835,9 +813,7 @@ function PuzzleLevelDetailDialog({
}} }}
/> />
) : null} ) : null}
</div> </PlatformToolModalShell>
</div>,
document.body,
); );
} }
@@ -860,49 +836,40 @@ function PuzzlePublishDialog({
onClose: () => void; onClose: () => void;
onPublish: () => void; onPublish: () => void;
}) { }) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const primaryLevel = editState.levels[0] ?? null; const primaryLevel = editState.levels[0] ?? null;
const formalImageSrc = primaryLevel const formalImageSrc = primaryLevel
? resolveLevelFormalImageSrc(primaryLevel) ? resolveLevelFormalImageSrc(primaryLevel)
: ''; : '';
if (typeof document === 'undefined') { return (
return null; <PlatformToolModalShell
} open
title="发布拼图作品"
return createPortal( onClose={onClose}
<div closeLabel="关闭发布拼图作品"
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[140] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`} zIndexClassName="z-[140]"
onClick={(event) => { size="lg"
if (event.target === event.currentTarget) { panelClassName="!max-h-[min(90vh,42rem)]"
onClose(); footerClassName="flex-col-reverse sm:flex-row sm:justify-end"
} footer={
}} <>
<PlatformActionButton onClick={onClose} tone="ghost">
</PlatformActionButton>
<PlatformActionButton
onClick={onPublish}
disabled={!publishReady || isBusy}
> >
<div {isBusy
role="dialog" ? '发布中...'
aria-modal="true" : `发布到广场 · ${PUZZLE_PUBLISH_POINT_COST}泥点`}
aria-label="发布拼图作品" </PlatformActionButton>
className="platform-modal-shell platform-remap-surface flex max-h-[min(90vh,42rem)] w-full max-w-3xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]" </>
onClick={(event) => event.stopPropagation()} }
> >
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<PlatformModalCloseButton
variant="platformIcon"
label="关闭发布拼图作品"
onClick={onClose}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_14rem]"> <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_14rem]">
<div className="space-y-3"> <div className="space-y-3">
<PlatformFieldLabel variant="section"> <PlatformFieldLabel variant="section"></PlatformFieldLabel>
</PlatformFieldLabel>
{actionError ? ( {actionError ? (
<PlatformStatusMessage tone="error" surface="platform"> <PlatformStatusMessage tone="error" surface="platform">
{actionError} {actionError}
@@ -936,9 +903,7 @@ function PuzzlePublishDialog({
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<PlatformFieldLabel variant="section"> <PlatformFieldLabel variant="section"></PlatformFieldLabel>
</PlatformFieldLabel>
<PlatformMediaFrame <PlatformMediaFrame
src={formalImageSrc} src={formalImageSrc}
refreshKey={imageRefreshKey} refreshKey={imageRefreshKey}
@@ -954,24 +919,7 @@ function PuzzlePublishDialog({
</div> </div>
</div> </div>
</div> </div>
</div> </PlatformToolModalShell>
<div className="flex flex-col-reverse gap-3 border-t border-[var(--platform-subpanel-border)] px-5 py-4 sm:flex-row sm:justify-end">
<PlatformActionButton onClick={onClose} tone="ghost">
</PlatformActionButton>
<PlatformActionButton
onClick={onPublish}
disabled={!publishReady || isBusy}
>
{isBusy
? '发布中...'
: `发布到广场 · ${PUZZLE_PUBLISH_POINT_COST}泥点`}
</PlatformActionButton>
</div>
</div>
</div>,
document.body,
); );
} }

View File

@@ -89,7 +89,10 @@ import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton'; import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { PlatformNavigableListItem } from '../common/PlatformNavigableListItem'; import { PlatformNavigableListItem } from '../common/PlatformNavigableListItem';
import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs'; import {
PlatformOptionSegment,
PlatformUnderlineTabRail,
} from '../common/PlatformSegmentedTabPresets';
import { PlatformStatusDialog } from '../common/PlatformStatusDialog'; import { PlatformStatusDialog } from '../common/PlatformStatusDialog';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel'; import { PlatformSubpanel } from '../common/PlatformSubpanel';
@@ -2178,27 +2181,11 @@ function PlatformCategoryFilterDialog({
<div className="text-xs font-black text-[var(--platform-text-soft)]"> <div className="text-xs font-black text-[var(--platform-text-soft)]">
</div> </div>
<PlatformSegmentedTabs <PlatformOptionSegment
items={kindFilterTabs} items={kindFilterTabs}
activeId={kindFilter} activeId={kindFilter}
onChange={onKindFilterChange} onChange={onKindFilterChange}
columns="two" variant="categoryFilter"
gap="md"
frame="bare"
surface="transparent"
size="sm"
semantics="segment"
className="platform-category-filter-dialog__options"
itemClassName={(_, active) =>
[
'platform-category-filter-dialog__option !min-h-[2.35rem] !rounded-[0.78rem] !border !border-[var(--platform-subpanel-border)] !bg-[var(--platform-subpanel-fill)] !px-3 !text-[0.88rem] !font-black !text-[var(--platform-text-base)] !shadow-none hover:!bg-[var(--platform-subpanel-fill)]',
active
? 'platform-category-filter-dialog__option--active !border-[var(--platform-cool-border)] !bg-[var(--platform-cool-bg)] !text-[var(--platform-cool-text)]'
: null,
]
.filter(Boolean)
.join(' ')
}
/> />
</div> </div>
@@ -2206,27 +2193,11 @@ function PlatformCategoryFilterDialog({
<div className="text-xs font-black text-[var(--platform-text-soft)]"> <div className="text-xs font-black text-[var(--platform-text-soft)]">
</div> </div>
<PlatformSegmentedTabs <PlatformOptionSegment
items={sortModeTabs} items={sortModeTabs}
activeId={sortMode} activeId={sortMode}
onChange={onSortModeChange} onChange={onSortModeChange}
columns="two" variant="categoryFilter"
gap="md"
frame="bare"
surface="transparent"
size="sm"
semantics="segment"
className="platform-category-filter-dialog__options"
itemClassName={(_, active) =>
[
'platform-category-filter-dialog__option !min-h-[2.35rem] !rounded-[0.78rem] !border !border-[var(--platform-subpanel-border)] !bg-[var(--platform-subpanel-fill)] !px-3 !text-[0.88rem] !font-black !text-[var(--platform-text-base)] !shadow-none hover:!bg-[var(--platform-subpanel-fill)]',
active
? 'platform-category-filter-dialog__option--active !border-[var(--platform-cool-border)] !bg-[var(--platform-cool-bg)] !text-[var(--platform-cool-text)]'
: null,
]
.filter(Boolean)
.join(' ')
}
/> />
</div> </div>
@@ -3604,30 +3575,15 @@ export function RpgEntryHomeView({
<section <section
className={`${PANEL_SURFACE_CLASS} platform-ranking-panel px-4 py-3.5 sm:px-5 sm:py-4`} className={`${PANEL_SURFACE_CLASS} platform-ranking-panel px-4 py-3.5 sm:px-5 sm:py-4`}
> >
<PlatformSegmentedTabs <PlatformUnderlineTabRail
items={PLATFORM_RANKING_TABS.map((tab) => ({ items={PLATFORM_RANKING_TABS.map((tab) => ({
id: tab.id, id: tab.id,
label: tab.label, label: tab.label,
}))} }))}
activeId={activeRankingTab} activeId={activeRankingTab}
onChange={setActiveRankingTab} onChange={setActiveRankingTab}
layout="scroll"
gap="md"
frame="bare"
surface="transparent"
size="sm"
tone="neutral"
semantics="tabs"
ariaLabel="作品排行" ariaLabel="作品排行"
className="platform-ranking-tabs pb-1" variant="ranking"
itemClassName={(_, active) =>
[
'platform-ranking-tab shrink-0 !min-h-[2.35rem] !rounded-none !border-0 !bg-transparent !px-[0.15rem] !shadow-none hover:!bg-transparent',
active ? 'platform-ranking-tab--active' : null,
]
.filter(Boolean)
.join(' ')
}
/> />
<PlatformAsyncStatePanel <PlatformAsyncStatePanel
@@ -3801,27 +3757,11 @@ export function RpgEntryHomeView({
/> />
) : ( ) : (
<> <>
<PlatformSegmentedTabs <PlatformUnderlineTabRail
items={discoverChannelTabs} items={discoverChannelTabs}
activeId={discoverChannel} activeId={discoverChannel}
onChange={handleDiscoverChannelChange} onChange={handleDiscoverChannelChange}
layout="scroll"
gap="md"
frame="bare"
surface="transparent"
size="sm"
tone="neutral"
semantics="tabs"
ariaLabel="发现频道" ariaLabel="发现频道"
className="platform-mobile-home-channelbar pb-1"
itemClassName={(_, active) =>
[
'platform-mobile-home-channel shrink-0 !min-h-8 !rounded-none !border-0 !bg-transparent !px-0 !shadow-none hover:!bg-transparent',
active ? 'platform-mobile-home-channel--active' : null,
]
.filter(Boolean)
.join(' ')
}
/> />
{platformError ? ( {platformError ? (
@@ -3969,27 +3909,11 @@ export function RpgEntryHomeView({
const desktopDiscoverContent: ReactNode = ( const desktopDiscoverContent: ReactNode = (
<div className={DESKTOP_DISCOVER_PAGE_STAGE_CLASS}> <div className={DESKTOP_DISCOVER_PAGE_STAGE_CLASS}>
<PlatformSegmentedTabs <PlatformUnderlineTabRail
items={discoverChannelTabs} items={discoverChannelTabs}
activeId={discoverChannel} activeId={discoverChannel}
onChange={handleDiscoverChannelChange} onChange={handleDiscoverChannelChange}
layout="scroll"
gap="md"
frame="bare"
surface="transparent"
size="sm"
tone="neutral"
semantics="tabs"
ariaLabel="发现频道" ariaLabel="发现频道"
className="platform-mobile-home-channelbar pb-1"
itemClassName={(_, active) =>
[
'platform-mobile-home-channel shrink-0 !min-h-8 !rounded-none !border-0 !bg-transparent !px-0 !shadow-none hover:!bg-transparent',
active ? 'platform-mobile-home-channel--active' : null,
]
.filter(Boolean)
.join(' ')
}
/> />
{platformError ? ( {platformError ? (

View File

@@ -1,14 +1,12 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { import {
puzzleAssetClient, puzzleAssetClient,
type PuzzleHistoryAsset, type PuzzleHistoryAsset,
} from '../../../services/puzzle-works/puzzleAssetClient'; } from '../../../services/puzzle-works/puzzleAssetClient';
import { formatPuzzleHistoryAssetCreatedAt } from '../../../services/puzzle-works/puzzleHistoryAsset'; import { formatPuzzleHistoryAssetCreatedAt } from '../../../services/puzzle-works/puzzleHistoryAsset';
import { useAuthUi } from '../../auth/AuthUiContext';
import { PlatformAssetPickerGrid } from '../../common/PlatformAssetPickerCard'; import { PlatformAssetPickerGrid } from '../../common/PlatformAssetPickerCard';
import { PlatformModalCloseButton } from '../../common/PlatformModalCloseButton'; import { PlatformToolModalShell } from '../../common/PlatformToolModalShell';
type PuzzleHistoryAssetPickerDialogProps = { type PuzzleHistoryAssetPickerDialogProps = {
isBusy: boolean; isBusy: boolean;
@@ -21,7 +19,6 @@ export function PuzzleHistoryAssetPickerDialog({
onClose, onClose,
onSelect, onSelect,
}: PuzzleHistoryAssetPickerDialogProps) { }: PuzzleHistoryAssetPickerDialogProps) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const [assets, setAssets] = useState<PuzzleHistoryAsset[]>([]); const [assets, setAssets] = useState<PuzzleHistoryAsset[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -57,38 +54,16 @@ export function PuzzleHistoryAssetPickerDialog({
}; };
}, []); }, []);
if (typeof document === 'undefined') { return (
return null; <PlatformToolModalShell
} open
title="选择历史图片"
return createPortal( onClose={onClose}
<div closeLabel="关闭历史图片选择器"
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[145] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`} zIndexClassName="z-[145]"
onClick={(event) => { size="xl"
if (event.target === event.currentTarget) { panelClassName="!max-h-[min(92vh,46rem)]"
onClose();
}
}}
> >
<div
role="dialog"
aria-modal="true"
aria-label="选择历史图片"
className="platform-modal-shell platform-remap-surface flex max-h-[min(92vh,46rem)] w-full max-w-5xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<PlatformModalCloseButton
variant="platformIcon"
label="关闭历史图片选择器"
onClick={onClose}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<PlatformAssetPickerGrid <PlatformAssetPickerGrid
items={assets} items={assets}
isLoading={isLoading} isLoading={isLoading}
@@ -109,10 +84,7 @@ export function PuzzleHistoryAssetPickerDialog({
cardRadiusClassName="rounded-[1.25rem]" cardRadiusClassName="rounded-[1.25rem]"
bodyClassName="px-4 py-3" bodyClassName="px-4 py-3"
/> />
</div> </PlatformToolModalShell>
</div>
</div>,
document.body,
); );
} }