diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index af072588..3914583b 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -2071,8 +2071,11 @@ - 决策:新增 `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/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。 +- 决策:简单泥点确认流的开关状态机统一收口到 `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 语义。 - 决策:平台入口的创作前置泥点阻断提示只在 `platform-entry` 局部抽成 `src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx`,并使用 `DraftGenerationPointNotice` union(`insufficient-points` / `balance-load-failed`)承接业务真相;不要在 `common/` 再抽一个泛化 `BlockingNoticeDialog`,否则会把 `PlatformAcknowledgeStatusDialog` 的样式透传再包装一层而不缩小调用面。 -- 验证方式:`npm run test -- src/components/common/PlatformAsyncStatePanel.test.tsx src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx src/components/common/PlatformSegmentedTabs.test.tsx src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、`npm run test -- src/components/common/PlatformModalCloseButton.test.tsx src/components/PixelCloseButton.test.tsx src/components/CharacterChatModal.test.tsx src/components/MapModal.test.tsx`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile game category filter dialog filters by play type|bottom category tab becomes ranking and switches ranking metrics|ranking rows limit displayed work name and show two short tags on the third line"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "searching unmatched public work code shows not-found search result dialog|public code search shows public user summary in shared search result modal and clears it on close|bark battle form checks mud points before creating image assets|puzzle form checks mud points before creating a draft|match3d form checks mud points before creating a draft"`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +- 验证方式:`npm run test -- src/components/common/PlatformAsyncStatePanel.test.tsx src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx src/components/common/PlatformSegmentedTabs.test.tsx src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、`npm run test -- src/components/common/PlatformModalCloseButton.test.tsx src/components/PixelCloseButton.test.tsx src/components/CharacterChatModal.test.tsx src/components/MapModal.test.tsx`、`npm run test -- src/components/common/useMudPointConfirmController.test.tsx src/components/match3d-result/Match3DResultView.test.tsx src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/rpg-creation-result/RpgCreationResultActionBar.test.tsx src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 ## 2026-05-26 敲木鱼发布后作品架与推荐流刷新口径 diff --git a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md index 0c9fd810..82c3f6bf 100644 --- a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md +++ b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md @@ -251,6 +251,9 @@ 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.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.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. creative-agent 首页的侧边栏菜单、账号入口、开启新对话、我的创作、首页激励 CTA 和 prompt suggestion 按钮迁移到 `PlatformIconButton` / `PlatformActionButton`;首页继续保留 `creative-agent-home__*` 本地 class 承接透明顶栏、抽屉和品牌化胶囊视觉,不把视觉回收和语义收口绑成一次大改。`Beta` 徽标和历史记录纯文本行暂保留本地实现,等出现更多同构轻量列表行后再评估是否抽新的共享 row primitive。 19.4. 大鱼吃小鱼结果页 hero 的返回入口迁移到 `PlatformIconButton variant="darkMini"`,测试 / 发布动作迁移到 `PlatformActionButton surface="editorDark"`;结果页只保留测试运行、发布提交和文案状态语义,不再手写 hero 顶栏按钮壳。 19.4.1. 大鱼吃小鱼结果页的发布失败弹层迁移到 `src/components/common/PlatformStatusDialog.tsx`;`PlatformStatusDialog` 补充自定义图标、可访问标签和动作按钮样式透传后,`BigFishResultView` 不再保留 `BigFishResultErrorModal` 内联的 `UnifiedConfirmDialog + PlatformIconBadge` 组合。结果页只保留失败文案和关闭回调,发布失败的状态图标、遮罩、白底面板和“知道了”主动作统一由共享状态弹层承接。验证命令:`npm run test -- src/components/common/PlatformStatusDialog.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`、`npm run typecheck`。 diff --git a/src/components/CustomWorldEntityCatalog.tsx b/src/components/CustomWorldEntityCatalog.tsx index 6cf99b3d..fb61d8ae 100644 --- a/src/components/CustomWorldEntityCatalog.tsx +++ b/src/components/CustomWorldEntityCatalog.tsx @@ -30,6 +30,7 @@ import { PlatformEmptyState } from './common/PlatformEmptyState'; import { PlatformMediaFrame } from './common/PlatformMediaFrame'; import { PlatformPillBadge } from './common/PlatformPillBadge'; import { PlatformProgressBar } from './common/PlatformProgressBar'; +import { PlatformSegmentedTabs } from './common/PlatformSegmentedTabs'; import { PlatformStatGrid } from './common/PlatformStatGrid'; import { PlatformStatusMessage } from './common/PlatformStatusMessage'; import { PlatformSubpanel } from './common/PlatformSubpanel'; @@ -833,6 +834,22 @@ export function CustomWorldEntityCatalog({ 1 + (pendingGeneratedEntity?.kind === 'landmark' ? 1 : 0), } satisfies Record; + const resultTabItems = useMemo( + () => + RESULT_TABS.map((tab) => ({ + id: tab.id, + ariaLabel: `${tab.label} ${counts[tab.id]}`, + label: ( +
+
{tab.label}
+
+ {counts[tab.id]} +
+
+ ), + })), + [counts], + ); const worldStatItems = [ { label: '可扮演角色', value: profile.playableNpcs.length }, { label: '场景角色', value: profile.storyNpcs.length }, @@ -974,22 +991,28 @@ export function CustomWorldEntityCatalog({
-
- {RESULT_TABS.map((tab) => ( -
- -
- ))} -
+ + [ + 'platform-tab shrink-0 !min-h-0 !rounded-full !px-3 !py-2 xl:min-w-[5.25rem] xl:!px-4 xl:!py-2', + active ? 'platform-tab--active' : null, + ] + .filter(Boolean) + .join(' ') + } + /> {activeTab !== 'world' ? (
diff --git a/src/components/CustomWorldEntityEditorModal.test.tsx b/src/components/CustomWorldEntityEditorModal.test.tsx index 3790fd6d..b8b5c687 100644 --- a/src/components/CustomWorldEntityEditorModal.test.tsx +++ b/src/components/CustomWorldEntityEditorModal.test.tsx @@ -950,6 +950,37 @@ test('世界页基本设定编辑按钮打开基本设定编辑目标', async () expect(handleEditTarget).toHaveBeenCalledWith({ kind: 'foundation' }); }); +test('实体目录 sticky rail 改用共享 segmented tabs 并保留计数标签', async () => { + const user = userEvent.setup(); + const handleActiveTabChange = vi.fn(); + + render( + {}} + onProfileChange={() => {}} + />, + ); + + const tablist = screen.getByRole('tablist', { name: '世界实体目录' }); + const worldTab = screen.getByRole('tab', { name: '世界 1' }); + const storyTab = screen.getByRole('tab', { name: '场景角色 1' }); + + expect(tablist.className).toContain('flex'); + expect(tablist.className).toContain('overflow-x-auto'); + expect(worldTab.getAttribute('aria-selected')).toBe('true'); + expect(storyTab.getAttribute('aria-selected')).toBe('false'); + expect(within(storyTab).getByText('场景角色')).toBeTruthy(); + expect(within(storyTab).getByText('1')).toBeTruthy(); + + await user.click(storyTab); + + expect(handleActiveTabChange).toHaveBeenCalledWith('story'); +}); + test('基本设定用分号拆分成标签展示', () => { const profile = { ...createProfile(), diff --git a/src/components/common/useMudPointConfirmController.test.tsx b/src/components/common/useMudPointConfirmController.test.tsx new file mode 100644 index 00000000..301277bc --- /dev/null +++ b/src/components/common/useMudPointConfirmController.test.tsx @@ -0,0 +1,47 @@ +/* @vitest-environment jsdom */ + +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useMudPointConfirmController } from './useMudPointConfirmController'; + +describe('useMudPointConfirmController', () => { + it('opens closes and confirms with the latest handler', () => { + const firstConfirm = vi.fn(); + const secondConfirm = vi.fn(); + const { result, rerender } = renderHook( + ({ onConfirm }: { onConfirm: () => void }) => + useMudPointConfirmController(onConfirm), + { + initialProps: { + onConfirm: firstConfirm, + }, + }, + ); + + expect(result.current.open).toBe(false); + + act(() => { + result.current.requestOpen(); + }); + expect(result.current.open).toBe(true); + + act(() => { + result.current.close(); + }); + expect(result.current.open).toBe(false); + + rerender({ onConfirm: secondConfirm }); + + act(() => { + result.current.requestOpen(); + }); + act(() => { + result.current.confirm(); + }); + + expect(result.current.open).toBe(false); + expect(firstConfirm).not.toHaveBeenCalled(); + expect(secondConfirm).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/common/useMudPointConfirmController.ts b/src/components/common/useMudPointConfirmController.ts new file mode 100644 index 00000000..90a5c369 --- /dev/null +++ b/src/components/common/useMudPointConfirmController.ts @@ -0,0 +1,29 @@ +import { useCallback, useState } from 'react'; + +/** + * 泥点确认状态机只收口最小开关语义。 + * 业务页继续自己持有点数、文案、禁用条件和是否需要弹确认的判断。 + */ +export function useMudPointConfirmController(onConfirm: () => void) { + const [open, setOpen] = useState(false); + + const requestOpen = useCallback(() => { + setOpen(true); + }, []); + + const close = useCallback(() => { + setOpen(false); + }, []); + + const confirm = useCallback(() => { + setOpen(false); + onConfirm(); + }, [onConfirm]); + + return { + open, + requestOpen, + close, + confirm, + }; +} diff --git a/src/components/match3d-result/Match3DResultView.tsx b/src/components/match3d-result/Match3DResultView.tsx index c4f13eb1..8cdbde0a 100644 --- a/src/components/match3d-result/Match3DResultView.tsx +++ b/src/components/match3d-result/Match3DResultView.tsx @@ -70,6 +70,7 @@ import { PlatformSubpanel } from '../common/PlatformSubpanel'; import { PlatformTagEditor } from '../common/PlatformTagEditor'; import { PlatformTextField } from '../common/PlatformTextField'; import { PlatformUploadPreviewCard } from '../common/PlatformUploadPreviewCard'; +import { useMudPointConfirmController } from '../common/useMudPointConfirmController'; import { MATCH3D_RUNTIME_BOARD_BASE_CLASS, MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS, @@ -2308,7 +2309,7 @@ function Match3DBatchAddItemsPanel({ const parsedNames = normalizeMatch3DItemNameList(values); const isGenerating = generationState.phase === 'generating'; const pointsCost = calculateMatch3DItemAssetsPointsCost(parsedNames.length); - const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false); + const costConfirm = useMudPointConfirmController(onSubmit); return ( @@ -2357,7 +2358,7 @@ function Match3DBatchAddItemsPanel({ setIsCostConfirmOpen(true)} + onClick={costConfirm.requestOpen} size="md" fullWidth className="min-h-11 gap-2" @@ -2371,13 +2372,10 @@ function Match3DBatchAddItemsPanel({ setIsCostConfirmOpen(false)} - onConfirm={() => { - setIsCostConfirmOpen(false); - onSubmit(); - }} + onClose={costConfirm.close} + onConfirm={costConfirm.confirm} confirmDisabled={parsedNames.length <= 0 || isGenerating} showCloseButton={false} portal={false} @@ -2410,7 +2408,7 @@ function Match3DBatchRegenerateItemsPanel({ const pointsCost = calculateMatch3DItemAssetsPointsCost( targetItemNames.length, ); - const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false); + const costConfirm = useMudPointConfirmController(onSubmit); return ( @@ -2448,7 +2446,7 @@ function Match3DBatchRegenerateItemsPanel({ setIsCostConfirmOpen(true)} + onClick={costConfirm.requestOpen} size="md" fullWidth className="min-h-11 gap-2" @@ -2462,13 +2460,10 @@ function Match3DBatchRegenerateItemsPanel({ setIsCostConfirmOpen(false)} - onConfirm={() => { - setIsCostConfirmOpen(false); - onSubmit(); - }} + onClose={costConfirm.close} + onConfirm={costConfirm.confirm} confirmDisabled={targetItemNames.length <= 0 || isGenerating} showCloseButton={false} portal={false} diff --git a/src/components/platform-entry/PlatformProfileRechargeModal.test.tsx b/src/components/platform-entry/PlatformProfileRechargeModal.test.tsx index b5ae13bb..a4ebc938 100644 --- a/src/components/platform-entry/PlatformProfileRechargeModal.test.tsx +++ b/src/components/platform-entry/PlatformProfileRechargeModal.test.tsx @@ -97,4 +97,52 @@ describe('PlatformProfileRechargeModal', () => { expect(screen.getByText('暂无可购买套餐')).toBeTruthy(); }); + + test('uses shared segmented tabs for recharge type switching', async () => { + const user = userEvent.setup(); + const onTabChange = vi.fn(); + + render( + , + ); + + const tablist = screen.getByRole('tablist', { name: '充值类型' }); + const pointsTab = screen.getByRole('tab', { name: '泥点充值' }); + const membershipTab = screen.getByRole('tab', { name: '会员卡' }); + + expect(tablist.className).toContain('grid'); + expect(tablist.className).toContain('grid-cols-2'); + expect(pointsTab.getAttribute('aria-selected')).toBe('true'); + expect(membershipTab.getAttribute('aria-selected')).toBe('false'); + + await user.click(membershipTab); + + expect(onTabChange).toHaveBeenCalledWith('membership'); + }); }); diff --git a/src/components/platform-entry/PlatformProfileRechargeModal.tsx b/src/components/platform-entry/PlatformProfileRechargeModal.tsx index bbedb10a..a584c0f4 100644 --- a/src/components/platform-entry/PlatformProfileRechargeModal.tsx +++ b/src/components/platform-entry/PlatformProfileRechargeModal.tsx @@ -9,6 +9,7 @@ import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; +import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformSubpanel } from '../common/PlatformSubpanel'; import { PlatformProfileModalShell } from './PlatformProfileModalShell'; @@ -24,6 +25,10 @@ import { } from '../rpg-entry/rpgEntryProfileFundsViewModel'; const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; +const RECHARGE_TAB_ITEMS: Array<{ id: RechargeTab; label: string }> = [ + { id: 'points', label: '泥点充值' }, + { id: 'membership', label: '会员卡' }, +]; export type PlatformProfileRechargeModalProps = { center: ProfileRechargeCenterResponse | null; @@ -167,22 +172,30 @@ 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(' ') + } + /> { expect( within(dialog).queryByRole('button', { name: /关卡测试/u }), ).toBeNull(); - fireEvent.click(screen.getByLabelText('关闭')); + fireEvent.click(screen.getByLabelText('关闭关卡详情')); expect(screen.getAllByText('第2关').length).toBeGreaterThan(0); await act(async () => { @@ -741,7 +741,7 @@ describe('PuzzleResultView', () => { { name: '确定' }, ), ); - fireEvent.click(screen.getByLabelText('关闭')); + fireEvent.click(screen.getByLabelText('关闭关卡详情')); expect( within(screen.getByLabelText('拼图关卡列表')).getAllByText('生成中') @@ -790,11 +790,11 @@ describe('PuzzleResultView', () => { { levelId: 'puzzle-level-1' }, ); - fireEvent.click(screen.getByLabelText('关闭')); + fireEvent.click(screen.getByLabelText('关闭关卡详情')); openPuzzleLevelsTab(); fireEvent.click(screen.getByRole('button', { name: /新增关卡/u })); expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy(); - fireEvent.click(screen.getByLabelText('关闭')); + fireEvent.click(screen.getByLabelText('关闭关卡详情')); fireEvent.click(screen.getByRole('button', { name: /发布/u })); const publishDialog = screen.getByRole('dialog', { name: '发布拼图作品' }); diff --git a/src/components/puzzle-result/PuzzleResultView.tsx b/src/components/puzzle-result/PuzzleResultView.tsx index 17067b9d..4f4e249e 100644 --- a/src/components/puzzle-result/PuzzleResultView.tsx +++ b/src/components/puzzle-result/PuzzleResultView.tsx @@ -6,7 +6,6 @@ import { Play, Plus, Trash2, - X, } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -28,6 +27,7 @@ import { PlatformFieldLabel } from '../common/PlatformFieldLabel'; import { PlatformIconBadge } from '../common/PlatformIconBadge'; import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformMediaFrame } from '../common/PlatformMediaFrame'; +import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton'; import { PlatformMudPointConfirmDialog } from '../common/PlatformMudPointConfirmDialog'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformProgressBar } from '../common/PlatformProgressBar'; @@ -652,10 +652,10 @@ function PuzzleLevelDetailDialog({
{level.levelName || '关卡详情'}
- } />
@@ -896,10 +896,10 @@ function PuzzlePublishDialog({
发布拼图作品
- } />
diff --git a/src/components/rpg-creation-result/RpgCreationResultActionBar.test.tsx b/src/components/rpg-creation-result/RpgCreationResultActionBar.test.tsx index 274c8986..387f67ab 100644 --- a/src/components/rpg-creation-result/RpgCreationResultActionBar.test.tsx +++ b/src/components/rpg-creation-result/RpgCreationResultActionBar.test.tsx @@ -105,7 +105,7 @@ test('RPG result publish dialog keeps generated and default cover source labels' expect(screen.getByText('AI封面')).toBeTruthy(); - await user.click(screen.getByRole('button', { name: '关闭' })); + await user.click(screen.getByRole('button', { name: '关闭发布作品' })); rerender( - } />
diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 9560aae2..94f76c61 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -1606,7 +1606,7 @@ test('profile recharge modal posts membership goods virtual payment params in mi renderProfileView(); await openRechargeModal(user); - await user.click(screen.getByRole('button', { name: '会员卡' })); + await user.click(screen.getByRole('tab', { name: '会员卡' })); await user.click(await screen.findByRole('button', { name: /月卡/u })); await waitFor(() => { @@ -3766,6 +3766,14 @@ test('mobile discover keeps edutainment works in the last dedicated channel only true, ); expect(discoverStage?.classList.contains('platform-page-stage')).toBe(false); + expect( + within(discoverPanel).getByRole('tablist', { name: '发现频道' }), + ).toBeTruthy(); + expect( + within(discoverPanel).getByRole('tab', { name: '推荐' }).getAttribute( + 'aria-selected', + ), + ).toBe('true'); const channels = Array.from( discoverPanel.querySelectorAll('.platform-mobile-home-channel'), @@ -3774,18 +3782,23 @@ test('mobile discover keeps edutainment works in the last dedicated channel only expect(within(discoverPanel).getByText('普通拼图作品')).toBeTruthy(); expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull(); - await user.click(screen.getByRole('button', { name: '今日' })); + await user.click(screen.getByRole('tab', { name: '今日' })); + expect( + within(discoverPanel).getByRole('tab', { name: '今日' }).getAttribute( + 'aria-selected', + ), + ).toBe('true'); expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull(); - await user.click(screen.getByRole('button', { name: '分类' })); + await user.click(screen.getByRole('tab', { name: '分类' })); expect(screen.getByRole('button', { name: '儿童教育' })).toBeTruthy(); - expect(screen.queryByRole('button', { name: '寓教于乐' })).toBeTruthy(); + expect(screen.queryByRole('tab', { name: '寓教于乐' })).toBeTruthy(); expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull(); - await user.click(screen.getByRole('button', { name: '排行' })); + await user.click(screen.getByRole('tab', { name: '排行' })); expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull(); - await user.click(screen.getByRole('button', { name: '寓教于乐' })); + await user.click(screen.getByRole('tab', { name: '寓教于乐' })); expect( within(discoverPanel).getByRole('button', { name: /儿童动作热身 Demo/u, @@ -3816,7 +3829,7 @@ test('desktop discover shows child motion demo in edutainment channel', async () onOpenChildMotionDemo, }); await user.click(screen.getByRole('button', { name: '发现' })); - await user.click(screen.getByRole('button', { name: '寓教于乐' })); + await user.click(screen.getByRole('tab', { name: '寓教于乐' })); const warmupButton = screen.getByRole('button', { name: /热身关卡/u }); expect(warmupButton).toBeTruthy(); @@ -3889,7 +3902,7 @@ test('mobile discover keeps baby object match works in edutainment channel only' expect(within(discoverPanel).getByText('普通拼图作品')).toBeTruthy(); expect(within(discoverPanel).queryByText('宝贝识物水果篮')).toBeNull(); - await user.click(screen.getByRole('button', { name: '寓教于乐' })); + await user.click(screen.getByRole('tab', { name: '寓教于乐' })); const babyObjectMatchButton = within(discoverPanel).getByRole('button', { name: /宝贝识物水果篮/u, }); @@ -5190,7 +5203,7 @@ test('mobile today channel only shows newly published works from today', async ( }); await user.click(screen.getByRole('button', { name: '发现' })); - await user.click(screen.getByRole('button', { name: '今日' })); + await user.click(screen.getByRole('tab', { name: '今日' })); const discoverPanel = document.getElementById('platform-tab-panel-category'); if (!discoverPanel) { throw new Error('缺少发现面板'); @@ -5295,7 +5308,7 @@ test('mobile home moves category shelf into game category channel', async () => expect(screen.queryByRole('button', { name: '即点即玩' })).toBeNull(); await user.click(screen.getByRole('button', { name: '发现' })); - await user.click(screen.getByRole('button', { name: '分类' })); + await user.click(screen.getByRole('tab', { name: '分类' })); expect(screen.getAllByText('分类').length).toBeGreaterThan(0); expect(screen.getByRole('button', { name: /筛选/u })).toBeTruthy(); @@ -5317,7 +5330,7 @@ test('mobile game category list orders works by composite public metric', async }); await user.click(screen.getByRole('button', { name: '发现' })); - await user.click(screen.getByRole('button', { name: '分类' })); + await user.click(screen.getByRole('tab', { name: '分类' })); await user.click(screen.getByRole('button', { name: '奇幻' })); const gameItems = Array.from( @@ -5349,7 +5362,7 @@ test('mobile game category filter dialog filters by play type', async () => { }); await user.click(screen.getByRole('button', { name: '发现' })); - await user.click(screen.getByRole('button', { name: '分类' })); + await user.click(screen.getByRole('tab', { name: '分类' })); await user.click(screen.getByRole('button', { name: '奇幻' })); expect(screen.getByRole('button', { name: /奇幻拼图,试玩/u })).toBeTruthy(); @@ -5384,7 +5397,7 @@ test('bottom category tab becomes ranking and switches ranking metrics', async ( }); await user.click(screen.getByRole('button', { name: '发现' })); - await user.click(screen.getByRole('button', { name: '排行' })); + await user.click(screen.getByRole('tab', { name: '排行' })); expect(await screen.findByRole('tab', { name: '热门榜' })).toBeTruthy(); expect(screen.getByRole('tab', { name: '改造榜' })).toBeTruthy(); @@ -5418,7 +5431,7 @@ test('ranking rows limit displayed work name and show two short tags on the thir }); await user.click(screen.getByRole('button', { name: '发现' })); - await user.click(screen.getByRole('button', { name: '排行' })); + await user.click(screen.getByRole('tab', { name: '排行' })); const rankingPanel = document.getElementById('platform-tab-panel-category'); expect(rankingPanel).toBeTruthy(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index ce1817ae..7b6693c4 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -2743,11 +2743,27 @@ export function RpgEntryHomeView({ : DISCOVER_CHANNELS, [edutainmentEntryEnabled], ); + const discoverChannelTabs = useMemo( + () => + visibleDiscoverChannels.map((channel) => ({ + id: channel.id, + label: channel.label, + })), + [visibleDiscoverChannels], + ); const categoryGroups = useMemo( () => buildPublicCategoryGroups(generalFeaturedEntries, generalLatestEntries), [generalFeaturedEntries, generalLatestEntries], ); + const categoryGroupTabs = useMemo( + () => + categoryGroups.map((group) => ({ + id: group.tag, + label: group.tag, + })), + [categoryGroups], + ); const publicEntries = useMemo( () => getPlatformPublicEntries(generalFeaturedEntries, generalLatestEntries), @@ -2821,6 +2837,13 @@ export function RpgEntryHomeView({ const activeCategoryFilterCount = activeCategoryEntries.length; const categoryFilterApplied = categoryKindFilter !== DEFAULT_PLATFORM_CATEGORY_KIND_FILTER; + const handleDiscoverChannelChange = useCallback((channel: DiscoverChannel) => { + setDiscoverChannel(channel); + }, []); + const handleCategoryGroupChange = useCallback((tag: string) => { + hasManualCategoryTagSelectionRef.current = true; + setSelectedCategoryTag(tag); + }, []); const visibleTabs = useMemo( () => isAuthenticated @@ -3684,21 +3707,28 @@ export function RpgEntryHomeView({ /> ) : ( <> -
- {visibleDiscoverChannels.map((channel) => { - const active = discoverChannel === channel.id; - 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(' ') + } + /> {platformError ? ( -
- {categoryGroups.map((group) => { - const active = group.tag === activeCategoryGroup.tag; - - return ( - - ); - })} -
+ + [ + 'platform-category-chip shrink-0 !min-h-[2.35rem] !rounded-none !border-0 !bg-transparent !px-0 !shadow-none hover:!bg-transparent', + active ? 'platform-category-chip--active' : null, + ] + .filter(Boolean) + .join(' ') + } + />
- ); - })} - + + [ + '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 ? ( -
- {categoryGroups.map((group) => { - const active = group.tag === activeCategoryGroup.tag; - - return ( - - ); - })} -
+ + [ + 'platform-category-chip shrink-0 !min-h-[2.35rem] !rounded-none !border-0 !bg-transparent !px-0 !shadow-none hover:!bg-transparent', + active ? 'platform-category-chip--active' : null, + ] + .filter(Boolean) + .join(' ') + } + /> -
- {categoryGroups.map((group) => { - const active = group.tag === activeCategoryGroup.tag; - - return ( - - ); - })} -
+ + [ + 'platform-category-chip shrink-0 !min-h-[2.35rem] !rounded-none !border-0 !bg-transparent !px-0 !shadow-none hover:!bg-transparent', + active ? 'platform-category-chip--active' : null, + ] + .filter(Boolean) + .join(' ') + } + />