继续收口平台分段与泥点确认

新增泥点确认状态机共享 hook 并接入拼图与抓大鹅工作台

将首页发现页与个人中心剩余切换条收口到 PlatformSegmentedTabs

统一平台弹窗 header 关闭入口并补齐相关测试

更新前端组件收口文档与团队决策记录
This commit is contained in:
2026-06-11 01:30:13 +08:00
parent 94122583ac
commit 0a4ccdf45c
19 changed files with 511 additions and 242 deletions

View File

@@ -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 敲木鱼发布后作品架与推荐流刷新口径

View File

@@ -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`

View File

@@ -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">

View File

@@ -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(),

View 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);
});
});

View 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,
};
}

View File

@@ -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}

View File

@@ -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');
});
});

View File

@@ -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={

View File

@@ -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: '发布拼图作品' });

View File

@@ -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>

View File

@@ -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="编辑设定"

View File

@@ -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">

View File

@@ -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();

View File

@@ -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}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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)]"

View File

@@ -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)]"