合并最新master并融合图片生成链路
保留BgFilter complex、cross-check、单次重试、全帧并发排空与任务阶段上报 采用master的生成产物一次上传、真实素材类型及成本和供应商归因 同步后台多账号、Dashboard、SpacetimeDB schema、迁移与生成绑定 融合项目决策记录和相关前后端文档
This commit is contained in:
@@ -1,120 +0,0 @@
|
||||
/* @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();
|
||||
});
|
||||
@@ -1,269 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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');
|
||||
}
|
||||
@@ -58,7 +58,6 @@ 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>
|
||||
@@ -87,25 +86,21 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
|
||||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||||
target: { value: '新的提示' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('资源名称'), {
|
||||
target: { value: '自定义主视觉' },
|
||||
});
|
||||
const panel = screen.getByRole('dialog', { name: '生成图片' });
|
||||
expect(
|
||||
within(panel).queryByRole('textbox', { name: '资源名称' }),
|
||||
).toBeNull();
|
||||
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: '新的提示',
|
||||
assetLabel: '自定义主视觉',
|
||||
status: 'idle',
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
GenerateDialogState,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
|
||||
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
||||
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
|
||||
import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel';
|
||||
@@ -268,20 +267,6 @@ 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,7 +24,6 @@ import type {
|
||||
CanvasLayer,
|
||||
CharacterAnimationPanelState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
|
||||
import {
|
||||
CHARACTER_ANIMATION_ACTION_PROMPTS,
|
||||
CHARACTER_ANIMATION_DURATION_OPTIONS,
|
||||
@@ -207,16 +206,11 @@ export function ImageCanvasCharacterAnimationPanelView({
|
||||
placeholder="你希望角色做什么动作?"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt image-canvas-editor__generation-prompt--borderless image-canvas-editor__character-animation-textarea"
|
||||
className="image-canvas-editor__generation-prompt image-canvas-editor__character-animation-textarea"
|
||||
onChange={(event) =>
|
||||
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
|
||||
|
||||
@@ -93,7 +93,7 @@ function CharacterGenerationHarness({
|
||||
}
|
||||
|
||||
describe('ImageCanvasCharacterGenerationComposerView', () => {
|
||||
it('keeps the prompt as a borderless single text input with a question placeholder', () => {
|
||||
it('keeps the prompt as a bordered single text input with a question placeholder', () => {
|
||||
render(<CharacterGenerationHarness />);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成角色形象' });
|
||||
@@ -105,7 +105,8 @@ describe('ImageCanvasCharacterGenerationComposerView', () => {
|
||||
.join(''),
|
||||
).not.toContain('角色设定');
|
||||
expect(prompt.getAttribute('placeholder')).toBe('你希望角色如何设计?');
|
||||
expect(prompt.className).toContain(
|
||||
expect(prompt.className).toContain('image-canvas-editor__generation-prompt');
|
||||
expect(prompt.className).not.toContain(
|
||||
'image-canvas-editor__generation-prompt--borderless',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ import type {
|
||||
SpecGenerationType,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
|
||||
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
||||
import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel';
|
||||
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
|
||||
@@ -263,7 +262,7 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
placeholder="你希望角色如何设计?"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt image-canvas-editor__generation-prompt--borderless"
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
onChange={(event) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog?.mode === 'character'
|
||||
@@ -276,20 +275,6 @@ 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"
|
||||
|
||||
@@ -266,21 +266,28 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
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,
|
||||
});
|
||||
it('hides quick edit from the standalone menu for individual icons', () => {
|
||||
const layer = createLayer({ assetKind: 'icon' });
|
||||
renderContextMenus({
|
||||
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
|
||||
imageContextMenuLayer: layer,
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('menuitem', { name: '快速编辑' })).toBeNull();
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: '查看图片信息' }),
|
||||
).toBeTruthy();
|
||||
},
|
||||
);
|
||||
expect(screen.queryByRole('menuitem', { name: '快速编辑' })).toBeNull();
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: '查看图片信息' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps quick edit in the standalone menu for icon spritesheets', () => {
|
||||
const layer = createLayer({ assetKind: 'icon-spritesheet' });
|
||||
renderContextMenus({
|
||||
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
|
||||
imageContextMenuLayer: layer,
|
||||
});
|
||||
|
||||
expect(screen.getByRole('menuitem', { name: '快速编辑' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps quick edit in the standalone menu for icon specs', () => {
|
||||
const layer = createLayer({ assetKind: 'icon-spec' });
|
||||
|
||||
@@ -2019,12 +2019,19 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
{ target: { value: '返回按钮\n设置按钮\n提示按钮' } },
|
||||
);
|
||||
|
||||
const iconPrompt = within(iconPanel).getByRole('textbox', {
|
||||
name: '素材描述',
|
||||
});
|
||||
expect(iconPrompt.tagName).toBe('TEXTAREA');
|
||||
expect(iconPrompt.className).toContain(
|
||||
'image-canvas-editor__generation-prompt',
|
||||
);
|
||||
expect(iconPrompt.className).not.toContain(
|
||||
'image-canvas-editor__generation-prompt--borderless',
|
||||
);
|
||||
expect(
|
||||
within(iconPanel).getByRole('textbox', { name: '素材描述' }).tagName,
|
||||
).toBe('TEXTAREA');
|
||||
expect(
|
||||
within(iconPanel).getByRole('textbox', { name: '资源名称' }),
|
||||
).toBeTruthy();
|
||||
within(iconPanel).queryByRole('textbox', { name: '资源名称' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(iconPanel).queryByRole('button', { name: '添加素材描述' }),
|
||||
).toBeNull();
|
||||
|
||||
@@ -1146,7 +1146,6 @@ 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' ||
|
||||
|
||||
@@ -20,7 +20,6 @@ export type CanvasAssetKind =
|
||||
| 'icon'
|
||||
| 'icon-spritesheet'
|
||||
| 'icon-spec'
|
||||
| 'editor_green_screen_source'
|
||||
| 'publication-material'
|
||||
| 'ui-design'
|
||||
| 'video'
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
// @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));
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,6 @@ import {
|
||||
resizeGenerationPlaceholderToVideoSelection,
|
||||
SPEC_TYPE_LABEL,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
|
||||
import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView';
|
||||
import { ImageCanvasPublicationMaterialsDemoPanelView } from './ImageCanvasPublicationMaterialsDemoPanelView';
|
||||
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
|
||||
@@ -402,11 +401,6 @@ function ImageCanvasVideoGenerationComposerView({
|
||||
updateVideoDialog({ prompt: event.target.value })
|
||||
}
|
||||
/>
|
||||
<ImageCanvasGenerationAssetNameField
|
||||
value={dialog.assetLabel}
|
||||
disabled={isGenerating}
|
||||
onChange={(assetLabel) => updateVideoDialog({ assetLabel })}
|
||||
/>
|
||||
<div className="image-canvas-editor__generation-composer-footer">
|
||||
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
|
||||
<PlatformInlineOptionButton
|
||||
@@ -793,11 +787,6 @@ function ImageCanvasAudioGenerationComposerView({
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
onChange={(event) => updateAudioDialog({ prompt: event.target.value })}
|
||||
/>
|
||||
<ImageCanvasGenerationAssetNameField
|
||||
value={dialog.assetLabel}
|
||||
disabled={isGenerating}
|
||||
onChange={(assetLabel) => updateAudioDialog({ assetLabel })}
|
||||
/>
|
||||
{dialog.status === 'failed' ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
getGenerationFrameAriaLabel,
|
||||
getGenerationFrameLabel,
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
isQuickEditUnsupportedAssetKind,
|
||||
resolveCharacterAnimationSourceImageSrc,
|
||||
resolveImageGenerationErrorMessage,
|
||||
VIDEO_MODEL_KLING_3,
|
||||
@@ -392,6 +393,18 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
},
|
||||
{ label: 'gpt-image-2', value: 'gpt-image-2' },
|
||||
]);
|
||||
expect(
|
||||
isQuickEditUnsupportedAssetKind({
|
||||
...buildSourceLayer(),
|
||||
assetKind: 'icon',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isQuickEditUnsupportedAssetKind({
|
||||
...buildSourceLayer(),
|
||||
assetKind: 'icon-spritesheet',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('adds reference image semantics and snapshots for spec generation references', () => {
|
||||
|
||||
@@ -556,7 +556,7 @@ export function buildQuickEditModelOptions(currentModel: string) {
|
||||
}
|
||||
|
||||
export function isQuickEditUnsupportedAssetKind(layer: CanvasLayer) {
|
||||
return layer.assetKind === 'icon' || layer.assetKind === 'icon-spritesheet';
|
||||
return layer.assetKind === 'icon';
|
||||
}
|
||||
|
||||
export function buildCharacterSpecPrompt(values: SpecFormValues) {
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
SpecGenerationType,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
|
||||
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
||||
import {
|
||||
calculateEditorIconSpritesheetPrice,
|
||||
@@ -220,32 +219,10 @@ export function ImageCanvasIconSpritesheetComposerView({
|
||||
placeholder="你需要哪些图标?"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt image-canvas-editor__generation-prompt--borderless"
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
onChange={(event) => onUpdateIconDescriptionText(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<ImageCanvasGenerationAssetNameField
|
||||
value={dialog.assetLabel}
|
||||
disabled={dialog.status === 'generating'}
|
||||
onChange={(assetLabel) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog?.mode === 'icon'
|
||||
? {
|
||||
...currentDialog,
|
||||
status:
|
||||
currentDialog.status === 'failed'
|
||||
? 'idle'
|
||||
: currentDialog.status,
|
||||
errorMessage:
|
||||
currentDialog.status === 'failed'
|
||||
? undefined
|
||||
: currentDialog.errorMessage,
|
||||
assetLabel,
|
||||
}
|
||||
: currentDialog,
|
||||
)
|
||||
}
|
||||
/>
|
||||
{dialog.status === 'failed' ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
|
||||
@@ -21,7 +21,6 @@ import type {
|
||||
PublicationMaterialsGameInfo,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
|
||||
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
||||
import {
|
||||
buildPublicationMaterialsPrompt,
|
||||
@@ -287,21 +286,6 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ImageCanvasGenerationAssetNameField
|
||||
value={dialog.assetLabel}
|
||||
disabled={dialog.status === 'generating'}
|
||||
onChange={(assetLabel) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog?.mode === 'publication'
|
||||
? {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
assetLabel,
|
||||
}
|
||||
: currentDialog,
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{dialog.status === 'failed' ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
|
||||
@@ -137,6 +137,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
const buttons = within(toolbar).getAllByRole('button');
|
||||
|
||||
expect(buttons.map((button) => button.getAttribute('aria-label'))).toEqual([
|
||||
'快速编辑',
|
||||
'裁扩按钮',
|
||||
'去除背景按钮',
|
||||
'拆分图集',
|
||||
@@ -258,16 +259,22 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
expect(screen.queryByRole('button', { name: '生成动画' })).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['icon', 'icon-spritesheet'] as const)(
|
||||
'hides quick edit for %s assets',
|
||||
(assetKind) => {
|
||||
renderSelectedToolbar({
|
||||
selectedLayer: createLayer({ assetKind }),
|
||||
});
|
||||
it('hides quick edit only for individual icon assets', () => {
|
||||
renderSelectedToolbar({
|
||||
selectedLayer: createLayer({ assetKind: 'icon' }),
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: '快速编辑' })).toBeNull();
|
||||
},
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: '快速编辑' })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps quick edit and manual split available for icon spritesheets', () => {
|
||||
renderSelectedToolbar({
|
||||
selectedLayer: createLayer({ assetKind: 'icon-spritesheet' }),
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: '快速编辑' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '拆分图集' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps quick edit available for icon specs', () => {
|
||||
renderSelectedToolbar({
|
||||
|
||||
@@ -138,7 +138,8 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
|
||||
).toBe(true);
|
||||
expect(panel.textContent).not.toContain('UI设计要求');
|
||||
expect(prompt.getAttribute('placeholder')).toBe('你希望这个 UI 长什么样?');
|
||||
expect(prompt.className).toContain(
|
||||
expect(prompt.className).toContain('image-canvas-editor__generation-prompt');
|
||||
expect(prompt.className).not.toContain(
|
||||
'image-canvas-editor__generation-prompt--borderless',
|
||||
);
|
||||
const submitButton = screen.getByRole('button', { name: '生成UI设计图' });
|
||||
|
||||
@@ -25,7 +25,6 @@ import type {
|
||||
SpecGenerationType,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField';
|
||||
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
||||
import {
|
||||
calculateEditorSpecGenerationPrice,
|
||||
@@ -258,7 +257,7 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
placeholder="你希望这个 UI 长什么样?"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt image-canvas-editor__generation-prompt--borderless"
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
onChange={(event) => {
|
||||
const nextPrompt = event.target.value;
|
||||
if (setGenerateDialog) {
|
||||
@@ -416,30 +415,6 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ImageCanvasGenerationAssetNameField
|
||||
value={dialog.assetLabel}
|
||||
disabled={isGenerating}
|
||||
onChange={(assetLabel) =>
|
||||
setGenerateDialog?.((currentDialog) =>
|
||||
currentDialog &&
|
||||
(currentDialog.mode === 'spec' ||
|
||||
currentDialog.mode === 'ui-design')
|
||||
? {
|
||||
...currentDialog,
|
||||
status:
|
||||
currentDialog.status === 'failed'
|
||||
? 'idle'
|
||||
: currentDialog.status,
|
||||
errorMessage:
|
||||
currentDialog.status === 'failed'
|
||||
? undefined
|
||||
: currentDialog.errorMessage,
|
||||
assetLabel,
|
||||
}
|
||||
: currentDialog,
|
||||
)
|
||||
}
|
||||
/>
|
||||
{dialog.status === 'failed' ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
|
||||
@@ -108,9 +108,6 @@ describe('ImageCanvasTopbarView', () => {
|
||||
});
|
||||
|
||||
expect(walletChip.textContent).toBe('泥点 1.2万');
|
||||
expect(walletChip.querySelector('img')?.getAttribute('src')).toBe(
|
||||
'/creation-home/topbar-wallet.png',
|
||||
);
|
||||
|
||||
await user.hover(walletChip);
|
||||
const details = screen.getByRole('dialog', { name: '泥点账户详情' });
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { PlatformMudPointWalletEntry } from '@/packages/shared/src/components/PlatformMudPointWalletEntry.tsx';
|
||||
|
||||
import type { ProfileMudPointBalance } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { PlatformMudPointWalletEntry } from '../common/PlatformMudPointWalletEntry';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
|
||||
@@ -285,7 +285,7 @@ describe('ImageCanvasWorldView', () => {
|
||||
id: 'layer-source',
|
||||
resourceId: 'resource-source',
|
||||
title: '角色原图',
|
||||
assetKind: 'editor_green_screen_source',
|
||||
assetKind: 'character',
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -298,7 +298,7 @@ describe('ImageCanvasWorldView', () => {
|
||||
|
||||
expect(within(layerButton).getByText('未知')).toBeTruthy();
|
||||
expect(within(characterButton).getByText('角色')).toBeTruthy();
|
||||
expect(within(sourceButton).getByText('原图')).toBeTruthy();
|
||||
expect(within(sourceButton).getByText('角色')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders snap guides, marquee and floating generation status', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user