继续沉淀筛选工具条与裁剪弹窗壳层
新增共享 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 追加:`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 删除按钮优先继续沿共享按钮 + 薄包装的方向推进。
|
||||
|
||||
@@ -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 再回退成原生 `<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.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.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`。
|
||||
|
||||
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';
|
||||
|
||||
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 (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.4rem]"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div id={titleId} className="text-base font-black">
|
||||
{labels.title}
|
||||
</div>
|
||||
<PlatformModalCloseButton
|
||||
label={labels.close}
|
||||
variant="profileCompact"
|
||||
onClick={onClose}
|
||||
icon="×"
|
||||
/>
|
||||
<UnifiedModal
|
||||
open
|
||||
title={labels.title}
|
||||
titleId={titleId}
|
||||
onClose={onClose}
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={false}
|
||||
closeLabel={labels.close}
|
||||
closeVariant="profileCompact"
|
||||
closeIcon="×"
|
||||
portal={false}
|
||||
zIndexClassName="z-[80]"
|
||||
panelClassName="platform-remap-surface max-w-sm rounded-[1.4rem]"
|
||||
headerClassName="border-white/10 px-5 py-4"
|
||||
titleClassName="font-black"
|
||||
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 className="px-5 py-5">
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
ref={previewRef}
|
||||
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}
|
||||
</PlatformStatusMessage>
|
||||
) : 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>
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -15,10 +15,14 @@ type UnifiedModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen';
|
||||
type UnifiedModalCloseVariant = NonNullable<
|
||||
ComponentProps<typeof PlatformModalCloseButton>['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<UnifiedModalProps, 'portal'>) {
|
||||
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}
|
||||
</div>
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
<div className="platform-category-filter-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCategoryFilterPanelOpen(true)}
|
||||
aria-haspopup="dialog"
|
||||
className="platform-category-filter-button"
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
<span>{categoryFilterApplied ? activeCategoryFilterLabel : '筛选'}</span>
|
||||
<span className="platform-category-filter-button__count">
|
||||
{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>
|
||||
<PlatformFilterToolbar
|
||||
filterLabel={categoryFilterApplied ? activeCategoryFilterLabel : '筛选'}
|
||||
filterCount={activeCategoryFilterCount}
|
||||
tabItems={categoryGroupTabs}
|
||||
activeTabId={activeCategoryGroup.tag}
|
||||
sortLabel={activeCategorySortLabel}
|
||||
layout="mobile"
|
||||
onOpenFilter={() => setIsCategoryFilterPanelOpen(true)}
|
||||
onTabChange={handleCategoryGroupChange}
|
||||
onToggleSort={cycleCategorySortMode}
|
||||
/>
|
||||
|
||||
<PlatformAsyncStatePanel
|
||||
isEmpty={activeCategoryEntries.length === 0}
|
||||
@@ -3313,48 +3280,17 @@ export function RpgEntryHomeView({
|
||||
const renderDesktopCategorySection = (cardKeyPrefix: string) => {
|
||||
const desktopCategoryPanelContent = activeCategoryGroup ? (
|
||||
<>
|
||||
<div className="mb-4 flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCategoryFilterPanelOpen(true)}
|
||||
aria-haspopup="dialog"
|
||||
className="platform-category-filter-button"
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
<span>{categoryFilterApplied ? activeCategoryFilterLabel : '筛选'}</span>
|
||||
<span className="platform-category-filter-button__count">
|
||||
{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>
|
||||
<PlatformFilterToolbar
|
||||
filterLabel={categoryFilterApplied ? activeCategoryFilterLabel : '筛选'}
|
||||
filterCount={activeCategoryFilterCount}
|
||||
tabItems={categoryGroupTabs}
|
||||
activeTabId={activeCategoryGroup.tag}
|
||||
sortLabel={activeCategorySortLabel}
|
||||
layout="desktop"
|
||||
onOpenFilter={() => setIsCategoryFilterPanelOpen(true)}
|
||||
onTabChange={handleCategoryGroupChange}
|
||||
onToggleSort={cycleCategorySortMode}
|
||||
/>
|
||||
<PlatformAsyncStatePanel
|
||||
isEmpty={desktopCategoryGrid.length === 0}
|
||||
emptyState={<PlatformEmptyState>当前筛选下没有作品。</PlatformEmptyState>}
|
||||
|
||||
Reference in New Issue
Block a user