Merge remote-tracking branch 'origin/master' into editor-agent-refactored

# Conflicts:
#	server-rs/crates/api-server/src/editor_generation_queue.rs
#	src/components/image-editor/ImageCanvasEditorView.test.tsx
#	src/components/image-editor/ImageCanvasEditorView.tsx
This commit is contained in:
2026-07-13 14:31:08 +08:00
486 changed files with 14293 additions and 4581 deletions
+6 -2
View File
@@ -161,7 +161,9 @@ describe('App title sync', () => {
test('主站阶段变化会同步浏览器与宿主标题', () => {
renderApp();
expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith('陶泥儿');
expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith(
'陶泥儿 Genarrative|游戏美术AI创作工具与美术Agent工作台',
);
act(() => {
fireEvent.click(screen.getByRole('button', { name: '打开拼图创作' }));
@@ -187,7 +189,9 @@ describe('App title sync', () => {
fireEvent.click(screen.getByRole('button', { name: '退出 RPG' }));
});
expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith('陶泥儿');
expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith(
'陶泥儿 Genarrative|游戏美术AI创作工具与美术Agent工作台',
);
});
test('启动时回读宿主 runtime 后刷新壳能力 UI', async () => {
@@ -30,7 +30,15 @@ test('renders auth modal shell with platform theme and auth card chrome', () =>
expect(dialog.className).toContain('!max-w-md');
expect(within(dialog).getByText('登录表单')).toBeTruthy();
fireEvent.click(dialog.parentElement as HTMLElement);
const backdrop = dialog.parentElement as HTMLElement;
fireEvent.pointerDown(within(dialog).getByText('登录表单'));
fireEvent.pointerUp(backdrop);
fireEvent.click(backdrop);
expect(onClose).not.toHaveBeenCalled();
fireEvent.pointerDown(backdrop);
fireEvent.pointerUp(backdrop);
fireEvent.click(backdrop);
expect(onClose).toHaveBeenCalledTimes(1);
});
@@ -0,0 +1,120 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { PlatformMudPointWalletEntry } from './PlatformMudPointWalletEntry';
import { formatMudPointCount } from './platformMudPointWalletModel';
const breakdown = {
totalPoints: 207,
permanentPoints: 100,
limitedPoints: 80,
limitedExpiresAt: '2026-07-06T16:00:00Z',
dailyFreePoints: 27,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-07-12T16:00:00Z',
};
test('formats mud point counts consistently', () => {
expect(formatMudPointCount(12_345)).toBe('12,345');
expect(formatMudPointCount(12_345, true)).toBe('1.2万');
});
test('shows only permanent and daily free points in the shared wallet panel', async () => {
const user = userEvent.setup();
const onRequestDetails = vi.fn();
const onRecharge = vi.fn();
const onOpenLedger = vi.fn();
render(
<PlatformMudPointWalletEntry
balance={999}
breakdown={breakdown}
onRequestDetails={onRequestDetails}
onRecharge={onRecharge}
onOpenLedger={onOpenLedger}
/>,
);
const balanceButton = screen.getByRole('button', { name: '泥点 207' });
await user.hover(balanceButton);
const details = screen.getByRole('dialog', { name: '泥点账户详情' });
expect(details.className).toContain('rounded-[1.12rem]');
expect(within(details).getByText('不限时泥点')).toBeTruthy();
expect(within(details).getByText('按量充值、兑换码获得')).toBeTruthy();
expect(within(details).getByText('100')).toBeTruthy();
expect(within(details).queryByText('限时泥点')).toBeNull();
expect(within(details).queryByText('2026-07-07 到期')).toBeNull();
expect(within(details).getByText('每日免费泥点')).toBeTruthy();
expect(within(details).getByText('27')).toBeTruthy();
expect(within(details).getByText('每天重置为 20 泥点')).toBeTruthy();
expect(onRequestDetails).not.toHaveBeenCalled();
await user.click(within(details).getByRole('button', { name: '使用详情' }));
expect(onOpenLedger).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole('button', { name: '充值' }));
expect(onRecharge).toHaveBeenCalledTimes(1);
});
test('keeps the desktop panel open while moving across the gap without click pinning', () => {
vi.useFakeTimers();
try {
render(
<PlatformMudPointWalletEntry
balance={207}
breakdown={breakdown}
onRequestDetails={vi.fn()}
onRecharge={vi.fn()}
onOpenLedger={vi.fn()}
/>,
);
const balanceButton = screen.getByRole('button', { name: '泥点 207' });
const root = balanceButton.closest('.platform-mud-point-wallet-entry');
expect(root).toBeTruthy();
fireEvent.mouseEnter(balanceButton);
const details = screen.getByRole('dialog', { name: '泥点账户详情' });
fireEvent.mouseLeave(root as HTMLElement, { relatedTarget: null });
fireEvent.mouseEnter(details);
act(() => vi.advanceTimersByTime(120));
expect(screen.getByRole('dialog', { name: '泥点账户详情' })).toBeTruthy();
balanceButton.focus();
fireEvent.click(balanceButton);
expect(screen.getByRole('dialog', { name: '泥点账户详情' })).toBeTruthy();
fireEvent.mouseLeave(root as HTMLElement, { relatedTarget: null });
act(() => vi.advanceTimersByTime(120));
expect(screen.queryByRole('dialog', { name: '泥点账户详情' })).toBeNull();
} finally {
vi.useRealTimers();
}
});
test('requests the balance breakdown when a compact entry opens', async () => {
const user = userEvent.setup();
const onRequestDetails = vi.fn();
render(
<PlatformMudPointWalletEntry
balance={20}
breakdown={null}
isLoading={false}
variant="mobile"
onRequestDetails={onRequestDetails}
onRecharge={vi.fn()}
onOpenLedger={vi.fn()}
/>,
);
await user.click(screen.getByRole('button', { name: '泥点 20' }));
expect(onRequestDetails).toHaveBeenCalledTimes(1);
expect(screen.getByText('余额明细暂不可用')).toBeTruthy();
});
@@ -0,0 +1,269 @@
import { ChevronRight, ReceiptText } from 'lucide-react';
import {
type FocusEvent,
type MouseEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import type { ProfileMudPointBalance } from '../../../packages/shared/src/contracts/runtime';
import { formatMudPointCount } from './platformMudPointWalletModel';
const MUD_POINT_ICON_SRC = '/creation-home/topbar-wallet.png';
export type PlatformMudPointWalletEntryProps = {
balance: number | null;
breakdown?: ProfileMudPointBalance | null;
isLoading?: boolean;
error?: string | null;
variant?: 'desktop' | 'mobile' | 'editor';
className?: string;
onRequestDetails: () => void;
onRecharge: () => void;
onOpenLedger: () => void;
};
function MudPointBalanceRow({
label,
points,
detail,
}: {
label: string;
points: number;
detail?: string | null;
}) {
return (
<div className="flex min-h-[3.25rem] items-center justify-between gap-4 border-t border-[var(--platform-subpanel-border)] px-4 py-2.5">
<div className="min-w-0">
<div className="text-[13px] font-bold text-[var(--platform-text-strong)]">
{label}
</div>
{detail ? (
<div className="mt-0.5 truncate text-[11px] text-[var(--platform-text-soft)]">
{detail}
</div>
) : null}
</div>
<div className="shrink-0 text-[15px] font-black tabular-nums text-[var(--platform-text-strong)]">
{formatMudPointCount(points)}
</div>
</div>
);
}
export function PlatformMudPointWalletEntry({
balance,
breakdown,
isLoading = false,
variant = 'desktop',
className,
onRequestDetails,
onRecharge,
onOpenLedger,
}: PlatformMudPointWalletEntryProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const isOpenRef = useRef(false);
const closeTimerRef = useRef<number | null>(null);
const [isOpen, setIsOpen] = useState(false);
const isCompact = variant === 'mobile';
const displayedBalance = breakdown?.totalPoints ?? balance;
const balanceLabel =
displayedBalance === null
? '--'
: formatMudPointCount(displayedBalance, true);
const exactBalanceLabel =
displayedBalance === null ? '--' : formatMudPointCount(displayedBalance);
const cancelPendingClose = useCallback(() => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
const closeDetails = useCallback(() => {
cancelPendingClose();
isOpenRef.current = false;
setIsOpen(false);
}, [cancelPendingClose]);
const requestAndOpen = useCallback(() => {
cancelPendingClose();
if (isOpenRef.current) {
return;
}
isOpenRef.current = true;
setIsOpen(true);
if (!breakdown && !isLoading) {
onRequestDetails();
}
}, [breakdown, cancelPendingClose, isLoading, onRequestDetails]);
useEffect(() => cancelPendingClose, [cancelPendingClose]);
useEffect(() => {
if (!isOpen) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) {
closeDetails();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeDetails();
}
};
document.addEventListener('pointerdown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('pointerdown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [closeDetails, isOpen]);
const closeAfterFocusLeaves = (event: FocusEvent<HTMLDivElement>) => {
const nextTarget = event.relatedTarget;
if (
!(nextTarget instanceof Node) ||
!event.currentTarget.contains(nextTarget)
) {
closeDetails();
}
};
const closeAfterPointerLeaves = (event: MouseEvent<HTMLDivElement>) => {
const nextTarget = event.relatedTarget;
if (
nextTarget instanceof Node &&
event.currentTarget.contains(nextTarget)
) {
return;
}
cancelPendingClose();
closeTimerRef.current = window.setTimeout(() => {
closeTimerRef.current = null;
closeDetails();
}, 120);
};
return (
<div
ref={rootRef}
className={`platform-mud-point-wallet-entry relative shrink-0 ${className ?? ''}`}
onMouseEnter={isCompact ? undefined : requestAndOpen}
onMouseLeave={isCompact ? undefined : closeAfterPointerLeaves}
onFocusCapture={isCompact ? undefined : requestAndOpen}
onBlurCapture={closeAfterFocusLeaves}
>
<div
className={`flex items-stretch overflow-hidden rounded-full border border-[rgba(214,184,159,0.78)] bg-[rgba(255,250,244,0.9)] text-[#6f3d24] shadow-[0_0.18rem_0.55rem_rgba(112,62,32,0.08)] ${
isCompact ? 'h-8 text-[11px]' : 'h-9 text-xs'
}`}
>
<button
type="button"
className="flex min-w-0 items-center gap-1.5 px-2 font-black outline-none transition-colors hover:bg-white/70 focus-visible:bg-white/80"
aria-label={`泥点 ${exactBalanceLabel}`}
aria-expanded={isOpen}
aria-haspopup="dialog"
aria-busy={isLoading}
onClick={
isCompact
? () => {
if (isOpenRef.current) {
closeDetails();
return;
}
requestAndOpen();
}
: undefined
}
>
<img
src={MUD_POINT_ICON_SRC}
alt=""
aria-hidden="true"
draggable={false}
className={`${isCompact ? 'h-[1.05rem] w-[1.05rem]' : 'h-[1.2rem] w-[1.2rem]'} shrink-0 object-cover mix-blend-multiply`}
/>
<span className="truncate whitespace-nowrap">
{balanceLabel}
</span>
</button>
<span
className="my-1.5 w-px shrink-0 bg-[rgba(196,153,120,0.62)]"
aria-hidden="true"
/>
<button
type="button"
className="shrink-0 px-2.5 font-black outline-none transition-colors hover:bg-white/75 focus-visible:bg-white/80"
onClick={() => {
closeDetails();
onRecharge();
}}
>
</button>
</div>
{isOpen ? (
<div
role="dialog"
aria-label="泥点账户详情"
onMouseEnter={isCompact ? undefined : cancelPendingClose}
className="absolute right-0 top-[calc(100%+0.5rem)] z-[95] w-[min(19rem,calc(100vw-1rem))] overflow-hidden rounded-[1.12rem] border border-[var(--platform-subpanel-border)] bg-[#fffaf4] text-left shadow-[0_1rem_2.8rem_rgba(76,44,27,0.2)]"
>
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="text-[15px] font-black text-[var(--platform-text-strong)]">
{exactBalanceLabel}
</div>
<button
type="button"
className="text-xs font-black text-[var(--platform-accent-strong)] outline-none hover:underline focus-visible:underline"
onClick={() => {
closeDetails();
onRecharge();
}}
>
</button>
</div>
{breakdown ? (
<>
<MudPointBalanceRow
label="不限时泥点"
points={breakdown.permanentPoints}
detail="按量充值、兑换码获得"
/>
<MudPointBalanceRow
label="每日免费泥点"
points={breakdown.dailyFreePoints}
detail={`每天重置为 ${formatMudPointCount(breakdown.dailyFreeResetPoints)} 泥点`}
/>
</>
) : (
<div className="border-t border-[var(--platform-subpanel-border)] px-4 py-6 text-center text-xs font-semibold text-[var(--platform-text-soft)]">
{isLoading ? '余额读取中' : '余额明细暂不可用'}
</div>
)}
<button
type="button"
className="flex w-full items-center justify-center gap-2 border-t border-[var(--platform-subpanel-border)] px-4 py-3 text-[13px] font-black text-[var(--platform-text-strong)] outline-none transition-colors hover:bg-white/45 focus-visible:bg-white/55"
onClick={() => {
closeDetails();
onOpenLedger();
}}
>
<ReceiptText className="h-3.5 w-3.5" aria-hidden="true" />
使
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
) : null}
</div>
);
}
@@ -39,6 +39,24 @@ test('closes through backdrop and escape', () => {
expect(onClose).toHaveBeenCalledTimes(2);
});
test('keeps the modal open when a pointer press starts inside and releases over the backdrop', () => {
const onClose = vi.fn();
render(
<UnifiedModal open title="统一弹窗" onClose={onClose} portal={false}>
<button type="button"></button>
</UnifiedModal>,
);
const dialog = screen.getByRole('dialog');
const backdrop = dialog.parentElement as HTMLElement;
fireEvent.pointerDown(screen.getByRole('button', { name: '窗口内容' }));
fireEvent.pointerUp(backdrop);
fireEvent.click(backdrop);
expect(onClose).not.toHaveBeenCalled();
});
test('supports disabling escape close while keeping the custom close button chrome', () => {
const onClose = vi.fn();
render(
+18
View File
@@ -4,6 +4,7 @@ import {
type ReactNode,
useEffect,
useId,
useRef,
} from 'react';
import { createPortal } from 'react-dom';
@@ -118,6 +119,7 @@ function UnifiedModalContent({
const generatedTitleId = useId();
const descriptionId = useId();
const titleId = titleIdProp ?? generatedTitleId;
const backdropPointerSequenceRef = useRef<boolean | null>(null);
useEffect(() => {
if (!open || closeDisabled || !closeOnEscape) {
@@ -175,10 +177,26 @@ function UnifiedModalContent({
<div
className={joinClassNames(overlayClasses, zIndexClassName, overlayClassName)}
style={overlayStyle}
onPointerDownCapture={(event) => {
backdropPointerSequenceRef.current =
event.target === event.currentTarget;
}}
onPointerUpCapture={(event) => {
backdropPointerSequenceRef.current =
backdropPointerSequenceRef.current === true &&
event.target === event.currentTarget;
}}
onPointerCancelCapture={() => {
backdropPointerSequenceRef.current = false;
}}
onClick={(event) => {
const pointerSequenceStayedOnBackdrop =
backdropPointerSequenceRef.current !== false;
backdropPointerSequenceRef.current = null;
if (
closeOnBackdrop &&
!closeDisabled &&
pointerSequenceStayedOnBackdrop &&
event.target === event.currentTarget
) {
onClose();
@@ -0,0 +1,10 @@
export function formatMudPointCount(value: number, compact = false) {
const normalizedValue = Math.max(0, Math.round(value));
if (compact && normalizedValue >= 100_000_000) {
return `${(normalizedValue / 100_000_000).toFixed(1)}亿`;
}
if (compact && normalizedValue >= 10_000) {
return `${(normalizedValue / 10_000).toFixed(1)}`;
}
return normalizedValue.toLocaleString('zh-CN');
}
@@ -156,16 +156,34 @@ describe('CreationLandingView', () => {
renderCreationLanding();
expect(screen.getByRole('main', { name: '陶泥儿创作主页' })).toBeTruthy();
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
expect(
screen.getByRole('heading', {
name: '陶泥儿 - 开启全民精品游戏创作',
level: 1,
name: '陶泥儿 · 开启全民精品游戏创作',
}),
).toBeTruthy();
expect(
screen.getByText('登录即送100泥点,可以免费制作50个素材'),
screen.getByText('陶泥儿 Genarrative|游戏美术 AI 创作工具'),
).toBeTruthy();
const subtitle = screen.getByText(
/ AI /u,
);
expect(subtitle.textContent).toContain('美术 Agent');
expect(subtitle.textContent).toContain('无限画布');
expect(subtitle.textContent).toContain('角色、场景、UI 与宣发素材');
expect(
screen.getByText('登录即送 100 泥点,可以免费制作 50 个素材'),
).toBeTruthy();
expect(
screen.getByRole('heading', {
level: 2,
name: '游戏美术 AI 创作工具',
}),
).toBeTruthy();
expect(
screen.getByRole('heading', { level: 3, name: '游戏视觉规范' }),
).toBeTruthy();
expect(screen.getByRole('heading', { name: '创作工具' })).toBeTruthy();
expect(screen.getByText('游戏视觉规范')).toBeTruthy();
expect(screen.getByRole('heading', { name: '陶泥儿精选' })).toBeTruthy();
expect(screen.getByRole('tab', { name: '全部' })).toBeTruthy();
expect(screen.queryByRole('tab', { name: '素材包' })).toBeNull();
@@ -310,6 +328,10 @@ describe('CreationLandingView', () => {
await user.click(screen.getByRole('button', { name: //u }));
const dialog = screen.getByRole('dialog', { name: '抱歉' });
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
expect(
within(dialog).getByRole('heading', { level: 2, name: '抱歉' }),
).toBeTruthy();
expect(dialog.textContent).toContain('功能还在调试中');
expect(dialog.textContent).toContain('暂未开放');
expect(dialog.closest('.platform-theme--light')).toBeTruthy();
@@ -858,9 +858,20 @@ export function CreationLandingView({
<main className="creation-landing" aria-label="陶泥儿创作主页">
<section className="creation-landing__hero">
<div className="creation-landing__hero-copy">
<span className="creation-landing__eyebrow"></span>
<h1> - </h1>
<p>10050</p>
<span className="creation-landing__eyebrow">
Genarrative AI
</span>
<h1> · </h1>
<div className="creation-landing__hero-detail">
<p className="creation-landing__hero-subtitle">
AI
<br />
Agent UI
</p>
<p className="creation-landing__hero-benefit">
100 50
</p>
</div>
<div className="creation-landing__hero-actions">
<PlatformActionButton
size="md"
@@ -908,7 +919,7 @@ export function CreationLandingView({
<section className="creation-landing__section">
<div className="creation-landing__section-header">
<div>
<h2></h2>
<h2> AI </h2>
</div>
</div>
<div className="creation-landing__feature-grid">
@@ -1084,7 +1095,7 @@ export function CreationLandingView({
alt=""
className="platform-mobile-home-welcome-dialog__icon"
/>
<h1 className="platform-mobile-home-welcome-dialog__title"></h1>
<h2 className="platform-mobile-home-welcome-dialog__title"></h2>
<p className="platform-mobile-home-welcome-dialog__copy">
<br />
@@ -99,8 +99,11 @@ function createTopbarProps(): ImageCanvasTopbarViewProps {
isProjectRenameSaving: false,
projectRenameError: null,
layers: [],
walletBalanceLabel: '0泥点',
walletBalance: 0,
walletBreakdown: null,
isWalletBalanceLoading: false,
isWalletDetailsLoading: false,
walletDetailsError: null,
currentUser: null,
assetExportStatus: null,
isExportingAssets: false,
@@ -111,7 +114,9 @@ function createTopbarProps(): ImageCanvasTopbarViewProps {
resetProjectRenameError: vi.fn(),
exportCanvasAssets: vi.fn(),
onOpenShortcuts: vi.fn(),
onOpenWallet: vi.fn(),
onRequestWalletDetails: vi.fn(),
onRecharge: vi.fn(),
onOpenWalletLedger: vi.fn(),
onOpenAccount: vi.fn(),
};
}
@@ -12,7 +12,6 @@ import userEvent from '@testing-library/user-event';
import JSZip from 'jszip';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { EditorAgentConversationClient } from './EditorAgentConversation/useEditorAgentConversation.ts';
import {
ApiClientError,
AuthUiContext,
@@ -25,6 +24,7 @@ import {
readZipText,
setupImageCanvasEditorViewTestLifecycle,
} from './ImageCanvasEditorView.test-utils';
import type { EditorAgentConversationClient } from './useEditorAgentConversation';
type EditorAgentListConversations =
EditorAgentConversationClient['listConversations'];
@@ -121,6 +121,8 @@ const renameEditorProjectMock = vi.hoisted(() => vi.fn());
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
const getPlatformProfileDashboardMock = vi.hoisted(() => vi.fn());
const loadFrontendRuntimeConfigMock = vi.hoisted(() => vi.fn());
const getRpgProfileRechargeCenterMock = vi.hoisted(() => vi.fn());
const getRpgProfileWalletLedgerMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/image-editor/editorProjectClient', async () => {
const actual = await vi.importActual<
@@ -151,6 +153,17 @@ vi.mock('../../services/platform-entry/platformProfileClient', () => ({
getPlatformProfileDashboard: getPlatformProfileDashboardMock,
}));
vi.mock('../../services/rpg-entry/rpgProfileClient', async () => {
const actual = await vi.importActual<
typeof import('../../services/rpg-entry/rpgProfileClient')
>('../../services/rpg-entry/rpgProfileClient');
return {
...actual,
getRpgProfileRechargeCenter: getRpgProfileRechargeCenterMock,
getRpgProfileWalletLedger: getRpgProfileWalletLedgerMock,
};
});
vi.mock('../../services/frontendRuntimeConfigService', () => ({
loadFrontendRuntimeConfig: loadFrontendRuntimeConfigMock,
}));
@@ -319,6 +332,46 @@ describe('ImageCanvasEditorView', () => {
playedWorldCount: 0,
updatedAt: null,
});
getRpgProfileRechargeCenterMock.mockResolvedValue({
walletBalance: 1234,
mudPointBalance: {
totalPoints: 1234,
permanentPoints: 1000,
limitedPoints: 214,
limitedExpiresAt: '2026-07-31T16:00:00Z',
dailyFreePoints: 20,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-07-12T16:00:00Z',
},
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
cycleStartedAt: null,
cycleResetsAt: null,
cycleGrantedPoints: 0,
cycleRemainingPoints: 0,
cyclePeriodDays: 30,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
});
getRpgProfileWalletLedgerMock.mockResolvedValue({
entries: [
{
id: 'editor-ledger-1',
amountDelta: -5,
balanceAfter: 1234,
sourceType: 'asset_operation_consume',
createdAt: '2026-07-12T08:00:00Z',
},
],
});
});
afterEach(() => {
@@ -330,6 +383,8 @@ describe('ImageCanvasEditorView', () => {
confirmEditorAgentToolCallMock.mockReset();
cancelEditorAgentToolCallMock.mockReset();
getPlatformProfileDashboardMock.mockReset();
getRpgProfileRechargeCenterMock.mockReset();
getRpgProfileWalletLedgerMock.mockReset();
loadFrontendRuntimeConfigMock.mockReset();
});
@@ -540,7 +595,7 @@ describe('ImageCanvasEditorView', () => {
</AuthUiContext.Provider>,
);
expect(await screen.findByLabelText('泥点余额 1,234泥点')).toBeTruthy();
expect(await screen.findByLabelText('泥点 1,234')).toBeTruthy();
expect(getPlatformProfileDashboardMock).toHaveBeenCalledWith({
authImpact: 'local',
skipRefresh: true,
@@ -585,7 +640,7 @@ describe('ImageCanvasEditorView', () => {
expect(openAccountModal).toHaveBeenCalledTimes(1);
});
it('opens the account recharge entry from the canvas topbar mud point button', async () => {
it('opens the shared wallet breakdown and ledger from the canvas topbar', async () => {
render(
<AuthUiContext.Provider
value={createAuthValue({
@@ -607,15 +662,23 @@ describe('ImageCanvasEditorView', () => {
);
const walletButton = await screen.findByRole('button', {
name: '泥点余额 1,234泥点',
name: '泥点 1,234',
});
fireEvent.click(walletButton);
const details = await screen.findByRole('dialog', {
name: '泥点账户详情',
});
expect(within(details).getByText('不限时泥点')).toBeTruthy();
expect(within(details).queryByText('限时泥点')).toBeNull();
expect(within(details).getByText('每日免费泥点')).toBeTruthy();
fireEvent.click(within(details).getByRole('button', { name: '使用详情' }));
expect(
await screen.findByRole('dialog', { name: '账户充值' }),
await screen.findByRole('dialog', { name: '泥点账单' }),
).toBeTruthy();
expect(screen.queryByPlaceholderText('输入兑换码')).toBeNull();
expect(getRpgProfileWalletLedgerMock).toHaveBeenCalledTimes(1);
});
it('suspends canvas interaction during account payment dialogs and restores completed quick edit selections', async () => {
@@ -707,11 +770,11 @@ describe('ImageCanvasEditorView', () => {
const viewport = screen.getByLabelText('画布工作区');
fireEvent.click(
await screen.findByRole('button', {
name: '泥点余额 1,234泥点',
name: '充值',
}),
);
expect(
await screen.findByRole('dialog', { name: '账户充值' }),
await screen.findByRole('dialog', { name: '购买更多泥点' }),
).toBeTruthy();
fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' });
@@ -737,7 +800,7 @@ describe('ImageCanvasEditorView', () => {
).toBe('true');
expect(viewport.getAttribute('aria-disabled')).toBe('true');
fireEvent.click(screen.getByRole('button', { name: '关闭账户充值' }));
fireEvent.click(screen.getByRole('button', { name: '关闭购买更多泥点' }));
const resumedToolbar = await screen.findByRole('toolbar', {
name: '快速编辑框选工具',
@@ -9,6 +9,7 @@ import {
useState,
} from 'react';
import type { EditorAgentGenerationResultEvent } from '../../../packages/shared/src/contracts/editorAgent';
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import { loadFrontendRuntimeConfig } from '../../services/frontendRuntimeConfigService';
import {
@@ -25,12 +26,12 @@ import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal';
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal';
import {
PlatformRechargePaymentConfirmationMask,
PlatformRechargePaymentResultDialog,
} from '../platform-entry/PlatformRechargePaymentStatusDialogs';
import { usePlatformProfileCenterController } from '../platform-entry/usePlatformProfileCenterController';
import { formatDashboardCount } from '../rpg-entry/rpgEntryProfileDashboardPresentation';
import {
canvasAssetKindOrNull,
DEFAULT_CANVAS_BACKGROUND_COLOR,
@@ -294,9 +295,7 @@ export function ImageCanvasEditorView({
}: ImageCanvasEditorViewProps = {}) {
const authUi = useAuthUi();
const [, setGenerationPricingVersion] = useState(0);
const [walletBalanceLabel, setWalletBalanceLabel] = useState<string | null>(
null,
);
const [walletBalance, setWalletBalance] = useState<number | null>(null);
const [isWalletBalanceLoading, setIsWalletBalanceLoading] = useState(false);
const editorRootRef = useRef<HTMLElement | null>(null);
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
@@ -478,37 +477,40 @@ export function ImageCanvasEditorView({
clearAuthOnUnauthorized: false,
})
.then((dashboard) => {
setWalletBalanceLabel(
`${formatDashboardCount(dashboard.walletBalance)}`,
);
setWalletBalance(dashboard.walletBalance);
})
.catch(() => undefined);
}, []);
const {
activeRechargeTab,
buyRechargeProduct,
closeNativeWechatPayment,
confirmNativeWechatPayment,
isLoadingRechargeCenter,
isLoadingWalletLedger,
isRechargeOpen,
isRewardCodeOpen,
isSubmittingRewardCode,
isWalletLedgerOpen,
loadRechargeCenter,
loadWalletLedger,
nativeWechatPayment,
openRechargeOrRewardCodeModal,
openWalletLedgerPanel,
rechargeCenter,
rechargeError,
rechargePaymentResult,
rewardCodeError,
rewardCodeInput,
rewardCodeSuccess,
setActiveRechargeTab,
setIsRechargeOpen,
setIsRewardCodeOpen,
setIsWalletLedgerOpen,
setRechargePaymentResult,
setRewardCodeInput,
submittingRechargeProductId,
submitRewardCode,
walletLedger,
walletLedgerError,
wechatRechargeOrderConfirmationState,
} = usePlatformProfileCenterController({
activeTab: 'editor-canvas',
@@ -521,6 +523,7 @@ export function ImageCanvasEditorView({
const isAccountPaymentModalOpen =
isRewardCodeOpen ||
isRechargeOpen ||
isWalletLedgerOpen ||
Boolean(nativeWechatPayment) ||
Boolean(rechargePaymentResult) ||
Boolean(wechatRechargeOrderConfirmationState);
@@ -540,7 +543,7 @@ export function ImageCanvasEditorView({
}, [authUi]);
useEffect(() => {
if (!authUi?.canAccessProtectedData || !authUi.user?.id) {
setWalletBalanceLabel(null);
setWalletBalance(null);
setIsWalletBalanceLoading(false);
return;
}
@@ -562,15 +565,13 @@ export function ImageCanvasEditorView({
if (!isMounted || currentRequestId !== requestId) {
return;
}
setWalletBalanceLabel(
`${formatDashboardCount(dashboard.walletBalance)}`,
);
setWalletBalance(dashboard.walletBalance);
})
.catch(() => {
if (!isMounted || currentRequestId !== requestId) {
return;
}
setWalletBalanceLabel(null);
setWalletBalance(null);
})
.finally(() => {
if (!isMounted || currentRequestId !== requestId) {
@@ -2022,8 +2023,11 @@ export function ImageCanvasEditorView({
isProjectRenameSaving,
projectRenameError,
layers,
walletBalanceLabel,
walletBalance,
walletBreakdown: rechargeCenter?.mudPointBalance ?? null,
isWalletBalanceLoading,
isWalletDetailsLoading: isLoadingRechargeCenter,
walletDetailsError: rechargeError,
currentUser: authUi?.user,
assetExportStatus,
isExportingAssets,
@@ -2034,7 +2038,9 @@ export function ImageCanvasEditorView({
resetProjectRenameError,
exportCanvasAssets,
onOpenShortcuts: () => setIsShortcutDialogOpen(true),
onOpenWallet: openAccountPaymentModal,
onRequestWalletDetails: loadRechargeCenter,
onRecharge: openAccountPaymentModal,
onOpenWalletLedger: openWalletLedgerPanel,
onOpenAccount: () => {
if (authUi?.user) {
authUi.openAccountModal();
@@ -2225,8 +2231,6 @@ export function ImageCanvasEditorView({
error={rechargeError}
submittingProductId={submittingRechargeProductId}
nativePayment={nativeWechatPayment}
activeTab={activeRechargeTab}
onTabChange={setActiveRechargeTab}
onClose={() => setIsRechargeOpen(false)}
onRetry={loadRechargeCenter}
onBuy={buyRechargeProduct}
@@ -2234,6 +2238,16 @@ export function ImageCanvasEditorView({
onCloseNativePayment={closeNativeWechatPayment}
/>
) : null}
{isWalletLedgerOpen ? (
<PlatformProfileWalletLedgerModal
ledger={walletLedger}
fallbackBalance={walletBalance ?? 0}
isLoading={isLoadingWalletLedger}
error={walletLedgerError}
onClose={() => setIsWalletLedgerOpen(false)}
onRetry={loadWalletLedger}
/>
) : null}
{rechargePaymentResult ? (
<PlatformRechargePaymentResultDialog
result={rechargePaymentResult}
@@ -287,8 +287,8 @@ describe('ImageCanvasGenerationLayerModel', () => {
const sourceLayer = createSourceLayer({
width: 320,
height: 240,
originalWidth: 1024,
originalHeight: 768,
originalWidth: 1537,
originalHeight: 1025,
});
const layer = applyImageEditResultToSourceLayer({
generated: createGenerated({
@@ -313,10 +313,10 @@ describe('ImageCanvasGenerationLayerModel', () => {
id: 'layer-source',
title: '源图',
src: 'data:image/png;base64,edited',
width: 1536,
height: 1024,
originalWidth: 1536,
originalHeight: 1024,
width: 320,
height: 240,
originalWidth: 1537,
originalHeight: 1025,
resourceId: 'resource-edited',
sourceResourceId: 'resource-source',
objectKey: 'generated/edited.png',
@@ -247,18 +247,10 @@ export function applyImageEditResultToSourceLayer({
sourceLayer: CanvasLayer;
generationInputs: CanvasGenerationInputs;
}): CanvasLayer {
const originalWidth =
generated.width || sourceLayer.originalWidth || sourceLayer.width;
const originalHeight =
generated.height || sourceLayer.originalHeight || sourceLayer.height;
const { width, height } = resolveLayerResolutionSize(
originalWidth,
originalHeight,
{
width: sourceLayer.width,
height: sourceLayer.height,
},
);
const originalWidth = sourceLayer.originalWidth || sourceLayer.width;
const originalHeight = sourceLayer.originalHeight || sourceLayer.height;
const width = sourceLayer.width;
const height = sourceLayer.height;
return applyGeneratedMetadata(
{
...sourceLayer,
@@ -122,6 +122,8 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
const sourceLayer = createLayer({
objectKey: 'generated-character-drafts/editor/source.png',
assetKind: 'character',
originalWidth: 1537,
originalHeight: 1025,
});
const plan = buildImageGenerationSubmissionPlan({
@@ -150,7 +152,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
normalizedPrompt: '把当前图改成雨天',
sourceLayer,
editInput: {
size: '1024x768',
size: '1537x1025',
model: IMAGE_MODEL_GPT_IMAGE_2,
},
result: {
@@ -41,11 +41,8 @@ import {
DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
ICON_DESCRIPTION_LIMIT,
IMAGE_MODEL_GPT_IMAGE_2,
inferEditorImageAspectRatio,
inferEditorImageSizeLabel,
normalizeEditorImageModel,
resolveCharacterAnimationSourceImageSrc,
resolveEditorImageGenerationPixelSize,
SEEDANCE_VIDEO_REFERENCE_LIMITS,
SPEC_GENERATION_ASPECT_RATIO,
SPEC_GENERATION_IMAGE_SIZE,
@@ -260,29 +257,12 @@ export function buildImageGenerationSubmissionPlan({
const basePrompt = dialog.prompt.trim() || '快速编辑图片';
const normalizedQuickEditPrompt = basePrompt;
const imageModel = IMAGE_MODEL_GPT_IMAGE_2;
const aspectRatio =
dialog.aspectRatio ??
inferEditorImageAspectRatio(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
const imageSize =
dialog.imageSize ??
inferEditorImageSizeLabel(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
const outputSize = resolveEditorImageGenerationPixelSize({
model: imageModel,
aspectRatio,
imageSize,
});
return {
kind: 'quick-edit',
normalizedPrompt: normalizedQuickEditPrompt,
sourceLayer,
editInput: {
size: `${outputSize.width}x${outputSize.height}`,
size: `${sourceLayer.originalWidth}x${sourceLayer.originalHeight}`,
model: imageModel,
},
result: {
@@ -1,12 +1,23 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import { ImageCanvasTopbarView } from './ImageCanvasTopbarView';
import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts';
const walletBreakdown = {
totalPoints: 1_234,
permanentPoints: 1_000,
limitedPoints: 214,
limitedExpiresAt: '2026-07-31T16:00:00Z',
dailyFreePoints: 20,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-07-12T16:00:00Z',
};
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
const id = overrides.id ?? 'layer-a';
return {
@@ -27,9 +38,7 @@ function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
}
function renderTopbar(
overrides: Partial<
Parameters<typeof ImageCanvasTopbarView>[0]
> = {},
overrides: Partial<Parameters<typeof ImageCanvasTopbarView>[0]> = {},
) {
useImageCanvasContextStore.getState().setProjectId('project-a');
const props: Parameters<typeof ImageCanvasTopbarView>[0] = {
@@ -39,8 +48,11 @@ function renderTopbar(
isProjectRenameSaving: false,
projectRenameError: null,
layers: [],
walletBalanceLabel: '1,234泥点',
walletBalance: 1_234,
walletBreakdown,
isWalletBalanceLoading: false,
isWalletDetailsLoading: false,
walletDetailsError: null,
currentUser: null,
assetExportStatus: null,
isExportingAssets: false,
@@ -51,7 +63,9 @@ function renderTopbar(
resetProjectRenameError: vi.fn(),
exportCanvasAssets: vi.fn(),
onOpenShortcuts: vi.fn(),
onOpenWallet: vi.fn(),
onRequestWalletDetails: vi.fn(),
onRecharge: vi.fn(),
onOpenWalletLedger: vi.fn(),
onOpenAccount: vi.fn(),
...overrides,
};
@@ -80,22 +94,36 @@ describe('ImageCanvasTopbarView', () => {
expect(props.onOpenShortcuts).toHaveBeenCalledTimes(1);
});
it('shows the current mud point balance in the topbar', () => {
it('shows the shared mud point breakdown and wallet actions', async () => {
const user = userEvent.setup();
const props = renderTopbar({
walletBalanceLabel: '1.2万泥点',
walletBalance: 12_345,
walletBreakdown: {
...walletBreakdown,
totalPoints: 12_345,
},
});
const walletChip = screen.getByRole('button', {
name: '泥点余额 1.2万泥点',
name: '泥点 12,345',
});
expect(walletChip.textContent).toBe('1.2万泥点');
expect(walletChip.textContent).toBe('泥点 1.2万');
expect(walletChip.querySelector('img')?.getAttribute('src')).toBe(
'/creation-home/topbar-wallet.png',
);
fireEvent.click(walletChip);
await user.hover(walletChip);
const details = screen.getByRole('dialog', { name: '泥点账户详情' });
expect(within(details).getByText('不限时泥点')).toBeTruthy();
expect(within(details).queryByText('限时泥点')).toBeNull();
expect(within(details).getByText('每日免费泥点')).toBeTruthy();
expect(props.onOpenWallet).toHaveBeenCalledTimes(1);
await user.click(within(details).getByRole('button', { name: '使用详情' }));
expect(props.onOpenWalletLedger).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole('button', { name: '充值' }));
expect(props.onRecharge).toHaveBeenCalledTimes(1);
expect(props.onRequestWalletDetails).not.toHaveBeenCalled();
});
it('shows the current user avatar beside the mud point balance', () => {
@@ -151,8 +179,11 @@ describe('ImageCanvasTopbarView', () => {
isProjectRenameSaving={false}
projectRenameError={null}
layers={[]}
walletBalanceLabel="0泥点"
walletBalance={0}
walletBreakdown={null}
isWalletBalanceLoading={false}
isWalletDetailsLoading={false}
walletDetailsError={null}
currentUser={null}
assetExportStatus={null}
isExportingAssets={false}
@@ -163,14 +194,19 @@ describe('ImageCanvasTopbarView', () => {
resetProjectRenameError={vi.fn()}
exportCanvasAssets={exportCanvasAssets}
onOpenShortcuts={vi.fn()}
onOpenWallet={vi.fn()}
onRequestWalletDetails={vi.fn()}
onRecharge={vi.fn()}
onOpenWalletLedger={vi.fn()}
onOpenAccount={vi.fn()}
/>,
);
expect(
(screen.getByRole('button', { name: '下载画布素材' }) as HTMLButtonElement)
.disabled,
(
screen.getByRole('button', {
name: '下载画布素材',
}) as HTMLButtonElement
).disabled,
).toBe(true);
rerender(
@@ -181,8 +217,11 @@ describe('ImageCanvasTopbarView', () => {
isProjectRenameSaving={false}
projectRenameError={null}
layers={[createLayer()]}
walletBalanceLabel="0泥点"
walletBalance={0}
walletBreakdown={null}
isWalletBalanceLoading={false}
isWalletDetailsLoading={false}
walletDetailsError={null}
currentUser={null}
assetExportStatus={{
tone: 'success',
@@ -196,7 +235,9 @@ describe('ImageCanvasTopbarView', () => {
resetProjectRenameError={vi.fn()}
exportCanvasAssets={exportCanvasAssets}
onOpenShortcuts={vi.fn()}
onOpenWallet={vi.fn()}
onRequestWalletDetails={vi.fn()}
onRecharge={vi.fn()}
onOpenWalletLedger={vi.fn()}
onOpenAccount={vi.fn()}
/>,
);
@@ -7,6 +7,8 @@ import {
X,
} from 'lucide-react';
import type { ProfileMudPointBalance } from '../../../packages/shared/src/contracts/runtime';
import { PlatformMudPointWalletEntry } from '../common/PlatformMudPointWalletEntry';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
@@ -21,8 +23,11 @@ export type ImageCanvasTopbarViewProps = {
isProjectRenameSaving: boolean;
projectRenameError: string | null;
layers: CanvasLayer[];
walletBalanceLabel: string | null;
walletBalance: number | null;
walletBreakdown?: ProfileMudPointBalance | null;
isWalletBalanceLoading: boolean;
isWalletDetailsLoading: boolean;
walletDetailsError: string | null;
currentUser:
| {
id: string;
@@ -41,13 +46,13 @@ export type ImageCanvasTopbarViewProps = {
resetProjectRenameError: () => void;
exportCanvasAssets: () => void | Promise<void>;
onOpenShortcuts: () => void;
onOpenWallet: () => void;
onRequestWalletDetails: () => void;
onRecharge: () => void;
onOpenWalletLedger: () => void;
onOpenAccount: () => void;
};
function buildCanvasUserCode(
user: ImageCanvasTopbarViewProps['currentUser'],
) {
function buildCanvasUserCode(user: ImageCanvasTopbarViewProps['currentUser']) {
if (user?.publicUserCode?.trim()) {
return user.publicUserCode.trim();
}
@@ -63,8 +68,11 @@ export function ImageCanvasTopbarView({
isProjectRenameSaving,
projectRenameError,
layers,
walletBalanceLabel,
walletBalance,
walletBreakdown,
isWalletBalanceLoading,
isWalletDetailsLoading,
walletDetailsError,
currentUser,
assetExportStatus,
isExportingAssets,
@@ -75,17 +83,15 @@ export function ImageCanvasTopbarView({
resetProjectRenameError,
exportCanvasAssets,
onOpenShortcuts,
onOpenWallet,
onRequestWalletDetails,
onRecharge,
onOpenWalletLedger,
onOpenAccount,
}: ImageCanvasTopbarViewProps) {
const projectId = useImageCanvasContextStore((state) => state.projectId);
const hasExportableLayer = layers.some(
(layer) => layer.src.trim().length > 0,
);
const walletDisplayLabel = walletBalanceLabel ?? '--泥点';
const walletAriaLabel = walletBalanceLabel
? `泥点余额 ${walletBalanceLabel}`
: '泥点余额读取中';
const userDisplayName = currentUser?.displayName?.trim() || '登录';
const userAvatarUrl = currentUser?.avatarUrl?.trim() || null;
const userAvatarLabel = userDisplayName.slice(0, 1).toUpperCase();
@@ -198,22 +204,16 @@ export function ImageCanvasTopbarView({
{assetExportStatus.message}
</PlatformStatusMessage>
) : null}
<button
type="button"
className="image-canvas-editor__wallet-chip"
onClick={onOpenWallet}
aria-label={walletAriaLabel}
aria-busy={isWalletBalanceLoading}
aria-live="polite"
>
<img
src="/creation-home/topbar-wallet.png"
alt=""
aria-hidden="true"
draggable={false}
/>
<span>{walletDisplayLabel}</span>
</button>
<PlatformMudPointWalletEntry
variant="editor"
balance={walletBalance}
breakdown={walletBreakdown}
isLoading={isWalletBalanceLoading || isWalletDetailsLoading}
error={walletDetailsError}
onRequestDetails={onRequestWalletDetails}
onRecharge={onRecharge}
onOpenLedger={onOpenWalletLedger}
/>
<button
type="button"
className="image-canvas-editor__account-chip"
@@ -25,6 +25,7 @@ import type { UiAssetExtractionState } from './ImageCanvasUiAssetExtractionModel
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import {
applyQueuedEditorGenerationProject,
resolveEditorGenerationMediaReference,
useImageCanvasGenerationSubmissionWorkflow,
} from './useImageCanvasGenerationSubmissionWorkflow';
@@ -32,6 +33,7 @@ const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn());
const resolveEditorImageReferenceDataUrlForGenerationMock = vi.hoisted(() =>
vi.fn(),
);
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
const editEditorImageMock = vi.hoisted(() => vi.fn());
const extractEditorUiDesignAssetsMock = vi.hoisted(() => vi.fn());
const renderUiDesignAssetExtractionMarkedImageMock = vi.hoisted(() => vi.fn());
@@ -68,6 +70,10 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
};
});
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
}));
vi.mock('../../services/external-generation', () => ({
getExternalGenerationJobStatus: getExternalGenerationJobStatusMock,
}));
@@ -466,6 +472,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
beforeEach(() => {
resolveEditorImageReferenceDataUrlMock.mockReset();
resolveEditorImageReferenceDataUrlForGenerationMock.mockReset();
uploadEditorMediaAssetFileMock.mockReset();
editEditorImageMock.mockReset();
extractEditorUiDesignAssetsMock.mockReset();
renderUiDesignAssetExtractionMarkedImageMock.mockReset();
@@ -483,16 +490,58 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
resolveEditorImageReferenceDataUrlForGenerationMock.mockImplementation(
async (src: string) => src,
);
uploadEditorMediaAssetFileMock.mockResolvedValue({
src: 'https://signed.example.test/generation-reference.png',
objectKey:
'generated-character-drafts/editor/generation-references/reference.png',
assetObjectId: 'asset-object-generation-reference',
legacyPublicPath:
'/generated-character-drafts/editor/generation-references/reference.png',
});
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValue(
'data:image/png;base64,marked-ui-design',
'data:image/png;base64,bWFya2VkLXVpLWRlc2lnbg==',
);
});
it('uses distinct upload paths for concurrent inline references in the same millisecond', async () => {
const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(1_752_144_000_000);
try {
await Promise.all([
resolveEditorGenerationMediaReference(
{ src: 'data:image/png;base64,YQ==' },
'image',
'project-1',
),
resolveEditorGenerationMediaReference(
{ src: 'data:image/png;base64,Yg==' },
'image',
'project-1',
),
]);
} finally {
dateNowSpy.mockRestore();
}
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(2);
const [firstFile, , firstOptions] =
uploadEditorMediaAssetFileMock.mock.calls[0] ?? [];
const [secondFile, , secondOptions] =
uploadEditorMediaAssetFileMock.mock.calls[1] ?? [];
expect((firstFile as File).name).not.toBe((secondFile as File).name);
expect(firstOptions.pathSegments).not.toEqual(secondOptions.pathSegments);
});
it('submits quick edits and updates the source layer directly', async () => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '快速修图' }),
);
render(<SubmissionWorkflowHarness />);
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({ originalWidth: 1537, originalHeight: 1025 }),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' }));
@@ -506,8 +555,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '快速修图',
sourceImageSrc: 'data:image/png;base64,source.png',
size: '1024x768',
sourceImageSrc: 'resource-source',
size: '1537x1025',
model: 'gpt-image-2',
}),
);
@@ -516,6 +565,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
'canvasCompletion',
);
expect(generateEditorImageMock).not.toHaveBeenCalled();
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-source:源图:resource-source:-:-:data:image/png;base64,generated',
@@ -606,7 +656,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '把当前图改成雨天',
sourceImageSrc: 'data:image/png;base64,source.png',
sourceImageSrc: 'resource-source',
size: '1024x768',
model: 'gpt-image-2',
sourceResourceId: 'resource-source',
@@ -727,7 +777,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
/>,
);
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValueOnce(
'data:image/png;base64,marked-source',
'data:image/png;base64,bWFya2VkLXNvdXJjZQ==',
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
@@ -751,13 +801,24 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
});
await waitFor(() => {
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
expect.any(File),
'image',
expect.objectContaining({
assetKind: 'editor_generation_reference_image',
}),
);
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceImageSrc: 'data:image/png;base64,marked-source',
sourceImageSrc:
'generated-character-drafts/editor/generation-references/reference.png',
targetLayerId: 'layer-source',
}),
);
});
expect(
uploadEditorMediaAssetFileMock.mock.invocationCallOrder[0],
).toBeLessThan(editEditorImageMock.mock.invocationCallOrder[0] ?? 0);
expect(editEditorImageMock.mock.calls[0]?.[0]).not.toHaveProperty(
'referenceImageSrcs',
);
@@ -812,7 +873,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
/>,
);
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValueOnce(
'data:image/png;base64,marked-source',
'data:image/png;base64,bWFya2VkLXNvdXJjZQ==',
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
@@ -839,7 +900,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
await waitFor(() => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceImageSrc: 'data:image/png;base64,marked-source',
sourceImageSrc:
'generated-character-drafts/editor/generation-references/reference.png',
model: 'gpt-image-2',
targetLayerId: 'layer-source',
}),
@@ -907,7 +969,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
imageSize: '1K',
kind: 'character',
model: 'gpt-image-2',
referenceImageSrcs: ['data:image/png;base64,source.png'],
referenceImageSrcs: ['resource-source'],
assetKind: 'character',
}),
);
@@ -959,8 +1021,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
initialLayers={[
createLayer({
title: '源视频',
src: 'https://assets.example.test/source.mp4',
objectKey: 'generated-character-drafts/editor/videos/source.mp4',
src: 'data:video/mp4;base64,c291cmNlLXZpZGVv',
mediaType: 'video',
assetKind: 'video',
model: 'gpt-image-2',
@@ -1001,7 +1062,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
mode: 'std',
sound: 'off',
referenceVideoSrcs: [
'generated-character-drafts/editor/videos/source.mp4',
'generated-character-drafts/editor/generation-references/reference.png',
],
assetFolderId: 'project',
assetLabel: '源视频 快速编辑',
@@ -1026,6 +1087,66 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
});
it('uploads inline video references and keeps image objectKey and asset URLs', async () => {
generateEditorVideoMock.mockResolvedValueOnce(createVideoGenerated());
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-video-references',
mode: 'video',
prompt: '让画面动起来',
status: 'idle',
composerOpen: true,
videoModel: 'seedance2.0-fast',
generationReferences: [
{
id: 'image-reference',
label: '图片参考',
src: 'data:image/png;base64,aW1hZ2U=',
objectKey: 'generated-character-drafts/editor/refs/image.png',
},
{
id: 'video-reference',
label: '视频参考',
src: 'data:video/mp4;base64,dmlkZW8=',
mediaType: 'video',
},
{
id: 'audio-reference',
label: '音频参考',
src: 'asset://asset-audio-reference',
mediaType: 'audio',
},
],
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
expect.any(File),
'video',
expect.objectContaining({
assetKind: 'editor_generation_reference_video',
}),
);
expect(generateEditorVideoMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrcs: [
'generated-character-drafts/editor/refs/image.png',
],
referenceVideoSrcs: [
'generated-character-drafts/editor/generation-references/reference.png',
],
referenceAudioSrcs: ['asset://asset-audio-reference'],
}),
);
});
});
it('submits character action quick edits through image edits', async () => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({
@@ -1075,7 +1196,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '让当前图跳得更高',
sourceImageSrc: 'data:image/png;base64,frame.png',
sourceImageSrc: 'resource-source',
model: 'gpt-image-2',
assetLabel: '角色动作 快速编辑',
targetLayerId: 'layer-source',
@@ -1117,7 +1238,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '修改当前图片',
sourceImageSrc: 'data:image/png;base64,source.png',
sourceImageSrc: 'resource-source',
}),
);
});
@@ -1341,10 +1462,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
});
it('resolves uploaded objectKey references before submitting image generation', async () => {
resolveEditorImageReferenceDataUrlForGenerationMock.mockResolvedValueOnce(
'data:image/jpeg;base64,normalized-ref',
);
it('submits uploaded objectKey references without downloading or inlining them', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '参考图生成' }),
);
@@ -1382,16 +1500,17 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(
resolveEditorImageReferenceDataUrlForGenerationMock,
).toHaveBeenCalledWith('generated-character-drafts/editor/refs/ref.png');
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '参考图生成',
referenceImageSrcs: ['data:image/jpeg;base64,normalized-ref'],
referenceImageSrcs: [
'generated-character-drafts/editor/refs/ref.png',
],
}),
);
});
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled();
});
it('refreshes the wallet balance after a queued generation reaches a terminal state', async () => {
@@ -1525,10 +1644,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
1,
unresolvedProject,
);
expect(applyProjectSnapshot).toHaveBeenNthCalledWith(
2,
completedProject,
);
expect(applyProjectSnapshot).toHaveBeenNthCalledWith(2, completedProject);
} finally {
vi.useRealTimers();
}
@@ -1691,7 +1807,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
{
id: 'ref-1',
label: '主视觉参考',
src: 'data:image/png;base64,large-ref',
src: 'DaTa:image/png;base64,bGFyZ2UtcmVm',
},
],
placeholder: {
@@ -1721,7 +1837,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
model: 'gpt-image-2',
aspectRatio: '9:16',
imageSize: '1K',
referenceImageSrcs: ['data:image/png;base64,large-ref'],
referenceImageSrcs: [
'generated-character-drafts/editor/generation-references/reference.png',
],
}),
);
});
@@ -1742,10 +1860,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
});
it('compresses publication references before submitting generation', async () => {
resolveEditorImageReferenceDataUrlForGenerationMock.mockResolvedValueOnce(
'data:image/jpeg;base64,compressed-ref',
);
it('submits publication resource ids without downloading or inlining them', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({
imageSrc: 'data:image/png;base64,publication',
@@ -1773,6 +1888,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
id: 'ref-1',
label: '主视觉参考',
src: 'data:image/png;base64,large-original-ref',
resourceId: 'resource-publication-ref',
},
],
}}
@@ -1783,18 +1899,17 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(
resolveEditorImageReferenceDataUrlForGenerationMock,
).toHaveBeenCalledWith('data:image/png;base64,large-original-ref');
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'publication-material',
aspectRatio: '4:3',
imageSize: '1K',
referenceImageSrcs: ['data:image/jpeg;base64,compressed-ref'],
referenceImageSrcs: ['resource-publication-ref'],
}),
);
});
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled();
});
it('submits icon spec objects without requiring an icon spec reference', async () => {
@@ -1913,7 +2028,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
iconSpecReference: {
id: 'spec',
label: '图标规范',
src: 'data:image/png;base64,spec',
src: 'data:image/png;base64,c3BlYw==',
},
iconDescriptions: ['旧返回', '旧设置'],
placeholder: {
@@ -1934,7 +2049,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
await waitFor(() => {
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrc: 'data:image/png;base64,spec',
referenceImageSrc:
'generated-character-drafts/editor/generation-references/reference.png',
iconDescriptions: ['返回按钮', '设置按钮'],
model: 'gpt-image-2',
}),
@@ -1992,7 +2108,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
id: 'ref-style',
label: '风格参考',
src: 'https://signed.example.test/icon-style.png?expires=1',
objectKey: 'generated-character-drafts/editor/refs/icon-style.png',
objectKey:
'generated-character-drafts/editor/refs/icon-style.png',
assetObjectId: 'assetobj-icon-style',
},
],
@@ -2004,7 +2121,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
fireEvent.click(screen.getByRole('button', { name: '提交图标' }));
await waitFor(() => {
expect(resolveEditorImageReferenceDataUrlForGenerationMock).not.toHaveBeenCalled();
expect(
resolveEditorImageReferenceDataUrlForGenerationMock,
).not.toHaveBeenCalled();
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrc:
@@ -2115,6 +2234,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
it('renders selected UI marks into the extraction reference image and sends derived extraction pricing', async () => {
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValueOnce(
'data:image/png;base64,bWFya2VkLXVpLWRlc2lnbg==',
);
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
spritesheetWidth: 512,
@@ -2166,7 +2288,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
await waitFor(() => {
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceImageSrc: 'data:image/png;base64,marked-ui-design',
sourceImageSrc:
'generated-character-drafts/editor/generation-references/reference.png',
model: 'gemini-3.1-flash-image-preview',
screenColor: 'auto',
segModel: 'birefnet',
@@ -2213,7 +2336,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
fireEvent.click(screen.getByRole('button', { name: '提取带参考图UI素材' }));
await waitFor(() => {
expect(resolveEditorImageReferenceDataUrlForGenerationMock).not.toHaveBeenCalled();
expect(
resolveEditorImageReferenceDataUrlForGenerationMock,
).not.toHaveBeenCalled();
});
await waitFor(() => {
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
@@ -2290,7 +2415,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
createLayer({
id: 'character-layer',
assetKind: 'character',
objectKey: 'generated/character.png',
src: 'data:image/png;base64,Y2hhcmFjdGVy',
}),
]}
initialCharacterAnimationPanel={{
@@ -2314,7 +2439,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceLayerId: 'character-layer',
sourceImageSrc: 'generated/character.png',
sourceImageSrc:
'generated-character-drafts/editor/generation-references/reference.png',
promptText: '跑步',
model: 'seedance2.0-fast',
}),
@@ -2450,7 +2576,12 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
render(
<SubmissionWorkflowHarness
initialLayers={[createLayer({ id: 'character-layer' })]}
initialLayers={[
createLayer({
id: 'character-layer',
objectKey: 'generated/character.png',
}),
]}
initialCharacterAnimationPanel={{
sourceLayerId: 'character-layer',
promptText: '跑步',
@@ -2478,7 +2609,12 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
generateEditorCharacterAnimationMock.mockRejectedValueOnce(new Error(''));
render(
<SubmissionWorkflowHarness
initialLayers={[createLayer({ id: 'character-layer' })]}
initialLayers={[
createLayer({
id: 'character-layer',
objectKey: 'generated/character.png',
}),
]}
initialCharacterAnimationPanel={{
sourceLayerId: 'character-layer',
promptText: '',
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,12 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { useRef, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -12,9 +18,9 @@ import type {
ImageContextMenuState,
SidebarPanel,
} from './ImageCanvasEditorTypes';
import { IMAGE_MODEL_GPT_IMAGE_2 } from './ImageCanvasGenerationModel';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
import { IMAGE_MODEL_GPT_IMAGE_2 } from './ImageCanvasGenerationModel';
const generateEditorImageMock = vi.hoisted(() => vi.fn());
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
@@ -1268,7 +1274,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
assetFolderId: undefined,
assetKind: undefined,
assetLabel: '生成图片 1',
referenceImageSrcs: ['data:image/png;base64,source'],
referenceImageSrcs: ['resource-source'],
generationInputs: {
fields: [{ title: '生成提示词', value: '一张生成图' }],
references: [
@@ -1514,6 +1520,14 @@ describe('useImageCanvasGenerationWorkflow', () => {
});
it('submits spec generation with reference image and reference prompt semantics', async () => {
uploadEditorMediaAssetFileMock.mockResolvedValueOnce({
src: 'https://signed.example.test/generation-reference.png',
objectKey:
'generated-character-drafts/editor/generation-references/reference.png',
assetObjectId: 'asset-object-generation-reference',
legacyPublicPath:
'/generated-character-drafts/editor/generation-references/reference.png',
});
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: 'UI规范图' }),
);
@@ -1531,7 +1545,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
model: 'gpt-image-2',
aspectRatio: '16:9',
imageSize: '2K',
referenceImageSrcs: ['data:image/png;base64,ref'],
referenceImageSrcs: [
'generated-character-drafts/editor/generation-references/reference.png',
],
prompt: expect.stringContaining('参考图生成规范'),
}),
);
@@ -1630,7 +1646,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '快速修图',
sourceImageSrc: 'data:image/png;base64,source',
sourceImageSrc: 'resource-source',
assetLabel: '源图 快速编辑',
sourceResourceId: 'resource-source',
generationInputs: {
@@ -1789,7 +1805,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(removeImageBackgroundMock).toHaveBeenCalledWith({
sourceImageSrc: '/generated-character-drafts/editor/private.png',
sourceImageSrc: 'generated-character-drafts/editor/private.png',
projectId: undefined,
targetLayerId: 'layer-source',
assetKind: 'character',
@@ -1832,6 +1848,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
);
expect(removeImageBackgroundMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceImageSrc: 'resource-source',
projectId: 'project-1',
targetLayerId: 'layer-source',
assetLabel: '源图 去背景',
@@ -1845,6 +1862,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
}),
}),
);
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
await act(async () => {
resolveBackgroundRemoval?.({
imageSrc:
@@ -19,6 +19,7 @@ import {
type EditorProjectResourceSnapshot,
type EditorProjectSnapshot,
} from '../../services/image-editor/editorProjectClient';
import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel';
import type {
CanvasGenerationDialogState,
CanvasLayer,
@@ -36,7 +37,6 @@ import type {
SpecFormValues,
SpecGenerationType,
} from './ImageCanvasEditorTypes';
import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel';
import {
appendCharacterReference,
appendGenerationReference,
@@ -103,6 +103,8 @@ import {
} from './ImageCanvasUiAssetExtractionModel';
import {
applyQueuedEditorGenerationProject,
createEditorGenerationMediaUploadId,
resolveEditorGenerationMediaReference,
useImageCanvasGenerationSubmissionWorkflow,
} from './useImageCanvasGenerationSubmissionWorkflow';
@@ -222,7 +224,10 @@ function preserveSourceLayerInProjectSnapshot(
resources: sourceResource
? [...project.resources, sourceResource]
: project.resources,
layers: [createProjectLayerSnapshotFromLayer(sourceLayer), ...project.layers],
layers: [
createProjectLayerSnapshotFromLayer(sourceLayer),
...project.layers,
],
};
}
@@ -1434,9 +1439,10 @@ export function useImageCanvasGenerationWorkflow({
cropExpandSourceLayer.resourceId;
let cropExpandAssetKind = cropExpandSourceLayer.assetKind;
if (cropExpandProjectId) {
const cropExpandUploadId = createEditorGenerationMediaUploadId();
const cropExpandFile = dataUrlToImageFile(
result.imageSrc,
`crop-expand-${cropExpandIndex}.png`,
`crop-expand-${cropExpandIndex}-${cropExpandUploadId}.png`,
);
const uploadedCropExpand = await uploadEditorMediaAssetFile(
cropExpandFile,
@@ -1447,7 +1453,7 @@ export function useImageCanvasGenerationWorkflow({
'editor',
'crop-expand',
cropExpandProjectId,
`${Date.now()}`,
cropExpandUploadId,
],
entityId: cropExpandProjectId,
metadata: {
@@ -1480,8 +1486,7 @@ export function useImageCanvasGenerationWorkflow({
cropExpandObjectKey =
cropExpandResource.objectKey ?? uploadedCropExpand.objectKey;
cropExpandAssetObjectId =
cropExpandResource.assetObjectId ??
uploadedCropExpand.assetObjectId;
cropExpandResource.assetObjectId ?? uploadedCropExpand.assetObjectId;
cropExpandSourceResourceId =
cropExpandResource.sourceResourceId ??
cropExpandSourceLayer.resourceId;
@@ -1574,10 +1579,11 @@ export function useImageCanvasGenerationWorkflow({
}
: undefined;
try {
const sourceObjectKey = sourceLayer.objectKey?.trim();
const sourceImageSrc = sourceObjectKey
? `/${sourceObjectKey.replace(/^\/+/u, '')}`
: await resolveEditorImageReferenceDataUrl(sourceLayer.src);
const sourceImageSrc = await resolveEditorGenerationMediaReference(
sourceLayer,
'image',
projectId,
);
const result = await removeImageBackground({
sourceImageSrc,
projectId,
@@ -1672,7 +1678,6 @@ export function useImageCanvasGenerationWorkflow({
openPlacedCanvasGenerationDialog,
projectId,
refreshTaskListForQueuedGeneration,
resolveEditorImageReferenceDataUrl,
setActiveSidebarPanel,
setImageContextMenu,
setMetadataLayer,
@@ -95,7 +95,10 @@ test('renders mobile home welcome copy and closes with the acknowledge button',
);
expect(screen.getByRole('dialog', { name: '欢迎' })).toBeTruthy();
expect(screen.getByRole('heading', { name: '欢迎' })).toBeTruthy();
expect(
screen.getByRole('heading', { level: 2, name: '欢迎' }),
).toBeTruthy();
expect(screen.queryByRole('heading', { level: 1 })).toBeNull();
const copy = document.querySelector(
'.platform-mobile-home-welcome-dialog__copy',
);
@@ -68,7 +68,7 @@ export function PlatformMobileHomeWelcomeDialog({
alt=""
className="platform-mobile-home-welcome-dialog__icon"
/>
<h1 className="platform-mobile-home-welcome-dialog__title"></h1>
<h2 className="platform-mobile-home-welcome-dialog__title"></h2>
<p className="platform-mobile-home-welcome-dialog__copy">
er
<br />
@@ -4,6 +4,7 @@ import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, test, vi } from 'vitest';
import type { ProfileRechargeProduct } from '../../../packages/shared/src/contracts/runtime';
import { PlatformProfileRechargeModal } from './PlatformProfileRechargeModal';
function buildNormalMembership() {
@@ -21,22 +22,73 @@ function buildNormalMembership() {
};
}
function buildPointProduct() {
function buildPointProduct(
overrides: Partial<ProfileRechargeProduct> = {},
): ProfileRechargeProduct {
return {
productId: 'points_60',
title: '60泥点',
priceCents: 600,
kind: 'points' as const,
pointsAmount: 60,
bonusPoints: 30,
bonusPoints: 0,
durationDays: 0,
badgeLabel: '首充加赠',
description: '首充加赠30泥点',
badgeLabel: '',
description: '60泥点',
tier: 'normal' as const,
membershipPeriodPoints: 0,
membershipPeriodDays: 0,
membershipQueueLimit: 0,
membershipDiscountBps: 0,
...overrides,
};
}
function buildPointProducts(): ProfileRechargeProduct[] {
return [
buildPointProduct(),
buildPointProduct({
productId: 'points_180',
title: '180泥点',
priceCents: 1800,
pointsAmount: 180,
bonusPoints: 90,
badgeLabel: '首充加赠',
description: '首充加赠90泥点',
}),
buildPointProduct({
productId: 'points_300',
title: '300泥点',
priceCents: 3000,
pointsAmount: 300,
bonusPoints: 150,
badgeLabel: '首充加赠',
description: '首充加赠150泥点',
}),
buildPointProduct({
productId: 'points_680',
title: '680泥点',
priceCents: 6800,
pointsAmount: 680,
bonusPoints: 340,
badgeLabel: '首充加赠',
description: '首充加赠340泥点',
}),
];
}
function buildMembershipProduct(): ProfileRechargeProduct {
return {
...buildPointProduct(),
productId: 'member_month',
title: '会员月卡',
priceCents: 2800,
kind: 'membership',
pointsAmount: 0,
durationDays: 30,
description: '会员月卡',
tier: 'month',
membershipPeriodDays: 30,
};
}
@@ -47,7 +99,7 @@ vi.mock('qrcode', () => ({
}));
describe('PlatformProfileRechargeModal', () => {
test('renders point products and forwards buy action', async () => {
test('renders the four point products and forwards the selected buy action', async () => {
const user = userEvent.setup();
const onBuy = vi.fn();
@@ -56,8 +108,8 @@ describe('PlatformProfileRechargeModal', () => {
center={{
walletBalance: 29,
membership: buildNormalMembership(),
pointProducts: [buildPointProduct()],
membershipProducts: [],
pointProducts: buildPointProducts(),
membershipProducts: [buildMembershipProduct()],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
@@ -66,8 +118,6 @@ describe('PlatformProfileRechargeModal', () => {
error={null}
submittingProductId={null}
nativePayment={null}
activeTab="points"
onTabChange={vi.fn()}
onClose={vi.fn()}
onRetry={vi.fn()}
onBuy={onBuy}
@@ -76,24 +126,48 @@ describe('PlatformProfileRechargeModal', () => {
/>,
);
expect(screen.getByText('账户充值')).toBeTruthy();
expect(screen.getByText('29泥点 · 普通用户')).toBeTruthy();
expect(screen.getByText('60+30泥点')).toBeTruthy();
const dialog = screen.getByRole('dialog', { name: '购买更多泥点' });
expect(within(dialog).getByText('当前余额 29 泥点')).toBeTruthy();
expect(
within(dialog).getByRole('button', { name: '60泥点 ¥6 购买' }),
).toBeTruthy();
expect(
within(dialog).getByRole('button', {
name: '180泥点 +90泥点 ¥18 购买',
}),
).toBeTruthy();
expect(
within(dialog).getByRole('button', {
name: '300泥点 +150泥点 ¥30 购买',
}),
).toBeTruthy();
expect(
within(dialog).getByRole('button', {
name: '680泥点 +340泥点 ¥68 购买',
}),
).toBeTruthy();
expect(within(dialog).getAllByText('首充加赠')).toHaveLength(3);
expect(within(dialog).queryByRole('tablist')).toBeNull();
expect(within(dialog).queryByText('会员月卡')).toBeNull();
await user.click(screen.getByRole('button', { name: /60.*/u }));
await user.click(
within(dialog).getByRole('button', {
name: '180泥点 +90泥点 ¥18 购买',
}),
);
expect(onBuy).toHaveBeenCalledWith(
expect.objectContaining({ productId: 'points_60' }),
expect.objectContaining({ productId: 'points_180' }),
);
});
test('shows empty state when the selected tab has no products', () => {
test('shows the point-product empty state without exposing membership purchase', () => {
render(
<PlatformProfileRechargeModal
center={{
walletBalance: 0,
membership: buildNormalMembership(),
pointProducts: [],
membershipProducts: [],
membershipProducts: [buildMembershipProduct()],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
@@ -102,8 +176,6 @@ describe('PlatformProfileRechargeModal', () => {
error={null}
submittingProductId={null}
nativePayment={null}
activeTab="membership"
onTabChange={vi.fn()}
onClose={vi.fn()}
onRetry={vi.fn()}
onBuy={vi.fn()}
@@ -113,49 +185,8 @@ 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: buildNormalMembership(),
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()}
onCloseNativePayment={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');
expect(screen.queryByText('会员月卡')).toBeNull();
expect(screen.queryByRole('tablist')).toBeNull();
});
test('opens native payment QR code in a separate dialog', async () => {
@@ -186,8 +217,6 @@ describe('PlatformProfileRechargeModal', () => {
isConfirming: false,
confirmMessage: '暂未确认到账,请确认付款完成后再点一次。',
}}
activeTab="points"
onTabChange={vi.fn()}
onClose={vi.fn()}
onRetry={vi.fn()}
onBuy={vi.fn()}
@@ -196,7 +225,9 @@ describe('PlatformProfileRechargeModal', () => {
/>,
);
const rechargeDialog = screen.getByRole('dialog', { name: '账户充值' });
const rechargeDialog = screen.getByRole('dialog', {
name: '购买更多泥点',
});
const paymentDialog = screen.getByRole('dialog', {
name: '微信扫码支付',
});
@@ -253,8 +284,6 @@ describe('PlatformProfileRechargeModal', () => {
expiresAt: '2000-01-01T00:00:00Z',
isConfirming: false,
}}
activeTab="points"
onTabChange={vi.fn()}
onClose={vi.fn()}
onRetry={vi.fn()}
onBuy={vi.fn()}

Some files were not shown because too many files have changed in this diff Show More