diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 649d08de..48d345bc 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -61,6 +61,8 @@ - 2026-06-11 追加:`PlatformAsyncStatePanel` 继续补齐 RPG 首页分类分支;移动端“发现 -> 分类”、桌面发现页“分类”和桌面首页“作品分类”模块现在都统一委托共享状态壳切换外层 `loading / empty / content`,分类控制条与排序按钮继续留在内容 slot 中。筛选后无结果的“当前筛选下没有作品。”也统一改成内层 `PlatformAsyncStatePanel` 切换,不再在三处 JSX 中各自维护嵌套 ternary。 - 2026-06-11 追加:`PlatformDarkModalFooter` 不只收动作按钮区,也继续覆盖纯内容 footer;`CompanionCampModal.tsx` 底部“营地气氛”区域已改成 `layout="content"` + `padding="roomy"` 的共享 footer frame,保留原有文案和卡片布局,不再单独手写 `border-t border-white/10 px-5 py-4`。 - 2026-06-11 追加:`PlatformDarkModalFooter` 继续从标准双按钮 footer 扩到 detail / confirm 收尾;`NpcModals.tsx` 的交易详情 footer 和 `MapModal.tsx` 的场景切换确认 footer 已改成复用同一个 dark footer frame,即使只有单个“关闭”按钮也不再手写 `flex justify-end`。这条抽象继续只覆盖 dark / pixel modal 里的底部分隔线与常规动作区排布,不向白底 profile 弹窗 footer、sticky 工作台 footer 或运行态 HUD 工具条扩张。 +- 2026-06-11 追加:`PlatformFilterToolbar.tsx` 作为薄结构组件收口 RPG 首页分类工具条;组件只承接“筛选按钮 + tabs + 排序按钮”的排布与 `mobile / desktop` 两种布局差异,不持有筛选状态、空态或排序逻辑。后续只有在同构壳层真的复现时才继续往 `common` 扩覆盖面;如果只是单页内局部重复、接口会越抽越胖,就优先退回文件内 helper。 +- 2026-06-11 追加:`SquareImageCropModal.tsx` 的白底弹窗壳层改为复用 `UnifiedModal.tsx`,同时给 `UnifiedModal` 薄补 `titleId` 与 `closeIcon` 透传,让裁剪弹窗继续保留自定义 close icon、无 backdrop / Escape 关闭和两列 footer,而不把 `PlatformProfileModalShell` 这类带页面语义的壳层倒灌回 `common/`。这条规则适用于 `common` 级工具弹窗:先看 `UnifiedModal` 能不能承接,再决定是否需要新的薄壳。 - 2026-06-11 追加:`PlatformProfileModalShell` 继续补齐标准 footer 插槽,直接透传 `UnifiedModal.footer` 与 `footerClassName`;`RpgEntryHomeView.tsx` 的昵称修改弹窗已改成标准 profile footer,不再把双按钮动作区手写在 body 末尾。后续个人中心里同类“表单内容 + 底部双按钮”弹窗优先走壳层 footer 接法。 - 2026-06-11 追加:`PlatformProfileModalShell` 的标准 footer 接法继续扩展到单 CTA 表单收尾;`PlatformProfileRewardCodeRedeemModal.tsx` 的兑换按钮已迁到壳层 footer,body 只保留输入和反馈消息。`PlatformAsyncStatePanel` 同日继续扩展到 `PlatformAssetPickerGrid`、`VisualNovelSavePanel.tsx` 与 `AccountModal.tsx` 的账号安全三个子区块;其中公共素材网格继续把 `error` banner 放在状态壳外层,保持错误提示可与加载态或内容并存的原语义。 - 2026-06-11 追加:按钮层继续补齐轻量漏网项。`PlatformTagEditor.tsx` 的标签 chip 删除入口已改成紧凑 `PlatformIconButton`,保留透明背景和原 chip 高度;`RpgEntryCharacterSelectView.tsx` 的两处“返回”按钮统一沉到局部 `CharacterSelectBackButton`,底层委托 `PlatformActionButton surface="editorDark"`。同日 `GenerationProgressHero.tsx` 新增 `GenerationHeaderBackButton`,`CustomWorldGenerationView.tsx` 与 `BarkBattleGeneratingView.tsx` 已开始复用这套暖色生成页返回入口骨架;后续同类轻量返回按钮与 chip 删除按钮优先继续沿共享按钮 + 薄包装的方向推进。 diff --git a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md index 38828248..103bf35d 100644 --- a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md +++ b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md @@ -271,6 +271,8 @@ 19.3.45. 白底 / 浅色结果页与工作台顶部的轻量返回入口继续收口到 `src/components/common/PlatformBackActionButton.tsx`;该 Module 固定承接 `PlatformActionButton tone="ghost" size="xs"` 上的 `ArrowLeft + 返回文案` 骨架,并只暴露 `compact / regular` 两档尺寸,分别覆盖紧凑结果页 header 与标准白底结果页顶栏。当前 `PuzzleResultView.tsx`、`SquareHoleResultView.tsx`、`Match3DResultView.tsx`、`VisualNovelResultView.tsx` 四个结果页已接入 `variant="compact"`,`PuzzleClearResultView.tsx`、`JumpHopResultView.tsx`、`WoodenFishResultView.tsx` 三个结果页已接入 `variant="regular"`,`BabyObjectMatchResultView.tsx` 继续使用紧凑款并保留 `className="px-3"` 贴合原横向留白。暖色生成页顶部返回入口继续走 `GenerationHeaderBackButton`,`BigFishResultView.tsx` 这类 dark hero / 强品牌 special case 继续保留 `PlatformIconButton variant="darkMini"` 路线,不强行并入同一个白底返回按钮基元。后续白底结果页、浅色工作台或普通 platform 顶栏里若只是“左箭头 + 返回”轻量返回入口,优先直接复用 `PlatformBackActionButton`,只在局部补尺寸和少量外边距,不再各页重复手写 ghost button class。 19.3.46. `PlatformNavigableListItem` 继续从桌面 flat row 扩展到首页公开列表里的排行行与分类行;`RpgEntryHomeView.tsx` 的 `PlatformRankingItem` 和 `PlatformCategoryGameItem` 现在都统一委托共享 `button + leading + body + trailing` 骨架,同时保留原有 `platform-ranking-item__*`、`platform-category-game-item__*` 局部 class 承接封面尺寸、标题摘要、公开 badge、metric 和右侧 `试玩 / 进入` affordance。后续首页、发现页或 profile 侧同类“封面 + 文本主体 + 右侧动作提示”的浅色导航行,优先先尝试复用 `PlatformNavigableListItem` 并把局部视觉挂在业务 class 上,不要为了这类 row 再回退成原生 ` + {isMobileLayout ? ( + + ) : null} + buildToolbarTabItemClassName(active)} + /> + {!isMobileLayout ? ( + + ) : null} + + {isMobileLayout ? ( + + ) : null} + + ); +} diff --git a/src/components/common/SquareImageCropModal.test.tsx b/src/components/common/SquareImageCropModal.test.tsx new file mode 100644 index 00000000..3a386ca2 --- /dev/null +++ b/src/components/common/SquareImageCropModal.test.tsx @@ -0,0 +1,122 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { expect, test, vi } from 'vitest'; + +import { SquareImageCropModal } from './SquareImageCropModal'; + +function renderSquareImageCropModal( + overrideProps: Partial[0]> = {}, +) { + const onCropRectChange = vi.fn(); + const onClose = vi.fn(); + const onSubmit = vi.fn(); + + render( + , + ); + + return { onCropRectChange, onClose, onSubmit }; +} + +test('reuses unified modal shell while keeping crop close semantics disabled on backdrop and escape', () => { + const { onClose } = renderSquareImageCropModal(); + + const dialog = screen.getByRole('dialog', { name: '裁剪拼图图片' }); + + fireEvent.click(dialog.parentElement as HTMLElement); + fireEvent.keyDown(window, { key: 'Escape' }); + + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: '关闭拼图图片裁剪' }).className).toContain( + 'platform-profile-icon-button', + ); + expect( + screen.getByRole('button', { name: '关闭拼图图片裁剪' }).textContent, + ).toContain('×'); +}); + +test('keeps shared footer actions and saving disabled state', () => { + const { onClose, onSubmit } = renderSquareImageCropModal({ isSaving: true }); + + const cancelButton = screen.getByRole('button', { name: '取消' }); + const submitButton = screen.getByRole('button', { name: '裁剪中' }); + + expect(cancelButton.className).toContain('platform-button--secondary'); + expect(submitButton.hasAttribute('disabled')).toBe(true); + + fireEvent.click(cancelButton); + fireEvent.click(submitButton); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); +}); + +test('keeps pointer drag flow wired through the crop surface', () => { + const { onCropRectChange } = renderSquareImageCropModal(); + const preview = screen.getByLabelText('拼图图片裁剪操作区'); + const moveHandle = preview.querySelector('[class*="cursor-grab"]'); + + expect(moveHandle).toBeTruthy(); + + Object.defineProperty(preview, 'getBoundingClientRect', { + value: () => ({ + width: 400, + height: 300, + top: 0, + left: 0, + right: 400, + bottom: 300, + x: 0, + y: 0, + toJSON: () => ({}), + }), + }); + + const setPointerCapture = vi.fn(); + const releasePointerCapture = vi.fn(); + Object.defineProperty(moveHandle as HTMLDivElement, 'setPointerCapture', { + value: setPointerCapture, + }); + Object.defineProperty(moveHandle as HTMLDivElement, 'releasePointerCapture', { + value: releasePointerCapture, + }); + + fireEvent.pointerDown(moveHandle as Element, { + pointerId: 1, + clientX: 100, + clientY: 80, + }); + fireEvent.pointerMove(moveHandle as Element, { + pointerId: 1, + clientX: 140, + clientY: 120, + }); + fireEvent.pointerUp(moveHandle as Element, { + pointerId: 1, + }); + + expect(setPointerCapture).toHaveBeenCalledTimes(1); + expect(releasePointerCapture).toHaveBeenCalledTimes(1); + expect(onCropRectChange).toHaveBeenCalledTimes(1); + expect(onCropRectChange.mock.calls[0]?.[0]).toMatchObject({ + size: 600, + }); +}); diff --git a/src/components/common/SquareImageCropModal.tsx b/src/components/common/SquareImageCropModal.tsx index aebc7c41..1b7cc1d7 100644 --- a/src/components/common/SquareImageCropModal.tsx +++ b/src/components/common/SquareImageCropModal.tsx @@ -7,8 +7,8 @@ import { } from 'react'; import { PlatformActionButton } from './PlatformActionButton'; -import { PlatformModalCloseButton } from './PlatformModalCloseButton'; import { PlatformStatusMessage } from './PlatformStatusMessage'; +import { UnifiedModal } from './UnifiedModal'; import { clampNumber, clampSquareImageCropRect, @@ -309,25 +309,35 @@ export function SquareImageCropModal({ }; return ( -
-
-
-
- {labels.title} -
- + + + {labels.cancel} + + + {isSaving ? labels.saving : labels.submit} +
-
+ } + > +
) : null} -
- - {labels.cancel} - - - {isSaving ? labels.saving : labels.submit} - -
-
-
+ ); } diff --git a/src/components/common/UnifiedModal.test.tsx b/src/components/common/UnifiedModal.test.tsx index f801a1d1..66dad8a6 100644 --- a/src/components/common/UnifiedModal.test.tsx +++ b/src/components/common/UnifiedModal.test.tsx @@ -124,6 +124,6 @@ test('uses shared pixel close button chrome', () => { expect(screen.getByRole('dialog', { name: '像素弹窗' }).className).toContain( 'pixel-modal-shell', ); - expect(closeButton.className).toContain('bg-black/20'); + expect(closeButton.className).toContain('bg-black/30'); expect(closeButton.className).toContain('text-zinc-400'); }); diff --git a/src/components/common/UnifiedModal.tsx b/src/components/common/UnifiedModal.tsx index 83d2f025..572f3cb0 100644 --- a/src/components/common/UnifiedModal.tsx +++ b/src/components/common/UnifiedModal.tsx @@ -15,10 +15,14 @@ type UnifiedModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen'; type UnifiedModalCloseVariant = NonNullable< ComponentProps['variant'] >; +type UnifiedModalCloseIcon = ComponentProps< + typeof PlatformModalCloseButton +>['icon']; type UnifiedModalProps = { open: boolean; title: string; + titleId?: string; description?: ReactNode; children: ReactNode; footer?: ReactNode; @@ -32,6 +36,7 @@ type UnifiedModalProps = { showCloseButton?: boolean; closeLabel?: string; closeVariant?: UnifiedModalCloseVariant; + closeIcon?: UnifiedModalCloseIcon; portal?: boolean; zIndexClassName?: string; overlayClassName?: string; @@ -81,6 +86,7 @@ function getPanelStyle( function UnifiedModalContent({ open, title, + titleId: titleIdProp, description, children, footer, @@ -94,6 +100,7 @@ function UnifiedModalContent({ showCloseButton = true, closeLabel = '关闭', closeVariant, + closeIcon, zIndexClassName = 'z-[90]', overlayClassName, panelClassName, @@ -104,8 +111,9 @@ function UnifiedModalContent({ footerClassName, panelStyle, }: Omit) { - const titleId = useId(); + const generatedTitleId = useId(); const descriptionId = useId(); + const titleId = titleIdProp ?? generatedTitleId; useEffect(() => { if (!open || closeDisabled || !closeOnEscape) { @@ -209,6 +217,7 @@ function UnifiedModalContent({ onClick={onClose} disabled={closeDisabled} variant={closeVariant ?? (isPixel ? 'pixel' : 'platformIcon')} + icon={closeIcon} /> ) : null}
diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 1983eebe..54ea247e 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -19,7 +19,6 @@ import { Search, Settings, Share2, - SlidersHorizontal, Sparkles, Star, ThumbsUp, @@ -84,6 +83,7 @@ import { import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformFieldLabel } from '../common/PlatformFieldLabel'; +import { PlatformFilterToolbar } from '../common/PlatformFilterToolbar'; import { PlatformIconBadge } from '../common/PlatformIconBadge'; import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton'; @@ -3248,50 +3248,17 @@ export function RpgEntryHomeView({ const desktopCategoryGrid = activeCategoryEntries.slice(0, 6); const mobileCategoryPanelContent = activeCategoryGroup ? ( <> -
- - - - [ - '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(' ') - } - /> -
- - + setIsCategoryFilterPanelOpen(true)} + onTabChange={handleCategoryGroupChange} + onToggleSort={cycleCategorySortMode} + /> { const desktopCategoryPanelContent = activeCategoryGroup ? ( <> -
- - - [ - '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(' ') - } - /> - -
+ setIsCategoryFilterPanelOpen(true)} + onTabChange={handleCategoryGroupChange} + onToggleSort={cycleCategorySortMode} + /> 当前筛选下没有作品。}