继续收口平台分段与泥点确认
新增泥点确认状态机共享 hook 并接入拼图与抓大鹅工作台 将首页发现页与个人中心剩余切换条收口到 PlatformSegmentedTabs 统一平台弹窗 header 关闭入口并补齐相关测试 更新前端组件收口文档与团队决策记录
This commit is contained in:
@@ -2071,8 +2071,11 @@
|
||||
- 决策:新增 `src/components/common/PlatformAsyncStatePanel.tsx` 作为互斥异步状态骨架,只承接 `errorState / loadingState / emptyState / children` 四类 slot 的优先级切换;`PlatformProfileWalletLedgerModal.tsx`、`PlatformProfileTaskCenterModal.tsx`、`PlatformProfileRechargeModal.tsx`、`PlatformProfilePlayedWorksModal.tsx` 与 `PlatformProfileReferralModal.tsx` 已接入。若错误或成功提示需要与内容并存,继续留在业务组件外层,不把 `PlatformAsyncStatePanel` 扩成全能状态机。
|
||||
- 决策:`src/components/common/PlatformSegmentedTabs.tsx` 支持 `layout="scroll"`,用于横向可滚动 tab rail;`CustomWorldCreationStartCard.tsx`、`CustomWorldWorkTabs.tsx` 以及 `RpgEntryHomeView.tsx` 的排行 / 分类筛选已接入。共享组件只负责 tab 语义、滚动容器和基础交互,业务页通过 `itemClassName` 保留本地皮肤,不额外抽“频道 tab”视觉 preset。
|
||||
- 决策:`src/components/PixelCloseButton.tsx` 保持为 RPG 语义薄封装,底层统一复用 `src/components/common/PlatformModalCloseButton.tsx` 的 `variant="pixel"`;共享 close button 现在负责 `absolute / inline` placement、默认 `title=label` 和可选 `stopPropagation` 点击拦截,业务 importer 不再各自维护像素风关闭按钮壳和冒泡控制。
|
||||
- 决策:`PlatformSegmentedTabs` 继续承接首页 / 结果页剩余的横向 rail 与二选一切换;`RpgEntryHomeView.tsx` 的 discover channel bar、移动端 / 桌面端分类 chip rail,`CustomWorldEntityCatalog.tsx` 的 `RESULT_TABS` sticky rail,以及 `PlatformProfileRechargeModal.tsx` 的“泥点充值 / 会员卡”切换条已迁移。像 `CustomWorldEntityCatalog` 这种“标题 + count”内容直接走 `ReactNode label`,业务皮肤继续落在 `itemClassName`;同类切换在测试里应优先按 `role="tablist" / "tab"` 查询,而不是把它们继续当普通 button。
|
||||
- 决策:简单泥点确认流的开关状态机统一收口到 `src/components/common/useMudPointConfirmController.ts`,只暴露 `open / requestOpen / close / confirm`,不持有点数、标题、描述或禁用态等业务字段;`PuzzleCreationWorkspace.tsx`、`Match3DCreationWorkspace.tsx` 与 `Match3DResultView.tsx` 的两个批量素材面板已接入。`PuzzleResultView.tsx` 和 `RpgCreationRoleAssetStudioModalImpl.tsx` 这类节奏不同或携带 pending payload 的场景继续保留本地状态机,避免把简单 hook 扩成泛型动作路由器。
|
||||
- 决策:标准平台 modal header 的关闭入口继续统一到 `PlatformModalCloseButton variant="platformIcon"`;`PuzzleResultView.tsx` 的关卡详情 / 发布弹窗、`RpgCreationResultActionBar.tsx` 的发布检查弹窗,以及 `PuzzleHistoryAssetPickerDialog.tsx` 的历史素材弹窗已迁移。像素风 runtime、drawer collapse、玩法规则面板和运行态 overlay 不跟这条线混收,继续保留局部 close 语义。
|
||||
- 决策:平台入口的创作前置泥点阻断提示只在 `platform-entry` 局部抽成 `src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx`,并使用 `DraftGenerationPointNotice` union(`insufficient-points` / `balance-load-failed`)承接业务真相;不要在 `common/` 再抽一个泛化 `BlockingNoticeDialog`,否则会把 `PlatformAcknowledgeStatusDialog` 的样式透传再包装一层而不缩小调用面。
|
||||
- 验证方式:`npm run test -- src/components/common/PlatformAsyncStatePanel.test.tsx src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx src/components/common/PlatformSegmentedTabs.test.tsx src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、`npm run test -- src/components/common/PlatformModalCloseButton.test.tsx src/components/PixelCloseButton.test.tsx src/components/CharacterChatModal.test.tsx src/components/MapModal.test.tsx`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile game category filter dialog filters by play type|bottom category tab becomes ranking and switches ranking metrics|ranking rows limit displayed work name and show two short tags on the third line"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "searching unmatched public work code shows not-found search result dialog|public code search shows public user summary in shared search result modal and clears it on close|bark battle form checks mud points before creating image assets|puzzle form checks mud points before creating a draft|match3d form checks mud points before creating a draft"`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
- 验证方式:`npm run test -- src/components/common/PlatformAsyncStatePanel.test.tsx src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx src/components/common/PlatformSegmentedTabs.test.tsx src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、`npm run test -- src/components/common/PlatformModalCloseButton.test.tsx src/components/PixelCloseButton.test.tsx src/components/CharacterChatModal.test.tsx src/components/MapModal.test.tsx`、`npm run test -- src/components/common/useMudPointConfirmController.test.tsx src/components/match3d-result/Match3DResultView.test.tsx src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/rpg-creation-result/RpgCreationResultActionBar.test.tsx src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-05-26 敲木鱼发布后作品架与推荐流刷新口径
|
||||
|
||||
|
||||
@@ -251,6 +251,9 @@
|
||||
19.3.27. `PlatformSegmentedTabs` 支持 `layout="scroll"` 承接横向可滚动 tab rail;`CustomWorldCreationStartCard.tsx`、`CustomWorldWorkTabs.tsx` 和 `RpgEntryHomeView.tsx` 的排行 tab、分类筛选项已接入。共享组件只统一 `tablist/tab` 语义、滚动容器和基础交互,业务视觉仍通过 `itemClassName` 保留本地样式,不在 `common/` 新增“频道 tab”皮肤 preset;后续同类横向 tab 优先扩展这套 `scroll + itemClassName` 组合。
|
||||
19.3.28. `PixelCloseButton.tsx` 保持为 RPG 语义薄封装,底层改为复用 `src/components/common/PlatformModalCloseButton.tsx` 的 `variant="pixel"`;共享 close button 统一承接像素风基础 chrome、`absolute / inline` placement、默认 `title=label` 和可选 `stopPropagation` 冒泡拦截,`CharacterChatModal.tsx` 与 `MapModal.tsx` 的 inline / absolute 真实 importer 已补测试。后续需要像素风关闭按钮时优先使用 `PlatformModalCloseButton variant="pixel"` 或继续复用 `PixelCloseButton` 语义壳,不再手写本地 close button。
|
||||
19.3.29. 平台入口创作前置泥点阻断提示抽到 `src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx`,并用 `DraftGenerationPointNotice` union(`insufficient-points` / `balance-load-failed`)承接业务真相;`PlatformEntryFlowShellImpl.tsx` 不再直接拼 `PlatformAcknowledgeStatusDialog` 的标题、说明和 amber icon 条件分支。后续若只是平台入口里的泥点前置检查提示,优先继续扩展这个局部语义 wrapper;不要急着在 `common/` 抽泛化 `BlockingNoticeDialog`,避免把底层状态弹窗的样式透传再次包装一层。
|
||||
19.3.30. `PlatformSegmentedTabs` 继续承接首页 / 结果页里剩余的横向 rail 与二选一切换:`RpgEntryHomeView.tsx` 的 discover channel bar、移动端 / 桌面端分类 chip rail,`CustomWorldEntityCatalog.tsx` 的 `RESULT_TABS` sticky rail,以及 `PlatformProfileRechargeModal.tsx` 的“泥点充值 / 会员卡”切换条已迁移。`CustomWorldEntityCatalog` 通过 `ReactNode label` 保留“标题 + count”两行内容,`RpgEntryHomeView` 和个人中心切换条继续通过 `itemClassName` 贴回本地皮肤;同类 rail 优先直接复用 `PlatformSegmentedTabs`,测试也应按 `role="tablist" / "tab"` 查询,不再把这些切换项当普通 button。
|
||||
19.3.31. 简单泥点确认流的开关状态机收口到 `src/components/common/useMudPointConfirmController.ts`;该 hook 只承接 `open / requestOpen / close / confirm` 四个动作,`confirm` 固定先关弹窗再执行回调,不持有 `points / title / description / confirmDisabled` 之类业务字段。`PuzzleCreationWorkspace.tsx`、`Match3DCreationWorkspace.tsx` 和 `Match3DResultView.tsx` 的两个批量素材面板已接入;`PuzzleCreationWorkspace` 仍在业务页判断“只有 `aiRedraw` 才弹确认”。`PuzzleResultView.tsx` 与 `RpgCreationRoleAssetStudioModalImpl.tsx` 这类要么节奏不同、要么携带 pending payload 的场景先保留本地状态机,不把 hook 扩成泛型动作路由器。
|
||||
19.3.32. 标准平台 modal header 的关闭入口继续统一到 `PlatformModalCloseButton variant="platformIcon"`;`PuzzleResultView.tsx` 的关卡详情 / 发布弹窗、`RpgCreationResultActionBar.tsx` 的发布检查弹窗,以及 `PuzzleHistoryAssetPickerDialog.tsx` 的历史素材弹窗已迁移。像素风 runtime、drawer collapse、玩法规则面板和运行态专属 overlay 继续保留本地 close 语义,不把 `PlatformModalCloseButton` 硬塞进非平台 modal header 场景。
|
||||
19.3. creative-agent 首页的侧边栏菜单、账号入口、开启新对话、我的创作、首页激励 CTA 和 prompt suggestion 按钮迁移到 `PlatformIconButton` / `PlatformActionButton`;首页继续保留 `creative-agent-home__*` 本地 class 承接透明顶栏、抽屉和品牌化胶囊视觉,不把视觉回收和语义收口绑成一次大改。`Beta` 徽标和历史记录纯文本行暂保留本地实现,等出现更多同构轻量列表行后再评估是否抽新的共享 row primitive。
|
||||
19.4. 大鱼吃小鱼结果页 hero 的返回入口迁移到 `PlatformIconButton variant="darkMini"`,测试 / 发布动作迁移到 `PlatformActionButton surface="editorDark"`;结果页只保留测试运行、发布提交和文案状态语义,不再手写 hero 顶栏按钮壳。
|
||||
19.4.1. 大鱼吃小鱼结果页的发布失败弹层迁移到 `src/components/common/PlatformStatusDialog.tsx`;`PlatformStatusDialog` 补充自定义图标、可访问标签和动作按钮样式透传后,`BigFishResultView` 不再保留 `BigFishResultErrorModal` 内联的 `UnifiedConfirmDialog + PlatformIconBadge` 组合。结果页只保留失败文案和关闭回调,发布失败的状态图标、遮罩、白底面板和“知道了”主动作统一由共享状态弹层承接。验证命令:`npm run test -- src/components/common/PlatformStatusDialog.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`、`npm run typecheck`。
|
||||
|
||||
@@ -30,6 +30,7 @@ import { PlatformEmptyState } from './common/PlatformEmptyState';
|
||||
import { PlatformMediaFrame } from './common/PlatformMediaFrame';
|
||||
import { PlatformPillBadge } from './common/PlatformPillBadge';
|
||||
import { PlatformProgressBar } from './common/PlatformProgressBar';
|
||||
import { PlatformSegmentedTabs } from './common/PlatformSegmentedTabs';
|
||||
import { PlatformStatGrid } from './common/PlatformStatGrid';
|
||||
import { PlatformStatusMessage } from './common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from './common/PlatformSubpanel';
|
||||
@@ -833,6 +834,22 @@ export function CustomWorldEntityCatalog({
|
||||
1 +
|
||||
(pendingGeneratedEntity?.kind === 'landmark' ? 1 : 0),
|
||||
} satisfies Record<ResultTab, number>;
|
||||
const resultTabItems = useMemo(
|
||||
() =>
|
||||
RESULT_TABS.map((tab) => ({
|
||||
id: tab.id,
|
||||
ariaLabel: `${tab.label} ${counts[tab.id]}`,
|
||||
label: (
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">{tab.label}</div>
|
||||
<div className="mt-1 text-[10px] tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
{counts[tab.id]}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
})),
|
||||
[counts],
|
||||
);
|
||||
const worldStatItems = [
|
||||
{ label: '可扮演角色', value: profile.playableNpcs.length },
|
||||
{ label: '场景角色', value: profile.storyNpcs.length },
|
||||
@@ -974,22 +991,28 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
|
||||
<div className="platform-sticky-fade sticky top-0 z-10 -mx-1 space-y-3 px-1 pb-3 pt-1 backdrop-blur-sm xl:rounded-[1.75rem] xl:border xl:border-[var(--platform-subpanel-border)] xl:bg-white/70 xl:px-4 xl:py-3 xl:shadow-[0_16px_48px_rgba(112,57,30,0.08)]">
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide xl:pb-0">
|
||||
{RESULT_TABS.map((tab) => (
|
||||
<div key={tab.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActiveTabChange(tab.id)}
|
||||
className={`platform-tab px-3 py-2 text-left text-sm xl:min-w-[5.25rem] xl:px-4 xl:py-2 ${activeTab === tab.id ? 'platform-tab--active' : ''}`}
|
||||
>
|
||||
<div className="font-semibold">{tab.label}</div>
|
||||
<div className="mt-1 text-[10px] tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
{counts[tab.id]}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<PlatformSegmentedTabs
|
||||
items={resultTabItems}
|
||||
activeId={activeTab}
|
||||
onChange={onActiveTabChange}
|
||||
layout="scroll"
|
||||
gap="md"
|
||||
frame="bare"
|
||||
surface="transparent"
|
||||
size="sm"
|
||||
tone="neutral"
|
||||
semantics="tabs"
|
||||
ariaLabel="世界实体目录"
|
||||
className="pb-1 xl:pb-0"
|
||||
itemClassName={(_, active) =>
|
||||
[
|
||||
'platform-tab shrink-0 !min-h-0 !rounded-full !px-3 !py-2 xl:min-w-[5.25rem] xl:!px-4 xl:!py-2',
|
||||
active ? 'platform-tab--active' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
/>
|
||||
|
||||
{activeTab !== 'world' ? (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center xl:gap-3">
|
||||
|
||||
@@ -950,6 +950,37 @@ test('世界页基本设定编辑按钮打开基本设定编辑目标', async ()
|
||||
expect(handleEditTarget).toHaveBeenCalledWith({ kind: 'foundation' });
|
||||
});
|
||||
|
||||
test('实体目录 sticky rail 改用共享 segmented tabs 并保留计数标签', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleActiveTabChange = vi.fn();
|
||||
|
||||
render(
|
||||
<CustomWorldEntityCatalog
|
||||
profile={createProfile()}
|
||||
previewCharacters={[]}
|
||||
activeTab="world"
|
||||
onActiveTabChange={handleActiveTabChange}
|
||||
onEditTarget={() => {}}
|
||||
onProfileChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tablist = screen.getByRole('tablist', { name: '世界实体目录' });
|
||||
const worldTab = screen.getByRole('tab', { name: '世界 1' });
|
||||
const storyTab = screen.getByRole('tab', { name: '场景角色 1' });
|
||||
|
||||
expect(tablist.className).toContain('flex');
|
||||
expect(tablist.className).toContain('overflow-x-auto');
|
||||
expect(worldTab.getAttribute('aria-selected')).toBe('true');
|
||||
expect(storyTab.getAttribute('aria-selected')).toBe('false');
|
||||
expect(within(storyTab).getByText('场景角色')).toBeTruthy();
|
||||
expect(within(storyTab).getByText('1')).toBeTruthy();
|
||||
|
||||
await user.click(storyTab);
|
||||
|
||||
expect(handleActiveTabChange).toHaveBeenCalledWith('story');
|
||||
});
|
||||
|
||||
test('基本设定用分号拆分成标签展示', () => {
|
||||
const profile = {
|
||||
...createProfile(),
|
||||
|
||||
47
src/components/common/useMudPointConfirmController.test.tsx
Normal file
47
src/components/common/useMudPointConfirmController.test.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useMudPointConfirmController } from './useMudPointConfirmController';
|
||||
|
||||
describe('useMudPointConfirmController', () => {
|
||||
it('opens closes and confirms with the latest handler', () => {
|
||||
const firstConfirm = vi.fn();
|
||||
const secondConfirm = vi.fn();
|
||||
const { result, rerender } = renderHook(
|
||||
({ onConfirm }: { onConfirm: () => void }) =>
|
||||
useMudPointConfirmController(onConfirm),
|
||||
{
|
||||
initialProps: {
|
||||
onConfirm: firstConfirm,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.open).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.requestOpen();
|
||||
});
|
||||
expect(result.current.open).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.close();
|
||||
});
|
||||
expect(result.current.open).toBe(false);
|
||||
|
||||
rerender({ onConfirm: secondConfirm });
|
||||
|
||||
act(() => {
|
||||
result.current.requestOpen();
|
||||
});
|
||||
act(() => {
|
||||
result.current.confirm();
|
||||
});
|
||||
|
||||
expect(result.current.open).toBe(false);
|
||||
expect(firstConfirm).not.toHaveBeenCalled();
|
||||
expect(secondConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
29
src/components/common/useMudPointConfirmController.ts
Normal file
29
src/components/common/useMudPointConfirmController.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
/**
|
||||
* 泥点确认状态机只收口最小开关语义。
|
||||
* 业务页继续自己持有点数、文案、禁用条件和是否需要弹确认的判断。
|
||||
*/
|
||||
export function useMudPointConfirmController(onConfirm: () => void) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const requestOpen = useCallback(() => {
|
||||
setOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
const confirm = useCallback(() => {
|
||||
setOpen(false);
|
||||
onConfirm();
|
||||
}, [onConfirm]);
|
||||
|
||||
return {
|
||||
open,
|
||||
requestOpen,
|
||||
close,
|
||||
confirm,
|
||||
};
|
||||
}
|
||||
@@ -70,6 +70,7 @@ import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
import { PlatformTagEditor } from '../common/PlatformTagEditor';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import { PlatformUploadPreviewCard } from '../common/PlatformUploadPreviewCard';
|
||||
import { useMudPointConfirmController } from '../common/useMudPointConfirmController';
|
||||
import {
|
||||
MATCH3D_RUNTIME_BOARD_BASE_CLASS,
|
||||
MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS,
|
||||
@@ -2308,7 +2309,7 @@ function Match3DBatchAddItemsPanel({
|
||||
const parsedNames = normalizeMatch3DItemNameList(values);
|
||||
const isGenerating = generationState.phase === 'generating';
|
||||
const pointsCost = calculateMatch3DItemAssetsPointsCost(parsedNames.length);
|
||||
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
|
||||
const costConfirm = useMudPointConfirmController(onSubmit);
|
||||
|
||||
return (
|
||||
<Match3DModalShell title="批量新增物品" onClose={onClose}>
|
||||
@@ -2357,7 +2358,7 @@ function Match3DBatchAddItemsPanel({
|
||||
|
||||
<PlatformActionButton
|
||||
disabled={parsedNames.length <= 0 || isGenerating}
|
||||
onClick={() => setIsCostConfirmOpen(true)}
|
||||
onClick={costConfirm.requestOpen}
|
||||
size="md"
|
||||
fullWidth
|
||||
className="min-h-11 gap-2"
|
||||
@@ -2371,13 +2372,10 @@ function Match3DBatchAddItemsPanel({
|
||||
</PlatformActionButton>
|
||||
|
||||
<PlatformMudPointConfirmDialog
|
||||
open={isCostConfirmOpen}
|
||||
open={costConfirm.open}
|
||||
points={pointsCost}
|
||||
onClose={() => setIsCostConfirmOpen(false)}
|
||||
onConfirm={() => {
|
||||
setIsCostConfirmOpen(false);
|
||||
onSubmit();
|
||||
}}
|
||||
onClose={costConfirm.close}
|
||||
onConfirm={costConfirm.confirm}
|
||||
confirmDisabled={parsedNames.length <= 0 || isGenerating}
|
||||
showCloseButton={false}
|
||||
portal={false}
|
||||
@@ -2410,7 +2408,7 @@ function Match3DBatchRegenerateItemsPanel({
|
||||
const pointsCost = calculateMatch3DItemAssetsPointsCost(
|
||||
targetItemNames.length,
|
||||
);
|
||||
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
|
||||
const costConfirm = useMudPointConfirmController(onSubmit);
|
||||
|
||||
return (
|
||||
<Match3DModalShell title="批量重新生成物品" onClose={onClose}>
|
||||
@@ -2448,7 +2446,7 @@ function Match3DBatchRegenerateItemsPanel({
|
||||
|
||||
<PlatformActionButton
|
||||
disabled={targetItemNames.length <= 0 || isGenerating}
|
||||
onClick={() => setIsCostConfirmOpen(true)}
|
||||
onClick={costConfirm.requestOpen}
|
||||
size="md"
|
||||
fullWidth
|
||||
className="min-h-11 gap-2"
|
||||
@@ -2462,13 +2460,10 @@ function Match3DBatchRegenerateItemsPanel({
|
||||
</PlatformActionButton>
|
||||
|
||||
<PlatformMudPointConfirmDialog
|
||||
open={isCostConfirmOpen}
|
||||
open={costConfirm.open}
|
||||
points={pointsCost}
|
||||
onClose={() => setIsCostConfirmOpen(false)}
|
||||
onConfirm={() => {
|
||||
setIsCostConfirmOpen(false);
|
||||
onSubmit();
|
||||
}}
|
||||
onClose={costConfirm.close}
|
||||
onConfirm={costConfirm.confirm}
|
||||
confirmDisabled={targetItemNames.length <= 0 || isGenerating}
|
||||
showCloseButton={false}
|
||||
portal={false}
|
||||
|
||||
@@ -97,4 +97,52 @@ describe('PlatformProfileRechargeModal', () => {
|
||||
|
||||
expect(screen.getByText('暂无可购买套餐')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('uses shared segmented tabs for recharge type switching', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onTabChange = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformProfileRechargeModal
|
||||
center={{
|
||||
walletBalance: 0,
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
}}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
submittingProductId={null}
|
||||
nativePayment={null}
|
||||
activeTab="points"
|
||||
onTabChange={onTabChange}
|
||||
onClose={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onBuy={vi.fn()}
|
||||
onConfirmNativePayment={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tablist = screen.getByRole('tablist', { name: '充值类型' });
|
||||
const pointsTab = screen.getByRole('tab', { name: '泥点充值' });
|
||||
const membershipTab = screen.getByRole('tab', { name: '会员卡' });
|
||||
|
||||
expect(tablist.className).toContain('grid');
|
||||
expect(tablist.className).toContain('grid-cols-2');
|
||||
expect(pointsTab.getAttribute('aria-selected')).toBe('true');
|
||||
expect(membershipTab.getAttribute('aria-selected')).toBe('false');
|
||||
|
||||
await user.click(membershipTab);
|
||||
|
||||
expect(onTabChange).toHaveBeenCalledWith('membership');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
|
||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||
import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
import { PlatformProfileModalShell } from './PlatformProfileModalShell';
|
||||
@@ -24,6 +25,10 @@ import {
|
||||
} from '../rpg-entry/rpgEntryProfileFundsViewModel';
|
||||
|
||||
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
|
||||
const RECHARGE_TAB_ITEMS: Array<{ id: RechargeTab; label: string }> = [
|
||||
{ id: 'points', label: '泥点充值' },
|
||||
{ id: 'membership', label: '会员卡' },
|
||||
];
|
||||
|
||||
export type PlatformProfileRechargeModalProps = {
|
||||
center: ProfileRechargeCenterResponse | null;
|
||||
@@ -167,22 +172,30 @@ export function PlatformProfileRechargeModal({
|
||||
panelClassName="platform-recharge-modal !max-w-[34rem] rounded-[1.4rem]"
|
||||
bodyClassName="max-h-[min(76vh,36rem)] overflow-y-auto px-5 py-5"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange('points')}
|
||||
className={`platform-category-chip justify-center ${activeTab === 'points' ? 'platform-category-chip--active' : ''}`}
|
||||
>
|
||||
泥点充值
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange('membership')}
|
||||
className={`platform-category-chip justify-center ${activeTab === 'membership' ? 'platform-category-chip--active' : ''}`}
|
||||
>
|
||||
会员卡
|
||||
</button>
|
||||
</div>
|
||||
<PlatformSegmentedTabs
|
||||
items={RECHARGE_TAB_ITEMS}
|
||||
activeId={activeTab}
|
||||
onChange={onTabChange}
|
||||
columns="two"
|
||||
layout="grid"
|
||||
gap="sm"
|
||||
frame="bare"
|
||||
surface="transparent"
|
||||
size="sm"
|
||||
tone="neutral"
|
||||
semantics="tabs"
|
||||
ariaLabel="充值类型"
|
||||
itemClassName={(_, active) =>
|
||||
[
|
||||
'w-full !min-h-[2.25rem] !rounded-[0.78rem] !border !px-3 !text-sm !font-extrabold !shadow-none',
|
||||
active
|
||||
? '!border-[var(--platform-cool-border)] !bg-[var(--platform-cool-bg)] !text-[var(--platform-cool-text)]'
|
||||
: '!border-[var(--platform-subpanel-border)] !bg-[rgba(255,255,255,0.04)] !text-[var(--platform-text-base)] hover:!bg-[rgba(255,255,255,0.08)]',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
/>
|
||||
|
||||
<PlatformAsyncStatePanel
|
||||
errorState={
|
||||
|
||||
@@ -594,7 +594,7 @@ describe('PuzzleResultView', () => {
|
||||
expect(
|
||||
within(dialog).queryByRole('button', { name: /关卡测试/u }),
|
||||
).toBeNull();
|
||||
fireEvent.click(screen.getByLabelText('关闭'));
|
||||
fireEvent.click(screen.getByLabelText('关闭关卡详情'));
|
||||
expect(screen.getAllByText('第2关').length).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
@@ -741,7 +741,7 @@ describe('PuzzleResultView', () => {
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText('关闭'));
|
||||
fireEvent.click(screen.getByLabelText('关闭关卡详情'));
|
||||
|
||||
expect(
|
||||
within(screen.getByLabelText('拼图关卡列表')).getAllByText('生成中')
|
||||
@@ -790,11 +790,11 @@ describe('PuzzleResultView', () => {
|
||||
{ levelId: 'puzzle-level-1' },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('关闭'));
|
||||
fireEvent.click(screen.getByLabelText('关闭关卡详情'));
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByRole('button', { name: /新增关卡/u }));
|
||||
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
|
||||
fireEvent.click(screen.getByLabelText('关闭'));
|
||||
fireEvent.click(screen.getByLabelText('关闭关卡详情'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
|
||||
const publishDialog = screen.getByRole('dialog', { name: '发布拼图作品' });
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Play,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
@@ -28,6 +27,7 @@ import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import { PlatformIconBadge } from '../common/PlatformIconBadge';
|
||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
|
||||
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
|
||||
import { PlatformMudPointConfirmDialog } from '../common/PlatformMudPointConfirmDialog';
|
||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||
import { PlatformProgressBar } from '../common/PlatformProgressBar';
|
||||
@@ -652,10 +652,10 @@ function PuzzleLevelDetailDialog({
|
||||
<div className="min-w-0 truncate text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
{level.levelName || '关卡详情'}
|
||||
</div>
|
||||
<PlatformIconButton
|
||||
<PlatformModalCloseButton
|
||||
variant="platformIcon"
|
||||
label="关闭关卡详情"
|
||||
onClick={onClose}
|
||||
label="关闭"
|
||||
icon={<X className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -896,10 +896,10 @@ function PuzzlePublishDialog({
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
发布拼图作品
|
||||
</div>
|
||||
<PlatformIconButton
|
||||
<PlatformModalCloseButton
|
||||
variant="platformIcon"
|
||||
label="关闭发布拼图作品"
|
||||
onClick={onClose}
|
||||
label="关闭"
|
||||
icon={<X className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ test('RPG result publish dialog keeps generated and default cover source labels'
|
||||
|
||||
expect(screen.getByText('AI封面')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '关闭' }));
|
||||
await user.click(screen.getByRole('button', { name: '关闭发布作品' }));
|
||||
rerender(
|
||||
<RpgCreationResultActionBar
|
||||
editActionLabel="编辑设定"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
@@ -7,7 +6,7 @@ import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
|
||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
@@ -87,10 +86,10 @@ function PublishPanelDialog({
|
||||
发布前检查与封面设置
|
||||
</div>
|
||||
</div>
|
||||
<PlatformIconButton
|
||||
<PlatformModalCloseButton
|
||||
variant="platformIcon"
|
||||
label="关闭发布作品"
|
||||
onClick={onClose}
|
||||
label="关闭"
|
||||
icon={<X className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
|
||||
@@ -1606,7 +1606,7 @@ test('profile recharge modal posts membership goods virtual payment params in mi
|
||||
|
||||
renderProfileView();
|
||||
await openRechargeModal(user);
|
||||
await user.click(screen.getByRole('button', { name: '会员卡' }));
|
||||
await user.click(screen.getByRole('tab', { name: '会员卡' }));
|
||||
await user.click(await screen.findByRole('button', { name: /月卡/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -3766,6 +3766,14 @@ test('mobile discover keeps edutainment works in the last dedicated channel only
|
||||
true,
|
||||
);
|
||||
expect(discoverStage?.classList.contains('platform-page-stage')).toBe(false);
|
||||
expect(
|
||||
within(discoverPanel).getByRole('tablist', { name: '发现频道' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(discoverPanel).getByRole('tab', { name: '推荐' }).getAttribute(
|
||||
'aria-selected',
|
||||
),
|
||||
).toBe('true');
|
||||
|
||||
const channels = Array.from(
|
||||
discoverPanel.querySelectorAll('.platform-mobile-home-channel'),
|
||||
@@ -3774,18 +3782,23 @@ test('mobile discover keeps edutainment works in the last dedicated channel only
|
||||
expect(within(discoverPanel).getByText('普通拼图作品')).toBeTruthy();
|
||||
expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '今日' }));
|
||||
await user.click(screen.getByRole('tab', { name: '今日' }));
|
||||
expect(
|
||||
within(discoverPanel).getByRole('tab', { name: '今日' }).getAttribute(
|
||||
'aria-selected',
|
||||
),
|
||||
).toBe('true');
|
||||
expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '分类' }));
|
||||
await user.click(screen.getByRole('tab', { name: '分类' }));
|
||||
expect(screen.getByRole('button', { name: '儿童教育' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '寓教于乐' })).toBeTruthy();
|
||||
expect(screen.queryByRole('tab', { name: '寓教于乐' })).toBeTruthy();
|
||||
expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '排行' }));
|
||||
await user.click(screen.getByRole('tab', { name: '排行' }));
|
||||
expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '寓教于乐' }));
|
||||
await user.click(screen.getByRole('tab', { name: '寓教于乐' }));
|
||||
expect(
|
||||
within(discoverPanel).getByRole('button', {
|
||||
name: /儿童动作热身 Demo/u,
|
||||
@@ -3816,7 +3829,7 @@ test('desktop discover shows child motion demo in edutainment channel', async ()
|
||||
onOpenChildMotionDemo,
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||||
await user.click(screen.getByRole('button', { name: '寓教于乐' }));
|
||||
await user.click(screen.getByRole('tab', { name: '寓教于乐' }));
|
||||
|
||||
const warmupButton = screen.getByRole('button', { name: /热身关卡/u });
|
||||
expect(warmupButton).toBeTruthy();
|
||||
@@ -3889,7 +3902,7 @@ test('mobile discover keeps baby object match works in edutainment channel only'
|
||||
expect(within(discoverPanel).getByText('普通拼图作品')).toBeTruthy();
|
||||
expect(within(discoverPanel).queryByText('宝贝识物水果篮')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '寓教于乐' }));
|
||||
await user.click(screen.getByRole('tab', { name: '寓教于乐' }));
|
||||
const babyObjectMatchButton = within(discoverPanel).getByRole('button', {
|
||||
name: /宝贝识物水果篮/u,
|
||||
});
|
||||
@@ -5190,7 +5203,7 @@ test('mobile today channel only shows newly published works from today', async (
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||||
await user.click(screen.getByRole('button', { name: '今日' }));
|
||||
await user.click(screen.getByRole('tab', { name: '今日' }));
|
||||
const discoverPanel = document.getElementById('platform-tab-panel-category');
|
||||
if (!discoverPanel) {
|
||||
throw new Error('缺少发现面板');
|
||||
@@ -5295,7 +5308,7 @@ test('mobile home moves category shelf into game category channel', async () =>
|
||||
expect(screen.queryByRole('button', { name: '即点即玩' })).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||||
await user.click(screen.getByRole('button', { name: '分类' }));
|
||||
await user.click(screen.getByRole('tab', { name: '分类' }));
|
||||
|
||||
expect(screen.getAllByText('分类').length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('button', { name: /筛选/u })).toBeTruthy();
|
||||
@@ -5317,7 +5330,7 @@ test('mobile game category list orders works by composite public metric', async
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||||
await user.click(screen.getByRole('button', { name: '分类' }));
|
||||
await user.click(screen.getByRole('tab', { name: '分类' }));
|
||||
await user.click(screen.getByRole('button', { name: '奇幻' }));
|
||||
|
||||
const gameItems = Array.from(
|
||||
@@ -5349,7 +5362,7 @@ test('mobile game category filter dialog filters by play type', async () => {
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||||
await user.click(screen.getByRole('button', { name: '分类' }));
|
||||
await user.click(screen.getByRole('tab', { name: '分类' }));
|
||||
await user.click(screen.getByRole('button', { name: '奇幻' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: /奇幻拼图,试玩/u })).toBeTruthy();
|
||||
@@ -5384,7 +5397,7 @@ test('bottom category tab becomes ranking and switches ranking metrics', async (
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||||
await user.click(screen.getByRole('button', { name: '排行' }));
|
||||
await user.click(screen.getByRole('tab', { name: '排行' }));
|
||||
|
||||
expect(await screen.findByRole('tab', { name: '热门榜' })).toBeTruthy();
|
||||
expect(screen.getByRole('tab', { name: '改造榜' })).toBeTruthy();
|
||||
@@ -5418,7 +5431,7 @@ test('ranking rows limit displayed work name and show two short tags on the thir
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||||
await user.click(screen.getByRole('button', { name: '排行' }));
|
||||
await user.click(screen.getByRole('tab', { name: '排行' }));
|
||||
|
||||
const rankingPanel = document.getElementById('platform-tab-panel-category');
|
||||
expect(rankingPanel).toBeTruthy();
|
||||
|
||||
@@ -2743,11 +2743,27 @@ export function RpgEntryHomeView({
|
||||
: DISCOVER_CHANNELS,
|
||||
[edutainmentEntryEnabled],
|
||||
);
|
||||
const discoverChannelTabs = useMemo(
|
||||
() =>
|
||||
visibleDiscoverChannels.map((channel) => ({
|
||||
id: channel.id,
|
||||
label: channel.label,
|
||||
})),
|
||||
[visibleDiscoverChannels],
|
||||
);
|
||||
const categoryGroups = useMemo(
|
||||
() =>
|
||||
buildPublicCategoryGroups(generalFeaturedEntries, generalLatestEntries),
|
||||
[generalFeaturedEntries, generalLatestEntries],
|
||||
);
|
||||
const categoryGroupTabs = useMemo(
|
||||
() =>
|
||||
categoryGroups.map((group) => ({
|
||||
id: group.tag,
|
||||
label: group.tag,
|
||||
})),
|
||||
[categoryGroups],
|
||||
);
|
||||
const publicEntries = useMemo(
|
||||
() =>
|
||||
getPlatformPublicEntries(generalFeaturedEntries, generalLatestEntries),
|
||||
@@ -2821,6 +2837,13 @@ export function RpgEntryHomeView({
|
||||
const activeCategoryFilterCount = activeCategoryEntries.length;
|
||||
const categoryFilterApplied =
|
||||
categoryKindFilter !== DEFAULT_PLATFORM_CATEGORY_KIND_FILTER;
|
||||
const handleDiscoverChannelChange = useCallback((channel: DiscoverChannel) => {
|
||||
setDiscoverChannel(channel);
|
||||
}, []);
|
||||
const handleCategoryGroupChange = useCallback((tag: string) => {
|
||||
hasManualCategoryTagSelectionRef.current = true;
|
||||
setSelectedCategoryTag(tag);
|
||||
}, []);
|
||||
const visibleTabs = useMemo<PlatformHomeTab[]>(
|
||||
() =>
|
||||
isAuthenticated
|
||||
@@ -3684,21 +3707,28 @@ export function RpgEntryHomeView({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="platform-mobile-home-channelbar flex min-w-0 gap-4 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{visibleDiscoverChannels.map((channel) => {
|
||||
const active = discoverChannel === channel.id;
|
||||
return (
|
||||
<button
|
||||
key={channel.id}
|
||||
type="button"
|
||||
onClick={() => setDiscoverChannel(channel.id)}
|
||||
className={`platform-mobile-home-channel shrink-0 ${active ? 'platform-mobile-home-channel--active' : ''}`}
|
||||
>
|
||||
{channel.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<PlatformSegmentedTabs
|
||||
items={discoverChannelTabs}
|
||||
activeId={discoverChannel}
|
||||
onChange={handleDiscoverChannelChange}
|
||||
layout="scroll"
|
||||
gap="md"
|
||||
frame="bare"
|
||||
surface="transparent"
|
||||
size="sm"
|
||||
tone="neutral"
|
||||
semantics="tabs"
|
||||
ariaLabel="发现频道"
|
||||
className="platform-mobile-home-channelbar pb-1"
|
||||
itemClassName={(_, active) =>
|
||||
[
|
||||
'platform-mobile-home-channel shrink-0 !min-h-8 !rounded-none !border-0 !bg-transparent !px-0 !shadow-none hover:!bg-transparent',
|
||||
active ? 'platform-mobile-home-channel--active' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
/>
|
||||
|
||||
{platformError ? (
|
||||
<PlatformStatusMessage
|
||||
@@ -3737,25 +3767,26 @@ export function RpgEntryHomeView({
|
||||
</span>
|
||||
</button>
|
||||
<span className="platform-category-filter-divider" />
|
||||
<div className="platform-category-chip-scroll scrollbar-hide">
|
||||
{categoryGroups.map((group) => {
|
||||
const active = group.tag === activeCategoryGroup.tag;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={group.tag}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
hasManualCategoryTagSelectionRef.current = true;
|
||||
setSelectedCategoryTag(group.tag);
|
||||
}}
|
||||
className={`platform-category-chip ${active ? 'platform-category-chip--active' : ''}`}
|
||||
>
|
||||
{group.tag}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<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
|
||||
@@ -3901,22 +3932,28 @@ export function RpgEntryHomeView({
|
||||
|
||||
const desktopDiscoverContent: ReactNode = (
|
||||
<div className={DESKTOP_DISCOVER_PAGE_STAGE_CLASS}>
|
||||
<div className="platform-mobile-home-channelbar flex min-w-0 gap-4 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{visibleDiscoverChannels.map((channel) => {
|
||||
const active = discoverChannel === channel.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`desktop-${channel.id}`}
|
||||
type="button"
|
||||
onClick={() => setDiscoverChannel(channel.id)}
|
||||
className={`platform-mobile-home-channel shrink-0 ${active ? 'platform-mobile-home-channel--active' : ''}`}
|
||||
>
|
||||
{channel.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<PlatformSegmentedTabs
|
||||
items={discoverChannelTabs}
|
||||
activeId={discoverChannel}
|
||||
onChange={handleDiscoverChannelChange}
|
||||
layout="scroll"
|
||||
gap="md"
|
||||
frame="bare"
|
||||
surface="transparent"
|
||||
size="sm"
|
||||
tone="neutral"
|
||||
semantics="tabs"
|
||||
ariaLabel="发现频道"
|
||||
className="platform-mobile-home-channelbar pb-1"
|
||||
itemClassName={(_, active) =>
|
||||
[
|
||||
'platform-mobile-home-channel shrink-0 !min-h-8 !rounded-none !border-0 !bg-transparent !px-0 !shadow-none hover:!bg-transparent',
|
||||
active ? 'platform-mobile-home-channel--active' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
/>
|
||||
|
||||
{platformError ? (
|
||||
<PlatformStatusMessage
|
||||
@@ -3953,25 +3990,26 @@ export function RpgEntryHomeView({
|
||||
{activeCategoryFilterCount}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{categoryGroups.map((group) => {
|
||||
const active = group.tag === activeCategoryGroup.tag;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${group.tag}:desktop-discover-category`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
hasManualCategoryTagSelectionRef.current = true;
|
||||
setSelectedCategoryTag(group.tag);
|
||||
}}
|
||||
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
|
||||
>
|
||||
{group.tag}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<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}
|
||||
@@ -4768,25 +4806,26 @@ export function RpgEntryHomeView({
|
||||
{activeCategoryFilterCount}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{categoryGroups.map((group) => {
|
||||
const active = group.tag === activeCategoryGroup.tag;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${group.tag}:desktop-category`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
hasManualCategoryTagSelectionRef.current = true;
|
||||
setSelectedCategoryTag(group.tag);
|
||||
}}
|
||||
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
|
||||
>
|
||||
{group.tag}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<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}
|
||||
|
||||
@@ -68,7 +68,7 @@ test('selects a history asset from the picker', async () => {
|
||||
);
|
||||
|
||||
const historyButton = await screen.findByRole('button', {
|
||||
name: /选择.*历史图片/u,
|
||||
name: /选择.*的历史图片$/u,
|
||||
});
|
||||
fireEvent.click(historyButton);
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
@@ -9,7 +8,7 @@ import {
|
||||
import { formatPuzzleHistoryAssetCreatedAt } from '../../../services/puzzle-works/puzzleHistoryAsset';
|
||||
import { useAuthUi } from '../../auth/AuthUiContext';
|
||||
import { PlatformAssetPickerGrid } from '../../common/PlatformAssetPickerCard';
|
||||
import { PlatformIconButton } from '../../common/PlatformIconButton';
|
||||
import { PlatformModalCloseButton } from '../../common/PlatformModalCloseButton';
|
||||
|
||||
type PuzzleHistoryAssetPickerDialogProps = {
|
||||
isBusy: boolean;
|
||||
@@ -82,10 +81,10 @@ export function PuzzleHistoryAssetPickerDialog({
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
选择历史图片
|
||||
</div>
|
||||
<PlatformIconButton
|
||||
<PlatformModalCloseButton
|
||||
variant="platformIcon"
|
||||
label="关闭历史图片选择器"
|
||||
onClick={onClose}
|
||||
label="关闭"
|
||||
icon={<X className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Loader2, Sparkles, WandSparkles } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
CreateMatch3DSessionRequest,
|
||||
@@ -9,12 +9,13 @@ import type {
|
||||
} from '../../../../packages/shared/src/contracts/match3dAgent';
|
||||
import { PlatformActionButton } from '../../common/PlatformActionButton';
|
||||
import { PlatformFieldLabel } from '../../common/PlatformFieldLabel';
|
||||
import { PlatformMudPointConfirmDialog } from '../../common/PlatformMudPointConfirmDialog';
|
||||
import { PlatformPillBadge } from '../../common/PlatformPillBadge';
|
||||
import { PlatformSegmentedTabs } from '../../common/PlatformSegmentedTabs';
|
||||
import { PlatformStatusMessage } from '../../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../../common/PlatformSubpanel';
|
||||
import { PlatformTextField } from '../../common/PlatformTextField';
|
||||
import { PlatformMudPointConfirmDialog } from '../../common/PlatformMudPointConfirmDialog';
|
||||
import { useMudPointConfirmController } from '../../common/useMudPointConfirmController';
|
||||
|
||||
type Match3DCreationWorkspaceProps = {
|
||||
session: Match3DAgentSessionSnapshot | null;
|
||||
@@ -131,7 +132,6 @@ export function Match3DCreationWorkspace({
|
||||
const [formState, setFormState] = useState<Match3DFormState>(() =>
|
||||
resolveInitialFormState(session, initialFormPayload),
|
||||
);
|
||||
const [isPointCostConfirmOpen, setIsPointCostConfirmOpen] = useState(false);
|
||||
const appliedInitialFormKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -143,7 +143,6 @@ export function Match3DCreationWorkspace({
|
||||
|
||||
appliedInitialFormKeyRef.current = nextInitialFormKey;
|
||||
setFormState(resolveInitialFormState(session, initialFormPayload));
|
||||
setIsPointCostConfirmOpen(false);
|
||||
}, [initialFormPayload, session]);
|
||||
|
||||
const themeText = formState.themeText.trim();
|
||||
@@ -165,32 +164,40 @@ export function Match3DCreationWorkspace({
|
||||
[selectedDifficultyOption, themeText],
|
||||
);
|
||||
|
||||
const submitForm = () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPointCostConfirmOpen(true);
|
||||
};
|
||||
|
||||
const executeSubmitForm = () => {
|
||||
const executeSubmitForm = useCallback(() => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onCreateFromForm) {
|
||||
setIsPointCostConfirmOpen(false);
|
||||
onCreateFromForm(formPayload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session) {
|
||||
setIsPointCostConfirmOpen(false);
|
||||
onExecuteAction({
|
||||
action: 'match3d_compile_draft',
|
||||
generateClickSound: false,
|
||||
});
|
||||
}
|
||||
}, [canSubmit, formPayload, onCreateFromForm, onExecuteAction, session]);
|
||||
const {
|
||||
open: isPointCostConfirmOpen,
|
||||
requestOpen: requestPointCostConfirm,
|
||||
close: closePointCostConfirm,
|
||||
confirm: confirmPointCostConfirm,
|
||||
} = useMudPointConfirmController(executeSubmitForm);
|
||||
|
||||
useEffect(() => {
|
||||
closePointCostConfirm();
|
||||
}, [closePointCostConfirm, initialFormPayload, session]);
|
||||
|
||||
const submitForm = () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestPointCostConfirm();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -354,8 +361,8 @@ export function Match3DCreationWorkspace({
|
||||
<PlatformMudPointConfirmDialog
|
||||
open={isPointCostConfirmOpen}
|
||||
points={mudPointCost}
|
||||
onClose={() => setIsPointCostConfirmOpen(false)}
|
||||
onConfirm={executeSubmitForm}
|
||||
onClose={closePointCostConfirm}
|
||||
onConfirm={confirmPointCostConfirm}
|
||||
confirmDisabled={!canSubmit}
|
||||
overlayClassName="platform-modal-backdrop z-[80]"
|
||||
panelClassName="platform-remap-surface max-w-xs rounded-[1.35rem] shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { PuzzleAgentActionRequest } from '../../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type {
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type CreativeImageInputReferenceImage,
|
||||
} from '../../common/CreativeImageInputPanel';
|
||||
import { PlatformActionButton } from '../../common/PlatformActionButton';
|
||||
import { PlatformMudPointConfirmDialog } from '../../common/PlatformMudPointConfirmDialog';
|
||||
import { PlatformPillBadge } from '../../common/PlatformPillBadge';
|
||||
import { SquareImageCropModal } from '../../common/SquareImageCropModal';
|
||||
import {
|
||||
@@ -26,7 +27,7 @@ import {
|
||||
clampSquareImageCropRect,
|
||||
type SquareImageCropRect,
|
||||
} from '../../common/squareImageCropModel';
|
||||
import { PlatformMudPointConfirmDialog } from '../../common/PlatformMudPointConfirmDialog';
|
||||
import { useMudPointConfirmController } from '../../common/useMudPointConfirmController';
|
||||
import PuzzleHistoryAssetPickerDialog from '../shared/PuzzleHistoryAssetPickerDialog';
|
||||
import {
|
||||
normalizePuzzleImageModel,
|
||||
@@ -254,7 +255,6 @@ export function PuzzleCreationWorkspace({
|
||||
);
|
||||
const [cropState, setCropState] = useState<PuzzleImageCropState | null>(null);
|
||||
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
|
||||
const [isPointCostConfirmOpen, setIsPointCostConfirmOpen] = useState(false);
|
||||
const previousSessionIdRef = useRef<string | null>(
|
||||
session?.sessionId ?? null,
|
||||
);
|
||||
@@ -284,7 +284,6 @@ export function PuzzleCreationWorkspace({
|
||||
setReferenceImageError(null);
|
||||
setCropState(null);
|
||||
setIsHistoryPickerOpen(false);
|
||||
setIsPointCostConfirmOpen(false);
|
||||
}, [initialFormPayload, session]);
|
||||
|
||||
const pictureDescription = formState.pictureDescription.trim();
|
||||
@@ -476,6 +475,71 @@ export function PuzzleCreationWorkspace({
|
||||
setReferenceImageError(null);
|
||||
};
|
||||
|
||||
const executeSubmitForm = useCallback(() => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadPictureDescription = formState.aiRedraw
|
||||
? pictureDescription
|
||||
: pictureDescription || formState.referenceImageLabel || '上传拼图图片';
|
||||
const payload = {
|
||||
seedText: payloadPictureDescription,
|
||||
workDescription: payloadPictureDescription,
|
||||
pictureDescription: payloadPictureDescription,
|
||||
referenceImageSrc: mainReferenceImageSrcForPayload,
|
||||
referenceImageSrcs: promptReferenceImageSrcsForPayload,
|
||||
referenceImageAssetObjectId:
|
||||
formState.referenceImageAssetObjectId || null,
|
||||
referenceImageAssetObjectIds: promptReferenceAssetObjectIds,
|
||||
imageModel: formState.imageModel,
|
||||
aiRedraw: formState.aiRedraw,
|
||||
};
|
||||
|
||||
if (!session && onCreateFromForm) {
|
||||
onCreateFromForm(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
onExecuteAction({
|
||||
action: 'compile_puzzle_draft',
|
||||
promptText: payloadPictureDescription,
|
||||
workDescription: payloadPictureDescription,
|
||||
pictureDescription: payloadPictureDescription,
|
||||
referenceImageSrc: mainReferenceImageSrcForPayload,
|
||||
referenceImageSrcs: promptReferenceImageSrcsForPayload,
|
||||
referenceImageAssetObjectId:
|
||||
formState.referenceImageAssetObjectId || null,
|
||||
referenceImageAssetObjectIds: promptReferenceAssetObjectIds,
|
||||
imageModel: formState.imageModel,
|
||||
aiRedraw: formState.aiRedraw,
|
||||
candidateCount: 1,
|
||||
});
|
||||
}, [
|
||||
canSubmit,
|
||||
formState.aiRedraw,
|
||||
formState.imageModel,
|
||||
formState.referenceImageAssetObjectId,
|
||||
formState.referenceImageLabel,
|
||||
mainReferenceImageSrcForPayload,
|
||||
onCreateFromForm,
|
||||
onExecuteAction,
|
||||
pictureDescription,
|
||||
promptReferenceAssetObjectIds,
|
||||
promptReferenceImageSrcsForPayload,
|
||||
session,
|
||||
]);
|
||||
const {
|
||||
open: isPointCostConfirmOpen,
|
||||
requestOpen: requestPointCostConfirm,
|
||||
close: closePointCostConfirm,
|
||||
confirm: confirmPointCostConfirm,
|
||||
} = useMudPointConfirmController(executeSubmitForm);
|
||||
|
||||
useEffect(() => {
|
||||
closePointCostConfirm();
|
||||
}, [closePointCostConfirm, initialFormPayload, session]);
|
||||
|
||||
const updateCropState = (nextCrop: {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -538,56 +602,12 @@ export function PuzzleCreationWorkspace({
|
||||
}
|
||||
|
||||
if (formState.aiRedraw) {
|
||||
setIsPointCostConfirmOpen(true);
|
||||
requestPointCostConfirm();
|
||||
return;
|
||||
}
|
||||
|
||||
executeSubmitForm();
|
||||
};
|
||||
|
||||
const executeSubmitForm = () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadPictureDescription = formState.aiRedraw
|
||||
? pictureDescription
|
||||
: pictureDescription || formState.referenceImageLabel || '上传拼图图片';
|
||||
const payload = {
|
||||
seedText: payloadPictureDescription,
|
||||
workDescription: payloadPictureDescription,
|
||||
pictureDescription: payloadPictureDescription,
|
||||
referenceImageSrc: mainReferenceImageSrcForPayload,
|
||||
referenceImageSrcs: promptReferenceImageSrcsForPayload,
|
||||
referenceImageAssetObjectId:
|
||||
formState.referenceImageAssetObjectId || null,
|
||||
referenceImageAssetObjectIds: promptReferenceAssetObjectIds,
|
||||
imageModel: formState.imageModel,
|
||||
aiRedraw: formState.aiRedraw,
|
||||
};
|
||||
|
||||
if (!session && onCreateFromForm) {
|
||||
setIsPointCostConfirmOpen(false);
|
||||
onCreateFromForm(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPointCostConfirmOpen(false);
|
||||
onExecuteAction({
|
||||
action: 'compile_puzzle_draft',
|
||||
promptText: payloadPictureDescription,
|
||||
workDescription: payloadPictureDescription,
|
||||
pictureDescription: payloadPictureDescription,
|
||||
referenceImageSrc: mainReferenceImageSrcForPayload,
|
||||
referenceImageSrcs: promptReferenceImageSrcsForPayload,
|
||||
referenceImageAssetObjectId:
|
||||
formState.referenceImageAssetObjectId || null,
|
||||
referenceImageAssetObjectIds: promptReferenceAssetObjectIds,
|
||||
imageModel: formState.imageModel,
|
||||
aiRedraw: formState.aiRedraw,
|
||||
candidateCount: 1,
|
||||
});
|
||||
};
|
||||
const removeReferenceImage = () => {
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
@@ -750,8 +770,8 @@ export function PuzzleCreationWorkspace({
|
||||
<PlatformMudPointConfirmDialog
|
||||
open={isPointCostConfirmOpen}
|
||||
points={mudPointCost}
|
||||
onClose={() => setIsPointCostConfirmOpen(false)}
|
||||
onConfirm={executeSubmitForm}
|
||||
onClose={closePointCostConfirm}
|
||||
onConfirm={confirmPointCostConfirm}
|
||||
confirmDisabled={!canSubmit}
|
||||
overlayClassName="platform-modal-backdrop z-[80]"
|
||||
panelClassName="platform-remap-surface max-w-xs rounded-[1.35rem] shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
|
||||
Reference in New Issue
Block a user