优化画布泥点入口

让画布顶部泥点余额按钮复用创作页充值和兑换码入口

抽取账户充值支付结果与确认弹窗组件供创作页和画布页共用

补充画布泥点入口回归测试和编辑器技术文档
This commit is contained in:
2026-06-22 21:04:32 +08:00
parent 7fe4a10d19
commit 25421140bf
10 changed files with 391 additions and 192 deletions
@@ -9,10 +9,11 @@
- 主站新增 `/editor/canvas` 路由,进入独立图片画布编辑器阶段。
- 主站新增 `/project` 项目页,从“我的”页项目入口进入,展示当前用户所有图片画布工程;点击项目进入 `/editor/canvas?projectid=<projectId>`
- 创作 Tab 顶部提供编辑器入口,入口只负责跳转,不参与玩法创作链路。
- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧常驻展示当前账号泥点余额,样式对齐创作主页顶部钱包 chip。
- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧常驻展示当前账号泥点余额,样式对齐创作主页顶部钱包 chip,点击后复用创作主页右上角泥点入口的账户充值 / 兑换码弹窗与支付确认反馈
- 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。
- 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。
- 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。
- 画布 chrome 的边框、hover / 选中态、吸附 / 框选参考线、生成类按钮和通用 active 控件使用陶泥儿暖色主题(以 `--platform-accent`、陶土橙主按钮和深棕文字为基准),不得回退为黑色或蓝色主题;元素类型自身的识别色可继续保留。
- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸;无 `assetKind` 的素材右上角显示 `未知` 标签,点击标签弹出独立标签选择菜单并可写回图层 `assetKind`,不能触发图层选择 / 拖拽事件;图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。
- 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成图片、生成视频、生成音乐、生成规范、生成角色形象、生成图标素材、生成 UI 设计图。底部栏不再展示文字工具、形状标注工具和导出工具;上传与生成图片之间、生成音乐与生成规范之间各有一个半图标高度分割线。
- 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。
@@ -3371,7 +3371,6 @@ describe('ImageCanvasEditorView generation integration', () => {
if (!metadataCornerButton) {
throw new Error('metadata corner button should exist');
}
expect(metadataCornerButton.className).toContain('bg-black/55');
expect(metadataCornerButton.className).toContain(
'image-canvas-editor__metadata-corner',
);
@@ -109,6 +109,7 @@ function createTopbarProps(): ImageCanvasTopbarViewProps {
resetProjectRenameError: vi.fn(),
exportCanvasAssets: vi.fn(),
onOpenShortcuts: vi.fn(),
onOpenWallet: vi.fn(),
};
}
@@ -205,6 +205,37 @@ describe('ImageCanvasEditorView', () => {
});
});
it('opens the same account wallet entry from the canvas topbar mud point button', async () => {
render(
<AuthUiContext.Provider
value={createAuthValue({
user: {
id: 'user-1',
publicUserCode: 'U001',
displayName: '测试用户',
avatarUrl: null,
phoneNumberMasked: '138****0000',
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
canAccessProtectedData: true,
})}
>
<ImageCanvasEditorView />
</AuthUiContext.Provider>,
);
const walletButton = await screen.findByRole('button', {
name: '泥点余额 1,234泥点',
});
fireEvent.click(walletButton);
expect(await screen.findByRole('dialog', { name: '兑换码' })).toBeTruthy();
expect(screen.getByPlaceholderText('输入兑换码')).toBeTruthy();
});
it('opens the login modal immediately when entering the editor while logged out', async () => {
const openLoginModal = vi.fn();
@@ -329,6 +360,7 @@ describe('ImageCanvasEditorView', () => {
originalHeight: 1024,
zIndex: 3,
sourceType: 'generated',
sourceResourceId: 'resource-generated',
prompt: '明亮主视觉',
model: 'gpt-image-2',
provider: 'VectorEngine',
@@ -347,6 +379,7 @@ describe('ImageCanvasEditorView', () => {
originalHeight: 120,
zIndex: 4,
sourceType: 'generated',
sourceResourceId: 'resource-failed',
},
],
resources: [],
@@ -376,7 +409,8 @@ describe('ImageCanvasEditorView', () => {
});
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url: string) => {
if (url === '/generated-ok.png') {
const pathname = new URL(url, 'http://localhost').pathname;
if (pathname === '/generated-ok.png') {
return new Response(new Blob(['generated'], { type: 'image/png' }));
}
return new Response(null, { status: 404 });
@@ -14,9 +14,17 @@ import {
type EditorAssetSnapshot,
loadEditorGenerationPricing,
} from '../../services/image-editor/editorProjectClient';
import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform';
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal';
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
import {
PlatformRechargePaymentConfirmationMask,
PlatformRechargePaymentResultDialog,
} from '../platform-entry/PlatformRechargePaymentStatusDialogs';
import { usePlatformProfileCenterController } from '../platform-entry/usePlatformProfileCenterController';
import { formatDashboardCount } from '../rpg-entry/rpgEntryProfileDashboardPresentation';
import {
canvasAssetKindOrNull,
@@ -93,6 +101,7 @@ export function ImageCanvasEditorView() {
const publicationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const selectedLayerIdRef = useRef<string | null>(null);
const showRechargeEntry = shouldShowRechargeEntry();
useEffect(() => {
let isMounted = true;
@@ -167,6 +176,56 @@ export function ImageCanvasEditorView() {
},
[],
);
const refreshEditorWalletBalance = useCallback(() => {
if (!authUiRef.current?.canAccessProtectedData || !authUiRef.current.user) {
return;
}
void getPlatformProfileDashboard({
authImpact: 'local',
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
})
.then((dashboard) => {
setWalletBalanceLabel(
`${formatDashboardCount(dashboard.walletBalance)}泥点`,
);
})
.catch(() => undefined);
}, []);
const {
activeRechargeTab,
buyRechargeProduct,
confirmNativeWechatPayment,
isLoadingRechargeCenter,
isRechargeOpen,
isRewardCodeOpen,
isSubmittingRewardCode,
loadRechargeCenter,
nativeWechatPayment,
openRechargeOrRewardCodeModal,
rechargeCenter,
rechargeError,
rechargePaymentResult,
rewardCodeError,
rewardCodeInput,
rewardCodeSuccess,
setActiveRechargeTab,
setIsRechargeOpen,
setIsRewardCodeOpen,
setRechargePaymentResult,
setRewardCodeInput,
submittingRechargeProductId,
submitRewardCode,
wechatRechargeOrderConfirmationState,
} = usePlatformProfileCenterController({
activeTab: 'editor-canvas',
isAuthenticated: Boolean(authUi?.user),
showRechargeEntry,
onRechargeSuccess: refreshEditorWalletBalance,
requestLogin: () => authUiRef.current?.openLoginModal(),
currentUser: authUi?.user ?? null,
});
useEffect(() => {
if (!authUi || authUi.user || authUi.canAccessProtectedData) {
@@ -1256,6 +1315,7 @@ export function ImageCanvasEditorView() {
resetProjectRenameError,
exportCanvasAssets,
onOpenShortcuts: () => setIsShortcutDialogOpen(true),
onOpenWallet: openRechargeOrRewardCodeModal,
};
const stageProps = {
canvasViewportRef,
@@ -1400,6 +1460,43 @@ export function ImageCanvasEditorView() {
open={isShortcutDialogOpen}
onClose={() => setIsShortcutDialogOpen(false)}
/>
{isRewardCodeOpen ? (
<PlatformProfileRewardCodeRedeemModal
value={rewardCodeInput}
isSubmitting={isSubmittingRewardCode}
error={rewardCodeError}
success={rewardCodeSuccess}
onChange={setRewardCodeInput}
onSubmit={submitRewardCode}
onClose={() => setIsRewardCodeOpen(false)}
/>
) : null}
{isRechargeOpen ? (
<PlatformProfileRechargeModal
center={rechargeCenter}
isLoading={isLoadingRechargeCenter}
error={rechargeError}
submittingProductId={submittingRechargeProductId}
nativePayment={nativeWechatPayment}
activeTab={activeRechargeTab}
onTabChange={setActiveRechargeTab}
onClose={() => setIsRechargeOpen(false)}
onRetry={loadRechargeCenter}
onBuy={buyRechargeProduct}
onConfirmNativePayment={confirmNativeWechatPayment}
/>
) : null}
{rechargePaymentResult ? (
<PlatformRechargePaymentResultDialog
result={rechargePaymentResult}
onClose={() => setRechargePaymentResult(null)}
/>
) : null}
{wechatRechargeOrderConfirmationState ? (
<PlatformRechargePaymentConfirmationMask
orderId={wechatRechargeOrderConfirmationState.orderId}
/>
) : null}
</>
);
}
@@ -49,6 +49,7 @@ function renderTopbar(
resetProjectRenameError: vi.fn(),
exportCanvasAssets: vi.fn(),
onOpenShortcuts: vi.fn(),
onOpenWallet: vi.fn(),
...overrides,
};
@@ -77,16 +78,21 @@ describe('ImageCanvasTopbarView', () => {
});
it('shows the current mud point balance in the topbar', () => {
renderTopbar({
const props = renderTopbar({
walletBalanceLabel: '1.2万泥点',
});
const walletChip = screen.getByLabelText('泥点余额 1.2万泥点');
const walletChip = screen.getByRole('button', {
name: '泥点余额 1.2万泥点',
});
expect(walletChip.textContent).toBe('1.2万泥点');
expect(walletChip.querySelector('img')?.getAttribute('src')).toBe(
'/creation-home/topbar-wallet.png',
);
fireEvent.click(walletChip);
expect(props.onOpenWallet).toHaveBeenCalledTimes(1);
});
it('submits, resets, and cancels project rename edits', () => {
@@ -132,6 +138,7 @@ describe('ImageCanvasTopbarView', () => {
resetProjectRenameError={vi.fn()}
exportCanvasAssets={exportCanvasAssets}
onOpenShortcuts={vi.fn()}
onOpenWallet={vi.fn()}
/>,
);
@@ -163,6 +170,7 @@ describe('ImageCanvasTopbarView', () => {
resetProjectRenameError={vi.fn()}
exportCanvasAssets={exportCanvasAssets}
onOpenShortcuts={vi.fn()}
onOpenWallet={vi.fn()}
/>,
);
@@ -32,6 +32,7 @@ export type ImageCanvasTopbarViewProps = {
resetProjectRenameError: () => void;
exportCanvasAssets: () => void | Promise<void>;
onOpenShortcuts: () => void;
onOpenWallet: () => void;
};
export function ImageCanvasTopbarView({
@@ -53,6 +54,7 @@ export function ImageCanvasTopbarView({
resetProjectRenameError,
exportCanvasAssets,
onOpenShortcuts,
onOpenWallet,
}: ImageCanvasTopbarViewProps) {
const hasExportableLayer = layers.some(
(layer) => layer.src.trim().length > 0,
@@ -167,8 +169,10 @@ export function ImageCanvasTopbarView({
{assetExportStatus.message}
</PlatformStatusMessage>
) : null}
<div
<button
type="button"
className="image-canvas-editor__wallet-chip"
onClick={onOpenWallet}
aria-label={walletAriaLabel}
aria-busy={isWalletBalanceLoading}
aria-live="polite"
@@ -180,7 +184,7 @@ export function ImageCanvasTopbarView({
draggable={false}
/>
<span>{walletDisplayLabel}</span>
</div>
</button>
</div>
</div>
);
@@ -0,0 +1,53 @@
import { PlatformAcknowledgeStatusDialog } from '../common/PlatformAcknowledgeStatusDialog';
import { PlatformStatusDialog } from '../common/PlatformStatusDialog';
import type { RechargePaymentResult } from './usePlatformProfileCenterController';
const PROFILE_MODAL_OVERLAY_CLASS =
'platform-modal-backdrop !items-center !justify-center !px-4 !py-6';
export function PlatformRechargePaymentResultDialog({
result,
onClose,
}: {
result: RechargePaymentResult;
onClose: () => void;
}) {
const status =
result.kind === 'success'
? 'success'
: result.kind === 'pending'
? 'loading'
: result.kind === 'cancel'
? 'cancel'
: 'error';
return (
<PlatformAcknowledgeStatusDialog
status={status}
title={result.title}
description={result.message}
onClose={onClose}
actionSurface="profile"
zIndexClassName="z-[90]"
overlayClassName={PROFILE_MODAL_OVERLAY_CLASS}
/>
);
}
export function PlatformRechargePaymentConfirmationMask({
orderId,
}: {
orderId: string;
}) {
return (
<PlatformStatusDialog
status="confirming"
title="正在确认支付"
description={`订单 ${orderId} 正在同步到账状态,请先停留在当前页面。`}
onClose={() => undefined}
closeDisabled
zIndexClassName="z-[95]"
overlayClassName={PROFILE_MODAL_OVERLAY_CLASS}
/>
);
}
+6 -51
View File
@@ -74,7 +74,6 @@ import {
import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformAcknowledgeStatusDialog } from '../common/PlatformAcknowledgeStatusDialog';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
import { LegalDocumentModal } from '../common/LegalDocumentModal';
@@ -95,7 +94,6 @@ import {
PlatformOptionSegment,
PlatformUnderlineTabRail,
} from '../common/PlatformSegmentedTabPresets';
import { PlatformStatusDialog } from '../common/PlatformStatusDialog';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformTextField } from '../common/PlatformTextField';
@@ -134,13 +132,16 @@ import { PlatformProfileQrScannerModal } from '../platform-entry/PlatformProfile
import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal';
import { PlatformProfileReferralModal } from '../platform-entry/PlatformProfileReferralModal';
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
import {
PlatformRechargePaymentConfirmationMask,
PlatformRechargePaymentResultDialog,
} from '../platform-entry/PlatformRechargePaymentStatusDialogs';
import { PlatformProfileTaskCenterModal } from '../platform-entry/PlatformProfileTaskCenterModal';
import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal';
import type { ExternalGenerationQueueStatus } from '../platform-entry/platformExternalGenerationQueueStatusModel';
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
import {
type ProfilePopupPanel,
type RechargePaymentResult,
usePlatformProfileCenterController,
} from '../platform-entry/usePlatformProfileCenterController';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
@@ -2465,9 +2466,6 @@ function cropAvatarImage(params: {
});
}
const PROFILE_MODAL_OVERLAY_CLASS =
'platform-modal-backdrop !items-center !justify-center !px-4 !py-6';
function ProfileNicknameModal({
value,
error,
@@ -2536,49 +2534,6 @@ function ProfileNicknameModal({
);
}
function RechargePaymentResultModal({
result,
onClose,
}: {
result: RechargePaymentResult;
onClose: () => void;
}) {
const status =
result.kind === 'success'
? 'success'
: result.kind === 'pending'
? 'loading'
: result.kind === 'cancel'
? 'cancel'
: 'error';
return (
<PlatformAcknowledgeStatusDialog
status={status}
title={result.title}
description={result.message}
onClose={onClose}
actionSurface="profile"
zIndexClassName="z-[90]"
overlayClassName={PROFILE_MODAL_OVERLAY_CLASS}
/>
);
}
function RechargePaymentConfirmationMask({ orderId }: { orderId: string }) {
return (
<PlatformStatusDialog
status="confirming"
title="正在确认支付"
description={`订单 ${orderId} 正在同步到账状态,请先停留在当前页面。`}
onClose={() => undefined}
closeDisabled
zIndexClassName="z-[95]"
overlayClassName={PROFILE_MODAL_OVERLAY_CLASS}
/>
);
}
export function RpgEntryHomeView({
activeTab,
activeDesktopNavTab,
@@ -4920,14 +4875,14 @@ export function RpgEntryHomeView({
/>
) : null;
const rechargePaymentResultModal: ReactNode = rechargePaymentResult ? (
<RechargePaymentResultModal
<PlatformRechargePaymentResultDialog
result={rechargePaymentResult}
onClose={() => setRechargePaymentResult(null)}
/>
) : null;
const rechargePaymentConfirmationMask: ReactNode =
wechatRechargeOrderConfirmationState ? (
<RechargePaymentConfirmationMask
<PlatformRechargePaymentConfirmationMask
orderId={wechatRechargeOrderConfirmationState.orderId}
/>
) : null;
+180 -133
View File
File diff suppressed because it is too large Load Diff