diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index e77dacb6..260b4ed0 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -2085,11 +2085,11 @@ - 背景:个人中心 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/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 不再各自维护像素风关闭按钮壳和冒泡控制。 -- 决策:`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 扩成泛型动作路由器。 -- 决策:标准平台 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` 的样式透传再包装一层而不缩小调用面。 - 决策:`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。 diff --git a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md index 3ed45b54..3dfab569 100644 --- a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md +++ b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md @@ -250,12 +250,12 @@ 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.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.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.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.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`。 diff --git a/src/components/common/PlatformSegmentedTabPresets.test.tsx b/src/components/common/PlatformSegmentedTabPresets.test.tsx new file mode 100644 index 00000000..69fc4f5d --- /dev/null +++ b/src/components/common/PlatformSegmentedTabPresets.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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]'); +}); diff --git a/src/components/common/PlatformSegmentedTabPresets.tsx b/src/components/common/PlatformSegmentedTabPresets.tsx new file mode 100644 index 00000000..5c26e5a2 --- /dev/null +++ b/src/components/common/PlatformSegmentedTabPresets.tsx @@ -0,0 +1,182 @@ +import { + PlatformSegmentedTabs, + type PlatformSegmentedTabItem, +} from './PlatformSegmentedTabs'; + +type PlatformSegmentedTabPresetProps = { + items: readonly PlatformSegmentedTabItem[]; + 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({ + items, + activeId, + onChange, + ariaLabel, + className, + variant = 'channel', +}: PlatformSegmentedTabPresetProps & { + variant?: PlatformUnderlineTabRailVariant; +}) { + return ( + + [ + PLATFORM_UNDERLINE_TAB_ITEM_CLASS[variant].base, + active ? PLATFORM_UNDERLINE_TAB_ITEM_CLASS[variant].active : null, + ] + .filter(Boolean) + .join(' ') + } + /> + ); +} + +/** + * 统一二列按钮式切换,只负责稳定的视觉 preset,不承接业务语义。 + */ +export function PlatformOptionSegment({ + items, + activeId, + onChange, + ariaLabel, + className, + variant, +}: PlatformSegmentedTabPresetProps & { + variant: PlatformOptionSegmentVariant; +}) { + return ( + + [ + PLATFORM_OPTION_SEGMENT_CLASS[variant].idle, + active ? PLATFORM_OPTION_SEGMENT_CLASS[variant].active : null, + ] + .filter(Boolean) + .join(' ') + } + /> + ); +} + +/** + * 创作入口使用的轻量 pill rail,保留 snap 与下划线的组合语义。 + */ +export function PlatformPillTabRail({ + items, + activeId, + onChange, + ariaLabel, + className, +}: PlatformSegmentedTabPresetProps) { + return ( + + [ + "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(' ') + } + /> + ); +} diff --git a/src/components/common/PlatformToolModalShell.test.tsx b/src/components/common/PlatformToolModalShell.test.tsx new file mode 100644 index 00000000..cec4a10c --- /dev/null +++ b/src/components/common/PlatformToolModalShell.test.tsx @@ -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( + {}} + footer={} + panelClassName="!max-h-[min(90vh,42rem)]" + > +
这里是正文
+
, + ); + + 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( + +
这里是正文
+
, + ); + + 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); +}); diff --git a/src/components/common/PlatformToolModalShell.tsx b/src/components/common/PlatformToolModalShell.tsx new file mode 100644 index 00000000..ea069b80 --- /dev/null +++ b/src/components/common/PlatformToolModalShell.tsx @@ -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) { + 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 ( + + {children} + + ); +} diff --git a/src/components/common/UnifiedModal.test.tsx b/src/components/common/UnifiedModal.test.tsx index 66dad8a6..7e163776 100644 --- a/src/components/common/UnifiedModal.test.tsx +++ b/src/components/common/UnifiedModal.test.tsx @@ -85,6 +85,25 @@ test('supports headerless dialogs while preserving the accessible name', () => { expect(screen.getByText('窗口内容')).toBeTruthy(); }); +test('supports a stable aria label while keeping the visible title', () => { + render( + {}} + portal={false} + > +
窗口内容
+
, + ); + + const dialog = screen.getByRole('dialog', { name: '关卡详情' }); + + expect(dialog).toBeTruthy(); + expect(screen.getByText('雨夜猫街')).toBeTruthy(); +}); + test('respects closeDisabled for every default close path', () => { const onClose = vi.fn(); render( diff --git a/src/components/common/UnifiedModal.tsx b/src/components/common/UnifiedModal.tsx index 572f3cb0..54b68db5 100644 --- a/src/components/common/UnifiedModal.tsx +++ b/src/components/common/UnifiedModal.tsx @@ -22,6 +22,7 @@ type UnifiedModalCloseIcon = ComponentProps< type UnifiedModalProps = { open: boolean; title: string; + ariaLabel?: string; titleId?: string; description?: ReactNode; children: ReactNode; @@ -86,6 +87,7 @@ function getPanelStyle( function UnifiedModalContent({ open, title, + ariaLabel, titleId: titleIdProp, description, children, @@ -183,8 +185,8 @@ function UnifiedModalContent({
- - [ - "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 ? ( diff --git a/src/components/custom-world-home/CustomWorldWorkTabs.tsx b/src/components/custom-world-home/CustomWorldWorkTabs.tsx index 9b50c18d..8f53eb8d 100644 --- a/src/components/custom-world-home/CustomWorldWorkTabs.tsx +++ b/src/components/custom-world-home/CustomWorldWorkTabs.tsx @@ -1,4 +1,4 @@ -import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs'; +import { PlatformUnderlineTabRail } from '../common/PlatformSegmentedTabPresets'; export type CustomWorldWorkFilter = 'all' | 'draft' | 'published'; @@ -39,27 +39,12 @@ export function CustomWorldWorkTabs({ }); return ( - - [ - '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(' ') - } /> ); } diff --git a/src/components/match3d-result/Match3DResultView.tsx b/src/components/match3d-result/Match3DResultView.tsx index 4fa43d44..3dfe769d 100644 --- a/src/components/match3d-result/Match3DResultView.tsx +++ b/src/components/match3d-result/Match3DResultView.tsx @@ -19,7 +19,6 @@ import { useMemo, useState, } from 'react'; -import { createPortal } from 'react-dom'; import type { CreationAudioAsset } from '../../../packages/shared/src/contracts/creationAudio'; import type { Match3DResultDraft } from '../../../packages/shared/src/contracts/match3dAgent'; @@ -51,7 +50,6 @@ import { type Match3DDecodedSpritesheetRegion, } from '../../services/match3dSpritesheetParser'; import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage'; -import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformBackActionButton } from '../common/PlatformBackActionButton'; import { PlatformAssetPickerGrid } from '../common/PlatformAssetPickerCard'; @@ -59,7 +57,6 @@ import { PlatformFieldLabel } from '../common/PlatformFieldLabel'; import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformMediaFrame } from '../common/PlatformMediaFrame'; import { PlatformMediaTileGrid } from '../common/PlatformMediaTileGrid'; -import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton'; import { PlatformMudPointConfirmDialog } from '../common/PlatformMudPointConfirmDialog'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformPillSwitch } from '../common/PlatformPillSwitch'; @@ -70,6 +67,7 @@ import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformSubpanel } from '../common/PlatformSubpanel'; import { PlatformTagEditor } from '../common/PlatformTagEditor'; import { PlatformTextField } from '../common/PlatformTextField'; +import { PlatformToolModalShell } from '../common/PlatformToolModalShell'; import { PlatformUploadPreviewCard } from '../common/PlatformUploadPreviewCard'; import { useMudPointConfirmController } from '../common/useMudPointConfirmController'; import { @@ -1459,43 +1457,18 @@ function Match3DModalShell({ children: ReactNode; onClose: () => void; }) { - const platformTheme = useAuthUi()?.platformTheme ?? 'light'; - if (typeof document === 'undefined') { - return null; - } - - return createPortal( -
{ - if (event.target === event.currentTarget) { - onClose(); - } - }} + return ( + -
event.stopPropagation()} - > -
-
- {title} -
- -
-
- {children} -
-
-
, - document.body, + {children} + ); } @@ -1766,92 +1739,22 @@ function Match3DPublishDialog({ onUploadedImageRemove: () => void; onSubmitCover: () => void; }) { - const platformTheme = useAuthUi()?.platformTheme ?? 'light'; const publishReady = blockers.length === 0; - if (typeof document === 'undefined') { - return null; - } - - return createPortal( -
{ - if (event.target === event.currentTarget && !isGeneratingCover) { - onClose(); - } - }} - > -
event.stopPropagation()} - > -
-
- 发布抓大鹅作品 -
- -
- -
-
- 发布检查 - {publishError ? ( - - {publishError} - - ) : publishReady ? ( - - 当前作品已满足发布条件。 - - ) : ( -
- {blockers.map((blocker, index) => ( - - {blocker} - - ))} -
- )} -
- - - 封面图 - - -
- -
+ return ( + -
+ + } + > +
+ 发布检查 + {publishError ? ( + + {publishError} + + ) : publishReady ? ( + + 当前作品已满足发布条件。 + + ) : ( +
+ {blockers.map((blocker, index) => ( + + {blocker} + + ))} +
+ )}
-
, - document.body, + + + 封面图 + + + ); } diff --git a/src/components/platform-entry/PlatformProfileRechargeModal.tsx b/src/components/platform-entry/PlatformProfileRechargeModal.tsx index c66b351d..6dc34141 100644 --- a/src/components/platform-entry/PlatformProfileRechargeModal.tsx +++ b/src/components/platform-entry/PlatformProfileRechargeModal.tsx @@ -10,7 +10,7 @@ import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList'; -import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs'; +import { PlatformOptionSegment } from '../common/PlatformSegmentedTabPresets'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformSubpanel } from '../common/PlatformSubpanel'; import { PlatformProfileModalShell } from './PlatformProfileModalShell'; @@ -173,29 +173,12 @@ export function PlatformProfileRechargeModal({ panelClassName="platform-recharge-modal !max-w-[34rem] rounded-[1.4rem]" bodyClassName="max-h-[min(76vh,36rem)] overflow-y-auto px-5 py-5" > - - [ - '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(' ') - } /> void; onStartTestRun?: (levelId: string) => void; }) { - const platformTheme = useAuthUi()?.platformTheme ?? 'light'; const [referenceImageSrc, setReferenceImageSrc] = useState(''); const [referenceImageLabel, setReferenceImageLabel] = useState(''); const [referenceImageError, setReferenceImageError] = useState( @@ -622,164 +619,20 @@ function PuzzleLevelDetailDialog({ } }; - if (typeof document === 'undefined') { - return null; - } - - return createPortal( -
{ - if (event.target === event.currentTarget) { - onClose(); - } - }} - > -
event.stopPropagation()} - > -
-
- {level.levelName || '关卡详情'} -
- -
- -
-
-
- - - onLevelChange({ ...level, levelName: event.target.value }) - } - size="lg" - density="roomy" - aria-label="关卡名称" - /> -
- -
- - ) : null - } - mainImageInputId={`puzzle-level-reference-upload-${level.levelId}`} - promptTextareaId={`puzzle-level-picture-description-${level.levelId}`} - prompt={level.pictureDescription} - promptLabel={ - effectiveReferenceImageSrc - ? '画面AI重绘要求(提示词)' - : '画面描述' - } - promptRows={7} - aiRedraw={aiRedraw} - promptReferenceImages={promptReferenceImages} - promptReferenceLimit={PUZZLE_LEVEL_PROMPT_REFERENCE_LIMIT} - imageLimitHint="图片≤6MB" - imageModelPicker={ - - } - inputError={referenceImageError} - submitLabel={hasFormalImage ? '重新生成画面' : '生成画面'} - submitCostLabel={`消耗${PUZZLE_IMAGE_GENERATION_POINT_COST}泥点`} - submitDisabled={ - isBusy || - generationProgress.isGenerating || - (!level.pictureDescription.trim() && - !effectiveReferenceImageSrc) - } - labels={{ - imageField: '画面图', - uploadImage: '上传参考图', - replaceImage: '更换参考图', - emptyImageHint: '上传图片/填写画面描述', - removeImage: '移除参考图', - removeImageConfirmTitle: '移除参考图?', - removeImageConfirmBody: '移除后可重新上传或选择历史图片。', - promptReferenceUpload: '上传描述参考图', - promptReferencePreviewAlt: '参考图预览', - closePromptReferencePreview: '关闭参考图预览', - previewMainImage: '查看关卡图片', - closeMainImagePreview: '关闭关卡图片预览', - history: '选择历史图片', - }} - onMainImageFileSelect={(file) => { - void handleReferenceImageFile(file); - }} - onMainImageRemove={() => { - setReferenceImageSrc(''); - setReferenceImageLabel(''); - setReferenceImageError(null); - setAiRedraw(true); - onLevelChange({ ...level, pictureReference: null }); - }} - onAiRedrawChange={setAiRedraw} - onPromptChange={(value) => - onLevelChange({ - ...level, - pictureDescription: value, - }) - } - onPromptReferenceFilesSelect={(files) => { - void handlePromptReferenceImageFiles(files); - }} - onPromptReferenceRemove={(referenceId) => { - setPromptReferenceImages((current) => - current.filter((image) => image.id !== referenceId), - ); - setReferenceImageError(null); - }} - onHistoryClick={() => setIsHistoryPickerOpen(true)} - onSubmit={() => setIsCostConfirmOpen(true)} - /> -
-
-
- -
+ return ( + {onStartTestRun && hasFormalImage ? ( ) : null} -
- - setIsCostConfirmOpen(false)} - onConfirm={executeGeneration} - confirmDisabled={isBusy || generationProgress.isGenerating} - portal={false} - overlayClassName="absolute z-20 bg-black/45" - panelClassName="platform-remap-surface rounded-[1.5rem] shadow-[0_24px_80px_rgba(0,0,0,0.45)]" - /> - - {isHistoryPickerOpen ? ( - setIsHistoryPickerOpen(false)} - onSelect={(asset) => { - setReferenceImageSrc(asset.imageSrc); - setReferenceImageLabel( - getPuzzleHistoryAssetReferenceLabel(asset.imageSrc), - ); - setAiRedraw(true); - setReferenceImageError(null); - setIsHistoryPickerOpen(false); - }} + + } + > +
+
+ + + onLevelChange({ ...level, levelName: event.target.value }) + } + size="lg" + density="roomy" + aria-label="关卡名称" /> - ) : null} +
+ +
+ + ) : null + } + mainImageInputId={`puzzle-level-reference-upload-${level.levelId}`} + promptTextareaId={`puzzle-level-picture-description-${level.levelId}`} + prompt={level.pictureDescription} + promptLabel={ + effectiveReferenceImageSrc + ? '画面AI重绘要求(提示词)' + : '画面描述' + } + promptRows={7} + aiRedraw={aiRedraw} + promptReferenceImages={promptReferenceImages} + promptReferenceLimit={PUZZLE_LEVEL_PROMPT_REFERENCE_LIMIT} + imageLimitHint="图片≤6MB" + imageModelPicker={ + + } + inputError={referenceImageError} + submitLabel={hasFormalImage ? '重新生成画面' : '生成画面'} + submitCostLabel={`消耗${PUZZLE_IMAGE_GENERATION_POINT_COST}泥点`} + submitDisabled={ + isBusy || + generationProgress.isGenerating || + (!level.pictureDescription.trim() && + !effectiveReferenceImageSrc) + } + labels={{ + imageField: '画面图', + uploadImage: '上传参考图', + replaceImage: '更换参考图', + emptyImageHint: '上传图片/填写画面描述', + removeImage: '移除参考图', + removeImageConfirmTitle: '移除参考图?', + removeImageConfirmBody: '移除后可重新上传或选择历史图片。', + promptReferenceUpload: '上传描述参考图', + promptReferencePreviewAlt: '参考图预览', + closePromptReferencePreview: '关闭参考图预览', + previewMainImage: '查看关卡图片', + closeMainImagePreview: '关闭关卡图片预览', + history: '选择历史图片', + }} + onMainImageFileSelect={(file) => { + void handleReferenceImageFile(file); + }} + onMainImageRemove={() => { + setReferenceImageSrc(''); + setReferenceImageLabel(''); + setReferenceImageError(null); + setAiRedraw(true); + onLevelChange({ ...level, pictureReference: null }); + }} + onAiRedrawChange={setAiRedraw} + onPromptChange={(value) => + onLevelChange({ + ...level, + pictureDescription: value, + }) + } + onPromptReferenceFilesSelect={(files) => { + void handlePromptReferenceImageFiles(files); + }} + onPromptReferenceRemove={(referenceId) => { + setPromptReferenceImages((current) => + current.filter((image) => image.id !== referenceId), + ); + setReferenceImageError(null); + }} + onHistoryClick={() => setIsHistoryPickerOpen(true)} + onSubmit={() => setIsCostConfirmOpen(true)} + /> +
-
, - document.body, + + setIsCostConfirmOpen(false)} + onConfirm={executeGeneration} + confirmDisabled={isBusy || generationProgress.isGenerating} + portal={false} + overlayClassName="absolute z-20 bg-black/45" + panelClassName="platform-remap-surface rounded-[1.5rem] shadow-[0_24px_80px_rgba(0,0,0,0.45)]" + /> + + {isHistoryPickerOpen ? ( + setIsHistoryPickerOpen(false)} + onSelect={(asset) => { + setReferenceImageSrc(asset.imageSrc); + setReferenceImageLabel( + getPuzzleHistoryAssetReferenceLabel(asset.imageSrc), + ); + setAiRedraw(true); + setReferenceImageError(null); + setIsHistoryPickerOpen(false); + }} + /> + ) : null} + ); } @@ -860,103 +836,23 @@ function PuzzlePublishDialog({ onClose: () => void; onPublish: () => void; }) { - const platformTheme = useAuthUi()?.platformTheme ?? 'light'; const primaryLevel = editState.levels[0] ?? null; const formalImageSrc = primaryLevel ? resolveLevelFormalImageSrc(primaryLevel) : ''; - if (typeof document === 'undefined') { - return null; - } - - return createPortal( -
{ - if (event.target === event.currentTarget) { - onClose(); - } - }} - > -
event.stopPropagation()} - > -
-
- 发布拼图作品 -
- -
- -
-
-
- - 发布检查 - - {actionError ? ( - - {actionError} - - ) : publishReady ? ( -
- - 当前作品已满足发布条件。 - - - 消耗 {PUZZLE_PUBLISH_POINT_COST} 泥点 - -
- ) : ( -
- {blockers.map((blocker, index) => ( - - {blocker} - - ))} -
- )} -
- -
- - 封面关卡 - - 封面关卡} - aspect="square" - surface="soft" - className="rounded-[1.15rem]" - /> -
- {editState.workTitle} -
-
-
-
- -
+ return ( + 取消 @@ -968,10 +864,62 @@ function PuzzlePublishDialog({ ? '发布中...' : `发布到广场 · ${PUZZLE_PUBLISH_POINT_COST}泥点`} + + } + > +
+
+ 发布检查 + {actionError ? ( + + {actionError} + + ) : publishReady ? ( +
+ + 当前作品已满足发布条件。 + + + 消耗 {PUZZLE_PUBLISH_POINT_COST} 泥点 + +
+ ) : ( +
+ {blockers.map((blocker, index) => ( + + {blocker} + + ))} +
+ )} +
+ +
+ 封面关卡 + 封面关卡} + aspect="square" + surface="soft" + className="rounded-[1.15rem]" + /> +
+ {editState.workTitle} +
-
, - document.body, + ); } diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 54ea247e..f649b0d4 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -89,7 +89,10 @@ import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton'; import { PlatformNavigableListItem } from '../common/PlatformNavigableListItem'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; -import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs'; +import { + PlatformOptionSegment, + PlatformUnderlineTabRail, +} from '../common/PlatformSegmentedTabPresets'; import { PlatformStatusDialog } from '../common/PlatformStatusDialog'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformSubpanel } from '../common/PlatformSubpanel'; @@ -2178,27 +2181,11 @@ function PlatformCategoryFilterDialog({
玩法
- - [ - '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(' ') - } + variant="categoryFilter" />
@@ -2206,27 +2193,11 @@ function PlatformCategoryFilterDialog({
排序
- - [ - '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(' ') - } + variant="categoryFilter" />
@@ -3604,30 +3575,15 @@ export function RpgEntryHomeView({
- ({ id: tab.id, label: tab.label, }))} activeId={activeRankingTab} onChange={setActiveRankingTab} - layout="scroll" - gap="md" - frame="bare" - surface="transparent" - size="sm" - tone="neutral" - semantics="tabs" ariaLabel="作品排行" - className="platform-ranking-tabs pb-1" - 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(' ') - } + variant="ranking" /> ) : ( <> - - [ - '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 ? ( @@ -3969,27 +3909,11 @@ export function RpgEntryHomeView({ const desktopDiscoverContent: ReactNode = (
- - [ - '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 ? ( diff --git a/src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.tsx b/src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.tsx index b606277b..3499f28d 100644 --- a/src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.tsx +++ b/src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.tsx @@ -1,14 +1,12 @@ import { useEffect, useState } from 'react'; -import { createPortal } from 'react-dom'; import { puzzleAssetClient, type PuzzleHistoryAsset, } from '../../../services/puzzle-works/puzzleAssetClient'; import { formatPuzzleHistoryAssetCreatedAt } from '../../../services/puzzle-works/puzzleHistoryAsset'; -import { useAuthUi } from '../../auth/AuthUiContext'; import { PlatformAssetPickerGrid } from '../../common/PlatformAssetPickerCard'; -import { PlatformModalCloseButton } from '../../common/PlatformModalCloseButton'; +import { PlatformToolModalShell } from '../../common/PlatformToolModalShell'; type PuzzleHistoryAssetPickerDialogProps = { isBusy: boolean; @@ -21,7 +19,6 @@ export function PuzzleHistoryAssetPickerDialog({ onClose, onSelect, }: PuzzleHistoryAssetPickerDialogProps) { - const platformTheme = useAuthUi()?.platformTheme ?? 'light'; const [assets, setAssets] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -57,62 +54,37 @@ export function PuzzleHistoryAssetPickerDialog({ }; }, []); - if (typeof document === 'undefined') { - return null; - } - - return createPortal( -
{ - if (event.target === event.currentTarget) { - onClose(); - } - }} + return ( + -
event.stopPropagation()} - > -
-
- 选择历史图片 -
- -
- -
- asset.assetObjectId} - getImageSrc={(asset) => asset.imageSrc} - getImageAlt={() => ''} - getSubtitle={(asset) => - formatPuzzleHistoryAssetCreatedAt(asset.createdAt) - } - getAriaLabel={(asset) => - `选择${formatPuzzleHistoryAssetCreatedAt(asset.createdAt)}的历史图片` - } - onSelect={onSelect} - cardRadiusClassName="rounded-[1.25rem]" - bodyClassName="px-4 py-3" - /> -
-
-
, - document.body, + asset.assetObjectId} + getImageSrc={(asset) => asset.imageSrc} + getImageAlt={() => ''} + getSubtitle={(asset) => + formatPuzzleHistoryAssetCreatedAt(asset.createdAt) + } + getAriaLabel={(asset) => + `选择${formatPuzzleHistoryAssetCreatedAt(asset.createdAt)}的历史图片` + } + onSelect={onSelect} + cardRadiusClassName="rounded-[1.25rem]" + bodyClassName="px-4 py-3" + /> + ); }