From 94122583ac167ebb1e70ee58cba3453bbbbf9540 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 11 Jun 2026 01:06:31 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E5=89=8D=E7=AB=AF=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E7=BB=84=E4=BB=B6=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 PlatformAsyncStatePanel 统一 profile 异步状态骨架 扩展 PlatformSegmentedTabs 支持滚动 tab 并接入创作入口与发现页 统一 PixelCloseButton 复用 PlatformModalCloseButton 像素关闭能力 抽取平台入口泥点前置提示弹层并收紧阻断语义 补充组件收口文档与共享决策记录 --- .hermes/shared-memory/decision-log.md | 9 + ...】PlatformUiKit弹窗组件收口计划-2026-06-08.md | 4 + src/components/CharacterChatModal.test.tsx | 25 ++ src/components/MapModal.test.tsx | 27 ++ src/components/PixelCloseButton.tsx | 33 +-- .../common/PlatformAsyncStatePanel.test.tsx | 61 ++++ .../common/PlatformAsyncStatePanel.tsx | 37 +++ .../common/PlatformModalCloseButton.test.tsx | 33 ++- .../common/PlatformModalCloseButton.tsx | 40 ++- .../common/PlatformSegmentedTabs.test.tsx | 28 ++ .../common/PlatformSegmentedTabs.tsx | 9 +- .../CustomWorldCreationStartCard.tsx | 86 +++--- .../custom-world-home/CustomWorldWorkTabs.tsx | 65 +++-- ...mDraftGenerationPointNoticeDialog.test.tsx | 49 ++++ ...atformDraftGenerationPointNoticeDialog.tsx | 79 +++++ .../PlatformEntryFlowShellImpl.tsx | 42 +-- .../PlatformProfilePlayedWorksModal.tsx | 43 +-- .../PlatformProfileRechargeModal.tsx | 81 +++--- .../PlatformProfileReferralModal.tsx | 273 +++++++++--------- .../PlatformProfileTaskCenterModal.tsx | 74 ++--- .../PlatformProfileWalletLedgerModal.tsx | 74 +++-- src/components/rpg-entry/RpgEntryHomeView.tsx | 170 ++++++----- 22 files changed, 897 insertions(+), 445 deletions(-) create mode 100644 src/components/common/PlatformAsyncStatePanel.test.tsx create mode 100644 src/components/common/PlatformAsyncStatePanel.tsx create mode 100644 src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.test.tsx create mode 100644 src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 7306b657..af072588 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -2065,6 +2065,15 @@ - 验证方式:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml work_author`、`npm run test -- scripts/rebind-orphan-work-owners.test.ts`。 - 关联文档:`server-rs/crates/module-auth/src/domain.rs`、`server-rs/crates/module-auth/src/lib.rs`、`server-rs/crates/api-server/src/work_author.rs`、`scripts/rebind-orphan-work-owners.mjs`。 +## 2026-06-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/PixelCloseButton.tsx` 保持为 RPG 语义薄封装,底层统一复用 `src/components/common/PlatformModalCloseButton.tsx` 的 `variant="pixel"`;共享 close button 现在负责 `absolute / inline` placement、默认 `title=label` 和可选 `stopPropagation` 点击拦截,业务 importer 不再各自维护像素风关闭按钮壳和冒泡控制。 +- 决策:平台入口的创作前置泥点阻断提示只在 `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`。 + ## 2026-05-26 敲木鱼发布后作品架与推荐流刷新口径 - 背景:敲木鱼已具备公开广场投影,但草稿 Tab 的作品架没有当前用户作品列表接口,导致已发布作品在发布后不能立即出现在“已发布”筛选和推荐流里。 diff --git a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md index 9f85f71e..0c9fd810 100644 --- a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md +++ b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md @@ -247,6 +247,10 @@ 19.3.23. 平台危险确认弹窗收口到 `src/components/common/PlatformDangerConfirmDialog.tsx`;组件固定承接“确认 / 取消 + 危险主动作”的标准骨架,并透传忙碌态、遮罩关闭策略、按钮文案和局部面板样式。`PlatformEntryFlowShellImpl.tsx` 的删除作品确认、`RpgCreationResultViewImpl.tsx` 的重新生成确认,以及 `CustomWorldEntityCatalog.tsx` 的删除角色 / 批量删除确认已迁移;业务页继续保留标题、说明文案和确认回调,不再各自拼接 `UnifiedConfirmDialog` 的危险按钮配置。后续删除、覆盖、清空等危险动作优先复用该 Module,再按需要补充更窄的语义 wrapper。 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.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. 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/CharacterChatModal.test.tsx b/src/components/CharacterChatModal.test.tsx index f18225d1..087c7360 100644 --- a/src/components/CharacterChatModal.test.tsx +++ b/src/components/CharacterChatModal.test.tsx @@ -1,6 +1,7 @@ /* @vitest-environment jsdom */ import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { expect, test, vi } from 'vitest'; import type { CharacterChatModalState } from '../hooks/rpg-runtime-story'; @@ -110,3 +111,27 @@ test('角色聊天状态、空态和建议复用暗色 UI Kit chrome', () => { expect(draftTextarea.className).toContain('platform-text-field--editor-dark'); expect(draftTextarea.className).toContain('focus:border-sky-300/35'); }); + +test('角色聊天标题栏内联关闭按钮保持共享关闭行为', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + + render( + , + ); + + const closeButton = screen.getByRole('button', { name: '关闭角色聊天' }); + await user.click(closeButton); + + expect(closeButton.className).toContain('relative'); + expect(closeButton.className).toContain('shrink-0'); + expect(closeButton.getAttribute('title')).toBe('关闭角色聊天'); + expect(onClose).toHaveBeenCalledTimes(1); +}); diff --git a/src/components/MapModal.test.tsx b/src/components/MapModal.test.tsx index c46be1e2..69dfc025 100644 --- a/src/components/MapModal.test.tsx +++ b/src/components/MapModal.test.tsx @@ -105,3 +105,30 @@ test('目标场景确认面板复用暗色琥珀 PlatformSubpanel 和胶囊标 ); expect(confirmButton.className).toContain('bg-amber-500/20'); }); + +test('地图右上关闭按钮复用共享像素关闭按钮能力', () => { + const currentScene = getWorldCampScenePreset(WorldType.WUXIA); + if (!currentScene) { + throw new Error('测试需要武侠营地场景'); + } + + const onClose = vi.fn(); + + render( + , + ); + + const closeButton = screen.getByRole('button', { name: '关闭地图' }); + fireEvent.click(closeButton); + + expect(closeButton.className).toContain('absolute'); + expect(closeButton.className).toContain('right-4'); + expect(closeButton.getAttribute('title')).toBe('关闭地图'); + expect(onClose).toHaveBeenCalledTimes(1); +}); diff --git a/src/components/PixelCloseButton.tsx b/src/components/PixelCloseButton.tsx index f9b6beb7..6f904bcb 100644 --- a/src/components/PixelCloseButton.tsx +++ b/src/components/PixelCloseButton.tsx @@ -1,6 +1,5 @@ -import type { MouseEvent } from 'react'; - import { CHROME_ICONS } from '../uiAssets'; +import { PlatformModalCloseButton } from './common/PlatformModalCloseButton'; import { PixelIcon } from './PixelIcon'; type PixelCloseButtonProps = { @@ -12,7 +11,7 @@ type PixelCloseButtonProps = { /** * RPG 像素风弹窗右上关闭按钮。 - * 统一拦截点击冒泡,避免历史手写 overlay / panel 的点击处理影响关闭行为。 + * 这里只保留 RPG 语义层封装,底层样式与行为统一复用共享 close button。 */ export function PixelCloseButton({ onClick, @@ -20,26 +19,16 @@ export function PixelCloseButton({ placement = 'absolute', className = '', }: PixelCloseButtonProps) { - const handleClick = (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onClick(); - }; - - const placementClassName = - placement === 'absolute' - ? 'absolute right-4 top-3 sm:right-5 sm:top-4' - : 'relative shrink-0'; - return ( - + variant="pixel" + placement={placement} + stopPropagation + onClick={() => onClick()} + className={['z-20', className].filter(Boolean).join(' ')} + icon={} + /> ); } diff --git a/src/components/common/PlatformAsyncStatePanel.test.tsx b/src/components/common/PlatformAsyncStatePanel.test.tsx new file mode 100644 index 00000000..f0c634ea --- /dev/null +++ b/src/components/common/PlatformAsyncStatePanel.test.tsx @@ -0,0 +1,61 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; + +import { PlatformAsyncStatePanel } from './PlatformAsyncStatePanel'; + +describe('PlatformAsyncStatePanel', () => { + test('prefers error state over loading and content', () => { + render( + 出错了} + isLoading + loadingState={
读取中
} + > +
内容
+
, + ); + + expect(screen.getByText('出错了')).toBeTruthy(); + expect(screen.queryByText('读取中')).toBeNull(); + expect(screen.queryByText('内容')).toBeNull(); + }); + + test('renders loading state before empty state', () => { + render( + 读取中} + emptyState={
暂无内容
} + > +
内容
+
, + ); + + expect(screen.getByText('读取中')).toBeTruthy(); + expect(screen.queryByText('暂无内容')).toBeNull(); + }); + + test('renders empty state when requested', () => { + render( + 暂无内容}> +
内容
+
, + ); + + expect(screen.getByText('暂无内容')).toBeTruthy(); + expect(screen.queryByText('内容')).toBeNull(); + }); + + test('falls back to content when no async state is active', () => { + render( + +
内容
+
, + ); + + expect(screen.getByText('内容')).toBeTruthy(); + }); +}); diff --git a/src/components/common/PlatformAsyncStatePanel.tsx b/src/components/common/PlatformAsyncStatePanel.tsx new file mode 100644 index 00000000..8236af4a --- /dev/null +++ b/src/components/common/PlatformAsyncStatePanel.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from 'react'; + +type PlatformAsyncStatePanelProps = { + errorState?: ReactNode; + isLoading?: boolean; + loadingState?: ReactNode; + isEmpty?: boolean; + emptyState?: ReactNode; + children: ReactNode; +}; + +/** + * 平台异步状态面板骨架。 + * 只负责在错误、读取、空态和内容之间切换,具体文案与外观继续交给调用方传入 slot。 + */ +export function PlatformAsyncStatePanel({ + errorState, + isLoading = false, + loadingState = null, + isEmpty = false, + emptyState = null, + children, +}: PlatformAsyncStatePanelProps) { + if (errorState !== undefined && errorState !== null) { + return <>{errorState}; + } + + if (isLoading) { + return <>{loadingState}; + } + + if (isEmpty) { + return <>{emptyState}; + } + + return <>{children}; +} diff --git a/src/components/common/PlatformModalCloseButton.test.tsx b/src/components/common/PlatformModalCloseButton.test.tsx index ffa1118d..3a4a318b 100644 --- a/src/components/common/PlatformModalCloseButton.test.tsx +++ b/src/components/common/PlatformModalCloseButton.test.tsx @@ -1,7 +1,8 @@ /* @vitest-environment jsdom */ import { render, screen } from '@testing-library/react'; -import { expect, test } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { expect, test, vi } from 'vitest'; import { PlatformModalCloseButton } from './PlatformModalCloseButton'; @@ -80,9 +81,37 @@ test('supports pixel close button', () => { const button = screen.getByRole('button', { name: '关闭像素弹窗' }); - expect(button.className).toContain('bg-black/20'); + expect(button.className).toContain('bg-black/30'); expect(button.className).toContain('text-zinc-400'); + expect(button.className).toContain('absolute'); expect(button.className).toContain('disabled:opacity-45'); + expect(button.getAttribute('title')).toBe('关闭像素弹窗'); +}); + +test('supports inline pixel placement and intercepted click behavior', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + const onHeaderClick = vi.fn(); + + render( +
+ +
, + ); + + const button = screen.getByRole('button', { name: '关闭像素标题栏' }); + await user.click(button); + + expect(button.className).toContain('relative'); + expect(button.className).toContain('shrink-0'); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onHeaderClick).not.toHaveBeenCalled(); }); test('supports editor dark close button', () => { diff --git a/src/components/common/PlatformModalCloseButton.tsx b/src/components/common/PlatformModalCloseButton.tsx index d1e90f74..b73bb515 100644 --- a/src/components/common/PlatformModalCloseButton.tsx +++ b/src/components/common/PlatformModalCloseButton.tsx @@ -1,5 +1,9 @@ import { X } from 'lucide-react'; -import type { ButtonHTMLAttributes, ReactNode } from 'react'; +import type { + ButtonHTMLAttributes, + MouseEvent as ReactMouseEvent, + ReactNode, +} from 'react'; type PlatformModalCloseButtonVariant = | 'profile' @@ -10,6 +14,8 @@ type PlatformModalCloseButtonVariant = | 'pixel' | 'editorDark'; +type PlatformModalCloseButtonPlacement = 'absolute' | 'inline'; + type PlatformModalCloseButtonProps = Omit< ButtonHTMLAttributes, 'children' @@ -17,6 +23,8 @@ type PlatformModalCloseButtonProps = Omit< label: string; variant?: PlatformModalCloseButtonVariant; icon?: ReactNode; + placement?: PlatformModalCloseButtonPlacement; + stopPropagation?: boolean; }; const PLATFORM_MODAL_CLOSE_BUTTON_CLASS_BY_VARIANT: Record< @@ -33,11 +41,19 @@ const PLATFORM_MODAL_CLOSE_BUTTON_CLASS_BY_VARIANT: Record< 'absolute right-3 top-2 z-10 flex h-8 w-8 items-center justify-center rounded-full text-[#ff4056]', platformIcon: 'platform-icon-button disabled:cursor-not-allowed disabled:opacity-45', pixel: - 'rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-45', + 'flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/30 p-0 text-zinc-400 shadow-[0_8px_18px_rgba(0,0,0,0.28)] transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200/70 disabled:cursor-not-allowed disabled:opacity-45', editorDark: 'platform-modal-close-button--editor-dark rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white', }; +const PLATFORM_MODAL_CLOSE_BUTTON_PIXEL_PLACEMENT_CLASS_BY_PLACEMENT: Record< + PlatformModalCloseButtonPlacement, + string +> = { + absolute: 'absolute right-4 top-3 sm:right-5 sm:top-4', + inline: 'relative shrink-0', +}; + /** * 平台弹窗关闭按钮。 * 收口个人中心和平台浮层里重复的关闭 aria、尺寸和视觉样式。 @@ -48,15 +64,35 @@ export function PlatformModalCloseButton({ icon = , className, type = 'button', + placement = 'absolute', + stopPropagation = false, + onClick, + title, ...buttonProps }: PlatformModalCloseButtonProps) { + const handleClick = (event: ReactMouseEvent) => { + if (stopPropagation) { + event.preventDefault(); + event.stopPropagation(); + } + + onClick?.(event); + }; + return ( - ) : null} - {creationTypeGroups.map((group) => { - const selected = group.id === activeGroup?.id; - 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(' ') + } + /> {isRecentTabActive ? (
diff --git a/src/components/custom-world-home/CustomWorldWorkTabs.tsx b/src/components/custom-world-home/CustomWorldWorkTabs.tsx index 1eb6cc92..9b50c18d 100644 --- a/src/components/custom-world-home/CustomWorldWorkTabs.tsx +++ b/src/components/custom-world-home/CustomWorldWorkTabs.tsx @@ -1,3 +1,5 @@ +import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs'; + export type CustomWorldWorkFilter = 'all' | 'draft' | 'published'; const FILTER_OPTIONS: Array<{ @@ -22,33 +24,42 @@ export function CustomWorldWorkTabs({ publishedCount, onChange, }: CustomWorldWorkTabsProps) { - return ( -
- {FILTER_OPTIONS.map((option) => { - const count = - option.id === 'draft' - ? draftCount - : option.id === 'published' - ? publishedCount - : draftCount + publishedCount; + const filterTabs = FILTER_OPTIONS.map((option) => { + const count = + option.id === 'draft' + ? draftCount + : option.id === 'published' + ? publishedCount + : draftCount + publishedCount; - return ( - - ); - })} -
+ return { + id: option.id, + label: `${option.label} ${count}`, + }; + }); + + 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/platform-entry/PlatformDraftGenerationPointNoticeDialog.test.tsx b/src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.test.tsx new file mode 100644 index 00000000..789ac7ee --- /dev/null +++ b/src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.test.tsx @@ -0,0 +1,49 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { expect, test, vi } from 'vitest'; + +import { + PlatformDraftGenerationPointNoticeDialog, +} from './PlatformDraftGenerationPointNoticeDialog'; + +test('renders the insufficient-points notice with the shared blocking copy', () => { + const onClose = vi.fn(); + + render( + , + ); + + expect(screen.getByRole('dialog', { name: '泥点不足' })).toBeTruthy(); + expect(screen.getByText('本次需要 30 泥点,当前 12 泥点。')).toBeTruthy(); + expect( + screen.getByText('当前表单不会丢失,关闭后可继续编辑或补足泥点再继续。'), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '知道了' })); + expect(onClose).toHaveBeenCalledTimes(1); +}); + +test('renders the balance-load-failed notice without the amber icon override', () => { + render( + {}} + />, + ); + + const dialog = screen.getByRole('dialog', { name: '读取泥点余额失败' }); + + expect(screen.getByText('请稍后重试。')).toBeTruthy(); + expect( + screen.getByText('当前表单不会丢失,关闭后可继续编辑,稍后再试。'), + ).toBeTruthy(); + expect(dialog.innerHTML).not.toContain('bg-amber-100/80'); +}); diff --git a/src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx b/src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx new file mode 100644 index 00000000..97393a48 --- /dev/null +++ b/src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx @@ -0,0 +1,79 @@ +import { PlatformAcknowledgeStatusDialog } from '../common/PlatformAcknowledgeStatusDialog'; + +export type DraftGenerationPointNotice = + | { + kind: 'insufficient-points'; + requiredPoints: number; + currentPoints: number; + } + | { + kind: 'balance-load-failed'; + }; + +type PlatformDraftGenerationPointNoticeDialogProps = { + notice: DraftGenerationPointNotice | null; + onClose: () => void; + overlayClassName?: string; + panelClassName?: string; + zIndexClassName?: string; +}; + +function resolveDraftGenerationPointNoticeTitle( + notice: DraftGenerationPointNotice, +) { + return notice.kind === 'balance-load-failed' ? '读取泥点余额失败' : '泥点不足'; +} + +function resolveDraftGenerationPointNoticeDescription( + notice: DraftGenerationPointNotice, +) { + return notice.kind === 'balance-load-failed' + ? '当前表单不会丢失,关闭后可继续编辑,稍后再试。' + : '当前表单不会丢失,关闭后可继续编辑或补足泥点再继续。'; +} + +function resolveDraftGenerationPointNoticeMessage( + notice: DraftGenerationPointNotice, +) { + return notice.kind === 'balance-load-failed' + ? '请稍后重试。' + : `本次需要 ${notice.requiredPoints} 泥点,当前 ${notice.currentPoints} 泥点。`; +} + +/** + * 创作前置泥点提示弹层。 + * 只承接平台入口里“泥点不足 / 读取余额失败”这类阻断提示,避免 FlowShell 直接拼底层状态弹窗。 + */ +export function PlatformDraftGenerationPointNoticeDialog({ + notice, + onClose, + overlayClassName, + panelClassName, + zIndexClassName, +}: PlatformDraftGenerationPointNoticeDialogProps) { + if (!notice) { + return null; + } + + return ( + + {resolveDraftGenerationPointNoticeMessage(notice)} + + ); +} diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index e09c25f6..ccda5196 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -498,6 +498,10 @@ import { EDUTAINMENT_HIDDEN_MESSAGE, filterGeneralPublicWorks, } from './platformEdutainmentVisibility'; +import { + PlatformDraftGenerationPointNoticeDialog, + type DraftGenerationPointNotice, +} from './PlatformDraftGenerationPointNoticeDialog'; import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal'; import type { PlatformCreationTypeId } from './platformEntryCreationTypes'; import { @@ -1419,10 +1423,8 @@ export function PlatformEntryFlowShellImpl({ : 'platform-theme--light'; const isDesktopLayout = usePlatformDesktopLayout(); const [showCreationTypeModal, setShowCreationTypeModal] = useState(false); - const [draftGenerationPointNotice, setDraftGenerationPointNotice] = useState<{ - title: string; - message: string; - } | null>(null); + const [draftGenerationPointNotice, setDraftGenerationPointNotice] = + useState(null); const [selectedDetailEntry, setSelectedDetailEntry] = useState | null>(null); const [selectedPublicWorkDetail, setSelectedPublicWorkDetail] = @@ -2246,14 +2248,14 @@ export function PlatformEntryFlowShellImpl({ } setDraftGenerationPointNotice({ - title: '泥点不足', - message: `本次需要 ${pointsCost} 泥点,当前 ${walletBalance} 泥点。`, + kind: 'insufficient-points', + requiredPoints: pointsCost, + currentPoints: walletBalance, }); return false; } catch { setDraftGenerationPointNotice({ - title: '读取泥点余额失败', - message: '请稍后重试。', + kind: 'balance-load-failed', }); return false; } @@ -4364,11 +4366,6 @@ export function PlatformEntryFlowShellImpl({ barkBattleDraftGenerationPointCost, ensureEnoughDraftGenerationPointsFromServer, ]); - const draftGenerationPointNoticeDescription = draftGenerationPointNotice - ? draftGenerationPointNotice.title === '读取泥点余额失败' - ? '当前表单不会丢失,关闭后可继续编辑,稍后再试。' - : '当前表单不会丢失,关闭后可继续编辑或补足泥点再继续。' - : undefined; const recoverCompletedPuzzleDraftGeneration = useCallback( async ({ sessionId, @@ -16997,25 +16994,12 @@ export function PlatformEntryFlowShellImpl({ }} /> ) : null} - setDraftGenerationPointNotice(null)} - showHeader - showCloseButton - closeOnBackdrop overlayClassName={`platform-theme ${platformThemeClass} !items-center`} panelClassName="platform-remap-surface rounded-[1.75rem]" - iconClassName={ - draftGenerationPointNotice?.title === '读取泥点余额失败' - ? undefined - : 'bg-amber-100/80 text-amber-600' - } - > - {draftGenerationPointNotice?.message} - + /> ) : null} - {isLoading ? ( -
- {Array.from({ length: 4 }).map((_, index) => ( -
- ))} -
- ) : hasArchiveEntries || hasPlayedWorks ? ( + + {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+ } + isEmpty={!hasArchiveEntries && !hasPlayedWorks} + emptyState={ + + 暂无玩过 + + } + >
{hasArchiveEntries ? (
@@ -245,16 +263,7 @@ export function PlatformProfilePlayedWorksModal({
) : null}
- ) : ( - - 暂无玩过 - - )} +
); } diff --git a/src/components/platform-entry/PlatformProfileRechargeModal.tsx b/src/components/platform-entry/PlatformProfileRechargeModal.tsx index d0e1a0ed..bbedb10a 100644 --- a/src/components/platform-entry/PlatformProfileRechargeModal.tsx +++ b/src/components/platform-entry/PlatformProfileRechargeModal.tsx @@ -6,6 +6,7 @@ import type { ProfileRechargeProduct, } from '../../../packages/shared/src/contracts/runtime'; import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; @@ -183,35 +184,49 @@ export function PlatformProfileRechargeModal({
- {error ? ( - -
{error}
- +
{error}
+ + 重新加载 + +
+ ) : null + } + isLoading={isLoading} + loadingState={ +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+ } + isEmpty={products.length === 0} + emptyState={ + - 重新加载 - - - ) : null} - - {isLoading ? ( -
- {Array.from({ length: 4 }).map((_, index) => ( -
- ))} -
- ) : products.length > 0 ? ( + 暂无可购买套餐 + + } + >
{products.map((product) => ( ))}
- ) : ( - - 暂无可购买套餐 - - )} + {nativePayment ? ( ); } else if (panel === 'redeem') { - content = isLoading ? ( -
-
-
-
- ) : center?.hasRedeemedCode ? ( - - 已填写邀请码 - - ) : ( -
{ - event.preventDefault(); - onSubmitRedeemCode(); - }} - > - onRedeemCodeChange(event.target.value)} - size="lg" - density="roomy" - tone="rose" - className="rounded-xl text-center font-black uppercase tracking-[0.16em]" - placeholder="邀请码" - aria-label="邀请码" - autoComplete="off" - autoFocus - /> - - {isSubmittingRedeem ? '提交中' : '提交'} - - - ); - } else if (isLoading) { content = ( -
-
-
-
+ +
+
+
+ } + isEmpty={Boolean(center?.hasRedeemedCode)} + emptyState={ + + 已填写邀请码 + + } + > +
{ + event.preventDefault(); + onSubmitRedeemCode(); + }} + > + onRedeemCodeChange(event.target.value)} + size="lg" + density="roomy" + tone="rose" + className="rounded-xl text-center font-black uppercase tracking-[0.16em]" + placeholder="邀请码" + aria-label="邀请码" + autoComplete="off" + autoFocus + /> + + {isSubmittingRedeem ? '提交中' : '提交'} + + + ); } else { content = ( -
- - - 邀请码 - -
- {center?.inviteCode ?? '--------'} + +
+
- - -
- {`邀请一个用户注册,双方都可以获得${center?.rewardPoints ?? 30}泥点。`} -
-
每日最多获得十次邀请奖励。
-
- } - actionSurface="profile" - actionSize="md" - actionFullWidth - className="gap-2 rounded-xl" - /> - - +
+ - 成功邀请 - - {center?.invitedUsers?.length ? ( -
- {center.invitedUsers.map((user) => ( - - -
-
- {user.displayName || '玩家'} -
-
-
- ))} -
- ) : ( - - 暂无成功邀请 - - )} -
-
+ 邀请码 +
+
+ {center?.inviteCode ?? '--------'} +
+
+ +
+ {`邀请一个用户注册,双方都可以获得${center?.rewardPoints ?? 30}泥点。`} +
+
每日最多获得十次邀请奖励。
+
+ } + actionSurface="profile" + actionSize="md" + actionFullWidth + className="gap-2 rounded-xl" + /> + + + 成功邀请 + + {center?.invitedUsers?.length ? ( +
+ {center.invitedUsers.map((user) => ( + + +
+
+ {user.displayName || '玩家'} +
+
+
+ ))} +
+ ) : ( + + 暂无成功邀请 + + )} +
+
+
); } diff --git a/src/components/platform-entry/PlatformProfileTaskCenterModal.tsx b/src/components/platform-entry/PlatformProfileTaskCenterModal.tsx index e3879031..c147f9bf 100644 --- a/src/components/platform-entry/PlatformProfileTaskCenterModal.tsx +++ b/src/components/platform-entry/PlatformProfileTaskCenterModal.tsx @@ -1,5 +1,6 @@ import type { ProfileTaskCenterResponse } from '../../../packages/shared/src/contracts/runtime'; import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformSubpanel } from '../common/PlatformSubpanel'; @@ -50,24 +51,6 @@ export function PlatformProfileTaskCenterModal({ panelClassName="platform-recharge-modal !max-w-md rounded-[1.4rem]" bodyClassName="space-y-3 px-5 py-5" > - {error ? ( - -
{error}
- - 重新加载 - -
- ) : null} {success ? ( ) : null} - {isLoading ? ( -
- {Array.from({ length: 2 }).map((_, index) => ( -
- ))} -
- ) : tasks.length === 0 ? ( - - 暂无任务 - - ) : ( + +
{error}
+ + 重新加载 + + + ) : null + } + isLoading={isLoading} + loadingState={ +
+ {Array.from({ length: 2 }).map((_, index) => ( +
+ ))} +
+ } + isEmpty={tasks.length === 0} + emptyState={ + + 暂无任务 + + } + >
{tasks.map((task) => { const isClaimable = task.status === 'claimable'; @@ -137,7 +145,7 @@ export function PlatformProfileTaskCenterModal({ ); })}
- )} + ); } diff --git a/src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx b/src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx index fb72af98..bc4e445e 100644 --- a/src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx +++ b/src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx @@ -2,6 +2,7 @@ import { Coins } from 'lucide-react'; import type { ProfileWalletLedgerResponse } from '../../../packages/shared/src/contracts/runtime'; import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; @@ -60,37 +61,48 @@ export function PlatformProfileWalletLedgerModal({
- {error ? ( - -
{error}
- +
{error}
+ + 重新加载 + +
+ ) : null + } + isLoading={isLoading} + loadingState={ +
+ {Array.from({ length: 5 }).map((_, index) => ( +
+ ))} +
+ } + isEmpty={entries.length === 0} + emptyState={ + - 重新加载 - - - ) : isLoading ? ( -
- {Array.from({ length: 5 }).map((_, index) => ( -
- ))} -
- ) : entries.length === 0 ? ( - - 暂无账单记录 - - ) : ( + 暂无账单记录 + + } + >
{entries.map((entry) => ( ))}
- )} + ); } diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index d73e97ea..ce1817ae 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -87,6 +87,7 @@ import { PlatformIconBadge } from '../common/PlatformIconBadge'; import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; +import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs'; import { PlatformStatusDialog } from '../common/PlatformStatusDialog'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformSubpanel } from '../common/PlatformSubpanel'; @@ -1000,14 +1001,16 @@ function PublicCodeSearchBar({ placeholder="搜索作品号、名称、作者、描述" className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]" /> - +
); } @@ -2127,6 +2130,23 @@ function PlatformCategoryFilterDialog({ onReset: () => void; onClose: () => void; }) { + const kindFilterTabs = useMemo( + () => + PLATFORM_CATEGORY_KIND_FILTERS.map((option) => ({ + id: option.id, + label: option.label, + })), + [], + ); + const sortModeTabs = useMemo( + () => + PLATFORM_CATEGORY_SORT_OPTIONS.map((option) => ({ + id: option.id, + label: option.label, + })), + [], + ); + return ( 玩法
-
- {PLATFORM_CATEGORY_KIND_FILTERS.map((option) => { - const active = option.id === kindFilter; - - return ( - - ); - })} -
+ + [ + '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(' ') + } + />
排序
-
- {PLATFORM_CATEGORY_SORT_OPTIONS.map((option) => { - const active = option.id === sortMode; - - return ( - - ); - })} -
+ + [ + '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(' ') + } + />
- - +
); @@ -3455,28 +3490,31 @@ export function RpgEntryHomeView({
-
- {PLATFORM_RANKING_TABS.map((tab) => { - const active = tab.id === activeRankingTab; - - return ( - - ); - })} -
+ ({ + 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(' ') + } + /> {isLoadingPlatform ? ( 正在读取公开作品...