继续沉淀筛选工具条与裁剪弹窗壳层
新增共享 PlatformFilterToolbar 收口首页分类筛选与排序工具条 将 SquareImageCropModal 收口到 UnifiedModal 并补充薄能力透传 补充组件与调用侧回归测试并更新 PlatformUiKit 收口计划与共享决策记录
This commit is contained in:
@@ -61,6 +61,8 @@
|
|||||||
- 2026-06-11 追加:`PlatformAsyncStatePanel` 继续补齐 RPG 首页分类分支;移动端“发现 -> 分类”、桌面发现页“分类”和桌面首页“作品分类”模块现在都统一委托共享状态壳切换外层 `loading / empty / content`,分类控制条与排序按钮继续留在内容 slot 中。筛选后无结果的“当前筛选下没有作品。”也统一改成内层 `PlatformAsyncStatePanel` 切换,不再在三处 JSX 中各自维护嵌套 ternary。
|
- 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;`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 追加:`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 插槽,直接透传 `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 追加:`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 删除按钮优先继续沿共享按钮 + 薄包装的方向推进。
|
- 2026-06-11 追加:按钮层继续补齐轻量漏网项。`PlatformTagEditor.tsx` 的标签 chip 删除入口已改成紧凑 `PlatformIconButton`,保留透明背景和原 chip 高度;`RpgEntryCharacterSelectView.tsx` 的两处“返回”按钮统一沉到局部 `CharacterSelectBackButton`,底层委托 `PlatformActionButton surface="editorDark"`。同日 `GenerationProgressHero.tsx` 新增 `GenerationHeaderBackButton`,`CustomWorldGenerationView.tsx` 与 `BarkBattleGeneratingView.tsx` 已开始复用这套暖色生成页返回入口骨架;后续同类轻量返回按钮与 chip 删除按钮优先继续沿共享按钮 + 薄包装的方向推进。
|
||||||
|
|||||||
@@ -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.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 再回退成原生 `<button>` 手写布局;但教培 promo card、runtime 列表项和带复杂手势的卡片仍保留本地语义,不把共享行骨架扩成万能作品卡。验证命令:`npx vitest run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
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 再回退成原生 `<button>` 手写布局;但教培 promo card、runtime 列表项和带复杂手势的卡片仍保留本地语义,不把共享行骨架扩成万能作品卡。验证命令:`npx vitest run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
19.3.47. `PlatformDarkModalFooter` 继续从标准双按钮 footer 扩展到 detail / confirm 收尾:`NpcModals.tsx` 的交易详情单按钮 footer 与 `MapModal.tsx` 的场景切换确认 footer 已接入共享 dark footer frame,分别保留“关闭”单 CTA 和“取消 / 确认前往”双 CTA 的业务语义、按钮 tone 与禁用态。后续 dark / pixel modal 里若只是标准底部分隔线 + 常规动作区排布,优先直接复用 `PlatformDarkModalFooter`,即使只有单个按钮也不再手写 `flex justify-end`;但像 `SquareImageCropModal.tsx` 这类白底弹窗 footer、sticky 工作台 footer 和运行态 HUD 工具条继续留在各自语义壳层,不强行混到 dark footer 抽象里。验证命令:`npx vitest run src/components/NpcModals.test.tsx src/components/MapModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
19.3.47. `PlatformDarkModalFooter` 继续从标准双按钮 footer 扩展到 detail / confirm 收尾:`NpcModals.tsx` 的交易详情单按钮 footer 与 `MapModal.tsx` 的场景切换确认 footer 已接入共享 dark footer frame,分别保留“关闭”单 CTA 和“取消 / 确认前往”双 CTA 的业务语义、按钮 tone 与禁用态。后续 dark / pixel modal 里若只是标准底部分隔线 + 常规动作区排布,优先直接复用 `PlatformDarkModalFooter`,即使只有单个按钮也不再手写 `flex justify-end`;但像 `SquareImageCropModal.tsx` 这类白底弹窗 footer、sticky 工作台 footer 和运行态 HUD 工具条继续留在各自语义壳层,不强行混到 dark footer 抽象里。验证命令:`npx vitest run src/components/NpcModals.test.tsx src/components/MapModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
19.3.48. `RpgEntryHomeView.tsx` 里的分类筛选工具条继续从页面内重复 JSX 收口到 `src/components/common/PlatformFilterToolbar.tsx`;该 Module 只承接“筛选按钮 + 横向 tabs + 排序按钮”的结构排布,暴露 `mobile / desktop` 两种 layout 以覆盖移动端 divider + 独立排序行和桌面端同排布局差异,但不持有分类列表、筛选状态、空态或排序逻辑。当前 RPG 首页分类区已接入,后续若其它白底列表页也出现同构的筛选壳层,可直接复用这套薄结构组件;若场景只是在单页内局部重复、接口会为了兼容业务差异不断膨胀,则优先退回文件内 helper,不把 `common` 扩成假的“万能筛选条”。验证命令:`npx vitest run src/components/common/PlatformFilterToolbar.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
19.3.49. `SquareImageCropModal.tsx` 的白底 modal 壳层与 footer 已收口到 `src/components/common/UnifiedModal.tsx`;`UnifiedModal` 为此只薄补了 `titleId` 与 `closeIcon` 透传,继续由调用方决定 `closeOnBackdrop`、`closeOnEscape`、`portal`、header/footer 样式和按钮内容,不额外掺入 profile 业务语义,也不让 `common/` 反向依赖 `platform-entry/`。`SquareImageCropModal.tsx` 继续保留裁剪拖拽、pointer capture、保存禁用态与两列等宽 footer 行为,只把 header / body / footer 外壳交给共享 modal 承接。后续 `common` 级白底工具弹窗若只是标准标题栏 + 内容区 + footer 按钮排布,优先先看 `UnifiedModal` 是否够用,再决定是否需要新的薄壳;不要为了一个弹窗把 `PlatformProfileModalShell` 之类带页面语义的壳层倒灌回 `common`。验证命令:`npx vitest run src/components/common/SquareImageCropModal.test.tsx src/components/common/UnifiedModal.test.tsx src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
19.3. creative-agent 首页的侧边栏菜单、账号入口、开启新对话、我的创作、首页激励 CTA 和 prompt suggestion 按钮迁移到 `PlatformIconButton` / `PlatformActionButton`;首页继续保留 `creative-agent-home__*` 本地 class 承接透明顶栏、抽屉和品牌化胶囊视觉,不把视觉回收和语义收口绑成一次大改。`Beta` 徽标和历史记录纯文本行暂保留本地实现,等出现更多同构轻量列表行后再评估是否抽新的共享 row primitive。
|
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. 大鱼吃小鱼结果页 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`。
|
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`。
|
||||||
|
|||||||
72
src/components/common/PlatformFilterToolbar.test.tsx
Normal file
72
src/components/common/PlatformFilterToolbar.test.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { PlatformFilterToolbar } from './PlatformFilterToolbar';
|
||||||
|
|
||||||
|
const TAB_ITEMS = [
|
||||||
|
{ id: 'all', label: '全部' },
|
||||||
|
{ id: 'story', label: '剧情' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
test('renders mobile platform filter toolbar with divider and prefixed sort label', () => {
|
||||||
|
const onOpenFilter = vi.fn();
|
||||||
|
const onTabChange = vi.fn();
|
||||||
|
const onToggleSort = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<PlatformFilterToolbar
|
||||||
|
filterLabel="筛选"
|
||||||
|
filterCount={12}
|
||||||
|
tabItems={TAB_ITEMS}
|
||||||
|
activeTabId="all"
|
||||||
|
sortLabel="最热"
|
||||||
|
layout="mobile"
|
||||||
|
onOpenFilter={onOpenFilter}
|
||||||
|
onTabChange={onTabChange}
|
||||||
|
onToggleSort={onToggleSort}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /筛选/u })).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', { name: /按最热排序/u })).toBeTruthy();
|
||||||
|
expect(container.querySelector('.platform-category-filter-divider')).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '剧情' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /按最热排序/u }));
|
||||||
|
|
||||||
|
expect(onTabChange).toHaveBeenCalledWith('story');
|
||||||
|
expect(onToggleSort).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders desktop platform filter toolbar with inline sort button', () => {
|
||||||
|
const onOpenFilter = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<PlatformFilterToolbar
|
||||||
|
filterLabel="剧情"
|
||||||
|
filterCount={3}
|
||||||
|
tabItems={TAB_ITEMS}
|
||||||
|
activeTabId="story"
|
||||||
|
sortLabel="最新"
|
||||||
|
layout="desktop"
|
||||||
|
onOpenFilter={onOpenFilter}
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
onToggleSort={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterButton = container.querySelector(
|
||||||
|
'.platform-category-filter-button',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
const sortButton = screen.getByRole('button', { name: /最新/u });
|
||||||
|
|
||||||
|
expect(filterButton).toBeTruthy();
|
||||||
|
expect(filterButton?.textContent).toContain('剧情');
|
||||||
|
expect(sortButton).toBeTruthy();
|
||||||
|
expect(sortButton.className).toContain('shrink-0');
|
||||||
|
expect(container.querySelector('.platform-category-filter-divider')).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(filterButton!);
|
||||||
|
|
||||||
|
expect(onOpenFilter).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
108
src/components/common/PlatformFilterToolbar.tsx
Normal file
108
src/components/common/PlatformFilterToolbar.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { ChevronDown, SlidersHorizontal } from 'lucide-react';
|
||||||
|
|
||||||
|
import { PlatformSegmentedTabs } from './PlatformSegmentedTabs';
|
||||||
|
|
||||||
|
export interface PlatformFilterToolbarTabItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformFilterToolbarProps {
|
||||||
|
filterLabel: string;
|
||||||
|
filterCount: number;
|
||||||
|
tabItems: readonly PlatformFilterToolbarTabItem[];
|
||||||
|
activeTabId: string;
|
||||||
|
sortLabel: string;
|
||||||
|
layout: 'mobile' | 'desktop';
|
||||||
|
onOpenFilter: () => void;
|
||||||
|
onTabChange: (id: string) => void;
|
||||||
|
onToggleSort: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToolbarTabItemClassName(active: boolean) {
|
||||||
|
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(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlatformFilterToolbar({
|
||||||
|
filterLabel,
|
||||||
|
filterCount,
|
||||||
|
tabItems,
|
||||||
|
activeTabId,
|
||||||
|
sortLabel,
|
||||||
|
layout,
|
||||||
|
onOpenFilter,
|
||||||
|
onTabChange,
|
||||||
|
onToggleSort,
|
||||||
|
}: PlatformFilterToolbarProps) {
|
||||||
|
const isMobileLayout = layout === 'mobile';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isMobileLayout
|
||||||
|
? 'platform-category-filter-row'
|
||||||
|
: 'mb-4 flex min-w-0 items-center gap-2'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenFilter}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
className="platform-category-filter-button"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="h-4 w-4" />
|
||||||
|
<span>{filterLabel}</span>
|
||||||
|
<span className="platform-category-filter-button__count">
|
||||||
|
{filterCount}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{isMobileLayout ? (
|
||||||
|
<span className="platform-category-filter-divider" />
|
||||||
|
) : null}
|
||||||
|
<PlatformSegmentedTabs
|
||||||
|
items={tabItems}
|
||||||
|
activeId={activeTabId}
|
||||||
|
onChange={onTabChange}
|
||||||
|
layout="scroll"
|
||||||
|
gap="md"
|
||||||
|
frame="bare"
|
||||||
|
surface="transparent"
|
||||||
|
size="sm"
|
||||||
|
tone="neutral"
|
||||||
|
className={
|
||||||
|
isMobileLayout
|
||||||
|
? 'platform-category-chip-scroll min-w-0 flex-1'
|
||||||
|
: 'min-w-0 flex-1 pb-1'
|
||||||
|
}
|
||||||
|
itemClassName={(_, active) => buildToolbarTabItemClassName(active)}
|
||||||
|
/>
|
||||||
|
{!isMobileLayout ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleSort}
|
||||||
|
className="platform-category-sort-button shrink-0"
|
||||||
|
>
|
||||||
|
<span>{sortLabel}</span>
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{isMobileLayout ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleSort}
|
||||||
|
className="platform-category-sort-button"
|
||||||
|
>
|
||||||
|
<span>按{sortLabel}排序</span>
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
src/components/common/SquareImageCropModal.test.tsx
Normal file
122
src/components/common/SquareImageCropModal.test.tsx
Normal file
@@ -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<Parameters<typeof SquareImageCropModal>[0]> = {},
|
||||||
|
) {
|
||||||
|
const onCropRectChange = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SquareImageCropModal
|
||||||
|
source="data:image/png;base64,preview"
|
||||||
|
imageSize={{ width: 800, height: 600 }}
|
||||||
|
cropRect={{ x: 100, y: 0, size: 600 }}
|
||||||
|
labels={{
|
||||||
|
title: '裁剪拼图图片',
|
||||||
|
close: '关闭拼图图片裁剪',
|
||||||
|
editor: '拼图图片裁剪操作区',
|
||||||
|
previewAlt: '拼图图片裁剪预览',
|
||||||
|
cancel: '取消',
|
||||||
|
submit: '应用',
|
||||||
|
saving: '裁剪中',
|
||||||
|
}}
|
||||||
|
onCropRectChange={onCropRectChange}
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
{...overrideProps}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import { PlatformActionButton } from './PlatformActionButton';
|
import { PlatformActionButton } from './PlatformActionButton';
|
||||||
import { PlatformModalCloseButton } from './PlatformModalCloseButton';
|
|
||||||
import { PlatformStatusMessage } from './PlatformStatusMessage';
|
import { PlatformStatusMessage } from './PlatformStatusMessage';
|
||||||
|
import { UnifiedModal } from './UnifiedModal';
|
||||||
import {
|
import {
|
||||||
clampNumber,
|
clampNumber,
|
||||||
clampSquareImageCropRect,
|
clampSquareImageCropRect,
|
||||||
@@ -309,25 +309,35 @@ export function SquareImageCropModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
<UnifiedModal
|
||||||
<div
|
open
|
||||||
role="dialog"
|
title={labels.title}
|
||||||
aria-modal="true"
|
titleId={titleId}
|
||||||
aria-labelledby={titleId}
|
onClose={onClose}
|
||||||
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.4rem]"
|
closeOnBackdrop={false}
|
||||||
>
|
closeOnEscape={false}
|
||||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
closeLabel={labels.close}
|
||||||
<div id={titleId} className="text-base font-black">
|
closeVariant="profileCompact"
|
||||||
{labels.title}
|
closeIcon="×"
|
||||||
</div>
|
portal={false}
|
||||||
<PlatformModalCloseButton
|
zIndexClassName="z-[80]"
|
||||||
label={labels.close}
|
panelClassName="platform-remap-surface max-w-sm rounded-[1.4rem]"
|
||||||
variant="profileCompact"
|
headerClassName="border-white/10 px-5 py-4"
|
||||||
onClick={onClose}
|
titleClassName="font-black"
|
||||||
icon="×"
|
bodyClassName="px-5 py-5"
|
||||||
/>
|
footerClassName="border-transparent px-5 pt-0 pb-5"
|
||||||
|
footer={
|
||||||
|
<div className="grid w-full grid-cols-2 gap-3">
|
||||||
|
<PlatformActionButton tone="secondary" onClick={onClose}>
|
||||||
|
{labels.cancel}
|
||||||
|
</PlatformActionButton>
|
||||||
|
<PlatformActionButton onClick={onSubmit} disabled={isSaving}>
|
||||||
|
{isSaving ? labels.saving : labels.submit}
|
||||||
|
</PlatformActionButton>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-5">
|
}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
<div
|
<div
|
||||||
ref={previewRef}
|
ref={previewRef}
|
||||||
className="relative mx-auto overflow-hidden rounded-[1.2rem] border border-white/12 bg-black/35 select-none touch-none"
|
className="relative mx-auto overflow-hidden rounded-[1.2rem] border border-white/12 bg-black/35 select-none touch-none"
|
||||||
@@ -388,19 +398,7 @@ export function SquareImageCropModal({
|
|||||||
{error}
|
{error}
|
||||||
</PlatformStatusMessage>
|
</PlatformStatusMessage>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
|
||||||
<PlatformActionButton tone="secondary" onClick={onClose}>
|
|
||||||
{labels.cancel}
|
|
||||||
</PlatformActionButton>
|
|
||||||
<PlatformActionButton
|
|
||||||
onClick={onSubmit}
|
|
||||||
disabled={isSaving}
|
|
||||||
>
|
|
||||||
{isSaving ? labels.saving : labels.submit}
|
|
||||||
</PlatformActionButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</UnifiedModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,6 @@ test('uses shared pixel close button chrome', () => {
|
|||||||
expect(screen.getByRole('dialog', { name: '像素弹窗' }).className).toContain(
|
expect(screen.getByRole('dialog', { name: '像素弹窗' }).className).toContain(
|
||||||
'pixel-modal-shell',
|
'pixel-modal-shell',
|
||||||
);
|
);
|
||||||
expect(closeButton.className).toContain('bg-black/20');
|
expect(closeButton.className).toContain('bg-black/30');
|
||||||
expect(closeButton.className).toContain('text-zinc-400');
|
expect(closeButton.className).toContain('text-zinc-400');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,10 +15,14 @@ type UnifiedModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen';
|
|||||||
type UnifiedModalCloseVariant = NonNullable<
|
type UnifiedModalCloseVariant = NonNullable<
|
||||||
ComponentProps<typeof PlatformModalCloseButton>['variant']
|
ComponentProps<typeof PlatformModalCloseButton>['variant']
|
||||||
>;
|
>;
|
||||||
|
type UnifiedModalCloseIcon = ComponentProps<
|
||||||
|
typeof PlatformModalCloseButton
|
||||||
|
>['icon'];
|
||||||
|
|
||||||
type UnifiedModalProps = {
|
type UnifiedModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
|
titleId?: string;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
@@ -32,6 +36,7 @@ type UnifiedModalProps = {
|
|||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
closeLabel?: string;
|
closeLabel?: string;
|
||||||
closeVariant?: UnifiedModalCloseVariant;
|
closeVariant?: UnifiedModalCloseVariant;
|
||||||
|
closeIcon?: UnifiedModalCloseIcon;
|
||||||
portal?: boolean;
|
portal?: boolean;
|
||||||
zIndexClassName?: string;
|
zIndexClassName?: string;
|
||||||
overlayClassName?: string;
|
overlayClassName?: string;
|
||||||
@@ -81,6 +86,7 @@ function getPanelStyle(
|
|||||||
function UnifiedModalContent({
|
function UnifiedModalContent({
|
||||||
open,
|
open,
|
||||||
title,
|
title,
|
||||||
|
titleId: titleIdProp,
|
||||||
description,
|
description,
|
||||||
children,
|
children,
|
||||||
footer,
|
footer,
|
||||||
@@ -94,6 +100,7 @@ function UnifiedModalContent({
|
|||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
closeLabel = '关闭',
|
closeLabel = '关闭',
|
||||||
closeVariant,
|
closeVariant,
|
||||||
|
closeIcon,
|
||||||
zIndexClassName = 'z-[90]',
|
zIndexClassName = 'z-[90]',
|
||||||
overlayClassName,
|
overlayClassName,
|
||||||
panelClassName,
|
panelClassName,
|
||||||
@@ -104,8 +111,9 @@ function UnifiedModalContent({
|
|||||||
footerClassName,
|
footerClassName,
|
||||||
panelStyle,
|
panelStyle,
|
||||||
}: Omit<UnifiedModalProps, 'portal'>) {
|
}: Omit<UnifiedModalProps, 'portal'>) {
|
||||||
const titleId = useId();
|
const generatedTitleId = useId();
|
||||||
const descriptionId = useId();
|
const descriptionId = useId();
|
||||||
|
const titleId = titleIdProp ?? generatedTitleId;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || closeDisabled || !closeOnEscape) {
|
if (!open || closeDisabled || !closeOnEscape) {
|
||||||
@@ -209,6 +217,7 @@ function UnifiedModalContent({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={closeDisabled}
|
disabled={closeDisabled}
|
||||||
variant={closeVariant ?? (isPixel ? 'pixel' : 'platformIcon')}
|
variant={closeVariant ?? (isPixel ? 'pixel' : 'platformIcon')}
|
||||||
|
icon={closeIcon}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
Share2,
|
Share2,
|
||||||
SlidersHorizontal,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Star,
|
Star,
|
||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
@@ -84,6 +83,7 @@ import {
|
|||||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||||
|
import { PlatformFilterToolbar } from '../common/PlatformFilterToolbar';
|
||||||
import { PlatformIconBadge } from '../common/PlatformIconBadge';
|
import { PlatformIconBadge } from '../common/PlatformIconBadge';
|
||||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||||
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
|
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
|
||||||
@@ -3248,50 +3248,17 @@ export function RpgEntryHomeView({
|
|||||||
const desktopCategoryGrid = activeCategoryEntries.slice(0, 6);
|
const desktopCategoryGrid = activeCategoryEntries.slice(0, 6);
|
||||||
const mobileCategoryPanelContent = activeCategoryGroup ? (
|
const mobileCategoryPanelContent = activeCategoryGroup ? (
|
||||||
<>
|
<>
|
||||||
<div className="platform-category-filter-row">
|
<PlatformFilterToolbar
|
||||||
<button
|
filterLabel={categoryFilterApplied ? activeCategoryFilterLabel : '筛选'}
|
||||||
type="button"
|
filterCount={activeCategoryFilterCount}
|
||||||
onClick={() => setIsCategoryFilterPanelOpen(true)}
|
tabItems={categoryGroupTabs}
|
||||||
aria-haspopup="dialog"
|
activeTabId={activeCategoryGroup.tag}
|
||||||
className="platform-category-filter-button"
|
sortLabel={activeCategorySortLabel}
|
||||||
>
|
layout="mobile"
|
||||||
<SlidersHorizontal className="h-4 w-4" />
|
onOpenFilter={() => setIsCategoryFilterPanelOpen(true)}
|
||||||
<span>{categoryFilterApplied ? activeCategoryFilterLabel : '筛选'}</span>
|
onTabChange={handleCategoryGroupChange}
|
||||||
<span className="platform-category-filter-button__count">
|
onToggleSort={cycleCategorySortMode}
|
||||||
{activeCategoryFilterCount}
|
/>
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<span className="platform-category-filter-divider" />
|
|
||||||
<PlatformSegmentedTabs
|
|
||||||
items={categoryGroupTabs}
|
|
||||||
activeId={activeCategoryGroup.tag}
|
|
||||||
onChange={handleCategoryGroupChange}
|
|
||||||
layout="scroll"
|
|
||||||
gap="md"
|
|
||||||
frame="bare"
|
|
||||||
surface="transparent"
|
|
||||||
size="sm"
|
|
||||||
tone="neutral"
|
|
||||||
className="platform-category-chip-scroll min-w-0 flex-1"
|
|
||||||
itemClassName={(_, active) =>
|
|
||||||
[
|
|
||||||
'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(' ')
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={cycleCategorySortMode}
|
|
||||||
className="platform-category-sort-button"
|
|
||||||
>
|
|
||||||
<span>按{activeCategorySortLabel}排序</span>
|
|
||||||
<ChevronDown className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<PlatformAsyncStatePanel
|
<PlatformAsyncStatePanel
|
||||||
isEmpty={activeCategoryEntries.length === 0}
|
isEmpty={activeCategoryEntries.length === 0}
|
||||||
@@ -3313,48 +3280,17 @@ export function RpgEntryHomeView({
|
|||||||
const renderDesktopCategorySection = (cardKeyPrefix: string) => {
|
const renderDesktopCategorySection = (cardKeyPrefix: string) => {
|
||||||
const desktopCategoryPanelContent = activeCategoryGroup ? (
|
const desktopCategoryPanelContent = activeCategoryGroup ? (
|
||||||
<>
|
<>
|
||||||
<div className="mb-4 flex min-w-0 items-center gap-2">
|
<PlatformFilterToolbar
|
||||||
<button
|
filterLabel={categoryFilterApplied ? activeCategoryFilterLabel : '筛选'}
|
||||||
type="button"
|
filterCount={activeCategoryFilterCount}
|
||||||
onClick={() => setIsCategoryFilterPanelOpen(true)}
|
tabItems={categoryGroupTabs}
|
||||||
aria-haspopup="dialog"
|
activeTabId={activeCategoryGroup.tag}
|
||||||
className="platform-category-filter-button"
|
sortLabel={activeCategorySortLabel}
|
||||||
>
|
layout="desktop"
|
||||||
<SlidersHorizontal className="h-4 w-4" />
|
onOpenFilter={() => setIsCategoryFilterPanelOpen(true)}
|
||||||
<span>{categoryFilterApplied ? activeCategoryFilterLabel : '筛选'}</span>
|
onTabChange={handleCategoryGroupChange}
|
||||||
<span className="platform-category-filter-button__count">
|
onToggleSort={cycleCategorySortMode}
|
||||||
{activeCategoryFilterCount}
|
/>
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<PlatformSegmentedTabs
|
|
||||||
items={categoryGroupTabs}
|
|
||||||
activeId={activeCategoryGroup.tag}
|
|
||||||
onChange={handleCategoryGroupChange}
|
|
||||||
layout="scroll"
|
|
||||||
gap="md"
|
|
||||||
frame="bare"
|
|
||||||
surface="transparent"
|
|
||||||
size="sm"
|
|
||||||
tone="neutral"
|
|
||||||
className="min-w-0 flex-1 pb-1"
|
|
||||||
itemClassName={(_, active) =>
|
|
||||||
[
|
|
||||||
'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(' ')
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={cycleCategorySortMode}
|
|
||||||
className="platform-category-sort-button shrink-0"
|
|
||||||
>
|
|
||||||
<span>{activeCategorySortLabel}</span>
|
|
||||||
<ChevronDown className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<PlatformAsyncStatePanel
|
<PlatformAsyncStatePanel
|
||||||
isEmpty={desktopCategoryGrid.length === 0}
|
isEmpty={desktopCategoryGrid.length === 0}
|
||||||
emptyState={<PlatformEmptyState>当前筛选下没有作品。</PlatformEmptyState>}
|
emptyState={<PlatformEmptyState>当前筛选下没有作品。</PlatformEmptyState>}
|
||||||
|
|||||||
Reference in New Issue
Block a user