合并主分支最新改动
同步 master 的最新功能与修复 保留 AI 游戏创作客户端分支现有实现 # Conflicts: # docs/project-memory/shared-memory/decision-log.md # docs/project-memory/shared-memory/pitfalls.md # scripts/dev.mjs # server-rs/crates/api-server/src/editor_screen_background_decision.rs # server-rs/crates/api-server/src/modules/admin.rs # src/components/rpg-entry/RpgEntryHomeView.tsx
This commit is contained in:
+6
-2
@@ -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 () => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { removeBackgroundFromRgba } from '../../../packages/shared/src/assets/chromaKey';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
@@ -453,19 +452,6 @@ export function loadImageFromSource(source: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function loadVideoFromSource(source: string) {
|
||||
return new Promise<HTMLVideoElement>((resolve, reject) => {
|
||||
const video = document.createElement('video');
|
||||
video.crossOrigin = 'anonymous';
|
||||
video.preload = 'auto';
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.onloadeddata = () => resolve(video);
|
||||
video.onerror = () => reject(new Error(`加载视频失败:${source}`));
|
||||
video.src = source;
|
||||
});
|
||||
}
|
||||
|
||||
function createCanvas(width: number, height: number) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
@@ -708,200 +694,6 @@ export async function buildAnimationClipFromMaster(
|
||||
} satisfies DraftAnimationClip;
|
||||
}
|
||||
|
||||
function applyGreenScreenAlpha(
|
||||
context: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
const imageData = context.getImageData(0, 0, width, height);
|
||||
removeBackgroundFromRgba(imageData.data, width, height);
|
||||
|
||||
context.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
async function normalizeFrameSourceToDataUrl(
|
||||
frameSource: string,
|
||||
options: {
|
||||
frameWidth: number;
|
||||
frameHeight: number;
|
||||
applyChromaKey: boolean;
|
||||
},
|
||||
) {
|
||||
const image = await loadImageFromSource(frameSource);
|
||||
const { canvas, context } = createCanvas(
|
||||
options.frameWidth,
|
||||
options.frameHeight,
|
||||
);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawContainedImage(context, image, {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
});
|
||||
|
||||
if (options.applyChromaKey) {
|
||||
applyGreenScreenAlpha(context, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
export async function normalizeMasterVisualSourceToDataUrl(
|
||||
source: string,
|
||||
options: {
|
||||
applyChromaKey?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const image = await loadImageFromSource(source);
|
||||
const { canvas, context } = createCanvas(
|
||||
MASTER_VISUAL_WIDTH,
|
||||
MASTER_VISUAL_HEIGHT,
|
||||
);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawContainedImage(context, image, {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
});
|
||||
|
||||
if (options.applyChromaKey !== false) {
|
||||
applyGreenScreenAlpha(context, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
return {
|
||||
dataUrl: canvas.toDataURL('image/png'),
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
};
|
||||
}
|
||||
|
||||
function seekVideo(video: HTMLVideoElement, targetTime: number) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (Math.abs(video.currentTime - targetTime) < 0.001) {
|
||||
window.requestAnimationFrame(() => resolve());
|
||||
return;
|
||||
}
|
||||
|
||||
const handleSeeked = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const handleError = () => {
|
||||
cleanup();
|
||||
reject(new Error('视频定位失败'));
|
||||
};
|
||||
const cleanup = () => {
|
||||
video.removeEventListener('seeked', handleSeeked);
|
||||
video.removeEventListener('error', handleError);
|
||||
};
|
||||
|
||||
video.addEventListener('seeked', handleSeeked, { once: true });
|
||||
video.addEventListener('error', handleError, { once: true });
|
||||
video.currentTime = Math.max(0, targetTime);
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildAnimationClipFromImageSources(
|
||||
sources: string[],
|
||||
options: {
|
||||
animation: AnimationState;
|
||||
fps: number;
|
||||
loop: boolean;
|
||||
frameWidth?: number;
|
||||
frameHeight?: number;
|
||||
applyChromaKey?: boolean;
|
||||
},
|
||||
) {
|
||||
const frameWidth = options.frameWidth ?? GENERATED_FRAME_WIDTH;
|
||||
const frameHeight = options.frameHeight ?? GENERATED_FRAME_HEIGHT;
|
||||
const frames = await Promise.all(
|
||||
sources.map((source) =>
|
||||
normalizeFrameSourceToDataUrl(source, {
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
applyChromaKey: options.applyChromaKey ?? false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
animation: options.animation,
|
||||
frames,
|
||||
fps: Math.max(1, options.fps),
|
||||
loop: options.loop,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
} satisfies DraftAnimationClip;
|
||||
}
|
||||
|
||||
export async function buildAnimationClipFromVideoSource(
|
||||
videoSource: string,
|
||||
options: {
|
||||
animation: AnimationState;
|
||||
fps: number;
|
||||
loop: boolean;
|
||||
frameCount?: number;
|
||||
frameWidth?: number;
|
||||
frameHeight?: number;
|
||||
applyChromaKey?: boolean;
|
||||
sampleStartRatio?: number;
|
||||
sampleEndRatio?: number;
|
||||
},
|
||||
) {
|
||||
const video = await loadVideoFromSource(videoSource);
|
||||
const frameWidth = options.frameWidth ?? GENERATED_FRAME_WIDTH;
|
||||
const frameHeight = options.frameHeight ?? GENERATED_FRAME_HEIGHT;
|
||||
const duration =
|
||||
Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 1;
|
||||
const derivedFrameCount = Math.max(
|
||||
2,
|
||||
options.frameCount ?? Math.round(duration * Math.max(1, options.fps)),
|
||||
);
|
||||
const sampleStartRatio = Math.min(
|
||||
0.85,
|
||||
Math.max(0, options.sampleStartRatio ?? 0),
|
||||
);
|
||||
const sampleEndRatio = Math.min(
|
||||
1,
|
||||
Math.max(sampleStartRatio + 0.05, options.sampleEndRatio ?? 1),
|
||||
);
|
||||
const sampleWindowDuration = duration * (sampleEndRatio - sampleStartRatio);
|
||||
const { canvas, context } = createCanvas(frameWidth, frameHeight);
|
||||
const frames: string[] = [];
|
||||
|
||||
for (let frameIndex = 0; frameIndex < derivedFrameCount; frameIndex += 1) {
|
||||
const progress = options.loop
|
||||
? frameIndex / derivedFrameCount
|
||||
: frameIndex / Math.max(1, derivedFrameCount - 1);
|
||||
const targetTime = Math.min(
|
||||
duration - 0.001,
|
||||
duration * sampleStartRatio + sampleWindowDuration * progress,
|
||||
);
|
||||
|
||||
await seekVideo(video, targetTime);
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawContainedSource(context, video, video.videoWidth, video.videoHeight, {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
});
|
||||
|
||||
if (options.applyChromaKey) {
|
||||
applyGreenScreenAlpha(context, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
frames.push(canvas.toDataURL('image/png'));
|
||||
}
|
||||
|
||||
return {
|
||||
animation: options.animation,
|
||||
frames,
|
||||
fps: Math.max(1, options.fps),
|
||||
loop: options.loop,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
previewVideoPath: videoSource,
|
||||
} satisfies DraftAnimationClip;
|
||||
}
|
||||
|
||||
async function buildReferenceVideoFromFrameSources(
|
||||
frameSources: string[],
|
||||
options: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>登录即送100泥点,可以免费制作50个素材</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 />
|
||||
|
||||
@@ -58,6 +58,7 @@ function BasicGenerationHarness({
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
<output aria-label="当前提示词">{dialog.prompt}</output>
|
||||
<output aria-label="当前资源名称">{dialog.assetLabel ?? '-'}</output>
|
||||
<output aria-label="当前比例">{dialog.aspectRatio}</output>
|
||||
<output aria-label="当前尺寸">{dialog.imageSize}</output>
|
||||
<output aria-label="当前模型">{dialog.imageModel}</output>
|
||||
@@ -86,17 +87,27 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
|
||||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||||
target: { value: '新的提示' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('资源名称'), {
|
||||
target: { value: '自定义主视觉' },
|
||||
});
|
||||
const panel = screen.getByRole('dialog', { name: '生成图片' });
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '添加参考图' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
expect(screen.getByLabelText('当前提示词').textContent).toBe('新的提示');
|
||||
expect(screen.getByLabelText('当前资源名称').textContent).toBe(
|
||||
'自定义主视觉',
|
||||
);
|
||||
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
|
||||
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
|
||||
expect(requestUpload).toHaveBeenCalledWith('generation-reference');
|
||||
expect(submitGeneration).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ prompt: '新的提示', status: 'idle' }),
|
||||
expect.objectContaining({
|
||||
prompt: '新的提示',
|
||||
assetLabel: '自定义主视觉',
|
||||
status: 'idle',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -183,7 +194,7 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
|
||||
expect(submitButton.textContent).toBe('生成5泥点');
|
||||
});
|
||||
|
||||
it('keeps quick edit to one prompt box and model selection', () => {
|
||||
it('keeps quick edit to one prompt box with image parameters', () => {
|
||||
render(
|
||||
<BasicGenerationHarness
|
||||
initialDialog={createDialog({
|
||||
@@ -213,10 +224,10 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
|
||||
).toBeNull();
|
||||
expect(within(panel).queryByText('旧参考图')).toBeNull();
|
||||
expect(
|
||||
within(panel).queryByRole('button', {
|
||||
name: /快速编辑图片尺寸/u,
|
||||
within(panel).getByRole('button', {
|
||||
name: '快速编辑图片尺寸 1:1·1K',
|
||||
}),
|
||||
).toBeNull();
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('button', {
|
||||
name: '快速编辑图片模型 gpt-image-2',
|
||||
@@ -234,22 +245,16 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
|
||||
name: '生成图片尺寸 16:9·1K',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('menu', { name: '生成图片尺寸选项' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('menu', { name: '生成图片尺寸选项' })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('textbox', { name: '生成提示词' }));
|
||||
|
||||
expect(
|
||||
screen.queryByRole('menu', { name: '生成图片尺寸选项' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('menu', { name: '生成图片尺寸选项' })).toBeNull();
|
||||
});
|
||||
|
||||
it('does not render a standalone close button', () => {
|
||||
render(<BasicGenerationHarness />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '关闭生成图片' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '关闭生成图片' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
GenerateDialogState,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
|
||||
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
||||
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
|
||||
import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel';
|
||||
@@ -140,19 +141,21 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
const references = dialog.generationReferences ?? [];
|
||||
const isQuickEdit = dialog.mode === 'quick-edit';
|
||||
const resolvedDialogLabel =
|
||||
dialogLabel ?? optionLabelPrefix ?? (isQuickEdit ? '快速编辑图片' : '生成图片');
|
||||
dialogLabel ??
|
||||
optionLabelPrefix ??
|
||||
(isQuickEdit ? '快速编辑图片' : '生成图片');
|
||||
const resolvedPromptLabel =
|
||||
promptLabel ?? (isQuickEdit ? '快速编辑提示词' : '生成提示词');
|
||||
const resolvedPromptPlaceholder =
|
||||
promptPlaceholder ??
|
||||
(isQuickEdit ? '写下每个编号要怎么改' : '今天想生成什么画面?');
|
||||
(isQuickEdit ? '你希望素材如何修改?' : '今天想生成什么画面?');
|
||||
const resolvedOptionLabelPrefix =
|
||||
optionLabelPrefix ?? (isQuickEdit ? '快速编辑图片' : '生成图片');
|
||||
const resolvedReferenceButtonLabel = referenceButtonLabel;
|
||||
const resolvedReferenceButtonAriaLabel =
|
||||
referenceButtonAriaLabel ?? `添加${resolvedReferenceButtonLabel}`;
|
||||
const shouldIncludeReferences = isQuickEdit ? false : includeReferences;
|
||||
const shouldIncludeDimensions = isQuickEdit ? false : includeDimensions;
|
||||
const shouldIncludeDimensions = includeDimensions;
|
||||
const resolvedSubmitLabel =
|
||||
isQuickEdit && submitLabel === '生成' ? '修改' : submitLabel;
|
||||
const resolvedSubmitAriaLabel =
|
||||
@@ -265,6 +268,20 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ImageCanvasGenerationAssetNameField
|
||||
value={dialog.assetLabel}
|
||||
disabled={dialog.status === 'generating'}
|
||||
onChange={(assetLabel) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog
|
||||
? {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
assetLabel,
|
||||
}
|
||||
: currentDialog,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className={finalFooterClassName}>
|
||||
<ImageCanvasGenerationImageOptionsView
|
||||
dialog={dialog}
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
CanvasLayer,
|
||||
CharacterAnimationPanelState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
|
||||
import {
|
||||
CHARACTER_ANIMATION_ACTION_PROMPTS,
|
||||
CHARACTER_ANIMATION_DURATION_OPTIONS,
|
||||
@@ -211,6 +212,11 @@ export function ImageCanvasCharacterAnimationPanelView({
|
||||
updatePanel({ promptText: event.target.value.slice(0, 4000) })
|
||||
}
|
||||
/>
|
||||
<ImageCanvasGenerationAssetNameField
|
||||
value={panel.assetLabel}
|
||||
disabled={isGenerating}
|
||||
onChange={(assetLabel) => updatePanel({ assetLabel })}
|
||||
/>
|
||||
<div className="image-canvas-editor__character-animation-presets">
|
||||
{CHARACTER_ANIMATION_ACTION_PROMPTS.map((preset) => (
|
||||
<button
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
SpecGenerationType,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
|
||||
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
||||
import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel';
|
||||
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
|
||||
@@ -275,6 +276,20 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<ImageCanvasGenerationAssetNameField
|
||||
value={dialog.assetLabel}
|
||||
disabled={dialog.status === 'generating'}
|
||||
onChange={(assetLabel) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog?.mode === 'character'
|
||||
? {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
assetLabel,
|
||||
}
|
||||
: currentDialog,
|
||||
)
|
||||
}
|
||||
/>
|
||||
{dialog.status === 'failed' ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
|
||||
@@ -265,4 +265,30 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(2);
|
||||
expect(props.onDeleteLayerById).toHaveBeenCalledWith(layer.id);
|
||||
});
|
||||
|
||||
it.each(['icon', 'icon-spritesheet'] as const)(
|
||||
'hides quick edit from the standalone menu for %s assets',
|
||||
(assetKind) => {
|
||||
const layer = createLayer({ assetKind });
|
||||
renderContextMenus({
|
||||
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
|
||||
imageContextMenuLayer: layer,
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('menuitem', { name: '快速编辑' })).toBeNull();
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: '查看图片信息' }),
|
||||
).toBeTruthy();
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps quick edit in the standalone menu for icon specs', () => {
|
||||
const layer = createLayer({ assetKind: 'icon-spec' });
|
||||
renderContextMenus({
|
||||
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
|
||||
imageContextMenuLayer: layer,
|
||||
});
|
||||
|
||||
expect(screen.getByRole('menuitem', { name: '快速编辑' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
CanvasViewport,
|
||||
ImageContextMenuState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { isQuickEditUnsupportedAssetKind } from './ImageCanvasGenerationModel';
|
||||
|
||||
type ImageCanvasContextMenusViewProps = {
|
||||
viewport: CanvasViewport;
|
||||
@@ -477,17 +478,19 @@ export function ImageCanvasContextMenusView({
|
||||
<hr />
|
||||
{imageContextMenuLayer ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
onOpenQuickEditPanel(imageContextMenuLayer);
|
||||
onCloseContextMenu();
|
||||
onCloseImageContextMenu();
|
||||
}}
|
||||
>
|
||||
快速编辑
|
||||
</button>
|
||||
{!isQuickEditUnsupportedAssetKind(imageContextMenuLayer) ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
onOpenQuickEditPanel(imageContextMenuLayer);
|
||||
onCloseContextMenu();
|
||||
onCloseImageContextMenu();
|
||||
}}
|
||||
>
|
||||
快速编辑
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -539,15 +542,17 @@ export function ImageCanvasContextMenusView({
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<PlatformFloatingMenu label="图片功能面板" placement="bottom-start">
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
onOpenQuickEditPanel(imageContextMenuLayer);
|
||||
onCloseImageContextMenu();
|
||||
}}
|
||||
>
|
||||
快速编辑
|
||||
</PlatformFloatingMenuItem>
|
||||
{!isQuickEditUnsupportedAssetKind(imageContextMenuLayer) ? (
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
onOpenQuickEditPanel(imageContextMenuLayer);
|
||||
onCloseImageContextMenu();
|
||||
}}
|
||||
>
|
||||
快速编辑
|
||||
</PlatformFloatingMenuItem>
|
||||
) : null}
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
|
||||
@@ -262,6 +262,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
phaseDetail: overrides.phaseDetail ?? '正在生成角色形象。',
|
||||
progress: overrides.progress ?? 40,
|
||||
error: overrides.error ?? null,
|
||||
warning: overrides.warning ?? null,
|
||||
priceMudPoints: overrides.priceMudPoints ?? 3,
|
||||
refundLedgerId: overrides.refundLedgerId ?? null,
|
||||
notificationAcknowledgedAt: overrides.notificationAcknowledgedAt ?? null,
|
||||
@@ -2446,7 +2447,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('opens icon asset generation panel, only picks icon specs, and lays only the generated spritesheet on canvas', async () => {
|
||||
it('opens icon asset generation panel, only picks icon specs, and lays out the generated spritesheet slices', async () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-icons',
|
||||
title: '图标素材画布',
|
||||
@@ -2489,7 +2490,20 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
spritesheetImageSrc: 'data:image/png;base64,sheet',
|
||||
spritesheetWidth: 512,
|
||||
spritesheetHeight: 512,
|
||||
iconImageSrcs: [],
|
||||
iconImageSrcs: [
|
||||
{
|
||||
name: '返回按钮',
|
||||
imageSrc: 'data:image/png;base64,back',
|
||||
width: 128,
|
||||
height: 128,
|
||||
},
|
||||
{
|
||||
name: '设置按钮',
|
||||
imageSrc: 'data:image/png;base64,settings',
|
||||
width: 128,
|
||||
height: 128,
|
||||
},
|
||||
],
|
||||
prompt: '图标 prompt',
|
||||
actualPrompt: '图标 prompt',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
@@ -2566,7 +2580,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
await waitFor(() => {
|
||||
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
referenceImageSrc: 'data:image/png;base64,icon-spec',
|
||||
referenceImageSrc: 'resource-icon-spec',
|
||||
iconDescriptions: ['返回按钮', '设置按钮'],
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
aspectRatio: '1:1',
|
||||
@@ -2588,8 +2602,8 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText(/图层缩略图:图标素材图集/u)).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByAltText('画布图片:返回按钮')).toBeNull();
|
||||
expect(screen.queryByAltText('画布图片:设置按钮')).toBeNull();
|
||||
expect(screen.getByAltText('画布图片:返回按钮')).toBeTruthy();
|
||||
expect(screen.getByAltText('画布图片:设置按钮')).toBeTruthy();
|
||||
expect(screen.queryByLabelText('图标素材生成占位图')).toBeNull();
|
||||
expect(screen.getByText('图集')).toBeTruthy();
|
||||
fireEvent.click(
|
||||
@@ -2882,6 +2896,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
phaseDetail: '生成已完成。',
|
||||
warning: '连通域数量不足',
|
||||
completedAt: '2026-06-21T00:01:00.000Z',
|
||||
updatedAt: '2026-06-21T00:01:00.000Z',
|
||||
updatedAtMicros: 2,
|
||||
@@ -2921,6 +2936,9 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(loadEditorProjectMock).toHaveBeenCalledWith(projectId);
|
||||
});
|
||||
expect(await screen.findByAltText('画布图片:刷新后生成结果')).toBeTruthy();
|
||||
expect((await screen.findByRole('alert')).textContent).toBe(
|
||||
'图集已生成,但自动拆分未完成:连通域数量不足',
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(loadEditorAssetLibraryMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
canvasDisplayViewportToViewport,
|
||||
CANVAS_WORLD_ORIGIN,
|
||||
createLayerFromAsset,
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
formatCanvasDisplayScalePercent,
|
||||
hydrateLayer,
|
||||
normalizeAssetLibrary,
|
||||
@@ -49,6 +50,42 @@ describe('ImageCanvasEditorModel', () => {
|
||||
expect(normalizeCanvasBackgroundHex('#not-a-color')).toBeNull();
|
||||
});
|
||||
|
||||
it('serializes canvas background settings without treating them as layers', () => {
|
||||
const layout = serializeCanvasLayout({
|
||||
layers: [],
|
||||
canvasGenerationDialogs: [],
|
||||
canvasBackgroundColor: ' #ABC ',
|
||||
});
|
||||
|
||||
expect(layout).toEqual([
|
||||
expect.objectContaining({
|
||||
itemType: 'canvas-settings',
|
||||
layerId: 'canvas-settings:default',
|
||||
resourceId: 'canvas-settings:default',
|
||||
canvasBackgroundColor: '#aabbcc',
|
||||
}),
|
||||
]);
|
||||
|
||||
const { layerItems, generationDialogs, canvasBackgroundColor } =
|
||||
splitCanvasLayoutItems(layout);
|
||||
|
||||
expect(layerItems).toEqual([]);
|
||||
expect(generationDialogs).toEqual([]);
|
||||
expect(canvasBackgroundColor).toBe('#aabbcc');
|
||||
});
|
||||
|
||||
it('drops invalid canvas background settings from serialized layouts', () => {
|
||||
const layout = serializeCanvasLayout({
|
||||
layers: [],
|
||||
canvasGenerationDialogs: [],
|
||||
canvasBackgroundColor: '#not-a-color',
|
||||
});
|
||||
|
||||
expect(layout).toEqual([]);
|
||||
expect(splitCanvasLayoutItems(layout).canvasBackgroundColor).toBeUndefined();
|
||||
expect(DEFAULT_CANVAS_BACKGROUND_COLOR).toBe('#f8fafc');
|
||||
});
|
||||
|
||||
it('keeps only one default asset folder when normalizing the persisted library', () => {
|
||||
const library = normalizeAssetLibrary({
|
||||
folders: [
|
||||
@@ -132,7 +169,7 @@ describe('ImageCanvasEditorModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a layer from an account asset at the requested screen point', () => {
|
||||
it('creates a cascaded layer from an account asset near the requested screen point', () => {
|
||||
const asset: EditorAsset = {
|
||||
id: 'asset-1',
|
||||
label: '角色草图',
|
||||
@@ -173,6 +210,31 @@ describe('ImageCanvasEditorModel', () => {
|
||||
expect(layer.y).toBe(12);
|
||||
});
|
||||
|
||||
it('centers a dropped asset exactly at the requested screen point', () => {
|
||||
const asset: EditorAsset = {
|
||||
id: 'asset-drop',
|
||||
label: '投放素材',
|
||||
src: 'data:image/png;base64,drop',
|
||||
width: 640,
|
||||
height: 480,
|
||||
folderId: 'project',
|
||||
sourceKind: 'uploaded',
|
||||
sourceType: 'uploaded',
|
||||
persisted: true,
|
||||
};
|
||||
|
||||
const layer = createLayerFromAsset(
|
||||
asset,
|
||||
3,
|
||||
{ x: 20, y: 40, scale: 2 },
|
||||
{ x: 420, y: 340 },
|
||||
{ applyCascadeOffset: false },
|
||||
);
|
||||
|
||||
expect(layer.x + layer.width / 2).toBe(200);
|
||||
expect(layer.y + layer.height / 2).toBe(150);
|
||||
});
|
||||
|
||||
it('preserves source resource ids from the persisted asset library', () => {
|
||||
const library = normalizeAssetLibrary({
|
||||
folders: [
|
||||
|
||||
@@ -138,6 +138,7 @@ export function createLayerFromAsset(
|
||||
index: number,
|
||||
viewport: CanvasViewport,
|
||||
screenCenter: { x: number; y: number },
|
||||
options: { applyCascadeOffset?: boolean } = {},
|
||||
): CanvasLayer {
|
||||
const { width, height } = resolveLayerResolutionSize(
|
||||
asset.width,
|
||||
@@ -151,7 +152,7 @@ export function createLayerFromAsset(
|
||||
};
|
||||
const worldCenterX = (safeScreenCenter.x - viewport.x) / safeScale;
|
||||
const worldCenterY = (safeScreenCenter.y - viewport.y) / safeScale;
|
||||
const offset = index * 34;
|
||||
const offset = options.applyCascadeOffset === false ? 0 : index * 34;
|
||||
const assetKind: CanvasAssetKind | undefined =
|
||||
asset.mediaType === 'video'
|
||||
? 'video'
|
||||
@@ -300,8 +301,15 @@ type CanvasGenerationDialogSnapshot = EditorProjectLayerSnapshot & {
|
||||
dialog: CanvasGenerationDialogState;
|
||||
};
|
||||
|
||||
type CanvasSettingsLayoutSnapshot = EditorProjectLayerSnapshot & {
|
||||
itemType: 'canvas-settings';
|
||||
canvasBackgroundColor?: string;
|
||||
};
|
||||
|
||||
export type CanvasLayoutItems = EditorProjectLayerSnapshot[];
|
||||
|
||||
const CANVAS_SETTINGS_LAYOUT_ITEM_ID = 'canvas-settings:default';
|
||||
|
||||
function isPersistedReferenceResourceId(resourceId: string | null | undefined) {
|
||||
const normalizedResourceId = resourceId?.trim();
|
||||
return Boolean(
|
||||
@@ -419,14 +427,37 @@ export function serializeCanvasGenerationDialog(
|
||||
};
|
||||
}
|
||||
|
||||
function serializeCanvasSettings({
|
||||
canvasBackgroundColor,
|
||||
}: {
|
||||
canvasBackgroundColor?: string | null;
|
||||
}): CanvasSettingsLayoutSnapshot | null {
|
||||
const normalizedBackgroundColor = canvasBackgroundColor
|
||||
? normalizeCanvasBackgroundHex(canvasBackgroundColor)
|
||||
: null;
|
||||
if (!normalizedBackgroundColor) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
itemType: 'canvas-settings',
|
||||
layerId: CANVAS_SETTINGS_LAYOUT_ITEM_ID,
|
||||
resourceId: CANVAS_SETTINGS_LAYOUT_ITEM_ID,
|
||||
canvasBackgroundColor: normalizedBackgroundColor,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeCanvasLayout({
|
||||
layers,
|
||||
canvasGenerationDialogs,
|
||||
canvasBackgroundColor,
|
||||
}: {
|
||||
layers: CanvasLayer[];
|
||||
canvasGenerationDialogs: CanvasGenerationDialogState[];
|
||||
canvasBackgroundColor?: string | null;
|
||||
}): CanvasLayoutItems {
|
||||
const canvasSettings = serializeCanvasSettings({ canvasBackgroundColor });
|
||||
return [
|
||||
...(canvasSettings ? [canvasSettings] : []),
|
||||
...layers.map(serializeLayer),
|
||||
...canvasGenerationDialogs.map(serializeCanvasGenerationDialog),
|
||||
];
|
||||
@@ -445,6 +476,12 @@ export function isCanvasGenerationDialogLayoutItem(
|
||||
);
|
||||
}
|
||||
|
||||
function isCanvasSettingsLayoutItem(
|
||||
item: EditorProjectLayerSnapshot,
|
||||
): item is CanvasSettingsLayoutSnapshot {
|
||||
return item.itemType === 'canvas-settings';
|
||||
}
|
||||
|
||||
export function splitCanvasLayoutItems(
|
||||
items: EditorProjectLayerSnapshot[],
|
||||
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
|
||||
@@ -452,11 +489,23 @@ export function splitCanvasLayoutItems(
|
||||
): {
|
||||
layerItems: EditorProjectLayerSnapshot[];
|
||||
generationDialogs: CanvasGenerationDialogState[];
|
||||
canvasBackgroundColor?: string;
|
||||
} {
|
||||
const layerItems: EditorProjectLayerSnapshot[] = [];
|
||||
const generationDialogs: CanvasGenerationDialogState[] = [];
|
||||
let canvasBackgroundColor: string | undefined;
|
||||
|
||||
items.forEach((item) => {
|
||||
if (isCanvasSettingsLayoutItem(item)) {
|
||||
const normalizedBackgroundColor =
|
||||
typeof item.canvasBackgroundColor === 'string'
|
||||
? normalizeCanvasBackgroundHex(item.canvasBackgroundColor)
|
||||
: null;
|
||||
if (normalizedBackgroundColor) {
|
||||
canvasBackgroundColor = normalizedBackgroundColor;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isCanvasGenerationDialogLayoutItem(item)) {
|
||||
const dialog = hydrateCanvasGenerationDialog(
|
||||
item.dialog,
|
||||
@@ -471,7 +520,7 @@ export function splitCanvasLayoutItems(
|
||||
layerItems.push(item);
|
||||
});
|
||||
|
||||
return { layerItems, generationDialogs };
|
||||
return { layerItems, generationDialogs, canvasBackgroundColor };
|
||||
}
|
||||
|
||||
export function hydrateCanvasGenerationDialog(
|
||||
@@ -1097,6 +1146,7 @@ export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null {
|
||||
value === 'icon' ||
|
||||
value === 'icon-spritesheet' ||
|
||||
value === 'icon-spec' ||
|
||||
value === 'editor_green_screen_source' ||
|
||||
value === 'publication-material' ||
|
||||
value === 'ui-design' ||
|
||||
value === 'video' ||
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -189,6 +194,7 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
onOpenRedrawPanel: vi.fn(),
|
||||
onOpenCropExpandPanel: vi.fn(),
|
||||
onRemoveBackground: vi.fn(),
|
||||
onSplitIconSpritesheet: vi.fn(),
|
||||
onExtractUiDesignAssets: vi.fn(),
|
||||
onUiAssetExtractionToolChange: vi.fn(),
|
||||
onUiAssetExtractionModelChange: vi.fn(),
|
||||
|
||||
@@ -20,6 +20,7 @@ export type CanvasAssetKind =
|
||||
| 'icon'
|
||||
| 'icon-spritesheet'
|
||||
| 'icon-spec'
|
||||
| 'editor_green_screen_source'
|
||||
| 'publication-material'
|
||||
| 'ui-design'
|
||||
| 'video'
|
||||
@@ -197,6 +198,7 @@ export type GenerateDialogState = {
|
||||
| 'audio-sound-effect'
|
||||
| 'audio-background-music';
|
||||
prompt: string;
|
||||
assetLabel?: string;
|
||||
status: 'idle' | 'generating' | 'failed';
|
||||
composerOpen?: boolean;
|
||||
sourceLayerId?: string;
|
||||
@@ -304,6 +306,7 @@ export type QuickEditPanelState = {
|
||||
mode?: 'quick-edit' | 'redraw';
|
||||
sourceLayerId: string;
|
||||
prompt: string;
|
||||
assetLabel?: string;
|
||||
size: string;
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
@@ -347,6 +350,7 @@ export type CropExpandResizeHandle =
|
||||
export type CharacterAnimationPanelState = {
|
||||
sourceLayerId: string;
|
||||
promptText: string;
|
||||
assetLabel?: string;
|
||||
resolution: EditorCharacterAnimationResolution;
|
||||
ratio: EditorCharacterAnimationRatio;
|
||||
frameCount: EditorCharacterAnimationFrameCount;
|
||||
|
||||
@@ -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 './useEditorAgentConversation';
|
||||
import {
|
||||
ApiClientError,
|
||||
AuthUiContext,
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
readZipText,
|
||||
setupImageCanvasEditorViewTestLifecycle,
|
||||
} from './ImageCanvasEditorView.test-utils';
|
||||
import type { EditorAgentConversationClient } from './useEditorAgentConversation';
|
||||
|
||||
type EditorAgentListConversations =
|
||||
EditorAgentConversationClient['listConversations'];
|
||||
@@ -110,6 +110,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<
|
||||
@@ -140,6 +142,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,
|
||||
}));
|
||||
@@ -276,6 +289,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(() => {
|
||||
@@ -285,6 +338,8 @@ describe('ImageCanvasEditorView', () => {
|
||||
deleteEditorAgentConversationMock.mockReset();
|
||||
streamEditorAgentMessageMock.mockReset();
|
||||
getPlatformProfileDashboardMock.mockReset();
|
||||
getRpgProfileRechargeCenterMock.mockReset();
|
||||
getRpgProfileWalletLedgerMock.mockReset();
|
||||
loadFrontendRuntimeConfigMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -314,6 +369,46 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores the canvas background color from the persisted project layout', async () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-background',
|
||||
title: '背景色项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
itemType: 'canvas-settings',
|
||||
layerId: 'canvas-settings:default',
|
||||
resourceId: 'canvas-settings:default',
|
||||
canvasBackgroundColor: '#112233',
|
||||
},
|
||||
],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const viewport = screen.getByLabelText('画布工作区');
|
||||
await waitFor(() => {
|
||||
expect((viewport as HTMLElement).style.backgroundColor).toBe(
|
||||
'rgb(17, 34, 51)',
|
||||
);
|
||||
});
|
||||
|
||||
const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' });
|
||||
fireEvent.click(
|
||||
within(panelToolbar).getByRole('button', { name: '画布背景色' }),
|
||||
);
|
||||
|
||||
expect(
|
||||
(
|
||||
within(
|
||||
screen.getByRole('dialog', { name: '画布背景设置' }),
|
||||
).getByLabelText('画布背景十六进制颜色') as HTMLInputElement
|
||||
).value,
|
||||
).toBe('#112233');
|
||||
});
|
||||
|
||||
it('shows the toolbar guide for a newly created blank project until a generator opens', async () => {
|
||||
loadEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-guide',
|
||||
@@ -455,7 +550,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,
|
||||
@@ -500,7 +595,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(openAccountModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens the same account wallet 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({
|
||||
@@ -522,13 +617,175 @@ describe('ImageCanvasEditorView', () => {
|
||||
);
|
||||
|
||||
const walletButton = await screen.findByRole('button', {
|
||||
name: '泥点余额 1,234泥点',
|
||||
name: '泥点 1,234',
|
||||
});
|
||||
|
||||
fireEvent.click(walletButton);
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: '兑换码' })).toBeTruthy();
|
||||
expect(screen.getByPlaceholderText('输入兑换码')).toBeTruthy();
|
||||
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: '泥点账单' }),
|
||||
).toBeTruthy();
|
||||
expect(getRpgProfileWalletLedgerMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('suspends canvas interaction during account payment dialogs and restores completed quick edit selections', 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 sourceLayer = (
|
||||
await screen.findByAltText('画布图片:拼图素材')
|
||||
).closest('button')!;
|
||||
dispatchPointerEvent(sourceLayer, 'pointerdown', {
|
||||
button: 0,
|
||||
pointerId: 71,
|
||||
clientX: 120,
|
||||
clientY: 120,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
|
||||
|
||||
const quickEditDialog = screen.getByRole('dialog', {
|
||||
name: '快速编辑图片',
|
||||
});
|
||||
const selectionToolbar = screen.getByRole('toolbar', {
|
||||
name: '快速编辑框选工具',
|
||||
});
|
||||
const rectTool = within(selectionToolbar).getByRole('button', {
|
||||
name: '矩形框选',
|
||||
});
|
||||
fireEvent.click(rectTool);
|
||||
|
||||
const selectionCanvas = screen.getByRole('application', {
|
||||
name: 'UI素材框选画布',
|
||||
});
|
||||
dispatchPointerEvent(selectionCanvas, 'pointerdown', {
|
||||
pointerId: 72,
|
||||
clientX: 20,
|
||||
clientY: 20,
|
||||
});
|
||||
dispatchPointerEvent(selectionCanvas, 'pointermove', {
|
||||
pointerId: 72,
|
||||
clientX: 90,
|
||||
clientY: 90,
|
||||
});
|
||||
dispatchPointerEvent(selectionCanvas, 'pointerup', {
|
||||
pointerId: 72,
|
||||
clientX: 90,
|
||||
clientY: 90,
|
||||
});
|
||||
|
||||
expect(
|
||||
(
|
||||
within(quickEditDialog).getByLabelText(
|
||||
'快速编辑提示词',
|
||||
) as HTMLTextAreaElement
|
||||
).value,
|
||||
).toContain('对1号红色圈选框里的内容做以下修改');
|
||||
|
||||
dispatchPointerEvent(selectionCanvas, 'pointerdown', {
|
||||
pointerId: 73,
|
||||
clientX: 30,
|
||||
clientY: 30,
|
||||
});
|
||||
dispatchPointerEvent(selectionCanvas, 'pointermove', {
|
||||
pointerId: 73,
|
||||
clientX: 110,
|
||||
clientY: 110,
|
||||
});
|
||||
fireEvent.keyDown(window, {
|
||||
key: 'c',
|
||||
code: 'KeyC',
|
||||
ctrlKey: true,
|
||||
});
|
||||
|
||||
const viewport = screen.getByLabelText('画布工作区');
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', {
|
||||
name: '充值',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '购买更多泥点' }),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' });
|
||||
fireEvent.contextMenu(viewport, { clientX: 320, clientY: 220 });
|
||||
const pasteEvent = new Event('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
act(() => {
|
||||
window.dispatchEvent(pasteEvent);
|
||||
});
|
||||
|
||||
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
|
||||
expect(screen.getAllByAltText('画布图片:拼图素材')).toHaveLength(1);
|
||||
expect(screen.queryByRole('menu', { name: '画布右键菜单' })).toBeNull();
|
||||
const pausedToolbar = screen.getByRole('toolbar', {
|
||||
name: '快速编辑框选工具',
|
||||
});
|
||||
expect(
|
||||
within(pausedToolbar)
|
||||
.getByRole('button', { name: '矩形框选' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(viewport.getAttribute('aria-disabled')).toBe('true');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭购买更多泥点' }));
|
||||
|
||||
const resumedToolbar = await screen.findByRole('toolbar', {
|
||||
name: '快速编辑框选工具',
|
||||
});
|
||||
expect(viewport.getAttribute('aria-disabled')).toBeNull();
|
||||
expect(
|
||||
within(resumedToolbar)
|
||||
.getByRole('button', { name: '矩形框选' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
|
||||
const resumedCanvas = screen.getByRole('application', {
|
||||
name: 'UI素材框选画布',
|
||||
});
|
||||
dispatchPointerEvent(resumedCanvas, 'pointermove', {
|
||||
pointerId: 73,
|
||||
clientX: 110,
|
||||
clientY: 110,
|
||||
});
|
||||
dispatchPointerEvent(resumedCanvas, 'pointerup', {
|
||||
pointerId: 73,
|
||||
clientX: 110,
|
||||
clientY: 110,
|
||||
});
|
||||
|
||||
const resumedPrompt = screen.getByLabelText(
|
||||
'快速编辑提示词',
|
||||
) as HTMLTextAreaElement;
|
||||
expect(resumedPrompt.value).toContain('对1号红色圈选框里的内容做以下修改');
|
||||
expect(resumedPrompt.value).not.toContain('2号红色圈选框');
|
||||
});
|
||||
|
||||
it('opens the login modal immediately when entering the editor while logged out', async () => {
|
||||
@@ -1436,9 +1693,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: '画布 Agent' })).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '打开画布 Agent' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '打开画布 Agent' })).toBeNull();
|
||||
expect(screen.queryByLabelText('发送给画布 Agent')).toBeNull();
|
||||
expect(listEditorAgentConversationsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1457,6 +1712,43 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(screen.queryByLabelText('发送给画布 Agent')).toBeNull();
|
||||
});
|
||||
|
||||
it('closes the Agent conversation before opening quick edit', async () => {
|
||||
enableEditorAgentSidebarForTest();
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
expect(await screen.findByLabelText('发送给画布 Agent')).toBeTruthy();
|
||||
const sourceLayer = (
|
||||
await screen.findByAltText('画布图片:拼图素材')
|
||||
).closest('button')!;
|
||||
fireEvent.click(sourceLayer);
|
||||
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '快速编辑图片' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByLabelText('发送给画布 Agent')).toBeNull();
|
||||
});
|
||||
|
||||
it('closes the Agent conversation panel when runtime config disables it after focus', async () => {
|
||||
loadFrontendRuntimeConfigMock
|
||||
.mockResolvedValueOnce({
|
||||
imageEditorAgentSidebarEnabled: true,
|
||||
})
|
||||
.mockResolvedValue({
|
||||
imageEditorAgentSidebarEnabled: false,
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
expect(await screen.findByLabelText('发送给画布 Agent')).toBeTruthy();
|
||||
|
||||
fireEvent.focus(window);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: '画布 Agent' })).toBeNull();
|
||||
});
|
||||
expect(screen.queryByLabelText('发送给画布 Agent')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the Agent conversation panel open when restored tasks auto-open the task list', async () => {
|
||||
enableEditorAgentSidebarForTest();
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
@@ -1704,9 +1996,11 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
expect(within(settingsPanel).getByText('画布背景')).toBeTruthy();
|
||||
expect(within(settingsPanel).getByLabelText('画布背景色相')).toBeTruthy();
|
||||
expect(within(settingsPanel).getByLabelText('画布背景色盘')).toBeTruthy();
|
||||
expect(
|
||||
within(settingsPanel).getByLabelText('画布背景十六进制颜色'),
|
||||
).toBeTruthy();
|
||||
expect(settingsPanel.querySelector('input[type="color"]')).toBeNull();
|
||||
|
||||
fireEvent.click(
|
||||
within(settingsPanel).getByRole('button', { name: '暖灰' }),
|
||||
@@ -1716,13 +2010,6 @@ describe('ImageCanvasEditorView', () => {
|
||||
'rgb(243, 240, 234)',
|
||||
);
|
||||
|
||||
fireEvent.change(within(settingsPanel).getByLabelText('自定义画布背景色'), {
|
||||
target: { value: '#ffffff' },
|
||||
});
|
||||
expect((viewport as HTMLElement).style.backgroundColor).toBe(
|
||||
'rgb(255, 255, 255)',
|
||||
);
|
||||
|
||||
const hexInput =
|
||||
within(settingsPanel).getByLabelText('画布背景十六进制颜色');
|
||||
fireEvent.change(hexInput, { target: { value: '#abc' } });
|
||||
|
||||
@@ -9,31 +9,32 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import type { EditorAgentGenerationResultEvent } from '../../../packages/shared/src/contracts/editorAgent';
|
||||
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import { loadFrontendRuntimeConfig } from '../../services/frontendRuntimeConfigService';
|
||||
import {
|
||||
createEditorAsset,
|
||||
createEditorProjectResource,
|
||||
type EditorAssetSnapshot,
|
||||
type EditorProjectSnapshot,
|
||||
loadEditorProject,
|
||||
loadEditorGenerationPricing,
|
||||
loadEditorProject,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { loadFrontendRuntimeConfig } from '../../services/frontendRuntimeConfigService';
|
||||
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 { 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,
|
||||
generationInputsOrNull,
|
||||
isInlineEditorMediaSource,
|
||||
resolveContextMenuPosition,
|
||||
@@ -78,11 +79,11 @@ import {
|
||||
} from './useImageCanvasAssetCanvasBridge';
|
||||
import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow';
|
||||
import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary';
|
||||
import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts';
|
||||
import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome';
|
||||
import { useImageCanvasGenerationSurface } from './useImageCanvasGenerationSurface';
|
||||
import { useImageCanvasKeyboardShortcuts } from './useImageCanvasKeyboardShortcuts';
|
||||
import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
|
||||
import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts';
|
||||
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
||||
import { useImageCanvasStageController } from './useImageCanvasStageController';
|
||||
import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions';
|
||||
@@ -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);
|
||||
@@ -308,6 +307,7 @@ export function ImageCanvasEditorView({
|
||||
const layersRef = useRef<CanvasLayer[]>([]);
|
||||
const canvasGenerationDialogsRef = useRef<CanvasGenerationDialogState[]>([]);
|
||||
const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT);
|
||||
const canvasBackgroundColorRef = useRef(DEFAULT_CANVAS_BACKGROUND_COLOR);
|
||||
const captureCanvasHistoryRef = useRef<() => void>(() => {});
|
||||
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
|
||||
const closeGenerationTransientStateRef = useRef<() => void>(() => {});
|
||||
@@ -320,6 +320,7 @@ export function ImageCanvasEditorView({
|
||||
const publicationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const startupIntentConsumedRef = useRef(false);
|
||||
const hasLoadedAgentRuntimeConfigRef = useRef(false);
|
||||
const selectedLayerIdRef = useRef<string | null>(null);
|
||||
const showRechargeEntry = shouldShowRechargeEntry();
|
||||
const currentEditorUserId = authUi?.user?.id ?? null;
|
||||
@@ -347,26 +348,57 @@ export function ImageCanvasEditorView({
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
void loadFrontendRuntimeConfig()
|
||||
.then((config) => {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
const enabled = config.imageEditorAgentSidebarEnabled === true;
|
||||
setIsAgentConversationEnabled(enabled);
|
||||
setIsAgentConversationOpen(enabled);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
setIsAgentConversationEnabled(false);
|
||||
setIsAgentConversationOpen(false);
|
||||
});
|
||||
let requestId = 0;
|
||||
|
||||
const refreshAgentRuntimeConfig = () => {
|
||||
const currentRequestId = requestId + 1;
|
||||
requestId = currentRequestId;
|
||||
void loadFrontendRuntimeConfig()
|
||||
.then((config) => {
|
||||
if (!isMounted || currentRequestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
const enabled = config.imageEditorAgentSidebarEnabled === true;
|
||||
const shouldAutoOpen = !hasLoadedAgentRuntimeConfigRef.current;
|
||||
hasLoadedAgentRuntimeConfigRef.current = true;
|
||||
setIsAgentConversationEnabled(enabled);
|
||||
setIsAgentConversationOpen((currentOpen) => {
|
||||
if (!enabled) {
|
||||
return false;
|
||||
}
|
||||
return shouldAutoOpen ? true : currentOpen;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isMounted || currentRequestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
hasLoadedAgentRuntimeConfigRef.current = true;
|
||||
setIsAgentConversationEnabled(false);
|
||||
setIsAgentConversationOpen(false);
|
||||
});
|
||||
};
|
||||
|
||||
refreshAgentRuntimeConfig();
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
refreshAgentRuntimeConfig();
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
refreshAgentRuntimeConfig();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleWindowFocus);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
window.removeEventListener('focus', handleWindowFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
}, [currentEditorUserId]);
|
||||
const selectedLayerIdsRef = useRef<string[]>([]);
|
||||
const setQuickEditPanelRef = useRef<
|
||||
Dispatch<SetStateAction<QuickEditPanelState | null>>
|
||||
@@ -445,36 +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',
|
||||
@@ -484,6 +520,13 @@ export function ImageCanvasEditorView({
|
||||
requestLogin: () => authUiRef.current?.openLoginModal(),
|
||||
currentUser: authUi?.user ?? null,
|
||||
});
|
||||
const isAccountPaymentModalOpen =
|
||||
isRewardCodeOpen ||
|
||||
isRechargeOpen ||
|
||||
isWalletLedgerOpen ||
|
||||
Boolean(nativeWechatPayment) ||
|
||||
Boolean(rechargePaymentResult) ||
|
||||
Boolean(wechatRechargeOrderConfirmationState);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authUi || authUi.user || authUi.canAccessProtectedData) {
|
||||
@@ -500,7 +543,7 @@ export function ImageCanvasEditorView({
|
||||
}, [authUi]);
|
||||
useEffect(() => {
|
||||
if (!authUi?.canAccessProtectedData || !authUi.user?.id) {
|
||||
setWalletBalanceLabel(null);
|
||||
setWalletBalance(null);
|
||||
setIsWalletBalanceLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -522,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) {
|
||||
@@ -590,6 +631,7 @@ export function ImageCanvasEditorView({
|
||||
toggleBackgroundSettings,
|
||||
toggleMinimap,
|
||||
} = useImageCanvasEditorChrome({ openEditorLoginModal });
|
||||
canvasBackgroundColorRef.current = canvasBackgroundColor;
|
||||
const removeCanvasLayersLinkedToAssets = useImageCanvasAssetLayerCleanup({
|
||||
layers,
|
||||
setLayers,
|
||||
@@ -998,6 +1040,7 @@ export function ImageCanvasEditorView({
|
||||
layersRef,
|
||||
viewportRef,
|
||||
canvasGenerationDialogsRef,
|
||||
canvasBackgroundColorRef,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -1012,8 +1055,10 @@ export function ImageCanvasEditorView({
|
||||
layerCounterRef.current = value;
|
||||
},
|
||||
restoreCanvasGenerationDialogs,
|
||||
applyCanvasBackgroundColor,
|
||||
}),
|
||||
[
|
||||
applyCanvasBackgroundColor,
|
||||
restoreCanvasGenerationDialogs,
|
||||
selectSingleLayer,
|
||||
setLayers,
|
||||
@@ -1033,6 +1078,7 @@ export function ImageCanvasEditorView({
|
||||
layers,
|
||||
canvasGenerationDialogs,
|
||||
viewport,
|
||||
canvasBackgroundColor,
|
||||
isViewportInteracting,
|
||||
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
|
||||
currentUserId: currentEditorUserId,
|
||||
@@ -1052,18 +1098,6 @@ export function ImageCanvasEditorView({
|
||||
},
|
||||
[applyProjectSnapshot, refreshAssetLibrary],
|
||||
);
|
||||
const handleExternalGenerationTasksCompleted = useCallback(
|
||||
(tasks: ExternalGenerationTaskRecord[]) => {
|
||||
if (!projectId || tasks.length === 0) {
|
||||
return;
|
||||
}
|
||||
refreshEditorWalletBalance();
|
||||
void loadEditorProject(projectId)
|
||||
.then(applyGeneratedProjectSnapshot)
|
||||
.catch(() => undefined);
|
||||
},
|
||||
[applyGeneratedProjectSnapshot, projectId, refreshEditorWalletBalance],
|
||||
);
|
||||
const handleEditorAgentGenerationResult = useCallback(
|
||||
(event: EditorAgentGenerationResultEvent) => {
|
||||
const hasGeneratedResource = event.images.some((image) =>
|
||||
@@ -1209,6 +1243,30 @@ export function ImageCanvasEditorView({
|
||||
applyProjectSnapshot: applyGeneratedProjectSnapshot,
|
||||
onWalletBalanceMayHaveChanged: refreshEditorWalletBalance,
|
||||
});
|
||||
const showGenerationWarning = generationSurface.showGenerationWarning;
|
||||
const handleExternalGenerationTasksCompleted = useCallback(
|
||||
(tasks: ExternalGenerationTaskRecord[]) => {
|
||||
if (!projectId || tasks.length === 0) {
|
||||
return;
|
||||
}
|
||||
const warning = tasks.find((task) => task.warning?.trim())?.warning?.trim();
|
||||
if (warning) {
|
||||
showGenerationWarning(
|
||||
`图集已生成,但自动拆分未完成:${warning}`,
|
||||
);
|
||||
}
|
||||
refreshEditorWalletBalance();
|
||||
void loadEditorProject(projectId)
|
||||
.then(applyGeneratedProjectSnapshot)
|
||||
.catch(() => undefined);
|
||||
},
|
||||
[
|
||||
applyGeneratedProjectSnapshot,
|
||||
projectId,
|
||||
refreshEditorWalletBalance,
|
||||
showGenerationWarning,
|
||||
],
|
||||
);
|
||||
const effectiveIsAgentConversationOpen =
|
||||
isAgentConversationEnabled && isAgentConversationOpen;
|
||||
const toggleAgentConversation = useCallback(() => {
|
||||
@@ -1230,6 +1288,16 @@ export function ImageCanvasEditorView({
|
||||
}
|
||||
generationSurface.toggleTaskSidebar();
|
||||
}, [effectiveIsAgentConversationOpen, generationSurface]);
|
||||
const openQuickEditPanelWithAvailableCanvas = useCallback(
|
||||
(layer: CanvasLayer) => {
|
||||
if (generationSurface.isTaskSidebarOpen) {
|
||||
generationSurface.toggleTaskSidebar();
|
||||
}
|
||||
setIsAgentConversationOpen(false);
|
||||
generationSurface.openQuickEditPanel(layer);
|
||||
},
|
||||
[generationSurface],
|
||||
);
|
||||
const toggleCanvasSidebarPanel = useCallback(
|
||||
(panel: SidebarPanel) => {
|
||||
toggleSidebarPanel(panel);
|
||||
@@ -1281,9 +1349,9 @@ export function ImageCanvasEditorView({
|
||||
setIsPickingUiDesignSpecFromCanvas,
|
||||
openCharacterAnimationPanel,
|
||||
openRedrawPanel,
|
||||
openQuickEditPanel,
|
||||
openCropExpandPanel,
|
||||
removeSelectedLayerBackground,
|
||||
splitSelectedIconSpritesheet,
|
||||
extractUiDesignAssets,
|
||||
pickCharacterSpecFromLayer,
|
||||
pickGenerationReferenceFromLayer,
|
||||
@@ -1307,6 +1375,8 @@ export function ImageCanvasEditorView({
|
||||
moveQuickEditSelectionPointer,
|
||||
endUiAssetExtractionPointer,
|
||||
endQuickEditSelectionPointer,
|
||||
cancelUiAssetExtractionPointer,
|
||||
cancelQuickEditSelectionPointer,
|
||||
cancelUiAssetExtraction,
|
||||
appendUiAssetExtractionReferences,
|
||||
removeUiAssetExtractionReference,
|
||||
@@ -1541,6 +1611,30 @@ export function ImageCanvasEditorView({
|
||||
onCloseImageContextMenu: () => setImageContextMenu(null),
|
||||
});
|
||||
resetCanvasInteractionStateRef.current = clearActiveInteraction;
|
||||
useEffect(() => {
|
||||
if (!isAccountPaymentModalOpen) {
|
||||
return;
|
||||
}
|
||||
clearActiveInteraction();
|
||||
cancelUiAssetExtractionPointer();
|
||||
cancelQuickEditSelectionPointer();
|
||||
}, [
|
||||
cancelQuickEditSelectionPointer,
|
||||
cancelUiAssetExtractionPointer,
|
||||
clearActiveInteraction,
|
||||
isAccountPaymentModalOpen,
|
||||
]);
|
||||
const openAccountPaymentModal = useCallback(() => {
|
||||
clearActiveInteraction();
|
||||
cancelUiAssetExtractionPointer();
|
||||
cancelQuickEditSelectionPointer();
|
||||
openRechargeOrRewardCodeModal();
|
||||
}, [
|
||||
cancelQuickEditSelectionPointer,
|
||||
cancelUiAssetExtractionPointer,
|
||||
clearActiveInteraction,
|
||||
openRechargeOrRewardCodeModal,
|
||||
]);
|
||||
const handleCanvasPointerDownWithUiExtractionDismiss = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (uiAssetExtractionState) {
|
||||
@@ -1737,6 +1831,7 @@ export function ImageCanvasEditorView({
|
||||
);
|
||||
|
||||
useImageCanvasKeyboardShortcuts({
|
||||
isInteractionPaused: isAccountPaymentModalOpen,
|
||||
generateDialogRef,
|
||||
selectedLayerIdRef,
|
||||
selectedLayerIdsRef,
|
||||
@@ -1808,6 +1903,9 @@ export function ImageCanvasEditorView({
|
||||
if (isEditablePasteTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
if (isAccountPaymentModalOpen) {
|
||||
return;
|
||||
}
|
||||
const didPasteCanvasClipboard = pasteCanvasClipboard();
|
||||
if (didPasteCanvasClipboard) {
|
||||
event.preventDefault();
|
||||
@@ -1825,7 +1923,7 @@ export function ImageCanvasEditorView({
|
||||
return () => {
|
||||
window.removeEventListener('paste', handleClipboardPaste);
|
||||
};
|
||||
}, [addUploadedFiles, pasteCanvasClipboard]);
|
||||
}, [addUploadedFiles, isAccountPaymentModalOpen, pasteCanvasClipboard]);
|
||||
|
||||
useEffect(() => {
|
||||
const blockBrowserZoom = (event: WheelEvent) => {
|
||||
@@ -1953,8 +2051,11 @@ export function ImageCanvasEditorView({
|
||||
isProjectRenameSaving,
|
||||
projectRenameError,
|
||||
layers,
|
||||
walletBalanceLabel,
|
||||
walletBalance,
|
||||
walletBreakdown: rechargeCenter?.mudPointBalance ?? null,
|
||||
isWalletBalanceLoading,
|
||||
isWalletDetailsLoading: isLoadingRechargeCenter,
|
||||
walletDetailsError: rechargeError,
|
||||
currentUser: authUi?.user,
|
||||
assetExportStatus,
|
||||
isExportingAssets,
|
||||
@@ -1965,7 +2066,9 @@ export function ImageCanvasEditorView({
|
||||
resetProjectRenameError,
|
||||
exportCanvasAssets,
|
||||
onOpenShortcuts: () => setIsShortcutDialogOpen(true),
|
||||
onOpenWallet: openRechargeOrRewardCodeModal,
|
||||
onRequestWalletDetails: loadRechargeCenter,
|
||||
onRecharge: openAccountPaymentModal,
|
||||
onOpenWalletLedger: openWalletLedgerPanel,
|
||||
onOpenAccount: () => {
|
||||
if (authUi?.user) {
|
||||
authUi.openAccountModal();
|
||||
@@ -1979,6 +2082,7 @@ export function ImageCanvasEditorView({
|
||||
specToolWrapRef,
|
||||
musicToolWrapRef,
|
||||
publicationToolWrapRef,
|
||||
isInteractionPaused: isAccountPaymentModalOpen,
|
||||
isPanning,
|
||||
effectiveTool,
|
||||
canvasBackgroundColor,
|
||||
@@ -2000,8 +2104,12 @@ export function ImageCanvasEditorView({
|
||||
generateDialog,
|
||||
cropExpandPanel: generationSurface.cropExpandPanel,
|
||||
cropExpandSourceLayer: generationSurface.cropExpandSourceLayer,
|
||||
uiAssetExtractionState,
|
||||
uiAssetExtractionSourceLayer,
|
||||
uiAssetExtractionState: isAccountPaymentModalOpen
|
||||
? null
|
||||
: uiAssetExtractionState,
|
||||
uiAssetExtractionSourceLayer: isAccountPaymentModalOpen
|
||||
? null
|
||||
: uiAssetExtractionSourceLayer,
|
||||
quickEditSelectionState,
|
||||
quickEditSelectionSourceLayer,
|
||||
generationComposerStyle,
|
||||
@@ -2050,10 +2158,11 @@ export function ImageCanvasEditorView({
|
||||
onToggleTaskSidebar: toggleTaskSidebar,
|
||||
onToggleAgentConversation: toggleAgentConversation,
|
||||
onCropExpandHandlePointerDown: generationSurface.startCropExpandFrameResize,
|
||||
onOpenQuickEditPanel: openQuickEditPanel,
|
||||
onOpenQuickEditPanel: openQuickEditPanelWithAvailableCanvas,
|
||||
onOpenRedrawPanel: openRedrawPanel,
|
||||
onOpenCropExpandPanel: openCropExpandPanel,
|
||||
onRemoveBackground: removeSelectedLayerBackground,
|
||||
onSplitIconSpritesheet: splitSelectedIconSpritesheet,
|
||||
onExtractUiDesignAssets: extractUiDesignAssets,
|
||||
onUiAssetExtractionToolChange: changeUiAssetExtractionTool,
|
||||
onUiAssetExtractionModelChange: changeUiAssetExtractionModel,
|
||||
@@ -2150,12 +2259,21 @@ export function ImageCanvasEditorView({
|
||||
error={rechargeError}
|
||||
submittingProductId={submittingRechargeProductId}
|
||||
nativePayment={nativeWechatPayment}
|
||||
activeTab={activeRechargeTab}
|
||||
onTabChange={setActiveRechargeTab}
|
||||
onClose={() => setIsRechargeOpen(false)}
|
||||
onRetry={loadRechargeCenter}
|
||||
onBuy={buyRechargeProduct}
|
||||
onConfirmNativePayment={confirmNativeWechatPayment}
|
||||
onCloseNativePayment={closeNativeWechatPayment}
|
||||
/>
|
||||
) : null}
|
||||
{isWalletLedgerOpen ? (
|
||||
<PlatformProfileWalletLedgerModal
|
||||
ledger={walletLedger}
|
||||
fallbackBalance={walletBalance ?? 0}
|
||||
isLoading={isLoadingWalletLedger}
|
||||
error={walletLedgerError}
|
||||
onClose={() => setIsWalletLedgerOpen(false)}
|
||||
onRetry={loadWalletLedger}
|
||||
/>
|
||||
) : null}
|
||||
{rechargePaymentResult ? (
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
IMAGE_CANVAS_GENERATION_ASSET_NAME_MAX_LENGTH,
|
||||
ImageCanvasGenerationAssetNameField,
|
||||
} from './ImageCanvasGenerationAssetNameField';
|
||||
|
||||
describe('ImageCanvasGenerationAssetNameField', () => {
|
||||
it('renders as a controlled optional name input', () => {
|
||||
const onChange = vi.fn();
|
||||
const { rerender } = render(
|
||||
<ImageCanvasGenerationAssetNameField
|
||||
value="角色立绘"
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox', {
|
||||
name: '资源名称',
|
||||
}) as HTMLInputElement;
|
||||
expect(input.value).toBe('角色立绘');
|
||||
expect(input.getAttribute('maxlength')).toBe(
|
||||
String(IMAGE_CANVAS_GENERATION_ASSET_NAME_MAX_LENGTH),
|
||||
);
|
||||
|
||||
fireEvent.change(input, { target: { value: '新名称' } });
|
||||
expect(onChange).toHaveBeenCalledWith('新名称');
|
||||
expect(input.value).toBe('角色立绘');
|
||||
|
||||
rerender(
|
||||
<ImageCanvasGenerationAssetNameField
|
||||
value="新名称"
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
expect(input.value).toBe('新名称');
|
||||
});
|
||||
|
||||
it('limits callback values to 80 characters and supports disabled state', () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<ImageCanvasGenerationAssetNameField
|
||||
value=""
|
||||
disabled
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox', {
|
||||
name: '资源名称',
|
||||
}) as HTMLInputElement;
|
||||
expect(input.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(input, { target: { value: '名'.repeat(90) } });
|
||||
expect(onChange).toHaveBeenCalledWith('名'.repeat(80));
|
||||
|
||||
fireEvent.change(input, { target: { value: '🎨'.repeat(90) } });
|
||||
expect(onChange).toHaveBeenLastCalledWith('🎨'.repeat(80));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import { EDITOR_GENERATED_ASSET_LABEL_MAX_CHARS } from './ImageCanvasGenerationSubmissionModel';
|
||||
|
||||
export const IMAGE_CANVAS_GENERATION_ASSET_NAME_MAX_LENGTH =
|
||||
EDITOR_GENERATED_ASSET_LABEL_MAX_CHARS;
|
||||
|
||||
type ImageCanvasGenerationAssetNameFieldProps = {
|
||||
value?: string | null;
|
||||
disabled?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function ImageCanvasGenerationAssetNameField({
|
||||
value,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: ImageCanvasGenerationAssetNameFieldProps) {
|
||||
return (
|
||||
<label className="image-canvas-editor__generation-asset-name-field">
|
||||
<PlatformFieldLabel
|
||||
variant="form"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
资源名称
|
||||
</PlatformFieldLabel>
|
||||
<PlatformTextField
|
||||
aria-label="资源名称"
|
||||
value={value ?? ''}
|
||||
maxLength={IMAGE_CANVAS_GENERATION_ASSET_NAME_MAX_LENGTH}
|
||||
disabled={disabled}
|
||||
placeholder="例如:勇者立绘"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-asset-name-input"
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
Array.from(event.target.value)
|
||||
.slice(0, IMAGE_CANVAS_GENERATION_ASSET_NAME_MAX_LENGTH)
|
||||
.join(''),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user